Memoization في JavaScript: ازاي توفّر 99.9% من زمن التنفيذ بـ 5 أسطر
لو الكود عندك بيستدعي نفس الدالة بنفس المدخل آلاف المرات، الـ CPU بيعيد نفس الحسبة من الصفر كل مرة. Memoization بتحفظ نتيجة كل استدعاء في Map وبترجّعها فورًا في المرة اللي بعدها. fibonacci(45) بتنزل من 8.4 ثانية إلى 0.9 ملي ثانية على نفس الجهاز.
المشكلة باختصار
الـ recursion في حسابات معينة بيعمل شجرة استدعاءات ضخمة فيها تكرار مرعب. fibonacci(45) الكلاسيكية بتعمل أكثر من 2.2 مليار استدعاء، 99.99% منهم بيحسبوا نفس الأرقام اللي اتحسبت قبل كده. النتيجة: ركز معايا — الـ CPU بيشتغل ساعة عشان يطلعلك رقم اتحسب فعلًا في أول 30 ميلي ثانية.
مثال بسيط قبل ما ندخل في الكود
تخيّل كاشير في سوبر ماركت. كل ما حد يجيب عبوة كولا، الكاشير بيقفل العين ويعد ثمن الكولا من الأول: العبوة 12 جنيه + الضريبة 1.68 = 13.68. لو 200 عميل في اليوم اشتروا كولا، الكاشير عمل نفس العملية 200 مرة. لو الكاشير كتب الرقم 13.68 على ورقة جنب المكنة في أول مرة، الـ 199 عميل اللي بعد كده هيخلصوا في ثواني.
الورقة دي اسمها cache. والاستراتيجية اللي بتقول "احسب مرة واحدة، ارجع نفس النتيجة لو نفس المدخل جه تاني" اسمها Memoization.
التعريف العلمي الدقيق
Memoization تقنية optimization بتعتمد على شرطين أساسيين:
- الدالة لازم تكون pure — يعني نفس المدخل دايمًا بيدّيك نفس المخرج، وبدون side effects (مفيش كتابة في DB أو تعديل متغيّر خارجي).
- المخرج بيتحدد بالكامل من المدخلات — مفيش اعتماد على وقت أو حالة عشوائية.
الفكرة: نخزّن كل (input → output) في Map، وقبل ما الدالة تنفّذ أي حساب نسأل الـ Map: "شفت المدخل ده قبل كده؟". لو آه، رجّع النتيجة المخزّنة. لو لا، احسب وخزّن.
الكود قبل وبعد — fibonacci بـ Node.js 22
الـ fibonacci الساذج (بدون memoization):
// fib_naive.js
function fib(n) {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
}
const t0 = performance.now();
const result = fib(45);
const t1 = performance.now();
console.log(`fib(45) = ${result}`);
console.log(`Time: ${(t1 - t0).toFixed(2)} ms`);
// fib(45) = 1134903170
// Time: 8412.36 msنفس الدالة مع Memoization:
// fib_memo.js
function memoize(fn) {
const cache = new Map();
return function (n) {
if (cache.has(n)) return cache.get(n);
const result = fn(n);
cache.set(n, result);
return result;
};
}
const fib = memoize(function self(n) {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
});
const t0 = performance.now();
const result = fib(45);
const t1 = performance.now();
console.log(`fib(45) = ${result}`);
console.log(`Time: ${(t1 - t0).toFixed(3)} ms`);
// fib(45) = 1134903170
// Time: 0.912 msالفرق 9230x أسرع على نفس الجهاز (MacBook M2، Node 22.13). السبب إن أي قيمة fib(k) بتتحسب مرة واحدة بس بدل ما تتحسب ملايين المرات في شجرة الاستدعاءات.
ليه Map وليه مش Object؟
ناس كتير بتستخدم const cache = {}. Map أفضل لثلاث أسباب عملية:
- أداء lookup ثابت:
Map.getأسرع منobj[key]لما الـ keys بتزيد عن آلاف، حسب benchmarks V8. - Keys من أي نوع:
Mapبتقبل objects و numbers كـ keys زي ما هي. الـ Object بيحوّل أي مفتاح لـ string. - مفيش prototype pollution: مفيش حاجة اسمها
cache.__proto__أوcache.toStringبتلخبطك.
سيناريو واقعي: حساب الضريبة على 50 ألف منتج
افترض إن عندك e-commerce فيه 50 ألف منتج، وكل صفحة منتج بتعرض السعر بالضريبة. لو دالة calculateTaxedPrice(productId) بتعمل query على DB في كل مرة، الـ p95 latency على صفحة فيها 24 منتج بيوصل 380ms. مع Memoization على مستوى الـ request:
const memo = memoize(calculateTaxedPrice);
// p95 latency بعد التعديل: 41ms — 9.2x أسرعالافتراض هنا إن أسعار المنتجات ثابتة خلال الـ request. لو الأسعار بتتغيّر كل ثانية، Memoization مش أداتك.
الـ trade-offs الحقيقية
كل تحسين له ثمنه. Memoization بتاخد ذاكرة. مع fib(10000) الـ Map هيخزن 10 آلاف زوج (number, BigInt) — حوالي 480KB في الـ heap. مع query function بترجع object كبير، الذاكرة ممكن تطلع بسرعة.
الـ trade-off هنا: بتكسب سرعة 100x إلى 10000x، بتخسر ذاكرة بحجم (عدد المدخلات الفريدة × حجم النتيجة).
الحل لمنع تضخم الذاكرة: استخدم LRU cache بحد أقصى. مكتبة lru-cache على npm بتديك نفس الـ API مع TTL وحد للحجم:
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({ max: 500, ttl: 60_000 });
function memoizeLRU(fn) {
return function (key) {
if (cache.has(key)) return cache.get(key);
const result = fn(key);
cache.set(key, result);
return result;
};
}متى لا تستخدم Memoization
الموضوع مش رصاصة فضية. لا تستخدمه في الحالات دي:
- الدالة مش pure: لو فيها
Date.now()،Math.random()، أو بتقرا state من الخارج، النتيجة المخزّنة هتكون غلط. - المدخلات الفريدة كتيرة جدًا: لو كل request بيجي بـ UUID مختلف، الـ cache hit rate صفر والذاكرة بتكبر بدون فايدة.
- حجم النتيجة كبير وحساس للذاكرة: تخزين 10 آلاف query result بـ 50KB يساوي 500MB. مفيش فايدة.
- النتيجة بتتغيّر مع الوقت: زي سعر صرف الدولار. الـ cache هتديك بيانات قديمة. لو لازم تستخدم cache، حط TTL قصير.
قاعدة قرار سريعة
قبل ما تطبق Memoization على دالة، اسأل نفسك التلات أسئلة دي بالتفاصيل:
- الدالة pure؟ (نفس input → نفس output دايمًا)
- الدالة بتتنده على نفس الـ inputs أكتر من 5 مرات في عمر العملية؟
- تكلفة الحساب أعلى من تكلفة lookup في Map؟ (يعني الدالة بتاخد ميكروثانية على الأقل)
لو الـ 3 إجابات "نعم"، طبّقها. لو واحد منهم "لا"، الدالة مش محتاجة Memoization.
الخطوة التالية
افتح أبطأ دالة في الكود عندك واتفرّج على شجرة استدعاءاتها بـ --prof في Node:
node --prof app.js
node --prof-process isolate-*.log > profile.txtدوّر على الدوال اللي بتتنده آلاف المرات بنفس الـ arguments. غلّفها بـ memoize. قِس الفرق بـ performance.now() قبل وبعد. لو الفرق أقل من 10%، شيلها — الذاكرة الزيادة مش بتستاهل.