Javascript中的非同步程式設計

世有因果知因求果發表於2019-03-29

前言

最近,小夥伴S 問了我一段程式碼:

const funB = (value) => {
    console.log("funB "+ value);
};

const funA = (callback) => {
    ...
    setTimeout(() => {
        typeof callback === "function" && callback("is_ok!");
    }, 1000);
}

funA(funB);
複製程式碼

他不太理解這段程式碼中,funB 函式作為 funA 函式的引數這樣的寫法。從語義上看,callback 的意思是回撥,那麼是說 funB 是 funA 的回撥嘛?

我給他解釋說,funB 函式的確是 funA 函式的回撥,它會等待 funA 中前面的語句都執行完,再去執行。這是一種非同步程式設計的寫法。

小夥伴S 還是有點不太理解:非同步程式設計是什麼?除了回撥函式之外,非同步程式設計還有哪些?

別急,讓我們先從概念入手,再逐個理解非同步程式設計中的方法,看看它的前世今生。

什麼是非同步?

所謂"非同步"(Asynchronous),可以理解為一種不連續的執行。簡單地說,就是把一個任務分成兩段,先執行第一段,然後轉而執行其他任務,等接到通知了,再回過頭執行第二段。

我們都知道,JavaScript是單執行緒的。而非同步,對於JavaScript的重要性,則體現在非阻塞這一點上。一些常見的非同步有:

  • onclick 在其事件觸發的時候,回撥會立即新增到任務佇列中。
  • setTimeout 只有當時間到達的時候,才會將回撥新增到任務佇列中。
  • ajax 在網路請求完成並返回之後,才將回撥新增到任務佇列中。

接下來,我們一起來看看Javascript中的非同步程式設計,具體有哪幾種。

實現非同步程式設計的方法

一、回撥函式

上面不止一次提到了回撥函式。它從概念上說很簡單,就是把任務的第二段單獨寫在一個函式裡面,等到重新執行這個任務的時候,就直接呼叫這個函式。它是非同步程式設計中,最基本的方法。

舉個例子,假定有兩個函式 f1 和 f2,後者等待前者的執行結果。順序執行的話,可以這樣寫:

f1();
f2();
複製程式碼

但是,如果 f1 是一個很耗時的任務,該怎麼辦?

改寫一下 f1,把 f2 寫成 f1 的回撥函式:

const f1 = (callback) => {
    setTimeout(() => {
        typeof callback === "function" && callback();
    }, 1000);
}
f1(f2);
複製程式碼

二、事件監聽

onclick 的寫法,在非同步程式設計中,稱為事件監聽。它的思路是:如果任務的執行不取決於程式碼的順序,而取決於某個事件是否發生,也就事件驅動模式。

還是 f1 和 f2 的例子,為了簡化程式碼,這裡採用jQuery的寫法:

// 為f1繫結一個事件,當f1發生done事件,就執行f2
f1.on('done', f2);

// 改寫f1
function f1(){
    setTimeout(() => {
        // f1的任務程式碼,執行完成後,立即觸發done事件
        f1.trigger('done');
    }, 1000);
}
複製程式碼

它的優點是:比較容易理解,耦合度降低了。可以繫結多個事件,而且每個事件還能指定多個回撥函式。

缺點是:整個程式都會變為由事件來驅動,流程會變得很不清晰。

三、釋出/訂閱

這是一種為了處理一對多的業務場景而誕生的設計模式,它也是一種非同步程式設計的方法。vue中MVVM的實現,就有它的功勞。

關於概念,我們可以這樣理解,假定存在一個"訊號中心",某個任務執行完成,就向訊號中心"釋出"(publish)一個訊號,其他任務可以向訊號中心"訂閱"(subscribe)這個訊號,從而知道什麼時候自己可以開始執行。這就叫做"釋出/訂閱模式"(publish-subscribe pattern),又稱"觀察者模式"(observer pattern)。

下面的例子,採用的是 Morgan Roderick 的 PubSubJS ,這是一個無依賴的JavaScript外掛:

import PubSub from 'pubsub-js';

// f2向 'PubSub' 訂閱訊號 'done'
PubSub.subscribe('done', f2);

const f1 = () => {
    setTimeout(() => {
        // f1執行完成後,向 'PubSub' 釋出訊號 'done',從而執行 f2
        PubSub.publish('done');
    }, 1000);
};
f1();

// f2 完成執行後,也可以取消訂閱
PubSub.unsubscribe("done", f2);
複製程式碼

這種模式有點類似於“事件監聽”,但是明顯優於後者。因為,我們可以通過檢視“訊息中心”,瞭解存在多少訊號、每個訊號有多少訂閱者,從而監控程式的執行。

四、Promise物件

接下來,我們聊聊與ajax相關的非同步程式設計方法,Promise物件。

Promise 是由 CommonJS 提出的一種規範,它是為了解決回撥函式巢狀,也就是回撥地獄的問題。它不是新的語法功能,而是一種新的寫法,允許將回撥函式的橫向載入,改成縱向載入。它的思想是,每一個非同步任務返回一個Promise物件,該物件有一個then方法,允許指定回撥函式。

繼續改寫 f1 和 f2:

const f1 = () => {
    return new Promise((resolve, reject) => {
        let timeOut = Math.random() * 2;
        setTimeout(() => {
            if (timeOut < 1) {
                resolve('200 OK');
            } else {
                reject('timeout in ' + timeOut + ' seconds.');
            }
        }, 1000);
    });  
};

const f2 = () => {
    console.log('start f2');  
};

f1().then((result) => {
    console.log(result);
    f2();
}).catch((reason) => {
    ...
);
複製程式碼

例子中,用隨機數模擬了請求的超時。當 f1 返回 Promise 的 resolve 時,執行 f2。

Promise的優點是:回撥函式變成了鏈式的寫法,程式的流程可以看得很清楚。還有就是,如果一個任務已經完成,再新增回撥函式,該回撥函式會立即執行。所以,你不用擔心是否錯過了某個狀態。

缺點就是:編寫和理解,都相對比較難。

五、Generator

generator(生成器)是 ES6 標準引入的資料型別。它最大特點,就是可以交出函式的執行權(即暫停執行),是協程在 ES6 中的實現。

看上去它像一個函式,定義如下:

function* gen(x) {
  var y = yield x + 2;
  return y;
}
複製程式碼

它不同於普通函式,函式名之前要加星號(*),是可以暫停執行的。

整個 Generator 函式就是一個封裝的非同步任務,或者說是非同步任務的容器。用 yield 語句註明非同步操作需要暫停的地方。

我們來看一下 Generator 函式執行的過程:

var g = gen(1);

// { value: 3, done: false }
g.next();
// { value: undefined, done: true }
g.next();
複製程式碼

上面程式碼中,呼叫 Generator 函式,會返回一個內部指標(即遍歷器 )g 。這是 Generator 函式不同於普通函式的另一個地方,即執行它不會返回結果,返回的是指標物件。呼叫指標 g 的 next 方法,會移動內部指標(即執行非同步任務的第一段),指向第一個遇到的 yield 語句,上例是執行到 x + 2 為止。

換言之,next 方法的作用是分階段執行 Generator 函式。每次呼叫 next 方法,會返回一個物件,表示當前階段的資訊( value 屬性和 done 屬性)。value 屬性是 yield 語句後面表示式的值,表示當前階段的值;done 屬性是一個布林值,表示 Generator 函式是否執行完畢,即是否還有下一個階段。

六、async/await

這是 ES8 中提出的一種更優雅的非同步解決方案,靈感來自於 C# 語言。具體可前往 細說 async/await 相較於 Promise 的優勢 ,深入理解其原理及特性。

來看個例子,要實現一個暫停功能,輸入 N 毫秒,則停頓 N 毫秒後才繼續往下執行。

const sleep = (time) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve();
        }, time);
    })
};

const start = async () => {
    console.log('start');
    // 在這裡使用起來就像同步程式碼那樣直觀
    await sleep(1000);
    console.log('end');
};

start();
複製程式碼

控制檯先輸出 start,稍等 1 秒後,輸出結果 ok,最後輸出 end。

解析一下上述程式碼:

  • async 表示這是一個async函式,await 只能用在這個函式裡面。
  • await 表示在這裡等待 promise 返回了結果,再繼續執行。
  • 使用起來,就像寫同步程式碼一樣地優雅。

總結

JavaScript的非同步編寫方式,從 回撥函式 到 async/await,感覺在寫法上,每次都有進步,其本質就是一次次對語言層抽象的優化。以至於現在,我們可以像同步一樣地,去處理非同步。

換句話說就是:非同步程式設計的最高境界,就是根本不用關心它是不是非同步

PS:歡迎關注我的公眾號 “超哥前端小棧”,交流更多的想法與技術。

Javascript中的非同步程式設計

相關文章