Vue异步更新机制及$nextTick原理的深入讲解

目录
  • 前言
  • Vue的异步更新
    • DOM更新是异步的
    • DOM更新还是批量的
  • 事件循环
    • 执行过程
  • 源码深入
    • 异步更新队列
    • nextTick
    • $nextTick
  • 总结
    • 一般更新DOM是同步的
    • 既然更新DOM是个同步的过程,那为什么Vue却需要借用$nextTick来处理呢?
    • 为什么优先使用微任务?
  • 总结

前言

相信很多人会好奇Vue内部的更新机制,或者平时工作中遇到的一些奇怪的问题需要使用$nextTick来解决,今天我们就来聊一聊Vue中的异步更新机制以及$nextTick原理

Vue的异步更新

可能你还没有注意到,Vue异步执行DOM更新。只要观察到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据改变。如果同一个watcher被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和DOM操作上非常重要。然后,在下一个的事件循环“tick”中,Vue刷新队列并执行实际 (已去重的) 工作。

DOM更新是异步的

当我们在更新数据后立马去获取DOM中的内容是会发现获取的依然还是旧的内容。

<template>
  <div class="next_tick">
      <div ref="title" class="title">{{name}}</div>
  </div>
</template>
<script>
export default {
    data() {
        return {
            name: '前端南玖'
        }
    },
    mounted() {
        this.name = 'front end'
        console.log('sync',this.$refs.title.innerText)
        this.$nextTick(() => {
            console.log('nextTick',this.$refs.title.innerText)
        })
    }
}
</script>

从图中我们可以发现数据改变后同步获取dom元素中的内容是老的数据,而在nextTick里面获取的是更新后的数据,这是为什么呢?

其实这里你用微任务或宏任务去获取dom元素中的内容也是更新后的数据,我们可以来试试:

mounted() {
  this.name = 'front end'
  console.log('sync',this.$refs.title.innerText)
  Promise.resolve().then(() => {
    console.log('微任务',this.$refs.title.innerText)
  })
  setTimeout(() => {
    console.log('宏任务',this.$refs.title.innerText)
  }, 0)
  this.$nextTick(() => {
    console.log('nextTick',this.$refs.title.innerText)
  })
}

是不是觉得有点不可思议,其实没什么奇怪的,在vue源码中它的实现原理就是利用的微任务与宏任务,慢慢往下看,后面会一一解释。

DOM更新还是批量的

没错,vue中的DOM更新还是批量处理的,这样做的好处无疑就是能够最大程度的优化性能。OK这里也有看点,别着急

vue同时更新了多个数据,你觉得dom是更新多次还是更新一次?我们来试试

<template>
  <div class="next_tick">
      <div ref="title" class="title">{{name}}</div>
      <div class="verse">{{verse}}</div>
  </div>
</template>

<script>
export default {
    name: 'nextTick',
    data() {
        return {
            name: '前端南玖',
            verse: '如若东山能再起,大鹏展翅上九霄',
            count:0
        }
    },
    mounted() {
        this.name = 'front end'
        this.verse = '世间万物都是空,功名利禄似如风'
        // console.log('sync',this.$refs.title.innerText)
        // Promise.resolve().then(() => {
        //     console.log('微任务',this.$refs.title.innerText)
        // })
        // setTimeout(() => {
        //     console.log('宏任务',this.$refs.title.innerText)
        // }, 0)
        // this.$nextTick(() => {
        //     console.log('nextTick',this.$refs.title.innerText)
        // })
    },
    updated() {
        this.count++
        console.log('update:',this.count)
    }
​
}
</script>
<style lang="less">
.verse{
    font-size: (20/@rem);
}
</style>

我们可以看到updated钩子只执行了一次,说明我们同时更新了多个数据,DOM只会更新一次

再来看另一种情况,同步与异步混合,DOM会更新几次?

mounted() {
  this.name = 'front end'
  this.verse = '世间万物都是空,功名利禄似如风'
  Promise.resolve().then(() => {
    this.name = 'study ...'
  })
  setTimeout(() => {
    this.verse = '半身风雨半身寒,一杯浊酒敬流年'
  })
  // console.log('sync',this.$refs.title.innerText)
  // Promise.resolve().then(() => {
  //     console.log('微任务',this.$refs.title.innerText)
  // })
  // setTimeout(() => {
  //     console.log('宏任务',this.$refs.title.innerText)
  // }, 0)
  // this.$nextTick(() => {
  //     console.log('nextTick',this.$refs.title.innerText)
  // })
},
  updated() {
    this.count++
    console.log('update:',this.count)
  }

从图中我们会发现,DOM会渲染三次,分别是同步的一次(2个同步一起更新),微任务的一次,宏任务的一次。并且在用setTimeout更新数据时会明显看见页面数据变化的过程。(这句话是重点,记好小本本)这也就是为什么nextTick源码中setTimeout做最后兜底用的,优先使用微任务。

