Vue 核心语法与组件模式篇:Vue 组件通信全图 | props、emit、ref、provide-inject 全局状态

12 阅读17分钟

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。


前言

做 Vue 开发,组件通信是绕不开的核心话题。但凡你写过一个后台管理系统,就一定遇到过这些场景:

  • 父组件把「用户列表」传给子组件表格——props
  • 子组件的「删除按钮」点完了要通知父组件刷新——emit
  • 父组件需要直接调用子弹窗的 open() 方法——ref
  • 顶层布局把「当前用户信息」透传到深层组件——provide / inject
  • 多个页面共享「购物车数量」——全局状态(Pinia)

很多人写了好几年 Vue,这些通信方式都用过,但要说"什么场景该选哪个、混着用会出什么问题",可能还是有点模糊。

这篇文章的目标就是:用后台管理系统的真实场景,把五种通信方式一次讲透。

一、先看全景图:五种通信方式速览

通信方式方向典型场景适用层级
props父 → 子传数据、传配置父子
emit子 → 父子组件事件通知父组件父子
ref父 → 子(命令式)父组件直接调用子组件方法父子
provide / inject祖先 → 后代跨层级传递共享数据跨多层
全局状态(Pinia)任意 → 任意多页面/多组件共享状态全局

一句话记忆法:
props 往下传数据,emit 往上抛事件,ref 直接操作子组件,provide/inject 跨层穿透,Pinia 全局兜底。

二、props:父传子的标准姿势

2.1 场景:用户管理页 → 用户表格组件

后台管理系统里最常见的结构:页面负责请求数据,表格组件只负责渲染。

父组件 UserManage.vue

<template>
  <div class="user-manage">
    <h2>用户管理</h2>
    <!-- 把用户列表和加载状态通过 props 传给子组件 -->
    <UserTable :user-list="userList" :loading="loading" />
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import UserTable from './UserTable.vue'

const userList = ref([])
const loading = ref(false)

onMounted(async () => {
  loading.value = true
  // 模拟接口请求
  const res = await fetch('/api/users')
  userList.value = await res.json()
  loading.value = false
})
</script>

子组件 UserTable.vue

<template>
  <el-table :data="userList" v-loading="loading">
    <el-table-column prop="name" label="姓名" />
    <el-table-column prop="email" label="邮箱" />
    <el-table-column prop="role" label="角色" />
  </el-table>
</template>

<script setup>
// defineProps 声明接收的 props
const props = defineProps({
  userList: {
    type: Array,
    default: () => []
  },
  loading: {
    type: Boolean,
    default: false
  }
})
</script>

2.2 关键规范与踩坑

规范一:props 命名用 camelCase,模板里用 kebab-case

<!-- 模板里用 kebab-case -->
<UserTable :user-list="userList" />

<!-- script 里用 camelCase -->
defineProps({ userList: Array })

这不是"可以这么做",而是 Vue 官方推荐的约定。因为 HTML attribute 本身是大小写不敏感的,userListuserlist 对 HTML 来说没区别,用 kebab-case 可以避免歧义。

规范二:永远不要在子组件里直接修改 props

<!-- ❌ 错误写法:直接修改 props -->
<script setup>
const props = defineProps({ userList: Array })

function deleteUser(index) {
  // 这行代码在开发环境会报警告,生产环境会产生难以追踪的 bug
  props.userList.splice(index, 1)
}
</script>

为什么不行?因为 Vue 的数据流设计是单向的:父 → 子。子组件偷偷改了 props,父组件完全不知道数据变了,后续如果父组件重新赋值 props,子组件的修改就会被覆盖,排查起来非常痛苦。

正确做法有两种:

  1. 通过 emit 通知父组件去改(推荐,下一节讲)
  2. 在子组件里用一个本地变量拷贝一份
<script setup>
const props = defineProps({ userList: Array })

// ✅ 如果确实需要本地修改,先拷贝一份
const localList = ref([...props.userList])
</script>

但注意,这种拷贝方式只适用于"子组件需要在本地做临时操作、不需要同步回父组件"的场景,比如表格的前端排序、筛选。

