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

288 阅读4分钟

需求描述

  • 平台:移动端优先,布局尽量用 flex 而避免用 fix
  • 记账功能:金额、标签、备注、收入、支出
  • 管理标签:添加、改名、删除
  • 查看历史:按天、按周、按月
  • 持久化:页面关闭再打开,数据不消失(localStorage)
  • 持久化:换手机,数据不消失(需要登录)

重要环节

  • 详细的项目搭建过程:引入SVG、Router、封装工具库
  • 重点功能思路:列表筛选、数据保存、Tab切换

UI 细节:尽量不写宽高,只写max或min,而用内容去撑开,这种思路写出的 UI 响应式

技术栈

Vue2 + TypeScript + Vue Router + Vuex

项目搭建

安装

yarn global add @vue/cli@4.1.2

// 如遇到版本不同的话,就先卸载原版本,再重新安装:
yarn global remove @vue/cli

// Node.js v10 以上版本

配置

// 创建项目(vue-cli脚手架初始化项目)
vue create morney-1  
// 详细配置
Mannully select features
//除E2E Testing外的,都选中
Use class-style component syntax? y
Babel alongside TypeScript? y
Use history mode for router? n //后台有配置的就选y
Pick a CSS pre-processor with dart-sass
Pick a linter/formatter config:ESLint with error prevention only (basic)
Pick additional lint features: lint and fix on commit
Pick a unit testing solution: Jest
where do you prefer placing config for Babel, ESLint ? In dedicated config files
Save this as a preset for future projects? n 

运行

cd morney-1  
yarn serve

校验工具eslint

//vue.config.js
// 可以校验声明的变量是否使用
module.exports = {
    // 关闭 eslint
    lintOnSave:false   
}

Webstrom 配置

  • 模板配置

打开 File > setting ,搜索template,找到File and Code Template,找到对应的Vue Single File Component(Vue单文件组件),

1212_20220728162350.png

修改为项目需求的模板:

<script lang="ts">

<style scoped lang="scss">

修改后,确定!

  • 如果编辑器提示路径下划线,就添加一下配置,下划线提示即消失。打开File > setting ,搜索webpack:

1212111.png

  • webstrom项目文件恢复出厂设置:
git reset --hard HEAD
  • 编辑器代码,自动格式化,可以双击shift键调出快捷键设置列表,reformat Code对应的快捷键为Ctrl+Alt+L,也可以设置为自动加分号:

2222.png

JS或TS里使用@

<script lang="ts">
 // @/ 代表不知道啥路径下的目标文件夹的引入文件
 import x from '@/components/Test.vue'    
</script>

CSS或SCSS里使用@

// scss
$red:#f00;
<style lang="scss" scoped>
@import "~@/assets/styles/test.scss"

底部导航

路由分析

路由就是分发请求的对象;前端路由是键值对:

{
    path: '/money',
    component: Money
}
前端路由模式内容备注
hash模式通过url存路径SEO不友好,服务器收不到 hash
history模式通过url存路径后端将所有前端路由都渲染同一页面
memory模式通过localStorage存路径
CSS预处理器内容备注
LESS
功能更强大
声明变量用@浏览器不识别LESS样式,要通过LESS、less-loader进行处理把less变为css
SCSS
清晰明了,易上手
声明变量用$<style lang="scss" scoped>

路由配置

确定页面 url

  • 初始化组件:在view文件夹中,添加Money、labels、Statistics三个.vue文件
  • 在router文件夹中,写index.ts添加router,配置4个路径对应组件:
//index.ts 配置路由
import Vue from 'vue';
import VueRouter, {RouteConfig} from 'vue-router';
//使用插件
Vue.use(VueRouter);    

//引入路由组件
import Money from '@/views/Money.vue';    
import Labels from '@/views/Labels.vue';
import Statistics from '@/views/Statistics.vue';

//配置路由
const routes: Array<RouteConfig> = [       
{
    //设置根路径
    path: '/', 
    //重定向路径
    redirect:'/money'  
},
{
    path: '/money',
    component: Money
},
{
    path: '/labels',
    component: Labels
},
{
    path: '/statistics',
    component: Statistics
},
]

引入路由

// main.js
import router from '@/router';

new Vue({
// 注册路由。自动添加文件链接import router from './router'  
// 其中router:可以省略,直接写为router
    router:router,  
})

路由属性

路由注册后内容备注
$router属性编程式导航进行路由跳转(pushreplace)
$route属性获取路由信息(路径、query、params)
路由跳转router-link 声明式导航to属性
路由跳转编程式导航(pushreplace)this.$router.push("/search")
路由传参 params属于路径中的一部分,配置路由时需占位
路由传参 query不属于路径中的一部分queryString /home?k=v&kv=

指定渲染区域 <router-view/>

  • 在App组件里暂时用<router-view/>给出router渲染区域,但是路由部分,应该单独放在一个Nav.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>

页面效果:

1255.png

封装导航组件

  • 在components文件夹中,新建Nav.vue文件,用来放路由,而App.vue只保留展示路由的代码
// Nav.vue
<template>
    <div>
      <router-link to="/money">记账</router-link>
      |
      <router-link to="/labels">标签</router-link>
      |
      <router-link to="/statistics">统计</router-link>
    </div>
<template>
// App.vue
<template>
  <div>
    <router-view/> //展示对应路由
  </div>

全局引入导航组件

// main.js
Vue.component('Nav',Nav)  //可以根据webstrom提示自动引入

添加 404 页面

  • 新建 NotFound.vue,并把对应的路由添加到index中
// NotFound.vue
<template>
    <div>
        <div>当前页面不存在,请检查网址是否正确</div>
        <div>
            <router-link to="/">返回首页</router-link>
        </div>
    </div>
</template>
// router/index.ts
{
    path:'*',  //以上路由之外的所有剩余路径
    component: NotFound
}

导航栏样式

可选择的布局有fixed定位flex布局手机上不推荐使用fixed定位,坑很多,相比之下,flex布局会避免那么多的坑。

// Nav.vue
<template>
    <div class="nav">
...    
<style lang="scss" scoped> 
    .nav {
        border: 1px solid red;
    }
</style>
  • flex 布局
<template>
    <div class="nav-wrapper">
    <div class="content">
    Money.vue  
    </div>
    <Nav/>
    </div>
</template>

<style lang="scss" scoped>
    .nav-wrapper{
        border:1px solid green;
        display:flex;
        flex-direction:column;
        height:100vh;    
    }
    .content{
        overflow:auto;
        flex-grow:1;
    }
</style>
// App.vue 全局样式
<style lang="scss">
 *{
     margin:0;
     padding:0;
     box-sizing:border-box;
 }

封装导航组件样式

插槽

以上代码只做了Money的一个布局,点击Labels标签时,如果抄一遍样式代码,就会出现重复写的情况,归纳一下3个文件的重复代码,重叠在了<template><style>两块,所以可以再新增一个存放共同样式的文件Layout.vue

// Layout.vue
<template>
    <div class="nav-wrapper">
        <div class="content">
            ???                //此处占位符,为不同的内容
        </div>
        <Nav/>
    </div>
</template>

<script lang="ts">

    export default {
        name: 'Layout'
    };
</script>

<style lang="scss" scoped>
    .nav-wrapper{
        border: 1px solid green;
        display: flex;
        flex-direction: column;
        height: 100vh;
    }
    .content{
        border: 1px solid blue;
        overflow: auto;
        flex-grow: 1;
    }
</style>
  • 另外需要上述样式代码的文件直接引用,然后把个性化的内容插入到占位符???中,Vue给了一个插槽方法来实现这个功能,先引入全局组件,
//main,js
Vue.component('Layout',Layout)
// Money.vue
<template>
    <Layout>
      <p>Money.vue</p>
    </Layout>
</template>
  • Layout如何获取到页面中?使用插槽<slot></slot>,即通过<slot>来自动获取<Layout>里面的样式内容。可以总结为重复的样式封装到组件中,再把不重复的个性内容,通过插槽来引入。
// Layout.vue
<template>
    <div class="nav-wrapper">
        <div class="content">
            <slot/>           //自动获取`<Layout>`里面的样式内容
        </div>
        <Nav/>
    </div>
</template>
  • Money实现渲染的过程为:先是解析出<Layout>,可以再main.js中找到索引,找到Layout.vue这个文件,它首先是渲染一个<div class="nav-wrapper">,再渲染<div class="content">,接着解析<slot/>,可以把Money.vue里的<Layout>内容<p>Money.vue</p>,直接填到里面.
template>
    <Layout>
      <p>Money.vue</p>
    </Layout>
</template>

引入SVG Icon

  • 使用iconfont.cn,挑选合适的svg图标(svg就是xml文件),在asset文件夹中新建一个icons文件夹,直接粘贴,引入到项目中,
// Nav.vue
<script lang="ts">
    import x from '@/assets/icons/labels.svg';
    console.log(x);
  • 提示如下信息:

44.png

  • 判断为TypeScript的设置问题,搜索TypeScript svg cannot得到解决方法为:
declare module "*.svg" {
  const content: any;
  export default content;
}

