【Geek議題】合理的VueSPA架構討論(上)

前言

web前端發展到現代,已經再也不是嚴格意義上的後端MVC的V層,它愈來愈向相似客戶端開發的方向發展,已獨立擁有了本身的MVVM設計模型。先後端的分離也使前端人員擁有更大的自由,能夠獨立設計客戶端部分的架構。javascript

【科普】MVVM是Model-View-ViewModel的簡寫。它本質上就是MVC 的改進版。MVVM 就是將其中的View 的狀態和行爲抽象化,讓咱們將視圖 UI 和業務邏輯分開。固然這些事 ViewModel 已經幫咱們作了,它能夠取出 Model 的數據同時幫忙處理 View 中因爲須要展現內容而涉及的業務邏輯。

Vue做爲如今流行的MVVM框架,也是本人日常業務中用得最多的框架。如何才能更合理、優雅的寫VueSPA,是本人一直研究的課題,通過一年左右的思考和實踐總結出本文。
本文屬於中高級實踐討論,不適合新手。
本人我的的觀點,不表明是最佳實踐,歡迎大牛一塊兒討論,批評指正。html

工程搭建

秉着不重複造輪子的原則(其實就是懶),工程直接使用Vue2.0官方腳手架生成,使用最新webpack模板。與標準模板的主要差別:前端

  1. 增長了Sass預編譯器
  2. 增長了Vuex狀態管理
  3. 增長了Axios基礎Ajax工具庫

新增部分的安裝請參考他們各自的文檔,這裏不贅述。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

  1. 全部vue組件都以大寫字面開頭的駝峯命名法命名,這樣保持到模板代碼上,能夠便於區分開html的原生標籤;
  2. 人爲劃分vue組件爲「頁面」和「頁面上的組件」,原則上「頁面上的組件」不發請求,不改變公共狀態,所有經過事件交由「頁面」完成,本人更傾向用˙集中管理。(其實vue中並無頁面概念);
  3. 各個模塊,包括路由管理、公共狀態管理、接口集等都在目錄下有個index.js的入口文件,方便引用;
  4. 基礎工具內的工具使用函數式編程,作到可移植,不要對本項目產生依賴;
  5. 資源圖片只在項目中保留小圖(就是會被webpack處理成base64那些),大圖應使用cdn,能夠動態獲取也能夠把地址寫到一個腳本里;
  6. 使用eslint使js代碼符合Airbnb規範。

低耦合模塊化開發

項目過程當中常遇到要把原來的項目分開部署,或是組件間耦合、或是多人開發時組件衝突等問題。本人提出的解決辦法是將項目細分紅模塊進行開發,每一個模塊由若干相關「頁面」組成,擁有私有組件、路由、api等,如示例所示:劃分了三個模塊,首頁模塊、財務模塊、用戶模塊。ios

【小結】這種方案的核心就是要將太過零散的組件(頁面)聚合成模塊,每一個模塊都有必定遷移性,互不耦合,實現按需打包,而且在代碼分割上比單純的分頁面加載更加靈活可控。

Layout模塊承載頁

這個是爲了讓開發這個模塊的程序員有相似根組件<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在此處分割代碼,也就是說進入模塊內是須要再此請求的,能夠減小首次加載的數據量,提升速度。
官方關於懶加載的文檔
這裏你會發現後續的子路由,又是以直接引入的方式加載,也就是說整個模塊會一塊兒加載,實現了分模塊加載
這與簡單的分頁面加載不一樣,分頁面加載一直有個難點,就是分割的量比較難把握(太多會增長請求次數,太少又下降了速度),而分模塊能夠將相關頁面一塊兒加載(跟提升緩存命中率很像),能夠更靈活的規劃咱們的加載,最終效果:

  1. 用戶進入應用,首頁的三個頁面(有tabbar的)就已經加載完畢,這時點擊哪一個tabbar按鈕都能流暢;
  2. 當用戶進入某個頁面內的子頁面,會產生一次請求;
  3. 這時整個模塊的頁面都加載完(不必定要所有),用戶在這個模塊內又能流暢訪問。

模塊api集

這個設計跟模塊內路由相似,目的也是爲了按需加載和隔離全局。
下面也是以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
]

api集的配置

【背景】示例項目模擬常見的接口約定,服務器與應用交互有兩個自定義頭部:token和userId。token是權限標識符,幾乎所有api都須要帶上,爲了防CSRF;userId是登陸狀態標識符,有些須要登陸狀態才能使用的接口才須要帶上,這兩個標識符都有有效期。本示例暫不考慮自動續期的機制。

在api管理方面本人比較喜歡集中管理接口和配置,但發起請求和請求回調傾向與每一個接口單獨處理。

導出axios實例

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都會編寫到這裏,實現集中化管理,最後公共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();
});

自動化管理權限標識符(token)

權限標識符的特色就是幾乎每一個連接都要帶上,須要維護有效期,爲了避免浪費服務器資源還須要持久化並保證請求惟一。
本人比較推薦使用公共狀態管理vuex進行自動化管理,減小代碼編寫時的顧慮。

妙用公共狀態管理獲取token

示例中公共狀態中的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在請求代碼中使用

將須要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('網絡錯誤');
  });
});
相關文章
相關標籤/搜索