深入理解nextTick

2,384 阅读5分钟

nextTick什么时候用

道理讲再多,不如带你真真切切的踩个坑。如下图所示,一个门店后厨的统计页面,在第一个“后厨出菜统计”模块,要绘制每个厨师出菜比例的饼图。

既然Vue是双向数据绑定的,我们直接把响应数据赋值给data后,视图理应就会更新,可我们紧接着通过DOM对象绘制图表的时候,却报错找不到DOM元素。显然Vue的数据赋值后,没有立即去更新DOM。

<template>
  <div>
    <p class="title">后厨出菜统计</p>
  </div>
  <div class="cook-item" 
   v-for="(cook,index) in cookList" 
   :key="'chef_list_'+cook.oid">
    <!-- 出菜信息 -->
    <div></div>
    <!-- 出菜图表 -->
    <div style="flex: 1;background-color: #f8f8f8;border-radius: 6px;">
      <canvas :id="'pie_' + cook.oid"></canvas>
    </div>
  </div>
</template>


<script>
import F2 from '@antv/f2';
const axios = require('axios').default;

// 动态构造饼图
function makePieChart(list, containerId) {
  // ....
  const pieChart = new F2.Chart({
    id: containerId,
    pixelRatio: window.devicePixelRatio,
    padding: [20, 'auto']
  });
  // ...
  pieChart.render();
}

export default {
  name: 'cookReport',
  props: {
    storeCode: String,  // 门店编码
    date: String  // 统计日期
  },
  data() {
    return {
      cookList: []
    }
  },
  mounted() {
    // 挂载完成后,发起请求数据
    axios.get(`${process.env.VUE_APP_BASE_URL}/report?date=${this.date}&storeCode=${this.storeCode}`).then(res => {
        // 赋值
        this.cookList = res.data
        // 直接调用
        this.cookList.forEach(cook => {
          makePieChart(cook.pieDataList, `pie_${cook.oid}`)
        })
    })
  }
}
</script>

F2图表绘制报错:

Uncaught (in promise) TypeError: Cannot read property 'currentStyle' of null
    at getStyle (f2.js?e004:765)
    at getWidth (f2.js?e004:769)
    at Canvas._initCanvas (f2.js?e004:9848)
    at new Canvas (f2.js?e004:9801)
    at createCanvas (f2.js?e004:10031)
    at Chart._initCanvas (f2.js?e004:10490)
    at Chart._init (f2.js?e004:10567)
    at new Chart (f2.js?e004:10606)
    at makePieChart (StoreReport.vue?8e92:161)
    at eval (StoreReport.vue?8e92:293)

这是因为Vue采取的是异步更新策略,我们把图表绘制的代码写在nextTick里,能正常运行了。

mounted() {
  // 挂载完成后,发起请求数据
  axios.get(`${process.env.VUE_APP_BASE_URL}/report?date=${this.date}&storeCode=${this.storeCode}`).then(res => {
      // 赋值
      this.cookList = res.data
      // 等待DOM更新完成后,再调用
      this.$nextTick(() => {
        this.cookList.forEach(cook => {
          makePieChart(cook.pieDataList, `pie_${cook.oid}`)
        })
      });
  })
}

nextTick()的本质

  • 结合上面的例子,再理解官方文档的解释,就一目了然了:

在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。

那么问题来了,我们不禁要思考nextTick回调函数的本质是什么?

  1. nextTick,单词next我知道是"下一个",那什么是tick?
  2. nextTick明明是JS代码,它怎么就知道DOM什么时候更新呢?又是谁去通知它呢?
  3. nextTick是钩子函数嘛,能否像mounted/watch生命周期函数的方式来使用?反正DOM更新就会通知调用不是嘛?

结合EventLoop理解nextTick()

解答第一个问题,就不得不提事件循环机制。

众所周知,事件循环(EventLoop)是JS协调异步程序的机制,异步程序中又分为宏任务和微任务,宏任务通常是和浏览器级别的,比如定时器/轮询器/XHR等;微任务通常是ECMAScript级别的,比如promise/await+async/mutationObserver/processe.nextTick等; 宏任务和微任务都是以队列(queue)的数据结构存在,宿主环境轮询检查执行栈(CallStack)的清空情况,然后逐个放入宏任务执行,当前宏任务完后执行紧接着会执行微任务。

现在可以回答第一个问题了,每执行一个宏任务,就是一个tick。而nextTick()在语境上,可以理解成"在下一个宏任务执行之前的回调函数"。

nextTick()通过MutataionObsever监听DOM的更新

接着解答第二个问题,DOM的增删改查操作是同步执行的,对于DOM变化的监听提供的MutationObsever API是异步的,并且属于微任务。 一个简单的MutationObsever示例:

<body>
    <div id="container"></div>
    <script>
        // 厨师列表
        let cookList = [{
            name: '斯蒂芬周',
            oid: 10081
        },{
            name: '鸡姐',
            oid: 10082
        },{
            name: '唐牛',
            oid: 10083
        }];
        // 模板容器
        let $container = document.getElementById('container');

        // 当容器内的chart DOM插入后,打印日志
        new MutationObserver(() => {
            console.log('大厨已就绪,可以进行下一步图表绘制工作!')
        }).observe($container, {
            childList: true,
            subtree: true
        });

        // 遍历数组,插入chart DIV
        cookList.forEach(cook => {
            let cookDiv = document.createElement('div');
            cookDiv.setAttribute('id','cook_' + cook.oid);
            cookDiv.textContent = cook.name;
            $container.appendChild(cookDiv);
        });
    </script>
