规模化场景下的 Twitter Lite 与高性能 React 渐进式 Web 应用

阅读数:797 2017 年 5 月 3 日

话题:语言 & 开发架构

本文旨在帮助读者了解,在全球规模最大的 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)开发团队。


感谢韩婷对本文的策划和审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ@丁晓昀),微信(微信号:InfoQChina)关注我们。