Giảm tải trọng JavaScript bằng cách sử dụng kỹ thuật rung cây

Các ứng dụng web ngày nay có thể khá lớn, đặc biệt là phần JavaScript. Kể từ giữa năm 2018, Kho lưu trữ HTTP đặt kích thước truyền trung bình của JavaScript trên thiết bị di động ở mức khoảng 350 KB. Và đây mới chỉ là kích thước chuyển! JavaScript thường được nén khi gửi qua mạng, nghĩa là lượng JavaScript thực tế sẽ lớn hơn khá nhiều sau khi trình duyệt giải nén. Điều này rất quan trọng vì liên quan đến việc xử lý tài nguyên, việc nén là không liên quan. 900 KB JavaScript đã giải nén vẫn là 900 KB đối với trình phân tích cú pháp và trình biên dịch, mặc dù kích thước này có thể là khoảng 300 KB khi nén.

Sơ đồ minh hoạ quy trình tải xuống, giải nén, phân tích cú pháp, biên dịch và thực thi JavaScript.
Quá trình tải xuống và chạy JavaScript. Xin lưu ý rằng mặc dù kích thước truyền của tập lệnh là 300 KB đã nén, nhưng vẫn phải phân tích cú pháp, biên dịch và thực thi 900 KB JavaScript.

JavaScript là một tài nguyên tốn kém để xử lý. Không giống như hình ảnh chỉ mất thời gian giải mã tương đối nhỏ sau khi tải xuống, JavaScript phải được phân tích cú pháp, biên dịch rồi cuối cùng mới được thực thi. Byte cho byte, điều này làm cho JavaScript tốn kém hơn so với các loại tài nguyên khác.

Sơ đồ so sánh thời gian xử lý 170 KB JavaScript so với hình ảnh JPEG có kích thước tương đương. Tài nguyên JavaScript tiêu tốn nhiều tài nguyên hơn nhiều so với JPEG.
Chi phí xử lý để phân tích cú pháp/biên dịch 170 KB JavaScript so với thời gian giải mã của một tệp JPEG có kích thước tương đương. (nguồn).

Mặc dù chúng tôi liên tục cải tiến để nâng cao hiệu quả của các công cụ JavaScript, nhưng việc cải thiện hiệu suất JavaScript vẫn luôn là nhiệm vụ của các nhà phát triển.

Để đạt được mục tiêu đó, có một số kỹ thuật để cải thiện hiệu suất JavaScript. Phân tách mã là một trong những kỹ thuật giúp cải thiện hiệu suất bằng cách phân chia JavaScript của ứng dụng thành các đoạn và chỉ phân phát các đoạn đó cho các tuyến của ứng dụng cần đến.

Mặc dù kỹ thuật này hoạt động, nhưng không giải quyết được một vấn đề thường gặp của các ứng dụng nặng về JavaScript, đó là việc đưa vào mã không bao giờ được sử dụng. Tính năng loại bỏ mã thừa sẽ cố gắng giải quyết vấn đề này.

Rung cây là gì?

Rung cây là một hình thức loại bỏ mã chết. Rollup đã phổ biến thuật ngữ này, nhưng khái niệm loại bỏ mã chết đã tồn tại được một thời gian. Ý tưởng này cũng được áp dụng trong webpack, được minh hoạ trong bài viết này thông qua một ứng dụng mẫu.

Thuật ngữ "rút gọn cây" bắt nguồn từ mô hình tinh thần của ứng dụng và các phần phụ thuộc của ứng dụng dưới dạng cấu trúc giống cây. Mỗi nút trong cây đại diện cho một phần phụ thuộc cung cấp chức năng riêng biệt cho ứng dụng của bạn. Trong các ứng dụng hiện đại, các phần phụ thuộc này được đưa vào thông qua câu lệnh import tĩnh như sau:

// Import all the array utilities!
import arrayUtils from "array-utils";

Khi mới ra mắt, ứng dụng có thể có ít phần phụ thuộc. Ứng dụng này cũng sử dụng hầu hết (nếu không phải tất cả) các phần phụ thuộc mà bạn thêm. Tuy nhiên, khi ứng dụng của bạn phát triển, bạn có thể thêm nhiều phần phụ thuộc hơn. Để phức tạp thêm vấn đề, các phần phụ thuộc cũ không còn được sử dụng nhưng có thể không bị loại bỏ khỏi cơ sở mã của bạn. Kết quả cuối cùng là ứng dụng sẽ đi kèm với nhiều JavaScript không dùng đến. Tính năng loại bỏ mã thừa giải quyết vấn đề này bằng cách tận dụng cách các câu lệnh import tĩnh lấy các phần cụ thể của mô-đun ES6:

// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";

Sự khác biệt giữa ví dụ import này và ví dụ trước là thay vì nhập tất cả từ mô-đun "array-utils" (có thể là rất nhiều mã), ví dụ này chỉ nhập một số phần cụ thể của mô-đun đó. Trong các bản dựng dành cho nhà phát triển, việc này không thay đổi gì vì toàn bộ mô-đun sẽ được nhập bất kể điều gì. Trong các bản dựng chính thức, bạn có thể định cấu hình webpack để "loại bỏ" các mục xuất từ các mô-đun ES6 không được nhập rõ ràng, giúp các bản dựng chính thức đó nhỏ hơn. Trong hướng dẫn này, bạn sẽ tìm hiểu cách làm việc đó!

Tìm cơ hội để lắc cây

Để minh hoạ, chúng tôi có một ứng dụng mẫu một trang minh hoạ cách hoạt động của tính năng loại bỏ cây. Bạn có thể sao chép và làm theo nếu muốn, nhưng chúng ta sẽ cùng nhau tìm hiểu từng bước trong hướng dẫn này, vì vậy, bạn không cần phải sao chép (trừ phi bạn muốn học hỏi thực tế).

Ứng dụng mẫu là một cơ sở dữ liệu có thể tìm kiếm về các bàn đạp hiệu ứng cho guitar. Bạn nhập một truy vấn và danh sách bàn đạp hiệu ứng sẽ xuất hiện.

Ảnh chụp màn hình một ứng dụng mẫu gồm một trang để tìm kiếm cơ sở dữ liệu về bàn đạp hiệu ứng cho guitar.
Ảnh chụp màn hình ứng dụng mẫu.

Hành vi thúc đẩy ứng dụng này được tách thành nhà cung cấp (tức là PreactEmotion) và các gói mã dành riêng cho ứng dụng (hoặc "mảnh", như webpack gọi):

Ảnh chụp màn hình của hai gói (hoặc đoạn) mã ứng dụng hiển thị trong bảng điều khiển mạng của DevTools của Chrome.
Hai gói JavaScript của ứng dụng. Đây là kích thước chưa nén.

Các gói JavaScript hiển thị trong hình trên là các bản dựng chính thức, nghĩa là các gói này được tối ưu hoá thông qua việc làm xấu mã. 21,1 KB cho một gói dành riêng cho ứng dụng không phải là quá tệ, nhưng cần lưu ý rằng không có hiện tượng loại bỏ cây nào xảy ra. Hãy xem mã ứng dụng và tìm hiểu những việc có thể làm để khắc phục vấn đề đó.

Trong bất kỳ ứng dụng nào, việc tìm cơ hội gỡ bỏ cây sẽ liên quan đến việc tìm kiếm các câu lệnh import tĩnh. Gần đầu tệp thành phần chính, bạn sẽ thấy một dòng như sau:

import * as utils from "../../utils/utils";

Bạn có thể nhập các mô-đun ES6 theo nhiều cách, nhưng bạn nên chú ý đến những mô-đun như thế này. Dòng cụ thể này cho biết "import mọi thứ từ mô-đun utils và đặt nó vào một không gian tên có tên là utils". Câu hỏi lớn cần đặt ở đây là "có bao nhiêu nội dung trong mô-đun đó?"

Nếu xem mã nguồn mô-đun utils, bạn sẽ thấy có khoảng 1.300 dòng mã.

Bạn cần tất cả những thứ đó không? Hãy kiểm tra kỹ bằng cách tìm tệp thành phần chính nhập mô-đun utils để xem có bao nhiêu thực thể của không gian tên đó xuất hiện.

Ảnh chụp màn hình về nội dung tìm kiếm trong trình soạn thảo văn bản cho "utils", chỉ trả về 3 kết quả.
Không gian tên utils mà chúng ta đã nhập hàng tấn mô-đun chỉ được gọi ba lần trong tệp thành phần chính.

Hóa ra, không gian tên utils chỉ xuất hiện ở 3 vị trí trong ứng dụng của chúng ta – nhưng cho những hàm nào? Nếu bạn xem lại tệp thành phần chính, có vẻ như chỉ có một hàm là utils.simpleSort, được dùng để sắp xếp danh sách kết quả tìm kiếm theo một số tiêu chí khi thay đổi trình đơn thả xuống sắp xếp:

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);
}

Trong một tệp 1.300 dòng có một loạt các lệnh xuất, chỉ có một lệnh xuất được sử dụng. Điều này dẫn đến việc gửi nhiều JavaScript không dùng đến.

Mặc dù ứng dụng mẫu này có phần hơi gượng ép, nhưng điều đó không làm thay đổi thực tế là loại tình huống tổng hợp này giống với các cơ hội tối ưu hoá thực tế mà bạn có thể gặp phải trong một ứng dụng web chính thức. Giờ đây, khi bạn đã xác định được cơ hội để việc loại bỏ cây trở nên hữu ích, làm cách nào để thực hiện việc này?

Ngăn Babel chuyển đổi mô-đun ES6 sang mô-đun CommonJS

Babel là một công cụ không thể thiếu, nhưng có thể khiến bạn khó quan sát được hiệu ứng của việc lắc cây. Nếu bạn đang sử dụng @babel/preset-env, Babel có thể chuyển đổi các mô-đun ES6 thành các mô-đun CommonJS tương thích rộng rãi hơn, tức là các mô-đun bạn require thay vì import.

