vue.js章鱼记账小结

270 阅读5分钟

技术栈:Vue2+TypeScript+Vue Router+Vuex

1. 底部导航

1.1 用哈希模式确定每个页面的路径

默认进入#/money,其余路径#/money记账,#/labels标签,#/statistics统计404页面

$ git reset --hard Hard    //强制清楚项目文件

1.2 router/index.ts添加router,配置4个路径对应组件

//router/index.ts
import Vue from 'vue'
import VueRouter from 'vue-router'
import Money from '@/views/Money.vue'
import Labels from '@/views/Labels.vue'
import Statistics from '@/views/Statistics.vue'

Vue.use(VueRouter);

const routes = [
  {
     path:'/',            //根目录
     component:'/money'   //重定向到money
  },
  {
     path:'/money',
     component:Money
  },
  {
     path:'/labels',
     component:Labels
  },
  {
     path:'/statistics',
     component:Statistics
  },
  {
     path:'*',    //其他的所有路径
     component:NotFound
  }
];

const router = new VueRouter( {routes} );

export default router;

1.3 初始化组件

  • main.ts,入口文件,最先执行的文件,用来渲染app
//main.ts

import Vue from 'vue'
import App from '/App.vue'
import './registerServiceWorker'
import router from './router'  //省略写法router/index.ts
import store from './store'

Vue.config.productionTip = false

new Vue({
    router,
    store,
    render: h => h(App)
}).$mount('#app')
  • App.vue 唯一的根组件,展示所有组件
//app.vue
<template>
  <div>
    <router-view/>   //指定路径对应的组件显示在页面中
    <hr/>
    <div>
      <router-link to="/money">记账</router-link>  
      |
      <router-link to="/labels">标签</router-link> 
      |
      <router-link to="/statistics">统计</router-link>
    </div>
  </div>
</template>
...

1.4 将Nav组件做成全局组件

  • 上步中,把导航栏直接写在app.vue中是不严谨的,因为有的页面(比如404页面)不需要导航栏,这就需要单独封装为一个组件,谁用谁引入。
//Nav.vue
<template>
    <div class="nav">
      <router-link to="/money">记账</router-link>  
      |
      <router-link to="/labels">标签</router-link> 
      |
      <router-link to="/statistics">统计</router-link>
    </div>
</template>
  • 把Nav组件放在需要展示它的组件中
//Money.vue
<template>
    <div>Money.vue
      <Nav />    //按Nav,提示出现时按Tab键,可自动引入
    </div>
</template>

<script lang="ts">
  import Nav from '@/conponents/Nav.vue';   //全局变量Nav时,可省略
  export default {
      name:'Money',
      components:{Nav},   //全局变量Nav时,可省略
  };
  • 全局引入Nav组件,这样在Money.vue中引入Nav就不需要单独引入了
...
Vue.component('Nav',Nav)
...
  • 写404页面组件,就不需要<Nav>组件
//NotFound.vue
<template>
    <div>
     <div>当前页面不存在,请检查网址是否正确</div>
     <div>
         <router-link to="/">返回首页</router-link>
     </div>
    </div>
</template>

1.5 继续优化<Nav>组件

//Nav.vue
<template>
    <div class="nav">
      <router-link to="/money">记账</router-link>  
      |
      <router-link to="/labels">标签</router-link> 
      |
      <router-link to="/statistics">统计</router-link>
    </div>
</template>

//scoped 精准添加样式,只会影响当前结构层template里的内容
<style lang="scss" scoped>
    .nav{ }
</style>

1.6 添加样式

//Money.vue
<template>
    <div class="nav-wrapper">
      <div class="content">
        Money.vue
      </div>
      <Nav />    
    </div>
</template>

<script lang="ts">
   export default {
      name:'Money',
  };
  
<style lang="scss" scoped>
    .nav-wrapper{
        display: flex;       //常用flex一维布局(container,item)
        flex-direction: column;        //竖向展示
        height: 100vh;      //app上的body默认有一个margin
    }
    .content{
        overflow: auto;    //保证屏幕完整显示内容,多余的部分滚动条控制
        flex-grow:1;       //设置该元素为占绝大部分空间(如何长胖)
    }
</style>
  • 重置App中的body里面的margin
//app.vue
<template>
  <div>
    <router-view/>   //指定路径对应的组件显示在页面中
  </div>
</template>

<style lang="scss">
  *{               // *所有元素
      margin: 0; padding: 0;
      box-sizing: border-box;
  }
  • 解决CSS在三个组件中重复使用的问题,新建Layout组件,容纳共用的部分,并把Layout做成全局组件。
//main.ts  把`Layout`做成全局组件

import Vue from 'vue';
import App from '/App.vue';
import './registerServiceWorker';
import router from './router';  //省略写法router/index.ts
import store from './store';
import Nav from '@/components/Nav.vue';     //@是src文件夹简写
import Layout from '@/components/Layout.vue';

Vue.config.productionTip = false;

Vue.component('Nav', Nav);
Vue.component('Layout', Layout);

new Vue({
    router,
    store,
    render: h => h(App)
}).$mount('#app')
//Layout.vue
<template>
    <div class="nav-wrapper">
      <div class="content">
        <slot />    //占位符
      </div>
      <Nav />    
    </div>
</template>

<script lang="ts">
   export default {
       name:'Layout',
   }
<script>

<style lang="scss" scoped>
    .nav-wrapper{
        display: flex;       //常用flex一维布局(container,item)
        flex-direction: column;        //竖向展示
        height: 100vh;      //app上的body默认有一个margin
    }
    .content{
        overflow: auto;    //保证屏幕完整显示内容,多余的部分滚动条控制
        flex-grow:1;       //设置该元素为占绝大部分空间(如何长胖)
    }
</style>

1.7 slot插槽

  • 渲染过程是:进入Money.vue组件,首先看到Layout标签,就先渲染Layout.vue组件的内容,然后看到<slot />插槽,此时就把Money.vue组件结构层中的<p>Money.vue</p> 替换掉<slot />
//Money.vue
<template>
  <div>
    <Layout>
      <p>Money.vue</p>        
    </Layout>
  </div>
</template>

以上步骤实现了把重复的内容封装到组件中,不重复的内容通过插槽替换掉

1.8 引入icon图标(使用svg-sprite-loader)

SVG文件的本质就是XML文件

//Nav.vue
<script lang="ts">
    import x from '@/assets/icons/label.svg';
  • 上面方法无法引入svg文件,搜索 typescript svg cannot

011.png

//custom.d.ts
declare module "*.svg" { 
  const content:string; 
  export default content; 
}
  • 安装svg-sprite-loader
$ yarn add --dev svg-sprite-loader -D
  • 添加配置
//vue.config.js
const path = require('path')        //先引入一个path的nodejs模块
module.exports = {
    lintOnSave: false,
    chainWebpack: config => {
        const dir = path.resolve(__dirname, 'src/assets/icons') //确定icon所在的目录
        config.module       //jQuery的链式操作
            .rule('svg-sprite')
            .test(/.svg$/)              //正则
            .include.add(dir).end()     //只包含icons目录  
            .use('svg-sprite-loader').loader('svg-sprite-loader').options({ extract: false }).end()
              //extract:false是不需要解析一个文件出来
           //  这时候不用 .use('svgo-loader').loader('svgo-loader')
           //  这时候不用 .tap(options => ({...options, plugins: [{ removeAttrs: { attrs: 'fill' } }] })).end()
        config.plugin('svg-sprite').use(require('svg-sprite-loader/plugin'), [{ plainSprite: true }])
        config.module.rule('svg').exclude.add(dir) //其他SVG loader排除icons目录
    }
}
  • <Nav>导航栏可以正常引入use标签
//Nav.vue
<template>
    <div class="nav">
      <router-link to="/money">
      <svg>
        <use xlink:href="#label"/>
      </svg>
      记账
      </router-link>  
      |
      <router-link to="/labels">标签</router-link> 
      |
      <router-link to="/statistics">统计</router-link>
    </div>
</template>

<script lang="ts">
  import x from '@/assets/icons/label.svg';
</script>
  • 引入icon文件目录

加入通用的svg样式:iconfont-帮助-代码应用-搜索svg-symbol引入

.icon {       
  width: 1em;
  height: 1em; 
  vertical-align: -0.15em; 
  fill: currentColor; 
  overflow: hidden; 
  }
//Icon.vue
<template>
<svg class="icon">
    <use :xlink:href=" '#'+name"></use>  //搜索vue bind xlinkhref
</svg>
</template>

<script lang="ts">
  const importAll = (requireContext: __WebpackModuleApi.RequireContext) =>     requireContext.keys().forEach(requireContext);   //import一个目录
  try {
    importAll(require.context('../assets/icons', true, /.svg$/));
} catch (error) {
    console.log(error);
}
  export default {
    name: 'Nav'
  };
</script>

<style lang="scss" scoped>
 .icon {       
   width: 1em;
   height: 1em; 
   vertical-align: -0.15em; 
   fill: currentColor; 
   overflow: hidden; 
 }
</style>
  • 封装Icon组件
//Icon.vue
<template>
      <svg>
        <use xlink:href="#label"/>
      </svg>
</template>

<script lang="ts">
  const importAll = (requireContext: __WebpackModuleApi.RequireContext) =>     requireContext.keys().forEach(requireContext);   //import一个目录
  try {importAll(require.context('../assets/icons', true, /.svg$/));} catch (error) {console.log(error)}
  export default {
    props:['name'],
    name: 'Icon'
  };
</script>
  • Icon组件做成全局变量
//main.js

Vue.component('Icon',Icon);
  • 其他组件引入Icon
//Nav.vue
<template>
    <div class="nav">
      <router-link to="/money">
        <Icon name="money"/>
      记账
      </router-link>  
      |
      <router-link to="/labels">
        <Icon name="label"/>
      标签</router-link> 
      |
      <router-link to="/statistics">
        <Icon name="statistics"/>
      统计</router-link>
    </div>
</template>

<script lang="ts">
  import x from '@/assets/icons/label.svg';
</script>
  • 修改Nav样式
//Nav.vue
<template>
    <nav>
        <router-link to="/money" class="item" active-class="selected">
            <Icon name="money"/>
            记账
        </router-link>
        <router-link to="/labels" class="item" active-class="selected">
            <Icon name="labels"/>
            标签
        </router-link>
        <router-link to="/statistics" class="item" active-class="selected">
            <Icon name="statistics"/>
            统计
        </router-link>
    </nav>
</template>

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

<style lang="scss" scoped> 
@import "~@/assets/style/helper.scss";
nav {
    display: flex;
    box-shadow: 0 0 3px rgba(0,0,0,0.25);
    flex-direction: row;
    font-size: 12px;
    > .item {
        padding: 2px 0;
       width: 33.3333%;
        display: flex;
        justify-content: center;
        align-items: center;
        flex-direction: column;
        .icon {
            width: 32px;
            height: 32px;
        }
    }
    > .item.selected{
        color: $color-highlight;
    }
}
</style>
  • 更新meta viewport
// public/index.html
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no,viewport-fit=cover">

2. Money.vue组件实现

2.1 结构层<template>实现

<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="numberPad">
       <div class="output">100</div>
       <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>
  </Layout>
<template>  

2.2 样式层<style>实现

整体思路

  1. reset
  2. 全局字体行高
  3. 变量
  4. 局部
  • 把所有不想用的默认样式去掉
//src-assets-style-reset.scss
*{
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
a{
  text-decoration: none;  //去掉图标下方文字的下划线
  color: inherit;
}
ul, ol{
  list-style: none;
}
button, input{
  font: inherit;
}
:focus{
  outline: none;
}
h1,h2,h3,h4,h5,h6{      //取消默认加粗
  font-weight: normal;
}
//src-assets-style-helper.scss
$font-hei:-apple-system, "Helvetica Neue", Helvetica, "Nimbus Sans L", Arial, "Liberation Sans", "PingFang SC", "Hiragino Sans GB", "Source Han Sans CN", "Source Han Sans SC", "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", 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:#E63629;
%clearFix {
  &::after {
    content: '';
    clear: both;
    display: block;
  }
}
$color-shadow: rgba(0, 0, 0, 0.25);
%outerShadow {
  box-shadow: 0 0 3px $color-shadow;
}
%innerShadow {
  box-shadow: inset 0 -3px 3px -3px $color-shadow,
  inset 0 3px 3px -3px $color-shadow;
}

2.3 行为层<script>实现

  • 模块化

分别建四个组件:
Tags.vue、Notes.vue、Types.vue、NumberPad.vue

//NumberPad.vue
<template>
  <div class="numberPad">
       <div class="output">100</div>
       <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>
</template>
//Types.vue
<template>
  <div>
      <ul class="types">
        <li class="selected">支出</li>
        <li>收入</li>
      </ul>
  </div>
</template>
//Notes.vue
<template>
    <div>
      <label class="notes">
        <span class="name">备注</span>
        <input type="text">
      </label>
    </div>
<template>
//Tags.vue
<template>
  <div class="tags">
        <ul class="current">
            <li></li>
            <li></li>
            <li></li>
            <li></li>
        </ul>
        <div class="new">
           <button>新增标签</button>
        </div>   
  </div>
<template>
  • 模块化后的Money.vue引入以上四个组件
//Money.vue
<template>
  <Layout class-prefix="layout">
     <NumberPad />
     <Types />
     <Notes />
     <Tags />
  <Layout/>  
</template>

<script lang="ts">
  import NumberPad from '@/component/NumberPad.vue'
  import Types ...
  import Notes ...
  import Tags ...
  
  export default{
      name:'Money',
      components:{Tags, Notes, Types, NumberPad}
  };
</script>  

2.3.1 写Types.vue组件

//  JS实现
<template>
  <div>
      <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'],
        data(){
          return {
            type:'-' 
          }  
        },
        mounted(){
            console.log(this.xxx)
        },
        methods:{
            selectType(type){
              if (type!=='-' && type!=='+'){
                  throw new Error('type is unknown')
              }
              this.type = type
            }
        },
    }
</script>
  • 使用外部属性接收组件传的数据
//Money.vue
...
<Types xxx="hi" />
  • 用TS实现

TS就是JS:类型,本质是通过tsc程序检查JS是否正确,若报错也可以正常编译,可以通过tsconfig.json中的compilerOptions来配置noEmitOnError:false,来指定若报错就不继续编译。

TypeScriptJavaScript最大的不同是,前者不用构造选项(没有数据类型,不够严谨),而是用class装饰器

//  TS实现
<template>
  <div>
      <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    //使用官方装饰器,告诉TS以下为Vue组件,type='-'就会自动处理成data,selectType就会自动处理成methods
    export default class Types extends Vue{
        //以下赋值语句都会变为实例的属性,默认为data
        type='-';              //相当于data
        selectType(type: string){
              if (type!=='-' && type!=='+'){
                  throw new Error('type is unknown')
              }
              this.type = type
            }
        },
    }
</script>
  • TS 组件 @Prop 装饰器

因为官方库的@Prop不好用,所以就用第三方库的vue-property-decorator

<script lang="ts">
    import Vue from 'vue'
    import {Component,Prop} from 'vue-property-decorator';
    @Component    
    export default class Types extends Vue{
        type='-';     //类型TS可以自动识别,可省略
        @Prop(Number) xxx: number | undefined;
        //@Prop 告诉Vue, xxx不是 data 而是 props
        //Number 告诉Vue, xxx 运行时是个number类型
        //number | undefined 告诉 TS xxx的编译时类型
        selectType(type: string){
              if (type!=='-' && type!=='+'){
                  throw new Error('type is unknown')
              }
              this.type = type
            }
        },
        mounted(){
            console.log(this.xxx);
        }
    }
</script>
//Money.vue
...
<Types xxx=" 333 " />
  • 三种写Vue单文件组件的方法:用JS对象,用TS类,用JS类

2.3.2 写NumberPad.vue组件

  • 功能实现。总体的响应是点击14个按钮和输出框显示内容(默认显示字符串0),都会有对应的响应。分为不同的功能实现:
  1. 数字区0-9,和小数点.要实现点击每个数字显示在输出框中,且数字区的细节流程控制有不可以输入两次0,两次小数点.,还有输出框的最大输出长度;
  2. 删除,实现每次点击,输出框数字都缩进1个数字;
  3. 清空,实现点击后,数据清空显示0
  4. ok,实现记账后,把数据提交到本地存储,功能后面实现。
//NumberPad.vue
<template>
  <div class="numberPad">
       <div class="output">{{output}}</div>
       <button @click="inputContent">1</button>
       <button @click="inputContent">2</button>
       <button @click="inputContent">3</button>
       <button @click="remove">删除</button>
       <button @click="inputContent">4</button>
       <button @click="inputContent">5</button>
       <button @click="inputContent">6</button>
       <button @click="clear">清空</button>
       <button @click="inputContent">7</button>
       <button @click="inputContent">8</button>
       <button @click="inputContent">9</button>
       <button @click="ok" class="ok">OK</button>
       <button @click="inputContent">0</button>
       <button @click="inputContent">.</button>
  </div>
</template>

<script lang="ts">
  import Vue from 'vue';
  import {Component} from 'vue-property-decorator'
  
  @Component
  export default class NumberPad extends Vue {
      output:string = '';
      //inputContent不传参数,Vue会自动传event(这个事件的所有信息),也就是当前元素的所有内容
      inputContent(event:MouseEvent){
        const button = (event.target.textContent) as HTMLButtonElement
        this.outout +=  button.textContent;
      }
      
      remove(){
          if(this.output.length ===1) {
             this.output = '0';
          } else {
             this.output = this.output.slice(0,-1);
          }
      }
      clear(){
          this.output = '0';
      }
  }

待解决输入两个.的逻辑和两个0 ,输入ok时的响应的逻辑

2.3.3 写Notes.vue组件

//Notes.vue
<template>
    <div>
      <label class="notes">
      {{value}}    //log技巧,查看代码效果
        <span class="name">备注</span>
        <input type="text" :value="value"
               @input="onChange"
               placeholder="在这里输入备注">
      </label>
    </div>
</template>

<script lang="ts">
  import Vue from 'vue';
  import {Component} from 'vue-property-decorator';
  @Component
  export default class Notes extends Vue{
      value = '';
      onInput(event: KeyboardEvent){
          const input = (event.target) as HTMLButtonElement
          this.value = input.value
      }
}
</script>
  • v-model简写代码
//Notes.vue
<template>
    <div>
      <label class="notes">  
        <span class="name">备注</span>
        <input type="text" 
               v-model="value" 
               placeholder="在这里输入备注">
      </label>
    </div>
</template>

<script lang="ts">
  import Vue from 'vue';
  import {Component} from 'vue-property-decorator';
  @Component
  export default class Notes extends Vue{
      value = '';
}
</script>

2.3.4 写Tags.vue组件

<template>
  <Layout>
    <div class="tags">
        <ul class="current">
            <li v-for="tag in dataSource" :key="tag"
                :class="{selected: selectedTags.indexOf(tag)>=0}"
                @click="toggle(tag)">{{tag}}</li>            //遍历tags数组里的所有内容 v-for和:key同时出现
        </ul>
        <div class="new">
           <button @click="create">新增标签</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;    //空的字符串数组
     seletedTags: string[] = []; 
     toggle(tag: string){
      this.seletedTags.push(tag)
     }
     create(){
         window.prompt('请输入标签名');
     }
 }
</script>

待解决选中取消tag逻辑,toggle和create尚不完整

  • 引入Money.vue,先用JS写行为层
...
<Tags :data-source.sync="tags"/>
...
<script>
...
  export default {
      name:'Money',
      component:{Tags, Notes, Types, NumberPad},
      data(){
          tags:['衣','食','住','行']
      } 
  }

2.4 收集四个组件的value

需求是把用户填的东西,包括新增标签、备注、支出或收入下的金额输入信息,收集起来,单击ok键的时候,变成一个大的对象。

<template>
    <Layout class-prefix="layout">
        <NumberPad @update:value="onUpdateAmount"/>  //获取ok提交时的数据,通过触发update:value事件
        <Types @update:value="onUpdateType"/>   //获取选中的支出或收入,通过触发update:value事件
        <Notes @update:value="onUpdateNotes"/>  //获取输入的备注通过触发update:value事件
        <Tags :data-source.sync="tags" @update:value="onUpdateTags"/>  //获取选中tag的监听事件xxx
    </Layout>
</template>

