Vue组件设计分享(一)——代码和逻辑复用
最近几年,一直在负责公司的组件和项目结构优化。有了一些思考,在这里和大家分享、探讨一下。
为了行文方便,下面的所有
React Hooks
、Vue Composition API
之类的概念,统一称为Hooks
。
首先先定义一些概念。
- 组件:在 Vue 中,就是一组基于 模板、JSX 或者 VNode 的封装。实际上,Vue 中最后都会编译成 VNode,也就是 render 函数。
- 可维护性:代码清晰易读,易修改,易扩展。代码是给人看的,设计代码时最大程度考虑其他人能否看懂。
- 可访问性:基于 TS,hooks,FP,框架设计等能让访问属性直观体现来源。
我们的组件设计目的就是在满足产品需求的前提下,提高组件的 可维护性
和 可访问性
。
下面举个简单的例子来理解下 可维护性
和 可访问性
:
可维护性:
const Pi = 3.141592654
const floor = Math.floor(Pi) // Good
// const floor = ~~Pi // Bad
比如向下取整
,要用更直观的 Math.floor(Pi)
,而不要用看起来更简洁的位运算。而且千万不要用 parseInt(Pi)
,虽然结果一样,但是语义是不同的。
const str = '3.1415'
const int = parseInt(str)
可访问性:
this.$router.push('/dashboard') // Options Api
const router = useRouter() // Composition API
router.push('/dashboard')
在 Vue3 中可以清晰的知道 router
的来源、参数、方法和注释。但原来的 Vue2 中只有看源码、看文档才能知道 $router 是什么。
当然实际情况比例子复杂的多,这里先简单的感性认识下。
Vue2 的代码和逻辑复用
就理论来说,这部分是和 React
通用的。
- mixin
在 2016 年
React
博客就发表过一篇文章 mixins-considered-harmful,你可以看到隐式依赖
、命名冲突
、侵入式引用导致的复杂度提升
等等问题对之前提到的可维护性
和可访问性
都是背道而驰。 - 高阶组件 高阶组件是参数为组件,返回值为新组件的函数。
React HOC f(class) => 新的 class
Vue HOC f(object) => 新的 object
Vue 里的 object 就是原来的 Options Api
下面是一个 Vue
高阶组件的 Demo
function WithConsole (WrappedComponent) {
return {
mounted () {
console.log('I have already mounted')
},
props: WrappedComponent.props,
render (h) {
const slots = Object.keys(this.$slots)
.reduce((arr, key) => arr.concat(this.$slots[key]), [])
.map(vnode => {
vnode.context = this._self
return vnode
})
return h(WrappedComponent, {
on: this.$listeners,
props: this.$props,
// 透传 scopedSlots
scopedSlots: this.$scopedSlots,
attrs: this.$attrs
}, slots)
}
}
}
高阶组件的好处是不侵入原组件,更像是组件组合。缺点是因 Vue 在 render 函数之上又做了封装,导致使用方便的同时损失了一定的灵活性。编写上手比较难,而且也不符合之前定义的 可访问性
。
- 函数
函数式编程是 Hooks
之前最符合 可维护性
和 可访问性
的复用方法了。
当然,好的函数式编程要符合不可变数据流
,无副作用
等等。
但是,在框架中,很难做到全部函数的都是无副作用的,因为 Vue 的 vm,React 中的 Class,天然就是带上下文的。
所以,你的函数调用不可避免的会变成 fn.call(this),你的函数也会改变上下文中的内容。
关于副作用,这里引用一下 Vue 设计与实现 第4.1节 副作用函数是会产生副作用的函数,如下面的代码所示:
function effect() { document.body.innerText = 'hello vue3' }
当 effect 函数执行时,它会设置 body 的文本内容,但除了effect 函数之外的任何函数都可以读取或设置 body 的文本内容。也就是说,effect 函数的执行会直接或间接影响其他函数的执行,这时我们说 effect 函数产生了副作用。副作用很容易产生,例如一个函数修改了全局变量,这其实也是一个副作用,如下面的代码所示:
// 全局变量 let val = 1 function effect() { val = 2 // 修改全局变量,产生副作用 }
-
组件继承 Vue 和 React 都有 extends,作用是使一个组件可以继承另一个组件的组件选项。 可以在有限的情况下复用基类,比如你的一个组件有 A 和 B 两部分,你想复用 A 的话只能进行拆分或者传递参数做分支。这种情况下 extends 作用就不是很大了。
-
provide 逻辑上
mixin
一样,只是一个是平行注入,一个是树形注入。
从面向对象到函数式的趋势
注意观察的同学可能会发现一个趋势。比如五年前前端面试很喜欢问面向对象相关的(继承,多态什么的),现在的面试更多是问函数式(柯里化什么的)。
知乎上对应的问题。
这里不讨论一些定义或者起源,只结果来看,对于 UI 确实是组合优于继承。
也能在某一方面体现为,为什么多数前端框架都支持了 Hooks
。
选项式 Api 的缺点
<script>
export default {
// data() 返回的属性将会成为响应式的状态
// 并且暴露在 `this` 上
data() {
return {
count: 0
}
},
// methods 是一些用来更改状态与触发更新的函数
// 它们可以在模板中作为事件监听器绑定
methods: {
increment() {
this.count++
}
},
// 生命周期钩子会在组件生命周期的各个不同阶段被调用
// 例如这个函数就会在组件挂载完成后被调用
mounted() {
console.log(`The initial count is ${this.count}.`)
}
}
</script>
<template>
<button @click="increment">Count is: {{ count }}</button>
</template>
先看一下这个写法有什么问题。
-
数据来源不清晰 选项式 API 以“组件实例”的概念为中心,所以所有的属性
data
、props
、attrs
、$options
、$store
、$router
都是挂在 vm,也就是this
上。经过长时间的迭代,你的组件可能会变的非常复杂,
this.count
这个属性是data
还是props
还是从mixin
或者provide
注入的?这样会导致你的数据流异常混乱。
下图是 element-ui 的表格的一部分事件,属性+事件是这个数据的几倍。而且这还是非二次修改的,加上业务需求的二次封装,这个数量的 props 加上 data,想想都头疼。
一个封装的组件,几百行的 props 和几百行的 data
![]()
- 逻辑和代码复用难 前文提了一些解决办法,但是只是限于能用。
- 维护难 一个组件有 A 和 B 两部分,需要用参数来控制使用 A 还是 B ,或者 A、B 同时使用。随着时间增长,组件的控制分支会越来越多。叠加越来越多的数据流,维护越来越困难。
组合式 Api
<script setup>
import { ref, onMounted } from 'vue'
// 响应式状态
const count = ref(0)
// 用来修改状态、触发更新的函数
function increment() {
count.value++
}
// 生命周期钩子
onMounted(() => {
console.log(`The initial count is ${count.value}.`)
})
</script>
<template>
<button @click="increment">Count is: {{ count }}</button>
</template>
组合式 Api 能解决的问题:
- 数据来源
数据的
可访问性
,也就是props
、data
或者router
都非常清晰。可以非常清晰的知道props.count
是不可变数据,count
是当前作用域的数据。
下面的图,可以很清晰的区分 props
、data
和 emit
。
- 逻辑复用
因为
hooks
是无状态的,所以上面的例子可以改为
<script setup>
import { onMounted } from 'vue'
import { useIncrement } from './Comp.js'
const { count, increment } = useIncrement()
const { count: count1 , increment: increment1 } = useIncrement()
// lifecycle hooks
onMounted(() => {
console.log(`The initial count is ${count.value}.`)
})
</script>
<template>
<button @click="increment">Count is: {{ count }}</button>
<button @click="increment1">Count is: {{ count1 }}</button>
</template>
// Comp.js
import { ref } from 'vue'
export function useIncrement() {
// reactive state
const count = ref(0)
// functions that mutate state and trigger updates
function increment() {
count.value++
}
return {
count,
increment
}
}
选项式 Api 的要复用的话,只能复用整个 SFC 组件。如果这时候有一个新需求,button 可以控制是圆的或者方的。那只能添加 props,来做控制分支。就会回到前文所说,数据流和控制分支越来多,维护越来越难
。
组合式 Api 的话,因为逻辑已经是抽离复用的,所以分支只要控制各自的 UI 就可以。(其实也就是 headless ui
)
-
可维护性 用一下官方的这张图吧,结合上面的逻辑复用和 TS 推断可以很好的解决逻辑的
可维护性
。 -
测试 可以很容易的看出来,组合式比选项式更容易做单元测试,测试逻辑也更清晰。因为没有上下文,逻辑是减少了的。
目前 UI 组件库的问题
目前的各大 UI 组件库在前端开发中解决了非常大的可复用问题,但同时也会有一些不好的体验。
比如产品有一个新需求,当前组件是不支持的。那你只能用 DOM
那套硬加上去,或者套一层 Vue
的运行时。
因为组件库不是定制的,是基于预设控制分支的,如果没有这个分支,那很难在原逻辑上添加功能的。
headless ui
zhuanlan.zhihu.com/p/578736019 可以看下这位大佬关于 headless
的介绍。
其实 headless
就是关于这篇文章一个最新的解决方案,也是文中所说还算前沿的技术
。
这就是本篇文章的所有内容了,感谢阅读。 感谢文中所有被引用内容、文章的大佬。
PS:预计第二篇是性能。
转载自:https://juejin.cn/post/7205766006253240380