Vue 全家桶仿原生App切換效果和頁面快取實踐

JooZh發表於2018-09-03

需求

在之前做的 WEB 單頁應用在切換效果上有些生硬,而且頁面的快取和更新在體驗上達不到預期的效果。雖然 vue 的 keep-alive 能達到將元件進行快取,但是在做一些特殊的需求的時候,如把新開啟的頁面(元件)進行快取,當點選返回的時候就將該快取的頁面(元件)進行銷燬,就像模擬 App 中體驗的效果一樣,而且在類似於開啟商品詳情的時候是使用的同一個元件,不同的路由。並且在商品詳情中繼續開啟商品詳情。在一般的路由配置和元件複用的貌似達不到這種效果。而且也不可能將所有的詳情頁路由進行配置。

幾個問題。

要實現這麼一個需求就遇到了以下幾個問題。

  1. 模擬 app 切換的效果。
  2. 元件複用動態前端路由。
  3. 頁面(元件)按需求進行快取和銷燬。
  4. 快取的頁面(元件)進行資料更新。
  5. 瀏覽器前進後退按鈕對前端路由的影響。
  6. 手機端滑動手勢對前端路由的影響。

最終還是差不多實現了這個效果,雖然不是很完善。

主要是基於 vue vue-router

直接使用的 vue-cli 進行示例檔案構建

這外掛是 【控制切換效果】 和 【按需快取頁面】 以及 【動態路由管理】 功能

具體需要實現完整的效果還需要參考示例配置檔案

外掛地址: vue-app-effect

示例配置: Examples

示例演示: Demo

這裡就不放效果圖了直接掃二維碼真實體驗 微信演示:

如果覺得有用的話,記得點個 Star 。

Vue 全家桶仿原生App切換效果和頁面快取實踐

配置指南

安裝外掛

$ npm install vue-app-effect -S
複製程式碼

配置外掛

vue 入口檔案 main.js 配置外掛後 就會附加一個 vnode-cache 快取元件,用法和 keep-alive 一樣。 另外還會在 window 物件上掛上一個 $VueAppEffect 物件,用於儲存操作路由的一些記錄。

import VnodeCache from 'vue-app-effect'                         // 引入外掛
import router from './router'                                   // 必須要有 router

Vue.use(VnodeCache, {
  router,
  tabbar: ['/tabbar1', '/tabbar2', '/tabbar3', '/tabbar4'],     // 導航路由
  common: '/common'                                             // 公共頁面路由
})
複製程式碼

路由配置

vue 路由檔案 router.js

// tabBar 容器
import TabCon from '@/Components/TabCon/index'
Vue.use(Router)
// 按需配置,動態路由不需要配置入路由組
export default new Router({
  routes: [{
    path: '/',
    component: TabCon,
    redirect: '/tabbar1',
    children: [ {
      path: '/tabbar1',
      name: '/tabbar1',
      component: Movie
    }, {
      path: '/tabbar2',
      name: '/tabbar2',
      component: Singer
    }, {
      path: '/tabbar3',
      name: '/tabbar3',
      component: Rank
    }, {
      path: '/tabbar4',
      name: '/tabbar4',
      component: Song
    }]
  }, {
    path: '/common',
    name: '/common',
    component: Common
  }]
})
複製程式碼

App.vue 配置

動態載入的路由和元件需要有動畫效果,而且是按需快取,頁面點選返回後銷燬元件,使用外掛的快取元件 vnode-cache

<template>
  <div id="app">
    <transition :name="transitionName" :css="!!direction">
      <vnode-cache>
        <router-view class="router-view"></router-view>
      </vnode-cache>
    </transition>
    <TabBar v-show="isTab"></TabBar>
  </div>
</template>
複製程式碼
import TabBar from '@/ComponentsLayout/TabBar/index'
export default {
  name: 'App',              // 每個元件建議帶上名字
  components: {
    TabBar
  },
  data () {
    return {
      transitionName: '',   // 切換效果類名
      direction: '',        // 前進還是返回動作
      isTab: true           // 是否顯示 tabbar
    }
  },
  created () {
    // 監聽前進事件
    this.$direction.on('forward', (direction) => {
      this.transitionName = direction.transitionName
      this.direction = direction.type
      this.isTab = direction.isTab      
    })
    // 監聽返回事件
    this.$direction.on('reverse', (direction) => {
      this.transitionName = direction.transitionName
      this.direction = direction.type
      this.isTab = direction.isTab
    })
  }
}
複製程式碼

TabBar 容器配置

TabBar 裡面的頁面需要一直被快取下來,並不在按需快取的效果中,而且切換也沒有滑動效果。這裡直接使用 keep-alive

<template>
  <div>
    <keep-alive>
      <router-view class="tab-router-view"></router-view>
    </keep-alive>
  </div>
</template>
複製程式碼

複用元件配置

複用元件需要在 router.js 中進行配置

// 需要被複用的元件
import MovieDetail from '@/ComponentsDetails/MovieDetail/index'
import SingerDetail from '@/ComponentsDetails/SingerDetail/index'

// 每個動態註冊的路由重複使用的元件
Router.prototype.extends = {
  MovieDetail,
  SingerDetail
}
複製程式碼

跳轉到動態路由並且載入複用元件時候

methods: {
    goDetailMv (index, name) {  // 傳參
      // 建立一個新路由
      let newPath = `/movie/${index}`
      let newRoute = [{
        path: newPath,
        name: newPath,
        component: {extends: this.$router.extends.MovieDetail}
      }]
      // 判斷路由是否存在
      let find = this.$router.options.routes.findIndex(item => item.path === newPath)
      // 不存在 新增一個新路由
      if (find === -1) {
        this.$router.options.routes.push(newRoute[0])
        this.$router.addRoutes(newRoute)
      }
      // 然後跳轉
      this.$router.replace({    
        name: newPath,
        params: { id: index, name: name }
      })
    }
}
複製程式碼

路由跳轉的方法

這是一個很嚴肅的問題。關係到整個效果切換在各個瀏覽器中的切換相容。
複製程式碼

通常 我們都是使用 this.$router.push() 去跳轉,這跳轉方法會給 瀏覽器的 history 物件中新增記錄,於是瀏覽器的前進和後退按鈕就會生效,會在無意間產生一些錯誤的路由跳轉操作。 最典型的就是 safari 的側滑前進和返回功能,會影響整個切換的效果,偶爾會導致錯亂。

如果不使用 replace 方法而使用 push 的話就會產生 history 歷史記錄,瀏覽器的前進後退按鈕會生效。

解決方法就是 不使用 this.$router.push() 去做產生瀏覽器的 history 記錄。使用this.$router.replace() 這個方法去跳轉,不會給瀏覽器的 history中新增記錄,就不會有上面因為前進後退產生的問題。這樣就犧牲了部分瀏覽器的特性,但是在微信瀏覽器中就不會顯示底部兩個前進後退按鈕。這也是一種補償吧,大多數的移動網站在微信瀏覽器中出現的次數還是比較多的。 當然沒有瀏覽器的後退按鈕,那麼返回功能就集中在應用中的後退按鈕上,以下是使用 this.$router.replace() 推薦的返回按鈕寫法。

<div class="back-btn">
  <div @click="back"></div>
</div>
複製程式碼
methods: {
    back () {
      window.$VueAppEffect.paths.pop()
      this.$router.replace({
        name: window.$VueAppEffect.paths.concat([]).pop()  // 不影響原物件取到要返回的路由
      })
    }
}
複製程式碼

在導航器中也推薦使用 replace 方式

<template>
  <div id="tab-bar">
    <div class="container border-half-top">
      <router-link class="bar" :to="'/movie'" replace>  <!--this.$router.replace() 宣告式寫法 -->
        <div class="button"></div>
      </router-link>
      <router-link class="bar" :to="'/singer'" replace> <!--this.$router.replace() 宣告式寫法 -->
        <div class="button"></div>
      </router-link>
      <router-link class="bar" :to="'/rank'" replace>   <!--this.$router.replace() 宣告式寫法 -->
        <div class="button"></div>
      </router-link>
      <router-link class="bar" :to="'/song'" replace>   <!--this.$router.replace() 宣告式寫法 -->
        <div class="button"></div>
      </router-link>
    </div>
  </div>
</template>
複製程式碼

佈局結構配置

佈局結構直接影響切換效果。

佈局結構請參考 示例配置: Examples 中的 css 這裡就不寫了。

切換效果

可以重新覆蓋一下樣式,但是類名不能改變

