Vue.js 进阶技巧 - 组件封装相关

720 阅读8分钟

在组件开发中,最难得环节应该是解耦组件的交互逻辑,尽量把复杂的逻辑分发到不同的子组件中,然后彼此建立联系,比如Vue中的计算属性(computed)和混合属性(mixins)等技术点的合理使用

Vue 组件的 props 校验 - validator

通常在编写通用组件时,props的传递必不可少,在定义对应的props时,建议使用对象的形式,这样可以进行自定义校验规则(也包含常规的类型的设置、默认值)等;

定义一个通用的button组件

// 判断参数是否是其中之⼀
function oneOf(value, validList) {
  for (let i = 0; i < validList.length; i++) {
    if (value === validList[i]) {
      return true;
    }
  }
  return false;
}

export default {
  props: {
    size: {
      validator(value) {
        return oneOf(value, ["small", "large", "default"]);
      },
      default: "default",
    },
    checked: {
      type: [String, Number, Boolean],
      default: false
    },
    disabled: {
      type: Boolean,
      default: false,
    },
  },
};

组件间的通信可以参考ref $parent $children进行跨组件通信,但是在跨级和兄弟组件间通信时就得依赖其他插件或者工具了

v-model 语法糖

v-model常用于表单元素上进行数据的双向绑定,可以拆解为props:value和events:input,即在自定义的组件上使用v-model,必须满足组件提供一个名为value的prop和名为input的自定义事件

<InputNumber :value="value" @input="handleChange" />
<InputNumber v-model="value" />

实现原理

组件的设计理念是:组件内部不能更改props值,只能通过父组件修改,因此内部通常有一个内部data去维护数据,并通过watch进行监听data变化同时更新内部定义的data;组件内部修改自己维护的data数据时,会通过exit input事件到父组件,父组件通过@input进行监听后由父组件修改value;

model选项更改语法糖对应的key值

如果你不想⽤ value 和 input 这两个名字,从 Vue.js 2.2.0 版本 开始,提供了⼀个 model 的选项,可以指定它们的名字

<script>
export default {
  name: 'InputNumber',
  props: {
    number: {
      type: Number
    }
  },
  model: {
    prop:
      'number',
    event: 'change'
  },
  data() {
    return {
      currentValue: this.number
    }
  },
  watch: {
    value(val) {
      this.currentValue = val;
    }
  },
  methods: {
    increase(val) {
      this.currentValue += val;
      this.$emit('number', this.currentValue);
    }
  }
}


// .sync 语法糖
// this.$emit('update:value', this.value + val)
// <InputNumber :value.sync="value" />
</script>

computed的set操作

常规的computed是采用函数的方式进行使用的,此时只是默认调用了他的get方法,当书写为对象格式时,就可以添加set方法了;

// 普通函数形式
computed: {
  fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

// 对象形式
computed: {
  fullName: {
    get() {
      return `${this.firstName} ${this.lastName}`;
    },
    set(val) {
      const names = val.split(' ');
      this.firstName = names[0];
      this.lastName = names[names.length - 1];
    }
  }
}

无依赖组件间的通信 - provide/inject

provideinject 绑定并不是可响应的。这是刻意为之的。然⽽,如果你传⼊了⼀个可监听的对象,那么其对象的属性还是可响应的。其他情况下都不是响应式的,后续的更改不会导致子组件的inject更新;

用途主要是子组件获取上级组件的状态,跨级组件间建立了一种主动提供依赖注入的关系

provide 全局父组件代替VueX等数据管理

可以在全局父组件里通过provide将所有需要对外提供的全局属性方法进行透传,包括不限于计算属性、方法等,甚至将整个app.vue实例进行相应的透传;

// 全局组件
<template>
  <div>
    <router-view></router-view>
  </div>
</template>
<script>
export default {
  name: "App",
  provide() {
    return {
      app: this,
    };
  },
};
</script>

// 子组件
<template>
  <div>
    {{ app.userName }}
  </div>
</template>
<script>
export default {
  name: "user",
  inject: ["app"],
  methods: {
    changeUserInfo() {
      // 这⾥修改完⽤户数据后,通知 app.vue 进行相应的更新;
      $.ajax("/dataApi/update", ({errno,data}) => {
        errno === 0 && (this.App.getUserInfo(data))
      });
    },
  }
};
</script>

利用mixins进行独立逻辑的抽离

当进行协同开发、全局状态统一维护时,不同的逻辑往往都会堆积到一起,这样是不利于维护的,可以利用mixins进行相应独立逻辑的抽离;

// userInfo.js
export default {
  data() {
    return {
      userInfo: {},
    };
  },
  methods: {
    getUserInfo() {
      $.ajax("/dataApi/getUserinfo", (data) => {
        this.userInfo = data;
      });
    },
  },
  mounted() {
    this.getUserInfo();
  },
};

// app.vue
<template>
  <div>
    {{ userInfo.userName }}
  </div>
</template>

<script>
import mixins_user from "../mixins/user.js";
export default {
  mixins: [mixins_user],
  data() {
    return {};
  },
};
</script>

旧版本实现provideinject功能 - 计算属性

通常的多级组件的通信可以通过$parent/$children进行获取数据与方法调用,但是在层级比较深的组件中不能单纯的通过$parent/$children进行获取对应组件的实例,可以通过配合组件中的name配置计算属性进行获取;

computed: {
  parentTem() {
    let parent = this.$parent;
    while (parent.$options.name && parent.$options.name !== "app") {
      parent = parent.$parent;
    }
    return parent;
  },
},
provide() {
  return {
    parent: this.parentTem
  };
},

vue 1.x中的$dispatch$broadcast

相关简介

因为基于组件树结构的事件流方式实在是让人难以理解,并且在组件结构扩展的过程中会变得越来越脆弱。这种事件方式确实不太好,我们也不希望在以后让开发者们太痛苦。并且 $dispatch 和 $broadcast 也没有解决兄弟组件间的通信问题。 -- vue官方

dispatch 是一个事件,首先会在自己实例本身上触发,然后沿父链向上传播。当它触发父组件上的事件侦听器时传播即会停止,除非该侦听器返回 true。 任何其他参数都将传递给侦听器的回调函数 -- vue官方

broadcast 是一个事件,它向下传播到当前实例的所有后代。由于后代扩展为多个子树,事件传播将会遵循许多不同的“路径”。 除非回调返回 true,否则在沿该路径触发侦听器回调时,每个路径的传播将会停止。 -- vue官方

$dispatch$broadcast适用于有祖先关系的组件间通信,原理是通过遍历组件树,根据组件名(或者其它组件唯一标识)定位到对应的组件,并在找到的组件实例中调用emit,从而触发emit,从而触发on监听的事件,这样就完成了一次组件间的通信。$dispatch是父组件遍历他下面的所有后代组件,而$broadcast的顺序则相反,是子组件向上寻找对应的父组件。

实现$dispatch$broadcast

功能分析
  • dispatch:子组件调用dispatch方法,向指定的最近的上级组件实例触发自定义事件,并传递数据,但前提是该目标父组件已经提前监听了这个自定义的dispatch事件
  • broadcast:父组件调用broadcast方法,向指定的最近的下级组件实例触发自定义的事件,并传递数据,但前提是该目标子组件已经提前监听了这个已定义的broadcast事件
设计思路
  • 关键点:正确的找到目标组件实例,并触发指定的事件
  • 设计原理:可以先确定好该功能的相关信息,如方法名、参数、回调、使用样例等
  • 拓展:可以采用mixins进行独立逻辑的提取,以便在尽可能多的组件中进行使用与相关的复用
具体实现

在进行指定组件实例的查找过程中,有两种方案进行;方案一:通过parentparent或children获取到所有父节点或子节点,然后利用组件的方法事件进行判断,方案二:通过多传入一个目标组件名称(或唯一标识)进行目标组件查找,然后再在找到的目标组件实例上触发指定的事件;

//mixinsEvent.js
function broadcast(componentName, eventName, params) {
  this.$children.forEach((child) => {
    const name = child.$options.name;
    if (name === componentName) {
      child.$emit.apply(child, [eventName].concat(params));
    } else {
      broadcast.apply(child, [componentName, eventName].concat([params]));
    }
  });
}
export default {
  methods: {
    dispatch(componentName, eventName, params) {
      let parent = this.$parent || this.$root;
      let name = parent.$options.name;
      while (parent && (!name || name !== componentName)) {
        parent = parent.$parent;
        if (parent) {
          name = parent.$options.name;
        }
      }
      if (parent) {
        parent.$emit.apply(parent, [eventName].concat(params));
      }
    },
    broadcast(componentName, eventName, params) {
      broadcast.call(this, componentName, eventName, params);
    },
  },
};

//componentA.vue
<template>
  <button @click="handleClick">触发事件</button>
</template>
<script>
import Emitter from "../mixins/mixinsEvent.js";
export default {
  name: "componentA",
  mixins: [Emitter],
  methods: {
    handleClick() {
      this.broadcast(
        "componentB", 
        "on-message", 
        "Hello Vue.js"
      );
    },
  },
};
</script>

// componentB.vue
export default {
  name: "componentB",
  created() {
    this.$on("on-message", this.showMessage);
  },
  methods: {
    showMessage(text) {
      window.alert(text);
    },
  },
};
vue官方实现

vue官方是接受一个参数,通过该参数进行向上或向下进行遍历寻找,找到目标组件后会执行相应的指定事件,根据返回的是否为true进行判断是否继续向下或向上遍历通知

// Vue
/**
 * Recursively broadcast an event to all children instances.
 * 递归地向所有子实例广播事件。
 * @param {String|Object} event
 * @param {...*} additional arguments
 */
// $dispatch 方法是定义在 Vue 的 prototype 上的
// 接受一个事件
Vue.prototype.$broadcast = function (event) {
  // 获取传入事件的类型,判断是否为字符串
  var isSource = typeof event === "string";
  // 校正 event 的值,当接受 event 的类型为字符串时就直接使用,如果不是字符串就使用 event 上的 name 属性
  event = isSource ? event : event.name;
  // if no child has registered for this event,
  // then there's no need to broadcast.
  // 如果当前组件的子组件没有注册该事件,就直接返回,并不用 broadcast
  if (!this._eventsCount[event]) return;
  // 获取当前组件的子组件
  var children = this.$children;
  // 将函数接受的参数转换成数组
  var args = toArray(arguments);
  // 如果传入事件为字符串
  if (isSource) {
    // use object event to indicate non-source emit
    // on children
    // 根据传入的事件名称的参数组装成 object
    args[0] = { name: event, source: this };
  }
  // 循环子组件
  for (var i = 0, l = children.length; i < l; i++) {
    var child = children[i];
    // 在每个子组件中调用 $emit 触发事件
    var shouldPropagate = child.$emit.apply(child, args);
    // 判断调用 $emit 返回的值是否为 true
    if (shouldPropagate) {
      // 如果调用 $emit 返回的值为 true,就递归孙子组件继续广播
      child.$broadcast.apply(child, args);
    }
  }
  // 最后返回当前组件的实例
  return this;
};
父子组件渲染顺序,实例创建顺序

订阅必须先于发布,也就是说先有on再有emit

  • 子组件先于父组件前渲染,所以在子组的mounted派发事件时,在父组件中的mounte中是监听不到的。
  • 父组件的create是先于子组件的,所以可以在父组件中的create可以监听到

拓展

组件封装技巧与原则

组件封装前戏 - 接口设计
  • 接口设计主要考虑到三个部分:propsslotsevents,通过将需要封装的组件进行精细化拆分后,
    • 利用slots进行相关组件的关联,父子孙等组件进行slots拆分插入管理
    • 利用events或者其他事件方法进行组件间通信与校验「在组件拆分时应该进行解耦,每个组件只负责独立的功能,如相关数据校验和初始化等,需要进行组件间通信时常常进行父级组件缓存子组件实例的方案
    • 利用props进行组件间数据传递
  • 其他可以用到的如ref等,可以按照组件实际应用场景和实现逻辑进行使用,如在最外层组件进行方法调用时就可以用ref进行获取实例(使用者进行手动调用根组件的API进行检验等方法调用),然后进行后续操作逻辑;
设计原则
  • 组件间进行解耦,相互拆分后只负责单独独立的功能逻辑;
  • 关联组件间通过指定的方式进行插入关联;
  • 主组件进行全局数据及相关交互把控,必要时进行控制组件间通信与交互;
  • 利用组件实例的缓存达到全局组件的整体把控;
  • 组件设计时先设计组件的API,定义好接口,完事后再补全组件细节逻辑,对于使用者而言,只需要知道props、events和slots即可;
示例代码
<template>
  <div>
    <el-form :model="formValidate" :rules="ruleValidate">
      <el-form-item label="⽤户名" prop="name">
        <el-input v-model="formValidate.name"></el-input>
      </el-form-item>
      <el-form-item label="邮箱" prop="eMail">
        <el-input v-model="formValidate.eMail"></el-input>
      </el-form-item>
    </el-form>
  </div>
</template>
<script>
import elForm from "@/components/form/form.vue";
import elFormItem from "@/components/form/form-item.vue";
import elInput from "@/components/input/input.vue";
export default {
  components: { 
    elForm, 
    elFormItem, 
    elInput 
  },
  data() {
    return {
      formValidate: {
        name: "",
        eMail: "",
      },
      ruleValidate: {
        name: [{ required: true, message: "⽤户名不能为空", trigger: "bpplur" }],
        eMail: [
          { required: true, message: "邮箱不能为空", trigger: "blur" },
          { type: "email", message: "邮箱格式不正确", trigger: "blur" },
        ],
      },
    };
  },
};
</script>

组件通信 - 指定节点组件实例获取

通过找到指定组件的实例,进而调用组件上的数据或方法,常见的需求是向上或向下找到最近的单个或所有的指定组件,或找到指定组件的兄弟组件 - 5种不同的应用场景
实现原理都是通过递归遍历后找到相匹配的实例并返回

向上找到最近的指定组件 - findComByUpward

function findComByUpward(current,target){
    let parent = current.$parent,
        name = parent.$options.name;

    while(parnet && (!name || [target].indexOf(name) <0)){
        // 没有找到指定组件
        parent = parent.$parent;
        parent && (name = parent.$options.name)
    }  
    // 返回目标组件实例
    return parent
}

// 示例
findComByUpward(this,'app')

向上找到所有的指定组件 - findComsByUpward

function findComsByUpward(current, target) {
  let parents = [];
  const parent = current.$parent;
  if (parent) {
    if (parent.$options.name === target) parents.push(parent);
    return parents.concat(findComponentsUpward(parent, target));
  } else {
    return [];
  }
}

向下找到最近的指定组件 - findComByDownward

function findComByDownward(current, target) {
  // $children返回的是当前组件的所有子组件数组
  let childrens = current.$children,
    result;
  if (childrens.length) {
    for (const child of childrens) {
      if (child.$options.name === target) {
        result = child;
        break;
      } else {
        result = findComByDownward(child, target);
        if (!result) break;
      }
    }
  }
  return result;
}

向下找到所有的指定组件 - findComsByDownward

function findComsByDownward(current, target) {
  return current.$children.reduce(
    (components, child) => {
      if (child.$options.name === target) components.push(child);
        const nextChildren = findComsByDownward(child, target);
        return components.concat(nextChildren);
      }, 
    []);
}

找到指定组件的所有兄弟组件 - findComsByBrother

function findComsByBrother(current, target, excludeCurrent = true) {
  let res = current.$parent.$children.filter((item) => {
    return item.$options.name === target;
  });
  let index = res.findIndex((item) => item._uid === current._uid);
  if (excludeCurrent) res.splice(index, 1);
  return res;
}

文献推荐

广播与派发原理
表单检验库 - validator