Virtual Dom的简单实现及其功能点描述

15 篇文章 0 订阅
1 篇文章 0 订阅

simple-dom

The simple Virtual Dom which includes vnode & h & patch and so on.

Author:zwf193071

E-mail: 997131679@qq.com

date: 2020/08/13

Preface

How can I learn to write the Virtual Dom by myself? I am a green-hand, not having much experience, even the simplest Virtual Dom can puzzle me…
Do you have the same questions above? If you do, please just look at this simple-dom, I will explain anything in detail during the process when I learn to write the Virtual Dom.

Documentation

我们如何开始学习一个npm包,很简单,从它的test测试包出发。看测试文件里测试哪些功能,我们便可以开始从最简单的点写起。
以下是我在实际开发中(仿照snabbdom)所搜集到的知识点,希望对大家有所帮助。

ttypescript

Currently TypeScript doesn't support custom transformers in the tsconfig.json, but supports it programmatically.

And there is no way to compile your files using custom transformers using tsc command.

TTypescript (Transformer TypeScript) solves this problem by patching on the fly the compile module to use transformers from tsconfig.json.

这是ttypescript出来的初衷,为了方便根据配置文件进行自定义编译。package.json内有一条compile脚本,执行npm run compile会运行ttsc命令,再根据src/test/tsconfig.json以及src/package/tsconfig.json里的配置文件信息将编译之后的代码输出到build文件夹下

完成编译后,即可运行npm run unit命令,开始愉快的码转之旅啦~~~

h

h函数,采用重载方式,对其实现进行定义,见下面代码:

export function h (sel: string): VNode
export function h (sel: string, data: VNodeData | null): VNode
export function h (sel: string, children: VNodeChildren): VNode
export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h (sel: any, b?: any, c?: any): VNode {
  ...
  return vnode(sel, data, children, text, undefined)
}

h函数有三个参数,分别是sel(一般是html标签,但也可能为"svg.“或"svg#”),b,c

  • b参数:可选,类型为VNodeData或VNodeChildren或null
  • c参数:可选,类型为VNodeChildren

h函数的返回值,为vnode函数,而vnode函数的定义及返回值如下所示:

export function vnode(sel: string | undefined,
  data: any | undefined,
  children: Array<VNode | string> | undefined,
  text: string | undefined,
  elm: Element | Text | undefined): VNode {
  const key = data === undefined ? undefined : data.key
  return { sel, data, children, text, elm, key }
}

vnode函数有5个参数,皆为必填项。我在编写代码的过程中,先编写unit下core.ts的测试代码。

  1. 测试h创建的vnode是否有合适的tag
assert.strictEqual(h('div').sel, 'div')
assert.strictEqual(h('a').sel, 'a')
  1. 测试h创建的vnode是否有children
var vnode = h('div', [h('span#hello'), h('b.world')])
assert.strictEqual(vnode.sel, 'div')
const children = vnode.children as [VNode, VNode]
assert.strictEqual(children[0].sel, 'span#hello')
assert.strictEqual(children[1].sel, 'b.world')

为了方便大家理解h,下面有一段完整功能的简单代码

	   function Sdom(tag, data, children) {
           this.tag = tag;
           this.data = data;
           this.children = children
       }
       Sdom.prototype.render = function() {
           const el = document.createElement(this.tag);
           for (let attr in this.data) {
               el.setAttribute(attr, this.data[attr]);
           }
           this.children.forEach(child => {
               let childEl = child instanceof Sdom ? child.render() : document.createTextNode(child);
               el.appendChild(childEl);
           });
           return el;
       }
       function el(tagName, attrs, children) {
           return new Sdom(tagName, attrs, children)
       }
       let ul = el('ul', { id: 'list' }, [
           el('li', { class: 'item' }, ['Item 1']),
           el('li', { class: 'item' }, ['Item 2']),
           el('li', { class: 'item' }, ['Item 3'])
       ])
       let ulRoot = ul.render()
       document.body.appendChild(ulRoot);

Sdom从某种意义上来讲,便是上面的h,上面的代码会在浏览器界面生成ul标签,如下图所示:
在这里插入图片描述

h函数易于理解,这里我便不再赘述了,在Virtual Dom里,patch才是重点,因其实现了diff算法,下面会根据测试例子一一剖析源码。

patch

新旧节点是否有一样的tagName

通过测试案例,为大家一步一步讲解源码原理

test/unit文件夹下的core.ts里新增以下代码:

import { init } from '../../package/init'
var patch = init()

describe('simpledom', function () {
    var elm: any, vnode0: any
    // 在每个测试前,创建一个DOM节点div
    beforeEach(function () {
        elm = document.createElement('div')
        vnode0 = elm
    })
	describe('created element', function () {
        it('has tag', function () {
            elm = patch(vnode0, h('div')).elm
            assert.strictEqual(elm.tagName, 'DIV')
        });
	 });
});  

patch函数的作用是,比较新旧vnode节点的差异,对新vnode做处理后,返回新vnode

为了实现测试代码的功能,首先,我们需要在src/package目录下新建init.ts文件

import { htmlDomApi, DOMAPI } from './htmldomapi'

function isUndef(s: any): boolean {
    return s === undefined
}
// DOMAPI是document的一些创建插入节点等操作
export function init(domApi?: DOMAPI) {
	const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi
	return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
		let i: number, elm: Node, parent: Node
        if (!isVnode(oldVnode)) {
            oldVnode = emptyNodeAt(oldVnode)
        }
        if (sameVnode(oldVnode, vnode)) {
            patchVnode(oldVnode, vnode)
        }
		return vnode;
	}
}

isVnode(oldVnode)判断旧节点是否为vnode节点,若不是,便创建一个空vnode,isVnodeemptyNodeAt代码如下所示:

function isVnode(vnode: any): vnode is VNode {
    return vnode.sel !== undefined
}
// Element在这里指创建的dom元素
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)
}

将老节点即测试前创建的div转变为vnode后,将其与新的vnode比较

function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
    return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}

由于新旧节点的key皆没有定义,为undefinedsel值皆为div,故上面等式相等

为实现patch后返回的节点有elm属性,需在patchVnode里进行下一步操作

function patchVnode(oldVnode: VNode, vnode: VNode) {
	// 将老节点的elm属性赋值给新节点vnode里的elm属性,并保存该值,供后续代码使用
	const elm = vnode.elm = oldVnode.elm!
}

实现assert.strictEqual(elm.tagName, 'DIV')这个功能,patchVnode里只需上面这一行代码即可

注意:tagName是dom元素的属性

新旧节点是否有不同的tag和id

测试代码如下:

it('has different tag and id', function () {
	var elm = document.createElement('div')
    vnode0.appendChild(elm)
    var vnode1 = h('span#id')
    const patched = patch(elm, vnode1).elm as HTMLSpanElement
    assert.strictEqual(patched.tagName, 'SPAN')
    assert.strictEqual(patched.id, 'id')
});

vnode0下新增一个子节点div,vnode1为h('span#id'),很明显,vnode0vnode1不是相同的vnode节点,如此,我们便需要修改patch函数

	return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
        let i: number, elm: Node, parent: Node
        if (!isVnode(oldVnode)) {
            oldVnode = emptyNodeAt(oldVnode)
        }
        if (sameVnode(oldVnode, vnode)) {
            patchVnode(oldVnode, vnode)
        } else {
            elm = oldVnode.elm!
            parent = api.parentNode(elm) as Node
            
            createElm(vnode)

            if (parent !== null) {
                api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
                removeVnodes(parent, [oldVnode], 0, 0)
            }
        }
        return vnode;
    }

如上图所示,此时我们需要获取到oldVnode.elm,即document.createElement('div'),通过api.parentNode(elm)获取到parent节点,即vnode0,将其存为变量。
createElm(vnode)这段代码主要是根据h('span#id')生成vnode节点的elm属性,即dom属性,如下所示:

	function createElm(vnode: VNode): Node {
        let i: any
        let data = vnode.data
        const sel = vnode.sel
        // sel为'span#id'
        if (sel !== undefined) {
            // Parse selector
            const hashIdx = sel.indexOf('#')
            const hash = hashIdx > 0 ? hashIdx : sel.length
            const tag = hashIdx !== -1 ? sel.slice(0, hash) : sel //tag为span
            // 由于data.ns(namespace)为undefined,执行api.createElement(tag),生成span元素
            const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
                ? api.createElementNS(i, tag)
                : api.createElement(tag)
            elm.setAttribute('id', sel.slice(hash + 1, sel.length))//为span元素添加属性id
        } else {
            vnode.elm = api.createTextNode(vnode.text!)
        }
        return vnode.elm
    }

