返回经验列表
React前端开发组件设计

React 组件实战:一个完整的美国地址输入表单(含实时校验)

Petmind2026-08-1113分钟阅读

React 组件实战:完整的美国地址输入表单

一个高质量的美国地址表单不仅要外观美观,更要在用户体验和数据准确性之间取得平衡。本文将带你从零构建一个生产级的地址表单组件,包含实时校验、州下拉选择、邮编格式验证等完整功能。

组件设计思路

功能需求

  • 实时校验:用户输入时即时反馈错误
  • 州下拉选择:避免用户输入错误的州缩写
  • 邮编自动格式化:输入时自动格式化为 5 位或 5+4
  • 地址行2切换:根据是否有公寓号动态显示
  • 无障碍支持:完整的 ARIA 标签和键盘导航
  • 状态设计

    ```typescript

    interface AddressFormState {

    addressLine1: string;

    addressLine2: string;

    hasApartment: boolean;

    city: string;

    state: string;

    zipCode: string;

    }

    interface ValidationErrors {

    addressLine1?: string;

    city?: string;

    state?: string;

    zipCode?: string;

    }

    ```

    完整组件实现

    1. 常量与工具函数

    ```typescript

    // constants.ts

    export const US_STATES = [

    { code: 'AL', name: 'Alabama' },

    { code: 'AK', name: 'Alaska' },

    { code: 'AZ', name: 'Arizona' },

    { code: 'AR', name: 'Arkansas' },

    { code: 'CA', name: 'California' },

    // ... 所有 50 个州 + DC

    { code: 'WY', name: 'Wyoming' }

    ] as const;

    export const ZIP_CODE_REGEX = /^\d{5}(-\d{4})?$/;

    export function formatZipCode(value: string): string {

    const digits = value.replace(/\D/g, '');

    if (digits.length <= 5) return digits;

    if (digits.length <= 9) return `${digits.slice(0, 5)}-${digits.slice(5)}`;

    return `${digits.slice(0, 5)}-${digits.slice(5, 9)}`;

    }

    export function validateAddressForm(values: AddressFormState): ValidationErrors {

    const errors: ValidationErrors = {};

    if (!values.addressLine1.trim()) {

    errors.addressLine1 = 'Street address is required';

    } else if (values.addressLine1.length < 3) {

    errors.addressLine1 = 'Address is too short';

    }

    if (!values.city.trim()) {

    errors.city = 'City is required';

    }

    if (!values.state) {

    errors.state = 'Please select a state';

    }

    if (!values.zipCode) {

    errors.zipCode = 'ZIP code is required';

    } else if (!ZIP_CODE_REGEX.test(values.zipCode)) {

    errors.zipCode = 'Invalid ZIP code format (e.g., 90210 or 90210-1234)';

    }

    return errors;

    }

    ```

    2. 表单组件

    ```typescript

    // AddressForm.tsx

    import React, { useState, useCallback, useEffect } from 'react';

    import { US_STATES, formatZipCode, validateAddressForm } from './constants';

    interface AddressFormProps {

    onSubmit: (address: AddressFormState) => void;

    initialValues?: Partial<AddressFormState>;

    }

    const AddressForm: React.FC<AddressFormProps> = ({ onSubmit, initialValues }) => {

    const [values, setValues] = useState<AddressFormState>({

    addressLine1: initialValues?.addressLine1 || '',

    addressLine2: initialValues?.addressLine2 || '',

    hasApartment: !!initialValues?.addressLine2,

    city: initialValues?.city || '',

    state: initialValues?.state || '',

    zipCode: initialValues?.zipCode || ''

    });

    const [errors, setErrors] = useState<ValidationErrors>({});

    const [touched, setTouched] = useState<Record<string, boolean>>({});

    // 实时校验

    useEffect(() => {

    const validationErrors = validateAddressForm(values);

    setErrors(validationErrors);

    }, [values]);

    const handleChange = useCallback((field: keyof AddressFormState, value: string | boolean) => {

    setValues(prev => ({ ...prev, [field]: value }));

    }, []);

    const handleBlur = useCallback((field: string) => {

    setTouched(prev => ({ ...prev, [field]: true }));

    }, []);

    const handleZipChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {

    const formatted = formatZipCode(e.target.value);

    handleChange('zipCode', formatted);

    }, [handleChange]);

    const handleSubmit = (e: React.FormEvent) => {

    e.preventDefault();

    // 标记所有字段为 touched

    setTouched({

    addressLine1: true,

    city: true,

    state: true,

    zipCode: true

    });

    const validationErrors = validateAddressForm(values);

    if (Object.keys(validationErrors).length === 0) {

    onSubmit(values);

    }

    };

    const showError = (field: string) => touched[field] ? errors[field as keyof ValidationErrors] : undefined;

    return (

    <form onSubmit={handleSubmit} className="address-form" noValidate>

    <h2>Shipping Address</h2>

    {/* Address Line 1 */}

    <div className="form-group">

    <label htmlFor="addressLine1">

    Street Address <span className="required">*</span>

    </label>

    <input

    id="addressLine1"

    type="text"

    value={values.addressLine1}

    onChange={(e) => handleChange('addressLine1', e.target.value)}

    onBlur={() => handleBlur('addressLine1')}

    placeholder="123 Main St"

    aria-invalid={!!showError('addressLine1')}

    aria-describedby={showError('addressLine1') ? 'addressLine1-error' : undefined}

    />

    {showError('addressLine1') && (

    <span id="addressLine1-error" className="error" role="alert">

    {showError('addressLine1')}

    </span>

    )}

    </div>

    {/* Apartment Toggle */}

    <div className="form-group checkbox">

    <label>

    <input

    type="checkbox"

    checked={values.hasApartment}

    onChange={(e) => {

    handleChange('hasApartment', e.target.checked);

    if (!e.target.checked) handleChange('addressLine2', '');

    }}

    />

    Add apartment, suite, or unit

    </label>

    </div>

    {/* Address Line 2 (Conditional) */}

    {values.hasApartment && (

    <div className="form-group">

    <label htmlFor="addressLine2">Apt / Suite / Unit</label>

    <input

    id="addressLine2"

    type="text"

    value={values.addressLine2}

    onChange={(e) => handleChange('addressLine2', e.target.value)}

    placeholder="Apt 5B"

    />

    </div>

    )}

    {/* City and State Row */}

    <div className="form-row">

    <div className="form-group flex-2">

    <label htmlFor="city">

    City <span className="required">*</span>

    </label>

    <input

    id="city"

    type="text"

    value={values.city}

    onChange={(e) => handleChange('city', e.target.value)}

    onBlur={() => handleBlur('city')}

    placeholder="Los Angeles"

    aria-invalid={!!showError('city')}

    />

    {showError('city') && <span className="error">{showError('city')}</span>}

    </div>

    <div className="form-group flex-1">

    <label htmlFor="state">

    State <span className="required">*</span>

    </label>

    <select

    id="state"

    value={values.state}

    onChange={(e) => handleChange('state', e.target.value)}

    onBlur={() => handleBlur('state')}

    aria-invalid={!!showError('state')}

    >

    <option value="">Select...</option>

    {US_STATES.map(s => (

    <option key={s.code} value={s.code}>{s.name} ({s.code})</option>

    ))}

    </select>

    {showError('state') && <span className="error">{showError('state')}</span>}

    </div>

    </div>

    {/* ZIP Code */}

    <div className="form-group">

    <label htmlFor="zipCode">

    ZIP Code <span className="required">*</span>

    </label>

    <input

    id="zipCode"

    type="text"

    value={values.zipCode}

    onChange={handleZipChange}

    onBlur={() => handleBlur('zipCode')}

    placeholder="90210"

    maxLength={10}

    inputMode="numeric"

    aria-invalid={!!showError('zipCode')}

    />

    {showError('zipCode') && <span className="error">{showError('zipCode')}</span>}

    </div>

    <button type="submit" className="submit-btn">

    Continue

    </button>

    </form>

    );

    };

    export default AddressForm;

    ```

    3. CSS 样式

    ```css

    .address-form {

    max-width: 500px;

    padding: 24px;

    background: #fff;

    border-radius: 8px;

    box-shadow: 0 2px 8px rgba(0,0,0,0.1);

    }

    .form-group {

    margin-bottom: 16px;

    }

    .form-group label {

    display: block;

    margin-bottom: 4px;

    font-weight: 500;

    font-size: 14px;

    }

    .form-group input,

    .form-group select {

    width: 100%;

    padding: 10px 12px;

    border: 1px solid #ddd;

    border-radius: 4px;

    font-size: 16px;

    transition: border-color 0.2s;

    }

    .form-group input:focus,

    .form-group select:focus {

    outline: none;

    border-color: #4a90d9;

    box-shadow: 0 0 0 3px rgba(74, 144, 217, 0.1);

    }

    .form-group input[aria-invalid="true"] {

    border-color: #e74c3c;

    }

    .form-group input[aria-invalid="true"]:focus {

    box-shadow: 0 0 0 3px rgba(231, 76, 60, 0.1);

    }

    .error {

    display: block;

    margin-top: 4px;

    color: #e74c3c;

    font-size: 13px;

    }

    .required {

    color: #e74c3c;

    }

    .form-row {

    display: flex;

    gap: 16px;

    }

    .flex-2 { flex: 2; }

    .flex-1 { flex: 1; }

    .checkbox label {

    display: flex;

    align-items: center;

    gap: 8px;

    cursor: pointer;

    }

    .checkbox input {

    width: auto;

    }

    .submit-btn {

    width: 100%;

    padding: 12px;

    background: #4a90d9;

    color: white;

    border: none;

    border-radius: 4px;

    font-size: 16px;

    font-weight: 500;

    cursor: pointer;

    transition: background 0.2s;

    }

    .submit-btn:hover {

    background: #357abd;

    }

    ```

    4. 使用示例

    ```typescript

    // App.tsx

    import AddressForm from './AddressForm';

    function App() {

    const handleSubmit = (address: AddressFormState) => {

    console.log('Submitted address:', address);

    // 发送到后端或进行下一步处理

    };

    return (

    <div className="app">

    <AddressForm onSubmit={handleSubmit} />

    </div>

    );

    }

    ```

    高级功能扩展

    1. 与 USPS API 集成实时验证

    ```typescript

    const useAddressVerification = () => {

    const [verifying, setVerifying] = useState(false);

    const [verified, setVerified] = useState(false);

    const verify = async (address: AddressFormState) => {

    setVerifying(true);

    try {

    const response = await fetch('/api/verify-address', {

    method: 'POST',

    headers: { 'Content-Type': 'application/json' },

    body: JSON.stringify(address)

    });

    const result = await response.json();

    setVerified(result.valid);

    return result;

    } finally {

    setVerifying(false);

    }

    };

    return { verify, verifying, verified };

    };

    ```

    2. 地址自动补全(Google Places)

    ```typescript

    const useAddressAutocomplete = () => {

    const autocompleteRef = useRef<google.maps.places.Autocomplete | null>(null);

    useEffect(() => {

    if (window.google && inputRef.current) {

    autocompleteRef.current = new google.maps.places.Autocomplete(inputRef.current, {

    types: ['address'],

    componentRestrictions: { country: 'us' }

    });

    autocompleteRef.current.addListener('place_changed', () => {

    const place = autocompleteRef.current!.getPlace();

    // 解析 address_components 填充表单

    });

    }

    }, []);

    };

    ```

    总结

    一个生产级的美国地址表单需要关注:

  • 实时校验:减少用户提交后的错误反馈
  • 州下拉选择:避免拼写错误,提升数据质量
  • 邮编自动格式化:输入时自动处理,减少用户负担
  • 无障碍支持:ARIA 标签、键盘导航、错误提示
  • 条件渲染:根据用户选择动态显示/隐藏字段
  • 通过本文的组件,你可以快速集成一个专业、易用的地址表单到你的 React 项目中。

    返回经验列表