探究React的渲染

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事件处理程序运行时,它访问快照创建时的propsstate——在那个时刻,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事件处理程序运行时,它访问快照创建时的propsstate——在那一刻,计数是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在那个特定时刻更新视图所需要的一切。propsstateevent handlers和UI的描述(基于这些propsstate)都在这个快照里。

从那里,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。在生产模式中它将被忽略。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容