Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

了不起的Virtual DOM(二): 使用TypeScript开发简易Virtual DOM库 #43

Open
MrErHu opened this issue Jul 28, 2019 · 0 comments
Open

Comments

@MrErHu
Copy link
Owner

MrErHu commented Jul 28, 2019

前言

  首先欢迎大家关注、点赞、收藏我的掘金账号和Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。之前的文章我们介绍了MV*框架的历史以及React引入Virtual DOM所带来的新的解决思路,俗话说,百闻不如一见,百见不如一干。这篇文章我们将尝试使用去实现一个Virtual DOM的最小化实现方案,因为最近刚学了TypeScript,正好拿来练手。源码地址将在文章最后附录。
  

回顾

  无论是MVC模式还是后来改进的MVP模式以及目前更为常见的MVVM模式,其目的都是为了解决Model层和View如何连接,通过采用各种中间层(Controller、Presenter、View of Model)协调View与Model的关系。但是React所倡导的Virtual DOM方案却剑走偏锋,即每次Model层的变化都会重新渲染View层,那么作为开发者而言,只需要处理好数据和视图映射,从而将我们的关注重点集中于数据和数据流的变化,从而极大的降低开发关注度。

  实际上我们都知道浏览器对DOM的操作所带来的渲染重绘相比于JavaScript计算速度肯定是慢上好几个数量级的。假设仅仅只是页面中一个数据的变化就重绘整个页面,那肯定是我们所不能接受的。借鉴计算机学科中最常用的Cache思想,我们在低速的DOM操作和高速的JavaScript执行之间引入了Virtual DOM,通过对比两个Virtual DOM节点的变化,找出其中的变化,从而精准地修改DOM节点,在实现思路的同时尽可能地降低操作代价,达到良好的性能体验。

  众所周知,把大象装到冰箱需要三步,那么实现一个Virtual DOM库需要几步呢?

  上图就是我们要实现Virtual DOM的基本流程:

  • 创建Virtual DOM节点
  • 渲染Virtual DOM树
  • Diff算法比较两个Virtual DOM树,得到结果Patch
  • 应用Patch,更新DOM树

  上面的四个步骤也就基本对应着我们所要实现Virtual DOM的四个函数:

  • createElement
  • render
  • diff
  • applyDiff

  乍一看想要实现Virtual DOM库可能感觉颇有难度,但是经过仔细的分析,其实将问题转化成实现四个特定功能的函数。其实这种思维方式在我们软件开发中还是非常的实用的,当目标过大而无从下手时,要学会将目标合理拆分。React所倡导的前端组件化其实就包含这个思想,组件化最重要的两个特点就是:复用和分治,我们往往过于强调复用的特性。其实相比复用,分治才是组件化的精髓,我们通过划分组件,往往使得特定组件仅具有相对较为简单的职责功能,然后通过组合简单的组件成为复杂的功能。相比而言,维护功能职责简单的组件更为容易,也不容易出错。接下来我们要做的就是一步步实现各个函数功能,最终实现一个简单的Virtural DOM库。

创建Virtual DOM节点

  在此之前,我们首先简要介绍JSX的作用,由React发扬光大的JSX语法使得我们更为方便的在JavaScript中创建HTML,描述UI界面。JSX语法并不是某个库所独有的,而是一种JavaScript函数调用的语法糖,JSX其实相当于JavaScript + HTML(也被称为hyperscript,即hyper + script,hyper是HyperText超文本的简写,而script是JavaScript的简写)。在React中,JSX语法都会转化成React.createElement调用,而在Preact中,JSX语法则会被转成preact.h函数调用。

例如在React中:

<ul>
    <li>列表1</li>
    <li>列表2</li>
    <li>列表3</li>
</ul>

则会被转化为:

