کاهش بارهای جاوا اسکریپت با تکان دادن درخت

برنامه‌های وب امروزی می‌توانند بسیار بزرگ شوند، به خصوص بخش جاوا اسکریپت آنها. از اواسط سال ۲۰۱۸، HTTP Archive میانگین حجم انتقال جاوا اسکریپت در دستگاه‌های تلفن همراه را تقریباً ۳۵۰ کیلوبایت اعلام کرده است. و این فقط حجم انتقال است! جاوا اسکریپت اغلب هنگام ارسال از طریق شبکه فشرده می‌شود، به این معنی که مقدار واقعی جاوا اسکریپت پس از خارج کردن آن توسط مرورگر، کمی بیشتر است. ذکر این نکته مهم است، زیرا تا آنجا که به پردازش منابع مربوط می‌شود، فشرده‌سازی اهمیتی ندارد. ۹۰۰ کیلوبایت جاوا اسکریپت خارج شده از حالت فشرده، هنوز برای تجزیه‌کننده و کامپایلر ۹۰۰ کیلوبایت است، حتی اگر هنگام فشرده‌سازی تقریباً ۳۰۰ کیلوبایت باشد.

نموداری که فرآیند دانلود، خارج کردن از حالت فشرده، تجزیه، کامپایل و اجرای جاوا اسکریپت را نشان می‌دهد.
فرآیند دانلود و اجرای جاوا اسکریپت. توجه داشته باشید که اگرچه حجم انتقال اسکریپت فشرده شده ۳۰۰ کیلوبایت است، اما هنوز ۹۰۰ کیلوبایت جاوا اسکریپت وجود دارد که باید تجزیه، کامپایل و اجرا شود.

جاوا اسکریپت یک منبع گران‌قیمت برای پردازش است. برخلاف تصاویری که پس از دانلود، زمان رمزگشایی نسبتاً کمی را متحمل می‌شوند، جاوا اسکریپت باید تجزیه، کامپایل و در نهایت اجرا شود. بایت به بایت، این باعث می‌شود جاوا اسکریپت گران‌تر از سایر انواع منابع باشد.

نموداری که زمان پردازش ۱۷۰ کیلوبایت جاوا اسکریپت را در مقابل یک تصویر JPEG با اندازه معادل مقایسه می‌کند. منبع جاوا اسکریپت بایت به بایت بسیار فشرده‌تر از JPEG است.
هزینه پردازش تجزیه/کامپایل ۱۷۰ کیلوبایت جاوا اسکریپت در مقابل زمان رمزگشایی یک فایل JPEG با اندازه معادل. ( منبع )

در حالی که به طور مداوم پیشرفت‌هایی برای بهبود کارایی موتورهای جاوا اسکریپت انجام می‌شود ، بهبود عملکرد جاوا اسکریپت - مثل همیشه - وظیفه توسعه‌دهندگان است.

برای این منظور، تکنیک‌هایی برای بهبود عملکرد جاوا اسکریپت وجود دارد. تقسیم کد ، یکی از این تکنیک‌هاست که با تقسیم جاوا اسکریپت برنامه به تکه‌هایی و ارائه آن تکه‌ها فقط به مسیرهای برنامه‌ای که به آنها نیاز دارند، عملکرد را بهبود می‌بخشد.

اگرچه این تکنیک کار می‌کند، اما مشکل رایج برنامه‌های سنگین جاوا اسکریپت، یعنی گنجاندن کدی که هرگز استفاده نمی‌شود، را برطرف نمی‌کند. Tree Shaking تلاش می‌کند این مشکل را حل کند.

تکان دادن درخت چیست؟

درخت‌تکانی نوعی حذف کد مرده است. این اصطلاح توسط Rollup رواج یافت ، اما مفهوم حذف کد مرده مدتی است که وجود دارد. این مفهوم همچنین در webpack نیز مورد استفاده قرار گرفته است که در این مقاله با استفاده از یک برنامه نمونه نشان داده شده است.