Vì việc loại bỏ mã thừa khó thực hiện hơn đối với các mô-đun CommonJS, nên webpack sẽ không biết phải loại bỏ gì khỏi các gói nếu bạn quyết định sử dụng các mô-đun đó. Giải pháp là định cấu hình @babel/preset-env để rõ ràng là không xử lý các mô-đun ES6. Bất cứ nơi nào bạn định cấu hình Babel (dù là trong babel.config.js hay package.json), bạn đều cần thêm một chút:

// babel.config.js
export default {
  presets: [
    [
      "@babel/preset-env", {
        modules: false
      }
    ]
  ]
}

Việc chỉ định modules: false trong cấu hình @babel/preset-env sẽ giúp Babel hoạt động như mong muốn, cho phép webpack phân tích cây phần phụ thuộc và loại bỏ các phần phụ thuộc không dùng đến.

Lưu ý đến các hiệu ứng phụ

Một khía cạnh khác cần cân nhắc khi lắc các phần phụ thuộc khỏi ứng dụng là liệu các mô-đun của dự án có tác dụng phụ hay không. Ví dụ về hiệu ứng phụ là khi một hàm sửa đổi một nội dung nào đó bên ngoài phạm vi của chính hàm đó. Đây là hiệu ứng phụ của quá trình thực thi hàm:

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"]

Trong ví dụ này, addFruit tạo ra một hiệu ứng phụ khi sửa đổi mảng fruits nằm ngoài phạm vi của nó.

Các hiệu ứng phụ cũng áp dụng cho các mô-đun ES6 và điều đó quan trọng trong bối cảnh loại bỏ mã không dùng đến. Các mô-đun nhận dữ liệu đầu vào có thể dự đoán và tạo ra kết quả đầu ra cũng có thể dự đoán mà không sửa đổi bất kỳ nội dung nào nằm ngoài phạm vi của chính mô-đun đó là các phần phụ thuộc có thể bị xoá một cách an toàn nếu chúng ta không sử dụng các phần phụ thuộc đó. Đó là các đoạn mã mô-đun, tự chứa. Do đó, "mô-đun".

Đối với webpack, bạn có thể sử dụng gợi ý để chỉ định rằng một gói và các phần phụ thuộc của gói đó không có hiệu ứng phụ bằng cách chỉ định "sideEffects": false trong tệp package.json của dự án:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": false
}

Ngoài ra, bạn có thể cho webpack biết những tệp cụ thể nào không có hiệu ứng phụ:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": [
    "./src/utils/utils.js"
  ]
}

Trong ví dụ sau, mọi tệp không được chỉ định sẽ được giả định là không có hiệu ứng phụ. Nếu không muốn thêm cờ này vào tệp package.json, bạn cũng có thể chỉ định cờ này trong cấu hình webpack thông qua module.rules.

Chỉ nhập những gì cần thiết

Sau khi hướng dẫn Babel để lại các mô-đun ES6, bạn cần điều chỉnh một chút cú pháp import để chỉ đưa vào các hàm cần thiết từ mô-đun utils. Trong ví dụ của hướng dẫn này, bạn chỉ cần hàm simpleSort:

import { simpleSort } from "../../utils/utils";

Vì chỉ nhập simpleSort thay vì toàn bộ mô-đun utils, nên mọi thực thể của utils.simpleSort sẽ cần được thay đổi thành 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);
}

Đây là tất cả những gì cần thiết để tính năng loại bỏ mã thừa hoạt động trong ví dụ này. Đây là kết quả của webpack trước khi lắc cây phần phụ thuộc:

                 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

Đây là kết quả sau khi việc loại bỏ mã không dùng đến thành công:

                 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

Mặc dù cả hai gói đều bị thu hẹp, nhưng gói main thực sự là gói được hưởng lợi nhiều nhất. Bằng cách loại bỏ các phần không dùng đến của mô-đun utils, gói main sẽ giảm khoảng 60%. Điều này không chỉ làm giảm thời gian tải tập lệnh xuống mà còn giảm thời gian xử lý.

Hãy đi lắc một số cây!

Mọi lợi ích mà bạn nhận được từ việc loại bỏ cây phụ thuộc đều phụ thuộc vào ứng dụng, các phần phụ thuộc và cấu trúc của ứng dụng. Hãy dùng thử! Nếu bạn biết chắc rằng mình chưa thiết lập trình đóng gói mô-đun để thực hiện hoạt động tối ưu hoá này, thì bạn nên thử và xem cách hoạt động này mang lại lợi ích cho ứng dụng của mình như thế nào.

Bạn có thể nhận thấy hiệu suất tăng lên đáng kể nhờ tính năng loại bỏ cây hoặc không tăng lên chút nào. Tuy nhiên, bằng cách định cấu hình hệ thống xây dựng để tận dụng tính năng tối ưu hoá này trong các bản dựng chính thức và chỉ nhập những gì ứng dụng của bạn cần một cách có chọn lọc, bạn sẽ chủ động giữ cho gói ứng dụng của mình nhỏ nhất có thể.

Xin cảm ơn đặc biệt Kristofer Baxter, Jason Miller, Addy Osmani, Jeff Posnick, Sam Saccone và Philip Walton vì những ý kiến phản hồi quý giá giúp cải thiện đáng kể chất lượng của bài viết này.