功能特性
- ✅ 产品对比表格: 左侧规格列表,右侧产品对比,清晰展示多产品差异
- ✅ 动态产品切换: 下拉菜单选择不同产品进行对比,支持实时切换
- ✅ 响应式列数: 移动端最多 2 列,桌面端最多 3 列,自动适配屏幕
- ✅ SKU 信息展示: 产品图片、价格、优惠券信息完整展示
- ✅ 富文本支持: 规格值支持 HTML 和图片展示,支持复杂内容
- ✅ 价格格式化: 支持多货币、多地区价格展示,优惠券价格自动显示
- ✅ 智能图片识别: 自动识别规格值中的图片 URL,支持 8 种图片格式
- ✅ 点击外部关闭: 下拉菜单点击外部自动关闭,交互友好
Props 参数
主要 Props
| Prop | 类型 | 默认值 | 必需 | 说明 |
|---|---|---|---|---|
data | SpecsData | - | ✅ | 规格对比数据配置 |
buildData | BuildData | - | ✅ | Shopify 产品数据 |
onChange | (product, index) => void | - | - | 产品选择回调 |
onSecondaryChange | (product, index) => void | - | - | 次要操作回调 |
className | string | '' | - | 自定义类名 |
类型定义
SpecsData
/**
* 规格对比数据配置
*/
interface SpecsData {
data: {
/**
* 左侧规格菜单
*/
LeftMenu: {
data: Array<{
/**
* 规格分类标题
*/
title: string
/**
* 规格项列表
*/
subTitle: string[]
/**
* 是否包含产品信息(图片、价格、按钮)
* 设置为 false 可仅显示规格对比表格
*/
isProduct?: boolean
}>
}
/**
* 右侧产品数据
*/
RightMenu: {
menus: Array<{
/**
* 产品 handle,用于匹配 buildData
*/
handle: string
/**
* SKU 编号
*/
sku: string
/**
* 规格值对象
* 键为规格名称,值为规格内容
* 支持三种格式:
* 1. 纯文本字符串
* 2. 图片 URL 字符串(自动识别)
* 3. 图文对象 { text: string; imgUrl: string }
*/
subTitle: {
[specName: string]: string | { text: string; imgUrl: string }
}
}>
}
/**
* 默认选中配置
*/
DefaultSelectMenu: {
/**
* 逗号分隔的默认 SKU
* 示例: "A1340011,A2524014,A3456789"
* 移动端自动限制为前 2 个
*/
sku: string
/**
* 按钮文本
* 可以是统一字符串,或按索引自定义
*/
buttonText: string | { [index: string]: string }
}
}
}BuildData
/**
* Shopify 产品数据
*/
interface BuildData {
products: Array<{
/**
* 产品 handle,对应 RightMenu.menus[].handle
*/
handle: string
/**
* 产品名称
*/
name: string
/**
* 产品标题
*/
title: string
/**
* 产品价格
*/
price: {
value: number
currencyCode: string
}
/**
* 产品变体列表
*/
variants: Array<{
/**
* SKU 编号
*/
sku: string
/**
* 变体价格
*/
price: number
/**
* 变体图片
*/
image: {
url: string
altText: string
}
/**
* 是否可售
*/
availableForSale: boolean
/**
* 优惠券列表
*/
coupons?: Array<{
/**
* 优惠后价格
*/
variant_price4wscode: number
}>
}>
}>
}SpecValue 类型
/**
* 规格值支持三种格式
*/
type SpecValue =
| string // 纯文本: "27,650mAh"
| string // 图片 URL: "https://example.com/image.png"
| {
// 图文对象
text: string // 文本内容,支持 HTML
imgUrl: string // 图片 URL
}使用示例
示例 1: 基础产品对比
import { Specs } from '@anker-in/headless-ui/biz'
export default function BasicSpecsExample() {
const data = {
data: {
LeftMenu: {
data: [
{
title: 'Specifications',
isProduct: true,
subTitle: [
'Battery Capacity',
'Charging Power',
'Ports',
'Weight',
],
},
],
},
RightMenu: {
menus: [
{
handle: 'power-bank-250w',
sku: 'A1340011',
subTitle: {
'Battery Capacity': '27,650mAh',
'Charging Power': '250W',
Ports: '2× USB-C, 1× USB-A',
Weight: '548g',
},
},
{
handle: 'power-bank-140w',
sku: 'A2524014',
subTitle: {
'Battery Capacity': '20,000mAh',
'Charging Power': '140W',
Ports: '2× USB-C',
Weight: '368g',
},
},
],
},
DefaultSelectMenu: {
sku: 'A1340011,A2524014',
buttonText: 'Buy Now',
},
},
}
const buildData = {
products: [
{
handle: 'power-bank-250w',
name: 'Anker Prime 250W',
title: 'Anker Prime 250W Power Bank',
price: { value: 179.99, currencyCode: 'USD' },
variants: [
{
sku: 'A1340011',
price: 179.99,
image: {
url: 'https://images.unsplash.com/photo-1609091839311-d5365f9ff1c5?w=400&q=80',
altText: 'Power Bank',
},
availableForSale: true,
},
],
},
{
handle: 'power-bank-140w',
name: 'Anker 737 140W',
title: 'Anker 737 Power Bank',
price: { value: 149.99, currencyCode: 'USD' },
variants: [
{
sku: 'A2524014',
price: 149.99,
image: {
url: 'https://images.unsplash.com/photo-1585338107529-13afc5f02586?w=400&q=80',
altText: 'Power Bank',
},
availableForSale: true,
},
],
},
],
}
return <Specs data={data} buildData={buildData} />
}示例 2: 带图片规格值
import { Specs } from '@anker-in/headless-ui/biz'
export default function ImageSpecsExample() {
const data = {
data: {
LeftMenu: {
data: [
{
title: 'Design',
isProduct: true,
subTitle: ['Color', 'Size Chart', 'Material'],
},
],
},
RightMenu: {
menus: [
{
handle: 'product-a',
sku: 'SKU001',
subTitle: {
// 纯图片 URL,自动识别并显示
Color: 'https://example.com/color-swatch.png',
// 图文混合对象
'Size Chart': {
text: 'View Full Chart',
imgUrl: 'https://example.com/size-chart.png',
},
// 纯文本
Material: 'Premium Aluminum Alloy',
},
},
],
},
DefaultSelectMenu: {
sku: 'SKU001',
buttonText: 'Add to Cart',
},
},
}
const buildData = {
products: [
{
handle: 'product-a',
name: 'Product A',
title: 'Product A',
price: { value: 99.99, currencyCode: 'USD' },
variants: [
{
sku: 'SKU001',
price: 99.99,
image: {
url: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400&q=80',
altText: 'Product',
},
availableForSale: true,
},
],
},
],
}
return <Specs data={data} buildData={buildData} />
}示例 3: 三产品对比(移动端限制为 2 列)
import { Specs } from '@anker-in/headless-ui/biz'
export default function ThreeProductsExample() {
const data = {
data: {
LeftMenu: {
data: [
{
title: 'Specifications',
isProduct: true,
subTitle: ['Capacity', 'Power', 'Price Range'],
},
],
},
RightMenu: {
menus: [
{
handle: 'pro-model',
sku: 'A1340011',
subTitle: {
Capacity: '27,650mAh',
Power: '250W',
'Price Range': 'Premium',
},
},
{
handle: 'standard-model',
sku: 'A2524014',
subTitle: {
Capacity: '20,000mAh',
Power: '140W',
'Price Range': 'Mid-range',
},
},
{
handle: 'lite-model',
sku: 'A1229011',
subTitle: {
Capacity: '10,000mAh',
Power: '20W',
'Price Range': 'Budget',
},
},
],
},
DefaultSelectMenu: {
// 注意:移动端自动限制为前 2 个 SKU
sku: 'A1340011,A2524014,A1229011',
buttonText: 'Buy Now',
},
},
}
const buildData = {
products: [
/* 3 个产品数据 */
],
}
return <Specs data={data} buildData={buildData} />
}示例 4: 自定义按钮文本(多产品)
import { Specs } from '@anker-in/headless-ui/biz'
export default function CustomButtonTextExample() {
const data = {
data: {
LeftMenu: {
data: [
{
title: 'Specifications',
isProduct: true,
subTitle: ['Capacity', 'Power'],
},
],
},
RightMenu: {
menus: [
{
handle: 'pro-model',
sku: 'A1340011',
subTitle: {
Capacity: '27,650mAh',
Power: '250W',
},
},
{
handle: 'standard-model',
sku: 'A2524014',
subTitle: {
Capacity: '20,000mAh',
Power: '140W',
},
},
],
},
DefaultSelectMenu: {
sku: 'A1340011,A2524014',
// 为不同产品设置不同按钮文本
buttonText: {
'0': 'Buy Pro Model',
'1': 'Buy Standard Model',
},
},
},
}
const buildData = {
products: [
/* 产品数据 */
],
}
return <Specs data={data} buildData={buildData} />
}示例 5: 仅规格对比(无产品信息)
import { Specs } from '@anker-in/headless-ui/biz'
export default function SpecsOnlyExample() {
const data = {
data: {
LeftMenu: {
data: [
{
title: 'Technical Specs',
// 不显示产品卡片
isProduct: false,
subTitle: ['Input', 'Output', 'Dimensions', 'Warranty'],
},
],
},
RightMenu: {
menus: [
{
handle: 'product-a',
sku: 'A1340011',
subTitle: {
Input: '100-240V~50/60Hz',
Output: '5V⎓3A / 9V⎓3A',
Dimensions: '29 × 29 × 29mm',
Warranty: '24 Months',
},
},
],
},
DefaultSelectMenu: {
sku: 'A1340011',
// 不显示按钮
buttonText: '',
},
},
}
const buildData = {
products: [
/* 产品数据 */
],
}
return <Specs data={data} buildData={buildData} />
}示例 6: 带优惠券价格
import { Specs } from '@anker-in/headless-ui/biz'
export default function CouponPriceExample() {
const data = {
data: {
LeftMenu: {
data: [
{
title: 'Specifications',
isProduct: true,
subTitle: ['Capacity', 'Power'],
},
],
},
RightMenu: {
menus: [
{
handle: 'power-bank',
sku: 'A1340011',
subTitle: {
Capacity: '27,650mAh',
Power: '250W',
},
},
],
},
DefaultSelectMenu: {
sku: 'A1340011',
buttonText: 'Buy Now',
},
},
}
const buildData = {
products: [
{
handle: 'power-bank',
name: 'Anker Prime',
title: 'Anker Prime Power Bank',
price: { value: 179.99, currencyCode: 'USD' },
variants: [
{
sku: 'A1340011',
price: 179.99,
image: {
url: 'https://images.unsplash.com/photo-1609091839311-d5365f9ff1c5?w=400&q=80',
altText: 'Power Bank',
},
availableForSale: true,
// 优惠券数据
coupons: [
{
// 优惠后价格
variant_price4wscode: 159.99,
},
],
},
],
},
],
}
// 组件会自动显示优惠后价格和划线的原价
return <Specs data={data} buildData={buildData} />
}示例 7: HTML 富文本规格值
import { Specs } from '@anker-in/headless-ui/biz'
export default function RichTextExample() {
const data = {
data: {
LeftMenu: {
data: [
{
title: 'Features',
isProduct: true,
subTitle: ['Fast Charging', 'Safety', 'Compatibility'],
},
],
},
RightMenu: {
menus: [
{
handle: 'power-bank',
sku: 'A1340011',
subTitle: {
// 支持 HTML 标签
'Fast Charging': '<p><b>USB-C PD 3.1</b><br/>Up to 250W</p>',
Safety:
'<p>MultiProtect™ System<br/><small>Surge, temperature, and short-circuit protection</small></p>',
Compatibility: 'Compatible with all USB-C devices',
},
},
],
},
DefaultSelectMenu: {
sku: 'A1340011',
buttonText: 'Buy Now',
},
},
}
const buildData = {
products: [
/* 产品数据 */
],
}
return <Specs data={data} buildData={buildData} />
}示例 8: 带产品选择回调
import { Specs } from '@anker-in/headless-ui/biz'
import { useState } from 'react'
export default function WithCallbackExample() {
const [selectedProduct, setSelectedProduct] = useState<any>(null)
const handleChange = (product: any, index: number) => {
console.log('Product selected:', product.name, 'at index', index)
setSelectedProduct(product)
// 执行其他操作,如更新 URL、发送事件等
}
const data = {
data: {
LeftMenu: {
data: [
{
title: 'Specifications',
isProduct: true,
subTitle: ['Capacity', 'Power'],
},
],
},
RightMenu: {
menus: [
{
handle: 'power-bank-a',
sku: 'A1340011',
subTitle: {
Capacity: '27,650mAh',
Power: '250W',
},
},
{
handle: 'power-bank-b',
sku: 'A2524014',
subTitle: {
Capacity: '20,000mAh',
Power: '140W',
},
},
],
},
DefaultSelectMenu: {
sku: 'A1340011,A2524014',
buttonText: 'Buy Now',
},
},
}
const buildData = {
products: [
/* 产品数据 */
],
}
return (
<div>
<Specs data={data} buildData={buildData} onChange={handleChange} />
{selectedProduct && (
<p>当前选中产品: {selectedProduct.name}</p>
)}
</div>
)
}示例 9: 自定义样式
import { Specs } from '@anker-in/headless-ui/biz'
export default function CustomStyleExample() {
const data = {
data: {
LeftMenu: {
data: [
{
title: 'Specifications',
isProduct: true,
subTitle: ['Capacity', 'Power'],
},
],
},
RightMenu: {
menus: [
{
handle: 'power-bank',
sku: 'A1340011',
subTitle: {
Capacity: '27,650mAh',
Power: '250W',
},
},
],
},
DefaultSelectMenu: {
sku: 'A1340011',
buttonText: 'Buy Now',
},
// 自定义 CSS
style: `
.specs-wrapper {
background: #fff !important;
border-radius: 8px;
}
.specs-sku-node-text {
border-radius: 4px !important;
}
`,
},
}
const buildData = {
products: [
/* 产品数据 */
],
}
return <Specs data={data} buildData={buildData} className="my-custom-specs" />
}示例 10: 多分类规格对比
import { Specs } from '@anker-in/headless-ui/biz'
import scenarios from './scenarios.json'
export default function MultiCategoryExample() {
const data = {
data: {
LeftMenu: {
data: [
// 第一个分类
{
title: 'Power Specifications',
isProduct: true,
subTitle: ['Battery Capacity', 'Charging Power', 'Fast Charging'],
},
// 第二个分类
{
title: 'Physical Specifications',
isProduct: false,
subTitle: ['Weight', 'Dimensions', 'Material'],
},
// 第三个分类
{
title: 'Other Information',
isProduct: false,
subTitle: ['Warranty', 'Package Contents', 'Certifications'],
},
],
},
RightMenu: {
menus: [
{
handle: 'power-bank',
sku: 'A1340011',
subTitle: {
// 第一个分类的规格值
'Battery Capacity': '27,650mAh',
'Charging Power': '250W',
'Fast Charging': 'USB-C PD 3.1',
// 第二个分类的规格值
Weight: '548g',
Dimensions: '159 × 54 × 49.5mm',
Material: 'Aluminum Alloy',
// 第三个分类的规格值
Warranty: '24 Months',
'Package Contents': 'Power Bank, USB-C Cable, Manual',
Certifications: 'CE, FCC, RoHS',
},
},
],
},
DefaultSelectMenu: {
sku: 'A1340011',
buttonText: 'Buy Now',
},
},
}
const buildData = {
products: [
/* 产品数据 */
],
}
return <Specs data={data} buildData={buildData} />
}响应式行为
列数限制
| 屏幕尺寸 | 最大列数 | 说明 |
|---|---|---|
| 移动端 (< 768px) | 2 列 | 自动限制最多显示 2 个产品 |
| 平板/桌面 (≥ 768px) | 3 列 | 根据 DefaultSelectMenu.sku 决定列数 |
重要: 移动端会自动截取前 2 个 SKU,即使 DefaultSelectMenu.sku 配置了 3 个产品。
实现原理:
// 移动端检测
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// SKU 列表处理
const skuList = data.data.DefaultSelectMenu.sku.split(',')
const displaySkus = isMobile ? skuList.slice(0, 2) : skuList
// 列数计算
const isShowMax = displaySkus.length === 3 && !isMobile断点样式
// 规格表格容器
<div
className="
grid grid-cols-3 // 默认 3 列布局
l-tablet:flex // 平板及以上改为 flex 布局
l-tablet:flex-col
"
/>
// SKU 卡片网格
<div
className={cn(
'grid',
`grid-cols-${isShowMax ? 3 : 2}`, // 动态列数
'l-tablet:w-full',
'l-tablet:gap-4',
'laptop:gap-8',
'desktop:gap-12',
'lg-desktop:gap-16'
)}
/>
// 下拉菜单
<div className="relative w-full l-tablet:w-auto" />规格表格响应式
| 断点 | 间距 | 布局 |
|---|---|---|
| 移动端 | gap-4 | 垂直堆叠 |
| 平板 | gap-4 | 水平对比 |
| 笔记本 | gap-8 | 水平对比 |
| 桌面 | gap-12 | 水平对比 |
| 超大屏 | gap-16 | 水平对比 |
核心算法解析
1. SKU 选择与匹配算法
组件通过 SKU 匹配产品和变体数据,支持自动限制移动端列数。
/**
* SKU 选择算法
* 1. 解析逗号分隔的 SKU 字符串
* 2. 移动端限制为前 2 个
* 3. 匹配 buildData 中的产品和变体
*/
const skuList = data.data.DefaultSelectMenu.sku.split(',')
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const displaySkus = isMobile ? skuList.slice(0, 2) : skuList
// 匹配产品和变体
const selectedProducts = displaySkus.map((sku) => {
for (const product of buildData.products) {
const variant = product.variants.find((v) => v.sku === sku)
if (variant) {
return {
product,
variant,
sku,
}
}
}
return null
})
// 初始化 active 状态
const [activeProducts, setActiveProducts] = useState(
selectedProducts.map((item, index) => ({
product: item?.product,
variant: item?.variant,
index,
}))
)算法复杂度: O(n × m),n 为 SKU 数量,m 为产品数量
2. 智能图片识别算法
自动识别规格值中的图片 URL,支持 8 种图片格式。
/**
* 图片 URL 识别算法
* 检测字符串是否为图片 URL
*/
const IMAGE_EXTENSIONS = [
'.jpeg',
'.jpg',
'.gif',
'.png',
'.webp',
'.bmp',
'.svg',
'.tiff',
]
function isImageUrl(value: string): boolean {
if (typeof value !== 'string') return false
// 转为小写进行比较
const lowerValue = value.toLowerCase()
// 检查是否包含任何图片扩展名
return IMAGE_EXTENSIONS.some((ext) => lowerValue.includes(ext))
}
// 使用示例
const specValue = 'https://example.com/color-red.png'
if (isImageUrl(specValue)) {
// 渲染图片
return <img src={specValue} alt="Spec Image" />
} else if (typeof specValue === 'object' && specValue.imgUrl) {
// 渲染图文混合
return (
<div>
<div dangerouslySetInnerHTML={{ __html: specValue.text }} />
<img src={specValue.imgUrl} alt="Spec Image" />
</div>
)
} else {
// 渲染纯文本
return <div>{specValue}</div>
}支持的图片格式: JPEG, JPG, GIF, PNG, WebP, BMP, SVG, TIFF
3. 价格格式化算法
支持多货币、多地区价格展示,自动处理优惠券价格。
/**
* 价格格式化算法
* 支持优惠券价格显示
*/
function formatPrice(variant: Variant): {
displayPrice: string
originalPrice?: string
hasCoupon: boolean
} {
const { price, coupons, currencyCode } = variant
// 检查是否有优惠券
const couponPrice = coupons?.[0]?.variant_price4wscode
const hasCoupon = couponPrice != null && couponPrice < price
// 格式化价格
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode || 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
}
if (hasCoupon) {
return {
displayPrice: formatCurrency(couponPrice!),
originalPrice: formatCurrency(price),
hasCoupon: true,
}
}
return {
displayPrice: formatCurrency(price),
hasCoupon: false,
}
}
// 渲染价格
const { displayPrice, originalPrice, hasCoupon } = formatPrice(variant)
return (
<div>
<span className="text-lg font-bold">{displayPrice}</span>
{hasCoupon && (
<span className="ml-2 text-sm line-through text-gray-500">
{originalPrice}
</span>
)}
</div>
)支持的货币: USD, EUR, GBP, JPY, CNY 等所有 ISO 4217 货币代码
4. 下拉菜单自动关闭算法
点击下拉菜单外部自动关闭,使用 useRef 优化性能。
/**
* 点击外部关闭算法
* 使用 useRef 和 useEffect 监听点击事件
*/
function useClickOutside(
ref: React.RefObject<HTMLElement>,
handler: () => void
) {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
// 如果点击的是 ref 元素内部,不执行 handler
if (!ref.current || ref.current.contains(event.target as Node)) {
return
}
// 点击外部,执行 handler
handler()
}
document.addEventListener('mousedown', listener)
document.addEventListener('touchstart', listener)
return () => {
document.removeEventListener('mousedown', listener)
document.removeEventListener('touchstart', listener)
}
}, [ref, handler])
}
// 使用示例
const dropdownRef = useRef<HTMLDivElement>(null)
const [isOpen, setIsOpen] = useState(false)
useClickOutside(dropdownRef, () => {
setIsOpen(false)
})
return (
<div ref={dropdownRef}>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && <div>Dropdown Content</div>}
</div>
)5. 动态按钮文本算法
支持统一按钮文本或按索引自定义,提供灵活配置。
/**
* 按钮文本获取算法
* 支持字符串或对象格式
*/
function getButtonText(
buttonText: string | { [index: string]: string },
index: number
): string {
// 统一文本
if (typeof buttonText === 'string') {
return buttonText
}
// 按索引自定义
if (typeof buttonText === 'object' && buttonText[index] != null) {
return buttonText[index]
}
// 默认文本
return 'Buy Now'
}
// 使用示例
const buttonText = {
'0': 'Buy Pro Model',
'1': 'Buy Standard Model',
}
// 渲染按钮
activeProducts.map((item, index) => (
<button key={index}>{getButtonText(buttonText, index)}</button>
))6. 规格值渲染算法
统一处理三种规格值格式(文本、图片、图文混合)。
/**
* 规格值渲染算法
* 自动识别并渲染不同格式
*/
function renderSpecValue(value: SpecValue): React.ReactNode {
// 1. 图文混合对象
if (typeof value === 'object' && value.imgUrl) {
return (
<div className="flex flex-col gap-2">
{value.text && (
<div
className="text-sm"
dangerouslySetInnerHTML={{ __html: value.text }}
/>
)}
<img
src={value.imgUrl}
alt="Spec"
className="max-w-full h-auto rounded"
/>
</div>
)
}
// 2. 纯图片 URL
if (typeof value === 'string' && isImageUrl(value)) {
return (
<img src={value} alt="Spec" className="max-w-full h-auto rounded" />
)
}
// 3. 纯文本(可能包含 HTML)
if (typeof value === 'string') {
// 检查是否包含 HTML 标签
if (value.includes('<') && value.includes('>')) {
return (
<div
className="text-sm"
dangerouslySetInnerHTML={{ __html: value }}
/>
)
}
return <div className="text-sm">{value}</div>
}
return null
}
// 使用示例
<div>{renderSpecValue(subTitle['Battery Capacity'])}</div>7. 产品切换回调算法
处理下拉菜单产品选择,更新状态并触发回调。
/**
* 产品切换算法
* 更新 active 状态并触发回调
*/
function handleProductChange(
newProduct: Product,
newVariant: Variant,
columnIndex: number
) {
// 1. 更新 active 状态
const newActiveProducts = [...activeProducts]
newActiveProducts[columnIndex] = {
product: newProduct,
variant: newVariant,
index: columnIndex,
}
setActiveProducts(newActiveProducts)
// 2. 触发 onChange 回调
if (onChange) {
onChange(newProduct, columnIndex)
}
// 3. 关闭下拉菜单
setOpenDropdown(null)
// 4. 可选:发送分析事件
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', 'product_compare_switch', {
product_name: newProduct.name,
product_sku: newVariant.sku,
column_index: columnIndex,
})
}
}
// 渲染下拉选项
buildData.products.map((product) =>
product.variants.map((variant) => (
<button
key={variant.sku}
onClick={() => handleProductChange(product, variant, columnIndex)}
>
{product.name}
</button>
))
)设计规范
对比产品数量
- 最少: 1 个产品(不显示下拉菜单)
- 推荐: 2-3 个产品
- 最多: 3 个产品(移动端自动限制为 2 个)
规格项命名
- 简短明了,建议不超过 20 个字符
- 使用名词或名词短语
- 统一使用英文或中文
良好示例:
- ✅ Battery Capacity, Charging Power, Ports
- ✅ 电池容量、充电功率、接口数量
避免使用:
- ❌ How much battery does it have?
- ❌ What is the charging speed?
规格值格式
- 文本: 优先使用单位符号(如 27,650mAh 而非 27650 mAh)
- 图片: 确保图片尺寸一致,推荐 360×360px
- HTML: 使用
<b>标签强调关键信息,避免复杂样式
产品卡片设计
| 元素 | 桌面端 | 移动端 |
|---|---|---|
| 产品图片 | 200×200px | 120×120px |
| 产品名称 | 16px, 2 行截断 | 14px, 2 行截断 |
| 价格 | 24px, 粗体 | 20px, 粗体 |
| 按钮 | 高度 48px | 高度 44px |
无障碍性
- ✅ 使用语义化 HTML 标签(
<h3>作为规格标题) - ✅ 下拉菜单支持键盘导航(可增强)
- ✅ 图片必须提供
alt属性 - ⚠️ 建议添加
aria-label到下拉触发器 - ⚠️ 建议添加
role="grid"到对比表格
可增强的无障碍性功能:
// 下拉触发器
<button
aria-label={`Select product for column ${index + 1}`}
aria-expanded={isOpen}
aria-haspopup="listbox"
>
{product.name}
</button>
// 对比表格
<div role="grid" aria-label="Product comparison table">
<div role="row">
<div role="columnheader">Specifications</div>
<div role="columnheader">{product1.name}</div>
<div role="columnheader">{product2.name}</div>
</div>
<div role="row">
<div role="rowheader">Battery Capacity</div>
<div role="gridcell">27,650mAh</div>
<div role="gridcell">20,000mAh</div>
</div>
</div>性能优化
- ✅ 使用
React.forwardRef()支持 ref 转发 - ✅ 使用
useMemo()缓存菜单列表计算 - ✅ 使用
useMediaQuery优化响应式检测 - ✅ 下拉菜单使用
useRef优化点击外部检测 - ✅ 条件渲染减少 DOM 节点
- ✅ 图片懒加载(可增强)
性能优化示例:
// 1. 缓存菜单列表计算
const displaySkus = useMemo(() => {
const skuList = data.data.DefaultSelectMenu.sku.split(',')
return isMobile ? skuList.slice(0, 2) : skuList
}, [data.data.DefaultSelectMenu.sku, isMobile])
// 2. 缓存产品匹配结果
const selectedProducts = useMemo(() => {
return displaySkus.map((sku) => {
for (const product of buildData.products) {
const variant = product.variants.find((v) => v.sku === sku)
if (variant) {
return { product, variant, sku }
}
}
return null
})
}, [displaySkus, buildData.products])
// 3. 条件渲染减少 DOM 节点
{
isProduct && (
<div className="product-card">
{/* 产品卡片内容 */}
</div>
)
}
// 4. 图片懒加载
<img src={imageUrl} alt={altText} loading="lazy" />常见问题 FAQ
1. 如何隐藏产品卡片,只显示规格表格?
设置 isProduct: false 并清空 buttonText:
LeftMenu: {
data: [{
title: 'Specs',
isProduct: false, // 不显示产品卡片
subTitle: [...]
}]
},
DefaultSelectMenu: {
sku: '...',
buttonText: '' // 不显示按钮
}2. 为什么移动端只显示 2 个产品?
这是性能和用户体验优化。移动端屏幕窄,3 列会导致内容过于拥挤。组件会自动检测 isMobile 并限制最多显示前 2 个 SKU。
3. 如何为不同产品设置不同的按钮文本?
使用对象格式的 buttonText:
DefaultSelectMenu: {
sku: 'A1340011,A2524014',
buttonText: {
'0': 'Buy Pro Model', // 索引对应 sku 的顺序
'1': 'Buy Basic Model'
}
}4. 规格值支持哪些图片格式?
支持常见图片格式: .jpeg, .jpg, .gif, .png, .webp, .bmp, .svg, .tiff。组件会自动识别 URL 路径中的扩展名。
5. 如何显示优惠券价格?
确保 buildData.products[].variants[].coupons 包含优惠券数据:
variants: [{
sku: 'A1340011',
price: 179.99,
coupons: [{
variant_price4wscode: 159.99 // 优惠后价格
}]
}]组件会自动显示优惠后的价格和划线的原价。
6. 下拉菜单如何关闭?
- 点击下拉菜单外的任何区域
- 选择一个产品后自动关闭
- 不支持 ESC 键关闭(可增强)
7. 如何自定义规格表格样式?
通过 data.style 字段注入自定义 CSS:
data: {
style: `
.specs-wrapper {
background: #fff !important;
}
.specs-sku-node-text {
border-radius: 0 !important;
}
`
}8. 规格值可以包含 HTML 吗?
可以。规格值支持 HTML 标签,组件会使用 dangerouslySetInnerHTML 渲染。但请确保 HTML 内容是安全的,避免 XSS 攻击。
subTitle: {
'Fast Charging': '<p><b>USB-C PD 3.1</b><br/>Up to 250W</p>'
}9. 如何处理产品选择事件?
使用 onChange 回调:
const handleChange = (product: any, index: number) => {
console.log('Product selected:', product.name, 'at index', index)
// 执行其他操作
}
<Specs data={data} buildData={buildData} onChange={handleChange} />10. 如何处理 SKU 不存在的情况?
组件会自动跳过不存在的 SKU。如果所有 SKU 都不存在,组件不会渲染任何产品卡片。建议在传入数据前验证 SKU 的有效性:
// 验证 SKU 是否存在
const validateSku = (sku: string, buildData: BuildData): boolean => {
return buildData.products.some((product) =>
product.variants.some((variant) => variant.sku === sku)
)
}
const skuList = data.data.DefaultSelectMenu.sku.split(',')
const validSkus = skuList.filter((sku) => validateSku(sku, buildData))
if (validSkus.length === 0) {
console.error('No valid SKUs found')
}相关资源
- Storybook: 查看 Specs 组件示例
- 源代码:
packages/ui/src/biz-components/Specs/index.tsx - 下拉组件:
packages/ui/src/biz-components/Specs/dropdown.tsx - Story 文件:
packages/ui/src/stories/specs.stories.tsx - Intl.NumberFormat API: MDN 文档
- useMediaQuery Hook: React 响应式设计模式
- Click Outside Pattern: React 设计模式
- WCAG Grid Pattern: W3C ARIA 规范
- dangerouslySetInnerHTML: React 文档