一、表单自动保存

用户在填写表单时,经常会遇到意外关闭页面或浏览器崩溃的情况,导致长时间的输入成果丢失。通过实现表单自动保存功能,可以在用户输入时实时保存数据到本地存储,当用户意外离开后重新进入页面时,能够恢复之前填写的内容,大大提升用户体验。这种机制特别适用于注册表单、问卷调查、订单填写等需要用户投入时间的场景。本文将介绍如何使用 HTML、CSS 和 JavaScript 实现表单自动保存功能。

二、效果演示

当用户在表单中进行输入操作时,系统会在用户停止输入 1 秒后自动将当前填写的数据保存到浏览器的本地存储中。页面右上角会显示一个"已自动保存"的提示信息,提醒用户数据已保存。当用户刷新页面或重新访问该页面时,之前填写的数据会自动恢复到相应的表单控件中。即使用户意外关闭浏览器后再次打开,数据依然存在。用户可以通过提交按钮提交表单,或使用清空按钮清除表单内容和本地存储。
在这里插入图片描述

三、系统分析

1.页面结构

页面主要包括以下几个区域:

1.1 表单主体区域

包含各种类型的表单控件,如文本输入框、日期选择器、单选按钮组、复选框组、下拉选择器和文本域。

<div class="form-group">
  <label for="name">姓名</label>
  <input type="text" id="name" name="name" placeholder="请输入您的姓名">
</div>
<div class="form-group">
  <label for="birthdate">出生日期</label>
  <input type="date" id="birthdate" name="birthdate">
</div>
<!-- 更多表单元素... -->

1.2 操作按钮区域

包含提交表单和清空表单的按钮。

<div class="buttons">
  <button type="submit" class="btn-primary">提交表单</button>
  <button type="button" class="btn-secondary" onclick="clearForm()">清空表单</button>
</div>

1.3 提示信息区域

位于页面右上角,用于显示自动保存状态的提示信息。

<div id="saveIndicator" class="save-indicator">已自动保存</div>

2、核心功能实现

2.1 自动保存类初始化

创建 AutoSaveForm 类,用于管理表单的自动保存功能。在构造函数中初始化必要的 DOM 元素引用,并在 init 方法中设置事件监听器。主要监听 input 和 change 事件来检测用户输入,同时监听页面可见性变化以在用户切换标签页时保存数据。

class AutoSaveForm {
  constructor(formId, storageKey = 'autoSaveForm') {
    this.form = document.getElementById(formId);
    this.storageKey = storageKey;
    this.saveIndicator = document.getElementById('saveIndicator');
    this.status = document.getElementById('status');
    this.saveTimeout = null;
    this.init();
  }

  init() {
    this.loadSavedData();
    this.form.addEventListener('input', (e) => this.handleInput(e));
    this.form.addEventListener('change', (e) => this.handleInput(e));
    this.form.addEventListener('submit', (e) => this.handleSubmit(e));
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') this.saveData();
    });
  }
}

2.2 输入事件处理与延迟保存

当用户输入时,需要避免频繁保存造成性能问题。通过 handleInput 方法实现防抖机制,每次输入后设置 1 秒的延迟,如果在延迟期间又有新的输入,则清除之前的定时器并重新设置,确保只在用户停止输入后才执行保存操作。

handleInput(e) {
  if (this.saveTimeout) clearTimeout(this.saveTimeout);
  this.saveTimeout = setTimeout(() => this.saveData(), 1000);
}

2.3 数据收集与本地存储

saveData 方法负责收集表单中的所有数据并保存到 localStorage。对于复选框组,需要特殊处理以正确收集所有选中的值。同时处理未选中的复选框,确保它们的值也被正确记录。

saveData() {
  const data = {};
  const formData = new FormData(this.form);
  for (let [key, value] of formData.entries()) {
    const elements = this.form.querySelectorAll(`input[name="${key}"][type="checkbox"]`);
    if (elements.length > 1) {
      if (data[key]) {
        if (!Array.isArray(data[key])) data[key] = [data[key]];
        data[key].push(value);
      } else {
        data[key] = [value];
      }
    } else {
      data[key] = value;
    }
  }
  this.form.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
    if (!formData.has(checkbox.name)) {
      const groupCheckboxes = this.form.querySelectorAll(`input[type="checkbox"][name="${checkbox.name}"]`);
      if (groupCheckboxes.length > 1) {
        if (!data[checkbox.name]) data[checkbox.name] = [];
      } else {
        data[checkbox.name] = false;
      }
    }
  });
  localStorage.setItem(this.storageKey, JSON.stringify(data));
  this.showSaveIndicator();
}

