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

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

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

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

رسم بياني يقارن وقت معالجة 170 كيلوبايت من JavaScript مقارنةً بصورة JPEG بالحجم نفسه يستهلك ملف JavaScript موارد أكثر من ملف JPEG لكل بايت.
تشير هذه السمة إلى تكلفة معالجة تحليل/تجميع 170 كيلوبايت من JavaScript مقارنةً بوقت فك ترميز ملف JPEG بحجم مماثل. (source).

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

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

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

ما هو "هَزّ الشجرة"؟

إزالة الأشجار هي شكل من أشكال إزالة الرموز غير الصالحة. انتشرت هذه العبارة من خلال أداة Rollup، ولكن كان مفهوم إزالة الرموز غير الصالحة متوفّرًا منذ بعض الوقت. وقد تم استخدام هذا المفهوم أيضًا في 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"، التي قد تتضمّن الكثير من الرموز البرمجية، يستورد هذا المثال أجزاء معيّنة منها فقط. في إصدارات المطوّرين، لا يؤدي ذلك إلى تغيير أي شيء، لأنّه يتم استيراد الوحدة بأكملها بغض النظر عن ذلك. في عمليات الإنشاء المخصّصة للإصدار العلني، يمكن ضبط Webpack على "إزالة" العناصر التي تم تصديرها من وحدات ES6 التي لم يتم استيرادها صراحةً، ما يجعل عمليات الإنشاء هذه أصغر حجمًا. ستتعرف في هذا الدليل على كيفية إجراء ذلك!

العثور على فرص لزيادة عدد المستخدمين

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

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

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

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

لقطة شاشة لحزمتَي (أو مقاطع) لرموز تطبيقات تظهر في لوحة الشبكة ضِمن "أدوات مطوري البرامج" في 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);
}

من ملف يتضمّن 1,300 سطر مع مجموعة من عمليات التصدير، يتم استخدام عملية واحدة فقط. ينتج عن ذلك شحن الكثير من محتوى 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 على النحو المطلوب، ما يسمح لواجهة webpack بتحليل شجرة التبعيات وإزالة التبعيات غير المستخدَمة.

مراعاة الآثار الجانبية

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

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، وهو خارج نطاق addFruit.

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

في ما يتعلّق بـ webpack، يمكن استخدام تلميح لتحديد أنّ الحزمة وتبعياتها خالية من الآثار الجانبية من خلال تحديد "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 تقريبًا. لا يقتصر هذا الإجراء على تقليل الوقت الذي يستغرقه النص البرمجي للتنزيل، بل يقلل أيضًا من وقت المعالجة.

طاب يومك.

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

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

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