侧边栏壁纸
博主头像
Setyg的博客

当秋风染红落叶的时候,就是新生~

  • 累计撰写 86 篇文章
  • 累计创建 66 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录
Vue

Vue 2 / Vue 3 父子组件传值方法总结

Setyg
2026-09-14 / 0 评论 / 0 点赞 / 11 阅读 / 0 字

Vue 的组件化开发中,父子组件之间如何传递数据是最基础、也最常被问到的问题。Vue 2 与 Vue 3 在这一点上既有共通之处,也有不少差异。本文把两代版本中常用的传值方式整理成一份对照速查,每种方式都给出可直接参考的代码。

阅读约定:下文代码中的「父组件」指数据的持有方或调用方,「子组件」指被引入、被渲染的一方。

目录

  1. Props 传值(父 → 子)

  2. 事件传值(子 → 父)

  3. 双向绑定(v-model)

  4. 父组件访问子组件(ref)

  5. provide / inject 依赖注入

  6. slot 插槽

  7. Vue 2 与 Vue 3 的差异对照

  8. 最佳实践

一、Props 传值(父 → 子)

Props 是单向数据流的基础:父组件通过属性把数据「送下去」,子组件只读不写。这是最常用、也是最推荐的方式。

1.1 父组件

<template>
  <div>
    <ChildComponent
      :message="parentMessage"
      :user="userInfo"
      :count="123"
    />
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue'

export default {
  components: { ChildComponent },
  data() {
    return {
      parentMessage: 'Hello from Parent',
      userInfo: { name: 'John', age: 25 }
    }
  }
}
</script>

1.2 子组件

子组件必须显式声明接收哪些 props。声明时写清类型与默认值,既能让代码自解释,也能在传错类型时得到开发环境警告:

<template>
  <div>
    <p>Message: {{ message }}</p>
    <p>User: {{ user.name }}</p>
    <p>Count: {{ count }}</p>
  </div>
</template>

<script>
export default {
  props: {
    message: {
      type: String,
      required: true
    },
    user: {
      type: Object,
      // 对象/数组类型的默认值必须用工厂函数返回
      default: () => ({})
    },
    count: {
      type: Number,
      default: 0
    }
  }
}
</script>

一个高频坑:对象和数组的 default 必须写成 () => ({}) 这样的工厂函数。如果直接写 default: {},所有子组件实例会共用同一个对象引用,一个实例改了,其他实例跟着变。

1.3 更完整的 Props 声明(Vue 3)

Vue 3 保留了对象式写法,同时可以借助 PropType 补上 TypeScript 类型推导:

<script>
import { PropType } from 'vue'

export default {
  props: {
    // 基础类型
    message: String,

    // 允许多种类型
    value: [String, Number],

    // 自定义对象类型
    user: {
      type: Object as PropType<{
        name: string
        age: number
      }>,
      required: true
    },

    // 函数类型
    callback: {
      type: Function as PropType<(id: number) => void>,
      default: () => {}
    },

    // 自定义校验
    age: {
      type: Number,
      validator: (value) => value >= 0 && value <= 150
    }
  }
}
</script>

1.4 使用 <script setup> 的写法

Composition API 下用 defineProps 声明,运行时行为与上面的对象式写法完全等价:

<script setup>
const props = defineProps({
  message: {
    type: String,
    required: true
  },
  count: {
    type: Number,
    default: 0
  }
})
</script>

<template>
  <p>{{ props.message }} - {{ props.count }}</p>
</template>

这里有一个容易混淆的点:props 是只读的。如果子组件需要「修改」传入的值,正确做法是把它复制到本地状态,或通过事件让父组件去改,而不是直接赋值——直接改会在开发环境报错,且破坏了单向数据流的可预测性。

二、事件传值(子 → 父)

数据要往上传,靠的是自定义事件:子组件 $emit 抛出一个事件,父组件在模板上用 @事件名 监听。

2.1 子组件

<template>
  <div>
    <button @click="sendMessage">发送消息给父组件</button>
    <button @click="sendUser">发送用户数据</button>
  </div>
</template>

<script>
export default {
  methods: {
    sendMessage() {
      this.$emit('message', 'Hello from Child')
    },

    sendUser() {
      this.$emit('update:user', {
        name: 'Child User',
        age: 18
      })
    }
  }
}
</script>

2.2 父组件

<template>
  <div>
    <ChildComponent
      @message="handleMessage"
      @update:user="handleUserUpdate"
    />

    <p>Received: {{ receivedMessage }}</p>
    <p>User: {{ userInfo }}</p>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue'

export default {
  components: { ChildComponent },
  data() {
    return {
      receivedMessage: '',
      userInfo: {}
    }
  },
  methods: {
    handleMessage(msg) {
      this.receivedMessage = msg
    },

    handleUserUpdate(user) {
      this.userInfo = user
    }
  }
}
</script>

