はじめに
今回はECサイトを構築する開発において、Hydrogenというフレームワークが非常に強力だったため紹介します。HydrogenはRemixをベースとしたヘッドレスコマースのためのスタックです。「なぜRemix?」と思う方もいるかもしれませんが、2022年にShopifyがRemixを買収し、その連携を強固にしている背景からも今後Shopifyを用いたECサイトの開発ではRemixを使用する機会が多くなるのではないかと推測しています。
前提
今回はHydrogenで公式に提供されているdemo-store
というテンプレートを前提に解説します。というのも、このdemo-store
が非常に強力で、ECサイトの基盤をすでに構築してくれています。私もこのdemo-storeを拡張することで一つのECサイトを完成させることができました。その強みや、ディレクトリ構成については後に紹介します。
✨ 公式テンプレートが超優秀
先ほど紹介したdemo-store
というテンプレートが非常に優秀でした。以下にその理由と解説をします。
主要な機能要件をすでに満たしている
ECサイトには決まって必要になる機能要件があります。
- 商品一覧画面
- 商品詳細画面
- ログイン画面
- 認証機能
- 商品選択から購入までの導線
- カート機能
- 多言語切り替え
demo-store
はこれら全ての機能をすでに実装してくれています。特にログイン認証やカートなど意図しないバグがユーザに大きな影響を与える機能に関しては自前で実装するだけでも大きな工数と確認作業が必要になります。その一方でこれら機能は、商品のマーケティングに効果のあるものではありません。つまり、Hydrogenの強力なテンプレートによって私たちは最低限必要な機能要件を満たし、よりマーケティングや商品価値の向上に影響する実装に集中することができるようになります。
どれだけの機能がテンプレートによって実装されているかは以下のリンクからお試しください。
ディレクトリ構成
🌳 demo-store
├── README.md
├── app
│ ├── components/
│ ├── data/
│ ├── hooks/
│ ├── lib/
│ ├── routes/
│ ├── root.tsx
│ ├── entry.client.tsx
│ ├── entry.server.tsx
│ └── styles/
├── package-lock.json
├── package.json
├── public/
├── postcss.config.js
├── remix.config.js
├── remix.env.d.ts
├── server.ts
├── tailwind.config.js
└── tsconfig.json
主要な箇所を抜粋して紹介します。前提として、Hydrogenはv2からRemixベースなディレクトリ構成になっているため、Remixの基礎知識が必須となります。
app/
- components
- ページで使用されるコンポーネントを管理
- data
- 言語情報などの定数を管理
- hooks
- 便利フックを管理
- lib
- 便利関数や型定義を管理
- routes
- ルーティング対象のモジュールを管理
- このディレクトリ配下にあるファイルをもとにURLを設計する
app/root.tsx
このファイルはRemixアプリケーションのルートになります。そのため、アプリケーション全体で使用される<link>
<meta>
<script>
などのタグを設定します。また、Globalに扱いたいデータ(storefrontのnameやdescription、カート情報など)はここで定義する必要があります。
Remixの公式でより詳細な解説がありますので、ご参照ください。
app/entry.client.tsx
このファイルはブラウザのバンドル時におけるentry pointです。このモジュールによってJavaScriptがドキュメントに読み込まれた際のハイドレーションを制御することができます。
app/entry.server.tsx
HTTPステータス、ヘッダー、HTMLを含むレスポンスを作成できマークアップが生成されてクライアントに送信されるまでの方法を制御することができます。
public/
静的なファイルを格納するためのディレクトリです。ホスティングにOxygenを採用している場合、ここに存在するファイルはすべてデプロイ時にShopifyのCDNへアップロードされます。そのため、静的なファイルはpublic/
に格納することが推奨されています。
server.ts
このファイルはアプリケーションのmainとなるentry pointです。headerの追加やレスポンスロジックをカスタマイズをするなどの操作をすることができます。また、storefront APIの設定やデプロイするホストのセットアップも行います。
🦋 Remixの設計思想が詰まっていて勉強になる
私自身Hydrogenを通じて初めてRemixに触れましたが、Remixを知るという観点でもHydrogenのprojectは有効だなと感じました。ここでは特に勉強になった点を解説します。
Remixの基本
まずは、Remixの基本となるloader関数とaction関数についてです。
loader
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader() {
return json({ name: "Ryan", date: new Date() });
}
export default function SomeRoute() {
const data = useLoaderData<typeof loader>();
}
loader関数は各routeごとに定義することができ、初回のサーバレンダリング時にのみ実行されHTMLドキュメントに提供されます。サーバで実行されるという特徴から、直接DBにアクセスをしたりAPI secretをサーバのみで使用するなどの活用もできます。そしてloader関数で記述されたコードは全てブラウザのバンドルには含まれません。さらにはコンポーネントでloader関数のデータを取得する際に型安全にしてくれる点も非常に強力です。
action
import type { ActionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import { Form } from "@remix-run/react";
import { TodoList } from "~/components/TodoList";
import { fakeCreateTodo, fakeGetTodos } from "~/utils/db";
export async function loader() {
return json(await fakeGetTodos());
}
export async function action({ request }: ActionArgs) {
const body = await request.formData();
const todo = await fakeCreateTodo({
title: body.get("title"),
});
return redirect(`/todos/${todo.id}`);
}
export default function Todos() {
const data = useLoaderData<typeof loader>();
return (
<div>
<TodoList todos={data} />
<Form method="post">
<input type="text" name="title" />
<button type="submit">Create Todo</button>
</Form>
</div>
);
}
loader関数同様にサーバのみで実行されブラウザのバンドルからは除外されます。順序としては、GET以外のメソッド(POST, PUT, PATCH, DELETE)が呼ばれるとloader関数の実行前にaction関数が実行されます。loader関数を含め、データの読み込みや書き込みに関するロジックを1つのモジュールの中に集約(co-locate)できることが非常に強みです。
Remix Streamingでページの読み込み時間を改善
RemixはWeb Streaming APIを完全にサポートしており、大量のデータを効率的に受信することができます。公式で記載されている例をもとに解説します。
import type { LoaderArgs } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno
import { useLoaderData } from "@remix-run/react";
import { getPackageLocation } from "~/models/packages";
export async function loader({ params }: LoaderArgs) {
// 大量のデータを受信する重い処理を想定
const packageLocation = await getPackageLocation(
params.packageId
);
return json({ packageLocation });
}
export default function PackageRoute() {
const { packageLocation } =
useLoaderData<typeof loader>();
return (
<main>
<h1>Let's locate your package</h1>
<p>
Your package is at {packageLocation.latitude} lat
and {packageLocation.longitude} long.
</p>
</main>
);
}
上のコードで仮にgetPackageLocation
が非常に重い処理であることを想定すると、初回ページがロードされるまでの時間が非常に長くなってしまいます。まずはDBクエリの最適化やキャッシュ戦略などによって重い処理を解消することが先決ではありますが、RemixはReact18のStreamingとSuspenseを活用した解決策を提示してくれています。それが、defer
とAwait
コンポーネントを使用したStreamingです。実装はとても簡単です。
import type { LoaderArgs } from "@remix-run/node"; // or cloudflare/deno
import { defer } from "@remix-run/node"; // or cloudflare/deno
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
import { getPackageLocation } from "~/models/packages";
export function loader({ params }: LoaderArgs) {
const packageLocationPromise = getPackageLocation(
params.packageId
);
// 1. jsonではなくdeferを使用
return defer({
packageLocation: packageLocationPromise,
});
}
export default function PackageRoute() {
const data = useLoaderData<typeof loader>();
return (
<main>
<h1>Let's locate your package</h1>
{/* 2. Suspenseで境界を指定 */}
<Suspense
fallback={<p>Loading package location...</p>}
>
{/* 3. Awaitでresolveされるまで待つ対象を指定 */}
<Await
resolve={data.packageLocation}
errorElement={
<p>Error loading package location!</p>
}
>
{(packageLocation) => (
<p>
Your package is at {packageLocation.latitude}{" "}
lat and {packageLocation.longitude} long.
</p>
)}
</Await>
</Suspense>
</main>
);
}
- Streamingしたい場合は
json
ではなくdefer
をloader関数からreturnします。 - Suspenseで境界を区切り、Promiseのオブジェクトが解決されるまで表示させるfallbackの内容を指定します。
- Suspense内に
<Await>
を配置し、resolve
に解決されることを期待する対象を指定します。この時、errorElement
にはPromiseがrejectされた場合に表示したい内容を指定します。 - 最後に、
<Await>
内で対象のPromiseのオブジェクトが解決されたことを前提にJSXを記述していきます。
以上のようにRemixでは非常に簡潔にStreamingを扱えるのが強みだと実感しました。また、loader関数からdeferをreturnしたからといって、全てがStreaming扱いになるわけではありません。キーとなるポイントはPromiseを返却するか否かです。以下のコードが参考になります。
return defer({
// critical data (not deferred):
packageLocation: await packageLocationPromise,
// non-critical data (deferred):
packageLocation: packageLocationPromise,
});
awaitして解決されたデータを返却した場合、そのデータは初回のロードに含まれStreamingされることはありません。「なぜデフォルトでdeferを使用して全てStreamingしないのか?」という問いに対して公式が回答してくれています。
Remixのdefer APIはTTFB(Time to First Byte)
を改善します。TTFBとは「最初の1バイトを受信するまでの時間」であり、TTFBが高くなるほどページの表示が遅くなります。そのため、重たいデータに関してはStreamingを行い遅延して取得することによって、初回のページ表示速度を向上させることができます。しかし、defer APIはCLS(Cumulative Layout Shift)
とのトレードオフを孕んでいます。CLSとは「ページを読み込んでから表示されるまでのレイアウトのズレ」であり、この指標が低いほどUXが高いことを表します。Streamingしてデータを取得する場合、Promiseが解決されるまではfallbackの内容が表示されるため、レイアウトにズレが生じ結果的にCLSが悪化してしまいます。そのため、defer APIを使用して遅延させるデータは処理が重く重要性が低いものを選択することが望ましいです。
❄️ Oxygenを用いたデプロイが便利で容易
Hydrogenを用いた開発をする場合は、ホスティングにOxygenを用いることが推奨されています。以下でその特徴と強みを解説します。
Oxygenとは
Oxygenはstorefrontのホスティングプラットフォームであり、shopifyのHydrogen channelからアクセスできます。サーバインフラを維持する役割を持ち、Hydrogen開発の管理、デプロイをすることができます。公式では以下のユースケースが紹介されています。
- Hydrogen storefrontをホストしたい。
- 本番前にHydrogen storefrontのプレビューを共有したい。
- 本番用のバージョンを選択する前に、隔離された環境で異なるバージョンのHydrogen storefrontを比較し議論したい。
- Hydrogen storefrontの変更を、メインバージョンに影響を与えることなくテストしたい。
Shopify管理画面のHydrogen Channelにて、Gitレポジトリと連携をさせるだけで簡単にデプロイをすることができます。また、開発中はURLをPrivacy
に設定しておくことでstaging環境として検証することもできます。さらには、各ブランチに対応してCI/CDを実行し、ブランチごとの検証環境を自動で用意してくれる点がとても開発体験が良いと感じました。
以下に添付する解説動画が特に参考になります。
Oxygen Architecture
開発者視点
開発者はHydrogenのコードをGitHubで管理することで、簡単にShopifyのHydrogen Channelとの紐付けが可能です。また、Hydrogen Channelは独自のCI/CDやデプロイ管理機構、Observability機構を持っておりそれらの恩恵を受けることができます。
ユーザ視点
HydrogenのアプリケーションはCloudFlare(CDN)にデプロイされ、よりユーザに近い位置からコンテンツを配信されます。これによりユーザはm秒以内にstorefrontにアクセスすることを可能にし、高価な体験を受けることができます。
まとめ
以上、Hydrogenというフレームワークの特徴を紹介しました。ECサイトにおいて高いパフォーマンスを実現することは顧客の購買につながる大きな要素だと思います。その観点でもHydrogenは強力な選択肢であり、今後も注目していきたいと思います。