本文主要講了實際業務在結合 vue 開發的過程中的探索與實踐。
業務介紹
基於目標使用者的孩子畫像,打通、聚合京東現有體系關聯資源,建立共生關係的開放式生態平臺,涵蓋滿足家庭陪伴孩子成長過程中的多維度需求。覆蓋場景場景導購、精準推薦、專屬權益等,為京東有孩家庭購物提供優質優購體驗。在專案開發中我們遇到的問題主要有以下三個:
- 介面眾多:近90個資料介面,資料欄位不規範、不統一、難理解,介面開發經常延期且頻繁變更;
- 互動複雜:各種互動及狀態,且一態多用,給使用者展示的是多狀態共同作用的結果,使用者操作非同步更新頁面;
- 快速上線:同時規劃多版本,多版本並行開發。
技術選型
技術選型要對症下藥,為了統一管理介面和資料,所採用的框架要有統一的資料中心,能做到檢視與邏輯的分離,用資料來驅動檢視,專案可以工程化來應對快速上線,以及利於後期維護。從學習成本來說,Vue 更容易上手,更輕量,結合 Vuex 管理狀態,檢視邏輯和資料的耦合度低,專案結構清晰明瞭,Vue 的可擴充套件性也非常好。Vue 核心技術主要有以下幾點:
- 宣告式渲染:通過簡潔的模板語法來宣告式地將資料渲染進 DOM,DOM 狀態是資料狀態的一個對映。
- 元件系統:跟大多數前端框架一樣,都是把 UI 結構拆解成小的、可複用的元件樹,然後像零件一樣組裝它們,Vue 還有比較獨特的地方,那就是單檔案元件,把歸屬於同一元件的模板、指令碼、樣式放在一個檔案中,你不必再同時維護一個元件的多個檔案,這樣是不是很酷。
- 客戶端路由:結合 vue-router,Vue 就可以實現一個 SPA 應用了,主要通過 hash 值來控制路由,路由又可以傳遞狀態引數給元件。
- 狀態管理:Vue 的基本狀態觸發過程是,使用者行為使得 state 發生變化,state 的變化又觸發檢視的更新。而結合 Vuex 則可以管理全域性的資料。
專案詳解
專案結構
專案開發
下面將分為以下幾方面來闡述:開發輔助、路由、元件化、mixins、常量管理、資料中心、環境相容、滾動行為。
開發依賴
專案採用 Webpack,並結合了 ESLint 和 Babel 等來進行開發和編譯打包,Webpack 的基本配置不詳講,在基本配置的基礎上,再分了開發環境的生產環境的配置:
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 |
// Dev 的配置 module.exports = merge(base, { plugins: [ new webpack.HotModuleReplacementPlugin(), new webpack.NoErrorsPlugin(), new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development') }), new HtmlWebpackPlugin({ filename: 'index.html', template: '../index.html' }) ] }) // Prod 的配置 module.exports = merge.smart(base, { module: { loaders: [ { test: /\.s[a|c]ss$/, loader: ExtractTextPlugin.extract({ fallbackLoader: "style-loader", loader: 'css!sass' }) } ] }, plugins: [ new ExtractTextPlugin('style.css'), new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production') }), new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', filename: 'vendor.js' }), new webpack.LoaderOptionsPlugin(loadersConf), new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false } }) ] }) |
開發環境中,用 express
和 webpack-dev-middleware
來搭建一個 dev server:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
const express = require('express') const webpackDevMiddleware = require('webpack-dev-middleware') const webpack = require('webpack') const conf = require('./webpack.dev.conf') const app = express() const port = process.env.PORT || 8080 conf.entry.app = ['webpack-hot-middleware/client', conf.entry.app] const compiler = webpack(conf) app.use(webpackDevMiddleware(compiler, { publicPath: conf.output.publicPath, stats: { colors: true, chunks: false } })) app.use(require('webpack-hot-middleware')(compiler)) app.listen(port, () => { console.log(`server started at localhost:${port}`) }) |
路由
一個路由子項如下:
1 2 3 4 5 |
{ name: 'index', path: '/index', meta: {title: '陪伴空間', pv: 50, profiles: true, visitor: true, verify () { return true }}, components: {default: Index2, navbar: Navbar} } |
其中,配置裡的 meta 包含了該頁面(檢視)的配置資訊:
- title:頁面的標題
- pv:用作記錄頁面的 PV
- profiles:用於判斷是否需要有孩子才能進入這個頁面
- visitor: 是否支援遊客訪問
- verify:如果支援遊客訪問,可選的額外的放行校驗
問題:在 ios 裡,單頁面應用切換檢視時頁面標題不能更新
解決:切換路由時用 iframe 載入一個空頁面即可觸發 title 更新,如下所示
1234567891011 const iframeLoad = (src) => {let iframe = document.createElement('iframe')iframe.style.display = 'none'iframe.src = srcdocument.body.appendChild(iframe)iframe.addEventListener('load', function() {setTimeout(function() {iframe.remove()}, 0)})}
路由中還要處理比較多的事情,在 router.beforeEach
中處理傳進頁面的引數,請求登陸狀態和檔案資料等基本介面,上報 PV,在 router.afterEach
中處理比較次要的事情。
元件化
接下來講的是專案中的單檔案元件。下面是一段特別編輯過的單檔案元件程式碼:
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 47 48 49 |
<template> <div v-show="isShow" class="test"> <!-- slot 的運用 --> <slot></slot> <slot name="slot2"></slot> <template v-if="testProp"></template> <template v-else></template> <!-- 對於巢狀較深的元件,可以用 function-type-prop 來代替 emit 觸發鏈 --> <div @click="changeNickname && changeNickname('小鎮')"></div> <div @click="close" class="test_btn">{{btnText}}</div> </div> </template> <script> import Utils from '@/utils' export default { props: { testProp: { type: [Number, String], required: true }, changeNickname: Function }, data () { return { isShow: false, btnText: '', closeFn: null } }, methods: { close () { this.isShow = false this.closeFn && this.closeFn() }, // 除了 poops 傳參,函式傳參也是一種方式 open (btnText = '', closeFn) { this.isShow = true this.btnText = btnText this.closeFn = closeFn } } } </script> <style lang="sass"> @import "common"; .test { background-image: url(~@img/test/bg.png); } </style> |
slot 對於可複用元件來說意義重大,因為我們在實際的應用中,元件往往大同小異,看起來可以做成元件的模組總會或多或少差異的地方,通過引數來控制這些差異也是可行的,但非常不利於元件的擴充套件,所以這些地方就交給 slot 來應對,slot 的意思是插槽,意指我們能在父元件中需要的時候,給元件填充自定義內容。
父元件通過 props 給子元件傳值,或者,父元件還可以通過子元件例項的方法來給子元件傳參(如程式碼中的 open 方法)。
子元件可以通過 emit 觸發事件來向上通訊,或者,通過直接呼叫作為 prop 傳進來的父元件方法也可以實現向上通訊(如程式碼中的 changeNickname)。
mixins
通常來說,不建議使用全域性的 mixin,但總會有特殊需要,比如在本專案中,由於埋點和其他需要,幾乎每個元件都要用到幾個公用的全域性資料,所以放到全域性的 mixin 是最好不過的了 Vue.mixin(mixins)
。使用全域性的 mixin 要注意的是,不要把邏輯放到 mixin 裡,因為每個元件都會執行一遍 mixin 的內容,元件一多就非常可怕了。
常量管理
為了以後能更好地維護程式碼,需要對常量作歸集管理,這裡的常量主要是連結和資料的欄位等。
1 2 3 4 5 |
// 連結常量的統一管理 export const REBUY_LIST = `${NIGHT}/re_purchase_detail` export const REBUY_SWITCH = `${NIGHT}/re_purchase_switch_good` export const REBUY_REMIND = `${NIGHT}/re_purchase_remind` // ... |
1 2 3 4 5 6 7 8 9 |
/ 資料欄位的統一管理 export const ID = 'id' export const SKU = 'sku' export const LINK = 'link' export const NAME = 'name' export const IMAGE = 'image' export const JD_PRICE = 'jdPrice' export const PRICE = 'price' // ... |
統一的常量管理也有利於規範統一,比如資料欄位,介面給到的資料可能有欄位不統一,或者不表意,或者髒資料多等問題,這就需要在獲取到後端資料後對其進行“修剪”,規範的統一的欄位名也有利於元件化。
資料中心
專案用了 vuex 來統一管理資料,在 view 元件中通過 vuex 提供的 mapActions 和 mapGetters 來求取資料,如下程式碼所示。
1 2 3 4 5 6 7 8 9 10 11 12 |
computed: { ...mapGetters({ cate1st: 'cate1st', cate2nd: 'cate2nd' }) }, methods: { ...mapActions([ 'getCate1st', 'getCate2nd' ]) } |
而在“資料中心”中,getters 從 state 中取值,呼叫 action 請求後端介面,主動觸發 mutation,在 mutation 裡進行資料的“修剪”,得到我們真正想要的資料。大致過程如下圖所示:
環境相容
專案需要相容多環境,包括購物車相關、商詳頁連結、優惠券連結、搜尋連結等因環境不同而不同的方法,為此得針對不同環境分別定義它們,再根據 ua 進行選擇:
1 2 3 4 5 6 7 8 |
// ... let configs = { [uaTypes.APP]: App, [uaTypes.WECHAT]: Wechat, [uaTypes.QQ]: QQ, [uaTypes.MOBILE]: Mobile } export default configs[UA.type] |
滾動行為
對於 SPA 應用來說滾動行為是個挺頭疼的問題,畢竟其本質只是一個頁面,又是非同步渲染的,所以難以保證各個檢視的滾動行為能像多頁面應用一樣。為此進行了以下幾步的探索。
- 結合 vuex 來儲存滾動
在 view 的 beforeDestory 時,主動記錄該檢視的滾動值,在下次 mounted 時延時滾動到該位置。
這個方案需要為每個需要記錄滾動的檢視新增 state、mutation 和 action,並在檢視新增額外的程式碼,實際操作繁瑣,且跳外部連結後再返回時所記錄的值也已經被銷燬。
- 使用瀏覽器儲存
為了解決跳外部連結後返回也能定位滾動位置,使用 localStorage 來記錄滾動值,而且使用了 mixin,這樣有需要操縱滾動行為的檢視插入這個 mixin 就可以了,不需要在檢視裡加額外程式碼。
但是問題來了,我們並不能區分當次訪問是第一次開啟還是剛從外鏈返回,就導致了第一次訪問也會被定位,就想到了 cookie,讓 cookie 保持 30min。顯然,這不是好的解決方案,再考慮到的是 sessionStorage,在當前會話中它能一直保持資料,跳外鏈返回後資料也還能保持著(此前以為跳外鏈後 sessionStorage 的資料也會被清除),新標籤開啟視為新會話,互不共用資料,這幾點特性正好符合我們的要求。
另外要考慮的一個問題是,頁面是非同步渲染的,我們並不知道它的介面什麼時候都請求完了,於是除了有預設的延時滾動外,還新增了主動觸發滾動的特性,讓開發者考慮什麼時候頁面才算載入完(通常是 watch 某個或多個非同步請求的狀態),然後主動去呼叫滾動方法。
最後要指出的是,滾動行為的解決方案也並不是完美的,比如,這個方案並不適用於有模組懶載入的頁面。
最終 mixin 程式碼如下:
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 47 48 49 50 |
/** * 如需手動觸發滾動: * manualTriggerLivescroll: true * this._livescroll() */ import Tools from '@/utils/tools' const ss = window.sessionStorage export default { data () { return { routeName: this.$route.name, liveScrollFlag: false, liveScrollFn: null, liveScrollTimer: null } }, computed: { liveScrollTop () { return ss ? ss.getItem(`view-${this.routeName}`) : Tools.getCookie(`view-${this.routeName}`) } }, methods: { _livescroll () { if (this.liveScrollFlag || !this.liveScrollTop) { return } this.liveScrollFlag = true // $nextTick 發揮不太穩定 this.liveScrollTimer = window.setTimeout(() => { document.body.scrollTop = document.documentElement.scrollTop = this.liveScrollTop }, 500) } }, mounted () { document.body.scrollTop = document.documentElement.scrollTop = 0 !this.manualTriggerLivescroll && this._livescroll() this.liveScrollFn = () => { ss ? ss.setItem(`view-${this.routeName}`, this.getScrollTop()) : Tools.setCookie(`view-${this.routeName}`, this.getScrollTop(), 0.2083) } window.addEventListener('touchend', this.liveScrollFn, false) }, beforeDestroy () { window.removeEventListener('touchend', this.liveScrollFn, false) this.liveScrollTimer && window.clearTimeout(this.liveScrollTimer) } } |
其他
以下都是些瑣碎的小問題,也有在專案開發過程踩過的坑。
- 介面延遲
為了儘量減少請求到的資料為空出的情況,基於 vue 的請求方法上包了一層,對於超時的介面重新發起一次請求。
- 支援 rest spread
給 babel 加 "plugins": ["transform-object-rest-spread"]
以支援 rest spread 的寫法,或者直接用 babel-preset-env
,同時 eslint 的配置加上 "parserOptions": { "ecmaFeatures": { "experimentalObjectRestSpread": true } }
- 如何切換 Webpack 的 publicPath 在開發環境和生產環境的配置
一開始是手動去更改,後來根據當前環境自動去選擇
1 2 3 4 5 |
const publicPath = { development: '/', labs: 'http://xx.xxx.xx/mtd/h5/accompany/3.0.0-alpha/', production: '//xx.xxx.xx/mtd/h5/accompany/3.2.2/' }[env] |
- 別名在 mixin 資源路徑的應用
由於頁面的路徑跟 includePath 的路徑不一樣,比如有個 @mixin iconAddcart { background: url(../addcart.png); }
,在元件樣式裡 include 它時會提示找不到圖片,這時如果改成帶別名的路徑 ~@img/addcart.png
就能很好解決這個問題。
- CSS Masking 的運用
可以參考 leeenx 的 CSS3 Mask 安利報告,在本專案中較大範圍地使用了 mask,主要的好處就是:縮減背景圖的大小,自定義遮罩,適應同形狀多背景色的情況。
需要注意的是,如果還要用 drop-shadow 的話,就得在外面再套一層來加 drop-shadow。
以上就是本文的全部內容。