理解虚拟 DOM

DOM 和虚拟 DOM

定义

从最原始定义来讲,DOM 是用于访问和处理 HTML 和 XML 文档的 API。通过这套 API 我们可以将一段合规的 HTML 代码转换成一堆由 Node 对象描述的结点组成的结点树, 在 JavaScript 里,这个结点树就呈现为一个可以表示这个结点树的对象。显然这个结点树很复杂,因为组成树 的 Node 就是复杂的,它里面包含了很多的属性。可实际使用上,那些 Node 对象里真正被用到的属性很有限,比如 nodeType、attributes、tagName、绑定的事件等核心属性,通过维护一份与这些属性相关的信息,我们用一个 JavaScript 对象来描述,这个对象就称为虚拟 Node(后简称为 vNode),vNode 的概念明晰后,对应地,虚拟 DOM 的定义也就不难给出了。

node 和 vNode 是一一对应的,这样我们便可以通过这些 vNode 来还原一个结点树,以及通过对比两个不同的 vNode tree 找出它们之间的最小差异,进而在旧结点树中只需对对应最小差异的部分做修改便能以最小成本的实现一个旧结点树向新结点树的转换。以上便是通过虚拟 DOM 实现以最小的成本更新 DOM 的原理。

小结

JavaScript 里,node tree 的 node 是一个复杂的对象,vNode tree 的 vNode 是一个只保存了 node 里必要信息的简单对象,可以通过某种算法 D 找出两个 vNode tree 的最小差异部分,然后通过某种算法 P 来对 node tree 上对应的差异部分作出修改,使之变成与新 vNode tree 对应的 node tree。

具体实现

节点类型

需要区分的节点类型包括 element 节点、文本节点、注释节点。

vNode 的定义

interface VNode {
  sel: string | undefined;
  data: VNodeData | undefined; // 与 attributes 和 时间绑定有关,简便起见,暂忽略之
  children: Array<VNode | string> | undefined;
  text: string | undefined;
  elm: Node | undefined;
  key: string | number | undefined;
}

其中 sel 的可能取值为:

  • 只包含 HTML 标签名、id 和 class 的 selector 字符串,此时表示一个 element 节点
  • undefined 此时表示一个文本节点
  • ! 此时表示一个注释节点

children 表示子节点,或为空或为一个由 VNode 组成的数据;text 当表示一个 elementtextContentelm 为 vNode 所对应的 node;key 为优化效率用的。

先看看对比两个 vNode 的函数 patch

function isVoid (v: any): boolean {
  return v === void 0 || v === null
}

function isUndef (v: any): boolean {
  return v === void 0
}

function patch (oldVNode: VNode | Element, vNode: VNode) : VNode {
  // 初始化时,在一个 Node 上挂载
  if (oldVNode.sel === undefined)  {
    oldVNode = emptyNodeAt(oldVNode)
  }

  if (sameVNode(oldVNode, vNode)) {
    patchVNode(oldVNode, vNode)
  } else {
    elm = oldVNode.elm
    parent = api.parent(elm)
    createElm(vNode)

    api.insertBefore(parent, vNode.elm, api.nextSibling(elm))
    removeVNodes(parent, [oldVNode], 0, 0)
  }

  return vNode
}

function emptyNodeAt (elm: Element): VNode {
  const id = elm.id ? `#${elm.id}` : ''
  const c = elm.class ? `.${elm.class.split(' ').join('.')}`

  return {
    sel: `${elm.tagName.toLowerCase()}${id}${c}`,
    data: {},
    children: [],
    text: undefined,
    elm,
    key: undefined
  }
}

function sameVNode (oldVNode: VNode, vNode: VNode): boolean {
  return oldVNode.sel === vNode.sel && oldVNode.key === vNode.key
}

patch 函数的逻辑为:

  1. 第一个参数是否为 Element,是的话将其初始化为一个空的 vNode(这主要发生在首次从一个 node 下挂载新的 node 时,后续的 node tree 的变化都是基于 vNode 来操作)
  2. 对比两个 vNode 是否是相同的 vNode
    1. 是,则调用 patchVNode 函数
    2. 否,则根据 vNode 创建一个新的 node 并将这个新 node 插入到原来的 node 之后,然后删除 oldVNode(删除对应 elm 的操作也在其中)
  3. return vNode

其中 api 是浏览器提供的一系列 DOM 操作的封装,真正找出两个 vNode 最小差异的函数是 patchVNode 函数,createElm 函数的作用是根据 vNode 创建一个与之对应的 node,removeNodes 函数的作用是根据提供的 vNodes 删除对应的 node。它们的定义如下:

api

function createElement(tagName: any): HTMLElement {
  return document.createElement(tagName)
}
function createElementNS(namespaceURI: string, qualifiedName: string): Element {
  return document.createElementNS(namespaceURI, qualifiedName)
}
function createTextNode(text: string): Text {
  return document.createTextNode(text)
}
function createComment(text: string): Comment {
  return document.createComment(text)
}
function insertBefore(parentNode: Node, newNode: Node, referenceNode: Node | null): void {
  parentNode.insertBefore(newNode, referenceNode)
}
function removeChild(node: Node, child: Node): void {
  node.removeChild(child)
}
function appendChild(node: Node, child: Node): void {
  node.appendChild(child)
}
function parentNode(node: Node): Node | null {
  return node.parentNode
}
function nextSibling(node: Node): Node | null {
  return node.nextSibling
}
function tagName(elm: Element): string {
  return elm.tagName
}
function setTextContent(node: Node, text: string | null): void {
  node.textContent = text
}
function getTextContent(node: Node): string | null {
  return node.textContent
}
function isElement(node: Node): node is Element {
  return node.nodeType === 1
}
function isText(node: Node): node is Text {
  return node.nodeType === 3
}
function isComment(node: Node): node is Comment {
  return node.nodeType === 8
}

export const htmlDomApi = {
  createElement,
  createElementNS,
  createTextNode,
  createComment,
  insertBefore,
  removeChild,
  appendChild,
  parentNode,
  nextSibling,
  tagName,
  setTextContent,
  getTextContent,
  isElement,
  isText,
  isComment,
} as DOMAPI

createElm

function createElm (vNode: VNode): Node {
  const { sel, children } = vNode

  if (sel === '!') {
    if (isUndef(vNode.text)) {
      vNode.text = ''
    }
    vNode.elm = api.createComment(vNode.text)
  } else if (isDef(sel) {
    const { id, c, tag } = getIdCTagFromSel(sel) // 通过 sel 解析出 tagName、className、id
    const elm = api.createElement(tag)

    if (isDef(id)) {
      elm.setAttribute('id', id)
    }
    if (isDef(c)) {
      elm.setAttribute('class', c)
    }
    if (Array.isArray(children)) {
      for (let i = 0; i < children.length; ++i) {
        const ch = children[i]

        if (!isVoid(ch))  {
          api.appendChild(elm, createElm(ch))
        }
      }
    } else if (typeof vNode.text === 'number' || typeof vNode.text === 'string') {
      api.appendChild(elm, api.createTextNode(vNode.text))
    }
  } else {
      vNode.elm = api.createTextNode(vNode.text)
  }

  return vNode.elm
}

removeVNodesaddVNodes

function removeVNodes (parentElm: Element, vNodes: Array<VNode>, startIdx: number, endIndex: number): void {
  for(; startIdx <= endIdx; ++startIdx) {
    const ch = vNodes[startIdx]

    if (!isVoid(ch)) {
      api.removeChild(parentElm, ch.elm)
    }
  }
}

function addVNodes (parentElm: Element, before: Node | null, vNodes: Array<VNode>, startIdx: number, endIdx: number): void {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vNodes[startIdx]

    if (!isVoid(ch))  {
      api.insertBefore(parentElement, createElm(ch), before)
    }
  }
}

patchNode

function patchVNode(oldVNode: VNode, vNode: VNode): void {
  const elm = vNode.elm = oldVNode.elm
  let oldCh = oldVNode.children
  let ch = vNode.children

  if (isUndef(vNode.text)) {
    if (Array.isArray(oldCh) && Array.isArray(ch)) {
      updateChildren(elm, oldCh, ch)
    } else if (Array.isArray(oldCh)) {
      removeVNodes(elm, oldCh, 0, oldCh.length - 1)

      if (isDef(oldVNode.text)) {
        api.setTextContent(elm, '')
      }
    } else if (Array.isArray(ch)){
      if (isDef(oldVNode.text)) {
        api.setTextContent(elm, '')
      }
      addVNodes(elm, null, ch, 0, ch.length - 1)
    } else if (isDef(oldVNode.text)) {
      api.setTextContent(elm, '')
    }
  } else if (oldVNode.text !== vNode.text){
    if (Array.isArray(oldCh)) {
      removeVNodes(elm, oldCh, 0, oldCh.length - 1)
    }

    api.setTextContent(elm, vNode.text)
  }
}

