跳至主要內容

React Native 常被介紹成「React,但輸出是 native views 而不是 DOM」。這跳過了為什麼 View 裡的字串會 crash、為什麼 scroll offset 上的 useState 會掉 frame、為什麼 Expo config plugin 不能 over the air 送達,或為什麼 useLayoutEffect 可以在一次 commit 裡量到 view,但若你每 frame 都 animate height 仍然會失敗。

這個技術棧透過 Expo 交付 React Native,就像這個網站透過 Next.js 交付 React 一樣。Expo 是圍繞 React Native 的 orchestrator。 React Native 是 React 的 host renderer。Expo 分類 native modules、產生 iOS 與 Android 專案、用 Metro bundle JavaScript,並把 artifacts 對應到 devices。React Native 把 React tree 轉成 C++ shadow tree、跑 Yoga,並 mount UIView / android.view.View instances。React 仍擁有 elements、Fibers、lanes、render 與 commit。


text
app source + app.json / config plugins
  → Expo prebuild / autolinking / Expo Modules
  → Metro bundle (Hermes bytecode)
  → React render (Fibers)
  → Fabric shadow nodes (C++, via JSI)
  → Yoga layout
  → mount native views
  → native events / C++ state / Expo Modules feed back

這篇文章針對 Expo SDK 57(expo@57.0.9、React Native 0.86.2、React 19.2、Hermes V1)。New Architecture 是預設,而且在這條 SDK line 裡,也是唯一受支援的 runtime。四種陳述:

  • 一條 React 規則,例如 render purity,或 state setter 會 schedule 工作而不是突變目前的 closure。
  • 一份 React Native 契約,例如 <View> 與 <Text> 是不同的 host components。
  • 一份 Expo 契約,例如 config plugins、Continuous Native Generation、Expo Modules、expo-router,或 EAS Build 與 EAS Update 的分工。
  • 一項 實作觀察,例如 shadow-node field。應用程式碼不得依賴它們。

Legacy Bridge 與 react-native init 只在舊詞彙會造成歧義時出現。Expo Go 是帶固定 native module set 的 development host。它不是 production architecture。


1. Expo 在 React Native 周圍加上 Policy 與基礎設施

React 定義 components、reconciliation、Suspense 與 concurrent rendering。它不決定 TypeScript screen 如何變成 IPA、哪個 native module 被 link、fonts 如何嵌入,或之後的 JavaScript bundle 如何取代隨 store build 一起出貨的那一份。React Native 決定 host views 如何建立。Expo 決定其餘的 delivery path。


text
Expo app source
  ├→ Prebuild, config plugins, autolinking → iOS / Android binary
  └→ Metro + Hermes
       → Hermes: React render
       → JSI ─┬→ Fabric shadow tree → Yoga layout → Mount on UI thread → UIView / android.view.View
              │                         → Native events and C++ state → JSI
              └→ TurboModules and Expo Modules → Camera, SecureStore, network

EAS Build / Updates → iOS / Android binary
EAS Build / Updates → Metro + Hermes

  • Config 與 prebuild 決定 binary 裡存在哪些 native code。app.json / app.config.ts 加上 config plugins 就是契約。npx expo prebuild 會 materialize ios/ 與 android/。
  • Autolinking 在 app package 的 node_modules 裡發現 native modules。只存在於 shared workspace package 的 native dependency 是不可見的。
  • Metro 把 JavaScript graph 轉成 Hermes 可執行的 bundle。開發時,Fast Refresh 會替換那個 graph。Hermes V1 在 production 把它 compile 成 bytecode。
  • React Native 負責 render、layout 與 mount。Expo 不會取代 Fabric。
  • EAS 是 deployment adapter。EAS Build 產出 native binary。EAS Update / expo-updates 會替換已包含 matching native surface 的 binary 裡的 JavaScript bundle。

Expo 之於 React Native,就像 Next.js 之於 browser:圍繞 React core 的 policy 與基礎設施。Next.js 把 URLs 對應到 browser 的 React trees。Expo 把 app/ 檔案與 native modules 對應到 device 的 React trees。


2. React Native 是 Renderer,不是 Browser

Browser 解析 HTML、建立 DOM 與 CSSOM、style、layout、paint 與 composite。那條路徑是 Browser 裡的 Critical Rendering Path。React Native 不做任何那些事。沒有 HTML、沒有 CSSOM、也沒有 DOM。JSX 仍產生 React elements。Host tree 是 platform views。

<View> 與 <Text> 不是帶不同 default styles 的 div。它們是 不同的 host components,有不同的 native types 與不同的 layout rules。Yoga 可以 size 一個 View。Text metrics 來自 platform(NSLayoutManager / Android text layout)。字串只能作為 Text 的 child 才合法。


tsx
function Badge({ count }: { count: number }) {
  return (
    <View>
      {count > 0 ? <Text>{count}</Text> : null}
    </View>
  )
}

  • React Native 會嘗試把 raw 字串 mount 成不接受 text 的 view 的 host child。<View>Hello</View> 在 production 會 crash。
  • Web DOM 會把 0 stringify 成 text node。Native host tree 不會。
  • Expo Go 不會改變這份契約。它是 prebuilt 應用,其 native module set 就是 Expo 在該 Go binary 裡出貨的內容。Config plugin、custom Expo Module 或新的 native dependency 在 你的 binary 裡存在之前,對 Go 不可見。
  • Production development 使用 dev client(expo-dev-client)或 native surface 與 app.json 匹配的 EAS build。

失敗: {count && <Text>{count}</Text>} 當 count 是 0。JSX render 的是 0,不是 false。Fabric 隨後會嘗試在 View 底下建立 text host node。條件應放在 boolean 或 ternary 裡。


3. 四棵 Tree

深入理解 React 區分 React elements、Fiber tree 與 host tree。Fabric 加上第四棵 tree,它活在 C++ 裡。


text
React elements   descriptions returned by components
Fiber tree       React's persistent bookkeeping and unit of work
Shadow tree      C++ layout tree (Yoga)
Host view tree   UIView / android.view.View on the UI thread

  • 像 HomeScreen 或 expo-router layout 這樣的 composite component 永遠不會變成 shadow node。Fabric 只為 hosts 建立 shadow nodes:View、Text、ScrollView、來自 expo-image 的 Image。
  • Host Fiber 儲存指向其 shadow node 的 JSI pointer。Element 是暫時的描述。Fiber 記住 identity、hooks 與 lanes。Shadow node 持有 style、children 與 layout。Host view 是用戶能看到的。
  • 新的 element object 不代表新的 UIView。一次 component render 不代表 mount。JSX 裡的 wrapper View 在 flattening 之後可能永遠不會作為 native view 存在。
  • Device 上改變的 ScrollView offset 不一定經過 React state。

4. JSI 取代了 Bridge

在 New Architecture 之前,JavaScript 與 native code 透過 asynchronous queue 通訊。Calls 被序列化成 JSON、post 過 bridge,並在另一端 decode。Layout 無法在 render 期間被同步讀取。Native modules 被 eager 初始化。Concurrent React 無法 commit 一棵連貫的 native tree。

那條 queue 坐在更緊的耦合上:React Native 是對著 JavaScriptCore 的 C API 寫的。Engine call sites 冗長、要手管 memory,換 engine 意味著 fork framework。JSI(JavaScript Interface)是打破那層耦合的 C++ wrapper。Hermes、JSC、V8 與 Chakra 都可以實作同一份 Runtime。Hermes 存在,是因為有這條縫。

New Architecture 用 JSI 取代 queue,用 Fabric 取代 renderer,用 TurboModules(在 Expo 裡還有 Expo Modules API)取代 native modules。Expo SDK 55 丟掉了 legacy architecture。SDK 57 不把它作為 runtime 選項提供。

