Promise 可簡化延遲和非同步運算。Promise 代表尚未完成的作業。
開發人員,準備好迎接網頁開發史上最重要的時刻。
[Drumroll begins]
JavaScript 支援 Promise 了!
[Fireworks explode, glittery paper rains from above, the crowd goes wild]
此時,您會屬於下列其中一個類別:
- 周圍的人都在歡呼,但你不知道發生了什麼事。你甚至可能不確定「承諾」是什麼。你聳聳肩,但閃亮紙張的重量壓在你的肩上。如果沒有,也別擔心,我花了很長的時間才弄清楚為什麼應該重視這些東西。建議您先從開頭開始。
- 你揮拳慶祝!這項功能終於推出了,您之前使用過這些 Promise 項目,但所有實作項目都有稍微不同的 API,這讓您感到困擾。官方 JavaScript 版本的 API 是什麼?建議您先瞭解術語。
- 你早就知道這件事,而且對於那些像發現新大陸一樣興奮的人嗤之以鼻。請先享受一下自己的優越感,然後直接前往 API 參考資料。
瀏覽器支援和 Polyfill
如要讓缺少完整 Promise 實作的瀏覽器符合規格,或在其他瀏覽器和 Node.js 中新增 Promise,請查看這個 Polyfill (2k gzipped)。
這有什麼好大驚小怪的?
JavaScript 是單一執行緒,表示兩段指令碼無法同時執行,必須依序執行。在瀏覽器中,JavaScript 會與大量其他項目共用執行緒,這些項目因瀏覽器而異。但通常 JavaScript 會與繪製、更新樣式及處理使用者動作 (例如醒目顯示文字和與表單控制項互動) 位於同一個佇列。其中一項活動會延遲其他活動。
身為人類,你具有多執行緒能力。你可以用多根手指打字,同時開車和進行對話。我們唯一需要處理的封鎖函式是打噴嚏,因為打噴嚏時必須暫停所有目前的活動。這相當惱人,尤其是在開車時想與人交談時。您不會想編寫容易出錯的程式碼。
您可能已使用事件和回呼來解決這個問題。以下是相關事件:
var img1 = document.querySelector('.img-1');
img1.addEventListener('load', function() {
// woo yey image loaded
});
img1.addEventListener('error', function() {
// argh everything's broken
});
這完全不會讓人想打噴嚏。我們取得圖片、新增幾個監聽器,然後 JavaScript 可以停止執行,直到其中一個監聽器遭到呼叫為止。
很遺憾,在上述範例中,事件可能發生在我們開始監聽事件之前,因此我們需要使用圖片的「complete」屬性來解決這個問題:
var img1 = document.querySelector('.img-1');
function loaded() {
// woo yey image loaded
}
if (img1.complete) {
loaded();
}
else {
img1.addEventListener('load', loaded);
}
img1.addEventListener('error', function() {
// argh everything's broken
});
這不會擷取我們有機會監聽之前發生錯誤的圖片;很遺憾,DOM 無法提供這類做法。此外,這項作業會載入一張圖片。如果想知道一組圖片何時載入,情況會更加複雜。
事件不一定是最佳做法
事件非常適合用於在同一物件上發生多次的動作,例如 keyup
、touchstart
等。對於這些事件,您其實不在意附加監聽器之前發生了什麼事。但如果是非同步成功/失敗,理想情況下您會希望看到類似下列內容:
img1.callThisIfLoadedOrWhenLoaded(function() {
// loaded
}).orIfFailedCallThis(function() {
// failed
});
// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
// all loaded
}).orIfSomeFailedCallThis(function() {
// one or more failed
});
這就是 Promise 的用途,但命名方式更完善。如果 HTML 圖片元素有傳回 Promise 的「ready」方法,我們就可以執行下列操作:
img1.ready()
.then(function() {
// loaded
}, function() {
// failed
});
// and…
Promise.all([img1.ready(), img2.ready()])
.then(function() {
// all loaded
}, function() {
// one or more failed
});
從最基本的角度來看,Promise 有點類似事件監聽器,但有以下例外狀況:
- Promise 只能成功或失敗一次。該作業不會成功或失敗兩次,也不會從成功切換為失敗,反之亦然。
- 如果 Promise 成功或失敗,而您稍後新增成功/失敗回呼,即使事件發生在稍早,系統仍會呼叫正確的回呼。
這對非同步成功/失敗來說非常實用,因為您對某個項目何時可供使用不太感興趣,而是對結果的反應更感興趣。
Promise 術語
Domenic Denicola 校對了本文初稿,並因術語問題給了我「F」的成績。他讓我留校察看,強迫我抄寫 100 次《States and Fates》,還寫信給我的父母,表示很擔心我。儘管如此,我還是經常搞混這些術語,但以下是基本概念:
承諾可以是:
- fulfilled - 與承諾相關的動作已成功完成
- rejected - The action relating to the promise failed
- 待處理:尚未完成或拒絕
- 已結算 - 已完成或拒絕
規格也使用「thenable」一詞來描述類似 Promise 的物件,因為這類物件具有 then
方法。這個詞讓我想起前英格蘭足球隊總教練 Terry Venables,因此我會盡量少用。
JavaScript 支援 Promise!
Promise 已存在一段時間,並以程式庫的形式提供,例如:
上述 Promise 和 JavaScript Promise 具有共同的標準化行為,稱為 Promises/A+。如果您是 jQuery 使用者,則有類似的項目,稱為「延遲」。不過,Deferreds 不符合 Promise/A+ 規範,因此略有不同且實用性較低,請多加留意。jQuery 也有 Promise 型別,但這只是 Deferred 的子集,且有相同問題。
雖然 Promise 實作遵循標準化行為,但整體 API 不同。JavaScript Promise 的 API 與 RSVP.js 類似。建立 Promise 的方法如下:
var promise = new Promise(function(resolve, reject) {
// do a thing, possibly async, then…
if (/* everything turned out fine */) {
resolve("Stuff worked!");
}
else {
reject(Error("It broke"));
}
});
Promise 建構函式會採用一個引數,也就是含有兩個參數 (resolve 和 reject) 的回呼。在回呼中執行某些動作 (可能是非同步),然後在一切順利時呼叫 resolve,否則呼叫 reject。
如同舊版 JavaScript 中的 throw
,以 Error 物件拒絕是慣例,但並非必要。錯誤物件的優點是會擷取堆疊追蹤記錄,讓偵錯工具更加實用。
使用該 Promise 的方式如下:
promise.then(function(result) {
console.log(result); // "Stuff worked!"
}, function(err) {
console.log(err); // Error: "It broke"
});
then()
會採用兩個引數,一個是成功案例的回呼,另一個是失敗案例的回呼。這兩者都是選用項目,因此您可以只為成功或失敗案例新增回呼。
JavaScript Promise 最初是 DOM 中的「Futures」,後來重新命名為「Promises」, 最後移至 JavaScript。將這些 API 放在 JavaScript 中而非 DOM 中,好處是可在非瀏覽器 JS 環境 (例如 Node.js) 中使用 (是否在核心 API 中使用則是另一個問題)。
雖然 DOM 是 JavaScript 功能,但仍可放心使用。事實上,所有具有非同步成功/失敗方法的新 DOM API 都會使用 Promise。這項措施已在配額管理、字型載入事件、ServiceWorker、Web MIDI、Streams 等功能中實施。
與其他程式庫的相容性
JavaScript Promise API 會將任何具有 then()
方法的項目視為類似 Promise (或 Promise 說法中的 thenable
sigh),因此如果您使用會傳回 Q Promise 的程式庫,這沒問題,它會與新的 JavaScript Promise 順利運作。
不過,如我所說,jQuery 的 Deferreds 有點…沒什麼幫助。幸好您可以將這些項目轉換為標準 Promise,建議您盡快執行這項操作:
var jsPromise = Promise.resolve($.ajax('/whatever.json'))
在這裡,jQuery 的 $.ajax
會傳回 Deferred。由於它有 then()
方法,Promise.resolve()
可以將其轉換為 JavaScript Promise。不過,有時延遲會將多個引數傳遞至回呼,例如:
var jqDeferred = $.ajax('/whatever.json');
jqDeferred.then(function(response, statusText, xhrObj) {
// ...
}, function(xhrObj, textStatus, err) {
// ...
})
但 JS Promise 會忽略第一個以外的所有項目:
jsPromise.then(function(response) {
// ...
}, function(xhrObj) {
// ...
})
幸好這通常是您想要的結果,或至少能讓您存取想要的內容。另請注意,jQuery 不會遵循將 Error 物件傳遞至拒絕的慣例。
簡化複雜的非同步程式碼
好,我們來編寫一些程式碼。假設我們想要:
- 啟動微調器,表示正在載入
- 擷取故事的 JSON,其中包含標題和各章節的網址
- 為頁面新增標題
- 擷取每個章節
- 將故事新增至頁面
- 停止轉輪
…但如果過程中發生錯誤,也請告知使用者。我們也想在該時間點停止微調器,否則微調器會持續微調、暈眩,並撞上其他 UI。
當然,您不會使用 JavaScript 傳送故事,因為以 HTML 形式放送速度更快,但處理 API 時,這種模式相當常見:多次擷取資料,然後在所有資料擷取完畢後執行某些動作。
首先,我們來處理從網路擷取資料的問題:
將 XMLHttpRequest 轉換為 Promise
如果能以回溯相容的方式更新舊版 API,就會改用 Promise。XMLHttpRequest
是主要候選項目,但在此期間,我們先編寫簡單函式來發出 GET 要求:
function get(url) {
// Return a new promise.
return new Promise(function(resolve, reject) {
// Do the usual XHR stuff
var req = new XMLHttpRequest();
req.open('GET', url);
req.onload = function() {
// This is called even on 404 etc
// so check the status
if (req.status == 200) {
// Resolve the promise with the response text
resolve(req.response);
}
else {
// Otherwise reject with the status text
// which will hopefully be a meaningful error
reject(Error(req.statusText));
}
};
// Handle network errors
req.onerror = function() {
reject(Error("Network Error"));
};
// Make the request
req.send();
});
}
現在來使用這項功能:
get('story.json').then(function(response) {
console.log("Success!", response);
}, function(error) {
console.error("Failed!", error);
})
現在我們不必手動輸入 XMLHttpRequest
即可發出 HTTP 要求,這很棒,因為我越少看到 XMLHttpRequest
令人惱火的駝峰式大小寫,生活就越快樂。
鏈結
then()
並非故事的結尾,您可以將 then
串連在一起,轉換值或依序執行其他非同步動作。
轉換值
只要傳回新值,即可轉換值:
var promise = new Promise(function(resolve, reject) {
resolve(1);
});
promise.then(function(val) {
console.log(val); // 1
return val + 2;
}).then(function(val) {
console.log(val); // 3
})
以實際範例來說,我們回到:
get('story.json').then(function(response) {
console.log("Success!", response);
})
回覆是 JSON,但我們目前收到的是純文字。我們可以變更 get 函式來使用 JSON responseType
,但也可以在 Promise 領域中解決這個問題:
get('story.json').then(function(response) {
return JSON.parse(response);
}).then(function(response) {
console.log("Yey JSON!", response);
})
由於 JSON.parse()
採用單一引數並傳回轉換後的值,因此我們可以建立捷徑:
get('story.json').then(JSON.parse).then(function(response) {
console.log("Yey JSON!", response);
})
事實上,我們可以輕鬆建立 getJSON()
函式:
function getJSON(url) {
return get(url).then(JSON.parse);
}
getJSON()
仍會傳回 Promise,但會擷取網址,然後將回應剖析為 JSON。
將非同步動作加入佇列
您也可以鏈結 then
,依序執行非同步動作。
從 then()
回呼傳回項目時,會發生一些神奇的事。如果傳回值,系統會使用該值呼叫下一個 then()
。不過,如果您傳回類似 Promise 的內容,下一個 then()
會等待該內容,且只有在該 Promise 結算 (成功/失敗) 時才會呼叫。例如:
getJSON('story.json').then(function(story) {
return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
console.log("Got chapter 1!", chapter1);
})
我們在這裡對 story.json
發出非同步要求,取得一組要要求使用的網址,然後要求第一個網址。這時,Promise 真的開始從簡單的回呼模式中脫穎而出。
您甚至可以建立捷徑方法來取得章節:
var storyPromise;
function getChapter(i) {
storyPromise = storyPromise || getJSON('story.json');
return storyPromise.then(function(story) {
return getJSON(story.chapterUrls[i]);
})
}
// and using it is simple:
getChapter(0).then(function(chapter) {
console.log(chapter);
return getChapter(1);
}).then(function(chapter) {
console.log(chapter);
})
我們會等到呼叫 getChapter
時才下載 story.json
,但下次呼叫 getChapter
時,我們會重複使用故事 Promise,因此只會擷取一次 story.json
。Yay Promises!
處理錯誤
如先前所述,then()
會採用兩個引數,一個用於成功,一個用於失敗 (或以 Promise 術語來說,就是完成和拒絕):
get('story.json').then(function(response) {
console.log("Success!", response);
}, function(error) {
console.log("Failed!", error);
})
你也可以使用 catch()
:
get('story.json').then(function(response) {
console.log("Success!", response);
}).catch(function(error) {
console.log("Failed!", error);
})
catch()
沒有特別之處,只是 then(undefined, func)
的語法糖,但更容易閱讀。請注意,上述兩個程式碼範例的行為並不相同,後者相當於:
get('story.json').then(function(response) {
console.log("Success!", response);
}).then(undefined, function(error) {
console.log("Failed!", error);
})
兩者差異不大,但非常實用。Promise 拒絕會略過,並以拒絕回呼 (或 catch()
,因為兩者等效) 轉送至下一個 then()
。使用 then(func1, func2)
時,系統會呼叫 func1
或 func2
,但不會同時呼叫兩者。但使用 then(func1).catch(func2)
時,如果 func1
拒絕,兩者都會被呼叫,因為這是鏈結中的個別步驟。請拍攝下列內容:
asyncThing1().then(function() {
return asyncThing2();
}).then(function() {
return asyncThing3();
}).catch(function(err) {
return asyncRecovery1();
}).then(function() {
return asyncThing4();
}, function(err) {
return asyncRecovery2();
}).catch(function(err) {
console.log("Don't worry about it");
}).then(function() {
console.log("All done!");
})
上述流程與一般的 JavaScript try/catch 非常相似,在「try」中發生的錯誤會立即前往 catch()
區塊。以下是上述內容的流程圖 (因為我喜歡流程圖):
藍線代表已完成的 Promise,紅線則代表遭拒的 Promise。
JavaScript 例外狀況和 Promise
如果明確拒絕承諾,或在建構函式回呼中擲回錯誤,系統也會隱含拒絕承諾:
var jsonPromise = new Promise(function(resolve, reject) {
// JSON.parse throws an error if you feed it some
// invalid JSON, so this implicitly rejects:
resolve(JSON.parse("This ain't JSON"));
});
jsonPromise.then(function(data) {
// This never happens:
console.log("It worked!", data);
}).catch(function(err) {
// Instead, this happens:
console.log("It failed!", err);
})
也就是說,在 Promise 建構函式回呼中完成所有與 Promise 相關的工作,有助於自動擷取錯誤並成為拒絕。
then()
回呼中擲回的錯誤也適用相同原則。
get('/').then(JSON.parse).then(function() {
// This never happens, '/' is an HTML page, not JSON
// so JSON.parse throws
console.log("It worked!", data);
}).catch(function(err) {
// Instead, this happens:
console.log("It failed!", err);
})
實際處理錯誤
有了故事和章節,我們可以使用 catch 向使用者顯示錯誤:
getJSON('story.json').then(function(story) {
return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
addHtmlToPage(chapter1.html);
}).catch(function() {
addTextToPage("Failed to show chapter");
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
如果擷取 story.chapterUrls[0]
失敗 (例如 http 500 或使用者離線),系統會略過所有後續的成功回呼,包括 getJSON()
中嘗試將回應剖析為 JSON 的回呼,以及將 chapter1.html 新增至網頁的回呼。而是會移至 catch 回呼。因此,如果先前的任何動作失敗,頁面就會顯示「無法顯示章節」。
如同 JavaScript 的 try/catch,系統會擷取錯誤並繼續執行後續程式碼,因此微調器一律會隱藏,這正是我們想要的結果。上述內容會變成非封鎖的非同步版本:
try {
var story = getJSONSync('story.json');
var chapter1 = getJSONSync(story.chapterUrls[0]);
addHtmlToPage(chapter1.html);
}
catch (e) {
addTextToPage("Failed to show chapter");
}
document.querySelector('.spinner').style.display = 'none'
您可能只是想記錄 catch()
,而不想從錯誤中復原。如要這麼做,只要重新擲回錯誤即可。我們可以在 getJSON()
方法中執行這項操作:
function getJSON(url) {
return get(url).then(JSON.parse).catch(function(err) {
console.log("getJSON failed for", url, err);
throw err;
});
}
因此我們只擷取到一個章節,但我們想要擷取所有章節。讓我們實現這個目標。
平行處理和排序:兼具兩者的優點
非同步思考並不容易。如果難以起步,請試著撰寫程式碼,就好像是同步一樣。在這種情況下:
try {
var story = getJSONSync('story.json');
addHtmlToPage(story.heading);
story.chapterUrls.forEach(function(chapterUrl) {
var chapter = getJSONSync(chapterUrl);
addHtmlToPage(chapter.html);
});
addTextToPage("All done");
}
catch (err) {
addTextToPage("Argh, broken: " + err.message);
}
document.querySelector('.spinner').style.display = 'none'
這樣就可以了!但會同步處理,並在下載時鎖定瀏覽器。如要以非同步方式執行這項作業,請使用 then()
依序執行作業。
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
// TODO: for each url in story.chapterUrls, fetch & display
}).then(function() {
// And we're all done!
addTextToPage("All done");
}).catch(function(err) {
// Catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
// Always hide the spinner
document.querySelector('.spinner').style.display = 'none';
})
但如何依序擷取章節網址並逐一處理?以下做法無效:
story.chapterUrls.forEach(function(chapterUrl) {
// Fetch chapter
getJSON(chapterUrl).then(function(chapter) {
// and add it to the page
addHtmlToPage(chapter.html);
});
})
forEach
不支援非同步作業,因此章節會依下載順序顯示,這基本上就是《黑色追緝令》的編劇方式。這不是《黑色追緝令》,所以讓我們修正這個問題。
建立序列
我們想將 chapterUrls
陣列轉換為一連串的 Promise。我們可以使用 then()
執行這項操作:
// Start off with a promise that always resolves
var sequence = Promise.resolve();
// Loop through our chapter urls
story.chapterUrls.forEach(function(chapterUrl) {
// Add these actions to the end of the sequence
sequence = sequence.then(function() {
return getJSON(chapterUrl);
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
})
這是我們第一次看到 Promise.resolve()
,這個函式會建立 Promise,並解析為您提供的任何值。如果您傳遞 Promise
的例項,系統只會傳回該例項 (注意:這是規格的變更,部分實作項目尚未遵循)。如果您傳遞類似 Promise 的內容 (具有 then()
方法),系統會建立真正的 Promise
,並以相同方式完成/拒絕。如果您傳遞任何其他值,例如 Promise.resolve('Hello')
,系統會建立以該值完成的 Promise。如未提供值就呼叫這個函式 (如上所示),系統會以「undefined」填入值。
此外,Promise.reject(val)
會建立承諾,並以您提供的值 (或未定義) 拒絕。
我們可以使用 array.reduce
整理上述程式碼:
// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
// Add these actions to the end of the sequence
return sequence.then(function() {
return getJSON(chapterUrl);
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve())
這與上一個範例的作用相同,但不需要個別的「sequence」變數。系統會針對陣列中的每個項目呼叫 reduce 回呼。
「sequence」Promise.resolve()
是第一次,但其餘呼叫的「sequence」是我們從上一次呼叫傳回的內容。array.reduce
非常適合將陣列縮減為單一值,在本例中為 Promise。
讓我們將所有內容整合在一起:
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
return story.chapterUrls.reduce(function(sequence, chapterUrl) {
// Once the last chapter's promise is done…
return sequence.then(function() {
// …fetch the next chapter
return getJSON(chapterUrl);
}).then(function(chapter) {
// and add it to the page
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
// And we're all done!
addTextToPage("All done");
}).catch(function(err) {
// Catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
// Always hide the spinner
document.querySelector('.spinner').style.display = 'none';
})
這樣就完成了,這就是同步版本的完整非同步版本。但我們可以做得更好。目前網頁的下載方式如下:
瀏覽器很擅長一次下載多個項目,因此我們逐一下載章節會導致效能降低。我們希望同時下載所有檔案,然後在全部下載完成後再進行處理。 幸好有相關 API 可供使用:
Promise.all(arrayOfPromises).then(function(arrayOfResults) {
//...
})
Promise.all
會採用一系列 Promise,並在所有 Promise 成功完成時建立 Promise。您會取得一系列結果 (無論承諾完成的內容為何),順序與您傳入的承諾相同。
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
// Take an array of promises and wait on them all
return Promise.all(
// Map our array of chapter urls to
// an array of chapter json promises
story.chapterUrls.map(getJSON)
);
}).then(function(chapters) {
// Now we have the chapters jsons in order! Loop through…
chapters.forEach(function(chapter) {
// …and add to the page
addHtmlToPage(chapter.html);
});
addTextToPage("All done");
}).catch(function(err) {
// catch any error that happened so far
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
視連線情況而定,這項做法比逐一載入圖片快上幾秒,而且程式碼比第一次嘗試時更少。章節可以依任意順序下載,但會依正確順序顯示在畫面上。
不過,我們還是可以提升感知效能。第一章推出後,我們應將其新增至頁面。這樣一來,使用者就能在其他章節送達前開始閱讀。如果第三章推出,我們不會將其新增至頁面,因為使用者可能不會發現缺少第二章。第二章推出時,我們可以加入第二章和第三章,依此類推。
為此,我們會同時擷取所有章節的 JSON,然後建立序列,將這些章節新增至文件:
getJSON('story.json')
.then(function(story) {
addHtmlToPage(story.heading);
// Map our array of chapter urls to
// an array of chapter json promises.
// This makes sure they all download in parallel.
return story.chapterUrls.map(getJSON)
.reduce(function(sequence, chapterPromise) {
// Use reduce to chain the promises together,
// adding content to the page for each chapter
return sequence
.then(function() {
// Wait for everything in the sequence so far,
// then wait for this chapter to arrive.
return chapterPromise;
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
addTextToPage("All done");
}).catch(function(err) {
// catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
這樣就大功告成,兩全其美!傳送所有內容所需的時間相同,但使用者會更快收到第一段內容。
在這個簡單的範例中,所有章節幾乎同時抵達,但如果章節數量更多、更大,一次顯示一個章節的好處就會更加明顯。
使用 Node.js 樣式的回呼或事件執行上述操作時,程式碼會增加一倍左右,但更重要的是,這類程式碼較難追蹤。不過,這並非 Promise 的最終樣貌,搭配其他 ES6 功能使用時,Promise 會更加簡單易用。
加分題:擴充功能
自我撰寫本文以來,使用 Promise 的功能已大幅擴展。自 Chrome 55 起,非同步函式允許以同步方式編寫以 Promise 為基礎的程式碼,但不會封鎖主執行緒。詳情請參閱這篇非同步函式文章。主要瀏覽器廣泛支援 Promise 和 async 函式。詳情請參閱 MDN 的「Promise」和「async 函式」參考資料。
感謝 Anne van Kesteren、Domenic Denicola、Tom Ashworth、Remy Sharp、Addy Osmani、Arthur Evans 和 Yutaka Hirano 校對本文,並提供修正內容/建議。
此外,也感謝 Mathias Bynens更新文章的各個部分。