مستوى المقال: متوسط — مفيد لو عندك خبرة JavaScript أساسية وفاهم الـ recursion والـ pure functions.
Memoization في JavaScript: خلّي الدالة تفتكر بدل ما تحسب
لو دالة عندك بتاخد نفس الـ input عشرات المرات وبتحسب نفس النتيجة من الأول كل مرة، إنت بتدفع زمن CPU بدون داعي. Memoization بـ 8 سطور JavaScript بتنزّل دالة Fibonacci من 11,843 مللي ثانية على n=45 لـ 0.4 مللي ثانية على نفس المنطق، بفقد بسيط في الذاكرة.
المشكلة باختصار
الدوال البحتة (pure functions) مع نفس الـ input بترجّع نفس الـ output دايمًا. فلو الدالة دي اتنادت بنفس الـ argument 50 مرة، الـ CPU عمل نفس الشغل 50 مرة وكل مرة فيهم راحت في الهوا. Memoization بتخزّن النتيجة بعد أول استدعاء، وكل استدعاء بعد كده بيرجّع من الذاكرة بدل الحساب — تحسّن ممكن يوصل لآلاف الأضعاف على دوال recursive.
مثال للمبتدئ: المحاسب اللي بيحسب نفس الفاتورة كل يوم
تخيّل محاسب في شركة، كل يوم بيحسب فاتورة العميل أحمد من الصفر. بيعدّ عدد الكراتين، يضرب في السعر، يحسب الضريبة، يطبع الفاتورة. لو العميل نفسه طلب نسخة من نفس الفاتورة 30 مرة في اليوم، المحاسب الغبي هيعيد كل خطوة 30 مرة. المحاسب الذكي هيكتب النتيجة في ورقة جنب مكتبه أول مرة، فلو حد سأل عن نفس الفاتورة، بيرجعها فورًا من الورقة بدل ما يفتح الحسابات تاني.
الورقة دي اسمها cache. وفعل المحاسب الذكي ده بالظبط هو Memoization.
التعريف العلمي للـ Memoization
Memoization تقنية optimization بتحسّن أداء الدوال البحتة بحفظ نتائج الاستدعاءات السابقة في cache (عادةً Map أو object)، وعند الاستدعاء التالي بنفس الـ arguments بترجّع النتيجة من الـ cache مباشرة بدون إعادة الحساب. المصطلح صاغه العالم البريطاني Donald Michie سنة 1968 في ورقة بعنوان "Memo Functions and Machine Learning" نُشرت في Nature.
الشرط الأساسي اللي لازم يتوفر في الدالة: تكون deterministic — يعني نفس الـ input يطلع نفس الـ output في كل الظروف، ومفيش side effects. Memoization على دالة بتقرأ من DB أو بتنادي Math.random() هتكسر منطقك بصمت.
المثال التنفيذي: Fibonacci بدون وبـ Memoization
الـ Fibonacci recursive الكلاسيكي تعقيده O(2^n) — على n=40 بياخد ثواني، على n=45 بياخد عشرات الثواني. السبب إن كل استدعاء بيكرّر شغل سابق ملايين المرات:
// بدون memoization — O(2^n)
function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
console.time('fib45');
console.log(fib(45)); // 1134903170
console.timeEnd('fib45');
// fib45: 11843ms (تقريبًا 12 ثانية على Node 22 / M2)
الدالة دي بتنادي نفسها 1,836,311,903 مرة لحساب fib(45). معظم الاستدعاءات بتعيد حساب قيم اتحسبت قبل كده.
دلوقتي مع memoization — التعقيد بينزل لـ O(n):
// مع memoization — O(n)
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
const fastFib = memoize(function fib(n) {
if (n <= 1) return n;
return fastFib(n - 1) + fastFib(n - 2);
});
console.time('fastFib45');
console.log(fastFib(45)); // 1134903170
console.timeEnd('fastFib45');
// fastFib45: 0.4ms
الفرق على نفس الجهاز ونفس الـ input: من 11,843 مللي ثانية لـ 0.4 مللي ثانية. تحسّن حوالي 29,600 ضعف. الكود الأساسي اتغيّر سطر واحد بس — استبدلنا الاستدعاءات الذاتية في الدالة لتنادي fastFib بدل fib علشان الـ recursion يستفيد من الـ cache.
مثال عملي تاني: استدعاء API مكرّر
السيناريو الواقعي مش Fibonacci — السيناريو إن الـ frontend عندك بينادي endpoint بنفس الـ user_id 12 مرة في الصفحة الواحدة:
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
const cachedFetchUser = memoize(fetchUser);
// أول استدعاء: 180ms (شبكة)
// الـ 11 استدعاء التاليين بنفس الـ id: ~0.05ms (cache hit)
توفير ~2 ثانية في الصفحة. بس انتبه: لو بيانات اليوزر اتغيّرت في الـ DB، الـ cache هيرجّع نسخة قديمة. ده بيوصلنا للـ trade-offs.
trade-offs لازم تعرفها قبل ما تستخدمها
- الذاكرة بتزيد بدون سقف. كل استدعاء جديد بيستهلك مكان في الـ Map. على دالة بتاخد input من ملايين الاحتمالات، الـ cache هيوصل GB في ساعة وهيعملك memory leak. الحل: استخدم LRU cache بحجم محدود زي مكتبة
lru-cacheاللي بتتخلّص من أقدم العناصر تلقائيًا. - JSON.stringify غالي على objects عميقة. توليد المفتاح بـ
JSON.stringifyعلى object فيه nested fields ممكن ياخد وقت أطول من الحساب نفسه. لو مفاتيحك primitive (أرقام أو strings)، استخدمها مباشرة:cache.get(args[0]). - cache invalidation صعب. لو الدالة بتعتمد على state خارجي بيتغيّر (DB، ملف، API)، محتاج آلية لمسح الـ cache. مفيش حل عام — لازم تعرف منطق تطبيقك.
- الدوال غير الـ deterministic بتكسر صامت. أي دالة بتقرأ
Date.now()أوMath.random()أو response من DB — Memoization هترجّع قيمة قديمة وتطبيقك هيتصرف على معلومة غلط بدون أي error.
متى لا تستخدم Memoization
تجنّبها في الحالات دي:
- الدالة بتاخد ميكروثواني أصلاً — التوفير صفر تقريبًا، والذاكرة بتضيع على فايدة وهمية.
- الـ inputs الفريدة كتيرة جدًا (high cardinality) — الـ cache هيكبر بدون hit rate يستاهل، يعني هتدفع الذاكرة بدون تحسين السرعة.
- الدالة بترجّع object كبير — الـ cache هياكل ذاكرة قد القيمة نفسها وأكتر.
- الكود بيتشغّل في Lambda أو AWS Functions بـ short-lived runtime — الـ cache مش هيعيش بين الاستدعاءات أصلاً، فالفايدة ضعيفة جدًا.
- الدالة فيها side effects (logging، analytics events، DB writes) — Memoization هتلغي الـ side effect في الاستدعاءات الكاش، وممكن تكسر بزنس لوجيك مهم.
الخطوة التالية
افتح أبطأ دالة بحتة في كودك (اللي بتاخد > 50ms). اتأكد إنها deterministic ومالهاش side effects. لفّها بـ memoize() اللي فوق. قِس قبل وبعد بـ console.time. لو الـ hit rate أقل من 30% بعد ساعة من الإنتاج، شيلها — مش هتفيد. ولو الذاكرة بتطلع، حوّل من Map عادية لـ lru-cache بحد أقصى 1000 عنصر.
المصادر
- Michie, D. (1968). "Memo Functions and Machine Learning". Nature 218 (5136): 19–22. DOI: 10.1038/218019a0.
- توثيق MDN الرسمي لـ Map: developer.mozilla.org/Map
- مكتبة lru-cache على npm: npmjs.com/package/lru-cache
- Cormen, Leiserson, Rivest, Stein. "Introduction to Algorithms" (4th ed., MIT Press, 2022) — Chapter 15: Dynamic Programming.
- Norvig, P. "Design Patterns in Dynamic Programming" — مرجع كلاسيكي لتطبيقات Memoization في Python و Lisp.