由於筆者在過渡到 TypeScript ,所以本次開發依舊會採用 TypeScript 來敲。
這應該是筆者最後一次用 TypeScript 冠名分享文章,再見 ?,我已經可以安全上路了。( 喊了那麼多次,快上車,都沒有多少人上車,那我就先走了。)
本文適合從零瞭解或者想重新深入研究 Promise 的讀者,並且可以得到如下知識:
-
Promise 重要知識點
-
Promise API 實現方法
-
前端何來安全感
筆者希望讀者可以僅通過看僅此一篇文章就可以對 Promise 有個深刻的認知,並且可以自己實現一個 Promise 類。所以會從方方面面講 Promise,內容可能會比較多,建議讀者選讀。
為什麼要實現 Promise
Promise 出現在 Es6 ,如果 Es5 需要使用 Promise,通常需要用到 Promise-polyfill
。也就是說,我們要實現的是一個 polyfill。實現它不僅有助於我們深入瞭解 Promise 而且能減少使用中犯錯的概率,以至於獲得 Promise 最佳實踐。
從另外一個角度來說重新實現某個依賴也是一種原始碼解讀的方式,堅持這麼做,意味著解讀原始碼能力的提升。
Promise
Promise 表示一個非同步操作的最終結果,與之進行互動的方式主要是 then 方法,該方法註冊了兩個回撥函式,用於接收 promise 的終值或本 promise 不能執行的原因。
來看筆者用心畫的一張 API 結構圖 ( 看不清楚的可以進我的 GitHub 看,有大圖和 xmind 原始檔 ):
上圖只是一個 Promise 的 API 藍圖,其實 Promises/A+
規範並不設計如何建立、解決和拒絕 promise,而是專注於提供一個通用的 then 方法。所以,Promise/A+ 規範的實現可以與那些不太規範但可用的實現能良好共存。如果大家都按規範來,那麼就沒有那麼多相容問題。(PS:包括 web 標準 ) 接著聊下 Promises/A+
,看過的可以跳過。
Promises/A+
所有 Promise 的實現都離不開 Promises/A+ 規範,內容不多,建議大家可以過一遍。這邊講一些規範中重要的點
術語
-
Promise
一個擁有then
方法的物件或函式,其行為符合Promises/A+
規範; -
thenable
一個定義了then
方法的物件或函式,也可視作 “擁有then
方法” -
值(value)
指任何 JavaScript 的合法值(包括 undefined , thenable 和 promise) -
異常(exception)
使用throw
語句丟擲的一個值 -
據因(reason)
表示一個 promise 的拒絕原因。
Promise 的狀態
一個 Promise 的當前狀態必須為以下三種狀態中的一種:等待態(Pending)、執行態(Fulfilled)和拒絕態(Rejected)。
-
等待態(Pending)
處於等待態時,promise 需滿足:
可以
遷移至執行態或拒絕態 -
執行態(Fulfilled)
處於執行態時,promise 需滿足:
不能
遷移至其他任何狀態,必須擁有一個不可變
的終值
-
拒絕態(Rejected)
處於拒絕態時,promise 需滿足:
不能
遷移至其他任何狀態,必須擁有一個不可變
的據因
這裡的不可變指的是恆等(即可用
===
判斷相等),而不是意味著更深層次的不可變( 指當 value 或 reason 不是基本值時,只要求其引用地址相等,但屬性值可被更改)。
Then 方法
一個 promise 必須提供一個 then 方法以訪問其當前值、終值和據因。
promise 的 then 方法接受兩個引數:
promise.then(onFulfilled, onRejected);
複製程式碼
-
onFulfilled
和onRejected
都是可選引數。 -
如果
onFulfilled
是函式,當 promise 執行結束後其必須被呼叫,其第一個引數為 promise 的終值,在 promise 執行結束前其不可被呼叫,其呼叫次數不可超過一次 -
如果
onRejected
是函式,當 promise 被拒絕執行後其必須被呼叫,其第一個引數為 promise 的據因,在 promise 被拒絕執行前其不可被呼叫,其呼叫次數不可超過一次 -
onFulfilled
和onRejected
只有在執行環境堆疊僅包含平臺程式碼 ( 指的是引擎、環境以及 promise 的實施程式碼 )時才可被呼叫 -
實踐中要確保
onFulfilled
和onRejected
方法非同步執行,且應該在then
方法被呼叫的那一輪事件迴圈之後的新執行棧中執行。 -
onFulfilled
和onRejected
必須被作為函式呼叫即沒有 this 值 ( 也就是說在 嚴格模式(strict) 中,函式 this 的值為 undefined ;在非嚴格模式中其為全域性物件。) -
then 方法可以被同一個 promise 呼叫多次
-
then 方法必須返回一個 promise 物件
Then 引數 (函式) 返回值
希望讀者可以認真看這部分的內容,對於理解 promise 的 then
方法有很大的幫助。
先來看下 promise 執行過程:
大致的過程是,promise 會從 pending
轉為 fulfilled
或 rejected
,然後對應呼叫 then
方法引數的 onFulfilled
或 onRejected
,最終返回 promise
物件。
進一步理解,假定 有如下兩個 promise:
promise2 = promise1.then(onFulfilled, onRejected);
複製程式碼
會有以下幾種情況:
-
如果
onFulfilled
或者onRejected
丟擲異常 e ,則 promise2 必須拒絕執行,並返回拒因 e
-
如果
onFulfilled
不是函式 且 promise1 成功執行, promise2 必須成功執行並返回 相同的值 -
如果
onRejected
不是函式 且 promise1 拒絕執行, promise2 必須拒絕執行並返回 相同的據因
希望進一步搞懂的,可以將下面程式碼拷貝到 chrome 控制檯或其他可執行環境感受一下:
// 通過改變 isResolve 來切換 promise1 的狀態
const isResolve = true;
const promise1 = new Promise((resolve, reject) => {
if (isResolve) {
resolve('promise1 執行態');
} else {
reject('promise1 拒絕態');
}
});
// 一、promise1 處於 resolve 以及 onFulfilled 丟擲異常 的情況
// promise2 必須拒絕執行,並返回拒因
promise1
.then(() => {
throw '丟擲異常!';
})
.then(
value => {
console.log(value);
},
reason => {
console.log(reason);
}
);
// 二、promise1 處於 resolve 以及 onFulfilled 不是函式的情況
// promise2 必須成功執行並返回相同的值
promise1.then().then(value => {
console.log(value);
});
// 三、promise1 處於 reject 以及 onRejected 不是函式的情況
// promise2 必須拒絕執行並返回拒因
promise1.then().then(
() => {},
reason => {
console.log(reason);
}
);
// 四、promise1 處於 resolve 以及 onFulfilled 有返回值時
promise1
.then(value => {
return value;
})
.then(value => {
console.log(value);
});
複製程式碼
下面還有一個比較重要的情況,它關係到業務場景中傳值問題:
onFulfilled
或者onRejected
返回 一個 JavaScript 合法值 的情況
我們先來假定 then 方法內部有一個叫做 [[Resolve]]
的方法用於處理這種特殊情況,下面來具體瞭解下這個方法。
[[Resolve]]
方法
一般像 [[...]]
這樣的認為是內部實現,如 [[Resolve]]
,該方法接受兩個引數:
[[Resolve]](promise, x);
複製程式碼
對於 x
值,有以下幾種情況:
-
x
有 then 方法 且看上去像一個Promise
-
x
為物件或函式 -
x
為Promise
另外 promise 不能與 x 相等即 promise !== x
,否則:
下面來看張圖,大致瞭解下各情況的應對方式:
Promise/A+
小結
至此,Promise/A+
需要 瞭解的就講完了。主要包括了,術語以及 Then 方法的用法和相關注意事項。需要特別注意的是,then 方法中引數返回值的處理。接下來,我們在規範的基礎上,用 TypeScript 來 實現 Promise。
接下來,文中出現的規範特指 Promise/A+
規範
Promise 實現
Promise 本身是一個建構函式,即可以實現為類。接下來,主要圍繞實現一個 Promise 類來講。
先來看下一個標準 promise 物件具備的屬性和 方法,做到心中有數。
Promise 提供的 API:
Promise 內部屬性包括:
下面開始正式的實現部分
宣告檔案
在開始前,先來了解下,用 TypeScript 寫 Promise 涉及的一些型別宣告。可以看這個宣告檔案。
主要包括:
TypeScript 中宣告檔案用於外部模組,是 TypeScript 的核心部分。另外,從一個宣告檔案就可以大致瞭解所用模組暴露的 API 情況 (接受什麼型別,或者會返回什麼型別的資料)。這種事先設計好 API 是一個好的開發習慣,但實際開發中會比較難。
接著來看,Promise 類核心實現的開始部分,建構函式。
建構函式
規範提到 Promise 建構函式接受一個 Resolver
型別的函式作為第一個引數,該函式接受兩個引數 resolve 和 reject,用於處理 promise 狀態。
實現如下:
class Promise {
// 內部屬性
private ['[[PromiseStatus]]']: PromiseStatus = 'pending';
private ['[[PromiseValue]]']: any = undefined;
subscribes: any[] = [];
constructor(resolver: Resolver<R>) {
this[PROMISE_ID] = id++;
// resolver 必須為函式
typeof resolver !== 'function' && resolverError();
// 使用 Promise 建構函式,需要用 new 操作符
this instanceof Promise ? this.init(resolver) : constructorError();
}
private init(resolver: Resolver<R>) {
try {
// 傳入兩個引數並獲取使用者傳入的終值或拒因。
resolver(
value => {
this.mockResolve(value);
},
reason => {
this.mockReject(reason);
}
);
} catch (e) {
this.mockReject(e);
}
return null;
}
private mockResolve() {
// TODO
}
private mockReject() {
// TODO
}
}
複製程式碼
[[Resolve]] 實現
通過前面規範部分,我們瞭解到 [[Resolve]]
屬於內部實現,用於處理 then 引數的返回值。也就是這裡即將要實現的名為 mockResolve
的方法。
根據規範內容可以得知,mockResolve
方法接受的 value 可能為 Promise,thenable,以及其他有效 JavaScript 值。
private mockResolve(value: any) {
// 規範提到 resolve 不能傳入當前返回的 promise
// 即 `[[Resolve]](promise,x)` 中 promise !== x
if (value === this) {
this.mockReject(resolveSelfError);
return;
}
// 非物件和函式,直接處理
if (!isObjectORFunction(value)) {
this.fulfill(value);
return;
}
// 處理一些像 promise 的物件或函式,即 thenable
this.handleLikeThenable(value, this.getThen(value));
}
複製程式碼
處理 Thenable 物件
重點看下 handleLikeThenable
實現,可結合前面規範部分提及 Thenable 的幾種情況來分析:
private handleLikeThenable(value: any, then: any) {
// 處理 "真實" promise 物件
if (this.isThenable(value, then)) {
this.handleOwnThenable(value);
return;
}
// 獲取 then 值失敗且丟擲異常,則以此異常為拒因 reject promise
if (then === TRY_CATCH_ERROR) {
this.mockReject(TRY_CATCH_ERROR.error);
TRY_CATCH_ERROR.error = null;
return;
}
// 如果 then 是函式,則檢驗 then 方法的合法性
if (isFunction(then)) {
this.handleForeignThenable(value, then);
return;
}
// 非 Thenable ,則將該終植直接交由 fulfill 處理
this.fulfill(value);
}
複製程式碼
處理 Thenable 中 Then 為函式的情況
規範提及:
如果 then 是函式,將 x 作為函式的作用域 this 呼叫之。傳遞兩個回撥函式作為引數,第一個引數叫做 resolvePromise ,第二個引數叫做 rejectPromise。
此時,handleForeignThenable
就是用來檢驗 then 方法的。
實現如下:
private tryThen(then, thenable, resolvePromise, rejectPromise) {
try {
then.call(thenable, resolvePromise, rejectPromise);
} catch (e) {
return e;
}
}
private handleForeignThenable(thenable: any, then: any) {
this.asap(() => {
// 如果 resolvePromise 和 rejectPromise 均被呼叫,
// 或者被同一引數呼叫了多次,則優先採用首次呼叫並忽略剩下的呼叫
// 此處 sealed (穩定否),用於處理上訴邏輯
let sealed = false;
const error = this.tryThen(
then,
thenable,
value => {
if (sealed) {
return;
}
sealed = true;
if (thenable !== value) {
this.mockResolve(value);
} else {
this.fulfill(value);
}
},
reason => {
if (sealed) {
return;
}
sealed = true;
this.mockReject(reason);
}
);
if (!sealed && error) {
sealed = true;
this.mockReject(error);
}
});
}
複製程式碼
fulfill 實現
來看 [[Resolve]]
中最後一步,fulfill
實現:
private fulfill(value: any) {
this['[[PromiseStatus]]'] = 'fulfilled';
this['[[PromiseValue]]'] = value;
// 用於處理非同步情況
if (this.subscribes.length !== 0) {
this.asap(this.publish);
}
}
複製程式碼
看到這裡,大家可能有關注到很多方法都帶有 private
修飾符,在 TypeScript 中
private 修飾的屬性或方法是私有的,不能在宣告它的類的外部訪問
規範提到過,PromiseStatus
屬性不能由外部更改,也就是 promise 狀態只能改變一次,而且只能從內部改變,也就是這裡私有方法 fulfill
的職責所在。
[[Resolve]]
小結
至此,一個內部 [[Resolve]]
就實現了。我們回顧一下,[[Resolve]]
用於處理以下情況
// 例項化建構函式,傳入 resolve 的情況
const promise = Promise(resolve => {
const value: any;
resolve(value);
});
複製程式碼
以及
// then 方法中有 返回值的情況
promise.then(
() => {
const value: any;
return value;
},
() => {
const reason: any;
return reason;
}
);
複製程式碼
對於終值 value
有多種情況,在處理 Thenable 的時候,請參考規範來實現。promise
除了 resolve
的還有 reject
,但這部分內容比較簡單,我們會放到後面再講解。先來看與 resolve
密不可分的 then
方法實現。這也是 promise
的核心方法。
Then 方法實現
通過前面的實現,我們已經可以從 Promise 建構函式來改變內部 [[PromiseStatus]]
狀態以及內部 [[PromiseValue]]
值,並且對於多種 value 值我們都有做相應的相容處理。接下來,是時候把這些值交由 then 方法中的第一個引數 onFulfilled
處理了。
在講解之前先來看下這種情況:
promise2 = promise1.then(onFulfilled, onRejected);
複製程式碼
使用 promise1
的 then
方法後,會返回一個 promise 物件 promise2
實現如下:
class Promise {
then(onFulfilled?, onRejected?) {
// 對應上述的 promise1
const parent: any = this;
// 對應上述的 promise2
const child = new parent.constructor(() => {});
// 根據 promise 的狀態選擇處理方式
const state = PROMISE_STATUS[this['[[PromiseStatus]]']];
if (state) {
// promise 各狀態對應列舉值 'pending' 對應 0 ,'fulfilled' 對應 1,'rejected' 對應 2
const callback = arguments[state - 1];
this.asap(() =>
this.invokeCallback(
this['[[PromiseStatus]]'],
child,
callback,
this['[[PromiseValue]]']
)
);
} else {
// 呼叫 then 方法的 promise 處於 pending 狀態的處理邏輯,一般為非同步情況。
this.subscribe(parent, child, onFulfilled, onRejected);
}
// 返回一個 promise 物件
return child;
}
}
複製程式碼
這裡比較惹眼的 asap
後續會單獨講。先來理順一下邏輯,then
方法接受兩個引數,由當前 promise
的狀態決定呼叫 onFulfilled
還是 onRejected
。
現在大家肯定很關心 then 方法裡的程式碼是如何被執行的,比如下面的 console.log
:
promise.then(value => {
console.log(value);
});
複製程式碼
接下來看與之相關的 invokeCallback
方法
then 方法中回撥處理
then 方法中的 onFulfilled
和 onRejected
都是可選引數,開始進一步講解前,建議大家先了解規範中提及的兩個引數的特性。
現在來講解 invokeCallback
接受的引數及其含義:
-
settled
(穩定狀態),promise 處於非 pending 狀態則稱之為 settled,settled 的值可以為fulfilled
或rejected
-
child
即將返回的 promise 物件 -
callback
根據settled
選擇的onFulfilled
或onRejected
回撥函式 -
detail
當前呼叫 then 方法 promise 的 value(終值) 或 reason(拒因)
注意這裡的 settled
和 detail
,settled
用於指 fulfilled
或 rejected
, detail 用於指 value
或 reason
這都是有含義的
知道這些之後,就只需要參考規範建議實現的方式進行處理相應:
private invokeCallback(settled, child, callback, detail) {
// 1、是否有 callback 的對應邏輯處理
// 2、回撥函式執行後是否會丟擲異常,即相應處理
// 3、返回值不能為自己的邏輯處理
// 4、promise 結束(執行結束或被拒絕)前不能執行回撥的邏輯處理
// ...
}
複製程式碼
需要處理的邏輯已給出,剩下的實現方式讀者可自行實現或看本專案原始碼實現。建議所有實現都應參考規範來落實,在實現過程中可能會出現遺漏或錯誤處理的情況。(ps:考驗一個依賴健壯性的時候到了)
截至目前,都是處理同步的情況。promise 號稱處理非同步的大神,怎麼能少得了相應實現。處理非同步的方式有回撥和訂閱釋出模式,我們實現 promise 就是為了解決回撥地獄的,所以這裡當然選擇使用 訂閱釋出模式。
then 非同步處理
這裡要處理的情況是指:當呼叫 then 方法的 promise 處於 pending 狀態時。
那什麼時候會出現這種情況呢?來看下這段程式碼:
const promise = new Promise(resolve => {
setTimeout(() => {
resolve(1);
}, 1000);
});
promise.then(value => {
console.log(value);
});
複製程式碼
程式碼編寫到這裡,如果出現這種情況。我們的 promise 其實是不能正常工作的。由於 setTimeout
是一個異常操作,當內部 then 方法按同步執行的時候,resolve 根本沒執行,也就是說呼叫 then 方法的 promise 的 [[PromiseStatus]]
目前還處於 'pending',[[PromiseValue]]
目前為 undefined,此時新增對 pending
狀態下的回撥是沒有任何意義的 ,另外規範提及 then 方法的回撥必須處於 settled( 之前有講過 ) 才會呼叫相應回撥。
或者我們不用考慮是不是非同步造成的,只需要明確一件事。存在這麼一種情況,呼叫 then 方法的 promise 狀態可能為pending
。
這時就必須有一套機制來處理這種情況,對應程式碼實現就是:
private subscribe(parent, child, onFulfillment, onRejection) {
let {
subscribes,
subscribes: { length }
} = parent;
subscribes[length] = child;
subscribes[length + PROMISE_STATUS.fulfilled] = onFulfillment;
subscribes[length + PROMISE_STATUS.rejected] = onRejection;
if (length === 0 && PROMISE_STATUS[parent['[[PromiseStatus]]']]) {
this.asap(this.publish);
}
}
複製程式碼
subscribe
接受 4 個引數 parent
,child
,onFulfillment
,onRejection
parent
為當前呼叫 then 方法的 promise 物件child
為即將由 then 方法返回的 promise 物件onFulfillment
then 方法的第一個引數onFulfillment
then 方法的第二個引數
用一個陣列來儲存 subscribe
,主要儲存即將返回的 promise 物件及相應的 onFulfillment
和 onRejection
回撥函式。
滿足 subscribe
是新增的情況及呼叫 then 方法的 promise 物件的 [[PromiseStatus]]
值不為 'pending',則呼叫 publish
方法。也就是說非同步的情況下,不會呼叫該 publish
方法。
這麼看來這個 publish
是跟執行回撥相關的方法。
那非同步的情況,什麼時候會觸發回撥呢?可以回顧之前講解過的 fulfill
方法:
private fulfill(value: any) {
this['[[PromiseStatus]]'] = 'fulfilled';
this['[[PromiseValue]]'] = value;
// 用於處理非同步情況
if (this.subscribes.length !== 0) {
this.asap(this.publish);
}
}
複製程式碼
當滿足 this.subscribes.length !== 0
時會觸發 publish
。也就是說當非同步函式執行完成後呼叫 resolve
方法時會有這麼一個是否呼叫 subscribes
裡面的回撥函式的判斷。
這樣就保證了 then
方法裡回撥函式只會在非同步函式執行完成後觸發。接著來看下與之相關的 publish
方法
publish 方法
首先明確,publish
是釋出,是通過 invokeCallback
來呼叫回撥函式的。在本專案中,只與 subscribes
有關。直接來看下程式碼:
private publish() {
const subscribes = this.subscribes;
const state = this['[[PromiseStatus]]'];
const settled = PROMISE_STATUS[state];
const result = this['[[PromiseValue]]'];
if (subscribes.length === 0) {
return;
}
for (let i = 0; i < subscribes.length; i += 3) {
// 即將返回的 promise 物件
const item = subscribes[i];
const callback = subscribes[i + settled];
if (item) {
this.invokeCallback(state, item, callback, result);
} else {
callback(result);
}
}
this.subscribes.length = 0;
}
複製程式碼
then 方法小結
到這我們就實現了 promise 中的 then 方法,也就意味著目前實現的 promise 已經具備處理非同步資料流的能力了。then 方法的實現離不開規範的指引,只要參考規範對 then 方法的描述,其餘就只是邏輯處理了。
至此 promise 的核心功能已經講完了,也就是內部 [[Resolve]]
和 then
方法。接下來快速看下其餘 API。
語法糖 API 實現
catch 和 finally 都屬於語法糖
-
catch
屬於this.then(null, onRejection)
-
finally
屬於this.then(callback, callback);
promise 還提供了 resolve
,reject
,all
,race
的靜態方法,為了方便鏈式呼叫,上述方法均會返回一個新的 promise 物件用於鏈式呼叫。
之前主要講 resolve
,現在來看下
reject
reject
處理方式跟 resolve
略微不同的是它不用處理 thenable 的情況,規則提及 reject 的值 reason 建議為 error 例項程式碼實現如下:
private mockReject(reason: any) {
this['[[PromiseStatus]]'] = 'rejected';
this['[[PromiseValue]]'] = reason;
this.asap(this.publish);
}
static reject(reason: any) {
let Constructor = this;
let promise = new Constructor(() => {});
promise.mockReject(reason);
return promise;
}
private mockReject(reason: any) {
this['[[PromiseStatus]]'] = 'rejected';
this['[[PromiseValue]]'] = reason;
this.asap(this.publish);
}
複製程式碼
all & race
在前面 API 的基礎上,擴充套件出 all 和 race 並不難。先來看兩者的作用:
-
all 用於處理一組 promise,當滿足所有 promise 都 resolve 或 某個 promise reject 時返回對應由 value 組成的陣列或 reject 的 reason
-
race 賽跑的意思,也就是在一組 promise 中看誰執行的快,則用這個 promise 的結果
對應實現程式碼:
// all
let result = [];
let num = 0;
return new this((resolve, reject) => {
entries.forEach(item => {
this.resolve(item).then(data => {
result.push(data);
num++;
if (num === entries.length) {
resolve(result);
}
}, reject);
});
});
// race
return new this((resolve, reject) => {
let length = entries.length;
for (let i = 0; i < length; i++) {
this.resolve(entries[i]).then(resolve, reject);
}
});
複製程式碼
時序組合
如果有需要按順序執行的非同步函式,可以採用如下方式:
[func1, func2].reduce((p, f) => p.then(f), Promise.resolve());
複製程式碼
在 ES7 中時序組合可以通過使用 async/await
實現
for (let f of [func1, func2]) {
await f();
}
複製程式碼
更多使用方式可參考 這篇
知識補充
Promise 的出現是為了更好地處理非同步資料流,或者常說的回撥地獄。這裡說的回撥,是在非同步的情況下,如果非非同步,則一般不需要用到回撥函式。下面來了解下在實現 Promise 過程中出現的幾個概念:
-
回撥函式
-
非同步 & 同步
-
EventLoop
-
asap
回撥函式
回撥函式的英文定義:
A callback is a function that is passed as an argument to another function and is executed after its parent function has completed。
字面上的理解,回撥函式就是一個引數,將這個函式作為引數傳到另一個函式裡面,當那個函式執行完之後,再執行傳進去的這個函式。這個過程就叫做回撥。
在 JavaScript 中,回撥函式具體的定義為: 函式 A 作為引數(函式引用)傳遞到另一個函式 B 中,並且這個函式 B 執行函式 A。我們就說函式 A 叫做回撥函式。如果沒有名稱(函式表示式),就叫做匿名回撥函式。
首先需要宣告,回撥函式只是一種實現,並不是非同步模式特有的實現。回撥函式同樣可以運用到同步(阻塞)的場景下以及其他一些場景。
回撥函式需要和非同步函式區分開來。
非同步函式 & 同步函式
-
如果在函式返回的時候,呼叫者就能夠得到預期結果,這個就是同步函式。
-
如果在函式返回的時候,呼叫者還不能夠得到預期結果,而是需要在將來通過一定的手段得到,那麼這個函式就是非同步的。
那什麼是非同步,同步呢?
非同步 & 同步
首先要明確,Javascript 語言的執行環境是"單執行緒"(single thread)。所謂"單執行緒",就是指一次只能完成一件任務。如果有多個任務,就必須排隊,前面一個任務完成,再執行後面一個任務,以此類推。
這種模式會造成一個阻塞問題,為了解決這個問題,Javascript 語言將任務的執行模式分成兩種:同步(Synchronous)和非同步(Asynchronous)。
但需要注意的是:非同步機制是瀏覽器的兩個或以上常駐執行緒共同完成的,Javascript 的單執行緒和非同步更多的應該是屬於瀏覽器的行為。也就是說 Javascript 本身是單執行緒的,並沒有非同步的特性。
由於 Javascript 的運用場景是瀏覽器,瀏覽器本身是典型的 GUI 工作執行緒,GUI 工作執行緒在絕大多數系統中都實現為事件處理,避免阻塞互動,因此產生了 Javascript 非同步基因。所有涉及到非同步的方法和函式都是由瀏覽器的另一個執行緒去執行的。
瀏覽器中的執行緒
-
瀏覽器事件觸發執行緒 當一個事件被觸發時該執行緒會把事件新增到待處理佇列的隊尾,等待 JavaScript 引擎的處理。這些事件可以是當前執行的程式碼塊如:定時任務、也可來自瀏覽器核心的其他執行緒如滑鼠點選、AJAX 非同步請求等,但由於 JavaScript 是單執行緒,所有這些事件都得排隊等待 JavaScript 引擎處理;
-
定時觸發器執行緒 瀏覽器定時計數器並不是由 JavaScript 引擎計數的, 因為 JavaScript 引擎是單執行緒的, 如果處於阻塞執行緒狀態就會影響記計時的準確, 因此通過單獨執行緒來計時並觸發定時是更為合理的方案;
-
非同步 HTTP 請求執行緒 XMLHttpRequest 在連線後是通過瀏覽器新開一個執行緒請求,將檢測到狀態變更時,如果設定有回撥函式,非同步執行緒就產生狀態變更事件放到 JavaScript 引擎的處理佇列中 等待處理;
通過以上了解,可以知道其實 JavaScript 是通過 JS 引擎執行緒與瀏覽器中其他執行緒互動協作實現非同步。
但是回撥函式具體何時加入到 JS 引擎執行緒中執行?執行順序是怎麼樣的?
接下來了解一下,與之相關的 EventLoop 機制
EventLoop
先來看一些概念,Stack,Heap,Queue,直接上圖:
-
那些不需要回撥函式的操作都可歸為 Stack 這一類
-
Heap 用來儲存宣告的變數、物件
-
一旦某個非同步任務有了響應就會被推入 Queue 佇列中
一個大致的流程如下:
JS 引擎執行緒用來執行棧中的同步任務,當所有同步任務執行完畢後,棧被清空,然後讀取訊息佇列中的一個待處理任務,並把相關回撥函式壓入棧中,單執行緒開始執行新的同步任務。
JS 引擎執行緒從訊息佇列中讀取任務是不斷迴圈的,每次棧被清空後,都會在訊息佇列中讀取新的任務,如果沒有新的任務,就會等待,直到有新的任務,這就叫事件迴圈。
這張圖不知道誰畫的,真的是非常棒!先借來描述下 AJAX 大概的流程:
AJAX 請求屬於非常耗時的非同步操作,瀏覽器有提供專門的執行緒來處理它。當主執行緒上有呼叫 AJAX 的程式碼時,會觸發非同步任務。執行這個非同步任務的工作交由 AJAX 執行緒,主執行緒並沒有等待這個非同步操作的結果而是接著執行。假設主執行緒程式碼在某個時刻執行完畢,也就是此時的 Stack 為空。而在早些時刻,非同步任務執行完成後已經將訊息存放在 Queue 中,以便 Stack 為空時從中拿去一個回撥函式來執行。
這背後運作的就叫 EventLoop
,有了上面的大致瞭解。下面來重新瞭解下 EventLoop:
非同步背後的“靠山”就是 event loops。這裡的非同步準確的說應該叫瀏覽器的 event loops 或者說是 javaScript 執行環境的 event loops,因為 ECMAScript 中沒有 event loops,event loops 是在 HTML Standard 定義的。
event loop 翻譯出來就是事件迴圈,可以理解為實現非同步的一種方式,我們來看看 event loop 在 HTML Standard 中的定義章節:
為了協調事件,使用者互動,指令碼,渲染,網路等,使用者代理必須使用本節所述的 event loop。
事件,使用者互動,指令碼,渲染,網路這些都是我們所熟悉的東西,他們都是由 event loop 協調的。觸發一個 click 事件,進行一次 ajax 請求,背後都有 event loop 在運作。
task
一個 event loop 有一個或者多個 task 佇列。每一個 task 都來源於指定的任務源,比如可以為滑鼠、鍵盤事件提供一個 task 佇列,其他事件又是一個單獨的佇列。可以為滑鼠、鍵盤事件分配更多的時間,保證互動的流暢。
task 也被稱為macrotask,task 佇列還是比較好理解的,就是一個先進先出的佇列,由指定的任務源去提供任務。
task 任務源:
- setTimeout
- setInterval
- setImmediate
- I/O
- UI rendering
microtask
每一個 event loop 都有一個 microtask 佇列,一個 microtask 會被排進 microtask 佇列而不是 task 佇列。microtask 佇列和 task 佇列有些相似,都是先進先出的佇列,由指定的任務源去提供任務,不同的是一個 event loop 裡只有一個 microtask 佇列。
通常認為是 microtask 任務源有:
- process.nextTick
- promises
- Object.observe
- MutationObserver
一道關於 EventLoop 的面試題
console.log('start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve()
.then(function() {
console.log('promise1');
})
.then(function() {
console.log('promise2');
});
console.log('end');
// start
// end
// promise1
// promise2
// setTimeout
複製程式碼
上面的順序是在 chrome 執行得出的,有趣的是在 safari 9.1.2 中測試,promise1 promise2 會在 setTimeout 的後邊,而在 safari 10.0.1 中得到了和 chrome 一樣的結果。promise 在不同瀏覽器的差異正源於有的瀏覽器將 then 放入了 macro-task 佇列,有的放入了 micro-task 佇列。
EventLoop 小結
event loop 涉及到的東西很多,但本文怕重點偏離,這裡只是提及可能與 promise 相關的知識點。如果想深入瞭解的同學,建議看完這篇必定會受益匪淺。
什麼是 asap
as soon as possible
英文的縮寫,在 promise 中起到的作用是儘快響應變化。
在 Promises/A+規範 的 Notes 3.1 中提及了 promise 的 then 方法可以採用“巨集任務(macro-task)”機制或者“微任務(micro-task)”機制來實現。
本專案,採用 macro-task
機制
private asap(callback) {
setTimeout(() => {
callback.call(this);
}, 1);
}
複製程式碼
或者 MutationObserver 模式:
function flush() {
...
}
function useMutationObserver() {
var iterations = 0;
var observer = new MutationObserver(flush);
var node = document.createTextNode('');
observer.observe(node, { characterData: true });
return function () {
node.data = iterations = ++iterations % 2;
};
}
複製程式碼
初次看這個 useMutationObserver
函式總會很有疑惑,MutationObserver
不是用來觀察 dom 的變化的嗎,這樣憑空造出一個節點來反覆修改它的內容,來觸發觀察的回撥函式有何意義?
答案就是使用 Mutation
事件可以非同步執行操作(例子中的 flush 函式),一是可以儘快響應變化,二是可以去除重複的計算。
或者 node 環境下:
function useNextTick() {
return () => process.nextTick(flush);
}
複製程式碼
前端安全感
現如今前端處於模組化開發的鼎盛時期,面對 npm 那成千上萬的 "名牌包包" 很多像我這樣的小白難免會迷失自我,漸漸沒有了安全感。
是做拿來主義,還是像筆者一樣偶爾研究下底層原理,看看別人的程式碼,自己擼一遍來感悟其中的真諦來提升安全感。
做前端也有段時日了,被業務埋沒的那些年,真是感嘆!回想曾經差點去做設計的我終於要入門前端了,心中難免想去搓一頓,那就寫到這吧!
結語
寫的有點多,好累。希望對大家有所幫助。