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 trung bình để chuyển 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 bị 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ì liên quan đến việc xử lý tài nguyên thì việc nén là không liên quan. 900 KB của 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ù nó có thể là khoảng 300 KB khi nén.

Sơ đồ minh hoạ quá 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 chuyển của tập lệnh là 300 KB được nén, nhưng JavaScript vẫn cần phải được phân tích cú pháp, biên dịch và thực thi vẫn có giá trị là 900 KB.

JavaScript là một tài nguyên đắt đỏ để 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 nhỏ sau khi được tải xuống, JavaScript phải được phân tích cú pháp, biên dịch và cuối cùng là thực thi. Byte cho byte, điều này làm cho JavaScript đắt hơn các loại tài nguyên khác.

Một sơ đồ so sánh thời gian xử lý 170 KB của JavaScript với một 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 so với JPEG.
Chi phí xử lý phân tích cú pháp/biên dịch 170 KB của 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ù liên tục cải tiến để cải thiện hiệu quả của công cụ JavaScript, nhưng việc cải thiện hiệu suất JavaScript (như mọi khi) là một nhiệm vụ của các nhà phát triển.

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

Mặc dù có hiệu quả, kỹ thuật này không giải quyết vấn đề chung của các ứng dụng dùng nhiều JavaScript, đó là việc bao gồm các đoạn mã không bao giờ được sử dụng. rung cây 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ã chết. Thuật ngữ này được Rollup phổ biến, nhưng khái niệm loại bỏ mã chết đã 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.

Thuật ngữ "sự rung chuyển của 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 giống như cây. Mỗi nút trên 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ác 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ỏ (là con), nếu bạn còn nhỏ, ứng dụng đó có thể có một vài phần phụ thuộc. Trình quản lý thẻ của Google 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 vào. Tuy nhiên, khi ứng dụng của bạn đã hoàn thiện thì có thể thêm nhiều phần phụ thuộc hơn. Để khắc phục vấn đề, các phần phụ thuộc cũ sẽ không còn sử dụng được nhưng có thể không được cắt bớt khỏi cơ sở mã của bạn. Kết quả cuối cùng là ứng dụng sẽ được đưa vào vận chuyển cùng với rất nhiều JavaScript không sử dụng. Tình trạng rung cây 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ụ về import này và ví dụ trước là thay vì nhập mọi thứ từ mô-đun "array-utils" (có thể là nhiều mã) – ví dụ này chỉ nhập các phần cụ thể. Trong các bản dựng dành cho 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 được nhập bất kể. Trong các bản dựng chính thức, bạn có thể định cấu hình gói web để "loại bỏ" dữ liệu xuất từ các mô-đun ES6 không được nhập rõ ràng, giúp giảm kích thước 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 việc đó!

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

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

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

Ảnh chụp màn hình về một ứng dụng mẫu trên một trang để tìm kiếm cơ sở dữ liệu về bàn đạp hiệu ứng ghi-ta.
Ảnh chụp màn hình của ứng dụng mẫu.

Hành vi điều khiển ứ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 "các đoạn", như gói web gọi chúng):

Ảnh chụp màn hình 2 gói mã ứng dụng (hoặc phân đoạn) xuất hiện 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à những 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à chúng sẽ được tối ưu hoá thông qua việc nâng cấp. 21,1 KB đối với một gói dành riêng cho ứng dụng không phải là 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à xem bạn có thể làm gì để khắc phục vấn đề đó.

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 bạn nên chú ý đến những cách nêu trên. Dòng cụ thể này cho biết "import mọi thứ từ mô-đun utils và đặt nó trong một không gian tên có tên là utils". Câu hỏi lớn cần đặt ra ở đâ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ó 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 một lượt tìm kiếm trong trình chỉnh sửa văn bản cho "utils.", chỉ trả về 3 kết quả.
Không gian tên utils mà chúng tôi đã 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.

Thực tế, không gian tên utils chỉ xuất hiện ở ba vị trí trong ứng dụng nhưng dành cho chức năng gì? Nếu bạn xem lại tệp thành phần chính, thì có vẻ như tệp này chỉ là 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 trình đơn sắp xếp thả xuống được 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 một tệp 1.300 dòng với một loạt dữ liệu xuất, chỉ có một trong số chúng được sử dụng. Điều này dẫn đến việc có nhiều JavaScript không được sử dụng.

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

Duy trì chế độ biên dịch đối vớ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ông cụ này có thể khiến việc quan sát ảnh hưởng của rung cây trở nên khó khăn hơn một chút. Nếu bạn đang sử dụng @babel/preset-env, thì Partner có thể chuyển đổi các mô-đun ES6 thành các mô-đun CommonJS tương thích rộng hơn, tức là các mô-đun bạn require thay vì import.

Vì việc rung cây khó thực hiện hơn đối với các mô-đun CommonJS, nên gói web sẽ không biết cần cắt giảm gì khỏi các gói nếu bạn quyết định sử dụng chúng. Giải pháp là định cấu hình @babel/preset-env để giữ nguyên các mô-đun ES6 một cách rõ ràng. Bất cứ khi nào bạn định cấu hình nền tảng Android, dù là trong babel.config.js hay package.json, thì bạn sẽ phải thêm một vài nội dung 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 CameraX hoạt động như mong muốn, 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.

Ghi nhớ các tác dụng phụ

Một khía cạnh khác cần cân nhắc khi rung các phần phụ thuộc từ ứng dụng là liệu các mô-đun trong dự án của bạn có tác dụng phụ hay không. Một ví dụ về hiệu ứng phụ là khi một hàm sửa đổi nội dung nào đó nằm 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:

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 mảng đó.

Các tác dụng phụ cũng áp dụng cho các mô-đun ES6 và điều đó quan trọng trong bối cảnh rung cây. 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ó thể dự đoán như nhau mà không cần sửa đổi bất kỳ nội dung nào bên ngoài phạm vi của chúng là các phần phụ thuộc có thể bị loại bỏ một cách an toàn nếu chúng ta không sử dụng các phần phụ thuộc đó. Đây là các đoạn mã mô-đun độc lập. Do đó, "mô-đun".

Trong trường hợp liên quan đến gói web, 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ó 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 những tệp cụ thể nào không 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 coi là không có tác dụng phụ. Nếu không muốn thêm đoạn mã 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 của mình thông qua module.rules.

Chỉ nhập những dữ liệu cần thiết

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

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

Vì chỉ có simpleSort được nhập 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 để cây rung lắc có thể thực hiện được trong ví dụ này. Đây là kết quả gói web trước khi rung 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 có kích thước nhỏ, 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ỏ những 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 thời gian tập lệnh cần để tải xuống mà còn giảm thời gian xử lý.

Đi lắc lư vài cái cây!

Bất kể quãng đường bạn đi được sau khi rung cây đều phụ thuộc vào ứng dụng 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 biết rằng bạn chưa thiết lập trình đóng gói mô-đun của mình để thực hiện việc tối ưu hoá này, thì cũng không có gì gây hại khi thử và xem việc thiết lập 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ể nhờ rung cây hoặc thậm chí không hiệu quả 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 có chọn lọc những gì ứng dụng của bạn 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ì những ý kiến phản hồi hữu ích, giúp cải thiện đáng kể chất lượng của bài viết này.