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 của chúng. Tính đến giữa năm 2018, HTTP Archive ước tính kích thước truyền trung bình của JavaScript trên thiết bị di động là khoảng 350 KB. Và đây chỉ là kích thước truyề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. Bạn cần lưu ý điều này vì đối với 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ù có thể chỉ 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. 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 là 900 KB JavaScript 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ư hình ảnh chỉ phát sinh 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 và sau đó mới được thực thi. Theo từng byte, điều này khiến JavaScript tốn kém hơn các loại tài nguyên khác.

Biểu đồ so sánh thời gian xử lý của 170 KB JavaScript so với một hình ảnh JPEG có kích thước tương đương. Tài nguyên JavaScript tốn nhiều tài nguyên hơn đáng kể 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 tệp JPEG có kích thước tương đương. (nguồn).

Mặc dù các cải tiến liên tục được thực hiệ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 nhà phát triển.

Để đạt được mục tiêu đó, có một số kỹ thuật giúp cải thiện hiệu suất JavaScript. Phân tách mã là một kỹ thuật như vậy 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á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 được 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. Kỹ thuật rung cây (tree shaking) cố gắng giải quyết vấn đề này.

Kỹ thuật rung cây (tree shaking) là gì?

Kỹ thuật rung cây (tree shaking) là một hình thức loại bỏ mã không dùng đến. Thuật ngữ này được Rollup phổ biến, nhưng khái niệm loại bỏ mã không dùng đến đã tồn tại trong một thời gian. Khái niệm này cũng được sử 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ữ "rung cây" (tree shaking) xuất phát 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 như 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ác câu lệnh static import như sau:

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

Khi một ứng dụng còn non trẻ (một cây con, nếu bạn muốn), ứng dụng đó có thể có ít phần phụ thuộc. Ứng dụng đó cũng đang 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 trưởng thành, bạn có thể thêm nhiều phần phụ thuộc hơn. Để giải quyết vấn đề này, các phần phụ thuộc cũ không còn được sử dụng nữa, nhưng có thể không được cắt tỉa khỏi toàn bộ mã nguồn 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 nhiều JavaScript không dùng đến. Kỹ thuật rung cây (tree shaking) 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 kéo các phần cụ thể của mô-đun ES6 vào:

// 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ể của mô-đun đó. Trong các bản dựng dành cho nhà phát triển, điều này không thay đổi gì vì toàn bộ mô-đun sẽ đượ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 webpack để "rung" các thành phần xuất khỏi các mô-đun ES6 chưa đượ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ẽ học cách thực hiện việc đó!

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

Để minh hoạ, một ứng dụng mẫu một trang có sẵn sẽ minh hoạ cách hoạt động của kỹ thuật rung cây (tree shaking). Bạn có thể sao chép ứng dụng đó 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 sao chép (trừ phi bạn thích học thực hành).

Ứ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 guitar. Bạn nhập một truy vấn và một danh sách các 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 một trang để tìm kiếm cơ sở dữ liệu về bàn đạp hiệu ứng guitar.
Ả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 các gói mã dành cho nhà cung cấp (tức là PreactEmotion) và dành riêng cho ứng dụng (hoặc "các phần", như webpack gọi):

Ảnh chụp màn hình của 2 gói mã xử lý ứng dụng (hoặc đoạn mã) 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à các kích thước chưa nén.

Các gói JavaScript được hiển thị trong hình trên là các bản dựng chính thức, nghĩa là chúng đượ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á lớn, nhưng bạn cần lưu ý rằng không có kỹ thuật rung cây (tree shaking) nào đang diễn ra. Hãy xem mã ứng dụng và xem những việc bạn có thể làm để khắc phục vấn đề đó.

Trong bất kỳ ứng dụng nào, việc tìm kiếm các cơ hội rung cây (tree shaking) 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 mô-đun như thế này sẽ thu hút sự chú ý của bạn. Dòng cụ thể này cho biết "import mọi thứ từ mô-đun utils và đặt chúng vào 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 thứ trong mô-đun đó?"

Nếu bạn 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 lại bằng cách tìm kiế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 nội dung 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 ta đã nhập rất nhiều mô-đun 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 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, thì có vẻ như chỉ có một hàm là utils.simpleSort. Hàm này đượ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 các trình đơn thả xuống sắp xếp đượ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 tệp 1.300 dòng có một loạt thành phần xuất, chỉ có một thành phần được sử dụng. Điều này dẫn đến việc phân phối nhiều JavaScript không dùng đến.

Mặc dù ứng dụng mẫu này có phần hơi giả tạo, nhưng điều đó không thay đổi sự thật rằng tình huống sắp xếp 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, bạn đã xác định được một cơ hội để kỹ thuật rung cây (tree shaking) hữu ích. Vậy thì kỹ thuật này được thực hiện như thế nào?

Ngăn Babel chuyển đổi các mô-đun ES6 thành các mô-đun CommonJS

Babel là một công cụ không thể thiếu, nhưng nó có thể khiến bạn khó quan sát được các hiệu ứng của kỹ thuật rung cây (tree shaking). 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 hơn (tức là các mô-đun mà bạn require thay vì import).

Vì kỹ thuật rung cây (tree shaking) khó thực hiện hơn đối với các mô-đun CommonJS, nên webpack sẽ không biết nên cắt tỉa những 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 để rõ ràng không sử dụng các mô-đun ES6. Bất cứ nơi nào bạn định cấu hình Babel (trong babel.config.js hoặc package.json), bạn đều cần thêm một chút 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 Babel hoạt động như mong muốn, cho phép webpack phân tích cây phần phụ thuộc của bạn 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 xem xét khi loại bỏ các phần phụ thuộc khỏi ứng dụng của bạn là liệu các mô-đun của dự án có hiệu ứng phụ hay không. Ví dụ về tác dụng phụ là khi một hàm sửa đổi nội dung bên ngoài phạm vi của chính nó. Đây là tác dụng phụ của việc 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 tác dụ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 rung cây (tree shaking). Các mô-đun nhận dữ liệu đầu vào có thể dự đoán và tạo ra dữ liệu đầu ra có thể dự đoán tương đương mà không sửa đổi bất kỳ nội dung nào bên ngoài phạm vi của chính chúng là các phần phụ thuộc có thể được loại bỏ 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 đó, chúng được gọi là "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 thông tin 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 không sử dụng các mô-đun ES6, bạn cần điều chỉnh một chút cú pháp import để chỉ đưa các hàm cần thiết vào 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ỉ simpleSort được nhập thay vì toàn bộ mô-đun utils, nên bạn cần thay đổi mọi thực thể của utils.simpleSort 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 để kỹ thuật rung cây (tree shaking) hoạt động trong ví dụ này. Đây là kết quả webpack trước khi rung 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 rung 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 gói main mới thực sự đượ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 giảm khoảng 60%. Điều này không chỉ giảm thời gian tập lệnh tải xuống mà còn giảm cả thời gian xử lý.

Hãy rung cây!

Mức độ hiệu quả của kỹ thuật rung cây (tree shaking) 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 chắn rằng mình chưa thiết lập trình đóng gói mô-đun để thực hiện quá trình tối ưu hoá này, thì bạn nên thử và xem cách nó mang lại lợi ích cho ứng dụng của mình.

Bạn có thể nhận thấy hiệu suất tăng đáng kể nhờ kỹ thuật rung cây (tree shaking), hoặc không tăng 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 quá trình 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 ở mức nhỏ nhất có thể.

Xin chân thành 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 quý giá của họ, giúp cải thiện đáng kể chất lượng của bài viết này.