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

Vue专题 - Vue.nextTick() 的一些思考 #29

Open
kangschampagne opened this issue Apr 13, 2018 · 0 comments
Open

Vue专题 - Vue.nextTick() 的一些思考 #29

kangschampagne opened this issue Apr 13, 2018 · 0 comments

Comments

@kangschampagne
Copy link
Owner

@kangschampagne kangschampagne commented Apr 13, 2018

先看 vue 官方文档 api 中的介绍:链接

Vue.nextTick( [callback, context] ):
Defer the callback to be executed after the next DOM update cycle. Use it immediately after you’ve changed some data to wait for the DOM update.

简单讲,他可以在 DOM 更新完毕之后,执行一个 callback 的回调函数。
提供这个 api 有什么作用呢? 其实最主要的只有一点,就是确保某一些特殊的场景下,我们操作的 DOM 结构,为更新之后的 DOM 。Vue.nextTick() 所做的就是在更新 DOM 完毕之后,所做的一个回调。

跟随笔者做一个案例熟悉一下:

<div id="test">
  {{ dataTest }}
</div>
new Vue({
  data: {
    dataTest: 'old'
  },
  methods: {
    changeData () {
      this.dataTest = 'new string'

      this.$nextTick( _ => {
         console.log('changed datTest:', this.getTextEltextContent(this.$el))
      })
      
      console.log('change dataTest:', this.getTextEltextContent(this.$el))
    },
    getTextEltextContent(el) {
       return el.childNodes[0].textContent
    }
  },
 mounted () {
    this.changeData()
 }
}).$mount('#test')

在线版本: jsBin
然后先想一下输出的 console 的结果:

"change dataTest:" "old"

"changed datTest:" "new string"

很明显,可以看出 nextTick 这个 api 的作用。

看完上面的解释,就有问题来了:如何知道 DOM 更新完毕?什么时候 DOM 会更新完毕,如何监听呢?

猜测:

  1. 在HTML5新增的属性中,有一个用于监听DOM 修改事件的 api。MutationObserver,能够监听节点属性,文本内容和子节点的改动。Vue使用这个 api 监听 DOM 的改变。
  2. 在JS的运行机制中,每次清空执行栈之后会进行UI渲染。如果按照这个原理,想要获取渲染完更新的 DOM,必须是在渲染之后的下一个执行栈中去获取。Vue运用js运行机制监听 DOM 的改变。

带着这两个猜测,跟随笔者进入 Vue 关于 nextTick 源码的部分,关于 nextTick 的提交的历史,可以看到有一个变化:

In pre 2.4, we used to use microtasks (Promise/MutationObserver)
代码

在版本2.4之前确实是有使用 MutationObserver,那后来为什么删掉了呢?删掉之后如何监听 DOM 修改,或者说,根本就不是使用 MO 去监听改变呢?
因此跟随笔者查看 2.4 版本之前 v2.3.4 关于 nextTick 的代码:

// https://github.com/vuejs/vue/blob/1f9416d514d80a99eb45184459fdf390405967ec/src/core/util/env.js#L107
if (typeof MutationObserver !== 'undefined' && (
    isNative(MutationObserver) ||
    // PhantomJS and iOS 7.x
    MutationObserver.toString() === '[object MutationObserverConstructor]'
  )) {
    // use MutationObserver where native Promise is not available,
    // e.g. PhantomJS IE11, iOS7, Android 4.4
    var counter = 1
    var observer = new MutationObserver(nextTickHandler)
    var textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
      characterData: true
    })
    timerFunc = () => {
      counter = (counter + 1) % 2
      textNode.data = String(counter)
    }
  }

基本的步骤就是创建一个 MO 和一个文本节点,并通过 MO 监听改文本节点的更改。
那么问题又来了,为什么可以通过监听一个创建的文本节点更新完毕进而确定其原始的节点 DOM 已经更新完毕了呢?这样看来,推断 1 很可能是错误的。

接着验证第二个推测,使用列队监听 DOM 的改变。
可以从Vue的更新机制看出,Vue 默认是使用异步执行DOM更新。
为什么需要异步执行DOM更新呢,举个简单的例子:

<template>
  <div>
    <div>{{number}}</div>
    <div @click="handleClick">click</div>
  </div>
</template>
export default {
    data () {
        return {
            number: 0
        };
    },
    methods: {
        handleClick () {
            for(let i = 0; i < 1000; i++) {
                this.number++;
            }
        }
    }
}

