现行方案
整套方案采用 Web Components + 数据嵌入(JSON in Script) + 服务端辅助渲染 的混合架构,以"本地优先、服务端补充"的策略实现变体切换后的页面联动。
核心组件层次
theme-product-detail ← 协调中心
├── theme-variant-radio-picker ← SKU 选择器:Radio 模式
├── theme-variant-select-picker ← SKU 选择器:Select 下拉模式
├── theme-product-media-gallery ← 媒体图片画廊
├── theme-product-form ← 购买表单
└── (价格/库存 blocks) ← 通过服务端重渲染更新
四类组件各司其职,由 theme-product-detail 在中间协调,没有相互直接耦合。
相关文件:
| 组件 | 文件 |
|---|---|
| 协调中心 | components/product/detail.js / detail.html |
| SKU 选择器 | blocks/product-variant-picker.js / .html / .css |
| 媒体画廊 | components/product/media-gallery.js |
| 购买表单 | public/product/form.js |
数据来源:服务端注入两份 JSON
页面首次服务端渲染(SSR)时,Sline 模板向 HTML 中内嵌两段 <script type="application/json">:
| JSON 名称 | 内容 | 用途 |
|---|---|---|
variant-data |
该商品全量 variants 列表(含 id、options、price、available、featured_media_id) |
给 VariantPicker 本地计算当前变体 |
default-variant-data |
当前默认选中变体 | 给 ProductDetail 初始化状态 |
相关模板文件:components/product/variant-data.html
意义:前端无需任何 API 调用即可完成初始化,并能在本地做 options → variant 的匹配。
SKU 切换完整数据流
用户点击 / 选择 option
↓
VariantPicker (theme-variant-radio-picker / theme-variant-select-picker)
· 从本地 variants 数据中通过 options 组合匹配出 currentVariant
· 实时更新不可用选项的 .disabled 样式(本地计算)
· 触发 DOM change 事件冒泡
↓
ProductDetail.#variantChangeHandler()
① 同步所有 VariantPicker 的选中 options(多选择器联动,用 #isSyncing 防止循环)
② mediaGallery.activeMedia(featured_media_id) ← 本地切图(纯前端)
③ buyForm.updateVariant(variant) ← 本地更新按钮状态(正常/售罄/不可用)
④ fetch 服务端 HTML (?sku=variantId§ion_id) ← 异步获取新价格/库存等
↓ 服务端返回完整 section HTML
⑤ themeUtils.reRenderDomContent(旧DOM, 新DOM) ← 增量 DOM 替换
⑥ history.replaceState(...?sku=xxx) ← 更新 URL(可选)
⑦ themeEventCenter.dispatch('variant:changed') ← 全局事件通知外部模块
两类更新策略
| 更新内容 | 策略 | 原因 |
|---|---|---|
| 选项不可用状态 | 纯前端本地 | variant-data 中已有 available 字段 |
| 商品主图切换 | 纯前端本地 | variant-data 中已有 featured_media_id |
| 购买按钮状态 | 纯前端本地 | variant-data 中已有 available |
| 价格、库存数量、阶梯定价等 | 服务端 fetch + 增量更新 | 涉及复杂定价逻辑(折扣、阶梯价),留在服务端处理更可靠 |
设计意图:视觉感知最强的反馈(图片、按钮)用本地数据秒级响应,保证交互流畅;价格等需要可信计算的数据则异步从服务端拉取,不影响操作体验。
增量 DOM 更新机制(reRenderDomContent)
服务端返回新 HTML 后,并非全量替换。themeUtils.reRenderDomContent(public/base/index.js)扫描所有带 data-rerender-id 属性的元素,按 ID 进行一对一的精确替换:
- **若元素设置了
data-rerender-attrs/ **data-rerender-props:只更新指定属性,Web Component 实例保持不变(不触发重新创建) - 若无上述标记:直接
replaceWith()整个替换元素
这样既能让价格、库存等内容刷新,又能保留 Carousel、表单等有状态组件的运行实例。
全局事件总线
themeEventCenter 实现了发布/订阅模式,定义于 public/base/index.js。
事件枚举
| 事件名 | 枚举值 | 触发时机 |
|---|---|---|
ProductViewed |
product:viewed |
商品详情页首次加载完成(mounted())时,触发一次 |
VariantChanged |
variant:changed |
每次 SKU 切换或数量变更时触发 |
VariantAdded |
variant:added |
商品成功加入购物车时触发 |
product:viewed 事件
携带数据: { productId, productHandle, variantId, quantity, price, currency }
用途: 这是"页面级"事件,而非"操作级"事件。主要消费方是全局监听器,负责维护最近浏览记录:
用户访问商品详情页
↓
theme-product-detail.mounted()
↓
dispatch('product:viewed', { productId, ... })
↓
base/index.js 全局监听器
· 从 localStorage 读取已有浏览记录(key: recently_viewed_products_ids)
· 去重 + 前插当前 productId
· 截取最多 12 条写回 localStorage
与 variant:changed 的区别:product:viewed 只在首次加载时触发一次,variant:changed 则在每次切换 SKU 时持续触发。
variant:changed 事件
携带数据: { productId, productHandle, variantId, quantity, price, currency }
**用途:**用于解耦通知外部模块(如第三方 App 插件)响应变体变更,无需感知内部实现。
关联组件:商品推荐 & 最近浏览
recommended-product 商品推荐
技术方案: 懒加载(进入视口时)+ 服务端推荐接口
相关文件: sections/recommended-product/recommended-product.js / .html
数据来源: SHOPLINE 平台推荐接口 routes.product_recommendations_url,由服务端根据当前商品计算关联推荐,前端无任何本地逻辑。
渲染流程:
SSR:HTML 中嵌入占位 <theme-recommended-product>
data-url 携带 section_id + product_id + limit
用户滚动,组件进入视口(触发 custom:visible,rootMargin = '0px 0px 400px 0px')
↓
fetch(data-url) → 服务端返回完整 section HTML
↓
DOMParser 解析,提取新 <theme-recommended-product> 的 innerHTML
↓
替换占位内容 + 动态插入 style 标签 + 执行内联 script
rootMargin = '0px 0px 400px 0px' 向下预留 400px 触发距离,让请求在用户滚动到之前就发出,减少可见延迟。整个请求只发生一次。
recently-viewed-product 最近浏览商品
技术方案: 懒加载 + localStorage 缓存 ID + 服务端搜索接口拼装
相关文件: sections/recently-viewed-product/recently-viewed-product.js / .html
数据来源: 读取 localStorage['recently_viewed_products_ids'](由 product:viewed 事件写入),构造搜索 URL 请求服务端渲染结果。
渲染流程:
SSR:HTML 中嵌入占位 <theme-recently-viewed-product>
data-url 指向搜索接口,data-product-id 为当前商品 ID
用户滚动,组件进入视口
↓
从 localStorage 读取 recently_viewed_products_ids
· 排除当前商品 ID(避免自身出现在列表)
· 截取前 N 条(由 data-page-size 决定)
↓
fetch(`${data-url}&q=${ids.join(',')}`) → 服务端按 ID 列表查询并渲染
↓
DOMParser 解析,替换占位内容 + 插入 style + 执行 script
整体协作关系
商品详情页加载
↓
① theme-product-detail.mounted()
└─ dispatch('product:viewed')
└─ base/index.js 监听 → 写入 localStorage
② 用户切换 SKU
└─ VariantPicker change 事件
└─ ProductDetail 协调:
├─ 本地:切图 / 更新按钮
└─ 异步:fetch 服务端 → reRenderDomContent 更新价格/库存
└─ dispatch('variant:changed') 通知外部模块
③ 用户滚动页面
├─ recommended-product 进入视口
│ └─ fetch 推荐接口 → 替换占位内容
└─ recently-viewed-product 进入视口
└─ 读 localStorage → fetch 搜索接口 → 替换占位内容
适配 combined listing 方案
概述
Combined Listings(组合商品)允许将多个独立 SPU 整合到同一商品详情页。用户选择不同颜色/抛期等"组合维度"选项时,页面会切换到对应的子产品内容,而不仅仅是切换同一商品的 SKU。
示例:
父产品 SPU5(1 系列美瞳)
├── 棕色 + 日抛 → 子产品 SPU1(独立商品,含 26 个度数 SKU)
├── 粉色 + 日抛 → 子产品 SPU2
├── 棕色 + 半年抛 → 子产品 SPU3
└── 粉色 + 半年抛 → 子产品 SPU4
现有"同产品变体切换"方案需要向上扩展,支持"跨产品切换"这一新场景,同时完全兼容普通产品的已有行为。
架构扩展:双路径设计
在现有的 #variantChangeHandler 入口处增加分支判断,将两种场景分流处理:
用户点击选项
↓
VariantPicker change 事件
↓
ProductDetail.#variantChangeHandler()
│
├── 【路径 A:跨产品切换】
│ 条件:isCombinedListing = true
│ 且 targetProductUrl ≠ currentProductUrl
│ → #handleCombinedListingSwitch()
│ → 全量替换 <theme-product-detail>
│ → ProductViewed 事件由新元素 mounted() 自动触发
│
└── 【路径 B:同产品变体切换】(原有逻辑,不变)
条件:普通产品 或 targetProductUrl 与当前相同
→ 原有 currentVariant 匹配逻辑
→ reRenderDomContent 局部更新
→ 触发 VariantChanged 事件
关键原则:路径 B 的代码路径完全保持不变,普通产品零影响。
判断依据:如何区分两种场景
Combined Listings 通过 product_option_value 对象上的 product_url 字段标识跨产品关联。
模板层:仅在字段存在时输出 data-product-url 属性:
<!-- input / option 上条件输出 -->
{{#if item.product_url }}data-product-url="{{ item.product_url }}"{{/if}}
JS 层:通过 DOM 检测自动判断,无需额外配置:
isCombinedListing:检查 picker 内是否存在[data-product-url]元素targetProductUrl:遍历当前已选 input/option,取最后一个有data-product-url的值productUrl:来自 picker 的data-product-url属性(SSR 时注入当前产品 URL)
普通产品不会渲染这些属性,isCombinedListing 始终返回 false。
目标 SKU 匹配机制
跨产品切换时,不做本地 variant 匹配,不传 sku 参数,由服务端返回新产品的默认首个可用变体。用户在当前子产品中已选的 SKU 选项(如度数)切换后会重置。
variant-data字段保持不变(id、options、available、price、featured_media_id),不新增product_url字段,也不包含其他子产品的变体数据,只含当前渲染子产品自身的 variants。
路径 A:跨产品切换(#handleCombinedListingSwitch)
核心机制
全量替换 <theme-product-detail> 元素,以确保商品主图、标题、价格、变体选择器等所有内容都使用新产品数据重新渲染。
全量替换的优势:
- 所有内容(包括商品主图)完整更新,无遗漏
- 新元素经历完整 Web Component 生命周期(
constructor → connectedCallback → mounted) mounted()自动触发ProductViewed事件,无需手动 dispatch- 实现简单,无需维护
data-rerender-id的完整性
完整数据流
用户点击「粉色」选项
(input[data-product-url="/products/pink-lens"])
↓
ProductDetail 检测到跨产品切换
↓
① fetch 新产品 section HTML(不传 sku,服务端返回首个可用变体)
/products/pink-lens?section_id=main-product§ion_template=product
↓
服务端返回:新产品的完整 section HTML
(含新标题、新主图、新价格、新变体选择器、新购买按钮)
↓
② history.replaceState
URL 更新为 /products/pink-lens
↓
③ this.replaceWith(newProductDetailElement)
旧 <theme-product-detail> 从 DOM 移除
新元素插入,触发正常 Web Component 生命周期
↓
④ 新元素 mounted() 自动执行
dispatch('product:viewed', { productId: 新产品ID })
(下游:localStorage 记录、推荐商品刷新 均由此驱动)
URL 参数格式
| 场景 | URL 格式 |
|---|---|
| 跨产品切换 | /products/{handle} |
| 子产品内部变体切换(原有逻辑) | /products/{handle}?sku={variantId} |
| 普通产品变体切换(不变) | /products/{handle}?sku={variantId} |
变更文件清单
核心实现
| 文件 | 变更类型 | 说明 |
|---|---|---|
blocks/product/_detail-variant-picker.html |
修改 | 在 input/option 上条件输出 data-product-url |
blocks/product-variant-picker.js |
修改 | 新增 isCombinedListing、targetProductUrl; #updateOptionStatus 跳过带 data-product-url 的跨商品维度选项,避免因本地 variant-data 不含其他子产品数据而错误标为不可用 |
components/product/detail.js |
修改 | 新增跨产品切换分支 + #handleCombinedListingSwitch(全量替换) |
关联组件适配
| 文件 | 变更类型 | 说明 |
|---|---|---|
sections/recommended-product/recommended-product.html |
修改 | 补充 data-product-id 属性 |
sections/recommended-product/recommended-product.js |
修改 | 监听 ProductViewed 事件,子产品切换后刷新推荐 |
关联组件适配
最近浏览商品(recently-viewed-product)
无需改动代码,已天然满足。
数据流验证:
用户切换子产品
→ 新 <theme-product-detail> 插入 DOM
→ mounted() 自动执行
→ dispatch('product:viewed', { productId: 新产品ID })
→ base/index.js 全局监听器
· 从 localStorage 读取 recently_viewed_products_ids
· 去重 + 前插新 productId
· 截取最多 12 条写回 localStorage
每次切换子产品等同于"访问一个新商品页",浏览记录自动更新。recently-viewed-product 组件通常出现在其他页面(首页/列表页),不需要在详情页实时刷新。
商品推荐(recommended-product)
需改动 2 个文件,让商品推荐组件在子产品切换后立即刷新。
recommended-product 是独立 section,位于 theme-product-detail 之外,不会随元素替换而自动更新。通过订阅全局 ProductViewed 事件(由新元素 mounted() 触发),检测到产品变化后主动刷新。
HTML 层
在 <theme-recommended-product> 上增加 data-product-id 属性,记录当前渲染时的产品 ID:
<theme-recommended-product
data-url="...?product_id={{ product.id }}&..."
data-product-id="{{ product.id }}" <!-- 新增 -->
>
JS 层
在组件构造函数中注册 ProductViewed 事件监听:
构造函数初始化
├── 原有:lazy-load on custom:visible(不变)
└── 新增:监听 themeEventCenter ProductViewed
│
▼
事件触发时:
if (newProductId !== this.dataset.productId && event.target.dataset.updateUrl === 'true') ← 主商品组件发生产品变化
├── 更新 data-url 中的 product_id 参数
├── 更新 data-product-id = newProductId
└── 重新 fetch 并替换内容
完整数据流
用户切换子产品(棕色 → 粉色)
↓
新 <theme-product-detail> mounted()
→ dispatch('product:viewed', { productId: 'spuPink_id' })
↓
recommended-product 监听器触发
→ 'spuPink_id' ≠ this.dataset.productId ('spuBrown_id')
→ 更新 data-url: product_id=spuPink_id
→ 更新 data-product-id='spuPink_id'
→ fetch 推荐接口(不使用缓存,确保获取最新推荐)
→ DOMParser 解析 → 替换内容区
普通产品零影响:整个页面只触发一次 ProductViewed(mounted() 时),product ID 不变,不会触发重新 fetch。
尚未进入视口时:custom:visible 还未触发时,监听器只更新 data-url 和 data-product-id;用户滚动到时正常走 #render(),此时已用最新 product ID 拉取,无需特殊分支。
全局事件在适配中的作用
本次适配依赖 ProductViewed 事件作为"商品切换"信号,由新元素的 mounted() 生命周期钩子自动派发:
【已有,全量替换后自动触发】
dispatch('product:viewed') ← 新 <theme-product-detail> mounted() 触发
├── base/index.js → localStorage 写入浏览记录(最近浏览)
└── recommended-product → 刷新推荐商品(新增监听)
【路径 B 保持不变】
dispatch('variant:changed')
└── 外部插件(POD 等)→ 响应变体变更
注意:跨产品切换(路径 A)走全量替换,不再触发
VariantChanged事件。外部插件应监听ProductViewed事件来感知跨产品切换。
兼容性保障
| 场景 | 行为 | 保证机制 |
|---|---|---|
| 普通产品变体切换 | 完全走原有路径,无任何变化 | isCombinedListing = false 跳过所有新逻辑 |
| 普通产品推荐刷新 | 不触发 | product ID 不变,监听器条件判断短路 |
| Combined Listing 跨产品 | 全量替换 + 刷新推荐 + 记录浏览 | 双路径分支 + ProductViewed 事件链 |
| Combined Listing 同产品变体 | 走原有 SKU 切换逻辑 | targetProductUrl === currentProductUrl |
| CL 跨商品维度选项状态 | 始终不标为 disabled | #updateOptionStatus 跳过带 data-product-url 的选项 |
方案小结
切换选项
│
┌─────────────┴──────────────┐
▼ ▼
跨产品切换(新路径) 同产品变体切换(原有路径)
│ │
┌───────────┴──────────┐ │ 本地 JSON 匹配
│ fetch 新产品 HTML │ │ reRenderDomContent
│ (不传 sku 参数) │ │ VariantChanged 事件
│ history.replaceState │ │
│ replaceWith(新元素) │ │
└───────────┬──────────┘ │
│ 新元素 mounted() │
│ 自动触发 ProductViewed │
┌───────┴───────────────┐
▼ ▼
recently-viewed recommended-product
localStorage 记录 立即刷新推荐内容
(无需额外改动) (监听事件 + re-fetch)