并把上面的代码,直接粘贴到views/shims-vue.d.ts,回到编辑器会看到错误提示已消除,然后打印出x,看一下是什么东西?

/img/labels.31bd1e40.svg
  • 还要用到svg-sprite-loader来进一步的引入图标,具体实现的原理是第一步先把svg变成symbol标签,第二部把symbol标签放在svg标签中,再放到body标签中,这样可以通过use标签来引入。
  • 安装svg-sprite-loader
yarn add svg-sprite-loader -D
<template>
    <div class="nav">
        <router-link to="/money">
        <svg>
            <use xlink:href="#labels"></use>
        </svg>
            记账
        </router-link>
  • 以上的方法可以初步实现svg图标的 引入

  • 过程中遇到eslint问题报错:

66.png

  • 解决方法:搜索选中的关键词:

55.png

  • 需要在.eslintrc.js中的 rules 属性新增以下内容,就可以消除错误提示:
rules: {
    '@typescript-eslint/no-var-requires'0
       }

遇到类似上面的ESlint报错问题,有三种解决思路:

  1. 直接把webStrom的错误提示改掉,编辑器右下角有个老头图标,把检查等级改为中级,可以消除;

  2. 根据提示,改代码,去搜索解决方案,回到项目中试;

  3. 根据提示,改配置。

  • 但是上述步骤存在操作比较麻烦,有待封装这些操作的余地。而且每一个icon都要引入,就非常麻烦,怎么能实现批量引入?

引入目录

  • 先来实现批量引入,即import一个目录:
// Nav.vue
// eslint-disable-next-line no-undef
let importAll = (requireContent: __WebpackModuleApi.RequireContext)=>requireContent.keys().forEach(requireContent);
try{
    importAll(require.context('../assets/icons',true,/.svg$/));
}catch(error) {
    console.log(error);        ;
}

封装icon组件

原代码是这样的,既然有重复写代码,就需要封装:

// Nav.vue
<template>
    <div class="nav">
        <router-link to="/money">
            <svg>
                <use xlink:href="#money"/>
            </svg>
            记账
        </router-link>
        |
        <router-link to="/labels">
            <svg>
                <use xlink:href="#labels"/>
            </svg>
            标签
        </router-link>
        |
        <router-link to="/statistics">
            <svg>
                <use xlink:href="#statistics"/>
            </svg>
            统计
        </router-link>
    </div>
</template>
  • 新建一个组件,Icon.vue:
// Icon.vue
<template>
    <svg>
        <use xlink:href="#money"/>
    </svg>
</template>

<script lang="ts">
    // eslint-disable-next-line no-undef
    let importAll = (requireContent: __WebpackModuleApi.RequireContext) => requireContent.keys().forEach(requireContent);
    try {
        importAll(require.context('../assets/icons', true, /.svg$/));
    } catch (error) {
        console.log(error);
    }
    export default {
        name: 'Icon'
    };
</script>
<style scoped lang="scss">
</style>
  • 引入Icon全局组件:
Vue.component('Icon',Icon)
  • 修改Nav中的标签:
<template>
    <div class="nav">
        <router-link to="/money">
            <Icon/>
            记账
        </router-link>
        |
        <router-link to="/labels">
            <Icon/>
            标签
        </router-link>
        |
        <router-link to="/statistics">
            <Icon/>
            统计
        </router-link>
    </div>
</template>
  • 此时显示为三个money的svg,去icon.vue中添加属性:
<script lang="ts">
    let importAll = (requireContent: __WebpackModuleApi.RequireContext) => requireContent.keys().forEach(requireContent);
    try {importAll(require.context('../assets/icons', true, /.svg$/));} catch (error) {console.log(error);}
    export default {
        props:['name'],  //添加的属性name
        name: 'Icon'
    };
</script>
  • 回到Nav.vue中,给每个引入的Icon添加name属性:
// Nav.vue
<template>
    <div class="nav">
        <router-link to="/money">
            <Icon name="money"/>
            记账
        </router-link>
        |
        <router-link to="/labels">
            <Icon name="labels"/>
            标签
        </router-link>
        |
        <router-link to="/statistics">
            <Icon name="statistics"/>
            统计
        </router-link>
    </div>
</template>
  • 绑定事件:
// Icon.vue
<template>
    <svg>
        <use v-bind:xlink:href="#money"/> //语法不通
    </svg>
</template>

-会出现v-bind:xlink:冲突的情况,去搜索一下解决方法,v-bind:简写为: 然后把后面的内容修改如下:

// Icon.vue
<template>
    <svg>
        <use :xlink:href="'#'+name"/>    //修改后
    </svg>
</template>
  • 给icon加样式:
<template>
    <svg class="icon">   //加样式
        <use :xlink:href="'#'+name"/>
    </svg>
</template>

<style scoped lang="scss">
.icon{
    width: 1em;
    height: 1em;
}
</style>
  • 优化调整icon大小,去iconfont.cn搜索帮助文档,帮助--代码应用-搜索svg,找到默认样式:
<style scoped lang="scss">
.icon {
    width: 1em; height: 1em;
    vertical-align: -0.15em;
    fill: currentColor;
    overflow: hidden;
}
</style>
  • 导航栏样式:
// Nav.vue
<template>
    <nav>
        <router-link to="/money" class="item">
            <Icon name="money"/>
            记账
        </router-link>
        <router-link to="/labels" class="item">
            <Icon name="labels"/>
            标签
        </router-link>
        <router-link to="/statistics" class="item">
            <Icon name="statistics"/>
            统计
        </router-link>
    </nav>
</template>

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

<style lang="scss" scoped> //scoped可以精准加样式,给当前div
nav {
    > .item {     // 这种写法为scss语法
    }
}
</style>
  • 进一步优化:
// Nav.vue
<style lang="scss" scoped> 
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;
        }
    }
}
</style>
// App.vue
<style lang="scss">
    *{
        margin: 0;
        padding: 0;
        box-sizing: border-box;
    }
    body{
        line-height: 1.5;
    }
    a{
        text-decoration: none;  //去掉图标下方文字的下划线
        color: inherit;
    }

路由激活 active-class="x"

即点击导航栏图标,图标即高亮显示:在需要高亮显示的标签中,加入属性active-class="x"

<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>
  • 更新viewport标签:
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
  • 设置自动换行代码,双击shift--

77.png

svg的两个坑: .svg文件中含有自带的颜色属性,比如fill="red",怎么做到批量删除呢?

解决方案:使用svgo-loader,复制到vue.config.js文件中

// vue.config.js
.use('svgo-loader').loader('svgo-loader')
.tap(options => ({...options, plugins: [{removeAttrs: {attrs: 'fill'}}]})).end()

安装svgo-loader

yarn add --dev svgo-loader

Money 组件

  • 需求是完成如下页面:
011.png

写 结构

// Money.vue
<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>
            <div class="buttons">
                <button>1</button>
                <button>2</button>
                <button>3</button>
                <button>删除</button>
                <button>4</button>
                <button>5</button>
                <button>6</button>
                <button>清空</button>
                <button>7</button>
                <button>8</button>
                <button>9</button>
                <button>OK</button>
                <button>0</button>
                <button>.</button>
            </div>
        </div>
    </Layout>
</template>

写 样式

总体思路

重置样式reset

// assets/style/reset.scss
*{
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
a{
  text-decoration: none;  //去掉图标下方文字的下划线
  color: inherit;
}

font css跨平台中文字体解决方案

全局样式设置,文字相关的字体和行高之类的,有一行设置代码:

// 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;

引入这个字体设置:

// App.vue
<template>
    <div id="app">
        <router-view/>
    </div>
</template>

<style lang="scss">
    @import "~@/assets/style/helper.scss";
    @import "~@/assets/style/reset.scss";
 body {
   -webkit-font-smoothing: antialiased;  
   -moz-osx-font-smoothing: grayscale;   
        color: #333;
        font-family: $font-hei;
        line-height: 1.5;
    }
</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:orange;

定义局部变量

写 行为

拆分组件 NotesNumberPadTagsTypes

目前结构冗余,需模块化的重构,把对应的功能拆分为4个子组件,再引入子组件:

// Money.vue
<template>
    <Layout class-prefix="layout">
    <!--由于scss样式写的column-reverse布局,所以组件由下往上堆叠-->
      <NumberPad/>
      <Types/>
      <Notes/>
      <Tags/>
    </Layout>
</template>

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

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

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

JS 写子组件 Types

// Types.vue
<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'],  //外部接收的属性
        // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
        data(){         //用来保存当前选中的类型,内部属性
            return{
                type:'-'  //'-'表示支出,'+'表示收入
            }
        },
        // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
        mounted() {
            console.log(this.xxx);
        },
        methods:{            //用于切换 Type 的方法
            // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
            selectType(type){     //type 只能是'-'和'+'中的一个
                if(type!=='-' && type !=='+'){
                    throw new Error('type is unknown')
                }
                this.type= type
            }
        }
    };
</script>

TS 写子组件 Types

用class类、装饰器写组件

