总览 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),可以实现子组件修改父组件的需求。
代码语言:javascript复制<!-- 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 等效。
官网示例代码:
代码语言:javascript复制<!-- 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 向父组件提交申请。
如果对内部原理感兴趣可以看这里:
- https://juejin.cn/post/7331021519965356071
- https://juejin.cn/post/7354960709010260005
依赖注入(provide/inject)也有单向数据流?
https://cn.vuejs.org/guide/components/provide-inject.html#working-with-reactivity
父子组件之间传值,就不得不说说依赖注入,那么是否存在“单向数据流”的问题呢?那也是必然应该存在呀,只是官网没有直接明确说。
注意:依赖注入只负责传递数据,并不负责响应性。
官网的意思,是让我们在父组件实现状态的变更,然后把状态和负责状态变更的函数一起传给(注入到)子组件,子组件不要直接改状态,而是通过调用 【父组件传入的函数】 来变更状态。
官网原文:
当提供 / 注入响应式的数据时,建议尽可能将任何对响应式状态的变更都保持在供给方组件中。这样可以确保所提供状态的声明和变更操作都内聚在同一个组件内,使其更容易维护。 有的时候,我们可能需要在注入方组件中更改数据。在这种情况下,我们推荐在供给方组件内声明并提供一个更改数据的方法函数:
官网推荐的方式是这样的:
代码语言:javascript复制<!-- 在供给方组件内 -- > 父组件
<script setup>
import { provide, ref } from 'vue'
// 数据、状态
const location = ref('North Pole')
// 变更状态的函数
function updateLocation() {
location.value = 'South Pole'
}
// 提供数据和操作方法(function)
provide('location', {
location,
updateLocation
})
</script>
代码语言:javascript复制<!-- 在注入方组件 --> 子组件
<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 恰恰相反。
当然了,自己写一个函数也是有好处的,比如:
代码语言:javascript复制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 行
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行
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行
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 是代理,这个想必大家都知道,所以我们可以设置这样的代码:
代码语言:javascript复制const 张三 = {
name:'zhangsan',
age:20
}
const 张三的代理 = reactive(张三)
const setAge = (age) => {
if (age < 0) {
// 年龄不能是负数
}
// 其他验证
// 通过验证后才能赋值
张三的代理.age = age
}
平时大家都是一步成,现在分成了两步,是不是就很明确了呢。
张三是一个普通的对象,没有响应性,张三的代理是 reactive 有响应性,是张三的代理。
所以,我们传递给子组件的是张三的代理,并不是张三本尊。 既然子组件根本就得不到张三的本尊,那么又何来直接修改呢?
如果说通过 emit 是间接修改(抛出事件),那么通过 reactive 也是通过代理间接修改的。 虽然一个是事件,一个是代理,但是有啥本质区别呢?事件是函数,Proxy 里的 set 也是函数呀。 同样都是没有记录(timeline)、判断、验证、限制,想怎么改就怎么改。
如果你还不理解,可以看看这个演化过程。
阶段一:参考官网里面依赖注入的推荐方式
代码语言:javascript复制// 阶段一:按照官网里面注入的推荐方式
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 之后,通过代理的修改方式都给堵死了,是严格遵守单向数据流了吧。
阶段二:充血实体类,把数据和方法合在一起
代码语言:javascript复制// 阶段二:充血实体类,把数据和方法合在一起
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,或者是判断、验证等,那么就有意义了。
- 数据 方法 可以在方法里面做一些操作,比如验证、判断等,那么就有意义,如果是个“空”函数,除了赋值啥都没做,那么有何意义呢?