likes
comments
collection
share

开源 | Web Workers这样封装才用得爽

作者站长头像
站长
· 阅读数 22

最近在公司多次负责到图像像素级处理的业务,为了保证用户页面体验,大量的计算和离屏渲染会通过Web Workers实现,但Web Workers原生API用起来麻麻哋(不怎么样),因此我给它做了一次“大改造”。

温馨提示

文章提供了一种Web Workers API的封装方案,如果你从未用过或者不知道使用场景可以先看看MDN: Web Workers API,如果觉得用不上,那这篇文章可能暂时对你没啥用,可以掉头去看其他文章啦。

Web Workers原生API 为何让人感到不好用

我们既然选择封装,那必然有让我们选择封装的原因,使用Web Workers的原生API可能会让我们经历以下痛苦:

(头号痛苦)worker 文件引用

/** main.js */
const worker = new Worker('worker.js');

使用worker我们首先需要提供worker脚本文件的链接,而这个链接却如拦路虎一般让很多第一次用worker的伙伴因为这个直接选择放弃。

因为打包的原因导致worker.js 404 (Not Found)相信大家或多或少都会遇到。

很多时候我们想要的就是直接在代码上下文中直接加入worker线程的代码,而不是另外建一个worker.js文件。

当然后面我们会接触到嵌入式worker,不过用起来还是麻烦了些。

(二号痛苦)糟糕的数据传递体验

主线程和worker线程的数据传递通过postMessageonmessage两个方法相互传递信息。

按道理就两个API用起来应该问题不大呀,但实际使用中却会让人感到混乱,特别当我们使用Blob([script脚本字符串])嵌入式将worker脚本和主线程代码写在同个文件的时候,我们下手写代码时就需要耗费精力去确保它们是属于哪个线程的。

其次,数据作为参数传递也并不方便。postMessage(message,options)只有两个参数,如果我们需要传递不止一个数据时就需要整合成对象传递,同时取出数据时也需要从event.data中提取才能拿到数据。

worker.onmessage = function(event){
    const { initial, numbers, ... } = event.data;
};
worker.postMessage({
    initial: 10,
    numbers: [0, 1, 2, 4],
    ...
});

(三号痛苦)代码逻辑上下文惨遭分割

...上文
/** main.js */
const worker = new Worker('worker.js');
worker.onmessage = (event) => {
    const result = ...;
    ...下文
};

主线程通过onmessage监听worker线程传递回来的信息,但这种监听回调的写法是免不了将自己的代码逻辑分割开来的,如果不想分割,只能将worker相关的代码包在一个Promise里面,如下:

/** main.js */
...上文
const result = await new Promise((resolve) => {
  const worker = new Worker('worker.js');
  worker.onmessage = (event) => {
    resolve(result);
  };
})
...下文

你还觉得有哪些用起来不太好的地方可以在评论区提出。

寻找替代品

为解决上面的问题,首先当然是在GITHUB上找一个好的库作为替代品,比如greenlet

import greenlet from 'greenlet'

const sum = greenlet(async (a, b) => a + b);

console.log(await sum(1, 2)) // 3

它很好地解决上面的三个问题,使用时与普通异步函数无异,能满足业务中大部分场景的使用,不过用多几次后发现有些场景下它也无能为力:

① 没办法在worker线程中向主线程发送数据,而这在需要中间处理数据的场景下就等于缴械投降,比如:获取数据的中间处理进度。

② 没办法手动终止worker线程,greenlet没有抛出worker实例,所以我们也做不到手动终止线程的操作。另外源码中也没有控制线程的关闭,所以一旦创建了worker线程,便不会终止。

③ 每个worker线程中用到的主线程数据都需要设置为参数传入,如果遇到下面这种情况会很痛苦:

import greenlet from 'greenlet'

const a, b, c, d, e, f, g;

const sum = greenlet((a, b, c, d, e, f, g) => {...});

console.log(await sum(a, b, c, d, e, f, g))

当然这里也是可以整合成一个对象传入,不过这里最让人感到崩溃的是有时候项目eslint设置上下文不能出现重名变量时,这里想变量名会想到崩溃。

④ 无法传递函数进入线程使用,如:

import greenlet from 'greenlet'

const sum = (a, b) => a + b;

const worker = greenlet((sum, a, b) => {
    sum(a, b)
});

await worker(sum, 1, 2); // Error: (a, b) => a + b could not be cloned.

也就是因为上面的几点原因,再加上自身业务需要,我学习了greenlet的源码并封装了一个新的库:assist-worker,见名知意,其作用就是辅助你使用Web Workers

先看看封装后的使用效果

假设以下这段逻辑需要使用web workers

① 创建一个worker

② 传递初始值数值列表进worker

③ 算出数值和并发送回主线程

④ 遍历数值时每次遇到数值0时向主线程发送其所在索引

原生API实现的代码:

/** main.js */
const numbers = [0, 1, 0, 2];
// ① 创建一个worker
const worker = new Worker('worker.js');
worker.onmessage = function(event){
  const { type, value } = event.data;
  if (type === 'sum') console.log(value)
  if (type === 'zero-index') console.log(value)
};
// ② 传递初始值和数值列表进worker
worker.postMessage({ initial: 10, numbers });

/** worker.js */
onmessage = function(event){
  const { initial, numbers } = event.data;
  let sum = initial;
  for (let i = 0, len = numbers.length; i < len; i++) {
    const num = numbers[i];
    sum += num;
    // ④ 遍历列表时每次遇到数值0时向主线程发送其所在索引
    if (num === 0) postMessage({ type: 'zero-index', value: i });
  }
  // ③ 向主线程发送数值和
  postMessage({ type: 'sum', value: sum });
};

使用封装函数实现的代码(注释较多,需耐心看):

import assistWorker from "assist-worker";

/** main.js */
const numbers = [0, 1, 0, 2];
// ① 使用封装方法创建worker,无需引用外部worker文件
const worker = assistWorker
  // 接收并处理worker线程向主线程发送的数据
  .onMessage((message) => console.log(message))
  // 收集worker线程中需要用到的主线程静态数据
  .collect({ numbers })
  // 编写worker内工作流程代码,最后一个参数提供worker内部可用方法,前面的参数都是自定义的动态入参
  .create((initial, { postMessage, close }) => {
    let sum = initial;
    for (let i = 0, len = numbers.length; i < len; i++) {
      const num = numbers[i];
      sum += num;
      // ④ 遍历列表时每次遇到数值0时向主线程发送其所在索引
      if (num === 0) postMessage({ type: 'zero-index', value: i });
    }
    // ③ 向主线程发送数值和
    return { type: 'sum', value: sum };
  });

// ② 传递动态初始值给worker,并执行worker工作流程
console.log(await worker.run(/* initial */ 10)); // { type: 'sum', value: 13 }
console.log(await worker.run(/* initial */ 20)); // { type: 'sum', value: 23 }

// 终止线程
worker.terminate();

看完后有没有一点小期待呢?

下面将先介绍assist-worker的API和基础使用,你可以在跟着我一起封装前自己先想想怎么进行封装以满足我所设定的API和使用规则,这样相信你能从这篇文章中得到更多(如有想法,可以在评论区中提出,让我看看谁与我心灵相通哈哈)。

assist-worker 的安装和使用

安装

npm i -S assist-worker

API

create(job)

创建和初始化worker线程。

@param {function(...dynamicParameters, workerMethods):any} job 需要放入worker线程执行的工作流程函数

@returns {object} 用于控制工作流程执行和线程关闭的对象

其中job函数的参数介绍:

@param {any} dynamicParameters 工作流程中需要用到的动态参数列表

@param {object} workerMethods 包含worker内部控制方法的对象

示例

import assistWorker from "assist-worker";

// 需要放入worker线程执行的工作流程
const job = (param, workerMethods) => {
  console.log(param); // PARAM TO WORKER
};

// 创建worker线程
const worker = assistWorker.create(job);

// 执行工作
await worker.run('PARAM TO WORKER');

// 关闭worker线程
await worker.terminate();

onMessage(callback)

接收并处理worker线程向主线程发送的信息。

@param {function(message):void} callback worker线程向主线程发送信息的监听回调

@returns {object} Web Workers API封装对象

示例

import assistWorker from "assist-worker";

const job = (workerMethods) => {
  // 在包含worker内部控制方法的对象中拿出发送信息的方法
  const { postMessage } = workerMethods;
  // 向主线程发送信息
  postMessage('MESSAGE TO MAIN')
};

const worker = assistWorker
  // 接收并处理信息
  .onMessage((message) => {
      console.log(message); // MESSAGE TO MAIN
  })
  .create(job);

await worker.run();

collect(data)

收集worker线程内需要用到的主线程数据,数据的字段名需和线程中用到的名称保持一致。

@param {object} sourceData worker线程内用到的主线程数据

@returns {object} Web Workers API封装对象

示例

import assistWorker from "assist-worker";

const numbers = [1, 2, 3, 4, 5];
const sum = (arr) => arr.reduce((total, i) => total + i, 0);

const worker = assistWorker
  // 收集用到两个主线程数据:numbers 和 sum
  .collect({ numbers, sum })
  // 线程中使用只要保持变量名一致即可,无需再作为参数传递
  .create(() => {
    console.log(sum(numbers)); // 15
  });

await worker.run();

❗警告

这里收集的数据只支持web workers本身限制的可序列化的数据

不过这里有个特例,这里还支持函数,不过需要保证函数体内引用到的主线程变量也被收集,比如:

...
const id = 1;

