在前面的章节中我们其实多次提到了 directives 指令相关内容,不过都是分散在各个生命周期中的。因此大家对指令的整个工作原理还没有很明白,这一篇文章,让我们以内置指令 v-model 为例,来看看一个指令的完整生命周期内的工作原理

初始化

在前面《组件的初始化过程》一章中我们其实讲到过,内置指令其实在Vue类的创建过程中就被创建出来,并存储在 Vue.options.directives 中,作为默认存在的指令。代码如下:

platforms/web/runtime/index.js

  1. // 省略
  2. // 注册指令和组件,这里的 directives 和 components 也是web平台上的,是内置的指令和组件,其实很少
  3. // install platform runtime directives & components
  4. extend(Vue.options.directives, platformDirectives) // 内置的directives只有两个,`v-show` 和 `v-model`

假设我们有如下组件:

  1. var app = new Vue({
  2. el: '#app',
  3. template:
  4. '<div class="hello">' +
  5. '<input v-model="message" />'+
  6. '<p>{{message}}</p>'+
  7. '</div>'
  8. ,
  9. data: {
  10. message: 'Hello Vue!'
  11. }
  12. })

当我们通过 new Vue(options) 创建实例的时候,会调用 _init 进行初始化,在 _init 函数内部,会把 optionsVue.options 进行合并,因此我们的组件$options 中就有了默认的 directives:

core/instance/init.js

  1. Vue.prototype._init = function (options?: Object) {
  2. const vm: Component = this
  3. // a flag to avoid this being observed
  4. vm._isVue = true
  5. // merge options
  6. if (options && options._isComponent) {
  7. // 省略
  8. } else {
  9. vm.$options = mergeOptions(
  10. resolveConstructorOptions(vm.constructor),
  11. options || {},
  12. vm
  13. )
  14. }
  15. }

上面的代码 mergeOptions 会进行合并,最终 vm.$options 包含了我们创建实例是传入的自定义指令和系统自带的 v-model 指令。

而,我们生成的render 函数是这样的:

  1. (function anonymous() {
  2. with (this) {
  3. return _c('div', {
  4. staticClass: "hello"
  5. }, [_c('input', {
  6. directives: [{
  7. name: "model",
  8. rawName: "v-model",
  9. value: (message),
  10. expression: "message"
  11. }],
  12. domProps: {
  13. "value": (message)
  14. },
  15. on: {
  16. "input": function($event) {
  17. if ($event.target.composing)
  18. return;
  19. message = $event.target.value
  20. }
  21. }
  22. }), _c('p', [_v(_s(message))])])
  23. }
  24. }
  25. )

在生成 render 函数的时候,通过对模板的语法解析,已经知道我们用到了 v-model 这个指令,并解析出了他的参数。这样我们创建出来的 vnode 就存在一个 vnode.data.directives 保存了 v-model 指令。
大家可能注意到了,这里面还生成了一个 input 事件,在我们输入的时候会修改 message。那么我们的 v-model 其实在 render 函数中变成了两部分:

  • v-model 指令的配置
  • input 事件

这样我们执行 render之后,得到的 vnodedata 字段是这样的:

  1. {
  2. data: {
  3. directives: [
  4. {
  5. expression:"message",
  6. name:"model",
  7. rawName:"v-model",
  8. value:"Hello Vue!"
  9. ],
  10. on: {
  11. input: function () {}
  12. }
  13. }
  14. }

输入框如何更新 message

patch 阶段,在 createElm 函数中会通过 invokeCreateHooks 来绑定 input 事件。具体代码这里不贴出来,感兴趣的可以去 core/vdom/patch.js 中看 invokeCreateHooks 相关的代码。

patch 完成之后,最后一段代码是 invokeInsertedHook ,会调用 v-modelinserted 方法,我们看看这个方法:

platform/web/runtime/directives/model.js

  1. const directive = {
  2. inserted (el, binding, vnode, oldVnode) {
  3. if (vnode.tag === 'select') {
  4. // #6903
  5. if (oldVnode.elm && !oldVnode.elm._vOptions) {
  6. mergeVNodeHook(vnode, 'postpatch', () => {
  7. directive.componentUpdated(el, binding, vnode)
  8. })
  9. } else {
  10. setSelected(el, binding, vnode.context)
  11. }
  12. el._vOptions = [].map.call(el.options, getValue)
  13. } else if (vnode.tag === 'textarea' || isTextInputType(el.type)) {
  14. el._vModifiers = binding.modifiers
  15. if (!binding.modifiers.lazy) {
  16. el.addEventListener('compositionstart', onCompositionStart)
  17. el.addEventListener('compositionend', onCompositionEnd)
  18. // Safari < 10.2 & UIWebView doesn't fire compositionend when
  19. // switching focus before confirming composition choice
  20. // this also fixes the issue where some browsers e.g. iOS Chrome
  21. // fires "change" instead of "input" on autocomplete.
  22. el.addEventListener('change', onCompositionEnd)
  23. /* istanbul ignore if */
  24. if (isIE9) {
  25. el.vmodel = true
  26. }
  27. }
  28. }
  29. },
  30. componentUpdated (el, binding, vnode) {
  31. // 省略
  32. }
  33. }

可以发现 inserted 主要就是调用DOM的API添加了几个事件监听,主要是这三行代码:

  1. el.addEventListener('compositionstart', onCompositionStart)
  2. el.addEventListener('compositionend', onCompositionEnd)
  3. el.addEventListener('change', onCompositionEnd)

如果你以为这几个回调函数比如 onCompositionEnd 里面会去修改 message,那你就错了。其实这几个回调完全不是为了做数据修改,而是为了为了生成一个 input 事件。没错,这几行代码只是为了处理兼容性,在各种浏览器环境中都能生成正确的 input 事件。

而当我们输入框的内容发生变化的时候,其实是由 input 事件触发的。input函数正是我们render函数生成的:

  1. on: {
  2. "input": function($event) {
  3. if ($event.target.composing)
  4. return;
  5. message = $event.target.value
  6. }
  7. }

如果 $event.target.composingtrue ,说明是在组合事件,因此不用管。等组合事件结束了,会触发 inpunt 事件,此时只要去更新一下值就行了。这样我们就理解了我们输入的时候,是如何更新 message 的值的。

我画了一个图来表示 input 是如何更新 message 值的,其实就是这么简单:

Vue2.x源码解析系列十一:插件系统 - 图1

那么,当 message 被其他地方更新的时候,输入框的值是怎么更新的呢?

message 被更新时,输入框如何一起更新

可能有人第一反应是 v-model 会监听 this.message 的更新,然后通过 input.value 来设置输入框的值。实际上这种想法是完全错误的,因为 v-model 指令根本不用处理这种情况,这是交给 patch 来做的。当 message 被更新的时候, 会触发 vm._update 来更新组件,最终会把这个更新patch到真实的 DOM 上。所以,如果我们画一个流程图,其实是这样的:
Vue2.x源码解析系列十一:插件系统 - 图2

记住这一点:在 v-model 插件中,如果我们要更新DOM,只需要修改vm的状态,它自己会进行patch来更新。关于 patch 的机制请参阅之前的文章。

v-show

那么可能大家会想,是不是在 vue2.x 中由于vdom的存在,我们的指令就不会操作真实DOM呢?其实并不是,这里简单看下内置指令 v-show 的代码:

platform/web/runtime/directives/show.js

  1. var show = {
  2. bind: function bind (el, ref, vnode) {
  3. var value = ref.value;
  4. //省略动画相关
  5. {
  6. el.style.display = value ? originalDisplay : 'none';
  7. }
  8. },
  9. update: function update (el, ref, vnode) {
  10. var value = ref.value;
  11. var oldValue = ref.oldValue;
  12. // 省略动画相关
  13. {
  14. el.style.display = value ? el.__vOriginalDisplay : 'none';
  15. }
  16. }

主要的代码都是对动画的处理,这里我们不考虑动画部分,那么其实在 v-show 指令中,真的是对原生DOM进行操作的:

  1. el.style.display = xxxx

我们写指令的时候,在生命周期的阶段都可以直接访问原生DOM,而且指令本来作用就是拓展 DOM 能里的,因此指令中操作原生DOM是很常见的操作。因此我们也可以知道,这些需要操作原生DOM的指令,和平台是相关的,他们的代码在 platforms 里而不是 core里面。

Vue 官方对指令生命周期的定义如下:

一个指令定义对象可以提供如下几个钩子函数 (均为可选):

  • bind:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
  • inserted:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。
  • update:所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前。指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板更新 (详细的钩子函数参数见下)。
  • componentUpdated:指令所在组件的 VNode 及其子 VNode 全部更新后调用。
  • unbind:只调用一次,指令与元素解绑时调用。

在生命周期的各个阶段,第一个参数都是 el.