Vue进阶之使用命名空间更优雅地规划store

时隔大半年,又回到 Vue 的怀抱。自以为对 Vue 还是有一定理解,但短短的两周的项目实践,让我还是有新的收获:使用命名空间规划设计 store,真是活到老,学到老。本文将基于 Vuex 探讨如何更优雅地规划设计 store。

1. 复习 Vuex 的基本使用

在 Vuex 中,一个 store 实例,由四个部分组成:

  • state:保存应用状态
  • getter:把状态挂载到特定的组件上供组件使用
  • mutation:用于修改应用状态
  • action:mutation 都是同步操作,引入了 action 处理异步改变应用状态的场景

1.1 mapState & getter

先看第一个问题:store 中保存的应用状态,如何挂载到组件上供组件使用?通常是基于 store 中的数据为组件生成计算属性,常见有 3 种方法:

  • 使用 this.$store
  • 通过 Vuex.mapState() 方法
  • 通过 Vuex.mapGetters() 方法
<div id="app">
    <ul><li v-for="item in fruits" :key="item.key">{{item.name}}</li></ul>
    <ul><li v-for="item in persons" :key="item.key">{{item.name}}</li></ul>
    <ul><li v-for="item in persons2" :key="item.key">{{item.name}}</li></ul>
    <ul><li v-for="item in persons3" :key="item.key">{{item.name}}</li></ul>
</div>
<script src="https://unpkg.com/vue@2.5.17/dist/vue.js"></script>
<script src="https://unpkg.com/vuex@3.0.1/dist/vuex.js"></script>
<script>
    Vue.use(Vuex);
    let store = new Vuex.Store({
        state: {
            fruits: [
                {
                    name: '苹果',
                    id: 0
                },
                {
                    name: '香蕉',
                    id: 1
                },
                {
                    name: '西瓜',
                    id: 2
                }
            ],
            persons: [
                {
                    name: '刘能',
                    id: 0
                },
                {
                    name: '赵四',
                    id: 1
                },
                {
                    name: '广坤',
                    id: 2
                }
            ]
        },
        getters: {
            personsGetter(state) {
                return state.persons;
            }
        }
    });

    new Vue({
        el: '#app',
        store,
        computed: {
            // 直接通过 store 实例生成计算属性
            fruits() {
                return this.$store.state.fruits
            },
            // 使用 mapState 生成计算属性。不需要改变键值时使用数组参数
            ...Vuex.mapState([
                'persons'
            ]),
            // 需要改变键时使用对象参数
            ...Vuex.mapState({
                persons2: 'persons'
            }),
            // 通过 getter 生成计算属性
            ...Vuex.mapGetters({
                persons3: 'personsGetter'
            })
        }
    });
</script>

1.2 mutation

上面介绍了如何使用 store 中的状态,接下来的问题就是如何修改 store 了?在 Vuex 中通过 mutation 来修改应用状态,即 store。

<div id="app">
    <dl>
        <dt>{{personLength}}</dt>
        <dd v-for="item in persons" :key="item.key">{{item.name}}</dd>
    </dl>
    <button @click="addPerson">添加</button><button @click="delPerson">删除</button>
</div>
<script src="https://unpkg.com/vue@2.5.17/dist/vue.js"></script>
<script src="https://unpkg.com/vuex@3.0.1/dist/vuex.js"></script>

<script>
    Vue.use(Vuex);
    let store = new Vuex.Store({
        state: {
            persons: [
                {
                    name: '刘能',
                    id: 0
                },
                {
                    name: '赵四',
                    id: 1
                },
                {
                    name: '广坤',
                    id: 2
                }
            ]
        },
        getters: {
            persons(state) {
                return state.persons;
            }
        },
        mutations: {
            addPerson(state) {
                state.persons = state.persons.concat({
                    name: `名称${Date.now()}`,
                    id: state.persons.length + 1
                });
            },
            delPerson(state, delId) {
                state.persons = state.persons.filter(d => d.id !== delId);
            }
        }
    });

    new Vue({
        el: '#app',
        store,
        computed: {
            personLength() {
                return this.persons.length;
            },
            ...Vuex.mapGetters([
                'persons'
            ])
        },
        methods: {
            addPerson() {
                // 提交 mutation 修改 state
                this.$store.commit('addPerson');
            },
            delPerson() {
                // 对象方式提交 mutation
                this.$store.commit({
                    type: 'delPerson',
                    id: this.persons.length--
                });
            }
        }
    });
</script>

关于 mutation 函数的命名,最佳实践是使用字符串常量。这样一来,上面的 mutation 会变成大概下面的样子:

const ADD_PERSON = 'ADD_PERSON';
const DEL_PERSON = 'DEL_PERSON';

// ...

mutations: {
    // 最佳实践: 使用常量替代 Mutation 事件类型
    [ADD_PERSON](state) {
        state.persons = state.persons.concat({
            name: `名称${Date.now()}`,
            id: state.persons.length + 1
        });
    },
    [DEL_PERSON](state, delId) {
        state.persons = state.persons.filter(d => d.id !== delId);
    }
}

// ...

1.3 action

mutation 可以修改 store,但 mutation 中都是同步操作,不能包含异步操作.异步操作交给 action 来处理

<div id="app">
    <dl v-show="personLength > 0">
        <dt>{{personLength}}</dt>
        <dd v-for="item in persons" :key="item.key">{{item.name}}</dd>
    </dl>
    <div v-show="personLength===0">加载中...</div>
</div>
<script src="https://unpkg.com/vue@2.5.17/dist/vue.js"></script>
<script src="https://unpkg.com/vuex@3.0.1/dist/vuex.js"></script>

<script>
    Vue.use(Vuex);

    const SET_QUERY_FLAG = 'SET_QUERY_FLAG';
    const SET_PERSONS = 'SET_PERSONS';

    let store = new Vuex.Store({
        state: {
            querying: false,
            persons: []
        },
        getters: {
            persons(state) {
                return state.persons;
            }
        },
        actions: {
            startQueryPersons({commit}) {
                // 设置loading    
                commit(SET_QUERY_FLAG, true);
            },
            queryPersons({dispatch, commit}) {
                // action 组合,在这里先调用了 startQueryPersons action
                dispatch('startQueryPersons').then(() => {
                    // 异步操作
                    setTimeout(() => {
                        commit(SET_PERSONS, [
                            {
                                name: '刘能',
                                id: 0
                            },
                            {
                                name: '赵四',
                                id: 1
                            },
                            {
                                name: '广坤',
                                id: 2
                            }
                        ])
                    }, 2000);
                });
            }
        },
        mutations: {
            [SET_PERSONS](state, persons) {
                state.persons = persons;
            },
            [SET_QUERY_FLAG](state, flag) {
                state.querying = flag;
            }
        }
    });

    new Vue({
        el: '#app',
        store,
        computed: {
            personLength() {
                return this.persons.length;
            },
            ...Vuex.mapGetters([
                'persons'
            ])
        },
        methods: {
            // 通过 mapActions 可以把 action 映射为方法
            ...Vuex.mapActions({
                doQueryPersons: 'queryPersons'
            })
        },
        created() {
            //this.$store.dispatch('queryPersons'); // 直接 dispatch action
            this.doQueryPersons();
        }
    });
</script>

如上面的代码,action 可以通过 dispatch 直接触发,也可以通过 Vuex.mapActions 变成方法来供组件调用。

2. 为什么 store 需要命名空间

复习完了 Vuex 的使用,我们来看一个真实的应用场景。假设我们要做一个商城应用,有商品列表,订单列表两个功能页。很快,我们实现了功能,代码如下:

<div id="app">
    <dl>
        <dt>商品列表</dt>
        <dd v-for="item in products">{{item.name}}</dd>
    </dl>

    <dl>
        <dt>订单列表</dt>
        <dd v-for="item in orders">{{item.name}}</dd>
    </dl>
</div>
<script src="https://unpkg.com/vue@2.5.17/dist/vue.js"></script>
<script src="https://unpkg.com/vuex@3.0.1/dist/vuex.js"></script>
<script>
    Vue.use(Vuex);

    let store = new Vuex.Store({
        modules: {
            product: {
                state: {
                    list: [
                        {
                            name: '苹果电脑',
                            id: 0
                        },
                        {
                            name: 'IphoneX',
                            id: 1
                        }
                    ]
                },
                getters: {
                    productList(state) {
                        return state.list;
                    }
                },
                actions: {
                    queryList() {
                        console.log('product queryList');
                    }
                },
                mutations: {
                    setList() {
                        console.log('product setList')
                    }
                }
            },
            order: {
                state: {
                    list: [
                        {
                            name: '订单-苹果电脑',
                            id: 0
                        },
                        {
                            name: '订单-IphoneX',
                            id: 1
                        }
                    ]
                },
                getters: {
                    orderList: (state) => state.list
                },
                actions: {
                    queryList() {
                        console.log('order queryList');
                    }
                },
                mutations: {
                    setList() {
                        console.log('order setList')
                    }
                }
            }
        }
    });

    new Vue({
        el: '#app',
        store,
        computed: {
            ...Vuex.mapGetters({
                products: 'productList'
                orders: 'ordersList'
            })
        },
        created() {
            // this.$store.dispatch('queryList'); // product 和 order 中的 queryList 都被调用了
            this.$store.commit('setList'); // product 和 order 中的 setList 都被调用了
        }
    });
</script>

通常,像商城这样中大型项目,都是多人协作共同开发的。多人协作时,最怕的就是代码冲突。上面的 store 设计,有一个商品列表和一个订单列表,它们对应的 getters 分别是 productList 和 orderList。 如果我们定义 mutation-type 的话,大概率会是下面这样:

const ADD_PRODUCT = 'ADD_PRODUCT';
const DEL_PRODUCT = 'DEL_PRODUCT';
const UPDATE_PRODUCT = 'UPDATE_PRODUCT';

const ADD_ORDER = 'ADD_ORDER';
const DEL_ORDER = 'DEL_ORDER';
const UPDATE_ORDER = 'UPDATE_ORDER';

大家应该也注意到了,为了防止冲突,我们为每个 mutation 加了模块后缀,使原本 ADD,DEL 就可以很好表示 mutation 类型的硬生生变成了 ADD_PRODUCT, ADD_ORDER。

当然上面的代码,完全可以实现业务功能。但是在防止冲突或模块解藕上还是差了一步。同样,为了防止 actions 冲突,我们不得不这样来命名 actions:queryProductList, queryOrderList。

getter重复

那么,怎么让各模块的 store 完全解藕,在为 getter, mutation 或 action 命名时不用小心翼翼地加前后缀来防止冲突呢?Vuex 提供了命名空间来解决这个问题。

3. 基于命名空间的 store 设计

同样是上面讲的商城应用,使用了 namespace 的 store 代码大概如下:

<div id="app">
    <dl>
        <dt>商品列表</dt>
        <dd v-for="item in products">{{item.name}}</dd>
    </dl>

    <dl>
        <dt>订单列表</dt>
        <dd v-for="item in orders">{{item.name}}</dd>
    </dl>
</div>
<script src="https://unpkg.com/vue@2.5.17/dist/vue.js"></script>
<script src="https://unpkg.com/vuex@3.0.1/dist/vuex.js"></script>
<script>
    Vue.use(Vuex);
    let store = new Vuex.Store({
        modules: {
            product: {
                namespaced: true, // 添加命名空间
                state: {
                    list: [
                        {
                            name: '苹果电脑',
                            id: 0
                        },
                        {
                            name: 'IphoneX',
                            id: 1
                        }
                    ]
                },
                getters: {
                    list(state) {
                        return state.list;
                    }
                },
                actions: {
                    queryList() {
                        console.log('product queryList');
                    }
                },
                mutations: {
                    setList() {
                        console.log('product setList')
                    }
                }
            },
            order: {
                namespaced: true, // 添加命名空间
                state: {
                    list: [
                        {
                            name: '订单-苹果电脑',
                            id: 0
                        },
                        {
                            name: '订单-IphoneX',
                            id: 1
                        }
                    ]
                },
                getters: {
                    list: (state) => state.list
                },
                actions: {
                    queryList() {
                        console.log('order queryList');
                    }
                },
                mutations: {
                    setList() {
                        console.log('order setList')
                    }
                }
            }
        }
    });

    new Vue({
        el: '#app',
        store,
        computed: {
            // 把 product 模块中的 list getter 映射为 products
            ...Vuex.mapGetters('product', {
                products: 'list'
            }),
            // 把 order 模块中的 list getter 映射为 orders
            ...Vuex.mapGetters('order', {
                orders: 'list'
            }),
        },
        mounted() {
            this.$store.dispatch('product/queryList'); // 调用 product模块的 getList action
            this.$store.commit('order/setList'); // 调用order模块的 setList mutation
        }
    });
</script>

大家会发现,虽然 product 和 order 模块的 state, getter, action, mutation 的名称都是一样的,但却因为加了 namespaced: true 这个配置而被很好的隔离开来。

关于 store 的设计,我个人建议至少要有2级结构,这样能保证最大的扩展性。基于上面的商城应用,其 store 的建议设计是这样的:

商城store

概要代码如下:

<div id="app">
    <h1>{{appName}}</h1>
    <dl>
        <dt>商品列表</dt>
        <dd v-for="item in products">{{item.name}}</dd>
    </dl>
</div>
<script src="https://unpkg.com/vue@2.5.17/dist/vue.js"></script>
<script src="https://unpkg.com/vuex@3.0.1/dist/vuex.js"></script>
<script>
    Vue.use(Vuex);
    let store = new Vuex.Store({
        modules: {
            // 全局模块
            app: {
                namespaced: true,
                state: {
                    name: 'XYZ商城'
                },
                getters: {
                    name: state => state.name
                },
                actions: {},
                mutations: {}
            },
            // 商品模块
            product: {
                namespaced: true,
                modules: {
                    // 商品列表
                    list: {
                        namespaced: true, // 添加命名空间
                        state: {
                            list: [
                                {
                                    name: '苹果电脑',
                                    id: 0
                                },
                                {
                                    name: 'IphoneX',
                                    id: 1
                                }
                            ]
                        },
                        getters: {
                            list(state) {
                                return state.list;
                            }
                        },
                        actions: {
                            queryList() {
                                console.log('product queryList');
                            }
                        },
                        mutations: {
                            setList() {
                                console.log('product setList')
                            }
                        }
                    },
                    // 商品详情
                    detail: {
                        namespaced: true,
                        state: {},
                        getters: {},
                        actions: {},
                        mutations: {}
                    },
                    // 其它商品页面...
                }
            },
            // 订单
            order: {
                namespaced: true,
                modules: {
                    // 订单列表
                    list: {
                        namespaced: true,
                        state: {},
                        getters: {},
                        actions: {},
                        mutations: {}
                    },
                    // 订单详情
                    detail: {
                        namespaced: true,
                        state: {},
                        getters: {},
                        actions: {},
                        mutations: {}
                    },
                    // ... 其它订单模块页面
                }
            }
        }
    });

    new Vue({
        el: '#app',
        store,
        computed: {
            ...Vuex.mapGetters('app', {
                appName: 'name'
            }),
            ...Vuex.mapGetters('product/list', {
                products: 'list'
            })
        },
        mounted() {
            this.$store.dispatch('product/list/queryList'); // 调用 product/list 模块的 getList action
        }
    });
</script>

4. 小结

建议大家在项目实践中为 store 加上命名空间,以上!

留言列表
  • ewr:
    eqr
      2018年09月21日 09:17 回复
    • 小王:
      你好,请问一下,官网demo 跑起来了,但是用浏览器http://localhost:8080.一直加载不出来是怎么回事呢?
        2018年09月20日 14:50 回复
      • dk:
        请教博主一点问题,目前我的ssr项目已基本完成开发,但不知如何部署到虚拟服务器centos7中,具体的问题是不懂需要将哪些代码上传到服务器并使用pm2来启动项目.希望博主有空回答下,谢谢.
        • u3xyz:
          你好,其实这个问题与“hp5”的问题有点类似,你可以看下那个回复,理论上构建生成的所有前端资源文件都会用到,都需要放到服务器
          2018年09月11日 21:47
        2018年09月11日 20:18 回复
      • hp5:
        你好,vue-ssr开发基本没什么问题,但是不知道如何部署,希望请教一下,上线部署时,需要将那些文件给后端,希望博主有时间回复一下,谢谢。
        • u3xyz:
          你好,所谓VueSSR,本质是在前后端共用一套js代码,在Node环境下直接吐出页面的过程。SSR一定是在Node环境 ,这个与Java,php等传统后端没什么关系(后端只提供数据接口),也就谈不上要给他们什么文件了,独立部署即可。
          2018年07月25日 10:36
        2018年07月24日 13:10 回复
      • lgf:
        我的项目是vue-cli 然后 实现ssr seo渲染
          2018年07月03日 19:07 回复
        • lgf:
          你好,我现在也是有SEO需求,SSR做,在浏览器打开的时候显示网页源代码可以看到详细的html,增加百度搜索更多的关键子,现在我ssr还是不太能行,用了个prerender-spa-plugin,但是这个插件问题太多,你这篇文章我看了一遍我实现有点困难,(比较菜) 有没有再详细点的教程呢 需求比较着急,所以问问您还有再详细点的demo吗
          • u3xyz:
            推荐多参考官方Demo: https://github.com/vuejs/vue-hackernews-2.0/
            2018年07月25日 10:28
          2018年07月03日 19:05 回复
        • 小白白:
          刚开始研究 我有一个问题 开发过程中我修改代码 就要重新打一次包么?怎么实现热更新啊 希望指点一下
            2018年06月29日 11:40 回复
          • 木メメ木+大:
            你好,发现你的博客的一个现象:比如在第二页点击进入详情,然后按下浏览器返回,就回到第一页,这个是博主刻意为之的吗? 另外特别想请教一下,左右目录联动的实现~
            • u3xyz:
              第一个问题:你是说在第二页的文章返回时默认到了第一页吗?这个不是刻意为之,算小bug吧 第二个问题:可以参看我的文章《如何实现markdown文章标题导航》
              2018年06月28日 12:33
            2018年06月27日 11:46 回复
          • 昌子:
            最近在做ssr,前面过程很顺利,到后面npm run build时,会爆出一个elementUI的样式错误,搞了好几天了还是无头绪,有时间帮忙看下吗?项目急上线
            • u3xyz:
              这个得你自己想办法解决哈
              2018年06月28日 12:31
            2018年06月26日 22:56 回复
          • meme:
            不太懂服务端,骨架屏倒是实现了,ssr看了好多,明白大致流程,就是对服务端不太了解,首先阿里买个云主机,然后你用sheel 连接ssh 登录 起一个www服务器,那么数据库放哪里呢,怎么就线上也能用了?还有node 服务放哪里,还有比如我是node写的接口,怎么弄呢。最近又看了 nginx 是说可以静态资源转发,不太明白其中的流程。期望大佬,写篇文章。或者私信一下。谢谢了
            • u3xyz:
              可以看看文章中的服务器部署模块。我只用了一台阿里云主机,node服务,nginx,数据库都是在这台机器上
              2018年06月28日 12:30
            2018年06月26日 21:10 回复
          • PlainHeart:
            作者,您好,我最近刚开始研究这个,按照您的文档我并不能实现功能,希望在配置上能给予一些更详细的帮助,谢谢!
            • u3xyz:
              首先,得耐心一点,我也不是一下就很顺利得做出来了,那里有问题就想办法解决。此文章也只是列出了关键步骤,并不是所有的步骤。建议多研究官方示例vue-hackernews-2.0,或者试试Nuxt.js
              2018年06月28日 12:28
            2018年06月23日 17:55 回复
          • ljc:
            你好,本人刚接触vue-ssr,开发的时候基本没什么问题,但是不知道如何上线部署,想请教一下,上线部署的时候 ,需要把哪些文件给后端呢,希望博主有时间回复一下
            • u3xyz:
              你好,回复已经单独发qq邮箱(7048***)
              2018年04月21日 00:56
            2018年04月20日 15:33 回复

          发表评论: