Vue是如何把虚拟dom(vnode)转化为真实dom的

相信很多学习过vue的小伙伴们都觉得很容易上手,因为vue不需要太过复杂的配置,也不需要你太关注它背后运行的逻辑,你只要用vue-cli脚手架建一个项目就可以在本地进行开发了,个人认为vue最好的一点就是你只要关注数据的变化就好了,其他的一切都有vue背后来实现。
我们今天就来探究一下vue内部是如何吧虚拟dom变为我们页面上的html代码的,并且手写一个简单的例子来实现。
写之前我们先用一张图来了解一下vue把vnode变为真实dom的一个流程

流程图
那么总结起来就是三步走战略:

  • 第一步拿到我们写的template模版,通过complie编译成AST抽象语法树
  • 第二部通过createElement方法把语法书转换为vnode
  • 最后就是通过render方法把vnode变为真实dom

相信到了这里大家已经大致了解这么一个转换过程,下面来看我们的项目结构
目录结构
这是自己用webpack手敲的一个简易项目,主要是设置一下项目的入口index.js,index.html是我们的基础模板文件;diff目录下主要存放了刚刚那三步的编译文件,

  • vnode.js是虚拟dom的构造函数,会返回一个虚拟dom的事例;
  • h.js将我们的模板转换为虚拟dom,借助vnode的构造函数生成虚拟dom并最终返还这个结果出去
  • diff.js,就是我们常说的diff算法里面的diff,主要负责虚拟dom的diff
  • patch.js 用于收集虚拟dom的变化,以及将变化update到真实dom上
  • index.js 是主要负责把其他模块函数引入进来,在这里进行统一处理
  • vueCompile.js 是我从vue源码拷出来的,这里直接用了vue的模版编译以及双向数据绑定

现在我们来住个文件分析,首先是vnode.js

// 虚拟dom的构造函数
class Element {
  constructor(type, key, props, children, text) {
    this.type = type
    this.props = props
    this.key = key
    this.children = children
    this.text = text
  }
}

function vNode(type, key, props, children, text) {
  return new Element(type, key, props, children, text)
}

export { Element, vNode }

这里写的是虚拟dom的构造函数,构造函数需要接受五个参数,第一个是节点的类型,第二个是节点的key,第三个是节点的props属性,第四个是子节点数组,第五个是节点的文本。执行vNode方法后会返回一个新的节点对象。

h.js

import { vNode } from './vnode'

function createElement(type, props = {}, children) {
  let key;
  if (props.key) {
    key = props.key
    delete props.key
  }

  if (Array.isArray(children)) {
    children = children.map((child) => {
      if (typeof child === 'string') return vNode(undefined, undefined, undefined, undefined, child)
      return child
    })
  } else if (typeof children === 'string') {
    children = [vNode(undefined, undefined, undefined, undefined, children)]
  }
  return vNode(type, key, props, children)
}

export default createElement;

h.js主要是借用vNode的方法来转换成虚拟节点,接收三个参数,节点类型,节点属性以及子节点。如果节点属性props里面有key就会复制给局部变量key,并且删除props对key的引用;接着判断子节点是不是数组,如果是数组证明有一个或多个子节点,需要我们去逐个判断每个节点是否是string类型的文本节点,如果是文本节点则直接利用vNode生成一个其他属性都为空的文本节点,否则不做处理,直接返回;
如果子节点不是数组,是个字符串,例如

hello

,子节点就是字符串类型的文本节点,那这里返回的跟之前一样的文本节点,这里要用数组包起来。
最后返回一个vNode虚拟节点。

render.js

该文件的作用是将虚拟节点转换为真实的dom节点,主要是三个方法render,createRealElement以及setProperties。

function render(vNode, target) {
  let el = createRealElement(vNode)
  let container = typeof target === 'string' ? document.querySelector(target) : target

  container && el && container.appendChild(el)
  return el;
}

function createRealElement(vNode) {
  const { type, key, props, children, text } = vNode;

  if (type) {
    vNode.realDom = document.createElement(type)

    setProperties(vNode);

    if (Array.isArray(children)) {
      children.forEach(child => render(child, vNode.realDom))
    }
  } else {
    vNode.realDom = document.createTextNode(text)
  }

  return vNode.realDom
}

function setProperties(newVNode, oldProps = {}) {
  const realDom = newVNode.realDom
  const newProps = newVNode.props

  for (const oldPropsKey in oldProps) {
    if (!newProps[oldPropsKey]) {
      realDom.removeAttribute(oldPropsKey)
    }
  }

  let newStyleObj = newProps.style || {};
  let oldStyleObj = oldProps.style || {};

  for (const key in oldStyleObj) {
    if (!newStyleObj[key]) realDom.style[key] = ""
  }

  for (const newPropsKey in newProps) {
    switch (newPropsKey) {
      case "style":
        const style = newProps[newPropsKey]
        for (const styleKey in style) {
          realDom.style[styleKey] = style[styleKey]
        }
        break;

      default:
        realDom.setAttribute(newPropsKey, newProps[newPropsKey])
        break;
    }
  }
}

