目录
- 1,更新的2种场景
- 2,节点更新
- 3,对比 diff 更新
- 3.1,React 的假设
- 3.1.2,key
- 2.1,找到了对比的目标
- 2.1.1,节点类型一致
- 1,空节点
- 2,DOM节点
- 3,文本节点
- 4,组件节点
- 1,函数组件
- 2,类组件
- 5,数组节点
- 2.1.2,节点类型不一致
- 2.2,没有找到对比的目标
- 4,举例
- 例1,组件节点类型不一致
- 例2,子节点结构发生变化
- 例3,key 的作用
上篇文章介绍了首次渲染时,React 做的事情。
这篇介绍下在是如何更新节点的。
1,更新的2种场景
- 重新调用
ReactDOM.render()
,触发根节点更新。 - 调用类组件实例的
this.setState()
,导致该实例所在的节点更新。
2,节点更新
第1种情况,直接进入根节点的对比 diff 更新。
第2种情况,调用this.setState()
的更新流程:
- 运行生命周期函数
static getDerivedStateFromProps
; - 运行生命周期函数
shouldComponentUpdate
,如果返回false
,则到此结束,终止流程。 - 运行
render
,得到一个新的节点,进入该节点的对比 diff 更新。 - 将生命周期函数
getSnapshotBeforeUpdate
加入执行队列,以待将来执行 - 将生命周期函数
componentDidUpdate
加入执行队列,以待将来执行。
后续步骤:
- 更新虚拟DOM树;
- 完成真实DOM更新;
- 依次调用执行队列中的
componentDidMount
- 依次调用执行队列中的
getSnapshotBeforeUpdate
- 依次调用执行队列中的
componentDidUpdate
注意,这里的
componentDidMount
指的是子组件的,但子组件也不一定会执行(重新挂载)。
另外,涉及到的生命周期函数的执行顺序时,注意父render
执行完后遍历进入子组件,当子组件的所有生命周期函数执行后,才会跳出循环继续执行父的其他生命周期函数。
3,对比 diff 更新
整体流程:将运行 render
产生的新节点,对比旧虚拟DOM树中的节点,发现差异并完成更新。
问题:如何确定,对比旧虚拟DOM中的哪个节点?
3.1,React 的假设
React 为了提高对比效率,会做以下假设:
- 节点不会出现层级移动,这样可以直接在旧树中找到对应位置的节点进行对比。
- 不同的节点类型,会生成不同的结构。节点类型指 React 元素的
type
值。 - 多个兄弟节点,通过
key
做唯一标识,这样可以确定要对比的新节点。 如果没有 key,则按照顺序进行对比。
3.1.2,key
如果某个旧节点有 key
值,则它在更新时,会寻找相同层级中相同 key
的节点进行对比。
所以,key 值应该在一定范围内(一般为兄弟节点之间)保持唯一,并保持稳定。
保持稳定:不能随意更改,比如通过随机数生成,更新后随机数发生变化找不到旧值。(有意为之需要每次都使用新节点的情况除外)
2.1,找到了对比的目标
2.1.1,节点类型一致
根据不同的节点类型,做不同的事情:
1,空节点
无事发生。
2,DOM节点
- 直接重用之前的真实DOM对象,
- 属性的变化会记录下来,以待将来统一进行更新(此时不会更新),
- 遍历该DOM节点的子节点,递归对比 diff 更新。
3,文本节点
- 直接重用之前的真实DOM对象,
- 将新文本(
nodeValue
)的变化记录下来,以待将来统一进行更新。
4,组件节点
1,函数组件
重新调用函数得到新一个新节点对象,递归对比 diff 更新。
2,类组件
- 重用之前的实例;
- 运行生命周期函数
static getDerivedStateFromProps
; - 运行生命周期函数
shouldComponentUpdate
,如果返回false
,则到此结束,终止流程。 - 运行
render
,得到一个新的节点,进入该节点的递归对比 diff 更新。 - 将生命周期函数
getSnapshotBeforeUpdate
加入执行队列,以待将来执行 - 将生命周期函数
componentDidUpdate
加入执行队列,以待将来执行。
5,数组节点
遍历数组,递归对比 diff 更新。
2.1.2,节点类型不一致
卸载旧节点,使用新节点。
1,类组件节点
直接放弃,并运行生命周期函数 componentWillUnmount
,再递归卸载子节点。
2,其他节点
直接放弃,如果该节点有子节点,递归卸载子节点。
2.2,没有找到对比的目标
有2种情况:
- 新的DOM树中有节点被删除,则卸载多余的旧节点。
- 新的DOM树中有节点添加,则创建新加入的节点。
4,举例
例1,组件节点类型不一致
更新时如果节点类型不一致,那所有的子节点全部卸载,重新更新。
不管子节点的类型是否一致。所以如果是类组件,会重新挂载并运行 componentDidMount
。
下面的例子中,就是因为节点类型发生变化 div --> p
,所以当点击按钮切换时,子组件 Child
会重新挂载(3个生命周期函数都会执行),并且 button 也不是同一个。
import React, { Component } from "react";
export default class App extends Component {
state = {
visible: false,
};
changeState = () => {
this.setState({
visible: !this.state.visible,
});
};
render() {
if (this.state.visible) {
return (
<div>
<Child />
<button onClick={this.changeState}>toggle</button>
</div>
);
} else {
return (
<p>
<Child />
<button onClick={this.changeState}>toggle</button>
</p>
);
}
}
}
// 子组件
class Child extends Component {
state = {};
static getDerivedStateFromProps() {
console.log("子 getDerived");
return null;
}
componentDidMount() {
console.log("子 didMount");
}
render() {
console.log("子 render");
return <span>子组件</span>;
}
}
例2,子节点结构发生变化
根节点类型一致,子节点结构发生变化。
下面的例子中,节点对比是按照顺序的,参考上文提到的React的假设1和假设3。
所以,当点击出现 h1 元素的节点对比更新过程中,
- 对比组件根节点 div,类型一致重用之前的真实DOM对象,遍历子节点。
- 新节点 h1 会和原来这个位置的旧节点 button 对比,不一致则删除旧节点 button。
- 新节点 button 发现没有找到对比的目标,则没有其他操作。
- 通过新虚拟DOM树,完成真实DOM更新。
export default class App extends Component {
state = {
visible: false,
};
changeState = () => {
this.setState({
visible: !this.state.visible,
});
};
render() {
if (this.state.visible) {
return (
<div>
<h1>标题1</h1>
<button className="btn" onClick={this.changeState}>
toggle
</button>
</div>
);
} else {
return (
<div>
<button className="btn" onClick={this.changeState}>
toggle
</button>
</div>
);
}
}
}
所以,一般需要改变DOM 结构时,为了提升效率,要么指定 key来直接告诉 React 要对比的旧节点,要么保证顺序和层级一致。
上面的例子可以更改如下,这也是空节点的作用之一。
render() {
return (
<div className="parent">
{this.state.visible && <h1>标题1</h1>}
<button className="btn" onClick={this.changeState}>
toggle
</button>
</div>
);
}
// 或
render() {
return (
<div className="parent">
{this.state.visible ? <h1>标题1</h1> : null}
<button className="btn" onClick={this.changeState}>
toggle
</button>
</div>
);
}
例3,key 的作用
下面的例子,子组件是类组件,有自己的状态,也会更改状态。
父组件以数组的形式渲染多个子组件,同时会在数组头部插入新的子组件。
import React, { Component } from "react";
class Child extends Component {
state = {
num: 1,
};
componentDidMount() {
console.log("子 didMount");
}
componentWillUnmount() {
console.log("子组件卸载");
}
changeNum = () => {
this.setState({
num: this.state.num + 1,
});
};
render() {
return (
<div>
<span>数字:{this.state.num}</span>
<button onClick={this.changeNum}>加一</button>
</div>
);
}
}
export default class App extends Component {
state = {
arr: [<Child />, <Child />],
};
addArr = () => {
this.setState({
arr: [<Child />, ...this.state.arr],
});
};
render() {
return (
<div className="parent">
{this.state.arr}
<button className="btn" onClick={this.addArr}>
添加
</button>
</div>
);
}
}
效果:
会发现,新的子组件加到最后去了,同时会打印一次 子 didMount
,并且 componentWillUnmount
并没有执行。
原因:因为没有设置 key,所以在新旧节点对比时,发现第1个节点类型一致,于是重用了之前的实例。直到对比到最后一个发现没有找到对比目标,才会用新的节点来创建真实DOM。
另外,正因为是类组件节点,所以并不会像我们印象中数组没有指定 key 时,如果往数组的开头插入元素,会导致所有的数组元素重新渲染。
增加 key 调整:
export default class App extends Component {
state = {
arr: [<Child key={1} />, <Child key={2} />],
nextId: 3,
};
addArr = () => {
this.setState({
arr: [<Child key={this.state.nextId} />, ...this.state.arr],
nextId: this.state.nextId + 1,
});
};
render() {
return (
<div className="parent">
{this.state.arr}
<button className="btn" onClick={this.addArr}>
添加
</button>
</div>
);
}
}
以上。