2.3 Vue 3 中用 defineEmits 显式声明

Vue 3 推荐把组件会抛出的事件显式列出来。好处是事件会被记入组件契约,父组件拼错事件名时能有提示,同时 emits 里声明过的事件名不会再被当作原生 DOM 事件透传到根元素上:

<script setup>
const emit = defineEmits({
  // 带校验的写法
  submit: (payload) => {
    if (!payload || !payload.id) {
      console.warn('submit 事件需要携带 id')
      return false
    }
    return true
  },
  // 只声明不校验
  cancel: null
})

function onSubmit() {
  emit('submit', { id: 1, name: 'demo' })
}
</script>

<template>
  <button @click="onSubmit">提交</button>
  <button @click="emit('cancel')">取消</button>
</template>

三、双向绑定(v-model)

v-model 本质上是「props 传值 + 事件回传」的语法糖,把上面两节的内容合成了一个指令。但两代版本的默认约定不同,这是从 Vue 2 迁移到 Vue 3 时最容易踩的点。

3.1 Vue 2 的实现

Vue 2 的约定是固定的:prop 名必须是 value,事件名必须是 input,不能改。

<!-- 父组件 -->
<template>
  <div>
    <ChildComponent v-model="message" />
    <p>父组件显示: {{ message }}</p>
  </div>
</template>
<!-- 子组件 -->
<template>
  <div>
    <input
      :value="value"
      @input="$emit('input', $event.target.value)"
    />
  </div>
</template>

<script>
export default {
  props: ['value']
}
</script>

3.2 Vue 3 的实现

Vue 3 把约定改成了 modelValue + update:modelValue,并且允许通过参数自定义名称,从而支持多个 v-model:

<!-- 父组件 -->
<template>
  <ChildComponent v-model="message" />
  <p>父组件显示: {{ message }}</p>
</template>
<!-- 子组件 -->
<template>
  <div>
    <input v-model="internalValue" />
  </div>
</template>

<script>
import { ref, watch } from 'vue'

export default {
  props: {
    modelValue: {
      type: String,
      default: ''
    }
  },

  emits: ['update:modelValue'],

  setup(props, { emit }) {
    const internalValue = ref(props.modelValue)

    // 内部变化 → 通知父组件
    watch(internalValue, (newValue) => {
      emit('update:modelValue', newValue)
    })

    // 外部变化 → 同步到内部
    watch(() => props.modelValue, (newValue) => {
      internalValue.value = newValue
    })

    return { internalValue }
  }
}
</script>

3.3 更简洁的写法:可写计算属性

上面两个 watch 来回同步显得啰嗦。实践中更常见的做法是用 computed 的 getter/setter 一步到位:

<script setup>
import { computed } from 'vue'

const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  }
})

const emit = defineEmits(['update:modelValue'])

const internalValue = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value)
})
</script>

<template>
  <input v-model="internalValue" />
</template>

3.4 多个 v-model

<!-- 父组件:同时绑定标题与正文 -->
<template>
  <ArticleEditor
    v-model:title="pageTitle"
    v-model:content="pageContent"
  />
</template>

<!-- 子组件:接收 title / content 两个 prop -->
<script setup>
defineProps({
  title: { type: String, default: '' },
  content: { type: String, default: '' }
})

const emit = defineEmits(['update:title', 'update:content'])
</script>

四、父组件访问子组件(ref)

前面几种方式都是「数据」的流动。还有一种场景是父组件需要主动调用子组件的方法,比如让弹窗打开、让表单重置。这时用模板 ref 拿到子组件实例。

4.1 Vue 2 写法

<!-- 父组件 -->
<template>
  <div>
    <ChildComponent ref="child" />
    <button @click="openChild">打开子组件</button>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue'

export default {
  components: { ChildComponent },
  methods: {
    openChild() {
      // 直接调用子组件暴露的方法
      this.$refs.child.open()
    }
  }
}
</script>

4.2 Vue 3 写法

Vue 3 中必须先声明一个与 ref 同名的变量,实例才会被赋进去:

<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

const childRef = ref(null)

function openChild() {
  childRef.value.open()
}
</script>

<template>
  <ChildComponent ref="childRef" />
  <button @click="openChild">打开子组件</button>
</template>

注意:<script setup> 的组件默认是封闭的,父组件通过 ref 拿不到任何东西。子组件需要显式用 defineExpose 声明哪些方法可以被外部调用。这个限制是有意为之——它让组件的公开接口变得明确,而不是把内部实现全部暴露出去。

<!-- 子组件:只暴露 open 方法 -->
<script setup>
function open() {
  // ...
}

defineExpose({ open })
</script>

五、provide / inject 依赖注入

当层级很深时(祖父 → 父 → 子 → 孙),用 props 一层层往下传会变成「prop 逐级透传」,中间那些组件明明不需要这个数据却被迫声明。provide / inject 用来解决这个问题:祖先组件提供数据,任意深度的后代组件直接注入使用。

5.1 基础用法

<!-- 祖先组件 -->
<script>
import ChildComponent from './ChildComponent.vue'

export default {
  components: { ChildComponent },
  data() {
    return {
      theme: 'dark',
      user: { name: 'Admin', role: 'admin' }
    }
  },
  provide() {
    return {
      theme: this.theme,
      user: this.user,
      updateUser: this.updateUser
    }
  },
  methods: {
    updateUser(name) {
      this.user.name = name
    }
  }
}
</script>
<!-- 后代组件 -->
<template>
  <div :class="theme">
    <p>Theme: {{ theme }}</p>
    <p>User: {{ user.name }}</p>
    <button @click="changeName">改名</button>
  </div>
</template>

<script>
export default {
  inject: ['theme', 'user', 'updateUser'],
  methods: {
    changeName() {
      // 注入的是一个函数引用,调用它会修改祖先组件的状态
      this.updateUser('New Name')
    }
  }
}
</script>

关键点:provide 是「一次性」的。provide() { return { theme: this.theme } } 传下去的是当时那个值,不是响应式的引用。祖先组件后来改了 theme,后代拿到的还是旧值。这是 provide/inject 最常见的理解偏差。

5.2 传递响应式数据(Vue 3)

要让注入的数据保持响应式,就要提供一个响应式对象本身,而不是它的当前值:

<!-- 祖先组件 -->
<script setup>
import { provide, ref, reactive } from 'vue'

const theme = ref('light')
const user = reactive({ name: 'John', age: 30 })

// 提供 ref / reactive 对象本身
provide('theme', theme)
provide('user', user)

function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}
</script>
<!-- 后代组件 -->
<script setup>
import { inject, watch } from 'vue'

// 第二个参数是默认值,祖先没有 provide 时使用
const theme = inject('theme', ref('light'))
const user = inject('user', reactive({ name: 'Guest' }))

watch(theme, (newValue) => {
  console.log('主题变了:', newValue)
})
</script>

<template>
  <p>当前主题: {{ theme }}</p>
</template>

5.3 用 Symbol 作为 key

provide/inject 的 key 是字符串,大项目里容易重名或拼错,而且拼错时不会报错,只是静默地拿到 undefined。用 Symbol 可以避免这个问题,代价是需要把 key 抽到一个共享文件里:

// keys.js
export const THEME_KEY = Symbol('theme')
// 提供方
import { THEME_KEY } from './keys'
provide(THEME_KEY, theme)

// 注入方
import { THEME_KEY } from './keys'
const theme = inject(THEME_KEY)

六、slot 插槽

插槽传递的不是数据,而是「内容」——父组件决定渲染什么,子组件决定渲染在哪里。它让组件具备了布局能力而不必知道具体内容。

6.1 默认插槽

<!-- 父组件 -->
<template>
  <Card>
    <h3>卡片标题</h3>
    <p>这是卡片内容</p>
  </Card>
</template>
<!-- 子组件 Card.vue -->
<template>
  <div class="card">
    <slot></slot>
  </div>
</template>

6.2 具名插槽

需要多个插入点时用 name 区分。注意写法在 Vue 2.6+ 与 Vue 3 中统一成了 v-slot / #,老教程里的 slot="header" 已废弃。

<!-- 父组件 -->
<template>
  <Layout>
    <template #header>
      <h1>页面头部</h1>
    </template>

    <template #main>
      <p>主要内容</p>
    </template>

    <template #footer>
      <p>页脚</p>
    </template>
  </Layout>
</template>
<!-- 子组件 Layout.vue -->
<template>
  <div class="layout">
    <header class="header">
      <slot name="header"></slot>
    </header>

    <main class="main">
      <slot name="main"></slot>
    </main>

    <footer class="footer">
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

6.3 作用域插槽:数据从子流向父

这是插槽里唯一带「传值」意味的形式,方向恰好和 props 相反:子组件把内部数据绑定到 <slot> 上,父组件通过 v-slot 的解构拿到它。典型用途是列表组件——组件负责遍历和逻辑,父组件负责决定每一项长什么样。

<!-- 父组件:拿到子组件内部的 item 和 index -->
<template>
  <List :items="users">
    <template #default="{ item, index }">
      <div>
        <strong>{{ item.name }}</strong>
        <span>{{ index }}</span>
      </div>
    </template>
  </List>
</template>

<script>
import List from './List.vue'

export default {
  components: { List },
  data() {
    return {
      users: [
        { id: 1, name: 'John' },
        { id: 2, name: 'Jane' }
      ]
    }
  }
}
</script>
<!-- 子组件 List.vue:把数据「抛」给插槽 -->
<template>
  <div>
    <div v-for="(item, index) in items" :key="item.id">
      <slot :item="item" :index="index"></slot>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    items: {
      type: Array,
      default: () => []
    }
  }
}
</script>

七、Vue 2 与 Vue 3 的差异对照

传值方式

方向

Vue 2 写法

Vue 3 写法

Props

父 → 子

props: { msg: String }

defineProps({ msg: String })

自定义事件

子 → 父

this.$emit('msg', data)

const emit = defineEmits(['msg'])

v-model

双向

固定 value + input

默认 modelValue + update:modelValue,可自定义名称

多个 v-model

双向

.sync 修饰符

v-model:title 原生支持

访问子组件实例

父 → 子

this.$refs.child 直接可访问全部

ref + 子组件 defineExpose

跨层级注入

祖先 → 后代

provide() / inject: []

provide() / inject(),建议配 Symbol key

内容分发

父 → 子

slot / slot-scope

统一 v-slot / #

作用域插槽

子 → 父

slot-scope="props"

#default="{ item }"

两个需要特别留意的迁移点

  1. v-model 的默认值变了。Vue 2 的 value 在 Vue 3 中改为 modelValue。如果子组件里还写着 props: ['value'],父组件的 v-model 会静默失效——不报错,只是不生效。

  2. Vue 3 中 .sync 被移除。原来的 :title.sync="x" 要改成 v-model:title="x"。

八、最佳实践

8.1 先选对方式,再写代码

绝大多数「传值」问题其实在选型阶段就决定了成败。按这个顺序判断即可:

  1. 父子之间传数据 → props 向下,事件向上。这是默认选择,不要一上来就用 provide/inject 或全局状态。

  2. 需要「双向」的表单类交互 → v-model。它是 props + 事件的语法糖,语义清晰,代码也短。

  3. 父组件要触发子组件的动作 → ref + defineExpose。注意这属于「命令式」调用,能不用就不用,优先考虑用 props 驱动状态。

  4. 跨三四层以上传递,且中间层完全不关心 → provide/inject。

  5. 无关层级的组件之间要共享状态 → 都不合适,该上状态管理(Pinia / Vuex)。

8.2 props 设计:少而明确

// 好:每个 prop 都有独立语义,类型和默认值清楚
props: {
  title: { type: String, required: true },
  size: {
    type: String,
    default: 'medium',
    validator: (value) => ['small', 'medium', 'large'].includes(value)
  }
}

// 差:把所有配置塞进一个大对象
// props: { config: { type: Object } }

用一个大对象承载所有配置,看起来省事,实际上让组件的接口变得不可发现——调用方必须翻源码才知道能传什么,也无法利用类型检查、默认值和校验器。

8.3 事件命名用 kebab-case

// 推荐:模板里可以用 @update-user 监听
this.$emit('update-user', userData)
this.$emit('item-selected', item)

// 不推荐:驼峰命名在模板中需要写成 @updateUser,
// 而 HTML 属性本身不区分大小写,容易在迁移或重构时出错
// this.$emit('updateUser', userData)

8.4 不要直接修改 props

<script setup>
const props = defineProps({ count: Number })

// 错误:props 是只读的
// props.count++

// 正确做法一:需要独立的本地状态时,复制一份
import { ref, watch } from 'vue'
const localCount = ref(props.count)
watch(() => props.count, (value) => { localCount.value = value })
</script>

如果子组件确实需要改这个值,说明这个状态的所有权应该在父组件——改成 v-model,或者抛一个事件让父组件去改。

8.5 列表渲染必须带 key

<template>
  <!-- 推荐:用数据自身的唯一标识 -->
  <div v-for="item in items" :key="item.id">{{ item.name }}</div>

  <!-- 避免:用 index 作为 key -->
  <!-- <div v-for="(item, index) in items" :key="index"> -->
</template>

用 index 作 key 时,一旦列表发生插入、删除或排序,Vue 会复用错节点,导致输入框内容错位、组件状态串台等难以排查的问题。

九、小结

  1. 父传子用 props,子传父用事件,双向用 v-model。这三条覆盖了绝大部分场景。

  2. v-model 在两代之间的约定不同:Vue 2 是 value + input,Vue 3 是 modelValue + update:modelValue。迁移时这里最容易静默失效。

  3. provide/inject 传的是值不是引用。要让它保持响应式,必须 provide 响应式对象本身。

  4. 作用域插槽是唯一「子 → 父」的插槽形式,用于让父组件决定渲染内容、子组件提供数据。

  5. Vue 3 的 <script setup> 默认封闭,父组件想通过 ref 调用子组件方法,子组件必须 defineExpose。

  6. props 永远只读。需要修改时,要么复制成本地状态,要么把所有权交还给父组件。

0
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin

评论区