小编不才,愿与你聊聊 vue 中的依赖注入

Vue 的组件通信方式

开篇先俗套一下,在 Vue 中,组件间的通信有以下几种方式:

  • prop 作用: 父组件向子组件传值,单向数据流

  • $emit $on 作用: 子组件发布事件,父组件订阅事件

  • vuex 作用: 集中数据管理,数据共享

  • Event Bus 作用: 作为全局事件池,发布订阅事件

  • ref 作用: 通过 ref 获取子组件的引用(实例),不是响应式的

  • $attrs $listeners 作用: 获取父作用域中除了 props 中声明的属性( class 和 style 也不包含在内) 和 事件( .native 除外)

  • $parent $children 作用: 获取当前组件的父实例和所有子实例(非响应式)

  • $root 作用: 获取当前组件数的根实例,实际上和 vuex 有相似之处

  • provide inject 作用: 依赖注入,将当前组件的方法和数据注入的子组件中,可以跨层级,非响应式

封装组件的方式与目的

进入正题,通常情况下在设计一个组件的时候,我们会习惯性的将组件中的部分状态设计成 props 。以一个简单的 button 组件为例:

const ButtonBase = {
  name: 'ButtonBase',
  props: {
    type: {
      type: String,
      defualt: 'default',
    },
    size: {
      type: String,
      defualt: 'small',
    },
    disabled: {
      type: Boolean,
      defualt: false,
    },
  },
  template: '...'
};

常用的事件则会通过 $emit 派发事件,例如 $emit('click', $event) 。如果还有更多不重要的属性和事件时,或者所写的组件只是对另一个更底层的组件进行包装(高阶组件),就可以直接使用 v-bind="$attrs" 和 v-on="$listeners" 来进行事件和数据的绑定。

以上的这种思路,可以 cover 大部分的场景,但当涉及到双向数据流或非父子组件传值的时候,大家的方式就开始奔放起来了,常见的方式大概是以下几种:

  • ref

  • veux

  • Event Bus

借助这些方式几乎所有的场景都可以解决了,那我们再进入具体的场景分析一下。

首先想一下为什么需要封装组件?

为了复用

我猜你已经抢答了,但事实上除了组件库和项目中的基础组件,我们所写的业务组件几乎都不会有复用的场景,那我们在写业务代码的时候,为什么还要封装呢?

为了代码的可维护性和可读性

机智的你又回答对了,确实如此,作为一个平淡无奇的打工人,在封装代码的时候,一开始都是从可复用的角度出发,最后发现根本没有太多可复用的场景,甚至只用到一次,那我们封装的作用剩下了一个: 可维护性。

业务组件的拆分

站在 可维护性 这个角度再去思考组件该如何封装,以下面的原型图和需求为例,你会如何拆分组件?

  1. 上半部分为表单,用于写处方

  2. 下半部分为表格,用于展示历史处方

  3. 写处方和获取历史处方都需要一个 patientId ,从路由中获取

  4. 处方提交完成之后,下方历史处方需要同步更新

  5. 可以将下方表格中的一条处方复制到上方表单中,方便快速开方

先简单分析一下,上半部分的表单和下半部分的表格功能虽然有耦合,但还是相对独立的,可以拆分为三个组件

  • Prescription.vue 根组件

  • PrescriptionAdd.vue 写处方

  • PrescriptionHistory.vue 历史处方

根组件用于接收路由参数,因为需求中提到写处方和历史处方有一些联动,所以它可能还会承担两个兄弟组件间的通信作用。

写处方组件有一部分独立的功能,那就是提交处方表单。需要联动的功能则是提交完成后通知历史处方组件更新数据。

历史处方组件的独立功能是拉取展示历史数据,需要联动的功能是将一行数据复制发送给写处方组件

每个组件的独立功能都比较简单,具体代码不予赘述。我们直接探讨需要联动的部分如何去实现。

第一个要探讨的点是路由中的参数 patientId 如何传递。最简单暴力的方式就是随用随取了: this.$route.params.patientId 。但是很明显,作为一个稍微有一点点追求的人,都不会用这种方式,因为耦合度太高了,所以我们选择通过路由组件传参的方式将 patientId 以 prop 的方式传递给根组件。然后再继续通过 prop 的方式将其传递给写处方历史处方组件。

我们再考虑的长远一些,随着业务的不断增长需求也不断变化,有可能写处方历史处方两个组件也会变成类似根组件一样的容器组件,这样可能又会抽一些组件出去,又要继续将 patientId 向更深层的子组件传递。再极端一点,可能有时候我们为了传递一个 prop 中间隔了很多层组件,而这个 prop 在这些组件却不会被使用到。