const test = () => {
  console.log(id);
});

assistWorker.collect({ test })     // ✖️ ReferenceError: id is not defined
assistWorker.collect({ id, test }) // ✔️
...

正式开启封装之旅

assist-worker的核心API就是上面三个,你有想到我是怎样实现的吗?现在就跟着我一同实现封装吧!

第一步、如何实现直接将函数放入worker线程中执行

我们知道Web Workers是有一种嵌入式的写法的:

// 这个函数里放着需要在worker线程中执行的工作流程代码
const job = () => { ... };

const assistWorker = (func) => {
  // 如果我将函数先转为字符串,并且组合成立即执行函数(func)(),那当worker线程的脚本跑起就能立即执行
  const script = `(${func})()`;

  // 嵌入式的写法
  const workerScriptBlob = new Blob([script], {type: "text/javascript"});

  const worker = new Worker(window.URL.createObjectURL(workerScriptBlob));
}

assistWorker(job);

但这种情况只符合job函数不需要传参的情况,如果我们需要给函数传参数,那就不能让函数立即执行了。

我们需要在worker线程中提前定义job函数体;然后在接收参数后通过postMessage向worker线程发送参数,让worker执行线程中的job函数:

const job = (param1, param2) => { ... };

const assistWorker = (func) => {
  const script = `
    // 这里不再组合成立即执行的脚本,而是在脚本中定义一个$job的函数体
    $job=${job};
    // 然后在worker中注册onmessage方法等待主线程的发号施令
    onmessage=(e)=>{
      // 接收到主线程发送的参数后立即执行工作函数
      const arguments = e.data;
      $job.apply($job, arguments);
    }
  `;
  const workerScriptBlob = new Blob([script], {type: "text/javascript"});
  const worker = new Worker(window.URL.createObjectURL(workerScriptBlob));
  
  // 获取参数后则立即向worker线程发送
  // 这里的参数需要变成数组形式传递是因为postMessage只有第一个参数才是用来传数据的
  return (...args) => worker.postMessage(args);
}

assistWorker(job)(param1, param2);

第二步、如何实现主线程获取到worker线程工作流程执行结束后的返回值

我们明白worker线程除了postMessage也没有其他方式向主线程发送数据了,所以执行后一定是以消息形式发送回主线程,而主线程也是通过onMessage去接收执行结果。

那怎么在拿到结果后抛出给外部呢?当然是使用Promise

const job = (param1, param2) => result;

const assistWorker = (func) => {
  const script = `
    $job=${job};
    onmessage=(e)=>{
      const arguments = e.data;
      const result = $job.apply($job, arguments);
      // ② worker线程工作流程执行完通过postMessage将结果发送回主线程
      postMessage(result);
    }
  `;
  const workerScriptBlob = new Blob([script], {type: "text/javascript"});
  const worker = new Worker(window.URL.createObjectURL(workerScriptBlob));
  
  // 接收凭证
  let done;
  
  worker.onmessage((event) => {
    // ③ 主线程拿到返回的执行结果
    const result = event.data;
    // ④ 通过接收凭证向外部抛出
    done(result);
  })

  return (...args) => new Promise(resolve => {
    // ① 设置接收凭证,等待接收到执行结果后抛出外部
    done = resolve;

    worker.postMessage(args);
  });
}

console.log(await assistWorker(job)(param1, param2)); // result

第三步、如何实现不用传参数就可以访问到主线程的数据

首先我们要知道worker线程和主线程之间的数据传递基本上都是通过拷贝数据实现的,如果不传参数,我们在worker线程中用到的主线程变量都是未声明的,这会引起错误。

那我就在想是否能通过解析脚本中的未声明的变量然后在主线程中寻找并将变量声明和定义加入脚本字符串中。

因为“解析”这个想法,我用上了acorn这个JS解析器,找是能找到未声明的变量,但是后面仔细一想放弃了,因为这个操作得不偿失:

① 库的打包代码行数从100行飙升5000行

② 解析本身影响速度

③ 无法区分变量是内置还是用户声明的,就像console

④ 最主要的问题,就算拿到,我也没办法根据一个变量名提取到外部数据

所以选择退一步,还是让使用者手动标记脚本中需要使用到的主线程变量,也就是collectAPI的作用。

/** assist-worker.js */
let sourceData = {};

const create = (func) => {
  const script = `
    // ② 将收集到的变量声明和定义加入脚本字符串,后面函数执行的时候便不会出现not defined的错误了
    ${Object.entries(sourceData).reduce(
      (variablesStr, [key, value]) => {
        // 这里必须传入序列化数据后在脚本内反序列化,不然传的值只是调用了toString()
        // 如[1, 2, 3, 4]=>1, 2, 3, 4,是无法在脚本内正确解析的
        return variablesStr + `${key}=JSON.parse("${JSON.stringify(value)}");`;
      },
      ""
    )}
 
    $job=${job};
    
    onmessage=(e)=>{
      const arguments = e.data;
      const result = $job.apply($job, arguments);
      postMessage(result);
    }
  `;
  const workerScriptBlob = new Blob([script], {type: "text/javascript"});
  const worker = new Worker(window.URL.createObjectURL(workerScriptBlob));

  let done;
  
  worker.onmessage((event) => {
    const result = event.data;
    done(result);
  })

  return (...args) => new Promise(resolve => {
    done = resolve;

    worker.postMessage(args);
  });
};

export default {
  // ① 让使用者收集worker线程内用到的主线程数据(变量)
  collect: (data) => {
    sourceData = data;
    return create;
  },
  create,
}

/** example.js */
import assistWorker from 'assist-worker.js';

const name = 'result';

// 用到了主线程的变量name
const job = () => name;

const worker = assistWorker.collect({ name }).create(job);

console.log(await worker()); // result

第四步、如何实现collect对函数体的收集

相信如果你看懂了第三步,这一步也是很容易就能想到的,我们将收集到的函数通过toString()的方式组合函数名声明并定义在脚本字符串中,因为函数无法序列化所以和变量是完全不一样的处理方式:

...
${Object.entries(sourceData).reduce(
  (variablesStr, [key, value]) => {
    // 如果是函数直接toString即可,否则需要序列化+反序列化
    let variable = typeof value === 'function'
      ? `${key}=${value};`
      : `${key}=JSON.parse("${JSON.stringify(value)}");`;
    return variablesStr + variable;
  },
  ""
)}
...

第五步、如何实现最后一个参数是包含worker内部控制方法的对象

到了这一步,其实纠结了很久是放在第一个参数的位置还是最后一个参数的位置,因为我们都是拿...rest表示后面的未知参数,所以如果放在最后一个参数可能用起来有点反常识(而且,ts不好写)。

但最后还是选择放在最后一个参数,因为用起来方便且不易出错!

很多情况根本用不到这个对象,如果放在第一位,一旦createAPI存在自定义入参就首先要求加上这个对象参数,但是如果不知道这个参数的存在将不会达到使用者的预期,另外这个对象很容易被忽略。

这个的实现也十分容易:

/** assist-worker.js */
let sourceData = {};

const create = (func) => {
  const script = `
    ${Object.entries(sourceData).reduce(
      (variablesStr, [key, value]) => {
        let variable = typeof value === 'function'
          ? `${key}=${value};`
          : `${key}=JSON.parse("${JSON.stringify(value)}");`;
        return variablesStr + variable;
      },
      ""
    )}
 
    $job=${job};
    
    onmessage=(e)=>{
      const arguments = e.data;
      // 直接在传入参数中加入这个对象
      const result = $job.apply($job, arguments.concat([{
        // 这里要注意下面执行结果也通过postMessage发送,为了区分是结果还是过程信息,需要包多一层
        postMessage: (message) => self.postMessage({ type: 'message', message }),
        close: self.close,
      }]));
      postMessage({ type: 'result', message });  
    }
  `;
  const workerScriptBlob = new Blob([script], {type: "text/javascript"});
  const worker = new Worker(window.URL.createObjectURL(workerScriptBlob));

  let done;
  let onMessage;
  
  // 主线程获取到信息后也需要根据信息类型对应抛出
  worker.onmessage((event) => {
    const { type, message } = event.data;
    if (type === 'result') done(result);
    if (type === 'message') onMessage(result);
  })

  return (...args) => new Promise(resolve => {
    done = resolve;

    worker.postMessage(args);
  });
};

const assist = {
  // 添加一个新的API onMessage,记录接收到信息后的监听函数
  onMessage: (handler) => {
    onMessage = handler;
    return assist;
  }
  collect: (data) => {
    sourceData = data;
    return assist;
  },
  create,
}

export default assist;

/** example.js */
import assistWorker from 'assist-worker.js';

const job = ({ postMessage }) => {
  postMessage('MESSAGE TO MAIN');
};

const worker = assistWorker
  .onMessage((message) => {
    console.log(message); // MESSAGE TO MAIN
  })
  .create(job);

await worker();

最后

在实现和讲解完上面五个步骤后,assist-worker这个库的核心API就基本介绍完啦,是不是看完其实觉得还挺简单的哈哈。

这个库的实现关键就是要明确下面几个问题:

① 如何创建worker线程

② 线程之间如何传递信息

③ 线程之间传递什么类型的信息

所以如果你看完后也能自己动手从零写一个出来,相信你对Web Workers的使用就已经得心应手啦~

最后,希望你看完这篇文章有所得,也希望assist-worker这个库在你往后的工作中能给你提供便利。

感谢你的阅读。

转载自:https://juejin.cn/post/7215226343712604221
评论
请登录