Web 多线程开发利器 Comlink 的剖析与思考

发表于 2年以前  | 总阅读数:239 次

前言

JavaScript 属于单线程语言,所有任务都跑在主线程上,若主线程阻塞,后续任务将无法执行。既然是单线程,那为何我们在使用过程中主观感知却是“多线程”?

事件循环

主要由于 JavaScript 提供了 事件循环 机制,我们在发起异步请求或定时等操作后,处理完地回调会放入任务队列,在执行栈空时,处理任务队列中的回调,因此不会阻塞主线程,参考下图:

Node、Deno 环境同样使用事件循环机制进行处理,不过在模型上存在差异。关于事件循环的具体细节本文不会细说,但核心思想在于:任务队列 + 异步回调

事实上,即使存在事件循环机制,某些任务依然会极大地占用主线程,例如近无限循环,会直接导致 CPU 占用 100%,此时后续的所有任务被阻塞,页面卡住,甚至失去响应,这在用户体验上是非常不友好的。但往往这样的任务不可避免,通常我们将其分为两类:

  • CPU 密集型:完成计算所需的时间主要受限于 CPU 的计算
  • I/O 密集型:完成计算所需的时间主要受限于输入/输出操作

此时,多线程往往能起到关键性的作用,目前绝大多数现代计算机都拥有多核心,多线程处理能力,如果能物尽其用,必然是极好的。

查看逻辑处理器内核数量

navigator.hardwareConcurrency // 16

有了上述的先决条件,我们就可以调用多线程处理这些阻塞型任务了。

Web Worker

现代主流浏览器,都已经支持了 Web Worker API,通过该接口,可以开启多线程。使用过程中需要注意以下几点:

  • DOM 限制、BOM 部分限制
  • 同源限制
  • 通过消息监听机制通信
  • 脚本文件必须通过网络访问
  • 国际惯例,资源用完后要及时释放

一个非常简单的例子

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
</head>
<body>
  <p id="first"></p>
  <p id="second"></p>
  <p id="third"></p>
  <script>
    // 第一个文本
    document.querySelector('#first').innerHTML = 'First'
    // 第二个文本
    const second = document.querySelector('#second')
    if (window.Worker) {
      second.innerHTML = '...'
      const worker = new Worker('worker.js')
      worker.postMessage({
        uuid: new Date().getTime()
      });
      worker.onmessage = function(e) {
        second.innerHTML = e.data
      }
      worker.onerror = function(e) {
        second.innerHTML = 'Error occured!'
      }
    } else {
      second.innerHTML = 'Not supprot Web Worker!'
    }
    // 第三个文本
    document.querySelector('#third').innerHTML = 'Third'
  </script>
</body>
</html>

worker.js

onmessage = function(e) {
  const time = Math.random() * 3000
  // 模拟复杂计算
  setTimeout(() => {
    postMessage(`Second ${time.toFixed(0)} ms, ID is ${e.data.uuid}`)
  }, time)
}

查看代码 (https://codepen.io/konp/pen/VwbRexR)(注:本示例及后续示例代码中会采用 Blob 转 URL 的方式加载脚本)

可以看出,主线程主要负责展示 UI,工作线程负责计算需要展示的值,那么问题来了:

  1. 那这个计算展示值的步骤是否可以后端返回?
  2. 如果要在独立的线程中进行多种操作要如何做到?

对于问题 1,答案是肯定的,前端开启多线程只是为了扩展现代浏览器的计算能力,通常这一部分未挖掘的潜力是很大的,可以用来做很多事情,比如生成文件、复杂计算等。如果不这样做,很显然可以通过异步请求方式达到。

对于问题 2,如果在独立工作线程中声明多个 onmessage 函数,根据变量提升规则,只会有最后一个生效。那么想要执行不同的操作,除了新开一个工作线程外(失去意义),就只能在这个监听函数中通过 switchif 进行返回,这样违反了单一职责原则。


// 若要在线程脚本中执行多个操作,通常需要这么写
onmessage = function(e) {
 if (condition1) // do something
 if (condition2) // do something
 if (condition3) // do something
 ...
}

除了工作线程外,主线程也存在这样的问题,由于 Message 事件只能绑定一次,想要执行复杂的条件判断会让代码显得异常臃肿难看,那么 如何优雅的使用多线程开发 呢?

对于刚才提到的问题一,我们可以通过异步接口的形式返回想要的结果,得益于 ES6 中的 Promise 对象,通常我们对于异步的写法如下:

fetchSometing().then(res => {
    // do something
})

再比较 Web Worker 的写法:

worker.postMessage();
worker.onmessage = function(e) {
    // do something
}

设想,我们是否可以将多线程写法进一步优化,将 postMessageonmessage 封装成一个函数,该函数返回一个 Promise,通过调用,进行“异步”操作?

这当然是可以的。那么,这个函数必然在工作线程中,我们怎么去调用工作线程中的函数进行操作呢?

RPC:Remote Procedure Call (https://en.wikipedia.org/wiki/Remote_procedure_call),远程过程调用,指调用不同于当前上下文环境的方法,通常可以是不同的线程、域、网络主机,通过提供的接口进行调用。

通过 RPC 方式,我们可以达到想要的目的。这里就会介绍本文的主角 Comlink (https://github.com/GoogleChromeLabs/comlink)!

没有条件,就要创造条件

Comlink

Comlink 是由 Google Chrome Labs 开源出的项目,提供了前端多线程编程的 PRC 能力。

Comlink makes WebWorkers enjoyable.

请看该项目提供的最简单的例子:

main.js

// <script src="https://unpkg.com/comlink/dist/umd/comlink.js"></script>
async function init() {
  const worker = new Worker("worker.js");
  const obj = Comlink.wrap(worker);
  alert(`Counter: ${await obj.counter}`);
  await obj.inc();
  alert(`Counter: ${await obj.counter}`);
}
init();

worker.js

importScripts("https://unpkg.com/comlink/dist/umd/comlink.js");

const obj = {
  counter: 0,
  inc() {
    this.counter++;
  },
};

Comlink.expose(obj);

很显然,Comlink 的 “RPC” 能力正是我们想要的,注意上述例子中关键的两点 Comlink.wrap(worker)Comlink.expose(obj),它通过这种方式,将工作线程脚本中的上下文暴露给主线程环境中,下面通过查看部分核心代码来了解其具体的实现方式。

源码解析

先来看 wrap 函数的具体实现:

// 包装函数
export function wrap<T>(ep: Endpoint, target?: any): Remote<T> {
  return createProxy<T>(ep, [], target) as any;
}

// 由函数名可见,返回的是一个 Proxy
function createProxy<T>(
  ep: Endpoint,
  path: (string | number | symbol)[] = [],
  target: object = function () {}
): Remote<T> {
  let isProxyReleased = false;
  // 从以下大体的结构可以看出,Proxy 分别代理了 get、set、apply、construct 等操作
  const proxy = new Proxy(target, {
   // 举例 get 操作
    get(_target, prop) {
      // ...
      // 由于 await 的原因,最后会对 'then' 属性进行访问
      if (prop === "then") {
        if (path.length === 0) {
          return { then: () => proxy };
        }
        // 请看文章后续部分
        const r = requestResponseMessage(ep, {
          type: MessageType.GET,
          path: path.map((p) => p.toString()),
        }).then(fromWireValue);
        return r.then.bind(r);
      }
      // 如果访问 obj.counter 时,重新调用 createProxy 方法,此时返回一个新的 Proxy
      // 需要注意 path,代表了当前访问属性的深度,如 obj.counter.a.b.c 时,path 为 ['counter', 'a', 'b', 'c']
      // path 在 expose 方法中需要用到
      return createProxy(ep, [...path, prop]);
    },
    set(_target, prop, rawValue) {
      // ...
    },
    apply(_target, _thisArg, rawArgumentList) {
      // ...
    },
    construct(_target, rawArgumentList) {
      // ...
    },
  });
  return proxy as any;
}

可以看出,wrap 返回了一个 Proxy 对象,并且代理了 getsetapplyconstruct 四种不同的操作。如 obj.counter 操作,又会返回一个新的 Proxy 对象。此处需要注意的是,await obj.counter,会访问 Proxy 对象上的 then 属性,因此会进入 if (prop === "then") 判断,执行 requestResponseMessage 函数:

function requestResponseMessage(
  ep: Endpoint,
  msg: Message,
  transfers?: Transferable[]
): Promise<WireValue> {
  return new Promise((resolve) => {
    const id = generateUUID();
    // 消息监听
    ep.addEventListener("message", function l(ev: MessageEvent) {
      if (!ev.data || !ev.data.id || ev.data.id !== id) {
        return;
      }
      ep.removeEventListener("message", l as any);
      resolve(ev.data);
    } as any);
    // 若使用 onMessage,则不需要主动开启
    if (ep.start) {
      ep.start();
    }
    ep.postMessage({ id, ...msg }, transfers);
  });
}

熟悉的 addEventListenerpostMessage 呈现在眼前,所以当访问代理对象上的属性时,其实是发送了 GET 消息到工作线程,把真实值通过消息返回,形成看上去是本地调用的假象。 再来看 expose 函数的具体实现:

export function expose(obj: any, ep: Endpoint = self as any) {
  // 消息监听
  ep.addEventListener("message", function callback(ev: MessageEvent) {
    if (!ev || !ev.data) {
      return;
    }
    // id: 每一次消息的 ID,通过上述 generateUUID 生成
    // type: 操作类型,如 get 为 MessageType.GET
    // path: 访问对象层级,wrap 中有详述
    const { id, type, path } = {
      path: [] as string[],
      ...(ev.data as Message),
    };
    const argumentList = (ev.data.argumentList || []).map(fromWireValue);
    let returnValue;
    try {
      const parent = path.slice(0, -1).reduce((obj, prop) => obj[prop], obj);
      // 根据 path 取到 obj 相应层级的值
      const rawValue = path.reduce((obj, prop) => obj[prop], obj);
      switch (type) {
        // 举例 get 操作
        case MessageType.GET:
          {
            returnValue = rawValue;
          }
          break;
        case MessageType.SET:
          // ...
        case MessageType.APPLY:
          // ...
        case MessageType.CONSTRUCT:
          // ...
        case MessageType.ENDPOINT:
          // ...
        case MessageType.RELEASE:
          // ...
          break;
        default:
          return;
      }
    } catch (value) {
      returnValue = { value, [throwMarker]: 0 };
    }
    Promise.resolve(returnValue)
      .catch((value) => {
        return { value, [throwMarker]: 0 };
      })
      .then((returnValue) => {
       // 忽略,感兴趣可以参看源码
        const [wireValue, transferables] = toWireValue(returnValue);
       // 将处理完后的数据返回
        ep.postMessage({ ...wireValue, id }, transferables);
        if (type === MessageType.RELEASE) {
          // 释放处理
          ep.removeEventListener("message", callback as any);
          closeEndPoint(ep);
        }
      });
  } as any);
  // 若使用 onMessage,则不需要主动开启
  if (ep.start) {
    ep.start();
  }
}

self 指向工作线程上下文环境,addEventListenerstart 开始发送和监听消息队列,本质和方法 onmessage (https://developer.mozilla.org/zh-CN/docs/Web/API/MessagePort#方法) 一致,这也印证了 wrap 的设想 —— 取工作线程上下文中对象的值,并通过消息返回。

此处仅例举了 GET 操作,从 switch-case 结构和 Proxy 对象拦截的操作可以看出,不同的操作,会进行相应的处理,本文不一一详述。

由此可见,Comlink 采用的 RPC 代理方式,并不是传递上下文环境,因为这是非常危险的,而且函数传递时会导致 Uncaught (in promise) DOMException: Failed to execute 'postMessage' on 'Worker': xxx could not be cloned. 报错。它本质上依然是 MessagePort 消息通讯,不过封装了我们所头疼的“操作判断”,并以一种更优雅的方式(Proxy + Promise)来处理。另外除了简单的调用和取值外,Comlink 还支持回调 (https://github.com/GoogleChromeLabs/comlink#callbacks) 和共享线程 (https://github.com/GoogleChromeLabs/comlink#sharedworker),感兴趣的可以自行了解。

案例:导出 Excel

往往业务中有这样的需求,导出 Excel 报表。通常技术实现由后端返回文件流,前端生成文件并下载,这也是考虑到性能问题。事实上,在多线程的加持下,纯前端也完全可以实现,以下为 Comlink 的代码写法(10 万数据):

main.js

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <script src="https://unpkg.com/comlink/dist/umd/comlink.js"></script>
  <script src="https://unpkg.com/xlsx/dist/xlsx.full.min.js"></script>
  <script src="https://unpkg.com/file-saver/dist/FileSaver.min.js"></script>
</head>
<body>
  <button id="btn">Download</button>
  <p id="time"></p>
  <script>
    const button = document.querySelector('#btn');
    const worker = new Worker("worker.js");
    // 使用 Comlink 包装
    const getWorkBook = Comlink.wrap(worker);
    // 点击触发下载
    async function download() {
      button.disabled = true;
      // 生成 xlsx 文档的 blob 数据
      const blob = await getWorkBook(100000);
      // 下载
      saveAs(blob, "test.xlsx");
      button.disabled = false;
    };
    button.addEventListener('click', download);
    // 观察时间是否卡顿
    setInterval(() => {
      document.querySelector('#time').innerHTML = new Date().toLocaleTimeString();
    }, 1000);
  </script>
</body>
</html>

worker.js

importScripts("https://unpkg.com/comlink/dist/umd/comlink.js");
importScripts("https://unpkg.com/xlsx/dist/xlsx.full.min.js");

// 模拟生成 Excel 并导出
const getWorkBook = (count) => {
  const aoa = [];
  for (let i = 0; i < count; i++) {
    const arr = []
    for (let j = 0; j < 10; j++) {
      if (i === 0) {
        arr.push(`Column${j + 1}`);
        continue;
      }
      arr.push(Math.floor(Math.random() * 100));
    }
    aoa.push(arr);
  }
  const wb = XLSX.utils.book_new();
  const ws = XLSX.utils.aoa_to_sheet(aoa);
  XLSX.utils.book_append_sheet(wb, ws, 'Sheet');
  // XLSX.writeFile 无法获取 DOM,故采用此写法
  const data = XLSX.write(wb, { type: 'array' });
  return new Blob([data],{type:"application/octet-stream"});
};

Comlink.expose(getWorkBook);

可以看出,采用了 Comlink 的代码非常整洁,并且极易扩展(如:读取 Excel 并解析)!

查看代码 (https://codepen.io/konp/pen/WNOojPb)

顺便贴一张未采用多线程的效果对比,可以说非常明显:

查看代码 (https://codepen.io/konp/pen/MWobmRb)

思考

对于多线程编码的痛点,Comlink 很巧妙的在其外层进一步封装,隐藏了内部通讯逻辑,实现了 RPC 的模式。实际开发过程中,我们也常常会遇到这种基于 Message Event 的通讯方式,比如 iframewindow.openwindow.opener,理论上说,Comlink 的实现方式都可以适用于这些场景。

回到最初,通过 switch-case 或条件判断来扩展函数逻辑,往往是我们能想到的第一种解决方案,因为违反了单一职责原则,被无情抛弃。但是如果这种方式能够进行一定程度内聚,往往会有出其不意的效果,这样的设计思维方式一样可以适用于其他领域。

参考链接

Comlink (https://github.com/GoogleChromeLabs/comlink)

Web Workers API (https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Workers_API)

Web Worker 使用教程 (http://www.ruanyifeng.com/blog/2018/07/web-worker.html)

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/gcIDZ-Kwk8WVyBA_CmLIuQ

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:7月以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:7月以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:7月以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:7月以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:7月以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:7月以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:7月以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:7月以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:7月以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:7月以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:7月以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:7月以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:7月以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:7月以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:7月以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:7月以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:7月以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:7月以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:7月以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:7月以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  236860次阅读
vscode超好用的代码书签插件Bookmarks 1年以前  |  6846次阅读
 目录