我们在用 Web3.js 写 DApp 的时候,最崩溃的瞬间是什么?是你看着页面转圈,用户点一下转账,整个界面就像被冻住一样,点哪里都没反应。更恐怖的是,链上数据一旦多起来,比如你监听一堆合约事件,或者频繁查询区块,这卡顿就会变成常态。要说根子上的原因,就是 JavaScript 是单线程的,Web3.js 的同步或者复杂异步操作把这条线程堵死了。今天我们就来聊聊这个问题,以及怎么用 Worker 线程把“路”拓宽,同时把那些容易踩的坑一个一个填平。
一、卡顿到底是怎么发生的
1.1 单线程的无奈
先想象一个画面。你正在厨房炒菜,只有一个煤气灶,你既要洗菜、切菜,又要盯着锅里别糊了。如果你在某一步上愣了神,比如盯着锅盖看了一分钟,那其他的菜就只能等着。JavaScript 的主线程就是这唯一的煤气灶,它要干的事有 UI 渲染、用户点击事件响应、网络请求回调、定时器等等。而 Web3.js 要做的事情往往是计算量大得吓人的,比如对一笔交易做哈希计算,对一大段 RLP 编码的数据进行解码,或者把一个区块里的几百笔交易全部解析一遍。这些活儿一旦在主线程上执行,其他所有事都得排队,于是界面就卡了。
1.2 Web3.js 里的“重量级操作”
很多人以为 Web3.js 都是异步的,异步就不会卡,其实不然。异步只是说你不必等待结果返回,但计算过程本身还是占用主线程的。比如:
- 对一个私钥进行签名或者恢复地址的操作,涉及椭圆曲线算法,这个计算是纯 CPU 密集型的。
- 解析合约事件日志,尤其是 Event 参数里的动态类型数据,需要一层层递归解码。
- 对区块进行 whole block 的格式化,比如把十六进制变成十进制、把数字字符串变成 BigInt,数据量大时主线程也会喘不上气。
我们来模拟一个简单的例子,看看卡顿是什么样的。假设我们要监听某个合约的 Transfer 事件,然后给每个交易做一次近似“重放”的解析,这在主线程里做就会让页面失去响应。
// 技术栈:JavaScript + Web3.js
const Web3 = require('web3');
const web3 = new Web3('wss://mainnet.infura.io/ws/v3/你的项目ID');
// 这是我们要监听的某个USDT类似的合约地址
const contractAddress = '0xdAC17F958D2ee523a2206206994597C13D831ec7';
const abi = [{
"anonymous": false,
"inputs": [
{ "indexed": true, "name": "from", "type": "address" },
{ "indexed": true, "name": "to", "type": "address" },
{ "indexed": false, "name": "value", "type": "uint256" }
],
"name": "Transfer",
"type": "event"
}];
const contract = new web3.eth.Contract(abi, contractAddress);
// 监听实时的Transfer事件
contract.events.Transfer()
.on('data', async (event) => {
// 注意!这里有个隐形的雷,下面我们一步步拆解
// 模拟一个耗时的解码操作:这里不仅解析当前事件,还回查这笔交易所在区块的所有交易
const block = await web3.eth.getBlock(event.blockNumber, true);
// 遍历区块里所有交易并做“伪验证”,纯粹为了占用CPU
const results = block.transactions.map(tx => {
// 模拟做一次复杂的哈希重算
let hash = tx.hash;
for (let i = 0; i < 1000; i++) {
// 在真实场景里这可能是某种签名重放或者Merkle证明验证
hash = Web3.utils.sha3(hash);
}
return hash;
});
console.log(`解析了 ${results.length} 笔交易`);
// 如果事件来得多,主线程就被永无止境的CPU计算占满了
});
看到没?这个 for 循环加上 sha3 的一千次重复计算,在主线程里足够把界面冻结好几秒。哪怕只是偶尔来一个事件,用户每点一次按钮就卡一下,体验就很糟糕了。
二、解决思路:把脏活累活丢给 Worker 线程
2.1 Worker 线程是什么
Worker 线程就是浏览器或者 Node.js 里提供的“第二厨房”。主线程可以开一个 Worker,把复杂计算任务交给它,然后主线程和 Worker 之间通过消息来通信,类似于你把菜递给隔壁的厨师,等他把菜做好再端回来。这个过程中主线程就能继续去响应点击事件、渲染动画了。
在浏览器里用的是 Web Worker,在 Node.js 里用的是 worker_threads。好消息是,Web3.js 在两者里都能用,只是加载方式有些细节不同。我们先拿 Node.js 环境来举例,因为 Node.js 里跑服务端 DApp 也会遇到同样的问题,而且调试起来更方便。
2.2 Worker 线程能直接使用 Web3.js 吗
这是大家最关心的问题。答案是“能用,但有个大坑”,所以这里先卖个关子。我们直接看一个比较粗糙的例子里会遇到什么问题。
// 技术栈:Node.js + worker_threads + Web3.js
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
if (isMainThread) {
// ---------- 这是主线程 ----------
const worker = new Worker(__filename);
worker.on('message', (msg) => {
console.log('主线程收到回复:', msg);
});
// 给worker发送一个任务:计算一个区块的解析结果
worker.postMessage({ task: 'parseBlock', blockNumber: 18000000 });
} else {
// ---------- 这是Worker线程 ----------
const Web3 = require('web3');
// 在Worker里直接new一个Web3实例连主网
const web3 = new Web3('https://mainnet.infura.io/v3/你的项目ID');
parentPort.on('message', async (data) => {
if (data.task === 'parseBlock') {
// 请求区块数据并进行解析
const block = await web3.eth.getBlock(data.blockNumber, true);
const total = block.transactions.length;
const list = block.transactions.map(tx => tx.from + ' -> ' + tx.to);
parentPort.postMessage({ result: list, count: total });
}
});
}
你可能会想,这不是挺简单的吗?直接把 Web3 搬进 Worker 里,主线程就轻松了。但这里面埋了至少三个雷:
第一,连接数爆炸。每个 Worker 里 new 一个 Web3,等于开了新的网络连接。如果你起十个 Worker,就会跟链上节点保持十条连接。如果你的 RPC 服务商限制了并发连接数,你很快会被封掉。
第二,数据序列化开销。主线程和 Worker 之间传递消息时,所有数据都要经过结构化克隆或者JSON序列化。如果你把整个区块对象从主线程挪到 Worker,再把解析结果挪回来,传输的字节数可能比计算本身还要大,最终卡没卡好,反而憋得更难受了。
第三,Web3.js 里有浏览器兼容性坑。在某些版本里,Web3.js 进入 Worker 环境后,window 未定义,一些依赖浏览器 API 的模块会加载失败。尤其是旧版本 Web3.js 或者某些插件,一进 Worker 就崩溃。
所以,正确的姿势不是简单地把 Web3 整个塞进 Worker,而是拆分任务。
三、正确的分工姿势:主线程管网络,Worker 管计算
3.1 数据从哪来
通常我们建议的做法是:主线程负责跟链上节点通信,通过 Web3.js 拿到原始数据,然后把原始数据作为消息传给 Worker;Worker 只做纯计算和格式化,算完再扔回主线程。为什么这样更合理?因为网络 I/O 本身并不怎么占用 CPU,主线程发出请求后是处于异步等待状态的,UI 照样能动。真正让界面卡住的是收到数据之后那一段难以忍受的计算。
当然,如果主线程频繁地拿到数据,每次拿到都立刻 postMessage 给 Worker,消息传输的序列化也会成为瓶颈。所以最好在 Worker 里做数据整理,但不要反复传输原始大对象。比如你只需要区块里的交易哈希列表,就别把整个区块对象(包含所有交易详细信息)传给 Worker,先在主线程里做一次简单的字段裁剪,再传过去。
3.2 一个完整的最佳实践示例
下面我们来构建一个实际可用的方案。假设我们有这样一个功能:订阅一个合约的 Swap 事件,每收到一个事件,就把包含这笔交易的那个区块里的所有交易做个统计,比如算一下在这个区块里,有多少交易是代币转账,有多少是合约调用,以及平均 gas 价格。这活儿挺耗 CPU,原因是需要解码每个交易的 input 数据。
我们会把数据处理的活丢给 Worker,而主线程只负责订阅和展示结果。
// 技术栈:Node.js + worker_threads + Web3.js
// 文件:main.js —— 主线程部分
const { Worker } = require('worker_threads');
const Web3 = require('web3');
// 在主线程创建 Web3,只做网络请求
const web3 = new Web3('wss://mainnet.infura.io/ws/v3/你的项目ID');
// 这里定义一个处理消息的Worker
const analysisWorker = new Worker('./analysisWorker.js');
// 存储待处理的任务,回调函数以messageId为key存起来
const pendingTasks = new Map();
// 统一发送任务给Worker,并返回Promise以支持async/await
function sendTaskToWorker(payload) {
return new Promise((resolve, reject) => {
const messageId = Date.now() + Math.random().toString(16).slice(2);
pendingTasks.set(messageId, { resolve, reject });
analysisWorker.postMessage({
id: messageId,
data: payload
});
});
}
// 监听Worker的返回消息
analysisWorker.on('message', (message) => {
const task = pendingTasks.get(message.id);
if (!task) return;
pendingTasks.delete(message.id);
if (message.error) {
task.reject(new Error(message.error));
} else {
task.resolve(message.result);
}
});
// 模拟合约的Swap事件监听(这里用轮询代替,避免篇幅过长)
async function watchSwapEvents() {
// 这里我们用一个简单的轮询来模拟实时事件
let lastBlock = await web3.eth.getBlockNumber();
setInterval(async () => {
const currentBlock = await web3.eth.getBlockNumber();
if (currentBlock === lastBlock) return;
for (let blockNumber = lastBlock + 1; blockNumber <= currentBlock; blockNumber++) {
// 主线程只做一件事:拿到区块的原始数据
const block = await web3.eth.getBlock(blockNumber, true);
// 裁剪数据,只保留必要字段,避免把巨大的原始对象发给Worker
const simplified = {
number: block.number,
transactions: block.transactions.map(tx => ({
from: tx.from,
to: tx.to,
gasPrice: tx.gasPrice,
input: tx.input,
value: tx.value
}))
};
// 把解析任务丢给Worker
const analysis = await sendTaskToWorker({
type: 'analyzeBlock',
block: simplified
});
// 拿到结果展示在主线程,完全不卡
console.log(`区块 #${blockNumber} 分析结果:`);
console.log(analysis);
}
lastBlock = currentBlock;
}, 5000);
}
watchSwapEvents().catch(console.error);
上面的代码里,主线程每 5 秒检查一次新区块,拿到区块数据后,把区块裁剪成只有几个字段的轻量对象,然后发给 Worker。这样主线程本身除了网络请求和消息收发,不做任何繁重的计算。
下面是 Worker 的代码:
// 技术栈:Node.js + worker_threads
// 文件:analysisWorker.js —— Worker线程部分
const { parentPort } = require('worker_threads');
const Web3 = require('web3');
// 在Worker里我们只使用Web3的工具函数,不发起网络请求
// 因此这里不需要连接RPC,只是用来做数据解码等纯计算
// 注意:这样使用并不会产生连接,只利用了它的本地算法能力
const { utils } = Web3;
// 用来识别一个交易是不是代币转账(ERC20)
function isTokenTransfer(tx) {
// 如果to字段为null,说明是合约创建
if (!tx.to) return false;
// 通过交易input来简单判断:
// ERC20的transfer函数签名是 0xa9059cbb
// 如果input以这个开头,大概率就是代币转账交易
if (tx.input && tx.input.startsWith('0xa9059cbb')) {
return true;
}
return false;
}
// 统计一段bytecode中的函数调用数量
function analyzeTransactions(transactions) {
let tokenTransferCount = 0;
let contractCallCount = 0;
let totalGasPrice = 0n;
for (const tx of transactions) {
// 模拟一个复杂的计算:对每个交易做500次哈希,模拟CPU密集工作
// 在实际项目中你一定有合理的复杂计算场景,这里只是体现压力
let temporaryHash = tx.input;
for (let i = 0; i < 500; i++) {
// sha3是一种相对开销较大的哈希算法,特别是在大数据量时
temporaryHash = utils.sha3(temporaryHash) || '0x';
}
// 这个循环只是为了让CPU累一点,不考究具体逻辑是否科学
if (isTokenTransfer(tx)) {
tokenTransferCount++;
}
if (tx.input && tx.input !== '0x') {
contractCallCount++;
}
totalGasPrice += BigInt(tx.gasPrice || 0);
}
return {
tokenTransferCount,
contractCallCount,
avgGasPrice: (totalGasPrice / BigInt(transactions.length || 1)).toString()
};
}
// 监听主线程发来的消息
parentPort.on('message', (message) => {
const { id, data } = message;
try {
let result;
if (data.type === 'analyzeBlock') {
// 在Worker里做繁重的数据分析和解码
result = analyzeTransactions(data.block.transactions);
}
// 把结果回传给主线程
parentPort.postMessage({
id: id,
result: result
});
} catch (error) {
parentPort.postMessage({
id: id,
error: error.message || String(error)
});
}
});
这里 Worker 没连接任何节点,只用了 Web3.utils 来做哈希,完全够了。这样主线程不会卡,Worker 也不会跟 RPC 节点抢连接数。
四、Worker 线程使用中的关键注意事项
4.1 注意 Worker 数量
有的人一听说 Worker 能解决卡顿,就啪的一下开十几个 Worker,结果内存暴涨,CPU 被占得比原来还满。同时跑太多 Worker 也会导致线程切换太频繁,得不偿失。对于大多数 DApp 应用,一个到两个 Worker 就足够。如果任务可以分成两拨,比如一拨处理链上事件,一拨处理签名计算,就用两个。但如果任务性质差不多,用一个 Worker 就做好了。
4.2 注意消息传递的带宽
前面提到了传输开销。我们再来一个真实对比。如果你的区块里有一笔交易,input 数据特别长,比如一个几十 KB 的合约调用数据,你直接整个扔给 Worker,结构化克隆要把这几十 KB 复制一遍。如果来个 100 笔交易的区块,那一次消息可能就几 MB 了。这期间主线程虽然不用计算,但序列化和复制操作也会占用一部分主线程时间。所以,在发送前先想清楚,Worker 里到底需要哪些字段。
4.3 注意 Web3.js 在 Worker 里的正确引入方式
在浏览器环境中,有些 Web3.js 版本直接通过 new Worker('worker.js') 创建 Worker,然后在 Worker 里 importScripts('web3.min.js'),这样的全局引入可能会出问题。推荐用模块化方式,或者在 Node.js 环境下用打包工具把 Worker 脚本打成独立文件。这里给出一个浏览器端的简版示例思路:
<!-- 技术栈:浏览器原生 Worker + Web3.js 打包文件 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>浏览器Worker处理链上数据</title>
</head>
<body>
<script>
// 主线程代码
const worker = new Worker('worker.js');
worker.onmessage = (event) => {
console.log('从Worker拿到的解码结果:', event.data);
};
// 当我们收到一个链上事件时,把原始日志发给Worker
function handleEventLog(rawLog) {
// rawLog就是链上原始event数据,比如event.raw
worker.postMessage(rawLog);
}
// 模拟一次事件日志
handleEventLog({
address: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
topics: [
'0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
'0x000000000000000000000012b3a3e1e0ce2e1e0ce2e1e0ce2e1e0ce2e1e0ce2',
'0x0000000000000000000012a3b3e1e0ce2e1e0ce2e1e0ce2e1e0ce2e1e0ce2'
],
data: '0x0000000000000000000000000000000000000000000000000000000000000064'
});
</script>
</body>
</html>
对应 worker.js 里,你用 importScripts 引入 web3.min.js,然后处理消息:
// 技术栈:浏览器端Worker + Web3.js
// 文件:worker.js
// 引入Web3打包文件,注意这里路径要自己调整
importScripts('./lib/web3.min.js');
// 使用全局的Web3
const { utils } = Web3;
self.onmessage = (event) => {
const rawLog = event.data;
try {
// 解析合约事件日志
const decoded = {
from: '0x' + rawLog.topics[1].slice(26),
to: '0x' + rawLog.topics[2].slice(26),
value: utils.hexToNumberString(rawLog.data)
};
// 这里我们可以做更复杂的事,比如Merkle验证等等
// 目前只做一个简化的解码,示意为主
self.postMessage(decoded);
} catch (e) {
self.postMessage({ error: e.message });
}
};
这个浏览器端的例子,大家注意 importScripts 方式是同步加载脚本的,如果 web3.min.js 比较大,Worker 首次启动时会稍慢一点,但之后就不阻塞主线程了。
4.4 注意错误处理与超时控制
Worker 因为跑的是异步代码,如果你发给它的任务永远不回来,主线程的 Promise 就会一直挂着,用户界面虽然不卡,但功能就等于是坏了。因此,给每一个发给 Worker 的任务设置超时是很重要的。我们可以在 sendTaskToWorker 函数里加一个定时器来兜底。
// 技术栈:Node.js
function sendTaskToWorker(payload, timeout = 30000) {
return new Promise((resolve, reject) => {
const messageId = Date.now() + Math.random().toString(16).slice(2);
// 设置超时计时器
const timer = setTimeout(() => {
pendingTasks.delete(messageId);
reject(new Error('Worker处理任务超时'));
}, timeout);
// 在pendingTasks里存储时带着timer,以便消息返回后清除计时器
pendingTasks.set(messageId, {
resolve: (value) => {
clearTimeout(timer);
resolve(value);
},
reject: (error) => {
clearTimeout(timer);
reject(error);
}
});
analysisWorker.postMessage({ id: messageId, data: payload });
});
}
4.5 注意 Worker 里的异步任务排队
如果你把所有事件直接发给某一个 Worker,而 Worker 同时只能处理一条消息,那事件哐哐哐来了,Worker 处理不过来,消息就会在队列里堆积。这个问题很多人会忽略。表面上看主线程不卡了,但 Worker 变成了新的瓶颈。
解决方案是引入一个任务队列管理器。在主线程里维护一个 FIFO 队列,只有等当前 Worker 的回包到了之后,再从队列里取下一个任务发出去。下面是一个简化版:
// 技术栈:Node.js
class WorkerQueue {
constructor(worker) {
this.worker = worker;
this.taskQueue = []; // 待发送的任务
this.isBusy = false; // Worker是否空闲
this.callbacks = new Map(); // 回调映射
worker.on('message', (message) => {
const { id, result, error } = message;
const callback = this.callbacks.get(id);
if (callback) {
this.callbacks.delete(id);
error ? callback.reject(new Error(error)) : callback.resolve(result);
}
// 处理完一条,看看队列里还有没有任务
this.isBusy = false;
this._nextTick();
});
}
// 添加任务
addTask(data) {
return new Promise((resolve, reject) => {
this.taskQueue.push({ data, resolve, reject });
this._nextTick();
});
}
// 如果当前没有在处理任务,则从队列里取出一个发送给Worker
_nextTick() {
if (this.isBusy || this.taskQueue.length === 0) return;
const task = this.taskQueue.shift();
this.isBusy = true;
const id = Date.now() + Math.random().toString(16).slice(2);
this.callbacks.set(id, task);
this.worker.postMessage({ id, data: task.data });
}
}
// 使用方式示例:
// const queue = new WorkerQueue(analysisWorker);
// const result = await queue.addTask({ type: 'analyzeBlock', block: simplified });
有了队列之后,Worker 永远只会处理一个任务,消息排队都在主线程里,不会无限堆积 Worker 的消息缓冲。
五、应用场景与方案优缺点
5.1 什么时候必须用 Worker
如果你的 DApp 在交互中经常做以下事情,那基本属于必用 Worker 的场景:
- 批量查询区块并计算一些聚合指标,比如统计某地址的历史交易总额。
- 对合约交易的 calldata 进行深度解码,特别是涉及嵌套的 struct、数组里面套数组。
- 频繁地进行本地签名操作,比如离线签名批量交易,用助记词批量生成地址。
- 监听大量链上事件,并对事件日志做复杂的规则过滤或动态计算。
5.2 Worker 方案的优点
把计算丢到 Worker 之后,最直观的好处就是 DApp 界面终于能保持流畅了。用户点击按钮想转账,页面能立刻响应,输入框的焦点不会失焦,动画也不会一卡一卡的。同时主线程的崩溃风险也降低了,就算 Worker 里跑的任务抛了异常,也不会影响页面的基本渲染,实现了一定程度的隔离。
5.3 Worker 方案的缺点
缺点也不能装看不见。首先是代码复杂度上来了,以前就是一个回调,现在要搞主线程、worker、消息协议、队列管理,调试起来难度也大了。其次,数据传输的序列化开销在某些极端场景下会被放大,特别是如果数据是一段极大的 hex 字符串,那复制成本是避不开的。第三,Worker 环境里没有窗口对象,也没有 DOM,Web3.js 里如果有些模块是依赖浏览器环境的(比如某些需要调用 window.ethereum 的注入函数),就不能在 Worker 里用。所以你要很小心,把跟钱包交互的代码留在主线程,不能一股脑全搬进去。
5.4 一种另类的替代方案
其实还有一种思路是,不用 Web3.js 自带的解码工具,而是用更加轻量的库,像是 ethers.js 的 Interface 或者 @ethersproject/abi,单独拎出来放到 Worker 里做解码。这类库比 Web3.js 更小、更专注,而且不依赖网络环境。如果你只需要解码 ABI 数据,完全可以用这些轻量库。这里我们稍微演示一下怎么用 ethers 的 ABI 工具在 Worker 里做一些解码,也算是一种关联技术的介绍。
// 技术栈:Node.js + Worker线程 + ethers.js(ABI相关工具)
const { parentPort } = require('worker_threads');
const { ethers } = require('ethers');
// 定义我们想要的ERC20 Transfer事件的ABI结构
const transferAbi = [
'event Transfer(address indexed from, address indexed to, uint256 value)'
];
// 创建Interface对象
const iface = new ethers.Interface(transferAbi);
parentPort.on('message', (message) => {
const { id, data } = message;
try {
// data里包含事件日志的topics和data
// 使用ethers的parseLog方法解码,比Web3.utils更轻更快
const decoded = iface.parseLog({
topics: data.topics,
data: data.data
});
const result = {
from: decoded.args.from,
to: decoded.args.to,
value: decoded.args.value.toString()
};
parentPort.postMessage({ id, result });
} catch (error) {
parentPort.postMessage({ id, error: error.message });
}
});
这个示例告诉我们,如果任务只是解码 ABI 数据,不一定非要在 Worker 里塞下一个完整的 Web3.js,按需引入更细分的技术库反而更舒服。
六、一些额外的小提醒
6.1 不要忘了关闭 Worker
如果你的 DApp 生命周期比较长,在单页应用里经常需要切换页面,Worker 如果创建了不销毁,会一直被垃圾回收不了。在 React 或者 Vue 项目的组件卸载时,记得调用 worker.terminate() 来结束 worker。否则后台线程持续占用资源,时间长了整个应用会变得迟钝。
6.2 确保消息版本一致
业务升级时,主线程发的数据结构可能跟 Worker 里期待的不一致,如果你没有做兼容,Worker 解析就会出现问题。建议定义好消息协议,比如固定的 type 和 version 字段,Worker 里校验版本号,避免静默出错。
6.3 消息不要串线
如果你用同一个 Worker 处理多种任务,务必带上唯一的任务 ID 和任务类型。我们的示例里已经做了这个设计,但还是要强调:回调映射里如果缺少 messageId,就很容易出现多个请求互相覆盖结果的情况,尤其是当前一个请求还没回来,后一个请求已经到了的时候。
七、文章总结
说到底,Web3.js 阻塞 DApp 不是一个无法解决的难题。第一步要先意识到哪些操作是 CPU 密集的,不能因为它们是“异步的”就觉得万事大吉。第二步是把网络 I/O 和计算解耦,主线程负责跟节点说话,Worker 负责算到头秃。第三步是别让自己创造的复杂度超越原始问题,控制好 Worker 数量、任务队列和消息体大小。Worker 不是万能药,但对于链上数据解析、地址生成、事件解码这些又重又脏的活计,它实在是一剂对症的良药。我希望你读完这篇文章后,能回头审视一下自己的 DApp,把那些让用户抓狂的大计算量操作统统挪到 Worker 线程里去,让你的 DApp 在重压下依然操作如飞。
评论
围绕“Web3.js事件循环阻塞导致DApp卡顿,使用Worker线程处理链上数据时的关键注意事项”参与讨论