利用思维导图带你阅读Vue源码

4,071 阅读10分钟

Vue源码解读

版本:vue@2.6.11 前言:本篇纯属个人对知识点的整理,非整体原创。一些题目和答案来源于其他文章的借鉴,并融入自己理解,最终产出这篇文章。

在这之前,偶尔的会去阅读Vue相关知识点的源码,比如看到一些面试:“nextTick的原理是什么?” ,或者是:“请说一下响应式的实现原理”,或者在开发中遇到了棘手的bug(耽误我下班啦),也就不得不去找资料 or 阅读相关源码啦,以上等等情况.... 大家也是不是跟我差不多?(say yes, pls)

I know, so, 基于这种情况所了解到的知识点,太过碎片化了

那有没有一种东西,能把我们所学到的东西去串起来?Of course!

本yu就整理了一份简单的阅读Vue源码思维导图,能够方便你去理解整个Vue运行的机制,也方便知识回顾时,思维导图能够帮助你更快更直接的找到那种feel~~

废话不多唠,先上干货:

想要自由缩放的点击 Vue源码思维导图

Vue源码思维导图

这一切的开始,还得从......

算了,还是从new Vue()说起吧

生命周期

我是不是该在这扯些东西???

Vue组件有哪些生命周期?

  • beforeCreate

    在实例初始化之后,数据观测 (data observer) 和 event/watcher 事件配置之前被调用。

  • created

    在实例创建完成后被立即调用。在这一步,实例已完成以下的配置:数据观测 (data observer),property 和方法的运算,watch/event 事件回调。然而,挂载阶段还没开始,$el property 目前尚不可用。

  • beforeMount

    在挂载开始之前被调用:相关的 render 函数首次被调用。 该钩子在服务器端渲染期间不被调用。

  • mounted

    实例被挂载后调用,这时 el 被新创建的 vm.$el 替换了。如果根实例挂载到了一个文档内的元素上,当 mounted 被调用时 vm.$el 也在文档内。 注意 mounted 不会保证所有的子组件也都一起被挂载。如果你希望等到整个视图都渲染完毕,可以在 mounted 内部使用 vm.$nextTick该钩子在服务器端渲染期间不被调用。

  • beforeUpdate

    数据更新时调用,发生在虚拟 DOM 打补丁之前。这里适合在更新之前访问现有的 DOM,比如手动移除已添加的事件监听器。 该钩子在服务器端渲染期间不被调用,因为只有初次渲染会在服务端进行。

  • updated

    由于数据更改导致的虚拟 DOM 重新渲染和打补丁,在这之后会调用该钩子。 该钩子在服务器端渲染期间不被调用。

  • activated

    被 keep-alive 缓存的组件激活时调用。 该钩子在服务器端渲染期间不被调用。

  • deactivated

    官网原文:被 keep-alive 缓存的组件停用时调用。 该钩子在服务器端渲染期间不被调用。

  • beforeDestroy

    实例销毁之前调用。在这一步,实例仍然完全可用。 该钩子在服务器端渲染期间不被调用。

  • destroyed

    实例销毁后调用。该钩子被调用后,对应 Vue 实例的所有指令都被解绑,所有的事件监听器被移除,所有的子实例也都被销毁。 该钩子在服务器端渲染期间不被调用。

Vue中组件生命周期调用顺序是什么样的?

  • 组件的调用顺序都是先父后子,渲染完成的顺序是先子后父。

  • 组件的销毁操作是先父后子,销毁完成的顺序是先子后父。

说明:

这里有一个父组件parent,和2个子组件child1child2,child1有子组件child1child

那么此时的声明周期顺序应该是:

组件的调用顺序都是先父后子,为什么?

这个我没啥好说,难道要先子后父?

渲染完成的顺序是先子后父,为什么?

insertedVnodeQueue 被插入的虚拟节点队列

function patch(vnode) {
  // 1.虚拟节点队列
  const insertedVnodeQueue = [];
  
  // 2.创建新节点,具体查看下面函数的具体内容
  // create new node
  createElm(
    vnode,
    insertedVnodeQueue,
    // extremely rare edge case: do not insert if old element is in a
    // leaving transition. Only happens when combining transition +
    // keep-alive + HOCs. (#4590)
    oldElm._leaveCb ? null : parentElm,
    nodeOps.nextSibling(oldElm)
  )
  
  // 3.清空insertedVnodeQueue队列
  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
}

