当组件内容由 AI 实时拼接时,插槽层怎么设计才不会每次都得改源码

很多组件库的插槽设计,在一开始就写死了。

它们基于一个朴素假设:开发者会手动 import 组件,然后在 JSX 里按固定结构填好所有 slot。但 AI 生成代码的场景完全打破了这种假设——AI 可能随时往某个区域注入一段全新的 JSX、一个你从未预定义过的子组件,甚至是一个完全不同的渲染逻辑。

不改源码的解法,是把插槽层从“枚举型”升级为“注册型”。

具体来说,不是让父组件在代码里 hardcode 所有可能的 children 或具名 slot,而是让父组件只负责划定“区域”,由外部系统(包括 AI)在运行时向这些区域注入内容。我用 Vue 3 和 React 18 各实践过一套方案,下面拆开讲。


从枚举到注册:为什么静态插槽扛不住 AI 生成

传统的插槽设计,不管是 Vue 的 <slot name="header"> 还是 React 的 {children} 或者 Render Props,本质上都是一种编译期约定

你在写 <Card> 的时候,已经明确知道它有三个插槽:headerbodyfooter。如果你的代码里没传 header,那它就渲染默认内容或者空着。这种模式在人工编码时完全没问题,因为开发者清楚每个组件的插槽契约。

但 AI 生成代码是另一回事。AI 可能生成一个完全新的业务组件 <ProductFlashSale>,它需要往 <Card>header 里注入一个倒计时组件,而这个倒计时组件在组件库设计之初根本不存在。更麻烦的是,AI 可能需要在页面渲染之后的某个时刻,根据用户行为动态决定是否注入、注入什么。

如果每次这种需求变化都要改 <Card> 的源码或类型定义,组件库就谈不上任何扩展性。


Vue 3 方案:动态具名插槽 + 渲染函数注入

Vue 3 的 <slot> 其实支持动态名称,这一点在文档里提得不多,但实战中非常关键。

<!-- Card.vue - 组件库中的卡片组件 -->
<template>
  <div class="card">
    <div class="card-header">
      <slot :name="dynamicSlotName" />
    </div>
    <div class="card-body">
      <slot />
    </div>
    <div class="card-footer">
      <slot name="footer" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{
  headerSlot?: string
}>()

const dynamicSlotName = computed(() => props.headerSlot || 'header')
</script>

关键点在于 dynamicSlotName。它允许外部传入一个字符串来决定 header 区域实际使用哪个具名插槽。这样,AI 生成的代码不需要知道组件内部结构,只需要向一个插槽注册表里塞内容。

接下来是运行时注入层。我通常会单独维护一个 slotRegistry

// slotRegistry.ts
import { shallowRef, type Component } from 'vue'

type SlotContent = Component | string

const registry = shallowRef<Record<string, SlotContent>>({})

export function registerSlot(name: string, content: SlotContent) {
  registry.value = { ...registry.value, [name]: content }
}

export function unregisterSlot(name: string) {
  const next = { ...registry.value }
  delete next[name]
  registry.value = next
}

export function useSlotRegistry() {
  return registry
}

在父组件里,通过一个高阶组件把注册表中的内容映射为实际的 VNode:

<!-- DynamicSlotRenderer.vue -->
<template>
  <component :is="resolvedComponent" v-bind="passedProps" />
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useSlotRegistry } from './slotRegistry'

const props = defineProps<{
  name: string
  fallback?: any
  passedProps?: Record<string, any>
}>()

const registry = useSlotRegistry()
const resolvedComponent = computed(() => registry.value[props.name] || props.fallback)
</script>

AI 生成的代码只需要调用 registerSlot

// AI 生成的业务逻辑
import { registerSlot } from '@/component-lib/slotRegistry'
import CountdownTimer from './CountdownTimer.vue'

// 在适当的生命周期里注入
registerSlot('card-header-extension', CountdownTimer)

<Card> 内部通过 <DynamicSlotRenderer name="card-header-extension" /> 渲染这个区域。整个过程,Card.vue 的源码一行没改,类型定义也不需要调整,因为注册表是运行时的。

这套方案在 Vue 3.3+ 上运行稳定,shallowRef 保证了替换整个注册表对象时触发响应式更新,不会出现深层追踪的性能问题。


React 18 方案:Slot Context + 可注册 Portal

React 没有 Vue 那种原生具名插槽,但 Context + Portal 的组合可以做到更灵活的效果。

核心思路:组件库提供一个 SlotProvider,它内部维护一个 Map<string, ReactNode>。组件通过 Context 读取这个 Map 并渲染对应内容。外部(包括 AI)通过 useSlot Hook 往这个 Map 里写入。

// SlotContext.tsx
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react'

type SlotMap = Map<string, ReactNode>

const SlotContext = createContext<{
  slots: SlotMap
  registerSlot: (name: string, node: ReactNode) => void
  unregisterSlot: (name: string) => void
} | null>(null)

export function SlotProvider({ children, initialSlots }: { children: ReactNode; initialSlots?: [string, ReactNode][] }) {
  const [slots, setSlots] = useState<SlotMap>(new Map(initialSlots))

  const registerSlot = useCallback((name: string, node: ReactNode) => {
    setSlots(prev => {
      const next = new Map(prev)
      next.set(name, node)
      return next
    })
  }, [])

  const unregisterSlot = useCallback((name: string) => {
    setSlots(prev => {
      const next = new Map(prev)
      next.delete(name)
      return next
    })
  }, [])

  return (
    <SlotContext.Provider value={{ slots, registerSlot, unregisterSlot }}>
      {children}
    </SlotContext.Provider>
  )
}

export function useSlot(name: string): ReactNode | undefined {
  const ctx = useContext(SlotContext)
  if (!ctx) throw new Error('useSlot must be used within SlotProvider')
  return ctx.slots.get(name)
}

export function useRegisterSlot() {
  const ctx = useContext(SlotContext)
  if (!ctx) throw new Error('useRegisterSlot must be used within SlotProvider')
  return { registerSlot: ctx.registerSlot, unregisterSlot: ctx.unregisterSlot }
}

组件库里的 <Card> 变成这样:

// Card.tsx
import React from 'react'
import { useSlot } from './SlotContext'

interface CardProps {
  children: React.ReactNode
  headerSlotName?: string
  footerSlotName?: string
}

export function Card({ children, headerSlotName = 'card-header', footerSlotName = 'card-footer' }: CardProps) {
  const headerContent = useSlot(headerSlotName)
  const footerContent = useSlot(footerSlotName)

  return (
    <div className="card">
      <div className="card-header">
        {headerContent ?? <DefaultHeader />}
      </div>
      <div className="card-body">{children}</div>
      <div className="card-footer">
        {footerContent ?? <DefaultFooter />}
      </div>
    </div>
  )
}

AI 生成的代码通过 useRegisterSlot 向卡片注入内容,不需要触碰 Card.tsx 的源码:

// AI 生成的嵌入逻辑
import { useEffect } from 'react'
import { useRegisterSlot } from '@/component-lib/SlotContext'
import { CountdownBanner } from './CountdownBanner'

export function useInjectFlashSale() {
  const { registerSlot, unregisterSlot } = useRegisterSlot()

  useEffect(() => {
    registerSlot('card-header', <CountdownBanner endTime="2025-12-31T23:59:59" />)
    return () => unregisterSlot('card-header')
  }, [])
}

这个方案的额外好处是,SlotProvider 可以嵌套。如果你有多个 AI 生成模块在同一个页面里,每个模块可以有自己的 Provider 作用域,互不干扰。这在微前端或模块联邦场景下特别实用。


插槽协议的契约化:TypeScript 类型不该写死在组件 Props 里

很多组件库会把插槽名称定义为组件 Props 的联合类型,比如 type CardSlots = 'header' | 'body' | 'footer'。这在注册型架构下会成为瓶颈。

正确的做法是把插槽名当作一个可扩展的字符串命名空间,类型定义放在一个独立的协议层:

// slot-protocol.ts
export const CARD_SLOTS = {
  HEADER: 'card:header',
  BODY: 'card:body',
  FOOTER: 'card:footer',
} as const

export type CardSlotName = (typeof CARD_SLOTS)[keyof typeof CARD_SLOTS] | `card:${string}`

组件库内部使用 CARD_SLOTS.HEADER 这类常量的值作为默认 key,但类型上接受 card: 前缀的任意扩展。AI 系统可以注册 card:flash-sale-banner,类型检查通过,渲染也正常,不需要改任何源码。

这套命名空间约定在实际项目中用冒号做分隔符,能有效避免不同组件库之间的插槽名冲突。我在两个项目里分别用 app:ai: 前缀区分人工代码和 AI 生成的插槽,调试时一眼就能看出谁注册的。


常见问题

插槽注册表会不会导致内存泄漏?

会,如果只注册不清理。Vue 方案里,我在 onUnmounted 里调用 unregisterSlot;React 方案里,useEffect 的清理函数负责移除。另外,SlotProvider 卸载时会自动释放整个 Map,所以即使某个 AI 模块忘记清理,只要它的 Provider 作用域跟着路由一起卸载,就不会累积。

多个 AI 模块同时注册同名插槽怎么办?

默认行为是后注册的覆盖先注册的,这符合大多数场景的预期。如果业务需要允许多个内容叠加,可以把 SlotMap 的值类型从 ReactNode 改为 ReactNode[],渲染时用 Fragment 包裹。但我不建议默认这么做,因为多个模块往同一个区域塞内容往往意味着设计上的耦合,应该在插槽协议层面就区分开。

性能怎么样?频繁注册/注销会导致不必要的重渲染吗?

Vue 的 shallowRef 只追踪整个对象的替换,不会触发深层依赖收集,所以性能开销很小。React 这边,useState 每次注册都会触发 Provider 子树的重渲染,如果注册非常频繁(比如每秒几十次),需要用 useRef 配合 forceUpdate 做批量更新,或者直接用 useSyncExternalStore 订阅外部 store。实际项目中,AI 注入插槽通常是低频操作(页面加载、路由切换、用户交互触发),默认方案足够用。