使用 ES Module 的正确姿势

前面我们在深入理解 ES Module 中详细介绍过 ES Module 的工作原理。目前,ES Module 已经在逐步得到各大浏览器厂商以及 NodeJS 的原生支持。像 vite 等新一代的构建工具已经逐步使用 ES Module 并有计划的运用到生产环境中。因此,了解如何在浏览器以及 NodeJS 中使用 ES Module 是必要的。

在浏览器中使用

支持 ES Module 的浏览器通过 script 标签上的 type 字段来识别 ES Module,即 type=module 就是 ES Module。

<script type="module">
  import { foo } from 'bar';
  export default foo;
</script>
<script type="module" src="/path/to/script"></script>

浏览器在遇到 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。我们简单说明一下。

<script type="importmap">
{
  "imports": {
    "moment": "/node_modules/moment/src/moment.js",
    "lodash": "/node_modules/lodash-es/lodash.js",
  }
}
</script>

通过 type=importmap 的 script 标签,来告诉浏览器可以在哪里找到这些模块。

import-map 的浏览器支持情况

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 包的名称,可以与 importsexports 配合使用
  • main 包的默认导出模块
  • type 用于在加载 .js 文件时确定模块类型
  • exports 指定包导出了哪些模块
  • imports 包导入了哪些模块,只供包内部使用

main 字段指定包的默认导出模块,在所有 NodeJS 版本中都适用。同时,exports 字段也可以定义包的入口点,而且除了 exports 定义的入口点以外,包内的其他模块将对外不可见,即 exports 同时还提供了一定的封装特性。

mainexports 同时定义的时候,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-addonsnode 类似,用于 NodeJS 插件。
  • import 当通过 import 或者 import() 方式加载模块时使用,与 require 互斥。
  • require 当通过 require() 方式加载模块时使用,与 import 互斥。
  • default 兜底方案,目标文件可以为 CommonJS 文件也可以为 ES Module 文件,通常排在最后。

exports 字段中 key 的顺序至关重要,排在前面的优先级更高。因此,排在前面的通常是条件要求最严格的,排在后面的通常是要求最宽泛的。

除了上面官方支持的几个条件以外,社区还定义了 typesdenobrowserdevelopmentproduction 等条件。

子路径导出也支持设置条件,如下:

{
  "main": "./main.js",
  "exports": {
    ".": "./main.js",
    "./feature": {
      "node": "./feature-node.js",
      "default": "./feature.js"
    }
  }
}

同时,条件导出还支持嵌套,如下,在 node 条件下,又区分了 importrequire 条件。

{
  "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/
众里千寻

posted @ 2022-01-22 12:13  西风D碧树  阅读(937)  评论(0编辑  收藏  举报