Babel Coder

ยกเลิกการ Fetch อย่างไร? รู้จัก AbortSignal และ AbortController

beginner

Fetch API เป็นมาตรฐานใหม่สำหรับการร้องขอข้อมูลผ่านเน็ตเวิร์ค ด้วยการใช้งานที่ง่ายคล้าย $.ajax ของ jQuery ทำให้มาตรฐานเก่าแบบ XMLHttpRequest ยุ่งยากและน่าตบยิ่งนัก จะส่ง request ซะทีเขียนอะไรยาวนักหนา แถมยังไม่สนับสนุนการทำงานกับ service worker อีก

ความ XMLHttpRequest นั้นแม้ว่าจะซับซ้อนแต่ก็ซ่อนความครบเครื่อง อย่างน้อย ๆ ส่ง request ออกไปแล้วก็ยังสามารถยกเลิก (abort) ทิ้งเสียได้ เพราะ XMLHttpRequest นั้นอาบน้ำร้อนมาก่อน จึงได้ทั้งท่ายากและท่าง่าย แล้ว Fetch API ที่เกิดมาทีหลังละทำได้ไหม?

สารบัญ

abort และความพยายามของ Fetch API

ปี 2015 มีกระทาชายนายหนึ่งได้เปิด issue ใน Github ของ Fetch API ภายใต้การดูแลของ WHATWG ด้วยประเด็นที่ว่า เห้ยแกรรเราควรจะ abort การทำงานของ Fetch ได้ป๊ะ?

ด้วยความเห็นที่ยาวเป็นหางว่าว บ้างก็ว่าไหน ๆ เรียก fetch แล้วก็ให้มันคืนออบเจ็กต์ที่มีเมธอด abort ซะเลยแบบ XMLHttpRequest ซิ จะได้เรียกง่าย ๆ หน่อยเหมือนแบบนี้

const xhr = new XMLHttpRequest()

xhr.open('GET', 'https://jsonplaceholder.typicode.com/posts', true)

xhr.send()
xhr.abort() // ยกเลิก xhr ตรงนี้

ทว่าอีกฝ่ายก็ไม่เห็นด้วยกับการเพิ่ม abort เข้าไปในออบเจ็กต์ของ fetch เช่นกัน

ทางฝั่งของ TC39 ทีมผู้ดูแล ECMAScript ภาษารากของ JavaScript ก็เคยมีความพยายามเสนอร่างที่คล้าย ๆ กันนี้สำหรับการสร้าง cancelable promises เช่นกัน แต่เสียใจด้วยคุณไม่ได้ไปต่อค่ะ เพราะร่างนี้นั้นถูกยกเลิกไปเป็นที่เรียบร้อย

แน่นอนว่าการออกแบบ API ให้ตอบสนองความต้องการของทุกคนนั้นเป็นเรื่องยาก สุดท้ายแล้ว AbortSignal น่าจะเป็นทางออกที่ใช่สำหรับเวลานี้

รู้จักกับ AbortSignal

บรรดาเหล่า Promise ทั้งหลาย เราไม่มีวิธีการยกเลิกการทำงานของพวกมันมาก่อน (อย่าลืมนะร่างของ cancelable promises ตกกระป๋องไปแล้ว) แน่นอนว่าผลลัพธ์จากการเรียก fetch() ก็ได้ค่ากลับเป็น Promise เช่นกัน กฎข้อนี้จึงใช้ได้กับ Fetch ด้วย

ความกระหายกลวิธีในการยกเลิกการทำงาน เป็นผลให้เกิด AbortSignal ขึ้นมา ตามมาตรฐานของ DOM นั้น API ใด ๆ ต้องการสนับสนุนให้เกิดการยกเลิกการทำงานได้ API เหล่านั้นต้องรับค่า AbortSignal เข้ามา เมื่อใดที่ AbortSignal ตัวนั้นมีสถานะเป็น aborted (ถ้าพูดให้ถูกต้องมากขึ้นคือ เมื่อเกิดอีเวนต์ abort) ให้ทำการ reject Promise ที่ตัวเองทำงานทิ้งไปด้วยการโยน AbortError ออกไป อันเป็นการถือว่ายุติการทำงานแล้วนั่นเอง

Fetch API ต้องการความสามารถของการ abort มันจึงต้องรับ AbortSignal เข้ามาในจังหวะที่เรียก เมื่อไหร่ที่สัญญาณบ่งบอกว่าถูกยกเลิกแล้ว (aborted) promise จาก fetch จะทำการ reject ด้วย AbortError ซึ่งเป็นข้อผิดพลาดประเภทหนึ่งของ DOMException

// --> เรียก fetch พร้อมส่ง signal --> ได้รับสัญญาณว่า abort --> reject
// --> fetch(url, { signal })   --> aborted           --> AbortError

// signal ในที่นี้คือตัวแปรของ AbortSignal
fetch(url, { signal })
  .then(res => res.json())
  .then(console.log)
  .catch(ex) {
    // ถ้าข้อผิดพลาดเกิดจากการ abort ตัว ex จะเป็นข้อผิดพลาดชนิด AbortError
  }

ใครจะไปเชื่อหละ เบราเซอร์เจ้าแรกที่พัฒนา AbortController และ AbortSignal ตามมาตรฐานนี้คือ Microsoft Edge นี่ถ้า IE ไม่ถูกมวลมนุษยชาติทอดทิ้ง ลื้อจะกระเหี้ยนกะหือรือขนาดนี้ไหม พูด!

AbortController กับการควบคุมสัญญาณ

AbortSignal เป็นเพียงสัญญาณที่บอกว่าเราควรยกเลิกการทำงาน (abort) หรือไม่ จากตัวอย่างก่อนหน้าเราจะพบว่าแม้มีตัวสัญญาณ (signal) แต่ถ้าไม่มีวิธีควบคุมให้ตัวสัญญาณนี้เปลี่ยนสถานะไปเป็น aborted ยังไงเราก็ไม่สามารถยกเลิกการทำงานของ Fetch ได้อยู่ดี

AbortController คือตัวควบคุมสัญญาณที่อนุญาตให้เรา abort request ได้ โดยในที่นี้ request ไม่ได้จำกัดอยู่แค่ Fetch แต่หมายรวมถึง DOM request ใด ๆ ก็ได้

หลังจากทำการสร้างออบเจ็กต์ของ AbortController แล้วจะเกิด AbortSignal ตามขึ้นมาด้วย เราสามารถเข้าถึง signal ตัวนี้ที่ controller ควบคุมอยู่ผ่านเมธอด signal ของ controller

const controller = new AbortController()
// signal จะเป็น AbortSignal ตัวที่ controller ควบคุมอยู่
const signal = controller.signal

fetch(url, { signal })
  .then(res => res.json())
  .then(console.log)
  .catch(ex) {
    // ถ้าข้อผิดพลาดเกิดจากการ abort ตัว ex จะเป็นข้อผิดพลาดชนิด AbortError
  }

เมื่อไหร่ก็ตามที่เราต้องการยุติการทำงาน (abort) เราสามารถเรียกเมธอด abort จาก controller ได้โดยตรง อันจะมีผลทำให้ตัว signal ดังกล่าวเปลี่ยนสถานะเป็น aborted เมื่อตัว Fetch มองเห็นจะทำการ reject promise ด้วย AbortError นั่นเอง

ตัวอย่างการยกเลิก Fetch ด้วย AbortSignal และ AbortController

ทฤษฎีล่อไปครึ่งหน้ากระดาษ A4 แล้ว ลองมาดูตัวอย่างการใช้งานบ้างครับ

กำหนด timeout ให้ Fetch

สมมติเราต้องการตั้งเวลา timeout ไว้ที่ 2 วินาที หากการร้องขอข้อมูลนานเกิน 2 วินาทีให้ทำการ abort request นั้นเสีย

const controller = new AbortController()
const signal = controller.signal

// เมื่อครบ 2 วินาทีให้ทำการ abort
setTimeout(() => controller.abort(), 2000)

fetch('https://jsonplaceholder.typicode.com/posts', { signal })
  .then(res => res.json())
  .then(console.log)
  .catch(ex => 
    // กรณีของ AbortError ให้ทำการพิมพ์ Fetch aborted
    console.error(ex.name === 'AbortError' ? 'Fetch aborted' : ex.message)
  )

การกำหนด abort ให้เป็นส่วนหนึ่งของผลลัพธ์ Fetch

สมมติเราอยาก custom เจ้าตัว fetch ของเราให้สามารถ abort ได้โดยตรงผ่านการสร้างฟังก์ชัน abortableFetch ดังนี้

function abortableFetch(request, opts = {}) {
  const controller = new AbortController()
  const signal = controller.signal
  
  return {
    abort() { controller.abort() },
    ready() {      
      // เมื่อเรียกเมธอด ready จะทำการ fetch ข้อมูลตามปกติ
      // เพิ่มเติมคือส่ง signal ไปด้วย
      // หากต้องการ abort ให้เรียกผ่านเมธอด abort
      return fetch(request, { ...opts, signal })
    }
  }
}

เช่นเดียวกับสถานการณ์ข้างต้น หาก timeout ของเราอยู่ที่ 2 วินาที เราสามารถตั้ง timeout ควบคู่กับการใช้งาน abortableFetch ได้ดังนี้

const promise = abortableFetch('https://jsonplaceholder.typicode.com/posts')

promise
  .ready()
  .then(res => res.json())
  .then(console.log)
  .catch(ex => 
    console.error(ex.name === 'AbortError' ? 'Fetch aborted' : ex.message)
  )

// ทำการยกเลิกเมื่อครบ timeout ที่ 2 วินาที
setTimeout(() => promise.abort(), 2000)

แหมะ จะมานั่ง setTimeout ทุกครั้งเพื่อทำการ abort ก็กะไรอยู่ จัดการให้ abortableFetch สนับสนุน timeout ไปซะเลยซิ!

// opts ที่ส่งเข้ามาสามารถใส่ค่า timeout ได้
function abortableFetch(request, opts = {}) {
  // ดึงเอา timeout ออกมาจาก options ที่ส่งเข้ามา
  const { timeout, ...rest } = opts
  const controller = new AbortController()
  const signal = controller.signal
  
  return {
    abort() { controller.abort() },
    ready() {
      // abort เมื่อครบ timeout
      if(timeout) setTimeout(() => controller.abort(), timeout)
      
      return fetch(request, { ...rest, signal })
    }
  }
}

ด้วยรูปโฉมใหม่ ตอนนี้ abortableFetch ก็สนับสนุนการใช้งาน timeout แล้ว

const promise = abortableFetch('https://jsonplaceholder.typicode.com/posts', { timeout: 2000 })

promise
  .ready()
  .then(res => res.json())
  .then(console.log)
  .catch(ex => 
    console.error(ex.name === 'AbortError' ? 'Fetch aborted' : ex.message)
  )

การยกเลิกหลาย requests ในคราเดียว

หากเรามี API ของ posts ทั้งหมด โดย API ดังกล่าวคืนแค่เพียง userId กลับมา เมื่อเราต้องการทราบว่าแต่ละโพสต์นั้นผู้เขียนคือใคร เราจึงต้องนำ userId ไปทำการดึงข้อมูลต่อที่ API ของ users ดังนี้

const BASE_API_ENDPOINT = 'https://jsonplaceholder.typicode.com'
      
function fetchUsersByIds(userIds) {
  // หา user แต่ละคนตามแต่ ID ที่ส่งเข้ามา
  const userFetchs = userIds.map(id => 
    fetch(`${BASE_API_ENDPOINT}/users/${id}`)
      .then(res => res.json())
  )
  
  // รอให้หาครบทุก user ก่อน
  return Promise.all(userFetchs)
}

function fetchAuthors() {
 // ดึงข้อมูลของ posts ทั้งหมด
 return fetch(`${BASE_API_ENDPOINT}/posts`)
  .then(res => res.json())
  // ทำการดึงเอาเฉพาะ userId ของทุก posts ออกมา
  .then(posts => posts.map(post => post.userId))
  // แล้วจัดการส่งให้ fetchUsersByIds เพื่อทำการหา users ต่อไป
  .then(fetchUsersByIds)
}

// แสดงผลลัพธ์เป็น users ที่เขียนแต่ละโพสต์
fetchAuthors().then(console.log) 

AbortSignal นั้นสามารถใช้เพื่อยกเลิกหลาย fetches พร้อมกันได้ในคราเดียว ดังนั้นแล้วหากเราต้องการยกเลิกการค้นหาข้างต้นหากการทำงานเกิน 2 วินาที จึงสามารถแปลงโค้ดได้ใหม่ดังนี้

const BASE_API_ENDPOINT = 'https://jsonplaceholder.typicode.com'
      
function fetchUsersByIds(userIds, signal) {
  const userFetchs = userIds.map(id => 
    fetch(`${BASE_API_ENDPOINT}/users/${id}`, { signal })
      .then(res => res.json())
  )
  
  return Promise.all(userFetchs)
}

function fetchAuthors(signal) {
 return fetch(`${BASE_API_ENDPOINT}/posts`, { signal })
  .then(res => res.json())
  .then(posts => posts.map(post => post.userId))
  .then(userIds => fetchUsersByIds(userIds, signal))
}

const controller = new AbortController()
const signal = controller.signal

// ส่ง signal เข้าไปเพื่อใช้ควบคุมการ abort
fetchAuthors(signal)
  .then(console.log) 
  .catch(ex => 
    console.error(ex.name === 'AbortError' ? 'Fetch aborted' : ex.message)
  ) 

// abort เมื่อครบ 2 วินาที
setTimeout(() => {
  console.log('Aborted!')
  controller.abort()
}, 2000)

AbortController และ AbortSignal มิได้ใช้ได้กับแค่ Fetch

Fetch API เอ๋ย อย่าสำคัญตัวผิดไป AbortController และ AbortSignal ไม่ได้ออกแบบมาให้แกใช้คนเดียวหรอกนะ หากแต่เป็น API ใด ๆ ที่สนับสนุนก็ใช้ได้ต่างหาก

function doSth({ signal }) {
  // ถ้า signal มีสถานะเป็น aborted ให้โยน AbortError ไปพร้อม reject
  if(signal.aborted) {
    return Promise.reject(new DOMException('Aborted', 'AbortError'))
  }
  
  return new Promise((resolve, reject) => {
    // คอยดักฟังว่าสัญญาณเป็น aborted sinvw,j
    signal.addEventListener('abort', () => {
      reject(new DOMException('Aborted', 'AbortError'))
    })
    // ...
    // ...
  })
}

สรุป

AbortSignal นั้นทำให้เราสามารถยกเลิกการทำงานของ Fetch ได้ โดยเมื่อไหร่ที่มีสัญญาณว่าเกิดการ abort แล้ว Fetch จะทำการ reject เจ้า promise นั้นทิ้งเสียด้วย AbortError

การใช้งานกับเบราเซอร์ทั่วไปนั้นดีนัก แต่กลับเบราเซอร์ที่คนไม่รักแบบ IE นั้นอย่าหวัง เพราะเธอนั้นหาได้ support ตัว AbortSignal ไม่ นั่นเอง

เอกสารอ้างอิง

Abortable fetch (2017). Retrieved Sep, 24, 2018, from https://developers.google.com/web/updates/2017/09/abortable-fetch Aborting ongoing activities. Retrieved Sep, 24, 2018, from https://dom.spec.whatwg.org/#aborting-ongoing-activities AbortController (2017). Retrieved Sep, 24, 2018, from https://developer.mozilla.org/en-US/docs/Web/API/AbortController AbortSignal (2017). Retrieved Sep, 24, 2018, from https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal


แสดงความคิดเห็นของคุณ


No any discussions