Importing a frontend Javascript library without a build system

Julia Evans

在不使用构建系统的情况下导入前端 JavaScript 库

我喜欢在不使用构建系统的情况下编写 JavaScript,昨天我第无数次遇到了一个问题:需要在不使用构建系统的情况下在代码中导入一个 JavaScript 库,而弄清楚如何导入它花了极长的时间,因为该库的配置说明默认你正在使用构建系统。

幸运的是,到目前为止我已经基本学会了如何应对这种情况——要么成功使用这个库,要么判断它太麻烦而换用另一个库,所以这就是我希望几年前就能看到的 JavaScript 库导入指南。

本文只讨论在前端使用 JavaScript 库,以及只讨论如何在不使用构建系统的配置下使用它们。

在这篇文章中,我将讨论:

  1. 库可能提供的三种主要 JavaScript 文件类型(ES Modules(ES 模块)、“经典”全局变量类型和 CommonJS(CommonJS 模块))
  2. 如何判断一个 JavaScript 库在其构建产物中包含了哪些类型的文件
  3. 在代码中导入每种类型文件的方法

三种 JavaScript 文件类型

库可以提供 3 种基本类型的 JavaScript 文件:

  1. 定义全局变量的“经典”类型文件。这种文件只需用 <script src> 引入就能直接工作。如果能拿到当然很好,但并不总是能拿到
  2. ES module(可能依赖其他文件,也可能不依赖,稍后会讲到)
  3. “CommonJS” 模块。这是给 Node 用的,不使用构建系统就完全无法在浏览器中使用。

我不确定“经典”类型是否有更好的名字,但我就称它为“经典”类型。另外还有一种叫做“AMD”的类型,但我不确定它在 2024 年还有多大用处。

既然已经了解了这 3 种文件类型,接下来聊聊如何判断一个库到底提供了其中的哪几种!

去哪里找文件:NPM 构建产物

每个 JavaScript 库都有一个会上传到 NPM 的构建产物。你可能会想(就像我最初想的那样)——Julia(茱莉亚)!重点是我们根本不用 Node 来构建库!为什么还要谈 NPM?

但如果你使用的是来自 CDN 的链接,比如 https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js,你其实仍在使用 NPM 构建产物!CDN 上的所有文件最初都来自 NPM。

正因如此,即使我完全不打算用 Node 来构建我的库,我有时也喜欢先 npm install 一下这个库——我会新建一个临时文件夹,在里面执行 npm install,用完再删掉。我喜欢在本地文件系统里翻看 NPM 构建产物中的文件,因为这样我就能百分之百确定自己看到了该库在构建中提供的所有内容,而不会被 CDN 隐藏了什么。

那么我们来 npm install 几个库,试着看看它们在构建产物中提供了哪些类型的 JavaScript 文件!

示例库 1:chart.js

首先来看看绘图库 Chart.js 内部是什么样的。

$ cd /tmp/whatever
$ npm install chart.js
$ cd node_modules/chart.js/dist
$ ls *.*js
chart.cjs  chart.js  chart.umd.js  helpers.cjs  helpers.js

这个库似乎有 3 种基本选项:

选项 1: chart.cjs.cjs 后缀告诉我这是一个CommonJS 文件,用于在 Node 中使用。这意味着不经过某种构建步骤就无法直接在浏览器中使用它。

选项 2:chart.js.js 后缀本身并不能说明它是什么类型的文件,但如果打开它,我会看到 import '@kurkle/color';,这立刻表明它是一个 ES module——import ... 语法就是 ES module 语法。

选项 3:chart.umd.js。“UMD” 代表 “Universal Module Definition(通用模块定义)”,我认为这意味着你可以通过基本的 <script src>、CommonJS 或我不太理解的第三种叫 AMD 的方式来使用这个文件。

如何使用 UMD 文件

在使用 Chart.js 时我选择了选项 3。我只需要在代码中添加这一行:

<script src="./chart.umd.js"> </script>

然后我就可以通过全局的 Chart 变量来使用这个库了。再简单不过了。我直接把 chart.umd.js 复制到了我的 Git 仓库里,这样就不用担心 NPM 或 CDN 挂掉之类的问题了。

构建文件并不总是在 dist 目录中

很多库会把构建产物放在 dist 目录中,但并非总是如此!构建文件的位置是在库的 package.json 中指定的。

例如,这是 Chart.js 的 package.json 中的一段摘录。

  "jsdelivr": "./dist/chart.umd.js",
  "unpkg": "./dist/chart.umd.js",
  "main": "./dist/chart.cjs",
  "module": "./dist/chart.js",

我认为这是在说,如果你想使用 ES Module(module),就应该使用 dist/chart.js,而 jsDelivr 和 unpkg 这两个 CDN 则应该使用 ./dist/chart.umd.js。我想 main 是给 Node 用的。

chart.jspackage.json 还写着 "type": "module"根据这份文档,这会告诉 Node 默认将文件当作 ES modules 处理。我认为它并没有具体说明哪些文件是 ES modules、哪些不是,但它确实告诉我们里面有东西是 ES module。

示例库 2:@atcute/oauth-browser-client

@atcute/oauth-browser-client 是一个用于在浏览器中通过 OAuth 登录 Bluesky 的库。

来看看它在构建产物中提供了哪些类型的 JavaScript 文件!

$ npm install @atcute/oauth-browser-client
$ cd node_modules/@atcute/oauth-browser-client/dist
$ ls *js
constants.js  dpop.js  environment.js  errors.js  index.js  resolvers.js

看起来这里唯一合理的入口文件是 index.js,它看起来像这样:

export { configureOAuth } from './environment.js';
export * from './errors.js';
export * from './resolvers.js';

这种 export 语法意味着它是一个ES module。这意味着我们可以在不使用构建步骤的情况下在浏览器中使用它!来看看具体怎么做。

如何通过 importmaps(导入映射) 使用 ES module

使用 ES module 并不像简单地添加一个 <script src="whatever.js"> 那么容易。相反,如果 ES module 有依赖(比如 @atcute/oauth-browser-client 就有),步骤是:

  1. 在 HTML 中设置 import map
  2. 在 JS 代码中加入类似 import { configureOAuth } from '@atcute/oauth-browser-client'; 的导入语句
  3. 像这样在 HTML 中引入你的 JS 代码:<script type="module" src="YOURSCRIPT.js"></script>

我们需要 import map 而不是直接写类似 import { BrowserOAuthClient } from "./oauth-client-browser.js" 的原因在于,该模块内部还有更多类似 import {something} from @atcute/client 的导入语句,我们需要告诉浏览器去哪里获取 @atcute/client 及其所有其他依赖的代码。

这是我为 @atcute/oauth-browser-client 使用的 importmap 的样子:

<script type="importmap">
{
  "imports": {
    "nanoid": "./node_modules/nanoid/bin/dist/index.js",
    "nanoid/non-secure": "./node_modules/nanoid/non-secure/index.js",
    "nanoid/url-alphabet": "./node_modules/nanoid/url-alphabet/dist/index.js",
    "@atcute/oauth-browser-client": "./node_modules/@atcute/oauth-browser-client/dist/index.js",
    "@atcute/client": "./node_modules/@atcute/client/dist/index.js",
    "@atcute/client/utils/did": "./node_modules/@atcute/client/dist/utils/did.js"
  }
}
</script>

让这些 import maps 正常工作相当繁琐,我觉得肯定有能自动生成它们的工具,但我还没找到。确实可以编写一个脚本,利用 esbuild 的 metafile 来自动生成 importmaps,但我还没这么做,也许还有更好的办法。

我昨天决定设置 importmaps 来让 github.com/jvns/bsky-oauth-example 跑起来,所以那个仓库里有一些示例代码。

还有人向我推荐了 Simon Willison(西蒙·威利森)的 download-esm,它会下载一个 ES module 并重写导入语句以直接指向 JS 文件,这样就不需要 importmaps 了。我还没试过,但听起来是个很棒的主意。

importmaps 的问题:文件太多

不过,在浏览器中使用 importmaps 时我确实遇到了一些问题——加载我的网站需要下载几十个 JavaScript 文件,而开发环境中的网页服务器不知为何跟不上。我经常看到文件随机加载失败,然后不得不刷新页面,祈祷这次能成功。

