React 应用页面首次加载通常比后端渲染静态页面慢,这对用户体验有着极大的负面影响,尤其是移动端。服务器端渲染(SSR)是非常有效的手段,但实现成本过高,不止要调整技术架构,也会增加服务器负载。那么在不使用 SSR 的前提下,如何优化 React 应用的首屏加载速度呢?
加载过程分析
使用 Firefox 开发者工具网络面板对页面加载过程中的网络流量进行监控,是最常用的分析方法。
注意右侧的进度瀑布图有两根线条,一根红色,一根蓝色:
- 蓝色是 DOMContentLoaded 事件触发的时间点
- 红色是 load 事件触发的时间点
但是对于 React 应用而言,以上两个事件并不标志着页面内容已加载完成。React 应用通常会需要请求一些 JSON 接口(比如商品信息),然后再加载 UI 组件(比如商品列表)。实际加载时间,我们可以通过 performance.now() 接口在组件实际挂载(componentDidMount 或 useEffect)时获取:
import React, { useEffect, useState } from 'react';
function App() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('api/products').then(res => res.json()).then(setProducts);
}, []);
if (products?.length > 0) {
return <ProductList products={products} />
} else {
return <div>Loading...</div>;
}
}
function ProductList({ products }) {
useEffect(() => {
console.log('ProductList loaded in ' + performance.now()/1000 + 's');
}, []);
return products.map(({name}) => <div>{name}</div>);
}
本地的测试分析不能反映实际用户的加载速度。建议将加载时长结果上报到服务器,进行统计分析,制作按日/小时的趋势图,以及按加载秒数的分布图。统计结果将有助于判断后续的优化工作的效用有多大。
数据加载优化
首屏展示依赖的数据,后端直接注入到 HTML 中,从而减少网络请求数量。
<script>
window.user = {{ JSON.stringify(user) }};
window.productInfo = {{ JSON.stringify(productInfo) }};
window.translations = {{ JSON.stringify(translations) }};
</script>
<script src="path/to/js"></script>
要小心控制注入 HTML 中的数据数量,过大的 HTML 可能会导致页面加载失败的概率增加。首屏不展示的数据,或者不重要的数据要排除:Select 的数据源,弹窗中用到的数据,通知数据等。翻译数据最好按首屏需要做一次切分,且只透出当前用户语言。
从实践经验来看,这一项措施,对加载速度提升最为明显。
图标加载优化
icon font 是图标加载方式的主流,但加载字体文件会多发出一个请求。且字体请求通常是在 CSS 中触发,会阻塞页面加载。另外,字体文件很难根据首屏内容进行切分,不够灵活。某些大型应用,图标高达上百个,加载数 MB 的图标字体会让体验变得很差。
更好的方案是用 SVG Sprite 直接嵌入 HTML 中,或用 Inline SVG 直接嵌入 JS 中,减少一次网络请求,同时只加载首屏需要的图标。
打包尺寸优化
首先,建议所有使用 Webpack 4 的项目迁移到基于 Webpack 5 或者 Rollup 的构建工具,以使用 ES6 Module 的特性进行 Tree Shaking,去掉未被使用的部分,缩减产物尺寸。
对于 lodash 这种非 esm 包,建议用 lodash-es 替代或者采用分量引用:
// Bad
import _ from 'lodash';
// Good
import get from 'lodash/get';
最后,使用 Webpack Bundle Analyzer 插件分析产物尺寸,识别对产物体积影响较大的包。
- 存在多版本的包,考虑升级依赖版本进行优化。
- 尺寸过大的包,考虑轻量级替代品,比如 moment -> date-fns/day.js
代码分割
注意:不支持 IE,Edge < 16,Firefox < 60,Chrome < 61 和 Safari < 11
常见的构建工具都支持以下语法进行代码分割:
import React from 'react';
import Header from './Header';
const Description = React.lazy(() => import('./Description'));
const Comments = React.lazy(() => import('./Comments'));
const FeedbackDialog = React.lazy(() => import('./FeedbackDialog'));
const Footer = React.lazy(() => import('./Footer'));
function ProductDetailPage() {
return <div>
<Header/>
<Description/>
<Comments/>
<FeedbackDialog/>
<Footer/>
</div>
}
对于首屏需要展示的组件,Header,我们用传统的引用方式打包,在页面加载时直接加载其 JS 和 CSS。而对于不会首屏展示的组件,用 ES6 模块动态加载。这样做之后,首屏加载的 CSS 和 JS 尺寸会缩小,从而加快首屏展示。
但实际项目中,构成项目产物的大部分来自第三方包,比如 React,Moment 和 Ant Design 这样的库,也就是 vendor chunk。如果你的业务代码产物 app chunk 在 gzip 压缩之后不超过 200KB,做代码分割的意义就不大了。
总结
在实际工作中,由于技术架构限制或是资源限制无法使用 SSR,我们还是有很多手段可以去快速优化 React 应用加载速度。
本文没有涉及 HTTP2,缓存策略,CDN 等优化传输速度的方法,因为这些是 DevOps 考虑的范畴,在大的 IT 公司中,都已经做得比较到位了,前端开发者不需要亲历亲为。
发表回复