核心理念

从基础功能出发,逐层增强体验 - 构建一个在任何环境下都能工作的表单,然后为现代浏览器用户提供更流畅的交互体验。

第一层:核心服务(纯 HTML)

基础表单结构

<!-- 完全自洽的基础表单 - 无JS依赖 -->
<form id="searchForm" action="/search" method="GET" class="basic-form">
  <div class="form-group">
    <label for="search">搜索产品</label>
    <input 
      type="text" 
      id="search" 
      name="q" 
      required
      minlength="2"
      placeholder="请输入至少2个字符..."
    >
    <div class="error-message" id="lengthError" style="display: none;">
      搜索词至少需要2个字符
    </div>
  </div>
  
  <div class="form-group">
    <label for="category">产品分类</label>
    <select id="category" name="category">
      <option value="">全部</option>
      <option value="electronics">电子产品</option>
      <option value="clothing">服装</option>
      <option value="books">图书</option>
    </select>
  </div>
  
  <div class="form-actions">
    <button type="submit">搜索</button>
    <button type="reset">重置</button>
  </div>
</form>

核心服务特性

  • 原生表单验证:使用 HTML 5 requiredminlength 属性
  • 语义化结构:正确的标签关联和表单分组
  • 无 JS 提交:依赖浏览器默认行为,向 /search?q=...&category=... 发起 GET 请求
  • 普适兼容:在所有能解析 HTML 的客户端中工作
  • 优雅重置:原生 type="reset" 按钮

第二层:增强服务(JavaScript)

渐进式 JavaScript 架构

// 增强脚本 - 安全地添加交互功能
class SearchFormEnhancer {
  constructor(formId) {
    this.form = document.getElementById(formId);
    this.isEnhanced = false;
    
    this.init();
  }
  
  init() {
    // 能力检测:确保浏览器支持必要的API
    if (!this.form || !window.fetch || !window.Promise) {
      return; // 静默降级到基础表单
    }
    
    this.enhanceForm();
    this.isEnhanced = true;
  }
  
  enhanceForm() {
    // 1. 接管表单提交
    this.form.addEventListener('submit', this.handleSubmit.bind(this));
    
    // 2. 实时输入验证
    this.setupLiveValidation();
    
    // 3. 自动完成增强
    this.setupAutocomplete();
    
    // 4. 搜索历史
    this.setupSearchHistory();
    
    // 添加增强状态标识
    this.form.classList.add('enhanced');
  }
  
  async handleSubmit(event) {
    event.preventDefault();
    
    const formData = new FormData(this.form);
    const searchData = this.serializeForm(formData);
    
    // 客户端验证
    if (!this.validateForm(searchData)) {
      return;
    }
    
    try {
      // 显示加载状态
      this.setLoadingState(true);
      
      // 异步搜索
      const results = await this.performSearch(searchData);
      
      // 动态更新结果
      this.renderResults(results);
      
      // 更新搜索历史
      this.updateSearchHistory(searchData.q);
      
    } catch (error) {
      console.error('搜索失败:', error);
      this.handleSearchError(error);
    } finally {
      this.setLoadingState(false);
    }
  }
  
  setupLiveValidation() {
    const searchInput = this.form.querySelector('#search');
    
    searchInput.addEventListener('input', (event) => {
      const value = event.target.value;
      const errorElement = this.form.querySelector('#lengthError');
      
      if (value.length > 0 && value.length < 2) {
        this.showError(errorElement, '搜索词至少需要2个字符');
        event.target.setCustomValidity('搜索词太短');
      } else {
        this.hideError(errorElement);
        event.target.setCustomValidity('');
      }
    });
    
    // 失去焦点时进行最终验证
    searchInput.addEventListener('blur', (event) => {
      if (event.target.value.length === 1) {
        this.showError(
          this.form.querySelector('#lengthError'),
          '搜索词至少需要2个字符'
        );
      }
    });
  }
  