render方法接收两个参数,虚拟节点vNode以及需要挂载的el节点。
进来render方法后,我们会根据createRealElement方法,传入之前生成的虚拟节点去创建元素。
进入createRealElement后,获取虚拟节点的属性值,进行第一轮的判断。
如果该节点有节点类型,比如‘div’,‘p’之类的,那么我们根据这个节点类型去创建节点,并把创建出来的节点挂在虚拟节点vNode的realDom上面,创建完节点后我们需要对该节点进行一些属性的设置。
进入setProperties方法后,我们拿到最新的realDom以及newProps属性,然后遍历旧属性,如果新属性里面没有旧属性的oldPropsKey,那我们就把realDom的oldPropsKey属性移除掉。接着我们进行style的新老样式比较,同样的,我们遍历旧样式表,如果新的style里面没有了key属性,那么我们把新的style对应的key属性设置为空。除了style外我们还要处理一些特殊属性,例如这里我们处理新的style值,我们先遍历新的newProps,利用switch来映射要我们要处理的属性,我们这里拿style举例子,拿到新的style后我们继续遍历它,把对应的属性值赋值给realDom的style。case的default我们默认使用setAttribute来设置就行了。到了这里新创建的节点的属性赋值就到一段落了。
设置完属性我们回到createRealElement方法继续执行,判读子节点是否是数组,如果是证明有一个或多个子节点,这里我们递归调用render方法就可以了;如果不是那就只是普通的文本节点,直接document.createTextNode就行了。最后把realDom返回出去。
执行完createRealElement方法后我们回到render,判断一下我们的target是不是一个字符串,如果是就获取该节点,否则就是传入的一个节点对象,直接使用;当挂载节点container以及新的节点realDom同时存在时,我们把realDom挂载到container下,至此从虚拟dom到真实dom的转换完成。

diff.js

diff.js用于比较新老节点的不同,这里是根据diff的逻辑自己实现的一个diff比较,不是vue内部的diff算法,先看看代码。

// 主要负责虚拟dom的diff
// 层级比较,当没有新节点的时候,就要移除老节点
// 节点相同的时候,就要判断“属性”跟“子节点”是否相同
// 当节点内容是String或者是Number的时候,先比较是否相等,然后再做处理
// 当以上集中情况都不匹配的时候,说明节点是不一样的类型,直接替换

let INDEX = 0; // 标记当前比较到第几个节点

function diff(oldNode, newNode) {
  let patch = {}

  walk(oldNode, newNode, INDEX, patch)
  return patch
}

const ATTR = "ATTR"
const TEXT = "TEXT"
const REMOVE = "REMOVE"
const REPLACE = "REPLACE"

function walk(oldNode, newNode, index, patch) {
  let currentPatch = []

  if (!newNode) {
    currentPatch.push({ type: REMOVE, index: index })
  } else if (!oldNode.children && !newNode.children) {
    if (oldNode.text != newNode.text) currentPatch.push({ type: TEXT, text: newNode.text })
  } else if (oldNode.type === newNode.type) {
    const newAttrs = diffAttr(oldNode.props, newNode.props)
    if (Object.keys(newAttrs).length > 0) {
      currentPatch.push({ type: ATTR, attr: newAttrs })
    }

    // 处理子节点
    if (oldNode.children && newNode.children) {
      diffChildren(oldNode.children, newNode.children, index, patch)
    } else if (!newNode.children) {
      currentPatch.push({ type: REMOVE, index: index })
    } else if (!oldNode.children) {
      currentPatch.push({ type: REPLACE, newNode })
    }
  } else {
    currentPatch.push({ type: REPLACE, newNode })
  }

  if (currentPatch.length) patch[index] = currentPatch
}

function diffAttr(oldAttr, newAttr) {
  let attrs = {}
  for (const key in oldAttr) {
    if (oldAttr[key] !== newAttr[key]) attrs[key] = newAttr[key]
  }

  if (oldAttr && newAttr) {
    let newStyleObj = newAttr.style || {}
    let oldStyleObj = oldAttr.style || {}

    for (const key in oldStyleObj) {
      if (!newStyleObj[key]) {
        attrs.style[key] = ""
      }
    }
  }

  return attrs
}

