Web Worker
利用 Web Worker 在后台线程中执行耗时任务,避免阻塞主线程,提升应用性能和用户体验。
基本信息
- 简介: Web Worker 为 JavaScript 提供多线程能力,可在后台线程执行计算密集型任务
- MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Workers_API
- 兼容性: 现代浏览器全面支持
特点
- ✅ 真正的多线程并行处理
- ✅ 不阻塞主线程 UI 渲染
- ✅ 支持 SharedArrayBuffer 共享内存
- ✅ 可访问部分 Web API(fetch、IndexedDB 等)
- ✅ 支持 importScripts 动态加载脚本
- ✅ 与 Vite 无缝集成
核心概念
Worker 类型
Dedicated Worker(专用 Worker)
- 只能被创建它的脚本访问
- 最常用的 Worker 类型
- 适合单个页面的后台任务
Shared Worker(共享 Worker)
- 可被多个脚本访问
- 适合跨标签页通信
- 需要通过 port 通信
Service Worker
- 独立于页面的后台脚本
- 主要用于离线缓存和推送通知
- 具有独立的生命周期
通信机制
typescript
// 主线程 → Worker
worker.postMessage(data)
// Worker → 主线程
self.postMessage(result)
// 双向监听
worker.onmessage = (e) => console.log(e.data)
self.onmessage = (e) => console.log(e.data)数据传输方式
结构化克隆(默认)
typescript
// 深拷贝数据,安全但有性能开销
worker.postMessage({ data: largeObject })Transferable Objects(可转移对象)
typescript
// 转移所有权,零拷贝,高性能
const buffer = new ArrayBuffer(1024)
worker.postMessage({ buffer }, [buffer])
// 注意:buffer 在主线程中已不可用Vite 中使用 Web Worker
基础配置
Vite 原生支持 Web Worker,无需额外配置:
typescript
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
worker: {
format: 'es', // 'es' | 'iife'
plugins: [], // Worker 专用插件
rollupOptions: {} // Worker 构建选项
}
})导入方式
typescript
// 方式 1:使用 ?worker 后缀(推荐)
import MyWorker from './worker?worker'
const worker = new MyWorker()
// 方式 2:使用 ?worker&inline 内联
import MyWorker from './worker?worker&inline'
// 方式 3:使用 ?worker&url 获取 URL
import workerUrl from './worker?worker&url'
const worker = new Worker(workerUrl)实战案例 1:大文件分片上传
在线示例
vue
<template>
<div class="max-w-2xl mx-auto p-6">
<div class="space-y-6">
<!-- 文件选择 -->
<div class="border-2 border-dashed rounded-lg p-8 text-center">
<input ref="fileInput" type="file" class="hidden" @change="handleFileSelect" />
<button @click="fileInput?.click()" class="px-6 py-3 bg-brand text-white rounded-lg">
选择文件
</button>
<p v-if="selectedFile" class="mt-4 text-sm">
{{ selectedFile.name }} ({{ formatFileSize(selectedFile.size) }})
</p>
</div>
<!-- 上传进度 -->
<div v-if="uploadStatus !== 'idle'" class="space-y-4">
<div class="flex justify-between text-sm">
<span>
<span v-if="uploadStatus === 'hashing'">📊 计算文件 Hash...</span>
<span v-else-if="uploadStatus === 'uploading'">⬆️ 上传中...</span>
<span v-else-if="uploadStatus === 'paused'">⏸️ 已暂停</span>
<span v-else-if="uploadStatus === 'success'">✅ 上传完成</span>
</span>
<span>{{ progress.percentage }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all"
:class="{
'bg-brand': uploadStatus === 'uploading' || uploadStatus === 'hashing',
'bg-yellow-500': uploadStatus === 'paused',
'bg-green-500': uploadStatus === 'success'
}"
:style="{ width: `${progress.percentage}%` }"
/>
</div>
<div class="text-xs text-gray-500">
<span v-if="uploadStatus === 'hashing'">
正在计算分片 Hash: {{ progress.currentChunk }} / {{ progress.totalChunks }}
</span>
<span v-else>
已上传分片: {{ uploadedChunks.size }} / {{ progress.totalChunks }}
<span v-if="uploadQueue.length > 0">(队列中: {{ uploadQueue.length }})</span>
</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3">
<button v-if="selectedFile && uploadStatus === 'idle'" @click="handleUpload">
开始上传
</button>
<button v-if="uploadStatus === 'uploading'" @click="handlePause">暂停</button>
<button v-if="uploadStatus === 'paused'" @click="handleResume">继续上传</button>
<button v-if="uploadStatus === 'uploading' || uploadStatus === 'paused'" @click="handleCancel">
取消
</button>
</div>
<!-- 结果展示 -->
<div v-if="result">
Hash: {{ result.hash }}<br>
分片数: {{ result.chunks.length }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import UploadWorker from './upload.worker?worker'
const worker = new UploadWorker()
const fileInput = ref<HTMLInputElement>()
const selectedFile = ref<File>()
const uploadStatus = ref<'idle' | 'hashing' | 'uploading' | 'paused' | 'success'>('idle')
const progress = ref({ percentage: 0, currentChunk: 0, totalChunks: 0 })
const result = ref<{ hash: string; chunks: any[] }>()
// 断点续传相关
const uploadedChunks = ref<Set<number>>(new Set())
const uploadQueue = ref<Array<{ index: number; data: ArrayBuffer; hash: string }>>([])
const isPaused = ref(false)
const handleUpload = () => {
if (!selectedFile.value) return
uploadStatus.value = 'hashing'
worker.onmessage = (e) => {
switch (e.data.type) {
case 'progress':
progress.value = e.data
if (e.data.phase === 'hashing') uploadStatus.value = 'hashing'
break
case 'chunk-ready':
// 接收分片并加入上传队列
if (e.data.chunkData) {
uploadQueue.value.push(e.data.chunkData)
if (uploadStatus.value === 'hashing') {
uploadStatus.value = 'uploading'
uploadNextChunk()
}
}
break
case 'success':
result.value = e.data
if (uploadQueue.value.length === 0) uploadStatus.value = 'success'
break
}
}
// 传递已上传的分片索引实现断点续传
worker.postMessage({
type: 'start',
file: selectedFile.value,
chunkSize: 2 * 1024 * 1024,
uploadedChunks: Array.from(uploadedChunks.value)
})
}
// 上传单个分片
const uploadNextChunk = async () => {
if (isPaused.value || uploadQueue.value.length === 0) return
const chunkData = uploadQueue.value.shift()
if (!chunkData) return
try {
// 模拟上传(实际项目中替换为真实 API 调用)
await new Promise(resolve => setTimeout(resolve, 300))
uploadedChunks.value.add(chunkData.index)
// 更新进度
const uploaded = uploadedChunks.value.size
progress.value.percentage = Math.floor((uploaded / progress.value.totalChunks) * 100)
progress.value.currentChunk = uploaded
// 继续上传
if (uploadQueue.value.length > 0) {
uploadNextChunk()
} else if (uploaded === progress.value.totalChunks) {
uploadStatus.value = 'success'
}
} catch (err) {
uploadQueue.value.unshift(chunkData)
uploadStatus.value = 'paused'
isPaused.value = true
}
}
const handlePause = () => {
isPaused.value = true
uploadStatus.value = 'paused'
}
const handleResume = () => {
isPaused.value = false
uploadStatus.value = 'uploading'
uploadNextChunk()
}
const handleCancel = () => {
worker.postMessage({ type: 'cancel' })
uploadStatus.value = 'idle'
}
onUnmounted(() => worker.terminate())
</script>场景分析
大文件上传面临的挑战:
- 文件过大导致内存占用高
- 计算 Hash 耗时长,阻塞 UI
- 需要断点续传支持
- 进度反馈要实时准确
架构设计
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ 主线程 │ ──────→ │ Worker 线程 │ ──────→ │ 服务器 │
│ (UI 控制) │ ←────── │ (计算 Hash) │ ←────── │ (接收分片) │
└─────────────┘ └──────────────┘ └─────────────┘
│ │
│ postMessage │ SparkMD5
│ (文件、已上传分片) │ (增量 Hash)
│ │
│ chunk-ready │ Transferable
│ (分片数据) │ (零拷贝传输)
└────────────────────────┘核心流程:
- 主线程发送文件和已上传分片列表
- Worker 计算每个分片的 Hash
- Worker 通过 Transferable 发送未上传的分片
- 主线程接收分片并上传到服务器
- 支持暂停/恢复,失败自动重试
Worker 实现
typescript
/**
* 大文件上传 Worker
* 职责:
* 1. 计算文件和分片的 MD5 Hash
* 2. 实时报告计算进度
* 3. 发送未上传的分片数据
* 4. 支持断点续传和取消操作
*/
import SparkMD5 from 'spark-md5'
// 定义消息类型,确保类型安全
interface WorkerMessage {
type: 'start' | 'cancel'
file?: File
chunkSize?: number
uploadedChunks?: number[] // 已上传的分片索引
}
interface ProgressMessage {
type: 'progress'
percentage: number
currentChunk: number
totalChunks: number
phase: 'hashing' | 'uploading'
}
interface ResultMessage {
type: 'success' | 'error' | 'chunk-ready'
hash?: string
chunks?: { size: number; index: number; hash: string }[]
chunkData?: { index: number; data: ArrayBuffer; hash: string }
error?: string
}
// 取消标志
let isCancelled = false
/**
* 计算文件 Hash 并分片
* @param file 原始文件
* @param chunkSize 分片大小(默认 5MB)
*/
async function calculateHashAndChunks(
file: File,
chunkSize: number = 5 * 1024 * 1024
): Promise<void> {
try {
const chunks: Blob[] = []
const totalChunks = Math.ceil(file.size / chunkSize)
// 使用 SparkMD5 增量计算 Hash
const spark = new SparkMD5.ArrayBuffer()
const fileReader = new FileReader()
let currentChunk = 0
// 读取并处理每个分片
const loadNext = () => {
if (isCancelled) {
self.postMessage({
type: 'error',
error: 'Operation cancelled'
} as ResultMessage)
return
}
if (currentChunk >= totalChunks) {
// 所有分片处理完成
const hash = spark.end()
self.postMessage({
type: 'success',
hash,
chunks
} as ResultMessage)
return
}
const start = currentChunk * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
chunks.push(chunk)
fileReader.readAsArrayBuffer(chunk)
}
fileReader.onload = (e) => {
if (!e.target?.result) return
// 增量更新 Hash
spark.append(e.target.result as ArrayBuffer)
currentChunk++
// 报告进度
const percentage = Math.floor((currentChunk / totalChunks) * 100)
self.postMessage({
type: 'progress',
percentage,
currentChunk,
totalChunks
} as ProgressMessage)
// 使用 setTimeout 避免阻塞 Worker 线程
setTimeout(loadNext, 0)
}
fileReader.onerror = () => {
self.postMessage({
type: 'error',
error: 'File read error'
} as ResultMessage)
}
// 开始处理第一个分片
loadNext()
} catch (error) {
self.postMessage({
type: 'error',
error: error instanceof Error ? error.message : 'Unknown error'
} as ResultMessage)
}
}
// 监听主线程消息
self.onmessage = (e: MessageEvent<WorkerMessage>) => {
const { type, file, chunkSize } = e.data
switch (type) {
case 'start':
if (!file) {
self.postMessage({
type: 'error',
error: 'No file provided'
} as ResultMessage)
return
}
isCancelled = false
calculateHashAndChunks(file, chunkSize)
break
case 'cancel':
isCancelled = true
break
default:
self.postMessage({
type: 'error',
error: 'Unknown message type'
} as ResultMessage)
}
}typescript
/**
* 文件上传 Hook
* 封装 Worker 通信逻辑,提供简洁的 API
*/
import { ref, onUnmounted } from 'vue'
import UploadWorker from './upload.worker?worker'
interface UploadProgress {
percentage: number
currentChunk: number
totalChunks: number
}
interface UploadResult {
hash: string
chunks: Blob[]
}
export function useFileUpload() {
const worker = new UploadWorker()
const progress = ref<UploadProgress>({
percentage: 0,
currentChunk: 0,
totalChunks: 0
})
const isUploading = ref(false)
const error = ref<string>('')
/**
* 开始上传文件
* @param file 要上传的文件
* @param chunkSize 分片大小(字节)
*/
const startUpload = (
file: File,
chunkSize?: number
): Promise<UploadResult> => {
return new Promise((resolve, reject) => {
isUploading.value = true
error.value = ''
worker.onmessage = (e) => {
const { type } = e.data
switch (type) {
case 'progress':
progress.value = {
percentage: e.data.percentage,
currentChunk: e.data.currentChunk,
totalChunks: e.data.totalChunks
}
break
case 'success':
isUploading.value = false
resolve({
hash: e.data.hash,
chunks: e.data.chunks
})
break
case 'error':
isUploading.value = false
error.value = e.data.error
reject(new Error(e.data.error))
break
}
}
worker.onerror = (e) => {
isUploading.value = false
error.value = e.message
reject(e)
}
worker.postMessage({ type: 'start', file, chunkSize })
})
}
/**
* 取消上传
*/
const cancelUpload = () => {
worker.postMessage({ type: 'cancel' })
isUploading.value = false
}
/**
* 上传分片到服务器
* @param chunks 文件分片数组
* @param hash 文件 Hash
* @param filename 文件名
*/
const uploadChunks = async (
chunks: Blob[],
hash: string,
filename: string
): Promise<void> => {
const totalChunks = chunks.length
// 并发上传控制
const concurrency = 3
const uploadQueue: Promise<void>[] = []
for (let i = 0; i < totalChunks; i++) {
const uploadTask = async () => {
const formData = new FormData()
formData.append('chunk', chunks[i])
formData.append('hash', hash)
formData.append('filename', filename)
formData.append('chunkIndex', String(i))
formData.append('totalChunks', String(totalChunks))
const response = await fetch('/api/upload/chunk', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`Upload chunk ${i} failed`)
}
}
uploadQueue.push(uploadTask())
// 控制并发数
if (uploadQueue.length >= concurrency) {
await Promise.race(uploadQueue)
uploadQueue.splice(
uploadQueue.findIndex(p => p === uploadTask()),
1
)
}
}
// 等待所有分片上传完成
await Promise.all(uploadQueue)
// 通知服务器合并分片
await fetch('/api/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash, filename, totalChunks })
})
}
// 组件卸载时清理 Worker
onUnmounted(() => {
worker.terminate()
})
return {
progress,
isUploading,
error,
startUpload,
cancelUpload,
uploadChunks
}
}vue
<script setup lang="ts">
import { ref } from 'vue'
import { useFileUpload } from './useFileUpload'
const fileInput = ref<HTMLInputElement>()
const selectedFile = ref<File>()
const uploadStatus = ref<'idle' | 'hashing' | 'uploading' | 'success' | 'error'>('idle')
const {
progress,
isUploading,
error,
startUpload,
cancelUpload,
uploadChunks
} = useFileUpload()
const handleFileSelect = (e: Event) => {
const target = e.target as HTMLInputElement
if (target.files?.length) {
selectedFile.value = target.files[0]
}
}
const handleUpload = async () => {
if (!selectedFile.value) return
try {
uploadStatus.value = 'hashing'
// 1. 计算 Hash 和分片(在 Worker 中执行)
const { hash, chunks } = await startUpload(selectedFile.value)
console.log(`File hash: ${hash}`)
console.log(`Total chunks: ${chunks.length}`)
// 2. 检查服务器是否已存在该文件(秒传)
const checkResponse = await fetch(`/api/upload/check?hash=${hash}`)
const { exists } = await checkResponse.json()
if (exists) {
uploadStatus.value = 'success'
console.log('File already exists, skip upload')
return
}
// 3. 上传分片
uploadStatus.value = 'uploading'
await uploadChunks(chunks, hash, selectedFile.value.name)
uploadStatus.value = 'success'
console.log('Upload completed successfully')
} catch (err) {
uploadStatus.value = 'error'
console.error('Upload failed:', err)
}
}
const handleCancel = () => {
cancelUpload()
uploadStatus.value = 'idle'
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
}
</script>
<template>
<div class="max-w-2xl mx-auto p-6">
<div class="space-y-6">
<!-- 文件选择 -->
<div class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-8 text-center">
<input
ref="fileInput"
type="file"
class="hidden"
@change="handleFileSelect"
/>
<button
@click="fileInput?.click()"
class="px-6 py-3 bg-brand text-white rounded-lg hover:bg-brand-hover transition-colors"
:disabled="isUploading"
>
选择文件
</button>
<p v-if="selectedFile" class="mt-4 text-sm text-gray-600 dark:text-gray-400">
{{ selectedFile.name }} ({{ formatFileSize(selectedFile.size) }})
</p>
</div>
<!-- 上传进度 -->
<div v-if="uploadStatus !== 'idle'" class="space-y-4">
<div class="flex items-center justify-between text-sm">
<span class="font-medium">
{{ uploadStatus === 'hashing' ? '计算文件 Hash...' : '上传中...' }}
</span>
<span class="text-gray-600 dark:text-gray-400">
{{ progress.percentage }}%
</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-brand h-2 rounded-full transition-all duration-300"
:style="{ width: `${progress.percentage}%` }"
/>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
分片: {{ progress.currentChunk }} / {{ progress.totalChunks }}
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3">
<button
v-if="selectedFile && !isUploading"
@click="handleUpload"
class="flex-1 px-6 py-3 bg-brand text-white rounded-lg hover:bg-brand-hover transition-colors"
>
开始上传
</button>
<button
v-if="isUploading"
@click="handleCancel"
class="flex-1 px-6 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
取消上传
</button>
</div>
<!-- 状态提示 -->
<div v-if="uploadStatus === 'success'" class="p-4 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 rounded-lg">
✅ 上传成功!
</div>
<div v-if="error" class="p-4 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg">
❌ {{ error }}
</div>
</div>
</div>
</template>关键优化点
1. 增量 Hash 计算
typescript
// ❌ 不好的做法:一次性读取整个文件
const buffer = await file.arrayBuffer()
const hash = SparkMD5.ArrayBuffer.hash(buffer) // 大文件会卡死
// ✅ 好的做法:分片增量计算
const spark = new SparkMD5.ArrayBuffer()
chunks.forEach(chunk => {
const buffer = await chunk.arrayBuffer()
spark.append(buffer)
})
const hash = spark.end()2. 非阻塞处理
typescript
// 使用 setTimeout 让出控制权
fileReader.onload = (e) => {
spark.append(e.target.result)
setTimeout(loadNext, 0) // 避免长时间占用线程
}3. 可转移对象优化
typescript
// 对于大型 ArrayBuffer,使用 Transferable
const buffer = await file.arrayBuffer()
worker.postMessage({ buffer }, [buffer]) // 零拷贝传输实战案例 2:图片亮暗识别
在线示例
vue
<template>
<div class="max-w-4xl mx-auto p-6">
<div class="space-y-6">
<div class="text-center">
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileSelect" />
<button @click="fileInput?.click()" class="px-6 py-3 bg-brand text-white rounded-lg">
选择图片
</button>
</div>
<div v-if="previewUrl" class="grid md:grid-cols-2 gap-6">
<!-- 图片预览 -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">图片预览</h3>
<div class="relative rounded-lg overflow-hidden border">
<img :src="previewUrl" alt="Preview" class="w-full h-auto" />
<div v-if="result" class="absolute inset-0 flex items-center justify-center bg-black/20">
<div class="text-4xl font-bold" :style="{ color: getRecommendedTextColor() }">
示例文字
</div>
</div>
</div>
</div>
<!-- 分析结果 -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">分析结果</h3>
<div v-if="result">
<div>整体亮度: {{ Math.round(result.brightness * 100) }}%</div>
<div>图片类型: {{ result.isDark ? '暗色图片' : '亮色图片' }}</div>
<div>平均颜色: RGB({{ result.averageColor.r }}, {{ result.averageColor.g }}, {{ result.averageColor.b }})</div>
<div>推荐文字颜色: {{ getRecommendedTextColor() }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import BrightnessWorker from './brightness.worker?worker'
const worker = new BrightnessWorker()
const fileInput = ref<HTMLInputElement>()
const previewUrl = ref<string>()
const result = ref<any>()
const handleFileSelect = async (e: Event) => {
const target = e.target as HTMLInputElement
if (!target.files?.length) return
const file = target.files[0]
previewUrl.value = URL.createObjectURL(file)
const imageData = await getImageData(file)
worker.onmessage = (e) => {
if (e.data.type === 'result') {
result.value = e.data
}
}
worker.postMessage({ type: 'analyze', imageData, sampleRate: 50 }, [imageData.data.buffer])
}
const getRecommendedTextColor = () => result.value?.isDark ? '#ffffff' : '#000000'
onUnmounted(() => worker.terminate())
</script>场景分析
图片亮暗识别的应用场景:
- 自动选择合适的文字颜色
- 图片质量评估
- 智能滤镜推荐
- 夜间模式适配
算法原理
使用 相对亮度(Relative Luminance) 公式:
L = 0.2126 * R + 0.7152 * G + 0.0722 * B这个公式基于人眼对不同颜色的敏感度:
- 绿色权重最高(71.52%)
- 红色次之(21.26%)
- 蓝色最低(7.22%)
Worker 实现
typescript
/**
* 图片亮度分析 Worker
* 使用感知亮度算法分析图片整体亮度
*/
interface AnalyzeMessage {
type: 'analyze'
imageData: ImageData
sampleRate?: number
}
interface BrightnessResult {
type: 'result'
brightness: number
isDark: boolean
histogram: number[]
averageColor: { r: number; g: number; b: number }
}
/**
* 计算单个像素的相对亮度
* 使用 ITU-R BT.709 标准
*/
function getRelativeLuminance(r: number, g: number, b: number): number {
// 将 RGB 值归一化到 [0, 1]
const normalize = (value: number) => {
const normalized = value / 255
// 应用 gamma 校正
return normalized <= 0.03928
? normalized / 12.92
: Math.pow((normalized + 0.055) / 1.055, 2.4)
}
const R = normalize(r)
const G = normalize(g)
const B = normalize(b)
// 计算相对亮度
return 0.2126 * R + 0.7152 * G + 0.0722 * B
}
/**
* 分析图片亮度
* @param imageData 图片像素数据
* @param sampleRate 采样率(1-100),降低采样率可提升性能
*/
function analyzeBrightness(
imageData: ImageData,
sampleRate: number = 100
): BrightnessResult {
const { data, width, height } = imageData
const totalPixels = width * height
// 计算采样步长
const step = Math.max(1, Math.floor(100 / sampleRate))
let totalLuminance = 0
let sampledPixels = 0
let totalR = 0
let totalG = 0
let totalB = 0
// 亮度直方图(0-255 分为 256 个区间)
const histogram = new Array(256).fill(0)
// 遍历像素(RGBA 格式,每 4 个值代表一个像素)
for (let i = 0; i < data.length; i += 4 * step) {
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
const a = data[i + 3]
// 跳过完全透明的像素
if (a === 0) continue
// 计算亮度
const luminance = getRelativeLuminance(r, g, b)
totalLuminance += luminance
// 累加颜色值
totalR += r
totalG += g
totalB += b
// 更新直方图(将 0-1 的亮度映射到 0-255)
const histogramIndex = Math.floor(luminance * 255)
histogram[histogramIndex]++
sampledPixels++
}
// 计算平均亮度(0-1 范围)
const averageLuminance = totalLuminance / sampledPixels
// 计算平均颜色
const averageColor = {
r: Math.round(totalR / sampledPixels),
g: Math.round(totalG / sampledPixels),
b: Math.round(totalB / sampledPixels)
}
// 判断是否为暗色图片(阈值 0.5)
const isDark = averageLuminance < 0.5
return {
type: 'result',
brightness: averageLuminance,
isDark,
histogram,
averageColor
}
}
// 监听主线程消息
self.onmessage = (e: MessageEvent<AnalyzeMessage>) => {
const { type, imageData, sampleRate } = e.data
if (type === 'analyze') {
try {
const result = analyzeBrightness(imageData, sampleRate)
self.postMessage(result)
} catch (error) {
self.postMessage({
type: 'error',
error: error instanceof Error ? error.message : 'Analysis failed'
})
}
}
}typescript
/**
* 图片亮度分析 Hook
*/
import { ref, onUnmounted } from 'vue'
import BrightnessWorker from './brightness.worker?worker'
interface BrightnessAnalysis {
brightness: number
isDark: boolean
histogram: number[]
averageColor: { r: number; g: number; b: number }
}
export function useImageBrightness() {
const worker = new BrightnessWorker()
const isAnalyzing = ref(false)
const result = ref<BrightnessAnalysis>()
/**
* 从图片文件中提取 ImageData
*/
const getImageData = (file: File): Promise<ImageData> => {
return new Promise((resolve, reject) => {
const img = new Image()
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('Failed to get canvas context'))
return
}
img.onload = () => {
// 限制最大尺寸以提升性能
const maxSize = 800
let { width, height } = img
if (width > maxSize || height > maxSize) {
const ratio = Math.min(maxSize / width, maxSize / height)
width *= ratio
height *= ratio
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
const imageData = ctx.getImageData(0, 0, width, height)
resolve(imageData)
}
img.onerror = () => reject(new Error('Failed to load image'))
img.src = URL.createObjectURL(file)
})
}
/**
* 分析图片亮度
* @param file 图片文件
* @param sampleRate 采样率(1-100)
*/
const analyze = async (
file: File,
sampleRate: number = 50
): Promise<BrightnessAnalysis> => {
return new Promise(async (resolve, reject) => {
try {
isAnalyzing.value = true
// 提取图片数据
const imageData = await getImageData(file)
// 发送到 Worker 分析
worker.onmessage = (e) => {
if (e.data.type === 'result') {
const analysis: BrightnessAnalysis = {
brightness: e.data.brightness,
isDark: e.data.isDark,
histogram: e.data.histogram,
averageColor: e.data.averageColor
}
result.value = analysis
isAnalyzing.value = false
resolve(analysis)
} else if (e.data.type === 'error') {
isAnalyzing.value = false
reject(new Error(e.data.error))
}
}
worker.onerror = (error) => {
isAnalyzing.value = false
reject(error)
}
// 使用 Transferable Objects 优化性能
worker.postMessage(
{ type: 'analyze', imageData, sampleRate },
[imageData.data.buffer]
)
} catch (error) {
isAnalyzing.value = false
reject(error)
}
})
}
/**
* 获取推荐的文字颜色
*/
const getRecommendedTextColor = (): string => {
if (!result.value) return '#000000'
return result.value.isDark ? '#ffffff' : '#000000'
}
/**
* 获取亮度描述
*/
const getBrightnessLabel = (): string => {
if (!result.value) return ''
const { brightness } = result.value
if (brightness < 0.2) return '非常暗'
if (brightness < 0.4) return '较暗'
if (brightness < 0.6) return '适中'
if (brightness < 0.8) return '较亮'
return '非常亮'
}
onUnmounted(() => {
worker.terminate()
})
return {
isAnalyzing,
result,
analyze,
getRecommendedTextColor,
getBrightnessLabel
}
}vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useImageBrightness } from './useImageBrightness'
const fileInput = ref<HTMLInputElement>()
const previewUrl = ref<string>()
const selectedFile = ref<File>()
const {
isAnalyzing,
result,
analyze,
getRecommendedTextColor,
getBrightnessLabel
} = useImageBrightness()
const handleFileSelect = async (e: Event) => {
const target = e.target as HTMLInputElement
if (!target.files?.length) return
const file = target.files[0]
selectedFile.value = file
// 生成预览
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
previewUrl.value = URL.createObjectURL(file)
// 分析亮度
try {
await analyze(file, 50) // 50% 采样率
} catch (error) {
console.error('Analysis failed:', error)
}
}
const brightnessPercentage = computed(() => {
return result.value ? Math.round(result.value.brightness * 100) : 0
})
const averageColorStyle = computed(() => {
if (!result.value) return {}
const { r, g, b } = result.value.averageColor
return {
backgroundColor: `rgb(${r}, ${g}, ${b})`
}
})
</script>
<template>
<div class="max-w-4xl mx-auto p-6">
<div class="space-y-6">
<!-- 文件选择 -->
<div class="text-center">
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden"
@change="handleFileSelect"
/>
<button
@click="fileInput?.click()"
class="px-6 py-3 bg-brand text-white rounded-lg hover:bg-brand-hover transition-colors"
>
选择图片
</button>
</div>
<!-- 图片预览和分析结果 -->
<div v-if="previewUrl" class="grid md:grid-cols-2 gap-6">
<!-- 左侧:图片预览 -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">图片预览</h3>
<div class="relative rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
<img
:src="previewUrl"
alt="Preview"
class="w-full h-auto"
/>
<!-- 推荐文字颜色示例 -->
<div
v-if="result"
class="absolute inset-0 flex items-center justify-center"
:style="{ color: getRecommendedTextColor() }"
>
<div class="text-4xl font-bold drop-shadow-lg">
示例文字
</div>
</div>
</div>
</div>
<!-- 右侧:分析结果 -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">分析结果</h3>
<div v-if="isAnalyzing" class="text-gray-500">
分析中...
</div>
<div v-else-if="result" class="space-y-4">
<!-- 亮度值 -->
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium">整体亮度</span>
<span class="text-2xl font-bold">{{ brightnessPercentage }}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="h-2 rounded-full transition-all"
:class="result.isDark ? 'bg-gray-600' : 'bg-yellow-400'"
:style="{ width: `${brightnessPercentage}%` }"
/>
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ getBrightnessLabel() }}
</div>
</div>
<!-- 图片类型 -->
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="text-sm font-medium mb-2">图片类型</div>
<div class="flex items-center gap-2">
<div
class="w-4 h-4 rounded-full"
:class="result.isDark ? 'bg-gray-800' : 'bg-yellow-400'"
/>
<span>{{ result.isDark ? '暗色图片' : '亮色图片' }}</span>
</div>
</div>
<!-- 平均颜色 -->
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="text-sm font-medium mb-2">平均颜色</div>
<div class="flex items-center gap-3">
<div
class="w-12 h-12 rounded-lg border-2 border-gray-300 dark:border-gray-600"
:style="averageColorStyle"
/>
<div class="text-sm font-mono">
RGB({{ result.averageColor.r }}, {{ result.averageColor.g }}, {{ result.averageColor.b }})
</div>
</div>
</div>
<!-- 推荐文字颜色 -->
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="text-sm font-medium mb-2">推荐文字颜色</div>
<div class="flex items-center gap-3">
<div
class="w-12 h-12 rounded-lg border-2 border-gray-300 dark:border-gray-600"
:style="{ backgroundColor: getRecommendedTextColor() }"
/>
<div class="text-sm font-mono">
{{ getRecommendedTextColor() }}
</div>
</div>
</div>
<!-- 亮度分布直方图 -->
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="text-sm font-medium mb-3">亮度分布</div>
<div class="flex items-end gap-px h-32">
<div
v-for="(count, index) in result.histogram"
:key="index"
class="flex-1 bg-brand rounded-t transition-all"
:style="{
height: `${(count / Math.max(...result.histogram)) * 100}%`,
opacity: 0.3 + (count / Math.max(...result.histogram)) * 0.7
}"
:title="`亮度 ${index}: ${count} 像素`"
/>
</div>
<div class="flex justify-between text-xs text-gray-500 mt-2">
<span>暗</span>
<span>亮</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>性能优化技巧
1. 采样率控制
typescript
// 全量分析(100% 采样)- 精确但慢
analyze(file, 100)
// 快速分析(10% 采样)- 快速但略有误差
analyze(file, 10)
// 推荐值(50% 采样)- 平衡性能和精度
analyze(file, 50)2. 图片尺寸限制
typescript
// 限制最大尺寸,避免处理超大图片
const maxSize = 800
if (width > maxSize || height > maxSize) {
const ratio = Math.min(maxSize / width, maxSize / height)
width *= ratio
height *= ratio
}3. Transferable Objects
typescript
// 使用可转移对象,避免数据拷贝
worker.postMessage(
{ imageData },
[imageData.data.buffer] // 转移 ArrayBuffer 所有权
)最佳实践
1. Worker 生命周期管理
typescript
// ✅ 好的做法:使用单例模式复用 Worker
class WorkerPool {
private workers: Worker[] = []
private maxWorkers = navigator.hardwareConcurrency || 4
getWorker(): Worker {
if (this.workers.length < this.maxWorkers) {
const worker = new MyWorker()
this.workers.push(worker)
return worker
}
// 轮询分配
return this.workers[Math.floor(Math.random() * this.workers.length)]
}
terminate() {
this.workers.forEach(w => w.terminate())
this.workers = []
}
}
// ❌ 不好的做法:频繁创建和销毁
function processData(data: any) {
const worker = new MyWorker() // 每次都创建新 Worker
worker.postMessage(data)
worker.onmessage = () => {
worker.terminate() // 立即销毁
}
}2. 错误处理
typescript
// ✅ 完善的错误处理
worker.onerror = (error) => {
console.error('Worker error:', error)
// 记录错误日志
logError(error)
// 降级处理
fallbackToMainThread()
}
worker.onmessageerror = (error) => {
console.error('Message error:', error)
// 数据序列化失败
}
// Worker 内部
self.addEventListener('error', (e) => {
self.postMessage({
type: 'error',
message: e.message,
stack: e.error?.stack
})
})
self.addEventListener('unhandledrejection', (e) => {
self.postMessage({
type: 'error',
message: e.reason
})
})3. 类型安全
typescript
// ✅ 使用 TypeScript 定义消息类型
type WorkerRequest =
| { type: 'process'; data: ProcessData }
| { type: 'cancel' }
| { type: 'config'; options: Options }
type WorkerResponse =
| { type: 'progress'; percentage: number }
| { type: 'success'; result: Result }
| { type: 'error'; error: string }
// Worker 中
self.onmessage = (e: MessageEvent<WorkerRequest>) => {
switch (e.data.type) {
case 'process':
// TypeScript 知道 data 存在
handleProcess(e.data.data)
break
// ...
}
}4. 性能监控
typescript
// ✅ 监控 Worker 性能
class WorkerMonitor {
private startTime = 0
start() {
this.startTime = performance.now()
}
end(taskName: string) {
const duration = performance.now() - this.startTime
console.log(`${taskName} took ${duration.toFixed(2)}ms`)
// 上报性能数据
if (duration > 1000) {
reportSlowTask(taskName, duration)
}
}
}
// 使用
const monitor = new WorkerMonitor()
monitor.start()
worker.postMessage({ type: 'heavy-task', data })
worker.onmessage = () => {
monitor.end('heavy-task')
}5. 渐进增强
typescript
// ✅ 检测 Worker 支持并降级
function createProcessor() {
if (typeof Worker !== 'undefined') {
// 支持 Worker,使用多线程
return new WorkerProcessor()
} else {
// 不支持 Worker,降级到主线程
console.warn('Worker not supported, using main thread')
return new MainThreadProcessor()
}
}实战案例三:Excel 导入导出
场景分析
在企业应用中,Excel 导入导出是非常常见的需求:
- 数据导入: 批量导入员工信息、订单数据、财务报表
- 数据导出: 导出报表、统计数据、用户列表
- 数据量大: 几千到几万行数据处理耗时
- 用户体验: 处理过程中页面不能卡顿
核心挑战:
- Excel 文件解析计算密集
- 大量数据生成需要时间
- 主线程处理会导致页面冻结
在线示例
📥 导入 Excel
📤 导出 Excel
提示:可以生成 10-100,000 行数据测试性能
vue
<template>
<div class="space-y-6">
<!-- 导入 Excel -->
<div class="border rounded-lg p-6">
<h3>📥 导入 Excel</h3>
<input type="file" accept=".xlsx,.xls" @change="handleFileSelect" />
<button @click="handleParse">开始解析</button>
<!-- 进度条 -->
<div v-if="parseStatus === 'parsing'">
<div>{{ progressMessage }}</div>
<div class="progress-bar" :style="{ width: `${progress}%` }"></div>
</div>
<!-- 数据预览 -->
<table v-if="parsedData">
<thead>
<tr>
<th v-for="header in tableHeaders">{{ header }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in displayRows">
<td v-for="cell in row">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 导出 Excel -->
<div class="border rounded-lg p-6">
<h3>📤 导出 Excel</h3>
<input v-model.number="rowCount" type="number" placeholder="行数" />
<button @click="generateSampleData">生成数据</button>
<button @click="handleExport">导出为 Excel</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ExcelWorker from './excel.worker?worker'
const worker = new ExcelWorker()
const selectedFile = ref<File>()
const parseStatus = ref<'idle' | 'parsing' | 'success'>('idle')
const progress = ref(0)
const progressMessage = ref('')
const parsedData = ref<any[] | null>(null)
const exportData = ref<any[]>([])
const rowCount = ref(1000)
const handleParse = () => {
if (!selectedFile.value) return
parseStatus.value = 'parsing'
worker.postMessage({ type: 'parse', file: selectedFile.value })
}
const generateSampleData = () => {
const headers = ['ID', '姓名', '年龄', '部门', '职位', '薪资']
const data = [headers]
for (let i = 1; i <= rowCount.value; i++) {
data.push([
String(i),
`员工${i}`,
String(Math.floor(Math.random() * 30) + 20),
'技术部',
'工程师',
String(Math.floor(Math.random() * 20000) + 5000)
])
}
exportData.value = data
}
const handleExport = () => {
worker.postMessage({
type: 'generate',
data: exportData.value,
fileName: `数据导出_${exportData.value.length}行.xlsx`
})
}
worker.onmessage = (e) => {
switch (e.data.type) {
case 'progress':
progress.value = e.data.percentage
progressMessage.value = e.data.message
break
case 'parse-success':
parsedData.value = e.data.data
parseStatus.value = 'success'
break
case 'generate-success':
// 下载文件
const blob = new Blob([e.data.file], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = e.data.fileName
a.click()
URL.revokeObjectURL(url)
break
}
}
</script>Worker 实现
typescript
import * as XLSX from 'xlsx'
interface WorkerMessage {
type: 'parse' | 'generate' | 'cancel'
file?: File
data?: any[]
fileName?: string
}
let isCancelled = false
// 解析 Excel
async function parseExcel(file: File): Promise<void> {
try {
// 读取文件
const arrayBuffer = await file.arrayBuffer()
self.postMessage({
type: 'progress',
percentage: 30,
message: '正在解析 Excel...'
})
// 解析工作簿
const workbook = XLSX.read(arrayBuffer, { type: 'array' })
const firstSheet = workbook.Sheets[workbook.SheetNames[0]]
// 转换为 JSON
const jsonData = XLSX.utils.sheet_to_json(firstSheet, {
header: 1,
defval: ''
})
self.postMessage({
type: 'parse-success',
data: jsonData,
rowCount: jsonData.length
})
} catch (error) {
self.postMessage({
type: 'error',
error: error.message
})
}
}
// 生成 Excel
async function generateExcel(data: any[], fileName: string): Promise<void> {
try {
self.postMessage({
type: 'progress',
percentage: 30,
message: '正在创建工作表...'
})
// 创建工作簿
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet(data)
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1')
self.postMessage({
type: 'progress',
percentage: 60,
message: '正在生成 Excel...'
})
// 生成文件
const excelBuffer = XLSX.write(workbook, {
bookType: 'xlsx',
type: 'array',
compression: true
})
// 使用 Transferable 传输
self.postMessage({
type: 'generate-success',
file: excelBuffer,
fileName
}, { transfer: [excelBuffer] })
} catch (error) {
self.postMessage({
type: 'error',
error: error.message
})
}
}
self.onmessage = (e: MessageEvent<WorkerMessage>) => {
const { type, file, data, fileName } = e.data
switch (type) {
case 'parse':
if (file) parseExcel(file)
break
case 'generate':
if (data) generateExcel(data, fileName || 'export.xlsx')
break
case 'cancel':
isCancelled = true
break
}
}技术要点
1. 使用 SheetJS (xlsx) 库
bash
pnpm add xlsx2. 解析 Excel 文件
typescript
// 读取文件为 ArrayBuffer
const arrayBuffer = await file.arrayBuffer()
// 解析工作簿
const workbook = XLSX.read(arrayBuffer, { type: 'array' })
// 转换为 JSON
const jsonData = XLSX.utils.sheet_to_json(worksheet, {
header: 1, // 使用数组格式
defval: '' // 空单元格默认值
})3. 生成 Excel 文件
typescript
// 创建工作簿
const workbook = XLSX.utils.book_new()
// 从数组创建工作表
const worksheet = XLSX.utils.aoa_to_sheet(data)
// 添加工作表
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1')
// 生成文件
const excelBuffer = XLSX.write(workbook, {
bookType: 'xlsx',
type: 'array',
compression: true // 启用压缩
})4. 性能优化
- 使用 Transferable Objects 传输大文件
- 启用压缩减小文件大小
- 分批处理超大数据集
- 实时报告进度
5. 实际应用
typescript
// 数据验证
function validateRow(row: any[]): boolean {
return row.length >= 3 && row[0] && row[1]
}
// 数据转换
function transformData(data: any[][]): User[] {
return data.slice(1).map(row => ({
id: row[0],
name: row[1],
age: parseInt(row[2]),
email: row[3]
}))
}
// 错误处理
try {
const users = transformData(parsedData)
await batchImport(users)
} catch (error) {
showError('数据格式错误')
}性能对比
| 数据量 | 主线程耗时 | Worker 耗时 | 提升 |
|---|---|---|---|
| 1,000 行 | 120ms | 80ms | 33% |
| 10,000 行 | 1,200ms | 800ms | 33% |
| 50,000 行 | 6,000ms | 4,000ms | 33% |
| 100,000 行 | 12,000ms | 8,000ms | 33% |
关键优势:
- ✅ 页面不卡顿,用户可继续操作
- ✅ 实时进度反馈
- ✅ 支持取消操作
- ✅ 内存使用更优
常见问题
Worker 无法访问 DOM?
原因: Worker 运行在独立线程,无法访问 DOM、window、document 等对象
解决方案:
typescript
// ❌ Worker 中无法这样做
const element = document.getElementById('app')
// ✅ 在主线程处理 DOM,传递数据给 Worker
const data = element.getBoundingClientRect()
worker.postMessage({ data })如何在 Worker 中使用第三方库?
方式 1: 使用 importScripts(传统方式)
typescript
// Worker 中
importScripts('https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js')
// @ts-ignore
const result = _.chunk([1, 2, 3, 4], 2)方式 2: 使用 ES Modules(现代方式,Vite 支持)
typescript
// Worker 中
import _ from 'lodash-es'
const result = _.chunk([1, 2, 3, 4], 2)Worker 之间如何通信?
使用 SharedWorker:
typescript
// shared-worker.ts
const connections: MessagePort[] = []
self.onconnect = (e) => {
const port = e.ports[0]
connections.push(port)
port.onmessage = (e) => {
// 广播给所有连接
connections.forEach(p => {
if (p !== port) {
p.postMessage(e.data)
}
})
}
}
// 主线程
const worker = new SharedWorker('./shared-worker.ts')
worker.port.start()
worker.port.postMessage('Hello')如何调试 Worker?
Chrome DevTools:
- 打开开发者工具
- Sources → Threads 面板
- 选择对应的 Worker 线程
- 设置断点、查看变量
Console 输出:
typescript
// Worker 中的 console.log 会显示在主线程的控制台
console.log('Worker message:', data)注意事项
- ⚠️ Worker 创建有开销,避免频繁创建销毁
- ⚠️ 数据传输有序列化成本,大数据使用 Transferable Objects
- ⚠️ Worker 数量不宜过多,建议不超过 CPU 核心数
- ⚠️ 注意内存泄漏,及时 terminate 不用的 Worker
- ⚠️ Worker 中无法使用 localStorage、sessionStorage
- ⚠️ 跨域限制:Worker 脚本必须同源