|
背景最近接到一个需求,公司内部的 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 romise(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
|
|