در این مقاله، بهصورت گامبهگام با نحوه استفاده از APIهای کاربردی AbortController
و AbortSignal
در محیطهای فرانتاند و بکاند آشنا خواهیم شد. تمرکز اصلی ما بر استفاده از AbortController در React برای مدیریت و لغو درخواستهای asynchronous خواهد بود، اما نحوه پیادهسازی آن در Node.js را نیز بررسی خواهیم کرد.
API مربوط به AbortController
از نسخه ۱۵ به Node.js افزوده شد. این API ابزاری کاربردی برای متوقفسازی فرآیندهای asynchronous محسوب میشود و عملکردی مشابه رابط AbortController
در محیط مرورگر دارد.
برای استفاده از این API، ابتدا باید یک نمونه از کلاس AbortController
ایجاد کنیم:
const controller = new AbortController();
نمونهای که از کلاس AbortController
ساخته میشود، شامل دو ویژگی است:
abort
signal
فراخوانی متد abort
، یک event با نام abort
منتشر میکند تا به API قابل لغو که به controller متصل است، اطلاع دهد که عملیات باید متوقف شود. همچنین هنگام فراخوانی abort
میتوانیم یک دلیل دلخواه نیز برای لغو عملیات ارسال کنیم. اگر دلیلی مشخص نشود، بهصورت پیشفرض خطای AbortError
صادر خواهد شد.
برای شنیدن event مربوط به abort
، باید با استفاده از متد addEventListener
، یک event listener به ویژگی signal
اضافه کنیم تا در زمان وقوع event، کدی اجرا شود. برای حذف این listener نیز میتوانیم از متد removeEventListener
استفاده کنیم.
کد زیر نحوه اضافه کردن و حذف کردن event listener abort
را با استفاده از این دو متد نشان میدهد:
const controller = new AbortController(); const { signal } = controller; const abortEventListener = (event) => { console.log(signal.aborted); // true console.log(signal.reason); // Hello World }; signal.addEventListener("abort", abortEventListener); controller.abort("Hello World"); signal.removeEventListener("abort", abortEventListener);
ویژگی signal
در controller دارای دو ویژگی کلیدی reason
و aborted
است. ویژگی reason
، دلیلی است که هنگام فراخوانی abort
به آن ارسال میشود. مقدار اولیه این ویژگی undefined
است، اما پس از لغو عملیات، به مقدار دلیل ارائهشده (یا AbortError
در صورت عدم ارائه دلیل) تغییر خواهد کرد.
همچنین ویژگی aborted
که بهصورت پیشفرض مقدار false
دارد، پس از فراخوانی متد abort
به true
تغییر میکند.
برخلاف مثالهای آموزشی ساده، در عمل استفاده از AbortController
به این صورت است که ویژگی signal
را به APIهایی ارسال میکنیم که قابلیت لغو دارند. حتی میتوان یک signal
را به چندین عملیات asynchronous تخصیص داد؛ این APIها در صورت فراخوانی abort
توسط controller، عملیات خود را متوقف خواهند کرد.
بسیاری از APIهای داخلی که از لغو پشتیبانی میکنند، این مکانیزم را بهطور پیشفرض پیادهسازی کردهاند. تنها کافی است ویژگی signal
را به آنها بدهیم تا در زمان فراخوانی متد abort
، فرآیندشان بهدرستی متوقف شود.
اما اگر قصد پیادهسازی عملکردی دلخواه و مبتنی بر Promise داشته باشیم که قابلیت لغو داشته باشد، باید بهصورت دستی یک event listener برای abort
اضافه کرده و هنگام وقوع آن، عملیات مورد نظر را متوقف کنیم.
زبان جاوااسکریپت بهصورت پیشفرض single-threaded است. با توجه به محیط اجرایی، موتور جاوااسکریپت پردازشهای asynchronous مانند درخواستهای شبکهای، دسترسی به سیستم فایل و سایر وظایف زمانبر را به برخی APIهای دیگر واگذار میکند تا بهصورت asynchronous اجرا شوند.
بهطور معمول انتظار داریم که نتیجه یک عملیات asynchronous یا موفقیتآمیز باشد یا با خطا مواجه شود. با این حال، ممکن است عملیات زمان بیشتری نسبت به حد انتظار بگیرد یا حتی در برخی موارد دیگر، اصلاً به نتیجه آن نیازی نداشته باشیم.
بنابراین منطقی است که عملیاتی که بیش از حد طول کشیده یا نتیجهاش دیگر مورد نیاز نیست را لغو کنیم. اما تا پیش از ارائه AbortController، این کار به صورت native در جاوااسکریپت بسیار دشوار بود.
با معرفی AbortController
در نسخه ۱۵ Node.js، امکان لغو برخی عملیات asynchronous بهصورت native فراهم شد.
AbortController
یکی از قابلیتهای نسبتاً جدید در Node.js است. بنابراین در حال حاضر تنها برخی از APIهای asynchronous از آن پشتیبانی میکنند. این APIها شامل نسخه جدید Fetch API، تایمرها، fs.readFile
، fs.writeFile
، http.request
و https.request
میباشند.
در این بخش، نحوه استفاده از AbortController
در کنار برخی APIهای داخلی را بررسی میکنیم. از آنجا که روش استفاده در اکثر آنها مشابه است، تنها به دو مورد پرکاربرد یعنی Fetch و fs.readFile
بسنده خواهیم کرد.
در گذشته، کتابخانه node-fetch
ابزار اصلی برای ارسال درخواستهای HTTP در Node.js محسوب میشد. اما با معرفی API داخلی fetch در نسخههای جدید Node.js، این روند در حال تغییر است.
fetch یکی از APIهای native در Node است که میتوانیم عملکرد آن را با استفاده از AbortController
کنترل کنیم. همانطور که پیشتر نیز اشاره شد، برای لغو عملیات باید ویژگی signal
را به اینگونه APIها که از Promise پشتیبانی میکنند، ارسال نماییم.
کد زیر نشان میدهد که چطور میتوانیم از AbortController
همراه با fetch در Node.js استفاده کنیم:
const url = "https://jsonplaceholder.typicode.com/todos/1"; const controller = new AbortController(); const signal = controller.signal; const fetchTodo = async () => { try { const response = await fetch(url, { signal }); const todo = await response.json(); console.log(todo); } catch (error) { if (error.name === "AbortError") { console.log("Operation timed out"); } else { console.error(err); } } }; fetchTodo(); controller.abort();
مثال بالا بهصورت ساده نحوه استفاده از AbortController
را با API fetch در Node نمایش میدهد. اما در پروژههای واقعی، بهندرت پیش میآید که بلافاصله پس از شروع عملیات، آن را لغو نماییم.
همچنین لازم است تاکید کنیم که fetch هنوز در Node.js یک ویژگی آزمایشی محسوب میشود و ممکن است در نسخههای آینده تغییراتی در آن ایجاد شود.
در بخش قبلی نحوه استفاده از AbortController
همراه با API fetch را بررسی کردیم. بههمان صورت، میتوانیم از این قابلیت با سایر APIهای قابل لغو نیز بهره ببریم.
برای این منظور، کافی است ویژگی signal
مربوط به controller را به تابع موردنظر (مثلاً fs.readFile
) ارسال نماییم. مثال زیر نحوه استفاده از AbortController
را همراه با fs.readFile
نشان میدهد:
const fs = require("node:fs"); const controller = new AbortController(); const { signal } = controller; fs.readFile("data.txt", { signal, encoding: "utf8" }, (error, data) => { if (error) { if (error.name === "AbortError") { console.log("Read file process aborted"); } else { console.error(error); } return; } console.log(data); }); controller.abort();
از آنجا که سایر APIهای قابل لغو نیز تقریباً به همین شیوه با AbortController
کار میکنند، در این مقاله به بررسی آنها نمیپردازیم.
هر نمونهای از کلاس AbortController دارای یک نمونه متناظر از کلاس AbortSignal
است که از طریق ویژگی signal
قابل دسترسی میباشد.
با این حال، کلاس AbortSignal
دارای قابلیتهایی مانند متد استاتیک AbortSignal.timeout
نیز هست که میتوانیم بهصورت مستقل از AbortController نیز از آنها استفاده کنیم.
کلاس AbortSignal
از کلاس EventTarget
ارثبری کرده است و میتواند event abort
را دریافت کند. بنابراین میتوانیم با استفاده از متدهای addEventListener
و removeEventListener
listenerهایی برای این event اضافه یا حذف نماییم:
const controller = new AbortController(); const { signal } = controller; signal.addEventListener( "abort", () => { console.log("First event handler"); }, { once: true } ); signal.addEventListener( "abort", () => { console.log("Second event handler"); }, { once: true } ); controller.abort();
همانطور که در مثال بالا مشاهده میکنیم، میتوانیم تعداد دلخواهی handler برای event abort
تعریف کنیم. با فراخوانی متد abort
، تمام این listenerها فعال میشوند.
برای جلوگیری از memory leak، حذف event handler پس از لغو عملیات یک رویه استاندارد محسوب میشود.
همچنین بهجای حذف دستی handler با removeEventListener
، میتوانیم هنگام افزودن آن از آرگومان اختیاری { once: true }
استفاده کنیم. در این صورت، event تنها یک بار اجرا میشود و سپس بهصورت خودکار حذف خواهد شد.
علاوه بر استفاده در کنار AbortController، کلاس AbortSignal دارای متدهای کاربردی خاص خود نیز هست. یکی از این متدها، متد استاتیک AbortSignal.timeout
میباشد.
همانطور که از نام آن پیداست، از این متد میتوان برای لغو عملیات asynchronous پس از گذشت زمان مشخصی استفاده کرد. این متد، یک عدد بر حسب میلیثانیه دریافت کرده و یک سیگنال بازمیگرداند که میتوانیم از آن برای لغو عملیات قابل لغو استفاده نماییم.
در ادامه، نحوه استفاده از این متد را در کنار API fetch مشاهده خواهیم کرد.
const signal = AbortSignal.timeout(200); const url = "https://jsonplaceholder.typicode.com/todos/1"; const fetchTodo = async () => { try { const response = await fetch(url, { signal }); const todo = await response.json(); console.log(todo); } catch (error) { if (error.name === "AbortError") { console.log("Operation timed out"); } else { console.error(err); } } }; fetchTodo();
میتوانیم از AbortSignal.timeout
به شیوهای مشابه برای سایر APIهای قابل لغو نیز استفاده کنیم.
زمانی که با بیش از یک abort signal سر و کار داریم، میتوانیم آنها را با استفاده از متد AbortSignal.any()
ترکیب کنیم. این قابلیت زمانی بسیار مفید است که بخواهیم چندین روش مختلف برای لغو یک عملیات داشته باشیم.
در مثال زیر، دو controller ایجاد خواهیم کرد:
اولی توسط کاربر API کنترل میشود و دومی برای اهداف داخلی مانند تعیین محدودیت زمانی به کار میرود. اگر هرکدام از این دو سیگنال عملیات را لغو کند، event listener مربوطه حذف خواهد شد:
// Create two separate controllers for different concerns const userController = new AbortController(); const timeoutController = new AbortController(); // Set up a timeout that will abort after 5 seconds setTimeout(() => timeoutController.abort(), 5000); // Register an event listener that can be aborted by either signal document.addEventListener('click', handleUserClick, { signal: AbortSignal.any([userController.signal, timeoutController.signal]) });
اگر هر یک از سیگنالها در گروه ترکیبی، عملیات را لغو کند، سیگنال ترکیبی بلافاصله غیرفعال میشود و سایر eventهای لغو بعد از آن نادیده گرفته میشوند. این روش باعث میشود مسئولیتها بهخوبی از هم جدا و قابل مدیریت باشند.
AbortSignals
این امکان را فراهم میکند که جریانها را بهآسانی متوقف کنیم. برای مثال، اگر بخواهیم یک جریان را متوقف کنیم چون به مقدار موردنظر خود رسیدهایم، یا قصد داریم کاری کاملاً متفاوت انجام دهیم، میتوانیم از AbortSignal
به شکل زیر استفاده نماییم:
const abortController = new AbortController(); const { signal } = abortController; const uploadStream = new WritableStream({ /* implementation */ }, { signal }); // To abort: abortController.abort();
در مثال بالا، یک AbortController
ایجاد میکنیم که سیگنال حاصل از آن را به گزینههای constructor WritableStream
پاس میدهیم. سپس با فراخوانی متد abort()
روی controller، پردازش جریان را بهصورت کامل لغو میکنیم.
همانطور که در بخشهای قبلی اشاره شد، چندین API داخلی asynchronous از AbortController
پشتیبانی میکنند. با این حال، میتوانیم یک API سفارشی مبتنی بر Promise نیز بسازیم که با AbortController
قابل لغو باشد.
مشابه APIهای داخلی، API ما باید ویژگی signal
مربوط به یک نمونه از کلاس AbortController
را به عنوان ورودی دریافت کند. این روش در طراحی APIهایی که از Abort پشتیبانی میکنند، بهعنوان یک استاندارد رایج شناخته میشود:
const myAbortableApi = (options = {}) => { const { signal } = options; if (signal?.aborted === true) { throw new Error(signal.reason); } const abortEventListener = () => { // Abort API from here }; if (signal) { signal.addEventListener("abort", abortEventListener, { once: true }); } try { // Run some asynchronous code if (signal?.aborted === true) { throw new Error(signal.reason); } // Run more asynchronous code } finally { if (signal) { signal.removeEventListener("abort", abortEventListener); } } };
در مثال بالا، ابتدا بررسی میکنیم که آیا ویژگی aborted
در signal
مقدار true
دارد یا نه. اگر این ویژگی true
باشد، یعنی متد abort
روی controller فراخوانی شده و باید یک خطا throw کنیم.
همانطور که در بخشهای قبلی نیز توضیح داده شد، میتوانیم event listener abort
را با متد addEventListener
ثبت کنیم. برای جلوگیری از memory leak، گزینه { once: true }
را به عنوان سومین آرگومان به addEventListener
میدهیم تا پس از اجرای event handler، abort
بهصورت خودکار حذف شود.
همچنین، در بلاک finally
نیز event listener را با استفاده از removeEventListener
حذف میکنیم تا در صورتی که تابع myAbortableApi
بدون لغو کامل شود، event listener همچنان متصل باقی نماند و باعث مصرف غیرضروری حافظه نشود.
API مربوط به AbortController برای توسعهدهندگان React بسیار مفید است، اما استفاده از آن در این محیط کمی متفاوت میباشد.
برای مثال، در مواقعی که نیاز به استفاده از event listenerها داریم، باید با دقت از addEventListener
استفاده کرده و سپس در یک تابع cleanup، removeEventListener
های مربوطه را حذف کنیم.
هرچند این روش در عمل مؤثر است، اما میتواند خستهکننده باشد و احتمال بروز خطاهای تایپی در آن بالاست. در ادامه، یک مثال واقعی را بررسی میکنیم.
فرض کنید در حال توسعه یک داشبورد هستیم که حرکت ماوس را ردیابی میکند، به کلیدهای میانبر کیبورد واکنش نشان میدهد، موقعیت اسکرول را بررسی میکند و نسبت به تغییر اندازه پنجره پاسخ میدهد. اینها چهار event listener متفاوت هستند که باید بهدرستی مدیریت شوند.
اگر یک سناریوی معمولی را در نظر بگیریم، کد ما به این شکل خواهد بود:
useEffect(() => { // Define all your handler functions const handleMouseMove = (e) => { /* update state */ }; const handleKeyPress = (e) => { /* update state */ }; const handleScroll = () => { /* update state */ }; const handleResize = () => { /* update state */ }; // Add all the listeners document.addEventListener('mousemove', handleMouseMove); document.addEventListener('keydown', handleKeyPress); window.addEventListener('scroll', handleScroll); window.addEventListener('resize', handleResize); // Return a cleanup function that removes them all return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('keydown', handleKeyPress); window.removeEventListener('scroll', handleScroll); window.removeEventListener('resize', handleResize); }; }, []);
این کد کار میکند، اما باید referenceهای تمام توابع را نگه داریم تا بتوانیم همان reference را در addEventListener
و removeEventListener
استفاده کنیم. حال اگر از AbortController استفاده نماییم، میتوانیم به این صورت عمل کنیم:
useEffect(() => { const controller = new AbortController(); const { signal } = controller; // Define all your handler functions const handleMouseMove = (e) => { /* update state */ }; const handleKeyPress = (e) => { /* update state */ }; const handleScroll = () => { /* update state */ }; const handleResize = () => { /* update state */ }; // Add all the listeners with the signal document.addEventListener('mousemove', handleMouseMove, { signal }); document.addEventListener('keydown', handleKeyPress, { signal }); window.addEventListener('scroll', handleScroll, { signal }); window.addEventListener('resize', handleResize, { signal }); // Just one line for cleanup! return () => controller.abort(); }, []);
با استفاده از AbortController میتوانیم تنها با یک خط کد تمام event listenerها را، بدون توجه به تعداد آنها پاکسازی کنیم. این روش بسیار تمیزتر است و احتمال بروز خطا را بهشدت کاهش میدهد.
بیشتر نمونههایی که بهصورت آنلاین درباره استفاده از AbortController در React منتشر شدهاند، معمولاً درون یک هوک useEffect()
پیادهسازی شدهاند. با این حال، برای بهرهگیری از AbortController در React، الزاماً نیازی به استفاده از useEffect نیست.
در واقع، میتوان بهصورت مستقیم و مداوم از AbortController
در زمان ارسال درخواستهای fetch استفاده کرد.
برای مثال، فرض کنید در یک پروژه نیاز به پیادهسازی قابلیت جستجوی live داریم؛ به این صورت که همزمان با تایپ کاربر، درخواستهایی به سمت سرور ارسال میشوند.
چالش اینجاست که اگر کاربر سریع تایپ کند، ممکن است چندین درخواست همزمان به سرور ارسال شود. در چنین حالتی، گاهی پاسخ درخواستهای قدیمیتر دیرتر از درخواستهای جدیدتر برمیگردند و باعث بروز مشکلاتی نظیر نمایش نتایج نامرتبط یا تداخل بین نتایج جدید و قدیمی میشوند.
در این شرایط، استفاده از AbortController در React میتواند این مشکل را بهخوبی حل کند:
-language="js">// Key implementation of AbortController for API requests in React import { useRef, useState } from 'react'; // Component with search functionality const SearchComponent = () => { const controllerRef = useRef<AbortController>(); const [query, setQuery] = useState<string>(); const [results, setResults] = useState<Array<any> | undefined>(); async function handleOnChange(e: React.SyntheticEvent) { const target = e.target as typeof e.target & { value: string; }; // Update the query state setQuery(target.value); setResults(undefined); // Cancel any previous in-flight request if (controllerRef.current) { controllerRef.current.abort(); } // Create a new controller for this request controllerRef.current = new AbortController(); const signal = controllerRef.current.signal; try { const response = await fetch('/api/search', { method: 'POST', body: JSON.stringify({ query: target.value }), signal }); const data = await response.json(); setResults(data.results); } catch(e) { // Silently catch aborted requests // For production, you might want to check if error is an AbortError } } return ( <div> <input type="text" onChange={handleOnChange} /> {/* Results rendering */} </div> ); };
با استفاده از هوک useRef
، توانستیم referenceای به درخواست جاری نگه داریم و هر بار که مقدار ورودی تغییر میکند، آن را لغو کرده و درخواست جدیدی ارسال کنیم.
در حالت عادی، یک فرآیند asynchronous ممکن است با موفقیت به پایان برسد، با شکست مواجه شود یا بیش از حد انتظار زمان ببرد. بنابراین، منطقی است که بتوانیم در مواقعی که عملیات asynchronous دیگر موردنیاز نیست یا بیش از حد طول کشیده، آن را بهصورت دستی متوقف کنیم.
API مربوط به AbortController
دقیقاً برای پاسخ به همین نیاز طراحی شده است.
این API بهصورت سراسری در Node.js و مرورگر در دسترس است و نیازی به import کردن آن وجود ندارد. یک نمونه از کلاس AbortController
دو قابلیت کلیدی را فراهم میکند:
متد abort
برای لغو عملیات
ویژگی signal
برای ارائه سیگنال لغو به API مورد نظر
ویژگی signal
در واقع نمونهای از کلاس AbortSignal
است. هر بار که از AbortController
یک نمونه ساخته میشود، بهصورت خودکار یک نمونه متناظر از AbortSignal
نیز ایجاد میگردد که از طریق ویژگی signal
قابل دسترسی است.
ما ویژگی signal
را به asynchronous API مورد نظر ارسال میکنیم و هر زمان نیاز به توقف عملیات بود، با فراخوانی متد abort
روی controller، فرآیند لغو را آغاز میکنیم.
در مواردی که APIهای داخلی پاسخگوی نیاز ما نیستند، میتوانیم با استفاده از AbortController
و AbortSignal
یک API سفارشی و قابل لغوی طراحی کنیم که کنترل کامل عملیات را در اختیار ما قرار دهد.
تنها نکته مهمی که باید همواره در نظر داشت، رعایت بهترین شیوههای مدیریت منابع برای جلوگیری از memory leak است؛ موضوعی که در طول این مقاله بهطور کامل مورد بررسی قرار گرفت.