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

เว็บแอปพลิเคชันในปัจจุบันมีขนาดใหญ่ได้ โดยเฉพาะส่วนที่เป็น JavaScript เมื่อช่วงกลางปี 2018 ที่ผ่านมา HTTP Archive ระบุว่าขนาดการโอนค่ามัธยฐานของ JavaScript ในอุปกรณ์เคลื่อนที่อยู่ที่ประมาณ 350 KB และนี่เป็นเพียงขนาดการโอนเท่านั้น JavaScript มักจะได้รับการบีบอัดเมื่อส่งผ่านเครือข่าย ซึ่งหมายความว่าจํานวน JavaScript จริงจะมากกว่ามากหลังจากที่เบราว์เซอร์ทำการขยายไฟล์ เราขอชี้แจงเรื่องนี้เนื่องจากเมื่อพูดถึงการประมวลผลทรัพยากร การบีบอัดจะไม่เกี่ยวข้อง JavaScript 900 KB ที่ไม่มีการบีบอัดจะยังคงมีขนาด 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 มาก ซึ่งก็คือการรวมโค้ดที่ไม่ได้ใช้ การแยกไฟล์ออกจากไฟล์กลุ่มพยายามแก้ปัญหานี้

Tree shaking คืออะไร

Tree shaking คือการกำจัดโค้ดที่ตายแล้ว Rollup เป็นผู้ทำให้คํานี้เป็นที่รู้จัก แต่แนวคิดในการนําโค้ดที่ตายแล้วออกนั้นมีมานานแล้ว แนวคิดนี้ยังพบการซื้อใน webpack ด้วย ซึ่งแสดงอยู่ในบทความนี้ผ่านแอปตัวอย่าง

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

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

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

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

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

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

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

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

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

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

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

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

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

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

คุณนําเข้าโมดูล ES6 ได้หลายวิธี แต่คุณควรให้ความสนใจกับวิธีต่อไปนี้ บรรทัดนี้ระบุว่า "import everything from the utils module, and put it in a namespace called 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 บรรทัดที่มีการส่งออกหลายรายการ มีเพียงรายการเดียวที่ใช้ ซึ่งส่งผลให้มีการส่ง JavaScript ที่ไม่ได้ใช้จำนวนมาก

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

ป้องกันไม่ให้ 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 ที่ไม่ได้ใช้

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

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

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

สำหรับ 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 คุณยังระบุ 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);
}

เท่านี้ก็เพียงพอแล้วสำหรับให้ Tree Shaking ทํางานในตัวอย่างนี้ นี่คือเอาต์พุต 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% ซึ่งไม่เพียงแต่จะช่วยลดเวลาในการดาวน์โหลดสคริปต์เท่านั้น แต่ยังช่วยลดเวลาในการประมวลผลด้วย

ไปหาต้นไม้สักต้น

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

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

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