你们项目的浏览器兼容怎么做的?------一个设备 Web 项目的三层实践
我维护的项目是一个跑在嵌入式设备里的 Web 配置管理系统,React 16 + Webpack + antd 4,特点是:Web 随固件出厂,用户浏览器完全不可控------客户可能用十年前的 IE,也可能用最新的 Chrome,还有大量企业内网的国产套壳浏览器;而且视频能力依赖一个浏览器原生插件。
框架层在构建期消灭问题,规范层在编码期拦住问题,插件层在运行期消化消不掉的问题。每一层我都带几个真实踩过的案例。
一、先对齐概念:先了解浏览器,再谈兼容问题
1.1 先了解浏览器:五大浏览器,四大内核
主流浏览器就五个:IE、Firefox、Safari、Chrome、Edge。浏览器解析渲染页面的核心叫内核(渲染引擎),四大内核对应关系是:
| 浏览器 | 内核 | 备注 |
|---|---|---|
| IE | Trident | 老 IE 专属,Edge 及国产浏览器"兼容模式"仍可能走它 |
| Firefox | Gecko | |
| Safari | WebKit | |
| Chrome | Blink | Blink 是 WebKit 的分支 |
| Edge / Opera / 360 极速模式 | Blink | 国产套壳浏览器"极速模式"走 Blink,"兼容模式"可能回退 Trident |
要特别注意国产套壳浏览器这个变量:360 浏览器在极速模式下是 Chromium 内核,在兼容模式下可能走 Trident------同一个浏览器图标背后是两个完全不同的内核,做企业内网项目时这个差异极容易在表单、打印、下载这些场景翻车。
1.2 什么是兼容问题,为什么会存在
先给定义:同样的代码,在不同的浏览器上显示的效果不一样------这就是浏览器兼容性问题。不一样的原因,是各浏览器使用了不同的内核,处理同一件事情时思路不同。
兼容问题存在的具体原因,可以归纳成三条:
- 同一浏览器,版本越老 bug 越多,对新属性、新标签、新特性的支持越少;
- 不同浏览器,核心技术(内核)不同、标准落地进度不同、实现方式有差异,最终呈现给用户的自然不同;
- 不规范的代码会放大不兼容现象------这一点常被忽略,兼容问题不全是浏览器的锅。
1.3 从内核角度看,兼容问题分三类
- 渲染相关:和样式相关,体现在布局效果上(盒模型、flex 支持度、厂商前缀)。特点是"失效但不崩溃"------不认识的属性被浏览器静默忽略。
- 脚本相关:和 JS 语法、DOM/BOM 相关。语法错误会让整个文件不执行;API 缺失则容易静默失败------对象在、方法没了,不报错但功能跑不出来。
- 其他类别:浏览器在内核层之上提供的功能和策略(自动播放、Cookie、外部协议唤起、CSP、插件机制)。最易被忽略,因为表象永远像"前端 Bug"或"产品 Bug",根因却在浏览器产品策略上。
1.4 这三类问题,映射到我们项目的三层方案
概念对齐完,答案的主干就出来了:渲染相关的问题交给 CSS 工具链(autoprefixer / pxtorem)在构建期解决;脚本相关的问题交给 Babel 语法转译 + 分级 polyfill + 统一封装层解决;其他类别的问题------我们这个项目里最重的一块是浏览器原生插件------只能在运行期做能力探测和渐进降级。这三层方案分别对应"构建期消灭、编码期拦住、运行期消化",下面逐层展开。
二、第一层:代码整体框架------把兼容问题在构建期消灭
这一层解决的是"新语法、新 API、新样式在旧浏览器跑不起来",对应三分法里的脚本相关和渲染相关这两类。
2.1 案例先行:老浏览器里一行箭头函数就是白屏
先讲一个最基础的认知:语法错误和 API 缺失的死亡方式完全不同。
js
// 业务代码里随处可见的写法
const loadData = async () => {
const ret = await RPC.ConfigManager.getConfig('Network');
this.setState({ ...ret.params });
};
这段代码在 IE11 里的下场是:箭头函数、const、async/await、解构------每一个都是语法错误 。语法错误发生在脚本的 parse 阶段 ,整个文件直接不执行,try/catch 拦不住,没有报错堆栈,页面就是一片空白。你连"哪一行错了"都不知道。
而这类写法在项目里是海量存在的:
js
// src/base/plugin/api/ocx.js ------ Object.entries,IE 不支持
for (let [name, pars] of Object.entries(PLUGIN_FUNCTIONS)) { ... }
// src/base/plugin/component/h5.js ------ Number.parseInt,IE 不支持(只有全局 parseInt)
width={Number.parseInt(this.viewStyles[index].width) - borderWidth}
// src/base/utils/util.js ------ String.prototype.includes,IE 不支持(只有 indexOf)
if (base64.includes(',')) { ... }
我在项目里搜过 .includes( / Object.entries / Number.parseInt 这类 IE 不支持的 API,命中四百多个文件。也就是说,只要去掉构建期兼容这层防护,全项目没有任何一个页面能在 IE 里活过首屏。
所以我们编译的时候才必须做语法转译 + API 补齐,而且要覆盖到最后一行代码、最后一个依赖。
2.2 一份 browserslist,两条流水线共用
json
"browserslist": [
"Firefox > 20",
"iOS >= 7",
"ie >= 9",
"last 5 versions",
"> 5%"
]
这份配置写在 package.json 里,同时驱动两条流水线:Babel(babel-preset-react-app)按它决定 JS 语法转译力度;postcss-preset-env 的 autoprefixer 按它决定 CSS 补哪些厂商前缀。"驱动"这个词背后是一个完整的调用链,展开讲:
先懂 browserslist 是什么
browserslist 不是 webpack 或 babel 自带的东西,它是一个独立的 npm 包 ,本质是一套"浏览器目标查询"的共享标准。它的作用是:你只写一份查询条件,所有支持它的工具(babel、postcss、autoprefixer、eslint-plugin-compat 等)都去读同一份条件、算出同一份目标浏览器列表。这样不同工具的目标天然一致,不用你在每个工具里重复配置。
数据流向:browserslist 在中间,两边是工具
browserslist 包干的事是"算出目标浏览器列表",分三步:
- 读配置 :从
package.json的browserslist字段(或.browserslistrc文件)读你写的查询条件------Firefox > 20、ie >= 9这些; - 查数据库 :拿这些条件去查
caniuse-lite(一个记录"每个浏览器版本支持哪些 Web 特性"的公共数据库),算出最终的目标浏览器列表------具体到 Chrome 110、Firefox 89、IE 9-11 这些版本; - 返回结果:把列表返回给调用它的工具。
babel 和 postcss 都用这份列表
具体的调用链路后面讲到 babel 和 postcss 配置时会展开,这里先记一个结论:babel 在决定"哪些语法要转译"、postcss 的 autoprefixer 在决定"哪些 CSS 属性要补厂商前缀"时,调的都是 browserslist,读的都是同一份目标列表 。所以改一行 package.json 的 browserslist,JS 转译力度和 CSS 前缀补法会一起跟着变------这就是前面说"JS 和 CSS 兼容目标必须收敛到同一份配置"的技术落地,不是嘴上说说,是架构层面的共享。
怎么亲手验证
browserslist 是装在 node_modules 里的,在项目根目录直接跑:
npx browserslist
它会按 package.json 的配置算出并打印目标浏览器列表。我在本项目里实际跑了一次,输出是这样的:
and_chr 110
and_ff 110
and_qq 13.1
and_uc 13.4
android 109
baidu 13.18
chrome 110
chrome 109
chrome 108
chrome 107
chrome 106
edge 110
edge 109
edge 108
edge 107
edge 106
firefox 110
firefox 109
...
firefox 89
你能亲眼看到 chrome 106-110、edge 106-110、firefox 89-110 这些具体版本------这就是 last 5 versions 和 > 5% 两条条件算出来的结果。改一下 package.json 里的条件(比如把 ie >= 9 删掉),再跑一次,列表会跟着变。这就是"驱动"最直接的证据:一份配置 → 算出一份列表 → babel 和 postcss 都按这份列表干活。
这是我的第一条经验:JS 和 CSS 的兼容目标必须收敛到同一份配置,否则会出现"JS 能跑、样式崩了"的半兼容状态。
2.3 Babel 双通道编译:业务代码和第三方依赖区别对待
这一节我放慢一点,把配置逐行拆开讲,因为理解了它,就理解了整个"构建期兼容"的核心机制。
先懂两个前提:webpack 流水线和 babel 翻译工序
webpack 构建可以想成一条流水线:源代码从入口进去,沿途经过一道道工序(loader) ,每道工序对文件做一次加工,最后打包成产物。其中最关键的一道工序是 babel------它是个"翻译器",把 ES6+ 新语法翻译成 ES5 旧语法,老浏览器才看得懂。
问题来了:项目里有几万甚至几十万个文件,哪些文件要送进翻译器、用哪套翻译规则 ,就是 webpack 配置里 module.rules 要回答的问题。这个项目在 config/webpack.config.prod.js 里配了两条规则,也就是两条流水线:
js
// 流水线一(babel2):业务代码
{
test: /\.(js|mjs|jsx|ts|tsx)$/, // ① 什么样的文件进来
include: paths.appSrc, // ② 只看 src 目录里的
exclude: /jsFFMPEG/, // ③ 但 jsFFMPEG 除外
loader: 'happypack/loader?id=babel2' // ④ 交给 babel2 这台机器
},
// 流水线二(babel3):第三方依赖
{
test: /\.(js|mjs)$/, // 依赖里没有 jsx/ts,后缀放宽到 js/mjs
exclude: /@babel(?:\/|\\{1,2})runtime|jsFFMPEG/,
loader: 'happypack/loader?id=babel3'
}
逐字段解释:
test:一个正则,用来筛选文件后缀。js|mjs|jsx|ts|tsx就是"所有 JS 家族的文件"------普通 js、ES 模块 mjs、带 JSX 的 js、TypeScript;include:白名单,只有这个目录里的文件才进这条流水线。paths.appSrc就是我们自己写的业务代码目录;exclude:黑名单,命中的文件跳过这条流水线;loader:指定交给哪台机器加工。
这两条规则在同一个 oneOf 数组里------webpack 从上到下匹配,命中一条就停,不会重复加工。
babel2、babel3 到底是什么?在哪里配置的?
注意规则里写的是 happypack/loader?id=babel2------这行本身不包含任何翻译参数,它只是一个"挂号" :意思是"这个文件交给名叫 babel2 的机器处理"。机器的真正定义在同一个文件的 plugins 数组里,通过 new HappyPack({ id: 'babel2', ... }) 注册:
js
// config/webpack.config.prod.js ------ plugins 数组里
// 机器 babel2:处理业务代码
new HappyPack({
id: 'babel2',
loaders: [
{
loader: 'babel-loader',
options: {
customize: require.resolve('babel-preset-react-app/webpack-overrides'),
plugins: [
// named-asset-import:让 import { ReactComponent } from './x.svg'
// 这种"SVG 当 React 组件用"的写法生效
],
cacheDirectory: true, // 翻译结果缓存到磁盘,第二次构建提速
compact: true
}
}
],
threadPool: happyThreadPool // 共享的多进程池,大小 = CPU 核数
}),
// 机器 babel3:处理第三方依赖
new HappyPack({
id: 'babel3',
loaders: [
{
loader: 'babel-loader',
options: {
babelrc: false, // 不读项目根目录的 babel 配置
configFile: false,
compact: false,
presets: [
// CRA 专门为"第三方依赖"准备的 preset 套餐
[require.resolve('babel-preset-react-app/dependencies'), { helpers: true }]
],
sourceMaps: false // 依赖出错时看编译后代码更好定位,不开源码映射
}
}
],
threadPool: happyThreadPool
})
两段配置拆开看,两台机器的分工就很清楚了:
**babel2(业务代码)**的关键参数是 customize: require.resolve('babel-preset-react-app/webpack-overrides')。这个 customize 是 CRA 留的自定义入口------它告诉 babel-loader:"去读 package.json 里的 babel 字段"。也就是说,业务代码用的 preset(react-app)和那两个插件(可选链、空值合并),不在 webpack 配置文件里,而是在 package.json 的 babel 配置块里 ,webpack-overrides 负责把它们合并进来。这就是为什么前面说的 ?. / ?? 插件写在 package.json 而不是 webpack 配置里。
**babel3(第三方依赖)**的关键是三个参数配合:babelrc: false + configFile: false 意思是"不要读项目里任何 babel 配置文件,完全用这里写的参数";preset 用的是 babel-preset-react-app/dependencies------注意 /dependencies 这个后缀,这是 CRA 专门为 node_modules 准备的"依赖专用套餐",只转译标准 ES 特性,不带 JSX、装饰器这些业务语法。{ helpers: true } 表示依赖侧的辅助函数从 runtime 包复用(和业务侧的取舍相反,因为依赖文件多,复用能省体积)。
一句话总结这个机制:webpack 规则负责"分拣"(哪个文件去哪台机器),HappyPack 的 id 负责"挂牌"(把规则和机器连起来),HappyPack 配置里的 loaders[].options 才是"机器的真实参数"。 下次在任何项目里看到 happypack/loader?id=xxx,直接去 plugins 数组里找同 id 的 new HappyPack(...) 就能看到真实配置。
为什么要分两条流水线?因为两种代码的"语法花哨程度"不一样
babel2(自己的业务代码)配的是"完整套餐" :完整的 preset(babel-preset-react-app,包含 JSX、TypeScript 等转译规则)加业务插件(装饰器 @observer、antd-mobile 按需引入等)。因为我们自己写的代码语法最花哨------JSX、装饰器、类属性满天飞。
插一句概念:preset 就是"插件套餐"。babel 的转译能力全由一个个小插件提供(一个插件负责一种语法),preset 是把一组常用插件打包好的预设,比如 react-app 这个 preset 就等于"React 项目常用转译插件全家桶"。
babel3(node_modules 第三方依赖)配的是"简餐" :babelrc: false, configFile: false------这两个参数的意思是"不要去读项目根目录的 babel 配置文件",只用内置的标准 ES 转译规则。因为第三方包里没有 JSX、没有装饰器,只有标准 ES 语法,用简餐就够了,还能避免项目的装饰器配置误伤依赖。
为什么连 node_modules 也要翻译?
这是这节最重要的问题,也是面试官最爱追问的。
原因是:npm 生态里大量包默认只发 ES6+ 产物 。包作者发布时只管"现代浏览器能跑",源码里就带着箭头函数、const。如果你只翻译自己的 src 代码,import 进来的那些依赖里的箭头函数会原封不动地进最终产物------IE 打开页面,parse 阶段语法错误,整页白屏。
打个比方:你自己说的是普通话(你的代码转译成了 ES5),但你请来的外包还在说外语(依赖没转译),客户(老浏览器)照样听不懂。
不是你的代码不兼容,是你依赖的代码不兼容 ------这是只编译 src 的项目最常踩的坑。
那为什么要排除 @babel/runtime 和 jsFFMPEG?
排除项是"翻译了反而坏事"的两类文件:
@babel/runtime:它是 babel 自己生成的辅助函数库(比如转译 class 时生成的_classCallCheck),发布时已经是 ES5 了。再翻译一遍纯属浪费构建时间------翻译已经翻译过的东西,就是重复劳动;jsFFMPEG:视频解码用的 wasm/asm.js 库。asm.js 的代码是经过极端优化的紧凑写法(比如大量| 0强制类型标注),babel 重新解析、转译会破坏它的性能假设,甚至直接损坏逻辑。所以它必须原样通过。
编译范围变大了,构建变慢怎么办?------ happypack 是个多线程加速器
先直接回答你的疑问:是的,happypack 本质就是给 webpack 加多线程并行能力的工具。讲清楚它的背景和原理:
- 为什么需要它。Node.js 本身是单线程的,webpack 跑在 Node 上,默认也是单线程。loader 处理文件时一个接一个串行加工,遇到 babel 这种重计算任务,几万个文件逐个翻译,构建时间会爆炸------我们这种规模的代码,纯串行可能要十几分钟到半小时。
- 它是怎么做的 。happypack 在主线程之外开多个 Node 子进程(worker),把文件分批发给各个子进程并行翻译,主线程只负责收集结果。配置里那个
threadPool: happyThreadPool就是共享的进程池,大小取 CPU 核数(HappyPack.ThreadPool({ size: os.cpus().length }))。 - 它和 babel-loader 怎么衔接 。webpack 规则里写
happypack/loader?id=babel2------这个 loader 叫"代理 loader",它自己不翻译任何东西,只负责把文件"转交"给对应 id 的 HappyPack 实例;真正的 babel-loader 和它的参数,定义在plugins数组里那个同 id 的new HappyPack(...)里。这就是上一节讲的"挂号"机制------id是挂号牌,机器本体在 plugins 里。 - 为什么 babel2 和 babel3 是两个实例。可以理解为开了两台并行机器,分别处理业务代码和第三方依赖,互不干扰;它们共享同一个进程池(CPU 资源统一调度),不重复开进程。
顺带一句:happypack 现在基本被 webpack 5 自带的
thread-loader替代了,但本项目用的是 webpack 4,所以还在用 happypack。看项目选型时,工具版本要和 webpack 主版本对得上。
所以这条链路的完整逻辑是:为了不白屏,必须翻译依赖 → 翻译依赖会变慢 → 用多线程把速度找回来。每一步都是前一步的代价换来的。
babel 本身不能解决这些语法吗?为什么还要手动加插件?
这是这一节最核心的疑问。很多人以为 babel 是"翻译器",应该能翻译所有新语法,为什么还要手动加这些插件命令?要把 babel 的设计哲学讲清楚:
- babel 不是魔法黑盒 。它不会自动扫描源码、发现新语法然后翻译。babel 是按需、显式配置 的工具:每种语法特性对应一个插件(plugin),一个插件只负责翻译一种语法------可选链插件只管
?.,空值合并插件只管??,装饰器插件只管@xxx;preset 是"插件套餐",把一组常用插件打包好。 - "用 babel 转译" ≠ "babel 自动翻译所有新语法"。必须显式告诉 babel"我要翻译这些特性",它才会加载对应插件去干活;没声明的特性,babel 会按"目标浏览器是否支持"决定保留原样还是报错。
- 为什么这么设计。ES 标准每年都在演进,新语法层出不穷;如果 babel 默认全开,产物会臃肿、构建会变慢,而且很多项目根本不需要那么宽的兼容范围。所以 babel 的哲学是"可组合、按需",把选择权交给工程。
- 那 preset-react-app 这个套餐默认带哪些 。它带的是 React 项目最常用的那一批------JSX、TypeScript、async/await、class、解构等"已进入标准且稳定"的特性。但有些特性(可选链、空值合并长期处于 proposal 提案阶段,后来才正式进入标准),不同版本的 preset 对它们的默认态度不一致:有的版本默认开启,有的默认关闭。团队里有人升级了 preset 版本,行为就可能悄悄改变。
- 所以项目显式声明这两个插件 ,等于把行为钉死------"不管你 preset 是哪个版本,
?.和??都必须翻译"。这是工程化的稳健做法:不依赖 preset 的默认值,而是显式表达意图。
理解了这个哲学,package.json 里这段配置每一行的作用就清楚了:
js
["@babel/plugin-transform-runtime", { "helpers": false, "regenerator": true }],
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator"
transform-runtime 解决的是"翻译产物的重复"问题 ------而且它同样不是 babel 核心自带的,是个独立插件,babel 默认不做"辅助函数复用",要你显式装上才做。它解决的具体问题:babel 翻译每个文件时会生成辅助函数------比如把 class 翻译成函数时,需要一段 _classCallCheck 的辅助代码。如果一百个文件都用了 class,这段辅助代码就内联一百份,产物体积爆炸。transform-runtime 的作用是把辅助函数改成从 @babel/runtime 公共包里 import,全项目共享一份。
regenerator: true 解决的是 async/await 的完整降级链路。 async/await 的转译是两段式的:babel 先把 async 函数翻译成 generator 函数 (function*),但 generator 老浏览器也不认识;于是还需要 regenerator-runtime 这个运行时库,在 ES5 环境里用一个状态机循环模拟 generator 的执行。两段链路缺任何一段,async 函数在老浏览器里都跑不起来------regenerator: true 就是把第二段接上。
?. 和 ?? 两个插件------这俩到底是干什么的、为什么必须装,单独展开讲:
@babel/plugin-proposal-optional-chaining(可选链插件) 管的是 ?. 这种语法。可选链有三种写法:属性访问 a?.b、方法调用 a?.()、计算属性 a?.[x]。语义是:如果 a 是 null 或 undefined,整个表达式不抛错、直接返回 undefined;否则继续往下取。它解决的痛点很实际------以前要安全访问深层属性,得写一长串 &&:a && a.b && a.b.c,又长又容易漏;有了 ?. 直接 a?.b?.c 一行搞定。插件装上后,babel 会把 a?.b 转成等价的 ES5 写法(类似 a == null ? void 0 : a.b,实际更复杂,要保证 a 只求值一次、不抛异常)。不装会怎样 :preset 不认识这个语法,要么 parse 阶段直接报 Unexpected token,要么保留原样进产物------老浏览器同样 parse 失败,白屏。
@babel/plugin-proposal-nullish-coalescing-operator(空值合并插件) 管的是 ?? 运算符。语义:a ?? b,只有 a 是 null 或 undefined 时才取 b,否则取 a。它和 || 的关键区别是最容易踩坑的点:|| 会把所有"假值"(0、''、false、null、undefined、NaN)都当 false 处理,而 ?? 只认 null 和 undefined。真实场景差异------const port = config.port ?? 8080,如果 config.port 是 0(合法的端口值),用 || 会被替换成 8080(错误),用 ?? 会保留 0(正确)。插件装上后,babel 转成等价的 (a !== null && a !== undefined) ? a : b。
为什么两个插件名字都带 proposal :因为这两个语法当年处于 ECMAScript 提案阶段(proposal),babel 用 @babel/plugin-proposal-xxx 提前支持;2020 年它们正式进入 ES2020 标准后,新版 babel 把插件改名成 @babel/plugin-transform-xxx,proposal 旧名仍能向后兼容但被标记弃用。本项目是 babel 7.1 时代装的,还是 proposal 命名。这也是为什么前面强调"不信任 preset 默认值"------团队升级 babel 或 preset 版本时,插件名、默认启用状态都可能悄悄变,行为漂移就发生了。
这里还有个血泪案例:构建服务器的 Node 版本太老不认识 ?.,配置文件直接 require 一个用了可选链的包,构建直接挂掉------踩坑实录的案例二就是它。
2.4 压缩器也要讲兼容:Terser 的 ecma 双档位
这一节也展开讲,因为"压缩器也要讲兼容"是个反直觉的认知------很多人以为 babel 翻译完就万事大吉,殊不知压缩阶段也可能把已经兼容的代码重新搞成不兼容。
先懂 Terser 是什么、在流水线哪个位置
Terser 是 JS 代码压缩器(minifier) ,老牌 uglify-js 的继任者(uglify 不支持 ES6+ 语法,Terser 接管并支持现代语法)。在 webpack 里通过 TerserPlugin 接入,是构建流水线的最后一道工序:babel 翻译完 → webpack 打包完 → Terser 再对最终 bundle 做压缩------删空格换行、缩短变量名、删死代码、合并常量、提字面量。
它和"兼容"的关系是:压缩器在做语法变换时,如果不受目标 ES 版本约束,可能产出老浏览器解析不了的代码。比如它可能"聪明"地把某段 ES5 代码优化成更短的 ES6 写法(箭头函数、解构),或者把变量重命名成和浏览器保留字冲突的短名------产物在 Chrome 里能跑,到 IE 就挂。所以 Terser 的配置里要显式约束"产物不许超出 ES5"。
逐字段拆解 terserOptions
js
new TerserPlugin({
terserOptions: {
parse: { ecma: 8 }, // ① 解析输入
compress:{ ecma: 5 }, // ② 压缩变换
mangle: { safari10: true }, // ③ 变量名混淆
output: { ecma: 5, ascii_only: true } // ④ 输出产物
}
})
Terser 处理代码分四个阶段,每个阶段都能单独设定 ES 版本,对应上面四个字段:
① parse 解析输入 。Terser 先把输入代码读进来、解析成 AST(抽象语法树)。ecma: 8 的意思是"我能接受 ES8 语法的输入"(async/await、async generator 等)。为什么这里放 8 而不是 5?因为输入是 babel 翻译后的代码,理论上应该已经 ES5 了,但万一有遗漏 ------依赖漏网、第三方 wasm 边界代码没被 babel 翻译------允许 Terser 自己也认识 ES8,能避免它在解析阶段就抛"语法错误"把整个构建搞挂。这里是容错:宁可放宽输入,也不要让一个漏网的文件拖垮整次构建。
② compress 压缩变换 。这是 Terser 真正干活的阶段:删死代码、合并常量、简化 if、提字面量等。ecma: 5 的意思是"我只做对 ES5 安全的变换,产物不许超出 ES5"。这是关键约束------压缩器有时会"聪明反被聪明误",比如它发现某段代码用 ES6 解构写更短,就给你转过去;锁死 ecma: 5 等于告诉它"优化可以,但产物必须是 ES5"。
③ mangle 变量名混淆 。把 userConfig 缩成 a、getUserData 缩成 b,减小体积。safari10: true 是个针对性修复:Safari 10 早期版本对某些被压缩成超短名的标识符有解析 bug(会和内置保留字冲突),开这个选项让 Terser 在 Safari 10 上避开这些坑。
④ output 输出产物 。ecma: 5 表示最终产物严格 ES5,老浏览器一定能解析;ascii_only: true 表示所有非 ASCII 字符(中文、emoji、unicode 转义)都以 \uXXXX 形式输出,避免某些浏览器对源码字符编码处理不一致导致解析问题。comments: false 顺便删掉注释。
核心思想:输入放宽、输出收紧
这套"parse ecma 8 / output ecma 5"组合的核心思想就一句话:输入放宽(容错,防止解析阶段挂掉),输出收紧(保底,保证产物一定 ES5)。这是 CRA 遗留下来的经典配置,也是"压缩器也要讲兼容"这句话的具体落地------兼容防护要覆盖到产物生成链路的最后一环,babel 之外还有 Terser 这一关。
还有一行容易被忽略的
js
compress: { pure_funcs: ['console.log'] }
为什么生产环境要剥掉 console.log?除了防泄密,有个老浏览器的经典坑:IE9 在不打开 F12 开发者工具时,window 上根本没有 console 对象 ,console.log('xxx') 直接抛 ReferenceError,脚本中断。剥掉它等于顺手堵掉一类崩溃。
2.5 三级 Polyfill:按生效时机分层
先讲清楚什么是 polyfill
这一节的"polyfill"是个高频词,但很多人其实没真正分清它和 babel 的区别,先科普一下。
polyfill(中文叫"补丁"或"垫片")不是翻译语法,而是给老浏览器补上缺失的 API 实现。 关键在于理解"语法"和"API"是两回事:
- 语法 是"怎么写"------箭头函数
() => {}、const、class、?.、async/await,这些是写法,babel 的工作就是把新写法翻译成旧写法; - API 是"有什么可用"------
Promise、Object.assign、Array.prototype.includes、String.prototype.padStart,这些是运行时能力 ,是浏览器内置的对象和方法。老浏览器里这些 API 根本不存在 ,调用new Promise(...)会直接抛Promise is not defined。
polyfill 干的就是后面这件事:在你的业务代码执行之前,先执行一段"模拟实现"的代码,把老浏览器缺失的 API 挂到全局环境上 。比如先执行一段"模拟 Promise"的代码,把 window.Promise 挂上去,之后业务代码调用 Promise 就能找到它,不会报 is not defined。
babel 和 polyfill 不能互相替代 :babel 处理"语法",polyfill 处理"API"。你用 babel 把箭头函数翻译成了普通函数,但代码里
Array.prototype.flat这种方法老浏览器原型链上没有,babel 翻译不出"方法实现"来------只能靠 polyfill 运行时往Array.prototype上挂一个flat的实现。打个比方:babel 是"翻译官"(把外语翻成中文),polyfill 是"装备库"(给缺装备的人发装备)。
词源:polyfill 来自英国装修术语,指填墙缝的小物件,引申为"填补浏览器能力缺口"------填上老浏览器和现代标准之间的那条缝。@babel/polyfill就是 babel 官方打包好的一组补丁集合(含core-js+regenerator-runtime),一次引入补一大批 API。
为什么 polyfill 要分层
Babel 只做语法转译,不做 API polyfill ------这是最容易被误解的一点。Promise、Object.assign、Array.prototype.includes 这些不是语法,是运行时 API,必须运行时打补丁。但不同的 polyfill 必须"就位"的时机不同,所以我们按"必须就位的时机"分三级:
第一级:HTML 静态引入,先于一切 bundle。
html
<script src="./IEpolyfill.min.js"></script>
<script src="./browserJudge.js"></script>
IEpolyfill.min.js 只有 1KB,纯 ES5 手写,只补三个 API:Object.assign、requestAnimationFrame、Object.setPrototypeOf。为什么是这三个?因为 webpack 的 runtime 自身就会用到它们 ------polyfill 必须在 bootstrap 代码执行前就位,所以只能走 HTML <script>,不能走会被异步加载的 chunk。
第二级:独立 webpack entry。
这一级要先懂一个 webpack 概念------entry(入口) 。webpack 构建有"起点",从 entry 指定的文件出发,顺着 import 关系一路把依赖打包成产物 bundle。一个 entry 就对应一个独立的产物文件(chunk)。项目配了三个 entry:
js
// config/webpack.config.prod.js
entry: {
babelPolyfill: '@babel/polyfill', // ① polyfill 包
setPrototypeOf: require.resolve('./setPrototypeOf'), // ② 自定义补丁
[projectName]: path.join(paths.appSrc, `/${projectPath}`) // ③ 业务入口
}
为什么 polyfill 要单独做成一个 entry、单独成一个 chunk,而不是直接 import 'core-js' 写进业务代码?三个原因:
- 可控的加载顺序 。三个 entry 会生成三个独立的 chunk 文件,
html-webpack-plugin把它们都注入index.html作为<script>标签,浏览器按标签顺序加载执行。webpack + html-webpack-plugin 默认按依赖关系排序(chunksSortMode: 'auto'),polyfill chunk 会被排在业务 chunk 前面,保证业务代码运行时补好的 API 已就位。不过这里真正的"硬保证"其实是第一级------写死在index.html里的IEpolyfill.min.js那个静态<script>,它在所有 webpack chunk 之前执行,才是"webpack runtime 自己要用的 API 先就位"的兜底;第二级 chunk 的顺序更多是约定。 - 独立的浏览器缓存(省下载,不是省执行) 。这里要澄清一个容易误解的点------"独立缓存"不是"polyfill 执行一次以后就跳过不走了"。polyfill 补的是运行时 API,每次页面打开、运行环境都是新的,polyfill chunk 每次都会加载并执行 。缓存省的是"下载":polyfill chunk 文件名带 contenthash、内容基本不变,浏览器缓存命中后下次访问不再重新下载这个文件,直接用本地缓存;而天天改的业务 chunk 哈希会变、缓存失效、需要重新下载。分开打包 = polyfill 走长缓存、业务走短缓存,互不拖累。
- 和第一级的分工 。第一级 HTML 静态脚本只补三个 webpack runtime 急需的 API(1KB),它解决的是"webpack 自己要用"的问题;第二级
@babel/polyfill是个大补丁集合(含 core-js + regenerator-runtime,几十 KB),补的是业务代码用到的所有通用 API(Promise、Map/Set、Symbol、Array 方法等)。两者量级不同、就位时机不同,所以分开。
第二级里还有一个自定义补丁 config/setPrototypeOf.js,它只补两个 API,但要先讲清这两个 API 是干嘛的、为什么项目运行离不开它们:
requestAnimationFrame(rAF) :浏览器提供的"专为动画优化的定时器"。回调在每次屏幕重绘前触发,频率自动对齐显示器刷新率(一般 60Hz ≈ 每秒 60 次),比setTimeout更准、不卡顿、标签页切到后台还会自动暂停省电。现代浏览器才有,IE9 及以下没有。项目里 React 16 的调度、动画过渡会调它------没有它要么报错,要么动画掉帧。Object.setPrototypeOf:设置一个对象的原型(也就是它的"继承关系")。babel 把 ES6 的class extends(类继承)转译成 ES5 时,底层要靠它建立子类和父类的原型链------没有它,类继承在老浏览器里建不起来,React 组件的继承体系直接崩。
所以这两个补丁补的是"动画调度 + 类继承"的底层依赖,实现得很接地气:
js
// config/setPrototypeOf.js
(function(){
// ① 补 requestAnimationFrame:老浏览器没有这个 API,降级为 setTimeout
// 每 1000/60 ≈ 16.7ms 调一次,模拟 60 帧的节奏
window.requestAnimationFrame = window.requestAnimationFrame || function (fn) { return setTimeout(fn, 1000/60); }
window.cancelAnimationFrame = window.cancelAnimationFrame || clearTimeout;
// ② 补 Object.setPrototypeOf:优先用浏览器原生的;没有就走两条降级路
// ({ __proto__: [] } instanceof Array 是个探测:能通过 __proto__ 改原型就走 A 路,否则走 B 路)
Object.setPrototypeOf = Object.setPrototypeOf || ({ __proto__: [] } instanceof Array ? setProtoOf : mixinProperties);
function setProtoOf (obj, proto) { // 降级路 A:直接改 __proto__(IE10/11 支持,最快)
obj.__proto__ = proto;
return obj;
}
function mixinProperties (obj, proto) { // 降级路 B:__proto__ 也不支持时(IE9 及以下),逐属性拷贝兜底
Object.getOwnPropertyNames(proto).forEach(function(item){
if (!obj.hasOwnProperty(item)) { obj[item] = proto[item]; }
});
return obj;
}
})()
这段代码体现的思路是"渐进降级":能用原生 Object.setPrototypeOf 就用原生(最高效);原生没有就看 __proto__ 能不能用,能用就直接改原型链(次优);连 __proto__ 都不支持的远古浏览器(IE9 以下),就退到最朴素的"逐属性拷贝"兜底。兼容策略的本质就是一层层留退路。
第三级:业务代码按需引入。
先回答你的疑问------这一级项目确实用到了 ,不是杜撰。真实代码就在 src/base/app/initApp/index.js 第一行:
js
// src/base/app/initApp/index.js
// 解决数组的ES6方法flat babel不支持
import 'array-flat-polyfill';
import createApp from './createApp';
import createAppStore from './createStore';
// ...其他业务代码
这一行的来历是这样的:项目某次用了 Array.prototype.flat(数组拍平方法,ES2019),结果在老浏览器上挂了。排查发现:
- babel 不会补这个方法(前面讲过,babel 只转语法不转 API 原型方法);
@babel/polyfill这个大集合虽然覆盖面广,但对一些较新的原型方法支持不全,或者你不想为了一个方法引入整个大包增加体积。
于是开发者只针对 flat 这一个方法,单独引入 array-flat-polyfill 这个小包(只几十行代码),并在注释里写清楚原因------"解决数组的ES6方法flat babel不支持"。注释本身就是工程纪律:让后来的人知道这行不是随便加的,是踩过坑后刻意补的。
为什么叫"按需引入"?因为它和第二级的差别就在"需"字:第二级 @babel/polyfill 是"全量补"------把一堆通用 API 一次性都补上,不管你用没用;第三级是"缺啥补啥"------业务代码用到某个特定的、集合没覆盖的 API,就单独引一个小包补这一个。前者图省心,后者图精准。
这里有个真实验证 :你项目里 .includes( / Object.entries / Number.parseInt 这些 API 全项目四百多个文件在用(前面 2.1 节讲过),它们能跑起来,靠的是第二级 @babel/polyfill 的兜底,没走到第三级;而 Array.prototype.flat 这个偏新的方法,第二级兜不住,才单独走第三级补。三级各管一摊,是这个意思。
2.6 案例:有些特性连 polyfill 都救不了,只能换写法
这是我认为最能体现代理深度的点。项目里要处理门禁卡号的进制转换,卡号可能超出 Number 的安全精度,需要大整数运算。直觉写法是 BigInt:
js
// ❌ 这个写法在老浏览器里无解
0x1234567890ABCDEFn // BigInt 字面量
问题在于:BigInt 的字面量语法 123n 是语法层面的东西,Babel 无法把它转译成 ES5(没有任何 ES5 语法能表达任意精度整数),运行时也没有真正的 BigInt 可以 polyfill。所以项目选了 JSBI 库------用函数调用替代语法:
js
// src/base/utils/util.js
import JSBI from 'jsbi';
return JSBI.BigInt(`0x${CardNo}`).toString(); // 16进制卡号转10进制
return [JSBI.BigInt(CardNo).toString(16).toUpperCase(), '', ''];
同类的还有 Proxy(immer 这类库依赖它,Proxy 的行为无法被 polyfill ,不支持的环境只能换实现)和宿主级 API(ResizeObserver 等需要完整 DOM 行为模拟)。兼容策略的边界感就在这里:语法能转译、API 能补、Proxy/BigInt 这类只能换写法或换库,三种问题三条路。
2.7 CSS 侧:一个 flex 看懂 autoprefixer 在干什么
CSS 的兼容问题比 JS 更"安静"------不认识的属性会被浏览器静默忽略,不报错。项目里几乎每个组件都在用 flex 布局,随便挑一个:
less
// src/base/component/CustomEmpty/index.less
.ant-empty {
display: flex;
flex-direction: column;
justify-content: center;
}
display: flex 这行看着简单,背后却是一段"标准演进史"。flexbox 标准经过三个阶段,不同年代的浏览器认识的是不同写法:
| 阶段 | 写法 | 认它的浏览器 |
|---|---|---|
| 2009 老语法 | display: -webkit-box + -webkit-box-flex |
古早 Safari/Chrome |
| 2011 过渡语法 | display: -ms-flexbox + -ms-flex |
IE10 |
| 2012 现代语法 | display: flex + -webkit-flex |
现代浏览器 |
autoprefixer 根据 browserslist 算出的目标浏览器,决定要不要给 display: flex 补上前几行老写法。如果目标含 IE10 和旧 webkit,它会补成:
css
.ant-empty {
display: -ms-flexbox; /* IE10 过渡语法 */
display: -webkit-flex; /* 旧 webkit 前缀 */
display: flex; /* 现代标准 */
flex-direction: column;
justify-content: center;
}
这类"失效但不崩溃"的问题靠人肉根本测不全------你不可能挨个浏览器去看 flex 布局有没有塌。所以交给工具链:
js
// config/webpack.config.prod.js
require('postcss-flexbugs-fixes'),
require('postcss-preset-env')({
autoprefixer: { flexbox: 'no-2009' }, // 不补 2009 草案版前缀
stage: 3
}),
isMobile && require('postcss-pxtorem')({ rootValue: 32, propList: ['*'] })
注意配置里那个 flexbox: 'no-2009'------它正好呼应上面那张三阶段表:项目主动决定不为 2009 老语法补前缀 (不生成 display: -webkit-box 那套),因为那种远古浏览器行为差异太大、补了也未必对,只为 2011/2012 补。这是一条"兼容边界"的主动选择------不是无脑全补,而是按 ROI 决定补到哪个阶段。postcss-flexbugs-fixes 再修一轮旧浏览器 flexbox 的著名布局 bug;移动端产品线叠加 postcss-pxtorem 做单位换算。同一套构建配置靠环境变量服务 PC 和移动两条产品线,而不是复制两份 webpack 配置------配置分裂是兼容漂移的温床。
2.8 版本守门:支持不了的,礼貌拒绝
对 IE9 以下这种彻底支持不了的浏览器,public/browserJudge.js 用**纯 ES5 + 原生 XHR(含 ActiveXObject 降级)**实现了版本门槛:低于 Chrome 41 / IE 9 / Firefox 50 / Safari 10,直接 document.write 一张多语言的"请升级浏览器"提示页。
为什么写得这么原始?因为执行这段代码的浏览器可能连 Promise 都没有------兼容守门代码自身必须是零依赖 ES5,和 polyfill 分级是同一个逻辑:越是底层的代码,越不能依赖被兼容的对象。
三、第二层:代码编写规范------让问题在写代码时就被拦住
构建期消灭得了语法问题,消灭不了"人"的问题。这一层的思路是:把兼容约束变成规则,把规则变成构建失败。
3.1 ESLint 挂进构建链路,而不是靠自觉
这一节我先放慢讲,因为 ESLint、airbnb、prettier 这几个词很多人只是听过名字,没真正搞清它们各自干什么、为什么一起用。
先懂 ESLint 是什么
ESLint 是个"静态代码检查工具" ------它读你的源码、按规则找问题,但只读不改,发现问题只报错或警告,不动你的代码。
它和前面 babel、polyfill 的分工要分清:babel/polyfill 是"事后补救"------你写了不兼容的代码,它们在构建期帮你翻译/补齐,让它能跑;ESLint 是"事前预防"------在你写的时候就拦住不兼容的写法,从源头不让它进仓库。一个治标一个治本。
ESLint 的检查能力全靠规则(rule) ------一条规则管一类问题,比如禁止用 var、强制用 ===、React 里禁止用已废弃的生命周期。你启用哪些规则,它就查哪些;不启用的,它不管。
airbnb 和 prettier 是什么、为什么一起用
光有 ESLint 还不够,因为"启用哪些规则"是个大问题------几百条规则一条条配太累。于是社区出了共享配置(shareable config) ,别人配好一整套规则,你 extends 一下就能用。最出名的就是 airbnb。
- airbnb 是一份 ESLint 共享配置 (
eslint-config-airbnb)。airbnb 公司把自家团队的代码规范开源成了几百条 ESLint 规则(含 React 规则),业界公认最严的规则集之一。.eslintrc里写extends: 'airbnb',就等于"启用 airbnb 那几百条规则"。 - prettier 是个代码格式化工具。它和 ESLint 管的不是一回事------ESLint 管"代码对不对"(质量规则),prettier 只管"代码长什么样"(风格:缩进几个空格、单引号还是双引号、行尾要不要分号、哪里换行)。保存或提交时 prettier 自动把代码格式统一。
为什么 airbnb + prettier 要一起用 :airbnb 既管质量也管一部分风格(它对缩进、引号也有规则);prettier 专管风格。两者在风格上会打架------airbnb 要求 4 空格,prettier 默认 2 空格,保存时 prettier 改成 2,ESLint 又报错要改回 4,来回打架。解决办法是用 eslint-config-prettier 这个包,关掉 airbnb 里所有和风格相关的规则,让 prettier 在风格上说了算 ,airbnb 只留质量规则。看项目 .eslintrc.js 这行:
js
// .eslintrc.js
extends: ['airbnb', 'prettier', 'prettier/react']
含义就是:先继承 airbnb 全套规则 → 再用 prettier 和 prettier/react 两份配置,把 airbnb 里那些和格式相关的规则(含 React 相关的)关掉。最终格局是 airbnb 管质量、prettier 管风格、各管一摊不打架。
babel-eslint 解析器是什么、为什么要它
ESLint 自己带个默认解析器,但它只认标准 JS 语法。项目里有 JSX(<div>)、装饰器(@observer)、类属性这些 babel 才认识的扩展语法,ESLint 默认解析器遇到它们直接报"语法错误"、解析不下去,后面的规则也就没法查了。所以项目把解析器换成 babel-eslint:
js
// .eslintrc.js
parser: 'babel-eslint', // 让 ESLint 用和 babel 一样的语法理解能力去解析
parserOptions: {
ecmaFeatures: { jsx: true, legacyDecorators: true },
ecmaVersion: 2018,
sourceType: 'module'
}
babel-eslint 和 babel 用同一套语法理解能力,ESLint 就能正确解析 JSX、装饰器这些扩展语法,避免"ESLint 报语法错但 babel 能正常编译"的割裂------否则你 ESLint 一直报错,构建却好好的,开发者就会嫌烦把 ESLint 关掉,规范就形同虚设。
ecmaVersion: 2018 锁语法上限
这行 parserOptions.ecmaVersion: 2018 很关键------它告诉 ESLint"最高只允许 ES2018 语法"。写了 ES2019+ 的语法(比如空值合并 ??、可选链 ?.、BigInt 字面量),ESLint 直接报错,不让你过。
为什么这么干:前面讲过 preset 的转译能力有上限,ESLint 允许的语法必须 ≤ preset 能转译的语法。否则就是"ESLint 放行 → 你写了 → 构建 babel 不转或转不动 → 线上崩"。锁死 ecmaVersion 等于在编码期就把超纲语法拦住,是 ESLint 在"兼容"这件事上最直接的着力点。
为什么 ESLint 要挂进 webpack 构建链路
ESLint 可以当独立命令跑(npm run lint),但那样靠人自觉------开发者忘了跑、CI 没接,问题就溜走。项目通过 eslint-loader 把 ESLint 挂进了 webpack 构建链路(dev/prod 配置里都有对应规则),每次构建都会先过 ESLint,发现 error 级问题直接构建失败。再配合 npm run check 把 lint:report、build、coverage 串成一条 CI 校验链------提交即检查。规则只有变成构建失败,才会真正被执行,挂在 npm script 上靠自觉,挂进构建链路才是强制。
3.2 浏览器判断收敛成一个工具模块
所有需要区分浏览器的地方,统一走 src/base/utils/BrowserType.js:
js
export function BrowserInfo () {
var ua = navigator.userAgent.toLowerCase(), ...
var Browser = {
name: ...,
version: mode || parseFloat(...),
Platform: { name: ... }
};
Browser[Browser.name] = true; // browser.chrome / browser.firefox
Browser[Browser.name + parseInt(Browser.version, 10)] = true;
return Browser;
}
返回语义化字段(browser.chrome、browser.version、browser.Platform.win)。UA 判断散落是兼容性债务的头号来源------同一个"是否 Chrome 45+"在五个文件里写五遍,阈值必然漂移。收敛成一个纯函数(该文件在 jest 覆盖率统计范围内),改一处阈值全局生效。
3.3 案例:网络层为什么不裸调 fetch
面试官如果追问"你们请求层怎么处理兼容",这里有两个真实案例。
案例一是 XHR 降级链。src/base/utils/ajax.js 里手写的同步请求工具:
js
const xhr = window.XMLHttpRequest
? new XMLHttpRequest()
: new window.ActiveXObject('Microsoft.XMLHTTP'); // 兼容ie
项目没有裸调 fetch------fetch 在 IE 完全缺失,而且它的流式响应、超时控制语义很难被完整 polyfill。统一走自封装层,能力检测放在工具层,业务代码无感知。
案例二是 navigator.sendBeacon------页面卸载/关闭时上报数据(比如登出通知),sendBeacon 是唯一"页面死了请求还能发出去"的 API,但各浏览器行为差异大。项目里的处理是能力 + 浏览器双重判断,只在 Chrome 走它:
js
// src/base/utils/ajax.js
const brower = BrowserInfo();
if (brower.chrome) {
navigator.sendBeacon('/RPC2', options.data);
return;
}
// 其他浏览器走传统 XHR 同步请求兜底
"其他类别"(浏览器功能策略差异)的兼容问题,答案往往不是 polyfill,而是"按浏览器分路线 + 保守方案兜底"。
3.4 案例:组件和库层面的兼容选型
规范层还有一类隐性工作:选型时就把兼容性纳入考量。几个真实例子:
文件下载 :浏览器另存为在 IE 上要走 msSaveBlob,标准浏览器走 Blob + URL.createObjectURL。项目没有自己写 if/else,而是引入 file-saver,业务代码只有一句:
js
// src/base/utils/util.js
import { saveAs } from 'file-saver';
saveAs(blob, `${filename}.txt`); // 库内部自动分流 msSaveBlob / Blob 两条路
剪贴板 :navigator.clipboard.writeText 要求安全上下文,老浏览器根本没有。项目用 copy-to-clipboard(src/base/pages/NetCapture/netTest.js),库内部自动降级到 document.execCommand('copy') 那条老路。
移动端滚动------这一段展开讲,因为它最能体现"用库解决兼容"的思路。先说原生滚动在移动端为什么不行:
- 惯性滚动差异 :
overflow: scroll在桌面浏览器上滚轮一滚就完事,但在移动端,手指划完松手后内容应该继续"惯性"滑一段再慢慢停。这个惯性是浏览器原生实现的,老 Android WebView、不同 iOS 版本对手感、减速曲线的实现差异巨大------同一个页面在小米和 iPhone 上滑动手感完全两样。 - 边界回弹差异:滑到顶/底应该有个"橡皮筋"回弹效果,iOS 有、老 Android 没有,体验割裂。
- 下拉刷新/上拉加载原生根本不支持:这是业务高频需求,但浏览器没有现成 API,得自己监听 touch 事件算位移------在原生滚动上实现极其别扭。
所以移动端没有用原生 overflow: scroll,而是统一封装了 ScrollCmp(src/base/libs/Scroll/index.jsx),底层用 better-scroll 这个库模拟滚动。better-scroll 的做法是:自己接管 touch 事件、自己算手指位移、自己用 transform 移动内容------不靠浏览器原生滚动,手感、回弹、惯性全由库统一控制,跨设备表现一致。
先看那几行 BScroll.use(...) 是干嘛的:
jsx
import BScroll from '@better-scroll/core';
import ObserveDOM from '@better-scroll/observe-dom';
import Pullup from '@better-scroll/pull-up';
BScroll.use(ObserveDOM); // 注册"DOM 变化监听"插件
BScroll.use(Pullup); // 注册"上拉加载"插件
better-scroll 是按需插件化 的------核心包 @better-scroll/core 只有最基础的滚动能力,额外能力(监听 DOM 变化自动重算高度、上拉加载、下拉刷新等)都拆成独立插件,用 BScroll.use(插件) 注册后才能用。这种设计是为了控制体积 :移动端对包大小敏感,你只用核心滚动就不引插件,用到上拉才引 pull-up。这里注册了两个:ObserveDOM(列表内容变了自动 refresh 重算可滚动高度,否则新数据加载进来滚动范围不对)和 Pullup(上拉到阈值触发回调)。
注册完插件,组件内部 new BScroll(容器, 配置) 实例化,并监听事件实现业务需求:
jsx
// src/base/libs/Scroll/index.jsx
useEffect(() => {
const scroll = new BScroll(scrollContaninerRef.current, {
scrollX: direction === 'horizontal',
scrollY: direction === 'vertical',
pullUpLoad: true, // 开启上拉加载
click, // 移动端 click 事件常被滚动吞掉,这里补偿
observeDOM: true // 开启 DOM 监听
});
setBScroll(scroll);
}, []);
// 上拉加载:滚到底部(y 距离 maxScrollY 100px 内)触发业务回调
useEffect(() => {
bScroll.on('scrollEnd', () => {
if (bScroll.y <= bScroll.maxScrollY + 100) { pullUpDebounce(); }
});
}, [pullUp, bScroll]);
// 下拉刷新:手指下拉超过 50px 触发业务回调
useEffect(() => {
bScroll.on('touchEnd', pos => {
if (pos.y > 50) { pullDownDebounce(); }
});
}, [pullDown, bScroll]);
业务页面用起来只关心两个回调:
jsx
<Scroll pullUp={loadMore} pullDown={refresh} pullUpLoading={loading} />
pullUp/pullDown 是业务自己写的"加载更多/刷新"逻辑,组件内部还套了 debounce 防抖(500ms 内只触发一次),避免用户疯狂滑动时接口被打爆。
这套封装的兼容价值在于:滚动的跨设备差异、touch 事件接管、惯性回弹模拟、下拉上拉判断,全封在 ScrollCmp 内部。业务页面传两个回调就行,完全不用知道底下是 better-scroll 还是别的什么、不用管 Android 和 iOS 手感差异。这就是"用库 + 封装"解决兼容问题的典型------和前面 file-saver、copy-to-clipboard 是同一个套路:兼容逻辑要么在封装层,要么在库的选型决策里。
这三个例子(下载、剪贴板、滚动)是同一个原则:兼容逻辑要么在封装层,要么在库的选型决策里,绝不允许散落在业务代码里。
3.5 三条编码约定
- 新 API 不裸调:前缀重灾区只在公共封装层写一次探测链,业务层只调封装后的方法;
- 数组/对象新方法先查支持度 :不支持就显式
import 'xxx-polyfill'并写注释说明原因(如前面 array-flat-polyfill 的注释),让后人知道这是刻意的而不是碰巧; - HTML 内联脚本必须手写 ES5 :
public/index.html里的移动端 UA 重定向逻辑不经任何 loader,babel 管不到它------这是最容易踩的盲区,内联脚本里的箭头函数在老浏览器直接语法错误,整页渲染中断。
四、第三层:插件兼容------运行期的渐进式适配
这一层是我这个项目最特殊的部分。视频预览、录像回放、本地录像下载、对讲,早期全部依赖一个浏览器原生插件(OCX)。为什么要在运行期做兼容,而不是直接干掉插件?这就要先讲一小段历史。
4.1 背景:浏览器升级后,插件为什么"消失"了
早期的网页插件分两派:IE 走 ActiveX,页面 new ActiveXObject(...) 创建实例;Chrome/Firefox 走 NPAPI。2015 年是分水岭------Chrome 42 默认禁用 NPAPI,Chrome 45 彻底移除接口层代码 。注意是"移除"不是"禁用":浏览器根本不再发起插件实例化调用,重装插件、改注册表、regsvr32 全都无效,因为调用方不存在了。用户侧的表现就是我们最熟悉的那句反馈:"浏览器升级后,插件找不到了。"
厂商的集体应对是"去插件化但保留本地组件":插件从"浏览器内嵌控件"变成"独立本地进程",起一个本地 WebSocket 服务,网页通过 ws://127.0.0.1:端口 与它通信,视频窗口由本地进程创建的原生窗口叠加在页面指定位置上;再往后,纯 H5 无插件方案(WebSocket 直接取流 + MSE 解码)成为终态。
这段历史在我们项目的代码里留了完整的"化石层",所以插件兼容不是补丁,是一套分层的运行期架构。核心思路四个词:能力探测、双实现适配器、版本协商、渐进降级。
4.2 三种插件形态,对应三种浏览器环境
要讲清楚"插件怎么兼容浏览器",得先明白一件事:项目要兼容的不是"一种插件",而是三种形态,分别适配不同年代的浏览器。这也是上一节那段历史在代码里的直接映射。
| 形态 | 长什么样 | 适配哪种浏览器 |
|---|---|---|
| 老插件(ActiveX/NPAPI) | 浏览器内嵌控件,页面渲染 <object>/<embed> 标签嵌入 OCX |
IE(ActiveX)、古早 Chrome/Firefox(NPAPI) |
| 新插件/高版本插件 | 独立本地进程 + 本地 WebSocket 服务,网页走 ws://127.0.0.1 通信 |
现代浏览器过渡方案 |
| 无插件(H5) | 纯浏览器能力,WebSocket 取流 + H5 播放器渲染 | 现代浏览器终态 |
老插件的形态在 component/ocx.js 里看得很清楚,按浏览器分流两种内嵌写法:
jsx
// src/base/plugin/component/ocx.js
class RealOcx extends Component {
render () {
if (browserInfo.ie) {
return (
<object ref={this.ocxRef} classID={'CLSID:' + caps.Classid} width="100%" height="100%">
<param name="lVideoWindNum" value="1"/>
<param name="VideoWindBarColor" value="414141"/>
</object>
);
} else {
return (
<embed ref={this.ocxRef}
type={'application/media-plugin-version-' + caps.PluginVersion}
VideoWindBarColor="414141" />
);
}
}
}
IE 走 <object classID="CLSID:...">(ActiveX COM 控件,靠 CLSID 在注册表里找控件),其他老浏览器走 <embed type="application/media-plugin-version-xxx">(NPAPI,靠 MIME 类型匹配插件)。两种都是"浏览器内嵌控件"形态,调用方式都是操作这个 DOM 对象的方法(ocx.PostMessage(data))。
为什么要在代码里备着三套?因为不同年代的浏览器认不同的插件形态 ------IE 只认 ActiveX、古早 Chrome 只认 NPAPI、现代浏览器两者都不认。代码必须同时备着三套实现,按浏览器识别分流到对应路线。门面 api/index.js 对上暴露统一的 plugin.open / plugin.playBack / plugin.hide,内部按 playMode 路由给对应实现,业务代码完全不感知底下跑的是哪种形态。
4.3 识别浏览器,选路线
核心是两处探测代码,识别完浏览器就分流到对应路线。
第一处 component/index.js 选 playMode,决定整体走哪条路:
js
// src/base/plugin/component/index.js
if (CLOUDWEB || (browserInfo.chrome && browserInfo.version >= 45) ||
(browserInfo.firefox && browserInfo.version >= 46) ||
(browserInfo.safari && browserInfo.version >= 12)) {
this.playMode = 'h5'; // 现代浏览器:H5 兜底 + 高版本插件探测升级
} else {
this.playMode = 'ocx'; // 老 IE 等:直接老 ActiveX 插件
}
第二处 api/index.js 的 isHighBroser 判断"能不能走高版本插件路线",并刻意排除 Edge:
js
// src/base/plugin/api/index.js
if (((browserInfo.chrome && browserInfo.version >= 45) || (browserInfo.firefox && browserInfo.version >= 46))
&& (navigator.userAgent.indexOf('Edge') === -1)) {
return true;
}
三个识别细节值得说:
>= 45是 NPAPI 死亡线:Chrome 45 移除 NPAPI,所以 Chrome 45+ 走不了内嵌控件、只能走 H5 + 高版本插件;低于这条线的老浏览器(老 IE)走老 ActiveX。这个数字不是随手写的,是按插件生态的死亡线画的分界。- 排除 Edge :Edge 的 UA 里含 "Chrome/Edg" 字样,不排除会被
browserInfo.chrome误判成 Chrome,走上它不支持的路径。UA 特征混淆是能力探测的经典陷阱------套壳浏览器同样带着 Chrome 字样,"UA 含 Chrome 就当 Chrome"在国产浏览器环境几乎必然出错。 CLOUDWEB强制 H5:云端版被 iframe 嵌套部署,业务约束优先于浏览器能力,再新的浏览器也不能走需要本地进程的插件路线。
分流结果就三条路线:现代浏览器 → H5 兜底 + 高版本插件探测;老 IE → 老 ActiveX;云端 → 强制 H5。下面逐条讲怎么兼容。
4.4 每条路线的兼容细节
路线一:现代浏览器(H5 兜底 + 高版本插件探测升级)
现代浏览器默认先用 H5 顶着------保证页面一进来就有视频、不卡死。同时后台探测有没有装高版本插件,装了就升级到插件播放(插件解码能力更强、支持本地录像下载等 H5 做不到的功能)。探测把"插件能不能用"拆成三个独立问题,分别探测、分别兜底:
js
// src/base/plugin/api/index.js
// 问题 1:服务在不在------连本地 WebSocket 服务
var checkSocket = new WebSocket('ws://127.0.0.1:' + caps.SocketPort);
// 问题 2:版本够不够------连上发 GetVersion 协商
checkSocket.onopen = function () {
checkSocket.send(JSON.stringify({ method: 'GetVersion', id: 0, params: '' }));
};
// 问题 3:服务起没起------连不上用自定义协议把本地进程拉起来
checkSocket.onclose = function () {
var ele = document.createElement('iframe');
ele.src = 'WebSocketServer64://'; // 注册表注册的自定义协议,唤起插件进程
ele.style.display = 'none';
document.body.appendChild(ele);
};
三个问题的处理逻辑:连得上就发 GetVersion 比对版本;连不上(服务没起)就插一个隐藏 iframe,用 WebSocketServer64:// 这个自定义协议(靠 Windows 注册表注册的协议项)去拉起插件进程,然后循环重试。探测成功后 changePlayMode('ocx'),不刷新页面从 H5 热切到插件播放;探测失败或版本不匹配就停在 H5 兜底,不卡死。
路线二:老 IE(ActiveX 检测 + 下载提示 + 轮询)
老 IE 没法装高版本插件,只能走老 ActiveX。检测安装用 checkIEOcx,思路是"试创建 COM 对象 + 读版本 + 逐段比对":
js
// src/base/utils/util.js
export function checkIEOcx(ocxName, needVersion) {
let ieOcx;
try {
ieOcx = new window.ActiveXObject(ocxName); // 试创建 ActiveX 控件实例
localOcxVersion = ieOcx.GetVersion(); // 读已安装的版本
} catch (e) { /* 没装会抛异常 */ }
// 逐段比对版本号
if (parseInt(local[3]) >= parseInt(need[3]) && parseInt(local[2]) >= parseInt(need[2])) {
return true;
}
}
组件层(component/ocx.js)没检测到就渲染下载提示(图标 + 国际化文案),并每 5 秒轮询重试:
js
// src/base/plugin/component/ocx.js
startCheck () {
let checkHandler = () => {
if (checkIEOcx(caps.PluginName, caps.PluginVersion)) {
this.setState({ isInstall: true }); // 装好了,切换到渲染 <object>
} else {
checkPoint = setTimeout(checkHandler, 5000); // 没装,5 秒后再查
}
};
}
用户点开下载、装完插件,页面不刷新,轮询自动检测到并恢复,渲染 <object> 嵌入控件。
路线三:状态机管理切换和兜底
三条路线之间靠状态机管理"当前实际用哪条实现",因为存在"装了插件但先用 H5 顶着"的中间态:
js
// src/base/plugin/api/index.js
this.isLower = plugin.playMode === 'ocx' && !plugin.highOcx; // 是否走老低版本 OCX
curPlugin = plugin.isLower ? OCX : H5; // 实际调哪套实现
isLower(老 OCX)/ highOcx(高版本插件)/ playMode / curPlugin 四个变量协同管理。H5 永远是兜底------任何一步失败都退回 H5,每一条"不支持"的路径都有用户看得懂的出口(下载提示、版本不匹配提示),宁可降级,不可卡死。
五、踩坑实录
策略不能只有成功案例,三个真实翻车记录,各对应一层认知边界。
案例一:浏览器升级后,插件本地服务连不上了(安全模型收紧)。 用户明明装好了插件,代码也没变,页面却提示插件未安装、一片黑屏------插件装了、服务进程也在跑,网页就是连不上本地服务。这类问题的排查思路是逐个排除四个环节:插件是否真的装好(版本协商 GetVersion 能不能对上);浏览器侧是否就绪(协议注册是否有效,部分 ActiveX 类插件还要求访问域名在插件的信任站点列表里,换了域名会静默失败);本地服务进程是否在运行(任务管理器查进程,不在就查是不是被杀毒软件禁止了自启);最后才是调用协议是否被浏览器安全策略拦截。前三环项目里都有探测代码兜底,真正的根因落在第四环:浏览器升级后收紧了本地网络访问的安全策略,页面发往 ws://127.0.0.1 回环地址的 WebSocket 连接被静默拦截,控制台几乎什么都不留。
这和 NPAPI 被移除是同一个故事的新篇章:插件没坏,是浏览器单方面改了"页面能不能跟本地进程通信"的规则。应急解法两个:整站升级 HTTPS ,安全上下文下策略对本地访问放行;或者浏览器地址栏输入 chrome://flags/#local-network-access-check,把 Default 改为 Disabled 重启生效------说白了这是绕过浏览器安全策略,不是修复,flags 随时可能失效,只适合应急和内部环境。根本出路还是无插件 H5 化,把"页面与本地服务通信"这个依赖从架构里去掉。另一个经验:这类投诉不是前端 Bug 也不是插件 Bug,而是浏览器安全模型、本地服务、页面前端三方配合的问题,第一件事是发一份环境自检清单(浏览器版本、是否 HTTPS、插件进程在不在、控制台报错截图)让用户侧自查,再拉三方一起排。
案例二:构建工具链自身的不兼容------为什么构建脚本不走 babel? Jenkins 构建节点还跑着 Node 10,某个构建插件新版本用了可选链 ?.,而它被 webpack 配置文件直接 require,构建直接失败。这里要回答一个关键疑问:前面不是讲了 babel 会把 ?. 转译掉吗,为什么这次没保护住?
因为 babel 不是"全局语法翻译器",它只对 webpack 配置里 module.rules 声明要用 babel-loader 处理的文件生效(业务源码 src + node_modules),这些是要输出给浏览器的产物 。但 webpack 配置文件本身(webpack.config.prod.js)、构建脚本(scripts/*.js)、以及它们 require 的构建工具包,是构建程序自身的代码,由 Node.js 直接加载执行,根本不进 webpack 的 loader 流水线,babel 连碰都碰不到。
所以得分清构建过程的两个阶段,babel 只在后一个阶段里:
阶段一:启动构建程序(Node 直接执行,不经 babel)
跑构建命令时,Node 先要把"构建程序"本身跑起来。这一步是:执行 scripts/build.js → 它 require webpack.config.prod.js → 配置文件顶部又 require 了一堆构建工具。看项目真实配置文件开头:
js
// config/webpack.config.prod.js 顶部
const TerserPlugin = require('terser-webpack-plugin');
const { getLessVars } = require('@sdd-web/plugin-theme-generator');
const WebVersionInfo = require('@sdd-web/version-generate'); // ← 出问题就是这个包
const HappyPack = require('happypack');
这些 require 都是 Node 直接加载并执行的------Node 读这些包的源码、当场运行,不经过 webpack 的任何 loader,babel 根本插不上手。这一步的目的是"把工厂建起来"。
阶段二:处理业务源码(webpack + babel-loader,产出给浏览器)
工厂建起来后,webpack 才开始按 module.rules 里的规则,用 babel-loader 处理 src 和 node_modules 里的业务代码,翻译成 ES5 产物给浏览器。这一步才是 2.3 节讲的那套,babel 在这里发挥作用。
两条路对照一下就清楚:
| 阶段一(建工厂) | 阶段二(生产产品) | |
|---|---|---|
| 处理的代码 | webpack 配置、scripts、它们 require 的工具包 | src 业务源码 + node_modules |
| 谁来执行 | Node 直接 require 执行 |
webpack + babel-loader |
| 经不经 babel | 不经 | 经 |
| 语法能跑与否由谁决定 | Node 版本 | browserslist(目标浏览器) |
所以"路径 B 能不能跑只取决于 Node 版本"------指的是阶段一里那些被 Node 直接执行的代码,认不认 ?. 全看 Node 版本:Node 14+ 才原生支持可选链,Node 10 不支持。这正是下面 Node 10 出问题的地方。
Node 10 不支持可选链(要 Node 14+ 才原生支持),所以 require 一个用了 ?. 的包时,Node 在加载阶段就抛 SyntaxError,整个构建起都起不来,babel 根本没机会介入。教训:兼容边界不止在产物里,也在工具链的运行时里 ------升级依赖前先确认构建环境的 Node 版本。这和 2.3 节正好是对照关系:2.3 是"产物里用 ?.,babel 转译保护浏览器";这个案例是"构建工具自己用 ?.,babel 管不到,只能靠 Node 版本"。两条路径、两套兼容边界,都得顾。
案例三:运行时 less 编译撞上 CSP。 项目支持在线换肤,走 public/less.min.js 浏览器运行时编译 + less.modifyVars() 变量替换。less 运行时编译内部用 new Function,在带严格 CSP 的宿主环境里被拦截,结果是"页面无样式"。运行时编译类方案在选型时就要把 CSP 纳入考量。和案例一是同一类问题:安全模型收紧,杀的从来不是你的代码,是你依赖的那条通道。
六、总结:回到面试官那个问题
回头看整套策略,其实是一条决策链:
能在构建期消灭的(语法、API、CSS 前缀),用 browserslist + Babel 双通道 + 三级 Polyfill + PostCSS 消灭;消灭不了的(API 缺失、UA 混淆、行为差异),用统一封装层和 ESLint 约束隔离在工具模块里;封装不了的(插件依赖),用能力探测 + 双实现适配器 + 版本协商在运行期路由;实在不支持的,给用户明确的降级出口;最后,工具链自身也要满足运行环境约束。
这个回答之所以立得住,是因为每个结论都对应一段真实代码和一段真实历史:.includes 命中四百个文件说明"编译期防护一撤全项目白屏";JSBI.BigInt(...) 说明有些特性只能换写法不能转译;>= 45 的版本阈值背后是 NPAPI 的死亡线;WebSocketServer64:// 的 iframe 背后是插件方案的过渡期;插件本地服务被静默拦截的背后,是三分法里最容易被忽略的"其他类别"------内核层之上、浏览器产品层的安全策略。
浏览器杀插件的历史告诉我们:今天默认存在的能力,明天可能整体消失。能在架构上为"能力消失"留好退路的系统,才是真正兼容的系统。