背景
最近接到一个需求,公司内部的OA系统(后端人员基于jQuery开发),需要给所有的表单增加暂存与还原功能。具体来说就是对于系统内的任何表单,都增加一个暂存按钮,用户填写到一半,点击暂存可以保存起来。下次再打开时,点击还原,可以恢复到之前编辑的样子,继续编辑。
听完这个需求,眉头不由的一锁,心想这能实现吗?不过好在PM说这个不紧急,可以调研下,于是对这个需求进行了些许分析。
可行性分析
如果是 React 或 Vue 的项目,基于数据驱动视图,那么还是比较好实现的,只需保存好当前状态下的数据就行。但对于这种传统老项目,非数据驱动模式,存在通用的解决办法么。
分场景考虑
既然咋一看没啥思路,那么就对最简单和最复杂的场景分别分析了下
- 最简单场景
页面都是静态表单情况。这种情况一想便知可以实现,只需保存好当前表单的所有数据,恢复时再赋值给对应的表单元素即可。 - 最复杂的场景
想象一下,对于表单来说,最复杂的情况无非是包含下列几种场景- 表单数据之间存在联动关系,最常见的
Select多级联动 - 表单可以被动态增加,比如提交一个报销单,可以添加多条报销明细
- 表单数据会影响页面的信息展示,比如某个银行卡的输入框,输入完后,页面其他部分会展示一个格式化后的银行卡号
- 表单数据之间存在联动关系,最常见的
这么一分析后觉得,这肯定实现不了。就说表单动态添加这一项,怎么样能做到100%还原呢?
去网上搜索了一些开源方案,比如 https://github.com/simsalabim/sisyphus/,发现都是针对上述简单场景的还原,看来对于复杂的场景,确实没有通用的方法。
解决特定场景问题
静下来思考了下,虽然对于最复杂的场景没有好办法,但目前OA系统中遇到的场景复杂度,是略低于最复杂场景的。 那么能不能做到尽可能覆盖更多的场景,对于实在无法暂存还原的,进行单独处理呢?
方案思考
首先对问题进行了抽象,复杂场景和简单场景最大的区别就是DOM结构产生了变化,那么如果能还原出DOM结构,再把数据进行赋值,那不就可以了。
方案目标
基于上述思考,重新理了下方案的目标
- 还原所有表单的HTML,包括动态加载部分
- 还原数据
- 还原非表单部分的展示性HTML
实现思路
方案的难点在于如何还原表单的HTML,思索一番产生一个想法,能否通过还原用户行为来还原表单
这个办法理论上是可行的,同样的用户行为,在同一个系统的不同的时刻执行一遍,执行的结果大概率是一样的。并且暂存与还原的操作之间,并不会相隔太久,所以大概率可以还原成功。
基于这个思路,梳理了一下暂存还原的流程
- 按照时间线记录用户所有操作
难点:事件这么多,如何只记录会对表单有影响的关键事件 - 对于实在无法记录的表单,提供可以对局部HTML结构进行保存的方法
- 记录当前表单的所有数据
还原流程
- 遍历所有保存的用户操作,逐一进行触发
难点:事件触发的时间间隔如何确定?比如事件A触发后,可能需要等一些异步操作结束后,才能触发事件B,那么具体等多久该如何确定 - 对于上述步骤2提到的无法记录的表单,进行HTML结构还原
- 对表单项进行赋值,还原表单数据
- 对新老表单数据进行对比,新老不一致的地方,提示用户手动修改
按照这个思路,感觉应该是能实现了,不过还有一个大难点,就是还原流程中事件的触发时序问题。
代码实现流程
经过上面的分析,虽然有一些难点问题,但整体流程比较清晰了,下面跟着核心代码的实现来看看整个的过程
- 业务调用以及暴露的API 提供了开启记录、暂存和还原三个API,让这些老项目可以最简单的接入
// 初始化还原对象
window.record = new Restore({
form: window.$('#commentForm'), // 表单的handler
customListenType: { // 自定义需要监听的元素类型和监听的事件
'span[type="button"]': 'click',
}
})
// 开启记录
window.record.init()
// 保存当前表单状态
window.record.holdForm()
// 还原表单
window.record.recoverForm()
- 记录用户行为
这部分重点是:确定要记录的事件。
用户的事件这么多,都记录下来的话既无意义,也为后续的存储增加了负担。所以需要定义出,哪些用户事件要记录,要记录哪些事件相关信息。
来看看这部分代码
/**
* 定义需要监听的元素类型以及对应的事件
* 元素的key为CSS选择器,值为事件名称,事件名称为事件类型,如click, mouseover, mouseout等
*/
this.eleWithEvent = {
input: 'blur',
'input[type="text"]': 'blur',
'input[type="button"]': 'click',
'input[type="radio"]': 'click|change',
'input[type="checkbox"]': 'click|change',
textarea: 'blur',
select: 'change',
'button[type="button"]': 'click'
}
// 监听事件
#listenEvent() {
const eventNames = this.#getEventNames()
eventNames.forEach(eventName => { // 只监听定义好的事件
this.form.addEventListener(
eventName,
e => {
this.#addUserActions(e, eventName)
},
true
)
})
}
/**
* @description 记录用户行为
* @param {Event} e 事件对象
* @param {String} eventName 事件名称
* @memberof Restore
*/
#addUserActions(e, eventName) {
const ele = e.target
const eleType = getEleTypeName(ele)
const eleSelector = getUniqueSelector(ele)
const eleName = ele.name
const id = `${eleSelector.selector}-${eleSelector.index}`
const hasChangeDOM = false
if (this.eleWithEvent[eleType] && this.eleWithEvent[eleType].includes(eventName)) {
const eventModel = this.#createEventModel({
id,
eleType,
eventName,
eleSelector,
eleName,
hasChangeDOM,
})
this.userActions.push(eventModel)
}
}
最后一步的添加事件中,可以看到事件有一个 hasChangeDOM 属性,接下来我们讲下这个属性是做什么用的
- 记录该事件是否改变了DOM
上面分析的时候提到了一个难点,就是在还原的时候,如何确定事件触发的时间间隔。
比如用户填写了输入框A,间隔10秒后再次填写输入框B,对于这两个事件,还原的时候可以循环直接触发即可。
但另一种情况,用户在选择框A中选择了A1,这时候发送了一个AJAX请求,选择框B中的数据更新,然后用户在选择框B选择了B1,如果按照事件顺序直接还原,那么在还原选择框B的时候就会出现问题,因为选择框A发送的AJAX请求数据还没有回来,选择框B并没有相应的数据可以选择。
经过对此类问题的分析,提出了这样一种方式解决思路: 在事件A发生之后,如果监听到表单内有DOM结构变化,那么对事件A记录该事件触发了DOM变化,后续在还原的时候,也需要触发完事件A,并且DOM变化后,再触发事件B
这里看下监听DOM变化的代码
#listenDOMChange(isRecover) { // isRecover代表是否是还原的流程
this.observer = new MutationObserver(mutations => {
if (mutations.length) {
if (!isRecover && this.userActions.length) {
this.userActions.at(-1).hasChangeDOM = true // DOM变化后记录hasChangeDOM
}
if (isRecover && this.eventResolver) {
this.eventResolver()
this.eventResolver = null
}
}
})
// 只监听DOM结构的变化
this.observer.observe(this.form, {
childList: true,
subtree: true
})
}
- 用户数据的暂存
用户数据的存储,最重要的就是对事件去重。
上面提了只记录关键元素的事件,这么做之后可以减少很大一部分的无用事件记录,但还有一个问题,就是对于关键的元素,事件也会重复发生,比如一个
input框,用户会反复输入,所以在保存阶段,需要对事件去重,只保留同元素同类型事件的最后一次,下面是具体代码
// 事件去重
#filterSameAction() {
const userActions = this.userActions
const filterUserActions = []
// userActions倒序遍历
for (let i = userActions.length - 1; i >= 0; i--) {
if (parentHasAttr(document.querySelectorAll(userActions[i].eleSelector.selector)[
userActions[i].eleSelector.index
], this.customDOMAttr)) {
continue
}
if (
userActions[i].eleType === 'input[type="button"]' ||
!filterUserActions.find(item => {
return item.id === userActions[i].id
})
) {
// 像数组前面添加元素
filterUserActions.unshift(userActions[i])
}
}
this.userActions = filterUserActions
}
/**
* @description 暂存表单
* @memberof Restore
*/
holdForm() {
this.#filterSameAction()
const store = {
actions: this.userActions, // 去重后的事件
formData: getFormData(this.form), // 表单的数据
customDOM: getCustomDOM(this.form, this.customDOMAttr, this.customDOMAttrContent), // 自定义的DOM结构
}
// 存储到localStorage
localStorage.setItem('form-restore', JSON.stringify(store))
return this
}
- 表单的还原 终于到了最重要的表单还原部分了,整个还原流程是这样的:
- 遍历用户的行为,并进行触发
- 页面渲染
- 判断渲染结束(DOM变化结束)
- 循环第一步,直到所有用户操作都完成
- 恢复自定义DOM
- 对表单数据进行赋值
- 获取当前FORM与原始数据对比,提示数据异常的部分,让用户手动调整
具体代码实现如下
/**
* @description 恢复用户操作
* @memberof Restore
*/
async #recoverEvent() {
const { actions, formData } = this.store
// eslint-disable-next-line no-unused-vars
for (const [index, action] of actions.entries()) {
await new Promise(resolve => {
const ele = document.querySelectorAll(action.eleSelector.selector)[
action.eleSelector.index
]
// 如果元素存在,则对元素赋值,并触发事件
if (ele) {
const name = action.eleName
const value = formData[name]
if (action.eleType !== 'input[type="button"]') {
if ('input[type="radio"]|input[type="checkbox"]'.includes(action.eleType)) {
ele.checked = true
} else {
ele.value = typeof value === 'undefined' ? '' : value
}
}
ele.dispatchEvent(new Event(action.eventName))
if (action.hasChangeDOM) {
this.eventResolver = resolve // 把完成的resovle交给DOM变化的函数去控制
// 如果持续没有返回,那么在固定时间后执行resolve,避免僵死
setTimeout(() => {
resolve && resolve(action)
}, this.recoverTimeout)
} else {
resolve(action)
}
} else {
// 如果没找到元素,那么直接resolve
resolve(action)
}
})
}
// 恢复自定义的DOM
this.#customDOM()
// 恢复自定义表单数据
if (formData) {
this.#recoverFormData()
}
console.log('[Restore]表单复原完成')
}
至此,整个表单暂存还原的核心逻辑实现完成。具体的细节部分,还是有一些小坑的,比如老项目中会有一些 alert、confirm等弹窗,还原时,如果触发了则会中断代码的执行,需要在还原的时候先重置它们,之后在恢复过来。
源码地址
行文仓促,有些部分讲述的不太清楚,并且该方法也只适用于传统的非数据驱动的项目,感兴趣的小伙伴可以直接 clone 该项目的源码直接使用,地址:github.com/huangjiaxin…