【项目-喵内记账-meoney-04】Vue 全局数据管理

大纲链接 §

[toc]


store

知识点

组件间数据通信实现过程

  • 再次封装 recordListModel.ts
  • window 来容纳数据
    • window 来封装 API
    • 消除对 window 的依赖
  • model 融合进 store
  • 修复 Tags.vuebug
  • storebug 之值与地址
  • 小技巧:把 store2 变成 this.$store2(不用跟着做)
  • 全局状态管理的优点
  • 再次封装 recordListModel.ts

解决bug

  • 标签组件和记账组件不共享标签数据
    • 在记账组件中新增标签的数据不会同步到标签组件中
    • 切换标签组件和记账组件时,新增标签的数据丢失
    • 没有统一的组件间数据通信管理

Vuex 初体验 - 数据读写

  • Money.vue 中使用 Vuex
  • 重构 Tags.vueLabels.vue
  • 在 TS 里使用 mixin
  • 重构 EditLabel.vue
  • 在 TS 里使用 computed 要用 getter 语法
  • 继续重构 EditLabel.vue
  • Vuex 小结

抽象一个克隆数据函数模块src/lib/clone.ts

  • recordListModel.clone()功能可复用,仅数据深拷贝
1
2
3
4
5
6
function clone(data: any) {
  return JSON.parse(JSON.stringify(data)) as RecordItem;
}

export default clone;


再次封装 recordListModel.ts

  • 数据变量data: [] as RecordItem[]
  • 获取数据fetchRecord() {... return this.data}
  • 保存数据saveRecord(){window.localStorage.setItem(localStorageKeyName, JSON.stringify(this.data));}
  • 创建一个数据项目createItem(record: RecordItem) {...}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import clone from '@/lib/clone';

const localStorageKeyName = 'recordList';
const recordListModel = {
  data: [] as RecordItem[],
  createItem(record: RecordItem) {
    const clonedRecord = clone(record);
    clonedRecord.createdAt = new Date();
    this.data.push(clonedRecord);
  },
  fetchRecord() {
    this.data = JSON.parse(
      window.localStorage
        .getItem(localStorageKeyName) ?? '[]') as RecordItem[];
    return this.data;
  },
  saveRecord() {
    window.localStorage.setItem(localStorageKeyName,
      JSON.stringify(this.data));
  }
};
export default recordListModel;

对应修改Money.vue的 API,引入recordListModel.ts并调用方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<template>
  <Layout class-prefix="layout">
    <Tags :data-source.sync="tags"
          @update:selectedTags="pickTags"/>
    <FormItem class="form-item"
              field-name="备注"
              placeholder="在这里输入备注"
              @update:inputValue="onUpdateTips"/>
    <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 FormItem from '@/components/Money/FormItem.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';
import {Component, Watch} from 'vue-property-decorator';
import recordListModel from '@/models/recordListModel.ts';
import tagListModel from '@/models/tagListModel';

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

export default class Money extends Vue {
  tags = tagListModel.fetch();
  recordList = recordListModel.fetchRecord();
  record: RecordItem = {
    tags: [],
    tips: '',
    type: '-',
    amount: 0,
    createdAt: new Date(),
  };

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

  onUpdateTips(value: string) {
    this.record.tips = value;
  }

  saveRecord() {
    recordListModel.createItem(this.record);
  }

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

如何解决Money.vue中对于数据tagsrecordList各自获取数据时, 重复调用相同功能 的API

同样地,Labels.vueEditLabel.vue中也如此,优化逻辑,避免每次都fetch,就clone(JSON.parse)一次数据

window 来容纳数据

Money.vueLabels.vueEditLabel.vue的 入口层main.ts中,统管数据

  • window.tagList = tagListModel.fetch(); TS中未声明类型
  • custom.ts中统一声明全局类型
    • 将原本在tagListModel.ts中声明的类型一并移至custom.d.ts

custom.d.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type RecordItem = {
  tags: string[];
  tips: string;
  type: string;
  amount: number;
  createdAt?: Date;
}
type Tag = {
  id: string;
  name: string;
}
type TagListModel = {
  data: Tag[];
  fetch: () => Tag[];
  create: (name: string) => 'success' | 'duplicated';
  save: () => void;
  update: (id: string, name: string) => 'success' | 'not found' | 'duplicated';
  remove: (id: string) => boolean;
}

interface Window {
  tagList: Tag[]
}

main.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import Vue from 'vue';
import App from './App.vue';
import './registerServiceWorker';
import router from './router';
import store from './store';
import Nav from '@/components/Nav.vue';
import Layout from '@/components/Layout.vue';
import Icon from '@/components/Icon.vue';
import tagListModel from '@/models/tagListModel.ts';

Vue.config.productionTip = false;

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

window.tagList = tagListModel.fetch();

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app');

