本文最后更新于:2020年12月25日 上午
场景
该功能吾辈已经封装成 NPM 库 vue-url-persist
在使用 Vue SPA 开发面向普通用户的网站时,吾辈也遇到了一些之前未被重视,但却实实在在存在的问题,这次便浅谈一下 SPA 网站将所有数据都存储到内存中导致数据很容易丢失以及吾辈思考并尝试的解决方案。
参考:SPA 全称 single page application
,意为 单页应用 ,不是泥萌想的那样!#笑哭
思维导图
首先列出为什么遇到这个问题,具体场景及解决的问题是什么?
想要解决的一些问题
刷新页面数据不丢失:因为数据都保存在内存中,所以刷新之后自然不存在了
URL 复制给其他人数据不丢失:因为数据没有持久化到 URL 上,也没有根据 URL 上的数据进行初始化,所以复制给别人的 URL 当然会丢失数据(搜索条件之类)
页面返回数据不丢失:因为数据都保存在内存中,所以跳转到其他路由再跳转回来数据当然不会存在了
那么,先谈一下每个问题的解决方案
刷新页面数据不丢失
将数据序列化到本地,例如 localStorage
中,然后在刷新后获取一次
将数据序列化到 URL 上,每次加载都从 URL 上获取数据
URL 复制给其他人数据不丢失
页面返回数据不丢失
将数据放到 vuex 中,并且在 URL 上使用 key
进行标识
将数据序列化到 URL 上,并且不新增路由记录
使用 vue-router 的缓存 keep-alive
在了解了这么多的解决方案之后,吾辈最终选择了兼容性最好的 URL 保存数据,它能同时解决 3 个问题。然而,很遗憾的是,这似乎并没有很多人讨论这个问题,或许,这个问题本应该是默认就需要解决的,亦或是 SPA 网站真的很少关心这些了。
虽说如此,吾辈还是找到了一些讨论的 StackOverflow: How to hold URL query params in Vue with Vue-Router
思路 一个基本的思路是能够确定的
在组件创建时,从 URL 获取数据并为需要的数据进行初始化
在这些数据变化时,及时将数据序列化到 URL 上
然后,再次出现了一个分歧点,到底要不要绑定 Vue?
不绑定 vue 手动监听对象变化并将对象的变化响应到 URL 上
绑定 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 上导致路由记录会随着改变增加
即时序列化数据到 URL 上不现实
这里吾辈对 yarn 进行了考察发现其也是异步更新 URL
序列化到 URL 上时导致的死循环,序列化数据到 URL 上 => 路由更新触发 => 初始化数据到 URL 上 => 触发数据改变 => 序列化数据到 URL 上。。。
同一个路由携带不同的查询参数的 URL 直接在地址栏输入回车一次不会触发页面数据更新
URL 最大保存数据 IE 最多支持 2083
长度的 URL,换算为中文即为 231
个,所以不能作为一种通用方式进行
Vue 插件不能动态混入,而是在各个生命周期中判断是否要处理的
仍遗留
JSON 序列化的数据长度较 query param 更大
下面是具体实现及代码,不喜欢的话可以直接跳到最下面的 总结 。
实现
GitHub
基本尝试 首先,尝试不使用任何封装,直接在 created
生命周期中初始化并绑定 $watch
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 51 52 53 54 55 56 57 58 59 60 61 62 63 <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 >
分离通用性函数 然后,便是将之分离为单独的函数,方便在所有组件中进行复用
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 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
生命中调用
1 2 3 4 5 export default { created ( ) { initUrlData.call (this , ['form' ]) }, }
处理深层监听 如果需要监听的值不是 data 下的顶级字段,而是深层字段的话,便不能直接使用 []
进行取值和赋值了,而是需要实现支持深层取值/赋值的 get/set
。而且,深层监听也意味着一般不会是对象,所以也不能采用 Object.assign
进行合并。
例如需要监听 page
对象中的 offset, size
两字段
首先,需要编写通用的 get/set
函数
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 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 function parseFieldStr (str ) { return str .split (/[\\.\\[]/ ) .map ((k ) => (/\]$/ .test (k) ? k.slice (0 , k.length - 1 ) : k)) }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 }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 }
然后,是替换赋值操作,将之修改为一个专门的函数
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 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) } }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 , }, ) }) }
这样,便能单独监听对象中的某个字段了。
1 initUrlData.call (this , ['form.keyword' ])
参考:lodash 的函数 get /set
使用防抖避免触发过快 但目前而言每次同步都是即时的,在数据量较大时,可能会存在一些问题,所以使用防抖避免每次数据更新都即时同步到 URL 上。
首先,实现一个简单的防抖函数
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 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
进行包装
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 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 上的数据为需要的数据进行初始化
1 2 3 4 5 6 7 8 9 10 11 function initUrlDataByRouteUpdate (exps, route ) { const urlData = JSON .parse (route.query [key] || '{}' ) exps.forEach ((exp ) => { setInitData (this , exp, urlData) }) }
在 vue 实例的生命周期 beforeRouteUpdate, beforeRouteEnter
重新初始化 data
中的数据
1 2 3 4 5 6 7 8 9 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 的警告
1 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
生命周期。
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 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 插件 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 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 import { debounce, get, set } from './common' class VueUrlPersist { constructor ( ) { this .expListName = 'exps' this .urlPersistName = 'qb' } 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) } } initNextUrlData (vm, expOrFnList, route ) { const urlData = JSON .parse (route.query [this .urlPersistName ] || '{}' ) console .log ('urlData: ' , urlData) expOrFnList.forEach ((expOrFn ) => { this .initVueData (vm, expOrFn, urlData) }) } 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
使用起来和其他的插件没什么差别
1 2 3 4 5 import VueUrlPersist from './views/js/VueUrlPersist' const vueUrlPersist = new VueUrlPersist ()Vue .use (vueUrlPersist)
在需要使用的组件中只要声明这个属性就好了。
1 2 3 4 5 6 7 8 9 10 11 12 export default { name : 'Form2Tab' , data ( ) { return { form : { keyword : '' , sex : 0 , }, exps : ['form' ], } }, }
然而,使用 vue 插件有个致命的缺陷:无论是否需要,都会为每个组件中都混入三个生命周期函数,吾辈没有找到一种可以根据实例中是否包含某个值而决定是否混入的方式。
使用高阶函数 所以,我们使用 高阶函数
+ 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 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 import { debounce, get, set } from './common' class VueUrlPersist { constructor ({ key = 'qb' } = {} ) { this .key = key } 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) } } 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 , }, ) }) } initUrlDataByRouteUpdate (vm, exps, route ) { const urlData = JSON .parse (route.query [this .key ] || '{}' ) exps.forEach ((exp ) => this .setInitData (vm, exp, urlData)) } 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)) }, } } config (options ) { Object .assign (this , options) } }const vueUrlPersist = new VueUrlPersist ()const generateInitUrlData = vueUrlPersist.generateInitUrlData .bind ( vueUrlPersist, )export { vueUrlPersist, generateInitUrlData, VueUrlPersist }export default vueUrlPersist
使用起来几乎一样简单
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import { generateInitUrlData } from './js/VueUrlPersist' export default { name : 'Form1' , mixins : [generateInitUrlData ('form' )], data ( ) { return { form : { keyword : '' , hobbyList : [], }, } }, }
看起来,使用高阶函数也没有比 Vue 插件麻烦太多。
总结 总的来说,虽然路途坎坷,不过这个问题还是很有趣的,而且确实能解决实际的问题,所以还是有研究价值的。