用 Expo React Native 构建一个用户管理应用
本教程演示了如何构建一个基本的用户管理应用。该应用可以进行用户认证和识别,将用户的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料信息以及上传头像。该应用使用了:
🌐 This tutorial demonstrates how to build a basic user management app. The app authenticates and identifies the user, stores their profile information in the database, and allows the user to log in, update their profile details, and upload a profile photo. The app uses:
- Supabase 数据库 - 一个用于存储用户数据的 Postgres 数据库,并且有 行级安全,所以数据是受保护的,用户只能访问自己的信息。
- Supabase Auth - 允许用户注册和登录。
- Supabase 存储 - 允许用户上传个人头像。

如果你在使用本指南的过程中遇到困难,可以参考 GitHub 上的完整示例。
🌐 If you get stuck while working through this guide, refer to the full example on GitHub.
项目设置 #
🌐 Project setup
在你开始构建之前,你需要先设置数据库和 API。你可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“架构”来完成这一步。
🌐 Before you start building you need to set up the Database and API. You can do this by starting a new Project in Supabase and then creating a "schema" inside the database.
创建一个项目 #
🌐 Create a project
- 在 Supabase 仪表板中创建一个新项目。
- 输入你的项目详情。
- 等新数据库上线。
设置数据库模式 #
🌐 Set up the database schema
现在设置数据库模式。你可以在 SQL 编辑器中使用“用户管理入门”快速开始,也可以复制粘贴下面的 SQL 并运行。
🌐 Now set up the database schema. You can use the "User Management Starter" quickstart in the SQL Editor, or you can copy/paste the SQL from below and run it.
- 在仪表板中转到SQL 编辑器页面。
- 点击 社区 > 快速入门 标签下的 用户管理入门。
- 点击运行。
你可以通过运行 db pull 命令将数据库架构拉到本地项目。查看本地开发文档获取详细说明。
🌐 You can pull the database schema down to your local project by running the db pull command. Read the local development docs for detailed instructions.
1supabase link --project-ref <project-id>2# You can get <project-id> from your project's dashboard URL: https://supabase.com/dashboard/project/<project-id>3supabase db pull获取 API 详情 #
🌐 Get API details
要与数据库表中的数据进行交互,你可以使用封装了自动生成的数据 API 端点的客户端库,并使用来自项目 Connect 对话框的项目 URL 和密钥进行认证。
🌐 To interact with data in database tables, you use the client libraries that wrap the auto-generated Data API endpoints, authenticating using the Project URL and key from the project Connect dialog.
阅读 API 密钥文档 以全面了解所有密钥类型、用途以及在哪里可以找到它们。
正在构建应用 #
🌐 Building the app
先从零开始搭建 React Native 应用吧。
🌐 Start by building the React Native app from scratch.
初始化一个 React Native 应用 #
🌐 Initialize a React Native app
使用 expo 来初始化一个名为 expo-user-management 的应用:
🌐 Use expo to initialize
an app called expo-user-management:
1npx create-expo-app -t expo-template-blank-typescript expo-user-management23cd expo-user-management然后安装额外的依赖:
🌐 Then install the additional dependencies:
1npx expo install @supabase/supabase-js @react-native-async-storage/async-storage现在创建一个辅助文件,使用你之前复制的 API URL 和密钥来初始化 Supabase 客户端。
🌐 Now create a helper file to initialize the Supabase client using the API URL and the key that you copied earlier.
这些变量在你的 Expo 应用中是安全的,因为 Supabase 在你的数据库上启用了行级安全。
🌐 These variables are safe to expose in your Expo app since Supabase has Row Level Security enabled on your Database.
1import { createClient } from '@supabase/supabase-js'2import AsyncStorage from '@react-native-async-storage/async-storage'34const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!5const supabasePublishableKey = process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY!67export const supabase = createClient(supabaseUrl, supabasePublishableKey, {8 auth: {9 storage: AsyncStorage as any,10 autoRefreshToken: true,11 persistSession: true,12 detectSessionInUrl: false,13 },14})应用样式 #
🌐 App styling
你可以在 styles/styles.ts 中使用以下 StyleSheet 组件来为应用添加样式:
🌐 You can use the following StyleSheet component in styles/styles.ts to add style to the app:
1import { StyleSheet } from 'react-native'23export const appStyles = StyleSheet.create({4 container: {5 marginTop: 40,6 padding: 12,7 },8 verticallySpaced: {9 paddingTop: 4,10 paddingBottom: 4,11 alignSelf: 'stretch',12 },13 mt20: {14 marginTop: 20,15 },16 label: {17 fontSize: 16,18 fontWeight: '600',19 color: '#86939e',20 marginBottom: 6,21 },22 input: {23 borderWidth: 1,24 borderColor: '#86939e',25 borderRadius: 4,26 padding: 12,27 fontSize: 16,28 },29 inputDisabled: {30 backgroundColor: '#f2f2f2',31 borderColor: '#d1d1d1',32 color: '#9e9e9e',33 },34 button: {35 backgroundColor: '#2089dc',36 borderRadius: 4,37 padding: 12,38 alignItems: 'center',39 },40 buttonDisabled: {41 opacity: 0.5,42 },43 buttonText: {44 color: '#fff',45 fontSize: 16,46 fontWeight: '600',47 },48 avatarContainer: {49 alignItems: 'center',50 justifyContent: 'center',51 marginTop: 20,52 },53 avatar: {54 borderRadius: 5,55 overflow: 'hidden',56 maxWidth: '100%',57 marginBottom: 20,58 },59 image: {60 objectFit: 'cover',61 paddingTop: 0,62 },63 noImage: {64 backgroundColor: '#333',65 borderWidth: 1,66 borderStyle: 'solid',67 borderColor: 'rgb(200, 200, 200)',68 borderRadius: 5,69 },70})设置一个登录组件 #
🌐 Set up a login component
设置一个 React Native 组件来管理登录和注册。用户应该能够使用他们的邮箱和密码登录。
🌐 Set up a React Native component to manage logins and sign ups. Users should be able to sign in with their email and password.
1import React, { useState } from 'react'2import { Alert, Text, TextInput, TouchableOpacity, View } from 'react-native'3import { supabase } from '../lib/supabase'4import { appStyles } from '../styles/styles'56export default function Auth() {7 const [email, setEmail] = useState('')8 const [password, setPassword] = useState('')9 const [loading, setLoading] = useState(false)10 const styles = appStyles1112 async function signInWithEmail() {13 setLoading(true)14 const { error } = await supabase.auth.signInWithPassword({15 email: email,16 password: password,17 })1819 if (error) Alert.alert(error.message)20 setLoading(false)21 }2223 async function signUpWithEmail() {24 setLoading(true)25 const { error } = await supabase.auth.signUp({26 email: email,27 password: password,28 })2930 if (error) Alert.alert(error.message)31 setLoading(false)32 }3334 return (35 <View style={styles.container}>36 <View style={[styles.verticallySpaced, styles.mt20]}>37 <Text style={styles.label}>Email</Text>38 <TextInput39 onChangeText={(text) => setEmail(text)}40 value={email}41 placeholder="email@address.com"42 autoCapitalize="none"43 style={styles.input}44 />45 </View>46 <View style={styles.verticallySpaced}>47 <Text style={styles.label}>Password</Text>48 <TextInput49 onChangeText={(text) => setPassword(text)}50 value={password}51 secureTextEntry={true}52 placeholder="Password"53 autoCapitalize="none"54 style={styles.input}55 />56 </View>57 <View style={[styles.verticallySpaced, styles.mt20]}>58 <TouchableOpacity59 style={[styles.button, loading && styles.buttonDisabled]}60 onPress={() => signInWithEmail()}61 disabled={loading}62 >63 <Text style={styles.buttonText}>Sign in</Text>64 </TouchableOpacity>65 </View>66 <View style={styles.verticallySpaced}>67 <TouchableOpacity68 style={[styles.button, loading && styles.buttonDisabled]}69 onPress={() => signUpWithEmail()}70 disabled={loading}71 >72 <Text style={styles.buttonText}>Sign up</Text>73 </TouchableOpacity>74 </View>75 </View>76 )77}默认情况下,Supabase Auth 在为用户创建会话之前要求进行电子邮件验证。要支持电子邮件验证,你需要实现深度链接处理!
🌐 By default Supabase Auth requires email verification before a session is created for the users. To support email verification you need to implement deep link handling!
在测试时,你可以在你的项目的邮箱认证提供商设置中禁用邮箱确认。
🌐 While testing, you can disable email confirmation in your project's email auth provider settings.
账户页面 #
🌐 Account page
用户登录后,让他们编辑个人资料并管理账户。
🌐 After a user signs in, let them edit their profile details and manage their account.
为此创建一个名为 Account.tsx 的新组件。
🌐 Create a new component for that called Account.tsx.
1import { useState, useEffect } from 'react'2import { supabase } from '../lib/supabase'3import { View, Alert, TextInput, Text, TouchableOpacity } from 'react-native'4import Avatar from './Avatar'56// ...789export default function Account({ userId, email }: { userId: string; email?: string }) {10 const [loading, setLoading] = useState(true)11 const [username, setUsername] = useState('')12 const [website, setWebsite] = useState('')13 const [avatarUrl, setAvatarUrl] = useState('')14 const styles = appStyles1516 useEffect(() => {17 if (userId) getProfile()18 }, [userId])1920 async function getProfile() {21 try {22 setLoading(true)2324 let { data, error, status } = await supabase25 .from('profiles')26 .select(`username, website, avatar_url`)27 .eq('id', userId)28 .single()29 if (error && status !== 406) {30 throw error31 }3233 if (data) {34 setUsername(data.username)35 setWebsite(data.website)36 setAvatarUrl(data.avatar_url)37 }38 } catch (error) {39 if (error instanceof Error) {40 Alert.alert(error.message)41 }42 } finally {43 setLoading(false)44 }45 }4647 async function updateProfile({48 username,49 website,50 avatar_url,51 }: {52 username: string53 website: string54 avatar_url: string55 }) {56 try {57 setLoading(true)5859 const updates = {60 id: userId,61 username,62 website,63 avatar_url,64 updated_at: new Date(),65 }6667 let { error } = await supabase.from('profiles').upsert(updates)6869 if (error) {70 throw error71 }72 } catch (error: any) {73 Alert.alert(error.message)74 } finally {75 setLoading(false)76 }77 }7879 return (80 <View style={styles.container}>81 <View>8283 {/* ... */}8485 <Text style={styles.label}>Email</Text>86 <TextInput87 value={email ?? ''}88 editable={false}89 selectTextOnFocus={false}90 style={[styles.input, styles.inputDisabled]}91 />92 </View>93 <View style={styles.verticallySpaced}>94 <Text style={styles.label}>Username</Text>95 <TextInput96 value={username || ''}97 onChangeText={(text) => setUsername(text)}98 style={styles.input}99 />100 </View>101 <View style={styles.verticallySpaced}>102 <Text style={styles.label}>Website</Text>103 <TextInput104 value={website || ''}105 onChangeText={(text) => setWebsite(text)}106 style={styles.input}107 />108 </View>109110 <View style={[styles.verticallySpaced, styles.mt20]}>111 <TouchableOpacity112 style={[styles.button, loading && styles.buttonDisabled]}113 onPress={() => updateProfile({ username, website, avatar_url: avatarUrl })}114 disabled={loading}115 >116 <Text style={styles.buttonText}>{loading ? 'Loading ...' : 'Update'}</Text>117 </TouchableOpacity>118 </View>119120 <View style={styles.verticallySpaced}>121 <TouchableOpacity style={styles.button} onPress={() => supabase.auth.signOut()}>122 <Text style={styles.buttonText}>Sign Out</Text>123 </TouchableOpacity>124 </View>125 </View>126 )127}触发! #
🌐 Launch!
现在你已经准备好所有组件了,更新一下 App.tsx :
🌐 Now that you have all the components in place, update App.tsx:
1import { useState, useEffect } from 'react'2import { supabase } from './lib/supabase'3import Auth from './components/Auth'4import Account from './components/Account'5import { View } from 'react-native'67export default function App() {8 const [userId, setUserId] = useState<string | null>(null)9 const [email, setEmail] = useState<string | undefined>(undefined)1011 useEffect(() => {12 supabase.auth.getClaims().then(({ data: { claims } }) => {13 if (claims) {14 setUserId(claims.sub)15 setEmail(claims.email)16 }17 })1819 supabase.auth.onAuthStateChange(async (_event, _session) => {20 const {21 data: { claims },22 } = await supabase.auth.getClaims()23 if (claims) {24 setUserId(claims.sub)25 setEmail(claims.email)26 } else {27 setUserId(null)28 setEmail(undefined)29 }30 })31 }, [])3233 return <View>{userId ? <Account key={userId} userId={userId} email={email} /> : <Auth />}</View>34}完成后,在终端窗口运行这个:
🌐 Once that's done, run this in a terminal window:
1npm start然后按下你想测试应用的环境的相应按键,你就应该能看到完整的应用了。
🌐 And then press the appropriate key for the environment you want to test the app in and you should see the completed app.
额外奖励:头像 #
🌐 Bonus: Profile photos
每个 Supabase 项目都配置了 Storage,用于管理像照片和视频这样的大文件。
🌐 Every Supabase project is configured with Storage for managing large files like photos and videos.
额外依赖安装 #
🌐 Additional dependency installation
你需要一个适用于你正在构建项目的环境的图片选择器,这个例子使用了 expo-image-picker。
🌐 You need an image picker that works on the environment you are building the project for, this example uses expo-image-picker.
1npx expo install expo-image-picker创建一个上传小工具 #
🌐 Create an upload widget
为用户创建一个头像,这样他们就可以上传个人资料照片。先从创建一个新组件开始:
🌐 Create an avatar for the user so that they can upload a profile photo. Start by creating a new component:
1import { useState, useEffect } from 'react'2import { supabase } from '../lib/supabase'3import { View, Alert, Image, Text, TouchableOpacity } from 'react-native'4import * as ImagePicker from 'expo-image-picker'5import { appStyles } from '../styles/styles'67interface Props {8 size: number9 url: string | null10 onUpload: (filePath: string) => void11}1213export default function Avatar({ url, size = 150, onUpload }: Props) {14 const [uploading, setUploading] = useState(false)15 const [avatarUrl, setAvatarUrl] = useState<string | null>(null)16 const avatarSize = { height: size, width: size }17 const styles = appStyles1819 useEffect(() => {20 if (url) downloadImage(url)21 }, [url])2223 async function downloadImage(path: string) {24 try {25 const { data, error } = await supabase.storage.from('avatars').download(path)2627 if (error) {28 throw error29 }3031 const fr = new FileReader()32 fr.readAsDataURL(data)33 fr.onload = () => {34 setAvatarUrl(fr.result as string)35 }36 } catch (error: any) {37 console.log('Error downloading image: ', error.message)38 }39 }4041 async function uploadAvatar() {42 try {43 setUploading(true)4445 const result = await ImagePicker.launchImageLibraryAsync({46 mediaTypes: ImagePicker.MediaTypeOptions.Images, // Restrict to only images47 allowsMultipleSelection: false, // Can only select one image48 allowsEditing: true, // Allows the user to crop / rotate their photo before uploading it49 quality: 1,50 exif: false, // We don't want nor need that data.51 })5253 if (result.canceled || !result.assets || result.assets.length === 0) {54 console.log('User cancelled image picker.')55 return56 }5758 const image = result.assets[0]59 console.log('Got image', image)6061 if (!image.uri) {62 throw new Error('No image uri!') // Realistically, this should never happen, but just in case...63 }6465 const arraybuffer = await fetch(image.uri).then((res) => res.arrayBuffer())6667 const fileExt = image.uri?.split('.').pop()?.toLowerCase() ?? 'jpeg'68 const path = `${Date.now()}.${fileExt}`69 const { data, error: uploadError } = await supabase.storage70 .from('avatars')71 .upload(path, arraybuffer, {72 contentType: image.mimeType ?? 'image/jpeg',73 })7475 if (uploadError) {76 throw uploadError77 }7879 onUpload(data.path)80 } catch (error: any) {81 if (error) {82 Alert.alert(error.message)83 } else {84 throw error85 }86 } finally {87 setUploading(false)88 }89 }9091 return (92 <View style={styles.avatarContainer}>93 {avatarUrl ? (94 <Image95 source={{ uri: avatarUrl }}96 accessibilityLabel="Avatar"97 style={[avatarSize, styles.avatar, styles.image]}98 />99 ) : (100 <View style={[avatarSize, styles.avatar, styles.noImage]} />101 )}102 <View>103 <TouchableOpacity104 style={[styles.button, uploading && styles.buttonDisabled]}105 onPress={uploadAvatar}106 disabled={uploading}107 >108 <Text style={styles.buttonText}>{uploading ? 'Uploading ...' : 'Upload'}</Text>109 </TouchableOpacity>110 </View>111 </View>112 )113}添加新小部件 #
🌐 Add the new widget
然后把小部件添加到账户页面上:
🌐 And then add the widget to the Account page:
1import { useState, useEffect } from 'react'2import { supabase } from '../lib/supabase'3import { View, Alert, TextInput, Text, TouchableOpacity } from 'react-native'4import Avatar from './Avatar'5import { appStyles } from '../styles/styles'67export default function Account({ userId, email }: { userId: string; email?: string }) {8 const [loading, setLoading] = useState(true)9 const [username, setUsername] = useState('')10 const [website, setWebsite] = useState('')11 const [avatarUrl, setAvatarUrl] = useState('')12 const styles = appStyles1314 useEffect(() => {15 if (userId) getProfile()16 }, [userId])1718 async function getProfile() {19 try {20 setLoading(true)2122 let { data, error, status } = await supabase23 .from('profiles')24 .select(`username, website, avatar_url`)25 .eq('id', userId)26 .single()27 if (error && status !== 406) {28 throw error29 }3031 if (data) {32 setUsername(data.username)33 setWebsite(data.website)34 setAvatarUrl(data.avatar_url)35 }36 } catch (error) {37 if (error instanceof Error) {38 Alert.alert(error.message)39 }40 } finally {41 setLoading(false)42 }43 }4445 async function updateProfile({46 username,47 website,48 avatar_url,49 }: {50 username: string51 website: string52 avatar_url: string53 }) {54 try {55 setLoading(true)5657 const updates = {58 id: userId,59 username,60 website,61 avatar_url,62 updated_at: new Date(),63 }6465 let { error } = await supabase.from('profiles').upsert(updates)6667 if (error) {68 throw error69 }70 } catch (error: any) {71 Alert.alert(error.message)72 } finally {73 setLoading(false)74 }75 }7677 return (78 <View style={styles.container}>79 <View>80 <Avatar81 size={200}82 url={avatarUrl}83 onUpload={(url: string) => {84 setAvatarUrl(url)85 updateProfile({ username, website, avatar_url: url })86 }}87 />88 </View>89 <View style={[styles.verticallySpaced, styles.mt20]}>90 <Text style={styles.label}>Email</Text>91 <TextInput92 value={email ?? ''}93 editable={false}94 selectTextOnFocus={false}95 style={[styles.input, styles.inputDisabled]}96 />97 </View>98 <View style={styles.verticallySpaced}>99 <Text style={styles.label}>Username</Text>100 <TextInput101 value={username || ''}102 onChangeText={(text) => setUsername(text)}103 style={styles.input}104 />105 </View>106 <View style={styles.verticallySpaced}>107 <Text style={styles.label}>Website</Text>108 <TextInput109 value={website || ''}110 onChangeText={(text) => setWebsite(text)}111 style={styles.input}112 />113 </View>114115 <View style={[styles.verticallySpaced, styles.mt20]}>116 <TouchableOpacity117 style={[styles.button, loading && styles.buttonDisabled]}118 onPress={() => updateProfile({ username, website, avatar_url: avatarUrl })}119 disabled={loading}120 >121 <Text style={styles.buttonText}>{loading ? 'Loading ...' : 'Update'}</Text>122 </TouchableOpacity>123 </View>124125 <View style={styles.verticallySpaced}>126 <TouchableOpacity style={styles.button} onPress={() => supabase.auth.signOut()}>127 <Text style={styles.buttonText}>Sign Out</Text>128 </TouchableOpacity>129 </View>130 </View>131 )132}现在运行预构建命令,让应用在你选择的平台上运行起来。
🌐 Now run the prebuild command to get the application working on your chosen platform.
1npx expo prebuild在这个阶段,你已经有了一个完全可用的应用!
🌐 At this stage you have a fully functional application!