在Vue的生态圈内提供了一个叫Vuex的库,专门用于状态共享与状态管理,感觉上它就像Flux的复制品。在最初始接触Vuex的时候我并不喜欢它,虽然从概念图谱上来讲很容易理解,但对于Vue它却有着非凡的意义。
开始学习时,即使看着官方提供的中文文档都会感到一脸的茫然,或者是我过于愚钝很难一时间理解它的意义所在,所以就将其弃之一角。使用Vue进行项目开发一段时间后,慢慢碰到以下两种问题:
首先,从前几章中我们已经非常清楚地知道Vue是一种完全基于组件化的可视化开发框架,它的美在于我们会很自然地用设计原则来规划组件粒度与关系,用面向对象的思维来思考与编程。然而事物都有两面性,当我们将视角拉近一点,关注那些流动在组件与组件之间的变量,尤其是那些较为复杂的复合型组件,会发觉父组件与子组件之间、子代组件与其子组件之间的变量维护变得越来越不容易,由于Vue取消了属性的变量同步功能,父子组件之间的变量传递实际上是单向的:当子组件需要对传入的变量进行修改,同时又希望通知父组件传入的变量发生了变化时,则不得不使用事件冒泡来进行这种传递,最后在父组件中使用一个方法来进行重绘或者执行其他相应的行为,此类同质性操作又不能被封装为可重用的代码,这样会不断地加大父组件与子组件之间的耦合度,让它们之间的关系变得错综复杂。
其次,vue-router可能是绝大多数的Vue项目都必不可少的工具库,有了vue-router,我们可以将一个组件视为一个页面来使用。由于组件只维护自身的状态(data),组件创建时或者说进入路由时它们被初始化,切换至其他的组件页时当前页自然要被销毁,从而导致data也随之销毁。页面与页面之间总会产生各种需要的共享变量,如果通过$router.param或者$router.meta来传递是远远不够的,很多情况下不得不采用window来保存一些全局的共享变量(有很多的JavaScript框架或者库都是这样做的)。一旦这样就会陷入了新的困局,Vue是不会维护window的这些共享变量的。对于组件来讲,这些变量都存在于组件作用域以外,组件并不会“多管闲事”替我们托管。那我们就不得不手工来接管这些变量的赋值与读取。然而,只要我们知道一些基本的JS编程规范或者风格规范都会明白这么一条准则:全局变量是毒瘤,是具有极高副作用的。
这样讲可能还不容易理解,还是按照本书的风格以示例说话。举一个经常遇到的例子:用户对象的共享。由于登录逻辑都放在后台,为了减少前后台的请求数量,当页面加载时我们经常通过服务端页面直接将一个用户对象输出为JSON并保存到window内,让客户端代码无须频繁地向服务器获取当前用户的对象数据。以Rails为例,在用户登入成功后在Vue的引导页面中加入下面的代码:
<!DOCTYPE html> <html> <head> <title></title> <script> // 将服务端的@user对象变成浏览器端的current_user window.$data = { current_user : </script> </head> <body> <p> <!--用于挂载vue的元素容器--> </p> </body> </html>
当完成了这一步以后,相当于用windows.$data作为服务端对象到客户端的一个输出出口,将变量从服务端“传递”到客户端,接下来就可以在Vue组件内将这个$data变成Vue的data:
import _ from 'lodash' export default { data { return _.extend({ current_user: { is_auth: false } }, window.$data) }, // ... }
这种做法是一种很常用也很实用的技巧。因为确实可以省去不少在created钩子中用$http调用服务端API的初始化动作。当不使用vue-router的时候,或者说window.$data的变量是完全只读的时候,这种方法是没有副作用的。
然而,只要将window.$data内的对象绑定到不同的自定义组件内,一旦要对window.$data内的变量进行修改,那么你的噩梦就开始了——你会发现所有以对象方式绑定的自定义组件,当对象内的某个属性发生改变时将不会执行自动刷新,所有的计算属性也同时失效!更诡异的是这种情况并不是绝对出现的,当页面元素相对简单的时候一切都显得很正常,一旦页面元素增多,对应的交互操作增多时,这种奇怪的现象就会出现。
例如,对于window.$data.current_user.has_new_mail这个字段,我们定义一个这样的组件:
<template> <p> <span v-if="user.has_new_mail">你有新邮件</span> </p> </template> <script> export default { props: ['user'] } </script>
并且建立这样的一个父组件来进行绑定:
<tempalte> <p> <email-notifier :user="current_user"></email-notifier> </p> </tempalte> <script> import _ from 'lodash' import EmailNotifier from './EmailNotifier' export default { data { return _.extend({ current_user: { is_auth: false, has_new_mail: false } }, window.$data) }, components : { EmailNotifier } } </script>
此时,如果对current_user进行赋值,email-notifier是不会产生任何变化的,交互式刷新完全失败!然而这只是将问题最小化至一个组件中,当有很多组件都在共享这个window.$data.current_user对象时,将会非常难以控制它的改变与DOM的刷新。
这种类似的问题在我没有使用Vuex之前开始不断地在项目中出现,直至我下决心将状态变量交由Vuex来管理,这种变量共享问题才得以真正地解决。
7.1 Vuex的基本结构
Vuex是一个专为Vue.js应用程序开发的状态管理模式。它采用集中式存储来管理应用所有组件的状态,并以相应的规则来保证状态以一种可预测的方式发生变化。Vuex也集成到了Vue的官方调试工具devtools extension中,提供了诸如零配置的time-travel调试、状态快照导入导出等高级调试功能。
这个状态自管理应用包含以下几个部分:
● state——驱动应用的数据源,也就是各种共享变量;
● view——以声明方式将state映射到视图,也就是在<template>模块上引用这些state,让Vuex自动处理重绘;
● actions——响应在view上用户输入导致的状态变化。
以下是一个表示“单向数据流”理念的示意图:
但是,当应用遇到多个组件共享状态时,单向数据流的简洁性很容易被破坏:
● 多个视图依赖于同一状态。
● 来自不同视图的行为需要变更为同一状态。
对于问题一,传参的方法对于多层嵌套的组件将会非常烦琐,并且对于兄弟组件间的状态传递无能为力。对于问题二,我们经常会采用父子组件直接引用,或者通过事件来变更和同步状态的多份副本的方式。以上的这些模式非常脆弱,通常会产生无法维护的代码。
因此,为什么不把组件的共享状态抽取出来,以一个全局单例模式管理呢?在这种模式下,组件树构成了一个巨大的“视图”,不管在树的哪个位置,任何组件都能获取状态或者触发行为!
另外,通过定义和隔离状态管理中的各种概念并强制遵守一定的规则,代码将会变得更结构化且易维护。
安装 Vuex
$ npm i vuex -S
示例说明
此处我们将使用第3章中的手机书店的示例,当我们使用Vuex对此示例进行改写时,你会发现所有的视图模板几乎没有发生任何的改变,我们只将代码内的“状态”提取到一个可以由this.store访问的公共状态对象内,其次我们会将所有的data用计算属性加以取代,当状态发生改变时,Vue的响应式系统依然按照原有的工作方式自动跟踪状态变化并对视图进行自动重绘,而当我们需要对状态进行改变时则是调用store内的Mutation进行的。254
在main.js全局配置文件内引入Vuex并进行基本的配置:
// main.js import Vue from 'vue' import App from './App.vue' import store from './store' new Vue({ el: '#app', store, render: h => h(App) })
这个配置语法相信你现在已经很熟悉了,正如前文中的vue-router、vue-resource和vee-validate等库一样,在全局Vue实例中引入store后,在每个Vue组件实例内,可以用this.$store来引用这个store对象了。
先建立一个store并保存于~/src/store/index.js文件内,具体内容如下:
// store/index.js import Vue from 'vue' import Vuex from 'vuex' // 从环境变量判断当前的运行模式 const debug = process.env.NODE_ENV !== 'production' // 声明引入此库文件的Vue实例使用Vuex插件应用状态管理 // 这样可以从main.js文件内减少对vuex库的依赖 Vue.use(Vuex) // 导出store实例对象 export default new Vuex.Store({ strict:debug, // 设置运行模式 plugin: debug ? [createLogger] : // 调试模式则加入日志插件 });
简言之,store就是Vuex的全局配置,同时也是Vue实例(this)访问Vuex获取状态共享服务的公共程序调用入口。Store的定义与Vue的定义是有很多相似之处的,以下就是一个完整的Store定义的结构(也就是上述代码中传入Vuex.Store(options)方法的options参数):
export default { state: {}, actions: {} getters : {}, mutations: {}, modules: {}, strict: true, plugin }
● state——Vuex store实例的根状态对象,用于定义共享的状态变量,就像Vue实例中的data。
● getters——读取器,外部程序通过它获取变量的具体值,或者在取值前做一些计算(可以认为是store的计算属性)。
● actions——动作,向store发出调用通知,执行本地或者远端的某一个操作(可以理解为store的methods)。
● mutations——修改器,它只用于修改state中定义的状态变量。
● modules——模块,向store注入其他子模块,可以将其他模块以命名空间的方式引用。
● strict——用于设置Vuex的运行模式,true为调试模式,false为生产模式。
● plugin——用于向Vuex加入运行期的插件。
由store的定义其实就可以看出一些结构上的端倪,store可以通过modules的注册形成树状的实例结构。我们必须要先建立这样一个概念,后面的内容就好理解了。modules的具体使用在下文会有详细的交待。
7.2 data的替代者——State和Getter
我们来为图书对象建立一个模块,将其保存于~/src/modules/books.js中。先定义一个state对象作为读取图书类数据的容器,简单地说就是将各个Vue页面实例内凡是用于保存图书数据的data属性一并提取到以下这个文件中,并统一放在state变量内,具体做法如下:
// ~/src/modules/books.js export default { state: { announcements:, promotions:, recommended: } }
由于Mutation是为了改变state内的状态而存在的,为了不引起理解上的混淆,在此暂时先将其放下,后面再对它进行详细的讲解。
我们先从Home.vue文件开始,将原有的代码重构为使用Vuex的方式。从原则上来讲,视图模板是不需要做出任何改动的。我们首先要做的是将从data获取数据改由计算属性去处理。以下是Home.vue原有的data定义:
export default { data { announcements:, promotions:, recommended: }, computed : { promotionCount { return this.promtions.length }, recommendedCount { return this.recommendedCount.length } } // ... 省略 }
接入Vuex并改由计算属性进行处理:
export default { computed: { announcements { return this.$store.state.announcements }, promotions { return this.$store.state.promotions }, recommended { return this.$store.state.recommended }, promotionCount { return this.$store.state.promtions.length }, recommendedCount { return this.$store.state.recommendedCount.length } } }
显然,如果每个使用到图书数据的页面都要这样改写定义的话,代码会变得很啰唆。而且promotionCount和recommendedCount这两个计算属性如果在多个地方使用的话,就要复制。复制是程序员的大忌,好代码绝对不能出现任何的复制!凡是可以被复制的代码就说明其可以被封装且有重用的需要,因此我们可以采用另一个方法从state中获取这些变量,这就是Vuex中的读取器(Getter)。我们将上述代码统一封装到图书模块内并以Getter的方式将它们重新暴露出来:
// ~/src/modules/books.js export default { state: { announcements:, promotions:, recommended: }, getters: { announcements: state => state.announcements, promotions: state => state.promotions, recommended: state => state.recommended, totalPromotions: state => state.promotions.length, totalRecommended: state => state.recommended.length } }
然后Home.vue就可以改写为:
export default { computed: { announcements { return this.$store.getters.announcements }, promotions { return this.$store.getters.promotions }, recommended { return this.$store.getters.recommended }, promotionCount { return this.$store.getters.totalPromotions }, recommendedCount { return this.$store.getters.totalRecommended } } }
将从状态值直接读取数据转换成从读取器获取数据看起来似乎没有很大的区别,但只要你细想一下Vue实例中data与computed两者之间的关系,你就能理解了——state相当于store实例的data,而getters就相当于store实例的computed。
进一步用Vuex提供的帮助方法mapGetters简化上述代码:
import {mapGetters} from 'vuex' export default { computed: { ...mapGetters([ 'announcements', 'promotions', 'recommended', 'promotionCount', 'recommendedCount' ]) } }
mapGetters本质上就是动态方法生成器,作用就是生成上面那些将store.getter方法映射为Vue实例的computed。
上述代码中采用了ES6 stage-3阶段中的对象展开符,如果你学过Python,它的作用就相当于**object,也就是将一个对象进行解体并展开为多个方法。
这只是为了能让你了解Getter存在的意义,才在此例中有意地写出几个毫无意义的Getter。如果这些Getter只是单纯地返回状态值的话,我们可以不定义Getter,而使用mapState帮助方法将所有状态直接映射为计算属性:
export default { state: { announcements:, promotions:, recommended: }, getters: { totalPromotions: state => state.promotions.length, totalRecommended: state => state.recommended.length } }
Home.vue应简化为:
import {mapGetters} from 'vuex' import _ from 'lodash' export default { computed: { ...mapState([ 'announcements', 'promotions', 'recommended']), ...mapGetters([ 'promotionCount', 'recommendedCount' ]) } }
7.3 测试Getter
如果你的Getter带有复杂计算,那么测试它们是值得的。Getter本质上只是一个普通的JS函数,并不需要做什么特殊的配置,就当作普通的单元测试来写就可以了。
以下的Getter用于从状态变量数组中筛选出指定类别的图书:
// getters.js export const getters = { filteredProducts (state, {filterCategory}) { return state.products.filter(product => { return product.category === filterCategory }) } }
单元测试:
// test/unit/store/book-getters.spec.js import { getters } from './getters' describe('getters', => { it('filteredProducts', => { // 准备仿真的状态数据 const state = { products: [ { id: 1, title: '揭开数据真相:从小白到数据分析达人', category: '大数据' }, { id: 2, title: '淘宝天猫电商运营与数据化选品完全手册', category: '电商运营' }, { id: 3, title: '大数据架构详解:从数据获取到深度学习', category: '大数据' } ] } const filterCategory = '大数据' // 直接调用getter const result = getters.filteredProducts(state, { filterCategory }) // 对结果进行深度断言 expect(result).to.deep.equal([ { id: 1, title: '揭开数据真相:从小白到数据分析达人', category: '大数据' }, { id: 3, title: '大数据架构详解:从数据获取到深度学习', category: '大数据' } ]) }) })
关于Promise polyfill的问题
加入Vuex后一旦通过Karma启用单元测试,我们会接收到以下的出错信息:
[vuex] vuex requires a Promise polyfill in this browser.
可以安装一个Babel的polyfill库来解决这个问题,先安装babel-polyfill:
$ npm i babel-polyfill -D
然后在Karma.conf.js的file设项内加入以下代码:
config.set({ files: [ '../../node_modules/babel-polyfill/dist/polyfill.js', './index.js'], // ... 省略 })
7.4 Action——操作的执行者
此时你可能会产生这样的疑惑,那应该在哪里使用vue-resource从服务器读取数据并分别写到state的各个属性中呢?首先继续强调的一点是,一旦引入Vuex后,我们的Vue实例不能直接修改$store.state内的任意内容,要修改状态就要通过Mutation来修改。在本例中,在修改这些状态数据之前,我们需要执行与服务器之间的通信。那么在使用Mutation之前,我们得先做一些Action来完成这个需要。
用上文的对应法来理解动作(Action)的话,我们可以将它看作$store的methods。接下来就开始定义从服务器中读取数据的动作(Actions):
// ~/src/modules/books.js import Vue from 'vue' export default { state: { announcements:, promotions:, recommended: }, getters: { totalPromotions: state => state.promotions.length, totalRecommended: state => state.recommended.length }, actions: { getStarted (context) { Vue.http.get('/api/get-start', (res)=>{ context.commit('startedDataReceived',res.body) }) } } }
开始调用vue-resource时,你可能会变得无所适从。在前文中我们都是通过Vue实例的上下文this.$http来获取vue-resource的对象实例,可现在store实例中的this并不是一个Vue实例,所以我们得重新导入Vue对象的引用,直接从Vue.http里获取vue-resource实例:
Vue.http.get('/api/get-start', (res) => { // ... })
Action是不能直接修改state中的状态的,每个Action定义的第一个参数必然是一个与当前store实例结构相同的context对象,这个对象具有以下属性:
● state——等同于store.state,若在模块中则为局部状态。
● rootState——等同于store.state,只存在于模块中。
● commit——等同于store.commit,用于提交一个mutation。
● dispatch——等同于store.dispatch,用于调用其他action。
● getters——等同于store.getters,获取store中的getters。
我们只调用context.commit提交了一个startedDataReceived并传入AJAX调用返回的JSON对象res.body,这里是否有点触发事件的意味?事实正是如此!我们可以将commit方法看作Action的一个执行终点,它只是负责通知Vuex执行完一个动作或者处理完成,这个动作产生的结果就是输入的参数,交由名为startedDataReceived的Mutation继续进行下一步的处理。
如果实际面对的业务很复杂,那么可能一个Action内的操作就不止是像上述代码中单纯地进行远程方法调用了,有可能在这个Action中读取其他state对象作为输入参数,也可能需要执行另外一个Action。简言之,Action除了像Vue实例中的methods,更像是MVC模式中的Controller,唯一的区别只是Action可以执行任何的异步处理,并且它只对状态进行读取而不做出任何修改。
7.5 测试Action
测试Action就有点棘手了,因为它们可能依赖外部API。当测试Action的时候,我们通常需要做某种程度的mocking(模拟)——例如,我们可以把API调用抽象封装到一个服务类,然后在测试中模拟这个服务。为了简单模拟依赖,我们可以使用webpack和inject-loader类打包我们的测试文件。
测试异步Action的例子:
// actions.js import shop from '../api/shop' export const getAllProducts = ({dispatch}) => { dispatch('REQUEST_PRODUCTS') shop.getProducts(products => { dispatch('RECEIVE_PRODUCTS', products) }) } // actions.spec.js // use require syntax for inline loaders. // with inject-loader, this returns a module factory // that allows us to inject mocked dependencies. import { expect } from 'chai' const actionsInjector = require('inject!./actions') // create the module with our mocks const actions = actionsInjector({ '../api/shop': { getProducts (cb) { setTimeout( => { cb([/* mocked response */]) }, 100) } } }) // helper for testing action with expected mutations const testAction = (action, args, state, expectedMutations, done) => { let count = 0 // mock commit const commit = (type, payload) => { const mutation = expectedMutations[count] expect(mutation.type).to.equal(type) if (payload) { expect(mutation.payload).to.deep.equal(payload) } count++ if (count >= expectedMutations.length) { done } } // call the action with mocked store and arguments action({ commit, state }, ...args) // check if no mutations should have been dispatched if (expectedMutations.length === 0) { expect(count).to.equal(0) done } } describe('actions', => { it('getAllProducts', done => { testAction(actions.getAllProducts, , {}, [ { type: 'REQUEST_PRODUCTS' }, {type: 'RECEIVE_PRODUCTS', payload: { /* mocked response */ }} ], done) }) })
7.6 只用Mutation修改状态
当我们从远程数据库读取完数据后就要通知store调用一个Mutation来修改状态,也就是上文中的startedDataReceived,在Action中的写法有点像在Vue实例内调用$emit触发事件。确实从理解上来说,Mutation就像是store内的专属事件,它只能由store本身进行回调,当然也可以通过store.commit方法直接触发一个Mutation。
加入mutations:
// ~/src/modules/books.js import Vue from 'vue' export default { state: { announcements:, promotions:, recommended: }, getters: { totalPromotions: state => state.promotions.length, totalRecommended: state => state.recommended.length }, actions: { getStarted (context) { Vue.http.get('/api/get-start', (res)=>{ context.commit('startedDataReceived',res.body) }) } }, mutations: { startedDataReceived (state,{started_data}) { state.announcements = started_data.announcements state.promotions = started_data.promotions state.recommended = started_data.recommended } } }
因此,在actions的下方我定义了一个名为startedDataReceived的Mutation回调处理方法,使之与getStarted Action内提交的startedDataReceived对应。最后,上述代码中有一个地方是重复性非常强的,就是startedDataReceived的定义与引用,它们的出现频率将是mutation定义的两倍以上,为了避免这种可怕而低级的重复性的出现,我们可以用ES6的常量定义来修改一下上述的代码,直接将Mutation作为一种事件常量来使用:
//~/src/store/mutation-types.js export const STARTED_DATA_RECEIVED = 'startedDataReceived'
改用常量定义:
// ~/src/modules/books.js import Vue from 'vue' import {STARTED_DATA_RECEIVED} from '../mutation-types' export default { state: { // ...省略 }, getters: { // ...省略 }, actions: { getStarted (context) { Vue.http.get('/api/get-start', (res)=>{ context.commit(STARTED_DATA_RECEIVED,res.body) }) } }, mutations: { [STARTED_DATA_RECEIVED] (state,{started_data}) { // ...省略 } } };
最后,回到Home.vue中来调用Action,以初始化共享状态中的数据。Action也是不能直接调用的,它只能通过Vuex的dispatch方法向Vuex发出调用通知,由Vuex来找到这个指定的Action并执行。
import {mapGetters} from 'vuex' export default { computed: { ...mapGetters([ 'announcements', 'promotions', 'recommended', 'promotionCount', 'recommendedCount' ]) }, created { this.$store.dispatch('getStarted') } }
如前文所提及的,Action相当于Vue的methods。同样地,Vuex也提供了将Actions映射为methods的帮助方法mapActions,这样我们就不用直接调用dispatch,而是可以对象化地进行操作了:
import {mapGetters,mapActions} from 'vuex' import _ from 'lodash' export default { computed: { ...mapState([ 'announcements', 'promotions', 'recommended' ]), ...mapGetters([ 'promotionCount', 'recommendedCount' ]) }, methods: { ...mapActions(['getStarted']) }, created { this.getStarted } }
Vue2中的属性绑定都是单向的,也就是说,绑定到组件属性上的变量必须是只读的、不可改变的(Inmutatable),只有这样的变量才没有副作用。
7.7 测试Mutations
Mutations很好测试,因为它们就是仅依赖参数本身的方法。有一个技巧就是,如果用ES2015的模块化并且把Mutation放到store.js文件里,除了默认的export,你还可以把这个Mutation以一个命名参数来“export”。
const state = { ... } // 命名参数export mutation export const mutations = { ... } export default new Vuex.Store({ state, mutations });
使用Mocha + Chai测试一个Mutation的例子:
// mutations.js export const mutations = { increment: state => state.count++ } // mutations.spec.js import { mutations } from './store' // destructure assign mutations const { increment } = mutations describe('mutations', => { it('INCREMENT', => { // mock state const state = { count: 0 } // apply mutation increment(state) // assert result expect(state.count).to.equal(1) }) })
7.8 子状态和模块
前文中我们展示了如何在一个$store实例内直接定义购物车的状态,我们称其为“单一状态树”。此时getter、action和mutation都是围绕购物车的行为及状态修改而定义的。从这个示例就可以全面地了解操作一个“模型(Model)”所需要的最小的Vuex结构是怎样的,下面就全面回顾一下store的全部代码定义:
//~/src/modules/cart.js import Vue from 'vue' import * as types from '../mutation-types' import * as apis from '../apis' export default { state { all: }, getters: { cartItems { return state.all } }, actions: { addToCart (context,{id}) { Vue.http .post(apis.ADD_TO_CART,{id:id}) .then(res => { context.commit(types.ADD_TO_CART,res.body) }) }, getCartItems (context) { Vue.http .get(apis.GET_CART_ITEMS) .then(res => { context.commit(types.SET_CART_ITEMS,res.body.data) }) }, clear (context) { Vue.http .post(apis.CLEAR_CART) .then(res => { context.commit(type.SET_CART_ITEMS,) }) } }, mutations: { [types.SET_CART_ITEMS] (state,{items}) { state.all = items }, [types.ADD_TO_CART](state,{cartItem}) { state.all.push(cartItem) } } }
真正的项目所需要处理的模型远远不止一个,一个商城除了购物车,还需要定义用来存取商品信息的商品(图书)模型;需要用来记录商品SKU的库存模型;需要记录交易信息的订单模型,等等。如果向现在的store中加入对图书商品和订单的状态支持,首先就要向state对象加入两个不同的all状态,用于获取所有的图书与订单。显然这样就会导致命名冲突,那么就不得不将这三个状态用以下的方式来命名:
export default { state { allCartItems:, allBooks:, allOrders: }, // ... 省略 }
而它们的action和mutation也会面对类似的问题。从语言级别上而言,这是没有命令空间所导致的大量不得已的含义性重复,随着项目变得越来越大,这种含义性的重复也将以倍数级别增长,store会变得非常臃肿且难以维护。幸好Vuex提供了“模块”的定义,通过模块,我们可以将各种在功能使用上具有独立性的模型状态,将各自的模型状态作为子状态挂入至store根状态内,以模板名称作为命名空间来使用。这样做的话,操作和变更就被划分到各自模块内,由模块提供的命名空间进行引用,从而使store的结构与代码组织变得清晰和容易理解。
简言之,模块可看作一个独立处理某个业务对象的store,而store.js文件就作为一个文件入口,是所有业务的“根”,就像main.js文件那样用作一种全局性的配置,在运行期组合出真正完整的store对象。要达到这样的效果,就需要对store的工程文件结构进行重整。Vue官方推荐了以下的文件结构的组织方式及使用原则:
(1)应用级的状态集中在store中。
(2)修改状态的唯一方式就是通过提交mutation来实现的,它是同步的事务。
(3)异步逻辑应该封装在action中,并且可以组合action。
只要遵循这些规则,可以任意设计项目结构。如果store文件非常大,直接开始分割action、mutation和getter到多个文件。
对于复杂的应用,我们可能需要使用模块化。下面是一个项目结构:
├── index.html ├── App.vue ├── main.js ├── components │ └── ... └── store ├── index.js # 配置 store 的配置文件 ├── actions.js # 公共根 store 的 actions ├── mutations.js # 公共根 store 的 mutations ├── mutation-types.js # 公共 mutations 名称定义 └── modules # 模块文件夹 ├── cart.js # 模块定义 └── products.js # ...
用以上这种组织型重新改写store和各自的模块,首先入口文件store.js的定义将变成以下的方式:
import Vue from 'vue' import Vuex from 'vuex' import createLogger from 'vuex/dist/logger' import * as actions from './actions' import * as getters from './getters' import cart from './modules/cart' import products from './modules/products' const debug = process.env.NODE_ENV !== 'production' Vue.use(Vuex) export default new Vuex.Store({ modules: { cart, products }, actions: actions, getters: getters, strict: debug, // 设置运行模式 plugin: debug ? [createLogger] : // 调试模式则加入日志插件 });
~/src/store/modules/cart.js就会变为:
import Vue from 'vue' import * as apis from '../apis' import * as types from '../mutation-types' const state = { all: } const mutations = { [types.SET_CART_ITEMS] (state, cart) { state.all = cart.items } } const actions = { getItems (context) { Vue.http.get(apis.GET_CART_ITEMS) .then(res => { context.commit(types.SET_CART_ITEMS,res.body.data) }) } } export default { state, mutations, actions }
~/src/modules/products.js模板的定义如下:
import Vue from 'vue' import * as apis from '../apis' import * as types from '../mutation-types' const state = { announcement: {}, top: , promotions: , recommended: } const mutations = { [types.SET_BOOK] (state, book) { state.book = book }, [types.STARTED_DATA_RECEIVED] (state, {top, announcement, promotions,recommended}) { state.top = top state.announcement = announcement state.promotions = promotions state.recommended = recommended } } const actions = { getBook (context, id) { Vue.http.get(apis.GET_BOOK,{id}).then(res => { context.commit(types.SET_BOOK, res.body) }) }, getStarted (context) { Vue.http.get(apis.GET_STARTED).then(res => { context.commit(types.STARTED_DATA_RECEIVED, res.body) }) } } export default { state, actions, mutations }
当文件结构重新组织和整理后,对cart和product的引用方式也要稍加变化,只需要在对应的state内先加入与模块同名的对象名称作为命名空间就可以直接引用子模块内的对象:
● 引用子状态——this.$store.state.cart.all。
● 引用子读取器——this.$store.getters.cart.allItems;
● 引用子动作——this.$store.actions.cart.getAll;
● 引用子变更——this.$store.mutations.cart[types.SET_BOOK]。
那么子读取器、子动作和子变更也可以这样引用吗?
答案是否定的!事实上,只有子状态可以通过命名空间引用!我认为这是Vuex设计上的一大遗憾!如果我们用调试器观察$store变量内的内容,就会发现这一真相。getters、actions和mutations并没有任何与模块命名相同的对象命名空间存在,也就是说,如果通过this.$store.getters.cart.allItems引用,你将会得到一个undefined的值!allItems仍然被放置于最顶层的getters变量内,这一点你需要清楚地了解,因为这样就会使Action和Getters不得不使用命名前缀来进行区分。
动态注册模块
除了在入口文件store.js中对模块进行注册,在某些应用场景中可能需要向现有的store内注册新的自定义模块,在这种情况下可以使用registerModule方法,以编程方式动态地向store实例注册新的模块:
store.registerModule('books', { // ... })
模块动态注册功能可以让其他Vue插件向已有的store附加新模块,以此来分割Vuex的状态管理。例如,vuex-router-sync插件可以集成vue-router与Vuex,管理动态模块的路由状态。
也可以使用store.unregisterModule(moduleName)动态地卸载模块。注意,不能使用此方法卸载静态模块(在创建store时声明的模块)。
7.9 用服务分离外部操作
当我们开始使用Vuex分离原有大量被掺合在组件当中的各种状态管理之后,会慢慢地觉得Vuex并不是那么难以理解,也会体验到它确实让Vue回归了本原:一个制作界面的框架。在进行Vuex编程的过程中,不知道你是否会发现Actions内有大量相同的或者相似的Vue.http的远程方式调用。它们显得是如此的类似、不雅,甚至是让人感到臃肿。
在实际的Vue项目开发过程中,RESTFul API调用可以说是无处不在,在Vue的生态圈内除了前文中推荐使用的vue-resource,还有其他一些功能类似的AJAX包,可以到https://github.com/vuejs/awesome-vue上找到它们。另外这个Repository集中了所有Vue中各类出色的包,是一个非常值得收藏并参与的资源。
言归正传,对于这些像面条一样揉搓在我们代码中的AJAX调用,我们可以引用Angular中的服务概念来进行重构,虽然Angular的服务是通过反转注入的模式实现的,但模式的过度使用有时并不会减少开发代价,而优秀的思路则不然。因此,我们可以用Vue的方式构建一个服务引用结构来重新将所有的远程方法调用进行有效的重构。
借用Vuex的文件结构组织方式,建立一个独立的services文件夹,独立存放所有的API调用相关的文件:
├── index.html ├── App.vue ├── main.js ├── components └── services ├── index.js # 配置 service 的入口文件 ├── apis.js # API 地址引用 ├── cart.js # 购物车相关的RESTful API ├── product.js # 图书(产品)相关的RESTFul API └── ...
然后构造一个service (index.js)入口对象:
import _ from 'lodash' import Vue from 'vue' import VueResource from 'vue-resource' import product from './product.js' import cart from './cart.js' Vue.use(VueResource) export default { product, cart }
product.js的内容如下:
import Vue from 'vue' import * apis from './apis' export default { get: (id) => Vue.http.get(apis.GET_BOOK,{id}) getStarted: => Vue.http.get(apis.GET_START) }
cart.js的代码如下:
import Vue from 'vue' import * apis from './apis' export default { getAllItems: (id) => Vue.http.get(apis.GET_CARTITEMS) }
~/src/store/modules/cart.js就会变为:
import * as types from '../mutation-types' import services from '../../services' const state = { // ... 省略 } const mutations = { // ... 省略 } const actions = { getItems (context) { services.cart.getAllItems.then(res => { context.commit(types.SET_CART_ITEMS,res.body.data) }) } } export default { state, mutations, actions }
这里采用的是面向对象的一点小技巧,以命名空间的方式有效地重新组织所有API服务,旨在重新合理规划我们的代码,以适应不断膨胀的项目。