React-Virtual DOM 及 Diff 算法(一)

526 阅读13分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

TIP 👉 三军可夺帅也,匹夫不可夺志也。——《论语·子罕》

什么是 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"
                    }
                }
            ]
        }
    ]
}

Virtual DOM 如何提升效率

精准找出发生变化的 DOM 对象,只更新发生变化的部分。

在 React 第一次创建 DOM 对象后,会为每个 DOM 对象创建其对应的 Virtual DOM 对象,在 DOM 对象发生更新之前,React 会先更新所有的 Virtual DOM 对象,然后 React 会将更新后的 Virtual DOM 和 更新前的 Virtual DOM 进行比较,从而找出发生变化的部分,React 会将发生变化的部分更新到真实的 DOM 对象中,React 仅更新必要更新的部分。

Virtual DOM 对象的更新和比较仅发生在内存中,不会在视图中渲染任何内容,所以这一部分的性能损耗成本是微不足道的。

image.png

<div id="container">
    <p>Hello React</p>
</div>
const before = {
    type: "div",
    props: { id: "container" },
    children: [
        {
            type: "p",
            props: null,
            children: [
                { type: "text", props: { textContent: "Hello React" } }
            ]
        }
    ]
}

创建 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)

image.png

而我们期望是文本节点应该是这样的

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
}

image.png

通过观察返回的 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
}

渲染 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 mountElement(virtualDOM, container) {
    // 通过调用 mountNativeElement 方法转换 Native Element
    mountNativeElement(virtualDOM, container)
}
// mountNativeElement.js
import createDOMElement from "./createDOMElement"
export default function mountNativeElement(virtualDOM, container) {
    const newElement = createDOMElement(virtualDOM)
    container.appendChild(newElement)
}
// createDOMElement.js
import mountElement from "./mountElement"
import updateElementNode from "./updateElementNode"

export default function createDOMElement(virtualDOM) {
    let newElement = null
    if (virtualDOM.type === "text") {
        // 创建文本节点
        newElement = document.createTextNode(virtualDOM.props.textContent)
    } else {
        // 创建元素节点
        newElement = document.createElement(virtualDOM.type)
        // 更新元素属性
        updateElementNode(newElement, virtualDOM)
    }
    // 递归渲染子节点
    virtualDOM.children.forEach(child => {
        // 因为不确定子元素是 NativeElement 还是 Component 所以调用 mountElement 方法进行确定
        mountElement(child, newElement)
    })
    return newElement
}

为元素节点添加属性

// createDOMElement.js
// 看看节点类型是文本类型还是元素类型
if (virtualDOM.type === "text") {
    // 创建文本节点 设置节点内容
    newElement = document.createTextNode(virtualDOM.props.textContent)
} else {
// 根据 Virtual DOM type 属性值创建 DOM 元素
    newElement = document.createElement(virtualDOM.type)
    // 为元素设置属性
    updateElementNode(newElement, virtualDOM)
}
export default function updateElementNode(element, virtualDOM) {
    // 获取要解析的 VirtualDOM 对象中的属性对象
    const newProps = virtualDOM.props
    // 将属性对象中的属性名称放到一个数组中并循环数组
    Object.keys(newProps).forEach(propName => {
        const newPropsValue = newProps[propName]
        // 考虑属性名称是否以 on 开头 如果是就表示是个事件属性 onClick -> click
        if (propName.slice(0, 2) === "on") {
            const eventName = propName.toLowerCase().slice(2)
            element.addEventListener(eventName, newPropsValue)
            // 如果属性名称是 value 或者 checked 需要通过 [] 的形式添加
        } else if (propName === "value" || propName === "checked") {
            element[propName] = newPropsValue
            // 刨除 children 因为它是子元素 不是属性
        } else if (propName !== "children") {
            // className 属性单独处理 不直接在元素上添加 class 属性是因为 class 是 JavaScript 中的关键字
            if (propName === "className") {
                element.setAttribute("class", newPropsValue)
            } else {
            // 普通属性
                element.setAttribute(propName, newPropsValue)
            }
        }
    })
}

渲染组件

函数组件

在渲染组件之前首先要明确的是,组件的 Virtual DOM 类型值为函数,函数组件和类组件都是这样的。

