今年もアドベントカレンダーの時期ですね。この記事は Shopify が React をベースに構築した公式のヘッドレスコマースフレームワーク Hydrogen の調査記事です。掲載画像やソースコードは全て Hydrogen のものを利用しています。
(2022-03 追記)
Hydrogen ドキュメントを和訳しています。より詳しく知りたい方はこちらをどうぞ。
Hydrogen とは
Hydrogen は Shopify Unite 2021 で発表されました。
Shopify は API が豊富でマイクロサービスアーキテクチャに適しています。Hydrogen は Shopify をヘッドレスコマースとして利用して、「ヘッド」の EC フロント部分を React で構築するためのフレームワークです。
ヘッドレスコマースとは
EC サイトは、購入してもらうために時代のトレンドに合わせて柔軟に UI/UX を素早く変更していく必要がある一方で、お金を扱うためミスが許されない慎重さが求められるシステムです。なかなか両立するのが難しく、この課題に悩まされている人は多いことかと思います。
これはフロントエンド(UI/UX)とバックエンド(機能・DB)が一体化した作りになっていることが根本的な原因です。ヘッドレスコマースは、フロントエンドとバックエンドを分離するための設計手法です。
ヘッドレスコマースに用いる技術
Shopify はフロントエンド(Liquid)とバックエンド(API)が分離した作りになっています。Liquid を用いてフロントを開発すると Shopify のサーバー上に ECサイトを構築できます。
新規で EC サイトを構築する分にはそれで十分かと思いますが、既存の会員サイトやスマホアプリに EC 機能を組み込みたいという場合には Shopify の API を用いて Shopify の外側にサイト・アプリケーションを構築します。
※その辺りの解説記事は以下を御覧ください。
ヘッドレスの流れを汲んで Next.js や Gatsby もヘッドレスコマースに取り組んでいます。Shopify Partner of the Year 2021に選ばれたnon-standardworldさんの解説記事がわかりやすいのでご紹介します。
しかしながら、これらは開発者各々が学習する必要があり、なかなか取り組みやすいものではありませんでした。そこに登場したのが Shopify 公式フレームワークの Hydrogen です。それでは、私自身の学習も兼ねて解説記事を書いていきたいと思います。
執筆時点では Developer Preview です。
実際に試してみましょう
まずは hydrogen のベースをインストールします。
$ npx create-hydrogen-app
Need to install the following packages:
create-hydrogen-app
Ok to proceed? (y)
✔ Project name: · hydrogen-app
Scaffolding Hydrogen app in /mnt/c/dev/prac/hydrogen-app...
Done. Now:
Update hydrogen-app/shopify.config.js with the values for your storefront. If you want to test your Hydrogen app using the demo store, you can skip this step.
and then run:
$ cd hydrogen-app
$ npm install --legacy-peer-deps
動かしてみましょう。
$ npm run dev
> hydrogen-app@0.6.4 dev
> vite
Pre-bundling dependencies:
@headlessui/react
react/jsx-dev-runtime
html-dom-parser
html-react-parser
react-helmet-async
(...and 3 more)
(this will be run only when your dependencies or config have changed)
vite v2.7.1 dev server running at:
> Local: http://localhost:3000/
> Network: use `--host` to expose
ready in 1291ms.
トップページ
localhost:3000/
import {
useShopQuery,
flattenConnection,
ProductProviderFragment,
Image,
Link,
} from '@shopify/hydrogen';
import gql from 'graphql-tag';
import Layout from '../components/Layout.server';
import FeaturedCollection from '../components/FeaturedCollection.server';
import ProductCard from '../components/ProductCard.server';
import Welcome from '../components/Welcome.server';
function GradientBackground() {
return (
<div className="fixed top-0 w-full h-3/5 overflow-hidden">
<div className="absolute w-full h-full bg-gradient-to-t from-gray-50 z-10" />
<svg
viewBox="0 0 960 743"
fill="none"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
className="filter blur-[30px]"
aria-hidden="true"
>
<defs>
<path fill="#fff" d="M0 0h960v540H0z" id="reuse-0" />
</defs>
<g clipPath="url(#a)">
<use xlinkHref="#reuse-0" />
<path d="M960 0H0v743h960V0Z" fill="#7CFBEE" />
<path
d="M831 380c200.48 0 363-162.521 363-363s-162.52-363-363-363c-200.479 0-363 162.521-363 363s162.521 363 363 363Z"
fill="#4F98D0"
/>
<path
d="M579 759c200.479 0 363-162.521 363-363S779.479 33 579 33 216 195.521 216 396s162.521 363 363 363Z"
fill="#7CFBEE"
/>
<path
d="M178 691c200.479 0 363-162.521 363-363S378.479-35 178-35c-200.4794 0-363 162.521-363 363s162.5206 363 363 363Z"
fill="#4F98D0"
/>
<path
d="M490 414c200.479 0 363-162.521 363-363S690.479-312 490-312 127-149.479 127 51s162.521 363 363 363Z"
fill="#4F98D0"
/>
<path
d="M354 569c200.479 0 363-162.521 363-363 0-200.47937-162.521-363-363-363S-9 5.52063-9 206c0 200.479 162.521 363 363 363Z"
fill="#7CFBEE"
/>
<path
d="M630 532c200.479 0 363-162.521 363-363 0-200.4794-162.521-363-363-363S267-31.4794 267 169c0 200.479 162.521 363 363 363Z"
fill="#4F98D0"
/>
</g>
<path fill="#fff" d="M0 540h960v203H0z" />
<defs>
<clipPath id="a">
<use xlinkHref="#reuse-0" />
</clipPath>
</defs>
</svg>
</div>
);
}
export default function Index({country = {isoCode: 'US'}}) {
const {data} = useShopQuery({
query: QUERY,
variables: {
country: country.isoCode,
},
});
const collections = data ? flattenConnection(data.collections) : [];
const featuredProductsCollection = collections[0];
const featuredProducts = featuredProductsCollection
? flattenConnection(featuredProductsCollection.products)
: null;
const featuredCollection =
collections && collections.length > 1 ? collections[1] : collections[0];
return (
<Layout hero={<GradientBackground />}>
<div className="relative mb-12">
<Welcome />
<div className="bg-white p-12 shadow-xl rounded-xl mb-10">
{featuredProductsCollection ? (
<>
<div className="flex justify-between items-center mb-8 text-md font-medium">
<span className="text-black uppercase">
{featuredProductsCollection.title}
</span>
<span className="hidden md:inline-flex">
<Link
to={`/collections/${featuredProductsCollection.handle}`}
className="text-blue-600 hover:underline"
>
Shop all
</Link>
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-8">
{featuredProducts.map((product) => (
<div key={product.id}>
<ProductCard product={product} />
</div>
))}
</div>
<div className="md:hidden text-center">
<Link
to={`/collections/${featuredCollection.handle}`}
className="text-blue-600"
>
Shop all
</Link>
</div>
</>
) : null}
</div>
<FeaturedCollection collection={featuredCollection} />
</div>
</Layout>
);
}
const QUERY = gql`
query indexContent(
$country: CountryCode
$numCollections: Int = 2
$numProducts: Int = 3
$numProductMetafields: Int = 0
$numProductVariants: Int = 250
$numProductMedia: Int = 1
$numProductVariantMetafields: Int = 10
$numProductVariantSellingPlanAllocations: Int = 0
$numProductSellingPlanGroups: Int = 0
$numProductSellingPlans: Int = 0
) @inContext(country: $country) {
collections(first: $numCollections) {
edges {
node {
descriptionHtml
description
handle
id
title
image {
...ImageFragment
}
products(first: $numProducts) {
edges {
node {
...ProductProviderFragment
}
}
}
}
}
}
}
${ProductProviderFragment}
${Image.Fragment}
`;
商品ページ
localhost:3000/products/snowboard
import {useShopQuery, ProductProviderFragment} from '@shopify/hydrogen';
import {useParams} from 'react-router-dom';
import gql from 'graphql-tag';
import ProductDetails from '../../components/ProductDetails.client';
import NotFound from '../../components/NotFound.server';
import Layout from '../../components/Layout.server';
export default function Product({country = {isoCode: 'US'}}) {
const {handle} = useParams();
const {data} = useShopQuery({
query: QUERY,
variables: {
country: country.isoCode,
handle,
},
});
if (!data.product) {
return <NotFound />;
}
return (
<Layout>
<ProductDetails product={data.product} />
</Layout>
);
}
const QUERY = gql`
query product(
$country: CountryCode
$handle: String!
$numProductMetafields: Int = 20
$numProductVariants: Int = 250
$numProductMedia: Int = 6
$numProductVariantMetafields: Int = 10
$numProductVariantSellingPlanAllocations: Int = 0
$numProductSellingPlanGroups: Int = 0
$numProductSellingPlans: Int = 0
) @inContext(country: $country) {
product: product(handle: $handle) {
id
vendor
seo {
title
description
}
images(first: 1) {
edges {
node {
url
}
}
}
...ProductProviderFragment
}
}
${ProductProviderFragment}
`;
チェックアウト
hydrogen-preview.myshopify.com/5514****/checkouts/3988****
これはいつもの Shopify のチェックアウト画面です。
コレクション
localhost:3000/collections/freestyle-collection
import {
MediaFileFragment,
ProductProviderFragment,
useShopQuery,
flattenConnection,
RawHtml,
} from '@shopify/hydrogen';
import {useParams} from 'react-router-dom';
import gql from 'graphql-tag';
import LoadMoreProducts from '../../components/LoadMoreProducts.client';
import Layout from '../../components/Layout.server';
import ProductCard from '../../components/ProductCard.server';
import NotFound from '../../components/NotFound.server';
export default function Collection({
country = {isoCode: 'US'},
collectionProductCount = 24,
}) {
const {handle} = useParams();
const {data} = useShopQuery({
query: QUERY,
variables: {
handle,
country: country.isoCode,
numProducts: collectionProductCount,
},
});
if (data?.collection == null) {
return <NotFound />;
}
const collection = data.collection;
const products = flattenConnection(collection.products);
const hasNextPage = data.collection.products.pageInfo.hasNextPage;
return (
<Layout>
<h1 className="font-bold text-4xl md:text-5xl text-gray-900 mb-6 mt-6">
{collection.title}
</h1>
<RawHtml string={collection.descriptionHtml} className="text-2xl" />
<p className="text-sm text-gray-500 mt-5 mb-5">
{products.length} {products.length > 1 ? 'products' : 'product'}
</p>
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-16">
{products.map((product) => (
<li key={product.id}>
<ProductCard product={product} />
</li>
))}
</ul>
{hasNextPage && (
<LoadMoreProducts startingCount={collectionProductCount} />
)}
</Layout>
);
}
const QUERY = gql`
query CollectionDetails(
$handle: String!
$country: CountryCode
$numProducts: Int!
$numProductMetafields: Int = 0
$numProductVariants: Int = 250
$numProductMedia: Int = 6
$numProductVariantMetafields: Int = 0
$numProductVariantSellingPlanAllocations: Int = 0
$numProductSellingPlanGroups: Int = 0
$numProductSellingPlans: Int = 0
) @inContext(country: $country) {
collection(handle: $handle) {
id
title
descriptionHtml
products(first: $numProducts) {
edges {
node {
vendor
...ProductProviderFragment
}
}
pageInfo {
hasNextPage
}
}
}
}
${MediaFileFragment}
${ProductProviderFragment}
`;
404エラーページ
localhost:3000/error-page
App 全体で NotFound を拾ったり、個別の collections, products, pages で見つからなかった時の処理を書いています。
if (data?.collection == null) {
return <NotFound />;
}
import {ShopifyServerProvider, DefaultRoutes} from '@shopify/hydrogen';
import {Switch} from 'react-router-dom';
import {Suspense} from 'react';
import shopifyConfig from '../shopify.config';
import DefaultSeo from './components/DefaultSeo.server';
import NotFound from './components/NotFound.server';
import CartProvider from './components/CartProvider.client';
import LoadingFallback from './components/LoadingFallback';
export default function App({...serverState}) {
const pages = import.meta.globEager('./pages/**/*.server.[jt]sx');
return (
<Suspense fallback={<LoadingFallback />}>
<ShopifyServerProvider shopifyConfig={shopifyConfig} {...serverState}>
<CartProvider>
<DefaultSeo />
<Switch>
<DefaultRoutes
pages={pages}
serverState={serverState}
fallback={<NotFound />}
/>
</Switch>
</CartProvider>
</ShopifyServerProvider>
</Suspense>
);
}
import {
useShopQuery,
ProductProviderFragment,
flattenConnection,
} from '@shopify/hydrogen';
import gql from 'graphql-tag';
import Layout from './Layout.server';
import Button from './Button.client';
import ProductCard from './ProductCard.server';
function NotFoundHero() {
return (
<div className="py-10 border-b border-gray-200">
<div className="max-w-3xl text-center mx-4 md:mx-auto">
<h1 className="text-gray-700 text-5xl font-bold mb-4">
We've lost this page
</h1>
<p className="text-xl m-8 text-gray-500">
We couldn’t find the page you’re looking for. Try checking the URL or
heading back to the home page.
</p>
<Button
className="w-full md:mx-auto md:w-96"
url="/"
label="Take me to the home page"
/>
</div>
</div>
);
}
export default function NotFound({country = {isoCode: 'US'}}) {
const {data} = useShopQuery({
query: QUERY,
variables: {
country: country.isoCode,
numProductMetafields: 0,
numProductVariants: 250,
numProductMedia: 0,
numProductVariantMetafields: 0,
numProductVariantSellingPlanAllocations: 0,
numProductSellingPlanGroups: 0,
numProductSellingPlans: 0,
},
});
const products = data ? flattenConnection(data.products) : [];
return (
<Layout>
<NotFoundHero />
<div className="my-8">
<p className="mb-8 text-lg text-black font-medium uppercase">
Products you might like
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-16">
{products.map((product) => (
<div key={product.id}>
<ProductCard product={product} />
</div>
))}
</div>
</div>
</Layout>
);
}
const QUERY = gql`
query NotFoundProductDetails(
$country: CountryCode
$numProductMetafields: Int!
$numProductVariants: Int!
$numProductMedia: Int!
$numProductVariantMetafields: Int!
$numProductVariantSellingPlanAllocations: Int!
$numProductSellingPlanGroups: Int!
$numProductSellingPlans: Int!
) @inContext(country: $country) {
products(first: 3) {
edges {
node {
...ProductProviderFragment
}
}
}
}
${ProductProviderFragment}
`;
ページ
localhost:3000/home
ソースコードはあるのですが、サンプルのストアにはページのデータが登録されていないのか見つかりませんでした。(Developer Preview なのでご愛嬌)
import {useParams} from 'react-router-dom';
import {useShopQuery, RawHtml} from '@shopify/hydrogen';
import gql from 'graphql-tag';
import Layout from '../../components/Layout.server';
import NotFound from '../../components/NotFound.server';
export default function Page() {
const {handle} = useParams();
const {data} = useShopQuery({query: QUERY, variables: {handle}});
if (!data.pageByHandle) {
return <NotFound />;
}
const page = data.pageByHandle;
return (
<Layout>
<h1 className="text-2xl font-bold">{page.title}</h1>
<RawHtml string={page.body} className="prose mt-8" />
</Layout>
);
}
const QUERY = gql`
query PageDetails($handle: String!) {
pageByHandle(handle: $handle) {
title
body
}
}
`;
コンポーネント
コンポーネントは個別のサイトで中身は変わると思いますが Hydrogen にもかなりの数が準備されていて嬉しいですね。
storefront の設定
export default {
locale: 'en-us',
storeDomain: 'hydrogen-preview.myshopify.com',
storefrontToken: '3b58********',
graphqlApiVersion: 'unstable',
};
所感
Server-Side Rendering (SSR) や StoreFront API にアクセスするための GraphQL が整備されていることがありがたいです。また、Shopify データモデルに沿って components, hooks, utilities を扱えばいいので開発は随分楽になると思います。
先日開催された React Conf 2021 でも React は「design principles」(設計原則)に根差していて、デザインとプログラミングの統合という話が印象的でした。Hydrogen + React 18 のパートもありましたので時間がある方は是非ご覧ください。
単純に、フロントエンド(UI/UX=デザイナー)とバックエンド(機能・DB=プログラマー)を切り離すだけならこれまでも出来ていたはずなのに、ここに来てヘッドレスコマースが現実味を帯びてきたのは React の進化のおかげと言えます。React とヘッドレスコマースは補完関係にあり、今後もこの流れは目が離せませんね。
以上、Hydrogen の調査記事でした。
まだ Developer Preview なので GA が待ち遠しいです。