$nextTick的回调函数一定在Dom更新后执行吗——$nextTick原理解密

nextTick的回调函数一定在Dom更新后执行吗------nextTick原理解密

大家有没有思考过一个问题:就是大家都知道 nextTick** 它是一个异步任务,但是响应式数据的 Dom 更新也是异步的,那么怎么保证二者之间的执行顺序呢?nextTick的回调函数一定在 Dom 更新后执行吗?我将从 **nextTick 源码实现结合 dom 异步更新来帮大家解答这个问题。

写在前面

大家可以先思考下,下面这段代码里两次执行$nextTick回调函数都能获取到最新的 message dom 值吗?

js 复制代码
<template>
  <div>
    <div ref="contentDiv">{{ message }}</div>
    <button @click="handlerClick">点击</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: '初始值'
    }
  },
  methods: {
    handlerClick() {
      this.$nextTick(() => {
        console.log(this.$refs.contentDiv.textContent, '$nextTick') // 第一次输出
      })
      this.message = '更新了'
      this.$nextTick(() => {
        console.log(this.$refs.contentDiv.textContent, '$nextTick') // 第二次输出
      })
    },
  }
}
</script>

如果我修改一下,这段代码里两次执行$nextTick回调函数都能获取到最新的message dom 值吗?

js 复制代码
<template>
  <div>
    <div ref="contentDiv">{{ message }}</div>
    <div>{{ text }}</div>
    <button @click="handlerClick">点击</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: '初始值',
      text: ''
    }
  },
  methods: {
    handlerClick() {
      this.text = '测试'
      this.$nextTick(() => {
        console.log(this.$refs.contentDiv.textContent, '$nextTick') // 第一次输出
      })
      this.message = '更新了'
      this.$nextTick(() => {
        console.log(this.$refs.contentDiv.textContent, '$nextTick') // 第二次输出
      })
    },
  }
}
</script>

在介绍整个原理之前,我先简单介绍下浏览器的事件循环机制。

1.浏览器事件循环机制

%%{init: {'theme':'base'}}%% graph LR A[事件循环] --> B[执行宏任务] B --> C{微任务队列} C -->|队列空| D[UI渲染] C -->|有任务| E[执行全部微任务] E --> C D --> F[取下一个宏任务] F --> B classDef macro fill:#f4d03f,stroke:#f1c40f; classDef micro fill:#58d68d,stroke:#27ae60; classDef process fill:#3498db,stroke:#2980b9; class A,F process class B,D,F macro class C,E micro

浏览器事件循环工作机制详解:

  1. 执行宏任务阶段

    从宏任务队列中取出最旧的任务执行,常见宏任务类型包括:

    • 页面主线程代码(<script>标签内容)
    • 定时器回调(setTimeout/setInterval
    • I/O操作回调(文件读写、网络请求)
    • DOM事件回调(点击、滚动等)
    • requestAnimationFrame(特殊类型宏任务)
  2. 处理微任务队列

    当前宏任务执行完毕后立即进入微任务处理阶段:

    • 检查微任务队列是否为空
    • 非空时 :依次执行全部微任务(先进先出)
    • 常见微任务
      • Promise.then/catch/finally回调
      • MutationObserver监听回调
      • Vue的DOM更新任务及$nextTick回调
      • queueMicrotaskAPI创建的任务
  3. UI渲染阶段(可选)

    当微任务队列清空后,浏览器根据需要执行:

    • 样式计算 → 布局 → 绘制 的渲染流水线
    • 执行requestAnimationFrame回调(若存在)
  4. 开启下一轮循环

    重复上述过程,从宏任务队列中取下一个任务执行。整个机制的关键特点:

    • 微任务具有最高优先级,必须在本轮循环中全部执行。什么意思呢?就是在一个事件循环开始时,会从宏任务队列取出一个宏任务执行,然后进入微任务处理阶段,会将微任务队列中所有的任务全部执行完,如果这时候有新的微任务加入,则会继续处理微任务,直到微任务队列为空。
    • 宏任务按队列顺序执行,保证任务公平性
    • UI渲染可能被跳过(当无视觉变化需求时)

2. $nextTick原理解密

2.1 创建异步执行器------timerFunc

在分析 nextTick 函数前需要先了解下 vue2 是如何创建异步执行器的,都用了哪些方法。

js 复制代码
var timerFunc; // 异步执行器
// timerFunc = Promise.then
if (typeof Promise !== 'undefined' && isNative(Promise)) {
    var p_1 = Promise.resolve();
    timerFunc = function () {
      p_1.then(flushCallbacks);
      if (isIOS)
        setTimeout(noop);
    };
    isUsingMicroTask = true;
}
// timerFunc = MutationObserver
else if (!isIE &&
  typeof MutationObserver !== 'undefined' &&
  (isNative(MutationObserver) ||
      MutationObserver.toString() === '[object MutationObserverConstructor]')) {
  var counter_1 = 1;
  var observer = new MutationObserver(flushCallbacks);
  var textNode_1 = document.createTextNode(String(counter_1));
  observer.observe(textNode_1, {
      characterData: true
  });
  timerFunc = function () {
    counter_1 = (counter_1 + 1) % 2;
    textNode_1.data = String(counter_1);
  };
  isUsingMicroTask = true;
}
// timerFunc = setImmediate
else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = function () {
    setImmediate(flushCallbacks);
  };
}
// timerFunc = setTimeout
else {
  timerFunc = function () {
      setTimeout(flushCallbacks, 0);
  };
}