那有没有其他方法呢?这里就要引出本文所介绍的 provide/inject。两者配合可以跨层级传递数据和方法,ElementUI 的表单组件就大量运用了这个功能,但它有一个明显的缺陷, provide 和 inject 绑定不是响应式的,这是 Vue 故意这样设计的,当然如果传入的是一个可监听对象,那对象的属性还是响应式的。

所以,我们可以用 provide/inject 代替 prop 传递数据。这时候你可能会问,通过 provide/inject 传递数据,那组件的复用性不就大大降低了吗,因为在使用的时候,必须保证被注入数据的子组件在有同样 provide 的父组件中使用。确实如此,但其实这种担心全是多余的,因为这一原则只适用于业务组件,业务组件的封装主要目的是提升代码的可维护性,也很少有复用的场景,即使有也是一个完整的业务流程,既 provide 和 inject 一定会同时存在。 如果出现了特殊的场景,可能要考虑组件的粒度是否需要更细致一些。当然这种方式千万不要用在基础组件中,比如上文所提到 Button 。

这样一来,我们的三个组件代码类似下面这样:

export default {
  name: 'Prescription', // 强烈建议书写Name,方便在devtool调试
  props: {
    // 通过路由组件传参,将路由参数以 prop 方式传递给路由组件
    patientId: {
      type: [String, Number],
      required: true,
    },
  },
  provide() {
    return {
      // 通过 provide 将 patientId 注入到子孙后代组件中
      patientId: this.patientId,
    }
  },
}
export default {
  name: 'PrescriptionHistory',
  // 通过 inject 从父组件获取注入的 patientId
  inject: ['patientId'],
}

第二个要解决的问题是,如何解决两个兄弟组件间的数据通信。既在写处方完成之后如何通知历史处方组件更新数据,和如何将历史处方的数据发送到写处方组件中

有一些经验的开发者可能早就想到用 Event Bus 了。在完成对应的操作之后,将相关的数据通过 Event Bus emit 一个事件发送出去,然后在需要订阅的组件内订阅相关事件,简略的代码大概如下:

export default {
  name: 'PrescriptionAdd',
  methods: {
    onSubmit() {
      /** some code */
      // 提交表单完成后,发布事件
      bus.$emit('prescription-add-success', this.prescriptionFormList);
    },

    /** 将历史数据转换为表单数据 */
    history2From(data) { },

  },
  mounted() {
    /** 在生命周期中订阅和取消订阅事件 */
    bus.$on('copy-prescription', this.history2From);
    this.$once('hook:beforeDestroy', () => {
      bus.$off('copy-prescription', this.history2From);
    });
  },
}
export default {
  name: 'PrescriptionHistory',
  methods: {
    onCopy(row) {
      // 将选中的数据发送出去
      bus.$emit('copy-prescription', row);
    },

    /**
     * 获取历史处方数据
     * 不要怕名字长,一定要做到表意清晰
     */
    getPrescriptionHistoryList() {},
  },
  mounted() {
    /** 在生命周期中订阅和取消订阅事件 */
    bus.$on('prescription-add-success', this.getPrescriptionHistoryList);
    this.$once('hook:beforeDestroy', () => {
      bus.$off('prescription-add-success', this.getPrescriptionHistoryList);
    });
  },
}

这种方式实现起来很简单,甚至不需要父组件的参与。但是这就造成了事件满天飞的后果,一堆的 emit('xxx') 这样的魔法自字符串不是很好维护,如果将这些事件名单独维护在一个文件中,又会像 redux 那样罗里吧嗦的。本人是很讨厌这样的模式,甚至因为魔法字符串的问题,我已经在新的项目中放弃了 Vuex 。

如果不用 Event Bus 的方式,还可使用 ref + $emit 的形式,主要思路就是由他们相同的父组件去订阅各自派发出的事件:

<PrescriptionAdd ref="PrescriptionAdd" 
  @prescription-add-success="onPrescriptionAddSuccess"/>

<PrescriptionHistory ref="PrescriptionHistory" 
  @copy-prescription="onCopyPrescription"/>
export default {
  name: 'Prescription',
  computed: {
    PrescriptionAdd: {
      cache: false,
      get() {
        return this.$refs.PrescriptionAdd;
      },
    },
    PrescriptionHistory: {
      cache: false,
      get() {
        return this.$refs.PrescriptionHistory;
      },
    }
  },
  methods: {
    onPrescriptionAddSuccess() {
      this.PrescriptionHistory
        .getPrescriptionHistoryList();
    },
    onCopyPrescription(row) {
      this.PrescriptionHistory
        .history2From(row);
    }
  }
}

这样可以从一定程度上减少事件满天飞情况,但是已经让父组件参与进来了。而且不论是哪一种,两个完整的功能都被分散到三个(或以上的)文件当中。那从站在可维护的角度来说,我们肯定希望相关功能的代码不要被拆的七零八散,写的到出都是。如果能写在一起,不仅对后续的维护者是一种便利,就连 code review 也变得轻松很多。

provide & inject

如何将这个功能写在一起呢?这时候就可以用到 provide/inject 了。在 Vue 中用 js 写依赖注入有连个很明明显的缺陷:

  1. 非响应式

  2. 没有一点点的类型提示

这时候我们可以借助 vue-property-decorator 提供的一些装饰器,通过 ts 的方式来写 Vue ,社区中已经有很多很多关于 vue-property-decorator(见参考资料一) 的介绍了,这里不再便赘述了(安利一下自己写的ppt见参考资料二)。

这里主要使用 ProvideReactive 和 InjectReactive 这两个装饰器。 ProvideReactive/InjectReactive 是 provide/inject 的反射版本,直白的讲就是让注入的值由非响应变为响应式。

额外补充: 
ProvideReactive 是将所有被注入数据包装成名称为 __reactiveInject__的对象传入子孙组件,子孙组件中 的 InjectReactive 再通过属性计算的方式将 __reactiveInject__ 映射到当前组件中,从而实现数据的同步更新,原理还是利用 当然如果传入的是一个可监听对象,那对象的属性还是响应式的。

接下来,我们忘记前面所有的,甚至忘记组件封装,原型图长什么样。要做的就是将开处方、查看历史处方、复制处方这些一连串的功能在一个类中实现,业务场景中也可以拆分成多个类,再通过 Mixins 的方式聚合到一起,这样一来我们大概会得到这样一份代码:

import { Component, Prop, Vue } from 'vue-property-decorator';

@Component({
  name: 'Prescription',
})
export default class Prescription extends Vue {
  @ProvideReactive()
  @Prop({ type: Number, required: true })
  readonly patientId!: number;

  prescriptionFormList = [];

  onDelFormItem(index) { }

  onSubmit() {
    fetch('').then(() => this.getPrescriptionHistoryList());
  }

  prescriptionHistoryList = [];

  getPrescriptionHistoryList() { }

  onCopyPrescription(row: any) {
    this.history2From(row);
  }

  history2From(data: any) {
    const item = { /** */ };
    this.prescriptionFormList.push(item);
  }
}

接下来我们再按照最初的思路去拆分并编写组件:

@Component({
  name: 'PrescriptionAdd',
})
export default class Prescription extends Vue {
  @InjectReactive()
  readonly patientId!: number;

  prescriptionFormList = [];

  onDelFormItem(index) { }

  onSubmit() { }
}
@Component({
  name: 'PrescriptionHistory',
})
export default class Prescription extends Vue {
  @InjectReactive()
  readonly patientId!: number;

  prescriptionHistoryList = [];

  getPrescriptionHistoryList() { }

  onCopyPrescription(row: any) { }
}

写到这里就会发现,父组件和子孙组件有很多相同的数据和方法,那就在父组件(根组件)中,为需要注入到子孙组件中的属性加上 @ProvideReactive 装饰器,在子孙组件中相对应的数据上加上 @InjectReactive 装饰器,同时删除细节只保留声明,需要注意的是,虽然通过 Reactive 注入的数据虽然是响应式的,但依旧是单向数据流,所以对子孙组件而言仍旧是只读的,所以需要像 prop 一样加上 readonly 修饰符;需要注入到子孙组件中的方法则加上 @Provide 装饰器,这也可以节省更多的内存开销,修改之后代码如下:

@Component({
  name: 'Prescription',
})
export default class Prescription extends Vue {
  @ProvideReactive()
  @Prop({ type: Number, required: true })
  readonly patientId!: number;

  @ProvideReactive()
  prescriptionFormList = [];

  onDelFormItem(index) { }

  onSubmit() { }

  @ProvideReactive()
  prescriptionHistoryList = [];

  getPrescriptionHistoryList() { }

  onCopyPrescription(row: any) { }

  history2From(data) { }
}
@Component({
  name: 'PrescriptionAdd',
})
export default class PrescriptionAdd extends Vue {
  @InjectReactive()
  readonly patientId!: number;

  @InjectReactive()
  readonly prescriptionFormList!: any[];

  @Inject()
  readonly onDelFormItem!: (index) => void;

  @Inject()
  readonly onSubmit!: () => void;
}
@Component({
  name: 'PrescriptionHistory',
})
export default class PrescriptionHistory extends Vue {
  @InjectReactive()
  readonly patientId!: number;

  @InjectReactive()
  readonly prescriptionHistoryList!: any[];

  @Inject()
  readonly getPrescriptionHistoryList!: () => void;

  @Inject()
  readonly onCopyPrescription!: (row: any) => void;

