【现代声明式UI学与练】第4课 列表渲染与 key——如何高效渲染列表、key 的作用、列表重排时的状态保持

一、本课目标

学完这一课,你要建立六个认知:

  1. 列表渲染的本质:列表是"状态数组"到"UI 描述数组"的映射。
  2. key 的作用:key 是列表元素的身份标识,帮助框架在对比新旧描述时正确匹配元素。
  3. key 的选择:稳定 id > 唯一业务字段 > index。
  4. key 用 index 的隐患:列表重排时,框架按位置复用元素,导致内部状态错乱。
  5. 列表重排时的状态保持:key 决定了哪些元素的状态被保留,哪些被重建。
  6. 列表性能优化:Lazy 容器、稳定 key、避免内联对象。

本课核心线索:

txt 复制代码
视图 = 函数(状态)
  ↓
列表 = 函数(数组状态)
  ↓
数组状态 → 映射为 UI 描述数组
  ↓
框架对比新旧描述数组
  ↓
key 决定元素身份 → 决定复用还是重建
  ↓
稳定 key → 状态正确保持
  ↓
不稳定 key → 状态错乱

二、列表渲染的本质

2.1 一句话说透

列表渲染就是"数组状态到 UI 描述数组的映射":

txt 复制代码
列表 = 函数(数组状态)
txt 复制代码
数组状态:[A, B, C]
  ↓ map
UI 描述数组:[<Item A>, <Item B>, <Item C>]

关键理解: 列表不是"循环创建视图",而是"把数组状态映射为 UI 描述数组"。你只描述"每一项对应什么 UI",框架负责对比新旧描述数组,更新真实界面。

2.2 列表渲染的基本写法

React Native:

tsx 复制代码
// TSX (React Native)
function TodoList() {
  // 状态:数组
  const [todos, setTodos] = useState([
    { id: 1, text: '学习声明式UI' },
    { id: 2, text: '写代码' },
    { id: 3, text: '复习' },
  ])

  // 函数返回 UI 描述:数组状态映射为 UI 描述数组
  return (
    <View>
      {todos.map(todo => (
        // key:元素身份标识
        <Text key={todo.id}>{todo.text}</Text>
      ))}
    </View>
  )
}

SwiftUI:

swift 复制代码
// Swift (SwiftUI)
struct TodoList: View {
    // 状态:数组
    @State private var todos = [
        Todo(id: 1, text: "学习声明式UI"),
        Todo(id: 2, text: "写代码"),
        Todo(id: 3, text: "复习"),
    ]

    // body 返回 UI 描述:数组状态映射为 UI 描述数组
    var body: some View {
        VStack {
            ForEach(todos) { todo in
                // id 由 Identifiable 协议提供
                Text(todo.text)
            }
        }
    }
}

struct Todo: Identifiable {
    let id: Int
    let text: String
}

ArkUI:

ts 复制代码
// ArkTS (ArkUI)
@Component
struct TodoList {
  // 状态:数组
  @State todos: Todo[] = [
    { id: 1, text: '学习声明式UI' },
    { id: 2, text: '写代码' },
    { id: 3, text: '复习' },
  ]

  // build 返回 UI 描述:数组状态映射为 UI 描述数组
  build() {
    Column() {
      ForEach(this.todos, (todo: Todo) => {
        // keyGenerator 提供元素身份
        Text(todo.text)
      }, (todo: Todo) => todo.id.toString())
    }
  }
}

Compose:

kotlin 复制代码
// Kotlin (Jetpack Compose)
@Composable
fun TodoList() {
    // 状态:数组
    val todos = remember {
        mutableStateListOf(
            Todo(1, "学习声明式UI"),
            Todo(2, "写代码"),
            Todo(3, "复习"),
        )
    }

    // Composable 函数返回 UI 描述:数组状态映射为 UI 描述数组
    Column {
        todos.forEach { todo ->
            // key:元素身份标识
            key(todo.id) {
                Text(todo.text)
            }
        }
    }
}

Flutter:

dart 复制代码
// Dart (Flutter)
class _TodoListState extends State<TodoList> {
  // 状态:数组
  List<Todo> todos = [
    Todo(id: 1, text: '学习声明式UI'),
    Todo(id: 2, text: '写代码'),
    Todo(id: 3, text: '复习'),
  ];

  @override
  Widget build(BuildContext context) {
    // build 返回 UI 描述:数组状态映射为 UI 描述数组
    return Column(
      children: todos.map((todo) => Text(
        todo.text,
        key: ValueKey(todo.id),  // key:元素身份标识
      )).toList(),
    );
  }
}

2.3 列表渲染的三层含义

第一层:列表是状态的映射。

数组状态中的每一项,映射为一个 UI 描述。状态变化,映射结果变化。

txt 复制代码
[A, B, C] → [<Item A>, <Item B>, <Item C>]

第二层:列表是递归的。

列表中的每一项,可能又是一个列表。列表渲染是递归的。

txt 复制代码
分组列表:
  [组1, 组2, 组3]
    → 组1: [项1, 项2]
    → 组2: [项3, 项4]
    → 组3: [项5, 项6]

第三层:列表需要身份。

框架对比新旧描述数组时,需要知道"哪个旧元素对应哪个新元素"。这个身份就是 key。

txt 复制代码
旧:[<A>, <B>, <C>]
新:[<B>, <A>, <C>]
  ↓ 没有 key,框架不知道谁是谁
  ↓ 有 key,框架知道 B 从位置 1 移到位置 0

2.4 各框架列表渲染对照

框架 列表 API key 机制

React Native array.map() key prop

SwiftUI ForEach Identifiable 或 id:

ArkUI ForEach keyGenerator

Compose forEach + key() key 函数

Flutter map().toList() Key 对象

关键结论: 五种框架的列表渲染本质相同------数组状态映射为 UI 描述数组,key 标识元素身份。


三、key 的作用

3.1 key 是元素身份标识

key 的作用:在框架对比新旧描述数组时,标识每个元素的身份。

txt 复制代码
旧描述数组:[<Item key=A>, <Item key=B>, <Item key=C>]
新描述数组:[<Item key=B>, <Item key=A>, <Item key=C>]
  ↓ 框架对比
  → key=B 从位置 1 移到位置 0
  → key=A 从位置 0 移到位置 1
  → key=C 位置不变
  ↓
  框架复用所有元素,只调整位置

没有 key:

txt 复制代码
旧描述数组:[<Item>, <Item>, <Item>]
新描述数组:[<Item>, <Item>, <Item>]
  ↓ 框架对比
  → 框架按位置匹配:
    位置 0 的旧元素 ↔ 位置 0 的新元素
    位置 1 的旧元素 ↔ 位置 1 的新元素
    位置 2 的旧元素 ↔ 位置 2 的新元素
  ↓
  框架认为所有元素都没变,只是内容变了
  实际上元素已经换了,内部状态错乱

3.2 key 决定复用还是重建

框架的 diff 策略:

txt 复制代码
对于列表中的每个新元素:
  1. 查找旧描述数组中是否有相同 key 的元素
  2. 如果有 → 复用该元素(保留内部状态),更新属性
  3. 如果没有 → 创建新元素
  4. 旧描述数组中未被匹配的元素 → 销毁

对于旧描述数组中的每个元素:
  如果新描述数组中没有相同 key 的元素 → 销毁

示例:

txt 复制代码
旧:[A, B, C]
新:[B, A, D]

diff:
  A:旧有新有 → 复用,移动位置
  B:旧有新有 → 复用,移动位置
  C:旧有新无 → 销毁
  D:旧无新有 → 创建

3.3 key 与状态保持

key 决定了哪些元素的状态被保留,哪些被重建。

swift 复制代码
// Swift (SwiftUI)
// 示例:带输入框的列表
struct ItemRow: View {
    let item: Item
    @State private var text = ""  // 内部状态

    var body: some View {
        HStack {
            Text(item.name)
            TextField("输入", text: $text)
        }
    }
}

struct ItemList: View {
    @State private var items = [
        Item(id: 1, name: "苹果"),
        Item(id: 2, name: "香蕉"),
        Item(id: 3, name: "橙子"),
    ]

    var body: some View {
        VStack {
            // 使用 id 作为 key
            ForEach(items) { item in
                ItemRow(item: item)
            }

            Button("删除第一个") {
                items.removeFirst()
            }
        }
    }
}

行为分析:

· 使用 item.id 作为 key:删除"苹果"后,"香蕉"和"橙子"的 ItemRow 被复用,text 状态保留。

· 使用 index 作为 key:删除"苹果"后,位置 0 的 ItemRow 被复用但内容变成"香蕉",text 状态错乱------原来"苹果"的输入框内容现在显示在"香蕉"上。

3.4 各框架 key 对照

React Native:

tsx 复制代码
// TSX (React Native)
{todos.map(todo => (
  <Text key={todo.id}>{todo.text}</Text>
))}
// key 是特殊的 prop

SwiftUI:

swift 复制代码
// Swift (SwiftUI)
ForEach(todos) { todo in
  Text(todo.text)
}
// 需要 Todo 实现 Identifiable 协议

// 或者手动指定 id
ForEach(todos, id: \.id) { todo in
  Text(todo.text)
}

ArkUI:

ts 复制代码
// ArkTS (ArkUI)
ForEach(this.todos, (todo: Todo) => {
  Text(todo.text)
}, (todo: Todo) => todo.id.toString())
// 第三个参数是 keyGenerator

Compose:

kotlin 复制代码
// Kotlin (Jetpack Compose)
todos.forEach { todo ->
  key(todo.id) {
    Text(todo.text)
  }
}
// key 是一个 Composable 函数

Flutter:

dart 复制代码
// Dart (Flutter)
todos.map((todo) => Text(
  todo.text,
  key: ValueKey(todo.id),
)).toList()
// key 是 Widget 的属性

四、key 的选择

4.1 key 的三个优先级

txt 复制代码
第一优先:稳定 id
  → 数据库 id、UUID、服务端返回的唯一标识
  → 最稳定,最适合作为 key

第二优先:唯一业务字段
  → 用户名、邮箱、手机号
  → 在业务上唯一,但可能变化

第三优先:index
  → 数组索引
  → 只在列表不会重排、不会增删时使用

4.2 稳定 id 作为 key

最佳实践:使用稳定 id。

tsx 复制代码
// TSX (React Native)
// 数据模型
interface Todo {
  id: string      // 稳定 id
  text: string
  done: boolean
}

// 渲染
{todos.map(todo => (
  <TodoItem key={todo.id} todo={todo} />
))}

为什么稳定 id 最好?

· 不随列表重排变化

· 不随数据更新变化

· 框架可以准确匹配新旧元素

· 元素状态正确保持

4.3 唯一业务字段作为 key

次优选择:使用唯一业务字段。

tsx 复制代码
// TSX (React Native)
// 如果没有 id,用唯一业务字段
{users.map(user => (
  <UserItem key={user.email} user={user} />
))}

风险: 业务字段可能变化(用户改了邮箱),导致 key 变化,元素被重建。

4.4 index 作为 key

不推荐,但有时可用。

tsx 复制代码
// TSX (React Native)
// 使用 index 作为 key
{todos.map((todo, index) => (
  <TodoItem key={index} todo={todo} />
))}

什么时候可以用 index?

· 列表是静态的,不会重排

· 列表不会增删

· 列表项没有内部状态

什么时候不能用 index?

· 列表会重排

· 列表会增删

· 列表项有内部状态(输入框、动画、展开状态)

4.5 key 选择的决策树

txt 复制代码
有稳定 id 吗?
  → 有 → 用 id
  → 没有 → 有唯一业务字段吗?
    → 有 → 用唯一业务字段
    → 没有 → 列表会重排或增删吗?
      → 会 → 生成稳定 id(UUID)
      → 不会 → 可以用 index

五、key 用 index 的隐患

5.1 经典 bug:删除第一项后输入框内容错乱

场景: 带输入框的待办列表,用 index 作为 key。

tsx 复制代码
// TSX (React Native)
function TodoList() {
  const [todos, setTodos] = useState([
    { text: '学习' },
    { text: '写代码' },
    { text: '复习' },
  ])

  const removeFirst = () => {
    setTodos(todos.slice(1))
  }

  return (
    <View>
      {todos.map((todo, index) => (
        <TodoRow key={index} todo={todo} />
      ))}
      <Button title="删除第一项" onPress={removeFirst} />
    </View>
  )
}

function TodoRow({ todo }) {
  const [input, setInput] = useState('')
  return (
    <View>
      <Text>{todo.text}</Text>
      <TextInput value={input} onChangeText={setInput} />
    </View>
  )
}

bug 复现:

txt 复制代码
初始状态:
  位置 0:key=0, todo="学习", input="用户输入A"
  位置 1:key=1, todo="写代码", input="用户输入B"
  位置 2:key=2, todo="复习", input="用户输入C"

点击"删除第一项":
  新数组:[{text: '写代码'}, {text: '复习'}]

框架对比:
  新位置 0:key=0 → 旧位置 0 有 key=0 → 复用
    但内容变成"写代码"
    input 状态保留"用户输入A"  ← BUG!
  新位置 1:key=1 → 旧位置 1 有 key=1 → 复用
    但内容变成"复习"
    input 状态保留"用户输入B"  ← BUG!
  旧位置 2:key=2 → 新数组没有 key=2 → 销毁

结果: 用户看到的是:

· "写代码"的输入框里有"用户输入A"

· "复习"的输入框里有"用户输入B"

· "用户输入C"丢失

5.2 正确做法:使用稳定 id

tsx 复制代码
// TSX (React Native)
function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: '学习' },
    { id: 2, text: '写代码' },
    { id: 3, text: '复习' },
  ])

  const removeFirst = () => {
    setTodos(todos.slice(1))
  }

  return (
    <View>
      {todos.map(todo => (
        <TodoRow key={todo.id} todo={todo} />
      ))}
      <Button title="删除第一项" onPress={removeFirst} />
    </View>
  )
}

