آموزش استفاده از AbortController در React

در این مقاله، به‌صورت گام‌به‌گام با نحوه استفاده از APIهای کاربردی AbortController و AbortSignal در محیط‌های فرانت‌اند و بک‌اند آشنا خواهیم شد. تمرکز اصلی ما بر استفاده از AbortController در React برای مدیریت و لغو درخواست‌های asynchronous خواهد بود، اما نحوه پیاده‌سازی آن در Node.js را نیز بررسی خواهیم کرد.

معرفی API AbortController و کاربردهای آن در مدیریت عملیات asynchronous

API مربوط به AbortController از نسخه ۱۵ به Node.js افزوده شد. این API ابزاری کاربردی برای متوقف‌سازی فرآیندهای asynchronous محسوب می‌شود و عملکردی مشابه رابط AbortController در محیط مرورگر دارد.

برای استفاده از این API، ابتدا باید یک نمونه از کلاس AbortController ایجاد کنیم:

const controller = new AbortController();

آشنایی با متد abort و ویژگی signal

نمونه‌ای که از کلاس AbortController ساخته می‌شود، شامل دو ویژگی است:

  • متد abort
  • ویژگی signal

فراخوانی متد abort، یک event با نام abort منتشر می‌کند تا به API قابل لغو که به controller متصل است، اطلاع دهد که عملیات باید متوقف شود. همچنین هنگام فراخوانی abort می‌توانیم یک دلیل دلخواه نیز برای لغو عملیات ارسال کنیم. اگر دلیلی مشخص نشود، به‌صورت پیش‌فرض خطای AbortError صادر خواهد شد.

استفاده از متد addEventListener برای شنیدن abort event

برای شنیدن 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);

بررسی ویژگی‌های reason و aborted در signal

ویژگی 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 اضافه کرده و هنگام وقوع آن، عملیات مورد نظر را متوقف کنیم.

دلایل استفاده از AbortController در پروژه‌های Node.js و مرورگر

زبان جاوااسکریپت به‌صورت پیش‌فرض single-threaded است. با توجه به محیط اجرایی، موتور جاوااسکریپت پردازش‌های asynchronous مانند درخواست‌های شبکه‌ای، دسترسی به سیستم فایل و سایر وظایف زمان‌بر را به برخی APIهای دیگر واگذار می‌کند تا به‌صورت asynchronous اجرا شوند.

به‌طور معمول انتظار داریم که نتیجه یک عملیات asynchronous یا موفقیت‌آمیز باشد یا با خطا مواجه شود. با این حال، ممکن است عملیات زمان بیشتری نسبت به حد انتظار بگیرد یا حتی در برخی موارد دیگر، اصلاً به نتیجه آن نیازی نداشته باشیم.

بنابراین منطقی است که عملیاتی که بیش از حد طول کشیده یا نتیجه‌اش دیگر مورد نیاز نیست را لغو کنیم. اما تا پیش از ارائه AbortController، این کار به صورت native در جاوااسکریپت بسیار دشوار بود.

با معرفی AbortController در نسخه ۱۵ Node.js، امکان لغو برخی عملیات asynchronous به‌صورت native فراهم شد.

نحوه استفاده از AbortController در محیط Node.js

AbortController یکی از قابلیت‌های نسبتاً جدید در Node.js است. بنابراین در حال حاضر تنها برخی از APIهای asynchronous از آن پشتیبانی می‌کنند. این APIها شامل نسخه جدید Fetch API، تایمرها، fs.readFile، fs.writeFile، http.request و https.request می‌باشند.

در این بخش، نحوه استفاده از AbortController در کنار برخی APIهای داخلی را بررسی می‌کنیم. از آنجا که روش استفاده در اکثر آن‌ها مشابه است، تنها به دو مورد پرکاربرد یعنی Fetch و fs.readFile بسنده خواهیم کرد.

استفاده از AbortController برای لغو عملیات Fetch در Node.js و مرورگر 

در گذشته، کتابخانه 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 و متد fs.readFile 

در بخش قبلی نحوه استفاده از 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 کار می‌کنند، در این مقاله به بررسی آن‌ها نمی‌پردازیم.

بررسی ساختار و نقش کلاس AbortSignal در فرآیند لغو عملیات asynchronous 

هر نمونه‌ای از کلاس 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 تنها یک بار اجرا می‌شود و سپس به‌صورت خودکار حذف خواهد شد.

نحوه اعمال time out در عملیات asynchronous با استفاده از AbortSignal 

علاوه بر استفاده در کنار 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های قابل لغو نیز استفاده کنیم.

روش لغو هم‌زمان چند عملیات asynchronous با استفاده از یک نمونه AbortSignal 

زمانی که با بیش از یک 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های لغو بعد از آن نادیده گرفته می‌شوند. این روش باعث می‌شود مسئولیت‌ها به‌خوبی از هم جدا و قابل مدیریت باشند.

مدیریت جریان داده‌ها در Node.js با کمک AbortSignal برای لغو عملیات

AbortSignals این امکان را فراهم می‌کند که جریان‌ها را به‌آسانی متوقف کنیم. برای مثال، اگر بخواهیم یک جریان را متوقف کنیم چون به مقدار موردنظر خود رسیده‌ایم، یا قصد داریم کاری کاملاً متفاوت انجام دهیم، می‌توانیم از AbortSignal به شکل زیر استفاده نماییم:

const abortController = new AbortController();
const { signal } = abortController;

const uploadStream = new WritableStream({
  /* implementation */
}, { signal });

// To abort:
abortController.abort();

در مثال بالا، یک AbortController ایجاد می‌کنیم که سیگنال حاصل از آن را به گزینه‌های constructor WritableStream پاس می‌دهیم. سپس با فراخوانی متد abort() روی controller، پردازش جریان را به‌صورت کامل لغو می‌کنیم.

پیاده‌سازی یک asynchronous API و قابل لغو با استفاده از AbortController و AbortSignal 

همان‌طور که در بخش‌های قبلی اشاره شد، چندین 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 همچنان متصل باقی نماند و باعث مصرف غیرضروری حافظه نشود.

استفاده از AbortController در فریم‌ورک React برای کنترل عملیات asynchronous 

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);
  };
}, []);

مقایسه روش سنتی و استفاده از AbortController برای مدیریت event listener

این کد کار می‌کند، اما باید 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 در درخواست‌های fetch در React

بیشتر نمونه‌هایی که به‌صورت آنلاین درباره استفاده از 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 است؛ موضوعی که در طول این مقاله به‌طور کامل مورد بررسی قرار گرفت.

دیدگاه‌ها:

افزودن دیدگاه جدید