Vue.nextTick源码解析

597 阅读7分钟

这是我参与8月更文挑战的第17天,活动详情查看:8月更文挑战

Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。并且当一个watcher被多次触发,只会被推入队列一次,然后在当前事件循环的宏任务结束后,调用刚刚推入的所有异步任务。

为什么渲染更新是异步执行?

为了提升性能,如果在主线程中更新DOM,循环10次就需要更新10次DOM。如果采用异步队列的话,只需要更新一次。

Vue 更新 DOM 原理

Vue官网对数据操作的描述:

Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

例如,当你设置vm.someData = 'new value',该组件不会立即重新渲染。当刷新队列时,组件会在事件循环队列清空时的下一个“tick”更新。多数情况我们不需要关心这个过程,但是如果你想在 DOM 状态更新后做点什么,这就可能会有些棘手。虽然 Vue.js 通常鼓励开发人员沿着“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们确实要这么做。为了在数据变化之后等待 Vue 完成更新 DOM ,可以在数据变化之后立即使用Vue.nextTick(callback)。这样回调函数在 DOM 更新完成后就会调用。

Vue.$nextTick()

语法Vue.nextTick([callback, context])

参数

  • {Function} [callback]:回调函数,不传时提供promise调用
  • {Object} [context]:回调函数执行的上下文环境,不传默认是自动绑定到调用它的实例上。

Vue实例方法vm.$nextTick做了进一步封装,把context参数设置成当前Vue实例。

作用: 把我们放入其中的回调函数放在DOM更新之后执行

在数据更新操作之后,往往需要对更新后的DOM做一些操作,但数据更新之后DOM并不是立即更新的,所以直接定义对DOM的操作很可能不起作用,在数据变化之后使用Vue.$nextTick()函数,将对DOM的操作放到nextTick()函数的回调函数中,就可以在DOM更新完成之后调用定义的回调函数,完成对更新后DOM的操作。

例1:

<template>
  <div class="hello">
    <h2 class="myh2">{{test}}</h2>
    <button @click = "onSubmit">点我</button>
   </div>
</template>
<script>
export default {
  name: 'HelloWorld',
  data() {
    return {
      test: '哈哈哈哈哈',
    }
  },
  methods: {
    onSubmit() {
      console.log('before:', document.querySelector('.myh2').innerHTML);//1. before: 哈哈哈哈哈
      this.test = '嘿嘿嘿嘿嘿'
      console.log( 'after:', document.querySelector('.myh2').innerHTML);//2. after: 哈哈哈哈哈
      this.$nextTick(() => {
        console.log('nextTick:', document.querySelector('.myh2').innerHTML );//4. nextTick: 嘿嘿嘿嘿嘿
      })
      console.log('我是同步代码!');//3. 我是同步代码!
    }
  }
}
</script>

先搭建一个vue项目,在某个vue组建中写上述代码。

可以看出,在初始状态下,test的值是'哈哈哈哈哈',DOM上展示的也是'哈哈哈哈哈';点击button以后执行onSubmit(),第一个console.log输出'before: 哈哈哈哈哈';接下来,改变数据,将test赋值为'嘿嘿嘿嘿嘿',此时数据改变了,但同步代码还没执行完所以DOM的值还未改变,仍是'哈哈哈哈哈',所以第二个console.log输出'after: 哈哈哈哈哈';接着执行下面的同步代码,this.$nextTick() 将回调函数放在DOM更新后执行;接着执行下面的同步代码,输出'我是同步代码!';此时所有的 同步代码执行完毕 ,也就是第一个宏任务执行完毕,开始执行异步任务的回调函数,在更新DOM后,执行this.$nextTick()的回调函数,输出'nextTick: 嘿嘿嘿嘿嘿'。

从这个例子我们可以看到,this.$nextTick()作用就是把我们放入其中的回调函数放在DOM更新之后执行。那么,this.$nextTick()是怎么实现的呢?

Vue.$nextTick()原理分析

nextTick 的实现单独有一个JS文件来维护它,在src/core/util/next-tick.js中。

nextTick 源码主要分为两部分:

  • 根据能力检测,决定以哪种方式执行回调队列,并将回调队列的执行函数(flushCallbacks)赋值给timerFunc
  • 调用$nextTick时,向回调队列callbacks中新增回调函数执行timerFunc()

1.根据能力检测,决定以哪种方式执行回调队列,并将回调队列的执行函数(flushCallbacks)赋值给timerFunc

由于宏任务耗费的时间是大于微任务的,所以在浏览器支持的情况下,优先使用微任务。如果浏览器不支持微任务,再使用宏任务。

对当前环境进行不断的降级处理,尝试使用原生的Promise.thenMutationObserversetImmediate,上述三个都不支持最后使用setTimeout;降级处理的目的都是将flushCallbacks函数放入微任务(判断1和判断2)或者宏任务(判断3和判断4),等待下一次事件循环时来执行。

Vue就在更新DOM的那个microtask后追加了我们自己的回调函数,从而确保我们的代码在DOM更新后执行。

// 空函数,可用作函数占位符
import { noop } from 'shared/util'// 错误处理函数
import { handleError } from './error'// 是否是IE、IOS、内置函数
import { isIE, isIOS, isNative } from './env'// 使用 MicroTask 的标识符,这里是因为火狐在<=53时 无法触发微任务,在modules/events.js文件中引用进行安全排除
export let isUsingMicroTask = false// 用来存储所有需要执行的回调函数
const callbacks = []
​
// 用来标志是否正在执行回调函数
let pending = false 
​
​
/**
 * 做了三件事:
 *   1、将 pending 置为 false
 *   2、清空 callbacks 数组
 *   3、执行 callbacks 数组中的每一个函数(比如 flushSchedulerQueue、用户调用 nextTick 传递的回调函数)
 */
// 对callbacks进行遍历,然后执行相应的回调函数
function flushCallbacks () {
    pending = false
    // 这里拷贝的原因是:
    // 有的cb 执行过程中又会往callbacks中加入内容
    // 比如 $nextTick的回调函数里还有$nextTick
    // 后者的应该放到下一轮的nextTick 中执行
    // 所以拷贝一份当前的,遍历执行完当前的即可,避免无休止的执行下去
    const copies = callbcks.slice(0)
    callbacks.length = 0
    for(let i = 0; i < copies.length; i++) {
        copies[i]()
    }
}
​
let timerFunc // 异步执行函数 用于异步延迟调用 flushCallbacks 函数,将 flushCallbacks 函数放入浏览器的异步任务队列中// 在2.5中,我们使用(宏)任务(与微任务结合使用)。
// 然而,当状态在重新绘制之前发生变化时,就会出现一些微妙的问题
// (例如#6813,out-in转换)。
// 同样,在事件处理程序中使用(宏)任务会导致一些奇怪的行为
// 因此,我们现在再次在任何地方使用微任务。
// 优先使用 Promise
//判断1:是否原生支持Promise
if(typeof Promise !== 'undefined' && isNative(Promise)) {
    const p = Promise.resolve()
    timerFunc = () => {
        p.then(flushCallbacks)
        
        // IOS 的UIWebView, Promise.then 回调被推入 microTask 队列,但是队列可能不会如期执行
        // 因此,添加一个空计时器强制执行 microTask
        if(isIOS) setTimeout(noop)
    }
    isUsingMicroTask = true
    //判断2:是否原生支持MutationObserver
} else if(!isIE && typeof MutationObserver !== 'undefined' && (isNative(MutationObserver) || MutationObserver.toString === '[object MutationObserverConstructor]')) {
    // 当 原生Promise 不可用时,使用 原生MutationObserver
    // e.g. PhantomJS, iOS7, Android 4.4
 
    let counter = 1
    // 创建MO实例,监听到DOM变动后会执行回调flushCallbacks
    const observer = new MutationObserver(flushCallbacks)
    const textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
        characterData: true // 设置true 表示观察目标的改变
    })
    
    // 每次执行timerFunc 都会让文本节点的内容在 0/1之间切换
    // 切换之后将新值复制到 MO 观测的文本节点上
    // 节点内容变化会触发回调
    timerFunc = () => {
        counter = (counter + 1) % 2
        textNode.data = String(counter) // 触发回调
    }
    isUsingMicroTask = true
  //判断3:是否原生支持setImmediate
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    timerFunc = () => {
        setImmediate(flushCallbacks)
    }
  // 判断4:上面都不支持,直接使用setTimeout
} else {
    timerFunc = () => {
        setTimeout(flushCallbacks, 0)
    }
}

2.调用$nextTick时,向回调队列callbacks中新增回调函数执行timerFunc()。:

