Phát hành, gửi và cài đặt JavaScript hiện đại cho các ứng dụng nhanh hơn

Cải thiện hiệu suất bằng cách bật các phần phụ thuộc và đầu ra JavaScript hiện đại.

Hơn 90% trình duyệt có khả năng chạy JavaScript hiện đại, nhưng sự phổ biến của JavaScript cũ vẫn là một nguồn lớn gây ra vấn đề về hiệu suất trên web hiện nay.

JavaScript hiện đại

JavaScript hiện đại không được mô tả là mã được viết trong một phiên bản thông số kỹ thuật ECMAScript cụ thể, mà ở cú pháp được tất cả trình duyệt hiện đại hỗ trợ. Các trình duyệt web hiện đại như Chrome, Edge, Firefox và Safari chiếm hơn 90% thị trường trình duyệt, và các trình duyệt khác nhau dựa trên cùng công cụ kết xuất cơ bản chiếm thêm 5% nữa. Như vậy có nghĩa là 95% lưu lượng truy cập web trên toàn cầu đến từ các trình duyệt hỗ trợ các tính năng ngôn ngữ JavaScript được sử dụng rộng rãi nhất trong 10 năm qua, bao gồm:

  • Các lớp (ES2015)
  • Hàm mũi tên (ES2015)
  • Máy phát điện (ES2015)
  • Phạm vi khối (ES2015)
  • Phá vỡ cấu trúc (ES2015)
  • Thông số nghỉ và phân tán (ES2015)
  • Viết tắt đối tượng (ES2015)
  • Async/await (ES2017)

Các tính năng trong những phiên bản mới hơn của thông số kỹ thuật ngôn ngữ thường được hỗ trợ ít nhất quán hơn trên các trình duyệt hiện đại. Ví dụ: nhiều tính năng ES2020 và ES2021 chỉ được hỗ trợ trên 70% thị trường trình duyệt – vẫn là phần lớn trình duyệt, nhưng chưa đủ để có thể trực tiếp dựa vào các tính năng đó một cách an toàn. Điều này có nghĩa là mặc dù JavaScript "hiện đại" là mục tiêu luôn thay đổi, nhưng ES2017 có khả năng tương thích với trình duyệt rộng nhất, trong khi vẫn có hầu hết các tính năng cú pháp hiện đại thường dùng. Nói cách khác, ES2017 là cú pháp gần giống nhất với cú pháp hiện đại hiện nay.

JavaScript cũ

JavaScript cũ là mã đặc biệt tránh sử dụng tất cả các tính năng ngôn ngữ ở trên. Hầu hết các nhà phát triển đều viết mã nguồn bằng cú pháp hiện đại, nhưng biên dịch mọi thứ theo cú pháp cũ để tăng khả năng hỗ trợ trình duyệt. Việc biên dịch theo cú pháp cũ sẽ làm tăng khả năng hỗ trợ trình duyệt, tuy nhiên hiệu quả thường nhỏ hơn so với chúng ta vẫn thấy. Trong nhiều trường hợp, dịch vụ hỗ trợ tăng từ khoảng 95% lên 98% nhưng phát sinh chi phí đáng kể:

  • JavaScript cũ thường lớn hơn khoảng 20% và chậm hơn so với mã hiện đại tương đương. Việc thiếu công cụ và cấu hình sai thường khiến khoảng cách này rộng hơn nữa.

  • Các thư viện đã cài đặt chiếm tới 90% mã JavaScript phát hành chính thức thông thường. Mã thư viện phải chịu mức hao tổn JavaScript cũ thậm chí còn cao hơn do có thể tránh được tình trạng trùng lặp polyfill và trình trợ giúp bằng cách phát hành mã hiện đại.

JavaScript hiện đại trên npm

Gần đây, Node.js đã chuẩn hoá một trường "exports" để xác định điểm truy cập cho một gói:

{
  "exports": "./index.js"
}

Các mô-đun mà trường "exports" tham chiếu ngụ ý là phiên bản Nút tối thiểu là 12.8, hỗ trợ ES2019. Điều này có nghĩa là bất kỳ mô-đun nào được tham chiếu bằng trường "exports" đều có thể được viết bằng JavaScript hiện đại. Người sử dụng gói phải giả định các mô-đun có trường "exports" chứa mã hiện đại và bản dịch nếu cần.

Hiện đại

Nếu bạn muốn phát hành một gói có mã hiện đại và để người tiêu dùng tự mình xử lý việc dịch mã khi họ sử dụng gói này làm phần phụ thuộc, thì bạn chỉ cần sử dụng trường "exports".

{
  "name": "foo",
  "exports": "./modern.js"
}

Hiện đại với tính năng dự phòng cũ

Sử dụng trường "exports" cùng với "main" để phát hành gói bằng mã hiện đại, nhưng cũng bao gồm tính năng dự phòng ES5 + CommonJS cho các trình duyệt cũ.

{
  "name": "foo",
  "exports": "./modern.js",
  "main": "./legacy.cjs"
}

Hiện đại với tính năng dự phòng cũ và tối ưu hoá bộ gói ESM

Ngoài việc xác định điểm truy cập CommonJS dự phòng, bạn có thể sử dụng trường "module" để trỏ đến một gói dự phòng cũ tương tự, nhưng là gói sử dụng cú pháp mô-đun JavaScript (importexport).

{
  "name": "foo",
  "exports": "./modern.js",
  "main": "./legacy.cjs",
  "module": "./module.js"
}

Nhiều trình đóng gói, chẳng hạn như webpack và Rollup, dựa vào trường này để tận dụng các tính năng của mô-đun và bật tính năng lắc cây. Đây vẫn là một gói cũ không chứa bất kỳ mã hiện đại nào ngoài cú pháp import/export. Vì vậy, hãy sử dụng phương pháp này để gửi mã hiện đại bằng một gói dự phòng cũ vẫn được tối ưu hoá cho việc gói.

JavaScript hiện đại trong các ứng dụng

Các phần phụ thuộc của bên thứ ba chiếm phần lớn mã JavaScript sản xuất điển hình trong các ứng dụng web. Mặc dù trước đây các phần phụ thuộc npm được phát hành dưới dạng cú pháp ES5 cũ, nhưng điều này không còn là giả định an toàn và gây ra rủi ro cho việc cập nhật phần phụ thuộc có thể phá vỡ khả năng hỗ trợ trình duyệt trong ứng dụng của bạn.

Khi số lượng gói npm chuyển sang JavaScript hiện đại ngày càng tăng, điều quan trọng là bạn phải đảm bảo thiết lập công cụ xây dựng để xử lý các gói đó. Có khả năng cao là một số gói npm mà bạn đang dựa vào đã sử dụng các tính năng ngôn ngữ hiện đại. Có một số tuỳ chọn để sử dụng mã hiện đại từ npm mà không làm hỏng ứng dụng trong các trình duyệt cũ, nhưng ý tưởng chung là để hệ thống xây dựng chuyển các phần phụ thuộc sang cùng mục tiêu cú pháp như mã nguồn của bạn.

gói web

Kể từ webpack 5, bạn hiện có thể định cấu hình cú pháp mà webpack sẽ sử dụng khi tạo mã cho các gói và mô-đun. Thao tác này không chuyển mã hoặc các phần phụ thuộc mà chỉ ảnh hưởng đến mã "kết dính" do gói web tạo ra. Để chỉ định mục tiêu hỗ trợ trình duyệt, hãy thêm cấu hình danh sách trình duyệt vào dự án hoặc thực hiện việc này trực tiếp trong cấu hình gói web:

module.exports = {
  target: ['web', 'es2017'],
};

Bạn cũng có thể định cấu hình gói web để tạo các gói được tối ưu hoá giúp bỏ qua các hàm bao bọc không cần thiết khi nhắm mục tiêu môi trường Mô-đun ES hiện đại. Thao tác này cũng định cấu hình gói web để tải các gói phân tách mã bằng cách sử dụng <script type="module">.

module.exports = {
  target: ['web', 'es2017'],
  output: {
    module: true,
  },
  experiments: {
    outputModule: true,
  },
};

Có một số trình bổ trợ webpack giúp bạn có thể biên dịch và gửi JavaScript hiện đại trong khi vẫn hỗ trợ các trình duyệt cũ, chẳng hạn như Trình bổ trợ Optimize và BumblebeeEsmPlugin.

Trình bổ trợ Optimize

Trình bổ trợ Optimize là một trình bổ trợ webpack giúp chuyển đổi mã đi kèm cuối cùng từ JavaScript hiện đại sang JavaScript cũ thay vì từng tệp nguồn riêng lẻ. Đây là một cách thiết lập độc lập cho phép cấu hình gói web của bạn giả định mọi thứ đều là JavaScript hiện đại mà không có phân nhánh đặc biệt cho nhiều kết quả đầu ra hoặc cú pháp.

Vì trình bổ trợ Optimize hoạt động trên các gói thay vì từng mô-đun riêng lẻ, nên trình bổ trợ này sẽ xử lý mã của ứng dụng và các phần phụ thuộc một cách như nhau. Điều này giúp bạn có thể sử dụng các phần phụ thuộc JavaScript hiện đại từ npm một cách an toàn, vì mã của các phần phụ thuộc đó sẽ được gói và chép lời theo đúng cú pháp. Giải pháp này cũng có thể nhanh hơn các giải pháp truyền thống gồm 2 bước biên dịch, trong khi vẫn tạo các gói riêng cho trình duyệt hiện đại và cũ. Hai tập hợp gói được thiết kế để tải bằng mẫu mô-đun/không mô-đun.

// webpack.config.js
const OptimizePlugin = require('optimize-plugin');

module.exports = {
  // ...
  plugins: [new OptimizePlugin()],
};

Optimize Plugin có thể nhanh và hiệu quả hơn so với các cấu hình gói web tuỳ chỉnh, thường gói riêng mã hiện đại và mã cũ. Công cụ này cũng xử lý việc chạy Babel cho bạn, đồng thời giảm kích thước các gói bằng cách sử dụng Terser với các chế độ cài đặt tối ưu riêng cho kết quả hiện đại và cũ. Cuối cùng, các đoạn mã polyfill cần thiết cho gói cũ đã tạo sẽ được trích xuất vào một tập lệnh chuyên dụng để chúng không bao giờ bị trùng lặp hoặc tải một cách không cần thiết trong các trình duyệt mới.

So sánh: dịch mã mô-đun nguồn hai lần so với dịch mã gói được tạo.

BabelEsmPlugin

BabelEsmPlugin là một trình bổ trợ webpack hoạt động cùng với @babel/preset-env để tạo phiên bản hiện đại của các gói hiện có nhằm gửi mã ít được chuyển đổi hơn đến các trình duyệt hiện đại. Đây là giải pháp có sẵn phổ biến nhất cho mô-đun/không mô-đun, được Next.jsPreact CLI sử dụng.

// webpack.config.js
const BabelEsmPlugin = require('babel-esm-plugin');

module.exports = {
  //...
  module: {
    rules: [
      // your existing babel-loader configuration:
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
    ],
  },
  plugins: [new BabelEsmPlugin()],
};

BabelEsmPlugin hỗ trợ nhiều cấu hình gói web vì ứng dụng này chạy 2 bản dựng phần lớn riêng biệt của ứng dụng. Việc biên dịch hai lần có thể mất thêm một chút thời gian đối với các ứng dụng lớn. Tuy nhiên, kỹ thuật này cho phép BabelEsmPlugin tích hợp liền mạch vào các cấu hình gói web hiện có và biến nó thành một trong những lựa chọn thuận tiện nhất hiện có.

Định cấu hình trình tải babel để chuyển đổi nút_mô-đun nút

Nếu đang sử dụng babel-loader mà không có một trong hai trình bổ trợ trước đó, thì bạn cần thực hiện một bước quan trọng để sử dụng các mô-đun npm JavaScript hiện đại. Việc xác định hai cấu hình babel-loader riêng biệt giúp bạn có thể tự động biên dịch các tính năng ngôn ngữ hiện đại có trong node_modules sang ES2017, trong khi vẫn biên dịch mã của bên thứ nhất của riêng bạn bằng các trình bổ trợ nỗ lực và giá trị đặt trước của Android đã xác định trong cấu hình của dự án. Thao tác này không tạo ra các gói hiện đại và cũ cho chế độ thiết lập mô-đun/không mô-đun, nhưng giúp bạn có thể cài đặt và sử dụng các gói npm chứa JavaScript hiện đại mà không làm hỏng các trình duyệt cũ.

webpack-plugin-modern-npm sử dụng kỹ thuật này để biên dịch các phần phụ thuộc npm có trường "exports" trong package.json, vì những phần phụ thuộc này có thể chứa cú pháp hiện đại:

// webpack.config.js
const ModernNpmPlugin = require('webpack-plugin-modern-npm');

module.exports = {
  plugins: [
    // auto-transpile modern stuff found in node_modules
    new ModernNpmPlugin(),
  ],
};

