文中會講述我從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 後端渲染及重構實踐》,講了掘金重構後的架構設計,我覺得他們的架構設計的挺不錯,所以參考掘金的架構,設計了一個更適合我們自己業務場景的架構
整體架構圖
目錄結構
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
. ├── 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,大概是下面的樣子:
1 |
<!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
場景:
我們的專案是純前端專案,又是個管理系統,所以登陸功能就比較神奇
上面是登陸的整體流程圖,關於登陸前端需要做幾個事情:
- 監聽所有api的響應,如果未登入後端會返回一個錯誤碼
- 如果後端返回一個未登入的錯誤碼,前端需要跳轉到公司統一的登陸中心去登陸,登陸成功後會跳轉回當前地址並在url上攜帶sid
- 監聽所有路由,如果發現路由上帶有sid,說明是從登陸中心跳過來的,用這個sid去請求一下使用者資訊
- 登陸成功並拿到使用者資訊
經過上面一系列的登陸流程,最後的結果是登陸之後會拿到一個使用者資訊,這個獲取使用者資訊的操作是在router裡發起的執行,那麼問題就來了,router中拿到了使用者資訊我希望把這個使用者資訊放到store裡,因為在router中拿不到vue例項,無法直接操作vuex的方法,這個時候如果沒有 event-bus 就很難操作。
所以通常 event-bus 我們都會用在表現層下面的其他層級(沒有vue例項)之間通訊,而且必須要很清楚自己在做什麼
為什麼 event-bus 很容易出問題?好像它就是一個普通的事件機制而已,為什麼那麼危險?
這是個好問題,我說一下我曾經遇到的一個問題。先描述一個很簡單的業務場景:“進入一個頁面然後載入列表,然後點選了翻頁,重新拉取一下列表”
用event-bus來寫的話是這樣的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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之後趕緊加了幾行程式碼把這個問題修復了:
1 2 3 |
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
的事件可以幫助我完成任務,所以我寫了下面這樣的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
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,所以我寫了下面這樣的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
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也新增到配置檔案中:
1 2 3 4 5 6 7 8 9 10 11 12 |
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 怎樣使用呢?
舉個例子:
1 2 3 4 5 6 |
. └── api └── log ├── index.js └── fetchLogs.js |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
/* * /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': '操作日誌列表' } } |
1 2 3 4 5 6 7 8 9 |
/* * /api/log/index.js */ import proxy from '../base.js' import fetchLogs from './fetchLogs.js' export default proxy.api({ fetchLogs }) |
使用:
1 2 3 |
import log from '@/api/log' log.fetchLogs(query) .then(...) |
考慮到特殊情況,也並不是強制必須這樣使用,我還是丟擲了一個 api方法來供開發者正常使用,例如:
1 2 3 4 5 6 7 8 9 10 11 12 |
// 不使用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 來生成了一個配置檔案
1 2 3 4 5 6 |
// package.json { "scripts": { "init": "speike init ./config/init-tpl ./config/dev.conf" } } |
初始化專案
這次該有的都有了,可以愉快的寫碼了,為了以後有類似的管理系統建立專案方便,我把這次精心設計的架構,編譯邏輯等定製成了模板,日後可以直接使用speike
選擇這個模板來生成專案。
整理與總結
經過上面一系列做的事,最後整理一下專案工程化的生命週期