本文旨在帮助读者了解,在全球规模最大的React.js
PWA之一——Twitter Lite当中,是如何消除各类常规与罕见之性能瓶颈的。
想要构建一款性能出色的Web应用程序,我们需要投入大量技术周期以检测时间浪费点、了解其发生原因并尝试各类解决方案。遗憾的是,这种做饭往往无法快速解决问题。性能无疑是一项永恒的命题,技术人员永远徘徊在观察与测量当中,却几乎永远找不到最优解。不过利用Twitter
Lite,我们已经在众多层面内取得了细小但却极具价值的改进:从初始加载时间到React组件渲染(防止二次渲染),再到图像加载以及更多层面。尽管大多数变更本身并不显著,但其相加所带来的最终结果是,我们得以构建起一款规模极大且速度极快的渐进式Web应用程序。
阅读说明如果你刚刚开始投身Web应用程序性能提升的测量工作,我强烈建议您首先了解如何读取火焰图信息。
后文中的各个章节皆包含有截取自Chrome开发者工具内的时间线记录。为了更易于理解,我们在每项示例中强调了哪些信息代表情况不利,而哪些代表情况正常。
这里需要特别就时间线与火焰图进行说明:由于我们需要对大量移动设备进行观察,因此通常只在模拟环境中记录CPU速度仅为五分之一且使用3G网络连接的情况。这些条件不仅更为现实,同时亦更易于暴露性能问题。在使用React
v15.4.0的组件配置时,甚至会对运行配置加以进一步压缩。桌面性能时间线中的实际值将远高于我们在本文中列举的示例值。
一、面向浏览器进行优化
1. 使用基于路由的代码拆分机制
Webpack虽然极为强大,但却难于学习。我们也曾经遭遇到CommonsChunkPlugin问题,且很难弄清其与我们部分循环代码依赖性的对接方式。考虑到这一点,我们最终只保留了3个JavaScript资源文件,且总计略大于1
MB(gzip传输格式则为420 KB)。
在站点运行过程中,加载数个甚至单一大型JavaScript文件都可能给移动用户的网站浏览与交互带来巨大性能瓶颈。除了各大型脚本在传输过程中需要消耗更多网络资源及传输时长之外,浏览器的解析工作量也将因此有所提升。
在经过多次争论之后,我们最终得以利用路由机制将常规区域拆分成多个独立块(如下所示)。
const
plugins = [
// extract vendor and webpack's module manifest
new webpack.optimize.CommonsChunkPlugin({
names: [ 'vendor', 'manifest' ],
minChunks: Infinity
}),
// extract common modules from all the chunks
(requires no 'name' property)
new webpack.optimize.CommonsChunkPlugin({
async: true,
children: true,
minChunks: 4
})
]; |
最后,我们在收件箱中收到了这样一份代码审查结论:
添加了细粒度、基于路由的代码拆分机制。应用整体的初始化速度与HomeTimeline渲染速度皆有所改善,且目前的应用被拆分为40个独立块,并根据会话长度进行时间配额均摊。-
Nicolas Gallagher
如图所示,时间线由代码拆分前状态(图1)转化为之后状态(图2)。
图1
图2
我们的初始设置(图1)需要5秒种才能完成主捆绑包的加载,但在利用路由机制与常规区块对代码进行拆分后(图2),加载时间降低到了3秒(模拟3G网络环境下)。
这一突出性能提升在此前的就得到了关注,但单凭这一项变更,即令谷歌Lighthouse Web应用审计工具的运行速度出现巨大变化:
图3
我们还通过运行谷歌Lighthouse Web应用审计工具了解此前(图3 Before)与此后(图3
After)的性能差异。
2.避免使用可能造成跳帧的函数
在对我们无限滚动时间线进行多次迭代的过程中,我们尝试使用多种不同方法以计算滚动位置及方向,旨在确定是否有必要要求API显示更多推文内容。就在不久之前,我们还在使用react-waypoint,且获得了不错的效果。然而为了将性能水平提升至新的高度,其作为我们应用程序的主要底层组件之一仍无法在速度上满足要求。
Waypoints的工作方式为计算大量不同元素的高度、宽度与位置,从而确定用户的当前滚动位置、每次操作之间的相隔距离以及具体指向哪个方向。这些信息虽然确实有用,但由于需要在每一次滚动事件时进行处理,因此会带来相应成本——即此类计算会导致跳帧问题,且发生频率极高。
但在解决问题之前,我们首先需要理解开发者工具所给出的“跳帧”结论究竟是什么含义。
目前大多数设备会每秒对屏幕显示内容进行60次刷新。如果其中运行有动画或者过渡效果,抑或用户进行页面滚动操作,则浏览器需要匹配设备的刷新率并提供一张新的图像——或者称为帧——以作为每次屏幕刷新的显示内容。
其中每一帧的持续时间约为略高于16毫秒(即1秒的六十分之一,约为16.66毫秒)。不过在实际场景中,浏览器仍有其它管理任务需要处理,因此整个刷新内容的生成时间约在10毫秒左右。如果无法满足这一条件,则帧显示速率将有所下降,导致屏幕上的内容出现跳动。这种现象通常被称为跳帧,且会给用户的体验造成负面影响。—?Paul
Lewis 著于《渲染性能》
随着时间的推移,我们开发出一种新的无限滚动组件,并将其命名为VirtualScroller。利用这款新组件,我们能够确切了解特定时段的特定时间轴中哪部分推文片段需要进行渲染,从而避免为了呈现视觉效果而进行需要占用大量资源的计算任务。
图4
图5
虽然看起来问题并不严重,但之前(图4)进行滚动时,我们由于需要计算多个元素的高度而引发了渲染跳帧问题。之后(图5),我们不仅彻底摆脱了跳帧,亦减少了卡顿并提升了时间轴滚动速度。
通过避免调用那些可能引发不必要跳帧的函数,推文的时间轴滚动变得更为无缝,这意味着我们能够提供更为丰富且几乎与原生应用无异的使用体验。更值得一提的是,这项变更还给时间轴的滚动顺滑度带来提升。这再次证明,每一项小改进都将积累起来并最终实现理想性能表现。
3.使用更小图像
为了在Twitter Lite上率先使用较低传输带宽资源,我们配合多个团队对CDN上的可用图像进行了更新与尺寸调整。事实证明,通过降低图像尺寸,我们得以显著降低所需要渲染的实际工作量(包括规模与质量),并发现此举不仅能够降低传输带宽占用率,同时亦能够提升浏览器的性能表现——特别是在对包含大量图像的推文时间轴进行滚动操作时。
为了核实小尺寸图像给性能带来的确切提升,我们对Chrome开发者工具中的Raster时间线进行了观察。在对图像尺寸进行瘦身之前,解码单一图像的时间一般为300毫秒甚至更长,具体如以下时间线记录图左侧所示。这一过程发生在图像内容下载完成之后,且需要经过处理,图像才能在页面中得到正确显示。
当滚动页面并希望符合每秒60帧渲染标准要求时,我们希望尽可能将每帧显示内容的渲染时间控制在16.667毫秒以内。通过计算,这意味着我们需要近18帧才能将单一图像渲染完成并显示在视图内,效果显然不够理想。另一项需要注意的时间指标在于,大家可以看到,Maine时间线会持续受到阻断,直到对应图像完成解码(如空白区所示)。这意味着这正是我们要找的性能瓶颈!
图6
图7
较大图像(图6)将在18帧周期内阻碍主线程的运行,而较小图像(图7)则仅需要1帧左右。
现在我们已经对图像尺寸进行了削减(图6),而尺寸最大的图像如今仅需要1帧周期即可完成解码。
二、优化React
1.使用shouldComponentUpdate方法
对React应用程序进行性能优化的一种常见作法在于使用shouldComponentUpdate方法。我们一直在尽可能使用这一方法,但有时效果并不尽如人意。
图8
赞第一条推文会导致其本身以及其下的整个Conversation进行重新渲染!
下面我们来看一个始终保持更新的组件救命:当在主时间线中点击心形图标以赞一条推文时,当前屏幕上的全部Conversation组件都将进行重新渲染。在动画示例当中,大家可以看到绿色的高亮框体,这是因为我们的操作导致当前推文之下的整个Conversation组件皆进行更新,而浏览器需要对其进行重新填充。
以下为对这一操作进行概括的两幅火焰图。在未使用shouldComponentUpdate方法(图9)时,我们可以看到整体树状结构皆进行了更新与重新渲染,而效果仅为对屏幕上的心形图标进行着色。而在添加了shouldComponentUpdate方法(图10)之后,我们无需更新整个树状结构,从而通过避免运行不必要进程而节约了十分之一秒处理时间。
图9
图10
之前(图9),在赞某条非相关推文时,整个Conversations皆进行更新及重新渲染。而在添加该逻辑(图10)之后,可以看到该组件及其各子元素不再浪费不必要的CPU周期。点击或点触进行放大。
2.将不必要任务推迟至componentDidMount之后
这一变更似乎非常简单,但在开发Twitter Lite这类大型应用程序时却很容易被忽略。
我们发现,我们的原有代码中存在大量立足componentWillMount React生命周期方法进行高资源占用量计算分析的情况。每一次此类计算都会给其它组件的渲染造成妨碍。这里20毫秒,那里90毫秒,最终的性能拖累将非常沉重。最初,我们曾尝试将实现进行渲染的每条推文进行记录,并将结果写入至componentWillMount中的数据分析服务中,而后才对其进行实际渲染(如下图左侧时间线所示)。
图11
图12
通过将非必要代码路径由componentWillMount推迟至componentDidMount,我们得以节约了大量当前屏幕内的推文渲染时长。点击或点触进行放大。
通过将计算与网络调用转移至React组件的componentDidMount方法中,我们得以解除对主线程的效率妨碍,同时减少了对各组件进行渲染时的意外跳帧状况(图12所示)。
3.避免使用dangerouslySetInnerHTML
在Twitter Lite当中,我们选择使用SVG图标,因为其极具可移植性且是最为理想的可扩展选项。遗憾的是,在旧有React版本当中,大部分SVG属性在立足组件进行元素创建时并不受支持。因此,在最初开始编写这款应用程序时,我们被迫通过dangerouslySetInnerHTML以将SVG图标作为React组件进行使用。
举例来说,我们的原始HeartIcon如下所示:
const
HeartIcon = (props) => React.createElement('svg',
{
...props,
dangerouslySetInnerHTML: { __html: '<g><path
d="M38.723 12c-7.187 0-11.16 7.306-11.723
8.131C26.437 19.306 22.504 12 15.277 12 8.791
12 3.533 18.163 3.533 24.647 3.533 39.964 21.891
55.907 27 56c5.109-.093 23.467-16.036 23.467-31.353C50.467
18.163 45.209 12 38.723 12z"></path></g>'
},
viewBox: '0 0 54 72'
}); |
这里需要强调一点,我们不仅不鼓励使用dangerouslySetInnerHTML,更重要的是,事实证明其正是导致一系列挂载与渲染缓慢问题的源头。
图13
图14
之前(图13),可以看到挂载4个SVG图标需要约20毫秒,而之后(图14)则仅需要8毫秒。点击或点触进行放大。
通过对以上火焰图进行分析,我们的原始代码(图13)显示其在低配置设备上需要20毫秒方可完成推文底部4个SVG图标的挂载操作。虽然就本身而言时耗并不夸张,但考虑到大量推文滚动操作情况,我们意识到这会造成巨大的时间浪费。
由于React v15对大部分SVG属性提供支持,因此我们开始尝试并希望了解不再使用dangerouslySetInnerHTML会带来怎样的效果。通过检查升级版本的火焰图(图14),我们得以将每组图标的挂载与渲染时间平均缩短60%!
现在,我们的SVG图标属于简单的无状态组件,且不再使用“dangerous”函数,且挂载速度平均提升60%。具体如下:
const
HeartIcon = (props = {}) => ( <svg {...props}
viewBox='0 0 ${width} ${height}'> <g><path
d='M38.723 12c-7.187 0-11.16 7.306-11.723 8.131C26.437
19.306 22.504 12 15.277 12 8.791 12 3.533 18.163
3.533 24.647 3.533 39.964 21.891 55.907 27 56c5.109-.093
23.467-16.036 23.467-31.353C50.467 18.163 45.209
12 38.723 12z'></path></g>
</svg>
); |
4.在挂载及卸载大量组件时推迟渲染
在低配置设备当中,我们注意到自己的主导航栏可能需要相当长的时间才能够完成对多项点触操作的正确响应,这会导致用户误以为第一次点触未能奏效并进行反复尝试。
通过图15可以看到,我们的Home图标耗时近2秒才完成更新并对点触操作作出响应:
图15
如果不对渲染进行推迟,则导航栏需要较长耗时才能开始响应。
请别误会,这绝不是由于运行GIF所造成的帧率缓慢。事实上,其速度确实令人无法忍受,但此次Home屏幕中的全部数据都已经加载完成——那么,为什么仍需要长长时间才能将全部内容正确显示出来?
事实证明,大型组件树状结构(例如推文时间轴)的挂载与卸载在React中会消耗大量计算资源。
作为最简单的要求,我们希望解决这一导航栏无法响应用户输入操作的状况。因此,我们创建了一个小型HigherOrderCompoent组件:
import
hoistStatics from 'hoist-non-react-statics';
import React from 'react';
/**
* Allows two animation frames to complete to
allow other components to update
* and re-render before mounting and rendering
an expensive `WrappedComponent`.
*/
export default function deferComponentRender(WrappedComponent)
{
class DeferredRenderWrapper extends React.Component
{
constructor(props, context) {
super(props, context);
this.state = { shouldRender: false };
}
componentDidMount() {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => this.setState({
shouldRender: true }));
});
}
render() {
return this.state.shouldRender ? <WrappedComponent
{...this.props} /> : null;
}
}
return hoistStatics(DeferredRenderWrapper,
WrappedComponent);
} |
我们的HigherOrderComponent由 Katie Sievert编写。
在被应用于我们的HomeTimeline之后,我们发现导航栏能够实现近即时响应,这极大提高了应用程序的整体使用感受。
const
DeferredTimeline = deferComponentRender(HomeTimeline);
render(<DeferredTimeline />); |
图16
在推迟渲染之后,导航栏能够实现立即响应。
三、优化Redux
1.避免频繁进行状态存储
尽管组件控制往往被作为理想的实践方案,但事实证明控制输入内容会导致每一次按键皆造成更新与重新渲染。
这一点在主频高达3 GHz的台式计算机上并不是问题,但对于CPU性能较为有限的小型移动设备而言,用户将在输入时遭遇明显的延迟——特别是在对输出内容中的大量字符进行删除时。
为了保留当前所输入的推文值并计算剩余可输入字符数,我们使用一项受控组件并在每次按键时将输入内容中的当前值传递至我们的Redux状态内。
图17为一款典型的Android 5设备,每次按键带来的变更都会导致约200毫秒的延迟。如果用户输入速度很快,则应用的实际运行状态将非常糟糕。事实上,用户经常报告称其字符插入点会到处乱窜并导致输入内容陷入混乱。
图17
图18
在使用与不使用Redux两种情况下,对每次按键的更新速度进行对比。点击或点触进行放大。
通过阻止每次按键后将草稿推文状态传递至主Redux状态并将其保留在React组件的本地状态内,我们得以将延迟水平降低超过50%(图18)。
2.将批量操作合并为单一调度
在Twitter Lie当中,我们利用redux配合react-redux以将组件确保各组件能够订阅数据状态变更。我们还对数据进行了优化,即利用Normalizr与combineReducers将其拆分为单一大型存储内容中的多个独立区间。这一切最终有效避免了数据重复并确保我们的存储量保持在较低水平。然而,每一次获取到新数据,我们都需要调度多项操作以将此新数据添加至适合的存储库内。
考虑到react-redux的工作方式,这意味着每项调度操作都将导致我们的连接组件(被称为Containers,即容器)需要重新计算变更并可能需要进行重新渲染。
尽管我们使用了一款定制化中间件,但仍存在其它大量中间件可供选择。大家可以按照需求从中挑选或者编写您自己的定制中间件。
判断批量操作收益的最佳方式在于使用Chrome React Perf扩展。在初始加载时,我们在后台中对未读取DM进行预缓存及计算。在此过程中,我们会向其中添加大量功能实体(包括会话、用户、消息条目等)。在未进行批量调度前(图19),大家可以看到每一组件的渲染次数(约16次)约为使用批量调度后(图20,约8次)的2倍。
图19
图20
利用Chrome React Perf扩展对批量调度前(图19)与批量调度后(图20)的Redux渲染次数进行比较。点击或点触进行放大。
四、Service Workers
尽管目前Service Workers尚未得到全部浏览器的支持,但其已经成为Twitter
Lite中极具价值的组成部分。在使用Service Workers的情况下,我们能够利用其推送通知并预缓存应用程序资产。遗憾的是,由于其尚属于一种新兴技术,因此我们还需要进行深入研究以了解其性能特性。
1.预缓存资源
与大多数产品一样,Twitter Lite的开发工作还远未完成。我们正在积极对其进行拓展、添加新功能、修复bug并提升其运行速度。这意味着我们需要频繁部署新的JavaScript资产版本。
遗憾的是,这可能会给该应用程序的用户带来困扰,迫使其重新下载大量脚本文件以查看推文内容。
在支持Service Workers的浏览器当中,我们得以确保各工作程序在后台中以自动化方式更新、下载并缓存各变更文件,从而以不影响用户的方式完成升级。
那么这一切能够给用户带来怎样的收益?具体来讲,其能够以几乎即时方式完成后续应用版本加载。
未启用ServiceWorker预缓存(图21)与启用预缓存(图22)情况下的网络资产加载时间。点击或点触进行放大。
图21
图22
如大家所见(图21),在未启用ServiceWorker预缓存机制的情况下,当前视图中的每一项资产都需要从网络处加载并返回至应用程序处。在良好的3G网络环境下,这一加载过程仍需要约6秒方可结束。然而在启用ServiceWorker的预缓存机制后(图22),同样的3G网络可在1.5秒以内完成页面加载——性能提升高达75%!
2。推迟ServiceWorker注册
在大多数应用程序当中,我们能够安全地将ServiceWorker立即注册至加载页面当中:
<script>
window.navigator.serviceWorker.register('/sw.js');
</script> |
然而考虑到我们需要向浏览器发送大量数据以渲染出完整的页面内容,因此在Twitter
Lite中这一切往往无法实现。我们可能无法快速发送充足的数据,或者您所登陆的页面并不支持对来自服务器的数据进行预填充。由于这一点外加其它一些限制,我们需要在初始页面加载后立即生成部分API请求。
一般来讲,这种作法并不会带来负面影响。然而如果目标浏览器尚未安装当前版本的ServiceWorker,我们则需要要求其安装——这会带来用于对多项JS、CSS以及图像资产进行预缓存的约50项请求。
当我们简单对ServiceWoker进行立即注册时,可以看到浏览器内会立即进行网络连接,且直接到达我们的并发请求数量上限。
图23
图24
请注意,在立即对Service Worker进行注册时,其会阻碍全部其它网络请求(图23)。推迟Service
Worker注册(图24)允许我们对页面加载内容进行初始化,从而在并发请求上限之内完成必要的网络请求。点击或点触进行放大。
通过将ServiceWorker注册推迟至其它API请求、CSS与图像资产加载完成之后,我们能够保证页面完成渲染并具备响应能力,具体如截图所示(图24)。
五、本文小结
总体而言,本文只列出了我们在Twitter Lite当中所实现的部分改进。未来我们还将在Twitter
Lite中作出更多尝试,并继续分享我们在期间发现的问题以及克服困难的具体方法。欲了解更多与我们当前开发进度与React及PWA分析结论的信息,请关注我(https://mobile.twitter.com/paularmstrong)及
Twitter Lite(https://mobile.twitter.com/paularmstrong/lists/twitter-lite/members)开发团队。
|