勉強がてらWeb Speed Hackathon 2023 に参加
概要
webpackやrollup等のビルドツールの学習がひと段落した辺りでweb-speed-hackathon-2023に参加するお誘いがかかり、渡りに船と参加を決めた。
結果としてはぼちぼちな成績(24/73 レギュレーションチェックで繰り上がり込みでも10位を切れず)であったが、個人的には学びが多く満足度の高いイベントだったので、参加記事に近い形で作成。
web-speed-hackathon-2023
【イベントページ(終了済)】
今回は二日間で実施され、題目は架空のショッピングサイトであった。
【題目】
個人的な話
前述した通り成績としては不足な面も多かった。パフォーマンスチューニングの技術が必要だとは感じていたので、イベント一週間前から過去の開催内容や参加記事で学習を行った。
当方はJava Scriptのコードを書く際に「この処理は負荷高そうだしやめとくかー」くらいの気持ちでやっていた人間なので、以降の内容もそれくらいの人が参加したんだな、記事書いたんだな。の感じで見てほしい。
実施内容
以降に自分が実施した内容を列挙していく。一部は終了時に主催者側から行われた解説も踏まえて記載
パッケージサイズの確認
まずは初期状態でのパッケージサイズを確認するため、ビルド時のパッケージサイズを表示するライブラリを導入することにした。
2022年までは課題がwebpackだったのでwebpack-bundle-analyzerを利用していたが、2023年の課題はビルドツールがViteに変わっており利用ができない。
調べたところViteは内部的にはrollupが動いているらしい。
rollup用のバンドルサイズを確認するライブラリを調査したところrollup-plugin-visualizerが使えることが判明したので採用。
【参考記事】
まずはライブラリを追加。
yarn add -D rollup-plugin-visualizer
ビルド時にrollup-plugin-visualizerが利用されるように、viteのconfigファイルを修正。
...
+import { visualizer } from 'rollup-plugin-visualizer';
...
...
plugins: [
react(),
wasm(),
topLevelAwait(),
ViteEjsPlugin({
module: '/src/client/index.tsx',
title: '買えるオーガニック',
videos,
}),
+ visualizer(),
],
...
また、例年通り初期のビルドコマンドがproductionではなくdevelopmentになっていることを確認したためproductionに合わせて変更した。
(developmentを利用したい場合もあるので、コマンドを複製)
+ "devstart": "npm-run-all -p devstart:client devstart:server",
+ "devstart:client": "cross-env NODE_ENV=development vite build --watch",
+ "devstart:server": "nodemon --watch src/server --ext 'ts,tsx' --exec \"ts-node src/server\"",
+ "devbuild": "npm-run-all -s devbuild:tsc devbuild:vite",
+ "devbuild:tsc": "tsc",
+ "devbuild:vite": "cross-env NODE_ENV=development vite build",
"start": "npm-run-all -p start:client start:server",
- "start:client": "cross-env NODE_ENV=development vite build --watch",
+ "start:client": "cross-env NODE_ENV=production vite build --watch",
"start:server": "nodemon --watch src/server --ext 'ts,tsx' --exec \"ts-node src/server\"",
"build": "npm-run-all -s build:tsc build:vite",
"build:tsc": "tsc",
- "build:vite": "cross-env NODE_ENV=development vite build",
+ "build:vite": "cross-env NODE_ENV=production vite build",
実行した結果、下記の結果が得られた。
大きめのライブラリの名前を確認するとこんな感じ。
今回は下記のライブラリが重そうであることを判断。削減対象の予定とした。
- date-time-format-timezone
- zipcode-ja
- react-icons
- core-js
- lodash
date-time-format-timezone
date-time-format-timezoneについて、まずは使い方を確認。
例年のお題の通りアプリが最新のChromeブラウザに対応してれば問題ないので、Chromeが対応していることを確認して削除。
pnpm remove date-time-format-timezone
読み込んでいた部分からも削除する。
import 'core-js';
-import 'date-time-format-timezone';
import 'setimmediate';
import './temporal';
この時点でpolifillディレクトリ配下の物はことごとく取り除いた方が良さそうだなぁと思いつつも次へ
合わせてpackage.jsonに対応ブラウザの記載を追加しておいた。
+ },
+ "browserslist": [
+ "last 1 chrome version"
+ ]
zipcode-ja
zipcode-jaの利用場所を確認したところ、郵便番号を入力した際に他の入力フォームに自動的に住所を埋めるものであった。操作してみるとまぁ重たいので、容量圧縮と操作性改善のために着手。
とりあえず郵便番号から住所の変換なんてAPI使えばいいやの精神だったので、zipcloudを利用することに
(業務ではないので、使えるフリーの物はどんどん活用していく)
最後に行われた運営の解説ではzipcode-jsの処理をサーバー側に移管すれば良かったらしい。
また、動作が重たい原因はcloneDeepでzipcode-jsの内容が郵便番号を入力されるたびにコピーされていたのだそう。
入力が重たいから入力チェック機構自体に欠陥があると思ってはいたが、正直ちゃんと見ずに消してしまった。反省。
import { useFormik } from 'formik';
import _ from 'lodash';
import type { ChangeEventHandler, FC } from 'react';
-import zipcodeJa from 'zipcode-ja';
import { PrimaryButton } from '../../foundation/PrimaryButton';
import { TextInput } from '../../foundation/TextInput';
...
...
const handleZipcodeChange: ChangeEventHandler<HTMLInputElement> = (event) => {
formik.handleChange(event);
const zipCode = event.target.value;
- const address = [...(_.cloneDeep(zipcodeJa)[zipCode]?.address ?? [])];
- const prefecture = address.shift();
- const city = address.join(' ');
- formik.setFieldValue('prefecture', prefecture);
- formik.setFieldValue('city', city);
+ if (zipCode.length >= 7) {
+ const params = {method : "POST", body : JSON.stringify({zipcode : zipCode})};
+ fetch(`https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipCode}`, params)
+ .then(response => response.json())
+ .then(response => {
+ formik.setFieldValue('prefecture', response.results[0].address1 || '');
+ formik.setFieldValue('city', response.results[0].address2 && response.results[0].address3 ? `${response.results[0].address2} ${response.results[0].address3}` : '');
+ });
+ }
};
7文字以上入力されないとチェックしても意味がないので、長さを確認してAPIをコールする形に修正。無駄なAPIコールは減らそう。
どうせなら9桁以上入力された場合にも送信しない形でもよかったか。もしくは入力フォームでの制限。
ちなみにzipcloudはハイフン付きでも結果を返してくれる。
ともかく、これでzipcode-jaは不要になったのでライブラリから取り外せた。
pnpm remove zipcode-ja
react-icons
つづけてreact-iconsの対応を行っていく。
読み込んでいる箇所を確認したところ、Iconsとして全量importされていたが、コード全体で利用箇所を確認したところ利用されていたのは下記のアイコンのみだった。
- FaArrowLeft
- FaArrowRight
- FaShoppingCart
- FaUser
- FaPlay
- FaCheckCircle
これを踏まえてimport量を削減する。
import classNames from 'classnames';
import type { FC } from 'react';
-import * as Icons from 'react-icons/fa';
+import { FaArrowLeft, FaArrowRight, FaShoppingCart, FaUser, FaPlay, FaCheckCircle } from 'react-icons/fa';
import * as styles from './Icon.styles';
+const Icons = {
+ "FaArrowLeft": FaArrowLeft,
+ "FaArrowRight": FaArrowRight,
+ "FaShoppingCart": FaShoppingCart,
+ "FaUser": FaUser,
+ "FaPlay": FaPlay,
+ "FaCheckCircle": FaCheckCircle
+}
type Props = {
type: keyof typeof Icons;
width: number;
Iconコンポーネントにコンポーネント名が文字列で渡された際にそのまま解決できるように、利用するアイコン名で連想配列を作成して保持する。importも合わせて読み込む内容を絞り込む形とした。
解説では各コンポーネントで個別にreact-iconsからimportし、Iconコンポーネントに読み込んだIconを受け渡す形を提示していた。確かにその形の方がimport文も長くならないのできれい。
しいて言うなら同じIconの読み込みが複数個所に散ることだが、IDEで十分巻き取れる範囲だと思う。
core-js
恒例のcore-js。今回の課題はChrome最新版だけ対応すればよいので、Polyfill用のcore-jsは不要。
読み込んでいる箇所も問題ないことを確認してから削除。
-import 'core-js';
import 'setimmediate';
import './temporal';
pnpm remove core-js
lodash
lodashも恒例。
便利なUtilityライブラリだが、不要な物も多くライブラリサイズが大きい。
利用箇所を確認したところisEqualしか使われておらず、isEqualもmemoの等価比較にのみ利用されているだけだった。
lodashのisEqualはオブジェクト内の中身も確認したうえで比較を行う処理であるが、memoの等価比較にオブジェクトの細部まで確認する比較処理を入れると処理が重くなってしまう。そのためisEqual自体も不要と判断して削除を行った。
(対応箇所は多いので省略)
pnpm remove lodash
public資材の軽量化
各画面の処理を確認したところ、画像などのpublicに配置している資材の読み込みに負荷がかかっていることを確認。これらを軽量化していく。
JPEG画像の変換
画像ファイルとしてJPEG画像が利用されていたが、こちらのサイズが重たいので軽量な画像ファイルであるWebPに変換していく。
正しくは各画像が利用されている箇所の確認を行って上で適切なサイズや品質を指定して変換を行うべきだが、「手早くやること」「業務ではない作業」の面からWebサイト上に公開されている変換ツールでまとめて実施した。
これによってimages配下に配置されていた画像群をすべてWebp形式に変換。合わせて読み込みの箇所もWebPに変更を行った。
(正しくはAPI側のメタデータから再生成を行ってAPIから返却される画像名称を.webpに変換することが望ましいが、再生成が正しく実行できなかったため一旦フロント画面側で.replaceによるjpg→.webpの読み替え処理を加えた。処理負荷が掛かるので本来は避けたい)
SVG画像の圧縮
ロゴとしてSVG画像が利用されていたが、異様に重たいSVGが使われていたため圧縮を行った。
こちらもコマンド等ではなくWebサービスを利用して圧縮を実施。
適切なサイズの画像の生成
WebPに変換した画像群だが、利用箇所を確認すると画像サイズに見合ってない利用が見受けられたので、適切な画像を生成していく。
ここから画像サイズを変更して別名称での画像生成などが必要になってきたため、諦めてWebP用のコマンドをローカル環境に導入し、バッチファイルを作成して変換を実施していった。
-
アイコン画像
今回の画面ではレビュワーの画像がアイコンとして各レビューに表示される形となっていたが、そのアイコンサイズが大きすぎたためアイコンのサイズまで縮小を実施。こちらはまとめて置き換えとした。 -
プレビュー画像
商品の詳細画面に遷移する前に表示されているプレビュー画像と、詳細画面で大きく表示される画像が同一のファイルを参照していた。
プレビュー画像に大サイズの画像は不要であるため、プレビュー用のリサイズした画像を作成して新たに割り当てを実施。 -
サムネイル画像
商品の詳細画面で、サムネイル画像を押下すると大きく画像が見れる処理部分が存在した。
このサムネイルにも同一の画像が使われていたため、サムネイル用の画像を作成して割り当てを実施。
また、動画のサムネイルを割り当てる際にCanvasを生成して動画の0秒時点の映像を画像として都度出力して割り当てる処理が存在した。
もちろん処理としては無駄なのでCanvasに生成されてる画像を開発者ツールのElement情報から引っこ抜いてサムネイルとした。
これらで画像については最適化が完了した。動画についても改善するべきだとは思われたが、利用されるのは動画のサムネイルがクリックされて動画が流れ始める時だけなので、一旦対応を見送って次へ。
フォントの軽量化
画面を確認していったところ、文字が表示されるのみのエラー画面や完了画面のスコアが低いことを確認。
処理を確認したところpublicに配置されたフォントの読み込みを行っており、その処理で負荷がかかっていた。
フォントの利用箇所を確認したところ一部の箇所でしか利用されていないことを確認したため、利用箇所を確認したうえで下記の対応を実施した。
- 利用する文字のみ切り出して資材に含める
- フォントの形式をotfからwoffに(これはwoff2でよかった)
切り出しと変換はこのツールを利用して対応を行った
今回は利用箇所が一部であること、固定文言しか存在しないこと、利用している文字数も少ないことからこの対応とした。
ウェブフォントで指定した物だけ読み込む形でもよかったが、フォントのアセットを作る作業をしたことが無かったのでフォントファイルの修正を試してみた形になる。
画像の読み込み改善
今回の課題では画像が多く使われており、それらの読み込みに負荷がかかっていることを確認した。これらを次は改善していく。
画像へのloading=lazyの追加
トップページで多くの画像が一気に読み込まれることから、初期読み込みに大きな負荷がかかっていた。
画像にloading=lazyを追加することで表示外の画像についてはスクロールされるまで読み込まれないようにし、初期読み込み処理を軽減していく。
まず初めに、共通で利用されるコンポーネントであるImage.tsxを確認したところ、loading="eager"(遅延読み込みを行わない)になっていたため、lazyに変更を行った。
className={classNames(styles.container(), {
[styles.container__fill()]: fill === true,
})}
- loading="eager"
+ loading="lazy"
{...rest}
/>
);
ただこれには罠があり、全ての画像にloading=lazyが追加されてしまった。これによってファーストプレビューの画像にも遅延読み込みが発生して別の読み込み負荷がかかる事態に。
幸いImage.tsxの記載内容は親コンポーネントからpropsを受け取れる形になっていたので、一律lazyではなく親コンポーネントからlazyのパラメータを受け渡す形に変更した。
className={classNames(styles.container(), {
[styles.container__fill()]: fill === true,
})}
- loading="lazy"
{...rest}
/>
);
</ul>
<NavLink to="/">
<div className={styles.logo()}>
- <Image src="/icons/logo.svg" height={32} width={205} />
+ <Image src="/icons/logo.svg" height={32} width={205} loading="lazy"/>
</div>
</NavLink>
</footer>
Canvas利用箇所の削除
今回はCanvasを利用している処理が2箇所あり、それが処理負荷となっていた。
JavaScriptでCanvas要素を生成して加工するのは処理負荷が高いので取り外していく。
- TOP画像
- 画像を読み込んだ上でCanvas要素に出力、DataURLとして返却して表示している
→ 出力される結果は同一なので、Canvasに出力する分処理負荷がかかっている - Canvasの出力に失敗した場合には透明なGifを返却する
→ レギュレーションを確認したが、透明なGifを表示する要件はない
- 画像を読み込んだ上でCanvas要素に出力、DataURLとして返却して表示している
このことからCanvas要素として一度出力する必要はなしと判断し、画像のパスをそのままimgタグに返却する形に修正した。
- mp4のサムネイル
→ mp4のサムネイルの生成については前述の通り。動画の種類も少ないのでサムネイルを作成して処理を置き換えた
これらの対応と合わせてcanvaskit-wasmライブラリも利用箇所が存在しなくなったため削除。
Dynamic Import
Dynamic Importによりページ毎にチャンク(ビルド後のファイル)を分割して初期読み込み量を減らす。
画面コンポーネント毎の分割
Dynamic Importの分割は画面毎に分割して、画面遷移する際に画面コンポーネントを合わせて読み込むのが簡易に実行できて効果が高い。らしい。
ReactでDynamic Importを行う際にはreactライブラリのlazyを利用する形になるが、そのままlazyを利用しようとするとコンポーネントをdefault exportする必要がある。
default exportを利用すると、
- import側で名称を自由に設定できるため、後からコード検索した際に漏れが発生する可能性がある
- IDEによる予測入力ができなくなる
この2点が気になるため、可能ならnamed exportを利用したい。
そのため、こちらの記事を参考にnamed exportでもlazyを利用できるラップ関数を定義する形とした。
ラップ関数lazyImportとして定義し、画面遷移を管理しているRoutes.tsxを以下の形で修正した。
-import type { FC } from 'react';
+import { FC, Suspense } from 'react';
import * as Router from 'react-router-dom';
+import { lazyImport } from "../../../utils/load_lazy"
-import { NotFound } from '../../../pages/NotFound';
-import { Order } from '../../../pages/Order';
-import { OrderComplete } from '../../../pages/OrderComplete';
-import { ProductDetail } from '../../../pages/ProductDetail';
-import { Top } from '../../../pages/Top';
+const { NotFound } = lazyImport(() => import("../../../pages/NotFound"), 'NotFound');
+const { Order } = lazyImport(() => import("../../../pages/Order"), 'Order');
+const { OrderComplete } = lazyImport(() => import("../../../pages/OrderComplete"), 'OrderComplete');
+const { ProductDetail } = lazyImport(() => import("../../../pages/ProductDetail"), 'ProductDetail');
+const { Top } = lazyImport(() => import("../../../pages/Top"), 'Top');
import { useScrollToTop } from './hooks';
export const Routes: FC = () => {
useScrollToTop();
return (
- <Router.Routes>
- <Router.Route element={<Top />} path="/" />
- <Router.Route element={<ProductDetail />} path="/product/:productId" />
- <Router.Route element={<Order />} path="/order" />
- <Router.Route element={<OrderComplete />} path="/order/complete" />
- <Router.Route element={<NotFound />} path="*" />
- </Router.Routes>
+ <Suspense fallback={<>Loading...</>}>
+ <Router.Routes>
+ <Router.Route element={<Top />} path="/" />
+ <Router.Route element={<ProductDetail />} path="/product/:productId" />
+ <Router.Route element={<Order />} path="/order" />
+ <Router.Route element={<OrderComplete />} path="/order/complete" />
+ <Router.Route element={<NotFound />} path="*" />
+ </Router.Routes>
+ </Suspense>
);
};
条件分岐に合わせたDynamic Import
条件によって利用する/しないが存在するコンポーネントについてもDynamic Importを利用する形に変更した。
が、対応としてはこちらは失敗であった
理由としては
- 表示コントロールが必要なコンポーネントが少なく、コンポーネント自体も軽量であった
- 一部のコンポーネントについては非同期でコンポーネントを読み込むと表示エラーになる
これらの事象が発生したためである。
(正しく対応すれば2個目については問題なくなるかもしれないが、時間をかけたところでメリットはほぼないと判断して取りやめた)
ビルド設定の見直し
Viteの設定ファイル変更によるビルド内容の見直しを行った。
今回対応したのは下記
- 配信ファイルのgzip化(rollup-plugin-gzip)
- 大きいライブラリの個別切り出し(splitVendorChunkPlugin)
- ビルド時の対象ライブラリをChromeに変更
- CSSの分割を有効化(cssCodeSplit)
- minifyの有効化(minify)
...
-import { defineConfig } from 'vite';
+import { defineConfig, splitVendorChunkPlugin } from 'vite';
...
...
+import gzipPlugin from 'rollup-plugin-gzip'
...
...
return {
build: {
assetsInlineLimit: 20480,
- cssCodeSplit: false,
+ cssCodeSplit: true,
- cssTarget: 'es6',
- minify: false,
+ minify: true,
rollupOptions: {
output: {
experimentalMinChunkSize: 40960,
+ manualChunks: {
+ recoil: ['recoil'],
+ '@js-temporal/polyfill': ['@js-temporal/polyfill'],
+ '@apollo/client': ['@apollo/client'],
+ }
},
},
- target: 'es2015',
+ target: 'chrome87',
},
plugins: [
react(),
wasm(),
topLevelAwait(),
ViteEjsPlugin({
module: '/src/client/index.tsx',
title: '買えるオーガニック',
videos,
}),
+ splitVendorChunkPlugin(),
+ gzipPlugin({fileName: '.gz'}),
],
};
});
それぞれの対応した結果は下記となった。
-
配信ファイルのgzip化(rollup-plugin-gzip)
→ 配信ファイルが削減されたことによってLighthouseによるワーニングが減った -
大きいライブラリの個別切り出し(splitVendorChunkPlugin)
→ ライブラリが分割されたことにより初期読み込みが減り、Lighthouseのワーニングが減った
→ ただ、ハッカソンのスコアとしては平均して低下することになった。各ページのLighthouseのスコアは向上したように見えたが、ハッカソンのスコアが落ちては本末転倒なので撒き戻した -
ビルド時の対象ライブラリをChromeに変更
→ 対応としてはあまり影響がなかった -
CSSの分割を有効化(cssCodeSplit)
→ 今回利用されているCSSフレームワークはemotionであり、CSSはJavaScriptで動的に生成される(ビルド時にCSSが出力されない)そのため、有効化の影響はなかった
→ vanilla-extract等の事前にCSSを出力するフレームワーク等であれば有意かもしれない -
minifyの有効化(minify)
→ minifyが有効になったことでJavaScriptファイルの削減が実施された
なお解説の結論ではbuildの設定群は全て削除(Viteのデフォルト値)で問題ないとのことだったViteは優秀
うまくいったのかイマイチ判断できてない対応群
画面外のコンポーネントの読込を遅延させる
初期読み込み時に画面範囲外に存在するコンポーネントについて、スクロール後に必要に応じて読み込まれるようにcontent-visibilityを設定した。また、合わせてCLS(画面の追加読み込みによるがたつき)を軽減するためにontain-intrinsic-sizeも合わせて設定。
画像に対してAspectRatioの設定
CLSを軽減するために画像に対してAspectRatioを設定した。が、Lighthouseのメッセージを見るとワーニングは減ったがスライダーの表示部分など、一部では残る形であった。
この辺りは遅延読み込みとも関係があるのか、要確認。
今回できなかったこと
GraphQL周りは一切手を付けることができなかった
GraphQLに関連した処理がボトルネックになっているのは把握していたが、
- GraphQLを未学習であること
- 期間が短いこと
- まずはフロント画面の処理を可能な限り軽減すること
これらの思想を元に、最後の解説を聞いた後に別途学習を行うことを決意した上で対応をすべて見送った。案の定最後までボトルネックとして残り、スコアが伸び悩む形に。
フロントエンドエンジニアのロードマップのメインルートにGraphQLが入っていることを知りながらも、利用機会の無さから学習を見送っていたツケを払わされた。
今回のことをうけてサーバー側もReactでまとめて組む機会が来る可能性を考慮してGraphQLの学習を行うことを決意。今回の課題の延長戦を行う際にはそのあたりも頭の中に入れておきたい。
今回見落としたこと
最後の解説を聞いた際に、完全に見落として対応の「た」の字も行ってなかった事項。
これらは改善ポイントを見落としている形であったので大いに反省するべき点であった
会員登録・ログイン時の正規表現
今回のスコア計算にはユーザーの動作フローの時間で決まるスコアが存在し、ログイン時のスコアが異様に低い状態から始まっていた。
これについては、ログイン時のダイアログに入力するメールアドレスとパスワードの正規表現が処理負荷の高いものが利用されており、ReDoS攻撃の対象となる処理であったためだが、こちらを見落としていた。
もちろん該当箇所のスコアが低いことは把握していたが、ログイン時にはブラウザ記憶の入力を利用しており、文字入力毎の動作確認ができていなかった(メールアドレスなども短い物が利用されていた)
「ログインだけの処理なのになぜこんなスコアが出ているのか」
「ダイアログもそこまで重たいようには思えない」
「トップページの読込が改善されればおのずと改善されるだろう」
こんな思想のもと原因調査すら怠ってしまっていた。なんという体たらく。
SPAの機能を利用しない画面遷移
今回の課題では、初期状態の画面遷移でaタグが利用されており、SPAの機能で遷移をしていなかった。そのため、画面遷移のたびに新たにSPA資材を読み込んでいる形になる。
確かにreact-router-domのLinkタグを利用すると若干だが画面遷移が早くなった。
「そういやSPAでのaタグ使うの珍しいよなー」
ではないのである。変に思ったら見直すべきだし、aタグとルーティングを利用した遷移の違いを把握していなかったのは恥ずかしい。
参加を終えて
前述したとおりまだまだ課題が多く残る結果ではあったが、事前の学習も含めてとても有意義なイベントだった。
主催の方々に感謝すると共に、今回のことを機にさらに学習を進めていきたいと思う。
リーダーボードのソースが公開されたら自環境で採点を行えるようにし、スコアを伸ばすための修正を模索していきたい。
おまけ
参加するにあたってfly.ioへのリリース自動化したのを別記事として書きました
Discussion