此文比较适合想要优化React
项目,却不知道如何下手的人阅读。介绍了常见的调试工具和优化手段以及整个优化的思考过程。
最近在工作中遇到了一些React
的性能问题。
需求点击一个添加商品按钮,将商品添加商品到购物车,然后商品数量延迟了将近1s才变化。在经过一系列的优化之后,将渲染次数从20次优化到4次,渲染时间也降到了毫秒级。
在此期间,学习到一些React
函数组件的调试和优化技巧(自从用了hook
再没用过类组件,真香😄)。故想写篇水文记录并分享一下此次优化的心路历程。
这里我们用TodoList
的例子🌰作为基础,然后一步步通过调试工具,查找可以优化的点,再一步步优化。
首先,使用Create-React-App创建一个简单的项目:
npx create-react-app react-optimize-practice --template typescript
然后,写个简单的TodoList
,层级结构如下:
代码如下:
TodoList.tsx
import { FC } from "react";
import { useState } from "react";
import TodoInput from "./TodoInput";
import TodoItem from "./TodoItem";
type TodoItemType = {
id: number;
text: string;
isComplete: boolean;
};
const TodoList: FC = () => {
const [todoList, setTodoList] = useState<TodoItemType[]>([]);
const handleAddItem = (text: string) => {
if (!text) return;
setTodoList((preTodoList) => {
return [
...preTodoList,
{ text, isComplete: false, id: +new Date() },
];
});
};
return (
<div>
<TodoInput onAddItem={handleAddItem} />
<ul>
{todoList.map((item) => {
return (
<TodoItem
key={item.id}
text={item.text}
isComplete={item.isComplete}
/>
);
})}
</ul>
</div>
);
};
export default TodoList;
TodoInput.tsx
import { FC, useState } from "react";
type Props = {
onAddItem: (text: string) => void;
};
const TodoInput: FC<Props> = ({ onAddItem }) => {
const [text, setText] = useState('');
const handleAdd = () => {
onAddItem(text);
};
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={handleAdd}>add</button>
</div>
);
};
export default TodoInput;
TodoItem.tsx
import { FC } from "react";
type Props = {
text: string;
isComplete: boolean;
};
const TodoItem: FC<Props> = ({ text, isComplete }) => {
return (
<li>
<button>x</button>
<input type="checkbox" checked={isComplete} />
<span>{text}</span>
</li>
);
};
export default TodoItem;
页面效果
这里我们先来介绍一款用来调试React
项目的Chrome插件,下载地址:React Developer Tools
需要翻墙,如果翻不了墙的话,用新版的Edge也行:[Microsoft Edge 加载项 - react developer tools](https://microsoftedge.microsoft.com/addons/search/react developer tools?hl=zh-CN)
现在我们使用React Developer Tools
查看渲染时间和次数。
进行如下操作:
- 再点击一下录制按钮,结束录制
开始查看数据
每个部分的含义:
- 当前选择的tab是火焰图模式;
1/2
代表,录制期间总共触发了2次渲染,当前看的是第一次渲染的数据;柱子也是两条,和渲染次数一样,蓝色是当前选中的渲染,柱子高低代表渲染时间;- 灰色的代表没有渲染的组件,如
App
、TodoList
;有颜色代表有渲染的组件,绿色代表渲染时间很快,如果渲染很慢的话可能是黄色或红色的(此时就可以重点关注);可以点击对应的组件查看详细渲染数据; - 当前选中组件的详细渲染数据,点击框框“3”内的组件可以切换组件。
再来看看另一个标签页的内容
这里其它部分都一样,只是下面的组件渲染火焰图变成按组件渲染时间排序图(按渲染时长倒序排序),没有渲染的组件不显示,这个组件排行图比较适合直接找到渲染时长最长的组件。
点击查看第二次渲染的信息
通过上面的第一次渲染的图和第二次渲染的图可以得出如下信息:
- 在第一次渲染中
TodoInput
触发渲染
- 在第二次渲染中
TodoList
触发渲染TodoInput
触发渲染TodoItem
触发渲染
优化可以从下列两个角度出发
- 一是减少渲染次数
- 二是减少每次渲染的渲染总时间(减少组件渲染时长)
这里进行了两次操作,所以渲染次数为两次,从渲染次数的角度出发已经没有优化空间了,所以这里主要考虑如何减少渲染总时长
分析:
- 第一次渲染是因为在输入框输入一个字符
a
,TodoInput
中的state
发生变化,触发重新渲染,这里没什么问题。 - 第二次渲染是因为点击了
add
按钮,将数据添加到TodoList
中,由于新增一条数据,所以TodoList
触发重新渲染,TodoItem
也触发重新渲染,这里没什么问题,但是奇怪的是,为什么TodoInput
也触发从新渲染呢?
这里引申出一个问题:什么会触发React
组件重新渲染?
答:
state
变化或props
变化
TodoInput
触发重新渲染无非就是上述两种情况,在第二次渲染中,state
明显是没有变化的,那么变化的就只能是props
,从父组件TodoList
传入的props
只有一个:onAddItem
其实,在函数组件每次重新渲染的时候,相当于重新调用了一遍。所以当TodoList
重新渲染的时候,handleAddItem
是重新生成的,相当于给TodoInput
组件传入一个新的onAdd
方法。那么有没有办法将handleAddItem
缓存起来,保证每次传给TodoInput
的props
都相同?这样就可以不用渲染TodoInput
,从而减少第二次渲染的总时长。
答案是肯定的,React提供了一个叫
useCallback
的hook
可以将函数缓存起来。
用法
useCallback
是一个函数,返回一个新的函数;- 第一个参数传入你要缓存的函数;
- 第二个参数是个数组,表示依赖项数组,当依赖项数组变化后
useCallback
会返回新的函数,如果没有依赖项,写个空数组即可。
我们改造一下原来的代码
// TodoList.tsx
import { useCallback } from "react";
const handleAddItem = useCallback((text: string) => {
if (!text) return;
setTodoList((preTodoList) => {
return [...preTodoList, { text, isComplete: false, id: +new Date() }];
});
}, []);
现在再重新测试一下,然后你会发现并没有什么变化,TodoInput
还是渲染了
Why???
原因就是,虽然这里确实通过
useCallback
保证handleAddItem
的引用不变,但是TodoInput
并没有根据这个比较需不需要重新渲染,这个时候就需要使用React提供的memo
函数
对函数组件的props
进行比较,如果props
不变,不进行渲染。
用法
-
memo
是一个高阶组件,返回一个新组件,是函数组件版的PureComponent
; -
第一个参数是要进行包装的组件;
-
第二个参数是一个比较函数,不传默认对
props
进行浅比较。
改造一下原来的代码
import { FC, memo } from "react";
type Props = {
onAddItem: (text: string) => void;
};
const TodoInput: FC<Props> = ({ onAddItem }) => {
//...略
};
export default memo(TodoInput);
测试结果:
这个时候我们会发现,TodoInput
是灰色的,并且多了个Memo
的标记,代表TodoInput
因为Memo
的比较没有触发渲染。
以上就是对TodoList
渲染时间的优化的全过程,有两个点需要注意一下
memo
和useCallback
需要同时使用,否则不会生效(我真的看到过只写useCallback
的代码-_-||);memo
用在子组件,useCallbak
用在父组件,不要搞混了。
React还提供了useMemo对其它类型的数据进行缓存,用法和
useCallback
一致,但是可以返回任何值,useCallback
只能返回函数,是useMemo
的子集。
下面给大家介绍另一个很好用的调试工具:https://github.com/welldone-software/why-did-you-render
这个库可以打印出每一个操作组件重新渲染的原因,所以很适合在hook
使用的很多的组件中,查找渲染原因的时候使用。
yarn add --dev @welldone-software/why-did-you-render
or
npm install @welldone-software/why-did-you-render --save-dev
创建一个新文件/src/wdyr.ts
/// <reference types="@welldone-software/why-did-you-render" />
import React from "react";
// 不要在生成环境打开,会影响性能
if (process.env.NODE_ENV === "development") {
const whyDidYouRender = require("@welldone-software/why-did-you-render");
whyDidYouRender(React, {
trackAllPureComponents: true, // 跟踪所有纯组件(React.PureComponent or React.memo)
});
}
然后在index.tsx
导入
import './wdyr'; // <--- 在第一行导入
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
进行一样的步骤,输入一个字符然后点击添加按钮,打开控制台,发现了一个错误
这是一个React
错误,之前写的代码有点,受控组件没有加onChange
事件,这里先完善一下,TodoItem
中checkbox
的改变函数由TodoList
传入。
// TodoItem.tsx
import { ChangeEventHandler, FC } from "react";
type Props = {
text: string;
isComplete: boolean;
onCheckboxChange: (checked: boolean) => void;
};
const TodoItem: FC<Props> = ({ text, isComplete, onCheckboxChange }) => {
const handleCheckboxChange: ChangeEventHandler<HTMLInputElement> = (e) =>
onCheckboxChange(e.target.checked);
return (
<li style={{ display: "flex", flexDirection: "row", alignItems: "center", paddingBottom: 5 }}>
<input
type="checkbox"
checked={isComplete}
onChange={handleCheckboxChange}
/>
<span style={{marginLeft: 5}}>{text}</span>
<button style={{marginLeft: 5}}>x</button>
</li>
);
};
export default TodoItem;
// TodoList.tsx
import { FC, useCallback } from "react";
import { useState } from "react";
import TodoInput from "./TodoInput";
import TodoItem from "./TodoItem";
type TodoItemType = {
id: number;
text: string;
isComplete: boolean;
};
const TodoList: FC = () => {
const [todoList, setTodoList] = useState<TodoItemType[]>([]);
const handleAddItem = useCallback((text: string) => {
if (!text) return;
setTodoList((preTodoList) => [
...preTodoList,
{ text, isComplete: false, id: +new Date() },
]);
}, []);
const handleChangeBox = (id: number) => (checked: boolean) => {
setTodoList((preTodoList) =>
preTodoList.map((item) =>
item.id === id ? { ...item, isComplete: checked } : item
)
);
};
return (
<div>
<TodoInput onAddItem={handleAddItem} />
<ul style={{ margin: 10, padding: 0 }}>
{todoList.map((item) => {
const { id, text, isComplete } = item;
return (
<TodoItem
key={id}
text={text}
isComplete={isComplete}
onCheckboxChange={handleChangeBox(id)}
/>
);
})}
</ul>
</div>
);
};
export default TodoList;
改完发现并wdyr
没有打印渲染原因,查文档发现用Create React App (CRA) ^4
创建的项目会有个问题,尝试过按照官网的提示修改,无果。如有大佬知道什么问题,麻烦告知小弟一声。
好在,这只是全局设置的打印没有效果,组件内配置的还是可以的,现在给每个组件都添加上wdyr
的配置,表示要监听渲染触发打印触发原因。
// TodoInput.tsx
import { ChangeEventHandler, FC, memo, useState } from "react";
type Props = {
onAddItem: (text: string) => void;
};
const TodoInput: FC<Props> = ({ onAddItem }) => {
//...略
};
TodoInput.whyDidYouRender = {
logOnDifferentValues: true,
}
export default memo(TodoInput);
其它组件配置也一样,这里就不贴代码了。
然后重新操作一遍,然后就可以看到控制台的输出了
这里可以很清楚的看到什么组件因为什么原因触发了重新渲染。
为了挖这个坑,啊,不对,为了复现这个问题,我们先把代码改造一下。
引入react-redux等库
yarn add react-redux
yarn add -D @types/react-redux
yarn add @reduxjs/toolkit
@reduxjs/toolkit一个工具库,写起来有点像dva
,但是对Typescript
的支持比dva
好,dva
已经很久没有维护了,下面代码使用了@reduxjs/toolkit
编写。
使用createSlice
创建一个todoList.ts
// src/model/todoList.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
export type TodoItemType = {
id: number;
text: string;
isComplete: boolean;
};
interface TodoListState {
data: TodoItemType[];
}
const initialState: TodoListState = {
data: [],
};
// createSlice相当于dva的创建一个model
export const todoListSlice = createSlice({
name: "todoList", // 命名空间
initialState, // 初始值
reducers: {
// 往todoList添加一项
add: (state, action: PayloadAction<string>) => {
// 可以直接改变state,因为@reduxjs/toolkit用了Immer库
state.data.push({
id: +new Date(),
isComplete: false,
text: action.payload,
});
},
// 删除todoList的一条内容
remove: (state, action: PayloadAction<number>) => {
state.data.filter((item) => item.id !== action.payload);
},
// 更新todoList的一条内容
update: (state, action: PayloadAction<TodoItemType>) => {
state.data = state.data.map((item) =>
item.id === action.payload.id ? action.payload : item
);
},
},
});
// 导出actions
export const { add, remove, update } = todoListSlice.actions;
export default todoListSlice.reducer;
创建仓库
// src/model/index.ts
import { configureStore } from "@reduxjs/toolkit";
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import todoListReducer from "./todoList";
const store = configureStore({
reducer: {
todoList: todoListReducer,
},
});
// 从store本身推断出RootState类型
type RootState = ReturnType<typeof store.getState>;
// 从store本身推断出AppDispatch类型: {todoList: TodoListState}
type AppDispatch = typeof store.dispatch;
// 在app中使用加入了类型的useDispatch和useSelector
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export default store;
连接仓库到React中
import "./wdyr";
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import store from "./model";
import { Provider } from "react-redux";
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById("root")
);
使用,修改TodoList.tsx
import { FC, useCallback } from "react";
import { useAppDispatch, useAppSelector } from "../model";
import { add, TodoItemType, update } from "../model/todoList";
import TodoInput from "./TodoInput";
import TodoItem from "./TodoItem";
const TodoList: FC = () => {
const todoList = useAppSelector(({ todoList }) => todoList.data);
const dispatch = useAppDispatch();
const handleAddItem = useCallback(
(text: string) => {
if (!text) return;
dispatch(add(text));
},
[dispatch]
);
const handleChangeBox = (item: TodoItemType) => (checked: boolean) => {
dispatch(update({ ...item, isComplete: checked }));
};
return (
<div>
<TodoInput onAddItem={handleAddItem} />
<ul style={{ margin: 10, padding: 0 }}>
{todoList.map((item) => {
const { id, text, isComplete } = item;
return (
<TodoItem
key={id}
text={text}
isComplete={isComplete}
onCheckboxChange={handleChangeBox(item)}
/>
);
})}
</ul>
</div>
);
};
TodoList.whyDidYouRender = {
logOnDifferentValues: true,
};
export default TodoList;
dispatch
有两种用法
第一种
直接使用导出的action
dispatch(add(text));
第二种
和dva一样,使用字符串:"命名空间/action名",
dispatch({
type: 'todoList/add',
payload: text
});
我比较偏向于第一种,因为有代码提示比较香
现在这种写法还没什么问题暴露出来,控制台啥也没打印。
但是如果useAppSelector
换一种写法,就不一样了
import { FC, useCallback } from "react";
import { useAppDispatch, useAppSelector } from "../model";
import { add, TodoItemType, update } from "../model/todoList";
import TodoInput from "./TodoInput";
import TodoItem from "./TodoItem";
const TodoList: FC = () => {
- const todoList = useAppSelector(({ todoList }) => todoList.data);
+ const { todoList } = useAppSelector(({ todoList }) => ({
+ todoList: todoList.data,
+ }));
//...略
};
TodoList.whyDidYouRender = {
logOnDifferentValues: true,
};
export default TodoList;
这里我们用useAppSelector
返回了一个对象,实际上,这种写法在你想要从不同的reducer
一次性获取多个值的时候很常用。
现在我们刷新页面,看看控制台。
啥也没干,初始化就多了一次useReducer
的刷新。
这里引出了两个问题:
- 为什么多了一次渲染?
- 为什么是
useReducer
改变触发的渲染,代码里并没有使用useReducer
?
先解决第二个问题,其实react-redux
在监听到store
数据变化的时候是通过useReducer
来进行强制刷新,从下图的useSelector
的源码可以很清楚的看出来,这也是官网推荐的写法。
再来看看第一个问题,这里要先修改一下wdyr.ts
的配置,才能看到useSelector
触发更新的日志
// src/wdyr.ts
/// <reference types="@welldone-software/why-did-you-render" />
import React from "react";
// 不要在生成环境打开,会影响性能
if (process.env.NODE_ENV === "development") {
const whyDidYouRender = require("@welldone-software/why-did-you-render");
+ const ReactRedux = require("react-redux");
whyDidYouRender(React, {
trackAllPureComponents: true, // 跟踪所有纯组件(React.PureComponent or React.memo)
+ trackExtraHooks: [[ReactRedux, "useSelector"]], // 跟踪useSelector
});
}
现在再来看看控制台,看起来数据并没有变化,却触发了两次渲染
这是因为,useSelector
返回数据的时候是使用===
进行比较的,如果你返回的对象,则每次比较都是false
,所以会触发多次渲染
针对第一个问题,这里有几种解决办法。
第一种
不要返回对象,就用一开始的写法,如果有多个值要返回,就使用多个useSelector
。
const todoList = useAppSelector(({ todoList }) => todoList.data);
第二种
如果非要写对象,可以使用useSelector
的第二个参数,传入一个比较函数。
在这里官网给我们导出了一个比较函数,可以直接使用。
import { shallowEqual } from "react-redux";
const { todoList } = useAppSelector(
({ todoList }) => ({
todoList: todoList.data,
}),
shallowEqual
);Ï
重新打开控制台,查看效果,没有任何多余的重新渲染。
你也可以使用第三方库的比较函数,如:react-fast-compare,lodash/isEquald等。
第三种
使用reselect缓存useSelector
,这个没怎么用过,就不展开说了,感兴趣的自己看文档吧。
第四种
配合useMemo
使用。
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
/** 原始写法 **/
const { todoList, usename } = useAppSelector(({ todoList, user }) => ({
todoList: todoList.data,
usename: user.username,
}));
/** 优化写法 **/
const state = useAppSelector((state) => state); // 保持useSelector返回的值不变
// 使用useMemo拆分数据
const { todoList, usename } = useMemo(() => {
return {
todoList: state.todoList.data,
usename: state.user.username
};
}, [state])
- React有两个常用的工具,分别是
React Developer Tools
和why-did-you-render
,优化可以从减少渲染次数和渲染时间下手; React.memo
和useCallback
或useMemo
同时使用,可在父组件渲染的时候减少不必要的子组件渲染;react-redux
的useSeletor
使用不当容易造成重复渲染,有四种方式可以解决。
其实这些性能优化的点,都是在一开始写代码的时候就可以避免的,写多两次就熟了。很多人觉得React
的学习成本高,可能就是因为不熟悉React
的渲染机制。在vue
中,这些优化框架已经做好了,但是React
中需要自己写。
参考: