案例开源地址:https://atomgit.com/nutpi/rn_openharmony_buy

写在前面

请添加图片描述

商品详情页是电商 App 里最重要的页面之一,用户在这里决定买不买。所以这个页面要做的事情挺多:展示商品图片、价格、描述,支持收藏、加购物车、立即购买,还要能跳转去看评价。

我做这个页面的时候踩了几个坑,比如图片尺寸怎么处理、收藏状态怎么同步、浏览历史什么时候记录。这篇文章把这些都聊一聊。

数据从哪来

商品详情页的数据是从上一个页面传过来的。用户在首页或分类页点击商品卡片,跳转时把整个商品对象传过来:

navigate('productDetail', {product: item})

这样详情页一打开就能直接显示内容,不用等接口返回。

传对象还是传 ID?

传对象的好处是秒开,用户体验好。坏处是如果商品信息在这期间更新了,详情页显示的是旧数据。对于大多数电商场景,商品信息不会频繁变化,传对象没问题。如果是秒杀、库存紧张的场景,建议传 ID 让详情页自己请求最新数据。

引入依赖

import React from 'react';
import {View, Text, Image, StyleSheet, ScrollView, TouchableOpacity, Dimensions} from 'react-native';
import {useApp} from '../store/AppContext';
import {Header} from '../components/Header';

const {width} = Dimensions.get('window');

Dimensions 用来获取屏幕宽度,商品大图要用。Header 是封装的头部组件,带返回按钮和标题。

组件主体

export const ProductDetailScreen = () => {
  const {
    screenParams, 
    addToCart, 
    addFavorite, 
    removeFavorite, 
    isFavorite, 
    addBrowseHistory, 
    navigate, 
    totalItems
  } = useApp();
  
  const product = screenParams?.product;

从全局状态里解构出一堆方法。screenParams 是页面参数,里面有上一个页面传过来的商品对象。

这里用到的方法挺多的:

  • addToCart: 加购物车
  • addFavorite / removeFavorite: 收藏和取消收藏
  • isFavorite: 判断是否已收藏
  • addBrowseHistory: 记录浏览历史
  • navigate: 页面跳转
  • totalItems: 购物车商品总数,用来显示角标

记录浏览历史

用户打开商品详情,就应该记录到浏览历史里:

React.useEffect(() => {
  if (product) addBrowseHistory(product);
}, [product]);

useEffect 在组件挂载时执行。依赖数组里放 product,意思是只有 product 变化时才执行。实际上这个页面的 product 不会变,所以就是挂载时执行一次。

为什么要判断 product 存在?

防御性编程。虽然正常情况下 product 肯定有值,但万一哪里出了问题,product 是 undefined,调用 addBrowseHistory(undefined) 就会出错。加个判断更稳妥。

if (!product) return null;

const favorite = isFavorite(product.id);

如果没有商品数据,直接返回 null,不渲染任何东西。favorite 变量存储当前商品是否已收藏,后面要用来决定显示红心还是白心。

页面头部

return (
  <View style={styles.container}>
    <Header
      title="商品详情"
      rightElement={
        <TouchableOpacity style={styles.cartBtn} onPress={() => navigate('cart')}>
          <Text style={styles.cartIcon}>🛒</Text>
          {totalItems > 0 && (
            <View style={styles.badge}>
              <Text style={styles.badgeText}>{totalItems}</Text>
            </View>
          )}
        </TouchableOpacity>
      }
    />

头部右边放了个购物车图标,点击跳转到购物车页面。如果购物车里有商品,图标右上角显示数量角标。

Header 组件接收 rightElement 属性,可以传入自定义的右侧内容。这种设计让头部组件更灵活,不同页面可以放不同的东西。

商品大图

    <ScrollView style={styles.content}>
      <Image source={{uri: product.image}} style={styles.image} />

商品图片放在最上面,占满屏幕宽度。样式里设置了 widthheight 都等于屏幕宽度,做成正方形。

image: {width, height: width, resizeMode: 'contain', backgroundColor: '#f9f9f9'},

resizeMode: 'contain' 保证图片完整显示,不会被裁切。背景色设成浅灰,图片加载前不会是一片空白。

为什么用正方形?

电商商品图一般都是正方形的,这是行业惯例。正方形在列表里排列整齐,在详情页也好布局。如果你的商品图是其他比例,可以调整 height 的值。

价格和收藏

      <View style={styles.info}>
        <View style={styles.priceRow}>
          <Text style={styles.price}>${product.price.toFixed(2)}</Text>
          <TouchableOpacity onPress={() => favorite ? removeFavorite(product.id) : addFavorite(product)}>
            <Text style={styles.favoriteIcon}>{favorite ? '❤️' : '🤍'}</Text>
          </TouchableOpacity>
        </View>

价格和收藏按钮放在一行,左边价格,右边收藏。

收藏按钮的逻辑:如果已收藏(favorite 为 true),点击就取消收藏;如果未收藏,点击就添加收藏。图标也跟着变,已收藏显示红心,未收藏显示白心。

三元表达式的嵌套

这行代码有两个三元表达式,一个控制点击行为,一个控制图标显示。虽然写在一起有点绕,但逻辑是清晰的。如果觉得难读,可以拆成两个变量。

商品标题和评分

        <Text style={styles.title}>{product.title}</Text>
        <View style={styles.ratingRow}>
          <Text style={styles.rating}>⭐ {product.rating.rate}</Text>
          <Text style={styles.count}>({product.rating.count} 评价)</Text>
          <TouchableOpacity onPress={() => navigate('reviews', {productId: product.id})}>
            <Text style={styles.viewReviews}>查看评价 ›</Text>
          </TouchableOpacity>
        </View>

标题下面是评分区域,显示评分数值和评价数量。右边有个"查看评价"的入口,点击跳转到评价列表页,传入商品 ID。

FakeStoreAPI 返回的商品数据里自带 rating 字段,包含 rate(评分)和 count(评价数量),正好能用。

分类标签和描述

        <Text style={styles.category}>{product.category}</Text>
        <Text style={styles.descTitle}>商品描述</Text>
        <Text style={styles.description}>{product.description}</Text>
      </View>
    </ScrollView>

分类做成标签样式,蓝色背景圆角矩形,看起来像个 tag。textTransform: 'uppercase' 让英文分类名全大写,比如 “electronics” 显示成 “ELECTRONICS”。

商品描述就是普通的文本,设置了 lineHeight: 22 增加行间距,阅读起来更舒服。

底部操作栏

这是整个页面最重要的部分,用户的购买行为从这里触发:

    <View style={styles.bottomBar}>
      <TouchableOpacity style={styles.iconBtn} onPress={() => navigate('home')}>
        <Text style={styles.iconBtnIcon}>🏠</Text>
        <Text style={styles.iconBtnText}>首页</Text>
      </TouchableOpacity>
      <TouchableOpacity style={styles.iconBtn} onPress={() => navigate('cart')}>
        <Text style={styles.iconBtnIcon}>🛒</Text>
        <Text style={styles.iconBtnText}>购物车</Text>
      </TouchableOpacity>

左边两个图标按钮:首页和购物车。用户可能看完商品想回首页继续逛,或者想去购物车看看之前加的东西。

      <TouchableOpacity style={styles.addBtn} onPress={() => addToCart(product)}>
        <Text style={styles.addBtnText}>加入购物车</Text>
      </TouchableOpacity>
      <TouchableOpacity style={styles.buyBtn} onPress={() => { addToCart(product); navigate('checkout'); }}>
        <Text style={styles.buyBtnText}>立即购买</Text>
      </TouchableOpacity>
    </View>
  </View>
);
};

右边两个主要按钮:加入购物车和立即购买。

"加入购物车"就是调用 addToCart,商品会被添加到购物车里,用户可以继续逛。

"立即购买"稍微复杂一点,先把商品加到购物车,然后直接跳转到结算页。这样结算页就能从购物车里拿到商品数据。

为什么立即购买也要加购物车?

因为结算页是从购物车读取商品的。如果立即购买不加购物车,结算页就拿不到商品数据。当然也可以单独传参,但那样结算页的逻辑会变复杂,要处理两种数据来源。

样式细节

底部操作栏的样式值得说一下:

bottomBar: {
  flexDirection: 'row',
  alignItems: 'center',
  padding: 12,
  paddingBottom: 28,
  backgroundColor: '#fff',
  borderTopWidth: 1,
  borderTopColor: '#eee',
},

paddingBottom: 28 比较大,是为了适配有底部安全区域的手机(比如 iPhone X 及以后的机型)。如果用 react-native-safe-area-context,可以动态获取安全区域高度。

iconBtn: {alignItems: 'center', paddingHorizontal: 12},
iconBtnIcon: {fontSize: 20},
iconBtnText: {fontSize: 10, color: '#666', marginTop: 2},

图标按钮是上下结构,图标在上,文字在下。文字很小(10px),因为这里主要靠图标识别,文字只是辅助。

addBtn: {
  flex: 1,
  backgroundColor: '#f5a623',
  paddingVertical: 12,
  borderRadius: 20,
  marginHorizontal: 8,
  alignItems: 'center',
},
buyBtn: {
  flex: 1,
  backgroundColor: '#e74c3c',
  paddingVertical: 12,
  borderRadius: 20,
  alignItems: 'center',
},

两个主按钮用 flex: 1 平分剩余空间。"加入购物车"用橙黄色,"立即购买"用红色。红色更醒目,引导用户直接购买。这是电商 App 的常见设计,红色按钮的点击率通常更高。

收藏功能的实现

顺便说一下收藏功能在 Context 里是怎么实现的:

const [favorites, setFavorites] = useState<Favorite[]>([]);

const addFavorite = (product: Product) => {
  if (!favorites.find(f => f.id === product.id)) {
    setFavorites(prev => [{...product, addTime: new Date().toISOString()}, ...prev]);
  }
};

const removeFavorite = (id: number) => {
  setFavorites(prev => prev.filter(f => f.id !== id));
};

const isFavorite = (id: number) => favorites.some(f => f.id === id);

addFavorite 先检查是否已收藏,避免重复添加。新收藏的商品加上 addTime 时间戳,放在数组最前面(最新的在前)。

removeFavoritefilter 过滤掉指定 ID 的商品。

isFavoritesome 方法判断数组里是否存在指定 ID 的商品,返回布尔值。

为什么用 some 而不是 find?

find 返回找到的元素,some 返回布尔值。这里只需要知道"有没有",不需要拿到具体元素,用 some 更语义化。

浏览历史的实现

const [browseHistory, setBrowseHistory] = useState<BrowseHistory[]>([]);

const addBrowseHistory = (product: Product) => {
  setBrowseHistory(prev => {
    const filtered = prev.filter(h => h.id !== product.id);
    return [{...product, viewTime: new Date().toISOString()}, ...filtered].slice(0, 50);
  });
};

浏览历史的逻辑稍微复杂一点。先把已存在的同一商品过滤掉,再把新记录加到最前面。这样同一个商品不会重复出现,而且最近浏览的排在前面。

slice(0, 50) 限制最多保存 50 条记录,避免数据无限增长。

为什么要过滤再添加?

假设用户先看了商品 A,再看了商品 B,又看了商品 A。如果不过滤,历史记录就是 [A, B, A],商品 A 出现两次。过滤后再添加,结果是 [A, B],商品 A 只出现一次,而且在最前面(因为是最近看的)。

一些可以优化的地方

1. 图片画廊

现在只显示一张图片,正式项目商品通常有多张图。可以用 FlatList 做成横向滑动的画廊,或者用第三方轮播组件。

2. 规格选择

衣服有尺码,手机有颜色,这些规格选择现在没做。需要的话可以加个弹窗让用户选择规格再加购物车。

3. 加购动画

点击"加入购物车"后,可以加个商品飞入购物车的动画,给用户一个反馈。用 Animated API 可以实现。

4. 分享功能

商品详情页通常有分享按钮,用户可以把商品分享给朋友。需要接入分享 SDK。

写在最后

商品详情页是转化的关键页面,用户在这里做出购买决策。所以信息要全、操作要方便。价格要醒目,购买按钮要好点,收藏和加购要顺手。

这个页面涉及到的全局状态操作比较多:加购物车、收藏、浏览历史。这些功能在其他页面也会用到,所以放在 Context 里统一管理是对的。

下一篇写搜索功能,包括搜索页面和搜索结果页面。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