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ể trở nên khá lớn, đặc biệt là phần JavaScript. Tính đến giữa năm 2018, HTTP Archive đặt kích thước truyền trung bình của JavaScript trên thiết bị di động vào khoảng 350 KB. Đây chỉ là kích thước chuyển! JavaScript thường được nén khi được gửi qua mạng, có nghĩa là lượng JavaScript thực tế sẽ nhiều hơn một chút sau khi trình duyệt giải nén JavaScript. Điều này rất quan trọng cần chỉ ra, vì theo như liên quan đến xử lý tài nguyên, thì việc nén là không liên quan. 900 KB JavaScript được giải nén vẫn là 900 KB cho trình phân tích cú pháp và trình biên dịch, mặc dù nó có thể khoảng 300 KB khi được 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. Lưu ý rằng mặc dù kích thước truyền của tập lệnh là 300 KB được nén, nhưng nó vẫn là JavaScript giá trị 900 KB cần phải được phân tích cú pháp, biên dịch và thực thi.

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

Sơ đồ so sánh thời gian xử lý của tệp JavaScript 170 KB so với hình ảnh JPEG có kích thước tương đương. Tài nguyên JavaScript dành cho byte tốn nhiều tài nguyên hơn nhiều so với JPEG.
Chi phí xử lý khi 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 để cải thiện hiệu quả của các công cụ JavaScript, nhưng việc cải thiện hiệu suất của JavaScript vẫn luôn là một nhiệm vụ đối với các nhà phát triển.

Để đạt được mục tiêu đó, có những kỹ thuật giúp cải thiện hiệu suất của 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 ứng dụng thành các phần và chỉ phân phát các phần đó cho các tuyến của ứng dụng cần chúng.

Mặc dù kỹ thuật này hoạt động, nhưng nó không giải quyết một vấn đề phổ biến của các ứng dụng nặng JavaScript, đó là việc bao gồm mã không bao giờ được sử dụng. Rung cây sẽ cố gắng giải quyết vấn đề này.

Cây rung lắc là gì?

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

Từ khoá "giật cây" xuất phát từ mô hình tư duy 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 dạ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. 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ột ứng dụng còn nhỏ (nếu là cây non), thì ứng dụng đó có thể có ít phần phụ thuộc. Trình bổ trợ này cũng sử dụng hầu hết (nếu không phải là tất cả) các phần phụ thuộc mà bạn thêm vào. Tuy nhiên, khi ứng dụng phát triển, bạn có thể thêm nhiều phần phụ thuộc khác. Đối với vấn đề phức tạp, các phần phụ thuộc cũ sẽ không được sử dụng, nhưng có thể không bị cắt bớt khỏi cơ sở mã của bạn. Kết quả cuối cùng là một ứng dụng sẽ được phân phối với rất nhiều JavaScript không sử dụng. Kỹ thuật rung cây giải quyết vấn đề này bằng cách tận dụng cách câu lệnh import tĩnh kéo vào 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 mọi thứ từ mô-đun "array-utils" (có thể là rất nhiều mã)), ví dụ này chỉ nhập các phần cụ thể trong mô-đun đó. Trong các bản dựng của nhà phát triển, điều này không thay đổi bất cứ điều gì vì toàn bộ mô-đun đều được nhập. Trong các bản dựng chính thức, bạn có thể định cấu hình gói web để "lắc" tắt dữ liệu xuất từ các mô-đun ES6 không được nhập rõ ràng, làm giảm kích thước của các bản dựng chính thức đó. Trong hướng dẫn này, bạn sẽ tìm hiểu cách làm điều đó!

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

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

Ứng dụng mẫu là một cơ sở dữ liệu có thể tìm kiếm gồm các bàn đạp có hiệu ứng ghi-ta. Bạn nhập 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 của 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 của đàn ghi-ta.
Ảnh chụp màn hình của ứ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 (ví dụ: Dự đoánBiểu tượng cảm xúc) và các gói mã dành riêng cho ứng dụng (hoặc "các đoạn", như cách gói webpack gọi chúng):

Ảnh chụp màn hình hai gói mã ứng dụng (hoặc đoạn) hiển thị trong bảng điều khiển mạng của Công cụ cho nhà phát triển của Chrome.
Hai gói JavaScript của ứng dụng. Đây là các kích thước không 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, có nghĩa là các gói này được tối ưu hóa thông qua hình thức không hợp lệ. 21,1 KB cho một gói dành riêng cho ứng dụng không phải là quá xấu, nhưng cần lưu ý rằng không có hiện tượng rung cây nào xảy ra. Hãy xem mã ứng dụng và biết những việc bạn có thể làm để khắc phục vấn đề này.

