ยกเลิกการ Fetch อย่างไร? รู้จัก AbortSignal และ AbortController
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 ซิ จะได้เรียกง่าย ๆ หน่อยเหมือนแบบนี้
1const xhr = new XMLHttpRequest()23xhr.open('GET', 'https://jsonplaceholder.typicode.com/posts', true)45xhr.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
1// --> เรียก fetch พร้อมส่ง signal --> ได้รับสัญญาณว่า abort --> reject2// --> fetch(url, { signal }) --> aborted --> AbortError34// signal ในที่นี้คือตัวแปรของ AbortSignal5fetch(url, { signal })6 .then(res => res.json())7 .then(console.log)8 .catch(ex) {9 // ถ้าข้อผิดพลาดเกิดจากการ abort ตัว ex จะเป็นข้อผิดพลาดชนิด AbortError10 }
ใครจะไปเชื่อหละ เบราเซอร์เจ้าแรกที่พัฒนา AbortController และ AbortSignal ตามมาตรฐานนี้คือ Microsoft Edge นี่ถ้า IE ไม่ถูกมวลมนุษยชาติทอดทิ้ง ลื้อจะกระเหี้ยนกะหือรือขนาดนี้ไหม พูด!
AbortController กับการควบคุมสัญญาณ
AbortSignal เป็นเพียงสัญญาณที่บอกว่าเราควรยกเลิกการทำงาน (abort) หรือไม่ จากตัวอย่างก่อนหน้าเราจะพบว่าแม้มีตัวสัญญาณ (signal) แต่ถ้าไม่มีวิธีควบคุมให้ตัวสัญญาณนี้เปลี่ยนสถานะไปเป็น aborted ยังไงเราก็ไม่สามารถยกเลิกการทำงานของ Fetch ได้อยู่ดี
AbortController คือตัวควบคุมสัญญาณที่อนุญาตให้เรา abort request ได้ โดยในที่นี้ request ไม่ได้จำกัดอยู่แค่ Fetch แต่หมายรวมถึง DOM request ใด ๆ ก็ได้
หลังจากทำการสร้างออบเจ็กต์ของ AbortController แล้วจะเกิด AbortSignal ตามขึ้นมาด้วย เราสามารถเข้าถึง signal ตัวนี้ที่ controller ควบคุมอยู่ผ่านเมธอด signal
ของ controller
1const controller = new AbortController()2// signal จะเป็น AbortSignal ตัวที่ controller ควบคุมอยู่3const signal = controller.signal45fetch(url, { signal })6 .then(res => res.json())7 .then(console.log)8 .catch(ex) {9 // ถ้าข้อผิดพลาดเกิดจากการ abort ตัว ex จะเป็นข้อผิดพลาดชนิด AbortError10 }
เมื่อไหร่ก็ตามที่เราต้องการยุติการทำงาน (abort) เราสามารถเรียกเมธอด abort จาก controller ได้โดยตรง อันจะมีผลทำให้ตัว signal ดังกล่าวเปลี่ยนสถานะเป็น aborted เมื่อตัว Fetch มองเห็นจะทำการ reject promise ด้วย AbortError นั่นเอง
ตัวอย่างการยกเลิก Fetch ด้วย AbortSignal และ AbortController
ทฤษฎีล่อไปครึ่งหน้ากระดาษ A4 แล้ว ลองมาดูตัวอย่างการใช้งานบ้างครับ
กำหนด timeout ให้ Fetch
สมมติเราต้องการตั้งเวลา timeout ไว้ที่ 2 วินาที หากการร้องขอข้อมูลนานเกิน 2 วินาทีให้ทำการ abort request นั้นเสีย
1const controller = new AbortController()2const signal = controller.signal34// เมื่อครบ 2 วินาทีให้ทำการ abort5setTimeout(() => controller.abort(), 2000)67fetch('https://jsonplaceholder.typicode.com/posts', { signal })8 .then((res) => res.json())9 .then(console.log)10 .catch((ex) =>11 // กรณีของ AbortError ให้ทำการพิมพ์ Fetch aborted12 console.error(ex.name === 'AbortError' ? 'Fetch aborted' : ex.message)13 )
การกำหนด abort ให้เป็นส่วนหนึ่งของผลลัพธ์ Fetch
สมมติเราอยาก custom เจ้าตัว fetch ของเราให้สามารถ abort ได้โดยตรงผ่านการสร้างฟังก์ชัน abortableFetch
ดังนี้
1function abortableFetch(request, opts = {}) {2 const controller = new AbortController()3 const signal = controller.signal45 return {6 abort() {7 controller.abort()8 },9 ready() {10 // เมื่อเรียกเมธอด ready จะทำการ fetch ข้อมูลตามปกติ11 // เพิ่มเติมคือส่ง signal ไปด้วย12 // หากต้องการ abort ให้เรียกผ่านเมธอด abort13 return fetch(request, { ...opts, signal })14 },15 }16}
เช่นเดียวกับสถานการณ์ข้างต้น หาก timeout ของเราอยู่ที่ 2 วินาที เราสามารถตั้ง timeout ควบคู่กับการใช้งาน abortableFetch ได้ดังนี้
1const promise = abortableFetch('https://jsonplaceholder.typicode.com/posts')23promise4 .ready()5 .then((res) => res.json())6 .then(console.log)7 .catch((ex) =>8 console.error(ex.name === 'AbortError' ? 'Fetch aborted' : ex.message)9 )1011// ทำการยกเลิกเมื่อครบ timeout ที่ 2 วินาที12setTimeout(() => promise.abort(), 2000)
แหมะ จะมานั่ง setTimeout ทุกครั้งเพื่อทำการ abort ก็กะไรอยู่ จัดการให้ abortableFetch สนับสนุน timeout ไปซะเลยซิ!
1// opts ที่ส่งเข้ามาสามารถใส่ค่า timeout ได้2function abortableFetch(request, opts = {}) {3 // ดึงเอา timeout ออกมาจาก options ที่ส่งเข้ามา4 const { timeout, ...rest } = opts5 const controller = new AbortController()6 const signal = controller.signal78 return {9 abort() {10 controller.abort()11 },12 ready() {13 // abort เมื่อครบ timeout14 if (timeout) setTimeout(() => controller.abort(), timeout)1516 return fetch(request, { ...rest, signal })17 },18 }19}
ด้วยรูปโฉมใหม่ ตอนนี้ abortableFetch ก็สนับสนุนการใช้งาน timeout แล้ว
1const promise = abortableFetch('https://jsonplaceholder.typicode.com/posts', {2 timeout: 2000,3})45promise6 .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 ดังนี้
1const BASE_API_ENDPOINT = 'https://jsonplaceholder.typicode.com'23function fetchUsersByIds(userIds) {4 // หา user แต่ละคนตามแต่ ID ที่ส่งเข้ามา5 const userFetchs = userIds.map((id) =>6 fetch(`${BASE_API_ENDPOINT}/users/${id}`).then((res) => res.json())7 )89 // รอให้หาครบทุก user ก่อน10 return Promise.all(userFetchs)11}1213function 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}2425// แสดงผลลัพธ์เป็น users ที่เขียนแต่ละโพสต์26fetchAuthors().then(console.log)
AbortSignal นั้นสามารถใช้เพื่อยกเลิกหลาย fetches พร้อมกันได้ในคราเดียว ดังนั้นแล้วหากเราต้องการยกเลิกการค้นหาข้างต้นหากการทำงานเกิน 2 วินาที จึงสามารถแปลงโค้ดได้ใหม่ดังนี้
1const BASE_API_ENDPOINT = 'https://jsonplaceholder.typicode.com'23function fetchUsersByIds(userIds, signal) {4 const userFetchs = userIds.map((id) =>5 fetch(`${BASE_API_ENDPOINT}/users/${id}`, { signal }).then((res) =>6 res.json()7 )8 )910 return Promise.all(userFetchs)11}1213function 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}1920const controller = new AbortController()21const signal = controller.signal2223// ส่ง signal เข้าไปเพื่อใช้ควบคุมการ abort24fetchAuthors(signal)25 .then(console.log)26 .catch((ex) =>27 console.error(ex.name === 'AbortError' ? 'Fetch aborted' : ex.message)28 )2930// abort เมื่อครบ 2 วินาที31setTimeout(() => {32 console.log('Aborted!')33 controller.abort()34}, 2000)
AbortController และ AbortSignal มิได้ใช้ได้กับแค่ Fetch
Fetch API เอ๋ย อย่าสำคัญตัวผิดไป AbortController และ AbortSignal ไม่ได้ออกแบบมาให้แกใช้คนเดียวหรอกนะ หากแต่เป็น API ใด ๆ ที่สนับสนุนก็ใช้ได้ต่างหาก
1function doSth({ signal }) {2 // ถ้า signal มีสถานะเป็น aborted ให้โยน AbortError ไปพร้อม reject3 if (signal.aborted) {4 return Promise.reject(new DOMException('Aborted', 'AbortError'))5 }67 return new Promise((resolve, reject) => {8 // คอยดักฟังว่าสัญญาณเป็น aborted sinvw,j9 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
- สรุป
- เอกสารอ้างอิง