Vue 2.0 起步(3) 数据流vuex和LocalStorage实例 - 微信公众号RSS

参考:

如果你是克隆git里源码的,注意工程目录名是vue-tutorial/,步骤:http://www.jianshu.com/p/b3c76962e3d4
https://github.com/kevinqqnj/vue-tutorial
请使用新的template: https://github.com/kevinqqnj/flask-template-advanced

本篇目标:给我们的应用 - “简读 - 微信公众号RSS”,添加搜索、订阅、取消公众号功能,以及实现本地数据持久化

功能:

  • 用户搜索公众号 -> 左侧显示搜索结果
  • 点击左侧搜索结果的公众号右边星星 -> 订阅,同时右侧状态栏会更新
  • 再次点击星星 -> 取消订阅
  • 右侧状态栏里,鼠标移动到某个订阅号上 -> 动态显示删除按钮
  • 订阅列表 -> 保存到用户本地LocalStorage,关掉浏览器,下次打开依旧有效

DEMO网站
免费空间,有时第一次打开会等待启动 -- 约10秒,后面打开就快了

最终完成是酱紫的:


起步(3)完成图

数据流管理vuex

Vue是以数据驱动的框架,我们应用的各个组件之间,相互有共用数据的交互,比如订阅、取消等等。
对于大型应用来说,需要保证数据正确传输,防止数据被意外双向更改,或者出错后想调试数据的走向,这种情况下,vuex就可以帮忙,而且它有个灰常好用的Chrome插件 -- vue-devtools,谁用谁知道!
当然,对于小应用,用eventbus也够用了。我们这里为了打好基础,就采用vuex了。

vuex-1.jpg

绿圈中是vuex 部分。它是整个APP的数据核心,相当于总管,所有“共用”数据的变动,都得通知它,且通过它来处理和分发,保证了“单向数据流”的特点:

  • 客户端所有组件都通过action 中完成对流入数据的处理(如异步请求、订阅、取消订阅等)
  • 然后通过action 触发mutation修改state (同步)。mutation(突变)就是指数据的改变
  • 最后由state经过getter分发给各组件

另外,为了拿到搜索结果,我们用ajax请求搜狗网站搜索,会用到vue-resouce,当然,你用其它的ajax也没问题。

vuex官方文档:(http://vuex.vuejs.org/zh-cn/)
vue-resource文档: (https://github.com/pagekit/vue-resource)

安装vuex和vue-resource

cnpm i vuex vue-resource -S

创建/store目录,在目录里给vuex新建4个文件。它们对应于vuex的三个模块:Actions(actions.js),Mutations (另加types),State(index.js)


vuex-files.PNG

先考虑哪些数据是需要在组件之间交互的:

  1. 订阅列表subscribeList:肯定是要的
  2. 搜索结果列表mpList:搜索结果有很多页,需要有个总表来存储多个搜索页面。另外,左边搜索页面里公众号的订阅状态,需要跟右边订阅列表同步
  3. 没有了 ;)

把它们写入 vuex index.js:

# /src/store/index.js

import Vue from 'vue'
import Vuex from 'vuex'
import mutations from './mutations'
import actions from './actions'

Vue.use(Vuex);

const state = {
    mpList: [],         // 搜索结果列表
    subscribeList: []   // 订阅列表
};

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

定义各个突变类型:

# /src/store/mutation-types.js
// 订阅公众号
export const SUBSCRIBE_MP = 'SUBSCRIBE_MP';
export const UNSUBSCRIBE_MP = 'UNSUBSCRIBE_MP';

// 搜索列表处理
export const ADD_SEARCHRESULT_LIST = 'ADD_SEARCHRESULT_LIST';
export const UNSUBSCRIBE_SEARCHRESULT = 'UNSUBSCRIBE_SEARCHRESULT';
export const CLEAR_SEARCHRESULT = 'CLEAR_SEARCHRESULT';

触发的事件:

# /src/store/actions.js
import * as types from './mutation-types'

export default {
     subscribeMp({ commit }, mp) {
        commit(types.SUBSCRIBE_MP, mp)
    },
    unsubscribeMp({ commit }, weixinhao) {
        commit(types.UNSUBSCRIBE_MP, weixinhao)
    },
    addSearchResultList({ commit }, mp) {
        commit(types.ADD_SEARCHRESULT_LIST, mp)
    },
    unsubSearchResult({ commit }, weixinhao) {
        commit(types.UNSUBSCRIBE_SEARCHRESULT, weixinhao)
    },
    clearSearchResult({ commit }, info) {
        commit(types.CLEAR_SEARCHRESULT, info)
    }
}

突变时处理数据:

# /src/store/mutations.js
import * as types from './mutation-types'

export default {
     // 在搜索列表中,订阅某公众号
    [types.SUBSCRIBE_MP] (state, mp) {
        state.subscribeList.push(mp);
        for(let item of state.mpList) {
            if(item.weixinhao == mp.weixinhao) {
                var idx = state.mpList.indexOf(item);
                state.mpList[idx].isSubscribed = true;
                break;
            }
        }
    },
    // 在Sidebar中,取消某公众号订阅
    [types.UNSUBSCRIBE_MP] (state, weixinhao) {
        for(let item of state.mpList) {
            if(item.weixinhao == weixinhao) {
                var idx = state.mpList.indexOf(item);
                state.mpList[idx].isSubscribed = false;
                break;
            }
        }
        for(let item of state.subscribeList) {
            if(item.weixinhao == weixinhao) {
                var idx = state.subscribeList.indexOf(item);
                console.log('unscrib:'+idx);
                break;
            }
        }
        state.subscribeList.splice(idx, 1);
    },
    // 搜索列表更新
    [types.ADD_SEARCHRESULT_LIST] (state, mps) {
        state.mpList = state.mpList.concat(mps);
    },
    // 在搜索列表中,取消某公众号订阅
    [types.UNSUBSCRIBE_SEARCHRESULT] (state, weixinhao) {
        for(let item of state.mpList) {
            if(item.weixinhao == weixinhao) {
                var idx = state.mpList.indexOf(item);
                state.mpList[idx].isSubscribed = false;
                break;
            }
        }
        for(let item of state.subscribeList) {
            if(item.weixinhao == weixinhao) {
                var idx = state.subscribeList.indexOf(item);
                console.log('unscrib:'+idx);
                break;
            }
        }
        state.subscribeList.splice(idx, 1);
    },
    // 清空搜索列表
    [types.CLEAR_SEARCHRESULT] (state, info) {
        console.log('clear search result:' + info);
        state.mpList = [];
    }
};

项目中引用 store和 vue-resource:

# src/main.js
import VueResource from 'vue-resource'
import store from './store'

Vue.use(VueResource)

new Vue({
    // el: '#app',
    router,
    store,
    ...App
}).$mount('#app')

后台数据处理到这里,就定义好了,下面就让各个组件来引用这些action了。

用搜狗接口,得到搜索公众号列表

组件:在Search.vue里,删除假数据,真数据上场了!

这里面用到了vue.js的基本功能,比如:双向绑定、计算属性、事件处理器、Class绑定等等,查一下官网很容易理解的。
微信返回的数据,是个小坑!微信很坑爹,把数据定义成XML格式,而且tag和attribute混在一起,导致javascript 处理起来又臭又长。。。

这里不贴完整代码了,后面有项目的git地址

# /src/components/Search.vue

<template>
    <div class="card">
        <div class="card-header" align="center">
            <form class="form-inline">
                <input class="form-control form-control-lg wide" v-model="searchInput" type="text"
                       @keyup.enter="searchMp(1)" placeholder="搜索公众号">
                <button type="button" class="btn btn-outline-success btn-lg" :disabled="searchInput==''"
                        @click="searchMp(1)" ><i class="fa fa-search"></i></button>
            </form>
        </div>
        <div class="card-block">
            <div class="media" v-for="(mp,index) in mpList">
  // 循环显示搜索结果中,每个公众号的详细信息
。。。请查看源码
            </div>
        </div>
        <div class="card card-block text-xs-right" v-if="hasNextPage && searchResultJson && !isSearching">
            <h5 class="btn btn-outline-success btn-block" @click="searchMp(page)"> 下一页 ({{page}})
                <i class="fa fa-angle-double-right"></i></h5>
        </div>
    </div>
</template>

<script>
    export default {
        name : 'SearchResult',
        data() {
            return {
                searchKey: '',
                searchInput: '',    // 输入框的值
                searchResultJson: '',
                isSearching: false,
                page: 1,
                hasNextPage: true
            }
        },
        computed : {
            subscribeList() {
                // 重要!从vuex store中取出数据
                return this.$store.state.subscribeList
            },
            mpList() {
                // 重要!从vuexstore中取出数据
                return this.$store.state.mpList
            }
        },
        methods:{
            searchMp(pg) {
                this.isSearching = true;
                if (pg==1) {
                    this.searchKey = this.searchInput;
                    this.$store.dispatch('clearSearchResult', 'clear');
                    this.page = 1;
                    this.hasNextPage = true
                }
                this.$nextTick(function () { });
                this.$http.jsonp("http://weixin.sogou.com/weixinwap?_rtype=json&ie=utf8",
                    {
                        params: {
                            page: pg,
                            type: 1, //公众号
                            query: this.searchKey
                        },
                        jsonp:'cb'
                    }).then(function(res){
    // 处理搜狗返回的数据,又臭又长
。。。请查看源码
                    }
                    this.$store.dispatch('addSearchResultList', onePageResults);    // 通知 vuex保存搜索结果
                    this.searchInput = '';
                    this.page = this.page+1;
                    if (this.page > this.searchResultJson.totalPages) {
                        this.hasNextPage = false;
                    }
                    this.isSearching = false;
                },function(){
                    this.isSearching = false;
                    alert('Sorry, 网络似乎有问题')
                });
            },
            subscribe(idx) {
                if (this.mpList[idx].isSubscribed== true ) {
                    // 如果已经订阅,再次点击则为取消订阅该公众号
                    return this.$store.dispatch('unsubSearchResult',this.mpList[idx].weixinhao);
                }
                var mp = {
                    mpName : this.mpList[idx].title,
                    image : this.mpList[idx].image,
                    date : this.mpList[idx].date,
                    weixinhao : this.mpList[idx].weixinhao,
                    encGzhUrl : this.mpList[idx].encGzhUrl,
                    subscribeDate : new Date().getTime(),
                    showRemoveBtn: false
                };
                for(let item of this.subscribeList) {
                    if(item.mpName == mp.mpName) return false
                }
  // 通知 vuex,订阅某公众号
                this.$store.dispatch('subscribeMp', mp);
            }
        }
    }
</script>

右侧的状态栏 Sidebar

这个组件相对简单,左侧搜索结果里点击某公众号后,vuex会记录这个公众号到subscribeList,那在Sidebar.vue里,用computed计算属性,读取subscribeList state就行。
如果我在Sidebar.vue里取消订阅某公众号,也会通知vuex。vuex就会把左侧搜索结果里,此公众号的状态,设为“未订阅”。这也是为什么我们在vuex里,需要mpList state的原因!

# /src/components/Sidebar.vue
<template>
    <div class="card">
        <div class="card-header" align="center">
            <img src="http://avatar.csdn.net/1/E/E/1_kevin_qq.jpg"
                 class="avatar img-circle img-responsive" />
            <p><strong> 非梦</strong></p>
            <p class="card-title">订阅列表</p>
        </div>
        <div class="card-block">
            <p v-for="(mp, idx) in subscribeList" @mouseover="showRemove(idx)" @mouseout="hideRemove(idx)">
                <small>
                    <a class="nav-link" :href="mp.encGzhUrl" target="_blank">
                        ![](mp.image) {{ mp.mpName }} </a>
                    <a href="javascript:" @click="unsubscribeMp(mp.weixinhao)">
                        <i class="fa fa-lg float-xs-right text-danger sidebar-remove"
                           :class="{'fa-minus-circle': mp.showRemoveBtn}"></i></a></small>
            </p>
        </div>
    </div>
</template>

<script>
    export default {
        name : 'Sidebar',
        data() {
            return { }
        },
        computed : {
            subscribeList () {
                // 从store中取出数据
                return this.$store.state.subscribeList
            }
        },
        methods : {
            unsubscribeMp(weixinhao) {
                // 删除该公众号
                return this.$store.dispatch('unsubscribeMp',weixinhao);
            },
            showRemove(idx) {
                return this.subscribeList[idx]['showRemoveBtn']= true;
            },
            hideRemove(idx) {
                return this.subscribeList[idx]['showRemoveBtn']= false;
            }        
        }
    }
</script>

重点看一下代码中:

  • methods部分!订阅、取消订阅,都会使用$store.dispatch(action, <data>),来通知 vuex更新数据
  • computed部分,其它组件用计算属性,访问$store.state.<数据源>来得到更新后的数据。
-> 事件触发(action) -> 突变(mutation) -> 更新(state) -> 读取(新state)

其实vuex没有想象中的复杂吧,哈哈~

好,现在下载源码,npm run dev试一下,最好用vue-devtools好好体会一下,订阅、取消操作时,vuex里action、state的变化,可以回退到任一状态哦:(只支持Chrome)


vue-devtools.png

本地存储

LocalStorage是HTML5 window的一个属性,有5MB大小,足够了,而且各浏览器支持度不错:


2011052411384081.jpg

LS操作极其简单,我们只须用到保存、读取的函数就行:

window.localStorage.setItem("b","isaac");  //设置b为"isaac"
varb=window.localStorage.getItem("b");  //获取b的值,字符串
window.localStorage.removeItem("c");  //清除c的值

由于vuex里,我们保存的状态,都是数组,而LS只支持字符串,所以需要用JSON转换:

JSON.stringify(state.subscribeList);   // array -> string
JSON.parse(window.localStorage.getItem("subscribeList"));    // string -> array 

然后,来更新我们的vuex mutation和Sidebar.vue组件。
mutations.js对subscribeList操作时,顺带操作LocalStorage:

# /src/store/mutations.js(部分)
import * as types from './mutation-types'

export default {
     // 订阅某公众号
    [types.SUBSCRIBE_MP] (state, mp) {
。。。
        window.localStorage.setItem("subscribeList", JSON.stringify(state.subscribeList))
    },
    // 删除某公众号
    [types.UNSUBSCRIBE_MP] (state, weixinhao) {
。。。
        state.subscribeList.splice(idx, 1);
        window.localStorage.setItem("subscribeList", JSON.stringify(state.subscribeList))
    },
    //从LocalStorage 初始化订阅列表
    [types.INIT_FROM_LS] (state, info) {
        console.log(info + window.localStorage.getItem("subscribeList"));
        if (window.localStorage.getItem("subscribeList")) {
            state.subscribeList = JSON.parse(window.localStorage.getItem("subscribeList")) ;
        }
        else state.subscribeList = []
    }
};

每次新打开浏览器,对Sidebar组件初始化时,读取LocalStorage里存储的数据:

# /src/components/Sidebar.vue (部分)
。。。
    export default {
        name : 'Sidebar',
        data() {
            return {}
        },
        created: function () {
            // 从LocalStorage中取出数据
            return this.$store.dispatch('initFromLS', 'init from LS');
        },
        computed : {
。。。

最后,放上项目源码

DEMO网站

TODO:

  • 用户点击右侧订阅的公众号 -> 左侧显示公众号文章阅读记录,不再导向外部链接
  • 用户注册、登录功能 -> 后台Flask
  • 搜狗页面经常要输入验证码 -> 后台处理

敬请关注!
Vue 2.0 起步(4) 轻量级后端Flask用户认证 - 微信公众号RSS

你的关注和评论,鼓励作者写更多的好文章!

参考:

http://www.jianshu.com/p/ab778fde3b99

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容