当前位置:网站首页>让mixin为项目开发助力【及递归优化新尝试】

让mixin为项目开发助力【及递归优化新尝试】

2022-08-10 17:59:00 恪愚

背景

我们通常会遇到这么一个场景:有几个基本功能一样的组件,但是他们之间又存在着足够的差异。这时候你就来到了一个岔路口:我是把他们“按部就班”地写成不同的组件呢?还是保留为一个“公共组件”,然后通过props传参进行不同功能之间的区分呢?

现在还有一个场景:在一些组件(甚至是项目中全部和某个功能有关的组件)中,有某个功能是相同的。而且都需要利用这个功能进行后续操作。你又需要选择了,但是这次有一个前提:肯定是要“复用”的 —— 公共组件?还是 mixin

这里其实笔者个人认为并将其分为“css- UI复用”和“功能复用”两种方式。这里先按下不提。本文默认讲的是后者。

我们现在来分析下:
在第一个场景中,其实两种解决方案都不够完美:如果拆分成多个组件,你就不得不冒着一旦功能变动就要在所有相关文件中更新代码的风险,这违背了 DRY 原则;反之,太多的 props 传值会让代码变得混乱不堪,后续难以维护、团队理解困难,效率降低。那有没有更好的方法?
再来看第二个场景,其实我们很清楚地知道:这时候我们需要的不是一个可以传值的组件,而是一个类似于插件一样的 js 代码(这么说能够理解吧)!

使用Mixin吧

Vue 中的 Mixin 对编写函数式风格的代码很有用,因为函数式编程就是通过减少移动的部分让代码更好理解。Mixin 允许你封装一块在应用的其他组件中都可以使用的函数。如果使用姿势得当,他们不会改变函数作用域外部的任何东西。因此哪怕执行多次,只要是同样的输入你总是能得到一样的值。

如何使用

mixin其实有两种写法 —— ObjectFunction
它们都可以在单个组件或者全局中引用。但对于 function 形式的mixin,笔者更推荐将其作为组件级别使用(而非全局的)。

先看第一种写法:
假设有一对不同的组件,它们的作用是通过切换状态(Boolean)来展示或者隐藏模态框或提示框。这些提示框和模态框除了功能相似以外,没有其他共同点:它们看起来不一样,用法不一样,但是逻辑一样。
这时我们可以将它们的公共逻辑部分封装为一个js文件:

// mixins目录下的toggle.js文件
export const toggle = {
    
    data() {
    
        return {
    
            isShowing: false
        }
    },
    methods: {
    
        toggleShow() {
    
            this.isShowing = !this.isShowing;
        }
    }
}

一般我们选择新建一个专门的mixin目录。在里面创建一个文件含有.js扩展名,为了使用Mixin我们需要输出一个对象。(es6 Modules)

然后使用mixins:[] 的方式引入mixin文件,(引入后)对象中的属性可直接使用(就像开头说的“插件”一样):

import {
     toggle } from './mixins/toggle';

//...
const Modal = {
    
    template: '#modal',
    mixins: [toggle],
    //...
};

const Tooltip = {
    
    template: '#tooltip',
    mixins: [toggle],
    //...
};

第二种写法:
这种形式其实就特别适用于开头说的第二种情况。因为 mixin 内部一个组件该有的它都可以具备。而且上面也说了:当mixin被引入后它内部的东西可以被直接使用 —— 其实就是被merge到引用它的组件中了!(相当于对父组件的扩展)

假如我们请求完要根据数据给出提示并且要给出降级方案(默认提示)。这个需求基本是项目中必不可少而且不止一次出现的。但是像一般情况没有引用其余外部UI而且又不是模态框那样的“通用提示”,放在全局中不太合适。这时候就需要我们的 mixin 出场了:

// mixins目录下的index.js文件
function formatRes(res) {
    
    const data = res.data;
    if (data.status.code === '表示通过的数') {
    
      return data
    } else {
    
      if (判断是否引入了提示框组件) {
    
        //提示框组件的调用和传参
      }
      return data
  
    }
  }
  
  var mixin = function (options) {
    
    let defaultData = {
    }
    let defaultMethods = {
    }
  
    defaultMethods.formatRes = formatRes;
    return {
    
      data: function () {
       //这个会在引用它的组件的data中出现
        return defaultData;
      },
      methods: defaultMethods,   //同上,在引用它的组件中可直接通过this.formatRes调用到
    }
  }
  
  export default mixin

因为是函数形式,所以在引用vue的script开头应该这么写:

import mixin from '../mixin/index'
const mixinCommen = mixin();
//在export default中这么写:
mixins: [mixinCommen],

使用:

const res = await this.$http({
       //封装的请求库
    method: 'GET',
    url: '请求地址',
    params: {
    
        param: {
    
        }
    }
})
const {
    result} = this.formatRes(res)   // 使用mixins函数
if(result) {
    
    this.areaList = result
} else {
    
    //...
    return false
}
return true

闭包!一方面让外部函数可以接收参数,另一方面函数内暴露对象的写法和vue组件中data必须是函数的原理一样 —— 让一个地方的修改不影响其余地方的数据。

合并和冲突

Mixin 中的生命周期的钩子也同样是可用的。因此,当我们在组件上应用 Mixin 的时候,有可能会有钩子的执行顺序的问题。默认 Mixin 上会首先被注册,组件上的接着注册,这样我们就可以在组件中按需要重写 Mixin 中的语句。组件拥有最终发言权。

在vue的源码中,我们可以很清楚的看到:mergeOptions 会去遍历 mixins ,parent 先和 mixins 合并,然后才去和 child 合并

function mergeOptions(parent, child, vm) {
        
    if (child.mixins) {
            

        for (var i = 0, l = child.mixins.length; i < l; i++) {
    
            parent = mergeOptions(parent, child.mixins[i], vm);
        }
    }    
    //...
}

而对于生命周期来说,vue会把所有的钩子函数保存进一个数组。并顺序执行(清空这个数组)。
在这里面,混入对象的钩子会在组件自身的钩子之前被调用。如果两者有重复,则组件的方法将会重写mixin里的方法 —— methods、props等等也是一样!

Mixin还能干啥?

你有没有遇到过这样的场景:有如下代码结构

		父组件0
		/	\
	父组件	父组件
	/			\
子组件A			父组件
					\
					子组件B

现在要从 子组件A 向 父组件0 传递数据,或者说“通信”。你怎么办?localStorage?vuex?

抛开使用和学习成本、编辑器代码智能补全等一系列“外物”,假如一个项目中只有这一个地方需要跨任意组件传递数据,而你却引入了整个vuex。在代码体积上也是一个不小的增量 —— 而你原本可以避免的。

我突然想到,为什么我们不能直接操作vnode呢?就像这样:

export default {
    
    methods: {
    
        dispatch(componentName, eventName, params) {
    
            let parent = this.$parent || this.$root;
            let name = parent.$options.componentName;

            while(parent && (!name || name !== componentName)) {
    
                parent = parent.$parent;

                if(parent) {
    
                    name = parent.$options.componentName;
                }
            }
            if(parent) {
    
                parent.$emit.apply(parent, [eventName].concat(params))
            }
        },
    }
}

我写了一段js,这个函数接收三个参数:目标组件的componentName、emit的事件名、以及想要传出去的参数。

你是否还记得在“组件间通信”的方法中有一个鲜为人知的方法:provide & inject。它的优势也是为人诟病的一点就是“使用这两个API,祖先组件不需要知道哪些后代组件在使用它提供的数据,后代组件也不需要知道注入的数据来自哪里。”
现在mixin一定程度上解决了这个问题。

我们把这个js文件作为mixin引入 —— 在需要往外传数据的组件中

import DataMixin from "xxx.js";

export default {
    
  mixins: [DataMixin],
  //...
  methods: {
    
  	onHandleChangeStock(data) {
    
      this.dispatch('comboEditRoot', 'stock-transfer-send', data); //使用!
    },
  }
}

然后在某一个祖先组件上,你只需要在 和data属性同级处增加componentName属性并赋予和第一个参数相同的值,然后在created生命周期中监听事件 即可:

this.$on('stock-transfer-send', (data) => {
    
  console.log('传出来的数据', data)
  this.formData.stockLimit = data;
})

你有没有发现局限?上面的代码只适用于“同支子孙组件传递数据给祖先组件”。往任意组件怎么传?
由于mixin的局限,我们可以先找到一个公共父组件,然后再去找其下的具体子组件:

function broadcast(_this=this, componentName, eventName, params) {
    
    _this.$children.forEach(child => {
    
      var name = child.$options.componentName;
  
      if (name === componentName) {
    
        child.$emit.apply(child, [eventName].concat(params));
      } else {
    
        // console.log('child',child.$options.componentName)
        broadcast.apply(child, [child, componentName, eventName].concat([params]));
      }
    });
}
export default {
    
    methods: {
    
      dispatch(componentName, eventName, params, uncle=false, childName="") {
    
        var parent = this.$parent || this.$root;
        var name = parent.$options.componentName;
  
        while (parent && (!name || name !== componentName)) {
    
          parent = parent.$parent;
  
          if (parent) {
    
            name = parent.$options.componentName;
          }
        }
        if (parent) {
    
          if(uncle) {
    
            console.log('parent', parent)
            broadcast(parent, childName, eventName, params);
          } else {
    
            parent.$emit.apply(parent, [eventName].concat(params));
          }
        }
      },
      // 父组件传递数据给人以一个子组件
      broadcast(componentName, eventName, params) {
    
        broadcast.call(this, componentName, eventName, params);
      },
    }
};
  

改写后的 dispatch 方法就达到了这一效果。而单独使用broadcast则是从父组件传出数据给某一个子组件。

!注意:上面说的“任意”是在使用效果来看。而对于开发过程中,“任意”是指你可以随意将componentName插到某一个组件中去。

有了上面的实践,我突然觉得能够继续完成之前的一个畅想:有一个方法能够在不深入侵入业务代码的同时完成任意组件联动的功能。即:数据互通。

让我们改写一下dispatch方法:

function broadVal(_this=this, componentName, propName) {
    
    _this.$children.forEach(child => {
    
      var name = child.$options.componentName;
  
      if (name === componentName) {
    
        return child[propName];
      } else {
    
        broadVal.apply(child, [child, componentName, propName].concat([params]));
      }
    });
}
export default {
    
    methods: {
    
      focusWatchData(componentName="", childName="", propName) {
    
        let parent = this.$parent || this.$root;
        let name = parent.$options.componentName;

        if(componentName) {
    
        	while(parent && (!name || name !== componentName)) {
    
	            parent = parent.$parent;
	
	            if(parent) {
    
	                name = parent.$options.componentName;
	            }
	        }
        } else {
    
        	parent = this;
        }
        if(parent) {
    
        	if(!childName) {
    
            	return parent[propName];
            }
            let propVal = broadVal(parent, childName, propName);
            return propVal;
        }
      }
    }
};

focusWatchData函数接收三个参数:父组件 componentName(为空表示从当前组件往下找)、子组件 componentName(为空表示只往上找)、以及 propName(要获取的data中的属性名)
同样将此js文件以mixin引入在某个组件(被触发方)中,然后在想要关联的组件(触发方)中插入 componentName 即可。

如果你的param是多个值,请使用call代替apply使用!

【更新· 优化

优化树形结构查找

可以看到上面“任意组件传值”和“父组件向子组件传值”是采用「递归」组件写法。能不能优化呢?
可能你第一时间想到递归中的“尾递归”。首先,上面已经使用了这种方式。其次,在 js 中并不能使用尾递归:

Proper tail calls have been implemented but not yet shipped given that a change to the feature is currently under discussion at TC39. —— V8引擎官方团队

然后笔者想到了著名的“二叉查找树”。很可惜,树的查找方式也是递归,不过是二叉查找树在树结构范围内效率更高。

偶然间突然想到,能不能在遍历第一层结构的时候,往下查找第二层,如果有第二层,就把它加到正在遍历的数组中,以试图让数组“一直”遍历下去?
不能的。因为在for循环形成的闭包中,是不能动态更改引用元素(被遍历的元素实际改变了,但是遍历这一行为仍然终止在其刚开始遍历时的length那)。更好理解的说你可以理解为C语言中的“形参和实参”。

但顺着这个思路,笔者紧接着想到:能不能用一个“很大”的数字去遍历,在里面拿到已经改变了的元素的子元素:

function flag(arr) {
    
  let result = []
  let originArr = JSON.parse(JSON.stringify(arr));
  for (let i=0; i< 100000; i++) {
    
    let item = originArr[i];
    console.log('1',item, item.children instanceof Array, item.children.length, originArr, originArr.length);
    if (item.children && item.children instanceof Array && item.children.length > 0) {
     // 如果当前child为数组并且长度大于0,才可进入flag()方法
      originArr = originArr.concat(item.children);
      delete item['children'];
    }
    result.push(item)
  }
  return result
}

其中 arr 是这样的结构:

const arr = [
    {
     xxx: xxx, children: [{
    xxx: xxx, children: []}] },
    {
     xxx: xxx, children: [] },
    {
     xxx: xxx, children: [] },
];

恰好,vue就是这样一颗!

我们唯一需要注意的是,让其在该结束时及时结束。不然对造成的空间和性能浪费来说,又为什么要替换掉「递归」呢?

拿上面的broadVal函数来说,可以这么改造:

function broadVal(_this=this, componentName, propName) {
    
    let originArr = JSON.parse(JSON.stringify(_this));
    for (let i=0; i< 100000; i++) {
    
        let item = originArr[i];
        if (item.$children && item.$children instanceof Array && item.$children.length > 0) {
     // 如果当前child为数组并且长度大于0,才可进入flag()方法
            if(item.$options.componentName && componentName === item.$options.componentName) {
    
                return item[propName];
            }
            originArr = originArr.concat(item.$children);
            delete item['children'];
        }
    }
}

结尾

当然,上面代码还可根据业务进一步优化。而且对于再复杂些的场景,mixin 是决然不够的。还是建议封装一个轻量的 store 或者用第三方的简洁vuex库。

顺便一说,上面最后一点的想法实现已然在理论上违反了一些“设计原则”。虽然确实简便好用~

原网站

版权声明
本文为[恪愚]所创,转载请带上原文链接,感谢
https://yunxiaomeng.blog.csdn.net/article/details/126201955