记录一次esm和commonjs互相调用报错后的分析
最近开发过程中碰到一次诡异的 webpack 相关报错。特此记录
01 问题发生
问题起因于:我们项目中引入了一个 npm 包(暂且叫它 aaa),而 aaa 依赖了一个公共库 npm 包(暂且叫它 common)。
其依赖关系如下图所示:
可以看到,项目中我们依赖了 2 个不同版本的 common 包,而这次我们恰好需要升级 common 包到 3.0,因此就碰到了升级版本的问题:
当我们将主项目的 package.json 里的 common 版本号改成 3.0 之后,确实理论上项目自身就应该用的是 3.0 的 common 了,而 aaa 包里面由于自己还打包了一份common@2.0,因此项目运行的时候实际最终产物里面有 2 个 common 包(其中就有一个 aaa 所依赖的common@2.0)。理论上二者不会互相影响。
但实际运行后,我们发现有的手机好像主项目生效的不是 3.0,而是 2.0,因此怀疑是不是由于 aaa 依赖了 2.0 导致主项目的 common 被污染从而没生效。实际上我们这个怀疑是不成立的,在如今最新版本的 npm 版本和 webpack 下,npm 会通过内嵌 node_modules 确保多版本无干扰共存,webpack 生成产物时理论上也不会让 2 个依赖互相冲突。
后来确实发现,我们所谓的有的手机的问题应该是自己看错了,实际上版本共存并没有什么问题。
不过为了能保持一个项目内不要出现这么多不同版本的 common 包,于是我们还是决定:索性把 aaa 库里面的 common 这个依赖改成“external”,即让 aaa 这个库直接使用宿主环境的 common,以避免同一个项目中出现 2 个 common 包。
aaa 这个包内 rollup 配置的操作方法:
1 | config.external = ["common"]; |
4、此时我们重新构建 aaa,可以发现 aaa 的产物中不再打包 common 内容了,而是反而变成了对 common 包的引用语句。(例如 esm 产物中就变成 import 语句,cjs 产物就变成 require 语句)。
cjs 类似这样:
umd 类似这样:
5、接下来,问题出现了。运行项目的时候,浏览器报错:
02 问题分析
当我们把问题最小化成“主项目”+“aaa 包”+“common 包” 的问题后,我们分析起来相对来说比较简单一些。
前置简化问题的操作:
模拟一个精简的 aaa 包,即精简 aaa 包内代码,只保留一两句
模拟一个 common 包,即精简 common 包内代码,只保留一两句
webpack 主项目配置里面,让 devserver 和生产 build 都不要压缩(即 config.optimization.minimize 改成 false),同时确保他按照生产环境构建从而保证能把 node_modules 里模块都参与编译(config.mode=’production’),同时不要让他产生 sourcemap 以免干扰我们在浏览器中看到最终产物(即 config.devtool=false)。
接下来,点击浏览器里的报错,进入浏览器的 source 面板内看一眼:
首先看到了,aaa 包的模块内容,他里面去 require 了 56 模块. 而 56 号模块 就是 common 包,这里看起来也都是正常的。
我们再来看 56 模块, 56 就是 common 包的内容。
可以发现,貌似 webpack 虽然识别并打包了我的 common 模块,但是呢, 其实这个闭包 factory 函数里,根本就没有 exports 这个对象。你看一下函数的形参:“unused_webpackwebpack_module”、“webpack_exports__”, 没有任何一个参数叫做“exports”。
这是什么原因呢?先说结论:
因为这个 56 号模块,被当作 ESM 模块来编译处理了,因此工厂函数的参数都是 esm 模块编译结果所需的 3 个参数,他期望你工厂函数内部用 esm 语法,假如你用了 esm 语法它会给你编译成使用上述webpack_exports等参数,但你用了 commonjs 语法,于是 webpack 没有对你代码做任何改动。
03 为何编译处理跟写法不一致
这就回到了 webpack/rollup 这类打包工具,是如何寻找模块文件,以及用何种模块化方式来处理找到的文件的。
这里涉及到 2 块内容 2 个话题需要先学习:
如何寻找模块文件
我们先看打包工具如何寻找文件,直接说结论吧,一般对于前端 vue 项目,webpack 的 target 设置默认是空(即默认为 web),而 rollup 的 plugin-resolve 插件一般设置为 browser:true。
此时,主项目寻找 node_modules 下面 npm 包的文件过程如下:
- 例如主项目加载 node_modules 下的 aaa 包。那么,主项目先拿出 aaa 项目的 package.json。并看 package.json 中是否存在 exports 字段。 2.若有 exoprts 字段,则优先看 exports 字段进行匹配。
- exports 字段内可能会有“import/require/node/browser”等字段。此时 webpack 就从上往下匹配,命中哪个就算哪个。
- 由于调用方项目是用 esm 语法导入 aaa 包的,因此他碰到 package.json 里面的 exports 下的 import 语句则算命中,另外由于 webpack 的 target 是 web,所以他碰到 browser 也算命中。这就看你编写的顺序了。
- 若 aaa 的 package.json 里面没有 exports 字段,则会用 package.json 第一层的 main/module/browser 字段。
- 外层的 main/module/browser 的优先级是:由于我们此时是 web 的 target,且调用方用的是 esm 导入语法。因此若有 browser 则优先用 browser,没有则优先用 module,再没有则优先用 main。
我们以本文案例为例,再解释下 aaa 和 common 包分别被加载了哪个文件:
- 主项目我们代码中是这么写的 “import foo from “aaa””, 而 aaa 项目中的 package.json 如下:
1 | { |
由于 aaa 这个包没有 exports 字段,因此使用第一层 3 个字段。由于 webpack 的 target 是 web,因此直接命中 browser,即匹配 dist/index.umd.js。
2.再看 dist/index.umd.js 代码:
由于我们 aaa 这个包内部,已经在本文最开始就说过“把依赖包 common 声明成了 external”,因此 aaa 包里面的 umd 代码中,并不会直接打入 common 内容,而是只保留一句 require(‘common’)。
在 webpack 打包过程中,当找到 aaa 的 dist/index.umd.js 后,发现 aaa 项目缺少 type 声明,它无法确定这个 index.umd.js 倒是是啥模块化方式,于是它只能去读取文件内容进行智能分析,最终发现里面有 commonjs 语法于是判定它为 commonjs 语法(这个下文再讲),于是他按照 commonjs 语法处理 aaa 的 index.umd.js 文件,此时发现里面 require 了 common 包。于是再去寻找 common 包的模块。
- 进入 common 包进行寻找。首先找到 package.json。发现 common 包是这么写的:
1 | "name": "common", |
此时,webpack 发现这里有 exports 字段,于是开始匹配。而调用方 aaa 用的是 require 语法调用,因此匹配到 dist/index.cjs.js 文件。
我们看该模块文件内容,确实也是用的 exports.xxx=111 这样的 commonjs 语法:
至此,webpack 已经找到了所有依赖树的文件。真正的问题则出现在下面这一步。
如何处理找到的模块文件
当 webpack 找到了 aaa 里的模块文件,以及 aaa 所依赖的 common 里的模块文件后, 他便要对 aaa 的文件和 common 下的这个文件进行编译处理,形成标准化的 webpack 模块从而放入最终产物。
他处理模块文件,就涉及到要用何种方式来处理该模块文件。这里其采用的策略是两种策略:要么是依靠该文件的扩展名+该模块包的默认扩展名声明;要么是智能分析文件内容。
对于策略一,即:
首先看该包 package.json 里面的 type 字段,若声明的是 commonjs,则 js 扩展名是 js 的就当作 commonjs 来处理,而 mjs 才当作 esm 模块化方式处理。若声明的 type 是 module,则 js 扩展名就是 esm,而 cjs 才是 commonjs 模块化处理。
确定了模块化处理方式后,webpack 将以该模块化方式语法来处理模块。例如如果以 commonjs 方式处理模块,那么你模块里就不能出现 export default 类似这种 esm 的语法,他构建时候就提示报错。
然而如果确定成 esm 方式处理,则你的模块文件里如果出现了 cjs 的 exports 语法,编译时不会报错,但运行时就会找不到这个 exports 和 require 对象了(因为 webpack 把这个模块当成 esm 处理的情况下,他根本没有给你注入 exports 对象和 require 对象。。。),这也就是问题出现的原因。
对于策略二,即:
若 package.json 里面没有写 type 字段。但你寻找到的模块文件又是 js 这种不确定的扩展名(如果是 mjs 或 cjs 就能很容易判定了)。则 webpack 必须进入智能模式来判定。
智能模式下,webpack 大概就是看该模块文件内是否有 export default 或者 export xxx 这样的语句,有的话那就是按 esm 来处理;没有就按 commonjs 来处理吧。
04 回溯问题出现的原因
根据上一节的原理分析。这里我们追溯一下 common 包报错的原因。
首先主项目用 esm 的 import 语法依赖了 aaa,而 aaa 里面 package.json 声明了 browser 字段,于是根据文件寻找规则,webpack 他找到了 aaa 里面 index.umd.js。
由于 aaa 包里面 package.json 的 type 字段是空。空的情况要特别注意:在 nodejs 中他会当作 commonjs,但在 webpack 下 webpack 就会去看一下你模块里到底写没写 esm 语法,写了就当成 esm,没写就当成 commonjs。于是上述找到的 index.umd.js 后 webpack 分析认为要按 commonjs 模块方式解析,于是里面应当使用 require 语法并识别 require 语法。
于是 webpack 发现 aaa 里面 require 了 common 包。
接下来,webpack 开始寻找 aaa 所依赖的 common 包。而 common 包里面是通过 exports 字段声明了导出内容,由于 aaa 是 commonjs 方式引入 common 包,于是 webpack 找到了 common 包里的 dist/index.cjs.js 作为寻找结果。
既然找到了 common 包里的 index.cjs.js。于是 webpack 要开始编译处理该模块,由于其 package.json 里面写的 type 是 module,因此此时要严格按照扩展名来判定,其 js 扩展名理论上要当作 esmodule 解析处理,因此 weback 按照 esm 方式处理编译。
而恰好 index.cjs.js 里却并不是 esm 语法,于是 webpack 他根本没有对 index.cjs.js 里面的 exports.xx=11 这样的语句做任何处理。于是最终浏览器里就看到了这种原始 commonjs 语法并未被做过任何处理:
也就是说,webpack 产物里面,esm 形式的工厂函数里面,包裹了一个 commonjs 形式的模块写法代码。这就肯定不一致了。
因此,如果从逻辑合理性角度讲,问题出现的根本原因在于:common 包里面 exports 字段声明有误。你作为一个声明为 type:module 的包。你内部的 commonjs 模块语法的文件,必须得使用 cjs 扩展名。而不应该使用 js 扩展名。正确改法如下:
1 |
|
另一种改法,是借用 webpack 的智能特性——即但凡你的 package.json 里面没有声明“type”字段,那就意味着 js 扩展名的模块化方式是不确定的,此时 webpack 会智能分析这个模块文件里到底有没有使用 esm 模块语法,用了就按照 esm 来处理,没用就按 commonjs 来处理。
其实 vue2 框架的 dist 产物有非常多的结果,他在处理 web 作为 target 场景的时候,就是直接借用了 webpack 等打包工具的智能特性。截图如下:
观察 vue 作者的设计,可以看到,在 nodejs 场景下,是缺少智能判定的,因此针对 nodejs 场景他依然要写清楚 mjs 扩展名从而告诉 node 我这个文件要按照 esm 解析。
而 webpack 场景 import vue 的时候,则会匹配到 default 里面的 vue.runtime.esm.js, 此时 vue 作者并没有在 package.json 中声明 type 字段,于是 webpack 智能被迫自动分析 vue.runtime.esm.js 里到底是什么模块化,从而自动使用 esm 来处理这个文件。
疑问:在我们当天的问题处理过程中,为什么我们把 aaa 的 package.json 中的 brower 字段声明指向成 esm 文件,也能解决该问题?
即,将 aaa 包的 package.json 改成:
解答:由于 aaa 的 package.json 里没有声明 type 字段。因此 js 扩展名的文件会被 webpack 智能解析,于是 webpack 找到 index.esm.js 并分析内容后,认为应该用 esm 方式解析它。于是 aaa 就变成以 import 语句的方式去加载 common 包。 而 common 包中 exports 字段声明了 imoprt 加载时则使用 index.esm.js 文件,而恰好 common 包里面 package.json 声明了 type:module,也就意味着 js 扩展名的文件就是会让 webpack 用 esmodule 方式解析,而该文件内容中的语法也恰好就是 esm 语法,因此 webpack 的识别结果以及编译结果就 100%不会出错。
05 总结
在你开发库包的时候,package.json 一旦你声明了 type 字段,请务必要按照 nodejs 的标准扩展名规则去生成你的类库产物的扩展名。否则你一旦写错扩展名,例如 commonjs 的模块,却用了 xxx.cjs.js 扩展名。那么,调用方 webpack 会判定你的一个模块文件是按 esm 解析,但你产物里面实际却出现了 module.exports 或 exports.a = 1 这样的 cjs 语法。最终会在浏览器运行时报错。
对于同构库(即你同时给 node 和前端打包工具使用),建议按照 vue 的这种操作作为最佳实践。
1 | ".": { |
即:
第一、对于 commonjs 文件的暴露。首先我不声明 type 字段。然后只需要用 require 字段声明来暴露,这样在 nodejs 环境下 js 扩展名由于缺少 type 声明所以就会把该文件当作 commonjs,而在浏览器环境下 webpack 会智能分析文件内容从而将该文件判定成 commonjs。
第二、对于 esm 方式的暴露。由于 node 环境下没有智能分析且 js 扩展名会被当成 commonjs,因此必须通过 mjs 扩展名明确告知 node 访问这个文件时采用 esm 解析;而对于前端浏览器打包场景,则可以不必写 mjs 扩展名,于是直接给一个 default 声明:default: ‘vue.runtime.esm.js’,webpack 碰到后会自动分析内容后判定他是 esm 方式解析。