之后进行下一步,在oldVnode.elm的兄妹节点(null)前插入vnode.elmremoveVnodes是移除parent里面的旧节点,这两步操作之后,parent变为<div><span id="id"></span></div>,再patch函数返回新vnode即可。

新节点的子元素是否有id

测试代码如下:

		it('has id', function () {
            elm = patch(vnode0, h('div', [h('div#unique')])).elm
            assert.strictEqual(elm.firstChild.id, 'unique')//firstChild为dom元素属性
        });

很明显,新旧节点为同一vnode节点,我们需修改patchVnode函数

	function patchVnode(oldVnode: VNode, vnode: VNode) {
        const elm = vnode.elm = oldVnode.elm!
        const ch = vnode.children as VNode[]
        // 新节点没有text
        if (isUndef(vnode.text)) {
        	// 新节点有children
            if (isDef(ch)) {
                addVnodes(elm, null, ch, 0, ch.length - 1)
            }
        }
    }

addVnodesdiv里插入vnode.children元素,代码如下:

	function addVnodes(
        parentElm: Node,
        before: Node | null,
        vnodes: VNode[],
        startIdx: number,
        endIdx: number
    ) {
        for (; startIdx <= endIdx; ++startIdx) {
            const ch = vnodes[startIdx]
            if (ch != null) {
                api.insertBefore(parentElm, createElm(ch), before)
            }
        }
    }
是否有namespace

以下是测试代码

		it('has correct namespace', function () {
            var SVGNamespace = 'http://www.w3.org/2000/svg'
            var XHTMLNamespace = 'http://www.w3.org/1999/xhtml'
			
            elm = patch(vnode0, h('div', [h('div', { ns: SVGNamespace })])).elm
            assert.strictEqual(elm.firstChild.namespaceURI, SVGNamespace)

            // verify that svg tag automatically gets svg namespace
            elm = patch(vnode0, h('svg', [
                h('foreignObject', [
                    h('div', ['I am HTML embedded in SVG'])
                ])
            ])).elm
            assert.strictEqual(elm.namespaceURI, SVGNamespace)
            assert.strictEqual(elm.firstChild.namespaceURI, SVGNamespace)
            assert.strictEqual(elm.firstChild.firstChild.namespaceURI, XHTMLNamespace)

            // verify that svg tag with extra selectors gets svg namespace
            elm = patch(vnode0, h('svg#some-id')).elm
            assert.strictEqual(elm.namespaceURI, SVGNamespace)

            // verify that non-svg tag beginning with 'svg' does NOT get namespace
            elm = patch(vnode0, h('svg-custom-el')).elm
            assert.notStrictEqual(elm.namespaceURI, SVGNamespace)
        })

h('div', { ns: SVGNamespace }是对export function h(sel: string, data: VNodeData | null): VNode的延伸扩展,data即vnode的各个属性集。

测试代码添加了命名空间,svg的各种校验,我们需要在之前的h代码内,添加以下功能:

export function h(sel: any, b?: any, c?: any): VNode {
    ...
    if (children !== undefined) {
        for (i = 0; i < children.length; ++i) {
        	// 若children为原始数据类型,比如测试代码中的'I am HTML embedded in SVG',将其转为vnode
            if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined)
        }
    }
    // sel为svg且长度必须为3,第4个元素为'.'或者'#'
    if (
        sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
        (sel.length === 3 || sel[3] === '.' || sel[3] === '#')
    ) {
        addNS(data, children, sel)
    }
    return vnode(sel, data, children, text, undefined)
}

addNS添加标签名的namespace,代码如下

export type VNodes = VNode[]
function addNS(data: any, children: VNodes | undefined, sel: string | undefined): void {
    data.ns = 'http://www.w3.org/2000/svg'
    if (sel !== 'foreignObject' && children !== undefined) {
        for (let i = 0; i < children.length; ++i) {
            const childData = children[i].data
            if (childData !== undefined) {
                addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel)
            }
        }
    }
}

init.ts文件内,在patchVnode方法里添加以下代码:

	function patchVnode(oldVnode: VNode, vnode: VNode) {
       	...
        if (isUndef(vnode.text)) {
            ...
        } else if (oldVnode.text !== vnode.text) { // 若vnode有text属性,即'I am HTML embedded in SVG',将text内容设置到当前的elm节点上
            api.setTextContent(elm, vnode.text!)
        }
    }

