知

模块一 · 起步与基础

第 4 / 17 节16 分钟阅读型

模板与组件:从能写到写对

模板语法只是入口,真正决定可维护性的是组件边界。这一节讲清插值、指令、修饰符、props/emit 与插槽的正确用法。

读完这一节,你能做到

  1. 区分插值、指令、修饰符各自解决什么问题。
  2. 用 props 向下、事件向上的方式组织组件通信。
  3. 判断什么时候该用插槽,而不是再传一个 prop。
  4. 避开模板里最常见的五类错误。

一、模板语法三件事

类型写法作用
插值{{ 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。

需求首选次选
传数据propsprovide
改父级emitv-model
换结构插槽动态组件

组件名、props 名、事件名都是对外契约,改名等于破坏 API。组件是 Vue 应用的最小复用单位,模板只是它的渲染声明;先定契约,再写实现,组件才不会越长越乱。命名要表达意图,而不是实现细节,测试也从契约开始。契约稳定,重构才安全;先把接口想清楚,契约是给未来的自己看的。

九、常见坑

  1. v-if 和 v-for 混用,取不到循环变量。
  2. key 用下标,列表插入后状态错位。
  3. 子组件直接修改 props,父组件数据被意外改掉。
  4. 对象类型 props 的默认值写成对象字面量,多个实例共享引用。
  5. 用插槽传一大段 HTML 字符串,失去编译期优化。

十、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 写一个 Button 组件,支持 type、disabled、默认插槽与 click 事件。
  2. 写一个 DataTable 组件,用作用域插槽让父组件决定单元格渲染。
  3. 找出你项目里一个"用 prop 传 HTML 字符串"的地方,改成插槽。

② 让 AI 写同一题

把第 2 题(列表 + 插槽)交给 AI,补一句 用 <script setup> + defineProps/defineEmits。

③ 对比要盯的点

  • 列表 :key 它用的是 id 还是 index?插入一条就错位。
  • 它有没有在子组件里直接改父组件传进来的对象?这是最容易埋的隐性耦合。
  • 它用插槽还是又加了一个 props 表达“内容区”?加 props 就把扩展性写死了。

④ 一句话取舍

它的写法能跑,但哪一条会让父组件的数据被偷偷改掉?

十一、自测

  1. v-if 与 v-for 为什么不能放在同一元素上?
  2. 单向数据流怎么保证可追踪?
  3. 什么时候用插槽而不是 prop?
  4. 对象类型 props 的默认值为什么要用工厂函数?
  5. key 用下标会带来什么问题?

参考答案要点:优先级导致变量不可用;唯一入口;父组件控制结构;避免共享引用;复用错节点导致状态错乱。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解