规范三:善用 props 的 validator

<script setup>
defineProps({
  status: {
    type: String,
    default: 'pending',
    // 限定只能传这几个值,传错了开发环境会报警告
    validator: (value) => ['pending', 'active', 'disabled'].includes(value)
  }
})
</script>

很多人知道 typedefault,但 validator 用得少。它在大型项目里特别有用——相当于给 props 加了一层运行时类型校验,比你写注释管用得多。

三、emit:子传父的标准姿势

3.1 场景:表格行的「删除按钮」通知父组件

接上面的例子,用户表格里有一个删除按钮,点了之后需要通知父组件去调接口。

子组件 UserTable.vue(增加删除功能)

<template>
  <el-table :data="userList" v-loading="loading">
    <el-table-column prop="name" label="姓名" />
    <el-table-column prop="email" label="邮箱" />
    <el-table-column label="操作">
      <template #default="{ row }">
        <el-button type="danger" size="small" @click="handleDelete(row)">
          删除
        </el-button>
      </template>
    </el-table-column>
  </el-table>
</template>

<script setup>
defineProps({
  userList: { type: Array, default: () => [] },
  loading: { type: Boolean, default: false }
})

// 声明这个组件会向外抛出的事件
const emit = defineEmits(['delete'])

function handleDelete(row) {
  // 抛出 delete 事件,附带被删除用户的数据
  emit('delete', row)
}
</script>

父组件 UserManage.vue(监听事件)

<template>
  <div class="user-manage">
    <h2>用户管理</h2>
    <!-- 监听子组件抛出的 delete 事件 -->
    <UserTable
      :user-list="userList"
      :loading="loading"
      @delete="onDeleteUser"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import UserTable from './UserTable.vue'

const userList = ref([])
const loading = ref(false)

async function onDeleteUser(user) {
  // 弹出确认框
  await ElMessageBox.confirm(`确定要删除用户「${user.name}」吗?`, '提示')
  // 调用接口
  await fetch(`/api/users/${user.id}`, { method: 'DELETE' })
  ElMessage.success('删除成功')
  // 重新加载列表
  loadUserList()
}

async function loadUserList() {
  loading.value = true
  const res = await fetch('/api/users')
  userList.value = await res.json()
  loading.value = false
}
</script>

3.2 关键规范与踩坑

规范一:一定要用 defineEmits 声明事件

<script setup>
// ✅ 声明之后,别人看这个组件一眼就知道它会抛哪些事件
const emit = defineEmits(['delete', 'edit', 'status-change'])

// ❌ 不声明也能用,但相当于写代码不写函数签名,维护的人会骂人
</script>

defineEmits 的作用类似于 TypeScript 的接口声明——它不是给机器看的,是给人看的。团队协作里,别人拿到你的组件,看 defineEmits 就知道有哪些事件可以监听。

规范二:事件命名用 kebab-case

<!-- ✅ 推荐 -->
<MyComponent @status-change="handleChange" />

<!-- ❌ 不推荐,虽然也能工作 -->
<MyComponent @statusChange="handleChange" />

原因同 props:HTML attribute 大小写不敏感,@statusChange@statuschange 对 HTML 来说一样。

踩坑:emit 回调里不要做"同步修改子组件 props 来源"的操作之后还依赖旧值

这是个稍微隐蔽的坑。看下面的代码:

<!-- 子组件 -->
<script setup>
const props = defineProps({ count: Number })
const emit = defineEmits(['update'])

function handleClick() {
  console.log('emit 前 count =', props.count) // 比如此时是 1
  emit('update', props.count + 1)
  // 注意:emit 之后,如果父组件在回调里同步修改了 count
  // 下一个 tick props.count 才会变成 2
  console.log('emit 后 count =', props.count) // 这里仍然是 1,不是 2!
}
</script>

这不是 bug,这是 Vue 的响应式更新机制——props 的变化是异步批量更新的。如果你在 emit 之后立刻需要用最新值,请用 nextTick 或在回调里处理。

四、ref:父组件直接操控子组件

4.1 场景:点击「新增用户」按钮,打开弹窗组件

后台管理系统里,弹窗(Dialog)通常封装成独立组件。父组件需要在某个按钮点击时「命令」弹窗组件打开,并传入初始数据。

子组件 UserFormDialog.vue

<template>
  <el-dialog v-model="visible" :title="isEdit ? '编辑用户' : '新增用户'" width="500px">
    <el-form :model="formData" label-width="80px">
      <el-form-item label="姓名">
        <el-input v-model="formData.name" />
      </el-form-item>
      <el-form-item label="邮箱">
        <el-input v-model="formData.email" />
      </el-form-item>
      <el-form-item label="角色">
        <el-select v-model="formData.role">
          <el-option label="管理员" value="admin" />
          <el-option label="普通用户" value="user" />
        </el-select>
      </el-form-item>
    </el-form>
    <template #footer>
      <el-button @click="visible = false">取消</el-button>
      <el-button type="primary" @click="handleSubmit">确定</el-button>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref, reactive } from 'vue'

const visible = ref(false)
const isEdit = ref(false)
const formData = reactive({ name: '', email: '', role: 'user' })

// 通过 defineExpose 暴露给父组件调用的方法
function open(data = null) {
  visible.value = true
  if (data) {
    isEdit.value = true
    Object.assign(formData, data)
  } else {
    isEdit.value = false
    Object.assign(formData, { name: '', email: '', role: 'user' })
  }
}

const emit = defineEmits(['success'])

async function handleSubmit() {
  const url = isEdit.value ? `/api/users/${formData.id}` : '/api/users'
  const method = isEdit.value ? 'PUT' : 'POST'
  await fetch(url, {
    method,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(formData)
  })
  visible.value = false
  emit('success')
}

// 关键:用 defineExpose 把 open 方法暴露出去
defineExpose({ open })
</script>

父组件 UserManage.vue(使用 ref 调用子组件方法)

<template>
  <div class="user-manage">
    <div class="header">
      <h2>用户管理</h2>
      <el-button type="primary" @click="handleAdd">新增用户</el-button>
    </div>
    <UserTable
      :user-list="userList"
      :loading="loading"
      @delete="onDeleteUser"
      @edit="onEditUser"
    />
    <!-- ref 绑定子组件实例 -->
    <UserFormDialog ref="formDialogRef" @success="loadUserList" />
  </div>
</template>

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

// 模板 ref:变量名必须和 template 里的 ref 值一致
const formDialogRef = ref(null)
const userList = ref([])
const loading = ref(false)

function handleAdd() {
  // 直接调用子组件暴露的 open 方法,不传参 = 新增模式
  formDialogRef.value.open()
}

function onEditUser(user) {
  // 传参 = 编辑模式
  formDialogRef.value.open(user)
}
</script>

4.2 关键规范与踩坑

规范一:<script setup> 下必须用 defineExpose 显式暴露

这是 Vue 3 <script setup> 的一个重要设计:默认情况下,子组件内部的所有变量和方法对父组件都是不可见的。

<script setup>
const visible = ref(false)
function open() { visible.value = true }

// 不写这行,父组件通过 ref 拿到的是一个"空对象"
defineExpose({ open })
</script>

这和 Vue 2 不同。Vue 2 里通过 this.$refs.xxx 可以访问子组件的所有东西,Vue 3 用 defineExpose 做了访问控制,更安全。

规范二:只暴露"方法",不暴露"数据"

<!-- ✅ 推荐:暴露操作接口 -->
defineExpose({ open, close, reset })

<!-- ❌ 不推荐:暴露内部数据 -->
defineExpose({ visible, formData, isEdit })

为什么?暴露数据等于让父组件可以直接修改子组件的内部状态,这和"直接改 props"一样,会让数据流变得混乱。

暴露方法是"遥控器",暴露数据是"把零件全拆开"。

踩坑:ref 值在 onMounted 之前是 null

<script setup>
const dialogRef = ref(null)

// ❌ 这里 dialogRef.value 是 null,子组件还没挂载
console.log(dialogRef.value) // null

onMounted(() => {
  // ✅ mounted 之后才有值
  console.log(dialogRef.value) // Proxy { open: ƒ }
})
</script>

如果你需要在创建时就调用子组件方法(比如某个异步操作完成后),确保用 onMountednextTick 包裹。

五、provide / inject:跨层级传递

5.1 场景:后台布局把「当前用户信息」传给深层组件

后台管理系统通常有这样的组件结构:

App.vue
└── Layout.vue(获取当前登录用户)
    ├── Sidebar.vue
    │   └── SidebarUserInfo.vue(要显示用户头像和名字)
    └── MainContent.vue
        └── Header.vue
            └── HeaderUserDropdown.vue(要显示用户名、有退出按钮)

如果用 props 层层往下传,Layout → Sidebar → SidebarUserInfo 至少要传两层,Layout → MainContent → Header → HeaderUserDropdown 要传三层。中间的组件根本不用这个数据,却不得不接收并转发——这叫 props drilling(props 钻孔),是非常糟糕的代码味道。

provide / inject 就是解决这个问题的。

祖先组件 Layout.vue(提供数据)

<template>
  <div class="layout">
    <Sidebar />
    <div class="main-area">
      <Header />
      <MainContent />
    </div>
  </div>
</template>

<script setup>
import { ref, provide, onMounted } from 'vue'

const currentUser = ref(null)

onMounted(async () => {
  const res = await fetch('/api/current-user')
  currentUser.value = await res.json()
  // 返回的数据类似:{ id: 1, name: '张三', avatar: '/img/avatar.jpg', role: 'admin' }
})

// 提供当前用户信息,所有后代组件都可以注入
provide('currentUser', currentUser)

// 也可以提供方法,比如退出登录
function logout() {
  localStorage.removeItem('token')
  window.location.href = '/login'
}
provide('logout', logout)
</script>

深层后代组件 HeaderUserDropdown.vue(注入数据)

<template>
  <el-dropdown v-if="currentUser">
    <span class="user-info">
      <el-avatar :src="currentUser.avatar" :size="32" />
      <span>{{ currentUser.name }}</span>
    </span>
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item>个人设置</el-dropdown-item>
        <el-dropdown-item divided @click="logout">退出登录</el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>

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

// 注入祖先组件提供的数据
const currentUser = inject('currentUser')
const logout = inject('logout')
</script>

深层后代组件 SidebarUserInfo.vue(同样注入)

<template>
  <div class="sidebar-user" v-if="currentUser">
    <el-avatar :src="currentUser.avatar" :size="48" />
    <div class="user-name">{{ currentUser.name }}</div>
    <el-tag size="small">{{ currentUser.role === 'admin' ? '管理员' : '普通用户' }}</el-tag>
  </div>
</template>

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

const currentUser = inject('currentUser')
</script>

注意看:中间的 Sidebar.vueHeader.vueMainContent.vue 完全不需要知道 currentUser 的存在——它们既不接收也不转发,代码零侵入。

5.2 进阶:用 Symbol 作为 key + 封装 useXxx

在实际项目中,字符串 key 容易冲突。推荐用 Symbol + 封装 hooks 的方式:

composables/useCurrentUser.js

import { inject, provide, ref } from 'vue'

// 用 Symbol 作为 key,绝不会和其他 provide 冲突
const CurrentUserKey = Symbol('currentUser')

/**
 * 在祖先组件中调用,提供当前用户信息
 */
export function provideCurrentUser() {
  const currentUser = ref(null)

  async function fetchUser() {
    const res = await fetch('/api/current-user')
    currentUser.value = await res.json()
  }

  function logout() {
    localStorage.removeItem('token')
    window.location.href = '/login'
  }

  provide(CurrentUserKey, {
    currentUser,
    fetchUser,
    logout
  })

  return { currentUser, fetchUser, logout }
}

/**
 * 在后代组件中调用,注入当前用户信息
 */
export function useCurrentUser() {
  const ctx = inject(CurrentUserKey)
  if (!ctx) {
    throw new Error('useCurrentUser() 必须在 provideCurrentUser() 的后代组件中使用')
  }
  return ctx
}

