前面我们在深入理解 ES Module 中详细介绍过 ES Module 的工作原理。目前,ES Module 已经在逐步得到各大浏览器厂商以及 NodeJS 的原生支持。像 vite 等新一代的构建工具已经逐步使用 ES Module 并有计划的运用到生产环境中。因此,了解如何在浏览器以及 NodeJS 中使用 ES Module 是必要的。
在浏览器中使用
支持 ES Module 的浏览器通过 script 标签上的 type
字段来识别 ES Module,即 type=module
就是 ES Module。
浏览器在遇到 type=module
的 script 标签时,会将其作为 ES Module 来解析,如果有依赖模块时,会递归的加载依赖模块。模块加载原理与 Webpack 是类似的。
现在问题来了,浏览器如何加载模块呢?
有三种主要方式:
- 绝对路径,比如
http://domain.com/path/to/module
- 相对路径,比如:
./path/to/module
- 包名(裸说明符,bare specifier),比如:
lodash-es
绝对路径和相对路径都很好理解,与普通的 script 用法是一样的。直接使用包名浏览器如何处理呢?
我们在使用 Webpack 等打包器的时候,项目依赖的模块是安装在 node_modules 目录下的。在打包器执行构建的时候,会从 node_modules 中查询依赖的包,找到对应的模块,最终将模块代码合并到最终的构建输出文件中。
在浏览器中,其实是一样的,只不过我们要告诉浏览器去哪里找这些包。目前有一个规范(草案阶段)给出了解决方案,那就是 import-map
。我们简单说明一下。
通过 type=importmap
的 script 标签,来告诉浏览器可以在哪里找到这些模块。
从 caniuse 上看,目前主流浏览器对 import-map
的支持不一,因此,我们还不能在浏览器中直接使用。
现在常规的做法还是经一道打包器的处理,将依赖的模块都打到最终的构建输出中(代码依然是 ES Module)。
在 NodeJS 中使用
NodeJS 有三种方式来识别 ES Module,分别是:
- 以
.mjs
后缀结尾的文件。 - 以
.js
后缀结尾的文件,但是所在包package.json
中设置了type
字段并且值为module
。 - 命令行中指定了
--input-type=module
参数
除了命令行以外,NodeJS 在处理 ES Module 的时候,都与 package.json
中的字段有关,这里详细说明下。
package.json
中与模块处理的字段主要有如下几个。
name
包的名称,可以与imports
和exports
配合使用main
包的默认导出模块type
用于在加载.js
文件时确定模块类型exports
指定包导出了哪些模块imports
包导入了哪些模块,只供包内部使用
main
字段指定包的默认导出模块,在所有 NodeJS 版本中都适用。同时,exports
字段也可以定义包的入口点,而且除了 exports
定义的入口点以外,包内的其他模块将对外不可见,即 exports
同时还提供了一定的封装特性。
当 main
和 exports
同时定义的时候,exports
的优先级比 main
更高,即 NodeJS 会忽略 main
中的定义。
exports
exports
字段定义了包导出的模块,有这么几种定义方式,我们分别说明。
.
导出
{
"exports": {
".": "./lib/index.js"
}
}
.
导出定义了包的默认导出模块,即 import xxx from 'package'
的导出模块。
如果 .
不与其他导出一同使用的话(就像上面的样例一样),可以简写为:
{
"exports": "./lib/index.js"
}
子路径导出
{
"exports": {
"./lib": "./lib/index.js"
}
}
上面的例子定义了 import xxx from 'package/lib'
导出的模块。当然,如果我们想将 ./lib
目录下的所有的模块不受限制的导出的话,可以这么设置:
{
"exports": {
"./lib/*": "./lib/*.js",
}
}
路径中的 *
只做字符串替换,即 import xxx from 'package/lib/a/b/c.js'
将会最终被定位到 ./node_modules/package/lib/a/b/c.js
。
exports
中的./lib
等都是相对于包的根目录而言,且子路径导出都需要以./
开头。
如果我们想禁止 ./lib
目录下的某些模块被外部使用,同时又想通过 *
的方式导出模块,我们可以显式的将某一个目录导出设置为 null
,如下。
{
"exports": {
"./lib/*": "./lib/*.js",
"./lib/private-internal/*": null
},
}
条件导出
{
"main": "./main-require.cjs",
"exports": {
"import": "./main-module.js",
"require": "./main-require.cjs"
},
"type": "module"
}
条件导出支持的条件如下:
node
NodeJS 环境下适用,既可以是 ES Module 文件,也可以是 CommonJS 文件,通常不需要显式指定。node-addons
与node
类似,用于 NodeJS 插件。import
当通过import
或者import()
方式加载模块时使用,与require
互斥。require
当通过require()
方式加载模块时使用,与import
互斥。default
兜底方案,目标文件可以为 CommonJS 文件也可以为 ES Module 文件,通常排在最后。
exports
字段中 key 的顺序至关重要,排在前面的优先级更高。因此,排在前面的通常是条件要求最严格的,排在后面的通常是要求最宽泛的。
除了上面官方支持的几个条件以外,社区还定义了
types
、deno
、browser
、development
、production
等条件。
子路径导出也支持设置条件,如下:
{
"main": "./main.js",
"exports": {
".": "./main.js",
"./feature": {
"node": "./feature-node.js",
"default": "./feature.js"
}
}
}
同时,条件导出还支持嵌套,如下,在 node
条件下,又区分了 import
和 require
条件。
{
"main": "./main.js",
"exports": {
"node": {
"import": "./feature-node.mjs",
"require": "./feature-node.cjs"
},
"default": "./feature.mjs"
}
}
我们可以通过如下方式指定条件:
node --conditions=development main.js
上面介绍了几种模块导出方式,这里需要强调的一点是,exports
显示定义了包导出的模块,未在 exports
导出的模块,外界不可访问。exports
给了包的开发者定义对外 API 的能力。
imports
我们可以通过 imports
定义导入包内模块的快捷方式。imports
字段中所有的 key 都需要以 #
开头。
{
"imports": {
"#dep": {
"node": "dep-node-native",
"default": "./dep-polyfill.js"
}
},
"dependencies": {
"dep-node-native": "^1.0.0"
}
}
与 exports
不同的是,imports
允许导入包外模块。上面的样例中,在 node
条件下,import '#dep'
会导入 dep-node-native
,在其他环境中,会导入 ./dep-polyfill.js
。
imports
也支持子路径导入,与 exports
类似,如下:
{
"imports": {
"#internal/*": "./src/internal/*.js"
}
}
效果如下:
import internalZ from '#internal/z';
// Loads ./node_modules/es-module-package/src/internal/z.js
小结
本文介绍了如何在浏览器和 NodeJS 中使用 ES Module 的方法。
如果你只是单纯的做页面开发,借助于成熟的构建工具,可能不太需要注意这些细节。但是掌握了基本原理,可以更好的帮助我们排查问题。
如果你是包开发者,那么如果想要使用 ES Module 并且想让包的使用者也能享受到 ES Module 的优点的话,就需要对模块的导入导出非常熟悉了。
常见面试知识点、技术方案分析、教程,都可以扫码关注公众号“众里千寻”获取,或者来这里 https://everfind.github.io/posts/ 。