React
主要内容
1. 简介
1.1 什么是react (DOM和虚拟DOM)
- React 是一个开源前端 JavaScript 库,一个框架,用于构建用户界面,尤其是单页应用程序。它用于处理网页和移动应用程序的视图层。React 是由 Facebook 的软件工程师 Jordan Walke 创建的。在 2011 年 React 应用首次被部署到 Facebook 的信息流中,之后于 2012 年被应用到 Instagram 上。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CVfvj2SA-1612685358025)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-23 下午5.15.59.png)]
-
React 扮演在开发者和 Domo 之间的中间人角色。
-
DOM 的全称是 Document Object Model (文档对象模型)。顾名思义它就是文档对应的对象模型。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uwqWVndE-1612685358027)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-23 下午5.19.12.png)]
难道 DOM 是……一棵树?对,就是一棵树!奇怪的是,计算机相关的很多东西其实都像是一棵树。
我们来给 DOM 起个昵称……就叫 Domo 如何?Domo 是 “Web Browser” 工作室的御用模特,他的工作就是在肖像画家(也可能是数百万个画家)面前摆 pose 。
肖像就是在浏览器中浏览网站时所看见的内容。开发者的职责就好比是导演,他来告诉 Domo 该穿什么衣服,摆什么 pose 。这将决定肖像最终画出来的样子。jQuery 和 React 都是库,开发者使用它们作为与 Domo 交流的工具。
虚拟DOM
作为一个严苛的导演,你讨厌等待。你想要肖像画尽可能快地完成。但是,Domo 和画家都比较慢,并非是树濑那种慢,只是 Domo 需要时间来换装和摆 pose ,并且画家作画也需要时间。
更糟糕的是,在画家完成一幅肖像画之前,你无法与 Domo 进行沟通。事实上,你什么也做不了,除了等待。真浪费时间!
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-liY83tvX-1612685358028)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-23 下午5.26.13.png)]
React 采用了另一项技术来解决此问题。React 画草稿的速度超级快。是当你告诉他你的要求后,他几乎就能立即将草稿完成并准备画下一张。现在就无需等待了!你可以不停地告诉 React 你想的肖像。React 将会纪录草稿的所有细节,并在适当的时候展示给 Domo 看。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EJcsxg4c-1612685358030)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-23 下午5.27.21.png)]
更重要的一点是 React 十分聪明。他还会对所有草稿进行整理,拿掉重复的并确保 Domo 和画家的工作量维持在最低水平。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xQl3HVis-1612685358031)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-23 下午5.27.40.png)]
这些草稿就是 “虚拟 DOM” 。虚拟 DOM 要比操纵 DOM 快得多得多。开发者绝大部分时间里其实都是在操纵虚拟 DOM ,而不是直接操纵真实的 DOM 。React 负责管理 DOM 的这部分脏活。
虚拟DOM如何工作
- 每当任何底层数据发生更改时,整个 UI 都将以 Virtual DOM 的形式重新渲染。
- 然后计算先前 Virtual DOM 对象和新的 Virtual DOM 对象之间的差异。
- 一旦计算完成,真实的DOM将只更新实际更改的内容。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W1v0Dcbh-1612685358033)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-23 下午5.31.47.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6f5kNJaF-1612685358033)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-23 下午5.31.57.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HbwGBtUD-1612685358034)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-23 下午5.32.10.png)]
2. JSX
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DdXnn89I-1612685358035)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-23 下午5.36.09.png)]
const title = <h1 className="title">Hello, world!</h1>;
这段代码并不是合法的js代码,它是一种被称为jsx的语法扩展,通过它我们就可以很方便的在js代码中书写html片段。
本质上,jsx是语法糖,上面这段代码会被babel转换成如下代码
const title = React.createElement(
'h1',
{ className: 'title' },
'Hello, world!'
);
(JSX 元素将通过 React.createElement()
函数来创建 React 元素,这些对象将用于表示 UI 对象。)
-
在 JSX 语法中,你可以在大括号内放置任何有效的 JavaScript 表达式。
const name = 'Josh Perez'; const element = <h1>Hello, {name}</h1>;
这就是jsx的大概用法
-
为什么 React 使用
className
而不是class
属性?class
是 JavaScript 中的关键字,而 JSX 是 JavaScript 的扩展。这就是为什么 React 使用className
而不是class
的主要原因。render() { return <span className={'menu navigation-menu'}>{'Menu'}</span> }
3. 组件和props
组件,从概念上类似于 JavaScript 函数。它接受任意的入参(即 “props”),并返回用于描述页面展示内容的 React 元素。
3.1 React 中的 props 是什么?
Props 是组件的输入。它们是单个值或包含一组值的对象,这些值在创建时使用,类似于 HTML 标记属性的命名约定传递给组件。它们是从父组件传递到子组件的数据。
Props 的主要目的是提供以下组件功能:
- 将自定义数据传递到组件。
- 触发状态更改。
- 在组件的
render()
方法中通过this.props.reactProp
使用。
例如,让我们使用 reactProp
属性创建一个元素:
<Element reactProp={'1'} />
然后,reactProp
将成为附加到 React props 对象的属性,该对象最初已存在于使用 React 库创建的所有组件上。
props.reactProp
3.2 如何创建组件
有两种可行的方法来创建一个组件:
-
Function Components: 这是创建组件最简单的方式。这些是纯 JavaScript 函数,接受 props 对象作为第一个参数并返回 React 元素:
function Greeting(props) { return <h1>{`Hello, ${props.name}`}</h1> }
-
Class Components: 你还可以使用 ES6 类来定义组件。上面的函数组件若使用 ES6 的类可改写为:
class Greeting extends React.Component { render() { return <h1>{`Hello, ${this.props.name}`}</h1> } }
通过以上任意方式创建的组件,可以这样使用:
<Greeting name="hjr"/>
3.3 React 如何区分class 和function?
在 React 内部对函数组件和类组件的处理方式是不一样的,如:
// 如果 Greeting 是一个函数
const result = Greeting(props); // <p>Hello</p>
// 如果 Greeting 是一个类
const instance = new Greeting(props); // Greeting {}
const result = instance.render(); // <p>Hello</p>
如果 Greeting
是一个函数,React 需要调用它。但如果 Greeting
是一个类,React 需要先用 new
操作符将其实例化,然后 调用刚才生成实例的 render
方法。无论哪种情况 React 的目标都是去获取渲染后的节点。但具体的步骤取决于 Greeting
是如何定义的。
通常,用来判断某样东西是否是一个类的实例,要用到js里面的原型概念,我们可以检查 Greeting
是否扩展了 React.Component
,我们从 Greeting.prototype
开始,一路沿着 __proto__
链:
let greeting = new Greeting();
console.log(greeting instanceof Greeting); // true
// greeting (🕵️ 我们从这儿开始)
// .__proto__ → Greeting.prototype (✅ 找到了!)
// .__proto__ → React.Component.prototype
// .__proto__ → Object.prototype
console.log(greeting instanceof React.Component); // true
// greeting (🕵️ 我们从这儿开始)
// .__proto__ → Greeting.prototype
// .__proto__ → React.Component.prototype (✅ 找到了!)
// .__proto__ → Object.prototype
console.log(greeting instanceof Object); // true
// greeting (🕵️ 我们从这儿开始)
// .__proto__ → Greeting.prototype
// .__proto__ → React.Component.prototype
// .__proto__ → Object.prototype (✅ 找到了!)
console.log(greeting instanceof Banana); // false
// greeting (🕵️ 我们从这儿开始)
// .__proto__ → Greeting.prototype
// .__proto__ → React.Component.prototype
// .__proto__ → Object.prototype (🙅 没找到!)
然而 React 并不是这么做的。 😳 React 为基类增加了一个特别的标记。React 检查是否有这个标记,以此知道某样东西是否是一个 React 组件类。
最初这个标记是在 React.Component
这个基类自己身上
// React 内部
class Component {}
Component.isReactClass = {};
// 我们可以像这样检查它
class Greeting extends Component {}
console.log(Greeting.isReactClass); // ✅ 是的
然而,有些我们希望作为目标的类实现并没有复制静态属性(或设置非标准的 __proto__
),标记也因此丢失。
这也是为什么 React 把这个标记移动到了 React.Component.prototype
:
// React 内部
class Component {}
Component.prototype.isReactComponent = {};
// 我们可以像这样检查它
class Greeting extends Component {}
console.log(Greeting.prototype.isReactComponent); // ✅ 是的
如果你不扩展 React.Component
,React 不会在原型上找到 isReactComponent
,因此就不会把组件当做类处理。现在你知道为什么解决 Cannot call a class as a function
错误的得票数最高的答案是增加 extends React.Component
。最后,我们还增加了一项警告,当 prototype.render
存在但 prototype.isReactComponent
不存在时会发出警告。
3.4 何时使用类组件和函数组件?
如果组件需要使用状态或生命周期方法,那么使用类组件,否则使用函数组件。
函数组件的性能是比类组件的性能要高,因为类组件使用的时候要实例化,而函数组件直接执行函数取返回结果即可。为了提高性能,尽量使用函数组件。函数组件没有this,没有生命周期,没有状态state, 类组件有this,有生命周期,有状态state。但是后来引入了hook,也解决了函数组件这些问题。
3.5 高阶组件
高阶组件(HOC) 就是一个函数,且该函数接受一个组件作为参数,并返回一个新的组件,它只是一种模式,
它们可以接受任何动态提供的子组件,但它们不会修改或复制其输入组件中的任何行为,我们将它们称为纯组件,
const EnhancedComponent = higherOrderComponent(WrappedComponent)
HOC 有很多用例:
- 代码复用,逻辑抽象化
- 通常我会通过高阶组件去优化之前老项目写的不好的地方,比如两个页面UI几乎一样,功能几乎相同,仅仅几个操作不太一样,却写了两个耦合很多的页面级组件。当我去维护它的时候,由于它的耦合性过多,经常会添加一个功能(这两个组件都要添加),我要去改完第一个的时候,还要改第二个。而且有时候由于我的记性不好,会忘掉第二个… 就会出现bug再返工。更重要的是由于个人比较懒,不想去重构这部分的代码,因为东西太多了,花费太多时间。所以加新功能的时候,我会写一个高阶组件,往HOC里添加方法,把那两个组件包装一下,也就是属性代理。这样新代码就不会再出现耦合,旧的逻辑并不会改变,说不定哪天心情好就会抽离一部分功能到HOC里,直到理想的状态。
- 渲染劫持
- 抽象化和操作状态(
state
) - 操作属性(
props
)
(首先,什么是渲染劫持:渲染劫持的概念是控制组件从另一个组件输出的能力,当然这个概念一般和react中的高阶组件(HOC)放在一起解释比较有明了。高阶组件可以在render函数中做非常多的操作,从而控制原组件的渲染输出,只要改变了原组件的渲染,我们都将它称之为一种渲染劫持
。实际上,在高阶组件中,组合渲染和条件渲染都是渲染劫持的一种,通过反向继承,不仅可以实现以上两点,还可以增强由原组件render函数产生的React元素。实际的操作中 通过 操作 state、props 都可以实现渲染劫持)
4. state
组件的状态是一个对象,它包含某些信息,这些信息可能在组件的生命周期中发生更改。我们应该尽量使状态尽可能简单,并尽量减少有状态组件的数量。让我们创建一个包含消息状态的 User 组件:
class User extends React.Component {
constructor(props) {
super(props)
this.state = {
message: 'Welcome to React world'
}
}
render() {
return (
<div>
<h1>{this.state.message}</h1>
</div>
)
}
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nQoLaVS7-1612685358035)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-30 上午10.31.53.png)]状态(State)与属性(Props)类似,但它是私有的,完全由组件控制。也就是说,除了它所属的组件外,任何组件都无法访问它。
4.1 state和props区别
state 和 props 都是普通的 JavaScript 对象。虽然它们都保存着影响渲染输出的信息,但它们在组件方面的功能不同。Props 以类似于函数参数的方式传递给组件,而状态则类似于在函数内声明变量并对它进行管理。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XG8x0kzy-1612685358036)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-30 上午10.36.49.png)]
4.2 更新状态
如果你尝试直接改变状态,那么组件将不会重新渲染。
//Wrong
this.state.message = 'Hello world'
正确方法应该是使用 setState()
方法。它调度组件状态对象的更新。当状态更改时,组件通将会重新渲染。
//Correct
this.setState({ message: 'Hello World' })
setState(updater, callback)
这个方法是用来告诉react组件数据有更新,有可能需要重新渲染。它是异步的,
那就是在使用setState
改变状态之后,立刻通过this.state
去拿最新的状态往往是拿不到的。回调函数
6. 生命周期
6.1 生命周期不同阶段
组件生命周期有三个不同的生命周期阶段:
- Mounting: 组件已准备好挂载到浏览器的 DOM 中. 此阶段包含来自
constructor()
,getDerivedStateFromProps()
,render()
, 和componentDidMount()
, 生命周期方法中的初始化过程。 - Updating: 在此阶段,组件以两种方式更新,发送新的属性并使用
setState()
或forceUpdate()
方法更新状态. 此阶段包含getDerivedStateFromProps()
,shouldComponentUpdate()
,render()
,getSnapshotBeforeUpdate()
和componentDidUpdate()
生命周期方法。 - Unmounting: 在这个最后阶段,不需要组件,它将从浏览器 DOM 中卸载。这个阶段包含
componentWillUnmount()
生命周期方法。
值得一提的是,在将更改应用到 DOM 时,React 内部也有阶段概念。它们按如下方式分隔开:
- Render 组件将会进行无副作用渲染。这适用于纯组件(Pure Component),在此阶段,React 可以暂停,中止或重新渲染。
- Pre-commit 在组件实际将更改应用于 DOM 之前,有一个时刻允许 React 通过
getSnapshotBeforeUpdate()
捕获一些 DOM 信息(例如滚动位置)。 - Commit React 操作 DOM 并分别执行最后的生命周期:
componentDidMount()
在 DOM 渲染完成后调用,componentDidUpdate()
在组件更新时调用,componentWillUnmount()
在组件卸载时调用。 React 16.3+ 阶段 (也可以看交互式版本)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-M84GklAM-1612685358037)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-31 下午1.14.44.png)]
6.2 生命周期方法
React 16.3+
-
getDerivedStateFromProps: 在调用
render()
之前调用,并在 每次 渲染时调用。React生命周期的命名一直都是非常语义化的,这个生命周期的意思就是 从props中获取state ,可以说是太简单易懂了。可以说,这个生命周期的功能实际上就是将传入的
props
映射到state
上面。由于16.4的修改,这个函数会在每次re-rendering之前被调用,这意味着什么呢?
是的,这意味着即使你的
props
没有任何变化,而是state
发生了变化,导致组件发生了re-render,这个生命周期函数依然会被调用。这个生命周期函数是为了替代
componentWillReceiveProps
存在的,所以在你需要使用componentWillReceiveProps
的时候,就可以考虑使用getDerivedStateFromProps
来进行替代了。两者的参数是不相同的,而
getDerivedStateFromProps
是一个 静态函数 ,也就是这个函数不能通过this
访问到class
的属性,也并不推荐直接访问属性。而是应该通过参数提供的nextProps
以及prevState
来进行判断,根据新传入的props
来映射到state
。 -
componentDidMount: 首次渲染后调用,所有得 Ajax 请求、DOM 或状态更新、设置事件监听器都应该在此处发生。
-
shouldComponentUpdate: 确定组件是否应该更新。 默认情况下,它返回
true
。 如果你确定在更新状态或属性后不需要渲染组件,则可以返回false
值。 它是一个提高性能的好地方,因为它允许你在组件接收新属性时阻止重新渲染。 -
getSnapshotBeforeUpdate: 在最新的渲染输出提交给 DOM 前将会立即调用,这对于从 DOM 捕获信息(比如:滚动位置)很有用。
-
componentDidUpdate: 它主要用于更新 DOM 以响应 prop 或 state 更改。 如果
shouldComponentUpdate()
返回false
,则不会触发。 -
componentWillUnmount 当一个组件被从 DOM 中移除时,该方法被调用,取消网络请求或者移除与该组件相关的事件监听程序等应该在这里进行。
Before 16.3
- componentWillMount: 在组件
render()
前执行,用于根组件中的应用程序级别配置。应该避免在该方法中引入任何的副作用或订阅。 - componentWillReceiveProps: 在组件接收到新属性前调用,若你需要更新状态响应属性改变(例如,重置它),你可能需对比
this.props
和nextProps
并在该方法中使用this.setState()
处理状态改变。 - componentWillUpdate: 当
shouldComponentUpdate
返回true
后重新渲染组件之前执行,注意你不能在这调用this.setState()
7. react diff
7.1 前言
React 中最值得称道的部分莫过于 Virtual DOM 与 diff 的完美结合,特别是其高效的 diff 算法,让用户可以无需顾忌性能问题而”任性自由”的刷新页面,让开发者也可以无需关心 Virtual DOM 背后的运作原理,因为 React diff 会帮助我们计算出 Virtual DOM 中真正变化的部分,并只针对该部分进行实际 DOM 操作,而非重新渲染整个页面,从而保证了每次操作更新后页面的高效渲染,因此 Virtual DOM 与 diff 是保证 React 性能口碑的幕后推手。
diff 算法的普识度高,就更应该认可 React 针对 diff 算法优化所做的努力与贡献,更能体现 React 开发者们的魅力与智慧!
7.2 传统diff算法
计算一棵树形结构转换成另一棵树形结构的最少操作,是一个复杂且值得研究的问题。传统 diff 算法通过循环递归对节点进行依次对比,效率低下,算法复杂度达到 O(n^3),其中 n 是树中节点的总数。O(n^3) 到底有多可怕,这意味着如果要展示1000个节点,就要依次执行上十亿次的比较。这种指数型的性能消耗对于前端渲染场景来说代价太高了!现今的 CPU 每秒钟能执行大约30亿条指令,即便是最高效的实现,也不可能在一秒内计算出差异情况。
如果 React 只是单纯的引入 diff 算法而没有任何的优化改进,那么其效率是远远无法满足前端渲染所要求的性能。
因此,想要将 diff 思想引入 Virtual DOM,就需要设计一种稳定高效的 diff 算法,而 React 做到了!
那么,React diff 到底是如何实现的呢?
7.3 react diff
传统 diff 算法的复杂度为 O(n^3),显然这是无法满足性能要求的。React 通过制定大胆的策略,将 O(n^3) 复杂度的问题转换成 O(n) 复杂度的问题。
- Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。
- 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。
- 对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。
基于以上三个前提策略,React 分别对 tree diff、component diff 以及 element diff 进行算法优化,事实也证明这三个前提策略是合理且准确的,它保证了整体界面构建的性能。
-
tree diff
基于策略一,React 对树的算法进行了简洁明了的优化,即对树进行分层比较,两棵树只会对同一层次的节点进行比较。
既然 DOM 节点跨层级的移动操作少到可以忽略不计,针对这一现象,React 通过 updateDepth 对 Virtual DOM 树进行层级控制,只会对相同颜色方框内的 DOM 节点进行比较,即同一个父节点下的所有子节点。当发现节点已经不存在,则该节点及其子节点会被完全删除掉,不会用于进一步的比较。这样只需要对树进行一次遍历,便能完成整个 DOM 树的比较。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OhXpwwRO-1612685358037)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-31 下午1.02.23.png)]
updateChildren: function(nextNestedChildrenElements, transaction, context) {
updateDepth++;
var errorThrown = true;
try {
this._updateChildren(nextNestedChildrenElements, transaction, context);
errorThrown = false;
} finally {
updateDepth--;
if (!updateDepth) {
if (errorThrown) {
clearQueue();
} else {
processQueue();
}
}
}
}
分析至此,大部分人可能都存在这样的疑问:如果出现了 DOM 节点跨层级的移动操作,React diff 会有怎样的表现呢?是的,对此我也好奇不已,不如试验一番。
如下图,A 节点(包括其子节点)整个被移动到 D 节点下,由于 React 只会简单的考虑同层级节点的位置变换,而对于不同层级的节点,只有创建和删除操作。当根节点发现子节点中 A 消失了,就会直接销毁 A;当 D 发现多了一个子节点 A,则会创建新的 A(包括子节点)作为其子节点。此时,React diff 的执行情况:create A -> create B -> create C -> delete A。
由此可发现,当出现节点跨层级移动时,并不会出现想象中的移动操作,而是以 A 为根节点的树被整个重新创建,这是一种影响 React 性能的操作,因此 React 官方建议不要进行 DOM 节点跨层级的操作。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Z6SKiuyE-1612685358038)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-31 下午1.04.08.png)]
- component diff
React 是基于组件构建应用的,对于组件间的比较所采取的策略也是简洁高效。
- 如果是同一类型的组件,按照原策略继续比较 virtual DOM tree。
- 如果不是,则将该组件判断为 dirty component,从而替换整个组件下的所有子节点。
- 对于同一类型的组件,有可能其 Virtual DOM 没有任何变化,如果能够确切的知道这点那可以节省大量的 diff 运算时间,因此 React 允许用户通过 shouldComponentUpdate() 来判断该组件是否需要进行 diff。
如下图,当 component D 改变为 component G 时,即使这两个 component 结构相似,一旦 React 判断 D 和 G 是不同类型的组件,就不会比较二者的结构,而是直接删除 component D,重新创建 component G 以及其子节点。虽然当两个 component 是不同类型但结构相似时,React diff 会影响性能,但正如 React 官方博客所言:不同类型的 component 是很少存在相似 DOM tree 的机会,因此这种极端因素很难在实现开发过程中造成重大影响的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4xBTyta4-1612685358039)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-31 下午1.05.51.png)]
-
element diff
当节点处于同一层级时,React diff 提供了三种节点操作,分别为:INSERT_MARKUP(插入)、MOVE_EXISTING(移动)和 REMOVE_NODE(删除)。
-
INSERT_MARKUP,新的 component 类型不在老集合里, 即是全新的节点,需要对新节点执行插入操作。
-
MOVE_EXISTING,在老集合有新 component 类型,且 element 是可更新的类型,generateComponentChildren 已调用 receiveComponent,这种情况下 prevChild=nextChild,就需要做移动操作,可以复用以前的 DOM 节点。
-
REMOVE_NODE,老 component 类型,在新集合里也有,但对应的 element 不同则不能直接复用和更新,需要执行删除操作,或者老 component 不在新集合里的,也需要执行删除操作。
function enqueueInsertMarkup(parentInst, markup, toIndex) { updateQueue.push({ parentInst: parentInst, parentNode: null, type: ReactMultiChildUpdateTypes.INSERT_MARKUP, markupIndex: markupQueue.push(markup) - 1, content: null, fromIndex: null, toIndex: toIndex, }); } function enqueueMove(parentInst, fromIndex, toIndex) { updateQueue.push({ parentInst: parentInst, parentNode: null, type: ReactMultiChildUpdateTypes.MOVE_EXISTING, markupIndex: null, content: null, fromIndex: fromIndex, toIndex: toIndex, }); } function enqueueRemove(parentInst, fromIndex) { updateQueue.push({ parentInst: parentInst, parentNode: null, type: ReactMultiChildUpdateTypes.REMOVE_NODE, markupIndex: null, content: null, fromIndex: fromIndex, toIndex: null, }); }
-
如下图,老集合中包含节点:A、B、C、D,更新后的新集合中包含节点:B、A、D、C,此时新老集合进行 diff 差异化对比,发现 B != A,则创建并插入 B 至新集合,删除老集合 A;以此类推,创建并插入 A、D 和 C,删除 B、C 和 D。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zh6GuWh7-1612685358039)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-31 下午1.07.37.png)]
React 发现这类操作繁琐冗余,因为这些都是相同的节点,但由于位置发生变化,导致需要进行繁杂低效的删除、创建操作,其实只要对这些节点进行位置移动即可。
针对这一现象,React 提出优化策略:允许开发者对同一层级的同组子节点,添加唯一 key 进行区分,虽然只是小小的改动,性能上却发生了翻天覆地的变化!
新老集合所包含的节点,如下图所示,新老集合进行 diff 差异化对比,通过 key 发现新老集合中的节点都是相同的节点,因此无需进行节点删除和创建,只需要将老集合中节点的位置进行移动,更新为新集合中节点的位置,此时 React 给出的 diff 结果为:B、D 不做任何操作,A、C 进行移动操作,即可。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZpUiW7GY-1612685358040)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-31 下午1.09.00.png)]
那么,如此高效的 diff 到底是如何运作的呢?让我们通过源码进行详细分析。
(只讲:首先对新集合的节点进行循环遍历,for (name in nextChildren),通过唯一 key 可以判断新老集合中是否存在相同的节点,if (prevChild === nextChild),如果存在相同节点,则进行移动操作,)
以上图为例,可以更为清晰直观的描述 diff 的差异对比过程:
- 从新集合中取得 B,判断老集合中存在相同节点 B,通过对比节点位置判断是否进行移动操作,B 在老集合中的位置 B._mountIndex = 1,此时 lastIndex = 0,不满足 child._mountIndex < lastIndex 的条件,因此不对 B 进行移动操作;更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),其中 prevChild._mountIndex 表示 B 在老集合中的位置,则 lastIndex = 1,并将 B 的位置更新为新集合中的位置prevChild._mountIndex = nextIndex,此时新集合中 B._mountIndex = 0,nextIndex++ 进入下一个节点的判断。但在移动前需要将当前节点在老集合中的位置与 lastIndex 进行比较,if (child._mountIndex < lastIndex),则进行节点移动操作,否则不执行该操作。这是一种顺序优化手段,lastIndex 一直在更新,表示访问过的节点在老集合中最右的位置(即最大的位置),如果新集合中当前访问的节点比 lastIndex 大,说明当前访问节点在老集合中就比上一个节点位置靠后,则该节点不会影响其他节点的位置,因此不用添加到差异队列中,即不执行移动操作,只有当访问的节点比 lastIndex 小时,才需要进行移动操作。
- 从新集合中取得 A,判断老集合中存在相同节点 A,通过对比节点位置判断是否进行移动操作,A 在老集合中的位置 A._mountIndex = 0,此时 lastIndex = 1,满足 child._mountIndex < lastIndex的条件,因此对 A 进行移动操作enqueueMove(this, child._mountIndex, toIndex),其中 toIndex 其实就是 nextIndex,表示 A 需要移动到的位置;更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),则 lastIndex = 1,并将 A 的位置更新为新集合中的位置 prevChild._mountIndex = nextIndex,此时新集合中A._mountIndex = 1,nextIndex++ 进入下一个节点的判断。
- 从新集合中取得 D,判断老集合中存在相同节点 D,通过对比节点位置判断是否进行移动操作,D 在老集合中的位置 D._mountIndex = 3,此时 lastIndex = 1,不满足 child._mountIndex < lastIndex的条件,因此不对 D 进行移动操作;更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),则 lastIndex = 3,并将 D 的位置更新为新集合中的位置 prevChild._mountIndex = nextIndex,此时新集合中D._mountIndex = 2,nextIndex++ 进入下一个节点的判断。
- 从新集合中取得 C,判断老集合中存在相同节点 C,通过对比节点位置判断是否进行移动操作,C 在老集合中的位置 C._mountIndex = 2,此时 lastIndex = 3,满足 child._mountIndex < lastIndex 的条件,因此对 C 进行移动操作 enqueueMove(this, child._mountIndex, toIndex);更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),则 lastIndex = 3,并将 C 的位置更新为新集合中的位置 prevChild._mountIndex = nextIndex,此时新集合中 C._mountIndex = 3,nextIndex++ 进入下一个节点的判断,由于 C 已经是最后一个节点,因此 diff 到此完成。
以上主要分析新老集合中存在相同节点但位置不同时,对节点进行位置移动的情况,如果新集合中有新加入的节点且老集合存在需要删除的节点,那么 React diff 又是如何对比运作的呢?
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zIm3jUIK-1612685358040)(/Users/jinronghe/Library/Application Support/typora-user-images/截屏2021-01-31 下午1.10.44.png)]
- 从新集合中取得 B,判断老集合中存在相同节点 B,由于 B 在老集合中的位置 B._mountIndex = 1,此时lastIndex = 0,因此不对 B 进行移动操作;更新 lastIndex = 1,并将 B 的位置更新为新集合中的位置B._mountIndex = 0,nextIndex++进入下一个节点的判断。
- 从新集合中取得 E,判断老集合中不存在相同节点 E,则创建新节点 E;更新 lastIndex = 1,并将 E 的位置更新为新集合中的位置,nextIndex++进入下一个节点的判断。
- 从新集合中取得 C,判断老集合中存在相同节点 C,由于 C 在老集合中的位置C._mountIndex = 2,lastIndex = 1,此时 C._mountIndex > lastIndex,因此不对 C 进行移动操作;更新 lastIndex = 2,并将 C 的位置更新为新集合中的位置,nextIndex++ 进入下一个节点的判断。
- 从新集合中取得 A,判断老集合中存在相同节点 A,由于 A 在老集合中的位置A._mountIndex = 0,lastIndex = 2,此时 A._mountIndex < lastIndex,因此对 A 进行移动操作;更新 lastIndex = 2,并将 A 的位置更新为新集合中的位置,nextIndex++ 进入下一个节点的判断。
- 当完成新集合中所有节点 diff 时,最后还需要对老集合进行循环遍历,判断是否存在新集合中没有但老集合中仍存在的节点,发现存在这样的节点 D,因此删除节点 D,到此 diff 全部完成。
_updateChildren: function(nextNestedChildrenElements, transaction, context) {
var prevChildren = this._renderedChildren;
var nextChildren = this._reconcilerUpdateChildren(
prevChildren, nextNestedChildrenElements, transaction, context
);
if (!nextChildren && !prevChildren) {
return;
}
var name;
var lastIndex = 0;
var nextIndex = 0;
for (name in nextChildren) {
if (!nextChildren.hasOwnProperty(name)) {
continue;
}
var prevChild = prevChildren && prevChildren[name];
var nextChild = nextChildren[name];
if (prevChild === nextChild) {
// 移动节点
this.moveChild(prevChild, nextIndex, lastIndex);
lastIndex = Math.max(prevChild._mountIndex, lastIndex);
prevChild._mountIndex = nextIndex;
} else {
if (prevChild) {
lastIndex = Math.max(prevChild._mountIndex, lastIndex);
// 删除节点
this._unmountChild(prevChild);
}
// 初始化并创建节点
this._mountChildAtIndex(
nextChild, nextIndex, transaction, context
);
}
nextIndex++;
}
for (name in prevChildren) {
if (prevChildren.hasOwnProperty(name) &&
!(nextChildren && nextChildren.hasOwnProperty(name))) {
this._unmountChild(prevChildren[name]);
}
}
this._renderedChildren = nextChildren;
},
// 移动节点
moveChild: function(child, toIndex, lastIndex) {
if (child._mountIndex < lastIndex) {
this.prepareToManageChildren();
enqueueMove(this, child._mountIndex, toIndex);
}
},
// 创建节点
createChild: function(child, mountImage) {
this.prepareToManageChildren();
enqueueInsertMarkup(this, mountImage, child._mountIndex);
},
// 删除节点
removeChild: function(child) {
this.prepareToManageChildren();
enqueueRemove(this, child._mountIndex);
},
_unmountChild: function(child) {
this.removeChild(child);
child._mountIndex = null;
},
_mountChildAtIndex: function(
child,
index,
transaction,
context) {
var mountImage = ReactReconciler.mountComponent(
child,
transaction,
this,
this._nativeContainerInfo,
context
);
child._mountIndex = index;
this.createChild(child, mountImage);
},
当然,React diff 还是存在些许不足与待优化的地方,如下图所示,若新集合的节点更新为:D、A、B、C,与老集合对比只有 D 节点移动,而 A、B、C 仍然保持原有的顺序,理论上 diff 应该只需对 D 执行移动操作,然而由于 D 在老集合的位置是最大的,导致其他节点的 _mountIndex < lastIndex,造成 D 没有执行移动操作,而是 A、B、C 全部移动到 D 节点后面的现象。
在此,读者们可以讨论思考:如何优化上述问题?
建议:在开发过程中,尽量减少类似将最后一个节点移动到列表首部的操作,当节点数量过大或更新操作过于频繁时,在一定程度上会影响 React 的渲染性能。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NIzKTDZu-1612685358041)(/Users/jinronghe/Desktop/截屏2021-01-31 下午1.11.57.png)]
7.4 总结
- React 通过制定大胆的 diff 策略,将 O(n3) 复杂度的问题转换成 O(n) 复杂度的问题;
- React 通过分层求异的策略,对 tree diff 进行算法优化;
- React 通过相同类生成相似树形结构,不同类生成不同树形结构的策略,对 component diff 进行算法优化;
- React 通过设置唯一 key的策略,对 element diff 进行算法优化;
- 建议,在开发组件时,保持稳定的 DOM 结构会有助于性能的提升;
- 建议,在开发过程中,尽量减少类似将最后一个节点移动到列表首部的操作,当节点数量过大或更新操作过于频繁时,在一定程度上会影响 React 的渲染性能。