吾辈的博客原文: 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 插件麻烦太多。
总的来讲,虽然路途坎坷,不过这个问题仍是颇有趣的,并且确实能解决实际的问题,因此仍是有研究价值的。