不使用构建系统引入前端 JavaScript 库
原文由 Julia Evans 于 发布,订阅该博客
我喜欢不使用构建系统来写 JavaScript,昨天我第无数次遇到了一个问题:需要在不使用构建系统的情况下在代码中引入一个 JavaScript 库,花了很久才弄明白怎么引入,因为这个库的接入文档默认你正在使用构建系统。
好在到了现在,我已经基本学会了如何应对这种情况——要么成功用上这个库,要么判断它太麻烦而换用别的库。所以,这就是我希望几年前就能看到的 JavaScript 库引入指南。
本文只讨论在前端使用 JavaScript 库,而且只讨论在不使用构建系统的环境下如何使用它们。
在这篇文章中,我会聊到:
- 库可能会提供的三种主要 JavaScript 文件类型(ES Module、定义全局变量的“传统”类型,以及 CommonJS)
- 如何判断一个 JavaScript 库的构建产物中包含了哪些类型的文件
- 在代码中引入每种类型文件的具体方法
JavaScript 文件的三种类型
库可以提供三种基本类型的 JavaScript 文件:
- 定义全局变量的“传统”类型文件。这类文件直接用
<script src>就能用。能拿到当然最好,但并不总会提供 - ES module(可能依赖其他文件,也可能不依赖,后面会讲到)
- “CommonJS”模块。这是给 Node 用的,不通过构建系统就完全无法在浏览器中使用。
我不确定“传统”类型有没有更准确的叫法,就先这么叫了。另外还有一种叫“AMD”的类型,但在 2024 年是否还有实际意义,我也不太清楚。
既然已经了解了这三种文件类型,接下来聊聊如何判断一个库到底提供了其中的哪几种!
去哪里找这些文件: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 构建产物中的文件,这样就能百分之百确定自己看到的是库在构建产物中提供的所有内容,而不会被 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
这个库似乎提供了三种基本选择:
选项 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.js 的 package.json 中还有 "type": "module",根据这份文档,这表示让 Node 默认将文件当作 ES module 来处理。我觉得它并没有明确指出具体哪些文件是 ES module、哪些不是,但至少说明里面有 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。这意味着我们可以在不使用构建步骤的情况下直接在浏览器中使用它!来看看具体怎么做。
如何通过 importmap 使用 ES module
使用 ES module 并不像直接加一个 <script src="whatever.js"> 那么简单。相反,如果 ES module 有依赖(比如 @atcute/oauth-browser-client 就有),步骤是:
- 在 HTML 中设置 import map
- 在 JS 代码中写入类似
import { configureOAuth } from '@atcute/oauth-browser-client';的导入语句 - 在 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 map 正常工作挺繁琐的,我总觉得应该有能自动生成它们的工具,但还没找到。完全可以写个脚本利用 esbuild 的 metafile 来自动生成 importmap,但我还没这么做,也许有更好的办法。
昨天我为了让 github.com/jvns/bsky-oauth-example 跑起来而配置了 importmap,所以那个仓库里有一些示例代码。
另外,有人向我推荐了 Simon Willison 的 download-esm,它会下载一个 ES module 并重写其中的导入路径,使其直接指向 JS 文件,这样就不需要 importmap 了。我还没试过,但听起来是个很棒的主意。
importmap 的问题:文件太多
不过,我在使用浏览器端的 importmap 时确实遇到了一些问题——加载我的网站需要下载几十个 JavaScript 文件,而开发环境下的 web 服务器不知为何跟不上。我经常看到有文件随机加载失败,然后不得不刷新页面,寄希望于这次能成功。
部署到生产环境后就不再有这个问题了,所以我想应该是本地开发环境的问题。
另外,ES module 总体上还有一个有点烦人的地方,就是必须启动一个 web 服务器才能使用它们,我确信这么设计是有充分理由的,但如果能直接打开 index.html 文件而不用启动服务器,当然会更方便。
因为“文件太多”这个问题,我觉得以这种方式使用带 importmap 的 ES module 对我来说其实吸引力不大,但知道可以这么做还是很有用的。
如何在不使用 importmap 的情况下使用 ES module
如果 ES module 没有依赖,那就更简单了——完全不需要 importmap!你只需要:
- 在 HTML 中加入
<script type="module" src="YOURCODE.js"></script>。type="module"很重要。 - 在
YOURCODE.js中写入import {whatever} from "https://example.com/whatever.js"
替代方案:使用 esbuild
如果你不想使用 importmap,也可以使用像 esbuild 这样的构建系统。我在《Some notes on using esbuild》一文中谈过具体做法,但这篇博客是关于完全避免构建系统的方法,所以这里就不展开这个选项了。不过我依然很喜欢 esbuild,也认为在这种情况下它是个不错的选择。
importmap 的浏览器支持情况如何?
CanIUse 显示 importmap 处于“Baseline 2023:各大主流浏览器新近可用”的状态,所以我的感觉是,到了 2024 年它可能还是有点太新了?我想我会在一些只有我和十几个人会用的有趣实验性代码中使用 importmap,但如果想让代码更广泛可用,我还是会选择 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 module
我还了解到,也可以用 esbuild 将 CommonJS 模块转换成 ES module,不过有一些限制——import { BrowserOAuthClient } from 这种语法无法使用。这里有一个相关的 GitHub issue。
我觉得 esbuild 的方案可能比 esm.sh 的方案对我更有吸引力,因为它是我电脑上已有的工具,所以我更信任它。不过我还没怎么尝试过。
三种文件类型的总结
下面总结一下你可能会遇到的三种 JS 文件类型、各自的使用方法以及如何识别它们。
不太方便的是,.js 或 .min.js 扩展名可能是这三种类型中的任何一种,所以如果文件是 something.js,你需要多做一些甄别工作才能弄清楚它到底属于哪一种。
- “传统”JS 文件
- 使用方法::
<script src="whatever.js"></script> - 识别方法:
- 网站的接入说明里有显眼友好的横幅写着“通过 CDN 使用!”之类的话
.umd.js扩展名- 直接试着用
<script src=...标签引入,看看是否能用
- 使用方法::
- ES Module
- 使用方法:
- 如果没有依赖,直接在代码中写
import {whatever} from "./my-module.js" - 如果有依赖,创建一个 importmap,然后写
import {whatever} from "my-module"- 或者使用 download-esm 来省去 importmap
- 使用 esbuild 或任意 ES Module 打包工具
- 如果没有依赖,直接在代码中写
- 识别方法:
- 查找
import或export语句。(不是module.exports = ...,那是 CommonJS) .mjs扩展名- 可能是
package.json中的"type": "module"(不过我不太清楚它具体指的是哪个文件)
- 查找
- 使用方法:
- CommonJS 模块
- 使用方法:
- 使用 https://esm.sh 将其转换为 ES module,比如
https://esm.sh/@atproto/[email protected] - 用某种构建方式(??)
- 使用 https://esm.sh 将其转换为 ES module,比如
- 识别方法:
- 在代码中查找
require()或module.exports = ... .cjs扩展名- 可能是
package.json中的"type": "commonjs"(不过我不太清楚它具体指的是哪个文件)
- 在代码中查找
- 使用方法:
ES module 实现了标准化,这真的很好
在我看来,CommonJS 模块和 ES module 的主要区别在于,ES module 是一个真正的标准。这让我使用起来更有信心,因为浏览器会对 Web 标准承诺永远向后兼容——如果我今天用 ES module 写了一些代码,我可以确信 15 年后它依然能以同样的方式工作。
这也让我对使用像 esbuild 这样的工具感觉更踏实,因为即使 esbuild 项目不再维护,由于它实现的是一个标准,未来很可能还会有其他类似的工具可以替代它。
JS 社区已经打造了许多非常酷的工具
很多时候当我谈论这些内容时,会得到“ 我讨厌 JavaScript!!!它最烂了!!!”这样的回应。但我的体会是,JavaScript 有很多很棒的工具(我昨天刚了解到 https://esm.sh,看起来就很不错!我很喜欢 esbuild!),而且如果花时间去了解它们是如何工作的,我就能利用其中一些工具,让自己的工作轻松很多。
所以,这篇文章的目的绝对不是抱怨 JavaScript,而是去了解整个生态,以便我能以自己觉得舒服的方式使用这些工具。
我仍有的疑问
以下是我仍有的一些疑问,如果找到了答案,我会补充到文章中。
- 有没有能为我本地已配置好的 ES Module 自动生成 importmap 的工具?(似乎有: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)?
文中提到的所有工具
以下是本文中提到的所有工具:
- Simon Willison 的 download-esm,它会下载一个 ES module 并将导入路径转换为直接指向 JS 文件,从而无需 importmap
- https://esm.sh/ 和 skypack.dev
- esbuild
- JSPM 可以生成 importmap
写这篇文章让我想到,虽然我通常不想在每次更新项目时都运行构建,但我或许可以接受一个只在搭建项目时运行一次的构建步骤(比如使用 download-esm 之类的工具),之后除非更新依赖版本,否则就再也不用运行。
就是这些!
感谢 Marco Rogers 教会了我本文中的许多知识。这篇文章中可能有一些错误,我很想知道它们是什么——欢迎在 Bluesky 或 Mastodon 上告诉我!
随机一篇博客
评论
登录后参与讨论