同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~
(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)
你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?
你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?
就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。
一天只有24小时,时间永远不够用,常常感到力不从心。
技术行业,本就是逆水行舟,不进则退。
如果你也有同样的困扰,别慌。
从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲。
这一次,我们一起慢慢来,扎扎实实变强。
不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,
咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。
模板语法扫盲:v-if / v-for / v-model / slot 的常见组合模式
适用版本:Vue 3(组合式 API +
<script setup>),Vue 2 用户同样可以对照理解核心概念。 定位:不讲底层编译原理,只聊日常写代码到底该怎么用、为什么这么用、坑在哪。
一、开篇:为什么要专门聊模板语法?
写 Vue 的人,天天都在和模板打交道。但你有没有遇到过:
- v-if 和 v-for 写在同一个标签上,结果行为跟预期不一样?
- 列表渲染忘加 key,导致表单输入框串值?
- v-model 绑在组件上,不知道怎么拆、怎么自定义修饰符?
- slot 到底怎么传数据,作用域插槽的写法老是记不住?
这些都是模板语法层面的问题。它们不难,但如果概念混着用,就会产出一堆"能跑但不好维护"的代码。
本文从 v-if → v-for → v-model → slot 逐一讲清楚,最后给出表单、列表、弹窗三个实战场景中的组合写法,让你看完就能对照项目校准。
二、概念速览
先用一张表快速回顾四者的职责:
| 指令/特性 | 一句话 | 典型场景 |
|---|---|---|
v-if / v-else-if / v-else | 条件渲染,不满足条件的 DOM 根本不会存在 | 权限控制、状态切换、空数据占位 |
v-show | 条件显示,DOM 始终存在,靠 display:none 切换 | 频繁切换的 tab、折叠面板 |
v-for | 列表渲染,遍历数组/对象生成多个元素 | 表格行、卡片列表、下拉选项 |
v-model | 双向绑定语法糖,本质是 :value + @input 的组合 | 表单输入、组件间数据同步 |
slot | 内容分发,让父组件往子组件"塞"内容 | 弹窗、卡片、布局组件的自定义区域 |
记住一个原则:模板是声明式的——你描述"想要什么",Vue 帮你处理"怎么更新 DOM"。搞清楚每个指令"描述的是什么意图",写起来就不会乱。
三、v-if vs v-show:什么时候该用哪个?
3.1 核心区别
<!-- v-if:条件为 false 时,DOM 完全不存在 -->
<div v-if="isLogin">欢迎回来</div>
<!-- v-show:条件为 false 时,DOM 存在但 display:none -->
<div v-show="isLogin">欢迎回来</div>
看起来效果一样,但底层差异很大:
| 对比项 | v-if | v-show |
|---|---|---|
| DOM 是否存在 | 条件为 false 时不渲染 | 始终渲染,CSS 隐藏 |
| 切换开销 | 高(销毁 + 重建组件) | 低(只切换 CSS) |
| 初始渲染 | 条件为 false 时不渲染,省初始开销 | 不管条件,都会渲染一次 |
配合 <transition> | 支持 | 支持 |
能用 v-else | 能 | 不能 |
3.2 选型口诀
切换频繁用 v-show,切换少用 v-if。
具体来说:
- Tab 切换、折叠面板、下拉菜单这类频繁切换的 → 用
v-show,避免反复销毁重建。 - 权限判断、角色分支、空数据占位这类一次性或极少切换的 → 用
v-if,条件不满足时根本不渲染,节省初始开销。 - 需要用 v-else-if / v-else 做多分支逻辑的 → 只能用
v-if。
3.3 实战示例:带权限的页面区块
<template>
<div class="dashboard">
<!-- 管理员看到的面板 -->
<AdminPanel v-if="role === 'admin'" />
<!-- 普通用户看到的面板 -->
<UserPanel v-else-if="role === 'user'" />
<!-- 未登录提示 -->
<LoginTip v-else />
</div>
</template>
<script setup>
import { ref } from 'vue'
import AdminPanel from './AdminPanel.vue'
import UserPanel from './UserPanel.vue'
import LoginTip from './LoginTip.vue'
const role = ref('user') // 'admin' | 'user' | 'guest'
</script>
这里用 v-if / v-else-if / v-else 做角色分支,逻辑清晰,且不需要的组件根本不会挂载——既省性能,也避免了不该出现的组件意外执行了 onMounted 里的请求。
3.4 一个容易忽视的点:v-if 会销毁组件状态
<template>
<button @click="show = !show">切换</button>
<!-- 每次 show 从 false → true,Counter 会重新创建,count 归零 -->
<Counter v-if="show" />
</template>
如果你希望隐藏时保留组件内部状态(比如用户填了一半的表单),要么改用 v-show,要么用 <KeepAlive> 包裹:
<KeepAlive>
<Counter v-if="show" />
</KeepAlive>
<KeepAlive> 会在组件被 v-if 移除时缓存实例,再次显示时恢复状态,而不是重新创建。
四、v-for:列表渲染的正确姿势
4.1 基本用法
<template>
<ul>
<li v-for="item in list" :key="item.id">
{{ item.name }}
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue'
const list = ref([
{ id: 1, name: '苹果' },
{ id: 2, name: '香蕉' },
{ id: 3, name: '橘子' }
])
</script>
几个要点:
v-for="item in list"中的item是每次迭代的当前元素,list是数据源。:key必须绑定一个唯一且稳定的值(通常是id),这是 Vue diff 算法高效更新 DOM 的依据。- 也支持
(item, index) in list拿到索引。
4.2 为什么 key 不能用 index?
这是面试常考也是实际最容易踩的坑。看个例子:
<template>
<div>
<div v-for="(item, index) in list" :key="index">
<span>{{ item.name }}</span>
<input placeholder="备注" />
</div>
<button @click="addToTop">在最前面插入一项</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const list = ref([
{ id: 1, name: '苹果' },
{ id: 2, name: '香蕉' }
])
function addToTop() {
list.value.unshift({ id: Date.now(), name: '新水果' })
}
</script>
操作步骤:在"苹果"那行的 input 里输入"好吃",然后点击按钮插入一项。
用 :key="index" 的结果:你会发现"好吃"跑到了"新水果"那行——因为 Vue 按 index 复用 DOM,index=0 的 DOM 被复用给了新水果,input 的值就串了。
用 :key="item.id" 的结果:每个 DOM 和对应的 id 绑定,插入新项后,旧 DOM 不会错位,input 值正确保留在"苹果"那行。
结论:只要列表会增删或排序,永远用唯一 id 做 key,不要用 index。
4.3 v-for 遍历对象
<template>
<div v-for="(value, key) in userInfo" :key="key">
{{ key }}: {{ value }}
</div>
</template>
<script setup>
import { reactive } from 'vue'
const userInfo = reactive({
name: '张三',
age: 28,
city: '深圳'
})
</script>
遍历对象时,(value, key) 分别是属性值和属性名。还可以拿到第三个参数 (value, key, index)。
4.4 v-for 与 v-if 的优先级问题(重点!)
这是 Vue 2 和 Vue 3 差异最大的地方之一:
| 版本 | 优先级 |
|---|---|
| Vue 2 | v-for 优先于 v-if(先循环,再判断每项) |
| Vue 3 | v-if 优先于 v-for(先判断,再循环) |
在 Vue 3 中,如果你这样写:
<!-- ❌ 错误写法:v-if 先执行,此时 item 还不存在,会报错 -->
<li v-for="item in list" v-if="item.active" :key="item.id">
{{ item.name }}
</li>
因为 v-if 优先级更高,它在 v-for 之前执行,此时 item 还没有被定义,直接访问 item.active 会报错。
正确做法有两种:
方法一:用 <template> 包一层,把 v-for 放外面
<template v-for="item in list" :key="item.id">
<li v-if="item.active">
{{ item.name }}
</li>
</template>
<template> 不会渲染成真实 DOM,只是一个逻辑容器。v-for 在外层先遍历,内层 v-if 再对每项做判断,逻辑清晰。
方法二:用计算属性提前过滤(推荐)
<template>
<li v-for="item in activeList" :key="item.id">
{{ item.name }}
</li>
</template>
<script setup>
import { ref, computed } from 'vue'
const list = ref([
{ id: 1, name: '苹果', active: true },
{ id: 2, name: '香蕉', active: false },
{ id: 3, name: '橘子', active: true }
])
const activeList = computed(() => list.value.filter(item => item.active))
</script>
用计算属性提前过滤好处更多:模板更干净、过滤逻辑可复用、计算属性自带缓存。这是官方推荐的做法。
五、v-model:双向绑定的本质与进阶
5.1 v-model 是语法糖
在原生元素上:
<!-- 这两种写法完全等价 -->
<input v-model="msg" />
<input :value="msg" @input="msg = $event.target.value" />
v-model 本质就是帮你省了"绑值 + 监听事件"这两步。理解这一点,后面在组件上用 v-model 就不会困惑了。
5.2 常用表单元素的 v-model
<template>
<div>
<!-- 文本输入 -->
<input v-model="form.name" placeholder="姓名" />
<!-- 多行文本 -->
<textarea v-model="form.remark" placeholder="备注"></textarea>
<!-- 单选 -->
<label><input type="radio" v-model="form.gender" value="male" /> 男</label>
<label><input type="radio" v-model="form.gender" value="female" /> 女</label>
<!-- 多选 -->
<label><input type="checkbox" v-model="form.hobbies" value="reading" /> 阅读</label>
<label><input type="checkbox" v-model="form.hobbies" value="coding" /> 编程</label>
<label><input type="checkbox" v-model="form.hobbies" value="gaming" /> 游戏</label>
<!-- 下拉选择 -->
<select v-model="form.city">
<option value="">请选择</option>
<option value="beijing">北京</option>
<option value="shanghai">上海</option>
<option value="shenzhen">深圳</option>
</select>
<p>当前表单数据:{{ form }}</p>
</div>
</template>
<script setup>
import { reactive } from 'vue'
const form = reactive({
name: '',
remark: '',
gender: 'male',
hobbies: [], // 多选绑定数组
city: ''
})
</script>
几个注意点:
- 单个 checkbox 绑定布尔值(
true/false);多个 checkbox 共用同一个 v-model 绑定数组,选中的value会被收集进数组。 - radio 多个共用同一个 v-model,选中哪个就是哪个的
value。 - select 绑定的是选中
<option>的value。
5.3 修饰符
| 修饰符 | 作用 | 示例 |
|---|---|---|
.lazy | 把 input 事件改为 change 事件(失焦时才更新) | v-model.lazy="msg" |
.number | 自动将输入值转为数字(parseFloat) | v-model.number="age" |
.trim | 自动去除首尾空格 | v-model.trim="name" |
<!-- 常见组合:年龄输入框 -->
<input v-model.number="form.age" type="number" placeholder="年龄" />
<!-- 常见组合:用户名输入框 -->
<input v-model.trim="form.username" placeholder="用户名" />
5.4 组件上的 v-model(Vue 3)
在自定义组件上用 v-model,这是 Vue 3 里非常核心的用法。
父组件:
<template>
<!-- 等价于 :modelValue="keyword" @update:modelValue="keyword = $event" -->
<SearchInput v-model="keyword" />
<p>搜索词:{{ keyword }}</p>
</template>
<script setup>
import { ref } from 'vue'
import SearchInput from './SearchInput.vue'
const keyword = ref('')
</script>
子组件 SearchInput.vue:
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
placeholder="请输入关键词"
/>
</template>
<script setup>
defineProps({
modelValue: String
})
defineEmits(['update:modelValue'])
</script>
在 Vue 3 中,组件上的 v-model 默认绑定 modelValue 属性和 update:modelValue 事件。子组件接收 prop,通过 emit 通知父组件更新,就完成了双向绑定。
5.5 多个 v-model 绑定(Vue 3 独有)
Vue 3 支持在一个组件上绑定多个 v-model,通过自定义参数名:
父组件:
<template>
<UserForm v-model:name="userName" v-model:age="userAge" />
<p>{{ userName }} - {{ userAge }}</p>
</template>
<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'
const userName = ref('张三')
const userAge = ref(28)
</script>
子组件 UserForm.vue:
<template>
<div>
<input
:value="name"
@input="$emit('update:name', $event.target.value)"
placeholder="姓名"
/>
<input
:value="age"
@input="$emit('update:age', Number($event.target.value))"
type="number"
placeholder="年龄"
/>
</div>
</template>
<script setup>
defineProps({
name: String,
age: Number
})
defineEmits(['update:name', 'update:age'])
</script>
这在 Vue 2 中需要用 .sync 修饰符或者多次手动绑定,Vue 3 的多 v-model 写法更统一、更直观。
5.6 使用 defineModel 简化(Vue 3.4+)
Vue 3.4 引入了 defineModel 宏,可以进一步简化子组件中 v-model 的写法:
<!-- 子组件 SearchInput.vue(Vue 3.4+ 写法) -->
<template>
<input v-model="model" placeholder="请输入关键词" />
</template>
<script setup>
const model = defineModel()
</script>
defineModel() 返回一个 ref,它自动帮你处理了 prop 接收和 emit 触发。不用再手动写 defineProps + defineEmits + :value + @input 那一套了,子组件可以直接对这个 ref 用 v-model。
多个 v-model 的简化:
<script setup>
const name = defineModel('name')
const age = defineModel('age', { type: Number })
</script>
六、slot:内容分发的完整指南
6.1 为什么需要 slot?
想象你封装了一个 Card 组件,卡片的外壳样式是固定的,但卡片里的内容每次都不一样。如果把内容写死在子组件里,就失去了复用性。slot 就是解决这个问题的——让父组件自由决定子组件内部某个区域渲染什么。
6.2 默认插槽
子组件 Card.vue:
<template>
<div class="card">
<div class="card-body">
<!-- 这个 slot 就是"留给父组件填内容"的位置 -->
<slot>
<!-- slot 标签里写的是默认内容,父组件不传时显示 -->
暂无内容
</slot>
</div>
</div>
</template>
父组件使用:
<template>
<!-- 传了内容 → 显示传入的内容 -->
<Card>
<p>这是一段自定义内容</p>
</Card>
<!-- 没传内容 → 显示默认的"暂无内容" -->
<Card />
</template>
6.3 具名插槽
一个组件如果有多个可自定义区域,就需要给 slot 起名字:
子组件 Dialog.vue:
<template>
<div class="dialog-overlay" v-if="visible">
<div class="dialog">
<div class="dialog-header">
<slot name="header">
<span>默认标题</span>
</slot>
</div>
<div class="dialog-body">
<slot>
<!-- 没有 name 的就是默认插槽 -->
默认内容
</slot>
</div>
<div class="dialog-footer">
<slot name="footer">
<button @click="$emit('close')">关闭</button>
</slot>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
visible: Boolean
})
defineEmits(['close'])
</script>
父组件使用:
<template>
<Dialog :visible="showDialog" @close="showDialog = false">
<!-- 具名插槽用 #name 语法(v-slot:name 的缩写) -->
<template #header>
<h3>用户详情</h3>
</template>
<!-- 默认插槽的内容直接写,或用 #default -->
<p>这里是弹窗的主体内容。</p>
<p>可以放任何东西。</p>
<template #footer>
<button @click="handleConfirm">确认</button>
<button @click="showDialog = false">取消</button>
</template>
</Dialog>
</template>
<script setup>
import { ref } from 'vue'
import Dialog from './Dialog.vue'
const showDialog = ref(false)
function handleConfirm() {
// 确认逻辑
showDialog.value = false
}
</script>
具名插槽让组件的不同区域都可以被父组件自定义,这在弹窗、布局、面板组件中非常常用。
6.4 作用域插槽(Scoped Slots)
有时候,子组件有数据,但展示方式想交给父组件决定。作用域插槽就是用来"把子组件的数据传给父组件的插槽内容"的。
子组件 UserList.vue:
<template>
<ul>
<li v-for="user in users" :key="user.id">
<!-- 通过 slot 把 user 数据"暴露"给父组件 -->
<slot :user="user" :index="index">
<!-- 默认展示方式 -->
{{ user.name }}
</slot>
</li>
</ul>
</template>
<script setup>
defineProps({
users: Array
})
</script>
父组件使用:
<template>
<!-- 通过 #default="{ user }" 接收子组件传出的数据 -->
<UserList :users="userList">
<template #default="{ user }">
<div class="user-card">
<img :src="user.avatar" />
<span>{{ user.name }}</span>
<span class="tag">{{ user.role }}</span>
<button @click="editUser(user)">编辑</button>
</div>
</template>
</UserList>
</template>
<script setup>
import { ref } from 'vue'
import UserList from './UserList.vue'
const userList = ref([
{ id: 1, name: '张三', role: 'admin', avatar: '/img/zhangsan.png' },
{ id: 2, name: '李四', role: 'user', avatar: '/img/lisi.png' }
])
function editUser(user) {
console.log('编辑', user.name)
}
</script>
一句话理解:子组件说"我有这些数据,你来决定怎么显示";父组件说"好,我用你的数据,按我的方式渲染"。
这在表格组件、列表组件、下拉组件中特别常见——组件负责循环和逻辑,展示方式留给使用者自定义。
七、实战组合模式
前面把四个特性拆开讲了,实际项目里它们总是组合使用的。下面用三个高频场景演示。
7.1 场景一:动态表单(v-for + v-model + v-if)
需求:根据配置动态渲染表单项,不同类型(input / select / radio)走不同渲染逻辑。
<template>
<form @submit.prevent="handleSubmit">
<div v-for="field in formFields" :key="field.key" class="form-item">
<label>{{ field.label }}</label>
<!-- 文本输入 -->
<input
v-if="field.type === 'input'"
v-model="formData[field.key]"
:placeholder="field.placeholder"
/>
<!-- 下拉选择 -->
<select
v-else-if="field.type === 'select'"
v-model="formData[field.key]"
>
<option value="">请选择</option>
<option
v-for="opt in field.options"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
<!-- 单选 -->
<div v-else-if="field.type === 'radio'" class="radio-group">
<label v-for="opt in field.options" :key="opt.value">
<input
type="radio"
v-model="formData[field.key]"
:value="opt.value"
/>
{{ opt.label }}
</label>
</div>
</div>
<button type="submit">提交</button>
<pre>{{ formData }}</pre>
</form>
</template>
<script setup>
import { reactive } from 'vue'
const formFields = [
{ key: 'name', label: '姓名', type: 'input', placeholder: '请输入姓名' },
{
key: 'city',
label: '城市',
type: 'select',
options: [
{ value: 'beijing', label: '北京' },
{ value: 'shanghai', label: '上海' },
{ value: 'shenzhen', label: '深圳' }
]
},
{
key: 'gender',
label: '性别',
type: 'radio',
options: [
{ value: 'male', label: '男' },
{ value: 'female', label: '女' }
]
}
]
const formData = reactive({
name: '',
city: '',
gender: ''
})
function handleSubmit() {
console.log('提交数据:', { ...formData })
}
</script>
拆解:
v-for遍历字段配置数组,每项生成一个表单控件。v-if / v-else-if根据field.type分支渲染不同类型的控件。v-model绑定到formData[field.key],实现动态双向绑定。- 嵌套
v-for(select 的 option、radio 的选项)各自有自己的:key。
这种模式在后台管理系统的动态表单、配置化表单中非常实用,字段配置可以从接口拿,也可以写在配置文件里。
7.2 场景二:可编辑列表(v-for + v-model + v-if + slot)
需求:一个用户列表,支持行内编辑,用 slot 让列的渲染方式可自定义。
子组件 EditableTable.vue:
<template>
<table class="editable-table">
<thead>
<tr>
<th v-for="col in columns" :key="col.key">{{ col.title }}</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in data" :key="row.id">
<td v-for="col in columns" :key="col.key">
<!-- 编辑状态 -->
<template v-if="editingId === row.id">
<!-- 有自定义编辑插槽就用插槽,否则用默认 input -->
<slot
:name="`edit-${col.key}`"
:row="row"
:value="editForm[col.key]"
:update="(val) => (editForm[col.key] = val)"
>
<input v-model="editForm[col.key]" />
</slot>
</template>
<!-- 展示状态 -->
<template v-else>
<slot :name="col.key" :row="row" :value="row[col.key]">
{{ row[col.key] }}
</slot>
</template>
</td>
<td>
<template v-if="editingId === row.id">
<button @click="handleSave(row)">保存</button>
<button @click="handleCancel">取消</button>
</template>
<template v-else>
<button @click="handleEdit(row)">编辑</button>
</template>
</td>
</tr>
</tbody>
</table>
</template>
<script setup>
import { ref, reactive } from 'vue'
const props = defineProps({
columns: Array,
data: Array
})
const emit = defineEmits(['save'])
const editingId = ref(null)
const editForm = reactive({})
function handleEdit(row) {
editingId.value = row.id
Object.assign(editForm, row)
}
function handleSave(row) {
emit('save', { ...editForm })
editingId.value = null
}
function handleCancel() {
editingId.value = null
}
</script>
父组件使用:
<template>
<EditableTable :columns="columns" :data="userList" @save="onSave">
<!-- 自定义"角色"列的展示方式 -->
<template #role="{ value }">
<span :class="['tag', value]">{{ value === 'admin' ? '管理员' : '普通用户' }}</span>
</template>
<!-- 自定义"角色"列的编辑方式:下拉选择 -->
<template #edit-role="{ value, update }">
<select :value="value" @change="update($event.target.value)">
<option value="admin">管理员</option>
<option value="user">普通用户</option>
</select>
</template>
</EditableTable>
</template>
<script setup>
import { ref } from 'vue'
import EditableTable from './EditableTable.vue'
const columns = [
{ key: 'name', title: '姓名' },
{ key: 'role', title: '角色' }
]
const userList = ref([
{ id: 1, name: '张三', role: 'admin' },
{ id: 2, name: '李四', role: 'user' }
])
function onSave(updated) {
const idx = userList.value.findIndex(u => u.id === updated.id)
if (idx > -1) {
userList.value[idx] = { ...updated }
}
}
</script>
拆解:
- 外层
v-for遍历行,内层v-for遍历列。 v-if="editingId === row.id"控制当前行是编辑状态还是展示状态。v-model="editForm[col.key]"在编辑状态下实现双向绑定。- 作用域插槽:子组件把
row、value、update传给父组件,父组件可以自定义任意列的展示和编辑方式;如果不传插槽,就走默认渲染。
这就是 v-for + v-if + v-model + slot 四者联动的典型模式。
7.3 场景三:通用弹窗(slot + v-if + v-model)
需求:封装一个通用弹窗组件,弹窗的显示/隐藏用 v-model 控制,内容区域用 slot 自定义。
子组件 Modal.vue:
<template>
<Teleport to="body">
<div v-if="modelValue" class="modal-overlay" @click.self="close">
<div class="modal-content">
<div class="modal-header">
<slot name="header">
<h3>{{ title }}</h3>
</slot>
<button class="close-btn" @click="close">×</button>
</div>
<div class="modal-body">
<slot />
</div>
<div class="modal-footer">
<slot name="footer" :close="close">
<button @click="close">关闭</button>
</slot>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
const props = defineProps({
modelValue: Boolean,
title: {
type: String,
default: '提示'
}
})
const emit = defineEmits(['update:modelValue'])
function close() {
emit('update:modelValue', false)
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #fff;
border-radius: 8px;
min-width: 400px;
max-width: 90vw;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.modal-body { padding: 20px; }
.modal-footer {
padding: 12px 20px;
border-top: 1px solid #eee;
text-align: right;
}
.close-btn {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
}
</style>
父组件使用:
<template>
<button @click="showModal = true">打开弹窗</button>
<Modal v-model="showModal" title="编辑用户">
<!-- 弹窗内容区放一个表单 -->
<form>
<div class="form-item">
<label>姓名</label>
<input v-model="editForm.name" />
</div>
<div class="form-item">
<label>邮箱</label>
<input v-model="editForm.email" type="email" />
</div>
</form>
<!-- 自定义 footer,拿到 close 方法 -->
<template #footer="{ close }">
<button @click="handleSave(close)">保存</button>
<button @click="close">取消</button>
</template>
</Modal>
</template>
<script setup>
import { ref, reactive } from 'vue'
import Modal from './Modal.vue'
const showModal = ref(false)
const editForm = reactive({
name: '',
email: ''
})
function handleSave(close) {
console.log('保存数据:', { ...editForm })
close()
}
</script>
拆解:
v-model="showModal"让弹窗的显隐状态和父组件双向绑定,点遮罩层或关闭按钮都能关闭。v-if="modelValue"控制弹窗 DOM 的渲染/销毁。<Teleport to="body">将弹窗渲染到body下,避免被父元素的overflow:hidden或z-index层级影响。- 默认插槽放主体内容,具名插槽 #header #footer 自定义头尾。
- footer 插槽是作用域插槽,把
close方法传给父组件,父组件的"保存"按钮可以在完成逻辑后调用close()关闭弹窗。
八、踩坑指南
| 坑 | 原因 | 正确做法 |
|---|---|---|
v-for 和 v-if 写在同一标签上 | Vue 3 中 v-if 优先级高于 v-for,访问不到迭代变量 | 用 <template v-for> 包裹,或用计算属性预过滤 |
v-for 用 index 做 key | 列表增删/排序时 DOM 复用错乱,输入框串值 | 用数据的唯一 id 做 key |
组件上 v-model 不生效 | 子组件没有接收 modelValue 或没有 emit update:modelValue | 完整实现 prop + emit,或用 defineModel(3.4+) |
v-if 切换后组件状态丢失 | v-if 为 false 时组件被销毁 | 用 v-show 或 <KeepAlive> 缓存 |
| slot 内容拿不到子组件数据 | 普通插槽是父组件作用域,访问不到子组件变量 | 用作用域插槽,子组件通过 slot 传出数据 |
动态表单 v-model 绑定不响应 | reactive 对象新增属性不会自动响应式 | 初始化时就声明好所有字段,或用 ref 管理 |
v-for 的 <template> 上忘了加 :key | Vue 3 要求 key 放在 <template> 上而非内部元素上 | <template v-for="..." :key="..."> |
补充说明几个高频问题:
1. 为什么 v-for 的 <template> 上要写 :key?
<!-- ✅ 正确:key 写在 template 上 -->
<template v-for="item in list" :key="item.id">
<h3>{{ item.title }}</h3>
<p>{{ item.desc }}</p>
</template>
<!-- ❌ 错误:key 写在内部元素上 -->
<template v-for="item in list">
<h3 :key="item.id">{{ item.title }}</h3>
<p>{{ item.desc }}</p>
</template>
在 Vue 3 中,<template v-for> 的 :key 必须写在 <template> 标签自身上,这是 Vue 3 和 Vue 2 的区别之一。
2. 动态表单初始化陷阱
const formData = reactive({})
// ❌ 后续动态添加的属性可能不是响应式的(取决于版本和写法)
formData.newField = 'value'
// ✅ 正确做法:初始化时就把所有可能的 key 声明好
const formData = reactive({
name: '',
city: '',
gender: ''
})
如果表单字段是动态配置的,建议在初始化时遍历配置,把所有字段都先声明一遍:
const formData = reactive(
Object.fromEntries(formFields.map(f => [f.key, f.defaultValue ?? '']))
)
九、小结
| 指令/特性 | 核心定位 | 最常搭配 |
|---|---|---|
v-if / v-show | 控制"显示不显示" | 权限分支、空状态、编辑/展示切换 |
v-for | 控制"渲染多少个" | 列表、表格、选项、标签 |
v-model | 控制"数据怎么同步" | 表单输入、组件双向绑定 |
slot | 控制"内容谁说了算" | 弹窗、卡片、布局的可定制区域 |
记住四个原则:
- v-if 和 v-for 不要写在同一个标签上。要么用
<template>分层,要么用计算属性预过滤。 - v-for 的 key 用唯一 id,不用 index。除非你百分之百确定列表不会增删排序。
- v-model 在组件上是语法糖。搞清楚它拆开是 prop + emit,写自定义组件就不会晕。Vue 3.4+ 可以直接用
defineModel省代码。 - slot 分三层理解:默认插槽(塞内容)→ 具名插槽(塞到指定位置)→ 作用域插槽(子传数据,父定展示)。
模板语法是 Vue 里每天都在用的东西,把这些组合模式搞明白,写起来就会又快又稳,也不容易埋坑。
🔍 本系列专栏导航
一、《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,你的电子学友,我们下一篇干货见~