同一段字符串处理,在模板里写了两遍;同一个 loading 遮罩,在五个页面各复制了一份;同一个确认弹窗,每次用都要先 import 再注册再声明一个 visible 变量。这些重复不会让你的代码跑不起来,但会在需求变更的时候集中爆发,改一处漏三处。Vue 项目里的复用其实有三个粒度,函数、组件、插件,各自解决不同大小的重复。这篇把这三层挨个讲清楚:怎么写、什么时候该往上升一级、以及每一级各自的坑。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 把重复的表达式封装成方法和过滤器,以及过滤器在 Vue 3 里的下场
- 组件封装的两种形态,整体封装和用插槽做定制化封装
- 用
install把公共逻辑封装成插件,全局挂一个$toast - 原文那份 toast 插件代码里的一个 bug,以及为什么它一直不显示
- 三种粒度怎么选,以及组合式 API 出现后这套划分有什么变化
在 Vue 项目里,每一个页面都可以看作是由大大小小的模块构成的,一行代码、一个函数、一个组件都算模块。提高复用性的关键就在于把这些模块写得可复用。
# 一、封装成一个函数
开发过程中最常见的重复不是数据的重复读取,而是功能点的重复。比如下面这段:
<template>
<div>
<input type="text" v-model="str1">
<input type="text" v-model="str2">
<div>{{ str1.slice(1).toUpperCase() }}</div>
<div>{{ str2.slice(1).toUpperCase() }}</div>
</div>
</template>
原文这段的开标签写成了 tempalte,是个 typo,上面改回来了。
重复的功能点是「截取输入框第二个字符开始到最后的值,并转成大写」。这么短的表达式写两遍看着没什么,但只要它出现在模板里,就有三个隐患:一是逻辑分散,改规则要满模板找;二是模板里塞表达式会让这一行的可读性迅速下降,再加两个条件判断就没法看了;三是这段逻辑没法单独测试。
所以把它提成方法:
export default {
methods: {
sliceUpperCase(val) {
return val.slice(1).toUpperCase()
}
}
}
用到的地方调用一下,传值进去拿新值出来。
如果这个功能点只出现在双花括号插值和 v-bind 表达式里,Vue 2 还给了一个更贴合的选择,过滤器:
// 单文件组件注册过滤器
filters: {
sliceUpperCase(val) {
return val.slice(1).toUpperCase()
}
}
// 全局注册过滤器
Vue.filter('sliceUpperCase', function (val) {
return val.slice(1).toUpperCase()
})
然后在模板里用「管道」符调用:
<div>{{ str1 | sliceUpperCase }}</div>
<div>{{ str2 | sliceUpperCase }}</div>
过滤器的好处是读起来顺,从左到右就是数据的流向。金额格式化、时间格式化这类场景用它特别舒服。
这里必须补一句时效性:过滤器在 Vue 3 里被移除了,全局的 Vue.filter 和组件内的 filters 选项都没有了。官方给的迁移建议是改用计算属性或者直接调方法。原因也说得很直白,管道语法是一套 Vue 自己发明的、只在模板里成立的规则,学习成本和收益不成正比,而方法调用是标准 JavaScript,谁都看得懂。
所以老项目里的过滤器不用急着改,Vue 2 里它是好用的;但新写的代码,尤其是准备往 Vue 3 迁的项目,直接用方法或计算属性,别再新增过滤器了。
不管是过滤器还是普通方法,底下都是函数封装这一件事。函数是复用粒度里最小、也最该优先考虑的一级。一段逻辑如果不依赖组件的状态,也不产生 DOM 相关的副作用,就把它抽成纯函数放进 utils,连组件都不用碰。
# 二、封装成一个组件
比函数大一号的粒度是组件。组件包含模板、脚本和样式三部分,是 Vue 里复用的主力。项目中每个页面都可以看作一个父组件,里面包含很多子组件,子组件接收父组件的值来渲染,父组件通过响应子组件的回调来触发事件。
封装组件主要有两种方式。
第一种是整体封装,使用者通过改变数据源来呈现不同状态,代码结构不可定制:
<div>
<my-component data="我是父组件传入子组件的数据"></my-component>
</div>
这种封装最简单,也最容易翻车。翻车的方式永远是同一种:需求方说「这里能不能加个图标」,你加了个 showIcon prop;下次说「图标想换成头像」,你加了个 iconType;再下次说「这块想放一个按钮」,你加了个 extraButton。半年之后这个组件有二十个 prop,里面全是 v-if。
这就是第二种方式存在的理由,用插槽做自定义封装。开放一部分槽位给父组件,让它自己往里塞结构:
<div>
<my-component data="我是父组件传入子组件的数据">
<template slot="customize">
<span>这是定制化的数据</span>
</template>
</my-component>
</div>
组件内部用 slot 标签的 name 接住:
<div class="container">
<span>{{ data }}</span>
<slot name="customize"></slot>
</div>
原文这段的结尾写成了 <div> 而不是 </div>,少了一个斜杠,上面补上了。
子组件里 slot 的 name 定为 customize,父组件在 template 上写 slot="customize",两边对上,父组件里那段结构就会被渲染到对应位置。不同的父组件可以塞不同的内容,实现差异化。最终渲染出来是这样:
<div>
<div class="container">
<span>我是父组件传入子组件的数据</span>
<span>这是定制化的数据</span>
</div>
</div>
这里的插槽语法也要更新一下。slot="customize" 这种写在标签属性上的老语法从 Vue 2.6 起就被废弃了,取而代之的是 v-slot:customize,简写成 #customize。Vue 3 直接把老语法移除了,写了不生效。所以上面那段父组件的代码,现在应该写成 <template #customize>。
还有一种插槽值得提一句,作用域插槽。上面这种插槽传的是「结构」,父组件塞什么子组件就渲染什么,但父组件拿不到子组件内部的数据。作用域插槽解决的就是这个:子组件把数据绑在 slot 标签上,父组件用 v-slot 的值接住。表格组件的自定义列、列表组件的自定义 item 全靠它。插槽的三种形态和组件之间九种通信方式,我在 Vue组件化实践详解 里完整写过一遍。
封装好的组件,页面里 import 进来注册一下就能用,也可以全局注册:
import myComponent from '../myComponent.vue'
// 全局
Vue.component('my-component', myComponent)
全局注册用起来省事,代价是这个组件无论用不用都会被打进主包,而且 tree-shaking 拿它没办法。所以全局注册只留给真正每个页面都用的东西,其余的老老实实局部注册。
# 三、封装成一个插件
再往上一级是插件。有些公共逻辑,使用者根本不需要了解内部结构,只要知道调什么方法、传什么参数。loading、toast、确认弹窗都属于这一类,它们的共同点是「随手就要能调,不想为它在模板里预留标签」。
Vue 2 提供了 install 方法来编写插件,第一个参数是 Vue 构造器,拿到它就能给项目添加全局方法、资源和选项:
/* toast.js */
import ToastComponent from './toast.vue' // 引入组件
let $vm
export default {
install(Vue, options) {
// 判断实例是否存在
if (!$vm) {
const ToastPlugin = Vue.extend(ToastComponent); // 创建一个「扩展实例构造器」
// 创建 $vm 实例
$vm = new ToastPlugin({
el: document.createElement('div') // 声明挂载元素
});
document.body.appendChild($vm.$el); // 把 toast 组件的 DOM 添加到 body 里
}
// 给 toast 设置自定义文案和时间
let toast = (text, duration) => {
$vm.text = text;
$vm.duration = duration;
$vm.isShow = true; // 原文漏了这一行,不置为 true 的话 toast 永远不显示
// 在指定 duration 之后让 toast 消失
setTimeout(() => {
$vm.isShow = false;
}, $vm.duration);
}
// 判断 Vue.$toast 是否存在
if (!Vue.$toast) {
Vue.$toast = toast;
}
Vue.prototype.$toast = Vue.$toast; // 全局添加 $toast 事件
}
}
这段代码里我改了一处。原文的 toast 函数只设置了 text 和 duration,然后开个定时器把 isShow 设成 false,但从头到尾没有把 isShow 设成 true。组件初始的 isShow 是 false,调用之后还是 false,结果就是调了没反应,页面上什么都不出现。这种 bug 很典型,代码逻辑通顺、控制台干净,就是没效果。
再拆解一下这段的几个关键点。
Vue.extend(ToastComponent) 是把一个组件配置对象变成可以 new 的构造函数。直接 new 一个组件配置对象是不行的,必须先 extend 一次。
el: document.createElement('div') 这一手是让实例挂载到一个还没进入文档的游离节点上。这样组件会正常完成渲染流程,DOM 就挂在 $vm.$el 上,之后我们自己决定 append 到哪里。这也是所有「脱离组件树的弹窗」的共同做法。
if (!$vm) 这个判断保证了全局只有一个 toast 实例,反复调用只是改文案,不会往 body 里堆一堆节点。
这里也有个坑要注意:连续快速调用两次 $toast,第一次的定时器还没到点,第二次又开了一个,第一个定时器到点时会把正在显示的第二条也关掉。真要做严谨的话,每次调用前先 clearTimeout 掉上一个,把定时器 id 存起来。原文这版没处理,用在低频提示上问题不大,但列表里循环触发就会看到闪烁。
写完插件脚本,在入口文件里用 Vue.use() 注册:
import Toast from '@/widgets/toast/toast.js'
Vue.use(Toast); // 注册 Toast