JavaScript从使用开初就一直基于事件循环的单线程运行模型,即使是成功进军后端开发的Nodejs也没有改变这一模型。那么对于计算密集型的应用,我们必须创建新进程来执行运算,然后执行进程间通信实现传参和获取运算结果。否则会造成UI界面卡顿,甚至导致浏览器无响应。
从功能实现来看,我们可以通过新增iframe加载同域页面来创建JSVM进程执行运算从而避免造成界面卡顿的问题。但存在如下问题:
幸运的是HTML5为JavaScript引入多线程运行模型,这也是本文将和大家一起探讨的———Web Worker。
在使用Web Worker前我们要了解它的能力边界,让我们避免无谓的撞壁:
1.1. 以http(s)://
协议加载给WebWorker线程运行的脚本时,其URL必须和UI线程所属页面的URL同源;
1.2. 不能加载客户端本地脚本给WebWorker线程运行(即采用file://
协议),即使UI线程所属页面也是本地页面;
1.1. 无法访问UI线程所属页面的任何DOM元素;
1.2. 可访问如下BOM元素
1.2.1. XMLHttpRequest/fetch
1.2.2. setTimeout/clearTimeout
1.2.3. setInterval/clearInterval
1.2.4. location,注意该location指向的是WebWorker创建时以UI线程所属页面的当前Location为基础创建的WorkerLocation对象,即使此后页面被多次重定向,该location的信息依然保持不变。
1.2.5. navigator,注意该navigator指向的是WebWorker创建时以UI线程所属页面的当前Navigator为基础创建的WorkerNavigator对象,即使此后页面被多次重定向,该navigator的信息依然保持不变。
Web Worker分为Dedicated Web Worker和Shared Web Worker两类,它们的特性如下:
const worker = new Worker('work.js') // 若下载失败如404,则会默默地失败不会抛异常,即无法通过try/catch捕获。 const workerWithName = new Worker('work.js', {name: 'worker2'}) // 为Worker线程命名,那么在Worker线程内的代码可通过 self.name 获取该名称。 worker.postMessage('Send message to worker.') // 发送文本消息 worker.postMessage({type: 'message', payload: ['hi']}) // 发送JavaScript对象,会先执行序列化为JSON文本消息再发送,然后在接收端自动反序列化为JavaScript对象。 const uInt8Array = new Uint8Array(new ArrayBuffer(10)) for (let i = 0; i < uint8array.length; ++i) { uInt8Array[i] = i * 2 } worker.postMessage(uInt8Array) // 以先序列化后反序列化的方式发送二进制数据,发送后主线程仍然能访问uInt8Array变量的数据,但会造成性能问题。 worker.postMessage(uInt8Array, [uInt8Array]) // 以Transferable Objets的方式发送二进制数据,发送后主线程无法访问uInt8Array变量的数据,但不会造成性能问题,适合用于影像、声音和3D等大文件运算。 // 接收worker线程向主线程发送的消息 worker.onmessage = event => { console.log(event.data) } worker.addEventListener('message', event => { console.log(event.data) }) // 当发送的消息序列化失败时触发该事件。 worker.onmessageerror = error => console.error(error) // 捕获Worker线程发生的异常 worker.onerror = error => { console.error(error) } // 关闭worker线程 worker.terminate()
// Worker线程的全局对象为WorkerGlobalScrip,通过self或this引用。调用全局对象的属性和方法时可以省略全局对象。 // 接收主线程向worker线程发送的消息 self.addEventListener('message', event => { console.log(event.data) }) addEventListener('message', event => { console.log(event.data) }) this.onmessage = event => { console.log(event.data) } // 当发送的消息序列化失败时触发该事件。 self.onmessageerror = error => console.error(error) // 向主线程发送消息 self.postMessage('send text to main worker') // 结束自身所在的Worker线程 self.close() // 导入其他脚本到当前的Worker线程,不要求所引用的脚本必须同域。 self.importScripts('script1.js', 'script2.js')
Blob
和URL.createObjectURL
限制:UI线程所属页面不是本地页面,即必须为http(s)://
协议。
const script = `addEventListener('message', event => { console.log(event.data) postMessage('echo') }` const blob = new Blob([script]) const url = URL.createObjectURL(blob) const worker = new Worker(url) worker.onmessage = event => console.log(event.data) worker.postMessage('main thread') setTimeout(()=>{ worker.terminate() URL.revokeObjectURL(url) // 必须手动释放资源,否则需要刷新Browser Context时才会被释放。 }, 1000)
限制:无法利用JavaScript的ASI机制少写分号。
优点:即使UI线程所属页面是本地页面也可以执行。
// 由于Data URL的内容为必须压缩为一行,因此JavaScript无法利用换行符达到分号的效果。 const script = `addEventListener('message', event => { console.log(event.data); postMessage('echo'); }` const worker = new Worker(`data:,${script}`) // 或 const worker = new Worker(`data:application/javascript,${script}`) worker.onmessage = event => console.log(event.data) worker.postMessage('main thread')
共享线程可以和多个同域页面间通信,当所有相关页面都关闭时共享线程才会被释放。
这里的多个同域页面包括:
const worker = new SharedWorker('./worker.js') worker.port.addEventListener('message', e => { console.log(e.data) }, false) worker.port.start() // 连接worker线程 worker.port.postMessage('hi') setTimeout(()=>{ worker.port.close() // 关闭连接 }, 10000)
let conns = 0 // 当UI线程执行worker.port.start()时触发建立连接 self.addEventListener('connect', e => { const port = e.ports[0] conns+=1 port.addEventListener('message', e => { console.log(e.data) // 注意console对象指向第一个创建Worker线程的UI线程的console对象。即如果A先创建Worker线程,那么后续B、C等UI线程执行worker.port.postMessage时回显信心依然会发送给A页面。 }) // 建立双向连接,可相互通信 port.start() port.postMessage('hey') })
const worker = new SharedWorker('./worker.js') worker.port.addEventListener('message', e => { console.log('SUM:', e.data) }, false) worker.port.start() // 连接worker线程 const button = document.createElement('button') button.textContent = 'Increment' button.onclick = () => worker.port.postMessage(1) document.body.appendChild(button)
let sum = 0 const conns = [] self.addEventListener('connect', e => { const port = e.ports[0] conns.push(port) port.addEventListener('message', e => { sum += e.data conns.forEach(conn => conn.postMessage(sum)) }) port.start() })
通过WebWorker执行计算密集型任务是否就可以肆无忌惮地编写代码,并保证用户界面操作流畅呢?当然不是啦,工具永远只能让你更好地完成工作,但无法禁止你用错。
只要在频繁持续执行的代码中加入console
对象方法的调用,加上一不小心打开Devtools工具,卡死浏览器简直不能再就简单了。这是为什么呢?
因为UI线程在创建WebWorker线程时会将自身的console对象绑定给WebWorker线程的console属性上,那么WebWorker线程是以同步阻塞方式调用console将参数传递给UI线程的console对象,自然会占用UI线程的处理时间。
上面说了这么多那实际项目中应该怎么使用呢?或者说如何更好的集成到工程自动化工具——Webpack呢?
worker-loader和shared-worker-loader就是我们想要的。
通过worker-loader将代码转换为Blob类型,并通过URL.createObjectURL创建url分配给WebWorker线程执行。
npm install worker-loader -D
// 处理worker代码的loader必须位于js和ts之前 { test: /\.worker\.ts$/, use: { loader: 'worker-loader', options: { name: '[name]:[hash:8].js', // 打包后的chunk的名称 inline: true // 开启内联模式,将chunk的内容转换为Blob对象内嵌到代码中。 } } }, { test: /\.js$/, use: { loader: 'babel-loader' }, exclude: [path.resolve(__dirname, 'node_modules')] }, { test: /\.ts(x?)$/, use: [ { loader: 'babel-loader' }, { loader: 'ts-loader' } // loader顺序从后往前执行 ], exclude: [path.resolve(__dirname, 'node_modules')] }
import MyWorker from './my.worker' const worker = new MyWorker(''); worker.postMessage('hi') worker.addEventListener('message', event => console.log(event.data))
cosnt worker: Worker = self as any worker.addEventListener('message', event => console.log(event.data)) export default null as any // 标识当前为TS模块,避免报xxx.ts is not a module的异常
一般场景下我们会这样使用WebWorker,
翻译为代码就是
let arg1 = getArg1() let arg2 = getArg2() const result = await performCalcuation(arg1, arg2) doSomething(result)
而UI线程和WebWorker线程的消息机制通信机制显然会加大代码复杂度,而Comlink类库恰好能抚平这道伤疤。
import * as Comlink from 'comlink' async function init() { const cl = Comlink.wrap(new Worker('worker.js')) console.log(`Counter: ${await cl.counter}`) await cl.inc() console.log(`Counter: ${await cl.counter}`) }
import * as Comlink from 'comlink' const obj = { counter: 0, inc() { this.counter+=1 } } Comlink.expose(obj)
Electron中使用Web Worker的同源限制中开了个口——UI线程所属页面URL为本地文件时,所分配给Web Worker的脚本可为本地脚本。
其实Electron打包后读取的HTML页面、脚本等都是本地文件,如果不能分配本地脚本给Web Worker执行,那就进入死胡同了。
const path = window.require('path') const worker = new Worker(path.resolve(__dirname, 'worker.js'))
上述代码仅表示Electron可以分配本地脚本给WebWorker线程执行,但实际开发阶段一般是通过 ~http(s)://~ 协议加载页面资源,而发布时才会打包为本地资源。
所以这里还要分为开发阶段用和发布用代码,还涉及资源的路径问题,所以还不如直接转换为Blob数据内嵌到UI线程的代码中更便捷。
随着边缘计算的兴起,客户端承担部分计算任务提高运算时效性和降低服务端压力必将成为趋势。WebWorker这一秘技你Get到了吗?:)
转载请注明来自: https://www.cnblogs.com/fsjoh... —— 肥仔John