浅析React渲染模型

150 阅读8分钟

摘要: 本篇文章主要描述React渲染模型是什么样的,它的渲染过程是怎样的、什么条件下引发渲染以及引发的性能优化相关的思考

参考文章:

1. React的渲染过程是怎样的?

渲染是一个过程,这个过程是你所使用的组件根据当前的propsstate来描述要呈现的UI部分时什么样子的!

在了解React渲染过程之前,我们首先要展开一下几个小知识点:

  1. React 的默认行为是当一个父组件渲染时,React 将递归渲染它组件树的所有子组件!
  2. React渲染模型的一个基本原则:React 中的每次渲染都发生在 state 变化。也就是说,state 变化是 React 重新渲染的唯一触发器。
  3. React每次渲染都像是一个快照,就像我们用手机拍下来的照片一样,根据当前的 state 来决定 UI 应该是什么样子。

我们以一个例子开头:

// 这是一个父组件, <Couter />为它的子组件
import React, {useState} from 'react';
function App() {
  return (
    <>
      <Counter />
    </>
  );
}
//组件Couter ,  <ShowNumber />为它的子组件
function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <main>
      <ShowNumber count={count} />
      <button onClick={() => setCount(count + 1)}>
        +1
      </button>
    </main>
  );
}
//组件<ShowNumber />
function ShowNumber({ count }) {
  return (
    <p>
      <span>总数:</span>
      {count}
    </p>
  );
}

export default App;

在这个例子中,我们有三个组件,层级顺序 <App /> > <Counter /> > <ShowNumber />, 在<Counter />中我们点击计数按钮后

  • 此时<Counter />组件由于state发生改变,<Counter />组件就会被标记为更新,等待更新
  • React从组件的的顶部开始渲染,<App />组件未被标记,跳过
  • 递归渲染下面的子组件,发现<Counter />组件被标记为更新,更新它
  • 根据知识点1,<Counter />组件下的<ShowNumber />组件由于父组件的渲染而渲染(这里要避开一个误区: <ShowNumber />的渲染和它的 props 没有直接关系,而是由于 Counter 渲染导致的无条件的渲染)
  • 此时渲染构造出新的虚拟Dom tree,并和原来的Dom tree用Diff算法进行比较,找到需要更新的地方批量改动,再渲染到真实的DOM上

2. Props的作用

有人会有这样的疑惑,既然state是导致React渲染的唯一方式,那props又是干嘛的呢?

我们先来解释一下props是什么 --> 其实就是这样:组件是一个函数,那props就是针对这个函数(组件)的入参 这个参数(props)是自上而下的,这里我们要注意一个点,props是只读的,我们不可以对props进行直接的修改,需要在它的最顶层修改state,来达到修改当前组件的props的目的,原因很简单,我们在函数内能直接修改参数嘛?只能在根源来解决对吧

我们对例子进行小小的改动(标准渲染):

// 这是一个父组件, <Couter />为它的子组件
import React, {useState} from 'react';
function App() {
  return (
    <>
      <Counter />
    </>
  );
}
//组件Couter ,  <ShowNumber />为它的子组件
function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <main>
      <ShowNumber />
      <button onClick={() => setCount(count + 1)}>
        +1
      </button>
    </main>
  );
}
//组件<ShowNumber />
function ShowNumber({ count }) {
  return (
    <p>
      <span>Hi, This is ShowNumber</span>
    </p>
  );
}

export default App;

点击<ShowNumber />的计数器按钮后,会发现,即使没有向子组件传入props,<ShowNumber />组件依然会渲染,那我们就会有个疑惑,我们并没有将count 作为 props 传递给 Decoration,它为什么要重新渲染?跟怪呀!

答案是这样的:我们把React想象的过于智能化了,它其实并没有想象中的那么智能,你当前的组件渲染的时候,它并不能100%确定子组件有没有直接或间接地使用变量count(纯组件、非纯组件),为了安全起见,它选择重新渲染这个子组件

那问题来了~~~ 那如果这样的话,每次父组件的渲染必然会引起子组件的渲染,那性能岂不是大打折扣?

首先组件的重新渲染是非常快的,在大多数的情况下我们根本不用关心这个问题,除非说你的组件下有很多子组件或者说组件内部的渲染逻辑很复杂的情况下,需要考虑这一点! 那既然React这样的渲染是由于它并不知道子组件有没有直接或间接的依赖父组件的变量,那我们是否可以明确让React知道这是一个纯组件(props变了就渲染,没变化就不渲染), 答案是 Of cource 可以的

3. 纯组件

React提供了两个API, React.memoReact.PureComponent

React.memo为例:

// 这是一个父组件, <Couter />为它的子组件
import React, {useState, memo} from 'react';
function App() {
  return (
    <>
      <Counter />
    </>
  );
}

//组件<ShowNumber />
function ShowNumber({ count }) {
  return (
    <p>
      <span>Hi, This is ShowNumber</span>
    </p>
  );
}

//memo包裹<ShowNumber />组件
const MemoShowNumber = memo(ShowNumber);

//组件Couter ,  <ShowNumber />为它的子组件
function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <main>
      <MemoShowNumber />
      <button onClick={() => setCount(count + 1)}>
        +1
      </button>
    </main>
  );
}


export default App;

使用memo来包裹组件,告诉React,这是一个纯组件,只有props发生改变我才会渲染,否则不渲染我! 当counter发生改变后,<Counter />重新渲染,然后渲染<MemoShowNumber />,但是在<MemoShowNumber />渲染之前,它会去对比 <MemoShowNumber /> 所依赖的 props 有没有发生改变,有的话渲染,否则跳过

