$nextTick 的应用及原理

115 阅读1分钟

一、nextTick 小测试

<template>
  <div id="app">
    <p ref="name">{{ name }}</p>
    <button @click="handleClick">修改name</button>
  </div>
</template>

<script>
  export default {
  name: 'App',
  data () {
    return {
      name: '11111111'
    }
  },
  mounted() {
    console.log('mounted', this.$refs.name.innerText)
  },
  methods: {
    handleClick () {
      this.$nextTick(() => console.log('nextTick1', this.$refs.name.innerText))
      this.name = '22222222'
      console.log('sync log', this.$refs.name.innerText)
      this.$nextTick(() => console.log('nextTick2', this.$refs.name.innerText))
      this.$nextTick().then(()=>{
        console.log('nextTick3', this.$refs.name.innerText')
      })
    }
  }
}
</script>

请问上述代码中,当点击按钮“修改 name”时,'nextTick1','sync log','nextTick2'对应的 this.$refs.name.innerText 分别会输出什么?注意,这里打印的是 DOM 的 innerText ~

二、nextTick 源码实现

源码位于 core/util/next-tick 中。可以将其分为 4 个部分来看

1. 全局变量

callbacks 队列、pending 状态

const callbacks = [] // 存放cb的队列
let pending = false // 是否马上遍历队列,执行cb的标志

2. flushCallbacks

遍历 callbacks 执行每个 cb

function flushCallbacks () {
  pending = false // 注意这里,一旦执行,pending马上被重置为false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]() // 执行每个cb
  }
}

3. nextTick 的异步实现

根据执行环境的支持程度采用不同的异步实现策略

let timerFunc // nextTick异步实现fn

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  // Promise方案
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks) // 将flushCallbacks包装进Promise.then中
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // MutationObserver方案
  let counter = 1
  const observer = new MutationObserver(flushCallbacks) // 将flushCallbacks作为观测变化的cb
  const textNode = document.createTextNode(String(counter)) // 创建文本节点
  // 观测文本节点变化
  observer.observe(textNode, {
    characterData: true
  })
  // timerFunc改变文本节点的data,以触发观测的回调flushCallbacks
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // setImmediate方案
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // 最终降级方案setTimeout
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

这里用个真实案例加深对 MutationObserver 的理解。毕竟比起其他三种异步方案,这个应该是大家最陌生的

const observer = new MutationObserver(() => console.log('观测到文本节点变化'))
const textNode = document.createTextNode(String(1))
observer.observe(textNode, {
  characterData: true
})

console.log('script start')
setTimeout(() => console.log('timeout1'))
textNode.data = String(2) // 这里对文本节点进行值的修改
console.log('script end')

4. nextTick 方法实现

cb、Promise 方式

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // 往全局的callbacks队列中添加cb
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      // 这里是支持Promise的写法
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    // 执行timerFunc,在下一个Tick中执行callbacks中的所有cb
    timerFunc()
  }
  // 对Promise的实现,这也是我们使用时可以写成nextTick.then的原因
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

理解 pending 有什么用,如何运作?

案例 1,同一轮 Tick 中执行 2 次$nextTicktimerFunc只会被执行一次

三、Vue 组件的异步更新

Vue 的异步更新 DOM 其实也是使用 nextTick 来实现的,跟我们平时使用的$nextTick 其实是同一个

回顾 当我们改变一个属性值会发生什么

this.msg='new val' --> 新值存在变化? --> 是否是引用类型 --> dep.notify() --> 遍历 subs --> 调用 watcher.update() --> queueWatcher() --> watcher 推到 queue 队列中 --> nexttick 调用 flushSchedulerQueue -->排序 watcher 让父在子前-->调用 watcher.run --> watcher.get -->pushTarget() -->调用 vm.update(vm.render())-->执行 render -->patch 到 dom 上 -->popTarget() -->结束

1. queueWatcher 做了什么?

// 用来存放Wathcer的队列。注意,不要跟nextTick的callbacks搞混了,都是队列,但用处不同
const queue: Array<Watcher> = []  //将需要批量更新的watcher存到一个队列中,稍后让watcher执行

function queueWatcher (watcher: Watcher) {
  const id = watcher.id // 拿到Wathcer的id,这个id每个watcher都有且全局唯一
  if (has[id] == null) {
    // 避免添加重复wathcer,这也是异步渲染的优化做法
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    }
    if (!waiting) {
      waiting = true
      // 这里把flushSchedulerQueue推进nextTick的callbacks队列中
      nextTick(flushSchedulerQueue)
    }
  }
}

2. flushSchedulerQueue 做了什么?

function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id
  // 排序保证先父后子执行更新,保证userWatcher在渲染Watcher前
  queue.sort((a, b) => a.id - b.id)
  // 遍历所有的需要派发更新的Watcher执行更新
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    id = watcher.id
    has[id] = null
    // 真正执行派发更新,render -> update -> patch
    watcher.run()
  }
}

相信经过上文对 nextTick 源码的剖析,我们已经揭开它神秘的面纱了。

    1. mounted 11111111
    1. sync log 11111111
    1. nextTick1 11111111
    1. nextTick2 22222222
    1. nextTick3 22222222

注意:其虽然是放在$nextTick 的回调中,在下一个 tick 执行,但是他的位置是在 this.name = '22222222'的前。也就是说,他的 cb 会比 App 组件的派发更新(flushSchedulerQueue)更先进入队列,当 nextTick1 打印时,App 组件还未派发更新,所以拿到的还是旧的 DOM 值。