写点什么

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

  • 2024-08-30
    福建
  • 本文字数:6906 字

    阅读完需:约 23 分钟

总览 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),没有做任何限制、验证。


画个表格对比一下:


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



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


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),可以实现子组件修改父组件的需求。


<!-- 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 等效。


官网示例代码:


<!-- 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

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


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


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


官网原文:


当提供 / 注入响应式的数据时,建议尽可能将任何对响应式状态的变更都保持在供给方组件中。这样可以确保所提供状态的声明和变更操作都内聚在同一个组件内,使其更容易维护。有的时候,我们可能需要在注入方组件中更改数据。在这种情况下,我们推荐在供给方组件内声明并提供一个更改数据的方法函数:


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


<!-- 在供给方组件内 -- > 父组件<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 去变更状态,而不要直接去改状态?


$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 是代理,这个想必大家都知道,所以我们可以设置这样的代码:



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 = 3
复制代码


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


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


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


小结


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

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

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

  • reactive 我觉得可以直接改,因为本身就是一个代理(Proxy),直接用就好了。如果外面再套一个 Proxy 有何意义呢?当然了,如果可以加上 timeline,或者是判断、验证等,那么就有意义了。

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


文章转载自:金色海洋(jyk)

原文链接:https://www.cnblogs.com/jyk/p/18338175

体验地址:http://www.jnpfsoft.com/?from=infoq

用户头像

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
【vue3】详解单向数据流,大家千万不用为了某某而某某了_JavaScript_快乐非自愿限量之名_InfoQ写作社区