Claude Code Web Worker 实战:React 与 TypeScript
用Claude Code实现类型安全的Web Worker,覆盖Vite、React、清理、transferable和测试。
在浏览器里做重任务时,问题通常不是代码完全失败,而是页面突然变得卡顿。导入CSV时输入框停住,给图片加滤镜时滚动不动,生成本地搜索索引时首屏交互变慢,解析几MB日志时按钮像失效了一样。Web Worker就是为这类问题准备的:把占用CPU的工作放到主线程之外,让界面继续响应。
这里的“主线程”可以理解为负责绘制页面、处理点击、输入和滚动的地方。Web Worker则是后台线程,适合做纯计算。它不能直接操作DOM,也不应该知道React state、路由、toast或CSS。Worker收到数据,完成转换,再把结果发回主线程。这个边界越清楚,Claude Code生成的代码越容易审查。
本文使用Vite + React + TypeScript实现一个可复制的Worker示例,覆盖CSV汇总、图片灰度处理、搜索索引、日志分析和大型JSON转换。官方资料建议同时查看MDN Web Workers API、MDN Transferable objects、Vite Web Workers和Claude Code文档。如果你要把规则写进仓库,请结合内部文章CLAUDE.md最佳实践。
架构图
Claude Code开始写代码之前,先让它理解职责划分:React负责界面,hook负责Worker生命周期,Worker只负责耗时的数据转换。不要让Worker同时承担UI提示、API请求、路由跳转和日志埋点。
flowchart LR
UI["React UI"]
Hook["useDataWorker hook"]
Worker["data.worker.ts"]
Tasks["CSV / image / search / logs / JSON"]
UI --> Hook
Hook --> Worker
Worker --> Tasks
Worker --> Hook
Hook --> UI
本文选择5个场景,是因为它们能暴露不同的坑。CSV汇总会考验数字解析,图片处理会考验transferable,搜索索引会考验大数组,日志分析会考验大量字符串,JSON flatten会考验递归和类型边界。
| 使用场景 | 传给Worker的数据 | 返回结果 |
|---|---|---|
| CSV汇总 | CSV文本 | 行数、列名、数值列统计 |
| 图片处理 | ImageData的buffer | 灰度化后的buffer |
| 搜索索引 | 文档数组 | token到文档ID的索引 |
| 日志分析 | 日志文本 | error、warning、常见消息 |
| 大JSON转换 | 嵌套JSON | 扁平键值对象 |
并不是所有循环都需要Worker。几十行数据的过滤、一次性的小排序、轻量格式化直接放在主线程更简单。Worker有启动成本、消息传递成本和数据复制成本。真正值得拆出去的是会让界面掉帧、会重复运行、数据大小不可控的任务。
给Claude Code的指令
不要只说“帮我加Web Worker”。好的指令应该包含文件范围、禁止事项和验证方法。尤其要明确Worker不能碰DOM和React state。
Add a Web Worker to an existing Vite + React + TypeScript app.
Files:
- src/workers/worker-protocol.ts
- src/workers/data.worker.ts
- src/hooks/useDataWorker.ts
- src/components/WorkerDemo.tsx
Requirements:
- Support CSV summary, image grayscale, search indexing, log summary, and JSON flattening.
- Define typed request and response messages with TypeScript union types.
- Create the Worker in a React hook and terminate it during unmount cleanup.
- Transfer the ImageData ArrayBuffer instead of copying it.
- Do not touch DOM, window, document, routing, toast UI, or React state inside the Worker.
- Add Playwright or manual verification steps.
Checks:
- npm run typecheck
- npm run test
- npm run dev and confirm the UI stays responsive
这段指令的价值在于“边界”。Claude Code很容易写出看似合理的代码,但如果边界不清,Worker里可能出现document.querySelector、组件状态更新或者UI文案。那样即使本地能跑,也不是可靠的Worker设计。
类型化消息协议
先写协议,再写实现。用payload: any起步看起来快,但第三个任务出现后就会变成维护负担。下面的discriminated union让每种任务都有明确输入。
// src/workers/worker-protocol.ts
export type CsvSummary = {
rows: number;
columns: string[];
numeric: Record<string, { count: number; average: number; max: number }>;
};
export type ImageResult = {
width: number;
height: number;
buffer: ArrayBuffer;
};
export type SearchDocument = {
id: string;
title: string;
body: string;
};
export type SearchIndex = {
documents: number;
tokens: Record<string, string[]>;
};
export type LogSummary = {
lines: number;
errors: number;
warnings: number;
topMessages: string[];
};
export type JsonFlatResult = Record<string, string | number | boolean | null>;
export type WorkerJob =
| { type: "csv:summary"; text: string; delimiter?: "," | "\t" }
| { type: "image:grayscale"; width: number; height: number; buffer: ArrayBuffer }
| { type: "search:index"; documents: SearchDocument[] }
| { type: "log:summary"; text: string }
| { type: "json:flatten"; value: unknown };
export type WorkerResultMap = {
"csv:summary": CsvSummary;
"image:grayscale": ImageResult;
"search:index": SearchIndex;
"log:summary": LogSummary;
"json:flatten": JsonFlatResult;
};
export type WorkerRequest = {
id: string;
job: WorkerJob;
};
export type WorkerResponse<T = unknown> =
| { id: string; ok: true; result: T }
| { id: string; ok: false; error: string };
以后让Claude Code新增任务时,先要求它更新这个协议文件,再更新Worker、hook和测试。这样通信契约不会散落在多个组件里。
Worker实现
下面使用原生postMessage。如果你想要RPC风格,可以使用Comlink,但第一次实现建议保留清晰的消息流。以后替换时可参考Comlink README。
// src/workers/data.worker.ts
import type {
CsvSummary,
ImageResult,
JsonFlatResult,
LogSummary,
SearchIndex,
WorkerRequest,
WorkerResponse,
} from "./worker-protocol";
const workerScope = self as unknown as DedicatedWorkerGlobalScope;
workerScope.onmessage = (event: MessageEvent<WorkerRequest>) => {
const { id, job } = event.data;
try {
const result = runJob(job);
const response: WorkerResponse = { id, ok: true, result };
const transfer = resultHasBuffer(result) ? [result.buffer] : [];
workerScope.postMessage(response, transfer);
} catch (error) {
const message = error instanceof Error ? error.message : "Worker failed";
workerScope.postMessage({ id, ok: false, error: message } satisfies WorkerResponse);
}
};
function runJob(job: WorkerRequest["job"]) {
switch (job.type) {
case "csv:summary":
return summarizeCsv(job.text, job.delimiter ?? ",");
case "image:grayscale":
return grayscale(job.width, job.height, job.buffer);
case "search:index":
return buildSearchIndex(job.documents);
case "log:summary":
return summarizeLogs(job.text);
case "json:flatten":
return flattenJson(job.value);
default:
return assertNever(job);
}
}
function summarizeCsv(text: string, delimiter: "," | "\t"): CsvSummary {
const rows = text.trim().split(/\r?\n/).filter(Boolean);
const headers = rows.shift()?.split(delimiter).map((cell) => cell.trim()) ?? [];
const numeric: CsvSummary["numeric"] = {};
for (const row of rows) {
row.split(delimiter).forEach((cell, index) => {
const key = headers[index] ?? `column_${index + 1}`;
const value = Number(cell);
if (!Number.isFinite(value)) return;
const current = numeric[key] ?? { count: 0, average: 0, max: Number.NEGATIVE_INFINITY };
current.count += 1;
current.average += (value - current.average) / current.count;
current.max = Math.max(current.max, value);
numeric[key] = current;
});
}
return { rows: rows.length, columns: headers, numeric };
}
function grayscale(width: number, height: number, buffer: ArrayBuffer): ImageResult {
const pixels = new Uint8ClampedArray(buffer);
for (let index = 0; index < pixels.length; index += 4) {
const gray = Math.round(pixels[index] * 0.299 + pixels[index + 1] * 0.587 + pixels[index + 2] * 0.114);
pixels[index] = gray;
pixels[index + 1] = gray;
pixels[index + 2] = gray;
}
return { width, height, buffer: pixels.buffer };
}
function buildSearchIndex(documents: Array<{ id: string; title: string; body: string }>): SearchIndex {
const tokens: SearchIndex["tokens"] = {};
for (const document of documents) {
const words = `${document.title} ${document.body}`
.toLowerCase()
.split(/[^a-z0-9]+/g)
.filter((word) => word.length >= 3);
for (const word of new Set(words)) {
tokens[word] = [...(tokens[word] ?? []), document.id];
}
}
return { documents: documents.length, tokens };
}
function summarizeLogs(text: string): LogSummary {
const lines = text.split(/\r?\n/).filter(Boolean);
const counts = new Map<string, number>();
let errors = 0;
let warnings = 0;
for (const line of lines) {
if (/\berror\b/i.test(line)) errors += 1;
if (/\bwarn(ing)?\b/i.test(line)) warnings += 1;
const normalized = line.replace(/\d{4}-\d{2}-\d{2}[^\s]*/g, "").replace(/\s+/g, " ").trim();
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
}
const topMessages = [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([message, count]) => `${count}x ${message}`);
return { lines: lines.length, errors, warnings, topMessages };
}
function flattenJson(value: unknown, prefix = ""): JsonFlatResult {
if (value === null || typeof value !== "object") {
return { [prefix || "value"]: value as string | number | boolean | null };
}
return Object.entries(value as Record<string, unknown>).reduce<JsonFlatResult>((acc, [key, child]) => {
const path = prefix ? `${prefix}.${key}` : key;
return { ...acc, ...flattenJson(child, path) };
}, {});
}
function resultHasBuffer(result: unknown): result is ImageResult {
return typeof result === "object" && result !== null && "buffer" in result && result.buffer instanceof ArrayBuffer;
}
function assertNever(value: never): never {
throw new Error(`Unsupported worker job: ${JSON.stringify(value)}`);
}
图片任务最值得检查。transferable可以避免复制大buffer,但发送方会失去这个buffer的所有权。也就是说,传出后不要再读取旧的ImageData。
React hook与cleanup
Worker生命周期应该放在hook里。组件只调用runJob,不直接创建和销毁Worker。这样页面切换、弹窗关闭、重复挂载时更安全。
// src/hooks/useDataWorker.ts
import { useCallback, useEffect, useRef } from "react";
import type { WorkerJob, WorkerRequest, WorkerResponse } from "../workers/worker-protocol";
type PendingJob = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
};
export function useDataWorker() {
const workerRef = useRef<Worker | null>(null);
const pendingRef = useRef(new Map<string, PendingJob>());
const nextIdRef = useRef(0);
useEffect(() => {
const worker = new Worker(new URL("../workers/data.worker.ts", import.meta.url), {
type: "module",
});
workerRef.current = worker;
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const response = event.data;
const pending = pendingRef.current.get(response.id);
if (!pending) return;
pendingRef.current.delete(response.id);
if (response.ok) {
pending.resolve(response.result);
} else {
pending.reject(new Error(response.error));
}
};
worker.onerror = (event) => {
for (const pending of pendingRef.current.values()) {
pending.reject(new Error(event.message));
}
pendingRef.current.clear();
};
return () => {
for (const pending of pendingRef.current.values()) {
pending.reject(new Error("Worker was terminated"));
}
pendingRef.current.clear();
worker.terminate();
workerRef.current = null;
};
}, []);
const runJob = useCallback(<T,>(job: WorkerJob, transfer: Transferable[] = []) => {
const worker = workerRef.current;
if (!worker) return Promise.reject(new Error("Worker is not ready"));
const id = `worker-job-${Date.now()}-${nextIdRef.current}`;
nextIdRef.current += 1;
return new Promise<T>((resolve, reject) => {
pendingRef.current.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
});
worker.postMessage({ id, job } satisfies WorkerRequest, transfer);
});
}, []);
return { runJob };
}
cleanup不只是“防止内存泄漏”。它还会处理未完成的Promise、旧页面的回调、重复挂载后残留的Worker,以及用户快速切换页面时的异常状态。
React调用示例
下面的组件把5个任务连到按钮上。实际项目里可以把CSV换成文件输入,把日志换成后端导出的文本,把搜索文档换成静态内容或CMS数据。
// src/components/WorkerDemo.tsx
import { useRef, useState } from "react";
import { useDataWorker } from "../hooks/useDataWorker";
import type { CsvSummary, ImageResult, LogSummary, SearchIndex } from "../workers/worker-protocol";
const sampleCsv = `team,score,cost
alpha,91,1200
beta,84,950
gamma,96,1430`;
const sampleLogs = `2026-06-02T10:00:00Z INFO started
2026-06-02T10:01:00Z WARN cache miss
2026-06-02T10:02:00Z ERROR payment retry failed
2026-06-02T10:03:00Z ERROR payment retry failed`;
export function WorkerDemo() {
const { runJob } = useDataWorker();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [message, setMessage] = useState("Idle");
async function handleCsv() {
const summary = await runJob<CsvSummary>({ type: "csv:summary", text: sampleCsv });
setMessage(`CSV rows: ${summary.rows}, score average: ${summary.numeric.score.average.toFixed(1)}`);
}
async function handleSearch() {
const index = await runJob<SearchIndex>({
type: "search:index",
documents: [
{ id: "a", title: "CSV reports", body: "Aggregate revenue and cost columns" },
{ id: "b", title: "Log monitor", body: "Find warning and error messages quickly" },
],
});
setMessage(`Search index tokens: ${Object.keys(index.tokens).length}`);
}
async function handleLogs() {
const summary = await runJob<LogSummary>({ type: "log:summary", text: sampleLogs });
setMessage(`Errors: ${summary.errors}, warnings: ${summary.warnings}`);
}
async function handleImage() {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
context.fillStyle = "#2f80ed";
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#f2994a";
context.fillRect(20, 20, 80, 80);
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const buffer = imageData.data.buffer as ArrayBuffer;
const result = await runJob<ImageResult>(
{ type: "image:grayscale", width: imageData.width, height: imageData.height, buffer },
[buffer],
);
context.putImageData(
new ImageData(new Uint8ClampedArray(result.buffer), result.width, result.height),
0,
0,
);
setMessage("Image converted in worker");
}
async function handleJson() {
const result = await runJob<Record<string, unknown>>({
type: "json:flatten",
value: { user: { id: 1, plan: "pro" }, flags: { beta: true } },
});
setMessage(`Flattened keys: ${Object.keys(result).join(", ")}`);
}
return (
<section>
<div>
<button onClick={handleCsv}>Summarize CSV</button>
<button onClick={handleSearch}>Build search index</button>
<button onClick={handleLogs}>Analyze logs</button>
<button onClick={handleImage}>Process image</button>
<button onClick={handleJson}>Flatten JSON</button>
</div>
<p role="status">Worker finished: {message}</p>
<canvas ref={canvasRef} width={160} height={120} aria-label="Image processing preview" />
</section>
);
}
检查方法
Worker的检查分两层:自动化确认结果是否回来,手动确认页面是否仍然顺畅。Playwright可以保护主要交互,Performance面板可以观察主线程是否长时间被占用。
// tests/worker-demo.spec.ts
import { expect, test } from "@playwright/test";
test("heavy worker jobs finish without blocking the page", async ({ page }) => {
await page.goto("http://localhost:5173/");
await page.getByRole("button", { name: "Summarize CSV" }).click();
await expect(page.getByRole("status")).toContainText("CSV rows");
await page.getByRole("button", { name: "Build search index" }).click();
await expect(page.getByRole("status")).toContainText("Search index tokens");
await page.getByRole("button", { name: "Analyze logs" }).click();
await expect(page.getByRole("status")).toContainText("Errors:");
});
Manual inspection checklist:
1. Open Chrome DevTools Performance tab.
2. Click each worker button with a large sample.
3. Confirm typing and scrolling still respond while the worker runs.
4. Navigate away from the component and confirm no new Worker remains.
5. Check that image processing transfers an ArrayBuffer and does not reuse the detached buffer.
最后让Claude Code做一次有范围的审查。
Review only the Web Worker implementation.
Find:
- code that touches DOM, window, or React state inside the worker
- untyped postMessage payloads
- missing terminate cleanup
- incorrect transferable usage
- bundler paths that fail in Vite
- responsibilities that should stay on the main thread
Return file paths, line numbers, and concrete fixes.
常见坑
第一个坑是Worker里操作DOM。Worker不能直接更新页面,只能返回结果。第二个坑是消息类型漂移,不同任务使用不同字段名,最后主线程到处都是防御代码。第三个坑是误解transferable,发送后还继续读旧buffer。
第四个坑是忘记终止Worker。路由切换、弹窗关闭、tab切换都会造成重复挂载,如果没有terminate(),旧Worker会继续占资源。第五个坑是Vite路径写法错误,建议使用new URL(..., import.meta.url)。第六个坑是职责膨胀,Worker不应该负责API调用、埋点、通知和UI文案。
咨询与验证记录
个人项目可以先复制这套协议、Worker、hook和demo,用真实CSV或日志文件试跑。团队项目则需要把规则写进CLAUDE.md,规定Worker目录、禁止访问的API、必需测试和发布前性能证据。ClaudeCodeLab可以通过Claude Code培训与咨询帮助团队把这些规则落到真实仓库中。日常效率提升也可以继续阅读Claude Code生产力技巧。
本文的实践结论是:CSV汇总、搜索索引、日志分析用原生postMessage已经足够清晰;图片处理最需要关注transferable;JSON转换最需要关注递归边界。发布前我会重点检查代码围栏、内部链接、官方链接、updatedDate和Worker cleanup,避免文章看似完整但无法通过质量审计。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。