码上问学问代码之理,学编程之道

第 2 章 · 模板语法与指令

上一章你把项目跑起来了,但页面还是脚手架自带的欢迎页。这一章教你把自己的数据「接」到页面上:用 {{ }} 显示内容、用 v-bind 绑属性、用 v-on 接住点击、用 v-model 做双向绑定。学完这四件工具,你就能独立写出「作品搜索表单」这种真实页面里的核心交互。

插值 {{ }}:把数据贴到页面上

模板语法的第一课
Vue 的数据流:数据是「源」,页面是「影子」——改数据,页面自动跟数据(响应式状态)ref('静夜思')声明式渲染:模板自动同步视图(模板渲染结果){{ title }} → 静夜思用户操作 @click事件处理改数据不要直接操作 DOM!只改数据,DOM 是 Vue 替你维护的影子对比 jQuery 时代:找元素 → 改元素 → 数据和页面两张皮,改着改着就对不上了
先讲为什么用纯 HTML 写死一个数字,它就一直是个数字。可「静夜思」今天 328 次浏览,明天就是 329——数据是会变的,写死的页面跟不上。所以 Vue 的模板不是普通 HTML,而是「带占位符的 HTML」:你先把位置留出来,运行时再把真实数据填进去。
这个占位符就是 {{ }},一对双大括号,江湖人称「mustache(小胡子)」。这一步,就是「数据驱动视图」的起点。
动手做在 src/views/ 下建一个 SyntaxDemo.vue,把下面几种常见的插值写法都试一遍。
src/views/SyntaxDemo.vuevue
<script setup>
import { ref } from 'vue'

const title = ref('静夜思')
const author = ref('李白')
const views = ref(328)
const isHot = ref(true)
</script>

<template>
  <!-- ① 变量 -->
  <p>{{ title }} · {{ author }}</p>

  <!-- ② 表达式:算术、字符串拼接 -->
  <p>共 {{ views }} 次浏览,点赞率约 {{ (42 / views * 100).toFixed(1) }}%</p>

  <!-- ③ 三元表达式:条件不同显示不同 -->
  <p>热度:{{ views > 300 ? '热门' : '普通' }}</p>

  <!-- ④ 逻辑运算:有值才显示 -->
  <p>{{ isHot && '本周推荐' }}</p>
</template>
页面效果组件渲染结果
运行结果: (浏览器页面从上到下显示) 静夜思 · 李白 共 328 次浏览,点赞率约 12.8% 热度:热门 本周推荐 -- 改一下 views 的值,比如改成 200,保存后页面自动变成: 热度:普通 共 200 次浏览,点赞率约 21.0%
讲透:{{ }} 里能放什么、不能放什么
  • 能放:单个表达式。变量、取值(article.views)、算术、三元、字符串拼接、调用有返回值的函数,都行。
  • 不能放:语句。if、for、const x = 1 这类「声明和流程控制」一律不行——模板只接受「算出一个值」的东西。想要条件显示,用三元表达式或下一章的 v-if。
  • 不能放多条语句。想在一个插值里干两件事,说明这段逻辑该抽成 computed(第 3 章)。
  • 插值结果永远是纯文本。里面就算写成 <b>静夜思</b>,页面上也只会看到这串字符,不会被当成 HTML 解析。真要插 HTML 得用 v-html,但绝不要拿用户输入去 v-html——那等于把 XSS 攻击的入口交给别人(书评内容里塞一段脚本,就能偷走所有人的 token)。
  • 它只管「标签之间的文字」。想表达「某个属性等于某个变量」,得用下一节的 v-bind。
避坑把 {{ }} 用在属性上——它是不生效的。写 <img src="{{ cover }}">,浏览器最终会把 src 设成字面量 "{{ cover }}" 这个字符串,图片当然加载不出来。属性一律用 :src="cover",这是新手第一天最容易犯的错。

v-bind:把数据绑到属性上

简写就是一个冒号
先讲为什么页面上几乎所有有用的东西都挂在标签的属性上:图片地址在 src、跳转地址在 href、按钮能不能点看 disabled、鼠标悬停的提示在 title。属性也是数据,也应该跟着变量走。
Vue 为此提供了「指令」——带 v- 前缀的特殊属性,专门告诉 Vue「这里别写死,按变量来」。管属性的那一个叫 v-bind。
动手做先写完整写法 v-bind:属性名="变量",再把 v-bind: 缩成一个冒号 :,对比一下两者完全等价。
src/views/BindDemo.vuevue
<script setup>
import { ref } from 'vue'

const title = ref('静夜思')
const link = ref('/api/article/detail/1')
const notLoggedIn = ref(true)
const tip = ref('点击查看「静夜思」详情')
</script>

<template>
  <!-- 完整写法 -->
  <a v-bind:href="link" v-bind:title="tip">{{ title }}</a>
  <button v-bind:disabled="notLoggedIn">收藏</button>

  <!-- 简写:v-bind: 一律可以缩成一个冒号,效果完全一样 -->
  <a :href="link" :title="tip">{{ title }}</a>
  <button :disabled="notLoggedIn">收藏</button>

  <!-- 一次绑一个对象的所有字段:把 article 的每个属性都摊到标签上 -->
  <img v-bind="cover" />
</template>
页面效果组件渲染结果
运行结果: (浏览器 Elements 面板里最终渲染出的 DOM) <a href="/api/article/detail/1" title="点击查看「静夜思」详情">静夜思</a> <button disabled>收藏</button> -- 未登录,按钮是灰的点不动。把 notLoggedIn 改成 false 保存: <button>收藏</button> -- disabled 属性整个消失了,按钮变回可点
讲透:三个容易含糊的点
  • 引号里是 JS 表达式,不是字符串。:href="link" 的意思是「取变量 link 的值」,不是给 href 赋一个叫 link 的字符串。想绑死一个字符串,得写成 :title="'静夜思'"——外面双引号、里面单引号。
  • 布尔属性看真假,不看有没有值。:disabled="false" 的结果是「这个属性被移除」;写 :disabled="0" 也等同于 false。这比写 disabled="false"(原生写法,只要出现就是禁用)符合直觉得多。
  • v-bind="对象" 可以一次绑一堆。接口返回的作品对象 { src, alt, title },写 <img v-bind="cover"> 就等于同时写了三个 :src、:alt、:title。传给子组件时特别常用(第 6 章)。
  • 指令的值里出现 &&、||、? : 都是合法的——它就是个 JS 表达式。

v-bind 绑定 class 与 style

页面上 90% 的状态变化都是换样式
先讲为什么界面上的「状态」几乎都是靠样式表达的:推荐作品加个红点、当前分类高亮、未登录的按钮变灰、热度高的作品字体加粗。如果每次都得用字符串拼类名——'tag ' + (isHot ? 'hot' : '')——很快就写成一坨,还容易多出空格、漏掉空格。
Vue 给 class 和 style 开了「对象」和「数组」两种专用写法,让这类条件判断变得一目了然。
动手做三种写法都写一遍:对象(要不要加这个类)、数组(把几个变量拼成一组类名)、静态与动态混用。
src/views/ClassDemo.vuevue
<script setup>
import { ref } from 'vue'

const isRecommend = ref(true)
const size = ref('large')
const activeTab = ref('唐诗')
const tabs = ref(['唐诗', '宋词', '元曲'])
</script>

<template>
  <!-- ① 对象写法:键是类名,值是「要不要加」的布尔值 -->
  <span :class="{ hot: isRecommend, large: size === 'large' }">念奴娇·赤壁怀古</span>

  <!-- ② 数组写法:变量本身就是一个类名 -->
  <span :class="['tag', size]">宋词</span>

  <!-- ③ 静态 + 动态混用:Vue 会合并,不会互相覆盖 -->
  <span class="tag" :class="{ hot: isRecommend }">推荐</span>

  <!-- ④ 数组里也能放对象 -->
  <a v-for="t in tabs" :key="t" :class="['tab', { on: t === activeTab }]">{{ t }}</a>

  <!-- style 同理:对象写法与 CSS 属性一一对应 -->
  <p :style="{ color: isRecommend ? '#c0392b' : '#333', fontSize: '14px' }">热度 412</p>
</template>
页面效果组件渲染结果
运行结果: (Elements 面板里最终生成的 class 与 style) <span class="hot large">念奴娇·赤壁怀古</span> <span class="tag large">宋词</span> <span class="tag hot">推荐</span> <a class="tab on">唐诗</a> <a class="tab">宋词</a> <a class="tab">元曲</a> <p style="color: rgb(192, 57, 43); font-size: 14px;">热度 412</p> -- 点「宋词」后 activeTab 变成 '宋词',on 类就挪到了第二个 a 上
讲透:为什么值得专门给它两种写法
  • 对象写法专治「条件类名」。{ hot: isRecommend } 读起来就是「hot 这个类,取决于 isRecommend」——比字符串拼接可读太多,也不会掉进「双空格」「多余空格」的坑。
  • 数组写法专治「类名本身就是变量」。作品卡片的大小来自 props、主题色来自 store,直接塞数组里最省事。
  • 混用是常态。基础类名写死在 class 上,状态类名交给 :class,两者会被合并成一个 class 列表。
  • style 的值要带单位,写成 fontSize: '14px',别写 fontSize: 14(数字会被当成无效值丢掉)。属性名用驼峰(fontSize)或短横线('font-size')都可以。
  • 能用 class 就别用 style。行内样式没法被媒体查询、伪类复用,也难维护。真正该写在 CSS 里的是样式,:style 只留给「运行时才算得出来」的值,比如进度条宽度。
  • 上面例子里的 v-for 是列表渲染,第 4 章细讲,这里先照着用。

v-on:接住用户的操作

简写是一个 @,还有一堆修饰符
先讲为什么光能显示数据还不够,页面得能被操作:点收藏、切分类、提交搜索、回车确认。这些动作在浏览器里都叫「事件」——click、input、submit、keyup。
原生写法要 document.querySelector('#btn').addEventListener(...) 去先找到元素再挂监听,元素一换位置就失效。Vue 的 v-on 直接把监听挂在标签上:元素在哪,事件就在哪。
动手做写一个点赞按钮和一个「看详情」链接,把常用的几种事件写法都试一遍,重点体会 修饰符的作用。
src/views/EventDemo.vuevue
<script setup>
import { ref } from 'vue'

const likes = ref(42)
const log = ref([])

function handleLike() {
  likes.value++
  log.value.push('点了赞,现在 ' + likes.value + ' 个')
}

function handleJump(url) {
  log.value.push('准备跳到 ' + url)
}
</script>

<template>
  <!-- 完整写法 v-on:click,简写 @click -->
  <button v-on:click="handleLike">点赞 {{ likes }}</button>
  <button @click="handleLike">点赞</button>

  <!-- 直接写一行表达式也行 -->
  <button @click="likes++">+1</button>

  <!-- .prevent 修饰符:拦住链接的默认跳转 -->
  <a href="/api/article/detail/3" @click.prevent="handleJump('/api/article/detail/3')">看详情</a>

  <!-- 表单提交也常用:不加 .prevent 会刷新整页 -->
  <form @submit.prevent="handleJump('/api/article/list')">
    <input placeholder="搜作品" />
    <button type="submit">搜索</button>
  </form>

  <!-- 其它常见修饰符:禁止冒泡、只触发一次、键盘按键 -->
  <div class="card" @click.stop="log.push('点到了里层')">里层</div>
  <button @click.once="log.push('只会记这一次')">只算一次</button>
  <input @keyup.enter="handleJump('/api/article/list')" />

  <p v-for="(line, i) in log" :key="i">{{ line }}</p>
</template>
页面效果组件渲染结果
运行结果: (依次点击上面几个元素,页面上 v-for 渲染出的日志逐条出现) 点「点赞」→ 点赞 43 点「点赞」→ 点了赞,现在 44 个 点「+1」→ 点赞 45 点「看详情」→ 准备跳到 /api/article/detail/3 (地址栏没有跳走 —— .prevent 生效了) 搜索框里按回车 → 准备跳到 /api/article/list 点「只算一次」→ 只会记这一次 (再点一次按钮,什么都不发生)
讲透:括号带不带?修饰符管什么?
  • @click="handleLike"(不带括号):把函数本身交给 Vue,触发时它自动调用,并把事件对象作为第一个参数传进去。
  • @click="handleLike()"(带括号):触发时执行你写的这段代码,事件对象不会自动传。需要的话得显式写 @click="handleLike($event)"。所以「要传自己的参数」时才加括号。
  • 修饰符就是帮你把常见小动作写短:.stop = event.stopPropagation()、.prevent = event.preventDefault()、.once = 只绑定一次、.self = 只有事件发生在自己身上才触发。
  • 键盘和鼠标按键也能当修饰符:@keyup.enter、@keyup.esc、@click.right。修饰符可以串联,如 @click.stop.prevent。
  • 在「古典文学网」里它们的落点:书评的「发布」用 @submit.prevent;点赞用 @click;搜索框回车即搜用 @keyup.enter;作品卡片里嵌着收藏按钮,卡片本身也能跳详情——这时候收藏按钮上必须加 .stop,否则点收藏会连带跳走。

v-model:双向绑定

本质就是 value + input 的语法糖
v-model:双向绑定的本质 = v-bind 传值 + v-on 收事件的语法糖v-model 一行搞定<input v-model="kw">数据变 → 输入框跟着变输入框变 → 数据跟着变等价于展开后的两行<input :value="kw" @input="kw = $event.target.value">:value 负责「数据→视图」(下行)@input 负责「视图→数据」(上行)两个方向的箭头合起来,才是「双向」——理解了展开式,自定义组件的 v-model 就不难
先讲为什么输入框是唯一一种「数据会反向流回代码」的元素。用户敲一个「静」字,你希望变量 keyword 立刻变成「静」(视图 → 数据);你点「清空」按钮,输入框也得跟着变空(数据 → 视图)。
两个方向都得要,所以叫双向绑定。手动写要绑两处::value 管进去、@input 管回来,少写一个方向就出现「输入框能打字但变量没变」这类鬼问题。v-model 把这两步合成了一句话。
动手做先感受效果:输入的文字实时显示在下面,点按钮又能反过来清空输入框。
src/views/ModelDemo.vuevue
<script setup>
import { ref } from 'vue'

const keyword = ref('')
const nickname = ref('青莲居士')
</script>

<template>
  <input v-model="keyword" placeholder="搜作品名 / 作者" />
  <p>你正在搜:{{ keyword }}</p>
  <p>关键词长度:{{ keyword.length }}</p>

  <input v-model="nickname" />
  <button @click="nickname = ''">清空昵称</button>
</template>
页面效果组件渲染结果
运行结果: (在第一个输入框里输入「静夜」) 你正在搜:静夜 关键词长度:2 (第二个输入框里默认显示「青莲居士」,点「清空昵称」) 第二个输入框立刻变空 —— 这就是「数据 → 视图」的那一半 -- 你在输入框里改,下面跟着变,是「视图 → 数据」那一半
动手做现在把 v-model 拆开,看它到底替你写了哪两行。
等价写法对照.vuevue
<!-- 写法 A:一行搞定 -->
<input v-model="keyword" />

<!-- 写法 B:v-model 等价于下面这两行,这就是它叫「语法糖」的原因 -->
<input
  :value="keyword"
  @input="keyword = $event.target.value"
/>
页面效果组件渲染结果
运行结果: 两种写法渲染出的 DOM 完全一样,行为也完全一样: <input value="静夜"> -- 输入「静夜」后,两种写法下 keyword 都是字符串 "静夜" -- 差别只在:写法 B 要自己记住「赋值给 $event.target.value」, -- 写错了(比如写成 $event.value)就变成「输入框能打字、变量没更新」的经典 bug
讲透:双向的「两半」分别是什么
  • 第一半(数据 → 视图):把变量的值放进元素的 value 属性,所以变量一变,输入框内容就跟着变。
  • 第二半(视图 → 数据):监听元素的 input 事件,把用户刚敲的内容写回变量。
  • 不同控件的「两半」不一样:文本框绑 value + input;复选框绑 checked + change;下拉框读的是选中 option 的 value。Vue 按元素类型自动切换,你只要写 v-model。
  • 它只能用在「能被输入的东西」上:input / textarea / select / 复选框 / 单选框,以及你自己的表单组件。写在 <div> 上没有任何意义。
  • 在自定义组件上,默认绑的是 modelValue 属性 + update:modelValue 事件。以后给「搜索框」这类组件包一层壳时会用到(第 6 章)。

表单处理:一个表单里的所有控件

三种修饰符最实用
先讲为什么登录、注册、发书评、搜索——「古典文学网」一半的交互都是表单。可表单控件各有各的脾气:文本框的值是字符串,复选框可能是布尔也可能是数组,下拉框要看 option 上写了什么。
如果用原生 JS,每个控件你都得单独记一套取值的写法。v-model 把它们统一了:你只需要记住「这个变量该是什么类型」,剩下的 Vue 负责。
动手做把五种控件写进同一个表单,观察每种控件绑定后变量变成了什么。
src/views/FormDemo.vuevue
<script setup>
import { ref } from 'vue'

const keyword = ref('')          // 单行文本 → 字符串
const onlyRecommend = ref(true)  // 单个复选框 → 布尔
const tags = ref(['唐诗'])        // 多个复选框 → 数组
const dynasty = ref('唐')         // 单选 → 字符串
const categoryId = ref('')       // 下拉 → 与 option 的 value 同类型
const content = ref('')          // 多行文本 → 字符串

const dynasties = ['唐', '宋', '元']
const categories = [
  { id: 1, name: '唐诗' },
  { id: 2, name: '宋词' },
  { id: 4, name: '散文' }
]
</script>

<template>
  <form>
    <!-- ① 单行文本:.trim 自动去掉首尾空格 -->
    <input v-model.trim="keyword" placeholder="关键词" />

    <!-- ② 复选框:绑布尔 = 勾没勾;绑数组 = 勾了哪几个 -->
    <label><input type="checkbox" v-model="onlyRecommend" />只看推荐</label>
    <label v-for="t in categories" :key="t.id">
      <input type="checkbox" :value="t.name" v-model="tags" />{{ t.name }}
    </label>

    <!-- ③ 单选:同一组共用一个变量,:value 就是选中后拿到的值 -->
    <label v-for="d in dynasties" :key="d">
      <input type="radio" :value="d" v-model="dynasty" />{{ d }}
    </label>

    <!-- ④ 下拉:option 的 value 决定拿到什么 -->
    <select v-model="categoryId">
      <option value="">全部分类</option>
      <option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
    </select>

    <!-- ⑤ 多行文本:不要在里面写 {{ }},用 v-model;.lazy 离开焦点才更新 -->
    <textarea v-model.lazy="content"></textarea>
  </form>

  <p>当前条件:{{ keyword }} / {{ dynasty }} / {{ categoryId || '全部分类' }}</p>
</template>
页面效果组件渲染结果
运行结果: (初始状态) 只看推荐:已勾选 → onlyRecommend = true 朝代:选中「唐」 → dynasty = '唐' 分类:停在「全部分类」 → categoryId = '' tags = ['唐诗'] (再勾上「宋词」) tags = ['唐诗', '宋词'] (把单选切到「宋」) dynasty = '宋' (下拉选「唐诗」) categoryId = 1 ← 注意是数字 1,因为 :value 绑的是 c.id (在 textarea 里输入「短短二十字,写尽思乡之情。」,然后点一下别处) content = '短短二十字,写尽思乡之情。' ← .lazy 让它离开焦点后才更新
讲透:三个修饰符 + 一个类型问题
  • .trim:去掉首尾空格。搜索关键词必加——用户不小心多打了空格,后端就搜不到东西,这种问题查起来最费时间。
  • .number:把输入转成数字。翻页输入框、价格输入框必加,否则 page + 1 会变成字符串拼接 "11"。
  • .lazy:从「每次按键都同步」改成「失焦或回车时才同步」。适合写书评这种长文本——不用每敲一个字就触发一次校验。
  • 可以串联:v-model.trim.lazy="content"、v-model.number.lazy="page"。
  • 类型由 :value 决定。下拉框写 :value="c.id" 拿到数字 1,写 value="1" 拿到字符串 "1"。发给后端 category_id 时,字符串通常也能用,但你在前端做 === 比较(比如高亮当前分类)就会永远为 false。想拿数字,就写 :value 或加 .number。
避坑<select> 的每个 <option> 一定要写 :value。漏写时,v-model 只能拿到 option 的显示文本——你绑在 categoryId 上的就会变成「唐诗」三个汉字,而不是 1。后端按 category_id 筛数据,传汉字过去自然一条都查不出来。请求参数里看到中文,先检查这里。

综合案例:作品搜索表单

把本章四个工具串起来
动手做「古典文学网」的列表页顶部有一个搜索区:关键词输入框 + 分类下拉 + 搜索按钮。它要对接的接口是 GET /api/article/list(参数 page、page_size、keyword、category_id),返回统一格式 { code, msg, data }。用本章学的东西把它写出来。
src/components/SearchBar.vuevue
<script setup>
import { ref } from 'vue'

// 子组件把搜索结果交给父组件(组件通信第 6 章细讲,这里先照写法用)
const emit = defineEmits(['search'])

const keyword = ref('')
const categoryId = ref('')
const page = ref(1)
const loading = ref(false)

const categories = [
  { id: 1, name: '唐诗' },
  { id: 2, name: '宋词' },
  { id: 3, name: '元曲' },
  { id: 4, name: '散文' },
  { id: 5, name: '小说' }
]

async function onSearch() {
  loading.value = true
  page.value = 1        // 换了搜索条件必须回到第 1 页,否则可能查到空列表

  const params = new URLSearchParams({
    page: page.value,
    page_size: 10
  })
  if (keyword.value) params.append('keyword', keyword.value)
  if (categoryId.value) params.append('category_id', categoryId.value)

  const res = await fetch('/api/article/list?' + params)
  const json = await res.json()

  if (json.code === 200) {
    emit('search', json.data)     // data 里有 list、total、page、page_size
  } else {
    alert(json.msg)               // 失败时 msg 可以直接给用户看
  }
  loading.value = false
}
</script>

<template>
  <form class="search-bar" @submit.prevent="onSearch">
    <input v-model.trim="keyword" placeholder="搜作品名 / 作者" />

    <select v-model.number="categoryId">
      <option value="">全部分类</option>
      <option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
    </select>

    <button type="submit" :disabled="loading">
      {{ loading ? '搜索中…' : '搜索' }}
    </button>

    <button type="button" @click="keyword = ''; categoryId = ''">重置</button>
  </form>
</template>
页面效果组件渲染结果
运行结果: (输入「静」,分类选「唐诗」,点「搜索」或按回车) 浏览器的 Network 面板里能看到请求: GET /api/article/list?page=1&page_size=10&keyword=%E9%9D%99&category_id=1 接口返回(后端统一响应格式): { "code": 200, "msg": "success", "data": { "list": [ { "id": 1, "title": "静夜思", "author": "李白", "dynasty": "唐", "category_name": "唐诗", "views": 328, "likes": 42 } ], "total": 1, "page": 1, "page_size": 10 } } -- 只输入关键词不选分类时,请求里就没有 category_id 这一项 -- 点「重置」两个输入框同时清空,因为数据 → 视图那一半在起作用
讲透:这个表单里每一处用到本章的知识点
  • v-model.trim:关键词去空格;v-model.number:分类 ID 保证是数字。
  • @submit.prevent:拦掉浏览器默认的表单提交。不写这一句,点按钮会刷新整页,你辛苦填的条件全没了——这是表单类 bug 的第一名。
  • :disabled="loading":请求还没回来时按钮禁用,防止用户连点五次发出五个请求。
  • 条件拼参数:只把有值的条件放进 URL。用户没填关键词就别传 keyword=,否则后端会按空字符串去模糊匹配。
  • page.value = 1:每次新搜索都回到第一页。这是真实项目里最容易被忽略、又最容易被用户骂的一行。
  • 用 fetch 只是暂时的:第 9 章会换成封装好的 axios,自动带上 Authorization 头、自动处理 code !== 200 的情况,这里先看清「请求是怎么发出去的」。

四个最容易踩的坑

错误写法 vs 正确写法

模板语法本身不难,难在「看起来差不多、结果差很远」。下面四组对照,建议抄下来贴在手边。

易错对照.vuevue
<!-- ① 属性上不能用插值 -->
<img src="{{ cover }}" />                 错误:src 会变成字面量 "{{ cover }}"
<img :src="cover" />                     正确

<!-- ② 插值里不能写语句 -->
<p>{{ if (isHot) '热门' }}</p>              错误:编译直接报错
<p>{{ isHot ? '热门' : '普通' }}</p>        正确

<!-- ③ v-model 是「赋值目标」,不能写表达式 -->
<input v-model="keyword + '!'" />         错误:赋值给表达式没有意义
<input v-model="keyword" />               正确

<!-- ④ 表单提交忘了 .prevent,整页刷新 -->
<form @submit="onSearch">                  错误:浏览器默认提交并刷新页面
<form @submit.prevent="onSearch">          正确
页面效果组件渲染结果
运行结果: 第 ① 条:图片加载失败,Elements 里看到 <img src="{{ cover }}"> 第 ② 条:终端里 Vite 报编译错误,页面直接白屏,形如 [plugin:vite:vue] Unexpected token 第 ③ 条:控制台报 [Vue warn] v-model value must be a valid JavaScript member expression 第 ④ 条:点「搜索」的一瞬间页面刷新,地址栏多出一个 ?, 输入框被清空,之前的搜索结果回到初始状态
避坑遇到「页面白屏 + 终端一片红」,先看终端里的第一行报错,那里通常直接写明了是哪个文件、第几行、什么问题。Vue 的编译错误信息质量很高,读完往往自己就修好了。反过来,如果终端是干净的、只有浏览器控制台有红字,那才是运行期逻辑问题。分清这两类,能省掉一半排查时间。
讲透:本章四个工具的分工,一句话记住
  • 要显示内容(标签之间的文字)→ {{ }}
  • 要给标签塞属性(src、href、disabled、class、style)→ :
  • 要接住用户动作(点击、回车、提交)→ @
  • 要让表单和变量互相同步 → v-model
  • 还有一类「要显示 / 隐藏、要循环一批数据」的需求,那是 v-if 和 v-for,下一章之后的第 4 章专讲。而「算出来的值」和「数据变了要做事」,是下一章的主角。
动手练习
1做一个「作品条目」组件 ArticleRow.vue:用插值显示标题、作者、浏览量;用 :href 绑到 /api/article/detail/1;当 likes > 50 时给标题加上 hot 类(用对象写法);浏览量大于 300 时显示「热门」两个字,否则显示「普通」(用三元表达式)。
2做一个「发书评」小表单:用 v-model.trim 绑定内容、v-model 绑一个「同时收藏这篇作品」的复选框;内容为空时发布按钮 :disabled;给表单加 @submit.prevent,提交时把 { article_id, content, favorite } 用 console.log 打印出来。
3迁移练习:「红影赏析」的电影搜索接口是 GET /api/movie/list,参数为 keyword(关键词)、genre(类型)、min_rating(最低评分,数字)。请用本章的模板语法写出这个搜索表单(关键词输入 + 类型下拉 + 最低评分输入),并在提交时打印最终拼好的查询参数对象。想清楚:min_rating 该不该用 .number?为什么?
参考答案
练习答案(三题各一个文件).vuevue
<!-- 第 1 题:作品条目 -->
<script setup>
import { ref } from 'vue'

const title = ref('念奴娇·赤壁怀古')
const author = ref('苏轼')
const views = ref(412)
const likes = ref(58)
</script>

<template>
  <a :href="'/api/article/detail/3'" :class="{ hot: likes > 50 }">{{ title }}</a>
  <span>{{ author }} 浏览 {{ views }} 点赞 {{ likes }}</span>
  <span>{{ views > 300 ? '热门' : '普通' }}</span>
</template>

<!-- 第 2 题:发书评 -->
<script setup>
import { ref } from 'vue'

const content = ref('')
const favorite = ref(true)

function onSubmit() {
  console.log({ article_id: 1, content: content.value, favorite: favorite.value })
}
</script>

<template>
  <form @submit.prevent="onSubmit">
    <textarea v-model.trim="content"></textarea>
    <label><input type="checkbox" v-model="favorite" />同时收藏</label>
    <button type="submit" :disabled="!content">发布</button>
  </form>
</template>

<!-- 第 3 题:红影赏析的电影搜索(表结构不同,思路完全一致) -->
<script setup>
import { ref } from 'vue'

const keyword = ref('')
const genre = ref('')
const minRating = ref('')

const genres = ['科幻', '剧情', '动画', '悬疑']

function onSearch() {
  const query = { page: 1, page_size: 10 }
  if (keyword.value) query.keyword = keyword.value
  if (genre.value) query.genre = genre.value
  if (minRating.value !== '') query.min_rating = minRating.value
  console.log('查询参数:', JSON.stringify(query))
}
</script>

<template>
  <form @submit.prevent="onSearch">
    <input v-model.trim="keyword" placeholder="搜片名" />
    <select v-model="genre">
      <option value="">全部类型</option>
      <option v-for="g in genres" :key="g" :value="g">{{ g }}</option>
    </select>
    <input v-model.number="minRating" type="number" placeholder="最低评分" />
    <button type="submit">搜索</button>
  </form>
</template>
页面效果组件渲染结果
运行结果: 第 1 题(views = 412 时) 念奴娇·赤壁怀古 苏轼 浏览 412 点赞 58 热门 -- class 里多了 hot:<a class="hot" href="/api/article/detail/3">念奴娇·赤壁怀古</a> 第 2 题(内容为空时按钮灰着;填「好诗!」后点发布) 控制台输出: { article_id: 1, content: '好诗!', favorite: true } 第 3 题(关键词「流浪地球」、类型「科幻」、最低评分 8) 控制台输出: 查询参数: {"page":1,"page_size":10,"keyword":"流浪地球","genre":"科幻","min_rating":8} -- min_rating 是 8 而不是 "8",因为加了 .number -- 没填最低评分时,query 里根本不会出现 min_rating 这一项

第 3 题的关键:min_rating 必须用 .number。它要参与数值比较(评分大于等于 8),字符串 "8" 送到后端,轻则被当成非法参数返回 400,重则被拼进 SQL 变成类型比较的隐式转换。凡是「参与计算或数值比较」的输入,一律加 .number。另外注意判断空值要用 minRating.value !== '' 而不是 if (minRating.value)——评分 0 也是合法值。

本章小结