深入理解Javascript之Promise

darjun發表於2018-11-13

目錄:

1.概述

相信大家都聽過Node中著名的回撥地獄(callback hell)。因為Node中的操作預設都是非同步執行的,所以需要呼叫者傳入一個回撥函式以便在操作結束時進行相應的處理。當回撥的層次變多,程式碼就變得越來越難以編寫、理解和閱讀。

Promise是ES6中新增的一種非同步程式設計的方式,用於解決回撥的方式的各種問題,提供了更多的可能性。其實早在ES6之前,社群就已經有多種Promise的實現方式了:

以上幾種Promise庫都遵循Promise/A+規範。ES6也採用了該規範,所以這些實現的API都是類似的,可以相互對照學習。

Promise表示的是一個計算結果或網路請求的佔位符。由於當前計算或網路請求尚未完成,所以結果暫時無法取得。

Promise物件一共有3中狀態,pendingfullfilled(又稱為resolved)和rejected

  • pending——任務仍在進行中。
  • resolved——任務已完成。
  • reject——任務出錯。

Promise物件初始時處於pending狀態,其生命週期內只可能發生以下一種狀態轉換:

  • 任務完成,狀態由pending轉換為resolved
  • 任務出錯返回,狀態由pending轉換為rejected

Promise物件的狀態轉換一旦發生,就不可再次更改。這或許就是Promise之“承諾”的含義吧。

2.基本用法

2.1 建立Promise

Javascript提供了Promise建構函式用於建立Promise物件。格式如下:

let p = new Promise(executor(resolve, reject));
複製程式碼

程式碼中executor是使用者自定義的函式,用於實現具體非同步操作流程。該函式有兩個引數resolvereject,它們是Javascript引擎提供的函式,不需要使用者實現。在executor函式中,如果非同步操作成功,則呼叫resolvePromise的狀態轉換為resolvedresolve函式以結果資料作為引數。如果非同步操作失敗,則呼叫rejectPromise的狀態轉換為rejectedreject函式以具體錯誤物件作為引數。

2.2 then方法

Promise物件建立完成之後,我們需要呼叫then(succ_handler, fail_handler)方法指定成功和/或失敗的回撥處理。例如:

let p = new Promise(function(resolve, reject) {
    resolve("finished");
});

p.then(function (data) {
    console.log(data); // 輸出finished
}, function (err) {
    console.log("oh no, ", err.message);
});
複製程式碼

在上面的程式碼中,我們建立了一個Promise物件,在executor函式中呼叫resolve將該物件狀態轉換為resolved

進而then指定的成功回撥函式被呼叫,輸出finished

let p = new Promise(function(resolve, reject) {
    reject(new Error("something be wrong"));
});

p.then(function (data) {
    console.log(data);
}, function (err) {
    console.log("oh no, ", err); // 輸出oh no,  something be wrong
});
複製程式碼

以上程式碼中,在executor函式中呼叫rejectPromise物件狀態轉換為rejected

進而then指定的失敗回撥函式被呼叫,輸出oh no, something be wrong

這就是最基本的使用Promise編寫非同步處理的方式了。但是,有幾點需要注意:

(1) then方法可以只傳入成功或失敗回撥。

(2)executor函式是立即執行的,而成功或失敗的回撥函式會到當前EventLoop的最後再執行。下面的程式碼可以驗證這一點:

let p = new Promise(function(resolve, reject) {
    console.log("promise constructor");
    resolve("finished");
});

p.then(function (data) {
    console.log(data);
});

console.log("end");
複製程式碼

輸出結果為:

promise constructor
end
finished
複製程式碼

(3) then方法返回的是一個新的Promise物件,所以可以鏈式呼叫:

let p = new Promise(function(resolve) {
    resolve(5);
});

p.then(function (data) {
    return data * 2;
})
 .then(function (data) {
    console.log(data); // 輸出10
});
複製程式碼

(4)Promise物件的then方法可以被呼叫多次,而且可以被重複呼叫(不同於事件,同一個事件的回撥只會被呼叫一次。)。

let p = new Promise(function(resolve) {
    resolve("repeat");
});

p.then(function (data) {
    console.log(data);
});

p.then(function (data) {
    console.log(data);
});

p.then(function (data) {
    console.log(data);
});
複製程式碼

輸出:

repeat
repeat
repeat
複製程式碼

2.3 catch方法

由前面的介紹,我們知道,可以由then方法指定錯誤處理。但是ES6提供了一個更好用的方法catch。直觀上理解可以認為catch(handler)等同於then(null, handler)

let p = new Promise(function(resolve, reject) {
    reject(new Error("something be wrong"));
});

p.catch(function (err) {
    console.log("oh no, ", err.message); // 輸出oh no, something be wrong
});
複製程式碼

通常不建議在then方法中指定錯誤處理,而是在呼叫鏈的最後增加一個catch方法用於處理前面的步驟中出現的錯誤。

使用時注意一下幾點:

  • then方法指定兩個處理函式,呼叫成功處理函式丟擲異常時,失敗處理函式不會被呼叫

  • Promise中未被處理的異常不會終止當前的執行流程,也就是說Promise會**“吞掉異常”**。

let p = new Promise(function (resolve, reject) {
    throw new Error("something be wrong");
});

p.then(function (data) {
    console.log(data);
});

console.log("end");
// 程式正常結束,輸出end
複製程式碼

2.4 其他建立Promise物件的方式

除了Promise建構函式,ES6還提供了兩個簡單易用的建立Promise物件的方式,即Promise.resolvePromise.reject

Promise.resolve

顧名思義,Promise.resolve建立一個resolved狀態的Promise物件:

let p = Promise.resolve("hello");

p.then(function (data) {
    console.log(data); // 輸出hello
});
複製程式碼

Promise.resolve的引數分為以下幾種型別:

(1)引數是一個Promise物件,那麼直接返回該物件。

(2) 引數是一個thenable物件,即擁有then函式的物件。這時Promise.resolve會將該物件轉換為一個Promise物件,並且立即執行其then函式。

let thenable = {
    then: function (resolve, reject) {
        resolve(25);
    };
};

let p = Promise.resolve(thenable);

p.then(function (data) {
    console.log(data); // 輸出25
});
複製程式碼

(3)其他引數(無引數相當於有一個undefined引數),建立一個狀態為resolvedPromise物件,引數作為操作結果會傳遞給後續回撥處理。

Promise.reject

Promise.reject不管引數為何種型別,都是建立一個狀態為rejectedPromise物件。

3.高階用法

3.1 Flatten Promise

then方法的成功回撥函式可以返回一個新的Promise物件,這時舊的Promise物件將會被凍結,其狀態取決於新Promise物件的狀態。

let p1 = new Promise(function (resolve) {
    setTimeout(function () {
        resolve("promise1");
    }, 3000);
});

let p2 = new Promise(function (resolve) {
    resolve("promise2");
});

p2.then(function (data) {
    return p1;  // (A)
})
  .then(function (data) { // (B)
    console.log(data); // 輸出promise2
});
複製程式碼

我們在(A)行直接返回了另一個Promise物件。後面的then方法執行取決於該物件的狀態,所以在3s後輸出promise1,不會輸出promise2

3.2 Promise.all 方法

很多時候,我們想要等待多個非同步操作完成後再進行一些處理。如果使用回撥的方式,會出現前面提到過的回撥地獄。例如:

let fs = require("fs");

fs.readFile("file1", "utf8", function (data1, err1) {
    if (err1 != nil) {
        console.log(err1);
        return;
    }

    fs.readFile("file2", "utf8", function (data2, err2) {
        if (err2 != nil) {
            console.log(err2);
            return;
        }

        fs.readFile("file3", "utf8", function (data3, err3) {
            if (err3 != nil) {
                console.log(err3);
                return;
            }

            console.log(data1);
            console.log(data2);
            console.log(data3);
        });
    });
});
複製程式碼

假設檔案file1file2file3中的內容分別是"in file1","in file2","in file3"。那麼輸出如下:

in file1
in file2
in file3
複製程式碼

這種情況下,Promise.all就派上大用場了。Promise.all接受一個可迭代物件(即ES6中的Iterable物件),每個元素通過呼叫Promise.resolve轉換為Promise物件。Promise.all方法返回一個新的Promise物件。該物件在所有Promise物件狀態變為resolved時,其狀態才會轉換為resolved,引數為各個Promise的結果組成的陣列。只要有一個物件的狀態變為rejected,新物件的狀態就會轉換為rejected。使用Promise.all我們可以很優雅的實現上面的功能:

let fs = require("fs");

let promise1 = new Promise(function (resolve, reject) {
    fs.readFile("file1", "utf8", function (err, data) {
        if (err != null) {
            reject(err);
        } else {
            resolve(data);
        }
    });
});

let promise2 = new Promise(function (resolve, reject) {
    fs.readFile("file2", "utf8", function (err, data) {
        if (err != null) {
            reject(err);
        } else {
            resolve(data);
        }
    });
});

let promise3 = new Promise(function (resolve, reject) {
    fs.readFile("file3", "utf8", function (err, data) {
        if (err != null) {
            reject(err);
        } else {
            resolve(data);
        }
    });
});

let p = Promise.all([promise1, promise2, promise3]);
p.then(function (datas) {
    console.log(datas);
})
 .catch(function (err) {
    console.log(err);
});
複製程式碼

輸出如下:

['in file1', 'in file2', 'in file3']
複製程式碼

第二段程式碼我們可以進一步簡化為:

let fs = require("fs");

let myReadFile = function (filename) {
    return new Promise(function (resolve, reject) {
        fs.readFile(filename, "utf8", function (err, data) {
            if (err != null) {
                reject(err);
            } else {
                resolve(data);
            }
        });
    });
}

let promise1 = myReadFile("file1");
let promise2 = myReadFile("file2");
let promise3 = myReadFile("file3");

let p = Promise.all([promise1, promise2, promise3]);
p.then(function (datas) {
    console.log(datas);
})
 .catch(function (err) {
    console.log(err);
});
複製程式碼

3.3 Promise.race 方法

Promise.racePromise.all一樣,接受一個可迭代物件作為引數,返回一個新的Promise物件。不同的是,只要引數中有一個Promise物件狀態發生變化,新物件的狀態就會變化。也就是說哪個操作快,就用哪個結果(或出錯)。利用這種特性,我們可以實現超時處理:

let p1 = new Promise(function (resolve, reject) {
    setTimeout(function () {
        reject(new Error("time out"));
    }, 1000);
});

let p2 = new Promise(function (resolve, reject) {
    // 模擬耗時操作
    setTimeout(function () {
        resolve("get result");
    }, 2000);
});

let p = Promise.race([p1, p2]);

p.then(function (data) {
    console.log(data);
})
 .catch(function (err) {
    console.log(err);
});
複製程式碼

物件p1在1s之後狀態轉換為rejectedp2在2s後轉換為resolved。所以1s後,p1狀態轉換時,p的狀態緊接著就轉為rejected了。從而,輸出為:

time out
複製程式碼

如果將物件p2的延遲改為0.5s,那麼在0.5s後p2狀態改變時,p緊隨其後狀態轉換為resolved。從而輸出為:

get result
複製程式碼

4.使用案例

前面我們提到過,then方法會返回一個新的Promise物件。所以then方法可以鏈式呼叫,前一個成功回撥的返回值會作為下一個成功回撥的引數。例如:

let p = new Promise(function (resolve, reject) {
    resolve(25);
});

p.then(function (num) { // (A)
    return num + 1;
})
 .then(function (num) { // (B)
    return num * 2;
})
 .then(function (num) { // (C)
    console.log(num);
});
複製程式碼

物件p狀態變為resolved時,結果為25。行(A)處函式最先被呼叫,引數num的值為25,返回值為2626又作為行(B)處函式的引數,函式返回5252作為行(C)處函式的引數,被輸出。

下面給出結合AJAX的一個案例。

let getJSON = function (url) {
    return new Promise(function (resolve, reject) {
        let xhr = new XMLHttpRequest();
        xhr.open('GET', url);
        xhr.onreadystatechange = function () {
            if (xhr.readyState !== 4) {
                return;
            }

            if (xhr.status === 200) {
                resolve(xhr.response);
            } else {
                reject(new Error(xhr.statusText));
            }
        }
        xhr.send();
    });
}

getJSON("http://api.icndb.com/jokes/random")
 .then(function (responseText) {
    return JSON.parse(responseText);
})
 .then(function (obj) {
    console.log(obj.value.joke);
})
 .catch(function (err) {
    console.log(err.message);
});
複製程式碼

getJSON函式接受一個url地址,請求json資料。但是請求到的資料是文字格式,所以在第一個then方法的回撥中使用JSON.parse將其轉為物件,第二個then方法回撥再進行具體處理。

http://api.icndb.com/jokes/random是一個隨機笑話的api,大家可以試試 :smile:。

5.總結

Promise是ES6新增的一種非同步程式設計的解決方案,使用它可以編寫更優雅,更易讀,更易維護的程式。Promise已經應用在各個角落了,個人認為掌握它是一個合格的Javascript開發者的基本功。

6.參考連結

JavaScript Promise:簡介

Tasks, microtasks, queues and schedules

How to escape Promise Hell

An Overview of JavaScript Promise

ES6 Promise:Promise語法介紹

Promise 物件:阮一峰老師Promise物件詳解

關於我: 個人主頁 簡書 掘金

相關文章