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

وداعاً،

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

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

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