案例开源地址:https://atomgit.com/nutpi/rn_openharmony_buy
请添加图片描述

写在前面

做电商 App 的时候,首页永远是最让人头疼的。信息太多怕用户找不到重点,信息太少又显得寒酸。这篇文章记录一下我用 React Native for OpenHarmony 搭建商城首页的过程,踩过的坑和一些心得体会。

说实话,刚开始做的时候我也没想好要放哪些东西。后来参考了几个主流电商 App,发现大家的首页结构其实差不多:搜索框、Banner、快捷入口、商品推荐。那就按这个来吧。

数据从哪来

在写页面之前,得先解决数据问题。总不能全写死吧?我找了个免费的电商 API 叫 FakeStoreAPI,不用注册直接能用,挺方便的。

const BASE_URL = 'https://fakestoreapi.com';

export const storeApi = {
  getProducts: async () => {
    const res = await fetch(`${BASE_URL}/products`);
    return res.json();
  },
};

就这么几行代码,简单粗暴。fetch 请求完直接 json() 解析返回。

为什么要单独封装 API?

你可能觉得这么简单的请求直接写在组件里不就行了?但是想想,如果哪天接口地址变了,或者要加个 token 什么的,你得满项目找哪里调用了这个接口。封装一下,改一个地方就够了。

开始写首页

先把需要的东西都 import 进来:

import React, {useEffect, useState} from 'react';
import {
  View, Text, StyleSheet, ActivityIndicator,
  TouchableOpacity, ScrollView, Image, Dimensions,
} from 'react-native';
import {Product} from '../types';
import {storeApi} from '../api/store';
import {ProductCard} from '../components/ProductCard';
import {useApp} from '../store/AppContext';
import {TabBar} from '../components/TabBar';

这里 Dimensions 是用来获取屏幕宽度的,后面 Banner 图片要用。useApp 是我们自己写的全局状态 hook,里面有导航方法和未读消息数量这些东西。

接下来定义一下 Banner 数据。正式项目肯定是从后端拿的,这里先写死:

const {width} = Dimensions.get('window');
const banners = [
  {id: 1, image: 'https://picsum.photos/800/300?random=1', title: '新年大促'},
  {id: 2, image: 'https://picsum.photos/800/300?random=2', title: '限时折扣'},
];

关于图片尺寸

Banner 图片我用的是 picsum.photos 这个占位图服务,800x300 的比例在手机上看着还行。实际项目中图片尺寸要跟设计师对好,不然可能会变形或者加载很慢。

组件主体结构

export const HomeScreen = () => {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);
  const {navigate, unreadCount} = useApp();

三个关键的东西:

  • products 存商品列表
  • loading 控制加载状态
  • navigate 用来跳转页面,unreadCount 是未读消息数

数据加载放在 useEffect 里:

useEffect(() => {
  loadProducts();
}, []);

const loadProducts = async () => {
  try {
    const data = await storeApi.getProducts();
    setProducts(data);
  } catch (error) {
    console.error('Failed to load products:', error);
  } finally {
    setLoading(false);
  }
};

finally 的妙用

不管请求成功还是失败,finally 里的代码都会执行。这样就不用在 try 和 catch 里都写一遍 setLoading(false) 了。这个小技巧能让代码简洁不少。

顶部搜索栏

首页顶部是搜索栏和消息入口:

<View style={styles.header}>
  <TouchableOpacity style={styles.searchBar} onPress={() => navigate('search')}>
    <Text style={styles.searchIcon}>🔍</Text>
    <Text style={styles.searchPlaceholder}>搜索商品</Text>
  </TouchableOpacity>
  <TouchableOpacity style={styles.msgBtn} onPress={() => navigate('messageList')}>
    <Text style={styles.msgIcon}>🔔</Text>
    {unreadCount > 0 && (
      <View style={styles.badge}>
        <Text style={styles.badgeText}>{unreadCount}</Text>
      </View>
    )}
  </TouchableOpacity>
</View>

搜索栏我没用 TextInput,而是用 TouchableOpacity 做成了一个假的输入框。点击后跳转到专门的搜索页面。

为什么不直接用输入框?

主要是考虑到搜索页面可能有搜索历史、热门搜索这些功能,放在首页太挤了。而且用户点击搜索框的时候,键盘弹出来会把首页内容顶上去,体验不太好。

消息图标右上角的小红点用 unreadCount > 0 来控制显示。这个数字是从全局状态里拿的,任何地方消息被读了,这里都会自动更新。

Banner 轮播

ScrollView 的水平滚动来实现简易轮播:

