vue2响应式原理(3)-- nextTick 异步更新

251 阅读1分钟

平时我们手写vm.$nextTick,Vue.nextTick都是这里的nextTick方法

src/core/observer/sheduler.ts

export function queueWatcher(watcher: Watcher) {
    // ...
    nextTick(flushSchedulerQueue)
  }
}

function flushSchedulerQueue() {
  currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id

  for (index = 0; index < queue.length; index++) {
    // ...
    watcher = queue[index]
    watcher.run()
    // ...
  }
}

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

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

src/core/util/next-tick.ts

function flushCallbacks() {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

let timerFunc

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)
  }
}

nextTick 把回调装进一个数组

src/core/util/next-tick.ts

export let isUsingMicroTask = false
const callbacks: Array<Function> = []
let pending = false

export function nextTick(cb?: (...args: any[]) => any, ctx?: object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e: any) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

首先可以看到,回调cb被放到了一个callbacks数组中,但是没有直接把cb放到数组中,而是用箭头函数包了一层,里面使用了try...catch来防止用户传递的回调cb错误导致程序崩溃,后面代码无法执行,这里就会把flushSchedulerQueue放到callbacks中;

timerFunc()是把callbacks中存储的函数在下一次tick中遍历执行,再把callbacks清空,pending为false的时候才会执行timerFunc(),同时在下一次tick循环中会把pending再次变为false,如果每调一次nextTick就调用一次timerFunc,那么flushCallbacks会被重复放进队列中去等待执行

this.$nextTick(cb)this.$nextTick().then(cb)

如果cb不存在,那么就会返回一个promise,this.$nextTick(cb)this.$nextTick().then(cb)有些差别,前者在callbacks数组中按顺序同步执行会先执行,而后者又是放在微任务中执行,所以会后执行,例如:

<body>
    <div id="app">
      <div ref="name">{{name}}</div>
      <button @click="change">change</button>
    </div>
    <script src="./vue.js"></script>
    <script>
      let vm = new Vue({
        el: '#app',
        data() {
          return {
            name: 'Cristiano'
          }
        },
        methods: {
          change() {
            this.name = 'Messi'
            this.$nextTick().then(() => {
              console.log('没有传cb', this.$refs.name.innerText)
            })
            this.$nextTick(() => {
              console.log('传了cb', this.$refs.name.innerText)
            })
          }
        }
      })
    </script>
  </body>

执行this.$nextTick().then(cb1), _resolve(ctx)被放入cllbacks数组中,然后执行this.$nextTick(cb2),cb2被放入cllbacks数组中,在下一个tick循环中,同步执行cllbacks中的函数,首先_resolve(ctx)执行,那么cb1就要放在本轮循环的微任务队列中等待执行,所以继续执行同步任务cb2,然后取出微任务cb1执行 image.png

执行顺序

而我们平时要在数据修改后使用使用$nextTick,才能保证拿到更新后的组件实例或者DOM元素,例如:

 <body>
    <div id="app">
      <div ref="name">{{name}}</div>
      <button @click="change">change</button>
    </div>
    <script src="./vue.js"></script>
    <script>
      let vm = new Vue({
        el: '#app',
        data() {
          return {
            name: 'Cristiano'
          }
        },
        methods: {
          change() {
            this.$nextTick().then(() => {
              console.log('没有传cb1', this.$refs.name.innerText)
            })
            this.$nextTick(() => {
              console.log('传了cb1', this.$refs.name.innerText)
            })
            this.name = 'Messi'
            this.$nextTick().then(() => {
              console.log('没有传cb2', this.$refs.name.innerText)
            })
            this.$nextTick(() => {
              console.log('传了cb2', this.$refs.name.innerText)
            })
          }
        }
      })
    </script>
  </body>

结合这个例子,放入callbacks数组的顺序为change方法中的书写顺序:

1._resolve(ctx)1

2.传了cb1回调函数,

3.this.name = 'Messi',修改了数据,flushSchedulerQueue被放入,flushSchedulerQueue函数里面有渲染watcher,渲染watcher里面更新页面数据,

4._resolve(ctx)2

5.传了cb2回调函数;

在下一个tick循环中同步执行callbacks中的函数:

1._resolve(ctx)1,把console.log('没有传cb1', this.$refs.name.innerText)放入微任务队列,

2.执行console.log('传了cb1', this.$refs.name.innerText)

3.执行flushSchedulerQueue,渲染watcher执行,页面数据更新,

4._resolve(ctx)2,把console.log('没有传cb2', this.$refs.name.innerText)放入微任务队列,

5.执行console.log('传了cb2', this.$refs.name.innerText),页面数据已经更新,

6.同步任务执行完了,依次执行微任务队列,console.log('没有传cb1', this.$refs.name.innerText),页面数据已经更新,

7.接着执行微任务队列中console.log('没有传cb2', this.$refs.name.innerText)

image.png