淺談使用 Vue 構建前端 10W+ 代碼的單頁面應用(一)
126 人贊了文章
開始之前
隨著業務的不斷累積,目前我們 ToC 端
主要項目,除去 node_modules
, build 配置文件
,dist 靜態資源文件
的代碼量為 137521
行,後台管理系統下各個子應用代碼,除去依賴等文件的總行數也達到 100萬
多一點。
代碼量意味不了什麼,只能證明模塊很多,但相同兩個項目,在
運行時性能相同
情況下,你的10 萬
行代碼能容納並維護150
個模塊,並且開發順暢,我的項目中10 萬
行代碼卻只能容納100
個模塊,添加功能也好,維護起來也較為繁瑣,這就很值得思考。
本文會在主要描述以 Vue 技術棧
為技術主體
,ToC 端
項目業務主體
,在構建過程中,遇到或者總結的點(也會提及一些 ToB 項目的場景),可能並不適合你的業務場景(僅供參考),我會儘可能多的描述問題與其中的思考,最大可能的幫助到需要的同學,也辛苦開發者發現問題或者不合理/不正確的地方及時向我反饋,會儘快修改,歡迎有更好的實現方式來 pr
。
Git 地址
- vue-develop-template 完善中,可以運行
React 項目
可以參考螞蟻金服數據體驗技術團隊
編寫的文章:
- 如何管理好10萬行代碼的前端單頁面應用
本文並不是基於上面文章寫的,不過當時在看到他們文章之後覺得有相似的地方,相較於這篇文章,本文可能會枯燥些,會有大量代碼,同學可以直接用上倉庫看。
大綱:
- ① 單頁面,多頁面
- ② 目錄結構
- ③ 通用組件
- ④ 全局配置,插件與攔截器
- ⑤ 路由配置與懶載入
- ⑥ Service 服務層
- ⑦ 狀態管理與視圖拆分
由於字數限制,本文會拆分成兩個部分,您也可以直接點擊下方地址一次看完。
淺談使用 Vue 構建前端 10w+ 代碼量的單頁面應用開發底層① 單頁面,多頁面
首先要思考我們的項目最終的構建主體
是單頁面
,還是多頁面
,還是單頁 + 多頁
,通過他們的優缺點來分析:
- 單頁面(SPA)
- 優點:體驗好,路由之間跳轉流程,可定製轉場動畫,使用了
懶載入
可有效減少首頁白屏時間,相較於多頁面
減少了用戶訪問靜態資源伺服器的次數等。 - 缺點:初始會載入較大的靜態資源,並且隨著業務增長會越來越大,
懶載入
也有他的弊端,不做特殊處理不利於 SEO 等。
- 多頁面(MPA):
- 優點:對搜索引擎友好,開發難度較低。
- 缺點:資源請求較多,整頁刷新體驗較差,頁面間傳遞數據只能依賴
URL
,cookie
,storage
等方式,較為局限。
- SPA + MPA
- 這種方式常見於較
老 MPA 項目遷移至 SPA 的情況
,缺點結合兩者,兩種主體通信方式也只能以兼容MPA 為準
- 不過這種方式也有他的好處,假如你的 SPA 中,有類似文章分享這樣(沒有後端直出,後端返
HTML 串
的情況下),想保證用戶體驗在 SPA 中開發一個頁面,在 MPA 中也開發一個頁面,去掉沒用的依賴,或者直接用原生 JS 來開發,分享出去是 MPA 的文章頁面,這樣可以加快分享出去的打開速度,同時也能減少靜態資源伺服器的壓力,因為如果分享出去的是 SPA 的文章頁面,那 SPA 所需的靜態資源至少都需要去進行協商請求
,當然如果服務配置了強緩存就忽略以上所說。
我們首先根據業務所需,來最終確定構建主體
,而我們選擇了體驗至上的 SPA
,並選用 Vue
技術棧。
② 目錄結構
其實我們看開源的絕大部分項目中,目錄結構都會差不太多,我們可以綜合一下來個通用的 src
目錄:
src├── assets // 資源目錄 圖片,樣式,iconfont├── components // 全局通用組件目錄├── config // 項目配置,攔截器,開關├── plugins // 插件相關,生成路由、請求、store 等實例,並掛載 Vue 實例├── directives // 拓展指令集合├── routes // 路由配置├── service // 服務層├── utils // 工具類└── views // 視圖層
③ 通用組件
components
中我們會存放 UI 組件庫中的那些常見通用組件了,在項目中直接通過設置別名
來使用,如果其他項目需要使用,就發到 npm
上。
結構
// components 簡易結構components├── dist├── build├── src ├── modal ├── toast └── ...├── index.js └── package.json
項目中使用
如果想最終編譯成 es5
,直接在 html 中使用或者部署 CDN 上,在 build
配置簡單的打包邏輯,搭配著 package.json
構建 UI組件 的自動化打包發布,最終部署 dist
下的內容,並發布到 npm
上即可。
而我們也可直接使用 es6
的代碼:
import Components/src/modal
其他項目使用
假設我們發布的 npm 包
叫 bm-ui
,並且下載到了本地 npm i bm-ui -S
:
修改項目的最外層打包配置,在 rules 里 babel-loader
或 happypack
中添加 include
,node_modules/bm-ui
:
// webpack.base.conf... rules: [{ test: /.vue$/, loader: vue-loader, options: vueLoaderConfig }, { test: /.js$/, loader: babel-loader, // 這裡添加 include: [resolve(src), resolve(test), resolve(node_modules/bm-ui)] },{ ... }]...
然後搭配著 babel-plugin-import
直接在項目中使用即可:
import { modal } from bm-ui
多個組件庫
同時有多個組件庫的話,又或者有同學專門進行組件開發的話,把 components
內部細分
一下,多一個文件分層。
components├── bm-ui-1 ├── bm-ui-2└── ...
你的打包配置文件可以放在 components
下,進行統一打包,當然如果要開源出去還是放在對應庫下。
④ 全局配置,插件與攔截器
這個點其實會是項目中經常被忽略的,或者說很少聚合到一起,但同時我認為是整個項目中的重要之一,後續會有例子說道。
全局配置,攔截器目錄結構
config├── index.js // 全局配置/開關├── interceptors // 攔截器 ├── index.js // 入口文件 ├── axios.js // 請求/響應攔截 ├── router.js // 路由攔截 └── ...└── ...
全局配置
我們在 config/index.js
可能會有如下配置:
// config/index.js// 當前宿主平台 兼容多平台應該通過一些特定函數來取得export const HOST_PLATFORM = WEB// 這個就不多說了export const NODE_ENV = process.env.NODE_ENV || prod// 是否強制所有請求訪問本地 MOCK,看到這裡同學不難猜到,每個請求也可以單獨控制是否請求 MOCKexport const AJAX_LOCALLY_ENABLE = false// 是否開啟監控export const MONITOR_ENABLE = true// 路由默認配置,路由表並不從此注入export const ROUTER_DEFAULT_CONFIG = { waitForData: true, transitionOnLoad: true}// axios 默認配置export const AXIOS_DEFAULT_CONFIG = { timeout: 20000, maxContentLength: 2000, headers: {}}// vuex 默認配置export const VUEX_DEFAULT_CONFIG = { strict: process.env.NODE_ENV !== production}// API 默認配置export const API_DEFAULT_CONFIG = { mockBaseURL: , mock: true, debug: false, sep: /}// CONST 默認配置export const CONST_DEFAULT_CONFIG = { sep: /}// 還有一些業務相關的配置// ...// 還有一些方便開發的配置export const CONSOLE_REQUEST_ENABLE = true // 開啟請求參數列印export const CONSOLE_RESPONSE_ENABLE = true // 開啟響應參數列印export const CONSOLE_MONITOR_ENABLE = true // 監控記錄列印
可以看出這裡彙集了項目中所有用到的配置,下面我們在 plugins
中實例化插件,注入對應配置,目錄如下:
插件目錄結構
plugins├── api.js // 服務層 api 插件├── axios.js // 請求實例插件├── const.js // 服務層 const 插件├── store.js // vuex 實例插件├── inject.js // 注入 Vue 原型插件└── router.js // 路由實例插件
實例化插件並注入配置
這裡先舉出兩個例子,看我們是如何注入配置,攔截器並實例化的
實例化 router
:
import Vue from vueimport Router from vue-routerimport ROUTES from Routesimport {ROUTER_DEFAULT_CONFIG} from Config/indeximport {routerBeforeEachFunc} from Config/interceptors/routerVue.use(Router)// 注入默認配置和路由表let routerInstance = new Router({ ...ROUTER_DEFAULT_CONFIG, routes: ROUTES})// 注入攔截器routerInstance.beforeEach(routerBeforeEachFunc)export default routerInstance
實例化 axios
:
import axios from axiosimport {AXIOS_DEFAULT_CONFIG} from Config/indeximport {requestSuccessFunc, requestFailFunc, responseSuccessFunc, responseFailFunc} from Config/interceptors/axioslet axiosInstance = {}axiosInstance = axios.create(AXIOS_DEFAULT_CONFIG)// 注入請求攔截axiosInstance .interceptors.request.use(requestSuccessFunc, requestFailFunc)// 注入響應攔截axiosInstance .interceptors.response.use(responseSuccessFunc, responseFailFunc)export default axiosInstance
我們在 main.js
注入插件:
// main.jsimport Vue from vueGLOBAL.vbus = new Vue()// import Components// 全局組件註冊import Directives // 指令// 引入插件import router from Plugins/routerimport inject from Plugins/injectimport store from Plugins/store// 引入組件庫及其組件庫樣式 // 不需要配置的庫就在這裡引入 // 如果需要配置都放入 plugin 即可import VueOnsen from vue-onsenuiimport onsenui/css/onsenui.cssimport onsenui/css/onsen-css-components.css// 引入根組件import App from ./AppVue.use(inject)Vue.use(VueOnsen)// rendernew Vue({ el: #app, router, store, template: <App/>, components: { App }})
axios
實例我們並沒有直接引用,相信你也猜到他是通過 inject
插件引用的,我們看下 inject
:
import axios from ./axiosimport api from ./apiimport consts from ./constGLOBAL.ajax = axiosexport default { install: (Vue, options) => { Vue.prototype.$api = api Vue.prototype.$ajax = axios Vue.prototype.$const = consts // 需要掛載的都放在這裡 }}
這裡可以掛載你想在業務中( vue
實例中)便捷訪問的 api
,除了 $ajax
之外,api
和 const
兩個插件是我們服務層中主要的功能,後續會介紹,這樣我們插件流程大致運轉起來,下面寫對應攔截器的方法。
請求,路由攔截器
在ajax 攔截器
中(config/interceptors/axios.js
):
// config/interceptors/axios.jsimport {CONSOLE_REQUEST_ENABLE, CONSOLE_RESPONSE_ENABLE} from ../index.jsexport function requestSuccessFunc (requestObj) { CONSOLE_REQUEST_ENABLE && console.info(requestInterceptorFunc, `url: ${requestObj.url}`, requestObj) // 自定義請求攔截邏輯,可以處理許可權,請求發送監控等 // ... return requestObj}export function requestFailFunc (requestError) { // 自定義發送請求失敗邏輯,斷網,請求發送監控等 // ... return Promise.reject(requestError);}export function responseSuccessFunc (responseObj) { // 自定義響應成功邏輯,全局攔截介面,根據不同業務做不同處理,響應成功監控等 // ... // 假設我們請求體為 // { // code: 1010, // msg: this is a msg, // data: null // } let resData = responseObj.data let {code} = resData switch(code) { case 0: // 如果業務成功,直接進成功回調 return resData.data; case 1111: // 如果業務失敗,根據不同 code 做不同處理 // 比如最常見的授權過期跳登錄 // 特定彈窗 // 跳轉特定頁面等 location.href = xxx // 這裡的路徑也可以放到全局配置里 return; default: // 業務中還會有一些特殊 code 邏輯,我們可以在這裡做統一處理,也可以下方它們到業務層 !responseObj.config.noShowDefaultError && GLOBAL.vbus.$emit(global.$dialog.show, resData.msg); return Promise.reject(resData); }}export function responseFailFunc (responseError) { // 響應失敗,可根據 responseError.message 和 responseError.response.status 來做監控處理 // ... return Promise.reject(responseError);}
定義路由攔截器
(config/interceptors/router.js
):
// config/interceptors/router.jsexport function routerBeforeFunc (to, from, next) { // 這裡可以做頁面攔截,很多後台系統中也非常喜歡在這裡面做許可權處理 // next(...)}
最後在入口文件(config/interceptors/index.js)
中引入並暴露出來即可:
import {requestSuccessFunc, requestFailFunc, responseSuccessFunc, responseFailFunc} from ./ajaximport {routerBeforeEachFunc} from ./routerlet interceptors = { requestSuccessFunc, requestFailFunc, responseSuccessFunc, responseFailFunc, routerBeforeEachFunc}export default interceptors
請求攔截這裡代碼都很簡單,對於 responseSuccessFunc
中 switch default
邏輯做下簡單說明:
responseObj.config.noShowDefaultError
這裡可能不太好理解我們在請求的時候,可以傳入一個 axios 中並沒有意義的noShowDefaultError
參數為我們業務所用,當值為 false 或者不存在時,我們會觸發全局事件global.dialog.show
,global.dialog.show
我們會註冊在app.vue
中:
// app.vueexport default { ... created() { this.bindEvents }, methods: { bindEvents() { GLOBAL.vbus.$on(global.dialog.show, (msg) => { if(msg) return // 我們會在這裡註冊全局需要操控試圖層的事件,方便在非業務代碼中通過發布訂閱調用 this.$dialog.popup({ content: msg }); }) } ... }}
這裡也可以把彈窗狀態放入
Store
中,按團隊喜好,我們習慣把公共的涉及視圖邏輯的公共狀態在這裡註冊,和業務區分開來。
GLOBAL
是我們掛載window
上的全局對象
,我們把需要掛載的東西都放在window.GLOBAL
里,減少命名空間衝突的可能。vbus
其實就是我們開始new Vue()
掛載上去的
GLOBAL.vbus = new Vue()
- 我們在這裡
Promise.reject
出去,我們就可以在error
回調裡面只處理我們的業務邏輯,而其他如斷網
、超時
、伺服器出錯
等均通過攔截器進行統一處理。
攔截器處理前後對比
對比下處理前後在業務中的發送請求的代碼
:
攔截器處理前:
this.$axios.get(test_url).then(({code, data}) => { if( code === 0 ) { // 業務成功 } else if () {} // em... 各種業務不成功處理,如果遇到通用的處理,還需要抽離出來}, error => { // 需要根據 error 做各種抽離好的處理邏輯,斷網,超時等等...})
攔截器處理後:
// 業務失敗走默認彈窗邏輯的情況this.$axios.get(test_url).then(({data}) => { // 業務成功,直接操作 data 即可})// 業務失敗自定義this.$axios.get(test_url, { noShowDefaultError: true // 可選}).then(({data}) => { // 業務成功,直接操作 data 即可}, (code, msg) => { // 當有特定 code 需要特殊處理,傳入 noShowDefaultError:true,在這個回調處理就行})
為什麼要如此配置與攔截器?
在應對項目開發過程中需求的不可預見性時,讓我們能處理的更快更好
到這裡很多同學會覺得,就這麼簡單的引入判斷,可有可無,
就如我們最近做的一個需求來說,我們 ToC 端項目之前一直是在微信公眾號中打開的,而我們需要在小程序中通過 webview 打開大部分流程,而我們也沒有時間,沒有空間
在小程序中重寫近 100 + 的頁面流程,這是我們開發之初並沒有想到的。這時候必須把項目兼容到小程序端,在兼容過程中可能需要解決以下問題:
- 請求路徑完全不同。
- 需要兼容兩套不同的許可權系統。
- 有些流程在小程序端需要做改動,跳轉到特定頁面。
- 有些公眾號的
api
,在小程序中無用,需要調用小程序的邏輯,需要做兼容。 - 很多也頁面上的元素,小程序端不做展示等。
可以看出,稍微不慎,會影響公眾號現有邏輯。
- 添加請求攔截
interceptors/minaAjax.js
,interceptors/minaRouter.js
,原有的換更為interceptors/officalAjax.js
,interceptors/officalRouter.js
,在入口文件interceptors/index.js
,根據當前宿主平台
,也就是全局配置HOST_PLATFORM
,通過代理模式
和策略模式
,注入對應平台的攔截器,在minaAjax.js
中重寫請求路徑和許可權處理,在minaRouter.js
中添加頁面攔截配置,跳轉到特定頁面,這樣一併解決了上面的問題 1,2,3
。 問題 4
其實也比較好處理了,拷貝需要兼容api
的頁面,重寫裡面的邏輯,通過路由攔截器一併做跳轉處理
。問題 5
也很簡單,拓展兩個自定義指令 v-mina-show 和 v-mina-hide ,在展示不同步的地方可以直接使用指令。
最終用最少的代碼,最快的時間完美上線,絲毫沒影響到現有 toC 端業務,而且這樣把所有兼容邏輯絕大部分聚合到了一起,方便二次拓展和修改。
雖然這只是根據自身業務結合來說明,可能沒什麼說服力,不過不難看出全局配置/攔截器 雖然代碼不多,但卻是整個項目的核心之一,我們可以在裡面做更多 awesome
的事情,比如遠端控制一些配置等。
由於字數限制,本文只能寫前四小節,如果還想繼續,可以點此鏈接到下一篇:
Zero:淺談使用 Vue 構建前端 10W+ 代碼的單頁面應用(二)推薦閱讀: