1. 前言
之前同事分享过一本书籍《前端性能优化原理与实践》
这本书主要是基于广义上的Frontend进行性能剖析,下面是我们从中已经采用的一些优化方法:
- 使用gzip压缩传输数据
- 浏览器缓存
- 缓存API数据
- 防抖(debounce)
- Lazy-Load
但是基于React应用的优化,我们还要具体问题,具体分析。下面是一个React网站性能优化过程中碰到的一些问题以及总结。
2. 分析生成的bundle大小及优化
Bundle即是React生成的Javascript文件,文件太大会导致页面Load很耗时。
优先级我觉得不是很高。首先,我们开发的React网站规模通常不大,生成的javascript也不是很大。其次,即使bundle有点大,作为单页面网站,也只会在家一次资源(componet mount),对于后续刷新不影响。
2.1 分析 – Webpack Bundle Analyzer
UMI自带了analyze模块,可以分析build之后各个文件的大小,package.json -> scripts里加入:
"analyze": "cross-env ANALYZE=1 umi build"
然后运行 yarn analyze
或 npm run analyze
,就会启动Webpack Bundle Analyzer。
访问Webpack Bundle Analyzer本地服务地址,就可以查看所有bundle的大小,方块越大,代表size越大。
2.2 优化 – 代码分割
React官方文档总结的很好,直接看官方文档就好:代码分割
- import():动态import module
- React.lazy:动态引入React component
- <Suspense fallback={JSX}>:组件没加载完成时显示内容
- startTransition():等异步组件加载完了,再更新UI,不显示<Suspense>
- Error boundaries:捕获子组件的错误,类似Java或其它语言的catch
3. 确保后端API返回都很快
前端调用后端API来获取数据,更新组件。如果API就很慢,前端无论怎么优化,都会很卡。
3.1 拆分大API
如果API响应慢,是因为API粒度太大,返回太多冗余用不上的数据,可以考虑拆分API。
3.2 优化后端代码
如果API粒度合适,还是响应慢,就要优化后端代码。这里的一个case是,后端API逻辑里,执行了太多的Java反射调用,拖慢了API的响应。
4. 减少Model, State更新引起的多次渲染
4.1 合并相关联Reducer
Reducer reducer是用来更新model状态的,model状态的更新,会引起相关组件重新渲染。
下面代码可能会引起三次组件渲染,取决于三次dispatch间隔是否够短:
const plan = ... dispatch({ type: "planning/setSelectedPlan", plan}); const product = ... dispatch({ type: "planning/setSelectedProduct", product}); const productPlan = ... dispatch({ type: "planning/setSelectedProductPlan", productPlan: productPlan});
前两次渲染是无效的,因为data不是完整的,虽然人眼看不到差异,只是有卡顿。合并成一个reducer,只有一次刷新:
const plan = ... const product = ... const productPlan = ... dispatch({ type: "planning/setInitState", plan, product, productPlan});
4.2 合并相关联setState
State是组件的状态,更新state会引起组件重新渲染。
下面代码可能会引起三次组件渲染,取决于三次setState间隔是否够短:
const dataSource = ... this.setState({dataSource}); const groupRule = ... this.setState({groupRule}); const columns = ... this.setState({columns});
前两次state改变引起的rerender是无效的,因为数据不完整。合并成一个setState,就会只有一次刷新:
const dataSource = ... const groupRule = ... const columns = ... this.setState({dataSource, groupRule, columns});
4.3 stateB由stateA计算而来,没必要声明stateB
错误示例,onFileChange执行后页面会刷新,刷新之后执行useEffect更新state后,页面又刷新一次。
function FileInfo(props) { const [filePath, setFilePath] = useState(''); const [fileName, setFileName] = useState(''); function onFileChange(e) { setFilePath(e.target.value); } useEffect(() => { setFileName(FileUtil.getFileName(filePath)); }, [filePath]) return <Fragment> <Input value={filePath} onChange={onFileChange} /> <label>File path: {filePath}</label> <label>File name: {fileName}</label> </Fragment>; }
通常来说,如果stateB是由stateA计算出来,就没必要声明stateB。把生成stateB的逻辑声明成一个方法,任何使用stateB的地方,就直接调用这个方法。如果转换过程比较耗时,可以使用5.2的useMemo来缓存,避免多次执行。下面是删除fileName的代码:
function FileInfo2(props) { const [filePath, setFilePath] = useState(''); function onFileChange(e) { setFilePath(e.target.value); } return <Fragment> <Input value={filePath} onChange={onFileChange} /> <label>File path: {filePath}</label> <label>File name: {FileUtil.getFileName(filePath)}</label> </Fragment>; }
如果stateB是由stateA计算出来,而又要执意声明stateB,那么应当把stateB的更新和stateA的更新放在一起。
function FileInfo3(props) { const [filePath, setFilePath] = useState(''); const [fileName, setFileName] = useState(''); function onFileChange(e) { setFilePath(e.target.value); setFileName(FileUtil.getFileName(filePath)); } return <Fragment> <Input value={filePath} onChange={onFileChange} /> <label>File path: {filePath}</label> <label>File name: {fileName}</label> </Fragment>; }
4.4 stateB由props计算而来,没必要声明stateB
下面的例子props改变会引起一次render,useEffect执行setTotalPrice后又会引起一次render。
function TotalWithEffect({price, quantity}) { const [totalPrice, setTotalPrice] = useState(0); useEffect(() => { setTotalPrice(price * quantity); }, [price, quantity]); return (<label>Total: {totalPrice}</label>); }
其实没必要将totalPrice声明成state,声明成一个变量或者方法便可。
function Total({price, quantity}) { const getTotalPrice = () => price * quantity; return (<label>Total: {getTotalPrice()}</label>); }
对于class component,如果仍然要声明成state,可以用getDerivedStateFromProps(),它会在render前执行,不会引起多余render。
class Child extends React.Component { state = { totalPrice: 0, } static getDerivedStateFromProps(props, state) { return { totalPrice: props.price * props.quantity, } } render() { return <>A component</> } }
5. React提供的优化方法
5.1 React.memo – 相同props跳过渲染
const MyComponent = React.memo(function MyComponent(props) { /* 使用 props 渲染 */ });
使用React.memo包装组件,当props相同时,React将跳过渲染组件的操作。
默认情况下,只会对props做浅比较,如果要控制props的比较,可以添加第二个参数。
function MyComponent(props) { /* 使用 props 渲染 */ } function areEqual(prevProps, nextProps) { /* 如果把 nextProps 传入 render 方法的返回结果与 将 prevProps 传入 render 方法的返回结果一致则返回 true, 否则返回 false */ } export default React.memo(MyComponent, areEqual);
5.2 React.PureComponent和shouldComponentUpdate
React.Component会在每次props或state更新重新render组件。React.PureComponent会用当前的props和state与之前的进行浅比较,如果相同就不会render。React.PureComponent类似React.memo,但是React.memo只比较props。
如果想控制props和state的比较,可以使用shouldComponentUpdate方法,Component和PureComponent都可用。返回true表示要render;false表示不render。
shouldComponentUpdate(nextProps, nextState) { return true; }
5.3 useCallback – 记住function instance
随着每次render,都会创建一个新的function,如果function作为props传递给子组件,就会导致子组件产生无意义的刷新。Note: <Hello /> 时一个React.memo包装的组件,仅在props变时rerender。
function App() { function countLetter(name: string) { return name.length; } return ( <div className="App"> <Hello name="Alex" countLetter={countLetter} /> </div> ); }
使用useCallback包装函数,仅在某个依赖项改变时才重新生成新的function,上面代码可以改写为:
function App() { const countLetter = useCallback((name: string) => { return name.length; }, []); //no deps, 只创建一次 return ( <div className="App"> <Hello name="Alex" countLetter={countLetter} /> </div> ); }
不要再JSX里使用lambda表达式,因为它每次render时都会生成一个新的函数,浅比较就每次都不一样。下面例子就算name不变,每次render App时,都会导致Hello组件重新render。
function App() { const countLetter = useCallback((name: string) => { return name.length; }, []); //no deps, 只创建一次 return ( <div className="App"> <Hello name="Alex" countLetter={name => countLetter(name)} /> </div> ); }
5.4 useMemo – 缓存耗时计算的结果
useMemo API:
// allow undefined, but don't make it optional as that is very likely a mistake function useMemo<T>(factory: () => T, deps: DependencyList | undefined): T; // Example: const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
仅在依赖项改变时,才会重新计算memoized的值,避免高开销的计算。
useMemo返回的结果可以是任何值,返回的如果是function的话,就等价于useCallback。
const setValue1 = useMemo(() => { return (value: boolean) => { setState(value); }; }, []); const setValue2 = useCallback((value: boolean) => { setState(value); }, []);
下面的useMemo返回React组件:
const menuItemRows = useMemo( () => thousandsOfMenuItems.map(menuItem => ( <MenuItemRow key={menuItem.uuid} name={menuItem.name} /> )), [thousandsOfMenuItems] );
如果后端API调用比较耗时,也可以用useMemo把API返回结果缓存起来。项目里有用 react-query 的 userQuery 来缓存API调用结果,它的功能更强大些,例如可以设置缓存何时到期,到期后重新call API拿数据。
6. 优化大Table组件
Ant design的Table组件,在每个cell更新时,都会去rerender所有的Table Cell。当Table数据少的时候,感觉不出来性能问题。当Table数据很大的时候,例如50列的Table,300行数据,rerender所有table会带来非常大的性能问题。
6.1 编辑状态下才显示复杂组件
下面时一个Table的截图,它的每一个cell都时一个复杂组件。
我们可以给组件一个editing状态,editing 是 false 时,直接显示文本;editing 是 true,才显示高级组件,让用户编辑。
6.2 延迟调用耗时API
例如点击 Select时,会弹出一个Dialog显示数据,而数据获取又很耗时。
那么数据的获取阶段,就不应该发生在 Select 组件渲染的周期里面,应当在点击 Select 时才获取数据。总结:啥时候用,啥时候去取数据。
6.3 分页
对大Table进行分页,这是很常用的手法。分页后,table小了,性能自然会提升。
6.4 Virtual Table
And design 4.X 版本之后,通过react-window,引入了虚拟滚动方案。仅会渲染当前视窗里的内容,就算有10万条数据,依然性能很好。