[vue源码笔记05]vue2.x的实例方法

215 阅读3分钟

数据相关

vm.$set

一般的用法:

this.$set(this.obj, 'value', 1)

源代码:

function set (target, key, val) {
  if (Array.isArray(target) && isValidArrayIndex(key)) { // taget如果是数组类型,同时key是一个正常的数组index,如1,'1'
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val) // 使用splice重新设置数组项值,因为Vue对splice进行了处理实现了更新响应化
    return val
  }
  // 如果key是target的属性,直接赋值,因为如果key in target为true就证明key属性已经响应化
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = target.__ob__
  // 不允许直接对vue实例额外添加属性
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  // 对于非响应化的对象target,且key为新增属性则直接进行赋值,注意这种情况下添加的属性是非响应化的
  if (!ob) {
    target[key] = val
    return val
  }
  // 对于响应化的对象target,但是key为新增属性,则调用defineReactive方法响应化属性key
  defineReactive(ob.value, key, val)
  ob.dep.notify() // 并通知对象target的订阅者进行更新
  return val
}

解释:

详细解释见代码注释,有一点额外说明:

对于第21行的情况:

new Vue({
    data() {
      return {}  
    },
    watch: {
        obj: { // watch将永远监听不到obj的变化
            deep: true,
            handler(newVal) {
                console.log('watch obj', newVal)
            }
        }
    },
    mounted() {
        this.obj = {}
        this.$set(this.obj, 'value', 3)
    }
})

vm.$watch

用法一:

new Vue({
    data() {
        return {
            obj: {
                value: 3
            }
        }
    },
    mounted() {
        this.$watch('obj.value', (newVal, oldVal) => {
            console.log('watch', newVal, oldVal)
        }, {
            immediate: true
        })
    }
})

用法二:

new Vue({
    data() {
        return {
            obj: {
                value: 3
            }
        }
    },
    mounted() {
        this.$watch(() => this.obj.value, {
            immediate: true,
            handler: (newVal, oldVal) => {
              console.log("watch", newVal, oldVal);
            },
        });
    }
})

用法说明:

vm.$watch方法支持3个参数,其中第三个参数可选,第一个参数可以是变量访问路径字符串如:'obj.value'也可以是一个getter函数:() => this.obj.value;第二个参数可以是回调函数也可以是包含回调函数的options对象;如果第二个参数不是options对象,则第三个参数为options对象

Vue.prototype.$watch = function (
    expOrFn: string | Function, // 函数表达式,可以是字符串也可以是函数
    cb: any, // 回调方法
    options?: Object // 选项
  ): Function {
    const vm: Component = this
    // 如果第二个参数为对象,则表明传入的是一个options对象,进行特殊处理,但是原理和下面一样
    if (isPlainObject(cb)) {
      return createWatcher(vm, expOrFn, cb, options)
    }
    options = options || {}
    options.user = true
    // 创建订阅者订阅expOrFn中引用的属性的变化
    const watcher = new Watcher(vm, expOrFn, cb, options)
    // 如果immediate为true,则立即调用回调方法
    if (options.immediate) {
      cb.call(vm, watcher.value)
    }
    // 返回接触watch的接口
    return function unwatchFn () {
      watcher.teardown()
    }
  }

解释:

vm.$watch和options写法的watch原理一样,watch背后也是调用vm.$watch,本质上是一套发布-订阅系统

事件系统

vm.$on

作用:注册一个事件

用法:

new Vue({
    mounted() {
        this.$on('test-event', (e) => {
            console.log('test-event emited', e)
        })
    }
})

源代码:

Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
    const vm: Component = this
    // 如果事件名称为一个数组,则遍历该数组调用$on
    if (Array.isArray(event)) {
      for (let i = 0, l = event.length; i < l; i++) {
        this.$on(event[i], fn)
      }
    } else {
      // 核心代码:将回调函数fn放入vm._envent[event]的数组中,将来事件触发的时候将通过事件event找到事件数组进行执行
      (vm._events[event] || (vm._events[event] = [])).push(fn)
      // 如果event为一个钩子事件(形如hook:xxx的事件,声明周期事件),则将当前vm标记为_hasHookEvent = true
      if (hookRE.test(event)) {
        vm._hasHookEvent = true
      }
    }
    return vm
}

解释: 见代码注释,其中第11行钩子事件将在后面做详细介绍

vm.$off

作用:解除一个事件方法的注册

用法:

const fn = (e) => {
    console.log('test-event emited', e)
}
new Vue({
    mounted() {
        this.$off('test-event', fn)
    }
})

源代码:

Vue.prototype.$off = function (
  event?: string | Array<string>, // 事件名称
  fn?: Function // 事件处理方法
): Component {
  const vm: Component = this;
  // 如果该方法没有接收到任何参数,则将当前实例注册的所有方法清空
  if (!arguments.length) {
    vm._events = Object.create(null);
    return vm;
  }
  // 数组类型事件名则遍历调用$off
  if (Array.isArray(event)) {
    for (let i = 0, l = event.length; i < l; i++) {
      this.$off(event[i], fn);
    }
    return vm;
  }
  const cbs = vm._events[event];
  if (!cbs) {
    return vm;
  }
  // 如果没有接收到fn参数,则将事件event的所有回调方法清空
  if (!fn) {
    vm._events[event] = null;
    return vm;
  }
  if (fn) {
    let cb;
    let i = cbs.length;
    // 从事件队列中找出fn予以解除
    while (i--) {
      cb = cbs[i];
      if (cb === fn || cb.fn === fn) {
        cbs.splice(i, 1);
        break;
      }
    }
  }
  return vm;
};

解释: 见代码注释

vm.$once

作用:注册事件,同时事件回调方法仅执行一次

用法:

new Vue({
    mounted() {
        this.$once('test-event', () => {})
    }
})

源代码:

Vue.prototype.$once = function (event: string, fn: Function): Component {
  const vm: Component = this;
  function on() {
    vm.$off(event, on);
    fn.apply(vm, arguments);
  }
  on.fn = fn;
  vm.$on(event, on);
  return vm;
};

解释:其实$once内部就是将回调方法fn包装了一下,当fn被执行的时候立即解除fn和事件的绑定

vm.$emit

作用:触发事件,执行该事件绑定的所有回调方法

用法:

new Vue({
    mounted() {
        this.$emit('test-event')
    }
})

源代码:

Vue.prototype.$emit = function (event: string): Component {
  const vm: Component = this;
  if (process.env.NODE_ENV !== "production") {
    const lowerCaseEvent = event.toLowerCase();
    // 主要是提示注册的事件名称应该采用'-'连接式命名,而不应使用驼峰式命名
    if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
      tip(
        `Event "${lowerCaseEvent}" is emitted in component ` +
          `${formatComponentName(
            vm
          )} but the handler is registered for "${event}". ` +
          `Note that HTML attributes are case-insensitive and you cannot use ` +
          `v-on to listen to camelCase events when using in-DOM templates. ` +
          `You should probably use "${hyphenate(event)}" instead of "${event}".`
      );
    }
  }
  // 找到事件event下的所有回调
  let cbs = vm._events[event];
  if (cbs) {
    cbs = cbs.length > 1 ? toArray(cbs) : cbs;
    const args = toArray(arguments, 1); // 事件回调的传入参数
    // 遍历执行回调方法
    for (let i = 0, l = cbs.length; i < l; i++) {
      try {
        cbs[i].apply(vm, args);
      } catch (e) {
        handleError(e, vm, `event handler for "${event}"`);
      }
    }
  }
  return vm;
};

总结

整个自定义事件系统的流程:

  1. $on注册事件event:将对应的fn放入vm._event[event]队列中
    1. $once注册事件过程同$on,不同点在于$once会把fn进行包装使得其在执行后即调用$off解除绑定
  2. $emit触发事件event:查找vm._event[event]事件队列,遍历该队列执行回调方法
  3. $off解除事件event和回调fn的绑定关系:查找vm._event[event]事件队列,从中去除fn

钩子事件:

要了解钩子事件要先了解一下Vue的声明周期,看如下源代码:

Vue.prototype._init = function() {
    ...
    initLifecycle(vm)
    initEvents(vm) // 初始化Vue实例事件
    initRender(vm) // 为Vue实例添加下列属性/方法 $slots、$scopedSlots、_c、$createElement、$attrs、$listeners
    callHook(vm, 'beforeCreate') // 调用'beforeCreate'钩子
    initInjections(vm) // resolve injections before data/props
    initState(vm) // 初始化props/methods/data/computed/watch
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created') // 调用'created'钩子
    ...
}

第6行和第10行代码显示vue声明周期(钩子)的调用依赖callHook方法

callhook方法源代码:

function callHook(vm: Component, hook: string) {
  // vue内置的生命周期方法注册在vm.$options属性上面
  const handlers = vm.$options[hook]
  if (handlers) {
    for (let i = 0, j = handlers.length; i < j; i++) {
      try {
        handlers[i].call(vm)
      } catch (e) {
        handleError(e, vm, `${hook} hook`)
      }
    }
  }
  // 这里的关键点在这:实例属性_hasHookEvent为true的话将触发'hook:' + hook事件
  // 即:如果hook为'created',则将调用vm.$emit('hook:created'),此处类似'hook:created'这样的事件就为用户注册的钩子事件
  if (vm._hasHookEvent) {
    vm.$emit('hook:' + hook)
  }
}

解释: 见代码注释

所以钩子事件可以理解为开发给用户的可以注册自定义生命周期方法的一个接口

问题是我们完全可以使用正常的方式注册生命周期方法:

new Vue({
    created() {
        // 生命周期方法代码
    }
})

为什么还需要这样的接口呢?

使用场景:

试想如果有一个三方表格组件,但是该组件渲染很慢,我们希望当该组件开始渲染的时候显示一个loading,该怎么做?

  1. 修改该组件源代码,声明一个beforeCreate生命周期方法写入显示loading的逻辑
  2. 使用钩子事件

方法一简单粗暴有效,但是并不优雅,如果三方组件并不开发源代码在实现起来将很困难,那方法二怎么做呢?

<Table @hook:beforeCreate="handleBeforeCreate" />
    ...
{
   methods: {
       handleBeforeCreate() {
           // 显示loading的逻辑
       }
   }
}

上面的方法就相当于给Table组件注册了一个hook:beforeCreate事件,通过$on方法可知,此情况下组件实例的_hasHookEvent属性将被赋值为true,那当Table组件更新callhook(vm, 'beforeCreate')阶段将执行注册的hook:beforeCreate事件,这样我们就实现了非侵入式的在Table组件中注入自定义的钩子方法

DOM相关

vm.$nextTick

作用:在下一帧执行一些操作,很多情况下用于在页面完成渲染后获取dom元素

用法:

new Vue({
    mounted() {
        this.$nextTick(() => {
            // 回调方法代码
        })
    }
})

源代码:

const callbacks = []; // 回调方法队列
let microTimerFunc, // 微任务寒素
  macroTimerFunc, // 宏任务函数
  pending = false; // 是否有任务正在执行的标识

// 遍历方法队列并执行
function flushCallbacks() {
  pending = false;
  const copies = callbacks.slice(0);
  callbacks.length = 0;
  for (let i = 0; i < copies.length; i++) {
    copies[i]();
  }
}

// 构造宏任务函数
// 宏任务依次降级使用setImmediate MessageChannel setTimeout
if (typeof setImmediate !== "undefined" && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks);
  };
} else if (
  typeof MessageChannel !== "undefined" &&
  (isNative(MessageChannel) ||
    MessageChannel.toString() === "[object MessageChannelConstructor]")
) {
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = flushCallbacks;
  macroTimerFunc = () => {
    port.postMessage(1);
  };
} else {
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0);
  };
}

// 构造微任务函数
// 微任务依次降级使用Promise 宏任务
if (typeof Promise !== "undefined" && isNative(Promise)) {
  const p = Promise.resolve();
  microTimerFunc = () => {
    p.then(flushCallbacks);
  };
} else {
  // 降级使用macro
  microTimerFunc = macroTimerFunc;
}

