【Shopify】オリジナルテーマを一から作るための「見た目を持たない土台」を自作した話
Shopify のオリジナルテーマを、案件ごとにデザインをゼロから作れるようにするための「土台」を作りました。土台が持つのは、カートやバリエーション選択といったShopify の機能(動きとデータ)だけで、見た目(HTML の構造・CSS)は一切持ちません。この記事では、その考え方と、見た目と動きを切り離すために決めたルールをまとめます。
- 既製テーマのカスタマイズではなく、土台を自作した理由
- JS がクラス名を見ない「カスタム要素+data 属性」の分け方
- Section Rendering API で、JS に HTML を組み立てさせない部分更新
- ビルドなしで ES Modules を使う import map の出し方
- ルールを破っていないかをテストで確かめる仕組み
なぜ土台から作ったのか
これまでの Shopify の案件は、Shopify の初期テーマ(Dawn)をカスタマイズする形で進めてきました。Dawn は、どんなお店でも設定だけで形になるようによくできたテーマです。ただ、そのぶん設定・変数・セクションがとても多く、僕のやり方だと次の2つで困ることが増えてきました。
- デザインが「初期テーマの枠」に収まってしまい、どのお店も似た見た目になる
- 使わない設定やコードが多く、全体を把握しきれない。絞り込みや多階層のカテゴリなど、やりたいことを自由に足しにくい
汎用テーマは「どのお店でも使える」ことが強みなので、これは向き不向きの話です。僕がやりたいのは、お店ごとにデザインを一から作ることなので、見た目を持たず、機能だけを持つ土台を自分で用意することにしました。
HTML の構造やクラス名を案件ごとに全部変えても、動きが壊れない
土台は小さく、すべてのファイルに存在理由がある(使わない設定・コード・セクションを残さない)
ファイルを「core」と「starter」の2種類に分ける
土台のファイルは、役割で2種類に分けています。見分けやすいように、ファイル名で区別します。
| 分類 | 目印 | 中身 | 案件での扱い |
|---|---|---|---|
| core(中核) | ファイル名が core- で始まる | 動きとデータを担う部品(JS と、データを出力する snippet) | 編集しない。直すときは土台で直し、案件に取り込む |
| starter(たたき台) | 目印なし | 装飾なしの骨組みのセクション・テンプレート・スタイル | 自由に書き換えてよい。使わないものは削除する |
starter は、土台だけで動作を確かめるための「装飾なしの骨組み」も兼ねています。案件では starter をほぼ全部書き換え、使わないものは消します。core は触らないので、土台を直したときに、その改善をそのまま案件に取り込めます。
いま core の JS と snippet は、合わせて約1,600行です。この中にカート・バリエーション選択・絞り込み・検索候補などが入っています。
JS はクラス名を一切見ない
このテーマのいちばんの決まりごとがこれです。動きは <core-variant-picker> のようなカスタム要素(Web Components)が担い、JS が掴む目印は data-core-* 属性だけにしています。クラス名(BEM)は見た目専用で、JS からは参照しません。
たとえば、商品ページのバリエーション選択は、starter では次のようなマークアップです(土台の実際のファイルから抜き出しています)。
<core-variant-picker class="product__options" section-id="{{ section.id }}" product-url="{{ product.url }}">
<div data-core-region="options">
{%- for option in product.options_with_values -%}
{%- assign option_name = section.id | append: '-' | append: option.name | handle | append: '-option' -%}
<fieldset class="product__option">
<legend class="product__option-name">{{ option.name }}</legend>
{%- for value in option.values -%}
{%- assign input_id = option_name | append: '-' | append: forloop.index -%}
<input
class="product__option-input"
type="radio"
id="{{ input_id }}"
name="{{ option_name }}"
value="{{ value.id }}"
data-core-option-value
{% if value.selected %}checked{% endif %}
{% unless value.available %}data-core-unavailable{% endunless %}
>
<label class="product__option-label" for="{{ input_id }}">
{{- value -}}
{%- unless value.available %}
<span class="product__option-note">{{ 'products.product.unavailable_value' | t }}</span>
{%- endunless -%}
</label>
{%- endfor -%}
</fieldset>
{%- endfor -%}
</div>
</core-variant-picker>
動きに必要なのは、core-variant-picker 要素と、data-core-option-value(選択肢)、data-core-region(差し替える範囲)だけです。product__option などのクラスは見た目のためのもので、名前を変えても、fieldset をやめて別の入れ子にしても動きます。
デザイナーやコーダーが、JS を気にせずに HTML と CSS を組めます。「このクラスを消したらカートが動かなくなった」という事故が起きません。
部分更新は、JS で HTML を組み立てない
カートの中身や絞り込みの結果など、ページの一部を差し替える処理は、Shopify の Section Rendering API を使っています。URL に section_id を付けて取得すると、そのセクションだけをサーバー側で描いた HTML が返ってくるので、それを data-core-region の範囲ごとに差し替えます。
// url に section_id を付けて取得し、Document として返す(Section Rendering API)。
export async function fetchSectionDocument(url, sectionId, options = {}) {
const target = new URL(url, window.location.href);
target.searchParams.set('section_id', sectionId);
const response = await fetch(`${target.pathname}${target.search}`, { signal: options.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return new DOMParser().parseFromString(await response.text(), 'text/html');
}
// target 内の各 [data-core-region] を、source にある同名の範囲の中身で置き換える。
export function replaceRegions(source, target) {
for (const region of target.querySelectorAll('[data-core-region]')) {
const name = region.getAttribute('data-core-region');
const next = source.querySelector(`[data-core-region="${CSS.escape(name)}"]`);
if (next) region.innerHTML = next.innerHTML;
}
}
JS の側で HTML を組み立てないので、見た目はすべて Liquid に集まります。案件でデザインを変えるときも、触るのは Liquid と CSS だけで済みます。
部品どうしは、document に発火するイベントでつないでいます。たとえば、カートを変更した部品は core:cart:updated を発火し、ヘッダーのカート件数やカートのパネルは、それを受けて自分の範囲を差し替えます。部品がお互いを直接知らなくてよいので、「カートページはあるけどパネルはない」のような組み合わせも自由です。
ビルドなしで ES Modules を使う
JS はフレームワーク・外部ライブラリ・ビルドを使わず、assets/ に core-*.js をそのまま置いています。ファイル同士は ./core-utils.js のような相対パスで import します。
困るのは、Shopify のアセットの URL です。asset_url が付けるキャッシュ更新用の ?v= が、相対パスの import では付きません。そこで、レイアウトで import map(読み込み先の対応表)を出力し、「バージョンなしの URL」を「?v= 付きの URL」に付け替えています。
{%- assign core_modules = 'core-utils.js,core-variant-picker.js,core-cart-api.js,core-cart.js,core-product-form.js,core-cart-items.js,core-cart-drawer.js,core-delivery-dates.js,core-delivery-date.js,core-category-cascade.js,core-facets.js,core-predictive-search.js,core-conditional-fields.js,core-slider.js,core-age-gate.js,core-recently-viewed.js' | split: ',' -%}
<script type="importmap">
{
"imports": {
{%- for file in core_modules -%}
{{ file | asset_url | split: '?' | first | json }}: {{ file | asset_url | json }}
{%- unless forloop.last %},{% endunless -%}
{%- endfor -%}
}
}
</script>
import map は、モジュールのスクリプトが読み込まれる前に置く必要があります。content_for_header にはアプリのスクリプトなどが入るので、レイアウトではその前に import map を出しています。
ビルドの仕組みを持たないのは、あとから見たときに「何がどう変換されているのか」で迷わないためでもあります。SCSS だけは手元で CSS に書き出していて、書き出した CSS もコミットしています(変換し忘れても、いつでもアップロードできるように)。
ルールを破っていないかを、テストで確かめる
「JS はクラス名を見ない」「JS に画面の文言を書かない」といったルールは、文書に書くだけだと、いつか誰かが(たぶん僕が)うっかり破ります。そこで、core の JS のソースを読んで、ルールどおりかを確かめるテストを用意しました。
test('core のJSはクラス名を参照しない', () => {
const patterns = [
/classList|className/,
/querySelector(All)?\(\s*['"`][^'"`]*\.[A-Za-z_-]/,
/closest\(\s*['"`][^'"`]*\.[A-Za-z_-]/,
];
for (const { name, source } of files) {
for (const pattern of patterns) {
assert.doesNotMatch(source, pattern, `${name} が ${pattern} に一致した`);
}
}
});
ほかにも、カスタム要素の名前が core- で始まっているか、新しく足した core の JS が import map の一覧に入っているか、import map が content_for_header より前にあるか、なども確かめています。作業のあとは、このテストと Theme Check(Shopify 公式の自動チェック)を必ず通すようにしています。
土台に入れた機能
「どのお店でも使いそうなもの」と「デモで見せたいもの」を入れています。どれも動きとデータだけで、見た目は案件ごとに作ります。
| 機能 | 内容 |
|---|---|
| カート | 追加・数量変更・削除を、ページ遷移なしで。カートページにするか、横から出るパネルにするかは見た目側で選べる |
| バリエーション選択 | 選択に応じて価格・在庫・画像・URL を切り替える。売り切れ・存在しない組み合わせも出せる |
| 商品一覧 | 絞り込み(Search & Discovery)・並び替え・ページ送りを再読み込みなしで。戻るボタンで前の状態に戻る |
| 検索 | 検索結果ページと、入力中に候補を出す検索 |
| カテゴリ階層 | メタオブジェクトで最大3階層。大分類を選ぶと中分類の選択肢が絞られる |
| 配送希望日時 | カートで日付と時間帯を選び、カート属性として注文に添える(アプリなし) |
| お問い合わせ | Shopify 標準のフォームに、条件で項目を出し分ける仕組みを足したもの |
| スライダー | 中身はメタオブジェクトで管理。外部ライブラリなし |
| そのほか | ブログ、年齢確認、のし・ギフト包装、送料無料まであと○円、最近見た商品、パスワード画面、SEO(OGP・構造化データ) |
逆に、商品レビュー・定期購入・再入荷通知・多言語化は入れていません。アプリや外部サービスのほうが向いている領域なので、必要になった案件で個別に考えます。
割り切ったこと
Shopify の仕組み上、テーマではどうにもならないところもあります。最初に割り切っておいたことを書いておきます。
- 決済画面
-
Shopify Plus でないとカスタマイズできないので、テーマの対象外にしました。
- マイページ
-
Shopify の新しい顧客アカウントを使います。テーマで変えられるのはロゴや色くらいなので、テーマからはリンクを置くだけです。
- 配送希望日時の「必須」
-
テーマの「購入手続きへ」ボタンでは未選択だと進めないようにしていますが、Shop Pay などの急ぎ決済ボタンや、決済画面への直接アクセスはテーマのチェックを通りません。完全な強制ではない、と割り切っています(急ぎ決済ボタンを隠す設定は用意しています)。
- 送料無料まであと○円
-
Shopify の送料設定はテーマから読めないので、基準額はテーマ設定に置き、送料設定と手で合わせます。
案件は、土台をクローンして始める
デモや案件は、土台のフォルダをコピーするのではなく、Git のリポジトリをクローンして始めます。元の土台を別のリモート名にしておけば、土台を直したときに、その改善を取り込めます。
git clone (土台のリポジトリ) my-shop
cd my-shop
git remote rename origin upstream
git remote add origin (案件のリポジトリ)
# 土台の改善を取り込む
git fetch upstream
git merge upstream/main
ファイルだけをコピーすると履歴がつながらず、あとから土台を取り込もうとしても、Git が「関係のない履歴」として取り込みを断ります。
この土台から、実際にデモストアを2つ作りました。静かな和の酒蔵と、爆音のレコードレーベルで、見た目はまったく違いますが、どちらもcore のファイルは1行も変えていません。デモの作り方は、それぞれ別の記事に書きました。
よくある疑問
この土台では、担当者はテーマエディタを開かない前提にしています。担当者が触るのは、商品・スライダーの中身(管理画面の「コンテンツ」)・ブログだけで、どれも管理画面のふつうの画面で完結します。テーマを更新しても、担当者の編集が上書きされることはありません。
既製テーマは、決まった見た目を設定で調整していく作り方です。この土台は、見た目を一から作り、そこに機能の部品を差し込む作り方です。デザインの自由度は高くなる一方で、見た目は毎回作るので、既製テーマのように「設定だけで形になる」わけではありません。
管理画面の Git 連携は GitHub 向けで、僕は Bitbucket を使っているので、テーマのアップロードは Shopify CLI で行っています。
おわりに
「見た目を持たない土台」は、作っている途中は地味でした。装飾なしの骨組みで、カートに入れて、絞り込んで、を延々と確かめるだけです。でも、その上にデモを作り始めたら、デザインのことだけを考えていればいいのが本当に楽でした。
既製テーマの枠に収まらないデザインで Shopify のお店を作りたい人や、テーマの全体を把握しきれずに困っている人の参考になればうれしいです。
Shopify のオリジナルテーマ制作のような制作・改修のご相談も受け付けています。お気軽にどうぞ。