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


リクルートテクノロジーズ でフロントエンドエンジニアをしている@SW20_Toshiです。
本記事はRecruit Engineers Advent Calendar 2019 - Adventar 20日目の記事です。

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

サイボウズ株式会社のこちらの記事には大変お世話になりました!
ありがとうございました!

きっかけ

10年近くABテストや機能追加を繰り返してきたWebサービス....

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

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

パフォーマンス観点

  • カバレッジが低いファイルが複数あり、描画速度が遅くなる
    • CSSの解決のされ方についてはこの記事がわかりやすいです
  • minifyもconcatもできていない

開発者観点

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

やったこと

css-optimizationというツールを開発し、結果としてCSSを180KBの削減に成功しました

light houseのパフォーマンススコアも大幅に改善
before

after

作ったツールについて

そのページの使われているCSSのみを抽出するcss-optimizationというCLIを開発しました。
任意のURLと操作をyamlに記述するだけで、使われているCSSのみを取得することができます。


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'

前提条件(ログインなど)を定義することも可能です
ツールを開発しリリースへ向けて作業していく上で、躓いた点などを説明していきたいと思います。

Puppeteerとは

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

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

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

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



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

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

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 queryfont-faceなどの@ルールが使われていないとみなされる
  • コメントを残したい
  • 最適化されたCSSを、読み込ませて回帰テストを楽にしたい

どうやって解決したか

動的に表示される要素を考慮したカバレッジを出力する

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

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

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


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

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

そこで、pupperiumのyamlでpuppeteerを操作する機能を流用しました。


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のオブジェクトで簡単に取り扱うことができるため、AtRuleCommentノードの探索と削除を行います。


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で上書きをすることができます。

// リクエストに対するインターセプトを有効にする
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も作りました。

最後に

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