在 React Native 开发中,构建一个看似简单的"待办事项列表",往往能折射出开发者对数据流、UI 适配和用户体验的理解。
本文将通过一个完整的示例,带你掌握 React Native 开发的三个核心支柱:
- 数据管理 :使用 TanStack Query (React Query) 替代手动
useEffect。 - 屏幕适配 :使用 React Native Safe Area Context 适配刘海屏。
- 交互优化 :使用 KeyboardAvoidingView 解决键盘遮挡问题。
最终效果预览
我们将实现一个包含以下功能的 App:
- ✅ 从模拟 API 异步加载数据。
- ✅ 提交新任务时显示加载状态(Pending)。
- ✅ 提交成功后自动刷新列表。
- ✅ 完美避开顶部刘海屏和底部键盘遮挡。
核心代码解析
1. 现代化架构:引入 TanStack Query
在传统的写法中,我们需要手动维护 isLoading、isError 和 data 三个状态。而使用 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 "工业级" 写法。
- 拒绝裸奔的 View :永远记得用
SafeAreaView保护你的内容。 - 拒绝繁琐的状态:用 React Query 让数据流变得清晰、自动。
- 拒绝被遮挡的输入框 :用
KeyboardAvoidingView给用户最好的输入体验。
掌握了这三点,你就掌握了构建高质量移动应用的基础。