بهینه سازی جاوااسکریپت: قاتلان خاموشی که در پروژهها دیدهام
بهینهسازی جاوااسکریپت: قاتلان خاموشی که در پروژهها دیدهام. بهینهسازی جاوااسکریپت از زاویه تجربه پروژههای واقعی: قاتلان خاموش سرعت، الگوهای عملی با اثر قابل اندازهگیری، مدیریت حافظه، Web Workers و نگاهی به رفتار موتور V8.
اولین باری که یک اپلیکیشن داخلی با فریمورک جاوااسکریپت را تحویل دادم، کاربران بعد از چند دقیقه کار مداوم شکایت کردند که «سایت گیر میکند». من اول به سمت سرور و کوئریهای دیتابیس رفتم، اما همه چیز تمیز بود. وقتی Chrome DevTools را باز کردم و پروفایلر حافظه را روشن کردم، فهمیدم مشکل جای دیگری است: هزاران event listener که هیچوقت پاک نشده بودند و main thread را در هر لحظه اشغال میکردند. آن تجربه، نقطهی شروع یک مسیر بلند در بهینه سازی جاوااسکریپت شد.
بهینه سازی JavaScript، برخلاف تصور رایج، فقط کمکردن حجم فایل نیست. یک تصمیم معماری است که از اولین خط کد شروع میشود و تا آخرین خط تولید ادامه پیدا میکند. چیزی که در دهها پروژهی مختلف یاد گرفتم این است: کندیها همیشه از جایی میآیند که کسی به آن نگاه نمیکند — نه در باندل نهایی، بلکه در رفتار runtime کد.
در این مسیر، از قاتلان خاموشی میگویم که در پروژههای واقعی دیدهام، از الگوهایی که اثرشان قابل اندازهگیری بوده، و از آن لایهای که فقط با پروفایلر میشود دید: رفتار موتور V8 با کد شما.
چرا بهینه سازی جاوااسکریپت یک تصمیم معماری است
وقتی از یک توسعهدهنده تازهکار میپرسم «بهینه سازی JavaScript برای تو یعنی چه؟»، معمولاً جواب میشنوم: «minify کردن فایلها و حذف console.log». اینها لازماند اما کافی نیستند. حقیقتی که در پروژهها به من ثابت شد این است: بزرگترین گلوگاههای سرعت در مرورگر، از تصمیمهای معماری میآیند، نه از فشردهسازی. یک تابع بیدقت در یک حلقهی سنگین، ممکن است چند صد میلیثانیه از هر فریم مرورگر را بگیرد؛ در حالی که فشردهسازی همان تابع تنها چند کیلوبایت صرفهجویی میکند.
سه محور اصلی که در بهینه سازی JavaScript روی آنها تمرکز میکنم:
- هزینهی اجرا (Runtime Cost): چه چیزی در هر لحظه روی main thread اجرا میشود؟ هر عملیات طولانی، به معنای یخزدگی رابط کاربری است.
- هزینهی انتقال (Transfer Cost): چه حجمی از کد از سرور به مرورگر میرسد؟ این همان چیزی است که با minify و tree-shaking بهینه میشود.
- هزینهی حافظه (Memory Cost): چقدر شیء زنده در حافظه نگه داشته میشود؟ نشتی حافظه، در بلندمدت کندترین قاتل است و در پروفایلرهای کوتاهمدت دیده نمیشود.
برای درک عمیقتر مفاهیم پایهای که در ادامه به آنها اشاره میکنم، پیشنهاد میکنم آموزش جاوااسکریپت از صفر را یک بار مرور کنید؛ چون بدون فهم پایهی اجرای کد، بهینهسازی فقط آزمونوخطا خواهد بود.
سرعت مرورگر، نتیجهی جمع جبری هزاران تصمیم کوچک است؛ و بهینهسازی جاوااسکریپت یعنی آگاهانه گرفتن همان تصمیمها.
مسیر بحرانی رندر و سهم جاوااسکریپت
مرورگر برای نمایش صفحه، مسیر مشخصی را طی میکند: دریافت HTML، ساختن DOM و CSSOM، ادغام به Render Tree، Layout و در نهایت Paint. جاوااسکریپت در چند نقطه میتواند این مسیر را متوقف کند:
- Scripts مسدودکننده: اگر
<script>درheadبدونdeferیاasyncباشد، پارس HTML را تا کامل اجرای اسکریپت متوقف میکند. - JavaScript همگام سنگین: هر حلقه یا محاسبهی طولانی، تا زمانی که تمام نشود، اجازهی هیچ بازچینش دیگری به مرورگر نمیدهد.
- تغییر مکرر DOM: هر دستکاری DOM یک Reflow یا Repaint را تحریک میکند؛ و Reflow هزینهاش به اندازهی تعداد گرههای درگیر است.
برای درک کامل مسیر بحرانی رندر و اینکه چطور defer و async رفتار متفاوت دارند، میتوانید کنار بهینهسازی سرعت سایت این بخش را مطالعه کنید؛ چون مرز بین بهینهسازی سمت سرور و سمت کلاینت دقیقاً همینجاست.
قاتلان خاموشی که در پروژهها دیدهام
در طول سالها، الگوهایی را شناسایی کردهام که همیشه به کندی میرسند، اما بهسختی در تستهای اولیه خودشان را نشان میدهند:
۱. event listenerهای بدون پاکسازی
در پروژهای که یک داشبورد مدیریتی با نقاشی تعاملی داشت، هر بار که کاربر از یک تب به تب دیگر جابهجا میشد، یک listener جدید روی window اضافه میشد. کد بهظاهر سالم بود، اما در پروفایلر حافظه، رشد پیوسته دیده میشد. راهحل: استفاده از removeEventListener در cleanup و انتقال به event delegation.
۲. دستکاری DOM در حلقهها
یک خطای کلاسیک که در بسیاری از کدبیسها دیدهام: بهجای ساختن یک قطعهی HTML کامل و افزودن آن یکبار، در هر تکرار حلقه یک المان به DOM اضافه میشود. هر افزودن، یک Reflow پرهزینه است. یک مثال ساده:
// بد
const list = document.getElementById('list');
data.forEach(item => {
const li = document.createElement('li');
li.textContent = item;
list.appendChild(li); // Reflow در هر تکرار
});
// خوب
const fragment = document.createDocumentFragment();
data.forEach(item => {
const li = document.createElement('li');
li.textContent = item;
fragment.appendChild(li);
});
list.appendChild(fragment); // فقط یک Reflow
۳. محاسبات همگام سنگین روی main thread
هر پردازش عددی، رمزنگاری، پردازش تصویر یا حلقهی مرتبسازی روی main thread، مرورگر را یخ میزند. این یخزدگی همان چیزی است که کاربر بهعنوان «سایت گیر کرد» توصیف میکند.
۴. closures که حافظه را زنده نگه میدارند
اگر یک closure به یک عنصر DOM مرجع نگه دارد، آن عنصر هرگز از حافظه آزاد نمیشود حتی اگر از DOM حذف شود. این الگوی نشتی، در اپلیکیشنهای SPA که سالها باز میمانند، فاجعهبار است.
الگوهای عملی با اثر قابلاندازهگیری
در همین بخش، الگوهایی که بیشترین اثر را در پروژههای من داشتهاند، کنار هم آوردهام. هر مورد را با یک مثال کوچک و اثر تقریبی توصیف کردهام:
| الگو | کجا مؤثر است | اثر تقریبی |
|---|---|---|
| Debounce روی ورودی کاربر | جستجو، resize، autocomplete | کاهش تعداد فراخوانیها تا ۹۵٪ |
| Throttle روی رویدادهای پرتکرار | Scroll، mousemove | کاهش بار تا حدود ۸۰٪ |
| Event delegation | لیستهای بزرگ، جداول | کاهش مصرف حافظه در حد چشمگیر |
| Virtual list | لیستهای بلند (۱۰ هزار ردیف) | کاهش تعداد گره DOM از ۱۰٬۰۰۰ به چند ده |
| requestAnimationFrame | انیمیشن، اسکرول نرم | هماهنگی با نرخ فریم مرورگر |
یک مثال از debounce که در پروژههای جستجو بسیار بهکارم آمده است:
function debounce(fn, delay = 300) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const search = debounce(query => {
fetch('/api/search?q=' + encodeURIComponent(query));
}, 400);
input.addEventListener('input', e => search(e.target.value));
برای مطالعهی الگوهای مرتبط با async که با همین تکنیک ترکیب میشوند، async و await در جاوااسکریپت را پیشنهاد میکنم.
بهینه سازی جاوااسکریپت در ۹۰٪ موارد یعنی «کمتر کردن تعداد کارهایی که مرورگر باید انجام دهد»، نه «سریعتر انجام دادن همان کارها».
مدیریت حافظه و نشتیهای پنهان
نشتی حافظه در JavaScript خودش را بهسرعت نشان نمیدهد. سایت باز میشود، همه چیز سریع است. اما بعد از نیم ساعت کار کاربر، همه چیز کند میشود. سه الگوی نشتی که بیشترین فراوانی را داشتهاند:
- تایمرهای بدون پاکسازی:
setIntervalکه با تغییر صفحه یا بستن مودال، پاک نمیشود. راهکار: در Angular باngOnDestroy، در React با return درuseEffect. - مراجع پنهان به DOM حذفشده: آرایهای از عناصر DOM در متغیر سراسری نگه میدارید و عنصرها را از DOM حذف میکنید. حافظهی عنصرها آزاد نمیشود.
- Listenerهای بدون
AbortController: در fetchهای async، اگر کاربر از صفحه خارج شود و response بعد برسد، callback هنوز اجرا میشود. راهحل مدرن:AbortControllerکه میتواند در cleanup لغو کند.
برای دیدن تفاوتهای مدیریت خطا و اینکه چطور یک خطای مدیریتنشده میتواند به نشتی حافظه تبدیل شود، مدیریت خطا در جاوااسکریپت را بخوانید.
الگوهای async و هزینهی پنهانشان
async/await سینتکس را تمیز کرد اما هزینهی پنهانی دارد: هر await یک microtask اضافه میکند. در یک حلقه با هزاران await پشت سر هم، همین microtaskها میتوانند سرعت را کم کنند. دو نکته که در پروژهها بهکارم آمده:
- وقتی ترتیب مهم نیست، parallel کنید:
Promise.allبهجایfor awaitپشتسرهم. - وقتی stream دارید، از for await استفاده کنید نه Promise.all: چون Promise.all همه را همزمان در حافظه نگه میدارد.
یک مثال عملی از تفاوت این دو:
// Parallel: سریعتر، حافظه بیشتر
const results = await Promise.all(urls.map(u => fetch(u)));
// Sequential: کندتر، حافظه کمتر
for (const url of urls) {
const res = await fetch(url);
process(res);
}
برای مبانی این تفاوتها، Promise در جاوااسکریپت را میتوانید در کنار این بخش بخوانید.
باندل، code splitting و lazy loading
در لایهی انتقال داده، سه تکنیک بیشترین اثر را دارند:
- Tree Shaking: حذف کدهایی که در باندل نهایی اصلاً فراخوانی نمیشوند. ابزارهای مدرن مثل Vite و Webpack این را در حالت production انجام میدهند؛ به شرطی که از
importسازگار استفاده کنید نهrequireپویا. - Code Splitting: شکستن باندل به تکههای کوچک که هر کدام هنگام نیاز بارگذاری میشوند. پرکاربردترین شکل آن، dynamic import در مسیرهای SPA است.
- Lazy Loading کامپوننتها: در React و Vue، کامپوننتهای سنگین مثل مودالها یا ویرایشگرها تنها در لحظهی نیاز دانلود میشوند.
برای مقایسهی دقیق ابزارها و انتخاب بینشان، تفاوت TypeScript و JavaScript را ببینید؛ چون انتخاب تایپسیستم، مسیر باندل را هم تغییر میدهد.
Web Workers: وقتی main thread خفه میشود
Web Worker یک ریسمان جدا در مرورگر است که بهطور کامل مستقل از main thread اجرا میشود. هر محاسبات سنگینی که بیش از ۵۰ میلیثانیه طول میکشد، کاندید جدی انتقال به worker است:
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ data: bigArray });
worker.onmessage = e => console.log('Result:', e.data);
// worker.js
self.onmessage = e => {
const result = heavyCompute(e.data.data);
self.postMessage(result);
};
سه نکته که در پروژهها یاد گرفتم:
- دادهها بین worker و main thread با structured clone کپی میشوند؛ برای دادههای حجیم، از
Transferableمثل ArrayBuffer استفاده کنید تا کپی نشوند. - هر worker یک context جدا دارد؛ DOM در دسترس نیست.
- بارگذاری worker هزینه دارد؛ برای کارهای زیر ۱۰ میلیثانیه صرف نمیکند.
ابزارهای پروفایلینگ که واقعاً کار میکنند
سه ابزاری که در پروژهها بیشترین کمک را کردهاند:
- Performance Tab در Chrome DevTools: برای دیدن Flame Chart و پیدا کردن Long Tasks. هر نوار زرد روی main thread، یک فرصت بهینهسازی است.
- Memory Tab و Heap Snapshot: برای پیدا کردن نشتی حافظه. با گرفتن سه snapshot در بازههای زمانی مختلف، رشد را میبینید.
- Lighthouse: برای ارزیابی کلی که مجموع همهچیز را نشان میدهد؛ اما برای عیبیابی دقیق، Performance Tab برنده است.
برای اینکه در پروژههای واقعی از این ابزارها بهتر استفاده کنید، پیدا کردن خطاهای جاوااسکریپت در کنسول مرورگر را ببینید؛ چون قبل از هر بهینهسازی، باید خطاها را دید.
اشتباهاتی که در دهها پروژه تکرار شدهاند
- بهینهسازی زودهنگام (Premature Optimization): وقتی هنوز عملکرد قابل اندازهگیری نیست، بهینهسازی یعنی پیچیدگی بیفایده. اول اندازه بگیرید، بعد بهینه کنید.
- تکیه بر console.log برای پروفایلینگ: خود console.log هزینه دارد؛ در کد production نتیجه را معکوس میکند.
- نادیده گرفتن موبایل: بهترین بهینهسازی روی دسکتاپ، روی موبایل متوسط بیاثر است. همیشه روی گوشی واقعی تست کنید.
- استفادهی نابجا از polyfill: polyfill برای مرورگرهایی که استفاده نمیشوند، فقط بار اضافه است.
- minify روی کدی که خطا دارد: اول خطاها را برطرف کنید؛ بعد فشردهسازی را روی کد تمیز اعمال کنید.
برای مشاهدهی اشتباهات مشابه در سطح مفاهیم بالاتر، مفاهیم پیشرفته جاوااسکریپت را پیشنهاد میکنم.
نگاه موتور V8 به کد شما
V8 (موتور اجرای JavaScript در Chrome و Node.js) کد شما را در چند مرحله پردازش میکند: parse، compile، و اجرا با دو حالت interpreter (Ignition) و compiler (TurboFan). نکاتی که در این لایه برای مهندسان پلتفرم اهمیت دارند:
- Inline Cache و Monomorphic Call Sites: وقتی یک تابع با یک نوع ثابت فراخوانی میشود، V8 کد را سریعتر اجرا میکند. اما اگر همان تابع با انواع مختلف ورودی صدا زده شود (polymorphic)، کد از حالت بهینه خارج میشود. مثلاً تابعی که یک بار با عدد و بار دیگر با رشته صدا زده میشود، monomorphic نخواهد ماند.
- Hidden Class و Shape: هر آبجکت در V8 یک «شکل» داخلی دارد که با اضافهشدن خاصیتها تغییر میکند. اگر ترتیب افزودن خاصیتها در نمونههای مختلف یکسان نباشد، shapeهای متفاوتی ساخته میشود و دسترسیها کندتر میشود. توصیهی عملی: همهی فیلدها را در constructor مقداردهی اولیه کنید.
- Deoptimization در اثر تغییر prototype: اگر در زمان اجرا، متدی به prototype اضافه یا حذف کنید، V8 کدهای بهینهشده را دور میریزد و از نو بهینه میکند. برای پروژههای پرترافیک، همهی متدها باید قبل از اجرای اولیه روی prototype باشند.
- Generational GC: V8 از یک GC نسلبندیشده استفاده میکند که آبجکتهای زودگذر (young generation) را سریع جمع میکند و آبجکتهای پایدار را به نسل قدیمی میبرد. الگوی عملی: اشیاء کوتاهعمر را در محدودهی محلی بسازید تا در نسل جوان بمیرند و سریع آزاد شوند.
برای درکی از تفاوت این لایه با تایپسیستم که در زمان کامپایل کار میکند، به آموزش تایپ اسکریپت از صفر سر بزنید. مطالعهی موازی این دو موضوع، بینش عمیقی به عملکرد کد JavaScript میدهد. همچنین برای درک اینکه چرا برخی از رفتارهای JavaScript به این شکل طراحی شدهاند، مرور مقالهی یادگیری جاوااسکریپت با پروژههای واقعی میتواند مفید باشد.
کدی که برای انسان خوانا نوشته شده اما برای V8 غیرقابل پیشبینی است، در عمل کندتر از کدی است که کمی زشتتر اما قابل پیشبینی است.
خط پایان این بررسی
بهینه سازی جاوااسکریپت را نه بهعنوان یک کار بهسازی، بلکه بهعنوان یک عادت معماری در نظر بگیرید. سه اصل که در پایان این مسیر میخواهم روی آنها تأکید کنم:
- اندازه بگیرید، بعد بهینه کنید. بدون پروفایلر، هر بهینهسازی حدس است. Performance Tab و Heap Snapshot دو ابزاری هستند که در هر پروژه باید باز باشند.
- به رفتار runtime فکر کنید، نه فقط به حجم فایل. یک باندل ۵۰ کیلوبایتی که main thread را در هر ثانیه قفل میکند، بدتر از یک باندل ۲۰۰ کیلوبایتی روان است.
- موتور را به دوست خودتان تبدیل کنید. کدی بنویسید که V8 بتواند بهینه کند: monomorphic calls، shape ثابت، prototype پایدار. این جزئیات روی کاغذ سادهاند اما در عمل تفاوت میلیثانیهها را میسازند.
اگر در پروژهای با یک گلوگاه غیرمنتظره روبرو شدهاید — مثلاً کندیای که با هیچ بهینهسازی معمولی رفع نشده — تجربهی دقیق خودتان را در دیدگاه بنویسید. مخصوصاً اگر از پروفایلر به بینش خاصی رسیدهاید، همان تجربه برای خوانندهی بعدی از هر توضیح عمومی ارزشمندتر است. ⚡