2.1 简单轮子VueInput.vue:文本输入框 和单元测试

大纲链接 §

[toc]


VueInput 组件需求分析

User Case

  • 输入
    • 提示 用户名必须为英文
    • 报错信息
    • 清空
  • 复制/粘贴
  • 键盘 Tab
  • 敲击回车
  • 不可输入

输入框状态 11 种

  • enable默认状态
  • hover状态 (移动端无)
  • focus状态
  • error状态 (危险色提示文字)
    • error hover状态 (移动端无)
    • error focus状态
  • sucess状态 (原谅色提示文字)
    • sucess hover状态 (移动端无)
    • sucess focus状态
  • disable状态
  • readonly状态

lable名称 + 提示文字 / + 按钮

  • lable名称 的 位置
    • lable在上方
    • lable在左方
  • 提示图标 + 提示文字
    • 提示图标 叹号
    • 提示文字 颜色跟随图标
    • 提示信息
      • 用户名
        • 已被占用
        • 错误
        • 不存在
      • 密码
        • 太短
        • 错误
      • 提示信息位置
        • 右方
        • 下方
  • 按钮
    • 输入框 + 搜索按钮(右侧)
    • 名称按钮(左侧) + 输入框
  • 默认占位提示 placeholder

查看文本输入框 UI


API设计


VueInput 组件

新建./src/components/input/VueInput.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<template>
  <div></div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VueInput extends Vue {
  name = 'VueInput';
}
</script>
  • 样式标签中加scoped属性,在生成的相同组件中的标签样式使用同一个data-v-xxxxx作为唯一id,隔绝同名样式

增加 error 状态

VueInput.vue

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
<template>
  <div class="wrapper">
    <label class="form-item">
      <template>
        <span class="name">{{ labelName }}</span>
        <input type="text"
               :placeholder="placeholder"
               :disabled="disabled"
               :readonly="readonly"
               :error="error"
               :class="{'fake-focus': isFakeFocus,
                        'fake-hover': isFakeHover,
                        'error': error}">
      </template>
    </label>
  </div>
</template>

<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator';

@Component
export default class VueInput extends Vue {
  name = 'VueInput';
  @Prop(String) labelName!: string;
  @Prop(String) placeholder!: string;
  @Prop(Boolean) isFakeFocus!: false;
  @Prop(Boolean) isFakeHover!: false;
  @Prop(Boolean) readonly!: false;
  @Prop(Boolean) disabled!: false;
  @Prop(String) error!: '';
}
</script>

<style lang="scss" scoped>
$button-height: 32px;
$font-size: 14px;
$button-bg: white;
$border-radius: 4px;
$button-active-bg: #eee;
$color: #333;
$disabled-color: #bbb;
$border-color: #999;
$border-color-hover: #666;
$box-shadow-color: rgb(0, 0, 0, 0.5);

.wrapper {
  font-size: $font-size;
  
<style lang="scss" scoped>
$button-height: 32px;
$font-size: 14px;
$border-radius: 4px;
$button-bg: white;
$button-active-bg: #eee;
$color: #333;
$disabled-color: #bbb;
$border-color: #999;
$border-color-hover: #666;
$border-color-hover-error: #f1453d;
$primary: #0d6efd;
$danger: #ff4136;
$success: #198754;
$info: #0dcaf0;
$warning: #ffc107;
$attention: #fd7e14;
$box-shadow-color: rgb(0, 0, 0, 0.5);

.wrapper {
  font-size: $font-size;
  padding: 5px;

  .form-item {
    > input {
      height: $button-height;
      border: 1px solid $border-color;
      border-radius: $border-radius;
      padding: 0 8px;
      font-size: inherit;

      &:hover, &.fake-hover {
        border: 1px solid $border-color-hover;
        border-radius: $border-radius;
        box-shadow: 0 0 0 1px var(--border-color-hover);
      }

      &:focus, &.fake-focus {
        box-shadow: inset 0 1px 3px $box-shadow-color;
        border-radius: $border-radius;
      }

      &:focus-visible {
        outline: none;
      }

      &[disabled],
      &[readonly] {
        border-color: $disabled-color;
        color: $disabled-color;
        cursor: not-allowed;
      }

      &.error {
        border-color: $danger;
        border-width: 1px;

        &::-webkit-input-placeholder {
          color: $danger;
        }

        &:hover,
        &.fake-hover-error {
          border: 1px solid $border-color-hover-error;
          box-shadow: 0 0 0 1px $border-color-hover-error;
        }

        &:focus, &.fake-focus {
          border: 1px solid $border-color-hover-error;
          box-shadow: inset 0 1px 3px $box-shadow-color,
          0 0 0 1px $border-color-hover-error;
        }
      }
    }
  }
}
</style>
  • :class="{..., 'error': error}">可以简写为:class="{..., error}">

使用<template></template>配合v-if包裹临时标签组

  • 不恰当的写法:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<template>
  <div class="wrapper">
    <label class="form-item">
      <template>
        <span class="name">{{ labelName }}</span>
        <input type="text"
               :placeholder="placeholder"
               :disabled="disabled"
               :readonly="readonly"
               :error="error"
               :class="{'fake-focus': isFakeFocus,
                        'fake-hover': isFakeHover && !error,
                        'fake-hover-error': isFakeHover && error,
                        error}">
        <VueIcon icon-name="settings"
                 v-if="error"></VueIcon>
        <span v-if="error">{{ error }}</span>
        
        <div v-if="error"
             style="display: inline-block;">
          <VueIcon icon-name="settings""></VueIcon>
          <spa>{{ error }}</span>
        </div>
        
      </template>
    </label>
  </div>
</template>
  • 更好的写法:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template>
  <div class="wrapper">
    <label class="form-item">
      <template>
        <span class="name">{{ labelName }}</span>
        <input type="text"
               :placeholder="placeholder"
               :disabled="disabled"
               :readonly="readonly"
               :error="error"
               :class="{'fake-focus': isFakeFocus,
                        'fake-hover': isFakeHover && !error,
                        'fake-hover-error': isFakeHover && error,
                        error}">
        <template v-if="error">
          <VueIcon icon-name="settings"></VueIcon>
          <span>{{ error }}</span>
        </template>
      </template>
    </label>
  </div>
</template>
  • <template></template>会编译后自动消失,不用添加额外的标签和样式
  • 缺点也是当需要特别的样式时,在<template></template>上添加样式无效

inputchange 事件

  • <VueInput></VueInput>上添加监听事件 @change.native="xxx"
  • 或者在VueInput.vue里的<input>标签上监听@change="xxx"

VueInput.vue

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
<template>
  <div class="wrapper">
    <label class="form-item" :for="labelName">
      <template>
        <span class="name">{{ labelName }}</span>
        <input type="text"
               :name="labelName"
               :value="value"
               :placeholder="placeholder"
               :disabled="disabled"
               :readonly="readonly"
               :error="error"
               :class="{'fake-focus': isFakeFocus,
                        'fake-hover': isFakeHover && !error,
                        'fake-hover-error': isFakeHover && error,
                        error}"
               @change="change">
        <div class="input-info" v-if="error">
          <VueIcon icon-name="error-solid"
                   class="icon-error"></VueIcon>
          <span>{{ error }}</span>
        </div>
      </template>
    </label>
  </div>
</template>

<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator';
import VueIcon from '../icon/VueIcon.vue';

@Component({
  components: {VueIcon}
})
export default class VueInput extends Vue {
  name = 'VueInput';
  @Prop(String) labelName!: string;
  @Prop(String) value!: '';
  @Prop(String) placeholder!: string;
  @Prop(Boolean) isFakeFocus!: false;
  @Prop(Boolean) isFakeHover!: false;
  @Prop(Boolean) readonly!: false;
  @Prop(Boolean) disabled!: false;
  @Prop(String) error!: '';

  change($event: { target: HTMLInputElement }) {
    this.$emit('updateChange',
      $event.target.value);
  }
}
</script>

<style lang="scss" scoped>
@import '../../style/global.scss';

.wrapper {
  font-size: $font-size;
  padding: 5px;
  overflow: hidden;
  display: inline-flex;
  align-items: center;

  > :not(last-child) {
    margin-right: .5em;
  }

  .form-item {
    display: inline-block;

    > input {
      height: $button-height;
      border: 1px solid $border-color;
      border-radius: $border-radius;
      padding: 0 8px;
      font-size: inherit;

      &:read-write {
        background-color: $input-color;
      }

      &:hover, &.fake-hover {
        border: 1px solid $border-color-hover;
        border-radius: $border-radius;
        box-shadow: 0 0 0 1px var(--border-color-hover);
        background-color: $input-hover-color;
      }

      &:focus, &.fake-focus {
        box-shadow: inset 0 1px 3px $box-shadow-color;
        border-radius: $border-radius;
        background-color: $input-hover-color;
      }

      &:focus-visible {
        outline: none;
      }

      &[disabled],
      &:read-only {
        border-color: $disabled-color;
        color: $disabled-color;
      }

      &[disabled] {
        cursor: not-allowed;
        background-color: $input-hover-color;
      }

      &.error {
        border-color: $danger;
        border-width: 1px;

        &::-webkit-input-placeholder {
          color: $danger;
        }

        &:hover,
        &.fake-hover-error {
          border: 1px solid $border-color-hover-error;
          box-shadow: 0 0 0 1px $border-color-hover-error;
          background-color: $input-hover-color;
        }

        &:focus, &.fake-focus {
          border: 1px solid $border-color-hover-error;
          box-shadow: inset 0 1px 3px $box-shadow-color,
          0 0 0 1px $border-color-hover-error;
        }
      }
    }

    .input-info {
      vertical-align: middle;
      display: inline-block;
      color: $danger;
      overflow: hidden;

      .icon-error {
        fill: $danger;
      }

      ::v-deep > .v-icon {
        width: 1.35em;
        height: 1.35em;
        vertical-align: -20%;
        margin-right: 0.2em;
        margin-left: 0.5em;
      }
    }
  }
}
</style>
  • 向父组件传递自定义事件 change($event: { target: HTMLInputElement }) {this.$emit('updateChange', $event.target.value);}
  • 使用解构赋值 $event: { target: HTMLInputElement } 定义形参类型
  • 发布的自定义事件可以传第三个参数
    • this.$emit('updateChange', $event.target.value , 'Hi');
    • Inputs.vue中回调函数接受参数,使用inputChange(xxx, yyy) {console.log(yyy)}的第二个形参接受
    • yyy的值即'Hi'

Inputs.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<template>
  <div>
    <details open>
      <summary>Primary</summary>
      <div>
        <VueInput placeholder="Enable"
                  @updateChange="inputChange">
        </VueInput>
        <VueInput placeholder="Hover"
                  :isFakeHover="true">
        </VueInput>
        <VueInput placeholder="Focus"
                  :isFakeFocus="true">
        </VueInput>
        <VueInput placeholder="Readonly"
                  value="Error Readonly"
                  :readonly="true">
        </VueInput>
        <VueInput placeholder="Disabled"
                  :disabled="true">
        </VueInput>
      </div>
    </details>
    ...
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';
import VueInput from './input/VueInput.vue';

@Component({
  components: {
    VueInput
  }
})
export default class Inputs extends Vue {
  name = 'Inputs';

  inputChange(e: string) {
    console.log(e);
  }
}
</script>

<style lang="scss" scoped>
@import '../style/global.scss';

details {
  cursor: pointer;

  .error {
    color: $danger;
  }
}
</style>

测试驱动开发

测试外部属性props

input.test.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import Vue from 'vue';
import chai from 'chai';
import VueInput from '../src/components/input/VueInput.vue';

chai.use(sinonChai);
const expect = chai.expect;

Vue.config.productionTip = false;
Vue.config.devtools = false;

