VueUse createReusableTemplate —— 单文件组件内的模板复用神器

一、痛点:那些"抽组件不值,复制又恶心"的模板

写 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>

注意三个细节:

  1. <DefineField>v-slot="{ ... }" 解构出参数,这些参数实际上是 <ReuseField> 上传入的 props/attrs
  2. $slots 也会作为参数传入------当复用区域需要再次塞进自定义内容时,用 <component :is="$slots.default" /> 把默认插槽渲染出来即可;
  3. <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> 出现,否则 reuserender.value 还没赋值,开发模式会直接抛错。

5. 跨 SFC 复用做不到。 rendercreateReusableTemplate 闭包内的局部变量,只有在同一个 <script setup> 的同一组组件里才能共享。要"全局可复用",VueUse 官方推荐换条思路------直接抽成组件,或者使用社区方案 unplugin-vue-reuse-template

理解了这 30 行,再去看作用域插槽、v-slotrender 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 一贯的味道。


参考资料:


本内容AI辅助生成

相关推荐
程序员小富1 小时前
我开源了一个开发者专属的智能 JSON 工具,得到了媳妇高度认可
前端·vue.js·后端
小小小小宇1 小时前
程序员如何给 LLM 装工具以及看懂推理过程
前端
写代码的皮筏艇1 小时前
React中的forwardRef
前端·react.js·面试
槑有老呆1 小时前
花三个月工资请了个 AI 程序员,结果它连青岛啤酒股价都查不了
前端
风骏时光牛马1 小时前
Verilog开发常见问题汇总解析
前端
子兮曰1 小时前
AI Coding Method Map:一张图看懂 AI 编程的完整链路
前端·人工智能·后端
weedsfly2 小时前
语法糖褪去之后——Babel 转译产物中的 JavaScript 本貌
前端·javascript
JustHappy2 小时前
「软件设计思想杂谈🤔」“切图仔”也能懂编译原理?框架源码也许没那么难。聊聊 Vue 的编译(上)
前端·javascript·vue.js