跳至主要内容

[note] 建立公司內部使用的 eslint-config package

在現今的 JavaScript 專案中,為了確保程式碼的品質和撰寫風格,ESLint 的設定和使用幾乎可以算是標配。

透過 ESLint 除了可以避免掉許多不必要的程式語法錯誤,還能確保開發者彼此之間有一致的程式撰寫風格(coding style),才不會 A 開發者寫出來的程式碼和 B 開發者寫出來的,在撰寫風格上有太大的落差。

為什麼要建立 OneDegree 自己的 eslint-config package

eslint-config package 指的就是把 ESLint 設定檔,打包成一個 npm 套件,這裡面包含了要套用那些規則、套用這些規則的邏輯等等,目的則是讓不同專案能夠直接套用。幾個大家可能聽過的像是 eslint-config-standard、eslint-config-airbnb、eslint-config-prettier、eslint-config-google、...等等,都是包好的 ESLint 設定檔,讓想要套用的開發者可以直接下載使用。

一般來說,每個專案都會有獨立的一隻 ESLint 設定檔,可以針對不同專案進行設定就好,不需要額外抽成一個套件。然而,在 OneDegree 中有許多不同的專案同時在進行,包含 B2B 和多個 B2C 專案,如果每次都要在各個專案中複製貼上相同的設定,會是相當麻煩的一件事;另外,如果在專案中有針對部分規則進行個別的微調,久了之後可能會忘了原始套用的設定是長什麼樣子,甚至發生不同的專案套用的設定差異過大的情況。

這時候如果可以把 ESLint 中的設定打包成一個套件,未來新開專案時只需要使用 npm 安裝這個套件後,就可以套用到公司內部一致的設定,將會省下非常多不必要的麻煩。

認識 ESLint 中的幾個設定欄位

實際上要建立 eslint-config 的套件並沒有太大的難度,因為本質上就是把寫好的 ESLint 設定進行匯出而已。比較需要釐清的反而是先了解和釐清 ESLint 設定檔中的幾個欄位。下面是一隻 ESLint 設定檔常見的欄位:

// .eslintrc.js
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: ['plugin:react/recommended', 'airbnb'],
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 12,
sourceType: 'module',
},
plugins: ['react'],
rules: {},
};

其中包含了:

  • env:讓 ESLint 知道那些是原本就存在專案中的全域變數(global variable),例如,alertwindow 等等,否則當你在專案中直接使用了這些全域變數卻又沒有定義的話,ESLint 就會認為這個變數不存在而報錯。

  • parserOptions:告訴 ESLint 專案中所使用的 JavaScript 語法版本(例如,ECMAScript6、ECMAScript7)。

  • rules:透過這個欄位的設定,可以讓 ESLint 知道當不同的規則觸發時,ESLint 要用什麼類型的方式給予提示,是要當成是 error、warning、或是不用理會。例如,在這裏可以設定,假如開發者在程式中定義了變數卻沒有使用到這個變數時,要當成是嚴重的錯誤(error),出現警告的提示(warning)就好,或是可以完全不用理會這個情況(off)。

除了這幾個欄位之外,比較容易混淆的則是 plugins 和 extends 這兩個欄位,因此會花多一些的篇幅來描述。

plugins:對應到的是一系列定義好的規則,但不包含如何使用這些規則

plugins 是一系列由其他開發者撰寫好的規則,讓使用的人可以把這些規則載入到 ESLint 中使用,但要留意的是:「它只是定義好的規則,並沒有說明要如何使用這些規則」,也就是說,plugin 只會載入規則,但不會說明這些規則發生時到底要被 ESLint 判斷成是 error、warning、還是可以不用理會,這個部分一樣是要在 rules 欄位中設定。

常見 eslint plugin 的套件像是 eslint-plugin-react、eslint-plugin-import、eslint-plugin-vue、eslint-plugin-prettier、eslint-plugin-jest、...等等。

eslint-plugin-react 為例,可以看到這裡定義好了一系列可以被 ESLint 使用的規則:

eslint-plugin-react

在這些 plugin 中都有定義了許多不同的規則可以載入到 ESLint 中,再由開發者自行針對這些規則,透過 rules 欄位來決定嚴重程度。

從這些 eslint plugin 的套件中可以看到,每個套件的前綴都是一樣的,都是以 eslint-plugin-* 當作開頭,因此當我們在 ESLint 的設定檔中,想要啟用這個 plugin 的規則時,可以省略掉這個前綴。

例如,在上面範例的 .eslintrc.js 中,可以看到 plugins 欄位只寫了 react,而實際上它完整的套件名稱是 eslint-plugin-react,eslint-plugin-* 的部分可以省略:

{
plugins: [
// eslint-plugin-react 的縮寫
'react',
],
}

當我們掛入 plugins 時,其實就只是把許多定義好的規則載入到 ESLint 中,但這些規則要怎麼被使用並不包括在 plugins 的範疇內,需要透過 rules 欄位,設定每個規則要如何被使用後才算真的有效。

extends:對應到的是 ESLint 的設定檔(eslint-config)

最後來說明 extends 這個欄位,extends 的功能其實很單純,就是把其他開發者寫好的 eslint-config(ESLint 設定檔)給載入進來。

舉例來說,上面範例的 .eslintrc.js 就是一個 ESLint 的設定檔,如果其他開發者想要使用同樣的設定,除了用複製貼上的方式,把同樣的設定貼到自己的 ESLint 設定檔之外,也可以透過用 extends 這個欄位,直接套用別人寫好的設定,ESLint 就會把對應的設定也都載入到你專案的 ESlint 設定檔中。

也就是說,我們可以先建立了公司內部要共用的 ESLint 設定檔後,在不同專案的 extends 欄位都去載入這個共用的 ESLint 設定檔後,這些設定以及建立好的規則判斷(哪些規則要顯示為嚴重、哪些是警告、哪些可以不用理會)就會自動套用到專案當中,如此就可以避免需要重複複製貼上的問題。

常見的 ESLint 設定檔像是文章開頭提到的 eslint-config-standard、eslint-config-airbnb、eslint-config-prettier、eslint-config-google,同樣的,你會發現它們也都有一樣的前綴,都是以 eslint-config-* 開頭,因此在使用時可以省略掉 eslint-config- 的前綴,像是這樣:

{
extends: [
'plugin:react/recommended',
'airbnb',
],
}

這裡 extends 了 airbnb,其實指的就是載入 eslint-config-airbnb 的 ESLint 設定檔。

在這裏我們還看到另外也載入了一個名為 plugin:react/recommend 的 ESLint 設定檔,之所以前面是以 plugin:react 開頭,是因為這隻設定檔實際上是放在 eslint-plugin-react 中(注意,是 eslint-plugin-react而不是 eslint-config-react 喔!)。

為什麼要把 React 的 ESLint 設定檔放在 eslint-plugin-react 中,而不是獨立成 eslint-config-react 呢?

這呼應到前面有提到的,plugins 本質上只是用來定義一系列的規則,但這些規則怎麼被使用並不在 plugin 的範疇內。可是,既然都把規則定義好了,何不乾脆幫大家把怎麼使用這些規則的建議設定也一併提供在 plugin 的專案中 ,方便大家下載 plugin 後就可以直接使用呢?這也就是為什麼在 eslint-plugin-react 中,還有 recommended 這個 eslint-config 可以使用。

eslint-plugin-react 本身是 ESLint 的 plugin,但套件裡同時提供了 plugin:react/recommended 這個 ESLint 設定檔(eslint-config)讓開發者使用。

當你在 extends 欄位中載入了 plugin:react/recommended 後,就會連帶的把使用這些規則的建議設定也載入到你的專案當中:

plugin/recommended

extends 和 plugin 的作用不同,但很容易搞混的原因也在這裡。

簡單來說,plugin 定義的是一系列可以被使用的規則,雖然不是必須,但它經常也會順便提供許多建議可以怎麼使用這些規則的設定,讓開發者可以直接透過 extends 來套用。像是大家常聽到的 prettier 也是透過這樣的方式,它先定義好一系列的規則後放在 eslint-plugin-prettier 中,而在這個 plugin 的套件裡也順便提供了建議的 ESLint 設定讓開發者能夠套用,所以你可能經常會看到這樣的 ESLint 設定檔:

// .eslintrc.js
module.exports = {
plugins: ['react', 'jest', 'prettier'],
extends: [
'plugin:react/recommended',
'plugin:jest/recommended',
'plugin:prettier/recommended',
],
};

都是類似的道理。

plugins 提供許多的規則,讓開發者可以自行設定要如何使用這些規則,而 plugin 的作者也可能一併提供了他認為合理的設定檔讓開發者可以放在 extends 中直接使用,但這並不是強制的。

有些時候你可能會看到下面這種只有 extends 卻沒有載入 plugins 的情況而感到困惑:

// .eslintrc.js
module.exports = {
plugins: [],
extends: [
'plugin:react/recommended',
'plugin:jest/recommended',
'plugin:prettier/recommended',
],
};

之所以可以這樣寫,是因為通常在這些 extends 的 ESLint 設定檔中,都已經幫你把使用到的 plugin 給定義好,所以你只要確保有安裝了這些 plugin,即使沒有在專案的 ESLint 設定檔中明確透過 plugins 定義這些 plugin,它們還是會被載入。

例如,你可以在 eslint-plugin-react 的 recommended config 中看到,它已經幫你把 plugins: ['react'] 寫好了:

eslint-plugin-react/recommended

因此一旦你有使用 extends: ['plugin:react/recommended'] 載入這個設定檔,它就已經幫你在 plugins 的地方載入 eslint-plugin-react。

實作 OneDegree 內部的 ESLint config

在了解 ESLint 中 plugin、extends 和 rules 的概念後,就可以知道,我們只需要先建立好一隻可以被共用的 ESLint 設定檔,在這裡面定義好各專案都希望遵循的規則及套用規則的邏輯後,接著只要在各個專案中,各自透過 extends 的方式,載入這個共用的設定檔,就可以達到預期的效果。

建立共用的 eslint 設定檔

以程式碼的概念會像是這樣子。

先建立一支準備被共用的 ESLint 設定檔,例如這裡取名叫 base.eslint.config.js

// base.eslint.config.js
module.exports = {
env: {
es6: true,
},
extends: ['plugin:react/recommended', 'plugin:react-hooks/recommended'],
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 12,
sourceType: 'module',
},
plugins: [],
rules: {
// new JSX transform
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off',

// customized rules
'no-console': 'off',
},
};

接著在專案本身的 ESLint 設定檔中,透過 extends 的方式去讀取到這支共用的設定檔就可以了。

假設這支共用的設定檔和專案自身的 ESLint 設定檔放在相同的路徑時,可以直接這樣載入:

// .eslintrc.js
// 專案本身的 ESLint 設定檔
module.exports = {
extends: ['./base.eslint.config'],
};

如此這個專案就會載入並套用這支共用的設定。

如果要確定專案有沒有實際吃到 extends 的內容,可以透過下列指令,複製專案中 ESLint 最終實際會套用到的設定來瞧瞧:

# 檢視並複製當前套用到的 eslint config
$ npx eslint --print-config path::String | pbcopy

可以看到,雖然專案本身的 ESLint config 只有短短一行 extends: ['./base.eslint.config'],但透過上述的指令,可以看到實際套用的設定非常多:

extends eslint config

除了可以看到有套用了幾個 plugins 之外,我們寫在 base.eslint.config.js 中的設定(例如:no-console 的規則邏輯)也有出現在最終實際會被 ESLint 使用到的設定中。

根據需要建立不同類型的設定檔

由於共用的設定檔本身也只是 JavaScript,因此不需要把所有的設定都放在同一支 base.eslint.config.js 中,而是可以透過 JavaScript 模組的方式來進行管理。

例如,可以有一支 base 的設定檔:

// base.eslint.config.js
module.exports = {
env: {
es6: true,
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 12,
sourceType: 'module',
},
rules: {
// ...
},
};

接著在建立一隻專門給 React 專案使用的,在這支 react.eslint.config.js 中,會先去 extends base 的設定,把剛剛寫的設定檔載入後,再加上針對 React 專案要套用的規則:

// react.eslint.config.js
module.exports = {
extends: [
// 載入 base 的設定
'./base.eslint.config',

// 套用針對 react 想要使用的設定
'plugin:react/recommended',
'plugin:react-hooks/recommended',
],
rules: {
// new JSX transform
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off',
},
};

針對 TypeScript 的專案,可以同樣 extends base 的設定後,針對 TypeScript 的部分進行設定:

// typescript.eslint.config.js
module.exports = {
extends: ['./base.eslint.config'],
plugins: ['@typescript-eslint'],
parser: '@typescript-eslint/parser',
rules: {
'no-param-reassign': [
'error',
{
props: true,
ignorePropertyModificationsFor: ['state'],
},
],
},
};

如此,未來如果新開的是 react 的專案,該專案可以直接 extends react.eslint.config.js;如果是 TypeScript 的專案,則可以 extends typescript.eslint.config.js 即可。

包成 npm 套件

實際上,我們不只是會建立共用的 ESLint 設定檔,還會把這個共用的 eslint 設定檔發佈到 npm 上,就像是前面提到的 eslint-config-standard 或 eslint-config-airbnb 一樣,讓想要套用這個設定檔的專案只需要透過 npm 就可以安裝對應的設定檔,並且透過 extends 的方式來載入。

以 OneDegree 為例,我們把共用的 eslint 設定檔包成一個名為 eslint-config-onedegree 的套件,未來開新專案的時候,只需透過 npm install eslint-config-onedegree 下載設定檔後,接著在專案本身的 ESLint 設定檔中去 extends 它就可以了:

