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

谈谈Web Worker

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

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

JavaScript的事件队列模型,做者是Lydia Hallieweb

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

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

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

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

多线程的力量

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

这里咱们假设页面存在一个很复杂的计算操做,须要耗费好几秒才能完成。因为JS引擎是单线程的,若是在主线程里执行这个计算逻辑,咱们将看到页面将在好几秒内是没法响应的。从用户视角来看,就是整个页面“卡住了”。毫无疑问,这是很是使人挫败的体验。 app

而后咱们看一下另外一个作法,把这个计算逻辑放到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?

两个关键数字

序列化后的数据大小 传输耗时 典型场景
100kb 100ms 用户可感知到的最短期(若是超过这个时间,用户会开始感受到卡顿)
10kb 16ms 流畅动画(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

未经赞成,禁止转载!

相关文章
相关标签/搜索