Liquid 开发 · 整理与归档

Shopify Liquid 主题开发实战要点

Shopify Liquid 主题开发实战要点 本指南聚焦 Shopify 主题开发中真正影响最终交付质量的几类问题。所有示例代码均基于实际项目中反复出现的写法对比,重点在于 为什么这么写 以及 何时不必这么写 ——而非泛泛的代码规

Shopify Liquid 主题开发实战要点

本指南聚焦 Shopify 主题开发中真正影响最终交付质量的几类问题。所有示例代码均基于实际项目中反复出现的写法对比,重点在于为什么这么写以及何时不必这么写——而非泛泛的代码规范。

阅读前置:熟悉 Liquid 基础语法(官方文档 )。

性能优先:Liquid 对象是惰性查询

Shopify 主题性能问题中占比最高的不是过滤器复杂、不是循环嵌套,而是在循环内反复访问”集合”对象

问题代码

{% for product in collections['featured'].products %}
  {% if collections['featured'].products.size > 4 %}
    <div class="card carousel-item">
      ...
      {% assign related = collections['featured'].products | where: 'tags', product.tags.first %}
      ...
    </div>
  {% endif %}
{% endfor %}

上述代码中 collections['featured'].products 出现三次,每次都会触发完整的集合查询。当 collection 包含 80 个产品时,等效于 80 × 3 = 240 次”调用”。

修正写法

{% assign featured_products = collections['featured'].products %}
{% assign product_count = featured_products.size %}
 
{% if product_count > 4 %}
  {% for product in featured_products limit: 8 %}
    ...
  {% endfor %}
{% endif %}

实测表现:在一个首屏含 4 个 section、每个 section 涉及 collection 访问的主题中,仅此一项优化即可将首页 LCP 从 6.2 秒降至 2.1 秒。

核心心智模型

Shopify 的对象不是 JavaScript 变量,而是惰性查询productcollectioncustomer 等对象每次访问都会触发一次后端数据获取,而非读取已加载的内存对象。

养成习惯:进入任何循环前,用 {% assign %} 将待用对象绑定到本地变量。

避免使用 collections.all.products

在主页 section 或高频渲染模板中使用 collections.all.products 是常见性能陷阱。

