news 2026/9/9 0:06:44

Expo Router 与 Supabase 集成实战:认证、路由守卫与数据安全

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Expo Router 与 Supabase 集成实战:认证、路由守卫与数据安全

1. 为什么我会把 Expo Router 和 Supabase 组合在一起

1.1 先理清两个东西各自是什么

先说结论:Expo Router 解决的是“移动端页面怎么组织、怎么跳转、怎么处理深链”,Supabase 解决的是“用户系统、数据库、实时订阅、文件存储这些后端能力”。这两个东西单独拎出来都不算新鲜,但放在一起以后,整个项目的前端路由和后端权限能形成一套非常顺的闭环。

如果你之前用过 React Navigation,应该对那种手动注册 Stack、Tab、Drawer 的路由写法有印象。Expo Router 是 Expo 官方主推的文件路由方案,玩法跟 Next.js 很像:你往app/目录里放一个文件,它就自动变成一条路由。目录嵌套、动态路由、布局嵌套、深链映射全是文件系统说了算。对独立开发者来说,最直观的好处是少写大量样板代码,项目结构也一眼能看懂。

Supabase 这边,本质是一个托管 Postgres 数据库加一堆现成服务。其中我们日常用得最多的是 Auth 和 Database。Auth 帮你把注册、登录、邮箱验证、第三方 OAuth、密码重置全部接好;Database 就是 Postgres,支持 Row Level Security(RLS),可以在数据库层面控制用户只能读自己的数据。这一点在移动端特别关键,因为你给客户端发的 anon key 本质上是公开的,真正的安全边界必须设在数据库里,而不是靠前端代码隐藏。

1.2 它们组合起来最舒服的地方

我第一次把这两个东西拼起来的时候,最直观的感受是:路由守卫和登录态居然可以绑得这么直接。

Expo Router 的路由是文件驱动的,所以你能在布局文件里做“登录墙”。比如app/(tabs)/_layout.tsx这个文件就是整个 Tab 区域的父布局,我在里面读一下当前用户 session,没登录就直接<Redirect href="/sign-in" />。所有需要登录才能访问的页面,全部塞进这个目录组里,不用在每个页面手动判断一次登录态。

Supabase 的 Auth 又恰好提供了一个全局的onAuthStateChange事件。登录、登出、token 刷新都会触发它。把这两个机制接起来以后,业务代码里几乎不用关心用户状态是怎么变更的。用户登录成功,路由自动跳进去;用户 token 失效,页面自动被弹回登录页。这种体验不是靠堆页面判断实现的,而是从一开始就由路由结构决定的。

1.3 适合谁,不适合谁

这个组合最典型的适用场景是:你需要快速做一个带账号体系的 MVP,或者你是个人开发、小团队,没有专门后端人力。我用它做过一个内部工具 App,从零到能提交测试,大概一个周末就差不多了,后端那套注册登录、数据库建表、权限策略全在 Supabase Dashboard 上搞定。

但如果你要做的是强实时、强离线、复杂事务型应用,或者你们的后端已经有完整 API 体系,那 Supabase 未必是最优选。它不是不能做复杂场景,而是你要额外支付学习成本去理解 RLS 策略、实时订阅、边缘函数这些概念。说白了,Expo Router + Supabase 是把“常见业务后端需求”压缩成了配置文件,但它不会替你设计数据模型和业务边界。

2. 初始化项目:从 create-expo-app 到能启动的最小骨架

2.1 脚手架命令

我习惯直接用官方脚手架,带 TypeScript 和 Expo Router 模板,省得手动搭:

npx create-expo-app@latest expo-supabase-demo --template tabs cd expo-supabase-demo

这里--template tabs会生成一个带 Tab 导航的 Expo Router 项目。如果你用的是默认模板,大概率也已经内置 Expo Router 了,只是目录结构不一定有(tabs)分组。脚手架装完以后,先跑一次:

npx expo start

