Blog về sự kiện phát trực tiếp "tăng cường" – Chia mã

Trong sự kiện phát trực tiếp Supercharged gần đây nhất, chúng tôi đã triển khai tính năng phân tách mã và phân đoạn dựa trên tuyến đường. Với HTTP/2 và các mô-đun ES6 gốc, các kỹ thuật này sẽ trở nên cần thiết để tải và lưu các tài nguyên tập lệnh vào bộ nhớ đệm một cách hiệu quả.

Các mẹo và thủ thuật khác trong tập này

  • asyncFunction().catch() với error.stack: 9:55
  • Mô-đun và thuộc tính nomodule trên thẻ <script>: 7:30
  • promisify() trong Nút 8: 17:20

TL;DR

Cách phân tách mã thông qua tính năng phân đoạn dựa trên tuyến:

  1. Lấy danh sách các điểm truy cập.
  2. Trích xuất các phần phụ thuộc mô-đun của tất cả các điểm truy cập này.
  3. Tìm các phần phụ thuộc dùng chung giữa tất cả các điểm truy cập.
  4. Gói các phần phụ thuộc dùng chung.
  5. Viết lại các điểm truy cập.

Phân tách mã so với phân đoạn dựa trên tuyến

Tính năng phân tách mã và phân đoạn dựa trên tuyến đường có liên quan chặt chẽ và thường được sử dụng thay thế cho nhau. Điều này đã gây ra một số nhầm lẫn. Hãy cùng làm rõ vấn đề này:

  • Phân tách mã: Phân tách mã là quá trình chia mã thành nhiều gói. Nếu bạn không gửi một gói lớn chứa tất cả JavaScript cho ứng dụng, thì bạn đang phân tách mã. Một cách cụ thể để phân tách mã là sử dụng tính năng phân đoạn dựa trên tuyến đường.
  • Phân đoạn dựa trên tuyến: Tính năng phân đoạn dựa trên tuyến tạo các gói liên quan đến tuyến của ứng dụng. Bằng cách phân tích các tuyến đường và phần phụ thuộc của các tuyến đường đó, chúng ta có thể thay đổi mô-đun nào sẽ đi vào gói nào.

Tại sao nên phân tách mã?

Mô-đun lỏng

Với các mô-đun ES6 gốc, mỗi mô-đun JavaScript đều có thể nhập các phần phụ thuộc riêng của nó. Khi trình duyệt nhận được một mô-đun, tất cả câu lệnh import sẽ kích hoạt các lệnh tìm nạp bổ sung để lấy các mô-đun cần thiết để chạy mã. Tuy nhiên, tất cả các mô-đun này đều có thể có phần phụ thuộc riêng. Điều nguy hiểm là trình duyệt sẽ kết thúc bằng một loạt các lượt tìm nạp kéo dài nhiều lượt đi và về trước khi mã có thể được thực thi.

Gói

Việc đóng gói (tức là nội tuyến tất cả các mô-đun của bạn vào một gói duy nhất) sẽ đảm bảo trình duyệt có tất cả mã cần thiết sau 1 lượt truy cập và có thể bắt đầu chạy mã nhanh hơn. Tuy nhiên, điều này buộc người dùng phải tải xuống nhiều mã không cần thiết, do đó lãng phí băng thông và thời gian. Ngoài ra, mọi thay đổi đối với một trong các mô-đun ban đầu của chúng ta sẽ dẫn đến thay đổi trong gói, làm mất hiệu lực mọi phiên bản gói được lưu vào bộ nhớ đệm. Người dùng sẽ phải tải lại toàn bộ ứng dụng.

Phân tách mã

Phân tách mã là giải pháp trung gian. Chúng tôi sẵn sàng đầu tư thêm các lượt đi và về để đạt được hiệu quả mạng bằng cách chỉ tải những gì chúng ta cần xuống và tăng hiệu quả lưu vào bộ nhớ đệm bằng cách giảm số lượng mô-đun trên mỗi gói. Nếu việc gói được thực hiện đúng cách, tổng số lượt khứ hồi sẽ thấp hơn nhiều so với các mô-đun rời. Cuối cùng, chúng ta có thể sử dụng cơ chế tải trước như link[rel=preload] để tiết kiệm thêm thời gian cho vòng ba nếu cần.

Bước 1: Lấy danh sách điểm truy cập

Đây chỉ là một trong nhiều phương pháp, nhưng trong tập này, chúng ta đã phân tích cú pháp sitemap.xml của trang web để lấy các điểm truy cập vào trang web của mình. Thông thường, hệ thống sẽ sử dụng một tệp JSON chuyên biệt liệt kê tất cả các điểm truy cập.

Sử dụng babel để xử lý JavaScript

Babel thường được dùng để "biên dịch chuyển đổi": sử dụng mã JavaScript mới nhất và chuyển mã đó thành phiên bản JavaScript cũ hơn để nhiều trình duyệt hơn có thể thực thi mã. Bước đầu tiên ở đây là phân tích cú pháp JavaScript mới bằng một trình phân tích cú pháp (Babel sử dụng babylon) để biến mã thành cái gọi là "Cây cú pháp trừu tượng" (AST). Sau khi tạo AST, một loạt trình bổ trợ sẽ phân tích và quản lý AST.

Chúng ta sẽ sử dụng nhiều babel để phát hiện (và sau đó thao tác) các lệnh nhập của một mô-đun JavaScript. Bạn có thể muốn sử dụng biểu thức chính quy, nhưng biểu thức chính quy không đủ mạnh để phân tích cú pháp một ngôn ngữ đúng cách và khó duy trì. Việc dựa vào các công cụ đã được kiểm thử như Babel sẽ giúp bạn tránh được nhiều rắc rối.

Dưới đây là ví dụ đơn giản về cách chạy adb bằng trình bổ trợ tuỳ chỉnh:

const plugin = {
  visitor: {
    ImportDeclaration(decl) {
      /* ... */
    }
  }
}
const {code} = babel.transform(inputCode, {plugins: [plugin]});

Một trình bổ trợ có thể cung cấp đối tượng visitor. Trình truy cập chứa một hàm cho mọi loại nút mà trình bổ trợ muốn xử lý. Khi gặp một nút thuộc loại đó trong khi duyệt qua AST, hàm tương ứng trong đối tượng visitor sẽ được gọi với nút đó làm tham số. Trong ví dụ trên, phương thức ImportDeclaration() sẽ được gọi cho mọi nội dung khai báo import trong tệp. Để hiểu rõ hơn về các loại nút và AST, hãy truy cập vào astexplorer.net.

Bước 2: Trích xuất các phần phụ thuộc của mô-đun

Để tạo cây phần phụ thuộc của một mô-đun, chúng ta sẽ phân tích cú pháp mô-đun đó và tạo danh sách tất cả các mô-đun mà mô-đun đó nhập. Chúng ta cũng cần phân tích cú pháp các phần phụ thuộc đó, vì các phần phụ thuộc đó cũng có thể có phần phụ thuộc. Một trường hợp kinh điển về đệ quy!

async function buildDependencyTree(file) {
  let code = await readFile(file);
  code = code.toString('utf-8');

  // `dep` will collect all dependencies of `file`
  let dep = [];
  const plugin = {
    visitor: {
      ImportDeclaration(decl) {
        const importedFile = decl.node.source.value;
        // Recursion: Push an array of the dependency’s dependencies onto the list
        dep.push((async function() {
          return await buildDependencyTree(`./app/${importedFile}`);
        })());
        // Push the dependency itself onto the list
        dep.push(importedFile);
      }
    }
  }
  // Run the plugin
  babel.transform(code, {plugins: [plugin]});
  // Wait for all promises to resolve and then flatten the array
  return flatten(await Promise.all(dep));
}

Bước 3: Tìm các phần phụ thuộc dùng chung giữa tất cả các điểm truy cập

Vì chúng ta có một tập hợp các cây phần phụ thuộc (nếu bạn muốn gọi là rừng phần phụ thuộc), nên chúng ta có thể tìm thấy các phần phụ thuộc dùng chung bằng cách tìm các nút xuất hiện trong mọi cây. Chúng ta sẽ làm phẳng và loại bỏ trùng lặp trong rừng và lọc để chỉ giữ lại các phần tử xuất hiện trong tất cả các cây.

function findCommonDeps(depTrees) {
  const depSet = new Set();
  // Flatten
  depTrees.forEach(depTree => {
    depTree.forEach(dep => depSet.add(dep));
  });
  // Filter
  return Array.from(depSet)
    .filter(dep => depTrees.every(depTree => depTree.includes(dep)));
}

Bước 4: Gói các phần phụ thuộc dùng chung

Để gói tập hợp các phần phụ thuộc dùng chung, chúng ta chỉ cần nối tất cả các tệp mô-đun. 2 vấn đề phát sinh khi sử dụng phương pháp đó: Vấn đề đầu tiên là gói vẫn sẽ chứa các câu lệnh import sẽ khiến trình duyệt cố gắng tìm nạp tài nguyên. Vấn đề thứ hai là các phần phụ thuộc của phần phụ thuộc chưa được đóng gói. Vì đã làm việc này trước đây, nên chúng ta sẽ viết một trình bổ trợ babel khác.

Mã này khá giống với trình bổ trợ đầu tiên của chúng ta, nhưng thay vì chỉ trích xuất các lệnh nhập, chúng ta cũng sẽ xoá chúng và chèn một phiên bản đi kèm của tệp đã nhập:

async function bundle(oldCode) {
  // `newCode` will be filled with code fragments that eventually form the bundle.
  let newCode = [];
  const plugin = {
    visitor: {
      ImportDeclaration(decl) {
        const importedFile = decl.node.source.value;
        newCode.push((async function() {
          // Bundle the imported file and add it to the output.
          return await bundle(await readFile(`./app/${importedFile}`));
        })());
        // Remove the import declaration from the AST.
        decl.remove();
      }
    }
  };
  // Save the stringified, transformed AST. This code is the same as `oldCode`
  // but without any import statements.
  const {code} = babel.transform(oldCode, {plugins: [plugin]});
  newCode.push(code);
  // `newCode` contains all the bundled dependencies as well as the
  // import-less version of the code itself. Concatenate to generate the code
  // for the bundle.
  return flatten(await Promise.all(newCode)).join('\n');
}

Bước 5: Viết lại các điểm truy cập

Ở bước cuối cùng, chúng ta sẽ viết một trình bổ trợ Babel khác. Công việc của lớp này là xoá tất cả các lệnh nhập của các mô-đun có trong gói dùng chung.

async function rewrite(section, sharedBundle) {
  let oldCode = await readFile(`./app/static/${section}.js`);
  oldCode = oldCode.toString('utf-8');
  const plugin = {
    visitor: {
      ImportDeclaration(decl) {
        const importedFile = decl.node.source.value;
        // If this import statement imports a file that is in the shared bundle, remove it.
        if(sharedBundle.includes(importedFile))
          decl.remove();
      }
    }
  };
  let {code} = babel.transform(oldCode, {plugins: [plugin]});
  // Prepend an import statement for the shared bundle.
  code = `import '/static/_shared.js';\n${code}`;
  await writeFile(`./app/static/_${section}.js`, code);
}

End

Thật là một hành trình thú vị, phải không? Xin lưu ý rằng mục tiêu của chúng ta trong tập này là giải thích và làm rõ tính năng phân tách mã. Kết quả hoạt động – nhưng chỉ dành riêng cho trang web minh hoạ của chúng tôi và sẽ không hoạt động trong trường hợp chung. Đối với phiên bản phát hành công khai, bạn nên sử dụng các công cụ đã được thiết lập sẵn như WebPack, RollUp, v.v.

Bạn có thể tìm thấy mã của chúng tôi trong kho lưu trữ GitHub.

Hẹn gặp bạn lần sau!