社内でフロントエンドのパフォーマンスチューニングコンテストを開催した

フロントエンド/Node.js エンジニアの mizchi です。plaid では新しい分析エンジンのフロントエンド側の技術的な仕様を考えたり、それを実装したりしています。趣味として社内の他のプロダクトのパフォーマンスを勝手に測って、貼り付けていくこともあります。

plaid のエンジニア組織には「組」という制度があって、メインとなるプロダクト以外にも、そのテーマで会社横断で活動するグループがあり、最低一つ所属することが奨励されています。例えばセキュリティ組、バグトリアージ組、Tech 勉強会組などがあります。

最近になって パフォーマンス組を新設しました。これは主にフロントエンドのパフォーマンスを調査して、各プロダクトにその改善を促すグループです。

plaid のフロントエンドは PaaS としての外向けのサードパーティスクリプトと、その管理画面があるのですが、サードパーティの方はパフォーマンスも計測しているのですが、管理画面の方は歴史が長く設計変更も多々あり、富豪的な感じになってしまっています。

これを改善するためにいくつか調査を行い、実際にいくつかは修正したのですが、本質的には「組織的なパフォーマンス意識」を向上させる必要がある、という結論になりました。組織としてのパフォーマンスを意識できないと、目の前の問題を解決したとして、いずれ再発します。(これはセキュリティなどでも同様ですね)

そこで、その文化の改善のために、まず社内のプロダクトをテーマにハッカソンの開催を開催してみよう、という話になりました。

開催の経緯

とある日の slack のスレッドの自分とデザイナーの高橋さんの会話。

mizchi 「ところで Baisu (社内の CSS ガイドライン兼モック) むちゃくちゃ重くないですか」

高橋「めちゃ重いですよね」

mizchi 「なんで重いんだろう… JS でした」

高橋「モック環境だから色んなライブラリとか試すのにいれまくってるからか!なるほど」

mizchi 「ここパフォチュのお題によさそう。lighthouse で高得点出した人が優勝」

mizchi 「壊しても悲しむのが高橋さんだけってのがパフォチュ向き」

見ていた同僚が突然テザーサイトを作成

開催決定!

mizchi「業務時間使って開催しますね」

nashibao(CPO)「どんどんやっていけ」

真面目な話

社内のワークフローでは、社内のモノレポの一部の baisu というプロダクトで vue.js のモックページを作成し、それを元に各プロダクトで実装が行われています。

このワークフローの抱える問題として、 baisu の抱えるパフォーマンス上の問題は、そのままプロダクトに引き継がれてしまいます。実際いくつかパフォーマンス上の問題として表出していました。

ここでの改善は、そのまま管理画面やその他のページの改修にそのまま活かせます。プロダクトに直接コミットするより、まず同じ問題を抱えたモックページをチューニングすることで、心理的な負荷を減らしつつ大胆なアプローチを取れるはずだ、と考えました。

まず自分で解いてみた

前提として、この baisu というのは、基本的に vue.js + vue-router で構築された SPA です。html-webpack-plugin と webpack, webpack-dev-server で起動し、本番は静的サイトとして superstatic 上で動作します。

firebase/superstatic: Superstatic: a static file server for fancy apps.

そもそも問題として適当かどうかを測るため、自分でまず自分がチューニングしつつ問題を洗い出しました。

  • チャンク分割の前に面倒だったのが、babel6 => 7 のアップグレードだったので、ここで詰まっては本質的ではないので babel 7 へ更新
  • webpack も dynamic import が使いづらいバージョンだったのを最新版に更新

その上で発生している問題の洗い出し。

  • 大量のルーティングあるにも関わらず、チャンク分割がなされずに、単一の js として出力されてる
  • moment の locale をすべて読みこんでいる
  • vue 用のビューライブラリで、 element-ui というライブラリのコンポーネントを、使うかどうかに関わらず全部読み込んでいる
  • apexcharts という重いチャートライブラリを全画面で読み込んでいる
  • fontawesome の全アイコンフォントを読み込んでいる

これによって、適当なお題だと判断。あえて放置して本番を迎えました。

開催告知

弊社は 2 ヶ月に一回チームを入れ替えるのですが、その入れ替え期で比較的締め切りなどがない余裕があるタイミングで開催しました。事前に「業務時間を使って参加していいよ、ただしチームの同意はとってね」という周知をしました。

レギュレーションとスコアサーバーの実装

  • baisu/app のビルド結果を競う
  • npm run bundleAnalyze した後の npm run evaluation で localhost:8080 にサーバーを起動
  • 計測サーバーの実装を clone して localhost:8080 のスコアを計測
  • UI が大幅に壊れている場合は計測対象外とする

周知した方向性として、「計測対象となるページの個別の最適化は避けて、できるだけ一般的な、マージしたいと思えるような実装で解いてね(それでもスコアは十分伸びるので)」という感じに。

計測用スクリプトはこんな感じ。

const fs = require('fs');
const path = require('path');
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');

