- 原文地址:Performance-tuning a React application
- 原文做者:Joshua Comeau
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:ZhangFe
- 校对者:atuooo,jonjia
最近几周,我一直在为 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
咱们须要一个能够用来比较结果的基准值!shell
咱们测试的页面是主登陆页的摘要视图,这是数据量最大的页面,所以它也有最大的优化空间
这个摘要部分就像下面这样包含了一组卡片:
每一个节目都有本身的卡片,而且每一集都有本身的一个小方块,蓝色的方块意味着这一集已经被观看了。
这是咱们在 3G 网络下作基准测试的 profile 视图,看起来性能就不怎么样。
首次有效渲染:~5000ms 首张图片加载:~6500ms 全部请求结束:>15,000ms
天哪,直到 5s 左右页面才展现了一些有意义的内容。第一张图片在 6.5s 左右的时候加载完成,全部的网络请求足足花了 15s 才结束。
这个时间线视图提供了一系列的内容。让咱们仔细研究一下这之间究竟发生了什么:
这些数据都来自 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,而且它对页面加载速度形成了巨大的变化。
带着前一步的明显进步,我又回过头来看了下时间线。首次渲染时在 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,咱们获取了能够被动态裁剪的图片。
在客户端,咱们使用 srcset 和 sizes 这两个图片属性来确保图片是基于窗口大小和像素比来提供的:
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,所以页面仍然是可交互的,可是服务端不须要花时间去构建了,由于我已经在代码部署时直接将这些页面构建好了。
还有一个我认为有很大潜力的想法就是将 React 和 ReactDOM 托管到 CDN 上。
Webpack 使得这很容易实现;你能够经过定义 externals 关键字避免将它们打包到你的 bundle 中。
// webpack.config.prod.js
{
externals: {
react: 'React',
'react-dom': 'ReactDOM',
},
}
复制代码
这种方法有两个优点:
我很惊讶的发现,至少在 CDN 未缓存的最坏状况下,将 React 移到 CDN 上并无什么益处:
首次有效渲染时间:2300ms -> 2650ms
你可能会发现 React 和 React DOM 是和个人主要软件包并行下载的,而且它确实拖慢了总体的时间。
我并非想说使用 CDN 是一个坏主意。在这方面我并非很专业而且极可能是我作错了,而不是这个想法的问题!至少在个人 case 里它并无生效。
译者注: 这里将 React 放在 CDN 上的方案,在本地无缓存的状况下很明显没什么优点,由于你的总代码体积不会减小,你的带宽没有变化,JS是并行下载可是串行执行,因此总的下载时间和执行时间并不会有什么优点;反而因为 http 创建连接的损耗可能会减慢速度,这也是咱们说要尽量减小 http 请求的缘由;并且因为是本地测试,CDN 的优点可能并无体现。 可是我以为这种方案仍是可取的,主要有两点:1. 由于有 CDN,能够保证大部分人的下载速度,而放在你的服务器上其实因为传输的问题不少人下载会很是慢;2. 因为将 React 相关的库抽离,后续每次更改代码和发布后这部分代码都是走的缓存,能够减小后续用户的加载时间
经过这篇文章,我但愿传达出两个观点:
举个例子,在一些传统的观念看来,服务端渲染是一个必经之路。可是我在的应用里,基于 local-storage 或者 service-workers 来作前端渲染则是一个更好的选择!也许你能够在编译时作一些工做,减小 SSR 的耗时,又或者学习 Netflix,彻底不将 React 传递给前端!
当你作性能优化时,你会发现这很是须要创造力和开阔的思路,而这也是它最有趣的的地方。
复制代码
很是感谢您的阅读!我但愿这篇文章能给您带来帮助:)。若是您有什么想法能够联系个人 Twitter 。
能够在 Github 上查看 Tello 的源码****🌟
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。