最近接到一个需求,公司内部的 OA 系统(后端人员基于 jQuery 开发),需要给所有的表单增加暂存与还原功能。具体来说就是对于系统内的任何表单,都增加一个暂存按钮,用户填写到一半,点击暂存可以保存起来。下次再打开时,点击还原,可以恢复到之前编辑的样子,继续编辑。
听完这个需求,眉头不由的一锁,心想这能实现吗?不过好在 PM 说这个不紧急,可以调研下,于是对这个需求进行了些许分析。
如果是 React
或 Vue
的项目,基于数据驱动视图,那么还是比较好实现的,只需保存好当前状态下的数据就行。但对于这种传统老项目,非数据驱动模式,存在通用的解决办法么。。
既然咋一看没啥思路,那么就对最简单和最复杂的场景分别分析了下
Select
多级联动这么一分析后觉得,这肯定实现不了。就说表单动态添加这一项,怎么样能做到 100%还原呢?
去网上搜索了一些开源方案,比如 https://github.com/simsalabim/sisyphus/
,发现都是针对上述简单场景的还原,看来对于复杂的场景,确实没有通用的方法。
静下来思考了下,虽然对于最复杂的场景没有好办法,但目前 OA 系统中遇到的场景复杂度,是略低于最复杂场景的。那么能不能做到尽可能覆盖更多的场景,对于实在无法暂存还原的,进行单独处理呢?
首先对问题进行了抽象,复杂场景和简单场景最大的区别就是DOM 结构产生了变化,那么如果能还原出 DOM 结构,再把数据进行赋值,那不就可以了。
基于上述思考,重新理了下方案的目标
方案的难点在于如何还原表单的 HTML,思索一番产生一个想法,能否通过还原用户行为来还原表单
这个办法理论上是可行的,同样的用户行为,在同一个系统的不同的时刻执行一遍,执行的结果大概率是一样的。并且暂存与还原的操作之间,并不会相隔太久,所以大概率可以还原成功。
基于这个思路,梳理了一下暂存还原的流程
还原流程
按照这个思路,感觉应该是能实现了,不过还有一个大难点,就是还原流程中事件的触发时序问题。
经过上面的分析,虽然有一些难点问题,但整体流程比较清晰了,下面跟着核心代码的实现来看看整个的过程
// 初始化还原对象
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
属性,接下来我们讲下这个属性是做什么用的
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
}
具体代码实现如下
/**
* @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
该项目的源码直接使用,地址:https://github.com/huangjiaxing/form-restore