即可通过本轮测试

class property定义的多个classes

测试代码:

		it('receives classes in class property', function () {
            elm = patch(vnode0, h('i', { class: { am: true, a: true, class: true, not: false } })).elm
            assert(elm.classList.contains('am'))
            assert(elm.classList.contains('a'))
            assert(elm.classList.contains('class'))
            assert(!elm.classList.contains('not'))
        })

h的第二个参数,为data对象属性集,此时我们需要在init.ts里添加以下代码:

const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']
export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
	let i: number
    let j: number
    const cbs: ModuleHooks = {
        create: [],
        update: [],
        remove: [],
        destroy: [],
        pre: [],
        post: []
    }
    const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi

    for (i = 0; i < hooks.length; ++i) {
        cbs[hooks[i]] = []
        for (j = 0; j < modules.length; ++j) {
            const hook = modules[j][hooks[i]]
            if (hook !== undefined) {
                (cbs[hooks[i]] as any[]).push(hook) // 将各个模块的钩子函数存储到cbs内
            }
        }
    }
}

由于h创建的是i标签,故我们需要在createElm方法内加上一段代码

function createElm(vnode: VNode): Node {
	...
	for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode) // 执行钩子内的create方法
	if (is.array(children)) {
		...
	}
	
}

测试代码,需在init方法内传入classModule模块,如下

import { classModule } from '../../package/modules/class'
var patch = init([
    classModule
])

package/modules/class内的代码如下所示:

import { VNode, VNodeData } from '../vnode'
import { Module } from './module'

export type Classes = Record<string, boolean>

function updateClass(oldVnode: VNode, vnode: VNode): void {
    var cur: any
    var name: string
    var elm: Element = vnode.elm as Element
    var oldClass = (oldVnode.data as VNodeData).class
    var klass = (vnode.data as VNodeData).class

    if (!oldClass && !klass) return
    if (oldClass === klass) return
    oldClass = oldClass || {}
    klass = klass || {}

    for (name in oldClass) {
        if (
            oldClass[name] &&
            !Object.prototype.hasOwnProperty.call(klass, name)
        ) {
            // was `true` and now not provided
            elm.classList.remove(name)
        }
    }
    for (name in klass) {
        cur = klass[name]
        if (cur !== oldClass[name]) {
            (elm.classList as any)[cur ? 'add' : 'remove'](name)
        }
    }
}

export const classModule: Module = { create: updateClass, update: updateClass }

暴露出了两个钩子函数createupdate,我们上面的测试代码测试的是create功能,若将测试代码稍作修改,如下所示:

	it('receives classes in class property', function () {
            elm = patch(vnode0, h('div', { class: { am: true, a: true, class: true, not: false } })).elm
    })

新旧节点都为div,此时需在patchVnode方法内添加一段代码

	function patchVnode(oldVnode: VNode, vnode: VNode) {
		...
		if (vnode.data !== undefined) {
           	for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)//调用模块内的update方法
        }
        if (isUndef(vnode.text)) {
        	...
        }
        ...
    }
updateChildren(diff算法的最最最核心功能)
是否移除根节点下的老children节点

测试代码如下所示

it('can remove previous children of the root element', function () {
    var h2 = document.createElement('h2')
    h2.textContent = 'Hello'
    var prevElm = document.createElement('div')
    prevElm.id = 'id'
    prevElm.className = 'class'
    prevElm.appendChild(h2)
    var nextVNode = h('div#id.class', [h('span', 'Hi')])
    elm = patch(toVNode(prevElm), nextVNode).elm
    assert.strictEqual(elm, prevElm)
    assert.strictEqual(elm.tagName, 'DIV')
    assert.strictEqual(elm.id, 'id')
    assert.strictEqual(elm.className, 'class')
    assert.strictEqual(elm.childNodes.length, 1)
    assert.strictEqual(elm.childNodes[0].tagName, 'SPAN')
    assert.strictEqual(elm.childNodes[0].textContent, 'Hi')
})

该测试代码所测得功能是,把<div id="id" class="class"><h2>Hello</h2></div>toVNode方法转成Vnode节点,接着与nextVNode进行patch比较。

patch(toVNode(prevElm), nextVNode).elm这段代码生成的便是<div id="id" class="class"><span>Hi</span></div>

根据上述代码分析,我们应修改patchVnode方法,新增代码如下

