解决 Polyfill 冗余:兼容性策略与优化实践

177 阅读12分钟

TL;DR

在公司内重构 toC 项目架构过程中,发现 Polyfill 冗余与兼容性策略不明确的问题,影响了性能与代码体积。通过调研行业最佳实践与内部优化,提出一套内部合适的 Polyfill 处理方案:

  1. 现代模式插件:针对不同浏览器版本区分构建,优化体积。
  2. Polyfill 管理:通过工具收集一方库依赖的 Polyfills,统一管理并动态注入,减少重复与膨胀。
    最终选择更轻量的声明式方式输出 Polyfill 依赖,兼容不同工具链并确保良好的维护性。
  3. browserlist 更新: 针对不同应用的使用场景,提供不同的浏览器兼容标准列表。

1. 背景

最近在重构公司内 toC 项目架构当中发现,部分一方库自带的 polyfills 与项目中手动引入的 polyfills 存在重叠,导致代码体积增加并影响页面加载性能;另外每个项目的兼容性策略和依据不明确。为了解决这些问题,我们开展了调研,旨在梳理清晰的兼容性需求,并制定适合我们的统一实施方案。在此之前,需要对相关背景知识进行充分了解。

1.1. 语法降级和 API 降级

语法降级是指利用转译器对高版本的语法转换至低版本的语法。

如:

const foo = {};
foo?.bar();

// 转译后的结果
var foo = {};
foo === null || foo === void 0 ? void 0 : foo.bar();

转译后代码的语法将会改变,但是代码本身意义没有变,并且语法是无法通过 polyfill 或者 shims 进行支持的。也就是说,如果在低版本浏览器中运行他不支持的语法,将会报语法错误,如以上代码在低版本浏览器执行:

现在我们的项目中常用的转译器:

  • Typescript
  • Esbuild

API Polyfill 是指对代码所调用宿主环境(浏览器、Node 等)的内置方法进行实现。随着 ECMAScript 的发展,宿主环境会迎来新的方法,如: ES2021 的 replaceAll 。

''.replaceAll('', '')

这段代码如果在旧版本的浏览器中运行,会出现:

我们需要引入了相关实现该方法的代码才能在旧版本浏览器运行起来。

我们项目中常用的 polyfill 工具:

  • Babel
  • SWC

这两个工具虽然不仅包括 Polyfill 能力,但是他们对 API Polyfill 都使用了 core-js ,core-js 是真正实现这些 降级 API 的库。

2. 业界方案实现

2.1. Vue CLI

他利用 Vue CLI 的内置 babel 预设 @vue/babel-preset-app 对 babel 进行配置。但是,在 ES 发展过程中,现代浏览器都支持了原生 ES6,所以逐渐不想要支持更老的浏览器,进而业界构建框架推出了构建的现代模式帮我们解决问题:

  1. 会构建两遍,一个现代的包,面向支持 ES modules (chrome >= 61) 的浏览器,另一个包面向低版本的浏览器(chrome < 61)
  2. 现代版的包会通过 <script type="module"> 在被支持的浏览器中加载;旧包会通过 <script nomodule>

接下来我们来看一下他的代码实现

2.1.1. modern 模式识别

// We'll no longer need this logic in Babel 8 as it's the default behavior
// See discussions at:
// https://github.com/babel/rfcs/pull/2#issuecomment-714785228
// https://github.com/babel/babel/pull/12189
function getIntersectionTargets (targets, constraintTargets) {
  const intersection = Object.keys(constraintTargets).reduce(
    (results, browser) => {
      // exclude the browsers that the user does not need
      if (!targets[browser]) {
        return results
      }

      // if the user-specified version is higher the minimum version that supports esmodule, than use it
      results[browser] = semver.gt(
        semver.coerce(constraintTargets[browser]),
        semver.coerce(targets[browser])
      )
        ? constraintTargets[browser]
        : targets[browser]

      return results
    },
    {}
  )

  return intersection
}

function getModuleTargets (targets) {
  const allModuleTargets = getTargets(
    { esmodules: true },
    { ignoreBrowserslistConfig: true }
  )

  // use the intersection of modern mode browsers and user defined targets config
  return getIntersectionTargets(targets, allModuleTargets)
}