const TARGET_URLS = [
    "http://localhost:8080/",
    "http://localhost:8080/user/story/",
    "http://localhost:8080/karte/wizard/",
    "http://localhost:8080/organization/",
    "http://localhost:8080/account/register/",
];
const metricsLighthouse = async (chrome, url) => {
    const options = { logLevel: 'error', output: 'html', onlyCategories: ['performance'], port: chrome.port };
    const runnerResult = await lighthouse(url, options);
    const filename = url.replace("http://localhost:8080/", "").replace(/\//g, "_");
    const reportHtml = runnerResult.report;
    fs.writeFileSync(`report_${filename}.html`, reportHtml);
    return {
        url,
        score: runnerResult.lhr.categories.performance.score * 100,
        totalByte: runnerResult.lhr.audits["total-byte-weight"].numericValue
    }
};

(async () => {
    const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
    try {
        const results = [];
        for (const url of TARGET_URLS) {
            const result = await metricsLighthouse(chrome, url);
            results.push(result);
        }
        const totalScore = results.reduce((total, result) => {
            return total + result.score;
        }, 0);
        const totalByte = results.reduce((total, result) => {
            return total + result.totalByte;
        }, 0);
        console.log(`Total score: ${totalScore}`);
        console.log(`Total bytes: ${totalByte} bytes`);
        console.table(results);
    } finally {
        await chrome.kill();
    }
})();

要は lighthouse の Performance のスコアです。

開催

  • カレンダーで 14:00~19:30 を確保
  • 14:00 に zoom のオンラインミーティングでレギュレーションの説明
  • 18:30 に終了。最終ビルドのスコアを自己申告してもらう。
  • 参加者一人ずつ、PullRequest を見ながらやったことの紹介。全員で講評。

結果

  • 事前の周知の甲斐あって、ほぼ全員が router の lazy load の実装を完了 80mb => 16mb
  • ほぼ全員が momment の未使用 locale を削除
  • 半数が element-ui をなんらかの形で未使用コードのドロップを実装
  • ほぼ全員が fontawesome の最適化に挑むも、削れた人は一部だけ
  • apexcharts を問題視した人はいたが、アプリケーションコードに手を入れないといけないので放置

ほぼ全員が 80mb を 10mb 付近まで削っていたのですが、最終的に優勝したのは、 html-webpack-plugin の吐くコードを書き換えた人で defer にしたことで、lighthouse のスコアが上がった、新卒一年目の kosukeoya でした。

kosukeoya 曰く、「自分はフロントエンドのパフォーマンスチューニングは初めてだったが、事前資料として共有されたサイバーエージェントのハッカソンでの最適化を上から順になぞった」ということで、あの資料やっぱ有効だったんだなぁという感じに。

https://github.com/CyberAgentHack/web-speed-hackathon-online/wiki/Web-Speed-Hackathon-Online-出題のねらいと解説

参加者の声

  • webpack-bundle-analyzer がリポジトリに組み込まれていたので、普段は意識しないものを意識できた
  • やってよかった。いろいろなテクニックにふれる機会があった
  • フロントエンドの経験値が少なく、直し方はまだ難しく感じるが、問題の見つけ方がわかるようになったのが大きい
  • 最適化ははじめてだったが、コスパよくできる場所があるのがわかった
  • ベストプラクティスはそんなに難しくなく、知ってれば日常的に採用できるコードが多い
  • 競プロ的な感じ(小難しい解法)かと思ったら普通に一番いいコードが一番効く
  • (baisu そのものの感想) そもそもうちにこんな数のモックページがあったのか

webpack-bundle-analyzer - npm

技術的な感想 / 得られた教訓

  • vue.use 使ってしまうと最適化が難しい
  • fontawesome が強敵
  • jquery 消そうとして時間がかかった
  • chunk に名前つけた方がデバッグしやすい
  • chunk いっぱい吐くとグラデーションが綺麗
  • 高橋さんの PC のメモリ節約できて嬉しい!

きれいな webpack chunk の様子です

baisu [11 Sep 2020 at 18:25] - Gyazo

講評中に見つかった、プロダクトに活かせそうなものの

  • superstatic の compression(gzip) を有効にする(なってなかった)
  • JS によって挿入される css で呼ばれる webfont を <link rel=preload ...> で先読みすると、 適用時のガタツキがなくなり、layout junk が減って LCP のスコアがよくなる

この辺やらないとわからなかった気がします。

運営してみての感想 / 反省

プロダクトと同じ構成のモックの読み込み改善というお題がよかったと思います。そのまま業務にフィードバックできるので、明日からやっていけそう、みたいな声が多かったです。

そもそもルーティングのチャンク分割、 lazyload にたどり着いてくれないとその後の改善がないのが心配だったんですが、参加者全員、そこはたどり着いていました。ハッカソン中に時間経過でヒントを小出ししたのも、何もできない人が出なくてよかったかなと思います。

反省として、最終的な script の defer 属性によるスコア差については、今回のお題ではやや評価が難しいところがあります。なぜなら、baisu は js を読み込まないと画面に何も表示されない、SSR なしの SPA なので、エントリポイントの同期/非同期は、結果的なユーザー体験にあまり影響がありません。

まあ、スコアサーバーの機嫌を読むのもコンテストの一部ということだと思うので、これはこれで良いと思います。ただ html-webpack-plugin の吐くコードに手を入れる、というのは気づきづらかったですね。

人が集まりやすい事前準備をして、告知の反応も多かったんですが、やはり業務時間を使う心理的な抵抗からか人が集まりづらく、想定の半分ぐらいでした。ここは次にもっと工夫したいと思います。

社内プロダクトの改善という他社で再現性があるか微妙なテーマではあり、これも外に出せないのが申し訳ないんですが、似たようなモックと課題をもつような会社では開催できるのではないでしょうか。そもそも実プロダクトを対象にしてもいいと思います。

Hiring

plaid では webpack chunk を 1byte でも絞り込みたいという人を募集しています。

参考にした先行事例