function patchVnode(oldVnode: VNode, vnode: VNode) {
    ...
    if (isUndef(vnode.text)) {
        if (isDef(oldCh) && isDef(ch)) {
            if (oldCh !== ch) updateChildren(elm, oldCh, ch)
        }...
    }
    ...
    
}

updateChildren代码如下所示:

    function updateChildren(parentElm: Node,
        oldCh: VNode[],
        newCh: VNode[]) {
        let oldStartIdx = 0
        let newStartIdx = 0
        let oldEndIdx = oldCh.length - 1
        let oldStartVnode = oldCh[0]
        let oldEndVnode = oldCh[oldEndIdx]
        let newEndIdx = newCh.length - 1
        let newStartVnode = newCh[0]
        let newEndVnode = newCh[newEndIdx]
        let oldKeyToIdx: KeyToIndexMap | undefined
        let idxInOld: number
        let elmToMove: VNode
        let before: any

        while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
            if (oldStartVnode == null) {
                oldStartVnode = oldCh[++oldStartIdx] // Vnode might have been moved left
            } 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)) {
                patchVnode(oldStartVnode, newStartVnode)
                oldStartVnode = oldCh[++oldStartIdx]
                newStartVnode = newCh[++newStartIdx]
            } else if (sameVnode(oldEndVnode, newEndVnode)) {
                patchVnode(oldEndVnode, newEndVnode)
                oldEndVnode = oldCh[--oldEndIdx]
                newEndVnode = newCh[--newEndIdx]
            } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
                patchVnode(oldStartVnode, newEndVnode)
                api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))
                oldStartVnode = oldCh[++oldStartIdx]
                newEndVnode = newCh[--newEndIdx]
            } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
                patchVnode(oldEndVnode, newStartVnode)
                api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
                oldEndVnode = oldCh[--oldEndIdx]
                newStartVnode = newCh[++newStartIdx]
            } else {
                if (oldKeyToIdx === undefined) {
                    oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
                }
                idxInOld = oldKeyToIdx[newStartVnode.key as string]
                if (isUndef(idxInOld)) { // 若新节点没有key,则直接根据新节点生成dom元素,并插入到老节点原第一节点的前面
                    api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm!)
                } else {
                    elmToMove = oldCh[idxInOld]
                    if (elmToMove.sel !== newStartVnode.sel) { // 新节点若不在老节点队伍里面,则插入到老节点原第一节点的前面
                        api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm!)
                    } else {
                        // 新节点若在老节点队伍里面,则进行patchNode操作,并将该节点插入到老节点原第一节点的前面
                        patchVnode(elmToMove, newStartVnode)
                        oldCh[idxInOld] = undefined as any
                        api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
                    }
                }
                newStartVnode = newCh[++newStartIdx]
            }
        }
        if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
            if (oldStartIdx > oldEndIdx) {
                before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
                addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
            } else {
                removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
            }
        }
    }

该方法有一篇博客分析的很好,感兴趣的同学可以点击查看进一步明白原理。

是否移除text元素

测试代码如下:

it('can remove text elements', function () {
    var h2 = document.createElement('h2')
    h2.textContent = 'Hello'
    var prevElm = document.createElement('div')
    prevElm.id = 'id'
    prevElm.className = 'class'
    var text = document.createTextNode('Foobar')
    prevElm.appendChild(text)
    prevElm.appendChild(h2)
    var nextVNode = h('div#id.class', [h('h2', 'Hello')])
    elm = patch(toVNode(prevElm), nextVNode).elm
    assert.strictEqual(elm, prevElm)
    assert.strictEqual(elm.tagName, 'DIV')
    assert.strictEqual(elm.id, 'id')
    assert.strictEqual(elm.className, 'class')
    assert.strictEqual(elm.childNodes.length, 1)
    assert.strictEqual(elm.childNodes[0].nodeType, 1)
    assert.strictEqual(elm.childNodes[0].textContent, 'Hello')
})

需要在原removeVnodes方法里添加一段代码

function removeVnodes(parentElm: Node,
    vnodes: VNode[],
    startIdx: number,
    endIdx: number): void {
        ...
        if (isDef(ch.sel)) {
            ...
        } else { // Text node
            api.removeChild(parentElm, ch.elm!) // text元素没有sel标签(标签便是div,span等)
        }
}

个人项目地址

Thanks to

License

This repo is released under the MIT.

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值