文章封面

vdom 原理解析与简单实现

发布于 2020-03-16 18:37:05阅读量 694

0. 什么是 vnode

相信大部分前端同学之前早已无数次听过或了解过 vnode(虚拟节点),那么什么是 vnode? vnode 应该是什么样的? 如果不使用前端框架,我们可能会写出这样的页面:

<html>
  <head>
    <title></title>
  </head>
  <body>
    <div></div>
    <script></script>
  </body>
</html>

不难发现,整个文档树的根节点只有一个 html,然后嵌套各种子标签,如果使用某种数据结构来表示这棵树,那么它可能是这样。

{
  tagName: 'html',
  children: [
    {
      tagName: 'head',
      children: [
        {
          tagName: 'title'
        }
      ]
    },

    {
      tagName: 'body',
      children: [
        {
          tagName: 'div'
        },

        {
          tagName: 'script'
        }
      ]
    }
  ]
}

但是实际开发中,整个文档树中headscript 标签基本不会有太大的改动。频繁交互可能改动的应当是 body 里面的除 script 的部分,所以构建 虚拟节点树 应当是整个 HTML 文档树的一个子树,而这个子树应当保持和 HTML 文档树一致的数据结构。它可能是这样。

<html>
  <head>
    <title></title>
  </head>
  <body>
    <div id="root">
      <div class="header"></div>
      <div class="main"></div>
      <div class="footer"></div>
    </div>
    <script></script>
  </body>
</html>

这里应当构建的 虚拟节点树 应当是 div#root 这棵子树:

{
  tagName: 'div',
  children: [
    {
      tagName: 'div',
    },
    {
      tagName: 'div',
    },
    {
      tagName: 'div',
    },
  ]
}

到这里,vnode 的概念应当很清晰了,vnode 是用来表示实际 dom 节点的一种数据结构,其结构大概长这样。

{
  tagName: 'div',
  attrs: {
    class: 'header'
  },
  children: []
}

一般,我们可能会这样定义 vnode

// vnode.js
export const vnode = function vnode() {}

1. 从 JSX 到 vnode

使用 React 会经常写 JSX,那么如何将 JSX 表示成 vnode?这里可以借助 @babel/plugin-transform-react-jsx 这个插件来自定义转换函数, 只需要在 .babelrc 中配置:

{
  "plugins": [
    [
      "@babel/plugin-transform-react-jsx",
      {
        "pragma": "window.h"
      }
    ]
  ]
}

然后在 window 对象上挂载一个 h 函数:

// h.js
const flattern = arr => [].concat.apply([], arr)

window.h = function h(tagName, attrs, ...children) {
  const node = new vnode()
  node.tagName = tagName
  node.attrs = attrs || {}
  node.children = flattern(children)

  return node
}

测试一下:

jsx->vnode

2. 渲染 vnode

现在我们已经知道了如何构建 vnode,接下来就是将其渲染成真正的 dom 节点并挂载。

// 将 vnode 创建为真正的 dom 节点
export function createElement(vnode) {
  if (typeof vnode !== 'object') {
    // 文本节点
    return document.createTextNode(vnode)
  }

  const el = document.createElement(vnode.tagName)
  setAttributes(el, vnode.attrs)
  vnode.children.map(createElement).forEach(el.appendChild.bind(el))
  return el
}

// render.js
export default function render(vnode, parent) {
  parent = typeof parent === 'string' ? document.querySelector(parent) : parent
  return parent.appendChild(createElement(vnode))
}

这里的逻辑主要为:

  1. 根据 vnode.tagName 创建元素
  2. 根据 vnode.attrs 设置元素的 attributes
  3. 遍历 vnode.children 并将其创建为真正的元素,然后将真实子元素节点 append 到第 1 步创建的元素

3. diff vnode

第 2 步已经实现了 vnodedom 节点的转换与挂载,那么接下来某一个时刻 dom 节点发生了变化,如何更新 dom树?显然不能无脑卸载整棵树,然后挂载新的树,最好的办法还是找出两棵树之间的差异,然后应用这些差异。

diff-2-vnode

在写 diff 之前,首先要定义好,要 diff 什么,明确 diff 的返回值。比较上图两个 vnode,可以得出:

  1. 更换第 1、2、3li 的内容
  2. ul 下创建两个 li,这两个 li 为 第 4 个第 5 个子节点

那么可能得返回值为:

{
  "type": "UPDATE",
  "children": [
    {
      "type": "UPDATE",
      "children": [
        {
          "type": "REPLACE",
          "newVNode": 0
        }
      ],
      "attrs": []
    },
    {
      "type": "UPDATE",
      "children": [
        {
          "type": "REPLACE",
          "newVNode": 1
        }
      ],
      "attrs": []
    },
    {
      "type": "UPDATE",
      "children": [
        {
          "type": "REPLACE",
          "newVNode": 2
        }
      ],
      "attrs": []
    },
    {
      "type": "CREATE",
      "newVNode": {
        "tagName": "li",
        "attrs": {},
        "children": [
          3
        ]
      }
    },
    {
      "type": "CREATE",
      "newVNode": {
        "tagName": "li",
        "attrs": {},
        "children": [
          4
        ]
      }
    }
  ],
  "attrs": []
}

diff 的过程中,要保证节点的父节点正确,并要保证该节点在父节点 的子节点中的索引正确(保证节点内容正确,位置正确)。diff 的核心流程:

  • case CREATE: 旧节点不存在,则应当新建新节点
  • case REMOVE: 新节点不存在,则移出旧节点
  • case REPLACE: 只比较新旧节点,不比较其子元素,新旧节点标签名或文本内容不一致,则应当替换旧节点
  • case UPDATE: 到这里,新旧节点可能只剩下 attrs 和 子节点未进行 diff,所以直接循环 diffAttrs 和 diffChildren 即可
/**
 * diff 新旧节点差异
 * @param {*} oldVNode
 * @param {*} newVNode
 */
export default function diff(oldVNode, newVNode) {
  if (isNull(oldVNode)) {
    return { type: CREATE, newVNode }
  }

  if (isNull(newVNode)) {
    return { type: REMOVE }
  }

  if (isDiffrentVNode(oldVNode, newVNode)) {
    return { type: REPLACE, newVNode }
  }

  if (newVNode.tagName) {
    return {
      type: UPDATE,
      children: diffVNodeChildren(oldVNode, newVNode),
      attrs: diffVNodeAttrs(oldVNode, newVNode)
    }
  }
}

4. patch 应用更新

知道了两棵树之前的差异,接下来如何应用这些更新?在文章开头部分我们提到 dom 节点树应当只有一个根节点,同时 diff 算法是保证了虚拟节点的位置和父节点是与 dom 树保持一致的,那么 patch 的入口也就很简单了,从 虚拟节点的挂载点开始递归应用更新即可。

/**
 * 根据 diff 结果更新 dom 树
 * 这里为什么从 index = 0 开始?
 * 因为我们是使用树去表示整个 dom 树的,传入的 parent 即为 dom 挂载点
 * 从根节点的第一个节点开始应用更新,这是与整个dom树的结构保持一致的
 * @param {*} parent
 * @param {*} patches
 * @param {*} index
 */
export default function patch(parent, patches, index = 0) {
  if (!patches) {
    return
  }

  parent = typeof parent === 'string' ? document.querySelector(parent) : parent
  const el = parent.childNodes[index]

  /* eslint-disable indent */
  switch (patches.type) {
    case CREATE: {
      const { newVNode } = patches
      const newEl = createElement(newVNode)
      parent.appendChild(newEl)
      break
    }

    case REPLACE: {
      const { newVNode } = patches
      const newEl = createElement(newVNode)
      parent.replaceChild(newEl, el)
      break
    }

    case REMOVE: {
      parent.removeChild(el)
      break
    }

    case UPDATE: {
      const { attrs, children } = patches

      patchAttrs(el, attrs)

      for (let i = 0, len = children.length; i < len; i++) {
        patch(el, children[i], i)
      }

      break
    }
  }
}

总结

至此,vdom 的核心 diffpatch 都已基本实现。在测试 demo 中,不难发现 diff 其实已经很快了,但是 patch 速度会比较慢,所以这里留下了一个待优化的点就是 patch

本文完整代码均在这个仓库

发布时间:2020-03-16 18:37:05

版权信息:非商用-署名-自由转载

推荐阅读

暂无推荐

    评论

    编辑器努力加载中...