Vue2+TS+Vuex 开发章鱼记账app(下)

51 阅读7分钟

Labels 组件

写 结构

//Labels.vue
<template>
    <Layout>
        <ol class="tagList">
            <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 class="createTag-wrapper">
            <button class="createTag" @click="createTag">新建标签</button>
        </div>
    </Layout>
</template>

写 样式

//Labels.vue
<template>
    <Layout>
        <ol class="tags">
            <li v-for="tag in tags" :key="tag">
                <span>{{tag}}</span>
                <Icon name="right"/>
            </li>
        </ol>
        <div class="createTag-wrapper">
            <button class="createTag" @click="createTag">新建标签</button>
        </div>
    </Layout>
</template>

<style scoped lang="scss">
    .tags {
        background: white;
        font-size: 16px;
        padding-left: 16px;
        > li {
            min-height: 44px;
            display: flex;
            align-items: center;
            justify-content: space-between;
            border-bottom: 1px solid #e6e6e6;
            svg {
                width: 18px;
                height: 18px;
                color: #666;
                margin-right: 16px;
            }
        }
    }
    .createTag {
        background: #767676;
        color: white;
        border-radius: 4px;
        border: none;
        height: 40px;
        padding: 0 16px;
        /* &-xx   xx为其父元素 */
        &-wrapper { 
            text-align: center;
            padding: 16px;
            margin-top: 44-16px;
        }
    }
</style>

写 行为

  • 新增的标签存在什么位置?参考之前写的Money.vue中写的标签初始值是固定的,如果把新增标签的数据放在tags初始值上,那么刷新时,就会再次出现初始值,并没有体现变化,所以还需要新建一个tagListModel.ts来存Labels.vue的新建标签:
// tagListModel.ts
<Tags :data-source.sync="tags" @update:value="onUpdateTags"/>   

//tags初始值
tags = ['衣','食','住','行']    
  • 新建一个初步的model,主体内容保持不变,看能否与之前的model合并:
// src/models/tagListModel.ts
const localStorageKeyName = 'tagList';
const recordListModel = {
    fetch() {   //获取数据
        return JSON.parse(window.localStorage.getItem(localStorageKeyName) || '[]') as RecordItem[];
    },
    save(data: RecordItem[]) {
        window.localStorage.setItem(localStorageKeyName, JSON.stringify(data));
    }
};

export default recordListModel;

封装之前的Money.vue中的record初始值

之前MVC时创建的model.ts重构为recordListModel.ts,重构时注意勾选

重构的操作步骤为:要重构的文件上单击右键-> 选refactor-> 选rename,勾选Search for references选项)

//Money.vue
recordList: RecordItem[] = JSON.parse(window.localStorage.getItem(recordList) || '[]');

//封装recordList为如下代码:
const recordList = recordlist.fetch();

//封装recordList为如下代码:
const tagList = tagListModel.fetch();
//recordListModel.ts
const localStorageKeyName = 'recordList';
const recordListModel = {
    ...
    fetch() {   
        return JSON.parse(window.localStorage.getItem(localStorageKeyName) || '[]') as RecordItem[];
    },
    ...
};

export default recordListModel;
//tagListModel
const localStorageKeyName = 'tagList';
const tagListModel = {
    fetch() {   
        return JSON.parse(window.localStorage.getItem(localStorageKeyName) || '[]');

    },
    ...
};
export default tagListModel;
  • 之前的初始值改为:
export default class Money extends Vue {
    //初始值改为重构后的tagList
    tags = tagList;    
    recordList: RecordItem[] = JSON.parse(window.localStorage.getItem(recordList) || '[]');
    record: RecordItem = {
        tags: [], notes: '', type: '-', amount: 0,
    };

封装labels.vue中的数据,创建新建标签的方法

<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;    //引用data
        createTag() {
            const name = window.prompt('请输入标签名');
            if (name){
                tagListModel.create(name)  //需要进一步实现封装a
            }
        }
    };
</script>
  • 实现上一步的封装a,需要在tagListModel中写入create方法,这就涉及到怎么拿数据,可以在开始时声明data:[],但是因为ts需要赋给data数据类型,而因为data数据类型为联合类型,就要再声明一下TagListModel的类型,用tagListModel: TagListModel,把声明与类型关联起来,从而实现data完全自己维护,外部要创建和声明,直接调用暴露在外的API即可。

tagListModel的data完全自己维护,与之前的recordListModel实现思路不一样:之前的recordListModel没有保存自己的data,所有的东西需要用参数和返回值的形式来实现。

//tagListModel.ts
const localStorageKeyName = 'tagList';