describe('VueInput', () => {

  it('VueInput存在.', () => {
    expect(VueInput).to.exist;
    console.log('VueInput存在');
  });

  describe('props', () => {
    it('VueInput可以接受value', () => {
      const Constructor = Vue.extend(VueInput);
      const vm = new Constructor({
        propsData: {
          value: '1234'
        }
      }).$mount();
      const inputElement = vm.$el.querySelector('input');
      expect((inputElement as HTMLInputElement).value)
        .to.equal('1234');
      console.log('VueInput可以接受value');
      vm.$destroy();
    });

    it('VueInput可以接受disabled', () => {
      const Constructor = Vue.extend(VueInput);
      const vm = new Constructor({
        propsData: {
          disabled: true
        }
      }).$mount();
      const inputElement = vm.$el.querySelector('input');
      expect((inputElement as HTMLInputElement).disabled)
        .to.equal(true);
      console.log('VueInput可以接受disabled');
      vm.$destroy();
    });

    it('VueInput可以接受readOnly', () => {
      const Constructor = Vue.extend(VueInput);
      const vm = new Constructor({
        propsData: {
          readonly: true
        }
      }).$mount();
      const inputElement = vm.$el.querySelector('input');
      expect((inputElement as HTMLInputElement).readOnly)
        .to.equal(true);
      console.log('VueInput可以接受readOnly');
      vm.$destroy();
    });

    it('VueInput可以接受error', () => {
      const Constructor = Vue.extend(VueInput);
      const vm = new Constructor({
        propsData: {
          error: '你错了'
        }
      }).$mount();
      //svg
      const useElement = vm.$el.querySelector('use');
      expect((useElement as SVGUseElement).getAttribute('xlink:href'))
        .to.equal('#i-error-solid');
      const errorMessage = vm.$el.querySelector('.errorMessage');
      expect((errorMessage as HTMLElement).innerText).to.equal('你错了');
      console.log('VueInput可以接受error');
      vm.$destroy();
    });
  });
  • 每次的重复代码太多
  • 将测试用例中的重复代码提取到 Mocha.js Hooks 的生命周期函数中
  • 使用每次运行测试前后的钩子函数beforeEach afterEach

重构input.test.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import Vue from 'vue';
import chai from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import VueInput from '../src/components/input/VueInput.vue';

chai.use(sinonChai);
const expect = chai.expect;

Vue.config.productionTip = false;
Vue.config.devtools = false;

describe('VueInput', () => {
  const Constructor = Vue.extend(VueInput);
  let vm: Vue;

  it('VueInput存在.', () => {
    expect(VueInput).to.exist;
    console.log('VueInput存在');
  });

  describe('props', () => {
    afterEach(() => {
      vm.$destroy();
    });

    it('VueInput可以接受value', () => {
      vm = new Constructor({
        propsData: {
          value: '1234'
        }
      }).$mount();
      const inputElement = vm.$el.querySelector('input');
      expect((inputElement as HTMLInputElement).value)
        .to.equal('1234');
      console.log('VueInput可以接受value');
    });

    it('VueInput可以接受disabled', () => {
      vm = new Constructor({
        propsData: {
          disabled: true
        }
      }).$mount();
      const inputElement = vm.$el.querySelector('input');
      expect((inputElement as HTMLInputElement).disabled)
        .to.equal(true);
      console.log('VueInput可以接受disabled');
    });

    it('VueInput可以接受readOnly', () => {
      vm = new Constructor({
        propsData: {
          readonly: true
        }
      }).$mount();
      const inputElement = vm.$el.querySelector('input');
      expect((inputElement as HTMLInputElement).readOnly)
        .to.equal(true);
      console.log('VueInput可以接受readOnly');
    });

    it('VueInput可以接受error', () => {
      vm = new Constructor({
        propsData: {
          error: '你错了'
        }
      }).$mount();
      //svg
      const useElement = vm.$el.querySelector('use');
      expect((useElement as SVGUseElement).getAttribute('xlink:href'))
        .to.equal('#i-error-solid');
      const errorMessage = vm.$el.querySelector('.errorMessage');
      expect((errorMessage as HTMLElement).innerText).to.equal('你错了');
      console.log('VueInput可以接受error');
    });
  });
  • 作用域被隔开了,vm的作用域分别在各自的测试用例中
  • 在外部统一提前声明
    • const Constructor = Vue.extend(VueInput);
    • let vm: Vue;

测试事件

测试 change 事件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
//...
  describe('事件', () => {
    afterEach(() => {
      vm.$destroy();
    });
    
    it('input 支持 change 事件', () => {
      vm = new Constructor({}).$mount();
      const callback = sinon.fake();
      // 监听
      vm.$on('change', callback);
      // 手动触发 input 的事件
      ...
      // 断言
      expect(callback).to.have.been.called;
    }
  });
//...
  • 如何手动触发事件
    • trigger change event manually
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//...
  describe('事件', () => {
    afterEach(() => {
      vm.$destroy();
    });

    it('input 支持 change 事件', () => {
      vm = new Constructor({}).$mount();
      const callback = sinon.fake();
      // 监听
      vm.$on('change', callback);

      // 手动触发 input 的事件
      const changeEvent = new Event('change');
      const inputElement = vm.$el.querySelector('input');

      console.log('changeEvent: ', changeEvent);
      console.log('inputElement: ', inputElement);

      (inputElement as HTMLInputElement).dispatchEvent(changeEvent);

      // 断言
      expect(callback).to.have.been.called;
      console.log('改变 input 值 触发 change 事件');
    });

  });
 //...
  • 使用 new Event('change') API 创建事件
  • 使用 inputElement.dispatchEvent(new Event('change')); API 触发事件
  • 控制台打印出changeEvent
    • 发现 Event{isTrusted: false} 注意属性 {isTrusted: false}
    • 事件不是浏览器响应UI产生的
  • 控制台打印出inputElement
    • <input data-v-f67744="" type="text" class="">

测试change事件失败

  • 因为VueInput组件传递给外部父组件的是自定义事件updateChange
    • this.$emit('updateChange', $event.target.value);
  • 改为传change事件
    • this.$emit('change', $event.target.value);
  • 再次测试 change 事件

手动触发的change能否正确地传递事件的参数

  • 先将子组件VueInput.vue触发input事件时发布的自定义事件改为this.$emit('change', $event);

测试 Inputs.vue事件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<template>
  <div>
    <VueInput placeholder="Enable"
              id="testInput"
              @change="inputChange">
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';
import VueInput from './input/VueInput.vue';

@Component({
  components: {
    VueInput
  }
})
export default class Inputs extends Vue {
  name = 'Inputs';

  mounted() {
    setTimeout(() => {
      const event = new Event('change');
      const inputElement = this.$el.querySelector('#testInput');
      console.log('inputElement: ', inputElement);
      console.log('event: ', event);
      (inputElement as HTMLInputElement).dispatchEvent(event);
      console.log('自己触发input事件');
    }, 0);
  }

  inputChange(e: Event) {
    console.log(e);
  }
  
}
</script>
  • 对比浏览器触发的和使用 API 代码触发的change事件
    • 用户操作UI产生的 Event {isTrusted: true...}
    • 调用 API产生的Event {isTrusted: false...}
    • 其他属性都相同
  • 可以模拟

还期待spy函数在触发(called)的时候传递参数Event


input.test.ts

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
import Vue from 'vue';
import chai from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import VueInput from '../src/components/input/VueInput.vue';

chai.use(sinonChai);
const expect = chai.expect;

Vue.config.productionTip = false;
Vue.config.devtools = false;

describe('VueInput', () => {
  const Constructor = Vue.extend(VueInput);
  let vm: Vue;

  it('VueInput存在.', () => {
    expect(VueInput).to.exist;
    console.log('VueInput存在');
  });

  describe('props', () => {
    afterEach(() => {
      vm.$destroy();
    });

    it('VueInput可以接受value', () => {
      vm = new Constructor({
        propsData: {
          value: '1234'
        }
      }).$mount();
      const inputElement = vm.$el.querySelector('input');
      expect((inputElement as HTMLInputElement).value)
        .to.equal('1234');
      console.log('VueInput可以接受value');
    });

    it('VueInput可以接受disabled', () => {
      vm = new Constructor({
        propsData: {
          disabled: true
        }
      }).$mount();
      const inputElement = vm.$el.querySelector('input');
      expect((inputElement as HTMLInputElement).disabled)
        .to.equal(true);
      console.log('VueInput可以接受disabled');
    });

    it('VueInput可以接受readOnly', () => {
      vm = new Constructor({
        propsData: {
          readonly: true
        }
      }).$mount();
      const inputElement = vm.$el.querySelector('input');
      expect((inputElement as HTMLInputElement).readOnly)
        .to.equal(true);
      console.log('VueInput可以接受readOnly');
    });

    it('VueInput可以接受error', () => {
      vm = new Constructor({
        propsData: {
          error: '你错了'
        }
      }).$mount();
      //svg
      const useElement = vm.$el.querySelector('use');
      expect((useElement as SVGUseElement).getAttribute('xlink:href'))
        .to.equal('#i-error-solid');
      const errorMessage = vm.$el.querySelector('.errorMessage');
      expect((errorMessage as HTMLElement).innerText).to.equal('你错了');
      console.log('VueInput可以接受error');
    });
  });

  describe('事件', () => {
    afterEach(() => {
      vm.$destroy();
    });

    it('input 支持 change 事件', () => {
      vm = new Constructor({}).$mount();
      const callback = sinon.fake();
      // 监听
      vm.$on('change', callback);

      // 手动触发 input 的事件
      const changeEvent = new Event('change');
      const inputElement = vm.$el.querySelector('input');

      console.log('changeEvent: ', changeEvent);
      console.log('inputElement: ', inputElement);
      
      (inputElement as HTMLInputElement).dispatchEvent(changeEvent);

      // 断言
      expect(callback).to.have.been.calledWith(changeEvent);
      console.log('改变 input 值 触发 change 事件');
    });

  });

});

测试 VueInput.vue 的其他事件

  • input事件
  • focus事件
  • blur事件

input.test.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
//...
  describe('事件', () => {
    afterEach(() => {
      vm.$destroy();
    });

    it('VueInput 支持 change 事件', () => {
      vm = new Constructor({}).$mount();
      const callback = sinon.fake();
      // 监听
      vm.$on('change', callback);
      // 手动触发 input 的事件
      const changeEvent = new Event('change');
      const inputElement = vm.$el.querySelector('input');
      (inputElement as HTMLInputElement).dispatchEvent(changeEvent);

      // 断言
      expect(callback).to.have.been.calledWith(changeEvent);
      console.log('VueInput 支持 change 事件');
    });

    it('VueInput 支持 input 事件', () => {
      vm = new Constructor({}).$mount();
      const callback = sinon.fake();
      // 监听
      vm.$on('input', callback);
      // 手动触发 input 的事件
      const changeEvent = new Event('input');
      const inputElement = vm.$el.querySelector('input');
      (inputElement as HTMLInputElement).dispatchEvent(changeEvent);

      // 断言
      expect(callback).to.have.been.calledWith(changeEvent);
      console.log('VueInput 支持 input 事件');
    });

    it('VueInput 支持 focus 事件', () => {
      vm = new Constructor({}).$mount();
      const callback = sinon.fake();
      // 监听
      vm.$on('focus', callback);
      // 手动触发 input 的事件
      const changeEvent = new Event('focus');
      const inputElement = vm.$el.querySelector('input');
      (inputElement as HTMLInputElement).dispatchEvent(changeEvent);

      // 断言
      expect(callback).to.have.been.calledWith(changeEvent);
      console.log('VueInput 支持 focus 事件');
    });

    it('VueInput 支持 blur 事件', () => {
      vm = new Constructor({}).$mount();
      const callback = sinon.fake();
      // 监听
      vm.$on('blur', callback);
      // 手动触发 input 的事件
      const changeEvent = new Event('blur');
      const inputElement = vm.$el.querySelector('input');
      (inputElement as HTMLInputElement).dispatchEvent(changeEvent);

      // 断言
      expect(callback).to.have.been.calledWith(changeEvent);
      console.log('VueInput 支持 blur 事件');
    });

  });
//...
  • 重复代码太多
  • 重构input.test.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//...
  describe('事件', () => {
    afterEach(() => {
      vm.$destroy();
    });

    it('VueInput 支持 change/input/focus/blur 事件', () => {
      ['change', 'input', 'focus', 'blur']
        .forEach((eventName) => {
          vm = new Constructor({}).$mount();
          const callback = sinon.fake();
          // 监听
          vm.$on(eventName, callback);
          // 手动触发 input 的事件
          const changeEvent = new Event(eventName);
          const inputElement = vm.$el.querySelector('input');
          (inputElement as HTMLInputElement).dispatchEvent(changeEvent);

          // 断言
          expect(callback).to.have.been.calledWith(changeEvent);
          console.log(`VueInput 支持 ${eventName} 事件`);
        });

    });
//...
  • TDD 测试代码全部通过,即开发完毕

目前的VueInput只支持传参和事件

VueInput 支持 v-model (模拟双向绑定)

双向绑定

  • 用户的操作(输入)可改变数据(绑定到JS中的变量)
  • JS代码逻辑改变数据

Vue 使用 v-model模拟双向绑定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
    <div>
        <input type="text" v-model="message">
        <p> {{ message }}</p>
    </div>
</template>
<script>
    const app = new Vue({
        el: "#app",
        data() {
            return {
                message: "hi"
            }
        },
        created() {
            setInterval( ()=>{
                this.message += '1'
            }, 1000)
        }
    })
</script>

其实v-model只是语法糖,相当于以下代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
    <div>
        <input type="text"
               :value="message"
               @input="message = $event.target.value">
        <p> {{ message }}</p>
    </div>
</template>
<script>
    const app = new Vue({
        el: "#app",
        data() {
            return {
                message: "hi"
            }
        },
        created() {
            setInterval( ()=>{
                this.message += '1'
            }, 1000)
        }
    })
</script>
  • <input>中的v-model相当于:
    • 绑定属性 :value="message"
    • 监听input事件,并且赋给事件目标的值 @input="message = $event.target.value"

参考 demo


VueInput 实现支持 v-model

修改VueInput中的事件传值$event.target.value

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
<template>
  <div class="wrapper">
    <label class="form-item" :for="labelName">
      <template>
        <span class="name">{{ labelName }}</span>
        <input type="text"
               :name="labelName"
               :value="value"
               :placeholder="placeholder"
               :disabled="disabled"
               :readonly="readonly"
               :error="error"
               :class="{'fake-focus': isFakeFocus,
                        'fake-hover': isFakeHover && !error,
                        'fake-hover-error': isFakeHover && error,
                        error}"
               @change="change"
               @input="input"
               @focus="focus"
               @blur="blur">
        <div class="input-info" v-if="error">
          <VueIcon icon-name="error-solid"
                   class="icon-error"></VueIcon>
          <span class="errorMessage">{{ error }}</span>
        </div>
      </template>
    </label>
  </div>
</template>

<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator';
import VueIcon from '../icon/VueIcon.vue';

@Component({
  components: {VueIcon}
})
export default class VueInput extends Vue {
  name = 'VueInput';
  @Prop(String) labelName!: string;
  @Prop(String) value!: '';
  @Prop(String) placeholder!: string;
  @Prop(Boolean) isFakeFocus!: false;
  @Prop(Boolean) isFakeHover!: false;
  @Prop(Boolean) readonly!: false;
  @Prop(Boolean) disabled!: false;
  @Prop(String) error!: '';

  change($event: { target: HTMLInputElement }) {
    this.$emit('change', $event.target.value);
  }

  input($event: { target: HTMLInputElement }) {
    this.$emit('input', $event.target.value);
  }

  focus($event: { target: HTMLInputElement }) {
    this.$emit('focus', $event.target.value);
  }

  blur($event: { target: HTMLInputElement }) {
    this.$emit('blur', $event.target.value);
  }

}
</script>

<style lang="scss" scoped>
...
</style>

Inpus.vue已经实现了v-model

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<template>
  <div>
    <form>
      <fieldset>
        <legend>v-model binding value: {{ message }}</legend>
        <VueInput placeholder="Enable"
                  @change="inputChange"
                  v-model="message">
        </VueInput>
        <VueButton @click="message += 1">+1</VueButton>
      </fieldset>
    </form>
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';
import VueInput from './input/VueInput.vue';
import VueButton from './button/VueButton.vue';

@Component({
  components: {
    VueInput,
    VueButton
  }
})
export default class Inputs extends Vue {
  name = 'Inputs';
  message = 'hi';

  inputChange(e: Event) {
    console.log(e);
  }

}
</script>

<style lang="scss" scoped>
@import '../style/global.scss';

details {
  cursor: pointer;

  .error {
    color: $danger;
  }
}
</style>

v-model实现了

  • 绑定数据 message
  • 监听input事件,响应触发,改变数据 message

测试v-model

测试挂了

  • 因为之前测试传的是事件expect(callback).to.have.been.calledWith(triggerEvent);
  • 现在传的是事件目标的值$event.target.value,未得到完整的事件对象,未调用calledWith(triggerEvent)

控制台打出console.log('triggerEvent: ', triggerEvent);

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
//...
    it('VueInput 支持 change/input/focus/blur 事件', () => {
      ['change', 'input', 'focus', 'blur']
        .forEach((eventName) => {
          vm = new Constructor({}).$mount();
          const callback = sinon.fake();
          // 监听
          vm.$on(eventName, callback);
          // 手动触发 input 的事件
          const triggerEvent = new Event(eventName);
          const inputElement = vm.$el.querySelector('input');
          (inputElement as HTMLInputElement).dispatchEvent(triggerEvent);

          console.log('triggerEvent: ', triggerEvent);
          // 断言
          expect(callback).to.have.been.calledWith(triggerEvent);
          console.log(`VueInput 支持 ${eventName} 事件`);
        });

    });
//...
  • 得到Event{isTrusted: false},只有isTrusted: false这一个属性,没有value属性

value = 'hi'赋值? 期待参数 calledWith('hi')

  • event.target = { value: 'hi' }
  • 期待参数 expect(callback).to.have.been.calledWith('hi');
  • 报错,显示cannot assign read only property 'target' of object '#<Event>' 是只读属性
  • 只能在事件上添加指定的属性new Event("change", {"bubbles": true, "canclable": false, "composed": true}) 是否冒泡 是否可取消 是否合成

搜索js new event set target property


使用Object.defineProperty添加事件对象的属性

  • Object.defineProperty( triggerEvent, 'target', {value: {value: 'hi'}} );

input.test.ts

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
import Vue from 'vue';
import chai from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import VueInput from '../src/components/input/VueInput.vue';

chai.use(sinonChai);
const expect = chai.expect;

Vue.config.productionTip = false;
Vue.config.devtools = false;

describe('VueInput', () => {
  const Constructor = Vue.extend(VueInput);
  let vm: Vue;

  it('VueInput存在.', () => {
    expect(VueInput).to.exist;
    console.log('VueInput存在');
  });

  describe('props', () => {
    afterEach(() => {
      vm.$destroy();
    });

    it('VueInput可以接受value', () => {
      vm = new Constructor({
        propsData: {
          value: '1234'
        }
      }).$mount();
      const inputElement = vm.$el.querySelector('input');
      expect((inputElement as HTMLInputElement).value)
        .to.equal('1234');
      console.log('VueInput可以接受value');
    });

    it('VueInput可以接受disabled', () => {
      vm = new Constructor({
        propsData: {
          disabled: true
        }
      }).$mount();
      const inputElement = vm.$el.querySelector('input');
      expect((inputElement as HTMLInputElement).disabled)
        .to.equal(true);
      console.log('VueInput可以接受disabled');
    });

    it('VueInput可以接受readOnly', () => {
      vm = new Constructor({
        propsData: {
          readonly: true
        }
      }).$mount();
      const inputElement = vm.$el.querySelector('input');
      expect((inputElement as HTMLInputElement).readOnly)
        .to.equal(true);
      console.log('VueInput可以接受readOnly');
    });

    it('VueInput可以接受error', () => {
      vm = new Constructor({
        propsData: {
          error: '你错了'
        }
      }).$mount();
      //svg
      const useElement = vm.$el.querySelector('use');
      expect((useElement as SVGUseElement).getAttribute('xlink:href'))
        .to.equal('#i-error-solid');
      const errorMessage = vm.$el.querySelector('.errorMessage');
      expect((errorMessage as HTMLElement).innerText).to.equal('你错了');
      console.log('VueInput可以接受error');
    });
  });

  describe('事件', () => {
    afterEach(() => {
      vm.$destroy();
    });

    it('VueInput 支持 change/input/focus/blur 事件', () => {
      ['change', 'input', 'focus', 'blur']
        .forEach((eventName) => {
          vm = new Constructor({}).$mount();
          const callback = sinon.fake();
          // 监听
          vm.$on(eventName, callback);
          // 创建事件 给事件对象添加属性
          const triggerEvent = new Event(eventName);
          Object.defineProperty(
            triggerEvent,
            'target',
            {value: {value: 'hi'}}
          );
          const inputElement = vm.$el.querySelector('input');
          // 手动触发 input 的事件
          (inputElement as HTMLInputElement).dispatchEvent(triggerEvent);
          // 断言
          expect(callback).to.have.been.calledWith('hi');
          console.log(`VueInput 支持 ${eventName} 事件`);
        });
      console.log(`VueInput 支持 v-model`);
    });

  });

});

其他注意

  • VueButton.vue注意其中的<button></button>元素必须设置属性type="button"
    • 按钮不是用于向服务器提交数据,请确保这些按钮的 type 属性设置成 button
    • 否则它们被按下后将会向服务器发送数据并加载(可能并不存在的)响应内容
    • 因而可能会破坏当前文档的状态,比如在 url 后添加 ? ,路由跳转
    • 详见 MDN

VueInput 简单轮子:文本输入框小结

  • 测试驱动开发
    • karma提供运行器,打开ChromeHeadless
    • karma打开测试文件 dist/**/*.test.js dist/**/*.test.css
    • karma测试结果信息 [progress]
    • karma引入mocha
      • mocha提供describe方法
      • mocha提供it方法
      • 全局属性,无需手动引入
    • karma引入sinon-chai
      • sinon-chai提供fake方法
      • sinon-chai提供expect方法
      • sinon-chai提供called方法
      • sinon-chai提供calledWith方法
    • ``提供describe方法

其他

安装浏览器插件vue开发者工具

  • 组件属性name的用途:进行组件标签的命名


参考文章

相关文章


  • 作者: Joel
  • 文章链接:
  • 版权声明
  • 非自由转载-非商用-非衍生-保持署名