Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

nextTick原理分析 #25

Open
daodaolee opened this issue Mar 17, 2021 · 0 comments
Open

nextTick原理分析 #25

daodaolee opened this issue Mar 17, 2021 · 0 comments

Comments

@daodaolee
Copy link
Owner

daodaolee commented Mar 17, 2021

上篇派发更新的最后提到了 nextTick,在Vue中,nextTick 也是一个核心实现,本篇来详细说一下 nextTick 的实现原理。

JS运行机制

JS执行是单线程的,它是基于事件循环,详细说明可以查看 一文讲解浏览器运行渲染机制、JS任务队列及事件循环,这里再简单过一下。事件循环大致分为以下几步:

  1. 所有同步任务都在主线程上执行,形成一个执行栈。
  2. 在主线程之外,存在一个 "任务队列"(task queue),只要异步任务有了运行结果,就在任务队列中放一个事件。
  3. 当执行栈中所有同步任务执行完毕,主线程就会读取任务队列,并把需要异步执行的逻辑放进来,此时异步队列的任务就会结束等待状态,开始执行。
  4. 主线程重复执行上面三步。

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

关于宏任务和微任务,也可以在 一文讲解浏览器运行渲染机制、JS任务队列及事件循环 里找到相应的解释,下面来模拟一下执行顺序:

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

Vue的实现

在Vue里实现的异步操作就是 nextTick。在 Vue 源码 2.5+ 后,nextTick 的实现单独有一个 JS 文件来维护它,它的源码并不多,总共也就 100 多行。接下来我们来看一下它的实现,在 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
    })
  }
}

简单提一下,除了上面的 nextTick 的实现之外,还有两个地方用到了 nextTick

// 1. src/instance/render.js
Vue.prototype.$nextTick = function (fn: Function) {
  return nextTick(fn, this)
}

// 2. src/global-api/index.js
Vue.nextTick = nextTick

下面来分析一下 nextTick 的具体实现:

首先在全局定义了一个数组 callbacks,一个状态 pending,以及一个函数 flushCallbacks。接着后面定义了 microTimerFuncmacroTimerFunc,分别对应的是 micro task 的函数和 macro task 的函数,它们两个其实就是针对浏览器的支持程度,做不同的处理。

对于 macro task 的实现,优先检测是否支持原生 setImmediate,这是一个高版本 IE 和 Edge 才支持的特性,不支持的话再去检测是否支持原生的 MessageChannel,如果也不支持的话就会降级为 setTimeout 0;而对于 micro task 的实现,则检测浏览器是否原生支持 Promise,不支持的话直接指向 macro task 的实现。

接着它对外暴露2个函数:withMacroTasknextTickwithMacroTask 其实就是做了一层封装,确保函数在执行过程中如果对数据进行了修改,触发变化执行 nextTick 的时候强制走一个 marcoTimeFunc,换句话说也就是,强制在DOM事件的回调函数期间,如果修改了数据,那这些数据更改推入的队列就会被当做 macroTasknextTick 后执行。

nextTick 函数,上篇派发更新的最后执行 nextTick(flushSchedulerQueue) 的时候用到了它,它不仅可以传入一个回调函数,还可以传入一个Promise,在这里使用 try/catch 的方式执行是因为JS是单线程的,如果不使用 try/catch 并且执行期间有一个报错了,整个逻辑就会崩掉,后面的逻辑就不会执行了。在执行 nextTick 的时候传入的是匿名函数,通过 push 的方式,把匿名函数全部压到 callbacks 中,接着判断 pending,目的就是确保这块的逻辑只执行一次。然后根据 useMaroTask 来判断走 macroTimerFunc 还是 microTimerFunc,注意,无论执行那个,都会在下一个tick的时候才执行 flushCallbacks

也就是说在当前 tick 内,无论进行多少次 nextTick,都会把 cb 收集起来,放到 callbacks 数组中,然后在下一个 tick 的时候遍历并执行这些匿名函数,整个就是一个异步过程。

除了传入匿名函数的方式之外,也可以不传入匿名函数,通过 nextTick.then(() => {}) 的方式调用:

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

_resolve 执行的时候,就会跳到 then 里面了。

所以:

if (cb) {
  try {
    cb.call(ctx)
  } catch (e) {
    handleError(e, ctx, 'nextTick')
  }
} else if (_resolve) {
  _resolve(ctx)
}

当检测不到 cb 的时候,就判断是不是传入了一个 Promise。
最后注意一点,当 flushCallbacks 执行的时候, cb 是同步执行的,promise 是异步执行。

有趣的问题

下面执行结果是什么?

<template>
  <div>
    <p ref="msg">{{ msg }}</p>
    <button @click="change">change</button>
  </div>
</template>
<script>
export default {
  data() {
    return {
      msg: "123"
    };
  },
  methods: {
    change() {
      this.$nextTick(() => {
        console.log("nextTick:", this.$refs.msg.innerText);
      });
      this.msg = "456";
      console.log("sync:", this.$refs.msg.innerText);
      this.$nextTick().then(() => {
        console.log("promise nextTick:", this.$refs.msg.innerText);
      });
    }
  }
};
</script>

输出:

sync: 123
nextTick: 123
promise nextTick: 456

过程大概是这样的:首先打印的是 sync 的值,接着打印 nextTick 的,最后打印 promise nextTick 的值。第一步调用 setter 的时候会调用内部的 nextTick 函数,第二步手动调用 nextTick,第三步也是手动调用,那上面分析过每一次进行 nextTick 的时候,都是在 callbacks 里进行 push 操作,这样也就意味着先 push 的先执行,遍历也是从前往后的,所以先添加的就会先执行,而对于上面的例子来说,先添加的是一个 nextTick 匿名函数,然后再添加修改msg为 456 时的watcher,也就是 flushSchedulerQueue,也就是说整体在执行 flushCallbacks 的时候,会先执行 nextTick 的匿名函数,然后在执行 flushSchedulerQueue 的时候才会重新渲染,所以重新渲染是在匿名函数之后,所以匿名函数打印的是原来的值,而第三个 promise nextTick 就是打印的渲染之后的了,

如果说打印的不是 this.$refs.msg.innerText,而是 this.msg,那么所有的就都是 456 了,因为 this.msg 修改了值,会立刻发生那个变化,而视图的更新(DOM的变化)是在下一个 tick 才会进行的,所以打印的就都一样了。

@daodaolee daodaolee changed the title nextTick原理 nextTick原理分析 Mar 21, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant