Vue 開發(fā)過程的探索與實踐
本文主要講了實際業(yè)務在結合 vue 開發(fā)的過程中的探索與實踐。
業(yè)務介紹
基于目標用戶的孩子畫像,打通、聚合京東現有體系關聯(lián)資源,建立共生關系的開放式生態(tài)平臺,涵蓋滿足家庭陪伴孩子成長過程中的多維度需求。覆蓋場景場景導購、精準推薦、專屬權益等,為京東有孩家庭購物提供優(yōu)質優(yōu)購體驗。在項目開發(fā)中我們遇到的問題主要有以下三個:
- 接口眾多:近90個數據接口,數據字段不規(guī)范、不統(tǒng)一、難理解,接口開發(fā)經常延期且頻繁變更;
 - 交互復雜:各種交互及狀態(tài),且一態(tài)多用,給用戶展示的是多狀態(tài)共同作用的結果,用戶操作異步更新頁面;
 - 快速上線:同時規(guī)劃多版本,多版本并行開發(fā)。
 
技術選型
技術選型要對癥下藥,為了統(tǒng)一管理接口和數據,所采用的框架要有統(tǒng)一的數據中心,能做到視圖與邏輯的分離,用數據來驅動視圖,項目可以工程化來應對快速上線,以及利于后期維護。從學習成本來說,Vue 更容易上手,更輕量,結合 Vuex 管理狀態(tài),視圖邏輯和數據的耦合度低,項目結構清晰明了,Vue 的可擴展性也非常好。Vue 核心技術主要有以下幾點:
- 聲明式渲染:通過簡潔的模板語法來聲明式地將數據渲染進 DOM,DOM 狀態(tài)是數據狀態(tài)的一個映射。
 - 組件系統(tǒng):跟大多數前端框架一樣,都是把 UI 結構拆解成小的、可復用的組件樹,然后像零件一樣組裝它們,Vue 還有比較獨特的地方,那就是單文件組件,把歸屬于同一組件的模板、腳本、樣式放在一個文件中,你不必再同時維護一個組件的多個文件,這樣是不是很酷。
 - 客戶端路由:結合 vue-router,Vue 就可以實現一個 SPA 應用了,主要通過 hash 值來控制路由,路由又可以傳遞狀態(tài)參數給組件。
 - 狀態(tài)管理:Vue 的基本狀態(tài)觸發(fā)過程是,用戶行為使得 state 發(fā)生變化,state 的變化又觸發(fā)視圖的更新。而結合 Vuex 則可以管理全局的數據。
 
項目詳解
項目結構
項目開發(fā)
下面將分為以下幾方面來闡述:開發(fā)輔助、路由、組件化、mixins、常量管理、數據中心、環(huán)境兼容、滾動行為。
開發(fā)依賴
項目采用 Webpack,并結合了 ESLint 和 Babel 等來進行開發(fā)和編譯打包,Webpack 的基本配置不詳講,在基本配置的基礎上,再分了開發(fā)環(huán)境的生產環(huán)境的配置:
- // 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
 - }
 - })
 - ]
 - })
 
開發(fā)環(huán)境中,用 express 和 webpack-dev-middleware 來搭建一個 dev server:
- 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}`)
 - })
 
路由
一個路由子項如下:
- {
 - 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 加載一個空頁面即可觸發(fā) title 更新,如下所示
- const iframeLoad = (src) => {
 - let iframe = document.createElement('iframe')
 - iframe.style.display = 'none'
 - iframe.src = src
 - document.body.appendChild(iframe)
 - iframe.addEventListener('load', function() {
 - setTimeout(function() {
 - iframe.remove()
 - }, 0)
 - })
 - }
 
路由中還要處理比較多的事情,在 router.beforeEach 中處理傳進頁面的參數,請求登陸狀態(tài)和檔案數據等基本接口,上報 PV,在 router.afterEach 中處理比較次要的事情。
組件化
接下來講的是項目中的單文件組件。下面是一段特別編輯過的單文件組件代碼:
- <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 觸發(fā)鏈 -->
 - <div @click="changeNickname && changeNickname('小鎮(zhèn)')"></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 觸發(fā)事件來向上通信,或者,通過直接調用作為 prop 傳進來的父組件方法也可以實現向上通信(如代碼中的 changeNickname)。
mixins
通常來說,不建議使用全局的 mixin,但總會有特殊需要,比如在本項目中,由于埋點和其他需要,幾乎每個組件都要用到幾個公用的全局數據,所以放到全局的 mixin 是***不過的了 Vue.mixin(mixins)。使用全局的 mixin 要注意的是,不要把邏輯放到 mixin 里,因為每個組件都會執(zhí)行一遍 mixin 的內容,組件一多就非常可怕了。
常量管理
為了以后能更好地維護代碼,需要對常量作歸集管理,這里的常量主要是鏈接和數據的字段等。
- // 鏈接常量的統(tǒng)一管理
 - 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`
 - // ...
 
- // 數據字段的統(tǒng)一管理
 - 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'
 - // ...
 
統(tǒng)一的常量管理也有利于規(guī)范統(tǒng)一,比如數據字段,接口給到的數據可能有字段不統(tǒng)一,或者不表意,或者臟數據多等問題,這就需要在獲取到后端數據后對其進行“修剪”,規(guī)范的統(tǒng)一的字段名也有利于組件化。
數據中心
項目用了 vuex 來統(tǒng)一管理數據,在 view 組件中通過 vuex 提供的 mapActions 和 mapGetters 來求取數據,如下代碼所示。
- computed: {
 - ...mapGetters({
 - cate1st: 'cate1st',
 - cate2nd: 'cate2nd'
 - })
 - },
 - methods: {
 - ...mapActions([
 - 'getCate1st',
 - 'getCate2nd'
 - ])
 - }
 
而在“數據中心”中,getters 從 state 中取值,調用 action 請求后端接口,主動觸發(fā) mutation,在 mutation 里進行數據的“修剪”,得到我們真正想要的數據。大致過程如下圖所示:
環(huán)境兼容
項目需要兼容多環(huán)境,包括購物車相關、商詳頁鏈接、優(yōu)惠券鏈接、搜索鏈接等因環(huán)境不同而不同的方法,為此得針對不同環(huán)境分別定義它們,再根據 ua 進行選擇:
- // ...
 - 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 就可以了,不需要在視圖里加額外代碼。
但是問題來了,我們并不能區(qū)分當次訪問是***次打開還是剛從外鏈返回,就導致了***次訪問也會被定位,就想到了 cookie,讓 cookie 保持 30min。顯然,這不是好的解決方案,再考慮到的是 sessionStorage,在當前會話中它能一直保持數據,跳外鏈返回后數據也還能保持著(此前以為跳外鏈后 sessionStorage 的數據也會被清除),新標簽打開視為新會話,互不共用數據,這幾點特性正好符合我們的要求。
另外要考慮的一個問題是,頁面是異步渲染的,我們并不知道它的接口什么時候都請求完了,于是除了有默認的延時滾動外,還添加了主動觸發(fā)滾動的特性,讓開發(fā)者考慮什么時候頁面才算加載完(通常是 watch 某個或多個異步請求的狀態(tài)),然后主動去調用滾動方法。
***要指出的是,滾動行為的解決方案也并不是***的,比如,這個方案并不適用于有模塊懶加載的頁面。
最終 mixin 代碼如下:
- /**
 - * 如需手動觸發(fā)滾動:
 - * 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 發(fā)揮不太穩(wěn)定
 - 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)
 - }
 - }
 
其他
以下都是些瑣碎的小問題,也有在項目開發(fā)過程踩過的坑。
- 接口延遲
 
為了盡量減少請求到的數據為空出的情況,基于 vue 的請求方法上包了一層,對于超時的接口重新發(fā)起一次請求。
- 支持 rest spread
 
給 babel 加 "plugins": ["transform-object-rest-spread"] 以支持 rest spread 的寫法,或者直接用 babel-preset-env,同時 eslint 的配置加上 "parserOptions": { "ecmaFeatures": { "experimentalObjectRestSpread": true } }
- 如何切換 Webpack 的 publicPath 在開發(fā)環(huán)境和生產環(huán)境的配置
 
一開始是手動去更改,后來根據當前環(huán)境自動去選擇
- 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。
以上就是本文的全部內容。
原文鏈接:https://aotu.io/notes/2017/07/17/The-Exploration-and-Practice-of-Vue/
作者:右小鎮(zhèn)
【本文是51CTO專欄作者“凹凸實驗室”的原創(chuàng)稿件,轉載請通過51CTO聯(lián)系原作者獲取授權】


















 
 
 







 
 
 
 