一文讀懂 babel7 的配置文件加載邏輯

近期,在波洞星球的PC官網項目中,我們採用了新版的 babel7 作爲 ES 語法轉換器。而 babel7 中的一大變更就是對配置文件的加載邏輯進行了改進,然而實際上對於不熟悉 babel 配置邏輯的朋友往往會帶來更多問題。本文就是 babel7 配置文件的中文指南,它是英語渣渣的救星,是給懶人送到口邊的一道美味。如有錯誤 概不負責 歡迎指正。

前言

babel7 從 2018年3月開始進入 alpha 階段,時隔5個月直到 2018年8月份 release 第一個版本,目前的最新版是2019年2月26號發佈的 7.3.4. 時光如梭,在這美好的 9012 年,ES2019 都快要發佈了的時刻,我想: 是時候用一用 babel7 了。

本文不是 babel7 的升級教程,而是對 babel7 的新變化和配置邏輯的一點心得。babel7 對monorepo 結構項目的優化恰好符合我們目前項目架構的預期,這簡化了我們配置的複雜度,但其難以理解的配置加載邏輯,卻讓我踩了不少坑,這也正是本文的來源。

說點變化

在開始講 babel7 的配置邏輯之前,我們先從以下幾個方面來囉嗦幾句 babel7 所做的變更及其邏輯意義。

proposal 語法特性

在歷史上(babel6)的時代,人們通常使用 babel 提供的 preset-stage 預設來體驗 ES6 之後的處於建議階段的語法特性。例如做如下的 babel 配置:

"presets": ["es2015", "react", "stage-0"]

其中,es2015 預設會包含 ES6 標準中所有語法特性;stage-0預設會包含當前(安裝該預設npm包的時刻) 的 ES 語法進展中的 stage 0到3的特性(數字小的包含數字大的)。但事實上 babel 官方這樣提供 stage 預設,會有不少問題

例如:

  • 隨着 es 標準的不斷髮展,大量的新特性幾乎已經成爲標準。與此同時,stage0-3階段的特性必然也發生變化。可以說,stage0-3的階段特性他們是不穩定的,極有可能在某個時機被TC39委員會除名、變更階段、改變語法。儘管 babel-preset-* 預設會跟隨TC39 保持一致的更新, 但這樣的用法需要使用者也不斷保持更新 才能跟標準一致
  • 歷史上的 preset-es2015 配合 preset-stage-0 的做法極易產生疑惑,例如沒有人知道他所需要的特性在stage幾
  • 一個語言特性如果從 stage3 變更爲 stage4,往往會導致以前的 stage0(包含了1、2、3) 的配置出問題。因爲特性推進後,新的stage0中就不再包含該特性內容,但使用者可能不知道要把該特性所在的 ES標準 加入到配置中
  • 大量的社區工具 eslint 等等都依賴 babel;babel 的 preset-stage 預設更新就會導致這些社區工具頻頻出現問題。

如今,babel 官方認爲,把不穩定的 stage0-3 作爲一種預設是不太合理的,因此廢棄了 stage 預設,轉而讓用戶自己選擇使用哪個 proposal 特性的插件,這將帶來更多的明確性(用戶無須理解 stage,自己選的插件,自己便能明確的知道代碼中可以使用哪個特性)。所有建議特性的插件,都改變了命名規範,即類似 @babel/plugin-proposal-function-bind 這樣的命名方式來表明這是個 proposal 階段特性。

ES 標準特性

對於正經的 ES 標準特性,babel從6開始就建議使用 babel-preset-env 這個能根據環境進行自動配置的預設。到了 babel7,我們就可以完全告別這幾個歷史預設了: preset-es2015/es2016/es2017/latest

爲什麼 preset-env 要更好呢?

我認爲,對於開發者而言,關注目標用戶平臺(兼容哪些瀏覽器)要比關注 "編譯爲哪份ES標準" 要更易理解。把選擇編譯插件的事情交給 preset-env 就好了。它會根據 compat table 和你設置的目標用戶平臺選擇正確的插件。

polyfill

跟 stage 預設的結局一樣,對於處於建議階段的特性,polyfill裏面也移除了對他們的支持。

以前的 babel-polyfill 是這麼實現的:

import "core-js/shim"; // included < Stage 4 proposals 

import "regenerator-runtime/runtime"

現在的 @babel/polyfill 就直接引入 core-js v2 的屬於ES正式標準的模塊。這意味着,如果你需要使用處於 proposal 階段的語法特性,你需要手工 import core-js 中的對應模塊。

命名空間

從 babel7 開始,所有的官方插件和主要模塊,都放在了 @babel 的命名空間下。從而可以避免在 npm 倉庫中 babel 相關名稱被搶注的問題。有必要說一下的,比如 @babel/node @babel/core @babel/clil @babel/preset-env

transform-runtime

以前的 babel-transform-runtime 是包含了 helpers 和 polyfill。而現在的 @babel/runtime 只包含 helper,如果需要 polyfill,則需主動安裝 core-js 的 runtime版本 @babel/runtime-corejs2 。並在 @babel/plugin-transform-runtime 的插件中做配置。

說重點: 配置

這是本文的重點,先來看一段 babel7 對配置的變更說明

Babel has had issues previously with handling node_modules, symlinks, and monorepos. We've made some changes to account for this: Babel will stop lookup at the package.json boundary instead of looking up the chain. For monorepo's we have added a new babel.config.js file that centralizes our config across all the packages (alternatively you could make a config per package). In 7.1, we've introduced a rootMode option for further lookup if necessary.

段落的意思大概有這麼幾點:

  • Babel將停止在package.json邊界查找而不是查找鏈。譯者注:這說明以前babel會遞歸向上查找babelrc 而現在檢索行爲會停在package.json所在層級。這可以解決部分符號鏈接的js向上查找babelrc錯亂的問題。
  • 添加了一個新的項目全局babel.config.js文件,可以將整個項目的配置集中在所有包中。譯者注:除了新增的這個全局配置,也可以同時支持以前的基於文件的.babelrc的配置
  • 引入了一個rootMode選項,以便在必要時按一定策略查找 babel.config.js

除此之外,babel7 還有一個特性是:

  • 默認情況下,不會加載monorepo項目的任何獨立子項目中的 .babelrc 文件

然而,對上面的解釋,你可能: 每個字都認識,連在一起卻不知道在說什麼。下面我們來剖析一下

概念

爲了理解 babel7 的配置邏輯,我們就以 babel7 真正所解決的痛點 [monorepo 類型的項目] 爲例來剖析。在此之前,我們需要預先確定幾個概念。

  • monorepo。這是個自造詞。按我的理解,它的含義是說 單個大項目但是包含多個子項目 的含義。如果還是不能理解的話,就把 項目 二字 換成 npm模塊包 (以package.json文件作爲分界線)。即 單個npm包中又包含多個子npm包 的項目。
    例如,波洞的 PC 版採用的是 Node.js 作爲前端接入層的方式,在我們的項目結構組織上,是這樣的:

    |- backend
      |-package.json
    |- frontend
      |-package.json
    |- node_modules
    |- config.js
    |- babel.config.js
    |- package.json

    這就是典型的 monorepo 結構。

  • 全局配置。在 babel 文檔中又叫 項目級別的配置,特指 babel.config.js。如上圖的monorepo結構,其 babel.config.js 就是全局配置/項目配置,該 babel 配置對 backend、frontend、甚至 node_modules 中的模塊全部生效。
  • 局部配置。在 babel 文檔中可能叫 相對於文件的配置。這種配置就是特指的 .babelrc 或 .babelrc.js 了。他們的生效範圍是與待編譯文件的位置有關的。

規則

懂了幾種配置文件的概念和作用範圍之後,我們就可以來根據文檔和代碼測試結果來精確描述 babel7 的配置規則。這裏我們直接以 monorepo 類型項目爲例來說,因爲普通項目會更簡單。

下文中可能用到的名詞解釋:

我們用 package 來代指一個具有獨立 package.json 的項目,如上面案例中的 frontend 可以稱作一個 package,backend也可以稱作一個package; 我們用 相對配置 這個名詞來表達所謂的 .babelrc 和 .babelrc.js,用全局配置來代指 babel.config.js這份配置

對monorepo類型項目,babel7 的處理邏輯是:

【全局配置】全局配置 babel.config.js 裏的配置默認對整個項目生效,包括node_modules。除非通過 exclude 配置進行剔除。
【全局配置】全局配置中如果沒有配置 babelrcRoots 字段,那麼babel 默認情況下不會加載任何子package中的相對配置(如.babelrc文件)。除非在全局配置中通過 babelrcRoots 字段進行配置。
【全局配置】babel 全局配置文件所在的位置就決定了你的項目根目錄在哪裏,默認就是執行babel的當前工作目錄,例如上面的例子,你在根目錄執行babel,babel才能找到babel.config.js,從而確定該monorepo的根目錄,進而將配置對整個項目生效
【相對配置】相對配置可被加載的前提是在 babel.config.js 中配置了 babelrcRoots. 如 babelrcRoots: ['.', './frontend'],這表示要對當前根目錄和frontend這個子package開啓 .babelrc 的加載。(注意: 項目根目錄除了可以擁有一個 babel.config.js,同時也可以擁有一個 .babelrc 相對配置)
【相對配置】相對配置加載的邊界是當前package的最頂層。假設上文案例中要編譯 frontend/src/index.js 那麼,該文件編譯時可以加載 frontend 下的 .babelrc 配置,但無法向上檢索總項目根目錄下的 .babelrc

實戰

還是以上面的代碼結構爲例。

|- backend
  |-package.json
|- frontend
  |-package.json
|- node_modules
|- config.js
|- babel.config.js
|- package.json

該案例中,我們思考發現,我們需要利用 babel7 的全局配置能力。原因在於,monrepo 中存在多個 子 package。由於 babel7 默認檢索 babelrc 的邊界是 當前package。因此每個package中撰寫的babelrc只會對當前package生效,這會導致我們的frontend中依賴根目錄的config.js時無法得到正確的編譯;另一個問題是: frontend和backend中的相同的babel配置部分無法共享 存在一定冗餘。爲此,我們需要在項目根目錄設置一個 babel.config.js的配置,用它再配合babelrc來做babel配置的共享和融合。

但是,問題很快來了:當工作目錄不在根目錄時,無法加載到全局配置。我們的前端編譯腳本通常放置在 frontend目錄下,(我們執行編譯的工作目錄是在 frontend 中),此時 babel build 行爲的 工作目錄 便是 frontend. 由於 babel 默認只在當前目錄尋找 babel.config.js 這個全局配置,因此會導致無法找到根目錄的 babel.config.js,這樣我們所設想的整個項目的全局配置就無法生效。 幸好,babel7 提供了 rootMode 選項,可以將它指定爲 "upward", 這樣babel 會自動向上尋找全局配置,並確定項目的根目錄位置。

設置方法:

CLI: babel --rootMode=upward

webpack: 在 babel-loader 的配置上設置 rootMode: 'upward'

現在,全局配置有了,我們可以在裏面配置 babel 轉譯規則,它可以對全項目生效,frontend下的 vue.js 編譯自然沒有問題了。

不過,假設我們 backend 項目中也要使用 babel 轉譯(目前我們實際在 backend 中並沒有使用,因爲我們認爲只圖esmodule而多加一層編譯得不償失),那麼必然 backend 與 frontend 中的編譯配置是不同的,frontend 需要加載 vue 的 jsx 插件和polyfill (useBuiltIns: usage,modules: false),而backend只需要轉譯基本模塊語法(modules: true, useBuiltIns: false)。該場景的解決方案便是,爲每個子 package 提供獨立的 .babelrc 相對配置,在全局 babel.config.js 中設置共用的配置。此時項目組織結構如下:

|- backend
  |- .babelrc.js
  |-package.json
|- frontend
  |- .babelrc.js
  |-package.json
|- node_modules
|- config.js
|- .babelrc.js // 這份配置在本場景下不需要(如果根目錄下的代碼有區別於子package的babel配置,則需要使用)
|- babel.config.js
|- package.json

根目錄的 babel.conig.js 配置應該如下:

const presets = [
 // 根、frontend、backend 的公共預設
]
const plugins = [
 // 根、frontend、backend 的公共插件
]

module.exports = {
  presets,
  plugins,
  babelrcRoots: ['.', './frontend', './backend'] // 允許這兩個子 package 加載 babelrc 相對配置
}

以爲此時已經高枕無憂了?navie,由於我們前端 Vue.js 採用 webpack 打包。實際開發過程中發現,這種配置會造成 webpack 打包模塊時出現故障,故障原因在於:同一個模塊中錯誤混用 esmodule 和 commonjs 語法會造成 webpack故障

前文講到 全局配置 global.config.js 會作用到 整個項目,甚至包括 node_modules。因此babel編譯時會同時編譯 node_modules 下的模塊,雖然模塊作者不可能在一個js文件中混用不同模塊語法,但他們作爲釋出包 通常是commonjs的模塊語法。 而preset-env預設在編譯時會通過 usage 方式 默認注入import語法的 polyfill

Since Babel defaults to treating files are ES modules, generally these plugins/presets will insert import statements

這便是蛋疼的來源:babel加載過的node_modules模塊會變成 同一個js文件裏既有commonjs語法又有esmodule語法。

解決方案:不要對 node_modules 下的模塊採用babel編譯。我們需要在 babel.config.js 配置中增加選項:

exclude: /node_modules/

總結

至此,我們的 monorepo 項目就可以使用一份 全局配置+兩份相對配置,實現分別對 前端和後端 進行合理的ES6+語法的編譯了。這是我們配置工程師的一小步,但是前端走向未來語法的一大步。

總結 babel7 的配置加載邏輯如下:

  • babel.config.js 是對整個項目(父子package) 都生效的配置,但要注意babel的執行工作目錄。
  • .babelrc 是對 待編譯文件 生效的配置,子package若想加載.babelrc是需要babel配置babelrcRoots纔可以(父package自身的babelrc是默認可用的)。
  • 任何package中的babelrc尋找策略是: 只會向上尋找到本包的 package.json 那一級。
  • node_modules下面的模塊一般都是編譯好的,請剔除掉對他們的編譯。如有需要,可以把個例加到 babelrcRoots 中。
  • 雖然寫的很亂,但您有收穫嗎,有的話點個贊吧.
  • 或許你還沒有看明白。沒關係,知道最終的配置代碼怎麼粘貼就好了~
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章