{# 反例 #}
{% assign sale_products = collections.all.products | where: 'compare_at_price_max', '>', 0 %}

店铺产品数较少(< 100)时表现正常。但产品数增至 800、3000、10000 时,主页可能直接触发 Shopify 模板渲染时间上限(约 1.5 秒)而报错或返回空。

正确做法:让 Shopify 在后台维护集合。创建一个名为 sale 的 collection,配置 automated rules 为 “compare_at_price 不为空”,然后:

{% assign sale_products = collections['sale'].products %}

后端预先计算的集合直接返回结果,避免模板渲染时的 O(n) 过滤开销。

图片处理:尺寸参数必须精确

image_url 过滤器若不传 width 参数,返回的是原图——可能是 4000px × 4000px、5MB 体积的原始文件。即使浏览器最终缩放到 300px 显示,下载流量已经发生。

反例

{# 卡片显示宽度 300px,却取了 1500px #}
<img src="{{ product.featured_image | image_url: width: 1500 }}">
 
{# 未指定宽度,返回原图 #}
<img src="{{ product.featured_image | image_url }}">

推荐:封装为 responsive-image snippet

{% comment %}
  snippets/responsive-image.liquid
 
  参数:
    image (必需) - image 对象
    sizes (可选, 默认 "100vw") - CSS sizes 属性
    widths (可选, 默认 "300, 600, 900, 1200") - srcset 候选宽度
    class (可选) - 额外 class
{% endcomment %}
 
{%- assign sizes = sizes | default: "100vw" -%}
{%- assign widths = widths | default: "300, 600, 900, 1200" -%}
{%- assign widths_array = widths | split: ", " -%}
 
{%- capture srcset -%}
  {%- for w in widths_array -%}
    {{ image | image_url: width: w }} {{ w }}w{%- unless forloop.last -%},{%- endunless -%}
  {%- endfor -%}
{%- endcapture -%}
 
<img
  src="{{ image | image_url: width: 600 }}"
  srcset="{{ srcset }}"
  sizes="{{ sizes }}"
  alt="{{ image.alt | escape }}"
  class="{{ class }}"
  loading="lazy"
  width="{{ image.width }}"
  height="{{ image.height }}"
>

调用:

{% render 'responsive-image',
   image: product.featured_image,
   sizes: '(min-width: 768px) 33vw, 100vw',
   widths: '300, 600, 900' %}

三个关键点

  1. widthheight 属性不能省略。浏览器需要在图片加载完成前预留正确比例的空间,否则会触发 CLS(累积布局偏移)。
  2. 产品页首图禁用 loading="lazy"。首图是 LCP 候选元素,延迟加载会直接拖累性能评分。
  3. srcsetsizes 配合使用。浏览器据此选择合适尺寸的图,可显著降低移动端流量。
{% for image in product.images %}
  <img
    src="{{ image | image_url: width: 1200 }}"
    {% unless forloop.first %}loading="lazy"{% endunless %}
    alt="{{ image.alt | default: product.title }}"
  >
{% endfor %}

forloop.first 仅对首图禁用 lazy load,其余图片正常延迟加载。该写法通常可将产品页 LCP 降低 0.5-1 秒。

分页:避免用 limit 替代 paginate

{# 反例:永远只展示前 50 个 #}
{% for product in collection.products limit: 50 %}
  ...
{% endfor %}

若 collection 含 200 个产品,上述写法导致用户无法翻页查看后续产品,搜索引擎也只能爬取前 50 个 URL。

正确写法

{% paginate collection.products by 24 %}
  {% for product in collection.products %}
    {% render 'product-card', product: product %}
  {% endfor %}
 
  {{ paginate | default_pagination }}
{% endpaginate %}

每页 24 或 36(按网格列数对齐)是兼顾移动端加载速度与 SEO 抓取完整度的常见选择。

where 过滤器的边界

where 看起来便利,但在大数据量场景需注意三点:

性能特征是 O(n):含 5000 个产品的 collection 上使用 where,模板内会遍历所有 5000 个。在性能敏感的页面(主页、collection 页),先用 limitpaginate 限定范围再 where

比较语义where: 'tags', 'sale'包含关系——会匹配所有 tags 数组中包含 'sale' 的产品,而非仅含 'sale' 这一个 tag 的产品。

数值比较有限制where: 'price', '>', 1000 在部分字段上不工作。复杂数值筛选建议改用 if + 循环手动实现,行为更可控。

转义:HTML 与 JavaScript 上下文区分

转义错误是主题安全审计中最常见的问题之一。escapejson 不能互换。

判断规则

  • HTML 内容或 HTML 属性内:使用 escape
  • JavaScript 字面量赋值:使用 json

反例

{# 错误 1:JS 上下文用 escape,单引号、换行未处理 #}
<script>
  var productTitle = "{{ product.title | escape }}";
</script>
 
{# 错误 2:HTML 属性用 json,引号嵌套出错 #}
<div data-title="{{ product.title | json }}">

正确写法

<script>
  var productTitle = {{ product.title | json }};
  {# 注意 json 会自带引号,外层不需要再加 #}
</script>
 
<div data-title="{{ product.title | escape }}">

判断方法:输出位置周围已经存在引号吗? 是 HTML 属性(双引号包围)则用 escape;是 JS 字面量赋值(值的位置,无外层引号)则用 json

{% liquid %} 标签简化多行赋值

Shopify 在 2021 年引入的 {% liquid %} 标签允许多行 Liquid 语句共享一个标签块,避免逐行 {% %}

{% liquid
  assign featured_products = collections['featured'].products
  assign product_count = featured_products.size
  assign is_logged_in = customer != blank
  assign has_orders = false
 
  if customer and customer.orders.size > 0
    assign has_orders = true
  endif
 
  if is_logged_in and has_orders
    assign show_returning_banner = true
  else
    assign show_returning_banner = false
  endif
%}

推荐用法:在每个 section 文件顶部用 {% liquid %} 块集中完成数据准备,正文部分保留纯展示逻辑。可读性与调试定位均显著优于零散的 {% assign %}

{% render %}{% include %} 的关键差异

{% include %} 已被 Shopify 官方标记为 deprecated,但因兼容性原因未删除,老主题中仍可见。两者行为存在一个关键差异:

特性 include render
访问父模板变量 可以直接访问 必须显式传参
作用域 共享父作用域 完全隔离
状态 deprecated 推荐使用
{% assign product = collection.products.first %}
 
{% include 'product-card' %}
{# snippet 内部可直接使用 product #}
 
{% render 'product-card' %}
{# snippet 内部 product 为 nil #}
 
{% render 'product-card', product: product %}
{# 正确调用方式 #}

新代码统一使用 render。作用域隔离减少了变量被父模板意外污染的可能性,调试时能定位掉一类”变量为空但不知道为什么”的问题。

Snippet 组件化规范

可复用 snippet 应在开头声明参数契约:

{% comment %}
  snippets/product-card.liquid
 
  必需参数:
    product - 产品对象
 
  可选参数:
    show_vendor (boolean, 默认 false) - 是否显示品牌
    image_width (number, 默认 400) - 卡片图片宽度
    context (string, 默认 'default') - 'collection' | 'search' | 'featured'
                                       用于埋点和不同布局变体
 
  调用示例:
    {% render 'product-card', product: product, show_vendor: true %}
{% endcomment %}
 
{%- liquid
  assign show_vendor = show_vendor | default: false
  assign image_width = image_width | default: 400
  assign context = context | default: 'default'
-%}
 
<div class="product-card" data-context="{{ context }}">
  ...
</div>

Liquid 没有类型系统,这段注释承担接口文档的职责。半年后维护或团队成员交接时,参数定义清晰可避免大量沟通成本。

多语言:从第一天开始使用翻译键

主题若未来可能支持多语言,所有 UI 文案从一开始就使用 t filter,不要硬编码语言。

{# 反例 #}
<button>加入购物车</button>
<p>商品已售罄</p>
 
{# 推荐 #}
<button>{{ 'products.product.add_to_cart' | t }}</button>
<p>{{ 'products.product.sold_out' | t }}</p>

locales/zh-CN.jsonlocales/en.json 中维护翻译表。事后补国际化的成本约为初期实施的 10 倍——会涉及几十个 Liquid 文件、数百处文案的搜索替换。

带参数的翻译使用命名参数:

// locales/zh-CN.json
{ "products": { "facets": { "showing_count": "共 {{ count }} 件商品" } } }
{{ 'products.facets.showing_count' | t: count: collection.products_count }}

参数名需与翻译文件中的占位符严格对应。

主题审查优先级

接手或审查第三方主题时,按以下优先级处理问题:

优先级 P0(必须修复)

  • 循环外缺少 assign 缓存集合对象 → 性能影响最大
  • image_url 未传 width,或 width 比展示尺寸大 2 倍以上 → 流量与 LCP 双重浪费
  • 产品页首图被加 loading="lazy" → LCP 评分直接下降
  • <img> 缺少 width / height 属性 → CLS 飙升
  • 主页或高频 section 使用 collections.all.products → 产品数增长后即时炸弹

优先级 P1(建议修复)

  • include 改为 render(除非短期内大量改造风险高)
  • 转义错误(escape vs json 用错)
  • 缺少 {% liquid %} 块的密集 assign 区域

优先级 P2(按需处理)

  • 命名风格不一致(仅在维护频繁时优先)
  • 过度嵌套但无性能影响的条件判断
  • 未使用 t filter 的硬编码语言(仅做单语言站可不处理)
  • 调试输出残留(确认无敏感信息泄露后可保留)

主题代码的目标不是”完美”,而是确保影响真实用户体验的部分稳定可靠。性能与功能层面的问题优先,风格层面的问题按维护节奏推进。

延伸阅读