ลดเพย์โหลด JavaScript ด้วยการเขย่าต้นไม้

เว็บแอปพลิเคชันในปัจจุบันนี้ค่อนข้างใหญ่มาก โดยเฉพาะส่วนที่เป็น JavaScript ตั้งแต่กลางปี 2018 ที่เก็บถาวรของ HTTP จะวางขนาดการโอนค่ามัธยฐานของ JavaScript บนอุปกรณ์เคลื่อนที่ที่ประมาณ 350 KB ขนาดนี้โอนเท่านั้น JavaScript มักจะได้รับการบีบอัดเมื่อส่งผ่านเครือข่าย ซึ่งหมายความว่าปริมาณตามจริงของ JavaScript จะค่อนข้างมากขึ้นหลังจากที่เบราว์เซอร์คลายการบีบอัด สิ่งสำคัญที่ต้องเน้นย้ำก็คือ การบีบอัดไม่มีความเกี่ยวข้องเท่าที่ควรจะเป็นเพราะการประมวลผลทรัพยากร 900 KB ของ JavaScript ที่บีบอัดยังคงเป็น 900 KB สำหรับโปรแกรมแยกวิเคราะห์และคอมไพเลอร์ แม้ว่าอาจมีขนาดประมาณ 300 KB เมื่อบีบอัด

วันที่ แผนภาพแสดงขั้นตอนการดาวน์โหลด การขยาย การแยกวิเคราะห์ การคอมไพล์ และการเรียกใช้ JavaScript
กระบวนการดาวน์โหลดและเรียกใช้ JavaScript โปรดทราบว่าแม้ขนาดการโอนของสคริปต์จะเป็นบีบอัด 300 KB แต่ JavaScript ยังคงมีขนาด 900 KB ซึ่งต้องมีการแยกวิเคราะห์ คอมไพล์ และดำเนินการ

JavaScript เป็นทรัพยากรที่มีราคาแพงในการประมวลผล JavaScript จะต้องได้รับการแยกวิเคราะห์ คอมไพล์ แล้วดำเนินการในที่สุด ซึ่งแตกต่างจากรูปภาพที่ใช้เวลาถอดรหัสเพียงเล็กน้อยเมื่อดาวน์โหลด ไบต์ต่อไบต์ซึ่งทำให้ JavaScript มีราคาแพงกว่าทรัพยากรประเภทอื่นๆ

วันที่ แผนภาพเปรียบเทียบเวลาประมวลผลของ JavaScript 170 KB กับรูปภาพ JPEG ที่มีขนาดเท่ากัน ทรัพยากร JavaScript จำนวนไบต์ต่อไบต์ที่ใช้ทรัพยากรมากกว่า JPEG มาก
ค่าใช้จ่ายในการแยกวิเคราะห์/คอมไพล์ JavaScript ขนาด 170 KB เทียบกับเวลาในการถอดรหัสของ JPEG ที่มีขนาดเท่ากัน (แหล่งที่มา)

ในขณะที่มีการปรับปรุงอย่างต่อเนื่องเพื่อปรับปรุงประสิทธิภาพของเครื่องมือ JavaScript แต่การปรับปรุงประสิทธิภาพของ JavaScript ก็ยังคงเป็นหน้าที่สำหรับนักพัฒนาซอฟต์แวร์เสมอ

ด้วยเหตุนี้เราจึงมีเทคนิคในการปรับปรุงประสิทธิภาพ JavaScript การแยกโค้ดเป็นเทคนิคหนึ่งที่ช่วยปรับปรุงประสิทธิภาพด้วยการแบ่งพาร์ติชัน JavaScript ของแอปพลิเคชันออกเป็นส่วนๆ และแสดงชิ้นส่วนเหล่านั้นไปยังเส้นทางของแอปพลิเคชันที่จำเป็นต้องใช้เท่านั้น

