Vue组件设计分享(一)——代码和逻辑复用

1,835 阅读6分钟

最近几年,一直在负责公司的组件和项目结构优化。有了一些思考,在这里和大家分享、探讨一下。

为了行文方便,下面的所有 React HooksVue Composition API 之类的概念,统一称为 Hooks

首先先定义一些概念。

  1. 组件:在 Vue 中,就是一组基于 模板、JSX 或者 VNode 的封装。实际上,Vue 中最后都会编译成 VNode,也就是 render 函数。
  2. 可维护性:代码清晰易读,易修改,易扩展。代码是给人看的,设计代码时最大程度考虑其他人能否看懂。
  3. 可访问性:基于 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 是什么。

image.png

当然实际情况比例子复杂的多,这里先简单的感性认识下。

Vue2 的代码和逻辑复用

就理论来说,这部分是和 React 通用的。

  1. mixin
    在 2016 年 React博客就发表过一篇文章 mixins-considered-harmful,你可以看到 隐式依赖命名冲突侵入式引用导致的复杂度提升 等等问题对之前提到的 可维护性可访问性 都是背道而驰。
  2. 高阶组件
    高阶组件是参数为组件,返回值为新组件的函数。

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 函数之上又做了封装,导致使用方便的同时损失了一定的灵活性。编写上手比较难,而且也不符合之前定义的 可访问性

  1. 函数

函数式编程是 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 // 修改全局变量,产生副作用
}
  1. 组件继承
    Vue 和 React 都有 extends,作用是使一个组件可以继承另一个组件的组件选项。 可以在有限的情况下复用基类,比如你的一个组件有 A 和 B 两部分,你想复用 A 的话只能进行拆分或者传递参数做分支。这种情况下 extends 作用就不是很大了。

  2. provide
    逻辑上 mixin 一样,只是一个是平行注入,一个是树形注入。

从面向对象到函数式的趋势

注意观察的同学可能会发现一个趋势。比如五年前前端面试很喜欢问面向对象相关的(继承,多态什么的),现在的面试更多是问函数式(柯里化什么的)。

知乎上对应的问题。 image.png

这里不讨论一些定义或者起源,只结果来看,对于 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>

先看一下这个写法有什么问题。

  1. 数据来源不清晰
    选项式 API 以“组件实例”的概念为中心,所以所有的属性 datapropsattrs$options$store$router 都是挂在 vm,也就是 this 上。

    经过长时间的迭代,你的组件可能会变的非常复杂,this.count 这个属性是 data 还是 props 还是从 mixin 或者 provide 注入的?这样会导致你的数据流异常混乱。

下图是 element-ui 的表格的一部分事件,属性+事件是这个数据的几倍。而且这还是非二次修改的,加上业务需求的二次封装,这个数量的 props 加上 data,想想都头疼。 image.png

一个封装的组件,几百行的 props 和几百行的 data
image.png
image.png

  1. 逻辑和代码复用难
    前文提了一些解决办法,但是只是限于能用。
  2. 维护难
    一个组件有 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 能解决的问题:

  1. 数据来源
    数据的 可访问性,也就是 propsdata 或者 router 都非常清晰。可以非常清晰的知道 props.count 是不可变数据,count 是当前作用域的数据。

下面的图,可以很清晰的区分 propsdataemitimage.png

  1. 逻辑复用
    因为 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

  1. 可维护性
    用一下官方的这张图吧,结合上面的逻辑复用和 TS 推断可以很好的解决逻辑的可维护性image.png

  2. 测试
    可以很容易的看出来,组合式比选项式更容易做单元测试,测试逻辑也更清晰。因为没有上下文,逻辑是减少了的。

目前 UI 组件库的问题

目前的各大 UI 组件库在前端开发中解决了非常大的可复用问题,但同时也会有一些不好的体验。

比如产品有一个新需求,当前组件是不支持的。那你只能用 DOM 那套硬加上去,或者套一层 Vue 的运行时。

image.png

因为组件库不是定制的,是基于预设控制分支的,如果没有这个分支,那很难在原逻辑上添加功能的。

headless ui

zhuanlan.zhihu.com/p/578736019 可以看下这位大佬关于 headless 的介绍。

其实 headless 就是关于这篇文章一个最新的解决方案,也是文中所说还算前沿的技术

这就是本篇文章的所有内容了,感谢阅读。
感谢文中所有被引用内容、文章的大佬。

PS:预计第二篇是性能。