2023年1月31日
既存のバンドラの手法は間違っている
(2021-8-31)by Miško Hevery
本記事は、原著者の許諾のもとに翻訳・掲載しております。
最近のバンドラは、アプリケーションコードのどの部分をいつ遅延読み込みするかを開発者が決めなければなりません。 開発者は以下のように、コードベースにdynamic importを挿入することによって、遅延読み込みをする場所とタイミングを決定します。
async function doSomething() {
const chunk = await import('./my-chunk');
console.log(chunk.someSymbol);
}
開発者は以下を行う必要があります。
- コードのどの部分が遅延読み込みに適しているかを判断します。
- 既存のアプリケーションワークフローとの互換性がある方法で遅延読み込みを実行します(遅延読み込みは本質的に非同期ですが、遅延読み込みを実行するための理想的な関数は同期型の可能性があるため、遅延読み込みコードを設置できる場所は限られます)。
./my-chunk
にチャンク名を割り当てます。バンドラがチャンクに割り当てる名前や、チャンクを1つのアプリケーションにまとめる方法に影響を及ぼします。- 何をチャンクに含めるかを判断します(例えば、
symbolA
とsymbolB
は同じチャンクに含めるべきか、別のチャンクに分けるべきか?)。
ここで問題なのは、開発者はソースコードを書いている時点では、遅延読み込みするコードの選択が適切であるか、チャンクに適切なシンボルが存在するかが分からないということです。 この点は、アプリケーションを実際にデプロイし、現実に利用状況を観察してみないとはっきりしません。 例えば、設定ページはほとんどアクセスされないので、メインバンドルから除くべきかもしれません。 あるいは、通知セクションを遅延読み込みにしたところ、実際はユーザが最もよくアクセスするページだったため、ユーザ体験がかえって悪化している可能性もあります。
さらに悪いことに、ひとたび開発者が選択をすると、バンドラがそれを補うためにできることはほんの少しです。 バンドラは、まさに開発者が求めることをしなければなりません。 バンドラにもっと自由を与えるには、まったく新しい方法で問題を見る必要があります。
ポイントは、コードを書いている時点では最終的なバンドルがどうなるかが分からないため、どこにdynamic importを挿入すべきかを判断するための十分な情報がないことです。 一方、チャンクの理想的な姿を判断するための十分なデータが集まったときには、ソースコードはすでに書き上がっています。 さかのぼってdynamic importを挿入するのは、大きな手間になるかもしれません(あるいは、過剰な遅延読み込みによって、アプリを細かく分割しすぎる恐れもあります)。
私たちが求めているのは、チャンクの理想的な数を判断し、現実のユーザによるアプリケーションの利用状況に基づいてチャンク間でコードを移動できることです。 これを実行する際に、さかのぼってソースコードをリファクタリングする必要がないことが望ましいでしょう。 チャンクのレイアウトは、コードとしてコードベースに組み込むのではなく、設定情報としてバンドラに移すべきです。
さらに複雑なことに、最近のフレームワークはすべて同期方式のレンダリングパイプラインを採用しています。 そのため、非同期方式のdynamic importをアプリケーションに挿入するのがとても難しくなります。
最適な遅延読み込み戦略を追求するなら、上記の問題を解決する必要があります。
Qwikの登場
コンポーネントはQwikアプリケーションの基本的な構成要素です。 Qwikはコンポーネントを3つの部分に分割することを求めます。
- ビュー:コンポーネントのビジュアル部分をレンダリングするJSXコードが含まれます。
- ステートファクトリー:コンポーネントの新たなステートを作成するコードが含まれます。
- イベントハンドラ:コンポーネントの挙動やユーザとのインタラクションに利用されるコードが含まれます。
なぜコンポーネントを3つの部分に分割するのか?
ほとんどのフレームワークは、ビュー、ステート、ハンドラのコードを1つにまとめています。 以下は、この働きを解説するために用意した擬似フレームワークのコードです。
export function Counter(props: {step?:number}) {
const [count, setCount] = useState({count: 50});
const step = props.step || 1;
return (
<div>
<button onclick={() => setCount(count - step)}>-</botton>
<span>{count}</span>
<button onclick={() => setCount(count + step)}>+</botton>
</div>
)
}
コンポーネントのビュー、ステート、ハンドラがすべて一緒になっているのに注目してください。 これはそれらのすべて(ビュー、ステート、ハンドラ)を同時にダウンロード、パース、実行しなければならないことを意味します。 そのため、遅延読み込みはかなり制限されます。
ここで挙げた程度の例では大きな問題にならないかもしれませんが、上記のコードがもっと複雑になって、何KBものコードを一度にダウンロード、パース、実行する必要がある場合を想像してみてください。 その場合、ビュー、ステート、ハンドラを何としても一緒に読み込ませようとするのは良くないかもしれません。 それがなぜ問題なのかを、ユーザのよくある利用パターンを通して見ていきましょう。
ユーザがコンポーネントをクリックしてインタラクトする:
- 一部の
handler
だけが必要:ダウンロードしなければならないのは、トリガーされた特定のハンドラのみです。その他のハンドラはすべて不要です。 view
は不要:ハンドラによる再レンダリングが行われない可能性や、異なるコンポーネントの再レンダリングが行われる可能性があるので、ビューは不要かもしれません。state factory
は不要:コンポーネントが再ハイドレートされるため、ステートを初期化するためのコードは不要です。
コンポーネントのステートが変更される:
handler
は不要:ハンドラを実行する必要はありません。view
は必要:コンポーネントを再レンダリングする必要があるため、ビューは必要です。state factory
は不要:コンポーネントが再ハイドレートされるため、ステートを初期化するためのコードは不要です。
親が新たなコンポーネントを作成する:
handler
は不要:ハンドラを実行する必要はありません。view
は必要:コンポーネントを再レンダリングする必要があるため、ビューは必要です。state factory
は必要:コンポーネントが作成されるため、ステートを初期化するためのコードは必要です。
上記の例は、それぞれのケースで必要なのがビュー、ステート、ハンドラ情報の一部のみであることを示しています。 問題は、3つの異なる情報がすべて一緒に埋め込まれているのに対し、それらがコンポーネントのライフサイクルにおいて異なるタイミングでしか利用されないことです。 最適なパフォーマンスを達成するには、コンポーネントに求められる役割に基づき、コンポーネントを部分ごとにダウンロード・実行する方法が必要です。 上記のコードでは、ご覧のとおり、コンポーネントを永久に分離できません。
分割は簡単
Qwikは、当面のタスクに必要なコードだけをダウンロード・実行することでこの問題を解決します。 上記の例のコードはシンプルですが、現実のコードはずっと複雑であることを忘れないでください。 さらに、コードが複雑になるとimportが増えることが多く(import自身もimportを必要とします)、コンポーネントのコードは一段と増加します。 この状況を「ツール」で解決することはできません。 コンポーネントをパーツ単位に分割し、必要に応じて遅延読み込みできるようにするための静的解析ツールを作成するのは不可能です。 開発者は、対応する部分ごとにコンポーネントを分割し、きめ細かい遅延読み込みを可能にしなければなりません。
そのために、Qwikはマーカー関数のqrlView
、qrlState
、qrlHandler
を導入しています。
ファイル:my-counter.tsx
import {
QComponent,
qComponent,
qrlView,
qrlHandler,
qrlState
} from '@builder.io/qwik';
//コンポーネントの型を宣言し、プロパティとステートの形態を定義します。
export type Counter = QComponent<{ step?: number },
{ count: number }>;
//コンポーネントのステートファクトリーを宣言します。
//これはステートを初期化するために新たなコンポーネントが作成される際に利用されます。
//(再ハイドレーションでは利用されません。)
export const CounterState = qrlState<Counter>(() => {
return { count: 0 };
});
//コンポーネントのレンダリングに利用されるコンポーネントのビューを定義します。
export const CounterView = qrlView<Counter>((props, state) => {
return (
<div>
<button on:click={Counter_update.with({ direction: -1 })}>
-
</button>
<span>{state.count}</span>
<button on:click={Counter_update.with({ direction: 1 })}>
+
</button>
</div>
);
});
//コンポーネントのビューは、挙動を記述するハンドラを必要とする場合があります。
export const Counter_update
= qrlHandler<Counter, {direction: number }>(
(props, state, params) => {
state.count += params.direction * (props.step || 1);
}
);
//最後にすべてを1つのコンポーネントにまとめます。
export const Counter = qComponent<Counter>({
state: CounterState,
view: CounterView,
});
上記のコードは他のフレームワークに比べて冗長です。 しかし、コンポーネントを部分ごとに明確に分割するという手間をかけることで、きめ細かい遅延読み込みが可能になるというメリットが生まれます。
- 開発者体験の観点から見て、コンポーネント当たりのオーバーヘッドはあまり変わらないことを忘れないでください。コンポーネントの複雑性が高まるにつれて、オーバーヘッドの増加は大きな問題ではなくなります。
- この方式のメリットは、ツールによってコンポーネントを自由に複数のチャンクにパッケージ化し、必要に応じて遅延読み込みできる点です。
裏側で何が起きているのか
qrlState
、qrlHandler
、qrlView
はいずれもQwik Optimizerのマーカーであり、自身への参照をQRLに変換する必要があることをツールに伝達します。
その結果、ファイルは次のようになります。
ファイル:my-counter.js
import {qComponent, qrlView, qrlHandler, qrlState} from '@builder.io/qwik';
export const CounterState = qrlState(() => ({
count: 0,
}));
export const CounterView = qrlView((props) => {
const state = getState(props);
return (
<div>
<button on:click="/chunk-pqr#Counter_update?direction=-1">
// ^^^^^^^^^^^^^^^^^注目^^^^^^^^^^^^^^^^
-
</button>
<span>{state.count}</span>
<button on:click="/chunk-pqr#Counter_update?direction=1">
// ^^^^^^^^^^^^^^^^^注目^^^^^^^^^^^^^^^^
+
</button>
</div>
);
});
export const Counter_update = qrlHandler(
(props, state, params) => {
state.count += params.direction * (props.step || 1);
);
export const Counter = qComponent({
state: '/chunk-abc#CounterState', // <<===注目
view: '/chunk-cde#CounterView', // <<===注目
});
ソースファイルの変換に加え、Optimizerはビュー、ステート、ハンドラ間の静的参照をすべて削除します。 QwikはRollupのためのエントリーポイントファイルも作成します。 これらのエントリーポイントは上記のQRLに対応します。
ファイル:chunk-abc.js
export { CounterState } from './my-counter';
ファイル:chunk-pqr.js
export { Counter_update } from './my-counter';
ファイル:chunk-cde.js
export { CounterView } from './my-counter';
重要なのは、いくつのエントリーファイルを作成するか、どのエクスポートをどのエントリーファイルと結びつけるかに関して、Qwikに大きな自由がある点です。 これは開発者が遅延読み込みする部分とそうでない部分の境界をまったく指定しないことによります。 その代わり、Qwikは、コードベースにたくさんの遅延読み込み境界(原文: lazy load boundaries)を導入するようにコードを書くよう開発者を導きます。 これによりQwikは、実際のアプリケーションの利用状況に基づき、最適なファイル配分を実現できます。 例えば、小規模なアプリケーションの場合はファイルを1つ作成し、アプリケーションの規模が大きくなるにつれて、エントリーファイルを増やすことができます。 また、特定の機能がめったに利用されない場合、その機能だけを単独のバンドルにすることもできます。
Rollupがエントリーファイルを処理すると、ファイルは次のようになります。
ファイル:chunk-abc.js
import { qrlState } from '@builder.io/qwik';
export const CounterState = qrlState(() => ({
count: 0,
}));
ファイル:chunk-pqr.js
import { qrlHandler} from '@builder.io/qwik';
export const Counter_update = qrlHandler(
(props, state, params) => {
state.count += params.direction * (props.step || 1);
);
ファイル:chunk-cde.js
import { qrlView} from '@builder.io/qwik';
export const CounterView = qrlView((props, state) => {
return (
<div>
<button on:click="/chunk-pqr#Counter_update?direction=-1">
-
</button>
<span>{state.count}</span>
<button on:click="/chunk-pqr#Counter_update?direction=1">
+
</button>
</div>
);
});
注目してほしいのは、Rollupがファイルの内容を展開してエントリーファイルにまとめ、不要なコードを削除し、理想的なサイズのバンドルを作成している点です。
制約
ツールがqComponent
、qrlState
、qrlHandler
を移動できるようにするため、これらのメソッドの利用は制約されています(すべての有効なJavaScriptプログラムが有効なQwikプログラムとは限りません)。
その制約とは、すべてのマーカー関数がexport
の対象となるトップレベル関数でなければならないというものです。
無効なコードの例は次のとおりです。
import { someFn } from './some-place';
function main() {
const MyStateFactory = qrlState(() => ({})); //トップレベルではないので無効
}
const MyStateFactory = qrlState(() => someFn({ data: 123 })); //有効なimportなので問題なし
ツールにとっての選択肢
アプリケーションを小さいファイルに分割しすぎて、ダウンロードのパフォーマンスに悪影響を与えてしまうのは、あり得ないことではありません(むしろ、とてもよくあることです)。 そのため、ツールではファイルをマージしてバンドルにすることを選択できます。 これは理想的な挙動と言えます。 アプリケーション全体の規模が比較的小さい(50KB未満の)場合、数百個ものファイルに分割するのは生産的ではないでしょう。
コード構造がきめ細かければ、ツールは常に、バンドルを大きくする(そして少なくする)ことを選択できます。 しかし、その逆は正しくありません。 コード構造が粗ければ、ツールがコードを分割するためにできることは何もありません。 Qwikは、開発者がアプリケーションを可能な限り小さいチャンクに分割し、ツールを利用して最適なバンドルのチャンクを発見する助けになります。 このように、Qwikはあらゆるサイズのアプリケーションに最適なパフォーマンスを提供できます。
この記事は面白かったでしょうか?もしそうならば、私たちのチームに参加して、一緒にWebの高速化を目指しませんか。
- StackBlitzで試す
- github.com/builderio/qwikでスターを送る
- @QwikDev と@builderioをフォロー
- Discordでチャット
- builder.ioの採用情報
シリーズ記事一覧
info
シリーズ記事一覧はこちらから参照できます。
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa