面试官:你能介绍下Vue的渲染机制吗

1,397 阅读10分钟

内容简介

 你能介绍下Vue的渲染机制吗?虽然是一个被问烂了的题,但真的挺考验应聘者在技术上的深度以及对框架的理解。实现一个组件,一定有数据绑定,当数据发生变化,视图在何时会触发渲染,要回答这个问题,就得了解Vue的渲染机制,本文围绕一个简单的例子,结合之前介绍的Watcher(观察者)、Dep(依赖对象)、Scheduler(调度器)、Component四个对象之间的关系来对渲染机制作介绍。

写一个简单组件

 以下demo是一个基础信息编辑组件,通过data定义了几个属性,用watch来监听属性的变化,使用computed对多属性进行监听。当我们修改了表格内容,界面右部的基础信息也会随着更新。

<template>
    <div class="info-editor">
        <div class="info-editor-form">
            <div class="info-editor-item">
                <span>姓名</span>
                <input v-model="name" />
            </div>
            <div class="info-editor-item">
                <span>电话</span>
                <input v-model="phone" />
            </div>
            <div class="info-editor-item">
                <span>地址</span>
                <input v-model="addr" />
            </div>
        </div>
        <div class="info-editor-view">
            <div>{{message}}</div>
        </div>        
    </div>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
    data() {
        return {
            name: '李磊',
            phone: '18200000000',
            addr: '北京五道口'
        }
    },
    watch: {
        phone: function phoneChange(value) {
            if (!value || value.length !== 11) {
                console.log('电话号码格式错误.')
            }
        }
    },
    computed: {
        message: function message() {
          return `${this.name},${this.phone},${this.addr}`
        }
    }
})
</script>

demo.png

初始化

 Vue在构造函数会调用_init函数执行初始化操作,_init函数直接挂在Vue的原型上,该函数会初始化生命周期、渲染、状态等等,像我们熟悉的生命周期事件beforeCreate、created会在初始化过程触发,这里我们关注两个地方,initState和vm.$mount(vm.$options.el)。

Vue.prototype._init = function (options?: Object) {
    ...
    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')
    ...
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }

 initState函数将初始化我们在组件中定义的prop、data、watch、computed属性,$mount初始化渲染过程并触发第一次渲染。initProps、 initMethod、initData会获取默认值以及将对应属性绑定到this上,例如我们在data中定义了name属性,通过initData函数,用户可以通过this.name来访问。这里我们重点介绍下initComputed和initWatch函数。这两个函数在《Vue的Watcher和Scheduler原理介绍》已经介绍过,这里我们再回顾下。

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 {
      // 将_data属性转换为Observer对象监听起来
      observe(vm._data = {}, true /* asRootData */)
    }
    if (opts.computed) initComputed(vm, opts.computed)
    if (opts.watch && opts.watch !== nativeWatch) {
      initWatch(vm, opts.watch)
    }
  }

 initComputed函数会遍历用户定义的所有计算属性,并为每个属性创建watcher对象,在实例化Watcher时传递了四个参数,vm表示组件自身;getter表示属性的值,例如demo中定义的message计算属性,其getter为获取value的函数function message();第三个参数为回调函数,由于计算属性不需要回调,所以用空函数noop表示;最后一个是Watcher构造函数的可选参数,只有计算属性创建Watcher才会标示为lazy,有什么作用我们介绍在Watcher对象时再说。

function initComputed (vm: Component, computed: Object) {
    const watchers = vm._computedWatchers = Object.create(null)

    for (const key in computed) {
      const userDef = computed[key]
      // 用户定义的执行函数可能是{ get: function() {} }形式
      const getter = typeof userDef === 'function' ? userDef : userDef.get
      // 为用户定义的每个computed属性创建watcher对象
      watchers[key] = new Watcher(
        vm,
        getter || noop, // demo中的 function message()
        noop,
        { lazy: true }
      )
    }
    ...
}

 initWatch初始化组件在watch属性中自定义的监听,函数会遍历watch属性,这里的key即为组件中定义了"phone"属性。createWatcher函数会调用vm.$watch(expOrFn, handler, options)实例化Watcher对象,vm为组件自身,expOrFn为"phone“字符串,handler为我们定义的phoneChange函数。

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

 在demo中我们在data中有定义name、phone、addr属性,Vue在initData函数中将调用observe(data, true /* asRootData */)函数将data对象转换为Observer对象。每当有地方读取data中的属性,都会创建一个Watcher,例如对于phone属性,template中的<input v-model="phone" />和message函数return phone,以及watch中的phone监听,都会读取data中的phone属性,所以共会生成三个Watcher对象。

<input v-model="phone" />

computed: {
    message: function message() {
      return `${this.name},${this.phone},${this.addr}`
    }
}

 observe会将phone通过defineProperty函数转换为{ get, set }形式,在get中会判断Dep.target是否为空,我们知道phone一共有三处get,那么每个地方在读取phone的值,Dep.target就为对应的Watcher,例如watch中对phone的监听也会生成Watcher,dep.depend会将该Watcher附加到subs(Watcher数组)中。当我们重新设置了phone的值,例如通过input改变, 将会触发set函数,调用dep.notify触发每个watcher的update函数,执行通知。

export function defineReactive (
    obj: Object,
    key: string,
    val: any
  ) {
    ...
    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) {
        ...
        dep.notify()
      }
    })
  }

开始渲染

$mount函数定义

 一个Vue项目,一般会在main.js函数中实例化Vue对象,在构造函数传入了渲染函数,实例化之后将会调用$mount函数启动渲染。

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

 $mount函数先将el转换为Dom(例如通过selector找到#app对应的Dom元素),挂在的Dom元素不能为body或者documentElement,18到48行查询模板字符串,如果template为字符串,只允许为#id形式,将会通过idToTemplate函数获取innerHTML内容。如果template为空,则直接把el的outerHTML赋给template。

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

  // 如果el为body或者documentElement,则抛出警告,Vue不允许将Vue挂接到这些元素上。
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && warn(
      `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
    )
    return this
  }

  const options = this.$options
  // 将模板转换为渲染函数
  if (!options.render) {
    let template = options.template
    if (template) {
      if (typeof template === 'string') {
        // 例如$mount('#app'), 获取对应元素的innerHTML字符串
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }
      } else if (template.nodeType) {
        template = template.innerHTML
      } else {
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      template = getOuterHTML(el)
    }
    if (template) {
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }
      // 将渲染模板转换为渲染函数,compileToFunctions函数将按options参数解析模板
      const { render, staticRenderFns } = compileToFunctions(template, {
        outputSourceRange: process.env.NODE_ENV !== 'production',
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns

      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`vue ${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  // 执行渲染函数
  return mount.call(this, el, hydrating)
}

 接下来调用compileToFunctions函数将template字符串转换为渲染函数,该函数首先将模板转换为抽象语法树AST(Abstract Syntax Tree),然后再根据AST生成函数表达,生成的匿名渲染函数中,所有的模板节点都会对应一个_c函数,该函数将创建对应tag(例如div)生成虚拟节点vnode。像input元素有绑定在data中定义的属性,例如name、phone,在_c函数中将通过_vm.name、_vm.phone、_vm.addr来获取值,这样就会命中属性的get函数,之前有介绍get函数会调用dep.depend()将观察者注册到观察目标中。这里的观察者指的是渲染Watcher(mountComponent函数创建,我们可以命名为Render Watcher),观察目标是name、phone、addr这些属性对应的dep,在渲染过程中,所有属性的dep都会附加上Render Watcher,这样当通过set函数更新属性时,将调用dep.notify函数通知Render Watcher重新渲染。

// 匿名渲染函数
function _render() {
    var _vm = this
    var _h = _vm.$createElement
    var _c = _vm._self._c || _h
    return _c("div", { staticClass: "info-editor" }, [
      _c("div", { staticClass: "info-editor-form" }, [
        _c("div", { staticClass: "info-editor-item" }, [
          _c("span", [_vm._v("姓名")]),
          _c("input", {
            directives: [
              {
                name: "model",
                rawName: "v-model",
                value: _vm.name,
                expression: "name",
              },
            ],
            domProps: { value: _vm.name },
            on: {
              input: function ($event) {
                if ($event.target.composing) {
                  return
                }
                _vm.name = $event.target.value
              },
            },
          }),
        ]),
        _c("div", { staticClass: "info-editor-item" }, [
          _c("span", [_vm._v("电话")]),
          _c("input", {
            directives: [
              {
                name: "model",
                rawName: "v-model",
                value: _vm.phone,
                expression: "phone",
              },
            ],
            domProps: { value: _vm.phone },
            on: {
              input: function ($event) {
                if ($event.target.composing) {
                  return
                }
                _vm.phone = $event.target.value
              },
            },
          }),
        ]),
        _c("div", { staticClass: "info-editor-item" }, [
          _c("span", [_vm._v("地址")]),
          _c("input", {
            directives: [
              {
                name: "model",
                rawName: "v-model",
                value: _vm.addr,
                expression: "addr",
              },
            ],
            domProps: { value: _vm.addr },
            on: {
              input: function ($event) {
                if ($event.target.composing) {
                  return
                }
                _vm.addr = $event.target.value
              },
            },
          }),
        ]),
      ]),
      _c("div", { staticClass: "info-editor-view" }, [
        _c("div", [_vm._v(_vm._s(_vm.message))]),
      ]),
    ])
  }

 这里有介绍Render Watcher,但还提到在何时创建的。回顾$mount函数,最后一行为 mount.call(this, el, hydrating),该函数为Vue最初定义的$mount函数,定义在src/platforms/web/runtime/index.js文件。

const mount = Vue.prototype.$mount

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
    ...
    mount.call(this, el, hydrating)   
}

 接下来我们看src/platforms/web/runtime/index.js文件中的$mount函数是如何定义的,首先判断当前是否为浏览器环境,将el转换为Dom元素,最后一行调用了mountComponenta函数。

// 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首先触发beforeMount hook事件,我们可以在组件中定义beforeMount事件,在组件渲染过程自动触发。函数内定义的updateComponent函数将调用_render、_update执行渲染和更新,把最新的值(例如将name由李磊更新为韩梅梅)更新到界面上。

 接下来实例化了Watcher对象,先回顾下Watcher的构造函数constructor (vm: Component, expOrFn: string | Function, cb: Function, options?: Object, isRenderWatcher?: boolean),一共包含5个参数,我们结合new Watcher传入的参数说明下:

  1. vm:vm对应组件自身;
  2. expOrFn: expOrFn可以为字符串或者函数,例如在watch监听时可以写成"a.b.c"形式, 这里updateComponent对应expOrFn;
  3. cb: 更新后的回调函数,这里传入的noop为空函数;
  4. options: 可选参数,这里传入的before属性,当watcher执行run之前调用;
  5. isRenderWatcher:是否为渲染Watcher,Watcher内部逻辑将对渲染Watcher做特殊处理,这里传入的参数为true,表明我们在mountComponent函数中创建的Watcher都为渲染Watcher;
/**
 * 生命周期mount事件触发函数
 * @param {*} vm 
 * @param {*} el 
 * @param {*} hydrating 
 * @returns 
 */
 export function mountComponent (
    vm: Component,
    el: ?Element,
    hydrating?: boolean
  ): Component {
    vm.$el = el
    ...
    callHook(vm, 'beforeMount')
  
    let updateComponent = () => {
        //_render函数执行渲染,生成新的虚拟节点vnode,_update函数将结合__patch__补丁算法来更新原始prevnode,并最终更新到Dom元素。
        vm._update(vm._render(), hydrating)
      }
  
    // 实例化Watcher对象,在Watcher构造函数中建立Watcher和vm的关系
    //  
    new Watcher(vm, updateComponent, noop, {
      // 在执行wather.run函数之前触发before hook事件
      before () {
        if (vm._isMounted && !vm._isDestroyed) {
          callHook(vm, 'beforeUpdate')
        }
      }
      // isRenderWatcher表示用于渲染的Watcher,在执行$forceupdate时会手动触发watcher.update
    }, true /* isRenderWatcher */)
    
    return vm
  }

触发渲染和更新

 到目前我们只知道updateComponent函数会执行渲染render和更新update函数,但updateComponent在何时将被触发目前我们还未知道。mountComponent函数中有实例化Render Watcher,接下来我们就看看在new Watcher时包含哪些逻辑。首先通过isRenderWatcher判断当前watcher是否为渲染watcher,是则将其赋值给vm._watcher,当组件创建好之后,可通过vm._watcher来触发重新渲染。组件内部创建的watcher都将附加到vm._watchers数组中,编译后续批量操作,例如$destroy函数批量注销所有watcher。

 getter一般为获取值的链式表达(如a.b.c),或者为获取值的函数, 例如我们在demo中定义的计算属性message函数。但渲染Watcher把触发渲染和更新函数updateComponent当着getter,其实这也是Vue设计的巧妙之处,稍后我们再说明。

 重点是最后一行代码,给this.value获取最新值,之前有提到只有计算属性watcher的lazy才会true,其他watcher的lazy都为false,所以渲染Watcher将执行this.get函数获取最新值。

export default class Watcher {
    constructor (
      vm: Component,
      expOrFn: string | Function,
      cb: Function,
      options?: Object,
      isRenderWatcher?: boolean
    ) {
      this.vm = vm
      if (isRenderWatcher) {
        // 将当前Watcher挂接到vm._watcher上。在执行$forceUpdate将使用_watcher
        vm._watcher = this
      }
      //组件中创建的watcher都放到_watchers队列中,例如执行$destroy将对其销毁。
      vm._watchers.push(this)
      ...
      if (typeof expOrFn === 'function') {
        this.getter = expOrFn
      } else {
          // 将表达式转换为函数,例如将'a.b.c'转换为函数从vm中通过链式获取c属性值
        this.getter = parsePath(expOrFn)
        if (!this.getter) {
          this.getter = noop
        }
      }
      // 触发get函数
      this.value = this.lazy
        ? undefined
        : this.get()
    }
  }

 接下来就是触发渲染和更新的核心环节了,Watcher构造函数最后一行调用了get函数,首先将当前Render Watcher通过pushTarget附加到全局Dep.target上,接着调用getter来获取最新值,之前提到Render Watcher的getter为updateComponent函数,所以此时将执行updateComponent函数。

/**
   * 执行getter,重新收集依赖项
   */
 get () {
    // 将当前Watcher附加到全局Dep.target上,并存储targetStack堆栈中
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      // 执行getter读取value
      value = this.getter.call(vm, vm)
    } catch (e) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {
        throw e
      }
    } finally {
      // 如果deep为true,将遍历+递归value对象
      // 将所有嵌套属性的dep都附加上当前watcher,所有子属性对应的dep都会从push(Dep.target)
      if (this.deep) {
        // 递归遍历所有嵌套属性,并触发其getter,将其对应的dep附加当前watcher
        traverse(value)
      }
      // 退出堆栈
      popTarget()
      // 清理依赖
      this.cleanupDeps()
    }
    return value
  }

 在updateComponent内部,先调用vm._render()将模板生成虚拟节点, _render内部会调用c_将每个模板tag生成vnode,例如将我们在模板中定义的div[class='info-editor-view']生成对应的虚拟节点vnode。vm._render执行之后返回模板root vnode节点,也就是<div class="info-editor">对应的虚拟节点。updateComponent接着调用_update函数将虚拟节点生成最终的真实DOM元素,当然_update函数内部会调用__patch__补丁函数做diff更新,保证最小的性能开销。

_c("div", { staticClass: "info-editor-view" }, [    _c("div", [_vm._v(_vm._s(_vm.message))]),
  ])
// _c定义
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)

 现在我们还是回到上面提到Watcher中的get函数,内部调用this.getter.call(vm, vm)执行getter函数,对于渲染Watcher的getter即为updateComponent,所以将触发_render函数,该函数内部会通过_vm.message、_vm.name来获取属性值,前面有提到在Vue组件中,我们定义的属性都将转换为{get,set}模式,所以调用_vm.message将触发其get函数,而get函数又会调用dep.depend(),每个属性都有对应的dep, dep.depnd函数将Dep.target(Render Watcher)附加到其维护的Watcher列表。这样,当_render执行完,组件中所有属性的dep都将有注册上Render Watcher。只要属性有更新,触发set,调用dep.notify()来通知Render Watcher执行update,从而触发重新渲染。 RenderWatcher.jpg  get函数中有判断this.deep,如果为true,递归遍历所有value的嵌套属性,并触发其getter,将其对应的dep附加上当前watcher。例如在data中定义了extra: { city: '武汉', code: '1000' }, 当我们在watch中监听了extra属性,生成的Watcher也会被city、code属性对应的dep附加上,这样当extra.city发生变化时,watch也能监听到。

 Watcher实体本身也会用newDeps列表维护在pushTarget和PopTarget期间附加的依赖项,接着调用cleanupDeps清理deps列表(上一版本的依赖项),并将新的依赖项newDeps赋值给deps,这样就将最新收集的依赖项收拢了。

重新渲染

 除了在初始化过程会触发渲染,有时我们在程序中也会调用$forceUpdate触发重新渲染,该函数会调用vm._watcher.update方法,这里的_watcher就是上文提到的Render Watcher,并且在Watcher的构造函数中通过vm._watcher = this绑定到vm上。那_watcher的update函数执行了什么操作?

Vue.prototype.$forceUpdate = function () {
    const vm: Component = this
    if (vm._watcher) {
      vm._watcher.update()
    }
  }

 Watcher的update函数一共判断了三种场景,如果为计算属性Watcher,那么lazy为true;如果是同步场景,将直接调用run函数获取最新值并通知回调cb;其他情况,将调用queueWatcher函数创建微任务批量执行watcher列表的run函数,通知更新。这里的run、queueWatcher在《Vue的Watcher和Scheduler原理介绍》中有详细介绍,这里我们只需记住在run函数中会调用构造函数总传入的getter函数获取最新值,而对于Render Watcher,其getter即为updateComponent函数,该函数内部会调用_render、_update来执行渲染和更新,这样重新渲染的目的就达到了。

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

总结

 本文结合一个简单的demo,介绍了在初始化过程中是如何为组件创建Render Watcher、Computed Watcher以及Watch Watcher,当我们在创建Render Watcher时会将其附加到template中依赖的各个属性的dep中,接着触发getter(传入的updateComponent函数),执行渲染和更新。最后说明了重新渲染的情况,当程序中调用$forceUpdate函数,会执行Render Watcher的updateComponent,实现重新渲染。

 本文提到了渲染和补丁更新,但没介绍细节,下一篇主题将介绍Vue如何根据模板生成虚拟节点vnode,以及vnode如何根据补丁算法来实现DOM的局部更新,waiting...。

写在最后

如果大家有其他问题可直接留言,一起探讨!最近我会持续更新Vue源码介绍、前端算法系列,感兴趣的可以持续关注