ES 模块与导入:现代前端体验
本教程共 45 篇 · 第 22 篇 · 更新于 2026-08-13 · 约 3 分钟阅读
本节目标:搞清各入口的 ES 模块(ESM)支持差异,学会用 #imports 统一导入 WXT API,并了解共享代码的组织方式。
源码写 ESM,打包格式看入口
ES 模块(ESM)是 JavaScript 官方的模块标准。import/export 语法带静态分析,tree-shaking 才能生效。WXT 要求源码一律写成 ESM,至于产物打包成什么格式,每个入口类型不一样。
HTML 页面:记得 type=“module”
弹窗、选项页这类 HTML 入口,Vite 只支持把页面里的 JS 按 ESM 打包。所以 script 标签必须写 type="module":
<!-- 错 -->
<script src="./main.ts"></script>
<!-- 对 -->
<script src="./main.ts" type="module"></script>
Background:默认 IIFE,可选 ESM
后台入口默认打包成单个 IIFE 文件。想换成 ESM,给入口点加 type: 'module':
export default defineBackground({
type: 'module',
main() {
// ...
},
});
开启后产物变成 ESM,后台和 HTML 页面之间可以做代码分割,manifest 里也会写入 "type": "module"。
Warning只有 MV3 支持 ESM 后台。目标版本是 MV2 时,
type会被忽略,永远打包成单个 IIFE 文件。
Content Script:暂不支持内置 ESM
内容脚本目前没有内置的 ESM 打包支持。官方计划先支持代码分割(chunking)来减小包体;HMR 因为技术难题暂不考虑。等不及的话,可以参考官方示例仓库里的 esm-content-script-ui 手动实现。
日常写内容脚本,照样用 import 语法写源码——打包器会把依赖合并进产物,只是产物本身不是 ESM 格式。
#imports:一个入口拿所有 API
WXT 0.20 起提供 #imports 虚拟模块。它是个「不存在的文件」,构建时会被拆成一个个真实的 import 语句:
import { browser, defineContentScript, createShadowRootUi } from '#imports';
等价于:
import { browser } from 'wxt/browser';
import { defineContentScript } from 'wxt/utils/define-content-script';
import { createShadowRootUi } from 'wxt/utils/content-script-ui/shadow-root';
好处有两个。一是 API 多了不用记路径;二是将来 WXT 调整内部路径时,#imports 的写法不会坏。构建时按需拆分,不增加包体,tree-shaking 照常生效。
自动导入和 #imports 并不冲突:自动导入帮你省掉 import 语句,#imports 则是需要显式导入时的推荐写法(如 §29 所述)。
Tip想查某个 API 的完整导入路径?打开项目里的
.wxt/types/imports-module.d.ts,全部列在那里。
写测试时要 mock 来自 #imports 的 API,记得用真实路径而不是 #imports:
import { injectScript } from '#imports';
import { vi } from 'vitest';
vi.mock('wxt/utils/inject-script');
const injectScriptMock = vi.mocked(injectScript);
因为 Vitest 经过 wxt/testing 配置后,看到的转换代码和打包器一样,#imports 已经拆成真实路径了。
裸导入:不写相对路径
现代前端工程里,import 很少写一长串相对路径。裸导入(bare import)直接写包名或别名,构建器负责解析。WXT 基于 Vite,天然支持 npm 包的裸导入:
import { browser } from 'wxt/browser';
#imports 和路径别名本质上也是裸导入的一种,只是指向的对象不同。
路径别名与共享代码
WXT 默认提供两组路径别名:@ 和 ~ 指向 srcDir,@@ 和 ~~ 指向项目根目录(详见 §29):
import { normalizeDomain } from '~/lib/domain-utils';
共享代码的组织方式很自由。社区惯例是把不依赖浏览器环境的纯逻辑放进 src/lib/,把可复用 UI 放进 src/components/,各入口之间通过别名互相引用。
Note内容脚本打包时会把引用的共享代码一起打进去。引用前想一下:这个模块会不会把 tabs、消息等重代码带进内容脚本包?mkext 专门把 domain-utils 从 domain-rating 里拆出来,就是为了避免内容脚本包变胖。内容脚本的运行环境约束见 §14。
小结
- WXT 的入口都是 ES Module,共享代码用别名(
@/等)导入。 #imports是自动导入的「显式形态」,团队项目常配imports: false使用。- 内容脚本的包体积要盯着:引用共享模块前想想它带了多少东西。