系列文章: 前端工程師的 Modern Web 實踐之道 - Day 14
預計閱讀時間: 12 分鐘
難度等級: ⭐⭐⭐☆☆
在前一篇文章中,我們探討了使用者體驗最佳化的策略,從載入效能到互動設計的完整實踐。今天我們將深入探討表單處理的現代化方案,這個看似基礎但實則極具挑戰性的技術領域,將幫助你打造出真正以使用者為中心的智能表單系統。
還記得你上一次處理複雜表單時的痛苦經驗嗎?可能是這樣的場景:
表單是 Web 應用中使用者互動最頻繁的元件之一,卻也是最容易被輕視的部分。一個設計良好的現代化表單系統,不僅能大幅提升使用者體驗,更能減少 70% 以上的表單相關 bug,降低維護成本。
根據我在實際專案中的統計,表單相關的問題佔前端 bug 總量的約 40%,而採用現代化表單處理方案後,這個比例可以降到 10% 以下。這就是我們今天要深入探討的主題價值所在。
讓我們先來看一個典型的傳統表單處理方式:
// 傳統的表單處理方式 - 充滿問題
function handleFormSubmit(event) {
  event.preventDefault();
  const email = document.getElementById('email').value;
  const password = document.getElementById('password').value;
  // 驗證邏輯散落各處
  if (!email) {
    document.getElementById('email-error').textContent = '請輸入 Email';
    return;
  }
  if (!email.includes('@')) {
    document.getElementById('email-error').textContent = 'Email 格式錯誤';
    return;
  }
  if (!password || password.length < 8) {
    document.getElementById('password-error').textContent = '密碼至少 8 個字元';
    return;
  }
  // 沒有 loading 狀態,可能重複提交
  fetch('/api/login', {
    method: 'POST',
    body: JSON.stringify({ email, password })
  })
  .then(response => response.json())
  .then(data => {
    // 成功處理
  })
  .catch(error => {
    // 錯誤處理不完善
    alert('登入失敗');
  });
}
這種傳統做法存在多個嚴重問題:
現代化表單處理的核心在於三個關鍵概念:
將驗證規則與 UI 分離,使用 schema 定義表單結構和驗證邏輯:
// 使用 Zod 定義表單 schema
import { z } from 'zod';
const loginSchema = z.object({
  email: z
    .string()
    .min(1, '請輸入 Email')
    .email('Email 格式錯誤'),
  password: z
    .string()
    .min(8, '密碼至少 8 個字元')
    .regex(/[A-Z]/, '密碼必須包含至少一個大寫字母')
    .regex(/[0-9]/, '密碼必須包含至少一個數字'),
  rememberMe: z.boolean().optional()
});
type LoginFormData = z.infer<typeof loginSchema>;
這種方式的優勢:
使用專業的表單庫來自動管理表單狀態:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isValid, touchedFields }
  } = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
    mode: 'onTouched' // 使用者離開欄位時驗證
  });
  const onSubmit = async (data: LoginFormData) => {
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
      });
      if (!response.ok) {
        throw new Error('登入失敗');
      }
      const result = await response.json();
      // 處理成功登入
    } catch (error) {
      // 統一的錯誤處理
      console.error('Login error:', error);
    }
  };
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          {...register('email')}
          aria-invalid={errors.email ? 'true' : 'false'}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {errors.email && (
          <span id="email-error" role="alert">
            {errors.email.message}
          </span>
        )}
      </div>
      <div>
        <label htmlFor="password">密碼</label>
        <input
          id="password"
          type="password"
          {...register('password')}
          aria-invalid={errors.password ? 'true' : 'false'}
          aria-describedby={errors.password ? 'password-error' : undefined}
        />
        {errors.password && (
          <span id="password-error" role="alert">
            {errors.password.message}
          </span>
        )}
      </div>
      <button
        type="submit"
        disabled={isSubmitting || !isValid}
      >
        {isSubmitting ? '登入中...' : '登入'}
      </button>
    </form>
  );
}
這種方式自動處理了:
現代化表單不只是驗證資料,更要提供智能化的使用者體驗:
import { useForm, useWatch } from 'react-hook-form';
import { useDebounce } from './hooks/useDebounce';
function RegistrationForm() {
  const { register, control, formState: { errors } } = useForm();
  // 監聽 email 欄位,實作即時可用性檢查
  const email = useWatch({ control, name: 'email' });
  const debouncedEmail = useDebounce(email, 500);
  const { data: emailAvailable, isLoading } = useEmailAvailability(debouncedEmail);
  return (
    <div>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        {...register('email')}
      />
      {isLoading && <span>檢查中...</span>}
      {!isLoading && debouncedEmail && !emailAvailable && (
        <span role="alert">此 Email 已被註冊</span>
      )}
      {!isLoading && debouncedEmail && emailAvailable && (
        <span style={{ color: 'green' }}>此 Email 可以使用</span>
      )}
    </div>
  );
}
// 自訂 Hook: Email 可用性檢查
function useEmailAvailability(email: string) {
  const [data, setData] = React.useState<boolean | null>(null);
  const [isLoading, setIsLoading] = React.useState(false);
  React.useEffect(() => {
    if (!email || !email.includes('@')) {
      setData(null);
      return;
    }
    setIsLoading(true);
    fetch(`/api/check-email?email=${encodeURIComponent(email)}`)
      .then(res => res.json())
      .then(result => {
        setData(result.available);
        setIsLoading(false);
      })
      .catch(() => {
        setIsLoading(false);
      });
  }, [email]);
  return { data, isLoading };
}
// Debounce Hook
function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = React.useState(value);
  React.useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);
  return debouncedValue;
}
讓我們實作一個包含多步驟、條件欄位、檔案上傳的完整註冊表單:
import { z } from 'zod';
// 基本資訊 Schema
const basicInfoSchema = z.object({
  email: z.string().email('Email 格式錯誤'),
  password: z
    .string()
    .min(8, '密碼至少 8 個字元')
    .regex(/[A-Z]/, '需包含大寫字母')
    .regex(/[a-z]/, '需包含小寫字母')
    .regex(/[0-9]/, '需包含數字'),
  confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
  message: '密碼不一致',
  path: ['confirmPassword']
});
// 個人資訊 Schema
const personalInfoSchema = z.object({
  firstName: z.string().min(1, '請輸入名字'),
  lastName: z.string().min(1, '請輸入姓氏'),
  birthDate: z.string().refine((date) => {
    const age = new Date().getFullYear() - new Date(date).getFullYear();
    return age >= 18;
  }, '必須年滿 18 歲'),
  phone: z.string().regex(/^09\d{8}$/, '請輸入有效的台灣手機號碼')
});
// 地址資訊 Schema
const addressSchema = z.object({
  country: z.string().min(1, '請選擇國家'),
  city: z.string().min(1, '請輸入城市'),
  postalCode: z.string().regex(/^\d{3,5}$/, '郵遞區號格式錯誤'),
  address: z.string().min(5, '請輸入完整地址')
});
// 檔案上傳 Schema
const fileSchema = z.object({
  avatar: z
    .custom<FileList>()
    .refine((files) => files?.length === 1, '請選擇頭像')
    .refine(
      (files) => files?.[0]?.size <= 5 * 1024 * 1024,
      '檔案大小不能超過 5MB'
    )
    .refine(
      (files) => ['image/jpeg', 'image/png', 'image/webp'].includes(files?.[0]?.type),
      '只支援 JPG、PNG、WebP 格式'
    )
});
// 完整註冊表單 Schema
const registrationSchema = basicInfoSchema
  .merge(personalInfoSchema)
  .merge(addressSchema)
  .merge(fileSchema);
type RegistrationFormData = z.infer<typeof registrationSchema>;
import React from 'react';
import { useForm, FormProvider, useFormContext } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
// 步驟導航組件
function StepIndicator({ currentStep, totalSteps }: { currentStep: number; totalSteps: number }) {
  return (
    <div className="step-indicator">
      {Array.from({ length: totalSteps }, (_, i) => (
        <div
          key={i}
          className={`step ${i < currentStep ? 'completed' : ''} ${i === currentStep ? 'active' : ''}`}
        >
          <div className="step-number">{i + 1}</div>
          <div className="step-label">
            {['基本資訊', '個人資料', '地址資訊', '上傳頭像'][i]}
          </div>
        </div>
      ))}
    </div>
  );
}
// 表單欄位組件 - 可重用的輸入元件
function FormField({
  name,
  label,
  type = 'text',
  placeholder,
  ...props
}: {
  name: string;
  label: string;
  type?: string;
  placeholder?: string;
}) {
  const {
    register,
    formState: { errors }
  } = useFormContext();
  const error = errors[name];
  return (
    <div className="form-field">
      <label htmlFor={name}>
        {label}
        <span className="required">*</span>
      </label>
      <input
        id={name}
        type={type}
        placeholder={placeholder}
        {...register(name)}
        aria-invalid={error ? 'true' : 'false'}
        aria-describedby={error ? `${name}-error` : undefined}
        className={error ? 'error' : ''}
      />
      {error && (
        <span id={`${name}-error`} className="error-message" role="alert">
          {error.message as string}
        </span>
      )}
    </div>
  );
}
// 主表單組件
function MultiStepRegistrationForm() {
  const [currentStep, setCurrentStep] = React.useState(0);
  const methods = useForm<RegistrationFormData>({
    resolver: zodResolver(registrationSchema),
    mode: 'onTouched'
  });
  const {
    handleSubmit,
    trigger,
    formState: { isSubmitting }
  } = methods;
  // 每個步驟需要驗證的欄位
  const stepFields = [
    ['email', 'password', 'confirmPassword'],
    ['firstName', 'lastName', 'birthDate', 'phone'],
    ['country', 'city', 'postalCode', 'address'],
    ['avatar']
  ];
  const nextStep = async () => {
    // 驗證當前步驟的欄位
    const isValid = await trigger(stepFields[currentStep] as any);
    if (isValid && currentStep < 3) {
      setCurrentStep(currentStep + 1);
    }
  };
  const prevStep = () => {
    if (currentStep > 0) {
      setCurrentStep(currentStep - 1);
    }
  };
  const onSubmit = async (data: RegistrationFormData) => {
    try {
      // 處理檔案上傳
      const formData = new FormData();
      // 添加基本資料
      Object.entries(data).forEach(([key, value]) => {
        if (key !== 'avatar') {
          formData.append(key, value as string);
        }
      });
      // 添加檔案
      if (data.avatar?.[0]) {
        formData.append('avatar', data.avatar[0]);
      }
      const response = await fetch('/api/register', {
        method: 'POST',
        body: formData
      });
      if (!response.ok) {
        throw new Error('註冊失敗');
      }
      const result = await response.json();
      console.log('註冊成功:', result);
      // 導向成功頁面或顯示成功訊息
    } catch (error) {
      console.error('Registration error:', error);
      // 顯示錯誤訊息
    }
  };
  return (
    <FormProvider {...methods}>
      <div className="registration-form">
        <StepIndicator currentStep={currentStep} totalSteps={4} />
        <form onSubmit={handleSubmit(onSubmit)}>
          {/* Step 1: 基本資訊 */}
          {currentStep === 0 && (
            <div className="form-step">
              <h2>基本資訊</h2>
              <FormField
                name="email"
                label="Email"
                type="email"
                placeholder="example@email.com"
              />
              <FormField
                name="password"
                label="密碼"
                type="password"
                placeholder="至少 8 個字元"
              />
              <FormField
                name="confirmPassword"
                label="確認密碼"
                type="password"
                placeholder="再次輸入密碼"
              />
            </div>
          )}
          {/* Step 2: 個人資料 */}
          {currentStep === 1 && (
            <div className="form-step">
              <h2>個人資料</h2>
              <div className="form-row">
                <FormField name="lastName" label="姓氏" />
                <FormField name="firstName" label="名字" />
              </div>
              <FormField
                name="birthDate"
                label="生日"
                type="date"
              />
              <FormField
                name="phone"
                label="手機號碼"
                type="tel"
                placeholder="0912345678"
              />
            </div>
          )}
          {/* Step 3: 地址資訊 */}
          {currentStep === 2 && (
            <div className="form-step">
              <h2>地址資訊</h2>
              <FormField name="country" label="國家" />
              <div className="form-row">
                <FormField name="city" label="城市" />
                <FormField name="postalCode" label="郵遞區號" />
              </div>
              <FormField name="address" label="詳細地址" />
            </div>
          )}
          {/* Step 4: 上傳頭像 */}
          {currentStep === 3 && (
            <div className="form-step">
              <h2>上傳頭像</h2>
              <AvatarUpload />
            </div>
          )}
          {/* 導航按鈕 */}
          <div className="form-navigation">
            {currentStep > 0 && (
              <button type="button" onClick={prevStep}>
                上一步
              </button>
            )}
            {currentStep < 3 ? (
              <button type="button" onClick={nextStep}>
                下一步
              </button>
            ) : (
              <button type="submit" disabled={isSubmitting}>
                {isSubmitting ? '註冊中...' : '完成註冊'}
              </button>
            )}
          </div>
        </form>
      </div>
    </FormProvider>
  );
}
// 頭像上傳組件
function AvatarUpload() {
  const { register, watch, formState: { errors } } = useFormContext();
  const avatarFiles = watch('avatar');
  const [preview, setPreview] = React.useState<string | null>(null);
  React.useEffect(() => {
    if (avatarFiles?.[0]) {
      const file = avatarFiles[0];
      const reader = new FileReader();
      reader.onloadend = () => {
        setPreview(reader.result as string);
      };
      reader.readAsDataURL(file);
    } else {
      setPreview(null);
    }
  }, [avatarFiles]);
  return (
    <div className="avatar-upload">
      <div className="preview-container">
        {preview ? (
          <img src={preview} alt="頭像預覽" className="avatar-preview" />
        ) : (
          <div className="avatar-placeholder">
            <span>選擇圖片</span>
          </div>
        )}
      </div>
      <label htmlFor="avatar" className="upload-button">
        選擇檔案
      </label>
      <input
        id="avatar"
        type="file"
        accept="image/jpeg,image/png,image/webp"
        {...register('avatar')}
        style={{ display: 'none' }}
      />
      {errors.avatar && (
        <span className="error-message" role="alert">
          {errors.avatar.message as string}
        </span>
      )}
      <p className="help-text">
        支援 JPG、PNG、WebP 格式,檔案大小不超過 5MB
      </p>
    </div>
  );
}
export default MultiStepRegistrationForm;
在實際應用中,我們常需要根據使用者的選擇動態調整表單欄位:
import { z } from 'zod';
import { useForm, useWatch } from 'react-hook-form';
// 條件 Schema - 根據帳戶類型調整驗證規則
const createAccountSchema = (accountType: 'personal' | 'business') => {
  const baseSchema = z.object({
    accountType: z.enum(['personal', 'business']),
    email: z.string().email('Email 格式錯誤'),
    name: z.string().min(1, '請輸入姓名')
  });
  if (accountType === 'business') {
    return baseSchema.extend({
      companyName: z.string().min(1, '請輸入公司名稱'),
      taxId: z.string().regex(/^\d{8}$/, '統一編號格式錯誤'),
      companyAddress: z.string().min(5, '請輸入公司地址')
    });
  }
  return baseSchema;
};
function DynamicAccountForm() {
  const { register, control, handleSubmit, formState: { errors } } = useForm({
    defaultValues: {
      accountType: 'personal' as const
    }
  });
  // 監聽帳戶類型變化
  const accountType = useWatch({ control, name: 'accountType' });
  const onSubmit = (data: any) => {
    // 動態驗證
    const schema = createAccountSchema(data.accountType);
    const result = schema.safeParse(data);
    if (!result.success) {
      console.error('驗證失敗:', result.error);
      return;
    }
    console.log('驗證成功:', result.data);
    // 提交資料
  };
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>帳戶類型</label>
        <select {...register('accountType')}>
          <option value="personal">個人帳戶</option>
          <option value="business">企業帳戶</option>
        </select>
      </div>
      <div>
        <label>Email</label>
        <input type="email" {...register('email')} />
        {errors.email && <span>{errors.email.message}</span>}
      </div>
      <div>
        <label>姓名</label>
        <input {...register('name')} />
        {errors.name && <span>{errors.name.message}</span>}
      </div>
      {/* 條件顯示企業欄位 */}
      {accountType === 'business' && (
        <>
          <div>
            <label>公司名稱</label>
            <input {...register('companyName')} />
            {errors.companyName && <span>{errors.companyName.message}</span>}
          </div>
          <div>
            <label>統一編號</label>
            <input {...register('taxId')} />
            {errors.taxId && <span>{errors.taxId.message}</span>}
          </div>
          <div>
            <label>公司地址</label>
            <input {...register('companyAddress')} />
            {errors.companyAddress && <span>{errors.companyAddress.message}</span>}
          </div>
        </>
      )}
      <button type="submit">提交</button>
    </form>
  );
}
使用者在填寫長表單時可能需要中斷,我們可以實作自動儲存功能:
import { useForm } from 'react-hook-form';
import { useEffect } from 'react';
import { useDebounce } from './hooks/useDebounce';
function AutoSaveForm() {
  const { register, watch, setValue } = useForm();
  const formData = watch();
  const debouncedFormData = useDebounce(formData, 1000);
  // 載入已儲存的草稿
  useEffect(() => {
    const savedData = localStorage.getItem('form-draft');
    if (savedData) {
      const parsed = JSON.parse(savedData);
      Object.entries(parsed).forEach(([key, value]) => {
        setValue(key, value);
      });
    }
  }, [setValue]);
  // 自動儲存
  useEffect(() => {
    if (Object.keys(debouncedFormData).length > 0) {
      localStorage.setItem('form-draft', JSON.stringify(debouncedFormData));
      console.log('草稿已自動儲存');
    }
  }, [debouncedFormData]);
  const handleSubmit = (data: any) => {
    // 提交成功後清除草稿
    localStorage.removeItem('form-draft');
    console.log('提交資料:', data);
  };
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>標題</label>
        <input {...register('title')} />
      </div>
      <div>
        <label>內容</label>
        <textarea {...register('content')} rows={10} />
      </div>
      <button type="submit">提交</button>
      <p className="auto-save-indicator">
        草稿自動儲存中...
      </p>
    </form>
  );
}
// ✅ 推薦:使用 schema 驗證
const schema = z.object({
  email: z.string().email(),
  age: z.number().min(18).max(120)
});
// ✅ 推薦:提供清晰的錯誤訊息
const passwordSchema = z
  .string()
  .min(8, '密碼至少需要 8 個字元')
  .regex(/[A-Z]/, '密碼必須包含至少一個大寫字母')
  .regex(/[0-9]/, '密碼必須包含至少一個數字');
// ❌ 避免:模糊的錯誤訊息
const badSchema = z.string().min(8, '密碼太短');
// ✅ 推薦:客製化驗證邏輯
const uniqueEmailSchema = z.string().email().refine(
  async (email) => {
    const response = await fetch(`/api/check-email?email=${email}`);
    const { available } = await response.json();
    return available;
  },
  { message: '此 Email 已被使用' }
);
import { useForm, useController } from 'react-hook-form';
import React from 'react';
// ✅ 推薦:使用 useController 減少重渲染
function OptimizedInput({ name, control }: { name: string; control: any }) {
  const {
    field,
    fieldState: { error }
  } = useController({
    name,
    control
  });
  // 只有這個欄位變化時才重渲染
  return (
    <div>
      <input {...field} />
      {error && <span>{error.message}</span>}
    </div>
  );
}
// ✅ 推薦:大表單拆分成多個組件
const BasicInfoSection = React.memo(({ control }: { control: any }) => {
  return (
    <div>
      <OptimizedInput name="firstName" control={control} />
      <OptimizedInput name="lastName" control={control} />
    </div>
  );
});
// ✅ 推薦:延遲驗證減少 API 呼叫
function EmailField() {
  const { register } = useForm({
    mode: 'onBlur' // 離開欄位時才驗證,而非每次輸入
  });
  return <input {...register('email')} />;
}
// ✅ 完整的無障礙表單範例
function AccessibleForm() {
  const { register, formState: { errors } } = useForm();
  return (
    <form>
      <fieldset>
        <legend>個人資訊</legend>
        <div className="form-group">
          <label htmlFor="name">
            姓名
            <span aria-label="必填欄位">*</span>
          </label>
          <input
            id="name"
            {...register('name')}
            aria-required="true"
            aria-invalid={errors.name ? 'true' : 'false'}
            aria-describedby={errors.name ? 'name-error' : 'name-hint'}
          />
          <span id="name-hint" className="hint">
            請輸入您的真實姓名
          </span>
          {errors.name && (
            <span id="name-error" role="alert" className="error">
              {errors.name.message}
            </span>
          )}
        </div>
        <div className="form-group">
          <label htmlFor="email">Email</label>
          <input
            id="email"
            type="email"
            {...register('email')}
            aria-required="true"
            aria-invalid={errors.email ? 'true' : 'false'}
            aria-describedby="email-error"
          />
          {errors.email && (
            <span id="email-error" role="alert">
              {errors.email.message}
            </span>
          )}
        </div>
      </fieldset>
      <button type="submit">
        提交
        <span className="sr-only">
          提交表單資料
        </span>
      </button>
    </form>
  );
}
宣告式驗證: 使用 Schema(如 Zod)定義表單結構和驗證規則,實現前後端共用、型別安全的驗證邏輯,大幅提升可維護性和開發效率。
狀態自動化管理: 採用專業表單庫(如 React Hook Form)自動管理表單狀態,包括欄位值、驗證錯誤、提交狀態等,減少 70% 以上的樣板程式碼。
智能化使用者體驗: 實作即時驗證、自動儲存草稿、檔案預覽、動態欄位等功能,配合完整的無障礙設計,打造真正以使用者為中心的表單系統。
複雜表單的架構設計: 當表單包含數十個欄位、多層級的條件邏輯時,如何設計可維護的表單架構?是否應該考慮表單狀態機(State Machine)的設計模式?
前後端驗證一致性: 如何確保前端和後端的驗證規則完全一致?是否應該將 Schema 定義抽取成獨立的 npm 套件,在前後端共用?
表單分析與最佳化: 如何收集表單互動資料(欄位放棄率、錯誤率、完成時間等),並根據資料進行 UX 最佳化?哪些指標最能反映表單的使用者體驗品質?