实际应用示例
本指南通过实际的应用场景和完整代码示例,帮助您深入理解 Shopify 主题开发的实践应用。
案例1:智能产品搜索
功能需求
- 实时搜索建议
- 搜索结果高亮
- 搜索历史记录
- 无搜索结果时的智能推荐
完整实现
<!-- sections/smart-search.liquid -->
<div class="smart-search" data-smart-search>
<form class="search-form" data-search-form role="search">
<div class="search-input-wrapper">
<input type="search"
class="search-input"
placeholder="搜索商品、品牌、分类..."
data-search-input
autocomplete="off"
aria-label="搜索商品">
<button type="submit" class="search-submit" aria-label="搜索">
{% render 'icon-search' %}
</button>
<button type="button" class="search-clear" data-search-clear aria-label="清空搜索">
{% render 'icon-close' %}
</button>
</div>
<!-- 搜索建议 -->
<div class="search-suggestions" data-search-suggestions>
<div class="suggestions-header">
<h4>搜索建议</h4>
</div>
<div class="suggestions-content" data-suggestions-content></div>
</div>
<!-- 搜索历史 -->
<div class="search-history" data-search-history>
<div class="history-header">
<h4>最近搜索</h4>
<button type="button" class="history-clear" data-history-clear>清空</button>
</div>
<div class="history-content" data-history-content></div>
</div>
</form>
</div>
<script>
class SmartSearch {
constructor(element) {
this.container = element;
this.form = element.querySelector('[data-search-form]');
this.input = element.querySelector('[data-search-input]');
this.suggestions = element.querySelector('[data-search-suggestions]');
this.history = element.querySelector('[data-search-history]');
this.debounceTimer = null;
this.searchHistory = this.loadSearchHistory();
this.cache = new Map();
this.init();
}
init() {
this.bindEvents();
this.showSearchHistory();
}
bindEvents() {
this.input.addEventListener('input', this.handleInput.bind(this));
this.input.addEventListener('focus', this.handleFocus.bind(this));
this.input.addEventListener('blur', this.handleBlur.bind(this));
this.form.addEventListener('submit', this.handleSubmit.bind(this));
// 历史记录清空
const historyCleanBtn = this.container.querySelector('[data-history-clear]');
historyCleanBtn?.addEventListener('click', this.clearHistory.bind(this));
// 搜索清空
const searchClearBtn = this.container.querySelector('[data-search-clear]');
searchClearBtn?.addEventListener('click', this.clearSearch.bind(this));
// 键盘导航
this.input.addEventListener('keydown', this.handleKeyNavigation.bind(this));
}
handleInput(event) {
const query = event.target.value.trim();
clearTimeout(this.debounceTimer);
if (query.length < 2) {
this.hideSuggestions();
this.showSearchHistory();
return;
}
this.debounceTimer = setTimeout(() => {
this.performSearch(query);
}, 300);
}
handleFocus() {
if (this.input.value.trim().length < 2) {
this.showSearchHistory();
} else {
this.showSuggestions();
}
}
handleBlur() {
// 延迟隐藏,允许点击建议项
setTimeout(() => {
this.hideSuggestions();
this.hideSearchHistory();
}, 200);
}
handleSubmit(event) {
event.preventDefault();
const query = this.input.value.trim();
if (query) {
this.saveToHistory(query);
this.redirectToSearch(query);
}
}
async performSearch(query) {
// 检查缓存
if (this.cache.has(query)) {
this.displaySuggestions(this.cache.get(query), query);
return;
}
try {
const response = await fetch(
`/search/suggest.json?q=${encodeURIComponent(query)}&resources[type]=product,collection,article&resources[limit]=8`
);
const data = await response.json();
const results = data.resources.results;
this.cache.set(query, results);
this.displaySuggestions(results, query);
} catch (error) {
console.error('搜索失败:', error);
}
}
displaySuggestions(results, query) {
const content = this.suggestions.querySelector('[data-suggestions-content]');
if (!results.products?.length && !results.collections?.length) {
content.innerHTML = `
<div class="no-suggestions">
<p>没有找到相关结果</p>
<div class="recommended-terms">
<p>试试这些搜索:</p>
<button type="button" onclick="this.closest('[data-smart-search]').querySelector('[data-search-input]').value='连衣裙';this.click()">连衣裙</button>
<button type="button" onclick="this.closest('[data-smart-search]').querySelector('[data-search-input]').value='手机';this.click()">手机</button>
</div>
</div>
`;
} else {
let html = '';
// 产品建议
if (results.products?.length) {
html += '<div class="suggestion-group">';
html += '<h5>商品</h5>';
html += results.products.slice(0, 4).map(product => `
<a href="${product.url}" class="suggestion-item suggestion-item--product">
<img src="${product.featured_image?.url}&width=40" alt="${product.title}" loading="lazy">
<span class="suggestion-text">${this.highlightQuery(product.title, query)}</span>
<span class="suggestion-price">${this.formatMoney(product.price)}</span>
</a>
`).join('');
html += '</div>';
}
// 分类建议
if (results.collections?.length) {
html += '<div class="suggestion-group">';
html += '<h5>分类</h5>';
html += results.collections.slice(0, 3).map(collection => `
<a href="${collection.url}" class="suggestion-item suggestion-item--collection">
<span class="suggestion-text">${this.highlightQuery(collection.title, query)}</span>
<span class="suggestion-count">${collection.products_count} 件商品</span>
</a>
`).join('');
html += '</div>';
}
content.innerHTML = html;
}
this.showSuggestions();
}
highlightQuery(text, query) {
const regex = new RegExp(`(${this.escapeRegex(query)})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
escapeRegex(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
formatMoney(cents) {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY'
}).format(cents / 100);
}
showSuggestions() {
this.suggestions.style.display = 'block';
this.hideSearchHistory();
}
hideSuggestions() {
this.suggestions.style.display = 'none';
}
showSearchHistory() {
if (this.searchHistory.length > 0) {
const content = this.history.querySelector('[data-history-content]');
content.innerHTML = this.searchHistory.map(term => `
<button type="button"
class="history-item"
onclick="this.closest('[data-smart-search]').querySelector('[data-search-input]').value='${term}';this.closest('form').submit()">
${term}
</button>
`).join('');
this.history.style.display = 'block';
}
}
hideSearchHistory() {
this.history.style.display = 'none';
}
saveToHistory(query) {
if (!this.searchHistory.includes(query)) {
this.searchHistory.unshift(query);
this.searchHistory = this.searchHistory.slice(0, 5); // 只保留最近5个
localStorage.setItem('searchHistory', JSON.stringify(this.searchHistory));
}
}
loadSearchHistory() {
try {
return JSON.parse(localStorage.getItem('searchHistory') || '[]');
} catch {
return [];
}
}
clearHistory() {
this.searchHistory = [];
localStorage.removeItem('searchHistory');
this.hideSearchHistory();
}
clearSearch() {
this.input.value = '';
this.hideSuggestions();
this.showSearchHistory();
this.input.focus();
}
redirectToSearch(query) {
window.location.href = `/search?q=${encodeURIComponent(query)}`;
}
handleKeyNavigation(event) {
const items = this.suggestions.querySelectorAll('.suggestion-item');
if (items.length === 0) return;
let currentIndex = Array.from(items).findIndex(item =>
item.classList.contains('highlighted')
);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
currentIndex = (currentIndex + 1) % items.length;
this.highlightItem(items, currentIndex);
break;
case 'ArrowUp':
event.preventDefault();
currentIndex = currentIndex <= 0 ? items.length - 1 : currentIndex - 1;
this.highlightItem(items, currentIndex);
break;
case 'Enter':
if (currentIndex >= 0 && items[currentIndex]) {
event.preventDefault();
items[currentIndex].click();
}
break;
case 'Escape':
this.hideSuggestions();
this.input.blur();
break;
}
}
highlightItem(items, index) {
items.forEach((item, i) => {
item.classList.toggle('highlighted', i === index);
});
}
}
// 初始化智能搜索
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-smart-search]').forEach(search => {
new SmartSearch(search);
});
});
</script>
<style>
.smart-search {
position: relative;
max-width: 600px;
margin: 0 auto;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
background: white;
border: 2px solid #e0e0e0;
border-radius: 25px;
overflow: hidden;
transition: border-color 0.3s;
}
.search-input-wrapper:focus-within {
border-color: #1a73e8;
box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
}
.search-input {
flex: 1;
padding: 12px 20px;
border: none;
font-size: 16px;
background: transparent;
}
.search-submit, .search-clear {
padding: 12px;
border: none;
background: transparent;
cursor: pointer;
color: #666;
transition: color 0.3s;
}
.search-submit:hover, .search-clear:hover {
color: #1a73e8;
}
.search-suggestions, .search-history {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
z-index: 1000;
display: none;
max-height: 400px;
overflow-y: auto;
}
.suggestions-header, .history-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
background: #f9f9f9;
}
.suggestion-item {
display: flex;
align-items: center;
padding: 12px 16px;
text-decoration: none;
color: #333;
transition: background-color 0.2s;
}
.suggestion-item:hover, .suggestion-item.highlighted {
background-color: #f5f5f5;
}
.suggestion-item img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
margin-right: 12px;
}
.suggestion-text {
flex: 1;
}
.suggestion-text mark {
background: #fff3cd;
color: #856404;
padding: 0 2px;
}
.history-item {
display: block;
width: 100%;
text-align: left;
padding: 8px 16px;
border: none;
background: none;
cursor: pointer;
transition: background-color 0.2s;
}
.history-item:hover {
background-color: #f5f5f5;
}
</style>
案例2:动态购物车
功能需求
- Ajax添加商品
- 实时购物车更新
- 商品数量调整
- 购物车商品推荐
核心实现
<!-- snippets/dynamic-cart.liquid -->
<div class="cart-overlay" data-cart-overlay></div>
<div class="cart-drawer" data-cart-drawer>
<div class="cart-header">
<h2>购物车 (<span data-cart-count>0</span>)</h2>
<button class="cart-close" data-cart-close>×</button>
</div>
<div class="cart-body">
<div class="cart-items" data-cart-items>
<!-- 购物车商品列表 -->
</div>
<div class="cart-empty" data-cart-empty style="display: none;">
<p>购物车是空的</p>
<a href="/collections" class="btn btn--primary">继续购物</a>
</div>
</div>
<div class="cart-footer" data-cart-footer>
<div class="cart-total">
<span>总计: <strong data-cart-total>¥0.00</strong></span>
</div>
<button class="btn btn--primary btn--full" data-cart-checkout>
去结账
</button>
</div>
</div>
<script>
class DynamicCart {
constructor() {
this.cart = null;
this.isOpen = false;
this.init();
}
async init() {
this.bindEvents();
await this.loadCart();
this.updateUI();
}
bindEvents() {
// 添加到购物车按钮
document.addEventListener('click', async (e) => {
if (e.target.matches('[data-add-to-cart]')) {
e.preventDefault();
await this.handleAddToCart(e.target);
}
});
// 购物车抽屉控制
document.addEventListener('click', (e) => {
if (e.target.matches('[data-cart-trigger]')) {
this.open();
}
if (e.target.matches('[data-cart-close]') || e.target.matches('[data-cart-overlay]')) {
this.close();
}
});
// 商品数量调整
document.addEventListener('click', async (e) => {
if (e.target.matches('[data-cart-quantity-plus]')) {
const key = e.target.dataset.cartKey;
await this.updateQuantity(key, 1);
}
if (e.target.matches('[data-cart-quantity-minus]')) {
const key = e.target.dataset.cartKey;
await this.updateQuantity(key, -1);
}
if (e.target.matches('[data-cart-remove]')) {
const key = e.target.dataset.cartKey;
await this.removeItem(key);
}
});
// 结账按钮
document.addEventListener('click', (e) => {
if (e.target.matches('[data-cart-checkout]')) {
window.location.href = '/checkout';
}
});
}
async loadCart() {
try {
const response = await fetch('/cart.js');
this.cart = await response.json();
} catch (error) {
console.error('加载购物车失败:', error);
}
}
async handleAddToCart(button) {
const form = button.closest('form');
const formData = new FormData(form);
try {
this.setButtonLoading(button, true);
const response = await fetch('/cart/add.js', {
method: 'POST',
body: formData
});
if (response.ok) {
const item = await response.json();
await this.loadCart();
this.updateUI();
this.open();
this.showAddedNotification(item);
} else {
const error = await response.json();
this.showError(error.message);
}
} catch (error) {
this.showError('添加商品失败');
} finally {
this.setButtonLoading(button, false);
}
}
async updateQuantity(key, change) {
const currentItem = this.cart.items.find(item => item.key === key);
if (!currentItem) return;
const newQuantity = Math.max(0, currentItem.quantity + change);
try {
const response = await fetch('/cart/change.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: key, quantity: newQuantity })
});
this.cart = await response.json();
this.updateUI();
} catch (error) {
this.showError('更新数量失败');
}
}
async removeItem(key) {
return this.updateQuantity(key, -999); // 设置为0来移除
}
updateUI() {
this.updateCartCount();
this.updateCartItems();
this.updateCartTotal();
this.updateEmptyState();
}
updateCartCount() {
const countElements = document.querySelectorAll('[data-cart-count]');
countElements.forEach(el => {
el.textContent = this.cart.item_count;
});
}
updateCartItems() {
const container = document.querySelector('[data-cart-items]');
if (!container) return;
if (this.cart.items.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = this.cart.items.map(item => `
<div class="cart-item" data-cart-item="${item.key}">
<div class="cart-item-image">
<img src="${item.featured_image.url}&width=80" alt="${item.product_title}">
</div>
<div class="cart-item-details">
<h4 class="cart-item-title">${item.product_title}</h4>
${item.variant_title ? `<p class="cart-item-variant">${item.variant_title}</p>` : ''}
<div class="cart-item-quantity">
<button class="quantity-btn" data-cart-quantity-minus data-cart-key="${item.key}">-</button>
<span class="quantity-display">${item.quantity}</span>
<button class="quantity-btn" data-cart-quantity-plus data-cart-key="${item.key}">+</button>
</div>
</div>
<div class="cart-item-price">
<span class="cart-item-total">${this.formatMoney(item.line_price)}</span>
<button class="cart-item-remove" data-cart-remove data-cart-key="${item.key}">移除</button>
</div>
</div>
`).join('');
}
updateCartTotal() {
const totalElements = document.querySelectorAll('[data-cart-total]');
totalElements.forEach(el => {
el.textContent = this.formatMoney(this.cart.total_price);
});
}
updateEmptyState() {
const emptyState = document.querySelector('[data-cart-empty]');
const cartFooter = document.querySelector('[data-cart-footer]');
if (this.cart.item_count === 0) {
emptyState.style.display = 'block';
cartFooter.style.display = 'none';
} else {
emptyState.style.display = 'none';
cartFooter.style.display = 'block';
}
}
open() {
const drawer = document.querySelector('[data-cart-drawer]');
const overlay = document.querySelector('[data-cart-overlay]');
drawer.classList.add('is-open');
overlay.classList.add('is-open');
document.body.classList.add('cart-open');
this.isOpen = true;
}
close() {
const drawer = document.querySelector('[data-cart-drawer]');
const overlay = document.querySelector('[data-cart-overlay]');
drawer.classList.remove('is-open');
overlay.classList.remove('is-open');
document.body.classList.remove('cart-open');
this.isOpen = false;
}
setButtonLoading(button, loading) {
button.disabled = loading;
button.textContent = loading ? '添加中...' : '加入购物车';
}
showAddedNotification(item) {
const notification = document.createElement('div');
notification.className = 'cart-notification';
notification.innerHTML = `
<div class="notification-content">
<img src="${item.featured_image}&width=60" alt="${item.product_title}">
<div>
<p><strong>${item.product_title}</strong> 已添加到购物车</p>
<p>${item.variant_title || ''}</p>
</div>
</div>
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('show');
}, 100);
setTimeout(() => {
notification.remove();
}, 3000);
}
showError(message) {
alert(message); // 简单实现,实际项目中应该用更好的UI
}
formatMoney(cents) {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY'
}).format(cents / 100);
}
}
// 初始化动态购物车
window.dynamicCart = new DynamicCart();
</script>
案例3:产品图片库
功能需求
- 图片缩放查看
- 缩略图导航
- 移动端滑动支持
- 懒加载优化
实现代码
<!-- snippets/product-gallery.liquid -->
<div class="product-gallery" data-product-gallery>
<div class="gallery-main">
<div class="main-image-container">
<img class="main-image"
data-main-image
src="{{ product.featured_image | img_url: '800x800' }}"
alt="{{ product.featured_image.alt | default: product.title }}">
<button class="zoom-btn" data-zoom-trigger aria-label="放大图片">
🔍
</button>
</div>
</div>
<div class="gallery-thumbs" data-gallery-thumbs>
{% for image in product.images %}
<button class="thumb-btn {% if forloop.first %}active{% endif %}"
data-thumb-btn
data-image-src="{{ image | img_url: '800x800' }}"
data-image-alt="{{ image.alt | default: product.title }}">
<img src="{{ image | img_url: '100x100' }}"
alt="{{ image.alt | default: product.title }}"
loading="lazy">
</button>
{% endfor %}
</div>
</div>
<!-- 图片放大模态框 -->
<div class="image-zoom-modal" data-zoom-modal>
<div class="zoom-overlay"></div>
<div class="zoom-content">
<img class="zoom-image" data-zoom-image>
<button class="zoom-close" data-zoom-close>×</button>
<button class="zoom-prev" data-zoom-prev>‹</button>
<button class="zoom-next" data-zoom-next">›</button>
</div>
</div>
<script>
class ProductGallery {
constructor(element) {
this.gallery = element;
this.mainImage = element.querySelector('[data-main-image]');
this.thumbs = element.querySelectorAll('[data-thumb-btn]');
this.zoomModal = document.querySelector('[data-zoom-modal]');
this.currentIndex = 0;
this.images = Array.from(this.thumbs).map(thumb => ({
src: thumb.dataset.imageSrc,
alt: thumb.dataset.imageAlt
}));
this.init();
}
init() {
this.bindEvents();
this.setupTouchSupport();
}
bindEvents() {
// 缩略图切换
this.thumbs.forEach((thumb, index) => {
thumb.addEventListener('click', () => {
this.switchToImage(index);
});
});
// 放大功能
const zoomTrigger = this.gallery.querySelector('[data-zoom-trigger]');
zoomTrigger?.addEventListener('click', () => {
this.openZoom();
});
// 模态框控制
this.zoomModal?.addEventListener('click', (e) => {
if (e.target.matches('[data-zoom-close]') || e.target.matches('.zoom-overlay')) {
this.closeZoom();
}
if (e.target.matches('[data-zoom-prev]')) {
this.prevImage();
}
if (e.target.matches('[data-zoom-next]')) {
this.nextImage();
}
});
// 键盘导航
document.addEventListener('keydown', (e) => {
if (this.zoomModal?.classList.contains('active')) {
switch (e.key) {
case 'ArrowLeft':
this.prevImage();
break;
case 'ArrowRight':
this.nextImage();
break;
case 'Escape':
this.closeZoom();
break;
}
}
});
}
setupTouchSupport() {
let startX = 0;
let startY = 0;
this.mainImage.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
}, { passive: true });
this.mainImage.addEventListener('touchend', (e) => {
const endX = e.changedTouches[0].clientX;
const endY = e.changedTouches[0].clientY;
const diffX = startX - endX;
const diffY = startY - endY;
// 水平滑动且距离足够
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
if (diffX > 0) {
this.nextImage();
} else {
this.prevImage();
}
}
}, { passive: true });
}
switchToImage(index) {
if (index < 0 || index >= this.images.length) return;
this.currentIndex = index;
const image = this.images[index];
// 更新主图片
this.updateMainImage(image.src, image.alt);
// 更新缩略图状态
this.updateThumbsState();
// 如果模态框打开,也更新模态框图片
if (this.zoomModal?.classList.contains('active')) {
this.updateZoomImage();
}
}
updateMainImage(src, alt) {
// 预加载图片
const img = new Image();
img.onload = () => {
this.mainImage.src = src;
this.mainImage.alt = alt;
};
img.src = src;
}
updateThumbsState() {
this.thumbs.forEach((thumb, index) => {
thumb.classList.toggle('active', index === this.currentIndex);
});
}
nextImage() {
const nextIndex = (this.currentIndex + 1) % this.images.length;
this.switchToImage(nextIndex);
}
prevImage() {
const prevIndex = this.currentIndex === 0 ? this.images.length - 1 : this.currentIndex - 1;
this.switchToImage(prevIndex);
}
openZoom() {
if (!this.zoomModal) return;
this.updateZoomImage();
this.zoomModal.classList.add('active');
document.body.classList.add('zoom-open');
}
closeZoom() {
if (!this.zoomModal) return;
this.zoomModal.classList.remove('active');
document.body.classList.remove('zoom-open');
}
updateZoomImage() {
const zoomImage = this.zoomModal?.querySelector('[data-zoom-image]');
if (zoomImage) {
const currentImage = this.images[this.currentIndex];
// 使用更高分辨率的图片用于放大
const highResImage = currentImage.src.replace('800x800', '1600x1600');
zoomImage.src = highResImage;
zoomImage.alt = currentImage.alt;
}
}
}
// 初始化产品图片库
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-product-gallery]').forEach(gallery => {
new ProductGallery(gallery);
});
});
</script>
通过这些实际应用示例,您可以学习到完整的功能实现过程和最佳实践!
下一步学习
完成实际应用示例后,建议继续学习:
最后更新时间: