资讯专栏INFORMATION COLUMN

尝鲜 workerize 源码

muzhuyu / 730人阅读

摘要:同时在初始化的过程中,会将主线程加载的模块中的每个方法,都绑定一个快捷方法,其方法名与模块中的函数声明保持一致,内部则使用来完成调用逻辑。

写在前面

最近正好在看web worker相关的东西,今天无意中就看到了github一周最热项目的推送中,有这么一个项目workerize,repo里的文档的描述如下:

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

关于README很简单,包含一个类似hello world的例子就没其他什么了。但是从例子本身可以看出这个库要解决的问题,是想通过模块化的方式编写运行在web worker中的脚本,因为通常情况下,web worker每加载一个脚本文件是需要通过一个符合同源策略的URL的,这样会对服务端发送一个额外的请求。同时对于web worker本身加载的js文件的执行环境,与主线程是隔离的(这也是它在进行复杂运算时不会阻塞主线程的原因),与主线程的通讯靠postMessageapi和onmessage回调事件来通讯,这样我们在编写一些通信代码时,需要同时在两个不同的环境中分别编写发送消息和接受消息的逻辑,比较繁琐,同时这些代码也不能以模块化的形式存在。

如果存在一种方式,我们可以以模块化的方式来编写代码,注入web worker,之后还能通过类似Promsie机制来处理等异步,那便是极好的。

先来看看例子:

import workerize from "workerize"

let worker1 = workerize(`
    export function add(a, b) {
        let start = Date.now();
        while (Date.now()-start < 500);
        return a + b;
  }

  export default function minus(a, b){
    let start = Date.now();
        while (Date.now()-start < 500);
    return a - b
  }
`)

let worker2 = workerize(function (m) {
  m.add = function (a, b) {
    let start = Date.now()
    while (Date.now() - start < 500);
    return a + b
  }
});

(async () => {
  console.log("1 + 2 = ", await worker1.add(1, 2))
  console.log("3 + 9 = ", await worker2.call("add", [3, 9]))
})()

worker1和worker2是两种不同的使用方式,一种是以字符串的形式声明模块,一种以函数的形式声明模块。但是无论哪种,最后的结果都是一样的,我们可以通过worker实例显示的调用我们想要调用的方法,每个方法的调用结果均是一个Promise,因此它还可以完美的适配async/await语法。

源码

那么问题来了,这种模块的加载机制和调用方式是怎样实现的呢?我在运行demo代码的时候心中也默默想到,我去,看了好几天的web worker原来还能这么玩,所以一定要研究研究它的源码和它的实现原理。

打开源代码才发现其实并没有多少代码,官文文档也通过一句话强调了这一点:

Just 900 bytes of gzipped ES3

所以对其中主要的两点进行简单说明:

如何实现按内容模块化加载脚本而不是通过URL

如何通过Promise来代理主线程与worker线程的通讯过程

使用Blob动态生成加载脚本资源
let blob = new Blob([code], {
      type: "application/javascript"
    }),
    url = URL.createObjectURL(blob),
    worker = new Worker(url)

这其实不是什么新鲜的东西,就是将代码的内容转化为Blob对象,之后再通过URL.createObjectURL将Blob对象转化为URL的形式,之后再用worker加载它,仅此而已。但是这里的问题是,这个code是哪里从哪里来的呢?

将加载代码模块化

在加载代码之前,还有重要的一步,就是需要将加载的代码转变为模块,模板本身只对外暴露统一的接口,这样不论对于主线程还是worker线程,就有了统一的约束条件。源码中作者把上一步中的code转化为了类似commonjs的形式,主要涉及的代码有:

let exportsObjName = `__EXPORTS_${Math.random().toString().substring(2)}__`
  if (typeof code === "function") code = `(${toCode(code)})(${exportsObjName})`
  code = toCjs(code, exportsObjName, exports)
  code += `
(${toCode(setup)})(self, ${exportsObjName}, {})`

toCjs方法

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

关于toCjs方法,如果你的正则知识比较扎实的话,可以发现,它做了一件事,就是将字符串类型的code中的所有导出方法的声明,使用commonjs的导出语法替换掉(中间会涉及一些具体的语法规则),如下:

// 如果 exportsObjName 使用默认值 exports, ...代表省略代码
export function foo(){ ... } => exports.foo = function foo(){ ... }
export default ... => exports.default = ...

如果code是函数类型,则首先使用toCode函数将code转化为string类型,之后再将它转化为IIFE的形式,如下

// 如果 exportsObjName 使用默认值 exports, ...代表省略代码
// 传入的code是如下形式:
function( m ){ 
  ... 
}
// 转化为
(function( m ){
  ...
})(exports)

这里的exportsObjName代表模块的名字,默认值是exports(联想commonjs),不过这里会在一开始就随机生成一个模块名字,生成代码如下:

let exportsObjName = `__EXPORTS_${Math.random().toString().substring(2)}__`

这样只有我们按照约定的语法来编写web worker加载的代码,它便会加载了一个符合同样约定的commonjs模块。

使用 Promise 来做异步代理

经过上面两步,web worker加载到了模块化的代码,但是worker线程与主线程进行通讯则是仍然需要通过postMessage方法和onmessage回调事件来进行,如果无法优雅地处理这里的异步逻辑,那么之前所做的工作其实意义并不大。

workerize针对这里的异步逻辑,设计了一个简单的rpc协议(文档中将这个称作a tiny, purpose-built RPC),先来看一下源码中的setup函数:

function setup (ctx, rpcMethods, callbacks) {
    ctx.addEventListener("message", ({ data }) => {
      // 只捕获满足条件的数据对象
      if (data.type === "RPC") {
        // 获取数据对象中的 id 属性
        let id = data.id
        if (id != null) {
          // 如果数据对象中存在非空 method 属性,则证明是主线程发送的消息
          if (data.method) {
            // 获取所要调用的方法实例
            let method = rpcMethods[data.method]
            if (method == null) {
              // 如果所调用的方法实例不存在,则发送方法不存在的消息
              ctx.postMessage({ type: "RPC", id, error: "NO_SUCH_METHOD" })
            } else {
              // 如果方法存在,则调用它,并将调用结果按不同的类型发送
              Promise.resolve()
                .then(() => method.apply(null, data.params))
                .then(result => { ctx.postMessage({ type: "RPC", id, result }) })
                .catch(error => { ctx.postMessage({ type: "RPC", id, error }) })
            }
          // 如果 method 属性为空,则证明是 worker 线程发送的消息
          } else {
            // 获取每个消息所对应的处于pending状态的Promise实例
            let callback = callbacks[id]
            if (callback == null) throw Error(`Unknown callback ${id}`)
            delete callbacks[id]

            // 按消息的类型将Promise转化为resolve状态或reject状态。
            if (data.error) callback.reject(Error(data.error))
            else callback.resolve(data.result)
          }
        }
      }
    })
  }

根据注释我们可以知道,这里的setup函数包含了rpc协议的解析规则,因此主线程和worker线程对会调用该方法来注册安装这个rpc协议,具体的代码如下:

主线程: setup(worker, worker.rpcMethods, callbacks)

worker线程: code += ` (${toCode(setup)})(self, ${exportsObjName}, {})

这两处代码都是在各自的作用域中,将rpc协议与当前加载的模块绑定起来,只不过主进程所传callbacks是有意义的,而worker则使用一个空对象代替。

注册调用逻辑

在拥有了rpc协议的基础上,只需要实现调用逻辑即可,代码如下:

worker.call = (method, params) => new Promise((resolve, reject) => {
    let id = `rpc${++counter}`
    callbacks[id] = { method, resolve, reject }
    worker.postMessage({ type: "RPC", id, method, params })
})

这个call方法,每次会将一次方法的调用,转化为一个pending状态的Promise实例,并存在callbacks变量中,同时向worker线程发送一个格式为调用方法数据格式的消息。

for (let i in exports) {
   if (exports.hasOwnProperty(i) && !(i in worker)) {
     worker[i] = (...args) => worker.call(i, args)
   }
}

同时在初始化的过程中,会将主线程加载的模块中的每个方法,都绑定一个快捷方法,其方法名与模块中的函数声明保持一致,内部则使用worker.call来完成调用逻辑。

最后

关于这个库本身,还存在一些可以探讨的问题,比如:

是否支持依赖解析机制

如果引入外部依赖模块

针对消息是否需要按队列进行处理

关于前两点,似乎作者有一个相同的项目,叫做workerize-loader,可以解决,关于第三点,作者在代码中增加了todo,表示实现消息队列机制可能没有必要,因为当前的通讯基于postMessage,本身的结果已经是有序状态的了。

关于源码本身的分析大概就这样了,希望可以抛砖引玉,如有错误,还望指正。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/92514.html

相关文章

  • php7尝鲜

    摘要:从容器里拷贝文件到宿主机这个根据你自己生成的容器来宿主机映射的配置文件夹位置修改宿主机上的配置文件文件注意点表示程序在前台运行。里面是我创建的一个文件。 通过yum源安装php7 PHP 7.0.0 已经推出了几天,带来了新版本的Zend引擎,不仅如此,还有许多新特性和改进,比如: 性能提升:PHP 7速度是PHP 5.6的两倍 内存的使用显著降低 抽象语法树 支持64位 许多重大的...

    Jackwoo 评论0 收藏0
  • php7尝鲜

    摘要:从容器里拷贝文件到宿主机这个根据你自己生成的容器来宿主机映射的配置文件夹位置修改宿主机上的配置文件文件注意点表示程序在前台运行。里面是我创建的一个文件。 通过yum源安装php7 PHP 7.0.0 已经推出了几天,带来了新版本的Zend引擎,不仅如此,还有许多新特性和改进,比如: 性能提升:PHP 7速度是PHP 5.6的两倍 内存的使用显著降低 抽象语法树 支持64位 许多重大的...

    xinhaip 评论0 收藏0
  • php7尝鲜

    摘要:从容器里拷贝文件到宿主机这个根据你自己生成的容器来宿主机映射的配置文件夹位置修改宿主机上的配置文件文件注意点表示程序在前台运行。里面是我创建的一个文件。 通过yum源安装php7 PHP 7.0.0 已经推出了几天,带来了新版本的Zend引擎,不仅如此,还有许多新特性和改进,比如: 性能提升:PHP 7速度是PHP 5.6的两倍 内存的使用显著降低 抽象语法树 支持64位 许多重大的...

    陈江龙 评论0 收藏0
  • php7尝鲜

    摘要:从容器里拷贝文件到宿主机这个根据你自己生成的容器来宿主机映射的配置文件夹位置修改宿主机上的配置文件文件注意点表示程序在前台运行。里面是我创建的一个文件。 通过yum源安装php7 PHP 7.0.0 已经推出了几天,带来了新版本的Zend引擎,不仅如此,还有许多新特性和改进,比如: 性能提升:PHP 7速度是PHP 5.6的两倍 内存的使用显著降低 抽象语法树 支持64位 许多重大的...

    suemi 评论0 收藏0
  • 前端每周清单第 49 期:Webpack 4 Beta 尝鲜,React Windowing 与 s

    摘要:尽管等待了多年,但是最终还是发布了正式版本与上一个版本相比未有重大变化,主要着眼于部分错误修复与提升。能够将异步函数移入独立线程中,可以看做函数的单函数简化版。不过需要注意的是,仅支持纯函数,其会在独立的作用域中运行这些函数。 showImg(https://segmentfault.com/img/remote/1460000013038757); 前端每周清单专注前端领域内容,以对...

    muzhuyu 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<