เว็บแอปพลิเคชันในปัจจุบันมีขนาดค่อนข้างใหญ่ โดยเฉพาะส่วน 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 คืออะไร
Tree Shaking เป็นรูปแบบหนึ่งของการกำจัดโค้ดที่ไม่ได้ใช้ Rollup เป็นผู้ทำให้คำนี้เป็นที่นิยม แต่แนวคิดการกำจัดโค้ดที่ไม่ได้ใช้มีมาสักระยะหนึ่งแล้ว แนวคิดนี้ยังใช้ได้กับ webpack ด้วย ซึ่งจะแสดงให้เห็นในบทความนี้ผ่านแอปตัวอย่าง
คำว่า "Tree Shaking" มาจากโมเดลทางความคิดของแอปพลิเคชันและการอ้างอิงในลักษณะโครงสร้างคล้ายต้นไม้ แต่ละโหนดในแผนผังแสดงถึงการอ้างอิงที่ให้ฟังก์ชันการทำงานที่แตกต่างกันสำหรับแอปของคุณ ในแอปสมัยใหม่ การอ้างอิงเหล่านี้จะนำเข้ามาผ่านคำสั่ง static import ดังนี้
// Import all the array utilities!
import arrayUtils from "array-utils";
เมื่อแอปยังใหม่ (เปรียบเสมือนต้นกล้า) อาจมีทรัพยากร Dependency น้อย นอกจากนี้ ยังใช้ทรัพยากร Dependency ส่วนใหญ่ที่คุณเพิ่มด้วย อย่างไรก็ตาม เมื่อแอปเติบโตขึ้น อาจมีการเพิ่มการอ้างอิงมากขึ้น นอกจากนี้ ทรัพยากร Dependency ที่เก่ากว่าจะเลิกใช้ แต่ระบบอาจไม่ได้นำออกจากฐานของโค้ด ผลลัพธ์ที่ได้คือแอปจะมาพร้อมกับ JavaScript ที่ไม่ได้ใช้จำนวนมาก Tree Shaking แก้ปัญหานี้ด้วยการใช้ประโยชน์จากวิธีที่คำสั่ง import แบบคงที่ดึงส่วนที่เฉพาะเจาะจงของโมดูล ES6 ดังนี้
// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";
ความแตกต่างระหว่างตัวอย่าง import นี้กับตัวอย่างก่อนหน้าคือตัวอย่างนี้จะนำเข้าเฉพาะบางส่วนจากโมดูล "array-utils" แทนที่จะนำเข้าทุกอย่าง (ซึ่งอาจมีโค้ดจำนวนมาก) ในบิลด์สำหรับนักพัฒนาซอฟต์แวร์ การดำเนินการนี้จะไม่เปลี่ยนแปลงสิ่งใด เนื่องจากระบบจะนำเข้าทั้งโมดูลอยู่แล้ว ในบิลด์ที่ใช้งานจริง คุณสามารถกำหนดค่า webpack ให้ "เขย่า" การส่งออกออกจากโมดูล ES6 ที่ไม่ได้นำเข้าอย่างชัดเจนได้ ซึ่งจะทำให้บิลด์ที่ใช้งานจริงมีขนาดเล็กลง ในคู่มือนี้ คุณจะได้เรียนรู้วิธีการทำเช่นนั้น
การหาโอกาสเขย่าต้นไม้
แอปหน้าเดียวตัวอย่างพร้อมให้ใช้งานแล้วเพื่อแสดงให้เห็นวิธีการทำงานของ Tree Shaking คุณสามารถโคลนและทำตามได้หากต้องการ แต่เราจะอธิบายทุกขั้นตอนในคู่มือนี้ร่วมกัน ดังนั้นคุณจึงไม่จำเป็นต้องโคลน (เว้นแต่คุณจะชอบการเรียนรู้ภาคปฏิบัติ)
แอปตัวอย่างคือฐานข้อมูลที่ค้นหาได้ของเอฟเฟ็กต์กีตาร์ คุณป้อนคำค้นหาและรายการเอฟเฟกต์เหยียบจะปรากฏขึ้น
ลักษณะการทำงานที่ขับเคลื่อนแอปนี้จะแยกออกเป็นผู้ให้บริการ (เช่น Preact และ Emotion) และชุดโค้ดเฉพาะแอป (หรือ "Chunk" ตามที่ webpack เรียก):
ชุด 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.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 เป็นเครื่องมือที่ขาดไม่ได้ แต่ก็อาจทำให้สังเกตผลของ Tree Shaking ได้ยากขึ้นเล็กน้อย หากคุณใช้ @babel/preset-env, Babel อาจแปลงโมดูล ES6 เป็นโมดูล CommonJS ที่เข้ากันได้ในวงกว้างมากขึ้น นั่นคือโมดูลที่คุณ require แทนที่จะเป็น import
เนื่องจากโมดูล CommonJS ทำได้ยากกว่า Webpack จึงไม่ทราบว่าจะตัดอะไรออกจาก Bundle หากคุณตัดสินใจใช้โมดูลดังกล่าว วิธีแก้คือการกำหนดค่า @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 ด้วย และมีความสำคัญในบริบทของการกำจัดโค้ดที่ไม่จำเป็น โมดูลที่รับอินพุตที่คาดการณ์ได้และสร้างเอาต์พุตที่คาดการณ์ได้เช่นกันโดยไม่แก้ไขสิ่งใดนอกขอบเขตของตัวเองคือการขึ้นต่อกันที่สามารถทิ้งได้อย่างปลอดภัยหากเราไม่ได้ใช้ ซึ่งเป็นโค้ดแบบแยกส่วนที่ทำงานได้ด้วยตัวเอง จึงเรียกว่า "โมดูล"
ในส่วนของ 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 จะขึ้นอยู่กับแอป การอ้างอิง และสถาปัตยกรรมของแอป ลองใช้งาน หากคุณทราบแน่ชัดว่าไม่ได้ตั้งค่าเครื่องมือรวมโมดูลให้ทำการเพิ่มประสิทธิภาพนี้ ก็ลองดูได้ว่าการเพิ่มประสิทธิภาพนี้เป็นประโยชน์ต่อแอปพลิเคชันของคุณอย่างไร
คุณอาจได้รับประสิทธิภาพเพิ่มขึ้นอย่างมากจากการกำจัดโค้ดที่ไม่ใช้ หรืออาจไม่ได้รับเลย แต่การกำหนดค่าระบบบิลด์ให้ใช้ประโยชน์จากการเพิ่มประสิทธิภาพนี้ในบิลด์เวอร์ชันที่ใช้งานจริง และการนำเข้าเฉพาะสิ่งที่แอปพลิเคชันต้องการอย่างเลือกสรรจะช่วยให้คุณรักษาขนาดของ App Bundle ให้เล็กที่สุดเท่าที่จะเป็นไปได้
ขอขอบคุณ Kristofer Baxter, Jason Miller, Addy Osmani, Jeff Posnick, Sam Saccone และ Philip Walton เป็นอย่างยิ่งสำหรับความคิดเห็นที่มีคุณค่า ซึ่งช่วยปรับปรุงคุณภาพของบทความนี้ได้อย่างมาก