一、本课目标
学完这一课,你要建立六个认知:
- 列表渲染的本质:列表是"状态数组"到"UI 描述数组"的映射。
- key 的作用:key 是列表元素的身份标识,帮助框架在对比新旧描述时正确匹配元素。
- key 的选择:稳定 id > 唯一业务字段 > index。
- key 用 index 的隐患:列表重排时,框架按位置复用元素,导致内部状态错乱。
- 列表重排时的状态保持:key 决定了哪些元素的状态被保留,哪些被重建。
- 列表性能优化: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 的过程:
- 遍历新描述数组的每个元素
- 查找旧描述数组中是否有相同 key 的元素
- 如果有 → 复用该元素的实例和状态
- 如果没有 → 创建新元素,初始化状态
- 旧描述数组中未被匹配的元素 → 销毁
所以 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、如何组合多个条件、如何避免条件渲染的陷阱。