注意不要过度的使用memo这个api来让组件都变成纯函数,就像刚才我们说的,React组件的重新渲染是很快的,而且纯组件中props比较也要花费时间,和重新渲染相比有时候可能会慢些!

再结合实际场景,props有时传入的不只是变量,有时候还会是函数,而在 JavaScript 中,function () {} 或者 () => {} 总是会生成不同的函数,这就会导致props 将永远不会是相同的,并且 memo对性能的优化永远不会生效,此时我们引入一个React的一个Hooks: useCallback

4. useCallback

useCallback 是一个允许你在多次渲染中缓存函数的 React Hook。 这个函数的表现形式为useCallback(fn, dependencies)

参数 

  • fn:想要缓存的函数。此函数可以接受任何参数并且返回任何值。React 将会在初次渲染而非调用时返回该函数。当进行下一次渲染时,如果 dependencies 相比于上一次渲染时没有改变,那么 React 将会返回相同的函数。否则,React 将返回在最新一次渲染中传入的函数,并且将其缓存以便之后使用。React 不会调用此函数,而是返回此函数。你可以自己决定何时调用以及是否调用。
  • dependencies:有关是否更新 fn 的所有响应式值的一个列表。响应式值包括 props、state,和所有在你组件内部直接声明的变量和函数。如果你的代码检查工具 配置了 React,那么它将校验每一个正确指定为依赖的响应式值。依赖列表必须具有确切数量的项,并且必须像 [dep1, dep2, dep3] 这样编写。React 使用 Object.is 比较每一个依赖和它的之前的值。

返回值

在初次渲染时,useCallback 返回你已经传入的 fn 函数

在之后的渲染中, 如果依赖没有改变,useCallback 返回上一次渲染中缓存的 fn 函数;否则返回这一次渲染传入的 fn

假设你正在从 ProductPage 传递一个 handleSubmit 函数到 ShippingForm 组件中:

function ProductPage({ productId, referrer, theme }) {  
// handleSubmit函数
const handleSubmit = () =>{
  post('/product/' + productId + '/buy', {  
      referrer,  
      orderDetails,  
    })
}
return (
<div className={theme}>  
    <ShippingForm onSubmit={handleSubmit} />  
</div>  

);

根据我们所掌握的渲染时机来说,theme一旦改变,那么ShippingForm组件就会重新渲染,我们此时把<ShippingForm />通过memo包裹一下,告诉这是一个纯组件,只依赖props渲染:

import { memo } from 'react';  

const ShippingForm = memo(function ShippingForm({ onSubmit }) {  
// ...  
});

此时我们运行后发现,这个memo的处理并没有什么用,因为这个props每次都是不相同的,永远不会跳过渲染,然后我们使用hooks: useCallback把传入的这个handleSubmit函数缓存下来:

//经过useCallback处理后的handleSubmit函数
const handleSubmit = useCallback(() =>{
  post('/product/' + productId + '/buy', {  
      referrer,  
      orderDetails,  
    })
},[productId,referrer])

handleSubmit 传递给 useCallback 就可以确保它在多次重新渲染之间是相同的函数,直到依赖发生改变。

咦~ 我记得还有一个useMemo,这个Hooks又是干嘛的呢?

5. useMemo

其实它和useCallback异曲同工,useCallback是缓存函数的hooks, useMemo是缓存函数返回的值的hooks~

useMemo 是一个 React Hook,它在每次重新渲染的时候能够缓存计算的结果。它的表现形式为const cachedValue = useMemo(calculateValue, dependencies)

我们来通过它与useCallback的区别来理解它的作用吧~~

import { useMemo, useCallback } from 'react';  

function ProductPage({ productId, referrer }) {  
       const product = useData('/product/' + productId);  
       const requirements = useMemo(() => { //调用函数并缓存结果  
           return computeRequirements(product);  
       }, [product]);  

       const handleSubmit = useCallback((orderDetails) => { // 缓存函数本身  
           post('/product/' + productId + '/buy', {  
              referrer,  
              orderDetails,  
           });  
       }, [productId, referrer]);  

return (  
   <div className={theme}>  
      <ShippingForm requirements={requirements} onSubmit={handleSubmit} />  
   </div>  
 );  
}

区别在于你需要缓存 什么:

  • useMemo 缓存函数调用的结果。在这里,它缓存了调用 computeRequirements(product) 的结果。除非 product 发生改变,否则它将不会发生变化。这让你向下传递 requirements 时而无需不必要地重新渲染 ShippingForm。必要时,React 将会调用传入的函数重新计算结果。
  • useCallback 缓存函数本身。不像 useMemo,它不会调用你传入的函数。相反,它缓存此函数。从而除非 productId 或 referrer 发生改变,handleSubmit 自己将不会发生改变。这让你向下传递 handleSubmit 函数而无需不必要地重新渲染 ShippingForm。直至用户提交表单,你的代码都将不会运行。

当然它们还有其它更多的作用,这里只是讲述与渲染相关的 具体的可以查看React文档 zh-hans.react.dev/

6. 结语:

文章中涉及到的技术可以再深入了解一下:

  • 告诉 React,这是一个纯组件,除非它的 props 发生变化,否则你不要重新渲染它。 所使用到的memoization技术,在里面有一个状态快照的说法,可以了解一下