Trong mọi ứng dụng, việc tìm cơ hội rung 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 những cách như thế này sẽ thu hút sự chú ý của bạn. Dòng này cho biết "import mọi thứ trong mô-đun utils và đặt nó vào một không gian tên utils." Câu hỏi quan trọng cần đặt ra ở đây là "có bao nhiêu nội dung trong mô-đun đó?"

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

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

Ảnh chụp màn hình kết quả tìm kiếm "utils." trong trình chỉnh sửa văn bản và chỉ trả về 3 kết quả.
Không gian tên utils mà chúng ta đã nhập rất nhiều mô-đun từ đó chỉ được gọi 3 lần trong tệp thành phần chính.

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

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 số 1.300 tệp dòng có nhiều tệp xuất, chỉ có một tệp trong số đó được sử dụng. Điều này dẫn đến việc phải gửi rất nhiều JavaScript không dùng đến.

Mặc dù ứng dụng ví dụ này được cho là có đôi chút được làm giả, nhưng nó không làm thay đổi thực tế là loại tình huống giả tạo 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 ứng dụng web chính thức. Giờ đây, bạn đã xác định được cơ hội để việc rung cây trở nên hữu ích, vậy thực sự làm cách nào để thực hiện việc này?

Giữ cho iMessage không chuyển đổi các mô-đun ES6 sang các mô-đun CommonJS

Babel là một công cụ không thể thiếu, nhưng có thể khiến việc quan sát ảnh hưởng của cây rung chuyển khó khăn hơn một chút. Nếu bạn đang sử dụng @babel/preset-env, Squarespace 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ì rung cây khó thực hiện hơn cho các mô-đun CommonJS, nên webpack sẽ không biết cần cắt giảm gì từ các gói nếu quyết định sử dụng chúng. Giải pháp là định cấu hình @babel/preset-env để không tách riêng các mô-đun ES6. Bất cứ khi nào bạn định cấu hình Squarespace, dù là trong babel.config.js hay package.json, thì điều này bao gồm việc thêm một số thông tin bổ sung:

// 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ẽ khiến cho ABI hoạt động như mong muốn. Điều này cho phép gói web 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 tác dụng phụ

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

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 hiệu ứng phụ khi sửa đổi mảng fruits nằm ngoài phạm vi của mảng đó.

Các hiệu ứng phụ cũng áp dụng cho các mô-đun ES6 và rất quan trọng trong trường hợp rung cây. Những mô-đun lấy dữ liệu đầu vào có thể dự đoán được và tạo ra kết quả dễ dự đoán như nhau mà không cần sửa đổi bất cứ thứ gì ngoài phạm vi của riêng mô-đun đó là những phần phụ thuộc có thể bị bỏ qua một cách an toàn nếu chúng ta không sử dụng chúng. Chúng là các đoạn mã mô-đun độc lập. Do đó, "mô-đun".

Khi liên quan đến gói web, bạn có thể 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ó tác dụ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 tệp cụ thể nào không có tác dụ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ó tác dụ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 gói web qua module.rules.

Chỉ nhập những thông tin cần thiết

Sau khi hướng dẫn adb để nguyên các mô-đun ES6, bạn cần điều chỉnh cú pháp import một chút để chỉ cung cấp 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 có hàm simpleSort:

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

Vì chỉ có simpleSort đang được nhập thay vì toàn bộ mô-đun utils, nên mọi thực thể của utils.simpleSort đều cần phải 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 để rung cây trong ví dụ này. Đây là kết quả webpack trước khi lắc cây 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 lắc cây 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 giảm kích thước, nhưng thực sự thì gói main được hưởng lợi nhiều nhất. Bằng cách loại bỏ các phần không sử dụng của mô-đun utils, gói main sẽ thu nhỏ khoảng 60%. Điều này không chỉ làm giảm lượng thời gian tập lệnh cần để tải xuống, mà còn làm giảm thời gian xử lý.

Hãy lắc cây nào!

Bất kể quãng đường bạn đã đi được với hiện tượng rung lắc cây phụ thuộc vào ứng dụng của bạn cũng như các phần phụ thuộc và cấu trúc của ứng dụng đó. Hãy dùng thử! Nếu trên thực tế, bạn không thiết lập bộ gói mô-đun để thực hiện quá trình tối ưu hoá này thì sẽ không có hại gì khi thử và xem việc này mang lại lợi ích gì cho ứng dụng của bạn.

Bạn có thể nhận thấy hiệu suất tăng đáng kể từ việc rung cây hoặc không hề nhiều. 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 có chọn lọc những nội dung ứng dụng cần, bạn sẽ chủ động giữ cho các gói ứng dụng của mình nhỏ nhất có thể.

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