2.4 数据恢复与状态显示

loadSavedData 方法从本地存储中读取之前保存的数据,并将其恢复到相应的表单控件中。针对不同类型的表单元素(文本框、单选框、复选框)使用不同的恢复逻辑。showSaveIndicator 方法控制保存状态提示信息的显示和隐藏。

loadSavedData() {
  const savedData = localStorage.getItem(this.storageKey);
  if (savedData) {
    const data = JSON.parse(savedData);
    Object.keys(data).forEach(key => {
      const elements = this.form.elements[key];
      if (elements) {
        if (elements.type === 'checkbox' || elements[0]?.type === 'checkbox') {
          this.setCheckboxValues(elements, data[key]);
        } else if (elements.type === 'radio' || elements[0]?.type === 'radio') {
          this.setRadioValues(elements, data[key]);
        } else if (Array.isArray(data[key]) && elements.length) {
          Array.from(elements).forEach(el => el.checked = data[key].includes(el.value));
        } else {
          elements.value = data[key];
        }
      }
    });
  }
}

四、完整代码

git地址:https://gitee.com/ironpro/hjdemo/blob/master/form-autosave/index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>表单自动保存</title>
  <style>
      * {
          margin: 0;
          padding: 0;
          box-sizing: border-box;
      }
      body {
          background: #f5f5f5;
          padding: 20px;
          min-height: 100vh;
          color: #333;
      }
      .container {
          background: white;
          padding: 20px;
          width: 800px;
          margin: 0 auto;
          box-shadow: 0 2px 10px rgba(0,0,0,0.05);
      }
      h1 {
          color: #2c3e50;
          font-size: 22px;
          margin-bottom: 25px;
          text-align: center;
          border-bottom: 1px solid #eee;
          padding-bottom: 15px;
      }
      .form-group {
          margin-bottom: 20px;
          display: flex;
          flex-direction: row;
          align-items: flex-start;
      }
      .form-group.checkbox-single {
          align-items: center;
      }
      label {
          display: inline-block;
          margin-bottom: 0;
          color: #374151;
          font-size: 14px;
          font-weight: 500;
          width: 120px;
          flex-shrink: 0;
      }
      .form-group.checkbox-single label {
          width: auto;
          margin-left: 8px;
      }
      input[type="text"], input[type="email"], input[type="number"], input[type="date"], textarea, select {
          width: calc(100% - 130px);
          padding: 10px 12px;
          border: 1px solid #d1d5db;
          font-size: 14px;
          transition: border-color 0.2s;
          background: white;
      }
      .form-group.checkbox-single input[type="checkbox"] {
          width: auto;
      }
      input[type="text"]:focus, input[type="email"]:focus, input[type="number"]:focus, input[type="date"]:focus, textarea:focus, select:focus {
          outline: none;
          border-color: #3b82f6;
          box-shadow: 0 0 0 2px rgba(59, 130, 246, .1);
      }
      textarea {
          resize: vertical;
          min-height: 100px;
      }
      .radio-group, .checkbox-group, .checkbox-grid {
          display: flex;
          flex-wrap: wrap;
          gap: 16px;
          width: calc(100% - 130px);
      }
      .radio-item, .checkbox-item {
          display: flex;
          align-items: center;
          gap: 8px;
          cursor: pointer;
          padding: 6px 0;
          flex-shrink: 0;
      }
      input[type="radio"], input[type="checkbox"] {
          width: 16px;
          height: 16px;
          cursor: pointer;
          accent-color: #3b82f6;
      }
      .save-indicator {
          position: fixed;
          top: 16px;
          right: 16px;
          background: #10b981;
          color: white;
          padding: 8px 16px;
          border-radius: 4px;
          font-size: 12px;
          opacity: 0;
          transform: translateY(-10px);
          transition: all 0.2s ease;
          pointer-events: none;
          box-shadow: 0 2px 4px rgba(0, 0, 0, .1);
      }
      .save-indicator.show {
          opacity: 1;
          transform: translateY(0);
      }
      .buttons {
          display: flex;
          gap: 12px;
          margin-top: 24px;
      }
      button {
          padding: 10px 20px;
          border: 1px solid transparent;
          font-size: 14px;
          cursor: pointer;
          transition: all 0.2s;
          font-weight: 500;
      }
      .btn-primary {
          background: #3b82f6;
          color: white;
      }
      .btn-primary:hover {
          background: #2563eb;
      }
      .btn-secondary {
          background: white;
          color: #374151;
          border: 1px solid #d1d5db;
      }
      .btn-secondary:hover {
          background: #f3f4f6;
      }
  </style>
