01|插值与表达式
让数据上台,别让逻辑抢戏。
第一章概览 / Vue 3.5+ / 阅读约 15~25 分钟。目标:把状态安全、清楚地投射到界面上,并知道模板表达式在哪儿该刹车。
本页完整示例以 Vue 3.5.x + Vite 的 TypeScript 模板为运行环境;把代码保存为 src/App.vue,执行项目已有的 npm run dev 后在浏览器观察。若你使用 CDN 或 DOM 内模板,大小写与标签解析还会受浏览器 HTML 解析规则影响,本页不覆盖那条分支。
新手主线
先跑最小示例,再看选择边界,最后做练习。每读完一个小节,改一行代码并预测结果。
老手快车
直接跳到“60 秒选择”“安全边界”“深水区”和“排错手册”。你要找的是所有权、时机和信任边界,不是把花括号念得更响。
本页要拿下的能力
| 层级 | 你要会什么 | 典型场景 |
|---|---|---|
| 必会 | {{ }}、JavaScript 表达式、文本展示 | 标题、计数、空状态 |
| 熟练 | v-text、属性绑定、空值和表达式边界 | 完整文本、提示属性、格式化展示 |
| 深入 | v-html 安全边界、渲染上下文、computed 的取舍 | 可信富文本、性能排查、模板瘦身 |
本页暂时不把事件、表单和列表讲成完整章节;它们会在后面接班。本篇只负责回答:一份值怎样抵达屏幕,以及它应该以什么身份抵达。
读完后,试着不用查文档回答四个问题:
- 为什么
{{ message }}会显示 HTML 字符,而不是把它变成标签? - 为什么
:disabled="busy"能控制按钮,disabled="{{ busy }}"却不行? - 为什么模板里可以写三元表达式,却不适合发请求或声明变量?
- 什么情况下
v-html才有资格进入生产代码?
如果这四题还能讲出“因为”,而不只是背出写法,说明你已经从语法站台走到理解站台了。
最小示例:让数据第一次上台
先看最小的 .vue 文件。这里故意不引入按钮和请求,先学会“值如何被显示”。
<!-- src/App.vue -->
<script setup lang="ts">
const learner = '小白'
const score = 42
const message = '<strong>这仍然是文字</strong>'
</script>
<template>
<h1>你好,{{ learner }}!</h1>
<p>经验值:{{ score }}</p>
<p>{{ message }}</p>
</template>运行后你会看到三行文本,第三行会显示 <strong>这仍然是文字</strong>,而不是粗体。把 learner 改成自己的名字,再预测一次页面结果。
这里的双花括号叫文本插值:Vue 读取表达式的结果,把它放进文本节点,并自动转义 HTML。它像一个可靠的播报员,只负责报幕,不负责把后台的锅端上台。
逐行拆解:三块区域各自做什么
| 代码 | 它在做什么 | 你现在可以先记住什么 |
|---|---|---|
<script setup> | 声明模板要读取的状态和函数 | 顶层绑定会自动暴露给模板 |
const learner = '小白' | 创建一个普通的 JavaScript 值 | 值不变时,普通常量已经够用 |
<h1>你好,{{ learner }}!</h1> | 把表达式结果放入文本节点 | 双花括号默认按文本处理 |
<p>{{ message }}</p> | 将字符串原样作为文字内容展示 | 字符串长得像 HTML,也不会自动变 HTML |
这里故意没有 ref()。下一篇会先补响应式容器的基本用法,第三章再展开原理;本篇先把“显示”这件事单独看清楚。想做个预习实验时,在脚本顶部加上 import { ref } from 'vue',再把 score 改成 ref(42),然后观察:在模板中读取顶层 ref 时,不需要手写 .value,Vue 会帮你拆包。
动手改一行:预测再刷新
把 message 依次改成下面三种值,并先猜页面会显示什么:
const message = '<em>斜体?</em>'
const message = ''
const message = null第一种会显示标签字符,第二种显示空白,第三种通常也显示为空。这个小实验比背“自动转义”四个字更牢靠:浏览器没有被 Vue 说服去执行任何 HTML。
60 秒选择:文本、属性还是 HTML
先辨别目标,再选择绑定通道。绑定不是越短越好,下一位读代码的人不用考古,才是最高礼貌。
| 你手上的内容 | 优先写法 | Vue / 浏览器做什么 | 不要误用成 |
|---|---|---|---|
| 普通文本、用户名、接口字段 | {{ value }} | 设置文本节点并转义 HTML | v-html |
| 元素的完整文本内容 | v-text="message" | 设置 textContent,会覆盖原有子节点 | 和子元素混用 |
href、disabled、aria-*、组件 prop | :href="url"、:disabled="busy" | 更新属性或 prop | 把 {{ }} 塞进属性字符串 |
| 已明确净化的富文本 | v-html="sanitizedHtml" | 设置 innerHTML,不会再次编译 Vue 模板 | 用户评论或 URL 原文 |
| 难读的展示计算 | computed 或命名函数 | 给逻辑一个可测试、可复用的名字 | 三层三元或渲染时请求数据 |
例如,<button :disabled="busy">保存</button> 在 busy 为真时会真的不能点;disabled="{{ busy }}" 则不是 Vue 的属性绑定方式。
再看一个容易混淆的边界:
<!-- 文本内容:元素里面放什么字 -->
<p>{{ message }}</p>
<!-- 属性 / DOM property:元素本身有什么能力 -->
<button :disabled="busy">保存</button>
<a :href="profileUrl">查看资料</a>{{ }} 的工作对象是文本节点;:(也就是 v-bind 的缩写)把表达式结果交给属性或组件 prop。可以把它们想成两个窗口:一个负责“说台词”,一个负责“递道具”。把台词塞进道具窗口,Vue 只好一脸困惑。
如果属性很多,可以使用对象绑定,但要先确认这确实属于属性层:
<button v-bind="buttonAttrs">保存</button>const buttonAttrs = {
type: 'button',
'aria-label': '保存公告',
}对象绑定很方便,也更容易把一堆属性整体传给组件;但它不会替你判断数据是否可信,href、style 等来自用户或接口的值仍然需要单独审查。
表达式边界:模板能算什么
花括号和指令值接受单个 JavaScript 表达式,不接受语句。把“能算”与“该不该在模板算”分开,是本篇最重要的判断。
| 可以留在模板 | 建议移到 <script setup> | 为什么 |
|---|---|---|
{{ count + 1 }} | items.filter(...).sort(...).map(...) | 后者难读,渲染时可能反复执行,排序还可能误改原数组 |
{{ ok ? '已完成' : '待办' }} | @click="if (...) { ... }" | 行为分支应该由命名函数负责 |
{{ user?.name ?? '访客' }} | {{ await fetchProfile() }} | 模板不该启动异步副作用 |
{{ formatDate(createdAt) }} | {{ new Intl.DateTimeFormat(...).format(...) }} | 格式化函数更容易复用;昂贵派生值再交给 computed |
一个好判断法:如果读出表达式时需要换气两次,或者很难给它起名字,它大概率该离开模板。visibleTasks、formattedPublishedAt 比一串筛选、排序和映射更会说人话。
表达式和语句:长得像亲戚,工作完全不同
模板需要的是“算出一个值”,而不是“执行一段流程”。下面左边可以计算,右边是语句,应该移到脚本中:
| 可以作为表达式 | 不能直接塞进模板 | 推荐改法 |
|---|---|---|
{{ count + 1 }} | {{ const next = count + 1 }} | 在脚本中声明 next |
{{ ok ? '已完成' : '待办' }} | {{ if (ok) { return '已完成' } }} | 使用三元表达式或命名函数 |
:id="'task-' + id" | @click="for (...) { ... }" | 在事件函数中处理循环 |
{{ user?.name ?? '访客' }} | {{ await fetchProfile() }} | 在生命周期或事件中请求数据 |
模板表达式运行在 Vue 提供的受限上下文中,不能把任意 JavaScript 模块作用域、浏览器全局对象和控制流语句都搬进来。限制不是 Vue 故意为难你,而是为了让模板更容易编译、分析和重复执行。
把复杂展示逻辑交给 computed
这段是为后续动态页面准备的预览,本页基础题可以使用普通函数。ref(initial)保存可变值,脚本读写使用 .value;computed(() => expression)返回派生ref,读取时按依赖求值。模板对顶层ref省略 .value。当值由状态推导时,可用computed表达关系:
import { computed, ref } from 'vue'
const tasks = ref([
{ id: 1, title: '读插值', done: true },
{ id: 2, title: '做练习', done: false },
])
const openTasks = computed(() => tasks.value.filter(task => !task.done))模板只需要写 {{ openTasks.length }}。这样做有三个好处:逻辑有名字、结果可测试、依赖不变时可以复用缓存。注意 computed 的 getter 应该是纯函数,不要在里面发请求、改数组或偷偷修改别的状态。
安全与生产案例:v-html 先验票
v-html 设置的是 innerHTML。Vue 不会替你清理脚本、事件属性或恶意 URL,内容也不会再被当成 Vue 模板编译。
<!-- 不要这样:comment 可能来自用户输入。 -->
<div v-html="comment"></div>
<!-- 更稳:默认按文本展示;换行交给 CSS。 -->
<p class="comment">{{ comment }}</p>
<style scoped>
.comment { white-space: pre-wrap; }
</style>| 内容来源 | 可以直接 v-html 吗? | 更好的处理方式 |
|---|---|---|
| 受控 CMS,服务端已经白名单净化 | 可以 | 命名为 sanitizedHtml,写清净化责任在哪一层 |
| 用户评论、编辑器原始结果 | 不可以 | 先在服务端或可信库完成白名单净化 |
| 接口错误信息、搜索参数、昵称 | 不可以 | 使用文本插值 |
| 固定 SVG 片段 | 通常不必 | 优先组件化或静态模板 |
安全红线
“看起来像 HTML”不等于“可信 HTML”。没有明确的净化边界,就不要使用 v-html。
可以用一个“恶作剧评论”测试自己的判断:
const comment = '<img src=x onerror="alert(\'糟糕\')">'用插值渲染时,它只是文字;用 v-html 渲染时,浏览器可能把它当成真正的元素和事件。真实项目里还要警惕 href、src、style 等输入,因为风险不只藏在 <script> 标签里。
谁负责净化:前端不是孤岛
安全边界最好写在数据命名和接口契约里,而不是藏在某个模板角落:
// 这个字段的名字提醒读者:它已经经过白名单净化。
const sanitizedHtml = response.content前端仍然应该默认不信任外部输入;“服务端已经净化”也应当是可验证的契约,而不是一句口头禅。若只是想展示换行、强调或链接,优先使用普通文本、CSS 或受控组件。
空值与未定义变量
null和undefined作为插值结果通常显示为空字符串;需要兜底时,使用user?.name ?? '访客'。0、false和空字符串是有效值,不要用||把它们误判成“没有数据”。- 模板找不到变量时,先检查
script setup顶层声明、拼写和v-for/ 插槽作用域;开发环境通常会给出警告。
可以把空值处理写成一个小表格:
| 值 | 插值通常显示 | 常见意图 |
|---|---|---|
null / undefined | 空字符串 | 数据还没到,准备显示占位符 |
'' | 空字符串 | 用户真的提交了空文本 |
0 | 0 | 数量就是零,不是“缺失” |
false | false | 开关关闭,不是“没有值” |
因此,user.name || '访客' 和 user.name ?? '访客' 不是一回事:前者会把空字符串、0 和 false 也当成缺失,后者只兜底 null 与 undefined。选择运算符时,先问业务上“空”到底指什么。
深水区:模板不是字符串拼接器
.vue 的 <template> 会被编译成渲染函数。<script setup> 的顶层绑定会自动暴露给模板,但模板不是任意 JavaScript 模块作用域的后门。
这解释了三个生产结论:
- 不要在渲染表达式里修改状态。渲染可能发生多次,副作用会像复印机卡纸一样重复。
- 展示所需但昂贵的派生值交给
computed;它按依赖缓存。一次无副作用的格式化函数调用通常没有问题。 v-html内容不会再次编译,也不会自动得到 SFC 的scoped样式选择器;富文本需要样式时,应设计受控内容模型。
从源码到屏幕:为什么模板值得被限制
在构建型项目里,.vue 文件大致经历这条路径:
<template> → 模板编译器 → render 函数 → VNode → 浏览器 DOM编译器会分析静态节点、绑定和更新提示,运行时再根据响应式状态变化更新需要变化的部分。你不必现在就读编译器源码,但这个模型能解释很多现象:
- 模板表达式可能在一次更新中执行多次,所以不要把修改状态、发请求、写日志当成“展示计算”。
computed把派生关系命名出来,也让依赖追踪更明确;它不是“把所有代码搬到脚本”的垃圾桶。v-html的内容是浏览器直接解析的 HTML,不会重新进入 Vue 模板编译流程。
如果你发现模板里出现了请求、排序、状态修改和三层条件判断,通常不是 Vue 变复杂了,而是职责该重新分房间了。
v-pre 与 v-cloak:两位救场同学
v-pre 跳过一小段 Vue 编译,适合原样展示 {{ title }}。v-cloak 在 Vue 挂载前标记元素,主要服务于 CDN 或渐进式接入;VitePress 这类构建型项目通常不需要它。
<code v-pre>{{ notAnExpression }}</code>
<div v-cloak>{{ message }}</div>
<style>[v-cloak] { display: none; }</style>不要把整块组件 v-pre,那会让这一区域的绑定一起放假。
v-cloak 也不是“让页面更快”的魔法。它只能在 Vue 挂载前隐藏带有 [v-cloak] 的元素;如果项目通过 Vite 构建,通常不会长时间暴露未编译模板。不要为了显得专业而给每个组件都贴一张隐身符。
排错手册:先查数据,再查绑定
| 现场症状 | 检查顺序 | 修复方向 |
|---|---|---|
页面显示 <strong>重点</strong> | 先确认内容是否只是文本 | 默认保持插值;确需 HTML 时使用已净化字段 |
模板里想写 const total = … | 确认是否把语句塞进表达式 | 在脚本中定义变量、函数或 computed |
| 一次渲染触发多次请求 | 搜索模板里是否调用有副作用的函数 | 请求放进事件、生命周期或 composable |
| 数据明明有,模板却找不到 | 检查暴露、拼写和作用域 | 使用 script setup 顶层绑定,核对 v-for / 插槽变量 |
v-text 后子元素消失 | 确认是否需要混合文本与子节点 | 改用插值和正常模板结构 |
排查顺序可以固定成:数据 → 展示通道 → 表达式 → 生命周期与 DOM。先从源头走到屏幕,别一上来就和空 div 深情对望。
一个五分钟排错演练
假设页面没有显示用户名:
<script setup lang="ts">
const profile = { name: '小白' }
</script>
<template>
<p>{{ user.name }}</p>
</template>按固定顺序排查:
- 数据: 脚本里实际叫
profile,不是user。 - 作用域:
profile是顶层绑定,可以被模板读取。 - 表达式: 改成
{{ profile.name }},不需要把对象复制一份。 - 通道: 这里要显示文字,插值就是合适的通道。
如果修正后仍然不更新,再去检查它是不是响应式状态,以及是否在异步数据到达前就需要占位符。排错的顺序越稳定,越不容易靠“刷新一下看看”碰运气。
练习:同一份公告的三种输入
在 学习环境 建好的项目里,把公告保存在 src/App.vue。本题只使用本篇已经讲过的常量、表达式、文本插值、属性绑定、CSS 和 v-pre。
输入为 title: string | null、body: string、priority: number 和固定的 ISO 时间字符串。先用 title='维护通知'、body='<em>请保存</em>\n稍后恢复'、priority=7;再将标题改成 null、优先级改成 8;最后把标题改成空字符串、优先级改成 0。每次修改源文件并保存,先预测再观察页面。
- 标题仅在
null时显示“未命名公告”,空字符串必须保留为空,不能用||混淆二者。 - 正文里的标签必须显示为文字,换行由 CSS 保留。
- 始终显示一行级别文本:小于8是“普通”,大于等于8是“高优先级”;使用三元表达式或命名纯函数。
<time>的datetime绑定原始ISO字符串;可见时间可以用命名格式化函数生成。- 用
v-pre原样展示{{ title }},并说明它为何不显示当前标题。
查看第01题答案。把输入从 null 改成 '' 是本题的独立变体:关键是解释空值合同,不能只比较页面是否有字。
点击提交、复选框与异步错误处理分别在事件、表单中学习。它们不属于本题的完成条件。
复习卡:
- 文本用插值,完整文本可用
v-text。 - 属性用
:prop,不是{{ }}。 - 模板放表达式,不放语句和副作用。
- 用户内容默认按文本展示。
v-html只接收可信且已净化的 HTML。- 复杂展示逻辑移到命名函数或
computed。
下一篇:02|属性绑定。下一站演员是 :href、:disabled 和动态 class,它们会把“文字”升级成真正的属性行为。