随便写写之vue虚拟dom原理

3,851 阅读6分钟

vue面试的灵魂四连击,你接住吗?

  1. 你对虚拟 DOM 原理的理解?
  2. Vue 的 diff 算法有了解过吗?
  3. 抽象语法树是什么,能介绍一下吗?
  4. 从 new 一个 Vue 对象开始,Vue 的内部运行机制是什么样的? 知耻而后勇,我们静下心来好好在学习一下 vue 吧?从哪里入手呢?我们以源码的角度看下从new 一个 Vue 实例开始,Vue 内部发生了什么?

1. Vue 初始化和挂载

image.png

let vm = new Vue({ el: '#app' })

从Vue 的构造函数开始入手:

function Vue (options) {
    if (process.env.NODE_ENV !== 'production' && !(this instanceof Vue)) {
       warn('Vue is a constructor and should be called with the `new` keyword')
    }
    this._init(options)
}

再看下 this._init(options)

Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    vm._uid = uid++

    let startTag, endTag
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      startTag = `vue-perf-start:${vm._uid}`
      endTag = `vue-perf-end:${vm._uid}`
      mark(startTag)
    }
    //一个避免 vm 实例被观察的标志
    vm._isVue = true
    //内部组件的options._isComponent是true,mian.js里面new Vue()时为false
    if (options && options._isComponent) {
      //初始化内部组件
      initInternalComponent(vm, options)
    } else {
    	//合并options选项
    	//resolveConstructorOptions函数是获取当前实例中的构造函数的options选项以及它所有的父级的构造函数的options
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
    /* istanbul ignore else */
    //为Vue实例的_renderProxy属性赋值
    if (process.env.NODE_ENV !== 'production') {
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }
    // expose real self
    vm._self = vm
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm)
    initState(vm)
    initProvide(vm) 
    callHook(vm, 'created')

    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      vm._name = formatComponentName(vm, false)
      mark(endTag)
      measure(`vue ${vm._name} init`, startTag, endTag)
    }
    //如果实例化Vue时传递了el选项,则自动执行$mount进行挂载
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }

从生命周期来看, _init 主要完成下列工作:

  • 初始化生命周期、事件、 render
  • 调用 beforeCreate 钩子函数
  • 初始化 props、methods、data、computed 与 watch ,并且对 options 中的数据进行"响应式化"(双向绑定)以及完成依赖收集
  • 调用 created 钩子函数
  • 挂载组件

2. 模板编译 compile

image.png

export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  const ast = parse(template.trim(), options)  //生成抽象语法树
  if (options.optimize !== false) {
    optimize(ast, options)       //标记静态节点
  }
  const code = generate(ast, options) //生成渲染函数(及静态渲染函数)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})

这里 baseCompile 函数可以划分为三个阶段:

  • parse 解析阶段。用正则等方式解析 template 中的指令、class、style等数据,形成 ast 。
  • optimize 优化阶段。标记 static 静态节点。在新旧节点比较变更时,diff 算法会直接跳过静态节点,这里优化了 patch 的性能。
  • generate 代码生成阶段。将 ast 转化成 render function 字符串,得到结果是 render 的字符串以及 staticRenderFns 字符串。 三个模块具体实现对应的源码:
/*解析阶段*/
src/compiler/parser/index.js
/*优化阶段*/
src/compiler/optimizer.js
/*代码生成阶段*/
src/compiler/codegen/index.js

3. Watcher 到更新视图

image.png Watcher 对象会调用 updateComponent 方法来实现更新视图:

updateComponent = () => {
    vm._update(vm._render(), hydrating)
}

updateComponent 就执行一句话,_render 函数会返回一个新的 VNode 节点,传入 _update 中与旧的 VNode 对象进行对比,经过一个 patch 的过程得到两个 VNode 节点的差异,最后我们只需要将这些差异的对应 DOM 进行修改即可。

3.1. VNode

这里 VNode 是一种全新的性能优化解决方案,它可以理解为用 JS 的计算性能来换取操作 DOM 所消耗的性能。 最直观的思路就是我们以数据驱动的思想去开发,我们只需要关注数据操作,而不是去操作真实 DOM。 我们可以用 JS 模拟出一个 DOM 节点,这里简称 VNode。当数据发生变化时,我们对比变化前后的 VNode,通过 diff 算法 计算出需要更新的地方,最后一起更新视图。另外 VNode 的存在也使得 Vue 不在依赖浏览器环境,使其有了服务端渲染的能力。

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
    }
    get child(): Component | void {
        return this.componentInstance
    }
}

