【项目-喵内记账-meoney-02】Money.vue 组件

大纲链接 §

[toc]


Figama UI


整体思路

代码超过 150行 就需要考虑模块化分割

  • HTML按结构逻辑:整体从上到下,局部从外到里依次写出
  • 将HTML按结构或功能分为若干个组件
  • 将SCSS分为reset、全局、变量、局部
  • TypeScript依据单一功能原则,模块化

目前文件结构

 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
src
 ┣ assets
 ┃ ┣ icons
 ┃ ┃ ┣ account.svg
 ┃ ┃ ┣ bills.svg
 ┃ ┃ ┗ statistics.svg
 ┃ ┣ style
 ┃ ┃ ┣ global.scss
 ┃ ┃ ┗ reset.scss
 ┃ ┗ logo.png
 ┣ components
 ┃ ┣ Icon.vue
 ┃ ┣ Layout.vue
 ┃ ┗ Nav.vue
 ┣ router
 ┃ ┗ index.ts
 ┣ store
 ┃ ┗ index.ts
 ┣ views
 ┃ ┣ Labels.vue
 ┃ ┣ Money.vue
 ┃ ┣ NotFound.vue
 ┃ ┗ Statistics.vue
 ┣ App.vue
 ┣ main.ts
 ┣ registerServiceWorker.ts
 ┣ shims-tsx.d.ts
 ┗ shims-vue.d.ts
  • App.vue中只展示router-view
  • 所有完整展示页面放在src/views路径下
  • 所有功能的组件放在src/components路径下
  • 全局样式和初始化样式放入src/assets/style路径下
  • router读取不同router-view,默认展示页面Money.vue,在 Nav.vue 组件中展示路由选项
  • Money大致结构为div.layout-wrapper中包含
    • 内容展示页div.content
    • 导航标签页Nav
    • 其他展示页面结构相同
  • 分别提取出Layout.vueNav.vue组件
    • Layout.vue: div.wrapper(div.content + Nav)
      • 不同页面中的div.content用匿名插槽<slot/>
      • div.content(Money | Labels | Statistics)
    • Nav.vue: <nav></nav>(若干个<router-link/>)
  • router-view>Layout>div.content_(Money | Labels | Statistics) + Nav

HTML初步结构

  • 先整体 后细化–类似递归

Money.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
<template>
  <Layout>
    <div class="tags">
      <ul class="current">
        <li></li>
        <li></li>
        <li></li>
        <li></li>
      </ul>
      <div class="new">
        <button>新增标签</button>
      </div>
    </div>
    <div>
      <label class="notes">
        <span class="name">备注</span>
        <input type="text" placeholder="在这里输入备注"/>
      </label>
    </div>
    <div>
      <ul class="types">
        <li class="selected">支出</li>
        <li>收入</li>
      </ul>
    </div>
    <div class="numpad">
      <div class="output">100</div>
      <div class="buttons">
        <button>1</button>
        <button>2</button>
        <button>3</button>
        <button>删除</button>
        <button>4</button>
        <button>5</button>
        <button>6</button>
        <button>清空</button>
        <button>7</button>
        <button>8</button>
        <button>9</button>
        <button class="ok">OK</button>
        <button class="zero">0</button>
        <button>.</button>
      </div>
    </div>
  </Layout>
</template>

<script lang="ts">
export default {
  name: 'Money',
};
</script>

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

CSS 思路

  • reset.scss
  • 全局
  • 变量
  • 局部
  • mixins.scss

CSS reset

  • 重置样式设置的组件中不可用scope,不仅仅影响当前组件,需要影响整个页面
  • 去除选中状态的:focus {outline: none;};

reset.scss

 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
* {
  box-sizing: border-box;
  line-height: 1.5;
  margin: 0;
  padding: 0;
}

a {
  color: inherit;
  text-decoration: none;
}

ul,
ol {
  list-style: none;
}

button,
input {
  border: 0;
  border-radius: 0;
  font: inherit;
}

:focus {
  outline: none;
}

h1,
h2,
h3,
h4,
h5,
h6 {
  font-weight: normal;
}


全局样式(字体、行高)

字体 fonts.css 搜索 "fonts.css" 中文

global.scss

1
2
3
4
$font-hei:  -apple-system, "Smartisan CNS", "Noto Sans", "Helvetica Neue", Helvetica, "Nimbus Sans L", Arial, "Liberation Sans", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN", "Microsoft YaHei", "Wenquanyi Micro Hei", "WenQuanYi Zen Hei", "ST Heiti", SimHei, "WenQuanYi Zen Hei Sharp", sans-serif;
$font-kai: Baskerville, Georgia, "Liberation Serif", "Kaiti SC", STKaiti, "AR PL UKai CN", "AR PL UKai HK", "AR PL UKai TW", "AR PL UKai TW MBE", "AR PL KaitiM GB", KaiTi, KaiTi_GB2312, DFKai-SB, "TW\-Kai", serif;
$font-song: Georgia, "Nimbus Roman No9 L", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", "Source Han Serif CN", STSong, "AR PL New Sung", "AR PL SungtiL GB", NSimSun, SimSun, "TW\-Sung", "WenQuanYi Bitmap Song", "AR PL UMing CN", "AR PL UMing HK", "AR PL UMing TW", "AR PL UMing TW MBE", PMingLiU, MingLiU, serif;

App.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
<template>
  <div id="app">
    <hr/>
    <router-view></router-view>
  </div>
</template>

<script>
export default {
  components: {
  },
  name: "App",
};
</script>

<style lang="scss">
@import "~@/assets/style/global.scss";
@import "~@/assets/style/reset.scss";
body {
  }
#app {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  line-height: 1.5;
  font-family: $font-hei;
  color: #2c3e50;
  font-size: 16px;
}

</style>

  • 柔和的黑色#333
  • 按照组件来隔离样式,如果将样式写在body上,团队间注意可能会互相影响的样式
  • 选择将总体样式写在#app
  • font-size: 16px;明确设置总体字体大小,局部可覆盖演示

变量

  • 所有变量放到global.scss
  • 字体以$font-开头
  • 颜色以$color-开头

global.scss

1
2
3
4
5
$font-hei:  -apple-system, "Smartisan CNS", "Noto Sans", "Helvetica Neue", Helvetica, "Nimbus Sans L", Arial, "Liberation Sans", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN", "Microsoft YaHei", "Wenquanyi Micro Hei", "WenQuanYi Zen Hei", "ST Heiti", SimHei, "WenQuanYi Zen Hei Sharp", sans-serif;
$font-kai: Baskerville, Georgia, "Liberation Serif", "Kaiti SC", STKaiti, "AR PL UKai CN", "AR PL UKai HK", "AR PL UKai TW", "AR PL UKai TW MBE", "AR PL KaitiM GB", KaiTi, KaiTi_GB2312, DFKai-SB, "TW\-Kai", serif;
$font-song: Georgia, "Nimbus Roman No9 L", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", "Source Han Serif CN", STSong, "AR PL New Sung", "AR PL SungtiL GB", NSimSun, SimSun, "TW\-Sung", "WenQuanYi Bitmap Song", "AR PL UMing CN", "AR PL UMing HK", "AR PL UMing TW", "AR PL UMing TW MBE", PMingLiU, MingLiU, serif;

$color-highlight: orangered;
  • global.scss中除了SCSS变量、函数和Mixin以外不要放任何其他样式
  • global.scss是被各个模块多次引用的,每引用一次,就多复制一次
  • 变量会最终被编译器删掉,文件也不存在

局部样式

  • 预先写好CSS结构

Money.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
<template>
  <Layout>
    <div class="tags">
      <ul class="current">
        <li></li>
        <li></li>
        <li></li>
        <li></li>
      </ul>
      <div class="new">
        <button>新增标签</button>
      </div>
    </div>
    <div>
      <label class="notes">
        <span class="name">备注</span>
        <input type="text"/>
      </label>
    </div>
    <div>
      <ul class="types">
        <li class="selected">支出</li>
        <li>收入</li>
      </ul>
    </div>
    <div class="numpad">
      <div class="output">100</div>
      <div class="buttons">
        <button>1</button>
        <button>2</button>
        <button>3</button>
        <button>删除</button>
        <button>4</button>
        <button>5</button>
        <button>6</button>
        <button>清空</button>
        <button>7</button>
        <button>8</button>
        <button>9</button>
        <button>OK</button>
        <button>0</button>
        <button>.</button>
      </div>
    </div>
  </Layout>
</template>

<script lang="ts">
export default {
  name: 'Money',
};
</script>

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

.tags {
  > .current {
    > li {
      }
    }
  > .new {
    button {
      }
    }
  }
.notes {
  .name {
    }
  input {
    }
  }
.types {
  > li {
    &.selected {
      }
    }
  }
.numpad {
  .output {
    }
  .buttons {
    }
  }

</style>

分为四个部分结构

  • div.tags部分结构
  • label.notes部分结构
  • ul.types部分结构
  • div.numpad部分结构

div.tags部分结构

 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
/*
<div class="tags">
  <ul class="current">
    <li>衣</li>
    <li>食</li>
    <li>住</li>
    <li>行</li>
  </ul>
  <div class="new">
    <button>新增标签</button>
  </div>
</div>
*/

.tags {
  font-size: 14px;
  padding: 16px;
  > .current {
    display: flex;
    > li {
      $h: 24px;
      background: #d9d9d9;
      height: $h;
      line-height: $h;
      border-radius: ($h/2);
      padding: 0 16px;
      margin-right: 12px;
      }
    }
  > .new {
    padding-top: 16px;
    button {
      background: transparent;
      border: none;
      border-bottom: 1px solid;
      color: #999;
      padding: 0 4px;
      }
    }
  }
  • border-radius: 50%; 默认是指宽度的50%,而需要的效果是高度的50%
  • 设置高度height: 24px;
  • 设置border-radius: (24px/2);,注意写法(24/2)px是错误的
  • 提取变量$h: 24px;,使用height: $h; border-radius: ($h/2);
  • 使用padding: 0 16px;而不是宽度开控制盒子整体尺寸
  • 使用margin-right: 4px;,不使用margin-left,左边空隙在外面容器上加样式
  • 覆盖掉全局样式font-size: 14px;
  • 字体默认不继承父元素,不继承body上设置的字体,必须在reset.scss中重置 button, input { font: inherit;}
  • 字体未设置垂直居中,调大高度可以发现目前并不居中
    • flex布局
    • 确保只有一行文字时,可用行高line-height和元素高height一样
  • “新增按钮”样式 button {background: transparent;border: none;border-bottom: 1px solid;color: #999;}
    • 注意细节,下划线长于文字 添加padding: 0 3px;

label.notes部分结构

  • 左右结构
  • label标签的display属性从inline改为block,否则不显示背景色
  • display: flex;和垂直居中align-items: center;
 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
/*
<div>
  <label class="notes">
    <span class="name">备注</span>
    <input type="text"/>
  </label>
</div>
*/

.notes {
  background: #f5f5f5;
  display: flex;
  align-items: center;
  font-size: 14px;
  padding-left: 16px;
  .name {
    padding-right: 16px;
    }
  input {
    height: 73px;
    flex-grow: 1;
    background: transparent;
    border: none;
    padding-right: 16px;
    }
  }

ul.types部分结构

  • 左右结构
  • 容器不用确定的宽度> li {width: 50%;}
 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
/*
<div>
  <ul class="types">
    <li class="selected">支出</li>
    <li>收入</li>
  </ul>
</div>
*/

.types {
  background: #c4c4c4;
  display: flex;
  text-align: center;
  > li {
    font-size: 24px;
    width: 50%;
    height: 64px;
    display: flex;
    justify-content: center;
    align-items: center;
    position: relative;
    &.selected {
      background: #c4c4c4;
      &::after {
        content: "";
        display: block;
        position: absolute;
        bottom: 0;
        left: 0;
        width: 100%;
        height: 4px;
        background: #333;
        }
      }
    }
  }

  • height: 64px; display: flex; justify-content: center; align-items: center; 代替 line-height: 64px;
  • 注意添加&.selected {border-bottom: 4px solid;}时会多一点高度,而处于未选中状态时,底部边框高度消失,切换时造成文字抖动
  • 所以不能用会影响文字所占空间的border
    • 附加的元素可用伪元素&.selected::after {}

div.numpad部分结构

  • 输出结果栏用等宽字体font-family: Consolas, monospace
 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
/*
<div class="numpad">
  <div class="output">100</div>
  <div class="buttons">
    <button>1</button>
    <button>2</button>
    <button>3</button>
    <button>删除</button>
    <button>4</button>
    <button>5</button>
    <button>6</button>
    <button>清空</button>
    <button>7</button>
    <button>8</button>
    <button>9</button>
    <button>OK</button>
    <button>0</button>
    <button>.</button>
  </div>
</div>
*/

.numpad {
  .output {
    @extend %innerShadow;
    font-size: 36px;
    font-family: Consolas, monospace;
    padding: 9px 16px;
    text-align: right;
    }
  .buttons {
    @extend %clearFix;
    > button {
      width: 25%;
      height: 64px;
      float: left;
      background: transparent;
      border: none;
      &.ok {
        height: (64px) * 2;
        float: right;
        }
      &.zero {
        width: 25 * 2%;
        }
      $bg: #f2f2f2;
      &:nth-child(1) {
        background: $bg;
        }
      &:nth-child(2),
      &:nth-child(5) {
        background: darken($bg, 5%);
        }
      &:nth-child(3),
      &:nth-child(6),
      &:nth-child(9) {
        background: darken($bg, 5 * 2%);
        }
      &:nth-child(4),
      &:nth-child(7),
      &:nth-child(10) {
        background: darken($bg, 5 * 3%);
        }
      &:nth-child(8),
      &:nth-child(11),
      &:nth-child(13) {
        background: darken($bg, 5 * 4%);
        }
      &:nth-child(14) {
        background: darken($bg, 5 * 5%);
        }
      &:nth-child(12) {
        background: darken($bg, 5 * 6%);
        }
      }
    }
  }
  • 数字键盘用float布局时注意清除浮动
    • float: left;
  • 提取清除浮动的共用样式 placeholderglobal.scss
    • %clearFix { &::after { content: ''; display: block; clear: both; } }
    • 在父元素上引用@extend %clearFix;
    • 相当于将所有拥有此placeholder的选择器名都复制过去,并用逗号分隔,替换掉原先%clearFix的位置
  • 提取外阴影公共样式%outShadow {box-shadow: inset 0 -5px 5px -5px fade_out(black, 0.6), inset 0 5px 5px -5px fade_out(black, 0.5);}
  • float布局 V.S. 网格结构grid布局

之后会改为grid布局,用数据循环生成键盘结构


提取变量到global.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
$font-hei:...;
$color-highlight: orangered;
$color-shadow: rgba(0,0,0,0.25);
// placeholder
%clearFix {
  &::after {
    content: '';
    display: block;
    clear: both
    }
  }
%outerShadow {
  box-shadow: inset 0 0px 3px $color-shadow;
  }
%innerShadow {
  box-shadow: inset 0 -3px 3px -3px $color-shadow,
  inset 0 3px 3px -3px $color-shadow;
  }

为了布局Money内部模块,改进Layout.vue

  • 需求:根据不同的展示页面,div.layout-wrapper>div.content使用不同的样式
  • 如果在Money.vueLayout标签上加样式属性class="xxx"
  • 因为使用了Layout.vue组件,实际会加到Layout.vue的根标签div.layout-wrapper
    • 但预期应该是样式加在更里面一层的div.content上,从而控制Money.vue内部四个模块的布局样式(.tags.notes.types.numpad),而Nav组件不受影响
    • 结构:Layout>div.layout-wrapper>div.content_(Money | Labels | Statistics)+Nav
  • 方法: 使用外部数据 动态绑定到内部具体某个标签样式属性上
    • Layout.vue组件中引入外部数据props: ['classPrefix']
    • 绑定动态样式,标签中可以同时出现class和绑定的:class属性,会自动合并
    • Layout.vue外部Money组件传来的数据<Layout class-prefix="layout">...</div>得到props: ['classPrefix']Vue会自动处理标签和js中的大小写,自动识别对应的驼峰和连字符写法,HTML标签中的属性名不可有大写的字符)
  • Money.vue组件的<style scoped>是由有范围的,加在原来样式标签里会因为作用域而无效,有scoped属性的样式,只能在当前组件中有效,在Layout.vue组件中无效
  • 再添加一个没有scoped属性的样式标签:Vue的单文件组件中可以有多个样式标签

重构Money.vue,其中<style><style scoped>并存

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
...
<style lang="scss">
.layout-content {
  border: 3px solid green;
  display: flex;
  flex-direction: column;
  }
</style>

<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
.tags {}
.notes {}
.types {}
.numpad {}
...
</style>

  • 文档中必须写清楚
  • 参考ant-design UI库的做法,通过前缀,传不同外部数据,来区分
    • props: ['classPrefix']

重构Layout.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
<template>
  <div class="layout-wrapper" :class="classPrefix && `${classPrefix}-wrapper`">
    <div class="content" :class="classPrefix && `${classPrefix}-content`">
      <slot/>
    </div>
    <Nav/>
  </div>
</template>

<script lang="ts">
export default {
  name: 'Layout',
  props: ['classPrefix'],
};
</script>

<style lang="scss" scoped>
.layout-wrapper {
  display: flex;
  flex-direction: column;
  height: 100vh;
  .content {
    flex-grow: 1;
    overflow: auto;
    }
  }
</style>

  • 注意 :class="classPrefix && `${classPrefix}-wrapper`" ,双引号里的是JS表达式
  • 用反引号语法作为插值,写入外部数据 ${classPrefix} 为变量
  • Layout.vueMoney.vue中标签<Layout class-prefix="layout">接受数据class-prefix
  • 动态绑定为 \`${classPrefix}-wrapper\` 进行拼接
  • 这样秩序按照前缀就可拼接应用到更多其他样式中
  • 组件外接受classPrefix变量,说明组件内有供外部控制的 CSS class,但不是一个个传,只需传一个前缀,对应活干多个元素上的 class
  • vue deep 防止样式互相覆盖

Money.vue组件模块化思路

vue组件输入补全提示,必须先输入 <, 而直接打组件名不会出不全提示

分割提取Money中的组件

可使用 webStorm 自动提取,自动补全引用路径,但会丢失样式,以及提取的目录只能是当前目录,需要手动移动

  • 保证每个模块代码不超过 150行,通用封装组件,避免屎山
  • 避免同名可建目录,webStorm 自动重构引入路径
  • 分割提取Money.vue 为四个子组件:Tags.vueNotes.vueTypes.vueNumpad.vue
  • 注意组件的引入路径和样式的依赖

Money.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
<template>
  <Layout class-prefix="layout">
    <Tags/>
    <Notes/>
    <Types/>
    <Numpad/>
  </Layout>
</template>

<script lang="ts">
import Tags from '@/components/Money/Tags.vue';
import Notes from '@/components/Money/Notes.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';

export default {
  name: 'Money',
  components: {Numpad, Types, Notes, Tags},
};
</script>

<style lang="scss">
.layout-content {
  display: flex;
  flex-direction: column;
  }
</style>

使用TypeScript与装饰器写Types组件

  • 用来分辨支出还是收入,从而展示不同UI

Types组件的 JS 对比 TS 写法

Types.vue JS 写法

 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
<template>
  <div class="div_types">
    <ul class="types">
      <li :class="type === '-' && 'selected'" @click="selectType('-')">支出</li>
      <li :class="type === '+' && 'selected'" @click="selectType('+')">收入</li>
    </ul>
  </div>
</template>
<script>
export default {
  name: 'Types',
  props: ['xxx'],
  mounted() {
    console.log(this.xxx)
  },
  data() {
    return {
      type: '-' // '-' 表示支出, '+'表示收入
    }
  },
  methods: {
    selectType(type) { // type 中能是 “-” 或 “+”,否则报错
      if (type !== '-' && type !== '+') {
        throw new Error('type is unknown')
      }
      this.type = type
    }
  },
}
</script>
<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
.types {...}
</style>

Money.vue 未用到装饰器的TS写法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
  <Layout class-prefix="layout">
    <Tags/>
    <Notes/>
    <Types xxx="Hi"/>
    <Numpad/>
  </Layout>
</template>

<script lang="ts">
import Tags from '@/components/Money/Tags.vue';
import Notes from '@/components/Money/Notes.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';

export default {
  name: 'Money',
  components: {Numpad, Types, Notes, Tags},
};
</script>
...
  • Vue 处理绑定值为 undefined 自动去除 三目运算符返回的false值,变为空值:
    • <li :class=" type === '-' ? 'selected' : '' ">支出</li>
  • 点击时,触发函数并传参 selectType('-')
  • 注意绑定的样式'selected'是字符串类型 <li :class="type === '-' && 'selected'" @click="selectType('-')">支出</li>
  • Types组件得到外部数据 "Hi"

Types.vue TS 写法 第一个 TS Vue组件

vue/cli初始化时已使用TS配置tsconfig.json

 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
{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "strict": true,
    "jsx": "preserve",
    "importHelpers": true,
    "moduleResolution": "node",
    "experimentalDecorators": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "sourceMap": true,
    "baseUrl": ".",
    "types": [
      "webpack-env",
      "jest"
    ],
    "paths": {
      "@/*": [
        "src/*"
      ]
    },
    "lib": [
      "esnext",
      "dom",
      "dom.iterable",
      "scripthost"
    ]
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx",
    "src/**/*.vue",
    "tests/**/*.ts",
    "tests/**/*.tsx"
  ],
  "exclude": [
    "node_modules"
  ]
}

安装 装饰器 vue-property-decorator 库使用ts装饰器写法

1
yarn add vue-property-decorator

src/components/Money/Types.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
<template>
  <div class="div_types">
    <ul class="types">
      <li :class="type === '-' && 'selected'" @click="selectType('-')">支出</li>
      <li :class="type === '+' && 'selected'" @click="selectType('+')">收入</li>
    </ul>
  </div>
</template>

<script lang="ts">
import Vue from 'vue';
import {Component} from 'vue-property-decorator';
@Component
export default class Types extends Vue {
  // initial data
  type = '-'; // '-' 表示支出, '+'表示收入
  // method
  selectType(type: string) { // type 中能是 “-” 或 “+”,否则报错
    if (type !== '-' && type !== '+') {
      throw new Error('type is unknown');
    }
    this.type = type;
  }
}
</script>

<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
...
</style>
  • TypeScript 只支持在 Vue 的<script> 标签里,<template>标签里只支持 JS 表达式
  • TypeScript 使用前先导入引用 import Vue from 'vue';也可从import {Vue,} from 'vue-property-decorator';中导入
  • TypeScript 语句末尾加分号
  • TS Vue组件不用构造选项,构造选项没有类型
  • TS Vue组件使用 类组件写法: export default class Types extends Vue {}
  • TypeScript 引入装饰器来自动处理export default class里的 成员变量方法声明
    • 解析为initial data method computed prop lifecycle hook等数据
    • import {Component} from 'vue-property-decorator'; @Component...
    • TypeScript 赋值语句自动变为实例的内部数据data
    • 参数type默认是any类型的,但 TS配置 不允许any类型的
    • 必须声明类型: selectType(type: string) {...}
  • 使用装饰器vue-property-decorator
  • 代替 Vue Class Component 官方的库 vue-class-component
  • vue-property-decorator使用指南
  • vue 官方的装饰器库 vue-class-component 的装饰器@Component
  • CRM旧版文档

使用 装饰器[vue-property-decorator] @Prop 目的

目的

  • 单一原则
  • 关注点分离
  • 装饰器使代码逻辑 高内聚
  • Vue 使代码逻辑低耦合
  • 解决了使用选项options写法时,同一逻辑分散在各个选项属性中

实践

  • 参照文档添加@Prop(Number) readonly propA: number | undefined;
    • 指定类型为 Number 只读
    • 属性名为 propA
    • 默认取值必须是 number | undefined
    • 此时传来的外部数据必须是数字类型,否则会报错
    • | undefined为编译时的检查: this.xxx.yyy直接出现红下划线,意味着在运行代码前接报错
    • 即 TS 可以提前告知代码错误
    • (需添加判断if(this.xxx === undefined){console.log('没有xxx')}else{console.log(this.xxx.yyy)}消除红线代码警告)
    • 还会继续检查是否存在yyy,改成this.xxx.toString()才可通过检查
  • Alt + Enter添加引入import {Component, Prop} from 'vue-property-decorator';
  • 装饰器可以极大程度简化Vue组件中各种状态的声明

Money.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<template>
  <Layout class-prefix="layout">
    <Tags/>
    <Notes/>
    <Types :xxx="123"/>
    <Numpad/>
  </Layout>
</template>

<script lang="ts">
import Tags from '@/components/Money/Tags.vue';
import Notes from '@/components/Money/Notes.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';

export default {
  name: 'Money',
  components: {Numpad, Types, Notes, Tags},
};
</script>

Types.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
<template>
  <div class="div_types">
    <ul class="types">
      <li :class="type === '-' && 'selected'" @click="selectType('-')">支出</li>
      <li :class="type === '+' && 'selected'" @click="selectType('+')">收入</li>
    </ul>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';
import {Component, Prop} from 'vue-property-decorator';

@Component
export default class Types extends Vue {
  // initial data
  type = '-'; // '-' 表示支出, '+'表示收入

  // 使用非官方 vue-property-decorator 的装饰器 @Props
  // Prop 告诉 Vue xxx 不是 data 而是 prop
  // Number 告诉 Vue xxx 运行时是个 Number
  // number | undefined 告诉 TS xxx 编译时的类型
  @Prop(Number) readonly xxx: number | undefined;

  // method
  selectType(type: string) { // type 只能是 “-” 或 “+”,否则报错
    if (type !== '-' && type !== '+') {
      throw new Error('type is unknown');
    }
    this.type = type;
  }
  mounted() {
    console.log(this.xxx)
  }
}
</script>
<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
.types {}
</style>

  • @Prop(Number) xxx: number | undefined;
  • Number是指运行时的类型
  • number | undefined是指编译时的类型
  • TS 增加了类型声明

使用TypeScript

  • 命令行中查看TS最新版本npm info typescript version
  • package.json中可以改版本,之后yarn install自动卸载重装
  • 手动重启webStorm

TypeScript 的好处

  1. 类型提示:更智能的提示
  2. 编译时报错:还没运行代码就知道自己写错了 无法编译成 JS
  3. 类型检查:无法.点出错误的属性

写 Vue 组件的三种方式(*.vue 单文件组件)

1.用 JS 对象 export default { data, props, methods, created, ...}

2.用 TS 类 <script lang="ts"> 默认使用class XXX *推荐

1
2
3
4
5
@Component
export default class XXX extends Vue{
  xxx: string = 'hi';
  @Prop(Number) xxx: number | undefined;
}

3.用 JS 类 <script lang="js">

1
2
3
4
@Component
export default class XXX extends Vue{
  xxx = 'hi'
}

TypeScript 的本质

  • JS + Type
  • TypeScript 通过编译器 编译为 JavaScript,再给浏览器
  • 编译时 V.S. 运行时
  • 编译时 如果有编译错误 终端 Error 导致编译失败 浏览器不自动刷新 不更新 JS
  • 运行时 如果错误 浏览器控制台 Error
  • 使用编译器TSCompiler检查,删掉类型就是 可以在浏览器运行的JS
  • 为了开发的流畅,就算TS编译报错,也能跳过错误,继续编译JS不停止,除非设置tsconfig.json 添加 {"compilerOptions": { "noEmitOnError": ..., ...} ...}

抽出封装NumPad.vue组件

功能需求

  • 点击0~9的数字,在div.output显示相应的数字(其实是字符串)
  • 点击.,添加小数点
  • 点击清空div.output显示0
  • 点击OK,确认标签名,添加相应的标签 (后续功能实现)

注意点

  • 先输入0,再输入.0.两个字符都会显示在div.output
  • 通常用字符串显示完整数字0.130
  • 一开始未设置默认高度,div.output塌陷
  • 必须给默认值,高度撑起文档流,使用'0' (空格或$nbsp;都不行)
  • 如果设置min-height,添加计算样式的高度,可能会造成页面的闪烁
  • Numpad中输入的不是数字,是字符串,包含小数点.的字符
  • Vue.js的模板template中不使用TS语法,无法做类型检查,只支持JS
  • 方法中不传参,绑定到事件上,Vue.js会自动传参,这个参数就是和此事件相关的所有信息的事件对象
    • 通常取名evente
    • 方法inputContent(event) { event.target.textContent }
    • event.target.textContent可以获取到当前元素里的内容,即写入的文本节点的字符串
    • TS 对 参数event进行类型检查,声明对象所属的类inputContent(event: Event) {...}
      • 没有所谓的点击事件类,只有鼠标事件类、键盘事件类和UI事件,用户事件等 MDN 鼠标事件
      • click属于 MouseEvent类,是DOM内置的类型,更具体地声明参数类型inputContent(event: MouseEvent) {...}
    • 声明方法参数的类型 inputContent(event: MouseEvent) {console.log(event.target.textContent);}
    • event.target错误提示可能为空值null,需要添加判断语句
    • event.target.textContent也可能为空值,比如图片元素就没有文字内容
    • 使用 TS 断言 可以强制指定为按捏元素的类型 (event.target as HTMLButtonElement) 来代替判断语句
    • Vue2.0和 TS 的结合得不太好

实现

  • 需要一个响应式data保存输出字符串 output: string = ''
    • TS 中变量有默认值,就不用声明变量的类型,可简化为output = ''
  • 插值{{output}}
  • 点击事件<button @click="output += 1">1</button>
  • 抽象出方法inputContent(content: string) {}
  • 改写事件方法 <button @click="inputContent("1")">1</button>,注意类型是字符串
  • 重复太多 改写事件为使用获取目标元素的文本内容
    • inputContent(event: MouseEvent) {const button = (event.target as HTMLButtonElement);}}
  • 不传参<button @click="inputContent">1</button>
  • 点击事件方法有默认参数event时执行不需加括号

功能限制与出现的Bug

  • 点击两次'0',重复出现'0'
    • 小数点前,当前存在的值为以'0'开头,点击多次'0',不重复出现'0'
    • 分别输入'01'自动消'0',变为'1'
    • 小数点后,可以多次出现'0'
  • 点击两次小数点'.',重复出现'.'
    • 正确的为点击一次'.',出现'.',再次点击'.',不出现'.'
  • 默认'0'存在,直接输入'.',显示''0.
  • 显示的数字长短限制

使用条件判断解决Bug

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
inputContent(event: MouseEvent) {
    const button = (event.target as HTMLButtonElement);
    const input = button.textContent as string;
    // 显示的数字长短限制
    if (this.output.length === 12) {return;}
    // '0'开头的逻辑
    if (this.output === '0') {
      if ('0123456789'.indexOf(input) >= 0) {
        this.output = input;
      } else { // '.'的逻辑
        // 按数字位数 拼接 字符串
        this.output += input;
      }
      return;
    }
    // '.'重复判断
    if (this.output.indexOf('.') >= 0 && input === '.') {
      return;
    }
    this.output += input;
}

  • 预想值必为字符串const input = button.textContent as string;,排除空值的情况
    • 语法简化为const input = button.textContent! ,但通不过 TSlint检查,所以不常用
  • 合并逻辑 输入 0 | 输入其他数字 1~9
  • 以任何数字(0~9 一位)开头输入都替换显示的数字this.output = input;

删除逻辑

1
2
3
4
5
6
removeContent() {
    this.output = this.output.slice(0, -1);
    if (this.output === '') {
      this.output = '0';
    }
}

清空逻辑

1
2
3
clearContent() {
    this.output = '0';
}

确认添加标签逻辑

1
2
3
4
confirmContent() {
    this.output = '0';
    // confirm save to Tags
}

出现的Bug2

  • 移动端输入300ms延迟,不顺畅
    • <button @click="inputContent">1</button> 改为 <button @touchstart="inputContent">1</button>
  • 每个按钮重复绑定事件
  • 限制小数到 人民币 分的单位

Numpad.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
<template>
  <div class="numpad">
    <div class="output">{{ output || '0' }}</div>
    <div class="buttons">
      <button @touchstart="inputContent">1</button>
      <button @touchstart="inputContent">2</button>
      <button @touchstart="inputContent">3</button>
      <button @touchstart="removeContent">删除</button>
      <button @touchstart="inputContent">4</button>
      <button @touchstart="inputContent">5</button>
      <button @touchstart="inputContent">6</button>
      <button @touchstart="clearContent">清空</button>
      <button @touchstart="inputContent">7</button>
      <button @touchstart="inputContent">8</button>
      <button @touchstart="inputContent">9</button>
      <button @touchstart="confirmContent" class="ok">OK</button>
      <button @touchstart="inputContent" class="zero">0</button>
      <button @touchstart="inputContent">.</button>
    </div>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';
import {Component} from 'vue-property-decorator';

@Component
export default class Numpad extends Vue {
  output = '0';

  inputContent(event: MouseEvent) {
    const button = (event.target as HTMLButtonElement);
    const input = button.textContent as string;
    // 显示的数字长短限制
    if (this.output.length >= 11) {alert("别做白日梦啦");return;}
    // '0'开头的逻辑
    if (this.output === '0') {
      if ('0123456789'.indexOf(input) >= 0) {
        this.output = input;
      } else { // '.'的逻辑
        // 按数字位数 拼接 字符串
        this.output += input;
      }
      return;
    }
    // '.'开头的逻辑
    if (this.output.indexOf('.') >= 0) {
      // '.'重复判断
      if (input === '.') {return;}
      // '.'限制小数位 2位
      if (this.output.slice(dotIndex, -1).length > 1) {return;}
    }
    this.output += input;
  }

  removeContent() {
    this.output = this.output.slice(0, -1);
    if (this.output === '') {
      this.output = '0';
    }
  }

  clearContent() {
    this.output = '0';
  }

  confirmContent() {
    this.output = '0';
    // confirm save to Tags
  }
}
</script>
<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
.numpad {}
</style>


抽出封装Notes.vue组件

notes 模块 - 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
<template>
  <div class="div_notes">
    <label class="notes">
      <span class="name">备注</span>
      <input type="text"
             :value="inputValue"
             @input="onInput" 
             placeholder="在这里输入备注"/>
    </label>
  </div>
</template>

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

@Component
export default class Notes extends Vue {
  inputValue = '';

  onInput(event: InputEvent | KeyboardEvent) { // value = $event.target.value
    const input = event.target as HTMLInputElement;
    this.inputValue = input.value;
  }
}
</script>

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

  • 绑定input标签的:value
  • 监听input标签的@change事件
  • @change事件 原生HTML自带标签拿到 事件目标值 @change="value = $event.target.value"
  • @change只有光标移入才会触发,改为@input事件更合适
  • 提取为自定义事件@input="onInput" 写自定义方法onInput(event: KeyboardEvent) {...}

v-model简写:

  • 绑定数据:value="xxx"
  • 原生输入事件@input="xxx = $event.target.value" 获取事件对象的目标值
  • 以上两句可以直接简写为 v-model="xxx",无需写method
  • xxx一般命名为value

Notes.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
  <div class="div_notes">
    <label class="notes">
      <span class="name">备注</span>
      <input type="text"
             v-model="inputValue"
             placeholder="在这里输入备注"/>
    </label>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';
import {Component} from 'vue-property-decorator';

@Component
export default class Notes extends Vue {
  inputValue = '';
}
</script>
...

  • v-model就是:value + @change=语法糖,模拟双向绑定

抽出封装Tags.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
<template>
  <div class="tags">
    <ul class="current">
      <li>...</li>
    </ul>
    <div class="new">
      <button>新增标签</button>
    </div>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';
import {Component, Prop} from 'vue-property-decorator';

@Component
export default class Tags extends Vue {
  @Prop(Array) dataSource: string[] | undefined;
  selectedTags: string[] = [];
}
</script>
<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
...
</style>

  • TS 声明 字符串数组 tags: string[] = [] 成员只能是字符串的数组
  • 标签是外部数据传来的 @Prop(Array) tags: string[] = [];
    • 必须加括号 @Prop() tags: string[];
  • 由于是外部数据,可不赋默认值 @Prop(Array) tags: string[] | undefined;

重构外部父组件 Money.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
<template>
  <Layout class-prefix="layout">
    <Tags :data-source="tags"/>
    <Notes/>
    <Types/>
    <Numpad/>
  </Layout>
</template>

<script lang="ts">
import Vue from 'vue';
import Tags from '@/components/Money/Tags.vue';
import Notes from '@/components/Money/Notes.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';
import { Component } from 'vue-property-decorator';

@Component({
  components: {
    Numpad, Types, Notes, Tags
  }
})
export default class Money extends Vue{
  tags= ['衣','食','住','行'];
};
</script>

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

  • 之后tags可以从localStorage读取

重写Tags.vue组件的 template, 指令v-for遍历<li>

  • <li v-for="tag in dataSource" :key="tag">{{ tag }}</li>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<template>
  <div class="tags">
    <ul class="current">
      <li v-for="tag in dataSource" :key="tag">{{tag}}</li>
    </ul>
    <div class="new">
      <button>新增标签</button>
    </div>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';
import {Component, Prop} from 'vue-property-decorator';

@Component
export default class Tags extends Vue {
  @Prop(Array) dataSource: string[] | undefined;
  selectedTags: string[] = [];
}
</script>

添加选中的逻辑和选中样式 Tags.vue

  • 点击选中的逻辑 <li v-for="tag in dataSource" :key="tag" @click="select(tag)">{{ tag }}</li>
  • 点击选中的方法 select(tag: string) {this.selectedTags.push(tag);}
  • 绑定选中时的样式
    • 使用对象的形式: :class="{selected: true | false}"
    • true 表示起效,false 表示无效 填写JS表达式来判断
    • 选中逻辑 :class="{selected: selectedTags.indexOf(tag)>=0}" 选中的标签数据是否包含当前标签名,字符串
  • 给选中的标签加样式&.selected {...} 如果点击选中,则添加样式
  • selecte选中的逻辑改为切换的逻辑toggle,通过点击切换,是否添加 选中的样式
 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
<template>
  <div class="tags">
    <ul class="current">
      <li v-for="tag in dataSource"
          :key="tag"
          @click="toggle(tag)"
          :class="{selected: selectedTags.indexOf(tag)>=0 }">{{ tag }}
      </li>
    </ul>
    <div class="new">
      <button>新增标签</button>
    </div>
  </div>
</template>

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

@Component
export default class Tags extends Vue {
  @Prop(Array) dataSource: string[] | undefined;
  selectedTags: string[] = [];

  toggle(tag: string) {
    const index = this.selectedTags.indexOf(tag);
    if (index >= 0) {
      this.selectedTags.splice(index, 1);
    } else {
      this.selectedTags.push(tag);
    }
  }
}
</script>

<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
.tags {
  display: flex;
  flex-direction: column-reverse;
  flex-grow: 1;
  font-size: 14px;
  padding: 16px;
  > .current {
    order: 1;
    display: flex;
    flex-wrap: wrap;
    > li {
      $h: 24px;
      $bg: #d9d9d9;
      background: $bg;
      height: $h;
      line-height: $h;
      border-radius: ($h/2);
      padding: 0 16px;
      margin-right: 12px;
      margin-top: 4px;
      &.selected {
        background: darken($bg, 50%);
        color: #fff;
        }
      }
    }
  > .new {
    padding-top: 16px;
    button {
      background: transparent;
      border: none;
      border-bottom: 1px solid;
      color: #999;
      padding: 0 4px;
      }
    }
  }
</style>


新增标签的逻辑

  • 点击 新增标签 按钮 绑定点击事件 <button @click="createTag">新增标签</button>
  • 创建标签的方法 createTag(){...}
  • 创建标签后 不能直接操作外部数据
    • 通过发布一个自定义事件来通知外部改变数据
    • TS 在声明外部数据时 添加 readonly 属性
 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
<template>
  <div class="tags">
    <ul class="current">
      <li v-for="tag in dataSource"
          :key="tag"
          @click="toggle(tag)"
          :class="{selected: selectedTags.indexOf(tag)>=0 }">{{ tag }}
      </li>
    </ul>
    <div class="new">
      <button @click="createTag">新增标签</button>
    </div>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';
import {Component, Prop} from 'vue-property-decorator';

@Component
export default class Tags extends Vue {
  @Prop(Array) readonly dataSource: string[] | undefined;
  selectedTags: string[] = [];

  toggle(tag: string) {
    const index = this.selectedTags.indexOf(tag);
    if (index >= 0) {
      this.selectedTags.splice(index, 1);
    } else {
      this.selectedTags.push(tag);
    }
  }

  createTag() {
    const name = window.prompt('请输入标签名');
    if (name !== '' && this.dataSource !== undefined) {
      this.$emit('update:dataSource', [...this.dataSource, name]);
    } else {return;}
  }
}
</script>

  • 通知父组件更改数据 this.$emit('update:dataSource', [...this.dataSource, name]);
  • <Tags :data-source="tags" @update:dataSource="tags = $event"/>
  • .sync语法糖简写 <Tags :data-source.sync="tags"/>

.sync修饰符 Money.vue

  • 子组件触发 this.$emit('undate:dataXxx', [...args])
  • 在父组件中对应标签绑定的属性上添加.sync修饰符 就得到子组件传来的数据[...args]
 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
<template>
  <Layout class-prefix="layout">
    <Tags :data-source.sync="tags"/>
    <Notes/>
    <Types/>
    <Numpad/>
  </Layout>
</template>

<script lang="ts">
import Vue from 'vue';
import Tags from '@/components/Money/Tags.vue';
import Notes from '@/components/Money/Notes.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';
import { Component } from 'vue-property-decorator';

@Component({
  components: {
    Numpad, Types, Notes, Tags
  }
})
export default class Money extends Vue{
  tags = ['衣','食','住','行','理财'];
}
</script>

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


TS 的 prop 类型错误

  • 留了一个坑,不写 @Props(这里的类型),只写冒号后面的类型,@Prop() xxx!: boolean;
  • 这种偷懒的写法会在很后面造成一个 bug。应该写成 @Prop(Boolean) xxx!: boolean;
  • 原因:
    • 左边的 Boolean 是跟 Vuexxx 的类型是 Boolean(运行时类型)
    • 如果不写左边的 Boolean,那么 Vue 就不知道 xxx 的类型是什么了,默认就把 xxx 当作字符串了
    • 因此,当你给 xxx 传值时,Vue 会将其自动转换成字符串。而与我们的预期「xxxboolean」不相符,这就是 bug

收集四个组件的 value

收集最新选中的标签

  • 触发toggle(){...}时,发布数据给父组件Money<Tags/>标签 this.$emit('update:selectedTags', this.selectedTags);

Tags.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ...
toggle(tag: string) {
    const index = this.selectedTags.indexOf(tag);
    if (index >= 0) {
      this.selectedTags.splice(index, 1);
    } else {
      this.selectedTags.push(tag);
    }
    this.$emit('update:selectedTags', this.selectedTags);
}
// ...

修改Money.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
//...
<script lang="ts">
import Vue from 'vue';
import Tags from '@/components/Money/Tags.vue';
import Notes from '@/components/Money/Notes.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';
import {Component} from 'vue-property-decorator';

@Component({
  components: {
    Numpad, Types, Notes, Tags
  }
})
export default class Money extends Vue {
  tags = ['衣', '食', '住', '行', '理财'];

  onUpdate(selectedTags: string[]) {
    console.log(selectedTags);
  }
}
</script>
// ...


分别收集标签的备注信息、类型信息、数额信息(点击ok时)

Money.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
<template>
  <Layout class-prefix="layout">
    <Tags :data-source.sync="tags"
          @update:selectedTags="onUpdate"/>
    <Notes @update:value="onUpdateNotes"/>
    <Types @update:value="onUpdateType"/>
    <Numpad @update:value="onUpdateAmount"/>
  </Layout>
</template>

<script lang="ts">
import Vue from 'vue';
import Tags from '@/components/Money/Tags.vue';
import Notes from '@/components/Money/Notes.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';
import {Component} from 'vue-property-decorator';

@Component({
  components: {
    Numpad, Types, Notes, Tags
  }
})
export default class Money extends Vue {
  tags = ['衣', '食', '住', '行', '理财'];

  onUpdate(selectedTags: string[]) {
    console.log(selectedTags);
  }

  onUpdateNotes(notesValues: string) {
    console.log(notesValues);
  }

  onUpdateType(type: string) {
    console.log(type);
  }

  onUpdateAmount(amount: string) {
    console.log(amount);
  }
}
</script>

  • 备注信息notesValues
  • 类型信息type
  • 数额信息amount

Notes.vue为数据inputValue添加监视属性watch 使用 TS @Watch装饰器

 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
<template>
  <div class="div_notes">
    <label class="notes">
      <span class="name">备注</span>
      <input type="text"
             v-model="inputValue"
             placeholder="在这里输入备注"/>
    </label>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';
import {Component, Watch} from 'vue-property-decorator';

@Component
export default class Notes extends Vue {
  inputValue = '';

  @Watch('inputValue')
  oninputValueChanged(newValue: string, oldValue: string) {
    this.$emit('update:value', newValue)
  }
}
</script>
// ...

  • @Watch('inputValue') 参数中写需要监听的响应式变量
  • 之后紧接一个函数表达式,参数为 newValue 表示新值
    • oldValue: string 没用到,可删

Types.vueselectType()触发时 发布this.$emit('update:value', value)

  • 为避免数据没有变化时重复触发selectType(),使用 TS @Watch装饰器,惰性监听
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
...
<script>
...
import Vue from 'vue';
import {Component, Watch} from 'vue-property-decorator';
...
  // watch
@Watch('type')
onTypeChanged(value: string) {
    this.$emit('update:value', value);
}
</script>
...
  • this.$emit('update:value', value);
    • 注意不带'@update:value'
    • 勿写成this.$emit('@update:value', value);

Numpad.vueconfirmContent()触发时 this.$emit('update:value', this.output)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ...
<script>
// ...
confirmContent() {
    // confirm save to Tags
    this.$emit('update:value', this.output);
    // recover
    this.output = '0';
}
</script>
// ...


Money.vue监听四个组件的变化,并收集传值放到Record类的数据record

  • TS 声明数据类型
1
2
3
4
5
6
type Record = {
    tags: string[];
    notes: string;
    type: string;
    amount: number;
}
  • 再声明数据(复杂类型)并赋初始值record: Record = {tags: [], notes: '', type: '-', amount: 0}
  • 触发对应方法时改变数据
 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
<template>
  <Layout class-prefix="layout">
    <Tags :data-source.sync="tags" @update:selectedTags="onUpdate"/>
    <Notes @update:value="onUpdateNotes"/>
    <Types @update:value="onUpdateType"/>
    <Numpad @update:value="onUpdateAmount"/>
    {{record}}
  </Layout>
</template>

<script lang="ts">
import Vue from 'vue';
import Tags from '@/components/Money/Tags.vue';
import Notes from '@/components/Money/Notes.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';
import {Component} from 'vue-property-decorator';

type Record = {
  tags: string[];
  notes: string;
  type: string;
  amount: number;
}

@Component({
  components: {
    Numpad, Types, Notes, Tags
  }
})
export default class Money extends Vue {
  tags = ['衣', '食', '住', '行', '理财'];
  record: Record = {
    tags: [],
    notes: '',
    type: '-',
    amount: 0
  };

  onUpdate(selectedTags: string[]) {
    this.record.tags = selectedTags;
  }

  onUpdateNotes(notesValue: string) {
    this.record.notes = notesValue;
  }

  onUpdateType(type: string) {
    this.record.type = type;
  }

  onUpdateAmount(amount: string) {
    this.record.amount = parseFloat(amount);
  }
}
</script>
//...


父组件统一传默认值给子组件

  • 子组件的默认数据靠父组件传值而来,而在子组件自己内部赋的默认值是不可靠的。(有可能变更需求时忘记改)
  • Type.vue 组件中的数据type应该是由父组件传入
    • 无需声明 data type = '-'; // '-' 表示支出, '+'表示收入
    • 无需监视 @Watch('type')
  • <Types :value="record.type" @update:value="onUpdateType"/>
    • 简化<Types :value.sync="record.type"/>
  • 其它组件也做同样操作

Numpad.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
...
import Vue from 'vue';
import {Component, Prop} from 'vue-property-decorator';

@Component
export default class Numpad extends Vue {
  @Prop() readonly value!: number;
  output = this.value.toString();

  inputContent(event: MouseEvent) {
    const button = (event.target as HTMLButtonElement);
    const input = button.textContent as string;
    // 显示的数字长短限制
    if (this.output.length >= 11) {
      alert('别做白日梦啦');
      return;
    }
    // '0'开头的逻辑
    if (this.output === '0') {
      if ('0123456789'.indexOf(input) >= 0) {
        this.output = input;
      } else { // '.'的逻辑
        // 按数字位数 拼接 字符串
        this.output += input;
      }
      return;
    }
    // '.'开头的逻辑
    const dotIndex = this.output.indexOf('.');
    if (dotIndex >= 0) {
      // '.'重复判断
      if (input === '.') {return;}
      // '.'限制小数位 2位
      if (this.output.slice(dotIndex, -1).length > 1) {return;}
    }
    this.output += input;
  }

  removeContent() {
    this.output = this.output.slice(0, -1);
    if (this.output === '') {
      this.output = '0';
    }
  }

  clearContent() {
    this.output = '0';
  }

  confirmContent() {
    // confirm save to Tags
    this.$emit('update:value', this.output);
    // recover
    this.output = '0';
  }
}

Types.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
...
import Vue from 'vue';
import {Component, Prop} from 'vue-property-decorator';

@Component
export default class Types extends Vue {
  @Prop() readonly type!: string;

  // method
  selectType(type: string) {
    if (type !== '-' && type !== '+') {
      throw new Error('type is unknown');
    }
    this.$emit('update:type', type);
  }
}
...


使用.sync修饰符 绑定四个组件的 value

Money.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
<template>
  <Layout class-prefix="layout">
    <Tags :data-source.sync="tags"
          @update:selectedTags="onUpdate"/>
    <Notes @update:value="onUpdateNotes"/>
    <Types :type.sync="record.type"/>
    <Numpad :value.sync="record.amount"/>
    {{record}}
  </Layout>
</template>

<script lang="ts">
import Vue from 'vue';
import Tags from '@/components/Money/Tags.vue';
import Notes from '@/components/Money/Notes.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';
import {Component} from 'vue-property-decorator';

type Record = {
  tags: string[];
  notes: string;
  type: string;
  amount: number;
}

@Component({
  components: {
    Numpad, Types, Notes, Tags
  }
})
export default class Money extends Vue {
  tags = ['衣', '食', '住', '行', '理财'];
  record: Record = {
    tags: [],
    notes: '',
    type: '-',
    amount: 0
  };

  onUpdate(selectedTags: string[]) {
    this.record.tags = selectedTags;
  }

  onUpdateNotes(notesValue: string) {
    this.record.notes = notesValue;
  }
}
</script>
...


使用window.localStorage持久化存储四个组件的 value

  • 需要将收集的 record 保存到本地

Numpad.vue添加按下 ok 发布提交数据事件的逻辑

1
2
3
4
5
6
7
8
9
//...
confirmContent() {
    // confirm save to Tags
    this.$emit('update:value', this.output);
    this.$emit('submit', this.output);
    // recover
    this.output = '0';
}
//...

写入localStorage.setItem 修改Money.vue

  • 监听提交事件<Numpad :value.sync="record.amount" @submit="saveRecord"/>
  • 保存记录方法saveRecord(){ this.recordList.push(this.record);}
    • 注意保存数据的操作逻辑统一放到监听数据变化的@Watch
    • 避免多处出现保存数据的操作
    • 好处是只要发现监听的数据变化了,自动触发@Watch的方法
  • @Watch('recordList')监听recordList变化onRecordeChange() {localStorage.setItem('recordList', JSON.stringify(this.recordList))}
 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
<template>
  <Layout class-prefix="layout">
    <Tags :data-source.sync="tags" @update:selectedTags="onUpdate"/>
    <Notes @update:value="onUpdateNotes"/>
    <Types :type.sync="record.type"/>
    <Numpad :value.sync="record.amount" @submit="saveRecord"/>
    {{ record }}
  </Layout>
</template>

<script lang="ts">
import Vue from 'vue';
import Tags from '@/components/Money/Tags.vue';
import Notes from '@/components/Money/Notes.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';
import {Component, Watch} from 'vue-property-decorator';

type Record = {
  tags: string[];
  notes: string;
  type: string;
  amount: number;
}

@Component({
  components: {
    Numpad, Types, Notes, Tags
  }
})
export default class Money extends Vue {
  tags = ['衣', '食', '住', '行', '理财'];
  record: Record = {
    tags: [],
    notes: '',
    type: '-',
    amount: 0
  };
  recordList: Record[] = [];

  onUpdate(selectedTags: string[]) {
    this.record.tags = selectedTags;
  }

  onUpdateNotes(notesValue: string) {
    this.record.notes = notesValue;
  }

  saveRecord() {
    this.recordList.push(this.record);
  }

  @Watch('recordList')
  onRecordeChange() {
    localStorage.setItem('recordList', JSON.stringify(this.recordList));
  }
}
</script>
// ...


数据引用的Bug

  • 生成多条数据后,查看Application里保存的localStorage中保存的数据都一样
  • 保存记录saveRecord(){ this.recordList.push(this.record);}推入数组的是一个内存的地址引用
  • 需要深拷贝数据 JSON.parse(JSON.stringify())
1
2
3
4
5
6
7
//...
saveRecord() {
    const clonedRecord = JSON.parse(JSON.stringify(this.record));
    this.recordList.push(clonedRecord);
}
//...


读取localStorage

  • 默认的recordListwindow.localStorage.getItem('recordList')中读取
  • 注意recordList的类型是Record[] 复杂数组
  • window.localStorage.getItem('recordList')读取的数据是以字符串形式保存的
  • 反序列化JSON.parse()
  • TS 提示 window.localStorage.getItem('recordList')有可能取到的值是null
  • window.localStorage.getItem('xxx')获取一个没有setItemkey就会得到null
  • 解决方法是赋一个空的字符串数组recordList: Record[] = JSON.parse(window.localStorage.getItem('recordList') ?? '[]');
  • recordList: Record[] = JSON.parse(window.localStorage.getItem('recordList') || '[]');

保存数据时再加一个时间戳createdAt: new Date(2020,0,0)

  • TS 声明类型时除了写基本数据类型外,还可以写内置的对象(类/构造函数)
  • type Record = { tags: string[]; notes: string; type: string; amount: number; createdAt: Date | undefined; }
  • type Record = { tags: string[]; notes: string; type: string; amount: number; createdAt?: Date; }

Money.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
<template>
  <Layout class-prefix="layout">
    <Tags :data-source.sync="tags" @update:selectedTags="onUpdate"/>
    <Notes @update:value="onUpdateNotes"/>
    <Types :type.sync="record.type"/>
    <Numpad :value.sync="record.amount" @submit="saveRecord"/>
  </Layout>
</template>

<script lang="ts">
import Vue from 'vue';
import Tags from '@/components/Money/Tags.vue';
import Notes from '@/components/Money/Notes.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';
import {Component, Watch} from 'vue-property-decorator';

type Record = {
  tags: string[];
  notes: string;
  type: string;
  amount: number;
  createdAt?: Date;
}

@Component({
  components: {
    Numpad, Types, Notes, Tags
  }
})
export default class Money extends Vue {
  tags = ['衣', '食', '住', '行', '理财'];
  record: Record = {
    tags: [],
    notes: '',
    type: '-',
    amount: 0,
    createdAt: new Date(),
  };
  recordList: Record[] = JSON.parse(window.localStorage.getItem('recordList') ?? '[]');

  onUpdate(selectedTags: string[]) {
    this.record.tags = selectedTags;
  }

  onUpdateNotes(notesValue: string) {
    this.record.notes = notesValue;
  }

  saveRecord() {
    const clonedRecord: Record = JSON.parse(JSON.stringify(this.record));
    clonedRecord.createdAt = new Date();
    this.recordList.push(clonedRecord);
  }

  @Watch('recordList')
  onRecordeChange() {
    localStorage.setItem('recordList', JSON.stringify(this.recordList));
  }
}
</script>
...


之前的localStorage中未保存时间戳,新加的数据带有时间戳

*数据迁移是什么(数据库版本)

  • localStorage中添加数据库版本
  • window.localStorage.setItem('version', '0.0.1');
  • 数据迁移:数据库升级 补充或消除原先的数据
  • 数据迁移复用,一次只做一个版本的迁移;跨版本只需一次一次地升级
  • 预先规划好数据
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// local data version
const version = window.localStorage.getItem('version') ?? 0;
const recordList: Record[] = JSON.parse(window.localStorage.getItem('recordList') ?? '[]');
// 数据库升级 数据迁移
if (version === '0.0.1' /* 字符串默认比较字典序 */) {
  recordList.forEach(record => {
    record.createdAt = new Date(2020, 0, 1);
  });
  // 保存数据
  localStorage.setItem('recordList', JSON.stringify(recordList));
}
window.localStorage.setItem('version', '0.0.2');


MVC 封装Model 封装数据(库/本地存储)有关的操作

  • 获取数据fetchData() {...}
  • 保存数据saveData() {...}
  • 导出export default model
  • 引用import model from '@/model.js'Money.vue

创建model.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const localStorageKeyName = 'recordList'
const model = {
  fetchData() {
    return JSON.parse(window.localStorage.getItem(localStorageKeyName) ?? '[]')
  },
  saveData(data) {
    localStorage.setItem(localStorageKeyName, JSON.stringify(data))
  }
}
// export default model
export {model}

Money.vue引入model.js

1
2
3
4
//...
const model = require('@/model.js').model;
console.log(model);
//...
  • TS 中不能用import直接引入 JS 的模块 比如import model from '@/model.js';
  • 使用require('xxx').default语法引入model.js默认导出的export default modelconst model = require('@/model.js').model;
  • 或者在model.jsexport {model}
    • Money.vuerequire('xxx').model
  • 或用解构const {model} = require('@/model.js').model
    • Money.vuerequire('xxx')
  • 缺点是导入的JS模块是没有任何类型信息的

Money.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
<template>
  <Layout class-prefix="layout">
    <Tags :data-source.sync="tags" @update:selectedTags="onUpdate"/>
    <Notes @update:value="onUpdateNotes"/>
    <Types :type.sync="record.type"/>
    <Numpad :value.sync="record.amount" @submit="saveRecord"/>
    {{ recordList }}
  </Layout>
</template>

<script lang="ts">
import Vue from 'vue';
import Tags from '@/components/Money/Tags.vue';
import Notes from '@/components/Money/Notes.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';
import {Component, Watch} from 'vue-property-decorator';
// import model from '@/model.js'; // TS 中不能用`import`直接引入 JS 的模块
// const model = require('@/model.js').model;
const {model} = require('@/model.js');

// 局部声明
type Record = {
  tags: string[]; notes: string; type: string; amount: number; createdAt?: Date;
}

@Component({
  components: {
    Numpad, Types, Notes, Tags
  }
})

export default class Money extends Vue {
  tags = ['衣', '食', '住', '行', '理财'];
  record: Record = {
    tags: [], notes: '', type: '-', amount: 0, createdAt: new Date(),
  };
  
  // recordList: Record[] = JSON.parse(window.localStorage.getItem('recordList') ?? '[]');
  recordList: Record[] = model.fetchData();

  onUpdate(selectedTags: string[]) {
    this.record.tags = selectedTags;
  }

  onUpdateNotes(notesValue: string) {
    this.record.notes = notesValue;
  }

  saveRecord() {
    const clonedRecord: Record = JSON.parse(JSON.stringify(this.record));
    clonedRecord.createdAt = new Date();
    this.recordList.push(clonedRecord);
  }

  @Watch('recordList')
  onRecordeChange() {
    model.saveData(this.recordList);
  }
}
</script>
...


改为model.ts

  • 全局声明类型时避免类型冲突 Record –> RecordItem
  • 新建TS全局声明文件custom.d.ts,文件名任意,只需注意后缀*.d.ts

custom.d.ts

1
2
3
4
5
6
7
8
type RecordItem = {
  tags: string[];
  notes: string;
  type: string;
  amount: number;
  createdAt?: Date;
}

  • model.js文件名改写为model.ts
  • 可识别 TS 文件的默认导出import {model} from '@/model.ts';
  • 声明形参的类型saveData(data: RecordItem[]) {...}
  • 断言返回值的类型 return JSON.parse(window.localStorage.getItem(localStorageKeyName) ?? '[]') as RecordItem[];
  • 封装深拷贝clone方法

mdoel.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const localStorageKeyName = 'recordList';
const model = {
  fetchData() {
    return JSON.parse(window.localStorage.getItem(localStorageKeyName) ?? '[]') as RecordItem[];
  },
  saveData(data: RecordItem[]) {
    localStorage.setItem(localStorageKeyName, JSON.stringify(data));
  }
};
// export default model
// export {model}
export default model;

  • 删除Money.vue中重复的类型声明recordList: RecordItem[] = model.fetchData();
    • model.fetchData()已经在model.ts中声明返回值的类型,无需重复声明recordList: RecordItem[]
  • 在声明时提前写好类型断言,调用时就可以不写, TS 自动推测

mdoel.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const localStorageKeyName = 'recordList';
const recordListModel = {
  clone(data: RecordItem | RecordItem[]) {
    return JSON.parse(JSON.stringify(data)) as RecordItem;
  },
  fetchData() {
    return JSON.parse(window.localStorage.getItem(localStorageKeyName) ?? '[]') as RecordItem[];
  },
  saveData(data: RecordItem[]) {
    localStorage.setItem(localStorageKeyName, JSON.stringify(data));
  }
};
// export default model
// export {model}
export default recordListModel;

  • 导入import {model} from '@/model.ts'; // export {model}
  • 或改成import recordListModel from '@/mdoel.ts'; // export default model

重构Money.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
<template>
  <Layout class-prefix="layout">
    <Tags :data-source.sync="tags" @update:selectedTags="onUpdate"/>
    <Notes @update:value="onUpdateNotes"/>
    <Types :type.sync="record.type"/>
    <Numpad :value.sync="record.amount" @submit="saveRecord"/>
  </Layout>
</template>

<script lang="ts">
import Vue from 'vue';
import Tags from '@/components/Money/Tags.vue';
import Notes from '@/components/Money/Notes.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';
import {Component, Watch} from 'vue-property-decorator';
// import model from '@/model.js'; // TS 中不能用`import`直接引入 JS 的模块
// const model = require('@/model.js').model;
// const {model} = require('@/model.ts');
// import {model} from '@/model.ts'; // export {model}
import recordListModel from '@/mdoel.ts';

@Component({
  components: {
    Numpad, Types, Notes, Tags
  }
})

export default class Money extends Vue {
  tags = ['衣', '食', '住', '行', '理财'];
  record: RecordItem = {
    tags: [],
    notes: '',
    type: '-',
    amount: 0,
    createdAt: new Date(),
  };
  
  // recordList: Record[] = JSON.parse(window.localStorage.getItem('recordList') ?? '[]');
  // recordList: RecordItem[] = model.fetchData();
  recordList = recordListModel.fetchData();

  onUpdate(selectedTags: string[]) {
    this.record.tags = selectedTags;
  }

  onUpdateNotes(notesValue: string) {
    this.record.notes = notesValue;
  }

  saveRecord() {
    const clonedRecord = recordListModel.clone(this.record);
    clonedRecord.createdAt = new Date();
    this.recordList.push(clonedRecord);
  }

  @Watch('recordList')
  onRecordeChange() {
    recordListModel.saveData(this.recordList);
  }
}
</script>

<style lang="scss">
.layout-content {
  display: flex;
  flex-direction: column;
  }
</style>


一些bug修正与代码重构

成功 按条件 给不同的事件类型(click | touchstart) 动态绑定 一个 按条件选出的事件

  • @[clientEvent]="handleButtonFn($event, item.bundleEvent)"
  • 显示地传参事件对象$event
  • :name="item.id" 'delete' | 'clear' | 'ok'
  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
<template>
  <div class="numpad">
    <div class="output">{{ output || '0' }}</div>
    <div class="buttons" @mousemove="showSearchlight" :style="searchlightStyle">
      <button v-for="(item, index) in numPadText"
              :data-index="index"
              :key="item.id"
              @[clientEvent]="handleButtonFn($event, item.bundleEvent)"
              :class="{ok: item.id === 'ok', zero: item.id === 'zero' }">{{ item.text }}
        <Icon v-if="item.name !== 'num'" :name="item.id"/>
      </button>
    </div>
  </div>
</template>

<script lang="ts">
  ...
  eventName = 'click';
  output = this.amount.toString();

// 按条件 给不同的事件类型(click | touchstart)
  get clientEvent() {
    if (document.documentElement.clientWidth > 500) {
      this.eventName = 'click';
    } else {
      this.eventName = 'touchstart';
    }
    return this.eventName;
  }

  numPadText = [
    {id: '1', text: '1', name: 'num', bundleEvent: 'inputNum'},
    {id: '2', text: '2', name: 'num', bundleEvent: 'inputNum'},
    {id: '3', text: '3', name: 'num', bundleEvent: 'inputNum'},
    {id: 'delete', text: '', name: 'delete', bundleEvent: 'removeNum'},
    {id: '4', text: '4', name: 'num', bundleEvent: 'inputNum'},
    {id: '5', text: '5', name: 'num', bundleEvent: 'inputNum'},
    {id: '6', text: '6', name: 'num', bundleEvent: 'inputNum'},
    {id: 'clear', text: '', name: 'clear', bundleEvent: 'clearNum'},
    {id: '7', text: '7', name: 'num', bundleEvent: 'inputNum'},
    {id: '8', text: '8', name: 'num', bundleEvent: 'inputNum'},
    {id: '9', text: '9', name: 'num', bundleEvent: 'inputNum'},
    {id: 'ok', text: '', name: 'ok', bundleEvent: 'confirmNum'},
    {id: 'zero', text: '0', name: 'num', bundleEvent: 'inputNum'},
    {id: 'dot', text: '.', name: 'dot', bundleEvent: 'inputNum'},
  ];

  handleButtonFn(e, bundleEvent) {
    const fn = bundleEvent;
    this[fn](e);
  }

  inputNum(event: TouchEvent) {
    const button = (event.target as HTMLButtonElement);
    const input = button.textContent.trim() as string;
    // '0'开头的逻辑
    if (this.output === '0') {
      if ('0123456789'.indexOf(input) >= 0) {
        return this.output = input;
      } else {
        // '.'的逻辑 // 按数字位数 拼接 字符串
        return this.output += input;
      }
    }

    // '.'开头的逻辑
    const dotIndex = this.output.indexOf('.');
    if (dotIndex >= 0) {
      // '.'重复判断
      if (input === '.') {return;}
      // '.'限制小数位 2位
      if (this.output.slice(dotIndex, -1).length > 1) {return;}
    }
    // 限制显示数字长度
    if (this.output.length >= 15) {
      alert('别做白日梦啦');
      this.removeNum(event, -3);
      return;
    }
    this.output += input;
  }

  removeNum(event: TouchEvent, number = -1) {
    this.output = this.output.slice(0, number);
    if (this.output === '') {
      this.clearNum();
    }
    return;
  }

  clearNum() {
    return this.output = '0';
  }

  confirmNum() {
    const number = parseFloat(this.output);
    if (number === 0) {
      this.$emit('checkZero');
      return;
    }
    this.$emit('update:amount', number);
    this.$emit('submit');
    this.$nextTick(() => {
      this.reset();
    });
  }
  ...
}
</script>

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


内置事件类型名注意全小写mousemove touchstart

  • 通过浏览器开发工具>事件侦听器查看具体的元素上绑定的事件名是否写错

实现仿win10计算器和日历的探照灯效果

  • getParent() 找父节点
  • showSearchlight() 显示探照灯
  • background: radial-gradient(circle at var(--x-pos) var(--y-pos), #aaa, transparent 100px);
 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
<template>
  <div class="numpad">
    <div class="output">{{ output || '0' }}</div>
    <div class="buttons" @mousemove="showSearchlight">
      <button v-for="(item, index) in numPadText.numPadText123"
              :data-index="index"
              :key="item.id"
              @[clientEvent]="inputNum">
        {{ item.text }}
        <Icon v-if="item.name !== 'num'"
              name='delete'/>
      </button>
      ...
      ...
    </div>
  </div>
</template>

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

@Component
export default class Numpad extends Vue {
    ...
  // TODO 待优化重构  不用 DOM 操作
  getParent(curEl: HTMLButtonElement, parentEl: HTMLElement) {
    while (curEl !== parentEl) {
      curEl = curEl.parentElement as HTMLButtonElement;
    }
    return curEl;
  }

  showSearchlight(e: MouseEvent) {
    let elem = e.target as HTMLButtonElement;
    const wrapper = document.querySelector('.buttons');
    if (e.target && elem !== wrapper) {
      elem = this.getParent(e.target as HTMLButtonElement, wrapper as HTMLElement);
    }
    const x = e.clientX - elem.offsetLeft;
    const y = e.clientY - elem.offsetTop;

    document.documentElement.style.setProperty('--x-pos', x + 'px');
    document.documentElement.style.setProperty('--y-pos', y + 'px');
  }
}
</script>


<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
.numpad {
  .output {
    ...
    }
  .buttons {
    ...
    &:hover {
      background: radial-gradient(circle at var(--x-pos) var(--y-pos), #aaa, transparent 100px);
      }
    > button {
      ...
      > .icon {
        ...
        }
      &:hover {
        border-color: #ccc;
        }
      &:last-child {
        grid-column: 2/4;
        grid-row: 4/5;
        }
      &.ok {
        grid-column: 4/5;
        grid-row: 3/5;
        }
      &.zero {
        grid-column: 1/2;
        grid-row: 4/5;
        }
      $bg: #f2f2f2;
      &:nth-child(1) {
        background: $bg;
        }
      ...
      }
    }
  }
</style>

重构searchlight逻辑 不进行DOM操作

  • 绑定节点的style属性
  • 设置变量var(--x),记录鼠标位置
  • 根据鼠标位置设置background: radial-gradient();的样式
 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
// 重构searchlight逻辑  不用 DOM 操作
  getParent(curEl: HTMLButtonElement, parentEl: HTMLElement) {
    while (curEl !== parentEl) {
      curEl = curEl.parentElement as HTMLButtonElement;
    }
    return curEl;
  }
  searchlightPosition = {
    x: 0,
    y: 0
  }
  get searchlightStyle() {
    return {
      '--x-pos': this.searchlightPosition.x + 'px',
      '--y-pos': this.searchlightPosition.y + 'px',
    }
  }
  showSearchlight(e: MouseEvent) {
    let elem = e.target as HTMLButtonElement;
    const wrapper = document.querySelector('.buttons');
    if (e.target && elem !== wrapper) {
      elem = this.getParent(e.target as HTMLButtonElement, wrapper as HTMLElement);
    }
    this.searchlightPosition.x = e.clientX - elem.offsetLeft;
    this.searchlightPosition.y = e.clientY - elem.offsetTop;
  }

使用.sync

  • <FormItem ... @update:inputValue="onUpdateTips" :inputValue="record.tips"/>改为:
    • <FormItem ... :inputValue.sync="record.tips"/>
    • 原来方法Mehods中的onUpdateTips(value: string) {this.record.tips = value;} 可以删除

成功提交一个记录后,初始化:

  • 标签tags初始化:取消选中标签 this.selectedTags = [];
  • 备注tips初始化:清空备注栏 this.record.tips = '';
  • 数字盘输出初始化:清空数字输出 this.output = '0';
  • Tab种类无需初始化

标签tags初始化 逻辑

  • 点击OK 成功提交一个记录
  • Numpad.vue -> click Button OK -> confirmNum() -> this.$emit
  • ('update:deselectTags', true) -> Money.vue
  • Money.vue -> <Numpad @update:deselectTags="deselectTags" /> -> deselectTags(){emptyTags = []}
1
2
3
4
5
6
7
8
/*
*   点击OK 成功提交一个记录
*   Numpad.vue -> click Button OK -> confirmNum() -> this.$emit('update:deselectTags', true) -> Money.vue
*   Money.vue  -> <Numpad @update:deselectTags="deselectTags" /> -> deselectTags(){emptyTags = []}
*   <Tags :is-deselect-tags="emptyTags"/>
*   Tags.vue   -> @Prop(Boolean) isDeselectTags!: boolean;
*              -> @Watch('isDeselectTags');deselectTag() {...this.selectedTags = [];}
* */

单一功能原则

  • 添加方法 submit() 其中包含单一化逻辑:
  • checkoutRecord()
  • alertInform()
  • saveRecord()
  • reset()

统一 成功提交一个记录后,初始化的逻辑

  • Numpad.vue控制 按下OK 按钮
    • 通知父组件Money.vue
      • const number = parseFloat(this.output); this.$emit('update:amount', number); 传值给父组件
      • this.$emit('submit'); 通知父组件Money.vue执行自定义事件@submit="submit"
      • Money.vue 开始检查逻辑 checkoutRecord()
        • 初始let checkoutResult = true;
        • 未选泽标签逻辑 if (!this.record.tags || this.record.tags.length === 0) checkoutResult = false;
        • 已选好标签:return checkoutResult;
        • checkoutResult值传回给Numpad.vue
      • Numpad.vue 开始在下次更新后,执行重置逻辑
        • this.$nextTick(() => {this.reset();});
        • reset()判断if (this.isReset)
          • true: this.output = '0'; this.$emit('update:deselectTags', true); 重置标签
          • false: this.$emit('update:deselectTags', false); 不清零,不重置标签
      • Money.vue 开始重置逻辑
    • 数字盘输出初始化:清空数字输出 this.output = '0';
  • Money.vue控制
    • 是否成功提交一个记录checkoutRecord()
    • 保存记录saveRecord()
    • 重置各子组件reset()
      • 重置Tags.vuethis.selectedTags = [];
      • 重置FormItem.vuethis.record.tips = '';
      • 重置Numpad.vue:``
      • 重置Tabs.vue:``
  • Tags.vue控制 标签tags初始化:取消选中标签 this.selectedTags = [];
  • FormItem.vue控制 备注tips初始化:清空备注栏 this.record.tips = '';
  • Tabs.vue控制 Tab种类无需初始化

使用事件代理实现监听 子组件事件 改变样式

  • 绑定样式 :class="['basic-btn', {'current': buttonIndex === curIndex}]"
  • 设置生效时的样式 .current { background: red; }
  • 绑定事件方法 @click="selectBtn(buttonIndex)"
  • 定义方法发布通知给父组件 this.$emit('selectBtn', buttonIndex)

在Vue中改用事件代理

  • 父组件 通过 事件冒泡机制 代理子组件的点击事件事件
    • 需要传入 事件对象 $event
    • 在方法参数中 接收事件对象 参数 e: Event
    • 解构 事件对象目标(事件原对象)target = e.target
  • 去除子组件中的 点击事件监听及对应方法
  • 子组件中 加上 HTML5 的自定义属性 :data-index="buttonIndex"
    • 在父组件中获取dataset属性:target.dataset.index;
    • 注意取到的属性值为 字符串类型,在这里需要转换为 数字类型 parseInt(target.dataset.index, 10)

注意 HTML5 的自定义属性 HTMLElement.dataset

  • 在标签中设置的 属性data-xxx-xxx 只可以包含 字母,数字 和下面的字符:
    • dash (-), dot (.), colon (:), underscore (_)
  • 此外不应包含 ASCII 码大写字母 形成的驼峰式命名
  • 比如 <numpad-button :data-bundle-event="item.bundleEvent">
  • HTML5自定义属性前缀 data-xxx
    • 解释:HTML规定可以为元素添加非标准的属性,但要添加前缀 data-
    • 目的:为元素提供与 渲染无关 的信息,或者语义信息。
    • 属性可以任意添加和命名,只要以 data- 开头
    • 访问:添加自定义属性之后,可以通过元素的 dataset 属性来访问自定义的值。

使用 js 操作dataset注意:

  • 在添加或读取属性的时候需要去掉前缀 data-
  • 如果属性名称中还包含连字符(-),需要转成 驼峰命名 方式
  • 但如果在CSS中使用选择器,需要使用 连字符格式

修改事件代理 辨别子组件内部元素是否为 目标元素e.target

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
//...
  handleButtonFn(e: TapEvent) {
    let target = e.target as HTMLElement;
    while (!target!.matches('button')) {
      // 向外寻找父节点
      target = target.parentNode as HTMLElement;
    }
    const bundleEvent = target!.dataset.bundleEvent;
    this[bundleEvent as BundleEventString](e);
  }
//...

事件代理小结

  • 子组件利用 HTML5的自定义属性 dataset 可以传递数据给父组件
  • 特别是当子组件是通过v-for循环渲染形成的,且需要事件代理

Numpad.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
<script lang="ts">
import {Component} from 'vue-property-decorator';
import {mixins} from 'vue-class-component';
import NumpadButton from '@/components/Money/numpad/NumpadButton.vue';
import SearchLight from '@/mixins/searchLight.ts';
import OperateNumpad from '@/mixins/operateNumpad.ts';
import NumpadOutput from '@/components/Money/numpad/NumpadOutput.vue';
import getClientType from '@/lib/getClient.ts';

@Component({
  components: {NumpadOutput, NumpadButton}
})
export default class Numpad extends mixins(SearchLight, OperateNumpad) {
  // 默认当前初始下标
  currentIndex = -1;
  // 默认绑定事件
  eventName = 'click';
  // 客户端设备信息
  clientType = getClientType();
  // 数字键盘文字图标数据 0~9 delete ok clear
  numPadText = [
    {id: '1', text: '1', name: 'num', bundleEvent: 'inputNum'},
    {id: '2', text: '2', name: 'num', bundleEvent: 'inputNum'},
    {id: '3', text: '3', name: 'num', bundleEvent: 'inputNum'},
    {id: 'delete', text: '', name: 'delete', bundleEvent: 'removeNum'},
    {id: '4', text: '4', name: 'num', bundleEvent: 'inputNum'},
    {id: '5', text: '5', name: 'num', bundleEvent: 'inputNum'},
    {id: '6', text: '6', name: 'num', bundleEvent: 'inputNum'},
    {id: 'clear', text: '', name: 'clear', bundleEvent: 'clearNum'},
    {id: '7', text: '7', name: 'num', bundleEvent: 'inputNum'},
    {id: '8', text: '8', name: 'num', bundleEvent: 'inputNum'},
    {id: '9', text: '9', name: 'num', bundleEvent: 'inputNum'},
    {id: 'ok', text: '', name: 'ok', bundleEvent: 'confirmNum'},
    {id: 'zero', text: '0', name: 'num', bundleEvent: 'inputNum'},
    {id: 'dot', text: '.', name: 'dot', bundleEvent: 'inputNum'},
  ];

  // 返回当前选中按钮的下标
  markButton(e: UIEvent) {
    const target = e.target as HTMLElement;
    const className = target.className;
    const index = parseInt(target.dataset.index || '-1', 10);
    if (index !== -1) {
      if (className === 'basic-btn' || className.includes('zero')) {
        this.currentIndex = index;
      }
    }
  }

  // 判断客户端尺寸 返回对应的事件类型
  get clientEvent(): string {
    if (this.clientType === 'PC') {
      this.eventName = 'click';
    } else if (this.clientType === 'mobile') {
      this.eventName = 'touchstart';
    }
    return this.eventName;
  }

  // 事件代理 给不同的按钮绑定对应事件的处理函数
  handleButtonFn(e: TapEvent) {
    let target = e.target as HTMLElement;
    // 获取 按钮 节点
    while (!target.matches('button')) {
      // 非按钮  则获取 父节点
      target = target.parentNode as HTMLElement;
    }
    const bundleEvent = target.dataset.bundleEvent;
    this[bundleEvent as BundleEventString](e);
  }

  getSessionOutput(output: string) {
    this.output = output.toString();
  }

}
</script>

<template>
  <section class="numpad">
    <numpad-output :output="output"
                   @update:output="getSessionOutput"/>
    <div class="buttons"
         @[clientEvent]="markButton($event); handleButtonFn($event)"
         @mousemove="showSearchlight"
         :style="searchlightCoordinate">
      <numpad-button
        v-for="(item, index) in numPadText"
        :data-bundle-event="item.bundleEvent"
        :button-index="index"
        :currentIndex="currentIndex"
        :key="item.id"
        :class="{ok: item.id === 'ok', zero: item.id === 'zero'}"
        :button-text="item.text">
        <Icon v-if="['num', 'dot'].indexOf(item.name) === -1"
              :name="item.id"/>
      </numpad-button>
    </div>
  </section>
</template>

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

.numpad {
  .output {
    @extend %inner-shadow;
    font-size: 36px;
    font-family: Consolas, monospace;
    padding: 9px 16px;
    text-align: right;
  }

  .buttons {
    display: grid;
    grid-template: repeat(4, 1fr) / repeat(4, 1fr);
    padding: 1px;
    grid-gap: 1px;
    height: 256px;
    overflow: hidden;
    justify-items: stretch;
    align-items: stretch;

    &:hover {
      background: radial-gradient(circle at var(--x-pos) var(--y-pos), #bbb, transparent 100px);
    }
  }
}
</style>


小结

  • 代码仓库
  • .sync 怎么用
  • TS 以及装饰器怎么用
  • window.localStorage 怎么用
  • 数据迁移是什么
  • 存储的标签是写死在数据中的tags = ['衣', '食', '住', '行', '理财']; 怎么用Model来存储所有Model相关数据
  • 仓库
  • 所有 commits(倒序)

保存选中状态可用<keep-alive></keep-alive>