继续修改第 1 章的学习卡片。卡片已经能跟着完成次数改变提示文字和按钮样式,现在把仍然写死的部分也接到状态上:标题由 courseTitle 提供,图片地址来自 coverUrl,按钮是否可用取决于 isSaving,点击后执行 submitLesson。
问题随即变得具体:同样是把状态放进页面,为什么标题内容可以写成 {{ courseTitle }},图片却不能写成 src="{{ coverUrl }}"?为什么按钮状态使用 :disabled="isSaving",点击动作又要使用 @click="submitLesson"?普通 HTML 属性、文本插值和事件监听明明都出现在模板里,为什么不能混成一种写法?
上一章已经给出了答案所在的方向:Vue 模板不是运行时字符串拼接,它会先被编译成 JavaScript 渲染函数。 现在把那张编译全景图放大,逐个看清编译器怎样理解元素内容、属性和事件。这样,{{ }}、: 与 @ 就不再是三组孤立暗号,而是三种不同的界面关系。
文件结构仍沿用那张学习卡片的 .vue 单文件组件与 <script setup>。template + script + style 已经是熟悉的容器,这里只把注意力放到 <template> 怎样读取状态、连接浏览器能力,以及怎样把结果交给后续更新流程。
先看一个最小组件:
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button @click="count++">
已点击 {{ count }} 次
</button>
</template>这个按钮正好落在上一章已经走过的主链路上。浏览器最终确实会看到一个按钮,但中间并不是“找到 {{ count }},换成数字”这么简单。构建工具读取这个组件时,会分析模板里哪些内容是固定的,哪些内容依赖 count,并生成对应的渲染代码。组件挂载时,渲染代码先产生一份页面结构描述,再由 Vue 创建真实 DOM。count 变化后,渲染过程再次得到新的描述,Vue 比较前后差异,只把按钮里的文本更新为新数字。

你可以把这条路记成四步:
编译器读取 template,识别元素、静态文本、插值、指令和事件等信息,并生成渲染函数。
组件首次出现时,Vue 调用渲染函数,得到描述界面结构的虚拟节点树。
渲染器根据这棵树创建真实 DOM,同时记录渲染时读取过哪些响应式数据。
某个依赖变化后,Vue 重新计算相关组件的界面描述,比较差异,再把必要的变化写入真实 DOM。
这和原生 JavaScript 的工作重心不同。原生写法要求你同时维护数据与节点:
let count = 0
const button = document.querySelector('#counter')
button.addEventListener('click', () => {
count++
button.textContent = '已点击 ' + count + ' 次'
})这里 count++ 只改变数据,button.textContent = ... 才改变页面。少写后一句,数据和界面立刻分家。Vue 里我们先声明“按钮文字取决于 count”,之后只修改 count。查找节点、选择更新位置、写回文本这些动作由渲染器处理。
这并不意味着 Vue 会粗暴地重画整个页面。编译器提前知道哪些节点是静态的,也知道哪些位置可能变化,因此运行时不必盲目扫描所有 DOM。理解这一点之后,模板语法就不再像一组零散暗号:插值是在声明文本依赖,v-bind 是在声明属性依赖,v-on 是在声明事件与处理逻辑的关系。
渲染函数的具体形状仍然可以暂时放在地图边缘。沿用上一章的三个定位点就够了:模板是源码,渲染函数是编译产物,DOM 是最后呈现的结果。看到模板限制时,先想“编译器要把这里转换成什么”,很多规则就会变得合理。
原生 DOM 写法常用的是命令式思路:先找到一个节点,再告诉它执行什么动作。一个加载按钮可能需要手动修改文字、disabled、样式类和提示区域。如果业务多出“提交失败”与“可以重试”两个状态,你还要继续寻找节点并补上几组相反操作。
button.disabled = true
button.textContent = '提交中…'
tip.textContent = '正在连接'
// 请求结束后还要逐项恢复
button.disabled = false
button.textContent = '重新提交'
tip.textContent = '连接失败,请重试'这种代码本身没有错,问题是界面规则被拆散在许多操作里。你需要自己保证每条路径都把按钮、提示和样式恢复到一致状态。只要某个异常分支漏写一行,页面就可能显示“提交成功”,按钮却还处于禁用状态。
Vue 模板更像状态说明书。我们先定义 isSaving、status 这些数据,再写清楚每种状态下按钮应该是什么样:
<button :disabled="isSaving">
{{ isSaving ? '提交中…' : '提交' }}
</button>
<p>{{ status }}</p>业务函数只负责改变状态,模板集中说明状态如何映射到界面。这样做的真正价值不是少写几行 document.querySelector,而是把“界面应该长什么样”放在一个能整体阅读的位置。当你排查错误时,可以明确区分两类问题:是状态算错了,还是状态到界面的映射写错了。
不过,声明式也不等于完全不需要理解浏览器。模板最后仍然落到按钮、表单、属性、事件和 DOM 上。@submit.prevent 能阻止刷新,是因为浏览器的表单本来有默认行为;:disabled 能禁用按钮,是因为这是 HTML 本身的语义。Vue 帮你组织这些能力,没有另造一套与 Web 无关的世界。
如果模板只在运行时逐字解释,框架每次更新都要重新判断“这段是固定标题,还是动态文本”“这个属性会不会变化”。编译阶段已经看到了完整模板,所以它可以提前标出动态位置,保留静态内容,并生成更有针对性的更新代码。
例如下面的标题和说明不会变化,只有人数会变:
<section>
<h2>晚间自习室</h2>
<p>每天开放两小时</p>
<strong>当前 {{ learnerCount }} 人</strong>
</section>编译器能区分固定结构与依赖 learnerCount 的文本。后续人数变化时,运行时拥有比“重新检查整段 HTML”更多的信息。这也是为什么模板虽有表达式能力,却故意不让你在任意位置随便执行一整段 JavaScript:语法边界越清楚,编译器越容易分析结构,人也越容易判断一行代码会影响哪里。
接下来的示例仍然沿用第 1 章的结构:
<script setup>
// 数据、计算逻辑和事件处理函数写在这里
</script>
<template>
<!-- 描述界面应该长什么样 -->
</template>
<style scoped>
/* 当前组件使用的样式 */
</style><script setup> 准备数据和行为,<template> 描述界面,<style scoped> 负责当前组件的外观。这三段已经见过,这里只补一个与模板排错直接相关的入口:脚本顶层声明的变量和函数可以直接用于模板,不需要写 return;模板报“某个属性不存在”时,先检查拼写和当前组件的 <script setup>。它不会自动读取另一个组件内部的变量,也不会凭变量名去全局环境里寻找。
双大括号 {{ }} 是最常见的文本插值。它把表达式的结果转换成文本,并在相关数据变化时更新对应文本节点。
<script setup>
import { ref } from 'vue'
const learnerName = ref('小林')
const completed = ref(3)
const total = 8
</script>
<template>
<section>
<h2>你好,{{ learnerName }}</h2>
<
初始界面显示“你好,小林”和“进度:3 / 8”。点击按钮后,completed 增加,进度文本随之变化。脚本里访问或修改 ref 需要写 completed.value;模板会帮我们拆开这个响应式盒子,所以模板里直接写 completed。

