1、背景
这两年我所在的技术团队受到教育和互联网双重 Debuff
加持,团队人数从 300+减少到 20+,期间接手了很多前端项目,也遇到很多问题。比如有些项目本地启动速度很慢,有些项目配置复杂、过度封装,一系列问题使得开发体验很差。
为了提升开发体验,我在项目中引入 Vite
用于开发调试(不用于构建)。配置好之后启动项目,页面白屏了,浏览器控制台输出这个错误:Module "crypto" has been externalized for browser compatibility
。
Vite
配置与项目原先使用的 Webpack
配置基本一致,但是 Webpack
启动不会报错,这是什么情况。
2、问题分析
2.1、Vite 为什么报错
排查问题首先从错误栈看起,错误提示看起来不太直观,以前也没见过类似错误。然后顺着错误栈找到出错的主要代码:
import { createHmac } from "crypto";
createHmac("sha1", key).update(str2Sign).digest("base64");
可以看到这段代码使用了 Node.js
的内建模块 crypto
,跟错误提示所述一致,其他没啥问题。一套操作下来没看出问题所在,好在错误提示里挂了 Vite 官网说明链接(中文版),官网说明如下:
当你在浏览器中使用一个 Node.js 模块时,Vite 会输出以下警告: Module "fs" has been externalized for browser compatibility. Cannot access "fs.readFile" in client code.
这是因为 Vite 不会自动 polyfill Node.js 的内建模块。 我们推荐你不要在浏览器中使用 Node.js 模块以减小包体积,尽管你可以为其手动添加 polyfill。如果该模块是被某个第三方库(这里意为某个在浏览器中使用的库)导入的,则建议向对应库提交一个 issue。
一句话概括就是:浏览器中使用 Node.js
的内建模块会报错,因为 Vite
不会自动 polyfill
内建模块,解决方案是不要使用内建模块或者手动 polyfill
。
如果有踩坑经验,这个问题基本就到此结束了。而我只觉得这好像说了什么又好像什么都没说。到底为什么会报错?
实际上 Vite Server
要把一份 JS
类的资源响应给浏览器且能正常运行,至少需要满足两个条件:
- 1、原始代码能转译成
JavaScript
- 2、转译后的代码能在浏览器环境下正常运行
以实际的 Vite
项目为例,下图中 index.tsx
的文件内容是转译后的 JavaScript
代码而非原始代码,另外这份代码在浏览器中可正常运行。
对内建模块而言无法完全满足上述条件。首先,内建模块底层调用的是 C++
,转译为 JavaScript
的开发成本较高,甚至可能无法转译;其次,即使可以全部转译,内建模块需要 Node.js
运行时环境才能正常运行。
所以 Vite
能咋办,要么自动 polyfill
内建模块让开发者无感,要么抛错提示开发者进行处理。Vite
选择了抛错,原因应该是时代不断进步没必要一直背着历史包袱,可参考 Webpack 官网博客(中文版)。
之后进一步查阅 Vite
源码,发现处理内建模块时有一段特殊逻辑。其中,解析逻辑 是返回一个特殊的虚拟资源标识,加载逻辑 是返回一段定制的抛错代码(错误内容与开篇的抛错截图一致)。
需要注意的是,
Vite
解析内建模块的错误不是在系统终端抛出,而是把抛错代码返回给浏览器,由浏览器抛出错误。这点与开篇的抛错截图相符。
2.2、Webpack 为什么不报错
文章开头提到项目原先使用 Webpack
启动开发服务不会报错,原因是项目原先使用的是 Webpack4
,而 Webpack5
之前会自动 polyfill
内建模块,因此不报错。
随着时代发展情况发生了变化,从 Webpack 官网博客(中文版)可以看到,Webpack5
与 Vite
采取一样的策略,不再自动 polyfill
Node.js
内建模块。具体说明如下:
在早期,Webpack 的目的是为了让大多数的 Node.js 模块运行在浏览器中,但如今模块的格局已经发生了变化,现在许多模块主要是为前端而编写。Webpack <= 4 的版本中提供了许多 Node.js 核心模块的 polyfills,一旦某个模块引用了任何一个核心模块(如 cypto 模块),webpack 就会自动引用这些 polyfills。
尽管这会使得使用为 Node.js 编写模块变得容易,但它在构建时给 bundle 附加了庞大的 polyfills。在大部分情况下,这些 polyfills 并非必须。
从 Webpack 5 开始不再自动填充这些 polyfills,而会专注于前端模块兼容。我们的目标是提高 web 平台的兼容性。
迁移:
- 尽量使用前端兼容的模块。
- 可以手动为 Node.js 核心模块添加 polyfill。错误提示会告诉你如何实现。
- Package 作者:在 package.json 中添加 browser 字段,使 package 与前端兼容。为浏览器提供其他的实现/dependencies。
为了验证自动 polyfill
内建模块这个变更是否属实,分别使用 Webpack4
和 Webpack5
构建下面的 src/index.js
代码:
// src/index.js
import { createHmac } from "crypto";
createHmac("sha1", "secret").update("data").digest("base64");
Webpack
使用如下相同配置:
// webpack.config.js
const path = require("path");
module.exports = {
entry: "./src/index.js",
output: {
filename: "main.js",
path: path.resolve(__dirname, "dist"),
},
};
Webpack4
构建成功,结果如下:
Webpack5
构建失败,结果如下:
可以看到 Webpack5
之后确实不再自动 polyfill
Node.js
内建模块。我只是恰好在引入 Vite
时踩坑了,如果项目要升级到 Webpack5
必然也会踩到这个坑。
2.3、如何 polyfill
既然 Webpack
早就实现了这个功能,那这里就以 Webpack
为例简单说明如何 polyfill
。
Webpack
在编译代码时借助 node-libs-browser 把内建模块替换成兼容浏览器的第三方包(具体代码参见 Github),使其既能在 Node.js
中运行又能在浏览器中运行。简单点说就是,如果项目中使用了内建模块 crypto
,Webpack
会在编译时使用兼容浏览器的包 crypto-browserify
去替换它。
node-libs-browser
是 Webpack
团队开发的工具包,下图是这个包为内建模块与第三方兼容包建立的对应关系:
3、解决方案
Vite
官网给出的方案主要有两个方向:不使用内建模块或者手动 polyfill
。这里以 crypto
这个内建模块为例,整理出 4 个解决方案,其中第一个是不使用内建模块,其他则是手动 polyfill
。
方案 1、不使用内建模块
修改项目代码,将代码中引用的内建模块 crypto
全部替换为第三方兼容包 crypto-browserify
。改动如下:
- 安装依赖:
npm install crypto-browserify --save
- 替换依赖:将代码中引用的
crypto
全部替换为crypto-browserify
如果条件允许,推荐使用这个方案。
方案 2、编译时替换依赖
在 Vite
配置文件的 resolve.alias
选项中将 crypto
配置为 crypto-browserify
,达到编译时替换依赖的效果。改动如下:
- 安装依赖:
npm install crypto-browserify --save
- 指定
alias
:在Vite
配置文件的resolve.alias
选项中添加配置{crypto:'crypto-browserify'}
方案 3、编译时替换依赖 + ESM 服务
这是编译时替换依赖方案的升级版,利用 ESM
特性将 alias
目标配置为 ESM
服务提供的在线文件。改动如下:
- 指定
alias
:在Vite
配置文件的resolve.alias
选项中添加配置{crypto:'https://jspm.dev/crypto-browserify'}
这里省去了安装依赖的步骤,ESM
服务也有比较多选择,比如:jsdelivr、esm.sh 等,只是国内访问这些服务都不容易。
方案 4、安装依赖使用别名
在项目根目录下安装 crypto-browserify
,并将其目录名改为 crypto
,编译时就会使用 node_modules/crypto
下的文件,实测可以正常启动项目。这里有很多改法,提供最简单的一个:
- 安装依赖:
npm install crypto@npm:crypto-browserify --save
这行命令的效果是:安装名为 crypto-browserify
的 npm
依赖,并且使用 crypto
作为目录名。
除了以上方案还有其他方案,比如定制
Vite
插件修改依赖的解析结果,还可以结合import maps
。玩法很多,可以自由发挥。
4、如何寻找兼容包
以上方案其实本质上都是使用兼容浏览器的第三方包替代 Node.js
内建模块,那如何找到合适的兼容包呢?有 3 个途径:
- 参考
Webpack5
之前官方使用的自动polyfill
方案,在 node-libs-browser 中找兼容包 - 参考
Rollup
插件们的polyfill
方案寻找兼容包:rollup-plugin-polyfill-node、rollup-plugin-node-polyfills、rollup-plugin-node-builtins - 不少内建模块的兼容包来自于 browserify,也可以直接去这个 Github 组织首页中寻找
5、总结
Vite
不支持自动 polyfill
Node.js
内建模块,浏览器端代码中引用内建模块可能会导致其报错。解决方案有:不使用内建模块、编译时替换依赖、编译时替换依赖 + ESM 服务、安装依赖使用别名等。这些方案本质上都是使用兼容浏览器的第三方包替代内建模块。寻找兼容包可以参考 Webpack5
之前的官方 polyfill
方案、Rollup
插件的 polyfill
方案、以及 browserify
。
此外,如果老项目需要升级到 Webpack5
,可能也会遇到这个问题,解决方案可以参考本文与官网说明。