zoukankan      html  css  js  c++  java
  • 从零开始学虚拟DOM

    此文主要翻译自:Building a Simple Virtual DOM from Scratch,看原文的同学请直达!

    此文是作者在一次现场编程演讲时现场所做的,有关演讲的相关资料我们也可以在原英文链接找到。

    背景:什么是虚拟DOM

    虚拟DOM指的是用于展现真实DOM的普通JS对象。简单说就是JS的普通对象,通过这个对象可以创建真实的DOM,它保存了创建真实DOM所需的所有东西。

    Virtual DOMs usually refer to plain objects representing the actual DOMs.

    The Document Object Model (DOM) is a programming interface for HTML documents.

    比如,我们这样写:

    const $app = document.getElementById('app');
    

    浏览器会创建DOM:

    , DOM也是一种对象,它提供了自己的接口,我们可以通过JS控制DOM,如:

    $app.innerHTML = 'Hello world';
    

    如果用一个对象来描述上面创建的真实DOM 我们可以像下面这样:

    const vApp = {
        tagName: 'div',
        attrs: {
            id: 'app',
        }
    };
    

    对于虚拟DOM,还没有任何严格的规则要求要怎么创建,或者说创建的对象要遵守什么编程接口,规则等。例如上面的例子,你可以用tagLabel替代tagName,或者props替代attrs。只要它能创建一个真实的DOM,那它可以认为是一个虚拟DOM。

    译者注:vue.js与react.js都使用了虚拟DOM,但他们的实现不一样

    虚拟DOM不像真实的DOM,它没有提供编程接口,就是普通对象。所以跟真实的DOM相比,它更加轻量。

    虽然虚拟DOM更加轻量,但真实DOM才是浏览器最基础的元素,大部分浏览器都对DOM的相关操作都做了大量的优化,所以真实的DOM操作可能不是像很多人说的那样慢的。

    安装

    https://codesandbox.io/s/7wqm7pv476?expanddevtools=1

    我们先用mkdir创建一个项目目录,然后用cd进入刚创建的目录,如下:

    $ mkdir /tmp/vdommm
    $ cd /tmp/vdommm
    

    然后初始化一个git仓库,用gitignorer创建.gitignore文件,然后用npm初始化项目,如:

    $ git init
    $ gitignore init node
    $ npm init -y
    

    译者注:gitignorer需要npm全局安装,没有安装的可以通过npm instrall gitignorer -g先安装。

    现在我们可以先进行初次提交,将现有代码提交到git仓库,如:

    $ git add -A
    $ git commit -am ':tada: initial commit'
    

    然后我们可以安装 Parcel Bundler(一个正在的零配置打包工具),它支持各种格式的开箱即用。在我做现场编码演讲时会经常用到它。

    $ npm install parcel-bundler
    

    译者注:Parcel跟Webpack功能类似,上手也快。

    (有趣的是:安装的时候你不在需要 --save 参数), 趁安装parcel的时候,我们创建其他的文件,如下:

    src/index.html

    <html>
        <head>
            <title>hello world</title>
        </head>
        <body>
            Hello world
            <script src="./main.js"></script>
        </body>
    </html>
    

    src/main.js

    const vApp = {
        tagName: 'div',
        attrs: {
            id: 'app',
        },
    };
    
    console.log(vApp);
    

    package.json

    {
      ...
      "scripts": {
        "dev": "parcel src/index.html", // add this script
      }
      ...
    }
    

    创建完上面的文件,我们可以直接执行下面的命令,如:

    $ npm run dev
    
    ## 如果成功会输出下面
    Server running at http://localhost:1234 
    √  Built in 1.26s.
    

    打开浏览器访问: http://localhost:1234/ 你应该会看到 hello world字样,还有控制台会输出我们定义的虚拟DOM对象。如果一切如上,那么你的环境准备完毕。

    createElement

    https://codesandbox.io/s/n9641jyo04?expanddevtools=1

    大多的虚拟DOM实现都会有个叫createElement的方法,通常简称为 h 。这个函数只是简单地返回一个“虚拟元素”,下面让我们看看它的实现。

    src/vdom/createElement.js

    export default (tagName, opts) => {
        return {
            tagName,
            attrs: opts.attrs,
            children: opts.children
        };
    };
    

    利用对象的析构功能,我们可以改写上面的代码,如:

    export default (tagName, {attrs, children}) => {
        return {
            tagName,
            attrs,
            children
        };
    };
    

    当opts为空时,也应该可以创建虚拟元素,所以继续修改代码,如:

    export default (tagName, {attrs={}, children=[]}={}) => {
        return {
            tagName,
            attrs,
            children
        };
    };
    

    译者注:opts默认值为{},attrs默认值为{},children默认值为[]

    定义了createElement方法,我们现在可以改写main.js,让main.js调用createElement方法创建虚拟DOM,

    src/main.js

    import createElement from "./vdom/createElement";
    
    const vApp = createElement('div', {
        attrs: {
            id: 'app'
        },
        children: [
            createElement('img', {
                attrs: {
                    src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif'
                }
            })
        ]
    });
    
    console.log(vApp);
    

    修改后的main.js, 我们添加了一张来自giphy的图片。回到浏览器,刷新刚才的页面,你会看到控制台输出了新的虚拟DOM。

    字面量对象(如:{a: 3})会自动继承自Object。就是说我们的虚拟DOM对象会自动包含hasOwnProperty, toString等这些方法。我们可以用Object.create(null)创建对象,这样不会继承只Object,也可以让我们的虚拟DOM更加的“纯”。所以修改createElement方法如下:

    src/vdom/createElement.js

    export default (tagName, {attrs={}, children=[]}={}) => {
        const vElem = Object.create(null);
        Object.assign(vElem, {
            tagName,
            attrs,
            children
        });
        return vElem;
    };
    

    render(vNode)

    https://codesandbox.io/s/pp9wnl5nj0?expanddevtools=1

    渲染虚拟元素

    现在我们已经有了一个函数能创建虚拟DOM,下面一个方法将虚拟DOM翻译成真实的DOM。定义render(vNode)方法,如下:

    src/vdom/render.js

    const render = vNode => {
        const $el = document.createElement(vNode.tagName);
        for(const [k,v] of Object.entries(vNode.attrs)){
            $el.setAttribute(k, v);
        }
        for(const child of vNode.children){
            $el.appendChild(render(child));
        }
        return $el;
    };
    
    export default render;
    

    上面的代码很简单,就不做过多解释了。

    ElementNode与TextNode

    对于真实的DOM,实际有8种类型的节点,这里我们只看2种类型:

    1. ElementNode, 比如:
      ,
    2. TextNode, 纯文本

    看我们上面创建的虚拟DOM对象 {tagName, attrs, children},它只能创建真实DOM中的 ElementNode。因此,我们还需要增加能创建TextNode的功能。我们将用String来创建TextNode

    为了好展示,让我们添加一些文本到虚拟DOM,如下:

    src/main.js

    import createElement from "./vdom/createElement";
    
    const vApp = createElement('div', {
        attrs: {
            id: 'app'
        },
        children: [
            'Hello world',
            createElement('img', {
                attrs: {
                    src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif'
                }
            })
        ]
    });
    
    console.log(vApp);
    

    扩展render函数以便支持文本节点(TextNode)

    想我上面提到的,我们需要考虑两种类型的DOM节点。当前的render方法只能渲染ElementNode。所以让我们扩展render方法以便支持渲染文本节点。

    我们将原来的render方法改名为renderElem,同时修改一下参数,将原来的vNode析构为{tagName, attrs, children},如下:

    const renderElem = ({tagName, attrs, children}) => {
        const $el = document.createElement(tagName);
        for(const [k,v] of Object.entries(attrs)){
            $el.setAttribute(k, v);
        }
        for(const child of children){
            $el.appendChild(render(child));
        }
        return $el;
    };
    
    export default render;
    

    接下来,我们重新定义一个render函数。新的render函数只需要检查vNode是否是string类型,如果是就调用document.createTextNode(string)来渲染文本节点,否则调用上面定义的renderElem即可,如下:

    src/vdom/render.js

    const renderElem = ({tagName, attrs, children}) => {
        const $el = document.createElement(tagName);
        for(const [k,v] of Object.entries(attrs)){
            $el.setAttribute(k, v);
        }
        for(const child of children){
            $el.appendChild(render(child));
        }
        return $el;
    };
    
    const render = vNode => {
        if(typeof vNode === 'string'){
            return document.createTextNode(vNode);
        }
        return renderElem(vNode);
    };
    
    export default render;
    

    渲染我们的vApp

    现在让我们来渲染我们的vApp,并输出它!

    src/main.js

    import createElement from "./vdom/createElement";
    import render from "./vdom/render";
    
    const vApp = createElement('div', {
        attrs: {
            id: 'app'
        },
        children: [
            'Hello world',
            createElement('img', {
                attrs: {
                    src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif'
                }
            })
        ]
    });
    const $app = render(vApp);
    console.log($app);
    

    控制台输出:

    <div id="app">
        Hello world
        <img src="https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif">
    </div>
    

    mount($node, $target)

    https://codesandbox.io/s/vjpk91op47

    现在我们已经可以创建虚拟DOM,并且能将它渲染成真实的DOM。接下来我们需要把真实的DOM显示在页面上。

    首先,我们需要一个挂载点,我们将原页面上的hello world替换为一个div元素,如下:

    src/index.html

    <html>
    <head>
        <title>hello world</title>
    </head>
    <body>
        <div id="app"></div>
        <script src="./main.js"></script>
    </body>
    </html>
    

    接下来我们要做的是用$app替换掉空的div元素。如果不考虑IE与Safari这将非常容易,我们只要用ChildNode.replaceWith方法即可。

    让我们定义render($node, $target)方法,它会将$target用$node替换掉,然后返回$node,如下:

    src/vdom/mount.js

    export default ($node, $target) => {
        $target.replaceWith($node);
        return $node;
    };
    

    现在修改main.js,调用mount方法,如下:

    src/main.js

    import createElement from "./vdom/createElement";
    import render from "./vdom/render";
    import mount from "./vdom/mount";
    
    const vApp = createElement('div', {
        attrs: {
            id: 'app'
        },
        children: [
            'Hello world',
            createElement('img', {
                attrs: {
                    src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif'
                }
            })
        ]
    });
    const $app = render(vApp);
    const $target = document.getElementById('app');
    mount($app, $target);
    

    让我们的app再有趣一点

    https://codesandbox.io/s/ox02294zo5

    接下来让我们的app变得有趣一点。我们用函数createVApp来创建vApp,createVApp接收一个count参数,用于创建vApp。如下:

    src/main.js

    import createElement from "./vdom/createElement";
    import render from "./vdom/render";
    import mount from "./vdom/mount";
    
    const createVApp = count => createElement('div', {
        attrs: {
            id: 'app',
            dataCount: count
        },
        children: [
            'The current count is: ',
            String(count),
            createElement('img', {
                attrs: {
                    src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif'
                }
            })
        ]
    })
    
    let count = 0;
    const vApp = createVApp(count);
    const $app = render(vApp);
    const $target = document.getElementById('app');
    let $rootEl = mount($app, $target);
    
    setInterval(() => {
        count++;
        $rootEl = mount(render(createVApp(count)), $rootEl);
    },1000)
    
    
    // function start(count){
    //     let vApp = createVApp(count);
    //     const $app = render(vApp);
    //     const $target = document.getElementById('app');
    //     return mount($app, $target);
    // }
    
    // let count = 0;
    // let $rootEl = start(count);
    
    // setInterval(() => {
    //     $rootEl = start(++count);
    // },4000)
    
    

    我们用$rootEle保存每次挂载后的根元素,mount函数每次都挂载到新的rootEl元素。现在我们回到浏览器界面,你应该会看到计数每隔1秒增加一次,完美!

    到现在我们已经可以以声明的方式创建应用了。通过上面的几行代码,应用能按照我们预期的渲染,这其中的秘密就是这么简单。如果你知道JQuery是怎么做渲染的,对比我们现在的方法,你将会感叹这是多么简洁的做法。

    然而,上面的做法是每次每隔1秒会重新渲染整个节点,这将会有以下一些问题:

    1. 真实DOM比虚拟DOM笨重,每次都将整个节点重新渲染为真实DOM可能比较耗时。
    2. 元素会丢失状态。比如input元素会丢失焦点。

    下面我们看看如何解决上面的问题。

    diff(oldVTree, newVTree)

    https://codesandbox.io/s/0xv007yqnv

    想像我们有一个diff(oldVTree, newVTree)函数,它用来比较旧的虚拟DOM与新的虚拟DOM之间的不同,为后面渲染做准备。diff函数返回一个patch函数,patch函数以旧的真实DOM为参数,对真实的DOM执行一些必要的操作,使其看上去像是从新的虚拟DOM创建出来的一样。

    下面我们尝试实现diff函数,我们从简单的情形开始:

    1. newVTree是undefined

      因为新的虚拟DOM已经不存在了,所以在patch函数里面可以将传入的旧的真实DOM直接删除。

    2. newVTree与oldVTree都是文本节点(TextNode)

      当两者都是文本时,如果两者相等,则无需处理;如果不相等,则用新虚拟DOM渲染出来的元素替换掉旧的真实DOM,即:用render(newTree)替换$node.

    3. 一个是文本节点一个元素节点

      这种情况,很显然两者不同,直接用新虚拟DOM渲染出来的元素替换掉旧的真实DOM。

    4. 新旧虚拟DOM的tagName不一样

      tagName不一样,即为不同的元素,直接用新虚拟DOM渲染出来的元素替换掉旧的真实DOM。react的算法中也是这么做的。

    src/vdom/diff.js

    import render from './render';
    
    const diff = (oldVTree, newVTree) => {
    
        // 如果新的虚拟dom是undefined
        if(newVTree === undefined){
            // 返回patch函数,$node为传入的旧的真实DOM元素
            return $node => {
                // 删除旧的元素
                $node.remove();
                // patch函数必须返回一个根元素,这种情况没有元素,所以返回undefined。
                return undefined;
            };
        }
    
        // 如果两者都是文本节点
        if(typeof oldVTree === 'string' || typeof newVTree === 'string'){
            // 文本内容不等
            if(oldVTree !== newVTree){
                return $node => {
                    // 通过新的虚拟DOM渲染得到新的真实DOM
                    const $newNode = render(newVTree);
                    // 将新的DOM替换旧的
                    $node.replaceWith($newNode);
                    return $node;
                }
            }else{ 
                // 文本相等,无需处理。
                return $node => $node
            }
        }
    
        // 如果两者的tagName不同
        if(oldVTree.tagName !== newVTree.tagName){
            return $node => {
                // 通过新的虚拟DOM渲染得到新的真实DOM
                const $newNode = render(newVTree);
                // 将新的DOM替换旧的
                $node.replaceWith($newNode);
                return $node;
            }
        }
        
        // (A) ---
    
    };
    
    export default diff;
    

    上面的代码完全根据我们比较算法实现,我们只考虑了三种大的情况(元素被删,文本类型,不同元素类型),如果代码执行到(A)的位置,那又如何处理?如果执行到(A)处,情况比较复杂,至少有以下几点我们知道:

    1. oldVTree与newVTree都是虚拟DOM
    2. 它们有相同的tagName
    3. 它们可能有不同的attrs和children

    我们将实现两个独立的方法来处理attrs与children的比较。暂时分别命名为diffAttrs(oldAttrs, newAttrs)diffChildren(oldVChildren, newVChildren),它们都会返回各自的patch函数。

    src/vdom/diff.js

    // 此处省略上面的相同代码
    ...
    
    const patchAttrs = diffAttrs(oldVTree.attrs, newVTree.attrs);
    const patchChildren = diffChildren(oldVTree.children, newVTree.children);
    
    return $node => {
        patchAttrs($node);
        patchChildren($node);
        return $node;
    }
    
    ....
    

    diffAttrs(oldAttrs, newAttrs)

    让我聚焦到diffAttrs函数。实际它非常简单。首先我们要将所有新的属性设置到dom上,然后将那些存在于旧的属性而不存在新的属性的属性全部删除。代码如下:

    const diffAttrs = (oldAttrs, newAttrs) => {
        const patches = []; // patch函数的数组
        // 将新的属性设置进去
        for(const [k, v] of Object.entries(newAttrs)){
            patches.push($node => {
                $node.setAttribute(k, v);
                return $node;
            });
        }
        // 删除不存在于新属性集而存在于旧属性集的属性
        for(const k in oldAttrs){
            if(!(k in newAttrs)){
                patches.push($node => {
                    $node.removeAttribute(k);
                    return $node;
                });
            }
        }
    
        return $node => {
            for(const patch of patches){
                patch($node);
            }
            return $node;
        }
    
    };
    

    diffChildren(oldVChildren, newVChildren)

    Children的比较会有点复杂,我们需要考虑下面三种情况:

    1. oldVChildren.length === newVChildren.length

      说明子元素个数一样,此时我们必须对子元素逐个比较,即:diff(oldVChildren[i], newVChildren[i]),i从0到oldVChildren.length

    2. oldVChildren.length > newVChildren.length

      还是需要逐个比较子元素,直到newVChildren变为undefined,因为我们在diff里考虑了newVTree为undefined的情况,所以这种情况的处理方法跟第一种一样,即:diff(oldVChildren[i], newVChildren[i]),i从0到oldVChildren.length

    3. oldVChildren.length < newVChildren.length

      先遍历oldVChildren,执行diff(oldVChildren[i], newVChildren[i]),然后将newVChildren中没有遍历到的虚拟dom,渲染为真实dom,手动加入到旧dom的子元素中。

    const diffChildren = (oldVChildren, newVChildren) => {
        const childrenPatches = [];
        // 1. 当oldVChildren.length === newVChildren.length
        // 2. 当oldVChildren.length > newVChildren.length
        // 3. 当oldVChildren.length < newVChildren.length
        oldVChildren.forEach((oldVChild, i)=>{
            childrenPatches.push(diff(oldVChild, newVChildren[i]));
        });
    
        // 上面的执行完毕后,只有第三种情况中newVChildren中的部分节点没有处理到
        // 处理余下的节点
        const additionalPatches = [];
        for(const additionalVChild of newVChildren.slice(oldVChildren.length)){
            additionalPatches.push($node => {
                $node.appendChild(render(additionalVChild));
                return $node;
            });
        }
    
        return $parent => {
            $parent.childNodes.forEach(($child, i)=>{
                childrenPatches[i]($child);
            });
            for(const patch of additionalPatches){
                patch($parent);
            }
            return $parent;
        }
    };
    

    译者注:作者原文用了一个zip函数来同时处理两个数组的循环,这里没有放出来。

    最终的diff.js

    import render from './render';
    
    const diffAttrs = (oldAttrs, newAttrs) => {
        const patches = []; // patch函数的数组
        // 将新的属性设置进去
        for(const [k, v] of Object.entries(newAttrs)){
            patches.push($node => {
                $node.setAttribute(k, v);
                return $node;
            });
        }
        // 删除不存在于新属性集而存在于旧属性集的属性
        for(const k in oldAttrs){
            if(!(k in newAttrs)){
                patches.push($node => {
                    $node.removeAttribute(k);
                    return $node;
                });
            }
        }
    
        return $node => {
            for(const patch of patches){
                patch($node);
            }
            return $node;
        }
    
    };
    
    const diffChildren = (oldVChildren, newVChildren) => {
        const childrenPatches = [];
        // 1. 当oldVChildren.length === newVChildren.length
        // 2. 当oldVChildren.length > newVChildren.length
        // 3. 当oldVChildren.length < newVChildren.length
        oldVChildren.forEach((oldVChild, i)=>{
            childrenPatches.push(diff(oldVChild, newVChildren[i]));
        });
    
        // 上面的执行完毕后,只有第三种情况中newVChildren中的部分节点没有处理到
        // 处理余下的节点
        const additionalPatches = [];
        for(const additionalVChild of newVChildren.slice(oldVChildren.length)){
            additionalPatches.push($node => {
                $node.appendChild(render(additionalVChild));
                return $node;
            });
        }
    
        return $parent => {
            $parent.childNodes.forEach(($child, i)=>{
                // childrenPatches[i]($child); -- 原文有错
                // 因为diff可能返回undefined,所以需要判断patch是否存在。
                childrenPatches[i]&&childrenPatches[i]($child);
            });
            for(const patch of additionalPatches){
                patch($parent);
            }
            return $parent;
        }
    };
    
    const diff = (oldVTree, newVTree) => {
    
        // 如果新的虚拟dom是undefined
        if(newVTree === undefined){
            // 返回patch函数,$node为传入的旧的真实DOM元素
            return $node => {
                // 删除旧的元素
                $node.remove();
                // patch函数必须返回一个根元素,这种情况没有元素,所以返回undefined。
                return undefined;
            };
        }
    
        // 如果两者都是文本节点
        if(typeof oldVTree === 'string' || typeof newVTree === 'string'){
            // 文本内容不等
            if(oldVTree !== newVTree){
                return $node => {
                    // 通过新的虚拟DOM渲染得到新的真实DOM
                    const $newNode = render(newVTree);
                    // 将新的DOM替换旧的
                    $node.replaceWith($newNode);
                    return $node;
                }
            }else{ 
                // 文本相等,无需处理。
                return $node => $node
            }
        }
    
        // 如果两者的tagName不同
        if(oldVTree.tagName !== newVTree.tagName){
            return $node => {
                // 通过新的虚拟DOM渲染得到新的真实DOM
                const $newNode = render(newVTree);
                // 将新的DOM替换旧的
                $node.replaceWith($newNode);
                return $node;
            }
        }
    
        const patchAttrs = diffAttrs(oldVTree.attrs, newVTree.attrs);
        const patchChidlren = diffChildren(oldVTree.children, newVTree.children);
    
        return $node => {
            patchAttrs($node);
            patchChidlren($node);
            return $node;
        };
    
    };
    
    export default diff;
    

    让我们的app再复杂一点

    https://codesandbox.io/s/mpmo2yy69

    我们当前的应用实际是没有用到虚拟DOM的全部功能的。为了展示虚拟DOM的强大功能,让我们再次修改我们应用,让它变得再复杂一点。

    src/main.js

    import createElement from "./vdom/createElement";
    import render from "./vdom/render";
    import mount from "./vdom/mount";
    import diff from './vdom/diff';
    
    const createVApp = count => createElement('div', {
        attrs: {
            id: 'app',
            dataCount: count
        },
        children: [
            'The current count is: ',
            String(count),
            ...Array.from({length: count}, ()=> createElement('img',{
                attrs: {
                    src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif'
                }
            }))
        ]
    })
    
    let vApp = createVApp(0);
    const $app = render(vApp);
    const $target = document.getElementById('app');
    let $rootEl = mount($app, $target);
    
    setInterval(() => {
        const n = Math.floor(Math.random()*10);
        let newVApp = createVApp(n);
        const patch = diff(vApp, newVApp);
        $rootEl = patch($rootEl);
        vApp = newVApp;
    },1000)
    
    
    // function start(count){
    //     let vApp = createVApp(count);
    //     const $app = render(vApp);
    //     const $target = document.getElementById('app');
    //     return mount($app, $target);
    // }
    
    // let count = 0;
    // let $rootEl = start(count);
    
    // setInterval(() => {
    //     $rootEl = start(++count);
    // },4000)
    
    

    感谢

    感谢您花时间一直阅读到这里,这篇文章确实有点长。读完还请留言!~

    原文中已经提供了文章中所有代码的链接,还有git仓库。这里我也按照作者的代码自己实现了一遍,需要的请链接:

    https://github.com/ywxgod/learningExamples/tree/master/tanslations/building_a_simple_virtual_dom_from_scratch

  • 相关阅读:
    软件工程2019:第3次作业—— 团队项目阶段一: 项目需求分析
    软件工程2019:第2次作业—— 时事点评
    第1次作业—— 自我介绍 + 软工五问(热身运动)
    软工作业(4)用户体验分析:以 “师路南通网站” 为例
    软工作业(3):用户体验分析
    软工作业: (2)硬币游戏—— 代码分析与改进
    《软件工程导论》读后感想与疑惑
    软工作业(1)
    用户体验分析:以 “师路南通网站” 为例
    用户体验分析: 以 “南通大学教务管理系统微信公众号” 为例
  • 原文地址:https://www.cnblogs.com/ywxgod/p/11824057.html
Copyright © 2011-2022 走看看