ลดเพย์โหลด JavaScript ด้วยการเขย่าต้นไม้

เว็บแอปพลิเคชันทุกวันนี้อาจมีจำนวนมหาศาล โดยเฉพาะส่วนที่เป็น JavaScript ตั้งแต่ช่วงกลางปี 2018 ที่เก็บถาวร HTTP จะทำให้ขนาดการโอนของ 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
ค่าใช้จ่ายในการประมวลผลของการแยกวิเคราะห์/คอมไพล์ 170 KB ของ JavaScript เทียบกับเวลาในการถอดรหัสของ JPEG ที่มีขนาดเท่ากัน (แหล่งที่มา)

ในขณะที่มีการปรับปรุงอย่างต่อเนื่องเพื่อปรับปรุงประสิทธิภาพของเครื่องมือ JavaScript การปรับปรุงประสิทธิภาพของ JavaScript จึงเป็นงานสำหรับนักพัฒนาซอฟต์แวร์เสมอ

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

แม้ว่าเทคนิคนี้จะได้ผล แต่ก็ไม่ได้กล่าวถึงปัญหาที่พบได้ทั่วไปเกี่ยวกับแอปพลิเคชันที่ใช้ JavaScript มาก ซึ่งก็คือการรวมโค้ดที่ไม่เคยใช้งานมาก่อน การสั่นสะเทือนของต้นไม้พยายามแก้ไขปัญหานี้

การสั่นสะเทือนของต้นไม้คืออะไร

การสั่นสะเทือนของต้นไม้เป็นรูปแบบหนึ่งของการกำจัดโค้ดเสีย คำนี้เป็นที่นิยมโดย Rollup แต่มีแนวคิดในการกำจัดโค้ดที่ใช้งานไม่ได้มาระยะหนึ่งแล้ว แนวคิดนี้ยังพบการซื้อใน Webpack ซึ่งแสดงให้เห็นในบทความนี้ผ่านตัวอย่างแอป

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

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

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

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

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

ระบบจะแยกลักษณะการทำงานที่ทำให้เกิดแอปนี้ออกเป็นผู้ให้บริการ (เช่น Preact และ Emotion) รวมถึงชุดโค้ดเฉพาะแอป (หรือ "ส่วน" ตามที่ Webpack เรียกว่า]

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

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

การป้องกัน Babel จากการเปลี่ยนรูปแบบโมดูล ES6 ไปยังโมดูล CommonJS

Babel เป็นเครื่องมือที่ขาดไม่ได้ แต่ก็อาจทำให้เห็นผลจากการสั่นสะเทือนของต้นไม้ได้ยากขึ้นเล็กน้อย หากคุณใช้ @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 ที่ไม่ได้ใช้ได้

อย่าลืมคำนึงถึงผลข้างเคียง

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

ในตัวอย่างนี้ควรเป็นสิ่งที่จำเป็นสำหรับการเขย่าต้นไม้ นี่คือเอาต์พุต 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

แม้ว่าแพ็กเกจทั้งสองจะหดตัวลง แต่ก็เป็นแพ็กเกจ main ที่มีประโยชน์มากที่สุดจริงๆ การแยกส่วนที่ไม่ได้ใช้ของโมดูล utils ออกจะทำให้แพ็กเกจ main ลดลงประมาณ 60% วิธีนี้ไม่เพียงลดระยะเวลาที่สคริปต์ใช้ในการดาวน์โหลด แต่ยังลดเวลาในการประมวลผลด้วย

ไปส่ายต้นไม้กันเถอะ

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

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

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