Virtual DOM 及 Diff 算法
文章出处:拉 勾 大前端 高薪训练营
1. JSX 到底是什么
使用 React 就一定会写 JSX,JSX 到底是什么呢?它是一种 JavaScript 语法的扩展,React 使用它来描述用户界面长成什么样子。虽然它看起来非常像 HTML,但它确实是 JavaScript 。在 React 代码执行之前,Babel 会对将 JSX 编译为 React API.
<div className="container">
<h3>Hello React</h3>
<p>React is great </p>
</div>
React.createElement(
"div",
{
className: "container"
},
React.createElement("h3", null, "Hello React"),
React.createElement("p", null, "React is great")
);
从两种语法对比来看,JSX 语法的出现是为了让 React 开发人员编写用户界面代码更加轻松。
2. DOM 操作问题
在现代 web 应用程序中使用 JavaScript 操作 DOM 是必不可少的,但遗憾的是它比其他大多数 JavaScript 操作要慢的多。
大多数 JavaScript 框架对于 DOM 的更新远远超过其必须进行的更新,从而使得这种缓慢操作变得更糟。
例如假设你有包含十个项目的列表,你仅仅更改了列表中的第一项,大多数 JavaScript 框架会重建整个列表,这比必要的工作要多十倍。
更新效率低下已经成为严重问题,为了解决这个问题,React 普及了一种叫做 Virtual DOM 的东西,Virtual DOM 出现的目的就是为了提高 JavaScript 操作 DOM 对象的效率。
3. 什么是 Virtual DOM
在 React 中,每个 DOM 对象都有一个对应的 Virtual DOM 对象,它是 DOM 对象的 JavaScript 对象表现形式,其实就是使用 JavaScript 对象来描述 DOM 对象信息,比如 DOM 对象的类型是什么,它身上有哪些属性,它拥有哪些子元素。
可以把 Virtual DOM 对象理解为 DOM 对象的副本,但是它不能直接显示在屏幕上。
<div className="container">
<h3>Hello React</h3>
<p>React is great </p>
</div>
{
type: "div",
props: {
className: "container" },
children: [
{
type: "h3",
props: null,
children: [
{
type: "text",
props: {
textContent: "Hello React"
}
}
]
},
{
type: "p",
props: null,
children: [
{
type: "text",
props: {
textContent: "React is great"
}
}
]
}
]
}
4. Virtual DOM 如何提升效率
精准找出发生变化的 DOM 对象,只更新发生变化的部分。
在 React 第一次创建 DOM 对象后,会为每个 DOM 对象创建其对应的 Virtual DOM 对象,在 DOM 对象发生更新之前,React 会先更新所有的 Virtual DOM 对象,然后 React 会将更新后的 Virtual DOM 和 更新前的 Virtual DOM 进行比较,从而找出发生变化的部分,React 会将发生变化的部分更新到真实的 DOM 对象中,React 仅更新必要更新的部分。
Virtual DOM 对象的更新和比较仅发生在内存中,不会在视图中渲染任何内容,所以这一部分的性能损耗成本是微不足道的。
<div id="container">
<p>Hello React</p>
</div>
<div id="container">
<p>Hello Angular</p>
</div>
const before = {
type: "div",
props: {
id: "container" },
children: [
{
type: "p",
props: null,
children: [
{
type: "text", props: {
textContent: "Hello React" } }
]
}
]
}
const after = {
type: "div",
props: {
id: "container" },
children: [
{
type: "p",
props: null,
children: [
{
type: "text", props: {
textContent: "Hello Angular" } }
]
}
]
}
5. 创建 Virtual DOM
在 React 代码执行前,JSX 会被 Babel 转换为 React.createElement 方法的调用,在调用 createElement 方法时会传入元素的类型,元素的属性,以及元素的子元素,createElement 方法的返回值为构建好的 Virtual DOM 对象。
{
type: "div",
props: null,
children: [{
type: "text", props: {
textContent: "Hello"}}]
}
/**
* 创建 Virtual DOM
* @param {string} type 类型
* @param {object | null} props 属性
* @param {createElement[]} children 子元素
* @return {object} Virtual DOM
*/
function createElement (type, props, ...children) {
return {
type,
props,
children
}
}
从 createElement 方法的第三个参数开始就都是子元素了,在定义 createElement 方法时,通过 ...children
将所有的子元素放置到 children 数组中。
const virtualDOM = (
<div className="container">
<h1>你好 Tiny React</h1>
<h2>(编码必杀技)</h2>
<div>
嵌套1 <div>嵌套 1.1</div>
</div>
<h3>(观察: 这个将会被改变)</h3>
{
2 == 1 && <div>如果2和1相等渲染当前内容</div>}
{
2 == 2 && <div>2</div>}
<span>这是一段内容</span>
<button onClick={
() => alert("你好")}>点击我</button>
<h3>这个将会被删除</h3>
2, 3
</div>
)
console.log(virtualDOM)
通过以上代码测试,发现返回的 Virtual DOM 存在一些问题,第一个问题是文本节点被直接放入到了数组中
而我们期望是文本节点应该是这样的
children: [
{
type: "text",
props: {
textContent: "React is great"
}
}
]
通过以下代码对 Virtual DOM 进行改造,重新构建 Virtual DOM。
// 将原有 children 拷贝一份 不要在原有数组上进行操作
const childElements = [].concat(...children).map(child => {
// 判断 child 是否是对象类型
if (child instanceof Object) {
// 如果是 什么都不需要做 直接返回即可
return child
} else {
// 如果不是对象就是文本 手动调用 createElement 方法将文本转换为 Virtual DOM
return createElement("text", {
textContent: child })
}
})
return {
type,
props,
children: childElements
}
通过观察返回的 Virtual DOM,文本节点已经被转化成了对象类型的 Virtual DOM,但是布尔值也被当做文本节点被转化了,在 JSX 中,如果 Virtual DOM 被转化为了布尔值或者null,是不应该被更新到真实 DOM 中的,所以接下来要做的事情就是清除 Virtual DOM 中的布尔值和null。
// 由于 map 方法无法从数据中刨除元素, 所以此处将 map 方法更改为 reduce 方法
const childElements = [].concat(...children).reduce((result, child) => {
// 判断子元素类型 刨除 null true false
if (child != null && child != false && child != true) {
if (child instanceof Object) {
result.push(child)
} else {
result.push(createElement("text", {
textContent: child }))
}
}
// 将需要保留的 Virtual DOM 放入 result 数组
return result
}, [])
在 React 组件中,可以通过 props.children 获取子元素,所以还需要将子元素存储在 props 对象中。
return {
type,
props: Object.assign({
children: childElements }, props),
children: childElements
}
6. 渲染 Virtual DOM 对象为 DOM 对象
通过调用 render 方法可以将 Virtual DOM 对象更新为真实 DOM 对象。
在更新之前需要确定是否存在旧的 Virtual DOM,如果存在需要比对差异,如果不存在可以直接将 Virtual DOM 转换为 DOM 对象。
目前先只考虑不存在旧的 Virtual DOM 的情况,就是说先直接将 Virtual DOM 对象更新为真实 DOM 对象。
// render.js
export default function render(virtualDOM, container, oldDOM = container.firstChild) {
// 在 diff 方法内部判断是否需要对比 对比也好 不对比也好 都在 diff 方法中进行操作
diff(virtualDOM, container, oldDOM)
}
// diff.js
import mountElement from "./mountElement"
export default function diff(virtualDOM, container, oldDOM) {
// 判断 oldDOM 是否存在
if (!oldDOM) {
// 如果不存在 不需要对比 直接将 Virtual DOM 转换为真实 DOM
mountElement(virtualDOM, container)
}
}
在进行 virtual DOM 转换之前还需要确定 Virtual DOM 的类 Component VS Native Element。
类型不同需要做不同的处理 如果是 Native Element 直接转换。
如果是组件 还需要得到组件实例对象 通过组件实例对象获取组件返回的 virtual DOM 然后再进行转换。
目前先只考虑 Native Element 的情况。
// mountElement.js
import mountNativeElement from "./mountNativeElement"
export default function