前端面试必问:shim与polyfill的区别与实战指南
前端面试时有个高频送命题shim 和 polyfill 到底有什么区别我见过不少写了三五年业务代码的同学一到这个问题就开始绕圈——“它俩好像差不多吧”“反正都是做兼容的”然后面试官微微一笑这场基本就凉了。其实这两个概念确实长得像都属于“填补能力缺口”这件事儿但一个是泛称一个是特指细节差得还挺远。把这层关系捋清楚不光面试能答得漂亮你日常查文档、读源码、做兼容方案的时候也会顺手很多。这篇文章我从概念源头、历史背景、手写实现、工程化配置一直讲到避坑一次性把来龙去脉讲透适合正在准备前端面试、或者第一次接手浏览器兼容方案的同学参考。1. 先搞清楚shim 和 polyfill 各自到底是什么1.1 一句话版本先建立印象shim 是个泛称英文原意是“垫片、楔子”凡是能“填补环境能力差异”的代码都能叫 shim。比如浏览器不支持某种能力你写了一段代码模拟出这个能力这就是一种 shim。而 polyfill 是 shim 里非常特殊的一小类特指“用 JavaScript 实现当前浏览器还不支持的标准 API让旧浏览器能跑新标准代码”的代码。换句话说所有 polyfill 都是 shim但并不是所有 shim 都是 polyfill。这个区分不是文字游戏。polyfill 的目标非常明确把“未来浏览器原生支持”的 API按照标准定义的行为在旧环境里实现一遍。将来浏览器升级了原生支持了你这段 polyfill 可以干干净净地删掉业务代码一个字都不用改。而 shim 没有这么严格的约束它可能只是把某个库的接口翻译成另一种形式或者做一层统一封装并不需要“将来可移除”。1.2 溯源一下这两个词怎么来的polyfill 这个词是 2009 年前后端工程师 Remy Sharp 造出来的他把“poly”很多、多种和“fill”填充组合到一起本意是“把缺失的能力填充上”。后来 Paul Irish 写了一篇非常有名的文章介绍 polyfill 的写法和推广思路这个词才在前端圈彻底传开。有意思的是Remy 当时并不是搞一个多宏大的理论就是发现越来越多新 API 在老浏览器上跑不了而他又不想等浏览器升级索性自己写一套兼容实现推给社区用。shim 这个词的历史就长多了计算机领域一直有 compatibility shim 的说法操作系统、驱动、虚拟化层面都在用意思是“在两层东西之间垫一片东西让接口能对上”。前端只是把这个老概念拿过来用而已。理解了这层背景你就知道为什么面试官执着于这个词的区别他考的不是你是否背下了定义而是你有没有认真琢磨过你每天写的代码背后在解决什么问题。1.3 用生活类比把概念钉死我平时给团队新人讲的时候喜欢用“转换插头”和“原厂零件”来类比。shim 像是一个万能转换插头你的手机是 Type-C 口酒店的插座是两孔的你需要一个转换头插上去才能充电。转换头不用考虑未来酒店会不会升级成 Type-C 插座只负责当下“能充上电”。polyfill 更像是一个“按照原厂标准图纸定制出来的兼容零件”比如某款发动机有个新型号的传感器你手里是旧款发动机没有这个接口于是你照着原厂的接口规范做了一个替换件装上。等以后整车换了新款、原厂自带这个传感器了你手工做的替换件就可以直接拆除接口完全兼容。这个类比能直接解释很多疑惑为什么有些 polyfill 需要严格遵循标准因为它就是要替代“原厂零件”行为必须和原生实现保持一致。为什么有些 shim 可以很粗糙因为它只解决眼前接口对不上的问题不需要考虑标准行为。1.4 从代码层面看边界举个最典型的例子Object.create 在 IE8 里不存在。当年很多类继承代码用到一个经典的 shimif (typeof Object.create ! function) { Object.create (function() { function F() {} return function(proto, propertiesObject) { if (typeof proto ! object typeof proto ! function) { throw new TypeError(Object prototype may only be an Object or null); } F.prototype proto; return new F(); }; })(); }这段代码怎么说它实现了 ES5 标准里的 Object.create符合标准定义的行为目标是等旧浏览器“将来原生支持”后可以移除所以它不仅仅是一般 shim而是 polyfill。但如果你只是给某个内部库写一个createObj辅助函数把各种创建对象的方式统一在一起那就只是普通的 shim因为你自己定义接口不是照着 ECMAScript 标准实现。现在概念清楚了下一步要解释的是为什么前端会有这么大一块“填补能力”的需求这得从浏览器分裂的历史说起。2. 为什么需要它们前端兼容性的历史遗留账2.1 浏览器分裂年代的技术债先回到 2015 年以前那是前端开发者最头疼的年代。IE6/IE7/IE8 市场份额还很高Chrome 一年蹦好几个版本Safari 又喜欢自己玩一套。同一个 CSS 属性要写三套前缀同一个 JS API 在不同浏览器里行为还不太一样。更麻烦的是TC39管理 ECMAScript 标准的委员会和 W3C 的节奏天生就慢一个新特性从提案到正式写入标准可能要三五年就算标准发布了浏览器厂商也不一定立刻跟进。这就造成一个很尴尬的局面你看到标准里有个好用的新 API写出来的代码在最新版 Chrome 上跑得飞起但用户的电脑上还装着旧版浏览器直接报xxx is not a function。业务方不管这些他们只丢下一句话“就要支持 IE8就要兼容 Android 4.4。”于是前端工程师必须想办法让旧环境也认识新 APIshim 和 polyfill 的战场就是这么被需要的。这不是个例是整整一个时代的常态。现在很多新入行的同事不理解为什么 package.json 里要挂一堆 polyfill 依赖其实代码库里的每一段兼容补丁背后都是当年某个真实用户的浏览器版本逼出来的。2.2 polyfill 的经典战场从 ES5 到 ES2020polyfill 主要分布在几个阶段。第一个大战场是 ES5 时代Array.prototype.forEach/map/filter、Object.defineProperty、Function.prototype.bind这些今天看起来稀松平常的方法在 IE8 以下全都没有。你会看到大量代码库在文件头部先写一段“if (!Array.prototype.forEach) { ... }”这就是最早期的 polyfill 写法。第二个大战场是 ES6/ES2015 时代Promise、Symbol、Set/Map、String.prototype.includes、Array.from这些简直是一套组合拳。Promise 这种异步核心 API 当时几乎每个项目都要补。第三个战场是 ES2016 之后的零零碎碎比如Object.entries/Object.values、String.prototype.padStart、Array.prototype.flat单个补丁不大但架不住 API 多。除了 JS 标准库还有不少特殊场景的 polyfill。html5shiv是为了让 IE8 识别section、article这些 HTML5 标签Respond.js是让 IE6-8 支持 CSS 媒体查询es5-shim是把 ES5 的 API 整体搬到古董浏览器上。这个名单还能拉很长每一行都是一个浏览器“不听话”的痕迹。2.3 shim 的典型场景封装、垫片、桥接polyfill 战场很热闹但 shim 的场景其实更宽。早期 jQuery 本质上就是一套跨浏览器的 DOM 操作 shim统一了事件绑定、Ajax、DOM 选择的差异你不需要关心底层是 IE 还是 FirefoxjQuery 帮你垫平了。axios 在浏览器端同时封装 XHR 和 fetch这也是一种能力统一层 shim——你的业务代码只面对 axios 的接口不用关心底层用什么发请求。微前端框架里的 JS 沙箱也是一种 shim。比如 qiankun 让多个子应用在同一个页面共存要在全局变量、事件监听、动态脚本加载之间做隔离本质上就是把“一套浏览器环境”伪装成“多套隔离环境”。这种做法不是实现什么标准 API而是为了解决接口对不上的问题所以它叫 shim 更合适。理解了 shim 的宽泛再看 polyfill 的精确整条脉络就通了。接下来聊聊最实际的问题polyfill 到底怎么实现我用自己的手写经验拆给你看。3. 实操环节手写 polyfill 的核心思路3.1 先判断这个 polyfill 该不该自己写很多人一上来就撸袖子写代码我反而建议你先想清楚什么情况下值得自己手写 polyfill什么情况下老老实实用社区方案。值得手写的场景有三个特征第一你要补的 API 标准已经足够稳定最好在 TC39 标准里已经定稿Stage 4不会天天改行为第二目标 API 本身逻辑简单比如加个方法、滤个数组不需要几百行才能模拟第三项目有很强的可维护性要求团队成员能看懂并持续维护这段补丁。如果三个特征全都不满足比如要补一个标准还没定稿的 API或者动不动牵扯到 Symbol、迭代器等深水区我建议直接用社区维护好的方案比如 core-js别自己造轮子后面我会专门讲工程化方案。判断另一条铁律是永远只补环境里“缺失”的能力不要覆盖浏览器已经原生实现的行为。因为原生实现的坑是你不知道的坑你手写的版本大概率在某些边界行为上和原生对不上。盲目覆盖轻则性能差重则直接改出线上 bug而且是那种只在老版本浏览器复现、极难排查的 bug。所以所有 polyfill 的标准写法都是先if (!xxx)再做处理。3.2 用一个真实案例拆解实现过程我拿Array.prototype.find练手。这个 API 在 ES6 里定义逻辑是传入一个测试函数返回数组中第一个满足条件的元素没有就返回 undefined。手写版本很直白if (!Array.prototype.find) { Array.prototype.find function(predicate, thisArg) { if (this null || this undefined) { throw new TypeError(Array.prototype.find called on null or undefined); } if (typeof predicate ! function) { throw new TypeError(predicate must be a function); } var list Object(this); var length list.length 0; for (var i 0; i length; i) { if (predicate.call(thisArg, list[i], i, list)) { return list[i]; } } return undefined; }; }这里面有几个细节值得单独说。首先是最前面的两个检查数组方法被调用在 null 或 undefined 上要抛错这是规范要求的边界行为不能省。其次是Object(this)把 this 转成对象因为规范允许这个方法用在类数组对象上比如arguments、{ 0: a, length: 1 }这也是很多 polyfill 都有的“兼容鸭子类型”的做法。最后length 0是把 length 强制转成非负 32 位整数防止传入个负数或者 NaN这属于把边界卡死的防御性写法。你看一个几十行的 polyfill几乎每一行都是从标准行为里抠出来的不是想当然写的。这就是为什么我说自己写之前先看看标准里的规范描述 section比找一堆二手博客靠谱得多。3.3 规范化 shim 的五个套路不管写 polyfill 还是 shim长久用下来我会固定一套套路分享给新人照着套。第一步是能力检测别做浏览器检测判断typeof 目标能力 undefined或者方法 in 原型而不是去判断navigator.userAgent后者会随着浏览器版本变化疯狂失效。第二步是最小侵入只在确定缺能力的时候挂补丁且只挂缺的那一个别顺手把别的 API 也改了。第三步是幂等性重复加载这段代码不能报错很多团队把兼容代码打包进多个 chunk没有幂等保护容易二次执行出问题。第四步是可剥离性设计时就要想着“将来能整体删掉”所以不要在 polyfill 里夹带业务逻辑。第五步是依赖顺序比如你先补了Array.prototype.find但find内部如果依赖了Array.prototype.slice那你得保证这段代码执行时slice已经存在否则补了等于白补。这五条不是套话每条背后都有真实的事故案例。现在实打实的应用场景来了现代前端项目里我们很少真的手写一堆 polyfill而是用 babel 加 core-js 自动化处理。这块配置怎么落地我继续讲。4. 工程化方案现代前端项目怎么管理 polyfill4.1 先理清Babel 转译和 polyfill 根本不是一回事很多新人对 Babel 有个误解以为代码经过 Babel 编译就万事大吉了旧浏览器就能跑所有新语法新 API。这是两码事。Babel 做的是语法转译把箭头函数、class、解构赋值、可选链这类“语法糖”转成 ES5 语法因为它处理的是语法层面的东西。但Promise、Array.from、Object.entries这些是“内置对象上的新方法”不是语法Babel 没法凭空造出方法。你可以在 Babel 编译器里看到它是怎么处理这个问题的遇到箭头函数它直接改写成一个function但遇到Promise.resolve()它不可能改写成一段“模拟 Promise 的普通代码”因为 Promise 是一个全新的对象体系语法层面没有对应物。这时候就必须靠 polyfill 在运行时补上这个对象。所以标准流程是“Babel 负责语法core-js 负责 API”两者搭配缺一不可。4.2 按需引入与全量引入的取舍core-js 是目前最主流的 ECMAScript 标准库 polyfill内置了几乎所有稳定阶段 API 的实现还分成了一个个小模块方便按需导入。全量引入最省心直接在入口文件import core-js但它会把整套标准库全打进去体积不小而且很多 API 你的业务代码根本用不到。我见过一个老项目全量引入 core-js 后 gzip 前体积多出 100 多 KB对于首屏压力大的场景完全不能接受。更好的方案是按需引入。配合 Babel 的babel/preset-env把useBuiltIns设置为usageBabel 会扫描你代码里实际用到的 API然后自动导入对应的 core-js 模块。这样既不缺也不多。如果你用的是 webpack 之类的打包器还能进一步配合 tree-shaking 减少冗余。还有个中间方案是自己控制粒度手动按模块引入import core-js/features/array/flat; import core-js/features/string/pad-start;这种方式适合你已经确定项目就用了那么几个新 API 的场景。注意版本号要对准core-js 2 和 core-js 3 的 API 名差异不小别在模块路径上踩坑。4.3 运行时 polyfill 的动态加载思路现在还有个更前卫的做法运行时动态 polyfill代表性的就是 polyfill.io。它的思路是让浏览器访问一个服务地址这个服务根据请求的User-Agent判断当前浏览器缺少哪些能力然后在返回的 JS 里只塞那些真正缺失的 polyfill。老浏览器拿到的是完整补丁包新浏览器拿到的几乎是空脚本体验和体积都拉满。但动态服务也有坑。首当其冲是稳定性polyfill 脚本加载失败业务代码全崩而且崩在业务加载之前很难兜底。第二个坑是信任在线服务本质上是把你的兼容策略交给第三方如果那几个服务被劫持或者挂了影响面是你整个站点的线上稳定性和合规风险。所以自建服务的话要做好缓存、SRI 完整性校验和本地兜底逻辑用公共服务的至少加个静态资源自监督没事就拨测。我的建议很实在大中型项目直接用“构建期按需 少量关键 polyfill 手动引入”的组合把在线动态方案留给确实需要“极致体积且有能力自建服务”的场景。工程化不是越炫越好而是可运维、可预期。4.4 一份经过验证的配置示例给你一个我实际项目里一直用的配置模板。先装依赖npm install --save core-js3 npm install --save-dev babel/preset-envBabel 配置module.exports { presets: [ [ babel/preset-env, { targets: { ie: 11, chrome: 49, android: 4.4 }, useBuiltIns: usage, corejs: 3, modules: false } ] ] };这个配置的意思是编译时适配 IE11、Chrome 49、Android 4.4 这几个目标环境自动按需注入标准 API 的 polyfillcorejs: 3表示用 core-js 第三版作为 polyfill 来源modules: false保持 ES Module 语法交给打包器处理。搭配 webpack 使用可以让打包器做更好的模块拆分。上面的配置我踩过一次坑当时useBuiltIns写成了entry需要在入口文件里手动import core-js结果代码里自动补丁和手动补丁撞在一起重复打包了几十 KB。后来改成usage并删掉手动引入干净很多。另外提醒一句如果你用的 TypeScript 项目要确保 tsconfig 的target和 Babel 的targets一致否则会出现“TS 认为这个 API 一定存在、但运行时根本没有”的诡异问题。到这里工程化说起来很简单但我相信你也猜到了真正的坑都在看不见的细节里。下面这部分是我个人经验里最有价值的一段。5. 避坑指南我踩过和见过的坑5.1 全局污染与加载顺序的坑polyfill 最大的原生问题是“全局污染”因为它要把方法挂到Array.prototype、Object、String.prototype这类全局对象上。一旦多个库各自实现了同一个 polyfill还可能互相覆盖。更经典的场景是加载顺序你把兼容脚本放在业务 bundle 后面加载业务代码在 polyfill 到达之前就执行了前面的Promise.resolve().then()直接抛错而且这种错误发生在初始化阶段错误堆栈往往杂乱无章定位成本极高。我看到过某个线上事故团队为了优化首屏把兼容脚本用动态注入的方式放到页面底部加载结果首页核心逻辑里用到了一个需要 polyfill 的 API一部分用户首屏直接白屏线上告警响了一整夜最后定位到就是加载顺序问题。所以一个铁律polyfill 必须在业务代码之前可靠地执行完。如果没法保证顺序就把关键 polyfill 直接打进入口 chunk或者干脆用script同步放在 head 里别为了那点性能把稳定性搭进去。5.2 包体积与重复引入的坑你以为只引入一次 core-js 就完事了不一定。项目里存在多个 npm 包时很可能会出现“这个库内部自己带了一份 Promise 降级实现那个库又带了一份”的情况。尤其是老项目历史包袱深重复的 polyfill 字符串会在打包产物里重复出现。你从 bundle 分析器里看会看到一堆长得很像的 Promise 兼容模块。重复引入不只是体积问题还有版本冲突问题。core-js 2 的 polyfill 和 core-js 3 的 polyfill 同时存在时它们的Symbol.toStringTag行为不一致可能引发一些隐蔽的运行时差异。我的做法是先全局搜索core-js依赖把无关依赖里的 core-js 用resolve.alias统一指向单一版本然后定期跑一次 webpack-bundle-analyzer专门看 polyfill 相关模块的体积占比防止不知不觉身材走形。5.3 你以为补完了其实没补完依赖链的坑polyfill 有个很坑的特性是“补了外层还要补内层”。比如你补了Symbol本身但没补Symbol.iterator那你写for...of遍历数组依然会炸因为for...of依赖的是Symbol.iterator这个具体符号。再比如你补了Map和Set但它们的迭代方法内部依赖其他符号只补外围 API 是远远不够的。最典型的连环坑是 Promise。只补Promise对象本身但Promise的实例方法里可能依赖微任务调度机制微任务调度又是老浏览器里最缺的东西。所以我一直强调用 core-js 这种经过充分测试的标准库方案是因为它内部把这些依赖链理清楚了。真要自己拼你得把整条链路都列出来一个 API 一张图复杂度很快就失控。还有个冷门的坑Object.entries的 polyfill 依赖Object.keys而Object.keys本身在 IE8 里也不存在。如果你在一个古老环境里手写了Object.entries但没先补Object.keys调用Object.entries(obj)会直接报Object.keys is not a function。这种坑刷十道题你想不到但线上环境就是会教做人。5.4 面试速答除了 shim 和 polyfill还有 ponyfill既然这篇是给准备面试的同学写的我把很容易扩展考到的对比直接做进来。除了 shim 和 polyfill还有个词叫 ponyfill意思是“不污染全局的 polyfill 实现”。它也是实现一个标准 API 的兼容方案但故意不去修改原生对象而是把它导出为一个模块你显式引入使用。比如你项目里有一个_entries工具函数内部实现了Object.entries的逻辑但导出的是一个普通函数调用时写成_entries(obj)而不是挂到Object上这就是一个 ponyfill。为什么会有 ponyfill就是为了规避全局污染的副作用。polyfill 改了Array.prototype所有依赖这个全局对象的第三方库都会看到这个修改万一你的实现某处不规范就可能在别人家的代码里引爆。ponyfill 把选择权交给调用方更安全代价是代码看起来没那么“原生”你得显式到处 import。再补一个对比点Babel 这类 transpiler 严格来说不是 polyfill也不等于 shim它是语法层翻译器跟运行时补齐不是一个维度。面试官很喜欢用一个连环问“Babel 能替代 polyfill 吗”答案是不能理由就是我前面那节讲的“语法 vs 运行时 API”。下面这张表可以直接背名称核心特征是否污染全局典型代表shim泛指所有能力填补代码不一定jQuery、兼容层封装polyfill专指实现标准 API 的 shim是core-js、es5-shimponyfill实现标准 API 但不污染全局否一些工具库导出的兼容函数transpiler语法转译不补运行时 API否Babel、TypeScript 编译器面试时如果再被追问“你会怎么做兼容”你可以顺着这个思路答先定目标浏览器范围再决定语法转换层用什么、运行时 API 补齐用什么最后考虑是否要动态按需加载。整个链条都捋清楚了比死记硬背几个名词高好几档。写在最后的一点体会碰上复杂的兼容问题别急着把锅甩给“就是旧浏览器垃圾”。前端兼容问题本质上是“环境差异管理”问题shim 和 polyfill 只是工具真正值钱的是你判断“该补什么、补到哪一层、用什么方案补”的决策能力。我自己的经验是能交给标准库的别自己手写能在构建期搞定的别拖到运行时会污染全局的先想想有没有 ponytail 替代方案。兼容设计不是越多越好而是越克制越好——补丁越少未来删起来越干净线上出问题的面就越小。这套思路帮我在好几个项目里少加了夜班你也值得试试。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →