【译】React 应用性能调优

React 应用性能调优

案例研究

最近几周,我一直在为 Tello 工做,这是一个跟踪和管理电视节目的 web app:html

做为一个 web app 来讲,它的代码量是很是小的,大概只有 10,000 行。这是一个基于 Webpack 的 React/Redux 应用,有一个比较轻量的后端 Node 服务(基于 Express 和 MongoDB)。咱们 90% 的代码都在前端。在 Github 上你能够看到咱们的源码。前端

前端性能能够从不少角度来考量。可是从历史角度来看,我更注重于页面加载后的一些点:好比确保滚动的连贯性,以及动画的流畅性。react

相比之下,我对于页面加载时间的关注比较少,至少在一些小型项目上是这样的。毕竟它并不须要传输太多的代码;它确定是很快就能被访问并使用的,对吧?android

然而,当我作了一些基准测试后,我惊奇地发现我这个 10k 行代码的小应用在 3G 网络下竟如此的慢~~,大约 5s 后才能显示一些有意义的内容,而且须要 15s 才能解决全部的网络请求。webpack

我意识到我得在这个问题上投入一些时间和精力。若是人们须要盯着一个空白的屏幕看 5s 的话,那个人动画作的再漂亮也没用了。ios

总而言之,我在这周末尝试了 6 种技术,而且如今只须要 2300ms 左右就能够在页面上展现一些有意义的内容了 —— 减小了大约 50% 的时间!git

这篇博客是我尝试的具体技术的研究案例以及他们的工做状况,更普遍地来讲,这里记录了我在解决问题时所学到的知识,以及我在提出解决方案时的一些思路。github

方法论

全部的分析都使用了相同的设置:web

  • “Fast 3G” 的网速。
  • 桌面端分辨率。
  • 禁止 HTTP 缓存。
  • 已登陆,而且这个帐户关注了 16 个电视节目。

基准值

咱们须要一个能够用来比较结果的基准值!shell

咱们测试的页面是主登陆页的摘要视图,这是数据量最大的页面,所以它也有最大的优化空间

这个摘要部分就像下面这样包含了一组卡片:

每一个节目都有本身的卡片,而且每一集都有本身的一个小方块,蓝色的方块意味着这一集已经被观看了。

这是咱们在 3G 网络下作基准测试的 profile 视图,看起来性能就不怎么样。

首次有效渲染:~5000ms 首张图片加载:~6500ms 全部请求结束:>15,000ms

天哪,直到 5s 左右页面才展现了一些有意义的内容。第一张图片在 6.5s 左右的时候加载完成,全部的网络请求足足花了 15s 才结束。

这个时间线视图提供了一系列的内容。让咱们仔细研究一下这之间究竟发生了什么:

  1. 首先,最初的 HTML 被加载。由于咱们的应用不是服务端渲染的,这部分很是的快。
  2. 以后,开始下载整个 JS bundle。这部分花费了好久的时间。🚩
  3. JS下载完后,React 开始遍历组件树,计算初始化时挂载的状态,而且将它推送到 DOM 上。这部分有一个 header,一个 footer,和一大片的黑色区域。🚩
  4. 挂载 DOM 后,这个应用发现它还须要一些数据,所以它向 /me 发起了一个 GET 请求来获取用户数据,以及他们关心的节目列表和看过的剧集。
  5. 一旦咱们拿到了关键的节目列表,就能够开始请求下面的内容:
    • 每一个节目的图片
    • 每一个节目的剧集列表

这些数据都来自 TV Maze 的 API

  • 你可能会想为何我不在个人数据库里存储这些剧集信息呢,这样我就不须要调用 TV Maze 的接口了。其实缘由主要是 TV Maze 的数据更加真实;它有全部新的剧集的信息。固然,我也能够在第四步的时候在服务端上拉取这些数据,但是这会增长这一步的响应时间,如此一来用户就只能盯着一大片空白的黑色区域了。另外,我喜欢比较轻量的服务端。

还有一个可行方法就是设置一个定时任务,天天都去同步 TV Maze 的数据,而且只在我没有最新数据的时候才会去拉取。不过我仍是喜欢实时的数据,所以这个方案一直都没有实施。

一次明显的提高

目前来看,最大的瓶颈就是初始的 JS bundle 体积太大了,下载它耗费了太多的时间。

bundle 的体积有 526kb,并且目前它尚未被压缩,咱们须要使用 Gzip 来解救它。

经过 Node/Express 的服务端很容易实现 Gzip;咱们只须要安装 compression 模块并将它做为一个 Express 中间件使用就能够了。

const path = require('path');

const express = require('express');
const compression = require('compression');


const app = express();

// 只须要将 compression 做为一个 Express 中间件!
app.use(compression());

app.use(express.static(path.join(rootDir, 'build')));
复制代码

经过使用这个很是简单的解决方案,让咱们看看咱们的时间线有什么变化:

首次有效渲染:5000ms -> 3100ms 首张图片加载:6500ms -> **4600ms **全部数据加载完成:6500ms -> **4750ms **全部图片加载完成:~15,000ms -> ~13,000ms

代码体积从 526kb 压缩到只有 156kb,而且它对页面加载速度形成了巨大的变化。

使用 LocalStorage 缓存

带着前一步的明显进步,我又回过头来看了下时间线。首次渲染时在 2400ms 时触发的,但此次并无什么意义。3100 ms 时才真正有内容展现,可是直到 5000ms 左右才获取到全部的剧集数据。

我开始考虑使用服务端渲染,可是这也解决不了问题。服务端仍须要调用数据库,而后调用 TV Maze 的 API。更糟糕的是,在这段时间里用户只能傻盯着白花花的屏幕。

若是使用 local-storage 呢?咱们能够把全部的状态变动都存储到浏览器上,并在用户数据返回的时候对这个本地状态进行补充。首屏的数据多是旧的,可是不要紧!真实的数据很快就能加载回来,而且这会使得首次加载的体验很是快。

由于这个 app 使用了 Redux,因此持久化数据是很是简单的。首先,咱们须要一个方案来保证 Redux 状态变化时更新 localStorage:

import { LOCAL_STORAGE_REDUX_DATA_KEY } from '../constants';
import { debounce } from '../utils'; // generic debounce util

// 当咱们的页面首次加载时,一堆 redux actions 会迅速被 dispatch
// 每一个节目都要获取它们的剧集,因此最小的 action 数量是 2n (n 是节目的数量)
// 咱们不须要太过于频繁的更新 localStorage,能够对他作 debounce
// 若是传入 null,咱们会抹去数据,一般用来在登陆登出时消除持久状态
const updateLocalStorage = debounce(
  value =>
    value !== null
      ? localStorage.setItem(LOCAL_STORAGE_REDUX_DATA_KEY, value)
      : localStorage.removeItem(LOCAL_STORAGE_REDUX_DATA_KEY),
  2500
);


// store 更新时,将相关部分存储到 localStorage 中
export const handleStoreUpdates = function handleStoreUpdates(store) {
  // 忽略 modals 和 flash 消息,他们不须要被存储
  const { modals, flash, ...relevantState} = store.getState();

  updateLocalStorage(JSON.stringify(relevantState));
}

// 在退出登陆时用来清除数据的一个函数
export const clearReduxData = () => {
  // 当即清除存储在 localStorage 中的数据
  window.localStorage.removeItem(LOCAL_STORAGE_REDUX_DATA_KEY);


  // 由于删除是同步的,而持久化数据是异步的,所以这里会致使一个微妙的 bug:
  // 存储的数据会被删除,可是稍后又会被填充上
  // 为了解决这个问题,咱们会传入一个 null,来终止当前队列全部的更新
 
  updateLocalStorage(null);
  
  // 咱们须要触发异步和同步的操做。
  // 同步操做保证数据能够马上被删除,因此若是用户点击退出后马上关闭页面,数据也能被删除
};
复制代码

下一步,咱们须要让 Redux store 订阅这个函数,以及用前一次会话的数据对它进行初始化。

import { LOCAL_STORAGE_REDUX_DATA_KEY } from './constants';
import { handleStoreUpdates } from './helpers/local-storage.helpers';
import configureStore from './store';


const localState = JSON.parse(
  localStorage.getItem(LOCAL_STORAGE_REDUX_DATA_KEY) || '{}'
);

const store = configureStore(history, localState);

store.subscribe(() => {
  handleStoreUpdates(store);
});
复制代码

虽然还有几个遗留的小问题,可是得益于 Redux 架构,咱们只作了一些很小的改动就完成了大部分的功能。

让咱们再来看看新的时间线:

棒极了!虽然经过这些很小的截屏很难说明什么,可是咱们在 2600ms 时的那次渲染已经能够展现一些内容了;它包括一个完整的节目列表以及从以前的会话里保存的剧集信息。

首次有效渲染:3100ms -> **2600ms **获取剧集数据:4750ms -> 2600ms (!)

虽然这并无影响到实际的加载时间(咱们仍然须要调用哪些 API,而且在这上面耗时),可是用户能够直接拿到数据,因此感知速度的提高很是明显。

在内容已经出现的状况下,页面仍在继续变化,这是一种很是流行的技术,可让页面更快地展示,而且当新的内容可用时,页面发生更新。但是我更喜欢当即呈现最终的 UI。

这个方案在一些 non-perf 的状况下有一些额外的优点。举个例子,用户能够更改节目的顺序,但可能因为会话的结束致使数据丢失了。如今,当他们返回页面时,以前的偏好仍是被保存了下来!

可是,这也有一个缺点:我不清楚你是否在等待新的数据加载。我计划在角落里添加一个加载框以显示是否还有其余请求正在加载。

另外,你可能会想“这对于老用户来讲可能不错,可是对于新用户并无什么用处!”。你说的没错,但实际上,这也确实不适用于新用户。新用户并无关注的节目,只有一个引导他们添加节目的提示,所以他们的页面加载的很是快。因此,对于全部的用户来讲,无论是新用户仍是老用户,咱们都已经有效避免了那种一直盯着黑屏的体验。

图片和懒加载

即便有了这个最新的改进,图片的加载仍然花费了不少的时间。这个时间线里没有展现出来,可是在 3G 网络下,全部的图片加载一共耗费了超过 12 秒。

缘由很简单:TV Maze 返回了一张巨大的电影海报风格的照片,然而我只须要一个狭长的条状图,用于帮助用户一眼就能分辨出节目。

左边:被下载的图片 ················ 右边:真正用到的图片

为了解决这个问题,我一开始的想法是使用一个相似于 ImageMagick 的 CLI 工具,我在制做 ColourMatch 时使用过它。

当用户添加一个新的节目时,服务端将请求一个图片的副本,使用 ImageMagick 将图片的中间裁剪出来并发送给 S3,而后客户端会使用 S3 的 url 而非 TV Maze 的图片连接。

不过,我决定使用 Imgix 来完成这个功能。Imgix 是一个基于 S3(或者其余云存储提供商) 的图片服务,它容许你动态建立裁剪过或者调整了大小的图片。你只须要使用下面这样的连接,它就会建立并提供合适的图片。

https://tello.imgix.net/some_file?w=395&h=96&crop=faces
复制代码

它还有一个优点就是可以找到图片中有趣的区域并作裁剪。你会注意到,在上面的左/右照片对比中,它将 4 个骑车的孩子裁剪了出来,而非仅仅裁剪出图片的中心

为了配合 Imgix 的工做,你的图片须要可以经过 S3 或者相似的服务被获取到。这里是一段个人后端代码片断,当添加一个新的节目时会上传一张图片:

const ROOT_URL = 'https://tello.imgix.net';

const uploadImage = ({ key, url }) => (
  new Promise((resolve, reject) => {
    // 有些状况下节目没有一个连接,这时候跳过这种状况
    if (!url) {
      resolve();
      return;
    }

    request({ url, encoding: null }, (err, res, body) => {
      if (err) {
        reject(err);
      }

      s3.putObject({
        Key: key,
        Bucket: BUCKET_NAME,
        Body: body,
      }, (...args) => {
        resolve(`${ROOT_URL}/${key}`);
      });
    });
  })
);
复制代码

