问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

聊聊单向数据流

创作时间:
作者:
@小白创作中心

聊聊单向数据流

引用
1
来源
1.
https://www.sunmoonblog.com/2025/03/06/one-way-data-flow/

单向数据流是现代前端和移动开发中一个重要的设计模式,它通过确保数据只在一个方向上流动来简化应用的状态管理。本文将深入探讨单向数据流的概念,并通过对比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
© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号