前言
通过 React 框架基础与 React 框架进阶两篇文章,我们已经对 React 框架有一定的理解,但是面对这个庞然大物,我们依然需要更加进一步的探索框架本身的执行原理,这虽然很难但是通关高级前端中也几乎是必须征服的一座大山。
基本上面试官在针对React原理上一般是如下几个问题:
- 什么是
virtualDom - 什么是
diff算法 key的作用
JSX
在 React 的入门学习,我们接触的第一个话题就是 JSX ,从文档中也了解到,我们可以在 JavaScript 中编写 HTML 代码。
let app = (
<div id="a">
<p>abc</p>
</div>
);
它最终会被编译器(例如 webpack )解析为如下代码:
var app = React.createElement("div", {id: "a"}, React.createElement("p", null, "abc"));
JSX 代码最终经过 babel-loader 会解析为 React.createElement(...) 嵌套对象。
这也解释了为什么我们编写 React 应用时,必须引入 import React from "react"; 如果不引入肯定就找不到 React 对象并且无法 React.createElement(...) 方法从而导致报错。
到这里我们可以思考 React.createElement 具体做了什么?答案就是创建一个虚拟 DOM 结构。
如果是我们自己去定义这个结构,应该会是这样的:
{
"type": "div",
"props": {
"id": "a"
},
"children": [
{
"type": "p",
"props": {},
"children": [
{
"props": {},
"children": [],
"type": "#text",
"content": "abc"
}
]
}
]
}
实际在 React 中将虚拟 DOM 转换成真实 DOM 这一过程也略微复杂,简单总结:
ReactDOM.render 将生成好的虚拟 DOM 渲染到指定容器上,其中采用了批处理、事务等机制并且对特定浏览器进行了性能优化,最终转换为真实 DOM。
JSX 本质是什么
所有的 JSX 代码最后都会转换成 React.createElement(...) , Babel 帮助我们完成了这个转换的过程。
因此它的本质就是 React.createElement(...) 嵌套对象结构。
VitrualDom
VitrualDOM 是什么
通过 React.createElement(...) 创建的一个 JavaScript 对象,该对象描述了 DOM 树的结构,可以通过它生成一个真实的 DOM 。
虚拟 DOM 的组成
即 ReactElement 对象树,我们的组件最终会被渲染成下面的虚拟 DOM:
- type:元素的类型,可以是原生 html 类型(字符串),或者自定义组件(函数或class);
- key:组件的唯一标识,用于 Diff 算法;
- ref:用于访问原生 DOM 节点;
- props:传入组件的 props,chidren 是 props 中的一个属性,它存储了当前组件的孩子节点,可以是数组(多个孩子节点)或对象(只有一个孩子节点);
- self:(非生产环境)指定当前位于哪个组件实例;
- _source:(非生产环境)指定调试代码来自的文件(
fileName)和代码行数(lineNumber)。
为什么需要虚拟 DOM
提高开发效率
使用 React ,你只需要告诉 React 你想让视图处于什么状态,React 则通过 VitrualDom 确保 DOM 与该状态相匹配。你不必自己去完成属性操作、事件处理、DOM 更新,React 会替你完成这一切。
提升性能
直接操作 DOM 是非常耗费性能的,这一点毋庸置疑。但是 React 使用 VitrualDom 也是无法避免操作 DOM 的。
如果是首次渲染, VitrualDom 不具有任何优势,甚至它要进行更多的计算,消耗更多的内存。
VitrualDom 的优势在于 React 的 Diff 算法和批处理策略,React 在页面更新之前,提前计算好了如何进行更新和渲染 DOM。实际上这个计算过程我们在直接操作 DOM 时,也是可以自己判断和实现的,但是一定会耗费非常多的精力和时间,而且往往我们自己做的是不如 React 好的。所以在这个过程中 React 帮助我们"提升了性能"。
所以,更倾向于说, VitrualDom 帮助我们提高了开发效率,在重复渲染时它帮助我们计算如何更高效的更新,而不是它比 DOM 操作更快。
跨浏览器兼容
React 基于 VitrualDom 自己实现了一套自己的事件机制,自己模拟了事件冒泡和捕获的过程,采用了事件代理,批量更新等方法,抹平了各个浏览器的事件兼容性问题。
跨平台兼容
VitrualDom 为 React 带来了跨平台渲染的能力。以 React Native 为例子。React 根据 VitrualDom 画出相应平台的 ui 层,只不过不同平台画的姿势不同而已。
Diff 算法
React Diff 会帮助我们计算出 VirtualDOM 中真正发生变化的部分,并且只针对该部分进行实际的 DOM 操作,而不是对整个页面进行重新渲染。
我们不需要深入的去理解如何实现这个算法,但是我们必须知道这个算法有哪些规律,不至于写出一些低效的代码。
传统 Diff 算法的问题
传统的 Diff 算法是使用循环递归对节点进行依次对比,时间复杂度为O(n^3),效率低下。
React Diff 算法策略
- 两个相同的组件产生类似的 DOM 结构,不同组件产生不同 DOM 结构
- 对于同一层次的一组子节点,它们可以通过唯一的 id 区分(key)
基于第一条策略:Diff 算法只会对同层的节点进行比较。如图它只会对颜色相同的节点进行比较:
也就是说如果父节点不同,React 将不会再去对比子节点。因为不同的组件 DOM 结构会不相同,所以就没有必要在去对比子节点了。这也提高了对比的效率。把时间复杂度降低为O(n)。
这里具体分为三种情况考虑:
- 节点类型不同
- 节点类型相同
- 子节点比较
新建组件 Bar、Foo、Wrap
import React,{ Component } from 'react';
class Bar extends Component{
componentDidMount() {
console.log("Bar componentDidMount");
}
componentWillUnmount() {
console.log("Bar componentWillUnmount");
}
componentDidUpdate(prevProps, prevState, snapshot) {
console.log("Bar componentDidUpdate");
}
render() {
return(
<div>
Bar
{this.props.children}
</div>
)
}
}
export default Bar;
// -------------------------------------------
import React,{ Component } from 'react';
class Foo extends Component{
componentDidMount() {
console.log("Foo componentDidMount");
}
componentWillUnmount() {
console.log("Foo componentWillUnmount");
}
componentDidUpdate(prevProps, prevState, snapshot) {
console.log("Foo componentDidUpdate");
}
render() {
return(
<div>
Foo
{this.props.children}
</div>
)
}
}
export default Foo;
// -------------------------------------------
import React,{ Component } from 'react';
class Wrap extends Component{
componentDidMount() {
console.log("Wrap componentDidMount");
}
componentWillUnmount() {
console.log("Wrap componentWillUnmount");
}
componentDidUpdate(prevProps, prevState, snapshot) {
console.log("Wrap componentDidUpdate");
}
render() {
return(
<div>
Wrap
{this.props.children}
</div>
)
}
}
export default Wrap;
节点类型不同
对于不同的节点类型,React 会基于第一条策略,直接删去旧的节点,新建一个节点。
import React, {useState} from 'react';
import Wrap from "./Component/Wrap";
import Foo from "./Component/Foo";
import Bar from "./Component/Bar";
function App() {
const [status,setStatus] = useState(true);
const toggle = ()=>{
setStatus(!status);
}
return (
<div className="App">
<button onClick={toggle}>节点类型不同</button>
{
status ?
(
<Foo>
<Wrap />
</Foo>
)
:
(
<Bar>
<Wrap />
</Bar>
)
}
</div>
);
}
export default App;
Foo 组件包含了 Wrap 组件,Bar 组件也包含了 Wrap 组件,切换状态时进行组件切换。我们看看发生了什么:
# 初始化时,Wrap组件与Foo组件挂载
Wrap componentDidMount
Foo componentDidMount
# 触发切换时,Foo与Wrap组件卸载,并且Wrap与Bar插入
Foo componentWillUnmount
Wrap componentWillUnmount
Wrap componentDidMount
Bar componentDidMount
由此可以看出,Foo 与其子节点 Wrap 会被直接删除,然后重新建一个 Bar,Wrap 插入。
假如 Wrap 是一个比较复杂的子组件,这样性能消耗非常大,我们应该尽量避免这样做。
相同节点类型
当对比相同的节点类型比较简单,这里分为两种情况,一种是 DOM 元素类型,对应 HTML 直接支持的元素类型:div,span 和 p,还有一种是自定义组件。
DOM元素对比
React 会对比它们的属性,只改变需要改变的属性,比如:
<div className="before" title="stuff" />
<div className="after" title="stuff" />
这两个 div 中,React 会只更新 className 的值
<div style={{color: 'red', fontWeight: 'bold'}} />
<div style={{color: 'green', fontWeight: 'bold'}} />
这两个 div 中,React 只会去更新 color 的值
自定义组件对比
由于 React 此时并不知道如何去更新 DOM 树,因为这些逻辑都在 React 组件里面,所以它能做的就是根据新节点的 props 去更新原来根节点的组件实例,触发一个更新的过程,最后在对所有的 child 节点在进行 Diff 的递归比较更新。
子节点比较
<div>
<button onClick={toggle}>子节点对比</button>
{
status?
(
<div>{/*状态1*/}
<Foo />
<Bar />
</div>
):
(
<div>{/*状态2*/}
<Foo />
<Wrap />
<Bar />
</div>
)
}
</div>
因为 React 在没有 key 的情况下对比节点的时候,是一个一个按着顺序对比的。从状态一到状态二,只是在中间插入了一个Wrap,但是如果没有 key 的时候,React 会把 Bar 删去,新建一个 Wrap 放在 Bar 的位置,然后重新建一个 Bar 放在尾部。
当节点很多的时候,这样做是非常低效的。有两种方法可以解决这个问题:
1、保持 DOM 结构的稳定性
我们来看这个变化,由两个子节点变成了三个,其实是一个不稳定的 DOM 结构,我们可以通过通过加一个 null ,保持 DOM 结构的稳定。这样按照顺序对比的时候,Bar 就不会被卸载又重建回来。
<div>
<button onClick={toggle}>子节点对比</button>
{
status?
(
<div>
<Foo />
{null}
<Bar />
</div>
):
(
<div>
<Foo />
<Wrap />
<Bar />
</div>
)
}
</div>
2、添加 key 属性
通过给节点配置 key,让 React 可以识别节点是否存在。
<div>
<button onClick={toggle}>子节点对比</button>
{
status?
(
<div>
<Foo key="Foo" />
<Bar key="Bar" />
</div>
):
(
<div>
<Foo key="Foo" />
<Wrap key="Wrap" />
<Bar key="Bar" />
</div>
)
}
</div>
配上 key 之后,再跑一遍代码看看:
# 初始化
Foo componentDidMount
Bar componentDidMount
# 改变状态
Foo componentDidUpdate
Wrap componentDidMount
Bar componentDidUpdate
配上 key 之后,状态二的生命周期就如我所愿,只在指定的位置创建 Wrap 节点插入。
这里要注意的一点是,key 值必须是稳定( 所以我们不能用 Math.random() 去创建 key ),可预测并且唯一的。
这里给我们性能优化也提供了两个非常重要的依据:
- 保持 DOM 结构的稳定性
- 加唯一 key
React 根据 key 来决定是销毁重新创建组件还是更新组件,原则是:
- key 相同,组件有所变化,React 会只更新组件对应变化的属性。
- key 不同,组件会销毁之前的组件,将整个组件重新渲染。
小结
本文并没有深入源码去讲解虚拟 DOM 是如何构建的以及更新组件时的执行流程。而是解析了虚拟 DOM 的由来以及为什么 React 要选择虚拟 DOM,最后演示了 React 是如何通过 Diff 算法更新 DOM 节点的。