3.0主题适配 combined listing

现行方案

整套方案采用 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&section_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&section_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)