【vue3】詳解單向數據流,大家千萬不用為了某某而某某了。

来源:https://www.cnblogs.com/jyk/p/18338175
-Advertisement-
Play Games

總覽 Vue3 的單向數據流 盡信官網,不如那啥。 vue的版本一直在不斷更新,內部實現方式也是不斷的優化,官網也在不斷更新。 既然一切皆在不停地發展,那麼我們呢?等著官網更新還是有自己的思考? 我覺得我們要走在官網的前面,而不是等官網更新後,才知道原來可以這麼實現。。。 我習慣先給大家一個整體的概 ...


總覽 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),可以實現子組件修改父組件的需求。

<!-- 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 = 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,或者是判斷、驗證等,那麼就有意義了。

  • 數據 + 方法
    可以在方法裡面做一些操作,比如驗證、判斷等,那麼就有意義,如果是個“空”函數,除了賦值啥都沒做,那麼有何意義呢?


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • title: 使用 clearError 清除已處理的錯誤 date: 2024/8/5 updated: 2024/8/5 author: cmdragon excerpt: 摘要:“文章介紹了clearError函數的作用與用法,用於清除已處理的錯誤並可實現頁面重定向,提升用戶體驗。通過示例展示 ...
  • title: 使用 addRouteMiddleware 動態添加中間 date: 2024/8/4 updated: 2024/8/4 author: cmdragon excerpt: 摘要:文章介紹了Nuxt3中addRouteMiddleware的使用方法,該功能允許開發者動態添加路由中間件 ...
  • Vue2存在源碼可維護性差、性能問題和API相容性不足等缺點。Vue3通過monorepo管理、TypeScript開發、性能優化和引入Composition API等方式,顯著提升了源碼可維護性、編程體驗、TypeScript支持和邏輯復用實踐,從源碼、性能和語法API三方面進行了優化。 ...
  • 最近練習了一些前端演算法題,現在做個總結,以下題目都是個人寫法,並不是標準答案,如有錯誤歡迎指出,有對某道題有新的想法的友友也可以在評論區發表想法,互相學習 ...
  • Vue 3在編譯template過程中,會通過patchFlags優化虛擬DOM更新,提升性能。這些標誌通過位運算進行操作,包括動態文本、類、樣式、屬性、靜態提升等。patchFlags的使用極大地提高了diff演算法的效率。 ...
  • title: 使用 abortNavigation 阻止導航 date: 2024/8/3 updated: 2024/8/3 author: cmdragon excerpt: 摘要:在Nuxt3中,abortNavigation是一個輔助函數,用於路由中間件內阻止不符合條件的頁面訪問,實現許可權控 ...
  • 動態路由 動態菜單 //通過迴圈組件完成動態菜單 <el-menu active-text-color="#ffd04b" background-color="#545c64" class="el-menu-vertical-demo" text-color="#fff" :collapse="is ...
  • 先來看結果圖(轉.gif掉幀了): 完整源碼分享網址: https://share.weiyun.com/Vpkp5KP3 1 首先初始化用到的所有圖片: 1 const images = [ 2 "./img/girls.jpg", 3 "./img/ball.png", 4 "./img/wat ...
一周排行
    -Advertisement-
    Play Games
  • 示例項目結構 在 Visual Studio 中創建一個 WinForms 應用程式後,項目結構如下所示: MyWinFormsApp/ │ ├───Properties/ │ └───Settings.settings │ ├───bin/ │ ├───Debug/ │ └───Release/ ...
  • [STAThread] 特性用於需要與 COM 組件交互的應用程式,尤其是依賴單線程模型(如 Windows Forms 應用程式)的組件。在 STA 模式下,線程擁有自己的消息迴圈,這對於處理用戶界面和某些 COM 組件是必要的。 [STAThread] static void Main(stri ...
  • 在WinForm中使用全局異常捕獲處理 在WinForm應用程式中,全局異常捕獲是確保程式穩定性的關鍵。通過在Program類的Main方法中設置全局異常處理,可以有效地捕獲並處理未預見的異常,從而避免程式崩潰。 註冊全局異常事件 [STAThread] static void Main() { / ...
  • 前言 給大家推薦一款開源的 Winform 控制項庫,可以幫助我們開發更加美觀、漂亮的 WinForm 界面。 項目介紹 SunnyUI.NET 是一個基於 .NET Framework 4.0+、.NET 6、.NET 7 和 .NET 8 的 WinForm 開源控制項庫,同時也提供了工具類庫、擴展 ...
  • 說明 該文章是屬於OverallAuth2.0系列文章,每周更新一篇該系列文章(從0到1完成系統開發)。 該系統文章,我會儘量說的非常詳細,做到不管新手、老手都能看懂。 說明:OverallAuth2.0 是一個簡單、易懂、功能強大的許可權+可視化流程管理系統。 有興趣的朋友,請關註我吧(*^▽^*) ...
  • 一、下載安裝 1.下載git 必須先下載並安裝git,再TortoiseGit下載安裝 git安裝參考教程:https://blog.csdn.net/mukes/article/details/115693833 2.TortoiseGit下載與安裝 TortoiseGit,Git客戶端,32/6 ...
  • 前言 在項目開發過程中,理解數據結構和演算法如同掌握蓋房子的秘訣。演算法不僅能幫助我們編寫高效、優質的代碼,還能解決項目中遇到的各種難題。 給大家推薦一個支持C#的開源免費、新手友好的數據結構與演算法入門教程:Hello演算法。 項目介紹 《Hello Algo》是一本開源免費、新手友好的數據結構與演算法入門 ...
  • 1.生成單個Proto.bat內容 @rem Copyright 2016, Google Inc. @rem All rights reserved. @rem @rem Redistribution and use in source and binary forms, with or with ...
  • 一:背景 1. 講故事 前段時間有位朋友找到我,說他的窗體程式在客戶這邊出現了卡死,讓我幫忙看下怎麼回事?dump也生成了,既然有dump了那就上 windbg 分析吧。 二:WinDbg 分析 1. 為什麼會卡死 窗體程式的卡死,入口門檻很低,後續往下分析就不一定了,不管怎麼說先用 !clrsta ...
  • 前言 人工智慧時代,人臉識別技術已成為安全驗證、身份識別和用戶交互的關鍵工具。 給大家推薦一款.NET 開源提供了強大的人臉識別 API,工具不僅易於集成,還具備高效處理能力。 本文將介紹一款如何利用這些API,為我們的項目添加智能識別的亮點。 項目介紹 GitHub 上擁有 1.2k 星標的 C# ...