一、痛点:那些"抽组件不值,复制又恶心"的模板
写 Vue 多了,几乎每个人都会遇到这种场景:
vue
<template>
<dialog v-if="showInDialog">
<div class="user-card">
<Avatar :src="user.avatar" />
<div>
<h3>{{ user.name }}</h3>
<p>{{ user.bio }}</p>
<Tag v-for="t in user.tags" :key="t">{{ t }}</Tag>
</div>
</div>
</dialog>
<div v-else class="user-card-list">
<!-- 一模一样的 user-card 又来一遍 -->
<div class="user-card">
<Avatar :src="user.avatar" />
<div>
<h3>{{ user.name }}</h3>
<p>{{ user.bio }}</p>
<Tag v-for="t in user.tags" :key="t">{{ t }}</Tag>
</div>
</div>
</div>
</template>
要复用,传统做法只有两条路。
抽成子组件。 看起来"标准",但成本不低:你得定义 props、想清楚 emits、暴露需要透传的 slots。如果模板里直接读取了组合式函数返回的若干本地状态,还要把这些状态全部转成 props 一层一层传过去。三五行模板,写 30 行接口,得不偿失。
复制粘贴。 当下最快,未来最贵------只要逻辑一改就两地跟改,漏一个就是 bug。
VueUse 提供了第三种思路:createReusableTemplate。它让你在同一个 SFC 内把一段模板"定义一次、调用多次",不抽组件、不写 props、不离开作用域。从设计意图上看,这更像是"把模板当成可调用函数"。
据 createReusableTemplate 官方文档,这个函数的初衷正是:
So this function is made to provide a way for defining and reusing templates inside the component scope.
本文基于 VueUse v14.3.0(2026 年 5 月发布,要求 Vue 3.5+),系统讲清楚它的用法、类型推导、源码实现、对比矩阵、实战场景,以及那些容易踩的坑。
二、基础用法:DefineTemplate + ReuseTemplate
2.1 最小可运行示例
vue
<script setup lang="ts">
import { createReusableTemplate } from '@vueuse/core'
// 一次返回一对组件:定义器 + 复用器
const [DefineUserCard, ReuseUserCard] = createReusableTemplate()
</script>
<template>
<!-- 1. 定义:仅注册模板,不渲染任何内容 -->
<DefineUserCard>
<div class="user-card">
<Avatar :src="user.avatar" />
<div>
<h3>{{ user.name }}</h3>
<p>{{ user.bio }}</p>
<Tag v-for="t in user.tags" :key="t">{{ t }}</Tag>
</div>
</div>
</DefineUserCard>
<!-- 2. 复用:真正渲染 -->
<dialog v-if="showInDialog">
<ReuseUserCard />
</dialog>
<div v-else class="user-card-list">
<ReuseUserCard />
</div>
</template>
三条规则需要记住:
<DefineUserCard>只负责"注册",自身不渲染任何 DOM;<ReuseUserCard>负责"渲染",输出的就是<DefineUserCard>默认插槽里的内容;<DefineUserCard>必须出现在<ReuseUserCard>之前------这是它的工作机制决定的,后面源码部分会解释。
2.2 通过作用域插槽传入数据
如果直接复用整段模板还不够灵活,可以借助作用域插槽传入参数:
vue
<script setup lang="ts">
import { createReusableTemplate } from '@vueuse/core'
interface FieldProps {
label: string
required?: boolean
error?: string
}
const [DefineField, ReuseField] = createReusableTemplate<FieldProps>()
</script>
<template>
<DefineField v-slot="{ label, required, error, $slots }">
<label class="field">
<span class="field__label">
{{ label }}
<em v-if="required" class="field__star">*</em>
</span>
<component :is="$slots.default" />
<small v-if="error" class="field__error">{{ error }}</small>
</label>
</DefineField>
<ReuseField label="用户名" required :error="errors.username">
<input v-model="form.username" />
</ReuseField>
<ReuseField label="邮箱" :error="errors.email">
<input v-model="form.email" type="email" />
</ReuseField>
<ReuseField label="备注">
<textarea v-model="form.remark" />
</ReuseField>
</template>
注意三个细节:
<DefineField>用v-slot="{ ... }"解构出参数,这些参数实际上是<ReuseField>上传入的 props/attrs;$slots也会作为参数传入------当复用区域需要再次塞进自定义内容时,用<component :is="$slots.default" />把默认插槽渲染出来即可;<ReuseField>上既能写命名 props,也支持v-bind="{ ... }"、属性透传,写法和普通组件一致。
2.3 Options API 的写法
如果你还在用 Options API(比如老项目逐步迁移),需要把 createReusableTemplate 调用放在 setup 之外,并显式注册到 components 选项中:
vue
<script lang="ts">
import { createReusableTemplate } from '@vueuse/core'
import { defineComponent } from 'vue'
const [DefineTemplate, ReuseTemplate] = createReusableTemplate()
export default defineComponent({
components: { DefineTemplate, ReuseTemplate },
setup() {
// ...
},
})
</script>
实际项目里更推荐 <script setup>,因为 createReusableTemplate 的设计哲学和组合式 API 是配套的。
三、TypeScript 类型支持:泛型 + 多种解构姿势
3.1 用泛型约束作用域插槽
vue
<script setup lang="ts">
import { createReusableTemplate } from '@vueuse/core'
// 单组件中可以创建多套,互不干扰
const [DefineFoo, ReuseFoo] = createReusableTemplate<{ msg: string }>()
const [DefineBar, ReuseBar] = createReusableTemplate<{ items: string[] }>()
</script>
<template>
<DefineFoo v-slot="{ msg }">
<!-- msg 自动推导为 string -->
<div>Hello {{ msg.toUpperCase() }}</div>
</DefineFoo>
<ReuseFoo msg="World" />
<!-- @ts-expect-error 类型错误:msg 应为 string -->
<ReuseFoo :msg="1" />
</template>
泛型参数会同时约束两端:<DefineFoo> 的作用域插槽参数被推导出来,<ReuseFoo> 上的 props 也会做类型校验。
3.2 不喜欢数组解构?还有两种姿势
VueUse 用 makeDestructurable 让返回值同时支持数组解构、对象解构和点语法,三种写法等价:
ts
// 方式 A:数组解构(最常见)
const [DefineFoo, ReuseFoo] = createReusableTemplate<{ msg: string }>()
// 方式 B:对象解构
const { define: DefineFoo, reuse: ReuseFoo } = createReusableTemplate<{ msg: string }>()
// 方式 C:保留命名空间,模板内用点语法
const TemplateFoo = createReusableTemplate<{ msg: string }>()
// <TemplateFoo.define v-slot="{ msg }">...</TemplateFoo.define>
// <TemplateFoo.reuse msg="World" />
工程里推荐方式 A 或 B:清晰、一目了然;方式 C 适合一组紧密相关的模板对,让代码更紧凑。
3.3 显式声明 props(v12.6.0+)
默认情况下,<ReuseTemplate> 上的所有 props 和 attrs 都会原样透传到模板里。但有时候你只想把某些字段当作模板参数,而不希望它们落到根元素的 DOM 上 。从 v12.6.0 起,createReusableTemplate 支持显式声明运行时 props:
ts
import { createReusableTemplate } from '@vueuse/core'
const [DefineCol, ReuseCol] = createReusableTemplate({
inheritAttrs: false, // 关闭 attrs 透传到根元素
props: {
label: String,
width: Number,
sortable: Boolean,
},
})
inheritAttrs: false 配合 props 声明,能让 <ReuseCol> 表现得更像一个"正经组件"------这正是 VueUse PR #4535 引入这个能力的动机。
四、源码原理浅析:30 行就讲完的"魔法"
这个 API 看起来像黑魔法,但实现非常朴素。这是 packages/core/createReusableTemplate/index.ts 的核心代码(按主线略作删减):
ts
import { defineComponent, shallowRef } from 'vue'
import { camelize, makeDestructurable } from '@vueuse/shared'
export function createReusableTemplate<Bindings, Slots>(
options: CreateReusableTemplateOptions<Bindings> = {},
) {
const { inheritAttrs = true, name = 'ReusableTemplate' } = options
// 关键:用一个 shallowRef 当作"模板插槽"的载体
const render = shallowRef<Slot | undefined>()
// 1) 定义器:把默认插槽函数写进 render
const define = defineComponent({
name: `${name}.define`,
setup(_, { slots }) {
return () => {
render.value = slots.default
}
},
})
// 2) 复用器:从 render 里读出函数,加上参数后调用
const reuse = defineComponent({
inheritAttrs,
name: `${name}.reuse`,
props: options.props,
setup(props, { attrs, slots }) {
return () => {
if (!render.value && process.env.NODE_ENV !== 'production')
throw new Error('[VueUse] Failed to find the definition of reusable template')
const vnode = render.value?.({
...(options.props == null ? keysToCamelKebabCase(attrs) : props),
$slots: slots,
})
return (inheritAttrs && vnode?.length === 1) ? vnode[0] : vnode
}
},
})
return makeDestructurable({ define, reuse }, [define, reuse])
}
把它拆开看,有几个关键设计:
1. shallowRef 作为模板载体。 render 持有 <DefineTemplate> 的默认插槽函数(slot 本身就是 render function)。这是 Vue 内部插槽机制的"逃逸口"------把插槽抓出来变量化,再换个地方调用。
2. define 组件本身不输出。 它的 render 函数把 render.value = slots.default 之后返回 undefined ,相当于一个"空组件",只产生副作用。所以 <DefineTemplate> 包裹的内容不会在原地渲染。
3. reuse 组件调用 render function。 它把传进来的 props/attrs 拼成对象、再把自己的 $slots 一起塞过去,作为参数调用 render.value(),得到一组 VNode 后输出。
4. 必须先定义后复用,因为 render 一开始是 undefined。 Vue 的 render 顺序是模板从上往下,所以 <DefineTemplate> 必须先于 <ReuseTemplate> 出现,否则 reuse 时 render.value 还没赋值,开发模式会直接抛错。
5. 跨 SFC 复用做不到。 render 是 createReusableTemplate 闭包内的局部变量,只有在同一个 <script setup> 的同一组组件里才能共享。要"全局可复用",VueUse 官方推荐换条思路------直接抽成组件,或者使用社区方案 unplugin-vue-reuse-template。
理解了这 30 行,再去看作用域插槽、v-slot、render function 这些概念,会觉得它们都被串到了一起。
五、对比矩阵:跟 slot / component / 子组件该怎么选
createReusableTemplate 不是要取代什么,而是补全了"组件抽得太重、插槽传不动"中间地带的空白。一个简单对比:
| 场景 | createReusableTemplate |
<slot> |
<component :is> |
抽成子组件 | render function |
|---|---|---|---|---|---|
| 同一 SFC 内复用模板 | ✅ 最佳 | ❌ slot 是接收外部内容 | △ 需提前注册组件 | △ 成本高 | ✅ 但代码丑 |
| 访问父组件局部变量 | ✅ 直接访问 | ✅ 默认作用域 | ✅ | ❌ 需 props 透传 | ✅ |
| 跨 SFC 复用 | ❌ 不支持 | ❌(需提供方) | ✅ | ✅ 最佳 | ✅ |
| 类型安全 | ✅ 泛型推导 | △ 作用域插槽需手写 | △ | ✅ | △ 需手写 |
| 调试体验 | △ devtools 显示为内置组件 | ✅ | ✅ | ✅ 独立节点 | ❌ 不直观 |
| 写法成本 | 一行调用 | 接收方零成本 | 中等 | 高(props/emits/slots) | 极高 |
挑选原则可以记成几句话:
- "在当前 SFC 里要重复输出一段 UI、并且想直接读父级响应式状态" →
createReusableTemplate; - "被外部组件嵌套使用、内容由调用方决定" → 用普通
<slot>; - "要在多种已知组件之间动态切换" →
<component :is>; - "逻辑/样式都成熟、其他页面也用得上" → 抽成组件,VueUse 官方文档也明确说:"It's recommended to extract as separate components whenever possible."
六、实战场景
6.1 表单字段复用:标签 + 控件 + 错误提示
表单是 createReusableTemplate 最经典的舞台。一个统一的字段壳子,配合插槽传入控件本体:
vue
<script setup lang="ts">
import { reactive } from 'vue'
import { createReusableTemplate } from '@vueuse/core'
const form = reactive({ username: '', email: '', age: 18 })
const errors = reactive<Record<string, string>>({})
const [DefineField, ReuseField] = createReusableTemplate<{
label: string
required?: boolean
error?: string
}>()
</script>
<template>
<DefineField v-slot="{ label, required, error, $slots }">
<div class="field" :class="{ 'field--error': error }">
<label class="field__label">
{{ label }}
<span v-if="required" class="field__star">*</span>
</label>
<div class="field__control">
<component :is="$slots.default" />
</div>
<p v-if="error" class="field__error">{{ error }}</p>
</div>
</DefineField>
<form>
<ReuseField label="用户名" required :error="errors.username">
<input v-model="form.username" />
</ReuseField>
<ReuseField label="邮箱" required :error="errors.email">
<input v-model="form.email" type="email" />
</ReuseField>
<ReuseField label="年龄">
<input v-model.number="form.age" type="number" min="0" />
</ReuseField>
</form>
</template>
跟"抽 <FormField> 子组件"相比,这个写法的好处是:你不需要为每个控件类型再去想 v-model 的转发、不需要 emits,控件本体就在父级作用域里,怎么写都行。
6.2 表格列模板:多视图共用一套行渲染
后台经常有同一份数据要在表格、卡片、紧凑列表三种视图之间切换的需求:
vue
<script setup lang="ts">
import { ref } from 'vue'
import { createReusableTemplate } from '@vueuse/core'
interface Order {
id: string
customer: string
amount: number
status: 'paid' | 'pending' | 'failed'
}
const view = ref<'table' | 'card' | 'compact'>('table')
const orders = ref<Order[]>([/* ... */])
const [DefineRow, ReuseRow] = createReusableTemplate<{ order: Order }>()
</script>
<template>
<DefineRow v-slot="{ order }">
<span class="cell-id">#{{ order.id }}</span>
<span class="cell-customer">{{ order.customer }}</span>
<span class="cell-amount">¥{{ order.amount.toFixed(2) }}</span>
<Badge :type="order.status">{{ order.status }}</Badge>
</DefineRow>
<table v-if="view === 'table'">
<tr v-for="o in orders" :key="o.id">
<td><ReuseRow :order="o" /></td>
</tr>
</table>
<div v-else-if="view === 'card'" class="cards">
<article v-for="o in orders" :key="o.id" class="card">
<ReuseRow :order="o" />
</article>
</div>
<ul v-else class="compact">
<li v-for="o in orders" :key="o.id">
<ReuseRow :order="o" />
</li>
</ul>
</template>
行内容的渲染逻辑只写了一次,外层容器随视图切换。新增一个状态字段,改一处即可。
6.3 列表项的多状态展示
带有 loading / empty / error / data 四态的列表,几乎每个项目都要写。这种场景可以把"骨架"抽出来,让列表项的多态共用同一个外壳:
vue
<script setup lang="ts">
import { createReusableTemplate } from '@vueuse/core'
const [DefineWrap, ReuseWrap] = createReusableTemplate<{ title: string }>()
</script>
<template>
<DefineWrap v-slot="{ title, $slots }">
<section class="panel">
<header class="panel__header">{{ title }}</header>
<div class="panel__body">
<component :is="$slots.default" />
</div>
</section>
</DefineWrap>
<ReuseWrap title="加载中">
<Spinner />
</ReuseWrap>
<ReuseWrap title="加载失败">
<ErrorEmpty :reason="error" @retry="reload" />
</ReuseWrap>
<ReuseWrap title="暂无数据">
<EmptyState />
</ReuseWrap>
<ReuseWrap title="订单列表">
<OrderTable :data="orders" />
</ReuseWrap>
</template>
注意这里不是 用 v-if/v-else 做四选一------通常你应该这么做。但如果你的需求是同一个 dashboard 里几个面板都要套同一种壳子 ,再用 v-if 决定每个面板的内部状态,那 createReusableTemplate 就比抽 <Panel> 子组件简单得多。
6.4 骨架屏:内容与骨架共用结构
骨架屏最大的痛点是"骨架结构容易和真实内容跑偏"。让两边复用同一套骨架,骨架只是把每个原子换成占位符:
vue
<script setup lang="ts">
import { createReusableTemplate } from '@vueuse/core'
const [DefineLayout, ReuseLayout] = createReusableTemplate<{
isSkeleton?: boolean
}>()
</script>
<template>
<DefineLayout v-slot="{ isSkeleton }">
<article class="article">
<h2 v-if="!isSkeleton">{{ post.title }}</h2>
<SkeletonBlock v-else width="60%" height="32px" />
<div class="meta">
<template v-if="!isSkeleton">
<Avatar :src="post.author.avatar" />
<span>{{ post.author.name }}</span>
<time>{{ formatDate(post.publishedAt) }}</time>
</template>
<template v-else>
<SkeletonCircle size="32px" />
<SkeletonBlock width="80px" />
<SkeletonBlock width="60px" />
</template>
</div>
<p v-if="!isSkeleton">{{ post.excerpt }}</p>
<SkeletonParagraph v-else :lines="3" />
</article>
</DefineLayout>
<ReuseLayout v-if="loading" :is-skeleton="true" />
<ReuseLayout v-else />
</template>
骨架结构和真实内容的 DOM 层级、间距完全一致,加载完成的视觉跳动会减到最小。
6.5 弹窗里"嵌入式 vs 独立"两种形态
很多业务表单要支持"页内编辑"和"弹窗中编辑"两种入口,UI 完全一样,只是包了一层 <Dialog>。这是官方文档里给出的原始案例,再贴一个最贴近真实业务的版本:
vue
<script setup lang="ts">
import { ref } from 'vue'
import { createReusableTemplate } from '@vueuse/core'
const showDialog = ref(false)
const draft = ref({ title: '', content: '' })
const [DefineEditor, ReuseEditor] = createReusableTemplate()
</script>
<template>
<DefineEditor>
<div class="editor">
<input v-model="draft.title" placeholder="标题" />
<textarea v-model="draft.content" placeholder="正文" rows="10" />
<Toolbar @save="onSave" @publish="onPublish" />
</div>
</DefineEditor>
<!-- 主区域:嵌入式 -->
<main v-if="!showDialog">
<ReuseEditor />
</main>
<!-- 也可以弹窗里来一份,逻辑共用、状态共用 -->
<Dialog v-model="showDialog" title="快速编辑">
<ReuseEditor />
</Dialog>
</template>
一个细节值得注意:两处 <ReuseEditor /> 渲染出来的是同一份 VNode 结构 ,但绑定的 <input> 仍然是各自独立的 DOM。v-model 指向的 draft 是同一个,所以两个入口编辑同一份草稿。这种"逻辑共用、状态共用"的写法,原本得抽组件再传 props,现在零成本。
七、坑与解
7.1 boolean 属性必须 v-bind
这是官方明确列出的 Caveat。普通 Vue 组件里 <MyComp disabled /> 等价于 disabled="true",但在 <ReuseTemplate> 上不成立:
vue
<DefineFoo v-slot="{ value }">
{{ typeof value }}: {{ value }}
</DefineFoo>
<ReuseFoo :value="true" /> <!-- boolean: true ✅ -->
<ReuseFoo :value="false" /> <!-- boolean: false ✅ -->
<ReuseFoo value /> <!-- string: '' ❌ 不是 true -->
<ReuseFoo /> <!-- undefined: ❌ 也不是 false -->
原因是源码里 attrs 没经过 Vue props 系统的 boolean 归一化,<ReuseFoo value /> 拿到的是空字符串属性。记得对所有 boolean 字段统一用 :propName="true|false" ,或者通过 props 选项显式声明 boolean 类型走 props 走完整流程。
7.2 同一对组件不能定义多个不同模板
vue
<DefineTemplate>
<div>A</div>
</DefineTemplate>
<ReuseTemplate />
<!-- 后面再写一遍 DefineTemplate 会覆盖前一个 -->
<DefineTemplate>
<div>B</div>
</DefineTemplate>
<ReuseTemplate />
后来的 <DefineTemplate> 会把 render 覆盖掉,导致前面已经渲染的复用器在下一次更新时也跟着变。要复用多段不同模板,调用多次 createReusableTemplate,每段一对。
7.3 别在 <DefineTemplate> 之前 <ReuseTemplate>
vue
<!-- ❌ 开发模式直接抛错 -->
<ReuseTemplate />
<DefineTemplate>...</DefineTemplate>
按 Vue render 的顺序来看,render.value 还是 undefined,VueUse 内部会在非生产环境抛 [VueUse] Failed to find the definition of reusable template。生产环境为了体积考虑没抛错,但渲染结果是空的。写成"先 Define 后 Reuse"是硬约束。
7.4 不要跨组件复用
闭包里那个 render 是单 SFC 私有的。如果你需要跨组件,做法只有:
- 抽成正经的 Vue 组件;
- 或上社区方案
unplugin-vue-reuse-template,它通过编译插件实现"全局命名模板"的能力------本质和 VueUse 这个不是一回事,但能补上跨文件这块缺口。
7.5 滥用会让代码难读
VueUse 文档里有句很克制的提示:
Note: It's recommended to extract as separate components whenever possible. Abusing this function might lead to bad practices for your codebase.
createReusableTemplate 的合适场景是"小到不值得抽组件、又跑不掉重复"的灰色地带。如果你发现一个 SFC 里写了五六对 Define/Reuse、模板里还套了好多层 $slots,那就是该考虑拆组件了。它是补丁,不是架构。
八、版本要求与迁移要点
| 版本 | 关键变更 |
|---|---|
v14.3.0(2026-05) |
支持指定组件名 name,devtools 中更易识别(#5300) |
v14.0.0 |
要求 Vue 3.5+,部分构建产物迁移到 tsdown |
v13.6.0(2025-07) |
加 @__NO_SIDE_EFFECTS__ 注解,改善 tree-shaking |
v12.6.0(2025-02) |
新增 props 选项,支持显式 props 声明 |
v12.0.0(2024-11) |
不再支持 Vue 2 |
v10.8.0(2024-02) |
类型推导改进 |
如果你的项目还在 Vue 2,需要锁在 v9.x;现代化项目建议直接 v14.3.0,能用上 name 和显式 props。整体安装:
bash
npm i @vueuse/core
九、小结
createReusableTemplate 不是什么"必学"的高深特性,它解决的是一个非常具体、非常日常的小烦恼:同一个 SFC 里,三五行模板想复用,又不想为它专门抽个组件。
它的实现也朴素得让人意外------一个 shallowRef 抓住默认插槽函数,再在另一个组件里把它调用出来。源码就 30 行,背后是 Vue 渲染机制的灵活性。
什么时候用:
- 同一 SFC 内重复出现的 UI 片段;
- 需要直接访问父组件的 reactive 状态,又不想 props 透传;
- 一个组件要在嵌入式和弹窗式两种容器里出现;
- 真实内容和骨架屏需要保持结构对齐。
什么时候不用:
- 可以跨组件复用 → 抽组件;
- 业务逻辑足够复杂 → 抽组件;
- 同一组件里超过五对 Define/Reuse → 抽组件。
把它当作"组合式时代下的模板函数"就好。简单、轻量、够用------这是 VueUse 一贯的味道。
参考资料:
- VueUse
createReusableTemplate官方文档:vueuse.org/core/create... - 源码(GitHub):github.com/vueuse/vueu...
- VueUse
v14.0.0发布说明:github.com/vueuse/vueu... - 显式 props 提案(PR #4535):github.com/vueuse/vueu...
- Vue 官方 Discussion - Reusing Templates:github.com/vuejs/core/...
- 替代方案
unplugin-vue-reuse-template:github.com/liulinboyi/... - Vue Macros
namedTemplate:vue-macros.sxzz.moe/features/na...
本内容AI辅助生成