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