# web-worker-demo **Repository Path**: kayanchan/web-worker-demo ## Basic Information - **Project Name**: web-worker-demo - **Description**: web-worker demo、web-worker的应用:文件切片 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2024-11-19 - **Last Updated**: 2024-11-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: web-worker, file-slicing ## README # Web Worker 学习资源: - [2023 年的 Web Worker 项目实践](https://mp.weixin.qq.com/s?__biz=MzIxMzExMjYwOQ==&mid=2651899575&idx=1&sn=0310a88f72f7b63bfebc0835a2df291f&chksm=8c5fa4f9bb282def5a8296269ed7e874a367809aefe3a88bca262672ad6fb34932c27f97ad0a#rd) - [web-worker 的基本用法并进行大文件切片上传(附带简易 node 后端)](https://juejin.cn/post/7351300892572745764) ## Web Worker Web Worker 是一种可以运行在 Web 应用程序后台线程,独立于主线程之外的技术 JavaScript 语言是单线程模型的,而通过使用 Web Workers,可以创造多线程环境,从而可以发挥现代计算机的多核 CPU 能力,在应对规模越来越大的 Web 程序时也有较多收益 Web Workers 宏观语义上包含了三种不同的 Worker:DedicatedWorker(专有 worker)、 SharedWorker(共享 Worker)、 ServiceWorker。 ## 兼容性 Web Workers 是 2009 年的提案,2012 年各大浏览器已经基本支持 - 是否有 Worker 能力:通过浏览器是否有 window.Worker 来判断 - 能否实例化 Worker:通过监控 new Worker() 是否报错来判断 - 能否跨线程通信:通过测试双向通信来验证,并设置超时 - 首次通信耗时:页面开始加载 Worker 脚本到首次通讯完成的耗时,该指标与 JS 资源加载时长,同步逻辑执行耗时相关 ## 使用场景 - Worker API 的局限性:同源限制、无 DOM 对象、异步通信,因此适合不涉及 DOM 操作的任务 - Worker 的使用成本:创建时间 + 数据传输时间;考虑到可以预创建,可以忽略创建时间,只考虑数据传输成本,大部分设备和数据情况下速度不是瓶颈 - 任务特点:需要是可并行的多任务,为了充分利用多核能力,可并行的任务数越接近 CPU 数量,收益会越高 ## Worker 线程 与 主线程的异同 ### 共同点 - 包含完整的 JS 运行时,支持 ES 规范定义的语言语法和内置对象 - 支持 XMLHTTPRequest,能独立发送网络请求和后端进行交互 - 包含只读的 Location,指向 Worker 线程执行的 script url,可通过 url 传递参数给 Worker 环境 - 包含只读的 Navigator,用于获取浏览器信息 - 支持 setTimeout / setInterval 计时器,可用于实现异步逻辑 - 支持 WebSocket 进行网络 I / O,支持 IndexDB 进行文件 I / O ### 差异点 (Worker 线程无法操作 UI,并受主线程控制) - Worker 线程没有 DOM API,无法新建和操作 DOM,也无法访问到主线程的 DOM Element - Worker 线程和主线程内存独立,Worker 线程无法访问页面上的全局变量(window,document 等)和 JS 函数 - Worker 线程不能调用 alert() 和 confirm() 等 UI 相关的 BOM API - Worker 线程被主线程控制,主线程可以新建和销毁 Worker - Worker 线程可以通过 `self.close` 自行销毁 ## 代码实现 ```javascript // main.js const worker = new Worker("./worker.js"); worker.onmessage = function (event) { const result = event.data; console.log(`Result from worker: ${result}`); // 10 }; worker.postMessage(5); // work.js self.onmessage = function (event) { const data = event.data; // 5 const result = data * 2; self.postMessage(result); }; ``` postMessage 会在接收线程创建一个 MessageEvent,传递的数据添加到 event.data,再触发该事件; MessageEvent 的回调函数进入 Message Queue,成为 待执行的宏任务。 因此 postMessage 顺序发送 的消息,在接收线程中会 顺序执行回调函数。 而且无需担心实例化 Worker 过程中 postMessage 的信息丢失问题,对此 Worker 内部机制已经处理。 Worker 事件驱动的通信 API 虽然简洁,但大多数场景下通信需要等待响应,并且多次同类型通信要匹配到各自的响应,所以业务使用一般会封装成 Promise。 ## Worker 的困境 - postMessage 传递信息的方式不适合现代编程模式,当出现多个事件时就涉及分拆解析和解决耦合问题,需要改造 - 新建 worker 需要单独文件,因此项目内需要处理打包拆分逻辑,独立出 worker 文件 - worker 内可支持定义函数,可通过 importScript 方式引入依赖文件,但是都独立于主线程文件,依赖和函数的复用都需要改造 - 多线程环境必然涉及同步运行多个 worker,多 worker 的启动、复用和管理都需要自行处理 ## Worker 类库 - workpool 改造 Worker 可以借助成熟类库的力量,可关注类库以下几个能力: 1. 通信是否有包装成更好用的方式,比如 promise 化或者 rpc 化 2. 是否可以动态创建函数——可以增加 worker 灵活性 3. 是否包含多 worker 的管理能力,也就是线程池 4. 考虑 node 的使用场景,是否可以跨端运行 | | 通信 | 动态执行函数 | 线程池 | 跨段 | |-----|-----|------------|-------|-----| | promise-worker | promise化 | x | x | √ | | comlink | rpc化 | x | x | x | | Workly | x | √ | x | x | | workpool | promise化 | √ | √ | √ | ## 代码优化 实现目标: 1. 足够灵活:可以随意编写函数,今天想计算1+1,明天想计算1+2,这些都可以动态编写,最好它可以直接写在主线程我自己的文件里,不需要我跑到 worker 文件里去改写(workerpool已具备动态创建函数的能力) 2. 足够强大:可以使用公共依赖,比如 lodash 或者是项目里已经定义好的某些公共函数 ### 搭建worker的依赖管理 1. 抽取依赖、管理依赖和更新 新增一个依赖管理文件worker-depts.js,可按照路径作为 key 名构建一个聚合依赖对象,然后在 worker 文件内引入这份依赖 ```javascript // worker-depts.js import * as _ from 'lodash-es' import * as math from '../math' const workerDepts = { _, 'util/math': math, } export default workerDepts ``` 2. 定义公共调用函数,引入所打包的依赖并串联流程 worker 内定义一个公共调用函数,注入 worker-depts 依赖,并注册在 workerpool 的方法内 ```javascript // worker.js import workerDepts from '../util/worker/worker-depts' function runWithDepts(fn: any, ...args: any) { var f = new Function('return (' + fn + ').apply(null, arguments);') return f.apply(f, [workerDepts].concat(args)) } workerpool.worker({ runWithDepts, }) ``` 主线程文件内定义相应的调用方法,入参是自定义函数体和该函数的参数列表 ```javascript // index.js import workerpool from 'workerpool' export async function workerDraw(fn, ...args) { const pool = workerpool.pool('./worker.js') return pool.exec('runWithDepts', [String(fn)].concat(args)) } ``` 3. 调用 引用了一个项目内的公共函数 fibonacci,也引用了一个 lodash 的 map 方法,都可以在depts 对象上取到 ```javascript // 项目内需使用worker时 const res = await workerDraw( (depts, m, n) => { const { map } = depts['_'] const { fibonacci } = depts['util/math'] return map([m, n], (num) => fibonacci(num)) }, input1, input2, ) ``` 4. 优化语法支持 没有语法支持的依赖管理是很难用的,通过对 workerDraw 进行 ts 语法包装,可以实现在使用时的依赖提示 ```javascript import workerpool from 'workerpool' import type TDepts from './worker-depts' export async function workerDraw(fn: (depts: typeof TDepts, ...args: T) => Promise | R, ...args: T) { const pool = workerpool.pool('./worker.js') return pool.exec('runWithDepts', [String(fn)].concat(args)) } ``` 5. 其他问题 新增了 worker 以后,出现了 window和 worker 两种运行环境,另外还有 node 环境,一共是三种运行环境。 globalThis 对象,它是三套环境内都存在的一个对象,通过判断globalThis.constructor.name的值,值分别是'Window' / 'DedicatedWorker'/ 'Object',从而实现环境的区分 ## 实现大文件切片上传 ### 逻辑梳理 1. 文件切片:使用 JavaScript 的 Blob.prototype.slice() 方法将大文件切分成多个切片 2. 上传切片:使用 axios 或其他 HTTP 客户端库逐个上传切片。可以为每个切片生成一个唯一的标识符(例如,使用文件的哈希值和切片索引),以便后端能够正确地将它们合并 3. 客户端线程数:获取用户CPU线程数量,以便最大优化上传文件速度 4. 控制上传接口的并发数量:防止大量的请求并发导致页面卡死,设计一个线程队列,控制请求数量一直保持在6 ### 获取客户端线程数 navigator.hardwareConcurrency 是一个只读属性,它返回用户设备的逻辑处理器内核数 ```javascript export const getConcurrency = () => navigator.hardwareConcurrency || 4 // 浏览器不支持就默认4核 ``` ### 计算切片的信息 1. 定义单个切片的大小 `const chunkSize = 1024; // 1kb` 2. 获取线程数 `const thread = getConcurrency()` 3. 定义储存切片的容器 `const chunks = []` 4. 计算切片总数量(文件大小/单个切片大小) `const chunkNum = Math.ceil(file.size / chunkSize)` 5. 计算每个线程需要处理切片的数量(切片总数量/线程数) `const workerChunkCount = Math.ceil(chunkNum / thread)` 6. 计算每个线程处理切片的开始索引和结束索引 ```javascript for (let i = 0; i < thread; i++) { const worker = new FileWorker() // 计算每个线程的开始索引和结束索引 const startIndex = i * workerChunkCount; let endIndex = startIndex + workerChunkCount; // 防止最后一个线程结束索引大于文件的切片数量的总数量 if (endIndex > chunkNum) { endIndex = chunkNum; } // 单个线程需要处理切片的数据 const data = { file, // 原文件 chunkSize, // 单个切片大小 startIndex, // 开始索引 endIndex, // 结束索引 } // 切片工作交给Worker } ``` ### 处理切片 1. 获取每个线程中的切片信息数据集合 ```javascript const arr = []; for (let i = startIndex; i < endIndex; i++) { arr.push( createChunks(file, i, chunkSize) // 进行切片 ); } const chunks = await Promise.all(arr) ``` 2. 切片 ```javascript // 单个切片的开始索引 const start = index * chunkSize; // 单个切片的结束索引 const end = start + chunkSize; const fileReader = new FileReader(); // 切片 const chunk = file.slice(start, end); // 每个切片都通过FileReader读取为ArrayBuffer fileReader.onload = (e) => { // ArrayBuffer => TypedArray const content = new Uint8Array(e.target.result); // 计算分片的MD5哈希值 const md5s = md5.arrayBuffer(content) // 将切片buffer转换为十六进制String function arrayBufferToHex(buffer) { let bytes = new Uint8Array(buffer); let hexString = ''; for (let i = 0; i < bytes.byteLength; i++) { let hex = bytes[i].toString(16); hexString += hex.length === 1 ? '0' + hex : hex; } return hexString; } resolve({ start, end, index, hash: arrayBufferToHex(md5s), // 生成唯一的hash files: chunk, }); } // 读取文件的分片 file => buffer fileReader.readAsArrayBuffer(chunk); ```