本文主要记载了针对js api兼容性检查的与借助工具和polyfill对项目中兼容性问题修复的一次实践。
一、引言:为什么要做兼容性问题检查?
相信很多做前端开发的小伙伴都知道网站caniuse,在该网站上面我们可以查询js api针对不同浏览器的不同版本的兼容性。如下图,我们通过网站查询到了Object.values兼容性。
但是在开发阶段,单单靠人工保证api的兼容是不可靠的,更加可靠的方式是借助工具来进行自动化扫描。这样,我们不仅可以随时查看当前使用的api兼容性从而即时修改,还可以保证检测出来有兼容问题的api不会被提交到项目代码中。
二、自动化兼容性问题扫描
主要借助eslint-plugin-compat以及eslint-plugin-builtin-compat这两个插件完成
2.1 使用eslint-plugin-compat
eslint-plugin-compat是ESlint的一个插件,由uber工程师Amila Welihinda开发。它可以帮助我们发现代码中不兼容api,插件github地址
下面我们将会介绍如何在工程中接入eslint-plugin-compat
2.1.1 安装eslint-plugin-compat
$npm install eslint-plugin-compat --save-dev 或
$yarn add eslint-plugin-compat -dev
由于该插件仅需要在开发环境使用,这里的-dev是将插件安装到devDependencies
我们还可以顺便把依赖的browserslist和caniuse-lite一起安装了:
$npm install browserslist caniuse-lite --save-dev 或
$yarn add browserslist caniuse-lite -dev
2.1.2 修改ESlint配置
之后我们需要修改ESLint的配置,加上该插件的使用:
// .eslintrc.json
{
"extends": "eslint:recommended",
"plugins": [
"compat"
],
"rules": {
//...
"compat/compat": "error"
},
"env": {
"browser": true
// ...
},
"settings": {
"polyfills": [""]
}
// ...
}
settings中的polyfills放置打好的补丁名称,则可以防止报错。
2.1.3 配置目标运行环境
通过在package.json中增加browserslist字段来配置目标运行环境。e.g.
// package.json
{
// ...
"browserslist": [ "chrome 80", "safari 9", "ie 9", "ios 8"]
}
上面的值表示目标有运行环境为chrome版本80以上,safari版本9以上等等。当然,这里的目标运行环境的指定可以使用其他一些形式,填写格式遵循browserslist 所定义的一套描述规范。
browserslist收录有Android, Baidu, BlackBerry,Chrome, Edge, Explorer等市面上存在的大部分浏览器,除了可以指定浏览器指定版本外,还可以使用条件语法以及条件组合(即or, and , not),简单举几个🌰
- > 5%: 表示兼容全球用户统计比例 >5% 的浏览器版本。>=, <, <=均可使用
- > 5% in US:表示兼容美国用户统计比例 >5% 的浏览器版本。这里的US是美国的Alpha-2编码,也可以使用其他国家的Alpha-2编码。例如中国就是CN
- > 5% in alt-AS: 表示兼容亚洲用户统计比例 >5% 的浏览版本
- > 5% in my stats表示兼容自定义用户统计比例 >5% 的浏览器版本
- cover 99.5%: 表示兼容用户份额累计前99.5%的浏览器版本,后面同样可以添加US,alt-AS,my stats等。
- maintained node versions:所有官方还在维护的 Node.js 版本。
- current node:Browserslist 现在正在使用的 Node.js 版本。
- extends browserslist-config-mycompany:表示要兼容 browserslist-config-mycompany 这个 npm 包的查询结果。
- ie 6-8:表示要兼容 IE 6 ~ IE 8 的版本(即 IE 6、IE 7 和 IE 8)。
- Firefox > 20:表示要兼容 > 20 的 Firefox 版本。>=、< 及 <= 也都是可用的。
- iOS 7:表示要兼容 iOS 7 。
- Firefox ESR:表示要兼容最新的 Firefox ESR 版本。
- PhantomJS 2.1 and PhantomJS 1.9:表示要兼容 PhantomJS 2.1 和 1.9 版本。
- unreleased versions 或 unreleased Chrome versions:表示要兼容未发布的开发版本。后者则具体指明是要兼容未发布的 Chrome 版本。
- last 2 major versions 或 last 2 iOS major versions:表示要兼容最近两个主要版本所包含的所有小版本。后者则具体指明是要兼容 iOS 的最近两个主要版本所包含的所有小版本。
- since 2015 或 last 2 years:自 2015 年或最近两年到现在所发布的所有版本。
- dead:官方不再维护或者超过两年没有更新的浏览器版本。
- last 2 versions:每种浏览器的最近两个版本。
- last 2 Chrome versions:Chrome 浏览器的最近两个版本。
- defaults:Browserslist 的默认规则(> 0.5%, last 2 versions, Firefox ESR, not dead)。
- not ie <= 8:从前面的条件中再排除掉低于或者等于 IE 8 的浏览器。
在阅读这些规则的时候,推荐访问 browsersl.ist 输入相同的命令进行测试,可以直接得出符合条件的浏览器版本。
配置完成后,可以使用npx browserslist
来测试你配置的browserslist。
2.1.4 测试效果
完成了browserslist规则的配置后,我们就可以结合Eslint扫描工程中的API兼容问题。同时VSCode的ESLint插件也可以即时提示不兼容的API调用。如下图
如果我们在.eslintrc
文件的polyfills中加入fetch api,则报错不会产生,如下
// .eslintrc
{
// ...
"settings": {
"polyfills": ["fetch"]
}
}
2.2 使用eslint-plugin-builtin-compat
eslint-plugin-compat的原理是针对确认的类型和属性,使用caniuse的数据集caniuse-db以及MDN的数据集mdn-browser-compat-data里的数据来确认API的兼容性。但对于不确定的实力对象,由于难以判断该实例的方法和兼容性,为了避免误报,eslint-plugin-compat会选择跳过这类API的检查。
举个🌰,
const foo = [1,2,3]
foo.includes(1)
中includes方法的兼容性并不会被扫描出来。为了避免这种漏报的情况,我们可以结合另一个兼容检查插件eslint-plugin-builtin-compat。该插件同样借助mdn-browser-compat-data来进行兼容扫描,与之不同的是,该插件不会放过实例对象,因此它会把所有foo.includes
的includes方法当成是Array.prototype.includes()
方法来扫描。可想而之,这个插件可能会误报。因此建议将其设置为警告级别。
2.2.1 安装eslint-plugin-builtin-compat
$npm install eslint-plugin-builtin-compat --save-dev 或
$yarn add eslint-plugin-builtin-compat -dev
2.2.2 修改ESlint配置
与 eslint-plugin-compat
类似,我们可以修改 ESLint
的配置,加上该插件的使用。但由于该插件容易误报,因此只建议将其告警级别改为 warning
级别:
// .eslintrc.json
{
"extends": "eslint:recommended",
"plugins": [
"compat",
"builtin-compat"
],
"rules": {
//...
"compat/compat": "error",
"builtin-compat/no-incompatible-builtins": "warn"
},
"env": {
"browser": true
// ...
},
// ...
}
加入该插件后,可以发现 Array.prototype.includes()
方法将会被该插件告警:
2.2.3 配置eslint检查脚本
除了VScode插件的提醒外,我们可以通过脚本检查项目代码的兼容性问题,配置如下
// packages.json
{
//...
"scripts": {
"lint": "eslint .",
// ...
}
}
现在我们就可以通过npm run lint
来进行eslint检查。
eslint .
中的.
表示项目代码的全部文件,我们也可以指定检查文件夹。如eslint src
等等。eslint还支持很多其他配置,具体可参见eslint网站
eslint命令如果项目中有错误,则退出码为1。可以设置
eslint . ;exit 0
以0码退出,则不会在lint阶段任务就报错退出。
现在有个问题:如果我们想要跳过某些文件的检查,应该怎么办呢?
其实很简单,配置一个.eslintignore
文件即可,配置方式同.gitignore
。具体也可见eslint网站。
在下面的🌰中,我跳过了webpack,build文件夹的lint检查。
// .eslintignore
webpack
build
⚠这里需要注意一点,如果你在.eslintignore中设置的需要跳过的文件是所有会被lint的文件(默认情况下,eslint会使用
.js
作为唯一性文件扩展名),则会报如下错误。
注意注意注意:同时针对react项目,记得要使用
--ext .jsx,.js
增加文件扩展名,否则检查不到jsx中的api兼容哦!!!
⚠️关于eslint的配置还有最后一个比较重要的点,如果我们不想使用默认的.eslintrc
文件该怎么办呢? 比如目前我手上的一个项目代码在.eslintrc
文件里设置非常多的检查规则,但是由于实际并没有使用(又不敢删😭),我想将自己的兼容性检查与其分开,从而可以在提交阶段做提交限制。那么我就只能单独建一个自己的文件,将其命名为compat-eslint.json
,然后将前文中的兼容性设置复制一份到该文件中。并在eslint运行脚本处进行修改如下。
"scripts": {
"lint": "eslint --no-eslintrc -c compat-eslint.json . --ext .jsx,.js"
}
--no-eslintrc
表示不使用默认的.eslintrc
文件,如果不指定,则配置将被合并。此后新增的配置文件的选项优先于.eslintrc
和package.json
文件中的选项。
-c
表示允许为ESLint指定一个额外配置文件,后面接需要使用的配置文件即可。
还有一个特别需要注意的点:VScode插件对于不兼容api的识别规则是基于.eslintrc的,在我们自己的eslint文件中的规则是不会被vscode ESLint插件探查到的!
eslint网站中有很多强大的规则,我们不需要都记住,只需要在使用时,像查字典一样查一下即可。有问题,查文档!
三、兼容性问题的提交限制
至此,我们已经完成兼容性问题的自动化扫描,那么如何强制开发者处理完兼容性问题才能够提交呢?这个也是很简单的,利用上我们的gitHooks在commit前自动调用npm run lint
,由于默认的退出码为1,则检查报错就会退出当前命令的执行,也就无法进行下一步的commit操作了。
我使用的git hook工具是huksy,它的使用和配置非常简单。
3.1 首先安装husky
$npm install husky --save-dev 或
$yarn add husky -dev
3.2 配置husky
// package.json
{
//...
scripts: {
"prepare": "husky install"
// ...
}
}
执行一次npm run prepare
。
这个时候我们会发现目录下有个.husky
的文件夹,接着添加hook npx husky add .husky/pre-commit "npm run lint"
,我们就可以看到如下图所示(仅需关注.husky
文件夹):
这个时候当我们执行一个代码commit,则会先执行npm run lint
,如果存在兼容性问题,则会报错退出,如下图。
关于husky我们这里不做过多的展开,本文主要围绕js api的兼容性问题。在之后的一部分,我们将会来解决这些兼容性问题,从而完成我们的代码提交。
四、使用polyfill解决问题
到达这一部分时,我们已经限制了具有兼容性问题的代码提交。但是如果仅仅是发现这些问题,而不去解决,那么我们的检查也是没有意义的。因此,我们需要为这些有兼容性问题的api补上相应的Polyfill。这样不仅可以为不兼容的浏览器提供支持,在开发过程中,我们也能够放心的去使用相应api,从而提高我们的开发效率。
4.1 手动打补丁
在前端工具还不繁荣的早期,我们需要手动引入,比如ES6的Object.assign
,在ie11上仍然会报错。所以需要手动引入补丁代码,一种是安装第三方包,另外一种则是引入mdn代码。
// 安装第三方包
npm i object-assign
// 引入
Object.assign = require('object-assign')
或者在需要的地方放置来自MDN的补丁代码
value: function assign(target, varArgs) { // .length of function is 2
'use strict';
if (target === null || target === undefined) {
throw new TypeError('Cannot convert undefined or null to object');
}
var to = Object(target);
for (var index = 1; index < arguments.length; index++) {
var nextSource = arguments[index];
if (nextSource !== null && nextSource !== undefined) {
for (var nextKey in nextSource) {
// Avoid bugs when hasOwnProperty is shadowed
if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
to[nextKey] = nextSource[nextKey];
}
}
}
}
return to;
},
writable: true,
configurable: true
});
}
4.2 自动打补丁 - babel-polyfill & polyfill.io
Babel(babeljs.io/)是一个广泛使用的转码…
因为这是一个 polyfill (它需要在源代码之前运行),我们需要让它成为一个 dependency(上线时的依赖),而不是一个 devDependency(开发时的依赖)。
4.2.1 使用转换插件: babel-plugin-transform-xxx
-
使用方法
- 缺啥补啥,在
package.json
添加所需的依赖babel-plugin-transform-xxx
- 在
.babelrc
中的plugins
项指定使用babel-plugin-transform-xxx
插件 - 代码中不需要显式
import/require
,.babelrc
中不需要指定useBuiltIns
,webpack.config.js
中不需要做额外处理,一切由babel插件完成转换
- 缺啥补啥,在
-
优点
- 作用域是模块,避免全局冲突
- 是按需引入,避免不必要引入造成及代码臃肿
-
缺点
- 每个模块内单独引用和定义polyfill函数,造成了重复定义,使代码产生冗余
配置完一个转换插件后, 代码中每个使用这个API的地方的代码都会被转换成使用
polyfill
中实现的代码
4.2.2 使用插件 babel-runtime 与 babel-plugin-tranform-runtime
相比方法1, 相当于抽离了公共模块, 避免了重复引入, 从一个叫core.js
的库中引入所需polyfill(一个国外大神用ES3写的ES5+ polyfill)
-
使用方法
package.json
中添加依赖babel-plugin-tranform-runtime
以及babel-runtime
.babelrc
中配置插件:"plugins": ["transform-runtime"]
- 接下来, 代码中可以直接使用ES6+的新特性,无需
import/require
额外东西,webpack
也不需要做额外配置
-
优点
- 无全局污染
- 依赖统一按需引入(polyfill是各个模块共享的), 无重复引入, 无多余引入
- 适合用来编写lib(第三方库)类型的代码
-
缺点
- 被
polyfill
的对象是临时构造并被import/require
的,并不是真正挂载到全局 - 由于不是全局生效, 对于实例化对象的方法,如
[].include(x)
, 依赖于Array.prototype.include
仍无法使用
- 被
4.2.3 全局babel-polyfill(不使用useBuiltIns)
-
使用方法
- 法3.1: (浏览器环境)单独在html的
<head>
标签中引入babel-polyfill.js
(CDN或本地文件均可) - 法3.2: 在
package.json
中添加babel-polyfill
依赖, 在webpack
配置文件增加入口: 如entry: ["babel-polyfill",'./src/app.js']
, polyfill将会被打包进这个入口文件中, 而且是放在文件最开始的地方 - 法3.3: 在
package.json
中添加babel-polyfill
依赖, 在webpack
入口文件顶部使用import/require
引入,如import 'babel-polyfill'
- 法3.1: (浏览器环境)单独在html的
-
优点
- 一次性解决所有兼容性问题,而且是全局的,浏览器的
console
也可以使用
- 一次性解决所有兼容性问题,而且是全局的,浏览器的
-
缺点
-
一次性引入了ES6+的所有polyfill, 打包后的js文件体积会偏大
-
对于现代的浏览器,有些不需要polyfill,造成流量浪费
-
污染了全局对象
-
不适合框架或库的开发
-
4.2.4 全局babel-polyfill(使用babel-preset-env插件和useBuiltIns属性)
@babel/preset-env 基于一些很厉害的项目:browserslist,compat-table,electron-to-chromium,......
@babel/preset-env 自动适配设定的目标环境需要的 js 特性,Babel 转换插件(不包含 stage-0/1/2/3的插件),corejs 的 poliyfill。自动适配可以有选择性的污染全局,当然也可以不分青红皂白全部转换到ES5,具体参见:babeljs.io/docs/en/bab…
你需要掌握在 webpack 中如何自定义配置 @babel/preset-env,掌握 @babel/preset-env + useBuiltIns 替代 @babel/polyfill 的配置。
三种方案:
- 方案1:安装@babel/polyfill;在
.babelrc
中配置useBuiltIns: 'usage'
- 方案2: 安装@babel/polyfill;在应用入口处引入
@babel/polyfill
;在.babelrc
中配置useBuiltIns: 'entry'
- 方案3: 安装@babel/polyfill;在
.babelrc
中配置useBuiltIns: 'false'
,这是默认行为;在webpack的入口添加@babel/polyfill,如import 'babel-polyfill'
优点:按需(按照指定的浏览器环境所需)引入polyfill
, 一定程度上减少了不必要polyfill
的引入,⚠️注意不可与上一个方法混用,否则会引起冲突。
我在这次项目中使用的就是该方法的方案1,配置如下:
// .babelrc
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "usage"
}
]
]
}
我没有配置类似在@babel/preset-env
中配置类似"target": {"browsers": ["ie >= 9"]}
之类的,因为在之前做兼容性检查的时候在package.json
中已经设置了browserslist了。
4.2.4 在线补丁工具:polyfill.io
动态打补丁仍然有一个最大的缺陷:补丁冗余。以 Object.assign
为例,在支持这个特性的浏览器上就没必要引入这个补丁,所以就会造成补丁的冗余。而社区就出现了根据浏览器特性动态打补丁的方案。
Polyfill.io 就是这样一个服务,它会根据浏览器 user-agent 的不同,返回不同的补丁代码。比如想加载 Promise
的补丁代码,可以直接引入:
<script src="https://polyfill.io/v3/polyfill.js features=Promise"></script>
我们可以通过修改chrome浏览器中的user-agent查看polyfill.io的返回。
另外,polyfill.io还开源了polyfill-service供我们自己搭建使用。
这种方式所加载的补丁量最少,如果就加载资源有极致的性能要求,是可以考虑基于polyfill.io自建polyfill服务,动态注入polyfill。但是就我自己的项目而言,必要性并不高,并且如果不部署一份到自己的cdn的话,依赖外部的服务总会有种不太可靠的感觉。
以上