</head>
<body>
<div class="container">
  <h1>表单自动保存</h1>
  <form id="autoSaveForm">
    <div class="form-group">
      <label for="name">姓名</label>
      <input type="text" id="name" name="name" placeholder="请输入您的姓名">
    </div>
    <div class="form-group">
      <label for="birthdate">出生日期</label>
      <input type="date" id="birthdate" name="birthdate">
    </div>
    <div class="form-group">
      <label>性别</label>
      <div class="radio-group">
        <div class="radio-item">
          <input type="radio" id="male" name="gender" value="male">
          <label for="male"></label>
        </div>
        <div class="radio-item">
          <input type="radio" id="female" name="gender" value="female">
          <label for="female"></label>
        </div>
      </div>
    </div>
    <div class="form-group">
      <label>兴趣爱好</label>
      <div class="checkbox-grid">
        <div class="checkbox-item">
          <input type="checkbox" id="reading" name="hobbies" value="reading">
          <label for="reading">阅读</label>
        </div>
        <div class="checkbox-item">
          <input type="checkbox" id="sports" name="hobbies" value="sports">
          <label for="sports">运动</label>
        </div>
        <div class="checkbox-item">
          <input type="checkbox" id="music" name="hobbies" value="music">
          <label for="music">音乐</label>
        </div>
        <div class="checkbox-item">
          <input type="checkbox" id="travel" name="hobbies" value="travel">
          <label for="travel">旅行</label>
        </div>
        <div class="checkbox-item">
          <input type="checkbox" id="cooking" name="hobbies" value="cooking">
          <label for="cooking">烹饪</label>
        </div>
      </div>
    </div>
    <div class="form-group">
      <label>技能水平</label>
      <div class="radio-group">
        <div class="radio-item">
          <input type="radio" id="beginner" name="skill" value="beginner">
          <label for="beginner">初学者</label>
        </div>
        <div class="radio-item">
          <input type="radio" id="intermediate" name="skill" value="intermediate">
          <label for="intermediate">中级</label>
        </div>
        <div class="radio-item">
          <input type="radio" id="advanced" name="skill" value="advanced">
          <label for="advanced">高级</label>
        </div>
      </div>
    </div>
    <div class="form-group">
      <label for="city">城市</label>
      <select id="city" name="city">
        <option value="">请选择城市</option>
        <option value="beijing">北京</option>
        <option value="shanghai">上海</option>
        <option value="guangzhou">广州</option>
        <option value="shenzhen">深圳</option>
        <option value="hangzhou">杭州</option>
        <option value="chengdu">成都</option>
      </select>
    </div>
    <div class="form-group">
      <label>通知设置</label>
      <div class="checkbox-group">
        <div class="checkbox-item">
          <input type="checkbox" id="emailNotify" name="notifications" value="email">
          <label for="emailNotify">邮件通知</label>
        </div>
        <div class="checkbox-item">
          <input type="checkbox" id="smsNotify" name="notifications" value="sms">
          <label for="smsNotify">短信通知</label>
        </div>
        <div class="checkbox-item">
          <input type="checkbox" id="pushNotify" name="notifications" value="push">
          <label for="pushNotify">推送通知</label>
        </div>
      </div>
    </div>
    <div class="form-group">
      <label for="message">留言</label>
      <textarea id="message" name="message" placeholder="请输入您的留言"></textarea>
    </div>
    <div class="form-group checkbox-single">
      <input type="checkbox" id="agree" name="agree">
      <label for="agree">我已阅读并同意服务条款</label>
    </div>
    <div class="buttons">
      <button type="submit" class="btn-primary">提交表单</button>
      <button type="button" class="btn-secondary" onclick="clearForm()">清空表单</button>
    </div>
  </form>
</div>

<div id="saveIndicator" class="save-indicator">已自动保存</div>

<script>
  class AutoSaveForm {
    constructor(formId, storageKey = 'autoSaveForm') {
      this.form = document.getElementById(formId);
      this.storageKey = storageKey;
      this.saveIndicator = document.getElementById('saveIndicator');
      this.status = document.getElementById('status');
      this.saveTimeout = null;
      this.init();
    }

    init() {
      this.loadSavedData();
      this.form.addEventListener('input', (e) => this.handleInput(e));
      this.form.addEventListener('change', (e) => this.handleInput(e));
      this.form.addEventListener('submit', (e) => this.handleSubmit(e));
      document.addEventListener('visibilitychange', () => {
        if (document.visibilityState === 'hidden') this.saveData();
      });
    }

    handleInput(e) {
      if (this.saveTimeout) clearTimeout(this.saveTimeout);
      this.saveTimeout = setTimeout(() => this.saveData(), 1000);
    }

    saveData() {
      const data = {};
      const formData = new FormData(this.form);
      for (let [key, value] of formData.entries()) {
        const elements = this.form.querySelectorAll(`input[name="${key}"][type="checkbox"]`);
        if (elements.length > 1) {
          if (data[key]) {
            if (!Array.isArray(data[key])) data[key] = [data[key]];
            data[key].push(value);
          } else {
            data[key] = [value];
          }
        } else {
          data[key] = value;
        }
      }
      this.form.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
        if (!formData.has(checkbox.name)) {
          const groupCheckboxes = this.form.querySelectorAll(`input[type="checkbox"][name="${checkbox.name}"]`);
          if (groupCheckboxes.length > 1) {
            if (!data[checkbox.name]) data[checkbox.name] = [];
          } else {
            data[checkbox.name] = false;
          }
        }
      });
      localStorage.setItem(this.storageKey, JSON.stringify(data));
      this.showSaveIndicator();
    }

    loadSavedData() {
      const savedData = localStorage.getItem(this.storageKey);
      if (savedData) {
        const data = JSON.parse(savedData);
        Object.keys(data).forEach(key => {
          const elements = this.form.elements[key];
          if (elements) {
            if (elements.type === 'checkbox' || elements[0]?.type === 'checkbox') {
              this.setCheckboxValues(elements, data[key]);
            } else if (elements.type === 'radio' || elements[0]?.type === 'radio') {
              this.setRadioValues(elements, data[key]);
            } else if (Array.isArray(data[key]) && elements.length) {
              Array.from(elements).forEach(el => el.checked = data[key].includes(el.value));
            } else {
              elements.value = data[key];
            }
          }
        });
      }
    }

    setCheckboxValues(elements, savedValue) {
      if (elements.length) {
        Array.from(elements).forEach(checkbox => {
          if (Array.isArray(savedValue)) checkbox.checked = savedValue.includes(checkbox.value);
          else checkbox.checked = savedValue === true;
        });
      } else {
        elements.checked = savedValue === true || elements.value === savedValue;
      }
    }

    setRadioValues(elements, savedValue) {
      if (elements.length) Array.from(elements).forEach(radio => radio.checked = radio.value === savedValue);
      else elements.checked = elements.value === savedValue;
    }

    showSaveIndicator() {
      this.saveIndicator.classList.add('show');
      setTimeout(() => this.saveIndicator.classList.remove('show'), 2000);
    }

    handleSubmit(e) {
      e.preventDefault();
      this.saveData();
      setTimeout(() => {
        if (confirm('是否清空已保存的数据?')) {
          this.clearSavedData();
          this.form.reset();
        }
      }, 1000);
    }

    clearSavedData() {
      localStorage.removeItem(this.storageKey);
    }
  }

  const autoSaveForm = new AutoSaveForm('autoSaveForm');

  function clearForm() {
    if (confirm('确定要清空表单吗?')) {
      autoSaveForm.clearSavedData();
      document.getElementById('autoSaveForm').reset();
    }
  }
</script>
</body>
</html>

更多推荐