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