.vue-app-effect-out-enter-active,
.vue-app-effect-out-leave-active,
.vue-app-effect-in-enter-active,
.vue-app-effect-in-leave-active {
  will-change: transform;
  transition: all 500ms cubic-bezier(0.075, 0.82, 0.165, 1) ;
  bottom: 50px;
  top: 0;
  position: absolute;
  backface-visibility: hidden;
  perspective: 1000;
}
.vue-app-effect-out-enter {
  opacity: 0;
  transform: translate3d(-70%, 0, 0);
}
.vue-app-effect-out-leave-active {
  opacity: 0 ;
  transform: translate3d(70%, 0, 0);
}
.vue-app-effect-in-enter {
  opacity: 0;
  transform: translate3d(70%, 0, 0);
}
.vue-app-effect-in-leave-active {
  opacity: 0;
  transform: translate3d(-70%, 0, 0);
}
複製程式碼

元件帶 name 的好處

能在開發工具中有效的顯示元件的 name

Vue 全家桶仿原生App切換效果和頁面快取實踐

如果沒有 name 會顯示當前元件的檔名 例如:

├── Movie          
│   └── index.vue      // 元件
├── Singer          
│   └── index.vue      // 元件
複製程式碼

那麼在開發工具中都會顯示為 Index

Vue 全家桶仿原生App切換效果和頁面快取實踐


以下部分是描述如何實現該效果

實現過程

問題一:需要一個儲存器來儲存當前載入的路由歷史記錄

方案:vux 的原始碼中是通過 在 vuexstore 中註冊一個模組,然後在 window.sessionStorage 中儲存資料記錄。在路由守衛 router.beforeEach() router.afterEach() 進行路由前進後退判斷, 然後通過 bus 進行事件提交狀態來動態的給 <transition> 元件新增一個css的過度效果。

解決:storewindow.sessionStorage 感覺有些麻煩,這裡直接採用全域性 window 物件在上面掛載一個狀態管理的物件 $VueAppEffect 用來儲存操作中產生的一些記錄。

設計路由儲存器

因為程式始終是執行在瀏覽器中,可以直接在 window 物件上掛載一個物件即可,簡單方便。

window.$VueAppEffect = {
  '/movie/23':1,                    // 新增動態路由名稱,值為層級
  // '/play':999999,                // 公共元件,層級為最高階。不計入 count 預設無
  'count':1,                        // 新增路由總量
  'paths':['/movie','/movie/23'],   // 給返回按鈕使用的路由記錄預設會將導航路由的期中一個新增在最前。
}
複製程式碼

問題二:需要重新設計一個快取元件,根據當前狀態動態快取和銷燬元件。

解決: 實現一個類似於<keep-alive> 一樣功能的元件,該元件會根據操作記錄動態的銷燬和快取內容。

抽象元件

這個東西的看起來跟元件一樣是一對標籤,但是它不會渲染出實際的 dom 常用的有兩個<keep-alive> <transition> 內部具體樣子大概是這樣的

name: '',
abstract: true,
props: {},
data() {
  return {}
},
computed: {},
methods: {},
created () {},
destroyed () {},
render () {}
複製程式碼

抽象元件也有生命週期函式 但是沒有html部分和css部分,而且有一個render() 方法, 這個方法主要是返回一個處理結果。

VNode基類

關於這個看可以看這篇文章 VNode基類

建立一個抽象元件

將元件單獨成一個檔案,然後再建立一個index檔案

├── src          
│   └── index.js            // 入口安裝檔案
│   └── vnode-cache.js      // 元件檔案
複製程式碼

先建立 index.js

import VnodeCache from './vnode-cache'
export default {
  install: (Vue, {router, tabbar, common='' } = {}) => {
  // 判斷引數的完整性 必須要有 router 和導航路由配置陣列
  if (!router || !tabbar) {
    console.error('vue-app-effect need options: router, tabbar')
    return
  }
  
  // 監聽頁面主動重新整理,主動重新整理等於重新載入 app
  window.addEventListener('load', () => {
    router.replace({path: '/'})
  })
  
  // 建立狀態記錄物件 
  window.$VueAppEffect = {
    'count':0,
    'paths':[]
  }
  // 如果有公共頁面再配置
  if(common){                                   
    window.$VueAppEffect[common] = 9999999
  }
  
  // 利用 bus 進行事件派發和監聽
  const bus = new Vue()
  
  /**
  * 判斷當前路由載入執行的方法為 push 還是 replace 
  * 根據路由守衛 router.beforeEach() router.afterEach() 進行載入和
  * 銷燬元件的判斷,並且使用 bus 進行傳送載入和銷燬元件的事件派發
  * 額外處理觸控事件返回的內容
  **/
  
  // 掛載 vnode-cache 元件
  Vue.component('vnode-cache', VnodeCache(bus, tabbar))
  Vue.direction = Vue.prototype.$direction = {
    on: (event, callback) => {
      bus.$on(event, callback)
    }
  }
}
複製程式碼

然後實現路由守衛監測操作記錄(上面/* */ 註釋中的部分),判斷是否是載入或者返回,並通過 bus 進行事件派發。


// 處理路由當前的執行方法和 ios 側滑返回事件
let isPush = false
let endTime = Date.now()
let methods = ['push', 'go', 'replace', 'forward', 'back']
document.addEventListener('touchend', () => {
  endTime = Date.now()
})
methods.forEach(key => {
  let method = router[key].bind(router)
  router[key] = function (...args) {
    isPush = true
    method.apply(null, args)
  }
})
// 前進與後退判斷
router.beforeEach((to, from, next)=>{
  // 如果是外鏈直接跳轉
  if (/\/http/.test(to.path)) {
    window.location.href = to.path
    return
  }
  // 不是外鏈的情況下
  let toIndex = Number(window.$VueAppEffect[to.path])       // 得到去的路由層級
  let fromIndex = Number(window.$VueAppEffect[from.path])   // 得到來的路由層級
  fromIndex = fromIndex ? fromIndex : 0
  // 進入新路由 判斷是否為 tabBar
  let toIsTabBar = tabbar.findIndex(item => item === to.path)
  // 不是進入 tabBar 路由 --------------------------
  if (toIsTabBar === -1) {
    // 層級大於0 即非導航層級
    if (toIndex > 0) {
      // 判斷是不是返回
      if (toIndex > fromIndex) { // 不是返回
        bus.$emit('forward',{
            type:'forward',
            isTab:false,
            transitionName:'vue-app-effect-in'
        })
        window.$VueAppEffect.paths.push(to.path)
      } else {                  // 是返回
        // 判斷是否是ios左滑返回
        if (!isPush && (Date.now() - endTime) < 377) {  
          bus.$emit('reverse', { 
            type:'', 
            isTab:false, 
            transitionName:'vue-app-effect-out'
          })
        } else {
          bus.$emit('reverse', { 
            type:'reverse', 
            isTab:false, 
            transitionName:'vue-app-effect-out'
          })
        }
      }
    // 是返回
    } else {
      let count = ++ window.$VueAppEffect.count
      window.$VueAppEffect.count = count
      window.$VueAppEffect[to.path] = count
      bus.$emit('forward', { 
        type:'forward', 
        isTab:false, 
        transitionName:'vue-app-effect-in'
      })
      window.$VueAppEffect.paths.push(to.path)
    }
  // 是進入 tabbar 路由 ---------------------------------------
  } else {
    // 先刪除當前的 tabbar 路由
    window.$VueAppEffect.paths.pop()
    // 判斷是否是ios左滑返回
    if (!isPush && (Date.now() - endTime) < 377) {
      bus.$emit('reverse', { 
        type:'', 
        isTab:true, 
        transitionName:'vue-app-effect-out'
      })
    } else {
      bus.$emit('reverse', { 
        type:'reverse', 
        isTab:true, 
        transitionName:'vue-app-effect-out'
      })
    }
    window.$VueAppEffect.paths.push(to.path)
  }
  next()
})

router.afterEach(function () {
  isPush = false
})

// 掛載 vnode-cache 元件
複製程式碼

最後實現 vnode-cache.js 這裡主要實現了 根據 bus 派發的事件主動銷燬元件。

export default (bus,tabbar) => {
  return {
    name: 'vnode-cache',
    abstract: true,
    props: {},
    data: () {
      return {
        routerLen: 0,       // 當前路由總量
        tabBar: tabbar,     // 導航路由陣列
        route: {},          // 需要被監測的路由物件
        to: {},             // 當前跳轉的路由
        from: {},           // 上一個路由
        paths: []           // 記錄路由操作記錄陣列
      }
    },
    // 檢測路由的變化,記錄上一個和當前路由並儲存路由的全路徑做為標識。
    watch: {                
      route (to, from) {
        this.to = to
        this.from = from
        let find = this.tabBar.findIndex(item => item === this.$route.fullPath)
        if (find === -1) {
          this.paths.push(to.fullPath)              // 不是tabbar就儲存下來
          this.paths = [...new Set(this.paths)]     // 去重
        }
      }
    },
    // 建立快取物件集
    created () {                                            
      this.cache = {}
      this.routerLen = this.$router.options.routes.length   // 儲存 route 長度
      this.route = this.$route                              // 儲存route
      this.to = this.$route                                 // 儲存route
      bus.$on('reverse', () => { this.reverse() })          // 監聽返回事件並執行對應操作
    },
    // 元件被銷燬清除所有快取
    destroyed () {                                          
      for (const key in this.cache) {
        const vnode = this.cache[key]
        vnode && vnode.componentInstance.$destroy()
      }
    },
    methods: {
      // 返回操作的時候清除上一個路由的元件快取
      reverse () {
        let beforePath = this.paths.pop()
        let routes = this.$router.options.routes
        // 查詢是不是導航路由
        let isTabBar = this.tabBar.findIndex(item => item === this.$route.fullPath)
        // 查詢當前路由在路由列表中的位置
        let routerIndex = routes.findIndex(item => item.path === beforePath)
        // 當不是導航路由,並且不是預設配置路由  清除對應歷史記錄  
        if (isTabBar === -1 && routerIndex >= this.routerLen) {
          delete  window.$VueAppEffect[beforePath]
          window.$VueAppEffect.count -= 1
        }
        // 當不是導航的時候 刪除上一個快取
        let key = isTabBar === -1 ? this.$route.fullPath : ''
        if (this.cache[key]) {
          this.cache[beforePath].componentInstance.$destroy()
          delete this.cache[beforePath]
        }
      }
    },
    // 快取 vnode
    render () {
      this.router = this.$route 
      // 得到 vnode
      const vnode = this.$slots.default ? this.$slots.default[0] : null
      // 如果 vnode 存在
      if (vnode) {
        // tabbar判斷如果是 直接儲存/tab-bar
        let findTo = this.tabBar.findIndex(item => item === this.$route.fullPath)
        let key = findTo === -1 ? this.$route.fullPath : '/tab-bar'
        // 判斷是否快取過了
        if (this.cache[key]) {
          vnode.componentInstance = this.cache[key].componentInstance
        } else {
          this.cache[key] = vnode
        }
        vnode.data.keepAlive = true
      }
      return vnode
    }
  }
}
複製程式碼

最後是將 css 效果程式碼直接打包進了 index.js 檔案中,這裡偷了個懶,因為程式碼不是很多,所以只有使用的是 js 動態建立 style 標籤的方式

// 插入 transition 效果檔案 偷懶不用改打包檔案---------------------
const CSS = `
.vue-app-effect-out-enter-active,
.vue-app-effect-out-leave-active,
.vue-app-effect-in-enter-active,
.vue-app-effect-in-leave-active {
  will-change: transform;
  transition: all 500ms cubic-bezier(0.075, 0.82, 0.165, 1) ;
  bottom: 50px;
  top: 0;
  position: absolute;
  backface-visibility: hidden;
  perspective: 1000;
}
.vue-app-effect-out-enter {
  opacity: 0;
  transform: translate3d(-70%, 0, 0);
}
.vue-app-effect-out-leave-active {
  opacity: 0 ;
  transform: translate3d(70%, 0, 0);
}
.vue-app-effect-in-enter {
  opacity: 0;
  transform: translate3d(70%, 0, 0);
}
.vue-app-effect-in-leave-active {
  opacity: 0;
  transform: translate3d(-70%, 0, 0);
}`
let head = document.head || document.getElementsByTagName('head')[0]
let style = document.createElement('style')
style.type = 'text/css'
if (style.styleSheet){ 
  style.styleSheet.cssText = CSS; 
}else { 
  style.appendChild(document.createTextNode(CSS))
} 
head.appendChild(style)
複製程式碼

到這裡就結束了 關於瀏覽器前進後退等方式的處理已經再配置中寫出,這裡推薦幾款視窗滾動外掛,更好的配合實現 app 的應用效果,下拉重新整理,上拉載入等。

better-scroll 體積比較大,功能比較全,效果還好

vue-scroller

iscroll

總結

這個其實就是利用 路由守衛,和 bus 以及 自定義一個快取元件進行動態管理路由的配合配置過程,做這個的目的也就是為了提高單頁應用的使用者體驗度,特別是再微信瀏覽器中,ios 系統下操作 history 歷史記錄視窗底部會出現兩個箭頭。切換效果的實現 讓單頁應用更像 WebApp 。

相關文章