4. 「vue@2.6.11 源码分析」new Vue() 整体流程和组件渲染之前的准备工作

749 阅读6分钟

这是一个系列文章,请关注 vue@2.6.11 源码分析 专栏

new Vue(...)

// src/core/instance/index.js
function Vue (options) { 
  this._init(options);
}
  • 组件实例的初始化(事件、方法、属性、数据等等)
  • 组件的渲染(vm._init中会去调用vm.$mount方法,个人感觉这个方法调用放到构造函数中更合适,逻辑更清晰些)。实际上在子组件的渲染逻辑中,这个两步就是分开的,如下:
    const componentVNodeHooks = {
      init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
        if (/** keepAlive场景 **/) {
            //... 忽略
        } else {
          // 1. createComponentInstanceForVnode内部会去调用子组件构造函数(Vue.extend返回,后面会说到)
          // 构造函数会直接调用 vm._init 进行组件实例的初始化
          const child = vnode.componentInstance = createComponentInstanceForVnode(vnode, activeInstance)
          // 2. 开始生成虚拟DOM,并将虚拟DOM同步到界面上
          child.$mount(hydrating ? vnode.elm : undefined, hydrating)
        }
      },
    }
    

因此:我将整体流程划分为两个阶段: image.png

下面我们先看下组件实例化过程中的初始化工作都有哪些,见下面vm._init方法分析。

vm._init: 组件实例初始化(渲染之前的准备工作)

// src/core/instance/init.js
export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    vm._uid = uid++ 
    vm._isVue = true

    // merge options
    if (options && options._isComponent) {
      // optimize internal component instantiation
      // since dynamic options merging is pretty slow, and none of the
      // internal component options needs special treatment.
      initInternalComponent(vm, options)
    } else {
      vm.$options = mergeOptions(resolveConstructorOptions(vm.constructor), options || {}, vm)
    }

    vm._renderProxy = vm
    vm._self = vm // expose real self

    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')
 
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

首先是收集当前组件实例的options并保存到 vm.$options(主要目的是复用公共选项,公共选项保存在祖先构造函数中,如Vue.options),这里区分了两种场景:组件、new Vue()

  1. 组件:针对组件的初始化,走initInternalComponent
    export function initInternalComponent (vm: Component, options: InternalComponentOptions) {
      const opts = vm.$options = Object.create(vm.constructor.options)
      // doing this because it's faster than dynamic enumeration.
      const parentVnode = options._parentVnode
      opts.parent = options.parent
      opts._parentVnode = parentVnode
    
      const vnodeComponentOptions = parentVnode.componentOptions
      opts.propsData = vnodeComponentOptions.propsData
      opts._parentListeners = vnodeComponentOptions.listeners
      opts._renderChildren = vnodeComponentOptions.children
      opts._componentTag = vnodeComponentOptions.tag
    
      if (options.render) {
        opts.render = options.render
        opts.staticRenderFns = options.staticRenderFns
      }
    }
    
  • 通过原型继承(Object.create)基于组件构造函数 选项创建一个新的对象保存到当前组件实例vm.$options中,目的就是复用构造函数中的选项;而后从入参中的options中取出父组件实例(Vue实例:parent),和父vnode(即parentVnode),保存到vm.$options中;父vnode实际上一个placeholder vnode即一个占位节点,并不会渲染到界面中,作为父子组件的纽带,这个placeholder vnode中有一个属性componentOptions保存了父组件传递给属性数据,监听的事件,和该vnode的孩子节点。

  • 如下demo中的<todo-item>会被转为一个虚拟节点就是这里的placeholder vnode,另外children是指如下案例的<span/>部分。todo-item是在作为父组件内容出现的,当然也是在渲染父组件之创建父组件虚拟节点阶段创建的,此时刚好把这些信息记录给这个placeholder vnode,当渲染todo-item的实际内容时,就会从placeholder vnode获取这些信息。

    <div>
        <todo-item @click='clickHandle' :propsA = 'A'>
            <span></span>
        </todo-item>
    </div>
    
  1. new Vue(options) :由于创建的时根组件不需要上面componentOptions信息的合并,只需要将祖先构造函数选项当前实例选项合并即可。注意,复现构造函数选项通过resolveConstructorOptions来收集和更新。由于组件构造函数选项在Vue.extend(options)中已经解析过,这里就必要次重复解析。注意:Vue中组件的构造函数是通过Vue.extend方法基于原型继承创建的,当然是返回一个构造函数,后面会说到。

