【开源鸿蒙跨平台开发学习笔记】Day09:React Native 开发 OpenHarmony —— 仓库列表组件封装

前言

本次参加开源鸿蒙跨平台开发学习活动,选择了 React Native 开发 HarmonyOS技术栈,在学习的同时顺便整理成一份系列笔记,记录从环境到开发的全过程。本篇作为第八篇,在前几篇文章中,我们已经完成了 React Native 项目在 OpenHarmony 上的环境搭建、基础组件使用、页面路由等内容。

本篇继续推进 GitCode 口袋工具 App 的实战开发,重点完成两个核心模块:

  1. 首页的「Star 仓库列表」

  2. 探索页的「仓库目录 Tree」

围绕这两个功能,我们将进一步完善:

  • 统一网络请求层(axios + token 自动注入 + 错误处理)

  • 仓库列表卡片组件 RepoItem 的 UI 封装

  • 首页的并发数据加载与 FlatList 性能优化

  • 路由结构与自定义底部 TabBar

  • Harmony 调试与离线包打包流程

  • 实战踩坑点与修复方案

这是本系列最偏"工程化"的一篇,基本按真实项目架构来组织代码。

一、项目背景:在 HarmonyOS 上做 GitCode 小工具 App

当前工程基于:

技术 版本
React Native 0.72.5
React 18.2
Harmony 适配 @react-native-oh/react-native-harmony@0.72.90
Node >=16

GitCode 口袋工具包含三个页面:

  • 首页:展示用户资料 + Star 仓库列表

  • 探索页:展示仓库 Tree(文件夹/文件),支持层级跳转

  • 设置页:简单展示配置项(占位)

为了让项目更可维护,需要:

  • 统一网络层

  • 组件化封装

  • 路由清晰

  • Harmony 调试流程完善

下面进入具体实现。

二、项目结构(关键文件)

src/

├── api/ # 网络接口封装

├── components/ # 封装组件

├── navigation/ # 路由/TabBar

├── screens/ # 三个页面

├── types/ # TS 类型

└── assets/ # 图标/样式

关键入口:

  • /App.tsx:渲染自定义 TabBar

  • src/navigation/RootTabs.tsx:三个 Tab 页面

  • src/screens/Home.tsx:用户信息 + Star 列表

  • src/screens/Explore.tsx:仓库 Tree 浏览界面

  • src/components/RepoItem.tsx:仓库卡片 UI

三、统一网络请求层(axios + token 注入 + 错误格式化)

真实项目中必须有统一网络层,本项目网络层在 src/api/client.ts 里处理:

自动注入 GitCode 私有 token

错误格式化输出

统一 baseURL

client.ts(核心代码)

TypeScript 复制代码
import axios from 'axios';

let privateToken = '';

export function setPrivateToken(token?: string) {
  if (token) privateToken = token;
}

export const http = axios.create({
  baseURL: 'https://api.gitcode.com/api/v5/',
  timeout: 10000,
});

http.interceptors.request.use(config => {
  const headers = config.headers ?? {};
  headers['private-token'] = privateToken;
  config.headers = headers;
  return config;
});

http.interceptors.response.use(res => res, err => Promise.reject(err));

export function getErrorMessage(error: unknown): string {
  if (axios.isAxiosError(error)) {
    const data = error.response?.data as any;
    const msg =
      typeof data === 'string'
        ? data
        : data?.message || data?.error || error.message;
    return msg;
  }
  return String((error as any)?.message || error);
}

四、调用 GitCode API:用户 & Star 列表 & 仓库 Tree

1)用户信息 API

TypeScript 复制代码
export async function fetchUserProfile(): Promise<UserProfile> {
  return (await http.get('users/qiaomu8559968')).data;
}

2)Star 列表 API

GitCode 需要附加 access_token

TypeScript 复制代码
export async function fetchStarred(username: string): Promise<Repo[]> {
  const res = await http.get(`users/${username}/starred`, {
    params: {access_token: ''},
  });
  return res.data;
}

3)仓库 Tree API

TypeScript 复制代码
export async function fetchRepoTree(owner, repo, sha) {
  return (await http.get(`repos/${owner}/${repo}/git/trees/${sha}`)).data;
}

五、RepoItem:仓库卡片组件封装

首页渲染大量仓库列表,因此需要一个 UI 稳定、参数清晰的组件。

传入属性

  • logo

  • name

  • description

  • language

  • stars

  • commits

  • isStarred

  • onToggleStar(本地态切换)

UI 要点

  • 左侧仓库 Logo

  • 右侧标题行 + Star 按钮

  • 描述文本

  • 语言/Star 数/提交数 统计区

  • 行宽需设 width: '100%',否则在 FlatList 中会压缩