/** 创建DOM元素,并且append到父元素 */
function createElm (
 vnode,
 insertedVnodeQueue,
 parentElm,
 refElm,
 nested,
 ownerArray,
 index
) {
  const data = vnode.data
  const children = vnode.children
  const tag = vnode.tag
  
  // 1.创建了DOM
  vnode.elm = vnode.ns
    ? nodeOps.createElementNS(vnode.ns, tag)
  	: nodeOps.createElement(tag, vnode)
   setScope(vnode)

   // 2.递归创建子vnode的DOM
   createChildren(vnode, children, insertedVnodeQueue)
   // 重点!!!
   // 3.递归创建好了子vnode,才把自己的vnode推到虚拟节点队列,此时,父虚拟节点在子虚拟节点后面
   if (isDef(data)) {
     // 实质:insertedVnodeQueue.push(vnode)
     invokeCreateHooks(vnode, insertedVnodeQueue)
   }
   
   /** DOM操作了 将生成的DOM append到target DOM(parentVnode.elm) */
   insert(parentElm, vnode.elm, refElm)
}



/** 将DOM append到父元素 */
function insert (parent, elm, ref) {
  if (isDef(parent)) {
    if (isDef(ref)) {
      if (nodeOps.parentNode(ref) === parent) {
        nodeOps.insertBefore(parent, elm, ref)
      }
    } else {
      nodeOps.appendChild(parent, elm)
    }
  }
}
// 清空insertedVnodeQueue队列
function invokeInsertHook (vnode, queue, initial) {
  // delay insert hooks for component root nodes, invoke them after the
  // element is really inserted
  if (isTrue(initial) && isDef(vnode.parent)) {
    vnode.parent.data.pendingInsert = queue
  } else {
    for (let i = 0; i < queue.length; ++i) {
      queue[i].data.hook.insert(queue[i])
    }
  }
}

这里再次解释下上面代码的逻辑:

  1. 在第一次patch,创建新的vnode节点时,会先定义一个insertedVnodeQueue 被插入的虚拟节点队列 然后调用createElm创建DOM元素,并且收集了vnode到insertedVnodeQueue 队列

    收集到顺序如何?

    请查看createEle函数第2点和第3点可以发现,如果有子vnode,就会先完成创建子vnode的DOM生成(即:优先挂载),最后才是父vnode进行挂载 故,若parent、child、grandson三个组件它们的关系是:

    -- parent

    -- child

    ​ -- grandson

    那么第一步会生成parent的DOM,第二步递归式的生成child的DOM,以此类推,生成grandson的DOM

    grandson组件没有子节点了,那么就会将grandson 的vnode添加到insertedVnodeQueue队列,接下来是child,parent,此时insertedVnodeQueue是[grandsonVnode, childVnode, parentVnode]

    然后parent将生成的DOM插入到目标元素下 最后patch的末尾调用了invokeInsertHook,主要是清空insertedVnodeQueue队列

    综合以上:渲染完成的顺序是先子后父

组件的销毁操作是先父后子,销毁完成的顺序是先子后父。

销毁组件的方式是调用组件实例的销毁方法vm.$destroy()

Vue.prototype.$destroy = function () {
  const vm: Component = this
  
  // 开始销毁组件
  callHook(vm, 'beforeDestroy')
  
  // call the last hook...
  vm._isDestroyed = true
  // invoke destroy hooks on current rendered tree
  vm.__patch__(vm._vnode, null)
  
  // 销毁完成
  callHook(vm, 'destroyed')
}

销毁方法先调用beforeDestroy钩子

然后调用patch函数来销毁vnode

最后调用销毁完成钩子destroyed

那么,调用patch函数来销毁vnode具体是怎样的一个过程?

首先来看下patch函数

function patch() {
  /** if -> 销毁: 如果 newVnode传了`null`和oldVnode有传 说明:销毁oldVnode节点 */
  if (isUndef(vnode)) {
    if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
    return
  }
}

所以patch函数销毁vnode主要调用了invokeDestroyHook

那么下面看下invokeDestroyHook主要做了什么?

/**
 * 销毁vnode和子vnode
 * */
function invokeDestroyHook (vnode) {
  let i, j
  const data = vnode.data
  if (isDef(data)) {
    /**
     * 销毁vnode
     **/
    if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode)
  }
  /**
   * 递归子节点vnode,进行销毁操作
   **/
  if (isDef(i = vnode.children)) {
    for (j = 0; j < vnode.children.length; ++j) {
      invokeDestroyHook(vnode.children[j])
    }
  }
}

根据上面可以看的出来,invokeDestroyHook函数先是调用当前的vnode的destroy hook,

vnode destory hook

componentVNodeHooks = {
  destroy (vnode: MountedComponentVNode) {
    const { componentInstance } = vnode
    if (!componentInstance._isDestroyed) {
      	// 调用的是实例的销毁方法
        componentInstance.$destroy()
    }
  }
}

然后在递归子节点vnode,一一的进行实例销毁。 但是父vnode只有等子vnode销毁完成后,才会调用destoryed钩子, 所以组件销毁操作是先父后子,销毁完成的顺序是先子后父。

在什么阶段才能访问操作DOM?

mounted阶段就可以访问操作DOM了。

说明:

在组件执行挂载$mount方法时,会调用beforeMount钩子,此时DOM还未生成 然后开始准备生成DOM的一些准备:

  1. 执行_render方法,生成vnode
  2. 执行__patch__方法生产了DOM,并被insert到父元素(具体参考别人的:VirtualDOM与diff 中的patch),DOM已挂载

调用mounted钩子。

你的接口请求一般放在哪个生命周期中?

在created、beforeMount和mounted都可以。

说明:因为在这三个钩子函数中,data 已经创建,可以将服务端端返回的数据进行赋值。 但我开发中习惯于用created,原因有以下:

  1. 可以尽可能早的获取服务端到数据
  2. beforeMountmounted 在服务器端渲染期间不被调用,created是会被调用的,故放在created有助于一致性。

继续问:那为啥不使用beforeCreate钩子,接口请求是异步的,应该不会影响到对服务端返回到数据进行赋值呀?

回答:如果单纯的只是接口请求,我想你说的是没啥毛病的; 但具体的情况也并非如你所说的简单,在请求接口前,往往都是需要做些数据的获取或者初始化,这个过程就很可能需要对data数据进行操作,基于此,我还是觉得使用beforeCreate钩子是不够稳定的。

基于发布订阅模式的响应系统

什么是发布订阅模式?可查看Vue源码思维导图中的左下角的发布订阅模式思维图

组件中的data为什么是一个函数?

在官方文档中:data的类型是 Object | Function, 但给出了一个限制:组件的定义只接受 function

我的简单回答:就是不是通过函数返回的对象,相同组件的多个实例公用一个数据对象,就会存在引用类型的隐患。

官方回答:当一个组件被定义,data 必须声明为返回一个初始数据对象的函数,因为组件可能被用来创建多个实例。如果 data 仍然是一个纯粹的对象,则所有的实例将共享引用同一个数据对象!通过提供 data 函数,每次创建一个新实例后,我们能够调用 data 函数,从而返回初始数据的一个全新副本数据对象。

如果需要,可以通过将 vm.$data 传入 JSON.parse(JSON.stringify(...)) 得到深拷贝的原始数据对象。

说说Vue的双向数据绑定原理

根据Vue源码思维导图中可发现:Vue的数据的可响应性是发生在beforeCreatecreated之间的

响应式原理

主要原理是基于es5的API:Object.defineProperty让data的property拥有getter&setter,从而是可响应性的

function initData (vm: Component) {
  let data = vm.$options.data
  
  // 1.代理每个属性到实例vm上
  // proxy data on instance
  const keys = Object.keys(data)
  let i = keys.length
  while (i--) {
    proxy(vm, `_data`, key)
  }

  // 观察数据data
  // observe data
  observe(data, true /* asRootData */)
}

export function observe (value: any) {
	Object.keys(value).forEach((key) => defineReactive(value, key, value[key]))
}

/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 获取到data的属性的描述器,只有可配置性的才能操作修改
  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  // 重新定义属性,让属性拥有`getter` & `setter`
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      return value
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }

      // #7981: for accessor properties without setter
      if (getter && !setter) return
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
    }
  })
}

依赖收集

依赖收集是怎样的一个过程? 谁收集谁? 是怎样收集的? 收集的目的是什么?

脑子一下蹦出这么多的疑问,那我就凭着这些疑问一一的找出答案吧,let's go!

这一切的一切还得从app.$mount('#app')说起:

为啥?follow me.

app.$mount('#app')大概做了这个操作简约版,后续源码相关都只会挑重点来展示

Vue.prototype.$mount = function (el) {
  const vm = this;
  // ...
  callHook(vm, 'beforeMount')
  // ...
  
  // 核心点:在这创建了一个render `Watcher`
  new Watcher(vm, () => {
    // 1.调用渲染函数返回一个虚拟节点
    const vnode = vm._render();
    // 2.调用patch生成真实的DOM
    //   渲染真实DOM,就会使用到数据进行模版填充
    //	 使用到数据就会被监听器`Observer`给监听到
    //	 由此触发了数据data的property的`getter`
    vm.__patch__(vm.$el, vnode);
  })

  // ...
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  // ...
}
监听器 Observer

上面提到了Observer,这是什么东西?之前怎么没提到呢?

好吧....Observer的创建应该是在上面的响应式原理中的observe方法创建的,这里在补充一下:

export function observe (value: any, asRootData: ?boolean): Observer | void {
  let ob: Observer | void
 	// ...
  // 在这里就创建了监听器
  ob = new Observer(value)
 	// ...
  return ob
}

我们来看看监听器Observer类:

export class Observer {
  value: any;

  constructor (value: any) {
    this.value = value
   
    // 1.给对象定一个监听器实例
    def(value, '__ob__', this)
    // 2.对数据进行深度观察
    // 	这里对数据的一些原型方法进行了复写
    if (Array.isArray(value)) {
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  /**
   * Walk through all properties and convert them into
   * getter/setters. This method should only be called when
   * value type is Object.
   */
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  /**
   * Observe a list of Array items.
   */
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

那么这个renderWatcher具体有什么东东?

观察者 Watcher
class Watcher {
  value: any;
	getter: Function;
	
	constructor (
    vm: Component,
    expOrFn: string | Function
  ) {
    this.vm = vm
  	
    // getter 就是一个render函数
    this.getter = expOrFn
  
    this.value = this.get()
  }

  /**
   * Evaluate the getter, and re-collect dependencies.
   */
  get () {
    let value;
    // 把当前的render `Watcher`推到全局`Dep.target`
    pushTarget(this)
    // 执行了render函数,可以通过触发property.getter进行对观察(Watcher),也就是当前Watcher的实例this,property的发布者(Dep)就会把观察者(Dep.target)收集起来
    value = this.getter();
    // 弹出当前的`Watcher`
    popTarget()
    return value
  }
}
发布者 Dep

在类Watcher看到了Dep,那么发布者又是在哪里定义的? 哪来的你???

好吧....Dep是在定义响应式属性defineReactive时候创建的,这里在补充一下:

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 1.这里创建了一个发布者
  const dep = new Dep()
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    // 2.在`getter`时候,进行依赖收集
    get: function reactiveGetter () {
      if (Dep.target) {
        dep.depend()
      }
    },
    // 3.在`setter`时候,通知观察这更新
    set: function reactiveSetter (newVal) {
      dep.notify()
    }
  })
}

总结:

  1. 依赖收集是怎样的一个过程?

    首先收集是发生在render时,渲染函数对数据的获取被监听器Observer拦截到了

    比如这里有个属性name,render的时候,对name进行值的获取

    监听器Observer拦截获取操作,并触发name属性的getter

    getter对当前的观察者进行依赖收集:dep.depend() ->dep.addSub(watcher)

  2. 谁收集谁?

    收集过程中提到了3个角色:

    • 监听器 Observer
    • 观察者 Watcher
    • 发布者 Dep

    根据第一点得出结论:发布者Dep收集观察者Watcher

等等....那收集的目的是啥?

往下看....

数据更新通知

<template>
	<div>
    My name is {{name}}.
  </div>
</template>
<script>
export default {
  data() {
		return {
      name: '?'
    }
  },
	created() {
		setTimeout(() => {
      this.name = 'Yu';
    }, 2000);
  }
}
</script>

上面组件中,有一个属性值name,和一个钩子事件:在2s后更新name的值为Yu

在初次render时候,字符串模版渲染对name属性进行了获取,被name属性的监听器监听到,并触发了getter,然后发布者dep将当前的观察者watcher收集作为了依赖。

2s后,name属性被更新了,这时候,同样被name属性的监听器监听到,但触发的是setter,根据上面提到的:

// 2.在`setter`时候,通知观察这更新
set: function reactiveSetter (newVal) {
  dep.notify()
}
class Dep {
  subs: Array<Watcher>;
  
  notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

所以,上面的答案就在此:

setter里面做的就是发布者通知已被收集到的观察者进行更新,即:watcher.update()

由此进入数据更新过程

数据更新过程

下面就是更新函数,主要逻辑:

  1. 数据更新是不是惰性的lazy,是的话就标记为脏数据,在数据被获取的时候,在根据是否脏数据进行重新计算。我了解到的只有计算属性了computed.
  2. 数据更新是不是同步的sync,是的话立即执行更新watcher.run()
  3. 否则,将更新推到一个异步更新队列,后续统一更新
class Watcher {
  /**
   * Subscriber interface.
   * Will be called when a dependency changes.
   */
  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
}

整个过程粗略的看Vue源码思维导图数据更新过程

在这里不再详细描述。

这里重点有个知识点异步更新队列,可前往异步更新队列

异步更新队列

/**
 * 将观察者推入观察者队列。
 * 具有重复ID的watcher将被跳过
 * 在刷新队列时推送。
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  // 如果有重复ID的watcher将被忽略
  if (has[id] == null) {
    has[id] = true
    // 如果没有在冲洗,即还没有开始清空队列
    // 将任务watcher添加到队列中
    if (!flushing) {
      queue.push(watcher)
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // 在非等待时候,即可开始启动冲洗队列,呜呜呜~~~
    // 只会开启一次哦
    // 
    // queue the flush
    if (!waiting) {
      waiting = true

      if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue()
        return
      }
      // 不过这里是异步的,就是说:
			// 在真正冲洗队列前,还有新的watcher被添加到队列中,
      // 直到所有同步代码执行完毕,没有新的watcher了,才是真正开始冲洗
      nextTick(flushSchedulerQueue)
    }
  }
}

冲洗队列

这块的代码会比较简单,就是遍历的去执行每个watcher的run方法,重新计算新的值,并执行watch的cb函数 还有就是对watcher的执行状态进行管理,保证每个watcher只会被执行一次。

function flushSchedulerQueue () {
	for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    // 其实就执行观察者的run方法
    watcher.run()
	}
}
class Watcher {
  run () {
    if (this.active) {
      const value = this.get()
      if (
        value !== this.value ||
        // Deep watchers and watchers on Object/Arrays should fire even
        // when the value is the same, because the value may
        // have mutated.
        isObject(value) ||
        this.deep
      ) {
        // set new value
        const oldValue = this.value
        this.value = value
        if (this.user) {
          try {
            this.cb.call(this.vm, value, oldValue)
          } catch (e) {
            handleError(e, this.vm, `callback for watcher "${this.expression}"`)
          }
        } else {
          this.cb.call(this.vm, value, oldValue)
        }
      }
    }
  }
}

nextTick

在下次 DOM 更新循环结束之后执行延迟回调,在修改数据之后立即使用 nextTick 来获取更新后的 DOM。

nextTick有两种使用方式

  1. 传了cb函数回调,不会返回一个Promise
  2. 没有传cb函数回调,会返回一个Promise

先上源码:

export function nextTick (cb?: Function, ctx?: Object) {
  // promise的resolve控制器
  let _resolve
  
  // 根据不同参数,重新包装传进来的`cb`或者`_resolve`
  callbacks.push(() => {
    // 如果有cb回调,尝试的去调用
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      // 不然的话,就是第二种使用方式,返回一个`Promise`
      _resolve(ctx)
    }
  })
  // 这里的代码目的也是保证一轮更新只会被启动一次,不会启动多次
  if (!pending) {
    pending = true
    // 一个根据浏览器差异返回最终的一个异步方案
    timerFunc()
  }
  
  // 如果传了没有传cb参数,并且客户端有`Promise`这个对象,就返回一个`Promise`
  // 也就是第二种使用方式
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

nextTick主要使用了宏任务微任务

根据上面nextTick发现有个timerFunc函数:

这个函数主要是根据执行环境分别尝试采用:

Promise、MutationObserver、setImmediate

如果以上都不行则采用setTimeout定义了一个异步方法,

多次调用nextTick会将方法存入队列中,通过这个异步方法清空当前队列。

结尾:好了,数据的整个大概的更新过程就是这样子的了,如果还有疑问或者有很重要的细节漏了,请反馈一下~

相关文章

mp.weixin.qq.com/s/y9olkntgR…