本文目录
[[toc]]
模块化演进过程
目的
- 减少命名冲突
- 分离代码,按需加载
- 高复用
- 高可维护
全局 function 模式
形式
function module1() {
return {
method1() {},
}
}
function module2() {
return {
method1() {},
}
}优点
- 使用简单,可以方便的调用依赖模块,减少重复代码
- 隔离作用域,避免变量名、函数名等冲突,导致变量、函数被异常覆盖
缺点
- 以函数名为单位污染全局,
window上会挂载大量函数 - 每次获取变量都需要调用函数,难以对变量进行隔离(每次调用会重置)
- 缺少访问控制,可能因拼写错误等原因,被其他模块意外修改
namespace 模式
形式
const myModule = {
data: 'Naprte',
foo() {
console.log(`foo() ${this.data}`)
},
bar() {
console.log(`bar() ${this.data}`)
},
}
myModule.data = '能直接修改模块内部的数据'
myModule.foo()优点
- 使用简单,可以方便的调用依赖模块,减少重复代码
- 隔离作用域,避免变量名、函数名等冲突,导致变量、函数被异常覆盖
- 模块内可以保持单例,不需要每次都调用函数返回
- 相比较全局
function形式,减少了全局污染
缺点
- 以变量名为单位污染全局,
window上会挂载大量全局变量 - 缺少访问控制,可能因拼写错误等原因,被其他模块意外修改
IIFE 模式
形式
// module.js文件
(function (window) {
const data = 'www.naparte.com'
// 操作数据的函数
function foo() {
// 用于暴露有函数
console.log(`foo() ${data}`)
}
function bar() {
// 用于暴露有函数
console.log(`bar() ${data}`)
otherFun() // 内部调用
}
function otherFun() {
// 内部私有的函数
console.log('otherFun()')
}
// 暴露行为
window.myModule = { foo, bar } // ES6写法
})(window)优点
- 创建独立作用域,避免全局污染
- 可以进行内部初始化,保持单例
- 权限控制收缩,由于匿名函数的特性,外部不容易直接修改
IIFE内部
缺点
- 由于权限控制收缩导致的难以复用问题,需要通过
IIFE嵌套实现调用、复用,代码结构不清晰 - 难以处理多个模块调用同一个模块的场景
CommonJS 模块
形式
// example.js
const x = 5
function addX(value) {
return value + x
}
module.exports.x = x
module.exports.addX = addX
// index.js
const example = require('./example.js')
console.log(example.x) // 5
console.log(example.addX(1)) // 6优点
- 以文件形式隔离作用域,内部是私有的,公开内容通过
module.exports、exports导出 - 对全局作用域无污染
- 在权限收缩的同时,也可以支持其他模块复用
- Node.js 原生支持,服务端友好
缺点
- 解析模块时使用
DFS,而加载是同步的,可能导致页面阻塞 - 浏览器中使用较为繁琐,需要使用构建工具进行处理
- 对模块加载顺序有要求,可能由于异常顺序导致模块初始化异常,难以排查
- 将基本类型绑定到
module.exports上时,需要修改module.exports而不是原变量,否则外部检测不到值改变 - 复用模块成本较高,可能引入大量无用代码(不支持
tree-shaking)
AMD 规范
形式
// 配置模块路径(需在加载库之前设置)
const requireConfig = {
paths: {
math: 'js/modules/math',
add: 'js/utils/add',
multiply: 'js/utils/multiply'
}
}
// 定义模块 math.js,依赖 'add' 和 'multiply' 模块
define(['add', 'multiply'], (add, multiply) => {
return {
calculate(a, b) {
return multiply(add(a, 5), b)
}
}
})
// 加载模块
require(['math'], (math) => {
console.log(math.calculate(2, 3)) // 输出计算结果
})优点
- 支持异步加载模块
- 依赖前置,所有依赖在模块加载前都需要声明
- 支持按需加载,只有执行了
require才会去加载模块 - 以文件形式隔离作用域,内部是私有的,公开内容通过
module.exports、exports导出 - 对全局作用域无污染
- 在权限收缩的同时,也可以支持其他模块复用
缺点
- 语法复杂,不易阅读、书写
- 运行时需要额外引入
require.js库 - 不支持动态加载依赖,只能静态声明
- AMD 导出可以是任意值,缺少约束
- 复用模块成本较高,可能引入大量无用代码(不支持
tree-shaking) - AMD 的依赖加载和模块初始化需在运行时完成,可能增加页面初始加载的延迟(尤其在依赖链较长时)
- 异步加载可能导致代码执行顺序难以追踪,增加调试难度
CMD 规范
形式
// 定义没有依赖的模块
define((require, exports, module) => {
exports.xxx = value
module.exports = value
})
// 定义有依赖的模块
define((require, exports, module) => {
// 引入依赖模块(同步)
const module2 = require('./module2')
// 引入依赖模块(异步)
require.async('./module3', (m3) => {})
// 暴露模块
exports.xxx = value
})
// 引入使用的模块
define((require) => {
const m1 = require('./module1')
const m4 = require('./module4')
m1.show()
m4.show()
})优点
- 支持同步、异步加载代码
- 支持动态导入
- 通过
module.exports控制导出内容 - 方便复用
- 独立作用域,避免全局污染
缺点
- 运行时依赖
sea.js库 - 复用模块成本较高,可能引入大量无用代码(不支持
tree-shaking)
ESM
形式
// export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能
/** 定义模块 math.js **/
var basicNum = 0;
var add = function (a, b) {
return a + b;
};
export { basicNum, add };
/** 引用模块 **/
import { basicNum, add } from "./math";
function test(ele) {
ele.textContent = add(99 + basicNum);
}
// 使用import命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default命令,为模块指定默认输出。
// export-default.js
export default function () {
console.log("foo");
}
// import-default.js
import customName from "./export-default";
customName(); // 'foo'优点
- ECMA 官方标准,语法简洁
- 支持静态分析优化,支持 tree-shaking
- 模块级作用域,避免全局污染
- 跨平台兼容,浏览器、 nodejs 等环境原生支持
缺点
- 老代码升级较为困难
- 浏览器环境通过
importmap导入时可能存在跨域问题 - 部分场景下需要使用
webpack等构建工具转为其他格式,才能提供给其他库使用
UMD 规范
形式
(function (root, factory) {
if (typeof module === 'object' && typeof module.exports === 'object') {
console.log('是commonjs模块规范,nodejs环境')
module.exports = factory()
} else if (typeof define === 'function' && define.amd) {
console.log('是AMD模块规范,如require.js')
define(factory)
} else if (typeof define === 'function' && define.cmd) {
console.log('是CMD模块规范,如sea.js')
define((require, exports, module) => {
module.exports = factory()
})
} else {
console.log('没有模块环境,直接挂载在全局对象上')
root.umdModule = factory()
}
})(this, () => {
return {
name: '我是一个umd模块',
}
})优点
- 复用性较高,支持跨运行环境复用,同时满足了 CommonJS 、 AMD 、 CMD 规范
- 兼容性强,覆盖主流运行环境
- 支持老规范渐进式升级
缺点
- 代码复杂度高
- 可能污染全局变量,如浏览器环境
- 不支持
tree-shaking
总结
CommonJS规范主要用于服务端编程,加载模块是同步的,这并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMDCMD解决方案;AMD规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD规范开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅;CMD规范与AMD规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在Node.js中运行;ES6在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代CommonJS和AMD规范,成为浏览器和服务器通用的模块解决方案;UMD为同时满足CommonJS,AMD,CMD标准的实现;
CommonJS
介绍
- 由 JS 运行时实现,是之前缺失底层模块机制时在上层做的弥补。
- 模块在执行阶段分析模块依赖,采用深度优先遍历( depth-first traversal ),执行顺序是父 -> 子 -> 父
- 同步加载并执行模块文件(不区分加载与解析,全部同步执行)
- 导入导出语句的位置会影响模块代码执行结果
CommonJS模块整体导出一个包含若干个变量的对象
使用场景
- nodejs 默认支持
- 其他场景需要通过构建工具打包代码,通过运行时 polyfill 模拟,如浏览器中运行
加载模块异常
由于是运行时方案,加载的报错会通过 throw 抛出
执行顺序
CommonJS 模块是顺序执行的,遇到 require 时,加载并执行对应模块的代码,然后再回来执行当前模块的代码。如下图所示,模块 A 依赖模块 B 和 C,模块 A 被 2 个 require 语句从上往下分为 3 段,记为 A1、A2、A3 。
---
title: CommonJS 模块关系图
---
flowchart TD
A[模块 A]
B[模块 B]
C[模块 C]
A --> B
A --> C执行顺序为: A1 -> B -> A2 -> C -> A3
也就是如下时序图
---
title: 模块解析时序图
---
sequenceDiagram
participant A
participant B
participant C
Note over A: 执行 A1
A ->> B: A require B
Note over B: 执行 B
B ->> A: 交还控制权
Note over A: 执行 A2
A ->> C: A require C
Note over C: 执行 C
C ->> A: 交还控制权
Note over A: 执行 A3循环依赖
模块 A 依赖模块 B,模块 B 又依赖模块 A,模块 A 和 B 分别被 require 语句从上往下分为 2 段,记为 A1、A2、B1、B2。
对应的引用关系图如下:
---
title: CommonJS 循环依赖模块关系图
---
flowchart TD
A[模块 A]
B[模块 B]
A --> B
B --> A也就是如下时序图:
---
title: CommonJS 循环依赖模块解析图
---
sequenceDiagram
participant A
participant B
Note over A: 执行 A1
A ->> B: A require B
Note over B: 执行 B1
B ->> A: B require A
Note over A: 获取 A1 的执行结果
A -->> B: 返回 A1 执行完的上下文
Note over B: 执行 B2
B ->> A: 交还控制权
Note over A: 执行 A2如果在 B1 中引用 A2 部分代码, Runtime 级别的 CommonJS 通常检测不出来,所以会获取到 undefined 值,如果代码中有进行容错处理,则不会抛出异常。
ESM ( ECMAScript Module )
介绍
- 在 JS 引擎内实现,属于语言层面的底层实现
- 模块在预处理阶段分析模块依赖,在执行阶段执行模块,两个阶段都采用深度优先遍历,执行顺序是子 -> 父
- 会提前加载并执行模块文件(先加载完模块,再挨个模块执行)
- 导入导出语句位置不影响模块代码语句执行结果
- ESM 分开导出单个变量,如果只看父模块, ESM 的父模块确实在预处理阶段就绑定了子模块的导出变量,但是预处理阶段的子模块的导出变量是还没有被赋最终值的,所以并不能算真正输出
使用场景
- node 16+ 默认支持 esm ,但是需要使用
mjs后缀并且package.json中需要声明"type": "module" - 非 IE 浏览器、移动端浏览器较新版本都支持通过
<script type="module">语法声明 esm 代码,通过importmap声明依赖包从哪里加载 - 其他场景需要通过构建工具打包代码,通过运行时 polyfill 模拟
加载模块异常
由于是引擎层的解决方案,加载的报错会直接定位到具体的错误语句,类似 throw 语句的效果
执行顺序
ESM 有 5 种状态,分别为 unlinked 、 linking 、 linked 、 evaluating 、 evaluated ,用循环模块记录( Module Environment Records )的 Status 字段表示。 ESM 的处理包括连接( link )和执行( evaluate )两步。连接成功之后才能进行执行。
连接主要由函数 InnerModuleLinking 实现。函数 InnerModuleLinking 会调用函数 InitializeEnvironment ,该函数会初始化模块的环境记录( Environment Records ),主要包括创建模块的执行上下文( Execution Contexts )、给导入模块变量创建绑定并初始化为子模块的对应变量,给 var 变量创建绑定并初始化为 undefined 、给函数声明变量创建绑定并初始化为函数体的实例化值、给其他变量创建绑定但不进行初始化。
---
title: ESM 模块关系图
---
flowchart TD
A[模块 A]
B[模块 B]
C[模块 C]
A --> B
A --> C执行顺序为: 预处理 B -> 预处理 C -> 预处理 A -> 执行 B -> 执行 C -> 执行 A
也就是如下时序图
---
title: ESM 模块解析流程图
---
sequenceDiagram
participant A
participant B
participant C
A ->> B: A import B
Note over B: 预处理 B
B ->> A: 交还控制权
A ->> C: A import C
Note over C: 预处理 C
C ->> A: 交还控制权
Note over A: 预处理 A
A ->> B: A import B
Note over B: 执行 B
B ->> A: 交还控制权
A ->> C: A import C
Note over C: 执行 C
C ->> A: 交还控制权
Note over A: 执行 A由于预处理阶段存在,所以以下代码是能正常执行的
// 预处理时会定义 a 变量,相当于变量提升了,所以不会报错
import { a } from './child.js'
console.log(a)循环依赖
模块 A 依赖模块 B,模块 B 又依赖模块 A,模块 A 和 B 分别被 require 语句从上往下分为 2 段,记为 A1、A2、B1、B2。
对应的引用关系图如下:
---
title: ESM 循环依赖模块图
---
flowchart TD
A[模块 A]
B[模块 B]
A --> B
B --> A也就是如下时序图:
---
title: ESM 循环依赖解析图
---
sequenceDiagram
participant A
participant B
A ->> B: A import B
Note over B: 预处理 B
B ->> A: 交还控制权
Note over A: 预处理 A
A ->> B: A import B
Note over B: 执行 B
B ->> A: 交还控制权
Note over A: 执行 A如果在 B 中引用 A 的代码,由于 B 先执行,在执行时从 A 导入的变量只定义了,未进行初始化,直接使用会导致 JS 错误。
模块解析策略
- 在讨论模块解析策略时,查找的文件类型不重要。
css,png,html,wasm文件都可以视为一个模块。 - 在哪个工具中查找模块也不重要。
tsc,nodejs,vite,esbuild,webpack,rspack都需要处理import/require,都需要解析模块,都需要选择一个查找模块的策略,而绝大多数都是使用node策略 node的模块解析策略本身是不断变化的。例如说早期的node并不支持package.json的exports字段
classic 策略
// 文件:/root/src/folder/index.js
import 'pkg'会经历下面的步骤来查找 pkg :
/root/src/folder/pkg.js/root/src/pkg.js/root/pkg.js/pkg.js
简单来说这种模块解析策略就是一直递归往上找同名文件,当前目录找不到同名文件就往父级目录找。不过这种策略目前前端界用得不多。
node 策略
目前最常见的就是 node 策略 。 其余的 node16 、 nodenext 、 bundler 等策略解析过程与 node 策略差不多,只是在文件名、模块规范等基础上有所条件,详见 模块解析策略总结。
nodejs ( module#all-together )、 webpack ( enhanced-resolve )、 vite ( resolve.exports )、 rspack ( nodejs_resolver )都采用此策略解析模块( rollup 没有内置解析策略)
// 文件:/root/src/folder/index.js
import 'pkg'会经历下面的步骤来查找 pkg :
- 同级目录的
node_modules找同名的 js 文件:/root/src/node_modules/pkg.js - 同级目录
node_modules里面找包含package.json的名为 pkg 文件夹:/root/src/node_modules/pkg/package.json - 同级目录
node_modules里面找包含index.js的名为 pkg 文件夹/root/src/node_modules/pkg/index.js - 还是找不到的话,那就往上一级目录重复前面的查找步骤
/root/node_modules/pkg.js/root/node_modules/pkg/package.json/root/node_modules/pkg/index.js
总结
classic 策略
- 递归查找的目录是父级文件夹,而不是
node_modules - 不解析了
package.json,整体解析策略较为简单粗暴 - 不支持文件夹模块,也就是 通过
require('pkg')索引到pkg/index.js
node ( Node.js CommonJS 解析 )
- 缺省扩展名: 允许省略文件扩展名,自动补全
.js、.json、.node、.ts、.d.ts、.tsx - 文件夹模块: 支持文件夹模块,可以通过
index文件或者package.json索引到文件夹对应的模块 - 基于
node_modules查找: 逐级查找node_modules目录,固定查找目录名 - 通过
package.json配置复杂策略:package.json中有大量字段可以声明该模块入口,从而影响解析策略,通过同时声明多个字段实现较好的解析兼容,但是也带来了优先级等复杂度问题。
node16 / nodenext ( Node.js ESM 严格模式 )
- ESM 支持: 支持
import/export语法与top await - 强制扩展名: 在 ESM 中必须显式指定扩展名,否则报错
- 通过模块类型区分模块类型: 根据
.mjs/.cjs或者package.json的type字段(声明.js文件使用什么格式)区分CommonJS与ESM,针对不同类型的模块使用不同的模块解析能力 - 子路径导出: 通过
exports字段配置,可以进行子路径导出,详见exports字段
bundler (面向构建工具的宽松模式)
- 缺省扩展名: 支持省略扩展名,并且不会自动补充,交由构建工具处理
- 模块类型: 不区分模块类型,交由构建工具处理
exports部分支持: 忽略node、browser字段,优先使用import与module字段- 不递归
node_modules: 交由构建工具处理,实现node_modules扁平化
模块主入口
classic 策略中不考虑 package.json ,所以本章节主要指除了 classic 策略之外的策略。
package.json 是前端绕不开的东西,很多前端工具都支持通过 package.json 来写配置。而在 node_modules 下,一个包含 package.json 的文件夹可以视为一个模块,我们可以通过 package.json 来定义这个模块在被另一个模块导入时的解析规则。
main 字段
通过 main 字段来定义一个模块如何导出是目前最常见的做法了。最精简的 package.json 声明如下:
{
"name": "lodash",
"version": "1.1.1",
"main": "lodash.js"
}当没有其它字段时, node 策略在解析不含子路径的模块时就会找到 main 字段对应的文件。
main 中的路径都是相对于 package.json 所在的目录计算的,不再适用 node 解析策略以及文件名补全、文件夹模块等。
所以像 lodash 等库需要将发布的代码平铺到 package.json 同级
module 字段
为了解决某些库想同时提供 cjs 和 esm 两份 js 代码,我们可以使用 module 字段来指定 esm 版本的入口。例如 redux ,简化后的 package.json:
{
"name": "redux",
"version": "1.1.1",
"main": "lib/redux.js",
"module": "es/redux.js"
}exports 字段
Node.js 官方文档关于 package.json 的介绍 ,基本都在说 exports 字段,可以说这是最终级的解决方案,适配现在所有场景。
主入口导出
类似 main 和 module 字段,我们可以使用下面的写法来配置一个模块没有写子路径时怎样导出的,也叫主入口:
exports 中所有的路径都必须以 . 开头 , 可以把 . 简单理解为就是模块名
{
"name": "xxx",
"exports": {
".": "./index.js"
}
}对于上面的例子,可以把对象简化为一个路径:
{
"name": "xxx",
"exports": "./index.js"
}子路径导出
当匹配 exports 字段规则时,如果支持子路径,需要在 exports 中声明,否则会报错,不能使用
声明子路径的时候,子路径必须以 ./ 开头,并且严格区分子路径后缀,如果声明了 ./bar.js ,则导入的时候必须是 pkg/bar.js ,使用 pkg/bar 导入会报错。
{
"name": "pkg",
"exports": {
// 导入时必须使用 `pkg/bar.js`
"./bar.js": "./src/bar.js",
// 导入时必须使用 `pkg/foo`
"./foo": "./src/bar.js"
}
}通配符导出
为了适配多子目录场景, exports 字段支持通配符导出,也是进行严格的字符串替换生成导入文件路径。示例如下:
{
"name": "lodash",
"exports": {
// 只要是子路径,就从 ./lib 目录中找对应名字的 `.js` 文件
// 即使是导入 `lodash/get.js` ,也会查找 `./lib/get.js.js`
// `*` 支持无限层路径,比如 lodash/a/b ,也会匹配,查找 `./lib/a/b.js`
"./*": "./lib/*.js"
}
}具体的查找规则如下:
- 给定一个模块 id
lodash/add - 使用模块名
lodash替换左侧的 pattern./*中的.,得到lodash/* - 把 pattern
lodash/*和模块 idlodash/add做模式匹配,得到 * 的值就是 add - 将 target pattern
./lib/*.js中的*替换第三步得到的*的值得到./lib/add.js,也就是相对于lodashpackage 的相对路径 - 把相对路径中的
.替换为lodashpackage 的绝对路径就能得到模块 idlodash/add的绝对路径:/xxx/node_modules/lodash/lib/add.js
禁止导出
通过将一个模块的 target pattern 设置为 null 来禁止某个子路径被另一个模块导入:
{
"name": "xxx",
"exports": {
"./forbidden": null
}
}多个 match 的优先级
有如下 package.json 定义:
{
"name": "xxx",
"export": {
"./*": "./*",
"./a/*": "./a/*.js",
"./a/b": "./a/b.js",
"./*.js": "./*.js"
}
}当使用 import 'pkg/a/b' 导入包时,会匹配 ./* 规则,即短路匹配策略
短路匹配:从前到后匹配,当一个 key pattern 匹配成功,不管 target pattern 对应的文件能否找到都结束匹配
但是,具体到不同的模块加载库时,匹配可能有所不同,目前并没有标准的匹配策略优先级定义
条件导出
exports 字段定义入口 / 子路径时,可以定义不同的条件进行匹配,如:
{
"exports": {
"./foo": {
// types, node-addons, node, import 这些 key 表示条件
"types": "./types.d.ts",
"node-addons": "./c-plus-native.node",
"node": "./can-be-esm-or-cjs.js",
"import": "./index-module.mjs",
"require": "./index-require.cjs",
"default": "./fallback-to-this-pattern.js"
}
}
}- 在
nodejs、esm情况下,会使用"import": "./index-module.mjs" - 在
CommonJS情况下,会使用"require": "./index-require.cjs" - 在各种情况不满足的情况下,会使用
"default": "./fallback-to-this-pattern.js"
条件导出的各个条件的优先级取决于它声明的顺序,越前面的越高。
换句话说它是从前到后短路匹配的,因此,在 node 使用 commonjs 情况下导入下面这个模块会报错:
{
"name": "xxx",
"exports": {
".": {
"default": null,
"require": "./dist/hello.js"
}
}
}这就要求我们使用条件导出的时候注意按照优先级顺序去编写,将 越特殊的条件放越前面 。
package.json 其他重要字段
files
声明当前包导出了哪些文件或目录,只有声明导出的内容会被发布到 npm 上
types / typings
声明当前依赖包导出模块对应的类型定义( .d.ts )目录,用于 ts 项目获取依赖包 ts 类型
typesVersions
解决不同版本 ts 类型语法兼容问题,针对特定范围的 ts 使用不同的 .d.ts 类型定义文件,除了分版本之外也支持分子模块加载类型。
{
"name": "pkg",
"version": "1.1.1",
"types": "dist/index.d.ts",
"typesVersions": {
"<=4.8": {
// 声明 `pkg/subpath` 模块的类型声明文件,支持通配符
// 如果使用的是 `node16` / `nodenext` ,需要使用 `ts4.8/*.d.ts`
"subpath": ["ts4.8/*"]
},
// 兜底策略,可省略,默认从 `.` 导入,如 `pkg/foo` 会从 `./pkg/foo.d.ts` 文件中导入类型
"*": {
// 声明 `pkg` 模块的类型声明文件,支持通配符
// 如果使用的是 `node16` / `nodenext` ,需要使用 `dist/*.d.ts`
"*": ["dist/*"]
}
}
}当同时配置 types / typings 与 typeVersions 时, types / typings 优先级更高,无法识别 types / typings 时才会使用 typeVersions
unpkg / jsdelivr / cdn / browser
提供给 cdn 厂商使用,一般是包含了完整 JS 的全量文件入口,参考 package.json 中支持 cdn 入口的讨论帖
以 jsdelivr 为例,优先级为:
jsdelivrbrowsermain
Vite 解析 npm 包
vite 使用 esbuild 将 ts 文件转成 js 文件, esbuild 在转换时会直接丢弃 ts 类型,并不会做类型检查,所以它不用管类型怎样解析,也就不用处理 typings 等字段。
当同时存在 main 和 module 入口,各种构建工具尤其是 rollup, vite 这些基于 ESM 的都是优先使用 module 字段。
vite 打包情况分很多种:
- pre bundling: 使用
esbuild预构建 - esm dev server:
vite内置插件vite:resolve处理模块id解析 - prod build: 生产环境构建,本质是
rollup+vite:resolve插件 +@rollup/plugin-commonjs插件
默认情况下, vite 预构建不管你第三方依赖支不支持 esm ,都会给你打包。主要是考虑类似 lodash-es 这样模块数量特多的依赖不预构建的话 http 请求数就太多了。
如果不想预构建,就得手动将依赖添加到预构建 exclude 列表。当把一个依赖添加到预构建 exclude 列表, vite 就不会对它进行 commonjs -> esm 转换,即便把 main 字段指向一个 commonjs 模块, vite 也会尊重用户配置,当 esm 模块处理。
vite 和 rollup 都是通过插件系统来增加自身的能力,它们都是先通过 resolve 插件确定一个模块的最终文件路径,再下一步使用 @rollup/plugin-commonjs 插件在需要转换的情况下给你转成 esm 。如果同时存在 esm 的入口和通用入口,都会优先使用 esm 入口。
main 入口也可以给 esm 用,它是一个通用入口。另一个类似的还有 exports 中的 default 字段。
{
"type": "module",
"exports": {
".": {
"import": {
// 开发环境使用 src 下的源码,因此我们修改源码也能热更新
"development": "./src",
// 生产环境下,也就是在 app 运行 vite build 时使用打包编译的 dist
"default": "./dist/es/index.mjs"
}
}
},
"publishConfig": {
// 发布出去时我们不需要保留 development 这个 condition
// 如果保留,会导致使用这个库的用户也走 src
// 并不是所有的字段都支持在 publicConfig 覆盖,例如 npm 不支持覆盖 typesVersion ,但是 pnpm 是支持的
"exports": {
".": {
"import": "./dist/es/index.mjs"
}
}
}
}