假设后端传来这一段内容:
const notice = ref('<strong>今晚八点维护</strong>')模板写 <p>{{ notice }}</p> 时,页面会把尖括号和标签名原样显示出来,不会把它渲染成粗体。这是一个很重要的默认行为:普通插值面向“文字”,不是面向“待执行的 HTML”。
因此,用户名即使恰好包含 <img> 或 <script> 这样的字符,也只会作为文字出现。Vue 最终借助浏览器的安全文本写入方式处理它,而不是把字符串当作新节点解释。
插值不只接受变量名。只要是一段能计算出值的 JavaScript 表达式,通常都能放进去:
<script setup>
import { ref } from 'vue'
const price = ref(39)
const quantity = ref(2)
const nickname = ref(' 小林 ')
const hasCoupon = ref(true)
</script>
<template>
加减乘除、字符串方法、数组访问、三元表达式和一些常用内置对象都可以参与求值。一个实用判断是:这段代码能不能自然地放在 return 后面? return price * quantity 合法,所以它是表达式;return if (...) 不合法,所以 if 不能这样放进插值。
下面这些写法会失败:
<template>
<!-- 声明变量是语句,不是表达式 -->
<p>{{ const result = price * quantity }}</p>
<!-- if 是流程控制语句 -->
<p>{{ if (hasCoupon) { return '已使用' } }}</p>
<!-- 多条语句不能塞进一次绑定 -->
<p>{{ quantity++; price * quantity }}</p>
</template>条件文本很短时,用三元表达式即可:
<p>{{ hasCoupon ? '已使用优惠券' : '暂无优惠券' }}</p>一旦逻辑出现多层判断、排序、筛选或格式化,就把它搬回脚本。模板应该让人一眼看懂界面结构,不适合承担一段小程序。
<script setup>
import { computed, ref } from 'vue'
const price = ref(39)
const quantity = ref(2)
const hasCoupon = ref(true)
const payableText = computed(() => {
const subtotal = price.value * quantity.value
这里 computed 把“怎么算”放回脚本,模板只负责“显示什么”。后面学习计算属性时会详细展开,现在先把它当成一个会随依赖变化而重新计算的结果。
你确实可以在绑定表达式里调用函数,例如 {{ formatPrice(price) }}。但渲染可能发生很多次,所以这个函数应该只根据输入返回结果。不要在里面修改响应式数据、发送请求、写本地存储或启动定时器。
function formatPrice(value) {
return '¥' + value.toFixed(2)
}这样的函数很安全:相同输入得到相同输出。反过来,如果 formatPrice 每调用一次还让计数器加一,组件一更新就可能触发新的更新,调试时会看到数字莫名变化,甚至形成循环。
模板表达式能直接使用 <script setup> 顶层声明的变量、函数和导入内容:
<script setup>
import { ref } from 'vue'
const keyword = ref('模板')
function normalize(text) {
return text.trim().toLowerCase()
}
</script>
<template>
<p>{{ normalize(keyword) }}</p>
</template这里的 normalize 不是凭空出现的全局函数,它属于当前组件的模板作用域。把函数改名后忘记同步模板,运行时就会提示找不到对应属性。相反,浏览器页面上偶然存在一个同名全局变量,也不该成为模板正常工作的前提。
模板可以使用 Math、Date 这类常见内置能力,但不能把它当成随意访问全局环境的脚本控制台。尤其不要在表达式里依赖挂在 window 上的临时值。那样的代码在测试、服务端渲染或多人协作时很容易失效。组件需要什么数据,就通过声明、属性或明确的应用配置提供什么数据。
还要留意模板作用域中的局部变量。v-for 会在它包裹的区域里创建当前项,例如 course;离开那段模板后,course 就不存在。这和 JavaScript 块级作用域很像:
<ul>
<li v-for="course in courses" :key="course.id">
{{ course.title }}
</li>
</ul>
<!-- 这里已经离开 v-for 的作用域,不能再读取 course -->看到“属性不存在”时,要检查的不只是脚本声明,还要看表达式是否离开了创建局部变量的模板范围。
字符串和数字最直观,布尔值也能被转成文字;null 与 undefined 通常不会产生可见文本。对象或数组用于插值时会经过字符串化,但这不适合作为正式界面。把整个用户对象丢进 {{ user }},最多用于临时观察数据,真正页面应该挑出 user.name、user.role 等具体字段。
调试对象时,开发者工具通常比页面插值更合适。你可以在脚本里记录对象,或在组件检查工具里查看响应式状态。这样能保留对象层级,也不会把调试信息误留在面向用户的界面里。
还有一个细节:模板中的 ref 自动拆包是为了让模板更像普通 HTML 表达式,但脚本里仍然要通过 .value 修改它。初学时如果在脚本与模板之间来回复制代码,很容易多写或漏写 .value。遇到“不更新”或“读取到一个对象”时,先确认代码当前位于哪块区域,再决定是否需要 .value。
新手很容易写出下面这句:
<img src="avatarUrl" alt="learnerName">浏览器不会猜测 avatarUrl 是变量。它只会把 src 的值当作普通字符串,于是尝试加载一个名为 avatarUrl 的地址;alt 也只会显示字面量“learnerName”。
双大括号也不放进属性:
<!-- 不要这样写 -->
<img src="{{ avatarUrl }}" alt="{{ learnerName }}">正确做法是用 v-bind。它的完整写法是 v-bind:src,常用简写是 :src:
<script setup>
import { ref } from 'vue'
const learnerName = ref('小林')
const avatarUrl = ref('/avatars/forest-learner.png')
const imageSize = ref(96)
</script>
<template>
<img
:src="avatarUrl"
:alt

可以把属性绑定拆成三部分读:
v-bind:要执行的指令,表示同步一个属性。src:指令参数,表示这次要同步的目标属性。avatarUrl:表达式,表示目标属性应该使用什么值。因此 :title="courseTitle" 的意思不是“把一段文字放进 title”,而是“让元素的 title 与当前组件中的 courseTitle 保持同步”。
一个元素常常同时拥有固定配置和动态状态:
<button
type="button"
class="action-button"
:title="buttonHint"
:disabled="isSaving"
>
{{ isSaving ? '保存中…' : '保存' }}
</button>type 和基础 class 不随数据变化,直接写普通 HTML 就好。title、disabled 和按钮文字取决于状态,所以需要绑定。不要为了“统一”给每个属性都加冒号;固定值使用固定写法更清楚。
disabled、checked、required 这类 HTML 布尔属性有点特别。对它们来说,属性是否出现比字符串内容更重要。下面的按钮会随着 isSaving 改变可用状态:
<button :disabled="isSaving">
{{ isSaving ? '正在提交' : '提交作业' }}
</button>当绑定结果使该属性成立时,DOM 上会出现 disabled,按钮不可点击;当结果不成立时,该属性会被移除。不要写 disabled="false" 并期待按钮恢复,因为在原生 HTML 里,只要 disabled 属性存在,按钮就可能仍被视为禁用。
普通属性绑定为 null 或 undefined 时,Vue 会把对应属性从元素上移除。这很适合处理“有值才显示”的场景:
<script setup>
import { computed, ref } from 'vue'
const helpText = ref('')
const titleText = computed(() => helpText.value || null)
</script>
<template>
<button :title="titleText">查看提示</button>
</template>当 helpText 为空,title 不会留下一个空壳;有实际内容时才出现。
如果一组属性本来就属于同一份配置,可以把对象交给不带参数的 v-bind:
<script setup>
const inputAttrs = {
id: 'course-search',
name: 'keyword',
placeholder: '输入课程名称',
autocomplete: 'off'
}
</script>
<template>
<input v-bind="inputAttrs">
</template>这种写法适合转交一组清晰、受控的属性。不要把来源不明的整包对象随手绑定到元素上,否则你很难看出最终会出现哪些属性,也可能把不该出现的内容带进 DOM。
在较新的 Vue 3 版本里,如果属性名和变量名完全一致,:id="id" 可以缩写为 :id:
<script setup>
const id = 'course-summary'
</script>
<template>
<section :id>课程摘要</section>
</template>它与 JavaScript 对象的同名属性简写很像。不过这项语法需要较新的 Vue 3 版本。维护旧项目或编写面向多个版本的示例时,写完整的 :id="id" 更稳妥。简写的目标是减少重复,不是隐藏数据来源;变量名不一致时仍要保留右侧表达式。
v-bind 可以把值送到属性上,但“能绑定”不代表“这样设计就合理”。例如图片的 alt 应该描述图片信息,装饰图则应使用空 alt;按钮的可访问名称应该说明动作,而不是只写“点这里”;折叠按钮除了改变文字,还应通过 aria-expanded 表达当前展开状态。
<button
type="button"
:aria-expanded="isOpen"
aria-controls="course-detail"
@click="isOpen = !isOpen"
>
{{ isOpen ? '收起课程详情' : '展开课程详情' }}
</button>
<div id="course-detail" v-if="isOpen">
课程详情内容
</Vue 不会替你推断这些语义。它能保证 aria-expanded 随数据变化,但是否选对属性、文字是否准确,仍然由组件作者决定。
排查绑定时,最好在浏览器 Elements 面板里看最终 DOM。若脚本状态正确而界面行为不对,确认目标属性是否出现、值是什么、是不是写到了错误元素上。例如把 disabled 绑定在按钮外层的 div 上,不会禁用里面的按钮;给非表单元素写 required,也不会自动得到输入校验能力。
class 和 style 可以像普通属性一样接收字符串,但 Vue 还支持更适合条件状态的对象与数组。完整规则会在后面的样式章节展开,这里先建立基本用法:
<script setup>
import { ref } from 'vue'
const isActive = ref(true)
const hasError = ref(false)
</script>
<template>
<div
class="course-card"
:class="{ active: isActive, error: hasError }"
>
模板语法
</div
固定的 course-card 始终保留;active 与 error 由状态决定是否加入。不要在事件函数里用 element.classList.add 与模板绑定同时控制同一组类名,这会产生两个状态来源。既然类名已经由数据决定,就让事件修改 isActive 或 hasError,保持单向关系。
Vue 指令都带有 v- 前缀,例如 v-bind、v-on、v-html。它们不是浏览器原生属性,而是写给 Vue 编译器看的特殊标记。
拿这句来说:
<form @submit.prevent="saveCourse">还原完整写法是:
<form v-on:submit.prevent="saveCourse">逐段读就是:
v-on:监听事件。submit:监听提交事件。prevent:触发时先阻止浏览器默认提交行为。saveCourse:随后执行这个函数。冒号不是随意的标点,它把指令名与参数隔开;点号也不是装饰,它引出修饰符。掌握这套读法,遇到 @keyup.enter.once、:class 或后面的 v-model.trim 时,就不用逐个死记。
平常的 :title 中,title 是写死的。动态参数用方括号把参数位置变成表达式:
<script setup>
import { ref } from 'vue'
const attributeName = ref('title')
const attributeValue = ref('查看课程说明')
const eventName = ref('click')
function handleAction() {
attributeValue.value = '操作已触发'
}
</script>
<
当 attributeName 是 'title' 时,第一行等价于 :title="attributeValue";当 eventName 是 'click' 时,第二行等价于 @click="handleAction"。如果把事件名改成 mouseenter,监听目标也会跟着变化。

动态参数的值应该是字符串,特殊值 null 表示移除绑定。数字、对象等其他结果会产生警告。方括号内部还受 HTML 属性名语法限制,空格和引号会让解析变得困难。下面这种写法不要用:
<!-- 参数表达式里含空格和引号,编译器会警告 -->
<button :['data-' + key]="value">按钮</button>先在脚本里算好参数名,再放入方括号:
<script setup>
import { computed, ref } from 'vue'
const key = ref('course-id')
const dataAttribute = computed(() => 'data-' + key.value)
</script>
<template>
<button :[dataAttribute]="42">按钮</button>
</template>不过,动态参数并不常用。多数界面在编写时已经知道属性名和事件名。只有组件确实需要根据配置切换“绑定到哪里”时才使用它。为了炫技把固定的 @click 改成 @[eventName],只会让读者多绕一步。
动态参数最常见的误会是把方括号当成数组语法。:[attributeName] 的方括号表示“先计算参数名”;它不读取数组,也不改变右侧表达式的求值方式。
原生 JavaScript 通过 addEventListener 监听事件。Vue 使用 v-on,常见简写是 @:
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increase() {
count.value++
}
</script>
<template>
<button @click="increase">
已点击 {{ count }} 次
</button>
</template
@click="increase" 绑定的是函数本身。点击时,Vue 调用它,并把原生 DOM 事件对象传进来。如果操作只有一小步,也可以内联:
<button @click="count++">加一</button>我建议把内联处理器限制在“读一眼就懂”的范围。count++、isOpen = !isOpen 这种很合适;只要涉及校验、多个状态或异步请求,就提取函数。
<script setup>
import { ref } from 'vue'
const status = ref('等待提交')
const isSaving = ref(false)
async function submitLesson() {
if (isSaving.value) return
isSaving.value = true
status.value = '正在提交'
try {
await Promise
直接绑定方法名时,函数的第一个参数就是浏览器产生的事件对象:
<script setup>
function showSource(event) {
console.log(event.type)
console.log(event.currentTarget.tagName)
}
</script>
<template>
<button @click="showSource">查看事件信息</button>
</template>需要同时传入自定义参数和事件对象时,可以使用特殊变量 $event:
<script setup>
function chooseCourse(courseId, event) {
console.log('课程编号:', courseId)
console.log('触发元素:', event.currentTarget)
}
</script>
<template>
<button @click="chooseCourse(42, $event)">
选择课程
</button>
</template如果只写 chooseCourse(42),它属于内联调用,原生事件不会自动补到你的自定义参数后面。需要事件时明确传 $event,代码意图会更清楚。
表单提交默认会刷新或跳转页面。原生写法通常在函数开头调用 event.preventDefault();Vue 可以把这层 DOM 行为写成 .prevent:
<script setup>
function saveCourse() {
console.log('保存课程')
}
</script>
<template>
<form @submit.prevent="saveCourse">
<button type="submit">保存</button>
</form>
</template>常见事件修饰符有这些:
.prevent:阻止默认行为,常用于表单提交和链接点击。.stop:阻止事件继续向外传播。.self:只有事件目标就是当前元素本身时才处理。.once:监听器最多执行一次。.capture:在捕获阶段处理事件。.passive:告诉浏览器处理器不会阻止默认滚动行为。修饰符可以串联,但顺序会影响生成的事件处理逻辑。@click.prevent.self 会先阻止默认行为,再判断点击是否来自元素本身;@click.self.prevent 会先判断目标,只有点击元素本身时才阻止默认行为。
.passive 和 .prevent 不要一起用。前者在告诉浏览器“我不会阻止默认行为”,后者又要求阻止,两个承诺相互冲突,浏览器会给出警告。
下面的按钮位于卡片内部,点击按钮时,事件默认也会传播到卡片:
<script setup>
function openCard() {
console.log('打开卡片')
}
function removeCard() {
console.log('删除卡片')
}
</script>
<template>
<article @click="openCard">
<h3>模板语法</h3>
<
如果忘了 .stop,一次“删除”可能同时触发“打开卡片”。排查这种问题时,不要只盯着按钮函数,先看按钮外层有没有点击监听,再在控制台记录 event.target 与 event.currentTarget。前者是事件最初发生的节点,后者是当前处理器绑定的节点。
需要在输入框按回车执行搜索时,可以写 @keyup.enter。它比在函数里反复比较按键值更直观:
<script setup>
import { ref } from 'vue'
const keyword = ref('')
const resultText = ref('等待搜索')
function search() {
const value = keyword.value.trim()
resultText.value = value ? '正在搜索:' + value : '请先输入关键词'
}
还可以组合系统按键修饰符,例如 @keyup.ctrl.enter。但不要让核心功能只能靠隐蔽快捷键完成,页面上仍应提供可点击的按钮,并给用户清楚提示。
选择 keyup 还是 keydown 也要看交互意图。keydown 在按键按下时触发,长按可能重复;keyup 在松开时触发。表单提交场景通常优先使用表单自身的 submit,因为它能统一按钮点击、回车提交与辅助技术触发,不必自己拼出所有键盘路径。
假设按钮内部有一个图标:
<button @click="inspect">
<span aria-hidden="true">✓</span>
完成
</button>点击文字时,event.target 可能是按钮;点击图标时,它可能是内部的 span。event.currentTarget 则始终是绑定了处理器的按钮。如果你要读取按钮上的 data-course-id,通常应从 currentTarget 读取,避免点击位置改变后突然拿不到属性。
更稳妥的做法往往是不从 DOM 反查业务数据,而是在模板调用函数时直接传值:
<button @click="completeCourse(course.id)">
完成
</button>这样处理函数收到的课程编号来自当前渲染数据,而不是依赖 DOM 属性和点击落点。事件对象主要处理浏览器事件细节,业务参数由组件数据明确传递,两者职责更清楚。
下面的代码虽然可能运行,却很难维护:
<button
@click="count++; status = 'done'; save(); panelOpen = false"
>
完成
</button>把它提取成 finishLesson,你就能在脚本里按顺序阅读、加错误处理和测试。模板只留下 @click="finishLesson",界面结构不会被业务细节淹没。一个简单界线是:内联处理器出现分号、赋值超过一次或需要注释时,就该移出模板。
普通插值会把字符串当文本。只有明确使用 v-html,Vue 才会把字符串交给元素的 innerHTML:
<script setup>
import { ref } from 'vue'
const trustedNotice = ref(
'<strong>维护提醒:</strong>今晚 20:00 暂停提交十分钟。'
)
</script>
<template>
<p>{{ trustedNotice }}</p>
<p v-html="trustedNotice"></p>
</template>第一行会显示标签字符,第二行会把“维护提醒”显示为粗体。v-html 还会替换目标元素原有的内部内容:
<!-- “这段会被替换”不会保留 -->
<div v-html="trustedNotice">这段会被替换</div>
真正的问题不在“能不能显示粗体”,而在字符串里还能携带什么。如果内容来自评论框、个人简介、查询参数或未经处理的接口字段,攻击者可能构造带危险行为的标签、属性或地址。当其他用户打开页面时,这些内容可能在他们的登录环境里执行,读取页面信息或诱导操作。
永远不要把用户输入直接交给 v-html。内容看起来只含一段富文本,也不代表它安全。安全处理应该从内容进入系统时就开始,并由成熟的白名单净化方案控制允许的标签与属性;前端再次检查只能算补充,不能代替数据入口处的处理。
还有一个常见误区:用 v-html 拼接 Vue 模板。
<script setup>
import { ref } from 'vue'
const fragment = ref('<button @click="count++">{{ count }}</button>')
</script>
<template>
<div v-html="fragment"></div>
</template>这段字符串不会变成一个拥有 Vue 事件绑定和插值的新组件。v-html 写入的是普通 HTML,它不会把内容重新当作当前组件模板编译。需要复用交互界面时,应当创建组件;需要展示有限格式的内容时,优先把数据拆成结构化字段,再用模板决定标签。
例如,通知数据可以写成:
const notice = {
title: '维护提醒',
content: '今晚 20:00 暂停提交十分钟',
level: 'warning'
}模板自己控制结构:
<aside :class="'notice notice--' + notice.level">
<strong>{{ notice.title }}</strong>
<p>{{ notice.content }}</p>
</aside>这比让接口直接返回任意 HTML 更容易控制样式、无障碍语义和安全边界。
Vue 会安全地写入普通文本和属性,避免字符串轻易“冲出”当前属性去拼出新标签。但业务含义仍要自己校验。例如一个用户提供的链接地址,即使在 DOM 结构上被正确绑定,也可能把访问者带到欺骗页面,或者使用不允许的协议。
所以安全问题可以分两层:
不要把 :href="url" 理解成“Vue 已经确认这个地址安全”,它只表示这个属性跟 url 保持同步。
“这是后台返回的,所以可信”并不够。后台数据也可能最初来自用户输入、第三方同步、旧数据库或运营编辑器。判断一段富文本是否可以交给 v-html,至少要弄清楚四件事:内容最初由谁产生,在哪一层做净化,允许哪些标签与属性,净化规则升级后旧数据怎样处理。
常见的安全方案是使用白名单:只允许业务需要的少量排版标签,移除事件属性、脚本、危险协议和无法控制的嵌入内容。白名单净化应该由经过验证的工具完成,不要用一条正则表达式尝试“删掉 script 标签”。HTML 存在嵌套、编码和浏览器纠错行为,表面看似被删干净的字符串仍可能以另一种方式被解释。
即使内容经过净化,也要限制它能影响的页面范围。富文本不应该覆盖登录按钮、伪造弹窗或用样式遮挡真实内容。若产品允许用户运行代码或自由设计页面,那已经不是普通富文本展示问题,需要更强的隔离边界,例如放在独立环境里运行,而不是直接塞进主应用 DOM。
很多使用场景其实可以绕开原始 HTML:
v-for 生成 p。strong。title、content、level 等结构化字段。结构化数据让渲染方式掌握在前端组件手里。设计改版时可以换标签和样式,安全规则也更容易说明。只有内容确实来自受控富文本流程,并且必须保留其排版结构时,才考虑 v-html。
风险不只藏在明显的脚本标签里。可绑定地址要限制允许的协议与目标范围;允许用户控制样式时,要防止内容覆盖页面、制造透明点击区域或伪装系统界面。Vue 提供对象式 :style,可以让应用只开放少量安全属性:
<span
:style="{
color: approvedTextColor,
backgroundColor: approvedBackgroundColor
}"
>
自定义标签
</span>这里的关键不是对象语法本身能自动消除风险,而是应用只把经过约束的颜色值送到明确属性。不要把用户提供的整段样式对象或 CSS 字符串无条件绑定到页面。
现在把插值、属性绑定、事件绑定和修饰符放进同一个组件。这个例子不依赖外部图片或接口,放进 Vue 项目的 src/App.vue 就能运行。
<script setup>
import { computed, ref } from 'vue'
const course = {
title: 'Vue 模板语法',
teacher: '林老师',
capacity: 5
}
const learnerName = ref('')
const joinedLearners = ref(['小周', '阿宁'])
const isDetailOpen = ref
组件打开后,页面显示课程标题、教师、剩余名额和两位已报名学员。输入名字并提交,列表会增加一项,剩余名额减少,反馈文字同步变化。名额用完后,输入框和提交按钮都会禁用。点击“动态属性当前绑定到”按钮,输入框会在 placeholder 与 title 之间切换动态参数。
这里还提前用了 v-if、v-for 和 v-model,你暂时不需要把它们全部学透。先观察模板语法的共同结构:指令右侧都在读取当前组件的状态;事件处理函数改变状态;模板根据新状态重新描述界面。
面对一个稍长的组件,不要从第一行挨个背语法。挑一次操作,沿着它走:
用户提交表单,@submit.prevent 阻止页面刷新,并调用 joinCourse。
joinCourse 检查名字、名额和重复报名,再修改 joinedLearners、learnerName 与 feedback。
remaining 和 isFull 读取了报名列表,它们随列表变化得到新结果。
这个阅读方法很适合调试。界面不对时,先问“事件触发了吗”,再问“数据改了吗”,最后问“模板绑定的是不是这份数据”。不要一上来就操作 DOM 强行修补,否则会绕开 Vue 的数据流,下一次渲染又可能把手动改动覆盖掉。
模板问题通常不是“Vue 没反应”,而是某一层的理解错位。下面按症状来拆。
如果图片请求的是 avatarUrl,标题显示的是固定文本 courseTitle,检查属性前有没有 ::
<!-- 固定字符串 -->
<img src="avatarUrl">
<!-- 变量绑定 -->
<img :src="avatarUrl">文本节点则相反,需要插值:
<p>{{ courseTitle }}</p>先看脚本中的数据是不是响应式的:
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increase() {
// 脚本中别漏掉 .value
count.value++
}
</script>如果写成普通 let count = 0,模板能读到初始值,但 Vue 没有一个可追踪的响应式信号。再检查是不是创建了同名局部变量,导致函数修改的不是模板读取的那一个。
优先检查四类问题:
if、const 等语句。编译错误一般会带文件位置。先缩小到那一行,把复杂表达式移到脚本中;如果错误消失,再逐步恢复逻辑,比盯着一整行嵌套表达式猜更快。
查看元素嵌套关系。子元素点击事件会向外传播,外层如果也监听 click,两个处理器都会运行。根据意图选择 .stop 或 .self,不要在所有点击上机械添加修饰符。
检查监听的是不是表单的 submit,以及是否写了 .prevent:
<form @submit.prevent="save">
<button type="submit">保存</button>
</form>只监听按钮 click 会漏掉用户在输入框里按回车的提交方式。把逻辑挂在表单语义上,鼠标和键盘路径都能工作。
这不是编译失败,而是预期就不成立。v-html 设置的是普通 innerHTML,不会把字符串重新编译成当前组件模板。需要交互就拆成组件,需要富文本就把它当作受控内容处理。
这些是编译阶段的指令,不是最终需要保留的浏览器属性。你在 Elements 面板里看到的是已经生成的 DOM,所以不会看到原样的 :title 或 @click。检查绑定结果时,看目标属性和页面状态;检查响应式数据时,看 Vue 组件状态。
遇到复杂组件没有按预期更新,可以暂时把问题缩成一条最短路径。先在事件函数第一行记录一条固定消息,确认事件确实发生;再记录修改前后的响应式数据,确认函数改到了正确变量;然后在模板旁边临时加一个纯文本插值,确认模板能读到新值;最后再检查目标属性、条件分支或子组件。
例如按钮应该在提交时禁用,却一直能点击。可以先加上:
<p>调试:isSaving = {{ isSaving }}</p>如果这里始终是 false,问题在事件函数或异步流程;如果这里变成 true,按钮仍未禁用,检查 :disabled 是否真的写在按钮上;如果当前按钮来自另一个子组件,再检查状态有没有通过属性传进去。这个顺序比一边改函数、一边改模板、一边加 DOM 操作更容易得到确定答案。
定位完成后删掉临时输出,保留真正有价值的错误提示。面向用户的反馈应说明下一步能做什么,例如“名字不能为空”或“网络异常,请重试”;控制台信息则服务于开发排查,可以包含事件类型和状态快照。不要把完整用户数据、令牌或接口敏感内容打印到控制台。
最后做一次反向检查:当状态恢复时,界面是否也恢复。加载成功、加载失败、用户取消和组件离开都可能结束一次操作。如果只测试“开始提交”而不测试每个结束分支,就容易留下永远禁用的按钮或过期提示。模板能帮你集中描述状态,但前提是脚本确实把状态收尾完整。
只看代码很容易产生“我会了”的感觉。下面几道题都围绕真实的判断点,建议先在编辑器里改出答案,再展开核对。
组件中已经有 coverUrl 和 courseTitle,但下面的图片不能正确读取变量。请修改模板:
<img src="coverUrl" alt="{{ courseTitle }}">下面的模板为什么不能编译?你会怎样改?
<p>{{ if (score >= 60) { return '通过' } else { return '继续练习' } }}</p>卡片点击时打开详情,内部按钮点击时删除卡片。现在按“删除”也会打开详情。请只改模板:
<article @click="openDetail">
<button @click="removeCourse">删除</button>
</article>个人简介来自用户自己填写的输入框,其中可能包含 HTML 标签。页面希望展示这段简介。应该使用插值还是 v-html?
把计数按钮再读一遍:<button @click="count++">已点击 {{ count }} 次</button>。先停在 {{ count }},确认编译后的模板在渲染时读取了 count;再移到 @click="count++",确认浏览器点击修改的正是同一个 count。标题、图片地址和按钮状态也可以用同样的方法检查:模板在哪里读取,事件在哪里写入,最后哪个 DOM 内容或属性需要跟上。
这条阅读路径比单独记住 : 和 @ 更可靠:模板声明数据与界面的关系,事件改变数据,编译后的渲染逻辑把必要变化同步到 DOM。 内容来自哪里、应该按纯文本还是受控 HTML 处理,也能在“数据 → 表达式 → DOM”这条线上找到位置。
现在把中间那一步留在眼前:模板已经读取了 count,Vue 怎样记住这次读取?等 count 被修改时,它又怎样只叫醒真正读过 count 的界面,而不是把所有内容都重做一遍?答案要从读取发生的瞬间继续追踪——谁在读取,谁记录了关系,变化后又通知了谁。响应式的因果链,就从这个具体动作向下展开。
模板里用到这些数据的位置更新:人数、列表、剩余名额、按钮文字、禁用状态和反馈都保持一致。