ลดเพย์โหลด 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 คืออะไร

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 เรียก):

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

ชุด 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 ปรากฏในแอปพลิเคชันของเราเพียง 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 เป็นอย่างยิ่งสำหรับความคิดเห็นที่มีคุณค่า ซึ่งช่วยปรับปรุงคุณภาพของบทความนี้ได้อย่างมาก