React.createElement(
    'ul',
    null,
    React.createElement('li', null, '列表1'),
    React.createElement('li', null, '列表2'),
    React.createElement('li', null, '列表3')
);

  其中createElement的参数依次是元素类型、属性、以及子元素。类型元素可以分为三种,依次是:字符串、函数、类,依次对应于HTML固有元素、无状态函数组件 (SFC)、类组件。本篇文章重点只在于阐释Virtual DOM基本原理,因此简单起见,我们仅支持HTML固有元素,暂不支持无状态函数组件 (SFC)和类组件。

  JSX可以根据使用的框架编译成不同的函数调用,例如React的React.createElement或者Preact的h,我们可以通过在JSX上添加编译注释(Pragma)来局部改变,例如:

/** @jsx h */
let dom = <div id="foo">Hello!</div>;

  通过为JSX添加注释@jsx(这也被成为Pragma,即编译注释),可以使得Babel在转化JSX代码时,将其装换成函数h的调用。当然,也可以在工程全局进行配置,比如我们可以在Babel6中的.babelrc文件中设置:

{
  "plugins": [
    ["transform-react-jsx", { "pragma":"h" }]
  ]
}

  这样工程中所有用到JSX的地方都是被Babel转化成使用h函数的调用。在TypeScript中我们可以通过更改配置文件tsconfig.json中的jsxFactory来控制JSX的编译,具体可参照TypeScript中关于JSX的文档,不再此处赘述。

  根据Virtual DOM节点的特点,我们给出Virtual DOM节点类描述:

// 类型别名
type TagNameType = string;

type KeyType = string | number | null;

interface PropsType {
    key?: string | number;
    [prop: string]: any;
}

// 类
class VNode {
    // 节点类型
    public tagName: TagNameType;
    // 属性
    public props: PropsType;
    // key
    public key? : KeyType;
    // 子元素
    public children: (VNode | string)[];

    public constructor(tagName: TagNameType) {
        this.tagName = tagName;
        this.key = null;
        this.children = [];
        this.props = {};
    }
}

  其中tagName为元素类型,例如:divp。因为我们暂时仅支持HTML固有元素,因此TagNameType是字符串类型。props为元素属性,在接口PropsType我们规定属性中的key值必须为number或者string或者null(null不传key属性),如果对key有不明白的同学,欢迎大家阅读我之前的文章:React技术内幕:key带来了什么

  接下来让我们看一下createElement函数的定义:

function createElement(tagName: TagNameType, props: PropsType, ...children: any[]) {

    let key: KeyType = null;

    if (isNotNull(props)) {

        if (isKey(props.key)) {
            key = props.key!;
            delete props.key;
        }

        if (isNotNull(props.children)) {
            children.push(props.children);
            delete props.children;
        }
    }

    const node = new VNode(tagName);
    node.children = flatten(children);
    node.key = key;
    node.props = isNotNull(props) ? props : {};
    return node;
}

  如果props中含有key属性,则会将其从props中删除,单独赋值给VNode的key属性,而处理props中的children属性主要目的是为了处理以下情况通过props中的children属性直接传递子元素。而对children调用flatten主要是为了处理:

const dom = (
    <ul>
    {
        Array.from({length: 3}).map((val, index)=>{
            return (<li key={index}>列表</li>)
        })
    }
    </ul>
);

  在这种情况下createElement中的chilren[0]是子元素数组,因此我们使用flatten函数将其处理普通的子元素数组。

  通过createElement函数我们就可以将JSX转化成Virtual DOM节点,写个单元测试验证一下是否正确:

describe('createElement', () => {
    test('多个子元素-数组形式', () => {
        const dom = (
            <ul>
                {
                    Array.from({ length: 2 }).map((val, index) => {
                        return <li key={index}></li>;
                    })
                }
            </ul>
        );
        const ul = new VNode('ul');
        ul.children = Array.from({ length: 2 }).map((val, index) => {
            const li = new VNode('li');
            li.key = index;
            return li;
        });
        expect(dom).toEqual(ul);
    });
});

  运行一下,Bingo,测试通过。

渲染Virtual DOM树

  将Virtual DOM树渲染成真实DOM函数也并不复杂:

