尽信官网,不如那啥。
vue的版本一直在不断更新,内部实现方式也是不断的优化,官网也在不断更新。 既然一切皆在不停地发展,那么我们呢?等着官网更新还是有自己的思考? 我觉得我们要走在官网的前面,而不是等官网更新后,才知道原来可以这么实现。。。
我习惯先给大家一个整体的概念,然后再介绍各个细节。
先整理一下和单向数据流有关的信息,做个脑图:
列个大纲看看:
再来看看各种方式的对比:
方式 | 实现手段 | 有无记录 | 有无限制、验证 | 官网意见 | 适合场景 |
---|---|---|---|---|---|
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 | 可以有 |
这样应该有一个明确的总体感觉了吧。
为啥弄得这么复杂?还不是因为两点:
如果没有 reactive,那么也就不会这么乱糟糟的了,让我们细细道来。
https://cn.vuejs.org/guide/components/props.html#one-way-data-flow
官网里关于 props 的单向数据流是这样描述的:
所有的 props 都遵循着单向绑定原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。 这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱而难以理解。
整理一下重点:
其实 props 本来就是单向的,用于子组件接收父组件传入的数据,完全没有让子组件修改父组件里的数据的功能。
那么为何还要强调单向数据流呢?原因有二:引用类型 和 reactive!
props可以设置两种数据类型:
现在,仅从代码的角度看看 props 在什么情况可以改、不可以改。
那么问题来了:
所以重点就是这个 reactive !如果没有他,props 即使直接改了,也无法保证响应性,从而被我们所抛弃,也就不用纠结和争论了。
那么 reactive 到底是怎么回事?大家先不要着急,先看看官网允许的情况,然后再对比思考。那谁不是说了吗,没有对比就没有那啥。。。
为什么会混乱?想到了一种可能性:父组件定义了一个 reactive 的数据,然后通过 props 传递个多个子组件,然后某个子组件里面还有很多子子组件,也传入了这个数据。 某个时候发现状态异常变更,那么问题来了:到底是谁改了状态?(后续跟进)
emit 本意是子组件向父组件抛出一个事件,然后 vue 内部提供了一种方式(update:XXXXX),可以实现子组件修改父组件的需求。
<!-- 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 并不是直接修改,而是通过向父组件抛出一个事件,父组件响应这个事件来实现的。所以,不是直接改,并没有破坏单向数据流。
这个说法嘛,确实很官方。只是从结果来看,还是子组件发起了状态的变更,那么问题来了,如果是上面的那种情况,可以方便获知是谁改了状态吗?(似乎也会导致混乱和难以理解吧)
那么问题来了:单向数据流,是限制发起者,还是手段?
不要钻牛角尖了,其实是有一个很实际的需求:
举个例子,各种 UI库 都有 xx-input 组件,外面用 v-model 绑定一个变量,然后 xx-input 里面必须可以修改传入的变量,而且要保持响应性对吧,否则咋办?
v-model + emit 就是解决这个实际需求的。(解决问题,给大家带来方便,然后才会选择vue,其余其他的嘛。。。)
当然,可以使用 ref,但是 ref 的本体是一个class,属于引用类型,如果传入 ref 本体的话,相当于传入一个对象给子组件。这个咋算?
vue 现在的做法是,template 会默认把 ref.value 传给子组件,而不是 ref 本体,这样传入的还是基础类型。
所以,这是实现父子组件之间,值类型的响应性的唯一方法。
https://cn.vuejs.org/guide/components/v-model.html
defineModel 是 vue3.4 推出来的语法糖(稳定版),内部依然使用了 emit 的方式,所以可以视为和 emit 等效。
官网示例代码:
<!-- 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://cn.vuejs.org/guide/components/provide-inject.html#working-with-reactivity
父子组件之间传值,就不得不说说依赖注入,那么是否存在“单向数据流”的问题呢?那也是必然应该存在呀,只是官网没有直接明确说。
注意:依赖注入只负责传递数据,并不负责响应性。
官网的意思,是让我们在父组件实现状态的变更,然后把状态和负责状态变更的函数一起传给(注入到)子组件,子组件不要直接改状态,而是通过调用 【父组件传入的函数】 来变更状态。
官网原文:
当提供 / 注入响应式的数据时,建议尽可能将任何对响应式状态的变更都保持在供给方组件中。这样可以确保所提供状态的声明和变更操作都内聚在同一个组件内,使其更容易维护。 有的时候,我们可能需要在注入方组件中更改数据。在这种情况下,我们推荐在供给方组件内声明并提供一个更改数据的方法函数:
官网推荐的方式是这样的:
<!-- 在供给方组件内 -- > 父组件
<script setup>
import { provide, ref } from 'vue'
// 数据、状态
const location = ref('North Pole')
// 变更状态的函数
function updateLocation() {
location.value = 'South Pole'
}
// 提供数据和操作方法(function)
provide('location', {
location,
updateLocation
})
</script>
<!-- 在注入方组件 --> 子组件
<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 恰恰相反。
当然了,自己写一个函数也是有好处的,比如:
const 张三 = reactive({name:'zs',age:20})
const setAge = (age) => {
if (age < 0) {
// 年龄不能是负数
}
// 其他验证
// 通过验证,赋值
张三.age = age
// 还可以做记录(timeline)
}
这样就不能瞎改年龄了。或者根据出生日期自动计算年龄。 不是说不能自己写函数,而是说这个函数要有点意义。
props 和注入说完了,那么就来到了状态管理,这里以 pinia 为例。
状态管理也涉及单向数据流吗?那当然是必须滴呀,否则 Vuex 的时候,为啥总强调要通过 mutation 去变更状态,而不要直接去改状态?
那么 pinia 为什么提供了 $state 用于“直接”改状态呢?这还得看看源码:
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)。
可能你会说,$state 并不是状态自己的属性,当然不算直接修改了,那么我们来试试直接修改状态。
通过测试我们可以发现:
那么是怎么实现的呢?
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);
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 不仅没有阻止我们直接改属性,还很贴心的做了记录。
以前就一直对这个 timeline 非常好奇,想知道记录的是什么,但是奈何各种原因总是看不到,现在vue 推出了,终于看到了。
这里的记录非常详细,有状态名称、动作、属性名称、新旧值、触发时间等等信息,只是有个小问题,到底是谁改了状态? 没发现有定位代码位置的功能。
好了,终于到了比较有争议的 reactive 了,大家有没有等着急? 首先 reactive 的本质是 Proxy,而 Proxy 是代理,这个想必大家都知道,所以我们可以设置这样的代码:
const 张三 = {
name:'zhangsan',
age:20
}
const 张三的代理 = reactive(张三)
const setAge = (age) => {
if (age < 0) {
// 年龄不能是负数
}
// 其他验证
// 通过验证后才能赋值
张三的代理.age = age
}
平时大家都是一步成,现在分成了两步,是不是就很明确了呢。
张三是一个普通的对象,没有响应性,张三的代理是 reactive 有响应性,是张三的代理。
所以,我们传递给子组件的是张三的代理,并不是张三本尊。 既然子组件根本就得不到张三的本尊,那么又何来直接修改呢?
如果说通过 emit 是间接修改(抛出事件),那么通过 reactive 也是通过代理间接修改的。 虽然一个是事件,一个是代理,但是有啥本质区别呢?事件是函数,Proxy 里的 set 也是函数呀。 同样都是没有记录(timeline)、判断、验证、限制,想怎么改就怎么改。
如果你还不理解,可以看看这个演化过程。
// 阶段一:按照官网里面注入的推荐方式
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 之后,通过代理的修改方式都给堵死了,是严格遵守单向数据流了吧。
// 阶段二:充血实体类,把数据和方法合在一起
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)