Vue 核心语法与组件模式篇:模板语法扫盲 | v-if、v-for、v-model、slot 的常见组合模式

9 阅读14分钟

同学们好,我是 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-ifv-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 2v-for 优先于 v-if(先循环,再判断每项)
Vue 3v-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自动将输入值转为数字(parseFloatv-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]" 在编辑状态下实现双向绑定。
  • 作用域插槽:子组件把 rowvalueupdate 传给父组件,父组件可以自定义任意列的展示和编辑方式;如果不传插槽,就走默认渲染。

这就是 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">&times;</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:hiddenz-index 层级影响。
  • 默认插槽放主体内容,具名插槽 #header #footer 自定义头尾。
  • footer 插槽是作用域插槽,把 close 方法传给父组件,父组件的"保存"按钮可以在完成逻辑后调用 close() 关闭弹窗。

八、踩坑指南

原因正确做法
v-forv-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> 上忘了加 :keyVue 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控制"内容谁说了算"弹窗、卡片、布局的可定制区域

记住四个原则:

  1. v-if 和 v-for 不要写在同一个标签上。要么用 <template> 分层,要么用计算属性预过滤。
  2. v-for 的 key 用唯一 id,不用 index。除非你百分之百确定列表不会增删排序。
  3. v-model 在组件上是语法糖。搞清楚它拆开是 prop + emit,写自定义组件就不会晕。Vue 3.4+ 可以直接用 defineModel 省代码。
  4. 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,你的电子学友,我们下一篇干货见~