Published on

当抽象层换了实现:Expo Router SDK 56 两个 header 坑背后的边界迁移

Authors
  • avatar
    Name
    Jack Qin
    Twitter

升级一个框架的大版本,最难处理的往往不是显式列在 changelog 里的 breaking change,而是那些底层实现被换掉、但上层 API 形态看起来没变的地方。Expo SDK 56 把 expo-router 与 react-navigation 的关系做了一次切割,同时 header 的渲染从 JS-stack 时代的语义迁到了 native-stack(react-native-screens)。这两件事都不会改变你写的 prop 名字,却会让两段"以前能跑"的代码——一段直接 Metro 硬报错、一段静默渲染错误的 UI。

本文不讲某个 header 怎么配,而是想把这两个坑还原成同一类问题的两个面:当一个抽象的实现被替换时,原来依附在旧实现上的隐含前提会失效。一个前提失效得很吵(编译期就拦你),一个失效得很安静(只有真机肉眼能看出来)。理解这两种失效的机制,比记住两个修法更有迁移价值。


坑一:被 resolver 守卫拦下的 import——一个"早失败"的边界

最自然的写法是直接从 react-navigation 取 useHeaderHeight

// Metro 启动时直接硬报错。
import { useHeaderHeight } from '@react-navigation/elements'

报错信息把原因写得很直白:

ERROR  As of SDK 56, expo-router is no longer compatible with
react-navigation. For more information, see
https://docs.expo.dev/router/migrate/sdk-55-to-56/. You can disable this
check by setting the environment variable
EXPO_ROUTER_DISABLE_RN_NAVIGATION_CHECK=1.

机制是这样的:SDK 56 的 expo-router 带了一个 Metro resolver 守卫,它会对任何从 app 代码 import @react-navigation/* 的行为抛错。这个检查住在 @expo/cliwithMetroMultiPlatform.js 里,在任何用户代码运行之前就触发——它是一个构建期、模块解析层的拦截,不是运行时报错。

有个反直觉的细节:即便 package.json确实列着 @react-navigation/elements,你也不能直接 import 它。它在那儿是 transitive 依赖,只供 expo-router 内部消费。依赖图里存在 ≠ 你被授权直接引用。那个逃生口 EXPO_ROUTER_DISABLE_RN_NAVIGATION_CHECK=1 是给一次性迁移用的临时开关,生产仓库不应设置——它关掉的正是这道防止你依赖被切割掉的契约的守卫。

正确的做法是走 expo-router 重新导出的安全子集:

// 走 expo-router 的 re-export shim,resolver 放行。运行时和类型都一样。
import { useHeaderHeight } from 'expo-router/react-navigation'
  • expo-router/react-navigation → re-export @react-navigation/elements(含 useHeaderHeightgetDefaultHeaderHeightHeader 等);
  • expo-router(默认)→ re-export DarkThemeDefaultThemeThemeProvideruseThemeuseRouteuseFocusEffectuseIsFocuseduseScrollToTop

TypeScript 类型也从这些 re-export 路径解析——走 expo-router/... 不会丢任何东西。这里的设计意图值得记:框架并不是简单"禁止"你用 react-navigation 的能力,而是收窄了入口,把它从"任意依赖图引用"降级成"一个被框架显式背书的导出面"。要加一个仓库里还没有的 navigation-elements import 时,先搜目标符号是否出现在 expo-router/react-navigation 的导出里(查 node_modules/expo-router/build/react-navigation/index.d.ts);没有,这个 import 就会 fail Metro。

这个坑的好处是它早失败:边界被显式守卫,越界立刻被拦。下一个坑没这么仁慈。


坑二:空字符串技巧的静默失效——一个 UIKit 语义边界

想把返回按钮做成"只有箭头、没有父路由标题",JS-stack 时代的惯用技巧是给 headerBackTitle 一个空字符串:

// 渲染成:"< (tabs)" —— 父路由组的名字漏出来了
<Stack.Screen
  name="flow-meter"
  options={{
    title: 'Flow Meter',
    headerBackTitle: '',
  }}
/>

返回按钮字面读成了 (tabs),连括号都在。

机制在于这次 header 是由 native-stack 渲染的:react-native-screensheaderBackTitle 映射到 UINavigationItem.backButtonTitle。而在 UIKit 看来,backButtonTitle 设成 "" 等于"未设置"——一个空字符串不是"显式的空标题",而是"没给标题"。于是 iOS 走它的默认回退逻辑:用上一个路由的标题当返回按钮文案。

对挂在路由(tabs))下的子屏幕,这个组没有显式标题,于是 iOS 拿到的回退值就是组名本身——字面的 (tabs),括号一起带上。空字符串技巧在 JS-stack 时代有效,是因为那套实现自己解释 "";切到 native-stack 后,解释权交给了 UIKit,而 UIKit 对 "" 的语义和 JS 那套完全不同。

正确的选项是用 iOS 原生为此设计的 display mode:

// 渲染成:"<" —— 只有箭头,没有父标题。
<Stack.Screen
  name="flow-meter"
  options={{
    title: 'Flow Meter',
    headerBackButtonDisplayMode: 'minimal',
  }}
/>

headerBackButtonDisplayMode 映射到 UINavigationItem.backButtonDisplayMode(iOS 14+),"minimal" 是 iOS 原生的"完全抑制返回标题、只留箭头"方式——这正是 headerBackTitle: "" 想表达却表达不出来的东西。区别在于:"minimal" 是 UIKit 一等公民的"我要 chevron-only"语义,而 "" 只是"我没给标题,你看着办",后者把决定权拱手让给了 iOS 的默认回退。什么时候用哪个:headerBackTitle: "Back"(或任何非空字符串)仍按预期工作,是你想具体覆盖文案时的正确选择;只有当设计要求 chevron-only 时,才用 headerBackButtonDisplayMode: "minimal"

这个坑的坏处是它静默失效:没有任何编译期或运行时报错,单元测试(渲染不了原生 header)全绿,只有真机/iPad 上肉眼能看出"父路由名漏出来了"。


两类边界,两种失效

把两个坑并排看,它们其实是同一句话的两种表现:

SDK 56 把 expo-router 与 react-navigation 解耦(坑一的边界),并把 header 渲染迁到 native-stack(坑二的边界)。前者是框架显式守卫的构建期边界——越界吵闹地早失败;后者是委托给 UIKit 的运行时语义边界——旧前提安静地失效。

可迁移的判断是:升级一个换了底层实现的抽象时,要主动区分两类风险。一类是框架愿意替你拦的(resolver 守卫、类型错误、lint),它们会吵,代价低;另一类是框架管不到、只能靠真机观测的(原生控件对某个值的语义解释),它们安静,代价高。后者才是大版本升级真正要花精力 QA 的地方。


配套:这两个坑撞上的那个完整模式

两个坑都是在给某屏接 iPad 风格的悬浮半透明 header 时撞上的。完整模式如下:

// app/_layout.tsx
<Stack.Screen
  name="flow-meter"
  options={{
    title: 'Flow Meter',
    headerBackButtonDisplayMode: 'minimal',
    headerTransparent: true,
    headerBlurEffect: 'regular',
    headerShadowVisible: false,
    headerStyle: { backgroundColor: 'transparent' },
  }}
/>
// src/features/flow-meter/FlowMeterScreen.tsx
import { useHeaderHeight } from 'expo-router/react-navigation'

export function FlowMeterScreen() {
  const headerHeight = useHeaderHeight()
  return (
    <ScrollView
      contentContainerStyle={[styles.scroll, { paddingTop: headerHeight + spacing[3] }]}
      contentInsetAdjustmentBehavior="never"
      refreshControl={
        <RefreshControl
          refreshing={isRefreshing}
          onRefresh={onRefresh}
          progressViewOffset={headerHeight}
        />
      }
    >
      {/* ... */}
    </ScrollView>
  )
}

每个选项都在偿还"透明 header"带来的连锁后果:headerTransparent: true 让滚动内容透上来,模糊材质来自 headerBlurEffect: "regular"(iOS)和透明 backgroundColor(Android 回退到纯色主题);headerShadowVisible: false 去掉 bar 下那条 hairline,否则它在模糊上会读成一条硬边;显式的 paddingTop: headerHeight 防止第一张卡滚到 bar 底下;contentInsetAdjustmentBehavior="never" 防止 iOS 在我们显式 padding 之上又自动重复算一遍 inset;RefreshControlprogressViewOffset 把转圈放到模糊 bar 下方,否则它藏在 chrome 后面。透明 header 不是一个 flag,而是一组必须一起算清的偏移账——这本身又是一个"改一处、连带前提一片"的小型例证。