Vue 2源码笔记

198 阅读9分钟

1、构建过程

首先,打开Vue源码的package.json文件,找到script属性,通常这里会配置项目构建的脚本。以最简单的build命令为例,该命令调用node执行项目根目录下的scripts/build.js

"scripts": {
    "build": "node scripts/build.js",
}

打开scripts/build.js,对部分逻辑逐行分析。该脚本导入同级目录的config模块,调用该模块的getAllBuilds方法。

let builds = require('./config').getAllBuilds()

打开scripts/config.js,该模块使用builds对象保存不同名称的配置,声明genConfig方法将指定配置转换为rollup配置。最后,根据TARGET环境变量决定该模块的导出内容,由于build命令没有指定环境变量,因此,模块底部导出了getAllBuilds函数。

getAllBuilds函数中,调用Object.keys获取builds对象的key数组,即配置名数组。使用map遍历数组,通过genConfig函数将配置名转换生成rollup配置数组。

const builds = {
  // ......
  // Runtime only ES modules build (for bundlers)
  'web-runtime-esm': {
    entry: resolve('web/entry-runtime.js'),
    dest: resolve('dist/vue.runtime.esm.js'),
    format: 'es',
    banner
  }
  // ......
}

function genConfig (name) {
  const opts = builds[name]
  const config = {
    input: opts.entry,
    external: opts.external,
    plugins: [
      flow(),
      alias(Object.assign({}, aliases, opts.alias))
    ].concat(opts.plugins || []),
    output: {
      file: opts.dest,
      format: opts.format,
      banner: opts.banner,
      name: opts.moduleName || 'Vue'
    },
    onwarn: (msg, warn) => {
      if (!/Circular/.test(msg)) {
        warn(msg)
      }
    }
  }
  // ......
  return config
}

if (process.env.TARGET) {
  module.exports = genConfig(process.env.TARGET)
} else {
  exports.getBuild = genConfig
  exports.getAllBuilds = () => Object.keys(builds).map(genConfig)
}

回到scripts/build.js,继续后面的逻辑。对于build命令,可知process.argv[2]为空,过滤weex相关配置,再调用build函数执行构建。

build函数中,通过next函数调用buildEntry函数执行构建。完成一次构建时,递归调用next函数执行下一次构建,保证串行执行每一次构建任务。buildEntry方法接受配置对象,rollup实例根据配置对象执行构建。取得构建结果之后,根据isProd来决定是否调用terser对构建结果进行压缩。最后,将输出结果写入文件中。

// filter builds via command line arg
if (process.argv[2]) {
  const filters = process.argv[2].split(',')
  builds = builds.filter(b => {
    return filters.some(f => b.output.file.indexOf(f) > -1 || b._name.indexOf(f) > -1)
  })
} else {
  // filter out weex builds by default
  builds = builds.filter(b => {
    return b.output.file.indexOf('weex') === -1
  })
}

build(builds)

function build (builds) {
  let built = 0
  const total = builds.length
  const next = () => {
    buildEntry(builds[built]).then(() => {
      built++
      if (built < total) {
        next()
      }
    }).catch(logError)
  }

  next()
}

function buildEntry (config) {
  const output = config.output
  const { file, banner } = output
  const isProd = /(min|prod)\.js$/.test(file)
  return rollup.rollup(config)
    .then(bundle => bundle.generate(output))
    .then(({ output: [{ code }] }) => {
      if (isProd) {
        const minified = (banner ? banner + '\n' : '') + terser.minify(code, {
          toplevel: true,
          output: {
            ascii_only: true
          },
          compress: {
            pure_funcs: ['makeMap']
          }
        }).code
        return write(file, minified, true)
      } else {
        return write(file, code)
      }
    })
}

2、参考代码

使用Vue CLI创建的项目中,通常会在main.js中导入Vue构造函数,创建实例,调用$mount方法挂载。换而言之,这个文件可以说是普通项目的入口,我们可以按照这个文件的代码,依次分析它们背后的源码逻辑。

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

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')

3、创建Vue实例

3.1、构造函数

回到package.json文件,main属性指定CommonJS规范的项目入口,module属性(参考rollup的Wiki文档)指定ES2015规范的项目入口。因此,我们使用import Vue from 'vue'导入的构造函数,就是由module属性指定的文件所导出的。

参考上一章的builds对象,dist/vue.runtime.esm.js对应构建前的代码文件是'web/entry-runtime.js'。其中,web表示路径别名,对应的实际路径是src/platforms/web

{
  "name": "vue",
  "version": "2.6.12",
  "main": "dist/vue.runtime.common.js",
  "module": "dist/vue.runtime.esm.js"
}

打开entry-runtime.js,它从其他文件中导入Vue构造函数,在当前模块中导出。根据导入路径层层深入,有些文件导入构造函数之后,在其原型上定义很多方法再导出,但这些暂时不是分析的重点。

/* @flow */
import Vue from './runtime/index'

export default Vue

最开始的Vue构造函数定义在src/core/instance/index.js中,接收options参数。该参数是一个对象,通常包含渲染函数、路由实例等内容。在非生产环境下,执行构造函数会先判断this指针是否指向Vue的实例,这就意味着该构造函数若被当作普通函数调用时,会抛出警告。最后,构造函数内部调用_init私有方法执行初始化。

注意,在JavaScript项目中,以_开头的方法表示私有方法,这是一种常见的约定。

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)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

3.2、_init方法

声明Vue构造函数之后,执行initMixin方法在构造函数原型上定义_init方法。该方法先把this赋值给常量vm,此处的this暂时指向Vue的根实例。再合并options,初始化LifecycleRenderStateProvide等。

export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // ......
    // 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
      )
    }
		// ......
    // expose real self
    vm._self = vm
    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')
    // ......
  }
}

3.3、初始化State

我们重点看看initState的过程,该方法依次对PropsMethodsDataComputedWatch进行初始化。

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)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

我们挑选Data的初始化过程进行分析。首先,获取data对象并赋值给data变量和vm._data属性,获取vm.$options上的propsmethods属性。然后,遍历data对象的key数组,判断data对象是否有属性与propsmethods的属性相冲突,若发生冲突,则抛出警告,若没有发生冲突,执行proxy方法完成数据代理。

function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  // ......
  // proxy data on instance
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        )
      }
    }
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }
  // observe data
  observe(data, true /* asRootData */)
}

结合上一段代码的调用场景深入proxy方法,参数target是指vm,参数sourceKey是字符串_data,参数key就是data对象中的数据属性名。接下来,该方法在vm实例上定义一个存取器属性。在获取vm上的指定属性时,get方法会返回vm._data上的相应属性,在vm上设置属性时,set方法会将值设置到vm._data上。在分析initData时,我们知道vm._data是指向最初的data对象。因此,我们可以通过vm来访问那些原本定义在data对象上的属性,这个过程也就是常说的数据代理。

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)
}

完成数据代理之后,调用observe方法为满足一定条件的data对象新建Observer对象。

export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}

Observer构造函数中,调用walk方法遍历data对象,再依次调用defineReactive方法进行数据劫持。

constructor (value: any) {
  this.value = value
  this.dep = new Dep()
  this.vmCount = 0
  def(value, '__ob__', this)
  if (Array.isArray(value)) {
    if (hasProto) {
      protoAugment(value, arrayMethods)
    } else {
      copyAugment(value, arrayMethods, arrayKeys)
    }
    this.observeArray(value)
  } else {
    this.walk(value)
  }
}

walk (obj: Object) {
  const keys = Object.keys(obj)
  for (let i = 0; i < keys.length; i++) {
    defineReactive(obj, keys[i])
  }
}

结合上一段代码的调用场景深入defineReactive方法,参数objdata对象,参数keydata对象的属性名。我们对方法的逻辑进行简化,暂时只考虑data对象的属性是原始值。

首先,定义新的Dep对象,取得key属性的对象描述符。如果对象描述符表明当前key属性不可配置,直接返回。然后,获取属性的get/set方法和属性值,使用Object.defineProperty方法对data对象上的属性重新定义。get方法判断Dep.target是否有值,如果有值,则调用dep.depend收集依赖,set方法调用dep.notify通知更新。

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  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]
  }

  // ......
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        // ......
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      // ......
      // #7981: for accessor properties without setter
      if (getter && !setter) return
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // ......
      dep.notify()
    }
  })
}

3.4、小结

创建Vue实例的过程,就是合并options,以及初始化LifecycleRenderStateProvide等。在初始化State时,对常用的data对象进行了改造,使其具备收集依赖和发布更新的能力。

4、依赖收集和发布更新

4.1、收集依赖

在改造data对象的过程中,defineReactive方法为data的属性新建了关联的dep对象,收集依赖是调用dep.depend方法来完成的。但是收集的过程没有直接调用addSub方法,而是去执行Dep.target.addDep

addSub (sub: Watcher) {
  this.subs.push(sub)
}

depend () {
  if (Dep.target) {
    Dep.target.addDep(this)
  }
}

打开src/core/observer/watcher.jsaddDep方法会先确定newDepIds是否已经存在该id,再将dep.iddep分别缓存到newDepIdsnewDeps中。最后,调用dep.addSub方法把watcher对象添加到dep实例的订阅者数组。

为什么没有直接调用addSub收集依赖,而是稍微走一点弯路来查重?因为watcher有可能对同一个数据项重复依赖,这时在watcher中维护两个数组,判断是否已经对某个dep实例产生依赖。如果数组中已经存在指定的dep实例,那么就不需要再把当前watcher添加到dep实例的订阅者数组中了,以免该watcher多次被触发更新。

addDep (dep: Dep) {
  const id = dep.id
  if (!this.newDepIds.has(id)) {
    this.newDepIds.add(id)
    this.newDeps.push(dep)
    if (!this.depIds.has(id)) {
      dep.addSub(this)
    }
  }
}

4.2、发布更新

发布更新是通过dep.notify方法来完成的,遍历订阅者数组,依次调用watcher.update方法通知更新。

notify () {
  // stabilize the subscriber list first
  const subs = this.subs.slice()
  for (let i = 0, l = subs.length; i < l; i++) {
    subs[i].update()
  }
}

默认情况下,this.lazythis.sync都是false,调用queueWatcher方法。

update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

打开src/core/observer/scheduler.js,该方法先判断缓存中是否已经存在这个watcher,防止同一个watcher重复推入待更新的队列。接下来,通过flushing标志位判断队列是否正在推出执行,若是,根据watcher.id的顺序插入队列,若否,直接将watcher插入队列末尾即可。最后,如果当前不处于waiting状态,调用flushSchedulerQueue方法推出队列中的watcher执行。

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    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

			// ......
      nextTick(flushSchedulerQueue)
    }
  }
}

首先,将flushing设置为true,将待更新的watcher队列按照id升序排列。然后,遍历watcher队列,依次调用watcher.run。最后,清空watcher队列,将flushingwaiting等标志位置为false,触发组件的生命周期回调。

function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id

  // ......
  queue.sort((a, b) => a.id - b.id)

  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    watcher.run()
    // in dev build, check and stop circular updates.
    // ......
  }

  // keep copies of post queues before resetting state
  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()

  resetSchedulerState()

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)

  // ......
}

function resetSchedulerState () {
  index = queue.length = activatedChildren.length = 0
  has = {}
  // ......
  waiting = flushing = false
}

执行watcher.run时,active属性默认为true,进一步调用get方法处理更新操作。

run () {
  if (this.active) {
    const value = this.get()
    // ......
  }
}

整体来说,发布更新的流程相对较长,基本步骤总结如下:

  1. 数据更新时,notify方法调用watcher.update发布更新。
  2. 待更新的watcher不是立即执行,而是将自己的放到调度器的队列中。
  3. nextTick中,调用flushSchedulerQueue方法安排待更新的watcher执行。

与同步更新的方式相比,这种方式区别并不大。同步发布更新时,数据改变通知更新,watcher会立即执行。而上述流程,只是在数据改变之后,将watcher放到待更新队列中,再统一安排执行。

5、nextTick

在发布更新的时候,提到待更新的watcher不是立刻执行,而是在nextTick中,调用flushSchedulerQueue方法统一安排执行。所以,我们继续看看nextTick的逻辑。

首先,将回调函数重新封装,如果回调函数cb有值,就执行这个回调,如果回调函数cb没有值,就执行_resolve方法,把重新封装的回调函数推入callbacks数组缓存起来。然后,判断pending标志位,执行timerFunc函数。最后,如果回调函数cb没有值,返回一个Promise对象,同时使上面的_resolve变量引用该Promise对象内部的resolve方法。

如果不给nextTick传递回调函数,而是用then方法监听nextTick返回的Promise。它的resolve方法是在某个任务中被调用,而then方法中的回调又是在另一个任务中执行。当然,这里仅仅强调一下执行逻辑,而实际差距可能不大,因为两个微任务很可能是在相同的时间间隙执行的。

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

timerFunc会根据平台对微任务和宏任务的支持程度,视情况定义。

  • 如果平台支持Promise,使用Promise规划微任务
  • 如果平台支持MutationObserver,使用MutationObserver规划微任务
  • 如果平台支持setImmediate,使用setImmediate规划宏任务
  • 如果平台支持setTimeout,使用setTimeout规划宏任务

按照从上到下的优先级,规划一个任务来执行flushCallbacks方法,该方法会遍历缓存中的回调函数并依次执行。

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

let timerFunc

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  // 设置characterData表示文本节点的值变化时,是否触发MutationObserver的回调
  observer.observe(textNode, {
    characterData: true
  })
  // 执行timerFunc会改变文本节点的值,使MutationObserver发起微任务执行flushCallbacks
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

综上所述,nextTick的逻辑也比较简单,先把回调函数推入队列中,再规划任务统一执行队列中的回调即可。其次,我们常用的$nextTick也是基于这个方法进行简单封装而成。

6、$mount

6.1、RenderWatcher

参考第二章的代码,当创建了Vue实例之后,下一步就会调用$mount方法挂载。

该方法定义在src/platforms/web/runtime/index.js中,它接受两个可选参数,对照第二章的代码可知,el参数的值是#app。首先,通过inBrowser判断是否在浏览器环境,若是,调用query方法获取真实的dom节点。接下来,调用mountComponent方法。

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

深入mountComponent方法。该方法的代码较多,但重要的逻辑其实只有创建Watcher

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

  let updateComponent
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    // ......
  } else {
    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 */)
  hydrating = false

  // ......
  return vm
}

Watcher构造函数的核心逻辑是调用get方法取值,回顾第四章,数据变化触发watcher更新也会调用get方法。它调用pushTarget方法将当前实例缓存在Dep.target上,再调用this.getter方法,这里就是指updateComponent。最后,调用popTarget把当前实例从Dep.target上移除。

结合第三章和第四章的内容,我们再次进行梳理。当Watcher初始化或更新的时候,会把自身挂到Dep.target上,再调用Watchergetter方法。如果getter方法对某些数据项存在依赖时,会触发数据属性的get方法,与数据属性关联的dep就会把Dep.target上的Watcher实例缓存起来,这个过程就是所谓的收集依赖。

constructor (
  vm: Component,
  expOrFn: string | Function,
  cb: Function,
  options?: ?Object,
  isRenderWatcher?: boolean
) {
  // ......
  // parse expression for getter
  if (typeof expOrFn === 'function') {
    this.getter = expOrFn
  } else {
    // ......
  }
  this.value = this.lazy
    ? undefined
    : this.get()
}

get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    // ......
  } finally {
    // ......
    popTarget()
    this.cleanupDeps()
  }
  return value
}

6.2、_render

上一节提到,构建Watcher对象会调用getter方法。在挂载过程中,这个getter方法就代表updateComponent,它的核心逻辑是,先调用_render方法生成vdom,再调用_update方法把vdom转换成真实节点插入文档。因此,这一节我们来看看_render方法。

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

_render方法从$options取出render方法,这个render方法就是参考代码中的渲染函数,调用render方法生成vdom

Vue.prototype._render = function (): VNode {
  const vm: Component = this
  const { render, _parentVnode } = vm.$options
  
  // ......
  // render self
  let vnode
  try {
    // There's no need to maintain a stack because all render fns are called
    // separately from one another. Nested component's render fns are called
    // when parent component is patched.
    currentRenderingInstance = vm
    vnode = render.call(vm._renderProxy, vm.$createElement)
  } catch (e) {
    // ......
  } finally {
    currentRenderingInstance = null
  }
  // ......
  return vnode
}

渲染函数被调用时,第二个参数传入vm.$createElement,这个方法也是生成vdom的关键方法,它是在initRender中定义的。

export function initRender (vm: Component) {
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
}

我们从参数出发,一步步分析这个方法的逻辑。第一个参数contextvm实例,第二个参数tag可能是标签名或者组件名,也可能是组件对象,第三个参数data是数据对象,比如:propsattrsstyle等属性的定义都是在这个对象上,第四个参数是子节点,余下两个参数一般不是自定义渲染函数传递的参数,暂时忽略。

首先,判断data是否为数组类型或原始类型,如果是,说明data对象实际为空,需要将参数的位置整体向后挪动一位。因为我们平时在渲染函数调用该方法时,data对象这个参数可以不传。上一段代码传入的参数alwaysNormalizetruenormalizationType赋值为ALWAYS_NORMALIZE。最后,调用私有方法_createElement

export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE
  }
  return _createElement(context, tag, data, children, normalizationType)
}

结合上一段代码的参数,已知这里的normalizationType值是ALWAYS_NORMALIZE,调用normalizeChildren方法打平children

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  // ......
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // ......
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  if (Array.isArray(vnode)) {
    return vnode
  } else if (isDef(vnode)) {
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    return createEmptyVNode()
  }
}

normalizeChildren方法会判断children是否为原始类型,如果是,返回一个文本节点的数组,如果不是,继续判断children是否为数组,再调用normalizeArrayChildren递归打平children数组。

normalizeArrayChildren方法先遍历children数组:

  1. 如果数组元素是数组,递归打平子数组,如果递归返回的数组第一个和res数组的最后一个都是文本节点,则需要合并这两个相邻的文本节点,再把递归打平的子数组缓存到res数组中。
  2. 如果数组元素是原始类型且res数组的最后一个为文本节点,合并这两个相邻的文本节点。否则,直接创建一个文本节点缓存到res数组中。
  3. 如果数组元素不是数组或原始类型,说明这个元素是普通的vnode。如果元素是文本节点,则需要考虑相邻文本节点的合并,否则直接将vnode缓存到res数组中。

总结一下,normalizeArrayChildren方法使用递归的方式把嵌套的vnode数组打平成一维数组。在打平的过程中,还会把相邻的文本节点合并到一起,减少最终渲染出来的dom数量。

export function normalizeChildren (children: any): ?Array<VNode> {
  return isPrimitive(children)
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}

function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode> {
  const res = []
  let i, c, lastIndex, last
  for (i = 0; i < children.length; i++) {
    c = children[i]
    if (isUndef(c) || typeof c === 'boolean') continue
    lastIndex = res.length - 1
    last = res[lastIndex]
    //  nested
    if (Array.isArray(c)) {
      if (c.length > 0) {
        c = normalizeArrayChildren(c, `${nestedIndex || ''}_${i}`)
        // merge adjacent text nodes
        if (isTextNode(c[0]) && isTextNode(last)) {
          res[lastIndex] = createTextVNode(last.text + (c[0]: any).text)
          c.shift()
        }
        res.push.apply(res, c)
      }
    } else if (isPrimitive(c)) {
      if (isTextNode(last)) {
        // merge adjacent text nodes
        // this is necessary for SSR hydration because text nodes are
        // essentially merged when rendered to HTML strings
        res[lastIndex] = createTextVNode(last.text + c)
      } else if (c !== '') {
        // convert primitive to vnode
        res.push(createTextVNode(c))
      }
    } else {
      if (isTextNode(c) && isTextNode(last)) {
        // merge adjacent text nodes
        res[lastIndex] = createTextVNode(last.text + c.text)
      } else {
        // default key for nested array children (likely generated by v-for)
        if (isTrue(children._isVList) &&
          isDef(c.tag) &&
          isUndef(c.key) &&
          isDef(nestedIndex)) {
          c.key = `__vlist${nestedIndex}_${i}__`
        }
        res.push(c)
      }
    }
  }
  return res
}

回到_createElement方法,暂时抛开创建组件的方法不谈。对于系统保留标签,这个方法只是调用构造函数创建一个VNode对象,返回VNode对象或数组。

简单的对_render函数做小结。这个函数核心逻辑是调用渲染函数获取待渲染的VNode数组,传入$createElement作为参数。想象一下,我们在创建渲染函数时,通常就是调用这个$createElement方法来创建VNode实例,$createElement方法会先把传入的children数组打平成一维数组,再使用构造函数创建VNode对象并返回。所谓的VNode只是真实Dom的一种抽象,只不过它占用的资源比真实Dom要小很多。

6.3、_update

了解到_render方法的执行逻辑之后,再来看看_update方法是如何把VNode渲染到页面上的。

首先,判断prevVnode是否有值,如果有,说明只是更新渲染,否则,说明是初始化渲染。无论初始化还是更新,渲染工作都是由vm.__patch__方法完成的。

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  const prevEl = vm.$el
  const prevVnode = vm._vnode
  const restoreActiveInstance = setActiveInstance(vm)
  vm._vnode = vnode
  // Vue.prototype.__patch__ is injected in entry points
  // based on the rendering backend used.
  if (!prevVnode) {
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  restoreActiveInstance()
  // update __vue__ reference
  if (prevEl) {
    prevEl.__vue__ = null
  }
  if (vm.$el) {
    vm.$el.__vue__ = vm
  }
  // if parent is an HOC, update its $el as well
  if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
    vm.$parent.$el = vm.$el
  }
  // updated hook is called by the scheduler to ensure that children are
  // updated in a parent's updated hook.
}

__patch__方法定义在src/platforms/web/runtime/index.js,如果处于浏览器环境,该方法就是patch方法,否则是一个空函数。

// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop

这里运用了函数科里化的技巧,patch方法是由createPatchFunction创建出来的。

export const patch: Function = createPatchFunction({ nodeOps, modules })

以首次渲染为例,先分析传入patch方法的参数,oldVnode代表vm.$el,参考6.1小节的代码,即被挂载的真实Domvnode表示待渲染的虚拟Domhydrating与服务端渲染有关,当前的值是undefinedremoveOnly的值是falsepatch方法先判断oldVnode是否有值以及oldVnode是否为真实节点。当前我们传入的oldVnode是真实Dom节点,因此创建一个空的VNode代替这个被挂载的真实节点,再调用createElm方法生成真实节点。

export function createPatchFunction (backend) {
  // ......

  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // ......

    let isInitialPatch = false
    const insertedVnodeQueue = []

    if (isUndef(oldVnode)) {
      // ......
    } else {
      const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        if (isRealElement) {
          // ......
          oldVnode = emptyNodeAt(oldVnode)
        }

        // replacing existing element
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)

        // 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)
        )

        // ......

        // destroy old node
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    }

    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }
}

生成真实节点的主要步骤如下:

  1. 调用nodeOps.createElement方法创建真实的元素
  2. 调用setScope方法设置作用域,这个方法会在生成的真实元素上添加一个作用域ID的属性
  3. 调用createChildren把子节点都转化为真实元素,转化的过程也是通过createElm方法间接递归完成
  4. 调用insert方法,把当前vnode对应的真实节点插入到parentElm下面,即插入到上述的被挂载真实节点的父节点下面
  function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) {
    // ......
    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag
    if (isDef(tag)) {
      // ......

      vnode.elm = vnode.ns
        ? nodeOps.createElementNS(vnode.ns, tag)
        : nodeOps.createElement(tag, vnode)
      setScope(vnode)

      /* istanbul ignore if */
      if (__WEEX__) {
        // ......
      } else {
        createChildren(vnode, children, insertedVnodeQueue)
        if (isDef(data)) {
          invokeCreateHooks(vnode, insertedVnodeQueue)
        }
        insert(parentElm, vnode.elm, refElm)
      }

      // ......
    }
  }

回到patch方法,createElm方法执行后,会调用removeVnodes方法销毁旧节点,过程是分别调用removeAndInvokeRemoveHook方法和invokeDestroyHook方法。由于初始化过程中的oldVnode是一个空节点,因此,removeAndInvokeRemoveHook方法会继续调用removeNode方法把旧节点对应的真实元素从页面中删除。

function removeVnodes (vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (isDef(ch)) {
      if (isDef(ch.tag)) {
        removeAndInvokeRemoveHook(ch)
        invokeDestroyHook(ch)
      } else { // Text node
        removeNode(ch.elm)
      }
    }
  }
}

function removeAndInvokeRemoveHook (vnode, rm) {
  if (isDef(rm) || isDef(vnode.data)) {
    // ......
  } else {
    removeNode(vnode.elm)
  }
}

function removeNode (el) {
  const parent = nodeOps.parentNode(el)
  // element may have already been removed due to v-html / v-text
  if (isDef(parent)) {
    nodeOps.removeChild(parent, el)
  }
}

一句话总结初始化场景下的_update逻辑,就是根据VNode来生成真实元素并挂载到指定的元素下。

6.4、小结

到这里,其实$mount方法的逻辑也差不多分析结束了。

  1. 创建Render Watcher,创建过程调用updateComponent方法
  2. updateComponent方法先调用_render生成VNode
  3. 调用_update更新成真实元素,挂载到指定元素的父节点之后,同时销毁指定元素

7、更新渲染

当数据变化触发更新时,仍然是调用_update方法来渲染更新后的VNode节点,核心的逻辑也是依赖patch方法来完成。先确保oldVnode不是真实节点并且oldVnodevnode是满足sameVnode的条件,再调用patchVnodevnode更新到oldVnode之上。

export function createPatchFunction (backend) {
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // ......

    let isInitialPatch = false
    const insertedVnodeQueue = []

    if (isUndef(oldVnode)) {
      // ......
    } else {
      const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        // ......
      }
    }

    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }
}

首先,如果新旧节点完全相等,表明数据更新没有生成新的VNode实例,不需要更新,直接返回即可,否则,把oldVnode.elm赋值给vnode.elm。当新旧节点满足sameVnode的条件时,不会为新vnode生成新的真实元素,而是复用oldVnode对应的真实元素。然后,获取新旧节点的children属性并对比,简要的对比流程如下:

  • 如果新旧节点的children属性都有定义且不完全相等,调用updateChildren方法更新
  • 如果只有新节点的children属性有定义,调用addVnodes方法插入这些新的children节点
  • 如果只有旧节点的children属性有定义,调用removeVnodes方法移除这些旧的children节点
  • 如果新节点没有定义vnode.text,但旧节点定义了vnode.text,调用setTextContent方法移除旧节点上的textContent
  • 如果新节点定义了vnode.text,调用setTextContent方法更新textContent
  function patchVnode (
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
  ) {
    if (oldVnode === vnode) {
      return
    }

    const elm = vnode.elm = oldVnode.elm
    const oldCh = oldVnode.children
    const ch = vnode.children
    if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        removeVnodes(oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      nodeOps.setTextContent(elm, vnode.text)
    }
  }

具体看看updateChildren的过程。先定义四个指针,oldStartIdx指向oldCh的开始位置,oldEndIdx指向oldCh的结束位置,newStartIdx指向newCh的开始位置,newEndIdx指向newCh的结束位置,再执行遍历。

  • 如果oldStartIdx指向的节点没有定义,将指针向右移动一位。如果oldEndIdx指向的节点没有定义,将指针向左移动一位。通过这两种比较,排除遍历oldCh的过程中出现的空节点。
  • 如果oldStartIdxnewStartIdx指向的是相同节点,调用patchVnode方法把newStartVnode更新到oldStartVnode上,再让这两个指针均向右移动一位。
  • 如果oldEndIdxnewEndIdx指向的是相同节点,调用patchVnode方法把newEndVnode更新到oldEndVnode上,再让这两个指针均向左移动一位。
  • 如果oldStartIdxnewEndIdx指向的是相同节点,调用patchVnode方法把newEndVnode更新到oldStartVnode上,再让oldStartIdx指针向右移动一位,newEndIdx指针向左移动一位。
  • 如果oldEndIdxnewStartIdx指向的是相同节点,调用patchVnode方法把newStartVnode更新到oldEndVnode上,再让oldEndIdx指针向左移动一位,newStartIdx指针向右移动一位。

综上所述,updateChildren的过程会先执行一些默认的比较逻辑。假设在oldChnewCh两个数组的相同节点顺序类似的情况下,这样的比较方式大大降低了算法的时间复杂度。

特殊情况,如果上述的比较过程中,没有找到两个相同的节点,则调用createKeyToOldIdx方法创建key:index形式的map。判断newStartVnode.key是否有值,若有值,根据这个keymap中获取index,若没有值,只能调用findIdxInOld方法遍历oldCh数组,依次查找是否有这个newStartVnode节点。

  • 如果在oldCh数组中没有找到这个newStartVnode节点,说明这是一个新节点,调用createElm方法创建新的元素并插入parentElm父元素中。
  • 如果在oldCh数组中找到某个位置上可能存在该节点,从oldCh数组的该位置上取出该节点,与newStartVnode节点做对比。如果是相同节点,调用patchVnode方法把newStartVnode节点更新到原节点上,如果不是相同节点,说明这是一个新节点,调用createElm方法创建新的元素并插入parentElm父元素中。
  • 最后,newStartVnode节点已经被更新,将newStartIdx指针向右移动一位。

这时,整个遍历的逻辑结束。如果newCh中还剩余了元素,调用addVnodes方法插入这些剩下的新节点。如果oldCh数组还剩余了元素,调用removeVnodes方法移除这些剩下的旧节点。

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  let oldStartIdx = 0
  let oldEndIdx = oldCh.length - 1
  let oldStartVnode = oldCh[0]
  let oldEndVnode = oldCh[oldEndIdx]

  let newStartIdx = 0
  let newEndIdx = newCh.length - 1
  let newStartVnode = newCh[0]
  let newEndVnode = newCh[newEndIdx]


  let oldKeyToIdx, idxInOld, vnodeToMove, refElm

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      
    if (isUndef(oldStartVnode)) {
      oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
    } else if (isUndef(oldEndVnode)) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
      canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
      if (isUndef(idxInOld)) { // New element
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
      } else {
        vnodeToMove = oldCh[idxInOld]
        if (sameVnode(vnodeToMove, newStartVnode)) {
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
          oldCh[idxInOld] = undefined
          canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
          // same key but different element. treat as new element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        }
      }
      newStartVnode = newCh[++newStartIdx]
    }
  }
  if (oldStartIdx > oldEndIdx) {
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
  } else if (newStartIdx > newEndIdx) {
    removeVnodes(oldCh, oldStartIdx, oldEndIdx)
  }
}