渲染原理
在了解渲染原理之前我们有必要知道一些专业术语
-
渲染: render, 生成用于显示的对象, 以及将这些对象形成真实的dom对象
-
React元素: React Element, 通过React.createElment创建(语法糖: jsx表达式)
// app存储了一个react元素div, 本质上app现在是一个对象, 而非真实的dom元素
const app = <div>我是一个react元素</div>
- React节点: React Node, 专门用于渲染进UI界面的对象, React会通过React元素来创建React节点
ReactDOM一定是通过React节点进行渲染的
节点类型:- React dom节点(ReactDOMComponent): 如果一个React元素的type值为字符串, 则会生成React dom节点
- React 组件节点(ReactCompsiteComponent): 创建该节点的React元素是一个函数或者是一个类
- Text 文本节点(TextNode): 由字符串, 数字创建的
- React 空节点: 由null undefined false, true创建的
- React 数组节点: 由一个数组创建的
首次渲染
- 根据参数的值创建节点
- 根据不同的节点做不同的事情
- 文本节点: 通过document.createTextNode创建真实的文本节点
- 空节点: 什么都不做
- 数组节点: 遍历数组, 将数组每一项递归创建节点
- DOM节点: 通过document.createElement创建真实的dom元素, 然后遍历对应React元素的children属性进行递归遍历操作
- 组件节点:
- 函数组件: 调用函数, 该函数必须返回一个可以生成节点的内容, react会将该函数的返回结果递归生成节点
- 类组件: 创建类的实例对象保存到组件节点上, 然后立即调用组件的生命周期方法static getDerivedStateFromProps, 再运行组件的render方法返回生成节点的内容开始递归生成节点,在render方法运行完成之后, 将该组件的componentDidMount放入执行队列中等待虚拟dom树整体构建完毕并且将真实的dom挂载到页面后再按顺序执行(先进先出, 后进后执行)
- 生成虚拟don树以后, 将该树保存起来以便以后使用, 同时将创建出来的真实dom对象, 加入到容器中
(会将自带的真实dom元素依次插入父级, 最后再将根元素一次性插入html中)
那么第一次渲染到底发生了什么?我们拿下方代码举例
const app = <div className = 'wrapper'>
<h1>
标题
{['abc', null}
</h1>
<p>{ undefined }</p>
</div>
ReactDOM.render(app, document.getElementById('root'));
我们用图来解析一下这一套流程是怎么渲染的
经过上方图中的操作以后, 就进行第三步操作将真实的dom放置进html了, 哦对了,上方的图他有一个大家都熟悉的名字叫做虚拟dom树
那么一个函数组件的渲染过程又是怎样的呢
function ChildB(props) {
return (
<div>helloWold</div>
)
}
function ChildA(props) {
return (
<div>
<ChildB />
</div>
)
}
ReactDOM.render(<ChildA />, document.getElementById('root'));
同样上图
函数组件在渲染中主要也就是看中这个返回值, 除了会生成一个组件节点以外其他方面笔者认为没有太大的冲突
最后我们再来看看类组件
class ChildA extends React.PureComponent {
render() {
return (
<ChildB />
)
}
}
class ChildB extends React.PureComponent {
render() {
return (
<h1>helloWorld</h1>
)
}
}
ReactDOM.render(<ChildA />, document.getElementById('root'));
类组件除了会保存类的实例以及会执行生命周期函数以外, 其他倒是也没太大的区别
说到类组件, 那我们应该要探究一下类组件的生命周期运行轨迹
class ChildA extends React.PureComponent {
state = {
}
constructor(props) {
super(props);
console.log('1, ChildA的constructor执行');
}
componentDidMount() {
console.log('8, ChildA的componentDidMount执行')
}
static getDerivedStateFromProps() {
console.log('2, ChildA的getDeriveStateFromProps执行');
return null;
}
render() {
console.log('3, ChildA的render执行');
return (
<ChildB />
)
}
}
class ChildB extends React.PureComponent {
state = {
}
constructor(props) {
super(props);
console.log('4, ChildB的constructor执行');
}
static getDerivedStateFromProps() {
console.log('5, ChildB的getDeriveStateFromProps执行');
return null;
}
componentDidMount() {
console.log('7, ChildB的componentDidMount执行')
}
render() {
console.log('6, ChildB的render执行');
return (
<h1>helloWorld</h1>
)
}
}
ReactDOM.render(<ChildA />, document.getElementById('root'));
上方的代码的生命周期函数输出顺序, 我想应该很简单都能猜到会按照顺序输出12345678了吧, ChildA会被先被生成实例, 所以ChildA的constructor会先行输出1, 而由我们之前的知识我们知道static getDerivedStateFromProps会在类实例生成以后就会调用, 所以2输出的理所当然, 之后render方法调用会输出3, (注意这个时候知识ChildA的render开始执行但并不是执行完毕)在render的解析过程中发现有类组件ChildB, 所以会按照上面的流程再走一次, 所以最后结果123456,同时当ChildB中的h1解析完毕以后代表ChildB的render执行完毕, 这个时候ChildB中的componentDidMount会被加入到执行队列中, 而后ChildArender执行完毕, childA的componentDidMount也被加入到执行队列中, 虚拟dom构建完毕真实dom挂载, 两个component遵循先进先执行的规则依次执行 如下图
更新和卸载的渲染
更新节点
更新的场景
- 重新调用ReactDOM.render, 完全重新生成节点树(触发的是根节点更新)
- 在类组件的实例中调用setState(触发的该类组件的实例的节点的更新)
节点的更新
- 如果调用的是ReactDOM.render, 进入根节点的对比更新(diff)
- 如果调用的是setState
- 运行生命周期函数 static getDerivedStateFromProps
- 运行生命周期函数shouldComponentUpdate, 如果返回false则结束当前流程
- 运行render得到一个新的节点, 进入该新节点的对比更新
- 将生命周期函数getSnapshotBeforeUpdate加入执行队列等待执行
- 将生命周期函数componentDidMount加入执行队列等待执行
后续步骤
- 更新虚拟dom树, 完成真实的dom更新
- 依次调用执行队列中的componentDidMount(因为更新阶段可能会有新的组件被创建)
- 依次调用执行队列中的getSnapshotBeforeUpdate
- 依次调用执行队列中的componentDidUpdate
- 依次调用执行队列中的componentWillUnMount
那么什么是对比更新呢?就是将新产生的节点对比之前虚拟dom中的节点发现差异,完成更新**
问题:那么怎么进行对比更新呢?换一种说法程序怎么知道自己应该怎么对比呢?
如果做唯一标识的话可能会导致效率特别低, 比如我这个dom要更新他要找到之前的dom进行比对如果是
通过唯一标识的话, 它势必是要遍历所有的节点找到这一个唯一标识, 假设有几万个节点的话就会导致效率特别的低
所以react为了提高效率做出以下假设
- 假设节点不会进行层级的移动(对比时直接找到旧的树中对应位置的节点进行优化)
- 不同的节点类型会生成不同的结构
- 相同的节点类型: 节点类型本身相同(比如之前是个文本节点现在也是文本节点), 如果是组件节点组件类型都必须相同(之前的节点是A现在是B就是不行的),如果是由React元素生成的, type值还必须是一致的
- 不同的节点类型: 除去相同的节点就是不同的
- 多个兄弟节点通过唯一表示key来确定对比的新节点
找到了对比的目标
- 判断节点类型是否一致
-
如果一致的话会根据不同的节点类型做不同的事情
- 空节点: 不做任何事情
- dom节点: 直接使用之前的真实dom对象, 不会再去创建新的真实dom, 但是将其属性的变化记录下来, 以待将来统一完成更新, 同时在遍历该新react元素的子元素, 进行递归对比更新
- 文本节点: 同样重用之前的真实dom对象, 但是记录更新的value值, 以待将来统一完成更新
- 组件节点
函数节点
直接重新调用函数得到一个节点对象重新进入递归对比更新
类组件
直接重用之前的组件实例, 调用生命周期方法 static getDerivedStateFromProps,再调用shouldComponentUpdate如果返回false则终止流程, 如果返回的是true就运行render得到新的节点对象再次进入递归对比更新, 然后将该对象的getSnapshotBeforeUpdate加入队列等待执行, 再讲该对象的componentDidUpdate放进执行队列等待执行 - 数组节点: 将数组的每一项拿出来进行递归对比更新
-
不一致
如果不一致的话会整体上卸载旧的节点, 全新创建新的节点
创建新节点- 进入上方写的节点首次渲染的流程
卸载旧节点
- 如果旧节点是文本节点,dom节点, 空节点, 数组节点或函数组件节点的话, 直接放弃该节点, 如果节点有子元素, 递归卸载节点
- 如果节点是类组件节点, 直接放弃该节点, 调用该节点的componentWillUnMount, 再递归卸载子元素节点
-
没找到对比的目标
新的dom树种有节点被删除或者添加则会没有找到对比目标
创建新加入的节点
卸载多余的节点
我们最后来说说这个key值
key值的作用是用于通过旧节点得到对应的新节点, 帮助react找到对比目标
如果某个旧节点有key值, 则其更新时会寻找相同层级中的相同key值的节点进行对比, 如果找不到就会进入没有找到对比目标的流程
key值在某一个范围内应该唯一且稳定, 不然会对react的对比算法进行影响从而造成不必要的渲染
React要求在数组渲染中必须提供key值, 因为如果不提供key值会导致数组发生改变以后React永远无法找到对比目标, 所以永远都会卸载旧的创建新的, 根本不存在重用dom的可能, 同时在数组中尽量要避免用index为key值, 因为index也会改变会导致key值不稳定也会导致dom很难重用