JSI 讓 JavaScript engine 持有 native object 的 reference,也讓 native code 持有 JavaScript function 的 reference。JS 對 native 的 call 可以是 synchronous。Hot path 上沒有 JSON round-trip。寫進 C++ shared memory 的 updates,JavaScript 立刻可見。

Runtime 是一等物件,不是口號。幾乎每次 JSI call 都要帶一份。它只能在 JS thread 上讀和寫;離 thread 存取是 undefined behavior。Framework 擁有它的 lifecycle,可以 tear down 再 restart。Capture 它意味著你自己管那份 lifecycle。它不是 singleton。


text
jsi::Runtime     engine-backed JS environment; JS-thread only
jsi::Value       C++ representation of a JS value
jsi::Object      Value with property get / set
jsi::Function    callable Value; how native creates a JS Promise
host function    C++ function installed into JS
host object      C++ object whose get / set run when JS reads a property

  • 那是 Fabric 在 render 期間建立 shadow nodes 所用的契約,也是 TurboModules 與 Expo Modules 暴露 SecureStore、camera frames 與 filesystem handles 所用的契約。
  • Host function 是裝進 JS 的 C++ function。早期 JSI native modules 走這條路:jsi::Value → C++ dynamic(一隻 generic bag)→ native types。Host object 是 C++ object,JS 讀 property 時跑它的 get / set。TurboModules、Fabric UIManager 與 Expo Modules 都是 host objects。
  • Fabric 更快,是因為它丟掉了 deserialization hops,不是因為 JSI 有魔法。Legacy UIManager 是 native module:dynamic → generic prop map → 再 deserialize 一次 → native view。Fabric 的 UIManager 是 host object:把 JSI value wrap 成 RawProps → 一次轉成 native props。
  • iOS 多贏這一 hop,因為 Objective-C++ 能更直接地把 JSI values bridge 到 native types。Android 仍要在 Java/Kotlin 與 C++ 之間付 JNI。JSI 不是銀彈。
  • 像 VisionCamera 這樣的 library 可以在不把約 30 MB 的 pixels 每秒六十次複製進 JSON string 的情況下處理 frame buffers。
  • Synchronous 不代表免費,也不代表「在另一條 thread」。JS thread 上一次重的 JSI call 仍會 stall React。若工作屬於 UI thread,module 必須明確 hop 過去。
  • JSI 不是 React API。應用程式碼應繼續呼叫已文件化的 modules。那些 modules 是 JSI host objects 這一事實解釋 latency;它不是你在 screen 裡該去 reach 的 type。

「Bridge」這個詞仍出現在 blog posts 與舊 modules 的 interop layers 裡。它不是 framework 所建立的契約。這些 Runtime 與 host-object 名字是實作觀察。Phillip Pan 的 React Native under the hood(App.js Conf 2024)走一遍它們。


5. Threading

Fabric 保持 renderer structures immutable:updates clone 而不是 mutate。幾乎每條 production bug 裡,有兩條 thread 很重要。


text
JS thread                         UI thread
  React render                      host view mutations
  most Yoga layout                  high-priority render (gestures)
  TurboModule / Expo Module JS      C++ state from native views
  Metro / Fast Refresh

  • JS thread 跑 Hermes、React 的 render phase,以及大部分 Yoga 工作。
  • UI thread(main thread)是唯一可以 create、update 或 destroy host views 的 thread。
  • 高優先級的 native events 可以在 UI thread 上跑 render pipeline,這樣 gesture 不會卡在 in-flight JS render 後面。較低優先級的工作可以被 interrupt。
  • C++ state updates,例如 ScrollView offset,可以完全跳過 React 的 render phase。
  • CallInvoker 是 native code 從 UI thread 或 background thread hop 上 JS thread 的方式。Dispatch 是 asynchronous。它不讓你 block 當前 thread 等到 JS 跑完。這就是 camera 與 animation libraries 仍需要自己的 UI-thread worklets,而不是從 UI thread 調 JSI 的原因。
  • Hermes 不會給你第二條 JS thread。Concurrent React 可以在 JS thread 上 pause 並 restart 一次 render。它不會把 JavaScript 挪到 UI thread 上,除非 renderer 明確在那裡跑一次高優先級 pass。

