聊聊单向数据流

第一次听到单向数据流这个词是在 Vue.js 官网上。后来发现 Android Compose 中也有这个说法。今天注意到虽然 Flutter 没有明确提单向数据流,但其实也遵守类似的原则。

Vue.js 中的单向数据流

Vue.js 官方文档关于单向数据流有如下描述:

所有的 props 都遵循着单向绑定原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱而难以理解。

另外,每次父组件更新后,所有的子组件中的 props 都会被更新到最新值,这意味着你不应该在子组件中去更改一个 prop

举个例子:

1
2
3
4
const props = defineProps(['foo'])

// ❌ 警告!prop 是只读的!
props.foo = 'bar'

你不应该在子组件(也就是当前组件中)中去更改一个 prop。若你这么做了,Vue 会在控制台上向你抛出警告。当然,不是说子组件中不能修改任何数据。比如,我们可以用 prop 作为初始值来定义一个局部数据:

1
2
3
4
5
6
7
8
const props = defineProps(['initialCounter'])

// 计数器只是将 props.initialCounter 作为初始值
// 像下面这样做就使 prop 和后续更新无关了
const counter = ref(props.initialCounter)

// ✅ 我们没有修改 prop, 我们在修改局部数据
counter.value = counter.value + 1

这是我第一次知道这个概念。Android View 中没有类似的说法。比如,如果将 mText 视为一个 prop 的话,那 TextView 内部其实有相当多的逻辑会修改这个 prop。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 值定义一个计算属性

1
2
3
4
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.

虽然没有明确提单向数据流,但我确认这就是同样的概念。看以下示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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() 是最关键的代码:

1
2
3
4
5
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)”。

1
2
3
flowchart
_CounterState --_counter: state--> CounterIncrementor\n+\nCounterDisplay
CounterIncrementor\n+\nCounterDisplay --++_counter : event--> _CounterState

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class CounterModel(scope: CoroutineScope) {
var count by mutableIntStateOf(0)
private set

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 _CounterState._counter
状态持有方 父组件 Couter.vue CounterModel _CounterState
状态使用方 子组件 CounterDisplay.vue Counter CounterDisplay Widget
事件 @click=”count++” model::incCount _increment