3.2. Patch

前面说过 _update 会将新旧两个 VNode 进行一次 patch 的过程,得到两 VNode 之间最小差异,然后将这些差异渲染到视图。其实仔细想想就三种操作:

  1. 创建节点
  2. 删除节点
  3. 更新节点 image.png

diff 算法相当的高效。它是一种通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有O(n)。上图代表新旧的 VNode 进行 patch 的过程,他们只是在同层级的 VNode 之间进行比较得到变化(第二张图中相同颜色的方块代表互相进行比较的 VNode 节点),然后修改变化的视图,所以十分高效。在 patch 的过程中,如果两个 VNode 被认为是同一个 VNode (sameVnode),才会进行深度的比较,得出最小差异,否则直接删除旧有 DOM 节点,创建新的 DOM 节点。 image.png 如上图所示,新、老VNode 节点的左右头尾两侧都有一个变量标记,在遍历中这几个变量都向中间靠拢。当 oldStartIdx > oldEndIdx 或者 newStartIdx > newEndIdx 时结束循环。在遍历中,如果存在 key ,并且满足 sameVnode ,复用该 DOM 节点,否则创建一个新的 DOM 节点。这里,oldStartVnode、oldEndVnode 与 newStartVnode、newEndVnode 两两比较一共有 4 种比较方法:

  1. 当新老 VNode 节点的 start 或者 end 满足 sameVnode 时,也就是 sameVnode(oldStartVnode, newStartVnode) 或者 sameVnode(oldEndVnode, newEndVnode) ,直接将该 VNode 节点进行 patchVnode 即可。
  2. 如果 oldStartVnode 与 newEndVnode 满足 sameVnode ,即 sameVnode(oldStartVnode, newEndVnode) ,这时候说明 oldStartVnode 已经跑到了 oldEndVnode 后面去了,进行 patchVnode 的同时还需要将真实 DOM 节点移动到 oldEndVnode 的后面。
  3. 如果 oldEndVnode 与 newStartVnode 满足 sameVnode ,即 sameVnode(oldEndVnode, newStartVnode)。这说明 oldEndVnode 跑到了 oldStartVnode 的前面,进行 patchVnode 的同时真实的 DOM 节点移动到了 oldStartVnode 的前面。
  4. 通过 createKeyToOldIdx 会得到一个 oldKeyToIdx ,里面存放了一个 key 为旧的 VNode , value 为对应 index 序列的哈希表。从这个哈希表中可以找到是否有与 newStartVnode 一致 key 的旧的 VNode 节点,如果同时满足 sameVnode , patchVnode 的同时会将这个真实 DOM(elmToMove) 移动到 oldStartVnode 对应的真实 DOM 的前面

当结束时,如果oldStartIdx > oldEndIdx ,这个时候老的 VNode 节点已经遍历完了,但是新的节点还没有。说明了新的 VNode 节点实际上比老的 VNode 节点多,也就是比真实 DOM 多,需要将剩下的(也就是新增的) VNode 节点插入到真实 DOM 节点中去,此时调用 addVnodes (批量调用 createElm 的接口将这些节点加入到真实 DOM 中去)。同理,当 newStartIdx > newEndIdx 时,新的 VNode 节点已经遍历完了,但是老的节点还有剩余,说明真实 DOM 节点多余了,需要从文档中删除,这时候调用 removeVnodes 将这些多余的真实 DOM 删除。

4. 映射到真实DOM

虚拟 DOM 提供一些钩子函数,分别在不同的时期会进行调用。 这里没有对 attr、class、props、events、style 以及 transition (过渡状态)的 DOM 属性进行操作的描述。下一步有机会补充。

const hooks = ['create', 'activate', 'update', 'remove', 'destroy'] /*构建 cbs 回调函数*/
for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
        if (isDef(modules[j][hooks[i]])) {
            cbs[hooks[i]].push(modules[j][hooks[i]])
        }
    }
}

参考资料: github.com/vuejs/vue