跳到主要内容

SwiftUI 常被介绍成「React,但 views 用 Swift 写」。这个描述并没有错,但压缩得太厉害,解释不了为什么 View struct 每次 update 都会被重建却不丢失 @State、为什么没有稳定 id 的 ForEach 会像缺了 key 一样打乱 rows、为什么 .onAppear 不是 useEffect([]),或为什么 Swift 里的 async/await 会发生 data-race,而 JavaScript 里同一套写法不会。

这个技术栈通过 Expo 交付 React Native。这篇文章是阅读 SwiftUI 时用的对照。它不是一份交付日记。它不假设你在写 native App Store 应用。它假设你已经把 React Native 理解成 React 的 host renderer:Fibers、commit、Fabric、Yoga,以及一条对 gestures 来说太晚的 JS thread。那个模型是 深入理解 React Native。这篇文章从那一篇结束的地方开始。你已经理解 native view tree。现在不靠 React 来写它。


text
RN:  setState → React render (Fibers) → commit → Fabric → UIView
SwiftUI: state mutation → invalidate body → value tree → diff by identity → UIView

示例针对 iOS 17+ 的 Observation(@Observable@State@Binding@Environment)以及 Swift 6 的 isolation(MainActorTask cancellation)。React Native 示例对齐姊妹篇里的 Expo SDK 57 line:function components、hooks、expo-router。Combine 的 ObservableObject / @ObservedObject 只出现一次,作为你在教程里仍会看到的旧路径。

它区分四种陈述:

  • 一份 React Native 契约,例如 <View><Text> 是不同的 host components,或 Fiber 上的 key identity。
  • 一份 SwiftUI 契约,例如 View 是一份 value description,或 @State 活在 framework 的 identity slot 里而不是 struct 上。
  • 一条 Swift 语言规则,例如 structs 在赋值时 copy,或 async functions 跑在它们继承到的 executor 上,直到你 hop。
  • 一项 实现观察,例如 List 包着 UICollectionView。观察有助于你读 stack traces。应用代码不得依赖它们。

贯穿全文的是一个小的 notes list → detail screen:fetch、下拉刷新、push 一条 detail route。中间各节的 snippets 是能证明这套对照的最小一对。第 13 节把两套 stack 放在同一处。



1. 这篇文章是什么

React Native 工程师已经有对的抽象:一棵 views 的 tree、会 invalidate 那棵 tree 的 state、layout、lazy lists、一条 native stack、不能等 JavaScript 的 gestures,以及带 cancellation 的 async 工作。SwiftUI 把每一项 remap 到不同的 runtime。

能带走的:

  • 先描述,再让 framework 更新 host。 JSX 返回 elements。body 返回 some View。两者都不是屏幕上的 UIView
  • Identity 决定哪些 state 能活下来。 React 在 Fiber 上用 type + key。SwiftUI 用 structural position,再加上显式的 id: / ForEach ids。
  • Lists 是 lazy 的,navigation 是 native 的,layout 是单独的一趟。 FlashList、expo-router 的 native stack,以及 Yoga,都有 SwiftUI 对应物。它们不是同一套 algorithms。

带不走的是 runtime。没有 Hermes,没有 Fabric shadow tree,没有 Yoga,也没有可以 OTA 替换的 Metro bundle。SwiftUI 被编译进 IPA。Update loop 是 framework 拥有的 value-tree diff,不是 React 的 render/commit。

这篇文章不会走 Xcode、signing 或 App Store。它不会教 UIKit wrapping、The Composable Architecture、SwiftData,或把 Combine 当默认 state 路径。那些是后续文章,或不同的工作。



2. Runtime 不是 React

React Native 的 loop 是 React 的 render 与 commit 加上一个 host renderer。Setter 会 schedule 工作。React reconcile Fibers。Fabric mount 或 update UIView instances。Yoga 已经 size 过 shadow tree。不能掉帧的 gestures 离开那条 loop,通过 Reanimated 或 Gesture Handler 跑在 UI thread 上。

SwiftUI 的 loop 没有 Fiber,没有 JS thread,也没有 Yoga。View 是一个 value。当 view 读到的 state 改变时,SwiftUI 会 invalidate 那个 view,要一份新的 body,按 identity 把新的 value tree 与上一份 diff,然后更新底层的 UIKit attributes。你写的那个 View struct 不是屏幕上的 object。重建它是预期的,而且便宜。



看起来一样的谎言是:body 就是 render()。两者都是描述。差别在 ownership。React 为每个 component instance 拥有一条 Fiber,并把 Hooks 存在上面。SwiftUI 拥有一张 identity map,并把 @State 存在那里。你的 struct 是那张 map 的 input,不是 instance。

一项实现观察:SwiftUI 仍然坐在 UIKit 上(UIViewUICollectionViewUINavigationController)。写 VStack 不会给你另一条 pixels pipeline。你得到的是另一种 description language,以及另一种 identity/state 模型。想着「没有 UIKit」帮不了你读 stack trace。



3. React Native 工程师会踩的 Swift 陷阱

JavaScript values 除非是 primitives,否则是 references。突变 object 的一个 field,每个 alias 都看得见。Swift 对你放进 View 的数据默认相反:struct 是 value。赋值会 copy。对 copy 的 mutation 不会改到原本。

这是一条 Swift 语言规则。它之所以重要,是因为 View 是 structs 所 adopt 的 protocol。Framework 建立在便宜的 copies 上,而不是长寿的 component instances。


ts
type Note = { id: string; title: string }

const a: Note = { id: "1", title: "Draft" }
const b = a
b.title = "Published"
// a.title === "Published"

swift
struct Note {
  var id: String
  var title: String
}

var a = Note(id: "1", title: "Draft")
var b = a
b.title = "Published"
// a.title == "Draft"

class 是 reference type。两个 variables 可以指向同一个 instance。@Observable models 是 classes,因为 store 必须被共享并原地突变。 store 的 view 仍然是 struct。

let vs var 不是 TypeScript 里的 const vs letlet 绑定一个不能被重新赋值的名字。let struct 不能突变它的 properties。let class reference 不能指向另一个 instance,但那个 instance 的 var properties 仍然可以改。

Optionals(String?Note?)是一种 type,不是 runtime 的 undefined。你 unwrap 它们(if letguard let??),而不是检查 == null。SwiftUI 大量用 optionals 做 presentation:.sheet(item:) 接受 Binding<Item?>,在它非 nil 时 present。

这些都不是 UI。跳过它们,@State 看起来就像在对 immutable struct 做魔法 mutation。并不是。Property wrapper 在跟 SwiftUI 的 identity slot 说话。你在 body 里看到的 struct 是一份新鲜的 value,它 project 那个 slot。



4. View 是描述,不是 Component Instance

Function component 是从 props(以及 Hook state)到 React element 的函数。SwiftUI view 是一个 conform 到 View 的 struct,并暴露 body


tsx
function NoteRow({ title }: { title: string }) {
  return (
    <View>
      <Text>{title}</Text>
    </View>
  )
}

swift
struct NoteRow: View {
  let title: String

  var body: some View {
    Text(title)
  }
}

什么相同:两者都是 纯描述。调用 NoteRow / 构造 NoteRow(title:) 不会 mount 一个 UIView。它产生一个 framework 会 reconcile 的 value。

哪里在说谎:

  • body 是 computed property,不是你去调用的函数。 SwiftUI 调用它。body 里的 side effects 与 React render 期间的 side effects 是同一类 bug。
  • some View 是 opaque type。 Compiler 知道具体的 nested type(Text,或 VStack<Text>,或你 compose 出来的任何东西)。Callers 不知道。它不是 ReactElement。它也不是 any View(type erasure)。AnyView 存在,但会牺牲 identity 与 performance。优先用 some View
  • ViewBuilder 是 result builder,这套 compiler feature 让你可以写 VStack { Text("A"); Text("B") } 而不返回 array。JSX 是 jsx() 的语法。Children 列表是另一套机制。Builder 里的 if / switch 会改变 structural identity。那是第 5 节。

每次 invalidation 都重建 struct,这正是重点。没有「在这次 setState 之后活下来的那个 component instance」的等价物。便宜的 value,持久的 identity slot。

Xcode Previews 不是 Fast Refresh。Fast Refresh 替换一个 JS function 并试图保住 Hook state。Preview 在 canvas 里重新 instantiate view tree。有用。不同的 delivery path。



5. Identity 不是 Fiber Identity

React 公开的 identity 规则是 type + position,用 key 覆盖。深入理解 React 是 Fiber 版本。State 活在 Fiber 上。改 key,React 就把它当新 instance:state 重置。

SwiftUI 公开的 identity 规则是 structural identity(view 在 body tree 里坐在哪里),用显式 identity 覆盖:ForEachid.id(_:),以及 identifiable 的 navigation values。

@State 不是存在 struct 上。Struct 会被 copy。SwiftUI 把 state 存在一个由该 view 的 identity 作为 key 的 slot 里。Identity 稳定,slot 就在新的 body 之后活下来。Identity 变了,slot 是新的,state 重置。如果两行共享同一个 identity,它们共享一个 slot,state 看起来「黏」在错误的 row 上——跟重复 key 是同一个 bug。


tsx
notes.map((note) => (
  <NoteRow key={note.id} title={note.title} />
))

swift
ForEach(notes) { note in
  NoteRow(title: note.title)
}

// Note: Identifiable, or:
ForEach(notes, id: \.id) { note in
  NoteRow(title: note.title)
}

什么相同:稳定的 ids 让 state 与 animations 留在对的 row 上。 Index-as-id 与 key={index} 是同一个陷阱。

哪里在说谎:

  • ViewBuilder 里的 if / else 是 type-level 的分支。 SwiftUI 看到的是两个不同的 structural positions,不是一个 props 变了的 view。每个分支有自己的 identity。React 里的条件 return,只要你 render 的是同一个 function,仍是同一个 component type。SwiftUI 里的条件 if 更接近在 没有 keys 的情况下切换 {condition ? <A /> : <B />},只是 framework 对此更严格。
  • .id(newValue) 强制一个新的 identity。 当你意思是 remount 时才用。不要用它在每次 keystroke 上「重置表单」。
  • 带 range 的 ForEachForEach(0..<count))是 index identity。优先用带 ids 的 data。

Notes list 依赖这一点。一行的 @State(swipe offset、expanded flag)必须跟随 note.id,而不是该 row 当前在 array 里的 index。



6. State

React state 是 Fiber 上的 Hook slot。SwiftUI state 是 identity 上的 slot。站得住的对照:

角色React NativeSwiftUI (iOS 17+)
本地 UI stateuseState@State
受控 childvalue + onChange@Binding ($state)
共享的 screen modelmodule store、Context、Zustand@Observable class,通常用 @State 持有,传入或放进 Environment
只读依赖Context@Environment / @Environment(\.dismiss)

tsx
function NoteEditor() {
  const [title, setTitle] = useState("")

  return (
    <TextInput value={title} onChangeText={setTitle} />
  )
}

swift
struct NoteEditor: View {
  @State private var title = ""

  var body: some View {
    TextField("Title", text: $title)
  }
}

什么相同:一个本地 value,一种写入它的方式,改变时 rebuild。$titleBinding<String>——SwiftUI 对「value 加上 setter」的名字。把 $title 传进 child 是反过来的 lifting:parent 拥有,child 穿过它来写。


tsx
function TitleField({
  value,
  onChange,
}: {
  value: string
  onChange: (next: string) => void
}) {
  return <TextInput value={value} onChangeText={onChange} />
}

swift
struct TitleField: View {
  @Binding var title: String

  var body: some View {
    TextField("Title", text: $title)
  }
}

Screen 的 notes array 不属于每一行上的 @State。它属于 list 与 detail 都会读的一份 model。那份 model 是标了 @Observableclass。View 用 @State 持有 它(iOS 17 对 Observable instances 的规则),或接收它。


swift
import Observation
import SwiftUI

@Observable
final class NotesStore {
  var notes: [Note] = []
  var isLoading = false
}

struct NotesListView: View {
  @State private var store = NotesStore()

  var body: some View {
    List(store.notes) { note in
      Text(note.title)
    }
  }
}

SwiftUI 追踪 body 读了 哪些 properties。突变 isLoading 不会 invalidate 一个只读了 notes 的 view。这比典型的 React context value 更细,后者只要 store tick 一下就会 re-render 每个 consumer,除非你拆 contexts 或 select。

@Environment(NotesStore.self) 是不靠 props 把 store 往下传的方式。它是 Context,不是 Redux。没有 reducer 要求。没有 time-travel。不要把 framework 没有的 JS Flux 词汇引进来。

旧路径,一段说完。 Observation 之前的代码用 ObservableObject@Published@StateObject(owner)和 @ObservedObject(child)。那些 types 坐在 Combine 的 objectWillChange 上。你在 samples 里仍会看到它们。iOS 17+ 上的新代码不该从那里起步。@StateObject 不是 @State@ObservedObject 并不拥有那个 object;如果 parent 重建它,你会重置。这份 ownership 上的区别,就是这里把 Observation + @State 当作教学默认的原因。



7. Layout 不是 Yoga

Yoga 是 flexbox:flexDirectionjustifyContentalignItemsflex: 1、margins。React Native 没有 CSSOM,但 algorithm 仍然是 shadow tree 上的 Yoga。

SwiftUI layout 是一套 proposal protocol。Parent 提出一个 size。Child 选择一个 size(最多到这份 proposal,除非它忽略)。Parent 再放置 child。HStackVStackZStack 是三条 composition axes。它们不是带 z-index 的 flexDirection: 'row' | 'column'ZStack 是 overlay,不是第三种 flex direction。


tsx
function NoteCard({
  title,
  excerpt,
}: {
  title: string
  excerpt: string
}) {
  return (
    <View style={styles.card}>
      <View style={styles.text}>
        <Text style={styles.title}>{title}</Text>
        <Text style={styles.excerpt}>{excerpt}</Text>
      </View>
    </View>
  )
}

const styles = StyleSheet.create({
  card: {
    flexDirection: "row",
    alignItems: "flex-start",
    gap: 12,
    padding: 16,
  },
  text: {
    flex: 1,
    gap: 4,
  },
  title: { fontWeight: "600" },
  excerpt: { opacity: 0.7 },
})

swift
struct NoteCard: View {
  let title: String
  let excerpt: String

  var body: some View {
    HStack(alignment: .top, spacing: 12) {
      VStack(alignment: .leading, spacing: 4) {
        Text(title).font(.headline)
        Text(excerpt).foregroundStyle(.secondary)
      }
      Spacer()
    }
    .padding(16)
  }
}

什么相同:一行、leading alignment、内层 column、padding。

哪里在说谎:

  • Spacer() 一般不是兄弟上的 flex: 1 它是一个会沿着 stack 的 axis 扩张、吃掉剩余 proposed space 的 view。在 HStack 里它推开。在 VStack 里它沿垂直方向推开。RN 里给 text container 加 flex: 1 是「这一列拿走剩余 width」。在 SwiftUI 里这常常是给 VStack.frame(maxWidth: .infinity, alignment: .leading),而不是在它后面放 spacer。这段 snippet 用 Spacer() 是为了匹配 trailing edge 上剩余空间的视觉。
  • .frame(width:height:) 是一份 proposal,然后才是 size。 它不总是 Yoga 里的 width / heightmaxWidth: .infinity 的意思是「拿走 parent 提出的无论多少」。
  • Safe area 默认是开的。 SwiftUI 在它里面 layout。.ignoresSafeArea() 选择退出。RN 默认的 View 不会 inset;你要自己加 SafeAreaViewuseSafeAreaInsets()。默认是反过来的。
  • 没有 StyleSheet Modifiers(.padding.background.clipShape)包住这个 value。顺序有意义:.padding().background() 不是 .background().padding()。这更接近 nested Views,而不是一个扁平的 style object。

不要在任何一套 stack 里每帧 animate height。在 RN 里那是在跟 Yoga 与 Fabric 打架。在 SwiftUI 里那是在跟 layout pass 打架。优先用 transforms 与 opacity,跟 native 上一样。



8. Lists

FlatList / FlashList recycle cells。ScrollView + map 不会。RN 契约是:长数据走 virtualized list,keyExtractor 是 identity。

SwiftUI 的 List 是 lazy 的。List 里的 ForEach 是 lazy 的。普通 ScrollView 里的 ForEach 不是,除非你换成 LazyVStack / LazyHStack。最后这句就是 FlashList 对 map 那场对话,只是换了名字。


tsx
<FlatList
  data={notes}
  keyExtractor={(item) => item.id}
  renderItem={({ item }) => <NoteRow title={item.title} />}
/>

swift
List(notes) { note in
  NoteRow(title: note.title)
}

// Or:
ScrollView {
  LazyVStack {
    ForEach(notes) { note in
      NoteRow(title: note.title)
    }
  }
}

什么相同:data 进去、每行一个 identity、一个 row renderer、recycling。

哪里在说谎:

  • List 带来 platform styling(inset grouped、separators、swipe actions)。FlashList 是一块空白的 recycling 表面。如果你要自定义 feed,ScrollView + LazyVStackList 更接近 FlashList。
  • Row 上的 onAppear 在这行被 realize 时触发,不是 screen mount 的时候。那是 viewability,不是 list screen 上的 useEffect。第 12 节。
  • Identity bugs 表现为错误的 row 在 animate。id,别修 animation。

Pull to refresh 是 list 上的 .refreshable,对应 FlatList 上的 RefreshControl。两者都是 UI-thread 上的 platform controls。两者都不该以挡住帧的方式在 JS/main thread 上 fetch——Swift 里没有 JS thread,但 main actor 上的同步 load 仍然会挡住 UI。



9. Navigation

expo-routerapp/ 下的文件 map 到一条 native stack(@react-navigation/native-stack)。URLs、deep links 与 layouts 是 Expo 契约。Transition 仍然跑在 UINavigationController 上。

SwiftUI 的 NavigationStack 也是 native stack。除非你自己建,它不是一棵 URL tree。你 push 的 value 就是 route。navigationDestination(for:) 是 registrar。


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

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

// app/notes/index.tsx
import { useRouter } from "expo-router"
import { Pressable, Text } from "react-native"

function NoteLink({ id, title }: { id: string; title: string }) {
  const router = useRouter()
  return (
    <Pressable onPress={() => router.push(`/notes/${id}`)}>
      <Text>{title}</Text>
    </Pressable>
  )
}

swift
NavigationStack {
  List(notes) { note in
    NavigationLink(value: note) {
      Text(note.title)
    }
  }
  .navigationDestination(for: Note.self) { note in
    NoteDetailView(note: note)
  }
}

Note 必须是 Hashable(通常也是 Identifiable)才能活在 path 上。

职责expo-routerSwiftUI
Stackapp/**/_layout.tsx + <Stack />NavigationStack
Pushrouter.push('/notes/1')NavigationLink(value:)path.append
带类型的 screenapp/notes/[id].tsx.navigationDestination(for: Note.self)
Modalpresentation: 'modal'.sheet(item:) / .fullScreenCover
Dismissrouter.back()@Environment(\.dismiss)

什么相同:一条 native stack,detail 被 push 到上面,一张不是 stack push 的 sheet。

哪里在说谎:没有 file-system router。 你不会从文件夹得到 typed routes。NavigationPath 是一个可以 encode 的无类型 stack。Deep linking 是 onOpenURL 加上你自己的 parsing,或一个 library。不要指望 href 成为 architecture。

这不是一份 routing 教程。重点是两套 stack 用对了,都能把 transition 留在你的 update loop 之外。RN 里的 JS stack navigator 每一帧都重新进入 React。自定义的 SwiftUI transition 如果每一帧都 tick @State,就会每一帧重新进入 body。优先用 platform stack。



10. Gestures 与 Animation

React Native 的 JS thread 对 60fps 的 pan 来说太晚。New Architecture 不改变这一点。不能等 JS 的工作属于 Reanimated worklets 或 Gesture Handler,它们跑在 UI thread 上,写入 C++ / native animated nodes。那是 深入理解 React Native 的第 14 节。

SwiftUI 没有 JS threadDragGestureMagnifyGesturewithAnimation 已经跑在 UI process 里。你不需要 worklet compiler 才能用手指移动一个 view。

这并不让 SwiftUI animation 等价于 Reanimated worklet。


tsx
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withSpring,
} from "react-native-reanimated"

function SwipeRow() {
  const x = useSharedValue(0)
  const pan = Gesture.Pan()
    .onChange((event) => {
      x.value = event.translationX
    })
    .onEnd(() => {
      x.value = withSpring(0)
    })
  const style = useAnimatedStyle(() => ({
    transform: [{ translateX: x.value }],
  }))

  return (
    <GestureDetector gesture={pan}>
      <Animated.View style={style}>
        <Text>Note</Text>
      </Animated.View>
    </GestureDetector>
  )
}

swift
struct SwipeRow: View {
  @State private var x: CGFloat = 0

  var body: some View {
    Text("Note")
      .offset(x: x)
      .gesture(
        DragGesture()
          .onChanged { value in
            x = value.translation.width
          }
          .onEnded { _ in
            withAnimation(.spring) { x = 0 }
          }
      )
  }
}

什么相同:由 pan 驱动的 translation,一个 spring 回家。

哪里在说谎:

  • Reanimated 的 x.value 可以在没有 React render 的情况下 update。 Shared values 是 native-thread state。SwiftUI 的 x@State。每次 onChanged 都会 invalidate view 并重新计算 body。对单行 offset 这没问题。对一棵在 body 里做真工作的 views graph,这不是免费的。Worklet 的对应物不是 withAnimation。它仍然是「把这件事留在 description pass 之外」——UIKit,或一个 Canvas,或一个写入 SwiftUI 可以在不重跑整棵 tree 的情况下 interpolate 的 transaction。
  • withAnimation interpolate 的是 SwiftUI 已经知道如何 animatable-diff 的 values(frame、opacity、offset、一部分 layout)。它不是 useAnimatedStylebody 里的任意工作不会因为你包了 setter 就变成 worklet。

能活下来的对照是:如果 gesture 不能等一份 JS bundle,你已经离开了 React Native 的 render loop。 在 SwiftUI 里你从那个世界起步。你仍然会掉帧。你掉帧是因为在 body 里做太多,不是因为在等 Hermes。



11. Swift Concurrency 对比 JavaScript Event Loop

JavaScript 是单线程的。Promises 与 async/await 是在 深入理解 JavaScript Event Loop 上 scheduling:一个 call stack、一条 microtask queue、一条 task queue。你不能从两条 threads 对同一个 JS object data-race。你仍然会有 stale closures、错过的 cleanups,以及一条在 UI 等待时忙碌的 JS thread。

Swift 是 concurrent 的。async/await 不是 event loop。一个 async function 会 suspend。当它 resume 时,可能已经在一个 不同的 executor 上。UIKit 与 SwiftUI state 属于 main actor。Hop 出去 fetch,然后在没有回到 MainActor 的情况下突变 store,就是 data race。Swift 6 的 compiler 常常会拒绝这件事。把这次拒绝当成 feature。


tsx
useEffect(() => {
  const controller = new AbortController()

  async function load() {
    const next = await fetchNotes({ signal: controller.signal })
    setNotes(next)
  }

  void load()
  return () => controller.abort()
}, [])

swift
.task {
  await store.load()
}

// Restart when the selected note changes — the dependency array:
.task(id: selectedId) {
  await store.loadDetail(id: selectedId)
}

swift
@Observable
@MainActor
final class NotesStore {
  var notes: [Note] = []

  func load() async {
    let next = try? await fetchNotes()
    notes = next ?? []
  }
}

什么相同:view 处于 active 时开始 async 工作,不在时 cancel,把结果放进 screen state。

哪里在说谎:

  • .task 的 cancellation 是 cooperative 的,像 AbortControllerURLSession 会尊重它。紧密的 for loop 不会,除非你检查 Task.isCancelled。离开 useEffect 却不 abort,是同一类 leak。
  • .task(id:) 是 dependency array。 不带 id 的 .task { } 更接近 useEffect(() => { ... }, []) 再加上 unmount 时 cancel,但「unmount」指的是 SwiftUI identity 消失,不是 Fiber unmount。如果 identity 闪烁,你会重新 fetch。那是正确的 cancellation,不是神秘的 double fetch。
  • actor 把可变 state isolate 到一个串行 mailbox。 它不是 Mutex,也不是「JavaScript 是单线程所以我们没事」。如果一个 background task 突变 body 在读的 class,你需要 isolation(store 上的 @MainActor 是常见的 UI 选择),否则你就有一套 JS 心智模型看不见的 race。

setTimeout(0) 不是 Task { }Task { } 会 schedule 工作。它不会等一个并不存在的 microtask checkpoint。MainActor.run { } 是「hop 到 UI actor」,不是 queueMicrotask



12. Lifecycle 不是 Mount

useEffect 在 commit 之后跑。useEffect(() => { ... }, []) 在 mount 之后跑。Cleanup 在 unmount 之前跑,或在 deps 改变时下一次 effect 之前跑。这是 React 规则。Fabric 让 commit 在 device 上成为真的;它不改变 effect 何时跑。

SwiftUI 的 .onAppear / .onDisappear 跟随的是 rendered tree 里的 identity,不是 Fiber mount。List row 的 onAppear 在 cell 被 realize 时触发——滚出去,onDisappear;滚回来,onAppear 再来一次。这更接近 onViewableItemsChanged,而不是 screen 级的 useEffect([])

.task 是更好的 loading primitive:view appear 时开始,view 的 identity 消失时 cancel,并且可以用 .task(id:) restart。优先用它,而不是 onAppear { Task { ... } },后者很容易 leak。


tsx
useEffect(() => {
  void load()
}, [id])

swift
.task(id: id) {
  await load(id: id)
}

什么相同:「当这个 screen 的 inputs 处于 active,就 load;不在时,就停。」

哪里在说谎:onAppear 不是 componentDidMount 一张盖住 view 的 sheet,历史上会产生与「底下仍然 mounted」不匹配的 appear/disappear 序列。不要把「整个 app session 生命周期只跑一次」的逻辑放进 onAppear。对 notes list,在 list identity 上用 .task load,在 detail identity 上用 .task(id: note.id) load detail。

如果你的意思是「Fiber 已经没了」,onDisappear 不是可靠的「保存 draft」hook。从显式 action 保存,或从 .task cancellation(defer / try await teardown)保存,前提是你已经决定把它绑在哪个 identity 上。



13. Notes List,两套 Stack

同一块产品表面。Fetch 一份 list、下拉刷新、push 一个 detail、load body。虚构的。RN 一侧是 Expo。SwiftUI 一侧是 Observation + NavigationStack

React Native(expo-router)

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

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

app/notes/index.tsx
// app/notes/index.tsx
import { useCallback, useEffect, useState } from "react"
import {
  ActivityIndicator,
  FlatList,
  Pressable,
  RefreshControl,
  Text,
} from "react-native"
import { useRouter } from "expo-router"

type Note = { id: string; title: string }

async function fetchNotes(): Promise<Note[]> {
  const response = await fetch("https://example.com/notes")
  return response.json()
}

export default function NotesListScreen() {
  const router = useRouter()
  const [notes, setNotes] = useState<Note[]>([])
  const [refreshing, setRefreshing] = useState(false)
  const [loading, setLoading] = useState(true)

  const load = useCallback(async () => {
    const next = await fetchNotes()
    setNotes(next)
  }, [])

  useEffect(() => {
    void load().finally(() => setLoading(false))
  }, [load])

  if (loading) {
    return <ActivityIndicator />
  }

  return (
    <FlatList
      data={notes}
      keyExtractor={(item) => item.id}
      refreshControl={
        <RefreshControl
          refreshing={refreshing}
          onRefresh={async () => {
            setRefreshing(true)
            await load()
            setRefreshing(false)
          }}
        />
      }
      renderItem={({ item }) => (
        <Pressable onPress={() => router.push(`/notes/${item.id}`)}>
          <Text>{item.title}</Text>
        </Pressable>
      )}
    />
  )
}

app/notes/[id].tsx
// app/notes/[id].tsx
import { useEffect, useState } from "react"
import { ActivityIndicator, Text } from "react-native"
import { useLocalSearchParams } from "expo-router"

type Note = { id: string; title: string; body: string }

async function fetchNote(id: string, signal: AbortSignal): Promise<Note> {
  const response = await fetch(`https://example.com/notes/${id}`, { signal })
  return response.json()
}

export default function NoteDetailScreen() {
  const { id } = useLocalSearchParams<{ id: string }>()
  const [note, setNote] = useState<Note | null>(null)

  useEffect(() => {
    const controller = new AbortController()
    void fetchNote(id, controller.signal)
      .then(setNote)
      .catch(() => {})
    return () => controller.abort()
  }, [id])

  if (!note) {
    return <ActivityIndicator />
  }

  return <Text>{note.body}</Text>
}

SwiftUI


swift
import Observation
import SwiftUI

struct Note: Identifiable, Hashable {
  let id: String
  var title: String
  var body: String
}

@Observable
@MainActor
final class NotesStore {
  var notes: [Note] = []
  var isLoading = false

  func load() async {
    isLoading = true
    defer { isLoading = false }
    notes = (try? await fetchNotes()) ?? []
  }

  func loadDetail(id: String) async -> Note? {
    try? await fetchNote(id: id)
  }
}

struct NotesListView: View {
  @State private var store = NotesStore()

  var body: some View {
    NavigationStack {
      Group {
        if store.isLoading && store.notes.isEmpty {
          ProgressView()
        } else {
          List(store.notes) { note in
            NavigationLink(value: note) {
              Text(note.title)
            }
          }
          .refreshable { await store.load() }
        }
      }
      .navigationDestination(for: Note.self) { note in
        NoteDetailView(note: note, store: store)
      }
      .task { await store.load() }
    }
  }
}

struct NoteDetailView: View {
  let note: Note
  var store: NotesStore
  @State private var bodyText: String?

  var body: some View {
    Group {
      if let bodyText {
        Text(bodyText)
      } else {
        ProgressView()
      }
    }
    .navigationTitle(note.title)
    .task(id: note.id) {
      bodyText = await store.loadDetail(id: note.id)?.body
    }
  }
}

把这一对当成同一套对照来读,不是两份教程:

  • Identity: keyExtractor / note.id vs path 上的 Identifiable / Hashable
  • Ownership: screen 上的 useState vs 用 @State 持有 @Observable store。
  • Load + cancel: useEffect + AbortController vs .task / .task(id:)
  • Refresh: RefreshControl vs .refreshable
  • Push: router.push("/notes/" + id) vs NavigationLink(value:) + navigationDestination

SwiftUI 的 detail 仍然会 fetch,即使 list 里已经有一份 Note。这是故意的。List row 不是完整 document。RN 的 detail 也一样。把整份 Note 当作 navigation value 传过去,是为了 title 的方便,不是 cache policy。



14. Analogy 在哪里破裂

上面的对照很有用,直到它们被当成等式。

View 是 value。Function component 是作用在 Fiber 上的函数。 重建 struct 不是 remount。@State 能活下来是因为 identity,不是因为 struct 长寿。如果你想着「component instance」,你会跟 copies 打架,也会读错 body

没有 Yoga,也没有 StyleSheet Proposed sizes 与 modifier 顺序取代 flexbox 与 style objects。Spacer 不是 flex: 1。Safe area 的默认是反过来的。

没有 JS thread,也没有 OTA JavaScript。 Reanimated 存在是因为 Hermes 太晚。SwiftUI gestures 从 UI process 起步;你仍然可以 stall body。一个新的 SwiftUI screen 是一份新的 binary。EAS Update 替换不了 NotesListView。Store build 就是 那棵 tree。这是 Expo 在固定 native 契约里做 JS-shaped updates 的反面。

Navigation 不是一棵 URL tree。 expo-router 让 file routes 成为产品能力。NavigationStack 让一条 value path 成为产品能力。你可以在上面建 URLs。Framework 不是从那里起步的。

async/await 不是 event loop。 单线程 JS 不能对同一个 object 的两次 mutations 产生 race。Swift 可以。Store 上的 @MainActor 不是迂腐。.task 的 cancellation 是 identity 形状的,不是 Fiber 形状的。

.onAppear 不是 mount。 Lazy lists、sheets 与 identity 变化会比 screen component 上的 useEffect([]) 更频繁地调用它。

读 sample 的第一遍时留着这套 analogy。当一个 view 重置 state、fetch 两次,或 animate 错的 row 时,丢掉它。问题那时变成 SwiftUI 的问题:identity 是什么、body 读了什么、哪个 actor 拥有这次 mutation? 不是「哪条 Fiber commit 了」。