非同步流程控制

本文內容受到Mixu的Node.js書的極大啟發。

在其核心,JavaScript 被設計為在“主”線程上是非阻塞的,這是視圖呈現的地方。您可以想像這在瀏覽器中的重要性。當主線程被阻塞時,會導致使用者害怕的臭名昭著的“凍結”,且無法發送其他事件,導致數據採集的損失,例如。

這創造了一些獨特的限制,只有函數式編程才能解決。這就是回調函數出現的地方。

然而,回調函數在處理更複雜的過程時可能變得具有挑戰性。這通常導致“回調地獄”,其中多個嵌套的帶有回調函數的函數使代碼變得更難閱讀、調試、組織等。

async1(function (input, result1) {
  async2(function (result2) {
    async3(function (result3) {
      async4(function (result4) {
        async5(function (output) {
          // do something with output
        });
      });
    });
  });
});

當然,在現實生活中,可能會有額外的代碼來處理result1result2等,因此,這個問題的長度和復雜性通常會導致代碼看起來比上面的示例更雜亂。

這就是函數發揮巨大作用的地方。更複雜的操作由許多函數組成。

  1. 啟動器風格 / 輸入
  2. 中介軟體
  3. 終止器

「啟動器風格 / 輸入」是序列中的第一個函數。此函數將接受操作的原始輸入(如果有的話)。該操作是一系列可執行的函數,原始輸入主要會是

  1. 全域環境中的變數
  2. 直接調用,帶有或不帶有參數
  3. 由檔案系統或網路請求獲取的值

網路請求可以是由外部網路、相同網路上的另一個應用程式,或應用程式本身在相同或外部網路上發起的傳入請求。

中介軟體函數將返回另一個函數,而終止器函數將調用回調函數。以下說明了流程到網路或檔案系統請求。這裡的延遲為0,因為所有這些值都存在於記憶體中。

function final(someInput, callback) {
  callback(`${someInput} and terminated by executing callback `);
}

function middleware(someInput, callback) {
  return final(`${someInput} touched by middleware `, callback);
}

function initiate() {
  const someInput = 'hello this is a function ';
  middleware(someInput, function (result) {
    console.log(result);
    // requires callback to `return` result
  });
}

initiate();

狀態管理

函數可能是有狀態依賴的,也可能不是。當函數的輸入或其他變數依賴於外部函數時,就會產生狀態依賴。

這樣就有了兩種主要的狀態管理策略

  1. 將變數直接傳遞給函數,以及
  2. 從快取、會話、檔案、資料庫、網路或其他外部來源獲取變數值。

請注意,我沒有提及全域變數。使用全域變數管理狀態通常是一種笨拙的反模式,它使得難以保證狀態的正確性或不可變性。在複雜的程式中應盡可能避免使用全域變數。

控制流程

如果一個物件存在於記憶體中,就可以進行迭代,並且不會改變控制流程。

function getSong() {
  let _song = '';
  let i = 100;
  for (i; i > 0; i -= 1) {
    _song += `${i} beers on the wall, you take one down and pass it around, ${
      i - 1
    } bottles of beer on the wall\n`;
    if (i === 1) {
      _song += "Hey let's get some more beer";
    }
  }

  return _song;
}

function singSong(_song) {
  if (!_song) throw new Error("song is '' empty, FEED ME A SONG!");
  console.log(_song);
}

const song = getSong();
// this will work
singSong(song);

然而,如果數據存在於記憶體之外,則迭代將不再起作用。

function getSong() {
  let _song = '';
  let i = 100;
  for (i; i > 0; i -= 1) {
    /* eslint-disable no-loop-func */
    setTimeout(function () {
      _song += `${i} beers on the wall, you take one down and pass it around, ${
        i - 1
      } bottles of beer on the wall\n`;
      if (i === 1) {
        _song += "Hey let's get some more beer";
      }
    }, 0);
    /* eslint-enable no-loop-func */
  }

  return _song;
}

function singSong(_song) {
  if (!_song) throw new Error("song is '' empty, FEED ME A SONG!");
  console.log(_song);
}

const song = getSong('beer');
// this will not work
singSong(song);
// Uncaught Error: song is '' empty, FEED ME A SONG!

為什麼會發生這種情況呢?setTimeout指示 CPU 將指令存儲在總線的其他位置,並指示在稍後的時間安排取件數據。數千個 CPU 週期過去之後,當函數再次在 0 毫秒標記處到達時,CPU 從總線中提取指令並執行它們。唯一的問題是,在此之前數千個週期返回了空的 ('')。

在處理文件系統和網絡請求時,出現了相同的情況。主線程不能被阻塞在一段不確定的時間內--因此,我們使用回調以控制的方式安排代碼的執行時間。

您將能夠使用以下 3 種模式執行幾乎所有操作

  1. 串行: 函數將按嚴格的順序執行,這與 for 循環最相似。
// operations defined elsewhere and ready to execute
const operations = [
  { func: function1, args: args1 },
  { func: function2, args: args2 },
  { func: function3, args: args3 },
];

function executeFunctionWithArgs(operation, callback) {
  // executes function
  const { args, func } = operation;
  func(args, callback);
}

function serialProcedure(operation) {
  if (!operation) process.exit(0); // finished
  executeFunctionWithArgs(operation, function (result) {
    // continue AFTER callback
    serialProcedure(operations.shift());
  });
}

serialProcedure(operations.shift());
  1. 全并行: 當順序不是問題時,比如向 1,000,000 個郵件收件人發送電子郵件。
let count = 0;
let success = 0;
const failed = [];
const recipients = [
  { name: 'Bart', email: 'bart@tld' },
  { name: 'Marge', email: 'marge@tld' },
  { name: 'Homer', email: 'homer@tld' },
  { name: 'Lisa', email: 'lisa@tld' },
  { name: 'Maggie', email: 'maggie@tld' },
];

function dispatch(recipient, callback) {
  // `sendEmail` is a hypothetical SMTP client
  sendMail(
    {
      subject: 'Dinner tonight',
      message: 'We have lots of cabbage on the plate. You coming?',
      smtp: recipient.email,
    },
    callback
  );
}

function final(result) {
  console.log(`Result: ${result.count} attempts \
      & ${result.success} succeeded emails`);
  if (result.failed.length)
    console.log(`Failed to send to: \
        \n${result.failed.join('\n')}\n`);
}

recipients.forEach(function (recipient) {
  dispatch(recipient, function (err) {
    if (!err) {
      success += 1;
    } else {
      failed.push(recipient.name);
    }
    count += 1;

    if (count === recipients.length) {
      final({
        count,
        success,
        failed,
      });
    }
  });
});
  1. 有限并行: 有限制的並行,比如成功地從 10E7 用戶列表中向 1,000,000 個收件人發送電子郵件。
let successCount = 0;

function final() {
  console.log(`dispatched ${successCount} emails`);
  console.log('finished');
}

function dispatch(recipient, callback) {
  // `sendEmail` is a hypothetical SMTP client
  sendMail(
    {
      subject: 'Dinner tonight',
      message: 'We have lots of cabbage on the plate. You coming?',
      smtp: recipient.email,
    },
    callback
  );
}

function sendOneMillionEmailsOnly() {
  getListOfTenMillionGreatEmails(function (err, bigList) {
    if (err) throw err;

    function serial(recipient) {
      if (!recipient || successCount >= 1000000) return final();
      dispatch(recipient, function (_err) {
        if (!_err) successCount += 1;
        serial(bigList.pop());
      });
    }

    serial(bigList.pop());
  });
}

sendOneMillionEmailsOnly();

每個模式都有自己的用例、好處和問題,您可以更詳細地進行實驗和閱讀。最重要的是,請記住將您的操作模組化並使用回調!如果您有任何疑問,請把一切都當作中間件來處理!

閱讀時間
6 分鐘閱讀
貢獻
編輯此頁面
目錄
  1. 狀態管理
  2. 控制流程