淺談使用 Vue 構建前端 10W+ 代碼的單頁面應用(一)

淺談使用 Vue 構建前端 10W+ 代碼的單頁面應用(一)

126 人贊了文章

開始之前

隨著業務的不斷累積,目前我們 ToC 端主要項目,除去 node_modulesbuild 配置文件dist 靜態資源文件的代碼量為 137521 行,後台管理系統下各個子應用代碼,除去依賴等文件的總行數也達到 100萬 多一點。

代碼量意味不了什麼,只能證明模塊很多,但相同兩個項目,在運行時性能相同情況下,你的 10 萬行代碼能容納並維護 150 個模塊,並且開發順暢,我的項目中 10 萬行代碼卻只能容納 100 個模塊,添加功能也好,維護起來也較為繁瑣,這就很值得思考

本文會在主要描述以 Vue 技術棧技術主體ToC 端項目業務主體,在構建過程中,遇到或者總結的點(也會提及一些 ToB 項目的場景),可能並不適合你的業務場景(僅供參考),我會儘可能多的描述問題與其中的思考,最大可能的幫助到需要的同學,也辛苦開發者發現問題或者不合理/不正確的地方及時向我反饋,會儘快修改,歡迎有更好的實現方式來 pr

Git 地址

  • vue-develop-template 完善中,可以運行

React 項目

可以參考螞蟻金服數據體驗技術團隊編寫的文章:

- 如何管理好10萬行代碼的前端單頁面應用

本文並不是基於上面文章寫的,不過當時在看到他們文章之後覺得有相似的地方,相較於這篇文章,本文可能會枯燥些,會有大量代碼,同學可以直接用上倉庫看。

大綱:

  • ① 單頁面,多頁面
  • ② 目錄結構
  • ③ 通用組件
  • ④ 全局配置,插件與攔截器
  • ⑤ 路由配置與懶載入
  • ⑥ Service 服務層
  • ⑦ 狀態管理與視圖拆分

由於字數限制,本文會拆分成兩個部分,您也可以直接點擊下方地址一次看完。

淺談使用 Vue 構建前端 10w+ 代碼量的單頁面應用開發底層?

juejin.im


① 單頁面,多頁面

首先要思考我們的項目最終的構建主體單頁面,還是多頁面,還是單頁 + 多頁,通過他們的優缺點來分析:

  • 單頁面(SPA)
    • 優點:體驗好,路由之間跳轉流程,可定製轉場動畫,使用了懶載入可有效減少首頁白屏時間,相較於多頁面減少了用戶訪問靜態資源伺服器的次數等。
    • 缺點:初始會載入較大的靜態資源,並且隨著業務增長會越來越大,懶載入也有他的弊端,不做特殊處理不利於 SEO 等。
  • 多頁面(MPA)
    • 優點:對搜索引擎友好,開發難度較低。
    • 缺點:資源請求較多,整頁刷新體驗較差,頁面間傳遞數據只能依賴 URLcookiestorage 等方式,較為局限。
  • 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-loaderhappypack 中添加 includenode_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 之外,apiconst 兩個插件是我們服務層中主要的功能,後續會介紹,這樣我們插件流程大致運轉起來,下面寫對應攔截器的方法。

請求,路由攔截器

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 邏輯做下簡單說明:

  1. responseObj.config.noShowDefaultError 這裡可能不太好理解

    我們在請求的時候,可以傳入一個 axios 中並沒有意義的 noShowDefaultError 參數為我們業務所用,當值為 false 或者不存在時,我們會觸發全局事件 global.dialog.showglobal.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 中,按團隊喜好,我們習慣把公共的涉及視圖邏輯的公共狀態在這裡註冊,和業務區分開來

  1. GLOBAL 是我們掛載 window 上的全局對象,我們把需要掛載的東西都放在 window.GLOBAL 里,減少命名空間衝突的可能。
  2. vbus 其實就是我們開始 new Vue() 掛載上去的

GLOBAL.vbus = new Vue()

  1. 我們在這裡 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 + 的頁面流程,這是我們開發之初並沒有想到的。這時候必須把項目兼容到小程序端,在兼容過程中可能需要解決以下問題:

  1. 請求路徑完全不同。
  2. 需要兼容兩套不同的許可權系統。
  3. 有些流程在小程序端需要做改動,跳轉到特定頁面。
  4. 有些公眾號的 api ,在小程序中無用,需要調用小程序的邏輯,需要做兼容。
  5. 很多也頁面上的元素,小程序端不做展示等。

可以看出,稍微不慎,會影響公眾號現有邏輯。

  • 添加請求攔截 interceptors/minaAjax.jsinterceptors/minaRouter.js,原有的換更為 interceptors/officalAjax.jsinterceptors/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+ 代碼的單頁面應用(二)?

zhuanlan.zhihu.com圖標
推薦閱讀:

如何更愉快地使用rem —— 別說你懂CSS相對單位
「前端面試題大全01」
Flutter 初體驗

TAG:前端開發 | 前端工程師 | Vuejs |