بررسی closure در جاوااسکریپت و React

closure در React یکی از مفاهیم بنیادین زبان جاوااسکریپت است که درک دقیق آن، نقش بسیار مهمی در توسعه مؤثر و بدون خطای رابط‌های کاربری دارد. این مفهوم می‌تواند منجر به بروز رفتارهای غیرمنتظره در اپلیکیشن‌های React شود، به‌ویژه زمانی که با state یا callbackها درگیر هستیم.

در این مقاله، ضمن تعریف دقیق closure و نحوه عملکرد آن، با ذکر مثال‌هایی ساده و همچنین یک سناریوی واقعی در محیط production، چالش‌های رایج مربوط به closure در React را بررسی کرده و راهکارهایی برای مدیریت آن ارائه می‌دهیم.

دسترسی به کدهای مرتبط با مثال‌های این مقاله از طریق این لینک امکان‌پذیر می‌باشد.

Closure در جاوااسکریپت چیست؟

Closure رابطه‌ای است که بین یک تابع در جاوااسکریپت و متغیرهای در دسترس از محیط بیرونی آن تابع ایجاد می‌شود. در جاوااسکریپت، متغیرها دارای مفهومی به نام scope هستند که مشخص می‌کند هر مقدار، در کدام بخش‌های کد قابل استفاده است. این ساختار دسترسی با اصطلاح lexical scope نیز شناخته می‌شود.

جاوااسکریپت به‌طور کلی سه سطح اصلی از scope را ارائه می‌دهد:

  • Global Scope: مقادیر در تمام بخش‌های برنامه قابل دسترسی هستند.
  • Function Scope: مقادیر فقط درون یک تابع خاص قابل استفاده‌اند.
  • Block Scope: مقادیر تنها در داخل یک بلاک کد (معمولاً محصور در {}) در دسترس هستند.

در ادامه مثالی ساده از scope در کد را داریم:

// Global Scope
let globalValue = "available anywhere";

// Function Scope
function yourFunction() { 
  // var1 and var2 are only accessible in this function
  let var1 = "hello";
  let var2 = "world";

  console.log(var1);
  console.log(var2);
}

// Block Scope
if(globalValue = "available anywhere") {
  // variables defined here are only accssible inside this conditional
  let b1 = "block 1";
  let b2 = "block 2";
}

در مثال بالا:

  • globalValue در همه جای برنامه قابل دسترسی است.
  • var1 و var2 تنها درون تابع yourFunction در دسترس هستند.
  • b1 و b2 نیز فقط در شرایط خاص، globalValue = “available anywhere”، قابل دسترسی هستند.

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

function start() {
  // variable created inside function
  const firstName = "John";

  // function inside the start function which has access to firstName
  function displayFirstName() {
    // displayFirstName creates a closure
    console.log(firstName);
  }
  // should print "John" to the console
  displayName();
}
start();

در پروژه‌های جاوااسکریپتی، closureها گاهی منجر به بروز رفتارهای ناخواسته‌ای می‌شوند؛ به‌ویژه زمانی که برخی متغیرها در دسترس باقی می‌مانند اما برخی دیگر نه.
اما در پروژه‌های React، این اتفاق بیشتر در هنگام مدیریت state لوکال یا هندل کردن eventها درون کامپوننت‌ها دیده می‌شود.

در صورتی که مایل به بررسی عمیق‌تر مفاهیمی همچون closure، توابع Higher-Order و تکنیک Currying هستید، مطالعه مقالات تخصصی منتشر شده در فرانت کست می‌تواند مفید باشد.

Closure در React

در پروژه‌های React، اغلب مشکلات مربوط به closure هنگام مدیریت state به وجود می‌آیند. در اپلیکیشن‌های React، می‌توان از هوک useState برای مدیریت state لوکال یک کامپوننت استفاده کرد. همچنین ابزارهایی مانند Redux برای مدیریت state متمرکز، یا React Context برای اشتراک state بین چند کامپوننت در سطح پروژه نیز در دسترس هستند.