  mounted() {
    this.getPrescriptionHistoryList();
  }
}

这样我们就把主要的(连续的)逻辑全部塞进了 Prescription 组件当中。这样做的优势不言而喻, code review 简直太爽了,同样代码的可维护性也有一定的提升。

但缺点也很明显:

  1. 有太多冗余的声明

  2. 部分子孙组件完全弱化成了一个壳子,主要作用也就是模板了

  3. 在某些场景下,注入的数据可能会被子孙组件修改,虽然我们可以在父组件中为子孙组件 Provide 修改数据的方法,但还是会显得极为繁琐,比如上文例子中的 prescriptionFormList ,我们需要修改 给药途径单次剂量数量备注。虽然可以直接在子组件内修改(因为是引用类型,是可以直接修改的),但是这毕竟违背了单向数据流的原则。而且对于浅层次的数据,直接在子孙组件中修改,也会在控制台中抛出错误。

Vue3 中的依赖注入

虽有 Vue 中的依赖注入有这些缺点,官方文档中也不推荐使用,但不妨碍它成为一个组织业务代码的大杀器。而且随着 Vue3 的到来,上面的这些缺点也都统统解决了。

Vue3 的组合式 API 为我们提供了 provide和 inject 这两个方法,它们的作用和 Vue2 的依赖注入相同。但是它们可以将响应式数据 ref 和 reactive 注入到子孙组件中,这意味着我们不需要再用 hack 的方式让注入的值变为响应式,也不用再考虑因单向数据流而带来的复杂更新操作了,再配合上自定义 hook ,也能省去很多繁琐的声明,如果非要确保通过 provide 传递的数据不会被 inject 的组件更改,则可以使用 readonly 方法。

同样是开处方的需求为例,我们用 Composition Api 的方式来实现一下:

  1. 首先写一个比较长的 hook ,将所需的业务代码代码组织起来

interface PrescriptionContext {
  prescriptionFormList: Ref<any[]>;
  onDelFormItem(index: number): void;
  onSubmit(): void;

  prescriptionHistoryList: Ref<any[]>;
  getPrescriptionHistoryList(): void;
  onCopyPrescription(row: any): void;
}

// 通过 InjectionKey 接口提供类型支持
const PrescriptionToken: InjectionKey<PrescriptionContext> = Symbol('PrescriptionToken');

export function usePrescriptionProvider(patientId) {
  const prescriptionFormList = ref([]);

  function onDelFormItem(index: number) { }

  function onSubmit() { }

  const prescriptionHistoryList = ref([]);

  function getPrescriptionHistoryList() { }

  function onCopyPrescription(row: any) { }

  provide(PrescriptionToken, {
    prescriptionFormList,
    onDelFormItem,
    onSubmit,
    prescriptionHistoryList,
    getPrescriptionHistoryList,
    onCopyPrescription,
  });

  onMounted(() => {
    getPrescriptionHistoryList();
  });
}

export function usePrescriptionInject() {
  const prescriptionContext = inject<PrescriptionContext>(PrescriptionToken);

  if (!prescriptionContext) {
    throw new Error('usePrescriptionInject must be used after usePrescriptionProvider');
  }

  return prescriptionContext;
}
  1. 剩下的工作就是愉快的复制粘贴模板了

defineComponent({
  name: 'Prescription',
  props: {
    patientId: {
      type: Number,
      required: true,
    },
  },
  setup(props) {
    usePrescriptionProvider(props.patientId);
  },
});
defineComponent({
  name: 'PrescriptionAdd',
  setup() {
    const {
      prescriptionFormList,
      onDelFormItem,
      onSubmit,
    } = usePrescriptionInject();
    return {
      prescriptionFormList,
      onDelFormItem,
      onSubmit,
    };
  },
});
defineComponent({
  name: 'PrescriptionHistory',
  setup() {
    const {
      prescriptionHistoryList,
      getPrescriptionHistoryList,
      onCopyPrescription,
    } = usePrescriptionInject();
    return {
      prescriptionHistoryList,
      getPrescriptionHistoryList,
      onCopyPrescription,
    };
  },
});

这样一番改造之后,有没有发现单文件组件完全沦为了 模板 和 setup 的容器,我们的将所有业务逻辑全都迁移到了自定义 hook 当中,是不是更加清爽了许多,而且还可以利用所学的设计模式,继续魔改我们的 usePrescriptionProvider 。什么?你觉得烦?但这不就是架构师该做的事情么(让别人参照自己的规范编写代码,同时提供周边的工具链)。webpack 配置工程师是不会的。

参考资料

一、https://github.com/kaorun343/vue-property-decorator

二、https://onlymisaky.gitee.io/2020/08/17/

全文完


以下文章您可能也会感兴趣:

我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值