使用起来非常清爽:

<!-- Layout.vue -->
<script setup>
import { provideCurrentUser } from '@/composables/useCurrentUser'
const { fetchUser } = provideCurrentUser()
fetchUser()
</script>

<!-- 任意深层后代组件 -->
<script setup>
import { useCurrentUser } from '@/composables/useCurrentUser'
const { currentUser, logout } = useCurrentUser()
</script>

5.3 关键规范与踩坑

规范一:provide 的数据如果是响应式的,传 ref/reactive 本身,不要传 .value

const user = ref({ name: '张三' })

// ✅ 传 ref 本身,后代组件拿到的也是响应式的
provide('user', user)

// ❌ 传 .value,后代组件拿到的是一个普通对象,失去响应性
provide('user', user.value)

规范二:如果不希望后代组件随意修改,用 readonly 包裹

import { provide, ref, readonly } from 'vue'

const user = ref({ name: '张三' })

// 后代组件只能读,不能改
provide('user', readonly(user))

这在团队协作里很重要,避免某个深层组件偷偷改了用户数据,其他地方莫名其妙出 bug。

踩坑:inject 找不到 provide 的时候,默认返回 undefined

// 如果祖先组件没有 provide('theme'),这里不会报错,只是返回 undefined
const theme = inject('theme')

// 可以给一个默认值
const theme = inject('theme', 'light')

这个"静默返回 undefined"在调试时很坑——你的组件不报错,但就是不渲染数据,排查半天才发现是 provide 忘写了或者组件层级搞错了。所以前面封装 useXxx 时加的 throw new Error 非常有必要。

六、全局状态管理(Pinia):跨页面、跨组件的终极方案

6.1 场景:多个页面共享「权限信息」和「系统配置」

后台管理系统中,这些数据几乎所有页面都需要:

  • 当前用户的权限列表(控制菜单、按钮显示)
  • 系统级配置(侧边栏折叠状态、主题色等)
  • 全局消息通知的未读数量

这些数据不属于任何一个父子关系,也不限于某棵组件树——它们是全局的。这就是 Pinia 的舞台。

定义 Store:stores/useUserStore.js

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  // state
  const userInfo = ref(null)
  const permissions = ref([])

  // getter
  const isAdmin = computed(() => userInfo.value?.role === 'admin')

  // action
  async function fetchUserInfo() {
    const res = await fetch('/api/current-user')
    const data = await res.json()
    userInfo.value = data
    permissions.value = data.permissions || []
  }

  function hasPermission(code) {
    return permissions.value.includes(code)
  }

  function clearUser() {
    userInfo.value = null
    permissions.value = []
  }

  return {
    userInfo,
    permissions,
    isAdmin,
    fetchUserInfo,
    hasPermission,
    clearUser
  }
})

定义 Store:stores/useAppStore.js

import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useAppStore = defineStore('app', () => {
  const sidebarCollapsed = ref(false)
  const theme = ref('light')

  function toggleSidebar() {
    sidebarCollapsed.value = !sidebarCollapsed.value
  }

  function setTheme(newTheme) {
    theme.value = newTheme
    document.documentElement.setAttribute('data-theme', newTheme)
  }

  return { sidebarCollapsed, theme, toggleSidebar, setTheme }
})

在任意组件中使用

<!-- Header.vue -->
<template>
  <div class="header">
    <el-button @click="appStore.toggleSidebar">
      <el-icon><Fold v-if="!appStore.sidebarCollapsed" /><Expand v-else /></el-icon>
    </el-button>

    <div class="right-area">
      <span v-if="userStore.isAdmin" class="admin-badge">管理员</span>
      <span>{{ userStore.userInfo?.name }}</span>
    </div>
  </div>
</template>

<script setup>
import { useUserStore } from '@/stores/useUserStore'
import { useAppStore } from '@/stores/useAppStore'

const userStore = useUserStore()
const appStore = useAppStore()
</script>
<!-- 权限按钮示例:任意页面 -->
<template>
  <div>
    <!-- 只有拥有 'user:create' 权限的人才能看到这个按钮 -->
    <el-button
      v-if="userStore.hasPermission('user:create')"
      type="primary"
      @click="handleAdd"
    >
      新增用户
    </el-button>
  </div>
</template>

<script setup>
import { useUserStore } from '@/stores/useUserStore'
const userStore = useUserStore()
</script>

6.2 关键规范与踩坑

规范一:Store 按职责拆分,不要搞一个"上帝 Store"

stores/
├── useUserStore.js      # 用户信息、权限
├── useAppStore.js       # 应用级配置(主题、侧边栏)
├── useNotifyStore.js    # 消息通知
└── useCartStore.js      # 购物车(如果有电商模块)

不要把所有全局状态堆在一个 store 里——后期维护会非常痛苦。

规范二:解构 store 时必须用 storeToRefs

<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/useUserStore'

const userStore = useUserStore()

// ❌ 直接解构会丢失响应性!
const { userInfo, isAdmin } = userStore
// 此时 userInfo 是一个普通值,后续 store 更新了,模板不会重新渲染

// ✅ 用 storeToRefs 保持响应性
const { userInfo, isAdmin } = storeToRefs(userStore)

// 注意:方法不需要 storeToRefs,直接解构就行
const { fetchUserInfo, hasPermission } = userStore
</script>

这是 Pinia 最常见的坑,几乎每个新手都会踩。原理是:store 本身是一个 reactive 对象,直接解构等于取出了一个普通值(就像 const { a } = reactive({ a: 1 }) 一样),storeToRefs 内部会用 toRef 包裹每个属性,保持响应性。

规范三:异步操作放在 action 里,不要在组件里直接操作 state

// ✅ 好的做法:action 内部处理异步 + 状态更新
async function fetchUserInfo() {
  const res = await fetch('/api/current-user')
  userInfo.value = await res.json()
}

// ❌ 不好的做法:组件里直接改 store 状态
// 在某个组件里
const store = useUserStore()
const res = await fetch('/api/current-user')
store.userInfo = await res.json()  // 这样写能跑,但逻辑散落在组件里,难以维护

七、选型决策树:到底该用哪个?

遇到组件通信需求时,按这个流程走:

需要通信的两个组件是什么关系?
│
├── 父子关系
│   ├── 父 → 子 传数据? → props
│   ├── 子 → 父 通知事件? → emit
│   └── 父需要命令式调用子组件方法? → ref + defineExpose
│
├── 祖先 → 后代(跨 2 层以上)
│   ├── 只有这棵组件树需要? → provide / inject
│   └── 其他页面也需要? → Pinia
│
└── 毫无关系的两个组件(兄弟、跨页面)
    └── Pinia

常见误区

误区正确做法
所有通信都用 Pinia父子通信优先用 props/emit,简单直接
用 ref 代替 emitref 是命令式的,emit 是声明式的;能用 emit 就别用 ref
provide/inject 代替 Piniaprovide/inject 依赖组件树结构,Pinia 不依赖
用 EventBus(事件总线)Vue 3 已移除 $on/$emit,请用 Pinia 或 mitt 替代

八、实战综合示例:一个完整的用户管理模块

最后,把五种通信方式整合到一个真实的后台用户管理模块里,看看它们是怎么协同工作的:

UserManage.vue(页面)

  使用 Pinia: useUserStore  获取权限,控制按钮显示
  使用 provide: 提供 "刷新列表" 方法给深层组件

├── UserSearchBar.vue
   emit: 搜索条件变化时通知父组件

├── UserTable.vue
   props: 接收用户列表、loading 状态
   emit: 删除、编辑事件
   └── UserRoleBadge.vue
       props: 接收角色信息
       inject: 注入权限判断方法(判断是否高亮显示)

└── UserFormDialog.vue
    ref: 父组件通过 ref 调用 open() 方法
    emit: 提交成功后通知父组件
<!-- UserManage.vue —— 把所有通信方式串起来 -->
<template>
  <div class="user-manage">
    <h2>用户管理</h2>

    <!-- emit: 搜索栏变化通知 -->
    <UserSearchBar @search="handleSearch" />

    <!-- 权限控制:只有有 user:create 权限才显示按钮 -->
    <el-button
      v-if="userStore.hasPermission('user:create')"
      type="primary"
      @click="formDialogRef?.open()"
    >
      新增用户
    </el-button>

    <!-- props: 传数据给表格;emit: 接收表格事件 -->
    <UserTable
      :user-list="filteredList"
      :loading="loading"
      @edit="(user) => formDialogRef?.open(user)"
      @delete="handleDelete"
    />

    <!-- ref: 通过 ref 控制弹窗;emit: 弹窗成功后回调 -->
    <UserFormDialog ref="formDialogRef" @success="loadUserList" />
  </div>
</template>

<script setup>
import { ref, computed, provide, onMounted } from 'vue'
import { useUserStore } from '@/stores/useUserStore'

const userStore = useUserStore()
const formDialogRef = ref(null)
const userList = ref([])
const loading = ref(false)
const searchKeyword = ref('')

const filteredList = computed(() => {
  if (!searchKeyword.value) return userList.value
  return userList.value.filter(u =>
    u.name.includes(searchKeyword.value) || u.email.includes(searchKeyword.value)
  )
})

function handleSearch(keyword) {
  searchKeyword.value = keyword
}

async function loadUserList() {
  loading.value = true
  const res = await fetch('/api/users')
  userList.value = await res.json()
  loading.value = false
}

async function handleDelete(user) {
  await fetch(`/api/users/${user.id}`, { method: 'DELETE' })
  loadUserList()
}

// provide: 把刷新方法提供给深层后代组件
provide('refreshUserList', loadUserList)

onMounted(loadUserList)
</script>

九、总结

方式一句话定位记忆口诀
props父给子传数据"数据往下流"
emit子通知父做事"事件往上冒"
ref父直接调子方法"拿遥控器操作"
provide/inject祖先给后代穿透数据"隔空投喂"
Pinia全局共享状态"中央仓库"

最后给几条实战建议:

  1. 能用 props + emit 解决的,就不要上 Pinia。 简单的父子通信用全局状态是杀鸡用牛刀,还会让状态散落在 store 里难以追踪。

  2. ref 是最后手段,不是首选。 它是命令式的,破坏了 Vue 声明式编程的优雅。只有在"需要调用子组件方法"这种场景下才用。

  3. provide/inject 适合"有明确组件树边界"的共享数据。 比如布局组件提供主题、表单组件提供表单上下文。如果发现全站多处都要用,说明该上 Pinia 了。

  4. 用 TypeScript。 本文为了降低阅读门槛用的是 JS,但实际项目中 props 的类型定义、emit 的参数类型、store 的 state 类型,用 TS 写会让协作效率提升一个档次。

  5. 命名要统一。 事件名用 kebab-case,props 模板里用 kebab-case、脚本里用 camelCase,store 文件用 useXxxStore.js——统一的命名规范比什么技术选型都重要。

🔍 本系列专栏导航

一、《Vue 核心语法与组件模式篇:从 Vue2 到 Vue3 | 语法差异与迁移时最容易懵的点》

二、《Vue 核心语法与组件模式篇:模板语法扫盲 | v-if、v-for、v-model、slot 的常见组合模式》

三、《Vue 核心语法与组件模式篇:Vue 组件通信全图 | props、emit、ref、provide-inject 全局状态》

四、《Vue 核心语法与组件模式篇:表单最佳实践 | 从 v-model 到自定义表单组件(含校验)》

五、《Vue 核心语法与组件模式篇:列表与表格最佳实践 | 分页、筛选、排序、批量操作》

六、《Vue 核心语法与组件模式篇:弹窗与抽屉组件封装 | 如何做一个全局可控的 Dialog 服务》

七、《Vue 核心语法与组件模式篇:组合式函数、Hooks | (Vue2 mixin、Vue3 composables) 的实战封装》

八、《Vue 核心语法与组件模式篇:后台权限与菜单渲染 | 基于路由和后端返回的几种实现方式》

👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~