type TagListModel = {      //声明TagListModel类型
    data: string[]           //声明data为字符串数组
    //对于函数声明类型,箭头=>前为输入的参数类型,箭头后为输出结果类型
    fetch: () => string[]    //声明fetch为函数,返回值为字符串数组
    create: (name: string) => string
    save: () => void        //save函数不返回
}
//tagListModel的实现方法,用冒号:TagListModel,使方法与声明类型关联
const tagListModel: TagListModel = {   
    data: [],
    fetch() {   //获取数据
        return JSON.parse(window.localStorage.getItem(localStorageKeyName) || '[]');

    },
    create() {
        this.data.push(name);
        this.save();
        return name;
    },
    save() {
        window.localStorage.setItem(localStorageKeyName, JSON.stringify(this.data));
    }
};
  • 以上的代码总结一下:首先Labels.vue中的数据,Label是不管的,践行MVC设计模式,所有涉及数据的操作都由Model模块自己提供,这样data可以直接拿来用,就可以放在tags里面,以后创建tag时,调用Model来创建,涉及到保存的Label还是不插手,依然由Model模块去实现,实现了简洁独立的设计模式。

  • 避免用户创建标签时,存在相同的名字,在tagListModel中实现:

可以叙述遇到的技术难题的解决路径,比如:创建成功要返回什么类型存在疑问:一开始认为只要返回布尔值truefalse就行,但是发现失败的情况有很多种,于是考虑用数字来判定,列了一个数字列表从0-100,分别表示什么错误,最后发现文档在运行一段时间以后,根本不记得不同数字对应的是什么错误类型,最终就统一改为字符串了,在字符串里不管字符串有多长,都可以接受,只要把错误描述清楚就行;也用了中文这种特殊字符串来判定。

//声明类型时
create(name: string)=> 'success'| 'duplicated'  //联合类型
//方法实现:
create(name: string) {
    if (this.data.indexOf(name)>=0){  //如果命名重复
        return 'duplicated';
    }
    this.data.push(name);
    this.save();
    return 'success';
},
  • 对应修改页面元素:
<template>
    <Layout>
        <ol class="tags">
            <li v-for="tag in tags" :key="tag">
                <span>{{tag}}</span>
                <Icon name="right"/>
            </li>
        </ol>
        <div class="createTag-wrapper">
            <button class="createTag" @click="createTag">新建标签</button>
        </div>
    </Layout>
</template>

写 删除标签项

  • 首先,新建一个路由EditLabel.vue
//router/index.ts
path:'/labels/edit', 
  component:EditLabel
},
  • 加底部导航栏,直接加<Layout>
//EditLabel.vue
<template>
    <Layout>   
        <div>
            <Icon name="left"/>
            <span>编辑标签</span>
        </div>

    </Layout>
</template>
  • 明确编辑的哪个标签,有个索引ID,查看创建标签的代码,因为没有数字的自增长,唯一能保证的是name不重复,所以可以把name当做id,对应地去修改tagListModel.ts中的内容:
//Labels.vue
createTag() {
    const name = window.prompt('请输入标签名');  
    if (name) {
        const message = tagListModel.create(name);
        if (message === 'duplicated') {
            window.alert('标签名重复了');
        } else if (message === 'success'){
            window.alert('标签添加成功');
        }
}
//tagListModel.ts
type Tag = {
    id:string;
    name:string;
}
type TagListModel = {      //声明TagListModel类型
    data: Tag[]           //data为字符串数组
    fetch: () => Tag[]
    
    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){   //names里面包含了新建的name,返回重复
        return 'duplicated';
    }
    this.data.push({id:name, name:name});  
    this.save();
    return 'success';
}
  • 对应的改Labels.vue里面的内容:
//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 class="createTag-wrapper">
            <button class="createTag" @click="createTag">新建标签</button>
        </div>
    </Layout>
</template>
  • 如何获取页面的中的id=1?
//router/index.ts
path:'/labels/edit/:id',  //意思是edit后面有个字符串但是未知,写个占位符
  component:EditLabel
},
  • EditLabel怎么知道上面的路由edit后面的是什么呢? 可以通过vue router文档,用于收集信息的:

$router

$route

  • 添加Vue的两个属性$router$route,来源为vue.d.ts,声明了在Vue中添加的属性。
//vue.d.ts
declare module 'vue/types/vue' {
  interface Vue {
    $router: VueRouter
    $route: Route
  }
}
//EditLabel.vue
<script lang="ts">
    import Vue from 'vue';
    import {Component} from 'vue-property-decorator';
    import tagListModel from '@/models/tagListModel';

    @Component
    export default class EditLabel extends Vue {
        created() {
            const id = this.$route.params.id;  //$route 用于获取路由信息
            tagListModel.fetch();
            const tags = tagListModel.data;
            const tag = tags.filter(tag => tag.id === id)[0];
            if (tag) {
                console.log(tag);
            } else {
                this.$router.replace('/404');
            }
        }
    }
