開發 eslint 規則

orangexc發表於2018-09-30

前端的日常開發離不開各種 lint 的支援,使用 lint 的一種誤解是:個人能力不足,必須 lint 規範才能寫出規範的程式碼,實際上規範的定義主要取決於開源專案作者的習慣,或者公司團隊編碼的習慣,即使兩個前端專家,寫出的程式碼規範也會有差別。

今天主題聊聊 eslint,作為最主流的 JavaScript lint 工具深受大家喜愛,而 JSHint 卻逐漸淡出了大家的視線,使用的比較少了

常用的 eslint 擴充套件有 standard,airbnb 等

剖析 eslint 擴充套件

擴充套件無非就作兩個事情

  • 在原有的 eslint 的基礎上配置些 config(具體規則引數,全域性變數,執行環境等)
  • 自定義些自己的 rule,以滿足需求

原理就是利用 eslint 的繼承模式,理論上可以無限繼承並覆蓋上一級的規則

第一條不詳細介紹了,eslint 官網說的十分詳細,基本每一條規則都支援自定義引數,覆蓋面也特別廣,基本上所有的語法都有 rule

第二條的自定義 rule 才是本文的重頭戲,因為特殊的業務場景靠 eslint 自身配置已經無法滿足業務需求了,如:

  • eslint-plugin-vue
  • eslint-plugin-react
  • eslint-plugin-jest

一般特殊場景的自定義規則都使用 eslint-plugin-* 的命名,使用時可以方便的寫成

{
  plugins: [
    'vue',
    'react',
    'jest'
  ]
}
複製程式碼

當然 eslint-config-* 同理,不過配置時需要寫成

{
  extends: 'standard'
}
複製程式碼

下面介紹下開發流程

建立 eslint plugin 工程

官方推薦使用 yeoman 生成專案,感覺生成的專案比較守舊,推薦下習慣我的專案結構

eslint-plugin-skr
  |- __tests__
  |  |- rules
  |  |- utils
  |
  |- lib
  |  |- rules
  |  |- utils
  |  |- index.js
  |
  |- jest.config.js
  |
  |- package.json
  |
  |- README.md
複製程式碼

整體看下來發現多了 jest 配置檔案,是的 yeoman 生成的專案預設採用 Mocha 作為測試框架,個人感覺除錯起來麻煩,沒有 jest 靈活,vscode 輕鬆搞定除錯

教程一搜一大把哈,給伸手黨一個連結 debugging-jest-tests

關於 jest 的 config 檔案也po出來一下,都是些基本的配置,複雜的用不到,下面會詳細介紹測試部分

module.exports = {
  testEnvironment: 'node',
  roots: ['__tests__'],
  resetModules: true,
  clearMocks: true,
  verbose: true
}
複製程式碼

自定義的規則全部在 lib/rules 下面,每條規則單獨一個檔案足以

下面一個簡單的例子打通任督二脈

開發一個規則

前期準備

這個官方文件寫的密密麻麻,好幾十個屬性,其實只是冰山一角,有很多複雜場景需要考慮

有人疑問:一定需要精通 AST?

我的回答是當然不需要,簡單瞭解便是,最起碼知道解析出來的語法樹大體結構長什麼樣子

那就隨便給自己一個命題寫吧!寫個超級簡單的

module.exports = {
  meta: {
    docs: {
      description: '禁止塊級註釋',
      category: 'Stylistic Issues',
      recommended: true
    }
  },

  create (context) {
    const sourceCode = context.getSourceCode()

    return {
      Program () {
        const comments = sourceCode.getAllComments()

        const blockComments = comments.filter(({ type }) => type === 'Block')

        blockComments.length && context.report({
          message: 'No block comments'
        })
      }
    }
  }
}
複製程式碼

具體寫法官方文件有介紹哈,就不贅述了,例子也十分簡單,呼叫了環境變數 context 中的方法獲取全部註釋

稍微複雜點的場景

如需要 lint bar 物件中屬性的順序,如下假設一個規則

// good
const bar = {
  meta: {},
  double: num => num * 2
}

// bed
const bar = {
  double: num => num * 2,
  meta: {},
}
複製程式碼

這個第一次些會有些蒙,官網沒有提供具體的例子,解決辦法很簡單,推薦一個利器 astexplorer

點進去彆著急複製程式碼檢視 AST 結果,首先選擇 espree(eslint 使用的語法解析庫),如下

開發 eslint 規則

這短短的四行程式碼會對應著一個抽象語法樹,如下圖:

開發 eslint 規則

由於全展開太長了哈,感興趣的自行嘗試,會發現層級巢狀的特別深,找到 bar 的屬性需要 Program.body[0].declarations[0].init.properties

當然不至於每次都從最頂級的 Program 找下來,從上面的例子可以看出 create 方法的 return 返回的是一個 object,裡面可以定義很多檢測型別,如官網的例子:

function checkLastSegment (node) {
  // report problem for function if last code path segment is reachable
}

module.exports = {
  meta: { ... },
  create: function(context) {
    // declare the state of the rule
    return {
      ReturnStatement: function(node) {
        // at a ReturnStatement node while going down
      },
      // at a function expression node while going up:
      "FunctionExpression:exit": checkLastSegment,
      "ArrowFunctionExpression:exit": checkLastSegment,
      onCodePathStart: function (codePath, node) {
        // at the start of analyzing a code path
      },
      onCodePathEnd: function(codePath, node) {
        // at the end of analyzing a code path
      }
    }
  }
}
複製程式碼

這裡可以使用 VariableDeclarator 型別作為檢察目標,從下面的解析樹可以分析出篩選條件

開發 eslint 規則

VariableDeclarator 物件作為當前的 node

當變數名為 bar,即 node.id.name === 'bar',且值為物件,即 node.init.type === 'ObjectExpression',程式碼如下:

module.exports = {
  meta: { ... },
  create (context) {
    return {
      VariableDeclarator (node) {
        const isBarObj = node.id.name === 'bar' &&
          node.init.type === 'ObjectExpression'

        if (!isBarObj) return

        // checker
      }
    }
  }
}
複製程式碼

就這樣成功取到 bar 物件後就可以檢測屬性的順序了,排序演算法一大把,挑一個喜歡的用就行了,這裡不囉嗦了,直接上結果:

const ORDER = ['meta', 'double']

function getOrderMap () {
  const orderMap = new Map()

  ORDER.forEach((name, i) => {
    orderMap.set(name, i)
  })

  return orderMap
}

module.exports = {
  create (context) {
    const orderMap = getOrderMap()

    function checkOrder (propertiesNodes) {
      const properties = propertiesNodes
        .filter(property => property.type === 'Property')
        .map(property => property.key)

      properties.forEach((property, i) => {
        const propertiesAbove = properties.slice(0, i)
        const unorderedProperties = propertiesAbove
          .filter(p => orderMap.get(p.name) > orderMap.get(property.name))
          .sort((p1, p2) => orderMap.get(p1.name) > orderMap.get(p2.name))

        const firstUnorderedProperty = unorderedProperties[0]

        if (firstUnorderedProperty) {
          const line = firstUnorderedProperty.loc.start.line

          context.report({
            node: property,
            message: `The "{{name}}" property should be above the "{{firstUnorderedPropertyName}}" property on line {{line}}.`,
            data: {
              name: property.name,
              firstUnorderedPropertyName: firstUnorderedProperty.name,
              line
            }
          })
        }
      })
    }

    return {
      VariableDeclarator (node) {
        const isBarObj = node.id.name === 'bar' &&
          node.init.type === 'ObjectExpression'

        if (!isBarObj) return

        checkOrder(node.init.properties)
      }
    }
  }
}
複製程式碼

這裡程式碼有點多,耐心看完其實挺簡單的,大致解釋下

getOrderMap 方法將陣列轉成 Map 型別,方面通過 get 獲取下標,這裡也可以處理多緯陣列,例如兩個 key 希望在相同的排序等級,不分上下,可以寫成:

const order = [
  'meta'
  ['double', 'treble']
]

function getOrderMap () {
  const orderMap = new Map()

  ORDER.forEach((name, i) => {
    if (Array.isArray(property)) {
      property.forEach(p => orderMap.set(p, i))
    } else {
      orderMap.set(property, i)
    }
  })

  return orderMap
}
複製程式碼

這樣 doubletreble 就擁有相同的等級了,方便後面擴充套件,當然實際情況會有 n 個屬性的排序規則,也可以在這個規則上輕鬆擴充套件,內部的 sort 邏輯就不贅述了。

開發就介紹到這裡,通過上面安利的線上語法解析工具可以輕鬆反推出 lint 邏輯。

如果 rule 比較複雜,就需要大量的 utils 支援,不然每個 rule 都會顯得一團糟,比較考驗公共程式碼提取的能力

測試

如前面所講建議使用 jest 進行測試,這裡的測試和普通的單元測試還不太一樣,eslint 是基於結果的測試,什麼意思呢?

lint 只有兩種情況,通過與不通過,只需要把通過和不通過的情況整理成兩個陣列,剩下的工作交給 eslint 的 RuleTester 處理就行了

上面的屬性排序 rule,測試如下:

const RuleTester = require('eslint').RuleTester
const rule = require('../../lib/rules/test')

const ruleTester = new RuleTester({
  parserOptions: {
    ecmaVersion: 6
  }
})

ruleTester.run('test rule', rule, {
  valid: [
    `const bar = {
      meta: {},
      double: num => num * 2
    }`
  ],
  invalid: [
    {
      code: `const bar = {
        double: num => num * 2,
        meta: {},
      }`,
      errors: [{
        message: 'The "meta" property should be above the "double" property on line 2.'
      }]
    }
  ]
})
複製程式碼

valid 中是希望通過的程式碼,invalid 中是不希望通過的程式碼和錯誤資訊,到這裡一個 rule 算是真正完成了。

打包輸出

最後寫好的 rules 需要發一個 npm 包,以便於在專案中使用,這裡就不贅述怎麼發包了,簡單聊聊怎麼優雅的把 rules 匯出來。

直接上程式碼:

const requireIndex = require('requireindex')

// import all rules in lib/rules
module.exports.rules = requireIndex(`${__dirname}/rules`)
複製程式碼

這裡使用了三方依賴 requireindex,對於批量的匯出一個資料夾內的所有檔案顯得簡潔很多。

當然前提是保證 rules 資料夾下都是 rule 檔案,不要把 utils 寫進去哈

總結

行文目的是國內外對於自定義 eslint rule 的相關資源較少,希望分享一些寫自定義規則的經驗。

千萬不要在學習 AST 上浪費時間,不同的庫對 AST 的實現是不同的,下次寫 babel 外掛又要學其它的 AST 規則,再次安利一下 AST 神器 astexplorer,只要把需要驗證的程式碼放到 astexplorer 裡跑一遍,然後總結出規律,邏輯其實十分簡單,對 AST 結果進行判斷就行了。

從團隊層面講,希望所有的團隊都有自己的 eslint 規則庫,可以大大降低 code review 的成本,又能保證程式碼的一致性,一勞永逸的事情。

相關文章