返回

Vue Ref 传递:如何阻止 Props 自动解包给子组件

vue.js

搞定 Vue Ref 传递:不让子组件拿到解包后的值

写 Vue 应用,特别是用 <script setup> 和 TypeScript 时,组件间传递数据是家常便饭。通常我们用 props 就行了。但有时会遇到一个有点绕的情况:你想把一个 ref 对象本身,而不是它内部的值,通过 prop 传给子组件。

比如,你可能有个 fabric.js 的 Canvas 实例,用 ref 包裹着:const c = ref<Canvas>(theCanvasObject)。然后你想把它传给子组件,并且子组件期望的 prop 类型就是 Ref<Canvas>

// ChildComponent.vue
import type { Ref } from 'vue';
import type { Canvas } from 'fabric'; // 假设从 fabric 库导入

const props = defineProps<{
  canvas: Ref<Canvas>
}>()

// 子组件期望直接操作这个 Ref<Canvas> 对象
watch(props.canvas, (newCanvasInstance) => {
  // ... 当父组件的 Canvas 对象实例变化时做点什么
  // 注意:这里直接拿到的是 Canvas 对象本身,因为 watch 会自动解包 ref
  // 但重点是 prop 传入时希望是 Ref<Canvas> 类型
});

你在父组件里尝试这样传递:

<!-- ParentComponent.vue -->
<template>
  <ChildComponent :canvas="c" />
</template>

<script setup lang="ts">
import { ref } from 'vue';
import type { Canvas } from 'fabric';
import ChildComponent from './ChildComponent.vue';

// 假设 theCanvasObject 是已经创建好的 fabric Canvas 实例
declare const theCanvasObject: Canvas;
const c = ref<Canvas>(theCanvasObject);
</script>

一运行,很可能 TypeScript 就给你报错了,提示 Type 'Canvas' is not assignable to type 'Ref<Canvas>'。或者类似的意思,总之就是类型对不上。就算你试了 toRef, shallowRef 这些,好像也没用。

这是怎么回事呢?

一、问题根源:Vue 的模板自动解包

这背后的“罪魁祸首”,其实是 Vue 为了方便开发者,在模板(template)中做的一个“智能”行为:自动解包 (unwrap) ref

当你在模板里通过 v-bind(或者简写 :)把一个 ref 对象绑定给子组件的 prop 时,比如 :canvas="c",Vue 会检查到 c 是一个 ref。为了让你在子组件里通常能更直接地使用值,Vue 不会把 c 这个 ref 对象本身传过去,而是把它内部的值,也就是 c.value,传递给了 canvas 这个 prop。

所以,虽然父组件里 c 的类型是 Ref<Canvas>,但经过模板传递这一步,子组件实际接收到的 props.canvas 的值变成了 c.value,它的类型自然就是 Canvas 了。

这就跟你子组件 defineProps 里定义的 canvas: Ref<Canvas> 对不上了。子组件期待一个装着 Canvas 的盒子 (Ref<Canvas>),结果只收到了盒子里的东西 (Canvas)。TypeScript 很严格,一看类型不匹配,自然就报警了。

知道原因就好办了。我们得想办法绕过 Vue 在模板里的这个自动解包行为。

二、解决方案

有几种方法可以实现把 ref 对象本身传过去。

方案一:给 Ref 套个“马甲”(对象包裹)

这是最直接的思路:既然 Vue 只自动解包顶层的 ref,那我们不把 ref 直接放在顶层传递不就行了?把它包在另一个普通对象里面,再把这个外层对象传过去。

1. 原理

Vue 的模板解包只针对绑定表达式的结果本身是 ref 的情况。如果你传递的是一个普通对象,就算这个对象里面某个属性的值是 ref,Vue 也不会深入进去解包那个内部的 ref

2. 操作步骤

父组件 (ParentComponent.vue):

<template>
  <!-- 传递包含 ref 的外层对象 -->
  <ChildComponent :canvas-wrapper="canvasWrapper" />
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue'; // 可以用 reactive 或普通对象
import type { Canvas } from 'fabric';
import ChildComponent from './ChildComponent.vue';

declare const theCanvasObject: Canvas;
const c = ref<Canvas>(theCanvasObject);

// 方式一:使用 reactive 包裹 (如果外层对象本身也需要响应性)
const canvasWrapper = reactive({
  canvasRef: c // 把 ref 放在对象的一个属性里
});

// 方式二:使用普通对象包裹 (如果只是为了传递 ref)
// const canvasWrapper = {
//   canvasRef: c
// };

// 注意:传递给子组件的 prop 名变成了 canvas-wrapper (或你起的名字)
</script>

子组件 (ChildComponent.vue):

import type { Ref } from 'vue';
import type { Canvas } from 'fabric';

// props 定义需要改变,接收那个外层对象
const props = defineProps<{
  canvasWrapper: { // 对应父组件传过来的 prop 名
    canvasRef: Ref<Canvas> // 明确指出这个属性的类型是 Ref<Canvas>
  }
}>()

// 在子组件内部,需要通过外层对象访问到 ref
const actualCanvasRef = props.canvasWrapper.canvasRef;

// 现在可以对 actualCanvasRef 进行操作了,它就是父组件传来的那个 ref 对象
console.log('Received ref object:', actualCanvasRef);
console.log('Accessing .value inside child:', actualCanvasRef.value);

watch(actualCanvasRef, (newCanvasInstance) => {
  // watch 仍然能正常工作
  console.log('Canvas ref changed in parent, detected in child:', newCanvasInstance);
});

// 如果你想在模板中使用 canvas 的值,可能需要解包
// const canvasValue = computed(() => actualCanvasRef.value);

3. 代码解释

  • 在父组件,我们创建了一个 canvasWrapper 对象(可以是 reactive 的,也可以是普通 JS 对象),把原始的 ref 对象 c 作为这个 canvasWrapper 的一个属性(比如叫 canvasRef)。
  • 传递给子组件的是 canvasWrapper 这个整体对象,而不是 c 本身。
  • 子组件的 defineProps 现在定义的是接收 canvasWrapper 这个对象,并且明确指出了它内部 canvasRef 属性的类型是 Ref<Canvas>
  • 子组件内部通过 props.canvasWrapper.canvasRef 就能拿到那个未被解包的 ref 对象了。

4. 额外提醒

  • 这种方法稍微增加了点复杂度,因为父子组件都要知道这个“包裹约定”。
  • 给属性命名时清晰一点,比如用 xxxRefxxxContainer,避免混淆。

方案二:provide / inject —— 跨层传递的“官方通道”

如果你不想为了传递 ref 而改变 props 的结构,或者你需要把这个 ref 传递给更深层的后代组件,那么 provideinject 是个更优雅的选择。

1. 原理

provideinject 是 Vue 提供的一种依赖注入机制。父组件(或祖先组件)可以通过 provide 提供数据或方法,任何后代组件都可以通过 inject 来接收。关键在于,provide 传递数据时,不会进行 ref 自动解包 。你 provide 什么,inject 就能拿到什么。

2. 操作步骤

父组件 (ParentComponent.vue):

<template>
  <!-- provide 是在 setup 中调用的,模板不需要特殊处理 -->
  <ChildComponent />
  <!-- 甚至可以传给更深层的孙子组件 -->
  <!-- <SomeOtherComponent> <GrandChildComponent /> </SomeOtherComponent> -->
</template>

<script setup lang="ts">
import { ref, provide } from 'vue';
import type { Ref, InjectionKey } from 'vue';
import type { Canvas } from 'fabric';
import ChildComponent from './ChildComponent.vue';

declare const theCanvasObject: Canvas;
const c = ref<Canvas>(theCanvasObject);

// 1. 定义一个 InjectionKey (推荐,提供类型安全)
// Symbol() 确保 key 的唯一性
export const canvasInjectionKey: InjectionKey<Ref<Canvas>> = Symbol('fabricCanvas');

// 2. 使用 provide 提供 ref 对象
provide(canvasInjectionKey, c);

// 如果不用 InjectionKey,也可以用字符串 key,但不推荐在 TS 项目中这么做
// provide('canvasRef', c);
</script>

子组件 (ChildComponent.vue):

import { inject } from 'vue';
import type { Ref } from 'vue';
import type { Canvas } from 'fabric';
// 导入父组件定义的 InjectionKey
import { canvasInjectionKey } from './ParentComponent.vue'; // 或者放到一个公共文件

// 子组件不再需要通过 props 接收
// const props = defineProps<{ ... }>()

// 使用 inject 获取 provide 的值
// TSLint:disable-next-line:whitespace
const injectedCanvasRef = inject(canvasInjectionKey);

// 安全检查:确保 inject 成功获取到了值
if (!injectedCanvasRef) {
  throw new Error('Canvas ref was not provided by parent component.');
}

// 现在 injectedCanvasRef 就是父组件提供的那个 Ref<Canvas> 对象
console.log('Injected ref object:', injectedCanvasRef);
console.log('Accessing .value from injected ref:', injectedCanvasRef.value);

watch(injectedCanvasRef, (newCanvasInstance) => {
  console.log('Provided canvas ref changed, detected via inject:', newCanvasInstance);
});

// 可选:提供一个默认值,以防父组件没有 provide
// const injectedCanvasRefOrDefault = inject(canvasInjectionKey, ref<Canvas | null>(null));

3. 代码解释

  • 我们在父组件使用 provide 方法。为了类型安全和避免 key 冲突,强烈推荐创建一个 InjectionKey(通常用 Symbol 实现)。然后用这个 key 和要传递的 ref 对象 c 调用 provide(key, c)
  • 子组件(或任何后代组件)使用 inject 方法,传入相同的 InjectionKey 来获取数据。inject 会沿着组件树向上查找,找到最近的那个提供了这个 key 的祖先组件,并返回其提供的值。
  • 因为 provide 不解包,所以 inject 拿到的 injectedCanvasRef 就是父组件里的 c 这个 Ref<Canvas> 对象本身。
  • 注意:provideinject 的调用必须在组件的 setup 函数或 <script setup> 的顶层作用域内。

4. 进阶使用与安全建议

  • 类型安全: 务必使用 InjectionKey<T>。它能帮助 TypeScript 理解 inject 返回值的类型,并在 provideinject 类型不匹配时给出提示。
  • 默认值与错误处理: inject 可以接受第二个参数作为默认值,当找不到对应的 provide 时会使用这个默认值。或者,你可以像示例中那样检查 inject 的返回值是否为 undefined 并抛出错误,确保依赖总是存在的。
  • 适用场景: provide/inject 特别适合传递那些“全局性”或跨越多层组件的依赖,比如主题配置、用户信息、或者像这种共享的画布实例引用。它能避免一层层地透传 props (prop drilling)。

为什么 toRef, shallowRef 这些可能不行?

简单提一下为什么你之前尝试的 toRef, reactive, shallowRef 可能没直接解决这个问题:

  • toRef(obj, 'key'): 这是用来从一个响应式对象(比如 reactive 创建的)中,为某个属性创建一个 ref。它创建的是新的 ref,跟我们“传递现有 ref 不被解包”的需求关系不大。
  • reactive(refObj): 对一个已经是 ref 的对象使用 reactive,通常会直接返回那个 ref 本身,并不会阻止它在模板中被解包。
  • shallowRef(value): 它创建的 ref,只有 .value 的赋值是响应式的,内部嵌套对象的属性变化不会触发更新。虽然它改变了响应性的行为,但在模板中传递时,Vue 依然会解包这个顶层的 shallowRef,把它的 .value 传给子组件。

总结

当你的目标是把一个 Ref<T> 对象本身作为 prop 传递给子组件,而不是它内部的值 T 时,核心是要绕开 Vue 在模板中自动解包 ref 的行为。

两个主要策略:

  1. 对象包裹:ref 放入一个普通对象或 reactive 对象的属性中,然后把这个外层对象作为 prop 传递。子组件接收这个外层对象,再从中取出 ref
  2. provide / inject: 使用 Vue 的依赖注入机制。父组件 provide(key, refObject),子组件 inject(key)。这种方式更适合跨层传递,且能保持 props 定义的简洁。推荐配合 InjectionKey 使用以获得类型安全。

根据你的具体场景和组件层级关系,选择一个觉得更清晰、更合适的方法就好。通常,对于直接父子关系,两种方法都行;对于跨越多层的传递,provide/inject 往往是更好的选择。