發布、提供及安裝新式 JavaScript,加快應用程式速度

啟用新版 JavaScript 依附元件和輸出內容,改善效能。

超過 90% 的瀏覽器都能執行新版 JavaScript,但舊版 JavaScript 的盛行,仍是目前網站上效能問題的主要來源。

新版 JavaScript

新式 JavaScript 的特色並非以特定 ECMAScript 規格版本編寫的程式碼,而是所有新式瀏覽器支援的語法。Chrome、Edge、Firefox 和 Safari 等新世代網路瀏覽器佔比90% 以上的瀏覽器市場,而採用相同基礎轉譯引擎的不同瀏覽器可額外創造 5%。這表示全球網路流量中有 95% 來自支援過去 10 年內最廣泛使用的 JavaScript 語言功能的瀏覽器,包括:

  • 課程 (ES2015)
  • 箭頭函式 (ES2015)
  • 發電機 (ES2015)
  • 區塊範圍 (ES2015)
  • 解構 (ES2015)
  • Rest 和 spread 參數 (ES2015)
  • 物件縮寫 (ES2015)
  • Async/await (ES2017)

新版語言規格中的功能通常在各款新式瀏覽器中不支援一致性。舉例來說,許多 ES2020 和 ES2021 功能只在瀏覽器市場的 70% 中獲得支援,儘管大多數瀏覽器仍舊支援,但其不夠安全直接依賴這些功能。也就是說,雖然「新式」JavaScript 是不斷變動的目標,但 ES2017 的瀏覽器相容性最廣泛,同時包含大多數常用的新式語法功能。換句話說,ES2017 最接近目前的現代語法

舊版 JavaScript

舊版 JavaScript 是指避免使用上述所有語言功能的程式碼。大多數開發人員都使用現代語法編寫原始碼,但將所有內容編譯為舊版語法,以增加瀏覽器支援範圍。編譯為舊版語法確實能增加瀏覽器的支援能力,但效果通常比我們發現的還要小。在許多情況下,支援服務會從約 95% 增加到 98%,但成本卻高昂:

  • 舊版 JavaScript 通常比同等的現代程式碼大約大 20%,速度也較慢。工具缺陷和設定錯誤通常會進一步擴大這項差距。

  • 安裝的程式庫可占一般正式版 JavaScript 程式碼的 90%。由於 polyfill 和輔助程式重複,程式庫程式碼會產生更高的舊版 JavaScript 額外負擔,但您可以透過發布新版程式碼來避免這種情況。

npm 上的新型 JavaScript

最近,Node.js 已將 "exports" 欄位標準化,以定義套件的進入點

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

"exports" 欄位參照的模組隱含支援 ES2019 的節點版本至少為 12.8。也就是說,使用 "exports" 欄位參照的任何模組都可以以新型 JavaScript 編寫。套件使用者必須假設含有 "exports" 欄位的模組包含新型程式碼,並視需要進行轉譯。

僅限現代

如果想發布含有新型程式碼的套件,並交由取用者把該檔案當做依附元件處理,請自行使用 "exports" 欄位處理。

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

現代版,搭配舊版備用方案

請使用 "exports" 欄位搭配 "main",以便使用新式程式碼發布套件,但也為舊版瀏覽器納入 ES5 + CommonJS 備用方案。

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

以舊版備用廣告和 ESM 套裝組合最佳化的方式進行現代化

除了定義備用 CommonJS 進入點之外,"module" 欄位也可用來指向類似的舊版備用套件,但使用 JavaScript 模組語法 (importexport)。

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

許多 bundler (例如 webpack 和 Rollup) 都會使用這個欄位來發揮模組功能的優勢,並啟用樹狀圖搖晃功能。這仍是舊版套件,除了 import/export 語法外,不含任何新式程式碼,因此請使用這種方法,搭配仍針對套件最佳化的舊版備用方案,發布新式程式碼。

應用程式中的新式 JavaScript

第三方依附元件是網頁應用程式中典型的實際 JavaScript 程式碼的絕大多數。雖然 npm 依附元件一直以來都是以舊版 ES5 語法發布,但這不再是安全假設,且因為依附元件更新會破壞應用程式中的瀏覽器支援。

隨著越來越多 npm 套件轉移至新式 JavaScript,請務必確保建構工具已設定好來處理這些套件。您所依賴的部分 npm 套件很可能已使用新式語言功能。您可以使用多種方法,從 npm 使用新式程式碼,且不會在舊版瀏覽器中破壞應用程式,但一般來說,您可以讓建構系統將依附元件轉譯為與原始碼相同的語法目標。

webpack

從 webpack 5 起,您可以設定 Webpack 產生套件和模組程式碼時要使用的語法。這不會轉譯程式碼或依附元件,只會影響 webpack 產生的「glue」程式碼。如要指定瀏覽器支援目標,請在專案中新增瀏覽器清單設定,或直接在 webpack 設定中執行此操作:

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

