React 组件实战:一个完整的美国地址输入表单(含实时校验)
React 组件实战:完整的美国地址输入表单
一个高质量的美国地址表单不仅要外观美观,更要在用户体验和数据准确性之间取得平衡。本文将带你从零构建一个生产级的地址表单组件,包含实时校验、州下拉选择、邮编格式验证等完整功能。
组件设计思路
功能需求
状态设计
```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 填充表单
});
}
}, []);
};
```
总结
一个生产级的美国地址表单需要关注:
通过本文的组件,你可以快速集成一个专业、易用的地址表单到你的 React 项目中。