function diffChildren(oldChildren, newChildren, index, patch) {
  oldChildren.forEach((children, idx) => {
    walk(children, newChildren[idx], ++INDEX, patch)
  })
}

export { diff }

根据注释的比较原则,我们可以归类为四种情况,节点属性变化(ATTR),节点的文本变化(TEXT),节点的移除(REMOVE),节点的替换(REPLACE)四种变化。
INDEX变量用于标记当前比较到第几个节点,diff主函数内部定义一个patch变量,并最终返回出去,比较原则在walk函数内部进行处理。
walk函数接收四个参数,分别是旧节点,新节点,节点的index,以及patch补丁包,patch其实是一个数组,用于记录哪一个节点发生了哪一种类型的情况。
currentPatch是当前节点的补丁包,如果在新的模板中,新节点已经不存在了,证明之前的旧节点已经被移除了,所以我们可以记录一次REMOVE类型的补丁,currentPatch.push({ type: REMOVE, index: index })。
如果新旧节点都不存在子节点,说明新旧节点都只是有一个文本节点,那我们比较新旧节点的text文本,如果相同不做处理,不相同则记录一次补丁{ type: TEXT, text: newNode.text }。
如果新节点与旧节点的type类型想等,那我们就比较两者的属性,利用diffAttr函数进行节点属性比较,定义一个attrs属性对象,遍历旧属性,如果新旧属性的值不一样,那我们就把新的值保存到attrs对象中;其次,在新旧属性都存在的情况下我们需要比较style的变化,如果旧的style属性在新的style中找不到,则设置为空并保存到atts对象中去,最终返回atts。做完属性比较我们拿到了一个属性的变化情况,并赋值给newAttrs,我们展开newAttrs的key,如果数组长度大于0则说明属性发生了变化,需要记录一次ATTR类型的补丁{ type: ATTR, attr: newAttrs };如果新旧节点都存在子节点,我们须对子节点进行同样的处理,只需要调用diffChildren方法就可以了,diffChildren方法内部遍历每个子节点去递归进行比较;如果在新的节点里不存在子节点,则证明子节点被移除了,需要记录一次REMOVE补丁currentPatch.push({ type: REMOVE, index: index });如果旧节点没有子节点,而新节点上出现了子节点,则需要记录一次REPLACE补丁,currentPatch.push({ type: REPLACE, newNode })。
如果以上判断都不符合证明节点类型不一致,节点遭到了替换,记录一次REPLACE补丁,currentPatch.push({ type: REPLACE, newNode })。

做完这几部我们简陋的diff比较就算是做完了,当然了,Vue源码中的diff算法远比这里复杂很多倍。

patch.js

我们之前讲过,patch在这里的作用收集变更依赖,并实时更新。

// 收集dom的变化,以及将变化update到真实dom上
import { Element } from './vnode'
import { render } from './render';

let index = 0;

function patch(node, patchs) {
  walk(node, patchs)
}

function walk(node, patchs) {
  const currentIndex = index++
  const currentPatch = patchs[currentIndex]
  const childNodes = node.childNodes;

  if (currentPatch) {
    doPatch(node, currentPatch)
  }

  childNodes.forEach(child => {
    walk(child, patchs)
  })
}

function doPatch(node, patch) {
  patch.forEach(p => {
    switch (p.type) {
      case 'ATTR':
        Object.keys(p.attr).forEach(key => {
          setAttr(node, key, p.attr[key])
        })
        break;
      case 'TEXT':
        node.textContent = p.text
        break;
      case 'REPLACE':
        let newNode = p.newNode instanceof Element ? render(p.newNode) : document.textContent(p.newNode)
        node.parentNode.replaceChild(newNode, node)
        break;
      case 'REMOVE':
        node.parentNode.removeChild(node)
        break;
      default:
        break;
    }
  })
}

function setAttr(element, key, value) {
  switch (key) {
    case 'value':
      const nodeName = element.nodeName.toUpperCase()
      if (nodeName === "INPUT" || nodeName === "TEXTAREA") {
        element.value = value
      } else {
        element.setAttribute(key, value);
      }
      break;
    case 'style':
      setStyle(element, value)
      break;
    default:
      element.setAttribute(key, value);
      break;
  }
}

function setStyle(element, style) {
  for (const key in style) {
    element.style[key] = style[key]
  }
}

export { patch };

在这里我们引入了vNode的构造函数element以及render方法;我们先定义一个index=0,index的作用是用于记录层级,因为我们进行变更依赖收集时需要知道当前是哪一个子节点发生了变化。这里我们定义了一个patch方法,接收两个参数,一个是真实节点,另一个是根据diff.js计算出来的patch补丁包。
这里同样有一个walk函数,用于处理节点发生的变化。没调用一次walk函数index就会自增一次。如果当前index层级发生了改变,则进入doPatch方法进行处理,处理原则就是根据我们diff的时候打的每个补丁包,里面都有个type属性,我们利用switch去处理它们;如果是ATTR类型的,我们还要根据ATTR的类型去处理,因为属性有可能是style,有可能是value,这里只列举这两种。
如果ATTR是value属性,则还需要判断是不是input或textarea输入框,如果是就直接设置value属性,否则直接setAttribute;
如果ATTR是style属性,我们就遍历style属性,并直接设置到节点的style上;
如果两种都不是就直接setAttribute。
处理完当前节点如果还有子节点,还需要遍历子节点去walk处理。

index.js

import VueCompile from './vueCompile'
import h from './h'
import { render } from './render'
import { diff } from './diff'
import { patch } from './patch'

const template = `
    <div id="div" class="text" style="color: red;fontSize:12px">
    文本
    <span class="text">span文本</span>
    </div>
`

const { ast } = VueCompile.compile(template)
const { tag, attrsMap, children, type, text } = ast

function convertAttrsToProps(attrs) {
  const attributes = Object.keys(attrs).reduce((p, k) => {
    return { ...p, [k]: attrs[k] }
  }, {})
  if (attributes.style) {

    attributes.style = attributes.style.split(';').filter(attr => attr).reduce((pre, next) => {
      console.log(pre,next)
      const [key = "", value = ""] = next.split(':').map(value => value.trim())
      return { ...pre, [key]: value }
    }, {})
  }
  console.log(attributes)
  return attributes
}

function createElementByType(tag, attrsMap, children = [], type, text = "") {
  attrsMap = convertAttrsToProps(attrsMap)
  let vNode;
  switch (type) {
    case 1:
      const vNodeChildren = children.map(child => {
        const { tag = "", attrsMap = "", children = [], type, text = "" } = child
        return createElementByType(tag, attrsMap, children, type, text)
      })
      vNode = h(tag, attrsMap, vNodeChildren)
      break;
    case 3:
      vNode = text
      break;
    default:
      break;
  }

  return vNode
}

const vNode = createElementByType(tag, attrsMap, children, type, text)
// const vNode2 = h('div', { id: 'div', class: 'test', style: { color: 'red' } }, ["文本", h("span", { class: "text" }, "hello world")])
// const patchs = diff(vNode, vNode2)
const realDom = render(vNode, "#app")
// patch(realDom, patchs)

介绍完主要模块的作用,我们把它们全部引入到index.js进行集中调用;这里我们额外引入了vueCompile,模板的编译工作我们就不处理了,直接引入可以省下很大的功夫。
这里我们手写一段平时开发用的template用于测试使用。我们把template传入到Vuecomplie模块中的complie方法,拿到编译后到ast语法树,这里我们打印看一下ast是什么。
加载失败
我们只需要关注一些我们需要的信息就可以了,比如tag、attrsMap、children、type以及text。但attrsMap我们不能直接用,需要调用convertAttrsToProps转换适合我们格式的类型参数。
我们调用createElementByType时,先转换属性,用attrsMap变量接收。
convertAttrsToProps内部,第一步先拷贝一份原数据,避免在原对象上操作;如果属性值存在style属性,需要将style属性处理一下,因为解析出来的ast对象上字符串,我们先用split方法切割成数组,再过滤没有值的style属性,并把每一项style的属性值用trim方法去除前后空格,并最终返回出去。最终返回我们需要的attributes。
属性转换完毕后我们继续回到createElementByType上,switch节点的type值,有Vuecomplie解析出来的ast每个节点有一个type值对应,这里1就是div;这里我们只列举两种,1和3,div节点和文本节点。
如果是div,我们遍历他的子节点并递归使用createElementByType处理,拿到我们想要的虚拟节点子列表就可以调用我们之前已经写好的h.js来创建节点了,并把vNode节点返回出去。
如果是文本节点我们直接赋值给vNode并直接返回就OK了。

最后拿着我们生成的vNode去调用我们的render方法渲染我们的页面。
在这里插入图片描述

上面是运行的效果图,可以看到已经成功渲染出来了,下面我们试试修改一下节点能不能渲染出来,我们把最后面注释掉的几行代码放开,让他执行一下。
在这里插入图片描述
这里运行也是没有问题的,这里的新节点是我们手动写的一个节点。
至此,模板从编译到创建到渲染就走完了,主要的逻辑和vue差不多,主要走的还是这几步,最大的不同就是diff的算法,大家有空可以去了解一下。
源码github地址:https://github.com/DJYang666/vnode.git

Logo

前往低代码交流专区

更多推荐