六、首页:并发请求 + FlatList 虚拟化优化

首页的逻辑非常典型:

  1. 并发请求用户和 Star 列表

  2. 处理字段映射与兜底

  3. FlatList 作为根容器(避免 ScrollView 嵌套虚拟列表)

  4. ListHeaderComponent 渲染用户信息区

字段映射策略(GitCode 接口字段较杂)

TypeScript 复制代码
名称     => name || path || full_name
Star    => stargazers_count
Commits => watchers_count(示例接口不含 commits)
语言     => language

本地 Star 逻辑

暂不请求 GitCode Star API,只做 UI 级操作:

TypeScript 复制代码
onToggleStar = id => {
  setList(prev => prev.map(r => (r.id === id ? {...r, isStarred: !r.isStarred} : r)))
}

七、探索页:仓库目录 Tree 展示(文件夹 + 面包屑导航)

GitCode Tree 接口可返回:

  • type: "tree" = 文件夹

  • type: "blob" = 文件

本页面实现:

目录优先排序

图标区分 folder / file

面包屑导航

"main" 分支标签

结构示例:

src/screens/Explore.tsx

├─ 面包屑渲染

├─ Tree 加载逻辑

├─ TreeList 渲染

└─ 排序/图标映射

  • src/screens/Explore.tsx
TypeScript 复制代码
import React, {useEffect, useState} from 'react';
import {View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable} from 'react-native';
import {fetchRepoTree} from '../api/repos';
import {RepoTreeResponse, TreeItem} from '../types/tree';

type Node = TreeItem;
const Sep = () => <View style={styles.sep} />;

export default function Explore(): JSX.Element {
  const [owner] = useState('nutpi');
  const [repo] = useState('GitCode_AI');
  const [stack, setStack] = useState<string[]>(['main']);
  const [data, setData] = useState<Node[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  const currentSha = stack[stack.length - 1];

  useEffect(() => {
    setLoading(true);
    setError('');
    fetchRepoTree(owner, repo, currentSha)
      .then((res: RepoTreeResponse) => {
        const nodes = (res.tree || []).slice().sort((a, b) =>
          a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'tree' ? -1 : 1,
        );
        setData(nodes);
      })
      .catch(e => setError(String(e?.message || e)))
      .finally(() => setLoading(false));
  }, [owner, repo, currentSha]);

  const goInto = (sha: string) => setStack(prev => [...prev, sha]);
  const goBack = () => setStack(prev => (prev.length > 1 ? prev.slice(0, prev.length - 1) : prev));

  const renderItem = ({item}: {item: Node}) => {
    const isDir = item.type === 'tree';
    return (
      <Pressable style={styles.row} onPress={() => { if (isDir) goInto(item.sha); }}>
        <Text style={[styles.icon, isDir ? styles.iconDir : styles.iconFile]}>{isDir ? '📁' : '📄'}</Text>
        <Text style={styles.name}>{item.name}</Text>
        <View style={styles.metaRight}>
          <Text style={styles.commitText}>{item.sha.slice(0, 7)}</Text>
        </View>
      </Pressable>
    );
  };

  if (loading) return (<View style={styles.center}><ActivityIndicator /><Text style={styles.loadingText}>加载中</Text></View>);
  if (error) return (
    <View style={styles.center}>
      <Text style={styles.errorText}>请求失败:{error}</Text>
      <Pressable style={styles.btn} onPress={() => setStack([...stack])}><Text style={styles.btnText}>重试</Text></Pressable>
    </View>
  );

  return (
    <View style={styles.container}>
      <View style={styles.header}>
        <Pressable style={styles.breadcrumbBtn} onPress={goBack}><Text style={styles.breadcrumbText}>{'<'}</Text></Pressable>
        <Text style={styles.repoTitle}>{owner}/{repo}</Text>
        <Text style={styles.branch}>{currentSha}</Text>
      </View>
      <View style={styles.tableHeader}>
        <Text style={[styles.th, styles.thName]}>文件</Text>
        <Text style={[styles.th, styles.thCommit]}>最后提交</Text>
      </View>
      <FlatList
        data={data}
        keyExtractor={(item, index) => item.sha + ':' + index}
        renderItem={renderItem}
        ItemSeparatorComponent={Sep}
        contentContainerStyle={styles.listContent}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {flex: 1, backgroundColor: '#fff'},
  center: {flex: 1, alignItems: 'center', justifyContent: 'center'},
  loadingText: {marginTop: 8, fontSize: 14, color: '#666'},
  errorText: {fontSize: 14, color: '#d00'},
  header: {flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8},
  breadcrumbBtn: {paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4, backgroundColor: '#f2f2f2'},
  breadcrumbText: {fontSize: 16, color: '#333'},
  repoTitle: {marginLeft: 8, fontSize: 18, fontWeight: '600'},
  branch: {marginLeft: 'auto', fontSize: 14, color: '#666'},
  tableHeader: {flexDirection: 'row', paddingHorizontal: 16, paddingVertical: 10, backgroundColor: '#f7f7f7'},
  th: {fontSize: 12, color: '#777'},
  thName: {flex: 1},
  thCommit: {width: 120, textAlign: 'right'},
  row: {flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12},
  icon: {width: 24, textAlign: 'center', marginRight: 8},
  iconDir: {},
  iconFile: {},
  name: {flex: 1, fontSize: 14, color: '#333'},
  metaRight: {width: 120, alignItems: 'flex-end'},
  commitText: {fontSize: 12, color: '#777'},
  listContent: {paddingBottom: 20},
  btn: {marginTop: 12, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 6, backgroundColor: '#007aff'},
  btnText: {color: '#fff', fontSize: 14, fontWeight: '600'},
  sep: {height: StyleSheet.hairlineWidth, backgroundColor: '#eee', marginLeft: 48},
});
  • src/types/tree.ts
TypeScript 复制代码
export type TreeItem = {
  type: 'tree' | 'blob';
  name: string;
  path: string;
  mode: string;
  sha: string;
  md5?: string;
};

export type RepoTreeResponse = {
  tree: TreeItem[];
  sha: string;
};

八、路由结构 + 自定义 BottomTabBar

你在前文已经铺好基础路由,本篇补充 BottomTabBar 的实践。

  • RootTabs.tsx 创建 3 个 Tab 页面

  • BottomTabBar.tsx 自绘底栏(解决 Harmony 默认样式不统一的问题)

底栏结构典型:

首页 | 探索 | 设置

图标可放 assets 或使用 lucide-react-native。

九、Harmony 调试与离线包构建(重点踩坑)

开发模式(Metro)

npm run start

hdc rport tcp:8081 tcp:8081

设备 Reload 加载:

TypeScript 复制代码
http://localhost:8081/index.bundle?platform=harmony

离线包构建

TypeScript 复制代码
npx react-native bundle-harmony --dev false

生成文件:

TypeScript 复制代码
harmony/entry/src/main/resources/rawfile/bundle.harmony.js

打包 hpk 时必须被包含。

最终效果:

十、踩坑记录 & 修复方案

1)"None of the provided JSBundleProviders was able to load a bundle"

解决:

  • 确保端口转发

  • Metro 正常运行

  • hpk 内包含离线包

2)ScrollView 嵌套 FlatList 报错

解决:

  • 使用 FlatList 作为根

  • 头部信息用 ListHeaderComponent

3)列表项宽度不够

解决:

  • RepoItem 外层必须写 width: '100%'

4)ESLint/Prettier 一堆格式错误

解决:

  • JSX 属性换行

  • 拆出 StyleSheet

  • import 按规则排序

十一、可扩展方向

  • Skeleton 骨架屏

  • 分页 + 加载更多

  • 使用 SWR / React Query

  • useRequest 通用 Hook

  • 语言标签 color mapping

  • 补充 "最后提交信息" 接口

总结

本篇完成了 GitCode 小工具的核心结构,包括:

  • 统一网络层

  • 仓库列表组件 RepoItem

  • 首页并发加载与性能优化

  • 仓库 Tree 浏览

  • 路由与 TabBar

  • Harmony 调试/打包实践

相关推荐
车载测试工程师1 小时前
CAPL学习-ETH功能函数-方法类1
网络协议·学习·以太网·capl·canoe
YJlio1 小时前
SDelete 学习笔记(9.15):安全擦除、不可恢复与合规清退实践
笔记·学习·安全
●VON1 小时前
《不止于“开箱即用”:DevUI 表格与表单组件的高阶用法与避坑手册》
学习·华为·openharmony·表单·devui
石像鬼₧魂石1 小时前
flag 是什么?
学习·安全
小简GoGo1 小时前
新手如何搭建配置Android Studio并成功运行React Native项目(使用自带的虚拟机运行)
react native·react.js·android studio
大江东去浪淘尽千古风流人物1 小时前
【MSCKF】StateHelper 学习备注
vscode·学习·性能优化·编辑器·dsp开发
摇滚侠1 小时前
零基础小白自学 Git_Github 教程,GitLFS ,笔记21
笔记·git·github
0和1的舞者1 小时前
Postman接口测试全攻略:传参技巧与实战解析
学习·测试工具·spring·springmvc·postman·web·开发
Mai Dang1 小时前
黑马Linux学习笔记
linux·笔记·学习·阿里云