تقسيم الكود
يُعد تقسيم الكود (Code Splitting) أحد أكثر ميزات webpack قوة وإقناعًا. تتيح لك هذه الميزة تقسيم الكود إلى حزم متعددة يمكن تحميلها عند الطلب أو بشكل متوازٍ. يمكن استخدام ذلك لتحقيق حزم أصغر والتحكم في أولوية تحميل الموارد، مما قد يكون له تأثير كبير على وقت التحميل إذا استُخدم بشكل صحيح.
هناك ثلاث طرق عامة متاحة لتقسيم الكود:
- نقاط الإدخال (Entry Points): تقسيم الكود يدويًا باستخدام إعداد
entry. - منع التكرار (Prevent Duplication): استخدام اعتماديات الإدخال أو
SplitChunksPluginلإزالة التكرار وتقسيم الأجزاء. - الاستيراد الديناميكي (Dynamic Imports): تقسيم الكود عبر استدعاءات دوال مضمّنة داخل الوحدات.
نقاط الإدخال
تُعد هذه أبسط وأكثر الطرق وضوحًا لتقسيم الكود. ومع ذلك فهي أكثر يدوية ولها بعض العيوب التي سنستعرضها. دعونا نلقِ نظرة على كيفية تقسيم وحدة أخرى من الحزمة الرئيسية:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
├── /src
│ ├── index.js
+ │ └── another-module.js
└── /node_modulesanother-module.js
import _ from "lodash";
console.log(_.join(["Another", "module", "loaded!"], " "));webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
- entry: './src/index.js',
+ mode: 'development',
+ entry: {
+ index: './src/index.js',
+ another: './src/another-module.js',
+ },
output: {
- filename: 'main.js',
+ filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
};سينتج عن ذلك نتيجة البناء التالية:
...
[webpack-cli] Compilation finished
asset index.bundle.js 553 KiB [emitted] (name: index)
asset another.bundle.js 553 KiB [emitted] (name: another)
runtime modules 2.49 KiB 12 modules
cacheable modules 530 KiB
./src/index.js 257 bytes [built] [code generated]
./src/another-module.js 84 bytes [built] [code generated]
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 245 msكما ذُكر، توجد بعض العيوب في هذا الأسلوب:
- إذا كانت هناك وحدات مكررة بين أجزاء الإدخال، فسيتم تضمينها في كلتا الحزمتين.
- ليس مرنًا جدًا ولا يمكن استخدامه لتقسيم الكود ديناميكيًا مع منطق التطبيق الأساسي.
النقطة الأولى من هاتين النقطتين تُعد مشكلة فعلية في مثالنا، لأن lodash تم استيراده أيضًا داخل ./src/index.js وبالتالي سيتم تكراره في كلتا الحزمتين. دعونا نزيل هذا التكرار في القسم التالي.
منع التكرار
اعتماديات الإدخال
يسمح خيار dependOn بمشاركة الوحدات بين الأجزاء:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
mode: 'development',
entry: {
- index: './src/index.js',
- another: './src/another-module.js',
+ index: {
+ import: './src/index.js',
+ dependOn: 'shared',
+ },
+ another: {
+ import: './src/another-module.js',
+ dependOn: 'shared',
+ },
+ shared: 'lodash',
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
};إذا كنا سنستخدم عدة نقاط إدخال في صفحة HTML واحدة، فسنحتاج أيضًا إلى optimization.runtimeChunk: 'single'، وإلا فقد نواجه المشكلة الموضحة هنا.
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
mode: 'development',
entry: {
index: {
import: './src/index.js',
dependOn: 'shared',
},
another: {
import: './src/another-module.js',
dependOn: 'shared',
},
shared: 'lodash',
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
+ optimization: {
+ runtimeChunk: 'single',
+ },
};وهنا نتيجة البناء:
...
[webpack-cli] Compilation finished
asset shared.bundle.js 549 KiB [compared for emit] (name: shared)
asset runtime.bundle.js 7.79 KiB [compared for emit] (name: runtime)
asset index.bundle.js 1.77 KiB [compared for emit] (name: index)
asset another.bundle.js 1.65 KiB [compared for emit] (name: another)
Entrypoint index 1.77 KiB = index.bundle.js
Entrypoint another 1.65 KiB = another.bundle.js
Entrypoint shared 557 KiB = runtime.bundle.js 7.79 KiB shared.bundle.js 549 KiB
runtime modules 3.76 KiB 7 modules
cacheable modules 530 KiB
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
./src/another-module.js 84 bytes [built] [code generated]
./src/index.js 257 bytes [built] [code generated]
webpack 5.x.x compiled successfully in 249 msكما ترى، تم إنشاء ملف إضافي باسم runtime.bundle.js بجانب shared.bundle.js و index.bundle.js و another.bundle.js.
على الرغم من أن استخدام عدة نقاط إدخال في الصفحة الواحدة مسموح به في webpack، فإنه يُفضل تجنبه متى أمكن لصالح نقطة إدخال واحدة تحتوي على عدة استيرادات: entry: { page: ['./analytics', './app'] }. ينتج عن ذلك تحسين أفضل وترتيب تنفيذ متسق عند استخدام وسوم script مع async.
SplitChunksPlugin
تسمح لنا SplitChunksPlugin باستخراج الاعتماديات المشتركة إلى جزء إدخال موجود أو إلى جزء جديد بالكامل. دعونا نستخدمها لإزالة تكرار اعتماد lodash من المثال السابق:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
mode: 'development',
entry: {
index: './src/index.js',
another: './src/another-module.js',
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
+ optimization: {
+ splitChunks: {
+ chunks: 'all',
+ },
+ },
};بعد إضافة إعداد optimization.splitChunks، ينبغي الآن إزالة الاعتماد المكرر من index.bundle.js و another.bundle.js. يجب أن يلاحظ الملحق أننا فصلنا lodash إلى جزء مستقل ويزيل الوزن الزائد من الحزمة الرئيسية. ومع ذلك، من المهم ملاحظة أن الاعتماديات المشتركة لن تُستخرج إلى جزء منفصل إلا إذا استوفت حدود الحجم التي يحددها webpack.
دعونا ننفذ npm run build لنرى إن كان ذلك قد نجح:
...
[webpack-cli] Compilation finished
asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors)
asset index.bundle.js 8.92 KiB [compared for emit] (name: index)
asset another.bundle.js 8.8 KiB [compared for emit] (name: another)
Entrypoint index 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB index.bundle.js 8.92 KiB
Entrypoint another 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB another.bundle.js 8.8 KiB
runtime modules 7.64 KiB 14 modules
cacheable modules 530 KiB
./src/index.js 257 bytes [built] [code generated]
./src/another-module.js 84 bytes [built] [code generated]
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 241 msإليك بعض الملحقات والمحملات الأخرى المفيدة المقدمة من المجتمع لتقسيم الكود:
mini-css-extract-plugin: مفيد لفصل CSS عن التطبيق الرئيسي.
الاستيراد الديناميكي
يدعم webpack تقنيتين متشابهتين لتقسيم الكود الديناميكي. الطريقة الأولى والمُوصى بها هي استخدام صيغة import() المتوافقة مع اقتراح ECMAScript للاستيراد الديناميكي. الطريقة القديمة الخاصة بـ webpack هي استخدام require.ensure. دعونا نجرب الطريقة الأولى...
قبل أن نبدأ، دعونا نزيل entry الإضافي و optimization.splitChunks من إعداداتنا في المثال السابق لأننا لن نحتاجهما في هذا العرض التالي:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
mode: 'development',
entry: {
index: './src/index.js',
- another: './src/another-module.js',
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
- optimization: {
- splitChunks: {
- chunks: 'all',
- },
- },
};سنقوم أيضًا بتحديث مشروعنا لإزالة الملفات غير المستخدمة الآن:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
├── /src
│ ├── index.js
- │ └── another-module.js
└── /node_modulesالآن، بدلًا من استيراد lodash بشكل ثابت، سنستخدم الاستيراد الديناميكي لفصل جزء مستقل:
src/index.js
-import _ from 'lodash';
-
-function component() {
+function getComponent() {
- const element = document.createElement('div');
- // Lodash, now imported by this script
- element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+ return import('lodash')
+ .then(({ default: _ }) => {
+ const element = document.createElement('div');
+
+ element.innerHTML = _.join(['Hello', 'webpack'], ' ');
- return element;
+ return element;
+ })
+ .catch((error) => 'حدث خطأ أثناء تحميل المكوّن');
}
-document.body.appendChild(component());
+getComponent().then((component) => {
+ document.body.appendChild(component);
+});السبب في حاجتنا إلى default هو أنه منذ webpack 4، عند استيراد وحدة CommonJS، لن يتم حل الاستيراد إلى قيمة module.exports مباشرة، بل سيتم إنشاء كائن namespace اصطناعي لوحدة CommonJS. لمزيد من المعلومات حول سبب ذلك، اقرأ webpack 4: import() and CommonJs.
دعونا نشغّل webpack لنرى lodash منفصلة في حزمة مستقلة:
...
[webpack-cli] Compilation finished
asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors)
asset index.bundle.js 13.5 KiB [compared for emit] (name: index)
runtime modules 7.37 KiB 11 modules
cacheable modules 530 KiB
./src/index.js 434 bytes [built] [code generated]
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 268 msimport(
/* webpackExports: ["default", "namedExport"] */
"./module"
);يمكن أن يساعد ذلك webpack في إزالة الصادرات غير المستخدمة (tree shaking). راجع Magic Comments للمزيد من التفاصيل.
وبما أن import() تُرجع Promise، فيمكن استخدامها مع async functions. إليك كيف يمكن أن تُبسّط الكود:
src/index.js
-function getComponent() {
+async function getComponent() {
+ const element = document.createElement('div');
+ const { default: _ } = await import('lodash');
- return import('lodash')
- .then(({ default: _ }) => {
- const element = document.createElement('div');
+ element.innerHTML = _.join(['Hello', 'webpack'], ' ');
- element.innerHTML = _.join(['Hello', 'webpack'], ' ');
-
- return element;
- })
- .catch((error) => 'An error occurred while loading the component');
+ return element;
}
getComponent().then((component) => {
document.body.appendChild(component);
});فهم ChunkLoadError
عند استخدام import() الديناميكي أو تقسيم الكود، قد يرمي webpack خطأ ChunkLoadError إذا فشل تحميل جزء أثناء وقت التشغيل.
يشير هذا الخطأ عادةً إلى أن الجزء المطلوب لم يتمكن من التنفيذ أو الحل بشكل صحيح. وفي بعض الحالات، قد لا ينعكس خطأ الشبكة أو تحميل السكربت الأساسي من المتصفح بالكامل داخل رسالة ChunkLoadError نفسها.
إذا واجهت هذا الخطأ:
- تحقق من أن ملف الجزء يمكن الوصول إليه عبر الشبكة.
- تحقق من أن
publicPathتم إعداده بشكل صحيح. - افحص وحدة التحكم في المتصفح للحصول على أخطاء إضافية تتعلق بالسكربت أو الشبكة.
للمزيد من التفاصيل، راجع النقاش ذي الصلة في متتبع مشاكل webpack.
الجلب المسبق/التحميل المسبق للوحدات
أضاف webpack 4.6.0+ دعمًا للجلب المسبق (prefetching) والتحميل المسبق (preloading).
يسمح استخدام هذه التعليمات المضمّنة أثناء تعريف الاستيرادات لـ webpack بإخراج “Resource Hint” يُخبر المتصفح بما يلي:
- prefetch: من المحتمل أن يكون المورد مطلوبًا لبعض التنقلات المستقبلية.
- preload: سيكون المورد مطلوبًا أيضًا أثناء التنقل الحالي.
مثال على ذلك وجود مكوّن HomePage يعرض مكوّن LoginButton الذي يقوم عند الطلب بتحميل مكوّن LoginModal بعد النقر عليه.
LoginButton.js
// ...
import(/* webpackPrefetch: true */ "./path/to/LoginModal.js");سينتج عن ذلك إضافة <link rel="prefetch" href="login-modal-chunk.js"> داخل رأس الصفحة، مما يوجّه المتصفح لجلب ملف login-modal-chunk.js مسبقًا أثناء وقت الخمول.
يوجد عدة اختلافات بين preload و prefetch:
- يبدأ تحميل الجزء المحمّل مسبقًا بالتوازي مع الجزء الأب. بينما يبدأ تحميل الجزء المجلوِب مسبقًا بعد انتهاء تحميل الجزء الأب.
- يمتلك الجزء المحمّل مسبقًا أولوية متوسطة ويتم تنزيله فورًا. بينما يتم تنزيل الجزء المجلوِب مسبقًا عندما يكون المتصفح في وضع الخمول.
- يجب أن يُطلب الجزء المحمّل مسبقًا فورًا من قبل الجزء الأب. بينما يمكن استخدام الجزء المجلوِب مسبقًا في أي وقت مستقبلًا.
- يختلف دعم المتصفحات بينهما.
يمكن أن يكون مثال ذلك وجود مكوّن Component يعتمد دائمًا على مكتبة ضخمة يجب وضعها في جزء منفصل.
لنتخيل مكوّنًا باسم ChartComponent يحتاج إلى مكتبة ضخمة اسمها ChartingLibrary. يعرض LoadingIndicator عند التصيير ويقوم مباشرة باستيراد ChartingLibrary عند الطلب:
ChartComponent.js
// ...
import(/* webpackPreload: true */ "ChartingLibrary");عندما يتم طلب صفحة تستخدم ChartComponent، سيتم أيضًا طلب جزء charting-library عبر <link rel="preload">. وبافتراض أن جزء الصفحة أصغر وينتهي تحميله أسرع، فسيتم عرض الصفحة مع LoadingIndicator حتى ينتهي تحميل charting-library-chunk الذي تم طلبه مسبقًا. هذا يعطي تحسينًا بسيطًا في وقت التحميل لأنه يحتاج إلى رحلة اتصال واحدة بدلًا من اثنتين، خاصة في البيئات ذات زمن الوصول العالي.
أحيانًا قد تحتاج إلى تحكمك الخاص في preload. على سبيل المثال، يمكن تنفيذ preload لأي استيراد ديناميكي عبر سكربت async. قد يكون هذا مفيدًا في حالة التصيير من جهة الخادم بشكل متدفق (streaming server side rendering).
const lazyComp = () =>
import("DynamicComponent").catch((error) => {
// افعل شيئًا مع الخطأ.
// على سبيل المثال، يمكننا إعادة المحاولة في حال حدوث خطأ شبكي
});إذا فشل تحميل السكربت قبل أن يبدأ webpack بتحميله بنفسه (يقوم webpack بإنشاء وسم script لتحميل الكود إذا لم يكن السكربت موجودًا في الصفحة)، فلن يبدأ معالج catch حتى انتهاء مدة chunkLoadTimeout. قد يكون هذا السلوك غير متوقع. لكنه قابل للتفسير — لأن webpack لا يستطيع رمي أي خطأ، إذ إنه لا يعلم أن السكربت قد فشل. سيضيف webpack معالج onerror للسكربت بعد حدوث الخطأ بالفعل.
لمنع هذه المشكلة يمكنك إضافة معالج onerror خاص بك يقوم بإزالة السكربت في حالة حدوث أي خطأ:
<script
src="https://example.com/dist/dynamicComponent.js"
async
onerror="this.remove()"
></script>في هذه الحالة، سيتم إزالة السكربت الذي حدث به خطأ. وسيقوم webpack بإنشاء سكربته الخاص وستتم معالجة أي خطأ بدون أي مهلات زمنية.
تحليل الحزم
بمجرد أن تبدأ بتقسيم الكود، قد يكون من المفيد تحليل المخرجات لمعرفة أين انتهت الوحدات. تُعد أداة التحليل الرسمية مكانًا جيدًا للبدء. كما توجد بعض الخيارات الأخرى المدعومة من المجتمع:
- webpack-chart: مخطط دائري تفاعلي لإحصائيات webpack.
- webpack-visualizer: تصور وتحليل الحزم لمعرفة الوحدات التي تستهلك المساحة وأيها قد تكون مكررة.
- webpack-bundle-analyzer: ملحق وأداة CLI تمثل محتوى الحزمة على شكل خريطة شجرية تفاعلية قابلة للتكبير.
- webpack bundle optimize helper: تقوم هذه الأداة بتحليل الحزمة وتقديم اقتراحات عملية لتقليل حجم الحزمة.
- bundle-stats: إنشاء تقرير عن الحزمة (الحجم، الأصول، الوحدات) ومقارنة النتائج بين البنايات المختلفة.
- webpack-stats-viewer: ملحق مدمج لإحصائيات webpack يعرض معلومات أكثر عن تفاصيل الحزمة.
الخطوات التالية
راجع التحميل الكسول للحصول على مثال عملي أكثر حول كيفية استخدام import() في تطبيق حقيقي، وراجع التخزين المؤقت لمعرفة كيفية تقسيم الكود بشكل أكثر فعالية.



