当组件内容由 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> 的时候,已经明确知道它有三个插槽:header、body、footer。如果你的代码里没传 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 注入插槽通常是低频操作(页面加载、路由切换、用户交互触发),默认方案足够用。