然后是eventslifecycleinject/provedestate(methodspropsdatacomputedwatch)的初始化

然后是vm.$mountnew Vue场景会提供el参数,从而会执行vm.$mount

最后除了几个initXxx方法,还有两个生命周期的触发:beforeCreatecreated

下面具体看看各种(实例vm相关的信息)初始化

initLifecycle

作用:父子组件实例关系建立、初始化生命周期需要的相关标识变量

export function initLifecycle (vm: Component) {
  const options = vm.$options

  // locate first non-abstract parent
  let parent = options.parent
  if (parent && !options.abstract) {
    while (parent.$options.abstract && parent.$parent) {
      parent = parent.$parent
    }
    parent.$children.push(vm)
  }

  vm.$parent = parent
  vm.$root = parent ? parent.$root : vm

  vm.$children = []
  vm.$refs = {}

  vm._watcher = null
  vm._inactive = null
  vm._directInactive = false
  vm._isMounted = false
  vm._isDestroyed = false
  vm._isBeingDestroyed = false
}

设置 $parent | $root | $children | $refs:建立父子组件vue实例关系链:$parent$children,这个过程忽略中间的抽象组件实例。$options.abstract,看到内置组件Keep-Alive是抽象的 ❎。

添加组件生命周期相关的的标识:_inactive、_directInactive、_isMounted、_isDestroyed、_isBeingDestroyed

添加_watcher属性,这个属性指向的Watcher实例和组件渲染有关

initEvents

export function initEvents (vm: Component) {
  vm._events = Object.create(null)
  vm._hasHookEvent = false
  // init parent attached events
  const listeners = vm.$options._parentListeners
  if (listeners) {
    updateComponentListeners(vm, listeners) // src/core/vdom/helpers/update-listeners.js
  }
}

每个Vue实例上都有$on$off和事件相关的方法(见上一小节),相当于每个组件实例都有事件能力。调用updateComponentListeners将新事件绑定到vm上,移除旧的事件。

vm.$options._parentListeners来自上面分析的initInternalComponent方法,事件实际来自placeholder vnode.componentOptions

initRender

给组件实例挂载创建虚拟DOM的方法(等价于snabbdom中的h函数,见snabbdom@3.5.1 源码分析 - 第二节

export function initRender (vm: Component) {
  vm._vnode = null // the root of the child tree
  vm._staticTrees = null // v-once cached trees

  const options = vm.$options
  const parentVnode = vm.$vnode = options._parentVnode // the placeholder node in parent tree
  const renderContext = parentVnode && parentVnode.context

  //... slot相关,暂时忽略,可能会单独出一节

  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

  // $attrs & $listeners are exposed for easier HOC creation.
  // they need to be reactive so that HOCs using them are always updated
  const parentData = parentVnode && parentVnode.data
  defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, null, true)
  defineReactive(vm, '$listeners', options._parentListeners || emptyObject, null, true)   
}

_c:对于模板编译生成(不管是动态生成还是 vue-loade + webpack 生成,编译核心方法是 compileToFunctions)的render函数在模板编译阶段已经规范化过,不用再次被规范化,所以看到最后一个参数alwaysNormalize = false。

$createElement:对于开发者自己提供的 render 函数需要被规范化,因此最后一个参数 alwaysNormalize = true

import Vue from 'vue'
import App from './App.vue'

var app = new Vue({
  el: '#app',
  render: h => h(App) // 这里的 h 是 createElement 方法
})

另外看到针对HOC场景,定义响应式数据:vm.$attrsvm.$listeners

initInjections、initProvide

provide and inject usage

不重要,遗留,有兴趣的同学可以自己看下,逻辑简单。

initState

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } //...
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

相关属性的响应式依赖过程:props -> data -> computed -> watch,因此需要按照这个顺序依次添加响应式能力。methods正常情况下应该无所谓,但是看到注释中提到methods名称不能和props重复,因此先props后methods。

比如 data中可能会到props中的数据,computed可能会用到data中的数据。watch可能会依赖前三者。

因此这里的 执行顺序 很重要。

initProps

function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = vm._props = {}
  // cache prop keys so that future props updates can iterate using Array
  // instead of dynamic object key enumeration.
  const keys = vm.$options._propKeys = []
  const isRoot = !vm.$parent
  // root instance props should be converted
  if (!isRoot) {
    toggleObserving(false)
  }
  for (const key in propsOptions) {
    keys.push(key)
    const value = validateProp(key, propsOptions, propsData, vm)
    defineReactive(props, key, value)
    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }
  toggleObserving(true)
}
  1. toggleObserving(false) :有一个专门的提交 avoid conversion when setting props
    • 这里只是不需要将父组件传递属性值变成响应式,只是将当前属性变成响应式。
    • defineReactive -> observe,observe会根据 shouldObserve 变量判断是否将对象变成响应式
    • defineReactive有入参shallow可以递归属性值,为什么不直接使用这个方式呢,因为如果属性值是原本就是响应式的话,子组件还是想继续监听的(针对属性set/del这种变化,因为是childOb,见defineReacive)。
    • defineReactive and observe
    • 属性值非响应式对象,暂时只能想起provide/inject情况,即父组件传递的属性值来自inject(猜测,尚未验证)

      v2.cn.vuejs.org/v2/api/#pro…
      提示:provide 和 inject 绑定并不是可响应的。这是刻意为之的。然而,如果你传入了一个可监听的对象,那么其对象的 property 还是可响应的。

  2. 调用validateProp:如果父组件没有传递属性值,则设置默认值等,并会将默认值变成响应式对象
  3. props的各属性变成响应式,由于上面第一步,不会主动将父组件传递的属性值变成响应式,想一想这是合理的,管理好自己的数据就好,没必要动父组件的,否则反而可能引起问题。
  4. 代理 vm.xxx => vm._props.xxx,见proxy方法实现,这也是为什么我们this.a可以直接访问属性的原因,等价于访问this._props.a
    export function proxy (target: Object, sourceKey: string, key: string) {
      sharedPropertyDefinition.get = function proxyGetter () {
        return this[sourceKey][key]
      }
      sharedPropertyDefinition.set = function proxySetter (val) {
        this[sourceKey][key] = val
      }
      Object.defineProperty(target, key, sharedPropertyDefinition)
    }
    

initMethods

function initMethods (vm: Component, methods: Object) {
  for (const key in methods) { 
    //... 开发环境同名判断,不重要
    vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm)
  }
}

将方法赋值给vm实例,注意绑定了this指向

initData

function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function' ? getData(data, vm) : data || {}
  if (!isPlainObject(data)) {
    data = {}
  }
  // proxy data on instance
  const keys = Object.keys(data)
  let i = keys.length
  while (i--) {
    const key = keys[i]
    proxy(vm, `_data`, key)
  }
  // observe data
  observe(data, true /* asRootData */)
}

首先是调用getData获取数据,注意genData的实现,防止被上层的watcher订阅,手动pushTarget空值,这样就不会被上层的watcher订阅到。因为这里只是获取data的默认值,此时并需要和任何watcher建立任何依赖关系。

export function getData (data: Function, vm: Component): any {
  // #7573 disable dep collection when invoking data getters
  pushTarget()
  try {
    return data.call(vm, vm)
  } // catch ... 
  } finally {
    popTarget()
  }
}

然后是将data变成响应式数据。

initComputed

computed用法

const computedWatcherOptions = { lazy: true }

function initComputed (vm: Component, computed: Object) {
  // $flow-disable-line
  const watchers = vm._computedWatchers = Object.create(null) 

  for (const key in computed) {
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get

    // create internal watcher for the computed property.
    watchers[key] = new Watcher(vm, getter || noop, noop, computedWatcherOptions)

    // component-defined computed properties are already defined on the
    // component prototype. We only need to define computed properties defined
    // at instantiation here.
    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    }  
  }
}

首先是遍历computed,针对每个属性去new Watcher(...),注意这里的有个lazy的选项即getter并不会立即执行,此时并不会真正的去收集依赖。这里另外的细节是,computed定义的getter中可以使用props\data中的数据,由于props\data在这之前已经是响应式数据,因此即使立即收集依赖也不会有问题,可以正确建立双向关系(观察者和props\data)。

然后调用defineComputed,分析如下


export function defineComputed (target: any, key: string, userDef: Object | Function) {
  if (typeof userDef === 'function') {
    sharedPropertyDefinition.get =  createComputedGetter(key)
    sharedPropertyDefinition.set = noop
  } else {
    sharedPropertyDefinition.get = userDef.get ? createComputedGetter(key): noop
    sharedPropertyDefinition.set = userDef.set || noop
  } 
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

调用 createComputedGetter 返回一个闭包函数(持有key的引用)作为新的getter;

然后将该key和属性描述符(getter/setter)添加到vm上,读取vm[key]这个属性值时会执行上面返回的 computedGetter(什么时候会调用到呢,可能是mounted生命周期,可能是点击事件回调,可能是页面渲染需要的数据)

computedGetter分析如下


function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

如果发现数据是脏的,则重新估算该值(这也是之前lazy的价值,延迟执行,真正需要时再去执行)

// Watcher.js
evaluate () {
  this.value = this.get()
  this.dirty = false
}

computedGetter中的watcher.depend()的作用:为了让当前组件的渲染watcher(后面会说到new Watcher(updateComponent))也订阅到相同的依赖。

场景1: mountComponetnew Watcher 创建一个渲染组件能力的watcher即这里的Dep.target,而上面computedGetter中的watchercomputed属性相关的watcher

  • 如果在render函数中用到了computed中的数据,则会读取,那么会执行computedGetter ,先是执行watcher.evaluate获取computed属性的值(执行属性的handler,这个过程读取响应式数据),这里帮助watcher(computed属性关联的)收集依赖,因此依赖的数据变化时,会重新执行handler计算新值。
  • 由于evaluate执行过程中的Dep.target指向的watchercomputed属性关联的(因为watcher.get中会去pushTarget,popTarget原因),而此时渲染Watcher并没有和这部分的相应的数据建立关系。因此在执行完evaluate后(popTarget),此时的Dep.target指向渲染watcher,需要手动去收集一次依赖即computedGetter中的watcher.depend()
  • 小结:这里有两个watcher,都应该向对computed[key]依赖的响应式数据添加订阅,但是由于框架的设计时同时只有一个watcher可以作为观察者,因此内部的watcher完成后,需要手动触发外层watcher向内层wathcher订阅的依赖订阅一次。(目前只有这一个地方这么用 , 渲染会在nextTick中执行,不会产生性能问题,也行得通;否则应该让外层的watcher向内层的watcher订阅,不过内层的watcher同时需要扮演依赖即Dep的角色。)
    /**
     * Depend on all deps collected by this watcher.
     */
    Watcher.prototype.depend = function depend () {
      var i = this.deps.length;
      while (i--) {
        this.deps[i].depend();
      }
    };
    

场景2: 如果没有在渲染阶段用到这个computed属性,则不会引起该组件的重新渲染,显然是合理的。因为界面不不需要这些信息。

initWatch

function initWatch (vm: Component, watch: Object) {
  for (const key in watch) {
    const handler = watch[key]
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      createWatcher(vm, key, handler)
    }
  }
}

function createWatcher (vm: Component, expOrFn: string | Function, handler: any, options?: Object) {
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  return vm.$watch(expOrFn, handler, options)
}

逻辑很显然,不赘述。

Vue.prototype.$watch

Vue.prototype.$watch = function (expOrFn: string | Function, cb: any, options?: Object): Function {
  const vm: Component = this
  if (isPlainObject(cb)) {
    return createWatcher(vm, expOrFn, cb, options)
  }
  options = options || {}
  options.user = true
  const watcher = new Watcher(vm, expOrFn, cb, options)
  if (options.immediate) {
    try {
      cb.call(vm, watcher.value)
    } //...
  }
  return function unwatchFn () {
    watcher.teardown()
  }
}

添加了对options.immediate支持、返回watcher卸载能力方法。

vm.$mount:创建虚拟DOM + 虚拟DOM同步到界面

我们使用的是具有运行时模板编译的版本,意味着我们在new Vue()参数中的template会被构建为render函数,这是“在线编译”的过程,它是调用 compileToFunction 方法实现的。

在 Vue 2.0 版本中,所有 Vue 的组件的渲染最终都需要 render 方法,无论我们是用单文件 .vue 方式开发组件,还是写了 el 或者 template 属性,最终都会转换成 render 方法。

// src/platforms/web/entry-runtime-with-compiler.js

import { compileToFunctions } from './compiler/index'

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (el?: string | Element, hydrating?: boolean): Component {
  el = el && query(el)

  if (el === document.body || el === document.documentElement) {
    // Vue 不能挂载在 body、html 这样的根节点上
    return this
  }

  const options = this.$options
  // resolve template/el and convert to render function
  if (!options.render) {
    let template = options.template
    if (template) {
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)          
        }
      } else if (template.nodeType) {
        template = template.innerHTML
      } else {
        return this
      }
    } else if (el) {
      template = getOuterHTML(el)
    }
    if (template) {
      const { render, staticRenderFns } = compileToFunctions(template, {...}, this)
      options.render = render
      options.staticRenderFns = staticRenderFns
    }
  }
  return mount.call(this, el, hydrating)
}

首先是提供运行时模板编译能力 ,即将template转为render函数

1. Vue不能挂载在 `body``html` 这样的根节点上
2. `template`可以是…,总之需要转为`html` 模板
    1. dom id 如 “#app”,获取其 innerHTML
    2. 又或者是DOM节点,获取其 innerHTML
    3. 如果没有提供,则获取`el` 的outerHTML
    4. 或者直接是 HTML字符串模板,如上述 demo
3.  调用 `compileToFunctions`方法将`template`转为`render`函数

然后是调用运行时版本的$mount函数,定义在 src/platforms/web/runtime/index.js 中(web版本的运行时构建入口)

// src/platforms/web/runtime/index.js
import { mountComponent } from 'core/instance/lifecycle'

Vue.prototype.$mount = function(el?:string| Element,  hydrating?:boolean): Component {
  el = el && inBrowser ? query(el) :undefined
  return mountComponent(this, el, hydrating)
}

mountComponent:vm._render + vm._update

export function mountComponent (vm: Component, el: ?Element, hydrating?: boolean): Component {
  vm.$el = el
  callHook(vm, 'beforeMount')

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

  // we set this to vm._watcher inside the watcher's constructor
  // since the watcher's initial patch may call $forceUpdate (e.g. inside child
  // component's mounted hook), which relies on vm._watcher being already defined
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */) 	

  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

两件事情

第一件:挂载和更新 DOM,

  • new Watcher() :提供了监听数据变化的基础能力,那重点来了,上面data,props,computed 等都变成了响应式数据,如果在当watcher中读取了这些数据,就会建立双向关系,当这些数据变化时则会通知这里的watcher,自然就会用最新的数据更新界面。
    new Watcher(vm, getter , ...)
    // 第一次触发getter 就是 挂载
    // 第二次触发getter 就是 更新
    
  • updateComponent_render: 创建虚拟DOM树,传递给_update;_update: 将_render后关于虚拟节点树的结果挂载或者更新到界面上(生成或者更新DOM

第二件:生命周期回调的触发:beforeMountbeforeUpdatemounted

总结

组件实例options选项的合并,分别有哪几类

  • 组件:组件选项,Vue选项,内部选项
  • new Vue:实例选项、Vue选项

image.png


这一节主要是在说组件渲染之前的准备过程,各种数据的初始化,事件注册等等。 下节重点说下组件渲染真正渲染的过程(虚拟DOM的创建和虚拟DOM同步到界面的逻辑)

updateComponent = () => {
  vm._update(vm._render(), hydrating) // hydrating: ssr相关 忽略
}

new Watcher(vm, updateComponent, noop, {
  before () { /*...*/ }
}, true /* isRenderWatcher */)