Nov 20

在Web前端践行js多线程编程

Lrdcq , 2021/11/20 22:13 , 程序 , 閱讀(2222) , Via 本站原創
一般我们说web页面解析特别是其中的js执行是“单线程”的,并且这是js这门语言的特点。本身确实没什么,但是实际项目实践中,特别是目前重js的spa大行其道的今天,界面中绝大部分的运行时卡顿,如滚动掉帧,事件响应延迟,均是js卡顿引起的。比较常见的卡顿常见如大量json序列化反序列化,对象deepcopy,界面diff刷新不收敛,总之代码稍微写得烂了一点页面性能就不堪入目,更别说编写大型项目如动画/3d内项目时大量货真价实的运输拖慢帧率了。

当然,这些都是可以针对性的优化的问题,但是考虑到优化成本与第三方代码的不可控,更理想的情况下,我们能像其他语言或者其他GUI框架那样,将可能耗时的操作放到后台线程中去执行,UI线程聚焦界面展示逻辑。虽然看起来相当异想天开,但是这确实是业界正在做的事情,比如回溯微信小程序的开发背景,独立jscore执行逻辑的一大理由就是通过将运算逻辑native化与后台线程化来减轻web页面本身的运行负担来提升h5用户体验,这种富web容器方案上,业界已经有相当丰富的方案了。而对于在Web标准开发上,各个新标准新技术也能为我们带来更低成本去降低UI线程负担的方案,从而带来用户体验收益。

——————

说到在web界面中尝试多线程逻辑执行,其实从上古以来就存在各种尝试方案,当时的理由是——混编页面服务端顺序输出,部分页面输出相当慢,需要独立执行以避免影响主页面渲染与样式套用,当然也有避免js/css加载执行的阻塞问题。当然,除去体验不谈,从保障不卡顿可用的角度当时确实存在了可用方案,即

1. 上古方案:Child document

即通过在主页面中打开新的子document来执行耗时操作,来保障主document的流程。实际实现上看起来,透明或者不展示的iframe听起来更合理,当时实际上iframe在页面中就算不展示,其中的加载过程与耗时也是会体现到父文档中的,所以实际上更多采用的是dialog window的方案。在一个新window中执行耗时操作并且通过postMessage来双向通信。
點擊在新視窗中瀏覽此圖片

顺带一提在容器方案中,通过开启多个webview来分担主webview的任务事实上是存在的方案,包括微信小程序内部的降级方案也是多个webview运行起来的。因此这并不是什么非常不合理的方案,之所以在标准web方案上不合理,确实是因为不断弹出新dialog window这种web体验已经不适合这个版本了。或者说如果不是现代web浏览器而是定制的浏览器包括Electron这样的框架下,这反倒是最简单可行的方案。

2. 标准方案:Web Worker

当然对应这个版本提出的方案,官方给出的解决方案就是worker。通过使用Web Workers,Web应用程序可以在独立于主线程的后台线程中,运行一个脚本操作。这样做的好处是可以在独立线程中执行费时的处理任务,从而允许主线程即UI线程不会因此被阻塞/放慢。Worker本身确实好用,参看mdn文档(https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Workers_API)基本上分分钟可以上手,不过仍然有一些疑问与可突破点。

- 困难点:worker中是无法调用Dom的

也不是说完全无法调用Dom,当时目前绝大部分Dom API包括通过window直接获取文档窗口布局属性都是无法执行的。根因上,worker所执行的jscore上根本没有注入dom api实现,浏览器为方便用户而构建的五大对象上当然也没有Dom的东西了。可能实现么?原理上是不太可能的,根因是跨线程通信考虑到线程安全不随便锁死进程,都是异步的,而众所周知Dom API是同步API(严格来说dom api大量api都是异步的,比如我往body中执行了3次append元素,元素并没有当场插入到body中而是将操作推送到dom任务队列中等待当前run loop完成后再执行,当时如果现在尝试通过getElement去读取相关元素,则会在调用读取方法时,强制将队列中的任务同步执行并同步返回结果,因此api调用起来看起来是同步的,实际上可以理解为是写异步读同步),因此同步API直接跨线程暴露确实似乎不合适。

目前暴露到worker中的dom api都是特殊定制的,比如OffscreenCanvas(https://developer.mozilla.org/zh-CN/docs/Web/API/OffscreenCanvas),要注意到图像绘制行为本身并不是要求一定要在主线程执行的,况且一方面OffscreenCanvas明确自己的输出结果是bitmap,另一方面Canvas的context上的api相对较为独立和dom确实没关系,简而言之各个浏览器实现上本身就没和主线程耦合,因此这件事情肯定能做。而其他dom api看起来就困难很多了,特别是“读”行为在dom操作中完全不可避免。另外目前之所以会暴露OffscreenCanvas,基本上是因为canvas后台渲染在游戏/多媒体开发的场景确实在常用了。更深层的原因上,游戏/多媒体开发对主线程的性能消耗确实太厉害了必须使用类似方案。而实际上随着业务页面的复杂度上升,普通Web界面也需要这样东西。

- 困难点:代码后台线程迁移

从设计的角度,Worker还是希望在后台线程的代码编写与普通的web代码编写一致。当时这样做的后果是反而和传统的多线程开发代码风格不一致了从而导致代码繁琐并且从node等运算风格脚本迁移代码时有所困难。

一个迁移难点是异步API,说实话浏览器中的大量JsAPI设计异步和同步混杂。比如fetch, settimeout等能够通过callback或者promise返回结果的API我们都理解为异步API,然后另外一些io操作如localstorage却是同步API。而不同于在主线程的编程,在后台线程中的编程更偏好用同步API,比如一个游戏核心逻辑的worker代码,基本上是while(true)包一个死循环。核心原因是除了进程之间的通信,worker可以视为是传统的顺序式编程的场所而不是事件驱动的地方,我们也会更倾向于把持续后台运行的逻辑放到worker而事件驱动的逻辑放在ui线程,因此在worker中堆满异步api反而更不友好了。更别是当从其他语言中迁移逻辑到js worker中时,各种异步api会让代码支离破碎。

另一个难点当然是dom api的剔除,如果是原本混杂有dom api的逻辑,将dom数据替换为入口参数将是一个非常繁杂的工程。可行的解决方案包括:不该动方法调用,而是通过proxy对象等技术构造fake dom object来减小代码改动范围;同步化(Synchronous)后台线程与主线程的通信来实时调用dom方法。同步化API也是上一个疑难点核心需要解决的问题,从语言层面上并没有可行的解决方案,只不过在es6普遍支持的今天,最简单的方法是将所有代码通过async包装并对异步调用添加await标记即可但仍然需要一定的改造成本。另外一个方案则是尝试寻找现有的api可以同步变异步,即api是同步当时实现是异步,并且实现可以进行拦截。

所以我们还真找到了这样的Api并且已经有试验性方案推出了,即方案3。

3. 拓展方案:Partytownhttps://github.com/builderio/partytown

Partytown这个框架本身的目的是在worker中运行第三方js代码,当然它的核心即使尝试解决了在不改变第三方代码的情况下,在worker中支持Dom API调用。因此它选取的方案确实解决了“同步转异步”这个难点,它的核心实现方案是。

a. 在worker中构造fake dom对象,通过proxy代理对象的实现。

b. dom方法调用通过“同步XHR”(https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/Synchronous_and_Asynchronous_Requests#synchronous_request)的方式序列化的发送出去并同步的获取返回结果,请求和返回对象均序列化处理。Synchronous XHR request即我们找到的同步转异步的API。

c. 另起一个Service Worker,Service Worker本质是浏览器提供的网络拦截器,它可以拦截Synchronous XHR发送的网络请求,这个过程就是“转异步”的过程。Service Worker收到dom操作请求后,继续转发,通过普通的postmessage发送给主线程,并listen结果callback给网络请求。

d. 主线程等待Service Worker发送的message并进行相应处理,返回结果。
點擊在新視窗中瀏覽此圖片

这个过程应该是在目前标准Web前端中可以找到的唯一“同步转异步”方案,当然目前问题也很大:a. 序列化数据传输,成本高。b. 至少跨了3个线程(还不包含js内部线程),执行成本高。因此实际项目中用于执行高性能诉求的dom请求还是得不偿失,只能用于非核心dom操作(比如广告,埋点采集等),只能说为后续继续演进提供方向。
关键词:worker , 多线程 , javascript
logo