失敗: 把「app 很慢」當成一種診斷。一次長的 React render 佔用 JS thread。一次 layout-thrashing 的 animation 佔用 Yoga。一個 main-thread native module 佔用 UI thread 並推遲 mount。Reanimated worklets 存在,是因為有些工作根本不能等 JS。


6. Render、Commit 與 Mount

Fabric 的 pipeline 有三個 phases。官方文件稱它們為 render、commit 與 mount。它們對應 React 的 render/commit 切割,再加一步 native publication。


text
React render (JS)
  → shadow nodes (C++, via JSI)
  → commit: Yoga + promote next tree
  → mount: diff, flatten, mutate UIView / android.view.View

  • Render。 React 把 composite components 收成 host elements。對每個 host,Fabric 同步在 C++ 裡建立 shadow node。這通常跑在 JS thread 上。React element tree 是暫時的。Fibers 持久存在並持有 JSI pointer。
  • Commit。 Fabric 用 Yoga 計算 layout,並把那棵 tree promote 為「next」。大部分 layout 是 C++。Text 與 TextInput 仍會呼叫 platform 取 metrics。Commit 不得突變 host views。
  • Mount。 UI thread 把上一棵已 mount 的 shadow tree 與下一棵 diff,flatten 不需要 host view 的 nodes,並套用原子 mutations:create、update、insert、remove、delete。只有這時 pixels 才會變。
  • 初次 render 對空樹做 diff。一次 update 可能只碰一個 backgroundColor。React 可以跳過中間的 trees:一次稍後完成的 render 可以對上一次已發布的 tree mount,而不是對每一個被放棄的嘗試。

這就是 concurrent features 能在 native 上工作的原因。Render 是 speculative 的。Mount 是 publication。一次從未完成的 transition 永遠不會突變 host views。


7. Yoga、Flattening,以及 Layout 可能花多少

Yoga 在 C++ 裡實作一套 Flexbox-like 的 layout。Styles 是 JavaScript objects,不是 CSS stylesheets。gap、padding 與 flex 是 shadow-node inputs。它們不是 browser cascade。


tsx
import { useEffect, type ReactNode } from "react"
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from "react-native-reanimated"

function SlideIn({ visible, children }: { visible: boolean; children: ReactNode }) {
  const progress = useSharedValue(visible ? 1 : 0)

  useEffect(() => {
    progress.set(withTiming(visible ? 1 : 0))
  }, [progress, visible])

  const animatedStyle = useAnimatedStyle(() => {
    const value = progress.get()
    return {
      opacity: value,
      transform: [{ translateY: (1 - value) * 24 }],
    }
  })

  return <Animated.View style={animatedStyle}>{children}</Animated.View>
}

  • View flattening。 只透傳 layout 的 nested wrapper Views 往往永遠不會變成 host views。Fabric 可以把一棵典型的 600–1000 nodes 的 shadow tree 減到大約 200 個 host views。Native inspector 裡少了一個 View,往往是 flattening,不是 missing mount。
  • Animate width、height、top、margin 或 padding 是在請 Yoga 每一 frame 重新計算。Animate transform 與 opacity 可以在 GPU 上跑,不必 relayout。
  • Fabric 讓 synchronous measurement 能在同一一次 commit 裡發生。useLayoutEffect 可以透過 getBoundingClientRect(0.82 之後的路徑)在 paint 之前讀 layout。onLayout 在 view 稍後改變時保持值是新的。Prefer dispatch updater,這樣相同的 size 不會再 schedule 一次 render。
  • 兩種 measurement API 都不是每 frame animate layout properties 的藉口。

失敗: 每 frame animate height,然後奇怪為什麼 Yoga 與 mount 保不住 60fps。


8. Structural Sharing 與 C++ State

Shadow trees 是 immutable 的。一次 React state update 會從改變的 node clone 到 root 的那條 path,並 share 未改變的 subtrees。Mount 把上一棵已發布的 tree 與新的一棵 diff。

Shadow tree 裡的大部分資訊源自 React 並向下流。C++ state 是例外。有些 host components 持有 JavaScript 並不擁有的 state。ScrollView offset 是常見例子:platform view 滾動,C++ 記錄 offset,像 measure 這樣的 APIs 可以讀到它。React 並沒有 render 那次 update。


tsx
function Feed() {
  const [scrollY, setScrollY] = useState(0)

  return (
    <ScrollView
      onScroll={(event) => {
        setScrollY(event.nativeEvent.contentOffset.y)
      }}
      scrollEventThrottle={16}
    />
  )
}

  • Keys 與 identity 仍然重要:它們決定哪條 Fiber、哪個 shadow node、哪個 host view 能活下來。
  • 一個大 screen 若每一列都回傳新的 inline style object,仍可能比截圖看起來做更多工作。即便視覺結果看起來一樣,style 仍是 cloning 的 input。
  • 若 React 在中間 commit 了,C++ state commits 會 retry。Source of truth 仍留在 native。

失敗: 把 scroll offset 放進 useState。JS thread 現在每一 frame 都要 reconcile。Offset 已經在 C++ 裡。做 animation 時,Reanimated shared value 在 UI thread 上更新。做非 reactive 的 tracking,一個 ref 就夠。


9. TurboModules、Codegen 與 Expo Modules

Legacy native modules 事先註冊。Startup 為 camera、maps 與 analytics 付錢,哪怕第一屏從未用過它們。

TurboModules 透過 JSI 懶載入。第一次 JS access 才建構 module。Codegen 讀取 TypeScript 或 Flow specs 並產生 native bindings,這樣 JS/native interface 在 build time 被檢查。

Expo 的 Expo Modules API 坐在同一層 JSI 上。它的 Swift 與 Kotlin DSL compile 成 JSI host bindings。expo-secure-store、expo-camera、expo-font 與 expo-image 是帶 JavaScript entry points 的 native modules。它們不是 fetch 外面的可選 wrappers。Screen 仍呼叫已文件化的 module,不是 jsi::Runtime。

C++ TurboModules 收到一份 Runtime,因為它們的 methods 預設跑在 JS thread 上。那是實作觀察,不是從 screen 安裝 raw bindings 的理由。


app.json
{
  "expo": {
    "plugins": [
      [
        "expo-font",
        {
          "fonts": ["./assets/fonts/Geist-Bold.otf"]
        }
      ]
    ]
  }
}

  • Autolinking 掃描 app 的 node_modules。在 monorepo 裡,packages/ui 用到的 native dependency 也必須在 app package 裡宣告。否則 prebuild 不會 link 它。
  • Config plugins 突變產生出來的 native project:Info.plist keys、Gradle permissions、embedded fonts、splash screens。Fonts 應放在 expo-font plugin 裡,這樣它們在 process start 時就存在,而不是等一個 async useFonts gate。
  • Images 應放在 expo-image:native caching、blurhash placeholders、recycling keys。react-native 的 Image 是更薄的 host wrapper。
  • 加上 plugin 或 native module 會改變 native surface。那需要 npx expo prebuild(或 EAS Build)以及一份新 binary。僅 JavaScript 的 changes 不需要。

10. Metro、Hermes,以及 OTA 能替換什麼