patchVNodeoldVNode.textvNode.text 变化(从无、有到有和从有到有)的 2 种情况以及 oldVNode.childrenvNode.children 变化(从有到有,从无到有,从有到无)的 3 种情况,一共分为 6 种情况,而在 vNode.text 为「有」时,vNode.children 应该为「无」的,所以最终剩 4 种情况。

最复杂的是 children 从有到有的情况,其处理逻辑在 updateChildren 函数中,实现如下:

function updateChildren(
  elm: Element,
  oldCh: Array<VNode>,
  newCh: Array<VNode>
) {
  let oldStartIdx = 0
  let oldEndIdx = oldCh.length - 1
  let oldStartVNode = oldCh[oldStartIdx]
  let oldEndVNode = oldCh[oldEndIdx]
  let newStartIdx = 0
  let newEndIdx = newCh.length - 1
  let newStartVNode = newCh[newStartIdx]
  let newEndVNode = newCh[newEndIdx]

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // filter the void vNode
    if (isVoid(oldStartVNode)) {
      oldStartVNode = oldCh[++oldStartIdx]
    }
    if (isVoid(oldEndVNode)) {
      oldEndVNode = oldCh[--oldEndIdx]
    }
    if (isVoid(newStartVNode)) {
      newStartVNode = newCh[++newStartIdx]
    }
    if (isVoid(newEndVNode)) {
      newEndVNode = newCh[--newEndIdx]
    }

    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)) {
      // vNode move to right
      patchVNode(oldStartVNode, newEndVNode)
      api.insertBefore(elm, oldStartVNode.elm, api.nextSibling(oldEndVNode.elm))
      oldStartVNode = oldCh[++oldStartIdx]
      newEndVNode = newCh[--newEndIdx]
    } else if (sameVNode(oldEndVNode, newStartVNode)) {
      // vNode move to left
      patchVNode(oldEndVNode, newStartVNode)
      api.insertBefore(elm, oldEndVNode.elm, oldStartVNode.elm)
      oldEndVNode = oldCh[--oldEndIdx]
      newStartVNode = newCh[++newStartIdx]
    } else {
      // a new element
      api.insertBefore(elm, createElm(newStartVNode), oldStartVNode.elm)
      newStartVNode = newCh[++newStartIdx]
    }
  }

  if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
    if (oldStartIdx > oldEndIdx) {
      // the count of vNodes increased
      const before = isVoid(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVNodes(elm, before, newCh, newStartIdx, newEndIdx)
    } else {
      // the count of vNodes decreased
      removeVNodes(elm, oldCh, oldStartIdx, oldEndIdx)
    }
  }
}

总结

以上便是一个简洁(因为 VNode 里的 data 属性和 key 属性都还没有用到)的通过 vNode 最小成本改动结点树的方法,基本逻辑:

  1. 判断两个 vNode(oldVNode 和 newVNode) 是否有相同的 sel 和 key,若否,则直接移除旧 vNode 所对应的整个 elm,然后根据新 vNode 创建一个新的 node,替代之
  2. 否则,则视为同一类型 vNode,然后进一步比较他们的 text 和 children:
    i. if(!isVoid(newVNode.text) && isArray(oldVNode.children)),则移除 oldVNode.elm 下面的所有子节点,并将其 textContent 赋值为 newVNode.text
    ii. 否则 if(isVoid(newVNode.text) && isArray(oldVNode.children)) && !isArray(newVNode.children)),则移除 oldVNode.elm 下面的所有子节点,并将其 textContent 赋值为 ''
    iii. 否则if(isVoid(newVNode.text) && !isArray(oldVNode.children)) && isArray(newVNode.children)) 则将 oldVNode.elm 的 textContent 赋值为 '',并根据 newVNode.children 生成对应 nodes 数组,然后挂在到 oldVNode 上
    iv. 否则if(isVoid(newVNode.text) && isArray(oldVNode.children)) && isArray(newVNode.children)),则依次遍历比较两者的 children,当发现两者的 children 中有可以视为同一类 vNode 的两个 vNode 时,则将这两个 vNode 代入步骤 2,同时将对应的 node 移到指定的位置;如果没有,则根据新的子 vNode 创建一个新 node,同时插入到 oldVNode.elm 对应位置,最后移除多余的 node 或补齐新增的 node

参考:
https://github.com/snabbdom/snabbdom#the-class-module

事件绑定

事件绑定的功能是通过 new VNode 的第二个参数 data 里的 on 属性来完成的,data.on 的值是一个对象,对象里的键可以是传入以 document.addEventListener(name, handler) 里的 name ,对应的值为 handler。可以通过一个 updateEventListeners(oldVNode, vNode) 的函数来实现,函数的作用就是对比 oldVNode 和 vNode 的 data.on 的属性,将 oldVNode.data.on 有而 vNode.data.on 没有的那些 name 调用 oldVNode.elm.removeEventLisetner(name, oldListener),对 vNode.data.on 有而 oldVNode.data.on 没有的调用 oldVNode.elm.addEventListener(name, listener)。其中 listeneroldListener 是为 vNode 处理 vNode.on 时在 vNode 上新增的一个属性 listenervNode.listener 是一个函数,同时 vNode.listener.vNode === vNodetrue。elm 上所有事件的回调函数都是这个 vNode.listener,由它的内部统一处理对应的事件,它由下面函数生成:

function createLisenter (): Function {
  return function handler (): void {
    handleEvent(event, handler.vNode)
  }
}

function handleEvent(event: Event, vNode: VNode): void {
  const name = event.type
  const on = vNode.on

  if (on && on[name]) {
     // 这里只是一个简单的实现,不能往回调函数传参,可以专门抽出一个方法处理传参的情形
    on[name].call(vNode, event, vNode)
  }
}

更新事件的的内部逻辑就是这些,还有个问题是我们应该何时执行 updateEventListeners 这个方法?初步看来下面三个时机:

  • 一个 vNode 刚被新建
  • newVNode 预备替换 oldVNode
  • 一个 vNode 要被移除

而且应该在 vnode 被真正渲染到 node tree 之前。于是我们可以为一个 vNode 定义一系列的 hooks,比如就这上面三个:create、update、destroy,然后在对应的时机调用 updateEventListeners 就可以了。snabbdom.js 将处理事件绑定的机制定义为一个 module,类似地专门处理 style 属性的又是一个 module,所有 module 有个公共的 hooks,每个 module 所要用到 hooks 并不一定相同,而且一个 module 里每个 hook 所调用的方法也不一定相同,这些 modules 用来传入 require('snabbdom').init

const hooks = ['hook1', 'hook2', /*...*/'hookN']

function init (modules, domApi) {
  let i, j, cbs = {}
  const api = domApi !== undefined ? domApi : htmlDomApi

  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      var hook = modules[j][hooks[i]]
      if (hook !== undefined) {
        cbs[hooks[i]].push(hook)
      }
    }
  }
  // ...
  return patch(oldVNode, vNode)
}

最终返回一个具备处理与那些 module 对应的属性的 patch 函数。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,651评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,468评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,931评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,218评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,234评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,198评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,084评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,926评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,341评论 1 311
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,563评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,731评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,430评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,036评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,676评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,829评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,743评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,629评论 2 354

推荐阅读更多精彩内容

  • 初六和家人出去玩,没写完博客。跳票了~ 所谓虚拟DOM,是一个用于表示真实 DOM 结构和属性的 JavaScri...
    VioletJack阅读 2,548评论 1 4
  • patch介绍 虚拟DOM最核心的部分是patch,它将vnode渲染成真实DOM过程中并不是暴力覆盖原有DOM,...
    打静爵阅读 759评论 0 0
  • snabbdom源码 现在流行的前端前端库都使用虚拟dom来提高dom渲染效率,简单的来说虚拟dom就是用js来模...
    起飞之路阅读 727评论 0 2
  • 什么是vnode 在vue中,存在一个VNode类,使用它可以实例化不同类型的vnode实例,不同类型的vnode...
    打静爵阅读 997评论 0 1
  • 充实的一天,早上5点闹钟就响了,那真是一个困啊,头也疼,昏昏糊糊的5.30起了床,收拾了会,6点多出的门。路上打电...
    肖博文阅读 133评论 1 2