webpack 模块化 Worker 实战:用 `new Worker`/`SharedWorker` 构建可复用模块的主线程多线程应用
new Worker()、new SharedWorker() 这类浏览器原生 API 通常只接受一个独立的脚本 URL,模块复用与按需加载都很受限;而 webpack 通过对 new URL("./xxx.js", import.meta.url) 模式的内建识别,把每个 Worker 当作独立入口进行打包,并允许主线程与 Worker 之间共享模块、共享异步 chunk。本文以官方示例仓库中的 module-worker 为例(源码见 examples/module-worker),从零拆解其构建配置、源码组织与产物形态,并结合仓库中 Worker 依赖与模板插件的实现(lib/dependencies/WorkerDependency.js、lib/webworker/WebWorkerTemplatePlugin.js),讲清楚 webpack 处理模块化 Worker 的完整链路。读完即可在自己的应用中照搬这套"多 Worker + 共享代码 + 动态 import"的工程结构。
示例解决的真实问题
在纯原生 JS 里写 Worker,几乎每一步都会遇到限制:
- Worker 脚本是独立文件,想复用
fibonacci.js这类公共逻辑,只能靠加载额外脚本或复制代码; importScripts无法配合 ES Module 和打包器做依赖解析、tree-shaking;SharedWorker的共享逻辑无法按需拆包,会拖慢首个连接;- 主线程与 Worker 各写一份逻辑,很容易出现两份代码语义漂移。
本示例用两个典型场景覆盖上述问题:一个是带聊天的 SharedWorker,多个页面连接共享同一份历史记录;一个是斐波那契计算 Worker,把阻塞主线程的 CPU 密集任务搬到独立线程,并让 Worker 内部也用 import() 按需加载同一份 fibonacci 模块。而这一切都建立在一个纯粹基于原生语法的工程之上——webpack 只是忠实完成打包。
工程全景:文件职责与构建配置
目录文件与职责
整个示例由 7 个源码/页面文件加 1 份构建配置组成(examples/module-worker):
| 文件 | 角色 | 说明 |
|---|---|---|
| example.js | 主线程入口 | 渲染页面 DOM,创建 SharedWorker 与 Worker,处理消息收发 |
| chat-module.js | 共享数据模块 | 维护 history 数组与 add() 方法,供 worker 内部动态加载 |
| chat-worker.js | 共享 Worker | 处理各端口发来的 message/history 请求 |
| fibonacci.js | 纯计算模块 | 被主线程与 Worker 两处动态 import() 的公共模块 |
| fib-worker.js | 普通 Worker | 收到数字后动态加载 fibonacci 并回传结果 |
| index.html | 页面载体 | 仅以 <script type="module" async> 引入 dist/main.js |
| webpack.config.js | 构建配置 | ESM 输出、浏览器 target、确定性 chunk id 等 |
此外目录中的 template.md 是"用真实构建产物生成 README"的模板文件,负责把上述源码与 dist/ 下的编译结果按章节拼装进 README.md,这正是文档中能看到完整 dist 产物代码的原因。
构建配置逐项解读
关键配置位于 webpack.config.js,它与"模块化 Worker"能否成立直接相关:
"use strict";
const path = require("path");
/** @type {import("webpack").Configuration} */
const config = {
entry: "./example.js",
output: {
path: path.join(__dirname, "dist"),
filename: "[name].js",
chunkFilename: "[name].js",
publicPath: "/dist/"
},
optimization: {
chunkIds: "deterministic" // To keep filename consistent between different modes (for example building only)
},
target: "browserslist: last 2 Chrome versions",
experiments: {
outputModule: true
}
};
module.exports = config;
对照逐项说明:
entry: "./example.js"——打包器入口只有主线程文件,chat-worker.js、fib-worker.js都不是入口配置项,它们会被打包器从new SharedWorker(new URL(...))/new Worker(new URL(...))调用处自动识别为额外入口;experiments.outputModule: true——这是模块化 Worker 成立的前提。它让 webpack 输出真正的 ES Module 产物。构建产物中的main.js以type="module"方式运行,Worker 构造参数里的type: "module"才合法;异步 chunk(如示例中的129.js)也以原生import()方式加载,而不再使用 script 注入;target: "browserslist: last 2 Chrome versions"——worker 内动态import()需要现代浏览器支持,因此把编译目标收紧到最近两个 Chrome 版本;output.filename: "[name].js"与output.chunkFilename: "[name].js"——决定入口与异步 chunk 的文件命名;worker 自动生成的入口遵循同一套模板(name来自 Worker 构造参数,见下文);output.publicPath: "/dist/"——产物中的 worker URL 与动态 import 地址都基于此前缀解析(从编译产物可见/dist/chat.js、/dist/workers/fibonacci.js、/dist/129.js等引用);optimization.chunkIds: "deterministic"——保证同一份代码在 unoptimized 与 production 两种模式下 chunk 编号(即129.js、936.js这些名字)保持一致,便于读者对照文档与真实构建输出。
运行与查看方式
在仓库根目录构建并启动静态服务(产物输出到 examples/module-worker/dist/),然后通过 publicPath 对应的 /dist/ 前缀访问。页面加载方式只有一个标签(见 index.html):
<script src="./dist/main.js" async type="module"></script>
由于 worker 脚本与异步 chunk 都是 ES Module,浏览器对静态资源服务有 CORS 要求,直接用 file:// 打开通常会失败,应通过本地 HTTP 服务访问。
主线程入口拆解:example.js
example.js 按功能注释分为三段,每一段对应一种"模块化能力"。
1. 页面骨架
通过 document.body.innerHTML 一次性构建 DOM:顶部是聊天历史区与消息发送表单,下方并排两组斐波那契输入——一组在主线程计算,一组交给 Worker,形成直观对比。
2. 基于 SharedWorker 的"聊天室"
const chatWorker = new SharedWorker(
new URL("./chat-worker.js", import.meta.url),
{
name: "chat",
type: "module"
}
);
要点在于 new URL("./chat-worker.js", import.meta.url) 这个面向打包器的模式:webpack 解析到 new URL(...) + Worker 构造的组合时,会把 chat-worker.js 及其依赖图编译成独立入口 chunk,并替换 URL 的取值。对照编译产物中 dist/main.js 的替换结果:
const chatWorker = new SharedWorker(
new URL(/* worker import */ "/dist/chat.js", import.meta.url),
{
name: "chat",
type: "module"
}
);
这里还能观察到两点设计:
- 入口命名:构造参数中的
name: "chat"被用作 worker 入口的 chunk 名,从而按output.filename模板输出为dist/chat.js; - 消息协议:主线程通过
chatWorker.port通信。页面首次加载会立即请求一次history,并每隔 1 秒在无新消息时再次请求(scheduleUpdateHistory的 1000ms 定时器);点击发送时通过postMessage携带{ type: "message", content, from };onmessage中根据msg.type分发,收到history后把历史逐行渲染到<pre id="history">中。
每个连接页面的用户名取 User ${Math.floor(Math.random() * 10000)},模拟多标签页/多用户并发场景——这正是 SharedWorker 相对普通 Worker 的核心价值:同一脚本实例被多个页面共享,聊天数据天然互通。
3. 主线程直算:动态 import 普通模块
fib1.addEventListener("change", async () => {
try {
const value = parseInt(fib1.value, 10);
const { fibonacci } = await import("./fibonacci");
const result = fibonacci(value);
output1.innerText = `fib(${value}) = ${result}`;
} catch (e) {
output1.innerText = e.message;
}
});
当用户输入 n 较大时,递归版 fibonacci 会长时间占用 JS 主线程,页面失去响应——这正是下一段要用 Worker 解决的痛点。此处的意义在于证明:fibonacci 这个模块既可以出现在主线程的 import() 里,也可以出现在 Worker 的 import() 里,webpack 会在两个"运行时"之间做好去重与共享(见产物分析一节)。
4. 斐波那契交给 Worker 计算
const fibWorker = new Worker(new URL("./fib-worker.js", import.meta.url), {
name: "fibonacci",
type: "module"
/* webpackEntryOptions: { filename: "workers/[name].js" } */
});
name: "fibonacci"提供 chunk 名;- 被注释掉的
webpackEntryOptions展示了如何通过魔法注释为自动生成的 worker 入口注入入口级选项——这里把文件名模板改写为workers/[name].js。从编译产物看,本示例中 fibonacci worker 确实落在dist/workers/fibonacci.js,而未带该注释的 chat worker 输出在dist/chat.js,两者差异正是该选项的作用效果; - 主线程只负责
postMessage发送字符串化的数字,并在onmessage里把结果写入output2。真正的计算与fibonacci模块的加载都发生在 worker 线程内,主线程因此保持流畅。
普通 Worker 侧:fib-worker.js 与 fibonacci.js
Worker 侧代码只有不到十行(fib-worker.js):
onmessage = async event => {
const { fibonacci } = await import("./fibonacci");
const value = JSON.parse(event.data);
postMessage(`fib(${value}) = ${fibonacci(value)}`);
};
值得注意的细节:
- worker 内部同样使用
import()。这依赖于两个前提:浏览器侧 worker 以type: "module"创建(所以能原生执行 import);webpack 侧开启experiments.outputModule,从而能把这些动态 import 编译成与主线程一致的 ESM 异步 chunk; fibonacci的纯函数实现(fibonacci.js)只有一行递归:
export function fibonacci(n) {
return n < 1 ? 0 : n <= 2 ? 1 : fibonacci(n - 1) + fibonacci(n - 2);
}
它既是主线程 import("./fibonacci") 的目标,也是 worker import("./fibonacci") 的目标。构建统计明确标注了这个"一处代码、两种运行时"的事实(见 README.md 的 chunk 信息):
> ./fibonacci ./example.js 70:30-51
> ./fibonacci ./fib-worker.js 2:29-50
其中 70:30-51 对应主线程的动态 import,2:29-50 对应 worker 内的动态 import。
SharedWorker 侧:chat-worker.js 与共享状态模块
聊天 worker 的实现(chat-worker.js)完整利用了 SharedWorker 的 onconnect 端口模型:
onconnect = function (e) {
for (const port of e.ports) {
port.onmessage = async event => {
const msg = event.data;
switch (msg.type) {
case "message":
const { add } = await import("./chat-module");
add(msg.content, msg.from);
// fallthrough
case "history":
const { history } = await import("./chat-module");
port.postMessage({
type: "history",
history
});
break;
}
};
}
};
三个设计点值得展开:
- 模块单例即"服务端状态"。
chat-module.js维护模块级的history数组与add()方法:
export const history = [];
export const add = (content, from) => {
if (history.length > 10) history.shift();
history.push(`${from}: ${content}`);
};
在 ESM 语义下,同一份模块只在 SharedWorker 中初始化一次,因此所有端口共享同一个 history 数组,天然充当"多页面共享的聊天记录存储",并限制最多保留 10 条。
-
落空(fallthrough)技巧。
message分支在调用add()后故意不写break,让执行流落入history分支——发送消息的端口会立刻拿到包含自己新消息的最新历史,一次往返完成"追加 + 刷新",逻辑紧凑且自洽。 -
按需加载而非启动即加载。
chat-module.js并不是随chat.js一并打包的,而是通过import()拆成独立 chunk(产物中即dist/936.js)。第一个用户发消息时才拉取聊天逻辑,SharedWorker 的"连接成本"被压到最低。
构建产物剖析:一个主 chunk 与三个"卫星 chunk"
在 experiments.outputModule: true 下,编译结果分为入口产物与共享异步 chunk两类,可从 README.md 中看到完整代码。
入口产物
| 产物 | 名称来源 | 内容 |
|---|---|---|
dist/main.js |
entry |
主线程 UI 逻辑 + 模块化打包运行时 |
dist/chat.js |
name: "chat" |
chat-worker 逻辑 + 自带运行时 |
dist/workers/fibonacci.js |
name: "fibonacci" |
fib-worker 逻辑 + 自带运行时 |
每个 worker 产物都是"自带 mini 运行时"的独立 ES Module。其运行时核心是 __webpack_require__.ei(chunkId, importFn)——专门面向 ESM 输出的按需 chunk 加载器:内部维护 installedChunks 状态表,用 importFn() 触发原生 import(),并对同一 chunk 的并发请求做去重合并(Promise.race),最后通过 installChunk 把 chunk 中的模块与运行时合并进模块表。
以 worker 入口为例,其编译后形态(见 dist/workers/fibonacci.js)是把 worker 脚本连同运行时一起输出:
onmessage = async event => {
const { fibonacci } = await __webpack_require__.ei(129, () => (import(/*! import() */ "/dist/129.js"))).then(() => (__webpack_require__(/*! ./fibonacci */ 3)));
const value = JSON.parse(event.data);
postMessage(`fib(${value}) = ${fibonacci(value)}`);
};
对照源码可见:worker 内的 import("./fibonacci") 被编译成"先加载 chunk 129、再从模块表中取模块 3(fibonacci.js)"两步,/dist/129.js 的 URL 是打包器按 publicPath 静态替换的结果。
共享异步 chunk 揭示的模块归属
两个异步 chunk 是关键看点:
dist/129.js承载fibonacci模块,它同时服务于主线程运行时(example.js里的import("./fibonacci"))与 fibonacci worker 运行时(fib-worker.js里的import("./fibonacci"))。也就是说,一个纯函数模块被打包成一份产物,被两个不同线程按需复用;dist/936.js承载chat-module模块,只属于 chat worker 运行时,被chat-worker.js中两处import("./chat-module")共享。
异步 chunk 的 ESM 形态(见 dist/129.js)直接导出 __webpack_esm_ids__、__webpack_esm_modules__ 等约定字段,供上文 installChunk 消费。这解释了为什么示例要把 chunkIds 设为 "deterministic":模块与 chunk 的编号(3、129、936)成为产物间的稳定契约,unoptimized 与 production 构建可一一对照。
源码级视角:webpack 是如何"读懂" new Worker 的
示例中主线程入口里并没有把 worker 配进 entry,它们是从哪里冒出来的?答案在依赖层插件中。
WorkerDependency:一种特殊的模块依赖
lib/dependencies/WorkerDependency.js 定义了专门表示 worker 的依赖类,其 get type() 返回字符串 "new Worker()",category 为 "worker"。它继承自 ModuleDependency,说明被 new Worker(new URL(...)) 引用的文件会作为一个真实模块进入模块图,进而作为独立 chunk 参与打包,而不是简单地把字符串拼进产物。
get type() {
return "new Worker()";
}
get category() {
return "worker";
}
WorkerAndWorkletPlugin:识别支持的语法形态
真正把调用语法识别为 worker 依赖的是 lib/dependencies/WorkerAndWorkletPlugin.js。源码中通过标签(worker specifier tag / worklet specifier tag)与一组默认语法常量完成判定:
const WORKER_DEFAULT_SYNTAX = [
"Worker",
"SharedWorker",
"navigator.serviceWorker.register()",
"Worker from worker_threads"
];
可见同一套机制覆盖了浏览器 Worker、SharedWorker、serviceWorker.register 甚至 Node 的 worker_threads。此外源码还专门处理了 worklet(audioWorklet.addModule()、CSS.paintWorklet 等)场景。示例页面中的 new SharedWorker(new URL(...)) 与 new Worker(new URL(...)) 正是最主流的两种 Web 形态。插件还会解析构造第二参数中的 name、type 等信息,用于命名入口与判断是否按 module 输出。
WebWorkerTemplatePlugin:为 worker chunk 定制加载方式
lib/webworker/WebWorkerTemplatePlugin.js 负责把 worker 相关 chunk 的加载机制切换到专用通道:
apply(compiler) {
compiler.options.output.chunkLoading = "import-scripts";
new ArrayPushCallbackChunkFormatPlugin().apply(compiler);
new EnableChunkLoadingPlugin("import-scripts").apply(compiler);
}
chunkLoading = "import-scripts"对应 worker 环境的经典importScripts加载机制;- 而在本示例开启
experiments.outputModule后,输出走的是 ESM 分支——即前文看到的__webpack_require__.ei+ 原生import()方案。两种路径由配置统一调度,对使用者透明。
把依赖层(识别 new Worker)与模板层(决定 chunk 如何加载)串起来,就得到了完整的调用链:example.js 中的 Worker 调用 → WorkerAndWorkletPlugin 识别并生成 WorkerDependency → 目标文件以 worker 入口身份进入 chunk 图 → WebWorkerTemplatePlugin 按 worker 环境配置 chunk 加载 → 输出独立 worker 产物与共享异步 chunk。示例中的行为——既不需要手动声明 worker 入口、又能让 worker 内部 import() 拆包——全部源于这条链路。
Unoptimized 与 Production 产物的数据对照
README.md 的 Info 章节记录了两种模式的完整构建统计,可直接复现验证:
- Unoptimized:
main.js7.28 KiB、chat.js5.53 KiB、workers/fibonacci.js5.16 KiB、共享 chunk936.js1020 bytes 与129.js842 bytes;五个 chunk 各自的runtime modules约 2.43 KiB(4 个运行时模块); - Production(minimized):
main.js压缩到 2.25 KiB、chat.js1.2 KiB、workers/fibonacci.js879 bytes、936.js185 bytes、129.js163 bytes;运行时缩减为 3 个模块。
对照统计还能发现两类差异:
- exports 使用信息差异:unoptimized 下
fibonacci.js标注[used exports unknown]、[missing usage info prevents renaming];production 下变为[all exports used],说明 minifier 在 tree-shaking 基础上拿到了确定的导出使用信息。chat-module.js同理([exports: add, history]→[all exports used]); - chunk 归属一目了然:
129.js的生成原因同时列出主线程(example.js)与 worker(fib-worker.js)两个引入点;936.js列出chat-worker.js的两处 import。这正是"模块跨线程共享"的直接证据。
小结与延伸阅读
回到最初的三个问题:公共模块怎么复用? —— fibonacci、chat-module 这类模块只需按 ESM 正常书写,webpack 会把它们打进可供主线程与多个 worker 按需加载的共享异步 chunk(129.js、936.js);Worker 怎么做到模块化? —— 通过 new URL("./xxx.js", import.meta.url) + Worker/SharedWorker 构造即可,无需任何手动入口声明,产物 URL、文件名、命名空间均由配置与魔法注释控制;按需加载怎么办? —— 开启 experiments.outputModule 后,worker 内部也能用原生 import() 把模块拆成 ESM chunk,配合共享模块机制让首包体积保持精简。
本示例还配套生成了可直接对比真实产物代码的 README.md。如果希望进一步横向对比,仓库还提供同主题的 examples/worker,可作为本示例的姊妹篇;想从工程配置层面理解实验特性开关的含义,可在仓库根目录执行 npx webpack --config examples/module-worker/webpack.config.js 本地重现本文全部产物与统计信息。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00