npx expo start 跑 Metro。Metro 從 Expo entry 走 module graph,resolve platform extensions(*.ios.tsx、*.android.tsx),並 serve 一份 bundle。開發時,Fast Refresh 在能保留 state 的地方替換 components。Production 裡,Hermes V1(自 SDK 56 起預設,包括 SDK 57)把 bundle compile 成 bytecode。

  • JS thread 仍是一條 thread。更便宜的 bytecode format 不會讓對 feed 做無界 map 變免費。
  • EAS Update / expo-updates 把新的 JavaScript bundle 出貨到已有的 binary。它不能加上 native module、permission、透過 config plugin 註冊的 font file,或新的 Expo Module。那些活在 IPA/APK 裡。
  • Store build 是 native 契約。Update 是給那份契約的一棵新 React tree。
  • React Native DevTools 跟 engine 說話。僅 JS 的 freeze 是 Hermes/React 問題。JS 空閒時仍繼續的 freeze 是 UI-thread 或 native-module 問題。

失敗: 一個 screen 在 OTA 之後開始呼叫 expo-camera,對著一份沒有那個 module 的 binary 建置。Update 不會發明 native code。


11. expo-router 是 Native Views 上的 Route Tree

expo-router 把 app/ 下的檔案對應到 screens,就像 Next.js 把 app/ 對應到 URL segments。這個類比有用,但不完整。Next.js 為 document 產生 HTML 與 Flight tree。Expo Router 產生一棵 React tree,Fabric 把它 mount 進 native navigator。


app/_layout.tsx
import { Stack } from "expo-router"

export default function Layout() {
  return <Stack />
}

  • 用 native stack。Expo Router 的 Stack 是 @react-navigation/native-stack。Transitions 與 back-swipe 跑在 UI thread 上。@react-navigation/stack 是 JS navigator:它透過 React animate views,並丟掉那種 native behavior。
  • 當產品在意 platform feel 時,tabs 也應是 native 的。在 SDK 57 上那是 expo-router/unstable-native-tabs(NativeTabs)。即便那條 module path 稍後穩定下來,也 prefer native tabs。
  • Layout file 是 composite component。它有 Fiber identity。它不會自己變成 shadow node。Native stack 擁有 headers、gestures 與 large titles 的 host views。Prefer native header options,而不是自訂 JS header。
  • Navigation 是 host tree 的一部分。若 transition 跑在 JavaScript 裡,每一 frame 都會 re-enter React。若它跑在 UINavigationController / Android native tab host 裡,React 可以在 OS animate 時保持 idle。

12. Native 上的 Concurrent React

Fabric 讓 React 18+ 的 scheduling 在 device 上成為真的。Legacy renderer 無法乾淨地參與 transitions、Suspense,或來自 native events 的 automatic batching。

  • startTransition 可以把一次重的 screen update 標成 interruptible。一個 slider 在設定很大的 list size 時,可以讓 gesture 保持 urgent、list 保持 transitional。
  • Suspense 可以為 native tree 的一個 region 顯示 fallback,而不拆掉該 boundary 外已 committed 的 host views。
  • 在 legacy architecture 上,onLayout 加上後續的 setState 常常會把 tooltip 畫錯一 frame 位置。有了 Fabric,useLayoutEffect 加上 getBoundingClientRect 可以在一次 commit 裡 measure 並套用。
  • Concurrent rendering 仍然不是 parallelism。Hermes 跑這些工作。Fabric 可以 interrupt 它。UI thread 可以接手一次高優先級 pass。已 committed 的 host tree 保持連貫。

至於 React APIs 本身,見 React 19 新功能 與 深入理解 React。那些 APIs 在 Expo SDK 57 上不是 no-ops。


13. 不能等 JS 的工作

60fps 的 gesture 無法 round-trip 過 JS thread 上的 React render、Yoga 與 mount,卻仍感覺 native。Reanimated worklets 與 Gesture Handler 跑在 UI thread 上。SDK 57 pin 了目前版本的 react-native-reanimated、react-native-worklets 與 react-native-gesture-handler。

Pressable 是 host press primitive。TouchableOpacity 是 legacy。Animated press states 屬於 GestureDetector 加上 shared values,而不是 press-in 時的 JS setState。

Lists 是另一個 JS-thread trap。一個把每一列都 map 出來的 ScrollView,會為 offscreen content 建立 Fiber、shadow node,以及可能的 host view。


tsx
import { FlashList } from "@shopify/flash-list"

function Feed({ items }: { items: Item[] }) {
  return (
    <FlashList
      data={items}
      renderItem={({ item }) => <ItemCard item={item} />}
      keyExtractor={(item) => item.id}
    />
  )
}

  • Virtualizer 大約 mount 可見視窗。那是一份 Fabric budget,不是風格偏好。LegendList 是同一想法。契約是「用戶看不見的就不要 mount」。
  • renderItem 上的 inline style objects 與 inline callbacks 在 compiler 沒有 inline 它們時仍會打敗 memoization;hoist,或讓 React Compiler 來做。
  • Extra render → extra shadow clones → extra mount diffs,發生在 JS 與 UI threads 上,而用戶正在滾動。

14. 從頭到尾跟一次 Press

Expo Router 裡的一張 membership card screen。用戶按下一個 Pressable。Handler 用 setState 寫一個 flag,並從 expo-secure-store 讀一個 token。

  1. OS 在 UI thread 上把 touch 交給 host view。
  2. Fabric / Gesture Handler 把 press 路由進 JS,並呼叫 Pressable handler。
  3. setState 在 screen 的 Fiber 上 enqueue 一次 Hook update,並 schedule root(深入理解 React)。
  4. React 在 JS thread 上 render。Composite components 跑。Host elements 被建立。
  5. Fabric 透過 JSI 為改變的 hosts clone 或建立 shadow nodes,並 share 其餘部分。
  6. Commit 跑 Yoga。Text metrics 可能呼叫進 UIKit / Android。
  7. Mount 把上一棵 shadow tree diff,flatten wrappers,並在 UI thread 上突變 native views。
  8. expo-secure-store 的讀是另一次進入 Expo Module 的 JSI call。它不經過 Fabric。它可能在 module 內部 hop threads。它永遠不會變成一個 View。
  9. 若這次 press 也導航了,native stack 在 UI thread 上 animate,同時 React render 目標 screen。這兩者由 routing 耦合,不是由一次 function call。

React 永遠不會 rewrite 已經跑過的 handler 裡的 pressed binding。一次 half-finished render 永遠不會變成 half-mutated 的 UIView tree。SecureStore 不在 shadow tree 裡。Card 的 View 在。


15. 調試問題與常見誤解

當 Expo screen 意外時,依序想這些:

  1. 哪條 thread 忙? JS freeze 對 UI freeze 對兩者都 freeze。
  2. 這是 Expo Go、dev client,還是 release binary? Go 不能長出新的 native modules。
  3. 是 React render、Fabric commit,還是 mount? Component 裡的 console.log 不能證明 UIView 改變了。
  4. 這是 C++ state 還是 React state? Scroll offset、text selection 與某些 gesture state 從未進入 useState。
  5. Flattening 是否移除了 native inspector 裡正在檢查的那個 view?
  6. 這個 change 需要 native rebuild,還是 JS 就夠? Config plugins、permissions、fonts 與新的 Expo Modules 需要新 binary。EAS Update 不能發明它們。
  7. Autolinking 是否在查 app package? 只在 workspace library 裡的 native dep 是 missing link,不是 Metro cache issue。
  8. 這是 host-component 契約嗎? Text 外的字串、0 && <Text />、Gesture Handler list 裡的 TouchableOpacity。
  9. Navigation 是 native 的嗎? JS stack 會在本該免費的 transition 期間顯示為 React 工作。
  10. Layout 是否每 frame 都在跑? Animated height 是 Yoga。Animated transform 不是。