<script lang="ts">
    import Vue from 'vue';
    import NumberPad from '@/components/Money/NumberPad.vue';
    import Types from '@/components/Money/Types.vue';
    import Notes from '@/components/Money/Notes.vue';
    import Tags from '@/components/Tags.vue';
    import {Component} from 'vue-property-decorator';
    
    @Component({
        components: {Types, Notes, Tags, NumberPad}
    })
    export default class Money extends Vue {
        tags = ['衣','食','住','行'];
        //需要在Tags组件中toggle方法添加`this.$emit('update:value',this.selectedTags)`
        onUpdateTags(value: string[]){}
        onUpdateNotes(value: string){}
        onUpdateType(value: string){}
        onUpdateAmount(value: string){}
  • Notes组件需要添加
...
export default class Notes extends Vue {
    value = '';
    @Watch('value')                 //只要value变化就会监听到,触发onValueChanged事件
    onValueChanged(value: string, oldValue: string){
        this.$emit('update:value',value);
    }
}
  • Types组件需要添加
...
export default class Types extends Vue {
    type = '-';
    @Watch('type')                 //只要type变化就会监听到,触发onTypeChanged事件
    onTypeChanged(value: string){
        this.$emit('update:value',value);
    }
}
  • NumberPad组件需要添加
ok(){
    this.$emit('update:value', this.output);
}
  • 收集到的数据放在一个对象中

TS的类型声明只关注类型,不关注值是多少;JS的变量声明,既有类型也有值

//Money.vue
...
type Record = {        //声明Record类型
    tags: string[]
    notes: string
    type: string
    amount: number
}
export default class Money extends Vue {
        tags = ['衣','食','住','行'];
        record: Record = {tags:[ ], notes:'', type: '-', amount: 0};  //设置初始值

2.5 赋值并生成Record数组

export default class Money extends Vue {
        tags = ['衣','食','住','行'];
        record: Record = {tags:[ ], notes:'', type: '-', amount: 0};  
        onUpdateTags(value: string[ ]) {this.record.tags = value}
        onUpdateNotes(value: string) {this.record.notes = value}
        onUpdateType(value: string) {this.record.type = value}
        onUpdateAmount(value: string) {this.record.amount = parseFloat(value)} //把字符串转换为数值

2.6 初始值传给四个组件

主要是tag默认的是+,-,要从外部传

//Money.vue
<Types :value="record.type" @update:value="onUpdateType"/>
  • 优化Types组件
...
@Prop( {default: '-'} ) readonly value!: string;  //忽略初始值的问题加!
selectType(type: string){
  if(type !== '-' && type !=='+'){
      throw new Error('type is unknown');
  }
  this.$emit('update:value', type);   //type是+或-,就触发事件更新value,值为type
}
  • 使用修饰符,删掉onUpdateTags方法;如果想给组件一个初始值,在其更新的时候,拿到更新值,就用.sync修饰符,一开始可以得到初始值,value变化时,只要触发updateValue就可以。
//Money.vue
<Types :value.sync="record.type"/>

待更新其余组件的.sync修饰符

2.7 Record数组存到数据库中(用LocalStorage实现)

在用户点击ok时,把数据放到LocalStorage,绑定一个@submit事件,并监听,提交时触发saveRecord方法,在NumberPad.vue组件中添加ok的saveRecord方法,此步骤为初步实现记一笔账,点击ok提交后,就把这条账目推到数组中,每次都是保存相同的内存地址,将上一次的覆盖掉。

  • 给Number组件中的ok方法添加一个submit事件,再添加一个savaRecord方法
recordList: Record[] = [];
ok(){
    this.$emit('update:value', this.output);
    this.$emit('submit',this.output);
    this.output = '0';
}
saveRecord(amount){
    const record2 = JSON.parse(JSON.stringify(this.record));
    this.recordList.push(record2);
}
<NumberPad :value.sync="record.amount" @submit="saveRecord"/>
  • 每次的RecordList列表不能初始值为空,而是应该直接读取数据库,
const recordList: Record[] = JSON.parse(window.localStorage.getItem('recordList') || '[]');  //将数据转换为 JS 对象
  • 时间戳
...
type Record = {        //声明Record类型
    tags: string[]
    notes: string
    type: string
    amount: number
    created: Date          //类、构造函数
}
saveRecord(){
    const record2: Record = JSON.parse(JSON.stringify(this.record));
    record2.createdAt = new Date();
    this.recordList.push(record2);
}

2.8 重构-封装Model层面的代码

  • 用JS封装数据
// src-views-model.js
const localStorageKeyName = 'recordList'
const model = {
    //获取数据
    fetch(){
       return recordList: Record[] = JSON.parse(window.localStorage.getItem(localStorageKeyName) || '[]');
    },
    save(data){
       window.localStorage.setItem(localStorageKeyName, JSON.stringify(data));
    }
}
export {model}
  • 在组件中引用
//Money.vue
const {model} = require('@/model.js');
const recordList: Record[ ] = model.fetch( );
  • 改用TS封装数据,先把之前命名的Record改为RecordItem
// src-custom.d.ts (自定义)全局声明
type RecordItem = {        
    tags: string[]
    notes: string
    type: string
    amount: number
    created: Date          
}
// src-views-model.ts
const localStorageKeyName = 'recordList'
const model = {
    clone(data: RecordItem[ ] | RecordItem){
       return JSON.parse(JSON.stringfy(data));
    }
    //获取数据
    fetch(){
       return JSON.parse(window.localStorage.getItem(localStorageKeyName) || '[ ]') as RecordItem[ ];
    },
    save(data: RecordItem[ ]){
       window.localStorage.setItem(localStorageKeyName, JSON.stringify(data));
    }
}
export {model}
  • 在组件中引用
//Money.vue
const {model} = require('@/model.js');
const recordList = model.fetch( );

3. Labels.vue组件实现

3.1 结构层<template>实现

<template>
    <Layout>
        <ol class="tags">
            <li>
                <span></span>
                <Icon name="right"/></li>
            <li>
                <span></span> 
                <Icon name="right"/></li>
            <li>
                <span></span> 
                <Icon name="right"/></li>
            <li>
                <span></span> 
                <Icon name="right"/></li>
        </ol>
        <div>
            <button>新建标签</button>
    </Layout>  
</template>   

CSS样式待更新

3.2 行为层<script>实现

  • 为了与之前的Money组件中的model数据做区别,需要再命名一个Labels组件的model数据tagListModel
// src-models-tagListModel.ts
const localStorageKeyName = 'tagList';
type TagListModel {
    data: string[ ]
    fetch: ( ) => string[ ]      //输入参数类型 => 输出结果参数类型
    create: (name: string) => string  //success 表示成功 duplicated 表示name重复
    save: ( ) => void
}

const tagListModel: TagListModel = {
    fetch(){
       return JSON.parse(window.localStorage.getItem(localStorageKeyName) || '[ ]');
    },
    create(name: string){
        if(this.data.indexOf(name)>=0){
          return 'duplicated';  
        }
        this.data.push(name);
        this.save();
        return 'success';
    },
    save(){
       window.localStorage.setItem(localStorageKeyName, JSON.stringify(this.data));
    }
}
export default tagListModel;
  • 在Money组件中引用
//Money.vue
import recordListModel from '@/models/recordListModel';
import tagListModel from '@/models/tagListModel';

const recordList = recordListModel.fetch( );
const tagList = tagListModel.fetch( );

@Component({ components:{Tags, Notes, Types, NumberPad} })
export default class Money extends Vue {
    tags = tagList;
    recordList: RecordItem[ ] = recordList;
    record: RecordItem = {
        tags: [ ], notes: ' ', type: '-', amount: 0
    };
}
  • 在Labels组件中引用
<template>
    <Layout>
        <ol class="tags">
            <li v-for="tag in tags" :key="tag">
                <span>{{tag}}</span>
                <Icon name="right"/>
            </li>
        </ol>
        <div>
            <button class="createTag" @click="createTag">新建标签</button>
    </Layout>  
</template>   

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

tagListModel.fetch();
@Component    
export default class Labels extends Vue {
        tags = tagListModel.data;
        
        createTag(){
            const name = window.prompt('请输入标签名');
            if(name){
                const message = tagListModel.create(name);
                if(message === 'duplicated'){
                    window.alert('标签名重复了');
                } else if (message === 'success'){
                    window.alert('添加成功');
                }
            }
        }
}

4. EditLabel.vue组件实现

4.1 新建路由

{
    path:'/labels/edit',
    component: EditLabel
}

4.2 写结构层

  • 修改之前创建的数据类型,因为编辑标签时需要id,就需要构建出一个数组,把idname分出来,方便获取。
// src-models-tagListModel.ts
const localStorageKeyName = 'tagList';
type Tag = {             //新建一个Tag数组
    id: string;
    name: string;
} 

type TagListModel {
    data: Tag[ ]
    fetch: ( ) => Tag[ ]      //输入参数类型 => 输出结果参数类型
    create: (name: string) => string  //success 表示成功 duplicated 表示name重复
    save: ( ) => void
}

    create(name: string){
    // this.data = [{id:'1', name:'1'}, {id: '2', name: '2'}]
        const names = this.data.map(item => item.name)
        if(names.indexOf(name)>=0){
          return 'duplicated';  
        }
        this.data.push({id: name, name: name});
        this.save();
        return 'success';
    },
    save(){
       window.localStorage.setItem(localStorageKeyName, JSON.stringify(this.data));
    }
  • 对应的Labels组件可以获取idname
// labels.vue
<template>
    <Layout>
        <ol class="tags">
            <li v-for="tag in tags" :key="tag.id">    //修改
                <span>{{tag.name}}</span>
                <Icon name="right"/>
            </li>
        </ol>
        <div>
            <button class="createTag" @click="createTag">新建标签</button>
        </div>
    </Layout>  
</template>   
  • 获取edit/1的路由
{
    path:'/labels/edit/:id',  //占位,edit后面有一个字符串
    component: EditLabel
}
  • EditLabel组件获取edit路由id,也就是导航完成后获取数据,用created钩子$route.params.id来获取:
// EditLabel.vue
<template>
    <Layout>
      编辑标签
    <Layout> 
<template>

<script lang="ts">
    import Vue from 'vue';
    import {Component} from 'vue-property-decorator';
    
    @Component
    export default class EditLabel extends Vue {
        created(){
           const id = this.$route.params.id    //获取到id是基于vue router库中有$router和$route两个属性,前者为路由器与转发有关,后者获取路由信息
           tagListModel.fetch();
           const tags = tagListModel.data;
           const tag = tags.filter(t => t.id === id)[0];  //找到id对应的tag
           if(tag){
               console.log(tag);
           } else {
               this.$router.replace('/404');
           }
        }
    }
  • 关联两个标签
// Labels.vue

<template>
    <Layout>
        <div class="tags">
            <router-link v-for="tag in tags" :key="tag.id"
                         :to="`/labels/edit/${tag.id}`">    //修改
                <span>{{tag.name}}</span>
                <Icon name="right"/>
            </li>
        </div>
        <div>
            <button class="createTag" @click="createTag">新建标签</button>
        </div>    
    </Layout>  
</template>  

4.2.1 改造Notes组件为通用组件,重构为FormItem组件

  • 直接<Notes/>引入的话,不能自定义,就需要改造
//Notes.vue
<template>
    <div>
      <label class="notes">
        <span class="name">{{this.fieldName}}</span>
        <input type="text" 
               v-model="value"
               placeholder="this.placeholder">
      </label>
    </div>
</template>

<script lang="ts">
  import Vue from 'vue';
  import {Component, Prop} from 'vue-property-decorator';
  @Component
  export default class Notes extends Vue{
      value = '';
      
      @Prop({required:true})fieldName!: string;  //默认值为必传项required:true
      @Prop() placeholder?: string;    //placeholder有可能不存在?
      onInput(event: KeyboardEvent){
          const input = (event.target) as HTMLButtonElement
          this.value = input.value
      }
}
</script>
  • Money组件也传一个field-name
<Notes field-name="备注"
       placeholder="在这里输入备注"
       @update:value="onUpdateNotes"/>
  • EditLabel也传一下field-name
<template>
    <Layout>
      <div>
        <Icon name="left"/>
        <span>编辑标签</span>
      </div>
      <Notes field-name="标签名" placeholder="请输入标签名"/>
    <Layout> 
<template>

4.2.2 添加删除标签按钮,新建Button组件

//Button.vue
<template>
  <button class="button" @click="$emit('click', $event)">
    <slot/>
  </button>
</template>
  • Labels组件中使用插槽引用button组件
//Button.vue
<Button class="createTag" @click="createTag">新建标签</Button>
  • EditLabel组件中,直接使用<Button>组件
<template>
    <Layout>
      <div>
        <Icon name="left"/>
        <span>编辑标签</span>
      </div>
      <FormItem field-name="标签名" placeholder="请输入标签名"/>
      <Button>删除标签</Button>
    <Layout> 
<template>

4.3 写功能

5. 手写store-封装recordListModel

背景是Money.vue和Labels.vue要实现同步数据显示,其优点如下:

  1. 解耦:将所有数据相关的逻辑放入 store(也就是 MVC 中的 Model)
  2. 数据读写更方便:任何组件不管在哪里,都可以直接读写数据
  3. 控制力更强:组件对数据的读写只能使用 store 提供的 API 进行
  • 封装clone
//lib-clone.ts
function clone( data: any ){
    return JSON.parse(JSON.stringfy(data));
}
export default clone;
  • 统一recordListModeltagListModel
//recordListModel
import clone from '@/lib/clone';
    data: [ ] as RecordItem[ ],
    fetch(){
      this.data = JSON.parse(window.localStorage.getItem)
      return this.data
    },
    create(record: RecordItem){
       const record2:RecordItem = clone(record);
       record2.createAt = new Date( );
       this.data.push(record2);
    },
    save(){
      window.localStorage.setItem(localStorageKeyName,
        JSON.stringfy(this.data));  
    }
//Money.vue
    saveRecord( ){
       recordListModel.create(this.record)
    }
  • 以上封装完成了对数据的fetch,create,save操作,其他组件只需要调用封装好的API即可。

5.1 用window封装数据tagList和API

  • 先声明数据类型
//custom.d.ts
type RecordItem = {
    tags: string[ ]
    notes: string
    type: string
    amount: number
    createdAt?: Date
}
type Tag = {
    id: string;
    name: string;
}
type TagListModel = {
    data: Tag[ ]
    fetch: ()=> Tag[ ]
    create: (name: string)=>'success'|'duplicated'
    update: (id: string, name: string)=>'success'|'not found'|'duplicated'
    remove: (id: string)=> boolean
    save: ( )=> void    
}
interface Window {
    tagList: Tag[ ],
    findTag: (id: string)=> Tag | undefined,
    createTag: (name: string)=> void,
    removeTag: (id:string)=>boolean,
    updateTag: (id:string, name:string)=>'success'|'not found'|'duplicated'
}
  • 声明tagList全局变量
//main.ts
window.tagList = tagListModel.fetch();
  • 在使用的组件中直接用window.tagList拿到数据
//Money.vue

export default class Money extends Vue {
    tags = window.tagList;
}
//Labels.vue
export default class Money extends Vue {
    tags = window.tagList;
}

5.2 用window封装tag的store

  • 声明createTag,removeTag,updateTag
//main.ts
window.tagList = tagListModel.fetch();
window.findTag = (id:string)=>{
   return window.tagList.filter(t =>t.id === id)[0];
}
window.createTag = (name: string)=> {
    const message = tagListModel.create(name);
    if(message === 'duplicated'){
        window.alert('标签名重复了');
    } else if (message === 'success'){
        window.alert('添加成功');
    }
};
window.removeTag = (id: string)=> {
    return tagListModel.remove(id);
};
window.updateTag = (id: string, name: string)=> {
    return tagListModel.update(id, name);
}
  • 调用createTag
//Labels.vue
export default class Money extends Vue {
    tags = window.tagList;
    createTag(){
        const name = window.prompt('请输出标签名'):
        if(name){
            window.createTag(name);
        }
    }
}
  • 调用removeTag,updateTag,findTag
//EditLabel.vue

export default class EditLabel extends Vue {
  tag?: Tag = undefined;
created(){
    //调用findTag
    this.tag = window.findTag(this.$route.params.id);
    if(!this.tag){
        this.$router.replace('/404');
    }
}
update(name: string){
    if(this.tag){
        window.updateTag(this.tag.id, name);
    }
}
remove(){
    if(this.tag){
        if(window.removeTag(this.tag.id)){
            this.$router.back();
        } else {
            window.alert('删除失败');
        }
    }
}

5.3 用window封装record的store

//main.ts
window.recordList = recordListModel.fetch();
window.createRecord = (record: RecordItem)=>recordListModel.create(record);
  • 目前存在的问题:全局变量太多、过于依赖window
//store-index(手写store)
//把刚才所有的重构都关联上store,作为脱离依赖window的中间变量
const store = {

//record store
recordList : recordListModel.fetch();
createRecord : (record: RecordItem)=>recordListModel.create(record);
//tag store
tagList : tagListModel.fetch();
findTag(id:string)=>{
   return this.tagList.filter(t =>t.id === id)[0];
}
createTag : (name: string)=> {
    const message = tagListModel.create(name);
    if(message === 'duplicated'){
        window.alert('标签名重复了');
    } else if (message === 'success'){
        window.alert('添加成功');
    }
};
removeTag = (id: string)=> {
    return tagListModel.remove(id);
};
updateTag = (id: string, name: string)=> {
    return tagListModel.update(id, name);
}
}

export default store;
  • 组件使用数据和API
//Money.vue
import store from '@/src/store/index.ts'

tags = store.tagList;
recordList = store.recordList;
//Labels.vue
tag = store.tagList

createTag(){
    const name = window.prompt('请输入标签名');
    if(name){
        store.createTag(name);
    }
}
//EditLabel.vue
export default class EditLabel extends Vue {
  tag?: Tag = undefined;
created(){
    //调用findTag
    this.tag = store.findTag(this.$route.params.id);
    if(!this.tag){
        this.$router.replace('/404');
    }
}
update(name: string){
    if(this.tag){
        store.updateTag(this.tag.id, name);
    }
}
remove(){
    if(this.tag){
        if(store.removeTag(this.tag.id)){
            this.$router.back();
        } else {
            window.alert('删除失败');
        }
    }
}
  • 重构完成以后,需要对各种功能做一下测试。

5.4 手写store拆分为recordStoretagStore,同时将model融合进store

//recordStore.ts

import clone from '@/lib/clone';
const localStorageKeyName = 'recordList';

const recordStore = {
    recordList: [] as RecordItem[],
    fetchRecords() {
        this.recordList = JSON.parse(window.localStorage.getItem(localStorageKeyName) || '[]') as RecordItem[];
        return this.recordList;
    },
    saveRecords() {
        window.localStorage.setItem(localStorageKeyName, JSON.stringify(this.recordList));
    },
    createRecord(record: RecordItem) {
        const record2: RecordItem = clone(record);
        record2.createdAt = new Date().toISOString();
        this.recordList && this.recordList.push(record2);
        //也可以写为 this.recordList?.push(record2); 可选链语法 ES2020
        recordStore.saveRecords();
    },
};

recordStore.fetchRecords();

export default recordStore;
  • 引入两个拆分后的store
import recordStore from '@/store/recordStore';
import tagStore from '@/store/tagStore';
const store = {
    ...recordStore,
    ...tagStore,
}
export default store

5.4 Vuex数据读写

Vue.use(Vuex);

const store = new Vuex.Store({
    state:{                 //相当于data
        count:0,
    },
    mutation:{              //相当于methods
        increment(state, n:number){
          state.count += n;
        }
    }
})
//commit中的两个参数,type:'increment'和payload:10
store.commit('increment', 10);    //触发状态变更  +10
export default store;
  • 在Vue组件中获得Vuex状态(从计算属性中返回某个状态)
//Money.vue
@Component(
    component:{Tags, FormItem, Types, NumberPad},
    computed:{
        count(){
            return store.state.count
        }
    }
)

6. 写统计页面

  • 页面效果图:由两个tab标签和数据结构组成

110.png

6.1 第一个type组件

  • 具体的样式需要使用deep语法::v-deep(可被sass识别)或/deep/
  • 通过classPrefix来灵活定义类; 原来的写法为:
<li :class="value ==='-' && 'selected'"
    @click="selectType('-')">支出
</li>

改为对象的形式,即为表驱动,把所有的类都用表的形式写下来,如果为true,那就要这个class:

<li :class="{selected:value ==='-'}"
</li>

再次写为完整格式:

<li :class="{'xxx-item':classPrefix, selected:value ==='-'}"
</li>

还可以写成ES6的新语法,如果key('xxx-item')里面有变量,就用中括号把这个key包起来:

<li :class="{[classPrefix+'-item']:classPrefix, selected:value ==='-'}"
</li>

引用的组件Statistics.vue写为:

<Types class-prefix="xxx" :value.sync="yyy">

得到的选中元素,就是li.xxx-item.selected,样式选择器就可以写为:::v-deep .xxx-item

6.2 第二个type组件

//Staistics.vue
<Types class-prefix="zzz" :value.sync="yyy">
<Types class-prefix="zzz3" :value.sync="yyy">

-这时候就重复使用相似组件了,可以新建一个tabs.vue组件,先来写实现按天 按周 按月type效果:

<template>
  <ul class="types">
   <li></li>
  </ul>
</template>

<script lang="ts">
    import Vue from 'vue';
    import {Component} from 'vue-property-decorator';
    
    @Component
    export default class Tabs extends Vue{
        @Prop({required:true, type:Array}) 
        dataSource!:{text:string, value:string}[] 
    }

ISO 8601 日期时间表示法

  • Date( ) API:发现很不友好,不推荐用
var d = new Date()
d.toISOString()  // '2022-08-19T07:45:15.081Z' 零时区标准时间
Date.parse('2022-08-19T07:45:15.081Z')  // 1660895115081
new Date(1660895115081)  // Fri Aug 19 2022 15:45:15 GMT+0800 (GMT+08:00)
  • moment.js:体积16k过大,也不常用

  • day.js:轻量化的moment.js,常用

dayjs()
  .startOf('month')
  .add(1, 'day')
  .set('year', 2018)
  .format('YYYY-MM-DD HH:mm:ss')

部署到github

Vue CLI部署文档

源代码的仓库和dist打包仓库分两个;前者是手动push,后者用sh deploy.sh推送

  • 本地预览
$ yarn build   //生成dist目录,提示部署的文档

$ yarn global add serve  //本地预览用于检查dist是否正确打包
$ serve -s dist     //本地预览,打开一个5000端口的网页,文件已打包
  • GitHub新建仓库并关联
git remote set-url origin git@github.com:jianlong5296/morney-9.git
git branch -M main
git push -u origin main
  • GitHub新建dist打包的新仓库morney-9-website

  • GitHub Page手动推送更新

//vue-config-js中设置正确的publicPath

module.exports = {
  publicPath: process.env.NODE_ENV === 'production'
    ? '/morney-9-website/'     //注意填写dist打包新仓库项目名
    : '/'
}
  • 在项目目录下,创建内容如下的 deploy.sh (可以适当地取消注释) 并运行它以进行部署
# !/usr/bin/env sh

# 当发生错误时中止脚本
set -e

# 构建
yarn build

# cd 到构建输出的目录下
cd dist

# 部署到自定义域域名
# echo 'www.example.com' > CNAME

git init
git add -A
git commit -m 'deploy'

# 部署到 https://<USERNAME>.github.io
# git push -f git@github.com:<USERNAME>/<USERNAME>.github.io.git master

# 部署到 https://<USERNAME>.github.io/<REPO>
git push -f git@github.com:jianlong5296/morney-1-website.git master:gh-pages

cd -
  • 首次提交或更改源代码后都需要重新deploy
$ sh deploy.sh    //sh即为shell;  deploy部署
  • 刷新仓库,打开自动生成的page地址,即可访问。

小bug:遇到修改代码但未呈现出来的情况,就换个浏览器查看效果。