您也可以設定 webpack,讓系統在指定新式 ES 模組環境時,產生經過最佳化的套件,藉此省略不必要的包裝函式。這也會將 webpack 設定為使用 <script type="module"> 載入程式碼分割套件。

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

您可以使用多種 webpack 外掛程式,在編譯及發布新式 JavaScript 的同時,仍支援舊版瀏覽器,例如 Optimize Plugin 和 BabelEsmPlugin。

最佳化工具

最佳化外掛程式是 webpack 外掛程式,可將最終已組合的程式碼從新式 JavaScript 轉換為舊版 JavaScript,而非個別來源檔案。這是一個自給自足的設定,可讓 webpack 設定假設所有內容都是新式 JavaScript,且沒有針對多個輸出或語法進行特殊分支。

最佳化工具外掛程式是在套件 (而非個別模組) 上運作,因此會同樣處理應用程式的程式碼和依附元件。這樣一來,您就能安全地使用 npm 中的新式 JavaScript 依附元件,因為這些程式碼會經過整合並轉譯為正確的語法。這項方法也比傳統的解決方案 (涉及兩個編譯步驟) 更快,同時仍可為新一代和舊版瀏覽器產生個別套件。這兩組套件設計為使用 module/nomodule 模式載入。

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

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

Optimize Plugin 比自訂 webpack 設定更快、更有效率,後者通常會將新版和舊版程式碼分開封裝。也可以為您處理 Babel 執行的作業,並使用 Terser 針對現代與舊版輸出內容提供最佳設定,藉此壓縮套裝組合。最後,產生的舊版套件所需的 polyfill 會擷取至專用指令碼,因此不會在較新的瀏覽器中重複或不必要地載入。

比較:兩次轉譯來源模組與轉譯產生的套件。

BabelEsmPlugin

BabelEsmPlugin 是 webpack 外掛程式,可與 @babel/preset-env 搭配運作,產生現有套件的新版,以便將經過較少轉譯的程式碼傳送至新版瀏覽器。這是模組/nomodule 最熱門的現成解決方案,由 Next.jsPreact CLI 使用。

// 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 支援多種 webpack 設定,因為它會執行兩個幾乎獨立的應用程式版本。對於大型應用程式,兩次編譯可能會耗費一點額外時間,但這項技巧可讓 BabelEsmPlugin 與現有的 webpack 設定完美整合,是目前最方便的選項之一。

將 babel-loader 設為 transpile node_modules

如果您使用 babel-loader 時沒有使用上述兩個外掛程式之一,則必須執行一項重要步驟,才能使用新式 JavaScript npm 模組。定義兩個個別的 babel-loader 設定後,系統就能自動將 node_modules 中的新式語言功能編譯為 ES2017,同時使用 Babel 外掛程式和專案設定中定義的預設值,轉譯您自己的第一方程式碼。這不會為模組/非模組設定產生新式和舊版套件,但可讓您安裝及使用含有新式 JavaScript 的 npm 套件,而不會破壞舊版瀏覽器。

webpack-plugin-modern-npm 會使用這項技巧,編譯 package.json 中含有 "exports" 欄位的 npm 依附元件,因為這些可能包含新式語法:

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

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

或者,您也可以在 webpack 設定中手動實作這項技巧,方法是在解析模組時,檢查模組的 package.json 中是否有 "exports" 欄位。為了簡化說明,我們省略快取,自訂實作可能如下所示:

// 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'],
          },
        },
      },
    ],
  },
};

使用這個方法時,需要確保壓縮器支援現代語法。Terseruglify-es 兩者都可以指定 {ecma: 2017} 來保留,在某些情況下,也會在壓縮和格式化期間產生 ES2017 語法。

匯總

Rollup 內建支援功能,可在單一版本中產生多組套件,並預設產生新式程式碼。因此,您可以設定 Rollup,讓它使用您可能已在使用的官方外掛程式,產生新版和舊版套件。

@rollup/plugin-babel

如果使用 Rollup,getBabelOutputPlugin() 方法 (由 Rollup 的官方 Babel 外掛程式提供) 會將程式碼轉換到產生的套件中,而非個別來源模組。Rollup 內建支援功能,可讓你在單一建構作業中產生多個套件組合,而每個套件都有自己的外掛程式。您可以使用此方法透過不同的 Babel 輸出外掛程式設定,針對現代與舊版產生不同的套件:

// 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'],
        }),
      ],
    },
  ],
};

其他建構工具

Rollup 和 webpack 可高度設定,這代表每個專案都必須更新其設定,才能在依附元件中啟用新型 JavaScript 語法。還有一些較高層級的建構工具,會優先採用慣例和預設值,而非設定,例如 ParcelSnowpackViteWMR。這些工具大多假設 npm 依附元件可能包含新式語法,並在正式建構時將其轉譯為適當的語法層級。

除了 webpack 和 Rollup 專用的外掛程式之外,您也可以使用退化功能,將含有舊版備用方案的現代 JavaScript 套件新增至任何專案。解析是一種獨立工具,可將建構系統的輸出內容轉換為舊版 JavaScript 變數,讓組合和轉換假設為現代輸出目標。