這些問題比一直加 memo 直到 list 不再掉 frame 更可靠。

常見誤解:

  • React Native 不是 WebView。 WebView 是應用裡的 browser origin。Fabric mount platform views。混用兩者是 security boundary,見 React Native 裡的 Security。
  • Expo Go 不是 production 怎麼運作。 Production 是你的 EAS binary,加上可選的、fit 那份 binary 的 OTA JavaScript bundle。
  • Managed 不代表沒有 native。 Expo generate native project。應用仍是 UIKit 與 Android views。Config plugins 是 native edits。Expo Modules 是 native code。
  • Bridge 不是 SDK 57 上 JS 與 native 怎麼說話。 JSI 是契約。Interop 可能為舊 modules 存在。
  • JSI 不代表零 serialization。 早期 JSI modules 仍經過 C++ dynamic 轉換。Android 仍要在 Java/Kotlin 與 C++ 之間付 JNI。
  • 不要 stash Runtime。 它只屬於 JS thread。Framework 可以 tear down 再 restart。
  • JSI 不代表 JavaScript 平行跑。 JSI 是 shared-memory calling。Hermes 仍是一條 JS thread。
  • Fabric 不是新的 React。 Fabric 是 React Native 的 renderer。React 的 reconciler 是 深入理解 React 描述的同一 core。
  • EAS Update 不能 ship 新的 native module。 它 ship JavaScript。Native surface changes 需要新 build。
  • 更多 Views 不代表更多 native views。 Flattening 丟掉 wrappers。Virtualization 拒絕 mount offscreen rows。
  • useLayoutEffect 在 native 上不是沒用。 在 SDK 57 的 Fabric 上,它是 measure 而不 visible jump 的方式。

最終心智模型

最短且準確的模型是:


text
Expo generates the native binary and the JS bundle.
Hermes runs React.
Fibers remember.
JSI shares memory with native.
Fabric builds a shadow tree.
Yoga lays out.
Mounting publishes UIView / android.view.View.
C++ state can bypass React.
OTA replaces JS, not native.

React Native 不會取代 React 的 rendering model。Expo 不會取代 React Native 的 renderer。Expo 決定哪些 modules 存在、native project 如何被 generate,以及 binary 與 bundle 如何到達 device。React Native 決定 React tree 如何變成 platform views。React 決定 updates 如何被 scheduled、rendered 與 committed。

保持 rendering pure,因為 Fabric 可能 restart 它。把字串放在 Text 裡,因為 host tree 不是 DOM。當 UI thread 已經擁有 scroll 與 gestures 時,把它們移出 React state tree。把 config plugins 放在 binary 裡,不要放在 EAS Update 裡。用 native navigators,這樣 transitions 不會 re-enter JavaScript。

至於 React 自己的 queues、lanes 與 commit phase,繼續看 深入理解 React。Web 對照見 Browser 裡的 Critical Rendering Path。Server/document 側對應的 orchestrator 見 深入理解 Next.js。Device threat model(extractable bundle、SecureStore、WebView bridges)見 React Native 裡的 Security。

主要參考:Expo 的 SDK 57 changelog;React Native 的 architecture overview、render pipeline、threading model 與 New Architecture notes;以及 Phillip Pan 的 React Native under the hood(App.js Conf 2024),講 JSI Runtime、host functions 與 host objects。公開的 Expo 與 React Native 契約是耐用的 API。Shadow-node internals 與 Metro 細節是今天 toolchain 的 debugging 證據,不是要 hard-code 的 API。

這篇文章中的實作觀察對應 Expo SDK 57 與 React Native 0.86.2(Hermes V1、New Architecture)。React Native 0.87 是下一個 Expo canary line,不是這個 SDK 的 runtime。


Recap Q&A

閱讀下一篇筆記
深入理解 Next.js