2022年了,如何写出优雅的vue3组件

拥抱Setup

在我看来,vue3和vue2最大的区别就是响应式的思考方式的改变,在vue2中,响应式是渲染框架的一部分,你没办法单独地创建一个响应式状态然后复用它,只能用mixin把状态像配置文件那样隔离出来,再用在其他组件当中,响应式也只在组件中生效,这使得一套业务逻辑很难在vue中复用。如果你想要构建组件库,在vue2中最好的方式是构建一个大而全的组件,然后提供数量繁多的props给开发者自定义,而不是提供细粒度的单个组件让开发者自己组装,因为组装需要的逻辑一旦涉及到组件状态就会无法抽离,导致许多重复又无意义的封装。

而在vue3中,响应式与组件渲染被彻底分离,并且与React hooks完全不同,响应式式真正的全局响应,而非只有在React或者Vue组件内使用才表现出响应性,例如computed和watch完全可以用在一个没有任何vue文件的项目中,而非依赖于组件框架。这也意味着在vue3中,渲染引擎成为了响应式的应用,Vue3的重新渲染是通过在响应式提供的监听变化的能力实现的,甚至我们可以自己写一个简单的渲染函数,例如

typescript
cons render = (state:Ref<string>)=>{
    const html=`<div>${state.value}</div>`
    watchEffect(()=>{
        document.body.innerHTML=html
    })
}

所以在vue3中,我更愿意去使用细粒度更小的组件,任何通过各式各样的useXXX hooks函数把它们组装起来,对响应式变量进行操作,而不是沿用vue2时代臃肿复杂的方式,对着this不停地放入和调用不知道是谁放进去的属性和函数。所以在实际体验方面,vue2和vue3项目看起最大的区别,除了每个vue文件都使用了setup模式来编写,每个vue文件的行数都少了许多之外,就是那个新增的hooks文件夹,里面放着从原本vue文件里抽离出来的各式各样封装好的逻辑,只需要维护这个hooks文件夹,就能维护好项目核心的业务逻辑。

为了达到这样的目的,setup模式,或者说composition api是必需的,如果在vue3中继续使用options api,那你也将与这些唾手可得的优势失之交臂。

如无必要,勿增指令

当我们不可避免地需要去手动操作dom的时候,vue2时的第一反应就是使用自定义指令,但在我看来,这个功能在有了composition api之后已经可以被完全代替了,在有了ref onMounted这些专门用来处理生命周期的函数和钩子之后,我们不再需要写自定义指令也能把dom操作拆分出来,事实上vueuse已经有例如useClickOutside onLongpress等和指令功能完全一致的替换hokk了,并且在类型支持方面比指令更为友好

实例 - MessageBox组件

在vue2时代,我最喜欢element-ui组件库的一个功能,就是ElMessageBox,因为只需要在代码中简单地引入,然后调用对应的this.$alert或者this.$confirm等方法,就可以简单快速地在页面上弹出一条提示,不需要引入其他的组件,也不需要写一堆data用来绑定组件参数,然后把乱七八糟的visible啊title啊之类的传进去,甚至还可以设置基于Promise的回调,整个过程就像调用浏览器的原生apiconfirm或者alert一样简单,于是在我编写自己的第一个组件库的时候,我就想把这种方法用到自己的项目中,于是我去了解了相关的源码,element是这样实现的

javascript
// todo

element-ui巧妙地使用了Vue2提供的plugin接口,通过设置全局属性在代码中注入显示message的方法,而方便地提供了弹出消息提示的功能。尽管用起来的很方便,但是背后的实现却不怎么优雅,它脱离了Vue自身的组件渲染机制,通过手动渲染vnode并挂载到body的方式来创建MessageBox的DOM,尽管完美地实现了功能,但还是存在了许多限制,例如无法方便地自定义message的样式,如果想要展示复杂的消息,只能通过传入html字符vnode来渲染自定义内容,传入HTML时不仅要考虑XSS攻击,还要手写一堆HTML模板,至于传入vnode,麻烦程度比起HTML只多不少,那还不如自己写一遍Dialog组件来的快了;同样也无法享受到Vue自带的css scope功能,如果只想改变当前组件内展示的Message样式,就只能在调用方法时传入customClass参数,然后在全局作用的css里小心翼翼地编写样式。在交互简单的模板类项目中,这样的方式无伤大雅,但一旦涉及到了复杂的交互逻辑,就不可避免地要去重新封装一遍。

在vue2时代,这样的方式可以说是带着镣铐跳舞,因为所有的响应式状态传递必须依托于vue的上下文,让我们无法脱离组件去编写单纯的业务逻辑。但是到了vue3时代的element plus,它的MessageBox还是基于相同的逻辑,为了保证迁移的一致性,即使vue3提供了vue/reactivity能力使得组件外编写响应状态成为可能,但是依旧没有解决上面存在的问题。

那么有方法可以鱼和熊掌兼得吗?得益于vue/reactivity,这个答案是肯定的

从送我们的需求触发,我们希望这个完美的MessageBox组件应该是这么用的

vue
<template>
    <other-components />
    <perfect-message-box :controller="controller" >
        <custom-component />
    </perfect-message-box>
</template>
<script lang="ts" setup>
import { useMessageBox, PerfectMessageBox } from "perfect-message-box"
const { confirm, controller } = useMessageBox();

const toDoSomething = async ()=>{
    await confirm('Are you sure?');
    doSomthing();
}
</script>

**注意所有的vue3代码都是使用setup模式来写的,因为我实在无法理解有了setup为什么还要去写options

通过这种方式,我们可以做到了: 1,支持以slots方式传入自定义内容,契合vue的使用方式。 2,按需引入,如果某个懒加载的路由完全没有使用到MessageBox,那么在加载页面的时候就完全不会包括相关的代码。 3,依旧支持Promise调用,函数式的使用方式令人精神愉悦(划掉)。

这样的方式看起来十分符合我们的需求,那么我们如何才能做到呢? conroller的实现是重点,它连接了confirm函数和组件的状态,使得调用confirm函数时传入的参数能够被渲染到组件之中,我们可以先从基础的MessageBox组件出发,看看controller需要提供哪些能力,一个简单的MessageBox demo如下:

vue
<template>
    <teleport v-if="visible" :to="body">
        <div class="message-box">
            <slot>
                <div>{{title}}</div>
            </slot>
            <button @click="()=>{$emit('update:visible',false);$emit('confirm')}">confirm</button>
            <button @click="()=>{$emit('update:visible',false);$emit('cancel')}">cancel</button>
        </div>
    </teleport>
</template>
<script lang="ts" setup>
defineProps<{
    visible: boolean;
    title: string;
}>()

defineEmit<{
    (name:"update:visible", value: boolean):void;
    (name:"confirm"):void;
    (name:"cancel"):void;
}>()
</script>

可以看出,在这里MessageBox是靠Vue的事件来传递用户是否点击了确认或者取消,同时根据父组件传入的visible值来展示或者隐藏自身,那么controller就需要模仿这种事件机制,因为Vue的事件是无法在组件外使用的,可以写出这样的一个controller:

typescript
const useMessageBox=()=>{
    const controller = ref({
        visible: false,
        title:'',
        confirm: ()=>undefined,
        cancel: ()=>undefined,
    })
    const show=(title:string)=>Promise<void>((resolve,reject)=>{
        controller.value={
            title,
            visible:true,
            confirm:()=>{
                controller.value = { ...controller.value, visible: false };
                resolve()
            },
            cancel:()=>{
                controller.value = { ...controller.value, visible: false };
                reject()
            }
        }
    })
    return {
        show, controller
    }
}

同时将MessageBox组件修改成这样:

vue
<template>
    <teleport v-if="visible" :to="body">
        <div class="message-box">
            <slot>
                <div>{{controller.title}}</div>
            </slot>
            <button @click="controller.confirm()">confirm</button>
            <button @click="controller.cancel()">cancel</button>
        </div>
    </teleport>
</template>
<script lang="ts" setup>
defineProps<{
    controller: Controller
}>()
</script>

通过巧妙地设置和改变controller的值,使得组件可以不通过props来获取参数并传递相应的事件,这是不是像极了Vuex或者其他类似的状态管理工具的思路?不同的是,这里我们没有依靠任何第三方状态管理框架,仅凭vue/reactivity的能力就办到了,这也侧面说明某种意义上全局的状态管理能力是可以被vue/reactivity取代的。而在这个实例中,我们简单地通过响应式状态的传递实现了组件props和父组件的解耦,子组件可以不再仅靠声明式的props来渲染内容,父组件也有了更多调用子组件能力的方式,完美实现了原版ElMessageBox的功能,并且与Vue的特性完美结合,不再有奇奇怪怪的hack。

它还有许多衍生用法,例如导出一个useGlobalConfirm方法,并在App.vue根组件下插入MessageBox,使得所有组件或者函数在使用MessageBox时共用同一个实例,完美复刻原版ElMessageBox的用法;也可以通过设置controller的更多属性,来支持传入更多配置例如按钮文案、按钮数量等等,还可以将参数本身设置为响应式对象,可以使MessageBox动态展示正在加载的进度等等,整个使用流程和逻辑更符合Vue的思考方式。

我把这个组件构建思路应用在了Cent中,这样每个组件都只需要关心自己的工作,不再需要处理额外的状态来管理MessageBox弹窗,整个逻辑条理清晰,十分利于维护。

思考

ElMessageBox采用手动构建和挂载DOM的方式还有另外一层因素,就是多层嵌套的MessageBox,例如二次确认或者多次确认,这时单个MessageBox组件就不够用了,那要如何处理这种情况呢?

其实解决方法也很简单,我们可以创建一个高阶组件,它专门负责创建重复的组件并自动为组件分配id和层级,把我们的MessageBox包裹起来,就可以了

vue
<template>
    <component v-for="{component,props,key} in list" :key="key" :is="component" v-bind="props" />
</template>
<script lang="ts" setup>
import type { Component } from 'vue';

defineProps<{
    list:{
        component:Component,
        props:any,
        key:string
    }[]
}>()
</script>

不过同样的,这样也会导致MessageBox失去对自定义slot的支持。大多数情况下,二次确认的弹窗数量不会超过两个因此完全可以将二次确认的MessageBox放在根组件中,而将首次弹窗的MessageBox放在当前的组件,这样也更方便理解逻辑。

Vue的不足

描述Vue和React、Angular之类的框架优劣之争已经有了很多说法,不过我在使用Vue的过程中最大一个痛点就是Vue的单文件写法,单文件在代码组织方面是有优势的,可以方便区分不同的组件,做到许多编译期的优化,但是一个Vue文件只能有一个组件的设计也让我不得不写很多额外的临时组件,例如渲染一个只会在当前组件用到的列表,我实在不想为了单独的列表项目去想一个新名字,为这些临时组件新建一个文件夹,再多写好几遍import、export语句。在react或者其他JSX语言的框架中,我可以把这些临时组件写在一个文件里,随时调用,当然vue也提供了JSX的写法,但是那样就会失去编译器的模板优化,并且vue的JSX与其他框架的语义并不完全相同,一些特殊的用法还得重新学习,显然算不上优雅的解决方法。