在学习过Snabbdom的源码后,自己实现一个虚拟DOM库(下)

420 阅读14分钟

前情提要


上篇地址:[造轮子]在学习过Snabbdom的源码后,自己实现一个虚拟DOM库(上)

原本没有打算分上下篇的,结果写作过程中发现篇幅超出预期了,想想还是分成了2篇。

上篇主要讲 h 函数和 modules 模块相关,那么今天继续来研究Snabbdom中另一个重点 patch 函数。

init.ts


和之前一样,首先在src目录创建init.ts文件。根据之前的案例,我们可以发现 patch 函数其实时由 init 函数返回的。

所以在看 patch 函数之前,我们先了解 init 函数的功能。

在Snabbdom中,init 函数接受需要使用的模块做为参数:

image.png

在Terdom中,我们省略了按需引用的功能:

image.png

所以,在init.ts直接引入所有Moudules

image.png

我们在modules文件夹下创建index.ts文件,在文件中引入所有模块,并统一进行导出

image.png

init

回到init.ts中,我们进入 init 函数,首先定义一个变量api用于缓存操作dom的相关api

image.png

我们在src目录下创建htmldomapi.ts文件,在init.ts中进行引入

image.png

这个htmlDomApi其实就是对DOM API进行了一层封装,新增几个判断方法,这里就不细讲了。

// 暂时不支持SVG相关操作
export interface DOMAPI {
    // 创建元素节点
    createElement: (tagName: any) => HTMLElement
    // 创建文本节点
    createTextNode: (text: string | number) => Text
    // 创建注释节点
    createComment: (text: string | number) => Comment
    // 在指定的已有子节点之前插入新的子节点。
    insertBefore: (parentNode: Node, newNode: Node, referenceNode: Node | null) => void
    // 删除子节点
    removeChild: (node: Node, child: Node) => void
    // 添加子节点
    appendChild: (node: Node, child: Node) => void
    // 获取元素父节点
    parentNode: (node: Node) => Node | null
    // 获取元素紧跟的节点
    nextSibling: (node: Node) => Node | null
    // 获取元素标签名
    tagName: (elm: Element) => string
    // 设置元素文本内容
    setTextContent: (node: Node, text: string | null) => void
    // 获取元素文本内容
    getTextContent: (node: Node) => string | null
    // 判定是否为元素节点
    isElement: (node: Node) => node is Element
    // 判定是否为文本节点
    isText: (node: Node) => node is Text
    // 判定是否为注释节点
    isComment: (node: Node) => node is Comment
}

function createElement(tagName: any): HTMLElement {
    return document.createElement(tagName)
}

function createTextNode(text: string): Text {
    return document.createTextNode(text)
}

function createComment(text: string): Comment {
    return document.createComment(text)
}

function insertBefore(parentNode: Node, newNode: Node, referenceNode: Node | null): void {
    parentNode.insertBefore(newNode, referenceNode)
}

function removeChild(node: Node, child: Node): void {
    node.removeChild(child)
}

function appendChild(node: Node, child: Node): void {
    node.appendChild(child)
}

function parentNode(node: Node): Node | null {
    return node.parentNode
}

function nextSibling(node: Node): Node | null {
    return node.nextSibling
}

function tagName(elm: Element): string {
    return elm.tagName
}

function setTextContent(node: Node, text: string | null): void {
    node.textContent = text
}

function getTextContent(node: Node): string | null {
    return node.textContent
}

function isElement(node: Node): node is Element {
    return node.nodeType === 1
}

function isText(node: Node): node is Text {
    return node.nodeType === 3
}

function isComment(node: Node): node is Comment {
    return node.nodeType === 8
}

export const htmlDomApi: DOMAPI = {
    createElement,
    createTextNode,
    createComment,
    insertBefore,
    removeChild,
    appendChild,
    parentNode,
    nextSibling,
    tagName,
    setTextContent,
    getTextContent,
    isElement,
    isText,
    isComment,
}