  setupAutocomplete() {
    const searchInput = this.form.querySelector('#search');
    let autocompleteContainer = this.form.querySelector('.autocomplete-suggestions');
    
    if (!autocompleteContainer) {
      autocompleteContainer = document.createElement('div');
      autocompleteContainer.className = 'autocomplete-suggestions';
      searchInput.parentNode.appendChild(autocompleteContainer);
    }
    
    let debounceTimer;
    searchInput.addEventListener('input', (event) => {
      clearTimeout(debounceTimer);
      debounceTimer = setTimeout(() => {
        this.fetchSuggestions(event.target.value);
      }, 300);
    });
    
    // 点击外部关闭建议
    document.addEventListener('click', (event) => {
      if (!this.form.contains(event.target)) {
        this.hideAutocomplete();
      }
    });
  }
  
  async fetchSuggestions(query) {
    if (query.length < 2) {
      this.hideAutocomplete();
      return;
    }
    
    try {
      const response = await fetch(`/api/suggestions?q=${encodeURIComponent(query)}`);
      const suggestions = await response.json();
      this.showAutocomplete(suggestions);
    } catch (error) {
      // 静默失败,不影响核心功能
      console.warn('获取搜索建议失败:', error);
    }
  }
  
  setupSearchHistory() {
    this.searchHistory = this.loadSearchHistory();
    this.renderSearchHistory();
  }
  
  loadSearchHistory() {
    try {
      return JSON.parse(localStorage.getItem('searchHistory') || '[]');
    } catch {
      return []; // 本地存储不可用时静默降级
    }
  }
  
  updateSearchHistory(query) {
    if (!query.trim()) return;
    
    // 去重并限制数量
    this.searchHistory = [
      query,
      ...this.searchHistory.filter(item => item !== query)
    ].slice(0, 10);
    
    try {
      localStorage.setItem('searchHistory', JSON.stringify(this.searchHistory));
    } catch {
      // 静默处理存储错误
    }
    
    this.renderSearchHistory();
  }
  
  validateForm(data) {
    if (data.q.length < 2) {
      this.showError(
        this.form.querySelector('#lengthError'),
        '搜索词至少需要2个字符'
      );
      this.form.querySelector('#search').focus();
      return false;
    }
    
    return true;
  }
  
  async performSearch(data) {
    const queryString = new URLSearchParams(data).toString();
    const response = await fetch(`/api/search?${queryString}`);
    
    if (!response.ok) {
      throw new Error(`搜索失败: ${response.status}`);
    }
    
    return response.json();
  }
  
  renderResults(results) {
    let resultsContainer = document.getElementById('searchResults');
    
    if (!resultsContainer) {
      resultsContainer = document.createElement('div');
      resultsContainer.id = 'searchResults';
      this.form.after(resultsContainer);
    }
    
    resultsContainer.innerHTML = `
      <div class="search-results-header">
        <h3>找到 ${results.total} 个结果</h3>
        <button onclick="this.closest('#searchResults').remove()">关闭</button>
      </div>
      <div class="results-list">
        ${results.items.map(item => `
          <div class="result-item">
            <h4><a href="${item.url}">${item.title}</a></h4>
            <p>${item.description}</p>
            <span class="price">${item.price}</span>
          </div>
        `).join('')}
      </div>
    `;
  }
  
  handleSearchError(error) {
    // 降级策略:回退到原生表单提交
    const fallback = confirm('搜索遇到问题,是否使用基础搜索?');
    if (fallback) {
      this.form.submit(); // 触发原生表单提交
    }
  }
  
  setLoadingState(loading) {
    const submitButton = this.form.querySelector('button[type="submit"]');
    
    if (loading) {
      submitButton.disabled = true;
      submitButton.innerHTML = '<span class="spinner"></span> 搜索中...';
      this.form.classList.add('loading');
    } else {
      submitButton.disabled = false;
      submitButton.textContent = '搜索';
      this.form.classList.remove('loading');
    }
  }
  
  // 工具方法
  showError(element, message) {
    element.textContent = message;
    element.style.display = 'block';
    element.setAttribute('role', 'alert');
  }
  
  hideError(element) {
    element.style.display = 'none';
    element.removeAttribute('role');
  }
  
  serializeForm(formData) {
    const data = {};
    for (let [key, value] of formData) {
      data[key] = value;
    }
    return data;
  }
}
 
// 安全初始化
document.addEventListener('DOMContentLoaded', () => {
  new SearchFormEnhancer('searchForm');
});

渐进式 CSS 增强

/* 基础样式 - 所有浏览器 */
.basic-form {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}
 
.form-group {
  margin-bottom: 1rem;
}
 
.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: bold;
}
 
.form-group input,
.form-group select {
  width: 100%;
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}
 
.error-message {
  color: #d32f2f;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}
 
/* 增强样式 - 现代浏览器 */
.enhanced .form-group {
  position: relative;
}
 
.enhanced input:focus,
.enhanced select:focus {
  outline: none;
  border-color: #2196f3;
  box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
 
/* 自动完成建议 */
.autocomplete-suggestions {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  background: white;
  border: 1px solid #ccc;
  border-top: none;
  border-radius: 0 0 4px 4px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  z-index: 1000;
  max-height: 200px;
  overflow-y: auto;
}
 
.autocomplete-suggestion {
  padding: 0.5rem;
  cursor: pointer;
  border-bottom: 1px solid #eee;
}
 
.autocomplete-suggestion:hover,
.autocomplete-suggestion:focus {
  background: #f5f5f5;
}
 
/* 加载状态 */
.loading .form-actions button[type="submit"] {
  position: relative;
  color: transparent;
}
 
.spinner {
  display: inline-block;
  width: 16px;
  height: 16px;
  border: 2px solid #ffffff;
  border-radius: 50%;
  border-top-color: transparent;
  animation: spin 1s ease-in-out infinite;
}
 
@keyframes spin {
  to { transform: rotate(360deg); }
}
 
/* 搜索历史 */
.search-history {
  margin-top: 1rem;
  padding: 1rem;
  background: #f9f9f9;
  border-radius: 4px;
}
 
.search-history-item {
  display: inline-block;
  margin: 0.25rem;
  padding: 0.25rem 0.5rem;
  background: #e3f2fd;
  border-radius: 16px;
  cursor: pointer;
}
 
/* 渐进式特性检测 */
@supports (display: grid) {
  .enhanced .form-actions {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 1rem;
  }
}

用户体验对比

无 JavaScript 环境

功能体验
表单提交整页跳转,传统 Web 体验
输入验证提交时浏览器原生提示
自动完成
搜索历史
错误处理浏览器默认错误页面

有 JavaScript 环境

功能体验
表单提交无刷新异步提交,SPA 体验
输入验证实时验证,即时反馈
自动完成输入时智能提示
搜索历史本地存储,快速访问
错误处理优雅的错误提示和降级

关键设计原则

  1. 功能分层

    • 核心层:纯 HTML 表单,确保基本功能
    • 增强层:JavaScript 添加交互和优化
  2. 能力检测

    • 检测 Fetch API、Promise 等现代特性
    • 不支持时静默降级
  3. 优雅降级

    • 网络错误时回退到原生提交
    • 本地存储不可用时静默处理
  4. 渐进加载

    • 核心功能立即可用
    • 增强功能按需加载
  5. 可访问性

    • 语义化 HTML 结构
    • 适当的 ARIA 属性
    • 键盘导航支持

这种渐进增强的表单设计确保了:

  • 鲁棒性:在任何环境下都能工作
  • 可访问性:所有用户都能完成核心任务
  • 现代体验:支持现代浏览器的用户获得最佳体验
  • 可维护性:清晰的关注点分离