Vue.prototype.$nextTick = function (fn: Function) {
  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;
    if (useMacroTask) {
      macroTimerFunc();
    } else {
      microTimerFunc();
    }
  }
  // 如果回调cb不合法,则返回Promise实例
  if (!cb && typeof Promise !== "undefined") {
    return new Promise((resolve) => {
      _resolve = resolve;
    });
  }
};

解释:

nextTick的原理就是维护一个回调队列callbacks,首先把回调fn加入队列,该队列是一个异步执行队列,执行方式可以是宏任务也可以是微任务,宏任务以及微任务的实现方式见代码注释,在下一个任务循环中callbacks中的方法将被取出一一执行

生命周期

vm.$mount

作用:将vue实例生成的vnode挂载到真实dom节点

使用:

new Vue({
    template: '',
}).$mount('#app')

源代码:

const mount = function (
  vm,
  el?: string | Element,
): Component {
  vm.$el = el
  callHook(vm, 'beforeMount') // 执行beforeMount钩子
  let updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }
  
  // 创建render-watcher
  new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)

  vm._isMounted = true
  callHook(vm, 'mounted')
  
  return vm
}

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el) // 通过el来获取真是dom,传入el可以是一个dom节点,也可以是一个dom选择器字符串
  const options = this.$options
  // 标准化模板template字符串
  if (!options.render) {
    let template = options.template // 获取最终template模板
    if (template) {
      if (typeof template === 'string') {
        // 如果获取到options中的template为以'#'开头的字符串,则以此为id查找dom对象,并获取其innerHTML作为template
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
        }
      } else if (template.nodeType) { // 如果template为dom对象,则取其innerHTML为template
        template = template.innerHTML
      } else {
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) { // 如果options中没有template,则获取el对象的outerHTML为template
      template = getOuterHTML(el)
    }
    // 通过template生成render方法
    if (template) {
      // 将template模板字符串转为render函数
      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns
    }
  }
  return mount(this, el, hydrating)
}

解释:

$mount主要做了以下几件事情

  1. 标准化模板template
  2. 基于template生成render函数
  3. 执行beforeMount生命周期
  4. 基于render函数作为getter创建Watcher订阅者实例
  5. 执行mounted生命周期

vm.$destroy

作用:销毁当前vue实例

源代码:

Vue.prototype.$destroy = function () {
  const vm: Component = this;
  if (vm._isBeingDestroyed) {
    return;
  }
  callHook(vm, "beforeDestroy"); // 执行beforeDestroy
  vm._isBeingDestroyed = true;
  const parent = vm.$parent;
  if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
    remove(parent.$children, vm); // 从父实例中移除当前实例
  }
  // 移除render-watcher
  if (vm._watcher) {
    vm._watcher.teardown();
  }
    // 移除所有watcher
  let i = vm._watchers.length;
  while (i--) {
    vm._watchers[i].teardown();
  }
  
  if (vm._data.__ob__) {
    vm._data.__ob__.vmCount--;
  }
  
  vm._isDestroyed = true;
  // 销毁当前实例对应的渲染树节点
  vm.__patch__(vm._vnode, null);
  callHook(vm, "destroyed"); // 执行destroyed
  // 移除所有注册在这个实例的事件
  vm.$off();
  // 移除当前实例对应的dom关于本实例的引用
  if (vm.$el) {
    vm.$el.__vue__ = null;
  }
  // 释放对父实例的引用
  if (vm.$vnode) {
    vm.$vnode.parent = null;
  }
};

解释:

$destory主要做了一些组件销毁及其后续的清理工作

  1. 调用beforeDestroy钩子
  2. 从父实例中移除当前实例
  3. 移除watchers
  4. 移除注册的事件
  5. 移除dom对当前实例的引用
  6. 释放对父实例的引用

vm.$forceUpdate

作用:强制实例更新

源代码:

Vue.prototype.$forceUpdate = function () {
  const vm: Component = this;
  // vm._watcher引用render-watcher
  if (vm._watcher) {
    vm._watcher.update(); // 触发render-watcher更新
  }
};

解释:

由于vm._watcher保持了对render-watcher的引用,该方法直接触发了render-watcher.update()进行更新