其次定义一个变量cbs用于缓存各个模块中的生命周期函数。遍历cbs获取生命周期name,然后遍历modules获取模块中对应的生命周期,如果生命周期有值,将其加入cbs对应的属性中。

image.png

最后返回patch函数

image.png

所以 init 函数的作用非常简单,就是将操作 DOM API 的变量api和缓存模块生命周期的变量cbs变成闭包,这样每次每次调用 patch 函数时,我们都可以获取到这两个变量的值。

patch

接下来来看 patch 函数,它接受两个参数:旧VNode对象和新VNode对象。所以还需要引入vnode.ts对VNode对象的类型限制。

image.png

进入 patch 函数,首先定义了几个变量用于缓存dom节点和父dom节点

image.png

然后,判断旧VNode节点是否为VNode节点,当oldVnode不是VNode时,说明是初次加载,创建一个空白VNode。isVnode 函数和 emptyNodeAt 函数我们之后会详细讲。

image.png

接着,判断新旧VNode是否为相同VNode节点,如果是更新VNode的差异。如果不是,先获取VNode的dom以及其父dom节点,然后创建dom元素,最后当父节点不为空时,将新创建的dom插入dom树,删除旧VNode节点。sameVnode、patchVnode、createElm和removeVnodes方法我们之后会详细讲。

image.png

在 patch 函数最后会返回新VNode节点

image.png

isVnode

了解完 patch 函数的逻辑后,我们开始详细了解其中的方法。

先看 isVnode 函数,如果传入参数中有 sel 属性则认为它是VNode数据

image.png

emptyNodeAt

接下来,我们来看 emptyNodeAt 函数,它接受一个dom节点参数,进入函数,先获取dom节点的id并加上“ # ”作为 变量 id ,然后获取dom节点的className并加上“ . ”作为变量 class,最后将 dom的标签名、id 和 class拼接作为参数,调用 vnode 函数,返回其返回结果。vnode 函数的详情我们在上篇文章已经详细讲过了。

image.png

sameVnode

我们来看 sameVnode 函数,它的逻辑一样非常简单,如果2个VNode节点的key和sel属性都相同,我们就认为它们是相同的VNode节点。

image.png

patchVnode

然后,我们来看 patchVnode,它接受两个参数:旧VNode对象和新VNode对象。进入 patchVnode 函数,判断VNode的data属性是否存在,如果存在触发update钩子函数。

image.png

首先定义了几个变量分别缓存dom节点、旧VNode的children和新VNode的children。

image.png

最后判断新旧VNode节点是否完全相同,如果是直接返回。再判断VNode是否为文本节点,如果不是文本节点,判断新旧VNode是否同时有children,如果同时有children属性,且children属性不相同就通过 updateChildren 函数更新children。

image.png

isUndef && isDef

首先,定义NonUndefined类型

image.png image.png

updateChildren

OK,终于来到了 patch 函数的重点了,我们来看VNode中是如何更新子节点组的。updateChildren 函数接受3个参数:父dom节点、旧VNode的子节点组和新VNode的子节点组。

image.png

然后定义了几个变量,分别是:

image.png

我们来看 oldKeyToIdx 对应的 KeyToIndexMap 类型,就是一个 string 为 key,value 为number 的对象

image.png

接着,遍历新旧VNode的 children 数组,直到其中之一遍历完。

image.png

在遍历过程中,先判断oldStartVnode、oldEndVnode、newStartVnode、newEndVnode是否为 null,因为我们之后可能会对它们重新赋值,可能会出现等于null的情况,如果等于 null,则将对应的索引前移或后移。

image.png

接下来,判断旧头VNode和新头VNode是否为相同VNode节点,如果是,将新头VNode差异更新到旧头VNode上,同时将新旧头索引后移。

image.png

再判断旧尾VNode和新尾VNode是否为相同VNode节点,如果是,将新尾VNode差异更新到旧尾VNode上,同时将新旧尾索引前移。

image.png

然后再判断旧头VNode和新尾VNode是否为相同VNode节点,如果是,将新尾VNode差异更新到旧头VNode上,然后将旧头VNode插入到旧尾VNode后,最后将旧头索引后移,新尾索引前移。

image.png

然后再判断旧尾VNode和新头VNode是否为相同VNode节点,如果是,将新头VNode差异更新到旧尾VNode上,然后将旧尾VNode插入到旧头VNode前,最后将旧尾索引前移,新头索引后移。

image.png

如果都不是,则先判断有没有创建Key Map,如果没有则通过 createKeyToOldIdx 函数创建Key Map,用于根据key来更新VNode。createKeyToOldIdx 函数我们之后会了解到。

image.png

之后,根据Key Map获取当前新头VNode的对应的旧VNode的index

image.png

如果 idxInOld 是 undefined,说明旧VNode节点没有设置key属性,直接根据新头VNode创建dom元素,插入到旧头VNode对应dom之前。

image.png

否则说明设置了 key 属性,定义一个变量 elmToMove 缓存key对应的旧VNode节点。判断该旧VNode和新头VNode的 sel属性是否相同。如果sel属性不同,则直接创建新VNode对应的dom,插入到旧头VNode对应dom之前。如果sel属性相同,则将新VNode数据更新到旧VNode上,再删除旧children对应的VNode,代表已经更新过,最后创建VNode对应的dom,插入到旧头VNode对应dom之前。

image.png

在遍历的最后,将新头VNode设置为新children数组中新头索引后一位。

image.png

当循环完成后,当旧头索引小于等于旧尾索引,或新头索引大于等于新尾索引时,说明新旧children其中之一已经遍历完成。再判断旧头索引是否大于旧尾索引,如果是说明新children中新增了VNode节点,先获取新增VNode后一位的VNode对应的dom,再将新增VNode对应dom插入对应dom树位置。否则直接删除多余dom节点

image.png

createKeyToOldIdx

看完 updateChildren 函数,我们来看函数中用到的 createKeyToOldIdx 函数。

这个函数接受三个参数:子VNode节点组、开始索引和结束索引,返回一个KeyToIndexMap类型的数据(之前介绍过,一个key为字符串,value为数字的对象)。

进入函数,先定义一个变量map为空对象。循环从开始索引到结束索引,获取子VNode节点组中对应索引的VNode节点的key属性值,如果属性值不为undefined,将其保存在map中,返回map。

image.png

createElm

接着来看同样是在 updateChildren 函数中使用到的 createElm 函数,它是用来根据VNode对象创建真实dom的。它接受一个VNode对象作为参数,返回一个 dom节点

进入函数,首先定义了几个变量:

image.png

接着判断是否为注释节点,如果是,再判断VNode对象是否有text属性(文本内容),如果没有设置为空字符串,最后根据文本内容创建注释节点

image.png

如果不是注解节点,判断 sel属性是否为空。当 sel属性不为空时,先解析标签和选择器,并将它们缓存到变量中:

image.png

当" # "的位置小于" . "的位置时,说明存在id,给dom节点添加id。当" . "的索引大于0时,说明存在class,给dom节点添加class:

image.png

然后循环调用 cbs 中的create中的方法,即触发各个模块的create生命周期:

image.png

接下来,判断该子VNode节点中是否含有孙VNode节点组,如果有,遍历孙VNode节点组,创建孙vnode对应的DOM元素并追加到DOM树上:

image.png

如果该子VNode节点没有孙VNode节点组,判断是否包含文本节点,如果有,同样创建并追加到DOM树上:

image.png

如果子VNode节点没有 sel属性,说明是文本节点,创建文本节点

image.png

在 createElm 函数最后,返回创建好的dom节点

image.png

addVnodes

接着来看同样是在 updateChildren 函数中使用到的 addVnodes 函数,它是用来根据VNode对象创建真实dom并添加到父dom的dom树上。它接受5个参数:父dom节点、要插入位置之后的dom节点、子VNode节点组、开始索引和结束节点。

进入函数,首先根据开始索引和结束索引遍历子VNode节点组,创建对应VNode对象的dom节点,再将dom插入到指定的dom元素之前:

image.png

removeVnodes

接着来看同样是在 updateChildren 函数中使用到的 removeVnodes 函数,它是用来根据传入的索引来删除父 dom节点上对应的子节点组。它接受4个参数:父 dom节点、子VNode节点组、开始索引和结束索引。

进入函数,首先根据开始索引和结束索引对子VNode数组进行遍历,定义两个变量 rm 和 ch 分别缓存删除dom的方法和当前要删除的节点,然后判断如果 ch 是否存在。如果存在再判断是否为VNode节点。如果是,先通过 invokeDestroyHook 函数触发 modules 的destroy生命周期(之后会详细看到),再通过 createRmCb 函数创建删除 dom 节点的方法,调用该方法。如果不是VNode节点,直接删除。

image.png

invokeDestroyHook

下面我们来看在 removeVnodes 函数中调用的 invokeDestroyHook 函数,它的作用是触发 modules 的destroy生命周期。它接受一个VNode节点作为参数。

进入函数,它先设一个变量 data 来缓存VNode节点的data属性。然后判断当 data 不为空时,先遍历触发 cbs 中 destroy 中的函数,再判断VNode节点是否有子节点,如果有,且子节点不为字符串或数字,触发子节点VNode的destroy钩子函数。

image.png

createRmCb

下面我们来看同样在 removeVnodes 函数中调用的 createRmCb 函数,它的作用是创建删除子节点dom的方法。它接受一个dom节点作为参数,返回一个 rmCB 函数作为删除该dom节点的方法。

这里是创建一个闭包,对每一个需要删除dom节点形成单独的删除函数,方便之后调用。

image.png

完整代码

// 虚拟dom
import { vnode, VNode } from './vnode'

// 引入操作的dom api的相关方法
import { htmlDomApi, DOMAPI } from './htmldomapi'
// 类型判断
import * as is from './is'
// 导入模块
import modules from "./modules/index"

type NonUndefined<T> = T extends undefined ? never : T


type KeyToIndexMap = { [key: string]: number }

// 如果2个VNode节点的key和sel属性都相同,我们就认为它们是相同的VNode节点
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
    return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}

// 判断是否为Vnode
function isVnode(vnode: any): vnode is VNode {
    return vnode.sel !== undefined
}

// 根据children item的key属性,创建Key Map
function createKeyToOldIdx(children: VNode[], beginIdx: number, endIdx: number): KeyToIndexMap {
    const map: KeyToIndexMap = {}
    for (let i = beginIdx; i <= endIdx; ++i) {
        const key = children[i]?.key
        if (key !== undefined) {
            map[key] = i
        }
    }
    return map
}

// 创建一个空白VNode,用于createElm中触发create钩子函数时调用
const emptyNode = vnode('', {}, [], undefined, undefined)

export function init() {
    // 获取操作html dom的api
    const api: DOMAPI = htmlDomApi

    // 用于缓存各个模块的钩子函数
    const cbs = {
        create: [],
        update: [],
        destroy: [],
    }

    // 遍历cbs
    for (let key in cbs) {
        // 初始化为数组
        cbs[key] = []
        // 遍历moudules
        for (let module in modules) {
            // 获取模块中对应的生命周期
            const hook = modules[module][key]
            // 如果生命周期有值
            if (hook !== undefined) {
                // 将其加入cbs对应的属性中
                (cbs[key] as any[]).push(hook)
            }
        }
    }

    // 创建一个只有标签名和选择器的空白VNode
    function emptyNodeAt(elm: Element) {
        const id = elm.id ? '#' + elm.id : ''
        const c = elm.className ? '.' + elm.className.split(' ').join('.') : ''
        return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm)
    }


    // 判断是否为undefined
    function isUndef(s: any): boolean {
        return s === undefined
    }

    // 判断是否为defined
    function isDef<A>(s: A): s is NonUndefined<A> {
        return s !== undefined
    }

    // 根据VNode创建dom
    function createElm(vnode: VNode): Node {
        // 缓存循环children中的index
        let i: number
        // 缓存VNode的children
        const children = vnode.children
        // 缓存VNode的选择器
        const sel = vnode.sel
        // 把 vnode 转化成真实DOM对象(没有渲染到页面)
        if (sel === '!') {
            // 如果选择器是!,创建注释节点
            if (isUndef(vnode.text)) {
                vnode.text = ''
            }
            vnode.elm = api.createComment(vnode.text!)
        } else if (sel !== undefined) {
            // 如果选择器不为空,解析选择器
            // 缓存id
            const hashIdx = sel.indexOf('#')
            // 缓存class
            const dotIdx = sel.indexOf('.', hashIdx)
            // 缓存 # 的位置
            const hash = hashIdx > 0 ? hashIdx : sel.length
            // 缓存 . 的位置
            const dot = dotIdx > 0 ? dotIdx : sel.length
            // 缓存标签
            const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel
            // 创建dom
            const elm = vnode.elm = api.createElement(tag)
            // dom设置id
            if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
            // dom设置class
            if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '))
            // 触发各个模块的create钩子函数
            for (i = 0; i < cbs.create.length; ++i) {
                cbs.create[i](emptyNode, vnode)
            }
            // 如果vnode中有子节点,创建子vnode对应的DOM元素并追加到DOM树上
            if (is.array(children)) {
                for (i = 0; i < children.length; ++i) {
                    const ch = children[i]
                    if (ch != null) {
                        api.appendChild(elm, createElm(ch as VNode))
                    }
                }
            } else if (is.primitive(vnode.text)) {
                // 如果有子文本节点,也创建并追加到DOM树上
                api.appendChild(elm, api.createTextNode(vnode.text))
            }

        } else {
            // 如果选择器为空,创建文本节点
            vnode.elm = api.createTextNode(vnode.text!)
        }
        // 返回新创建的DOM
        return vnode.elm
    }

    // 根据VNode新增dom
    function addVnodes(
        parentElm: Node,
        before: Node | null,
        vnodes: VNode[],
        startIdx: number,
        endIdx: number,
    ) {
        // 遍历传入的VNode数组,创建对应的dom,再将dom插入到指定的dom元素之前
        for (; startIdx <= endIdx; ++startIdx) {
            const ch = vnodes[startIdx]
            if (ch != null) {
                api.insertBefore(parentElm, createElm(ch), before)
            }
        }
    }

    // 触发destroy钩子函数
    function invokeDestroyHook(vnode: VNode) {
        // 获取VNode的data属性
        const data = vnode.data
        // 当data不为空时
        if (data !== undefined) {
            // 触发各个模块的destroy钩子函数
            for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
            // 当VNode有子节点,且子节点不为字符串或数字,触发子节点VNode的destroy钩子函数
            if (vnode.children !== undefined) {
                for (let j = 0; j < vnode.children.length; ++j) {
                    const child = vnode.children[j]
                    if (child != null && typeof child !== 'string' && typeof child !== 'number') {
                        invokeDestroyHook(child)
                    }
                }
            }
        }
    }

    // 创建删除子节点dom的方法
    function createRmCb(childElm: Node) {
        return function rmCb() {
            // 这里是一个闭包将需要删除的dom节点缓存起来,方便之后调用rmCb方法删除
            const parent = api.parentNode(childElm) as Node
            api.removeChild(parent, childElm)
        }
    }

    // 删除VNode对应dom 
    function removeVnodes(parentElm: Node,
        vnodes: VNode[],
        startIdx: number,
        endIdx: number): void {
        for (; startIdx <= endIdx; ++startIdx) {
            // 用于缓存删除dom的方法
            let rm: () => void
            // 获取当前要删除节点
            const ch = vnodes[startIdx]
            if (ch != null) {
                if (isDef(ch.sel)) {
                    // 触发destroy钩子函数
                    invokeDestroyHook(ch)
                    /* 
                     * 在snabbdom中这里需要判断remove钩子函数是否全部调用,而我们terdom没有remove钩子函数,所以
                     * 不需要判断
                     */
                    rm = createRmCb(ch.elm!)
                    // 删除子节点
                    rm()
                } else {
                    // 删除文本节点
                    api.removeChild(parentElm, ch.elm!)
                }
            }
        }
    }

    // 更新children
    function updateChildren(parentElm: Node,
        oldCh: VNode[],
        newCh: VNode[],
    ) {
        // 旧VNode的children头索引
        let oldStartIdx = 0
        // 新VNode的children头索引
        let newStartIdx = 0
        // 旧VNode的children尾索引
        let oldEndIdx = oldCh.length - 1
        // 新VNode的children尾索引
        let newEndIdx = newCh.length - 1
        // 旧VNode的children的头VNode
        let oldStartVnode = oldCh[0]
        // 旧VNode的children的尾VNode
        let oldEndVnode = oldCh[oldEndIdx]
        // 新VNode的children的头VNode
        let newStartVnode = newCh[0]
        // 新VNode的children的尾VNode
        let newEndVnode = newCh[newEndIdx]
        // 旧VNode的children的Key Map
        let oldKeyToIdx: KeyToIndexMap | undefined
        // 用于缓存key相同的新旧VNode
        let idxInOld: number
        // 用于缓存将要移动的旧VNode
        let elmToMove: VNode
        // 用于缓存将要插入新增VNode的位置之前的VNode
        let before: any

        // 遍历新旧children数组,直到其中之一遍历完
        while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
            // 我们之后可能会对oldStartVnode、oldEndVnode、newStartVnode、newEndVnode重新赋值,可能会出现等于null的情况
            if (oldStartVnode == null) {
                oldStartVnode = oldCh[++oldStartIdx]
            } else if (oldEndVnode == null) {
                oldEndVnode = oldCh[--oldEndIdx]
            } else if (newStartVnode == null) {
                newStartVnode = newCh[++newStartIdx]
            } else if (newEndVnode == null) {
                newEndVnode = newCh[--newEndIdx]
            } else if (sameVnode(oldStartVnode, newStartVnode)) {
                // 当旧头VNode和新头VNode是相同VNode时,将新头VNode差异更新到旧头VNode上,同时将新旧头索引后移
                patchVnode(oldStartVnode, newStartVnode)
                oldStartVnode = oldCh[++oldStartIdx]
                newStartVnode = newCh[++newStartIdx]
            } else if (sameVnode(oldEndVnode, newEndVnode)) {
                // 当旧尾VNode和新尾VNode是相同VNode时,将新尾VNode差异更新到旧尾VNode上,同时将新旧尾索引前移
                patchVnode(oldEndVnode, newEndVnode)
                oldEndVnode = oldCh[--oldEndIdx]
                newEndVnode = newCh[--newEndIdx]
            } else if (sameVnode(oldStartVnode, newEndVnode)) {
                /** 
                 * 当旧头VNode和新尾VNode是相同节点时,将新尾VNode差异更新到旧头VNode上,然后将旧头VNode插入到旧尾VNode后
                 * 最后将旧头索引后移,新尾索引前移
                 */
                patchVnode(oldStartVnode, newEndVnode)
                api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))
                oldStartVnode = oldCh[++oldStartIdx]
                newEndVnode = newCh[--newEndIdx]
            } else if (sameVnode(oldEndVnode, newStartVnode)) {
                /** 
                 * 当旧尾VNode和新头VNode是相同节点时,将新头VNode差异更新到旧尾VNode上,然后将旧尾VNode插入到旧头VNode前
                 * 最后将旧尾索引前移,新头索引后移
                 */
                patchVnode(oldEndVnode, newStartVnode)
                api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
                oldEndVnode = oldCh[--oldEndIdx]
                newStartVnode = newCh[++newStartIdx]
            } else {
                // 如果没有Key Map则创建Key Map,用于根据key来更新VNode
                if (oldKeyToIdx === undefined) {
                    oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
                }
                // 根据Key Map获取当前新头VNode的对应的旧VNode的index
                idxInOld = oldKeyToIdx[newStartVnode.key as string]
                
                if (isUndef(idxInOld)) {
                    // 如果没有设置key属性,直接根据新头VNode创建dom元素,插入到旧头VNode对应dom之前
                    api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm!)
                } else {
                    // 如果设置key属性,获取到key对应的旧VNode
                    elmToMove = oldCh[idxInOld]
                    if (elmToMove.sel !== newStartVnode.sel) {
                        // 如果旧VNode和新VNode的sel属性不同,则直接创建新VNode对应的dom,插入到旧头VNode对应dom之前
                        api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm!)
                    } else {
                        // 如果sel属性相同,则将新VNode数据更新到旧VNode上
                        patchVnode(elmToMove, newStartVnode)
                        // 删除旧children对应的VNode,代表已经更新过
                        oldCh[idxInOld] = undefined as any
                        // 创建VNode对应的dom,插入到旧头VNode对应dom之前
                        api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
                    }
                }
                // 将新头VNode设置为新children数组中新头索引后一位
                newStartVnode = newCh[++newStartIdx]
            }
        }
        // 循环完成后,当旧头索引小于等于旧尾索引,或新头索引大于等于新尾索引时,说明新旧children其中之一已经遍历完成
        if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
            // 如果存在旧头索引大于旧尾索引情况,说明新children中新增了VNode
            if (oldStartIdx > oldEndIdx) {
                // 获取新增VNode后一位的VNode对应的dom
                before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
                // 将新增VNode对应dom插入对应dom树位置
                addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
            } else {
                // 删除多余dom节点
                removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
            }
        }
    }

    // 对比新旧vnode差异,将差异更新到旧VNode上。
    function patchVnode(oldVnode: VNode, vnode: VNode) {
        // 当VNode的data属性存在时,触发update钩子函数 
        if (vnode.data !== undefined) {
            for (let i = 0; i < cbs.update.length; ++i) {
                cbs.update[i](oldVnode, vnode)
            }
        }

        // 由于是相同的VNode节点,使用新旧VNode的真实dom元素相同
        const elm = vnode.elm = oldVnode.elm!
        // 获取旧VNode的children
        const oldCh = oldVnode.children as VNode[]
        // 获取新VNode的children
        const ch = vnode.children as VNode[]

        // 如果新旧节点完全相同,直接返回
        if (oldVnode === vnode) return
        // 判断VNode是否为文本节点
        if (isUndef(vnode.text)) {
            // 如果不是文本节点,判断新旧VNode是否同时有children
            if (isDef(oldCh) && isDef(ch)) {
                // 如果同时有children属性,且children属性不相同就更新children
                if (oldCh !== ch) {
                    updateChildren(elm, oldCh, ch)
                }
            }
        }
    }

    return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {

        // 缓存dom节点和父dom节点
        let elm: Node, parent: Node

        // 当oldVnode不是VNode时,说明是初次加载,创建一个空白VNode
        if (!isVnode(oldVnode)) {
            oldVnode = emptyNodeAt(oldVnode)
        }

        // 判断是否为相同VNode节点
        if (sameVnode(oldVnode, vnode)) {
            // 更新VNode节点差异
            patchVnode(oldVnode, vnode)
        } else {
            // 获取旧VNode的dom
            elm = oldVnode.elm!
            // 获取父节点dom
            parent = api.parentNode(elm) as Node
            // 创建dom元素
            createElm(vnode)
            // 当父节点不为空时
            if (parent !== null) {
                // 将新创建的dom插入dom树
                api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
                // 删除旧VNode节点
                removeVnodes(parent, [oldVnode], 0, 0)
            }
        }
        return vnode
    }
}