</body>

nextTick()本质就是往微任务队列中追加执行函数

接下来看第三个问题:如果像生命周期函数一样,提前注册nextTick是否可行呢?答案是否定的

mounted() {
  // 先注册nextTick
  this.$nextTick(() => {
    console.log('DOM ready')
  });

  axios.get(`${process.env.VUE_APP_BASE_URL}/report?date=${this.date}&storeCode=${this.storeCode}`).then(res => {
      // 响应后对数据进行赋值
      this.cookList = res.data
      console.log('data assign')
  })
}

输出的结果:没等数据赋值,nextTick()就先执行了

> DOM ready
> data assign

结论:MutataionObsevernextTick都是微任务,在队列中按顺序执行。所以在业务代码中,数据赋值与nextTick()总是成对出现,并且nextTick必须在数据赋值后面,不能像钩子函数一样提前注册。

通过对比,认清自己

nextTickprocess.nextTick()的区别

process.nextTick是node.js的API,和Vue.nextTick一样,都是往微任务追加执行函数


nextTickjQuery.ready()的区别

当我和后端小哥用厨师图表渲染的例子,讲解nextTick时,他当下反应是,“哦!这个我知道,和jQuery.ready()一样嘛!”,我当时是一脸的黑人问号表情。这两者还是不一样的,jQuery.ready()是jQuery实例挂载到window的回调函数,函数里面就可以通过jQuery来操作DOM了。而Vue.nextTick()是响应数据绑定的视图更新后的回调函数,函数里面可以操作DOM。


全局的Vue.nextTick与组件内this.nextTick的区别

当前组件和与全局Vue实例都指向src/core/util/next-tick.js同一个nextTick()函数


nextTickrequestAnimationFrame()的关系

还是要回到EventLoop机制:

  1. 执行栈(Call Stack)清空后,放入宏任务队列排在最前面的task,并执行它;
  2. 执行当前宏任务后,检查是否存在微任务(Microtask),如果有,则轮询执行,直到清空微任务队列(包含嵌套产生的微任务);
  3. 检查是否进行浏览器渲染;
  4. 如需渲染,先调用requestAnimationFrame函数;
  5. 然后执行浏览器更新渲染;
  6. 最后执行requestIdleCallback函数;
  7. 重复以上步骤

可见,nextTick只是微任务队列中普通的函数,就是按顺序执行。而requestAnimationFrame()取决于本次轮询是否要进行更新渲染,在需要更新渲染前,调用执行。

nextTick实现原理

源码走读

/* @flow */
/* globals MutationObserver */
import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'

// 标识-是否使用微任务执行回调函数
export let isUsingMicroTask = false 
// 数组-回调函数列表
const callbacks = []  
// 标识-是否有回调函数在执行中
let pending = false

// 轮询callbacks数组,执行每个回调函数,并清空数组
function flushCallbacks () {
  pending = false
  // 复制到局部变量
  const copies = callbacks.slice(0)
  // 清空原数组
  callbacks.length = 0
  // 轮询执行每个回调函数
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

// 定义主逻辑的timerFunc方法
let timerFunc

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  /**
   * 判断是否支持Promise
   */ 
  const p = Promise.resolve()
  // 以Promise.then来处理flushCallbacks函数
  timerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
  // Promise属于微任务,进行标识
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  /**
   * 判断是否支持MutationObsever
   */ 
  let counter = 1
  // 以MutationObsever的监听来处理flushCallbacks函数
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  // MutationObsever也属于微任务,进行标识
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  /**
   * 判断是否支持setImmediate
   */ 
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  /**
   * 以上方法都不支持,则通过定时器的宏任务来处理
   */ 
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

// 对外暴露nextTick方法,传递callback回调函数,以及执行环境
export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve

  // 将回调函数包装后,放入callbacks数组
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })

  if (!pending) {
    // 有微任务在执行时,先设置pending为true进行等待
    pending = true
    timerFunc()
  }

  // 如果支持Promise,对外返回Promise类型
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

总结概述

  • 整个next-tick.js文件对外只暴露一个nextTick()方法,方法的参数就是业务代码中的回调函数;
  • 外部每调用一次nextTick(),传入的回调函数都会放入一个callbacks数组中,然后执行timerFun()异步方法;
  • timerFun()对环境进行判断,是否支持微任务对象Promise/MutationObsever,如果不支持则通过setImmediate/setTimeout宏任务包装成异步方法,回调处理flushCallbacks()
  • flushCallback()如方法名所写,做的就是按顺序遍历执行每个回调函数,并清空数组;

参考

未解决的疑惑

  • 既然tick是指宏任务之间的间隔,nextTick为什么不直接放到下一个宏任务,而是优先放入微任务队列?
  • queueMicrotask()也是往微任务队列中追加执行函数,nextTick为什么要用Promise来实现?