レガシーサービスにおけるパフォーマンス改善 - 使われていないCSSを削除してくれるCLIを作った話

リクルートテクノロジーズ でフロントエンドエンジニアをしている[@SW20_Toshi](https://twitter.com/SW20_Toshi)です。
本記事は[Recruit Engineers Advent Calendar 2019 - Adventar](https://adventar.org/calendars/4502) 20日目の記事です。

皆様はウェブのパフォーマンスを気にしていますか？
おそらく大抵の方はSQLのチューニングやロジックの改良などをした経験があるのではないでしょうか？
今回は、レガシーサービスにおけるパフォーマンス改善として、Puppeteerを使って不要なCSSを削除する取り組みを行いました。
ツールは[OSSとして公開](https://github.com/toshi1127/css-optimization)しているので使ってみてください！

サイボウズ株式会社の[こちらの記事](https://blog.cybozu.io/entry/2019/08/20/170000)には大変お世話になりました！
ありがとうございました！

## きっかけ
10年近くABテストや機能追加を繰り返してきたWebサービス....
<img width="380" alt="スクリーンショット 2019-12-19 14.39.50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218201/13e41311-59fd-8770-efdf-f3b6f212f863.png">

**1画面に大量のCSSファイルが読み込まれていて、カバレッジ関しては目も当てられない酷さ**

パフォーマンス・開発者観点で、次のような問題があげられます。

**パフォーマンス観点**

- カバレッジが低いファイルが複数あり、描画速度が遅くなる
    - CSSの解決のされ方については[この記事](https://qiita.com/mamiyan/items/778183160e9e58546824)がわかりやすいです
- minifyもconcatもできていない

**開発者観点**

- CSSが3万行以上あるため、影響範囲がわからない。
    - 案件に入る前に、リファクタリングが必要なことも...

## やったこと
`css-optimization`という[ツール](https://github.com/toshi1127/css-optimization)を開発し、結果としてCSSを**180KBの削減**に成功しました
<img width="289" alt="スクリーンショット 2019-12-19 14.40.35.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218201/86f861c9-ed0c-42f1-a42f-f742c8cf48e8.png">
light houseのパフォーマンススコアも**大幅に改善**
**before**
<img width="294" alt="スクリーンショット 2019-12-19 11.54.51.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218201/e35f8db7-7e33-18fc-7a0d-72a74c8f2ad3.png">
**after**
<img width="298" alt="スクリーンショット 2019-12-19 11.54.42.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218201/102ed368-665d-bcc9-ae64-fa8934a03500.png">

# 作ったツールについて
そのページの使われているCSSのみを抽出する[css-optimization](https://github.com/toshi1127/css-optimization)というCLIを開発しました。
任意のURLと操作をyamlに記述するだけで、使われているCSSのみを取得することができます。

```yaml

name: demo
# CSSを最適化したいページのURL
url: 'https://hogehoge/fuga/'
# userAgentを指定
userAgent: 'bot'

steps:
  # 良しなにDOM操作をして、モーダルとかを表示
  - action:
      type: hover
      selector: '#js-mylist > div > ul > li:nth-child(2) > div > div > ul'
  - action:
      type: click
      selector: '#js-mylist-myHistory' 
  - action:
      type: wait
      duration: 500
  # 意図した操作が行われているか、スクショをとる
  - action:
      type: screenshot
      name: 'demo'
```
前提条件(ログインなど)を定義することも可能です:v:
ツールを開発しリリースへ向けて作業していく上で、躓いた点などを説明していきたいと思います。

## Puppeteerとは
`puppeteer`とは、GUIを操作することなく、プログラムからAPIでChromeを制御できる ライブラリ です。

## Puppeteerでカバレッジを取る方法

```javascript
// カバレッジ収集を開始
await page.coverage.startCSSCoverage();

// ブラウザを操作しカバレッジを収集したいページを表示する
await page.goto('ここにURLが入ります');
…


// カバレッジ収集を終了
const coverage = await page.coverage.stopCSSCoverage();
```

coverageの中には、各CSSファイルの内容と、何文字目から何文字目から使われているかが返り値として入っています。
これをゴニョゴニョして最適化されたものだけが取り出されると思いきや、**楽にはいきませんでした。**
イメージとしてはこんな感じ

```javascript
const coverage = [
  {
    url: "ファイルのURL",
    text: "ファイルの中身",
    ranges: [
      { start: "開始位置", end: "終了位置" },
      { start: "開始位置", end: "終了位置" },
      …
    ]
  },
  …
]

const optimizedCSSMap = cssCoverage.map(entry => {
  const { url, ranges, text } = entry
  return {
    fileName: convertUrl(url),
    coverage: ranges
      .map(range => {
        return code + text.slice(range.start, range.end) + '\n'
      })
      .join(''),
  }
})

```

### ここで上がってきた課題

- 動的に表示される要素が、使われていないと判定されてしまう

  > これは現状レンダリングしているものを「使用している」と判断しているためです。
  > JSによって動作するモーダルやポップアップ、メニューバーがある場合は表示させないと「使用している」と判断されません。
→つまりは、stopCSSCoverageまでにpuppeteerでDOM操作をしなければならない

- `media query`や`font-face`などの@ルールが使われていないとみなされる
- コメントを残したい
- 最適化されたCSSを、読み込ませて回帰テストを楽にしたい


## どうやって解決したか
### 動的に表示される要素を考慮したカバレッジを出力する

↓のように、`stopCSSCoverage`の前にDOMを操作する処理を書いても良いのですが
他のサービスに横展開するときにハードルが高くなってしまったり、各画面に合わせてソースコードを書き換えるのはしんどいです

```javascript
// カバレッジ収集を開始
await page.coverage.startCSSCoverage();

// ブラウザを操作しカバレッジを収集したいページを表示する
await page.goto('ここにURLが入ります');
…

// ここに、動的に操作する処理を書く
await page.hover('hoge')
await page.click('fuga') 

// カバレッジ収集を終了
const coverage = await page.coverage.stopCSSCoverage();
```

そこで、[pupperium](https://github.com/akito0107/pupperium/tree/master/cli)のyamlで`puppeteer`を操作する機能を流用しました。

```yaml

steps:
  # 良しなにDOM操作をして、モーダルとかを表示
  - action:
      type: click
      selector: '#hoge' 
  - action:
      type: wait
      duration: 500
  - action:
      type: screenshot
      name: 'demo'
```

### media queryやfont-face、コメントを残すために
`PostCSS`には、ASTを簡単に操作するためのAPIが用意されています。
ASTは`JavaScript`のオブジェクトで簡単に取り扱うことができるため、`AtRule`と`Comment`ノードの探索と削除を行います。


```javascript

const isUnneededNode = (node, coverage) => {
  // Root, Comment, Declarationは削除しない
  if (['root', 'comment', 'decl'].includes(node.type)) {
    return false;
  }

  // AtRuleは削除しない
  if (node.type === 'atrule') {
    return false;
  }
};

// ASTの探索
root.walk(node => {
  // 削除対象か？
  if (isUnneededNode(node)) {
      // 削除対象ならASTから削除する
      node.remove();
  }
});
```

> ノードの一覧

> - Rootノード: ASTの1番上のノード（Rootノードは親ノードがない）
- Ruleノード: 1つのルールセット
- AtRuleノード: 1つの@ルール
- Declarationノード: 1プロパティ宣言
- Comment: 1つのコメント

### 回帰テストを楽にする

最適化されたCSSを代わりに読み込ませるためには、`setRequestInterception`でリクエストに対するインターセプトを有効し、`request.continue`で上書きをすることができます。


```javascript
// リクエストに対するインターセプトを有効にする
page.setRequestInterception(true)

// リクエストを監視する
page.on('request', request => {
  if (scrapingUrl === request.url()) {
    // overridesが上書きする内容
    request.continue(overrides).catch(err => console.error(err))
  } else {
    request.abort().catch(err => console.error(err))
  }
})
```

CSSを上書きした後に、同じシナリオで画像比較
わかりやすいように、比較結果をHTMLとして吐き出してくれるCLIも作りました。

<img width="600" alt="68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3231383230312f66346565333336652d306239372d306132372d656366632d3138383163643635393233302e706e67.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218201/a61629e1-0f62-14fc-f9e8-027b40761d07.png">



## 最後に
使われていないCSSを削除して、concatするだけでここまでパフォーマンスが改善するとは思いませんでした。
機会があれば、普段のパフォーマンスモニタリングやパフォーマンスバジェットなどもお話しできればと思います。