LightHouse 跑分 100!这个框架究竟是怎么做到的(一)
大家好,我是来自 MoonWebTeam 的卡子。最近看到有一位大佬在一个大会上分享了他们团队对官网进行了性能优化,将 LightHouse Performance 的跑分从原来的 52 分提升至 100 分(图 1),而我们自己的 Vue 项目的一个简单的页面花费九牛二虎之力只能优化到 80 多分(图 2),因此非常好奇究竟他们是怎么做到的,是不是有什么黑魔法。
图 1:大佬团队的官网 LightHouse 跑分
图 2:使用基于 Vue 3 SSR 的 Mole 框架开发的某业务页面的 LightHouse 跑分
1、这是什么页面?大佬是谁?
上面的跑分 100 的页面是 Builder.io 的官网。Builder.io 是一家致力于构建低代码/无代码平台来服务普通用户和开发者、并追求极致性能优化的公司。这家公司的 CTO,也就是上文提到的大佬,是知名框架 AngularJS 和 Angular 的作者 Miško Hevery。
Builder.io 有很多客户是从事电商行业的,也就是很多电商相关的购物页面使用 Builder.io 搭建。我们都知道,页面的转化率和页面性能有很强的正相关性,以下单率和利润为目标的电商页面会更加关注页面的性能。对于电商页面来说,需要考虑的是用户能够尽可能快地看到商品页面,并且下单按钮能够尽快可交互,所以 Builder.io 也是基于这个目标去进行性能优化的。
2、Builder.io 是怎么做的?
为了优化页面的首屏可见耗时和核心流程可交互耗时,资源下载和代码执行这两个阶段耗时比较长,对它们的优化也是非常关键的。在网速固定的情况下,优化资源下载耗时的唯一手段就是减少资源体积,从而也能减少代码执行耗时。在我们平时的性能优化实践中,一般会采用图 3中的这些措施来优化资源体积,比如精简、压缩、懒加载等。
Builder.io 经过分析后发现,相较于优化图片、CSS 体积,最有效的优化手段是优化 JS 代码(图 4)。而页面中的 JS 代码主要分为业务代码和通过外链引入的第三方代码(比如谷歌分析、广告 SDK 等)。
针对第三方代码,他们优化的核心思路是将这些第三方代码的下载和执行从 JS 主线程中抽离,放到 Web Worker 线程中去处理,并开源了实现这个能力的 PartyTown 工具(本文不涉及 PartyTown 的相关内容,后续再撰文介绍)。
针对业务代码,他们优化的核心理念是:只下载和执行最小限度的必要代码,尽可能推迟代码的下载和执行,简单来说就是代码的懒加载(Lazy Load),或者说是按需加载,基于这个理念,他们创建了一个号称下一代的全新框架——Qwik(https://qwik.builder.io/)。
图 4:优化 JS 代码性能表现最明显
3、懒加载不是已经支持了吗?为什么要重复造轮子?
回顾前端的历史,最开始的时候还没有模块化规范,最原始的代码懒加载的方式就是通过手动动态创建 script 标签引入 JS 代码来实现代码的按需下载和执行(图 5),到现在我们的业务在有些场景还在用。这种懒加载的方式一般是类库级别的,也可能是文件级别的。
大概在一零年前后,大家发现这单个 JS 文件成千上万行的代码难以维护,于是提出了非官方的 AMD、CMD 等前端模块化规范,能够有效地将前端代码拆分多个模块文件。其中基于 CMD 规范的 sea.js 提供了懒加载某个模块的方法(图 6),这种懒加载的方式就是模块级别的。只有 require.async 某个模块时,这个模块的 JS 代码才能被下载和执行。
众所周知,在 2015 年前后 JS 终于有了官方的模块化规范 ES Modules,可以使用原生的动态导入(dynamic import)实现模块的懒加载(图 7)。但由于浏览器兼容性限制,最终还需要打包工具将代码打包成一个 bundle 在浏览器中运行,而这些懒加载的模块代码则会被打包成不同的 chunk。这种懒加载的方式也是模块级别的。
后来 SPA 单页应用新起,以 React、Vue 为代表的 MVVM 框架也流行起来,如今占据了大半个前端市场。
如果使用这些框架开发,编译后的代码主要包含框架运行时代码和业务代码,由于是 SPA 单页应用,业务代码包含了多个不同路由的代码,整体的 JS 代码体积会比较大。从性能方面考虑,支持路由的懒加载能力是各框架必须要做的一件事情。以 Vue3 为例,Vue Router 中实际上也是利用了动态导入来实现路由的懒加载(图 8)。
此外,Vue 3 还可以通过异步组件的方式实现组件的懒加载(图 9),实际上也是利用了动态导入的特性来实现。当组件需要展示时,才开始下载和执行组件代码。
总的来说,框架包含了路由、组件、模块级别的懒加载的方式。
图 9:Vue 3 使用异步组件实现组件懒加载
更进一步来说,组件代码其实由好几部分组成,比如组件的不同生命周期的代码、不同事件的执行代码等。即便是实现了组件的懒加载,组件本身的代码体积还是挺大的,下载后还需要全部解析执行。
那么有没有办法能够做到超细粒度的懒加载呢?比如细到组件的某个方法,当 A 按钮被点击时,点击的代码才会被下载和执行;点击代码执行后发生数据变更,依赖这个数据的 B 组件的渲染代码才会被下载和执行。
所以 Qwik 的实现目标就是:实现超细粒度的懒加载,并且懒加载是框架层面实现的,大多数情况不需要开发者关注。这是现有框架难以做到的。
4、现有框架有什么局限性?
上述的第四个阶段称为水合(Hydration)。除了下载和解析执行 JS 阶段的耗时长外,水合过程的耗时也很长,并随着页面的复杂性而线性增长。
为什么对于现有的框架来说水合必不可少呢?在直出 HTML 的过程中,除了生成 HTML 中的 DOM 文本外,还生成了应用状态和框架内部状态,应用状态可以简单理解为 store 的 state,一般会以 JSON 形式序列化到 HTML 中,而框架内部状态包含组件渲染树,渲染树并不会序列化到 HTML 中。因此在水合阶段,代码还需要再执行一次,构建组件渲染树,结合应用状态才能还原直出时的状态(图 11)。
那么组件渲染树有办法序列化到 HTML 中、然后在浏览器中还原吗?这样就可以避免水合这个阶段了。我们知道,组件渲染树的数据并不像 store 中的数据这么简单,可以直接用 JSON 序列化和反序列化。组件渲染树的数据包含函数、Promise 等,序列化和反序列化的成本非常高,并且有可能导致 HTML 的体积膨胀,所以这个想法对于现有的框架来说并不现实,完整的水合过程必不可少,因此应用必须等待水合完成才可以交互,可交互耗时很长。
5、Qwik 是怎么做的?
页面可交互,最关键的流程就给对应的 DOM 绑定事件,一旦绑定了事件,页面就可以交互了。我们之前在做性能优化的时候,也考虑过在页面 JS 执行时,立即给关键元素绑定事件,而这个绑定事件的代码是轻量的、不依赖框架的,这样就可以实现在水合完成前实现页面关键流程可交互,在水合完成后再移除绑定的事件。
Qwik 的思路和这种做法在某些方面有些相似,在 HTML 直出的过程中,把原本在框架内部状态(组件渲染树)中的信息,也就是要给哪些 DOM 元素绑定事件、触发事件后执行的是哪个函数,“序列化”后添加到 DOM 元素的属性中,以 HTML 文本的形式传递到浏览器。
下面以一个计数器的简单例子来看下 Qwik 是怎么从框架层面实现的。图 12 是例子的源码,当按钮点击后,展示的 count 值会加 1。我们可以看到,为了减轻开发者的心智负担和学习成本,Qwik 的语法和 React 是极其相似的,包括 hooks、JSX 语法,只是在核心 API 上有一些区别,其中 component$、onClick$ 中的 $ 符号表示组件、事件执行代码需要拆分 chunk 进行懒加载。
上面的代码编译打包后除了框架代码外会拆分成两个 chunk,一个是 onClick$ 产生的按钮点击事件执行代码,另一个是 component$ 产生的 App 组件渲染代码(图 13),这样就能实现框架层面超细粒度的懒加载,即页面在浏览器初始化时不下载和解析执行上面的两个 chunk,按钮点击后才开始下载和执行按钮点击事件执行代码,count 数据发生变化后,再下载和执行 App 组件的渲染代码,实现 App 组件重渲染。
这里就有一个问题,Qwik 是怎么知道按钮点击的时候需要下载哪个 chunk 的代码、以及 count 数据发生变化后需要下载哪些组件的渲染代码呢?前面提到,Qwik 在 HTML 直出阶段会把一些状态信息“序列化”到 DOM 文本中,我们先看看直出的 HTML(图 14)。如图 14 所示,信息可以分为两部分:一部分是 DOM 属性上的信息,另一部分是一个序列化的 JSON(qwik/json)。
请先关注下 DOM 属性上的信息(图 15),Qwik 会以一个自增 id 唯一标识一个组件,如 q:id=0 表示 App 组件,q:id=1 表示按钮。按钮上的 on:click 属性值是一个序列化后的字符串,用于表示按钮点击后需要调用哪个 JS 文件的哪个函数。这个字符串分为三部分,第一部分表示需要下载和解析执行的 JS 文件,第二部分表示需要执行的具体函数名,第三部分表示函数内部变量的索引。
上面的这部分信息只能够用于告知按钮点击后执行哪个函数,而执行函数中 store 的初始值、store.count 发生变化后导致有哪些组件需要重渲染、以及怎样重渲染,这些就需要依赖序列化的 JSON 了。
如图 16,这个 JSON 初看很复杂,是因为这里有非常多的规则约定和引用关系。ctx 表示每个组件的上下文信息,#0、#1 分别标识 id 为 0、1 的组件,即 App 组件和按钮。objs 表示数据,subs 表示组件对数据的订阅情况。
先看 id=0 的组件的 ctx 上下文信息,h 中的 0! 表示组件传入的 props,初始值为 objs 数组中的索引为 0 的项,即空对象;h 中的 2 表示组件的渲染代码和渲染函数,即 objs 数组中索引为 2 的项;而 s 则表示组件内部的 state,初始值是 objs 数组中索引为 1 的项,这个就是在上面 App 组件代码中定义的 store。id = 1 的组件的 ctx 中 r 表示组件引用了别的组件的 state,同样是 objs 数组中索引为 1 的项。
objs 中索引为 1 的项表示 App 组件的 store 的初始值,其中 count 的值为 3,3 表示 count 的初始值为 objs 数组索引为 3 的项,即 count 的初始值是 0。
最后 subs 数组中的 id=0 的 App 组件有一个 count,表示 App 组件是 count 数据的订阅者。
从上面的过程可以看到,实际上这个 JSON 表示的是应用运行时状态、组件和数据之间的关系以及组件的渲染代码位置。结合 DOM 中描述组件的信息,就可以实现不需要水合过程就可以实现状态的还原,直接可交互,有种从暂停到继续(可恢复性)、而不是像其他框架一样重放(重新执行)的感觉。
3)和其他框架 store 序列化不同,如果框架发现组件的 state 后续不再被使用,就不会序列化到 JSON 的 objs 数据中。
6、点击时还需要网络请求,响应不会有延迟吗?
按照上面的做法,确实会存在响应延迟的问题,弱网环境下延迟会更加明显。Qwik 也考虑到了这种情形,提供了预拉取(PreFetching)的机制来保证必要资源先缓存下来,解决关键流程的响应延迟问题。Qwik 默认的预拉取策略是通过 Interception Observer 判断组件是否在可见视口内,如果可见才异步预拉取组件的资源。当然预拉取策略是支持自定义的,未来可以尝试一下通过用户行为的实时反馈来决定哪些资源需要预拉取,这里还是有不少提升空间的。
3)Qwik 的预拉取可以放到 WebWorker 中进行,很多浏览器甚至可以在 worker 线程中对 JS 代码预解析成语法树,减轻了主线程的负担。而现有框架基本上都是需要在主线程中下载并解析执行的。
7、总结和展望
本文主要是从一个性能跑分案例出发,了解到是使用懒加载的基本思路进行优化,并回顾了前端历史上不同阶段的懒加载实现方式。然后重点分析了现有框架使用懒加载进行性能优化的局限性,并用一个简单的案例来分析 Qwik 这个框架具体是怎么实现超细粒度的懒加载,以及利用预拉取的机制来解决响应延迟的问题。
目前 Qwik 还处于 Beta 阶段,周边的生态不太完善,用在生产环境还不太现实。但 Qwik 的核心理念和优化思路还是值得我们借鉴的,比如懒加载、预拉取的思路,同时也启发了我不要被现有框架的思维所禁锢,有的时候可以脱离现有的框架去思考,一定会有所收获。
目前团队内使用的主流框架还是 Vue3,在超细粒度的懒加载方面能做的事情不多,可以多尝试利用现有的异步组件、动态导入、资源预拉取能力,通过组件、模块的懒加载来优化页面性能。对于不使用框架的页面,可以参考 Qwik 的思路来实现超细力度的懒加载。
期待 Qwik 正式版的发布。
感谢你阅读到最后,希望本文对你有所收获,随时欢迎交流和讨论,如有问题随时批评指正,期待你的反馈!
8、参考材料
1)https://qwik.builder.io/
2)WWC22 - Qwik + Partytown: How to remove 99% of JavaScript from main thread
3)Resumable Frameworks: | Miško Hevery | ng-conf 2022 Webinar
4)Our current frameworks are O(n); we need O(1)
5)Hydration is Pure Overhead
6)Why Progressive Hydration is Harder than You Think
7)How we cut 99% of our JavaScript with Qwik + Partytown