正确行为:

txt 复制代码
初始状态:
  位置 0:key=1, todo="学习", input="用户输入A"
  位置 1:key=2, todo="写代码", input="用户输入B"
  位置 2:key=3, todo="复习", input="用户输入C"

点击"删除第一项":
  新数组:[{id: 2, text: '写代码'}, {id: 3, text: '复习'}]

框架对比:
  key=1 → 新数组没有 → 销毁(input="用户输入A" 一起销毁)
  key=2 → 新数组有 → 复用,移到位置 0,input="用户输入B" 保留
  key=3 → 新数组有 → 复用,移到位置 1,input="用户输入C" 保留

结果正确: "写代码"的输入框里是"用户输入B","复习"的输入框里是"用户输入C"。

5.3 其他 index 隐患场景

场景一:列表重排。

txt 复制代码
旧:[A, B, C]
新:[C, A, B]
  ↓ 用 index 作为 key
  框架认为:位置 0 还是 A,位置 1 还是 B,位置 2 还是 C
  实际上:位置 0 变成了 C,位置 1 变成了 A,位置 2 变成了 B
  结果:所有元素的内部状态都错乱

场景二:列表头部插入。

txt 复制代码
旧:[A, B, C]
新:[D, A, B, C]
  ↓ 用 index 作为 key
  框架认为:位置 0 还是 A(内容变了)
  实际上:位置 0 是 D
  结果:A 的内部状态现在在 D 上

场景三:列表排序。

txt 复制代码
旧:[A, B, C](按名称排序)
新:[C, B, A](按时间排序)
  ↓ 用 index 作为 key
  框架认为:位置 0 还是 A(内容变了)
  实际上:位置 0 是 C
  结果:所有元素的内部状态错乱

六、列表重排时的状态保持

6.1 状态保持的机制

框架如何决定"保留哪些状态"?

txt 复制代码
1. 框架对比新旧描述数组
2. 对于每个新元素,查找旧描述数组中是否有相同 key
3. 如果有 → 复用该元素的状态
4. 如果没有 → 创建新元素,初始化状态

关键: key 相同 → 状态保留;key 不同 → 状态重建。

6.2 状态保持的三种情况

情况一:元素位置不变。

txt 复制代码
旧:[A, B, C]
新:[A, B, C]
  → 所有元素 key 不变,位置不变
  → 所有状态保留

情况二:元素位置变化。

txt 复制代码
旧:[A, B, C]
新:[C, A, B]
  → 所有元素 key 不变,位置变化
  → 所有状态保留,只是位置调整

情况三:元素增删。

txt 复制代码
旧:[A, B, C]
新:[A, C]
  → B 被删除
  → A 和 C 的状态保留
  → B 的状态销毁

6.3 状态保持的实战示例

需求: 可展开的列表项。

swift 复制代码
// Swift (SwiftUI)
struct ExpandableRow: View {
    let item: Item
    @State private var isExpanded = false  // 内部状态

    var body: some View {
        VStack {
            HStack {
                Text(item.name)
                Spacer()
                Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
            }
            .onTapGesture {
                isExpanded.toggle()
            }

            if isExpanded {
                Text(item.detail)
            }
        }
    }
}

struct ItemList: View {
    @State private var items = [
        Item(id: 1, name: "苹果", detail: "红色水果"),
        Item(id: 2, name: "香蕉", detail: "黄色水果"),
        Item(id: 3, name: "橙子", detail: "橙色水果"),
    ]

    var body: some View {
        ScrollView {
            VStack {
                // 使用 id 作为 key,状态正确保持
                ForEach(items) { item in
                    ExpandableRow(item: item)
                }

                Button("倒序") {
                    items.reverse()
                }
            }
        }
    }
}

行为分析:

· 用户展开"苹果",看到详情

· 点击"倒序",列表变成 橙子, 香蕉, 苹果

· 使用 item.id 作为 key:"苹果"的展开状态跟着"苹果"走,仍然展开

· 使用 index 作为 key:位置 0 的展开状态留在位置 0,现在是"橙子"被展开------状态错乱

6.4 各框架状态保持对照

React Native:

tsx 复制代码
// TSX (React Native)
// key 相同 → 组件实例复用,状态保留
{todos.map(todo => (
  <TodoRow key={todo.id} todo={todo} />
))}

SwiftUI:

swift 复制代码
// Swift (SwiftUI)
// id 相同 → 视图实例复用,@State 保留
ForEach(items) { item in
  ExpandableRow(item: item)
}

