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ó thể 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 các 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 theo phiên bản thông số kỹ thuật ECMAScript cụ thể, mà là 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à nhiều 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%. Điều nà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:

  • Lớp (ES2015)
  • Hàm mũi tên (ES2015)
  • Trình tạo (ES2015)
  • Phạm vi khối (ES2015)
  • Phá vỡ cấu trúc (ES2015)
  • Tham số còn lại và tham số truyền (ES2015)
  • Đối tượng viết tắt (ES2015)
  • Async/await (ES2017)

Các tính năng trong phiên bản mới hơn của quy cách ngôn ngữ thường có mức độ hỗ trợ nhất quán thấp 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ợ tại 70% thị trường trình duyệt – vẫn là phần lớn các trình duyệt, nhưng vẫn chưa đủ an toàn để trực tiếp dựa vào các tính năng đó. Điều này có nghĩa là mặc dù JavaScript "hiện đại" là một mục tiêu luôn thay đổi, nhưng ES2017 có phạm vi tương thích với trình duyệt rộng nhất trong khi vẫn bao gồm 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à phiên bản gần nhất với cú pháp hiện đại hiện nay.

JavaScript cũ

JavaScript cũ là mã cụ thể 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 viết mã nguồn bằng cú pháp hiện đại, nhưng biên dịch mọi thứ thành cú pháp cũ để tăng khả năng hỗ trợ trình duyệt. Việc biên dịch sang 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 chúng ta nhận thấy. Trong nhiều trường hợp, mức độ hỗ trợ tăng từ khoảng 95% lên 98% trong khi chi phí tăng lên đáng kể:

  • JavaScript cũ thường lớn hơn và chậm hơn khoảng 20% 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 làm gia tăng khoảng cách này.

  • Các thư viện đã cài đặt chiếm đến 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 được tham chiếu bởi trường "exports" ngụ ý một phiên bản Nút tối thiểu là 12.8, hỗ trợ ES2019. Điều này có nghĩa là mọi mô-đun được tham chiếu bằng trường "exports" đều có thể được viết bằng JavaScript hiện đại. Người dùng gói phải giả định rằng các mô-đun có trường "exports" chứa mã hiện đại và biên dịch lại nếu cần.

Chỉ 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 dùng tự xử lý việc chuyển đổi mã đó khi họ sử dụng mã đó làm phần phụ thuộc, hãy chỉ sử dụng trường "exports".

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

Hiện đại với phương thức 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, đồng thời bao gồm cả phương án 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á trình kết hợp ESM

Ngoài việc xác định điểm truy cập dự phòng CommonJS, 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 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 tạo 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 xoá 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 để vận chuyển mã hiện đại bằng một phương án dự phòng cũ vẫn được tối ưu hoá để đóng gói.

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

Các phần phụ thuộc bên thứ ba chiếm phần lớn mã JavaScript phát hành chính thức trong các ứng dụng web. Mặc dù các phần phụ thuộc npm trước đây được phát hành dưới dạng cú pháp ES5 cũ, nhưng đây không còn là giả định an toàn và có nguy cơ các bản cập nhật phần phụ thuộc sẽ làm hỏng tính năng hỗ trợ trình duyệt trong ứng dụng của bạn.

Khi số lượng các gói npm chuyển sang JavaScript hiện đại ngày càng tăng, bạn phải đảm bảo rằng công cụ xây dựng được thiết lập để xử lý các gói đó. Rất có thể một số gói npm mà bạn phụ thuộc 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 của bạn trong các trình duyệt cũ, nhưng ý tưởng chung là để hệ thống xây dựng chuyển đổi các phần phụ thuộc sang cùng một mục tiêu cú pháp với mã nguồn của bạn.

webpack

Kể từ webpack 5, bạ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. Việc này không biên dịch mã hoặc phần phụ thuộc mà chỉ ảnh hưởng đến mã "keo" do webpack tạo. Để 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 trực tiếp trong cấu hình webpack:

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

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

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

Có một số trình bổ trợ webpack có sẵn cho phép biên dịch và phân phố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ợ tối ưu hoá và BabelEsmPlugin.

