當我們談論Promise時,我們說些什麼

熊貓7發表於2019-03-04

前言

各類詳細的Promise教程已經滿天飛了,我寫這一篇也只是用來自己用來總結和整理用的。如果有不足之處,歡迎指教。

當我們談論Promise時,我們說些什麼

為什麼我們要用Promise

JavaScript語言的一大特點就是單執行緒。單執行緒就意味著,所有任務需要排隊,前一個任務結束,才會執行後一個任務。

為了解決單執行緒的堵塞問題,現在,我們的任務可以分為兩種,一種是同步任務(synchronous),另一種是非同步任務(asynchronous)。

  • 同步任務指的是,在主執行緒上排隊執行的任務,只有前一個任務執行完畢,才能執行後一個任務;
  • 非同步任務指的是,不進入主執行緒、而進入"任務佇列"(task queue)的任務,只有"任務佇列"通知主執行緒,某個非同步任務可以執行了,該任務才會進入主執行緒執行。

非同步任務必須指定回撥函式,當主執行緒開始執行非同步任務,就是執行對應的回撥函式。而我們可能會寫出一個回撥地獄。

step1(function (value1) {
  step2(value1, function(value2) {
    step3(value2, function(value3) {
      step4(value3, function(value4) {
        // ...
      });
    });
  });
});
複製程式碼

而Promise 可以讓非同步操作寫起來,就像在寫同步操作的流程,而不必一層層地巢狀回撥函式。

(new Promise(step1))
  .then(step2)
  .then(step3)
  .then(step4);
複製程式碼

簡單實現一個Promise

關於Promise的學術定義和規範可以參考Promise/A+規範,中文版【翻譯】Promises/A+規範

Promise有三個狀態pendingfulfilledrejected: 三種狀態切換隻能有兩種途徑,只能改變一次:

  • 非同步操作從未完成(pending) => 成功(fulfilled)
  • 非同步操作從未完成(pending) => 失敗(rejected)

Promise 例項的then方法,用來新增回撥函式。

then方法可以接受兩個回撥函式,第一個是非同步操作成功時(變為fulfilled狀態)時的回撥函式,第二個是非同步操作失敗(變為rejected)時的回撥函式(該引數可以省略)。一旦狀態改變,就呼叫相應的回撥函式。

下面是一個寫好註釋的簡單實現的Promise的實現:

const PENDING = 'pending'
const FULFILLED = 'fullfilled'
const REJECTED = 'rejected'

class AJPromise {
  constructor(executor) {
    this.state = PENDING
    this.value = undefined
    this.reason = undefined
    this.onResolvedCallbacks = []
    this.onRejectedCallbacks = []

    let resolve = value => {
      // 確保 onFulfilled 非同步執行
      setTimeout(() => {
        if (this.state === PENDING) {
          this.state = FULFILLED
          this.value = value
          // this.onResolvedCallbacks.forEach(fn => fn)
          // 可以將 value 操作後依次傳遞
          this.onResolvedCallbacks.map(cb => (this.value = cb(this.value)))
        }
      })
    }

    let reject = reason => {
      setTimeout(() => {
        if (this.state === PENDING) {
          this.state = REJECTED
          this.reason = reason
          // this.onRejectedCallbacks.forEach(fn => fn)
          this.onRejectedCallbacks.map(cb => (this.reason = cb(this.reason)))
        }
      })
    }

    try {
      //執行Promise
      executor(resolve, reject)
    } catch (err) {
      reject(err)
    }
  }

  then(onFulfilled, onRejected) {
    if (this.state === FULFILLED) {
      onFulfilled(this.value)
    }

    if (this.state === REJECTED) {
      onRejected(this.reason)
    }

    if (this.state === PENDING) {
      typeof onFulfilled === 'function' &&
        this.onResolvedCallbacks.push(onFulfilled)

      typeof onRejected === 'function' &&
        this.onRejectedCallbacks.push(onRejected)
      // 返回 this 支援then方法可以被同一個 promise 呼叫多次
      return this
    }
  }
}
複製程式碼

如果需要實現鏈式呼叫和其它API,請檢視下面參考文件連結中的手寫Promise教程。

優雅的使用Promise

使用Promise封裝一個HTTP請求

function get(url) {
  return new Promise(function(resolve, reject) {
    var req = new XMLHttpRequest();
    req.open('GET', url);

    req.onload = function() {
      if (req.status == 200) {
        resolve(req.responseText);
      }
      else {
        reject(Error(req.statusText));
      }
    };

    req.onerror = function() {
      reject(Error("Network Error"));
    };

    req.send();
  });
}
複製程式碼

現在讓我們來使用這一功能:

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.error("Failed!", error);
})

// 當前收到的是純文字,但我們需要的是JSON物件。我們將該方法修改一下
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);
}
複製程式碼

then() 不是Promise的最終部分,可以將各個then連結在一起來改變值,或依次執行額外的非同步操作。

Promise.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);
})
複製程式碼

錯誤處理

then() 包含兩個引數onFulfilled, onRejectedonRejected是失敗時呼叫的函式。
對於失敗,我們還可以使用catch,對於錯誤進行捕捉,但下面兩段程式碼是有差異的:

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.log("Failed!", error);
})

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 拒絕後,將跳至帶有拒絕回撥的下一個then()(或具有相同功能的 catch())。如果是 then(func1, func2),則 func1func2 中的一個將被呼叫,而不會二者均被呼叫。但如果是 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!");
})
複製程式碼

以下是上述程式碼的流程圖形式:

當我們談論Promise時,我們說些什麼
藍線表示執行的 promise 路徑,紅路表示拒絕的 promise 路徑。與 JavaScript 的 try/catch 一樣,錯誤被捕獲而後續程式碼繼續執行。

並行和順序:兩者兼得

假設我們獲取了一個story.json檔案,其中包含了文章的標題,和段落的下載地址。

1. 順序下載,依次處理

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';
})
複製程式碼

2. 並行下載,完成後統一處理

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) {
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})
複製程式碼

3. 並行下載,一旦順序正確立即渲染

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 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';
})
複製程式碼

async / await

async函式返回一個 Promise 物件,可以使用then方法新增回撥函式。當函式執行的時候,一旦遇到await就會先返回,等到非同步操作完成,再接著執行函式體內後面的語句。

基本用法

我們可以重寫一下之前的getJSON方法:

// promise 寫法
function getJSON(url) {
    return get(url).then(JSON.parse).catch(err => {
        console.log('getJSON failed for', url, err);
        throw err;
    })
}

// async 寫法
async function getJSON(url) {
    try {
        let response = await get(url)
        return JSON.parse(response)
    } catch (err) {
        console.log('getJSON failed for', url, err);
    }
}

複製程式碼

注意:避免太過迴圈

假定我們想獲取一系列段落,並儘快按正確順序將它們列印:

// promise 寫法
function chapterInOrder(urls) {
  return urls.map(getJSON)
    .reduce(function(sequence, chapterPromise) {
      return sequence.then(function() {
        return chapterPromise;
      }).then(function(chapter) {
        console.log(chapter)
      });
    }, Promise.resolve())
}
複製程式碼

*不推薦的方式:

async function chapterInOrder(urls) {
  for (const url of urls) {
    const chapterPromise = await getJSON(url);
    console.log(chapterPromise);
  }
}
複製程式碼

推薦寫法:

async function chapterInOrder(urls) {
  const chapters = urls.map(getJSON);

  // log them in sequence
  for (const chapter of chapters) {
    console.log(await chapter);
  }
}
複製程式碼

參考資料

  1. 非同步函式 - 提高 Promise 的易用性
  2. 你能手寫一個Promise嗎?Yes I promise。
  3. JavaScript Promise:簡介
  4. JavaScript 執行機制詳解:再談Event Loop
  5. Promise 物件
  6. async 函式

相關文章