blog icon indicating copy to clipboard operation
blog copied to clipboard

通过 nextTick的实现 再次理解事件循环

Open wuweijia opened this issue 4 years ago • 0 comments

首先来看一下 vue 提供的 nextTick api

Vue.nextTick( [callback, context] )
	•	参数:
	◦	{Function} [callback]
	◦	{Object} [context]
	•	用法:
在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。
// 修改数据
	•	vm.msg = 'Hello'
	•	// DOM 还没有更新
	•	Vue.nextTick(function () {
	•	  // DOM 更新了
	•	})
	•	
	•	// 作为一个 Promise 使用 (2.1.0 起新增,详见接下来的提示)
	•	Vue.nextTick()
	•	  .then(function () {
	•	    // DOM 更新了
	•	  })



2.1.0 起新增:如果没有提供回调且在支持 Promise 的环境中,则返回一个 Promise。请注意 Vue 不自带 Promise 的 polyfill,所以如果你的目标浏览器不原生支持 Promise (IE:你们都看我干嘛),你得自己提供 polyfill。

带问题阅读

  1. 为什么需要nextTick
  2. 为什么Vue使用异步更新队列

正文

nextTick源码分析

import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'

export let isUsingMicroTask = false

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 timerFunc
/**
 * 这里主要是检测到底支持哪种微任务的实现
 * 最后保底用 setTimeout 可以把微任务理解为没有异步执行的宏任务
 */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  let counter = 1
  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)
  }
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

/**
 * 这里是函数的定义,该函数有两个参数,cb回调函数,ctx,当前上下文,就是vm
 */
export function nextTick(cb?: Function, ctx?: Object) {
  let _resolve

  /**
   * callbacks 在最上面定义  const callbacks = []
   * 这里使用 callbacks 而不是直接在 nextTick 中执行回调函数的原因是保证在同一个 tick 内多次执行 nextTick,不会开启多个异
   * 任务,而把这些异步任务都压成一个同步任务,在下一个 tick 执行完毕。
   * 当nexttick被调用的时候向回调队列里面添加当前回调  并通过call将this指向ctx
   */
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })

  //
  if (!pending) {
    pending = true
    timerFunc() // 实际执行的函数,在19行
  }
  // 这是当 nextTick 不传 cb 参数的时候,提供一个 Promise 化的调用
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

宏任务,微任务和事件循环

JavaScript是一个单进程的语言,同一时间不能处理多个任务,所以何时执行宏任务,何时执行微任务?我们需要有这样的一个判断逻辑存在。

这个就像去银行办业务一样,先要取号进行排号。一般上边都会印着类似:“您的号码为XX,前边还有XX人。 因为柜员同时职能处理一个来办理业务的客户,这时每一个来办理业务的人就可以认为是银行柜员的一个宏任务来存在的,当柜员处理完当前客户的问题以后,继续处理下一位,也就是下一个宏任务的开始。

而一个宏任务在执行的过程中,是可以添加一些微任务的,就像在柜台办理业务,你前边的一位老大爷可能在存款,在存款这个业务办理完以后,柜员会问老大爷还有没有其他需要办理的业务,这时老大爷想了一下:“最近P2P爆雷有点儿多,是不是要选择稳一些的理财呢”,然后告诉柜员说,要办一些理财的业务,这时候柜员肯定不能告诉老大爷说:“您再上后边取个号去,重新排队”。 所以本来快轮到你来办理业务,会因为老大爷临时添加的::任务::而往后推。

理解下面代码

// setTimeout 宏任务
// setTimeout排队优先拿到了号,但是临时有事先走了
setTimeout(() => { 
	console.log('为什么还没到我,明明是我先说 我马上就回来的')) // 产生一个’宏任务‘回调
}

/**
 * promise 微任务
 * 实例化的过程中所执行的代码都是同步进行的
 */
new Promise(resolve => { // Promise开心的一批 开始办理
  resolve() // 主线程
  console.log('办到一半发现没带身份证,叫log先办') // 主线程
}).then(_ => { // 产生了一个微任务回调
  Promise.resolve().then(_ => {
    console.log('我回来了继续办理, 给你身份证')
  }).then(_ => {  // 又产生了一个微任务回调
    Promise.resolve().then(_ => {
      console.log('设好密码了')
    })
  })
})

console.log('好。我办完了') // 资料齐全 马上完事

以上注释过程结束之后遇到一个问题 setTimeout 和 Promise 这两个兄弟 因为银行规定 有事走的人 回来的时候要重新排一个队 (任务队列) 并且规定,先办理过一些任务的人需要继续办理的人优先办理,所以 Promise 排在了 setTimeout 前面继续办理业务

先办理过一些任务的人需要继续办理的人优先办理的人员有 (微任务)

1. Promise
2. MutationObserver

其他人 (宏任务)

1. I/O
2. setTimeout
3. setInterval
4. requestAnimationFrame

回到现实的总结:

JavaScript的单线程,单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。

如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。

JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。

于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入”任务队列”(task queue)的任务,只有”任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

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

主线程从”任务队列”中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为::Event Loop::(事件循环)。

理解事件循环机制后,在来回看 nextTick 实现的原理

通过前面的了解,我们发现了 Vue的更新操作默认会将执行渲染操作的函数添加到微任务队列中,而微任务的执行时机优先于宏任务。

思考以下代码能否获取到最新的数据 ? 为什么?

<template>
  <main>
    <div> {{ message }} </div>
    <button @click="change">改变数据</button>
  </main>
</template>

<script>
export default {
  props: {
    type: {
      default: 'test',
      type: String
    }
  },
  data() {
    return {
      message: '我是小明'
    }
  },
  methods: {
    change() {
      setTimeout(() => {
        console.log(this.message);
      }, 0);
      this.message = '现在我是小李'
    }
  },
}
</script>

Ok 经过测试大家发现是能实现正常使用的,但是这是在理想情况下,通过源码我们可以看到 nextTick 最后保底实现方式是通过 setTimeout 来执行 flushCallbacks

timerFunc = () => {
   setTimeout(flushCallbacks, 0)
}

如果将这部分vue源码默认成settimeout实现就并不能得多上面demo的结果。 所以有时候代码能运行,并不意味着他是稳定的,希望大家都能了解原理,举一反三。

#技术笔记/博客

wuweijia avatar Oct 22 '20 08:10 wuweijia