前端面试时有个高频送命题: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 里不存在。当年很多类继承代码用到一个经典的 shim:
if (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。”于是前端工程师必须想办法让旧环境也认识新 API,shim 和 polyfill 的战场就是这么被需要的。
这不是个例,是整整一个时代的常态。现在很多新入行的同事不理解为什么 package.json 里要挂一堆 polyfill 依赖,其实代码库里的每一段兼容补丁,背后都是当年某个真实用户的浏览器版本逼出来的。
2.2 polyfill 的经典战场:从 ES5 到 ES2020+
polyfill 主要分布在几个阶段。第一个大战场是 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 还是 Firefox,jQuery 帮你垫平了。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. 工程化方案:现代前端项目怎么管理 polyfill
4.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设置为'usage',Babel 会扫描你代码里实际用到的 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-js@3 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 的 polyfill,corejs: 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-shim |
| ponyfill | 实现标准 API 但不污染全局 | 否 | 一些工具库导出的兼容函数 |
| transpiler | 语法转译,不补运行时 API | 否 | Babel、TypeScript 编译器 |
面试时如果再被追问“你会怎么做兼容”,你可以顺着这个思路答:先定目标浏览器范围,再决定语法转换层用什么、运行时 API 补齐用什么,最后考虑是否要动态按需加载。整个链条都捋清楚了,比死记硬背几个名词高好几档。
写在最后的一点体会
碰上复杂的兼容问题,别急着把锅甩给“就是旧浏览器垃圾”。前端兼容问题本质上是“环境差异管理”问题,shim 和 polyfill 只是工具,真正值钱的是你判断“该补什么、补到哪一层、用什么方案补”的决策能力。我自己的经验是:能交给标准库的别自己手写,能在构建期搞定的别拖到运行时,会污染全局的先想想有没有 ponytail 替代方案。兼容设计不是越多越好,而是越克制越好——补丁越少,未来删起来越干净,线上出问题的面就越小。这套思路帮我在好几个项目里少加了夜班,你也值得试试。