第 6 章 组件进阶
本章导读
- 你将学会:provide / inject 跨层级通信、defineExpose 与模板引用、组合式函数(Composables)封装------从"会用组件"到"会设计组件"
- 前置要求:第 5 章
- 预计用时:1 天。useXxx 组合式函数是 Vue3 的精髓,也是面试加分项

6.1 provide / inject:跨层级通信
【知识点讲解】
props 一层层传叫"逐级透传"(prop drilling),中间组件明明不用这个数据还得帮忙传,非常难受。provide / inject 解决祖先组件直接把数据发给任意后代组件,中间层无感。
- 祖先:
provide('key', 数据) - 后代:
const data = inject('key')
企业典型场景:主题色/语言/当前用户这类全局上下文,从 App.vue 注入,任何深层组件都能取。
坑点:默认单向(祖先 → 后代)。如果传的是 ref,后代改它祖先也能感知(不推荐,除非配合 readonly 保持单向)。最佳实践:provide 数据 + provide 修改方法,修改权留在祖先手里。
【示例代码】
xml
<script setup>
import { ref, provide, readonly } from 'vue'
import LayoutA from '@/components/LayoutA.vue'
const theme = ref('light')
// 只读数据给后代展示
provide('theme', readonly(theme))
// 修改方法也给后代,修改权在祖先
provide('toggleTheme', () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
})
</script>
<template>
<div :style="{ background: theme === 'dark' ? '#001529' : '#fff', color: theme === 'dark' ? '#fff' : '#333', minHeight: '100vh', padding: 20 }">
<LayoutA />
</div>
</template>
xml
<script setup>
import DeepChild from '@/components/DeepChild.vue'
</script>
<template>
<div>
<h2>布局层(不知道主题的存在)</h2>
<DeepChild />
</div>
</template>
xml
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const toggleTheme = inject('toggleTheme')
</script>
<template>
<div>
<p>当前主题:{{ theme }}</p>
<button @click="toggleTheme">切换主题</button>
</div>
</template>
【练习Demo】
需求 :App.vue 提供"当前登录用户名",隔两层的 ProfileCard 组件直接显示它,并提供 logout 方法把用户名清空。
参考实现:
xml
<script setup>
import { ref, provide } from 'vue'
import LayoutA from '@/components/LayoutA.vue'
const username = ref('张三')
provide('username', username)
provide('logout', () => {
username.value = ''
})
</script>
<template>
<div style="padding: 20px">
<LayoutA />
</div>
</template>
xml
<script setup>
import { inject } from 'vue'
const username = inject('username')
const logout = inject('logout')
</script>
<template>
<div class="card">
<p v-if="username">当前用户:{{ username }} <button @click="logout">退出</button></p>
<p v-else>未登录</p>
</div>
</template>
<style scoped>
.card { border: 1px solid #eee; padding: 12px; border-radius: 8px; }
</style>
xml
<script setup>
import ProfileCard from '@/components/ProfileCard.vue'
</script>
<template>
<div>
<h2>布局层</h2>
<ProfileCard />
</div>
</template>
6.2 defineExpose 与模板引用
【知识点讲解】
<script setup> 的组件默认是"封闭"的------父组件通过 ref 拿到子组件实例,却访问不到里面的任何东西。想让父组件能调用子组件的方法/数据,子组件必须用 defineExpose 显式"抛出"。
模板引用(也叫 ref 拿 DOM):给普通标签加 ref="xxx",const xxx = ref(null),挂载后 xxx.value 就是那个 DOM 元素,可以直接 focus、滚动等。注意:必须在 onMounted 之后访问(挂载前是 null)。
企业场景:父组件点按钮让子组件的输入框聚焦、调用子组件的 open() 打开弹窗、滚动容器到底部加载更多。
【示例代码】
xml
<script setup>
import { ref } from 'vue'
const count = ref(0)
const inputRef = ref(null)
function add() {
count.value++
}
// 只抛出父组件需要的东西
defineExpose({ add, count, focusInput: () => inputRef.value?.focus() })
</script>
<template>
<div style="border: 1px dashed #ccc; padding: 12px; margin: 8px 0">
<p>子组件内部计数:{{ count }}</p>
<input ref="inputRef" placeholder="父组件可以让我聚焦">
</div>
</template>
xml
<script setup>
import { ref, onMounted } from 'vue'
import ExposeChild from '@/components/ExposeChild.vue'
// 拿子组件实例:变量名必须和模板里 ref="childRef" 一致
const childRef = ref(null)
// 拿普通 DOM
const boxRef = ref(null)
onMounted(() => {
// DOM 挂载后才能访问
console.log('盒子高度:', boxRef.value.offsetHeight)
})
function callChild() {
// 调用子组件抛出的方法
childRef.value.add()
}
function focusChildInput() {
childRef.value.focusInput()
}
</script>
<template>
<div class="page">
<h2>defineExpose 与模板引用</h2>
<div ref="boxRef" style="height: 60px; background: #f5f5f5">我是一个盒子</div>
<ExposeChild ref="childRef" />
<button @click="callChild">调用子组件 add</button>
<button @click="focusChildInput">让子组件输入框聚焦</button>
</div>
</template>
css
// 文件:src/router/index.js(routes 追加)
{ path: '/expose', name: 'expose', component: () => import('@/views/ExposeView.vue') }
【练习Demo】
需求 :封装 SearchBox 组件(内部一个输入框 + clear() 清空方法),父组件提供"清空搜索框"和"聚焦搜索框"两个按钮。
参考实现:
xml
<script setup>
import { ref } from 'vue'
const keyword = ref('')
const inputRef = ref(null)
function clear() {
keyword.value = ''
}
function focus() {
inputRef.value?.focus()
}
defineExpose({ clear, focus })
</script>
<template>
<input ref="inputRef" v-model="keyword" placeholder="搜索...">
</template>
xml
<script setup>
import { ref } from 'vue'
import SearchBox from '@/components/SearchBox.vue'
const searchBoxRef = ref(null)
</script>
<template>
<div class="page">
<h2>搜索框控制</h2>
<SearchBox ref="searchBoxRef" />
<button @click="searchBoxRef.clear()">清空</button>
<button @click="searchBoxRef.focus()">聚焦</button>
</div>
</template>
6.3 组合式函数 Composables
【知识点讲解】
组合式函数(composable)= 把一段"带响应式的可复用逻辑"抽成一个 useXxx 函数 。组件只用结果,不关心过程。这是 Vue3 拥抱 Composition API 最大的意义:按功能组织代码、跨组件复用逻辑(Vue2 时代的 mixin 做同样的事但有命名冲突、来源不清等致命缺陷,已被淘汰)。
企业里的 composable 通常放 src/composables/ 或 src/hooks/ 目录,命名一律 use 开头。
写法套路(背下来):
- 函数名
useXxx,内部可以用 ref/computed/watch/生命周期等一切响应式能力 - 把组件需要的东西 return 出去(通常返回一个对象,包含数据 + 方法)
- 组件里解构使用,多个组件各自调用,状态互相独立
【示例代码】
javascript
// 文件:src/composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue'
// 追踪鼠标位置的逻辑,任何组件都能复用
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(e) {
x.value = e.pageX
y.value = e.pageY
}
// 生命周期写在函数里,组件调用时自动绑定到该组件
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
javascript
// 文件:src/composables/useLocalStorage.js(业务型:本地存储读写)
import { ref, watch } from 'vue'
// 传入 key 和初始值,返回响应式数据:改它自动存 localStorage,刷新页面自动恢复
export function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key)
const data = ref(stored !== null ? JSON.parse(stored) : defaultValue)
watch(
data,
(val) => localStorage.setItem(key, JSON.stringify(val)),
{ deep: true }
)
return { data }
}
xml
<script setup>
import { useMouse } from '@/composables/useMouse'
import { useLocalStorage } from '@/composables/useLocalStorage'
const { x, y } = useMouse()
// 偏好设置:刷新页面也不丢
const { data: settings } = useLocalStorage('app-settings', { theme: 'light', fontSize: 14 })
</script>
<template>
<div class="page">
<h2>组合式函数</h2>
<p>鼠标位置:{{ x }}, {{ y }}</p>
<h3>持久化设置</h3>
<p>
主题:
<select v-model="settings.theme">
<option value="light">浅色</option>
<option value="dark">深色</option>
</select>
</p>
<p>字号:{{ settings.fontSize }}
<button @click="settings.fontSize++">加大</button>
</p>
<p><small>改完刷新页面,数据还在(存到了 localStorage)</small></p>
</div>
</template>
css
// 文件:src/router/index.js(routes 追加)
{ path: '/hook', name: 'hook', component: () => import('@/views/HookView.vue') }
【练习Demo】
需求 :封装 useCounter:提供 count、increment、decrement、reset,并支持传入初始值(默认 0)。页面中同时使用两个计数器实例,验证状态互不干扰。
参考实现:
javascript
// 文件:src/composables/useCounter.js
import { ref } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => (count.value = initialValue)
return { count, increment, decrement, reset }
}
xml
<script setup>
import { useCounter } from '@/composables/useCounter'
// 两次调用,两份独立状态
const counterA = useCounter(100)
const counterB = useCounter()
</script>
<template>
<div class="page">
<h2>计数器 A(初始 100)</h2>
<p>{{ counterA.count.value }}</p>
<button @click="counterA.increment()">+</button>
<button @click="counterA.decrement()">-</button>
<button @click="counterA.reset()">重置</button>
<h2>计数器 B(初始 0)</h2>
<p>{{ counterB.count.value }}</p>
<button @click="counterB.increment()">+</button>
<button @click="counterB.decrement()">-</button>
<button @click="counterB.reset()">重置</button>
</div>
</template>
小技巧:如果想模板里不带
.value,可以先解构再经过toRefs之外的方法------最简单的是直接const { count, increment } = useCounter(),因为返回的就是 ref,模板自动解包。上面的写法(整体保留对象)适合多实例场景,两种都合法。
第 6 章小结
- 跨层级传数据用
provide / inject:祖先 provide、任意后代 inject;推荐"数据只读 + 修改方法"模式 <script setup>组件默认封闭,想让父组件调用必须defineExpose- 模板引用:标签
ref="xxx"+const xxx = ref(null),DOM 操作必须在onMounted后 - 逻辑复用统一走组合式函数:
src/composables/useXxx.js,内部可自由使用 ref/watch/生命周期,return 出数据和方法 - 通信方式选型:父传子 props → 子传父 emit → 跨层级 provide/inject → 逻辑复用 composable → 全局共享 Pinia(下一站)
企业踩坑实录
- provide 传了普通值,后代修改不响应 :
provide('theme', 'dark')传出去的是快照。保持响应式的姿势是传 ref:provide('theme', ref('dark')),后代 inject 拿到的是同一个 ref。 - inject 拿不到值导致页面报 undefined 崩溃 :始终写上默认值
inject('theme', 'light'),给极端情况留退路。 - defineExpose 滥用:把子组件内部方法大量暴露出去,等于把内部实现"焊死",后续重构必然牵连父组件。优先 props / emit 解决;只有"父组件确实需要调用子组件方法"的场景(聚焦输入框、重置表单)才用。
- composable 里的定时器和事件监听没有清理:页面切走后 setInterval、window.addEventListener 还在跑,内存泄漏且可能报错。onUnmounted 里逐个清理是硬性要求。
思考题(先自己答,再对照参考答案)
Q1:provide/inject 和 props 层层传递怎么选?
参考答案:中间组件被迫"接力"转发、且数据具备全局性(主题、语言、当前用户信息)→ provide/inject;普通父子通信仍然用 props + emit,链路更清晰。
Q2:composable 的命名和返回值有什么约定?
参考答案:函数名必须 use 开头(useMouse、useFetch);返回一个对象,内部值是 ref、方法为普通函数,调用方按需解构。这是社区事实标准,遵守它别人才能秒懂你的代码。
Q3:composable 相比 Vue2 的 mixin 好在哪?
参考答案:来源清晰(import 了哪个就来自哪个,mixin 全局混入后"查无此源");无命名冲突;按需引入;天然支持 TypeScript 类型推导。这也是 Vue3 淘汰 mixin 的原因。
Q4:两个组件同时调用 useMouse(),数据会互相影响吗?
参考答案:不会。每次调用都会创建独立的 ref 和独立的事件监听,互不干扰------这正是组合式函数优于 mixin 的架构基础。
面试高频
问:provide/inject 的响应性如何保证?
答:provide 传 ref(或包含 getter 的 reactive 对象),inject 拿到的是同一个引用,因此保持响应式;如果只传普通值,传出去那一刻就是死数据。