确认模拟器或 Expo Go 里能打开默认页面,再往后加东西。不要一上来就把 Supabase 的代码堆进去,否则出了问题你根本分不清是路由问题还是后端问题。

2.2 依赖安装和版本注意事项

需要安装的依赖并不多:

npx expo install @supabase/supabase-js @react-native-async-storage/async-storage react-native-url-polyfill

解释一下为什么是这三个:

  • @supabase/supabase-js:Supabase 的官方 JavaScript 客户端,它的 Auth、数据库查询、Realtime 全在这里面。
  • @react-native-async-storage/async-storage:用于持久化 Supabase 的登录会话,这样 App 杀掉重开以后用户不用重新登录。
  • react-native-url-polyfill:React Native 环境里 URL 实现不完整,Supabase 客户端在解析 Auth 回调地址时依赖标准 URL API,不装这个包会出现类似URL is not defined或者Cannot read property 'search' of undefined的报错。

我遇到过不少人在这一步直接npm install @supabase/supabase-js就开始了,结果跑到登录回调时各种莫名奇妙的问题。这里建议用npx expo install而不是npm install,因为 Expo 会帮你匹配当前 SDK 下兼容的依赖版本,尤其是 AsyncStorage 这类原生模块,版本不对是跑不起来的。

2.3 先建一个能跑的目录结构

装完之后,我给项目规划了这样一套目录结构:

app/ _layout.tsx (auth)/ _layout.tsx sign-in.tsx sign-up.tsx (tabs)/ _layout.tsx index.tsx profile.tsx lib/ supabase.ts auth.tsx database.types.ts

括号目录名(auth)(tabs)是 Expo Router 的分组语法,它不会出现在 URL 路径里,只用于组织布局和路由层级。比如app/(tabs)/index.tsx对应的实际路径是/app/(auth)/sign-in.tsx对应的实际路径是/sign-in

这种结构的意义在于:业务页面和认证页面从物理路径上就被分开了,后面做登录墙只需要在(tabs)(auth)两个分组各自的_layout.tsx里写判断逻辑,不需要在几十个页面里重复判断用户状态。

3. 初始化 Supabase 客户端:最大的坑全在这里

3.1 为什么必须引入 URL Polyfill

这一步是我见过翻车率最高的地方。

Supabase 客户端内部要处理 OAuth 回调、密码重置链接、URL 参数解析,这些逻辑依赖浏览器标准的URLURLSearchParams。React Native 的 JavaScript 引擎并不完整实现这些 API,尤其是 Android 上,很多 polyfill 不会自动生效。

解决办法很简单,在创建 Supabase 客户端之前先引入:

import 'react-native-url-polyfill/auto'

注意是“在创建客户端之前”,最好放在lib/supabase.ts的第一行。我见过有人把 polyfill 放在入口文件里,但 Supabase 客户端在别的模块里先被加载了,依然会报错。这个 import 顺序问题用语言很难查出来,因为报错信息往往五花八门,比如Invalid URLNetwork request failedURL is undefined,很容易被误判成网络问题。

3.2 客户端实例的正确配置

lib/supabase.ts我一般这么写:

import 'react-native-url-polyfill/auto' import AsyncStorage from '@react-native-async-storage/async-storage' import { createClient } from '@supabase/supabase-js' import type { Database } from './database.types' const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL! const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY! export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, { auth: { storage: AsyncStorage, autoRefreshToken: true, persistSession: true, detectSessionInUrl: false, }, })

这里有一个关键参数:detectSessionInUrl: false

Supabase 这个参数的默认行为是在 URL 里检测 session,比如 Web 端通过链接携带 access_token 完成认证。但在 React Native 里,没有浏览器地址栏这个概念,如果不关闭,客户端可能去监听一个不存在的 URL,导致登录回调处理失败。移动端统一改成false是标准做法。

persistSessionautoRefreshToken保持true,意思分别是“把 session 存到本地”和“token 快过期时自动刷新”。这两个不开的话,用户每次冷启动都可能要重新登录。

3.3 环境变量怎么配才安全

在项目根目录创建.env文件:

EXPO_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

从 Supabase Dashboard 的 Project Settings -> API 里能复制这两个值。

注意EXPO_PUBLIC_前缀是硬性要求。Expo 的编译机制只把带这个前缀的变量注入到客户端代码里。如果你写成SUPABASE_URL,代码里process.env.SUPABASE_URL编译出来会是undefined

另一个事实需要摆清楚:anon key本身不算秘密,它是要发到客户端的,任何人反编译 App 都能拿到。所以不要指望没人看见这个 key,真正的安全防线是后文要讲的 RLS。这也意味着.env里不要放service_rolekey,那个是全权限总管理 key,泄露等于数据库裸奔。

3.4 两种存储后端:AsyncStorage 与 SecureStore

官方示例默认用 AsyncStorage,用来存 session token。优点是零配置,跨平台稳定,缺点是它是明文存储,安全性一般。

如果做生产级应用,我更建议用expo-secure-store包装一个 storage adapter,token 会存进系统的 Keychain / Keystore,相对更难被窃取:

import * as SecureStore from 'expo-secure-store' const SecureStorage = { getItem: (key: string) => SecureStore.getItemAsync(key), setItem: (key: string, value: string) => SecureStore.setItemAsync(key, value), removeItem: (key: string) => SecureStore.deleteItemAsync(key), }

然后把storage换成SecureStorage。要注意 SecureStore 在 Web 端不可用,如果你的项目同时要发 Web 端,需要判断平台选择存储实现。对纯移动端项目,我推荐直接上 SecureStore,毕竟 auth token 属于敏感信息,能多点防护就多点防护。

4. 认证状态管理:全局 Provider 是必备胶水

4.1 不封装 Context 会有什么后果

你可以直接在页面里调用supabase.auth.getSession(),这样获取登录态确实能用,但不建议。原因有两个。

第一,页面各自获取 session,登录态更新后不会自动通知所有组件。比如用户在“我的”页面改完头像,首页的展示却还是旧头像,你还得手动写事件总线或者反复刷新。

第二,路由守卫需要读登录态。如果你在布局文件里手动调getSession(),每次路由变化都要额外发起一次异步操作,不仅慢,而且容易在 loading 态里出现白屏闪烁。

正确做法是把 session 放进 React Context,让整个应用树都能响应登录态变化。

4.2 AuthProvider 代码拆解

lib/auth.tsx我一般这么写:

import { Session } from '@supabase/supabase-js' import * as React from 'react' import { supabase } from './supabase' const AuthContext = React.createContext<{ session: Session | null loading: boolean }>({ session: null, loading: true, }) export function AuthProvider({ children }: { children: React.ReactNode }) { const [session, setSession] = React.useState<Session | null>(null) const [loading, setLoading] = React.useState(true) React.useEffect(() => { supabase.auth.getSession().then(({ data: { session } }) => { setSession(session) setLoading(false) }) const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, newSession) => { setSession(newSession) setLoading(false) }) return () => subscription.unsubscribe() }, []) return ( <AuthContext.Provider value={{ session, loading }}> {children} </AuthContext.Provider> ) } export const useAuth = () => React.useContext(AuthContext)

然后把根布局包一层:

import { Stack } from 'expo-router' import { AuthProvider } from '@/lib/auth' export default function RootLayout() { return ( <AuthProvider> <Stack screenOptions={{ headerShown: false }} /> </AuthProvider> ) }

4.3 为什么 getSession 和 onAuthStateChange 要一起用

这里是我看到很多人会漏掉的细节。

getSession()是“启动时先查一次本地已有的 session”,它是被动读取,适合 App 冷启动恢复登录态。但只有它不够,因为如果 Supabase 检测到 token 刷新了,或者另一个地方发生了登录、登出,前端不会自动收到通知。

onAuthStateChange()是“注册一个长期生效的监听器”,事件发生时主动推送新 session。这两个必须配合:一个负责初始化,一个负责后续更新。

