功能特性
- ✅ 2-3 产品对比支持 - 灵活支持2个或3个产品并排对比展示
- ✅ 3种标题类型 - selling-point(卖点标题)、primary(一级标题)、secondary(二级标题)
- ✅ 灵活布局比例 - 2产品支持2:3或1:1,3产品固定1:1:1等宽
- ✅ 文本对齐配置 - 支持左对齐(left)和居中对齐(center)
- ✅ 副标题图片展示 - 可在副标题下方显示对比图表或说明图
- ✅ 媒体类型自适应 - 自动识别视频/图片并渲染对应元素
- ✅ 响应式资源管理 - 支持桌面端和移动端独立媒体资源
- ✅ 智能懒加载机制 - IntersectionObserver实现,提前1500px加载
- ✅ 视频自动播放 - 支持自动播放、循环、静音、内联播放
- ✅ 智能标签样式 - 最后一个产品使用品牌色,突出自家产品
- ✅ 主题支持 - 内置light/dark主题切换
- ✅ 移动端优化 - 移动端垂直布局,2产品时智能顺序交换
Props 参数
ProductCompareProps
| Prop | 类型 | 默认值 | 必需 | 说明 |
|---|---|---|---|---|
| data | ProductCompareData | - | ✅ | 产品对比完整配置数据 |
| className | string | '' | ❌ | 外层容器自定义类名 |
ProductCompareData
| 字段 | 类型 | 默认值 | 必需 | 说明 |
|---|---|---|---|---|
| title | string | - | ❌ | 主标题文案 |
| titleType | TitleType | 'primary' | ❌ | 标题类型:‘selling-point’ | ‘primary’ | ‘secondary’ |
| titleIcon | Media | - | ❌ | 标题图标(仅当titleType=‘selling-point’时有效) |
| subtitle | string | - | ❌ | 副标题文案 |
| subtitleImage | Media | - | ❌ | 副标题下方的图片(如对比表、图表) |
| textAlign | TextAlign | 'left' | ❌ | 文本对齐方式:‘left’ | ‘center’ |
| products | ProductItemData[] | [] | ✅ | 产品列表(支持2-3个产品) |
| twoImageRatio | TwoImageRatio | '2:3' | ❌ | 2张图片时的宽度比例:‘2:3’ | ‘1:1’ |
| threeImageRatio | ThreeImageRatio | '1:1:1' | ❌ | 3张图片时的宽度比例(固定’1:1:1’) |
| theme | Theme | 'light' | ❌ | 主题模式:‘light’ | ‘dark’ |
ProductItemData
| 字段 | 类型 | 默认值 | 必需 | 说明 |
|---|---|---|---|---|
| text | string | - | ❌ | 产品标签文本(如”其他品牌”、“Anker E25”) |
| media | Media | - | ❌ | 桌面端媒体资源(视频或图片) |
| mobMedia | Media | - | ❌ | 移动端媒体资源(未指定时使用media) |
| poster | Media | - | ❌ | 桌面端视频封面图 |
| mobPoster | Media | - | ❌ | 移动端视频封面图 |
Media 类型
interface Media {
url: string // 媒体资源URL
alt?: string // 替代文本(无障碍必需)
mimeType?: string // MIME类型,用于判断视频/图片
width?: number // 媒体宽度(可选)
height?: number // 媒体高度(可选)
}类型定义
import type { Media, Theme } from '../../types/props.js'
export interface ProductItemData {
/** 产品标签文本 */
text?: string
/** 桌面端媒体(视频或图片) */
media?: Media
/** 移动端媒体(视频或图片) */
mobMedia?: Media
/** 桌面端封面图(仅用于视频) */
poster?: Media
/** 移动端封面图(仅用于视频) */
mobPoster?: Media
}
/** 标题类型 */
export type TitleType = 'selling-point' | 'primary' | 'secondary'
/** 文本对齐方式 */
export type TextAlign = 'left' | 'center'
/** 图片宽度比例(2张图片时) */
export type TwoImageRatio = '2:3' | '1:1'
/** 图片宽度比例(3张图片时) */
export type ThreeImageRatio = '1:1:1'
export interface ProductCompareData {
title?: string
titleType?: TitleType
titleIcon?: Media
subtitle?: string
subtitleImage?: Media
textAlign?: TextAlign
products?: ProductItemData[]
twoImageRatio?: TwoImageRatio
threeImageRatio?: ThreeImageRatio
theme?: Theme
}
export interface ProductCompareProps {
data: ProductCompareData
className?: string
}使用示例
示例 1: 基础2产品对比(默认2:3比例)
import { ProductCompare } from '@anker-in/headless-ui/biz'
export default function BasicCompare() {
return (
<ProductCompare
data={{
title: 'Cleaning Performance Comparison',
titleType: 'primary',
subtitle: 'Real-world cleaning effectiveness test results',
textAlign: 'left',
twoImageRatio: '2:3', // 左侧40%,右侧60%
theme: 'light',
products: [
{
text: 'Standard Mop',
media: {
url: 'https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=800&h=600&q=80',
alt: 'Standard mop cleaning',
mimeType: 'image/jpeg',
width: 800,
height: 600,
},
},
{
text: 'Anker E25 Robot',
media: {
url: 'https://images.unsplash.com/photo-1556911220-e15b29be8c8f?w=800&h=600&q=80',
alt: 'Anker E25 robot cleaning',
mimeType: 'image/jpeg',
width: 800,
height: 600,
},
},
],
}}
/>
)
}示例 2: 卖点标题 + 图标 + 居中对齐
import { ProductCompare } from '@anker-in/headless-ui/biz'
export default function SellingPointCompare() {
return (
<ProductCompare
data={{
titleType: 'selling-point',
title: 'Smart Cleaning System',
titleIcon: {
url: 'https://cdn-icons-png.flaticon.com/512/2917/2917995.png',
alt: 'Smart icon',
mimeType: 'image/png',
},
subtitle: 'Comprehensive comparison of cleaning technologies',
subtitleImage: {
url: 'https://images.unsplash.com/photo-1581291518633-83b4ebd1d83e?w=800&h=200&fit=crop',
alt: 'AI cleaning system comparison chart',
mimeType: 'image/jpeg',
width: 800,
height: 200,
},
textAlign: 'center',
products: [
{
text: 'Traditional Cleaning',
media: {
url: 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=800&h=600&q=80',
alt: 'Traditional cleaning method',
mimeType: 'image/jpeg',
},
},
{
text: 'Anker Smart Cleaning',
media: {
url: 'https://images.unsplash.com/photo-1593642632559-0c6d3fc62b89?w=800&h=600&q=80',
alt: 'Anker smart cleaning',
mimeType: 'image/jpeg',
},
},
],
}}
/>
)
}示例 3: 3产品对比(1:1:1等宽)
import { ProductCompare } from '@anker-in/headless-ui/biz'
export default function ThreeProductCompare() {
return (
<ProductCompare
data={{
title: 'Three Flagship Models Comparison',
titleType: 'primary',
subtitle: 'Performance, battery life, and smart features',
textAlign: 'left',
products: [
{
text: 'Brand A',
media: {
url: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=800&h=600&q=80',
alt: 'Brand A product',
mimeType: 'image/jpeg',
},
mobMedia: {
url: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=600&h=800&fit=crop',
alt: 'Brand A mobile',
mimeType: 'image/jpeg',
},
},
{
text: 'Brand B',
media: {
url: 'https://images.unsplash.com/photo-1572635196237-14b3f281503f?w=800&h=600&q=80',
alt: 'Brand B product',
mimeType: 'image/jpeg',
},
mobMedia: {
url: 'https://images.unsplash.com/photo-1572635196237-14b3f281503f?w=600&h=800&fit=crop',
alt: 'Brand B mobile',
mimeType: 'image/jpeg',
},
},
{
text: 'Anker Flagship',
media: {
url: 'https://images.unsplash.com/photo-1593642632559-0c6d3fc62b89?w=800&h=600&q=80',
alt: 'Anker flagship',
mimeType: 'image/jpeg',
},
mobMedia: {
url: 'https://images.unsplash.com/photo-1593642632559-0c6d3fc62b89?w=600&h=800&fit=crop',
alt: 'Anker flagship mobile',
mimeType: 'image/jpeg',
},
},
],
}}
/>
)
}示例 4: 2产品1:1等宽比例
import { ProductCompare } from '@anker-in/headless-ui/biz'
export default function EqualWidthCompare() {
return (
<ProductCompare
data={{
title: 'Before & After',
titleType: 'secondary',
subtitle: 'Cleaning results comparison',
twoImageRatio: '1:1', // 等宽显示
products: [
{
text: 'Before Cleaning',
media: {
url: 'https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=800&h=600&q=80',
alt: 'Before cleaning',
mimeType: 'image/jpeg',
},
},
{
text: 'After Cleaning',
media: {
url: 'https://images.unsplash.com/photo-1556911220-e15b29be8c8f?w=800&h=600&q=80',
alt: 'After cleaning',
mimeType: 'image/jpeg',
},
},
],
}}
/>
)
}示例 5: 响应式视频资源(桌面/移动端不同)
import { ProductCompare } from '@anker-in/headless-ui/biz'
export default function ResponsiveVideoCompare() {
return (
<ProductCompare
data={{
title: 'Cross-Device Optimization',
subtitle: 'Optimal experience on every device',
products: [
{
text: 'Standard Version',
// 桌面端横版视频
media: {
url: 'https://cdn.example.com/standard-desktop.mp4',
alt: 'Standard version desktop demo',
mimeType: 'video/mp4',
},
poster: {
url: 'https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=800&h=600&q=80',
alt: 'Standard desktop poster',
mimeType: 'image/jpeg',
},
// 移动端竖版视频
mobMedia: {
url: 'https://cdn.example.com/standard-mobile.mp4',
alt: 'Standard version mobile demo',
mimeType: 'video/mp4',
},
mobPoster: {
url: 'https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=600&h=800&fit=crop',
alt: 'Standard mobile poster',
mimeType: 'image/jpeg',
},
},
{
text: 'Flagship Version',
media: {
url: 'https://cdn.example.com/flagship-desktop.mp4',
alt: 'Flagship version desktop demo',
mimeType: 'video/mp4',
},
poster: {
url: 'https://images.unsplash.com/photo-1556911220-e15b29be8c8f?w=800&h=600&q=80',
alt: 'Flagship desktop poster',
mimeType: 'image/jpeg',
},
mobMedia: {
url: 'https://cdn.example.com/flagship-mobile.mp4',
alt: 'Flagship version mobile demo',
mimeType: 'video/mp4',
},
mobPoster: {
url: 'https://images.unsplash.com/photo-1556911220-e15b29be8c8f?w=600&h=800&fit=crop',
alt: 'Flagship mobile poster',
mimeType: 'image/jpeg',
},
},
],
}}
/>
)
}示例 6: Dark主题 + 无标签
import { ProductCompare } from '@anker-in/headless-ui/biz'
export default function DarkThemeNoLabel() {
return (
<ProductCompare
data={{
title: 'Noise Level Comparison',
subtitle: 'Real noise level testing',
theme: 'dark',
products: [
{
// 不设置text,不显示标签
media: {
url: 'https://cdn.example.com/competitor-noise.mp4',
alt: 'Competitor noise test',
mimeType: 'video/mp4',
},
poster: {
url: 'https://images.unsplash.com/photo-1595246140625-573b715d11dc?w=800&h=600&q=80',
alt: 'Competitor poster',
mimeType: 'image/jpeg',
},
},
{
media: {
url: 'https://cdn.example.com/anker-noise.mp4',
alt: 'Anker noise test',
mimeType: 'video/mp4',
},
poster: {
url: 'https://images.unsplash.com/photo-1609091839311-d98929fc8f1e?w=800&h=600&q=80',
alt: 'Anker poster',
mimeType: 'image/jpeg',
},
},
],
}}
/>
)
}示例 7: 自定义容器样式
import { ProductCompare } from '@anker-in/headless-ui/biz'
export default function CustomStyleCompare() {
return (
<div className="bg-gray-50 py-16">
<ProductCompare
className="max-w-7xl mx-auto shadow-2xl rounded-xl"
data={{
title: 'Premium Comparison',
subtitle: 'See the difference',
products: [
{
text: 'Competitor',
media: {
url: 'https://images.unsplash.com/photo-1625772452888-7e9b8f8f583e?w=800&h=600&q=80',
alt: 'Competitor product',
mimeType: 'image/jpeg',
},
},
{
text: 'Anker Premium',
media: {
url: 'https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=800&h=600&q=80',
alt: 'Anker premium',
mimeType: 'image/jpeg',
},
},
],
}}
/>
</div>
)
}示例 8: 副标题图片展示
import { ProductCompare } from '@anker-in/headless-ui/biz'
export default function WithSubtitleImage() {
return (
<ProductCompare
data={{
title: 'Performance Metrics',
subtitle: 'Comprehensive testing results',
subtitleImage: {
url: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=1200&h=300&fit=crop',
alt: 'Performance comparison chart',
mimeType: 'image/jpeg',
width: 1200,
height: 300,
},
products: [
{
text: 'Standard Model',
media: {
url: 'https://images.unsplash.com/photo-1625772452888-7e9b8f8f583e?w=800&h=600&q=80',
alt: 'Standard model',
mimeType: 'image/jpeg',
},
},
{
text: 'Pro Model',
media: {
url: 'https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=800&h=600&q=80',
alt: 'Pro model',
mimeType: 'image/jpeg',
},
},
],
}}
/>
)
}示例 9: 纯图片对比(无视频)
import { ProductCompare } from '@anker-in/headless-ui/biz'
export default function ImageOnlyCompare() {
return (
<ProductCompare
data={{
title: 'Design Comparison',
titleType: 'secondary',
products: [
{
text: 'Old Design',
media: {
url: 'https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=800&h=600&q=80',
alt: 'Old design',
mimeType: 'image/jpeg',
},
},
{
text: 'New Design',
media: {
url: 'https://images.unsplash.com/photo-1556911220-e15b29be8c8f?w=800&h=600&q=80',
alt: 'New design',
mimeType: 'image/jpeg',
},
},
],
}}
/>
)
}示例 10: 完整配置(所有可选参数)
import { ProductCompare } from '@anker-in/headless-ui/biz'
export default function FullConfigCompare() {
return (
<ProductCompare
className="my-custom-class" // 可选
data={{
title: 'Complete Feature Set', // 可选
titleType: 'selling-point', // 可选,默认'primary'
titleIcon: { // 可选,仅selling-point时有效
url: 'https://cdn-icons-png.flaticon.com/512/2917/2917995.png',
alt: 'Feature icon',
mimeType: 'image/png',
},
subtitle: 'Everything you need in one place', // 可选
subtitleImage: { // 可选
url: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=1200&h=300&fit=crop',
alt: 'Comparison chart',
mimeType: 'image/jpeg',
},
textAlign: 'center', // 可选,默认'left'
twoImageRatio: '2:3', // 可选,默认'2:3'
theme: 'dark', // 可选,默认'light'
products: [ // 必需
{
text: 'Competitor', // 可选
media: { // 可选
url: 'https://cdn.example.com/video1.mp4',
alt: 'Competitor demo',
mimeType: 'video/mp4',
width: 800,
height: 600,
},
mobMedia: { // 可选
url: 'https://cdn.example.com/video1-mobile.mp4',
alt: 'Competitor mobile demo',
mimeType: 'video/mp4',
},
poster: { // 可选
url: 'https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=800&h=600&q=80',
alt: 'Video poster',
mimeType: 'image/jpeg',
},
mobPoster: { // 可选
url: 'https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=600&h=800&fit=crop',
alt: 'Mobile poster',
mimeType: 'image/jpeg',
},
},
{
text: 'Anker Premium',
media: {
url: 'https://cdn.example.com/video2.mp4',
alt: 'Anker demo',
mimeType: 'video/mp4',
},
poster: {
url: 'https://images.unsplash.com/photo-1556911220-e15b29be8c8f?w=800&h=600&q=80',
alt: 'Anker poster',
mimeType: 'image/jpeg',
},
},
],
}}
/>
)
}核心算法解析
1. 布局计算算法
功能说明: 根据产品数量和比例配置,动态计算每个产品的宽度占比。
核心代码:
function getProductLayoutClasses(
index: number,
totalProducts: number,
twoImageRatio?: TwoImageRatio
): string {
// 2产品布局
if (totalProducts === 2) {
if (twoImageRatio === '1:1') {
return 'laptop:flex-[1]' // 等宽1:1
}
// 默认2:3比例
return index === 0 ? 'laptop:flex-[2]' : 'laptop:flex-[3]'
}
// 3产品布局(1:1:1等宽)
if (totalProducts === 3) {
return 'laptop:flex-[1]'
}
return 'laptop:flex-[1]'
}算法原理:
Flexbox比例分配:
- flex-[1]: 占据1份宽度
- flex-[2]: 占据2份宽度
- flex-[3]: 占据3份宽度
2产品默认2:3比例:
总宽度 = 2 + 3 = 5份
产品1宽度 = (2/5) * 100% = 40%
产品2宽度 = (3/5) * 100% = 60%
2产品1:1比例:
总宽度 = 1 + 1 = 2份
产品1宽度 = (1/2) * 100% = 50%
产品2宽度 = (1/2) * 100% = 50%
3产品1:1:1比例:
总宽度 = 1 + 1 + 1 = 3份
每个产品宽度 = (1/3) * 100% ≈ 33.33%应用示例:
<div className={cn('product-item', getProductLayoutClasses(0, 2, '2:3'))}>
{/* laptop:flex-[2] → 40%宽度 */}
</div>
<div className={cn('product-item', getProductLayoutClasses(1, 2, '2:3'))}>
{/* laptop:flex-[3] → 60%宽度 */}
</div>2. 懒加载算法
功能说明: 使用IntersectionObserver API实现智能懒加载,提前1500px加载媒体资源。
核心代码:
function LazyMedia({
children,
offset = 800,
}: {
children: React.ReactNode
offset?: number
}) {
const [loaded, setLoaded] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
// 1. 创建IntersectionObserver
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setLoaded(true) // 进入视口时标记为已加载
}
},
{
rootMargin: `${offset}px`, // 提前offset px触发
}
)
// 2. 观察元素
if (ref.current) {
observer.observe(ref.current)
}
// 3. 清理观察器
return () => {
observer.disconnect()
}
}, [offset])
return (
<div ref={ref} className="size-full">
{loaded && children} {/* 仅在加载后渲染 */}
</div>
)
}算法流程:
1. 组件挂载时创建IntersectionObserver
↓
2. 设置rootMargin为1500px
(元素距离视口1500px时触发)
↓
3. 观察目标元素
↓
4. 元素进入视口?
├─ 是 → setLoaded(true)
│ └─ 渲染children(视频/图片)
└─ 否 → 继续等待
↓
5. 组件卸载时断开观察器性能优化:
- 提前加载: 1500px提前量确保用户滚动到时媒体已加载
- 节省资源: 屏幕外的媒体不加载,减少初始加载时间
- 内存管理: 组件卸载时自动断开观察器,避免内存泄漏
使用示例:
<LazyMedia offset={1500}>
<video src="video.mp4" autoPlay loop muted />
</LazyMedia>3. 媒体类型检测算法
功能说明: 根据mimeType自动识别视频或图片,渲染对应HTML元素。
核心代码:
function MediaElement({
media,
poster,
className,
}: {
media?: Media
poster?: Media
className?: string
}) {
if (!media?.url) return null
// 1. 检测是否为视频
const isVideo = media.mimeType?.startsWith('video/')
// 2. 渲染视频元素
if (isVideo) {
return (
<video
src={media.url}
playsInline
autoPlay
muted
loop
poster={poster?.url || ''}
preload="metadata"
disablePictureInPicture
disableRemotePlayback
className={className}
width={media.width}
height={media.height}
/>
)
}
// 3. 渲染图片元素
return (
<img
src={media.url}
alt={media.alt || ''}
className={className}
width={media.width}
height={media.height}
/>
)
}检测逻辑:
输入: media.mimeType
↓
是否以'video/'开头?
├─ 是 → 视频类型
│ ↓
│ 渲染<video>
│ - autoPlay: 自动播放
│ - muted: 静音(移动端自动播放必需)
│ - loop: 循环播放
│ - playsInline: 内联播放(iOS必需)
│ - poster: 视频封面图
│ - preload="metadata": 只预加载元数据
│
└─ 否 → 图片类型
↓
渲染<img>
- alt: 替代文本(无障碍)
- width/height: 避免布局抖动mimeType 示例:
| 文件类型 | mimeType | 结果 |
|---|---|---|
| MP4视频 | 'video/mp4' | 渲染<video> |
| WebM视频 | 'video/webm' | 渲染<video> |
| JPEG图片 | 'image/jpeg' | 渲染<img> |
| PNG图片 | 'image/png' | 渲染<img> |
| WebP图片 | 'image/webp' | 渲染<img> |
4. 标签样式算法
功能说明: 最后一个产品使用品牌色背景,其他产品使用半透明黑色,突出自家产品。
核心代码:
const isLastProduct = index === products.length - 1
// 标签样式:最后一个产品用bg-brand-0,其他用rgba(0,0,0,0.2)
const labelBgClass = isLastProduct ? 'bg-brand-0' : 'bg-[rgba(0,0,0,0.2)]'视觉效果:
2产品对比:
┌─────────────┬─────────────┐
│ 竞品产品 │ Anker产品 │
│ ┌─────────┐ │ ┌─────────┐ │
│ │ 其他品牌│ │ │Anker E25│ │
│ └─────────┘ │ └─────────┘ │
│ 半透明黑 │ 品牌色 │ ← 标签背景
│ bg-[rgba │ bg-brand-0 │
│ (0,0,0, │ │
│ 0.2)] │ │
└─────────────┴─────────────┘
3产品对比:
┌────────┬────────┬────────┐
│ 品牌A │ 品牌B │ Anker │
│半透明黑│半透明黑│ 品牌色 │
└────────┴────────┴────────┘设计理念:
- 突出自家产品: 最后一个产品通常是自家产品,使用品牌色更醒目
- 弱化竞品: 竞品使用低调的半透明黑色,不喧宾夺主
- 视觉层次: 创造清晰的视觉层次,引导用户关注重点
5. 移动端顺序交换算法(2产品时)
功能说明: 移动端垂直布局时,自家产品显示在上方,提升视觉优先级。
核心代码:
<div
className={cn('product-item', {
'order-2 tablet:order-1': index === 0 && products.length === 2,
'order-1 tablet:order-2': index === 1 && products.length === 2,
})}
>
{/* 产品内容 */}
</div>顺序变化:
桌面端(水平布局):
┌──────────┬──────────┐
│ 竞品 │ Anker │
│ (index=0)│ (index=1)│
└──────────┴──────────┘
order-1 order-2
移动端(垂直布局):
┌──────────┐
│ Anker │ ← order-1(优先显示)
│ (index=1)│
├──────────┤
│ 竞品 │ ← order-2
│ (index=0)│
└──────────┘原理:
- Flexbox Order属性: 控制元素的显示顺序
- 移动端:
- index=0(竞品):
order-2→ 显示在下方 - index=1(Anker):
order-1→ 显示在上方
- index=0(竞品):
- 桌面端:
- index=0:
tablet:order-1→ 恢复原顺序 - index=1:
tablet:order-2→ 恢复原顺序
- index=0:
为什么交换顺序?
- 移动端屏幕小: 用户可能只看到第一个产品
- 优先展示自家产品: 确保用户先看到重点内容
- 桌面端恢复: 桌面端水平布局,左右对比更直观
6. 响应式资源选择算法
功能说明: 根据设备断点自动选择桌面端或移动端媒体资源。
核心代码:
// 桌面端媒体(laptop及以上)
{product.media && (
<div className="laptop:block hidden">
<LazyMedia offset={1500}>
<MediaElement media={product.media} poster={product.poster} />
</LazyMedia>
</div>
)}
// 移动端媒体(laptop以下)
{(product.mobMedia || product.media) && (
<div className="laptop:hidden block">
<LazyMedia offset={1500}>
<MediaElement
media={product.mobMedia || product.media}
poster={product.mobPoster || product.poster}
/>
</LazyMedia>
</div>
)}决策树:
加载媒体资源
↓
当前断点?
├─ laptop及以上(≥1024px)
│ ↓
│ 显示product.media(桌面端资源)
│ 隐藏移动端
│
└─ laptop以下(<1024px)
↓
product.mobMedia存在?
├─ 是 → 使用mobMedia(移动端专用资源)
└─ 否 → 回退到media(桌面端资源)优势:
- 流量优化: 移动端加载更小的资源
- 适配方向: 桌面端横版,移动端竖版
- 性能提升: 减少不必要的资源加载
- 灵活回退: mobMedia未指定时自动使用media
7. 标题类型渲染算法
功能说明: 根据titleType渲染不同样式的标题。
核心代码:
function renderTitle(titleType?: TitleType, title?: string, titleIcon?: Media) {
if (!title) return null
switch (titleType) {
case 'selling-point':
// 卖点标题:带图标,品牌色
return (
<div className="flex items-center justify-center gap-2">
{titleIcon && (
<img
src={titleIcon.url}
alt={titleIcon.alt || ''}
className="size-[24px] laptop:size-[32px]"
/>
)}
<Heading
size={4}
className="text-[#00BEFA]"
>
{title}
</Heading>
</div>
)
case 'secondary':
// 二级标题:size=3,较小字号
return <Heading size={3}>{title}</Heading>
case 'primary':
default:
// 一级标题:size=4,常规样式
return <Heading size={4}>{title}</Heading>
}
}标题类型对比:
| titleType | size | 颜色 | 图标 | 使用场景 |
|---|---|---|---|---|
selling-point | 4 | #00BEFA(品牌色) | ✅ | 强调卖点,营销页面 |
primary | 4 | 默认 | ❌ | 常规对比标题 |
secondary | 3 | 默认 | ❌ | 次要对比,子页面 |
响应式行为
断点定义
| 断点 | 最小宽度 | 设备类型 | 布局变化 |
|---|---|---|---|
| 默认 | 0px | 手机 | 垂直布局(flex-col),产品间距16px,2产品时顺序交换 |
| tablet | 768px | 平板 | 水平布局(flex-row),2产品顺序恢复,字号14px |
| laptop | 1024px | 笔记本 | 应用flex比例,间距32px,标签位置28px,资源切换 |
| desktop | 1440px | 桌面 | 标签位置32px,字号16px |
| lg-desktop | 1920px | 大屏 | 字号18px,最大化视觉效果 |
响应式特性表
| 特性 | Mobile | Tablet | Laptop+ | 说明 |
|---|---|---|---|---|
| 布局方向 | 垂直(flex-col) | 水平(flex-row) | 水平(flex-row) | 移动端堆叠,桌面端并排 |
| 产品顺序(2产品) | 交换(自家在上) | 原顺序 | 原顺序 | 移动端优先展示自家产品 |
| flex比例 | 不应用 | 不应用 | 应用(2:3或1:1) | 仅桌面端应用宽度比例 |
| 产品间距 | 16px | 16px | 32px | 桌面端增大间距 |
| 标签位置 | 16px | 16px | 28px/32px | 桌面端标签外移 |
| 副标题字号 | 14px | 14px | 14px/16px/18px | 随断点递增 |
| 媒体资源 | mobMedia | mobMedia | media | 桌面端切换资源 |
断点行为示例
// 容器布局
<div className="
flex flex-col // Mobile: 垂直布局
tablet:flex-row tablet:flex-nowrap // Tablet+: 水平布局
mt-[24px] laptop:mt-[32px] // 上边距响应式
gap-[16px] // 产品间距
">
// 产品项
<div className={cn('
w-full // Mobile: 全宽
laptop:flex-[2] // Laptop: flex比例
order-2 tablet:order-1 // 移动端顺序交换
')}>
// 标签定位
<div className="
absolute
left-[16px] top-[16px] // Mobile/Tablet
laptop:left-[28px] laptop:top-[28px] // Laptop
desktop:left-[32px] desktop:top-[32px] // Desktop
">
// 副标题字号
<Text className="
text-[14px] // Mobile/Tablet/Laptop
desktop:text-[16px] // Desktop
lg-desktop:text-[18px] // LG Desktop
">
// 桌面/移动端媒体切换
<div className="laptop:block hidden"> {/* 桌面端显示 */}
<MediaElement media={product.media} />
</div>
<div className="laptop:hidden block"> {/* 移动端显示 */}
<MediaElement media={product.mobMedia || product.media} />
</div>设计规范
使用建议
1. 产品数量选择
| 产品数量 | 推荐度 | 适用场景 | 注意事项 |
|---|---|---|---|
| 2个 | ⭐⭐⭐⭐⭐ 强烈推荐 | 单品对比,竞品对比 | 对比最直观,视觉焦点清晰 |
| 3个 | ⭐⭐⭐⭐ 推荐 | 多品牌横向对比 | 等宽展示,注意内容平衡 |
| 1个 | ⭐ 不推荐 | - | 无法体现”对比”功能 |
| 4+个 | ⭐ 不推荐 | - | 屏幕宽度有限,影响体验 |
2. 布局比例选择
| 场景 | 推荐比例 | 原因 |
|---|---|---|
| 突出自家产品 | twoImageRatio='2:3' | 右侧产品占60%,更醒目 |
| 平等对比 | twoImageRatio='1:1' | 等宽展示,公平对比 |
| 3产品对比 | 固定1:1:1 | 等宽展示,视觉平衡 |
3. 标签文本建议
| 长度 | 示例 | 效果 |
|---|---|---|
| ✅ 2-8字符 | ”其他品牌”、“Anker E25” | 清晰易读,不影响排版 |
| ⚠️ 9-12字符 | ”竞品扫地机器人” | 可接受,注意换行 |
| ❌ 13+字符 | ”其他品牌智能扫地机器人” | 过长,影响排版和视觉 |
4. 视频优化建议
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 时长 | 5-15秒 | 短视频,快速传达对比效果 |
| 格式 | MP4(H.264) | 兼容性最佳 |
| 分辨率 | 1280x720 | 平衡清晰度和文件大小 |
| 码率 | 2-5 Mbps | 5-10秒视频约1-3MB |
| poster | 必须提供 | 避免加载时黑屏 |
| mimeType | 必须设置 | ’video/mp4’用于类型检测 |
5. 标题类型选择指南
| titleType | 使用场景 | 视觉特点 |
|---|---|---|
selling-point | 营销页面,强调卖点 | 品牌色标题 + 图标,吸引眼球 |
primary | 常规对比页面 | size=4,常规样式,通用性强 |
secondary | 子页面,次要对比 | size=3,较小字号,层次清晰 |
注意事项
1. 必填项验证
// ❌ 错误:products为空或未定义
<ProductCompare data={{ title: 'Compare' }} />
// ✅ 正确:至少包含2个产品
<ProductCompare
data={{
title: 'Compare',
products: [
{ text: 'A', media: {...} },
{ text: 'B', media: {...} }
]
}}
/>2. mimeType必填
// ❌ 错误:未设置mimeType,无法判断类型
media: {
url: 'video.mp4',
alt: 'Demo'
}
// ✅ 正确:明确指定mimeType
media: {
url: 'video.mp4',
alt: 'Demo',
mimeType: 'video/mp4' // 必需!
}3. 视频自动播放限制
- 桌面端: 通常支持
autoPlay + muted自动播放 - 移动端: 部分浏览器可能阻止,即使设置了
autoPlay和muted - 解决方案: 提供poster海报图,或添加播放按钮
4. 懒加载offset固定
- 当前值: 1500px(硬编码在LazyMedia组件中)
- 修改: 需要修改源码,暂不支持通过props配置
- 建议: 保持默认值,适用于大多数场景
5. 响应式资源建议
// ✅ 推荐:桌面端和移动端使用不同资源
{
text: 'Product',
media: { url: 'desktop-video.mp4', ... }, // 桌面端横版视频
mobMedia: { url: 'mobile-video.mp4', ... }, // 移动端竖版视频
poster: { url: 'desktop-poster.jpg', ... }, // 桌面端海报
mobPoster: { url: 'mobile-poster.jpg', ... } // 移动端海报
}
// ⚠️ 可接受:仅提供桌面端资源(移动端自动使用)
{
text: 'Product',
media: { url: 'universal-image.jpg', ... } // 桌面端和移动端共用
}无障碍性
键盘导航
ProductCompare为纯展示组件,无交互元素,不需要键盘导航。
ARIA属性建议
// 建议在使用时添加
<ProductCompare
data={{
title: '性能对比',
products: [...]
}}
aria-label="产品性能对比展示"
role="region"
/>添加ARIA标签到图片/视频:
// 图片alt属性(必需)
media: {
url: 'image.jpg',
alt: '清洁效果演示', // 屏幕阅读器会读取
mimeType: 'image/jpeg'
}
// 视频描述(建议添加)
<video
src="video.mp4"
aria-label="清洁效果视频演示"
aria-describedby="video-desc"
>
<track kind="captions" src="captions.vtt" />
</video>
<p id="video-desc" className="sr-only">
视频展示Anker E25在硬木地板上的清洁效果
</p>屏幕阅读器支持
1. 图片替代文本
- ✅ 所有图片必须提供
alt属性 - ✅ alt文本应描述图片内容,不要包含”图片”、“照片”等词
- ✅ 装饰性图片使用空alt:
alt=""
// ✅ 正确
alt: 'Anker E25 robot vacuum cleaning hardwood floor'
// ❌ 错误
alt: 'Image of vacuum'
alt: '照片'2. 视频字幕
<video src="demo.mp4">
<track
kind="captions"
src="demo-zh.vtt"
srcLang="zh-CN"
label="简体中文"
default
/>
<track
kind="captions"
src="demo-en.vtt"
srcLang="en"
label="English"
/>
您的浏览器不支持视频播放。
</video>3. 语义化标签
- ✅ 标题使用
<Heading>组件(渲染为<h2>/<h3>) - ✅ 产品标签使用
<Heading as="h6">,保持语义层级
颜色对比度
标签文本对比度:
| 背景色 | 文字颜色 | 对比度 | WCAG等级 |
|---|---|---|---|
rgba(0,0,0,0.2) | 白色 | 4.8:1 | ✅ AA |
bg-brand-0(品牌色) | 白色 | 根据品牌色而定 | 需测试 |
建议: 使用WebAIM Contrast Checker 测试品牌色对比度。
性能优化
React优化
1. 懒加载媒体
// ✅ 使用LazyMedia组件
<LazyMedia offset={1500}>
<MediaElement media={product.media} />
</LazyMedia>
// 原理:IntersectionObserver,进入视口才渲染
// 优势:减少初始加载,提升首屏性能2. 条件渲染
// ✅ 仅在loaded后渲染
{loaded && children}
// ✅ 桌面/移动端分别渲染
<div className="laptop:block hidden">
{/* 桌面端内容 */}
</div>
<div className="laptop:hidden block">
{/* 移动端内容 */}
</div>3. 视频预加载策略
<video
preload="metadata" // ✅ 只预加载元数据(时长、尺寸)
// preload="auto" // ❌ 预加载整个视频,浪费流量
// preload="none" // ⚠️ 不预加载,可能导致播放延迟
/>视频优化建议
1. 视频压缩
| 视频时长 | 推荐码率 | 预估文件大小 |
|---|---|---|
| 5秒 | 2-3 Mbps | 1-2 MB |
| 10秒 | 3-5 Mbps | 2-3 MB |
| 15秒 | 5-8 Mbps | 3-6 MB |
使用FFmpeg压缩:
# H.264编码,码率3Mbps
ffmpeg -i input.mp4 -c:v libx264 -b:v 3M -c:a aac -b:a 128k output.mp4
# 优化移动端(竖版,分辨率720x1280)
ffmpeg -i input.mp4 -vf scale=720:1280 -c:v libx264 -b:v 2M output.mp42. 海报图优化
poster: {
url: 'poster.webp', // ✅ WebP格式,体积更小
alt: 'Video poster',
mimeType: 'image/webp'
}生成海报图:
# 提取视频第1帧作为海报
ffmpeg -i video.mp4 -vframes 1 -q:v 2 poster.jpg3. 视频属性优化
<video
preload="metadata" // 只预加载元数据
disablePictureInPicture // 禁用画中画
disableRemotePlayback // 禁用远程播放
playsInline // iOS内联播放
muted // 静音(自动播放必需)
loop // 循环播放
/>图片优化建议
1. 格式选择
| 图片类型 | 推荐格式 | 原因 |
|---|---|---|
| 照片 | WebP > JPEG | WebP体积小30%,质量相当 |
| 图标/图表 | WebP > PNG | 支持透明度,体积更小 |
| 简单图形 | SVG | 矢量格式,无限缩放 |
2. 尺寸建议
| 设备 | 推荐宽度 | 说明 |
|---|---|---|
| 桌面端media | 1200px | 足够清晰,文件不大 |
| 移动端mobMedia | 600px | 移动端屏幕较小 |
| 海报poster | 800px | 视频封面图 |
3. 压缩工具
# ImageMagick压缩JPEG(质量85%)
convert input.jpg -quality 85 output.jpg
# 转换为WebP(质量80%)
cwebp -q 80 input.jpg -o output.webp4. 响应式图片
<picture>
<source
srcSet="image.webp"
type="image/webp"
/>
<source
srcSet="image.jpg"
type="image/jpeg"
/>
<img
src="image.jpg"
alt="Product"
loading="lazy"
decoding="async"
/>
</picture>性能监控
使用Lighthouse测试:
# 1. 构建项目
pnpm run build
# 2. 运行生产服务器
pnpm run preview
# 3. 打开Chrome DevTools
# 4. 运行Lighthouse审计性能目标:
| 指标 | 目标值 | 说明 |
|---|---|---|
| Performance | > 90 | 整体性能得分 |
| First Contentful Paint | < 1.8s | 首次内容绘制 |
| Largest Contentful Paint | < 2.5s | 最大内容绘制 |
| Cumulative Layout Shift | < 0.1 | 累积布局偏移 |
常见问题 FAQ
1. 为什么移动端视频不自动播放?
原因: iOS Safari和部分Android浏览器限制非用户触发的视频播放。
解决方案:
// 1. 确保视频设置了muted和playsInline(组件已内置)
<video autoPlay muted playsInline />
// 2. 如果仍不播放,添加播放按钮
const [isPlaying, setIsPlaying] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
<div onClick={() => {
videoRef.current?.play()
setIsPlaying(true)
}}>
{!isPlaying && <PlayIcon />}
<video ref={videoRef} muted playsInline />
</div>2. 如何自定义2产品布局比例?
当前支持: '2:3'(默认) 和 '1:1'
// 默认2:3比例(左40%,右60%)
<ProductCompare data={{ twoImageRatio: '2:3', ... }} />
// 等宽1:1比例
<ProductCompare data={{ twoImageRatio: '1:1', ... }} />扩展其他比例(需修改源码):
// 在getProductLayoutClasses函数中添加
if (twoImageRatio === '1:2') {
return index === 0 ? 'laptop:flex-[1]' : 'laptop:flex-[2]'
}3. 如何修改懒加载提前距离?
当前值: 1500px(硬编码)
修改方法(需修改源码):
// 文件:index.tsx:254
<LazyMedia offset={1500}> // 修改这里
// 修改为800px
<LazyMedia offset={800}>
// 修改为2500px
<LazyMedia offset={2500}>未来建议: 通过props配置
// 理想API(未实现)
<ProductCompare
data={{
lazyLoadOffset: 2000, // 自定义offset
products: [...]
}}
/>4. 如何自定义标签背景色?
当前逻辑: 最后一个产品bg-brand-0,其他rgba(0,0,0,0.2)
修改方法(源码index.tsx:229):
// 当前
const labelBgClass = isLastProduct ? 'bg-brand-0' : 'bg-[rgba(0,0,0,0.2)]'
// 全部使用品牌色
const labelBgClass = 'bg-brand-0'
// 使用渐变背景
const labelBgClass = isLastProduct
? 'bg-gradient-to-r from-[#3ad1ff] to-[#008cd6]'
: 'bg-[rgba(0,0,0,0.3)]'
// 根据index区分颜色
const labelBgClass = index === 0
? 'bg-red-500'
: index === 1
? 'bg-blue-500'
: 'bg-brand-0'5. 如何禁用移动端顺序交换?
当前行为: 2产品时,移动端自家产品显示在上方
禁用方法(源码index.tsx:235-236):
// 当前
<div
className={cn('product-item', {
'order-2 tablet:order-1': index === 0 && products.length === 2,
'order-1 tablet:order-2': index === 1 && products.length === 2,
})}
>
// 修改为:移除条件判断
<div className="product-item">6. 3产品时如何自定义布局比例?
当前: 3产品固定1:1:1等宽
修改为1:2:1(中间产品更宽):
// getProductLayoutClasses函数
if (totalProducts === 3) {
if (index === 1) return 'laptop:flex-[2]' // 中间产品2份宽度
return 'laptop:flex-[1]' // 左右产品1份宽度
}
// 结果:左33.33% | 中50% | 右16.67%修改为2:3:2:
if (totalProducts === 3) {
if (index === 0) return 'laptop:flex-[2]'
if (index === 1) return 'laptop:flex-[3]'
return 'laptop:flex-[2]'
}7. 如何添加点击放大功能?
添加模态框:
import { useState } from 'react'
import { Dialog } from '@anker-in/headless-ui'
const [modalOpen, setModalOpen] = useState(false)
const [currentMedia, setCurrentMedia] = useState<Media | null>(null)
// 产品项添加点击事件
<div
onClick={() => {
setCurrentMedia(product.media)
setModalOpen(true)
}}
className="cursor-pointer transition-transform hover:scale-105"
>
<MediaElement media={product.media} />
</div>
// 模态框
{modalOpen && (
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<div className="max-w-4xl mx-auto">
<MediaElement media={currentMedia} />
</div>
</Dialog>
)}8. 如何处理视频加载失败?
添加错误处理:
const [videoError, setVideoError] = useState(false)
<video
src={media.url}
onError={() => setVideoError(true)}
autoPlay
muted
loop
/>
{videoError && (
<div className="flex items-center justify-center h-full bg-gray-100">
<p className="text-gray-500">视频加载失败</p>
{poster && <img src={poster.url} alt="Fallback poster" />}
</div>
)}9. 如何添加加载动画?
修改LazyMedia组件:
function LazyMedia({ children, offset = 800 }) {
const [loaded, setLoaded] = useState(false)
const ref = useRef<HTMLDivElement>(null)
// IntersectionObserver逻辑...
return (
<div ref={ref} className="size-full">
{!loaded && (
<div className="flex items-center justify-center h-full bg-gray-100">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-0" />
</div>
)}
{loaded && children}
</div>
)
}10. 如何支持4+产品对比?
当前限制: 仅支持2-3个产品
扩展支持(需修改源码):
// 1. 修改类型定义
export type FourImageRatio = '1:1:1:1'
// 2. 修改布局函数
function getProductLayoutClasses(
index: number,
totalProducts: number,
twoImageRatio?: TwoImageRatio,
fourImageRatio?: FourImageRatio
): string {
if (totalProducts === 4) {
return 'laptop:flex-[1]' // 4个产品等宽
}
// 原有逻辑...
}
// 3. 注意:4个产品在小屏幕上可能过于拥挤不推荐: 4+产品会导致单个产品显示区域过小,影响用户体验。建议使用其他布局方式(如轮播、网格等)。
相关资源
Storybook示例
查看组件的交互式文档和所有变体:
pnpm run storybook访问 http://localhost:6006/?path=/story/blocks-创意图文模块-productcompare--default
源代码
- 组件源码:
packages/ui/src/biz-components/ProductCompare/index.tsx - 类型定义:
packages/ui/src/biz-components/ProductCompare/types.ts - 单元测试:
packages/ui/tests/ProductCompare.test.tsx - Story文件:
packages/ui/src/stories/productCompare.stories.tsx
依赖组件
从 @anker-in/headless-ui 导入:
- Heading: 标题组件,支持多级标题(h1-h6)和自定义字号
- Text: 文本组件,用于副标题和描述性文本
- withLayout: 布局HOC,提供统一的容器样式和间距
内部组件
- LazyMedia: 懒加载容器组件,使用IntersectionObserver API
- MediaElement: 媒体元素组件,自动识别视频/图片并渲染
相关组件
- ImageWithText: 单图文组合展示
- ImageTextFeature: 图文特性展示组件
- MediaPlayerBase: 单视频播放器
- MediaSceneSwitcher: 多场景自动切换组件
设计资源
- Figma设计稿: 查看设计
- 设计系统文档: 请查阅项目Design System文档
- Tailwind配置:
packages/ui/tailwind.config.js
技术文章
性能工具
- Lighthouse: Chrome DevTools内置,测试性能和无障碍性
- WebPageTest: https://www.webpagetest.org/
- ImageOptim: https://imageoptim.com/ (Mac图片压缩)
- FFmpeg: https://ffmpeg.org/ (视频处理)