【现代声明式UI学与练】第1课 从命令式UI到声明式UI

一、本课目标

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

  1. 声明式 UI 和命令式 UI 的根本区别,以及为什么前者成为现代 UI 的主流范式。
  2. 核心公式:UI = f(state),以及它背后的完整渲染管线。
  3. 写声明式 UI 时,你只改状态,不手动改界面------理解框架替你做了什么。
  4. 四条关键原则和八种常见误区,从第一天起就建立正确的肌肉记忆。

二、命令式 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。

这个公式意味着:

  1. UI 由输入完全决定。 给定相同输入,渲染函数必然返回相同 UI。
  2. 输入是输入,UI 是输出。 不存在"输入没变但 UI 变了"的情况。
  3. 不需要手动同步。 输入变,重新执行 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>
}

如果三个状态同时变化:

  1. 框架把三次状态更新合并为一次渲染(批量调度)
  2. 只执行一次渲染函数,读取最新的三个状态
  3. diff 新旧 UI 描述
  4. 只更新需要更新的节点

解读: 多个状态时,渲染函数按优先级组合它们。通常加载态优先于错误态,错误态优先于正常态。不要手动同步界面,而是让渲染函数自然表达每种状态组合。

深入思考: 如果 isLoading 和 error 同时为 true,应该显示什么?这取决于业务逻辑。声明式的好处是:你只需要在渲染函数里写出优先级,框架保证界面和这个逻辑一致。命令式的话,你需要在每个可能改状态的地方都写一遍优先级逻辑,容易漏。

批量调度的意义: React 18 的自动批处理、Vue 的 nextTick、SwiftUI 的事务,都是把多次状态变化合并为一次渲染。这避免了中间态的闪烁,也提升了性能。这是命令式很难做到的------你必须手动管理批次。


九、下节预告

第 2 课:声明式 UI 的通用函数式写法,包括函数式组件和结构体组件。

我们会讲:

  • 组件即函数:输入 props/state,输出 UI 描述
  • 函数式组件:React、Compose
  • 结构体组件:SwiftUI、ArkUI
  • 组件组合与插槽
  • 副作用边界:渲染应该纯,副作用放事件/生命周期
  • 15 道多元化习题
相关推荐
老王爱玩车2 小时前
工具函数——清空输入缓冲区
c语言·开发语言·学习
frjc2 小时前
前端技术选型:主流方案对比与落地建议
vue·react·angular
m0_738185825 小时前
Flutter 鸿蒙化实战:flutter_nfc_kit 适配 OpenHarmony,NFC 读写一步到位
flutter·华为·harmonyos·鸿蒙
JWASX5 小时前
Java 转 go 学习 - 函数(1)
学习·golang
sunshine22 girl5 小时前
Java学习五 面向对象高级5 内部类3-静态内部类和局部内部类(了解)
java·学习
Cx330❀5 小时前
【Qt 进阶指南】详解 Qt 常用标准对话框与主窗口组件架构
开发语言·qt·ui·性能优化·架构·图形渲染
恋猫de小郭5 小时前
KMP 又改了编译流程, Separate Compilation 禁止了 `commonMain` 的依赖穿透
android·前端·flutter
小师兄吃牛肉5 小时前
什么是R语言?如何快速学习R语言
开发语言·学习·r语言