经过对每一个新的节目调用这个 Promise,咱们获取了能够被动态裁剪的图片。

在客户端,咱们使用 srcsetsizes 这两个图片属性来确保图片是基于窗口大小和像素比来提供的:

const dpr = window.devicePixelRatio;

const defaultImage = 'https://tello.imgix.net/placeholder.jpg';

const buildImageUrl = ({ image, width, height }) => (`
  ${image || defaultImage}?fit=crop&crop=entropy&h=${height}&w=${width}&dpr=${dpr} ${width * dpr}w
`);


// Later, in a render method:
<img
  srcSet={`
    ${buildImageUrl({
      image,
      width: 495,
      height: 128,
    })},
    ${buildImageUrl({
      image,
      width: 334,
      height: 96,
    })}
  `}
  sizes={`
    ${BREAKPOINTS.smMin} 334px,
    495px
  `}
/>
复制代码

这确保了移动设备能获取更大版本的图像(由于这些卡片占据了整个视口的宽度),而桌面客户端获得的是一个较小的版本。

懒加载

如今,每张图片都变小了,可是咱们仍是一次性加载了整个页面的图片!在个人大型桌面窗口上,每次只能看到 6 个节目,可是咱们在页面加载的时候一次性获取了所有的 16 张图片。

值得庆幸的是,有一个很棒的库 react-lazyload 提供了很是便利的懒加载功能。代码示例以下:

import LazyLoad from 'react-lazyload';

// In some render method somewhere:
<LazyLoad once height={UNITS_IN_PX[6]} offset={50}>
  <img
    srcSet={`...omitted`}
    sizes={`...omitted`}
  />
</LazyLoad>
复制代码

来吧,让咱们再来看看时间线。

咱们的首次有效渲染时间没什么变化,可是图片加载的时间有了明显的下降:

首张图片:4600ms -> 3900ms 全部可见范围内的图片:~9000ms -> 4100ms

眼尖的读者可能已经注意到了,这个时间线上只下载了 6 集的数据而不是所有的 16集。由于我最初的尝试(也是我记忆中惟一一个尝试)就是懒加载节目卡片,而并不只仅是懒加载图片。

不过,相比我这周末解决的问题,它也引起了更多的问题,所以我对它进行了一些简化。可是这并不会影响图片加载时间的优化。

代码分割

我敢确定,代码分割是一个很是明智的决定。

由于如今有一个显而易见的问题,咱们的代码 bundle 只有一个。让咱们使用代码分割来减小一个请求所须要的代码量!

我使用的路由方案是 React Router 4,它的文档上有一个很简单的建立 <Bundle /> 组件的例子。我设置了几个不一样的配置,可是最终代码并无比较有效的分割。

最后,我将移动端和桌面端的视图作了分离。移动版有本身的视图,它使用了一个滑动库,一些自定义的静态资源和几个额外的组件。使人吃惊的是,这个分离出来的 bundle 很是的小 —— 压缩前大概只有 30kb —— 可是它仍是带来了一些显著的影响:

首次有效渲染:2600ms -> 2300ms 首张图片加载:3900ms -> 3700ms

经过此次尝试让我学到了一件事:代码分割的效果很大程度上取决于你的应用类型。在我这个 case 里,最大的依赖就是 React 和它生态系统里的一些库,然而这些代码是整站都须要的而且不须要被分离出来

在页面加载时,咱们能够在路由层面对组件进行分割以得到一些边际效益,可是这样的话,每当路由变化时都会形成额外的延迟;到处都要处理这种小问题并不有趣。


一些其余方法的尝试和思考

服务端渲染

个人想法是在服务端渲染一个 "shell" —— 一个有正确布局的占位图,只是没有数据。

可是我预见到一个问题,由于客户端已经经过 localStorage 获取前一次会话的数据了,而且它使用这个数据进行了初始化。可是此时服务端是不知情的,因此我须要处理客户端与服务器之间的标记不匹配。

我认为虽然我能够经过 SSR 将个人首次有效渲染时间减小半秒,可是在那时整个网站都是不能交互的;当一个网站看起来已经准备好了但其实不是的时候,让人以为很是奇怪。

另外,SSR 也会增长复杂性,而且下降开发速度。性能很重要,可是足够好就够了。

有一个我很感兴趣可是没时间研究的问题是 —— 编译时 SSR。它可能这只适用于一些静态页面,好比登出页,可是我以为它是很是有效的。做为我构建过程的一部分,我会建立并持久化存储 index.html,并经过 Node 服务器将它做为一个纯 HTML 文件提供给用户。客户端仍然会下载并运行 React,所以页面仍然是可交互的,可是服务端不须要花时间去构建了,由于我已经在代码部署时直接将这些页面构建好了。

CDN 的依赖

还有一个我认为有很大潜力的想法就是将 React 和 ReactDOM 托管到 CDN 上。

Webpack 使得这很容易实现;你能够经过定义 externals 关键字避免将它们打包到你的 bundle 中。

// webpack.config.prod.js
{
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
  },
}
复制代码

这种方法有两个优点:

  • 从 CDN 获取一个流行的库,它有很大可能已经被用户缓存了
  • 依赖关系能够被并行化,能够同时下载你的代码,而不是下载一个大文件

我很惊讶的发现,至少在 CDN 未缓存的最坏状况下,将 React 移到 CDN 上并无什么益处:

首次有效渲染时间:2300ms -> 2650ms

你可能会发现 React 和 React DOM 是和个人主要软件包并行下载的,而且它确实拖慢了总体的时间。

我并非想说使用 CDN 是一个坏主意。在这方面我并非很专业而且极可能是我作错了,而不是这个想法的问题!至少在个人 case 里它并无生效。

译者注: 这里将 React 放在 CDN 上的方案,在本地无缓存的状况下很明显没什么优点,由于你的总代码体积不会减小,你的带宽没有变化,JS是并行下载可是串行执行,因此总的下载时间和执行时间并不会有什么优点;反而因为 http 创建连接的损耗可能会减慢速度,这也是咱们说要尽量减小 http 请求的缘由;并且因为是本地测试,CDN 的优点可能并无体现。 可是我以为这种方案仍是可取的,主要有两点:1. 由于有 CDN,能够保证大部分人的下载速度,而放在你的服务器上其实因为传输的问题不少人下载会很是慢;2. 因为将 React 相关的库抽离,后续每次更改代码和发布后这部分代码都是走的缓存,能够减小后续用户的加载时间


结论

经过这篇文章,我但愿传达出两个观点:

  1. 小型程序的开箱即用性很是高,可是一个周末就能够带来一个巨大的提高。这要感谢 Chrome 开发者工具,它能够帮你快速确认项目的瓶颈,而且让你惊讶的发现项目里有如此多的性能洼地。也能够将一些复杂的任务交给像 Imgix 这样的低成本或者免费的服务商。
  2. 每一个应用都是不一样的,这篇文章详细介绍了 Tello 的一些技巧,可是这些技巧的关注点比较特别。即便这些技巧不适用于你的应用,但我但愿我已经把理念表达清楚了:性能取决于 web 开发者的创造性。

举个例子,在一些传统的观念看来,服务端渲染是一个必经之路。可是我在的应用里,基于 local-storage 或者 service-workers 来作前端渲染则是一个更好的选择!也许你能够在编译时作一些工做,减小 SSR 的耗时,又或者学习 Netflix,彻底不将 React 传递给前端

当你作性能优化时,你会发现这很是须要创造力和开阔的思路,而这也是它最有趣的的地方。
复制代码

很是感谢您的阅读!我但愿这篇文章能给您带来帮助:)。若是您有什么想法能够联系个人 Twitter

能够在 Github 上查看 Tello 的源码****🌟


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索