事件循环

没错,这里跟事件循环还有很大的关系,这里稍微提一下,更详细可以看探索JavaScript执行机制

由于JavaScript是单线程的,这就决定了它的任务不可能只有同步任务,那些耗时很长的任务如果也按同步任务执行的话将会导致页面阻塞,所以JavaScript任务一般分为两类:同步任务与异步任务,而异步任务又分为宏任务与微任务。

宏任务: script(整体代码)、setTimeout、setInterval、setImmediate、I/O、UI rendering

微任务: promise.then、MutationObserver

执行过程

  • 同步任务直接放入到主线程执行,异步任务(点击事件,定时器,ajax等)挂在后台执行,等待I/O事件完成或行为事件被触发。
  • 系统后台执行异步任务,如果某个异步任务事件(或者行为事件被触发),则将该任务添加到任务队列,并且每个任务会对应一个回调函数进行处理。
  • 这里异步任务分为宏任务与微任务,宏任务进入到宏任务队列,微任务进入到微任务队列。
  • 执行任务队列中的任务具体是在执行栈中完成的,当主线程中的任务全部执行完毕后,去读取微任务队列,如果有微任务就会全部执行,然后再去读取宏任务队列
  • 上述过程会不断的重复进行,也就是我们常说的 「事件循环(Event-Loop)」

总的来说,在事件循环中,微任务会先于宏任务执行。而在微任务执行完后会进入浏览器更新渲染阶段,所以在更新渲染前使用微任务会比宏任务快一些,一次循环就是一次tick 。

在一次event loop中,microtask在这一次循环中是一直取一直取,直到清空microtask队列,而macrotask则是一次循环取一次。

如果执行事件循环的过程中又加入了异步任务,如果是macrotask,则放到macrotask末尾,等待下一轮循环再执行。如果是microtask,则放到本次event loop中的microtask任务末尾继续执行。直到microtask队列清空。

源码深入

异步更新队列

在Vue中DOM更新一定是由于数据变化引起的,所以我们可以快速找到更新DOM的入口,也就是set时通过dep.notify通知watcher更新的时候

// watcher.js
// 当依赖发生变化时,触发更新
update() {
  if(this.lazy) {
    // 懒执行会走这里, 比如computed
    this.dirty = true
  }else if(this.sync) {
    // 同步执行会走这里,比如this.$watch() 或watch选项,传递一个sync配置{sync: true}
    this.run()
  }else {
    // 将当前watcher放入watcher队列, 一般都是走这里
    queueWatcher(this)
  }
}

从这里我们可以发现vue默认就是走的异步更新机制,它会实现一个队列进行缓存当前需要更新的watcher

// scheduler.js
/*将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送*/
export function queueWatcher (watcher: Watcher) {
  /*获取watcher的id*/
  const id = watcher.id
  /*检验id是否存在,已经存在则直接跳过,不存在则标记在has中,用于下次检验*/
  if (has[id] == null) {
    has[id] = true
    // 如果flushing为false, 表示当前watcher队列没有在被刷新,则watcher直接进入队列
    if (!flushing) {
      queue.push(watcher)
    } else {
      // 如果watcher队列已经在被刷新了,这时候想要插入新的watcher就需要特殊处理
      // 保证新入队的watcher刷新仍然是有序的
      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) {
      // wating为false,表示当前浏览器的异步任务队列中没有flushSchedulerQueue函数
      waiting = true
      // 这就是我们常见的this.$nextTick
      nextTick(flushSchedulerQueue)
    }
  }
}

ok,从这里我们就能发现vue并不是跟随数据变化立即更新视图的,它而是维护了一个watcher队列,并且id重复的watcher只会推进队列一次,因为我们关心的只是最终的数据,而不是它更新多少次。等到下一个tick时,这些watcher才会从队列中取出,更新视图。

nextTick

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

// next-tick.js
const callbacks = []
let pending = false
​
// 批处理
function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  // 依次执行nextTick的方法
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}
​
export function nextTick (cb, ctx) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  // 因为内部会调nextTick,用户也会调nextTick,但异步只需要一次
  if (!pending) {
    pending = true
    timerFunc()
  }
  // 执行完会会返回一个promise实例,这也是为什么$nextTick可以调用then方法的原因
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

兼容性处理,优先使用promise.then 优雅降级(兼容处理就是一个不断尝试的过程,谁可以就用谁。

Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

// timerFunc
// promise.then -> MutationObserver -> setImmediate -> setTimeout
// vue3 中不再做兼容性处理,直接使用的就是promise.then 任性
​
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) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  let counter = 1
  const observer = new MutationObserver(flushCallbacks) // 可以监听DOM变化,监听完是异步更新的
  // 但这里并不是想用它做DOM监听,而是利用它是微任务这一特点
  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 {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

$nextTick

我们平常调用的$nextTick其实就是上面这个方法,只不过在源码中renderMixin中将该方法挂在了vue的原型上方便我们使用

export function renderMixin (Vue) {
  // install runtime convenience helpers
  installRenderHelpers(Vue.prototype)

  Vue.prototype.$nextTick = function (fn) {
    return nextTick(fn, this)
  }

  Vue.prototype._render = function() {
    //...
  }
  // ...
}

总结

一般更新DOM是同步的

上面说了那么多,相信大家对Vue的异步更新机制以及$nextTick原理已经有了初步的了解。每一轮事件循环的最后会进行一次页面渲染,并且从上面我们知道渲染过程也是个宏任务,这里可能会有个误区,那就是DOM tree的修改是同步的,只有渲染过程是异步的,也就是说我们在修改完DOM后能够立即获取到更新的DOM,不信我们可以来试一下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="title">欲试人间烟火,怎料世道沧桑</div>
    <script>
        title.innerText = '万卷诗书无一用,半老雄心剩疏狂'
        console.log('updated',title)
    </script>
</body>
</html>

既然更新DOM是个同步的过程,那为什么Vue却需要借用$nextTick来处理呢?

答案很明显,因为Vue处于性能考虑,Vue会将用户同步修改的多次数据缓存起来,等同步代码执行完,说明这一次的数据修改就结束了,然后才会去更新对应DOM,一方面可以省去不必要的DOM操作,比如同时修改一个数据多次,只需要关心最后一次就好了,另一方面可以将DOM操作聚集,提升render性能。

看下面这个图理解起来应该更容易一点

为什么优先使用微任务?

这个应该不用多说吧,因为微任务一定比宏任务优先执行,如果nextTick是微任务,它会在当前同步任务执行完立即执行所有的微任务,也就是修改DOM的操作也会在当前tick内执行,等本轮tick任务全部执行完成,才是开始执行UI rendering。如果nextTick是宏任务,它会被推进宏任务队列,并且在本轮tick执行完之后的某一轮执行,注意,它并不一定是下一轮,因为你不确定宏任务队列中它之前还有所少个宏任务在等待着。所以为了能够尽快更新DOM,Vue中优先采用的是微任务,并且在Vue3中,它没有了兼容判断,直接使用的是promise.then微任务,不再考虑宏任务了。

总结

到此这篇关于Vue异步更新机制及$nextTick原理的文章就介绍到这了,更多相关Vue异步更新及$nextTick原理内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • Vue中this.$nextTick的作用及用法

    Vue实现响应式后DOM的变化 data对象中数据改变是如何追踪的? vue将遍历data对象中所有的属性,并通过 Object.defineProperty 把这些属性全部转为 getter/setter:但是我们是没有办法看到 getter/setter的,但是在内部它们让 Vue 能够追踪依赖,在属性被访问和修改时通知变更. 每个组件都对应一个 watcher 实例,它会在组件渲染的过程中把"接触"过的数据属性记录为依赖.之后当依赖项的 setter 触发时,会通知 watche

  • VUE异步更新DOM - 用$nextTick解决DOM视图的问题

    VUE异步更新DOM 首先,Vue 在更新 DOM 时是异步执行的! 所以只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更.如果同一个 watcher 被多次触发,只会被推入到队列中一次.这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的.然后,在下一个的事件循环"tick"中,Vue 刷新队列并执行实际 (已去重的) 工作.Vue 在内部对异步队列尝试使用原生的 Promise.then.MutationObserver 和

  • 详解Vue的异步更新实现原理

    最近面试总是会被问到这么一个问题:在使用vue的时候,将for循环中声明的变量i从1增加到100,然后将i展示到页面上,页面上的i是从1跳到100,还是会怎样?答案当然是只会显示100,并不会有跳转的过程. 怎么可以让页面上有从1到100显示的过程呢,就是用setTimeout或者Promise.then等方法去模拟. 讲道理,如果不在vue里,单独运行这段程序的话,输出一定是从1到100,但是为什么在vue中就不一样了呢? for(let i=1; i<=100; i++){ console.

  • 全面解析Vue中的$nextTick

    当在代码中更新了数据,并希望等到对应的Dom更新之后,再执行一些逻辑.这时,我们就会用到$nextTick funcion callback(){ //等待Dom更新,然后搞点事. } $nextTick(callback): 官方文档对nextTick的解释是: 在下次 DOM 更新循环结束之后执行延迟回调.在修改数据之后立即使用这个方法,获取更新后的 DOM. 那么,Vue是如何做的这一点的,是不是在调用修改Dom的Api之后(appendChild, textContent = "xxxx

  • vue源码之批量异步更新策略的深入解析

    vue异步更新源码中会有涉及事件循环.宏任务.微任务的概念,所以先了解一下这几个概念. 一.事件循环.宏任务.微任务 1.事件循环Event Loop:浏览器为了协调事件处理.脚本执行.网络请求和渲染等任务而定制的工作机制. 2.宏任务Task: 代表一个个离散的.独立的工作单位.浏览器完成一个宏任务,在下一个宏任务开始执行之前,会对页面重新渲染.主要包括创建文档对象.解析HTML.执行主线JS代码以及各种事件如页面加载.输入.网络事件和定时器等. 3.微任务:微任务是更小的任务,是在当前宏任务

  • vue中$nextTick的用法讲解

    vue是非常流行的框架,他结合了angular和react的优点,从而形成了一个轻量级的易上手的具有双向数据绑定特性的mvvm框架.本人比较喜欢用之.在我们用vue时,我们经常用到一个方法是this.$nextTick,相信你也用过.我常用的场景是在进行获取数据后,需要对新视图进行下一步操作或者其他操作时,发现获取不到dom.因为赋值操作只完成了数据模型的改变并没有完成视图更新.在这个时候我们需要用到本章介绍的函数. 虽然 Vue.js 通常鼓励开发人员沿着"数据驱动"的方式思考,避免

  • Vue异步更新机制及$nextTick原理的深入讲解

    目录 前言 Vue的异步更新 DOM更新是异步的 DOM更新还是批量的 事件循环 执行过程 源码深入 异步更新队列 nextTick $nextTick 总结 一般更新DOM是同步的 既然更新DOM是个同步的过程,那为什么Vue却需要借用$nextTick来处理呢? 为什么优先使用微任务? 总结 前言 相信很多人会好奇Vue内部的更新机制,或者平时工作中遇到的一些奇怪的问题需要使用$nextTick来解决,今天我们就来聊一聊Vue中的异步更新机制以及$nextTick原理 Vue的异步更新 可能

  • vue异步更新dom的实现浅析

    目录 Vue异步更新DOM的原理 1 什么时候能获取到真正的DOM元素? 2 为什么Vue需要通过nextTick方法才能获取最新的DOM? 3 为什么this.$nextTick 能够获取更新后的DOM? 总结:vue异步更新的原理 Data对象:vue中的data方法中返回的对象: Dep对象:每一个Data属性都会创建一个Dep,用来搜集所有使用到这个Data的Watcher对象: Watcher对象:主要用于渲染DOM Vue异步更新DOM的原理 Vue中的数据更新是异步的,意味着我们在

  • vue中的任务队列和异步更新策略(任务队列,微任务,宏任务)

    目录 事件循环 任务队列 如何理解微任务和宏任务? 深究Vue异步更新策略原理 事件循环 JavaScript 语言的一大特点就是单线程,也就是说,同一个时间只能做一件事. 为了协调事件.用户交互.脚本.UI 渲染和网络处理等行为,防止主线程的不阻塞,Event Loop 的方案应用而生. Event Loop 包含两类: 一类是基于 Browsing Context 一种是基于 Worker 二者的运行是独立的,也就是说,每一个 JavaScript 运行的"线程环境"都有一个独立的

  • 关于vue组件的更新机制 resize() callResize()

    目录 组件的更新机制 resize() callResize() 异步更新机制是如何实现的 组件的更新机制 resize() callResize() 假设有一段代码,通过isCollapse改变触发ref的子组件child触发方法resize(),借着触发callResize()方法. 这是vue组件的更新机制. 子组件是child,父组件是整个界面 ,在父组件上任意触发监听,调用方法resize(): resize()调用callResize(),callResize()把下面的3个方法的r

  • 详解从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指出,

  • 浅谈Vuejs中nextTick()异步更新队列源码解析

    vue官网关于此解释说明如下: vue2.0里面的深入响应式原理的异步更新队列 官网说明如下: 只要观察到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据改变.如果同一个 watcher 被多次触发,只会一次推入到队列中.这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作上非常重要.然后,在下一个的事件循环"tick"中,Vue 刷新队列并执行实际(已去重的)工作.Vue 在内部尝试对异步队列使用原生的 Promise.then 和 MutationOb

  • vue的状态更新方式(异步更新解决)

    目录 状态更新(异步更新解决) 解决方案 异步更新及nexttick 为什么需要异步更新 nextTick 原理 状态更新(异步更新解决) 在vue中状态更新是异步的,这一点和react中的setstate类似. 解决方案 非组件解决方案: <div id="example">{{message}}</div> var vm = new Vue({   el: '#example',   data: {     message: '123'   } }) vm.

随机推荐