动态创建 Web Worker

hello~亲爱的看官老爷们大家好~最近入职新公司后,接手可视化平台的开发工作。经过收集意见后,发现使用者普遍吐槽渲染时太卡。查看接口后发现,后端直接返回查询数据库后的原始结果,需要页端傻乎乎地循环处理各种数据。当数据量大的时候,用户就只能傻看着浏览器卡死。

优化方案相信大家都有想法,在无法改后端数据体的情况下,最低成本的当然是引入 Web Worker,将处理逻辑丢给它就好。但由于各种原因,建一个 Web Worker 文件再求后端爸爸部署,暂时是不太可行的。那么,脑洞大开想一想,能不能页端动态创建一个呢?

于是,提出了如下问题:

  • 是否能动态创建 Web Worker
  • 如能创建的话,希望 API 调用是面向对象风格,而且支持 Pormise 调用。
  • 为节省资源起见,这个动态创建的 Web Worker 希望是可复用的。

带着这些问题,开始瞎折腾之旅吧!

动态创建 Web Worker

Web Worker 是什么,相信大家都了然于胸,一般的引用方法是请求一个 JS 文件,即:

const worker = new Wokrer(worker's url);

因而,问题可以转化为,我们如何将一个函数,转换成浏览器可识别并能重新解析为该函数的 url 呢?之前看 iframe和HTML5 blob实现JS,CSS,HTML直接当前页预览这篇文章时,接触到 URL.createObjectURL(),文中对此的描述是:“使用 URL.createObjectURL() 方法将 Blob 对象转换为 URL 对象并赋予我们创建的 iframe元素的src属性。”那么,是否能将其用于创建 Web Workerurl 呢?查阅 MDN 文档后发现了这么一句:

Note: 此特性在 Web Worker 中可用。

喜大普奔,计划通!该 API 接受 File 对象或者 Blob 对象作为参数,此处我们使用 Blob 对象作为参数并指定类型为 text/javascript,测试 demo 如下:

function demo() {
  setTimeout(() => {
    postMessage('success!');
  }, 1000)
}

const blob = new Blob([demo.toString() + ' demo()'], { type: 'text/javascript' });
const worker = new Worker(URL.createObjectURL(blob));

worker.addEventListener('message', function(res) {
  console.log(res);
})

打开 Chrome,在控制台中输入上面的代码,隔一秒之后便能打印出:MessageEvent {isTrusted: true, data: "success!", origin: "", lastEventId: "", source: null, …}。实验通过,可以确认 Web Worker 能被动态创建。

封装 API

既然能动态创建,作为程序员,肯定是不希望每次都手动创建,因而封装一下以便于使用。由于可能会多处使用,因而封装成一个“类”,有助于代码复用与节省内存。构造函数接受一个函数作为参数即可,这个“类”应有一个 send 方法让我们再想发送数据时调用。那么基础的架子应该是:

class DynamicWorker {
  constructor(cb) {
    //根据 cb 创建 Web Worker
  }

  send(data) {
    //传入参数后发送数据给 Web Worker,Web Worker处理后返回
  }
}

鉴于创造的 Web Worker 不是马上就处理数据,而是在 DynamicWorker 实例调用 send 方法时才开始干活,因而不能像上面的 demo 一样直接写死 postMessage,而应该多构建一个 onmessage 函数,因而 constructor 函数构建如下:

constructor(cb) {
    const _fn = `const _fn = ${cb.toString()};`;

    const _handle = ` onmessage =  function ({ data }) {
      postMessage(_fn(data));
    }`;

    const blob = new Blob([_fn + _handle], { type: 'text/javascript' });
    this.worker = new Worker(URL.createObjectURL(blob));
    //释放被引用的 url 对象,经过测试,就算释放了也能重复访问创建的 Web Worker的
    URL.revokeObjectURL(blob);
}

稍微需要解释下的是为何需要在 Web Worker 中创造一个 _fn 变量,这是由于生成 Blob 对象时,接受的参数是字符串数组,如果只是 cb.toString() 的话,是拿不到函数名的,那么在 Web Worker 中执行该函数更是无从谈起,因而赋值一个变量,在 message 事件触发后使用 postMessage 返回函数处理后的结果。

之后就是 send 方法了,该方法返回一个 Promise,当接收到 Web Worker 传回的数据后,改变 Promise 的状态。根据这个思路,有这样的设计:

send(data) {
    const worker = this.worker;
    let resolve = null;

    function _handleResult({ data }) {
      resolve(data);
    }

    worker.addEventListener('message', _handleResult);
    worker.postMessage(data);

    return new Promise((res) => {
      resolve = res;
    })
}

根据思路看代码,其实是很好理解的。复制 DynamicWorker 的代码丢去控制台,执行下面的代码:

const test = new DynamicWorker(function(data) {
  return data;
})
test.send(123).then(res => console.log(res));

就能愉快地看到浏览器打印出 123了。

复用与优化

上述的代码基本能达到我们的预期,但存在若干问题,如果短时间内调用多次 send 方法,那么后调用的方法会得出前面前面的结果。这是由于 worker.addEventListener('message', _handleResult) 区分不出每次调用,因而收到消息后一律执行 resolve,大家应该都知道 Promise 的状态决议后就无法再修改的,因而导致调用错误。此处可以通过添加一个标志位解决。另一个问题是潜藏的,为 worker 添加太多的事件监听器了,其实这大可不必,一个 DynamicWorker 实例一个事件监听器就足够了。结合这两点,修改一下对应的代码,首先是 constructor

...
const _handleResult = ({ data: { data, flag } }) => {
    const _res = this._map[flag];
    if (_res) {
        _res(data);
        this._map[flag] = null;
    }
}
this._map = {};
this.worker.addEventListener('message', _handleResult);
...

为其添上这几行代码即可,通过 _map 缓存不同的标志,而根据不同的标志可以区分出不同的 Promise,根据标志位对应的值调用即可。同理,send 方法也需要作出修改,为其添加标志的生成,完整的 DynamicWorker 代码如下:

class DynamicWorker {
  constructor(cb) {
    const _fn = `const _fn = ${cb.toString()};`;

    const _handle = ` onmessage =  function ({ data: { data, flag } }) {
      postMessage({
        data: _fn(data),
        flag
      });
    }`;

    const _handleResult = ({ data: { data, flag } }) => {
      const _res = this._map[flag];
      if (_res) {
        _res(data);
        this._map[flag] = null;
      }
    }

    const blob = new Blob([_fn + _handle], { type: 'text/javascript' });
    this.worker = new Worker(URL.createObjectURL(blob));
    this._map = {};
    this.worker.addEventListener('message', _handleResult);
    URL.revokeObjectURL(blob);
  }

  send(data) {
    const worker = this.worker;
    const flag = Math.random();
    worker.postMessage({
      data,
      flag,
    });

    return new Promise((res) => {
      this._map[flag] = res;
    })
  }
}

还是挺简单易懂的对不对?调用 API 是没有变化的,优化的只是内部的逻辑,有兴趣的同学可以复制进去控制台,随意写个需要运算很久的函数丢进去,尝试下浏览器是否不会卡住哦~

小结

这个 DynamicWorker 还只是未成品,缺少错误处理,异步处理等功能。而且应该有同学可能会怀疑这样的东西到底有何用途,兼容性上也有不少问题,当时我写完代码后也有这个疑问(因为下个月就会接入 node.js,可以自己部署文件)。思考后认为,对比正常使用 Web Worker,动态创建最大的优点是在于动态,不依赖于服务器部署文件,相对灵活易用。对于某些需要依赖运算大量数据,但又无法控制服务器的场景来说,不失为一个可行的解决思路。

最后,有个问题想请教一下,在需要处理大量数据的场景下,node.js 也不是一个好的选择,只是因为页端性能差与只是内部系统的关系,将处理逻辑上移到 node.js,那么正确的处理方式或者架构是怎样的呢?还望有相关经验的同学不吝赐教。

感谢各位看官大人看到这里,知易行难,希望本文对你有所帮助~谢谢!