概述
点击上方 前端Q,关注公众号
回复加群,加入前端Q技术交流群
前言
相信很多人跟我之前一样,看到源码
两个字觉得触不可及,觉得离自己还很遥远,是需要非常多年的工作经验的大佬才能触及到的领域。就在去年我改变了这个想法,当时被react
的几个生命周期执行顺序弄的睡不着觉,为什么有些时候生命周期
的执行事与愿违?又为什么数组
中必须要加上key
属性?为啥在render
中不能写setState
等等问题......在一系列的问题中,我终于还是打开了那份久违的源码,并且Ctrl + F
慢慢探索了起来。
直到今天,趁着二季度业务结束忙里偷闲总结出这份不看源码也能让你看懂的渲染原理
。因为有些地方需要承上启下,所以文本分为两大部分讲解,一部分是首次挂载渲染
原理,另一部分是更新和卸载
原理,很多地方非常抽象,希望大家仔细阅读,不然容易脱节。废话不多话,开车!!
正文
在开始之前,需要一些前置知识才能帮助我们更好的理解整个渲染过程。首先就是生命周期(16版本之后)
,为什么要讲一下生命周期?跟渲染原理有关系吗?当然有,如果你不理解渲染原理的话,更新一个嵌套很深的组件你甚至连父与子
生命周期执行的先后顺序都不知道。本文直接对照16
版本之后的新生命周期
进行讲解,就不讲解老版本了。
初探-生命周期
顾名思义,跟人生一样,生命周期
就是一个组件从诞生
到销毁
的过程。React
在组件的生命周期
中注册了一系列的钩子函数
,支持开发者在其中注入代码,并在适当的时机运行。这里指的生命周期
仅针对于类组件
中的钩子函数
。因为生命周期
不是本文的重点,所以Hooks
中的新增的钩子函数
在本文中均不涉及,可以以后出个Hooks
原理篇。
从图中可以看到,我把生命周期
分为了挂载阶段
、更新阶段
、卸载阶段
三个阶段。同时,在挂载阶段
和更新阶段
都会运行getDerivedStateFromProps
和render
,卸载阶段很好理解,只有一个componentWillUnMount
,在卸载组件之前做一些事情,通常用来清除定时器等副作用操作
。那么挂载阶段
和更新阶段
中的生命周期我们来逐一看下每个运行点及作用。
1. constructor
在同一个类组件对象只会运行一次。所以经常来做一些初始化
的操作。同一个组件对象被多次创建,它们的construcotr
互不干扰。
注意:在construcotr
中要尽量避免(最好禁止)使用setState
。 我们都知道使用setState
会造成页面的重新渲染,但是在初始化
阶段,页面都还没有将真实DOM
挂载到页面上,那么重新渲染的又有什么意义呢。除异步
的情况,比如setInterval
中使用setState
是没问题的,因为在执行的时候页面早已渲染完成
。但也最好不要,容易一些引起奇怪的问题。
constructor(props) {
super(props);
this.state = {
num: 1
};
//不可以,直接Warning
this.setState({
num: this.state.num + 1
});
//可以使用,但不建议
setInterval(()=>{
this.setState({
num: this.state.num + 1
});
}, 1000);
}
复制代码
2. 静态属性 static getDerivedStateFromProps
该方法是一个静态属性
,在16
版本之前不存在,在新版生命周期
中主要用来取代componentWillMount
和componentWillReceiveProps
,因为这两个老生命周期
方法在一些开发者不规范的使用下极容易产生一些反模式
的bug。因为是静态方法
,所以你在其中根本拿不到this
,更不可能调用setState
。
该方法在挂载阶段
和更新阶段
都会运行。它有两个参数props
和state
当前的属性值
和状态
。它的返回值会合并掉当前的状态(state)
。如果返回了非Object
的值,那么它啥都不会做,如果返回的是Object
,那么它将会跟当前的状态合并,可以理解为Object.assign[1]。通常情况下,几乎不怎么使用该方法。
/**
* 静态方法,首次挂载和更新渲染都会运行该方法
* @param {*} props 当前属性
* @param {*} state 当前状态
*/
static getDerivedStateFromProps(props, state){
// return 1; //没用
return {
num: 999, //合并到当前state对象
};
}
复制代码
3. render
最重要的生命周期
,没有之一。用来生成虚拟节点(vDom)
树。该方法只要遇到需要重新渲染都会运行。同样的,在render
中也严禁使用setState
,因为会导致无限递归
重新渲染导致爆栈
。
render() {
//严禁使用!!!
this.setState({
num: 1
})
return (
<>{this.state.num}</>
)
}
复制代码
4. componentDidMount
该方法只会运行一次,在首次渲染
时页面将真实DOM
挂载完毕之后运行。通常在这里做一些异步操作
,比如开启定时器、发起网络请求、获取真实DOM
等。在该方法中,可以大胆使用setState
,因为页面已经渲染完成。执行完该钩子函数
后,组件正式进入到活跃
状态。
componentDidMount(){
// 初始化或异步代码...
this.setState({});
setInterval(()=>{});
document.querySelectorAll("div");
}
复制代码
5. 性能优化 shouldComponentUpdate
在原理图更新阶段
中可以看到,执行完static getDerivedStateFromProps
后,会执行该钩子函数
。该方法通常用来做性能优化
。它的返回值(boolean)
决定了是否要进行渲染更新
。该方法有两个参数nextProps
和nextState
表示此次更新(下一次)的属性
和状态
。通常我们会将当前值与此次要更新的值做比较来决定是否要进行重新渲染。
在React
中,官方给我们实现好了一个基础版的优化组件PureComponent
,就是一个HOC
高阶组件,内部实现就是帮我们用shouldComponentUpdate
做了浅比较优化。如果安装了React
代码提示的插件,我们可以直接使用rpc
+ tab键
来生成模版。注意:继承了PureComponent
后不需要再使用shouldComponentUpdate
进行优化。
/**
* 决定是否要进行重新渲染
* @param {*} nextProps 此次更新的属性
* @param {*} nextState 此次更新的状态
* @returns {boolean}
*/
shouldComponentUpdate(nextProps, nextState){
// 伪代码,如果当前的值和下一次的值相等,那么就没有更新渲染的必要了
if(this.props === nextProps && this.state === nextState){
return false;
}
return true;
}
复制代码
6. getSnapshotBeforeUpdate
如果shouldComponentUpdate
返回是true
,那么就会运行render
重新生成虚拟DOM树
来进行对比更新,该方法运行在render
后,表示真实DOM
已经构建完成,但还没有渲染
到页面中。可以理解为更新前的快照
,通常用来做一些附加的DOM操作。
比如我突然想针对具有某个class
的真实元素做一些事情。那么就可以在此方法中获取元素并修改。该函数有两个参数prevProps
和prevState
表示此次更新前的属性
和状态
,该函数的返回值(snapshot)
会作为componentDidUpdate
的第三个参数。
/**
* 获取更新前的快照,通常用来做一些附加的DOM操作
* @param {*} prevProps 更新前的属性
* @param {*} prevState 更新前的状态
*/
getSnapshotBeforeUpdate(prevProps, prevState){
// 获取真实DOM在渲染到页面前做一些附加操作...
document.querySelectorAll("div").forEach(it=>it.innerHTML = "123");
return "componentDidUpdate的第三个参数";
}
复制代码
7. componentDidUpdate
该方法是更新阶段
最后运行的钩子函数
,跟getSnapshotBeforeUpdate
不同的是,它的运行时间点是在真实DOM
挂载到页面后。通常也会使用该方法来操作一些真实DOM
。它有三个参数分别是prevProps
、prevState
、snapshot
,跟Snapshot钩子函数
一样,表示更新前的属性
、状态
、Snapshot
钩子函数的返回值。
/**
* 通常用来获取真实DOM做一些操作
* @param {*} prevProps 更新前的属性
* @param {*} prevState 更新前的状态
* @param {*} snapshot getSnapshotBeforeUpdate的返回值
*/
componentDidUpdate(prevProps, prevState, snapshot){
document.querySelectorAll("div").forEach(it=>it.innerHTML = snapshot);
}
复制代码
8. componentWillUnmount
如开头提到的,该钩子函数
属于卸载阶段中唯一的方法。如果组件在渲染
的过程中被卸载了,React
会报出Warning:Can't perform a React state update on an unmounted component
的警告,所以通常在组件被卸载时做清除副作用的操作
。
componentWillUnmount(){
// 组件被卸载前清理副作用...
clearInterval(timer1);
clearTimeout(timer2);
this.setState = () => {};
}
复制代码
到这里,React生命周期
中每一个钩子函数
的作用以及运行时间点就已经全部了解了,斯国一!等在下文中提到的时候也有一个大致的印象。大家可以先喝口水休息一下~
React element(初始元素)
先来认识下第一个概念,就是React element
,what?当我伞兵?我还不知道什是element
?别激动,这里的元素不是指真实DOM
中的元素,而是通过React.createElement
创建的类似
真实DOM的元素。比如我们在开发中通过语法糖jsx
写出来的html
结构都是React element
,为了跟真实DOM
区分开来,本文就统称为React初始元素
。
为什么要有一个初始元素
的概念?我们都知道通过jsx
编写的html
不可能直接渲染
到页面上,肯定是经历了一系列的复杂
的处理最后生成真实DOM
挂载到页面上。那么到底是怎么样的一个过程?在我们认识一些概念之后才能更深入的理解整个过程。先看看平时写的代码哪些是初始元素
。
import React, { PureComponent } from 'react'
//创建的是React初始元素
const A = React.createElement("div");
//创建的是React初始元素
const B = <div>123</div>
export default class App extends PureComponent {
render() {
return (
//创建的是React初始元素
<div>
{A}
{B}
</div>
)
}
}
复制代码
React vDom(虚拟节点)
前面提到React
在渲染过程中要做很多事情,所以不可能直接通过初始元素
直接渲染。还需要一个东西就是虚拟节点
。在本文中不涉及React Fiber
的概念,将vDom
树和Fiber
树统称为虚拟节点
。有了初始元素
后,React
就会根据初始元素
和其他可以生成虚拟节点的东西
生成虚拟节点
。请记住:React
一定是通过虚拟节点
来进行渲染的。 接下来就是重点,除了初始元素
能生成虚拟节点
以外,还有哪些可能生成虚拟节点
?总共有多少种节点
类型?
1. DOM节点(ReactDomComponent)
此DOM非彼DOM,这里的DOM指的是
虚拟DOM节点
。当初始元素的type
属性为字符串
的时候React
就会创建虚拟DOM节点
。例如我们前面使用jsx
直接书写的const B = <div></div>
。它的属性就是"div"
,可以打印出来看一下。
2. 组件节点(ReactComposite)
当
初始元素
的type
属性为函数
或是类
的时候,React
就会创建虚拟组件节点
。
3. 文本节点(ReactTextNode)
顾名思义,直接书写
字符串
或者数字
,React
会创建为文本节点
。比如我们可以直接用ReactDOM.render
方法直接渲染字符串
或数字
。
import ReactDOM from 'react-dom/client';
const root = ReactDOM.createRoot(document.getElementById('root'));
//root.render('一头猪'); //创建文本节点
root.render(123465); //创建文本节点
复制代码
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(false); //创建空节点
//root.render(true); //创建空节点
//root.render(null); //创建空节点
root.render(undefined); //创建空节点
复制代码
5. 数组节点(ReactArrayNode)
什么?
数组
还能渲染?当然不是直接渲染
数组本身啦。当React
遇到数组
时,会创建数组节点
。但是不会直接进行渲染
,而是将数组里的每一项拿出来,根据不同的节点类型
去做相应的事情。**所以数组
里的每一项只能是这里提到的五个节点类型
**。不信?那放个对象试试。
import React from 'react';
import ReactDOM from 'react-dom/client';
const root = ReactDOM.createRoot(document.getElementById('root'));
function FuncComp(){
return (
<div>组件节点-Function</div>
)
}
class ClassComp extends React.Component{
render(){
return (
<div>组件节点-Class</div>
)
}
}
root.render([
<div>DOM节点</div>, //创建虚拟DOM节点
<ClassComp />, //创建组件节点
<FuncComp />, //创建组件节点
false, //创建空节点
"文本节点", //创建文本节点
123456, //创建文本节点
[1,2,3], //创建数组节点
// {name: 1} //对象不能生成节点,所以会报错
]);
复制代码
真实DOM(UI)
通过
document.createElement
创建的元素就是真实DOM
。了解完初始元素
、虚拟节点
以及真实DOM
这几个重要的概念后,就可以进入到原理
的学习了。再次强调:React
的工作是通过初始元素或可以生成虚拟节点的东西
生成虚拟节点
然后针对不同的节点类型
去做不同的事情最终生成真实DOM
挂载到页面上!所以为什么对象不能直接被渲染
,因为它生成不了虚拟节点
。(实际上是ReactDOM
库进行渲染,为了减少混淆本文中就直接说React
)
首次渲染阶段
如上图所示,React
首先根据初始元素
先生成虚拟节点
,然后做了一系列操作后最终渲染成真实的UI
。生成虚拟节点
的过程上面已经讲过了,所以这里说的是根据不同的虚拟节点
它到底做了些什么处理。
1. 初始元素-DOM节点
对于初始元素
的type
属性为字符串时,React会通过document.createElement
创建真实DOM
。因为初始元素
的type
为字符串,所以直接会根据type
属性创建不同的真实DOM
。创建完真实DOM
后会立即设置该真实DOM
的所有属性
,比如我们直接在jsx
中可以直接书写的className
、style
等等都会作用到真实DOM
上。
//jsx语法:React初始元素
const B = <div className="wrapper" style={{ color: "red" }}>
<p className="text">123</p>
</div>
复制代码
当然我们的html结构
肯定不止一层,所以在设置完属性后React
会根据children
属性进行递归遍历
。根据不同的节点类型
去做不同的事情,同样的,如果children
是初始元素
,创建真实DOM
、设置属性、然后检查是否有子元素。重复此步骤,一直到最后一个元素为止。遇到其他节点类型
会做以下事情。⬇️
2. 初始元素-组件节点
前面提到的,如果初始元素
的type
属性是一个class类
或者function函数
时,那么会创建一个组件节点
。所以针对类
或函数
组件,它的处理是不同的。
函数组件
对于函数组件
会直接调用函数,将函数的返回值
进行递归处理(看看是什么节点类型
,然后去做对应的事情,所以一定要返回能生成虚拟节点
的东西),最终生成一颗vDOM
树。
类组件
对于类组件
而言会相对麻烦一些。但前面有了生命周期
的铺垫,结合图中挂载阶段
来看这里理解起来就很方便了。
首先创建类的
实例
(调用constructor
)。调用
生命周期
方法static getDerivedStateFromProps
。调用
生命周期
方法render
,根据返回值
递归处理。跟函数组件处理返回值
一样,最终生成一颗vDom
树。将该组件的
生命周期
方法componentDidMount
加入到执行队列
中等待真实DOM挂载到页面后执行(注意:前面说了render
是一个递归处理,所以如果一个组件存在父子
关系的时候,那么肯定要等子组件
渲染完父组件
才能走出render
,所以子组件
的componentDidMount
一定是比父组件先入队列
的,肯定先运行!)。
3. 文本节点
针对文本节点
,会直接通过document.createTextNode
创建真实
的文本节点。
4. 空节点
如果生成的是空节点
,那么它将什么都不会做
!对,就是那么简单,啥都不做。
5. 数组节点
就像前面提到的一样,React
不会直接渲染数组,而是将里面的每一项
拿出来遍历,根据不同的节点类型
去做不同的事,直到递归
处理完数组里的每一项。(这里留个问题,为什么在数组
里我们要写key
?)
一图胜千言
当处理完了所有的节点
后,我们的vDom
树和真实DOM
也创建好了,React
会将vDom
树保存起来,方便后续使用。然后将创建好的真实DOM
都挂载到页面上。至此,首次渲染
的阶段就全部结束了。有点懵?没事,正常,我们举个例子。
import React from 'react';
import ReactDOM from 'react-dom/client';
const root = ReactDOM.createRoot(document.getElementById('root'));
/**
* 组件节点-类组件
*/
class ClassSon extends React.Component {
constructor(props){
super(props);
console.log("444 ClassSon constructor");
}
static getDerivedStateFromProps(props, state){
console.log("555 ClassSon getDerivedStateFromProps");
return {};
}
componentDidMount(){
console.log("666 ClassSon componentDidMount");
}
render() {
return (
<div className="func-wrapper">
<span>
textNode22
{undefined}
</span>
{[false, "textNode33", <div>textNode44</div>]}
</div>
)
}
}
/**
* 组件节点-类组件
*/
class ClassComp extends React.Component {
constructor(props){
super(props);
console.log("111 ClassComp constructor");
}
static getDerivedStateFromProps(props, state){
console.log("222 ClassComp getDerivedStateFromProps");
return {};
}
componentDidMount(){
console.log("333 ClassComp componentDidMount");
}
render() {
return (
<div className="class-wrapper">
<ClassSon />
<p>textNode11</p>
{123456789}
</div>
)
}
}
root.render(<ClassComp />);
复制代码
从代码结构来看,渲染的是ClassComp
类组件,类组件内包含了一个函数组件
以及一些其他可以生成虚拟节点
的东西,同样的,函数组件
内也是一些可以生成虚拟节点
的结构。因为用图表示比较复杂,时间可能会有点久(gif很大已压缩...,显示有点小的话麻烦右键新标签打开
看好了)
从图中可以看到,在ClassComp
首次挂载运行render
的过程中,发现了ClassSon
组件,然后又开始了一个新的类组件
节点的渲染过程。要等到ClassSon
和其他兄弟节点渲染完后ClassComp
的render
才算完成。所以ClassSon
的componentDidMount
一定是先进队列的。所以控制台执行顺序一定是111
、222
、444
、555
、666
、333
。到这里,首次挂载
的所有过程就结束了。再喝口水休息一下~
更新和卸载
挂载完成后组件进入活跃
状态,等待数据的更新进行重新渲染。那么到底有几种场景会触发更新?整个过程又是怎么样的,有哪些需要注意的地方?
更新的场景
组件更新(
setState
)
最常见的,我们经常用setState
来重新设置组件的状态
进行重新渲染(本文不涉及Hooks
概念,不讲useState
)。使用setState
只会更新调用此方法的类。不会涉及到兄弟节点以及父级节点。影响范围仅仅是自己的子节点
。结合文章最前面的生命周期
图看,步骤如下:
运行当前类组件的
生命周期
静态方法static getDerivedStateFromProps
。根据返回值合并当前组件的状态。运行当前类组件的
生命周期
方法shouldComponentUpdate
。如果该方法返回的false
。直接终止更新流程!运行当前类组件的
生命周期
方法render
,得到一个新的vDom
树,进入新旧两棵树的对比更新
。将当前类组件的
生命周期
方法getSnapshotBeforeUpdate
加入执行队列,等待将来执行。将当前类组件的
生命周期
方法componentDidUpdate
加入执行队列,等待将来执行。重新生成
vDom
树。根据
vDom
树更新真实DOM
.执行队列,此队列存放的是更新过程中所有新建类组件的
生命周期
方法componentDidMount
。执行队列,此队列存放的是更新过程涉及到原本存在的类组件的
生命周期
方法getSnapshotBeforeUpdate
。执行队列,此队列存放的是更新过程涉及到原本存在的类组件的
生命周期
方法componentDidUpdate
。执行队列,此队列存放的是更新过程中所有卸载的类组件的
生命周期
方法componentWillUnMount
。
根节点更新(
ReactDOM.createRoot().render
)
在ReactDOM
的新版本中,已经不是直接使用ReactDOM.render
进行更新了,而是通过createRoot(要控制的DOM区域)
的返回值来调用render
,无论我们在嵌套多少的组件里去调用控制区域.render
,都会直接触发根节点
的对比更新
。一般不会这么操作。如果触发了根节点的更新,那么后续步骤是上面组件更新
的6-11
步。
对比更新过程(diff)
知道了两个更新的场景以及会运行哪些生命周期
方法后,我们来看一下具体的过程到底是怎么样的。所谓对比更新
就是将新vDom
树跟之前首次渲染过程中保存的老vDom
树对比发现差异然后去做一系列操作的过程。那么问题来了,如果我们在一个类组件
中重新渲染了,React
怎么知道在产生的新树中它的层级呢?难道是给vDom
树全部挂上一个不同的标识来遍历寻找更新的哪个组件吗?当然不是,我们都知道React
的diff
算法将之前的复杂度O(n^3)
降为了O(n)
。它做了以下几个假设:
假设此次更新的节点层级不会发生移动(直接找到旧树中的位置进行对比)。
兄弟节点之间通过
key
进行唯一标识。如果新旧的
节点类型
不相同,那么它认为就是一个新的结构,比如之前是初始元素div
现在变成了初始元素span
那么它会认为整个结构全部变了,无论嵌套了多深也会全部丢弃
重新创建。
key的作用
如果前面copy了文中的代码例子就会发现在使用数组节点
的时候,如果里面有初始元素
,并且没有给初始元素
添加key
那么它会警告Warning: Each child in a list should have a unique "key" prop.
。那么key
值到底是干嘛用的呢?其实key
的作用非常简单,仅仅是为了通过旧节点
,寻找对应的新节点
进行对比提高节点
的复用率。我们来举个例子,假如现在有五个兄弟节点
更新后变成了四个节点
。
未添加key
添加了key
看完两张图会发现如果有key
的话在其他节点
未变动的情况下复用了之前的所有节点
。所以请尽量保持同一层级内key
的唯一性
和稳定性
。这就是为什么不要用Math.random
作为key
的原因,跟没写一样。
找到对比目标-节点类型一致
经过假设和一系列的操作找到了需要对比的目标,如果发现节点类型
一致,那么它会根据不同的节点类型做不同的事情。
1. 初始元素-DOM节点
如果是DOM节点
,React
会直接重用之前的真实DOM
。将这次变化的属性
记录下来,等待将来完成更新。然后遍历其子节点
进行递归对比更新
。
import React, { PureComponent } from 'react'
export default class App extends PureComponent {
state = {
flag: true
}
render() {
console.log("render了");
return (
<div className={this.state.flag ? "wrapper" : "flagFlase"}>
<button onClick={()=>{
this.setState({
flag: !this.state.flag
});
console.log("属性名变了吗现在?", document.querySelector(".wrapper").className);
}}>更新</button>
</div>
)
}
}
复制代码
2. 初始元素-组件节点
函数组件
如果是函数组件
,React
仅仅是重新调用函数
拿到新的vDom
树,然后递归进行对比更新
。
类组件
针对类组件
,React
也会重用之前的实例对象
。后续步骤如下:
运行`生命周期`静态方法`static getDerivedStateFromProps`。将返回值合并当前状态。
运行`生命周期`方法`shouldComponentUpdate`,如果该方法返回`false`,终止当前流程。
运行`生命周期`方法`render`,得到新的`vDom`树,进行新旧两棵树的递归`对比更新`。
将`生命周期`方法`getSnapshotBeforeUpdate`加入到队列等待执行。
将`生命周期`方法`componentDidUpdate`加入到队列等待执行。
import React, {Component} from 'react'
export default class App extends Component {
static getDerivedStateFromProps(props, state){
console.log("111 getDerivedStateFromProps");
return {};
}
shouldComponentUpdate(){
console.log("222 shouldComponentUpdate");
return true;
}
getSnapshotBeforeUpdate(){
console.log("444 getSnapshotBeforeUpdate");
return null;
}
componentDidUpdate(){
console.log("555 getSnapshotBeforeUpdate")
}
render() {
console.log("333 render");
return (
<div className={"wrapper"}>
<button onClick={()=>{
this.setState({});
}}>更新</button>
</div>
)
}
}
复制代码
3. 文本节点
对于文本节点,同样的React
也会重用之前的真实文本节点
。将新的文本记录下来,等待将来统一更新(设置nodeValue
)。
import React, { PureComponent } from 'react'
export default class App extends PureComponent {
state = {
text: "文本节点"
}
render() {
return (
<div className="wrapper">
{this.state.text}
<button onClick={()=>{
this.setState({
text: "新文本节点"
})
}}>更新</button>
</div>
)
}
}
复制代码
4. 空节点
如果节点的类型都是空节点
,那么React
啥都不会做。
5. 数组节点
首次挂载提到的,数组节点
不会直接渲染。在更新阶段也一样,遍历每一项,进行对比更新
,然后去做不同的事。
找到对比目标-节点类型不一致
如果找到了对比目标,但是发现节点类型
不一致了,就如前面所说,React
会认为你连类型都变了,那么你的子节点
肯定也都不一样了,就算一万个
子节点,并且他们都是没有变化的,只有最外层的父节点
的节点类型
变了,照样会全部进行卸载
重新创建,与其去一个个递归查看子节点
,不如直接全部卸载
重新新建。
import React, { PureComponent } from 'react'
export default class App extends PureComponent {
state = {
flag: true,
}
render() {
console.log("重新渲染render");
if (this.state.flag) {
return <span className="wrapper">
<button onClick={() => {
this.setState({
flag: !this.state.flag
})
}}>更新</button>
</span>
}
return (
<div className="wrapper">
<button onClick={() => {
this.setState({
flag: !this.state.flag
})
}}>更新</button>
</div>
)
}
}
复制代码
未找到对比目标
如果未找到对比的目标,跟节点类型
不一致的做法类似,那么对于多出的节点进行挂载流程
,对于旧节点进行卸载直接弃用。如果其包含子节点进行递归卸载
。对于初始类组件节点
会多一个步骤,那就是运行生命周期
方法componentWillUnmount
。注意:尽量保持结构的稳定性,如果未添加key
的情况下,兄弟节点更新位置前后错位一个那么后续全部的比较都会错位
导致找不到对比目标从而进行卸载
新建流程,对性能大打折扣。
import React, { PureComponent } from 'react'
export default class App extends PureComponent {
state = {
flag: true,
}
render() {
console.log("重新渲染render");
if (this.state.flag) {
return <div className="wrapper">
<span>123</span>
<button onClick={() => {
this.setState({
flag: !this.state.flag
})
}}>更新</button>
</div>
}
return (
<div className="wrapper">
<button onClick={() => {
this.setState({
flag: !this.state.flag
})
}}>更新</button>
</div>
)
}
}
复制代码
从图中可以看到,哪怕经过条件渲染前后button
理论上没有任何变化的情况下,照样没有重用之前的真实DOM
,如果在button
之后还有一万个
兄弟节点,那么也全部都找不到对比目标从而进行卸载
重新创建流程。所以在进行条件渲染
显示隐藏时,官方推荐以下做法:
控制`style:visibility`来控制显示隐藏。
在隐藏时给一个`空节点`来保证对比前后能找到同一位置。不影响后续`兄弟节点`的比较。
this.state.flag ? <div></div> : false
复制代码
来点栗子加深印象
1. 是否重用了真实DOM
import React, { PureComponent } from 'react'
export default class App extends PureComponent {
state = {
flag: true,
}
render() {
console.log("重新render!");
if(this.state.flag){
return <div className="flag-true">
<button onClick={()=>{
this.setState({
flag: !this.state.flag
})
}}>更新</button>
</div>
}
return (
<div className="flag-false">
<button onClick={()=>{
this.setState({
flag: !this.state.flag
})
}}>更新</button>
</div>
)
}
}
复制代码
尽管从代码结构看起来像是返回了两个不同的DOM
,但其实在更新的过程中,React
发现他们的节点类型
一致,所以会重用之前的真实DOM
。所以请注意:尽量保持节点的类型
一致,如果更新前后节点类型
不一致的话无论有多少子组件将全部卸载
重新创建。
2. 一个神奇的效果
import React, { PureComponent } from 'react'
export default class App extends PureComponent {
state = { flag: false }
render() {
return (
<>
{
this.state.flag ?
<div>
<input type="password" />
<button onClick={() => {
this.setState({
flag: !this.state.flag
})
}}>显示/隐藏</button>
</div>
:
<div>
<input type="password" />
<input type="text" />
<button onClick={() => {
this.setState({
flag: !this.state.flag
})
}}>显示/隐藏</button>
</div>
}
</>
)
}
}
复制代码
从图中可以看到,我们输入了密码后,重新渲染
生成了新的DOM,但是里面的密码还存在。这就很好的证明了React
是如何重用真实DOM
的。
一道面试题
import React, { PureComponent } from 'react'
class ClassCompA extends PureComponent {
componentDidMount() {
console.log("111 ClassCompA componentDidMount");
}
componentWillUnmount() {
console.log("222 ClassCompA componentWillUnmount");
}
render() {
return (<div className="ClassCompA"></div>)
}
}
class ClassCompB extends PureComponent {
componentDidMount() {
console.log("333 ClassCompB componentDidMount");
}
render() {
return (<div className="ClassCompB">
<ClassCompC />
</div>)
}
}
class ClassCompC extends PureComponent {
componentDidMount() {
console.log("444 ClassCompC componentDidMount");
}
render() {
return (<div className="ClassCompC"></div>)
}
}
export default class App extends PureComponent {
state = {
flag: true,
}
componentDidMount(){
console.log("666 App componentDidMount");
}
componentDidUpdate() {
console.log("555 App componentDidUpdate");
}
render() {
return (
<div className="wrapper">
{this.state.flag ? <ClassCompA/> : <ClassCompB/>}
<button onClick={() => {
this.setState({
flag: !this.state.flag
})
}}>更新</button>
</div>
)
}
}
复制代码
问:首次渲染和按下button控制台输出的顺序是什么?
看的仔细的同学,相信根本就难不倒你,我们一起来捋一捋。
首先,最外层的组件是
App
,所以开始App
的挂载流程,运行render
的过程中发现条件渲染
先渲染ClassCompA
。进入
ClassCompA
的挂载流程,没啥好渲染的就一个div,执行完render
后将componentDidMount
加入到队列中等待执行。此时队列里是[111]
。App
再针对初始元素button
做处理后,render
执行结束,将自己的componentDidMount
加入到队列中等待执行,此时队列里是[111、666]
。React
根据虚拟节点
生成真实DOM
后,保存vDom
树,开始运行队列。此时控制台打印111
、666
。按下
button
后,调用setState
进行重新渲染,此时App
还会运行两个生命周期方法getDerivedStateFromProps
和shouldComponentUpdate
,然后运行render
,生成新的vDom
树。进入新旧两棵树的
对比更新
,虽然都是组件节点
,但生成出的实例不同,认为是不相同的节点类型
。开始卸载旧节点ClassCompA
,并将ComponentWillUnMount
加入到执行队列,等待执行。此时队列[222]
。进入新节点挂载流程,创建
ClassCompB
实例,调用render
生成虚拟节点
。发现存在组件节点ClassCompC
。再次进入到新节点挂载流程,创建实例。ClassComC
运行完render
生成vDom
树,将自己的componentDidMount
加入到队列,等待将来执行。此时队列[222、444]
。挂载完
ClassComC
后,ClassComB
的render
才算结束,此时将自己的componentDidMount
加入到队列,等待执行,此时队列[222、444、333]
。此时
App
的render
才算结束,将自己的componentDidUpdate
加入到队列,等待执行。此时队列[222、444、333、555]
。将根据
虚拟节点
生成的真实DOM
挂载到页面上后,开始执行队列。控制台输出222
、444
、333
、555
。
总结
对于生命周期
我们只需关注比较重要的几个生命周期的运行点即可,比如render
的作用、使用componentDidMount
在挂载完真实DOM
后做一些副作用操作、以及性能优化点shouldComponentUpdate
、还有卸载时利用componentWillUnmount
清除副作用。
对于首次挂载
阶段,我们需要了解React
的渲染流程是:通过我们书写的初始元素
和一些其他可以生成虚拟节点的东西
来生成虚拟节点
。然后针对不同的节点类型去做不同的事情,最终将真实DOM
挂载到页面上。然后执行渲染期间加入到队列的一些生命周期
。然后组件进入到活跃状态。
对于更新卸载
阶段,需要注意的是有几个更新的场景
。以及key
的作用到底是什么。有或没有会产生多大的影响。还有一些小细节,比如条件渲染
时,不要去破坏结构。尽量使用空节点
来保持前后结构顺序的统一。重点是新旧两棵树的对比更新流程
。找到目标,节点类型一致时针对不同的节点类型
会做哪些事,类型不一致时会去卸载
整个旧节点。无论有多少子节点,都会全部递归
进行卸载。
到这里,文章所有的部分就全部结束了,本文没有涉及到一行源码,全部都是总结出能在不看源码的情况下能大致了解整个渲染流程
。为了减少混淆,也没有涉及到Hooks
以及Fiber
的概念,有兴趣的同学可以留言,可以考虑下次出一篇。最后,再喝一口水休息一下。对本文内容有异议或交流欢迎评论~
关于本文
作者:特立独行的猪_
https://juejin.cn/post/7121378029682556958
往期推荐
2022 年了,我才开始学 TypeScript ,晚吗?(7.5k字总结)
如何为 Vue3 组件标注 TS 类型,看这个就够了!
8月2日晚,累计 292 万人紧盯 Flightradar24 网站,航班跟踪的技术原理是什么?
最后
欢迎加我微信,拉你进技术群,长期交流学习...
欢迎关注「前端Q」,认真学前端,做个专业的技术人...
点个在看支持我吧
最后
以上就是威武高山为你收集整理的「万字总结」动画 + 大白话讲清楚React渲染原理前言正文总结作者:特立独行的猪_的全部内容,希望文章能够帮你解决「万字总结」动画 + 大白话讲清楚React渲染原理前言正文总结作者:特立独行的猪_所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复