可能有同窗会问咱们为何要重写组件呢?javascript
其实 element3 如今组件的实现逻辑都是强行从 options api 改写成 composition api 的形式的html
代码组织很乱,不具有可读性可维护性以及可扩展性前端
那可能还会有同窗问为何不在原有逻辑上重构呢?vue
说实话原有逻辑实在是乱,甚至会影响到你的思路java
因此不妨咱们大胆一点,重写react
这篇文章主要是详细的记录了重构 Button 组件的方式以及步骤git
主要是给想给贡献源码的同窗一个重写组件的思路github
本文内容很干,可能干到全是代码。请谨慎阅读shell
重写一个组件,大概会分为如下几个点编程
咱们接着依次来看一看
在重写前咱们先来定义一下咱们要重写成什么样子才能知足咱们的需求
首先,对外的接口是不能修改的,好比:
这些都是对外的接口,都要保持和原有逻辑一致
接着咱们逻辑是要用 composition api 来实现
最后还有更重要的是,须要保证单元测试覆盖率在百分之90以上
好,着就是咱们对组件重写的需求了
本着以终为始的思想,咱们须要先肯定 Button 到底有什么功能,咱们先一一列举出来
其实咱们看看 element 官网关于 Button 的文档,咱们就知道 Button 具体有什么功能了
除了表面的这些功能点,其实还有一些更细致的功能点,好比:
好,终于把以前全部的 Button 功能都列举出来了,其实重写一个组件这个点是最关键的,只有这一步先捋顺了,后面写起来才会顺利
我本身的习惯是把全部的任务都列出来
后面当完成一个任务的时候就勾选一个
有种打游戏作任务的感受,每勾选一个 经验就+1
固然我把这个称之为”看的见的进度“
这样你就能够知道本身距离完成这个功能还差多久了
有同窗可能会问 TDD 是什么?这里我就不科普了,感兴趣的同窗能够百度去学习
这里简单说一下 TDD 是一种编程方式
那问题来了,咱们写单元测试要测试什么?其实咱们要测试的点都已经在 Tasking 那一步列举出来了
这个章节其实涉及了不少重构小步骤,所有写出来的话十分浪费时间,因此我采用贴代码的形式,提升效率
先找最简单的功能来实现,这个最简单
先找软柿子捏
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>
复制代码
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 的校验
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>
复制代码
由于有了测试作保障,重构起来也十分有自信
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 的任务咱们就闯关成功啦
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 的样式
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>
复制代码
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 便可
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 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>
复制代码
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>
复制代码
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>
复制代码
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 就不能够显示了
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 上的
<Button autofocus></Button>
复制代码
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 以前,咱们须要先手动去看看组件的样式,毕竟刚刚 TDD 的过程咱们是都没有看 UI 的
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 步骤,由于实在太麻烦了!!!