有一点要注意:onAuthStateChange的回调里不要再去调用supabase.auth.getSession()或者supabase.auth.setSession(),否则事件回环可能造成重复请求甚至死循环。回调里只做setSession就足够了。

5. 路由守卫:用 Expo Router 做登录墙

5.1 目录分组:把业务路由和认证路由物理隔离

我在第二节规划的(auth)(tabs)两个分组,到这一步开始体现作用。

(auth)组里放登录、注册、找回密码这类公开页面;(tabs)组里放所有需要登录才能访问的业务页面。两个分组各自有独立的_layout.tsx,所以守卫逻辑非常集中。

你可能会问:为什么不直接在app/_layout.tsx里判断一次,然后决定渲染哪组?

理论上可以,但 Expo Router 的路由注册是文件系统决定的,根布局里做条件渲染有时会导致路由表不稳定,尤其是动态切换时容易出现 “Screen is not registered” 这类问题。我的实践是用分组布局做守卫,每个分组只负责判断自己管辖的路由,逻辑更干净。

5.2(auth)(tabs)两组布局的守卫代码

先看app/(auth)/_layout.tsx

import { Redirect, Stack } from 'expo-router' import { useAuth } from '@/lib/auth' export default function AuthLayout() { const { session, loading } = useAuth() if (loading) return null if (session) return <Redirect href="/" /> return <Stack screenOptions={{ headerShown: false }} /> }

逻辑很直白:已经登录的用户如果手动访问/sign-in,直接丢回首页。这在开发时尤其有用,否则你登录完以后退出去想再看一眼登录页,会被迫执行一次登出。

再看app/(tabs)/_layout.tsx

import { Redirect, Tabs } from 'expo-router' import { useAuth } from '@/lib/auth' export default function TabsLayout() { const { session, loading } = useAuth() if (loading) return null if (!session) return <Redirect href="/sign-in" /> return ( <Tabs screenOptions={{ headerShown: false }}> <Tabs.Screen name="index" options={{ title: '首页' }} /> <Tabs.Screen name="profile" options={{ title: '我的' }} /> </Tabs> ) }

loading状态非常重要。它存在的意义是防止误判:App 启动时session还没从 AsyncStorage 里读出来,此时如果直接判断!session,会把一个明明登录过的用户弹到登录页,造成“闪一下登录页”的糟糕体验。先返回null等逻辑执行完,再决定往哪走。

5.3 新版 expo-router 的 Stack.Protected 简化写法

如果你的 expo-router 版本在 3.x 以上,还有一个更简洁的 API:Stack.Protected。可以在根布局一次性声明哪些页面受保护:

import { Stack } from 'expo-router' import { useAuth } from '@/lib/auth' function RootNavigator() { const { session, loading } = useAuth() if (loading) return null return ( <Stack screenOptions={{ headerShown: false }}> <Stack.Protected guard={!!session}> <Stack.Screen name="(tabs)" /> </Stack.Protected> <Stack.Protected guard={!session}> <Stack.Screen name="sign-in" /> <Stack.Screen name="sign-up" /> </Stack.Protected> </Stack> ) }

guardfalse时,对应路由会从导航器里隐藏。这种做法代码量更少,但我需要提醒一句:这个 API 比较新,如果你的项目是旧版或做了深度自定义导航,建议先确认版本再决定用不用。上面分组布局的写法是万金油,任何版本都能跑,也更容易排查问题。

5.4 关于重定向的一次常见时序问题

这里分享一个我实际遇到过的经典问题:用户点登录按钮以后,页面没有跳转,但控制台也没报错。

排查思路是这样的:

  1. 先确认signInWithPassword()是否真的成功了。可以把返回的session打出来看。
  2. 再确认登录后(tabs)布局里的session是否更新了。如果 AuthProvider 没包对,或者useAuth()在守卫里拿到的是旧的 Context,就会卡住。
  3. 最后确认<Redirect href="/sign-in" />的路径写没写对。

大多数情况下,问题出在 AuthProvider 的层级和onAuthStateChange的触发时机。登录成功以后,setSession是异步更新状态,路由守卫会在下一次渲染时才看到新 session。你不需要在登录按钮里手动router.replace('/'),因为状态一变,布局守卫会自动把路由切过去。手动跳一次反而可能造成重复导航。

6. 注册、登录、登出:从表单到路由跳转的完整闭环

6.1 注册:邮箱确认没开/开了的区别

注册页的代码不复杂:

import { useState } from 'react' import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native' import { supabase } from '@/lib/supabase' async function onSignUp(email: string, password: string) { const { data, error } = await supabase.auth.signUp({ email, password, }) if (error) { Alert.alert('注册失败', error.message) return } if (data.session) { // 邮箱确认关闭时,signUp 会直接返回 session return } // 邮箱确认开启时,这里会走到,需要提示用户去查收邮件 Alert.alert('注册成功', '请前往邮箱点击确认链接') }

这里真正要注意的是data.session的判断。在 Supabase Dashboard 的 Auth -> Providers -> Email 里,有一个 “Confirm email” 开关。

  • 关闭时:signUp会直接给你一个 session,用户相当于注册即登录。
  • 开启时:signUp返回的data.sessionnull,用户必须点邮件里的确认链接以后才能登录。

开发阶段我通常先关掉确认,省得每次注册都要去邮箱点链接;准备提测或上线前再打开。注意改了这个开关,只影响之后新注册的用户,已经注册过的用户状态不会变。

6.2 登录:错误提示要处理而不是抛给用户

登录代码:

async function onSignIn(email: string, password: string) { const { error } = await supabase.auth.signInWithPassword({ email, password, }) if (error) { Alert.alert('登录失败', error.message) } }

Supabase 返回的错误信息里,像 “Invalid login credentials” 这种,直接展示给用户其实不太友好。我会做一个简单的映射:

  • Invalid login credentials-> “邮箱或密码错误”
  • Email not confirmed-> “邮箱未验证,请先查收确认邮件”
  • User not found-> 同样归入“邮箱或密码错误”,避免暴露账号是否存在

这种细节看起来很琐碎,但移动端用户对错误弹窗的容忍度很低,一个莫名其妙的英文报错很容易让用户直接卸载。

6.3 登出:布局守卫自动接管

登出更简单:

await supabase.auth.signOut()

因为我们在(tabs)布局里写了if (!session) return <Redirect href="/sign-in" />,所以signOut()一执行,onAuthStateChange会触发,session 变成null,路由会自动跳回登录页。你不需要写任何router.pushrouter.replace

顺带一提,登出以后最好把走的页面栈也重置一下。Expo Router 的<Redirect />在分组布局里通常不会保留旧的页面栈,至少在 Tab 页面内是安全的。如果你从某个业务子页面一路进到很深的详情页,登出时在页面里额外加一句router.dismissAll()会更稳妥。

6.4 密码重置流程的跳转细节

密码重置是这样一条链路:

  1. 用户输入邮箱,调用resetPasswordForEmail
  2. Supabase 给邮箱发一封带链接的邮件。
  3. 用户点击链接,跳转到你配置的重定向地址。
  4. 用户在这个地址对应的页面里输入新密码。
  5. 调用updateUser({ password: newPassword })完成修改。

关键在于第 3 步的深链配置。比如我在app.json里配置了"scheme": "myapp",那 Supabase Dashboard 的 Auth -> URL Configuration -> Redirect URLs 里就要加:

myapp://reset-password

然后项目里创建一个app/reset-password.tsx页面,去处理updateUser。这个页面要能接收到邮件链接带过来的参数,Expo Router 会自动把myapp://reset-password映射到同名路由,你只需要在页面里读一下 URL 参数,判断有没有 token 和 type。

7. 页面数据流:在业务页里读写 Supabase 并让 RLS 生效

7.1 用 useAuth 拿到 session,再拿 supabase 客户端

