【Vue.js】【ESLint】スコープ付きCSSの利用されていないセレクターを検出するESLintプラグイン作った

この記事は、[Vue Advent Calendar 2019 #1](https://qiita.com/advent-calendar/2019/vue) の14日目の記事です。

---

こんにちは。社内ではLintおじさんという二つ名を襲名していて、@ota-meshiという冷やかし感満載なアカウントで割と真面目にやってるつもりの者です。

本記事では、社内でこそこそ作っていたVue.jsのスコープ付きCSS用のESLintの拡張ルールの一部を先日npmで公開したので、その紹介とどんな感じで作ったのかを書こうと思います。 

まず、公開したものは以下です。

- ドキュメント [eslint-plugin-vue-scoped-css](https://future-architect.github.io/eslint-plugin-vue-scoped-css/)
- [npm - eslint-plugin-vue-scoped-css](https://www.npmjs.com/package/eslint-plugin-vue-scoped-css)
- [GitHub - future-architect/eslint-plugin-vue-scoped-css](https://github.com/future-architect/eslint-plugin-vue-scoped-css)


下記リンクからブラウザ上で試すことができます。
https://future-architect.github.io/eslint-plugin-vue-scoped-css/playground/


# 特徴

Vue.jsの単一ファイルコンポーネントで利用できる、[スコープ付きCSS]関連のLintルールを提供しています。

# 動機

[eslint-plugin-vue]という`.vue`ファイルをLintできる素晴らしいESLintプラグインがありますが、これはCSS（`<style>`ブロック）関連の情報は関知しません。
また、`.vue`ファイルでも利用できるCSSのリンターである、[stylelint]という素晴らしいツールもありますが、こちらはCSS以外の情報（例えば`<template>`ブロック）は関知しません。
これらのツールで実現できない、Vue.jsの[スコープ付きCSS]特有の静的検証を行いたかったというのが、このESLintプラグインを作成した動機です。

（[stylelint]プラグインで作成しても良かったんじゃないの？というのはありますが、`.vue`ファイル用の神パーサーである[vue-eslint-parser]を使いたかったのもありESLintプラグインで作成しました。）

# 使用方法

## インストール

npmでインストールします。

```bash
npm install --save-dev eslint eslint-plugin-vue-scoped-css
```

## 設定

詳しくは以下を参照してください。

> https://future-architect.github.io/eslint-plugin-vue-scoped-css/user-guide/#usage

このプラグインは設定構成を提供しているので、[ESLint - Using the configuration from a plugin](https://eslint.org/docs/user-guide/configuring#using-the-configuration-from-a-plugin)にある方法を利用して、（例えば`.eslintrc.js`であれば）以下のように設定して利用できます。

```js:.eslintrc.js
module.exports = {
  extends: [
    // ...
    // ... 既にあなたの利用している設定
    // ...
    // 下記を追加
    'plugin:vue-scoped-css/recommended'
    // ルールを自分で個別に設定したい場合は下記を追加
    // 'plugin:vue-scoped-css/base'
  ],
  rules: {
    // 個別にルールを設定する場合は下記のように追加。
    // 'vue-scoped-css/no-unused-selector': 'error'
  }
}
```

# 提供するLintルール

## [vue-scoped-css/no-unused-selector]

このプラグインの目玉機能です。（というかこのルールを作りたくてこのプラグインを作り始めました。）
`<style scoped>`内のCSSセレクターの内、`<template>`ブロックで利用されていないCSSセレクターを検出します。

このルールによって、リファクタリングしていくうちに使われなくなったのにウッカリ残してしまって、利用していない無駄なCSSをスッキリ消していくのに役立ちます。

```vue
<template>
  <div id="foo">
    <input class="bar">
  </div>
</template>
<style scoped>
/* ✗ BAD */
ul,
.foo,
#bar {
}

/* ✓ GOOD */
div,
#foo,
.bar {
}
</style>
```

## [vue-scoped-css/no-unused-keyframes]

こちらは、利用していない`@keyframes`を検出します。

たまに勘違いする人がいると思うのですが（自分も勘違いしていました）、`<style scoped>`内で宣言した`@keyframes`は、その`<style scoped>`内でしか利用できません。
なので、`<style scoped>`内で宣言した`@keyframes`は、その`<style scoped>`内で利用すべきです。利用しない場合は、無駄なデッドコードとなります。

```vue
<style scoped>
.item {
    animation-name: slidein;
}

/* ✗ BAD */
@keyframes fadein {
}

/* ✓ GOOD */
@keyframes slidein {
}
</style>
```

## [vue-scoped-css/require-scoped]

`scoped`が付与されていない、`<style>`タグを検出します。

私のような、[スコープ付きCSS]大好き人間が`<style scoped>`を強制したい場合に利用できます。
唯一`App.vue`にだけ`scoped`無しの`<style>`を許可したいような場合は、`.eslintrc.**`をうまいこと構成・配置するか、`<script>`内に[eslintの構成コメント](https://eslint.org/docs/user-guide/configuring#using-configuration-comments)を利用して除外するといいと思います。

```vue
<!-- ✗ BAD -->
<style>
</style>

<!-- ✓ GOOD -->
<style scoped>
</style>
```

このルールはESLint v6.7で追加された[Suggestions API](https://qiita.com/mysticatea/items/377b6ca6558dc253159b#suggestions-api)に対応していまして、Suggestionから`scoped`属性を追加できるようにしています。[VSCodeが対応したらクイックフィックスから選択できるように](https://github.com/microsoft/vscode-eslint/pull/814)なるかもしれません。

## [vue-scoped-css/require-selector-used-inside]

先に紹介した[vue-scoped-css/no-unused-selector]のもっと強制する版です。
`<style scoped>`内のCSSセレクターの内、`<template>`ブロックで利用されていないCSSセレクターを検出します。
こちらのルールは定義したCSSセレクターの各セレクター要素が全て`<template>`ブロックで利用されていない場合、レポートされます。

どういうことかと言いますと、

```vue
<template>
  <div>
    <input class="foo">
  </div>
</template>
<style scoped>
.theme .foo {}
</style>
```

とあった場合、`.theme .foo`の`.foo`は利用できる場合があります。
`theme`クラスをページのルート要素に付与して`.foo`のスタイルを変更するみたいなことができます。（この使い方が想定されているかどうかは知らないです。）
そのため、[vue-scoped-css/no-unused-selector]では検出しません。
いや、そんな使い方しないよ！やめてよ！という人は[vue-scoped-css/require-selector-used-inside]を有効にすると、全てのセレクター要素が`<template>`ブロックで利用されていない場合にエラーになります。

## [vue-scoped-css/no-parsing-error]

CSSのパースエラーを報告します。

パースエラーは[stylelint]とか使っておけば確認できるので必要ないんですけど、このプラグインのルール達がパースエラーで動作しないとき、「パースエラーだから動かないんだよ」ってことに気がつくためのルールです。通常は不要なルールです。

# 構文サポート

CSSとSCSSとStylusをサポートしています。
他は要望があればやるかもしれません。lessは全く知らないので誰か助けて。

# 作り

機能的な紹介は以上です。ここから先は作った時の思い出です。

## CSSとセレクターのパース

CSSのパースには[PostCSS]を利用しています。
そして、SCSSのパースには[postcss-scss]を使っています。
そして、セレクターのパースには[postcss-selector-parser]を使っています。
[PostCSS]ファミリー万歳です。


あと、Stylusのパースには「[VueファイルのStylusをstylelintしたかった話]」の時に作った、[postcss-styl]を使っています。

## セレクターのネストの解決

自作しました。車輪の再発明感ハンパないのですが、正しい箇所にエラーをレポートしたかったので、ここは自作に踏み切りました。
CSSの`&`セレクター・`@nest`・SCSSのネストに対応しているはずです。一応[Stylusのセレクター](http://stylus-lang.com/docs/selectors.html)も一部対応してます。

## CSSセレクターと`<template>`のマッピング

[vue-scoped-css/no-unused-selector]とかやるために`<template>`内のタグを走査します。
これがなかなか大変でした。全部書くと長いので（既に長いですが）`class`属性についてだけ思い出を残します。

### 静的`class`属性のマッピング

これは余裕です。普通のHTML的に探し出すだけです。

```vue
<template>
  <div class="foo" /> <!-- そのまま書いてあるので比較的簡単 -->
</template>
```

### `v-bind:class`のインライン式のマッピング

ちょっと大変でしたが、神パーサー[vue-eslint-parser]がいい感じのASTを返してくれるのでまだなんとかなります。

```vue
<template>
  <div v-bind:class="['foo', {'bar': true}]" /> <!-- ASTの解析が必要 -->
</template>
```

### `v-bind:class`へのプロパティのマッピング

そろそろきついです。Vueオブジェクト探し出して、ASTを走査し、`data`と`computed`のプロパティを探して、`return`部分を解析して頑張りました。

```vue
<template>
  <div v-bind:class="classes1"> <!-- dataを見ると`foo`が入る -->
    <div v-bind:class="classes2" /> <!-- computedを見ると`bar`が入る -->
  </div>
</template>
<script>
export default {
  data () {
    return {
      classes1: ['foo']
    }
  },
  computed: {
    classes2 () {
      return [{'bar': true}]
    }
  }
}
</script>
```

### `v-bind:class`への文字列結合のマッピング

きついです。テンプレートリテラルや、文字列結合があった場合は、部分一致で検証しています。

```vue
<template>
  <div v-bind:class="`foo-${kind}`" /> <!-- `foo-bar`は一致するとみなす -->
</template>
<style scoped>
.foo-bar {}
</style>
```

### `classList.add`のマッピング

きついです。classListによるclass操作は`$el`や`$refs`経由での操作を探し出してマッピングしました。

```vue
<template>
  <div> <!-- $elには`classList.add('foo')`されることがある -->
    <div ref="div1"> <!-- ref名`div1`には`classList.add('bar')`されることがある -->
  <div>
</template>
<script>
export default {
  mounted () {
    this.$el.classList.add('foo')
  },
  methods: {
    onClick() {
      this.$refs.div1.classList.add('bar')
    }
  }
}
</script>
```

### 複雑な`v-bind:class`へのプロパティのマッピング

無理でした。`data`や`computed`のプロパティの、`return`部分だけで解決できない情報で、さらに[eslint-utilsの`getStaticValue`](https://eslint-utils.mysticatea.dev/api/ast-utils.html#getstaticvalue)で解決できない情報は諦めて、検出対象から除外しています。

```vue
<template>
  <div v-bind:class="classes" /> <!-- 何が入るかわからないので全てのクラスが一致するとみなす。 -->
</template>
<script>
import CONST from './const-data'

export default {
  computed: {
    classes () {
      return [{[CONST.CLASS_FOO]: true}]
    }
  }
}
</script>
```

# まとめ

と、色々頑張った結果、概ね未使用CSSセレクターを検出することができたと思います。
結構頑張ったので[スコープ付きCSS]使っている方はぜひ使ってみて欲しいです。
もし使ってみて良かったら[GitHubで![Star]](https://github.com/future-architect/eslint-plugin-vue-scoped-css)つけてくれると励みになりますm(_ _)m


# あとがき

このESLint拡張ルールですが、台風で流れた[Vue Fes]のランチ（弊社はランチスポンサーする予定でした）で少し紹介する予定でしたが、行き場を失ったのでこの場で紹介させていただきしました。
来年もVue Fesあるといいなー。

あと。そうそう。僕、今年、Vue.jsのメンバーになりました:tada: 関わってくださった皆様ありがとうござます！これからもお宜しくお願いします！


[スコープ付きCSS]: https://vue-loader.vuejs.org/guide/scoped-css.html
[stylelint]: https://stylelint.io/
[eslint-plugin-vue]: https://github.com/vuejs/eslint-plugin-vue
[vue-eslint-parser]: https://github.com/mysticatea/vue-eslint-parser
[vue-scoped-css/no-unused-selector]: https://future-architect.github.io/eslint-plugin-vue-scoped-css/rules/no-unused-selector.html
[vue-scoped-css/require-selector-used-inside]: https://future-architect.github.io/eslint-plugin-vue-scoped-css/rules/require-selector-used-inside.html
[vue-scoped-css/require-scoped]: https://future-architect.github.io/eslint-plugin-vue-scoped-css/rules/require-scoped.html
[vue-scoped-css/no-unused-keyframes]: https://future-architect.github.io/eslint-plugin-vue-scoped-css/rules/no-unused-keyframes.html
[vue-scoped-css/no-parsing-error]: https://future-architect.github.io/eslint-plugin-vue-scoped-css/rules/no-parsing-error.html
[PostCSS]: https://postcss.org/
[postcss-selector-parser]: https://github.com/postcss/postcss-selector-parser
[postcss-scss]: https://github.com/postcss/postcss-scss
[postcss-styl]: https://github.com/ota-meshi/postcss-styl
[VueファイルのStylusをstylelintしたかった話]: https://qiita.com/ota-meshi/items/aafe3bbef12ac1f25f51
[Vue Fes]: https://vuefes.jp/2019/
[Star]: https://img.shields.io/github/stars/future-architect/eslint-plugin-vue-scoped-css?style=social