  • 统一使用window.tagList = tagListModel.fetch();容纳数据
  • 这样做的好处是,今后不管是属性还是值的类型写错,都可以得到TS报错的提示
  • TS的本质就是用类型去检查代码

window 来封装 API 1

  • Money.vuetags = window.tagList;代替tags = tagListModel.fetch();
  • Labels.vuetags = window.tagList;代替 tagListModel.fetch();tags = tagListModel.data;
  • 读取window.tagList
  • 写入tagListModel.create(name) {...}
  • 统一读取和写入,符合最小知识原则
  • 封装“增” window.createTag = (name: string) => {...};
  • 封装“改” window.updateTag = (id: string, object: Exclude<Tag, 'id'>) => {...};
  • 返回值和之前声明类型相同updateTag: (id: string, name: string) => 'success' | 'not found' | 'duplicated'; 简写为updateTag: TagListModel['update']
  • 封装“查” window.findTag = (id: string) => {...}

重构main.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import Vue from 'vue';
import App from './App.vue';
import './registerServiceWorker';
import router from './router';
import store from './store';
import Nav from '@/components/Nav.vue';
import Layout from '@/components/Layout.vue';
import Icon from '@/components/Icon.vue';
import tagListModel from '@/models/tagListModel.ts';

Vue.config.productionTip = false;

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

window.tagList = tagListModel.fetch();
window.findTag = (id: string) => {
  // return window.tagList.filter(t => t.id === id)[0] || undefined;
  return window.tagList.find(t => t.id === id) || undefined;
};
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);
};

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app');

重构custom.d.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
type RecordItem = {
  tags: string[];
  tips: string;
  type: string;
  amount: number;
  createdAt?: Date;
}
type Tag = {
  id: string;
  name: string;
}
type TagListModel = {
  data: Tag[];
  fetch: () => Tag[];
  create: (name: string) => 'success' | 'duplicated';
  save: () => void;
  update: (id: string, name: string) => 'success' | 'not found' | 'duplicated';
  remove: (id: string) => boolean;
}

interface Window {
  tagList: Tag[];
  findTag: (id: string) => Tag | undefined;
  createTag: (name: string) => void;
  removeTag: (id: string) => boolean;
  updateTag: TagListModel['update'];
}

重构Labels.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
<template>
  <Layout class="labels">
    <ul class="tags">
      <li v-for="tag in tags" :key="tag.id">
        <router-link class="tag" :to="`/labels/edit/${tag.id}`">
          <span class="tag-text">{{ tag.name }}</span>
          <Icon class="tag-icon" name="money_right"/>
        </router-link>
      </li>
    </ul>
    <div class="createTag-wrapper">
      <Button class="createTag" @click.native="createTag">新建标签</Button>
    </div>
  </Layout>
</template>

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

@Component({
  components: {Button}
})

export default class Labels extends Vue {
  tags = window.tagList; // [{id: '1', name: '1'}, {id: '2', name: '2'}]

  createTag() {
    const name = window.prompt('请输入标签名');
    if (name) {
      window.createTag(name);
    } else {
      window.alert('没有输入内容请重新创建标签');
    }
  }
}
</script>
...

重构EditLabel.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
<template>
  <Layout>
    <div class="headerBar">
      <Icon class="left-icon" name="money_right" @click.native="goBack"/>
      <span class="title">编辑标签</span>
    </div>
    <FormItem :inputValue="tag.name"
              @update:inputValue="update"
              class="form-item" field-name="标签名" placeholder="在这里输入标签名"/>
    <div class="button-wrapper">
      <Button @click="remove">删除标签</Button>
    </div>
  </Layout>
</template>

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

@Component({
  components: {Button, FormItem}
})
export default class EditLabel extends Vue {
  tag = window.findTag(this.$route.params.id);

  created() {
    if (!this.tag) {
      return this.$router.replace('/404');
    }
  }

  update(name: string) {
    if (!this.tag) { return; }
    if (name === '') {
      window.alert('标签名不能为空');
      this.$router.back();
      return;
    }
    window.updateTag(this.tag.id, name);
  }

  remove() {
    if (this.tag) {
      if (window.removeTag(this.tag.id)) {
        window.alert(`成功删除标签:${this.tag.name}`);
        this.$router.back();
      }
    }
  }

  goBack() {
    this.$router.back();
  }
}
</script>
...

window 来封装 API 2

  • 同样思路重构main.tsMoney.vuerecordListModel.tscustom.d.ts

main.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// import ...

Vue.config.productionTip = false;

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

// record store
window.recordList = recordListModel.fetchRecord();
window.createRecord = (record: RecordItem) => {
  recordListModel.createItem(record);
};

// tag store
window.tagList = tagListModel.fetch();
window.findTag = (id: string) => {...};
window.createTag = (name: string) => {...};
window.removeTag = (id: string) => {...};
window.updateTag = (id: string, name: string) => {...};

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app');


缺点

  • 全局变量太多,变量可能互相覆盖
  • window 的依赖

消除对 window 的依赖

将变量方法都挂在window.store

  • 需要在custom.d.ts中声明window.store类型

重构custom.d.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// ...
interface Window {
  store: {
    tagList: Tag[];
    findTag: (id: string) => Tag | undefined;
    createTag: (name: string) => void;
    removeTag: (id: string) => boolean;
    updateTag: TagListModel['update'];
    recordList: RecordItem[];
    createRecord: (record: RecordItem) => void;
  }
}

重构main.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// import ...
// ...
window.store = {
  // record store
  recordList: recordListModel.fetchRecord(),
  createRecord(record: RecordItem) {...},
  // tag store
  tagList: tagListModel.fetch(),
  findTag(id: string) {...},
  createTag(name: string) {...},
  removeTag(id: string) {...},
  updateTag(id: string, name: string) {...},
};

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app');

  • 注意改掉箭头函数和this指向

解决依赖 window 将变量挂在src/store/index2.ts

  • 将整个store2对象放到index2.ts里导出
  • 可在同一文件模块中引用数据
  • TS自动推测store2类型

src/store/index2.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import recordListModel from '@/models/recordListModel';
import tagListModel from '@/models/tagListModel';

const store2 = {
  // record store
  recordList: recordListModel.fetchRecord(),
  createRecord: (record: RecordItem) => {
    recordListModel.createItem(record);
  },
  // tag store
  tagList: tagListModel.fetch(),
  findTag(id: string) {
    return this.tagList.find(t => t.id === id) || undefined;
  },
  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 store2;

重构Money.vueLabels.vueEditLabel.vue对应的方法

  • window.相关自定义方法改为store2.

重构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
// import ...;
// import store2 from '@/store/index2';

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

export default class Money extends Vue {
  tags = store2.tagList;
  recordList = store2.recordList;
  record: RecordItem = {
    tags: [],
    tips: '',
    type: '-',
    amount: 0,
    createdAt: new Date(),
  };

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

  onUpdateTips(value: string) {
    this.record.tips = value;
  }

  saveRecord() {
    store2.createRecord(this.record);
  }

}

重构Labels.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// import ...;

@Component({
  components: {Button}
})

export default class Labels extends Vue {
  tags = store2.tagList; 
  createTag() {
    const name = window.prompt('请输入标签名');
    if (name) {
      store2.createTag(name);
    } else {
      window.alert('没有输入内容,请重新创建标签');
    }
  }
}

重构EditLabel.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
// import...;

@Component({
  components: {Button, FormItem}
})

export default class EditLabel extends Vue {
  tag = store2.findTag(this.$route.params.id);

  created() {
    if (!this.tag) {
      return this.$router.replace('/404');
    }
  }

  update(name: string) {
    if (!this.tag) { return; }
    if (name === '') {
      window.alert('标签名不能为空');
      this.$router.back();
      return;
    }
    store2.updateTag(this.tag.id, name);
  }

  remove() {
    if (this.tag) {
      if (store2.removeTag(this.tag.id)) {
        window.alert(`成功删除标签:${this.tag.name}`);
        this.$router.back();
      }
    }
  }

  goBack() {
    this.$router.back();
  }
}


拆分src/store/index2.ts中的store2recordStore.tstagListStore.ts

  • ...扩展原算符 浅拷贝...recordStore,
  • 注意合并话的同名屏蔽问题,重构重命名

src/store/index2.ts

1
2
3
4
5
6
7
8
9
import recordStore from '@/store/recordStore.ts';
import tagStore from '@/store/tagStore.ts';

const store2 = {
  ...recordStore,
  ...tagStore,
};
export default store2;

recordStore.ts

1
2
3
4
5
6
7
8
import recordListModel from '@/models/recordListModel.ts';

export default {
  recordList: recordListModel.fetchRecord(),
  createRecord: (record: RecordItem) => {
    recordListModel.createItem(record);
  }
}

tagListStore.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import tagListModel from '@/models/tagListModel.ts';

export default {
  tagList: tagListModel.fetch(),
  findTag(id: string) {
    return this.tagList.find(t => t.id === id) || undefined;
  },
  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);
  },
};

声明tagListModel类型,统一写到custom.d.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ...
type TagListModel = {
  tagList: Tag[];
  fetchTags: () => Tag[];
  findTag: (id: string) => Tag | undefined;
  createTag: (name: string) => 'success' | 'duplicated';
  saveTags: () => void;
  updateTag: (id: string, name: string) => 'success' | 'not found' | 'duplicated';
  removeTag: (id: string) => boolean;
}
...

import store2...两次是同一个store2吗?

  • 可尝试import两次store 测试console.log(store === store2),结果为true

import中的代码执行了几次

  • index2.ts测试console.log("index2 执行了一次"),查看控制台打印的次数
  • 结果为只执行一次
  • import多处的代码只执行一次

model 融合进 store

modelstore功能重合

  • store多了没必要的中转,比如createRecord: (record: RecordItem) => { recordListModel.createItem(record); }
  • model合并到store

重构recordStore.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import clone from '@/lib/clone';

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

const recordStore = {
  recordList: data,
  fetchRecords() {
    data = JSON.parse(window.localStorage.getItem(localStorageKeyName) ?? '[]') as RecordItem[];
    return data;
  },
  saveRecords() {
    window.localStorage.setItem(localStorageKeyName, JSON.stringify(data));
  },
  createRecord: (record: RecordItem) => {
    const clonedRecord = clone(record);
    clonedRecord.createdAt = new Date();
    data?.push(clonedRecord);
    recordStore.saveRecords();
  }
};

recordStore.fetchRecords();

export default recordStore;

  • 判断是否存在data && data.push(clonedRecord);改为新语法:可选链data?.push(clonedRecord);
  • 在默认导出的匿名对象中export default 不可用this.***()比如this.saveRecord() 在定义对象的过程中立即来使用该对象
  • 需改为具名对象,然后导出改对象
  • 在对象外部指向对象的方法,初始化recordStore.fetchRecord();
  • let data: RecordItem[] | undefined = undefined;recordStore内使用了闭包:函数使用了外部变量
  • 可以消除闭包,直接内部定义recordList: [] as RecordItem[],,不用箭头函数

再次重构recordStore.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 clonedRecord = clone(record);
    clonedRecord.createdAt = new Date();
    this.recordList?.push(clonedRecord);
    recordStore.saveRecords();
  }
};

recordStore.fetchRecords();

export default recordStore;

重构tagListStore.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import createId from '@/lib/createId';

const localStorageKeyName = 'recordList';

const tagStore = {
  tagList: [] as Tag[],
  findTag(id: string) {
    return this.tagList.find(t => t.id === id) || undefined;
  },
  saveTags() {
    localStorage.setItem(localStorageKeyName, JSON.stringify(this.tagList));
  },
  createTag(name: string) {
    const names = this.tagList.map(d => d.name);
    if (names.indexOf(name) >= 0) {
      window.alert('标签名重复了');
      return 'duplicated';
    }
    const id = createId().toString();
    this.tagList.push({id, 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 nameList = this.tagList.map(item => item.name);
      if (nameList.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';
    }
  },
};

export default tagStore;

  • 在定义变量时无法调用正在定义的方法
  • 需在对象外部调用方法
  • 对象的方法可调用对象内部的方法

可以删除src/model,直接用src/store代替


全局管理数据

Tags.vue实现全局管理数据

  • Tags.vuethis.$emit('update:dataSource', [...this.dataSource, name];name产生的bug
  • this.dataSource,即子组件传递事件所必要的外部数据@Prop({required: true}) readonly dataSource!: string[];
  • 来自父组件Money.vue:data-source.sync="tags"
  • Tags.vue新传来的数据[...this.dataSource, name];不能赋值给原来的tags
  • 因为tags = store2.tagList; 传的数据不够精确,不是更新整个数组,而是更新name这一项

修改Tag.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>
  <div class="tags">
    <ul class="current">
      <li v-for="tag in tagList"
          :key="tag.id"
          @click="toggle(tag)"
          :class="{selected: selectedTags.indexOf(tag)>=0 }">
        {{ tag.name }}
      </li>
    </ul>
    <div class="new">
      <button @click="createTag">添加新标签</button>
    </div>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';
import {Component} from 'vue-property-decorator';
import store2 from '@/store/index2';

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

  toggle(tag: Tag) {
    const index = this.selectedTags.map(tag => tag.id).indexOf(tag.id);
    if (index >= 0) {
      this.selectedTags.splice(index, 1);
    } else {
      this.selectedTags.push(tag);
    }
    this.$emit('update:selectedTags', this.selectedTags);
  }

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

  • Tag.vue组件中的this.dataSource通知给父组件的事件改为直接操作store2.ts,使用store2.createTag(name);
  • 把用户输入的name传给store2.ts,做到全局管理数据,代替原来的父子组件间通信
  • 无需@Prop({required: true}) readonly dataSource!: string[];
  • 而使用tagList = store2.fetchTags();
  • 原来所有的dataSource改为tagList
    • 比如<li v-for="tag in tagList" :key="tag.id" @click="toggle(tag)" :class="{selected: selectedTags.indexOf(tag) >= 0}"> {{ tag.name }}</li>

Money.vue中自己到全局store2.ts里取数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<template>
  <div class="tags">
    <ul class="current">
      <li v-for="tag in tagList"
          :key="tag.id"
          @click="toggle(tag)"
          :class="{selected: selectedTags.indexOf(tag)>=0 }">
        {{ tag.name }}
      </li>
    </ul>
    <div class="new">
      <button @click="createTag">添加新标签</button>
    </div>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';
import {Component} from 'vue-property-decorator';
import store2 from '@/store/index2';

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

  toggle(tag: Tag) {
    const index = this.selectedTags.map(tag => tag.id).indexOf(tag.id);
    if (index >= 0) {
      this.selectedTags.splice(index, 1);
    } else {
      this.selectedTags.push(tag);
    }
    this.$emit('update:selectedTags', this.selectedTags);
  }

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

  • <Tags @update:selectedTags="pickTags"/>无需再绑定:data-source="tags"
  • 因此无需数据tags = store2.tagList;
  • 而在Tags.vue中让tagList = store2.fetchTags(); 自己获取数据

数据无需一层一层传递,统一集中处理tagStore.ts中的数据


storebug 之值与地址

  • 原始值类型与引用类型的地址
  • 引用类型变化,个引用其地址的变量也变化
  • 原始值类型赋值的变量,值改变不会影响(同步到)改变量
  • data获取数据store只会在created创建阶段获取一遍,不会监听变更并重新渲染

改为使用computed

  • 同时将声明变量为内部数据,挂在App.vuedata函数上,给vue监听到
  • 可以将值和引用类型的变量都放到computedgettersetter
  • 自定义的store2.ts并没有在一开始就被vue监听到
  • 而使用Vuexsrc/store/index.ts则可以被vue监听到

小技巧:可以把 store2 变成 this.$store2 进行全局访问

  • main.ts中引入store2: import store2 from '@/store/index2.ts'
  • 放到Vue的原型上Vue.prototype.$store2 = store2
  • shims-vue.d.ts中声明接口类型interface Vue {$store: any;}
  • TypeScript增强类型以配合插件使用
  • custom.d.ts中声明模块declare module 'vue/types/vue' {interface Vue {$store2: any;}}
  • 可以全局使用this.$store
  • custom.d.ts中的类型声明(比如type RecordItem)之外的其他(store2)拆分到global.d.ts
  • Vuex就使用了该技巧Vue.use(Vuex);

全局状态管理的优点

全局状态管理(也叫全局数据管理)的好处是什么?

  • 解耦:将所有数据相关的逻辑放入 store(也就是 MVC 中的 Model,换了个名字而已)
  • 数据读写更方便:任何组件不管在哪里,都可以直接读写数据
  • 控制力更强:组件对数据的读写只能使用 store 提供的 API 进行(当然也不排除有猪队友直接对 tagListrecordList 进行 push 等操作,这是没有办法禁止的)

Vuex 跟我们手写的 store 没有本质区别。


Vuex 官方全局状态管理初体验 - 数据读写

  • Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式
  • 多个组件共享状态时,单向数据流 的简洁性很容易被破坏
  • 多个视图 依赖于 同一状态
  • 来自不同视图的行为需要 变更同一状态
  • Vuex就是一个用于管理数据的工具(对象),提供了读/写(增删改查)数据的API

核心概念

  • State 对应 data,用于存放数据
  • Getters 对应 computed,用于监听变更
  • Mutations 对应methods,意为改动、变更,用于写数据,调用同步方法,实时更改数据
  • Actions对应 调用methods,必须用于异步操作,调用Mutations的方法
  • Modules 对应componentsstore 分割成模块module,每个模块拥有自己的 statemutationactiongettermodule

Money.vue 中目前主要使用 VuexStateMutations


Money.vue 中使用 Vuex

  • 使用vue/cli项目初始化时已选
  • 操作src/store/index.ts

state属性

  • 单一状态树:树型结构数据的对象
  • 唯一数据源
  • 处处使用数据

main.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const store = new Vuex.Store({
  state: {
      count: 0,
  },
  mutations: {},
  actions: {},
  modules: {}
});

export default store;

// console.log(store.state.count);
  • 可以全局使用store.state.count
  • main.ts引用了src/store/index.ts
  • state中不必写方法返回一个值,和data的用法不同
  • 注意store不是{state:{...}, mutations: {...}}选项,而是由这个选项构造出的对象,拷贝了所有选项的属性

mutations属性

  • 其中的方法不传改对象本身为this
  • 而将state作为第一个形参this传给方法,显式地传递
  • 在方法中使用数据直接访问state.***
  • 在调用方法时,使用 store.commit('xxx'),以字符串方式将变更的方法名 提交一个操作,并自动传之前定义时传入的state给方法
  • mutations中的方法传第二个参数以及之后传的参数
    • 在 TS 中需声明之后传参的类型
    • store.commit(type, [payload...]);

main.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const store = new Vuex.Store({
  state: () => {
    return {
      count: 0,
    }
  },
  mutations: {
    increaseCount(state) {
      state.count += 1;
    },
    increaseN(state, n) {
      state.count += n;
    },
  },
  actions: {},
  modules: {}
});

export default store;

console.log(store.state.count); // 0
store.commit('increaseCount'); // +1 操作
console.log(store.state.count); // 1
store.commit('increaseN', 10); // +10 操作
console.log(store.state.count); // 11

使用computed: {}

  • 不使用只在初始化创建时使用一次的data,不会自动计算属性的变化,无法更新 UI
  • TS 在装饰器中使用@Compnent({ components: {...}, computed: {...})
  • template中无法直接访问store
  • 需要methods中的方法执行store.commit('increaseCount');作为中转
  • 或者使用$storetemplate中),方法中使用this.$store
    • 原理是Vue.use(Vuex);Vue.prototype(原型),把store绑定到共用属性上 Vue.prototype.$store = userStore
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<template>
  <div>
    count: {{ count }}
    <button @click="add">+1 +5</button>
    <button @click="$store.commit('increaseN', -10)">
      使用$store {{count}} - 10
    </button>
    dataCount不变: {{ dataCount }}
  </div>
</template>

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

@Component({
  components: {/*...*/},
  computed: {
    count() {
      return store.state.count;
    },
  },
})
export default class Money extends Vue {
  dataCount = store.state.count;
  // ...
  add() {
    // 对应 import store from '@/store/index.ts';
    store.commit('increaseCount'); // +1
    
    // 对应 `Vue.use(Vuex);`
    this.$store.commit('increaseN', 5); // +5
  }
}
</script>
// ...
  • 读数据 @Component({..., computed: {xxx() {return ...}, ...}, ... 计算属性
  • 写数据
    • 全局使用:@eventName="$store.commit(...)"/ this.$store.xxx
    • 局部使用:引用store来使用import store from '@/store/index.ts';
  • 注意mutations中的方法可以调用其中的其他方法fn1() {store.commit('fn2');}
    • store代替了this

重构Money.vue

将数据分模块写入index.ts,替换所有store2.ts的代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<template>
  <Layout class-prefix="layout">
    <Tags @update:selectedTags="pickTags"/>
    <FormItem class="form-item" field-name="备注" placeholder="在这里输入备注"
              @update:inputValue="onUpdateTips"/>
    <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 FormItem from '@/components/Money/FormItem.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, FormItem, Tags},
  computed: {
    recordList() {
      return this.$store.state.recordList;
    }
  },
})
export default class Money extends Vue {
  record: RecordItem = {
    tags: [],
    tips: '',
    type: '-',
    amount: 0,
    createdAt: new Date(),
  };

  created() {
    this.$store.commit('fetchRecords');
  }

  onUpdateTips(value: string) {
    this.record.tips = value;
  }
  pickTags(selectedTags: string[]) {
    this.record.tags = selectedTags;
  }

  saveRecord() {
    this.$store.commit('createRecord', this.record);
  }
}
</script>
...
  • 模块的局部状态:对于模块内部的 mutationgetter,接收的第一个参数是 state 模块的局部状态对象

重构 Tags.vueLabels.vue

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
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
<template>
  <div class="tags">
    <ul class="current">
      <li v-for="tag in tagList"
          :key="tag.id"
          @click="toggle(tag)"
          :class="{selected: selectedTags.indexOf(tag)>=0 }">
        {{ tag.name }}
      </li>
    </ul>
    <div class="new">
      <button @click="createTag">添加新标签</button>
    </div>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';
import {Component} from 'vue-property-decorator';

@Component({
  computed: {
    tagList() {
      return this.$store.state.tagList;
    },
  }
})

export default class Tags extends mixins(tagHelper) {
  selectedTags: Tag[] = [];

  created() {
    this.$store.commit('fetchTags');
  }

  toggle(tag: Tag) {
    const index = this.selectedTags.map(i => i.id).indexOf(tag.id);
    if (index >= 0) {
      this.selectedTags.splice(index, 1);
    } else {
      this.selectedTags.push(tag);
    }
    this.$emit('update:selectedTags', this.selectedTags);
  }
  
  createTag() {
    const name = window.prompt('请输入标签名');
    if (!name) {return window.alert('没有输入内容,请重新创建标签');}
    this.$store.commit('createTag', name);
  }
}
</script>
...

Labels.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="labels">
    <ul class="tags">
      <li v-for="tag in tags" :key="tag.id">
        <router-link class="tag" :to="`/labels/edit/${tag.id}`">
          <span class="tag-text">{{ tag.name }}</span>
          <Icon class="tag-icon" name="money_right"/>
        </router-link>
      </li>
    </ul>
    <div class="createTag-wrapper">
      <Button class="createTag" @click.native="createTag">新建标签</Button>
    </div>
  </Layout>
</template>

<script lang="ts">
import Vue from 'vue';
import {Component} from 'vue-property-decorator';
import Button from '@/components/Button.vue';
import tagHelper from '@/mixins/tagHelper.ts';

@Component({
  components: {Button},
  computed: {
    tags() {
      return this.$store.state.tagList;
    }
  }
})

export default class Labels extends mixins(tagHelper) {
  beforeCreate() {
    this.$store.commit('fetchTags');
  }

  createTag() {
    const name = window.prompt('请输入标签名');
    if (!name) {return window.alert('没有输入内容请重新创建标签');}
    this.$store.commit('createTag', name);
  }
}
</script>
...
  • 创建前 钩子beforeCreate() {this.$store.commit('fetchTags');} 获取数据,每次切换标签都触发这个钩子
  • 组件中都有相同的createTag(){...} ,可用mixin来复用代码

在 TS 里使用 mixin

src/mixins/tagHelper.ts 引入 mixin 的实例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import Vue from 'vue';
import Component from 'vue-class-component';

@Component
export default class TagHelper extends Vue {
  createTag() {
    const name = window.prompt('请输入标签名');
    if (!name) {return window.alert('没有输入内容,请重新创建标签');}
    this.$store.commit('createTag', name);
  }
};

重构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
26
27
28
29
30
31
32
33
// ...
<script lang="ts">
import {Component} from 'vue-property-decorator';
import {mixins} from 'vue-class-component';
import tagHelper from '@/mixins/tagHelper.ts';

@Component({
  computed: {
    tagList() {
      return this.$store.state.tagList;
    },
  }
})

export default class Tags extends mixins(tagHelper) {
  selectedTags: Tag[] = [];

  created() {
    this.$store.commit('fetchTags');
  }

  toggle(tag: Tag) {
    const index = this.selectedTags.map(i => i.id).indexOf(tag.id);
    if (index >= 0) {
      this.selectedTags.splice(index, 1);
    } else {
      this.selectedTags.push(tag);
    }
    this.$emit('update:selectedTags', this.selectedTags);
  }
}
</script>
...

重构Labels.vue子组件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//...
<script lang="ts">
import {Component} from 'vue-property-decorator';
import {mixins} from 'vue-class-component';
import Button from '@/components/Button.vue';
import tagHelper from '@/mixins/tagHelper.ts';

@Component({
  components: {Button},
  computed: {
    tags() {
      return this.$store.state.tagList;
    }
  }
})

export default class Labels extends mixins(tagHelper) {
  beforeCreate() {
    this.$store.commit('fetchTags');
  }
}
</script>
//...
  • 一处JS 处处JS
  • 注意必须写全后缀 import tagHelper from '@/mixins/tagHelper.ts';

重构 EditLabel.vue

注意 store.commit 没有返回值

  • 无法通过this.$store.commit('fn')来获取返回值,即使在回调函数fn写了return
  • 因为源码中export interface Commit {(type: string, payload?: any, options?: CommitOptions): void;<P extends Payload>(payloadWithType: P, options?: CommitOptions): void;},所有最后的无返回:void
  • 在所有mutations中的方法都不能写return,写了无意义

state中新增一个数据currentTag: undefined,

  • 通过 currenTag 来获取store.commit('setCurrentTag')的返回值

custom.d.ts中声明所有状态的类型RootState

1
2
3
4
5
6
//...
type RootState = {
  tagList: Tag[],
  recordList: RecordItem[],
  currentTag?: Tag,
}

src/store/index.ts中断言状态类型

1
2
3
4
5
6
7
8
const store = new Vuex.Store({
  state: {
    tagList: [],
    recordList: [],
    currentTag: undefined,
  } as RootState,
//...
})

EditLabel.vue中预想提交'setCurrenTag'方法,获取this.$store.state.currentTag;

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
//...
@Component({
  components: {Button, FormItem},
  computed: {
    tag() {
      return this.$store.state.currentTag;
    }
  }
})
export default class EditLabel extends Vue {
  created() {
    this.tag = this.$store.commit('setCurrentTag', this.$route.params.id);
    if (!this.tag) {
      this.$router.replace('/404');
    }
  }
...
}

src/store/index.tsmutations实现'setCurrenTag'方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
//...
    mutations: {
//...
 /**
  * 设置当前标签
  * @param state: RootState
  * @param id: string
  */
    setCurrentTag(state, id: string) {
      // state.currentTag = state.tagList.find(t => t.id === id);
      state.currentTag = state.tagList.filter(t => t.id === id)[0];
    },
  • src/store/index.ts中,无法获取this.$route.params.id
  • 需有this.$route.params.id的页面(view)中的方法通过commit第二个参数payload传递而得到
  • setCurrentTag方法代替原来的findTag

在 TS 里使用 computed 要用 getter 语法

存在console.log(this.tag),但会报错提示未设置getter setter

  • EditLabel.vue之前使用@component({...tag() {...}})错了
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
//...
@Component({
  components: {Button, FormItem}
})
export default class EditLabel extends Vue {
  get tag() {
    return this.$store.state.currentTag;
  }
  created() {
    this.$store.commit('setCurrentTag', this.$route.params.id);
    if (!this.tag) {
      this.$router.replace('/404');
    }
  }
 //...
}

将之前所有的写法更改


继续重构 EditLabel.vue

src/store/index.tsmutations里面的方法 只能接受两个参数

  • Muation 函数 不可为 async函数, 也不能 使用箭头函数来定义, 因为代码需要运行在重新绑定执行的上下文
  • 重构updateTag()
  • 重构removeTag()
 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
// ...
const store = new Vuex.Store({
  state: {
    tagList: [],
    recordList: [],
    currentTag: undefined,
  } as RootState,
  mutations: {
    // ...
    updateTag(state, tag: { id: string, name: string }) {
      const {id, name} = tag;
      const idList = state.tagList.map(item => item.id);
      if (idList.indexOf(id) >= 0) {
        const nameList = state.tagList.map(item => item.name);
        if (nameList.indexOf(name) >= 0) {
          window.alert('标签名重复类');
        } else {
          const tagItem = state.tagList.filter(item => item.id === id)[0];
          tagItem.name = name;
          store.commit('saveTags');
        }
      }
    },
//...

EditLabel.vue

  • 注意输入时会触发input
  • FormItem.vue组件中不可同时写@WatchonValueChanged方法监听变化,会触发两遍
  • 重构created
    • 注意setCurrentTag默认tagList是存在的 直接赋值state.currentTag = state.tagList.filter(t => t.id === id)[0];
    • 取到后需要获取当前新的tagList,需要this.$store.commit('fetchTags');
  • 重构update
  • 重构remove
 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
//...
@Component({
  components: {Button, FormItem}
})
export default class EditLabel extends Vue {
  get tag() {
    return this.$store.state.currentTag;
  }

  created() {
    this.$store.commit('fetchTags');
    this.$store.commit('setCurrentTag', this.$route.params.id);
    if (!this.tag) {
      this.$router.replace('/404');
    }
  }

  update(name: string) {
    if (!this.tag) { return; }
    if (name === '') {
      window.alert('标签名不能为空');
      this.$router.back();
      return;
    }
    this.$store.commit('updateTag', {id: this.tag.id, name});
  }

  remove() {
    if (this.tag) {
      // TODO : if (store2.removeTag(this.tag.id))
      this.$store.commit('removeTag', this.tag.id);
      window.alert(`成功删除标签:${this.tag.name}`);
      this.$router.back();
    }
  }
//...
}
  • 注意如果this.$router.back();放在数据中index.ts
  • 引入router
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import router from '@/router
//...
    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;
        }
      }
      if (index >= 0) {
        state.tagList.splice(index, 1);
        store.commit('saveTags');
        router.back();
      } else {
        window.alert('删除失败');
      }
    },
//...

原来的数据index2.ts可以删除了


Vuex 小结

  • 所有代码
  • 所有 commits
  • Vuex 依赖 Promise,特别是action异步接口

面试问:你是怎么使用 Vuex 的(常用使用套路)

  • 声明const store = new Vuex.Store({/*options*/...})
  • {/*options*/...}包括{state: {...}, mutations: {...}, ...}
  • state 中写表示状态的数据变量
  • mutations 中写表示状态变动的方法
    • mutations 中的方法通过传参state作为代替this指向本状态实例
    • 通过state可访问本实例的状态数据变量进行操作
    • 传且仅可传第二个参数payload表示接受其余参数
    • Muation 函数 不可为 async函数, 也不能 使用箭头函数来定义, 因为代码需要运行在重新绑定执行的上下文
  • 在组件中调用状态数据的方法(读数据)this.$store.state
    • 在入口main.js/ts中注册了全局可访问的new Vue({router, store, render...}).$mount(...)
    • src/store/index.tsVue.use(Vuex);
  • 在组件中调用状态变更的方法(写数据)this.$store.commit('methodInMutations')
    • 传且仅可传第二个参数payload表示其他参数(可用对象存多个变量,)类似Array.prototype.apply
  • 导出默认export default store

其他注意

  • 使用TS的computed是使用gettersetter函数

扩展功能 保存 vue-router 数据