</script>
  • 再把标签名对应的编辑页面关联起来:用户点击其中一个标签时,跳转到编辑页面,就需要改Labels.vue里面的内容,页面中的<li>改为<router-link>,外面的<ol>改为<div>,同时<router-link>要有to属性:
//Labels.vue
<template>
    <Layout>
        <div class="tags">
            <router-link class="tag" v-for="tag in tags" :key="tag.id"
              :to="/labels/edit/${tag.id}"
                <span>{{tag.name}}</span>
                <Icon name="right"/>
            </router-link>

        </div>
        <div class="createTag-wrapper">
            <button class="createTag" @click="createTag">新建标签</button>
        </div>
    </Layout>
</template>
  • 对应的样式也改为:
<style scoped lang="scss">
    .tags {
        background: white;
        font-size: 16px;
        padding-left: 16px;

        > .tag {
            min-height: 44px;
            display: flex;
            align-items: center;
            justify-content: space-between;
            border-bottom: 1px solid #e6e6e6;

            svg {
                width: 18px;
                height: 18px;
                color: #666;
                margin-right: 16px;
            }
        }
    }

    .createTag {
        background: #767676;
        color: white;
        border-radius: 4px;
        border: none;
        height: 40px;
        padding: 0 16px;

        &-wrapper { //父元素选择器
            text-align: center;
            padding: 16px;
            margin-top: 44-16px;
        }
    }
</style>
  • 关联建好以后,就可以写编辑页面的内容了:
//EditLabel.vue
<template>
    <Layout>
        <div>
            <Icon name="left"/>
            <span>编辑标签</span>
        </div>
    </Layout>
</template>
  • Notes.vue中用过的input组件改造为通用组件,可以任意复用到各处:
//EditLabels.vue
<template>
    <Layout>
        <div>
            <Icon name="left"/>
            <span>编辑标签</span>
        </div>
<Notes/>       //引入Notes组件
    </Layout>
</template>
//Notes.vue
<template>
    <div>
        {{value}}
        <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, Watch} from 'vue-property-decorator';

    @Component
    export default class Notes extends Vue {
        value = '';
        @Prop({required: true}) fieldName!: string;  //改动
        @Prop() palceholder?: string;

        @Watch('value')
        onValueChange(value: string, oldValue: string) {
            this.$emit('update:value', value);
        }
    }
</script>
  • 也要把Money.vue中引用的Notes也做一下增加:
<Notes field-name="备注"
       placeholder="在这里输入备注"
       @update:value="onUpdateNotes"
/>
  • 这样就可以在其他任何页面使用这个通用组件了,比如在EditLabel中使用:
<template>
    <Layout>
        <div>
            <Icon name="left"/>
            <span>编辑标签</span>
        </div>
        <Notes field-name="标签名" placeholder="请输入标签名"/>  //用此直接引用
    </Layout>
</template>
  • 文件名为Notes.vue重构为FormItem.vue
  • 类名为Notes重构为FormItem
  • 全局搜索一下,还有那些地方用到了 Notes,匹配大小写Match case,匹配单词Words

以上就实现了通用组件的复用,还可以把按钮复用,也就是把这个button改为通用组件

新建一个组件为Button.vue

//component/Button.vue
<template>
    <button class="button">新建标签</button>
</template>

<style scoped lang="scss">
    .button {
        background: #767676;
        color: white;
        border-radius: 4px;
        border: none;
        height: 40px;
        padding: 0 16px;

        &-wrapper { //父元素选择器
            text-align: center;
            padding: 16px;
            margin-top: 44-16px;
        }
    }
</style>
  • 新建标签,用插槽引入,<slot>
//component/Button.vue
<template>
    <button class="button">
    <slot/>
    </button>
</template>
  • Label.vue中的按钮用<Button>引入
<div class="createTag-wrapper">
            <Button class="createTag"
                    @click="createTag">新建标签
            </Button>
</div>
  • 添加<button>的点击事件触发<Button>
<template>
    <button class="button"
            @click="$emit('click',$event)">
        <slot/>
    </button>
</template>

写 EditLabel组件

  • 把当前的标签名获取到,FormItem的数据要从外部获取:
//FormItem.vue
@Prop({default:''}) readonly value!:string;
  • 查看用到他的地方是否还能用:
//Tag.vue
<li v-for="tag in dataSource" :key="tag.id"  //原来是"tag"
    :class="{selected:selectedTags.indexOf(tag)>=0}"
    @click="toggle(tag)">{{tag.name}}      //原来是{{tag}}
</li>
  • v-model会造成理解上的困难,所以把v-model改为通用写法:
