React SSR
定期計測とプロダクトに最適な
パフォーマンスアプローチ

2019-10-29 Nihonbashi.js #6

@tkdn

React SSR
定期計測とプロダクトに最適な
最適だと思ってしまった
パフォーマンスアプローチ

2019-10-29 Nihonbashi.js #6

@tkdn

Who

  {
    "name"  : "武田 諭 / Satoshi Takeda",
    "from"  : "株式会社 mediba",
    "job"   : "Front-End web developer / Architect",
    "social": "tkdn",
    "career": {
      "2003-2013" : "Actor",
      "2013-"     : "Developer"
    }
  }

昔おはスタに出てたので
Wikipedia になぜか記載がある。
※ そして名前の漢字が間違っている

出典: おはスタ - Wikipedia

今日話すこと

  • 📦 プロダクト特性
  • ⚡ なぜパフォーマンス改善を行うか
  • 📈 どう計測していくか
  • 💡 改善ポイントの追求とどう改善したか
  • ✨ 検証し結果どうだったか

TL;DR

  • サードパーティスクリプトを多く抱える
    メディア系サイトでのクライアントサイド React 辛い
  • perf up アプローチに失敗したが技術的には学びがあった

という内容を供養しにきました。
テキスト多めですが JS な話題も盛り込みます

📦 プロダクト特性

  • 某キャリアユーザ向けポータルサイト
  • ユーザ層: キャリアユーザ
    そこまでリテラシーは高くない
  • 収益が広告収入によるもの

プロダクトで採用している技術

  • 参考

    • mediba におけるフロントエンドの取り組み
    • Next.js / TypeScript でリニューアル・運用におけるハマりどころ
  • かいつまんで

    • ServerSide: Next.js(React SSR)
    • ClientSide: jQuery, Backbone.js

歪な状態 🤔

⚡ なぜパフォーマンス改善を行うか

クライアントで
React が動いていない経緯

  • 収益の問題

    • React と広告のサードパーティスクリプトの棲み分けが難しい
  • そのままでもいいのでは?

    • 歪を解消しないと技術的負債を抱えたままになる
    • プロダクトの技術的戦略が弱くなる
      ※ 組織の採用戦略としても weak point になりうる

📈 どう計測していくか

定期計測の方法

参考: Datadog と Lighthouse を利用した WebPerf の継続的計測

計測するメトリクス

  • TTI

    • アプリケーション成立しユーザが画面操作可能になるタイミング
  • 初期表示に必要な広告のロードタイミング

    • よくあるサードパーティスクリプトの所作
      スクリプトが iframe を生成しその iframe 内でさらに iframe を生成

FYI: Puppeteer

// 画面内の iframe の navigated なタイミングを取得
page.on("framenavigated", frame => console.log)

// デプロイ機構の変更による例外試験でアセットファイルに
// 不正なステータスコードを含んでいないかチェック
// これをローリングアップデート中に無限ループでテストする
page.on("response", response => {
  const url = response.url();
  if (url.includes(`${domain}`)) {
    statuses.push(response.status());
  }
});

いろんなイベント取れるので
API ドキュメントを読んでみるといいかもしれません

💡 改善ポイントの追求とどう改善したか

仮説と改善への道筋立て

  • なぜ収益が落ちたのか?

    • CTR/インプの低下 => 収益の落ち込み
  • ここから立てた仮説

    1. アプリケーションとサードパーティスクリプトとの棲み分けを
      うまく行えればインプ数が向上するのでは?
    2. ユーザ操作が可能になるタイミングを早めれば
      CTR が向上するのでは?

どうパフォーマンス改善を筋道立てるか

アプリケーションの成立を早めて
サードパーティスクリプトとの棲み分けを最適化する

  • 1: バンドルサイズの縮小

    • 肥大したファイルのチャンク化
    • 読み込み順の整理をする必要
  • 2: 効果がありそうな
    Dynamic Imports, Progressive Hydration の実施

    • 効果ありそうながミソ
      イメージだけで取り入れてみても…後述

施策候補を列挙し優先度をつけた

改善以前に…業務を優先し出来ていなかった

  • Next.js v5 -> v9

    • これだけでも十分にバンドルサイズが圧縮

Next.js

  • バンドルサイズ最適化や TTI 向上へのアプローチに熱心
  • Google Chrome Team のコミットメント
  • Chrome ❤️ ⚛️ (no, really) - Nicole Sullivan - React Rally 2019 - YouTube

💡 バンドルサイズの縮小

lodash 依存のライブラリ削除

redux/reducer 部分で state のアップデートに
lodash に依存していた updeep を置き換え
9kb ダイエット(小さなことからコツコツと)

{...state, someState: true}
object spread で十分

Sentry のアップデート



108kb ダイエット(けっこう大きなサイズ感)
サイズが大きいという issue : @sentry/browser size · Issue #1552

