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

Trong buổi phát trực tiếp có tính năng Super cáo gần đây nhất, chúng tôi đã triển khai việc chia 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 để cho phép 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ả.

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 các thẻ <script>: 7:30
  • promisify() trong Nút 8: 17:20

TL;DR

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

  1. Nhận danh sách điểm truy cập của bạn.
  2. Trích xuất các phần phụ thuộc của 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 được chia sẻ.
  5. Viết lại các điểm nhập.

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

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

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

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

Mô-đun rời

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ần tìm nạp bổ sung để giữ lại 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ó các phần phụ thuộc riêng. Mối 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 trong nhiều chuyến đi khứ hồi trước khi mã có thể được thực thi.

Gói

Việc gói, đưa tất 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 khứ hồi và có thể bắt đầu chạy mã nhanh hơn. Tuy nhiên, điều này sẽ buộc người dùng tải nhiều mã không cần thiết xuống, 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 gốc của chúng tôi sẽ dẫn đến thay đổi trong gói, làm mất hiệu lực mọi phiên bản đã lưu vào bộ nhớ đệm của gói. Người dùng sẽ phải tải lại toàn bộ nội dung.

Tách mã

Việc chia tách mã là bước trung gian. Chúng tôi sẵn sàng đầu tư thêm các lượt trọn vòng để đạt được hiệu quả về mạng bằng cách chỉ tải dữ liệu cần thiết xuống và cải thiện 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ác cơ chế tải trước như link[rel=preload] để tiết kiệm thêm thời gian của bộ ba vòng nếu cần.

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

Đây chỉ là một trong nhiều cách tiếp cận, nhưng trong tập này, chúng tôi đã 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. Thông thường, một tệp JSON chuyên dụng liệt kê tất cả các điểm truy cập sẽ được sử dụng.

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

Bumblebee thường được dùng để "dịch chuyển": sử dụng mã JavaScript tân tiến và chuyển mã đó thành phiên bản JavaScript cũ hơn để nhiều trình duyệt khác 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 trình phân tích cú pháp (Babel sử dụng babylon) để biến mã thành một "Cây cú pháp trừu tượng" (AST). Sau khi AST được tạo, một loạt trình bổ trợ sẽ phân tích và huỷ hoại AST.

Chúng ta sẽ sử dụng nhiều babel để phát hiện (và sau đó điều khiển) hoạt động nhập của mô-đun JavaScript. Bạn có thể muốn 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à rất khó duy trì. Nhờ sử dụng các công cụ đã được thử nghiệm và thử nghiệm như Partner, bạn sẽ không phải tốn nhiều công sức.

Dưới đây là một ví dụ đơn giản về cách chạy nỗ lực của bạn với 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. Khách truy cập chứa hàm dành 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 truyền tải AST, hàm tương ứng trong đối tượng visitor sẽ được gọi bằng nút đó dưới dạng 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. Để nắm rõ hơn về các loại nút và AST, hãy xem trang astexplorer.net.

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

Để xây dựng cây phần phụ thuộc của một mô-đun, chúng tôi 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 vào. Chúng tôi 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ó các phần phụ thuộc. Một trường hợp cổ điển cho đệ 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 tôi có một tập hợp các cây phần phụ thuộc – rừng phụ thuộc nếu có – nên chúng tôi có thể tìm 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 tôi sẽ làm phẳng và loại bỏ rừng cây trùng lặp, đồng thời lọc để chỉ giữ lại các thành phần 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: Nhóm các phần phụ thuộc được chia sẻ

Để gói tập hợp các phần phụ thuộc dùng chung, chúng ta có thể chỉ nối tất cả các tệp mô-đun. Có 2 vấn đề sẽ 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 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 các phần phụ thuộc chưa được đóng gói. Vì đã thực hiện việc này trước đó, 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, nhưng thay vì chỉ trích xuất các tệp dữ liệu 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 điểm nhập

Đối với bước cuối cùng, chúng ta sẽ viết một trình bổ trợ Nổi bật khác. Nhiệm vụ của lệnh này là xoá tất cả nội dung nhập của các mô-đun nằm 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

Các bạn đã trải nghiệm chuyến đi này, đúng không? Hãy nhớ rằng mục tiêu của chúng tôi cho tập này là giải thích và làm rõ việc phân tách mã. Kết quả có hiệu quả, nhưng dành riêng cho trang web minh hoạ của chúng tôi và sẽ không thành công tột cùng trong trường hợp chung. Đối với quá trình sản xuất, bạn nên sử dụng các công cụ có 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!