当我把网站部署到生产环境后就不再有这个问题了,所以我想这应该是本地开发环境的问题。

另外,关于 ES modules 总体而言还有一个有点烦人的地方,就是你需要运行一个网页服务器才能使用它们,我确信这肯定是有充分理由的,但如果能直接打开 index.html 文件而无需启动服务器会更方便。

因为“文件太多”这个问题,我觉得以这种方式配合 importmaps 使用 ES modules 其实对我来说吸引力不大,但知道可以这么做还是挺好的。

如何在不使用 importmaps 的情况下使用 ES module

如果 ES module 没有依赖,那就更简单了——你不需要 importmaps!你只需:

  • 在 HTML 中放入 <script type="module" src="YOURCODE.js"></script>type="module" 很重要。
  • YOURCODE.js 中写入 import {whatever} from "https://example.com/whatever.js"

替代方案:使用 esbuild

如果你不想使用 importmaps,也可以使用像 esbuild 这样的构建系统。我在 关于使用 esbuild 的一些笔记 中谈过具体做法,但这篇博文讲的是完全避免构建系统的方法,所以这里就不再讨论这个选项了。不过我仍然喜欢 esbuild,并且认为在这种情况下它是个不错的选择。

importmaps 的浏览器支持情况如何?

CanIUse 显示 importmaps 处于“Baseline 2023:已在主流浏览器中新近可用”阶段,所以我的感觉是到 2024 年可能还是有点太新了?我觉得我会把 importmaps 用在一些有趣的实验性代码上,只给自己和十几个人用,但如果想让代码更广泛可用,我会改用 esbuild

示例库 3:@atproto/oauth-client-browser

我们再来看最后一个示例库!这是一个与 @atcute/oauth-browser-client 不同的 Bluesky 认证库。

$ npm install @atproto/oauth-client-browser
$ cd node_modules/@atproto/oauth-client-browser/dist
$ ls *js
browser-oauth-client.js  browser-oauth-database.js  browser-runtime-implementation.js  errors.js  index.js  indexed-db-store.js  util.js

同样,这里唯一真正的候选文件似乎是 index.js。但这与前一个示例库的情况不同!来看看 index.js

index.js 中有很多类似这样的内容:

__exportStar(require("@atproto/oauth-client"), exports);
__exportStar(require("./browser-oauth-client.js"), exports);
__exportStar(require("./errors.js"), exports);
var util_js_1 = require("./util.js");

这种 require() 语法是 CommonJS 语法,这意味着我们完全无法在浏览器中使用这个文件,需要某种构建步骤,而且 ESBuild 也不行。

另外,在这个库的 package.json 中写着 "type": "commonjs",这也是判断它是 CommonJS 的另一种方式。

如何通过 esm.sh 使用 CommonJS 模块

最初我以为不学习构建系统就不可能使用 CommonJS 模块,但在 Bluesky 上有人告诉我 esm.sh!它是一个能把任何东西转换成 ES Module 的 CDN。skypack.dev 也做类似的事情,我不确定它们有什么区别,但有人提到如果一个不行,有时会试试另一个。

对于 @atproto/oauth-client-browser 来说,使用它似乎相当简单,我只需要在 HTML 中放入:

<script type="module" src="script.js"> </script>

然后在 script.js 中写入:

import { BrowserOAuthClient } from "https://esm.sh/@atproto/[email protected]"

它似乎就能直接工作了,很酷!当然,这某种程度上仍然是在使用构建系统——只是构建是由 esm.sh 而不是我来运行的。我对这种方法的主要顾虑是:

  • 我不太相信 CDN 能永远稳定工作——通常我喜欢把依赖复制到自己的仓库里,这样它们将来就不会因为某种原因消失。
  • 我听说过 CDN 出现安全被攻破的问题,这让我很担心。
  • 我不太理解 esm.sh 到底在做什么。

esbuild 也可以将 CommonJS 模块转换为 ES modules

我还了解到,你也可以使用 esbuild 将 CommonJS 模块转换为 ES module,不过有一些限制——import { BrowserOAuthClient } from 这种语法不起作用。这里有一个 关于此问题的 GitHub issue

我认为 esbuild 的方案对我来说可能比 esm.sh 的方案更有吸引力,因为它是我电脑上已有的工具,所以我更信任它。不过我还没怎么实验过这个。

三种文件类型的总结

以下是你可能遇到的三种 JS 文件类型的总结、它们的使用方法以及如何识别它们。

让人头疼的是,.js.min.js 文件扩展名可能是这 3 种选项中的任何一种,所以如果文件是 something.js,你就需要做更多的侦查工作来弄清楚自己面对的是哪一种。

  1. “经典” JS 文件
    • 使用方法:: <script src="whatever.js"></script>
    • 识别方法:
      • 网站的配置说明中有一个显眼友好的横幅写着“通过 CDN 使用!”之类的话
      • .umd.js 扩展名
      • 直接试着把它放到 <script src=... 标签里看看是否能工作
  2. ES Modules
    • 使用方法:
      • 如果没有依赖,直接在代码中写 import {whatever} from "./my-module.js"
      • 如果有依赖,创建一个 importmap 并写 import {whatever} from "my-module"
      • 使用 esbuild 或任何 ES Module 打包器
    • 识别方法:
      • 寻找 import export 语句。(不是 module.exports = ...,那是 CommonJS)
      • .mjs 扩展名
      • 可能是 package.json 中的 "type": "module"(不过我不清楚这具体指的是哪个文件)
  3. CommonJS 模块
    • 使用方法:
    • 识别方法:
      • 在代码中寻找 require()module.exports = ...
      • .cjs 扩展名
      • 可能是 package.json 中的 "type": "commonjs"(不过我不清楚这具体指的是哪个文件)

ES modules 实现了标准化真是太好了

在我看来,CommonJS 模块和 ES modules 之间的主要区别在于 ES modules 实际上是一个标准。这让我在使用它们时更有信心,因为浏览器会对网页标准永远保持向后兼容——如果我今天用 ES modules 写了一些代码,我可以确信 15 年后它仍会以同样的方式工作。

这也让我对使用像 esbuild 这样的工具感觉更踏实,因为即使 esbuild 项目停止维护,由于它实现的是一个标准,未来很可能会有另一个类似的工具可以替代它。

JS 社区已经构建了许多非常酷的工具

很多时候当我谈论这些东西时,会得到类似“我讨厌 JavaScript!!!它最烂了!!!”的回应。但我的体验是,JavaScript 有很多很棒的工具(我昨天刚了解到 https://esm.sh,看起来很棒!我喜欢 esbuild!),而且如果我花时间去了解它们是如何工作的,我就能利用其中一些工具让生活轻松很多。

所以这篇文章的目标绝对不是抱怨 JavaScript,而是了解全貌,以便我能以让自己感觉舒服的方式使用这些工具。

我仍有的疑问

以下是我仍有的一些疑问,如果我找到了答案,会把它们补充到文章中。

  • 有没有能为我在本地设置好的 ES Module 自动生成 importmaps 的工具?(显然有:jspm
  • 我怎样才能像 https://esm.sh 那样在本地电脑上把 CommonJS 模块转换成 ES module?(显然 esbuild 在某种程度上可以做到,不过 命名导出不起作用
  • 当人们通常把 CommonJS 模块构建成普通的 JS 代码时,是什么代码在做这件事?显然有 webpack、rollup、esbuild 等工具,但这些工具是否都实现了自己的 JS 解析器/静态分析?外面到底有多少 JS 解析器?
  • 有没有办法把一个 ES module 打包成单个文件(比如 atcute-client.js),但在浏览器中我仍然可以从该文件导入多个不同的路径(比如同时导入 @atcute/client/lexicons@atcute/client)?

所有工具

以下是我们在本文中谈到的所有工具的列表:

写这篇文章让我想到,尽管我通常不想在每次更新项目时都运行构建,但我或许愿意拥有一个构建步骤(使用 download-esm 之类的工具),在搭建项目时只运行一次,之后除非更新依赖版本,否则再也不运行。

就是这些!

感谢 Marco Rogers(马可·罗杰斯) 教会了我本文中的许多内容。这篇文章中可能有一些错误,我很想知道是哪些——请在 Bluesky 或 Mastodon 上告诉我!

原文由 Julia Evans 发布

本文章由 muse-spark-1.2-contributor 进行翻译