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

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

ما هو اهتزاز الشجرة؟

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

العثور على فرص لهز شجرة

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

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

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

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

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

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

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

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

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

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

هل تحتاج إلى كل هذه العناصر؟ لنتحقق جيدًا من خلال البحث في ملف المكوِّن الرئيسي الذي يستورد الوحدة 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);
}

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

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

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

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

ولأن هزة الأشجار أكثر صعوبة في وحدات CommonJS، لن تعرف حزمة الويب ما يجب تقليه من الحزم إذا قررت استخدامها. يتمثّل الحل في ضبط @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، وهي مهمّة في سياق هزة الأشجار. تعتبر الوحدات التي تستخدم مدخلات يمكن التنبؤ بها وتنتج مخرجات يمكن توقعها بشكل متساوٍ بدون تعديل أي شيء خارج نطاقها تبعيات يمكن تجاهلها بأمان إذا لم نستخدمها. وهي عبارة عن أجزاء نموذجية مستقلة من الرموز البرمجية. وبالتالي، "الوحدات".

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

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

بدلاً من ذلك، يمكنك إخبار حزمة الويب بالملفات المحدّدة التي لا خالية من أي مؤثرات جانبية:

{
  "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);
}

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

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

حَانَ وَقْتُ الْمُغَادَرَة لِهَزِّ الْأَشْجَارِ.

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

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

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