理解 React 中的渲染行为

已发表: 2020-11-16

与生、死、命运和税收一起,React 的渲染行为是生活中最伟大的真理和奥秘之一。

让我们潜入吧!

和其他人一样,我从 jQuery 开始了我的前端开发之旅。 纯基于 JS 的 DOM 操作在当时是一场噩梦,所以这是每个人都在做的事情。 然后慢慢地,基于 JavaScript 的框架变得如此突出,以至于我不能再忽视它们了。

我学的第一个是 Vue。 我度过了一段难以置信的艰难时期,因为组件和状态以及其他一切都是一个全新的心理模型,将所有东西都融入其中是很痛苦的。但最终,我做到了,并拍拍自己的后背。 恭喜,伙计,我告诉自己,你已经完成了陡峭的攀登; 现在,其余的框架,如果您需要学习它们,将非常容易。

所以,有一天,当我开始学习 React 时,我意识到我是多么的大错特错。 Facebook 加入 Hooks 并告诉所有人:“嘿,从现在开始使用它,这并没有让事情变得更容易。 但是不要重写类; 上课很好。 其实也不算多,不过还好。 但 Hooks 就是一切,它们就是未来。

知道了? 伟大的!”。

最后,我也翻过了那座山。 但后来我遇到了与 React 本身一样重要且困难的事情:渲染

惊喜!!!

如果您在 React 中遇到过渲染及其奥秘,您就会知道我在说什么。 如果你没有,你不知道有什么适合你!

但是在浪费时间做任何事情之前,最好先问问你会从中得到什么(不像我,他是一个过度兴奋的白痴,会为了它而高兴地学习任何东西)。 如果您作为 React 开发人员的生活过得很好,而不必担心渲染是什么,那么为什么要关心呢? 好问题,所以让我们先回答这个问题,然后我们会看到渲染实际上是什么。

为什么理解 React 中的渲染行为很重要?

我们都通过编写(这些天,功能性的)组件开始学习 React,这些组件返回称为 JSX 的东西。 我们还了解到,这个 JSX 以某种方式转换为页面上显示的实际 HTML DOM 元素。 页面随着状态更新而更新,路由按预期更改,一切都很好。 但是这种关于 React 工作原理的观点是幼稚的,并且是许多问题的根源。

虽然我们经常成功地编写完整的基于 React 的应用程序,但有时我们会发现应用程序的某些部分(或整个应用程序)非常缓慢。 而最糟糕的部分。 . . 我们不知道为什么! 我们所做的一切都是正确的,我们没有看到任何错误或警告,我们遵循了组件设计、编码标准等的所有良好实践,并且没有网络缓慢或昂贵的业务逻辑计算在幕后进行。

有时,这是一个完全不同的问题:性能没有问题,但应用程序的行为很奇怪。 例如,对身份验证后端进行 3 次 API 调用,但只对所有其他 API 调用一次。 或者某些页面被重绘了两次,同一页面的两个渲染之间的可见过渡创建了一个不和谐的用户体验。

不好了! 又不行了!!

最糟糕的是,在这种情况下没有可用的外部帮助。 如果你去你最喜欢的开发论坛问这个问题,他们会回答:“不看你的应用就无法判断。 你能在这里附上一个最低限度的工作例子吗?” 好吧,当然,出于法律原因,您不能附加整个应用程序,而该部分的一个小型工作示例可能不包含该问题,因为它与整个系统的交互方式与实际应用程序中的方式不同。

搞砸了? 是的,如果你问我。

所以,除非你想看到这样的悲惨日子,否则我建议你培养一种理解——和兴趣,我必须坚持; 勉强获得的理解不会让你在 React 世界中走得更远——在这个被称为 React 渲染的鲜为人知的事情中。 相信我,它并不难理解,虽然它很难掌握,但你会走得很远,而不必了解每一个角落。

React 中的渲染是什么意思?

我的朋友,这是一个很好的问题。 我们在学习 React 时不倾向于问它(我知道是因为我没有),因为“渲染”这个词可能会让我们陷入一种错误的熟悉感。 虽然字典的含义完全不同(在本次讨论中并不重要),但我们程序员已经对它的含义有了一个概念。 当我们阅读“渲染”这个词时,使用屏幕、3D API、显卡和阅读产品规格会训练我们的大脑去思考类似于“画一幅画”的东西。 在游戏引擎编程中,有一个渲染器,其唯一的工作就是——准确地说!绘制场景所传递的世界。