这里Vue采用四级降级方案实现 timerFunc。

  1. 🌟 优先采用 Promise.then(微任务)
  2. 🐭 次选 MutationObserver(微任务)
  3. 🎯 再次使用 setImmediate(宏任务,Node.js 特有 Api)
  4. 🕒 兜底方案 setTimeout(宏任务)

有的同学可能会问,在一个事件循环里边,明明是先执行宏任务,这里为什么要优先使用微任务呢?

我们要明白的一点是,执行 $nextTick 函数本身就是宏任务的一部分,当整个宏任务执行完成后,下一步进入微任务处理阶段,将整个微任务队列清空 。一个循环里边只能处理一个宏任务,而微任务是需要全部清空的。所以这里使用微任务能够最快执行回调函数。

2.2 nextTick 函数实现

接下来我们来看看 nextTick 函数的实现。

js 复制代码
var callbacks = [];
var pending = false;

function nextTick(cb, ctx) {
  var _resolve;
  // 逻辑分支1:存在回调函数
  callbacks.push(function () {
    if (cb) {
      try {
        cb.call(ctx);// 执行用户回调
      }
      catch (e) {
        handleError(e, ctx, 'nextTick');// 错误边界处理
      }
    }
    // 逻辑分支2:Promise模式
    else if (_resolve) {
      _resolve(ctx);// 触发Promise解析
    }
  });
  // 异步队列控制锁
  if (!pending) {
    pending = true;
    timerFunc();// 触发异步执行器
  }
  // Promise兼容处理
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(function (resolve) {
      _resolve = resolve;
    });
  }
}

nextTick 函数核心逻辑可以分解为三步:

  1. 回调收集 :所有回调函数收集到 callbacks 数组中,等待执行。
  2. 异步调度 这里是整个 $nextTick 的核心,我详细解释一下:
    • 通过 pending 状态锁,确保一次循环里只有一个异步任务。
    • 执行 timerFunc 启动异步任务。里面会执行 Promise.resolve().then(flushCallbacks);。而 flushCallbacks 函数就会等到当前宏任务执行完后,在微任务处理阶段执行。
    • 当 pending上锁后,后续所有的 $nextTick 回调函数都只会被收集,而不会触发异步任务。
  3. Promise兼容:未传入回调函数时,会返回一个 Promise 对象,相当于把 $nextTick 当成 Promise 来用。

2.3 执行回调------flushCallbacks

js 复制代码
function flushCallbacks() {
  pending = false;
  var copies = callbacks.slice(0);
  callbacks.length = 0;
  for (var i = 0; i < copies.length; i++) {
      copies[i]();
  }
}

当进行微任务处理阶段时,就会执行 flushCallbacks 函数,将 callbacks 数组中的回调函数依次执行。同时将 pending 状态锁释放,并清空 callbacks 数组。

为什么要复制一份callbacks来做处理? 主要是防止递归调用造成死循环。

js 复制代码
  // 假设回调中再次调用nextTick
  function callback() {
    nextTick(() => callback());
  }

如果不复制的话回调会一直加载当前的 callback 里,从而导致死循环。这样也能保证批次的完整性,新的回调放到下一个批次里去执行。

2.4 $nextTick 小结

到这里想必大家都清楚 $nextTick 的原理了。就是收集回调函数 ,然后创建一个微任务异步执行。但是如果这是这样的话,怎么保证 $nextTick 回调函数执行顺序一定在dom更新完成之后呢?因为 dom 更新也是异步的呀。所以我们还需要结合 dom 异步更新原理来弄清楚这件事。

3. Dom 异步更新原理

了解响应式原理的同学应该知道数据变化后会触发 watcher 实例的 update 函数。不清楚的同学可以看我上一篇文章

js 复制代码
Watcher.prototype.update = function () {
  if (this.lazy) {
    this.dirty = true;
  }
  else if (this.sync) {
    this.run();
  }
  else {
    queueWatcher(this);
  }
};

对于render watcher在 update 函数中,会调用 queueWatcher 函数将当前 watcher 实例加入到异步队列中。

js 复制代码
var waiting = false;
var flushing = false;
function queueWatcher(watcher) {
  var id = watcher.id;
  if (has[id] != null) {
      return;
  }
  if (watcher === Dep.target && watcher.noRecurse) {
      return;
  }
  has[id] = true;
  if (!flushing) {
      queue.push(watcher);
  }
  else {
    var i = queue.length - 1;
    while (i > index$1 && queue[i].id > watcher.id) {
        i--;
    }
    queue.splice(i + 1, 0, watcher);
  }
  if (!waiting) {
    waiting = true;
    if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue();
        return;
    }
    nextTick(flushSchedulerQueue);
  }
}
  1. 入队检测 :首先通过 watcher.id 来判断当前 watcher 是否已经加入到异步队列中。防止同一数据变更触发多次相同更新
  2. 通过 flushing 变量来判断当前是否正在执行异步队列,如果否,则加入到 queue 队列中。如果正在执行,则按id排序插入,保障组件更新顺序从父到子。
  3. 异步队列:通过 nextTick 函数,将 flushSchedulerQueue 函数加入到微任务队列中。利用 waiting 变量来确保一次循环里只执行一次。
  4. flushSchedulerQueue: 执行 queue 队列中的所有 watcher 实例的 run 函数完成视图更新。

这里面需要注意的是,所有的 watcher 实例都添加到了 queue 队列中,然后调用 nextTick 函数完成视图更新。调用 nextTick调用 nextTick调用 nextTick ,重要的事说三遍。这意味着,它把所有视图更新的操作打包成了一个回调函数,然后通过 nextTick 函数,将这个回调函数加入到微任务队列中。

也就是说注册的 $nextTick 回调函数,和视图更新操作共用一个callback数组,这也就意味着它们二者之间是有明确的顺序,谁先注册,谁先执行。所以我们能够通过 nextTick 获取视图更新之后的 dom 元素,是因为我们代码里基本上都会先修改值,再调用nextTick。

4.总结

%%{init: {'theme':'base', 'themeVariables': { 'primaryColor': '#f8f9fa', 'nodeBorder': '#4dabf7', 'clusterBorder': '#ced4da', 'edgeLabelBackground':'#fff', 'fontFamily': 'inherit' }}}%% graph TB classDef callback fill:#74c0fc,stroke:#4dabf7,stroke-width:2px,color:#fff,radius:8px; classDef dom fill:#ff8787,stroke:#ff6b6b,stroke-width:2px,color:#fff,radius:8px; classDef array fill:#96f2d7,stroke:#20c997,stroke-width:2px,color:#fff,radius:8px; classDef async fill:#fff3bf,stroke:#ffd43b,stroke-width:2px,radius:8px; A[回调函数1]:::callback B[回调函数2]:::callback C[回调函数3]:::callback D[视图更新]:::dom A -->|注册| CB[$nextTick]:::array B -->|注册| CB C -->|注册| CB D -->|注册| CB CB -->|按注册顺序添加| E[callback数组] CB -->|启动| F[异步任务] F -->|微任务阶段执行| E style CB width:180px,height:50px

就是视图异步更新会打包成一个任务 ,用 $nextTick 注册回调 。和用户自己用 $nextTick 注册的回调函数共用同一个 callbacks 数组 。按先后注册顺序存放 (只要有任何一个 watcher 先注册了,那么整个视图更新的任务就会在第一个)。同时第一个注册回调的函数会启动一个微任务,等到宏任务执行完,执行微任务的时候会遍历执行 callbacks 数组中的所有回调函数。

写在最后

到这里想必大家对文章开头的两个例子都有了答案。

第一个例子里:第一个回调函数先于 message 注册,所以它拿不到更新后的值,第二个回调函数是可以的。

第二个例子里:先改变了 text 值,这时候视图更新的任务会先注册回调,text 更新和 message 更新都会先执行。所以两个回调函数都能拿到更新后的值。

如果大家觉得我写的还不错的话,欢迎大家关注下我的个人博客。里面会不定期更新新的文章。

相关推荐
蜡笔小新星26 分钟前
Flask项目框架
开发语言·前端·经验分享·后端·python·学习·flask
Fantasywt4 小时前
THREEJS 片元着色器实现更自然的呼吸灯效果
前端·javascript·着色器
IT、木易5 小时前
大白话JavaScript实现一个函数,将字符串中的每个单词首字母大写。
开发语言·前端·javascript·ecmascript
张拭心7 小时前
2024 总结,我的停滞与觉醒
android·前端
念九_ysl7 小时前
深入解析Vue3单文件组件:原理、场景与实战
前端·javascript·vue.js
Jenna的海糖7 小时前
vue3如何配置环境和打包
前端·javascript·vue.js
星之卡比*7 小时前
前端知识点---库和包的概念
前端·harmonyos·鸿蒙
灵感__idea7 小时前
Vuejs技术内幕:数据响应式之3.x版
前端·vue.js·源码阅读
烛阴7 小时前
JavaScript 构造器进阶:掌握 “new” 的底层原理,写出更优雅的代码!
前端·javascript
Alan-Xia7 小时前
使用jest测试用例之入门篇
前端·javascript·学习·测试用例