vue升级到2.0之后就加入了Virtual DOM,对于Virtual DOM的概念这里就不做过多的说明了。本文主要分析一下vue中Virtual DOM渲染到真实DOM是如何实现的。
最近我在研究关于markdown解析相关的东西,在Github上找到相关的解析器基本都是会把markdown解析为字符串的方式。在参考了多个开源库之后选择了marked作为解析库去解析markdown。选择marked的原因是因为marked相对与其他库来说代码量相对较少,一个人比较容易搞定,并且解析能力与效率都是很不错的,并且star数量也比较高,所以代码质量也是非常高的。
在编写markdown解析的时候,第一目的就是把markdown解析为vnode,然后再渲染到真实DOM。所以自己实现解析markdown部分就是基于marked实现的,在解析完成之后就得到了vnode的树形结构。
目前解析为vnode基本已经实现,但很多地方还需要进行优化。目前最需要解决的就是把vnode渲染到真实DOM中去,所以就选择vue中的Virtual DOM作为基础去研究。其实写这篇文章也是为自己写vnode的render方法整理思路,所以这也是为什么标题叫做学习而不是分析了。对于文章中错误的还望给我指正。
vue中的vnode的数据结构
vue的vnode代码部分位于项目的src/core/vdom
文件夹下,vue的Virtual DOM是基于snabbdom修改的,vnode类的数据结构如下
export default class VNode {
tag: string | void;
data: VNodeData | void;
children: ?Array<VNode>;
text: string | void;
elm: Node | void;
ns: string | void;
context: Component | void; // rendered in this component's scope
functionalContext: Component | void; // only for functional component root nodes
key: string | number | void;
componentOptions: VNodeComponentOptions | void;
componentInstance: Component | void; // component instance
parent: VNode | void; // component placeholder node
raw: boolean; // contains raw HTML? (server only)
isStatic: boolean; // hoisted static node
isRootInsert: boolean; // necessary for enter transition check
isComment: boolean; // empty comment placeholder?
isCloned: boolean; // is a cloned node?
isOnce: boolean; // is a v-once node?
asyncFactory: Function | void; // async component factory function
asyncMeta: Object | void;
isAsyncPlaceholder: boolean;
ssrContext: Object | void;
constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
this.tag = tag
this.data = data
this.children = children
this.text = text
this.elm = elm
this.ns = undefined
this.context = context
this.functionalContext = undefined
this.key = data && data.key
this.componentOptions = componentOptions
this.componentInstance = undefined
this.parent = undefined
this.raw = false
this.isStatic = false
this.isRootInsert = true
this.isComment = false
this.isCloned = false
this.isOnce = false
this.asyncFactory = asyncFactory
this.asyncMeta = undefined
this.isAsyncPlaceholder = false
}
// DEPRECATED: alias for componentInstance for backwards compat.
/* istanbul ignore next */
get child (): Component | void {
return this.componentInstance
}
}
vnode是如何渲染到Real DOM的
vue中负责把vnode渲染到Real DOM主要工作的是位于src/core/vdom/patch.js
文件中的代码完成的
下面我们就来看看patch.js中都做了哪些工作呢,这里可以结合源码来看https://github.com/vuejs/vue/blob/dev/src/core/vdom/patch.js
在阅读源码的时候一般的顺序都是看看这个文件都依赖了哪些文件,其次也是最重要的,文件导出了什么东西,导出的就是文件的核心部分了,也就是阅读源码的入口。
在文件顶部就可以看到文件的依赖了,这里引入的文件如下(只写了重要模块的作用)
import VNode from './vnode' // vnode类
import config from '../config' // vue的全局配置,包括运行环境的检测
import { SSR_ATTR } from 'shared/constants'
import { registerRef } from './modules/ref'
import { activeInstance } from '../instance/lifecycle'
import {
warn, // 打印警告信息
isDef, // 判断值不为undefined和null
isUndef, // 判断值为undefined或null
isTrue,
makeMap, // 把字符串按``,``分割为数组
isPrimitive // 判断值是否为string、number或boolean
} from '../util/index' // 一些工具函数
导出模块一共有两个地方
- 位于[30行](https://github.com/vuejs/vue/blob/dev/src/core/vdom/patch.js#L30)的导出了一个空的vnode对象
- 位于70行的生成主patch函数的函数
可以看到最重要的函数是createPatchFunction,createPatchFunction接受一个参数,并返回了函数patch。patch函数接受6个参数:
- oldVnode: 旧的虚拟节点或旧的真实dom节点
- vnode: 新的虚拟节点
- hydrating: 是否要跟真是dom混合
- removeOnly: 特殊flag,用于<transition-group>组件
- parentElm:父节点
- refElm: 新节点将插入到refElm之前
patch函数的主要思想:
- 如果vnode不存在但oldVnode存在,则表示要移除旧的node,那么就调用invokeDestroyHook(oldVnode)来进行销毁
- 如果oldVnode不存在但是vnode存在,说明是要创建新节点,那么就调用createElm来创建新节点
- 当vnode和oldVnode都存在时:
- 如果oldVnode与Vnode是同一节点是就调用patchVnode处理去比较两个节点的差异
- 当vnode和oldVnode不是同一个节点时,如果oldVnode是真实DOM节点或hydrating设置为true,需要用hydrate函数将虚拟DOM和真实DOM进行映射,然后将oldVnode设置为对应的虚拟dom,找到oldVnode.elm的父节点,根据vnode创建一个真实dom节点并插入到该父节点中oldVnode.elm的位置
patchVnode函数主要思路:
- 如果vnode===oldVnode则直接返回,不执行任何操作
- 如果oldVnode跟vnode都是静态节点,且具有相同的key,当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode.elm和oldVnode.child都复制到vnode上,也不用再有其他操作
- 如果vnode不是text节点
- 如果oldVnode与vnode都有子节点,并且子节点不相等,就调用updateChildren执行更新子节点操作
- oldVnode没有子节点,vnode有子节点,则创建节点
- oldVnode有子节点,vnode没有子节点,就移除旧的节点
- 如果oldVnode为text节点,就移除文本节点
- vnode为text节点就设置节点文本内容
updateChildren函数实现功能:
- 分别获取oldVnode和vnode的第一个和最后一个节点,赋值给oldStartVnode、oldEndVnode、newStartVnode、newEndVnode,oldStartIdx、newStartIdx、oldEndIdx、newEndIdx分别为oldVnode与vnode的子节点的下标,和最后一个子节点的下标,然后在while循环中执行比较,并且移动oldStartIdx和newStartIdx,直到oldStartIdx大于oldEndIdx或者newStartIdx大于newEndIdx
- 假如没有oldStartVnode就将oldStartIdx加1,并重新求得oldStartVnode,进入下一次循环
- 假如没有oldEndVnode就将oldEndIdx减1,并重新求得oldEndVnode,进入下一次循环
- 假如oldStartVnode和newStartVnode是相同类型的节点,就调用patchVnode去比较两个节点,并使oldStartIdx和newStartIdx都加1,同时开始节点也更新对应下标的节点
- 假如oldEndVnode和newEndVnode是同类型节点,就调用patchVnode去比较两个节点,并使oldEndIdx和newEndIdx都减去1,同时开始节点也更新对应下标的节点
- 假如oldStartVnode和newEndVnode是同类型节点,就调用patchVnode去比较两个节点,如果removeOnly是false,那么可以把oldStartVnode.elm移动到oldEndVnode.elm之后,并使oldStartIdx加1,newEndIdx减去1,同时更新对应节点为最新的节点
- 假如oldEndVnode和newStartVnode是同类型的节点,就调用patchVnode去比较两个节点,如果removeOnly是false,那么可以把oldEndVnode.elm移动到oldStartVnode.elm之前,并使oldEndIdx减1,newStartIdx加1,同时更新对应节点为最新的节点
- 如果以上条件都不匹配,则查找oldVnode中与vnode具有相同key的节点,并将查找的结果赋值给elmToMove。
- 如果找不到相同key的节点,则表示是新创建的节点
- 如果找到了,就判断这两个节点是否为同一类型的节点
- 若为同一类型就调用patchVnode,就将对应下标处的oldVnode设置为undefined,如果removeOnly是false,就把elmToMove.elm插入到oldStartVnode.elm之前,newStartIdx加1,且把newStartVnode设置为下一个节点
- 如果没有找到就直接创建新的节点,并执行newStartVnode = newCh[++newStartIdx]-
- 循环结束后,如果oldStartIdx > oldEndIdx,就把vnode中间没有循环到的节点添加到新DOM中
- 如果newStartIdx > newEndIdx,就把oldVnode中没有遍历到的节点从DOM中移除
至此,vue中实现VDOM至真实DOM的就基本讲解完成了,至于生命周期,在这里没有提及,只要在对应的地方加入生命周期回调就ok了
最后成果
在实现了vnode之后,自己编写了一个比较简单的diff-render的类,基本原理和上面讲到的差不多,实现效果也差不多,只在添加和删除新元素的时候会重新渲染同级的后面的兄弟node,地址https://github.com/markdown365/markdown365-parser,有兴趣的朋友可以下载下来看一下效果。最后写本文的时候参考了Vue原理解析之Virtual Dom部分内容,并且这篇文章讲的十分的详细,也推荐去看看
由于水平有限,文章中有不正确的地方还望原谅与指正,谢谢!