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が標準で持っている処理のほとんどが、動的に更新したい箇所では二重に実装されることになります。

同じ表示ルールをLiquidとJavaScriptの両方に書く構造。従来はサーバー側の判定をクライアント側で再実装する必要があったが、partialではLiquidに1回書くだけで済む

同じ表示ルールが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は「バリアントが変わったから、この領域を更新して」と伝えているだけです。

実測した行数の比較。ページャーと並び替えが537行から27行、バリアント切替が866行から27行になった

実測した行数と、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 に登録しておけば、再描画でリンクが作り直されても貼り直しが要りません。

ページャーのリンクをクリックしてから再描画されるまでの流れ。Liquidが生成したURLをJavaScriptがそのまま使う仕組み

URLとpartial名は、それぞれ別の役割を持っている

効く場面と、効かない場面

実際に書いてみると、どこまでサーバーに任せられるかの判断は、意外とはっきりしていました。その情報を、サーバーが知っているかどうかです。

並び替え・ページャー・絞り込み・検索・バリアント切替は、いずれもShopifyが解釈するURLパラメータで表せます。カートの個数はサーバー側の状態そのものです。これらは素直に置き換わりました。

一方で、次のものはpartialでは扱えません

  • 開閉・ドラッグ・アニメーション:ドロワーやモーダルの状態は、サーバーが知り得ません
  • 入力中の文字数カウント:1文字ごとにサーバーへ問い合わせるわけにはいきません
  • 無限スクロール:partialは領域を「入れ替える」仕組みなので、要素を「足していく」動きには向きません
  • 現在時刻やランダム表示:試したところ、キャッシュが効いて同じ結果が返り続けました。サーバー側の状態が変わらないためです
サーバーに任せられるものとJavaScriptに残るものの線引き。判断基準はその情報をサーバーが知っているかどうか

サーバーに任せられるもの、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 %} を囲んでみるところから、手触りを確かめてみてください。

参照

Back to blog