React Native 自定义组件开发指南(三)

104 阅读5分钟

引言

在 React Native 应用开发中,良好的用户体验离不开完善的加载状态和错误处理机制。本文将带你一步步封装可复用的加载中组件和网络错误组件,并分享如何优雅地处理网络请求。

1. 加载中组件的封装与使用

1.1 为什么需要加载中组件?

在移动应用开发中,网络请求是不可避免的。用户在进行操作后,如果没有任何反馈,会感到困惑甚至认为应用卡死了。加载中组件就是用来告诉用户:"应用正在努力工作,请稍等片刻。"

1.2 基础实现

先来看一个简单的加载状态实现:

export default function App() {
  const [courses, setCourses] = useState([]);
  const [loading, setLoading] = useState(true);

  const fetchData = async () => {
    try {
      // 延迟 2 秒模拟网络请求
      await new Promise(resolve => setTimeout(resolve, 2000));
      const res = await fetch(`http://localhost:3000/search?q=${keyword}`);
      const { data } = await res.json();
      setCourses(data.courses);
    } finally {
      // 无论成功失败,都要关闭加载状态
      setLoading(false);
    }
  };

  // 关键:在返回主要内容前检查加载状态
  if (loading) {
    return <ActivityIndicator size="small" color="#1f99b0" style={styles.loading} />;
  }

  return (
    // 页面主要内容
  );
}

const styles = StyleSheet.create({
  loading: {
    backgroundColor: '#fff',
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
    bottom: 0,
    zIndex: 1, // 确保在最上层
  }
});

开发小技巧:在 Expo 开发环境中,按 r 键可以快速刷新页面,方便调试。

1.3 封装可复用组件

为了提高代码的复用性和可维护性,我们将加载组件独立封装:

// components/shared/Loading.js
import { ActivityIndicator, StyleSheet } from 'react-native';

/**
 * 加载中组件
 * 用于数据加载时显示等待状态
 */
export default function Loading() {
  return <ActivityIndicator size="small" color="#1f99b0" style={styles.loading} />;
}

const styles = StyleSheet.create({
  loading: {
    backgroundColor: '#fff',
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
    bottom: 0,
    zIndex: 1,
  }
});

在父组件中使用封装后的组件:

import Loading from './components/shared/Loading';

// 在需要显示加载状态的地方
if (loading) {
  return <Loading />;
}

2. 网络错误组件的封装

2.1 错误处理的重要性

网络请求可能会因为各种原因失败:网络不稳定、服务器错误、超时等。一个好的应用应该能够优雅地处理这些错误,而不是直接崩溃或显示空白页面。

2.2 基础错误组件

// components/shared/NetworkError.js
import { StyleSheet, Text, View } from 'react-native';
import SimpleLineIcons from '@expo/vector-icons/SimpleLineIcons';

export default function NetworkError() {
  return (
    <View style={styles.container}>
      <SimpleLineIcons name={'drawer'} size={160} color={'#ddd'} />
      <Text style={styles.title}>抱歉,网络连接出错了!</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  title: {
    color: '#999'
  }
});

在 App 组件中使用:

const [error, setError] = useState(false);

const fetchData = async () => {
  try {
    await new Promise(resolve => setTimeout(resolve, 2000));
    const res = await fetch(`http://localhost:3000/search?q=${keyword}`);
    const { data } = await res.json();
    setCourses(data.courses);
  } catch(err) {
    setError(true); // 捕获错误
  } finally {
    setLoading(false);
  }
};

if (error) {
  return <NetworkError />;
}

2.3 使用 Expo 图标库

Expo 提供了丰富的矢量图标库,可以轻松地为错误页面添加视觉元素:

# 安装图标库
npx expo install @expo/vector-icons

3. 实现重新加载功能

3.1 通过 Props 传递自定义内容

让错误组件更加灵活,可以接收父组件传递的参数:

// 父组件传递自定义标题
if (error) {
  return <NetworkError title='网络连接异常,请检查网络设置' />;
}

// 子组件接收 props
export default function NetworkError(props) {
  // 使用默认值,避免未传递 title 时出错
  const title = props.title || '抱歉,网络连接出错了!';
  
  return (
    <View style={styles.container}>
      <SimpleLineIcons name={'drawer'} size={160} color={'#ddd'} />
      <Text style={styles.title}>{title}</Text>
    </View>
  );
}

3.2 添加重新加载按钮

使用 TouchableOpacity 组件创建可点击的按钮:

import { TouchableOpacity } from 'react-native';

export default function NetworkError(props) {
  const title = props.title || '抱歉,网络连接出错了!';
  
  return (
    <View style={styles.container}>
      <SimpleLineIcons name={'drawer'} size={160} color={'#ddd'} />
      <Text style={styles.title}>{title}</Text>
      <TouchableOpacity style={styles.reload}>
        <Text style={styles.label}>重新加载</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  title: {
    color: '#999',
    marginTop: 10,
  },
  reload: {
    marginTop: 10,
    backgroundColor: '#1f99b0',
    height: 40,
    borderRadius: 4,
    paddingLeft: 10,
    paddingRight: 10,
    justifyContent: 'center',
    alignItems: 'center',
  },
  label: {
    color: '#fff',
    fontSize: 16,
  },
});

3.3 实现重新加载功能

// 父组件定义重新加载函数
const onReload = async () => {
  setLoading(true);
  setError(false);
  await fetchData(); // 重新执行数据获取
};

if (error) {
  // 注意:传递函数时不要加括号,否则会立即执行
  return <NetworkError title='网络连接异常' onReload={onReload} />;
}

// 子组件接收并绑定点击事件
export default function NetworkError(props) {
  const { title = '抱歉,网络连接出错了!', onReload } = props;
  
  return (
    <View style={styles.container}>
      <SimpleLineIcons name={'drawer'} size={160} color={'#ddd'} />
      <Text style={styles.title}>{title}</Text>
      <TouchableOpacity style={styles.reload} onPress={onReload}>
        <Text style={styles.label}>重新加载</Text>
      </TouchableOpacity>
    </View>
  );
}

4. 封装网络请求

4.1 环境变量配置

在项目根目录创建 .env 文件,管理不同环境下的 API 地址:

# 开发环境
EXPO_PUBLIC_API_URL=http://localhost:3000

# 生产环境(使用真机测试时需要改为实际 IP)
EXPO_PUBLIC_API_URL=http://192.168.31.100:3000

注意:环境变量名必须全大写且以 EXPO_PUBLIC_ 开头。

4.2 安装 URL 处理库

npm install urlcat

urlcat 可以智能地拼接 URL 和查询参数:

import urlcat from 'urlcat';

// 自动处理问号和连接符
urlcat('http://localhost:3000', '/articles', { page: 1, limit: 10 })
// 输出:http://localhost:3000/articles?page=1&limit=10

4.3 封装请求工具

创建 utils/request.js 文件:

import urlcat from 'urlcat';

/**
 * 基础请求函数
 * @param {string} url - API 请求路径(如 '/articles')
 * @param {object} [options] - 请求配置项
 * @param {string} [options.method='GET'] - HTTP 方法
 * @param {object} [options.params] - URL 查询参数
 * @param {object} [options.body] - 请求体数据
 * @returns {Promise<object>} 返回解析后的JSON数据
 */
const request = async (url, { method = 'GET', params, body } = {}) => {
  const apiUrl = process.env.EXPO_PUBLIC_API_URL;
  const requestUrl = urlcat(apiUrl, url, params);

  const headers = {
    Accept: 'application/json',
    'Content-Type': 'application/json',
    // 待完成:传递 token
  };

  const config = {
    method,
    headers,
    // RN 中需要将 JavaScript 对象转为 JSON 字符串
    ...(body && { body: JSON.stringify(body) }),
  };

  const response = await fetch(requestUrl, config);

  if (!response.ok) {
    // 处理 HTTP 错误状态
    const { message, errors } = await response.json().catch(() => ({}));
    const error = new Error(message || '请求失败');
    error.status = response.status;
    error.errors = errors;
    throw error;
  }

  return await response.json();
};

export default request;

4.4 封装语义化请求方法

为了提高代码的可读性,我们封装常用的 HTTP 方法:

/**
 * GET 请求
 * @param {string} url - 请求地址
 * @param {object} [params] - 查询参数
 */
export const get = (url, params) => request(url, { method: 'GET', params });

/**
 * POST 请求
 * @param {string} url - 请求地址
 * @param {object} body - 请求体数据
 */
export const post = (url, body) => request(url, { method: 'POST', body });

/**
 * PUT 请求
 * @param {string} url - 请求地址
 * @param {object} body - 请求体数据
 */
export const put = (url, body) => request(url, { method: 'PUT', body });

/**
 * PATCH 请求
 * @param {string} url - 请求地址
 * @param {object} body - 请求体数据
 */
export const patch = (url, body) => request(url, { method: 'PATCH', body });

/**
 * DELETE 请求(避免使用 delete 关键字)
 * @param {string} url - 请求地址
 */
export const del = (url) => request(url, { method: 'DELETE' });

4.5 在组件中使用封装的请求

方式一:使用基础的 request 函数

import request from '../utils/request';

const fetchData = async () => {
  try {
    const { data } = await request(`/search?q=${keyword}`);
    setCourses(data.courses);
  } catch (err) {
    setError(true);
  } finally {
    setLoading(false);
  }
};

方式二:使用 params 参数(推荐)

const { data } = await request('/search', {
  params: { q: keyword } // 自动处理查询参数
});

方式三:使用语义化的 get 方法

import { get } from '../utils/request';

const fetchData = async () => {
  try {
    // 两种写法都可以
    const { data } = await get(`/search?q=${keyword}`);
    // 或
    const { data } = await get('/search', { q: keyword });
    
    setCourses(data.courses);
  } catch (err) {
    setError(true);
  } finally {
    setLoading(false);
  }
};

5. 完整示例

将以上所有部分组合起来,形成一个完整的应用:

import React, { useState, useEffect } from 'react';
import { get } from './utils/request';
import Loading from './components/shared/Loading';
import NetworkError from './components/shared/NetworkError';

export default function App() {
  const [courses, setCourses] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(false);
  const [keyword, setKeyword] = useState('');

  const fetchData = async () => {
    try {
      setError(false);
      const { data } = await get('/search', { q: keyword });
      setCourses(data.courses);
    } catch (err) {
      setError(true);
    } finally {
      setLoading(false);
    }
  };

  const onReload = async () => {
    setLoading(true);
    await fetchData();
  };

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

  if (loading) {
    return <Loading />;
  }

  if (error) {
    return <NetworkError onReload={onReload} />;
  }

  return (
    // 应用主要内容
  );
}

总结

  1. 组件封装思维:将通用的 UI 逻辑封装成可复用组件,提高代码的复用性和可维护性
  2. 用户体验优化:提供清晰的加载状态和错误反馈,增强用户对应用的信任感
  3. 网络请求规范化:统一的请求处理和错误管理,减少重复代码
  4. 代码可维护性:语义化的 API 设计和合理的文件组织,便于团队协作