如何无痛的为你的前端项目引入多线程

978 阅读8分钟

谈谈Web Worker

众所周知,JavaScript引擎是单线程的,这意味着所有的操作都会在主线程当中发生。

尽管浏览器内核是多线程的,但是负责页面渲染的UI线程总是会在JS引擎线程空闲时(执行完一个macro task)才会执行。

JavaScript的事件队列模型,作者是Lydia Hallie

这意味着如果页面当中包含某些计算密集的代码时,因为JS引擎是单线程的,会阻塞整个事件队列,进而导致整个页面卡住。

而Web Worker就是为了解决这个问题而生的。

这里引用一段阮一峰老师的定义

Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。

多线程的力量

我这里写了一个简单示例,来展示将复杂的计算逻辑移出主线程能带来多大的提升。

这里我们假设页面存在一个很复杂的计算操作,需要耗费好几秒才能完成。由于JS引擎是单线程的,如果在主线程里执行这个计算逻辑,我们将看到页面将在好几秒内是无法响应的。从用户视角来看,就是整个页面“卡住了”。毫无疑问,这是非常令人挫败的体验。

然后我们看一下另一个做法,把这个计算逻辑放到worker线程当中去计算,计算完毕后再将结果传回主线程。

可以看到,复杂的计算操作一点也没有影响UI线程的运行,页面一直在流畅的更新,并且一点都不阻塞操作。

从上面这个简单的例子可以看出,仅仅是将计算逻辑转移到worker线程,就能够带来多大的变化。

不得不提的兼容性

web worker的兼容性非常好。 一个小缺点

web worker提出的时间非常早,这是它兼容性好的原因。但是也是问题所在,web worker原生的API设计得非常古老,是基于事件订阅的,不是特别好用。

引入项目当中的成本还是很高的。

//in main.js
first.onchange = function() {
  myWorker.postMessage([first.value,second.value]);
  console.log('Message posted to worker');
}

//in worker.js
onmessage = function(e) {
  console.log('Message received from main script');
  var workerResult = 'Result: ' + (e.data[0] * e.data[1]);
  console.log('Posting message back to main script');
  postMessage(workerResult);
}

一个顾虑,postMessage真的很慢么?

除了古老的API设计以外,很多开发者对于web worker还有个顾虑就是,听说postMessage很慢。

毕竟将数据作为参数传递给postMessage的时候,实际上会先将数据序列化为字符串,然后当worker接收到传递过来的数据(序列化后的字符串)时,还需要将字符串数据反序列化一次才能使用。

而worker再回返数据给主线程的时候,同样也要先经历一次序列化,然后字符串数据到了主线程以后,还需要再反序列化一次。

这中间涉及到四次数据的变换,从直觉上来看,开发者理所当然的会担忧性能层面的问题。

不过我们先把这个问题拆解一下。

数据变换的负担

首先,只有发生在主线程代码当中的数据转换,才会对主线程造成负担。这意味着只有两种情况下,主线程才会分配计算资源。

  • 传输数据(序列化)
  • 收到数据(反序列化)

发生在woker当中的数据转换是由worker线程负担的,对主线程是毫无影响的。

序列化和反序列化的性能问题

接下来是第二个问题,序列化和反序列化对性能的影响有多大?

Google的Surma做了一个关于postMessage性能的详细测试,这里我只放出他得出的结论。

如果想要详细了解相关情况,可以点击下面的链接阅读他的详细文章。

Is postMessage slow?

两个关键数字

序列化后的数据大小传输耗时典型场景
100kb100ms用户可感知到的最短时间(如果超过这个时间,用户会开始感觉到卡顿)
10kb16ms流畅动画(60 FPS)的一帧

Comlink——新瓶装旧酒

正如之前谈到的,webWorker实际上是非常有用的,只是它的API稍微古老了一点,它是基于事件订阅的,不是特别好用。稍微时髦一点的说法就是,给开发者带来的心智负担相对来说比较大。

上文当中提到的Surma设计了一套更加现代化的API,将postMessage的细节封装了起来,使得在向worker线程传递数据的时候,更加像是将变量的访问权共享给了其他线程。

下面我们简要看一下Comlink的官方给出的一个示例,一个简单的计数器。

// main.js
import * as Comlink from "https://unpkg.com/comlink?module";

const worker = new Worker("worker.js");
// This `state` variable actually lives in the worker!
const state = await Comlink.wrap(worker);
await state.inc();
console.log(await state.currentCount);
// worker.js
import * as Comlink from "https://unpkg.com/comlink?module";

