Expo 最佳实践与陷阱
把全手册散落的"顺带一提"收拢成一张网。这一章假设你已经知道每个功能怎么用(各功能章),这里只回答两个问题:哪里会出事,以及生产项目应该养成什么习惯。所有条目截至 Expo SDK 58 / 2026-10-20,来源为官方 changelog 正文与文档实测。
陷阱汇总表
环境与 Expo Go(详见 00 / 05 章)
| 陷阱 | 后果 | 正确做法 |
|---|---|---|
| 把 Expo Go 当生产开发环境 | 未内置的原生库、图标/splash/App Links、远程推送都不在能力范围内 | 官方已多次明说 Expo Go 是教学工具,起步后即迁 development build |
以为 npx create-expo-app 装的是最新 SDK |
2026-10-20 时点商店 Expo Go 58 未上架,默认创建的是 SDK 57 项目 | 要 SDK 58 用 npx create-expo-app --template default@sdk-58,并配 dev build(见 00 章) |
| 升级商店 Expo Go 后连不上旧项目 | 新版 Expo Go 只支持单一 SDK | 项目 SDK 与 Expo Go 版本对齐;过渡期用模拟器版或 eas go 自建(见 05 章) |
| Expo Go 扫 QR 无反应 | 2026-09 起强制登录:终端与 App 需同一账号 | npx expo login + App 内登录;模拟器版与 dev build 不受影响(见 05 章) |
| 用 Expo Go 加载他人 EAS Update / 自托管 HBC | 2026-05-12 起仅能加载本组织更新;Hermes 字节码只来自 EAS Update | 评审分发走 TestFlight/Play 轨道或 dev build;自托管更新提供纯 JS bundle(见 05 章) |
全局安装 expo-cli |
已废弃,行为和本地 CLI 不一致 | 一律 npx expo(见 02 章) |
| Node 用奇数版本或 22.x 过旧 | SDK 58 工具链要求 22 线 ≥22.13、24 线 ≥24.3、26+;23/25 不支持 | 用 nvm/volta 固定受支持版本(见 00 章) |
依赖与升级(详见 02 / 11 章)
| 陷阱 | 后果 | 正确做法 |
|---|---|---|
npm i expo-xxx 裸装包 |
装到与 SDK 不匹配的版本,红屏 “React Native version mismatch” | 一律 npx expo install;事后修 npx expo install --fix |
升级只 bump expo 版本号 |
expo-* 全家、react-native、router 版本错位 | 按官方流水线:expo@^58 → --fix → expo-doctor(见 11 章) |
| 升级后不重建 dev build | 旧原生壳 + 新 JS 运行时,行为诡异或直接崩 | 每次 SDK 升级都重新构建 dev client(见 05/11 章) |
| 认为"没有报错=升级完成" | 错过 Strict TS、SceneDelegate、R8 等静默破坏项 | 逐条对照 changelog 的 Deprecations/Breaking(见 11 章) |
手工编辑 android/、ios/ 后继续用 prebuild |
下次 --clean 全部蒸发 |
原生定制只有两个归宿:config plugin 或 Expo Modules/inline module(见 03/09 章) |
配置(详见 03 章)
| 陷阱 | 后果 | 正确做法 |
|---|---|---|
把密钥写进 extra 或 EXPO_PUBLIC_* |
二者都会进 bundle,反编译即泄露 | 构建期机密用 EAS secrets;运行时密钥永远在服务器侧 |
SDK 58 后仍用 NODE_ENV=test npx expo start 期望加载 .env.test |
CLI 强制按命令改写 NODE_ENV,实际加载 .env.development |
.env 按 development/production 组织;区分环境用 scheme/profile 参数(见 03 章) |
| 改了 plugins/icon/权限字段就以为生效 | 这些只在 prebuild/构建期消费 | 本地验证跑 npx expo config --type introspect;native 生效需重建(见 03 章) |
Expo Router(详见 04 章)
| 陷阱 | 后果 | 正确做法 |
|---|---|---|
升级 58 后沿用 withLayoutContext / expo-router/react-navigation 深导入 |
前者已弃用,后者大部分被移除 | 用 integrateWithRouter;自定义导航器按迁移指南改写(见 04 章) |
| Web 部署升级 58 后行为突变 | 58 起 web.output 未设置默认 server,按请求渲染而非导出预渲染 |
想要旧静态行为:显式 static 并配 expo-router 插件 apiRoutes 选项(见 04/10 章) |
| Web 上依赖 safe-area-context 的 insets | 58 起 Router 不再在 web 提供 | CSS 用 env(safe-area-inset-*)(见 04/10 章) |
useLocalSearchParams 结果直接当 number 用 |
值类型是 `string | string[]`,数组下标漏防会 NaN |
构建与发布(详见 06/07 章)
| 陷阱 | 后果 | 正确做法 |
|---|---|---|
| 以为 release 崩了是代码问题查半天 | SDK 58 Android 模板默认开 R8,反射依赖被裁剪 | 排查反射库;加 ProGuard keep 规则,或 expo-build-properties 关 android.enableMinifyInReleaseBuilds(见 06 章) |
| runtimeVersion 策略随手配 | OTA 推给原生不兼容的构建 → 白屏无回退 | 理解 appVersion/fingerprint 两策边界后再 eas update:configure;58 默认 balanced 指纹只是减少误报,不替你定策略(见 07 章) |
| 以为 EAS Update 什么都能热更 | 只覆盖 JS 与静态资源 | 任何原生变化(含新增原生库、改权限)必须走商店版本(见 07 章) |
eas update 的 --environment 与产出 bundle 的构建不一致 |
OTA 内联的 EXPO_PUBLIC_* 值和二进制里配的环境对不上 |
SDK 55+ 该旗标必传;构建与更新走同一 EAS 环境变量集(见 06/07 章) |
| 不设 buildNumber 自增 | 提审同号被拒/覆盖 | eas.json 配 autoIncrement(见 06/07 章) |
| 在文档/脚本里写死 EAS 额度价格 | 价格时效数据立刻过期 | 以 expo.dev/pricing 为准,流程上留出"额度检查"步骤(见 06 章) |
官方模块(详见 08 章)
| 陷阱 | 后果 | 正确做法 |
|---|---|---|
SDK 58 里沿用 File.write() 当同步用 |
58 起变异步,不 await 可能读到写一半/丢内容 | 需同步语义用 File.writeSync()(见 08 章) |
File.md5 继续用 |
58 已弃用 | File.digest() 支持 MD5/SHA-1/256/384/512(见 08 章) |
| expo-sqlite 仍开 libSQL 相关配置 | 58 彻底移除:syncLibSQL()/libSQLOptions 失效,插件 useLibSQL 无效 |
纯 SQLite 路径重写;确需 libSQL 要自行带第三方方案(见 08 章) |
| 按旧认知给通知写 handler"必须快否则被丢" | 58 起前台通知默认展示(除非 handler 否决),慢 handler 不再丢消息 | 重读 setNotificationHandler 语义,别保留过时的"秒回"技巧(见 08 章) |
| iOS 相册视频 URI 升级后"变了" | 58 起 Asset.getUri()/AssetInfo.uri 解析 Photos 当前版本 |
要原始文件传 version: AssetUriVersion.ORIGINAL(见 08 章) |
iOS 应用一直挂着 location background mode 只为 geofencing |
无实际使用场景会被 App Store 2.5.4 拒 | 58 起 geofencing 不再要求该 background mode,删掉闲置声明(见 08 章) |
| 依赖方向锁横竖屏 | iOS 27 应用可 resize:requireFullScreen 不再豁免、lockAsync 可能无效 |
按自适应布局设计,把方向锁当提示而非保证(见 08 章) |
| 在 SDK 58 上规划 macOS 目标 | 官方明确暂不支持,等 react-native-macos 跟上 RN 0.86+ | macOS 需求先钉在旧 SDK 或 bare(见 10 章) |
| 新项目还用 expo-av | 已被拆分为 expo-audio / expo-video 替代 | 音视频分别上 expo-audio、expo-video(见 08 章) |
两个值得单独讲的风险模式
热更新是双刃剑
EAS Update 的事故几乎都同构:推了一个当前原生运行时跑不了的 JS。防线只有三层,缺一层都要出事。第一层是 runtimeVersion 策略——它回答"哪些原生构建可以接收这批更新",策略要么保守(appVersion,跟着商店版本号走)要么精确(fingerprint,跟着原生内容走);SDK 58 把指纹默认改成 balanced preset,更新 version、动 node_modules 文件不再触发指纹变化,减少的是"该收更新却收不到"的误伤,不是给你乱配兜底。第二层是渐进发布:按百分比放量,配合 Expo Observe 的崩溃率对比再放全量。第三层是回滚预案:出问题先回滚(channel 指回上一版),而不是现场 hotfix。另记一条 58 的配套:updates.maxUpdatesToKeep 可调大本地保留数,频繁切 channel(channel surfing)的团队不必反复重下相同更新。
把 Expo Go 当分发渠道
Expo Go 的定位在 2026 年已经收窄得很明确:登录要求、组织限制、HBC 限制、单 SDK。任何"让客户在 Expo Go 里看我们的 demo/评审我们的 App"的流程都建在沙地上。评审分发用官方列出的三条正路:TestFlight / Play 内部轨道、EAS Build 的 internal distribution(或企业签方案)、dev build + EAS Update。
最佳实践清单
项目与依赖
- 仓库里不放
android/、ios/(用 CNG 的团队),把它们当构建产物;用 config plugin + Expo Modules 承载全部原生定制。 expo、expo-router、expo-*、react-native、React 的版本永远用npx expo install取得,CI 里跑npx expo-doctor当 lint 用。- lockfile 提交并保证本地/EAS 用同一包管理器与版本,避免"本地能构建,云端解析漂移"。
配置
app.config.ts做动态层(品牌/环境/bundle id 按变量切换),app.json只留静态公共部分,或者干脆全部 ts 化。- 权限描述、scheme、bundleIdentifier/package 在项目初始化就定死——它们进商店后改动成本高。
- 每次改配置先
npx expo config --type introspect看合并结果,再决定要不要 prebuild。
Router
- 目录结构一次设计到位:
(tabs)+(auth)分组、+not-found、layout 级错误边界(screenErrorBoundary)都配上。 - 数据获取优先 loader + 平台 HTTP 缓存(loader 里设
Cache-Control),客户端状态管理再叠加 TanStack Query(见tanstack-query/)。 - 深链即一等公民:每个对外入口都在真机上用 launch URL 实测过一遍,别只测应用内跳转。
构建发布
- eas.json 三档 profile(development/preview/production)职责固定:development 出 dev client,preview 内部分发可热更,production 只对接商店。
- iOS/Android 凭证用 EAS 托管起步,团队变大后再接 App Store Connect API Key / Play Service Account;
eas credentials定期盘点。 - release 构建先在真机跑一轮冒烟(R8、ProGuard、原生混淆问题 debug 包暴露不出来)。
热更新
- runtimeVersion 策略写进文档并评审,新人不许改。
- 更新先发 preview/staging channel,观察 Observe 指标再进 production channel,带百分比放量。
- OTA 与商店版本同仓管理:发原生版本时给该 runtimeVersion 的更新"封口"。
可观测性
- SDK 58 起直接上 Expo Observe(AppMetrics 已弃用),错误自动记录可按需用
errorHandlingEnabled关掉再接自建通道;Observe.clientId与自家埋点对齐。 - 需要产品分析用官方 PostHog 集成(
eas integrations:posthog:connect),事件自动带eas/update_id、eas/channel、eas/runtime_version,排查"哪个更新引入的回归"极快。
上线检查清单
原生版本发布:
npx expo-doctor全绿;expo install --fix无待处理差异- changelog 破坏性项逐条核对记录在升级 PR(尤其 Strict TS、SceneDelegate、R8、NODE_ENV)
- Android release 真机冒烟(R8 开启态);反射依赖的 keep 规则已验证
- iOS 27 目标:scene lifecycle 迁移自检(自定义过 AppDelegate 的必看);方向锁/全屏豁免不再依赖
- 权限文案、图标/splash、scheme 与商店条目一致
- buildNumber 自增生效、提审元数据同步
- 热更新窗口规划:本次原生版本的 runtimeVersion 边界、channel 状态
热更新发布:
- runtimeVersion 与目标原生构建匹配复核
- 先 preview/staging channel 灰度,Observe 崩溃率/启动指标对比基线
- production channel 百分比放量,留观察窗口
- 回滚路径演练可用(channel 指回上一 update)
- 更新内容不含任何原生/配置变更(有则禁止走 OTA)
调试速查表
| 症状 | 先查什么 | 详见 |
|---|---|---|
| 红屏 “React Native version mismatch” | npx expo install --fix 的候选差异 |
11 章 |
| QR 扫了不加载 | 终端与 Expo Go 是否同账号登录(57+iOS 起强制) | 05 章 |
| 加了第三方原生库就崩 | 还在用 Expo Go?切 dev build;autolinking 日志 | 05/09 章 |
| debug 正常 release 崩(Android) | R8/ProGuard keep 规则 | 06 章 |
| 推送更新后部分用户白屏 | runtimeVersion 策略与目标构建是否匹配;先回滚 | 07 章 |
| prebuild 生成的工程和预期不符 | npx expo config --type introspect + 插件执行顺序 |
03 章 |
| 深链冷启动没到目标页 | launch URL 的 __expo_ 保留参数与 scheme:// 前缀;Router 对 URL 的解析 |
04/05 章 |
| Web 导出后页面空白/路由 404 | 58 默认 server output 与预期 static 不符;apiRoutes 选项 | 04/10 章 |
| 改 app.json 的原生字段没生效 | 该字段只在构建期消费:重建 dev build 或 prebuild | 03 章 |
| 升级后 Metro 缓存诡异旧行为 | npx expo start --clear + EXPO_PUBLIC_* 在 58 已正确失效缓存 |
02/11 章 |
实战建议
用一句话概括这本手册的立场:Expo 的信任成本在 2026 年主要花在"理解平台边界",而不是"能不能做出功能"。功能层面(模块、Router、原生扩展)官方文档已经很好;出事的都是边界认知——Expo Go 是什么、OTA 能覆盖什么、runtimeVersion 承诺了什么、prebuild 什么时候会抹掉你的手工改动。团队落地时建议把这一章打印级对待:新人上手第一天读"环境与 Expo Go"表,第一次发版前过一遍"上线检查清单",升级 SDK 时把陷阱表里带"SDK 58"字样的行当作回归测试项。其余各章在需要时回查即可。