Vue3 TSX组件通信实战:类型安全的Props与Emit全解析

在Vue3生态中,TSX正逐渐成为类型敏感开发者的首选方案。不同于模板语法,TSX需要开发者直面JavaScript的灵活性,同时又要兼顾Vue的响应式特性。本文将聚焦父子组件通信这一核心场景,通过类型定义、Props验证和Emit事件三个维度,构建完整的TSX组件通信知识体系。

1. TSX环境下的Props类型系统

1.1 定义严格的Props接口

在TSX中,类型系统不是可选项而是必选项。我们需要通过TypeScript接口明确定义Props的契约:

interface UserProfileProps {
  userId: number
  userName: string
  isVerified?: boolean
  onFollow?: (userId: number) => void
}

这种定义方式相比Vue模板的 defineProps 更加直观,且能获得完整的IDE类型提示。注意可选参数( ? 修饰符)的合理使用,它们能有效降低组件间的强耦合。

1.2 Props的默认值策略

在函数式组件中设置默认值有两种推荐方式:

方法一:解构时直接赋值

const UserCard = ({ isVerified = false, ...props }: UserProfileProps) => {
  return <div>{props.userName}{isVerified && <VerifiedBadge />}</div>
}

方法二:使用withDefaults工具

import { withDefaults } from 'vue'

const UserCard = withDefaults(
  (props: UserProfileProps) => {
    return <div>{props.userName}{props.isVerified && <VerifiedBadge />}</div>
  },
  { isVerified: false }
)

提示:复杂对象的默认值建议使用工厂函数,避免引用类型共享问题

2. TSX特有的事件派发机制

2.1 类型化的事件发射器

不同于模板中的 defineEmits ,TSX中需要通过 ctx 参数访问emit方法。我们可以通过泛型约束事件类型:

type EmitEvents = {
  (e: 'follow', userId: number): void
  (e: 'message', payload: { text: string; timestamp: Date }): void
}

const SocialButton = (props: {}, ctx: { emit: EmitEvents }) => {
  const handleClick = () => {
    ctx.emit('follow', 123) // 类型检查生效
    ctx.emit('message', { 
      text: 'Hello', 
      timestamp: new Date() // 自动类型推导
    })
  }
  
  return <button onClick={handleClick}>互动</button>
}

2.2 事件与Props的联合类型

当组件需要同时处理传入的回调函数和内部事件时,可以创建联合类型:

type CommentComponentProps = {
  comments: Array<{ id: number; text: string }>
  onReply?: (commentId: number) => void
} & {
  onVote?: (direction: 'up' | 'down') => void
}

const CommentList = (props: CommentComponentProps, ctx: { emit: any }) => {
  const handleVote = (dir: 'up' | 'down') => {
    props.onReply?.(123) // 调用可选Prop
    ctx.emit('vote', dir) // 派发内部事件
  }
  
  return (
    <div>
      {props.comments.map(comment => (
        <div key={comment.id}>
          <p>{comment.text}</p>
          <button onClick={() => handleVote('up')}>赞同</button>
        </div>
      ))}
    </div>
  )
}

3. 条件渲染与列表渲染的TSX实践

3.1 类型安全的条件分支

TSX中不能使用 v-if 指令,但可以利用TypeScript的类型收窄特性实现更安全的条件渲染:

type Notification = {
  type: 'message' | 'alert'
  content: string
  read?: boolean
}

const NotificationItem = ({ notification }: { notification: Notification }) => {
  return (
    <div class={notification.read ? 'read' : 'unread'}>
      {notification.type === 'message' ? (
        <MessageBubble content={notification.content} />
      ) : (
        <AlertPopup content={notification.content} />
      )}
    </div>
  )
}

3.2 带类型的列表渲染

结合泛型可以实现完全类型安全的列表渲染:

interface DataTableProps<T> {
  data: T[]
  renderRow: (item: T) => JSX.Element
}

function DataTable<T>(props: DataTableProps<T>) {
  return (
    <table>
      <tbody>
        {props.data.map((item, index) => (
          <tr key={index}>{props.renderRow(item)}</tr>
        ))}
      </tbody>
    </table>
  )
}

// 使用示例
interface Product {
  id: number
  name: string
  price: number
}

const ProductList = ({ products }: { products: Product[] }) => (
  <DataTable
    data={products}
    renderRow={(product) => (
      <>
        <td>{product.name}</td>
        <td>{product.price.toFixed(2)}</td>
      </>
    )}
  />
)

4. 高级模式:双向绑定的TSX实现

4.1 受控组件模式

通过Props和Emit实现类似 v-model 的双向绑定:

interface InputFieldProps {
  modelValue: string
  placeholder?: string
}

const InputField = (
  props: InputFieldProps,
  ctx: { emit: (event: 'update:modelValue', value: string) => void }
) => {
  const handleInput = (e: Event) => {
    ctx.emit('update:modelValue', (e.target as HTMLInputElement).value)
  }

  return (
    <input
      type="text"
      value={props.modelValue}
      onInput={handleInput}
      placeholder={props.placeholder}
    />
  )
}

4.2 使用自定义hook抽象逻辑

将通用逻辑提取为可复用的hook:

import { ref, watch } from 'vue'

export function useModelWrapper<T>(
  props: { modelValue: T },
  emit: (event: 'update:modelValue', value: T) => void
) {
  const value = ref(props.modelValue) as Ref<T>

  watch(
    () => props.modelValue,
    (newVal) => (value.value = newVal)
  )

  watch(value, (newVal) => {
    emit('update:modelValue', newVal)
  })

  return value
}

// 在组件中使用
const MyComponent = (props: { modelValue: string }, ctx: any) => {
  const value = useModelWrapper(props, ctx.emit)
  
  return <input v-model={value.value} />
}

5. 实战:构建类型安全的表单系统

5.1 表单上下文传递

通过provide/inject实现跨组件类型安全:

// types.ts
interface FormField {
  name: string
  value: any
  valid: boolean
}

interface FormContext {
  fields: Ref<Record<string, FormField>>
  updateField: (name: string, value: any) => void
}

const formKey = Symbol() as InjectionKey<FormContext>

// FormProvider.tsx
const FormProvider = (props: { initialValues: Record<string, any> }, { slots }) => {
  const fields = ref<Record<string, FormField>>(
    Object.entries(props.initialValues).reduce((acc, [name, value]) => {
      acc[name] = { name, value, valid: true }
      return acc
    }, {})
  )

  const updateField = (name: string, value: any) => {
    fields.value[name].value = value
    // 这里可以添加验证逻辑
  }

  provide(formKey, { fields, updateField })

  return () => slots.default?.()
}

// FormInput.tsx
const FormInput = (props: { name: string }) => {
  const form = inject(formKey)
  if (!form) throw new Error('必须在FormProvider内使用')

  const field = computed(() => form.fields.value[props.name])

  const handleInput = (e: Event) => {
    form.updateField(props.name, (e.target as HTMLInputElement).value)
  }

  return (
    <div>
      <input value={field.value.value} onInput={handleInput} />
      {!field.value.valid && <span class="error">验证失败</span>}
    </div>
  )
}

5.2 表单提交与验证

整合所有技术点实现完整表单流程:

const LoginForm = () => {
  const formData = reactive({
    email: '',
    password: '',
    remember: false
  })

  const rules = {
    email: (v: string) => /.+@.+\..+/.test(v),
    password: (v: string) => v.length >= 8
  }

  const submit = (ctx: { emit: any }) => {
    const isValid = Object.entries(formData).every(([key, value]) => {
      return rules[key as keyof typeof rules]?.(value) ?? true
    })

    if (isValid) {
      ctx.emit('submit', { ...formData })
    } else {
      ctx.emit('validation-failed')
    }
  }

  return (
    <FormProvider initialValues={formData}>
      <form onSubmit={(e) => e.preventDefault()}>
        <FormInput name="email" />
        <FormInput name="password" type="password" />
        <button onClick={() => submit}>登录</button>
      </form>
    </FormProvider>
  )
}

更多推荐