终于,我们看完了 patch 中所有函数,但这样就万事大吉了吗?

ts-transform-js-extension.cjs


我们终于完成了 Terdom 所有代码的编写,下面让我们打个包体验一下自己写得虚拟DOM库吧。

npm run build

在案例中引入 build 文件夹下的导出的 init 函数和 h 函数

import { init } from '../../build/package/init.js'
import { h } from '../../build/package/h.js'

// 2. 注册模块
let patch = init()

// 3. 使用h()函数的第二个参数传入模块需要的数据(对象)
let content = h('div', [
    h('h1', '欢迎使用Terdom'),
    h('p', '掘金博文:'),
    h('a', {
        props: {
            href: "https://juejin.cn/user/1011206430663511/posts"
        }
    }, 'https://juejin.cn/user/1011206430663511/posts'),
    h('p', '源码地址:'),
    h('a', {
        props: {
            href: "https://github.com/zhtzhtx/Terdom"
        }
    }, 'https://github.com/zhtzhtx?tab=repositories')
])

let vnode = h('div', {
    style: {
        width: "100vw",
        height: "100vh",
        display: "flex",
        "justify-content": "center",
        "align-items": "center"
    }
}, [content])

let app = document.querySelector('#app')

patch(app, vnode)

但是,等我们加载页面发现出现了报错:

image.png

这是为什么呢?我们打开build文件夹下的 init.js,我们发现一个问题,那就是引入的其它模块文件路径没有加后缀名

image.png

这是由于TypeScript编译后,并不会帮你在路径后加上后缀名。也就是说,你自己要把所有的引入路径加上“ .js ”。这样非常麻烦,而且我们编写的是" .ts "文件,而路径却要加上“ .js ”,这也不符合编程习惯。

那么怎么解决呢?我们可以看一下Snabbdom的解决方法,他自己编写了一个插件 ts-transform-js-extension.cjs ,可以帮我们自动在引入路径后加上“ .js ”,具体逻辑这里就不深入研究了,代码如下:

const ts = require('typescript')

module.exports.transform = (ctx) => (sf) => ts.visitNode(sf, (node) => {
  const visitor = (node) => {
    const originalPath = (
      ts.isImportDeclaration(node) ||
      ts.isExportDeclaration(node)) &&
      node.moduleSpecifier
      ? node.moduleSpecifier.getText(sf).slice(1, -1)
      : (
        ts.isImportTypeNode(node) &&
        ts.isLiteralTypeNode(node.argument) &&
        ts.isStringLiteral(node.argument.literal)
      )
        ? node.argument.literal.text
        : null

    if (originalPath === null) return node
    const pathWithExtension = originalPath.endsWith('.js')
      ? originalPath
      : originalPath + '.js'
    const newNode = ts.getMutableClone(node)
    if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
      newNode.moduleSpecifier = ts.createLiteral(pathWithExtension)
    } else if (ts.isImportTypeNode(node)) {
      newNode.argument = ts.createLiteralTypeNode(pathWithExtension)
    }
    return newNode
  }
  return ts.visitEachChild(node, visitor, ctx)
})

然后在 tsconfig.json 中进行了设置:

image.png

我们也学着一样进行操作,然后重新打包一次。我们会发现引入路径之后依然没有加上“ .js ”,也就是说插件并没有运行,这是为什么呢?

查阅资料后,发现TypeScript本身还没有支持插件功能,而如果我们想要使用TypeScript插件,必须安装 ttypescript

npm install ttypescript -D

然后,将 package.json 中的 build 命令改写成“ ttsc --build src/tsconfig.json ”

image.png

再次运行“ npm run build ”,刷新页面发现已经可以正常显示了

image.png

结语


都看到这里了,如果觉得文章还不错的,请给个赞吧!

码字不易,求求了!!!