แม้ว่าเทคนิคนี้จะใช้ได้ผล แต่ก็ไม่สามารถแก้ปัญหาโดยทั่วไปของแอปพลิเคชันที่ใช้ JavaScript จำนวนมากได้ ซึ่งเป็นการรวมโค้ดที่ไม่เคยใช้งานมาก่อน การสั่นสะเทือนของต้นไม้พยายามแก้ไขปัญหานี้

ต้นไม้สั่นสะเทือนคืออะไร

การสั่นสะเทือนของต้นไม้เป็นรูปแบบหนึ่งของการกำจัดโค้ดที่ตายแล้ว คำนี้เป็นที่นิยมใน Rollup แต่แนวคิดการกำจัดโค้ดที่ใช้งานไม่ได้นั้นมีอยู่มาสักระยะหนึ่งแล้ว แนวคิดยังพบการซื้อใน Webpack ด้วย ซึ่งดูได้จากตัวอย่างแอปในบทความนี้

คำว่า "การสั่นสะเทือนของต้นไม้" มาจากโมเดลทางความคิดของแอปพลิเคชันของคุณและการขึ้นต่อกันของแอปพลิเคชันนี้เป็นโครงสร้างที่เหมือนต้นไม้ แต่ละโหนดในโครงสร้างแสดงถึงทรัพยากร Dependency ที่ให้ฟังก์ชันการทำงานที่แตกต่างกันสําหรับแอป ในแอปสมัยใหม่ ทรัพยากร Dependency ต่อไปนี้จะนำมาผ่านคำสั่ง import แบบคงที่ ดังนี้

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

เมื่อแอปยังอยู่ในช่วงวัยรุ่น แอปก็อาจต้องใช้ทรัพยากร Dependency เพียงเล็กน้อย (หากทำได้) นอกจากนี้ยังใช้ทรัพยากร Dependency ส่วนใหญ่ (หรือไม่ใช่ทั้งหมด) ที่คุณเพิ่มด้วย แต่จะเพิ่มทรัพยากร Dependency ได้มากขึ้นเมื่อแอปเติบโต ในการประกอบกรณี ทรัพยากร Dependency เก่าจะใช้งานไม่ได้ แต่อาจไม่ถูกตัดออกจากฐานของโค้ด สุดท้ายแล้ว แอปจะมี JavaScript ที่ไม่ได้ใช้เป็นจำนวนมาก การสั่นสะเทือนจากต้นไม้ช่วยแก้ปัญหานี้โดยใช้ประโยชน์จากการดึงคำสั่ง import แบบคงที่ในส่วนที่เฉพาะเจาะจงของโมดูล ES6 ดังนี้

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

ความแตกต่างระหว่างตัวอย่าง import นี้กับตัวอย่างก่อนหน้าคือ แทนที่จะนำเข้าทุกอย่างจากโมดูล "array-utils" ซึ่งอาจเป็นโค้ดจำนวนมาก) ตัวอย่างนี้จะนำเข้าเฉพาะบางส่วนของตัวอย่างเท่านั้น ในบิลด์ของนักพัฒนาซอฟต์แวร์ ส่วนนี้จะไม่เปลี่ยนแปลงอะไร เนื่องจากโมดูลทั้งหมดจะมีการนำเข้าไม่ว่าในกรณีใดก็ตาม ในบิลด์ที่ใช้งานจริง คุณสามารถกำหนดค่า Webpack ให้ "เขย่า" ได้ ปิด export จากโมดูล ES6 ที่ไม่ได้นำเข้าอย่างชัดเจน ส่งผลให้เวอร์ชันที่ใช้งานจริงเหล่านั้นมีขนาดเล็กลง ในคู่มือนี้ คุณจะได้ทราบวิธีทำแบบนั้น

หาโอกาสเขย่าต้นไม้

เพื่อวัตถุประสงค์ในการอธิบายให้เห็นภาพ เรามีตัวอย่างแอปขนาด 1 หน้าที่สาธิตวิธีการทำงานของการสั่นสะเทือนของต้นไม้ คุณสามารถโคลนและทำตามได้ถ้าต้องการ แต่เราจะอธิบายทุกขั้นตอนไปด้วยกันในคู่มือนี้ คุณจึงไม่จำเป็นต้องทำการโคลน (เว้นแต่คุณจะต้องเรียนรู้ด้วยตนเอง)

