21

本文对Vue和Vuex有一定基础的同学更容易掌握,如对Vue和Vuex不是很熟悉的同学,请先移步Vue官网自行学习

在这个教程中,我们会通过构建一个小米便签应用来学习怎么使用Vuex,开始我会简单的介绍Vuex的一些基础内容,什么时候使用以及用Vuex怎么组织代码,然后一步一步的把这些概念应用到小米便签应用里面。

废话不多说,先给大家看一下小米便签应用的截图:

clipboard.png

你可以从GitHub上下载源码,这里是项目源代码的地址和在线预览地址,安装成功后推荐使用chrome的设备模式查看效果更佳。

clipboard.png

Vuex概述

Vuex 是一个专门为 Vue.js 应用所设计的集中式状态管理架构,它借鉴了 Flux 和 Redux 的设计思想,但简化了概念,并且采用了一种为能更好发挥 Vue.js 数据响应机制而专门设计的实现。

如果你不太理解 Vue.js 应用里的状态是什么意思的话,你可以想象一下你此前写的 Vue 组件里面的 data 字段。Vuex 把状态分成组件内部状态和应用级别状态:

  • 组件内部状态:仅在一个组件内使用的状态(data 字段)
  • 应用级别状态:多个组件共用的状态

举个例子:比如说有一个父组件,它有两个子组件。这个父组件可以用 props 向子组件传递数据,这条数据通道很好理解。

那如果这两个子组件相互之间需要共享数据呢?或者子组件需要向父组件传递数据呢?这两个问题在应用体量较小的时候都好解决,只要用自定义事件即可。

但是随着应用规模的扩大:

  • 追踪这些事件越来越难了。这个事件是哪个组件触发的?谁在监听它?
  • 业务逻辑遍布各个组件,导致各种意想不到的问题。
  • 由于要显式地分发和监听事件,父组件和子组件强耦合。

Vuex 要解决的就是这些问题,Vuex 背后有四个核心的概念:

  • State: 包含所有应用级别状态的对象
  • Getters: 在组件内部获取 store 中状态的函数
  • Mutations: 修改状态的事件回调函数
  • Actions: 组件内部用来分发 mutations 事件的函数

下面这张图完美地解释了一个 Vuex 应用内部的数据流动:

clipboard.png

这张图的重点:

数据流动是单向的

  • 组件可以调用 actions
  • Actions 是用来分发 mutations 的
  • 只有 mutations 可以修改状态
  • store 是反应式的,即,状态的变化会在组件内部得到反映

搭建项目

项目结构:

clipboard.png

项目主要文件存放于src目录下:

  • assets/公共图片,css文件
  • components/包含所有组件
  • libs/扩展文件
  • router/路由文件
  • store/vuex相关文件(state,actions,getters,mutation)
  • App.vue根组件
  • main.js应用总入口

新建项目:

使用vue-cli脚手架,可用于快速搭建大型单页应用。该工具为现代化的前端开发工作流提供了开箱即用的构建配置。只需几分钟即可创建并启动一个带热重载、保存时静态检查以及可用于生产环境的构建配置的项目:

# 安装vue
npm install vue
# 全局安装 vue-cli
npm install --global vue-cli
# 创建一个基于 webpack 模板的新项目
vue init webpack notepad-xiaomi
# 安装依赖,走你
cd notepad-xiaomi
# 安装依赖
npm install muse-ui vue-awesome --save
# 安装vuex
npm install vue vuex --save
# 运行
npm run dev

使用vue-cli脚手架创建项目时,一定要安装vue-router插件。
安装依赖后再main.js中引用

clipboard.png

创建Vuex Store

在store文件夹下创建第一个index.js:

import Vue from 'vue'
import Vuex from 'vuex'
import state from './state'
import mutations from './mutation'
import * as getters from './getters'
import * as actions from './actions'

Vue.use(Vuex)

export default new Vuex.Store({
    state,
    mutations,
    getters,
    actions
})

现在我用下面这张图把应用分解成多个组件,并把组件内部需要的数据对应到 store.js 里的 state。

clipboard.png
clipboard.png
clipboard.png
clipboard.png

App根组件,第一幅图中的红色盒子
Header头部组件,第一幅图中的绿色盒子
NoteList列表组件,第一幅图中的橙色盒子
ToolBar工具栏组件,第一幅图中的蓝色盒子(包括删除和移动按钮)
Editor编辑组件,第二幅图,
NoteFolder便签夹组件,第三幅图
TrashHeader废纸篓头部组件,第四幅图蓝色盒子
TrashNoteList废纸篓列表组件,第四幅图灰色盒子
TrashToolBar废纸篓工具栏组件,第四幅图黄色盒子

state.js里面的状态对象会包含所有应用级别的状态,也就是各个组件需要共享的状态。
笔记列表(notes: [])包含了 NodesList 组件要渲染的 notes 对象。当前便签(activeNote: {})则包含当前编辑的便签对象,多个组件都需要这个对象。

聊完了状态state,我们来看看 mutations, 我们要实现的 mutation 方法包括:

  • 添加标签到notes数组中
  • 编辑选中便签
  • 删除便签
  • 便签布局
  • 勾选便签
  • 全部/取消勾选便签
  • 保存便签
  • 勾选废纸篓便签
  • 全部/取消勾选废纸篓便签
  • 恢复废纸篓便签

mutation-types中用于将常量放在单独的文件中,方便协作开发。

export const NEW_NOTE = 'NEW_NOTE'
export const EDIT_NOTE = 'EDIT_NOTE'
export const TOGGLE_NOTE = 'TOGGLE_NOTE'
export const CANCEL_CHECK = 'CANCEL_CHECK'
export const ALL_CHECK = 'ALL_CHECK'
export const DELETE_NOTE = 'DELETE_NOTE'
export const BACK_SAVE = 'BACK_SAVE'
export const TOGGLE_TRASHNOTE = 'TOGGLE_TRASHNOTE'
export const CANCEL_TRASHCHECk = 'CANCEL_TRASHCHECk'
export const ALL_TRASHCHECK = 'ALL_TRASHCHECK'
export const DELETE_TRASHNOTE = 'DELETE_TRASHNOTE'
export const RECOVERY_NOTE = 'RECOVERY_NOTE'

首先,创建一条新的便签,我们需要做的是:

  • 新建一个对象
  • 初始化属性
  • push到state.notes数组中
[types.NEW_NOTE](state) {
    let newNote = {
        id: +new Date(),
        date: new Date().Format('yyyy-MM-dd hh:mm'),
        content: '',
        done: false
    }
    state.notes.push(newNote)
}

然后,编辑便签需要用笔记内容 content 作参数:

[types.EDIT_NOTE](state, note) {
    state.activeNote = note;
}

剩下的这些 mutations 很简单就不一一赘述了。整个 store/mutation.js 如下:

import Format from '../libs/dateFormat'
import * as types from './mutation-types';

const mutations = {
    [types.NEW_NOTE](state) {
        let newNote = {
            id: +new Date(),
            date: new Date().Format('yyyy-MM-dd hh:mm'),
            content: '',
            done: false
        }
        state.notes.push(newNote)
    },
    [types.EDIT_NOTE](state, note) {
        state.activeNote = note;
    },
    [types.TOGGLE_NOTE](state, note) {
        state.notes.map((item, i) => {
            if (item.id == note.id) {
                item.done = !note.done;
            }
        })
        if (note.done) {
            state.deleteNotes.push(note);
        } else {
            state.deleteNotes.splice(state.deleteNotes.indexOf(note), 1);
        }
    },
    [types.CANCEL_CHECK](state) {
        state.notes.map((item, i) => {
            item.done = false;
        })
        state.deleteNotes = [];
        state.isCheck = false;
    },
    [types.ALL_CHECK](state, done) {
        state.deleteNotes = [];
        state.notes.map((item, i) => {
            item.done = done;
            if (done) {
                state.deleteNotes.push(item);
            } else {
                state.deleteNotes = [];
            }
        })
    },
    [types.DELETE_NOTE](state) {
        state.deleteNotes.map((item, i) => {
            item.done = false;
            state.notes.splice(state.notes.indexOf(item), 1);
            state.trashNotes.push(item)
        })
        state.isCheck = false;
        state.deleteNotes = [];
    },
    [types.BACK_SAVE](state, note) {
        if (note.content != '') return;
        state.notes.splice(state.notes.indexOf(note), 1);
    },
    [types.TOGGLE_TRASHNOTE](state, note) {
        state.trashNotes.map((item, i) => {
            if (item.id == note.id) {
                item.done = !note.done;
            }
        })
        if (note.done) {
            state.deleteTrashNotes.push(note);
        } else {
            state.deleteTrashNotes.splice(state.deleteTrashNotes.indexOf(note), 1);
        }
    },
    [types.CANCEL_TRASHCHECk](state) {
        state.trashNotes.map((item, i) => {
            item.done = false;
        })
        state.deleteTrashNotes = [];
        state.isTrashCheck = false;
    },
    [types.ALL_TRASHCHECK](state, done) {
        state.deleteTrashNotes = [];
        state.trashNotes.map((item, i) => {
            item.done = done;
            if (done) {
                state.deleteTrashNotes.push(item);
            } else {
                state.deleteTrashNotes = [];
            }
        })
    },
    [types.DELETE_TRASHNOTE](state) {
        state.deleteTrashNotes.map((item, i) => {
            state.trashNotes.splice(state.trashNotes.indexOf(item), 1);
        })
        state.deleteTrashNotes = [];
        state.isTrashCheck = false;
    },
    [types.RECOVERY_NOTE](state) {
        state.deleteTrashNotes.map((item, i) => {
            item.done = false;
            state.notes.unshift(item)
            state.trashNotes.splice(state.trashNotes.indexOf(item), 1);
        })
        state.deleteTrashNotes = [];
        state.isTrashCheck = false;
    }
}

export default mutations;

接下来聊 actions, actions 是组件内用来分发 mutations 的函数。它们接收 store 作为第一个参数。比方说,当用户点击 Toolbar 组件的添加按钮时,我们想要调用一个能分发NEW_NOTE mutation 的 action。现在我们在 store/文件夹下创建一个 actions.js 并在里面写上 newNote函数:

// 创建新便签
export const newNote = ({ commit }) => {
    commit(types.NEW_NOTE)
}

其他的这些actions都类似,整个store/actions.js如下:

import * as types from './mutation-types';

//创建新便签
export const newNote = ({ commit }) => {
    commit(types.NEW_NOTE)
}

//编辑便签
export const editNote = ({ commit }, note) => {
    commit(types.EDIT_NOTE, note)
}

//勾选便签
export const toggleNote = ({ commit }, note) => {
    commit(types.TOGGLE_NOTE, note)
}

//取消勾选便签
export const cancelCheck = ({ commit }) => {
    commit(types.CANCEL_CHECK)
}

//全部勾选
export const allCheck = ({ commit }, done) => {
    commit(types.ALL_CHECK, done)
}

//删除便签
export const deleteNote = ({ commit }) => {
    commit(types.DELETE_NOTE)
}

//返回自动保存
export const backSave = ({ commit }, note) => {
    commit(types.BACK_SAVE, note)
}

//勾选废纸篓便签
export const toggleTrashNote = ({ commit }, note) => {
    commit(types.TOGGLE_TRASHNOTE, note)
}

//取消勾选废纸篓便签
export const cancelTrashCheck = ({ commit }) => {
    commit(types.CANCEL_TRASHCHECk)
}

//全选废纸篓便签
export const allTrashCheck = ({ commit }, done) => {
    commit(types.ALL_TRASHCHECK, done)
}

//删除废纸篓便签
export const deleteTrashNote = ({ commit }) => {
    commit(types.DELETE_TRASHNOTE)
}

//恢复便签
export const recoveryNote = ({ commit }) => {
    commit(types.RECOVERY_NOTE)
}

最后说一下getters,在Store仓库里,state就是用来存放数据,若是对数据进行处理输出,比如数据要过滤,一般我们可以写到computed中。但是如果很多组件都使用这个过滤后的数据,比如饼状图组件和曲线图组件,我们是否可以把这个数据抽提出来共享?这就是getters存在的意义。我们可以认为,getters是store的计算属性

// 搜索过滤便签
export const filterNote = (state) => {
    if (state.search != '' && state.notes.length > 0) {
        return state.notes.filter(note => note.content.indexOf(state.search) > -1) || {}
    } else {
        return state.notes || {}
    }
}
// 当前编辑的便签
export const activeNote = (state) => {
    return state.activeNote
}
// 便签列表布局
export const layout = state => state.layout
// 便签选中状态
export const isCheck = state => state.isCheck
// 废纸篓便签选中状态
export const isTrashCheck = state => state.isTrashCheck

这样,在 store文件夹里面要写的代码就都写完了。这里面包括了 state.js 中的 state 和 mutation.js中的mutations,以及 actions.js 里面用来分发 mutations 的 actions,和getters.js中的处理输出。

构建Vue组件

最后这个小结,我们来实现四个组件 (App, Header,Toolbar, NoteList 和 Editor) 并学习怎么在这些组件里面获取 Vuex store 里的数据以及调用 actions。

创建根实例 - main.js

main.js是应用的入口文件,里面有根实例,我们要把 Vuex store 加到到这个根实例里面,进而注入到它所有的子组件里面:

// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import App from './App'
import router from './router'
import store from './store/index'

/* 第三方插件 */
import MuseUI from 'muse-ui'
import 'muse-ui/dist/muse-ui.css'
import 'muse-ui/dist/theme-teal.css'
import Icon from 'vue-awesome/components/Icon'
import 'vue-awesome/icons/flag'
import 'vue-awesome/icons'

Vue.use(MuseUI)
Vue.component('icon', Icon);
Vue.config.productionTip = false

/* eslint-disable no-new */
new Vue({
    el: '#app',
    router,
    store,
    components: { App },
    template: '<App/>'
})

App - 根组件

根组件 App 作为总的路由入口:

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

<script>
export default {
  name: 'App'
}
</script>

Notepad

Notepad 组件会 import 其余三个组件:Header,NoteList和ToolBar:

<template>
    <div class="notepad">
        <Header />
        <NoteList />
        <ToolBar />
    </div>
</template>

<script>
import Header from './Header'
import NoteList from './NoteList'
import ToolBar from './ToolBar'
export default {
    name: 'Notepad',
    data () {
        return {
        }
    },
    components:{
        Header,
        NoteList,
        ToolBar,
    }
}
</script>

Header

Header组件提供搜索和便签勾选和取消,并统计勾选数量功能,如图:

clipboard.png

对于Header组件来说,搜索框中输入查询内容时,需要对便签列表中的数据进行过滤,在创建state.js的时候就添加了search字段,用于存储搜索内容,而在getters.js中通过filterNote方法对便签列表进行过滤,筛选出符合条件的便签并返回,这时候我们在NoteList组件中就直接遍历filterNote方法就可以实现搜索功能。

store/getters中实现filterNote方法

// 搜索过滤便签
export const filterNote = (state) => {
    if (state.search != '' && state.notes.length > 0) {
        return state.notes.filter(note => note.content.indexOf(state.search) > -1) || {}
    } else {
        return state.notes || {}
    }
}

NoteList组件中遍历filterNote

<li v-for="note in filterNote" :key="note.id" @mousedown="gtouchstart(note)" @mouseup="gtouchend(note)" @touchstart="loopstart(note)" @touchend="clearLoop">
    <h4>{{note.date}}</h4>
    <p>{{note.content}}</p>
    <mu-checkbox label="" v-model="note.done" class="checkbox" v-show="isCheck"/>
</li>

Header组件:

...mapGetters中的...是es6的扩展运算符,不懂的可以查阅es6文档

<template>
    <header class="header" :class="{visible:isVisible}">
        <mu-flexbox class="headerTool" :class="{visible:isVisible}">
            <mu-flexbox-item order="0" class="flex">
                <mu-raised-button v-if="isCheck" label="取消" @click="cancelCheck" class="raised-button"/>
                <span v-else class="icon" @click="openFolder"><icon name="folder-open"></icon></span>
            </mu-flexbox-item>
            <mu-flexbox-item order="1" class="flex" style="text-align:center">
                <span v-if="isCheck">{{checkTitle}}</span>
                <span v-else>{{title}}</span>
            </mu-flexbox-item>
            <mu-flexbox-item order="2" class="flex" style="text-align:right">
                <mu-raised-button v-if="isCheck" :label="checkBtnTxt" @click="allCheck(!allChecked)" class="raised-button"/>
                <span v-else>
                    <span class="icon" v-if="layout=='grid'" @click="changeLayout"><icon name="list"></icon></span>
                    <span class="icon" v-else @click="changeLayout"><icon name="th-large"></icon></span>
                </span>
            </mu-flexbox-item>
        </mu-flexbox>
        <div class="search">
            <div class="icon"><icon name="search"></icon></div>
            <input type="text" v-model="searchTxt" @keyup="search" @focus="searchFocus" @blur="searchBlur"/>
        </div>
    </header>
</template>

<script>
import { mapActions,mapGetters } from 'vuex'
export default {
    name: 'Header',
    data(){
        return {
            title:'便签',
            checkBtnTxt:'全选',
            searchTxt:'',
            isVisible:false
        }
    },
    computed:{
        ...mapGetters([
            'layout',
            'isCheck'
        ]),
        //获取便签勾选状态
        allChecked(){
            return this.$store.state.notes.every(note => note.done)
        },
        //便签选中数量提示
        checkTitle(){
            return `已选择${this.$store.state.deleteNotes.length}项`
        }
    },
    methods:{
        //显示搜索框
        searchFocus(){
            this.isVisible = true;
        },
        //隐藏搜索框
        searchBlur(){
            this.isVisible = false;
        },
        //搜索
        search(){
            this.$store.state.search = this.searchTxt
        },
        //切换布局
        changeLayout(){
            if(this.$store.state.layout == 'list'){
                this.$store.state.layout = 'grid'
            }else{
                this.$store.state.layout = 'list'
            }
            
        },
        //取消勾选
        cancelCheck(){
            this.$store.dispatch('cancelCheck')
        },
        //全选切换
        allCheck(done){
            this.checkBtnTxt = done?'取消全选':'全选'
            this.$store.dispatch('allCheck',done)
        },
        //打开便签夹
        openFolder(){
            this.$router.push({path:'noteFolder'})
        }
    }
}
</script>

NoteList

NotesList 组件主要有三个功能:

  • 渲染便签列表
  • 对便签进行勾选或取消
  • 点击编辑便签
<template>
    <ul class="noteList" :class="layout">
        <li v-for="note in filterNote" :key="note.id" @mousedown="gtouchstart(note)" @mouseup="gtouchend(note)" @touchstart="loopstart(note)" @touchend="clearLoop">
            <h4>{{note.date}}</h4>
            <p>{{note.content}}</p>
            <mu-checkbox label="" v-model="note.done" class="checkbox" v-show="isCheck"/>
        </li>
    </ul>
</template>

<script>
import { mapGetters,mapActions } from 'vuex'
export default {
    name: 'NoteList',
    data(){
        return {
            timeOutEvent: 0,
            Loop:null
        }
    },
    computed:{
        ...mapGetters([
            'filterNote',
            'layout',
            'isCheck'
        ])
    },
    methods:{
        //编辑&选中
        editNote(note){
            if(this.isCheck){
                this.$store.dispatch('toggleNote',note);
            }else{
                this.$store.dispatch('editNote',note);
                this.$router.push({path:'/editor'})
            }
            
        },
        //鼠标按下,模拟长按事件
        gtouchstart(note){
            var _this = this;
            this.timeOutEvent = setTimeout(function(){
                _this.longPress(note)
            },500);//这里设置定时器,定义长按500毫秒触发长按事件,时间可以自己改,个人感觉500毫秒非常合适
            return false;
        },
        //鼠标放开,模拟长按事件
        gtouchend(note){
            clearTimeout(this.timeOutEvent);//清除定时器
            if(this.timeOutEvent!=0){
                //这里写要执行的内容(尤如onclick事件)
                this.editNote(note);
            }
            return false;
        },
        longPress(note){
            this.timeOutEvent = 0;
            this.$store.state.isCheck = true;
            this.$store.dispatch('toggleNote',note);
        },
        //手按住开始,模拟长按事件
        loopstart(note){
            var _this = this;
            clearInterval(this.Loop);
       this.Loop = setTimeout(function(){
             _this.$store.state.isCheck = true;
                _this.$store.dispatch('toggleNote',note);
      },500);
        },
        //手放开结束,模拟长按事件
        clearLoop(){
            clearTimeout(this.Loop);
        }
    }
}
</script>

ToolBar

Toolbar组件提供给用户三个按钮:创建便签,编辑便签和移动便签(移动便签功能还没有做):

<template>
    <div class="toolBar">
        <div class="toolBtn" v-if="isCheck">
            <span class="icon" @click="deleteNote"><icon name="trash-alt"></icon></span>
            <span class="icon"><icon name="dolly"></icon></span>
        </div>
        <div class="addNote" v-else>
            <div class="float-button mu-float-button" @click="addNote"><icon name="plus"></icon></div>
        </div>
        <mu-dialog :open="dialog" title="删除便签" @close="close">
            您确定删除所选便签吗?
            <mu-flat-button slot="actions" @click="close" primary label="取消"/>
            <mu-flat-button slot="actions" primary @click="deleteConfirm" label="确定"/>
        </mu-dialog>
    </div>
</template>

<script>
import { mapGetters,mapActions } from 'vuex'
export default {
    name: 'ToolBar',
    data(){
        return {
            dialog: false
        }
    },
    computed:{
        ...mapGetters([
            'isCheck'
        ])
    },
    methods:{
        //添加便签
        addNote(){
            this.$store.dispatch('newNote');
            this.$router.push({path:'editor'});
        },
        //删除便签
        deleteNote(){
            this.dialog = true;
        },
        //关闭窗口
        close () {
            this.dialog = false;
        },
        //确定删除
        deleteConfirm(){
            this.dialog = false;
            this.$store.dispatch('deleteNote');
        }
    }
}
</script>

Editor

Editor 组件是最简单的,它只做两件事:

从 store 获取当前笔记activeNote,把它的内容展示在 textarea
在用户更新笔记的时候,调用 editNote() action
以下是完整的 Editor.vue:

<template>
    <div class="edit-panel">
        <div class="edit-tool">
            <span class="back-list" @click="backList"><icon name="angle-left"></icon></span>          
            <span class="date" v-text="activeNote.date"></span>
            <span class="saveNote" v-show="isShow" @click="backList">完成</span>
        </div>
        <textarea v-focus class="edit-area" v-model="activeNote.content" @keyup="editorNote"></textarea>
    </div>
</template>

<script>
import { mapGetters } from 'vuex'
export default {
    name: 'Editor',
    data(){
        return {
            content:'',
            isShow:false
        }
    },
    created(){
        this.content = this.activeNote.content
    },
    computed:{
        //获取正在操作的便签
        ...mapGetters([
            'activeNote'
        ])
    },
    directives:{
        focus:{
            inserted(el){
                el.focus();
            }
        }
    },
    methods:{
        //返回便签列表
        backList(){
            this.$router.push({path:'/'})
            this.$store.dispatch('backSave',this.activeNote)
        },
        //完成按钮显示&隐藏
        editorNote(){
            if(this.content != this.activeNote.content){
                this.isShow = true;
            }else{
                this.isShow = false;
            }
        }
    }
}
</script>

这就是一个小米便签的创建和编辑,还有删除以及废纸篓功能这里就不多说了,功能都很简单不明白的地方可以看源代码,然后自己实战操作一番,如有写的不对的地方大家提出来,互相学习互相帮助嘛,谢谢!

来都来了点一下赞吧,你的赞是对我最大的鼓励^_^


Archer
93 声望5 粉丝