详解从Vue.js源码看异步更新DOM策略及nextTick

写在前面

因为对Vue.js很感兴趣,而且平时工作的技术栈也是Vue.js,这几个月花了些时间研究学习了一下Vue.js源码,并做了总结与输出。

文章的原地址:https://github.com/answershuto/learnVue

在学习过程中,为Vue加上了中文的注释https://github.com/answershuto/learnVue/tree/master/vue-src,希望可以对其他想学习Vue源码的小伙伴有所帮助。

可能会有理解存在偏差的地方,欢迎提issue指出,共同学习,共同进步。

操作DOM

在使用vue.js的时候,有时候因为一些特定的业务场景,不得不去操作DOM,比如这样:

<template>
 <div>
 <div ref="test">{{test}}</div>
 <button @click="handleClick">tet</button>
 </div>
</template>
export default {
 data () {
  return {
   test: 'begin'
  };
 },
 methods () {
  handleClick () {
   this.test = 'end';
   console.log(this.$refs.test.innerText);//打印“begin”
  }
 }
}

打印的结果是begin,为什么我们明明已经将test设置成了“end”,获取真实DOM节点的innerText却没有得到我们预期中的“end”,而是得到之前的值“begin”呢?

Watcher队列

带着疑问,我们找到了Vue.js源码的Watch实现。当某个响应式数据发生变化的时候,它的setter函数会通知闭包中的Dep,Dep则会调用它管理的所有Watch对象。触发Watch对象的update实现。我们来看一下update的实现。

update () {
 /* istanbul ignore else */
 if (this.lazy) {
  this.dirty = true
 } else if (this.sync) {
  /*同步则执行run直接渲染视图*/
  this.run()
 } else {
  /*异步推送到观察者队列中,下一个tick时调用。*/
  queueWatcher(this)
 }
}

我们发现Vue.js默认是使用异步执行DOM更新

当异步执行update的时候,会调用queueWatcher函数。

 /*将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送*/
export function queueWatcher (watcher: Watcher) {
 /*获取watcher的id*/
 const id = watcher.id
 /*检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验*/
 if (has[id] == null) {
 has[id] = true
 if (!flushing) {
  /*如果没有flush掉,直接push到队列中即可*/
  queue.push(watcher)
 } else {
  // if already flushing, splice the watcher based on its id
  // if already past its id, it will be run next immediately.
  let i = queue.length - 1
  while (i >= 0 && queue[i].id > watcher.id) {
  i--
  }
  queue.splice(Math.max(i, index) + 1, 0, watcher)
 }
 // queue the flush
 if (!waiting) {
  waiting = true
  nextTick(flushSchedulerQueue)
 }
 }
}

查看queueWatcher的源码我们发现,Watch对象并不是立即更新视图,而是被push进了一个队列queue,此时状态处于waiting的状态,这时候会继续会有Watch对象被push进这个队列queue,等待下一个tick时,这些Watch对象才会被遍历取出,更新视图。同时,id重复的Watcher不会被多次加入到queue中去,因为在最终渲染时,我们只需要关心数据的最终结果。

那么,什么是下一个tick?

nextTick

vue.js提供了一个nextTick函数,其实也就是上面调用的nextTick。

nextTick的实现比较简单,执行的目的是在microtask或者task中推入一个funtion,在当前栈执行完毕(也行还会有一些排在前面的需要执行的任务)以后执行nextTick传入的funtion,看一下源码:

/**
 * Defer a task to execute it asynchronously.
 */
 /*
 延迟一个任务使其异步执行,在下一个tick时执行,一个立即执行函数,返回一个function
 这个函数的作用是在task或者microtask中推入一个timerFunc,在当前调用栈执行完以后以此执行直到执行到timerFunc
 目的是延迟到当前调用栈执行完以后执行
*/
export const nextTick = (function () {
 /*存放异步执行的回调*/
 const callbacks = []
 /*一个标记位,如果已经有timerFunc被推送到任务队列中去则不需要重复推送*/
 let pending = false
 /*一个函数指针,指向函数将被推送到任务队列中,等到主线程任务执行完时,任务队列中的timerFunc被调用*/
 let timerFunc

 /*下一个tick时的回调*/
 function nextTickHandler () {
 /*一个标记位,标记等待状态(即函数已经被推入任务队列或者主线程,已经在等待当前栈执行完毕去执行),这样就不需要在push多个回调到callbacks时将timerFunc多次推入任务队列或者主线程*/
 pending = false
 /*执行所有callback*/
 const copies = callbacks.slice(0)
 callbacks.length = 0
 for (let i = 0; i < copies.length; i++) {
  copies[i]()
 }
 }

 // the nextTick behavior leverages the microtask queue, which can be accessed
 // via either native Promise.then or MutationObserver.
 // MutationObserver has wider support, however it is seriously bugged in
 // UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
 // completely stops working after triggering a few times... so, if native
 // Promise is available, we will use it:
 /* istanbul ignore if */

 /*
 这里解释一下,一共有Promise、MutationObserver以及setTimeout三种尝试得到timerFunc的方法
 优先使用Promise,在Promise不存在的情况下使用MutationObserver,这两个方法都会在microtask中执行,会比setTimeout更早执行,所以优先使用。
 如果上述两种方法都不支持的环境则会使用setTimeout,在task尾部推入这个函数,等待调用执行。
 */
 if (typeof Promise !== 'undefined' && isNative(Promise)) {
 /*使用Promise*/
 var p = Promise.resolve()
 var logError = err => { console.error(err) }
 timerFunc = () => {
  p.then(nextTickHandler).catch(logError)
  // 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 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
 /*新建一个textNode的DOM对象,用MutationObserver绑定该DOM并指定回调函数,在DOM变化的时候则会触发回调,该回调会进入主线程(比任务队列优先执行),即textNode.data = String(counter)时便会触发回调*/
 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)
 }
 } else {
 // fallback to setTimeout
 /* istanbul ignore next */
 /*使用setTimeout将回调推入任务队列尾部*/
 timerFunc = () => {
  setTimeout(nextTickHandler, 0)
 }
 }

 /*
 推送到队列中下一个tick时执行
 cb 回调函数
 ctx 上下文
 */
 return function queueNextTick (cb?: Function, ctx?: Object) {
 let _resolve
 /*cb存到callbacks中*/
 callbacks.push(() => {
  if (cb) {
  try {
   cb.call(ctx)
  } catch (e) {
   handleError(e, ctx, 'nextTick')
  }
  } else if (_resolve) {
  _resolve(ctx)
  }
 })
 if (!pending) {
  pending = true
  timerFunc()
 }
 if (!cb && typeof Promise !== 'undefined') {
  return new Promise((resolve, reject) => {
  _resolve = resolve
  })
 }
 }
})()

它是一个立即执行函数,返回一个queueNextTick接口。

传入的cb会被push进callbacks中存放起来,然后执行timerFunc(pending是一个状态标记,保证timerFunc在下一个tick之前只执行一次)。

timerFunc是什么?

看了源码发现timerFunc会检测当前环境而不同实现,其实就是按照Promise,MutationObserver,setTimeout优先级,哪个存在使用哪个,最不济的环境下使用setTimeout。

这里解释一下,一共有Promise、MutationObserver以及setTimeout三种尝试得到timerFunc的方法。
优先使用Promise,在Promise不存在的情况下使用MutationObserver,这两个方法的回调函数都会在microtask中执行,它们会比setTimeout更早执行,所以优先使用。
如果上述两种方法都不支持的环境则会使用setTimeout,在task尾部推入这个函数,等待调用执行。

为什么要优先使用microtask?我在顾轶灵在知乎的回答中学习到:

JS 的 event loop 执行时会区分 task 和 microtask,引擎在每个 task 执行完毕,从队列中取下一个 task 来执行之前,会先执行完所有 microtask 队列中的 microtask。

setTimeout 回调会被分配到一个新的 task 中执行,而 Promise 的 resolver、MutationObserver 的回调都会被安排到一个新的 microtask 中执行,会比 setTimeout 产生的 task 先执行。

要创建一个新的 microtask,优先使用 Promise,如果浏览器不支持,再尝试 MutationObserver。

实在不行,只能用 setTimeout 创建 task 了。

为啥要用 microtask?

根据 HTML Standard,在每个 task 运行完以后,UI 都会重渲染,那么在 microtask 中就完成数据更新,当前 task 结束就可以得到最新的 UI 了。

反之如果新建一个 task 来做数据更新,那么渲染就会进行两次。

参考顾轶灵知乎的回答

首先是Promise,(Promise.resolve()).then()可以在microtask中加入它的回调,

MutationObserver新建一个textNode的DOM对象,用MutationObserver绑定该DOM并指定回调函数,在DOM变化的时候则会触发回调,该回调会进入microtask,即textNode.data = String(counter)时便会加入该回调。

setTimeout是最后的一种备选方案,它会将回调函数加入task中,等到执行。

综上,nextTick的目的就是产生一个回调函数加入task或者microtask中,当前栈执行完以后(可能中间还有别的排在前面的函数)调用该回调函数,起到了异步触发(即下一个tick时触发)的目的。

flushSchedulerQueue

/*Github:https://github.com/answershuto*/
/**
 * Flush both queues and run the watchers.
 */
 /*nextTick的回调函数,在下一个tick时flush掉两个队列同时运行watchers*/
function flushSchedulerQueue () {
 flushing = true
 let watcher, id

 // Sort queue before flush.
 // This ensures that:
 // 1. Components are updated from parent to child. (because parent is always
 // created before the child)
 // 2. A component's user watchers are run before its render watcher (because
 // user watchers are created before the render watcher)
 // 3. If a component is destroyed during a parent component's watcher run,
 // its watchers can be skipped.
 /*
 给queue排序,这样做可以保证:
 1.组件更新的顺序是从父组件到子组件的顺序,因为父组件总是比子组件先创建。
 2.一个组件的user watchers比render watcher先运行,因为user watchers往往比render watcher更早创建
 3.如果一个组件在父组件watcher运行期间被销毁,它的watcher执行将被跳过。
 */
 queue.sort((a, b) => a.id - b.id)

 // do not cache length because more watchers might be pushed
 // as we run existing watchers
 /*这里不用index = queue.length;index > 0; index--的方式写是因为不要将length进行缓存,因为在执行处理现有watcher对象期间,更多的watcher对象可能会被push进queue*/
 for (index = 0; index < queue.length; index++) {
 watcher = queue[index]
 id = watcher.id
 /*将has的标记删除*/
 has[id] = null
 /*执行watcher*/
 watcher.run()
 // in dev build, check and stop circular updates.
 /*
  在测试环境中,检测watch是否在死循环中
  比如这样一种情况
  watch: {
  test () {
   this.test++;
  }
  }
  持续执行了一百次watch代表可能存在死循环
 */
 if (process.env.NODE_ENV !== 'production' && has[id] != null) {
  circular[id] = (circular[id] || 0) + 1
  if (circular[id] > MAX_UPDATE_COUNT) {
  warn(
   'You may have an infinite update loop ' + (
   watcher.user
    ? `in watcher with expression "${watcher.expression}"`
    : `in a component render function.`
   ),
   watcher.vm
  )
  break
  }
 }
 }

 // keep copies of post queues before resetting state
 /**/
 /*得到队列的拷贝*/
 const activatedQueue = activatedChildren.slice()
 const updatedQueue = queue.slice()

 /*重置调度者的状态*/
 resetSchedulerState()

 // call component updated and activated hooks
 /*使子组件状态都改编成active同时调用activated钩子*/
 callActivatedHooks(activatedQueue)
 /*调用updated钩子*/
 callUpdateHooks(updatedQueue)

 // devtool hook
 /* istanbul ignore if */
 if (devtools && config.devtools) {
 devtools.emit('flush')
 }
}

flushSchedulerQueue是下一个tick时的回调函数,主要目的是执行Watcher的run函数,用来更新视图

为什么要异步更新视图

来看一下下面这一段代码

<template>
 <div>
 <div>{{test}}</div>
 </div>
</template>
export default {
 data () {
  return {
   test: 0
  };
 },
 created () {
  for(let i = 0; i < 1000; i++) {
  this.test++;
  }
 }
}

现在有这样的一种情况,created的时候test的值会被++循环执行1000次。

每次++时,都会根据响应式触发setter->Dep->Watcher->update->patch。

如果这时候没有异步更新视图,那么每次++都会直接操作DOM更新视图,这是非常消耗性能的。

所以Vue.js实现了一个queue队列,在下一个tick的时候会统一执行queue中Watcher的run。同时,拥有相同id的Watcher不会被重复加入到该queue中去,所以不会执行1000次Watcher的run。最终更新视图只会直接将test对应的DOM的0变成1000。
保证更新视图操作DOM的动作是在当前栈执行完以后下一个tick的时候调用,大大优化了性能。

访问真实DOM节点更新后的数据

所以我们需要在修改data中的数据后访问真实的DOM节点更新后的数据,只需要这样,我们把文章第一个例子进行修改。

