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 SecurityRLS可以在数据库层面控制用户只能读自己的数据。这一点在移动端特别关键因为你给客户端发的 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-applatest 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-jsSupabase 的官方 JavaScript 客户端它的 Auth、数据库查询、Realtime 全在这里面。react-native-async-storage/async-storage用于持久化 Supabase 的登录会话这样 App 杀掉重开以后用户不用重新登录。react-native-url-polyfillReact 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 参数解析这些逻辑依赖浏览器标准的URL和URLSearchParams。React Native 的 JavaScript 引擎并不完整实现这些 API尤其是 Android 上很多 polyfill 不会自动生效。解决办法很简单在创建 Supabase 客户端之前先引入import react-native-url-polyfill/auto注意是“在创建客户端之前”最好放在lib/supabase.ts的第一行。我见过有人把 polyfill 放在入口文件里但 Supabase 客户端在别的模块里先被加载了依然会报错。这个 import 顺序问题用语言很难查出来因为报错信息往往五花八门比如Invalid URL、Network request failed、URL 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 createClientDatabase(supabaseUrl, supabaseAnonKey, { auth: { storage: AsyncStorage, autoRefreshToken: true, persistSession: true, detectSessionInUrl: false, }, })这里有一个关键参数detectSessionInUrl: false。Supabase 这个参数的默认行为是在 URL 里检测 session比如 Web 端通过链接携带 access_token 完成认证。但在 React Native 里没有浏览器地址栏这个概念如果不关闭客户端可能去监听一个不存在的 URL导致登录回调处理失败。移动端统一改成false是标准做法。persistSession和autoRefreshToken保持true意思分别是“把 session 存到本地”和“token 快过期时自动刷新”。这两个不开的话用户每次冷启动都可能要重新登录。3.3 环境变量怎么配才安全在项目根目录创建.env文件EXPO_PUBLIC_SUPABASE_URLhttps://your-project-ref.supabase.co EXPO_PUBLIC_SUPABASE_ANON_KEYyour-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 adaptertoken 会存进系统的 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.useStateSession | 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.tsximport { 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.tsximport { 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 nameindex options{{ title: 首页 }} / Tabs.Screen nameprofile options{{ title: 我的 }} / /Tabs ) }loading状态非常重要。它存在的意义是防止误判App 启动时session还没从 AsyncStorage 里读出来此时如果直接判断!session会把一个明明登录过的用户弹到登录页造成“闪一下登录页”的糟糕体验。先返回null等逻辑执行完再决定往哪走。5.3 新版 expo-router 的 Stack.Protected 简化写法如果你的 expo-router 版本在 3.x 以上还有一个更简洁的 APIStack.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 namesign-in / Stack.Screen namesign-up / /Stack.Protected /Stack ) }guard为false时对应路由会从导航器里隐藏。这种做法代码量更少但我需要提醒一句这个 API 比较新如果你的项目是旧版或做了深度自定义导航建议先确认版本再决定用不用。上面分组布局的写法是万金油任何版本都能跑也更容易排查问题。5.4 关于重定向的一次常见时序问题这里分享一个我实际遇到过的经典问题用户点登录按钮以后页面没有跳转但控制台也没报错。排查思路是这样的先确认signInWithPassword()是否真的成功了。可以把返回的session打出来看。再确认登录后(tabs)布局里的session是否更新了。如果 AuthProvider 没包对或者useAuth()在守卫里拿到的是旧的 Context就会卡住。最后确认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.session是null用户必须点邮件里的确认链接以后才能登录。开发阶段我通常先关掉确认省得每次注册都要去邮箱点链接准备提测或上线前再打开。注意改了这个开关只影响之后新注册的用户已经注册过的用户状态不会变。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.push或router.replace。顺带一提登出以后最好把走的页面栈也重置一下。Expo Router 的Redirect /在分组布局里通常不会保留旧的页面栈至少在 Tab 页面内是安全的。如果你从某个业务子页面一路进到很深的详情页登出时在页面里额外加一句router.dismissAll()会更稳妥。6.4 密码重置流程的跳转细节密码重置是这样一条链路用户输入邮箱调用resetPasswordForEmail。Supabase 给邮箱发一封带链接的邮件。用户点击链接跳转到你配置的重定向地址。用户在这个地址对应的页面里输入新密码。调用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] useStateNote[]([]) 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 RealtimeuseEffect(() { if (!session) return const channel supabase .channel(notes-changes) .on( postgres_changes, { event: INSERT, schema: public, table: notes, filter: user_ideq.${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 createClientDatabase(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_URL是undefined依次检查.env文件在项目根目录不在src里。变量名有没有EXPO_PUBLIC_前缀。修改.env后有没有停掉开发服务器重跑Expo 不会热更新环境变量。还有一个很多人不知道的点EXPO_PUBLIC_前缀的变量会直接被打包进 JS bundle所以控制台或者网络请求里能看到是正常的。绝对不要把service_role这种不该暴露的 key 放在EXPO_PUBLIC_里。9. 我实际踩过的几个坑按出现频率排序9.1 登录成功后一闪又回到登录页有一次我登录成功以后页面先闪到首页然后立刻又被弹回登录页。查了很久才发现是 AuthProvider 的挂载顺序问题。我的根布局刚开始是这样的Stack在AuthProvider外面。也就是说导航器先渲染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。以后遇到“查询结果异常为空”排查顺序应该是先去 Supabase Dashboard 的 Table Editor 看数据是否存在。检查表是否开启 RLS。检查是否建了对应的 policy。再回头检查查询条件。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 的缓存大多数时候会自动更新但偶尔会抽风一个干净的启动能帮你排除掉大量“看起来是代码问题”的干扰。