背景

就在这周四的部门weshare学习会上,课长分享了以前我们做过的一个澳大利亚菠菜项目,因为引入了大量的第三方脚本,导致了他的首屏加载过于缓慢, 第三方脚本会大量占用主线程上的宝贵资源。
会导致:

1.用户体验:加载速度缓慢会影响用户的体验,用户可能会感到不耐烦并选择离开网站,导致页面的跳失率增加。

2.搜索引擎排名:加载速度是搜索引擎优化(SEO)的一个重要因素。加载速度慢可能会导致搜索引擎降低对网站的排名,影响网站的曝光度和流量。

3.移动设备兼容性:大量的第三方脚本可能会使网站在移动设备上加载更加缓慢,影响移动用户的体验,进而影响移动端的流量和用户留存。

4.安全性:引入大量第三方脚本增加了网站的复杂性,可能会增加安全风险。如果其中的某些脚本存在漏洞或恶意代码,可能会导致网站受到攻击,用户信息泄露等安全问题。

5.跟踪和分析:过多的第三方脚本可能会干扰网站的跟踪和分析工具的性能,影响对用户行为的监控和分析,从而影响对网站性能和用户行为的理解。

那么,我们这次要用来处理加载问题的一个新的工具就是:Partytown

Its goal is to help speed up sites by dedicating the main thread to your code, and offloading third-party scripts to a web worker.

Partytown 是一个开源的轻量级库,旨在通过将资源密集型脚本(通常是第三方脚本)移至 Web Worker 并从主线程中移除,从而加快网站速度。这样做可以释放浏览器的主线程,让它运行你自己的代码。Partytown 由 Builder.io 维护,目前仍处于测试阶段

web worker的痛点

转自课长的原话:

Partytown 的核⼼思想是:主线程应当⼀⼼⼀意的执⾏你的代码,任何在关键渲染路径中不必要的脚本都应该被迁移到web worker中。web worker一直是一种将资源密集型任务从主线程上剥离下来的实用解决方法

但是,web worker的真正的问题,在于无法访问DOM API,主要是因为它们是在与主线程隔离的后台线程中运行的。这种设计是有意为之,旨在防止并发操作导致的问题,如竞争条件和数据不一致,同时允许进行重计算或处理不需要与用户界面直接交互的任务,从而提高网页的性能和响应速度。

主要原因

  • 线程隔离:Web Workers 运行在与主线程分开的环境中,这意味着它们没有访问或修改 DOM 的能力。这种隔离确保了主线程(通常负责 UI 渲染和事件处理)的操作不会被后台任务阻塞或干扰。
  • 安全和稳定性:并发访问和修改 DOM 可能会导致竞态条件,其中两个或多个线程尝试同时读取和修改同一数据,可能导致数据损坏或不可预测的行为。通过禁止 Workers 直接操作 DOM,Web 平台避免了这些问题,确保了网页的稳定性和一致性。

举个例子吧:
假设我们有一个在页面上显示实时股票价格的应用,我可能希望在后台线程中处理股票价格的获取和更新,以避免阻塞 UI 或其他用户交互。如果 Web Workers 能够直接修改 DOM,我可能会这样做:

// 假设在 Web Worker 中的代码
document.getElementById("stock-price").textContent = "新的股票价格";

这段代码会直接尝试在web worker中访问DOM,用来更新页面上的股票实时价格。但是,由于Worker无法直接访问DOM API。这段代码实际上是会失败的,并且会抛出错误。

为了安全的更新UI,我们需要从Worker发送信息到主线程,并且在主线程中接收这些信息并且进行DOM更新:

// Web Worker 中的代码
postMessage("新的股票价格");

// 主线程中的代码
worker.onmessage = function(event) {
    document.getElementById("stock-price").textContent = event.data;
};

这种方式遵循了 Web Workers 设计的初衷,即在不直接操作 DOM 的情况下执行后台任务,同时通过安全的消息传递机制与主线程通信,确保了应用的响应性和用户界面的流畅性。

但是有一个很严肃的问题:postmessage,是异步操作。可能会导致第三⽅脚本对DOM 的操作⽆法正常运⾏

这个限制主要由两部分构成:Web Workers 的设计哲学和 JavaScript 事件循环机制。

web Worker设计哲学

Web Workers 的设计初衷之一是使得重的计算任务可以在后台线程中运行,而不会阻塞主线程。因此,所有的通信机制(包括postMessage)被设计为异步,以避免在等待消息响应时阻塞任何线程。

JavaScript 事件循环

JavaScript 在浏览器中的执行模型基于事件循环,这意味着任务(如事件处理函数、定时器回调等)会被加入队列,然后异步执行。postMessage遵循这一模型,确保消息的发送和接收不会中断正在执行的脚本或导致界面冻结。

二者就导致了:
由于postMessage的异步特性,如果需要在 Worker 中执行计算,并且基于这个计算的结果立即更新 DOM,就会面临挑战。因为即使从 Worker 发送了更新DOM的消息,这个更新也不会立即发生,而是会在消息被主线程处理时发生。

不从Partytown上来考虑解决办法的话,虽然无法从 Web Worker 直接进行同步 DOM 操作,但可以采取一些策略来处理依赖异步消息传递的情形:

  1. 状态管理:在主线程中管理应用状态,当从 Worker 接收到更新时,同步更新状态并反映到 DOM 上。这种方法需要在主线程中维护状态与视图的同步。
  2. 预渲染和批处理:对于复杂的更新,可以在 Worker 中预先计算出多个变更,然后通过单次postMessage传送,减少通信次数并尽可能地集中更新 DOM。
  3. 优化用户体验:在某些场景下,即使操作是异步的,也可以通过设计来优化用户体验,比如使用加载指示器或动画等待异步操作完成。

尽管存在这些挑战,Web Workers 提供的性能优势通常使得采用这种架构进行复杂计算或数据处理变得值得。

Partytown

Partytown 最重⼤的功能是能让 Web Worker 同步地读取主线程⾥的东⻄。

注意:如果我们可以让web Worker中运的代码,可以同步调用阻塞的DOM API并得到返回值,就意味着,我们可以在未经修改的前提下,在Worker中执行第三方脚本,第三方脚本的代码可以按照预期执行,只不过它会运行在不同的线程中,不会和自己的代码争夺资源。

而我们的Partytown,则使用了两种方法在web Worker和主线程之间进行同步通信。

Partytown会优先使用Atomics,因为在性能上,此工具比Service Workers会好一点

上面的内容可能不太好理解,我们再做一下简单易懂的解释吧:

同步XHR请求与Service Workers结合

  • 同步XHR请求:传统上,XHR(XMLHttpRequest)请求主要用于在客户端与服务器之间异步交换数据,并且它也支持同步操作,尽管这种做法通常不推荐,因为会阻塞主线程直到响应返回,但是!在web Worker环境中,使用同步XHR不会影响到用户界面的响应性,因为Worker运行在背景线程里
  • Service Workers:一种运行在浏览器背景的脚本,用来拦截和处理网络请求,包括缓存文件以提供离线访问功能。在Partytown中,Service Worker可以拦截从web Worker发出来的同步XHR请求,并且据此在主线程中执行相关的DOM操作或者其他任务,然后将结果回传给Worker

结合这两种技术。可以运行Partytown以一种几乎是“同步”的方式,在Worker和主线程之间交互信息,尽管背后的实现是依然基于异步通信机制的。

Atomics结合SharedArrayBuffer

  • SharedArrayBuffer:一种允许多个线程共享内存数据的JavaScript构造。它可以被主线程和web Worker共享,从而无需通过信息传递就能实现内存共享。这就为线程之间的数据交换提供了一种高效的方式
  • Atomics:这个对象提供了一组静态方法,用于执行安全的原子操作到共享内存数据上。这些操作包括读写共享内存里面的数据、执行原子性的加减等计算操作,甚至于等待和唤醒操作。通过这些原子操作,可以确保即使在多线程环境中,对共享内存的访问也是安全的,不会发生数据竞争。

Partytown通过使用Atomics和SharedArrayBuffer实现了一种高效的同步通信机制。这种机制允许它在不牺牲性能的前提下,实现复杂的跨线程数据交互。利用Atomics进行同步通信比通过Service Workers和同步XHR请求更加的高效,因为它直接在内存级别进行操作,避免了额外的消息传递开销

数据竞争

这是一种现象,发生在多线程程序里,当两个或者多个线程同时访问相同的内存位置,至少有一个线程在没有采取适当同步措施的情况下对内存进行写操作的时候就会出现。如果线程的操作顺序会影响程序的结果,且程序没有对这些操作进行适当的同步,就会导致不确定的行为和难以预测的结果,这就是数据竞争。

特征
  • 并发访问:多个线程同时访问同一块内存区域
  • 至少一个线程进行写操作:参与竞争的线程中,至少有一个线程在进行写入操作,其他线程可能是读取或者写入
  • 缺乏适当的同步机制:没有使用锁、原子操作等同步机制来协调对共享资源的访问
问题

数据竞争可能导致多个问题,包括但不限于:

  • 不一致的数据:由于操作的执行顺序不确定,同一个变量在程序的不同执行中,可能会出现不同的值
  • 程序崩溃:在某些情况下,数据竞争可能导致程序异常终止
  • 难以调试和重现的错误:数据竞争导致的问题往往难以调试,因为错误只能在特定的条件,偶尔发生。
解决

避免它?那就这样做:

  • 锁和互斥量(Mutex):使用锁来同步对共享资源的访问,确保任何时刻,只有一个线程可以访问资源
  • 原子操作:利用原子操作来读取,修改,写入共享数据,保证了在执行过程中不会被其他线程中断
  • 条件变量:使用条件变量,同步线程之间的操作顺序,确保操作以正确的顺序执行
  • 读写锁:允许多个线程同时读取资源,但是在写入的时候,要求独占访问,从而在保证安全的同时提高了效率

性能考虑

Partytown优先使用Atomics和SharedArrayBuffer的主要原因还是在性能上,这种方法允许直接、高效地在Worker和主线程之间同步共享数据,减少了通信延迟和处理开销。然而,这种方法的使用受到了浏览器支持和安全限制的约束,例如跨域隔离措施(COOP和COEP头)可能要求网站满足特定的条件才能使用SharedArrayBuffer。关于跨域隔离措施,我会单独再出一篇文章进行讲解。

通过这些高级技术,Partytown能够在不阻塞主线程的情况下,有效地执行第三方脚本,显著提高页面的加载速度和响应性。

沙盒化和隔离

第三方脚本通常是一个充斥着大量JavaScript的代码黑箱,一般来说很难确保代码里藏了什么东西。也很难知晓第三方的脚本在用户的设备,以及你的网站上做了什么,而且这些三方脚本还会作为你的应用程序代码共享一样的线程以及上下文。

Partytown能将第三方脚本沙盒化或者隔离到web Worker里,可以选择是否允许其对主线程API的访问,包括localstorage,userAgent,cookie等等,因为这些第三方脚本都要必须走一遍Partytown的代理,才能访问到主线程,Partytown有能力记录每次的读写操作,甚至可以限制某些DOM API的访问。
严格来说,Partytown可以帮你:

  • 将第三方的脚本隔离到沙盒
  • 为特定的脚本配置能够访问哪一些浏览器的API
  • 可以选择记录API的调用和调用参数,以此更好的了解到第三方脚本到底在做什么

什么场景下很有用呢?

  • 阻止访问document.cookie
  • 提供一个标准的navigator.userAgent
  • 阻止脚本写入localStorage
  • document.write()失效
  • 阻止脚本请求其他的脚本

如何使用

⾸先⽤将静态⽂件下载到⽹站的根⽬录:

npx "@builder.io/partytown" copylib "public/~partytown"

调整 HTML 中想要让 partytown 处理的第三⽅库 js 的 script 标签,加上
type="text/partytown"

<script
type="text/javascript"
src="/public/~partytown/partytown.js"
async
defer>
</script>
<script type="text/partytown">...</script>

使用之前的lightinghouse的跑分:

使用之后的:

更多使⽤⽅式请参考
Integration Guides - Partytown

总结

虽然  Partytown 的设计初衷⾮常理想,就是协助管理第三⽅ JS 代码,降低 JS 占⽤主线程的状
况。但有不少 JS 代码在通过 Partytown 加载到 Web Worker 之后⽆法顺利执⾏,所以实际上
Partytown 并不使⽤与所有的 JS 代码,因此不建议将⽹⻚所有的 JS 都改⽤ Partytown 来加载。

⽬前经过测试的常⽤第三⽅库的列表
Common Services - Partytown
更多注意事项请参考
Trade-Offs - Partytown

最后修改:2024 年 04 月 01 日
收款不要了,给孩子补充点点赞数吧