了解 JavaScript 模块系统的基础知识,并建立自己的库 🍜

643 阅读11分钟

身为"前端工程师",我想很多人都听过说过"JavaScript 模块"。那你们每个人都知道如何处理它,以及它在日常工作中如何发挥作用吗?

JS 模块系统到底是什么呢

随着 JavaScript 开发越来越广泛,命名空间和依赖项变得越来越难以处理。极客时们早已经开发出不同的 模块系统 解决方案来解决该问题。

为什么理解 JS 模块系统很重要

我的日常工作是设计和项目架构,并且我很快意识到跨项目需要许多通用功能。我总是一次又一次地将这些功能复制粘贴到新项目中。

问题是,每当更改一部分代码时,我都需要在所有项目中手动同步这些更改。为了避免所有这些繁琐的手动任务,我决定 提取通用功能并从中组成一个 NPM 软件包 。这样,团队中的其他人将能够将它们重新用作依赖项,并在每次推出新版本时都可以对其进行更新。

这种方法具有一些优点:

  • 如果核心库中有一些更改,则只需在一个地方进行更改,而无需为同一件事重构所有应用程序的代码。
  • 所有应用程序保持同步。无论何时进行更改,所有应用程序仅需要运行 npm update 命令。
Source code of library

因此,下一步是 发布 library 。

这是最困难的部分,因为我脑海中突然跳出一堆东西,例如:

  1. 如何使用 tree shaking?
  2. 应该针对哪些 JS 模块系统(CommonJS、AMD、ES modules)。
  3. 需要转译源码吗?
  4. 需要打包源码吗?
  5. 应该发布哪些文件?

在发布第三方库(组件库、工具库)时,我们每个人的脑海中都应该冒出这些问题。

来, 我们一步步解决以上的问题。

不同类型的 JS 模块系统

1. CommonJS

  • 由 Node.js 实现。
  • 在安装模块时用于 服务器端
  • 没有 runtime/async 模块
  • 通过 require 导入模块
  • 通过 module.exports 导出模块
  • 当你导入时,你会获得一个对象
  • 无法使用 tree shaking,因为当你 import 时会得到一个对象
  • 因为你得到的是一个对象,所以属性查找在运行时进行,无法静态分析
  • You always get a copy of an object, so no live changes in the module itself(你总是会得到一个对象的副本,因此模块本身不会实时更改)
  • 循环依赖管理不优雅
  • 语法简单

2. AMD: Async Module Definition 异步模块定义

  • 由 RequireJS 实现
  • 当你在 客户端(浏览器)环境 中,异步加载模块时使用
  • 通过 require 实现导入
  • 语法复杂

3. UMD: Universal Module Definition 通用模块定义

  • CommonJS + AMD 的组合(即 CommonJS 的语法 + AMD 的异步加载)
  • 可以用于 AMD/CommonJS 环境
  • UMD 还支持全局变量定义。因此,UMD 模块能够在 客户端和服务器 上工作。

4. ES modules

  • 用于 服务器/客户端

  • 支持模块的 Runtime/static loading

  • 当你 import 时,获得是 绑定值(实际值)

  • 通过 import 导入,通过 export 导出

  • Static analyzing (静态分析)

    You can determine imports and exports at compile time (statically) — you only have to look at the source code, you don’t have to execute it

  • 由于 ES6 支持 静态分析 ,因此 Tree Shaking 是可行的

  • 始终获取 实际值 ,以便 实时更改模块本身

  • 比 CommonJS 有更好的循环依赖管理

现在,我们了解了不同类型的 JS 模块系统以及它们如何演变。

尽管所有工具和现代浏览器都支持 ES modules ,但我们在发布库时不知道用户如何利用我们的库。因此,我们必须确保我们的库在所有环境中都能正常工作。

让我们深入研究并设计一个示例库,以适当方式来回答,与 publishing a library(发布库)有关的所有问题。

我已经建立了一个小型的 UI 库(你可以在GitHub上找到源代码),并且我将分享我在编译,打包和发布中的所有经验和探索。

Directory Structure

在这里,我们有一个小的 UI 库,其中包含 3 个组件:Button,Card 和 NavBar。让我们一步步进行编译并发布。

发布前的最佳实践

1. Tree Shaking