ArkUI:

ts 复制代码
// ArkTS (ArkUI)
// keyGenerator 返回相同 key → 组件实例复用
ForEach(this.items, (item: Item) => {
  ExpandableRow({ item: item })
}, (item: Item) => item.id.toString())

Compose:

kotlin 复制代码
// Kotlin (Jetpack Compose)
// key 相同 → remember 状态保留
items.forEach { item ->
  key(item.id) {
    ExpandableRow(item)
  }
}

Flutter:

dart 复制代码
// Dart (Flutter)
// Key 相同 → Element 复用,State 保留
items.map((item) => ExpandableRow(
  key: ValueKey(item.id),
  item: item,
)).toList()

七、列表性能优化

7.1 使用 Lazy 容器

问题: 普通容器一次性渲染所有子视图。

swift 复制代码
// Swift (SwiftUI)
// 不推荐:一次性渲染 1000 项
ScrollView {
    VStack {
        ForEach(0..<1000) { index in
            Text("Item \(index)")
        }
    }
}

解决: 使用 Lazy 容器,只渲染可见区域。

swift 复制代码
// Swift (SwiftUI)
// 推荐:只渲染可见项
ScrollView {
    LazyVStack {
        ForEach(0..<1000) { index in
            Text("Item \(index)")
        }
    }
}

各框架 Lazy 容器对照:

框架 Lazy 容器 普通容器

React Native FlatList、SectionList ScrollView

SwiftUI LazyVStack、LazyHStack、List VStack、HStack

ArkUI LazyForEach、List ForEach、Column

Compose LazyColumn、LazyRow Column、Row

Flutter ListView.builder Column

7.2 稳定 key 提升性能

稳定 key 不仅保证正确性,还提升性能。

txt 复制代码
不稳定 key(index):
  列表重排 → 框架认为所有元素都变了 → 重建所有元素
  → 状态丢失 + 性能浪费

稳定 key(id):
  列表重排 → 框架知道哪些元素移动了 → 复用元素
  → 状态保留 + 性能优化

7.3 避免内联对象

问题: 每次渲染创建新对象,导致子组件不必要的重渲染。

tsx 复制代码
// TSX (React Native)
// 不推荐:每次渲染创建新对象
<FlatList
  data={todos}
  renderItem={({ item }) => <TodoRow todo={item} />}
  keyExtractor={item => item.id}
  // 每次渲染创建新的 style 对象
  contentContainerStyle={{ padding: 16 }}
/>

解决: 把内联对象提取到组件外部。

tsx 复制代码
// TSX (React Native)
// 推荐:样式对象提取到组件外部
const styles = {
  contentContainer: { padding: 16 }
}

function TodoListView({ todos }) {
  return (
    <FlatList
      data={todos}
      renderItem={({ item }) => <TodoRow todo={item} />}
      keyExtractor={item => item.id}
      contentContainerStyle={styles.contentContainer}
    />
  )
}

7.4 避免内联函数

问题: 每次渲染创建新函数,导致子组件不必要的重渲染。

tsx 复制代码
// TSX (React Native)
// 不推荐:每次渲染创建新函数
<FlatList
  data={todos}
  renderItem={({ item }) => <TodoRow todo={item} />}
  keyExtractor={item => item.id}  // 每次渲染创建新函数
/>

解决: 把函数提取到组件外部,或用 useCallback。

tsx 复制代码
// TSX (React Native)
// 推荐:keyExtractor 提取到组件外部
const keyExtractor = (item: Todo) => item.id

function TodoListView({ todos }) {
  return (
    <FlatList
      data={todos}
      renderItem={({ item }) => <TodoRow todo={item} />}
      keyExtractor={keyExtractor}
    />
  )
}

7.5 列表性能检查清单

txt 复制代码
列表性能检查清单:

1. 是否使用了 Lazy 容器?
   → 长列表用 FlatList、LazyVStack、LazyColumn、ListView.builder

2. 是否使用了稳定 key?
   → 用 id,不用 index

3. 是否避免了内联对象?
   → 样式对象提取到组件外部

4. 是否避免了内联函数?
   → 回调函数提取到组件外部,或用 useCallback

5. 是否避免了不必要的重渲染?
   → 用 React.memo、EquatableView、key 优化

6. 列表项是否有内部状态?
   → 有 → 必须用稳定 key
   → 没有 → index 也可接受,但仍推荐稳定 key

7.6 各框架性能优化对照

框架 Lazy 容器 key 优化 重渲染优化

React Native FlatList keyExtractor React.memo

SwiftUI LazyVStack、List Identifiable EquatableView

ArkUI LazyForEach、List keyGenerator @Reusable

Compose LazyColumn key() remember

Flutter ListView.builder ValueKey const、RepaintBoundary


八、常见误区

8.1 用 index 作为 key

tsx 复制代码
// TSX (React Native)
// 错误:用 index 作为 key
{todos.map((todo, index) => (
  <TodoRow key={index} todo={todo} />
))}

// 正确:用稳定 id 作为 key
{todos.map(todo => (
  <TodoRow key={todo.id} todo={todo} />
))}

后果: 列表重排或增删时,元素状态错乱。

8.2 用随机数作为 key

tsx 复制代码
// TSX (React Native)
// 错误:用随机数作为 key
{todos.map(todo => (
  <TodoRow key={Math.random()} todo={todo} />
))}

后果: 每次渲染 key 都变化,框架认为所有元素都是新的,全部重建。状态丢失 + 性能灾难。

8.3 用数组下标作为 key 的变体

tsx 复制代码
// TSX (React Native)
// 错误:用 todo.text 作为 key(业务字段可能重复)
{todos.map(todo => (
  <TodoRow key={todo.text} todo={todo} />
))}

后果: 如果两个 todo 的 text 相同,key 冲突,框架行为未定义。

8.4 忘记 key

tsx 复制代码
// TSX (React Native)
// 错误:忘记 key
{todos.map(todo => (
  <TodoRow todo={todo} />
))}

后果: React 会发出警告,框架按位置匹配元素,可能状态错乱。

8.5 在列表项内部使用 index

tsx 复制代码
// TSX (React Native)
// 错误:在列表项内部使用 index 作为 key
function TodoList({ todos }) {
  return (
    <View>
      {todos.map((todo, index) => (
        <View key={index}>
          {todo.items.map((item, i) => (
            <Text key={i}>{item}</Text>  // 嵌套列表也用 index
          ))}
        </View>
      ))}
    </View>
  )
}

后果: 嵌套列表同样有 index 隐患。

8.6 认为 key 只在列表中使用

真相: key 的概念不限于列表。任何"数组状态映射为 UI 描述数组"的场景都需要 key。

tsx 复制代码
// TSX (React Native)
// 条件渲染的多个分支,也可以用 key 区分
{isLoading ? (
  <Spinner key="loading" />
) : (
  <Content key="content" />
)}

九、习题(15 道)

题目 1(选择题)

列表渲染的本质是什么?

A. 循环创建视图

B. 数组状态映射为 UI 描述数组

C. 手动操作 DOM

D. 命令式更新列表项

参考答案:B

解读: 列表渲染是"数组状态到 UI 描述数组的映射"。你只描述"每一项对应什么 UI",框架负责对比新旧描述数组,更新真实界面。A 是命令式思维,C、D 都是命令式。


题目 2(判断题)

key 的作用是帮助框架在对比新旧描述数组时,正确匹配元素身份。

参考答案:正确

解读: key 是元素身份标识。框架对比新旧描述数组时,通过 key 判断"哪个旧元素对应哪个新元素",从而决定复用还是重建。


题目 3(填空题)

key 选择的三个优先级是:、、______。

参考答案:稳定 id;唯一业务字段;index

解读: 稳定 id 最稳定,最适合作为 key。唯一业务字段次之,但可能变化。index 只在列表不会重排、不会增删时使用。


题目 4(简答题)

为什么用 index 作为 key 会导致状态错乱?请举一个具体场景。

参考答案: 用 index 作为 key 时,框架按位置匹配元素。当列表重排或增删时,位置对应的元素变了,但框架认为还是同一个元素,导致内部状态错乱。

具体场景:带输入框的待办列表,用 index 作为 key。删除第一项后:

· 位置 0 的输入框内容留在位置 0,但内容变成了原来的第二项

· 用户看到"写代码"的输入框里有"用户输入A"

正确做法是用稳定 id 作为 key。


题目 5(代码分析题)

下面代码有什么问题?如何修正?

tsx 复制代码
// TSX (React Native)
function TodoList() {
  const [todos, setTodos] = useState([
    { text: '学习' },
    { text: '写代码' },
    { text: '复习' },
  ])

  return (
    <View>
      {todos.map((todo, index) => (
        <TodoRow key={index} todo={todo} />
      ))}
    </View>
  )
}

参考答案: 用 index 作为 key,当列表重排或增删时,元素状态错乱。

修正: 给每个 todo 添加稳定 id,用 id 作为 key。

tsx 复制代码
// TSX (React Native)
function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: '学习' },
    { id: 2, text: '写代码' },
    { id: 3, text: '复习' },
  ])

  return (
    <View>
      {todos.map(todo => (
        <TodoRow key={todo.id} todo={todo} />
      ))}
    </View>
  )
}

题目 6(代码改错题)

下面代码有什么问题?如何修正?

tsx 复制代码
// TSX (React Native)
{todos.map(todo => (
  <TodoRow key={Math.random()} todo={todo} />
))}

参考答案: 用随机数作为 key,每次渲染 key 都变化,框架认为所有元素都是新的,全部重建。状态丢失 + 性能灾难。

修正: 用稳定 id 作为 key。

tsx 复制代码
// TSX (React Native)
{todos.map(todo => (
  <TodoRow key={todo.id} todo={todo} />
))}

题目 7(跨框架对比题)

写出 React Native、SwiftUI、Compose 中列表渲染和 key 的核心写法。

参考答案:

React Native:

tsx 复制代码
// TSX (React Native)
{todos.map(todo => (
  <TodoRow key={todo.id} todo={todo} />
))}

SwiftUI:

swift 复制代码
// Swift (SwiftUI)
ForEach(todos) { todo in
  TodoRow(todo: todo)
}
// 需要 Todo 实现 Identifiable

Compose:

kotlin 复制代码
// Kotlin (Jetpack Compose)
todos.forEach { todo ->
  key(todo.id) {
    TodoRow(todo)
  }
}

解读: 三种框架的列表渲染本质相同------数组状态映射为 UI 描述数组,key 标识元素身份。差异只在 API 命名。


题目 8(选择题)

以下哪种 key 最稳定?

A. index

B. Math.random()

C. todo.id(数据库 id)

D. todo.text(业务字段)

参考答案:C

解读: 数据库 id 最稳定,不随列表重排、数据更新变化。A 会随位置变化,B 每次都变,D 可能重复或变化。


题目 9(判断题)

列表项没有内部状态时,用 index 作为 key 是安全的。

参考答案:正确

解读: 如果列表项没有内部状态(没有输入框、动画、展开状态),用 index 作为 key 不会导致状态错乱。但列表重排时,框架会重建元素而非复用,性能有浪费。所以仍推荐用稳定 id。


题目 10(填空题)

SwiftUI 中 ForEach 需要元素实现 ______ 协议,或手动指定 ______ 参数。

参考答案:Identifiable;id

解读: SwiftUI 的 ForEach 需要知道元素身份。如果元素实现了 Identifiable 协议,直接 ForEach(items) 即可。否则需要手动指定 id 参数,如 ForEach(items, id: .id)。


题目 11(简答题)

为什么列表重排时,稳定 key 能提升性能?

参考答案: 稳定 key 让框架知道哪些元素移动了,哪些元素没变。

· 稳定 key:框架知道元素 A 从位置 0 移到位置 2,只需调整位置,不需要重建。

· 不稳定 key(index):框架认为位置 0 的元素还是位置 0 的元素,内容变了就重建,导致所有元素重建。

稳定 key 避免了不必要的重建,提升性能,同时保留元素内部状态。


题目 12(代码分析题)

下面代码有什么问题?如何修正?

tsx 复制代码
// TSX (React Native)
function TodoList({ todos }) {
  return (
    <View>
      {todos.map((todo, index) => (
        <View key={index}>
          {todo.items.map((item, i) => (
            <Text key={i}>{item}</Text>
          ))}
        </View>
      ))}
    </View>
  )
}

参考答案: 嵌套列表也用了 index 作为 key,同样有状态错乱隐患。

修正: 给每层列表都使用稳定 id。

tsx 复制代码
// TSX (React Native)
function TodoList({ todos }) {
  return (
    <View>
      {todos.map(todo => (
        <View key={todo.id}>
          {todo.items.map(item => (
            <Text key={item.id}>{item.text}</Text>
          ))}
        </View>
      ))}
    </View>
  )
}

题目 13(设计题)

设计一个可展开的列表,要求:

· 每项可以展开/折叠

· 列表可以倒序

· 展开状态在倒序后正确保持

写出状态设计和 key 选择。

参考答案:

tsx 复制代码
// TSX (React Native)
function ExpandableList() {
  const [items, setItems] = useState([
    { id: 1, name: '苹果', detail: '红色水果' },
    { id: 2, name: '香蕉', detail: '黄色水果' },
    { id: 3, name: '橙子', detail: '橙色水果' },
  ])

  const reverse = () => {
    setItems([...items].reverse())
  }

  return (
    <View>
      {items.map(item => (
        // 用 id 作为 key,展开状态跟着 item 走
        <ExpandableRow key={item.id} item={item} />
      ))}
      <Button title="倒序" onPress={reverse} />
    </View>
  )
}

function ExpandableRow({ item }) {
  const [isExpanded, setIsExpanded] = useState(false)

  return (
    <View>
      <TouchableOpacity onPress={() => setIsExpanded(!isExpanded)}>
        <Text>{item.name}</Text>
      </TouchableOpacity>
      {isExpanded && <Text>{item.detail}</Text>}
    </View>
  )
}

状态:items、每个 ExpandableRow 的 isExpanded

key:item.id

解读: 用稳定 id 作为 key,倒序后展开状态跟着 item 走。用 index 作为 key,倒序后位置 0 的展开状态留在位置 0,现在是另一个 item 被展开------状态错乱。


题目 14(设计题)

设计一个聊天消息列表,要求:

· 消息按时间排序

· 新消息插入到顶部

· 每条消息有"已读/未读"状态

写出状态设计、key 选择和性能优化。

参考答案:

tsx 复制代码
// TSX (React Native)
function ChatList() {
  const [messages, setMessages] = useState([
    { id: 'uuid-1', text: '你好', read: true },
    { id: 'uuid-2', text: '在吗', read: false },
  ])

  const addMessage = (text) => {
    const newMessage = {
      id: generateUUID(),  // 稳定 id
      text,
      read: false,
    }
    setMessages([newMessage, ...messages])  // 插入到顶部
  }

  const keyExtractor = (item) => item.id  // 提取到组件外部

  return (
    <FlatList
      data={messages}
      keyExtractor={keyExtractor}  // 稳定 key
      renderItem={({ item }) => (
        <MessageRow message={item} />
      )}
    />
  )
}

状态:messages

key:message.id(UUID)

性能优化:FlatList(Lazy 容器)、keyExtractor 提取到外部

解读: 新消息插入到顶部时,用 index 作为 key 会导致所有消息的"已读/未读"状态错乱。用 UUID 作为 key,每条消息的状态跟着消息走,正确保持。


题目 15(思考题)

为什么说"key 决定了元素的状态保持"?请从框架 diff 的角度分析。

参考答案: 框架 diff 的核心是"对比新旧描述数组,找出差异"。

diff 的过程:

  1. 遍历新描述数组的每个元素
  2. 查找旧描述数组中是否有相同 key 的元素
  3. 如果有 → 复用该元素的实例和状态
  4. 如果没有 → 创建新元素,初始化状态
  5. 旧描述数组中未被匹配的元素 → 销毁

所以 key 是"元素身份"的标识:

· key 相同 → 框架认为这是同一个元素 → 复用状态

· key 不同 → 框架认为这是不同的元素 → 重建状态

具体例子:

txt 复制代码
旧:[{id: 1, text: "A"}, {id: 2, text: "B"}]
新:[{id: 2, text: "B"}, {id: 1, text: "A"}]

用 id 作为 key:
  id=1 → 旧有新有 → 复用状态,移动位置
  id=2 → 旧有新有 → 复用状态,移动位置
  结果:状态正确保持

用 index 作为 key:
  位置 0 → 旧有新有 → 复用状态(内容是 A→B,状态错乱)
  位置 1 → 旧有新有 → 复用状态(内容是 B→A,状态错乱)
  结果:状态错乱

终极结论: key 是元素身份的标识,决定了框架在 diff 时如何匹配新旧元素。稳定 key 保证状态正确保持,不稳定 key 导致状态错乱。


十、下节预告

第 5 课:条件渲染与组合------如何根据状态渲染不同 UI、如何组合多个条件、如何避免条件渲染的陷阱。

相关推荐
传奇开心果编程1 小时前
【Compose Multiplatform 跨端开发学与练】第5课 网络与数据层
android·网络·学习·ui·ios·kotlin·composer
yt004yt2 小时前
园区 VOC 绿岛集中治理项目,管网与监测系统设计要点
大数据·学习
一条破秋裤2 小时前
06_从设备读数据_字符设备read_write流程
学习
m4Rk_3 小时前
【论文阅读】Agent 记忆机制(92):EMPO²——让 Memory 从经验复用走向主动探索
论文阅读·人工智能·学习·开源·github
欣欣之王来了4 小时前
前端开发学习路线图:2026年Vue方向最新版
前端·学习·架构·项目·vue3教程
wdfk_prog4 小时前
LWIP教程 00:从网线上的电信号到 lwIP——PHY、MAC、DMA 与 `netif` 的完整边界
运维·网络·笔记·学习·ip
m0_738185824 小时前
Flutter 鸿蒙化实战:media_info 适配 OpenHarmony,媒体信息与缩略图
flutter·华为·harmonyos·鸿蒙·媒体
m0_734172425 小时前
Python用zip配对前先检查长度
python·学习
乌暮5 小时前
拆解网页的底层逻辑:用费曼学习法讲透 HTML 与 CSS
前端·css·学习·html