แอปตัวอย่างนี้เป็นฐานข้อมูลที่ค้นหาได้ของแป้นเหยียบเอฟเฟกต์กีตาร์ คุณป้อนคำค้นหาและรายการแป้นเอฟเฟกต์จะปรากฏขึ้น

วันที่ ภาพหน้าจอของตัวอย่างแอปพลิเคชันหน้าเว็บ 1 หน้าสำหรับค้นหาฐานข้อมูลแป้นเหยียบเอฟเฟกต์กีตาร์
ภาพหน้าจอของตัวอย่างแอป

ลักษณะการทำงานที่ขับเคลื่อนแอปนี้จะแยกออกจากผู้ให้บริการ (เช่น Preact และ Emotion) และ Code Bundle สําหรับแอปเฉพาะ (หรือ "กลุ่ม" ตามที่ Webpack เรียกใช้)

วันที่ ภาพหน้าจอของกลุ่มโค้ดของแอปพลิเคชัน 2 ชุด (หรือกลุ่ม) ที่แสดงในแผงเครือข่ายของเครื่องมือสำหรับนักพัฒนาเว็บของ Chrome
มี JavaScript 2 Bundle ของแอป ขนาดเหล่านี้คือขนาดที่ไม่มีการบีบอัด

แพ็กเกจ JavaScript ที่แสดงในรูปด้านบนเป็นบิลด์ในเวอร์ชันที่ใช้งานจริง ซึ่งหมายความว่าจะได้รับการเพิ่มประสิทธิภาพผ่านการรวมภายนอก 21.1 KB สำหรับ Bundle เฉพาะแอปไม่ถือว่าแย่ แต่ควรทราบว่าไม่มีการสั่นสะเทือนของต้นไม้ไม่ว่าในกรณีใดๆ เรามาดูโค้ดของแอปกันเพื่อดูว่าจะทำอะไรได้บ้างเพื่อแก้ปัญหานี้

การใช้งานใดๆ ก็ตาม การหาโอกาสเกิดการสั่นสะเทือนของต้นไม้จะต้องมองหาข้อความ import แบบคงที่ บริเวณด้านบนของไฟล์คอมโพเนนต์หลัก คุณจะเห็นบรรทัดดังนี้

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

คุณสามารถนำเข้าโมดูล ES6 ได้หลายวิธี แต่โมดูลแบบนี้ควรได้รับความสนใจจากคุณ บรรทัดนี้ระบุว่า "import ทุกอย่างจากโมดูล utils และใส่ไว้ในเนมสเปซที่ชื่อ utils" คำถามสำคัญที่ควรถามตอนนี้คือ "มีเนื้อหามากน้อยแค่ไหนในโมดูลนี้"

หากดูที่ซอร์สโค้ดของโมดูล utils คุณจะเห็นว่ามีโค้ดประมาณ 1,300 บรรทัด

คุณต้องการสิ่งเหล่านั้นไหม มาตรวจสอบอีกครั้งด้วยการค้นหาไฟล์คอมโพเนนต์หลักที่นำเข้าโมดูล utils เพื่อดูจำนวนอินสแตนซ์ของเนมสเปซนั้น

วันที่ ภาพหน้าจอของการค้นหาในเครื่องมือแก้ไขข้อความสำหรับ "utils" ซึ่งแสดงผลลัพธ์เพียง 3 รายการ
เนมสเปซ utils ที่เราได้นำเข้าโมดูลจำนวนมหาศาลมีการเรียกใช้เพียง 3 ครั้งภายในไฟล์คอมโพเนนต์หลัก

ผลที่ได้คือเนมสเปซ utils จะปรากฏในแอปพลิเคชันเพียง 3 จุดเท่านั้น แต่มีไว้เพื่อฟังก์ชันใด หากคุณดูไฟล์คอมโพเนนต์หลักอีกครั้ง จะเห็นเพียงฟังก์ชันเดียวซึ่งก็คือ 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 ไฟล์ที่มีการส่งออกจำนวนมาก จะใช้เพียง 1 รายการเท่านั้น ซึ่งส่งผลให้มีการจัดส่ง 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 วิเคราะห์โครงสร้างทรัพยากร Dependency และสลัดทรัพยากร Dependency ที่ไม่ได้ใช้ได้