کنترل دقیق state یک کامپوننت یا مجموعه‌ای از کامپوننت‌ها، نیازمند درک صحیحی از این است که چه مقادیری در چه محدوده‌ای قابل دسترسی هستند. هنگام مدیریت state در یک پروژه React، ممکن است با مسائلی مواجه شویم که ناشی از closure بوده و باعث ایجاد تغییرات ناهم‌خوان یا غیرقابل پیش‌بینی شوند.

یک مثال ساده از closure در React

برای توضیح بهتر مفهوم closure در React، مثالی با استفاده از تابع داخلی setTimeout ارائه می‌دهیم. فرض کنید اپلیکیشنی داریم که یک مقدار ورودی را دریافت کرده و بر اساس آن یک عملیات async انجام می‌دهد. معمولاً چنین حالتی زمانی رخ می‌دهد که فرم‌ها یا ورودی‌ها، داده‌ی کاربر را گرفته و به یک API ارسال می‌کنند. اما این رفتار را می‌توانیم با استفاده از setTimeout در یک کامپوننت ساده‌سازی کنیم.

const SetTimeoutIssue = () => {
    const [count, setCount] = useState(0);
    const handleClick = () => {
        setCount(count + 1);
        // This will always show the value of count at the time the timeout was set
        setTimeout(() => {
            console.log('Current count (Issue):', count);
            alert(`Current count (Issue): ${count}`);
        }, ۲۰۰۰);
    };
    return (
        <div className="p-4 bg-black rounded shadow">
            <h2 className="text-xl font-bold mb-4">setTimeout Issue</h2>
            <p className="mb-4">Current count: {count}</p>
            <button
                onClick={handleClick}
                className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
            >
                Increment and Check After 2s
            </button>
            <div className="mt-4 p-4 bg-gray-100 rounded">
                <p className="text-black">
                    Expected: Alert shows the updated count
                </p>
                <p className="text-black">
                    Actual: Alert shows the count from when setTimeout was
                    called
                </p>
            </div>
        </div>
    );
};

در نگاه اول، این کد نباید مشکلی ایجاد کند: کاربر روی دکمه‌ای کلیک می‌کند، مقدار شمارنده افزایش می‌یابد و سپس در یک modal نمایشی نمایش داده می‌شود. اما مشکل دقیقاً در این نقطه به‌وجود می‌آید:

const handleClick = () => {
        setCount(count + 1);
        // This will always show the value of count at the time the timeout was set
        setTimeout(() => {
            console.log('Current count (Issue):', count);
            alert(`Current count (Issue): ${count}`);
        }, ۲۰۰۰);
    };

تحلیل مشکل closure در مثال بالا

مقدار count توسط تابع setTimeout درون یک closure ذخیره می‌شود. اگر این مثال را اجرا کنیم و چند بار پشت سر هم روی دکمه کلیک کنیم، با رفتاری به این صورت مواجه می‌شویم که مقدار نمایش داده شده در modal عدد ۰ است، در حالی که مقدار واقعی count در همان لحظه برابر با ۱ است. دلیل این رفتار آن است که closure ایجاد شده توسط setTimeout، مقدار اولیه متغیر را قفل کرده و به‌روز نمی‌شود.

برای حل این مشکل، می‌توانیم از هوک useRef استفاده کنیم. این هوک یک رفرنس پایدار ایجاد می‌کند که مقدار به‌روزشده متغیر را در طول رندرهای مجدد حفظ می‌کند. در مدیریت state در React، مشکلاتی از این دست ممکن است زمانی رخ دهند که یک رندر جدید، داده‌ای را از state قبلی استخراج کند.

اگر فقط از useState استفاده می‌کنیم و منطق پیچیده‌ای نداریم، معمولاً مشکلی پیش نمی‌آید. اما وقتی closure با state درگیر می‌شود، ممکن است مقادیر را نادرست ذخیره کرده یا در زمان اشتباهی بازیابی کند. در اینجا، نسخه بازنویسی‌شده کامپوننت اولیه را می‌بینیم که مشکل closure را برطرف کرده است:

const SetTimeoutSolution = () => {
    const [count, setCount] = useState(0);
    const countRef = useRef(count);
    // Keep the ref in sync with the state
    countRef.current = count;
    const handleClickWithRef = () => {
        setCount(count + 1);
        // Using ref to get the latest value
        setTimeout(() => {
            console.log('Current count (Solution with Ref):', countRef.current);
            alert(`Current count (Solution with Ref): ${countRef.current}`);
        }, ۲۰۰۰);
    };
    return (
        <div className="p-4 bg-black rounded shadow">
            <h2 className="text-xl font-bold mb-4">setTimeout Solution</h2>
            <p className="mb-4">Current count: {count}</p>
            <div className="space-y-4">
                <div>
                    <button
                        onClick={handleClickWithRef}
                        className="bg-green-500 text-black px-4 py-2 rounded hover:bg-green-600"
                    >
                        Increment and Check After 2s
                    </button>
                    <div className="mt-4 p-4 bg-gray-100 rounded">
                        <p className="text-black">
                            Expected: Alert shows the updated count
                        </p>
                        <p className="text-black">
                            Actual: Alert shows the updated count
                        </p>
                    </div>
                </div>
            </div>
        </div>
    );
};

تفاوت کلیدی این نسخه با نسخه‌ی اولیه در این است:

const [count, setCount] = useState(0);
   const countRef = useRef(count);
   // Keep the ref in sync with the state
   countRef.current = count;

   const handleClickWithRef = () => {
       setCount(count + 1);
       // Using ref to get the latest value
       setTimeout(() => {
           console.log('Current count (Solution with Ref):', countRef.current);
           alert(`Current count (Solution with Ref): ${countRef.current}`);
       }, ۲۰۰۰);
   };

در این بازنویسی، به‌جای استفاده مستقیم از state count، از مقدار countRef استفاده می‌کنیم که به مقدار واقعی و به‌روز count رفرنس دارد. این رفرنس در طول رندرهای مختلف حفظ می‌شود و بنابراین، مشکل ناشی از closure را برطرف می‌کند.

یک نمونه واقعی از Closure در جاوااسکریپت

در یکی از پروژه‌های واقعی در سطح تولید، تیمی که مسئول نگه‌داری و توسعه یک اپلیکیشن با به‌روزرسانی‌های real-time بود، با مشکلی در مدیریت state مواجه شد. اپلیکیشن داده‌های چند صف مختلف را مدیریت می‌کرد و هر صف را در یک تب مجزا در رابط کاربری نمایش می‌داد. پیام‌های مربوط به تغییرات این داده‌ها از طریق سرویس SignalR در Azure از سمت بک‌اند برای فرانت‌اند ارسال می‌شدند. این پیام‌ها مشخص می‌کردند که داده‌ها چگونه باید به‌روزرسانی شوند یا به صف دیگری منتقل گردند.

در جریان اجرای این فرآیند، تیم توسعه با خطاهای متعددی مواجه شد. برخی از به‌روزرسانی‌ها به‌درستی انجام می‌شد، اما سیستم برخی دیگر را نادیده می‌گرفت یا به‌صورت نادرست اعمال می‌کرد. این مسئله باعث نارضایتی کاربران شد و از آنجا که فرآیند ارتباط با SignalR به‌صورت real-time انجام می‌شود، عیب‌یابی و تست مشکل، نیاز به شبیه‌سازی پیام‌های سرور و تعامل مداوم داشت که فرآیندی پیچیده و زمان‌بر بود.

تمرکز بر کد فرانت‌اند: منشأ مشکل کجاست؟

تیم توسعه در ابتدا احتمال داد که مشکل از سمت سرور باشد. اما پس از بررسی کدهای بک‌اند و اطمینان از صحت عملکرد ارسال پیام‌ها، تمرکز تیم بر کدهای فرانت‌اند قرار گرفت. در بررسی دقیق‌تر مشخص شد که مسئله اصلی، به یک مشکل closure در جاوااسکریپت مربوط می‌شود. در کد فرانت‌اند، callback مربوط به دریافت پیام‌های SignalR در واقع به داده‌های قدیمی state دسترسی داشت که منجر به رفتارهای نادرست در رابط کاربری می‌شد.

بازنویسی با useRef: راه‌حلی برای حفظ مقدار به‌روز state

برای حل این مشکل، handler دریافت پیام‌ها بازنویسی شد و از هوک useRef استفاده گردید تا همواره به جدیدترین مقدار state دسترسی داشته باشد. این دقیقاً همان روشی است که در مثال setTimeout نیز پیش‌تر بررسی کردیم. در صورتی که مقاله را همراه با پروژه‌ی نمونه دنبال می‌کنید، این بخش در کامپوننت‌های SignalRIssue و SignalRSolution قابل بررسی است.

کد اولیه کامپوننت SignalRIssue به‌صورت زیر می‌باشد:

import React, { useState, useEffect } from 'react';
import { ValueLocation, MoveMessage } from '../types/message';
import { createMockHub, createInitialValues } from '../utils/mockHub';
import ValueList from './ValueList';
import MessageDisplay from './MessageDisplay';

const SignalRIssue: React.FC = () => {
    const [tabAValues, setTabAValues] = useState<ValueLocation[]>(() =>
        createInitialValues()
    );
    const [tabBValues, setTabBValues] = useState<ValueLocation[]>([]);
    const [activeTab, setActiveTab] = useState<'A' | 'B'>('A');
    const [lastMove, setLastMove] = useState<MoveMessage | null>(null);
    useEffect(() => {
        const hub = createMockHub();
        hub.on('message', (data: MoveMessage) => {
            // The closure captures these initial arrays and will always reference
            // their initial values throughout the component's lifecycle
            if (data.targetTab === 'A') {
                // Remove from B (but using stale B state)
                setTabBValues(tabBValues.filter((v) => v.value !== data.value));
                // Add to A (but using stale A state)
                setTabAValues([
                    ...tabAValues,
                    {
                        tab: 'A',
                        value: data.value,
                    },
                ]);
            } else {
                // Remove from A (but using stale A state)
                setTabAValues(tabAValues.filter((v) => v.value !== data.value));
                // Add to B (but using stale B state)
                setTabBValues([
                    ...tabBValues,
                    {
                        tab: 'B',
                        value: data.value,
                    },
                ]);
            }
            setLastMove(data);
        });
        hub.start();
        return () => {
            hub.stop();
        };
    }, []); // Empty dependency array creates the closure issue

    return (
        <div className="p-4 bg-black rounded shadow">
            <h2 className="text-xl font-bold mb-4">SignalR Issue</h2>
            <div className="min-h-screen w-full flex items-center justify-center py-8">
                <div className="max-w-2xl w-full mx-4">
                    <div className="bg-gray-800 rounded-lg shadow-xl overflow-hidden">
                        <MessageDisplay message={lastMove} />
                        <div className="border-b border-gray-700">
                            <div className="flex">
                                <button
                                    onClick={() => setActiveTab('A')}
                                    className={`px-6 py-3 text-sm font-medium flex-1 ${
                                        activeTab === 'A'
                                            ? 'border-b-2 border-purple-500 text-purple-400 bg-purple-900/20'
                                            : 'text-gray-400 hover:text-purple-300 hover:bg-purple-900/10'
                                    }`}
                                >
                                    Tab A ({tabAValues.length})
                                </button>
                                <button
                                    onClick={() => setActiveTab('B')}
                                    className={`px-6 py-3 text-sm font-medium flex-1 ${
                                        activeTab === 'B'
                                            ? 'border-b-2 border-emerald-500 text-emerald-400 bg-emerald-900/20'
                                            : 'text-gray-400 hover:text-emerald-300 hover:bg-emerald-900/10'
                                    }`}
                                >
                                    Tab B ({tabBValues.length})
                                </button>
                            </div>
                        </div>
                        {activeTab === 'A' ? (
                            <ValueList values={tabAValues} tab={activeTab} />
                        ) : (
                            <ValueList values={tabBValues} tab={activeTab} />
                        )}
                    </div>
                    <div className="mt-4 p-4 bg-yellow-900 rounded-lg border border-yellow-700">
                        <h3 className="text-sm font-medium text-yellow-300">
                            Issue Explained
                        </h3>
                        <p className="mt-2 text-sm text-yellow-200">
                            This component demonstrates the closure issue where
                            the event handler captures the initial state values
                            and doesn't see updates. Watch as values may
                            duplicate or disappear due to stale state
                            references.
                        </p>
                    </div>
                </div>
            </div>
        </div>
    );
};
export default SignalRIssue;

این کامپوننت به‌صورت اولیه بارگذاری می‌شود، به یک hub متصل می‌گردد (در اینجا یک نسخه‌ی شبیه‌سازی‌شده از اتصال SignalR ایجاد شده است)، و سپس به‌محض دریافت پیام‌ها، واکنش نشان می‌دهد. در کلاینت شبیه‌سازی شده SignalR از تابع setInterval استفاده شده و به‌صورت تصادفی مقادیر بین تب‌ها جابه‌جا می‌شوند:

import { MoveMessage, ValueLocation } from '../types/message';
export const createInitialValues = (): ValueLocation[] => {
    return Array.from({ length: 5 }, (_, index) => ({
        value: index + 1,
        tab: 'A',
    }));
};
export const createMockHub = () => {
    return {
        on: (eventName: string, callback: (data: MoveMessage) => void) => {
            // Simulate value movements every 2 seconds
            const interval = setInterval(() => {
                // Randomly select a value (1-5) and a target tab
                const value = Math.floor(Math.random() * 5) + 1;
                const targetTab = Math.random() > 0.5 ? 'A' : 'B';
                callback({
                    type: 'move',
                    value,
                    targetTab,
                    timestamp: Date.now(),
                });
            }, ۲۰۰۰);
            return () => clearInterval(interval);
        },
        start: () => Promise.resolve(),
        stop: () => Promise.resolve(),
    };
};

در صورت اجرای این کامپوننت، رفتارهای غیرعادی‌ای مشاهده می‌کنیم، از جمله این که در حالت ایده‌آل، باید فقط یک نمونه از مقدارهای Value1 و Value5 در لیست وجود داشته باشد؛ اما در این مثال چندین نمونه تکراری مشاهده می‌شود و به‌نظر می‌رسد که انتقال داده‌ها به تب B به‌درستی انجام نشده است.

با بررسی دقیق‌تر کد، می‌توان محل بروز مشکل closure را شناسایی کرد:

