background

Recently, I received a demand that the company’s internal OA system (developed by backend personnel based on jQuery) needs to add temporary storage and restore functions to all forms. Specifically, for any form in the system, a temporary save button will be added. The user will fill in half and click the temporary save button to save it. The next time you open it again, click Restore and you can restore it to the way you edited it before and continue editing.

After listening to this demand, brow not from a lock, thought this can achieve? Fortunately, PM said it was not urgent and could be investigated, so they made some analysis on this demand.

Feasibility analysis

React or Vue projects with data-driven views are easier to implement, just save the data in its current state. But is there a universal solution to this legacy, non-data-driven model?

Consider by scenario

Since I didn’t seem to have any ideas at first, I analyzed the simplest and most complicated scenarios separately

  1. The simplest scenario pages are static form cases. You can do this by simply saving all the data from the current form and assigning it to the corresponding form element when you restore it.
  2. The most complicated scenario

    Imagine the most complex case for a form that contains the following scenarios
    • Linkage relationships exist between form data, the most commonSelectMultistage tandem
    • Forms can be added dynamically, such as submitting an expense report, and multiple expense details can be added
    • Form data will affect the display of information on the page, such as the input field of a bank card. After input, the rest of the page will display a formatted bank card number

On such an analysis, it certainly cannot be achieved. Let’s say the form is dynamically added. How can we restore it to 100%? To search some open source solution, such as https://github.com/simsalabim/sisyphus/, found that is aimed at the reduction of simple scenario, look for complex scenes, indeed there is no universal method.

Solve scenario-specific problems

After a quiet reflection, although there is no good method for the most complex scenes, the complexity of the scenes encountered in the CURRENT OA system is slightly lower than that of the most complex scenes. Is it possible to cover as many scenarios as possible, and to do separate processing for those that really can’t be temporarily restored?

Solution thinking

First of all, the problem is abstract, the biggest difference between complex scene and simple scene is the DOM structure has changed, so if you can restore the DOM structure, and then the data assignment, it is not ok.

Project objectives

Based on the above thinking, the goal of the program is reorganized

  1. Restore the HTML for all forms, including the dynamic loading section
  2. Restore data
  3. Restore the non-form part of the presentation HTML

Implementation approach

The difficulty of the scheme lies in how to restore the HTML of the form. It is theoretically feasible to generate an idea after thinking about whether to restore the form by restoring the user behavior. The same user behavior is executed at different times in the same system, and the execution result is most likely the same. In addition, there is not a long interval between the temporary and restore operations, so there is a high probability that the restore will succeed.

Based on this idea, combed the process of temporary storage restore

  1. Difficulty in recording all user operations according to the timeline: With so many events, how to record only the key events that affect the form
  2. For forms that really can’t be logged, provide a way to save partial HTML structures
  3. Records all data for the current form

Reduction process,

  1. Iterate through all saved user operations and trigger them one by one difficulty: How to determine the time interval for event triggering? For example, after event A is triggered, it may be necessary to wait for some asynchronous operations to end before event B can be triggered. How to determine the specific waiting time
  2. For the unrecordable form mentioned in Step 2 above, restore the HTML structure
  3. Assign values to form items and restore form data
  4. Compare the data of the old and new forms, and prompt users to manually modify the inconsistency between the old and new forms

According to this idea, it feels like it can be implemented, but there is still a big difficulty, is to restore the trigger timing of events in the process.

Code implementation process

After the above analysis, although there are some difficult problems, but the overall process is more clear, follow the implementation of the core code to see the whole process

  1. Business calls and exposed apis

Provides open record, temporary storage and restore three APIS, so that these old projects can be the simplest access

// Initialize the restore object
window.record = new Restore({
  form: window$('#commentForm'),  // Form handler
  customListenType: {  // Define the type of elements and events to listen on
    'span[type="button"]': 'click',}})// Start recording
window.record.init()

// Save the current form state
window.record.holdForm()

// Restore the form
window.record.recoverForm()
Copy the code
  1. Recording User behavior

This part focuses on identifying the events to be recorded. So many user events, all recorded words are meaningless, but also for the subsequent storage burden. So you need to define which user events to record and which event-related information to record. So let’s look at this part of the code

/** * Defines the type of element to listen on and the corresponding event * element's key is a CSS selector, value is the event name, event name is the event type, such as click, mouseover, mouseout, etc. */
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'
}

// Listen on events
#listenEvent() {
  const eventNames = this.#getEventNames()
  eventNames.forEach(eventName= > {  // Only listen for defined events
    this.form.addEventListener(
      eventName,
      e= > {
        this.#addUserActions(e, eventName)
      },
      true)})}/ * * *@description Record user behavior *@param {Event} E Event object *@param {String} EventName 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)
  }
}
Copy the code

In the last step of the add event, you can see that the event has a hasChangeDOM property. Now, what does this property do

  1. Log whether the event changed the DOM

One difficulty mentioned in the above analysis is how to determine the time interval for the event to fire during the restore. For example, the user fills in input box A and fills in input box B again after an interval of 10 seconds. For these two events, they can be directly triggered in A cycle during restoration. However, in the other case, the user selects A1 from selection box A, at this time, an AJAX request is sent, the data in selection box B is updated, and then the user selects B1 from selection box B. If the restoration is done directly according to the event sequence, problems will occur when restoring selection box B. Because the AJAX request data sent by select box A has not returned, select box B has no corresponding data to select from.

After the analysis of such problems, A solution is proposed: after the occurrence of event A, if the DOM structure change is detected in the form, then the event A is recorded to trigger the DOM change, and the subsequent restoration needs to trigger the event A, and the DOM change, and then trigger the event B

Here’s the code that listens for DOM changes

#listenDOMChange(isRecover) {  // isRecover indicates whether the process is restored
  this.observer = new MutationObserver(mutations= > {
    if (mutations.length) {
      if(! isRecover &&this.userActions.length) {
        this.userActions.at(-1).hasChangeDOM = true // Record hasChangeDOM after DOM changes
      }
      if (isRecover && this.eventResolver) {
        this.eventResolver()
        this.eventResolver = null}}})// Only listen for DOM structure changes
  this.observer.observe(this.form, {
    childList: true.subtree: true})}Copy the code
  1. Temporary storage of user data

In the storage of user data, the most important thing is to deduplicate events. Above mention the records only the key elements of the event, to do so after can reduce a large part of useless event record, but there is a problem, is the key element, the event will also repeat, for example, an input box, the user will enter repeatedly, so in the preservation stage, need to be heavy to events, only keep same element type events of the last time, Here’s the code

// Cancel the event
#filterSameAction() {
  const userActions = this.userActions
  const filterUserActions = []

  // userActions in reverse order
  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
      })
    ) {
      // Add elements to the front of an array
      filterUserActions.unshift(userActions[i])
    }
  }
  this.userActions = filterUserActions
}

/ * * *@description Temporary form *@memberof Restore* /
holdForm() {
  this.#filterSameAction()

  const store = {
    actions: this.userActions,  // The event after the reset
    formData: getFormData(this.form),  // Form data
    customDOM: getCustomDOM(this.form, this.customDOMAttr, this.customDOMAttrContent),  // Custom DOM structure
  }

  // Store to localStorage
  localStorage.setItem('form-restore'.JSON.stringify(store))

  return this
}
Copy the code
  1. Form restoration

Finally, the most important part of the form restoration is arrived, and the process looks like this:

  1. Traverses the user’s behavior and triggers
  2. Page rendering
  3. Determine the end of the render (end of the DOM change)
  4. Loop the first step until all user actions are complete
  5. Restore the custom DOM
  6. Assign values to form data
  7. Get the comparison between the current FORM and the original data, and let the user manually adjust the part that indicates the abnormal data

The specific code is as follows

/ * * *@description Restore user operation *@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 the element exists, the element is assigned and the event is raised
      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  // Give the completed resovle to the DOM variable function to control

          // If no return persists, execute resolve after a fixed time to avoid rigor mortis
          setTimeout(() = > {
            resolve && resolve(action)
          }, this.recoverTimeout)
        } else {
          resolve(action)
        }
      } else {
        // If no element is found, resolve
        resolve(action)
      }
    })
  }

  // Restore the custom DOM
  this.#customDOM()

  // Restore custom form data
  if (formData) {
    this.#recoverFormData()
  }

  console.log('[Restore] Form Restore completed ')}Copy the code

At this point, the core logic of the whole form temporary restore is completed. Specific details, there are some small holes, such as the old project will have some alert, confirm and other pop-ups, restore, if triggered, will interrupt the execution of the code, need to reset them during the restore, and then recover.

The source address

This method is only applicable to traditional non-data-driven projects. Interested partners can directly clone the source code of the project directly, address: github.com/huangjiaxin…