跳到主要内容

Vite解析内建模块错误

· 阅读需 13 分钟

1、背景

这两年我所在的技术团队受到教育和互联网双重 Debuff 加持,团队人数从 300+减少到 20+,期间接手了很多前端项目,也遇到很多问题。比如有些项目本地启动速度很慢,有些项目配置复杂、过度封装,一系列问题使得开发体验很差。

为了提升开发体验,我在项目中引入 Vite 用于开发调试(不用于构建)。配置好之后启动项目,页面白屏了,浏览器控制台输出这个错误:Module "crypto" has been externalized for browser compatibility

image.png

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 代码而非原始代码,另外这份代码在浏览器中可正常运行。

image.png

对内建模块而言无法完全满足上述条件。首先,内建模块底层调用的是 C++,转译为 JavaScript 的开发成本较高,甚至可能无法转译;其次,即使可以全部转译,内建模块需要 Node.js 运行时环境才能正常运行。

所以 Vite 能咋办,要么自动 polyfill 内建模块让开发者无感,要么抛错提示开发者进行处理。Vite 选择了抛错,原因应该是时代不断进步没必要一直背着历史包袱,可参考 Webpack 官网博客中文版)。

之后进一步查阅 Vite 源码,发现处理内建模块时有一段特殊逻辑。其中,解析逻辑 是返回一个特殊的虚拟资源标识,加载逻辑 是返回一段定制的抛错代码(错误内容与开篇的抛错截图一致)。 image.png

image.png

需要注意的是,Vite 解析内建模块的错误不是在系统终端抛出,而是把抛错代码返回给浏览器,由浏览器抛出错误。这点与开篇的抛错截图相符。

2.2、Webpack 为什么不报错

文章开头提到项目原先使用 Webpack 启动开发服务不会报错,原因是项目原先使用的是 Webpack4,而 Webpack5 之前会自动 polyfill 内建模块,因此不报错。

随着时代发展情况发生了变化,从 Webpack 官网博客中文版)可以看到,Webpack5Vite 采取一样的策略,不再自动 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 内建模块这个变更是否属实,分别使用 Webpack4Webpack5 构建下面的 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 构建成功,结果如下: image.png

Webpack5 构建失败,结果如下: image.png

可以看到 Webpack5 之后确实不再自动 polyfill Node.js 内建模块。我只是恰好在引入 Vite 时踩坑了,如果项目要升级到 Webpack5 必然也会踩到这个坑。

2.3、如何 polyfill

既然 Webpack 早就实现了这个功能,那这里就以 Webpack 为例简单说明如何 polyfill

Webpack 在编译代码时借助 node-libs-browser 把内建模块替换成兼容浏览器的第三方包(具体代码参见 Github),使其既能在 Node.js 中运行又能在浏览器中运行。简单点说就是,如果项目中使用了内建模块 cryptoWebpack 会在编译时使用兼容浏览器的包 crypto-browserify 去替换它。

node-libs-browserWebpack 团队开发的工具包,下图是这个包为内建模块与第三方兼容包建立的对应关系:

image.png

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 服务也有比较多选择,比如:jsdelivresm.sh 等,只是国内访问这些服务都不容易。

方案 4、安装依赖使用别名

在项目根目录下安装 crypto-browserify,并将其目录名改为 crypto,编译时就会使用 node_modules/crypto 下的文件,实测可以正常启动项目。这里有很多改法,提供最简单的一个:

  • 安装依赖:npm install crypto@npm:crypto-browserify --save

这行命令的效果是:安装名为 crypto-browserifynpm 依赖,并且使用 crypto 作为目录名。

除了以上方案还有其他方案,比如定制 Vite 插件修改依赖的解析结果,还可以结合 import maps。玩法很多,可以自由发挥。

4、如何寻找兼容包

以上方案其实本质上都是使用兼容浏览器的第三方包替代 Node.js 内建模块,那如何找到合适的兼容包呢?有 3 个途径:

5、总结

Vite 不支持自动 polyfill Node.js 内建模块,浏览器端代码中引用内建模块可能会导致其报错。解决方案有:不使用内建模块、编译时替换依赖、编译时替换依赖 + ESM 服务、安装依赖使用别名等。这些方案本质上都是使用兼容浏览器的第三方包替代内建模块。寻找兼容包可以参考 Webpack5 之前的官方 polyfill 方案、Rollup 插件的 polyfill 方案、以及 browserify

此外,如果老项目需要升级到 Webpack5,可能也会遇到这个问题,解决方案可以参考本文与官网说明。