将支持 esmodules 的 targets 和当前设置的 targets 取交集。

2.1.2. 设置 polyfills 方法

const {
  default: getTargets,
  isRequired
} = require('@babel/helper-compilation-targets')

function getPolyfills (targets, includes) {
  // if no targets specified, include all default polyfills
  if (!targets || !Object.keys(targets).length) {
    return includes
  }

  const compatData = require('core-js-compat').data
  return includes.filter(item => {
    if (!compatData[item]) {
      throw new Error(`Cannot find polyfill ${item}, please refer to 'core-js-compat' for a complete list of available modules`)
    }

    return isRequired(item, targets, { compatData })
  })
}

// ...省略代码
  // included-by-default polyfills. These are common polyfills that 3rd party
  // dependencies may rely on (e.g. Vuex relies on Promise), but since with
  // useBuiltIns: 'usage' we won't be running Babel on these deps, they need to
  // be force-included.
  let polyfills
  const buildTarget = process.env.VUE_CLI_BUILD_TARGET || 'app'
  if (
    buildTarget === 'app' &&
    useBuiltIns === 'usage' &&
    !process.env.VUE_CLI_BABEL_TARGET_NODE
  ) {
    polyfills = getPolyfills(targets, userPolyfills || defaultPolyfills)
    plugins.push([
      require('./polyfillsPlugin'),
      { polyfills, entryFiles, useAbsolutePath: !!absoluteRuntime }
    ])
  } else {
    polyfills = []
  }

通过 babel 的 helper 方法先校验再集中了所有合法的 polyfills 数据,随后添加一个自定义添加导入语句的 polyfillsPlugin 。

2.1.3. polyfillsPlugin 实现

const { addSideEffect } = require('@babel/helper-module-imports')

// slightly modifiled from @babel/preset-env/src/utils
// use an absolute path for core-js modules, to fix conflicts of different core-js versions
// TODO: remove the `useAbsolutePath` option in v5,
// because `core-js` is sure to be present in newer projects;
// we only need absolute path for babel runtime helpers, not for polyfills
function getModulePath (mod, useAbsolutePath) {
  const modPath =
    mod === 'regenerator-runtime'
      ? 'regenerator-runtime/runtime'
      : `core-js/modules/${mod}`
  return useAbsolutePath ? require.resolve(modPath) : modPath
}

function createImport (path, mod, useAbsolutePath) {
  return addSideEffect(path, getModulePath(mod, useAbsolutePath))
}

// add polyfill imports to the first file encountered.
module.exports = (
  { types },
  { polyfills, entryFiles = [], useAbsolutePath }
) => {
  return {
    name: 'vue-cli-inject-polyfills',
    visitor: {
      Program (path, state) {
        if (!entryFiles.includes(state.filename)) {
          return
        }

        // imports are injected in reverse order
        polyfills
          .slice()
          .reverse()
          .forEach(p => {
            createImport(path, p, useAbsolutePath)
          })
      }
    }
  }
}

手动将 core-js 和 regenerator 的 polyfills 插入到入口文件当中。这样做的同时将 babel 配置的 exclude 添加上我们需要的 polyfills ,防止重复导入 polyfills 。

  const envOptions = {
    // ...
    exclude: polyfills.concat(exclude || []),
  }

2.1.4. 使用必要的 babel 插件