クライアントでは Sentry 自体を
別バンドルにさせてアプリコードから除外したい
とはいえ SSR 時には Sentry を含めたい

// next.config.js ≒ webpack.config.js
// クライアントビルドでは @sentry/node
// => 無を返すモジュールに差し替える
if (!isServer) {
  config.resolve.alias["@sentry/node"] =
    path.join(__dirname, "./dummy/_sentry");
}
  • 無を返すモジュールを作成
    sentry.init(options) されてもなにも処理しない
  • レポートする関数も@sentry/minimalで置き換える

これらだけ対応したのではなく
詰められるサイズはどんどん詰めていった🔥

💡 TTI 上昇を目論んで
効果がありそうな施策実施

⚡ Dynamic Import

// 書き味は Next 風味です
const Header = dynamic(
  () => import(/* webpackChunkName: "Header" */ "./Header"));
const Info = dynamic(
  () => import(/* webpackChunkName: "Info" */ "./Info"));
const Search = dynamic(
  () => import(/* webpackChunkName: "Search" */ "./Search"));
const Tabs = dynamic(
  () => import(/* webpackChunkName: "Tabs" */ "./Tabs"));
const Panels = dynamic(
  () => import(/* webpackChunkName: "Panels" */ "./Panels"));
const Footer = dynamic(
  () => import(/* webpackChunkName: "Footer" */ "./Footer"));
  • とにかくページのコンポーネントを分割しチャンク化
  • ファイルが分割されることで処理自体も分割される(であろう)

⚡ Progressive Hydration

Google I/O 2019 で developit 先生の発表

参考URL: https://git.io/fjHSW

is 何?

  1. SSR は愚直に render
  2. CSR で初回では不要なコンポーネントで
    shouldComponentUpdate -> false
// ref を持った空 div
<div
    ref={el => this.root = el}
    dangerouslySetInnerHTML={{__html: ""}}
    suppressHydrationWarning
/>
  1. 必要になった時に 2 で描画した空 div に
    ReactDOM.hydrate でマウント

先ほどの ref を持った要素に新たな仮想 DOM をマウントさせる

使ってみての注意点

別のマウントポイントができるため…

  • redux を利用しているなら hydrate 時点の redux state を注入する必要がある
  • context を利用しているならコンテキストを注入する必要がある
// rehydrate するコンポーネント
<SomeContextProvider value={value}>
    <Provider store={window.__REDUX_STATE__}>
        {/** children */}
    </Provider>
</SomeContextProvider>

シンプルな構成なら大丈夫だが結構面倒くさい

FIY

React Conf で Concurrent Mode への注目が大きいが

  • ReactDOM.scheduleHydration も用意され始めています
  • [Selective Hydration] ReactDOM.unstable_scheduleHydration(domNode) by sebmarkbage · Pull Request #17004 · facebook/react

hacky じゃないやり方で公式でサポートしてくれるなら嬉しいかも。

✨ 検証し結果どうだったか

Optimize を利用した AB テストで検証

  • 収益効果を図るために別広告などを払い出し
  • リダイレクトの辛さ、白画面のラグ
    クライアントでのリダイレクトテスト
  • 提供する端末スペックによっては白画面が長い… 🤔

収益上は?

まだ分析途中ではあるものの

  • React 面がインプ数がいい(らしい)
  • CTR は相変わらず上がらない
  • 収益面で切り替えるメリットがそんなに多くはなさそう 👿

パフォーマンス数値は?

バンドルサイズの縮小

  • ネットワークのウォーターフォールを見る限り狙い通り
  • ただ目に見えて数値が向上したという点は
    計測数値から得られる値から有意差が見づらい

TTI 上昇を目論んだ結果…?

有意差がほとんどない…!
※ これはあくまでこのプロダクトでの話です

DevTools パフォーマンスタブ

BeforeAfter

チャンク化したからってそんなに旨味があるものでもない

チャンク化したスクリプトのロードと評価はバラせても
そのチャンクスクリプトを集約し実行するファイルは一つ

この辺のアプローチ詳しい方いたら
懇親会で教えてほしい!

悪いことだけではなかった

  • パフォーマンス数値上は jQuery+Backbone.js と遜色はない
  • アプリケーションコードとしては性能は担保できたはず
  • サードパーティスクリプトと棲み分けはまだまだ出来ていない

🤓 まとめ

  • バンドルサイズの圧縮

    • 可能ならバンドルサイズバジェットを
      リリース前から意識すること
    • 後でどこを詰めるかというのも悪くないが
      そもそもサイズが膨れる芽をを潰せる機構など
  • TTI の向上

    • perf up に最適であると見聞きしたものは
      実際に適用しないと効能がわからない
    • 可能であれば施策ひとつひとつに
      効果測定をしたほうがいい

      • 複数施策をまとめて計測すると
        どの施策が良いかわからなくなる