Shopifyの新タグ {% partial %} で、Horizonの描画コードを削減
2026年7月、Shopifyの developer preview(Liquid July '26)で、テーマの新しいタグが試せるようになりました。その中の {% partial %} は、ページの一部だけをサーバー側で再描画できるタグです。
触っているうちに気になったのが、「これでテーマのコードが大幅に削減できる」という話でした。Horizonのように高機能なテーマは、動きを実現するためにかなりの量のJavaScriptを抱えています。それが本当に減るのか——実際に同じ機能を書き直して、行数を比べてみました。
結果は、ページャーと並び替えが 537行 → 27行、バリアント切替が 866行 → 27行。なぜここまで変わるのか、実装したコードとあわせて整理します。
まず、Horizonが抱えているJavaScriptの量
比較の出発点として、Horizon 4.1.3 の assets/ にあるJSファイルを数えてみました。
| 項目 | 実測値 |
|---|---|
| JSファイル数 | 81ファイル |
| 総行数 | 22,610行 |
高機能なテーマなので、量が多いのは当然です。ただ中を見ていくと、「Liquidで書いた表示ルールを、JavaScriptでもう一度実装している」箇所が繰り返し出てきます。これが今回の主題です。
なぜJavaScriptが必要だったのか
「在庫が5以下なら残りわずかと出す」という表示を例に考えます。初回表示はLiquidで書けます。
{% if v.inventory_quantity <= 5 %}
<p>残り{{ v.inventory_quantity }}点</p>
{% endif %}
ところが、バリアントを切り替えたときにこの表示を更新したくなると、話が変わります。ページ全体を再読み込みせずに更新するには、JavaScriptで在庫数を取得して、同じ判定をもう一度書くことになります。
// 同じ判定を、JavaScript側でも実装する
if (data.inventory_quantity <= 5) {
el.textContent = `残り${data.inventory_quantity}点`
}
これが二重処理です。そして対象は在庫表示だけではありません。価格の桁区切り、通貨記号、セール価格の出し方、SKU、購入可否の判定——Liquidが標準で持っている処理のほとんどが、動的に更新したい箇所では二重に実装されることになります。
同じ表示ルールが2箇所に存在する構造
{% partial %} は、この構造を変えます。更新したい領域を名前で囲んでおくと、サーバーがその領域だけをLiquidで再描画して返してくれるようになります。
実測①:ページャーと並び替え(537行 → 27行)
まずコレクションページで試しました。ページ番号を押すとページ遷移なしで切り替わり、並び替えも同様に動く——という、よくある実装です。
Liquid側:更新したい領域を名前で囲む
{% partial 'product-list' %}
{% paginate collection.products by 12 %}
{% for product in collection.products %}
...商品カード...
{% endfor %}
{% comment %} ページャー(Liquidが生成する本物のリンク) {% endcomment %}
{% if paginate.pages > 1 %}
<nav>
{% for part in paginate.parts %}
{% if part.is_link %}
<a href="{{ part.url }}" data-page>{{ part.title }}</a>
{% else %}
<span aria-current="page">{{ part.title }}</span>
{% endif %}
{% endfor %}
</nav>
{% endif %}
{% endpaginate %}
{% endpartial %}
ポイントは paginate.parts です。「何ページ目が現在か」「各リンクのURLは何か」をLiquidが計算済みで持っています。現在ページかどうかの判定も part.is_link ひとつで済みます。
JavaScript側:27行
import { partials } from '@shopify/partial-rendering';
const status = document.getElementById('status');
async function render(url, push = true) {
status.textContent = '読み込み中…';
try {
if (push) history.pushState({}, '', url);
const update = await partials.fetch('product-list', { url });
partials.apply(update);
status.textContent = '';
} catch (e) {
status.textContent = 'エラー: ' + e.message;
console.error(e);
}
}
// 並び替え(1ページ目に戻す)
document.getElementById('sort-select').addEventListener('change', (e) => {
const url = new URL(location.href);
url.searchParams.set('sort_by', e.target.value);
url.searchParams.delete('page');
render(url.href);
});
// ページャー:イベント委譲なので、再描画後のリンクにも再バインド不要
document.addEventListener('click', (e) => {
const link = e.target.closest('[data-page]');
if (!link) return;
e.preventDefault();
render(link.href);
});
// ブラウザの戻る/進む
addEventListener('popstate', () => render(location.href, false));
これでページ切替・並び替え・URL同期・ブラウザの戻る/進むが動きます。ページ番号の計算も、商品カードのHTML組み立ても、JavaScript側には一行もありません。
機能は完全に同一ではありません。今回書いたのは古典的なページャーで、Horizonが持っている無限スクロールと先読みキャッシュは実装していません。数字は「同じ目的を最小限で実現した場合」の比較として見てください。
実測②:バリアント切替(866行 → 27行)
次に商品ページです。バリアントを切り替えると価格・SKU・在庫表示・画像・購入ボタンの状態が一度に変わる、という実務で必ず必要になる機能です。
Horizon側では、この機能が5つのファイルに分かれていました。
| ファイル | 行数 | 役割 |
|---|---|---|
| variant-picker.js | 597 | 選択の管理・HTML取得・DOM差し替え |
| product-price.js | 93 | 価格の差し替え |
| product-sku.js | 74 | SKUの差し替え |
| variant-resolution.js | 51 | 切替が重なったときの調整 |
| product-inventory.js | 51 | 在庫表示の差し替え |
| 合計 | 866 | → partial では 27行 |
Liquid側:変わる部分を丸ごと囲む
{% partial 'product-details' %}
{%- assign v = product.selected_or_first_available_variant -%}
{% comment %} 画像 {% endcomment %}
<img src="{{ v.featured_image | image_url: width: 480 }}">
{% comment %} 価格(セール時は比較価格も) {% endcomment %}
<p>{{ v.price | money }}
{% if v.compare_at_price > v.price %}
<s>{{ v.compare_at_price | money }}</s>
{% endif %}
</p>
{% if v.sku != blank %}<p>SKU: {{ v.sku }}</p>{% endif %}
{% comment %} 在庫の文言分岐 {% endcomment %}
{% if v.available %}
{% if v.inventory_quantity > 0 and v.inventory_quantity <= 5 %}
<p>残りわずか(あと {{ v.inventory_quantity }} 点)</p>
{% else %}
<p>在庫あり</p>
{% endif %}
{% else %}
<p>在庫切れ</p>
{% endif %}
{% comment %} 購入ボタン(在庫切れなら無効化) {% endcomment %}
{% form 'product', product %}
<input type="hidden" name="id" value="{{ v.id }}">
<button type="submit" {% unless v.available %}disabled{% endunless %}>
{% if v.available %}カートに追加{% else %}在庫切れ{% endif %}
</button>
{% endform %}
{% endpartial %}
JavaScript側:27行
import { partials } from '@shopify/partial-rendering';
const select = document.getElementById('variant-select');
const status = document.getElementById('status');
async function render(url, push = true) {
status.textContent = '読み込み中…';
try {
if (push) history.pushState({}, '', url);
const update = await partials.fetch('product-details', { url });
partials.apply(update);
status.textContent = '';
} catch (e) {
status.textContent = 'エラー: ' + e.message;
console.error(e);
}
}
select.addEventListener('change', () => {
const url = new URL(location.href);
url.searchParams.set('variant', select.value);
render(url.href);
});
addEventListener('popstate', async () => {
await render(location.href, false);
const id = new URL(location.href).searchParams.get('variant');
if (id) select.value = id;
});
注目したいのは、JavaScript側に表示ルールが一行もないことです。「在庫が5以下なら残りわずか」という判定も、在庫切れ時にボタンを無効化する処理も、すべてLiquidの中にあります。JavaScriptは「バリアントが変わったから、この領域を更新して」と伝えているだけです。
実測した行数と、866行の内訳
なぜここまで減るのか
書いてみて分かった理由は3つでした。
① 表示ルールを1箇所に書けばよくなる
これが最も大きい要因です。サーバーが同じLiquidを再実行してくれるので、価格の整形も在庫の判定もJavaScript側に書く必要がありません。バリアント切替で5ファイル・866行が必要だったのは、価格・SKU・在庫をそれぞれ個別に差し替えていたからでした。partialでは領域ごと入れ替える1回の呼び出しで済みます。
② DOMを差し替える処理を自前で持たなくてよい
サーバーから受け取ったHTMLを画面に反映するとき、単純に差し替えると入力中の文字が消える、開いていた要素が閉じる、スクロール位置が飛ぶといった問題が起きます。Horizonはこれを解決するために、DOMを差分適用する専用のライブラリ(690行)を持っていました。
この「新しいHTMLと現在のDOMを比べて、違う部分だけを書き換える」という処理は、突き詰めるとReactの仮想DOM(Virtual DOM)が解いているのと同じ問題です。Horizonは、テーマの内側でそれに近い仕組みを自作する必要がありました。690行という規模は、そのために積み上がったものです。
partialでは partials.apply() がこの役割を担います。実際に確認したところ、入力中の値・チェック状態・選択状態・フォーカス位置・スクロール位置は、いずれも維持されました。この面倒を自分で見る必要がなくなります。
つまりフロントエンド側で重厚に組み上げていた処理の大半が、サーバー側に戻るという構図です。表示するHTMLを組み立てるのはLiquid、それを画面に反映するのはプラットフォーム側。テーマが書くJavaScriptは「いつ、どの領域を更新するか」を伝えるだけの薄い層になります。公式の解説でも、仮想DOMを導入することなくきめ細かな更新を実現する、という位置づけで説明されています。
③ もともと存在していたURLをそのまま使える
?page=2 や ?variant=123 は、もともとページ遷移用に存在していたURLです。partialはそれを引数に取るので、JSONを返す専用のエンドポイントを設計する必要がありません。
さらに副産物として、ページャーのリンクは本物の <a href> のままなので、JavaScriptが読み込まれなくても通常のページ遷移として機能します。イベントを document に登録しておけば、再描画でリンクが作り直されても貼り直しが要りません。
URLとpartial名は、それぞれ別の役割を持っている
効く場面と、効かない場面
実際に書いてみると、どこまでサーバーに任せられるかの判断は、意外とはっきりしていました。その情報を、サーバーが知っているかどうかです。
並び替え・ページャー・絞り込み・検索・バリアント切替は、いずれもShopifyが解釈するURLパラメータで表せます。カートの個数はサーバー側の状態そのものです。これらは素直に置き換わりました。
一方で、次のものはpartialでは扱えません。
- 開閉・ドラッグ・アニメーション:ドロワーやモーダルの状態は、サーバーが知り得ません
- 入力中の文字数カウント:1文字ごとにサーバーへ問い合わせるわけにはいきません
- 無限スクロール:partialは領域を「入れ替える」仕組みなので、要素を「足していく」動きには向きません
- 現在時刻やランダム表示:試したところ、キャッシュが効いて同じ結果が返り続けました。サーバー側の状態が変わらないためです
サーバーに任せられるもの、JavaScriptに残るもの
つまりJavaScriptがなくなるわけではありません。これまでJavaScriptが担っていた「表示する内容を組み立てる処理」をサーバー側に返すことができ、JavaScript側は「どんな状態に更新するか」を伝えるだけで済むようになりました。今回27行に収まったのは、この分担になったからです。
まとめ
同じ機能を書き直してみた結果、ページャーと並び替えが537行 → 27行、バリアント切替が866行 → 27行になりました。減った理由は、Liquidが持っている処理をJavaScript側で作り直さなくてよくなったことに尽きます。
実装して感じたのは、コード量よりも「表示ルールが1箇所にある」ことの安心感でした。「在庫が5以下なら残りわずか」という条件を変えたいとき、直す場所が1つで済む。両方を直したか確認する必要がない。この見通しの良さが、行数以上に大きい変化だと思います。
ただし、これはまだ developer preview です。開発ストアを作るときに feature preview の「Liquid July '26 changes」を有効にする必要があり、仕様も変わり得ます。無限スクロールのような「追加型」の動きには向かない、キャッシュの都合で扱えない値がある——といった制約も見えてきました。
どこまで実務で使えるものになるかは、今後のアップデート次第でしょう。ただ「Liquidに1回書けば、更新もサーバーに任せられる」という方向性そのものは、テーマを触る立場からするとかなり筋が良さそうに感じています。まずは開発ストアで {% partial %} を囲んでみるところから、手触りを確かめてみてください。
参照
-
Liquid: Back to the Future(Ben Sehl / Shopify)
新しいアーキテクチャの背景と設計意図についての解説 -
Developer preview: Liquid block and partial tags(Shopify Changelog)
新タグの提供開始アナウンス -
Partial tag(Shopify 開発者ドキュメント)
タグの構文と JavaScript API のリファレンス