<ScrollView 
  horizontal 
  pagingEnabled 
  showsHorizontalScrollIndicator={false} 
  style={styles.bannerScroll}
>
  {banners.map(banner => (
    <Image key={banner.id} source={{uri: banner.image}} style={styles.banner} />
  ))}
</ScrollView>

pagingEnabled 这个属性很关键,加上它之后滑动会有"吸附"效果,每次正好停在一张图片上。showsHorizontalScrollIndicator={false} 把底部的滚动条藏起来,不然挺丑的。

想做自动轮播?

可以用 setInterval 配合 scrollTo 方法实现。不过要注意在组件卸载时清除定时器,不然会内存泄漏。如果需求复杂的话,建议直接用 react-native-snap-carousel 这类库。

快捷入口

Banner 下面是四个快捷入口:

<View style={styles.quickActions}>
  {[
    {icon: '📦', label: '分类', screen: 'category'},
    {icon: '🎫', label: '优惠券', screen: 'couponList'},
    {icon: '❤️', label: '收藏', screen: 'favorites'},
    {icon: '📋', label: '订单', screen: 'orderList'},
  ].map(item => (
    <TouchableOpacity 
      key={item.label} 
      style={styles.quickItem} 
      onPress={() => navigate(item.screen as any)}
    >
      <Text style={styles.quickIcon}>{item.icon}</Text>
      <Text style={styles.quickLabel}>{item.label}</Text>
    </TouchableOpacity>
  ))}
</View>

用数组配置的方式来写,后面要加减入口改数组就行,不用动 JSX 结构。

Emoji 当图标靠谱吗?

开发阶段用 Emoji 很方便,但正式上线建议换成图片或者 iconfont。因为不同系统的 Emoji 长得不一样,可能会影响视觉一致性。

商品列表

终于到重头戏了,商品列表:

<View style={styles.section}>
  <Text style={styles.sectionTitle}>热门商品</Text>
  {loading ? (
    <ActivityIndicator size="large" color="#3498db" style={styles.loader} />
  ) : (
    <View style={styles.productGrid}>
      {products.map(item => (
        <ProductCard 
          key={item.id} 
          product={item} 
          onPress={() => navigate('productDetail', {product: item})} 
        />
      ))}
    </View>
  )}
</View>

加载中显示菊花图,加载完显示商品网格。商品卡片抽成了单独的 ProductCard 组件,首页代码就不会太臃肿。

点击商品的时候,我把整个商品对象都传给了详情页。这样详情页打开就能直接显示内容,不用再等接口返回。

传对象还是传 ID?

传对象的好处是详情页秒开,用户体验好。坏处是如果商品信息更新了,详情页显示的可能是旧数据。看业务需求吧,对实时性要求高的话还是传 ID 让详情页自己请求。

样式部分

挑几个关键的样式说一下:

const styles = StyleSheet.create({
  container: {flex: 1, backgroundColor: '#f5f5f5'},
  header: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingHorizontal: 16,
    paddingTop: 50,
    paddingBottom: 12,
    backgroundColor: '#3498db',
  },

paddingTop: 50 是给状态栏留的空间。不同机型状态栏高度不一样,正式项目建议用 react-native-safe-area-context 来动态获取。

  searchBar: {
    flex: 1,
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: '#fff',
    borderRadius: 20,
    paddingHorizontal: 16,
    paddingVertical: 10,
  },

搜索栏用 flex: 1 占据剩余空间,borderRadius: 20 做成胶囊形状。

  productGrid: {
    flexDirection: 'row', 
    flexWrap: 'wrap', 
    justifyContent: 'space-between'
  },

商品网格用 flexWrap: 'wrap' 实现自动换行。justifyContent: 'space-between' 让两列商品左右分开,中间自动留出间距。

为什么不用 FlatList?

首页商品数量不多的话,用 map 渲染问题不大。如果商品很多(比如上百个),建议换成 FlatList,它有虚拟列表优化,性能更好。

最后别忘了 TabBar

<TabBar />

底部导航栏是个公共组件,首页、分类、购物车、我的这四个页面都要用。

总结一下

首页看着内容多,其实拆开来每个部分都不复杂。关键是要想清楚数据怎么流动、组件怎么拆分。

几个值得注意的点:

  • API 封装一下,别到处写 fetch
  • 加载状态要处理,别让用户干等着
  • 搜索框做成假的,跳转到专门的搜索页
  • 商品卡片抽成组件,保持首页代码简洁
  • 样式注意适配状态栏高度

下一篇写分类页面,敬请期待。


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

Logo

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

更多推荐