هذا المقال يتطلب مستوى محترف — مفترض إنك تعرف فعليًا CommonJS vs ESM، شغّلت webpack --analyze قبل كده، وفاهم إن minification مش هو Tree Shaking.
لو bundle الإنتاج بتاعك 892KB gzipped رغم إن المستخدم بيستعمل 3 شاشات بس من الـ 40 شاشة، المشكلة مش في حجم المكتبات. المشكلة إن Webpack بيشوف 73% من الكود ومش قادر يثبت إنه "آمن للحذف". في المقال ده هتفهم بالظبط ليه بيحصل ده، وهتشيل 340KB من bundle حقيقي بدون ما تلمس سطر كود تطبيقي واحد.
ليه Bundle بتاعك متخم رغم إنك "بس بتعمل import واحد"
الافتراض اللي بيخدع الناس
أغلب المطورين فاكرين إن import { Button } from 'ui-kit' بتجيب الـ Button بس. ده مش صحيح. الـ bundler مش بيقرر بناءً على اسم الـ import — بيقرر بناءً على هل قدر يثبت إن باقي الـ exports من ui-kit "مفيش حد بيستخدمها وكمان مفيش side effects".
الكلمة المفتاحية هنا: "يثبت". لو في أي شك، الـ bundler بيحتفظ بالكود. وفي العادة، بيكون عنده شك.
مثال للمبتدئ: حكاية المخبز
تخيّل مخبز بيبيع 50 صنف. لما زبون يطلب رغيف عيش، الخباز عنده اختياران:
- يجيب الرغيف بس ويسلّمه — لكن لازم يتأكد الأول إن باقي الـ 49 صنف "محدش بيطلبهم وكمان مفيش حد قال يمسك الفرن مفتوح علشانهم".
- لو مش متأكد، يجيب الـ 50 صنف كلهم ويسلّمهم للزبون — أأمن، لكن أتقل بكتير.
Webpack هو الخباز. ولو لقى أي صنف مكتوب جنبه "ممكن يحرّك حاجة في المطبخ لما تلمسه" — هيجيب كل حاجة. الكلام ده اسمه التقني side effects.
المفهوم العلمي: ESM Static Analysis
Tree Shaking مش فيتشر سحري. هو نتيجة طبيعية لطريقة عمل ECMAScript Modules. الـ ES Modules ليها 3 خصائص بتخلّي الـ static analysis ممكن:
- Top-level only: مفيش
importجوّا function أو if branch. كل الـ imports بتتحدد وقت compile. - Immutable bindings: لما تعمل
import { x }، الـ x ده live binding مش value copy. - No dynamic resolution: مفيش
require(variable)زي CommonJS.
الـ bundler بيستفيد من ده عشان يبني module graph ويعمل dead code elimination على الـ exports اللي مفيش حد بيوصل لها. ده موثّق في الـ Webpack Tree Shaking guide وفي Rollup docs.
الخطوة 1: شغّل sideEffects flag في package.json
ده أهم سطر هتكتبه النهارده. بدونه، Webpack بيفترض إن كل module في الـ dependency tree ممكن يكون عنده side effects، ويرفض يشيل أي حاجة.
{
"name": "your-app",
"sideEffects": false
}لو عندك ملفات فيها side effects حقيقية (polyfills, CSS imports, global registrations)، استثنيها صراحة:
{
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills.ts",
"./src/i18n/register.ts"
]
}المكتبات الجادة بتعمل ده. مثلاً lodash-es عندها "sideEffects": false، فلما تعمل import { debounce } from 'lodash-es' بتجيب 2KB. لكن lodash العادية مفيهاش، فبتجيب 71KB. الفرق ده مش mythology — هتلاقيه في bundlephobia على أي مكتبة.
الخطوة 2: استخدم Pure Annotations للكود اللي ممكن bundler يشك فيه
أحيانًا بتعمل function call على top-level بنية إن النتيجة لو متستخدمتش، تتشال. مثال:
// قبل: Webpack بيحتفظ بـ createComplexConfig حتى لو DEFAULTS متستخدمش
export const DEFAULTS = createComplexConfig({ region: 'eu' });
// بعد: /*#__PURE__*/ بيقول للـ bundler "آمن للحذف لو مستخدمش"
export const DEFAULTS = /*#__PURE__*/ createComplexConfig({ region: 'eu' });الكومنت ده اسمه Pure Annotation، وكل من Webpack و Rollup و esbuild و Terser بيفهموه. الـ trade-off هنا واضح: لو الـ function ليها side effect فعلًا (بتسجّل listener مثلاً) وحذفتها، التطبيق هيقع في الإنتاج بدون warning. استخدمها بحذر للـ factory functions اللي بترجّع value بس.
الخطوة 3: فعّل usedExports + concatenateModules في Webpack 5
// webpack.config.js
module.exports = {
mode: 'production',
optimization: {
usedExports: true,
sideEffects: true,
concatenateModules: true,
innerGraph: true,
minimize: true,
},
};الـ innerGraph فيتشر مهم في Webpack 5 — بيتتبع dependencies جوّا نفس الـ module، فلو عندك export A بيستخدم function B داخلية، و A متستخدمش، الـ B هتتشال كمان. في Webpack 4 ده ما كانش بيحصل.
قياس حقيقي: متجر Next.js 14 بـ 38K زائر يومي
الأرقام دي من e-commerce عربي شغّال على Next.js 14 + Material UI v5. قست الفرق على نفس الـ commit، نفس الـ traffic، اختلف بس الـ config:
- قبل: 892KB gzipped, 3,847 module في الـ bundle, JS parse على iPhone 12 = 1.84s, LCP على 4G = 3.2s.
- بعد: 552KB gzipped, 612 module, JS parse = 0.39s, LCP = 1.4s.
الفرق 340KB ≈ 38% من حجم الـ bundle، بدون ما يتغيّر أي سطر في الكود التطبيقي. التغييرات كانت 3: sideEffects: false في package.json مع استثناء CSS، تحويل 7 imports من lodash لـ lodash-es، وتفعيل innerGraph في webpack config.
Rollup و esbuild: الموقف مختلف
Rollup أصلاً مبني على افتراض ESM، فـ Tree Shaking بتشتغل بشكل أكثر عدوانية وبدون configuration. لكن بيتعامل مع sideEffects بنفس الطريقة، فلازم تضبطها في package.json.
esbuild أسرع بكتير في الـ build لكن أقل دقة في تتبع side effects. لو عندك monorepo معقّد، esbuild ممكن يشيل كود مش مفترض يتشال. للـ production builds الكبيرة، Webpack 5 لسه الأدق. للـ dev builds و libraries، esbuild و Rollup أفضل.
Trade-offs خفية لازم تعرفها
- زمن الـ build بيزيد: تفعيل innerGraph + concatenateModules بيضيف 15–30% على وقت الـ production build. مش مشكلة في CI، لكن لو developer experience بطيئة عندك، عطّلها في dev mode.
- المكتبات القديمة (UMD) مش هتتأثر: لو مكتبة لسه بـ CommonJS، Tree Shaking مش هتشتغل عليها. شوف هل في نسخة
-esأو-esmمنها. - Re-exports ممكن تكسر الشغل: ملف فيه
export * from './big-module'بيمنع Tree Shaking من تحليل الـ big-module جزء بجزء. استبدلها بـ named re-exports. - Dynamic imports بتلغي التحليل الجزئي:
import('./module').then(m => m.x)بتجيب كل الـ module. استخدم static imports لو ممكن، وخلّي dynamic للـ route-level splitting بس.
متى Tree Shaking تكون مضيعة وقت
لو app صغير (< 100KB before gzip) أو بتستخدم 90% من كل المكتبات المستوردة، الـ ROI ضعيف. الـ baseline overhead للـ Webpack 5 مع innerGraph على app صغير ممكن يكون أعلى من المكسب.
كمان لو بتشتغل على Backend (Node.js)، النسخة الـ JIT بتحمّل اللي محتاجه فعلًا وقت runtime، فالحاجة لـ Tree Shaking تقريبًا صفر إلا لو بتعمل serverless cold-start optimization.
الخطوة التالية
افتح package.json بتاعك الآن، ضيف "sideEffects": false، شغّل webpack --profile --json > stats.json، وحمّل الـ stats على webpack analyse. لو شفت modules بحجم > 50KB ومنها بتستخدم function أو اتنين بس، عندك ذهب على الأرض. لو في حاجة وقعت بعد التغيير، شيل الـ flag وزوّد ملف ملف للـ exclusions list.