اصطلاح «تغییر درخت» (tree shake) از مدل ذهنی برنامه شما و وابستگی‌های آن به عنوان یک ساختار درخت‌مانند گرفته شده است. هر گره در درخت، نشان‌دهنده یک وابستگی است که عملکرد متمایزی را برای برنامه شما فراهم می‌کند. در برنامه‌های مدرن، این وابستگی‌ها از طریق دستورات import استاتیک مانند زیر وارد می‌شوند:

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

وقتی یک برنامه جوان است - اگر بخواهیم بگوییم، یک نهال - ممکن است وابستگی‌های کمی داشته باشد. همچنین از بیشتر - اگر نگوییم همه - وابستگی‌هایی که اضافه می‌کنید استفاده می‌کند. با این حال، با بالغ شدن برنامه، وابستگی‌های بیشتری می‌توانند اضافه شوند. در نهایت، وابستگی‌های قدیمی‌تر از رده خارج می‌شوند، اما ممکن است از کدبیس شما حذف نشوند. نتیجه نهایی این است که یک برنامه در نهایت با مقدار زیادی جاوا اسکریپت بلااستفاده ارائه می‌شود. Tree Shaking با بهره‌گیری از نحوه‌ی وارد کردن بخش‌های خاصی از ماژول‌های ES6 توسط دستورات import استاتیک، این مشکل را برطرف می‌کند:

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

تفاوت بین این مثال import و مثال قبلی این است که به جای ایمپورت کردن همه چیز از ماژول "array-utils" - که می‌تواند کد زیادی باشد - این مثال فقط بخش‌های خاصی از آن را ایمپورت می‌کند. در نسخه‌های توسعه‌دهندگان، این موضوع چیزی را تغییر نمی‌دهد، زیرا کل ماژول صرف نظر از آن ایمپورت می‌شود. در نسخه‌های پروداکشن، وب‌پک را می‌توان طوری پیکربندی کرد که اکسپورت‌های ماژول‌های ES6 که صریحاً ایمپورت نشده‌اند را "حذف" کند و این باعث می‌شود که آن نسخه‌های پروداکشن کوچک‌تر شوند. در این راهنما، یاد خواهید گرفت که چگونه دقیقاً همین کار را انجام دهید!

پیدا کردن فرصت‌هایی برای تکان دادن درخت

برای اهداف توضیحی، یک نمونه برنامه تک صفحه‌ای موجود است که نحوه کار درخت تکانی را نشان می‌دهد. در صورت تمایل می‌توانید آن را کپی کرده و دنبال کنید، اما ما در این راهنما هر مرحله از راه را با هم پوشش خواهیم داد، بنابراین کپی کردن ضروری نیست (مگر اینکه یادگیری عملی مورد نظر شما باشد).

این برنامه نمونه، یک پایگاه داده قابل جستجو از پدال‌های افکت گیتار است. شما یک عبارت جستجو وارد می‌کنید و لیستی از پدال‌های افکت ظاهر می‌شود.

تصویری از یک نمونه برنامه تک صفحه‌ای برای جستجوی پایگاه داده پدال‌های افکت گیتار.
تصویری از نمونه برنامه.

رفتاری که این برنامه را هدایت می‌کند به دو بخش فروشنده (یعنی Preact و Emotion ) و بسته‌های کد مخصوص برنامه (یا "chunks"، همانطور که webpack آنها را می‌نامد) تقسیم شده است:

تصویری از دو بسته (یا تکه) کد برنامه که در پنل شبکه DevTools کروم نشان داده شده است.
این برنامه شامل دو بسته جاوا اسکریپت است. این دو بسته، اندازه‌های فشرده نشده دارند.

بسته‌های جاوا اسکریپت نشان داده شده در شکل بالا، نسخه‌های نهایی هستند، به این معنی که از طریق uglification بهینه‌سازی شده‌اند. ۲۱.۱ کیلوبایت برای یک بسته مخصوص برنامه بد نیست، اما باید توجه داشت که هیچ گونه تغییر درختی رخ نمی‌دهد. بیایید به کد برنامه نگاهی بیندازیم و ببینیم برای رفع این مشکل چه کاری می‌توان انجام داد.

در هر برنامه‌ای، یافتن فرصت‌های درخت‌تکانی شامل جستجوی دستورات import استاتیک می‌شود. نزدیک بالای فایل کامپوننت اصلی ، خطی مانند این را خواهید دید:

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

