一文搞懂nextTick的实现原理

1,344 阅读7分钟

引言

在前端开发中,我们经常遇到需要在DOM更新之后执行某些操作的需求。Vue.js 提供了一个非常有用的工具叫做 nextTick,它能确保我们的回调函数在所有数据更改被应用到 DOM 之后执行。本文将探讨 nextTick 的实现原理,还有它是宏任务还是微任务,并一步一步来展示它是如何工作的。

什么是 nextTick

nextTick 是 Vue.js 中的一个方法,它的作用是在数据变化之后延迟执行回调函数。这意味着我们可以确保在回调函数执行之前,Vue 已经完成了对 DOM 的更新。

正文

下面是模仿vue里面的nextTick功能手写的一个nextTick代码:

手写的nextTick完整代码(简易版)

function nextTick(fn) {
    return new Promise((resolve, reject) => {
      // DOM更新完成否?
      if (typeof MutationObserver !== 'undefined') {
        const observer = new MutationObserver(() => {
            const res=fn()
            if(res instanceof Promise){
                res.then(()=>{
                    resolve()
                })
            }else{
                resolve()
            }
            observer.disconnect();
        })
        observer.observe(document.getElementById('app'), { attributes: true, childList: true, subtree: true })       
      }
    })
  }

下面我们一步一步来对比vue里面的nextTick把这个nextTick实现过程搞明白。

nextTick 的实现过程

为什么要用nextTick

我们来一个例子,这个例子是我们先拿到点击按钮前的文本宽度,点击后再次拿到更新后文本的宽度,看是否有变化:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <style>
        h2{
            display: inline-block;
        }
    </style>
</head>

<body>
    <div id="app">
        <h2 ref="h2ref">{{message}}</h2>
        <button @click="updateMessage">更新</button>
    </div>
    <script>
        const { createApp,ref,onMounted } =Vue
        createApp({
            setup(){
                const message=ref('hello')
                const h2ref=ref(null)
                onMounted(()=>{  //不允许在函数里面使用
                    console.log(h2ref.value.clientWidth)                    
                })
                const updateMessage=()=>{
                    message.value='更新后的message'
                    console.log('更新后的宽度:'+h2ref.value.clientWidth)
                }
                return{
                    message,
                    h2ref,
                    updateMessage
                }
            }
        })
        .mount('#app')
    </script>
</body>
</html>

效果:

点击按钮前:

image.png

点击按钮后

image.png

解释: 可以看到点击按钮后,文本内容是发生了变化的,但是呢打印出来还59,诶这是为什么呢,其实是因为当我们用 message.value='更新后的message'这种代码改DOM时,它要经过很多步骤的:依赖收集,再到数据更新(下面有解释),再放入异步队列,所以这时conole.log()是不会等,会立即执行的,所以打印的还是更新前的宽度。

vue实现响应式的步骤:

  1. 依赖收集:当首次读取 message 的值时,Vue 会记录下当前活跃的副作用函数(effect)。在这种情况下,副作用函数可能是模板中的表达式,或者是组件的生命周期钩子等。
  2. 数据更新:当 message.value 被赋新值时,Vue 会检测到这个变化,并将相关的副作用函数标记为脏(dirty)。
  3. 异步队列:Vue 会把所有标记为脏的副作用函数放入一个异步队列中,以便稍后统一执行。这意味着所有的更新都会在一个微任务(microtask)结束时批量处理,而不是立即执行。这样做可以提高性能,因为频繁的 DOM 更新是非常昂贵的操作。

所以nextTick就是为解决这个问题而打造出来的,我们使用vue中的nextTick再来看效果:

const { createApp,ref,onMounted,nextTick } =Vue

                const updateMessage=()=>{
                    message.value='更新后的message'
                    let res=nextTick(()=>{ //保证内部代码会在页面渲染完成后执行
                    console.log('更新后的宽度:'+h2ref.value.clientWidth)
                    })

                    res.then(()=>{
                     console.log('nextTick执行完毕了')
                    })

                    console.log(res)
                }

效果: image.png

解释: 其它代码都不变,就改动了updateMessage和引入了vue官方nextTick,可以看到确实有用,并且可以发现vue中的nextTick可以return出来一个promise的,这个时候已经变成了fulfilled状态了,可以看到它可以监听DOM的改变,DOM改变后再执行代码。

手写nextTick过程

根据上面vue的效果,我们自己模仿动手手写一个:

1.0版本
function nextTick(fn) {
    // 接收一个参数 fn,这个参数是一个函数,。
    return new Promise((resolve, reject) => {
        // 返回一个新的 Promise 对象,因为vue官方里面的nextTick就是return出来了一个值的并且可以接.then
        
        if (typeof MutationObserver !== 'undefined') {
            // 检查当前环境是否支持 MutationObserver。
            
            const observer = new MutationObserver(fn);
            // 创建一个新的 MutationObserver 实例,传入函数 fn,每当观察到DOM发生变化时,就会调用 fn。
                            
            observer.observe(document.getElementById('app'), { attributes: true, childList: true, subtree: true });
            // 开始观察 id 为 'app' 的 DOM 元素的变化。
            
            //这里的配置选项为(根据自己的需求):
            // attributes: true 表示观察属性的变化;
            // childList: true 表示观察子节点的变化;
            // subtree: true 表示观察整个子树(不仅仅是直接子节点)的变化。
        }
    });
}

并且还是上面那个例子:

<body>
    <div id="app">
        <h2 ref="h2ref">{{message}}</h2>
        <button @click="updateMessage">更新</button>
    </div>
    <script src="./MynextTick.js"></script>
    <script>
        const { createApp,ref,onMounted } =Vue
        createApp({
            setup(){
                const message=ref('hello')
                const h2ref=ref(null)
                onMounted(()=>{  //不允许在函数里面使用
                    console.log(h2ref.value.clientWidth)  
                })
                const updateMessage=()=>{
                    message.value='更新后的message'
                    let res=nextTick(()=>{
                        console.log('更新后的宽度:'+h2ref.value.clientWidth)
                    })
                    res.then(()=>{
                     console.log('nextTick执行完毕了')
                    })
                    console.log(res)
                }
                return{
                    message,
                    h2ref,
                    updateMessage
                }
            }
        })
        .mount('#app')

    </script>
</body>

效果:

image.png 解释: 我们引入自己写的nextTickvue中的去掉,我们自己写的nextTick用到了一个MutationObserver,这是一个js自带的方法,用来观察DOM有没有发生改变,所以对于手写nextTick方便多了,可以看到也实现了效果,DOM更新后才执行console.log()

1.1版本

但是还是有一个问题,promise的状态没有改变,所以后面接的.then也就没有执行,所以我们改进一下:

function nextTick(fn) {
    return new Promise((resolve, reject) => {
      if (typeof MutationObserver !== 'undefined') {
        const observer = new MutationObserver(()=>{
            fn()
            resolve()
            observer.disconnect() // 观察结束,清理 observer
        })
        observer.observe(document.getElementById('app'), { attributes: true, childList: true, subtree: true })      
      }
    })
  }

效果:

image.png 解释: 既然每当观察到DOM发生变化时,new MutationObserver()就会执行()里面的函数,并且呢.then是在fn函数执行完在执行的,说明promise的状态是在fn执行完再变成fulfillled对吧,那么我们就可以用一个回调函数,回调函数里面先执行fnresolve()promise的状态变成fulfillled,那么就实现了根官方一样的效果了对吧,并且我们观察结束,清理 observer

1.2版本(完整版本)
function nextTick(fn) {
    return new Promise((resolve, reject) => {
      if (typeof MutationObserver !== 'undefined') {
        const observer = new MutationObserver(() => {
            const res=fn()
            if(res instanceof Promise){   //如果fn是promise我们就等它先执行
                res.then(()=>{
                    resolve()
                })
            }else{
                resolve()
            }
            observer.disconnect()
        })
        observer.observe(document.getElementById('app'), { attributes: true, childList: true, subtree: true })       
      }
    })
  }

上面呢是当fn为同步代码时,这个时候呢,nextTick里面有promiseMutationObserver,它们都是微任务对吧,所有nextTick这时就是微任务没有问题吧,那如果fn为异步代码呢,如果它是宏任务呢,那这时nextTick宏任务还是微任务呢,我们继续来深入了解一下:

首先我们先看官方nextTick是怎么处理的,下面的nextTick都是用的官方的

  • 如果是fn宏任务:
                const updateMessage=()=>{
                    message.value='更新后的message'
                    let res=nextTick(()=>{
                        setTimeout(()=>{
                            console.log('数据请求到了')
                        })
                    })
                    res.then(()=>{
                     console.log('nextTick执行完毕了')
                    })

                    console.log(res)
                }

效果:

image.png 解释: 其他代码不变,我们引入setTimeout(),所以这时fn是一个宏任务,可以看到如果fn是一个宏任务,官方的nextTick是会先执行的(打印'nextTick执行完毕了')再执行宏任务('数据请求到了'),这时候nextTick微任务对吧.

  • 如果是用promise包裹的宏任务:
                const getData=()=>{
                    return new Promise((resolve)=>{
                        setTimeout(()=>{
                            console.log('数据请求到了')
                            resolve()
                        })
                    })
                }
                const updateMessage=()=>{
                    message.value='更新后的信息!!!'
                    let res=nextTick(()=>{  
                        return getData()
                    })
                    res.then(()=>{
                     console.log('nextTick执行完毕了')
                    })
                    console.log(res)
                }

效果:

image.png 解释:诶,我们可以看到.then后执行了,宏任务先执行了,这个时候nextTick是宏任务对吧。


自己手写的nextTick代码解释:看到上面官方的效果,我们知道上面代码中return getData()是会执行getData对吧,并返回promise,所有我们代码中用const res=fn()接收它,如果它是promise咱就让它执行完我们再resolve(),这个时候的nextTick是宏任务,如果它是同步代码或者是直接是一个宏任务,我们就直接resolve()那么nextTick是微任务。

总结

本文到此就结束了,希望对你理解nextTick有所帮助,当面试官问道nextTick微任务还是宏任务,我们当说它既可以是宏任务也可以是微任务,分情况讲明白就好了,感谢你的阅读!!!