Skip to main content

主题应用扩展

主题应用扩展允许你将应用功能直接添加到商家的店面中,无需编辑他们的主题代码。 通过 Online Store 2.0,商家可以使用主题编辑器将你的应用区块拖放到页面中——无需代码更改,应用卸载时也不会破坏主题。这是将应用 UI 集成到 Shopify 店面的标准方式。

Online Store 2.0 和应用区块

Online Store 2.0 引入了基于 Section 的架构,其中每个页面由 Section 组成,Section 包含 Block。应用区块是你的应用注册的特殊类型的 Block。商家通过主题编辑器添加它们,就像原生 Shopify Block 一样。

主题应用扩展有两种类型:

应用区块

应用区块是商家放置在 Section 中的可见 UI 组件。例如产品评价组件、尺码指南、信任徽章和倒计时器。

  • 在 Section 的 Block 列表中渲染
  • 商家控制相对于其他 Block 的位置
  • 可以添加到任何支持 Block 的 Section
  • 通过主题编辑器的设置面板进行配置

应用嵌入区块

应用嵌入区块在全局范围注入到页面中,位于 Section 结构之外。它们适用于不受布局限制、需要在每个页面上显示的功能。

  • 在文档级别渲染(通常在 <head> 中或 </body> 之前)
  • 启用后始终活跃——不绑定到特定的 Section
  • 常用于聊天组件、分析脚本、浮动按钮和通知栏
  • 在主题编辑器的应用嵌入面板中开关

创建主题应用扩展

# Generate a theme app extension
shopify app generate extension --template theme_app_extension --name product-reviews

This creates:

extensions/product-reviews/
├── blocks/
│ ├── review-widget.liquid
│ └── review-summary.liquid
├── assets/
│ ├── reviews.css
│ └── reviews.js
├── locales/
│ └── en.default.json
├── snippets/
│ └── star-rating.liquid
└── shopify.extension.toml

Extension Configuration

# extensions/product-reviews/shopify.extension.toml
api_version = "2025-01"

[[extensions]]
type = "theme"
name = "Product Reviews"
handle = "product-reviews"

[[extensions.blocks]]
type = "review-widget"
name = "Review Widget"
target = "section"

[[extensions.blocks]]
type = "review-summary"
name = "Review Summary"
target = "section"

[[extensions.embeds]]
type = "review-popup"
name = "Review Request Popup"

使用 Liquid 构建应用区块

应用区块使用 Liquid(Shopify 的模板语言)编写。它们可以访问与主题代码相同的 Liquid 对象,以及你的应用的配置设置。

评价组件区块

{% comment %}
blocks/review-widget.liquid
Displays product reviews with star ratings and filtering
{% endcomment %}

{% schema %}
{
"name": "Review Widget",
"target": "section",
"stylesheet": "reviews.css",
"javascript": "reviews.js",
"settings": [
{
"type": "range",
"id": "reviews_per_page",
"min": 3,
"max": 20,
"step": 1,
"default": 5,
"label": "Reviews per page"
},
{
"type": "select",
"id": "default_sort",
"label": "Default sort order",
"options": [
{ "value": "newest", "label": "Newest first" },
{ "value": "highest", "label": "Highest rated" },
{ "value": "lowest", "label": "Lowest rated" },
{ "value": "helpful", "label": "Most helpful" }
],
"default": "newest"
},
{
"type": "checkbox",
"id": "show_photos",
"label": "Show review photos",
"default": true
},
{
"type": "checkbox",
"id": "show_verified_badge",
"label": "Show verified purchase badge",
"default": true
},
{
"type": "color",
"id": "star_color",
"label": "Star color",
"default": "#FFB800"
},
{
"type": "color",
"id": "star_empty_color",
"label": "Empty star color",
"default": "#E0E0E0"
}
]
}
{% endschema %}

<div
class="app-reviews-widget"
data-product-id="{{ product.id }}"
data-reviews-per-page="{{ block.settings.reviews_per_page }}"
data-default-sort="{{ block.settings.default_sort }}"
data-show-photos="{{ block.settings.show_photos }}"
style="
--star-color: {{ block.settings.star_color }};
--star-empty-color: {{ block.settings.star_empty_color }};
"
>
{% comment %} Summary section with aggregate rating {% endcomment %}
<div class="reviews-summary">
<div class="reviews-summary__average">
{% assign avg_rating = product.metafields.reviews.average_rating | default: 0 %}
{% assign review_count = product.metafields.reviews.count | default: 0 %}

<span class="reviews-summary__score">{{ avg_rating | round: 1 }}</span>
{% render 'star-rating', rating: avg_rating %}
<span class="reviews-summary__count">
{{ review_count }} {{ review_count | pluralize: 'review', 'reviews' }}
</span>
</div>

{% if review_count > 0 %}
<div class="reviews-summary__breakdown">
{% for i in (1..5) reversed %}
{% assign star_count = product.metafields.reviews[i | append: '_star_count'] | default: 0 %}
{% assign percentage = star_count | times: 100.0 | divided_by: review_count | round %}
<div class="reviews-breakdown__row">
<span class="reviews-breakdown__label">{{ i }} star</span>
<div class="reviews-breakdown__bar">
<div
class="reviews-breakdown__fill"
style="width: {{ percentage }}%"
></div>
</div>
<span class="reviews-breakdown__count">{{ star_count }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>

{% comment %} Filter and sort controls {% endcomment %}
<div class="reviews-controls">
<div class="reviews-controls__filter">
<label for="reviews-rating-filter">Filter by rating:</label>
<select id="reviews-rating-filter" class="reviews-filter-select">
<option value="all">All ratings</option>
<option value="5">5 stars</option>
<option value="4">4 stars</option>
<option value="3">3 stars</option>
<option value="2">2 stars</option>
<option value="1">1 star</option>
</select>
</div>
</div>

{% comment %} Reviews list -- populated by JavaScript {% endcomment %}
<div class="reviews-list" id="reviews-list-{{ product.id }}">
<div class="reviews-loading">Loading reviews...</div>
</div>

{% comment %} Write a review CTA {% endcomment %}
{% if customer %}
<div class="reviews-write">
<button
class="reviews-write__button"
data-product-id="{{ product.id }}"
data-customer-id="{{ customer.id }}"
>
Write a Review
</button>
</div>
{% else %}
<div class="reviews-write">
<p>
<a href="{{ routes.account_login_url }}">Log in</a> to write a review.
</p>
</div>
{% endif %}

{% if block.settings.show_verified_badge %}
<div class="reviews-badge-info">
<svg class="reviews-verified-icon" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0L10 5.5L16 6L11.5 10L13 16L8 13L3 16L4.5 10L0 6L6 5.5Z" fill="currentColor"/>
</svg>
<span>Verified Purchase reviews are from customers who bought this product.</span>
</div>
{% endif %}
</div>
主题编辑器设置

{% schema %} 区块定义了当商家选择你的应用区块时在主题编辑器中显示的设置。使用描述性标签和合理的默认值。为不明显的设置提供 info 文本。将相关设置分组在一起。商家在主题编辑器中的体验是应用采用率的关键差异化因素。

星级评分代码片段

{% comment %}
snippets/star-rating.liquid
Renders a star rating display

Usage: {% render 'star-rating', rating: 4.5 %}
{% endcomment %}

<div class="star-rating" role="img" aria-label="{{ rating }} out of 5 stars">
{% for i in (1..5) %}
{% if rating >= i %}
<svg class="star-rating__star star-rating__star--full" width="20" height="20" viewBox="0 0 20 20">
<path d="M10 1L12.39 6.97L18.78 7.6L13.89 11.84L15.28 18.08L10 14.85L4.72 18.08L6.11 11.84L1.22 7.6L7.61 6.97L10 1Z" fill="var(--star-color, #FFB800)"/>
</svg>
{% elsif rating > i | minus: 1 %}
<svg class="star-rating__star star-rating__star--half" width="20" height="20" viewBox="0 0 20 20">
<defs>
<linearGradient id="half-{{ i }}">
<stop offset="50%" stop-color="var(--star-color, #FFB800)"/>
<stop offset="50%" stop-color="var(--star-empty-color, #E0E0E0)"/>
</linearGradient>
</defs>
<path d="M10 1L12.39 6.97L18.78 7.6L13.89 11.84L15.28 18.08L10 14.85L4.72 18.08L6.11 11.84L1.22 7.6L7.61 6.97L10 1Z" fill="url(#half-{{ i }})"/>
</svg>
{% else %}
<svg class="star-rating__star star-rating__star--empty" width="20" height="20" viewBox="0 0 20 20">
<path d="M10 1L12.39 6.97L18.78 7.6L13.89 11.84L15.28 18.08L10 14.85L4.72 18.08L6.11 11.84L1.22 7.6L7.61 6.97L10 1Z" fill="var(--star-empty-color, #E0E0E0)"/>
</svg>
{% endif %}
{% endfor %}
</div>

与元字段集成

元字段是在主题扩展中存储和检索应用数据的主要方式。你的应用通过 Admin API 将数据写入元字段,而你的 Liquid 区块读取它们。

定义元字段访问

// In your app's shopify.app.toml, declare which metafields your theme extension reads
[access.metafields]
[[access.metafields.read]]
namespace = "reviews"
key = "average_rating"

[[access.metafields.read]]
namespace = "reviews"
key = "count"

[[access.metafields.read]]
namespace = "reviews"
key = "data"

从你的应用写入元字段

// app/services/reviews.server.ts
export async function updateProductReviewMetafields(
admin: AdminApiContext,
productId: string,
reviews: Review[]
) {
const avgRating =
reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;

const starCounts = [1, 2, 3, 4, 5].map(
(star) => reviews.filter((r) => r.rating === star).length
);

await admin.graphql(`
mutation SetReviewMetafields($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields {
id
namespace
key
value
}
userErrors {
field
message
}
}
}
`, {
variables: {
metafields: [
{
ownerId: productId,
namespace: 'reviews',
key: 'average_rating',
type: 'number_decimal',
value: String(avgRating.toFixed(2)),
},
{
ownerId: productId,
namespace: 'reviews',
key: 'count',
type: 'number_integer',
value: String(reviews.length),
},
...starCounts.map((count, index) => ({
ownerId: productId,
namespace: 'reviews',
key: `${index + 1}_star_count`,
type: 'number_integer',
value: String(count),
})),
],
},
});
}

在 Liquid 中读取元字段

{% comment %} Access metafields in your app block {% endcomment %}
{% assign avg = product.metafields.reviews.average_rating.value %}
{% assign count = product.metafields.reviews.count.value %}

{% if count > 0 %}
<div class="review-badge">
{% render 'star-rating', rating: avg %}
<span>({{ count }})</span>
</div>
{% endif %}
元字段命名空间

始终为元字段使用你的应用的保留命名空间。如果另一个应用使用相同的命名空间和键,将发生数据冲突。Shopify 保留了某些命名空间(如 global)。使用一个能清楚标识你的应用的命名空间,如 my_app_reviews

最佳实践

性能

  1. Minimize JavaScript -- Load JS only when the block is visible. Use IntersectionObserver to lazy-load.
  2. Use CSS custom properties -- Let merchants customize colors without loading additional stylesheets.
  3. Avoid blocking the main thread -- All API calls should be asynchronous. Never use synchronous XHR.
  4. Cache aggressively -- Use localStorage for review data that does not change frequently.

商家体验

  1. Provide meaningful defaults -- Every setting should have a sensible default so the block looks good immediately.
  2. Include preview data -- Use Liquid's request.design_mode to show sample data in the theme editor.
  3. Handle empty states -- Show a friendly message when there are no reviews, not a blank space.
  4. Document settings -- Use the info property in schema settings to explain what each option does.
{% if request.design_mode %}
{% comment %} Show sample data in the theme editor {% endcomment %}
<div class="reviews-preview">
{% render 'star-rating', rating: 4.5 %}
<p>Preview: 127 reviews will appear here</p>
</div>
{% endif %}

干净卸载

干净卸载要求

当商家卸载你的应用时,你的主题扩展区块会自动从其主题中移除。这是主题应用扩展相比旧版 Script Tag 或 Asset API 方法的核心优势。然而,你仍然应该清理你的应用创建的任何元字段,因为它们在卸载后会持续存在。处理 APP_UNINSTALLED Webhook 以移除孤立的元字段。

应用嵌入区块示例

{% comment %}
blocks/review-popup.liquid (embed block)
Shows a post-purchase review request popup
{% endcomment %}

{% schema %}
{
"name": "Review Request Popup",
"target": "body",
"stylesheet": "review-popup.css",
"javascript": "review-popup.js",
"settings": [
{
"type": "range",
"id": "delay_days",
"min": 1,
"max": 14,
"step": 1,
"default": 7,
"label": "Days after purchase to show popup",
"info": "How many days after a customer's purchase before prompting for a review"
},
{
"type": "text",
"id": "heading_text",
"label": "Popup heading",
"default": "How are you enjoying your purchase?"
},
{
"type": "checkbox",
"id": "show_on_mobile",
"label": "Show on mobile devices",
"default": false,
"info": "Popups can be disruptive on small screens"
}
]
}
{% endschema %}

<div
id="review-request-popup"
class="review-popup review-popup--hidden"
data-delay-days="{{ block.settings.delay_days }}"
data-show-mobile="{{ block.settings.show_on_mobile }}"
role="dialog"
aria-label="{{ block.settings.heading_text }}"
aria-hidden="true"
>
<div class="review-popup__overlay"></div>
<div class="review-popup__content">
<button class="review-popup__close" aria-label="Close">&times;</button>
<h2 class="review-popup__heading">{{ block.settings.heading_text }}</h2>
<div class="review-popup__body" id="review-popup-form">
<!-- Populated by JavaScript -->
</div>
</div>
</div>
下一步

继续学习 POS UI 扩展,了解如何使用自定义 UI 组件扩展 Shopify 的销售点应用以实现线下体验。