JavaScript兼容性问题检查与修复实践

2,381 阅读12分钟

本文主要记载了针对js api兼容性检查的与借助工具和polyfill对项目中兼容性问题修复的一次实践。

一、引言:为什么要做兼容性问题检查?

相信很多做前端开发的小伙伴都知道网站caniuse,在该网站上面我们可以查询js api针对不同浏览器的不同版本的兼容性。如下图,我们通过网站查询到了Object.values兼容性。

image.png 但是在开发阶段,单单靠人工保证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调用。如下图

image.png

如果我们在.eslintrc文件的polyfills中加入fetch api,则报错不会产生,如下

// .eslintrc
{
    // ...
    "settings": {
        "polyfills": ["fetch"]
      }
}

image.png

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() 方法将会被该插件告警:

image.png

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作为唯一性文件扩展名),则会报如下错误。

image.png

注意注意注意:同时针对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文件,如果不指定,则配置将被合并。此后新增的配置文件的选项优先于.eslintrcpackage.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文件夹):

image.png

这个时候当我们执行一个代码commit,则会先执行npm run lint,如果存在兼容性问题,则会报错退出,如下图。

image.png

关于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中不需要指定useBuiltInswebpack.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'
  • 优点

    • 一次性解决所有兼容性问题,而且是全局的,浏览器的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的话,依赖外部的服务总会有种不太可靠的感觉。

以上

参考文章: