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

Nuttavut Thongjor

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 ซิ จะได้เรียกง่าย ๆ หน่อยเหมือนแบบนี้

JavaScript
1const xhr = new XMLHttpRequest()
2
3xhr.open('GET', 'https://jsonplaceholder.typicode.com/posts', true)
4
5xhr.send()
6xhr.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

JavaScript
1// --> เรียก fetch พร้อมส่ง signal --> ได้รับสัญญาณว่า abort --> reject
2// --> fetch(url, { signal }) --> aborted --> AbortError
3
4// signal ในที่นี้คือตัวแปรของ AbortSignal
5fetch(url, { signal })
6 .then(res => res.json())
7 .then(console.log)
8 .catch(ex) {
9 // ถ้าข้อผิดพลาดเกิดจากการ abort ตัว ex จะเป็นข้อผิดพลาดชนิด AbortError
10 }

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

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

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

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

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

JavaScript
1const controller = new AbortController()
2// signal จะเป็น AbortSignal ตัวที่ controller ควบคุมอยู่
3const signal = controller.signal
4
5fetch(url, { signal })
6 .then(res => res.json())
7 .then(console.log)
8 .catch(ex) {
9 // ถ้าข้อผิดพลาดเกิดจากการ abort ตัว ex จะเป็นข้อผิดพลาดชนิด AbortError
10 }

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

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

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

กำหนด timeout ให้ Fetch

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

JavaScript
1const controller = new AbortController()
2const signal = controller.signal
3
4// เมื่อครบ 2 วินาทีให้ทำการ abort
5setTimeout(() => controller.abort(), 2000)
6
7fetch('https://jsonplaceholder.typicode.com/posts', { signal })
8 .then((res) => res.json())
9 .then(console.log)
10 .catch((ex) =>
11 // กรณีของ AbortError ให้ทำการพิมพ์ Fetch aborted
12 console.error(ex.name === 'AbortError' ? 'Fetch aborted' : ex.message)
13 )

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

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

JavaScript
1function abortableFetch(request, opts = {}) {
2 const controller = new AbortController()
3 const signal = controller.signal
4
5 return {
6 abort() {
7 controller.abort()
8 },
9 ready() {
10 // เมื่อเรียกเมธอด ready จะทำการ fetch ข้อมูลตามปกติ
11 // เพิ่มเติมคือส่ง signal ไปด้วย
12 // หากต้องการ abort ให้เรียกผ่านเมธอด abort
13 return fetch(request, { ...opts, signal })
14 },
15 }
16}

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

JavaScript
1const promise = abortableFetch('https://jsonplaceholder.typicode.com/posts')
2
3promise
4 .ready()
5 .then((res) => res.json())
6 .then(console.log)
7 .catch((ex) =>
8 console.error(ex.name === 'AbortError' ? 'Fetch aborted' : ex.message)
9 )
10
11// ทำการยกเลิกเมื่อครบ timeout ที่ 2 วินาที
12setTimeout(() => promise.abort(), 2000)

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

JavaScript
1// opts ที่ส่งเข้ามาสามารถใส่ค่า timeout ได้
2function abortableFetch(request, opts = {}) {
3 // ดึงเอา timeout ออกมาจาก options ที่ส่งเข้ามา
4 const { timeout, ...rest } = opts
5 const controller = new AbortController()
6 const signal = controller.signal
7
8 return {
9 abort() {
10 controller.abort()
11 },
12 ready() {
13 // abort เมื่อครบ timeout
14 if (timeout) setTimeout(() => controller.abort(), timeout)
15
16 return fetch(request, { ...rest, signal })
17 },
18 }
19}

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

JavaScript
1const promise = abortableFetch('https://jsonplaceholder.typicode.com/posts', {
2 timeout: 2000,
3})
4
5promise
6 .ready()
7 .then((res) => res.json())
8 .then(console.log)
9 .catch((ex) =>
10 console.error(ex.name === 'AbortError' ? 'Fetch aborted' : ex.message)
11 )

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

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

JavaScript
1const BASE_API_ENDPOINT = 'https://jsonplaceholder.typicode.com'
2
3function fetchUsersByIds(userIds) {
4 // หา user แต่ละคนตามแต่ ID ที่ส่งเข้ามา
5 const userFetchs = userIds.map((id) =>
6 fetch(`${BASE_API_ENDPOINT}/users/${id}`).then((res) => res.json())
7 )
8
9 // รอให้หาครบทุก user ก่อน
10 return Promise.all(userFetchs)
11}
12
13function fetchAuthors() {
14 // ดึงข้อมูลของ posts ทั้งหมด
15 return (
16 fetch(`${BASE_API_ENDPOINT}/posts`)
17 .then((res) => res.json())
18 // ทำการดึงเอาเฉพาะ userId ของทุก posts ออกมา
19 .then((posts) => posts.map((post) => post.userId))
20 // แล้วจัดการส่งให้ fetchUsersByIds เพื่อทำการหา users ต่อไป
21 .then(fetchUsersByIds)
22 )
23}
24
25// แสดงผลลัพธ์เป็น users ที่เขียนแต่ละโพสต์
26fetchAuthors().then(console.log)

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

JavaScript
1const BASE_API_ENDPOINT = 'https://jsonplaceholder.typicode.com'
2
3function fetchUsersByIds(userIds, signal) {
4 const userFetchs = userIds.map((id) =>
5 fetch(`${BASE_API_ENDPOINT}/users/${id}`, { signal }).then((res) =>
6 res.json()
7 )
8 )
9
10 return Promise.all(userFetchs)
11}
12
13function fetchAuthors(signal) {
14 return fetch(`${BASE_API_ENDPOINT}/posts`, { signal })
15 .then((res) => res.json())
16 .then((posts) => posts.map((post) => post.userId))
17 .then((userIds) => fetchUsersByIds(userIds, signal))
18}
19
20const controller = new AbortController()
21const signal = controller.signal
22
23// ส่ง signal เข้าไปเพื่อใช้ควบคุมการ abort
24fetchAuthors(signal)
25 .then(console.log)
26 .catch((ex) =>
27 console.error(ex.name === 'AbortError' ? 'Fetch aborted' : ex.message)
28 )
29
30// abort เมื่อครบ 2 วินาที
31setTimeout(() => {
32 console.log('Aborted!')
33 controller.abort()
34}, 2000)

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

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

JavaScript
1function doSth({ signal }) {
2 // ถ้า signal มีสถานะเป็น aborted ให้โยน AbortError ไปพร้อม reject
3 if (signal.aborted) {
4 return Promise.reject(new DOMException('Aborted', 'AbortError'))
5 }
6
7 return new Promise((resolve, reject) => {
8 // คอยดักฟังว่าสัญญาณเป็น aborted sinvw,j
9 signal.addEventListener('abort', () => {
10 reject(new DOMException('Aborted', 'AbortError'))
11 })
12 // ...
13 // ...
14 })
15}

สรุป

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

สารบัญ

สารบัญ

  • abort และความพยายามของ Fetch API
  • รู้จักกับ AbortSignal
  • AbortController กับการควบคุมสัญญาณ
  • ตัวอย่างการยกเลิก Fetch ด้วย AbortSignal และ AbortController
  • AbortController และ AbortSignal มิได้ใช้ได้กับแค่ Fetch
  • สรุป
  • เอกสารอ้างอิง