React本质上是建立用户界面的库。一个公式有助于理解React:view=function(state)
,或简写为v=f(s)
。下一个问题是:React在什么时间、如何更新视图?回答这个问题之前,我们先弄清楚——什么是渲染?
本文内容来自React.gg。
什么是渲染(rendering)
长话短说,渲染是指React调用部件(Component)更新视图。
React渲染部件的时候会发生两件事。首先React会为需要渲染的部件创建快照,这个快照包含属性、状态、事件处理函数,以及UI的描述。
为了得到你的应用的初始UI,React需要做初始的渲染,这个初始渲染发生在root
上。
import { createRoot } from "react-dom/client";
import App from "./App";
const rootElement = document.getElementById("root");
const root = createRoot(rootElement);
root.render(<App />);
当然,有趣的事都发生在初始渲染之后的子渲染上。那么,到底React在什么时候重新渲染一个部件?像上面公式所示,当s
变化的时候,f
被激活。
React什么时候重新渲染(re-rendering)
触发React部件重新渲染的唯一条件是状态的改变。React怎么知道部件的状态发生了改变?与上面提到的快照有关。当事件处理函数(event handler)被激活,函数会访问部件的属性(props)和状态(state),这些属性和状态都已经被保存在快照里的。
如果事件处理函数包含改变状态的内容,React会比较新的状态与快照中保存的状态,如果状态发生改变,会处罚部件的的重新渲染——创建新的快照,更新视图。
现在我们已经建立了React渲染原理的心智模型,接下来是实践时间。假设我们需要一个简单的应用,用户点击按钮后切换不同的问候语。为了实现这个功能,我们将问候语放入一个数组,然后用状态index
存储当前的问候语。用户点击按钮后,或者增加index
的值,如果到达数组最后一个元素,则将其重置为0
。代码如下:
import * as React from "react"
function Greeting ({ name }) {
const [index, setIndex] = React.useState(0)
const greetings = ['Hello', "Hola", "Bonjour"]
const handleClick = () => {
const nextIndex = index === greetings.length - 1
? 0
: index + 1
setIndex(nextIndex)
}
return (
<main>
<h1>{greetings[index]}, {name}</h1>
<button onClick={handleClick}>Next Greeting</button>
</main>
)
}
export default function App () {
return <Greeting name="Tyler" />
}
现在只要按钮被点击,handleClick
事件处理程序就会运行。handleClick
中的状态index
与最近的快照中的状态相同。事件处理程序中React看到有一个对setIndex
的调用,并且传递给它的值与快照中的状态不同,因此触发了重新渲染。
现在看另一段代码:
import * as React from "react"
export default function VibeCheck () {
const [status, setStatus] = React.useState("clean")
const handleClick = () => {
setStatus("dirty")
alert(status)
}
return (
<button onClick={handleClick}>
{status}
</button>
)
}
这个例子与上面的没有什么不同。
当handleClick
事件处理程序运行时,它访问快照创建时的props
和state
——在那个时刻,state
的值是clean
。因此提醒的状态是clean
。
再次点击按钮,因为之前的按钮点击触发了重新渲染,并创建了一个新的快照,其状态为dirty
,在最初的点击之后的任何点击中,我们都会得到dirty
。
继续,下面的代码中,点击按钮后会发生什么?
import * as React from "react"
export default function Counter () {
console.count("Rendering Counter")
const [count, setCount] = React.useState(0)
const handleClick = () => {
console.count("click")
setCount(count)
}
return (
<button onClick={handleClick}>
🤨
</button>
)
}
当按钮被点击,React运行事件处理程序并看到在其中调用了一个更新状态的函数。然后它注意到新的状态0
和快照中的状态0
是一样的。因此React没有触发重新渲染,快照和视图保持不变。
同样,只有当事件处理程序包含对useState
的状态更新函数的调用,并且React看到新的状态与快照中的状态不同,React才会重新渲染。
下面的代码,按钮被点击后count
的值是多少?
import * as React from "react"
export default function Counter () {
const [count, setCount] = React.useState(0)
const handleClick = () => {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
}
return (
<main>
<h1>{count}</h1>
<button onClick={handleClick}>
+
</button>
</main>
)
}
同样,当handleClick
事件处理程序运行时,它访问快照创建时的props
和state
——在那一刻,计数是0。最终,一旦React完成了对新state
的计算,它就会发现新状态1
与快照中的状态0
不同。一旦理解了渲染的工作原理,这类问题很容易理解。但在看了上一个例子后,可能会有一个问题。当按钮被点击,计数器组件会重新渲染多少次?直觉可能是,React会对它遇到的每个更新器函数进行重新渲染,所以在例子中是3次。
const handleClick = () => {
setCount(0 + 1)
setCount(0 + 1)
setCount(0 + 1)
}
值得庆幸的是不是这样,因为这将导致大量不必要的重新渲染。相反,React只会在考虑到事件处理程序中的每个更新函数并确定最终状态后才会重新渲染。所以在我们的例子中,React每次点击只重新渲染一次。
React如何计算状态更新的?答案是分批处理。React只在考虑到事件处理程序内部的每个更新器函数后才重新渲染,这意味着React有某种内部算法用来计算新的状态。React把这种算法称为 “批处理”。这个概念很容易理解。每当React遇到同一更新函数的多次调用(例如例子中的setCount
),它将跟踪每一个,但只有最后一次调用的结果将被用作新状态。上面的例子中state
的值会是3
。
但有一种方法可以告诉React使用更新器函数的前一次调用的值,而不是替换它。要做到这一点,你要传递给更新函数一个函数,该函数将接收最近一次调用的值作为其参数。
const handleClick = () => {
setCount(1)
setCount(2)
setCount((c) => c + 3)
}
在这个例子中c
将是2
,因为这是在回调函数运行之前传递给setCount
的最近一次调用的结果。因此,最终的状态将是2+3
,或5
。
下面的代码呢?
const handleClick = () => {
setCount(1)
setCount((c) => c + 3)
setCount(7)
setCount((c) => c + 10)
}
state
会是7+10
,或17
。
话归正题,看另一个例子。下面的代码,在点击按钮3次后,用户界面将显示什么,控制台将显示什么内容,以及App将重新渲染多少次?
import * as React from 'react'
export default function App () {
const [linear, setLinear] = React.useState(0)
const [exponential, setExponential] = React.useState(1)
const handleClick = () => {
setLinear(linear + 1)
setExponential(exponential * 2)
console.log({ linear, exponential })
}
return (
<main>
<p>Linear: {linear}</p>
<p>Exponential: {exponential}</p>
<button onClick={handleClick}>
Do Math
</button>
</main>
)
}
第一次点击按钮时,用户界面将显示1,2
,控制台将显示{linear:0,exponential:1 }
,而应用程序组件将重新渲染一次。
第二次点击按钮时,用户界面将显示2,4
,控制台将显示{linear:1,exponential:2 }
,并且应用程序组件将重新渲染两次。
第三次点击按钮时,用户界面将显示3,8
,控制台将显示{linear:2,exponential:4 }
,应用程序组件将重新渲染三次。
这个例子展示了React如何重新渲染的另一个有趣的方面。就是说React对每个事件处理程序只重新渲染一次,即使该事件处理程序包含多个状态的更新。这是另一个例子,说明React只有在绝对必要时才会重新渲染一个组件。考虑到这一点,让我们看看另一个可能让你吃惊的例子。
以下是上面的问候的代码:
import * as React from "react"
function Greeting ({ name }) {
const [index, setIndex] = React.useState(0)
const greetings = ['Hello', "Hola", "Bonjour"]
const handleClick = () => {
const nextIndex = index === greetings.length - 1
? 0
: index + 1
setIndex(nextIndex)
}
return (
<main>
<h1>{greetings[index]}, {name}</h1>
<button onClick={handleClick}>
Next Greeting
</button>
</main>
)
}
export default function App () {
return <Greeting name="Tyler" />
}
现在假设我们想让 “问候 “组件变得更温馨一些。为了做到这一点,我们将在Greeting中创建并渲染一个Wave组件,它将在用户界面的右上方添加一个👋表情符号。
// Wave.jsx
import * as React from "react"
export default function Wave () {
return (
<span role="img" aria-label="hand waving">
👋
</span>
)
}
</pre>
// App.jsx
import * as React from "react"
import Wave from "./Wave"
function Greeting ({ name }) {
const [index, setIndex] = React.useState(0)
const greetings = ['Hello', "Hola", "Bonjour"]
const handleClick = () => {
const nextIndex = index === greetings.length - 1
? 0
: index + 1
setIndex(nextIndex)
}
return (
<main>
<h1>{greetings[index]}, {name}</h1>
<button onClick={handleClick}>
Next Greeting
</button>
<Wave />
</main>
)
}
export default function App () {
return <Greeting name="Tyler" />
}
注意到应用的工作方式有什么特别之处吗?在试之前,试着猜一下的嵌套的Wave组件何时会重新渲染。
你的直觉可能认为永远不会。毕竟如果React真的只有在绝对必要的时候才会重新渲染,为什么Wave会重新渲染,因为它不接受任何props
,也没有任何state
。
实际上,每当点击按钮时,Wave就会重新显示(改变Greeting内部的index
状态时)。这可能不是很直观,但它展示了React的一个重要方面。每当状态发生变化时,React都会重新渲染拥有该状态的组件及其所有的子组件——不管这些子组件是否接受任何props
。
这可能看起来个奇怪。React不是应该只在子组件的道具发生变化时才重新渲染吗?其他的似乎都是一种浪费。
首先,React在渲染方面非常出色。如果你有一个性能问题,现实是它很少是因为太多的渲染。
其次,假设React只在子组件的道具发生变化时才重新渲染,这在React组件总是纯函数的世界里是可行的,而且props
是这些组件唯一需要渲染的东西。问题是,正如任何建立过真实世界的React应用的人所知道的,情况并不总是如此。
为了成为一个实用的工具,而不仅仅是一个我们在计算机科学课程中讨论的哲学工具,React提供了一些逃生舱口来突破其正常的v = fn(s)
范式。要知道,我们不能只是假设一个组件只在其props
改变时才重新渲染。
第三,如果你确实有一个昂贵的组件,并且你想让这个组件选择脱离这个默认行为,只在其props
改变时重新渲染,你可以使用React的React.memo
高阶组件。
组件
React.memo
是一个函数,它接收React组件作为参数,并返回一个新的组件,只有在其props
发生变化时才会重新渲染。
// Wave.jsx
import * as React from "react"
function Wave () {
console.count('Rendering Wave')
return (
<span role="img" aria-label="hand waving">
👋
</span>
)
}
export default React.memo(Wave)
现在,无论点击多少次按钮,Wave都只会在初始渲染时渲染一次。
但是,即使在处理子组件的时候,我们建立的心理模型也仍然适用。任何时候一个React组件的渲染,不管它为什么或位于组件树的什么位置,React都会创建一个组件的快照,它捕捉到React在那个特定时刻更新视图所需要的一切。props
、state
、event handlers
和UI的描述(基于这些props
和state
)都在这个快照里。
从那里,React将用户界面的描述用于更新视图。
StrictMode
组件
你可能已经听说过React的StrictMode组件了。这是React的说法:”如果我们把这个非常简单的心理模型完全炸掉,会怎么样?”
这是一种夸张的说法,但它确实改变了一些东西。
只要你启用了StrictMode,React就会额外重新渲染你的组件。
在这之前,我们所有的例子都是禁用严格模式的,原因很明显。但为了让你看到它的作用,这里是Wave例子,现在是StrictMode。注意,每次点击按钮时,应用程序就会渲染两次。
这可能看起来很奇怪,但StrictMode确保应用程序对重新渲染有弹性,而且组件是纯净的。如果不是这样,当React第二次渲染的时候就会变得很明显。
不管React渲染一次还是100次,因为视图应该是状态的一个函数,它不应该有问题。StrictMode可以帮助你确保这一点。
启用StrictMode的方法是像这样把你的应用程序包裹起来:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
const root = createRoot(
document.getElementById('root')
);
root.render(
<StrictMode>
<App />
</StrictMode>
);
最后一个问题,这对性能没有影响吗?是的,但React只在开发模式时允许StrictMode。在生产模式中它将被忽略。