/**
 * 完成两件事:
 *   1、用 try catch 包装  函数,然后将其放入 callbacks 数组
 *   2、如果 pending 为 false,表示现在浏览器的任务队列中没有 flushCallbacks 函数
 *     如果 pending 为 true,则表示浏览器的任务队列中已经被放入了 flushCallbacks 函数,
 *     待执行 flushCallbacks 函数时,pending 会被再次置为 false,表示下一个 flushCallbacks 函数可以进入
 *     浏览器的任务队列了
 * pending 的作用:保证在同一时刻,浏览器的任务队列中只有一个 flushCallbacks 函数
 * @param {*} cb 接收一个回调函数 
 * @param {*} ctx 上下文
 * @returns 
 */
————————————————
​
const callbacks = []
let pending = false
export function nextTick(cb? Function, ctx: Object) {
    let _resolve
    // cb 回调函数会统一处理压入callbacks数组
    callbacks.push(() => {
        if(cb) {
            try {
                cb.call(ctx)
            } catch(e) {
                handleError(e, ctx, 'nextTick')
            }
        } else if (_resolve) {
            _resolve(ctx)
        }
    })
    
    // pending 为false 说明本轮事件循环中没有执行过timerFunc(),pending用来标识同一个时间只能执行一次timerFunc。
    if(!pending) {
        pending = true
        // 执行 timerFunc,在浏览器的任务队列中(首选微任务队列)放入 flushCallbacks 函数
        timerFunc()
    }
    
    // 当不传入 cb 参数时,提供一个promise化的调用 
    // 如nextTick().then(() => {})
    // 当_resolve执行时,就会跳转到then逻辑中
    if(!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
            _resolve = resolve
        })
    }
}

next-tick.js文件对外暴露了nextTick这一函数,所以当我们调用Vue.nextTick或this.$nextTick时会执行:

  • 若传入了回调函数cb,把传入的回调函数cb压入callbacks数组。

  • 若没有传入回调函数cb,提供一个promise化的调用,如nextTick().then(() => {}),当_resolve执行时,就会跳转到then逻辑中。

    这里的代码,_resolve = resolve,个人理解:赋值(基本数据类型:赋值;引用数据类型:赋址),resolve是函数,函数也是对象是引用类型,所以这里 _ _resolve引用了resolve函数,当 _ _resolve执行时也就是resolve执行了,所以会跳转到hen逻辑中。

  • 执行timerFunc()

    timerFunc是存储了flushCallbacks 函数的变量,所以执行timerFunc()就会执行flushCallbacks 函数。flushCallbacks 函数的作用是对callbacks进行遍历,然后执行相应的回调函数(若没有回调函数,执行 _resolve,进入then逻辑中)。

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

附加分析

MutationObserver

简单介绍下MutationObserver:MO是HTML5中的API,是一个用于监视DOM变动的接口,它可以监听一个DOM对象上发生的子节点删除、属性修改、文本内容修改等。

调用过程是要先给它绑定回调,得到MO实例,这个回调会在MO实例监听到变动时触发。这里MO的回调是微任务,是放在microtask中执行的。

// 创建MO实例
const observer = new MutationObserver(callback)
​
const textNode = '想要监听的Don节点'
​
observer.observe(textNode, {
    characterData: true // 说明监听文本内容的修改
})

setImmediate(fn, 0)

在循环事件任务完成后马上运行指定代码.该方法用来把一些需要长时间运行的操作放在一个回调函数里,在浏览器完成后面的其他语句后,就立刻执行这个回调函数.

应用场景

  • 数据变化后操作DOM

    原因:Vue异步执行DOM更新,只要观察到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据改变,如果同一个watcher被多次触发,只会被推入到队列中一次。

  • created()钩子函数中操作DOM

    原因: created()钩子函数执行时DOM并未进行渲染。

    在Vue生命周期的created()钩子函数进行DOM操作一定要放到Vue.nextTick()的回调函数中。在created()钩子函数执行的时候DOM 其实并未进行任何渲染,而此时进行DOM操作无异于徒劳,所以此处一定要将DOM操作的js代码放进Vue.nextTick()的回调函数中。与之对应的就是mounted()钩子函数,因为该钩子函数执行时所有的DOM挂载和渲染都已完成,此时在该钩子函数中进行任何DOM操作都不会有问题。