从头开始构建一个简单的虚拟dom

从头开始构建一个简单的虚拟dom

1. 背景说明:virtual-dom是什么

  • Virtual DOM通常引用表示实际DOM的普通对象。它不具有任何编程接口。与实际DOM相比,这使得它们变得轻量级
  • 举例说明
const $app = document.getElementById('app');
在页面上获得<div id =“app”> </ div>的DOM。这个DOM将有一些编程接口供你控制
$app.innerHTML = 'Hello world';

要使普通对象代表$app,我们可以这样编写:
const vApp = {
  tagName: 'div',
  attrs: {
    id: 'app',
  },
};
复制代码

2. 项目初始化

  • 创建项目文件夹名字:vdommm
mkdir /***/vdommm
复制代码
  • 进入vdommm目录,初始化package.json
npm init -y
复制代码
  • 安装零配置的Parcel Bundler
npm install parcel-bundler
复制代码
  • 创建项目文件 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"
  }
  ...
}
复制代码
  • 运行 npm run dev
> vdommm@0.0.1 dev /private/tmp/vdommm

> parcel src/index.html

Server running at http://localhost:1234

Built in 959ms.
复制代码
  • 在浏览器地址栏输入http://localhost:1234 在页面上能看到 Hello world,在console能看到定义的vApp,说明项目初始化是成功的。

3. 创建元素

  • src/vdom/createElement.js
export default (tagName, {attrs = {}, children = []} = {}) => {
  const vElem = Object.create(null)
  Object.assign(vElem, {
    tagName,
    attrs,
    children
  })
  return vElem
}
复制代码
  • 为什么使用Object.create(null)创建对象解释如下
  • 因为{a:3}自动从Object继承。这意味着{a:3}将具有在Object.prototype中定义的方法,如hasOwnProperty,toString等。我们可以通过使用Object.create(null)使虚拟DOM有更“纯粹”。这将创建一个真正的普通对象,它不会从Object继承而是从null继承。

4. 渲染

  • src/vdom/render.js
  • render函数将虚拟DOM转换为真正的DOM。让我们定义渲染(vNode),它将接收虚拟节点并返回相应的DOM。
  • 渲染虚拟元素
const render = (vNode) => {
  // 创建元素
  //  例如. <div></div>
  const $el = document.createElement(vNode.tagName);

  // 为指定的元素添加 vNode.attrs属性
  //  例如. <div id="app"></div>
  for (const [k, v] of Object.entries(vNode.attrs)) {
    $el.setAttribute(k, v);
  }

  // 添加子节点
  //  例如. <div id="app"><img></div>
  for (const child of vNode.children) {
    $el.appendChild(render(child));
  }

  return $el;
};

export default render;
复制代码
  • 在真正的DOM中,有8种类型的节点。在本文中,我们只介绍两种类型:ElementNode和TextNode,目前render()函数只支持ElementNode节点,改写一下支持扩展渲染TextNode节点,以下是render函数的全部代码
const renderElem = (vNode) => {
    // 创建元素 <div></div>
    const $el = document.createElement(vNode.tagName)

    // 添加所有的vNode.attrs属性
    // 例如<div id='app'></div>
    for (const [k, v] of Object.entries(vNode.attrs)) {
        $el.setAttribute(k, v)
    }

    // 添加所有的孩子节点 vNode.children
    // e.g <div id='app'><img></div>
    for (const child of vNode.children) {
        $el.appendChild(render(child))
    }
    return $el
}

const render = (vNode) => {
    if (typeof vNode === 'string') {
        return document.createTextNode(vNode)
    }

    return renderElem(vNode)
}
export default render
复制代码

5. 挂载(mount)

  • 现在能够创建虚拟DOM并将其渲染为真正的DOM。接下来,我们需要将真正的DOM放在页面上。
  • 为应用创建一个挂载点。我将用
    </ div>替换src/index.html上的Hello world。
<html>
  <head>
    <title>hello world</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="./main.js"></script>
  </body>
</html>
复制代码
  • src/vdom/mount.js
export default ($node, $target) => {
    $target.replaceWith($node);
    return $node
}
复制代码
  • 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, // we use the count here
  },
  children: [
    'The current count is: ',
    String(count), // and here
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),
  ],
});

let count = 0;
const vApp = createVApp(count);
const $app = render(vApp);
mount($app, document.getElementById('app'));
复制代码
  • 运行程序可以看到虚拟节点转化为dom节点,并挂载到页面上
  • 修改一下main.js,用setInterval每秒递增计数,并在页面上再次创建,渲染和挂载我们的程序。
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, // we use the count here
  },
  children: [
    'The current count is: ',
    String(count), // and here
    createElement('img', {
      attrs: {
        src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
      },
    }),
  ],
});

let count = 0;
const vApp = createVApp(count);
const $app = render(vApp);
let $rootEl = mount($app, document.getElementById('app'));

setInterval(() => {
  count++;
  $rootEl = mount(render(createVApp(count)), $rootEl);
}, 1000);
复制代码
  • 我们现在获得了以声明方式创建应用程序的能力。应用程序以可预测的方式呈现,但每秒重新渲染整个应用程序有几个问题:
  • 真正的DOM比虚拟DOM重得多。将整个应用程序渲染到真实DOM可能很昂贵。
  • 元素将失去他们的状态。例如,只要应用程序重新渲染到页面,input就会失去焦点。
  • input失去焦点示例

6. diff

  • diff方法说明
  1. newVTree未定义
  • 删除节点
  1. 它们都是TextNode(字符串)
  • 如果它们是相同的字符串,则不执行任何操作。
  • 如果不是,用render(newVTree)替换$node节点。
  1. 其中一个树是TextNode,另一个是ElementNode
  • 在这种情况下,它们显然不是一回事,我们将用render(newVTree)替换node。
  1. oldVTree.tagName !== newVTree.tagName
  • 在这种情况下我们假设,oldVTree、newVTree完全不同。
  • 不会试图找到两棵树之间的差异,我们将只用render(newVTree)替换节点。
  • 不同类型的两个元素将产生不同的树。
import render from './render';

const diff = (oldVTree, newVTree) => {
  if (newVTree === undefined) {
    return $node => {
      $node.remove();
      return undefined;
    }
  }

  if (typeof oldVTree === 'string' ||
    typeof newVTree === 'string') {
    if (oldVTree !== newVTree) {
       // 这里包含两种情况
       // 1.oldVTree、newVTree 都是字符串它们的值不同
       // 2.oldVTree、newVTree其中一个是文本节点,另一个是元素节点
       // 无论哪种情况,调用render(newVTree)
      return $node => {
         const $newNode = render(newVTree);
         $node.replaceWith($newNode);
         return $newNode;
       };
    } else {
      return $node => $node;
    }
  }

  if (oldVTree.tagName !== newVTree.tagName) {
    // 当tagName名不同时,认为两个虚拟组件完全不同,
    // 不为去比较发现它们的不同,仅渲染新的newVtree
    return $node => {
      const $newNode = render(newVTree);
      $node.replaceWith($newNode);
      return $newNode;
    };
  }

  // (A)
};

export default diff;
复制代码
  • 代码走到 (A)处意味着:
  1. oldVTree和newVTree都是虚拟元素。
  2. 它们具有相同的tagName。
  3. 他们可能有不同的属性和孩子节点。
  4. 下面我们需要实现diffAttrs及diffChildren方法
  • diffAttrs方法
const diffAttrs = (oldAttrs, newAttrs) => {
    const patches = []

    // 设置新属性
    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
    }
}
复制代码
  • diff元素孩子节点
const zip = (xs, ys) => {
    const zipped = []
    for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
        zipped.push([xs[i], ys[i]])
    }
    return zipped
}

const diffChildren = (oldVChildren, newVChildren) => {
    const childPatches = [];
    oldVChildren.forEach((oldVChild, i) => {
        childPatches.push(diff(oldVChild, newVChildren[i]));
    });

    // newVChildren 新增的子元素
    // 比如 old [1,2,3] new [1, 2, 3, 4, 5] 将[4, 5] 追加
    const additionalPatches = [];
    for (const additionalVChild of newVChildren.slice(oldVChildren.length)) {
        additionalPatches.push($node => {
            $node.appendChild(render(additionalVChild));
            return $node;
        });
    }
    return $parnet => {
        for (const [patch, $child] of zip(childPatches, $parnet.childNodes)) {
            patch($child);
        }

        for (const patch of additionalPatches) {
            patch($parnet);
        }
        return $parnet;
    }
}
复制代码
  • 完整的diff方法
import render from './render'

const diffAttrs = (oldAttrs, newAttrs) => {
    const patches = []

    // 设置新属性
    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 zip = (xs, ys) => {
    const zipped = []
    for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
        zipped.push([xs[i], ys[i]])
    }
    return zipped
}

const diffChildren = (oldVChildren, newVChildren) => {
    const childPatches = [];
    oldVChildren.forEach((oldVChild, i) => {
        childPatches.push(diff(oldVChild, newVChildren[i]));
    });

    // newVChildren 新增的子元素
    // 比如 old [1,2,3] new [1, 2, 3, 4, 5] 将[4, 5] 追加
    const additionalPatches = [];
    for (const additionalVChild of newVChildren.slice(oldVChildren.length)) {
        additionalPatches.push($node => {
            $node.appendChild(render(additionalVChild));
            return $node;
        });
    }
    return $parnet => {
        for (const [patch, $child] of zip(childPatches, $parnet.childNodes)) {
            patch($child);
        }

        for (const patch of additionalPatches) {
            patch($parnet);
        }
        return $parnet;
    }
}

const diff = (oldVTree, newVTree) => {
    if (newVTree === undefined) {
        return $node => {
            $node.remove()
            return undefined
        }
    }

    if (typeof oldVTree === 'string' || typeof newVTree === 'string') {
        if (oldVTree !== newVTree ) {
            // 这里包含两种情况
            // 1.oldVTree、newVTree 都是字符串它们的值不同
            // 2.oldVTree、newVTree其中一个是文本节点,另一个是元素节点
            // 无论哪种情况,调用render(newVTree)
            return $node => {
                const $newNode = render(newVTree)
                $node.replaceWith($newNode)
                return $newNode
            }
        } else {
            // 字符串,且值相同
            return $node => $node
        }
    }

    if (oldVTree.tagName !== newVTree.tagName) {
        // 当tagName 名不同时,认为两个虚拟组件完全不同,
        // 不为去比较发现它们的不同,仅渲染新的newVtree并挂载
        return $node => {
            const $newNode = render(newVTree)
            $node.replaceWith($newNode)
            return $newNode
        }
    }

    const patchAttrs = diffAttrs(oldVTree.attrs, newVTree.attrs)
    const patchChildren = diffChildren(oldVTree.children, newVTree.children)

    return $node => {
        patchAttrs($node);
        patchChildren($node);
        return $node
    }
}

export default diff
复制代码
  • 改写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',
        dataCout: 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(2);
const $app = render(vApp);
let $rootEl = mount($app, document.getElementById('app'));

setInterval(() => {
     const n = Math.floor(Math.random() * 10)
     const vNewApp = createVApp(n)
     const patch = diff(vApp, vNewApp)

     $rootEl = patch($rootEl);
     vApp = vNewApp
}, 1000);
console.log($app);
复制代码

参考链接

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值