Vue 实例方法实现原理

440 阅读5分钟

事件相关实例方法

  • vm.$on

    用法: vm.$on(event: {String | Array<String>}, callback: Function)

    Vue.prototype.$on = function (event, fn) {
      const vm = this
      if (Array.isArray(event)) {
        for (let i = 0; i < event.length; i++) {
          this.$on(event[i], fn)
        }
      } else {
        (vm._events[event] || (vm._events[event] = [])).push(fn)
      }
      return vm
    }
    
  • vm.$off

    用法: vm.$off(event: {String | Array<String>}, callback: Function)

    如果提供了没有提供参数,则移除所有事件监听器;如果只提供了事件,则移除该事件绑定的所有监听器;如果同时提供了两个参数,则只移除这个回调的监听器。

    Vue.prototype.$off = function (event, fn) {
      const vm = this
      if (!arguments.length) {
        vm._events = Object.create(null)
        return vm
      }
    
      if (Array.isArray(event)) {
        for (let i = 0; i < event.length; i++) {
          this.$off(event[i], fn)
        }
        return vm
      }
    
      const cbs = vm._events[event]
      if (!cbs) {
        return vm
      }
    
      if (arguments.length === 1) {
        vm._events[event] = null
        return vm
      }
    
      if (fn) {
        let i = cbs.length
        while (i--) {
          cb = cbs[i]
          if (cb === fn || cb === fn.fn) {
            cbs.splice(i, 1)
            break
          }
        }
      }
    
      return vm
    }
    
  • vm.$once

    用法: vm.$once(event: {String | Array<String>}, callback: Function)

    监听一个自定义事件,但是只触发一次,第一次触发后移除监听器。

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

    这里对函数 fn 做了一层拦截,在函数执行前就先销毁了监听器,实现了需求。除此之外还需要注意 on.fn = fn,由于我们监听的函数 on 与实际这一段代码正是我们在 vm.$off 中添加了 if (cb === fn || cb === fn.fn) 的原因。

  • vm.$emit

    用法:vm.$emit(event: {String | Array<String>}, [...args])

    触发当前实例上的事件,附加参数都会传给监听器回调。实现起来相对简单,找出当前事件绑定的所有回调函数,传入参数并执行。

    Vue.prototype.$emit = function (event) {
      const vm = this
      let cbs = vm._events[event]
      if (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
    }
    

生命周期相关实例方法

与生命周期有关实例方法有四个:vm.$mount, vm.$forceUpdate, vm.$nextTick, vm.$destroy

  • vm.$forceUpdate

    vm.$forceUpdate 就是迫使实例重新渲染,但只影响实例本身以及插入插槽内容的子组件。所以只需要执行实例 watcherupdate 方法即可。前面提到过每个组件内部都有一个 watcher,当它的状态改变时,就会通知组件内部使用虚拟 DOM 进行重新渲染操作。

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

    vm.$destroy 是用来销毁一个实例,清理该实例与其他实例的链接,并解绑全部指令与监听器,同时触发 beforeDestroydestroyed钩子函数。但由于大部分场景下都能通过 v-if 或者 v-for 使用数据驱动的方式来控制子组件的生命周期,实用性并不是很高。

    Vue.prototype.$destroy = function () {
      const vm = this
      if (vm._isBeingDestroyed) {
        return
      }
      callHook(vm, 'beforeDestroy')
      vm._isBeingDestroyed = true
    
      // 从父组件中删除子组件的引用
      const parent = vm.$parent
      if (parent && !parent._isBeingDestroyed && !vm.options.abstact) {
        remove(parent.$children, vm)
      }
    
      // 从 watcher 监听的所有状态列表中移除 watcher
      // 不仅要移除组件自身的 watcher 实例,还要移除 vm.$watch 方法生成的实例
      if (vm._watcher) {
        vm._watcher.teardown()
      }
      let i = vm._watchers.length
    
      while (i--) {
        vm._watchers[i].teardown()
      }
    
      vm._isDestroyed = true
    
      // 在 vnode 树上触发 destroy 钩子函数
      vm.__patch__(vm.node, null)
    
      // 触发 destroyed 钩子函数
      callHook(vm, 'destroyed')
    
      vm.$off()
    }
    function remove (arr, item) {
      if (arr.length) {
        const index = arr.indexOf(item)
        if (index > -1) {
          return arr.splice(index, 1)
        }
      }
    }
    
  • vm.$nextTick

    vm.$nextTick 是在实际开发中经常用到的一个 API,比如当更新组件数据后,需要对新 DOM 做一些操作,但是此时是获取不到更新后的 DOM ,因为还没有重新渲染,这种时候就要用到 vm.$nextTick

    vm.$nextTick 接受一个回调函数作为参数,并将回调函数的调用延迟到下次 DOM 更新周期之后执行。

    思考一个问题,为什么 Vue 要引入异步更新队列?

    我们知道每个组件内部有一个 watcher 实例,组件内部任何一个状态的改变都会通知其更新,那么如果在一轮事件循环中,有两个状态改变了,必然会导致 watcher 收到两次通知,从而渲染两次,导致资源的浪费。而引入了异步更新队列后,将一个事件循环内收到通知的 watcher 实例缓存起来,并判断是否已经存在相同的实例,只有不存在才加入队列当中。在下一个时间循环中才触发 watcher 实例的渲染过程并清空队列。

    有一点需要注意的是,更新 DOM 的回调和 vm.$nextTick 注册的回调都是推入到微任务队列中,所以可能会存在顺序问题。如果回调中要操作更新后的 DOM ,注意将 vm.$nextTick 的调用放置于数据修改之后。

    const callbacks = []
    let pending = false
    
    function flushCallbacks () {
      pending = false
      const copies = callbacks.slice(0)
      callbacks.length = 0
      for (let i = 0; i < copies.length; i++) {
        copies[i]()
      }
    }
    
    let microTimerFunc 
    let macroTimerFunc
    let useMacroTask = false
    
    // 根据执行环境设置不同的宏任务执行函数
    if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
      macroTimerFunc = () => {
        setImmediate(flushCallbacks)
      }
    } else if (typeof MessageChannel !== 'undefined' && (
      isNative(MessageChannel)  ||
      MessageChannel.toString() === '[object MessageChannelConstrcutor]'
    )) {
      const channel = new MessageChannel()
      const port = channel.port2
      channel.port1.onmessage = flushCallbacks
      macroTimerFunc = () => {
        port.postMessage(1)
      }
    } else {
      macroTimerFunc = setTimeout(flushCallbacks, 0);
    }
    
    // 选择微任务执行函数
    
    if (typeof Promise !== 'undefined' && isNative(Promise)) {
      const p = Promise.resolve()
      microTimerFunc = () => {
        p.then(flushCallbacks)
      }
    } else {
      microTimerFunc = macroTimerFunc
    }
    
    
    export function widthMacroTask (fn) {
      return fn._withTask || (fn._withTask = function() {
        useMacroTask = true
        const res = fn.apply(null, arguments)
        useMacroTask = false
        return res
      })
    }
    
    export function nextTick (cb, ctx) {
      let _resolve
      callbacks.push(() => {
        if (cb) {
          cb.call(ctx)
        } else if (_resolve) {
          _resolve(ctx)
        }
      })
      if (!pending) {
        pending = true
        if (useMacroTask) {
          macroTimerFunc()
        } else {
          microTimerFunc()
        }
      }
      // 如果在不存在回调且环境支持 Promise 的情况下,返回一个 Promise
      if (!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
          _resolve = resolve
        })
      }
    }
    
  • vm.$mount

    用法:vm.$mount([elementOrSelector])

    如果在 Vue 实例初始化时没有传入 el 选项,那么就必须手动挂载;如果已经传入了,Vue 则会调用这个方法自动挂载。所以清楚这个 API 的实现对我们理解 Vue 实例是如何与 DOM 关联上是很有帮助的。

    由于 Vue 存在不同构建版本,而不同版本下的 vm.$mount 的表现都不一样,关键在于当前版本是否有编译器,编译器的实现原理在上一篇文章都已经提过了。对于有编译器的版本,会先检查 template 选项或是 el 选项提供的模板是否已经转换成渲染函数。如果没有则进入编译,将模板转换为渲染函数之后再进入挂载与渲染的过程。

    而只包含运行时版本的 vm.$mount 没有编译步骤,默认已经存在渲染函数,如果不存在,会设置一个返回值的空节点 VNode 的默认函数。

    1. 完整版的 vm.$mount

      只看完整版与运行时的差异部分:

      • 先通过函数劫持,为运行时版本的 vm.$mount 添加功能
      const mount = Vue.prototype.$mount
      
      Vue.prototype.$mount = function (el) {
        // 兼容选择器和 DOM 元素两种写法
        el = el && query(el)
        // dosomething
        return mount.call(this, el)
      }
      
      • 判断是否存在渲染函数,不存在再将模板编译,先将 template 解析出来:
      const mount = Vue.prototype.$mount
      
      Vue.prototype.$mount = function (el) {
        // 兼容选择器和 DOM 元素两种写法
        el = el && query(el)
        const options = this.$options
      
        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)
          }
        }
        return mount.call(this, el)
      }
      
      • 模板获取之后,就要对其进行编译处理:
      const mount = Vue.prototype.$mount
      
      Vue.prototype.$mount = function (el) {
        // 兼容选择器和 DOM 元素两种写法
        el = el && query(el)
        const options = this.$options
      
        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} = compileToFunctions(
            template,
            {...},
            this
          )
          options.render = render
        } 
        return mount.call(this, el)
      }
      
    2. 运行时版本的 vm.$mount

    运行时版本的 vm.$mount 主要完成了下面几件事;触发生命周期钩子;创建组件内的 vm._watcher;挂载组件。

    Vue.prototype.$mount = function (el) {
      el = el && inBrowser ? query(el) : undefined
      return mountComponent(this, el)
    }
    
    export function mountComponent (vm, el) {
      if (!vm.$options.render) {
        // 开发环境发出警告
        vm.$options.render = createEmptyNode()
      }
    
      callHook(vm, 'beforeMount')
    
      vm._watcher = new Watcher(vm, () => {
        vm._update(vm._render())
      }, noop)
    
      callHook(vm, 'mounted')
    
      return vm
    }
    

    Watcher 的实现在本系列最早的几篇文章中就分析过了,这里再来简单回顾一下。我们知道一旦 Vue 实例挂载之后,每当内部状态改变,都会触发渲染操作。而挂载的关键就在于 new Watcher 这段代码。

    Watcher 第二个参数传入函数,在读取一个 Watcher 实例时,会执行这个函数,进而触发函数中访问到的所有响应式数据的 getter,从而将当前的 Watcher 实例添加到各数据的依赖列表当中。当数据发生变化时,Watcher 会得到通知,并再次执行传入的第二个参数,这就是持续渲染的原因。

本系列文章均是深入浅出 Vue.js的学习笔记,有兴趣的小伙伴可以去看书哈。