如何封装Vue组件
用 Vue 开发项目也有一段时间了,是时候该思考一下如何对 Vue 组件进行封装的正确方式,也算是对自己的一个这么长时间的总结。着了我以弹窗组件为例,一种实现是在需要模板中引入需要弹窗展示的组件,然后通过一个 flag 变量来控制弹窗的组件,在业务代码里面会充斥着冗余的弹窗组件逻辑,十分不优雅。
本文主要讲的是要开发 Vue 组件的一些技巧,当然包含大量代码示例供大家参考,也欢迎大家积极参与讨论。
1.开发环境
vue-cli3 提供了非常方便的功能,可以快速编写一些测试 demo,是开发组件必备的环境。
// 全局安装 vue-cli3 npm install -g @vue/cli vue -V // 查看版本是否为 3.x // 安装扩展,此后可以快速启动单个 vue 文件 npm install -g @vue/cli-service-global // 快速启动 demo 文件 vue serve demo.vue
如果需要 scss,则还需要在目录下安装 sass-loader 等。
下面是使用 vue-cli3 可能会遇见的几个问题
自定义入口文件
如果需要(比如需要开发移动端的组件),可以在使用 vue serve 时自定义 html 入口文件,在根目录下编写 index.html,并确保页面包含#app 的 dom 即可。
引入公共混合文件
通过 style-resources-loader 在每个文件引入公共样式混合等,参考自动化导入
需要访问 Vue 全局对象
在某些时候需要放问全局 Vue 对象,如开发全局指令、插件时
import Vue from "vue" import somePlugin from "../src/somePlugin" Vue.use(somePlugin)
上面这种写法并不会生效,这是因为 vue serve xxx.vue 仅仅只能作为快速原型开发的方案,使用的 Vue 与 import 引入的 Vue 不是同一个对象。一种解决办法是手动指定 vue serve 的入口文件
// index.js import Vue from "../node_modules/vue/dist/vue.min" import placeholder from "../src/placeholder/placeholder" Vue.use(placeholder) new Vue({ el: "#app", template: ``, created(){}, })
2.Vue 的组件系统
Vue 组件的 API 主要包含三部分:prop、event、slot
- props 表示组件接收的参数,最好用对象的写法,这样可以针对每个属性设置类型、默认值或自定义校验属性的值,此外还可以通过 type、validator 等方式对输入进行验证
- slot 可以给组件动态插入一些内容或组件,是实现高阶组件的重要途径;当需要多个插槽时,可以使用具名 slot
- event 是子组件向父组件传递消息的重要途径
2.1.单向数据流
参考:单向数据流-官方文档。
父级 prop 的更新会向下流动到子组件中,但是反过来则不行
单向数据流是 Vue 组件一个非常明显的特征,不应该在子组件中直接修改 props 的值
- 如果传递的 prop 仅仅用作展示,不涉及修改,则在模板中直接使用即可
- 如果需要对 prop 的值进行转化然后展示,则应该使用 computed 计算属性
- 如果 prop 的值用作初始化,应该定义一个子组件的 data 属性并将 prop 作为其初始值
从源码/src/core/vdom/create-component.js 和/src/core/vdom/helpers/extract-props.js 里可以看见,在处理 props 的取值时,首先从
function extractPropsFromVNodeData(){ const res = {} const { attrs, props } = data // 执行浅拷贝 checkProp(res, props, key, altKey, true) || checkProp(res, attrs, key, altKey, false) return res }
在子组件修改 props,却不会修改父组件,这是因为 extractPropsFromVNodeData 中是通过浅复制将 attrs 传递给 props 的。
浅复制意味着在子组件中对对象和数组的 props 进行修改还是会影响父组件,这就违背了单向数据流的设计。因此需要避免这种情况出现。
2.2.组件之间的通信
这里可以参考:vue 组件通信全揭秘,写的比较全面
- 父子组件的关系可以总结为 prop 向下传递,事件 event 向上传递
- 祖先组件和后代组件(跨多代)的数据传递,可以使用 provide 和 reject 来实现
此外,如果需要跨组件或者兄弟组件之间的通信,可以通过 eventBus 或者 vuex 等方式来实现。
3.“绕开”单向数据流
考虑下面场景:父组件将数据通过 prop 形式传递给子组件,子组件进行相关操作并修改数据,需要修改父组件的 prop 值(一个典型的例子是:购物车的商品数量 counter 组件)。
根据组件单向数据流和和事件通信机制,需要由子组件通过事件通知父组件,并在父组件中修改原始的 prop 数据,完成状态的更新。在子组件中修改父组件的数据的场景在业务中也是比较常见的,那么有什么办法可以“绕开”单向数据流的限制呢?
3.1.状态提升
可以参考 React 的状态提升,直接通过 props 将父元素的数据处理逻辑传入子组件,子组件只做数据展示和事件挂载即可
<template> <div class="counter"> <div class="counter_btn" @click="onMinus">-</div> <div class="counter_val">{{value}}</div> <div class="counter_btn" @click="onPlus">+</div> </div> </template> <script> export default { props: { value: { type: Number, default: 0 }, onMinus: Function, onPlus: Function }, }; </script>
然后在调用时传入事件处理函数
<template> <div> <counter :value="counter2Val" :on-minus="minusVal" :on-plus="plusVal"></counter> </div> </template> <script> export default { data() { return { counter2Val: 0, } }, methods: { minusVal(){ this.counter2Val-- }, plusVal(){ this.counter2Val++ } } } </script>
很明显,由于在每个父组件中都需要实现 on-minus 和 on-plus,因此状态提升并没有从根本上解决问题。
3.2.v-model 语法糖
Vue 内置了 v-model 指令,v-model 是一个语法糖,可以拆解为 props:value 和 events:input。就是说组件只要提供一个名为 value 的 prop,以及名为 input 的自定义事件,满足这两个条件,使用者就能在自定义组件上使用 v-model
<template> <div> <button @click="changeValue(-1)">-1</button> <span>{{currentVal}}</span> <button @click="changeValue(1)">+1</button> </div> </template> <script> export default { props: { value: { type: Number // 定义 value 属性 } }, data() { return { currentVal: this.value }; }, methods: { changeVal(val) { this.currentVal += parseInt(val); this.$emit("input", this.currentVal); // 定义 input 事件 } } }; </script>
然后调用的时候只需要传入 v-model 指令即可
<counter v-model="counerVal"/>
使用 v-model,可以很方便地在子组件中同步父组件的数据。在 2.2 之后的版本中,可以定制 v-model 指令的 prop 和 event 名称,参考model 配置项
export default { model: { prop: 'value', event: 'input' }, // ... }
4.获得组件实例的引用
在开发组件中,获取组件实例是一个非常有用的方法。组件可以通过$refs、$parents、$childrend 等方式获得 vm 实例引用
- $refs 在组件(或者 dom 上)增加 ref 属性即可
- $parents 获取子组件挂载的父组件节点
- $childrend,获取组件的所有子节点
这些接口返回的都是 vnode,可以通过 vnode.componentInstance 获得对应的组件实例,然后直接调用组件的方法或访问数据。虽然这种方式多多少少有些违背组件的设计理念,增加了组件之间的耦合成本,但代码实现会更加简洁。
4.1.表单验证组件
通常情况下,表单验证是表单提交前一个十分常见的应用场景。那么,如何把表单验证的功能封装在组件内部呢?
下面是一个表单组件的示例,展示了通过获得组件的引用来实现表单验证功能。
首先定义组件的使用方式,
- xm-form 接收 model 和 rule 两个 prop
model 表示表单绑定的数据对象,最后表单提交的就是这个对象
rule 表示验证规则策略,表单验证可以使用 async-validator 插件
- xm-form-item 接收的 prop 属性,对应 form 组件的 model 和 rule 的某个 key 值,根据该 key 从 model 上取表单数据,从 rule 上取验证规则
下面是使用示例代码
<template> <div class="page"> <xm-form :model="form" :rule="rule" ref="baseForm"> <xm-form-item label="姓名" prop="name"> <input v-model="form.name"/> </xm-form-item> <xm-form-item label="邮箱" prop="email"> <input v-model="form.email"/> </xm-form-item> <xm-form-item> <button @click="submit">提交</button> </xm-form-item> </xm-form> </div> </template> <script> import xmForm from "../src/form/form" import xmFormItem from "../src/form/form-item" export default { components: { xmForm, xmFormItem, }, data() { return { form: { name: "", email: "" }, rule: { name: [ {required: true, message: '用户名不能为空', trigger: 'blur'} ], email: [ {required: true, message: '邮箱不能为空', trigger: 'blur'}, {type: 'email', message: '邮箱格式不正确', trigger: 'blur'} ], } } }, methods: { submit() { // 调用 form 组件的 validate 方法 this.$refs.baseForm.validate().then(res => { console.log(res) }).catch(e => { console.log(e) }) } } } </script>
接下来让我们实现 form-item 组件,其主要作用是放置表单元素,及展示错误信息
<template> <label class="form-item"> <div class="form-item_label">{{label}}</div> <div class="form-item_mn"> <slot></slot> </div> <div class="form-item_error" v-if="errorMsg">{{errorMsg}}</div> </label> </template> <script> export default { name: "form-item", props: { label: String, prop: String }, data() { return { errorMsg: "" } }, methods: { showError(msg) { this.errorMsg = msg } } } </script>
然后让我们来实现 form 组件
- 通过 calcFormItems 获取每个 xm-form-item 的引用,保存在 formItems 中
- 暴露 validate 接口,内部调用 AsyncValidator,并根据结果遍历 formItems 中每个表单元素的 prop 属性,处理对应的 error 信息
<template> <div class="form"> <slot></slot> </div> </template> <script> import AsyncValidator from 'async-validator'; export default { name: "xm-form", props: { model: { type: Object }, rule: { type: Object, default: {} } }, data() { return { formItems: [] } }, mounted() { this.calcFormItems() }, updated() { this.calcFormItems() }, methods: { calcFormItems() { // 获取 form-item 的引用 if (this.$slots.default) { let children = this.$slots.default.filter(vnode => { return vnode.tag && vnode.componentOptions && vnode.componentOptions.Ctor.options.name === 'form-item' }).map(({componentInstance}) => componentInstance) if (!(children.length === this.formItems.length && children.every((pane, index) => pane === this.formItems[index]))) { this.formItems = children } } }, validate() { let validator = new AsyncValidator(this.rule); let isSuccess = true let findErrorByProp = (errors, prop) => { return errors.find((error) => { return error.field === prop }) || "" } validator.validate(this.model, (errors, fields) => { this.formItems.forEach(formItem => { let prop = formItem.prop let error = findErrorByProp(errors || [], prop) if (error) { isSuccess = false } formItem.showError(error && error.message || "") }) }); return Promise.resolve(isSuccess) } } } </script>
这样我们就完成了一个通用的表单验证组件。从这个例子中可以看出获取组件引用,在组件开发中是一个非常有用的方法。
5.封装 API 组件
一些组件如提示框、弹出框等,更适合单独的 API 调用方式,如
import MessageBox from '@/components/MessageBox.vue' MessageBox.toast('hello)
如何实现制这种不需要手动嵌入模板里面的组件呢?原来,除了在通过在模板中嵌入组件到 children 挂载组件,Vue 还为组件提供了手动挂载的方法$mount
let component = new MessageBox().$mount() document.getElementById('app').appendChild(component.$el)
通过这种方式,我们就是可以封装 API 形式调用组件,下面是一个 alert 消息提示的接口封装
5.1.消息弹窗组件
一个消息组件就是在页面指定绘制展示提示消息的组件,下面是简单实现
<template> <div class="alert"> <div class="alert-main" v-for="item in notices" :key="item.name"> <div class="alert-content">{{ item.content }}</div> </div> </div> </template> <script> let seed = 0; function getUuid() { return 'alert_' + (seed++); } export default { data() { return { notices: [] } }, methods: { add(notice) { const name = getUuid(); let _notice = Object.assign({ name: name }, notice); this.notices.push(_notice); // 定时移除,单位:秒 const duration = notice.duration; setTimeout(() => { this.remove(name); }, duration * 1000); }, remove(name) { const notices = this.notices; for (let i = 0; i < notices.length; i++) { if (notices[i].name === name) { this.notices.splice(i, 1); break; } } } } } </script>
下面来实现消息组件挂载到页面的逻辑,并对外暴露展示消息的接口
// alert.js import Vue from 'vue'; // 具体的组件 import Alert from './alert.vue'; Alert.newInstance = properties => { const props = properties || {}; // 实例化一个组件,然后挂载到 body 上 const Instance = new Vue({ data: props, render (h) { return h(Alert, { props: props }); } }); const component = Instance.$mount(); document.body.appendChild(component.$el); // 通过闭包维护 alert 组件的引用 const alert = Instance.$children[0]; return { // Alert 组件对外暴露的两个方法 add (noticeProps) { alert.add(noticeProps); }, remove (name) { alert.remove(name); } } }; // 提示单例 let messageInstance; function getMessageInstance () { messageInstance = messageInstance || Alert.newInstance(); return messageInstance; } function notice({ duration = 1.5, content = '' }) { // 等待接口调用的时候再实例化组件,避免进入页面就直接挂载到 body 上 let instance = getMessageInstance(); instance.add({ content: content, duration: duration }); } // 对外暴露的方法 export default { info (options) { return notice(options); } }
然后就可以使用 API 的方式来调用弹窗组件了
import alert from './alert.js' // 直接使用 alert.info({content: '消息提示', duration: 2}) // 或者挂载到 Vue 原型上 Vue.prototype.$Alert = alert // 然后在组件中使用 this.$Alert.info({content: '消息提示', duration: 2})
6.高阶组件
高阶组件可以看做是函数式编程中的组合。可以把高阶组件看做是一个函数,他接收一个组件作为参数,并返回一个功能增强的组件。
高阶组件是一个接替 Mixin 实现抽象组件公共功能的方法,不会因为组件的使用而污染 DOM(添加并不想要的 div 标签等)、可以包裹任意的单一子元素等等
在 React 中高阶组件是比较常用的组件封装形式,在 Vue 中如何实现高阶组件呢?
在组件的 render 函数中,只需要返回一个 vNode 数据类型即可,如果在 render 函数中提前做一些处理,并返回 this.$slots.default[0]对应的 vnode,就可以实现高阶组件。
6.1.内置的 keep-alive
Vue 内置了一个高阶组件 keep-alive,查看源码可以发现其实现原理,就是通过维护一个 cache,并在 render 函数中根据 key 返回缓存的 vnode,来实现组件的持久化。
6.2.throttle
节流是 web 开发中处理事件比较常见的需求。常见的场景有及时搜索框避免频繁触发搜索接口、表单按钮防止在短暂时间误重复提交等
首先来看看 Throttle 组件的使用方式,接收两个 props
- time 表示节流的时间间隔
- events 表示需要处理的事件名,多个事件用逗号分隔
在下面的例子中,通过 Throttle 组件来控制其内部 button 的点击事件,此时连续点击多次,触发 clickBtn 的次数要比点击的次数小(节流函数通过一个定时器进行处理)。
<template> <div> <Throttle :time="1000" events="click"> <button @click="clickBtn">click {{count}}</button> </Throttle> </div> </template>
下面是具体实现,实现高阶组件的主要功能是在 render 函数中对当前插槽中的 vnode 进行处理
const throttle = function (fn, wait = 50, ctx) { let timer let lastCall = 0 return function (...params) { const now = new Date().getTime() if (now - lastCall < wait) return lastCall = now fn.apply(ctx, params) } } export default { name: 'throttle', abstract: true, props: { time: Number, events: String, }, created() { this.eventKeys = this.events.split(',') this.originMap = {} this.throttledMap = {} }, // render 函数直接返回 slot 的 vnode,避免外层添加包裹元素 render(h) { const vnode = this.$slots.default[0] this.eventKeys.forEach((key) => { const target = vnode.data.on[key] if (target === this.originMap[key] && this.throttledMap[key]) { vnode.data.on[key] = this.throttledMap[key] } else if (target) { // 将原本的事件处理函数替换成 throttle 节流后的处理函数 this.originMap[key] = target this.throttledMap[key] = throttle(target, this.time, vnode) vnode.data.on[key] = this.throttledMap[key] } }) return vnode }, }
我们还可以进一步封装,通过 debounce 函数来实现 Debounce 组件,可见高阶组件的作用,就是为了增强某个组件而存在的。
总结
本文整理了几种实现 Vue 组件的技巧
1.以 counter 计数器组件为例,展示了通过 v-model 语法糖同步父子组件的方式
2.以表单验证组件为例,展示了通过获取子组件的实例来封装组件的方法
3.以全局弹窗组件为例,展示了手动 mount 挂载组件封装 API 组件的方式
以 throttle 节流组件为例,展示了在 vue 中一种实现高阶组件的方式
在了解 Vue 的 API 之后,理解上面的概念都比较轻松,封装组件,除了对于 API 的熟练度之外,更多地是考察 JavaScript 基础。Vue 入门十分轻松,但是要写好优雅的 Vue 代码,也是一份不小的学问。
码云笔记 » 如何封装Vue组件