重写 Button 组件
背景
可能有同学会问我们为什么要重写组件呢?
其实 element3 现在组件的实现逻辑都是强行从 options api 改写成 composition api 的形式的
代码组织很乱,不具备可读性可维护性以及可扩展性
那可能还会有同学问为什么不在原有逻辑上重构呢?
说实话原有逻辑实在是乱,甚至会影响到你的思路
所以不妨我们大胆一点,重写
这篇文章主要是详细的记录了重构 Button 组件的方式以及步骤
主要是给想给贡献源码的同学一个重写组件的思路
本文内容很干,可能干到全是代码。请谨慎阅读
流程
重写一个组件,大概会分为以下几个点
- 确认需求
- Tasking
- Tdd
- snapshot
我们接着依次来看一看
需求
在重写前我们先来定义一下我们要重写成什么样子才能满足我们的需求
首先,对外的接口是不能修改的,比如:
- props
- emits
- slots
这些都是对外的接口,都要保持和原有逻辑一致
接着我们逻辑是要用 composition api 来实现
最后还有更重要的是,需要保证单元测试覆盖率在百分之90以上
好,着就是我们对组件重写的需求了
Tasking
本着以终为始的思想,我们需要先确定 Button 到底有什么功能,我们先一一列举出来
其实我们看看 element 官网关于 Button 的文档,我们就知道 Button 具体有什么功能了
功能列表
- 基于 size 属性可以设置 Button 的尺寸
- 基于 type 属性可以设置 Button 的类型
- 不同的类型,Button 的 style 是不一致的
- 基于 plain 属性可以设置 Button 是否为朴素按钮
- 朴素按钮其实也是一种 style 的改变
- 基于 round 属性可以设置 Button 是否为圆角按钮
- 也是 style 的改变
- 基于 circle 属性可以设置 Button 是否为圆形按钮
- 还是 style 的改变
- 基于 loading 属性可以设置 Button 是否是加载中状态
- 如果设置了 loading 后,会显示一个 “加载” 的 icon,一直显示
- 基于 disabled 属性可以设置 Button 是否为禁用状态
- 样式上有变化,显示一个禁用的 icon
- 不可以点击
- 基于 icon 属性可以设置 Button 上显示的 icon
- 基于 autofocus 属性可以设置 Button 是否默认聚焦
- 基于 native-type 属性可以设置 Button 原生 type 属性
除了表面的这些功能点,其实还有一些更细致的功能点,比如:
- 如果是 loading 状态下,不能在显示通过 icon 设置的图标了
- 也就是说组件只能有一个 icon 显示
- 要不是 loading,要不是设置的 icon
- 也就是说组件只能有一个 icon 显示
- loading 状态下,组件不可以点击
- 可以有三个点来控制 Button 的 size
- 自身的 props
- 父级 FormItem 时,可以获取 Item 的 Size
- 可以通过全局配置来设置 size
- 可以有两个点来控制 Button 的 Disabled
- 自身的 props
- 父级为 Form 时,Form.disabled 也可以控制
- 以上两个点,只要一个为 true ,那么 Button 都不会显示
- 用户可以通过 slot 的方式,定义组件的内容
好,终于把之前所有的 Button 功能都列举出来了,其实重写一个组件这个点是最关键的,只有这一步先捋顺了,后面写起来才会顺利
我自己的习惯是把所有的任务都列出来
后面当完成一个任务的时候就勾选一个
有种打游戏做任务的感觉,每勾选一个 经验就+1
当然我把这个称之为”看的见的进度“
这样你就可以知道自己距离完成这个功能还差多久了
TDD
有同学可能会问 TDD 是什么?这里我就不科普了,感兴趣的同学可以百度去学习
这里简单说一下 TDD 是一种编程方式
- 先写一个失败的测试
- 然后只写让这个失败的测试通过的逻辑
- 重构
那问题来了,我们写单元测试要测试什么?其实我们要测试的点都已经在 Tasking 那一步列举出来了
这个章节其实涉及了很多重构小步骤,全部写出来的话十分浪费时间,所以我采用贴代码的形式,提高效率
用户可以通过 slot 的方式,定义组件的内容
先找最简单的功能来实现,这个最简单
先找软柿子捏
测试
import Button from '../src/Button.vue' import { mount } from '@vue/test-utils' describe('Button.vue', () => { it('should show content', () => { const content = 'foo' const wrapper = mount(Button, { slots: { default: content } }) expect(wrapper.text()).toContain(content) }) })
代码实现
<template> <button> <slot></slot> </button> </template> <script> export default { setup() { return {} } } </script>
基于 size 属性可以设置 Button 的尺寸
测试
describe('set button size', () => { it.only('by props.size', () => { const size = 'small' const wrapper = mount(Button, { props: { size } }) expect(wrapper.classes()).toContain(`el-button--${size}`) }) })
使用 toContain 这种断言方式可以在测试失败的时候帮助我们打印出 wrapper 当前所拥有的 classes ,是更方便调试的一种测试写法
代码实现
<template> <button class="el-button" :class="[ buttonSize ? `el-button--${size}` : '' ]" > <slot></slot> </button> </template> <script> import { toRefs } from 'vue' export default { props: { size: { type: String, validator(val) { if(val === "") return true return ['medium', 'small', 'mini'].indexOf(val) !== -1 } }, } } </script>
这里实现了 props size 的校验
基于 elFormItem.elFormItemSize 来设置 Button 的尺寸
测试
it('by elFormItem.elFormItemSize', () => { const size = 'small' const wrapper = mount(Button, { global: { provide: { elFormItem: reactive({ elFormItemSize: size }) } } }) expect(wrapper.classes(`el-button--${size}`)).toBeTruthy() })
代码实现
<template> <button class="el-button" :class="[ buttonSize ? `el-button--${buttonSize}` : '', ]" > <slot></slot> </button> </template> <script> import { toRefs, inject, computed } from 'vue' export default { props: [ size: { type: String, validator(val) { if (val === '') return true return ['medium', 'small', 'mini'].indexOf(val) !== -1 } }, ], setup(props) { const { size } = toRefs(props) const buttonSize = useButtonSize(size) return { buttonSize } } } const useButtonSize = (size) => { return computed(() => { const elFormItem = inject('elFormItem', {}) return size?.value || elFormItem.elFormItemSize }) } </script>
因为有了测试做保障,重构起来也十分有自信
基于全局配置 size 来设置 Button 的尺寸
测试
it('by global config ', () => { const size = 'small' const wrapper = mount(Button, { global: { config: { globalProperties: { $ELEMENT: { size } } } } }) expect(wrapper.classes()).toContain(`el-button--${size}`) })
代码实现
const useButtonSize = (size) => { return computed(() => { const elFormItem = inject('elFormItem', {}) return ( size?.value || elFormItem.elFormItemSize || getCurrentInstance().ctx.$ELEMENT?.size ) }) }
关于 size 的任务我们就闯关成功啦
基于 type 属性可以设置 Button 的类型
测试
it('set button type by prop type ', () => { const type = 'success' const wrapper = mount(Button, { props: { type } }) expect(wrapper.classes()).toContain(`el-button--${size}`) })
代码实现
<template> <button class="el-button" :class="[ buttonSize ? `el-button--${buttonSize}` : '', type ? `el-button--${type}` : '' ]" > <slot></slot> </button> </template> <script> export default { props: { size: { type: String, validator(val) { if (val === '') return true return ['medium', 'small', 'mini'].indexOf(val) !== -1 } }, type: { type: String, validator(val) { return ( ['primary', 'success', 'warning', 'danger', 'info', 'text'].indexOf( val ) !== -1 ) } } } </script>
通过 class 来控制显示 type 的样式
基于 plain 属性可以设置 Button 是否为朴素按钮
测试
it('set button plain by prop type', () => { const wrapper = mount(Button, { props: { plain: true } }) expect(wrapper.classes()).toContain(`is-plain`) })
代码实现
<template> <button class="el-button" :class="[ buttonSize ? `el-button--${buttonSize}` : '', type ? `el-button--${type}` : '', { 'is-plain': plain } ]" > <slot></slot> </button> </template> <script> ... props:{ plain: Boolean } ... </script>
基于 round 属性可以设置 Button 是否为圆角按钮
测试
it('set button round by prop type', () => { const wrapper = mount(Button, { props: { round: true } }) expect(wrapper.classes()).toContain(`is-round`) })
代码实现
<template> <button class="el-button" :class="[ buttonSize ? `el-button--${buttonSize}` : '', type ? `el-button--${type}` : '', { 'is-plain': plain, 'is-round': round } ]" > <slot></slot> </button> </template> <script> …… props:{ round:Boolean } …… </script>
加一个 class 即可
基于 circle 属性可以设置 Button 是否为圆形按钮
测试
it('set button circle by prop type', () => { const wrapper = mount(Button, { props: { circle: true } }) expect(wrapper.classes()).toContain(`is-circle`) })
代码实现
<template> ... { 'is-plain': plain, 'is-round': round, 'is-circle': circle } ... > </template> <script> …… props:{ circle: Boolean } …… </script>
通过设置 loading ,来让按钮呈现加载中状态
如果是 loading 状态的话,按钮应该是不可以点击的,并且显示 loading icon
测试
it('set button loading by prop loading', async () => { const wrapper = mount(Button, { props: { loading: true } }) expect(wrapper.classes()).toContain(`is-loading`) expect(wrapper.attributes()).toHaveProperty('disabled') })
这里只需要验证 button 上有没有 disabled 属性即可
代码实现
<template> ... :disabled="loading" :class="[ { 'is-plain': plain, 'is-round': round, 'is-circle': circle, 'is-loading': loading } ] <i class="el-icon-loading" v-if="loading"></i> <slot></slot> ... > </template> <script> export default { props:{ loading: Boolean } } } </script>
基于 disabled 属性可以设置 Button 是否为禁用状态
测试
describe('set button disabled', () => { it('by props.disabled', () => { const wrapper = mount(Button, { props: { disabled: true } }) expect(wrapper.classes()).toContain(`is-disabled`) expect(wrapper.attributes()).toHaveProperty('disabled') }) })
因为 disabled 会涉及到 2 个功能点,一个是通过 props 一个是通过父级组件 Form 来控制,所以我们用 describe 来组织测试
这里的测试稍微和之前的不同,不光要验证有 is-disabled 类名,我们还需要给组件设置 disabled ,这样组件才是失效的
代码实现
<template> <button class="el-button" :disabled="disabled || loading" :class="[ buttonSize ? `el-button--${buttonSize}` : '', type ? `el-button--${type}` : '', { 'is-disabled': disabled } ]" ></template> <script> props:{ disabled: Boolean } </script>
如果父级组件未 From ,并且 From 的 disabled 为 true,那么当前组件也会受影响
测试
it('by elForm.disable', () => { const wrapper = mount(Button, { global: { provide: { elForm: reactive({ disabled: true }) } } }) expect(wrapper.classes()).toContain(`is-disabled`) expect(wrapper.attributes()).toHaveProperty('disabled') })
代码实现
<template> <button class="el-button" :disabled="buttonDisabled || loading" :class="[ buttonSize ? `el-button--${buttonSize}` : '', type ? `el-button--${type}` : '', { 'is-plain': plain, 'is-round': round, 'is-circle': circle, 'is-loading': loading, 'is-disabled': buttonDisabled } ]" > <slot></slot> </button> </template> <script> setup(props){ const { size, disabled } = toRefs(props) const buttonDisabled = useButtonDisabled(disabled) return { ... buttonDisabled } } const useButtonDisabled = (disabled) => { return computed(() => { const elForm = inject('elForm', {}) return disabled?.value || elForm.disabled }) } </script>
基于 icon 属性可以设置 Button 上显示的 icon
测试
it('set button icon by props.icon', () => { const wrapper = mount(Button, { props: { icon: 'el-icon-edit' } }) expect(wrapper.find('.el-icon-edit').exists()).toBe(true) })
检测一个元素的存在需要 find + exists 配合使用
代码实现
<template> …… + <i :class="icon" v-if="icon"></i> </button> </template> <script> props:{ icon:String } </script>
继续,我们还有一个逻辑,如果 loading 显示的话,那么 icon 就不可以显示了
如果是 loading 状态下,不能在显示通过 icon 设置的图标
测试
it("don't show icon when loading eq true", () => { const wrapper = mount(Button, { props: { icon: 'el-icon-edit', loading: true } }) expect(wrapper.find('.el-icon-edit').exists()).toBe(false) expect(wrapper.find('.el-icon-loading').exists()).toBe(true) })
代码实现
<template> …… <i class="el-icon-loading" v-if="loading"></i> <i :class="icon" v-else-if="icon"></i> …… </template>
实现起来也很简单,因为 loading 和 icon 只能保留一个,所有我们使用 v-else-if 来实现即可
基于 autofocus 属性可以设置 Button 是否默认聚焦
这个其实不需要实现,在外面设置 autofocus 时会自动添加到 内部 button 上的
<Button autofocus></Button>
基于 native-type 属性可以设置 Button 原生 type 属性
测试
it('set native-type by props.native-type', () => { const nativeType = 'reset' const wrapper = mount(Button, { props: { nativeType } }) expect(wrapper.attributes('type')).toBe(nativeType) })
代码实现
<template> <button :type="nativeType" > </button> </template> <script> props:{ nativeType:String } </script>
重构
重构前
<template> <button class="el-button" :type="nativeType" :disabled="buttonDisabled || loading" :class="[ buttonSize ? `el-button--${buttonSize}` : '', type ? `el-button--${type}` : '', { 'is-plain': plain, 'is-round': round, 'is-circle': circle, 'is-loading': loading, 'is-disabled': buttonDisabled } ]" > <i class="el-icon-loading" v-if="loading"></i> <i :class="icon" v-else-if="icon"></i> <slot></slot> </button> </template> <script> import { toRefs, inject, computed, getCurrentInstance } from 'vue' export default { props: { size: { type: String, validator(val) { if (val === '') return true return ['medium', 'samll', 'mini'].indexOf(val) !== -1 } }, type: { type: String, validator(val) { return ( ['primary', 'success', 'warning', 'danger', 'info', 'text'].indexOf( val ) !== -1 ) } }, plain: Boolean, round: Boolean, circle: Boolean, loading: Boolean, disabled: Boolean, icon: String, nativeType: String }, setup(props) { const { size, disabled } = toRefs(props) const buttonSize = useButtonSize(size) const buttonDisabled = useButtonDisabled(disabled) return { buttonSize, buttonDisabled } } } const useButtonDisabled = (disabled) => { return computed(() => { const elForm = inject('elForm', {}) return disabled?.value || elForm.disabled }) } const useButtonSize = (size) => { return computed(() => { const elFormItem = inject('elFormItem', {}) return ( size?.value || elFormItem.elFormItemSize || getCurrentInstance().ctx.$ELEMENT?.size ) }) } </script>
我不是太喜欢 class 都在 template 中处理,所以我要重构这个逻辑点
因为得益于单元测试,所以我可以十分有自信的去重构
重构后
<template> <button class="el-button" :class="classes" :type="nativeType" :disabled="buttonDisabled || loading" > <i class="el-icon-loading" v-if="loading"></i> <i :class="icon" v-else-if="icon"></i> <slot></slot> </button> </template> <script> import { toRefs, inject, computed, getCurrentInstance } from 'vue' export default { name: 'ElButton', props: { size: { type: String, validator(val) { if (val === '') return true return ['large', 'medium', 'small', 'mini'].indexOf(val) !== -1 } }, type: { type: String, validator(val) { return ( ['primary', 'success', 'warning', 'danger', 'info', 'text'].indexOf( val ) !== -1 ) } }, nativeType: { type: String, default: 'button' }, plain: Boolean, round: Boolean, circle: Boolean, loading: Boolean, disabled: Boolean, icon: String }, setup(props) { const { size, disabled } = toRefs(props) const buttonSize = useButtonSize(size) const buttonDisabled = useButtonDisabled(disabled) const classes = useClasses({ props, size: buttonSize, disabled: buttonDisabled }) return { buttonDisabled, classes } } } const useClasses = ({ props, size, disabled }) => { return computed(() => { return [ size.value ? `el-button--${size.value}` : '', props.type ? `el-button--${props.type}` : '', { 'is-plain': props.plain, 'is-round': props.round, 'is-circle': props.circle, 'is-loading': props.loading, 'is-disabled': disabled.value } ] }) } const useButtonDisabled = (disabled) => { return computed(() => { const elForm = inject('elForm', {}) return disabled?.value || elForm.disabled }) } const useButtonSize = (size) => { return computed(() => { const elFormItem = inject('elFormItem', {}) return ( size?.value || elFormItem.elFormItemSize || getCurrentInstance().ctx.$ELEMENT?.size ) }) } </script>
至此,我们所有的任务都已经完成了,不知道大家有没有感觉到,其实我们每次都只关注于一个小功能,实现起来十分简单
组件逻辑都已经完成了,那么我们要看看组件的样式了
增加 snapshot
其实在添加 snapshot 之前,我们需要先手动去看看组件的样式,毕竟刚刚 TDD 的过程我们是都没有看 UI 的
Snapshot 测试
it('snapshot', () => { const wrapper = mount(Button) expect(wrapper.element).toMatchSnapshot() })
snapshot 的测试很简单,写上着几行代码后, jest 会帮助我们生成当前组件的快照
// button/tests/_snapshots__/Button.spec.js.snap // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Button.vue snapshot 1`] = ` <button class="el-button" type="button" > <!--v-if--> </button> `;
## 测试覆盖率
最后,基于我们的需要是要达到 90% 的测试覆盖率
我们看看我们现在的覆盖率是多少
执行以下命令
yarn test packages/button/tests/Button.spec.js --coverage
可以看到以下结果
PASS packages/button/tests/Button.spec.js Button.vue ✓ snapshot (20 ms) ✓ should show content (10 ms) ✓ set button type by prop type (2 ms) ✓ set button plain by prop type (2 ms) ✓ set button round by prop type (2 ms) ✓ set button circle by prop type (2 ms) ✓ set button loading by prop loading (2 ms) ✓ set button loading by prop loading (2 ms) ✓ set native-type by props.native-type (2 ms) set button size ✓ by props.size (3 ms) ✓ by elFormItem.elFormItemSize (1 ms) ✓ by global config (2 ms) set button disabled ✓ by props.disabled (2 ms) ✓ by elForm.disable (1 ms) set button icon ✓ by props.icon (6 ms) ✓ don't show icon when loading eq true (2 ms) -----------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s -----------------|---------|----------|---------|---------|------------------- All files | 100 | 100 | 100 | 100 | src | 100 | 100 | 100 | 100 | Button.vue | 100 | 100 | 100 | 100 | tests | 100 | 100 | 100 | 100 | Button.spec.js | 100 | 100 | 100 | 100 | -----------------|---------|----------|---------|---------|------------------- Test Suites: 1 passed, 1 total Tests: 16 passed, 16 total Snapshots: 1 passed, 1 total Time: 3.359 s
测试覆盖率达到了百分之百
因为我们是用 TDD 来开发的,所以达到百分之百的测试覆盖率是常规操作
总结
以上就是重写 Button 组件的全部了,稍微总结总结
我们需要先确定组件的功能
然后基于 TDD 的方式一点一点去实现
最终我们会得到一个测试覆盖率达到百分百的组件
即使功能在复杂的组件,也是由一个个小功能实现的,我们在 TDD 的过程中,其实是降低了心智负担,让我们只关心一个小功能的实现,并且因为有测试的保障,可以随时的重构
后面 element3 所有的组件也都会是通过以上方式来完成重写的。
最大程度保证代码的质量,当然这也是为了后续新特性的扩展
后续的文章会简化 TDD 步骤,因为实在太麻烦了!!!