關於非同步 - AJAX & Promise

關於非同步 - AJAX & Promise

認識 JavaScript 的非同步機制與 AJAX 技術

JavaScript 在運行時是一種「單執行緒」語言,也就是一次只能處理一件事情。但實際開發中,我們常常需要進行「非同步操作」,像是請求資料、等待用戶互動、計時器等等。非同步的設計讓我們可以在等待某些事情完成的同時,繼續處理其他任務,讓使用者體驗不會被卡住。


AJAX 應用可以僅向伺服器傳送並取回必須的資料,並在客戶端用 JavaScript 處理來自伺服器的回應。因為在伺服器和瀏覽器之間交換的資料大量減少,伺服器回應更快了。同時,很多的處理工作可以在發出請求的客戶端機器上完成,因此 Web 伺服器的負荷也減少了。

AJAX 技術的出現,讓瀏覽器可以向 Server 請求資料而不需費時等待。當瀏覽器接收到 response 之後,新的內容就會即時地添入原本網頁

非同步是什麼?

非同步的本質是:發出請求之後,不會「卡住」等待結果,而是先記下這件事,讓它在未來某個時間點完成後再通知我們。

async

這種通知的機制,通常透過 callback(回呼函式)Promiseasync/await 實作。而非同步的概念,其實你早就在用,比如:

  • setTimeout()setInterval():等時間到再執行
  • DOM 事件:不知道用戶什麼時候點擊,但先寫好要發生的動作
  • AJAX 請求:資料還沒回來時先做別的事,回來再處理

AJAX 是什麼?

AJAX(Asynchronous JavaScript and XML)是一種網頁技術組合,讓瀏覽器可以在不重新整理整個頁面的情況下,與伺服器交換資料。

它的核心精神是:

  • 只傳送/接收必要的資料,並在前端用 JavaScript 處理回應
  • ✅ 減少伺服器與瀏覽器之間的資料傳輸量
  • ✅ 提升使用者互動體驗與伺服器效率

舉例來說:你在網頁中選擇縣市,下方的鄉鎮區就會根據選擇自動載入,不會重新載入整個頁面。這就是 AJAX 的功勞。

非同步處理的演進

JavaScript 實現非同步的方法不斷演進著:從 Callback 函式、Promise 到最新的 async / await。

  1. Callback 函式 非同步最原始的處理方式。把要執行的函式當作參數傳入:
setTimeout(function () {
  console.log("Hello after 1 second");
}, 1000);

缺點是容易產生「回呼地獄(Callback Hell)」:

getUser(id, function (user) {
  getPosts(user.id, function (posts) {
    getComments(posts[0].id, function (comments) {
      // 😵 嵌套又嵌套
    });
  });
});
  1. Promise

Promise 是對未來值的一種包裝,可以用 .then() 依序串接非同步操作:

fetch('/api/data')
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error(err));

好處:

  • 避免層層巢狀(但還是會一層層 .then()
  • 可集中處理錯誤(.catch()
  1. async / await(ES2017)

讓非同步程式碼看起來像同步的寫法,可讀性大大提升:

async function getData() {
  try {
    const res = await fetch('/api/data');
    const data = await res.json();
    console.log(data);
  } catch (err) {
    console.error(err);
  }
}

async/await 是建立在 Promise 之上的語法糖。

--- 以下待整合

發出 request 之後不需要等待 response ,可以持續處理其他事情,甚至繼續送出其他 request,等 response 傳回之後,就被融合進當下頁面或應用中。

當你在使用 DOM 事件時,其實你已經在運用非同步的概念,你不知道事件什麼時候會發生,但你可以先把要發生的函式準備好,等函式中的 callback 被呼叫的時候再執行要接著做的事。JavaScript 可以把函式當成值來傳遞,因此執行程序可以非同步的發生。

JavaScript 實現非同步的方法不斷演進著:從 callback 函式、promises 到最新的 async-await 函式。

async

  • 所有的 AJAX 都會有對應的 API 接口,API 的接口就是一段網址,不同的網址也對應不同的「HTTP 請求方法」,請求方法必須與網址完全對應才可運作。
  • AJAX 會統一由網頁端發出請求(無論新增資源或取得資源),依據不同的接口、請求方法進行請求,而伺服器會依據請求的方法、內容來進行回應。
  • AJAX 會在背景送出請求取得回應,不用等待也同時可以做其他事情,等同 Queue (佇列) 的概念,收到回應才產生一個 Queue,執行並顯示在畫面。

目前常見的技術

  • XMLHttpRequest (IE7 以上支援,jQuery,axios)

實務上很少直接使用原生的 XMLHttpRequest。取而代之的方法有很多種,過去較流行的是 jQuery 的 $.ajax(),然而在 JavaScript 日趨成熟之後,許多新的替代方案應運而生:

  • fetch (較新,HTML5 才有,IE11 以下不支援)。fetch 並不是 XMLHttpRequest 的升級版本,不是 jQuery 提供的 $.ajax 語法或 axios 那種原生 XHR 的封裝。它是一個全新的東西,並且基於 Promise 語法結構所設計,可配合使用 Async/Await 語法,使程式更加優雅。
    fetch('http://example.com/movies.json')
      .then(function(response) {
        return response.json();
      })
      .then(function(myJson) {
        console.log(myJson);
      });
    
    async function ajax() {
      const response = await fetch('http://example.com/movies.json');
      const data = await response.json();
      console.log(data);
    }
    ajax();
    
  • axios 是一個使用 Promise base 的 Ajax 函式庫,他有許多重要功能如:
    1. 廣泛的瀏覽器支持
    2. 可支援 Node.js 從後端發送的 Http request,這意味著 axios 可以兼用於前端與後端專案。
    3. 直接將回應的 JSON 資料轉換成 JavaScript 的 Object,這十分方便!

常見的非同步問題 (不限 AJAX)

  1. 回呼地獄
  2. 寫法不一致
  3. 無法同時執行 (無法確定什麼時候開始及結束)

Promise

!https://miro.medium.com/max/1400/1*gtdCpCvoZ6Q-YVC-N4en2w.png

使用 promise 可以解決 1. 回呼地獄 2. 寫法不一致 3. 無法同時執行 的問題

  • Promise 是為了解決傳統非同步語法難以建構及管理的問題。Promise 本身是一個建構函式
  • Promise 有三種狀態pending, resolved/fulfilled, rejected
  • new Promise 內的函式會立即被執行,當 resolve 得到內容後,才會執行 .then
  • 要提供一個函式 promise 功能,讓他 return 並透過 new 方式建立一個 promise 物件,並必須傳入一個函式作為參數帶有 resolve / reject 參數,分別代表成功及失敗的回傳結果。
  • 已實現的狀態會透過 resolve 這個參數回傳一個結果,在調用時使用 .then 接收回傳結果;反之否決狀態會透過 reject 參數回傳並用 .catch 接收,一次只有一種結果
  • .then 的 resolvedCallback 中,可以得到在 new Promiseresolve 內所得到的值 (value)。
  • .then() 是 promise 的 (原型) 方法
  • 如果在 .then 的 resolvedCallback 中 return 一個值,則這個值會以 Promise 物件的形式傳到下一個 .then
  • HTML5 中的 Fetch API 也是使用它
  1. 基礎運用
    const promiseSetTimeout = (status) => {
     return new Promise((resolve, reject) => { // 傳入函式參數,帶有 resolve/reject 參數
      setTimeout(() => {
       if(status) {
        resolve('promiseSetTimeout 成功')
       } else {
        reject('promiseSetTimeout 失敗')
       }
      }, 0);
     })
    }
    
    promiseSetTimeout(true)
     .then(function(res) {   // .then 放入狀態完成的回調函式,針對狀態做處理
      console.log(res)
     })
    
  2. 串接 (避免回呼地獄 + 巢狀寫法)
    promiseSetTimeout(true)
     .then(function(res) {
      console.log(1, res);
      return promiseSetTimeout(true) // return 下一個 promise
     })
     .then(res => {                   // return 的結果會在下一個 .then 出現
      console.log(2, res)
     })
    
    promiseWrap(A)
     .then(() => {
      return promiseWrap(B);
     })
     .then(() => {
      return promiseWrap(C);
     })
    
  3. 失敗的捕捉
    除了成功的方法之外也要把失敗的方法補進去
    promiseSetTimeout(true)
     .then(res => {     // 被 resolve 時執行
      console.log(res);
     })
     .catch(err => {    // 被 reject 時執行
      console.log(err);
     })
    
  4. Promise.all
    所有的 promise 都回傳成功了才進入下一個任務,在此之前都是等待,但若其一回傳為失敗就進入失敗的處理狀況
    Promise.all([axios.get(url), axios.get(url)])
     .then([res1, res2] => {
      console.log(res1, res2)
     })
    
  5. 實戰運用
    const component = {
     data: {},
     init() {
      console.log(this)        // this 是指向 component
      promiseSetTimeout(true)
       .then(res => {         // 箭頭函式會使用外層的作用域也就是指向 component
        this.data.res = res; // 將回傳的 res 寫入 data 內
       })
     }
    }
    component.init();
    
    /**
     * You can get token in a Promise
     * 利用 Promise 先透過 email 和 password 取得 access_key 和 secret 後,
     * 再用 access_key 和 secret 取得 token。
     **/
    
    const getTokenPromise = new Promise((resolve, reject) => {
      request
        .post(endpoint + '/users/cert')
        .send({
          email: '<your_email>',
          password: '<your_password>',
        })
        .end((err, res) => {
          resolve(res);
          reject(err);
        });
    });
    
    getTokenPromise()
      .then((response) => {
        let parseResponse = JSON.parse(response.text);
        console.log(parseResponse);
        return new Promise((resolve, reject) => {
          request
            .post(endpoint + '/users/token')
            .send({
              access_key: parseResponse.access_key,
              secret: parseResponse.secret,
            })
            .end((err, res) => {
              resolve(res);
              reject(err);
            });
        });
      })
      .then((response) => {
        let parseResponse = JSON.parse(response.text);
        console.log(parseResponse);     // You can get Token Here
      })
      .catch((err) => {
        console.warn('getTokenPromise with error', err);
      });
    

async...await

只要 function 標記為 async,就表示裡頭可以撰寫 await 的同步語法await 關鍵字只能在 async function 中執行,而 await 就是「等待」,它會確保一個 promise 物件都解決 (resolve) 或出錯 (reject) 後才會進行下一步,當 async function 的內容全都結束後,會返回一個 promise,這表示後方可以使用 .then 語法來做連接。

async 函式中使用 await 關鍵字意味者:「我們請 JavaScript 等待這個非同步的作業完成,才展開後續的動作,且這個函式會回傳一個 Promise 物件」,換成 Async/Await 的話,就不必寫下 .then() 了,就像同步的程式一般,不必理會它是否為非同步。

(async function() {
 await promiseWrap(A);
 await promiseWrap(B);
 await promiseWrap(C);
}());

另外,用迴圈處理非同步事件時,需要注意 ES6 後提供的許多 Array 方法都不支援 async / await 的語法,例如使用 forEach 取代 for,結果會變成同步執行

function getFirstInfo() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('first data')
    }, 1000);
  })
}

function getSecondInfo() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('second data')
    }, 2000);
  })
}

// 函式前加上 async 關鍵字,告知這是一個非同步函式
async function getGroupInfo() {
  // 代表等到第一筆資料回傳後,才印出結果和請求第二筆資料
  const firstInfo = await getFirstInfo() // 要處理非同步的地方,呼叫函式前加上 await
  console.log(firstInfo)
  // 代表等到第二筆資料回傳後,才印出結果
  const secondInfo = await getSecondInfo() // 要處理非同步的地方,呼叫函式前加上 await
  console.log(secondInfo)
}

getGroupInfo()