شما می‌توانید ماژول‌های ES6 را به روش‌های مختلفی وارد کنید ، اما مواردی مانند این باید توجه شما را جلب کند. این خط خاص می‌گوید: « همه چیز را از ماژول utils import و آن را در یک فضای نام به نام utils قرار دهید.» سوال بزرگی که اینجا مطرح می‌شود این است که «دقیقاً چه تعداد محتوا در آن ماژول وجود دارد؟»

اگر به کد منبع ماژول utils نگاهی بیندازید، متوجه خواهید شد که حدود ۱۳۰۰ خط کد وجود دارد.

آیا به همه این موارد نیاز دارید؟ بیایید با جستجوی فایل کامپوننت اصلی که ماژول utils را وارد می‌کند، دوباره بررسی کنیم تا ببینیم چند نمونه از آن فضای نام نمایش داده می‌شود.

تصویری از جستجوی «utils» در یک ویرایشگر متن که فقط ۳ نتیجه را نشان می‌دهد.
فضای نام 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);
}

از یک فایل ۱۳۰۰ خطی با کلی export، فقط یکی از آنها استفاده می‌شود. این منجر به ارسال مقدار زیادی جاوا اسکریپت بلااستفاده می‌شود.

اگرچه این برنامه‌ی نمونه مسلماً کمی ساختگی است، اما این واقعیت را تغییر نمی‌دهد که این سناریوی مصنوعی شبیه فرصت‌های بهینه‌سازی واقعی است که ممکن است در یک برنامه‌ی وب در حال تولید با آنها روبرو شوید. حالا که فرصتی برای مفید بودن tree shake شناسایی کرده‌اید، چگونه این کار انجام می‌شود؟

جلوگیری از Transpile کردن ماژول‌های ES6 به ماژول‌های CommonJS توسط Babel

بابل ابزاری ضروری است، اما ممکن است مشاهده اثرات درخت‌لرزه را کمی دشوارتر کند. اگر از @babel/preset-env استفاده می‌کنید، بابل ممکن است ماژول‌های ES6 را به ماژول‌های CommonJS سازگارتر تبدیل کند - یعنی ماژول‌هایی که به جای import به آنها require .

از آنجا که درخت‌تکانی برای ماژول‌های 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 که خارج از محدوده آن است، یک اثر جانبی ایجاد می‌کند.

عوارض جانبی در مورد ماژول‌های 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 خود اضافه کنید، می‌توانید این پرچم را در پیکربندی 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);
}

این تمام چیزی است که برای کار کردن درخت‌لرزه در این مثال لازم است. این خروجی وب‌پک قبل از تکان دادن درخت وابستگی است:

                 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 حدود ۶۰٪ کوچک می‌شود. این نه تنها مدت زمان دانلود اسکریپت را کاهش می‌دهد، بلکه زمان پردازش را نیز کاهش می‌دهد.

برو چند تا درخت رو تکون بده!

هر میزان بهره‌ای که از درخت‌تکانی (tree shake) ببرید، به برنامه شما، وابستگی‌ها و معماری آن بستگی دارد. امتحانش کنید! اگر مطمئن هستید که module bundler خود را برای انجام این بهینه‌سازی تنظیم نکرده‌اید، ضرری ندارد که امتحان کنید و ببینید که چگونه برای برنامه شما مفید است.

ممکن است با tree shake افزایش عملکرد قابل توجهی را تجربه کنید، یا اصلاً متوجه آن نشوید. اما با پیکربندی سیستم ساخت خود برای بهره‌گیری از این بهینه‌سازی در نسخه‌های عملیاتی و وارد کردن گزینشی تنها آنچه برنامه شما نیاز دارد، می‌توانید به طور فعال بسته‌های برنامه خود را تا حد امکان کوچک نگه دارید.

تشکر ویژه از کریستوفر بکستر، جیسون میلر ، ادی عثمانی ، جف پوسنیک ، سم ساکون و فیلیپ والتون برای بازخورد ارزشمندشان که به طور قابل توجهی کیفیت این مقاله را بهبود بخشید.