多榜单注入差异化组件方案
背景
榜单落地页是一个专门用来展示榜单列表的页面,榜单类型有热销榜、好评榜、回购榜等,这些榜单的请求链路大体是一致的,它们的数据逻辑可复用,但是在页面UI上是有差异的,不同榜单页面的整体UI结构是一样的,在页面局部上会有不同的展示,比如热销榜要展示销量,不用展示评分,而好评榜要展示评分,不用展示销量。
故而,我们需要提供一套方案实现前端界面的整体UI统一,局部差异解耦。
方案探索
用v-if
、v-else
控制
用v-if
、v-else
条件语句可以实现控制组件内部不同的UI展示,但是随着榜单类型的增加,这些代码耦合在一起,充斥着if (RankType === 'A')
的代码,会越来越难以维护。实际业务逻辑上可能只有热销榜才会展示销量标签组件,用条件判断实现的话,每种榜单都会去判断要不要展示销量标签组件。因此不推荐用v-if
、v-else
控制。
维护map去做组件映射也是类似的道理,存在多次判断、需要维护多个map的问题:
const compAMap = {
RankA: compA,
RankB: null,
...
}
const compBMap = {
RankA: null,
RankB: compB,
...
}
在多层级组件中使用vue插槽
项目使用的是vue框架,所以一开始是想用插槽实现,思路为在榜单布局组件中将不同的UI组件用插槽预留好位置进行占位,每个榜单提供自己的容器组件,在容器组件中通过vue的插槽机制填充注入这些UI组件。理想很美好,但是在实践过程中,发现存在差异的UI组件和入口的容器组件中间可能存在n层组件传递,vue在多层级组件中实现插槽的传递太麻烦了。
插槽的示例如下:
Root.vue
<script>
import Parent from './Parent.vue'
export default {
components: {
Parent
},
data () {
return {
a: 1
}
}
}
</script>
<template>
<Parent>
<template v-slot:demo="{ b }">
{{ a + b }}
</template>
</Parent>
</template>
Parent.vue
<script>
import Child from './Child.vue'
export default {
components: {
Child
}
}
</script>
<template>
<Child>
<template v-slot:demo="{ b }">
<slot name="demo" :b="b"></slot>
</template>
</Child>
</template>
Child.vue
<script>
export default {
data () {
return {
b: 2
}
}
}
</script>
<template>
<div>
<slot name="demo" :b="b" />
</div>
</template>
从上面的示例代码就可以很清晰地看到,在多层级组件中使用vue插槽,需要在每一层的组件中编写类似下面的代码进行透传:
<template v-slot:demo="{ b }">
<slot name="demo" :b="b"></slot>
</template>
每一层组件都要写,太繁琐了,而且这么写一眼看上去不知道在执行什么,影响代码的可读性,万一中间有一层组件写漏了,需要跟踪排查是哪一层组件出问题,维护成本也会增加,因此最终未采用该方案。
provide/inject差异化的组件
技术假想
在Vue.js中,provide和inject是一对用于实现依赖注入的功能。provide和inject允许你在一个组件的祖先组件中提供数据,并在任何后代组件中注入和使用这些数据,不论它们之间的层级关系有多深。
相比插槽机制,provide和inject的优势是不用在每一层组件去声明,使用起来简单很多。
那么我们是不是可以把每种榜单存在差异的组件看作是一份数据,在榜单的容器组件中通过provide/inject注入给后代组件使用呢?类似于下面这样的代码:
// RootA.vue
provide('RANK_COMP_TOKEN', {
CompA,
CompB: null,
})
// RootB.vue
provide('RANK_COMP_TOKEN', {
CompA: null,
CompB,
})
// 后代组件
const { CompA } = inject('RANK_COMP_TOKEN')
<Component v-if="CompA" :is="CompA" />
源码阅读/可行性分析
用provide和inject去注入组件,会不会有什么问题呢?好像没见过有人这么使用过。我们来看一下provide/inject的源码实现。
我们先看Vue.prototype._init
这个方法,它是Vue内部用来初始化组件实例的核心方法,在https://github.com/vuejs/vue/blob/main/src/core/instance/init.ts
可以找到:
vm._self = vm
initLifecycle(vm) // 划重点
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate', undefined, false /* setContext */)
initInjections(vm) // 划重点,resolve injections before data/props
initState(vm)
initProvide(vm) // 划重点,resolve provide after data/props
callHook(vm, 'created')
可以看到vue是通过initProvide和initInjections两个方法来初始化配置provide/inject,源码主要在:https://github.com/vuejs/vue/blob/main/src/core/instance/inject.ts
。
initProvide方法里面会去调用resolveProvided这个方法,我们要关注resolveProvided这个方法的实现
export function resolveProvided(vm) {
const existing = vm._provided
const parentProvides = vm.$parent && vm.$parent._provided
if (parentProvides === existing) {
return (vm._provided = Object.create(parentProvides))
} else {
return existing
}
}
vm._provided
是在initLifecycle方法里面初始化好的:vm._provided = parent ? parent._provided : Object.create(null)
,
也就是说在resolveProvided方法里,后代组件对于parentProvides === existing
是为真,
然后对vm._provided
进行重新设置:vm._provided = Object.create(parentProvides)
。
Object.create
用于创建一个新对象,并通过指定的原型对象(即另一个对象或null)来设置新对象的原型,就是说,在vm._provided = Object.create(parentProvides)
执行后,vm._provided可以通过原型链访问到parentProvides。
在initProvide方法中,我们在业务代码中写的vm.$options.provide会被复制到vm._provide上。在initInjections方法中会在vm._provide查找,然后赋值到vm上。
原来如此,provide/inject的核心原理就是javascript的原型链!
通过provide/inject去注入组件,就是把这些差异化的组件挂在vm._provide的原型链上。
落地实践
我们最终确定,通过provide/inject去注入差异化组件的方案,代码设计如下:
- rank
- Container.vue // 榜单入口容器组件
- common // 榜单公共代码
- Item.vue
- List.vue
- rank-a // a榜单
- Container.vue // a榜单容器组件
- Label.vue // 差异化组件
- rank-b // b榜单
- Container.vue // b榜单容器组件
- Label.vue // 差异化组件
榜单入口容器组件会根据榜单类型去导入使用a榜单或者b榜单容器组件,a/b榜单容器组件使用了统一的布局组件List.vue和Item.vue,a/b榜单容器组件分别注入各自的差异化组件。
收益:从目录结构和容器组件代码中就可以很清晰地看出组件的归属关系,通过RANK_COMP_TOKEN这种标识可以快速找出依赖的相关代码,后面每新增一种榜单,都可以参照已有榜单的实现,只处理差异化的组件即可。
转载自:https://juejin.cn/post/7367253337417826343