Ngoài ra, bạn có thể triển khai kỹ thuật này theo cách thủ công trong cấu hình gói web bằng cách kiểm tra trường "exports" trong package.json của các mô-đun khi các mô-đun đó được phân giải. Bỏ qua việc lưu vào bộ nhớ đệm để ngắn gọn, quy trình triển khai tuỳ chỉnh có thể có dạng như sau:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      // Transpile for your own first-party code:
      {
        test: /\.js$/i,
        loader: 'babel-loader',
        exclude: /node_modules/,
      },
      // Transpile modern dependencies:
      {
        test: /\.js$/i,
        include(file) {
          let dir = file.match(/^.*[/\\]node_modules[/\\](@.*?[/\\])?.*?[/\\]/);
          try {
            return dir && !!require(dir[0] + 'package.json').exports;
          } catch (e) {}
        },
        use: {
          loader: 'babel-loader',
          options: {
            babelrc: false,
            configFile: false,
            presets: ['@babel/preset-env'],
          },
        },
      },
    ],
  },
};

Khi sử dụng phương pháp này, bạn cần đảm bảo trình rút gọn của bạn hỗ trợ cú pháp hiện đại. Cả Terseruglify-es đều có tuỳ chọn để chỉ định {ecma: 2017} để lưu giữ và trong một số trường hợp, sẽ tạo cú pháp ES2017 trong quá trình nén và định dạng.

Kết quả tổng hợp

Rollup có tính năng hỗ trợ tích hợp sẵn để tạo nhiều tập hợp gói trong một bản dựng duy nhất và tạo mã hiện đại theo mặc định. Do đó, bạn có thể định cấu hình Rollup để tạo các gói hiện đại và cũ có các trình bổ trợ chính thức mà có thể bạn đang sử dụng.

@rollup/plugin-babel

Nếu bạn sử dụng Rollup, phương thức getBabelOutputPlugin() (do trình bổ trợ Bumblebee chính thức của Rollup cung cấp) sẽ biến đổi mã trong các gói được tạo thay vì từng mô-đun nguồn. Rollup có tính năng hỗ trợ tích hợp sẵn để tạo nhiều nhóm gói trong một bản dựng duy nhất, mỗi tập hợp có các trình bổ trợ riêng. Bạn có thể sử dụng tính năng này để tạo các gói khác nhau cho phiên bản hiện đại và phiên bản cũ bằng cách chuyển mỗi gói thông qua một cấu hình trình bổ trợ đầu ra Babel khác nhau:

// rollup.config.js
import {getBabelOutputPlugin} from '@rollup/plugin-babel';

export default {
  input: 'src/index.js',
  output: [
    // modern bundles:
    {
      format: 'es',
      plugins: [
        getBabelOutputPlugin({
          presets: [
            [
              '@babel/preset-env',
              {
                targets: {esmodules: true},
                bugfixes: true,
                loose: true,
              },
            ],
          ],
        }),
      ],
    },
    // legacy (ES5) bundles:
    {
      format: 'amd',
      entryFileNames: '[name].legacy.js',
      chunkFileNames: '[name]-[hash].legacy.js',
      plugins: [
        getBabelOutputPlugin({
          presets: ['@babel/preset-env'],
        }),
      ],
    },
  ],
};

Công cụ xây dựng bổ sung

Cuộn lên và gói web có khả năng định cấu hình cao, thường có nghĩa là mỗi dự án phải cập nhật cấu hình cho phép bật cú pháp JavaScript hiện đại trong các phần phụ thuộc. Ngoài ra, còn có các công cụ xây dựng cấp cao hơn ưu tiên quy ước và chế độ mặc định thay vì cấu hình, như Parcel, Snowpack, ViteDCLID. Hầu hết các công cụ này đều giả định các phần phụ thuộc npm có thể chứa cú pháp hiện đại và sẽ dịch mã chúng sang(các) cấp độ cú pháp thích hợp khi tạo bản dựng để phát hành chính thức.

Ngoài các trình bổ trợ chuyên dụng cho gói web và bản tổng hợp, bạn có thể thêm các gói JavaScript hiện đại với bản dự phòng cũ vào bất kỳ dự án nào bằng cách sử dụng tính năng phát triển. Cải tiến là một công cụ độc lập giúp chuyển đổi đầu ra từ hệ thống xây dựng để tạo ra các biến thể JavaScript cũ, cho phép gói và biến đổi để giả định mục tiêu đầu ra hiện đại.