بررسی Generator در جاوااسکریپت

Generator در جاوااسکریپت این امکان را می‌دهد که به راحتی iteratorها را تعریف کنیم و کدی بنویسیم که بتوان آن را متوقف و در ادامه اجرا کرد. این قابلیت کنترل دقیق‌تری بر جریان اجرای کد فراهم می‌کند.

بسیاری از توسعه‌دهندگان برای مدیریت تسک‌های asynchronous به ابزارهایی مانند RxJS یا سایر observableها روی می‌آورند، اما generatorها اغلب نادیده گرفته می‌شوند، در حالی که می‌توانند بسیار قدرتمند باشند.

در این مقاله بررسی می‌کنیم که چگونه generatorها به ما اجازه می‌دهند از یک تابع خارج شویم، state را مدیریت کنیم، پیشرفت عملیات‌های طولانی را گزارش دهیم و در نهایت کدی خواناتر داشته باشیم. همچنین، آن‌ها را با راه‌حل‌های محبوبی مانند RxJS مقایسه می‌کنیم تا ببینیم generatorها در چه مواردی بهتر عمل می‌کنند و چه زمانی استفاده از روش‌های دیگر منطقی‌تر است.

درک Generator در جاوااسکریپت

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

یک تابع generator در جاوااسکریپت یک آبجکت generator برمی‌گرداند که با پروتکل‌های iterable و iterator سازگار است.

Generatorها برای اولین بار در ES6 معرفی شدند و از آن زمان به یکی از قابلیت‌های مهم جاوااسکریپت تبدیل شده‌اند. برای تعریف آن‌ها از کلمه‌ی کلیدی function همراه با * استفاده می‌شود، به این شکل: function*.

در ادامه، یک مثال از نحوه استفاده از generatorها را داریم:

function* generatorFunction() {
  return "Hello World"; //generator body
}

گاهی اوقات ممکن است * را قبل از نام تابع ببینیم، مانند function*. در حالی که این سینتکس کم‌تر رایج است، اما همچنان معتبر می‌باشد.

تفاوت Generatorها با توابع عادی

در نگاه اول، یک generator در جاوااسکریپت ممکن است شبیه یک تابع عادی به نظر برسد (به جز *)، اما تفاوت‌های مهمی وجود دارد که آن‌ها را منحصربه‌فرد و قدرتمند می‌کند.

در یک تابع عادی، زمانی که آن را فراخوانی می‌کنیم، از ابتدا تا انتها اجرا می‌شود و هیچ راهی برای توقف در میانه و ادامه آن وجود ندارد. اما generatorها این امکان را می‌دهند که اجرای کد را در هر نقطه‌ای که yield قرار دارد متوقف کنیم و بعداً آن را ادامه دهیم.

این ویژگی توقف‌پذیری، باعث می‌شود که state بین توقف‌های مختلف حفظ شود، که این قابلیت را برای پردازش مجموعه داده‌های بزرگ در بخش‌های کوچک ایده‌آل می‌کند. علاوه بر این، در حالی که یک تابع عادی هنگام اجرا مقدار نهایی خود را return می‌کند، generatorها هنگام فراخوانی یک آبجکت generator برمی‌گردانند. این آبجکت یک iterator است که می‌توان از آن برای پیمایش در دنباله‌ای از مقادیر استفاده کرد.

هنگامی که با یک generator کار می‌کنیم، آن را فقط یک‌بار فراخوانی نمی‌کنیم و تمام! بلکه با استفاده از متدهایی مانند next()، throw() و return() می‌توانیم state آن را از بیرون کنترل نماییم:

  • next(value): اجرای generator را ادامه می‌دهد. همچنین، می‌تواند یک مقدار را به داخل generator ارسال کند که این مقدار توسط آخرین yield دریافت می‌شود. این متد یک آبجکت شامل دو ویژگی value و done (که مشخص می‌کند iterator به پایان رسیده است یا نه) را برمی‌گرداند.
  • throw(error): یک خطا را در داخل throw generator می‌کند، که به ما امکان مدیریت استثناها را می‌دهد.
  • return(value): اجرای generator را زودتر از موعد خاتمه داده و مقدار مشخص شده را برمی‌گرداند.

