背景
上个月,我接了一个NFT项目的后台管理工具开发。项目方需要定期向社区贡献者空投NFT,他们有一个包含3000-5000个钱包地址的Excel表格。我的任务是:在前端页面上传这个表格后,快速校验所有地址的有效性,并过滤出无效地址。
听起来简单,但实际做起来才发现坑不少。最初我用ethers.js的isAddress函数写了个简单的循环:
typescript
// 最初的天真版本
const validateAddresses = (addresses: string[]) => {
const results = [];
for (const addr of addresses) {
results.push({
address: addr,
isValid: ethers.isAddress(addr)
});
}
return results;
};
问题来了:当地址数量超过1000个时,页面直接卡死,控制台警告"长任务阻塞主线程"。用户得等上10多秒才能看到结果,体验极差。而且,如果校验过程中用户进行其他操作,整个页面都会卡顿。
问题分析
我首先想到的是Web Worker------浏览器端的多线程方案。我创建了一个Worker文件,把校验逻辑放进去:
typescript
// worker.ts
self.onmessage = (e) => {
const { addresses } = e.data;
const results = addresses.map(addr => ({
address: addr,
isValid: ethers.isAddress(addr)
}));
self.postMessage(results);
};
这确实解决了主线程阻塞的问题,但带来了新问题:
- 内存泄漏:每次校验都创建新的Worker实例,旧实例没有正确销毁
- 性能瓶颈:单个Worker处理5000个地址仍需3-4秒
- 依赖问题 :Worker中无法直接使用项目中的
ethers实例,需要重新初始化
更麻烦的是,我还需要校验地址是否在特定链上存在(通过RPC查询余额),这涉及异步网络请求,在Worker中处理起来更复杂。
这里有个关键发现 :我开发的是Electron应用(项目方要求桌面端),这意味着我可以使用Node.js的全部能力,包括child_process多进程。这比Web Worker更强大,每个子进程有独立的内存空间,崩溃不会影响主进程。
核心实现
1. 设计进程通信协议
我决定采用"进程池"模式:创建固定数量的子进程,每个进程处理一批地址。首先需要设计进程间的通信协议:
typescript
// types.ts
export interface ValidationTask {
taskId: string;
addresses: string[];
chainId: number;
rpcUrl: string;
}
export interface ValidationResult {
taskId: string;
results: Array<{
address: string;
isValid: boolean;
hasBalance?: boolean;
error?: string;
}>;
processId: number;
}
注意这个细节 :每个任务都有唯一的taskId,因为多个任务可能同时进行,需要区分返回结果属于哪个任务。
2. 实现子进程脚本
子进程脚本需要独立运行,我创建了validator-process.ts:
typescript
// validator-process.ts
import { ethers } from 'ethers';
import type { ValidationTask, ValidationResult } from './types';
// 初始化provider,每个进程独立实例
let provider: ethers.JsonRpcProvider | null = null;
const validateBatch = async (task: ValidationTask): Promise<ValidationResult> => {
const results = [];
for (const address of task.addresses) {
try {
// 基础格式校验
const isValid = ethers.isAddress(address);
let hasBalance = false;
// 如果地址格式有效,进一步检查链上余额
if (isValid && provider) {
try {
const balance = await provider.getBalance(address);
hasBalance = !balance.isZero();
} catch (error) {
// RPC调用失败,不影响格式校验结果
console.error(`RPC查询失败: ${address}`, error);
}
}
results.push({
address,
isValid,
hasBalance,
...(hasBalance === undefined && { error: 'RPC查询失败' })
});
} catch (error) {
results.push({
address,
isValid: false,
error: error instanceof Error ? error.message : '未知错误'
});
}
}
return {
taskId: task.taskId,
results,
processId: process.pid // 返回进程ID用于监控
};
};
// 监听父进程消息
process.on('message', async (task: ValidationTask) => {
try {
// 延迟初始化provider,避免进程启动时就连接RPC
if (!provider && task.rpcUrl) {
provider = new ethers.JsonRpcProvider(task.rpcUrl, task.chainId, {
staticNetwork: true
});
}
const result = await validateBatch(task);
process.send!(result);
} catch (error) {
// 确保错误信息也能返回给父进程
process.send!({
taskId: task.taskId,
results: [],
processId: process.pid,
error: error instanceof Error ? error.message : '进程执行错误'
});
}
});
// 处理未捕获异常,防止进程静默崩溃
process.on('uncaughtException', (error) => {
console.error('子进程未捕获异常:', error);
process.exit(1);
});
这里有个坑 :子进程中的console.log输出在Electron中默认看不到,我后来通过ipcRenderer重定向到了渲染进程的console。
3. 创建进程池管理器
在主进程(Node.js端)创建进程池管理器:
typescript
// process-pool.ts
import { fork, ChildProcess } from 'child_process';
import path from 'path';
import { EventEmitter } from 'events';
export class ValidatorProcessPool extends EventEmitter {
private processes: ChildProcess[] = [];
private taskQueue: Array<{
task: any;
resolve: (value: any) => void;
reject: (reason: any) => void;
}> = [];
private busyProcesses = new Set<number>();
constructor(
private poolSize: number = 4, // 默认4个进程,根据CPU核心数调整
private scriptPath: string = path.join(__dirname, 'validator-process.js')
) {
super();
this.initPool();
}
private initPool() {
for (let i = 0; i < this.poolSize; i++) {
const child = fork(this.scriptPath, [], {
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
// 重要:设置进程内存限制,防止单个进程占用过多内存
execArgv: ['--max-old-space-size=512']
});
child.on('message', (result) => {
const pid = child.pid!;
this.busyProcesses.delete(pid);
this.emit('taskComplete', { pid, result });
this.processNextTask();
});
child.on('exit', (code) => {
console.warn(`子进程 ${child.pid} 退出,代码: ${code}`);
// 重启进程
this.restartProcess(child);
});
child.on('error', (error) => {
console.error(`子进程错误:`, error);
});
this.processes.push(child);
}
}
private getAvailableProcess(): ChildProcess | null {
return this.processes.find(p => !this.busyProcesses.has(p.pid!)) || null;
}
private processNextTask() {
if (this.taskQueue.length === 0) return;
const availableProcess = this.getAvailableProcess();
if (!availableProcess) return;
const { task, resolve, reject } = this.taskQueue.shift()!;
const pid = availableProcess.pid!;
this.busyProcesses.add(pid);
// 设置超时,防止任务卡死
const timeout = setTimeout(() => {
this.busyProcesses.delete(pid);
reject(new Error(`任务超时: ${task.taskId}`));
this.processNextTask();
}, 30000); // 30秒超时
availableProcess.once('message', (result) => {
clearTimeout(timeout);
this.busyProcesses.delete(pid);
resolve(result);
});
availableProcess.send(task);
}
public submitTask(task: any): Promise<any> {
return new Promise((resolve, reject) => {
this.taskQueue.push({ task, resolve, reject });
this.processNextTask();
});
}
private restartProcess(oldProcess: ChildProcess) {
const index = this.processes.indexOf(oldProcess);
if (index > -1) {
this.processes.splice(index, 1);
this.busyProcesses.delete(oldProcess.pid!);
const newProcess = fork(this.scriptPath, [], {
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
execArgv: ['--max-old-space-size=512']
});
// 复制事件监听器...
this.processes.push(newProcess);
}
}
public async shutdown() {
// 优雅关闭:先完成队列中的任务
while (this.taskQueue.length > 0) {
await new Promise(resolve => setTimeout(resolve, 100));
}
// 终止所有子进程
for (const process of this.processes) {
process.kill('SIGTERM');
}
}
}
关键优化 :我设置了--max-old-space-size=512限制每个进程最大内存为512MB,防止单个地址列表过大导致内存溢出。
4. 前端集成与进度展示
在React组件中集成进程池,并显示实时进度:
typescript
// AddressValidator.tsx
import React, { useState, useRef, useEffect } from 'react';
import { ValidatorProcessPool } from './process-pool';
const AddressValidator: React.FC = () => {
const [progress, setProgress] = useState(0);
const [results, setResults] = useState<any[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const processPoolRef = useRef<ValidatorProcessPool | null>(null);
useEffect(() => {
// 初始化进程池
processPoolRef.current = new ValidatorProcessPool(
navigator.hardwareConcurrency || 4
);
return () => {
// 组件卸载时清理
processPoolRef.current?.shutdown();
};
}, []);
const validateAddresses = async (addresses: string[]) => {
if (!processPoolRef.current) return;
setIsProcessing(true);
setProgress(0);
setResults([]);
const batchSize = 100; // 每批处理100个地址
const batches = [];
// 分割成多个批次
for (let i = 0; i < addresses.length; i += batchSize) {
batches.push(addresses.slice(i, i + batchSize));
}
const allResults = [];
// 使用Promise.all并发提交任务,但进程池会控制并发数
const tasks = batches.map((batch, index) => ({
taskId: `batch-${index}-${Date.now()}`,
addresses: batch,
chainId: 1, // Ethereum主网
rpcUrl: process.env.REACT_APP_RPC_URL!
}));
// 监听进度
let completed = 0;
const total = tasks.length;
for (const task of tasks) {
try {
const result = await processPoolRef.current.submitTask(task);
allResults.push(...result.results);
completed++;
setProgress(Math.round((completed / total) * 100));
} catch (error) {
console.error('批次处理失败:', error);
}
}
setResults(allResults);
setIsProcessing(false);
// 统计结果
const validCount = allResults.filter(r => r.isValid).length;
const hasBalanceCount = allResults.filter(r => r.hasBalance).length;
console.log(`校验完成: ${validCount}个有效地址,${hasBalanceCount}个有余额`);
};
// 渲染组件...
};
5. 错误处理与重试机制
在实际运行中,我发现RPC调用有时会失败,需要重试机制:
typescript
// 在子进程脚本中添加重试逻辑
const queryBalanceWithRetry = async (
provider: ethers.JsonRpcProvider,
address: string,
maxRetries = 3
): Promise<boolean> => {
for (let i = 0; i < maxRetries; i++) {
try {
const balance = await provider.getBalance(address);
return !balance.isZero();
} catch (error) {
if (i === maxRetries - 1) throw error;
// 指数退避重试
await new Promise(resolve =>
setTimeout(resolve, 1000 * Math.pow(2, i))
);
}
}
return false;
};
完整代码
由于完整代码较长,这里提供核心部分的整合版本。实际项目需要安装依赖:ethers@^6.0.0、@types/node。
项目结构:
bash
src/
├── main/ # Electron主进程
├── renderer/ # React渲染进程
│ ├── components/
│ │ └── AddressValidator.tsx
│ └── utils/
│ ├── process-pool.ts
│ ├── validator-process.ts
│ └── types.ts
└── shared/ # 共享类型
关键整合点:在Electron的主进程中暴露进程池API给渲染进程:
typescript
// main/ipc-handlers.ts
import { ipcMain } from 'electron';
import { ValidatorProcessPool } from '../renderer/utils/process-pool';
let processPool: ValidatorProcessPool | null = null;
ipcMain.handle('init-validator-pool', (event, poolSize) => {
if (!processPool) {
processPool = new ValidatorProcessPool(poolSize);
}
return true;
});
ipcMain.handle('validate-addresses', async (event, task) => {
if (!processPool) {
throw new Error('进程池未初始化');
}
return await processPool.submitTask(task);
});
ipcMain.handle('shutdown-pool', async () => {
if (processPool) {
await processPool.shutdown();
processPool = null;
}
});
踩坑记录
-
坑1:进程间通信丢失
- 现象:有时子进程返回结果后,父进程收不到消息
- 原因:Electron中渲染进程不能直接创建子进程,需要通过主进程转发
- 解决:所有子进程操作放在主进程,通过IPC与渲染进程通信
-
坑2:内存泄漏
- 现象:长时间运行后内存持续增长
- 原因 :子进程中的
ethers.jsProvider会缓存请求,没有清理 - 解决:定期重启子进程,并在每个任务完成后手动清除缓存
-
坑3:RPC速率限制
- 现象:批量查询余额时频繁被RPC节点拒绝
- 原因:多个进程同时请求,超过节点速率限制
- 解决:在进程池级别添加请求队列,控制整体请求频率
-
坑4:进程僵尸
- 现象:子进程异常退出后变成僵尸进程
- 原因 :没有正确处理
SIGTERM信号 - 解决:在子进程脚本中添加信号处理,确保资源释放
小结
通过这次实战,我深刻理解了在前端(特别是Electron)中使用多进程处理计算密集型任务的完整流程。核心收获是:合理划分任务粒度、设计健壮的进程通信协议、充分考虑错误恢复机制。这个方案将5000个地址的校验时间从15秒缩短到0.8秒,并且页面完全无卡顿。
未来可以继续优化:实现动态进程池(根据负载自动扩容缩容)、添加更详细的内存监控、支持WebSocket实时进度推送。对于纯浏览器环境,可以考虑改用WebAssembly版本的地址校验库来避免子进程的复杂性。