因此我们认为,当 React “渲染”某些东西时,它会收集所有组件并重新绘制网页的 DOM。 但是在 React 世界中(是的,甚至在官方文档中),这并不是渲染的意义所在。 所以,让我们系好安全带,真正深入了解一下 React 的内部结构。

“我完蛋了 。 . 。”

您一定听说过 React 维护所谓的虚拟 DOM,它会定期将其与实际 DOM 进行比较并根据需要应用更改(这就是为什么您不能将 jQuery 和 React 放在一起——React 需要完全控制DOM)。 现在,这个虚拟 DOM 不像真正的 DOM 那样由 HTML 元素组成,而是由 React 元素组成。 有什么不同? 好问题! 为什么不创建一个小的 React 应用程序并亲自看看呢?

为此,我创建了这个非常简单的 React 应用程序。 整个代码只是一个包含几行的文件:

 import React from "react"; import "./styles.css"; export default function App() { const element = ( <div className="App"> <h1>Hello, there!</h1> <h2>Let's take a look inside React elements</h2> </div> ); console.log(element); return element; }

注意我们在这里做什么?

是的,只需记录 JSX 元素的样子。 这些 JSX 表达式和组件是我们已经编写了数百次的东西,但我们很少关注发生了什么。 如果您打开浏览器的开发控制台并运行此应用程序,您将看到一个Object为:

这可能看起来令人生畏,但请注意一些有趣的细节:

  • 我们看到的是一个普通的、常规的 JavaScript 对象,而不是一个 DOM 节点。
  • 请注意, props属性表示它的classNameApp (这是代码中设置的 CSS 类),并且该元素有两个子元素(这也匹配,子元素是<h1><h2>标签) .
  • _source属性告诉我们源代码从哪里开始元素的主体。 如您所见,它将文件App.js为源文件并提及第 6 行。如果您再次查看代码,您会发现第 6 行就在 JSX 开头标记之后,这是有道理的。 JSX 括号包含React 元素; 它们不是其中的一部分,因为它们稍后会转换为React.createElement()调用。
  • __proto__属性告诉我们这个对象派生了它的所有。 来自根 JavaScript Object的属性,再次强化了我们在这里看到的只是日常 JavaScript 对象的想法。

所以,现在,我们了解到所谓的虚拟 DOM 看起来不像真正的 DOM,而是代表当时 UI 的 React (JavaScript) 对象树。

*叹息*。 . . 我们到了吗?

筋疲力尽的?

相信我,我也是。 在我的脑海里一遍又一遍地翻动这些想法,试图以最好的方式呈现它们,然后想出用词把它们带出来并重新排列它们——这并不容易。

但是我们分心了!

经历了这么多,我们现在可以回答我们所追求的问题:React 中的渲染是什么?

好吧,渲染是 React 引擎进程遍历虚拟 DOM 并收集当前状态、道具、结构、UI 中所需的更改等。React 现在使用一些计算更新虚拟 DOM,并将新结果与实际 DOM 进行比较在页面上。 这种计算和比较就是 React 团队官方所说的“和解”,如果你对他们的想法和相关算法感兴趣,可以查看官方文档。

是时候提交了!

渲染部分完成后,React 开始一个称为“提交”的阶段,在此期间它将必要的更改应用于 DOM。 这些更改是同步应用的(一个接一个,尽管很快就会出现一种同时工作的新模式),并且更新 DOM。 React 何时以及如何应用这些更改并不是我们关心的问题,因为它完全在幕后,并且随着 React 团队尝试新事物而不断变化。

React 应用程序中的渲染和性能

我们现在已经明白,渲染意味着收集信息,它不需要每次都导致视觉 DOM 变化。 我们也知道,我们认为的“渲染”是一个涉及渲染和提交的两步过程。 我们现在将看到如何在 React 应用程序中触发渲染(更重要的是重新渲染),以及不了解细节如何导致应用程序性能不佳。

由于父组件的变化而重新渲染

如果 React 中的父组件发生更改(例如,因为其状态或道具发生更改),React 会沿着该父元素遍历整个树并重新渲染所有组件。 如果您的应用程序有许多嵌套组件和大量交互,那么每次更改父组件时都会在不知不觉中对性能造成巨大影响(假设它只是您想要更改的父组件)。

没错,渲染不会导致 React 改变实际的 DOM,因为在协调期间,它会检测到这些组件没有任何变化。 但是,它仍然浪费了 CPU 时间和内存,你会惊讶于它加起来的速度有多快。

由于上下文变化而重新渲染

React 的 Context 功能似乎是每个人最喜欢的状态管理工具(它根本不是为它而构建的)。 这一切都非常方便——只需将最顶层的组件包装在上下文提供程序中,剩下的就很简单了! 大多数 React 应用程序都是这样构建的,但是如果你到目前为止已经阅读了这篇文章,你可能已经发现了问题所在。 是的,每次更新上下文对象时,都会触发所有树组件的大规模重新渲染。

大多数应用程序都没有性能意识,因此没有人注意到,但如前所述,在大容量、高交互的应用程序中,这种疏忽可能会造成非常高的成本。

提高 React 渲染性能

那么,考虑到这一切,我们可以做些什么来提高应用程序的性能呢? 事实证明,我们可以做一些事情,但请注意,我们只会在功能组件的上下文中讨论。 React 团队非常不鼓励基于类的组件,并且正在退出。

使用 Redux 或类似的库进行状态管理

那些喜欢快速而肮脏的 Context 世界的人往往会讨厌 Redux,但它非常受欢迎是有充分理由的。 其中一个原因是性能——Redux 中的connect()函数很神奇,因为它(几乎总是)正确地只根据需要渲染那些组件。 是的,只要遵循标准的 Redux 架构,性能就免费了。 毫不夸张地说,如果您采用 Redux 架构,您会立即避免大部分性能(和其他)问题。

使用memo()来“冻结”组件

“备忘录”这个名字来自于记忆化,这是一个用于缓存的花哨名称。 如果你没有遇到太多缓存,没关系; 这是一个淡化的描述:每次您需要一些计算/运算结果时,您都会查看您一直在维护先前结果的地方; 如果你找到了,太好了,只需返回该结果; 如果没有,请继续执行该操作/计算。

在直接进入memo()之前,让我们先看看 React 中不必要的渲染是如何发生的。 我们从一个简单的场景开始:应用程序 UI 的一小部分,向用户显示他们喜欢该服务/产品的次数(如果您在接受用例时遇到困难,想想如何在 Medium 上“鼓掌” ”多次显示您对文章的支持/喜欢程度)。

还有一个按钮,可以让他们将喜欢的次数增加 1。最后,里面还有另一个组件,可以向用户显示他们的基本帐户详细信息。 如果您发现这很难理解,请不要担心; 我现在将为所有内容提供分步代码(而且内容不多),最后提供一个指向游乐场的链接,您可以在其中弄乱正在运行的应用程序并提高您的理解。

让我们首先处理有关客户信息的组件。 让我们创建一个名为CustomerInfo.js的文件,其中包含以下代码:

 import React from "react"; export const CustomerInfo = () => { console.log("CustomerInfo was rendered! :O"); return ( <React.Fragment> <p>Name: Sam Punia</p> <p>Email: [email protected]</p> <p>Preferred method: Online</p> </React.Fragment> ); };

没什么花哨的,对吧?

只是一些信息文本(可能已经通过道具传递),预计不会随着用户与应用程序交互而改变(对于那里的纯粹主义者来说,是的,肯定它可以改变,但重点是,与其他人相比的应用程序,它实际上是静态的)。 但请注意console.log()语句。 这将是我们知道组件被渲染的线索(记住,“渲染”意味着它的信息被收集和计算/比较,而不是它被绘制到实际的 DOM 上)。

因此,在我们的测试过程中,如果我们在浏览器控制台中没有看到这样的消息,那么我们的组件根本没有被渲染; 如果我们看到它出现了 10 次,这意味着该组件被渲染了 10 次; 等等。

现在让我们看看我们的主要组件如何使用这个客户信息组件:

 import React, { useState } from "react"; import "./styles.css"; import { CustomerInfo } from "./CustomerInfo"; export default function App() { const [totalLikes, setTotalLikes] = useState(0); return ( <div className="App"> <div className="LikesCounter"> <p>You have liked us {totalLikes} times so far.</p> <button onClick={() => setTotalLikes(totalLikes + 1)}> Click here to like again! </button> </div> <div className="CustomerInfo"> <CustomerInfo /> </div> </div> ); }

因此,我们看到App组件通过useState()钩子进行了内部状态管理。 此状态不断计算用户喜欢该服务/站点的次数,并且最初设置为零。 就 React 应用程序而言,没有什么挑战性的,对吧? 在 UI 方面,情况如下所示:

这个按钮看起来太诱人了,至少对我来说是这样! 但在此之前,我将打开浏览器的开发控制台并清除它。 之后,我将按下按钮几次,这就是我所看到的:

我已经点击了 19 次按钮,正如预期的那样,总点赞数为 19。配色方案很难阅读,所以我添加了一个红色框来突出显示主要内容: <CustomerInfo />组件被渲染了20次!

为什么是20?

一次是在最初渲染所有内容时,然后是点击按钮时的 19 次。 按钮改变totalLikes ,这是<App />组件内部的一个状态,结果,主组件重新渲染。 正如我们在本文前面部分所了解的,其中的所有组件也会重新渲染。 这是不需要的,因为<CustomerInfo />组件在过程中没有改变,但对渲染过程有贡献。

我们如何防止这种情况发生?

正如本节的标题所说,使用memo()函数创建<CustomerInfo />组件的“保留”或缓存副本。 使用 memoized 组件,React 查看它的 props 并将它们与之前的 props 进行比较,如果没有变化,React 不会从该组件中提取新的“渲染”输出。

让我们将这行代码添加到我们的CustomerInfo.js文件中:

 export const MemoizedCustomerInfo = React.memo(CustomerInfo);

是的,这就是我们需要做的! 现在是时候在我们的主要组件中使用它,看看是否有什么变化:

 import React, { useState } from "react"; import "./styles.css"; import { MemoizedCustomerInfo } from "./CustomerInfo"; export default function App() { const [totalLikes, setTotalLikes] = useState(0); return ( <div className="App"> <div className="LikesCounter"> <p>You have liked us {totalLikes} times so far.</p> <button onClick={() => setTotalLikes(totalLikes + 1)}> Click here to like again! </button> </div> <div className="CustomerInfo"> <MemoizedCustomerInfo /> </div> </div> ); }

是的,只改变了两行,但我还是想展示整个组件。 UI 方面没有任何改变,所以如果我试一试新版本并按下“赞”按钮几次,我会得到:

那么,我们有多少控制台消息?

只有一个! 这意味着除了初始渲染之外,根本没有触及组件。 想象一下真正大规模应用程序的性能提升! 好的好的,我承诺的代码游乐场的链接在这里。 要复制前面的示例,您需要从CustomerInfo.js导入并使用CustomerInfo而不是MemoizedCustomerInfo

也就是说, memo()并不是你可以随处洒落并期待神奇结果的神奇沙子。 过度使用memo()也会在你的应用程序中引入棘手的错误,有时,只会导致一些预期的更新失败。 关于“过早”优化的一般建议也适用于此。 首先,按照您的直觉构建您的应用程序; 然后,做一些深入的分析,看看哪些部分是慢的,如果记忆化的组件看起来是正确的解决方案,然后才引入这个。

“智能”组件设计

我把“智能”放在引号中是因为:1)智能是高度主观和情境化的; 2) 所谓的聪明行为往往会产生不愉快的后果。 所以,我对本节的建议是:不要对你正在做的事情过于自信。

除此之外,提高渲染性能的一种可能性是设计和放置组件的方式略有不同。 例如,可以重构子组件并将其移动到层次结构的上方某处,以避免重新渲染。 没有规则说“ChatPhotoView 组件必须始终位于 Chat 组件内”。 在特殊情况下(这些是我们有数据支持的证据表明性能受到影响的情况),弯曲/打破规则实际上是一个好主意。

结论

总体而言,可以做更多的事情来优化 React 应用程序,但是由于本文是关于渲染的,所以我限制了讨论的范围。 无论如何,我希望你现在可以更好地了解 React 底层发生了什么,渲染实际上是什么,以及它如何影响应用程序性能。

接下来,我们来了解一下什么是 React Hooks?