<template>
 <div>
 <div ref="test">{{test}}</div>
 <button @click="handleClick">tet</button>
 </div>
</template>
export default {
 data () {
  return {
   test: 'begin'
  };
 },
 methods () {
  handleClick () {
   this.test = 'end';
   this.$nextTick(() => {
    console.log(this.$refs.test.innerText);//打印"end"
   });
   console.log(this.$refs.test.innerText);//打印“begin”
  }
 }
}

使用Vue.js的global API的$nextTick方法,即可在回调中获取已经更新好的DOM实例了。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • vue.js全局API之nextTick全面解析

    官方介绍:在下次 DOM 更新循环结束之后执行延迟回调.在修改数据之后立即使用这个方法,获取更新后的 DOM. 就是DOM更新完成之后执行的方法,并且超级方便的事情是回调函数中的this会绑定到最新的vue实例上. 代码示例如下: <p ref="mgs">{{mgs}}</p> <button @click="updateDom">点击更新DOM</button> data:function () { return

  • vue之nextTick全面解析

    简介 vue是非常流行的框架,他结合了angular和react的优点,从而形成了一个轻量级的易上手的具有双向数据绑定特性的mvvm框架.本人比较喜欢用之.在我们用vue时,我们经常用到一个方法是this.$nextTick,相信你也用过.我常用的场景是在进行获取数据后,需要对新视图进行下一步操作或者其他操作时,发现获取不到dom.因为赋值操作只完成了数据模型的改变并没有完成视图更新.在这个时候我们需要用到本章介绍的函数. 为什么要用nextTick 请看如下一段代码 new Vue({ el:

  • 关于Vue.nextTick()的正确使用方法浅析

    本文主要给大家介绍了关于Vue.nextTick()的正确使用,分享出来供大家参考学习,下面话不多说了,来一起看看详细的介绍吧. 什么是Vue.nextTick() 官方文档解释如下: 在下次 DOM 更新循环结束之后执行延迟回调.在修改数据之后立即使用这个方法,获取更新后的 DOM. 我理解的官方文档的这句话的侧重点在最后那半句获取更新后的DOM,获取更新后的DOM言外之意就是什么操作需要用到了更新后的DOM而不能使用之前的DOM或者使用更新前的DOM或出问题,所以就衍生出了这个获取更新后的D

  • 浅谈Vue.nextTick 的实现方法

    这是一篇继event loop和MicroTask 后的vue.nextTick API实现的源码解析. 预热,写一个sleep函数 function sleep (ms) { return new Promise(resolve => setTimeout(resolve, ms) } async function oneTick (ms) { console.log('start') await sleep(ms) console.log('end') } oneTick(3000) 解释下

  • Vue中之nextTick函数源码分析详解

    1. 什么是Vue.nextTick()? 官方文档解释如下: 在下次DOM更新循环结束之后执行的延迟回调.在修改数据之后立即使用这个方法,获取更新后的DOM. 2. 为什么要使用nextTick? <!DOCTYPE html> <html> <head> <title>演示Vue</title> <script src="https://tugenhua0707.github.io/vue/vue1/vue.js"&

  • 详解从Vue.js源码看异步更新DOM策略及nextTick

    写在前面 因为对Vue.js很感兴趣,而且平时工作的技术栈也是Vue.js,这几个月花了些时间研究学习了一下Vue.js源码,并做了总结与输出. 文章的原地址:https://github.com/answershuto/learnVue. 在学习过程中,为Vue加上了中文的注释https://github.com/answershuto/learnVue/tree/master/vue-src,希望可以对其他想学习Vue源码的小伙伴有所帮助. 可能会有理解存在偏差的地方,欢迎提issue指出,

  • Vue.js源码分析之自定义指令详解

    前言 除了核心功能默认内置的指令 (v-model 和 v-show),Vue 也允许注册自定义指令. 官网介绍的比较抽象,显得很高大上,我个人对自定义指令的理解是:当自定义指令作用在一些DOM元素或组件上时,该元素在初次渲染.插入到父节点.更新.解绑时可以执行一些特定的操作(钩子函数() 自定义指令有两种注册方式,一种是全局注册,使用Vue.directive(指令名,配置参数)注册,注册之后所有的Vue实例都可以使用,另一种是局部注册,在创建Vue实例时通过directives属性创建局部指

  • 详解在Vue.js编写更好的v-for循环的6种技巧

    在VueJS中,v-for循环是每个项目都会使用的东西,它允许您在模板代码中编写for循环. 在最基本的用法中,它们的用法如下. <ul> <li v-for='product in products'> {{ product.name }} </li> </ul> 但是,在本文中,我将介绍六种方法来使你的 v-for 代码更加精确,可预测和强大. 让我们开始吧. 1.始终在v-for循环中使用key 首先,我们将讨论大多数Vue开发人员已经知道的常见最佳做

  • Mybatis-plus使用TableNameHandler分表详解(附完整示例源码)

    为什么要分表 Mysql是当前互联网系统中使用非常广泛的关系数据库,具有ACID的特性. 但是mysql的单表性能会受到表中数据量的限制,主要原因是B+树索引过大导致查询时索引无法全部加载到内存.读取磁盘的次数变多,而磁盘的每次读取对性能都有很大的影响. 这时一个简单可行的方案就是分表(当然土豪也可以堆硬件),将一张数据量庞大的表的数据,拆分到多个表中,这同时也减少了B+树索引的大小,减少磁盘读取次数,提高性能. 两种基础分表逻辑 说完了为什么要分表,下面聊聊业务开发中常见的两种基础的分表逻辑.

  • 详解go中panic源码解读

    panic源码解读 前言 本文是在go version go1.13.15 darwin/amd64上进行的 panic的作用 panic能够改变程序的控制流,调用panic后会立刻停止执行当前函数的剩余代码,并在当前Goroutine中递归执行调用方的defer: recover可以中止panic造成的程序崩溃.它是一个只能在defer中发挥作用的函数,在其他作用域中调用不会发挥作用: 举个栗子 package main import "fmt" func main() { fmt.

  • 深入理解Vue.js源码之事件机制

    写在前面 因为对Vue.js很感兴趣,而且平时工作的技术栈也是Vue.js,这几个月花了些时间研究学习了一下Vue.js源码,并做了总结与输出. 文章的原地址:https://github.com/answershuto/learnVue. 在学习过程中,为Vue加上了中文的注释https://github.com/answershuto/learnVue/tree/master/vue-src,希望可以对其他想学习Vue源码的小伙伴有所帮助. 可能会有理解存在偏差的地方,欢迎提issue指出

  • 详解SpringBoot自动配置源码

    一.引导加载自动配置类 @SpringBootApplication注解相当于@SpringBootConfiguration.@EnableAutoConfiguration.@ComponentScan这三个注解的整合 @SpringBootConfiguration 这个注解也使用了@Configuration标注,代表当前是一个配置类 @ComponentScan 包扫描,指定扫描哪些注解 @EnableAutoConfiguration 这个注解也是一个合成注解 @AutoConfig

  • Java从源码看异步任务计算FutureTask

    目录 了解一下什么是FutureTask? FutureTask 是如何实现的呢? FutureTask 运行流程 FutureTask 的使用 前言: 大家是否熟悉FutureTask呢?或者说你有没有异步计算的需求呢?FutureTask就能够很好的帮助你实现异步计算,并且可以实现同步获取异步任务的计算结果.下面我们就一起从源码分析一下FutureTask. 了解一下什么是FutureTask? FutureTask 是一个可取消的异步计算. FutureTask提供了对Future的基本实

  • 详解Element 指令clickoutside源码分析

    clickoutside是Element-ui实现的一个自定义指令,顾名思义,该指令用来处理目标节点之外的点击事件,常用来处理下拉菜单等展开内容的关闭,在Element-ui的Select选择器.Dropdown下拉菜单.Popover 弹出框等组件中都用到了该指令,所以这个指令在实现一些自定义组件的时候非常有用. 要分析该源码,首先要了解一下Vue的自定义指令.自定义指令的定义方式如下: // 注册一个全局自定义指令 Vue.directive('directiveName', { bind:

  • 微信小程序 授权登录详解(附完整源码)

    一.前言 由于微信官方修改了 getUserInfo 接口,所以现在无法实现一进入微信小程序就弹出授权窗口,只能通过 button 去触发. 官方连接:https://developers.weixin.qq.com/community/develop/doc/0000a26e1aca6012e896a517556c01 二.实现思路 自己写一个微信授权登录页面让用户实现点击的功能,也就是实现了通过 button 组件去触发 getUserInof 接口.在用户进入微信小程序的时候,判断用户是否

随机推荐