// 原始组件
const Heart = () => <span>&hearts;</span>
<Heart />
// 组件的 Virtual DOM
{
    type: f function() {},
    props: {}
    children: []
}

在渲染组件时,要先将 Component 与 Native Element 区分开,如果是 Native Element 可以直接开始渲染,如果是组件,特别处理。

// mountElement.js
export default function mountElement(virtualDOM, container) {
    // 无论是类组件还是函数组件 其实本质上都是函数
    // 如果 Virtual DOM 的 type 属性值为函数 就说明当前这个 Virtual DOM 为组件
    if (isFunction(virtualDOM)) {
        // 如果是组件 调用 mountComponent 方法进行组件渲染
        mountComponent(virtualDOM, container)
    } else {
        mountNativeElement(virtualDOM, container)
    }
}

// Virtual DOM 是否为函数类型
export function isFunction(virtualDOM) {
    return virtualDOM && typeof virtualDOM.type === "function"
}

在 mountComponent 方法中再进行函数组件和类型的区分,然后再分别进行处理。

// mountComponent.js
import mountNativeElement from "./mountNativeElement"

export default function mountComponent(virtualDOM, container) {
    // 存放组件调用后返回的 Virtual DOM 的容器
    let nextVirtualDOM = null
    // 区分函数型组件和类组件
    if (isFunctionalComponent(virtualDOM)) {
        // 函数组件 调用 buildFunctionalComponent 方法处理函数组件
        nextVirtualDOM = buildFunctionalComponent(virtualDOM)
    } else {
        // 类组件
    }
    // 判断得到的 Virtual Dom 是否是组件
    if (isFunction(nextVirtualDOM)) {
        // 如果是组件 继续调用 mountComponent 解剖组件
        mountComponent(nextVirtualDOM, container)
    } else {
        // 如果是 Navtive Element 就去渲染
        mountNativeElement(nextVirtualDOM, container)
    }
}

// Virtual DOM 是否为函数型组件
// 条件有两个: 1. Virtual DOM 的 type 属性值为函数 2. 函数的原型对象中不能有render方法
// 只有类组件的原型对象中有render方法
export function isFunctionalComponent(virtualDOM) {
    const type = virtualDOM && virtualDOM.type
    return (
        type && isFunction(virtualDOM) && !(type.prototype && type.prototype.render)
    )
}
// 函数组件处理

function buildFunctionalComponent(virtualDOM) {
    // 通过 Virtual DOM 中的 type 属性获取到组件函数并调用
    // 调用组件函数时将 Virtual DOM 对象中的 props 属性传递给组件函数 这样在组件中就可以通过 props 属性获取数据了
    // 组件返回要渲染的 Virtual DOM
    return virtualDOM && virtualDOM.type(virtualDOM.props || {})
}

类组件

类组件本身也是 Virtual DOM,可以通过 Virtual DOM 中的 type 属性值确定当前要渲染的组件是类组件还是函数组件。

在确定当前要渲染的组件为类组件以后,需要实例化类组件得到类组件实例对象,通过类组件实例对象调用类组件中的 render 方法,获取组件要渲染的 Virtual DOM。

类组件需要继承 Component 父类,子类需要通过 super 方法将自身的 props 属性传递给 Component 父类,父类会将 props 属性挂载为父类属性,子类继承了父类,自己本身也就自然拥有props属性了。这样做的好处是当 props 发生更新后,父类可以根据更新后的 props 帮助子类更新视图。

假设以下代码就是我们要渲染的类组件:

class Alert extends TinyReact.Component {
    constructor(props) {
        // 将 props 传递给父类 子类继承父类的 props 子类自然就有 props 数据了
        // 否则 props 仅仅是 constructor 函数的参数而已
        // 将 props 传递给父类的好处是 当 props 发生更改时 父类可以帮助更新 props 更新组件视图
        super(props)
        this.state = {
            title: "default title"
        }
    }
    render() {
        return (
            <div>
                <h2>{this.state.title}</h2>
                <p>{this.props.message}</p>
            </div>

        )

    }

}

TinyReact.render(<Alert message="Hello React" />, root)

