当你的页面需要解析和渲染大量数据时,用户可能会面对长时间的白屏等待。本文将介绍一种"Web Worker 分片处理 + 主线程渐进式渲染"的优化方案,让用户在数据加载过程中就能看到内容逐步呈现。
目录
问题场景
最近在做一个历史聊天记录恢复的功能,后端返回大量数据需要前端进行解析拼接在渲染到页面上,如果数据量大,聊天记录可能得十几秒才会显示,用户体验极差。我们需要解决的问题有两个,数据解析和DOM渲染
为什么传统方案不够好
方案一:直接同步处理
typescript
// ❌ 问题:阻塞主线程,页面完全卡死
const transactions = rawData.map(item => parseTransaction(item))
setTransactions(transactions)
问题:
- JavaScript 是单线程的,大量计算会阻塞 UI 渲染
- 用户无法进行任何操作(滚动、点击都失效)
- 没有任何进度反馈
方案二:setTimeout 分片
typescript
// ❌ 问题:仍在主线程执行,只是分散了阻塞时间
function processChunk(startIndex: number) {
const chunk = rawData.slice(startIndex, startIndex + 100)
chunk.forEach(item => transactions.push(parseTransaction(item)))
if (startIndex + 100 < rawData.length) {
setTimeout(() => processChunk(startIndex + 100), 0)
}
}
问题:
- 虽然不会完全卡死,但主线程仍然繁忙
- 用户操作仍然会感到卡顿
- 复杂计算仍会影响动画流畅度
方案三:虚拟列表
typescript
// ⚠️ 部分解决:只解决渲染问题,不解决解析问题
<VirtualList :items="transactions" />
问题:
- 虚拟列表只解决了"渲染大量 DOM"的问题
- 数据解析仍然需要在主线程完成
- 用户仍需等待全部解析完成才能看到内容
解决方案概述
我们采用 "Web Worker 分片处理 + 主线程渐进式渲染" 的组合方案:
┌─────────────────────────────────────────────────────────────────┐
│ 整体架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ postMessage ┌─────────────────────┐ │
│ │ │ ◄──────────────────│ │ │
│ │ 主线程 │ │ Web Worker │ │
│ │ │ ───────────────────► │ │
│ │ - 接收数据 │ 原始数据 │ - 分片解析数据 │ │
│ │ - 更新 UI │ │ - 逐批返回结果 │ │
│ │ - 渲染 DOM │ ◄──────────────────│ - 不阻塞主线程 │ │
│ │ │ 解析后的数据 │ │ │
│ └─────────────┘ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
核心思路:
- Web Worker:在独立线程中执行 CPU 密集型的数据解析
- 分片处理:将大数据分成小批次,逐批处理
- 渐进式渲染:每处理完一批就发送到主线程渲染
- 帧控制:确保每批数据渲染后有时间更新 DOM
技术原理详解
1. 为什么选择 Web Worker?
┌────────────────────────────────────────────────────────────┐
│ 浏览器线程模型 │
├────────────────────────────────────────────────────────────┤
│ │
│ 主线程 (Main Thread) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ JS 执行 ←→ 样式计算 ←→ 布局 ←→ 绘制 ←→ 合成 │ │
│ │ ↑ │ │
│ │ │ 如果 JS 执行时间过长 │ │
│ │ │ 后续步骤都会被阻塞 │ │
│ │ │ 导致页面卡顿 │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ Web Worker (独立线程) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 可以执行耗时的 JS 计算 │ │
│ │ 不影响主线程的渲染 │ │
│ │ 通过 postMessage 与主线程通信 │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────┘
Web Worker 的优势:
- 独立线程执行,不阻塞主线程
- 可以执行 CPU 密集型计算
- 通过消息传递与主线程通信
限制:
- 无法直接访问 DOM
- 无法使用某些 API(如 localStorage)
- 数据传递有序列化开销
2. 为什么需要分片处理?
即使使用 Worker,如果一次性处理完所有数据再返回,用户仍需等待。分片处理的好处:
传统方式:
[处理 1000 条数据...........................] → 一次性显示全部
3 秒等待
分片方式:
[处理 50 条] → 显示
[处理 50 条] → 追加显示
[处理 50 条] → 追加显示
...
用户立即看到内容,逐步加载完成
3. 为什么需要帧控制?
Worker 发送消息非常快,如果主线程收到消息后立即处理下一条,Vue/React 的响应式更新会被批量处理,导致:
Worker: chunk1 → chunk2 → chunk3 → chunk4 → chunk5
主线程: [批量渲染]
用户看到的仍然是一次性显示。
解决方案:在每批数据之间加入延迟,让浏览器有时间渲染 DOM:
Worker: chunk1 → [25ms] → chunk2 → [25ms] → chunk3
主线程: [渲染] [渲染] [渲染]
完整代码实现
架构流程图
┌─────────────────────────────────────────────────────────────────────────┐
│ 完整处理流程 │
└─────────────────────────────────────────────────────────────────────────┘
主线程 Worker 线程
│ │
│ 1. 获取原始数据 │
▼ │
┌─────────┐ │
│ 调用 API │ │
└────┬────┘ │
│ │
│ 2. 发送数据到 Worker │
│ postMessage({ type: 'parse', data }) │
├───────────────────────────────────────────────►│
│ │
│ 3. 分片处理数据
│ ┌───────────┴───────────┐
│ │ for (chunk of chunks) │
│ │ - 解析当前批次 │
│ │ - postMessage(结果) │
│ │ - 等待 25ms │
│ └───────────┬───────────┘
│ │
│ 4. 收到第一批数据(立即) │
│◄───────────────────────────────────────────────┤
│ │
┌────┴────┐ │
│ 更新状态 │ ←── 用户立即看到部分数据 │
│ 渲染 DOM │ │
└────┬────┘ │
│ │
│ 5. 收到后续批次(每 25ms 一批) │
│◄───────────────────────────────────────────────┤
│ │
┌────┴────┐ │
│ 追加数据 │ ←── 用户看到数据逐步增加 │
│ 渲染 DOM │ │
└────┬────┘ │
│ │
│ ... 重复直到全部完成 ... │
│ │
│ 6. 收到完成消息 │
│◄───────────────────────────────────────────────┤
│ │
┌────┴────┐ │
│ 最终排序 │ │
│ 完成加载 │ │
└─────────┘ │
步骤一:创建通用的 Web Worker
typescript
// src/workers/dataParser.worker.ts
/**
* 通用数据解析 Web Worker
* 支持任意数据结构的分片处理
*/
// Worker 接收的消息类型
interface WorkerInput<T> {
type: 'parse'
taskId: string // 任务标识,支持多任务并行
data: T[] // 原始数据数组
chunkSize?: number // 每批处理数量,默认 20
}
// Worker 发送的消息类型
interface WorkerOutput<R> {
type: 'chunk' | 'complete' | 'error'
taskId: string
results?: R[] // 当前批次的处理结果
progress?: number // 进度 0-100
total?: number // 总数据量
processed?: number // 已处理数量
error?: string
}
// ========== 数据处理函数(根据业务需求自定义)==========
/**
* 示例:解析交易记录
* 你可以替换为任何数据处理逻辑
*/
interface RawTransaction {
id: string
timestamp: string
amount: string
metadata: string
encryptedNote: string
}
interface Transaction {
id: string
date: Date
amount: number
category: string
note: string
formattedAmount: string
}
function parseTransaction(raw: RawTransaction): Transaction {
// 解析时间
const date = new Date(raw.timestamp)
// 解析金额
const amount = parseFloat(raw.amount)
// 解析 JSON 元数据
let category = '未分类'
try {
const metadata = JSON.parse(raw.metadata)
category = metadata.category || '未分类'
} catch {
// 解析失败使用默认值
}
// 解码 Base64 备注
let note = ''
try {
note = decodeBase64(raw.encryptedNote)
} catch {
note = raw.encryptedNote
}
// 格式化金额
const formattedAmount = new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY'
}).format(amount)
return { id: raw.id, date, amount, category, note, formattedAmount }
}
/**
* Base64 解码为 UTF-8 字符串
*/
function decodeBase64(base64: string): string {
const binaryString = atob(base64)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return new TextDecoder('utf-8').decode(bytes)
}
// ========== 分片处理核心逻辑 ==========
/**
* 分片处理数据
*/
async function processInChunks<T, R>(
taskId: string,
data: T[],
processor: (item: T) => R,
chunkSize: number
): Promise<void> {
const total = data.length
const allResults: R[] = []
let isFirstChunk = true
for (let i = 0; i < total; i += chunkSize) {
const chunk = data.slice(i, Math.min(i + chunkSize, total))
// 处理当前批次
const chunkResults: R[] = []
for (const item of chunk) {
try {
const result = processor(item)
chunkResults.push(result)
allResults.push(result)
} catch (err) {
// 单条数据处理失败,跳过继续
console.warn('处理数据失败:', err)
}
}
const processed = Math.min(i + chunkSize, total)
const progress = Math.round((processed / total) * 100)
// 发送当前批次结果
const output: WorkerOutput<R> = {
type: 'chunk',
taskId,
results: chunkResults,
progress,
total,
processed,
}
self.postMessage(output)
// 🔥 关键:控制发送节奏
// 第一批立即发送,让用户尽快看到内容
// 后续批次间隔 25ms,给主线程渲染时间
if (isFirstChunk) {
isFirstChunk = false
await new Promise(resolve => setTimeout(resolve, 0))
} else {
await new Promise(resolve => setTimeout(resolve, 25))
}
}
// 发送完成消息
const completeOutput: WorkerOutput<R> = {
type: 'complete',
taskId,
results: allResults,
progress: 100,
total,
processed: total,
}
self.postMessage(completeOutput)
}
// ========== Worker 消息处理 ==========
self.onmessage = async (event: MessageEvent<WorkerInput<RawTransaction>>) => {
const { type, taskId, data, chunkSize = 20 } = event.data
if (type === 'parse') {
try {
await processInChunks(
taskId,
data,
parseTransaction, // 替换为你的处理函数
chunkSize
)
} catch (error) {
const errorOutput: WorkerOutput<Transaction> = {
type: 'error',
taskId,
error: error instanceof Error ? error.message : '处理失败',
}
self.postMessage(errorOutput)
}
}
}
export {}
步骤二:创建主线程工具函数
typescript
// src/utils/progressiveParser.ts
/**
* 渐进式数据解析工具
* 封装 Web Worker 调用,提供简洁的 API
*/
// 解析配置选项
interface ParseOptions<R> {
/** 每批处理的数据数量,默认 20 */
chunkSize?: number
/** 收到每批数据时的回调 */
onChunk?: (results: R[], progress: number, total: number) => void
/** 解析完成时的回调 */
onComplete?: (results: R[]) => void
/** 解析出错时的回调 */
onError?: (error: string) => void
/** 进度更新时的回调 */
onProgress?: (progress: number, total: number, processed: number) => void
}
// Worker 输出消息类型
interface WorkerOutput<R> {
type: 'chunk' | 'complete' | 'error'
taskId: string
results?: R[]
progress?: number
total?: number
processed?: number
error?: string
}
// Worker 单例
let workerInstance: Worker | null = null
/**
* 获取 Worker 实例(懒加载单例)
*/
function getWorker(): Worker | null {
if (typeof Worker === 'undefined') {
return null
}
if (!workerInstance) {
try {
// Vite 项目中的 Worker 导入方式
workerInstance = new Worker(
new URL('../workers/dataParser.worker.ts', import.meta.url),
{ type: 'module' }
)
} catch (error) {
console.warn('创建 Worker 失败,将使用主线程降级处理:', error)
return null
}
}
return workerInstance
}
// 等待下一帧渲染
function waitNextFrame(): Promise<void> {
return new Promise(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve())
})
})
}
// 消息队列处理器(确保帧控制渲染)
interface QueueItem<R> {
results: R[]
progress: number
total: number
processed: number
}
class ChunkQueueProcessor<R> {
private queue: QueueItem<R>[] = []
private isProcessing = false
private onChunk?: ParseOptions<R>['onChunk']
private onProgress?: ParseOptions<R>['onProgress']
constructor(options: ParseOptions<R>) {
this.onChunk = options.onChunk
this.onProgress = options.onProgress
}
enqueue(item: QueueItem<R>) {
this.queue.push(item)
this.processQueue()
}
private async processQueue() {
if (this.isProcessing) return
this.isProcessing = true
while (this.queue.length > 0) {
const item = this.queue.shift()!
// 触发回调
if (item.results.length > 0) {
this.onChunk?.(item.results, item.progress, item.total)
}
this.onProgress?.(item.progress, item.total, item.processed)
// 等待下一帧,让 DOM 有机会更新
if (this.queue.length > 0) {
await waitNextFrame()
}
}
this.isProcessing = false
}
}
// 任务映射
const pendingTasks = new Map<string, {
resolve: (results: any[]) => void
reject: (error: Error) => void
options: ParseOptions<any>
queueProcessor: ChunkQueueProcessor<any>
}>()
/**
* 渐进式解析数据
* @param data 原始数据数组
* @param options 解析选项
* @returns Promise<R[]> 完整的处理结果
*/
export function parseProgressively<T, R>(
data: T[],
options: ParseOptions<R> = {}
): Promise<R[]> {
const { chunkSize = 20, onChunk, onComplete, onError, onProgress } = options
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2)}`
return new Promise((resolve, reject) => {
const worker = getWorker()
// Worker 不可用时,使用主线程降级处理
if (!worker) {
console.warn('Worker 不可用,请实现降级处理逻辑')
reject(new Error('Worker 不可用'))
return
}
// 创建消息队列处理器
const queueProcessor = new ChunkQueueProcessor({
onChunk,
onProgress,
})
// 存储任务信息
pendingTasks.set(taskId, {
resolve,
reject,
options: { onChunk, onComplete, onError, onProgress },
queueProcessor,
})
// 设置消息处理器
if (!worker.onmessage) {
worker.onmessage = (event: MessageEvent<WorkerOutput<R>>) => {
const { type, taskId: respTaskId, results, progress, total, processed, error } = event.data
const task = pendingTasks.get(respTaskId)
if (!task) return
switch (type) {
case 'chunk':
if (results) {
task.queueProcessor.enqueue({
results,
progress: progress || 0,
total: total || 0,
processed: processed || 0,
})
}
break
case 'complete':
if (results) {
task.options.onComplete?.(results)
task.resolve(results)
}
pendingTasks.delete(respTaskId)
break
case 'error':
task.options.onError?.(error || '未知错误')
task.reject(new Error(error || '未知错误'))
pendingTasks.delete(respTaskId)
break
}
}
worker.onerror = (error) => {
console.error('Worker 错误:', error)
pendingTasks.forEach((task, id) => {
task.options.onError?.(error.message)
task.reject(new Error(error.message))
pendingTasks.delete(id)
})
}
}
// 发送解析任务
worker.postMessage({
type: 'parse',
taskId,
data,
chunkSize,
})
})
}
/**
* 取消解析任务
*/
export function cancelParseTask(taskId: string): void {
const task = pendingTasks.get(taskId)
if (task) {
task.reject(new Error('任务已取消'))
pendingTasks.delete(taskId)
}
}
/**
* 终止 Worker
*/
export function terminateWorker(): void {
if (workerInstance) {
workerInstance.terminate()
workerInstance = null
}
}
步骤三:在 Vue/React 组件中使用
Vue 3 示例:
vue
<template>
<div class="transaction-list">
<!-- 加载进度条 -->
<div v-if="isLoading" class="progress-bar">
<div class="progress" :style="{ width: `${progress}%` }"></div>
<span>加载中... {{ progress }}%</span>
</div>
<!-- 数据列表(渐进式显示) -->
<div
v-for="item in transactions"
:key="item.id"
class="transaction-item"
>
<span class="date">{{ formatDate(item.date) }}</span>
<span class="category">{{ item.category }}</span>
<span class="amount">{{ item.formattedAmount }}</span>
<span class="note">{{ item.note }}</span>
</div>
<!-- 空状态 -->
<div v-if="!isLoading && transactions.length === 0" class="empty">
暂无数据
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { parseProgressively } from '@/utils/progressiveParser'
interface Transaction {
id: string
date: Date
amount: number
category: string
note: string
formattedAmount: string
}
// 响应式状态
const transactions = ref<Transaction[]>([])
const isLoading = ref(false)
const progress = ref(0)
// 加载数据
async function loadTransactions() {
isLoading.value = true
progress.value = 0
transactions.value = []
try {
// 1. 获取原始数据
const response = await fetch('/api/transactions')
const rawData = await response.json()
// 2. 使用渐进式解析
const results = await parseProgressively<RawTransaction, Transaction>(
rawData,
{
chunkSize: 20,
// 每批数据到达时,追加到列表
onChunk: (chunkResults, prog) => {
// 🔥 关键:使用重新赋值触发 Vue 响应式更新
transactions.value = [...transactions.value, ...chunkResults]
progress.value = prog
},
// 完成时,使用排序后的完整列表
onComplete: (allResults) => {
transactions.value = allResults
progress.value = 100
},
onError: (error) => {
console.error('解析失败:', error)
},
}
)
console.log('加载完成,共', results.length, '条数据')
} catch (error) {
console.error('加载失败:', error)
} finally {
isLoading.value = false
}
}
// 格式化日期
function formatDate(date: Date): string {
return date.toLocaleDateString('zh-CN')
}
onMounted(() => {
loadTransactions()
})
</script>
<style scoped>
.progress-bar {
height: 20px;
background: #f0f0f0;
border-radius: 10px;
overflow: hidden;
margin-bottom: 16px;
}
.progress {
height: 100%;
background: linear-gradient(90deg, #4facfe, #00f2fe);
transition: width 0.3s ease;
}
.transaction-item {
display: flex;
padding: 12px;
border-bottom: 1px solid #eee;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
性能对比
测试环境
- 数据量:1000 条交易记录
- 每条数据包含:JSON 解析、Base64 解码、日期格式化、金额格式化
- 设备:普通笔记本电脑
测试结果
| 指标 | 传统同步方案 | 本方案 | 提升 |
|---|---|---|---|
| 首次内容显示 | 3.5s | 0.2s | 17x |
| 页面可交互时间 | 3.5s | 0.2s | 17x |
| 总完成时间 | 3.5s | 3.8s | 略增 |
| 用户感知卡顿 | 严重 | 无 | ✅ |
| 进度反馈 | 无 | 有 | ✅ |
用户体验对比
传统方案:
用户点击 → [=========== 3.5秒白屏 ===========] → 突然全部显示
用户焦虑,不知道是否正常
本方案:
用户点击 → [立即显示部分内容] → [逐步加载更多] → 完成
用户立即看到反馈,体验流畅
适用场景
适合使用的场景
-
大量数据列表渲染
- 聊天记录、消息列表
- 交易流水、订单列表
- 日志查看器
-
需要复杂解析的数据
- JSON/XML 解析
- 编码解码(Base64、加密数据)
- 数据格式转换
-
实时数据展示
- 监控面板
- 数据分析仪表盘
- 搜索结果展示
不适合的场景
-
数据量小(< 100 条)
- 直接同步处理即可
- Worker 创建和通信有开销
-
需要数据完整性
- 所有数据必须同时展示
- 有复杂的数据关联关系
-
计算量很小
- 简单的数据映射
- 没有复杂的解析逻辑
总结
核心要点
-
Web Worker 解放主线程
- CPU 密集型计算放到 Worker
- 主线程专注 UI 渲染
-
分片处理实现渐进式
- 数据分批处理,逐步返回
- 用户立即看到内容
-
帧控制确保渲染
- 第一批立即发送
- 后续批次间隔 25ms
- 配合 requestAnimationFrame
-
响应式更新触发
- Vue/React 中使用重新赋值
- 确保每批数据都能触发渲染
最佳实践
typescript
// 推荐配置
{
chunkSize: 20, // 平衡效果和性能
workerDelay: 25, // 约 1-2 帧
firstChunkDelay: 0, // 第一批立即显示
}
扩展思路
- 结合虚拟列表:解决超大数据量的 DOM 渲染
- 添加缓存机制:避免重复解析相同数据
- 支持取消功能:用户切换页面时中断处理
- 错误恢复:单条数据失败不影响整体
性能优化的核心不是让任务执行更快,而是让用户感觉更快。渐进式渲染正是这一理念的完美体现。