1. 程式人生 > >Vue 2.0 起步 (3) 資料流 vuex 和 LocalStorage 例項

Vue 2.0 起步 (3) 資料流 vuex 和 LocalStorage 例項

本篇目標:給我們的應用 - “簡讀 - 微信公眾號RSS”,新增搜尋、訂閱、取消公眾號功能,以及實現本地資料持久化

功能:

  • 使用者搜尋公眾號 -> 左側顯示搜尋結果
  • 點選左側搜尋結果的公眾號右邊星星 -> 訂閱,同時右側狀態列會更新
  • 再次點選星星 -> 取消訂閱
  • 右側狀態列裡,滑鼠移動到某個訂閱號上 -> 動態顯示刪除按鈕
  • 訂閱列表 -> 儲存到使用者本地LocalStorage,關掉瀏覽器,下次開啟依舊有效

免費空間,有時第一次開啟會等待啟動 -- 約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和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 : {
。。。

最後,放上 專案原始碼

TODO:

  • 使用者點選右側訂閱的公眾號 -> 左側顯示公眾號文章閱讀記錄,不再導向外部連結
  • 使用者註冊、登入功能 -> 後臺Flask
  • 搜狗頁面經常要輸入驗證碼 -> 後臺處理

你的關注和評論,鼓勵作者寫更多的好文章!