淺談 Vue SPA 網站 URL 保存數據實踐

吾輩的博客原文: https://blog.rxliuli.com/p/d5...

場景

在使用 Vue SPA 開發面向普通用戶的網站時,吾輩也遇到了一些以前未被重視,但卻實實在在存在的問題,此次便淺談一下 SPA 網站將全部數據都存儲到內存中致使數據很容易丟失以及吾輩思考並嘗試的解決方案。vue

參考:SPA 全稱 single page application,意爲 單頁應用,不是泥萌想的那樣!#笑哭

首先列出爲何遇到這個問題,具體場景及解決的問題是什麼?webpack

想要解決的一些問題git

  1. 刷新頁面數據不丟失:由於數據都保存在內存中,因此刷新以後天然不存在了
  2. URL 複製給其餘人數據不丟失:由於數據沒有持久化到 URL 上,也沒有根據 URL 上的數據進行初始化,因此複製給別人的 URL 固然會丟失數據(搜索條件之類)
  3. 頁面返回數據不丟失:由於數據都保存在內存中,因此跳轉到其餘路由再跳轉回來數據固然不會存在了

那麼,先談一下每一個問題的解決方案github

  1. 刷新頁面數據不丟失web

    • 將數據序列化到本地,例如 localStorage 中,而後在刷新後獲取一次vue-router

    • 將數據序列化到 URL 上,每次加載都從 URL 上獲取數據
  2. URL 複製給其餘人數據不丟失vuex

    • 只能將數據序列化到 URL 上
  3. 頁面返回數據不丟失npm

    • 將數據放到 vuex 中,而且在 URL 上使用 key 進行標識數組

    • 將數據序列化到 URL 上,而且不新增路由記錄
    • 使用 vue-router 的緩存 keep-alive

在瞭解了這麼多的解決方案以後,吾輩最終選擇了兼容性最好的 URL 保存數據,它能同時解決 3 個問題。然而,很遺憾的是,這彷佛並無不少人討論這個問題,或許,這個問題本應該是默認就須要解決的,亦或是 SPA 網站真的不多關心這些了。promise

雖然說如此,吾輩仍是找到了一些討論的 StackOverflow: How to hold URL query params in Vue with Vue-Router

思路

一個基本的思路是可以肯定的

  1. 在組件建立時,從 URL 獲取數據併爲須要的數據進行初始化
  2. 在這些數據變化時,及時將數據序列化到 URL 上

思路圖

而後,再次出現了一個分歧點,到底要不要綁定 Vue?

  1. 不綁定 vue 手動監聽對象變化並將對象的變化響應到 URL 上
    不綁定 vue
  2. 綁定 vue 並使用它的生命週期 created, beforeRouteUpdate 與監聽器 watch
    綁定 vue

那麼,二者有什麼區別呢?

思路 不綁定 vue 綁定 vue
優勢 非框架強相關,理論上能夠通用 Vue/React 不須要手動實現 URL 的幾種序列化模式,能夠預見至少有兩種:HTML 5 History/Hash
沒有 vue/vue-router 的歷史包袱 不須要手動實現數據監聽/響應(雖然如今已然不算難了)
能夠無論 vue-router 實現 URL 動態設置,能夠自動優雅降級 靈活性很強,實現比較好的封裝以後使用成本很低
缺點 沒有包袱,但同時沒有基礎,序列化/數據監聽都須要手動實現 存在歷史包袱,vue/vue-router 的怪癖一點都繞不過去
靈活性不足,只能初始化一次,須要/不須要序列化的數據分割也至關有挑戰 依賴 vue/vue-router,在其更新之時也必須跟着更新
不綁定 vue 意味着與 vue 不可能完美契合 沒法通用,在任何一個其餘框架(React)上還要再寫一套

最終,在這個十字路口反覆躊躇以後,吾輩選擇了更加靈活、成本更低的第二種解決方案。

問題

已解決

  • 序列化數據到 URL 上致使路由記錄會隨着改變增長
  • 即時序列化數據到 URL 上不現實

    這裏吾輩對 yarn 進行了考察發現其也是異步更新 URL
  • 序列化到 URL 上時致使的死循環,序列化數據到 URL 上 => 路由更新觸發 => 初始化數據到 URL 上 => 觸發數據改變 => 序列化數據到 URL 上。。。
  • 同一個路由攜帶不一樣的查詢參數的 URL 直接在地址欄輸入回車一次不會觸發頁面數據更新
  • URL 最大保存數據 IE 最多支持 2083 長度的 URL,換算爲中文即爲 231 個,因此不能做爲一種通用方式進行
  • Vue 插件不能動態混入,而是在各個生命週期中判斷是否要處理的

仍遺留

  • JSON 序列化的數據長度較 query param 更大
下面是具體實現及代碼,不喜歡的話能夠直接跳到最下面的 總結

實現

GitHub

基本嘗試

首先,嘗試不使用任何封裝,直接在 created 生命週期中初始化並綁定 $watch

<template>
  <div class="form1">
    <div>
      <label for="keyword">搜索名:</label>
      <input type="text" v-model="form.keyword" id="keyword" />
    </div>
    <div>
      <input
        type="checkbox"
        v-model="form.hobbyList"
        id="anime"
        value="anime"
      />
      <label for="anime">動畫</label>
      <input type="checkbox" v-model="form.hobbyList" id="game" value="game" />
      <label for="game">遊戲</label>
      <input
        type="checkbox"
        v-model="form.hobbyList"
        id="movie"
        value="movie"
      />
      <label for="movie">電影</label>
    </div>
    <p>
      {{ form }}
    </p>
  </div>
</template>

<script>
export default {
  name: 'Form1',
  data() {
    return {
      form: {
        keyword: '',
        hobbyList: [],
      },
    }
  },
  created() {
    const key = 'qb'
    const urlData = JSON.parse(this.$route.query[key] || '{}')
    Object.assign(this.form, urlData.form)
    this.$watch(
      'form',
      function(val) {
        urlData.form = val
        this.$router.replace({
          query: {
            ...this.$route.query,
            [key]: JSON.stringify(urlData),
          },
        })
      },
      {
        deep: true,
      },
    )
  },
}
</script>

分離通用性函數

而後,即是將之分離爲單獨的函數,方便在全部組件中進行復用

/**
 * 初始化一些數據須要序列化/反序列化到 url data 上
 * @param exps 監視的數據的表達式數組
 */
function initUrlData(exps) {
  const key = 'qb'
  const urlData = JSON.parse(this.$route.query[key] || '{}')
  exps.forEach(exp => {
    Object.assign(this[exp], urlData[exp])
    this.$watch(
      exp,
      function(val) {
        urlData[exp] = val
        this.$router.replace({
          query: {
            ...this.$route.query,
            [key]: JSON.stringify(urlData),
          },
        })
      },
      {
        deep: true,
      },
    )
  })
}

使用起來須要在 created 生命中調用

export default {
  created() {
    initUrlData.call(this, ['form'])
  },
}

處理深層監聽

若是須要監聽的值不是 data 下的頂級字段,而是深層字段的話,便不能直接使用 [] 進行取值和賦值了,而是須要實現支持深層取值/賦值的 get/set。並且,深層監聽也意味着通常不會是對象,因此也不能採用 Object.assign 進行合併。

例如須要監聽 page 對象中的 offset, size 兩字段

首先,須要編寫通用的 get/set 函數

/**
 * 解析字段字符串爲數組
 * @param str 字段字符串
 * @returns 字符串數組,數組的 `[]` 取法會被解析爲數組的一個元素
 */
function parseFieldStr(str) {
  return str
    .split(/[\\.\\[]/)
    .map(k => (/\]$/.test(k) ? k.slice(0, k.length - 1) : k))
}

/**
 * 安全的深度獲取對象的字段
 * 注: 只要獲取字段的值爲 {@type null|undefined},就會直接返回 {@param defVal}
 * 相似於 ES2019 的可選調用鏈特性: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/%E5%8F%AF%E9%80%89%E9%93%BE
 * @param obj 獲取的對象
 * @param fields 字段字符串或數組
 * @param [defVal] 取不到值時的默認值,默認爲 null
 */
export function get(obj, fields, defVal = null) {
  if (typeof fields === 'string') {
    fields = parseFieldStr(fields)
  }
  let res = obj
  for (const field of fields) {
    try {
      res = Reflect.get(res, field)
      if (res === undefined || res === null) {
        return defVal
      }
    } catch (e) {
      return defVal
    }
  }
  return res
}

/**
 * 安全的深度設置對象的字段
 * 注: 只要設置字段的值爲 {@type null|undefined},就會直接返回 {@param defVal}
 * 相似於 ES2019 的可選調用鏈特性: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/%E5%8F%AF%E9%80%89%E9%93%BE
 * @param obj 設置的對象
 * @param fields 字段字符串或數組
 * @param [val] 設置字段的值
 */
export function set(obj, fields, val) {
  if (typeof fields === 'string') {
    fields = parseFieldStr(fields)
  }
  let res = obj
  for (let i = 0, len = fields.length; i < len; i++) {
    const field = fields[i]
    console.log(i, res, field, res[field])
    if (i === len - 1) {
      res[field] = val
      return true
    }
    res = res[field]
    console.log('res: ', res)
    if (typeof res !== 'object') {
      return false
    }
  }
  return false
}

而後,是替換賦值操做,將之修改成一個專門的函數

/**
 * 爲 vue 實例上的字段進行深度賦值
 */
function setInitData(vm, exp, urlData) {
  const oldVal = get(vm, exp, null)
  const newVal = urlData[exp]
  if (typeof oldVal === 'object' && newVal !== undefined) {
    Object.assign(get(vm, exp), newVal)
  } else {
    set(vm, exp, newVal)
  }
}

/**
 * 初始化一些數據須要序列化/反序列化到 url data 上
 * @param exps 監視的數據的表達式數組
 */
function initUrlData(exps) {
  const key = 'qb'
  const urlData = JSON.parse(this.$route.query[key] || '{}')
  exps.forEach(exp => {
    setInitData(this, exp, urlData)
    this.$watch(
      exp,
      function(val) {
        urlData[exp] = val
        this.$router.replace({
          query: {
            ...this.$route.query,
            [key]: JSON.stringify(urlData),
          },
        })
      },
      {
        deep: true,
      },
    )
  })
}

這樣,便能單獨監聽對象中的某個字段了。

initUrlData.call(this, ['form.keyword'])
參考:lodash 的函數 get/ set

使用防抖避免觸發過快

但目前而言每次同步都是即時的,在數據量較大時,可能會存在一些問題,因此使用防抖避免每次數據更新都即時同步到 URL 上。

首先,實現一個簡單的防抖函數

/**
 * 函數去抖
 * 去抖 (debounce) 去抖就是對於必定時間段的連續的函數調用,只讓其執行一次
 * 注: 包裝後的函數若是兩次操做間隔小於 delay 則不會被執行, 若是一直在操做就會一直不執行, 直到操做中止的時間大於 delay 最小間隔時間纔會執行一次, 無論任什麼時候間調用都須要中止操做等待最小延遲時間
 * 應用場景主要在那些連續的操做, 例如頁面滾動監聽, 包裝後的函數只會執行最後一次
 * 注: 該函數第一次調用必定不會執行,第一次必定拿不到緩存值,後面的連續調用都會拿到上一次的緩存值。若是須要在第一次調用獲取到的緩存值,則須要傳入第三個參數 {@param init},默認爲 {@code undefined} 的可選參數
 * 注: 返回函數結果的高階函數須要使用 {@see Proxy} 實現,以免原函數原型鏈上的信息丟失
 *
 * @param action 真正須要執行的操做
 * @param delay 最小延遲時間,單位爲 ms
 * @param init 初始的緩存值,不填默認爲 {@see undefined}
 * @return function(...[*]=): Promise<any> {@see action} 是否異步沒有太大關聯
 */
