吾輩的博客原文: https://blog.rxliuli.com/p/d5...
在使用 Vue SPA 開發面向普通用戶的網站時,吾輩也遇到了一些以前未被重視,但卻實實在在存在的問題,此次便淺談一下 SPA 網站將全部數據都存儲到內存中致使數據很容易丟失以及吾輩思考並嘗試的解決方案。vue
參考:SPA 全稱
single page application
,意爲
單頁應用,不是泥萌想的那樣!#笑哭
首先列出爲何遇到這個問題,具體場景及解決的問題是什麼?webpack
想要解決的一些問題git
那麼,先談一下每一個問題的解決方案github
刷新頁面數據不丟失web
將數據序列化到本地,例如 localStorage
中,而後在刷新後獲取一次vue-router
URL 複製給其餘人數據不丟失vuex
頁面返回數據不丟失npm
將數據放到 vuex 中,而且在 URL 上使用 key
進行標識數組
keep-alive
在瞭解了這麼多的解決方案以後,吾輩最終選擇了兼容性最好的 URL 保存數據,它能同時解決 3 個問題。然而,很遺憾的是,這彷佛並無不少人討論這個問題,或許,這個問題本應該是默認就須要解決的,亦或是 SPA 網站真的不多關心這些了。promise
雖然說如此,吾輩仍是找到了一些討論的 StackOverflow: How to hold URL query params in Vue with Vue-Router
一個基本的思路是可以肯定的
而後,再次出現了一個分歧點,到底要不要綁定 Vue?
created, beforeRouteUpdate
與監聽器 watch
那麼,二者有什麼區別呢?
思路 | 不綁定 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 上不現實
這裏吾輩對 yarn 進行了考察發現其也是異步更新 URL
序列化數據到 URL 上 => 路由更新觸發 => 初始化數據到 URL 上 => 觸發數據改變 => 序列化數據到 URL 上。。。
2083
長度的 URL,換算爲中文即爲 231
個,因此不能做爲一種通用方式進行下面是具體實現及代碼,不喜歡的話能夠直接跳到最下面的 總結。
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
接下來,就須要處理一種小衆,但確實存在的場景了。
首先肯定基本的思路:在路由改變但組件沒有從新建立時將 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, }, ) }) }
如今,控制檯不會再有警告了。
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 插件麻煩太多。
總的來講,雖然路途坎坷,不過這個問題仍是頗有趣的,並且確實能解決實際的問題,因此仍是有研究價值的。