直接在浏览器中使用 npm 包
在普通 HTML 中通过 import map 和 CDN URL 使用 npm 包。了解如何区分 ESM 与 CommonJS、固定版本并跳过构建步骤。
你可以在一个纯 HTML 页面中使用 npm 包,不需要打包器、不需要 node_modules、不需要配置文件——只要声明一个 import map,将裸模块标识符(bare specifier)指向某个以 ES module 形式提供该包的 CDN URL 即可。
一个页面、一个库、一次交互,通常并不值得专门开一个 Vite 项目,还要配上开发服务器、构建输出目录和一整套部署方案。真正容易出错的地方很少是 import map 的语法:npm 包会以三种不同的模块格式发布,而其中只有两种能在浏览器里跑起来。本文将讲解如何判断你手上是哪种格式、从 CDN 加载它的两种方式,以及为什么不锁定版本的 URL 是一个正确性缺陷,而不是风格偏好问题。
关键要点
- import map 是写在
<script type="importmap">标签内的一段 JSON,用于告诉浏览器像canvas-confetti这样的裸标识符应解析到哪个 URL——这和打包器在构建期做的事情是同一件事,只是被搬进了页面里。 - import map 无法挽救一个仅提供 CommonJS 的包,因为 map 改变的是标识符如何解析,而不是文件本身采用的模块格式。
- MDN 将 import maps 标记为 Baseline Widely available(广泛可用),自 2023 年 3 月起各浏览器均已支持。
- 在 map 中的每一个 CDN URL 里都锁定确切版本,否则你页面执行的代码可能在没有部署、没有提交的情况下发生变化。
- 跳过构建步骤意味着没有 tree shaking,因此你交付的是包里的全部内容,而不是你实际用到的那部分。
什么时候该跳过构建步骤?
当维护构建流程的成本超过它所构建的东西的寿命时,就该跳过它。这类场景包括:CodePen 风格的演示、丢进 WordPress 模板或 Rails 视图里的单个交互式小组件、只有两个人在用的内部仪表盘,以及任何生命周期以天计的原型。判断标准不是规模,而是归属:如果六个月后没人会去升级这套工具链,那这套工具链就是负债。任何你预期会成长、会面向真实流量、或者要交给团队维护的东西,仍然应该放进打包器里。
三种文件格式,其中只有两种能在浏览器里运行
一个 npm 包会以三种模块格式之一发布,而其中只有两种能在浏览器里运行,所以在动手写任何 import map 之前,先弄清这个包发布的是哪种构建产物。classic 或 UMD 文件可以直接用普通的 <script src> 加载,并会挂载一个全局变量。ES module 需要 type="module" 和 import 语句。而用 require() 和 module.exports 写成的 CommonJS 构建产物,在浏览器中根本无法执行。
最快的判断方法是安装这个包然后直接读它:
npm install canvas-confetti
ls node_modules/canvas-confetti/dist
cat node_modules/canvas-confetti/package.json
在输出中关注两件事:包里的文件扩展名,以及入口字段。Node 的 package 文档定义了 main、exports 和 type;而 module 是生态约定,由打包器和 CDN 读取,并非 Node 规范中的字段。有些包还会带上 jsdelivr 或 unpkg 字段,指明一个可直接在浏览器中使用的构建产物。例如 canvas-confetti@1.9.4 在它的 package.json 中声明了 "main": "src/confetti.js"、"module": "dist/confetti.module.mjs" 和 "jsdelivr": "dist/confetti.browser.js",这告诉你浏览器构建产物和 ES module 构建产物都存在。
| 格式 | 如何识别 | 浏览器需要什么 | 在没有构建步骤的情况下 |
|---|---|---|---|
| Classic / UMD | .umd.js、dist/*.browser.js,或源码中向 window 赋值 | 无需特殊处理 | <script src>,然后使用全局变量 |
| ES module | .mjs、源码中有 import/export、"type": "module" | type="module" | import map 加上一个 module script |
| CommonJS | .cjs、require()、module.exports、"type": "commonjs" | 需要先转换 | 使用能转译为 ESM 的 CDN,或者引入构建步骤 |
最后一行正是大多数尝试悄无声息失败的地方。import map 无法挽救一个仅提供 CommonJS 的包,因为 map 改变的是标识符如何解析,而不是文件本身采用的模块格式。
简单做法:从 CDN 引入一个 script 标签
如果这个包提供了 classic 或 UMD 构建产物,那么一个 script 标签就是全部集成工作。全局变量名由包作者决定,而不是由你决定,所以要查阅 README:canvas-confetti 的 README 说明其 CDN 构建产物会在 window 上挂载一个 confetti 函数。
<!doctype html>
<html lang="en">
<body>
<button id="go">Celebrate</button>
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/dist/confetti.browser.js"></script>
<script>
document.getElementById('go').addEventListener('click', () => confetti());
</script>
</body>
</html>
如果这个包提供的是 ESM 或 CommonJS,那就由 CDN 来完成转换。向 jsDelivr 的 /+esm 端点发起请求,返回的就是一个可直接在浏览器中使用的 ES module;jsDelivr 描述这远不只是一次语法替换:它会根据包自身的字段推断出正确的入口点、在必要时转换 CommonJS、把依赖打进响应中,并对返回内容做剥离和压缩。esm.sh 通过 URL 语法 https://esm.sh/PKG[@SEMVER][/PATH] 做同样的事。两者都会给你一个可以直接写进 import 语句的 URL。
更好的做法:一个 type 为 importmap 的 script 标签
所谓 import map,是写在 <script type="importmap"> 标签内的一段 JSON,它将裸标识符映射到 URL,这样你的模块代码读起来就和在打包器里完全一样。
<!doctype html>
<html lang="en">
<body>
<button id="go">Celebrate</button>
<script type="importmap">
{
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4"
}
}
</script>
<script type="module">
import confetti from 'canvas-confetti';
document.getElementById('go').addEventListener('click', () => confetti());
</script>
</body>
</html>
它带来的差别就是一行代码。如果不用 map,每个需要这个库的文件都要重复写一遍 CDN URL 和版本号:
import confetti from 'https://esm.sh/canvas-confetti@1.9.4';
有了 map,版本号只存在于唯一一个位置,而 import 语句可以原封不动地移植到打包项目中。
实践中有四条规则很重要。第一,顺序决定 map 是否生效:浏览器必须在遇到任何通过它导入的 module script 之前读到这个 map,所以 <script type="importmap"> 块要放在那段代码上方。第二,HTML 标准允许一个文档携带多个 map,并规定了它们如何合并,但引擎对此的支持并不一致,所以每个文档只写一个 map。第三,相对值必须以 /、./ 或 ../ 开头。第四,在映射两侧都加上尾部斜杠,可以映射整个包目录,而不只是单个入口点:
<script type="importmap">
{
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4",
"canvas-confetti/": "https://esm.sh/canvas-confetti@1.9.4/"
}
}
</script>
<script type="module">
import confetti from 'canvas-confetti';
import { default as raw } from 'canvas-confetti/dist/confetti.module.mjs';
</script>
MDN 将 import maps 评为 Baseline Widely available,自 2023 年 3 月起各浏览器均已支持,因此 polyfill 不再是常规配置的一部分。如果你仍然想做一次运行时检测,HTMLScriptElement.supports() 提供了这一能力,用法为 HTMLScriptElement.supports?.("importmap")。
有一个不会给出任何错误提示的坑:ES modules 是按 CORS 规则获取的,因此直接从磁盘打开 HTML 文件会失败,而同一个文件一旦由本地服务器提供就能正常工作。
每一次都锁定版本
在 map 中的每一个 CDN URL 里都锁定确切版本。不锁定版本或使用版本范围的 URL 意味着:你页面执行的代码可能在没有部署、没有提交、仓库里也没有任何东西能解释这种差异的情况下发生变化。页面的线上行为于是取决于 CDN 的时钟而非你的 git 历史,这会把一次例行的 bug 报告变成考古作业:HTML 没变、服务器日志没变,可 JavaScript 却不一样了。
这是唯一一条打破了毫无好处的规则。canvas-confetti@1.9.4 是一个你可以据以推理的事实;canvas-confetti@latest 则是别人替你许下的承诺。
你放弃了什么?
从 CDN 加载包,等于赋予了一个第三方源在你页面上下文中执行任意脚本的能力。你可以通过 CSP 和 subresource integrity 来收窄这一风险:MDN 指出 import map 的 JSON 对象在 imports 和 scopes 之外还接受一个 integrity 键,用于把模块 URL 映射到形如 sha384-… 的 SRI 哈希。如果你更希望完全掌控交付链路,那么自行托管资源是另一套方案,详见CDN 在前端性能中的角色和CDN 平台对比。
另外还有三项随之而来的代价。没有 tree shaking,因此你交付的是包里的全部内容,而不是你实际用到的那部分——对演示来说这笔交易很划算,对一个你预期会成长的应用来说则很糟糕。在运行时解析的深层依赖图意味着浏览器只有在抓取到父模块之后才能发现每一个子模块,这也正是 CDN 要介入的原因:esm.sh 默认会把一个包的子模块打进响应中,只保留那些被其 exports 字段声明的入口点共享的部分,而 ?bundle=false 可以关闭这一行为。还有,失败方式是静默的:文档解析完成、布局完整,但某个模块因为代理、扩展或 CSP 规则拦截了该源而始终没有到达——这类 bug 由 session replay 暴露出来的速度要快于错误报告,因为从头到尾都没有抛出任何异常。
对于任何要上生产的正式项目,请使用打包器。本文这套技巧适用于那些不值得动用打包器的场景。
在写下第一行 HTML 之前,先从阅读这个包开始:列出文件,读一遍 main、module、exports 和 type,然后据此判断你需要的到底是一个 script 标签、一个 import map,还是最终仍然得上构建步骤。
常见问题
我能把 import map 放在一个单独的 JSON 文件里,而不是内联在 HTML 中吗?
不能。规范明确禁止 type 为 importmap 的 script 元素携带 src 属性,同时也禁止 async、nomodule、defer、crossorigin、integrity 和 referrerpolicy,所以这段 JSON 必须位于文档内部。如果这个 map 是生成的,就在服务端把它渲染进页面,而不是通过链接引入,并保证它位于第一个 module script 之上。
如何在同一个页面上加载同一个包的两个不同版本?
使用 scopes 键。scope 会把第二份标识符映射附加到某个 URL 路径上,这样从该路径下加载的脚本可以把某个包解析到一个锁定版本,而页面其余部分则解析到另一个版本。当两个 scope 都匹配时,会先检查路径更长的那个,而 imports 映射作为兜底。更简单的替代方案是给每个版本各自分配一个裸标识符。
import maps 对 web worker 或 script 标签的 src 属性生效吗?
不生效。map 只会重写文档自身中的 import 语句和 import() 调用里的标识符。script 标签 src 属性中的 URL 永远不会经过它,worker 或 worklet 内部加载的任何内容也不会。文档模块内部的动态 import 确实会通过 map 解析,但 worker 的入口脚本及其自身的导入需要使用完整 URL。
如果某个裸标识符不在 import map 里会发生什么?
解析会在模块运行前抛出 TypeError,而两种引擎的措辞不同。Chrome 会报告解析模块标识符失败,指出该标识符,并补充说相对引用必须以 /、./ 或 ../ 开头(实际报错信息中这三者都带引号)。Firefox 报告:The specifier “canvas-confetti” was a bare specifier, but was not remapped to anything. Relative module specifiers must start with “./”, “../” or “/”。你的应用代码中不会抛出任何异常,因此页面照常渲染,只有依赖该模块的那个功能失效了。