hub.on('message', (data: MoveMessage) => {
            // The closure captures these initial arrays and will always reference
            // their initial values throughout the component's lifecycle
            if (data.targetTab === 'A') {
                // Remove from B (but using stale B state)
                setTabBValues(tabBValues.filter((v) => v.value !== data.value));
                // Add to A (but using stale A state)
                setTabAValues([
                    ...tabAValues,
                    {
                        tab: 'A',
                        value: data.value,
                    },
                ]);
            } else {
                // Remove from A (but using stale A state)
                setTabAValues(tabAValues.filter((v) => v.value !== data.value));
                // Add to B (but using stale B state)
                setTabBValues([
                    ...tabBValues,
                    {
                        tab: 'B',
                        value: data.value,
                    },
                ]);
            }

در اینجا، handler پیام‌ها مستقیماً بر پایه یک state قدیمی عمل می‌کند. زمانی که پیام‌ها دریافت می‌شوند، handler بر مبنای نقطه‌ای از state کار می‌کند که در زمان اجرا قدیمی‌تر از مقدار واقعی و مورد انتظار است که باید در طول رندرهای متوالی حفظ شود.

برای حل این مشکل، می‌توانیم همان کاری را انجام دهیم که در مثال setTimeout انجام دادیم، بازگشت به استفاده از هوک  useRef:

const [tabAValues, setTabAValues] = useState<ValueLocation[]>(() =>
       createInitialValues()
   );
   const [tabBValues, setTabBValues] = useState<ValueLocation[]>([]);
   const [activeTab, setActiveTab] = useState<'A' | 'B'>('A');
   const [lastMove, setLastMove] = useState<MoveMessage | null>(null);

   // Create refs to maintain latest state values
   const tabAValuesRef = useRef(tabAValues);
   const tabBValuesRef = useRef(tabBValues);

   // Keep refs in sync with current state
   tabAValuesRef.current = tabAValues;
   tabBValuesRef.current = tabBValues;

سپس در داخل handler مربوط به دریافت پیام‌ها، به‌جای استفاده از state، به مقدار جاری موجود در .current از ref ارجاع داده می‌شود:

useEffect(() => {
        const hub = createMockHub();
        hub.on('message', (data: MoveMessage) => {
            // Use refs to access current state values
            const valueInA = tabAValuesRef.current.find(
                (v) => v.value === data.value
            );
            if (data.targetTab === 'A') {
                if (!valueInA) {
                    // Value should move to A
                    const valueInB = tabBValuesRef.current.find(
                        (v) => v.value === data.value
                    );
                    if (valueInB) {
                        // Use functional updates to ensure clean state transitions
                        setTabBValues((prev) =>
                            prev.filter((v) => v.value !== data.value)
                        );
                        setTabAValues((prev) => [
                            ...prev,
                            {
                                tab: 'A',
                                value: data.value,
                            },
                        ]);
                    }
                }
            } else {
                if (valueInA) {
                    // Value should move to B
                    setTabAValues((prev) =>
                        prev.filter((v) => v.value !== data.value)
                    );
                    setTabBValues((prev) => [
                        ...prev,
                        {
                            tab: 'B',
                            value: data.value,
                        },
                    ]);
                }
            }
            setLastMove(data);
        });
        hub.start();
        return () => {
            hub.stop();
        };
    }, []); // Empty dependency array is fine now because we're using refs

همچنین متن مقاله به یک نکته مهم درباره Functional Updates اشاره می‌کند.

بررسی Functional Updates

در React، یک functional update به‌جای آن‌که مستقیماً state را تغییر دهد، مقدار قبلی state را به‌عنوان ورودی دریافت می‌کند و بر پایه‌ی آن مقدار جدید را محاسبه می‌کند. این رویکرد باعث می‌شود بتوانیم در life cycle یک کامپوننت، تغییرات را با دقت و بر اساس جدیدترین داده‌ها اعمال نماییم؛ حتی در شرایطی که رندرها متوالی یا async باشند. اگرچه استفاده از useRef تا حد زیادی این مشکل را پوشش می‌دهد، اما توجه به این نکته در هنگام کار با closureها بسیار اهمیت دارد.

مسائل مربوط به closure معمولاً بسیار چالش‌برانگیز هستند؛ زیرا در ظاهر، عملکرد کد صحیح به‌نظر می‌رسد. در مواجهه با چنین مشکلاتی، باید مسیر تغییرات state را به‌صورت پیوسته در طول فرآیند دنبال کنیم. برای تشخیص دقیق مشکل closure، می‌توانیم از step debugging همراه با بررسی گام‌به‌گام تغییرات داده بهره‌مند شویم.

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

جمع‌بندی

در این مقاله آموختیم که چگونه:

  • مفهوم closure را به‌صورت عمومی و در بستر جاوااسکریپت و React تعریف و درک کنیم.
  • Closureها را با بهره‌گیری از مفاهیم مربوط به scope مدیریت کنیم.
  • با استفاده از مثالی ساده در تابع setTimeout در جاوااسکریپت، رفتار closureها را مورد بررسی قرار دهیم.
  • یک نمونه واقعی از closure را در زمینه‌ی callbackهای اتصال در سرویس SignalR مایکروسافت تحلیل و حل کنیم.

همان‌طور که در طول مقاله نیز اشاره شد، کار با closureها در برخی مواقع می‌تواند چالش‌برانگیز باشد؛ به‌ویژه در محیط‌های production. بهترین رویکرد در مواجهه با این مسائل، داشتن درک دقیق از نحوه‌ی مدیریت state در اپلیکیشن است. در صورت بروز مشکل، توسعه‌دهنده باید مسیر جریان داده‌ها را مرحله‌به‌مرحله و به‌صورت پیوسته دنبال کرده و تحلیل کند.

دیدگاه‌ها:

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