const renderDOM = function (vnode: VNodeChildType) {

    if (isVNode(vnode)) {
        let node = document.createElement(vnode.tagName);
        // 设置元素属性
        for (const prop of Object.keys(vnode.props)) {
            let value = vnode.props[prop];
            if (prop === 'style') {
                value = transformStyleToString(value);
            }
            node.setAttribute(prop, value);
        }

        for (const child of vnode.children) {
            node.appendChild(renderDOM(child));
        }

        return node;
    }

    if (typeof vnode === 'number') {
        return document.createTextNode(String(vnode));
    }
    return document.createTextNode(vnode);

};

const render = function (vnode: VNode, root: HTMLElement) {
    const dom = renderDOM(vnode);
    root.appendChild(dom);
    return dom;
}

  其中逻辑并不复杂,只需要特殊提及一点,元素中style属性较为特殊,style属性用来通过CSS为元素指定样式。在通过getAttribute()访问时,返回的style特性值中包含的是CSS文本,而通过属性来访问它则会返回一个对象。因此在这里我们通过setAttribute函数设置元素样式前,通过transformStyleToString函数将样式从对象改变为字符串类型,并且将驼峰式的样式属性转化为普通的CSS样式属性,具体可见函数定义:

const transformStyleToString = function (style) {
    // 若是文本类型则直接返回
    if (isString(style)) {
        return style;
    }
    // 若是对象类型则转为字符串
    if (isObject(style)) {
        return Object.keys(style).reduce((acc, key) => {
            let cssKey = key.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`);
            return acc + `${cssKey}: ${style[key]};`;
        }, '');
    }
    return '';
};

Diff算法

  Diff算法可能是Virtual DOM中相对较为复杂的部分,当然我们只是为了实现一个简易的Virtual DOM系统,并不需要过于复杂的实现,下面只是我自己的一种实现策略,并不具有普遍性。Diff算法目的是为了比较两棵Virtual DOM树的差异,传统diff算法的复杂度为 O(n^3),实际上前端DOM树结构具有其自身的特定,因此衍生了各种各样的启发式算法,并将Diff算法的时间复杂度降低到O(n)。

  所谓的启发式算法是指:在解决问题时所采取的一种根据经验规则进行发现的方法。其特点是在解决问题时,利用过去的经验,选择已经行之有效的方法,而不是系统地、以确定的步骤去寻求答案。而在Diff中启发式算法主要是依赖于下列条件:

  • 同级比较
  • 同元素比较
  • 子元素比较

同级比较

  同级比较是指,我们仅会对比同一层次的节点,而忽略跨层级的DOM移动操作。对于同一层次节点的增加和删除,我们则不会进一步比较其子节点,这样只需要对树遍历一遍即可。

  以上图为例,父节点从ul变为p节点,即使ul大部分节点也可以重用,但我们并不会跨层级比较,因此我们会重新渲染div及其子节点。

同元素比较

  同元素比较是指,当遇到元素类型变化时,不会比较两个组件的不同,直接创建新的元素。

  以上图为例,父节点从ul变为ol节点,即使ul子节点并未发生改变,但我们认为元素类型从ul改变为ol,虽然子节点未发生改变,我们并不会比较子节点,直接创建新的节点。

子元素比较

  子元素比较是指,当节点处于同一层级时,我们认为存在以下的节点操作:

  • 插入节点(INSERT_MARKUP)
  • 删除节点(REMOVE_NODE)
  • 移动节点(MOVE_EXISTING)

  以上图为例,假设之前的Virtual DOM树为Old Tree。

  当比较第一个子元素div时,因为New Tree中的div与同位置Old Tree中的div节点类型一致,则我们认为前后变化中对应位置的节点仍是同一个,则我们会继续比较节点属性及其及其子节点。

  当比较第二个子元素时,因为p节点含有key属性,且key = 2的节点也存在于Old Tree,并且前后两个key = 2的节点类型是一致的,因此我们认为New Tree中key = 2p元素是由Old Tree中第三个子元素移动(MOVE_EXISTING)而来。

  当比较第三个子元素时,因为p节点含有key = 3且Old Tree中并不含有key = 3的同类型节点,则我们认为改节点属于插入节点(INSERT_MARKUP)。

  当我们比较a元素的子节点时,因为New Tree中已经不存在该位置的节点,因此我们认为改节点属于删除节点(REMOVE_NODE)。

Patch

  当比较两棵Virtual DOM树时,我们需要记录两棵Virtual DOM树的区别,我们将其称为Patch,因为我们需要记录的是树节点的差异,因此我们也可以将Patch同类化一个树结构。根据Patch类特点,我们给Patch类的定义:

class Patch {
    // 节点变化类型
    public types: OPERATOR_TYPE[];
    // 子元素Patch集合
    public children: Patch[];
    // 存储带渲染的新节点
    public node: VNode | string | null;
    // 存储属性改变
    public modifyProps: ModifyProps[];
    // 文本改变
    public modifyString: string;
    // 节点移动,搭配`MOVE_EXISTING`使用
    public removeIndex: number;

    public constructor(types?: (OPERATOR_TYPE | OPERATOR_TYPE[])) {
        this.types = [];
        this.children = [];
        this.node = null;
        this.modifyProps = [];
        this.modifyString = '';
        this.removeIndex = 0;

        if (types) {
            types instanceof Array ? this.types.push(...types) : this.types.push(types);
        }
    }
    
    // 省略类方法实现
    // addType
    // addModifyProps
    // addChildPatch
    // setNode
    // setModifyString
    // setRemoveIndex
}

  其中types属性用于存储变化类型,注意同一个Patch可能存在多种变化类型,因此我们使用数组存储。Patch存在以下几种类型:

export const enum OPERATOR_TYPE {
    INSERT_MARKUP,
    MOVE_EXISTING,
    REMOVE_NODE,
    PROPS_CHANG,
    TEXT_CHANGE
}

  其中INSERT_MARKUPMOVE_EXISTINGREMOVE_NODE我们都已经介绍过,而PROPS_CHANG表示节点属性发生改变,例如id属性变化。而TEXT_CHANGE适用于文本节点,表示文本节点内容发生改变。例如文本节点内容从Hello!改变为Hello World

  node属性用于存储待渲染的节点类型类型,搭配INSERT_MARKUP使用。removeIndex属性表示当前节点是从同层序号节点位置移动而来,搭配MOVE_EXISTING使用。modifyString表示文本节点变化后的内容,搭配TEXT_CHANGE使用。modifyProps表示属性改变的数组,搭配PROPS_CHANGE使用。其中ModifyProps接口描述为:

const enum PROP_TYPE {
    ADD, // 新增属性
    DELETE, // 删除属性
    MODIFY // 属性改变
}

interface ModifyProps {
    type: PROP_TYPE;
    key?: string;
    value?: any;
}

  完事具备,让我们开始实现diff函数

Diff函数简要实现

// 用于返回子元素数组NodeList中key-node集合(Map)
function getChildrenKeyMap(children: VNodeChildType[]) {
    let map = new Map();
    each(children, child => {
        if (isVNode(child) && isKey(child.key)) {
            map.set(child.key, child);
        }
    });
    return map;
}

// 返回给定key值对应节点所在位置
function getChildIndexByKey(children: VNodeChildType[], key) {
    return findIndex(children, child => (isVNode(child) && child.key === key));
}

function diffProps(preProps: PropsType, nextProps: PropsType) {
    // return [...addPropResult, ...deletePropResult, ...modifyPropResult];
    // 返回Props比较数组结果,如果不存在Props变化则返回空数组。
}
function diff(preNode: VNode | null, nextNode: VNode) {

    const patch = new Patch();

    // 若节点类型不一致或者之前的元素为空,则直接重新创建该节点及其子节点
    if (preNode === null || preNode.tagName !== nextNode.tagName) {
        return patch.addType(OPERATOR_TYPE.INSERT_MARKUP).setNode(nextNode);
    }

    // 前后两个虚拟节点类型一致,则需要比较属性是否一致
    const propsCompareResult = diffProps(preNode.props, nextNode.props);

    if (isNotEmptyArray(propsCompareResult)) {
        patch.addType(OPERATOR_TYPE.PROPS_CHANGE).addModifyProps(propsCompareResult);
    }

    // 如果上一个子元素不为空,且下一个子元素全为空,则需要清除所有子元素
    if (isEmptyArray(nextNode.children) && isNotEmptyArray(preNode.children)) {
        return patch.addChildPatch(preNode.children.map(() => new Patch(OPERATOR_TYPE.REMOVE_NODE)));
    }

    const preChildrenKeyMap = getChildrenKeyMap(preNode.children);

    // 遍历处理子元素
    each(nextNode.children, (child, index) => {
        const nextChild = child;
        const preChild = isNotNull(preNode.children[index]) ? preNode.children[index] : null;

        // 如果当前子节点是字符串类型
        if (isString(nextChild)) {
            // 之前对应节点也是字符串
            if (isString(preChild)) {
                if (nextChild === preChild) {
                    return patch.addChildPatch(new Patch());
                } else {
                    return patch.addChildPatch((new Patch(OPERATOR_TYPE.TEXT_CHANGE).setModifyString(nextChild)));
                }
            } else {
                // 之前对应节点不是字符串,则需要创建新的节点
                return patch.addChildPatch((new Patch(OPERATOR_TYPE.INSERT_MARKUP)).setNode(nextChild));
            }
        }

        // 若当前的子节点中存在key属性
        if (isVNode(nextChild) && isKey(nextChild.key)) {
            // 如果上一个同层虚拟DOM节点中存在相同key且元素类型相同的节点
            if (preChildrenKeyMap.has(nextChild.key) && preChildrenKeyMap.get(nextChild.key).tagName === nextChild.tagName) {
                // 如果前后两个元素的key值和元素类型相等
                const preSameKeyChild = preChildrenKeyMap.get(nextChild.key);
                const sameKeyIndex = getChildIndexByKey(preNode.children, nextChild.key);
                const childPatch = diff(preSameKeyChild, nextChild);
                if (sameKeyIndex !== index) {
                    childPatch.addType(OPERATOR_TYPE.MOVE_EXISTING).setRemoveIndex(sameKeyIndex);
                }
                return patch.addChildPatch(childPatch);
            } else {
                // 直接创建新的元素
                return patch.addChildPatch((new Patch(OPERATOR_TYPE.INSERT_MARKUP).setNode(nextChild)));
            }
        }

        // 子节点中不存在key属性
        // 若前后相同位置的节点是 非VNode(字符串) 或者 存在key值( nextChild不含有key) 或者是 节点类型不同,则直接创建新节点
        if (!isVNode(preChild) || isKey(preChild.key) || preChild.tagName !== nextChild.tagName) {
            return patch.addChildPatch((new Patch(OPERATOR_TYPE.INSERT_MARKUP)).setNode(nextChild));
        }

        return patch.addChildPatch(diff(preChild, nextChild));
    });

    // 如果存在nextChildren个数少于preChildren,则需要补充删除节点
    if (preNode.children.length > nextNode.children.length) {
        patch.addChildPatch(Array.from({ length: preNode.children.length - nextNode.children.length }, () => new Patch(OPERATOR_TYPE.REMOVE_NODE)));
    }
    return patch;
}

  我们简单举例下图场景,分别给new Treeold Tree调用diff算法,则会生成图中所示的Patch Tree:

应用Patch更新DOM树

  applyDiff函数的实现则相对要简单的多,我们只要对照Patch Tree,对之前渲染的DOM树进行修改即可。

function applyChildDiff(actualDOM: HTMLElement, patch: Patch) {
    // 因为removeIndex是基于old Tree中的序号位置,因此我们需要提前备份节点节点顺序关系
    const childrenDOM = map(actualDOM.childNodes, child => child);
    const childrenPatch = patch.children;

    for (let index = 0; index < actualDOM.childNodes.length; index++) {
        const childPatch = childrenPatch[index];
        let childDOM = childrenDOM[index];
        if (contains(childPatch.types, OPERATOR_TYPE.MOVE_EXISTING)) {
            const insertDOM = childrenDOM[childPatch.removeIndex];
            actualDOM.insertBefore(insertDOM, childDOM);
            childDOM = insertDOM;
        }
        innerApplyDiff(childDOM, childPatch, actualDOM);
    }
}

function innerApplyDiff(actualDOM: HTMLElement | Text, patch: Patch, parentDOM: HTMLElement) {
    // 处理INSERT_MARKUP,直接创建新节点替换之前节点
    if (contains(patch.types, OPERATOR_TYPE.INSERT_MARKUP)) {
        const replaceDOM = renderDOM(patch.node!);
        parentDOM.replaceChild(replaceDOM, actualDOM);
        return replaceDOM;
    }
    // 处理REMOVE_NODE,直接删除当前节点
    if (contains(patch.types, OPERATOR_TYPE.REMOVE_NODE)) {
        parentDOM.removeChild(actualDOM);
        return null;
    }
    // 处理TEXT_CHANGE
    if (contains(patch.types, OPERATOR_TYPE.TEXT_CHANGE)) {
        actualDOM.nodeValue = patch.modifyString;
        return actualDOM;
    }
    // 处理PROPS_CHANGE
    if (contains(patch.types, OPERATOR_TYPE.PROPS_CHANGE)) {
        each(patch.modifyProps, function (modifyProp) {
            let key = modifyProp.key;
            let value = modifyProp.value;
            switch (modifyProp.type) {
                case PROP_TYPE.ADD:
                case PROP_TYPE.MODIFY:
                    if (key === 'style') {
                        value = transformStyleToString(value);
                    }
                    actualDOM.setAttribute(key, value);
                    break;
                case PROP_TYPE.DELETE:
                    actualDOM.removeAttribute(key);
                    break;
            }
        });
    }

    if (isHTMLElement(actualDOM)) {
        applyChildDiff(actualDOM, patch);
    }

    return actualDOM;
}

function applyDiff (actualDOM: HTMLElement | Text, patch: Patch) {
    if (!(actualDOM.parentNode instanceof HTMLElement)) {
        throw Error('DOM元素未渲染');
    }
    return applyDiff(actualDOM, patch, actualDOM.parentNode);
}

Virtual DOM 使用演示

  现在我们的Virtual DOM库已经基本完成,我们起个名字就叫Vom,让我们尝试使用一下:

import Vom from '../index';

function getRandomArray(length) {
    return Array.from(new Array(length).keys()).sort(() => Math.random() - 0.5);
}

function getRandomColor() {
    const colors = ['blue', 'red', 'green'];
    return colors[(new Date().getSeconds()) % colors.length];
}

function getJSX() {
    return (
        <div>
            <p>这是一个由Vom渲染的界面</p>
            <p>
                <span style={{ color: getRandomColor() }}>现在时间: { Date().toString() }</span>
            </p>
            <p>下面是一个顺序动态变化的有序列表:</p>
            <ul>
                {
                    getRandomArray(10).map((key) => {
                        return <li key={key}>列表序号: {key} </li>;
                    })
                }
            </ul>
        </div>
    );
}

let preNode = getJSX();
let actualDom = Vom.render(preNode, document.body);

setInterval(() => {
    const nextNode = getJSX();
    const patch = Vom.diff(preNode, nextNode);
    actualDom = Vom.applyDiff(actualDom, patch)!;
    preNode = nextNode;
}, 1000);

结尾

  到目前为止我们已经实现一个Virtual DOM的基本功能,本篇文章重点还是在讲述Virtual DOM基本原理,实现方面相对比较简陋,如有不正确之处,望各位见谅。代码已经上传Github:Vom。写作不易,愿大家能多多支持,关注我的Github博客,关注、点赞、收藏 素质三连哦!希望这篇文章能对你有些许帮助,愿共同学习!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant