web前端發展到現代,已經再也不是嚴格意義上的後端MVC的V層,它愈來愈向相似客戶端開發的方向發展,已獨立擁有了本身的MVVM設計模型。先後端的分離也使前端人員擁有更大的自由,能夠獨立設計客戶端部分的架構。javascript
【科普】MVVM是Model-View-ViewModel的簡寫。它本質上就是MVC 的改進版。MVVM 就是將其中的View 的狀態和行爲抽象化,讓咱們將視圖 UI 和業務邏輯分開。固然這些事 ViewModel 已經幫咱們作了,它能夠取出 Model 的數據同時幫忙處理 View 中因爲須要展現內容而涉及的業務邏輯。
Vue做爲如今流行的MVVM框架,也是本人日常業務中用得最多的框架。如何才能更合理、優雅的寫VueSPA,是本人一直研究的課題,通過一年左右的思考和實踐總結出本文。
本文屬於中高級實踐討論,不適合新手。
本人我的的觀點,不表明是最佳實踐,歡迎大牛一塊兒討論,批評指正。html
秉着不重複造輪子的原則(其實就是懶),工程直接使用Vue2.0官方腳手架生成,使用最新webpack模板。與標準模板的主要差別:前端
新增部分的安裝請參考他們各自的文檔,這裏不贅述。vue
討論架構前咱們須要一個項目需求,這裏簡單模擬一個。
需求點:3個一級頁面,2個二級頁面,底部的tabbar只在一級頁面出現,首頁、我的中心和登陸頁面是未登陸也能夠進入;財務和編輯我的信息是隻有登陸用戶可見,簡單原型以下: java
下面不討論腳手架生成的部分目錄,只聚焦src開發目錄,依據原型咱們能夠大體規劃出下面的目錄:node
├── build ├── config ├── dist ├── src 開發目錄 │ ├── api 公共api集 │ │ ├── axiosConfig.js axios實例配置 | | └── index.js 公共api集入口 │ ├── assets 資源目錄 │ │ ├── images 圖片 │ │ ├── scripts 第三方腳本 | | └── styles 基礎樣式庫 │ ├── components 公共組件 │ │ ├── common 通常通用組件 │ │ ├── form 表單通用組件 │ │ └── popup 彈出類通用組件 │ │── config 項目配置 │ │ ├── dev.env.js 開發模式配置 │ │ ├── env.js 通常配置 │ │ ├── modules.js 模塊配置 │ │ └── prod.env.js 生產模式配置 │ │── mixin 用於vue文件混合的模板 │ │── modules 模塊 │ │ ├── finance 財務模塊 │ │ │ ├── components 財務模塊私有組件 │ │ │ │ └── FinanceIndexItem.vue 財務模塊首頁裏的條目項 │ │ │ ├── pages 財務模塊頁面 │ │ │ │ └── FinanceIndex.vue 財務模塊首頁 │ │ │ ├── api.js 模塊api集 │ │ │ ├── index.js 模塊入口 │ │ │ ├── Layout.vue 模塊承載頁 │ │ │ └── router.js 模塊內路由 │ │ ├── home 首頁模塊(子目錄同上) │ │ └── user 用戶模塊(子目錄同上) │ │── pages 公共頁面 │ │ ├── Success.vue 公共狀態管理模塊 │ │ └── NotFound.vue 用戶模塊(子目錄同上) │ ├── router 路由管理 │ ├── store 公共狀態管理 │ │ ├── modules 公共狀態管理模塊 │ │ │ ├── com.js 通用狀態 │ │ │ └── user.js 用戶狀態 │ │ └── index.js 公共狀態管理入口 │ └── utils 基礎工具 └── static
根據本人我的開發經驗總結的規範,不表明必須這麼作。webpack
項目過程當中常遇到要把原來的項目分開部署,或是組件間耦合、或是多人開發時組件衝突等問題。本人提出的解決辦法是將項目細分紅模塊進行開發,每一個模塊由若干相關「頁面」組成,擁有私有組件、路由、api等,如示例所示:劃分了三個模塊,首頁模塊、財務模塊、用戶模塊。ios
【小結】這種方案的核心就是要將太過零散的組件(頁面)聚合成模塊,每一個模塊都有必定遷移性,互不耦合,實現按需打包,而且在代碼分割上比單純的分頁面加載更加靈活可控。
這個是爲了讓開發這個模塊的程序員有相似根組件<App>
的公共空間。從路由的角度來講,全部的模塊內頁面都是它的子路由,這樣隔離了對全局路由的影響,至少路徑定義能夠隨意些。
通常來講它只是個空的路由跳轉頁,固然你把模塊的公共數據放這裏也能夠的,在子路由就能this.$parent
拿到數據,能夠當成子路由間的bus使用,以下以示例的user模塊爲例:git
<template> <router-view/> </template> <script> export default { name: 'user', data(){ return { name: '大白', age: 12, }; }, }; </script>
模塊內路由最後都會被導入總路由中,不要覺得只是簡單合併了文件,這裏的設計也跟Layout模塊承載頁有關,
下面以user模塊爲例,咱們把我的中心、登陸和修改我的信息這三個頁面歸爲user模塊,路由規劃以下。程序員
/user
/user/login
/user/userInfo
其中因爲「我的中心」是一級頁面,需求要求底部有tabBar,因此使它只能是一級路由。
接下來你會發現Layout模塊承載頁的路由路勁也是'/user',這裏不用擔憂會亂,由於路由管理是按順序匹配的,至於爲何要路徑同樣,這只是爲了知足路由規劃,讓路徑好看而已。
// 通用的tabbar import IndexTabBar from '@/components/common/IndexTabBar'; // 模塊內的頁面 import UserIndex from './pages/UserIndex'; import UserLogin from './pages/UserLogin'; import UserInfo from './pages/UserInfo'; export default [ // 一級路由 { name: 'userIndex', path: '/user', meta: { title: '我的中心', }, components: { default: UserIndex, footer: IndexTabBar, }, }, { path: '/user', // 這裏分割子路由 component: () => import('./layout.vue'), children: [ // 二級路由 { name: 'userLogin', path: 'login', meta: { title: '登陸', }, component: UserLogin, }, { name: 'userInfo', path: 'info', meta: { title: '修改我的信息', requiresAuth: true, }, component: UserInfo, }, ], }, ];
模塊承載頁以懶加載的形式component: () => import('./layout.vue')
引入,這會使webpack在此處分割代碼,也就是說進入模塊內是須要再此請求的,能夠減小首次加載的數據量,提升速度。
官方關於懶加載的文檔
這裏你會發現後續的子路由,又是以直接引入的方式加載,也就是說整個模塊會一塊兒加載,實現了分模塊加載。
這與簡單的分頁面加載不一樣,分頁面加載一直有個難點,就是分割的量比較難把握(太多會增長請求次數,太少又下降了速度),而分模塊能夠將相關頁面一塊兒加載(跟提升緩存命中率很像),能夠更靈活的規劃咱們的加載,最終效果:
這個設計跟模塊內路由相似,目的也是爲了按需加載和隔離全局。
下面也是以user模塊的模塊api集爲例,能夠發現和路由有一些不一樣就是這裏爲了防止模塊跟全局耦合,運用函數式編程思想(相似於依賴注入),將全局的axios實例做爲函數參數傳入,再返回出一個包含api的對象,這個導出的對象將會被以模塊名命名,合併到全局的api集中。
export default function (axios) { return { postHeadImg(token, userId, data) { const options = { method: 'post', name: '換頭像', url: '/data/user/updateHeadImg', headers: { token, userId, }, data, }; return axios(options); }, postProduct(token, userId, data) { const options = { method: 'post', name: '提交產品選擇', url: '/product/opt', headers: { token, userId, }, data, }; return axios(options); }, }; }
爲了方便引用,每一個模塊目錄下都有一個index.js,引入模塊的時候能夠省略,node會自動讀這個文件。
仍是以user模塊爲例,這裏主要是引入模塊專屬api和模塊內路由,並定義了模塊的名字,這個名字是後面掛載專屬api是時候用的。
import api from './api'; import router from './router'; export default { name: 'user', api, router, };
示例中config目錄下有個modules.js文件是指定打包須要的模塊,測試一下打包不一樣數量的模塊,會發現產品文件大小會改變,這就證實了已經實現按需打包。
至於路由和api集的子模塊整合實現,後面會提到。
import home from '@/modules/home'; import finance from '@/modules/finance'; import user from '@/modules/user'; export default [ home, finance, user ]
【背景】示例項目模擬常見的接口約定,服務器與應用交互有兩個自定義頭部:token和userId。token是權限標識符,幾乎所有api都須要帶上,爲了防CSRF;userId是登陸狀態標識符,有些須要登陸狀態才能使用的接口才須要帶上,這兩個標識符都有有效期。本示例暫不考慮自動續期的機制。
在api管理方面本人比較喜歡集中管理接口和配置,但發起請求和請求回調傾向與每一個接口單獨處理。
axios是比較流行的ajax的promise封裝。axios官方文檔
本人推薦在全局保留惟一的axios實例,全部的請求都使用這個公共實例發起,實現配置的統一。
示例項目的在api文件夾下的axiosConfig.js就是axios的配置,主要是導出一個符合項目設置的實例,並進行一些攔截器設置。
【PS】至於爲何到導出實例而不是直接修改axios默認值?
這是爲了預防某些特例狀況下公共實例沒法知足需求,須要單獨配置axios的狀況,因此爲了避免污染原始的axios默認值,不推薦修改默認值。
// 引入axios包 import axios from 'axios'; // 引入環境配置 import env from '../config/env'; // 引入公共狀態管理 import store from '../store/index'; // 全局默認配置 const myAxios = axios.create({ // 跨域帶cookie withCredentials: true, // 基礎url baseURL: `${env.apiUrl}/${env.apiVersion}`, // 超時時間 timeout: 12000, }); // 請求發起前攔截器 myAxios.interceptors.request.use((_config) => { // ... return config; }, () => { // 異常處理 }); // 響應攔截器 myAxios.interceptors.response.use((response) => { // ... }, (error) => { // 異常處理 return Promise.reject(error); }); export default myAxios;
項目的全部公共api都會編寫到這裏,實現集中化管理,最後公共api集會掛載到vue根實例下,使用this.$api
就能夠方便的訪問。
因爲token和userId不是必須頭部,這裏我推薦每一個接口函數都單獨處理,按需傳入,這樣api函數也能更加清晰。
給每一個接口起名字,是爲了後續取消請求所設計的。
總體思路:先定義公共api,再將模塊內api(按需)掛載進來,最後導出api集。
// 引入已經配置好的axios實例 import axios from './axiosConfig'; // 引入模塊 import modules from '../config/modules'; const apiList = { // 獲取token不須要 getToken() { const options = { method: 'post', name: '獲取token', url: '/token/get', }; return axios(options); }, loginWithName(token, data) { const options = { method: 'post', name: '用戶名密碼登陸', url: '/data/user/login4up', headers: { token, }, data, }; return axios(options); }, postHeadImg(token, userId, data) { const options = { method: 'post', name: '換頭像', url: '/data/user/updateHeadImg', headers: { token, userId, }, data, }; return axios(options); }, }; // 使每一個模塊裏的api集掛載到以模塊名爲名的命名空間下 modules.forEach((i) => { Object.assign(apiList, { [i.name]: i.api(axios), }); }); export default apiList;
使用示例中用router文件夾下的index.js配置全局路由,api集相似實現集中化管理,導出路由實例會掛載到vue根實例下,使用this.$router
就能夠方便的訪問。
配置參考官方文檔,這裏主要提的一點是,模塊內路由的整合,見實例代碼段。
Vue.use(Router); // 路由配置 const routerConfig = { routes: [ { path: '/', meta: { title: env.appName, }, redirect: { name: 'home' }, }, { name: 'success', path: '/success', meta: { title: '成功', }, component: Success, }, { path: '*', component: NotFound, }, ], }; // 將模塊內的路由拼接到全局 modules.forEach((i) => { routerConfig.routes = routerConfig.routes.concat(i.router); }); const router = new Router(routerConfig);
路由的鉤子函數有不少妙用,這裏列舉了一些例子。
路由元信息meta能夠自定義須要的數據,至關於給路由一個標記,而後在router.afterEach鉤子函數中能夠讀取到並進行處理。
回顧上面示例的模塊內路由,meta中定義了title(標題)和requiresAuth(是否要登陸狀態),這就會在這裏體現出用處。把登陸權限設置在這裏判斷是爲了防止用戶進入某些須要權限的「頁面」。
router.beforeEach((to, from, next) => { // 關閉公共彈框 if (window.loading) { window.loading.close(); } // 設置微信分享(若是有) wxShare({ title: '哇哈哈', desc: '在路由鉤子函數中處理標題和權限', link: env.shareBaseUrl, imgUrl: env.shareBaseUrl + '/images/shareLogo.png' }); // 設置標題 document.title = to.meta.title ? to.meta.title : '示例'; // 檢查登陸狀態 if (to.meta.requiresAuth) { // 目標路由須要登陸狀態 // ... } next(); });
權限標識符的特色就是幾乎每一個連接都要帶上,須要維護有效期,爲了避免浪費服務器資源還須要持久化並保證請求惟一。
本人比較推薦使用公共狀態管理vuex進行自動化管理,減小代碼編寫時的顧慮。
示例中公共狀態中的com模塊裏有tokenObj和waitToken兩個字段,其中tokenObj包含了token和過時時間,waitToken是一個標記是否當前在獲取token的布爾值。
【PS】爲何要token保證惟一一次請求?
常見的場景:當用戶進入應用,這時候token要麼沒有要麼已過時,這時頁面須要併發兩個ajax請求,因爲都沒有token,不惟一化處理的話,會同時先發起兩個token請求,這樣首先是浪費了請求資源,其次因爲是異步請求,不能保證兩次token的順序,若是服務器對token管理較嚴格則會出問題。
因爲獲取token是異步操做,因此getToken寫在actions中,把主要過程包裹成當即執行函數,並經過waitToken判斷是否要等待,若是要等待就隔一段時間再檢查,這樣就保證了併發請求時,token能惟一。
const actions = { // needToRegain是爲了特殊條件下強制獲取使用 getToken({ commit, state: _state }, needToRegain) { return new Promise((resolve, reject) => { (function main() { // 若是waitToken爲真即表示發起了請求但還未迴應 if (_state.waitToken) { console.log('等待token'); setTimeout(() => { main(); }, 1000); return; } // 是否過時標記 let isExpire = false; // 提取現有的tokenObj let tokenObj = { ..._state.tokenObj, }; // 若是沒有token就從本地存儲中讀取 if (!tokenObj.token) { tokenObj = JSON.parse(localStorage.getItem('tokenObj')); // 若是本地有tokenObj會順便添加到狀態管理 if (tokenObj) { commit('setTokenObj', tokenObj); } } // token是否過期 if (tokenObj && tokenObj.token) { isExpire = new Date().getTime() - tokenObj.expireTime > -10000; } // 綜合判斷是否須要獲取token if (!tokenObj || !tokenObj.token || isExpire || needToRegain) { commit('setWaitToken', true); api.getToken().then((res) => { // 檢查返回的數據 const checkedData = connect.dataCheck(res); if (checkedData.isDataReady) { const newTokenObj = { token: checkedData.data.token, expireTime: new Date().getTime() + (checkedData.data.expire_time * 1000), }; // 設置TokenObj會順便保留一份到本地存儲 commit('setTokenObj', newTokenObj); commit('setWaitToken', false); console.log('獲取token成功'); resolve(newTokenObj.token); } else { commit('setWaitToken', false); console.error('獲取token失敗'); reject(checkedData.msg); } }).catch((err) => { window.toast('網絡錯誤'); commit('setWaitToken', false); reject(err); }); } else { console.log('token已存在,直接返回'); resolve(tokenObj.token); } }()); }); }, };
將須要token的api函數套在getToken的回調中,就能方便的使用,不用再擔憂token是否過時。
const sendData = { mobile: this.formData1.mobile, }; this.$store.dispatch('getToken').then((token) => { this.$api.sendSMS(token, sendData).then((res) => { const checkedData = this.$connect.dataCheck(res); if (checkedData.isDataReady) { window.toast('驗證碼已發送,請查收短信'); } else { window.toast('驗證碼發送失敗'); } }).catch(() => { window.toast('網絡錯誤'); }); });