// Types.vue
<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'
    
    //自第三方库引入Component装饰器
    import {Component} from 'vue-property-decorator'; 
    
    //声明以下为 TS 写的组件,type='-'会自动处理为data,selectType会自动处理为methods
    //将此装饰器修饰到class上;@Component由vue-class-component官方库提供
    @Component 
    
    // TS 写组件的引入
    export default class Types extends Vue {     
        type = '-'                         
        selectType(type: string){         
            if (type !== '-' && type !== '+'){
                throw new Error('type is unknown')
            }
            this.type = type;
        }
    }
</script>
  • 查看TypeScript最新版本:
npm info typescript version
  • 重启webStrom

File--Invalidate Caches/Restart --Invalidate and Restart

  • TS 声明Props,首先要理解什么是编译时和运行时:

2132.png

@Prop(Number) xxx: number | undefined;
  • 上面代码中,(Number)就是运行时代码,number | undefined就是编译时代码。
<script lang="ts">
    import Vue from 'vue';
    import {Component, Prop} from 'vue-property-decorator';  //自第三方库引入Component装饰器
    //声明以下为 TS 写的组件,type='-'会自动处理为data,selectType会自动处理为methods
    // @Component      //将此装饰器修饰到class上;@Component由vue-class-component官方库提供
    @Component
    export default class Types extends Vue {     // TS 写组件的引入
        type = '-';      //相当于js中的data '-'表示支出,'+'表示收入
        // helloMsg = 'hello,' + this.propMessage;    //官方文档引入props,不能用
        @Prop(Number) xxx: number | undefined   //第三方库引入props,vue-property-decorator
        //@Prop是装饰器,作用是告诉 Vue xxx不是data 而是prop
        // (Number)告诉Vue xxx是个 Number ;
        //number | undefined 就是 TS 的语法 意为 xxx 的类型是number或undefined
        // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
        selectType(type: string) {       //相当于js中的methods;  type: string即为 TS 语法
            if (type !== '-' && type !== '+') {
                throw new Error('type is unknown');
            }
            this.type = type;
        }
        mounted() {
            if (this.xxx === undefined) {
                console.log('没有xxx')
            } else {
                console.log(this.xxx.toString());
            }
        }
    }
</script>

TS 写子组件 NumberPad

  • 完成下面区域的内容:
00.png
  • 页面就只有2层结构,1层是输出框output,另一层是14个按钮button,代码为:
// NumberPad.vue
<template>
    <div class="numberPad">
        <div class="output">{{output}}</div>
        <div class="buttons">
            <button>1</button>
            <button>2</button>
            <button>3</button>
            <button>删除</button>
            <button>4</button>
            <button>5</button>
            <button>6</button>
            <button>清空</button>
            <button>7</button>
            <button>8</button>
            <button>9</button>
            <button>OK</button>
            <button>0</button>
            <button>.</button>
        </div>
    </div>
</template>
  • 首先,确保NumberPad.vue里面的ts代码模板ts引入格式
<script lang="ts">
    //ts行间加分号';'
    import Vue from 'vue';    
    import {Component} from 'vue-property-decorator';
    
    @Component
    export default class NumberPad extends Vue{
    
    }
  • 功能实现。总体的响应是点击14个按钮,都会有对应的响应。分为不同的功能实现:
  1. 数字区0-9,和小数点.要实现点击每个数字显示在输出框中,且数字区的细节流程控制有不可以输入两次0,两次小数点.,还有输出框的最大输出长度;
  2. 删除,实现每次点击,输出框数字都缩进1个数字;
  3. 清空,实现点击后,数据清空显示0
  4. ok,实现记账后,把数据提交到本地存储,功能后面实现。
  • 默认输出框显示的是:字符串0
<script lang="ts">
    import Vue from 'vue';
    import {Component} from 'vue-property-decorator';
    @Component
    export default class NumberPad extends Vue {
        output ='0';
    }
</script>     
  • 元素绑定@click事件:
<template>
    <div class="numberPad">
        <div class="output">{{output}}</div>
        <div class="buttons">
            <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 class="ok">OK</button>
            <button @click="inputContent" class="zero">0</button>
            <button @click="inputContent">.</button>
        </div>
    </div>
</template>
  • inputContent的事件响应:
inputContent = (){
    if(this.output.length === 16)
    {return;}
}
<script lang="ts">
    import Vue from 'vue';
    import {Component} from 'vue-property-decorator';
    @Component
    export default class NumberPad extends Vue {
        output ='0';
        //TS指定事件类型
        inputContent(event:KeyboardEvent){  
            const button = event.target as HTMLButtonElement;
            const input = button.textContent as string;
            //输出达到16个数字的时候就结束函数,不继续执行了
            if(this.output.lenght === 16) {return;}  
            if(this.output === '0'){ 
            }
        }
    }
</script>

Money 组件数据处理

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

收集tags组件的 value

  • 新增标签内容的值:tags组件,怎么知道发生变化了呢?需要增加获取选中tags的东西,监听他的xxx事件,触发xxx事件时,就得到要收集的内容,再把这个内容放在yyy的方法中。
//Money.vue
<template>
    <Layout class-prefix="layout">
        <NumberPad/>
        <Types/>
        <Notes/>
        <Tags :data-source.sync="tags" @xxx="yyy"/>    //监听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/Money/Tags.vue';
    import {Component} from 'vue-property-decorator';

    @Component({
      components: {Tags, Notes, Types, NumberPad}
    })
    export default class Money extends Vue {
        tags = ['餐饮', '购物', '交通', '娱乐', '医疗'];
        yyy() {              //获得内容放在yyy方法中
        }
    };
</script>
  • 何时触发xxx事件呢?是在用户选中tag之后,
//Tags.vue
<script lang="ts">
    import Vue from 'vue';
    import {Component, Prop} from 'vue-property-decorator';

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

        toggle(tag: string) {
            const index = this.selectedTags.indexOf(tag);
            if (index >= 0) {
                this.selectedTags.splice(index, 1);
            } else {
                this.selectedTags.push(tag);
            }
            this.$emit('xxx',this.selectedTags)  //监听xxx事件,就可得到选中标签内容
        }
  • 怎么把内容放在yyy方法中呢?
//Money.vue
export default class Money extends Vue {
    tags = ['餐饮', '购物', '交通', '娱乐', '医疗'];
    yyy(zzz:string) {        
    }
};
  • 这里的xxx事件命名为@update:valueyyy方法命名为onUpdateTagszzz对应value,以上代码替换为:
//Money.vue
<Tags :data-source.sync="tags" @update:value="onUpdateTags"/> 
...
onUpdateTags(value:string) {              //获得内容放在onUpdateTags方法中
        }
//Tags.vue
this.$emit('update:selected',this.selectedTags)

收集其他子组件的 value

  • Types组件的value(用户选中的type值,也就是支出或收入)、Notes组件的value(用户输入的值)

  • 先完成3个组件获取用户输入的value值的事件绑定,及对应的方法:

//Money.vue

//触发update:value事件
<NumberPad @update:value="onUpdateAmount"/>   
<Types @update:value="onUpdateType"/>   
<Notes @update:value="onUpdateNotes"/>   
...
//获得内容放在onUpdateAmount方法中
onUpdateAmount(value:string) {              
}

//获得内容放在onUpdateType方法中
onUpdateType(value:string) {              
}

//获得内容放在onUpdateNotes方法中
onUpdateNotes(value:string) {              
}
  • 再写各组件的触发事件响应:
//Tags.vue
this.$emit('update:value',this.selectedTags)
//Notes.vue
<script lang="ts">
    import Vue from 'vue';
    import {Component, Watch} from 'vue-property-decorator';

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

        @Watch('value')     //Watch适用于发生变化时触发事件
        onValueChange(value: string, oldValue: string) {
            this.$emit('update:value', value);
        }
    }
</script>
//Types.vue
<script lang="ts">
    import Vue from 'vue';
    import {Component, Prop,Watch} from 'vue-property-decorator';  
    @Component
    export default class Types extends Vue {     
        type = '-';      
        @Prop(Number) xxx: number | undefined   
        selectType(type: string) {       
            if (type !== '-' && type !== '+') {
                throw new Error('type is unknown');
            }
            this.type = type;
        }

        @Watch('type')
        onTypeChange(value:string){
            this.$emit('update:value',value)
        }
    }
</script>

value 数组

拿到的四个组件对应的value,并收集到一个对象,收集到的 value 命名为 record,并做类型声明,并把对应的方法输出:

//Money.vue
<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/Money/Tags.vue';
    import {Component} from 'vue-property-decorator';
    
    //TS类型声明只写类型,JS类型声明写具体内容
    type Record = {  
        tags: string[]
        notes: string
        type: string
        amount: number
    }

    @Component(
        {
            components: {Tags, Notes, Types, NumberPad}
        })
    export default class Money extends Vue {
        tags = ['餐饮', '购物', '交通', '娱乐', '医疗'];
        record: Record = {
            tags: [], notes: '', type: '-', amount: 0
        };

        onUpdateAmount(value: string) {
            this.record.amount = parseFloat(value);
        }

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

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

        onUpdateTags(value: string[]) {
            this.record.tags = value;
        }
    };
</script>  
  • 验证代码是否正确,在页面中加一个占位符,并点击对应的按钮,看是否实现了需求:
// Money.vue
<template>
    <Layout class-prefix="layout">
        {{record}}
        <NumberPad @update:value="onUpdateAmount"/>
        <Types @update:value="onUpdateType"/>
        <Notes @update:value="onUpdateNotes"/>
        <Tags :data-source.sync="tags" @update:value="onUpdateTags"/>
    </Layout>
</template>

实现的效果:

225.png

初始值传给使用组件

  • 把初始值传给有需要的四个组件,以Type.vue为例,默认的type为'-'支出,不写在组件自身,而从外部(默认的record初始值)传给Type.vue组件。
// Money.vue
<Types :value="record.value" @update:value="onUpdateType"/>
  • 上一行代码可以用修饰符简写,如果想给组件一个初始值,然后在其更新时又拿到最新的值,就用.sync
<Types :value.sync="record.value" />
  • type支出和收入的类型初始值,由外部传给他,也就不需要在自身组件里,定义这个type,所以删掉以下代码:
//Types.vue
export default class Types extends Vue {     
     type = '-';  // 此行删除

@Prop({default: '-'}) readonly value!: string;     //重新声明原type
selectType(type: string) {       
    if (type !== '-' && type !== '+') {
        throw new Error('type is unknown');
    }
    this.type = type;  //此行删除,已经通过外部属性传默认值,也就不需要改data
    this.$emit('update:value', type);  //新增
}

//已经通过外部属性传默认值,直接通过外部改,也就不需要监听变化了
     @Watch('type')
     onTypeChange(value:string){
     this.$emit('update:value',value)
}   

LocalStorage 本地存储数据

  • 在用户点击ok时,把数据放到LocalStorage,绑定一个@submit事件,并监听,提交时触发saveRecord方法,在NumberPad.vue组件中添加ok的saveRecord方法,此步骤为初步实现记一笔账,点击ok提交后,就把这条账目推到数组中,每次都是保存相同的内存地址,将上一次的覆盖掉,而没有接续排列为一个数组:
// NumberPad.vue
ok() {
    this.$emit('update:value', this.output);
    this.$emit('submit', this.output);
    this.output = '0';
}
//Money.vue
export default class Money extends Vue {
    tags = ['餐饮', '购物', '交通', '娱乐', '医疗'];
    recordList: Record[] = [];    //创建一个recordList,类型是Record数组,默认为空数组
    record: Record = {
        tags: [], notes: '', type: '-', amount: 0
    };
    
saveRecord() {
    this.recordList.push(this.record);  //触发saveRecord方法时,把this.record推到recordList数组里
}

@Watch('recordlist')
onRecordListChange(){
    window.LocalStorage.setItem('recordList',JSON.stringfy(this.recordList));
    // JSON.stringfy() 用于从一个对象解析出字符串
}
  • 用深拷贝的方法实现接续列出数组的每次push数据,把 record 的复制保存到recordlist:
  saveRecord(){
      const record2 = JSON.parse(JSON.stringify(this.record));
      // JSON.parse() 用于从一个字符串中解析出json对象
      this.recordList.push(record2);
  }
  • List初始化时,不能赋值为空数组,要赋值,然后用插入语法在html页面中引入{{recordList}}
recordList: Record[] = JSON.parse(window.localStorage.getItem('recordList') || '[]');
  • 打时间戳:
//Money.vue
type Record = {  //TS类型声明只写类型,JS类型声明写具体内容
    tags: string[]
    notes: string
    type: string
    amount: number    //TS写数据类型
    createdAt: Date  //TS写类或构造函数  
    // createdAt: Date | undefined  可写为 createdAt?: Date
}

saveRecord() {
    const record2: Record = JSON.parse(JSON.stringify(this.record));  //写record2类型
    record2.createdAt = new Date();        //新增
    this.recordList.push(record2);
    console.log(this.recordList);
}

MVC 重构

MVC设计模式,把项目代码在数据层面进行重构,所有数据Model层面的集中到一个文件,所有视图View放在一起。

初始化数据

recordList: Record[] = JSON.parse(window.localStorage.getItem(recordList) || '[]');

更新数据

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

保存数据

@Watch('recordList')
onRecordListChange() {
    window.localStorage.setItem(recordList, JSON.stringify(this.recordList))
}    

JS封装 数据层

// src/model.js
const localStorageKeyName= 'recordList'
const model = {
    //获取数据
    fetch(){   
       return JSON.parse(window.localStorage.getItem(localStorageKeyName) || '[]')
    },
    save(data){
        window.localStorage.setItem(localStorageKeyName, JSON.stringify(this.recordList));
    }
}
export default model
  • 此时直接在Money.vue中的ts引入model.js文件,会出现识别fetch函数的情况,是因为引入语法的问题:
const model = require('@/model.js')  //ts引入js的正确打开方式
const recordList: Record[] = model.fetch()

77.png

  • 应修改导出model.js的原语句为具名导出:
export default model //原来的导出语句
export {model}       //修改为此方式
//Money.vue
const model = require('@/model.js').model; 
const {model} =require('@/model.js'); //这种析构写法也可以
const recordList: Record[] = model.fetch();

TS封装 数据层

  • 直接在原model.js右键,refactor-> rename,取消勾选,改后缀为model.ts,再把Money.vue中的Record重构为RecordItem,最后在src根目录新建一个custom.d.ts,用来自定义全局声明。
//custom.d.ts
type RecordItem = {  //TS类型声明只写类型,JS类型声明写具体内容
    tags: string[]
    notes: string
    type: string
    amount: number
    createdAt: Date
}
//model.ts
save(data: RecordItem[]) {
    window.localStorage.setItem(localStorageKeyName, JSON.stringify(data));
}
  • 重新引用model:
//Money.vue
import  model  from '@/model';
const recordList: RecordItem[] = model.fetch()
//model.ts
const localStorageKeyName = 'recordList';
const model = {
    fetch() {   //获取数据
      return JSON.parse(window.localStorage.getItem(localStorageKeyName) || '[]');
    },
    save(data: RecordItem[]) {
        window.localStorage.setItem(localStorageKeyName, JSON.stringify(data));
    }
};

export default model;
  • fetch的返回值类型定义一下:
//获取数据
fetch() {   
    return JSON.parse(window.localStorage.getItem('localStorageKeyName') || '[]') as RecordItem[];
},

封装的结果为:

//Money.vue
<template>
    <Layout class-prefix="layout">
        {{recordList}}
        <NumberPad @update:value="onUpdateAmount" @submit="saveRecord"/>
        <Types :value.sync="record.value"/>
        <Notes @update:value="onUpdateNotes"/>
        <Tags :data-source.sync="tags" @update:value="onUpdateTags"/>
    </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/Money/Tags.vue';
    import {Component, Watch} from 'vue-property-decorator';
    import model from '@/model';

    const recordList = model.fetch();

    @Component({
        components: {Tags, Notes, Types, NumberPad}
    })
    export default class Money extends Vue {
        tags = ['餐饮', '购物', '交通', '娱乐', '医疗'];
        recordList: RecordItem[] = JSON.parse(window.localStorage.getItem('recordList') || '[]');
        record: RecordItem = {
            tags: [], notes: '', type: '-', amount: 0
        };

        onUpdateAmount(value: string) {
            this.record.amount = parseFloat(value);
        }

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

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

        onUpdateTags(value: string[]) {
            this.record.tags = value;
        }

        saveRecord() {
            const record2: RecordItem = model.clone(this.record);
            record2.createdAt = new Date();
            this.recordList.push(record2);
        }

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

<style lang="scss">
    .layout-content {
        display: flex;
        flex-direction: column-reverse;
    }
</style>
//custom.d.ts
type RecordItem = {  //TS类型声明只写类型,JS类型声明写具体内容
    tags: string[]
    notes: string
    type: string
    amount: number
    createdAt: Date
}
//model.ts
const localStorageKeyName = 'recordList';

const model = {
    clone(data:RecordItem[]| RecordItem) {
        return JSON.parse(JSON.stringify(data));
    },
    fetch() {   //获取数据
        return JSON.parse(window.localStorage.getItem('localStorageKeyName') || '[]') as RecordItem[];
    },
    save(data: RecordItem[]) {
        window.localStorage.setItem('localStorageKeyName', JSON.stringify(data));

    }

};

export default model;

注意的坑

  • 所有model数据内容中涉及到window.localStorage.setItem,第一项参数,要注意不要有引号,应该是默认敲代码时候会自动加,这样会影响后面的开发体验,无法保存当前页面数据。应都改为如下代码:
//model.ts
fetch() {   
  return JSON.parse(window.localStorage.getItem('localStorageKeyName') || '[]') as RecordItem[];  //此行为敲代码默认写法,是错误的
  return JSON.parse(window.localStorage.getItem(localStorageKeyName) || '[]') as RecordItem[];  //此行为正确写法,去掉引号
    },
  save(data: RecordItem[]) {
    // 错误写法
    window.localStorage.setItem('localStorageKeyName', JSON.stringify(data)); 
    // 正确写法,去掉引号
    window.localStorage.setItem(localStorageKeyName, JSON.stringify(data)); 
    }