//FormItem.vue
<input type="text"
       :value="value"
       @change="value = $event.target.value"
       :placeholder="this.placeholder">
  • 有一个外部数据value,把value的值传给input,当用户input进行变更时,不会对value进行变更,只是把value的值,通过onValueChange()这个函数,回传给组件,只负责中转功能,不会对value进行任何的读写。
//FormItem.vue
<input type="text"
       :value="value"
       @input="onValueChange($event.target.value)"
       :placeholder="this.placeholder">
  • 如何传标签名的值:
//EditLabel.vue
<FormItem :value="tag.name" field-name="标签名" placeholder="请输入标签名"/>

tag?:{id:string, name:string} = undefined;  //把tag的初始值赋值为undefined

created() {
    const id = this.$route.params.id;  // 获取到url里面的id
    tagListModel.fetch();
    const tags = tagListModel.data;  //通过id在所有tags里面找到tag
    const tag = tags.filter(t => t.id === id)[0]; //把tag赋值到刚开始未定义的tag
    if (tag) {
        this.tag = tag;
    } else {
        this.$router.replace('/404');
    }
}
  • 用户对标签修改,监听FormItemvalue事件:
//EditLabel.vue
<div class="form-wrapper">
    <FormItem :value="tag.name"
              @update:value="updateTag"     //监听
              field-name="标签名" placeholder="请输入标签名"/>
</div>
  • 通过tagListModel.data拿到tags
//tagListModel.ts
type TagListModel = {
    data: Tag[]
    fetch: () => Tag[]
    create: (name: string) => 'success' | 'duplicated'  //重复
    update:(id:string, name:string)=> 'success'| 'not found' |'duplicated'
    save: () => void
}

update(id, name) {
    const idList = this.data.map(item => item.id);
    if (idList.indexOf(id) >= 0) {
        const names = this.data.map(item => item.name);
        if (names.indexOf(name) >= 0) {
            return 'duplicated';
        } else {
            const tag = this.data.filter(item => item.id === id)[0];
            tag.name = name;
            this.save();
            return 'success';
        }
    } else {
        return 'not found';
    }
},
  • 对应的改EditLabel.vue内容:
update(name:string){
    if (this.tag){
        tagListModel.update(this.tag.id, name)
    }
}
  • 实现删除功能:
//EditLabel.vue
<div class="button-wrapper">
    <Button @click="remove">删除标签</Button>
</div>

update(id, name) {
    const idList = this.data.map(item => item.id);
    if (idList.indexOf(id) >= 0) {
        const names = this.data.map(item => item.name);
        if (names.indexOf(name) >= 0) {
            return 'duplicated';
        } else {
            const tag = this.data.filter(item => item.id === id)[0];
            tag.name = tag.id = name;
            this.save();
            return 'success';
        }
    } else {
        return 'not found';
    }
},
remove(){
    if (this.tag){
        tagListModel.remove(this.tag.id)
    }
}
//tagListModel.ts
type TagListModel = { 
    remove: (id: string) => boolean;
}

remove(id: string) {
    let index = -1;
    for (let i = 0; i < this.data.length; i++) {
        if (this.data[i].id === id){
            index = i;
            break;
        }
    }
    this.data.splice(index, 1);
    this.save();
    return true;
},
  • 实现返回按钮的功能:
//EditLabel.vue
<Icon class="leftIcon" name="left" @click="goBack"/>

goBack() {
    this.$router.back();
}
//Icon.vue
<svg class="icon" @click="$emit('click',$event)">
  • 写ID生成器
// lib/createdId.ts
let id: number = parseInt(window.localStorage.getItem('_idMax') || '0');

function createId() {
    id++;
    return id;
}
export default createId;
//tagListModel.ts
create(name: string) {
    const names = this.data.map(item => item.name);
    if (names.indexOf(name) >= 0) {
        return 'duplicated';
    }
    const id = createId().toString();   //新增
    this.data.push({id, name: name});   //新增
    this.save();
    return 'success';
},

目前项目中实现标签动态的删减,与记账页面的标签显示不同步,因为两个组件都分别做了一次fetch、window.localStorage.getItem,解决此问题,需要先把两个存数据的Model统一一下。

const recordList = recordListModel.fetch();
const tagList = tagListModel.fetch();
  • 重新封装recordListModel.ts,重新封装create方法,同时封装clone方法(在lib目录下新建clone.ts):
//src/lib/clone.ts
function clone(data:any){
    return JSON.parse(JSON.stringify(data));
}
export default clone;
//recordListModel.ts
import clone from '@/lib/clone';

const localStorageKeyName = 'recordList';
const recordListModel = {
    data: [] as RecordItem[],

    create(record:RecordItem) {
        const record2: RecordItem = clone(record);
        record2.createdAt = new Date();
        this.data.push(record2);
    },
    fetch() {
        this.data = JSON.parse(window.localStorage.getItem(localStorageKeyName) || '[]');
        return this.data;
    },
    save() {
        window.localStorage.setItem(localStorageKeyName, JSON.stringify(this.data));
    }
};

export default recordListModel;
  • 使用封装好的API,对数据的任何操作都可以用通过recordListModel来搞定:
const recordList = recordListModel.fetch();

saveRecord() {
    recordListModel.create(this.record)
}

onRecordListChange() {
    recordListModel.save();
}

全局数据管理

解决标签显示不同步问题,需要先把两个存数据的Model统一一下,找到Money.vueLabels.vue的共同上一级组件:App.vue,或者说提到main.ts用于渲染app的这层,在此处获取到数据,在此之前需要去custom.d.ts中提前声明:

//custom.d.ts
type RecordItem = {  //TS类型声明只写类型,JS类型声明写具体内容
    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 {   //声明window属性
    tagList: Tag[];
}
  • 声明全局变量,用window来容纳数据:
//main.ts
import tagListModel from '@/models/tagListModel';

window.tagList = tagListModel.fetch();
  • 分别直接从window上拿到数据:
//Money.vue
export default class Money extends Vue {
    tags = window.tagList;
//Labels.vue
export default class Labels extends Vue {
    tags = window.tagList;
  • create也封装一下,在此之前需要去custom.d.ts中提前声明:
interface Window {   //声明window属性
    tagList: Tag[];
    createTag:(name:string)=>void
}
  • 声明全局变量,用window来容纳数据:
//main.ts
window.createTag = (name:string)=>{
    const message = tagListModel.create(name);
    if (message === 'duplicated') {
      window.alert('标签名重复了');
    } else if (message === 'success'){
      window.alert('标签添加成功');
    }
}
  • 使用这个API:
//Labels.vue
createTag() {
    const name = window.prompt('请输入标签名');
    if (name) {
        window.createTag(name);
    }
}
  • 继续封装removeupdate
//custom.d.ts
interface Window {   //声明window属性
    tagList: Tag[];
    createTag:(name:string)=>void
    removeTag:(id:string)=>boolean
    updateTag: (id: string, name: string) => 'success' | 'not found' | 'duplicated'
}
//main.ts
window.removeTag = (id: string) => {
    return tagListModel.remove(id);
};
window.updateTag = (id:string, name:string)=>{
       return tagListModel.update(id, name);
}

封装record

//custom.d.ts
interface Window {
    recordList:RecordItem[];
    createRecord:(record:RecordItem)=>void
}
//main.ts
// record store
window.recordList = recordListModel.fetch();
window.createRecord = (record: RecordItem) => recordListModel.create(record);

// tag store
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);
};
//Money.vue
saveRecord() {
    window.createRecord(this.record)
}
  • 目前存在的问题一是用了过多的全局变量,二是严重依赖window

  • 修改注释的样式:

222.png

  • 先把过多的全局变量挂到一个对象上,来解决用了过多的全局变量,先声明类型:
//custom.d.ts
interface Window {   //声明window属性
    store:{
        tagList: Tag[];
        createTag: (name: string) => void
        findTag:(id:string)=> Tag | undefined;
        removeTag: (id: string) => boolean
        updateTag: (id: string, name: string) => 'success' | 'not found' | 'duplicated'
        //也可以写为 updateTag: TagListModel['update']
        recordList:RecordItem[];
        createRecord:(record:RecordItem)=>void
    }
}
  • main.ts中把全局变量都挂在store上:
window.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);
    }

};
  • 再解决严重依赖window:
  • 新建一个index2.tsstore文件夹下,把刚才的main.ts中的store内容都粘贴到index2.ts里,然后把window删掉:
//index2.ts
import recordListModel from '@/models/recordListModel';
import tagListModel from '@/models/tagListModel';

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;
  • 此时,任何想用数据的就可以直接用,比如在Money.vue或其他组件中使用数据:
//Money.vue
import store from '@/store/index2';

export default class Money extends Vue {
    tags = store.tagList;
    recordList = store.recordList;

saveRecord() {
    store.createRecord(this.record)
}
//Labels.vue
tags = store.tagList;

createTag() {
    const name = window.prompt('请输入标签名');
    if (name) {
        store.createTag(name);
    }
}
//EditLabels.vue
tag = store.findTag(this.$route.params.id);

created() {
    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('删除失败');
        }
    }
}

手写store来实现数据集中管理

  • 继续优化store,index2实际上分两个部分,一部分是recordList,另一部分是tagList,可以分为两个文件recordStore.tstagStore.ts
//recordStore.ts
import recordListModel from '@/models/recordListModel';

export default {
    // record store
    recordList: recordListModel.fetch(),
    createRecord: (record: RecordItem) => recordListModel.create(record),

}
//tagStore.ts
import tagListModel from '@/models/tagListModel';

export default {
    // 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);
    }
};
  • 如何在index2.ts中引入上面的两个对象:
import recordStore from '@/store/recordStore';
import tagStore from '@/store/tagStore';

const store = {
    ...recordStore,   //浅拷贝(只复制地址)、模块化
    ...tagStore,      //浅拷贝(只复制地址)、模块化
};

export default store;
  • 将 model 融合进 store,把共性的功能合并,直接把model中的代码拷贝到store中,合并后,就可以删掉原model文件.
  • 先合并record:
//recordStore.ts
import clone from '@/lib/clone';

const localStorageKeyName = 'recordList';

let data: RecordItem[] | undefined = undefined;

function fetch() {
    data = JSON.parse(window.localStorage.getItem(localStorageKeyName) || '[]') as RecordItem[];
    return data;
}

function save() {
    window.localStorage.setItem(localStorageKeyName, JSON.stringify(data));
}

export default {
    fetch: fetch,
    save: save,
    recordList: fetch(),
    createRecord: (record: RecordItem) => {
        const record2: RecordItem = clone(record);
        record2.createdAt = new Date();
        data && data.push(record2);
        save();
    },
};
  • 进一步封装上面的代码:
//recordStore.ts
import clone from '@/lib/clone';

const localStorageKeyName = 'recordList';
let data: RecordItem[] | undefined = undefined;

const recordStore = {
    fetch() {
        data = JSON.parse(window.localStorage.getItem(localStorageKeyName) || '[]') as RecordItem[];
        return data;
    },
    save() {
        window.localStorage.setItem(localStorageKeyName, JSON.stringify(data));
    },
    recordList: data,
    createRecord: (record: RecordItem) => {
        const record2: RecordItem = clone(record);
        record2.createdAt = new Date();
        data && data.push(record2);
        recordStore.save();
    },
};

recordStore.fetch();

export default recordStore;
  • 搜索引用此recordStore.tsindex2.ts引用了recordStorefetchsave属性,可能与另外的数据可能也使用fetchsave相冲突,所以需要重命名为fetchRecordsaveRecord

  • 再合并tag:

//tagStore.ts
import createId from '@/lib/createId';

const localStorageKeyName = 'tagList';

const tagStore = {
    tagList: [] as Tag[],
    fetchTags() {
        this.tagList = JSON.parse(window.localStorage.getItem(localStorageKeyName) || '[]');
        return this.tagList;
    },
    findTag(id: string) {
        return this.tagList.filter(t => t.id === id)[0];

    },
    createTag(name: string) {
        const names = this.tagList.map(item => item.name);
        if (names.indexOf(name) >= 0) {
            window.alert('标签名重复了');
            return 'duplicated';
        }
        const id = createId().toString();
        this.tagList.push({id, name: name});
        this.saveTags();
        window.alert('添加成功');
        return 'success';
    },
    removeTag(id: string) {
        let index = -1;
        for (let i = 0; i < this.tagList.length; i++) {
            if (this.tagList[i].id === id) {
                index = i;
                break;
            }
        }
        this.tagList.splice(index, 1);
        this.saveTags();
        return true;
    },
    updateTag(id: string, name: string) {
        const idList = this.tagList.map(item => item.id);
        if (idList.indexOf(id) >= 0) {
            const names = this.tagList.map(item => item.name);
            if (names.indexOf(name) >= 0) {
                return 'duplicated';
            } else {
                const tag = this.tagList.filter(item => item.id === id)[0];
                tag.name = name;
                this.saveTags();
                return 'success';
            }
        } else {
            return 'not found';
        }
    },
    saveTags() {
        window.localStorage.setItem(localStorageKeyName, JSON.stringify(this.tagList));
    }
};

tagStore.fetchTags();

export default tagStore;
  • 解决创建标签的bug:

  • 优化前代码:

//Tags.vue
@Prop() readonly dataSource: string[] | undefined;

create() {
    const name = window.prompt('请输入标签名');
    if (name === '') {
        window.alert('标签名不能为空');
    } else if (this.dataSource) {
        this.$emit('update:dataSource',
            [...this.dataSource, name]);
    }
  • 优化后代码:
//Tags.vue
<template>
    <div class="tags">
        <div class="new">
            <button @click="create">添加标签</button>
        </div>
        <ul class="current">
            <li v-for="tag in tagList" :key="tag.id"
                :class="{selected:selectedTags.indexOf(tag)>=0}"
                @click="toggle(tag)">{{tag.name}}
            </li>
        </ul>
    </div>
</template>

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

    @Component
    export default class Tags extends Vue {
        tagList = store.fetchTags();
        selectedTags: string[] = [];

        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:value', this.selectedTags);
        }

        create() {
            const name = window.prompt('请输入标签名');
            if (!name) {
                return window.alert('标签名不能为空');
            }
                store.createTag(name);
            }
    }
