本规范定义了基于Taro框架的小程序表单开发标准,采用react-hook-form进行状态管理,zod进行表单验证,Tailwind CSS v4进行样式设计。
mini/
├── src/
│ ├── components/
│ │ └── ui/
│ │ ├── form.tsx # 表单核心组件
│ │ ├── input.tsx # 输入框组件
│ │ ├── label.tsx # 标签组件
│ │ └── button.tsx # 按钮组件
│ ├── utils/
│ │ ├── cn.ts # 类名合并工具
│ │ └── validators.ts # 验证规则(可选)
│ └── schemas/
│ └── user.schema.ts # 表单验证模式(可选)
mini/
├── src/
│ ├── components/
│ │ └── ui/
│ │ ├── form.tsx # 表单核心组件
│ │ ├── input.tsx # 输入框组件
│ │ └── button.tsx # 按钮组件
└── src/pages/
└── your-page/
└── index.tsx # 验证规则直接定义在页面中
// mini/src/components/ui/form.tsx
import { createContext, useContext, forwardRef } from 'react'
import { useFormContext } from 'react-hook-form'
import { cn } from '@/utils/cn'
const Form = forwardRef<
React.ElementRef<typeof TaroForm>,
React.ComponentPropsWithoutRef<typeof TaroForm>
>(({ className, ...props }, ref) => {
return (
<TaroForm
ref={ref}
className={cn('space-y-6', className)}
{...props}
/>
)
})
Form.displayName = 'Form'
const FormField = forwardRef<
React.ElementRef<typeof Controller>,
React.ComponentPropsWithoutRef<typeof Controller>
>(({ render, ...props }, ref) => {
return (
<Controller
ref={ref}
render={({ field, fieldState, formState }) => (
<FormItemContext.Provider value={{ name: props.name, fieldState, formState }}>
{render({ field, fieldState, formState })}
</FormItemContext.Provider>
)}
{...props}
/>
)
})
const FormItem = forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => {
const id = useId()
return (
<FormItemContext.Provider value={{ id }}>
<View
ref={ref}
className={cn('space-y-2', className)}
{...props}
/>
</FormItemContext.Provider>
)
})
const FormLabel = forwardRef<
React.ElementRef<typeof Text>,
React.ComponentPropsWithoutRef<typeof Text>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(
error && 'text-destructive',
className
)}
htmlFor={formItemId}
{...props}
/>
)
})
const FormControl = forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<View
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
const FormMessage = forwardRef<
React.ElementRef<typeof Text>,
React.ComponentPropsWithoutRef<typeof Text>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<Text
ref={ref}
id={formMessageId}
className={cn('text-sm font-medium text-destructive', className)}
{...props}
>
{body}
</Text>
)
})
// mini/src/schemas/user.schema.ts
import { z } from 'zod'
export const userSchema = z.object({
username: z.string()
.min(2, '用户名至少2个字符')
.max(20, '用户名最多20个字符')
.regex(/^[a-zA-Z0-9_]+$/, '用户名只能包含字母、数字和下划线'),
phone: z.string()
.regex(/^1[3-9]\d{9}$/, '请输入正确的手机号码'),
email: z.string()
.email('请输入正确的邮箱地址'),
password: z.string()
.min(6, '密码至少6个字符')
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{6,}$/, '密码必须包含大小写字母和数字'),
confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
message: '两次输入的密码不一致',
path: ['confirmPassword']
})
export type UserFormData = z.infer<typeof userSchema>
// 直接在页面文件中定义
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
const loginSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入正确的手机号码'),
password: z.string().min(1, '请输入密码')
})
type LoginFormData = z.infer<typeof loginSchema>
// 使用方式
const form = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
phone: '',
password: ''
}
})
// 对于复用验证规则,可以创建 utils/validators.ts
// 小型表单可直接在页面中定义,无需单独文件
// 常用验证规则(可选)
export const phoneSchema = z.string().regex(/^1[3-9]\d{9}$/, '请输入正确的手机号码')
export const emailSchema = z.string().email('请输入正确的邮箱地址')
// mini/src/pages/register/index.tsx
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { userSchema, UserFormData } from '@/schemas/user.schema'
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
export default function RegisterPage() {
const form = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: {
username: '',
phone: '',
email: '',
password: '',
confirmPassword: ''
}
})
const onSubmit = async (data: UserFormData) => {
try {
// 提交表单逻辑
console.log('表单数据:', data)
} catch (error) {
console.error('提交失败:', error)
}
}
return (
<View className="min-h-screen bg-gray-50 p-4">
<View className="max-w-md mx-auto">
<Text className="text-2xl font-bold text-center mb-6">用户注册</Text>
<Form {...form}>
<TaroForm onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>用户名</FormLabel>
<FormControl>
<Input
placeholder="请输入用户名"
{...field}
className="h-10"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>手机号</FormLabel>
<FormControl>
<Input
type="tel"
placeholder="请输入手机号"
{...field}
className="h-10"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>邮箱</FormLabel>
<FormControl>
<Input
type="email"
placeholder="请输入邮箱"
{...field}
className="h-10"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>密码</FormLabel>
<FormControl>
<Input
type="password"
placeholder="请输入密码"
{...field}
className="h-10"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>确认密码</FormLabel>
<FormControl>
<Input
type="password"
placeholder="请再次输入密码"
{...field}
className="h-10"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full h-10 bg-blue-500 text-white"
loading={form.formState.isSubmitting}
>
注册
</Button>
</TaroForm>
</Form>
</View>
</View>
)
}
// mini/src/pages/login/index.tsx
const loginSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入正确的手机号码'),
password: z.string().min(1, '请输入密码')
})
export default function LoginPage() {
const form = useForm({
resolver: zodResolver(loginSchema),
defaultValues: {
phone: '',
password: ''
}
})
return (
<View className="min-h-screen bg-gray-50 p-4">
<View className="max-w-md mx-auto">
<Text className="text-2xl font-bold text-center mb-6">用户登录</Text>
<Form {...form}>
<TaroForm onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>手机号</FormLabel>
<FormControl>
<Input
type="tel"
placeholder="请输入手机号"
{...field}
className="h-10"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>密码</FormLabel>
<FormControl>
<Input
type="password"
placeholder="请输入密码"
{...field}
className="h-10"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full h-10 bg-blue-500 text-white">
登录
</Button>
</TaroForm>
</Form>
</View>
</View>
)
}
const form = useForm({
resolver: zodResolver(userSchema),
mode: 'onChange', // 实时验证
reValidateMode: 'onChange',
defaultValues: {
username: '',
phone: ''
}
})
const asyncUsernameSchema = z.object({
username: z.string()
.min(2, '用户名至少2个字符')
.max(20, '用户名最多20个字符')
.refine(async (username) => {
const response = await checkUsernameAvailability(username)
return response.available
}, '用户名已被占用')
})
const onSubmit = async (data: UserFormData) => {
try {
await registerUser(data)
Taro.showToast({ title: '注册成功' })
Taro.navigateBack()
} catch (error) {
if (error.response?.data?.errors) {
Object.keys(error.response.data.errors).forEach(field => {
form.setError(field as any, {
message: error.response.data.errors[field][0]
})
})
}
}
}
// 标准间距
const formSpacing = {
item: 'space-y-2', // 表单项内部间距
section: 'space-y-6', // 表单区域间距
group: 'space-y-4' // 表单组间距
}
// 输入框样式
const inputStyles = {
base: 'h-10 px-3 bg-white border border-gray-300 rounded-md',
focus: 'focus:border-blue-500 focus:ring-1 focus:ring-blue-500',
error: 'border-red-500 focus:border-red-500 focus:ring-red-500'
}
<View className="max-w-md mx-auto p-4 sm:p-6 md:p-8">
<Form className="space-y-4 sm:space-y-6">
<FormItem className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormControl>
<Input className="w-full" />
</FormControl>
</FormItem>
</Form>
</View>
import { debounce } from 'lodash'
const debouncedValidate = debounce((value) => {
form.trigger('username')
}, 300)
const handleUsernameChange = (value: string) => {
form.setValue('username', value)
debouncedValidate(value)
}
// 避免不必要的重渲染
const FormFieldMemo = React.memo(({ name, control, render }) => (
<FormField
name={name}
control={control}
render={render}
/>
))
<FormItem>
<FormLabel>
<Text className="sr-only">用户名</Text>
</FormLabel>
<FormControl>
<Input
aria-label="用户名"
aria-required="true"
aria-invalid={!!form.formState.errors.username}
/>
</FormControl>
</FormItem>
// 支持Tab键导航
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
form.handleSubmit(onSubmit)()
}
}