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 变量,而是惰性查询。product、collection、customer 等对象每次访问都会触发一次后端数据获取,而非读取已加载的内存对象。
养成习惯:进入任何循环前,用 {% 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' %}三个关键点
width与height属性不能省略。浏览器需要在图片加载完成前预留正确比例的空间,否则会触发 CLS(累积布局偏移)。- 产品页首图禁用
loading="lazy"。首图是 LCP 候选元素,延迟加载会直接拖累性能评分。 srcset与sizes配合使用。浏览器据此选择合适尺寸的图,可显著降低移动端流量。
{% 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 页),先用 limit 或 paginate 限定范围再 where。
比较语义:where: 'tags', 'sale' 是包含关系——会匹配所有 tags 数组中包含 'sale' 的产品,而非仅含 'sale' 这一个 tag 的产品。
数值比较有限制:where: 'price', '>', 1000 在部分字段上不工作。复杂数值筛选建议改用 if + 循环手动实现,行为更可控。
转义:HTML 与 JavaScript 上下文区分
转义错误是主题安全审计中最常见的问题之一。escape 与 json 不能互换。
判断规则
- 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.json 与 locales/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(除非短期内大量改造风险高)- 转义错误(
escapevsjson用错) - 缺少
{% liquid %}块的密集assign区域
优先级 P2(按需处理)
- 命名风格不一致(仅在维护频繁时优先)
- 过度嵌套但无性能影响的条件判断
- 未使用
tfilter 的硬编码语言(仅做单语言站可不处理) - 调试输出残留(确认无敏感信息泄露后可保留)
主题代码的目标不是”完美”,而是确保影响真实用户体验的部分稳定可靠。性能与功能层面的问题优先,风格层面的问题按维护节奏推进。