</script>

Vuex 全局状态管理

  • state 存放数据的地方
  • mutation 用于写数据的地方,同步数据
  • action 用于调用method,异步操作,发AJAX请求

Vuex的常用方法:

//index.ts
import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const store = new Vuex.Store({
//给它构造参数,常用的是state和mutations
    state:{
        count:0tag:undefined;      
    },
    mutations:{
        addCount(state){
            state.count
        }
    }
})

export default store;
  • 在组件中使用Vuex来读写数据:
//money.vue 
import {Component} from 'vue-property-decorator';
import store from '@/store/index.ts';

@Component({
    components: {Tags, FormItem, Types, NumberPad},
    computed: {
        recordList() {
            return this.$store.state.count;  //对象语法,读数据
        }
    }
    ...
    export default class Money extends Vue {
        created(){
            this.$store.commit('fetchRecords')  //写数据,可以传一个参数payload
        }
        get count(){              
                                            //JS或TS类语法,读数据
        }
        setTag(){
              store.state.tag                //获取commit的返回值,读数据
        }
    
    }
})

Vuex 重构 Money组件

  • 安全删除store/index2.ts,有多个文件用到此文件内容
  • 分别去用到的文件中做暂时修改,赋空值或直接注释掉,标记//TODO,直到yarn serve页面控制台不报错为止,接下来再分别用Vuex重构以上的组件。
  • recordList一开始就有数据,recordList初始化:
//store.index.ts
import Vue from 'vue';
import Vuex from 'vuex';
import clone from '@/lib/clone';

Vue.use(Vuex);

const store = new Vuex.Store({
    state: {
        recordList:[] as RecordItem[]
    },
    mutations: {                    //不需要return
        fetchRecords(state) {       //`recordList`初始化
            state.recordList = JSON.parse(window.localStorage.getItem('recordList') || '[]') as RecordItem[];
        },
        createRecord(state,record){
            const record2: RecordItem = clone(record);
            record2.createdAt = new Date();
            state.recordList.push(record2);
            console.log(state.recordList);
            // TO DO
            // recordStore.saveRecord();
        },
        saveRecord(state) {
            window.localStorage.setItem('recordList', JSON.stringify(state.recordList));
        },
    }
});

export default store;
  • 只需要created的时候,调用fetchRecords即可获取到用户添加的数据
//Money.vue
created(){
    this.$store.commit('fetchRecords')
}
  • 保存用户添加的数据:
//index.ts
...
createRecord(state, record) {
    const record2: RecordItem = clone(record);
    record2.createdAt = new Date();
    state.recordList.push(record2);
    store.commit('saveRecord');      //保存用户添加的数据

Vuex 重构 Tags组件

  • 第一步,读取tag列表:
//Tags.vue
@Component({
    computed: {
        tagList() {
            return this.$store.state.tagList;  
        }
    }
})
  • 第二步,声明tagList,并初始化:
//store/index.ts
const store = new Vuex.Store({
    state: {
        recordList: [] as RecordItem[],
        tagList:[] as Tag[]               //声明tagList
    },
    mutations:{
        fetchTags: function (state) {         //初始化
            return state.tagList = JSON.parse(window.localStorage.getItem('tagList') || '[]');
        }
    }
}  
  • 第三步,使用tagList
//Tags.vue
export default class Tags extends Vue {
    created() {
        this.$store.commit('fetchTags');
    }    
}
  • 第四步,把原来的tagStore中的createTagsaveTags粘贴到index.ts中:
//
const store = new Vuex.Store({
    state: {
        recordList: [] as RecordItem[],
        tagList: [] as Tag[]
    },
    mutations: {
        createTag(state, name: string) {
            const names = state.tagList.map(item => item.name);
            if (names.indexOf(name) >= 0) {
                window.alert('标签名重复了');
                return 'duplicated';
            }
            const id = createId().toString();
            state.tagList.push({id, name: name});
            store.commit('saveTags');
            window.alert('添加成功');
            return 'success';
        },
        saveTags(state) {
            window.localStorage.setItem('tagList',
                JSON.stringify(state.tagList));
        }
    }
}
  • 第五步,在Tags.vue中调用create
create() {
    const name = window.prompt('请输入标签名');
    if (!name) {
        return window.alert('标签名不能为空');
    }
    this.$store.commit('createTag', name);
}

Vuex 重构 Labels组件

@Component({
    components: {Button},
    computed: {
        tags() {              //新增
            return this.$store.state.tagList;
        }
    }
})
export default class Labels extends Vue {
    beforeCreate() {                     //新增fetch,每次点击Labels时都fetch最新
        this.$store.commit('fetchTags');
    }

    createTag() {              //与Money.vue中的create()相同的代码,可以用mixin
        const name = window.prompt('请输入标签名');
        if (!name) {
            return window.alert('标签名不能为空');
        }
        this.$store.commit('createTag', name);
    }
}
  • mixin来优化createtag,把多个组件用到的方法归拢到一个mixins组件,然后用到的时候直接继承:

  • 官方文档为:Vue Class Component

  • 直接把mixin声明为组件

//TagHelper.ts
import Vue from 'vue'
import Component from 'vue-class-component'

@Component
export class TagHelper extends Vue {
    createTag() {
        const name = window.prompt('请输入标签名');
        if (!name) {
            return window.alert('标签名不能为空');
        }
        this.$store.commit('createTag', name);
    }
}
export default TagHelper;
  • labels.vue中引入这个mixin组件:
import {mixins} from 'vue-class-component';
const tagHelper:any = require('@/mixins/TagHelper') ;

@Component({
    components: {Button},
    mixins:[tagHelper],
    computed: {
        tags() {
            return this.$store.state.tagList;
        }
    }
    export default class Labels extends mixins(TagHelper) {
    beforeCreate() {
        this.$store.commit('fetchTags');
    }
}
})
  • Tags.vue中引入这个mixin组件:
<button @click="createTag">添加标签</button>

export default class Tags extends mixins(TagHelper) {
}

Vuex 重构 EditLabel组件

store.commit没有返回值,通过在store上添加一个currentTag来获取commit('setCurrentTag')的返回值

  • 先声明数据类型:
//store/index.ts
type RootState = {
    recordList: RecordItem[],
    tagList: Tag[],
    currentTag?: Tag
}

const store = new Vuex.Store({
    state: {
        recordList: [] ,
        tagList: [] ,
        currentTag:undefined
    } as RootState,
  • 用原生自带的setget,基于多次尝试如下代码无法读取到数据,比如Tags.vue中:
//Tags.vue
//此组件只是没有在下面引用到tagList;其实computed部分可以删掉,然后直接在下面用get tagList就行。
@Component({
    computed: {
        tagList() {
            return this.$store.state.tagList;
        }
    }
})
export default class Tags extends mixins(TagHelper) {
  • 再比如在Labels.vue中也存在这个情况:
//Labels.vue
@Component({
    components: {Button},
    computed: {         //可以删掉
        tags() {        //可以删掉
            return this.$store.state.tagList;
        }
    }
})
export default class Labels extends mixins(TagHelper) {
}
  • 按照上面的思路去改造之前的写法:
//Tag.vue
@Component({})  //删掉之前的computed
//Labels.vue
@Component({
    components: {Button},
})
export default class Labels extends mixins(TagHelper) {
    get tags() {
        return this.$store.state.tagList;  //新增内容
    }
//EditLabel.vue
export default class EditLabel extends Vue {
    get tag() {
        return this.$store.state.currentTag;
    }
//Money.vue
@Component({
    components: {Tags, FormItem, Types, NumberPad},
})
export default class Money extends Vue {
    get recordList() {
        return this.$store.state.recordList;
    }

mutations里面的函数只能接受两个参数

  • 实现修改标签名:
//index.ts
updateTag(state, payload: { id: string, name: string }) {   //只能接受两个参数
    const {id, name} = payload;
    const idList = state.tagList.map(item => item.id);
    if (idList.indexOf(id) >= 0) {
        const names = state.tagList.map(item => item.name);
        if (names.indexOf(name) >= 0) {
            window.alert('标签名重复了')
        } else {
            const tag = state.tagList.filter(item => item.id === id)[0];
            tag.name = name;
            store.commit('saveTags');
        }
    }
},
//EditLabel.vue
update(name: string) {
    if (this.tag) {
        this.$store.commit('updateTag',
            {id:this.tag.id,name});
    }
}
  • 实现删除功能
//index.ts
removeTag(state, id: string) {
    let index = -1;
    for (let i = 0; i < state.tagList.length; i++) {
        if (state.tagList[i].id === id) {
            index = i;
            break;
        }
    }
    state.tagList.splice(index, 1);
    store.commit('saveTags');
},
//EditLabel.vue
remove() {
    if (this.tag) {
        this.$store.commit('removeTag',this.tag.id);
    }
}

Statistics 组件

  • 页面效果图:
110.png

第一个 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

第二个 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:遇到修改代码但未呈现出来的情况,就换个浏览器查看效果。