คำนึงถึงผลข้างเคียง

อีกแง่มุมหนึ่งที่ควรพิจารณาเมื่อเขย่า Dependency จากแอปคือ โมดูลของโครงการจะมีผลข้างเคียง ตัวอย่างของผลข้างเคียงคือ จะแก้ไขบางอย่างนอกขอบเขตของตัวเองซึ่งเป็นผลข้างเคียง ของการดำเนินการ

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 และมีความสำคัญในบริบทของการสั่นสะเทือนของต้นไม้ โมดูลที่ใช้อินพุตที่คาดการณ์ได้และสร้างเอาต์พุตที่คาดการณ์ได้อย่างเท่าเทียมกันโดยไม่แก้ไขสิ่งอื่นนอกขอบเขตของตนเองนั้น เป็นทรัพยากร Dependency ที่สามารถนำอินพุตดังกล่าวออกได้อย่างปลอดภัยหากเราไม่ได้ใช้ โค้ดดังกล่าวเป็นของโค้ดที่ประกอบขึ้นแล้วแบบแยกส่วน ดังนั้น "โมดูล"

ในกรณีที่มีข้อกังวลเกี่ยวกับ Webpack คุณสามารถใช้คำแนะนำเพื่อระบุว่าแพ็กเกจและทรัพยากร Dependency จะไม่ได้รับผลกระทบข้างเคียง โดยระบุ "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 คุณสามารถระบุ Flag นี้ในการกำหนดค่า 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 ก่อนที่จะเขย่าแผนผัง Dependency

                 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

แม้ว่าแพ็กเกจทั้ง 2 แพ็กเกจจะหดตัว แต่แท้จริงแล้วแพ็กเกจ main ที่ได้ประโยชน์มากที่สุด การเขย่าส่วนที่ไม่ได้ใช้ของโมดูล utils ทำให้แพ็กเกจ main เล็กลงประมาณ 60% ซึ่งไม่เพียงจะลดระยะเวลาที่ใช้ในการดาวน์โหลดสคริปต์ แต่ยังลดเวลาในการประมวลผลอีกด้วย

ไปเขย่าต้นไม้กัน!

ระยะทางใดก็ตามที่คุณได้รับจากการสั่นสะเทือนของต้นไม้จะขึ้นอยู่กับแอปของคุณ รวมถึงทรัพยากร Dependency และสถาปัตยกรรมของแอป ลองใช้งาน หากคุณทราบข้อเท็จจริงว่าคุณยังไม่ได้ตั้งค่า Bundler โมดูลให้เพิ่มประสิทธิภาพนี้ ก็ไม่เสียหายที่จะลองใช้และพิจารณาว่าจะได้รับประโยชน์อย่างไรจากแอปพลิเคชันของคุณ

คุณอาจตระหนักถึงประสิทธิภาพที่เพิ่มขึ้นอย่างมากจากการสั่นสะเทือนของต้นไม้ หรือไม่ได้เพิ่มขึ้นมากนัก แต่การกำหนดค่าระบบบิลด์เพื่อใช้ประโยชน์จากการเพิ่มประสิทธิภาพนี้ในเวอร์ชันที่ใช้งานจริงและเลือกนำเข้าเฉพาะสิ่งที่แอปพลิเคชันต้องการเท่านั้น คุณจะทำให้แพ็กเกจแอปพลิเคชันมีขนาดเล็กที่สุดในเชิงรุก

ขอขอบคุณ Kristofer Baxter, Jason Miller, Addy Osmani, Jeff Posnick, Sam Saccone และ Philip Walton สำหรับความคิดเห็นที่มีค่า ซึ่งช่วยปรับปรุงคุณภาพของบทความนี้ได้อย่างมาก