Vue 項目架構設計與工程化實踐


https://segmentfault.com/p/1210000011779700/read

文中會講述我從0~1搭建一個前后端分離的vue項目詳細過程

Feature:

  • 一套很實用的架構設計
  • 通過 cli 工具生成新項目
  • 通過 cli 工具初始化配置文件
  • 編譯源碼與自動上傳CDN
  • Mock 數據
  • 反向檢測server api接口是否符合預期

前段時間我們導航在開發一款新的產品,名叫 快言,是一個主題詞社區,具體這個產品是干什么的就不展開講了,有興趣的小伙伴可以點進去玩一玩~

這個項目的1.0乞丐版上線后,需要一個管理系統來管理這個產品,這個時候我手里快言項目的功能已經上線,暫時沒有其他需要開發的功能,所以我跑去找我老大把后台這個項目給拿下了。

技術選型

接到這個任務后,我首先考慮這個項目日后會變得非常復雜,功能會非常多。所以需要精心設計項目架構和開發流程,保證項目后期復雜度越來越高的時候,代碼可維護性依然保持最初的狀態

后台項目需要頻繁的發送請求,操作dom,以及維護各種狀態,所以我需要先為項目選擇一款合適的mvvm框架,綜合考慮最后項目框架選擇使用 Vue,原因是:

  • 上手簡單,團隊新人可以很容易就參與到這個項目中進行開發,對開發者水平要求較低(畢竟是團隊項目,門檻低我覺得非常重要)
  • 我個人本身對Vue還算比較熟悉,一年前2.0還沒發布的時候閱讀過vue 1.x的源碼,對vue的原理有了解,項目開發中遇到的所有問題我都有信心能解決掉
  • 調研了我們團隊的成員,大部分都使用過vue,對vue多少都有過開發經驗,並且之前團隊內也用vue開發過一些項目

所以最終選擇了Vue

選擇vue周邊依賴(全家桶)

框架定了Vue 后,接下來我需要挑選一些vue套餐來幫助開發,我挑選的套餐有:

  • vuex - 項目復雜后,使用vuex來管理狀態必不可少
  • element-ui - 基於vue2.0 的組件庫,餓了么的這套組件庫還挺好用的,功能也全
  • vue-router - 單頁應用必不可少需要使用前端路由(這種管理系統非常適合單頁應用,系統經常需要頻繁的切換頁面,使用單頁應用可以很快速的切換頁面而且數據也是按需加載,不會重復加載依賴)
  • axios - vue 官方推薦的http客戶端
  • vue-cli 的 webpack 模板,這套模板是功能最全的,有hot reload,linting,testing,css extraction 等功能

架構設計

在開發這個項目前,我去參加了北京的首屆 vueconf 大會,其中有一個主題是陰明講的《掘金 Vue.js 2.0 后端渲染及重構實踐》,講了掘金重構后的架構設計,我覺得他們的架構設計的挺不錯,所以參考掘金的架構,設計了一個更適合我們自己業務場景的架構

整體架構圖

整體架構圖

目錄結構

.
├── README.md
├── build                   # build 腳本 ├── config # prod/dev build config 文件 ├── hera # 代碼發布上線 ├── index.html # 最基礎的網頁 ├── package.json ├── src # Vue.js 核心業務 │ ├── App.vue # App Root Component │ ├── api # 接入后端服務的基礎 API │ ├── assets # 靜態文件 │ ├── components # 組件 │ ├── event-bus # Event Bus 事件總線,類似 EventEmitter │ ├── main.js # Vue 入口文件 │ ├── router # 路由 │ ├── service # 服務 │ ├── store # Vuex 狀態管理 │ ├── util # 通用 utility,directive, mixin 還有綁定到 Vue.prototype 的函數 │ └── view # 各個頁面 ├── static # DevServer 靜態文件 └── test # 測試 

從目錄結構上,可以發現我們的項目中沒有后端代碼,因為我們是純前端工程,整個git倉庫都是前端代碼,包括后期發布上線都是前端項目獨立上線,不依賴后端~

代碼發布上線的時候會先進行編譯,編譯的結果是一個無任何依賴的html文件 index.html,然后把這個 index.html 發布到服務器上,在編譯階段所有的依賴,包括css,js,圖片,字體等都會自動上傳到cdn上,最后生成一個無任何依賴的純html,大概是下面的樣子:

<!DOCTYPE html><html><head><meta charset=utf-8><title>快言管理后台</title><link rel=icon href=https://www.360.cn/favicon.ico><link href=http://s3.qhres.com/static/***.css rel=stylesheet></head><body><div id=app></div><script type=text/javascript src=http://s2.qhres.com/static/***.js></script><script type=text/javascript src=http://s8.qhres.com/static/***.js></script><script type=text/javascript src=http://s2.qhres.com/static/***.js></script></body></html>

表現層

  • store/ - Vuex 狀態管理
  • router/ - 前端路由
  • view/ - 各個業務頁面
  • component/ - 通用組件

業務層

  • service/ - 處理服務端返回的數據(類似data format),例如 service 同時調用了不同的api,把不同的返回數據整合在一起在統一發送到 store 中

API 層

  • api/ - 請求數據,Mock數據,反向校驗后端api

util 層

  • util/ - 存放項目全局的工具函數
  • … 如果后期項目需要,例如需要寫一些vue自定義的指令,可以在這個根據需要自行創建目錄,也屬於util層

基礎設施層

  • init - 自動化初始化配置文件
  • dev - 啟動dev-server,hot-reload,http-proxy 等輔助開發
  • deploy - 編譯源碼,靜態文件上傳cdn,生成html,發布上線

全局事件機制

  • event-bus/ - 主要用來處理特殊需求

關於這一層我想詳細說一下,這一層最開始我覺得沒什么用,並且這個東西很危險,新手操作不當很容易出bug,所以就沒加,后來有一個需求正好用到了我才知道event-bus是用來干什么的

event-bus 我不推薦在業務中使用,在業務中使用這種全局的事件機制非常容易出bug,而且大部分需求通過vuex維護狀態就能解決,那 event-bus 是用來干什么的呢?

用來處理特殊需求的,,,,那什么是特殊需求呢,我說一下我們在什么地方用到了event-bus

場景:
我們的項目是純前端項目,又是個管理系統,所以登陸功能就比較神奇

登陸流程

上面是登陸的整體流程圖,關於登陸前端需要做幾個事情:

  1. 監聽所有api的響應,如果未登錄后端會返回一個錯誤碼
  2. 如果后端返回一個未登錄的錯誤碼,前端需要跳轉到公司統一的登陸中心去登陸,登陸成功后會跳轉回當前地址並在url上攜帶sid
  3. 監聽所有路由,如果發現路由上帶有sid,說明是從登陸中心跳過來的,用這個sid去請求一下用戶信息
  4. 登陸成功並拿到用戶信息

經過上面一系列的登陸流程,最后的結果是登陸之后會拿到一個用戶信息,這個獲取用戶信息的操作是在router里發起的執行,那么問題就來了,router中拿到了用戶信息我希望把這個用戶信息放到store里,因為在router中拿不到vue實例,無法直接操作vuex的方法,這個時候如果沒有 event-bus 就很難操作。

所以通常 event-bus 我們都會用在表現層下面的其他層級(沒有vue實例)之間通信,而且必須要很清楚自己在做什么

為什么 event-bus 很容易出問題?好像它就是一個普通的事件機制而已,為什么那么危險?

這是個好問題,我說一下我曾經遇到的一個問題。先描述一個很簡單的業務場景:“進入一個頁面然后加載列表,然后點擊了翻頁,重新拉取一下列表”

用event-bus來寫的話是這樣的:

watch: {
  '$route' () { EventHub.$emit('word:refreshList') } }, mounted () { EventBus.$on('word:refreshList', _ => { this.changeLoadingState(true) .then(this.fetchList) .then(this.changeLoadingState.bind(this, false)) .catch(this.changeLoadingState.bind(this, false)) }) EventBus.$emit('word:refreshList') }

watch 路由,點擊翻頁后觸發事件重新拉取一下列表,

功能寫完后測試了發現功能都好使,沒什么問題就上線了

然后過了幾天偶然一次發現怎么 network 里這么多重復的請求?點了一次翻頁怎么發了這么多個 fetchList 的請求???什么情況????

這里有一個新手很容易忽略的問題,即便是經驗非常豐富的人也會在不注意的情況犯錯,那就是生命周期不同步的問題,event-bus 的聲明周期是全局的,只有在頁面刷新的時候 event-bus 才會重置內部狀態,而組件的聲明周期相對來說就短了很多,所以上面的代碼當我進入這個組件然后又銷毀了這個組件然后又進入這個組件反復幾次之后就會在 event-bus 中監聽了很多個 word:refreshList 事件,每次觸發事件實際都會有好多個函數在執行,所以才會在 network 中發現N多個相同的請求。

所以發現這個bug之后趕緊加了幾行代碼把這個問題修復了:

destroyed () {
  EventHub.$off('word:refreshList') }

自從出了這個問題之后,我就像與我一同開發后台的小伙伴說了這個事,建議所有業務需求最好不要在使用event-bus了,除非很清楚的知道自己正在干什么。

發布上線

項目架構搭建好了之后已經可以開始寫業務了,所以我每天的白天是在開發業務功能,晚上和周末的時間用來開發編譯上線的功能

編譯源碼

前面說了我們的項目是純前端工程,所以期望是編譯出一個無任何依賴的純html文件

編譯

在使用 vue-cli 初始化項目的時候,官方的 webpack 模板會把webpack的配置都設置好,項目生成好了之后直接運行 npm run build 就可以編譯源碼,但是編譯出來的html中依賴的js、css是本地的,所以我現在要做的事情就是想辦法把這些編譯后的靜態文件上傳cdn,然后把html中的本地地址替換成上傳cdn之后的地址

項目是通過webpack插件 HtmlWebpackPlugin 來生成html的,所以我想這個插件應該會有接口來輔助我完成任務,所以我查看了這個插件的文檔,發現這個插件會觸發一些事件,我感覺這些事件應該可以幫助我完成任務,所以我寫了demo來嘗試一下各個事件都是干什么用的以及有什么區別,經過嘗試發現了一個事件名叫 html-webpack-plugin-alter-asset-tags的事件可以幫助我完成任務,所以我寫了下面這樣的代碼:

var qcdn = require('@q/qcdn') function CdnPlugin (options) {} CdnPlugin.prototype.apply = function (compiler) { compiler.plugin('compilation', function(compilation) { compilation.plugin('html-webpack-plugin-alter-asset-tags', function(htmlPluginData, callback) { console.log('> Static file uploading cdn...') var bodys = htmlPluginData.body.map(upload(compilation, htmlPluginData, 'body')) var heads = htmlPluginData.head.map(upload(compilation, htmlPluginData, 'head')) Promise.all(heads.concat(bodys)) .then(function (result) { console.log('> Static file upload cdn done!') callback(null, htmlPluginData) }) .catch(callback) }) }) } var extMap = { script: { ext: 'js', src: 'src' }, link: { ext: 'css', src: 'href' }, } function upload (compilation, htmlPluginData, type) { return function (item, i) { if (!extMap[item.tagName]) return Promise.resolve() var source = compilation.assets[item.attributes[extMap[item.tagName].src].replace(/^(\/)*/g, '')].source() return qcdn.content(source, extMap[item.tagName].ext) .then(function qcdnDone(url) { htmlPluginData[type][i].attributes[extMap[item.tagName].src] = url return url }) } } module.exports = CdnPlugin

其實原理並不復雜,compilation.assets 里保存了文件內容,htmlPluginData 里保存了如何輸出html, 所以從 compilation.assets 中讀取到文件內容然后上傳CDN,然后用上傳后的CDN地址把htmlPluginData 中的本地地址替換掉就行了。

然后將這個插件添加到build/webpack.prod.conf.js配置文件中。

這里有個關鍵點是,html中的依賴和靜態文件中的依賴是不同的處理方式

什么意思呢,舉個例子:

源碼編譯后生成了幾個靜態文件,把這些靜態文件上傳到cdn,然后用cdn地址替換掉html里的本地地址(就是上面CdnPlugin剛剛做的事情)

你以為完事了? No!No!No!

CdnPlugin 只是把在html中引入的編譯后的js,css上傳了cdn,但是js,css中引入的圖片或者字體等文件並沒上傳cdn

如果代碼中引入了本地的某個圖片或字體,編譯后這些地址還是本地的,此時的html是有依賴的,是不純的,如果只把html上線了,代碼中依賴的這些圖片和字體在服務器上找不到文件就會有問題

所以需要先把源碼中依賴的靜態文件(圖片,字體等)上傳到cdn,然后在把編譯后的靜態文件(js,css)上傳cdn。

代碼中依賴的靜態文件例如圖片,怎么上傳cdn呢?

答案是用 loader 來實現,webpack 中的 loader 以我的理解它是一個filter,或者是中間件,總之就是 import 一個文件的時候,這個文件先通過loader 過濾一遍,把過濾后的結果返回,過濾的過程可以是 babel這種編譯代碼,當然也可以是上傳cdn,所以我寫了下面這樣的代碼:

var loaderUtils = require('loader-utils') var qcdn = require('@q/qcdn') module.exports = function(content) { this.cacheable && this.cacheable() var query = loaderUtils.getOptions(this) || {} if (query.disable) { var urlLoader = require('url-loader') return urlLoader.call(this, content) } var callback = this.async() var ext = loaderUtils.interpolateName(this, '[ext]', {content: content}) qcdn.content(content, ext) .then(function upload(url) { callback(null, 'module.exports = ' + JSON.stringify(url)) }) .catch(callback) } module.exports.raw = true

其實就是把 content 上傳CDN,然后把CDN地址拋出去

有了這個loader 之后,在 import 圖片的時候,拿到的就是一個cdn的地址~

但是我不想在開發環境也上傳cdn,我希望只有在生成環境才用這個loader,所以我設置了一個 disable 的選項,如果 disable 為 true,我使用 url-loader 來處理這個文件內容。

最后把loader也添加到配置文件中:

rules: [ ..., { test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, loader: path.join(__dirname, 'cdn-loader'), options: { disable: !isProduction, limit: 10000, name: utils.assetsPath('img/[name].[hash:7].[ext]') } } ]

寫好了 cdn-loader 和 cdn-plugin 之后,已經可以編譯出一個無任何依賴的純html,下一步就是把這個html文件發布上線

發布上線

我們部門有自己的發布上線的工具叫 hera 可以把代碼發布到docker機上進行編譯,然后把編譯后的純html文件發布到事先配置好的服務器的指定目錄中

編譯的流程是先把代碼發布到編譯機上 -> 編譯機啟動 docker (docker可以保證編譯環境相同) -> 在 docker中執行 npm install 安裝依賴 -> 執行 npm run build 編譯 -> 把編譯后的 html 發送到服務器

因為每次編譯都需要安裝依賴,速度非常慢,所以我們有一個 diffinstall 的邏輯,每次安裝依賴都會進行一次 diff,把有緩存的直接用緩存copy到node_modules,沒緩存的使用qnpm安裝,之后會把這次新安裝的依賴緩存一份。依賴緩存了之后每次安裝依賴速度明顯快了很多。

現在項目已經可以正常開發和上線啦~

api-proxy

雖然項目可以正常開發了,但我覺得還不夠,我希望項目可以有 mock 數據的功能並且可以檢查服務端返回的數據是否正確,可以避免因為接口返回數據不正確的問題debug好久。

所以我開發了一個簡單的模塊 api-proxy ,就是封裝了一個http client,可以配置請求信息和Mock 規則,開啟Mock的時候使用Mock規則生成Mock數據返回,不開啟Mock的時候使用Mock規則來校驗接口返回是否符合預期。

那么 api-proxy 怎樣使用呢?

舉個例子:

.
└── api
    └── log ├── index.js └── fetchLogs.js 
/*  * /api/log/fetchLogs.js  */ export default { options: { url: '/api/operatelog/list', method: 'GET' }, rule: { 'data': { 'list|0-20': [{ 'id|3-7': '1', 'path': '/log/opreate', 'url': '/operate/log?id=3', 'user': 'berwin' }], 'pageData|7-8': { 'cur': 1, 'first': 1, 'last': 1, 'total_pages|0-999999': 1, 'total_rows|0-999999': 1, 'size|0-999999': 1 } }, 'errno': 0, 'msg': '操作日志列表' } }
/*  * /api/log/index.js  */ import proxy from '../base.js' import fetchLogs from './fetchLogs.js' export default proxy.api({ fetchLogs })

使用:

import log from '@/api/log' log.fetchLogs(query) .then(...)

考慮到特殊情況,也並不是強制必須這樣使用,我還是拋出了一個 api方法來供開發者正常使用,例如:

// 不使用api-proxy的api import {api} from './base' export default { getUserInfo (sid) { return api.get('/api/user/getUserInfo', { params: { sid } }) } }

這個 api 就是 axios ,並沒做什么特殊處理。

初始化配置文件

項目開發中會用到一些配置文件,比如開發環境需要配置一個server地址用來設置api請求的server。開發環境的配置文件每個人都不一樣,所以我在 .gitignore 中把這個dev.conf 屏蔽掉,並沒有入到版本庫中,所以就帶來了一個問題,每次有新人進入到這個項目,在第一次搭建項目的時候,總是要手動創建一個 dev.conf 文件,我希望能自動創建配置文件

正巧之前我寫了一個類似於 vue-cli 的工具 speike-cli,也是通過模板生成項目的一個工具,所以這一次正好派上用場,我把配置文件定義了一個模板,然后使用 speike 來生成了一個配置文件

// package.json { "scripts": { "init": "speike init ./config/init-tpl ./config/dev.conf" } }

init conf

初始化項目

這次該有的都有了,可以愉快的寫碼了,為了以后有類似的管理系統創建項目方便,我把這次精心設計的架構,編譯邏輯等定制成了模板,日后可以直接使用speike 選擇這個模板來生成項目。

init project

整理與總結

經過上面一系列做的事,最后整理一下項目工程化的生命周期

life cycle

了解更多可以看我寫過的 PPT


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM