拯救老项目-表单暂存与还原

2,419 阅读8分钟

背景

最近接到一个需求,公司内部的OA系统(后端人员基于jQuery开发),需要给所有的表单增加暂存与还原功能。具体来说就是对于系统内的任何表单,都增加一个暂存按钮,用户填写到一半,点击暂存可以保存起来。下次再打开时,点击还原,可以恢复到之前编辑的样子,继续编辑。

听完这个需求,眉头不由的一锁,心想这能实现吗?不过好在PM说这个不紧急,可以调研下,于是对这个需求进行了些许分析。

可行性分析

如果是 ReactVue 的项目,基于数据驱动视图,那么还是比较好实现的,只需保存好当前状态下的数据就行。但对于这种传统老项目,非数据驱动模式,存在通用的解决办法么。

分场景考虑

既然咋一看没啥思路,那么就对最简单和最复杂的场景分别分析了下

  1. 最简单场景
    页面都是静态表单情况。这种情况一想便知可以实现,只需保存好当前表单的所有数据,恢复时再赋值给对应的表单元素即可。
  2. 最复杂的场景
    想象一下,对于表单来说,最复杂的情况无非是包含下列几种场景
    • 表单数据之间存在联动关系,最常见的 Select 多级联动
    • 表单可以被动态增加,比如提交一个报销单,可以添加多条报销明细
    • 表单数据会影响页面的信息展示,比如某个银行卡的输入框,输入完后,页面其他部分会展示一个格式化后的银行卡号

这么一分析后觉得,这肯定实现不了。就说表单动态添加这一项,怎么样能做到100%还原呢?
去网上搜索了一些开源方案,比如 https://github.com/simsalabim/sisyphus/,发现都是针对上述简单场景的还原,看来对于复杂的场景,确实没有通用的方法。

解决特定场景问题

静下来思考了下,虽然对于最复杂的场景没有好办法,但目前OA系统中遇到的场景复杂度,是略低于最复杂场景的。 那么能不能做到尽可能覆盖更多的场景,对于实在无法暂存还原的,进行单独处理呢?

方案思考

首先对问题进行了抽象,复杂场景和简单场景最大的区别就是DOM结构产生了变化,那么如果能还原出DOM结构,再把数据进行赋值,那不就可以了。

方案目标

基于上述思考,重新理了下方案的目标

  1. 还原所有表单的HTML,包括动态加载部分
  2. 还原数据
  3. 还原非表单部分的展示性HTML

实现思路

方案的难点在于如何还原表单的HTML,思索一番产生一个想法,能否通过还原用户行为来还原表单
这个办法理论上是可行的,同样的用户行为,在同一个系统的不同的时刻执行一遍,执行的结果大概率是一样的。并且暂存与还原的操作之间,并不会相隔太久,所以大概率可以还原成功。

基于这个思路,梳理了一下暂存还原的流程

  1. 按照时间线记录用户所有操作
    难点:事件这么多,如何只记录会对表单有影响的关键事件
  2. 对于实在无法记录的表单,提供可以对局部HTML结构进行保存的方法
  3. 记录当前表单的所有数据

还原流程

  1. 遍历所有保存的用户操作,逐一进行触发
    难点:事件触发的时间间隔如何确定?比如事件A触发后,可能需要等一些异步操作结束后,才能触发事件B,那么具体等多久该如何确定
  2. 对于上述步骤2提到的无法记录的表单,进行HTML结构还原
  3. 对表单项进行赋值,还原表单数据
  4. 对新老表单数据进行对比,新老不一致的地方,提示用户手动修改

按照这个思路,感觉应该是能实现了,不过还有一个大难点,就是还原流程中事件的触发时序问题。

代码实现流程

经过上面的分析,虽然有一些难点问题,但整体流程比较清晰了,下面跟着核心代码的实现来看看整个的过程

  1. 业务调用以及暴露的API 提供了开启记录、暂存和还原三个API,让这些老项目可以最简单的接入
// 初始化还原对象
window.record = new Restore({
  form: window.$('#commentForm'),  // 表单的handler
  customListenType: {  // 自定义需要监听的元素类型和监听的事件
    'span[type="button"]': 'click',
  }
})

// 开启记录
window.record.init()

// 保存当前表单状态
window.record.holdForm()

// 还原表单
window.record.recoverForm()
  1. 记录用户行为 这部分重点是:确定要记录的事件
    用户的事件这么多,都记录下来的话既无意义,也为后续的存储增加了负担。所以需要定义出,哪些用户事件要记录,要记录哪些事件相关信息。
    来看看这部分代码
/**
 * 定义需要监听的元素类型以及对应的事件
 * 元素的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 属性,接下来我们讲下这个属性是做什么用的

  1. 记录该事件是否改变了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
  })
}
  1. 用户数据的暂存 用户数据的存储,最重要的就是对事件去重。 上面提了只记录关键元素的事件,这么做之后可以减少很大一部分的无用事件记录,但还有一个问题,就是对于关键的元素,事件也会重复发生,比如一个 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
}
  1. 表单的还原 终于到了最重要的表单还原部分了,整个还原流程是这样的:
  2. 遍历用户的行为,并进行触发
  3. 页面渲染
  4. 判断渲染结束(DOM变化结束)
  5. 循环第一步,直到所有用户操作都完成
  6. 恢复自定义DOM
  7. 对表单数据进行赋值
  8. 获取当前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]表单复原完成')
}

至此,整个表单暂存还原的核心逻辑实现完成。具体的细节部分,还是有一些小坑的,比如老项目中会有一些 alertconfirm等弹窗,还原时,如果触发了则会中断代码的执行,需要在还原的时候先重置它们,之后在恢复过来。

源码地址

行文仓促,有些部分讲述的不太清楚,并且该方法也只适用于传统的非数据驱动的项目,感兴趣的小伙伴可以直接 clone 该项目的源码直接使用,地址:github.com/huangjiaxin…