介绍与web worker有关的:

  • 概况: 支持情况, 类型, 特点等
  • 使用场景: 轮询, 复杂数据解析, JS库的代理等
  • 相关工具: workerize, comlink等

是什么

MDN上的一段介绍来开头:

Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。此外,他们可以使用XMLHttpRequest执行 I/O (尽管responseXML和channel属性总是为空)。一旦创建, 一个worker 可以将消息发送到创建它的JavaScript代码, 通过将消息发布到该代码指定的事件处理程序(反之亦然)。

支持情况

Can I Use webworkers? Data on support for the webworkers feature across the major browsers from caniuse.com.

类型

Web Worker具有如下三种不同的类型:

方面 Dedicated Worker Shared Worker Service Worker
用途 在属于创建者的worker线程中执行任务 在同一主域跨browser环境下(window,iframe..)的worker线程执行任务 缓存、推送、离线模式等
执行上下文 DedicatedWorkerGlobalScope SharedWorkerGlobalScope ServiceWorkerGlobalScope
与主线程通讯 Worker.postMessage() Worker.port.postMessage() 特殊生命周期+事件驱动
访问限制 DOM API DOM API DOM API & 同步API(localStorage, XHR…)

特点

Worker的本质是在本地开启一个系统级的新线程来处理任务,具有以下特点:

  • 上下文: 具有独立的worker执行上下文,也就是WorkerGlobalScope,在worker内通过self来获取引用它的对象
  • 事件循环: 具有与browser context下不同的worker event loop,它的任务队列仅包含事件、回调与网络活动
  • 数据通讯: 在主线程与worker线程之间传递消息时使用结构化克隆算法对数据进行处理,使用基于Transferable接口的postMessage()方法进行数据的传输,无法直接传输Error和Function对象,在另一侧通过监听message事件来获取数据。
  • 网络访问: XMLHttpRequest(Service Worker中无法使用)

主线程与worker线程之间的消息传递示意图:

提升渲染性能

具体可以看看这篇文章

直接在主线程中运行一段代码

使用worker后

worker的执行不会影响到页面渲染进程的执行

怎么用

  1. 创建一个worker对象(以Dedicated Worker为例)

    // main.js
    let myWorker = new Worker('worker.js')
    
  2. 主线程发送消息

    // main.js
    myWorker.postMessage('hi from main')
    
  3. worker线程处理&发送消息

    // worker.js
    self.addEventListener('message', e => {
      let { data } = e
      console.log('Message received from main script: ', data)
      console.log('Posting message back to main script')
      self.postMessage('hello fron worker')
      // self.close() 在内部关闭worker
    })
    
  4. 主线程监听消息

    // main.js
    myWorker.addEventListener('message', e => {
      let { data } = e
      console.log('Message received from worker: ', data)
      // myWorker.terminate() 在外部关闭worker
    })
    

什么时候用

列举一些常见场景

轮询

比如说对文件上传进度的轮询,对于每一个上传的文件均在worker内启用一个定时任务来进行轮询处理。

// polling-worker.js
import { apiURL, WORKER_SIGNAL } from '@/utils/config'
import axios from 'axios'
let intervalTasks = []
const pollingPeriod = 5000
self.onmessage = async e => {
    let { message, id, token } = e.data
    switch (message) {
        case WORKER_SIGNAL.AUDIO_POLLING:
            let url = apiURL + '/upload/status'
            let param = `?ids=${id}`
            let interval = setInterval(async() => {
                try {
                    let res = await axios.get(url + param, { headers: { token } })
                    self.postMessage({status: 'success', data: res.data})
                } catch (err) {
                    self.postMessage({status: 'error', data: err})
                }
            }, pollingPeriod)
            intervalTasks.push({ interval, id })
            break
        case WORKER_SIGNAL.STOP:
            let obj = intervalTasks.find(e => e.id === id)
            clearInterval(obj.interval)
    }
}

当请求成功时通知渲染进程已完成该文件上传,并在worker内部终止自身线程,在队列中取消该worker任务的flag。

复杂数据解析

在worker中处理与解析复杂的数据对象可以避免阻塞UI线程,而且也可以节省一次往返时间(round trip)。

// filter-worker.js
self.onmessage = function (e) {
  self.postMessage(e.data.filter(function () {
    return e.flagged;
  }));
}

// app.js
var filterWorker = new Worker('filter-worker.js');

filterWorker.onmessage = function (e) {
  // Log filtered list
  console.log(e.data);
}

var hugeData = [ ... ];

filterWorker.postMessage(hugeData);

代理其他JS库的API

可以在worker线程中加载与执行JS库,执行相关操作。

// proxy-worker.js
loadScripts('https://large/but/cool/library.js');

