聊聊单向数据流
聊聊单向数据流
单向数据流是现代前端和移动开发中一个重要的设计模式,它通过确保数据只在一个方向上流动来简化应用的状态管理。本文将深入探讨单向数据流的概念,并通过对比Vue.js、Android Compose和Flutter中的实现方式,帮助读者更好地理解这一模式。
Vue.js 中的单向数据流
Vue.js官方文档关于单向数据流有如下描述:
所有的 props 都遵循着单向绑定原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱而难以理解。
另外,每次父组件更新后,所有的子组件中的 props 都会被更新到最新值,这意味着你不应该在子组件中去更改一个 prop
举个例子:
const props = defineProps(['foo'])
// ❌ 警告!prop 是只读的!
props.foo = 'bar'
你不应该在子组件(也就是当前组件中)中去更改一个 prop。若你这么做了,Vue 会在控制台上向你抛出警告。当然,不是说子组件中不能修改任何数据。比如,我们可以用 prop 作为初始值来定义一个局部数据:
const props = defineProps(['initialCounter'])
// 计数器只是将 props.initialCounter 作为初始值
// 像下面这样做就使 prop 和后续更新无关了
const counter = ref(props.initialCounter)
// ✅ 我们没有修改 prop, 我们在修改局部数据
counter.value = counter.value + 1
这是我第一次知道这个概念。Android View 中没有类似的说法。比如,如果将 mText
视为一个 prop 的话,那 TextView
内部其实有相当多的逻辑会修改这个 prop。
public class TextView extends View implements ViewTreeObserver.OnPreDrawListener {
private @Nullable CharSequence mText;
public CharSequence getText() {
if (mUseTextPaddingForUiTranslation) {
ViewTranslationCallback callback = getViewTranslationCallback();
...
}
return mText;
}
public final void setText(CharSequence text) {
setText(text, mBufferType);
}
}
另一方面,要求 TextView
内部绝不修改 mText
这个 prop 是不可能的。这是个合理的需求。针对这种情况,Vue.js 的解决方案是基于该 prop 值定义一个计算属性。
const props = defineProps(['size'])
// 该 prop 变更时计算属性也会自动更新
const normalizedSize = computed(() => props.size.trim().toLowerCase())
怎么理解呢?简单来说就是,你还是不能在子组件中直接修改 prop,但是子组件可以根据你的需求和逻辑来基于 prop 提供一个新的值供你使用。
我用 Vue.js 写过一段时间 UI,确实很方便。单向数据流原则确认可以避免数据流程混乱,从源头上阻止不少 bug。
不过,由于 Javascript 语言的特点(对象和数组是按引用传递), Vue.js 中对象或数组作为 props 时,子组件还是可以更改它们内部的值。这是一个需要注意的坑。
Android Compose 中的单向数据流
Android Compose官方文档中关于单向数据流的描述是这样的:
单向数据流 (UDF) 是一种设计模式,其中状态向下流动,事件向上流动。通过遵循单向数据流,您可以将显示 UI 中状态的可组合项与存储和更改状态的应用部分解耦。
使用单向数据流的应用的 UI 更新循环如下所示
- 事件:UI 的一部分生成一个事件并将其向上传递,例如传递给 ViewModel 以进行处理的按钮点击;或者从应用的其他层传递事件,例如指示用户会话已过期。
- 更新状态:事件处理程序可能会更改状态。
- 显示状态:状态持有者向下传递状态,UI 显示它。
Android Compose 强调的是 state 数据 和 event 事件在 State 和 UI 之间单方向流动。这个表述跟 Vue.js 中的单向数据流看起来有些不一致的,但本质其实是相同的。
- Android Compose 中 State 是状态的持有方,UI 是状态的使用方
- Vue.js 中父组件是状态的持有方,子组件是状态的使用方。注:从物理上看,子组件确实通过 prop 持有状态;但逻辑上看,父组件才是状态的持有方,而非子组件。
Flutter 中的单向数据流
今天发现 Flutter官方文档中也有类似的表述,
In Flutter, change notifications flow “up” the widget hierarchy by way of callbacks, while current state flows “down” to the stateless widgets that do presentation.
虽然没有明确提单向数据流,但我确认这就是同样的概念。看以下示例代码:
import 'package:flutter/material.dart';
class CounterDisplay extends StatelessWidget {
const CounterDisplay({required this.count, super.key});
final int count;
@override
Widget build(BuildContext context) {
return Text('Count: $count');
}
}
class CounterIncrementor extends StatelessWidget {
const CounterIncrementor({required this.onPressed, super.key});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return ElevatedButton(onPressed: onPressed, child: const Text('Increment'));
}
}
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _counter = 0;
void _increment() {
setState(() {
++_counter;
});
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
CounterIncrementor(onPressed: _increment),
const SizedBox(width: 16),
CounterDisplay(count: _counter),
],
);
}
}
void main() {
runApp(const MaterialApp(home: Scaffold(body: Center(child: Counter()))));
}
_increment()
是最关键的代码:
void _increment() {
setState(() {
++_counter;
});
}
如果忽略名词表述上的差异,
- Flutter 的 change notifications 等同于 Android Compose 的事件流(event)
- Flutter 的 current state 等同于 Android Compose 的状态流(state)
上面的代码和图片中各元素的对应如下:
_CounterState
- 状态持有方(State)_counter
- 状态(state)CounterDisplay
- 状态使用方(UI)_increment()
- 事件(event)++_counter
更新状态,setState()
将状态更新由 “状态持有方(State)” 通知到 “状态使用方(UI)”。
flowchart TD
_CounterState --_counter: state--> CounterIncrementor\n+\nCounterDisplay
CounterIncrementor\n+\nCounterDisplay --++_counter : event--> _CounterState
示例
<!-- CounterDisplay.vue -->
<script setup>
const props = defineProps(['count'])
</script>
<template>
<span class="text-center">{{ props.count }}</span>
</template>
<!-- Counter.vue -->
<script setup>
import CounterDisplay from './CounterDisplay.vue';
import { ref } from 'vue';
const count = ref(0)
</script>
<template>
<van-row gutter="20">
<van-col>
<!-- <CounterIncrementor /> -->
<van-button type="primary" @click="count++">Increment</van-button>
</van-col>
<van-col>
<CounterDisplay :count="count"/>
</van-col>
</van-row>
</template>
class CounterModel(scope: CoroutineScope) {
var count by mutableIntStateOf(0)
private set
private fun incCount() {
count++
}
}
@Composable
fun Counter() {
val scope = rememberCoroutineScope()
val model = remember { CounterModel(scope) }
Row(verticalAlignment = Alignment.CenterVertically) {
Button(onClick = model::incCount) {
Text("Increment")
}
Text("count:${model.count}", modifier = Modifier.clickable(onClick = model::incCount))
}
}
对比如下:
Vue.js | Android Compose | Flutter |
---|---|---|
状态 | Couter.vue count | CounterModel count |
状态持有方 | 父组件 Couter.vue | CounterModel |
状态使用方 | 子组件 CounterDisplay.vue | Counter |
事件 | @click=”count++” | model::incCount |