美文网首页
Vue源码分析—响应式原理(四)

Vue源码分析—响应式原理(四)

作者: oWSQo | 来源:发表于2019-07-23 13:25 被阅读0次

    nextTick

    JS 运行机制

    JS 执行是单线程的,它是基于事件循环的。事件循环大致分为以下几个步骤:

    1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
    2. 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
    3. 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
    4. 主线程不断重复上面的第三步。

    主线程的执行过程就是一个tick,而所有的异步结果都是通过 “任务队列” 来调度。 消息队列中存放的是一个个的任务(task)。 规范中规定task分为两大类,分别是macro taskmicro task,并且每个macro task结束后,都要清空所有的micro task

    简单通过一段代码演示他们的执行顺序:

    for (macroTask of macroTaskQueue) {
        // 1\. Handle current MACRO-TASK
        handleMacroTask();
    
        // 2\. Handle all MICRO-TASK
        for (microTask of microTaskQueue) {
            handleMicroTask(microTask);
        }
    }
    

    在浏览器环境中,常见的macro tasksetTimeout、MessageChannel、postMessage、setImmediate;常见的micro taskMutationObseverPromise.then

    Vue 的实现

    Vue源码 2.5+ 后,nextTick 的实现单独有一个JS文件来维护它。接下来我们来看一下它的实现,在src/core/util/next-tick.js中:

    import { noop } from 'shared/util'
    import { handleError } from './error'
    import { isIOS, isNative } from './env'
    
    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]()
      }
    }
    
    // Here we have async deferring wrappers using both microtasks and (macro) tasks.
    // In < 2.4 we used microtasks everywhere, but there are some scenarios where
    // microtasks have too high a priority and fire in between supposedly
    // sequential events (e.g. #4521, #6690) or even between bubbling of the same
    // event (#6566). However, using (macro) tasks everywhere also has subtle problems
    // when state is changed right before repaint (e.g. #6813, out-in transitions).
    // Here we use microtask by default, but expose a way to force (macro) task when
    // needed (e.g. in event handlers attached by v-on).
    let microTimerFunc
    let macroTimerFunc
    let useMacroTask = false
    
    // Determine (macro) task defer implementation.
    // Technically setImmediate should be the ideal choice, but it's only available
    // in IE. The only polyfill that consistently queues the callback after all DOM
    // events triggered in the same loop is by using MessageChannel.
    /* istanbul ignore if */
    if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
      macroTimerFunc = () => {
        setImmediate(flushCallbacks)
      }
    } else if (typeof MessageChannel !== 'undefined' && (
      isNative(MessageChannel) ||
      // PhantomJS
      MessageChannel.toString() === '[object MessageChannelConstructor]'
    )) {
      const channel = new MessageChannel()
      const port = channel.port2
      channel.port1.onmessage = flushCallbacks
      macroTimerFunc = () => {
        port.postMessage(1)
      }
    } else {
      /* istanbul ignore next */
      macroTimerFunc = () => {
        setTimeout(flushCallbacks, 0)
      }
    }
    
    // Determine microtask defer implementation.
    /* istanbul ignore next, $flow-disable-line */
    if (typeof Promise !== 'undefined' && isNative(Promise)) {
      const p = Promise.resolve()
      microTimerFunc = () => {
        p.then(flushCallbacks)
        // in problematic UIWebViews, Promise.then doesn't completely break, but
        // it can get stuck in a weird state where callbacks are pushed into the
        // microtask queue but the queue isn't being flushed, until the browser
        // needs to do some other work, e.g. handle a timer. Therefore we can
        // "force" the microtask queue to be flushed by adding an empty timer.
        if (isIOS) setTimeout(noop)
      }
    } else {
      // fallback to macro
      microTimerFunc = macroTimerFunc
    }
    
    /**
     * Wrap a function so that if any code inside triggers state change,
     * the changes are queued using a (macro) task instead of a microtask.
     */
    export function withMacroTask (fn: Function): Function {
      return fn._withTask || (fn._withTask = function () {
        useMacroTask = true
        const res = fn.apply(null, arguments)
        useMacroTask = false
        return res
      })
    }
    
    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
        if (useMacroTask) {
          macroTimerFunc()
        } else {
          microTimerFunc()
        }
      }
      // $flow-disable-line
      if (!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
          _resolve = resolve
        })
      }
    }
    

    next-tick.js申明了microTimerFuncmacroTimerFunc2个变量,它们分别对应的是micro task的函数和macro task的函数。对于macro task的实现,优先检测是否支持原生setImmediate,这是一个高版本IE和Edge才支持的特性,不支持的话再去检测是否支持原生的MessageChannel,如果也不支持的话就会降级为setTimeout 0;而对于micro task的实现,则检测浏览器是否原生支持Promise,不支持的话直接指向macro task的实现。

    next-tick.js对外暴露了2个函数,先来看nextTick,它的逻辑也很简单,把传入的回调函数cb压入callbacks数组,最后一次性地根据useMacroTask条件执行macroTimerFunc或者是microTimerFunc,而它们都会在下一个tick执行flushCallbacksflushCallbacks的逻辑非常简单,对callbacks遍历,然后执行相应的回调函数。

    这里使用callbacks而不是直接在nextTick中执行回调函数的原因是保证在同一个tick内多次执行nextTick,不会开启多个异步任务,而把这些异步任务都压成一个同步任务,在下一个tick执行完毕。

    nextTick函数最后还有一段逻辑:

     if (!cb && typeof Promise !== 'undefined') {
      return new Promise(resolve => {
        _resolve = resolve
      })
    }
    

    这是当nextTick不传cb参数的时候,提供一个Promise化的调用,比如:

    nextTick().then(() => {})
    

    _resolve函数执行,就会跳到then的逻辑中。

    next-tick.js还对外暴露了withMacroTask函数,它是对函数做一层包装,确保函数执行过程中对数据任意的修改,触发变化执行nextTick 的时候强制走macroTimerFunc。比如对于一些DOM交互事件,如v-on绑定的事件回调函数的处理,会强制走macro task

    总结

    数据的变化到DOM的重新渲染是一个异步过程,发生在下一个tick。这就是我们平时在开发的过程中,比如从服务端接口去获取数据的时候,数据做了修改,如果我们的某些方法去依赖了数据修改后的DOM变化,我们就必须在nextTick后执行。比如下面的伪代码:

    getData(res).then(()=>{
      this.xxx = res.data
      this.$nextTick(() => {
        // 这里我们可以获取变化后的 DOM
      })
    })
    

    Vue.js提供了2种调用nextTick的方式,一种是全局 API Vue.nextTick,一种是实例上的方法vm.$nextTick,无论我们使用哪一种,最后都是调用next-tick.js中实现的nextTick方法。

    检测变化的注意事项

    对象添加属性

    对于使用Object.defineProperty实现响应式的对象,当我们去给这个对象添加一个新的属性的时候,是不能够触发它的setter的,比如:

    var vm = new Vue({
      data:{
        a:1
      }
    })
    // vm.b 是非响应的
    vm.b = 2
    

    但是添加新属性的场景我们在平时开发中会经常遇到,那么Vue为了解决这个问题,定义了一个全局 API Vue.set方法,它在 src/core/global-api/index.js 中初始化:

    Vue.set = set
    

    这个set方法的定义在 src/core/observer/index.js 中:

    /**
     * Set a property on an object. Adds the new property and
     * triggers change notification if the property doesn't
     * already exist.
     */
    export function set (target: Array<any> | Object, key: any, val: any): any {
      if (process.env.NODE_ENV !== 'production' &&
        (isUndef(target) || isPrimitive(target))
      ) {
        warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
      }
      if (Array.isArray(target) && isValidArrayIndex(key)) {
        target.length = Math.max(target.length, key)
        target.splice(key, 1, val)
        return val
      }
      if (key in target && !(key in Object.prototype)) {
        target[key] = val
        return val
      }
      const ob = (target: any).__ob__
      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
      }
      if (!ob) {
        target[key] = val
        return val
      }
      defineReactive(ob.value, key, val)
      ob.dep.notify()
      return val
    }
    

    set方法接收3个参数,target可能是数组或者是普通对象,key代表的是数组的下标或者是对象的键值,val代表添加的值。首先判断如果target是数组且key是一个合法的下标,则之前通过splice去添加进数组然后返回,这里的splice其实已经不仅仅是原生数组的splice了。接着又判断key已经存在于target中,则直接赋值返回,因为这样的变化是可以观测到了。接着再获取到target.__ob__并赋值给ob,它是在Observer的构造函数执行的时候初始化的,表示Observer的一个实例,如果它不存在,则说明target不是一个响应式的对象,则直接赋值并返回。最后通过defineReactive(ob.value, key, val)把新添加的属性变成响应式对象,然后再通过ob.dep.notify()手动的触发依赖通知,还记得我们在给对象添加getter的时候有这么一段逻辑:

    export function defineReactive (
      obj: Object,
      key: string,
      val: any,
      customSetter?: ?Function,
      shallow?: boolean
    ) {
      // ...
      let childOb = !shallow && observe(val)
      Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter () {
          const value = getter ? getter.call(obj) : val
          if (Dep.target) {
            dep.depend()
            if (childOb) {
              childOb.dep.depend()
              if (Array.isArray(value)) {
                dependArray(value)
              }
            }
          }
          return value
        },
        // ...
      })
    }
    

    getter过程中判断了childOb,并调用了childOb.dep.depend()收集了依赖,这就是为什么执行Vue.set的时候通过ob.dep.notify() 能够通知到watcher,从而让添加新的属性到对象也可以检测到变化。这里如果value是个数组,那么就通过dependArray 把数组每个元素也去做依赖收集。

    数组

    接着说一下数组的情况,Vue也是不能检测到以下变动的数组:
    1.当你利用索引直接设置一个项时,例如:vm.items[indexOfItem] = newValue
    2.当你修改数组的长度时,例如:vm.items.length = newLength

    对于第一种情况,可以使用:Vue.set(example1.items, indexOfItem, newValue);而对于第二种情况,可以使用vm.items.splice(newLength)

    我们刚才也分析到,对于Vue.set的实现,当target是数组的时候,也是通过target.splice(key, 1, val)来添加的,那么这里的splice到底有什么黑魔法,能让添加的对象变成响应式的呢。

    在通过observe方法去观察对象的时候会实例化Observer,在它的构造函数中是专门对数组做了处理,它的定义在src/core/observer/index.js中。

    export class Observer {
      constructor (value: any) {
        this.value = value
        this.dep = new Dep()
        this.vmCount = 0
        def(value, '__ob__', this)
        if (Array.isArray(value)) {
          const augment = hasProto
            ? protoAugment
            : copyAugment
          augment(value, arrayMethods, arrayKeys)
          this.observeArray(value)
        } else {
          // ...
        }
      }
    }
    

    这里我们只需要关注valueArray的情况,首先获取augment,这里的hasProto实际上就是判断对象中是否存在__proto__,如果存在则augment指向protoAugment, 否则指向copyAugment,来看一下这两个函数的定义:

    /**
     * Augment an target Object or Array by intercepting
     * the prototype chain using __proto__
     */
    function protoAugment (target, src: Object, keys: any) {
      /* eslint-disable no-proto */
      target.__proto__ = src
      /* eslint-enable no-proto */
    }
    
    /**
     * Augment an target Object or Array by defining
     * hidden properties.
     */
    /* istanbul ignore next */
    function copyAugment (target: Object, src: Object, keys: Array<string>) {
      for (let i = 0, l = keys.length; i < l; i++) {
        const key = keys[i]
        def(target, key, src[key])
      }
    }
    

    protoAugment方法是直接把target.__proto__原型直接修改为src,而copyAugment方法是遍历keys,通过def,也就是Object.defineProperty去定义它自身的属性值。对于大部分现代浏览器都会走到protoAugment,那么它实际上就把 value 的原型指向了arrayMethodsarrayMethods的定义在src/core/observer/array.js中:

    import { def } from '../util/index'
    
    const arrayProto = Array.prototype
    export const arrayMethods = Object.create(arrayProto)
    
    const methodsToPatch = [
      'push',
      'pop',
      'shift',
      'unshift',
      'splice',
      'sort',
      'reverse'
    ]
    
    /**
     * Intercept mutating methods and emit events
     */
    methodsToPatch.forEach(function (method) {
      // cache original method
      const original = arrayProto[method]
      def(arrayMethods, method, function mutator (...args) {
        const result = original.apply(this, args)
        const ob = this.__ob__
        let inserted
        switch (method) {
          case 'push':
          case 'unshift':
            inserted = args
            break
          case 'splice':
            inserted = args.slice(2)
            break
        }
        if (inserted) ob.observeArray(inserted)
        // notify change
        ob.dep.notify()
        return result
      })
    })
    

    可以看到,arrayMethods首先继承了Array,然后对数组中所有能改变数组自身的方法,如push、pop等这些方法进行重写。重写后的方法会先执行它们本身原有的逻辑,并对能增加数组长度的3个方法push、unshift、splice方法做了判断,获取到插入的值,然后把新添加的值变成一个响应式对象,并且再调用ob.dep.notify()手动触发依赖通知,这就很好地解释了之前的示例中调用vm.items.splice(newLength)方法可以检测到变化。

    相关文章

      网友评论

          本文标题:Vue源码分析—响应式原理(四)

          本文链接:https://www.haomeiwen.com/subject/dksnhctx.html