// Component.js 父类 Component 实现
export default class Component {
    constructor(props) {
        this.props = props
    }
}

在 mountComponent 方法中通过调用 buildStatefulComponent 方法得到类组件要渲染的 Virtual DOM

// mountComponent.js
export default function mountComponent(virtualDOM, container) {
    let nextVirtualDOM = null
    // 区分函数型组件和类组件
    if (isFunctionalComponent(virtualDOM)) {
        // 函数组件
        nextVirtualDOM = buildFunctionalComponent(virtualDOM)
    } else {
        // 类组件
        nextVirtualDOM = buildStatefulComponent(virtualDOM)
    }
    // 判断得到的 Virtual Dom 是否是组件
    if (isFunction(nextVirtualDOM)) {
        mountComponent(nextVirtualDOM, container)
    } else {
        mountNativeElement(nextVirtualDOM, container)
    }
}

// 处理类组件
function buildStatefulComponent(virtualDOM) {
    // 实例化类组件 得到类组件实例对象 并将 props 属性传递进类组件
    const component = new virtualDOM.type(virtualDOM.props)
    // 调用类组件中的render方法得到要渲染的 Virtual DOM
    const nextVirtualDOM = component.render()
    // 返回要渲染的 Virtual DOM
    return nextVirtualDOM
}

Virtual DOM 比对

在进行 Virtual DOM 比对时,需要用到更新后的 Virtual DOM 和更新前的 Virtual DOM,更新后的 Virtual DOM 目前我们可以通过 render 方法进行传递,现在的问题是更新前的 Virtual DOM 要如何获取呢?

对于更新前的 Virtual DOM,对应的其实就是已经在页面中显示的真实 DOM 对象。既然是这样,那么我们在创建真实DOM对象时,就可以将 Virtual DOM 添加到真实 DOM 对象的属性中。在进行 Virtual DOM 对比之前,就可以通过真实 DOM 对象获取其对应的 Virtual DOM 对象了,其实就是通过render方法的第三个参数获取的,container.firstChild。

在创建真实 DOM 对象时为其添加对应的 Virtual DOM 对象

// mountElement.js
import mountElement from "./mountElement"
export default function mountNativeElement(virtualDOM, container) {
    // 将 Virtual DOM 挂载到真实 DOM 对象的属性中 方便在对比时获取其 Virtual DOM
    newElement._virtualDOM = virtualDOM
}

Virtual DOM 类型相同

Virtual DOM 类型相同,如果是元素节点,就对比元素节点属性是否发生变化,如果是文本节点就对比文本节点内容是否发生变化

要实现对比,需要先从已存在 DOM 对象中获取其对应的 Virtual DOM 对象。

// diff.js
// 获取未更新前的 Virtual DOM
const oldVirtualDOM = oldDOM && oldDOM._virtualDOM

判断 oldVirtualDOM 是否存在, 如果存在则继续判断要对比的 Virtual DOM 类型是否相同,如果类型相同判断节点类型是否是文本,如果是文本节点对比,就调用 updateTextNode 方法,如果是元素节点对比就调用 setAttributeForElement 方法

// diff.js
else if (oldVirtualDOM && virtualDOM.type === oldVirtualDOM.type) {
    if (virtualDOM.type === "text") {
        // 文本节点 对比文本内容是否发生变化
        updateTextNode(virtualDOM, oldVirtualDOM, oldDOM)
    } else {
        // 元素节点 对比元素属性是否发生变化
        setAttributeForElement(oldDOM, virtualDOM, oldVirtualDOM)
    }

updateTextNode 方法用于对比文本节点内容是否发生变化,如果发生变化则更新真实 DOM 对象中的内容,既然真实 DOM 对象发生了变化,还要将最新的 Virtual DOM 同步给真实 DOM 对象。

function updateTextNode(virtualDOM, oldVirtualDOM, oldDOM) {
    // 如果文本节点内容不同
    if (virtualDOM.props.textContent !== oldVirtualDOM.props.textContent) {
        // 更新真实 DOM 对象中的内容
        oldDOM.textContent = virtualDOM.props.textContent
    }
    // 同步真实 DOM 对应的 Virtual DOM
    oldDOM._virtualDOM = virtualDOM
}

setAttributeForElement 方法用于设置/更新元素节点属性

思路是先分别获取更新后的和更新前的 Virtual DOM 中的 props 属性,循环新 Virtual DOM 中的 props 属性,通过对比看一下新 Virtual DOM 中的属性值是否发生了变化,如果发生变化 需要将变化的值更新到真实 DOM 对象中

再循环未更新前的 Virtual DOM 对象,通过对比看看新的 Virtual DOM 中是否有被删除的属性,如果存在删除的属性 需要将 DOM 对象中对应的属性也删除掉

// updateNodeElement.js
export default function updateNodeElement(
    newElement,
    virtualDOM,
    oldVirtualDOM = {}
) {
    // 获取节点对应的属性对象
    const newProps = virtualDOM.props || {}
    const oldProps = oldVirtualDOM.props || {}
    Object.keys(newProps).forEach(propName => {
        // 获取属性值
        const newPropsValue = newProps[propName]
        const oldPropsValue = oldProps[propName]
        if (newPropsValue !== oldPropsValue) {
            // 判断属性是否是否事件属性 onClick -> click
            if (propName.slice(0, 2) === "on") {
                // 事件名称
                const eventName = propName.toLowerCase().slice(2)
                // 为元素添加事件
                newElement.addEventListener(eventName, newPropsValue)
                // 删除原有的事件的事件处理函数
                if (oldPropsValue) {
                    newElement.removeEventListener(eventName, oldPropsValue)
                }
            } else if (propName === "value" || propName === "checked") {
                newElement[propName] = newPropsValue
            } else if (propName !== "children") {
                if (propName === "className") {
                    newElement.setAttribute("class", newPropsValue)
                } else {
                    newElement.setAttribute(propName, newPropsValue)
                }
            }
        }
    })
    // 判断属性被删除的情况
    Object.keys(oldProps).forEach(propName => {
        const newPropsValue = newProps[propName]
        const oldPropsValue = oldProps[propName]
        if (!newPropsValue) {
            // 属性被删除了
            if (propName.slice(0, 2) === "on") {
                const eventName = propName.toLowerCase().slice(2)
                newElement.removeEventListener(eventName, oldPropsValue)
            } else if (propName !== "children") {
                newElement.removeAttribute(propName)
            }
        }
    })
}

以上对比的仅仅是最上层元素,上层元素对比完成以后还需要递归对比子元素

else if (oldVirtualDOM && virtualDOM.type === oldVirtualDOM.type) {
    // 递归对比 Virtual DOM 的子元素
    virtualDOM.children.forEach((child, i) => {
        diff(child, oldDOM, oldDOM.childNodes[i])
    })
}

Virtual DOM 类型不同

当对比的元素节点类型不同时,就不需要继续对比了,直接使用新的 Virtual DOM 创建 DOM 对象,用新的 DOM 对象直接替换旧的 DOM 对象。当前这种情况要将组件刨除,组件要被单独处理。

// diff.js
else if (
    // 如果 Virtual DOM 类型不一样
    virtualDOM.type !== oldVirtualDOM.type &&
    // 并且 Virtual DOM 不是组件 因为组件要单独进行处理
    typeof virtualDOM.type !== "function"
) {
    // 根据 Virtual DOM 创建真实 DOM 元素
    const newDOMElement = createDOMElement(virtualDOM)
    // 用创建出来的真实 DOM 元素 替换旧的 DOM 元素
    oldDOM.parentNode.replaceChild(newDOMElement, oldDOM)
}

删除节点

删除节点发生在节点更新以后并且发生在同一个父节点下的所有子节点身上。

在节点更新完成以后,如果旧节点对象的数量多于新 VirtualDOM 节点的数量,就说明有节点需要被删除。

// 获取就节点的数量
let oldChildNodes = oldDOM.childNodes
// 如果旧节点的数量多于要渲染的新节点的长度
if (oldChildNodes.length > virtualDOM.children.length) {
    for (
    let i = oldChildNodes.length - 1;
    i > virtualDOM.children.length - 1;
    i--
    ) {
        oldDOM.removeChild(oldChildNodes[i])
    }
}