const state = {
  currentCount: 0,

  inc() {
    this.currentCount++;
  }
}

Comlink.expose(state);

实际上看完这个计数器的例子,你就已经完全搞懂Comlink该如何使用了,就这么简单。

Comlink精妙的地方,我个人认为在于将数据传递的操作变成了一个异步的操作,这样我们就能很好的利用ES6所提供的async/await语法糖,将数据的传递与接收逻辑写得非常简洁优雅。开发者不需要再去考虑事件订阅所带来的各种复杂度。

和现有框架结合

Comlink虽然只是一个简单的工具库,但是将它引入到现有的页面逻辑里,其实是非常简单的。并且代码侵入性是非常小的,我们并不需要大规模改造现有的代码,就能享受到webWorker带来的便利性。

下面我将给出两个简单的示例,展示如何让Comlink和Vue以及Vuex和谐的运转在一起。(React和Redux其实也是相同的道理,这里我就不赘述了)。

Comlink + Vue

Dom部分非常简单,就是一个普通的计数器

	<div id="app">
      <div class="counter">
        Counter is {{ counter }}
        <button @click="addCounter">Add</button>
      </div>
    </div>

Vue部分,实际上创建Worker之后,使用wrap方法将这个Worker变为一个proxy对象(ES6特性),就能够访问woker当中暴露的对象的任何属性了。唯一需要留心的就是,这是个异步的操作。

// main 
var app = new Vue({
        el: '#app',
        data: {
            counter: 0,
            remoteState: {},
        },
        methods:{
            async initWorker() {
                const worker = new Worker("./worker.js");
                this.remoteState = Comlink.wrap(worker);
            },
            async addCounter() {
                const count = this.counter;
                this.counter = await this.remoteWorker.inc(count);
            }
        },
        mounted(){
            this.initWorker();
        }
    })
 
// worker.js
const obj = {
  inc(count) {
    return count+1;
  },
};

Comlink.expose(obj);

Comlink + Vue + Vuex

和Vuex的结合其实也很简单。从worker线程当中获取值是一个异步操作,只要我们将它封装成一个Action就可以了,非常自然。

    const worker = new Worker("vuexWorker.js");
    const counterState = Comlink.wrap(worker);

    const store = new Vuex.Store({
        state: {
            count: 0
        },
        mutations: {
            setCount: (state, value) => state.count = value,
        },
        actions:{
            async changeCount ({ commit }, value) {
                const count = await counterState.changeCounter(value)
                commit('setCount', count)
            },
        }
    })

    var app = new Vue({
        el: '#app',
        computed: {
            count () {
                return store.state.count
            }
        },
        methods:{
            async addCounter() {
                store.dispatch('changeCount', 1)
            },
            async minusCounter() {
                store.dispatch('changeCount', -1)
            }
        },
    })
    
    // worker.js
    const obj = {
      changeCounter(count, value) {
        return count + value;
      },
    };

    Comlink.expose(obj);

总结

将复杂的计算操作从主线程转移到其他线程是一个简单却又收益巨大的改进,我非常推荐你试一试。

我们可能并不需要Comlink

看到这里,肯定有一些读者心中还有疑虑,因为实际上Comlink还提供了其他能力,为什么我却没有提及呢?

因为我们实际上需要的只是将postMessage的数据传递包装成一个异步的操作,并且暴露出一个proxy对象供主线程便利的操作Worker线程的数据。

这意味着实际上我们并不一定需要使用Comlink。如果有兴趣的话,也可以自己用Promise和Proxy封装一个更加轻量级的版本。

比如Comlink也提供一个方法,能够将回调函数传给Worker线程,然后Worker线程计算完毕再后将结果传回来。

但是我个人并不建议去使用这种特性,因为这会让主线程的代码太过于复杂了,如果编写得不够好,很多地方会变得难以理解,就像是“黑魔法”一样。

两个建议

在此我给两个建议,约束对webWorker的使用,避免代码过于复杂化。

  1. 只将包含复杂计算的操作转移到worker线程当中

没有必要把所有的计算逻辑都从主线程剥离,那样worker.js就太重了。

最好将worker.js作为外挂插件,只容纳包含复杂计算的逻辑,这样对现有代码的侵入性和改造量也比较小。

  1. 只在worker.js当中执行计算逻辑

理想的worker.js应该只暴露一个全部是计算函数的对象。

尽量不要在worker线程当中再额外维持一份数据状态了,否则线程间的状态同步是大问题

更多精彩内容,尽请关注腾讯VTeam技术团队微信公众号和视频号

原作者:Sihan Hu
未经同意,禁止转载!