认证闭环跑通以后,业务页面里的标准写法是这样的:

import { useEffect, useState } from 'react' import { Alert, FlatList, Text, View } from 'react-native' import { useAuth } from '@/lib/auth' import { supabase } from '@/lib/supabase' type Note = { id: string title: string body: string | null created_at: string } export default function HomeScreen() { const { session } = useAuth() const [notes, setNotes] = useState<Note[]>([]) useEffect(() => { if (!session) return loadNotes() }, [session]) async function loadNotes() { const { data, error } = await supabase .from('notes') .select('*') .order('created_at', { ascending: false }) if (error) { Alert.alert('加载失败', error.message) return } setNotes(data ?? []) } return ( <FlatList data={notes} keyExtractor={(item) => item.id} renderItem={({ item }) => ( <View> <Text>{item.title}</Text> <Text>{item.body}</Text> </View> )} /> ) }

这里有个认知要纠正一下:前端select('*')看起来会查出所有数据,但如果 RLS 策略正确,Supabase 在数据库层已经帮你过滤了,返回的只能是当前用户自己的数据。

7.2 查询列表:不写 user_id 过滤等于白查

虽然 RLS 会兜底,我还是建议在查询条件里显式加上用户维度:

supabase .from('notes') .select('*') .eq('user_id', session.user.id) .order('created_at', { ascending: false })

原因有两点。第一,代码可读性更好,看的人能立刻知道这是按用户隔离的数据。第二,如果你的 RLS 策略因为某些原因写错了,比如忘记建策略或策略条件有误,返回的将是空数组或全部数据。显式加过滤条件至少能提前暴露一部分问题。

我之前排查过一个线上问题:某个用户看到了另一个用户的数据,根因就是建表时没开 RLS,而查询代码里也没写.eq('user_id', ...)。Supabase 的 anon key 是公开的,这种情况等于任何人拿到你的 URL 就能遍历整个表。

7.3 写入数据:user_id 从 session 取,不要信前端传值

插入数据时,user_id要从 session 里取:

await supabase.from('notes').insert({ title: '学习 Expo Router', body: '今天把路由守卫搞明白了', user_id: session.user.id, })

有些开发者习惯在表单里放一个隐藏的user_id字段,让用户请求时“带着自己的身份”提交。这在移动端绝不推荐,因为客户端请求可以被篡改。正确的依赖链是:auth.uid()由 Supabase Auth 根据客户端 token 解析出来,RLS 策略里的auth.uid()与前端 session 里的user.id必须一致,否则插入会被拒绝。

所以我建表时会在 SQL 层再加一道保险:

create table public.notes ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) default auth.uid(), title text not null, body text, created_at timestamptz not null default now() ); alter table public.notes enable row level security; create policy "用户可以管理自己的笔记" on public.notes for all using (auth.uid() = user_id) with check (auth.uid() = user_id);

default auth.uid()意味着即使前端漏传了user_id,数据库也会自动填上当前登录用户。using控制读已有行,with check控制插入和更新的行。这样组合起来,哪怕是构造请求,也无法插入user_id属于别人的行。

7.4 Realtime 订阅与清理

如果某个页面需要实时更新,比如聊天列表、通知列表,可以直接用 Supabase Realtime:

useEffect(() => { if (!session) return const channel = supabase .channel('notes-changes') .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'notes', filter: `user_id=eq.${session.user.id}`, }, (payload) => { const newNote = payload.new as Note setNotes((prev) => [newNote, ...prev]) } ) .subscribe() return () => { supabase.removeChannel(channel) } }, [session])

这里有个容易忽略的坑:Realtime 不会自动启用。你需要在 Supabase Dashboard 里打开对应表的 Realtime 开关,或者用alter publication supabase_realtime add table public.notes;这条 SQL 手动添加。

另外订阅一定要在组件卸载时移除,否则页面来回切换很容易堆积无效连接,跑久了 App 会越来越卡。

8. 上线前容易漏掉的清单:深链、类型生成、运行环境

8.1 邮箱确认链接回跳 App 的深链配置

如果你的应用要上架,邮箱验证、密码重置这类流程必须能回跳 App,否则用户只能切到浏览器里折腾一圈。

Expo Router 对深链的支持比较友好,你只需要在app.json里配好 scheme:

{ "expo": { "scheme": "myapp" } }

然后在 Supabase Dashboard 的重定向地址里加入:

  • myapp://(通用回跳)
  • myapp://reset-password(密码重置专用)

第一次真机测试深链时,我犯过一个错误:只改了app.json,没有重启开发服务器。Expo 的 scheme 配置变更必须重启npx expo start,最好再清一下缓存:

npx expo start -c

否则你会点开邮件里的链接,发现 App 确实被唤起了,但路由没有落到对应页面。

8.2 类型安全:让 TS 校验表和行结构

Supabase 自带一个类型生成工具,能从数据库 schema 直接生成 TypeScript 类型:

npx supabase gen types typescript --project-id your-project-ref --schema public > lib/database.types.ts

然后在createClient里传入类型:

import type { Database } from './database.types' export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, { // ... })

这能把查询和插入的表名校验、字段名补全、返回类型推导全部做起来。比如我写.from('note')漏了s,编辑器立刻标红;insert时漏了必填字段,也会有提示。对不熟悉 Postgres 的开发者来说,这个类型文件相当于一份可查询的数据库文档。

8.3 Android 模拟器/真机和 Expo Go 的网络差异

这里整理一个经验:

  • Android 模拟器访问宿主机服务,要用10.0.2.2而不是localhost
  • 真机通过 Expo Go 调试时,Supabase URL 用线上云实例没有网络问题;如果是自建 Supabase,必须保证手机和服务器在同一个局域网,或者后端有公网地址。
  • iOS 的 ATS 默认会拦截 HTTP 明文请求,开发时如果自建服务,需要临时调整 Info.plist,否则请求会无声失败。

如果你一直用 Expo Go 开发,建议提交测试前至少用 development build 跑一遍。Expo Go 提供的原生运行时和真正的原生工程还是有差异,尤其是深链、SecureStore、复杂网络配置这些场景,提前验证能省掉不少发布期的手忙脚乱。

8.4 环境变量失效时先检查 EXPO_PUBLIC_ 前缀

这是个小但高频的问题。改完.env之后,如果你发现process.env.EXPO_PUBLIC_SUPABASE_URLundefined,依次检查:

  1. .env文件在项目根目录,不在src里。
  2. 变量名有没有EXPO_PUBLIC_前缀。
  3. 修改.env后有没有停掉开发服务器重跑,Expo 不会热更新环境变量。

还有一个很多人不知道的点:EXPO_PUBLIC_前缀的变量会直接被打包进 JS bundle,所以控制台或者网络请求里能看到是正常的。绝对不要把service_role这种不该暴露的 key 放在EXPO_PUBLIC_里。

9. 我实际踩过的几个坑(按出现频率排序)

9.1 登录成功后一闪又回到登录页

有一次我登录成功以后,页面先闪到首页,然后立刻又被弹回登录页。查了很久才发现是 AuthProvider 的挂载顺序问题。

我的根布局刚开始是这样的:StackAuthProvider外面。也就是说,导航器先渲染,Auth 状态还没有注入进去,路由守卫读到的是初始的session: null,判定为未登录,于是跳到/sign-in。等 AuthProvider 真正挂载、session 恢复以后,又触发一次重定向,就出现了“跳过去又弹回来”的闪烁。

解决办法就是严格把 AuthProvider 放在导航器外层,并且在所有用到useAuth()的守卫布局里,先判断loading再决定渲染什么。

9.2 Select 返回空数组,但数据库里明明有数据

这是 RLS 最常见的“无声失败”场景。客户端查出来的结果是空数组,没有报错,数据库里数据也确实存在。我一开始还以为是查询条件写错,排查到最后发现是表开了 RLS 但没建任何 policy。

Supabase 的规则是:表一旦开启 RLS,如果没有任何 policy,所有客户端查询都会被拒绝。这个拒绝不会以 SQL 异常形式返回,select 就是静默返回空,insert 和 update 才容易报 permission denied。

以后遇到“查询结果异常为空”,排查顺序应该是:

  1. 先去 Supabase Dashboard 的 Table Editor 看数据是否存在。
  2. 检查表是否开启 RLS。
  3. 检查是否建了对应的 policy。
  4. 再回头检查查询条件。

9.3 onAuthStateChange 重复触发导致死循环

有段时间我的 App 在登录后频繁发网络请求,看日志发现onAuthStateChange被触发了无数次。原因是我在回调里多写了一行supabase.auth.setSession,想“手动同步一下 session”。

其实setSession本身会触发一次认证状态变更,于是回调又被触发,回调里又调用setSession,就形成了死循环。这跟 React 里“在 render 里写 setState”是同一个道理。

回调里只应该做两件事:把新 session 同步到状态,以及根据业务需求做页面跳转或弹窗。不要在这个回调里再调用任何会改变认证状态的方法。

9.4 升级 expo 版本后路由全 404

从旧版升级到新版 Expo SDK 之后,App 能启动,但所有路由都 404,页面白屏。这种问题通常不是业务代码坏了,而是路由缓存或者入口配置问题。

我按顺序做了这几步就好了:

npx expo start -c

还不行的话,检查package.json里的main字段是不是:

"main": "expo-router/entry"

如果项目是从 React Navigation 老项目迁移过来的,这一步最容易漏。忘了设入口的话,Expo 找不到路由表,自然全部 404。

最后分享一个我个人很受用的习惯:每次改完app/目录里的文件结构,尤其是新增或移动路由文件的场景,我都会顺手执行一次npx expo start -c。Expo Router 的缓存大多数时候会自动更新,但偶尔会抽风,一个干净的启动能帮你排除掉大量“看起来是代码问题”的干扰。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 23:58:52

素材备份与换机恢复:一套本地优先的素材库备份方案复盘

素材备份与换机恢复&#xff1a;一套本地优先的素材库备份方案复盘 旧电脑交出去的前一晚&#xff0c;你打开素材库想最后检查一遍&#xff0c;才突然意识到&#xff1a;明天新机器到手&#xff0c;这三千多条素材的目录结构、几百个手打标签、按项目整理的智能集合&#xff0c…

作者头像 李华
网站建设 2026/9/8 23:58:21

2026最新降AI率工具实测:10款主流降AI软件优缺点盘点与避坑指南

看着满屏标红的修改提示&#xff0c;交付日期一天天逼近&#xff0c;你是不是也急得焦头乱额&#xff1f;这种焦虑我太懂了。因为常年跟各类文本优化工具打交道&#xff0c;我寻找过aigc免费降重的捷径。结果下场后踩了不少坑&#xff1a;不仅白白耗费精力&#xff0c;有些工具…

作者头像 李华
网站建设 2026/9/8 23:57:11

res-downloader 入门教程:网络资源嗅探,三步完成无水印视频下载

res-downloader 入门教程&#xff1a;网络资源嗅探&#xff0c;三步完成无水印视频下载 【免费下载链接】res-downloader 视频号、小程序、抖音、快手、小红书、直播流、m3u8、酷狗、QQ音乐等常见网络资源下载! 项目地址: https://gitcode.com/GitHub_Trending/re/res-downlo…

作者头像 李华
网站建设 2026/9/8 23:56:02

C++高频交易系统:无锁队列与低延迟架构实战拆解

简介&#xff1a;C高频交易源码包面向中高级C开发者和量化交易爱好者&#xff0c;旨在展示一套接近实战的高频交易系统骨架&#xff0c;覆盖算法交易、低延迟通信、实时行情处理、订单生成与风控等核心主题。压缩包内共16个文件&#xff0c;包含5个cpp源文件用于实现主流程与业…

作者头像 李华