【vue3】详解单向数据流,大家千万不用为了某某而某某了。

总览 Vue3 的单向数据流

尽信官网,不如那啥。

vue的版本一直在不断更新,内部实现方式也是不断的优化,官网也在不断更新。

既然一切皆在不停地发展,那么我们呢?等着官网更新还是有自己的思考?

我觉得我们要走在官网的前面,而不是等官网更新后,才知道原来可以这么实现。。。

我习惯先给大家一个整体的概念,然后再介绍各个细节。

脑图版

先整理一下和单向数据流有关的信息,做个脑图:

大纲版

列个大纲看看:

  • 自动版
    • v-model、emit(defineModel):组成无障碍通道,实现父子组件之间的值类型的响应性。
    • pinia.state、pinia.patch:状态管理提供的方法。
    • props + reactive:直接改 reactive,争议比较大
    • 注入 + reactive:直接改 reactive,一般可以忍受
  • 手动版
    • 注入 + reactive + function:官网建议通过 function 改 reactive,而不是直接改 reactive。
    • 状态管理的getter、mutation、action:状态管理,其实也涉及到了单向数据流。
  • props是否可以直接改?(从代码的角度来分析)
    • 值类型:不可改,否则响应性就崩了。
    • 引用类型:地址不可改,但是属性可以改。对于引用类型,其实都是通过 reactive 实现响应性的。
  • 有无意义的角度 (这是一个挨骂的话题
    • 有意义的方式:实现响应性的唯一方式,或者有记录(timeline)、有验证、限制等。
    • 无意义的方式:没有上面说的功能,还自认为是严格遵守规矩。
  • 限制的是谁?
    • 发起者:如果是限制子组件不能发起修改的话,那么任何方式都应该不能被允许,emit 也不行。
    • 方式(手段):如果只是限制一些方式的话,那么为啥 emit 可以,reactive 就不能直接改?有啥区别呢?
      • 二者都没有做记录(timeline),
      • 没有做任何限制、验证。

画个表格对比一下:

再来看看各种方式的对比:

方式 实现手段 有无记录 有无限制、验证 官网意见 适合场景
v-model + emit 抛出事件 可以 以前的方式
v-model + defineModel 抛出事件 推荐 V3.4 推荐的方式
props + reactive 代理,set 不推荐 适合传递引用类型
注入 + reactive 代理,set 不建议直接改reactive 适合多层级的组件结构
注入 + reactive + function 调用指定的函数 可以有 可以有 推荐方式 适合特殊需求
pinia.patch、state 代理,set等 timeline
pinia 的 getter、 action 调用指定的函数 timeline 可以有

这样应该有一个明确的总体感觉了吧。

props 的单向数据流

为啥弄得这么复杂?还不是因为两点:

  • vue 自带响应性,主要是 reactive有点太"逆天"。
  • composition API,可以把响应性分离出来单独使用。

如果没有 reactive,那么也就不会这么乱糟糟的了,让我们细细道来。

props 本身是单向的

https://cn.vuejs.org/guide/components/props.html#one-way-data-flow

官网里关于 props 的单向数据流是这样描述的:

所有的 props 都遵循着单向绑定 原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。

这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱而难以理解。

整理一下重点:

  • props 本身是单向的,只能接收父组件传入的数据,本身不具有改变父组件数据的能力。
  • 父组件的(响应性)数据如果变化,会通知 props 进行更新。
  • props.xxxx ,自带响应性。
  • props 不具有修改父组件数据的能力,这样就避免了父组件的数据被意外修改而受到影响。
  • 否则,数据流向 会混乱,导致难以理解

其实 props 本来就是单向的,用于子组件接收父组件传入的数据,完全没有让子组件修改父组件里的数据的功能。

那么为何还要强调单向数据流呢?原因有二:引用类型reactive

props可以设置两种数据类型:

  • 值类型(数字、字符串等),用于简单情况,比如 input、select 的值等。
  • 引用类型(对象、数组等),用于复杂情况,比如表单、验证信息、查询条件等。

现在,仅从代码的角度看看 props 在什么情况可以改、不可以改。

  • 值类型,那是肯定不能直接改,直接改就破坏了响应性,父子组件的数据也对应不上。
  • 引用类型,又分为两种情况:改地址、改属性。
    • 改地址,那当然也是不行滴!同上,地址换了怎么找到你家?
    • 如果传入的是普通对象,虽然可以改属性,但是没有响应性;
    • 如果传入的是 reactive 的话,那就可以改其属性了,因为 reactive 自带响应性。

那么问题来了:

  • reactive 在父组件可以改,不会难以理解。
  • reactive 通过依赖注入的方式给子组件,虽然官网不建议直接改,但是就问问你,你会不会直接改?
  • reactive 通过 props 的方式给子组件,为啥一改就混乱而难以理解了呢?
  • 【重点】单向数据流,限制的是发起者,还是"渠道"?

所以重点就是这个 reactive !如果没有他,props 即使直接改了,也无法保证响应性,从而被我们所抛弃,也就不用纠结和争论了。

那么 reactive 到底是怎么回事?大家先不要着急,先看看官网允许的情况,然后再对比思考。那谁不是说了吗,没有对比就没有那啥。。。

为什么会混乱?想到了一种可能性:父组件定义了一个 reactive 的数据,然后通过 props 传递个多个子组件,然后某个子组件里面还有很多子子组件,也传入了这个数据。

某个时候发现状态异常变更,那么问题来了:到底是谁改了状态?(后续跟进)

emit 怎么可以改了?

emit 本意是子组件向父组件抛出一个事件,然后 vue 内部提供了一种方式(update:XXXXX),可以实现子组件修改父组件的需求。

js 复制代码
<!-- Child.vue -->
<script setup>
  const props = defineProps(['modelValue'])
  const emit = defineEmits(['update:modelValue'])
</script>
<template>
  <input
    :value="props.modelValue"
    @input="emit('update:modelValue', $event.target.value)"
  />
</template>

update:XXX 可以视为内部标识,会特殊处理这个 emit。

好了,这里不讨论具体是如何实现了,而是要讨论一下,不是说好的单向数据流,子组件不能改父组件的吗?不是说改了会导致混乱而难以理解吗?

官方的说法:emit 并不是直接修改,而是通过向父组件抛出一个事件,父组件响应这个事件来实现的。所以,不是直接改,并没有破坏单向数据流。

这个说法嘛,确实很官方。只是从结果来看,还是子组件发起了状态的变更,那么问题来了,如果是上面的那种情况,可以方便获知是谁改了状态吗?(似乎也会导致混乱和难以理解吧)

那么问题来了:单向数据流,是限制发起者 ,还是手段

  • 如果限制的是发起者的话,那么 emit 也不行,因为也是在子组件发起的,啥时候改,怎么改都是由子组件决定,emit只是一个无障碍通道的起始端,另一端是 v-model。
  • 如果限制手段的话,那么不同的手段到底有啥区别?为啥 emit 可以,reactive 就不可以?

不要钻牛角尖了,其实是有一个很实际的需求:

  • 父子组件之间要保持响应性
  • 子组件有"直接"改的要求

举个例子,各种 UI库 都有 xx-input 组件,外面用 v-model 绑定一个变量,然后 xx-input 里面必须可以修改传入的变量,而且要保持响应性对吧,否则咋办?

v-model + emit 就是解决这个实际需求的。(解决问题,给大家带来方便,然后才会选择vue,其余其他的嘛。。。)

当然,可以使用 ref,但是 ref 的本体是一个class,属于引用类型,如果传入 ref 本体的话,相当于传入一个对象给子组件。这个咋算?

vue 现在的做法是,template 会默认把 ref.value 传给子组件,而不是 ref 本体,这样传入的还是基础类型。

所以,这是实现父子组件之间,值类型的响应性的唯一方法。

defineModel,是直接改?

https://cn.vuejs.org/guide/components/v-model.html

defineModel 是 vue3.4 推出来的语法糖(稳定版),内部依然使用了 emit 的方式,所以可以视为和 emit 等效。

官网示例代码:

js 复制代码
<!-- Child.vue -->
<script setup>
const model = defineModel()

function update() {
  model.value++
}
</script>

<template>
  <div>Parent bound v-model is: {{ model }}</div>
</template>

官方的示例代码,特意展示了一下可以在子组件"直接改"的特点。

看过内部实现代码的都知道,其内部有一个内部变量,然后返回的是一个customerRef(官方说是ref),所以我们不是直接改 props,而是改 ref.value,然后内部通过 set 拦截,调用 emit 向父组件提交申请。

如果对内部原理感兴趣可以看这里:

依赖注入(provide/inject)也有单向数据流?

https://cn.vuejs.org/guide/components/provide-inject.html#working-with-reactivity

父子组件之间传值,就不得不说说依赖注入,那么是否存在"单向数据流"的问题呢?那也是必然应该存在呀,只是官网没有直接明确说。

注意:依赖注入只负责传递数据,并不负责响应性。

官网的意思,是让我们在父组件实现状态的变更,然后把状态和负责状态变更的函数一起传给(注入到)子组件,子组件不要直接改状态,而是通过调用 【父组件传入的函数】 来变更状态。

官网原文:

当提供 / 注入响应式的数据时,建议尽可能将任何对响应式状态的变更都保持在供给方组件中 。这样可以确保所提供状态的声明和变更操作都内聚在同一个组件内,使其更容易维护。

有的时候,我们可能需要在注入方组件中更改数据。在这种情况下,我们推荐在供给方组件内声明并提供一个更改数据的方法函数:

官网推荐的方式是这样的:

js 复制代码
<!-- 在供给方组件内 -- > 父组件
<script setup>
import { provide, ref } from 'vue'

// 数据、状态
const location = ref('North Pole')

// 变更状态的函数
function updateLocation() {
  location.value = 'South Pole'
}

// 提供数据和操作方法(function)
provide('location', {
  location,
  updateLocation
})
</script>
js 复制代码
<!-- 在注入方组件 --> 子组件
<script setup>
import { inject } from 'vue'

// 被注入(得到)状态和方法
const { location, updateLocation } = inject('location')
</script>

<template>
  <!--调用函数修改状态-->
  <button @click="updateLocation">{{ location }}</button>
</template>

看着是不是有点眼熟?这让我想起了 react 的 useState。

其实想一想,为啥非得学 react?react 的特点就是:不能变。所以当需要变更的时候,必须调用专门的 hooks 来处理。

但是 vue 的特点就是响应性呀,和 react 恰恰相反。

当然了,自己写一个函数也是有好处的,比如:

js 复制代码
const 张三 = reactive({name:'zs',age:20})

const setAge = (age) => {
  if (age < 0) {
    // 年龄不能是负数
  }
  // 其他验证
  // 通过验证,赋值
  张三.age = age
  // 还可以做记录(timeline)
}

这样就不能瞎改年龄了。或者根据出生日期自动计算年龄。

不是说不能自己写函数,而是说这个函数要有点意义。

状态管理也涉及单向数据流吗?

props 和注入说完了,那么就来到了状态管理,这里以 pinia 为例。

状态管理也涉及单向数据流吗?那当然是必须滴呀,否则 Vuex 的时候,为啥总强调要通过 mutation 去变更状态,而不要直接去改状态?

$state 是直接改吗?

那么 pinia 为什么提供了 $state 用于"直接"改状态呢?这还得看看源码:

  • pinia.mjs 1541 行
js 复制代码
    Object.defineProperty(store, '$state', {
        get: () => ((process.env.NODE_ENV !== 'production') && hot ? hotState.value : pinia.state.value[$id]),
        set: (state) => {
            /* istanbul ignore if */
            if ((process.env.NODE_ENV !== 'production') && hot) {
                throw new Error('cannot set hotState');
            }
            $patch(($state) => {
                assign($state, state);
            });
        },
    });

不太会TypeScript,所以我们来看看编译后的代码,是不是有点眼熟。

虽然表面上看是直接修改,但是却被 set 给拦截了,实际上是通过 $patch 和 Object.assign 实现的赋值操作。

这个和 defineModel 有点类似,表面上看直接改,其实都是间接修改。

而 $patch 里面还有一些操作,比如做记录(timeline)。

store.xxx 是直接修改吗?

可能你会说,$state 并不是状态自己的属性,当然不算直接修改了,那么我们来试试直接修改状态。

通过测试我们可以发现:

  • 可以直接改状态
  • 可以产生记录(timeline)

那么是怎么实现的呢?

  • 其实 pinia 的状态(store)也是 reactive。
    pinia.mis:1436行
js 复制代码
    const store = reactive((process.env.NODE_ENV !== 'production') || USE_DEVTOOLS
        ? assign({
            _hmrPayload,
            _customProperties: markRaw(new Set()), // devtools custom properties
        }, partialStore
        // must be added later
        // setupStore
        )
        : partialStore);
  • 然后对 reactive 进行了监听
    pinia.mis:1409行
js 复制代码
    const partialStore = {
        _p: pinia,
        // _s: scope,
        $id,
        $onAction: addSubscription.bind(null, actionSubscriptions),
        $patch,
        $reset,
        $subscribe(callback, options = {}) {
            const removeSubscription = addSubscription(subscriptions, callback, options.detached, () => stopWatcher());
            const stopWatcher = scope.run(() => watch(() => pinia.state.value[$id], (state) => {
                if (options.flush === 'sync' ? isSyncListening : isListening) {
                    callback({
                        storeId: $id,
                        type: MutationType.direct,
                        events: debuggerEvents,
                    }, state);
                }
            }, assign({}, $subscribeOptions, options)));
            return removeSubscription;
        },
        $dispose,
    };

这里的第10行,用 watch 对状态的属性进行了监听,然后写记录(timeline)。

pinia 不仅没有阻止我们直接改属性,还很贴心的做了记录。

pinia 的 timeline

以前就一直对这个 timeline 非常好奇,想知道记录的是什么,但是奈何各种原因总是看不到,现在vue 推出了,终于看到了。

这里的记录非常详细,有状态名称、动作、属性名称、新旧值、触发时间等等信息,只是有个小问题,到底是谁改了状态? 没发现有定位代码位置的功能。

reactive 怎么算?

好了,终于到了比较有争议的 reactive 了,大家有没有等着急?

首先 reactive 的本质是 Proxy,而 Proxy 是代理,这个想必大家都知道,所以我们可以设置这样的代码:

js 复制代码
const 张三 = {
  name:'zhangsan',
  age:20
}

const 张三的代理 = reactive(张三)

const setAge = (age) => {
  if (age < 0) {
    // 年龄不能是负数
  }
  // 其他验证
  
  // 通过验证后才能赋值
  张三的代理.age = age
}

平时大家都是一步成,现在分成了两步,是不是就很明确了呢。

张三 是一个普通的对象,没有响应性,张三的代理 是 reactive 有响应性,是张三的代理。

所以,我们传递给子组件的是张三的代理 ,并不是张三 本尊。

既然子组件根本就得不到张三 的本尊,那么又何来直接修改呢?

如果说通过 emit 是间接修改(抛出事件),那么通过 reactive 也是通过代理间接修改的。

虽然一个是事件,一个是代理,但是有啥本质区别呢?事件是函数,Proxy 里的 set 也是函数呀。

同样都是没有记录(timeline)、判断、验证、限制,想怎么改就怎么改。

如果你还不理解,可以看看这个演化过程。

阶段一:参考官网里面依赖注入的推荐方式

js 复制代码
// 阶段一:按照官网里面注入的推荐方式
const person = reactive({
  name:'zhangsan',
  age:20
})

const setAge = (age) => {
  person.age = age 
}

// 通过 props 或者 依赖注入,把 proxyPerson 传给子组件,
const proxyPerson = reactive({
  // 使用 readonly 变成只读形式,只能通过 setAge 修改。
  person: readonly(person),
  setAge
})

这样子组件只能使用 setAge 修改,代理套上 readonly 之后,通过代理的修改方式都给堵死了,是严格遵守单向数据流了吧。

阶段二:充血实体类,把数据和方法合在一起

js 复制代码
// 阶段二:充血实体类,把数据和方法合在一起
const person2 = {
  name:'zhangsan',
  _age:20, // 内部成员,相当于"本尊"
  // set 拦截,其实也是一个函数,类似于代理。
  set age(age) { // 拦截设置属性
    // 可以做验证
    this._age = age 
  },
  get age(){ // 拦截读取属性
    return this._age
  }
}

//  给子组件用
const proxyPerson2 = reactive(person2)

// 子组件
// 表名上看是通过属性修改,但是实际上被 set 拦截了,调用的是一个函数
proxyPerson2.age = 30

在父组件里面把数据和变更方法合并,也是符合官网的建议对吧。

那么看看阶段二是不是有点眼熟?如果你熟悉 Proxy 和 reactive 内部原理的话,这不就是 reactive 内部代码的一小部分吗?

既然 reactive 都自带了这种功能,那么我们又何必自己手撸?

当然 reactive 也有点小问题,没有内置记录,不过我们可以用 watch 的 onTrigger 做记录,详细看下面:
给 Pinia 加一个定位代码的功能(支持 reactive)

小结

  • v-model + emit

    目的是实现父子组件之间,值类型数据的响应性,如果不用 emit 的话,如何实现?

  • defineModel

    语法糖(宏),封装复杂的代码,让我们使用起来更方便。

  • 状态管理

    pinia 提供了 timeline,弥补了 reactive 的不足,方便我们调试代码,提供 $state 方便我们直接赋值。
    给 Pinia 加一个定位代码的功能(支持 reactive)

  • reactive

    我觉得可以直接改,因为本身就是一个代理(Proxy),直接用就好了。

    如果外面再套一个 Proxy 有何意义呢?当然了,如果可以加上 timeline,或者是判断、验证等,那么就有意义了。

  • 数据 + 方法

    可以在方法里面做一些操作,比如验证、判断等,那么就有意义,如果是个"空"函数,除了赋值啥都没做,那么有何意义呢?