export function debounce(action, delay, init = null) {
  let flag
  let result = init
  return function(...args) {
    return new Promise(resolve => {
      if (flag) clearTimeout(flag)
      flag = setTimeout(
        () => resolve((result = action.apply(this, args))),
        delay,
      )
      setTimeout(() => resolve(result), delay)
    })
  }
}

$watch 中的函數用 debounce 進行包裝

/**
 * 初始化一些數據須要序列化/反序列化到 url data 上
 * @param exps 監視的數據的表達式數組
 */
function initUrlData(exps) {
  const key = 'qb'
  const urlData = JSON.parse(this.$route.query[key] || '{}')
  exps.forEach(exp => {
    setInitData(this, exp, urlData)
    this.$watch(
      exp,
      debounce(function(val) {
        urlData[exp] = val
        this.$router.replace({
          query: {
            ...this.$route.query,
            [key]: JSON.stringify(urlData),
          },
        })
      }, 1000),
      {
        deep: true,
      },
    )
  })
}
引用: 掘金:7 分鐘理解 JS 的節流、防抖及使用場景
參考:lodash 的函數 debounce

處理路由不變但 query 修改的問題

接下來,就須要處理一種小衆,但確實存在的場景了。

  • 同一個組件被多個路由複用,這些路由僅僅只是一個 path param 改變了。例如 標籤頁
  • 用戶複製 URL 以後,發現其中的查詢關鍵字錯了,因而修改了關鍵字以後又複製了一次,而粘貼兩次路由相同 query param 不一樣的 URL 是不會從新建立組件的

首先肯定基本的思路:在路由改變但組件沒有從新建立時將 URL 上的數據爲須要的數據進行初始化

/**
 * 在組件被 vue-router 路由複用時,單獨進行初始化數據
 * @param exps 監視的數據的表達式數組
 * @param route 將要改變的路由對象
 */
function initUrlDataByRouteUpdate(exps, route) {
  const urlData = JSON.parse(route.query[key] || '{}')
  exps.forEach(exp => {
    setInitData(this, exp, urlData)
  })
}

在 vue 實例的生命週期 beforeRouteUpdate, beforeRouteEnter 從新初始化 data 中的數據

export default {
  beforeRouteUpdate(to, from, next) {
    initUrlDataByRouteUpdate.call(this, ['form'], to)
    next()
  },
  beforeRouteEnter(to, from, next) {
    next(vm => initUrlDataByRouteUpdate.call(vm, ['form'], to))
  },
}

真的覺得問題都解決了麼?並否則,打開控制檯你會發現一些 vue router 的警告

vue-router.esm.js?8c4f:2051 Uncaught (in promise) NavigationDuplicated {_name: "NavigationDuplicated", name: "NavigationDuplicated", message: "Navigating to current location ("/form1/?qb=%7B%22…,%22movie%22,%22game%22%5D%7D%7D") is not allowed", stack: "Error↵    at new NavigationDuplicated (webpack-int…/views/Form1.vue?vue&type=script&lang=js&:222:40)"}

實際上是由於循環觸發致使的:序列化數據到 URL 上 => 路由更新觸發 => 初始化數據到 URL 上 => 觸發數據改變 => 序列化數據到 URL 上。。。,目前可行的解決方案是在 $watch 中判斷數據是否與原來的相同,相同就不進行賦值,避免再次觸發 vue-router 的 beforeRouteUpdate 生命週期。

/**
 * 初始化一些數據須要序列化/反序列化到 url data 上
 * @param exps 監視的數據的表達式數組
 */
function initUrlData(exps) {
  const urlData = JSON.parse(this.$route.query[key] || '{}')
  exps.forEach(exp => {
    setInitData(this, exp, urlData)
    this.$watch(
      exp,
      debounce(function(val) {
        urlData[exp] = val
        if (this.$route.query[key] === JSON.stringify(urlData)) {
          return
        }
        this.$router.replace({
          query: {
            ...this.$route.query,
            [key]: JSON.stringify(urlData),
          },
        })
      }, 1000),
      {
        deep: true,
      },
    )
  })
}

如今,控制檯不會再有警告了。

封裝起來

使用 Vue 插件

import { debounce, get, set } from './common'

class VueUrlPersist {
  /**
   * 一些選項
   */
  constructor() {
    this.expListName = 'exps'
    this.urlPersistName = 'qb'
  }

  /**
   * 將 URL 上的數據初始化到 data 上
   * 此處存在一個謬誤
   * 1. 若是對象不使用合併而是賦值,則處理 [乾淨] 的 URL 就會很棘手,由於沒法感知到初始值是什麼
   * 2. 若是對象使用合併,則手動輸入的相同路由不一樣參數的 URL 就沒法處理
   *    注:該問題已經經過在 watch 中判斷值是否變化而解決,但總感受還有莫名其妙的坑在前面等着。。。
   * @param vm
   * @param expOrFn
   * @param urlData
   */
  initVueData(vm, expOrFn, urlData) {
    const oldVal = get(vm, expOrFn, null)
    const newVal = urlData[expOrFn]
    if (oldVal === undefined || oldVal === null) {
      set(vm, expOrFn, newVal)
    } else if (typeof oldVal === 'object' && newVal !== undefined) {
      Object.assign(get(vm, expOrFn), newVal)
    }
  }
  /**
   * 在組件被 vue-router 路由複用時,單獨進行初始化數據
   * @param vm
   * @param expOrFnList
   * @param route
   */
  initNextUrlData(vm, expOrFnList, route) {
    const urlData = JSON.parse(route.query[this.urlPersistName] || '{}')
    console.log('urlData: ', urlData)
    expOrFnList.forEach(expOrFn => {
      this.initVueData(vm, expOrFn, urlData)
    })
  }

  /**
   * 在組件被 vue 建立後初始化數據並監聽之,在發生變化時自動序列化到 URL 上
   * 注:須要序列化到 URL 上的數據必須能被 JSON.stringfy 序列化
   * @param vm
   * @param expOrFnList
   */
  initUrlData(vm, expOrFnList) {
    const urlData = JSON.parse(vm.$route.query[this.urlPersistName] || '{}')
    expOrFnList.forEach(expOrFn => {
      this.initVueData(vm, expOrFn, urlData)

      vm.$watch(
        expOrFn,
        debounce(1000, async val => {
          console.log('val 變化了: ', val)
          urlData[expOrFn] = val

          if (
            vm.$route.query[this.urlPersistName] === JSON.stringify(urlData)
          ) {
            return
          }

          await vm.$router.replace({
            query: {
              ...vm.$route.query,
              [this.urlPersistName]: JSON.stringify(urlData),
            },
          })
        }),
        {
          deep: true,
        },
      )
    })
  }
  install(Vue, options = {}) {
    const _this = this
    if (options.expListName) {
      this.expListName = options.expListName
    }
    if (options.urlPersistName) {
      this.urlPersistName = options.urlPersistName
    }
    Vue.prototype.$urlPersist = this

    function initDataByRouteUpdate(to) {
      const expList = this[_this.expListName]
      if (Array.isArray(expList)) {
        this.$urlPersist.initNextUrlData(this, expList, to)
      }
    }

    Vue.mixin({
      created() {
        const expList = this[_this.expListName]
        if (Array.isArray(expList)) {
          this.$urlPersist.initUrlData(this, expList)
        }
      },
      beforeRouteUpdate(to, from, next) {
        initDataByRouteUpdate.call(this, to)
        next()
      },
      beforeRouteEnter(to, from, next) {
        next(vm => initDataByRouteUpdate.call(vm, to))
      },
    })
  }
}

export default VueUrlPersist

使用起來和其餘的插件沒什麼差異

// main.js
import VueUrlPersist from './views/js/VueUrlPersist'

const vueUrlPersist = new VueUrlPersist()
Vue.use(vueUrlPersist)

在須要使用的組件中只要聲明這個屬性就行了。

export default {
  name: 'Form2Tab',
  data() {
    return {
      form: {
        keyword: '',
        sex: 0,
      },
      exps: ['form'],
    }
  },
}

