虚拟dom_虚拟dom树 - CSDN
精华内容
参与话题
  • 虚拟 DOM (Virtual DOM )这个概念相信大家都不陌生,从 React 到 Vue ,虚拟 DOM 为这两个框架都带来了跨平台的能力(React-Native 和 Weex)。因为很多人是在学习 React 的过程中接触到的虚拟 DOM ,所以为...

    是什么?

    虚拟 DOM (Virtual DOM )这个概念相信大家都不陌生,从 React 到 Vue ,虚拟 DOM 为这两个框架都带来了跨平台的能力(React-Native 和 Weex)。因为很多人是在学习 React 的过程中接触到的虚拟 DOM ,所以为先入为主,认为虚拟 DOM 和 JSX 密不可分。其实不然,虚拟 DOM 和 JSX 固然契合,但 JSX 只是虚拟 DOM 的充分不必要条件,Vue 即使使用模版,也能把虚拟 DOM 玩得风生水起,同时也有很多人通过 babel 在 Vue 中使用 JSX。

    很多人认为虚拟 DOM 最大的优势是 diff 算法,减少 JavaScript 操作真实 DOM 的带来的性能消耗。虽然这一个虚拟 DOM 带来的一个优势,但并不是全部。虚拟 DOM 最大的优势在于抽象了原本的渲染过程,实现了跨平台的能力,而不仅仅局限于浏览器的 DOM,可以是安卓和 IOS 的原生组件,可以是近期很火热的小程序,也可以是各种GUI。

    回到最开始的问题,虚拟 DOM 到底是什么,说简单点,就是一个普通的 JavaScript 对象,包含了 tagpropschildren 三个属性。

    <div id="app">
      <p class="text">hello world!!!</p>
    </div>
    复制代码

    上面的 HTML 转换为虚拟 DOM 如下:

    {
      tag: 'div',
      props: {
        id: 'app'
      },
      chidren: [
        {
          tag: 'p',
          props: {
            className: 'text'
          },
          chidren: [
            'hello world!!!'
          ]
        }
      ]
    }
    复制代码

    该对象就是我们常说的虚拟 DOM 了,因为 DOM 是树形结构,所以使用 JavaScript 对象就能很简单的表示。而原生 DOM 因为浏览器厂商需要实现众多的规范(各种 HTML5 属性、DOM事件),即使创建一个空的 div 也要付出昂贵的代价。虚拟 DOM 提升性能的点在于 DOM 发生变化的时候,通过 diff 算法比对 JavaScript 原生对象,计算出需要变更的 DOM,然后只对变化的 DOM 进行操作,而不是更新整个视图。

    那么我们到底该如何将一段 HTML 转换为虚拟 DOM 呢?

    从 h 函数说起

    观察主流的虚拟 DOM 库(snabbdomvirtual-dom),通常都有一个 h 函数,也就是 React 中的 React.createElement,以及 Vue 中的 render 方法中的 createElement,另外 React 是通过 babel 将 jsx 转换为 h 函数渲染的形式,而 Vue 是使用 vue-loader 将模版转为 h 函数渲染的形式(也可以通过 babel-plugin-transform-vue-jsx 插件在 vue 中使用 jsx,本质还是转换为 h 函数渲染形式)。

    我们先使用 babel,将一段 jsx 代码,转换为一段 js 代码:

    安装 babel 依赖

    npm i -D @babel/cli @babel/core @babel/plugin-transform-react-jsx
    复制代码

    配置 .babelrc

    {
        "plugins": [
            [
                "@babel/plugin-transform-react-jsx",
                {
                    "pragma": "h", // default pragma is React.createElement
                }
            ]
        ]
    }
    复制代码

    转译 jsx

    在目录下新建一个 main.jsx

    function getVDOM() {
      return (
        <div id="app">
          <p className="text">hello world!!!</p>
        </div>
      )
    }
    复制代码

    使用如下命令进行转译:

    npx babel main.jsx --out-file main-compiled.js
    复制代码

    可以看到,最终 HTML 代码会被转译成 h 函数的渲染形式。h 函数接受是三个参数,分别代表是 DOM 元素的标签名、属性、子节点,最终返回一个虚拟 DOM 的对象。

    function h(tag, props, ...children) {
      return {
        tag,
        props: props || {},
        children: children.flat()
      }
    }
    复制代码

    渲染虚拟 DOM

    虽然虚拟 DOM 可以渲染到多个平台,但是这里讲一下在浏览器环境下如何渲染虚拟 DOM。

    function render(vdom) {
      // 如果是字符串或者数字,创建一个文本节点
      if (typeof vdom === 'string' || typeof vdom === 'number') {
        return document.createTextNode(vdom)
      }
      const { tag, props, children } = vdom
      // 创建真实DOM
      const element = document.createElement(tag)
      // 设置属性
      setProps(element, props)
      // 遍历子节点,并获取创建真实DOM,插入到当前节点
      children
        .map(render)
        .forEach(element.appendChild.bind(element))
    
      // 虚拟 DOM 中缓存真实 DOM 节点
      vdom.dom = element
      
      // 返回 DOM 节点
      return element
    }
    
    function setProps (element, props) {
      Object.entries(props).forEach(([key, value]) => {
        setProp(element, key, value)
      })
    }
    
    function setProp (element, key, vlaue) {
      element.setAttribute(
        // className使用class代替
        key === 'className' ? 'class' : key,
        vlaue
      )
    }
    复制代码

    将虚拟 DOM 渲染成真实 DOM 后,只需要插入到对应的根节点即可。

    const vdom = <div>hello world!!!</div> // h('div', {}, 'hello world!!!')
    const app = document.getElementById('app')
    const ele = render(vdom)
    app.appendChild(ele)
    复制代码

    当然在现代化的框架中,一般会有一个组件文件专门用来构造虚拟 DOM,我们模仿 React 使用 class 的方式编写组件,然后渲染到页面中。

    class Component {
      vdom = null // 组件的虚拟DOM表示
      $el  = null // 虚拟DOM生成的真实节点
    
      state = {
        text: 'Initialize the Component'
      }
      
      render() {
        const { text } = this.state
        return (
          <div>{ text }</div>
        )
      }
    }
    
    function createElement (app, component) {
      const vdom = component.render()
      component.vdom = vdom
      component.$el = render(vdom) // 将虚拟 DOM 转换为真实 DOM
      app.appendChild(component.$el)
    }
    
    const app = document.getElementById('app')
    const component = new Component
    createElement(app, component)
    复制代码

    diff 算法

    diff 算法,顾名思义,就是比对新老 VDOM 的变化,然后将变化的部分更新到视图上。对应到代码上,就是一个 diff 函数,返回一个 patches (补丁)。

    const before  = h('div', {}, 'before text')
    const after   = h('div', {}, 'after text')
    
    const patches = diff(before, after)
    复制代码

    修改我们之前的组件,增加 setState 方法,用于修改组件的内部状态。

    class Component {
      vdom = null // 组件的虚拟DOM表示
      $el = null // 虚拟DOM生成的真实节点
      
      state = {
        text: 'Initialize the Component'
      }
      
      // 手动修改组件state
      setState(newState) {
        this.state = {
          ...this.state,
          ...newState
        }
        const newVdom = this.render()
        const patches = diff(this.vdom, newVdom)
        patch(this.$el, patches)
      }
    
      changeText(text) {
        this.setState({
          text
        })
      }
      
      render() {
        const { text } = this.state
        return (
          <div>{ text }</div>
        )
      }
    }
    复制代码

    当我们调用 setState 时,state 内部状态发生变动,再次调用 render 方法就会生成一个新的虚拟 DOM 树,这样我们就能使用 diff 方法计算出新老虚拟 DOM 发送变化的部分,最后使用 patch 方法,将变动渲染到视图中。

    const app = document.getElementById('app')
    const component = new Component
    createElement(app, component)
    
    // 将文本更改为数字,每秒 +1
    let count = 0
    setInterval(() => {
      component.changeText(++count)
    }, 1000);
    复制代码

    diff 算法的进化

    关于 diff 算法的最经典的就是 Matt Esch 的 virtual-dom,以及 snabbdom(被整合进 vue 2.0中)。

    最开始出现的是 virtual-dom 这个库,是大家好奇 React 为什么这么快而搞鼓出来的。它的实现是非常学院风格,通过深度优先搜索与 in-order tree 来实现高效的 diff 。它与 React 后来公开出来的算法是很不一样。 然后是 cito.js 的横空出世,它对今后所有虚拟 DOM 的算法都有重大影响。它采用两端同时进行比较的算法,将 diff 速度拉高到几个层次。 紧随其后的是 kivi.js,在 cito.js 的基出提出两项优化方案,使用 key 实现移动追踪以及及基于 key 的最长自增子序列算法应用(算法复杂度 为O(n^2))。 但这样的 diff 算法太过复杂了,于是后来者 snabbdom 将 kivi.js 进行简化,去掉编辑长度矩离算法,调整两端比较算法。速度略有损失,但可读性大大提高。再之后,就是著名的vue2.0 把sanbbdom整个库整合掉了。

    引用自司徒正美的文章 去哪儿网迷你React的研发心得

    下面我们就来讲讲这几个虚拟 DOM 库 diff 算法的具体实现:

    1️⃣ virtual-dom

    virtual-dom 作为虚拟 DOM 开天辟地的作品,采用了对 DOM 树进行了深度优先的遍历的方法。

    DOM 树的遍历

    体现到代码上:

    function diff (oldNode, newNode) {
      const patches = []
      walk(oldNode, newNode, patches, 0) // 进行深度优先遍历
      return patches
    }
    
    function walk(oldNode, newNode, patches, index) {
      if (newNode === oldNode) {
        return
      }
      
      const patch = { type: 'update', vNode: newNode }
      
      const oldChildren = oldNode.children
      const newChildren = newNode.children
      const oldLen = oldChildren.length
      const newLen = newChildren.length
      const len = oldLen > newLen ? oldLen : newLen
      // 找到对应位置的子节点进行比对
      for (let i = 0; i < len; i++) {
        const oldChild = oldChildren[i]
        const newChild = newChildren[i]
        index++
        // 相同节点进行比对
        walk(oldChild, newChild, patches, index)
        if (isArray(oldChild.children)) {
          index += oldChild.children.length
        }
      }
      
      if (patch) {
        patches[index] = patch
      }
    }
    复制代码

    VDOM 节点的对比

    上面代码只是对 VDOM 进行了简单的深度优先遍历,在遍历中,还需要对每个 VDOM 进行一些对比,具体分为以下几种情况:

    1. 旧节点不存在,插入新节点;新节点不存在,删除旧节点
    2. 新旧节点如果都是 VNode,且新旧节点 tag 相同
      • 对比新旧节点的属性
      • 对比新旧节点的子节点差异,通过 key 值进行重排序,key 值相同节点继续向下遍历
    3. 新旧节点如果都是 VText,判断两者文本是否发生变化
    4. 其他情况直接用新节点替代旧节点
    import { isVNode, isVText, isArray } from '../utils/type'
    
    function walk(oldNode, newNode, patches, index) {
      if (newNode === oldNode) {
        return
      }
    
      let patch = patches[index]
    
      if (!oldNode) {
        // 旧节点不存在,直接插入
        patch = appendPatch(patch, {
          type: PATCH.INSERT,
          vNode: newNode,
        })
      } else if (!newNode) {
        // 新节点不存在,删除旧节点
        patch = appendPatch(patch, {
          type: PATCH.REMOVE,
          vNode: null,
        })
      } else if (isVNode(newNode)) {
        if (isVNode(oldNode)) {
          // 相同类型节点的 diff
          if (newNode.tag === oldNode.tag && newNode.key === oldNode.key) {
            // 新老节点属性的对比
            const propsPatch = diffProps(newNode.props, oldNode.props)
            if (propsPatch && propsPatch.length > 0) {
              patch = appendPatch(patch, {
                type: PATCH.PROPS,
                patches: propsPatch,
              })
            }
            // 新老节点子节点的对比
            patch = diffChildren(oldNode, newNode, patches, patch, index)
          }
        } else {
          // 新节点替换旧节点
          patch = appendPatch(patch, {
            type: PATCH.REPLACE,
            vNode: newNode,
          })
        }
      } else if (isVText(newNode)) {
        if (!isVText(oldNode)) {
          // 将旧节点替换成文本节点
          patch = appendPatch(patch, {
            type: PATCH.VTEXT,
            vNode: newNode,
          })
        } else if (newNode.text !== oldNode.text) {
          // 替换文本
          patch = appendPatch(patch, {
            type: PATCH.VTEXT,
            vNode: newNode,
          })
        }
      }
    
      if (patch) {
        // 将补丁放入对应位置
        patches[index] = patch
      }
    }
    
    // 一个节点可能有多个 patch
    // 多个patch时,使用数组进行存储
    function appendPatch(patch, apply) {
      if (patch) {
        if (isArray(patch)) {
          patch.push(apply)
        } else {
          patch = [patch, apply]
        }
    
        return patch
      } else {
        return apply
      }
    }
    复制代码

    属性的对比

    function diffProps(newProps, oldProps) {
      const patches = []
      const props = Object.assign({}, newProps, oldProps)
    
      Object.keys(props).forEach(key => {
        const newVal = newProps[key]
        const oldVal = oldProps[key]
        if (!newVal) {
          patches.push({
            type: PATCH.REMOVE_PROP,
            key,
            value: oldVal,
          })
        }
    
        if (oldVal === undefined || newVal !== oldVal) {
          patches.push({
            type: PATCH.SET_PROP,
            key,
            value: newVal,
          })
        }
      })
    
      return patches
    }
    复制代码

    子节点的对比

    这一部分可以说是 diff 算法中,变动最多的部分,因为前面的部分,各个库对比的方向基本一致,而关于子节点的对比,各个仓库都在前者基础上不断得进行改进。

    首先需要明白,为什么需要改进子节点的对比方式。如果我们直接按照深度优先遍历的方式,一个个去对比子节点,子节点的顺序发生改变,那么就会导致 diff 算法认为所有子节点都需要进行 replace,重新将所有子节点的虚拟 DOM 转换成真实 DOM,这种操作是十分消耗性能的。

    但是,如果我们能够找到新旧虚拟 DOM 对应的位置,然后进行移动,那么就能够尽量减少 DOM 的操作。

    virtual-dom 在一开始就进行了这方面的尝试,对子节点添加 key 值,通过 key 值的对比,来判断子节点是否进行了移动。通过 key 值对比子节点是否移动的模式,被各个库沿用,这也就是为什么主流的视图库中,子节点如果缺失 key 值,会有 warning 的原因。

    具体是怎么对比的,我们先看代码:

    function diffChildren(oldNode, newNode, patches, patch, index) {
      const oldChildren = oldNode.children
      // 新节点按旧节点的顺序重新排序
      const sortedSet = sortChildren(oldChildren, newNode.children)
      const newChildren = sortedSet.children
      const oldLen = oldChildren.length
      const newLen = newChildren.length
      const len = oldLen > newLen ? oldLen : newLen
      for (let i = 0; i < len; i++) {
        var leftNode = oldChildren[i]
        var rightNode = newChildren[i]
        index++
    
        if (!leftNode) {
          if (rightNode) {
            // 旧节点不存在,新节点存在,进行插入操作
            patch = appendPatch(patch, {
              type: PATCH.INSERT,
              vNode: rightNode,
            })
          }
        } else {
          // 相同节点进行比对
          walk(leftNode, rightNode, patches, index)
        }
        if (isVNode(leftNode) && isArray(leftNode.children)) {
          index += leftNode.children.length
        }
      }
    
      if (sortedSet.moves) {
        // 最后进行重新排序
        patch = appendPatch(patch, {
          type: PATCH.ORDER,
          moves: sortedSet.moves,
        })
      }
    
      return patch
    }
    复制代码

    这里首先需要对新的子节点进行重排序,先进行相同节点的 diff ,最后把子节点按照新的子节点顺序重新排列。

    这里有个较复杂的部分,就是对子节点的重新排序。

    function sortChildren(oldChildren, newChildren) {
      // 找出变化后的子节点中带 key 的 vdom (keys),和不带 key 的 vdom (free)
      const newChildIndex = keyIndex(newChildren)
      const newKeys = newChildIndex.keys
      const newFree = newChildIndex.free
    
      // 所有子节点无 key 不进行对比
      if (newFree.length === newChildren.length) {
        return {
          children: newChildren,
          moves: null,
        }
      }
    
      // 找出变化前的子节点中带 key 的 vdom (keys),和不带 key 的 vdom (free)
      const oldChildIndex = keyIndex(oldChildren)
      const oldKeys = oldChildIndex.keys
      const oldFree = oldChildIndex.free
    
      // 所有子节点无 key 不进行对比
      if (oldFree.length === oldChildren.length) {
        return {
          children: newChildren,
          moves: null,
        }
      }
    
      // O(MAX(N, M)) memory
      const shuffle = []
    
      const freeCount = newFree.length
      let freeIndex = 0
      let deletedItems = 0
    
      // 遍历变化前的子节点,对比变化后子节点的 key 值
      // 并按照对应顺序将变化后子节点的索引放入 shuffle 数组中
      for (let i = 0; i < oldChildren.length; i++) {
        const oldItem = oldChildren[i]
        let itemIndex
    
        if (oldItem.key) {
          if (newKeys.hasOwnProperty(oldItem.key)) {
            // 匹配到变化前节点中存在的 key
            itemIndex = newKeys[oldItem.key]
            shuffle.push(newChildren[itemIndex])
          } else {
            // 移除变化后节点不存在的 key 值
            deletedItems++
            shuffle.push(null)
          }
        } else {
          if (freeIndex < freeCount) {
            // 匹配变化前后的无 key 子节点
            itemIndex = newFree[freeIndex++]
            shuffle.push(newChildren[itemIndex])
          } else {
            // 如果变化后子节点中已经不存在无 key 项
            // 变化前的无 key 项也是多余项,故删除
            deletedItems++
            shuffle.push(null)
          }
        }
      }
    
      const lastFreeIndex =
        freeIndex >= newFree.length ? newChildren.length : newFree[freeIndex]
    
      // 遍历变化后的子节点,将所有之前不存在的 key 对应的子节点放入 shuffle 数组中
      for (let j = 0; j < newChildren.length; j++) {
        const newItem = newChildren[j]
        if (newItem.key) {
          if (!oldKeys.hasOwnProperty(newItem.key)) {
            // 添加所有新的 key 值对应的子节点
            // 之后还会重新排序,我们会在适当的地方插入新增节点
            shuffle.push(newItem)
          }
        } else if (j >= lastFreeIndex) {
          // 添加剩余的无 key 子节点
          shuffle.push(newItem)
        }
      }
    
      const simulate = shuffle.slice()
      const removes = []
      const inserts = []
      let simulateIndex = 0
      let simulateItem
      let wantedItem
    
      for (let k = 0; k < newChildren.length; ) {
        wantedItem = newChildren[k] // 期待元素: 表示变化后 k 的子节点
        simulateItem = simulate[simulateIndex] // 模拟元素: 表示变化前 k 位置的子节点
    
        // 删除在变化后不存在的子节点
        while (simulateItem === null && simulate.length) {
          removes.push(remove(simulate, simulateIndex, null))
          simulateItem = simulate[simulateIndex]
        }
    
        if (!simulateItem || simulateItem.key !== wantedItem.key) {
          // 期待元素的 key 值存在
          if (wantedItem.key) {
            if (simulateItem && simulateItem.key) {
              // 如果一个带 key 的子元素没有在合适的位置,则进行移动
              if (newKeys[simulateItem.key] !== k + 1) {
                removes.push(remove(simulate, simulateIndex, simulateItem.key))
                simulateItem = simulate[simulateIndex]
                // if the remove didn't put the wanted item in place, we need to insert it
                if (!simulateItem || simulateItem.key !== wantedItem.key) {
                  inserts.push({ key: wantedItem.key, to: k })
                }
                // items are matching, so skip ahead
                else {
                  simulateIndex++
                }
              } else {
                inserts.push({ key: wantedItem.key, to: k })
              }
            } else {
              inserts.push({ key: wantedItem.key, to: k })
            }
            k++
          }
          // 该位置期待元素的 key 值不存在,且模拟元素存在 key 值
          else if (simulateItem && simulateItem.key) {
            // 变化前该位置的元素
            removes.push(remove(simulate, simulateIndex, simulateItem.key))
          }
        } else {
          // 如果期待元素和模拟元素 key 值相等,跳到下一个子节点比对
          simulateIndex++
          k++
        }
      }
    
      // 移除所有的模拟元素
      while (simulateIndex < simulate.length) {
        simulateItem = simulate[simulateIndex]
        removes.push(
          remove(simulate, simulateIndex, simulateItem && simulateItem.key)
        )
      }
    
      // 如果只有删除选项中有值
      // 将操作直接交个 delete patch
      if (removes.length === deletedItems && !inserts.length) {
        return {
          children: shuffle,
          moves: null,
        }
      }
    
      return {
        children: shuffle,
        moves: {
          removes: removes,
          inserts: inserts,
        },
      }
    }
    
    
    function keyIndex(children) {
      const keys = {}
      const free = []
      const length = children.length
    
      for (let i = 0; i < length; i++) {
        const child = children[i]
    
        if (child.key) {
          keys[child.key] = i
        } else {
          free.push(i)
        }
      }
    
      return {
        keys: keys, // 子节点中所有存在的 key 对应的索引
        free: free, // 子节点中不存在 key 值的索引
      }
    }
    
    function remove(arr, index, key) {
      arr.splice(index, 1) // 移除数组中指定元素
    
      return {
        from: index,
        key: key,
      }
    }
    复制代码

    这一部分比较复杂,具体可以查看 virtual-dom 的两个 pr ,这两个 pr 里面讨论了关于 diff 子节点重新排序的优化逻辑。

    更新 DOM

    在拿到了 VDOM 的 diff 结果后,需要将得到的 patches 更新到视图上。

    function patch(rootNode, patches) {
      if (!patches || patches.length === 0) return
      // 取得对应 index 的真实 DOM
      const nodes = domIndex(rootNode)
      patches.forEach((patch, index) => {
        patch && applyPatch(nodes[index], patch)
      })
    }
    
    function domIndex(rootNode) {
      const nodes = [rootNode]
      const children = rootNode.childNodes
      if (children.length) {
        for (let child of children) {
          if (child.nodeType === 1 || child.nodeType === 3) {
            if (child.nodeType === 1) {
              nodes.push(...domIndex(child))
            } else if (child.nodeType === 3) {
              nodes.push(child)
            }
          }
        }
      }
      return nodes
    }
    复制代码

    遍历patches,然后得到每个真实 DOM 和其对应的 patch,然后在真实 DOM 上进行更新:

    function applyPatch(node, patchList) {
      for (let patch of patchList) {
        patchOp(node, patch)
      }
    }
    function patchOp(node, patch) {
      const { type, vNode } = patch
      const parentNode = node.parentNode
      let newNode = null
      switch (type) {
        case PATCH.INSERT:
          // 插入新节点
          break
        case PATCH.REMOVE:
          // 删除旧新节点
          break
        case PATCH.REPLACE:
          // 替换节点
          break
        case PATCH.ORDER:
          // 子节点重新排序
          break
        case PATCH.VTEXT:
          // 替换文本节点
          break
        case PATCH.PROPS:
          // 更新节点属性
          break
        default:
          break
      }
    }
    复制代码

    这里每一步操作,不进行具体展开,感兴趣的话可以在我的 github 查看完整代码

    2️⃣ cito.js

    cito 其他步骤与 virtual-dom 类似,最大的差异点就在子节点的对比上,而且 cito 移除了 patch 更新,在 diff 的过程中,直接更新真实 DOM ,这样省去了 patch 的存储,一定程度上节省了内存,后面其他的 VDOM 库基本使用这种方式。

    我们再来看看 cito 在子节点的对比上,到底有何优化?

    其实前面我们已经介绍过了,cito 主要变化就是引入了两端对比,将 diff 算法的速度提升了几个量级。

    /**
     * 子节点对比
     * @param {Element} domNode   父节点的真实DOM
     * @param {Array} oldChildren 旧的子节点
     * @param {Array} children    新的子节点
     */
    function updateChildren(domNode, oldChildren, children) {
      const oldChildrenLength = oldChildren.length
      const childrenLength = children.length
      
      let oldEndIndex = oldChildrenLength - 1
      let endIndex = childrenLength - 1
      let oldStartIndex = 0
      let startIndex = 0
      let successful = true
      let nextChild
      
      // 两端对比算法
      outer: while (
        successful &&
        oldStartIndex <= oldEndIndex &&
        startIndex <= endIndex
      ) {
        successful = false
        let oldStartChild = oldChildren[oldStartIndex]
        let startChild = children[startIndex]
        while (oldStartChild.key === startChild.key) {
          // 子节点对比
          updateNode(oldStartChild, startChild, domNode)
          oldStartIndex++
          startIndex++
          if (oldStartIndex > oldEndIndex || startIndex > endIndex) {
            break outer
          }
          oldStartChild = oldChildren[oldStartIndex]
          startChild = children[startIndex]
          successful = true
        }
        let oldEndChild = oldChildren[oldEndIndex]
        let endChild = children[endIndex]
        while (oldEndChild.key === endChild.key) {
          // 子节点对比
          updateNode(oldEndChild, endChild, domNode)
          oldEndIndex--
          endIndex--
          if (oldStartIndex > oldEndIndex || startIndex > endIndex) {
            break outer
          }
          oldEndChild = oldChildren[oldEndIndex]
          endChild = children[endIndex]
          successful = true
        }
    
        while (oldStartChild.key === endChild.key) {
          nextChild = endIndex + 1 < childrenLength ? children[endIndex + 1] : null
          // 子节点对比
          updateNode(oldStartChild, endChild, domNode)
          // 移动子节点
          moveChild(domNode, endChild, nextChild)
          oldStartIndex++
          endIndex--
          if (oldStartIndex > oldEndIndex || startIndex > endIndex) {
            break outer
          }
          oldStartChild = oldChildren[oldStartIndex]
          endChild = children[endIndex]
          successful = true
        }
        while (oldEndChild.key === startChild.key) {
          nextChild = oldStartIndex < oldChildrenLength ? oldChildren[oldStartIndex] : null
          // 子节点对比
          updateNode(oldEndChild, startChild, domNode)
          // 移动子节点
          moveChild(domNode, startChild, nextChild)
          oldEndIndex--
          startIndex++
          if (oldStartIndex > oldEndIndex || startIndex > endIndex) {
            break outer
          }
          oldEndChild = oldChildren[oldEndIndex]
          startChild = children[startIndex]
          successful = true
        }
      }
    }
    复制代码

    子节点对比:

    function updateNode(oldNode, node, domParent) {
      if (node === oldNode) {
        return
      }
    
      const tag = node.tag
    
      if (oldNode.tag !== tag) {
        // 标签不一致,创建新节点
        createNode(node, domParent, oldNode, true)
      } else {
        const oldChildren = oldNode.children
        const children = node.children
        const domNode = oldNode.dom
        node.dom = domNode // 真实 DOM 挂在到 虚拟 DOM 上
        // 子节点对比
        if (children !== oldChildren) {
          updateChildren(domNode, node, oldChildren, children)
        }
    
        const oldProps = oldNode.props
        const props = node.props
        // 属性对比
        if (props !== oldProps) {
          updateAttributes(domNode, props, oldProps)
        }
      }
    }
    复制代码

    移动子节点:

    function moveChild(domNode, child, nextChild) {
      const domRefChild = nextChild && nextChild.dom
      let domChild = child.dom
      if (domChild !== domRefChild) {
        if (domRefChild) {
          domNode.insertBefore(domChild, domRefChild)
        } else {
          domNode.appendChild(domChild)
        }
      }
    }
    复制代码

    3️⃣ kivi.js

    kivi 的 diff 算法在 cito 的基础上,引入了最长增长子序列,通过子序列找到最小的 DOM 操作数。

    算法思想

    翻译自 kivi/lib/reconciler.ts

    该算法用于找到最小的 DOM 操作数,可以分为以下几步:

    1. 找到数组中首部和尾部公共的节点,并在两端移动

    该方法通过比对两端的 key 值,找到旧节点(A) 和新节点(B)中索引相同的节点。

      A: -> [a b c d e f g] <-
      B:    [a b f d c g]
    复制代码

    这里我们可以跳过首部的 ab,以及尾部的 g

      A: -> [c d e f] <-
      B:    [f d c]
    复制代码

    此时,将尝试对边进行比较,如果在对边有一个 key 值相同的节点,将执行简单的移动操作,将 c 节点移动到 右边缘,将 f 节点移动到左边缘。

      A: -> [d e] <-
      B:    [d]
    复制代码

    现在将再次尝试查找公共的首部与尾部,发现 d 节点是相同的,我们跳过它。

      A: -> [e] <-
      B:    [ ]
    复制代码

    然后检查各个列表的长度是否为0,如果旧节点列表长度为0,将插入新节点列表的剩余节点,或者新节点列表长度为0,将删除所有旧节点列表中的元素。

    这个简单的算法适用于大多数的实际案例,比如仅仅反转了列表。

    当列表无法利用该算法找到解的时候,会使用下一个算法,例如:

      A: -> [a b c d e f g] <-
      B:    [a c b h f e g]
    复制代码

    边缘的 ag 节点相同,跳过他们。

      A: -> [b c d e f] <-
      B:    [c b h f e]
    复制代码

    然后上面的算法行不通了,我们需要进入下一步。

    2. 查找需要删除或者插入的节点,并且某个节点是否需要移动

    我们先创建一个数组 P,长度为新子节点列表的长度,并为数组每个元素赋值 -1 ,它表示新子节点应该插入的位置。稍后,我们将把旧子节点中的节点位置分配给这个数组。

      A: [b c d e f]
      B: [c b h f e]
      P: [. . . . .] // . == -1
    复制代码

    然后,我们构建一个对象 I,它的键表示新子节点的 key 值,值为子节点在剩余节点数组中的位置。

      A: [b c d e f]
      B: [c b h f e]
      P: [. . . . .] // . == -1
      I: {
        c: 0,
        b: 1,
        h: 2,
        f: 3,
        e: 4,
      }
      last = 0
    复制代码

    我们开始遍历旧子节点列表的剩余节点,并检查是否可以在 I 对象的索引中找到具有相同 key 值的节点。如果找不到任何节点,则将它删除,否则,我们将节点在旧节点列表位置分配给数组 P

      A: [b c d e f]
          ^
      B: [c b h f e]
      P: [. 0 . . .] // . == -1
      I: {
        c: 0,
        b: 1, <-
        h: 2,
        f: 3,
        e: 4,
      }
      last = 1
    复制代码

    当我们为数组 P 分配节点位置时,我们会保留上一个节点在新子节点列表中的位置,如果当一个节点的位置大于当前节点的位置,那么我们将 moved 变量置为 true

      A: [b c d e f]
            ^
      B: [c b h f e]
      P: [1 0 . . .] // . == -1
      I: {
        c: 0, <-
        b: 1,
        h: 2,
        f: 3,
        e: 4,
      }
      last = 1 // last > 0; moved = true
    复制代码

    上一个节点 b位置为 “1”,当前节点 c 的位置 “0”,所以将 moved 变量置为 true

      A: [b c d e f]
              ^
      B: [c b h f e]
      P: [1 0 . . .] // . == -1
      I: {
        c: 0,
        b: 1,
        h: 2,
        f: 3,
        e: 4,
      }
      moved = true
    复制代码

    对象 I 索引中不存在 d,则删除该节点

      A: [b c d e f]
                ^
      B: [c b h f e]
      P: [1 0 . . 3] // . == -1
      I: {
        c: 0,
        b: 1,
        h: 2,
        f: 3,
        e: 4, <-
      }
      moved = true
    复制代码

    为节点 e 分配位置。

      A: [b c d e f]
                  ^
      B: [c b h f e]
      P: [1 0 . 4 3] // . == -1
      I: {
        c: 0,
        b: 1,
        h: 2,
        f: 3, <-
        e: 4,
      }
      moved = true
    复制代码

    为节点 f 分配位置。

    此时,我们检查 moved 标志是否被打开,或者旧子节点列表的长度减去已删除节点的数量不等于新子节点列表的长度。如果其中任何一个条件为真,我们则进入下一步。

    3. 如果 moved 为真,查找最小移动数,如果长度发送变化,则插入新节点。

    如果 moved 为真,我们需要在 P 数组中找到 最长自增子序列,并移动不属于这个子序列的所有节点。

      A: [b c d e f]
      B: [c b h f e]
      P: [1 0 . 4 3] // . == -1
      LIS:     [1 4]
      moved = true
    复制代码

    现在我们需要同时从尾端遍历新的子节点列表以及最长自增子序列(后面简称 LIS),并检查当前位置是否等于 LIS 的值。

      A: [b c d e f]
      B: [c b h f e]
                  ^  // new_pos == 4
      P: [1 0 . 4 3] // . == -1
      LIS:     [1 4]
                  ^  // new_pos == 4
      moved = true
    复制代码

    节点 e 保持当前位置

      A: [b c d e f]
      B: [c b h f e]
                ^    // new_pos == 3
      P: [1 0 . 4 3] // . == -1
      LIS:     [1 4]
                ^    // new_pos != 1
      moved = true
    复制代码

    移动节点 f,移动到下一个节点 e 前面它。

      A: [b c d e f]
      B: [c b h f e]
              ^      // new_pos == 2
      P: [1 0 . 4 3] // . == -1
              ^      // old_pos == -1
      LIS:     [1 4]
                ^
      moved = true
    复制代码

    节点 h 在数组 P 中为 -1 ,则表示插入新节点 h

      A: [b c d e f]
      B: [c b h f e]
            ^        // new_pos == 1
      P: [1 0 . 4 3] // . == -1
      LIS:     [1 4]
                ^    // new_pos == 1
      moved = true
    复制代码

    节点 b 保持当前位置

      A: [b c d e f]
      B: [c b h f e]
          ^          // new_pos == 0
      P: [1 0 . 4 3] // . == -1
      LIS:     [1 4]
              ^      // new_pos != undefined
      moved = true
    复制代码

    移动节点 c ,移动到下一个节点 b 前面它。

    如果 movedfalse 时,我们不需要查找LIS,我们只需遍历新子节点列表,并检查它在数组 P 中的位置,如果是 -1 ,则插入新节点。

    关于 kivi

    kivi 是作者对虚拟 DOM 性能提升的一些猜想,一开始它就向着性能出发,所有它在实现上代码可能并不优雅,而且它的 api 也十分不友好。而接下来的 snabbdom 就在 kivi 的基础上,大大提升了代码的可读性,很多讲述虚拟 DOM 的文章也将 snabbdom 作为案例。

    另外,kivi 的作者也创建了另一个 源码以及 api 更友好的仓库:ivi,感兴趣可以了解一下。

    4️⃣ snabbdom

    snabbdom 的优势就是代码的可读性大大提升,并且也引入了两端对比,diff 速度也不慢。

    我们可以简单看下 snabbdom 的两端对比算法的核心代码:

    /**
     * 子节点对比
     * @param {Element} parentElm   父节点的真实DOM
     * @param {Array} oldCh 旧的子节点
     * @param {Array} newCh 新的子节点
     */
    function updateChildren(parentElm, oldCh, newCh) {
      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
      let idxInOld
      let elmToMove
      let before
    
      while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // 跳过两端不存在的旧节点
        if (oldStartVnode == null) {
          oldStartVnode = oldCh[++oldStartIdx]
        } else if (oldEndVnode == null) {
          oldEndVnode = oldCh[--oldEndIdx]
        }
        // 跳过两端不存在的新节点
        else if (newStartVnode == null) {
          newStartVnode = newCh[++newStartIdx]
        } else if (newEndVnode == null) {
          newEndVnode = newCh[--newEndIdx]
        }
        /* 
        ** 进行两端对比,分为四种状况:
        ** 1. oldStart <=>  newStart
        ** 2. oldEnd   <=>  newEnd
        ** 3. oldStart <=>  newEnd
        ** 4. oldEnd   <=>  newStart
        */
        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)) {
          patchVnode(oldStartVnode, newEndVnode)
          insertBefore(parentElm, oldStartVnode.dom, oldEndVnode.dom.nextSibling)
          oldStartVnode = oldCh[++oldStartIdx]
          newEndVnode = newCh[--newEndIdx]
        } else if (sameVnode(oldEndVnode, newStartVnode)) {
          // Vnode moved left
          patchVnode(oldEndVnode, newStartVnode)
          insertBefore(parentElm, oldEndVnode.dom, oldStartVnode.dom)
          oldEndVnode = oldCh[--oldEndIdx]
          newStartVnode = newCh[++newStartIdx]
        } 
        // 上面四种情况都不存在,通过 key 值查找对应 VDOM 进行对比
        else {
          // 构造旧子节点的 map 表 (key => vdom)
          if (oldKeyToIdx === undefined) {
            oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
          }
          idxInOld = oldKeyToIdx[newStartVnode.key]
          // 如果新的子节点在旧子节点不存在,进行插入操作
          if (idxInOld === undefined) {
            insertBefore(parentElm, render(newStartVnode), oldStartVnode.dom)
            newStartVnode = newCh[++newStartIdx]
          }
          // 如果新的子节点在旧子节点存在,进行对比
          else {
            elmToMove = oldCh[idxInOld]
            if (elmToMove.sel !== newStartVnode.sel) {
              // key 值相同,但是 tag 不同,重新生成节点并替换
              insertBefore(parentElm, render(newStartVnode), oldStartVnode.dom)
            } else {
              patchVnode(elmToMove, newStartVnode)
              oldCh[idxInOld] = undefined // 该位置已经对比,进行置空
              insertBefore(parentElm, elmToMove.dom, oldStartVnode.dom)
            }
            newStartVnode = newCh[++newStartIdx]
          }
        }
      }
      // 处理一些未处理到的节点
      if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
        if (oldStartIdx > oldEndIdx) {
          before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].dom
          addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
        } else {
          removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
        }
      }
    }
    复制代码

    关于 snabbdom ,网上有太多教程来分析它的 diff 过程了,不管是虚拟 DOM 的教程,还是 Vue 的源码分析,这里就不再详细讲述了。但是可以明显的看到,snabbdom 的 diff 算法是有 cito 和 kivi 的影子在的。

    总结

    毋庸置疑虚拟 DOM 带给前端的意义是非凡的,虚拟 DOM 在现如今还有更多新鲜的玩法。 比如 omi 将虚拟 DOM 与 Web Component 的结合,还有 TaroChameleon 带来的多端统一的能力。

    另外,文中相关的代码都可以在我的 github 查看,这篇文章更多是对自己学习的一个记录,如果有什么错误的观点,欢迎进行指正。

    转载于:https://juejin.im/post/5d085ce85188255e1305cda1

    展开全文
  • 全面理解虚拟DOM,实现虚拟DOM

    万次阅读 2016-05-01 22:44:08
    最近一两年前端最火的技术莫过于ReactJS,即便你没用过也该听过,ReactJS由业界顶尖的互联网公司facebook提出,其本身有很多先进的设计思路,比如页面UI组件化、虚拟DOM等。本文将带你解开虚拟DOM的神秘面纱,不仅要...

    最近一两年前端最火的技术莫过于ReactJS,即便你没用过也该听过,ReactJS由业界顶尖的互联网公司facebook提出,其本身有很多先进的设计思路,比如页面UI组件化、虚拟DOM等。本文将带你解开虚拟DOM的神秘面纱,不仅要理解其原理,而且要实现一个基本可用的虚拟DOM。

    1.为什么需要虚拟DOM

    DOM是很慢的,其元素非常庞大,页面的性能问题鲜有由JS引起的,大部分都是由DOM操作引起的。如果对前端工作进行抽象的话,主要就是维护状态和更新视图;而更新视图和维护状态都需要DOM操作。其实近年来,前端的框架主要发展方向就是解放DOM操作的复杂性。

    在jQuery出现以前,我们直接操作DOM结构,这种方法复杂度高,兼容性也较差;有了jQuery强大的选择器以及高度封装的API,我们可以更方便的操作DOM,jQuery帮我们处理兼容性问题,同时也使DOM操作变得简单;但是聪明的程序员不可能满足于此,各种MVVM框架应运而生,有angularJS、avalon、vue.js等,MVVM使用数据双向绑定,使得我们完全不需要操作DOM了,更新了状态视图会自动更新,更新了视图数据状态也会自动更新,可以说MMVM使得前端的开发效率大幅提升,但是其大量的事件绑定使得其在复杂场景下的执行性能堪忧;有没有一种兼顾开发效率和执行效率的方案呢?ReactJS就是一种不错的方案,虽然其将JS代码和HTML代码混合在一起的设计有不少争议,但是其引入的Virtual DOM(虚拟DOM)却是得到大家的一致认同的。

    2.理解虚拟DOM

    虚拟的DOM的核心思想是:对复杂的文档DOM结构,提供一种方便的工具,进行最小化地DOM操作。这句话,也许过于抽象,却基本概况了虚拟DOM的设计思想

    (1) 提供一种方便的工具,使得开发效率得到保证
    (2) 保证最小化的DOM操作,使得执行效率得到保证

    (1).用JS表示DOM结构

    DOM很慢,而javascript很快,用javascript对象可以很容易地表示DOM节点。DOM节点包括标签、属性和子节点,通过VElement表示如下。

    //虚拟dom,参数分别为标签名、属性对象、子DOM列表
    var VElement = function(tagName, props, children) {
        //保证只能通过如下方式调用:new VElement
        if (!(this instanceof VElement)) {
            return new VElement(tagName, props, children);
        }
    
        //可以通过只传递tagName和children参数
        if (util.isArray(props)) {
            children = props;
            props = {};
        }
    
        //设置虚拟dom的相关属性
        this.tagName = tagName;
        this.props = props || {};
        this.children = children || [];
        this.key = props ? props.key : void 666;
        var count = 0;
        util.each(this.children, function(child, i) {
            if (child instanceof VElement) {
                count += child.count;
            } else {
                children[i] = '' + child;
            }
            count++;
        });
        this.count = count;
    }

    通过VElement,我们可以很简单地用javascript表示DOM结构。比如

    var vdom = velement('div', { 'id': 'container' }, [
        velement('h1', { style: 'color:red' }, ['simple virtual dom']),
        velement('p', ['hello world']),
        velement('ul', [velement('li', ['item #1']), velement('li', ['item #2'])]),
    ]);

    上面的javascript代码可以表示如下DOM结构:

    <div id="container">
        <h1 style="color:red">simple virtual dom</h1>
        <p>hello world</p>
        <ul>
            <li>item #1</li>
            <li>item #2</li>
        </ul>   
    </div>

    同样我们可以很方便地根据虚拟DOM树构建出真实的DOM树。具体思路:根据虚拟DOM节点的属性和子节点递归地构建出真实的DOM树。见如下代码:

    VElement.prototype.render = function() {
        //创建标签
        var el = document.createElement(this.tagName);
        //设置标签的属性
        var props = this.props;
        for (var propName in props) {
            var propValue = props[propName]
            util.setAttr(el, propName, propValue);
        }
    
        //依次创建子节点的标签
        util.each(this.children, function(child) {
            //如果子节点仍然为velement,则递归的创建子节点,否则直接创建文本类型节点
            var childEl = (child instanceof VElement) ? child.render() : document.createTextNode(child);
            el.appendChild(childEl);
        });
    
        return el;
    }

    对一个虚拟的DOM对象VElement,调用其原型的render方法,就可以产生一颗真实的DOM树。

    vdom.render();

    既然我们可以用JS对象表示DOM结构,那么当数据状态发生变化而需要改变DOM结构时,我们先通过JS对象表示的虚拟DOM计算出实际DOM需要做的最小变动,然后再操作实际DOM,从而避免了粗放式的DOM操作带来的性能问题。

    (2).比较两棵虚拟DOM树的差异

    在用JS对象表示DOM结构后,当页面状态发生变化而需要操作DOM时,我们可以先通过虚拟DOM计算出对真实DOM的最小修改量,然后再修改真实DOM结构(因为真实DOM的操作代价太大)。

    如下图所示,两个虚拟DOM之间的差异已经标红:

    virtual dom

    为了便于说明问题,我当然选取了最简单的DOM结构,两个简单DOM之间的差异似乎是显而易见的,但是真实场景下的DOM结构很复杂,我们必须借助于一个有效的DOM树比较算法。

    设计一个diff算法有两个要点:

    如何比较两个两棵DOM
    如何记录节点之间的差异

    <1> 如何比较两个两棵DOM树

    计算两棵树之间差异的常规算法复杂度为O(n3),一个文档的DOM结构有上百个节点是很正常的情况,这种复杂度无法应用于实际项目。针对前端的具体情况:我们很少跨级别的修改DOM节点,通常是修改节点的属性、调整子节点的顺序、添加子节点等。因此,我们只需要对同级别节点进行比较,避免了diff算法的复杂性。对同级别节点进行比较的常用方法是深度优先遍历:

    function diff(oldTree, newTree) {
        //节点的遍历顺序
        var index = 0; 
        //在遍历过程中记录节点的差异
        var patches = {}; 
        //深度优先遍历两棵树
        dfsWalk(oldTree, newTree, index, patches); 
        return patches; 
    }

    <2>如何记录节点之间的差异

    由于我们对DOM树采取的是同级比较,因此节点之间的差异可以归结为4种类型:

    修改节点属性, PROPS表示
    修改节点文本内容, TEXT表示
    替换原有节点, REPLACE表示
    调整子节点,包括移动、删除等,用REORDER表示

    对于节点之间的差异,我们可以很方便地使用上述四种方式进行记录,比如当旧节点被替换时:

    {type:REPLACE,node:newNode}

    而当旧节点的属性被修改时:

    {type:PROPS,props: newProps}

    在深度优先遍历的过程中,每个节点都有一个编号,如果对应的节点有变化,只需要把相应变化的类别记录下来即可。下面是具体实现:

    function dfsWalk(oldNode, newNode, index, patches) {
        var currentPatch = [];
        if (newNode === null) {
            //依赖listdiff算法进行标记为删除
        } else if (util.isString(oldNode) && util.isString(newNode)) {
            if (oldNode !== newNode) {
                //如果是文本节点则直接替换文本
                currentPatch.push({
                    type: patch.TEXT,
                    content: newNode
                });
            }
        } else if (oldNode.tagName === newNode.tagName && oldNode.key === newNode.key) {
            //节点类型相同
            //比较节点的属性是否相同
            var propsPatches = diffProps(oldNode, newNode);
            if (propsPatches) {
                currentPatch.push({
                    type: patch.PROPS,
                    props: propsPatches
                });
            }
            //比较子节点是否相同
            diffChildren(oldNode.children, newNode.children, index, patches, currentPatch);
        } else {
            //节点的类型不同,直接替换
            currentPatch.push({ type: patch.REPLACE, node: newNode });
        }
    
        if (currentPatch.length) {
            patches[index] = currentPatch;
        }
    }

    比如对上文图中的两颗虚拟DOM树,可以用如下数据结构记录它们之间的变化:

    var patches = {
            1:{type:REPLACE,node:newNode}, //h1节点变成h5
            5:{type:REORDER,moves:changObj} //ul新增了子节点li
        }

    (3).对真实DOM进行最小化修改

    通过虚拟DOM计算出两颗真实DOM树之间的差异后,我们就可以修改真实的DOM结构了。上文深度优先遍历过程产生了用于记录两棵树之间差异的数据结构patches, 通过使用patches我们可以方便对真实DOM做最小化的修改。

    //将差异应用到真实DOM
    function applyPatches(node, currentPatches) {
        util.each(currentPatches, function(currentPatch) {
            switch (currentPatch.type) {
                //当修改类型为REPLACE时
                case REPLACE:
                    var newNode = (typeof currentPatch.node === 'String')
                     ? document.createTextNode(currentPatch.node) 
                     : currentPatch.node.render();
                    node.parentNode.replaceChild(newNode, node);
                    break;
                //当修改类型为REORDER时
                case REORDER:
                    reoderChildren(node, currentPatch.moves);
                    break;
                //当修改类型为PROPS时
                case PROPS:
                    setProps(node, currentPatch.props);
                    break;
                //当修改类型为TEXT时
                case TEXT:
                    if (node.textContent) {
                        node.textContent = currentPatch.content;
                    } else {
                        node.nodeValue = currentPatch.content;
                    }
                    break;
                default:
                    throw new Error('Unknow patch type ' + currentPatch.type);
            }
        });
    }

    至此,虚拟DOM的基本原理已经基本讲解完成了;我们也一起实现了一个基本可用的虚拟DOM。本文中只给出了关键的源代码,全部源代码请参考我的github

    本文同时发表在我的博客积木村の研究所 :http://foio.github.io/virtual-dom/

    展开全文
  • 什么是虚拟DOM

    2019-08-12 02:38:16
    继react之后vue2.0也在其核心引入了虚拟DOM的概念,本文将以vue2.0使用的snabbdom入手,来介绍虚拟DOM的主要实现原理。 二、虚拟DOM 在开始介绍snabbdom之前我们想来想两个问题, (1)什么是虚拟DOM? vdom可以...

    一、前言

    虚拟DOM概念随着react的诞生而诞生,由facebook提出,其卓越的性能很快得到广大开发者的认可;继react之后vue2.0也在其核心引入了虚拟DOM的概念,本文将以vue2.0使用的snabbdom入手,来介绍虚拟DOM的主要实现原理。

    二、虚拟DOM

    在开始介绍snabbdom之前我们想来想两个问题,

    (1)什么是虚拟DOM?

    vdom可以看作是一个使用javascript模拟了DOM结构的树形结构,这个树结构包含整个DOM结构的信息,如下图:

     

     

    可见左边的DOM结构,不论是标签名称还是标签的属性或标签的子集,都会对应在右边的树结构里。

    (2)为什么要使用虚拟DOM?

    之前使用原生js或者jquery写页面的时候会发现操作DOM是一件非常麻烦的一件事情,往往是DOM标签和js逻辑同时写在js文件里,数据交互时不时还要写很多的input隐藏域,如果没有好的代码规范的话会显得代码非常冗余混乱,耦合性高并且难以维护。

    另外一方面在浏览器里一遍又一遍的渲染DOM是非常非常消耗性能的,常常会出现页面卡死的情况;所以尽量减少对DOM的操作成为了优化前端性能的必要手段,vdom就是将DOM的对比放在了js层,通过对比不同之处来选择新渲染DOM节点,从而提高渲染效率。

    (3)vdom如何使用?

    下面我将使用snabbdom的用法介绍一下vdom的使用。

    三、snabbdom

    要了解snabbdom的话有必要先去github上先了解下snabbdom: https://github.com/snabbdom/snabbdom

    在这里看到官方给的一个example

    这里可以看到列出来的两个主要的核心函数,即h()函数和patch()函数,我们先来看下h()函数:

    h函数

     

     可以看到创建的虚拟DOM树里面的结构在左边的vnode里都有体现,所以现在看来我们的虚拟DOM结构树和snabbdom中的h()函数是完全可以对应起来的,可以通过一个方法将虚拟DOM结构转化成vnode;而上图中newVnode则指的是虚拟DOM树中的数据发生变化之后生成的vnode。

    我们在回过头来看patch()函数

    patch函数

    patch函数的执行分为两个阶段,两次传递的参数都是两个

    第一阶段为虚拟dom的第一次渲染,传递的两个参数分别是放真实DOM的container和生成的vnode,此时patch函数的作用是用来将初次生成的真实DOM结构挂载到指定的container上面。

    第二阶段传递的两个参数分别为vnode和newVnode,此时patch函数的作用是使用diff算法对比两个参数的差异,进而更新参数变化的DOM节点;

    可以发发现h函数和patch函数在cnabbdom中实现vdom到真实DOM的转化起到了至关重要的作用,那么还有一个很重要的环节,patch函数中是怎么样实现对比两个vnode从而实现对真实DOM的更新的呢,这里还要提一下snabbdom的另外一个核心算法,即diff算法。

    diff算法

    其实在我们日常开发中我们都在接触类似与diff算法的一些软件,比如svn可以看到当前代码和svn服务器上代码的不同之处,再如Beyond Compare这款软件也可以为我们指出两个对比文件的不同之处

    但是此处是如何实现对vnode的对比的呢?参考以下代码:

     1 function updateChildren(vnode, newVnode) {      // 创建对比函数
     2     var children = vnode.children || []         
     3     var newChildren = newVnode.children || []
     4 
     5     children.forEach(function(childrenVnode, index) {
     6         var newChildVnode = newChildren[index]  // 首先拿到对应新的节点
     7         if (childrenVnode.tag === newChildVnode.tag) {    // 判断节点是否相同
     8             updateChilren(childrenVnode, newChildVnode)   // 如果相同执行递归,深度对比节点
     9         } else {
    10             repleaseNode(childrenVnode, newChildVnode)    // 如果不同则将旧的节点替换成新的节点
    11         }
    12     })
    13 }
    14 
    15 
    16 function repleaseNode(vnode, newVnode) {    // 节点替换函数
    17     var elem = vnode.elem
    18     var newEle = createElement(newVnode)
    19 }

    此处简单的列举了一下diff算法的原理,以上是最简单的对比,更复杂的对比函数包括对节点的增删以及其它的节点逻辑就不一一赘述了,这里最重要的一部分就是递归的使用,才能将vnode进行深度对比。

    四、小结

    虚拟DOM在目前流行的几大框架中都作为核心的一部分使用,可见其性能的高效,本文只是简单的通过列举vue中使用的snabbdom库做一个简单的剖析,想要更深层次的理解vdom还有很长的路要走,本文如有不当之处,还劳烦路过大佬批评指出。

    转载于:https://www.cnblogs.com/gaosong-shuhong/p/9253959.html

    展开全文
  • 什么是虚拟dom

    千次阅读 2019-01-02 21:07:04
    虚拟dom就是一个特殊的对象。 Vue之所以运行高效,因为采用了虚拟dom,减少了对真实的dom操作。 一、dom和虚拟dom对比 //dom &amp;lt;ul id='test'&amp;gt; &amp;lt;p class='hehe'&amp;gt;...

    虚拟dom就是一个特殊的对象。

    Vue之所以运行高效,因为采用了虚拟dom,减少了对真实的dom操作。

    一、dom和虚拟dom对比

    //dom
    <ul id='test'>
    	<p class='hehe'>这里是p标签</p>
    </ul>
    //对应的虚拟dom对象
    let vdom={
        tag:'ul',
        attr:{
        	id:'test'
        },
        content:[
        	{
        		tag:'p',
        		attr:{
        			class:'hehe'
        		},
        		content:'这里是p标签'
        	}
        ]
    }
    //将虚拟dom转为真实dom基本操作:
    let ul=document.createElement(vdom.tag);
    ul.id=test;
    let p=document.createElement(vdom.content.tag);
    ul.p.class=hehe;
    //再通过append方法添加...等操作
    

    二、dom操作和虚拟dom操作耗时对比:

    let num=0
    console.time('test')
    // 方式一:平均 60ms   80ms
     for (var i = 0; i < 10000; i++) {
    let tmp=Number(document.getElementById('test').innerHTML)
    document.getElementById('test').innerHTML=tmp+1
     }
     console.timeEnd('test');
     
    //方式二: 平均  1ms   0.6ms
    //let num=0
    //console.time('test')
    // for (var i = 0; i < 10000; i++) {
    // 	num++
    // }
    // document.getElementById('test').innerHTML=num
    // console.timeEnd('test');
    
    

    三、虚拟dom实现过程:

    <!DOCTYPE html>
    <html lang="en">
    <head>
    	<meta charset="UTF-8">
    	<title>虚拟dom</title>
    </head>
    <body>
    <span id='test'></span>
    <ul id='test'>
    	<p class='hehe'>这里是p标签</p>
    	<li>{{1+1}}</li>
    </ul>
    </body>
    <script>
    //虚拟dom实现过程:
    // 1.根据真实dom产生虚拟dom?  虚拟dom?就是一个特殊对象(经过一些处理能产生真实dom)
    let vdom={
        tag:'ul',
        attr:{
        	id:'test'
        },
        content:[
        	{
        		tag:'p',
        		attr:{
        			class:'hehe'
        		},
        		content:'这里是p标签'
        	},
        	{
        		tag:'li',
        		content:1
        	}
        ]
    }
    // 2.进行编译解析
    let vdom={
        tag:'ul',
        attr:{
        	id:'test'
        },
        content:[
        	{
        		tag:'p',
        		attr:{
        			class:'hehe'
        		},
        		content:'这里是p标签'
        	},
        	{
        		tag:'li',
        		content:2
        	}
        ]
    }
    //3.将虚拟dom 变成真实dom 也就 挂载
    //4.数据发生变化 产生新的虚拟dom
    let vdom={
        tag:'ul',
        attr:{
        	id:'test'
        },
        content:[
        	{
        		tag:'p',
        		attr:{
        			class:'hehe'
        		},
        		content:'这里是p标签'
        	},
        	{
        		tag:'li',
        		content:2
        	},
        	{
        		tag:'li',
        		content:2
        	}
    
        ]
    }
    //5、将数据变化后的虚拟dom 和之前的虚拟dom 通过differ 算法 进行比对
    // 6、比对之后更新视图 一样的不变 不一样重新渲染
    </script>
    </html>
    
    展开全文
  • 虚拟DOM和真实DOM的区别

    千次阅读 2019-08-08 21:07:49
    虚拟DOM与真实DOM的区别(注意:需不需要虚拟DOM,其实与框架的DOM操作机制有关): 虚拟DOM不会进行排版与重绘操作 虚拟DOM进行频繁修改,然后一次性比较并修改真实DOM中需要改的部分(注意!),最后并在...
  • 虚拟DOM作为目前流行的DOM操作思想,被广泛用在react中,这套设计的确在用户体验上带来了显著提升。下面我们来浅析一下这个东西,一步步看下去,希望你能有所收获。 设计理念 尽管MVVM将页面逻辑实现的核心...
  • 虚拟dom的理解

    2018-05-30 14:47:15
    虚拟dom 随着mvvm框架的应用,react,vue都在用。 首先要理解什么是虚拟dom,为什么要用虚拟dom虚拟dom前身是什么。跟jquery比的好处是什么。 例如有个表格 name age address 张三 20 北京 ...
  • 什么是虚拟DOM

    千次阅读 2017-06-02 10:24:52
    因为它实现了一个虚拟DOM(Virtual DOM)。虚拟DOM是干什么的?这就要从浏览器本身讲起。 如我们所知,在浏览器渲染网页的过程中,加载到HTML文档后,会将文档解析并构建DOM树,然后将其与解析CSS生成的CSSOM树一起...
  • 虚拟DOM解析及其在框架里的应用

    千次阅读 2018-09-28 16:08:49
    虚拟DOM解析及其在框架里的应用 浏览器是怎样解析HTML并且绘出整个页面的 上图为webkit引擎浏览器的处理流程,如上图大致分为4大步: 第一步,HTML解析器分析html,构建一颗DOM树; 第二步,CSS解析器会分析外联的...
  • vue 虚拟dom实现原理

    万次阅读 2017-12-14 10:03:33
    相比于频繁的手动去操作dom而带来性能问题,vdom很好的将dom做了一层映射关系,进而将在我们本需要直接进行dom的一系列操作,映射到了操作vdom,而vdom上定义了关于真实dom的一些关键的信息,vdom完全是
  • react虚拟DOM的机制

    万次阅读 2019-08-04 10:37:51
    虚拟DOM的机制: 在浏览器端用JavaScript实现了一套DOM API。基于React进行开发时所有的DOM构造都是通过虚拟DOM进行,每当数据变化时,React都会重新构建整个DOM树,然后React将当前整个DOM树和上一次的DOM树进行...
  • 浅谈React的最大亮点——虚拟DOM

    万次阅读 2017-05-04 16:51:56
    虚拟DOM是在DOM的基础上建立了一个抽象层,对数据和状态所做的任何改动,都会被自动且高效的同步到虚拟DOM,最后再批量同步到DOM中。 1、传统App与React App的对比: 传统App React App 2、虚拟DOM的原理: ...
  • 虚拟dom及其优劣

    千次阅读 2019-04-29 16:36:57
    什么是虚拟dom 用js模拟一颗dom树,放在浏览器内存中.当你要变更时,虚拟dom使用diff算法进行新旧虚拟dom的比较,将变更放到变更队列中,反应到实际的dom树,减少了dom操作. 虚拟DOM将DOM树转换成一个JS对象树,diff算法...
  • React虚拟DOM及创建虚拟DOM的两种方式

    千次阅读 2018-11-29 17:03:05
    1.虚拟DOM是什么? 一个虚拟DOM(元素)是一个一般的js对象,准确的说是一个对象树(倒立的);虚拟DOM保存了真实DOM的层次关系和一些基本属性,与真实DOM一一对应;如果只是更新虚拟DOM,页面是不会重绘的。 Virtual ...
  • React-为什么要使用虚拟DOM

    千次阅读 2016-12-05 14:13:02
    什么是虚拟DOM(Virtual DOM)首先,解释下虚拟DOM虚拟DOM保存了真实DOM的层次关系和一些基本属性,与真实DOM一一对应。虚拟DOM的工作原理是:数据 -> 全新的虚拟DOM -> 与上一个状态的虚拟DOM进行diff算法比较,...
  • Vue.js 2.0新增的虚拟DOM是怎么回事?

    万次阅读 2017-01-17 10:17:25
    本文转载自:众成翻译 译者:QAQMiao ... 原文:https://medium.com/js-dojo/whats-new-in-vue-js-2-0-virtual-dom-dc4b5b827f40#.3twr9wzat 你可能早就已经听说了 ...一个主要的令人兴奋的新特性就是更新页面的”虚拟
  • 虚拟 DOM 的优缺点?

    千次阅读 2019-09-19 17:55:22
    保证性能下限: 框架的虚拟 DOM 需要适配任何上层 API 可能产生的操作,它的一些 DOM 操作的实现必须是普适的,所以它的性能并不是最优的;但是比起粗暴的 DOM 操作性能要好很多,因此框架的虚拟 DOM 至少可以保证在...
  • react 虚拟dom 的原理简单理解

    千次阅读 2018-03-30 09:24:32
    原理:react 在内存中生成维护一个跟真实DOM一样的虚拟DOM 树,在改动完组件后,会再生成一个新得DOM,react 会把新虚拟DOM 跟原虚拟DOM 进行比对,找出两个DOM 不同的地方diff ,然后把diff放到队列里面,然后批量...
  • 对vue虚拟DOM理解

    千次阅读 2017-06-02 09:51:08
    为什么需要虚拟DOMDOM是很慢的,其元素非常庞大,页面的性能问题鲜有由JS引起的,大部分都是由DOM操作引起的。如果对前端工作进行抽象的话,主要就是维护状态和更新视图;而更新视图和维护状态都需要DOM操作。其实...
  • 浅谈VUE虚拟dom

    千次阅读 2018-01-11 17:47:24
    Dom操作是比较浪费时间和性能的,当数据量很大时,更新DOM是非常耗费性能的操作,当我们使用Javascript来修改我们的页面,浏览器已经做了一些工作,以找到DOM节点进行更改,例如: document.getElementById('myId'...
1 2 3 4 5 ... 20
收藏数 46,753
精华内容 18,701
关键字:

虚拟dom