تقليل حمولات JavaScript باستخدام ميزة اهتزاز الشجرة

تزداد تطبيقات الويب في الوقت الحالي بشكل كبير، خاصةً جزء JavaScript منها. اعتبارًا من منتصف عام 2018، أصبح متوسط حجم نقل ملفات JavaScript على الأجهزة الجوّالة بحجم 350 كيلوبايت تقريبًا. وهذا مجرد حجم نقل! غالبًا ما يتم ضغط JavaScript عند إرسالها عبر الشبكة، ما يعني أنّ المقدار الفعلي من JavaScript يزيد إلى حد ما بعد أن يفكّ المتصفح ضغطه. ومن المهم الإشارة إلى هذا الأمر، لأنّه بالنسبة إلى معالجة الموارد، ليس للضغط أي تأثير. 900 كيلوبايت من محتوى JavaScript غير المضغوط لا يزال بحجم 900 كيلوبايت للمحلل اللغوي والمحول البرمجي، على الرغم من أنه قد يبلغ حوالي 300 كيلوبايت عند ضغطه.

مخطّط يوضّح عملية تنزيل JavaScript وفك ضغطها وتحليلها وتجميعها وتنفيذها
يشير هذا المصطلح إلى عملية تنزيل JavaScript وتشغيله. تجدر الإشارة إلى أنّه على الرغم من أنّ حجم نقل النص البرمجي هو 300 كيلوبايت مضغوط، إلا أنّ حجم JavaScript لا يزال 900 كيلوبايت، ويجب تحليله وتجميعه وتنفيذه.

تُعد JavaScript موردًا مكلفًا للمعالجة. وعلى عكس الصور التي لا تستغرق سوى وقت تافه نسبيًا لفك الترميز بعد تنزيلها، يجب تحليل JavaScript وتجميعها ثم تنفيذها في النهاية. بايت، وهذا يجعل JavaScript أكثر تكلفة من أنواع الموارد الأخرى.

مخطّط بياني يقارن بين وقت المعالجة الذي يبلغ 170 كيلوبايت من JavaScript مقابل صورة JPEG بحجم مكافئ مورد JavaScript هو أكبر بكثير من موارد البايت الخاصة بالبايت في JPEG.
تكلفة معالجة تحليل/تجميع 170 كيلوبايت من JavaScript مقابل وقت فك ترميز ملف JPEG بحجم مكافئ. (المصدر).

في حين أنّ التحسينات المستمرة تتم بهدف تحسين كفاءة محرّكات JavaScript، فإنّ تحسين أداء JavaScript هو مهمة تقع على عاتق المطوّرين كالعادة.

ولتحقيق هذه الغاية، ثمة أساليب لتحسين أداء JavaScript. تقسيم الرموز هو أحد الأساليب التي تعمل على تحسين الأداء عن طريق تقسيم JavaScript للتطبيق إلى أجزاء، وعرض هذه المقاطع إلى مسارات التطبيق الذي يحتاج إليها فقط.

على الرغم من نجاح هذا الأسلوب، فإنّه لا يعالج مشكلة شائعة في التطبيقات التي تحتوي على JavaScript، وهي تضمين رمز برمجي لا يتم استخدامه أبدًا. تحاول اهتزاز الشجرة حل هذه المشكلة.

ما هي هزة الشجرة؟

هزة الشجرة هو شكل من أشكال التخلص من الرموز البرمجية. تم نشر العبارة بواسطة دمج القنوات، لكن مفهوم إزالة الرمز المعطَّل أصبح متاحًا منذ فترة. وقد ظهر المفهوم أيضًا عملية الشراء في webpack، كما هو موضَّح في هذه المقالة من خلال نموذج تطبيق.

مصطلح "اهتزاز الشجرة" يأتي من النموذج العقلي لتطبيقك وتبعياته كهيكل يشبه الشجرة. تمثل كل عقدة في الشجرة تبعية توفر وظائف مختلفة لتطبيقك. في التطبيقات الحديثة، يتم جلب هذه التبعيات من خلال عبارات import الثابتة مثل:

// Import all the array utilities!
import arrayUtils from "array-utils";

عندما يكون التطبيق صغيرًا - نبتة، إذا فعلت ذلك - فقد يكون لديه القليل من التبعيات. كما أنه يستخدم معظم التبعيات التي تضيفها، إن لم يكن كلها. ومع ذلك، مع نمو تطبيقك، يمكن إضافة المزيد من التبعيات. ولتجميع المسائل، يتم إيقاف استخدام التبعيات القديمة، ولكن قد لا يتم اقتطاعها من قاعدة التعليمات البرمجية. والنتيجة النهائية هي شحن التطبيق بكميات كبيرة من محتوى JavaScript غير المستخدَم. يعالج اهتزاز الشجرة هذا الأمر من خلال الاستفادة من الطريقة التي تجمع بها عبارات import الثابتة أجزاءً معيّنة من وحدات ES6:

// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";

الفرق بين مثال import هذا ومثال المثال السابق هو أنّه بدلاً من استيراد كل شيء من وحدة "array-utils"، التي قد تكون عددًا كبيرًا من الرموز، يستورد هذا المثال أجزاءً معيّنة فقط منها. في إصدارات dev، لا يؤدي ذلك إلى حدوث أي تغيير، إذ يتم استيراد الوحدة بأكملها بغض النظر عن ذلك. في إصدارات الإنتاج، يمكن ضبط webpack على "الاهتزاز". عمليات التصدير من وحدات ES6 التي لم يتم استيرادها بشكل صريح، ما يجعل إصدارات الإنتاج هذه أصغر. ستتعرف في هذا الدليل على كيفية إجراء ذلك!

إيجاد فرص لهز شجرة

لأغراض التوضيح، يتوفّر نموذج تطبيق من صفحة واحدة يوضّح طريقة عمل اهتزاز الأشجار. يمكنك استنساخ هذه المعلومات والمتابعة إذا أردت، لكننا سنتناول كل خطوة من الخطوات معًا في هذا الدليل، لذا فإن استنساخ ليس ضروريًا (إلا إذا كان التعلم التطبيقي هو ما يناسبك).

نموذج التطبيق عبارة عن قاعدة بيانات قابلة للبحث عن دوّاسات تأثير الغيتار. تُدخل استعلامًا وستظهر قائمة بدوّاسات التأثير.

لقطة شاشة لنموذج من تطبيق من صفحة واحدة للبحث في قاعدة بيانات حول دوّاسات تأثير الغيتار
لقطة شاشة لنموذج التطبيق

ويتم فصل السلوك الذي يعمل على تشغيل هذا التطبيق حسب المورّد (أي Preact وEmotion) وحِزم الرموز الخاصة بالتطبيق (أو "الأجزاء"، كما تطلق عليها حزمة الويب):

لقطة شاشة لحزمتَي (أو مقاطع) لرموز تطبيقات تظهر في لوحة الشبكة ضِمن "أدوات مطوري البرامج" في Chrome
حزمتا JavaScript الخاصتان بالتطبيق هذه أحجام غير مضغوطة.

حزم JavaScript الموضحة في الشكل أعلاه هي إصدارات إنتاج، أي أنه يتم تحسينها من خلال التقسيم. فحجم 21.1 كيلوبايت لحزمة خاصة بالتطبيق ليس سيئًا، ولكن تجدر الإشارة إلى أنّه لا هزّ أي شجرة على الإطلاق. لنلقي نظرة على رمز التطبيق ونرى ما يمكن فعله لإصلاح ذلك.

في أي تطبيق، يتضمّن العثور على فرص اهتزاز الشجرة البحث عن عبارات import الثابتة. بالقرب من أعلى ملف المكوّن الرئيسي، سيظهر لك سطر على النحو التالي:

import * as utils from "../../utils/utils";

يمكنك استيراد وحدات ES6 بعدة طرق، ولكن ستلفت انتباهك وحدات كهذه. يعرض هذا السطر تحديدًا "import كل شيء من وحدة utils، ويضعه في مساحة اسم تُسمى utils". والسؤال الكبير الذي يجب طرحه هنا هو: "ما مقدار العناصر في تلك الوحدة؟"

إذا نظرت إلى رمز المصدر للوحدة utils، ستجد حوالي 1,300 سطر من الرموز.

هل تحتاج إلى كل هذه الأشياء؟ لنتحقق مرة أخرى من خلال البحث عن ملف المكوِّن الرئيسي الذي يستورد وحدة utils لمعرفة عدد مثيلات مساحة الاسم هذه.

لقطة شاشة لعملية بحث في محرِّر النصوص عن "utils."، وتعرض 3 نتائج فقط
يتم استدعاء مساحة الاسم utils التي تم استيراد أطنان من الوحدات منها ثلاث مرات فقط داخل ملف المكوِّن الرئيسي.

كما اتضح، تظهر مساحة الاسم utils في ثلاثة مواضع فقط في التطبيق، ولكن لأي وظائف؟ إذا ألقيت نظرة على ملف المكون الرئيسي مرة أخرى، فإنه يبدو أنه دالة واحدة فقط، وهي utils.simpleSort، والتي تُستخدم لتصنيف قائمة نتائج البحث حسب عدد من المعايير عند تغيير قوائم الترتيب المنسدلة:

if (this.state.sortBy === "model") {
  // `simpleSort` gets used here...
  json = utils.simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  // ..and here...
  json = utils.simpleSort(json, "type", this.state.sortOrder);
} else {
  // ..and here.
  json = utils.simpleSort(json, "manufacturer", this.state.sortOrder);
}

من بين ملف 1300 سطر يتضمن مجموعة من عمليات التصدير، يتم استخدام واحد منها فقط. ينتج عن ذلك شحن الكثير من محتوى JavaScript غير المستخدَم.

على الرغم من أن هذا المثال من التطبيق مفتعل بعض الشيء، إلا أنه لا يغير حقيقة أن هذا النوع من السيناريو الاصطناعي يشبه فرص التحسين الفعلية التي قد تواجهها في تطبيق ويب للإنتاج. الآن بعد أن حددت فرصة لاهتزاز الشجرة لتكون مفيدة، كيف يتم ذلك بالفعل؟

حماية Babel من تحويل وحدات ES6 إلى وحدات CommonJS

تُعدّ أداة Babel أداة لا غنى عنها، ولكنّها قد تزيد من صعوبة ملاحظة آثار هزّ الأشجار. في حال استخدام @babel/preset-env، قد تحوّل Babel وحدات ES6 إلى وحدات CommonJS أكثر توافقًا على نطاق واسع، أي الوحدات require بدلاً من import.

نظرًا إلى صعوبة تنفيذ اهتزاز الشجرة بالنسبة إلى وحدات CommonJS، لن تعرف webpack ما يجب إزالته من الحِزم إذا قرّرت استخدامها. يكمن الحل في ضبط @babel/preset-env لترك وحدات ES6 كما هي. وعند ضبط إعدادات Babel، سواء كنت تستخدم babel.config.js أو package.json، يمكنك إضافة بعض العناصر الإضافية:

// babel.config.js
export default {
  presets: [
    [
      "@babel/preset-env", {
        modules: false
      }
    ]
  ]
}

يؤدي تحديد modules: false في إعدادات @babel/preset-env إلى جعل Babel يعمل على النحو المطلوب، ما يسمح لحزمة الويب بتحليل شجرة التبعية والتخلّص من الاعتماديات غير المستخدَمة.

مع أخذ الآثار الجانبية في الاعتبار

هناك جانب آخر يجب مراعاته عند هز التبعيات من تطبيقك وهو ما إذا وحدات المشروع لديك آثار جانبية. مثال على الأثر الجانبي تعدّل عنصرًا خارج نطاقها الخاص، ما يُعدّ تأثيرًا جانبيًا. طريقة تنفيذها:

let fruits = ["apple", "orange", "pear"];

console.log(fruits); // (3) ["apple", "orange", "pear"]

const addFruit = function(fruit) {
  fruits.push(fruit);
};

addFruit("kiwi");

console.log(fruits); // (4) ["apple", "orange", "pear", "kiwi"]

في هذا المثال، ينتج عن addFruit تأثير جانبي عند تعديل المصفوفة fruits خارج نطاقها.

تنطبق التأثيرات الجانبية أيضًا على وحدات ES6، وهذا مهم في سياق اهتزاز الأشجار. الوحدات التي تأخذ مدخلات يمكن التنبؤ بها وتنتج مخرجات يمكن التنبؤ بها على حد سواء بدون تعديل أي شيء خارج نطاقها هي عناصر تبعية يمكن إسقاطها بأمان في حالة عدم استخدامها. وهي عبارة عن قِطع من الرموز البرمجية مجمّعة مستقلة. ومن هنا جاءت عبارة "الوحدات".

بالنسبة إلى حزمة الويب، يمكن استخدام تلميح لتحديد أنّ الحزمة وتبعياتها خالية من الآثار الجانبية من خلال تحديد "sideEffects": false في ملف package.json الخاص بالمشروع:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": false
}

ويمكنك بدلاً من ذلك إخبار webpack بالملفات المحددة التي ليست خالية من الآثار الجانبية:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": [
    "./src/utils/utils.js"
  ]
}

في المثال الثاني، أي ملف لم يتم تحديده سيفترض أن يكون خاليًا من الآثار الجانبية. إذا كنت لا تريد إضافة هذا إلى ملف package.json، يمكنك أيضًا تحديد هذه العلامة في إعداد حزمة الويب من خلال module.rules.

استيراد البيانات المطلوبة فقط

بعد أن تطلب من Babel التحكّم في وحدات ES6 وحدها، يجب إجراء تعديل بسيط على بنية import لإضافة الدوال المطلوبة من وحدة utils فقط. في مثال هذا الدليل، كل ما تحتاج إليه هو الدالة simpleSort:

import { simpleSort } from "../../utils/utils";

بما أنّه يتم استيراد simpleSort فقط بدلاً من وحدة utils بأكملها، يجب تغيير كل مثيل من utils.simpleSort إلى simpleSort:

if (this.state.sortBy === "model") {
  json = simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  json = simpleSort(json, "type", this.state.sortOrder);
} else {
  json = simpleSort(json, "manufacturer", this.state.sortOrder);
}

ينبغي أن يكون هذا هو كل ما هو مطلوب حتى تعمل هزة الشجرة في هذا المثال. هذا هو ناتج webpack قبل هزّ شجرة التبعية:

                 Asset      Size  Chunks             Chunk Names
js/vendors.16262743.js  37.1 KiB       0  [emitted]  vendors
   js/main.797ebb8b.js  20.8 KiB       1  [emitted]  main

هذا هو الناتج بعد نجاح اهتزاز الشجرة:

                 Asset      Size  Chunks             Chunk Names
js/vendors.45ce9b64.js  36.9 KiB       0  [emitted]  vendors
   js/main.559652be.js  8.46 KiB       1  [emitted]  main

على الرغم من تقليص حجم الحِزمتين، إلا أنّ حزمة main هي الأكثر استفادة منها. عند إزالة الأجزاء غير المستخدمة من وحدة utils، تتقلص الحزمة main بنسبة 60% تقريبًا. ولا يؤدي ذلك إلى تقليل الوقت الذي يستغرقه النص البرمجي في عملية التنزيل فحسب، بل إلى تقليل وقت المعالجة أيضًا.

هزّ بعض الأشجار.

مهما كانت المسافة المقطوعة التي تخرجها من هزة الشجرة تعتمد على تطبيقك وتبعياته وبنيته. جرّب الآن إذا كنت تعلم أنك لم تُعِدّ أداة تجميع الوحدات لإجراء هذا التحسين، فلا داعٍ من المحاولة ومعرفة كيف يفيد تطبيقك.

قد تدرك مكاسب كبيرة في الأداء بسبب هزّ الشجرة، أو ليس كثيرًا على الإطلاق. ولكن من خلال ضبط نظام التصميم للاستفادة من هذا التحسين في الإصدارات التجريبية والاستيراد بشكل انتقائي لما يحتاجه تطبيقك فقط، سيتم بشكل استباقي إبقاء حِزم التطبيقات صغيرة قدر الإمكان.

نشكر "كريستوف باكستر" وجيسون ميلر وأدي عثماني وجيف بوسنيك و"سام ساكون" وفيليب والتون على ملاحظاتهم القيّمة التي ساهمت في تحسين جودة هذه المقالة بشكل كبير.