模块一 · 起步与基础
模板与组件:从能写到写对
模板语法只是入口,真正决定可维护性的是组件边界。这一节讲清插值、指令、修饰符、props/emit 与插槽的正确用法。
读完这一节,你能做到
- 区分插值、指令、修饰符各自解决什么问题。
- 用 props 向下、事件向上的方式组织组件通信。
- 判断什么时候该用插槽,而不是再传一个 prop。
- 避开模板里最常见的五类错误。
一、模板语法三件事
| 类型 | 写法 | 作用 |
|---|---|---|
| 插值 | {{ msg }} | 输出文本,自动转义 |
| 指令 | v-if、v-for、v-bind、v-on | 控制结构与绑定 |
| 修饰符 | @click.stop、.prevent、.lazy | 修改事件的默认行为 |
几条硬规则:
v-if与v-for不要放在同一个元素上。v-if优先级更高,会拿不到v-for的变量。v-for必须给稳定key,不要用数组下标(插入排序时会复用错节点)。v-bind的对象语法适合批量传属性,但不要把整个响应式对象直接铺开,容易传多余字段。
二、组件:可复用的视图单元
组件的本质是"把一段视图 + 状态 + 行为封装起来",对外只暴露契约:
props(输入) + events(输出) + slots(内容占位)
<!-- Child.vue -->
<script setup>
const props = defineProps({ label: String, disabled: Boolean });
const emit = defineEmits(["submit"]);
</script>
<template>
<button :disabled="disabled" @click="emit('submit')">
{{ label }}
</button>
</template>
<!-- Parent.vue -->
<Child label="保存" :disabled="loading" @submit="save" />
单向数据流:子组件不直接改 props,而是通过事件请求父组件修改。这样数据变更只有一个入口,便于追踪。
三、props 的细节
- props 是只读的。需要派生值时用
computed,不要直接赋值。 - 对象/数组类型的默认值必须用工厂函数,否则多个实例共享同一个引用。
defineProps的类型声明在编译期被解析,运行时校验只在开发环境生效。- 需要"透传"未声明的属性时,用
v-bind="$attrs",并注意inheritAttrs的影响。
四、插槽:内容由父组件决定
当一个组件的"结构固定、内容可变"时,用插槽而不是再传一个 HTML 字符串:
<!-- Card.vue -->
<template>
<div class="card">
<header><slot name="title" /></header>
<main><slot /></main>
</div>
</template>
- 默认插槽:只传一段内容。
- 具名插槽:
#title指定放到哪个位置。 - 作用域插槽:子组件把内部数据"回传"给父组件决定怎么渲染,适合表格、列表这类通用组件。
判断标准:如果父组件需要控制"长什么样",用插槽;如果只是传数据,用 props。
五、组件注册与命名
<script setup>中的组件按文件名自动可用,无需注册。- 组件名用多单词 PascalCase(
UserCard),避免与原生标签冲突。 - 递归组件需要显式命名(
defineOptions({ name: 'TreeNode' }))。 - 全局注册只适合基础组件库,业务组件优先局部引入,便于 Tree-shaking。
六、模板里的表达式与作用域
模板表达式只能访问组件实例暴露的变量与少量全局白名单(如 Math、Date)。它适合做简单的取值与判断,不适合写多行逻辑:
- 复杂计算放 computed,模板只做展示。
- 避免在模板里调用会创建新对象的方法(每次渲染都产生新引用,可能触发子组件更新)。
- 事件处理函数可以直接写语句,但超过一行就该抽成方法。
模板的作用域是“组件实例”,不是 JavaScript 的词法作用域。这一点决定了你无法在模板里访问模块级变量,除非把它暴露出来。模板也不是 HTML 字符串:它会被编译成 render 函数,因此支持表达式、指令与编译期优化;浏览器不会帮你纠正错误的嵌套,写错标签会在编译期报错。
七、自定义指令:什么时候值得
自定义指令适合“直接操作 DOM”的横切逻辑,例如聚焦、点击外部关闭、懒加载:
const vFocus = {
mounted(el) {
el.focus();
},
};
但它有三个代价:逻辑不可组合、难以测试、与 SSR 不友好。能用组件或组合式函数解决的,不要用指令。 典型适用场景是“必须触碰真实 DOM”且与业务状态无关的行为。
八、script setup 编译成了什么
<script setup> 不是运行时特性,而是编译期语法糖:顶层的变量与函数会被暴露给模板,defineProps/defineEmits 被编译成 props 与 emits 选项,组件按文件名自动注册。
它带来的两个实际影响:不能使用 this,因为不存在组件实例上下文;顶层 await 会被编译成 async setup,需要配合 Suspense 使用。
判断组件是否好测:输入只有 props,输出只有事件与渲染结果。把请求、路由、全局状态直接写在组件里,测试时就要模拟一整圈环境。三个实践:请求放组合式函数、事件抛语义化动作、纯展示组件不依赖 store。
| 需求 | 首选 | 次选 |
|---|---|---|
| 传数据 | props | provide |
| 改父级 | emit | v-model |
| 换结构 | 插槽 | 动态组件 |
组件名、props 名、事件名都是对外契约,改名等于破坏 API。组件是 Vue 应用的最小复用单位,模板只是它的渲染声明;先定契约,再写实现,组件才不会越长越乱。命名要表达意图,而不是实现细节,测试也从契约开始。契约稳定,重构才安全;先把接口想清楚,契约是给未来的自己看的。
九、常见坑
v-if和v-for混用,取不到循环变量。key用下标,列表插入后状态错位。- 子组件直接修改 props,父组件数据被意外改掉。
- 对象类型 props 的默认值写成对象字面量,多个实例共享引用。
- 用插槽传一大段 HTML 字符串,失去编译期优化。
十、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 写一个
Button组件,支持type、disabled、默认插槽与click事件。 - 写一个
DataTable组件,用作用域插槽让父组件决定单元格渲染。 - 找出你项目里一个"用 prop 传 HTML 字符串"的地方,改成插槽。
② 让 AI 写同一题
把第 2 题(列表 + 插槽)交给 AI,补一句 用 <script setup> + defineProps/defineEmits。
③ 对比要盯的点
- 列表
:key它用的是 id 还是 index?插入一条就错位。 - 它有没有在子组件里直接改父组件传进来的对象?这是最容易埋的隐性耦合。
- 它用插槽还是又加了一个 props 表达“内容区”?加 props 就把扩展性写死了。
④ 一句话取舍
它的写法能跑,但哪一条会让父组件的数据被偷偷改掉?
十一、自测
v-if与v-for为什么不能放在同一元素上?- 单向数据流怎么保证可追踪?
- 什么时候用插槽而不是 prop?
- 对象类型 props 的默认值为什么要用工厂函数?
key用下标会带来什么问题?
参考答案要点:优先级导致变量不可用;唯一入口;父组件控制结构;避免共享引用;复用错节点导致状态错乱。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★★☆ 为什么不建议 v-if 和 v-for 一起用?
- ★★★★★ Vue 组件间通信有哪些方式?分别适用什么场景?
- ★★★★☆ 作用域插槽、动态组件和 keep-alive 分别解决什么问题?
- ★★★★☆ keep-alive 的原理是什么?有哪些生命周期钩子?
提示:读完可以顺手做几道对应题目,检验是否真的理解