JavaScript بعمق
أداء JavaScript والذاكرة: سريع، سلس، بلا تسريبات
دليل عملي لجعل JavaScript سريعة — النموذج أحادي الخيط وتجنّب حجب الخيط الرئيسي، وdebounce وthrottle، والتخزين المؤقّت، وتحديثات DOM الفعّالة والتجميع، وتسريبات الذاكرة وكيف تحدث، وجمع المهملات، وWeakMap/WeakSet، والقياس بـ DevTools — مع تمارين عملية وحلولها.
تطبيق صحيح يبدو بطيئًا يبقى تطبيقًا سيّئًا. أداء JavaScript ليس عن التحسين الدقيق للحساب — بل عن احترام الخيط الواحد، وعدم القيام بعمل مكلف أكثر من اللازم، ولمس DOM بكفاءة، وعدم تسريب الذاكرة حتى يزحف التبويب. هذا المقال يغطّي التقنيات التي تُحدِث فرقًا فعليًّا في التطبيقات الحقيقية، وكيف تقيس بدل أن تخمّن. (يبني على حلقة الأحداث من أساسيات JavaScript وعمل DOM من مقال DOM.)
القاعدة الذهبية: لا تحجب الخيط الرئيسي. JavaScript تعمل على خيط واحد مشترك مع الرسم والإدخال، فدالّة بطيئة واحدة تجمّد الصفحة كلها — التمرير، النقرات، الحركة، كل شيء. معظم "عمل الأداء" حقيقةً عن إبقاء ذلك الخيط حرًّا.
الخيط الواحد هو ميزانيتك
يقوم المتصفح بالتخطيط والرسم وJavaScript ومعالجة الأحداث على خيط رئيسي واحد. ليبدو سلسًا (60 إطارًا/ثانية)، لكل إطار نحو 16 مللي ثانية. مهمّة متزامنة تعمل أطول من ذلك تُسقط إطارات — تقطّع مرئيّ:
// ❌ يحجب الخيط — تتجمّد الصفحة حتى ينتهي هذا
for (let i = 0; i < 1e9; i++) { /* عمل ثقيل */ }
// ✅ قسّم العمل الطويل إلى قطع، متنازلًا بينها
async function processInChunks(items) {
for (let i = 0; i < items.length; i += 500) {
processBatch(items.slice(i, i + 500));
await new Promise(r => setTimeout(r, 0)); // تنازَل لتدع الصفحة تتنفّس
}
}
للحوسبة الثقيلة فعلًا (التحليل، معالجة الصور)، انقلها خارج الخيط الرئيسي كليًّا بـ عامل ويب (Web Worker)، يعمل في خيط منفصل ويرسل النتائج عائدًا.
Debounce وThrottle
الأحداث عالية التردّد — scroll، resize، input، mousemove — قد تُطلَق عشرات المرّات في الثانية. تشغيل معالِج مكلف على كلٍّ إهدار. تقنيتان تروّضانها:
// Debounce — شغّل فقط بعد توقّف النشاط (مثل البحث أثناء الكتابة)
function debounce(fn, ms) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
// Throttle — شغّل مرّة على الأكثر لكل فترة (مثل موضع التمرير)
function throttle(fn, ms) {
let last = 0;
return (...args) => {
const now = performance.now();
if (now - last >= ms) { last = now; fn(...args); }
};
}
input.addEventListener("input", debounce(search, 300));
window.addEventListener("scroll", throttle(updateHeader, 100));
Debounce ينتظر توقّفًا (جيّد حين يهمّك الحالة النهائية فقط — استعلام بحث مكتمل). Throttle يفرض معدّلًا ثابتًا (جيّد للتحديثات المستمرّة — مؤشّر تمرير). اختيار الصحيح هو معظم المكسب.
التخزين المؤقّت (Memoization)
إن استُدعيت دالّة نقية مرارًا بنفس المُدخَلات، خزّن النتائج كي يحدث العمل مرّة لكل مُدخَل فريد:
function memoize(fn) {
const cache = new Map();
return (arg) => {
if (cache.has(arg)) return cache.get(arg);
const result = fn(arg);
cache.set(arg, result);
return result;
};
}
const slowSquare = (n) => { /* مكلف */ return n * n; };
const fastSquare = memoize(slowSquare);
fastSquare(9); // محسوب
fastSquare(9); // مُرجَع من المخزن
التخزين المؤقّت يقايض الذاكرة بالسرعة — مثاليّ للحوسبات النقية المكلفة بمُدخَلات متكرّرة (هكذا يعمل useMemo/memo في React مفاهيميًّا). لا تخزّن دوالًّا رخيصة أو ذات آثار جانبية.
تحديثات DOM الفعّالة
لمس DOM بطيء؛ قلّل التكرار وجمّع تغييراتك. قاعدتان تغطّيان معظم الحالات (موسَّعتان في مقال DOM):
// ❌ أدرِج في حلقة — كلٌّ قد يفعّل تخطيطًا
items.forEach(i => list.append(makeRow(i)));
// ✅ ابنِ خارج الشاشة، أدرِج مرّة
const frag = document.createDocumentFragment();
items.forEach(i => frag.append(makeRow(i)));
list.append(frag);
// ❌ جلد التخطيط — قراءة بعد كتابة تفرض إعادة تدفّق متزامنة كل تكرار
els.forEach(el => { el.style.height = el.offsetHeight + 10 + "px"; });
// ✅ جمّع القراءات، ثم الكتابات
const heights = els.map(el => el.offsetHeight);
els.forEach((el, i) => { el.style.height = heights[i] + 10 + "px"; });
وللحركات، حرّك transform/opacity (مُركِّب فقط) بدل خصائص التخطيط — الفرق بين السلس والمتقطّع.
كيف تحدث تسريبات الذاكرة
لـ JavaScript جمع مهملات تلقائيّ: تُستردّ الذاكرة حين لا يشير شيء إلى كائن بعد الآن. والتسريب حين تُبقي بالخطأ مرجعًا حيًّا، فلا يستطيع الجامع تحريره. المتّهمون المعتادون:
// 1. مستمعون لم يُزالوا أبدًا — المعالِج (وإغلاقه) يبقى مُشارًا إليه
el.addEventListener("click", handler);
// ...أُزيل العنصر من DOM، لكن المستمع يُبقيه (ونطاقه) حيًّا
el.removeEventListener("click", handler); // ✅ نظّف
// 2. مؤقّتات لا تُمسَح أبدًا
const id = setInterval(tick, 1000);
clearInterval(id); // ✅ حين لا حاجة بعد
// 3. مخازن/مصفوفات متنامية لا تُشذَّب أبدًا
const cache = {};
function remember(k, v) { cache[k] = v; } // ينمو للأبد — حُدّه
// 4. إغلاقات تحمل كائنات كبيرة أطول من اللازم
النمط دائمًا نفسه: شيء طويل العمر (عام، مستمع، مؤقّت، مخزن) يحمل مرجعًا لشيء كان ينبغي تحريره. نظّف المستمعين والمؤقّتات، وحُدّ مخازنك.
WeakMap وWeakSet
حين تريد ربط بيانات بكائن دون منع ذلك الكائن من جمع المهملات، استخدم WeakMap/WeakSet. مفاتيحها مُمسَكة بضعف — إن كان الكائن غير مُشار إليه خلاف ذلك، يختفي المُدخَل تلقائيًّا:
const metadata = new WeakMap();
metadata.set(domNode, { clicks: 0 }); // حين يُزال domNode ولا يُشار إليه،
// يُجمَع هذا المُدخَل تلقائيًّا — بلا تسريب
هذه الطريقة الآمنة من التسريب لإرفاق بيانات جانبية بعقد DOM أو كائنات لا تملكها.
قِس، لا تخمّن
التحسين بالحدس يهدر الوقت على الأشياء الخطأ. استخدم الأدوات:
- لوحة Performance بـ DevTools — سجّل تفاعلًا وشاهد أين يذهب الوقت بالضبط (مهام طويلة، تخطيط، رسم).
- لوحة Memory — خذ لقطات كومة، قارنها لتجد ما ينمو (تسريب).
performance.now()— توقيت دقيق حول كتلة مشبوهة.- Lighthouse — تدقيق أداء صفحة شامل باقتراحات ملموسة.
حلّل أولًا، جِد عنق الزجاجة الفعليّ، ثم حسّن ذاك — لا ما تفترضه بطيئًا.
الأخطاء الشائعة
- حجب الخيط الرئيسي بحلقة متزامنة طويلة — قسّمها أو استخدم عامل ويب.
- تشغيل معالِجات مكلفة على كل حدث
scroll/input— استخدم debounce أو throttle. - إدراج عقد DOM واحدة في كل مرّة في حلقة بدل التجميع بجزء.
- مزج قراءات وكتابات DOM، فيسبّب جلد التخطيط.
- ترك مستمعي الأحداث والمؤقّتات مرفقين بعد ذهاب عنصرهم/مكوّنهم (تسريبات).
- ترك مخزن ينمو بلا حدّ — ضع حدّ حجم أو استخدم
WeakMapحيث يناسب. - التحسين بالتخمين بدل التحليل — ستسرّع شيفرة لم تكن عنق الزجاجة.
- تخزين دوال رخيصة أو غير نقية، فإضافة تعقيد بلا مكسب.
تمارين
جرّب كلًّا منها قبل فتح الحل.
تمرين 1 — Debounce لمعالِج تحجيم
recalcLayout() يعمل على كل حدث resize، أكثر من اللازم بكثير. اجعله يعمل فقط بعد استقرار التحجيم 200 مللي ثانية.
إظهار الحل
function debounce(fn, ms) {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
}
window.addEventListener("resize", debounce(recalcLayout, 200));
كل حدث تحجيم يعيد ضبط المؤقّت؛ وrecalcLayout يُطلَق مرّة فقط حين يتوقّف المستخدم عن التحجيم 200 مللي ثانية — بدل مئات المرّات أثناء السحب.
تمرين 2 — أصلِح التسريب
function setup(el) {
const data = bigArray();
el.addEventListener("click", () => use(data));
}
يُزال العنصر لاحقًا لكن الذاكرة تبقى تنمو. ما الخطأ وكيف تُصلحه؟
إظهار الحل
مستمع النقر يُغلِق على data (مصفوفة كبيرة) ولا يُزال أبدًا، فحتى بعد مغادرة el لـ DOM يبقى المستمع — وdata — مُشارًا إليه ولا يُجمَع. أصلِح بإزالة المستمع عند الانتهاء:
function setup(el) {
const data = bigArray();
const onClick = () => use(data);
el.addEventListener("click", onClick);
return () => el.removeEventListener("click", onClick); // استدعِ عند التنظيف
}
تمرين 3 — خزّن دالّة مكلفة
لُفّ fib(n) بطيئة نقية كي تكون الاستدعاءات المتكرّرة بنفس n فورية.
إظهار الحل
const memo = new Map();
function fib(n) {
if (n < 2) return n;
if (memo.has(n)) return memo.get(n);
const result = fib(n - 1) + fib(n - 2);
memo.set(n, result);
return result;
}
تخزين كل fib(n) محسوب يحوّل النسخة الأسّية الساذجة إلى خطّية — كل قيمة تُحسَب مرّة على الأكثر.
النموذج الذهني الذي تحتفظ به
الأداء غالبًا عن حماية خيط واحد مشترك. أبقِ كل إطار تحت ~16 مللي ثانية: قسّم العمل الطويل إلى قطع (أو ادفعه إلى عامل ويب)، وروّض الأحداث عالية التردّد بـ debounce (شغّل بعد توقّفها) أو throttle (شغّل بمعدّل ثابت). خزّن العمل النقيّ المتكرّر بـ memoization، والمس DOM بأقلّ قدر — جمّع الإدراجات بأجزاء ولا تمزج القراءات والكتابات. للذاكرة، تذكّر أن الجامع يحرّر فقط ما لا يُشار إليه، فـالتسريبات تأتي من مستمعين ومؤقّتات ومخازن نسيت تنظيفها؛ امسحها، وحُدّ مخازنك، واستخدم WeakMap للبيانات الجانبية التي لا ينبغي أن تُبقي الكائنات حيّة. وفوق كل شيء، قِس بـ DevTools قبل التحسين — أصلِح عنق الزجاجة الحقيقيّ، لا المتخيَّل.