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

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

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

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

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

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

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

ลักษณะการทำงานที่ขับเคลื่อนแอปนี้จะแยกออกจากผู้ให้บริการ (เช่น 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

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

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

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

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

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

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