【element3-開發日記】手摸手教你重寫 Button 組件

重寫 Button 組件

背景

可能有同窗會問咱們爲何要重寫組件呢?javascript

其實 element3 如今組件的實現邏輯都是強行從 options api 改寫成 composition api 的形式的html

代碼組織很亂,不具有可讀性可維護性以及可擴展性前端

那可能還會有同窗問爲何不在原有邏輯上重構呢?vue

說實話原有邏輯實在是亂,甚至會影響到你的思路java

因此不妨咱們大膽一點,重寫react

這篇文章主要是詳細的記錄了重構 Button 組件的方式以及步驟git

主要是給想給貢獻源碼的同窗一個重寫組件的思路github

本文內容很乾,可能幹到全是代碼。請謹慎閱讀shell

流程

重寫一個組件,大概會分爲如下幾個點編程

  • 確認需求
  • 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
  • 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 步驟,由於實在太麻煩了!!!


相關文章
相關標籤/搜索