看完这个例子,Vue 作为一个对 DOM 操作进行优化的视图库,肯定是不会操作1000次 DOM 的。所以Vue在这个方面做了异步更新的优化。那么 Vue 是如何优化的呢?这个时候就应该来熟悉一下 Vue 的异步更新机制。具体可以查看笔者的这篇文章Vue专题 - 异步更新机制

根据之前的猜测,渲染之后的下一个执行栈中去获取更新完的 DOM。而上面这个例子中,执行 1000次的for循环属于同一个 task,在这个 task 执行之后再进行一次 DOM 更新。这正好符合之前的猜测,即只要让 Vue.nextTick() 里面的代码在这个 UI 渲染之后执行,这个时候就能拿到更新之后的 DOM 了。

为了明白 Vue 如何控制这个列队,就应该熟悉一下 js 的运行机制方面的知识。具体可以查看笔者这篇文章Js专题 - 事件循环Event Loop

image
这里放上一张概念图和主要过程。

  1. 执行一个 宏任务(栈中没有就从 事件列队 中获取)
  2. 执行过程中如果有 微任务,就将它添加到 微任务的 任务列队中
  3. 宏任务 执行完毕之后,立即执行当前 微任务列队中的所有任务(依次执行)
  4. 当前 微任务 执行完毕之后,开始检查渲染,然后 GUI线程 接管渲染
  5. 渲染完毕之后,js 线程继续接管,开始下一个 宏任务,循环

在 EventLoop 中,有一个微任务(microtask),可以理解为当前 task 执行结束之后立即执行的任务。它主要有下面几个概念:

  1. 在当前task任务后,下一个task之前,在渲染之前
  2. 所以它的响应速度相比 setTimeout(setTimeout是task)会更快,因为无需等渲染
  3. 在某一个 macrotask 执行完后,就会将在它执行期间产生的所有 microtask 都执行完毕(在渲染前)

有了这个概念,大概的做法我们就可以明白了。当我们自己调用 nextTick 的时候,它就在更新 DOM 的那个 micktask 后追加一个 callback,就能确保 callback 函数能在 DOM 更新完之后执行。

现在我们已经知道,Vue 就是利用 microtask 的特性,实现 Vue.nextTick() 可以拿到更新完的 DOM。

那么实现 microtask 可以使用什么函数呢?常见的 microtask 一般包括下面几个:

  1. Promise
  2. MutationObserver
  3. Object.observe
  4. process.nextTick

所以在 2.4 版本之前使用的 MutationObserver,是使用其 microtask 的特性。那么为什么后来删除了该方法呢,可以从注释中知道:

// MutationObserver has wider support, however it is seriously bugged in
// UIWebView in iOS >= 9.3.3 when triggered in touch event handlers.

其在 iOS 中存在 bug。

在 v2.4 之后的版本中,我们可以发现,MutationObserver 已经被 MessageChannel 代替。下面是部分截取的代码。

// https://github.com/vuejs/vue/blob/v2.5.17-beta.0/src/core/util/next-tick.js
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
}

可以发现这个地方做了许多降级的处理,其优先级为:

  1. Promise
  2. setImmediate
  3. MessageChannel
  4. setTimeout
    分别解释一下每个选择的原因:
    使用 Promise 是因为 microtack 最佳的选择方案,但是 ES6 支持,因此就不得不降级为 macrotask。
    使用 setImmediate 是 macrotask 最佳方案,但是只有 IE 和 nodejs 支持。
    使用 MessageChannel 是因为其 portN.onmessage 回调也是 microtask,支持 IE 10+。
    使用 setTimeout 是最后的方法,有一定的执行延迟。

总结

  1. Vue 用异步列队方式控制 DOM更新和 nextTick 回调
  2. microtask 其高优先级的特性,能够确保其回调能够在当前事件循环中最早执行
  3. Vue 为 nextTick 做了从 microTask 到 macroTask 的降级处理

参考:vuejs/vue@6e41679
https://mp.weixin.qq.com/s/ZbF_4o8XrJb49_MU6y3iDQ?scene=25#wechat_redirect

@kangschampagne kangschampagne added WRITING and removed WRITING labels Apr 13, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked pull requests

Successfully merging a pull request may close this issue.

None yet
1 participant
You can’t perform that action at this time.