Trình bổ trợ Optimize

Trình bổ trợ Optimize là một trình bổ trợ webpack chuyển đổi mã gói 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 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ứ là JavaScript hiện đại mà không cần phân nhánh đặc biệt cho nhiều đầu ra hoặc cú pháp.

Vì Trình bổ trợ tối ưu hoá hoạt động trên các gói thay vì các mô-đun riêng lẻ, nên trình bổ trợ này xử lý mã của ứng dụng và các phần phụ thuộc của bạn một cách bình đẳng. Điều này giúp bạn 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 này sẽ được đóng gói và biên dịch sang cú pháp chính xác. Cách này cũng có thể nhanh hơn các giải pháp truyền thống liên quan đến hai bước biên dịch, trong khi vẫn tạo các gói riêng biệt cho trình duyệt hiện đại và trình duyệt cũ. Hai nhóm gói này được thiết kế để tải bằng mẫu mô-đun/không có 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 là gói mã hiện đại và mã cũ riêng biệt). Công cụ này cũng xử lý việc chạy Babel cho bạn và rút gọn các gói bằng Terser với các chế độ cài đặt tối ưu riêng biệt cho đầu ra hiện đại và cũ. Cuối cùng, các gói cũ được tạo cần có polyfill sẽ được trích xuất vào một tập lệnh chuyên dụng để không bao giờ bị trùng lặp hoặc tải không cần thiết trong các trình duyệt mới hơn.

So sánh: chuyển đổi mô-đun nguồn hai lần so với chuyển dịch các 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 các phiên bản hiện đại của các gói hiện có nhằm gửi ít mã được biên dịch sang trình duyệt hiện đại hơn. Đây là giải pháp phổ biến nhất có sẵn 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ì API 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 webpack hiện có và biến nó trở 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_modules

Nếu bạn đ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 cho phép bạn tự động biên dịch các tính năng ngôn ngữ hiện đại có trong node_modules sang ES2017, đồng thời vẫn chuyển đổi mã bên thứ nhất của riêng bạn bằng các trình bổ trợ và giá trị đặt trước Babel được xác định trong cấu hình của dự án. Điều 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 theo mô-đun, nhưng cho phép 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ì các 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 webpack 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. Để ngắn gọn, bạn có thể bỏ qua việc lưu vào bộ nhớ đệm, cách 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 hỗ trợ cú pháp hiện đại. Cả Terseruglify-es đều có tuỳ chọn chỉ định {ecma: 2017} để giữ nguyên và trong một số trường hợp tạo cú pháp ES2017 trong quá trình nén và định dạng.

Kết quả tổng hợp

Rollup tích hợp sẵn tính năng hỗ trợ tạo nhiều nhóm gói trong một bản dựng 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ũ bằng các trình bổ trợ chính thức mà bạn có thể đ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ợ Babel chính thức của Rollup cung cấp) sẽ chuyển đổi mã trong các gói được tạo thay vì các mô-đun nguồn riêng lẻ. Rollup tích hợp sẵn tính năng hỗ trợ tạo nhiều nhóm gói trong một bản dựng, mỗi nhóm 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à cũ bằng cách truyề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ác công cụ xây dựng khác

Rollup và webpack có khả năng định cấu hình cao, nghĩa là mỗi dự án phải cập nhật cấu hình để 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à mặc định hơn cấu hình, chẳng hạn như Parcel, Snowpack, ViteWMR. Hầu hết các công cụ này giả định các phần phụ thuộc npm có thể chứa cú pháp hiện đại và sẽ biên dịch các phần phụ thuộc đó sang (các) cấp độ cú pháp thích hợp khi tạo bản dựng chính thức.

Ngoài các trình bổ trợ chuyên dụng cho webpack và Rollup, bạn có thể thêm các gói JavaScript hiện đại có phương thức dự phòng cũ vào bất kỳ dự án nào bằng cách sử dụng tính năng devolution (hạ cấp). Devolution 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 các biến thể JavaScript cũ, cho phép gói và chuyển đổi để giả định một mục tiêu đầu ra hiện đại.