合理的组件结构
在 React 中,合理的组件结构可以有效提升性能,避免不必要的重新渲染。以下是对一个简单示例的分析与优化总结
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// 父组件
const App = () => {
const [value, setValue] = useState();
return (
<>
<input value={value} onChange={(e) => setValue(e.target.value)} />
<Child />
</>
);
};
// 子组件
const Child = () => {
// 假设子组件渲染需要很长时间
for( let i = 0; i < 20000;i++){
console.log(i);
}
return <div>我是Child组件, 渲染需要花费很长时间</div>;
};
|
当input改变值时,由于App重新组件渲染,Child组件也会重新渲染,但是Child渲染非常耗时。
我们发现Child与input的值并没有任何关联,所以我们可以将input单独写成一个组件,避免Child的重新渲染
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// Input组件
const Input = () => {
const [value, setValue] = useState();
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
};
// 父组件
const App = () => {
return (
<>
<Input />
<Child />
</>
);
};
|
也可以通过第二种方式 将Child组件以Children方式传入父组件,value更新时Child也不会渲染
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 父组件
const AppWrap = ({children}) => {
const [value, setValue] = useState();
return (
<>
<input value={value} onChange={(e) => setValue(e.target.value)} />
{children}
</>
);
};
// 使用App时
<AppWrap>
<Child />
</AppWrap>
|
通过以上两种优化方案,我们可以有效隔离 input 组件与 Child 组件的渲染逻辑,从而提升应用的性能和用户体验。
选择合适的组件结构和传递方式是 React 开发中的重要考量。
使用 API 减少组件渲染
shouldComponentUpdate
shouldComponentUpdate 是 React 组件生命周期中的一个方法,用于控制组件的重新渲染。它在组件接收到新的 props 或 state 时被调用,返回一个布尔值,指示组件是否应该更新。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
class Child extends React.Component {
state = {}
shouldComponentUpdate(nextProps,nexState){
// 对比下一次的state与props
// 返回true允许组件更新
return true;
}
render (){
// 假设子组件渲染需要很长时间
for( let i = 0; i < 20000;i++){
console.log(i);
}
return <div>我是Child组件, 渲染需要花费很长时间</div>;
}
}
|
当组件的渲染性能很重要时,使用 shouldComponentUpdate 可以避免不必要的渲染。
React.memo
React.memo 是 React 提供的一种高阶组件,用于优化函数组件的性能。它通过避免不必要的渲染,显著提高应用的性能,尤其在以下情况下尤为有效:
默认行为
React.memo默认使用浅比较props决定组件是否重新渲染
1
|
const MemoChild = React.memo(Child)
|
自定义比较函数
如果 props 是复杂对象或数组,可能需要提供自定义比较函数。在使用 React.memo 的自定义比较函数时,返回值的含义如下:
- 返回 true: 表示前后 props 相同,组件不会重新渲染。
- 返回 false: 表示前后 props 不同,组件会重新渲染。
1
2
3
|
const MemoChild = React.memo(Child,(preProps,nextProps)=>{
return prevProps.value === nextProps.value; // 自定义比较逻辑
})
|
useDeferredValue
参考官方文档:useDeferredValue
在某些场景下,我们可能需要将输入框的值传递给子组件,导致子组件在每次父组件更新时都重新渲染。这样会影响用户体验,尤其是在子组件渲染较慢的情况下。为了解决这个问题,我们可以使用 useDeferredValue 钩子。
考虑以下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
import React, { useState } from 'react';
// App父组件
const App = () => {
const [value, setValue] = useState("");
return (
<>
<input value={value} onChange={e => setValue(e.target.value)} />
<Child value={value} />
</>
);
}
// Child组件
const Child = ({ value }) => {
const items = Array.from({ length: 250 }, (_, index) => (
<SlowItem key={index} text={value} />
));
return <div>{items}</div>;
}
export default React.memo(Child); // 使用 React.memo 进行优化
function SlowItem({ text }) {
let startTime = performance.now();
while (performance.now() - startTime < 1) {
// 模拟慢操作
}
return <li className="item">Text: {text}</li>;
}
|
在这个示例中,我们的输入框值通过 props 传递给 Child 组件。每当输入框内容变化时,Child 组件都会重新渲染,导致性能问题。
防抖或者节流
为了解决这个问题,我们可以使用防抖或节流技术。在这种情况下,我们可以设置一个 childValue 状态,用于延迟更新 Child 组件的渲染。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
import _ from 'lodash';
// App父组件
const App = () => {
const [value, setValue] = useState("");
const [childValue, setChildValue] = useState("");
const debounceUpdateChildValue = _.debounce((value) => {
setChildValue(value);
}, 200);
return (
<>
<input value={value} onChange={e => {
setValue(e.target.value);
debounceUpdateChildValue(e.target.value);
}} />
<Child value={childValue} />
</>
);
}
|
输入框的值会先更新,200ms后Child组件更新,使用防抖或者节流的方式只是将Input框输入体验进行了部分优化,仍然会出现输入框不流畅的体验。
因为在Child组件渲染时如果继续进行输入操作,Child组件渲染占用js引擎,输入框事件并不会响应。也就是说再次输入时,不会中断正在进行的Child组件渲染,来执行优先级更高的输入事件任务。
这时就可以使用useDefrredValue解决这样的问题。
使用 useDeferredValue
useDefrredValue会接受一个延迟值,当延迟值改变时,会先使用旧的值进行渲染,然后在后台使用新值进行一次新的渲染,也就是说React会将组件渲染两次,一次展示旧的结果,一次展示新的结果。
最关键的是,后台渲染可以被中断,这意味着当有更高级的状态更新时,可以先渲染更高级的任务。比如这个例子中的输入框的渲染可以中断后台进行的Child组件的渲染,体验效果就会比防抖和节流更丝滑。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// App父组件
function App() {
const [value, setValue] = useState("");
const deferredValue = useDeferredValue(value);
return (
<>
<input value={value} onChange={(e)=>{
setValue(e.target.value);
}}/>
<Child value={deferredValue}/>
</>
);
}
|
useTranstion
官方文档: useTransition
useTransition 是 React 18 引入的一个钩子,用于处理 UI 更新的过渡状态。它允许你标记某些更新为“非紧急”更新,这样 React 就可以优先处理用户输入和其他紧急更新,同时在后台处理这些非紧急更新,从而改善应用的响应能力和用户体验。
在这个例子中,我们设置一个中间值childValue,并将其更新为过渡更新。当输入框的值更新时,过渡更新会被中断,等待输入框更新后,过渡更新会再次执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// 父组件
function App() {
const [value, setValue] = useState("");
const [childValue, setChildValue] = useState("");
const [isPending, startTransition] = useTransition();
return (
<>
<input value={value} onChange={(e)=>{
setValue(e.target.value)
startTransition(()=>{
// 我是非紧急更新的,可以被更高级的优先级任务
setChildValue(e.target.value);
})
}}/>
{isPending ? <div>我在过渡计算中...</div> : <Child value={childValue}/>}
</>
);
}
|
升级到React18或者以上版本
从React18开始,React提供了自动批处理和并发渲染。
自动批处理
在 React 的早期版本中,更新只能在合成事件(如点击事件)中批量处理。这意味着,如果你在异步函数(例如 Promise 或 setTimeout)中进行多次状态更新,React 将会逐个处理这些更新,导致多次渲染。
React 18 的自动批处理允许在任何地方(包括异步函数)自动合并多次状态更新。这一特性减少了渲染次数,从而提升了性能。例如:
1
2
3
4
5
|
// 在 React 18 中,批量更新
setTimeout(() => {
setCount(count + 1);
setName('New Name');
}, 1000);
|
并发渲染
并发渲染是 React 18 的另一项重要特性,它使得 React 能够更灵活地处理更新,从而提升用户界面的流畅性。假设有一个组件 Child,其渲染时间约为 200ms,但浏览器每帧的渲染时间通常约为 16ms。在 Child 组件渲染的过程中,页面将无法响应用户的交互。
Fiber 节点与任务管理
每个 JSX 元素都会转换为对应的 Fiber 节点,每个 Fiber 节点都有自己的任务。例如:
Host 节点(如 div、span):创建对应的真实 DOM 节点并设置样式和属性。
自定义组件(如 Child):管理组件内的 children,并处理其状态和副作用。
当 Child 组件包含大量节点(例如 10,000 个)时,React 会以深度遍历的方式处理每个 Fiber 节点,这会消耗大量时间。React 18 的并发渲染通过将渲染过程分解为多个小任务来解决这一问题。
具体实现
在每一帧中,React 会预留一部分时间(例如 5ms)用于处理 Child 组件中的前 1,000(假设值) 个节点。如下所示:
第一帧:
处理前 1,000 个节点,约耗时 5ms。
剩余时间(11ms)用于响应用户的点击和输入事件,确保页面保持响应。
后续帧:
如果没有用户交互,React 会在下一帧继续处理剩余的 9,000 个节点。
如果出现用户交互(如点击或输入),React 会优先处理这些高优先级的任务。
任务恢复:
在处理完高优先级的任务后,React 会从上一次中断的地方恢复 Child 组件的渲染任务,继续处理剩余的 Fiber 任务。
通过这种方式,React 能够在多帧中分配时间,确保用户界面始终保持流畅和响应迅速。最终,整个 Child 组件的渲染任务将在大约 10 帧后完成,而用户的交互体验不会受到影响。