این ارتباط دوطرفه یک برتری بزرگ نسبت به توابع عادی محسوب می‌شود و می‌تواند برای مدیریت پردازش‌های پیچیده، جریان‌های کاری و کنترل خطاها مفید باشد.

مثال استفاده از Generatorها

برای شروع، تابع generator Hello World را که قبلاً نشان دادیم، مقداردهی اولیه کرده و value آن را دریافت می‌کنیم:

const generator = generatorFunction();

وقتی تابع generatorFunction() را فراخوانی کرده و در یک متغیر ذخیره می‌کنیم، بلافاصله رشته "Hello World" را دریافت نمی‌کنیم. در عوض، یک آبجکت generator دریافت می‌کنیم که در ابتدا در حالت suspended قرار دارد. این یعنی اجرا متوقف شده و هنوز هیچ کدی اجرا نشده است.

اگر generator را در کنسول لاگ بگیریم، می‌بینیم که یک مقدار ساده نیست، بلکه یک آبجکت است که نشان می‌دهد generator هنوز فعال می‌باشد. برای دریافت مقدار تابع generator، باید متد next() را روی این آبجکت فراخوانی کنیم:

const result = generator.next();

خروجی حاصل به صورت زیر خواهد بود:

{ value: 'Hello World', done: true }

این فراخوانی، رشته “Hello World” را به عنوان مقدار کلید value در آبجکت بازگشتی به ما می‌دهد. همچنین، ویژگی done مقدار true خواهد داشت، زیرا دیگر هیچ کدی برای اجرا باقی نمانده است. در نتیجه، وضعیت تابع آبجکت generator از suspended به closed تغییر می‌کند.

تا اینجا، فقط دیدیم که چگونه می‌توانیم یک مقدار واحد را از یک تابع آبجکت generator برگردانیم. اما اگر بخواهیم چند مقدار مختلف را return کنیم، چه کاری باید انجام دهیم؟
اینجاست که عملگر yield به کمک ما می‌آید.

عملگر yield

Generator در جاوااسکریپت به ما اجازه می‌دهد اجرای تابع را متوقف کرده و دوباره آن را ادامه دهیم، و این کار با استفاده از کلمه‌ی کلیدی yield انجام می‌شود.

به عنوان مثال، فرض کنید یک تابع generator به این شکل داریم:

function* generatorFunction() {
  yield "first value";
  yield "second value";
  yield "third value";
  yield "last value";
}

هر بار که متد next() را روی generator فراخوانی می‌کنیم، تابع تا زمانی که به یک عبارت yield برسد، اجرا می‌شود و سپس متوقف می‌گردد. در این لحظه، تابع generator یک آبجکت شامل دو ویژگی را return می‌کند:

  • value: مقداری که yield return کرده است.
  • done: یک مقدار Boolean که نشان می‌دهد generator به پایان رسیده است یا نه.

تا زمانی که یک yield دیگر در تابع وجود داشته باشد (یا return اجرا نشده باشد)، مقدار done برابر با false خواهد بود. اما به محض این که generator هیچ yieldای برای اجرا نداشته باشد، مقدار done به true تغییر می‌کند.

اگر متد next() را چهار بار روی یک generator که سه yield دارد، اجرا کنیم، خروجی به این شکل خواهد بود:

const generator = generatorFunction();

generator.next(); // { value: 'first value', done: false }
generator.next(); // { value: 'second value', done: false }
generator.next(); // { value: 'third value', done: false }
generator.next(); // { value: 'last value', done: true }

ارسال مقادیر به Generatorها

نکته جالب این است که yield فقط برای return کردن value استفاده نمی‌شود؛ بلکه مثل یک مسیر دوطرفه عمل می‌کند و می‌تواند مقادیر را از بیرون دریافت نماید. این یعنی بین generator و کد فراخوانی‌کننده آن، ارتباط دوطرفه برقرار می‌شود.

برای ارسال مقدار به یک تابع generator در جاوااسکریپت، می‌توانیم متد next() را همراه با یک آرگومان فراخوانی کنیم. به عنوان مثال:

