编辑
2023-07-27
React
00
请注意,本文编写于 366 天前,最后修改于 365 天前,其中某些信息可能已经过时。

目录

组件重新渲染的触发机制
组件状态(state)发生变化
组件属性(props)发生变化
组件所依赖的上下文(context)发生变化
组件所依赖的自定义钩子(custom hook)发生变化
如何避免不必要的重新渲染
React.memo
useMemo
useCallback
React.PureComponent/shouldComponentUpdate
key
总结

React是一个用于构建用户界面的JavaScript库,它的核心特点之一是使用虚拟DOMVirtual DOM)来实现高效的组件渲染。那组件重新渲染的机制是如何呢?基于这些机制,如果进行优化呢?

虚拟DOM是一个用JavaScript对象表示的DOM树,它可以在内存中快速地创建和修改,而不需要直接操作真实的DOMReact会根据组件的状态(state)和属性(props)来生成虚拟DOM,并与上一次的虚拟DOM进行比较,找出差异,然后将这些差异应用到真实的DOM上,从而更新用户界面。

那么,React是如何知道什么时候需要重新渲染组件呢?

组件重新渲染的触发机制

一般来说,有以下四种情况会导致组件重新渲染:

组件状态(state)发生变化

当我们调用setState方法或者使用useState钩子(hook)来更新组件的状态时,React会自动重新渲染该组件及其子组件。

例如,下面的代码定义了一个简单的计数器组件,它使用useState钩子来管理计数值,并提供增加和减少的按钮。每次点击按钮时,都会调用setCount函数来更新计数值,从而触发组件的重新渲染。

js
import React, { useState } from "react"; function Counter() { // 使用useState钩子来创建一个状态变量count和一个更新函数setCount const [count, setCount] = useState(0); // 定义一个函数来增加计数值 function handleIncrease() { // 调用setCount函数来更新计数值,传入一个函数作为参数,该函数接收上一次的计数值作为参数,并返回新的计数值 setCount((prevCount) => prevCount + 1); } // 定义一个函数来减少计数值 function handleDecrease() { // 调用setCount函数来更新计数值,传入一个函数作为参数,该函数接收上一次的计数值作为参数,并返回新的计数值 setCount((prevCount) => prevCount - 1); } // 返回一个JSX元素,显示当前计数值,并提供增加和减少的按钮 return ( <div> <p>当前计数:{count}</p> <button onClick={handleIncrease}>增加</button> <button onClick={handleDecrease}>减少</button> </div> ); } export default Counter;

组件属性(props)发生变化

当我们向组件传递新的属性值时,React会自动重新渲染该组件及其子组件。但是,这里有一个重要的细节:React并不是根据属性值本身是否变化来判断是否需要重新渲染,而是根据属性值的引用是否变化。

也就是说,如果我们传递了一个新创建的对象或数组作为属性值,即使它们的内容没有变化,也会触发重新渲染,因为它们的引用不同于之前的值。这也是为什么我们应该尽量避免在渲染函数中直接创建对象或数组作为属性值的原因。

例如,下面的代码定义了一个简单的列表组件,它接受一个数组作为属性值,并显示数组中的每个元素。在父组件中,我们每隔一秒就向列表组件传递一个新创建的数组,即使数组中的元素没有变化,也会导致列表组件重新渲染。

js
import React, { useState, useEffect } from "react"; // 一个简单的列表组件,接受一个数组作为属性值,并显示数组中的每个元素 function List({ items }) { console.log("List is rendering"); return ( <ul> {items.map((item) => ( <li key={item}>{item}</li> ))} </ul> ); } // 一个简单的父组件,管理一个数组,并定时向列表组件传递新创建的数组 function App() { const [items, setItems] = useState(["a", "b", "c"]); // 使用useEffect钩子来在组件挂载后定时更新数组 useEffect(() => { // 定义一个定时器,在每隔一秒后调用setItems函数来更新数组 const timer = setInterval(() => { // 调用setItems函数来更新数组,传入一个新创建的数组作为参数,该数组与之前的数组内容相同 setItems(["a", "b", "c"]); }, 1000); // 返回一个清理函数,在组件卸载前取消定时器 return () => { clearInterval(timer); }; }, []); console.log("App is rendering"); return ( <div> <h1>React重新渲染示例</h1> {/* 向列表组件传递数组作为属性值 */} <List items={items} /> </div> ); } export default App;

组件所依赖的上下文(context)发生变化

当我们使用useContext钩子或者contextType属性来订阅一个上下文时,如果该上下文的值发生变化,React会自动重新渲染订阅了该上下文的所有组件。

例如,下面的代码定义了一个简单的主题组件,它使用createContext函数来创建一个主题上下文,并提供一个切换主题的按钮。在子组件中,我们使用useContext钩子来订阅主题上下文,并根据主题值来显示不同的颜色。每次点击按钮时,都会调用setTheme函数来更新主题值,从而触发子组件的重新渲染。

js
import React, { useState, createContext, useContext } from "react"; // 使用createContext函数来创建一个主题上下文,传入一个默认值作为参数 const ThemeContext = createContext("light"); // 一个简单的主题组件,使用useState钩子来管理主题值,并提供一个切换主题的按钮 function ThemeProvider({ children }) { // 使用useState钩子来创建一个状态变量theme和一个更新函数setTheme const [theme, setTheme] = useState("light"); // 定义一个函数来切换主题 function toggleTheme() { // 调用setTheme函数来更新主题值,传入一个函数作为参数,该函数接收上一次的主题值作为参数,并返回新的主题值 setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); } // 返回一个JSX元素,使用ThemeContext.Provider组件来提供主题值,并显示切换主题的按钮和子组件 return ( <ThemeContext.Provider value={theme}> <button onClick={toggleTheme}>切换主题</button> {children} </ThemeContext.Provider> ); } // 一个简单的子组件,使用useContext钩子来订阅主题上下文,并根据主题值来显示不同的颜色 function Child() { // 使用useContext钩子来获取主题上下文的值 const theme = useContext(ThemeContext); // 返回一个JSX元素,根据主题值来设置样式,并显示当前主题 return ( <div style={{ color: theme === "light" ? "black" : "white" }}> 当前主题:{theme} </div> ); } // 一个简单的父组件,使用ThemeProvider组件来包裹子组件 function App() { return ( <div> <h1>React重新渲染示例</h1> {/* 使用ThemeProvider组件来包裹子组件 */} <ThemeProvider> {/* 显示子组件 */} <Child /> </ThemeProvider> </div> ); } export default App;

组件所依赖的自定义钩子(custom hook)发生变化

当我们使用自定义钩子来封装一些逻辑或状态时,如果该钩子返回了一个新的值,React会自动重新渲染使用了该钩子的所有组件。

例如,下面的代码定义了一个简单的窗口大小钩子,它使用useStateuseEffect钩子来获取和更新窗口大小,并返回一个包含宽度和高度的对象。在组件中,我们使用这个自定义钩子来获取窗口大小,并显示在界面上。每次调整窗口大小时,都会触发自定义钩子返回一个新的对象,从而触发组件的重新渲染。

js
import React, { useState, useEffect } from "react"; // 定义一个自定义钩子,用于获取和更新窗口大小 function useWindowSize() { // 使用useState钩子来创建一个状态变量size和一个更新函数setSize const [size, setSize] = useState({ // 初始化状态值为当前窗口的宽度和高度 width: window.innerWidth, height: window.innerHeight, }); // 使用useEffect钩子来在组件挂载后添加一个窗口大小变化的事件监听器,并在组件卸载前移除该监听器 useEffect(() => { // 定义一个函数来处理窗口大小变化的事件 function handleResize() { // 调用setSize函数来更新状态值,传入一个新创建的对象作为参数,该对象包含当前窗口的宽度和高度 setSize({ width: window.innerWidth, height: window.innerHeight, }); } // 添加一个窗口大小变化的事件监听器,传入handleResize函数作为参数 window.addEventListener("resize", handleResize); // 返回一个清理函数,在组件卸载前移除事件监听器 return () => { window.removeEventListener("resize", handleResize); }; }, []); // 返回状态值 return size; } // 一个简单的组件,使用useWindowSize钩子来获取窗口大小,并显示在界面上 function App() { // 使用useWindowSize钩子来获取窗口大小 const size = useWindowSize(); // 返回一个JSX元素,显示当前窗口的宽度和高度 return ( <div> <h1>React重新渲染示例</h1> <p>当前窗口大小:{size.width} x {size.height}</p> </div> ); } export default App;

如何避免不必要的重新渲染

以上四种情况都是必要的重新渲染,因为它们反映了组件内部或外部的数据变化,从而保证了用户界面与数据保持一致。

但是,在一些情况下,我们可能会遇到不必要的重新渲染,即组件没有任何数据变化,但仍然被重新渲染了。这可能会导致性能问题,特别是当组件很复杂或者很频繁地被重新渲染时。那么,如何避免不必要的重新渲染呢?有以下几种常用的优化策略:

React.memo

使用React.memo高阶组件来包裹函数式组件。这样可以让React在渲染组件之前先对比一下属性值是否有变化,如果没有变化,则跳过渲染。这可以避免因为父组件重新渲染而导致子组件也跟着重新渲染的情况。

例如,下面的代码定义了一个简单的计数器组件,它接受一个数字作为属性值,并显示该数字。在父组件中,我们使用useState钩子来管理一个数字,并提供一个增加的按钮。每次点击按钮时,都会调用setNumber函数来更新数字,从而触发父组件的重新渲染。

如果我们不使用React.memo来包裹计数器组件,则每次点击按钮时,计数器组件也会被重新渲染,即使它的属性值没有变化。如果我们使用React.memo来包裹计数器组件,则只有当它的属性值变化时,才会触发它的重新渲染。

js
import React, { useState } from "react"; // 一个简单的计数器组件,接受一个数字作为属性值,并显示该数字 function Counter({ number }) { console.log("Counter is rendering"); return <p>当前数字:{number}</p>; } // 使用React.memo包裹计数器组件,使其只在属性值变化时才重新渲染 const MemoizedCounter = React.memo(Counter); // 一个简单的父组件,使用useState钩子来管理一个数字,并提供一个增加的按钮 function App() { // 使用useState钩子来创建一个状态变量number和一个更新函数setNumber const [number, setNumber] = useState(0); // 定义一个函数来增加数字 function handleIncrease() { // 调用setNumber函数来更新数字,传入一个函数作为参数,该函数接收上一次的数字作为参数,并返回新的数字 setNumber((prevNumber) => prevNumber + 1); } console.log("App is rendering"); return ( <div> <h1>React重新渲染示例</h1> <button onClick={handleIncrease}>增加</button> {/* 向计数器组件传递数字作为属性值 */} <MemoizedCounter number={number} /> </div> ); } export default App;

useMemo

使用useMemo钩子来缓存计算结果。这样可以让我们只在依赖项发生变化时才重新计算结果,而不是每次渲染都计算。这可以避免因为计算开销大而导致渲染时间过长的情况。

例如,下面的代码定义了一个简单的斐波那契数列组件,它接受一个数字作为属性值,并显示该数字对应的斐波那契数。在父组件中,我们使用useState钩子来管理一个数字,并提供一个增加和减少的按钮。每次点击按钮时,都会调用setNumber函数来更新数字,从而触发父组件和子组件的重新渲染。

如果我们不使用useMemo钩子来缓存斐波那契数的计算结果,则每次重新渲染都会重新执行斐波那契数的递归函数,这会消耗很多时间和资源。如果我们使用useMemo钩子来缓存斐波那契数的计算结果,则只有当依赖项变化时才会重新执行斐波那契数的递归函数。

js
import React, { useState, useMemo } from "react"; // 定义一个函数来计算斐波那契数,使用递归的方式 function fibonacci(n) { // 如果n小于等于1,则返回n if (n <= 1) { return n; } // 否则,返回前两项的和 return fibonacci(n - 1) + fibonacci(n - 2); } // 一个简单的斐波那契数列组件,接受一个数字作为属性值,并显示该数字对应的斐波那契数 function Fibonacci({ number }) { // 使用useMemo钩子来缓存斐波那契数的计算结果,传入一个函数作为参数,该函数返回斐波那契数的值,以及一个数组作为依赖项,该数组包含了影响计算结果的变量 const result = useMemo(() => { // 调用fibonacci函数来计算斐波那契数的值 return fibonacci(number); }, [number]); // 依赖项是number,只有当number变化时才会重新计算结果 console.log("Fibonacci is rendering"); return <p>第{number}项斐波那契数是:{result}</p>; } // 一个简单的父组件,使用useState钩子来管理一个数字,并提供一个增加和减少的按钮 function App() { // 使用useState钩子来创建一个状态变量number和一个更新函数setNumber const [number, setNumber] = useState(0); // 定义一个函数来增加数字 function handleIncrease() { // 调用setNumber函数来更新数字,传入一个函数作为参数,该函数接收上一次的数字作为参数,并返回新的数字 setNumber((prevNumber) => prevNumber + 1); } // 定义一个函数来减少数字 function handleDecrease() { // 调用setNumber函数来更新数字,传入一个函数作为参数,该函数接收上一次的数字作为参数,并返回新的数字 setNumber((prevNumber) => prevNumber - 1); } console.log("App is rendering"); return ( <div> <h1>React重新渲染示例</h1> <button onClick={handleIncrease}>增加</button> <button onClick={handleDecrease}>减少</button> {/* 向斐波那契数列组件传递数字作为属性值 */} <Fibonacci number={number} /> </div> ); } export default App;

useCallback

使用useCallback钩子来缓存函数。这样可以让我们只在依赖项发生变化时才重新创建函数,而不是每次渲染都创建。这可以避免因为传递了新的函数作为属性值而导致子组件重新渲染的情况。

例如,下面的代码定义了一个简单的计数器组件,它接受两个函数作为属性值,并提供增加和减少的按钮。在父组件中,我们使用useState钩子来管理一个数字,并定义两个函数来修改数字。每次点击按钮时,都会调用相应的函数来更新数字,从而触发父组件和子组件的重新渲染。

如果我们不使用useCallback钩子来缓存这两个函数,则每次重新渲染都会创建新的函数,并传递给子组件,从而触发子组件的重新渲染。如果我们使用useCallback钩子来缓存这两个函数,则只有当依赖项变化时才会重新创建函数,并传递给子组件。

js
import React, { useState, useCallback } from "react"; // 一个简单的计数器组件,接受两个函数作为属性值,并提供增加和减少的按钮 function Counter({ onIncrease, onDecrease }) { console.log("Counter is rendering"); return ( <div> <button onClick={onIncrease}>增加</button> <button onClick={onDecrease}>减少</button> </div> ); } // 一个简单的父组件,使用useState钩子来管理一个数字,并定义两个函数来修改数字 function App() { // 使用useState钩子来创建一个状态变量number和一个更新函数setNumber const [number, setNumber] = useState(0); // 使用useCallback钩子来缓存增加数字的函数,传入一个函数作为参数,该函数返回增加数字的操作,以及一个数组作为依赖项,该数组包含了影响函数结果的变量 const handleIncrease = useCallback(() => { // 调用setNumber函数来更新数字,传入一个函数作为参数,该函数接收上一次的数字作为参数,并返回新的数字 setNumber((prevNumber) => prevNumber + 1); }, []); // 依赖项是空数组,表示该函数不依赖于任何外部变量,只在组件挂载时创建一次 // 使用useCallback钩子来缓存减少数字的函数,传入一个函数作为参数,该函数返回减少数字的操作,以及一个数组作为依赖项,该数组包含了影响函数结果的变量 const handleDecrease = useCallback(() => { // 调用setNumber函数来更新数字,传入一个函数作为参数,该函数接收上一次的数字作为参数,并返回新的数字 setNumber((prevNumber) => prevNumber - 1); }, []); // 依赖项是空数组,表示该函数不依赖于任何外部变量,只在组件挂载时创建一次 console.log("App is rendering"); return ( <div> <h1>React重新渲染示例</h1> <p>当前数字:{number}</p> {/* 向计数器组件传递缓存后的函数作为属性值 */} <Counter onIncrease={handleIncrease} onDecrease={handleDecrease} /> </div> ); } export default App;

React.PureComponent/shouldComponentUpdate

使用React.PureComponent或者shouldComponentUpdate方法来优化类组件。这样可以让React在渲染组件之前先对比一下状态和属性值是否有变化,如果没有变化,则跳过渲染。这与React.memo类似,但是适用于类组件。

例如,下面的代码定义了一个简单的计数器组件,它继承自React.PureComponent类,并使用this.statethis.setState方法来管理计数值,并提供增加和减少的按钮。在父组件中,我们使用useState钩子来管理一个数字,并提供一个增加的按钮。

每次点击按钮时,都会调用setNumber函数来更新数字,从而触发父组件的重新渲染。如果我们不继承自React.PureComponent类,则每次点击按钮时,计数器组件也会被重新渲染,即使它的状态和属性值没有变化。如果我们继承自React.PureComponent类,则只有当它的状态或属性值变化时,才会触发它的重新渲染。

js
import React, { useState } from "react"; // 一个简单的计数器组件,继承自React.PureComponent类,并使用this.state和this.setState方法来管理计数值,并提供增加和减少的按钮 class Counter extends React.PureComponent { // 定义一个构造函数,接受一个属性对象作为参数,并调用父类的构造函数 constructor(props) { super(props); // 初始化状态对象,包含一个计数值 this.state = { count: 0, }; } // 定义一个方法来增加计数值 handleIncrease = () => { // 调用this.setState方法来更新状态对象,传入一个函数作为参数,该函数接收上一次的状态对象作为参数,并返回新的状态对象 this.setState((prevState) => ({ // 返回新的状态对象,将计数值加一 count: prevState.count + 1, })); }; // 定义一个方法来减少计数值 handleDecrease = () => { // 调用this.setState方法来更新状态对象,传入一个函数作为参数,该函数接收上一次的状态对象作为参数,并返回新的状态对象 this.setState((prevState) => ({ // 返回新的状态对象,将计数值减一 count: prevState.count - 1, })); }; // 定义一个渲染方法,返回一个JSX元素,显示当前计数值,并提供增加和减少的按钮 render() { console.log("Counter is rendering"); return ( <div> <p>当前计数:{this.state.count}</p> <button onClick={this.handleIncrease}>增加</button> <button onClick={this.handleDecrease}>减少</button> </div> ); } } // 一个简单的父组件,使用useState钩子来管理一个数字,并提供一个增加的按钮 function App() { // 使用useState钩子来创建一个状态变量number和一个更新函数setNumber const [number, setNumber] = useState(0); // 定义一个函数来增加数字 function handleIncrease() { // 调用setNumber函数来更新数字,传入一个函数作为参数,该函数接收上一次的数字作为参数,并返回新的数字 setNumber((prevNumber) => prevNumber + 1); } console.log("App is rendering"); return ( <div> <h1>React重新渲染示例</h1> <button onClick={handleIncrease}>增加</button> {/* 向计数器组件传递数字作为属性值 */} <Counter number={number} /> </div> ); } export default App;

key

使用key属性来优化列表渲染。这样可以让React在渲染列表时能够识别出每个列表项的唯一标识,从而避免不必要的创建和销毁。这可以提高列表渲染的性能和稳定性。

例如,下面的代码定义了一个简单的待办事项列表组件,它接受一个数组作为属性值,并显示数组中的每个元素。在父组件中,我们使用useState钩子来管理一个数组,并提供一个添加和删除的按钮。每次点击按钮时,都会调用相应的函数来更新数组,从而触发父组件和子组件的重新渲染。

如果我们不使用key属性来给每个列表项分配一个唯一标识,则每次重新渲染都会导致所有的列表项被重新创建和销毁,这会浪费很多时间和资源。如果我们使用key属性来给每个列表项分配一个唯一标识,则只有当列表项增加或减少时才会触发创建和销毁,而当列表项顺序变化时则只会触发移动,这会节省很多时间和资源。

js
import React, { useState } from "react"; // 一个简单的待办事项列表组件,接受一个数组作为属性值,并显示数组中的每个元素 function TodoList({ items }) { console.log("TodoList is rendering"); return ( <ul> {items.map((item) => ( // 使用key属性来给每个列表项分配一个唯一标识,这里我们使用item.id作为标识 <li key={item.id}>{item.text}</li> ))} </ul> ); } // 一个简单的父组件,使用useState钩子来管理一个数组,并提供一个添加和删除的按钮 function App() { // 使用useState钩子来创建一个状态变量items和一个更新函数setItems const [items, setItems] = useState([ // 初始化状态值为一个包含三个对象的数组,每个对象包含一个id和一个text { id: 1, text: "学习React" }, { id: 2, text: "写博客" }, { id: 3, text: "做运动" }, ]); // 定义一个函数来添加待办事项 function handleAdd() { // 调用setItems函数来更新状态值,传入一个函数作为参数,该函数接收上一次的状态值作为参数,并返回新的状态值 setItems((prevItems) => [ // 返回新的状态值,将上一次的状态值复制一份,并在末尾添加一个新的对象,该对象包含一个随机生成的id和一个固定的text ...prevItems, { id: Math.random(), text: "新待办事项" }, ]); } // 定义一个函数来删除待办事项 function handleDelete() { // 调用setItems函数来更新状态值,传入一个函数作为参数,该函数接收上一次的状态值作为参数,并返回新的状态值 setItems((prevItems) => [ // 返回新的状态值,将上一次的状态值复制一份,并删除第一个元素 ...prevItems.slice(1), ]); } console.log("App is rendering"); return ( <div> <h1>React重新渲染示例</h1> <button onClick={handleAdd}>添加</button> <button onClick={handleDelete}>删除</button> {/* 向待办事项列表组件传递数组作为属性值 */} <TodoList items={items} /> </div> ); } export default App;

总结

React是一个用于构建用户界面的JavaScript库,它使用虚拟DOM来高效地渲染组件。组件的状态(state)、属性(props)、上下文(context)和自定义钩子(custom hook)的变化会触发重新渲染。有时,我们需要避免不必要的重新渲染,以提高性能和稳定性。我们可以使用以下几种优化策略:

  • 使用React.memouseMemouseCallback来缓存函数式组件、计算结果和函数,使其只在依赖项变化时才重新渲染或创建。
  • 使用React.PureComponent或者shouldComponentUpdate方法来优化类组件,使其只在状态或属性值变化时才重新渲染。
  • 使用key属性来优化列表渲染,使React能够识别每个列表项的唯一标识,从而避免不必要的创建和销毁。
如果对你有用的话,可以打赏哦
打赏
ali pay
wechat pay

本文作者:CreatorRay

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!