Vue 组件、模板与编译原理 / Vue Components, Templates, and Compilation
📅 创建时间:2026-07-28 🏷️ 标签:#Vue #Components #Templates #SFC #vModel #Slots #Teleport #KeepAlive 📚 前置知识:[[00-overview]]
📋 本章目标
- 理解 SFC 三段式的编译流程:template 如何变成 render 函数,scoped style 如何实现隔离
- 掌握 defineProps / defineEmits / defineExpose / defineSlots 的类型安全契约设计
- 掌握 v-model 的全部变体(基础、多重、修饰符、defineModel)及其原理
- 理解 v-for 与 v-if 的优先级关系、key 的身份标识本质、以及 Template v-for 的妙用
- 掌握插槽全解:默认插槽、具名插槽、作用域插槽、动态插槽名及其编译结果
- 理解 Teleport / Suspense / KeepAlive 的使用场景与内部机制
- 掌握异步组件的注册方式、加载状态处理以及与 Suspense 的配合
- 能够编写实用的自定义指令(v-focus、v-click-outside)
第1部分:SFC 三段式与编译原理
1.1 SFC 三段式
Vue 单文件组件将模板、逻辑和样式组织在同一 *.vue 文件中,围绕业务功能聚合代码:
<template>
<div class="greeting">{{ message }}</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const message = ref('Hello Vue!')
</script>
<style scoped>
.greeting { color: #42b883; }
</style>1.2 SFC 编译流水线
当你执行构建时,@vue/compiler-sfc 将 .vue 文件拆解为三个独立管道:
┌─────────────────────────────────────────────────────────────┐
│ SFC 编译流水线 │
├─────────────────────────────────────────────────────────────┤
│ │
│ .vue 源文件 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ <template> │ <script setup> │ <style scoped> │ │
│ └──────┬──────┴────────┬─────────┴───────┬────────────┘ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Template │ │ Script │ │ Style │ │
│ │ Compiler │ │ Compiler │ │ PostCSS │ │
│ │ │ │ │ │ Processor │ │
│ │ Parse → │ │ <script │ │ │ │
│ │ Transform│ │ setup> → │ │ scoped → │ │
│ │ → Codegen│ │ 标准 JS/TS │ │ data-v-xxx │ │
│ └────┬─────┘ └──────┬───────┘ └──────┬───────┘ │
│ ▼ ▼ ▼ │
│ render(){} setup(){} __scopeId:"data-v-xxx" │
│ │
└─────────────────────────────────────────────────────────────┘Template 编译三阶段:
- Parse:HTML 字符串 → AST,识别元素、属性、指令、插值
- Transform:遍历 AST,将
v-if/v-for/v-model等结构指令转换为 JavaScript 表达式节点 - Codegen:生成 render 函数字符串,使用
createElementBlock、renderList等运行时辅助函数
编译后的 render 函数带 PatchFlag 静态标记,运行时 diff 可跳过静态内容只比较动态绑定:
// 编译结果示意
function render(_ctx, _cache) {
return (openBlock(), createElementBlock('div', null, [
_ctx.visible
? (openBlock(), createElementBlock('p', null, _ctx.message, 1 /* TEXT */))
: null,
(openBlock(), createElementBlock('ul', null, [
renderList(_ctx.list, (item) =>
(openBlock(), createElementBlock('li', { key: item.id }, item.name, 1))
)
]))
]))
}1.3 Scoped Style 实现原理
┌─────────────────────────────────────────────────────────────┐
│ Scoped Style 工作原理 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 源 CSS 编译后 CSS │
│ ────────────────── ────────────────────────── │
│ .title { ... } → .title[data-v-7ba5bd90] { ... } │
│ │
│ 生成的 DOM: │
│ <h1 class="title" data-v-7ba5bd90>Hello</h1> │
│ │
│ 穿透子组件: :deep(.child-class) { ... } │
│ 插槽内容: :slotted(.slot-class) { ... } │
│ 全局样式: :global(.global-class) { ... } │
│ │
│ 注意:子组件根元素同时携带父组件的 scopeId, │
│ 因此父组件 scoped style 可影响子组件根元素 │
│ │
└─────────────────────────────────────────────────────────────┘1.4 CSS Modules vs Scoped
| 特性 | Scoped | CSS Modules |
|---|---|---|
| 隔离方式 | data-v-xxx 属性选择器 | 类名哈希映射 |
| 写法 | 普通 CSS,学习成本低 | <style module> + $style.className |
| 穿透控制 | :deep() / :slotted() / :global() | 天然隔离,需组合(composes) |
| 适用场景 | 大多数组件级样式 | 强隔离需求、大型项目防命名冲突 |
<!-- CSS Modules 示例 -->
<template>
<div :class="$style.container">
<h1 :class="$style.title">Hello</h1>
</div>
</template>
<style module>
.container { padding: 20px; }
.title { font-size: 24px; }
</style>第2部分:defineProps / defineEmits / defineExpose / defineSlots
2.1 组件契约全景
┌─────────────────────────────────────────────────────────────┐
│ 组件契约全景图 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 父组件 子组件 │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ :propName ──────────► │ defineProps() │ │
│ │ @eventName ◄────────── │ defineEmits() │ │
│ │ ref.child ◄────────── │ defineExpose() │ │
│ │ <slot> ──────────► │ defineSlots() │ │
│ │ v-model ◄─────────► │ defineModel() │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ │
│ ───► 父→子 (Props, Slots) │
│ ◄─── 子→父 (Emits, Expose) │
│ ◄──► 双向 (v-model) │
│ │
└─────────────────────────────────────────────────────────────┘2.2 defineProps
支持泛型类型声明(Vue 3.3+,编译时擦除)和运行时声明(带校验器):
<script setup lang="ts">
// 方式一:泛型声明(推荐)
interface Props {
user: { id: string; name: string }
selected?: boolean
count: number
}
const props = withDefaults(defineProps<Props>(), {
selected: false,
count: 0,
})
// 方式二:运行时声明(需要校验器时)
const props = defineProps({
user: { type: Object as PropType<User>, required: true },
count: {
type: Number,
required: true,
validator: (v: number) => v >= 0,
},
})
</script>关键原则:Props 只读,子组件永不直接修改。需要衍生状态用 computed。
2.3 defineEmits
事件命名描述"已发生的事情"而非"要执行的命令":
<script setup lang="ts">
const emit = defineEmits<{
select: [id: string] // ✅ 描述事件
'update:modelValue': [value: string] // ✅ v-model 约定
close: [] // ✅ 无参数事件
}>()
// ❌ 避免:saveData, doSubmit, clickItem(命令式命名)
</script>2.4 defineExpose
<script setup> 默认封闭。defineExpose 显式暴露方法给父组件的 ref 访问:
<!-- Child.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const inputRef = ref<HTMLInputElement>()
function focus() { inputRef.value?.focus() }
defineExpose({ focus }) // 只暴露必要方法,不泄露内部状态
</script><!-- Parent.vue -->
<script setup lang="ts">
const childRef = ref<InstanceType<typeof Child>>()
childRef.value?.focus() // 类型安全
</script>2.5 defineSlots(Vue 3.3+)
为插槽提供类型检查:
<script setup lang="ts">
const slots = defineSlots<{
default?: (props: Record<string, never>) => any
header?: (props: Record<string, never>) => any
item?: (props: { item: Item; index: number }) => any // 作用域插槽
}>()
</script>第3部分:v-model 全变体
3.1 本质:语法糖
v-model="text"
↕ 等价于
:modelValue="text" + @update:modelValue="text = $event"
v-model:title="text"
↕ 等价于
:title="text" + @update:title="text = $event"3.2 全变体矩阵
┌─────────────────────────────────────────────────────────────┐
│ v-model 变体一览 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 变体 │ 父组件写法 │ 子组件接收 │
│ ───────────────────┼──────────────────────┼────────────── │
│ 基础 │ v-model="v" │ modelValue │
│ 具名 │ v-model:title="v" │ title │
│ 修饰符 .trim │ v-model.trim="v" │ modelModifiers│
│ 修饰符 .lazy │ v-model.lazy="v" │ modelModifiers│
│ 自定义修饰符 │ v-model:title.cap="v"│ titleModifiers│
│ defineModel (3.4+) │ const m = defineModel│ 一行搞定 │
│ │
└─────────────────────────────────────────────────────────────┘3.3 多重 v-model
<!-- 父组件 -->
<UserForm v-model:name="userName" v-model:email="userEmail" v-model:bio="userBio" />
<!-- UserForm.vue -->
<script setup lang="ts">
const name = defineModel<string>('name', { required: true })
const email = defineModel<string>('email', { required: true })
const bio = defineModel<string>('bio', { default: '' })
</script>3.4 修饰符
内置修饰符(仅原生表单元素):.trim、.number、.lazy
自定义修饰符:子组件通过 modelModifiers prop 感知:
<!-- 父:<CustomInput v-model.capitalize="text" /> -->
<script setup lang="ts">
const props = defineProps<{
modelValue: string
modelModifiers?: { capitalize: boolean }
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
function handleInput(e: Event) {
let value = (e.target as HTMLInputElement).value
if (props.modelModifiers?.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1)
}
emit('update:modelValue', value)
}
</script>3.5 defineModel(Vue 3.4+ 推荐)
┌─────────────────────────────────────────────────────────────┐
│ defineModel 简化对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 3.4 之前(每个 v-model 需要 ~5 行声明): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ const props = defineProps<{ modelValue: string }>() │ │
│ │ const emit = defineEmits<{ │ │
│ │ 'update:modelValue': [v: string] │ │
│ │ }>() │ │
│ │ const local = computed({ │ │
│ │ get: () => props.modelValue, │ │
│ │ set: (v) => emit('update:modelValue', v) │ │
│ │ }) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 3.4+(一行): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ const model = defineModel<string>({ required: true })│ │
│ │ const [model, modifiers] = defineModel<string>() │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘3.6 v-model 数据流
┌─────────────────────────────────────────────────────────────┐
│ v-model 数据流 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 父组件 子组件 │
│ ┌──────────┐ ┌──────────────┐ │
│ │ state │─── :modelValue ──►│ props.modelValue │
│ │ text │ │ │ │
│ │ │◄── @update:mv ────│ emit('update:mv', v) │
│ │ │ = $event │ ▲ │
│ └──────────┘ │ ┌────┴─────┐ │
│ │ │ <input> │ │
│ │ └──────────┘ │
│ └──────────────┘ │
│ │
│ 本质仍是单向数据流:Props 向下,Events 向上 │
│ v-model 只把这个模式封装为更简洁的语法 │
│ │
└─────────────────────────────────────────────────────────────┘第4部分:v-for / v-if 优先级与 key
4.1 v-if 优先级高于 v-for
Vue 3 中 v-if 先执行,此时循环变量尚不可用,禁止在同一元素上同时使用:
<!-- ❌ 错误:v-if 先执行,item 不存在 -->
<li v-for="item in items" v-if="item.active">{{ item.name }}</li>
<!-- ✅ 正确:computed 预过滤 -->
<script setup>
const activeItems = computed(() => items.value.filter(i => i.active))
</script>
<template>
<li v-for="item in activeItems" :key="item.id">{{ item.name }}</li>
</template>
<!-- ✅ 正确:template 包裹 v-for,内层 v-if -->
<template v-for="item in items" :key="item.id">
<li v-if="item.active">{{ item.name }}</li>
</template>4.2 key 的本质:身份标识
key 赋予每个节点稳定的身份标识,让 Vue 能正确追踪 DOM 与数据实体的对应关系,而非仅仅是性能优化参数:
┌─────────────────────────────────────────────────────────────┐
│ key 的作用演示 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 原数据: [{id:1,"A"},{id:2,"B"},{id:3,"C"}] │
│ → 头部插入 {id:4,"D"} │
│ │
│ 无 key(就地复用): 有 key(按身份追踪): │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ 位置0: "A"→"D" 更新 │ │ key=4: 新建插入头部 │ │
│ │ 位置1: "B"→"A" 更新 │ │ key=1,2,3: 原地不动 │ │
│ │ 位置2: "C"→"B" 更新 │ │ │ │
│ │ 位置3: 新建 "C" │ │ 内部状态全部保持正确!│ │
│ │ → 3次更新+所有状态错乱│ └──────────────────────┘ │
│ └──────────────────────┘ │
│ │
│ 核心:key 保护组件内部状态(表单输入、动画、focus 等) │
│ 这些状态绑定在组件实例上,不是 DOM 上 │
│ │
└─────────────────────────────────────────────────────────────┘使用原则:
- 始终提供稳定的唯一标识(数据库 ID)
- 不用数组索引(除非列表永不变序、永不增删)
- 不用
Math.random()或Date.now()(每次渲染都销毁重建) - Template v-for 时,key 放在
<template>上
4.3 Template v-for 与解构
<!-- 不引入额外包裹元素 -->
<template v-for="item in items" :key="item.id">
<dt>{{ item.term }}</dt>
<dd>{{ item.definition }}</dd>
</template>
<!-- 解构 -->
<li v-for="{ id, name, email } in users" :key="id">
{{ name }} ({{ email }})
</li>
<!-- 带索引解构 -->
<li v-for="({ id, name }, index) in users" :key="id">
{{ index + 1 }}. {{ name }}
</li>第5部分:插槽全解
5.1 三种插槽
┌─────────────────────────────────────────────────────────────┐
│ 插槽类型全景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 默认插槽: <Child>content</Child> ↕ <slot /> │
│ 具名插槽: <template #header> ↕ <slot name="header"> │
│ 作用域插槽: <template #item="{ item }"> ↕ <slot :item="...">│
│ 动态插槽名: <template #[dynamicName]> │
│ │
└─────────────────────────────────────────────────────────────┘5.2 具名插槽与动态插槽名
<!-- Layout.vue(子组件) -->
<template>
<div class="layout">
<header><slot name="header">默认头部</slot></header>
<main><slot>默认内容</slot></main> <!-- 默认插槽 -->
<footer><slot name="footer">默认底部</slot></footer>
</div>
</template>
<!-- 父组件使用 -->
<Layout>
<template #header><h1>页面标题</h1></template>
<p>主要内容</p> <!-- 自动落入默认插槽 -->
<template #footer><small>版权信息</small></template>
</Layout>
<!-- 动态插槽名(Vue 3) -->
<template #[dynamicSlotName]> 内容 </template>5.3 作用域插槽
子组件向父组件暴露数据,实现"数据反向传递":
<!-- DataTable.vue -->
<template>
<tr v-for="row in rows" :key="row.id">
<slot name="row" :row="row" :index="index">
<td>{{ row.name }}</td> <!-- 默认渲染 -->
</slot>
</tr>
</template>
<!-- 父组件使用 -->
<DataTable :rows="users">
<template #row="{ row: user, index = 0 }"> <!-- 解构+重命名+默认值 -->
<td>{{ index + 1 }}</td>
<td><UserAvatar :user="user" />{{ user.name }}</td>
<td><StatusBadge :status="user.status" /></td>
</template>
</DataTable>5.4 插槽的编译结果
┌─────────────────────────────────────────────────────────────┐
│ 插槽的编译结果 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 源模板: 编译后 render: │
│ ┌──────────────────────┐ ┌────────────────────────┐ │
│ │ <slot name="header"/>│ │ renderSlot( │ │
│ │ <slot :item="data"/> │ → │ $slots, 'header') │ │
│ └──────────────────────┘ │ renderSlot( │ │
│ │ $slots, 'default', │ │
│ │ { item: _ctx.data }) │ │
│ └────────────────────────┘ │
│ │
│ 每个插槽 = $slots 中的一个函数,接收 props,返回 VNode[] │
│ useSlots() 可在 <script setup> 中访问插槽函数 │
│ │
└─────────────────────────────────────────────────────────────┘5.5 $attrs 透传
底层 UI 组件应透传属性到原生元素:
<!-- BaseInput.vue —— 单根节点自动透传 -->
<template>
<input v-bind="$attrs" :value="modelValue" @input="..." />
</template>
<!-- 多根节点 —— 必须显式指定 -->
<template>
<label>Label</label>
<input v-model="model" v-bind="$attrs" /> <!-- 显式绑定 -->
</template>第6部分:Teleport / Suspense / KeepAlive
6.1 Teleport —— DOM 传送
<Teleport> 将组件的一部分模板渲染到 DOM 树其他位置,同时保持逻辑层级不变(props/emits/provide/inject 照常工作):
<template>
<Teleport to="body">
<div class="modal-overlay" @click.self="emit('close')">
<div class="modal-content" role="dialog" aria-modal="true">
<slot />
</div>
</div>
</Teleport>
</template>┌─────────────────────────────────────────────────────────────┐
│ Teleport DOM 结构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 组件树(逻辑层级): DOM 树(渲染结果): │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ <App> │ │ <body> │ │
│ │ ├── <Main> │ │ ├── <div id="app">│ │
│ │ │ └── <Modal> │ │ │ └── <Main> │ │
│ │ │ └── <Teleport> │ ├── <div.modals> │ │
│ │ │ └── <Overlay> │ │ └── <Overlay> │←传送 │
│ └────────────────────┘ └────────────────────┘ │
│ │
│ 适用: 模态框、Toast 通知、下拉菜单、全屏 Loading │
│ 禁用: <Teleport to="body" :disabled="isMobile"> │
│ │
└─────────────────────────────────────────────────────────────┘6.2 Suspense —— 异步依赖协调
<Suspense> 等待异步依赖(异步组件、async setup())时展示降级内容:
<template>
<Suspense>
<template #default>
<AsyncDashboard /> <!-- 异步组件 -->
</template>
<template #fallback>
<DashboardSkeleton /> <!-- 加载中展示 -->
</template>
</Suspense>
</template>触发 Suspense 的依赖:
defineAsyncComponent定义的异步组件<script setup>中使用顶层await的组件
<script setup lang="ts">
// 顶层 await 让整个 setup 变为 async,触发父级 Suspense
const data = await fetch('/api/dashboard').then(r => r.json())
</script>6.3 KeepAlive —— 组件实例缓存
缓存不活跃组件实例,避免重复创建/销毁,典型场景是 Tab 切换保留滚动位置和表单状态:
┌─────────────────────────────────────────────────────────────┐
│ KeepAlive 缓存机制 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 无 KeepAlive: Tab A → Tab B → Tab A │
│ 创建A 销毁A 重建A(状态丢失) │
│ │
│ 有 KeepAlive: Tab A → Tab B → Tab A │
│ 创建A 缓存A 激活A(状态保留) │
│ 创建B │
│ │
│ 内部: cache: Map<componentName, VNode> │
│ max=n: 最多缓存 n 个(LRU 淘汰) │
│ include/exclude: 按组件名精确控制缓存范围 │
│ │
└─────────────────────────────────────────────────────────────┘<template>
<KeepAlive :include="['Dashboard', 'Settings']" :exclude="['Login']" :max="10">
<component :is="currentTab" />
</KeepAlive>
</template>
<script setup lang="ts">
import { onActivated, onDeactivated } from 'vue'
// KeepAlive 专属生命周期
onActivated(() => {
// 从缓存激活 → 重新获取数据、恢复定时器
})
onDeactivated(() => {
// 被缓存 → 清除定时器、保存草稿
})
// 注意:被缓存的组件不触发 onUnmounted
</script>第7部分:组件注册与异步组件
7.1 全局注册 vs 局部注册
┌─────────────────────────────────────────────────────────────┐
│ 全局注册 vs 局部注册 对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 全局注册 (app.component): │
│ ✅ 任何组件中直接使用,无需 import │
│ ❌ 即使不引用也会打包(Tree-shaking 失效) │
│ ❌ 全局命名空间污染,难以追踪依赖 │
│ │
│ 局部注册 (import): │
│ ✅ Tree-shaking 友好,显式依赖关系 │
│ ❌ 每个文件需要 import │
│ │
│ 策略:基础 UI 组件全局注册,业务组件局部注册 │
│ │
└─────────────────────────────────────────────────────────────┘// main.ts —— 全局注册通用组件
import { createApp } from 'vue'
import BaseButton from './components/BaseButton.vue'
const app = createApp(App)
app.component('BaseButton', BaseButton)
app.mount('#app')7.2 defineAsyncComponent
创建仅在需要时才加载的异步组件,实现代码分割:
import { defineAsyncComponent } from 'vue'
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./Dashboard.vue'),
loadingComponent: DashboardSkeleton, // 加载中组件
errorComponent: ErrorDisplay, // 加载失败组件
delay: 200, // 展示 loading 前延迟(防止闪烁)
timeout: 3000, // 超时时间
onError(error, retry, fail, attempts) {
if (attempts <= 3) { retry() } // 最多重试 3 次
else { fail() }
},
})7.3 Suspense + 异步组件
┌─────────────────────────────────────────────────────────────┐
│ 异步组件加载生命周期 │
├─────────────────────────────────────────────────────────────┤
│ │
│ pending ──delay(200ms)──► loadingComponent 显示 │
│ │ │
│ ├── loader resolve ──► 渲染组件(成功) │
│ ├── timeout ──► errorComponent(超时) │
│ └── error ──► onError → retry 或 errorComponent │
│ │
│ Suspense 统一管理多个异步组件的加载状态 │
│ defineAsyncComponent 提供单个组件的精细控制 │
│ 两者可组合使用 │
│ │
└─────────────────────────────────────────────────────────────┘第8部分:自定义指令
8.1 指令钩子与生命周期
┌─────────────────────────────────────────────────────────────┐
│ 指令生命周期钩子 │
├─────────────────────────────────────────────────────────────┤
│ │
│ created → mounted → beforeUpdate → updated → beforeUnmount → unmounted
│ (绑定) (插入DOM) (数据变化前) (数据变化后) (卸载前) (已卸载)
│ │
│ binding 对象: │
│ • value: 指令值 v-dir="value" │
│ • arg: 参数 v-dir:arg │
│ • modifiers: 修饰符 v-dir.mod1.mod2 │
│ • instance: 组件实例 │
│ │
│ 函数简写: 同时作为 mounted 和 updated 钩子 │
│ │
└─────────────────────────────────────────────────────────────┘8.2 实用指令示例
v-focus —— 自动聚焦:
import type { Directive } from 'vue'
export const vFocus: Directive<HTMLInputElement> = {
mounted(el) { el.focus() },
}v-click-outside —— 点击外部关闭:
import type { Directive, DirectiveBinding } from 'vue'
export const vClickOutside: Directive<HTMLElement> = {
mounted(el: HTMLElement & { __handler?: Function }, binding: DirectiveBinding) {
const handler = (event: MouseEvent) => {
if (!el.contains(event.target as Node)) {
binding.value?.(event)
}
}
el.__handler = handler
document.addEventListener('click', handler)
},
unmounted(el: HTMLElement & { __handler?: Function }) {
if (el.__handler) {
document.removeEventListener('click', el.__handler)
}
},
}<template>
<div v-click-outside="() => (open = false)">
<input @focus="open = true" />
<ul v-if="open">...</ul>
</div>
</template>v-intersect —— 可见性检测:
export const vIntersect: Directive<HTMLElement, () => void> = {
mounted(el, binding) {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) binding.value?.()
}, { threshold: binding.arg ? Number(binding.arg) : 0.1 })
observer.observe(el)
;(el as any).__observer = observer
},
unmounted(el) {
;(el as any).__observer?.disconnect()
},
}<img v-intersect:0.5="loadImage" :data-src="imageUrl" alt="Lazy" />8.3 指令 vs 组件
| 维度 | 自定义指令 | 组件 |
|---|---|---|
| 适用场景 | 直接操作 DOM(focus, scroll, measure) | 渲染 UI 内容(新 DOM 子树) |
| DOM 影响 | 附加行为到已有元素 | 引入新 DOM 结构 |
| 状态管理 | 无内部状态 | 可管理内部状态 |
| 典型示例 | v-focus, v-click-outside, v-intersect | Modal, Dropdown, Tooltip |
综合实战 —— 可复用的搜索选择器
结合本章所有知识点实现一个带搜索、异步加载、点击外部关闭的选择器:
<script setup lang="ts" generic="T extends { id: string; label: string }">
import { ref, computed } from 'vue'
import { useDebounceFn } from '@vueuse/core'
const props = withDefaults(defineProps<{
items: T[]
placeholder?: string
loading?: boolean
}>(), {
placeholder: '请选择...',
loading: false,
})
const model = defineModel<T | null>({ default: null })
const open = ref(false)
const query = ref('')
const emit = defineEmits<{ search: [query: string] }>()
const debouncedSearch = useDebounceFn((q: string) => emit('search', q), 300)
const filteredItems = computed(() => {
if (!query.value) return props.items
const q = query.value.toLowerCase()
return props.items.filter(i => i.label.toLowerCase().includes(q))
})
function select(item: T) {
model.value = item
open.value = false
}
</script>
<template>
<div class="selector" v-click-outside="() => (open = false)">
<input
:value="model?.label ?? ''"
:placeholder="placeholder"
@focus="open = true"
@input="query = ($event.target as HTMLInputElement).value; debouncedSearch(query)"
/>
<div v-if="open" class="dropdown">
<div v-if="loading" class="loading">加载中...</div>
<ul v-else-if="filteredItems.length">
<li
v-for="item in filteredItems"
:key="item.id"
:class="{ active: model?.id === item.id }"
@click="select(item)"
>
{{ item.label }}
</li>
</ul>
<div v-else class="empty">无匹配结果</div>
</div>
</div>
</template>
<style scoped>
.selector { position: relative; }
.dropdown {
position: absolute; top: 100%; left: 0; right: 0;
background: white; border: 1px solid #ddd;
border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);
z-index: 100; max-height: 240px; overflow-y: auto;
}
.active { background: #e8f4fd; }
</style>核心总结
总结1:SFC 编译原理
.vue → @vue/compiler-sfc 三路解析
├── Template → Parse → Transform → Codegen → render()(带 PatchFlag 优化)
├── Script setup → 编译宏展开 → 标准 JS/TS
└── Scoped Style → data-v-xxx 属性选择器隔离总结2:组件契约四要素
| 宏 | 方向 | 作用 |
|---|---|---|
defineProps | 父→子 | 只读输入属性 |
defineEmits | 子→父 | 领域事件契约(描述已发生的事) |
defineExpose | 子→父(ref 访问) | 暴露方法/属性 |
defineSlots | 父→子(内容分发) | 类型安全的插槽定义 |
总结3:v-model 语法糖
v-model="v" ⟺ :modelValue="v" + @update:modelValue="v = $event"
v-model:name="v" ⟺ :name="v" + @update:name="v = $event"
defineModel<T>() → 一站式,自动处理 props + emits(Vue 3.4+)总结4:关键规则速查
- v-if > v-for 优先级:永不共存;用 computed 预过滤
- key = 身份标识:保护组件内部状态,不是性能优化参数
- 插槽 = 函数:
$slots中的函数,接收 props 返回 VNode[] - Teleport:DOM 传送但逻辑层级不变;Suspense:统一异步加载状态;KeepAlive:缓存实例保留状态
章节测试
测试1:SFC 编译
Vue SFC 的 <template> 块经过哪三个阶段变为 render 函数?
测试2:Scoped Style
<style scoped> 的隔离原理是什么?如何穿透子组件?
测试3:v-if 与 v-for
为什么 Vue 3 不允许同一元素上同时使用 v-if 和 v-for?正确做法是什么?
测试4:key 的作用
key 最核心的作用是什么? A. 帮助 Virtual DOM diff 算法更快 B. 赋予节点稳定身份标识,让 Vue 正确追踪 DOM 与数据的对应关系 C. 标记唯一的 CSS 类名 D. Vue Router 识别路由的参数
测试5:defineModel
写出用 defineModel 定义一个具名 v-model title 的代码(Vue 3.4+)。
测试6:Teleport 场景
哪些场景适合 <Teleport>?(多选) A. 模态框(需脱离 overflow:hidden) B. 全局通知 C. 兄弟组件共享数据 D. 全屏 Loading 遮罩
测试7:KeepAlive
被 <KeepAlive> 缓存的组件触发哪些特殊钩子?各适合做什么?
测试8:自定义指令
写出 v-autofocus 指令:挂载后自动聚焦,100ms 后选中所有文本。
参考答案
测试1答案
Parse(HTML → AST)→ Transform(指令展开为 JS 表达式)→ Codegen(生成 render 函数)。编译产物使用 PatchFlag 标记动态内容以优化 diff。
测试2答案
给组件内元素添加唯一 data-v-xxx 属性,CSS 选择器改写为属性选择器(.title → .title[data-v-xxx])。穿透使用 :deep(.child-class)。
测试3答案
Vue 3 中 v-if 优先级高于 v-for,v-if 执行时循环变量未定义。正确做法:用 computed 预过滤,或将 v-for 放在 <template> 上、v-if 放内层元素。
测试4答案
B。key 的核心是身份标识,保护组件内部状态在列表重排时不发生错乱。
测试5答案
<script setup lang="ts">
const title = defineModel<string>('title', { required: true })
</script>测试6答案
A、B、D。Teleport 解决 DOM 位置问题。C 是数据共享问题,应用 Pinia 或 provide/inject。
测试7答案
onActivated(重新获取数据、恢复定时器)和 onDeactivated(清除定时器、保存草稿)。被缓存时不触发 onUnmounted。
测试8答案
import type { Directive } from 'vue'
export const vAutofocus: Directive<HTMLInputElement> = {
mounted(el) { el.focus(); setTimeout(() => el.select(), 100) },
}相关笔记
- [[02-reactivity-and-composition-api]] - 响应式系统与 Composition API
- [[03-router-forms-and-component-architecture]] - 路由、表单与组件架构
- [[04-pinia-state-management]] - Pinia 状态管理
- [[../01-javascript-and-typescript/00-overview]] - JavaScript 与 TypeScript 基础
下一步学习
- [ ] 阅读 02 - 响应式系统与 Composition API
- [ ] 阅读 03 - 路由、表单与组件架构
学习状态:🟡 开始学习