記錄---nextTick用過嗎?講一講實現思路吧

林恒發表於2024-11-16

🧑‍💻 寫在開頭

點贊 + 收藏 === 學會🤣🤣🤣

原始碼實現思路(面試高分回答) 📖

面試官問我 Vue 的 nextTick 原理是怎麼實現的,我這樣回答:

在呼叫 this.$nextTick(cb) 之前:

  1. 存在一個 callbacks 陣列,用於存放所有的 cb 回撥函式。
  2. 存在一個 flushCallbacks 函式,用於執行 callbacks 陣列中的所有回撥函式。
  3. 存在一個 timerFunc 函式,用於將 flushCallbacks 函式新增到任務佇列中。

當呼叫 this.nextTick(cb) 時:

  1. nextTick 會將 cb 回撥函式新增到 callbacks 陣列中。
  2. 判斷在當前事件迴圈中是否是第一次呼叫 nextTick
    • 如果是第一次呼叫,將執行 timerFunc 函式,新增 flushCallbacks 到任務佇列。
    • 如果不是第一次呼叫,直接下一步。
  3. 如果沒有傳遞 cb 回撥函式,則返回一個 Promise 例項。


根據上述描述,對應的`流程圖`如下:

如果上面的描述沒有很理解。沒關係,花幾分鐘跟著我下面來,看完下面的原始碼逐行講解,你一定能夠清晰地向別人講出你的思路!

nextTick思路詳解 🏃‍♂‍➡

1. 核心程式碼 🌟

下面用十幾行程式碼,就已經可以基本實現nextTick的功能(預設瀏覽器支援Promise)

// 儲存所有的cb回撥函式
const callbacks = [];
/*類似於節流的標記位,標記是否處於節流狀態。防止重複推送任務*/
let pending = false;

/*遍歷執行陣列 callbacks 中的所有儲存的cb回撥函式*/
function flushCallbacks() {
  // 重置標記,允許下一個 nextTick 呼叫
  pending = false;
  /*執行所有cb回撥函式*/
  for (let i = 0; i < callbacks.length; i++) {
    callbacks[i]();
  }
  // 清空回撥陣列,為下一次呼叫做準備
  callbacks.length = 0;
}

function nextTick(cb) {
  // 將回撥函式cb新增到 callbacks 陣列中
  callbacks.push(() => {
    cb();
  });
  
  // 第一次使用 nextTick 時,pending 為 false,下面的程式碼才會執行
  if (!pending) {
    // 改變標記位的值,如果有flushCallbacks被推送到任務佇列中去則不需要重複推送
    pending = true;
    // 使用 Promise 機制,將 flushCallbacks 推送到任務佇列
    Promise.resolve().then(flushCallbacks);
  }
}

測試一下:

let message = '初始訊息';
  
nextTick(() => {
  message = '更新後的訊息';
  console.log('回撥:', message); // 輸出2: 更新後的訊息
});

console.log('測試開始:', message); // 輸出1: 初始訊息

如果你想要應付面試官,能手寫這部分核心原理就已經差不多啦。
如果你想徹底掌握它,請繼續跟著我來!!!🕵🏻‍♂

2. nextTick() 返回promise 🌟

我們在開發中,會使用await this.$nextTick();讓其下面的程式碼全部變成非同步程式碼。 比如寫成這樣

await this.$nextTick();
......
......

// 或者
this.$nextTick().then(()=>{
    ......
})

核心就是nextTick()如果沒有引數,則返回一個promise

const callbacks = [];
let pending = false;

function flushCallbacks() {
  pending = false;
  for (let i = 0; i < callbacks.length; i++) {
    callbacks[i]();
  }
  callbacks.length = 0;
}

function nextTick(cb) {
  // 用於儲存 Promise 的resolve函式
  let _resolve;
  callbacks.push(() => {
  /* ------------------ 新增start ------------------ */
    // 如果有cb回撥函式,將cb儲存到callbacks
    if (cb) {
      cb();
    } else if (_resolve) {
    // 如果引數cb不存在,則儲存promise的的成功回撥resolve
      _resolve();
    }
  /* ------------------ 新增end ------------------ */
  });
  if (!pending) {
    pending = true;
    Promise.resolve().then(flushCallbacks);
  }
  
  /* ------------------ 新增start ------------------ */
  if (!cb) {
    return new Promise((resolve, reject) => {
      // 儲存resolve到callbacks陣列中
      _resolve = resolve;
    });
  }
  /* ------------------ 新增end ------------------ */
}

測試一下:

async function testNextTick() {
  let message = "初始訊息";
  
  nextTick(() => {
    message = "更新後的訊息";
  });
  console.log("傳入回撥:", message); // 輸出1: 初始訊息

  // 不傳入回撥的情況
  await nextTick(); // nextTick 返回 Promise
  console.log("未傳入回撥後:", message); // 輸出2: 更新後的訊息
}

// 執行測試
testNextTick();

3. 判斷瀏覽器環境 🔧

為了防止瀏覽器不支援 PromiseVue 選擇了多種 API 來實現相容 nextTick
Promise --> MutationObserver --> setImmediate --> setTimeout

  1. Promise (微任務):
    如果當前環境支援 PromiseVue 會使用 Promise.resolve().then(flushCallbacks)

  2. MutationObserver (微任務):
    如果不支援 Promise,支援 MutationObserverVue 會建立一個 MutationObserver 例項,透過監聽文字節點的變化來觸發執行回撥函式。

  3. setImmediate (宏任務):
    如果前兩者都不支援,支援 setImmediate。則:setImmediate(flushCallbacks)
    注意setImmediate 在絕大多數瀏覽器中不被支援,但在 Node.js 中是可用的。

  4. setTimeout (宏任務):
    如果前面所有的都不支援,那你的瀏覽器一定支援 setTimeout!!!
    終極方案:setTimeout(flushCallbacks, 0)

// 儲存所有的回撥函式
const callbacks = [];
/* 類似於節流的標記位,標記是否處於節流狀態。防止重複推送任務 */
let pending = false;

/* 遍歷執行陣列 callbacks 中的所有儲存的 cb 回撥函式 */
function flushCallbacks() {
  // 重置標記,允許下一個 nextTick 呼叫
  pending = false;
  /* 執行所有 cb 回撥函式 */
  for (let i = 0; i < callbacks.length; i++) {
    callbacks[i](); // 依次呼叫儲存的回撥函式
  }
  // 清空回撥陣列,為下一次呼叫做準備
  callbacks.length = 0;
}

// 判斷最終支援的 API:Promise / MutationObserver / setImmediate / setTimeout
let timerFunc;

if (typeof Promise !== "undefined") {
  // 建立一個已resolve的 Promise 例項
  var p = Promise.resolve();
  // 定義 timerFunc 為使用 Promise 的方式排程 flushCallbacks
  timerFunc = () => {
    // 使用 p.then 方法將 flushCallbacks 推送到微任務佇列
    p.then(flushCallbacks);
  };
} else if (
  typeof MutationObserver !== "undefined" &&
  MutationObserver.toString() === "[object MutationObserverConstructor]"
) {
  /* 新建一個 textNode 的 DOM 物件,用 MutationObserver 繫結該 DOM 並指定回撥函式。
   在 DOM 變化的時候則會觸發回撥,該回撥會進入主執行緒(比任務佇列優先執行),
   即 textNode.data = String(counter) 時便會加入該回撥 */
   var counter = 1; // 用於切換文字節點的值
   var observer = new MutationObserver(flushCallbacks); // 建立 MutationObserver 例項
   var textNode = document.createTextNode(String(counter)); // 建立文字節點
   observer.observe(textNode, {
      characterData: true, // 監聽文字節點的變化
   });
   // 定義 timerFunc 為使用 MutationObserver 的方式排程 flushCallbacks
  timerFunc = () => {
    counter = (counter + 1) % 2; // 切換 counter 的值(0 或 1)
    textNode.data = String(counter); // 更新文字節點以觸發觀察者
  };
} else if (typeof setImmediate !== "undefined") {
  /* 使用 setImmediate 將回撥推入任務佇列尾部 */
  timerFunc = () => {
    setImmediate(flushCallbacks); // 將 flushCallbacks 推送到宏任務佇列
  };
} else {
  /* 使用 setTimeout 將回撥推入任務佇列尾部 */
  timerFunc = () => {
    setTimeout(flushCallbacks, 0); // 將 flushCallbacks 推送到宏任務佇列
  };
}

function nextTick(cb) {
  // 用於儲存 Promise 的解析函式
  let _resolve; 
  // 將回撥函式 cb 新增到 callbacks 陣列中
  callbacks.push(() => {
    // 如果有 cb 回撥函式,將 cb 儲存到 callbacks
    if (cb) {
      cb();
    } else if (_resolve) {
      // 如果引數 cb 不存在,則儲存 Promise 的成功回撥 resolve
      _resolve();
    }
  });

  // 第一次使用 nextTick 時,pending 為 false,下面的程式碼才會執行
  if (!pending) {
    // 改變標記位的值,如果有 nextTickHandler 被推送到任務佇列中去則不需要重複推送
    pending = true;
    // 呼叫 timerFunc,將 flushCallbacks 推送到合適的任務佇列
    timerFunc(flushCallbacks);
  }

  // 如果沒有 cb 且環境支援 Promise,則返回一個 Promise
  if (!cb && typeof Promise !== "undefined") {
    return new Promise((resolve) => {
      // 儲存 resolve 到 callbacks 陣列中
      _resolve = resolve;
    });
  }
}

你真的太牛了,居然幾乎全部看完了!

Vue純原始碼

上面的程式碼實現,對於 nextTick 功能已經非常完整了,接下來我將給你展示出 Vue 中實現 nextTick 的完整原始碼。無非是加了一些判斷變數是否存在的判斷。看完上面的講解,我相信聰明的你一定能理解 Vue 實現 nextTick 的原始碼了吧!💡

// 儲存所有的 cb 回撥函式
const callbacks = [];
/* 類似於節流的標記位,標記是否處於節流狀態。防止重複推送任務 */
let pending = false;

/* 遍歷執行陣列 callbacks 中的所有儲存的 cb 回撥函式 */
function flushCallbacks() {
  pending = false; // 重置標記,允許下一個 nextTick 呼叫
  const copies = callbacks.slice(0); // 複製當前的 callbacks 陣列
  callbacks.length = 0; // 清空 callbacks 陣列
  for (let i = 0; i < copies.length; i++) {
    copies[i](); // 執行每一個儲存的回撥函式
  }
}
// 判斷是否為原生實現的函式
function isNative(Ctor) {
  // 如Promise.toString() 為 'function Promise() { [native code] }'
  return typeof Ctor === "function" && /native code/.test(Ctor.toString());
}

// 判斷最終支援的 API:Promise / MutationObserver / setImmediate / setTimeout
let timerFunc;

if (typeof Promise !== "undefined" && isNative(Promise)) {
  const p = Promise.resolve(); // 建立一個已解決的 Promise 例項
  timerFunc = () => {
    p.then(flushCallbacks); // 使用 p.then 將 flushCallbacks 推送到微任務佇列

    // 在某些有問題的 UIWebView 中,Promise.then 並不會完全失效,
    // 但可能會陷入一種奇怪的狀態:回撥函式被新增到微任務佇列中,
    // 但佇列並沒有被執行,直到瀏覽器需要處理其他工作,比如定時器。
    // 因此,我們可以透過新增一個空的定時器來“強制”執行微任務佇列。
    if (isIOS) setTimeout(() => {}); // 解決iOS 的bug,推遲 空函式 的執行(如果不理解,建議忽略)
  };
} else if (
  typeof MutationObserver !== "undefined" &&
  (isNative(MutationObserver) ||
    MutationObserver.toString() === "[object MutationObserverConstructor]")
) {
  let counter = 1; // 用於切換文字節點的值
  const observer = new MutationObserver(flushCallbacks); // 建立 MutationObserver 例項
  const textNode = document.createTextNode(String(counter)); // 建立文字節點
  observer.observe(textNode, {
    characterData: true, // 監聽文字節點的變化
  });
  // 定義 timerFunc 為使用 MutationObserver 的方式排程 flushCallbacks
  timerFunc = () => {
    counter = (counter + 1) % 2; // 切換 counter 的值(0 或 1)
    textNode.data = String(counter); // 更新文字節點以觸發觀察者
  };
} else if (typeof setImmediate !== "undefined" && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(flushCallbacks); // 使用 setImmediate 推送到任務佇列
  };
} else {
  timerFunc = () => {
    setTimeout(flushCallbacks, 0); // 使用 setTimeout 推送到宏任務佇列
  };
}

function nextTick(cb, ctx) {
  let _resolve; // 用於儲存 Promise 的解析函式
  // 將回撥函式 cb 新增到 callbacks 陣列中
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx); // 執行傳入的回撥函式
      } catch (e) {
        handleError(e, ctx, "nextTick"); // 錯誤處理
      }
    } else if (_resolve) {
      _resolve(ctx); // 解析 Promise
    }
  });
  // 第一次使用 nextTick 時,pending 為 false,下面的程式碼才會執行
  if (!pending) {
    pending = true; // 改變標記位的值
    timerFunc(); // 呼叫 timerFunc,排程 flushCallbacks
  }
  // 如果沒有 cb 且環境支援 Promise,則返回一個 Promise
  if (!cb && typeof Promise !== "undefined") {
    return new Promise((resolve) => {
      _resolve = resolve; // 儲存解析函式
    });
  }
}

總結

透過這樣分成三步、循序漸進的方式,我們深入探討了 nextTick 的原理和實現機制。希望這篇文章能夠對你有所幫助,讓你在前端開發的道路上更加得心應手!🚀

本文轉載於:https://juejin.cn/post/7433439452662333466

如果對您有所幫助,歡迎您點個關注,我會定時更新技術文件,大家一起討論學習,一起進步。

記錄---nextTick用過嗎?講一講實現思路吧

相關文章