首页
/ webpack 模块化 Worker 实战:用 `new Worker`/`SharedWorker` 构建可复用模块的主线程多线程应用

webpack 模块化 Worker 实战:用 `new Worker`/`SharedWorker` 构建可复用模块的主线程多线程应用

2026-09-07 21:03:55作者:龚格成

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.jslib/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,创建 SharedWorkerWorker,处理消息收发
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.jsfib-worker.js 都不是入口配置项,它们会被打包器从 new SharedWorker(new URL(...)) / new Worker(new URL(...)) 调用处自动识别为额外入口
  • experiments.outputModule: true——这是模块化 Worker 成立的前提。它让 webpack 输出真正的 ES Module 产物。构建产物中的 main.jstype="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.js936.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;
			}
		};
	}
};

三个设计点值得展开:

  1. 模块单例即"服务端状态"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 条。

  1. 落空(fallthrough)技巧message 分支在调用 add()故意不写 break,让执行流落入 history 分支——发送消息的端口会立刻拿到包含自己新消息的最新历史,一次往返完成"追加 + 刷新",逻辑紧凑且自洽。

  2. 按需加载而非启动即加载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 的编号(3129936)成为产物间的稳定契约,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"
];

可见同一套机制覆盖了浏览器 WorkerSharedWorkerserviceWorker.register 甚至 Node 的 worker_threads。此外源码还专门处理了 worklet(audioWorklet.addModule()CSS.paintWorklet 等)场景。示例页面中的 new SharedWorker(new URL(...))new Worker(new URL(...)) 正是最主流的两种 Web 形态。插件还会解析构造第二参数中的 nametype 等信息,用于命名入口与判断是否按 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 章节记录了两种模式的完整构建统计,可直接复现验证:

  • Unoptimizedmain.js 7.28 KiB、chat.js 5.53 KiB、workers/fibonacci.js 5.16 KiB、共享 chunk 936.js 1020 bytes 与 129.js 842 bytes;五个 chunk 各自的 runtime modules 约 2.43 KiB(4 个运行时模块);
  • Production(minimized)main.js 压缩到 2.25 KiB、chat.js 1.2 KiB、workers/fibonacci.js 879 bytes、936.js 185 bytes、129.js 163 bytes;运行时缩减为 3 个模块。

对照统计还能发现两类差异:

  1. 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]);
  2. chunk 归属一目了然129.js 的生成原因同时列出主线程(example.js)与 worker(fib-worker.js)两个引入点;936.js 列出 chat-worker.js 的两处 import。这正是"模块跨线程共享"的直接证据。

小结与延伸阅读

回到最初的三个问题:公共模块怎么复用? —— fibonaccichat-module 这类模块只需按 ESM 正常书写,webpack 会把它们打进可供主线程与多个 worker 按需加载的共享异步 chunk(129.js936.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 本地重现本文全部产物与统计信息。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391