剩下部分就是常规的组合需要的 babel 插件。


  // cli-plugin-jest sets this to true because Jest runs without bundling
  if (process.env.VUE_CLI_BABEL_TRANSPILE_MODULES) {
    envOptions.modules = 'commonjs'
    if (process.env.VUE_CLI_BABEL_TARGET_NODE) {
      // necessary for dynamic import to work in tests
      plugins.push(require('babel-plugin-dynamic-import-node'))
    }
  }

  // pass options along to babel-preset-env
  presets.unshift([require('@babel/preset-env'), envOptions])

  // additional <= stage-3 plugins
  // Babel 7 is removing stage presets altogether because people are using
  // too many unstable proposals. Let's be conservative in the defaults here.
  plugins.push(
    require('@babel/plugin-syntax-dynamic-import'),
    [require('@babel/plugin-proposal-decorators'), {
      decoratorsBeforeExport,
      legacy: decoratorsLegacy !== false
    }],
    [require('@babel/plugin-proposal-class-properties'), { loose }]
  )

  // transform runtime, but only for helpers
  plugins.push([require('@babel/plugin-transform-runtime'), {
    regenerator: useBuiltIns !== 'usage',

    // polyfills are injected by preset-env & polyfillsPlugin, so no need to add them again
    corejs: false,

    helpers: useBuiltIns === 'usage',
    useESModules: !process.env.VUE_CLI_BABEL_TRANSPILE_MODULES,

    absoluteRuntime,

    version
  }])

  return {
    sourceType: 'unambiguous',
    overrides: [{
      exclude: [/@babel[/|\\]runtime/, /core-js/],
      presets,
      plugins
    }, {
      // there are some untranspiled code in @babel/runtime
      // https://github.com/babel/babel/issues/9903
      include: [/@babel[/|\\]runtime/],
      presets: [
        [require('@babel/preset-env'), envOptions]
      ]
    }]
  }

2.2. UA Polyfill

通过 Node 服务识别 UA 提供一个 Polyfill 服务,自动生成 Polyfill 文件。

优势:

  1. 不会插入到代码中,只根据访问页面的设备按需下发 Polyfill 代码,减少整体的代码体积
  2. 相同浏览器会公用一份 Polyfill,因此随着项目越来越多,下发速度会越来越快(跳过分析 UA 步骤)

劣势:

  1. 增加服务器压力
  2. 被大量魔改的国内浏览器厂商 UA 可能无法判断,只能下发兜底的 Polyfill

3. 方案实施与对比

我在 rsbuild 中实现了 modern 模式插件,并在真实项目中根据 chrome >= 51 作为浏览器兼容性标准,判断 modern 模式的收益,结果如下:

modern 模式 polyfill 体积:

legacy 模式 polyfill 体积:

由此可见,针对 Chrome 51 至 Chrome 61,需引入的 polyfill 体积差异为 21.7 KB(gzip 后为 7.9 KB)。经过讨论,我们认为这种优化对 Chrome >= 51 的收益较低,因此最终放弃了该方案。不过,这不影响我们实施现代模式插件中得到的宝贵经验,未来有机会可以进行分享这个插件。

3.1. 库如何处理 polyfills

在我们的业务场景中存在这样的情况:部分一方包生产时单独部署 CDN 并通过 script 引入,不参与项目构建步骤(配置 externals)。目的是为了跨项目跨页面可以缓存这些 JS 资源。因此会存在无法使用传统的 transpileDependencies或者是 rsbuild的 source.include配置编译一方库。之前我们的做法是将一方库编译成目标产物(ES5),保证无兼容性问题,但是经过测试发现是否 polyfill 会导致一方库的体积膨胀了接近 50%。core-js 的 polyfill 占用了包体积的 47.8%。

未 polyfill 体积增加 polyfill 后体积
库 ARendererd:210.22kb (-41.93%)Gzip:64.75kb (-49.10%)Rendererd:361.97kbGzip:127.21kb

在我看来这些膨胀的体积大部分是不必要的,因为包的 polyfill API 会在 Web 应用项目中重复 polyfill。针对重复引入的问题我大概有两种解法:

  1. 利用 @babel/plugin-transform-runtime将所需要的 polyfill 转换 @babel/runtime引入语句或者使用 @babel/preset-env 将 polyfill 转换成 core-js引入语句,随包产物一起发布。
  2. 包构建仅进行语法转译,并在发布前在 package.json 或同步发布的 meta.json 中声明这个包所需要的 polyfills,然后在应用项目中追加 polyfills。

我最终采取第二种解决方案,原因如下:

  1. 在 Vue CLI 中有这样的选项可以手动导入需要注入的 polyfills:

同样地,这个选项存在于 @babel/preset-env和 swc:

我们可以很方便地注入我们所需要的 polyfills 。

  1. 如果直接注入 import 'core-js/modules/esnext.set.map'; ,会导致我们的库产生 sideEffects,以至于此库丢失 tree-shaking 。当然这也能解决,分别给 CDN 用户和普通 ESM 引入的用户提供两个不同的产物,但这中间也会产生一定的理解成本。
  2. 现在项目中未必都使用 babel,可能存在 swc ,因此方法一也不能通用。

3.2. 构建时输出 polyfills

按照上一步的思路,我们的核心流程是:

  1. 收集一方库产物需要使用的 polyfills
  2. 写入 package.json 或者自定义的 json 文件,只要满足跟随库一起发布即可。

3.2.1. 收集 polyfills

通常在 JS 库中注入 polyfills 时我们会利用 babel 进行降级。以 rollup 作为打包器为例,我们会使用 rollup-plugin-babel ,我在 rollup 中配置 external: [/core-js/]让产物携带的 polyfills 更易见一些。JS 产物大致是这样的:

import 'core-js/modules/es.object.to-string.js';
import 'core-js/modules/es.regexp.to-string.js';
import 'core-js/modules/es.array.concat.js';
// 你的其他模块引入以及源代码...

可以看到,我们想要的 polyfills 是es.object.to-stringes.regexp.to-string 这部分字符串。到这里我实现收集的思路有两条:

  1. 给现有的一方库增加 rollup-plugin-babel 插件参与编译,并且对core-js进行 external,随后收集并提取所有的 core-js import 语句,最后对这部分语句进行删除。
  2. 在生成 bundle 后再对产物执行 babel transform,随后收集并提取所有的 core-js import 语句。

最终我采取了第 2 个思路,原因是因为方案 1 配置侵入性强、手动修改产物的操作容易出现不可控行为、每个模块的编译时间都会增加。

核心代码如下:

    // 省略其他 hook
    async generateBundle(_, bundle) {
      for (const [fileName, chunkOrAsset] of Object.entries(bundle)) {
        // 只处理代码块(chunk),忽略静态资源
        if (chunkOrAsset.type === 'chunk') {
          const { code, modules } = chunkOrAsset

          const source = await transformAsync(code)

          // 查找 core-js 导入
          const importRegex = /import\s+.*?['"]core-js(.*?)['"]/g
          let match
          while ((match = importRegex.exec(source.code)) !== null) {
            coreJsImports.add(`core-js${match[1]}`)
          }

          // 处理 require(兼容 CommonJS 风格)
          const requireRegex = /require(['"]core-js(.*?)['"])/g
          while ((match = requireRegex.exec(source.code)) !== null) {
            coreJsImports.add(`core-js${match[1]}`)
          }
        }
      }

      const polyfills = Array.from(coreJsImports).map(p => p.replace(/^core-js/modules/|.js$/g, ''))
      const newPolyfills = Array.from(polyfills).sort()
    }

我们只针对 js 产物进行 transform,然后通过正则表达式来匹配 import 语句以及 require 语句,收集并处理成我们要的数组。

3.2.2. 写入 package.json 或自定义 meta.json

这一步就很简单了,目的是为了发布库后,可以让使用的用户能将 polyfills 注入到应用项目的 babel/swc 配置当中。核心代码:

const pkgPath = path.resolve('./package.json')
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
pkg.polyfills = newPolyfills

fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2), 'utf-8')

// 计算增加和减少的 polyfill 数量
const added = newPolyfills.filter(polyfill => !prevPolyfills.has(polyfill))
const removed = Array.from(prevPolyfills).filter(polyfill => !polyfills.includes(polyfill))

console.log(`Polyfills 更新完成,总量为 ${polyfills.length} :
新增的 polyfills: ${added.length ? added : '无'}
移除的 polyfills: ${removed.length ? removed : '无'}
`)

这里我们可以统计每一次构建时 polyfills 的数量、体积等信息与上一次的做对比,作为日常迭代的防劣指标之一。

3.3. 定义合适的 browserlist

除了 polyfills 注入以外, polyfills 注入的 API 多少取决于 browserlist。我们在这期间重新梳理了我们所需要兼容的浏览器列表。

依据如下:

  1. 关键依据,根据业务页面的用户机型情况。
  2. 业界实践,其中比较有代表性的是 Philip Walton的博客。

他的博客中提到:

  • 默认情况下,绝大多数 bundler 和 build 工具不再转译为 ES5。同样值得注意的是,较新的工具根本不支持 ES5,这表明趋势正在朝着这个方向发展。
  • 通过对前 1w 名最受欢迎的网站进行分析,89% 的网站提供至少 1 个包含未转译的 ES6+ 语法的 JavaScript 文件,这些网站为用户提供同时包含 ES5 帮助程序和未转译的 ES6+ 语法的代码。

换句话说,有许多站点可能想要支持 ES5,但是他们没意识到他们一些依赖项发布了未转译的 ES6+ 代码;或者是这些站点不需要支持 ES5,但是他们的一些依赖项转译为 ES5。

  1. 客户端内/端外 toC browserlist
not dead
> 0.5%
Android >= 5
iOS >= 10
chrome >= 51
Firefox >= 51
UCAndroid >= 51
safari >= 10
Samsung >= 4
edge >= 15

2. 仅端外页面,微信支付宝环境 toC browserlist

微信版本 >= 8.0.32 (发布于 2023年1月)已经可以覆盖超过 98% 的用户,对应的小程序版本是2.30.4。

微信版本 >= 8.0.14 (发布于 2021年9月)已经可以覆盖 99.5% 的用户,对应小程序版本是 2.20.3。

根据3个月内的项目 APM 数据显示,最低版本的用户为 chrome 69 ,我们选择兼容 8.0.14 以上的微信版本即可,browserlist 如下:

Android >= 5
ios >= 11
Firefox >= 60
UCAndroid >= 66
chrome >= 66
safari >= 11
Samsung >= 8
edge >= 16
> 0.5%
not dead

browserlist 来自 miniprogram-compat

  1. Baseline

Baseline 是 W3C 中的 WebDX 社区组正在努力帮助开发人员轻松识别稳定且受到桌面和移动设备上所有主要浏览器和浏览器渲染引擎的良好支持的功能。如果一项功能在所有四种主要浏览器的稳定版本中可用至少 30 个月,则认为该功能已广泛可用。使用它的主要好处是这个 browserlist 是一个移动目标,这意味着它不会像直接定义为 ES5 那样固定在过去的版本。

chrome >0 and last 2.5 years
edge >0 and last 2.5 years
safari >0 and last 2.5 years
firefox >0 and last 2.5 years
and_chr >0 and last 2.5 years
and_ff >0 and last 2.5 years
ios >0 and last 2.5 years

总结

经过我们的方案实施后,这类 ES5 产物的一方库可以降低 20% 左右的体积,并且避免了 polyfills 多次注入到用户的应用程序当中。另外给公司内的类库开发与使用提出了一个合适的标准:

库类型处理方案
公司内部库,独立 CDN不参与应用程序构建,提供所需的 polyfills 数组,应用程序使用时引入
公司内部库参与应用程序构建,将此库进行编译,会增加少许应用程序编译时间
开源三方库参与应用程序构建 ,调研过程中查阅文档与 issue,判断开源库是否已经进行 polyfills 、进行 polyfils 的浏览器版本是否符合内部预期。符合预期,不编译此库;不符合预期,编译此库。

此外为了提升性能并确保合理的兼容性,我们根据用户设备分布和行业趋势重新定义了自身 browserlist 配置。通用场景覆盖主流现代 ES6 浏览器,微信等端外页面基于版本分布优化,而 Baseline 配置聚焦长期稳定支持功能,避免依赖固定标准,兼顾动态与高效的兼容策略。

结语

如果你对本文的内容感兴趣,或者想了解更多关于前端开发,欢迎关注我的公众号 Label 也是小猪呀

扫码_搜索联合传播样式-标准色版.png

相关资料

rsbuild 浏览器兼容性介绍 rsbuild.dev/zh/guide/ad…

微信小程序 miniprogram-compat github.com/wechat-mini…

polyfills 讨论 github.com/w3ctag/poly…

philip walton 博客 philipwalton.com/articles/us…

philip walton 博客 philipwalton.com/articles/th…