Babel マクロの力で JavaScript コードのバンドルサイズ増大に立ち向かう
Photo by Gabriella Clare Marino on Unsplash
この記事は Wantedly 21新卒 Advent Calendar 2021 の3日目の記事です。本記事では、Wantedly のページ内で用いられている JavaScript コードのバンドルサイズを縮小すべく作成した、webpack が最適化を行いやすいコードへの書き換えを行う Babel マクロを解説します。
解決すべき課題
一昔前のハイエンド PC に匹敵する計算機が手の内に収まり、何時でも何処ででも高速回線に接続できるようになった現代ですら、我々はプログラムの性能を改善しなければならない宿命から逃れることはできません。一日スパコンを占有すれば数百万円の使用料が発生するような HPC の分野に携わる人々がチューニングに勤しむように、Web エンジニアである我々も、Google のような検索エンジンの機嫌を良くするためにユーザーの体験を向上させるためにページ読み込みの高速化に取り組んでいます。
DX Squad としてページ読み込み高速化のために取り組んでいく中で、筆者が一部担当したのは JavaScript コードのバンドルサイズ削減です。バンドルサイズが大きければ読み込み時間が長くなるだけでなく、ユーザーが用いている回線のデータ通信量まで圧迫してしまいます。
バンドルサイズ削減のために、現状ではどのような JavaScript モジュールが多くのコード量を占めているのか Webpack Bundle Analyzer を用いて可視化したところ、アイコンの画像データが比較的大きな容量を占めていることが明らかになりました。
アイコンの SVG データがこれほど多くのコード量を占めるに至った原因として、アイコンを表示するコンポーネント Icon
の実装が webpack の行う tree shaking に適した形ではないため、実際には使っていない画像データが残ってしまっていることが挙げられます。
Icon
コンポーネントは表示するアイコンの名前を name
という文字列型の prop として受け取り、その値に応じて実際に表示する画像を選ぶような実装になっています。
// Icon コンポーネントの使用例
const Icons: React.FC = () => {
return (<div>
<Icon name="2-people" />
<Icon name="android" />
<Icon name="angle-down" />
</div>);
};
// Icon コンポーネントの実装抜粋
export const Icon = React.forwardRef<
HTMLElement,
React.PropsWithChildren<IconProps & StyledComponentProps<"svg", any, {}, never>>
>(function Icon(props, ref) {
const { name, ...restProps } = props;
const Comp = iconNameAndRendererMap[name];
return <Comp ref={ref} {...restProps} />;
});
実際に Icon
コンポーネントを使う際には name
prop は定数として使うケースが大半ですから、webpack には特定のアイコン画像しか使われないことを考慮した tree shaking を行なって欲しいものですが、どうやら Webpack Bundle Analyzer のレポートを見る限りでは使われていないアイコン画像の削除は行われていないようです。
どんなアイコン名が与えられてもそれに対応する画像を出力できるコンポーネント Icon
の存在が tree shaking を妨げているのだとしたら、インライン化してしまって表示すべき画像に対応するコンポーネントを直接使ってしまえば良いように思えますし、実際その方針でバンドルサイズを削減できます。
// Icon コンポーネントを介さず、画像に対応するコンポーネントを直接用いる例
const Icons: React.FC = () => {
return (<div>
<TwoPeople />
<Android />
<AngleDown />
</div>);
};
もっとも、Wantedly のコードベース上で Icon
コンポーネントは 55 個のファイルで、同様のインターフェースを持つ IconButton
コンポーネントも 29 個のファイルで使用されているため、それら全てを修正するには大変な労力が必要と言えるでしょう。
そこで、本記事では babel-plugin-macros と呼ばれるメタプログラミングの機構によって、Icon
及び IconButton
コンポーネントを用いている箇所を tree shaking できるようなコードに書き換えることを試みます。
余談
アイコンの SVG ファイルに対応するコンポーネントを直接使うようにするのではなく、アイコンの SVG ファイルをアップロードし、<object>
タグ等を用いてそれら画像を読み込む方針も検討しました。
その方針でも実際にバンドルサイズは削減できるのですが、SVG ファイルを個別に読み込むことになるので、リクエスト回数の増加やアイコン表示までのラグに懸念があり、見送った経緯があります。
babel-plugin-macros について
babel-plugin-macros は、Babel によって新しい規格の JavaScript で書かれたコードを古い規格の JS コードに変換する際に、メタプログラミングを行うような機能を提供するライブラリです。
正確に言えば Babel 自体もプラグインによって、トランスパイル時に構文木の書き換えを行う機構を提供しているのですが、以下のような3点から直接 Babel プラグインを書くのではなく babel-plugin-macros を用いた方が良いと判断しました。
- 一度 babel-plugin-macros の設定を .babelrc に追加すれば、使うマクロが増える度に設定を変更しなくてよい
- 一方、使う Babel プラグインを増やしたい時は毎回 .babelrc に設定しなくてはならない
- babel-plugin-macros は マクロから import したシンボルが使われている箇所の一覧を取得する API を提供しているため、Icon や IconButton が使われている箇所だけ置き換えるような処理を実装しやすい
- Babel マクロは import を起点として動作するため、マクロの実行順の制御が容易である
特に最後は、本記事で作成するマクロが React コンポーネントが使われているコードを書き換える都合上、似たような箇所で構文木を書き換える styled-components/macro
との併用に頭を悩ませなくて良いのが大きいですね。例えば以下のように、 styled-components/macro
と本記事で作成するマクロ @wantedly_private/ui-react.macro
を用いるコードの場合、先に import
された後者が構文木の書き換えを行なった後、次に import
された前者が構文木の書き換えを行うことになります。
import { Icon } fron '@wantedly_private/ui-react.macro'
import styled from 'styled-components/macro'
styled(Icon)`
width: 100%;
`
もし先に styled-components/macro
が先に書き換えを行なっていた場合、元のソース上では tree shaking できるように変形できるコードなのに、なまじ styled-components/macro
が最適化してしまったばかりにそれが崩れてしまったとなりかねませんから、マクロの適用順を制御できるのは大きな長所ですね。
マクロの作成
メタプログラミングに babel-plugin-macros を用いると決めたことですし、実際にそれを用いて、アイコンを表示するコンポーネント Icon
及び IconButton
を使っているコードを webpack の tree shaking が行われやすい形に変更するマクロを作成していきましょう。
課題設定
まず取り組むべき課題を整理しておくと、我々が作成しようとしているマクロは、Icon
コンポーネントにアイコン名が即値として与えられている場合、Icon
の代わりに実際に表示されるコンポーネントに置き換えるものです。
// 変換前
<Icon name="android" />
// 変換後
<Android />
また、Icon
と同様にアイコン名を prop として渡せるインターフェースを持つ IconButton
コンポーネントも tree shaking を妨げているので、実際に表示するアイコン画像に対応するコンポーネントを prop として受け取るコンポーネント DynamicIconButton
を用意し、それを用いるように書き換えなくてはなりません。
// 変換前
<IconButton name="android" />
// 変換後
<DynamicIconButton Icon={Android} />
もっとも、Icon
と IconButton
で微妙に違う変換を個別に実装するのは面倒ですから、前者を後者に寄せて、受け取ったアイコン画像をそのまま表示する DynamicIcon
コンポーネントを用意し、Icon
コンポーネントが使われているコードはそれを使うように置き換えるようにしましょう。
// 変換前
<Icon name="android" />
// 変換後
<DynamicIcon Icon={Android} />
また、Icon
や IconButton
が直接 JSX タグで使われているだけならば良いのですが、styled-components
でスタイルを適用した後で JSX タグとして使うこともしばしばあります。そのようなユースケースに対しても、アイコン名 name
ではなくアイコン画像 Icon
を prop として受け取るコンポーネント DynamicIcon
及び DynamicIconButton
を使うように置き換えなくてはなりません。
// 変換前
<StyledIcon name="android" />
<StyledAndroid />
const StyledIcon = styled(Icon)`width: 100%`;
const StyledAndroid = styled(Icon).attrs({ name: "android" })`width: 100%`;
// 変換後
<StyledIcon Icon={Android} />
<StyledAndroid />
const StyledIcon = styled(DynamicIcon)`width: 100%`;
const StyledAndroid = styled(DynamicIcon).attrs({ Icon: Android })`width: 100%`;
悩ましいのは、Icon
及び IconButton
コンポーネントを単純に置き換えるだけならば、それらが出現する箇所だけ見て書き換えれば良いのに対して、インターフェースの違いを吸収するために name
prop を置き換える部分は変数束縛も辿らなければならない点です。上の例で言うと StyledIcon
が JSX エレメントとして使われている部分では、name
prop を置き換えなければならないのに、コンポーネント名 StyledIcon
自体は(既に DynamicIcon
を用いる定義に置き換わっているので)そのままにしなければなりません。
マクロの実装方針
Icon
及び IconButton
コンポーネントではなく DynamicIcon
や DynamicIconButton
を使うように置き換える処理と、そのインターフェースの違いを吸収する処理では書き換え対象になるコードが異なるため、2ステップに分けて前者の処理を行なったのちに後者を行う方針でマクロを実装します。
export default createMacro(({ references }) => {
// 中略
// babel-plugin-macros が提供する機能のお陰で、Icon の使われている箇所の配列が
// refenrences.Icon で取れる
references.Icon?.forEach((path) => {
// Icon が使われている箇所 path で DynamicIcon を使うように変更する
replaceDynamicIcon(path, importedDynamicIcon);
});
references.Icon?.forEach((path) => {
// Icon が使われている箇所 path を起点に、DynamicIcon とのインターフェースの違いを吸収する
replaceUseCases(path, importedUiIcons);
});
});
Icon
コンポーネントが使われている箇所で DynamicIcon
を使うように変更する処理の実装はそう難しいものではなく、DynamicIcon
の import
を追加して、コンポーネント名を import
時に付けたものに変更するだけです。
// Icon が使われている箇所 path で DynamicIcon を使うように変更する関数
export replaceDynamicIcon = (path: NodePath<Node>, importedDynamicIcon: ImportedIdentCache) => {
// DynamicIcon を import していなければ import し、import していればその時に付けた変数名を得る
const dynamicIcon = insertDynamicIconImport(path, importedDynamicIcon);
// <Icon ... /> のように Icon が JSX タグで使われている場合
if (path.isJSXIdentifier()) {
path.replaceWith(jsxIdentifier(dynamicIcon.name));
// styled(Icon) のように Icon が JSX タグ以外で使われている場合
} else if (path.isIdentifier()) {
path.replaceWith(dynamicIcon);
} else {
throw path.buildCodeFrameError("Unknown use case of Icon");
}
};
IconButton
コンポーネントが使われている箇所で DynamicIcon
コンポーネントを使うように変更する処理も同様に実装できます。
一方で、使用するコンポーネントを変更した後にインターフェースの違いを吸収する処理では、styled-component
を使ってスタイルを適用した後で Icon
及び IconButton
が使われているような場合に対応するため、スタイルを適用して得られたコンポーネントに対しても再帰的に変換を施す実装にする必要があります。
// Icon、IconButton、及びそれらにスタイルを適用して得られたコンポーネントが使われている箇所で、
// アイコン名 name が指定されていたらそれを対応するコンポーネント Icon に置き換える関数
const replaceUseCases = ({ node, parentPath }: NodePath<Node>, importedUiIcons: ImportedIdentCache) => {
if (parentPath.isJSXOpeningElement() && parentPath.node.name === node) {
// JSX の開きタグ <Icon ...> で Icon 等が使われている場合
replaceJSXOpeningElement(parentPath, importedUiIcons);
return;
}
if (parentPath.isJSXClosingElement() && parentPath.node.name === node) {
// JSX の閉じタグ </Icon> で Icon 等が使われている場合
return;
}
if (parentPath.isCallExpression() && parentPath.node.arguments[0] === node) {
// 関数引数として Icon 等が使われている場合
replaceCallExpression(parentPath, importedUiIcons);
return;
}
throw path.buildCodeFrameError("Unknown use case of Icon or IconButton");
};
// 関数引数として Icon 等が使われている箇所が与えられた際に、アイコン名 name が指定されていたら
// それを対応するコンポーネント Icon に置き換える関数
const replaceCallExpression = (parentPath: NodePath<CallExpression>, importedUiIcons: ImportedIdentCache) => {
// styled-component で Icon 等にスタイルを適用するような式に対応したいので、まず
// styled(Icon) みたいな形になっていないものを弾く
if (!isIdentifier(parentPath.node.callee)) {
// Icon 等に適用する関数の中身が変数でなければ、明らかに styled(Icon) みたいな形ではない
throw parentPath.buildCodeFrameError("IconButton must be called by 'styled'");
}
// Icon 等に適用した関数の定義を辿る
const calleeDeclPath = parentPath.scope.getBinding(parentPath.node.callee.name)?.path;
if (
calleeDeclPath === undefined ||
!calleeDeclPath.isImportDefaultSpecifier() ||
!calleeDeclPath.parentPath.isImportDeclaration() ||
!["styled-components", "styled-components/macro"].includes(calleeDeclPath.parentPath.node.source.value)
) {
// Icon 等に適用する関数の定義を辿って、styled-components から import したものでなければエラーにする
throw parentPath.buildCodeFrameError("IconButton must be called by 'styled'");
}
// styled(Icon) みたいな使われ方をしている事が確かめられたので、構文木を遡りつつ必要な変換を行う
let focusedPath = parentPath.parentPath;
// styled(Icon).attrs みたいに .attrs メソッドが呼び出されているかどうか
if (
focusedPath.isMemberExpression() &&
isIdentifier(focusedPath.node.property) &&
focusedPath.node.property.name === "attrs"
) {
if (!focusedPath.parentPath.isCallExpression()) {
// .attrs メソッドが直に呼び出されていない場合は弾く
throw focusedPath.parentPath.buildCodeFrameError("styled(IconButton).attrs must be called directly");
}
// .attrs メソッドの引数を確認し、そこでアイコン名が指定されていれば置き換える
replaceAttrs(focusedPath.parentPath, importedUiIcons);
focusedPath = focusedPath.parentPath.parentPath;
}
// styled(Icon)`...` みたいにスタイルを適用している場合
if (focusedPath.isTaggedTemplateExpression()) {
// スタイルの中身には関心が無いので読み飛ばす
focusedPath = focusedPath.parentPath;
}
// .attrs メソッドを呼び出したりスタイルを適用した後で、変数束縛している場合
if (focusedPath.isVariableDeclarator() && isIdentifier(focusedPath.node.id)) {
// const StyledIcon = styled(Icon)`...`; のように定義した変数を使っている箇所全てについて、
// アイコン名が props で指定されていれば置き換える処理を再帰的に行う
focusedPath.scope.bindings[focusedPath.node.id.name].referencePaths.forEach((path) => {
replaceUseCases(path, importedUiIcons);
});
}
};
マクロの導入
以上のような方針で Icon
及び IconButton
コンポーネントが使われている箇所を tree shaking できるような形に書き換えるマクロを製作したので、実際に Wantedly のコードベースに導入し、JavaScript コードのバンドルサイズが減ることを確かめます。
と言ってもマクロ自体の導入は難しいことではなく、元々の Icon
や IconButton
を @wantedly_private/ui-react
という社内ライブラリから import
していた部分を、@wantedly_private/ui-react.macro
から import
するように変更するだけです。Wantedly のエンジニアでこれから Icon
または IconButton
を使うコードを書く人は、直接 ui-react
から import
するのではなく、ui-react.macro
を通して使うようにして下さい。
// 変更前
import { Icon, IconButton } from "@wantedly_private/ui-react"
// 変更後
import { Icon, IconButton } from "@wantedly_private/ui-react.macro"
注意点があるとしたら、ui-react.macro
と styled-component/macro
を両方使っている場合、前者を先に import
しなければならない事でしょうか。マクロの実行順を保つために少し配慮が必要です。
かくして80個以上の import
文をエディタの力も借りつつ修正して今回製作したマクロを導入したのち、Webpack Bundle Analyzer を用いて再びバンドルサイズの可視化を行いました。
喜ばしいことに、多くのページで共通して読み込まれる commons.js
というスクリプトから、アイコンの画像データが姿を消しました!僅かに残された画像データは個別のページで読み込まれるスクリプトに追放され、肩身の狭さに小さくなっていることでしょう。
まとめ
本記事では、Wantedly のページ内で用いられている JavaScript コードのバンドルサイズを縮小すべく作成した、webpack が最適化を行いやすいコードへの書き換えを行う Babel マクロを解説しました。これから Wantedly 社内のエンジニアが Icon
または IconButton
コンポーネントを使おうとした際は、"@wantedly_private/ui-react"
ではなく "@wantedly_private/ui-react.macro"
パッケージから import
するだけでバンドルサイズ削減の恩恵に預かることができるでしょう。
それにしても、まさかスクリプト言語を使っていながら、こんなコンパイラ最適化の真似事をする羽目になるとは思いませんでした。元はと言えば簡単なインライン最適化すらされていないのが原因だった訳ですから、そろそろ JS の類を捨ててまともな最適化コンパイラのある言語を使うべきですね。