closure در React یکی از مفاهیم بنیادین زبان جاوااسکریپت است که درک دقیق آن، نقش بسیار مهمی در توسعه مؤثر و بدون خطای رابطهای کاربری دارد. این مفهوم میتواند منجر به بروز رفتارهای غیرمنتظره در اپلیکیشنهای React شود، بهویژه زمانی که با state یا callbackها درگیر هستیم.
در این مقاله، ضمن تعریف دقیق closure و نحوه عملکرد آن، با ذکر مثالهایی ساده و همچنین یک سناریوی واقعی در محیط production، چالشهای رایج مربوط به closure در React را بررسی کرده و راهکارهایی برای مدیریت آن ارائه میدهیم.
دسترسی به کدهای مرتبط با مثالهای این مقاله از طریق این لینک امکانپذیر میباشد.
Closure رابطهای است که بین یک تابع در جاوااسکریپت و متغیرهای در دسترس از محیط بیرونی آن تابع ایجاد میشود. در جاوااسکریپت، متغیرها دارای مفهومی به نام scope هستند که مشخص میکند هر مقدار، در کدام بخشهای کد قابل استفاده است. این ساختار دسترسی با اصطلاح lexical scope نیز شناخته میشود.
جاوااسکریپت بهطور کلی سه سطح اصلی از 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 هستید، مطالعه مقالات تخصصی منتشر شده در فرانت کست میتواند مفید باشد.
در پروژههای React، اغلب مشکلات مربوط به closure هنگام مدیریت state به وجود میآیند. در اپلیکیشنهای React، میتوان از هوک useState
برای مدیریت state لوکال یک کامپوننت استفاده کرد. همچنین ابزارهایی مانند Redux برای مدیریت state متمرکز، یا React Context برای اشتراک state بین چند کامپوننت در سطح پروژه نیز در دسترس هستند.
کنترل دقیق state یک کامپوننت یا مجموعهای از کامپوننتها، نیازمند درک صحیحی از این است که چه مقادیری در چه محدودهای قابل دسترسی هستند. هنگام مدیریت state در یک پروژه React، ممکن است با مسائلی مواجه شویم که ناشی از closure بوده و باعث ایجاد تغییرات ناهمخوان یا غیرقابل پیشبینی شوند.
برای توضیح بهتر مفهوم 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}`); }, ۲۰۰۰); };
مقدار
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 را برطرف میکند.
در یکی از پروژههای واقعی در سطح تولید، تیمی که مسئول نگهداری و توسعه یک اپلیکیشن با بهروزرسانیهای real-time بود، با مشکلی در مدیریت state مواجه شد. اپلیکیشن دادههای چند صف مختلف را مدیریت میکرد و هر صف را در یک تب مجزا در رابط کاربری نمایش میداد. پیامهای مربوط به تغییرات این دادهها از طریق سرویس SignalR در Azure از سمت بکاند برای فرانتاند ارسال میشدند. این پیامها مشخص میکردند که دادهها چگونه باید بهروزرسانی شوند یا به صف دیگری منتقل گردند.
در جریان اجرای این فرآیند، تیم توسعه با خطاهای متعددی مواجه شد. برخی از بهروزرسانیها بهدرستی انجام میشد، اما سیستم برخی دیگر را نادیده میگرفت یا بهصورت نادرست اعمال میکرد. این مسئله باعث نارضایتی کاربران شد و از آنجا که فرآیند ارتباط با SignalR بهصورت real-time انجام میشود، عیبیابی و تست مشکل، نیاز به شبیهسازی پیامهای سرور و تعامل مداوم داشت که فرآیندی پیچیده و زمانبر بود.
تیم توسعه در ابتدا احتمال داد که مشکل از سمت سرور باشد. اما پس از بررسی کدهای بکاند و اطمینان از صحت عملکرد ارسال پیامها، تمرکز تیم بر کدهای فرانتاند قرار گرفت. در بررسی دقیقتر مشخص شد که مسئله اصلی، به یک مشکل closure در جاوااسکریپت مربوط میشود. در کد فرانتاند، callback مربوط به دریافت پیامهای SignalR در واقع به دادههای قدیمی 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 اشاره میکند.
در React، یک functional update بهجای آنکه مستقیماً state را تغییر دهد، مقدار قبلی state را بهعنوان ورودی دریافت میکند و بر پایهی آن مقدار جدید را محاسبه میکند. این رویکرد باعث میشود بتوانیم در life cycle یک کامپوننت، تغییرات را با دقت و بر اساس جدیدترین دادهها اعمال نماییم؛ حتی در شرایطی که رندرها متوالی یا async باشند. اگرچه استفاده از
useRef
تا حد زیادی این مشکل را پوشش میدهد، اما توجه به این نکته در هنگام کار با closureها بسیار اهمیت دارد.
مسائل مربوط به closure معمولاً بسیار چالشبرانگیز هستند؛ زیرا در ظاهر، عملکرد کد صحیح بهنظر میرسد. در مواجهه با چنین مشکلاتی، باید مسیر تغییرات state را بهصورت پیوسته در طول فرآیند دنبال کنیم. برای تشخیص دقیق مشکل closure، میتوانیم از step debugging همراه با بررسی گامبهگام تغییرات داده بهرهمند شویم.
در مورد SignalR، این چالش پیچیدهتر میشود؛ چرا که دریافت پیامها در سمت کلاینت نیازمند وجود یک محرک مناسب در سمت سرور است. بنابراین بهتر است پیش از اعمال هرگونه راهحل مستقیم، فرآیند مربوطه را بهدقت ردیابی کرده و رفتار state را در طول آن بهصورت کامل تحلیل کنیم.
در این مقاله آموختیم که چگونه:
setTimeout
در جاوااسکریپت، رفتار closureها را مورد بررسی قرار دهیم.همانطور که در طول مقاله نیز اشاره شد، کار با closureها در برخی مواقع میتواند چالشبرانگیز باشد؛ بهویژه در محیطهای production. بهترین رویکرد در مواجهه با این مسائل، داشتن درک دقیق از نحوهی مدیریت state در اپلیکیشن است. در صورت بروز مشکل، توسعهدهنده باید مسیر جریان دادهها را مرحلهبهمرحله و بهصورت پیوسته دنبال کرده و تحلیل کند.