然而,使用 vue 插件有個致命的缺陷:不管是否須要,都會爲每一個組件中都混入三個生命週期函數,吾輩沒有找到一種能夠根據實例中是否包含某個值而決定是否混入的方式。

使用高階函數

因此,咱們使用 高階函數 + mixin 的形式看看。

import { debounce, get, set } from './common'

class VueUrlPersist {
  /**
   * 一些選項
   */
  constructor({ key = 'qb' } = {}) {
    this.key = key
  }

  /**
   * 爲 vue 實例上的字段進行深度賦值
   */
  setInitData(vm, exp, urlData) {
    const oldVal = get(vm, exp, null)
    const newVal = urlData[exp]
    //若是原值是對象且新值也是對象,則進行淺合併
    if (
      oldVal === undefined ||
      oldVal === null ||
      typeof oldVal === 'string' ||
      typeof oldVal === 'number'
    ) {
      set(vm, exp, newVal)
    } else if (typeof oldVal === 'object' && typeof newVal === 'object') {
      Object.assign(get(vm, exp), newVal)
    }
  }
  /**
   * 初始化一些數據須要序列化/反序列化到 url data 上
   * @param vm vue 實例
   * @param exps 監視的數據的表達式數組
   */
  initUrlDataByCreated(vm, exps) {
    const key = this.key
    const urlData = JSON.parse(vm.$route.query[key] || '{}')
    exps.forEach(exp => {
      this.setInitData(vm, exp, urlData)
      vm.$watch(
        exp,
        debounce(function(val) {
          urlData[exp] = val
          if (vm.$route.query[key] === JSON.stringify(urlData)) {
            return
          }
          vm.$router.replace({
            query: {
              ...vm.$route.query,
              [key]: JSON.stringify(urlData),
            },
          })
        }, 1000),
        {
          deep: true,
        },
      )
    })
  }

  /**
   * 在組件被 vue-router 路由複用時,單獨進行初始化數據
   * @param vm vue 實例
   * @param exps 監視的數據的表達式數組
   * @param route 將要改變的路由對象
   */
  initUrlDataByRouteUpdate(vm, exps, route) {
    const urlData = JSON.parse(route.query[this.key] || '{}')
    exps.forEach(exp => this.setInitData(vm, exp, urlData))
  }

  /**
   * 生成能夠 mixin 到 vue 實例的對象
   * @param exps 監視的數據的表達式數組
   * @returns {{created(): void, beforeRouteEnter(*=, *, *): void, beforeRouteUpdate(*=, *, *): void}}
   */
  generateInitUrlData(...exps) {
    const _this = this
    return {
      created() {
        _this.initUrlDataByCreated(this, exps)
      },
      beforeRouteUpdate(to, from, next) {
        _this.initUrlDataByRouteUpdate(this, exps, to)
        next()
      },
      beforeRouteEnter(to, from, next) {
        console.log('beforeRouteEnter')
        next(vm => _this.initUrlDataByRouteUpdate(vm, exps, to))
      },
    }
  }

  /**
   * 修改一些配置
   * @param options 配置項
   */
  config(options) {
    Object.assign(this, options)
  }
}
const vueUrlPersist = new VueUrlPersist()
const generateInitUrlData = vueUrlPersist.generateInitUrlData.bind(
  vueUrlPersist,
)

export { vueUrlPersist, generateInitUrlData, VueUrlPersist }

export default vueUrlPersist

使用起來幾乎同樣簡單

import { generateInitUrlData } from './js/VueUrlPersist'

export default {
  name: 'Form1',
  mixins: [generateInitUrlData('form')],
  data() {
    return {
      form: {
        keyword: '',
        hobbyList: [],
      },
    }
  },
}

看起來,使用高階函數也沒有比 Vue 插件麻煩太多。

總結

總的來講,雖然路途坎坷,不過這個問題仍是頗有趣的,並且確實能解決實際的問題,因此仍是有研究價值的。

相關文章
相關標籤/搜索