从 Object.hasOwn 看 @vitejs/plugin-legacy

2022/12/4 jsvite

最近在处理一个项目的时候发现在某部 iPhone 的 Safari 上有页面无法正常访问,报错提示 Object.hasOwn 方法不存在。这基本上就是兼容性问题了,看了一下 iOS 的版本是 14.6,再一查 Object.hasOwn 在 iOS 上是从 15.4 才开始支持的。处理方案要么是把用了 hasOwn 的地方改掉(因为 Object.hasOwn 的平替方法很多,也不存在什么改的成本问题),要么就是在打包的地方配置兼容处理。如果兼容处理简单肯定是优先进行兼容处理,毕竟碰到不兼容的就改写起来也太折腾了。

object-has-own.jpg

项目是使用 vite 作为构建工具的,自然是找到了 @vitejs/plugin-legacy (opens new window) 来处理兼容问题。按照文档的配置走了一圈发现打出来的包还是不能用,然后就是怼着 target 属性在那改,发现不管把兼容的版本改到多低,或者添加更多的兼容浏览器都不起作用,后面仔细看过文档才发现对这个插件的默认作用产生了误解。

# @vitejs/plugin-legacy 做了什么

插件默认不是针对某某属性进行 polyfill 处理,而是为了使打包后的产物可以在旧版浏览器上运行。这是因为 Vite 默认的构建目标是能支持 原生 ESM 语法的 script 标签 (opens new window)原生 ESM 动态导入 (opens new window)import.meta (opens new window) 的浏览器,并且默认情况下 Vite 只处理语法转译,且默认不包含任何 polyfill。如果需要支持旧版浏览器就需要使用 @vitejs/plugin-legacy。

传统浏览器可以通过插件 @vitejs/plugin-legacy 来支持,它将自动生成传统版本的 chunk 及与其相对应 ES 语言特性方面的 polyfill。兼容版的 chunk 只会在不支持原生 ESM 的浏览器中进行按需加载。

使用 @vitejs/plugin-legacy 非常简单,执行 npm add -D @vitejs/plugin-legacy terser 安装后在 vite.config.ts 中直接使用即可。

import legacy from '@vitejs/plugin-legacy'

export default {
  plugins: [
    legacy({
      targets: ['defaults', 'not IE 11']
    })
  ]
}
1
2
3
4
5
6
7
8
9

配置完成之后执行打包命令,会发现打包后的产物除了之前的文件外还多了几个文件名中带有 legacy 的文件,这些文件就是用来做兼容处理的。

chunks.png

查看 index.html 文件中会发现这几个文件也在 script 中被加载,不过相较于加载普通 js 文件的 script 多了一个 nomodule 属性。

index-html.png

上述结果对应到插件文档中描述的:

  • 为每个 chunk 生成一个经过@babel/preset-env 转换的 legacy chunk
  • 根据 targets 中指定的浏览器以及实际使用到内容生成包含必要的 polyfill 以及 SystemJs 运行时的 polyfill chunk
  • 使用 <script nomodule> 加载 polyfill 和 legacy chunk
  • 插入 import.meta.env.LEGACY 环境变量,在 legacy 环境中为 true 否则就是 false。

# nomodule

发现了一个新东西,之前对 nomodule 不太了解,查找资料发现 nomodule 可以说是伴随着 ESM(script 的 type 为 module)一起出现的。

esm-caniuse.png

这个布尔属性被设置来标明这个脚本在支持 ES2015 modules (opens new window) 的浏览器中不执行。 — 实际上,这可用于在不支持模块化 JavaScript 的旧浏览器中提供回退脚本。

nomodule 是在 module 不受支持情况下的优雅降级方案,大致的原理就是支持 type="module" 的浏览器会忽略包含 nomodule 属性的 script 脚本执行;不支持 type="module" 的浏览器则会忽略 type="module" 脚本的执行,配置了 nomodule 属性的 script 由于没有指定 type 会被当作是 JavaScript 去执行,间接起到了浏览器不支持 module 的情况下降级执行 nomodule 对应的 script 的效果(PS:看到有说 iOS13 有 bug,会支持 ESM 但是不识别 nomodule 🙀)。

# modernPolyfills

弄明白 nomodule 大概是个什么东西以及插件定位和实现流程后就知道之前犯的错误是什么了,因为我们碰到了一种巧合的情况:如果在支持 ESM 的浏览器中使用了其不支持的属性会发生什么呢?

是的,很明显不会被兼容处理(或者说 polyfill 不会被加载),Object.hasOwn 就是一个很好的例子,iOS14.6 是支持 ESM 的,所以不会加载 polyfill,但是 14.6 不支持 Object.hasOwn,所以不管你怎么改 targets 中的浏览器版本都不会起作用。

插件应该是考虑到了有些特殊场景需要在支持 ESM 的现代浏览器也加载 polyfill,所以提供了 modernPolyfills 属性用于单独生成一个 polyfill chunk 在支持 ESM 的浏览器上也会被加载。

属性值为一个字符串列表用于指定包含哪些 polyfill,具体的 polyfill 可以是以下的值:

import legacy from '@vitejs/plugin-legacy'

export default {
  plugins: [
    legacy({
      modernPolyfills: ['es.promise.finally', 'es/map', 'es/set', 'es/object/has-own']
    })
  ]
}
1
2
3
4
5
6
7
8
9

⚠️注意:文档中非常不建议使用这个属性,因为配置之后不管什么情况下都会加载很多额外的内容(即 plyfill 相关的代码),即使要用也考虑使用 Polyfill.io (opens new window) 这种会根据浏览器按需加载的方案。

# 总结

实际上花了半天时间捣鼓打包为的兼容处理为什么不起作用的问题,最后处理方案还是把仅有的几个 Object.hasOwn 给改成了 lodashhas,毕竟兼容处理带来的收益比较起来不足以抵消带来的问题。不过这个过程肯定不算是白费了,知道了问题的原因了以后如果再碰到类似的问题也不会抓瞎,毕竟后面和 Vite 打交道的时间应该不会短。 同时这个也算是一次很有意思的教训,用一个东西如果只是按照文档表面模仿一遍那估计基本上用起来没问题,但是碰到具体问题就要抓瞎,如果想用好还是要认真的看文档、注重知识的积累。

# 参考