self.onmessage = function (e) {
  switch(e.data.type) {
    case: 'thingIWantToDo':
      myLibraryScope.doTheThing(e.data.payload).then(res => {
        self.postMessage({
          status: 'COMPLETED'
          type: e.data.type,
          payload: res
        })
      });
      break;
      
    default:
      throw new Error(`Action ${e.data.type} is not handled by proxy-worker`);
  }
}

// app.js
var coolWorker = new Worker('proxy-worker.js');

dispatch({
  type: 'thingIWantToDo',
  payload: 1000
}).then(console.log);

function dispatch(action) {
  return new Promise(resolve => {
    const listener = res => {
      if (res.data.type === action.type && res.data.status === 'COMPLETED') {
        resolve(res.data.payload);
        
        coolWorker.removeEventListener('message', listener);
      }
    };
    
    coolWorker.addEventListener('message', listener);
    
    coolWorker.postMessage(action);
  });
}

electron中的node任务

在使用electron时,由于全局node可用的特点,也可以把一些node任务放在渲染进程的worker线程中,如文件读写等。

// node-worker.js
import { WORKER_SIGNAL } from '@/utils/config'
import nodeUtils from '@/utils/node'
onmessage = async e => {
    let { message, detailInfos,type,targetPath } = e.data
    switch (message) {
        case WORKER_SIGNAL.EXPORT_CONFERENCE:
            try {
                await nodeUtils.generateOfficeWord(detailInfos, type, targetPath)
                self.postMessage({status: 'success'})
            } catch (err) {
                self.postMessage({status: 'error', data: err})
            }
            break
        default:
            console.log(message)
    }
}

更优雅的使用

worker-plugin & worker-loader

若要在使用webpack的项目中使用worker,需要根据webpack版本选择对应的loader:

worker-loader为例:

// webpack.config.js
{
  module: {
    rules: [
      {
        test: /\.worker\.js$/,
        use: { loader: 'worker-loader' }
      }
    ]
  }
}

// main.js
import FileWorker from 'worker-loader!@/workers/file.worker.js'
this.fileWorker = new FileWorker()
this.fileWorker.onmessage = e => {
  ...
}
this.fileWorker.postMessage({...})

// file.worker.js
onmessage = e => {...}

workerize & workerize-loader

如果想要通过async/await与ES6的模块化使用worker,可以使用workerize:

Moves a module into a Web Worker, automatically reflecting exported functions as asynchronous proxies.

workerize的使用

通过workerize使用worker如下所示:

let worker = workerize(`
	export function add(a, b) {
		// block for half a second to demonstrate asynchronicity
		let start = Date.now();
		while (Date.now()-start < 500);
		return a + b;
	}
`);

(async () => {
	console.log('3 + 9 = ', await worker.add(3, 9));
	console.log('1 + 2 = ', await worker.add(1, 2));
})();

workerize-loader的使用

如果是在webpack环境中需要使用workerize-loader来载入worker:

// worker.js
// block for `time` ms, then return the number of loops we could run in that time:
export function expensive(time) {
    let start = Date.now(),
        count = 0
    while (Date.now() - start < time) count++
    return count
}

// main.js
import worker from 'workerize-loader!./worker'
let instance = worker()  // `new` is optional
instance.expensive(1000).then( count => {
    console.log(`Ran ${count} loops`)
})

workerize的核心

workerize的源码非常简洁,来看看其中主要的两个方法。

生成worker对象的workerize方法:

export default function workerize(code, options) {
	let exports = {};
  ...
  // 若为函数,则获取函数的字符串转化结果
	if (typeof code==='function') code = `(${Function.prototype.toString.call(code)})(${exportsObjName})`;
  // 转换为commonjs模块规范
	code = toCjs(code, exportsObjName, exports) + `\n(${Function.prototype.toString.call(setup)})(self,${exportsObjName},{})`;
	let url = URL.createObjectURL(new Blob([code])),
		worker = new Worker(url, options);
    ...
}

该方法中最关键的是使用了Function.prototype.toString()将函数转换成了字符串再进行传输,这是由于根据结构化克隆的限制是不能传递Function类型对象的,只有使用这个trick。

另外,Function.prototype.toString()并不继承自Object.prototype.toString(),对于用户定义的函数会返回一个包含用于定义函数源文本段的字符串,详情见MDN文档

除此之外为了兼容性还使用了toCjs()方法来转换模块化的方式(ES6 –> CommonJS):

