React Native 实战:构建一个现代化的 Todo List (React Query + SafeArea + 键盘适配)

在 React Native 开发中,构建一个看似简单的"待办事项列表",往往能折射出开发者对数据流、UI 适配和用户体验的理解。

本文将通过一个完整的示例,带你掌握 React Native 开发的三个核心支柱:

  1. 数据管理 :使用 TanStack Query (React Query) 替代手动 useEffect
  2. 屏幕适配 :使用 React Native Safe Area Context 适配刘海屏。
  3. 交互优化 :使用 KeyboardAvoidingView 解决键盘遮挡问题。

最终效果预览

我们将实现一个包含以下功能的 App:

  • ✅ 从模拟 API 异步加载数据。
  • ✅ 提交新任务时显示加载状态(Pending)。
  • ✅ 提交成功后自动刷新列表。
  • ✅ 完美避开顶部刘海屏和底部键盘遮挡。

核心代码解析

1. 现代化架构:引入 TanStack Query

在传统的写法中,我们需要手动维护 isLoadingisErrordata 三个状态。而使用 TanStack Query,一切变得极其简洁。

配置 Provider

首先,我们需要在 App 的最外层包裹 QueryClientProvider,它是 React Query 的大脑。

TypeScript 复制代码
// 初始化 Client
const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* 应用其余部分 */}
    </QueryClientProvider>
  );
}

获取数据 (useQuery)

我们定义了一个 getTodos 函数模拟 API 请求。在组件中,通过 useQuery 钩子自动管理请求生命周期。

TypeScript 复制代码
const { data: todos, isLoading, isError, error } = useQuery({
  queryKey: ['todos'], // 缓存的唯一标识
  queryFn: getTodos,   // 具体的请求函数
});
  • 优势:组件挂载时自动请求,失败自动重试,自带 Loading 状态。

修改数据 (useMutation)

添加任务是一个"副作用"操作,我们使用 useMutation

TypeScript 复制代码
const mutation = useMutation({
  mutationFn: postTodo,
  onSuccess: () => {
    // 关键一步:成功后告诉 React Query 缓存过期了
    // 它会自动重新请求最新的列表数据,UI 随之更新
    queryClient.invalidateQueries({ queryKey: ['todos'] });
    setText(''); // 清空输入框
  },
  onError: (err) => {
    Alert.alert('错误', `添加失败: ${err}`);
  },
});

2. 屏幕适配:SafeArea 的正确姿势

随着 iPhone 灵动岛和 Android 挖孔屏的普及,普通的 View 已经无法满足需求。React Native 官方已弃用内置的 SafeAreaView,转而推荐社区标准库 react-native-safe-area-context

TypeScript 复制代码
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';

export default function App() {
  return (
    <SafeAreaProvider>
      {/* edges 控制只保护顶部、左侧和右侧,底部留给输入框自己处理 */}
      <SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
        <Text style={styles.header}>RN + React Query 待办清单</Text>
        <Todos />
      </SafeAreaView>
    </SafeAreaProvider>
  );
}
  • 最佳实践SafeAreaProvider 必须放在最顶层,而 SafeAreaView 则包裹具体的页面内容。

3. 交互优化:键盘避让指南

输入框在屏幕底部时,最大的痛点就是键盘弹出后会遮挡输入框 。我们使用 KeyboardAvoidingView 来优雅解决。

TypeScript 复制代码
<KeyboardAvoidingView
  style={styles.content}
  // iOS 和 Android 的处理机制不同,需要分别设置
  behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
  // 这里的 offset 用于微调,防止键盘紧贴输入框
  keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}
>
  <FlatList 
    /* ...列表内容... */ 
    // 关键属性:点击列表空白处收起键盘
    keyboardShouldPersistTaps="handled"
  />
  
  <View style={styles.inputContainer}>
    {/* 输入框和按钮 */}
  </View>
</KeyboardAvoidingView>

完整代码实现

你可以直接将以下代码复制到你的 App.tsx 中运行:

TypeScript 复制代码
import React, { useState } from 'react';
import {
  ActivityIndicator,
  Alert,
  FlatList,
  KeyboardAvoidingView,
  Platform,
  StyleSheet,
  Text,
  TextInput,
  TouchableOpacity,
  View
} from 'react-native';
import {
  QueryClient,
  QueryClientProvider,
  useMutation,
  useQuery,
  useQueryClient,
} from '@tanstack/react-query';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';

// --- 1. 类型定义 & 模拟 API ---
interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

const mockTodos: Todo[] = [
  { id: 1, title: '学习 React Native', completed: false },
  { id: 2, title: '集成 TanStack Query', completed: true },
];

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const getTodos = async (): Promise<Todo[]> => {
  await wait(1000); // 模拟网络延迟
  return [...mockTodos];
};

const postTodo = async (title: string): Promise<Todo> => {
  await wait(1000);
  if (!title.trim()) throw new Error('内容不能为空');
  const newTodo = { id: Date.now(), title, completed: false };
  mockTodos.push(newTodo);
  return newTodo;
};

// --- 2. 初始化 Client ---
const queryClient = new QueryClient();

// --- 3. 根组件 ---
export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <SafeAreaProvider>
        <SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
          <Text style={styles.header}>RN + React Query 待办清单</Text>
          <Todos />
        </SafeAreaView>
      </SafeAreaProvider>
    </QueryClientProvider>
  );
}

// --- 4. 业务组件 ---
function Todos() {
  const queryClient = useQueryClient();
  const [text, setText] = useState('');

  // 4.1 读取数据
  const { data: todos, isLoading, isError, error } = useQuery({
    queryKey: ['todos'],
    queryFn: getTodos,
  });

  // 4.2 修改数据
  const mutation = useMutation({
    mutationFn: postTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      setText('');
    },
    onError: (err) => {
      Alert.alert('错误', `添加失败: ${err}`);
    },
  });

  if (isLoading) {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" color="#0000ff" />
        <Text style={{ marginTop: 10 }}>正在加载任务...</Text>
      </View>
    );
  }

  if (isError) {
    return (
      <View style={styles.center}>
        <Text style={styles.errorText}>加载失败: {error.message}</Text>
      </View>
    );
  }

  // 4.3 渲染 UI
  return (
    <KeyboardAvoidingView
      style={styles.content}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
      keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}
    >
      <FlatList
        data={todos}
        keyExtractor={(item) => item.id.toString()}
        renderItem={({ item }) => (
          <View style={styles.item}>
            <Text style={[styles.itemText, item.completed && styles.completedText]}>
              {item.title}
            </Text>
          </View>
        )}
        ListEmptyComponent={<Text style={styles.emptyText}>暂无任务</Text>}
        contentContainerStyle={styles.listContent}
        keyboardShouldPersistTaps="handled"
      />

      <View style={styles.inputContainer}>
        <TextInput
          style={styles.input}
          value={text}
          onChangeText={setText}
          placeholder="输入新任务..."
          editable={!mutation.isPending}
        />
        <TouchableOpacity
          style={[styles.button, mutation.isPending && styles.buttonDisabled]}
          onPress={() => mutation.mutate(text)}
          disabled={mutation.isPending}
        >
          {mutation.isPending ? (
            <ActivityIndicator color="#fff" size="small" />
          ) : (
            <Text style={styles.buttonText}>添加</Text>
          )}
        </TouchableOpacity>
      </View>
    </KeyboardAvoidingView>
  );
}

// --- 5. 样式表 ---
const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#f5f5f5' },
  header: { fontSize: 24, fontWeight: 'bold', textAlign: 'center', marginVertical: 20 },
  content: { flex: 1, paddingHorizontal: 20 },
  listContent: { flexGrow: 1, paddingBottom: 10 },
  center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  errorText: { color: 'red', fontSize: 16 },
  item: { backgroundColor: 'white', padding: 15, borderRadius: 8, marginBottom: 10 },
  itemText: { fontSize: 16 },
  completedText: { textDecorationLine: 'line-through', color: '#999' },
  emptyText: { textAlign: 'center', color: '#999', marginTop: 20 },
  inputContainer: { flexDirection: 'row', marginBottom: 20, marginTop: 10 },
  input: { flex: 1, backgroundColor: 'white', padding: 15, borderRadius: 8, marginRight: 10, borderWidth: 1, borderColor: '#ddd' },
  button: { backgroundColor: '#007AFF', justifyContent: 'center', paddingHorizontal: 20, borderRadius: 8 },
  buttonDisabled: { backgroundColor: '#A0C4FF' },
  buttonText: { color: 'white', fontWeight: 'bold' },
});

总结

这段代码虽然短小,但它是一个非常标准的 React Native "工业级" 写法。

  1. 拒绝裸奔的 View :永远记得用 SafeAreaView 保护你的内容。
  2. 拒绝繁琐的状态:用 React Query 让数据流变得清晰、自动。
  3. 拒绝被遮挡的输入框 :用 KeyboardAvoidingView 给用户最好的输入体验。

掌握了这三点,你就掌握了构建高质量移动应用的基础。

相关推荐
低保和光头哪个先来3 分钟前
场景6:对浏览器内核的理解
开发语言·前端·javascript·vue.js·前端框架
想要一只奶牛猫22 分钟前
Spring Web MVC(三)
前端·spring·mvc
奋飛31 分钟前
微前端系列:核心概念、价值与应用场景
前端·微前端·micro·mfe·什么是微前端
进击的野人2 小时前
Vue Router 深度解析:从基础概念到高级应用实践
前端·vue.js·前端框架
北慕阳2 小时前
健康管理前端记录
前端
1024小神2 小时前
cloudflare的worker中的Environment环境变量和不同环境配置
前端
栀秋6662 小时前
从零开始调用大模型:使用 OpenAI SDK 实现歌词生成,手把手实战指南
前端·llm·openai
l1t2 小时前
DeepSeek总结的算法 X 与舞蹈链文章
前端·javascript·算法
智航GIS2 小时前
6.2 while循环
java·前端·python
2201_757830872 小时前
AOP核心概念
java·前端·数据库