// .eslintrc.js
module.exports = {
extends: ['onedegree'], // 套件名稱開頭的 "eslint-config-*"" 可以省略
};

如果是針對特定類型的專案一樣可以像這樣進行 extends:

// .eslintrc.js
module.exports = {
extends: ['onedegree/react'], // 如果是針對 react 的專案
};

或是針對 TypeScript 的專案:

module.exports = {
extends: ['onedegree/typescript'], // 如果是針對 typescript 的專案
};

註:在 OneDegree 中,公司內部有自己的 npm registry,因此佈署時,是發佈到公司內部 host 的 npm registry。

規則使用的邏輯是可以被覆蓋的

另外因為 extends 的概念是載入另一個設定檔進來,如果有需要根據專案客製化調整的話,還是可以在專案本身的 eslint 設定檔透過 rules 進行覆蓋和調整。

舉例來說,雖然在 eslint-config-onedegree 預設針對 no-console 這個規則是警告(warning),但根據專案個別的需要,還是可以覆蓋和調整:

// .eslintrc.js
module.exports = {
extends: ['onedegree'],

// 雖然在 eslint-config-onedegree 預設的規則是 warning,但可以透過覆蓋的方式調整
rules: {
'no-console': 'error',
},
};

一樣透過先前提供的指令,複製最終執行的 ESLint 設定檔後,可以看到後最終出來的設定會是這樣:

eslint override rule

OneDegree 內部使用的一些規則設定

最後來分享一些 OneDegree 內部使用的 ESLint 設定。

首先,因為 OneDegree 中有部分專案是從 JavaScript 導入成 TypeScript 的,因此針對 TS 的檔案我們是使用 ESLint 提供的 overrides 欄位來進行規則覆蓋,也就是 TS 的設定只會套用在以 .ts.tsx 為副檔名的檔案,而不會套用到 JS 的檔案,如此確保在專案中 JS 和 TS 的檔案可以共存:

// typescript.eslint.config.js
module.exports = {
extends: ['./base'].map(require.resolve),

// 使用 overrides 欄位,overrides 中的 ESLint 的設定只會套用到 .ts 或 .tsx 的檔案
overrides: [
{
files: ['**/*.ts?(x)'],
extends: ['eslint-config-airbnb-typescript'].map(require.resolve),
parserOptions: {
// typescript-eslint specific options
warnOnUnsupportedTypeScriptVersion: true,
},
rules: {
// ...
},
},
],
};

另外針對 TypeScript 的 enum 型別,會限制只能用大寫字母搭配底線來進行定義:

// typescript.eslint.config.js
module.exports = {
// ...
rules: {
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'enum',
format: null,
custom: {
// enum should be uppercase and snake case and allow double underscore
regex: '^[A-Z][A-Z0-9]*(__?[A-Z0-9]+)*$',
match: true,
},
},
],
},
};

以及內部一些開發上的慣例,也會建在 ESLint 中。像是針對型別沒有一定要在檔案中的上方先定義後,才能在下方被使用...等等:

// typescript.eslint.config.js
module.exports = {
// ...
rules: {
'@typescript-eslint/no-use-before-define': ['error', { ignoreTypeReferences: true }],
// ...
},
};

針對 TypeScript 的 React 元件,因為已經有透過 TypeScript 進行 props 的定義,就可以把原本的 react/prop-types 的規則給關掉:

// typescript.eslint.config.js
module.exports = {
// ...
rules: {
'react/prop-types': 'off',
'no-restricted-imports': [
'error',
{
name: 'react',
importNames: ['default'],
message: "use import { xxx } from 'react'; instead",
},
],
},
};

另外,為了避免在 React 專案中,有人使用 named import,有人使用 default import,導致程式碼中會同時出現這兩種不同的 pattern:

// named import pattern
import { useState } from 'react';
const [foo, setFoo] = useSate();

// default import pattern
import React from 'react';
const [bar, setBar] = React.useState();

一樣可以透過 ESLint 的設定來達到一致的撰寫規範:

module.exports = {
// ...
rules: {
'no-restricted-imports': [
'error',
{
name: 'react',
importNames: ['default'],
message: "use import { xxx } from 'react'; instead",
},
],
},
};

總結

透過把公司內部共用的 ESLint 設定檔包成 npm 套件,後續新開的專案都可以直接透過 ESLint 的 extends 欄位來共用設定,就可以有效解決新專案每次都要先複製重複的 ESLint 設定,也可以避免各專案間彼此 ESLint 設定差異過大的麻煩。此外,如果公司有什麼新規則希望可以套用到各個專案中時,也只需要去修改這個在 npm 上共用的 eslint-config package 就可以了,而不需要每個專案都去改動。

參考資料