function toCjs(code, exportsObjName, exports) {
	code = code.replace(/^(\s*)export\s+default\s+/m, (s, before) => {
		exports.default = true;
		return `${before}${exportsObjName}.default=`;
	});
	code = code.replace(/^(\s*)export\s+((?:async\s*)?function(?:\s*\*)?|const|let|var)(\s+)([a-zA-Z$_][a-zA-Z0-9$_]*)/mg, (s, before, type, ws, name) => {
		exports[name] = true;
		return `${before}${exportsObjName}.${name}=${type}${ws}${name}`;
	});
	return `var ${exportsObjName}={};\n${code}\n${exportsObjName};`;
}

如果想使用简化的worker操作接口,可以使用comlink:

makes WebWorkers enjoyable.

comlink抽象化了MessageChannel与postMessage,将本来基于message的API变成了像使用本地变量一样在一个线程中使用另一个线程中的变量。

comlink可以在提供postMessage方法的环境下使用,比如window,iframe和ServiceWorker。

comlink所提供的三个方法:

  • Comlink.proxy(endpoint) - 返回在endpoint另一端暴露的值,endpoint可以是Window,WorkerMessagePort
  • Comlink.expose(obj, endpoint) - 将obj暴露给endpoint,在endpoint的另一端使用Comlink.proxy
  • Comlink.proxyValue(value) - 确保参数或返回值是代理的引用值,而不是使用拷贝的值。

    const api = Comlink.proxy(new Worker('worker.js'));
    const obj = { x: 0 };
    // await api.setXto4(obj); obj.x仍为0
    await api.setXto4(Comlink.proxyValue(obj)); //obj.x变成了4
    console.log(obj.x);
    

comlink的使用

在不同的线程中使用上面的方法即可:

// main.js
const MyClass = Comlink.proxy(new Worker("worker.js"));
// `instance` is an instance of `MyClass` that lives in the worker!
const instance = await new MyClass();
await instance.logSomething(); // logs “myValue = 42”

// worker.js
const myValue = 42;
class MyClass {
  logSomething() {
    console.log(`myValue = ${myValue}`);
  }
}
Comlink.expose(MyClass, self);

comlink的内部

几个关键方法:

  • 在proxy中代理worker中的对象,包括endpoint和message通讯的处理

    export function proxy<T = any>(
      endpoint: Endpoint | Window,
      target?: any
    ): ProxyResult<T> {
      // 检查endpoint
      if (isWindow(endpoint)) endpoint = windowEndpoint(endpoint);
      if (!isEndpoint(endpoint))
        throw Error(
          "endpoint does not have all of addEventListener, removeEventListener and postMessage defined"
        );
      // 若endpoint为MessagePort,则开启port
      activateEndpoint(endpoint);
      // 返回一个绑定了endpoint中的代理对象、值或其他类型
      return cbProxy(
        async irequest => {
          let args: WrappedValue[] = [];
          if (irequest.type === "APPLY" || irequest.type === "CONSTRUCT")
          args = irequest.argumentsList.map(wrapValue);
          // 获取监听到的MessageEvent事件对象
          const response = await pingPongMessage(
            endpoint as Endpoint,
            Object.assign({}, irequest, { argumentsList: args }),
            transferableProperties(args)
          );
          // 处理并返回WrappedValue数据
          const result = response.data as InvocationResult;
          return unwrapValue(result.value);
        },
        [],
        target
      // ProxyResult<T>扩展自ProxyObject<T>,可以正确处理raw functions与class类型
      ) as ProxyResult<T>; 
    }
    
  • 对于message的抽象是在pingPongMessage()方法中做的,其中整合了postMessage()、监听与移除message事件Handler的操作,如下所示

    function pingPongMessage(
      endpoint: Endpoint,
      msg: Object,
      transferables: Transferable[]
    ): Promise<MessageEvent> {
      const id = `${uid}-${pingPongMessageCounter++}`;
    
      return new Promise(resolve => {
        // 在内部addEventListener
        attachMessageHandler(endpoint, function handler(event: MessageEvent) {
          if (event.data.id !== id) return;
          // 在内部removeEventListener
          detachMessageHandler(endpoint, handler);
          resolve(event);
        });
    
        // 使用当前endpoint的postMessage方法传送消息
        // Copy msg and add `id` property
        msg = Object.assign({}, msg, { id });
        endpoint.postMessage(msg, transferables);
      });
    }
    

在WebRTC中使用comlink的一个例子

引用一下Jason Miller对于三种常见worker wrapper言简意赅的对比:

  • greenlet: move a function into a thread
  • workerize: move a module with async function exports into a thread
  • comlink: move a module with any interface into a thread

workerize相对于comlink更轻量,issue中的一个总结:

  • workerize的实现更加小巧和简洁
  • workerize是基于Function的,无需导出Class
  • workerize仅需ES3(Promise,基本无需polyfill),而comlink需要ES6(Proxy, Map, WeakSet, generator)和ES7(async functions)的支持

在代码中可以根据实际情况选择workerize或comlink。

参考