function* generatorFunction() {
  console.log(yield);
  console.log(yield);
}

const generator = generatorFunction();

generator.next(); // First call — no yield has been paused yet, so nothing to pass in
generator.next("first input");
generator.next("second input");

این کد موارد زیر را به ترتیب ثبت می‌کند:

first input
second input

اگر دقت کنیم می‌بینیم که در فراخوانی اول generator.next()، هیچ مقداری چاپ نمی‌شود، دلیل این است که هنوز هیچ yield معلقی برای دریافت مقدار وجود ندارد.
اما در فراخوانی دوم که شامل generator.next("first input") است، مقدار "first input" به yield معلق قبلی ارسال شده و در خروجی چاپ می‌شود. همین الگو برای فراخوانی‌های بعدی نیز دنبال می‌شود.

این دقیقاً روشی است که generator‌ها امکان ارسال و دریافت داده را بین خود و فراخوانی‌کننده فراهم می‌کنند.

پردازش عملیات‌های طولانی و استریم‌های async

async generatorها با معرفی ECMAScript 2017، به جاوااسکریپت اضافه شدند. این نوع خاص از توابع generator، با Promiseها کار می‌کنند.

با کمک async generatorها، دیگر محدود به اجرای کدهای synchronous نیستیم.
اکنون می‌توانیم تسک‌هایی مانند دریافت داده از یک API، خواندن فایل‌ها، یا هر کاری که نیاز به انتظار برای یک Promise دارد را با این روش مدیریت نماییم.

در ادامه مثالی از یک تابع async generator را داریم:

async function* asyncGenerator() {
  yield await Promise.resolve("1");
  yield await Promise.resolve("2");
  yield await Promise.resolve("3");
}

const generator = asyncGenerator();
await generator.next(); // { value: '1', done: false }
await generator.next(); // { value: '2', done: false }
await generator.next(); // { value: '3', done: true }

تفاوت اصلی این نوع generator این است که باید روی هر فراخوانی generator.next() از await استفاده کنیم تا مقدار را دریافت نماییم، زیرا همه چیز به شکل asynchronous اجرا می‌شود.

کاربرد واقعی: دریافت داده‌های صفحه‌بندی‌شده از یک API

در این بخش بررسی می‌کنیم که چگونه می‌توانیم از async generatorها برای دریافت داده‌های صفحه‌بندی‌شده از یک API ریموت استفاده کنیم.
این یک سناریوی عالی برای استفاده از async generatorها است، زیرا می‌توانیم منطق تکرار متوالی داده‌ها را در یک تابع واحد قرار دهیم.

در این مثال، از API  رایگان DummyJSON استفاده می‌کنیم تا لیستی از محصولات صفحه‌بندی‌شده را دریافت نماییم.

برای دریافت داده از این API، باید یک درخواست GET به این Endpoint ارسال کنیم. پارامترهای limit و skip را تنظیم می‌کنیم تا بتوانیم تعداد نتایج و میزان جابه‌جایی در لیست را مشخص نماییم:

https://dummyjson.com/products?limit=10&skip=0

یک نمونه از پاسخ این Endpoint می‌تواند به شکل زیر باشد:

{
  "products": [
    {
      "id": 1,
      "title": "Annibale Colombo Bed",
      "price": 1899.99
    },
    {...},
    // ۱۰ items
  ],
  "total": 194,
  "skip": 0,
  "limit": 10
}

برای بارگذاری مجموعه بعدی از محصولات، تنها کاری که باید انجام دهیم این است که مقدار skip را به اندازه limit افزایش دهیم تا تمام داده‌ها را دریافت کنیم.

به این ترتیب، می‌توانیم یک تابع generator سفارشی برای دریافت داده‌ها را به صورت زیر پیاده‌سازی نماییم:

async function* fetchProducts(skip = 0, limit = 10) {
  let total = 0;

  do {
    const response = await fetch(
      `https://dummyjson.com/products?limit=${limit}&skip=${skip}`,
    );
    const { products, total: totalProducts } = await response.json();

    total = totalProducts;
    skip += limit;
    yield products;
  } while (skip < total);
}

اکنون می‌توانیم با استفاده از حلقه for await...of روی این تابع تکرار کنیم تا همه محصولات را دریافت نماییم:

for await (const products of fetchProducts()) {
  for (const product of products) {
    console.log(product.title);
  }
}

با این روش، تمامی محصولات را یکی پس از دیگری دریافت می‌کنیم تا زمانی که دیگر داده‌ای برای دریافت باقی نماند:

Essence Mascara Lash Princess
Eyeshadow Palette with Mirror
Powder Canister
Red Lipstick
Red Nail Polish
... // ۱۵ more items

چرا این روش مفید است؟

  • مدیریت ساده صفحه‌بندی: با قرار دادن کل منطق دریافت داده‌های صفحه‌بندی‌شده در یک async generator، کد ما تمیز و خوانا باقی می‌ماند.
  • دریافت داده‌های بیشتر به صورت خودکار: هر بار که به داده‌های جدید نیاز داریم، generator مجموعه بعدی از نتایج را دریافت کرده و return می‌کند، و این باعث می‌شود فرآیند صفحه‌بندی به یک جریان داده پیوسته تبدیل شود.
  • بهبود عملکرد و کاهش مصرف حافظه: به جای دریافت همه داده‌ها به یک‌باره، می‌توانیم آن‌ها را به تدریج و به اندازه نیاز دریافت نماییم.

در نتیجه، با استفاده از async generatorها، فرآیند دریافت داده‌های صفحه‌بندی‌شده کارآمدتر، خواناتر و ساده‌تر از همیشه خواهد بود.

استفاده از Generatorها به عنوان State Machineها

Generatorها می‌توانند به عنوان State Machineهای ساده عمل کنند، چون به خاطر می‌سپارند که در کجا متوقف شده‌اند.
اما همیشه بهترین گزینه برای مدیریت state نیستند، مخصوصاً وقتی فریم‌ورک‌های مدرن جاوااسکریپت ابزارهای پیشرفته‌ای برای مدیریت state دارند.

چرا استفاده از generatorها به عنوان State Machine همیشه ایده‌آل نیست؟
با این که generatorها حافظه داخلی دارند و می‌توانند فرآیندها را ادامه دهند، اما در بسیاری از موارد، پیاده‌سازی یک State Machine با آن‌ها در جاوااسکریپت پیچیدگی زیادی دارد و ارزش استفاده از روش‌های ساده‌تر را کم می‌کند.

راه حل جایگزین، مدل Actor است. اگر همچنان بخواهیم چنین رویکردی را بررسی کنیم، مدل Actor گزینه بهتری خواهد بود. این مدل که از زبان Erlang نشأت گرفته، بر پایه واحدهای مستقلی کار می‌کند که state و رفتار خود را مدیریت می‌کنند و فقط از طریق ارسال پیام با یکدیگر ارتباط می‌گیرند. این روش باعث ماژولار شدن سیستم و مدیریت بهتر تغییرات state می‌شود.

مقایسه RxJS و Generatorها برای پردازش Web Streamها

وقتی صحبت از پردازش Web Streamها می‌شود، هم generatorها و هم RxJS ابزارهای قدرتمندی هستند، اما هرکدام مزایا و معایب خاص خود را دارند.

خبر خوب این است که می‌توانیم از هر دو آن‌ها در کنار هم استفاده کنیم.

استفاده از Generator برای پردازش Web Streamها

فرض کنید یک API داریم که چندین رشته ۸ کاراکتری تصادفی را به صورت Stream بازمی‌گرداند.
در قدم اول، می‌توانیم یک تابع generator تعریف کنیم که به صورت Lazy، تکه‌های داده را یکی‌یکی return می‌کند:

// Fetch data from HTTP stream
async function* fetchStream() {
  const response = await fetch("https://example/api/stream");
  const reader = response.body?.getReader();
  if (!reader) throw new Error();

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      yield value;
    }
  } catch (error) {
    throw error;
  } finally {
    reader.releaseLock();
  }
}

فراخوانی fetchStream() یک async generator بازمی‌گرداند، و سپس می‌توانیم داده‌ها را با یک حلقه پردازش کنیم.

اما RxJS می‌تواند قابلیت‌های بیشتری به این فرآیند اضافه نماید.

استفاده از RxJS برای پردازش Web Streamها

RxJS مجموعه‌ای از عملگرهای قدرتمند مثل:

  • map: برای تبدیل داده‌ها
  • filter: برای فیلتر کردن داده‌های خاص
  • take: برای محدود کردن تعداد داده‌ها را در اختیار ما قرار می‌دهد.

برای استفاده از این قابلیت‌ها، ابتدا generator را به یک observable تبدیل می‌کنیم.

اکنون از عملگر take برای فیلتر کردن پنج تکه اول داده استفاده خواهیم کرد:

import { from, take } from "rxjs";

// Consume HTTP stream using RxJS
async () => {
  from(fetchStream())
    .pipe(take(5))
    .subscribe({
      next: (chunk) => {
        const decoder = new TextDecoder();
        console.log("Chunk:", decoder.decode(chunk));
      },
      complete: () => {
        console.log("Stream complete");
      },
    });
};

در این مثال، عملگر from در RxJS، تبدیل Generator به یک Observable را انجام می‌دهد که اجازه می‌دهد داده‌ها را به شکل synchronous مدیریت کنیم.

خروجی کد به صورت زیر خواهد بود:

Chunk: ky^p1egh
Chunk: 1q)zIz43
Chunk: xm5aJGSX
Chunk: GSx6a2UQ
Chunk: GFlwWPu^
Stream complete

آیا می‌توانیم به جای RxJS از حلقه for await...of استفاده کنیم؟

بله، می‌توانیم Stream را به صورت ساده و بدون RxJS مورد استفاده قرار دهیم:

// Consume the HTTP stream using for-await-of
for await (const chunk of fetchStream()) {
  const decoder = new TextDecoder();
  console.log("Chunk:", decoder.decode(chunk));
}

اما در این روش، مزایای RxJS را از دست می‌دهیم، یعنی نمی‌توانیم از عملگرهایی مثل take برای محدود کردن تعداد داده‌ها استفاده کرد. همچین، مدیریت داده‌های پیچیده دشوارتر خواهد شد.

اما خبر خوبی که وجود دارد این است که ویژگی Iteration Helpers در نسخه بعدی ECMAScript (در حال حاضر در مرحله ۴) اضافه خواهد شد. این قابلیت به ما اجازه می‌دهد که به طور Native خروجی generatorها را فیلتر یا محدود نماییم؛ درست مثل کاری که RxJS برای observableها انجام می‌دهد.

جمع‌بندی

Generator در جاوااسکریپت یک روش قدرتمند، اما کم‌تر شناخته شده برای مدیریت عملیات‌های asynchronous، کنترل state و پردازش جریان‌های داده است. با امکان توقف و ازسرگیری اجرا، این قابلیت کنترل دقیق‌تری نسبت به توابع معمولی فراهم می‌کند، به ویژه زمانی که با تسک‌های طولانی یا مجموعه داده‌های بزرگ سروکار داریم.

با این‌حال، در حالی که generatorها در بسیاری از سناریوها عملکرد خوبی دارند، اما ابزارهایی مانند RxJS یک اکوسیستم قدرتمند از اپراتورها ارائه می‌دهند که مدیریت جریان‌های پیچیده و رویدادمحور را ساده‌تر می‌کنند.

البته نیازی به انتخاب قطعی بین این دو روش نیست؛ بلکه می‌توانیم بسته به نیاز خود، سادگی generatorها را با قدرت تبدیل‌های RxJS ترکیب کرده یا حتی فقط از یک حلقه ساده for await...of در پروژه خود استفاده کنیم.

در آینده، Iteration Helperهای جدید ممکن است امکانات generatorها را به RxJS نزدیک‌تر کنند، اما در آینده‌ای قابل پیش‌بینی، RxJS همچنان گزینه‌ای کلیدی برای مدیریت الگوهای واکنشی پیچیده خواهد بود.

دیدگاه‌ها:

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