一、本课目标
学完这一课,你要建立四个认知:
- 声明式 UI 和命令式 UI 的根本区别,以及为什么前者成为现代 UI 的主流范式。
- 核心公式:UI = f(state),以及它背后的完整渲染管线。
- 写声明式 UI 时,你只改状态,不手动改界面------理解框架替你做了什么。
- 四条关键原则和八种常见误区,从第一天起就建立正确的肌肉记忆。
二、命令式 UI:一步步操作界面
2.1 什么是命令式
命令式编程的核心是:你写指令,程序执行。你告诉计算机"怎么做",每一步都要你安排。
用生活中的例子来类比:
命令式就像你亲自下厨。
你要先开火,再倒油,等油热了放葱姜,翻炒三下,放肉,加盐,加酱油,再翻炒,出锅。每一步你都要亲自操作。如果你忘了加盐,菜就淡了;如果你火开太大,菜就糊了。
在 UI 领域,命令式意味着:你持有界面控件的引用,手动调用它们的方法来改变界面。
2.2 一个完整的命令式例子
假设我们要做一个计数器:显示数字,点击加一。
javascript
// JavaScript
// 1. 创建状态
let count = 0
// 2. 创建界面元素
const container = document.createElement('div')
const label = document.createElement('span')
const button = document.createElement('button')
// 3. 初始化界面
label.textContent = `Count: ${count}`
button.textContent = '+1'
// 4. 定义更新函数------手动把状态同步到界面
function updateLabel() {
label.textContent = `Count: ${count}`
}
// 5. 绑定事件------改状态后手动调用更新
button.addEventListener('click', () => {
count += 1
updateLabel() // 必须手动同步界面
})
// 6. 挂载到页面
container.appendChild(label)
container.appendChild(button)
document.body.appendChild(container)
// 7. 初始化时调用一次
updateLabel()
这段代码的执行流程是:
txt
1. 创建状态 count = 0
2. 创建 label 和 button 对象
3. 定义 updateLabel,手动把 count 写进 label.textContent
4. 点击时:count 加 1,然后调用 updateLabel 同步界面
5. 初始化时调用一次 updateLabel
6. 挂载到页面
2.3 命令式的本质问题
命令式 UI 的根本问题是:状态和界面是两份独立的数据,需要你手动保持同步。
txt
count = 0 label.textContent = "Count: 0"
count = 1 label.textContent = "Count: 1"
count = 2 label.textContent = "Count: 2"
↑ ↑
状态源 界面表现
两者靠你手动同步
这带来四个深层问题:
问题一:同步遗漏
你新增了一个状态 isLoading,但忘记在某处更新按钮的 disabled 属性:
javascript
// JavaScript
// 假设 label 和 button 已在上文创建
let count = 0
let isLoading = false
function updateUI() {
label.textContent = `Count: ${count}`
// 忘记写 button.disabled = isLoading
}
button.addEventListener('click', () => {
isLoading = true
updateUI() // 按钮没有禁用,用户可以重复点击
fetchData().then(() => {
isLoading = false
count += 1
updateUI()
})
})
界面就和状态不一致了。这种 bug 在大型应用里非常常见,而且很难排查------因为代码看起来"没什么问题"。
问题二:同步顺序
如果 count 和 isLoading 有关联,你必须按正确顺序更新多个控件:
javascript
// JavaScript
// 假设 count、isLoading、button、label 都已定义
function updateUI() {
// 如果先启用按钮再更新文字,中间会有一个瞬间
// 按钮可用但文字还是旧的
button.disabled = isLoading
label.textContent = `Count: ${count}`
// 如果这两行顺序反了,用户可能看到不一致的状态
}
在复杂的 UI 中,同步顺序会变成一张依赖图,你必须在脑子里维护它。
问题三:同步爆炸
假设有 3 个状态(count、isLoading、error),5 个控件(label、button、spinner、errorText、retryButton),可能的同步路径是 3×5=15 条。
如果状态增加到 10 个,控件增加到 20 个,同步路径就是 200 条。组合爆炸。
问题四:难以推理
你无法只看代码就知道"界面现在应该长什么样"。因为界面取决于运行时一系列操作的历史:
javascript
// JavaScript
// 这段代码执行后,界面是什么样?
button.click() // count=1, isLoading=true
button.click() // 被忽略了?还是 count=2?
fetchData() // 什么时候 resolve?
你必须模拟整个执行过程才能知道结果。这在调试时非常痛苦。
2.4 命令式的适用场景
命令式并非一无是处。以下场景命令式更自然:
- 动画的逐帧控制:你需要精确控制每一帧的属性
- 画布/游戏循环:Canvas 2D、WebGL、游戏引擎
- 底层渲染引擎:浏览器内核、Skia、Core Animation
- 一次性脚本:数据迁移、批处理
- 需要手动控制渲染路径的场景:自定义动画引擎、性能剖析工具
但在"状态驱动的应用界面"这个场景,命令式的维护成本随规模指数上升。
三、声明式 UI:描述界面应该是什么样
3.1 什么是声明式
声明式编程的核心是:你描述结果,框架负责实现。你告诉计算机"是什么",不用安排每一步。
用生活中的例子来类比:
声明式就像点外卖。
你打开 App,选了一份宫保鸡丁,下单。你不需要知道厨师怎么开火、怎么切菜、怎么翻炒。你只描述了"我要一份宫保鸡丁",剩下的交给餐厅。如果餐厅换了厨师、换了灶台,你不需要改你的订单。
在 UI 领域,声明式意味着:你描述"在某个状态下,界面应该长什么样",框架负责把界面变成那样。
3.2 同一个计数器,声明式写法
jsx
// JSX
function Counter() {
const [count, setCount] = React.useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}
你描述的是:
当 count 是 0 时,按钮显示
Count: 0。当 count 是 1 时,按钮显示
Count: 1。点击时,把 count 加 1。
你没有写"找到按钮、修改文字"。你只写了"按钮的文字是 count"。
3.3 声明式的关键转变
从命令式到声明式,有一个关键的思维转变:
| 命令式思维 | 声明式思维 |
|---|---|
| 界面是对象,我操作它 | 界面是状态的投影,我描述它 |
| 我负责同步 | 框架负责同步 |
| 关注"怎么变" | 关注"是什么" |
| 代码是操作序列 | 代码是状态到 UI 的映射 |
| 调试要追踪执行历史 | 调试只需看当前状态 |
用一句话概括:
命令式:我一步步把界面改成我想要的样子。
声明式:我描述我想要的样子,框架去改。
3.4 声明式的代价与收益
声明式不是没有代价:
- 你需要学习框架的规则(Hooks 规则、状态装饰器、重组机制)
- 你需要理解 diff 和 key 的概念
- 极端性能场景需要手动优化
- 框架本身有运行时开销(虚拟树构建、diff)
但命令式也有开销(手动 DOM 操作、频繁重排重绘)。声明式通过批量更新和最小化 DOM 操作,很多场景反而更快。
声明式的收益:
- 状态和 UI 自动同步,不会遗漏
- 代码可预测,相同状态必然得到相同 UI
- 组件可组合,小单元拼成大界面
- 易于测试,渲染函数是纯函数
- 易于推理,看状态就知道界面
- 跨平台,同一套心智模型可用于 Web、移动端、桌面端
四、核心公式:UI = f(state)
4.1 公式的含义
txt
UI = f(state)
state:应用的输入,包括组件自身状态、父组件传入的 props、context 等f:渲染函数,把输入映射为 UI 描述UI:界面的描述,不是真实界面本身
说明: 严格来说,UI 由 state + props + context 共同决定。为简化表述,本课统一用
state指代所有输入。在组件树中,子组件的 UI 还取决于父组件传入的 props。
这个公式意味着:
- UI 由输入完全决定。 给定相同输入,渲染函数必然返回相同 UI。
- 输入是输入,UI 是输出。 不存在"输入没变但 UI 变了"的情况。
- 不需要手动同步。 输入变,重新执行 f,自然得到新 UI。
4.2 完整渲染管线
真实框架的渲染流程比公式复杂,但核心思想一致:
txt
┌─────────────────────────────────────────────────────────┐
│ 1. 状态变化 │
│ setCount(1) / count += 1 / setState │
│ │
│ 触发方式: │
│ - 用户事件(onClick、onChange) │
│ - 副作用回调(useEffect、LaunchedEffect) │
│ - 定时器、WebSocket、父组件传入新 props │
└──────────────────────────┬──────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 2. 框架标记需要重新渲染 │
│ React: schedule update │
│ SwiftUI: invalidate body │
│ ArkUI: mark dirty │
│ Compose: 标记为需要重组 │
│ │
│ 关键:框架不会立即渲染,而是批量调度 │
│ 多个状态变化可能合并为一次渲染 │
└──────────────────────────┬──────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 3. 重新执行渲染函数 │
│ render() / body / build() │
│ 得到新的 UI 描述(虚拟树/描述结构) │
│ │
│ 关键:渲染函数必须是纯的 │
│ 相同输入必须返回相同输出 │
│ 框架可能多次调用(严格模式、重组) │
└──────────────────────────┬──────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 4. Diff:新旧 UI 描述比较 │
│ 找出最小差异集 │
│ │
│ 策略: │
│ - 同层比较,不跨层移动 │
│ - 用 key 识别列表元素身份 │
│ - 类型不同直接替换 │
│ - 类型相同更新属性 │
└──────────────────────────┬──────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 5. Commit:把差异应用到真实界面 │
│ 只改需要改的节点,不动其他 │
│ │
│ React: 操作真实 DOM │
│ SwiftUI: 更新 Core Animation 层 │
│ ArkUI: 更新渲染树 │
│ Flutter: 更新 RenderObject 树 │
└─────────────────────────────────────────────────────────┘
4.3 为什么这个公式重要
因为它改变了你写代码的方式:
命令式:
txt
点击 → 改 count → 找到 label → 改 label.text → 找到 button → 改 button.disabled
声明式:
txt
点击 → 改 count → 重新执行 render → 框架 diff → 框架更新
你的责任从"操作界面"缩小到"改状态"。界面同步完全交给框架。
4.4 一个更贴切的例子:搜索框
假设我们要做一个搜索框:输入关键词,显示加载中,然后显示结果或错误。
命令式写法:
javascript
// JavaScript
let query = ''
let isLoading = false
let results = []
let error = null
let requestId = 0 // 用于处理竞态
const input = document.createElement('input')
const spinner = document.createElement('div')
const list = document.createElement('ul')
const errorText = document.createElement('div')
function updateUI() {
// 手动同步所有控件
spinner.style.display = isLoading ? 'block' : 'none'
errorText.textContent = error || ''
errorText.style.display = error ? 'block' : 'none'
list.innerHTML = ''
results.forEach(r => {
const li = document.createElement('li')
li.textContent = r
list.appendChild(li)
})
// 如果忘了上面任何一行,界面就不一致
}
input.addEventListener('input', (e) => {
query = e.target.value
isLoading = true
error = null
updateUI() // 手动同步
const currentId = ++requestId
fetch(`/api/search?q=${query}`)
.then(r => r.json())
.then(data => {
if (currentId !== requestId) return // 忽略过期请求
results = data
isLoading = false
updateUI() // 手动同步
})
.catch(err => {
if (currentId !== requestId) return
error = err.message
isLoading = false
updateUI() // 手动同步
})
})
每次状态变化,你都要记得调用 updateUI(),而且 updateUI() 必须处理所有状态的组合。如果有 4 个状态,理论上要处理 2^4=16 种组合。还要手动处理竞态------这是命令式代码里最容易出错的地方之一。
声明式写法:
jsx
// JSX
function Search() {
const [query, setQuery] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [results, setResults] = useState([])
const [error, setError] = useState(null)
useEffect(() => {
if (!query) return
let cancelled = false // 用于处理竞态
setIsLoading(true)
setError(null)
fetch(`/api/search?q=${query}`)
.then(r => r.json())
.then(data => {
if (cancelled) return
setResults(data)
setIsLoading(false)
})
.catch(err => {
if (cancelled) return
setError(err.message)
setIsLoading(false)
})
return () => { cancelled = true }
}, [query])
return (
<View>
<Input value={query} onChange={e => setQuery(e.target.value)} />
{isLoading && <Spinner />}
{error && <Text>{error}</Text>}
{!isLoading && !error && (
<List>
{results.map(r => <Text key={r}>{r}</Text>)}
</List>
)}
</View>
)
}
你只描述每种状态下界面应该是什么样。框架负责组合和更新。新增一个状态 hasMore,你只需要加一行 {hasMore && <LoadMore />},不需要修改任何同步逻辑。竞态处理也变成了局部的 cleanup 函数,而不是分散在各处的手动判断。
五、跨框架对比:同一思想的六种表达
5.1 React
jsx
// JSX
function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}
- 组件:函数
- 状态:
useState - 渲染:函数体执行,返回 JSX
- 更新:调用
setCount,React 重新执行组件函数 - 描述:虚拟 DOM
5.2 SwiftUI
swift
// Swift
struct CounterView: View {
@State private var count = 0
var body: some View {
Button("Count: \(count)") {
count += 1
}
}
}
- 组件:结构体
- 状态:
@State - 渲染:读取
body计算属性 - 更新:改
count,SwiftUI 重新计算body - 描述:View 树
5.3 ArkUI
ts
// ArkTS (ArkUI)
@Entry
@Component
struct Counter {
@State count: number = 0
build() {
Button(`Count: ${this.count}`)
.onClick(() => {
this.count += 1
})
}
}
- 组件:
@Component struct - 状态:
@State - 渲染:
build() - 更新:改
this.count,ArkUI 标记脏,重新执行build - 描述:组件树
5.4 Jetpack Compose
kotlin
// Kotlin (Jetpack Compose)
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
- 组件:
@Composable函数 - 状态:
remember+mutableStateOf - 渲染:函数执行(重组)
- 更新:改
count,Compose 重组依赖它的部分 - 描述:Compose 树
5.5 Flutter
dart
// Dart (Flutter)
class Counter extends StatefulWidget {
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int count = 0;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () => setState(() => count++),
child: Text('Count: $count'),
);
}
}
- 组件:StatefulWidget + State
- 状态:State 字段
- 渲染:
build() - 更新:
setState触发build - 描述:Widget 树
5.6 Vue 3
vue
<!-- Vue 3 (SFC) -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button @click="count++">
Count: {{ count }}
</button>
</template>
- 组件:单文件组件
- 状态:
ref - 渲染:模板编译为渲染函数
- 更新:改
count.value(模板中自动解包),Vue 响应式系统触发更新 - 描述:虚拟 DOM
5.7 对比总表
| 维度 | React | SwiftUI | ArkUI | Compose | Flutter | Vue 3 |
|---|---|---|---|---|---|---|
| 组件形态 | 函数 | 结构体 | 结构体 | 函数 | 类 | 单文件 |
| 状态声明 | useState | @State | @State | remember | State 字段 | ref |
| 渲染函数 | 函数体 | body | build | Composable 体 | build | 模板 |
| 更新触发 | setState | 改 @State | 改 @State | 改状态 | setState | 改 ref.value |
| UI 描述 | 虚拟 DOM | View 树 | 组件树 | Compose 树 | Widget 树 | 虚拟 DOM |
| 重组粒度 | 组件级 | body 级 | 组件级 | 细粒度 | 组件级 | 组件级 |
核心结论: 六种框架,一套心智模型。学会一个,迁移到其他只是语法差异。
六、四条关键原则(深入版)
6.1 不要直接改 UI
错误:
javascript
// JavaScript
label.text = count
button.disabled = true
view.visibility = isVisible ? VISIBLE : GONE
正确:
javascript
// JavaScript
setCount(1)
setDisabled(true)
setIsVisible(false)
为什么?
因为直接改 UI 会绕过状态系统。框架不知道界面变了,下一次渲染时可能把你改的东西覆盖掉。而且状态和界面又不一致了。
深层原因: 声明式框架假设"界面完全由状态决定"。你直接改界面,就破坏了这个假设,框架的 diff 结果就不正确了。
一个具体的 bug 场景:
jsx
// JSX
function Bad() {
const [count, setCount] = useState(0)
const handleClick = () => {
// 手动改 DOM,但忘记 setCount
document.querySelector('.label').textContent = `Count: ${count + 1}`
}
return <div className="label">Count: {count}</div>
}
这段代码里,你手动改了 DOM,但没有调用 setCount。React 不知道状态变了。界面暂时显示 Count: 1,但 React 内部认为 count 还是 0。
下一次任何其他状态触发渲染时,React 会根据 count=0 重新渲染,把你手动改的内容覆盖回 Count: 0。界面和状态不一致,而且你很难排查------因为代码看起来"只是改了个文字"。
正确做法:只改状态。
jsx
// JSX
const handleClick = () => {
setCount(count + 1)
}
6.2 状态尽量单一来源
错误:
jsx
// JSX
const [isOn, setIsOn] = useState(false)
const [label, setLabel] = useState('关')
// 切换时两个都要改,容易漏
setIsOn(!isOn)
setLabel(isOn ? '关' : '开')
正确:
jsx
// JSX
const [isOn, setIsOn] = useState(false)
const label = isOn ? '开' : '关' // 派生出来
为什么?
多个状态源意味着多个真相。当它们不一致时,你无法判断哪个是对的。单一数据源保证一致性,派生数据永远和源同步。
判断标准: 如果一个值能由其他状态算出来,它就不应该是独立状态。
一个具体的 bug 场景:
jsx
// JSX
// 错误:两个状态可能不同步
const [firstName, setFirstName] = useState('张')
const [lastName, setLastName] = useState('三')
const [fullName, setFullName] = useState('张三')
// 用户改了 firstName
setFirstName('李')
// 忘记改 fullName,界面显示"李三"但 fullName 还是"张三"
不可变更新: 声明式框架通常要求你返回新对象,而不是修改旧对象。
jsx
// JSX
// 错误:修改原数组
todos.push(newTodo)
setTodos(todos)
// 正确:创建新数组
setTodos([...todos, newTodo])
因为框架通过引用比较判断状态是否变化。修改原对象,引用不变,框架认为状态没变,不会重新渲染。
6.3 派生数据不要存成状态
错误:
jsx
// JSX
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [fullName, setFullName] = useState('') // 多余
// 每次 firstName 或 lastName 变,都要手动更新 fullName
正确:
jsx
// JSX
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const fullName = firstName + ' ' + lastName // 渲染时算
为什么?
存成状态意味着你需要手动同步。而且 props 变化时,如果忘了更新 fullName,它就过期了。
性能考量: 有人担心每次渲染都计算浪费性能。但简单计算的开销远小于状态同步出错的风险。真正昂贵的计算可以用 useMemo、derivedStateOf 等缓存。
但注意:useMemo 本身有依赖比较的开销,简单计算(字符串拼接、布尔判断)不需要 useMemo。只有真正昂贵的计算(大数组过滤、复杂对象构造)才值得。
一个更隐蔽的错误:
jsx
// JSX
// 错误:用 useEffect 同步派生数据
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [fullName, setFullName] = useState('')
useEffect(() => {
setFullName(firstName + ' ' + lastName)
}, [firstName, lastName])
这会导致两次渲染:一次是 firstName 变化,一次是 fullName 变化。而且如果 firstName 和 lastName 同时变,会触发两次 useEffect。直接算就好了。
6.4 单向数据流
txt
┌──────────────┐
│ 父组件 │
│ state │
└──────┬───────┘
│ props 向下
↓
┌──────────────┐
│ 子组件 │
│ 只读 props │
└──────┬───────┘
│ 事件向上
↓
┌──────────────┐
│ 父组件 │
│ 改 state │
└──────────────┘
为什么?
如果子组件能直接改父组件状态,数据流就变成网状,难以追踪。单向数据流让数据流向清晰:状态从父到子,事件从子到父。
好处:
- 数据来源可追踪
- 组件可预测
- 易于调试
- 易于测试
一个具体的例子:
jsx
// JSX
// 父组件持有状态
function Parent() {
const [count, setCount] = useState(0)
return (
<View>
<Text>Count: {count}</Text>
<Child count={count} onIncrement={() => setCount(count + 1)} />
</View>
)
}
// 子组件只读 props,通过事件通知父组件
function Child({ count, onIncrement }) {
return (
<Button onClick={onIncrement}>
当前: {count},点击加一
</Button>
)
}
子组件不持有 count,也不改 count。它只读 count,通过 onIncrement 通知父组件。父组件改 count,重新渲染,子组件拿到新 count。
七、常见误区(深入版)
7.1 React 里写 count++
jsx
// JSX
// 错误
onClick={() => { count++ }}
// 正确
onClick={() => setCount(count + 1)}
原因: count 是 useState 返回的快照,直接改不会触发渲染。必须调用 setter。
深层原因: React 用不可变数据模型。每次渲染,count 是一个新的常量,不是可变的引用。你必须通过 setCount 告诉 React"请用新值重新渲染"。
注意: React 的 setCount 是异步的。调用 setCount(1) 后,count 不会立即变成 1。React 会批量调度,在下一次渲染时更新。所以不要写:
jsx
// JSX
setCount(count + 1)
console.log(count) // 还是旧值
如果需要基于旧值计算新值,用函数式更新:
jsx
// JSX
setCount(c => c + 1)
7.2 在渲染过程中修改状态
jsx
// JSX
// 错误:无限循环
function Bad() {
const [count, setCount] = useState(0)
setCount(count + 1) // 渲染中改状态
return <Text>{count}</Text>
}
原因: 改状态触发重新渲染,重新渲染又改状态,无限循环。
正确: 副作用必须放在事件或 useEffect 中。
jsx
// JSX
function Good() {
const [count, setCount] = useState(0)
useEffect(() => {
// 副作用:比如订阅、发请求
const timer = setInterval(() => setCount(c => c + 1), 1000)
return () => clearInterval(timer)
}, [])
return <Text>{count}</Text>
}
7.3 把可算出的值存成 state
见 6.3。
7.4 状态之间手动同步
见 6.2。
7.5 以为声明式没有命令式底层
真相: 声明式框架底层仍然是命令式操作。React 最终调用 document.createElement,SwiftUI 最终调用 Core Animation,ArkUI 最终调用渲染引擎。只是这些被封装了。
理解这一点很重要: 声明式是"编程模型",不是"没有命令式"。性能优化时,你仍然需要理解底层。
一个性能优化的例子:
jsx
// JSX
// React 的 key 用于标识列表元素的身份
// 帮助 diff 算法正确匹配新旧元素
{items.map(item => <Item key={item.id} {...item} />)}
如果 key 用 index,列表重排时 diff 算法会错误地按位置复用组件,导致组件内部状态错乱。理解底层的命令式 diff,才能写出正确的声明式代码。
7.6 把"组件"当成真实控件对象
错误思维: "我要拿到这个 Button 组件,改它的颜色。"
正确思维: "Button 的颜色由 state 决定,我改 state。"
一个具体的例子:
jsx
// JSX
// 错误思维:想拿到 button 改颜色
const buttonRef = useRef()
buttonRef.current.style.color = 'red'
// 正确思维:颜色由 state 决定
const [color, setColor] = useState('blue')
<Button style={{ color }} />
7.7 状态放得太高或太低
太高: 所有状态放根组件,任何变化都重渲染整棵树,性能差,且组件难复用。
太低: 两个兄弟组件需要共享数据,但状态在各自内部,无法同步。
正确: 状态放在"需要它的最近公共祖先"。
jsx
// JSX
// 错误:状态放在 App,所有组件都重渲染
function App() {
const [input, setInput] = useState('')
return (
<View>
<Header /> {/* App 重新执行时,Header 元素被重新创建 */}
<Content /> {/* 如果 Header 没有 memo,它会重新渲染 */}
<InputBox value={input} onChange={setInput} />
</View>
)
}
// 正确:状态放在 InputBox 内部
function App() {
return (
<View>
<Header />
<Content />
<InputBox /> {/* 状态在内部,只有它重渲染 */}
</View>
)
}
7.8 用 useEffect 同步状态
jsx
// JSX
// 错误:用 useEffect 同步两个状态
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [fullName, setFullName] = useState('')
useEffect(() => {
setFullName(firstName + ' ' + lastName)
}, [firstName, lastName])
原因: 这会导致额外的渲染,而且逻辑上 fullName 是派生数据,不该是状态。直接算:
jsx
// JSX
const fullName = firstName + ' ' + lastName
什么时候 useEffect 是对的? 当副作用是真正的副作用(发请求、订阅、操作 DOM),而不是状态同步。
八、习题(15 道)
题目 1(选择题)
声明式 UI 的核心公式是?
A. UI = state + command
B. UI = f(state)
C. UI = f(command)
D. UI = state × command
参考答案:B
解读: 声明式 UI 的核心是 UI 由状态决定。你改状态,框架重新计算 UI。A、C、D 都把命令和状态混淆了。命令式才是 UI = f(command)------你写一系列命令,界面按命令变化。
题目 2(判断题)
声明式 UI 中,状态变化后需要你手动找到对应控件并修改它。
参考答案:错误
解读: 手动找控件、改属性是命令式思维。声明式里你只改状态,框架负责 diff 和更新真实界面。框架的 diff 算法会找出最小差异集,只更新需要更新的节点。
题目 3(填空题)
状态变化后,框架会重新执行 ______,得到新的 UI 描述,再和旧描述比较,最后最小化更新真实界面。这个比较过程叫 ______。
参考答案:渲染函数(render / body / build);diff(或 reconciliation)
解读: 不同框架叫法不同:React 叫 render,SwiftUI 叫 body,ArkUI 叫 build,Compose 叫重组,Flutter 叫 build。diff 是性能关键------框架通过比较新旧描述,避免全量重建。
题目 4(简答题)
用一句话说明命令式 UI 和声明式 UI 的根本区别。
参考答案: 命令式告诉程序"怎么做",一步步操作界面;声明式告诉程序"是什么",只描述状态对应的 UI,由框架负责更新。
解读: 关键在"谁负责同步界面"。命令式是你负责,声明式是框架负责。这也是为什么声明式代码更短、更可预测------你把同步的负担交给了框架。
题目 5(代码分析题)
下面 React 代码有什么问题?会导致什么现象?
jsx
// JSX
function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => { count += 1 }}>
Count: {count}
</button>
)
}
参考答案: count += 1 直接修改了状态变量,没有调用 setCount,React 不会重新渲染。界面上的数字不会变。
解读: count 是 useState 返回的快照值,直接赋值不会触发渲染。必须通过 setCount 通知 React。这是 React 和 ArkUI/SwiftUI 的区别:后两者改 @State 变量本身就能触发,React 必须调 setter。
正确写法:
jsx
// JSX
onClick={() => setCount(count + 1)}
题目 6(代码改错题)
把下面命令式代码改成声明式思路(伪代码即可),并说明你删掉了哪些"手动同步"的代码。
javascript
// JavaScript
let isOn = false
const label = createLabel()
const button = createButton()
function update() {
label.text = isOn ? '开' : '关'
button.color = isOn ? 'green' : 'gray'
}
button.onClick = () => {
isOn = !isOn
update()
}
update()
参考答案:
jsx
// JSX
function Toggle() {
const [isOn, setIsOn] = useState(false)
return (
<button
onClick={() => setIsOn(!isOn)}
style={{ color: isOn ? 'green' : 'gray' }}
>
{isOn ? '开' : '关'}
</button>
)
}
删掉的手动同步:
- 删掉了
update()函数 - 删掉了
label.text = ... - 删掉了
button.color = ... - 删掉了初始化时调用
update()
解读: 状态 isOn 同时决定文字和颜色,两者都是派生数据。事件只负责改状态。框架根据 isOn 重新计算 UI,自动同步文字和颜色。
题目 7(跨框架对比题)
写出 React、SwiftUI、ArkUI 中计数器的核心写法,并指出三者的共同点和关键差异。
参考答案:
React:
jsx
// JSX
const [count, setCount] = useState(0)
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
SwiftUI:
swift
// Swift
@State private var count = 0
Button("Count: \(count)") { count += 1 }
ArkUI:
ts
// ArkTS (ArkUI)
@State count: number = 0
Button(`Count: ${this.count}`)
.onClick(() => { this.count += 1 })
共同点:
- 都持有状态
- 都通过事件改状态
- 都由状态算 UI
- 都不手动改控件
- 都由框架负责 diff 和更新
关键差异:
- React 必须调
setCount,SwiftUI/ArkUI 改变量本身即可 - React 组件是函数,SwiftUI/ArkUI 是结构体
- React 用
useState,SwiftUI/ArkUI 用@State
解读: 语法不同,心智模型相同:状态驱动 UI。理解这一点,跨框架迁移只需要学语法和 API,不需要重新建立心智模型。
题目 8(选择题)
以下哪个不是声明式 UI 框架?
A. React
B. SwiftUI
C. jQuery
D. Jetpack Compose
E. ArkUI
参考答案:C
解读: jQuery 是典型命令式操作 DOM。你写 $('#label').text('hello'),就是手动改界面。React、SwiftUI、Compose、ArkUI 都是声明式。Vue 3 的 <script setup> 也是声明式。
题目 9(判断题)
声明式 UI 底层没有命令式操作。
参考答案:错误
解读: 声明式只是把命令式操作封装在框架内部。框架最终还是要创建、更新、删除真实节点,只是你不用手动做。理解这一点对性能优化很重要------React 的 key、Compose 的 key、SwiftUI 的 id,都是给底层 diff 算法用的,用于标识元素身份。
题目 10(填空题)
React 中通过 ______ 触发状态更新,SwiftUI 中通过 ______ 声明局部状态,ArkUI 中通过 ______ 声明局部状态,Compose 中通过 ______ 声明局部状态。
参考答案:setState / useState;@State;@State;remember + mutableStateOf
解读: React 用 useState 返回状态和更新函数;SwiftUI 和 ArkUI 都用 @State 属性装饰器;Compose 用 remember 保持状态,mutableStateOf 创建可观察状态。区别:React 必须调 setter,SwiftUI/ArkUI 改变量本身即可,Compose 改 value 或委托属性即可。
题目 11(简答题)
为什么同一个数据不要存在多个状态里?请举一个具体的 bug 场景。
参考答案: 多个来源会导致状态不同步。比如:
jsx
// JSX
const [isOn, setIsOn] = useState(false)
const [label, setLabel] = useState('关')
// 切换时只改了一个
setIsOn(true)
// 忘记 setLabel('开')
// 结果:开关是开的,但文字显示"关"
应该保持单一数据源:
jsx
// JSX
const [isOn, setIsOn] = useState(false)
const label = isOn ? '开' : '关' // 派生出来
解读: 单一数据源是声明式 UI 的重要原则。派生数据直接算,不要多存一份 state。判断标准:如果一个值能由其他状态算出来,它就不应该是独立状态。
题目 12(代码分析题)
下面代码有什么问题?会导致什么现象?
jsx
// JSX
function User({ firstName, lastName }) {
const [fullName, setFullName] = useState(firstName + lastName)
return <Text>{fullName}</Text>
}
参考答案: fullName 是可以从 firstName 和 lastName 算出来的派生数据,不应该存成 state。而且当 props 变化时,fullName 不会自动更新。
现象: 父组件传入新的 firstName,fullName 还是旧值。界面显示过期的全名。
正确写法:
jsx
// JSX
function User({ firstName, lastName }) {
const fullName = firstName + lastName
return <Text>{fullName}</Text>
}
解读: 这是"派生数据不要存成状态"的典型错误。存成 state 会导致 props 变化时不同步。而且用 useState 初始值只在首次渲染时生效,后续 props 变化不会重新初始化。
题目 13(设计题)
设计一个开关组件,要求:
- 状态:
isOn - 点击切换
- 显示"开"或"关"
- 开启时按钮绿色,关闭时灰色
- 开启时显示"已开启"提示文字
写出状态、事件、UI 三部分,并指出哪些是派生数据。
参考答案:
jsx
// JSX
function Toggle() {
const [isOn, setIsOn] = useState(false)
// 派生数据
const label = isOn ? '开' : '关'
const color = isOn ? 'green' : 'gray'
const hint = isOn ? '已开启' : ''
return (
<View>
<button
onClick={() => setIsOn(!isOn)}
style={{ backgroundColor: color }}
>
{label}
</button>
{hint && <Text>{hint}</Text>}
</View>
)
}
状态:isOn(唯一状态)
事件:onClick 调用 setIsOn
UI:根据 isOn 显示文字、颜色、提示
派生数据:label、color、hint 都是从 isOn 算出来的,不是独立状态
解读: 任何声明式组件都可以按"状态---事件---UI"三段式拆解。关键是把能算出来的都算出来,只保留最小状态集。这个组件只有 1 个状态,但有 3 个派生数据。
题目 14(设计题)
设计一个待办列表:
- 输入框 + 添加按钮
- 列表展示
- 没有待办时显示"暂无任务"
- 显示"共 N 项任务"
- 输入为空时添加按钮禁用
写出状态设计和渲染逻辑,并指出哪些是派生数据。
参考答案:
jsx
// JSX
function TodoList() {
// 状态:只有两个
const [todos, setTodos] = useState([])
const [input, setInput] = useState('')
// 派生数据
const isEmpty = todos.length === 0
const countText = `共 ${todos.length} 项任务`
const canAdd = input.trim().length > 0
const addTodo = () => {
if (!canAdd) return
setTodos([...todos, input])
setInput('')
}
return (
<View>
<Input value={input} onChange={e => setInput(e.target.value)} />
<Button onClick={addTodo} disabled={!canAdd}>添加</Button>
<Text>{countText}</Text>
{isEmpty
? <Text>暂无任务</Text>
: todos.map((t, i) => <Text key={i}>{t}</Text>)
}
</View>
)
}
状态:todos、input(只有两个)
派生数据:isEmpty、countText、canAdd
事件:addTodo 改 todos 和 input
UI:条件渲染 + 列表渲染
解读: 两个状态,三个派生数据。"暂无任务"是条件渲染,不是独立状态。"共 N 项"是派生数据,不是独立状态。"添加按钮禁用"是派生数据,不是独立状态。列表用 map 渲染。
注意: key={i} 用索引作为 key,在列表重排时会导致状态错乱。如果列表项有稳定 id,应该用 key={item.id}。
题目 15(思考题)
如果同时有 count、isLoading、error 三个状态,界面应该由什么决定?请设计渲染优先级,并说明如果三个状态同时变化,框架会怎么处理。
参考答案: 由三个状态共同决定。UI 是状态的函数:
txt
UI = f(count, isLoading, error)
推荐优先级:
jsx
// JSX
function View({ count, isLoading, error }) {
if (isLoading) return <Text>加载中...</Text>
if (error) return <Text>错误:{error}</Text>
return <Text>Count: {count}</Text>
}
如果三个状态同时变化:
- 框架把三次状态更新合并为一次渲染(批量调度)
- 只执行一次渲染函数,读取最新的三个状态
- diff 新旧 UI 描述
- 只更新需要更新的节点
解读: 多个状态时,渲染函数按优先级组合它们。通常加载态优先于错误态,错误态优先于正常态。不要手动同步界面,而是让渲染函数自然表达每种状态组合。
深入思考: 如果 isLoading 和 error 同时为 true,应该显示什么?这取决于业务逻辑。声明式的好处是:你只需要在渲染函数里写出优先级,框架保证界面和这个逻辑一致。命令式的话,你需要在每个可能改状态的地方都写一遍优先级逻辑,容易漏。
批量调度的意义: React 18 的自动批处理、Vue 的 nextTick、SwiftUI 的事务,都是把多次状态变化合并为一次渲染。这避免了中间态的闪烁,也提升了性能。这是命令式很难做到的------你必须手动管理批次。
九、下节预告
第 2 课:声明式 UI 的通用函数式写法,包括函数式组件和结构体组件。
我们会讲:
- 组件即函数:输入 props/state,输出 UI 描述
- 函数式组件:React、Compose
- 结构体组件:SwiftUI、ArkUI
- 组件组合与插槽
- 副作用边界:渲染应该纯,副作用放事件/生命周期
- 15 道多元化习题