React18原理: 渲染与更新时的重点关注事项
概述
- react 在渲染过程中要做很多事情,所以不可能直接通过初始元素直接渲染
- 还需要一个东西,就是虚拟节点,暂不涉及React Fiber的概念,将vDom树和Fiber 树统称为虚拟节点
- 有了初始元素后,React 就会根据初始元素和其他可以生成虚拟节点的东西生成虚拟节点
- React一定是通过虚拟节点来进行渲染的
常用节点类型
- 除了初始元素能生成虚拟节点以外,还有哪些可能生成虚拟节点?总共有多少节点类型?
1. Dom节点 (ReactDomComponent)
- 此dom非彼dom, 这里的dom指的是虚拟dom节点,当初始化元素的type属性为字符串的时候
- React 就会创建虚拟dom节点,例如,前面使用 jsx 直接书写的
const B = <div></div>
- 它的属性就是div, 可以打印出来
{ type: 'div' }
2. 组件节点 (ReactComposite)
- class组件和函数式组件
- type 有两类:class App 或 f Test() 这种举例
3. 文本节点 (ReactTextNode)
- 直接书写字符串或数字,React 会创建为文本节点
- 比如,我们可以直接用 ReactDOM.render 方法直接渲染字符串或数字
import ReactDOM from 'react-dom/client'; const root = ReactDOM.createRoot(document.getElementById('root')); // root.render('一头猪') // 创建文本节点 root.render(1111); // 创建文本节点
4. 空节点(ReactEmpty)
- 我们平时写 React 代码的时候,经常会写三目表达式
{this.state.xxx ? <App/> : false}
- 用来进行条件渲染,只知道为 false 就不会渲染,到底是怎么一回事?
- 其实,遇到字面量 null, false, true, undefined 在 React 中均会被创建一个空节点
- 在渲染过程中,如果遇到空节点,那么它将什么都不会做
import ReactDOM from 'react-dom/client' const root = ReactDOM.createRoot(document.getElementById('root')); // root.render(flase); // 创建空节点 // root.render(true); // 创建空节点 root.render(null) root.render(undefined) // 创建空节点
5. 数组节点(ReactArrayNode)
- 不是渲染数组本身,当React遇到数组时,会创建数组节点,但是不会直接进行渲染
- 而是将数组里的每一项按出来,根据不同节点类型去做相应的事情
- 所以,数组里的每一项只能是这里提到的五个节点类型
渲染过程
- 通过 document.createElement 创建的元素就是真实的dom
- React 的工作是通过初始元素或可以生成虚拟节点的东西生成虚拟节点然后针对不同的节点类型去做不同的事情最终生成真实dom挂载到页面上
- 渲染原理
- 初始元素和可以生成虚拟节点的东西
- 虚拟节点:根据不同的节点去做不同的事情
- 挂载到界面(UI)
首次渲染阶段
-
React 会根据初始元素先生成虚拟节点,然后做了一系列操作后最终渲染成真实的UI
-
根据不同的虚拟节点来看它到底做了些什么处理?
-
1 )初始元素-dom节点
- 对于初始元素的 type 属性为字符串时,React会通过 document.createElement 来创建真实DOM
- 因为,初始元素的 type 为字符串,所以直接会根据 type 属性创建不同的真实DOM
- 创建完真实DOM后立即设置该真实dom的所有属性,比如,直接在jsx中可以直接书写的 className, style 等都会作用到真实dom上
// jsx 语法: React初始元素 const B = <div class='wrapper' style={{color: 'red'}}> <p className='text'>123</p> </div>
- 当然 html 结构肯定不止一层,所以,在设置完属性后React会根据children属性进行递归遍历
- 根据不同的 节点类型 去做不同的事情,同样的,如果 children 是初始元素,创建真实dom、设置属性
- 然后检查是否有子元素,重复次步骤,移植到最后一个元素位置,遇到其他节点类型会做以下事情
-
2 )初始元素-组件节点
- 如果初始元素的 type 属性是一个 class 类 或 function 函数时
- 那么会创建一个组件节点,所以,针对类或函数组件, 它的处理是不同的
- 函数组件
- 对于函数组件会直接调用函数,将函数的返回值进行递归处理
- 看看是什么节点类型,然后去做对应的事情,所以一定要返回能生成虚拟节点的东西
- 最终生成一棵vDOM树
- 类组件
- 对于类组件而言,会相对麻烦一些
- a. 首先创建类的实例(调用constructor)
- b. 调用生命周期方法 static getDerivedStateFromProps
- c. 调用生命周期方法 render, 根据返回值递归处理,跟函数组件处理返回值一样,最终生成一棵 vDom树
- d. 将该组件的生命周期方法 componentDidMount 加入到执行队列中等待真实dom挂载到页面后执行
- 注意
- 前面说了 render 是一个递归处理,所以如果一个组件存在 父子关系的时候
- 那么肯定要等子组件渲染完
- 父组件才能走出 render, 所以,子组件的 componentDidMount 一定是比父组件
- 先入队列的,肯定先运行
- 对于类组件而言,会相对麻烦一些
-
3 )文本节点
- 针对文本节点,会直接通过 document.createTextNode 创建真实的文本节点
-
4 )空节点
- 如果生成的是 空节点,那么它将什么都不会做
-
5 )数组节点
- 就像前面提到的一样,React不会直接渲染数组,而是将里面的每一项拿出来遍历
- 根据不同的节点类型去做不同的事,直到递归处理完数组里的每一项 (这里流一个问题,为何数组里要写 key)
-
注意,嵌套组件渲染时的大致执行顺序
- 先执行父组件的 constructor, getDerivedStateFromProps, render
- 再执行子组件的 constructor, getDerivedStateFromProps, render, componentDidMount
- 最后执行父组件的 componentDidMount
更新与卸载
- 挂载完成后组件进入活跃状态,等待数据的更新进行重新渲染
- 那么到底有几种场景会触发更新?整个过程又是怎么样的,有哪些需要注意的地方?
组件更新(setState)
- 最常见的,我们经常用 setState 来重新设置组件的状态进行重新渲染
- 使用setState只会更新调用此方法的类。不会涉及到兄弟节点以及父级节点
- 影响范围仅仅是自己的子节点,步骤如下:
- 1 ) 运行当前类组件的生命周期静态方法static getDerivedStateFromProps,根据返回值合并当前组件的状态
- 2 ) 运行当前类组件的生命周期方法shouldComponentUpdate,如果该方法返回的false,直接终止更新流程
- 3 ) 运行当前类组件的生命周期方法render,得到一个新的vDom树,进入新旧两棵树的对比更新
- 4 ) 将当前类组件的生命周期方法 getSnapshotBeforeUpdate 加入执行队列,等待将来执行
- 5 ) 将当前类组件的生命周期方法 componentDidUpdate 加入执行队列,等待将来执行
- 6 ) 重新生成vDom树
- 7 ) 执行队列,此队列存放的是更新过程涉及到原本存在的类组件的 生命周期 方法 getSnapshotBeforeUpdate
- 8 ) 根据vDom树更新真实DOM
- 9 ) 执行队列,此队列存放的是更新过程涉及到原本存在的类组件的 生命周期 方法 componentDidUpdate
- 10 ) 执行队列,此队列存放的是更新过程中所有卸载的类组件的 生命周期方法 compoentWillUnmount
根节点更新(ReactDOM.createRoot().render)
- 在ReactDOM的新版本中,已经不是直接使用 ReactDOM.render 进行更新了
- 而是通过 createRoot (要控制的DOM区域)的返回值来调用 render
import React from 'react'; import ReactDOM from 'react-dom/client'; import'./index.css'; import App from'./App'; const root = ReactDOM.createRoot(document.getElementById('root'); root.render( <App/> );
对比更新过程(diff)
- 知道了两个更新的场景以及会运行哪些生命周期方法后,我们来看一下具体的过程到底是怎么样的。
- 所谓对比更新就是将新vDom树跟之前首次渲染过程中保存的老vDom树对比发现差异然后去做一系列操作的过程。
- 那么问题来了,如果我们在一个类组件中重新渲染了,React怎么知道在产生的新树中它的层级呢?
- 难道是给vDom树全部挂上一个不同的标识来遍历寻找更新的哪个组件吗?
- 当然不是,我们都知道React的diff算法将之前的复杂度0(n^3)降为了0(n)
- 它做了以下几个假设:
- 1.假设此次更新的节点层级不会发生移动(直接找到旧树中的位置进行对比)
- 2.兄弟节点之间通过key进行唯一标识
- 3.如果新旧的节点类型不相同,那么它认为就是一个新的结构
- 比如之前是初始元素div现在变成了初始元素 span那么它会认为整个结构全部变了,
- 无论嵌套了多深也会全部丢弃重新创建
key的作用
-
如果列表里面有初始元素,并且没有给初始元素添加 key那么它会警告
- Warning: Each child in a list should have a unique “key” prop. 。
-
那么 key值到底是干嘛用的呢?
- 其实key的作用非常简单,仅仅是为了通过旧节点
- 寻找对应的新节点进行对比提高节点的复用率
-
现在来举个例子,假如现在有五个兄弟节点更新后变成了四个节点
-
未添加key
- 添加了key
找到对比目标-节点类型一致
- 经过假设和一系列的操作找到了需要对比的目标
- 如果发现节点类型一致,那么它会根据不同的节点类型做不同的事情
- 初始元素-DOM节点
- 如果是DOM节点,React会直接重用之前的真实DOM
- 将这次变化的属性记录下来,等待将来完成更新
- 然后遍历其子节点进行递归对比更新
- 初始元素-组件节点
- 函数组件
- 如果是函数组件,React仅仅是重新调用函数拿到新的vDom树,然后递归进行对比更新
- 类组件
- 针对类组件,React也会重用之前的实例对象。后续步骤如下:
- 1.运行生命周期静态方法static getDerivedStateFromProps。将返回值合并当前状态
- 2.运行生命周期方法shouldComponentUpdate,如果该方法返回false,终止当前流程
- 3.运行生命周期方法render,得到新的vDom树,进行新旧两棵树的递归对比更新
- 4.将生命周期方法getSnapshotBeforeUpdate加入到队列等待执行
- 5.将生命周期方法componentDidUpdate加入到队列等待执行
3.文本节点
- 对于文本节点,同样的React也会重用之前的真实文本节点。
- 将新的文本记录下来,等待将来统一更新(设置nodeValue)
4.空节点
- 如果节点的类型都是空节点,那么React啥都不会做
5.数组节点
- 首次挂载提到的,数组节点不会直接渲染
- 在更新阶段也一样,遍历每一项,进行对比更新,然后去做不同的事
找到对比目标-节点类型不一致
- 如果找到了对比目标,但是发现节点类型不一致了,这时候类型变了,那么你的子节点肯定也都不一样了
- 就算一万个子节点,并且他们都是没有变化的,只有最外层的父节点的节点类型变了
- 照样会全部进行卸载重新创建,与其去一个个递归查看子节点,不如直接全部卸载重新创建
import'./App.css'; import React from 'react'; function Count(props) { console.log('Count') return <h1>{props. count}</h1> } class App extends React. Component { constructor() { super() this.state={ arr:[1,2,3] } this.update =this.update.bind(this) } update() { this.setState({ arr: [1,2,3,4] }) } render() { console.log('父亲render执行') return ( <div> <button onClick={this.update}>点我更新</button> { this.state.arr.map((count) => <Count key={count} count={count} />) } </div> ) } } export default App;
- 这个例子,初始化的时候,Count组件被初始化3次
- 而点击更新的时候,Count组件更新了4次
- 这是因为它是函数式组件,更新时,仅仅是重新调用函数,拿到新的vDOM树
- 在react内部加了key,可以复用的是底层的vDom的树,而非这个函数式组件
- 函数式组件,每次渲染,都会重新执行这个函数,这里要分清两者的区别
未找到对比目标
- 如果未找到对比的目标,跟 节点类型 不一致的做法类似,
- 那么对于多出的节点进行挂载流程,对于旧节点进行卸载直接弃用
- 如果其包含子节点进行递归卸载,对于初始类组件节点会多一个步骤,那就是运行生命周期方法componentWillUnmount。
- 注意:
- 尽量保持结构的稳定性,如果未添加key的情况下
- 兄弟节点更新位置前后错位一个那么后续全部的比较都会错位导致找不到对比目标从而进行卸载新建流程,对性能大打折扣
总结
- 对于首次挂载阶段
- 需要了解React的渲染流程
- 通过书写的初始元素和一些其他可以生成虚拟节点的东西来生成虚拟节点
- 然后针对不同的节点类型去做不同的事情,最终将真实DOM挂载到页面上
- 然后执行渲染期间加入到队列的一些生命周期,然后组件进入到活跃状态
- 对于更新卸载阶段
- 需要注意的是有几个更新的场景,以及key的作用到底是什么,有或没有会产生多大的影响
- 还有一些小细节,比如条件渲染时,不要去破坏结构,尽量使用空节点来保持前后结构顺序的统一
- 重点是新旧两棵树的对比更新流程
- 找到目标,节点类型一致时针对不同的节点类型会做哪些事,类型不一致时会去卸载整个旧节点
- 无论有多少子节点,都会全部递归进行卸载