webpack 官方文档有说明

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的 dead-code(未引用代码)。它依赖于 ES2015 模块系统中的静态结构特性,例如 import 和 export。这个术语和概念实际上是兴起于 ES2015 模块打包工具 Rollup。 新的 webpack 4 正式版本,扩展了这个检测能力,通过 package.json 的 "sideEffects" 属性作为标记,向 compiler 提供提示,表明项目中的哪些文件是 "pure(纯的 ES2015 模块)",由此可以安全地删除文件中未使用的部分。

webpack 和 Rollup 都支持 Tree Shaking,这意味着我们需要牢记某些事情,以便我们的代码可被 Tree Shaking。

2.Publish all module variants(发布所有模块形态)

  • 我们应该发布所有模块形态,例如 UMD 和 ES ,因为我们永远不知道我们的使用者在哪个浏览器/ webpack 版本中使用此库/包。
  • 即使所有打包程序(如 webpack 和 Rollup )都能解析 ES 模块,但如果我们的使用者使用的是 webpack 1.x,则它无法解析 ES 模块。
// package.json
{
    "name": "js-module-system",
    "version": "0.0.1",
    "main": "dist/index.js",
    "module": "dist/index.es.js"
}
  • package.json 文件的 main 字段通常用于指向 UMD 版本的库/包。
  • package.json 文件的 module 字段用于指向 ES 版本的库/包。

鲜为人知的事实 :webpack 使用 resolve.mainfields 确定检查 package.json 中的哪些字段。

性能提示 :由于所有现代浏览器现在都支持 ES 模块 ,因此也请务必发布 ES 版本 的库/包。这样一来,可以减少编译次数,最终可以减少向用户交付的代码。这将提高应用程序的性能。

那么,下一步是什么? 编译还是打包? 我们应该使用什么工具? 啊,这是最棘手的部分! 让我们深入研究研究。

webpack vs Rollup vs Babel

webpack、Rollup、Babel 这些我们在日常工作中使用的工具,用于承载我们的应用程序/库/软件包。没有它们,我无法想象现代的 Web 开发有多么糟糕。因此,我们无法将它们进行比较 ❌

每种工具都有其自身的优势,并根据使用者的需求达到不同的目的。

现在让我们看一下这些工具:

webpack

webpack 是一个很棒的模块打包工具, 它被广泛接受并且主要用于构建 SPA。它提供了开箱即用的所有功能,例如  code splitting、async loading of bundles、 tree shaking等等。并且它本身使用的是 CommonJS 模块系统。

PS:webpack-4.0.0 Alpha已经淘汰了。希望随着稳定版的发布,它将成为所有类型模块系统的通用打包工具。

RollupJS

RollupJS 还是类似于 webpack 的模块打包器。但是,RollupJS 的主要优点是它遵循 ES6 修订版中包含的代码模块的新标准化格式,因此你可以使用它来打包 ES module variant 的库/包。但它不支持 async loading of bundles。

Babel

Babel 是 JavaScript 的编译器,以将 ES6 代码转换为可在你的浏览器(或服务器)中运行的代码而闻名。请记住,它只是编译而不会打包你的代码。

我的建议:对库使用 Rollup.js,对应用程序使用 webpack

编译(Babel-ify)源代码还是直接打包源代码

在构建我的 NPM 库时,我花费了大量时间来试图找出该问题(如何编译?如何打包)的答案。我开始挖掘自己的 node_modules,查找所有优秀的库并检查它们的构建系统。

Libraries vs Packages build output comparision

在查看了不同库/包的构建输出之后,我清楚地了解了这些库的作者在发布之前可能会想到的不同策略。以下是我的观察。

如你在上图中所看到的,我已根据它们的特性将这些库/软件包分为两组:

  • UI Libraries-UI 库(styled-components、material-ui)
  • Core Packages-核心包(react、react-dom)

如果你是一个好观察者,你可能已经弄清楚了这两组之间的区别。

UI Libraries

  • 有一个 dist 文件夹,该文件夹是针对 ES 和 UMD/CJS 模块系统 的打包和压缩版本。
  • 有一个 lib 文件夹,用来存放被编译后的代码。

Core Packages

  • 只有一个文件夹,其中包含针对 CJS 或 UMD 模块系统 的打包和压缩版本。

但是,为什么 UI Libraries 和 Core Packages 的构建输出有所不同??

UI Libraries

想象一下,如果我们只是发布库的 bundled version  将其托管在 CDN 上。我们的用户将直接在 <script /> 标记中使用它。现在,如果使用者只想使用 <Button /> 组件,则他们必须加载整个库。另外,在浏览器中,没有可以解决 tree shaking 的打包工具,最终我们会将整个库代码发送给我们的使用者。因此,我们不能像如下代码引入整个库文件

<script type="module">
    import { Button } from 'https://unpkg.com/uilibrary/index.js';
</script>

现在,如果我们只是简单地将 src 转换为 lib 并将该 lib 托管在 CDN 上,那么我们的使用者实际上可以得到他们想要的任何东西而没有任何开销。"代码更少,加载更快"。✅

<script type="module">
    import { Button } from 'https://unpkg.com/uilibrary/lib/button.js';
</script>

Core Packages

核心软件包永远不会通过 <script /> 标记使用,因为它们必须是主应用程序的一部分。因此,我们可以安全地发布这些软件包的 bundled version(UMD、ES),并将构建系统交给用户使用。

例如,他们可以使用 UMD 形态而不使用 tree shaking,或者如果打包器能够识别并获得 tree shaking 的好处,则可以使用 ES 形态。

// CJS require
const Button = require("uilibrary/button");

// ES import
import {Button} from "uilibrary";

但是…… 关于我们的问题是什么:should we transpile (Babelify) the source or bundle it??

对于 UI 库

  • 当我们针对 ES 模块系统构建时,需要 Babel 编译源代码,并将编译后的代码放置在 lib 文件夹中。我们甚至可以将 lib 托管在 CDN 上。
  • 当我们针对 CJS/UMD 模块系统和 ES 模块系统 等多个模块系统构建时,需要 Rollup 📦 打包和压缩代码。

下面我们修改 package.json 以指向对应的模块系统。

// package.json
{
    "name": "js-module-system",
    "version": "0.0.1",

    // for umd/cjs builds
    "main": "dist/index.js",

    // for es build
    "module": "dist/index.es.js"
}

对于 core packages

我们不需要 lib 版本。我们只需要针对 CJS/UMD 模块系统和 ES 模块系统,使用 Rollup 进行 📦 打包和压缩源代码即可。

提示:对于愿意通过 <script /> 标记下载整个库/软件包的用户,我们也可以在 CDN 上托管 dist 文件夹 。

我们怎么进行打包

我们应在在 package.json 中为了不同的目的编写不同的脚本. 你能在 GitHub 上面找到 Rollup 的一些配置- Rollup config

// package.json
{
    "scripts": {
        "clean": "rimraf dist",
        "build": "run-s clean && run-p build:es build:cjs build:lib:es",
        "build:es": "NODE_ENV=es Rollup -c",
        "build:cjs": "NODE_ENV=cjs Rollup -c",
        "build:lib:es": "BABEL_ENV=es babel src -d lib"
    }
}

我们应该发布哪些东西

  • License
  • README
  • Changelog
  • Metadata ("main", "module", "bin") — package.json
  • Control through package.json "files" property

在 package.json 中, the "files" 字段是一个数组类型 ,用来指示 软件包被当做第三方依赖安装时,都有哪些文件或文件夹需要下载到业务项目中。如果你在数组中加入了一个文件夹,那么在你 npm install 时, 文件夹及下面的文件都会被下载。

在我的案例项目中, 我在 "files" 中加入了 lib 和 dist 文件夹

// package.json
{
    "files": ["dist", "lib"]
}

最后,终于可以准备发布了。只需在终端中键入 npm run build 命令,你将看到以下输出。仔细查看 dist 和 lib 文件夹都有哪些东西

Ready to publish ?

总结

至此,我们已经了解了 JavaScript 模块系统以及如何创建自己的库并发布它。 下面是一些注意事项:

  1. 是否可以启用 Tree Shaking?
  2. 至少需要构建 ES modules and CJS 两种模块系统
  3. Use Babel and Bundlers for libraries.
  4. Use Bundlers for Core packages.
  5. 在 package.json 中使用 "module" 字段 来构建 ES 模块的版本(PS: 这有助于使用 tree shaking)
  6. 发布已编译的文件夹以及模块的 bundled versions