我对vue中组件通信的思考以及provide&inject源码解析

Posted 恪愚

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了我对vue中组件通信的思考以及provide&inject源码解析相关的知识,希望对你有一定的参考价值。

一般来说,在vue中进行通信绝大多数人最先想到的一定是vuex
然而,随着实践的增加,一定会发现,有时候vuex 并不是我们需要的“答案”:

  1. 繁琐冗余的vuex,带来了你可能并不需要用到的API,还有几十K大小的文件包

组件间通信

我们大致整理下,会发现vue中组件间的通信分为下面几种情况:

  1. 父子组件
  2. 兄弟组件
  3. 祖孙组件
  4. 不相关组件

毫无疑问他们都能用 vuex 轻松实现你想要的效果。但我们来细看下:拿父子组件通信来说,

在父子组件通信中,父往子传值 和 子往父返回 常常被用v-bind + $emit、$on实现。对于简单数据流来说这确实非常方便。但有一天你遇到了这样的场景:“接口返回的数据并不是你所需要的字段”,或者是“为了减小网络传输的数据量,拿到的数据只有寥寥几个字段而你本地操作需要数十个字段”。
上面的场景中,你必然需要在拿到数据之后进行一次格式上的处理。这一步放在哪里操作呢?

普通父子组件通信引申出的另一种组件间通信方式

当然,这一步理论上放在哪里都可以:无论是在子组件中处理(created中)还是在接口返回字段之后直接处理完再传递给子组件。
但作为一个优秀的前端开发,你当然要考虑两件事:解耦抽离
什么是解耦?通俗地说:把“格式处理”这一并不和页面逻辑产生关联的步骤放在页面逻辑之外就是解耦。所以我们决定在进入到子组件时再处理数据!

所有和某个组件相关的逻辑全部交由这个组件处理,尽可能减少不相关代码对业务逻辑产生影响

既然要把数据放在子组件中处理。那么先由props拿到数据再在createdmounted生命周期内执行逻辑的行为太过麻烦。格局打开,为什么不能让子组件暴露一个方法,由父组件控制子组件的数据输入呢?就像这样:

//子组件
<script>
export default 
    methods: 
    	$_setData(list=[]) 
            this.list = xxx(list);
        ,
    

</script>
//父组件中
this.$refs.xxx.$_setData(purchaseList);

“子组件”中的“xxx”就是我们说的“对数据的处理”了。但这里还有一个问题:上面说了父组件中拿到数据后将数据传递给子组件,那么还有其它可能么?

完全有可能。拿笔者最近负责的某个项目来说:

在编辑页点“选择赠品”后进入商品选择组件,拿到数据后来到如图页面,这里还可以“继续选择”参加活动的商品。那么这个“外来”的组件提供的数据能保证和你每个使用它的项目中需要的数据格式一样么?
显然不能。

所以,我们需要实现一个全局的方法,用来接收拿到的数据,并返回本地操作所需的格式

进而可以想到,为什么我们不能维护一个全局的对象,通过暴露出去js方法,改变/提供所需的数据呢?

当然可以!我们甚至可以维护多个对象,让他们分别对应不同的场景:比如两个路由组件之间的通信、相同路由内不同组件的通信、临时跳转前的数据缓存 —— 我们一直称其为“全局缓存”。

笔者在项目中就是这么做的:

/**
 * and,
 * 全局级别:
 * info和loop_info作为真实数据,在回到列表页时才清空
 * hasloop全局标识,是否走的循环
 * 页面级别:
 * 四个cache开头的作为临时缓存,几乎都是和sku相关的属性,故只需维护一份,而且不需要index的介入(在每次离开赠品编辑页时就恢复原始值了)
 * skuindex,选择了哪个sku,它在离开sku时就无了。
 * 局部使用:
 * page-cache作为编辑页内切换时的缓存数据,真·临时。不和页面交互有联系
 */

let info = [];
let loop_info = [];   // 循环赠品保存

let page_cache_data = [
    range: 1,
    isDisabled: false,
    xxx: "", // 更多相关数据
    items: []
]; //切换循环和阶梯时的临时数据缓存
let page_cache_pdata = [
    isDisabled: false,
    xxx: "", // 更多相关数据
    items: []
]; //切换循环和阶梯时的临时数据缓存

let page_none_data = [];

let cache_sel_count = "";
let index = 0;
let sku_index = 0;

let has_loop = false; //维护一份是否loop的标识

// 初始时规范数据结构 - 结构转换
export function formatDataForInput(dataArr)
    return dataArr.map((infoq)=>
        let res = 
            isFulDisabled: infoq.isFulDisabled || false,
            sel_count: infoq.sel_count || "",
            sel_err: infoq.sel_err || "",
            item_err: infoq.item_err || "",
        
        let item = infoq.items;
        res.spuItems = item.map((info)=>
            let resq = 
                xxx: xxx, // 更多相关数据
                img: info.img || "https://si.geilicdn.com/daily_pcitem1122778024-23bd0000017857fb50db0a25386a_1080_1920.jpg",
                isChecked: info.isChecked || false,
                isDisabled: info.isDisabled || false,
                itemId: info.itemId || "",
                price: info.price || "",
                skuList: info.skuList || [],
                title: info.title || "",
            
            let sku_gifts = [];
            if(resq.skuList.length)
                for(let i=0;i<info.skuList.length;i++)
                    let selItem = info.skuList[i];
                    sku_gifts.push(
                        skuId: selItem.skuId, //商品id
                        skuName: selItem.skuName,
                        priceStr: selItem.priceStr || "",
                        isChecked: selItem.isChecked || false,
                    )
                
            
            resq.skuList = sku_gifts; //赠品
            return resq;
        )
        return res
    )


// 下面的所有读取操作都是直接读取,这是利用了js对象堆存储的特点,防止在页面跳转时忘了重新写入也能有效果。如果不需要,则在拿到数据的地方再做深拷贝处理
// 真实数据 - 写入
export function setLocalData(spuList)
    if(has_loop)
        loop_info[0] = JSON.parse(JSON.stringify(spuList));
    else 
        info[index] = JSON.parse(JSON.stringify(spuList));
    

// 真实数据 - 写入
export function setLoopLocalData(spuList)
    loop_info[0] = JSON.parse(JSON.stringify(spuList));


// 临时数据 - 写入
export function setCacheData(spuList)
    cache_info = JSON.parse(JSON.stringify(spuList));


// 临时数据 - 读取
export function getCacheData()
    return cache_info;


// 切换缓存 - 写入
export function setNormalData(spuList)
    page_cache_data = JSON.parse(JSON.stringify(spuList));


// 切换缓存 - 读取
export function getNormalData()
    return page_cache_data;


// 切换缓存2 - 写入
export function setNormalData2(spuList)
    page_cache_pdata = JSON.parse(JSON.stringify(spuList));


// 切换缓存2 - 读取
export function getNormalData2()
    return page_cache_pdata;



// 全局index - 写入 - 第几级赠品操作
export function setIndex(i)
    index = i;


// 全局index - 读取
export function getIndex()
    return index;


// 全局loop - 写入
export function setLoop(val)
    has_loop = val;


// 全局loop - 读取
export function getLoop()
    return has_loop;


// 真实数据 - 读取
export function getLocalData()
    return has_loop ? loop_info[0] : info[index];


// 真实数据 - 读取
export function getLoopLocalData()
    return loop_info[0];

在vue中,单页应用的路由改变本质是js动态选择映射的组件结构。和微前端或是普通网页跳转不一样,所以可以放心使用“全局对象”!

利用全局缓存来实现通信的方式确实是一个即巧妙,又非常便捷的方式。就目前来看,并没有什么弊端。

vue3中一种类似的数据通信方式:模块化共享数据

和上面的有些相似。得益于vue3 Composition API的强大。我们可以直接从某个组件中暴露数据和方法,也可以在根组件中直接暴露数据:

// Root.js 
export const sharedData = ref('') 
export default  
    name: 'Root', 
    setup() 
        // ... 
    , 
    // ... 

使用:

import  sharedData  from './Root.js' 
export default  
    name: 'Root', 
    setup() 
        // 这里直接使用 sharedData 即可 
     

使用这种方式必须注意两点:

  • 用户必须明确知道这个数据是在哪个模块定义的
  • 模块化提供的数据,是没有任何上下文的,仅仅是在这个模块中定义的数据,如果想要根据不同的情况提供不同数据,那么从 API 层面设计就需要做更改。

父子祖孙通信的另一种方式:provide & inject

和上面两种可以直观地“寻根朔源”的方法不同,vue还提供了一种逐级查找的方法:
在Vue 3.0,除了可以继续沿用2中 Options 的依赖注入,还可以使用依赖注入的 API 函数 provide 和 inject —— 在 setup 函数中调用它们。

// Provider 
import  provide, ref  from 'vue' 

export default  
    setup()  
        const theme = ref('dark') 
        provide('theme', theme) 
     

// Consumer 
import  inject  from 'vue' 

export default  
    setup()  
        const theme = inject('theme', 'light') 
        return  
            theme 
         
     

其中,inject 函数接受第二个参数作为默认值,如果祖先组件上下文没有提供 theme,则使用这个默认值。

使用这两个API,祖先组件不需要知道哪些后代组件在使用它提供的数据,后代组件也不需要知道注入的数据来自哪里。

很像「发布-订阅」模式有木有~

provide&inject是怎样写成的

我们先来看 provide API:

function provide(key, value)  
    let provides = currentInstance.provides 
    const parentProvides = currentInstance.parent && currentInstance.parent.provides 
    if (parentProvides === provides)  
        provides = currentInstance.provides = Object.create(parentProvides) 
    
    provides[key] = value 

在创建组件实例的时候,组件实例的 provides 对象指向父组件实例的 provides 对象:

const instance =  
    // 依赖注入相关 
    provides: parent ? parent.provides : Object.create(appContext.provides), 
    // 其它属性 
    // ... 

能够实现上面说的“父子组件都不必关心来源和去向”的关键是:“在默认情况下,组件实例的 provides 继承它的父组件,但是当组件实例需要提供自己的值的时候,它使用父级提供的对象创建自己的 provides 的对象原型。通过这种方式,在 inject 阶段,我们可以非常容易通过原型链查找来自直接父级提供的数据。”

另外,如果组件实例提供和父级 provides 中有相同 key 的数据,是可以覆盖父级提供的数据的。

再来看看inject中都做了什么:

function inject(key, defaultValue)  
    const instance = currentInstance || currentRenderingInstance 
    if (instance)  
        const provides = instance.provides 
        if (key in provides)  
            return provides[key] 
         
        else if (arguments.length > 1)  
            return defaultValue 
         
        else if ((process.env.NODE_ENV !== 'production'))  
            warn(`injection "$String(key)" not found.`) 
         
     

inject 支持两个参数,第一个参数是 key,通过它访问组件实例中的 provides 对象对应的 key,层层查找父级提供的数据。第二个参数是默认值,如果查找不到数据,则直接返回默认值。

如果既查找不到数据且也没有传入默认值,则在非生产环境下报警告,提示用户找不到这个注入的数据!

这种方式的缺点也是显而易见的:“如果在某次改动时我们不小心挪动了有依赖注入的后代组件的位置(当然这种情况应该是很少见的),或者是挪动了提供数据的祖先组件的位置,都有可能导致后代组件丢失注入的数据,进而导致应用程序异常。”
毕竟,这俩API只是为“同支”组件通信准备的。

以上是关于我对vue中组件通信的思考以及provide&inject源码解析的主要内容,如果未能解决你的问题,请参考以下文章

我对vue中组件通信的思考以及provide&inject源码解析

vue组件之间通信(provide/inject与$attrs/$listeners) 之四

vue3源码分析——实现组件通信provide,inject

vue3源码分析——实现组件通信provide,inject

vue3源码分析——实现组件通信provide,inject

Vue Provide / Inject 详细介绍(跨组件通信响应式变化版本变化)