作者:滴滴公共前端 黃軼
專案背景
滴滴的 webapp 是執行在微信、支付寶、手 Q 以及其它第三方渠道的叫車軟體。藉著產品層面的功能和視覺升級,我們用 Vue 2.0 對它進行了一次技術重構。
技術棧
MVVM框架: Vue 2.0
原始碼:es6
程式碼風格檢查:eslint
構建工具:webpack
前端路由:vue-router
狀態管理:vuex
服務端通訊:vue-resource
幾個問題
滴滴 webapp 是一個大的 SPA 應用麼?
滴滴 webapp 包含眾多業務線,每個業務線都有獨立的一套的發單流程邏輯。那麼問題來了,這些業務邏輯都是在一個單頁中完成的麼?如何實現元件化?
滴滴 webapp 5.0 的設計思路就是元件化,設計提供了很多元件,每個頁面都是由元件拼接而成。那麼問題來了,如何區分基礎元件和業務元件,並把基礎元件抽象成一個公共元件庫?一個程式碼倉庫多條業務線,如何很好的做到多人同時開發和持續整合?
滴滴有多條業務線,每條業務線會有一位前端同學開發程式碼。那麼問題來了,如何模組化的組織程式碼,如何儘可能的減少開發的衝突以及做好持續整合?有部分業務線需要非同步載入,這部分業務線如何開發?
滴滴目前會把類專車業務線的程式碼放在一個倉庫裡,但是部分業務線,如順風車的程式碼是不在這個倉庫裡的。那麼問題來了,這部分程式碼如何開發,如何使用 Vue,Vuex,store,以及一些公用方法和元件?非同步載入的業務線元件,如何動態註冊?
我們需要非同步載入業務線的 JS 程式碼,這些業務線實現的是一個 Vue component。那麼問題來了,如何優雅地動態註冊這些元件呢?非同步載入的業務線如何動態註冊路由?
我們在使用 Vue-router 初始化路由的時候,通常都會寫一個路由對映表。那麼問題來了,這些非同步載入的業務線,如果也想使用路由,那麼如何動態註冊呢?如何在測試環境下與後端介面互動?
我們在開發階段,通常都是在本地除錯,本地起的服務域名通常是 localhost:埠號。那麼問題來了,這樣會和 ajax 請求產生跨域問題,而我們也不能要求服務端把所有 ajax 請求介面都開跨域,如何解決呢?如何部署到線下測試環境?
我們在本地開發完程式碼後,需要把程式碼提測。通常測試拿到程式碼後,需要部署和測試,那麼問題來了,我們如何把原生程式碼部署到我們的開發機測試環境中呢?
解決方案
滴滴 webapp 是一個大的 SPA 應用麼?
滴滴 webapp 包含眾多業務線,每個業務線都有獨立的一套的發單 -> 接駕 -> 行程中 -> 訂單完成的流程邏輯。試想一下,如果整體是一個 SPA 應用,最終打包的 JS 會變的很大,雖然可以通過 code spliting 技術非同步載入,但也不可避免會增加程式碼量,而且非常不利於維護。
因此,我們把發單和後續的業務邏輯拆開,拆成發單首頁和後續流程頁面,每個業務線都有自己獨立的發單後的流程頁面。這樣滴滴的 webapp 相當於多個 SPA 應用構成,頁面間跳轉的資料傳遞通過 url 傳參即可。
如何實現元件化?
元件化現在幾乎成為 webapp 開發的標準,滴滴從設計角度就已經是元件化的思路了。但是設計只會把頁面拆成一個個元件,我們作為開發者,需要從這些眾多元件中提取出哪些是基礎元件,哪些是業務元件,哪些元件可被複用等等。
基礎元件主要指那些本身不包含任何業務邏輯、可以被輕鬆複用的元件,例如 picker、timepicker、toast、dialog、actionsheet 等等...我們基於 Vue 2.0 實現了一套移動端的基礎元件庫,打包了所有基礎元件,並託管在 npm 私服上,使用非常方便。基礎元件的通訊基本就是往元件傳入 prop,並監聽元件 $emit 的事件。
業務元件主要指那些包含業務邏輯,包括一些與後端介面通訊的邏輯。業務元件會包含若干個基礎元件,通常我們會把一些業務邏輯的資料通過 Vuex 管理起來,然後元件內部讀取資料和提交對資料修改的動作。這裡需要說明一點,當我們使用 Vuex 的時候,並不是所有和後端通訊的請求都需要提交一個 action,如果這個請求並不會修改我們 store 裡的資料,可以在元件內部消化。舉個實際的例子,我們在開發 suggest 元件的時候,每次輸入字元檢索相關的地址的時候,這個請求由元件內部發起,並且把請求的資料渲染到元件的列表即可,因為它並沒有修改 store 裡的資料。
基礎元件通常都是可複用的,部分業務元件同樣可複用,它們的 UI 和業務邏輯相似。我們會把單個可複用的業務元件單獨釋出到 npm 私服上,需要使用的業務線依賴即可。注意,業務元件我們是不建議使用 Vuex,需要考慮到不同的使用方對 Vuex 內部變數的定義和使用是不相同的。
一個程式碼倉庫多條業務線,如何很好的做到多人同時開發和持續整合?
滴滴的 webapp 首頁有多條業務線,每條業務線都有一個開發人員,為了保證儘量減少程式碼的衝突,我們按業務線對程式碼進行了模組劃分。由於 Vuex 支援modules,我們很自然地按業務線拆分了 modules,每個 modules 維護自己的 getters、actions、mutaions 和 state,而一些公共資料如經緯度、上下車資訊、使用者登入狀態等作為 root state,被所有業務線共享。同樣,components 裡也按業務線做了更細緻的劃分,每個業務線獨立的業務元件放在各自的目錄裡,彼此之前不會有衝突。
僅僅做到目錄拆分還是不夠的,我們還要考慮到持續整合,跟著產品的版本迭代節奏釋出上線。那麼每個版本的需求,每個業務線都會參與開發,我們用 gitlab 管理程式碼,如果每個開發同學都拉一個分支,那麼會面臨著分支太多,功能聯調麻煩等問題。因此,我們約定了一套 git 的管理規範,每個大需求版本,我們會約定以 "dev +上線時間日期" 作為分支名建立開發分支,所有人在這個分支上開發,開發完成讓 QA 測試該分支,上線前才會將分支合入主幹釋出。在兩個版本釋出期間如果有 bug fix,則約定以 "bugfix + 功能描述" 為分支名建立 bugfix 分支,修復完成後合入主幹上線。每次上線前,我們都會執行指令碼新增版本號,編譯打包,保證前端資源的增量釋出。
有部分業務線需要非同步載入,這部分業務線如何開發?
滴滴目前會把一些業務線的程式碼放在一個倉庫裡,但是部分業務線,如順風車的程式碼是不在這個倉庫裡的。首頁通過非同步載入 JS 去載入這部分業務線的程式碼,這部分業務線很顯然也是需要用 Vue 開發的,但是他們不可以再去單獨引入 Vue.js。
我們的解決方案是在 window 上註冊一個 XXApp 物件,把 Vue、Vuex 以及一些公共元件和方法等掛載到這個物件上,那麼這些非同步載入的業務線就可以通過 window.XXApp 訪問到了,程式碼如下:
window.XXApp = { Vue, Vuex, store, // 全域性store saveCurrentBiz, // 公共方法 Location // 公共元件 // 其它一些公共方法和元件 }複製程式碼
業務線可以訪問到這些物件後,接下來需要實現的就是一個 Vue component。
非同步載入的業務線元件,如何動態註冊?
Vue.js 官網提供的非同步元件的解決方案大多是基於 webpack 的 require.ensure 去非同步載入元件的,但很顯然這並不適用滴滴的業務場景,因為我們的程式碼並不在一個倉庫下。我們需要一種類似 amd 的解決方案,這些非同步業務線需要實現的是一個 Vue component,我們該如何優雅地動態註冊這個 component 呢?
我們知道 Vue 提供了動態註冊元件的 api,通過
Vue.component('async-example',function(resolve){ //... })
的方式在工廠函式裡通過 resolve 方法動態註冊元件。注意,這個工廠函式的執行時機是元件實際需要渲染時,那我們渲染這些非同步元件的時機就是當我們切換頂部導航欄到該業務線的時候。首先,每一條業務線對應著一個獨立的元件,業務線有各自的 id,因此,我們先用一個物件去維護這樣的對映關係,程式碼如下:
const modules = { 業務線id: Taxi, // 計程車 // 其它同步業務線元件 }複製程式碼
這個物件初始化的都是同步業務線元件,對於非同步載入的業務線元件,我們需要動態註冊。首先我們在全域性的 config.js 裡維護一個業務線的配置關係表,非同步載入的業務線會多一個 src 屬性,程式碼如下:
bizConf: { 非同步業務線id: { name: 'alift', // 業務線名稱 src: xxx // 載入非同步業務線的 js 地址 }, 同步業務線 id: { name: 'taxi' } // 其它業務線配置複製程式碼
接下來我們遍歷這個物件,程式碼如下:
// 獲取 bizConf 物件 const bizJSConf = config.get('bizConf') for (let id in bizJSConf) { let conf = bizJSConf[id] if (conf.src) { modules[id] = conf.name Vue.component(conf.name, (resolve, reject) => { loadScript(conf.src).then(() => { resolve(modules[id]) }).catch(() => { reject() }) }) } }複製程式碼
可以看到,對於非同步業務線,我們會把它的 name 新增到 modules 物件的對映關係中,並按這個 name 註冊一個非同步元件,注意,這個時候註冊元件的工廠函式並不會執行。
我們之前說到了渲染這些非同步元件的時機就是當我們切換頂部導航欄到該業務線的時候,我們來看看切換頂部導航欄的時候執行了什麼邏輯,關鍵程式碼如下:
this.currentView = modules[productid]複製程式碼
這個 currentView 我們是在 App.vue 的 data 裡初始化的,對映到 template 的程式碼如下:
<component :is="currentView"></component>複製程式碼
沒錯,這裡我們又用到一個 Vue 的高階用法,動態元件。我們的業務線元件對應的就是這個動態元件。官網文件介紹的動態元件是繫結到一個元件物件上的,這對於我們的同步元件,當然是沒有問題的,modules 對映的就是一個元件物件;但是對於非同步元件,我們對映的是元件的名稱,它是一個字串,當 currentView 指向這個字串的時候,註冊非同步元件的工廠函式執行了,回顧之前的程式碼,這個時候它會去載入非同步業務線的 js,載入完成的回撥函式裡,執行
resolve(modules[id])
。等等,看到這裡,有人不禁會問,這裡 modules[id] 是什麼,還是非同步元件的名稱嗎?當然不是了,這裡的 modules[id] 對應的是非同步業務線的元件物件。那麼,它是怎麼被賦值成元件物件的呢?我們來看程式碼:
window.XXApp = { // ... // 一些公共方法和元件 registerBiz(id, component) { modules[id] = component } }複製程式碼
我們在 window.XXApp 下又新增了一個 registerBiz 的方法,當我們非同步載入完業務線的 JS 後,非同步業務線呼叫這個方法真正的把自己實現的 Vue component 註冊到我們的 modules 裡,所以我們 resolve 的就是這個元件物件,是不是很優雅?至此,我們完成了非同步業務線元件的動態註冊。
非同步載入的業務線如何動態註冊路由?
再接著上述問題繼續發散,我們在使用 Vue-router 初始化路由的時候,通常都會寫一個路由對映表。對於同步業務線這些已知的元件,路由的對映是沒有問題的,那麼這些非同步載入的業務線,如果它的某些子元件也想使用路由該怎麼辦?我們需要一套動態註冊路由的方案,而官網文件提供的路由懶載入的方案並不能滿足我們的需求,因此我們想到了另一種變通方案。我們在路由配置如下:
{ path: 'pathA' //這裡的命名只是示意 component: componentA }, { path: 'pathB', component: componentB }, //... { path: '/:name', // 動態匹配 component: Dynamic // 已知元件 }複製程式碼
可以看到,我們在定義了一系列常規的路由後,最後定義了一個動態匹配路由,也就是任意 name 的一級 path,只要沒有命中之前的 path,都會對映到這個我們定義好的 Dynamic 元件上。我們來看看這個 Dynamic 元件的實現,先看一下模板:
<template> <transition :name="transitionName"> <component :is="currentRouter"></component> </transition> </template>複製程式碼
本質上,Dynamic 元件還是利用了 Vue 的動態元件,通過修改 currentRouter 這個變數,可以改變當前渲染的元件。我們來看一下這個 currentRouter 修改的邏輯:
created() { this.setCurrent() }, methods: { setCurrent() { const name = this.$route.params.name const component = this.routes[name] if (component) { this.currentRouter = component } } }複製程式碼
在元件建立的鉤子函式裡,我們會呼叫
this.setCurrent()
,該方法首先通過路由引數拿到 name,然後從this.routes[name]
拿到對應的元件,並賦給this.currentRouter
。那麼this.routes
變的尤為重要了。我們實際上是把 routes 儲存到了 Vuex 的 store 裡, 然後通過 Vuex 的 mapGetters 獲取的:computed: { ...mapGetters([ 'routes' ]) },複製程式碼
既然通過 Vuex 的方法可以獲取
this.routes
,我們一定會有寫的邏輯,而這個存的邏輯實際上就是我們提供給這些非同步業務線提供了一個 api 介面實現的:window.XXApp = { // ... // 一些公共方法和元件 registerRouter(name, component) { Vue.component(name, component) store.commit('ADD_ROUTES', { name, component }) } }複製程式碼
我們提供了 registerRouter 介面,引數就是路由的名稱和對應的元件例項,我們首先通過 Vue.component 全域性註冊這個元件,然後通過 Vuex 提供的 commit 介面提交了一個 ADD_ROUTES 的 mutation,來看一下這個 mutation 的定義:
[types.ADD_ROUTES](state, data) { state.routes = Object.assign({}, state.routes, {[data.name]: data.component}) },複製程式碼
至此,我們就完成了 routes 的存取邏輯,整個動態路由方案也就完成了, 非同步業務線想使用動態路由,只需要呼叫我們提供的 registerRouter 介面,是不是很方便呢~
如何在測試環境下與後端介面互動?
我們在開發階段,通常都是在本地除錯,本地起的服務域名通常是 localhost:埠號。這樣會產生一些介面的跨域問題,除了常規的一些跨域方案,我們實際上可以藉助 node.js 服務幫我們代理這些介面。
我們藉助 vue-cli 腳手架幫我們生成一些初始化程式碼。在
config/index.js
檔案中,我們修改 dev 下 proxyTable 的配置,程式碼如下:proxyTable: { '/xxxservice': { target: 'http://xxx.com.cn', //你的目標域名 changeOrigin: true }, //... }複製程式碼
實際上,它就是利用了 node.js 幫我們做了一層服務的轉發,這樣就可以解決開發階段的跨域問題了。
如何部署到線下測試環境?
我們在本地開發完程式碼後,需要把程式碼提測。通常測試拿到程式碼後,需要部署和測試,為此我們寫了一個 deploy 的指令碼。原理其實很簡單,就是利用一個 scp2 的 node 包上傳程式碼,它的執行時機是在 webpack 編譯完成後,程式碼如下:
var client = require('scp2') //... webpack(webpackConfig, function (err, stats) { // ... client.scp('deploy/home.html', { host, username, password, path }, function (err) { if (err) { console.log(err) } else { console.log('Finished, the page url is xxx') } }) })複製程式碼
總結
技術的重構總伴隨著產品的升級,從這次大重構中,我們對 Vue 有了更深入的理解和掌握。對於它的周邊外掛如 Vuex 和 Vue-router,我們團隊的小夥伴也有了較深入的研究,產出幾篇文章也在這裡和大家分享:
Vuex 2.0 原始碼分析
vue-router原始碼分析-整體流程
vue-router 原始碼分析-history
以上,歡迎拍磚~
歡迎關注DDFE
GITHUB:github.com/DDFE
微信公眾號:微信搜尋公眾號“DDFE”或掃描下面的二維碼