Treebo基于React/Preact的PWA性能调优案例分析[译文]

Treebo是在印度名列前茅的连锁经济酒店,在200亿美元规模的旅游产业中占据了重要位置。他们最近发布了全新的、先后基于React和Preact构建的PWA(Progressive Web App,渐进式网页应用),为其客户提供移动端体验。

相比他们的上一代移动端网站,新PWA的白屏(First Paint)时间缩短了70%,可交互时间(Time-to-Interactive)缩短了31%,3G网络下加载时间短于4秒。在WebPageTest中模拟印度3G网络测试(译者按:在本文“由React切换至Preact”章节末尾介绍了测试详情),页面5秒内即可交互。

单从React更换为Preact这一项就为可交互时间贡献了15%的提升。你可以访问Treebo.com体验这一PWA,本文将着重介绍其技术演进的过程。

Treebo的渐进式网页应用PWA

改进性能的过程

上一代移动网站

Treebo的上一代移动网站是用Django开发的单体应用。用户的每个页面跳转都需要一个服务器端请求。上一代的白屏时间为1.5秒,首次有效绘制(first meaningful paint)时间为5.9秒,可交互时间为6.5秒。

基础的React单页应用

重写Treebo的第一步,是基于React和简单Webpack配置的单页应用(SPA,Single Page Application)

请参考如下代码。该代码会构建简单的单体Javascript和CSS捆绑包(bundle)。

这次尝试的白屏时间为4.8秒,可交互时间为5.6秒,头图绘制完成于7.2秒。

服务器端渲染

下一步,他们采用了服务器端渲染来缩短白屏时间。要强调一点,服务器端渲染并非毫无代价,浏览器端得到了优化,而相应的代价转移到了服务器端

采用服务器端渲染,服务器将可以直接渲染的页面HTML作为响应返回给浏览器,浏览器无需等待Javascript下载和执行即可开始渲染页面。

Treebo调用了React的renderToString()接口,将组件渲染成HTML字符串,然后在JS初始化之后才注入应用状态(application state)。

Treebo在使用服务器端渲染后,他们的白屏时间减少至1.1秒,首次有效绘制则减少至2.4秒——这令用户能在更短时间内接收到该页面,并更早读到页面内容,顺便的,这对SEO效果也有稍许提高(译者按:现代搜索引擎已具有爬取含有AJAX内容页面的能力,为单页应用提供静态HTML已不再是SEO的必要项目,详情可参考这里)。但这一改动也导致页面的可交互时间明显增加。

虽然用户可以看到页面内容,但浏览器主线程会因启动Javascript繁忙而无法响应。

在服务器端渲染方案中,浏览器需要获取并处理比以前更多的HTML数据,且仍需要获取、解析/编译并执行Javascript。这实际上带来更多计算量。

可交互时间6.6秒,这是一个退步。

服务器端渲染也有可能导致低端设备的浏览器主线程被大量占用,进而使可交互时间进一步增加。

代码分割和基于路由的组块

Treebo的下一步是利用基于路由的组块(chunking)降低可交互时间。

基于路由的组块,是将各路由(route)依赖的代码分割为相应的、可按需加载的组块(chunk),其目标是只加载最少量的代码即可令一条路由可交互。这要求可下载资源具有更细的颗粒度。

他们采取的方式是,将代码分割成第三方依赖、Webpack运行时代码(runtime manifests)以及路由等三个独立的组块。

这次的可交互时间降到了4.8秒。干得漂亮!

唯一的缺点是,浏览器在初始化JS捆绑包执行完毕后才开始下载当前路由的Javascript,这并不理想。

但它至少对用户体验有着积极作用。在基于路由的代码分割这个方向上,他们的代码更为显式。他们利用了React Router的getComponent声名式API,以及Webpack的import()异步加载组块。

PRPL模式

基于路由的组块是非常重要的第一步,在这之后将是更加智能的代码打包方式,旨在提供更细粒度的服务和缓存。Treebo从Google的PRPL模式中获得了启发。

PRPL 是一种用于结构化和提供 渐进式网页应用 (PWA) 的模式,该模式强调应用交付和启动的性能。

PRPL代表:

  • 推送(Push)用于初始URL路由的关键资源;
  • 渲染(Render)初始路由;
  • 预缓存(Pre-cache)其他路由;
  • 懒加载(Lazy-load)并按需创建其他路由。
PRPL示意图(由Jimmy Moon绘制)

“推送”意味着服务器浏览器两端需要支持HTTP/2,服务器端提供未经捆绑(unbundled)的资源,这些资源传输到浏览器端并经过缓存优化,会带来更短的白屏时间。可以使用<link rel=”preload” />HTTP/2 Push触发这些资源的传输。

Treebo选择使用<link rel=”preload” />来预加载当前路由的组块。这一做法会有效减少可交互时间,因为当初始捆绑包完成执行、Webpack尝试获取当前路由的组块时,会发现这些组块已经在缓存中了。这次的成果是可交互时间稍有下降,落在4.6秒。

上述方式唯一的缺点是,目前并不是所有浏览器都支持预加载。好消息是,在Safari技术预览版(Tech Preview)中已实现了<link rel=”preload” />,我很希望它今年内能正式发布;Firefox也正在尝试实现该功能。

HTML串流(Streaming)

React中renderToString()接口的一个难题是,它是同步执行的,而且它有可能成为React服务器端渲染的一个性能瓶颈。服务器端直到整个HTML创建完成时才会发出响应(response)。当Web服务器改为串流输出内容时,浏览器端得以在整个响应完成之前就开始为用户渲染页面。这就是react-dom-stream项目的用武之地。

为了提升用户可察觉的性能,并带给用户一个应用逐步加载的体验,Treebo采用了HTML串流(Streaming)技术。他们将<head />标签及其包含的<link rel=”preload” />标签通过串流传输,使浏览器能更早地预加载CSS和Javascript。接下来服务器端进行组件渲染,并将剩余内容传输至浏览器端。

这一做法的好处是能更早开始资源下载,这令他们的白屏时间缩短至0.9秒,可交互时间缩短至4.4秒。应用在4.9~5秒时即可达到持续可交互的状态。

然而这种方式也有缺点,它需要将浏览器端与服务器端的HTTP连接保持地更久一些,如果网络延迟较高时会有问题。对于HTML串流,Treebo将<head />内容定义为前置组块,紧接着是包含主体内容的后置组块。所有这些都被注入到页面中。请参考以下代码:

从以上代码中可以看到,前置组块中包含了所需的所有以<link rel=”preload” />方式声明的Javascript标签;而后置组块则包含了服务器端渲染的HTML,以及其他需要包含状态、或是需要调用已加载的Javascript的代码。

内联关键路径CSS

CSS样式表可以阻塞渲染过程。直到浏览器完成请求、接收、下载并解析样式表之前,页面可能一直是空白的样子。减少浏览器需要处理的CSS数量,并将其内联(指关键路径样式,critical-path styles)到页面中,进而减少一次HTTP请求,我们得以让页面渲染地更快一些。

Treebo将当前路由的关键路径CSS以内联方式嵌入页面中,然后在DOMContentLoaded时,用loadCSS异步读取其他剩余的CSS。

这一改动的效果是,它移除了会阻塞渲染的关键路径<link />标签,将少数核心CSS内联到页面中,使得白屏时间进一步减少了0.4秒。

这一改动的问题是,内联样式使页面内容大小有所增长,而且也需要一定时间解析这些样式,所以推迟了Javascript的执行,这导致可交互时间有少量增加,达到4.6秒。

离线缓存静态资源

服务工作线程(Service Worker)是一种可编程网络代理,让您能够控制页面所发送网络请求的处理方式。

Treebo采用服务工作线程来缓存他们的静态资源和定制的离线页面。从如下代码中我们可以看到他们如何注册服务工作线程,以及他们如何利用sw-precache-webpack-plugin缓存资源。

缓存像CSS、Javascript捆绑包这类静态资源,意味着当用户重复访问时,无需再通过网络获取这些资源,而是直接读取本地磁盘缓存,所以页面几乎是瞬时完成加载。当然,为静态资源配置缓存HTTP头也可以达到这一目标,但服务工作线程为我们提供了额外的离线支持。

在服务工作线程中调用缓存API(Cache API,详情参见文章:JavaScript Start-up Performance)缓存Javascript,也能提早受益于V8引擎的代码缓存功能,重复访问页面时会节约一定的启动时间。

下一步,Treebo希望能减小其第三方依赖捆绑包的体积,减少JS执行时间,所以他们在生产环境中将React切换为Preact

由React切换为Preact

Preact是一个小到3KB但具有相同ES2015 API的React替代品。它着力于提供高性能渲染,同时提供一个可选的兼容层(preact-compat)用于整合React的原有生态,比如Redux。

Preact之所以能如此小巧,其原因之一是移除了合成事件(Synthetic Event)和属性类型(PropType)验证。除此之外它还提供了一系列特有功能:

  • 对比虚拟DOM和原生DOM;
  • 允许使用诸如class、for这样的通用属性;
  • 将(props, state)作为参数传入render方法;
  • 使用标准浏览器事件;
  • 支持完全的异步渲染;
  • 默认无效化子树。

多个渐进式网页应用的案例表明,切换至Preact可以有效减小JS捆绑包体积,减少JS启动时间。近期发布的渐进式网页应用如Lyft、Uber和Housing.com,都在其生产环境中使用了Preact。

注意事项:如果已经基于React开发的情况下如何使用Preact?理想情况下,你应该在开发、测试和生产环境中全面使用preact和preact-compat。这样可以帮助你尽早发现切换导致的bug。但如果你更倾向于只在生产环境的Webpack打包过程中,以别名方式引入preact和preact-compat(这样可以保证在开发环境或测试环境中,基于React和Enzyme的测试代码不受影响),那你在部署到生产服务器之前,需要对捆绑包进行全面测试,以确保切换后的代码正常工作。

Treebo经过这次切换,第三方依赖捆绑包体积从原来的140KB减小到100KB(前后均为gzip压缩后的体积),移动设备上的可交互时间从4.6秒减少至3.9秒。这是一次完胜。

你可以在Webpack配置中将react和react-dom别名分别设置成preact-compat,即可实现上述切换。

这一方式的缺点:他们不得不探索各种变通方案(workarounds)来保证Preact能顺利与他们已使用的各种React生态整合。

在你使用React时,95%的场合Preact是一个优异的选项;但对于其他5%场合,你也许不得不去给Preact开bug以期能支持这些边缘的用例。

备注:WebPageTest工具尚未支持在印度以真实Moto G4设备测试,本文性能测试均以“Mumbai — EC2 — Chrome — Emulated Motorola G (gen 4) — 3GSlow — Mobile”配置进行。你可以在这里找到详细测试结果。

加载占位屏

“加载占位屏(Skeleton Screen)本质上就是一个页面的空白版本,信息会逐渐加载并显示在上面。”——Luke Wroblewski

Treebo利用具有预览能力的组件来实现其加载占位屏(也可以说是每个组件都具有加载占位屏)。这种方案基本上就是为各个原子级组件(如Text、Image等)提供一个带有预览能力的版本,对这样一个组件,如果必要的数据尚未到位,则显示为它的预览版本。

例如,上图中每个列表项中都包含宾馆名称、城市名、价格等,这些信息是由诸如<Text />这样的排印(Typography)组件实现的,这些组件会接受两个额外属性,preview和previewStyle:

当hotel.name不存在时,组件会将背景改为灰色,并根据传入的previewStyle设定宽度以及其他样式(如果不设置previewStyle,则宽度默认为100%)。

Treebo认为这种方式很灵活,因为是否切换至预览模式的逻辑并不依赖于其显示的数据,而是可以自由定制。比如上图中的“Incl. of all taxes”(包含所有税费)部分,它是静态文本,本来可以在页面加载的第一时间显示出来,但这时它上面的价格因为调用API尚未返回,仍显示为预览模式,如果提前显示这个提示就会令用户感到困惑。

所以他们利用价格的预览逻辑来判断是否将静态文本“Incl. of all taxes”显示为预览模式。

这样当读取价格时,你会看到一个美观的预览界面,一旦API成功返回,你会看到数据和文本同时被显示出来。

Webpack-bundle-analyzer

到这一步,Treebo决定做一些捆绑包分析,看看有没有其他可以立竿见影的优化空间。

注意事项:如果你在移动端网页中使用React这样的库,引入第三方依赖库时需要更加谨慎,否则会对性能带来一些负面影响。可以考虑将第三方依赖库分成多个组块,这样路由只需读取必要的依赖。

Treebo利用webpack-bundle-analyzer持续关注他们捆绑包的体积变化,并监控每个路由的组块中分别包含了哪些模块。他们也利用它来发现针对捆绑包体积有哪些优化空间,比如剥除moment.js的地区(locale)、复用深层依赖等。

利用Webpack优化moment.js

Treebo重度依赖moment.js处理日期。当你导入moment.js并用Webpack构建后,默认情况下包含了moment.js本身和它的所有地区信息,捆绑包会增大约61.95KB(gzip压缩后)。这对你最终的第三方依赖捆绑包的体积有较大影响。

两款Webpack插件可以用于优化moment.js体积:IgnorePluginContextReplacementPlugin

Treebo的产品并不需要地区支持,他们选择用IgnorePlugin移除所有地区源文件。

剥除这些地区后,moment.js的捆绑包体积减小到16.48KB左右(gzip压缩后)。

剥除moment.js地区信息最大的收获是,第三方依赖捆绑包的整体体积从179KB左右减小到119KB左右。60KB的瘦身对于在初始化时就必须加载的关键捆绑包是非常显著的。这也有效减少了可交互时间。你可以在这里了解更多关于优化moment.js的方案。

复用现有的深层依赖

Treebo在初期就使用了“qs”库来处理URL中的查询字符串。从webpack-bundle-analyzer的报告中,他们发现“react-router”包含的“history”库递阶(in-turn)依赖了“query-string”库。

这两个不同的库可以实现相同的操作,他们将源码中将“qs”替换成了上图所示版本的“query-string”(作为显式依赖安装),结果他们的捆绑包进一步减小了2.72KB(gzip压缩后,等于原“qs”库的体积)。

Treebo致力于融入开源社区。他们使用了大量开源软件。作为回报,他们开源了自己大部分的Webpack配置,以及包含了他们很多生产环境配置的样板项目。你在这里可以找到:https://github.com/lakshyaranganath/pwa

他们也许诺了会尽量保持更新这些配置。你可以把它们作为又一个渐进式网页应用的参考实现。

总结与展望

Treebo知道没有哪个应用会是完美的,他们积极地探索更多方式来持续提升用户体验。这包括但不限于:

图片懒加载

你可能已经从前文的网络瀑布图表中发现了,网站图片下载在跟JS下载争抢带宽。

由于浏览器在解析<img>标签时就会触发下载图片,它们在JS下载同时分享网络带宽。一个简单方案是,仅当图片进入用户可视区域(viewport)时才懒加载图片,这会有效改善我们的可交互时间。

Google的Lighthouse工具的屏幕外图片检查项目可以有效排查出这些问题:

双重导入

Treebo意识到虽然他们异步读取了非关键CSS(在内联了关键CSS之后),但随着他们应用功能的演进,这种方式对于他们的用户是不可行的。更多的功能和路由意味着更多CSS,全量下载会造成带宽占用和浪费。

Treebo结合了loadCSSbabel-plugin-dual-import两种方案,改为通过显式调用读取CSS:它们实现了一个定制的importCss(‘chunkname’)方法,用于在import(‘chunkpath’)下载各自JS组块同时下载CSS组块。

不像原来所有CSS都在DOMContentLoaded时下载,在这个新方案中,一次路由跳转会触发两个并发的异步请求,一个请求JS,另一个请求CSS。这是一个更加可行的方案,用户只需下载当前路由所必须的CSS。

A/B测试

Treebo正在基于服务器端渲染和代码分割实现一套A/B测试方案,以确保无论在服务器端还是浏览器端渲染,都只推送用户需要的变体。(Treebo会另外发表一篇博客介绍细节)

贪婪加载(Eager Loading)

理想状况下,Treebo并不希望在页面初始化时就读取所有组块,这是因为他们想避免与关键资源下载争抢带宽——如果没有利用服务工作线程缓存,这也会浪费移动端用户宝贵的带宽资源。如果看一下Treebo在诸如持续可交互时间(Consistently Interactive)这样的指标上的表现,他们还有很大进步空间:

在这个领域他们在做着各种各样的尝试。一个例子是,在按下按钮的水波动画(译者按:猜测是指Material Design的按钮动画)过程中,贪婪加载下一个路由的组块。Treebo调用Webpack的动态导入dynamic import()来读取下一个路由的组块,并用setTimeout延迟路由转换。他们也要确保下一个路由的组块足够小,在较慢的3G网络下400毫秒内可以完成下载。

结尾

这次写作中各方的协作很愉快。很明显我们还有更多工作要做,但我们希望你阅读这篇Treebo改进性能之旅时能体会到乐趣 :)你可以在Twitter上联系到我们@addyosmani@__lakshya(是的,两个下划线XD),我们很乐于听到你们的想法。

感谢@_zouhir@_developit@samcccone的评审和反馈。

如果你刚开始接触React,Wes Bos的React for Beginners是很全面的入门指南。

感谢Jason MillerLakshya Ranganath

译者后记

对撰写技术文章我并不陌生,在工作过程中曾有幸先后发表过英文文章和中文文章。这次的翻译纯粹出于个人兴趣,一是读过英文原文后被原作者的专业风格所打动,二是十一放假期间终于有些自己的时间。没想到这篇翻译比自己写文章更辛苦,很大的一个问题是,前端领域近年发展很快,很多新兴的英文术语国内尚无官方翻译,只得多方搜索查证,比如:

  • JS Bundle:JS捆绑包,这是Google中文文档的翻译,业界也有翻译成“JS束”的;
  • First Paint Time:白屏时间,这个在中文社区最流行的翻译并不是直译,但很准确的表达了含义;
  • Skeleton Screen:加载占位屏,百度能搜得到的名字是加载占位图,但文中是由多个组件组成的,所以我擅自将“图”改为“屏”字。

总体而言,这次翻译我自己收获颇丰,希望之后还有机会翻译这样精彩的文章。