02|属性绑定:给 DOM 发对地址,别把信塞进门缝
第一章概览 / 02。目标:知道
v-bind绑定的到底是 attribute 还是 DOM property,并能安全地组织 class、style 与无障碍属性。
下面的主例可直接保存为 Vue 3.5.x + Vite TypeScript 项目的 src/App.vue。打开浏览器 Elements 与 Properties 面板一起观察:前者显示标记属性,后者显示 DOM 对象当前值。
本页能力清单
| 层级 | 需要掌握 | 关键词 |
|---|---|---|
| 必会 | v-bind、:、动态值、布尔属性 | :disabled、:href、:aria-* |
| 熟练 | 对象批量绑定、动态参数、class/style 的对象与数组语法 | v-bind="attrs"、:[name] |
| 深入 | attribute / property 差别、.prop / .attr / .camel | 初始标记、运行时状态、Web Components / SVG |
先接上可变化的值
上一页的常量只在修改源文件并保存后改变。本页示例用 ref(initial) 创建可更新的值:脚本通过 .value 读写,模板读取顶层ref时省略 .value。computed(() => ...) 则按读取的ref推导结果;不要直接赋值给派生结果。现在先通过修改初值观察绑定,下一页才用按钮修改它。
import { computed, ref } from 'vue'
const busy = ref(false)
const label = computed(() => busy.value ? '保存中' : '保存')
busy.value = true
// 脚本读取label.value得到“保存中”;模板写 {{ label }}。组件输入的最小前置
本页练习需要父组件传配置。子文件中 defineProps 声明可接收的名字和类型,返回值 props 用来读取;它是编译宏,不需要import。withDefaults给可选输入设置默认值:
<!-- src/components/LabelView.vue -->
<script setup lang="ts">
const props = withDefaults(defineProps<{ label?: string }>(), { label: '未命名' })
</script>
<template><span>{{ props.label }}</span></template><!-- src/App.vue -->
<script setup lang="ts">
import LabelView from './components/LabelView.vue'
</script>
<template><LabelView label="查看资料" /><LabelView /></template>两处分别显示“查看资料”和“未命名”。父模板用 :label="expression" 时先计算表达式;子组件只读输入。对象输入、类型校验和完整单向数据流在第二章深入。
最小可用模式
<!-- src/App.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
const isSaving = ref(false)
const theme = ref<'mint' | 'plum'>('mint')
const avatarUrl = ref('/avatars/yuzi.png')
const linkTarget = ref<'_self' | '_blank'>('_blank')
const buttonClasses = computed(() => [
'action-button',
{ 'is-busy': isSaving.value, [`theme-${theme.value}`]: true }
])
const buttonAttrs = computed(() => ({
type: 'button' as const,
'aria-busy': String(isSaving.value),
title: isSaving.value ? '正在保存,请勿催促按钮' : '保存草稿'
}))
</script>
<template>
<img :src="avatarUrl" alt="柚子的头像" width="96" height="96" />
<button
v-bind="buttonAttrs"
:class="buttonClasses"
:disabled="isSaving"
>
{{ isSaving ? '保存中…' : '保存' }}
</button>
<a
:href="'/guides/vue'"
:target="linkTarget"
:rel="linkTarget === '_blank' ? 'noreferrer' : undefined"
>阅读指南</a>
</template>v-bind:href="url" 可缩写成 :href="url"。动态参数也可以用 :[attributeName]="value";它适合少数真正由配置决定的属性名,日常不要把模板写成猜谜游戏。
attribute 与 property:同名双胞胎,不是同一个人
HTML attribute 是写在标记上的初始声明,例如 <input value="hello">;DOM property 是浏览器内存里此刻的对象状态,例如用户已经输入后 input.value。多数常见绑定 Vue 能正确推断,但理解差异能救你于自定义元素、SVG 或罕见属性的现场。
| 需求 | 优先写法 | 为什么 |
|---|---|---|
| 禁用按钮、勾选输入框 | :disabled="busy"、:checked="selected" | 这是可交互元素的当前 property 状态 |
设置 aria-label、data-id | :aria-label、:data-id | 这是应存在于标记上的 attribute |
| 对原生 DOM 强制 property | .prop 或简写 . | 仅在推断不符合需求时使用 |
| 对原生 DOM 强制 attribute | .attr | 例如需要始终保留属性字符串时 |
| 需要 camelCase attribute 名 | .camel | 偶见于 SVG / 自定义元素,先确认目标 API |
<script setup lang="ts">
const plainText = '由 Vue 写入 DOM property 的纯文本'
const attributeName = 'aria-describedby'
</script>
<template>
<!-- 强制设置 DOM property;textContent 不会解析 HTML。 -->
<div :textContent.prop="plainText"></div>
<!-- 强制设置 attribute;适合确有标记层语义的场景。 -->
<input :value.attr="'初始提示,不等于用户现在输入的值'" />
<button :[attributeName]="'saving-tip'">保存</button>
</template>深水区:动态属性不是动态数据模型
动态参数 :[name] 的价值是属性名确实由一个有限配置决定,例如无障碍关联的 ID、少量 SVG 属性或一组配置化的控件。它不适合把任意字符串直接变成 DOM API,也不应该承担业务分支;那会让模板从“看得见的界面结构”变成“运行时猜谜”。
<script setup lang="ts">
import { computed, ref } from 'vue'
const fieldId = 'email'
const errorId = `${fieldId}-error`
const error = ref('')
// 属性名稳定,值才响应式变化;大多数场景这样更易读。
const descriptionId = computed(() => error.value ? errorId : undefined)
</script>
<template>
<label :for="fieldId">邮箱</label>
<input :id="fieldId" type="email" :aria-describedby="descriptionId" />
<p v-if="error" :id="errorId" role="alert">{{ error }}</p>
</template>绑定值为 null 或 undefined 时,Vue 会移除普通 attribute;这通常正是“条件不成立就别输出”的语义。布尔属性则尤其不能传字符串:disabled="false" 仍表示禁用,aria-disabled="false" 却是保留属性并告知辅助技术未禁用。原生按钮和链接的行为能力不同,先选对元素,再谈绑定。
class 与 style:组合而非拼接
class 接受字符串、对象、数组及其嵌套。对象适合条件,数组适合基础类 + 变体;让状态决定 class,别在脚本里手搓长字符串然后祈祷少漏一个空格。
<script setup lang="ts">
import { computed, ref } from 'vue'
const compact = ref(false)
const danger = ref(false)
const accent = ref('#0f766e')
const cardStyle = computed(() => ({
'--accent': danger.value ? '#dc2626' : accent.value,
fontSize: compact.value ? '0.875rem' : '1rem'
}))
</script>
<template>
<article
:class="['notice-card', { 'notice-card--compact': compact, 'is-danger': danger }]"
:style="cardStyle"
>
颜色来自 CSS 变量,逻辑和视觉终于不必在一个对象里打架。
</article>
</template>
<style scoped>
.notice-card { border-left: 4px solid var(--accent); padding: 1rem; }
.is-danger { font-weight: 700; }
</style>批量绑定的边界
v-bind="object" 很适合透传经过筛选的属性,例如一个可复用按钮的 aria-*、data-* 或 DOM 配置。它不是“把所有东西倒进组件”的许可证:对象里有什么,读代码的人就得猜什么。
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ label: string; disabled?: boolean }>()
const nativeButtonAttrs = computed(() => ({
'aria-label': props.label,
disabled: props.disabled ?? false,
'data-component': 'primary-action'
}))
</script>
<template>
<button v-bind="nativeButtonAttrs"><slot /></button>
</template>排错路线:属性看着对,交互为什么不对
- 先确认元素语义。 这是原生
<button>、<a>、自定义元素还是 Vue 组件?例如<a>没有原生disabled行为,不能指望给它一个 attribute 就自动停下。 - 再看绑定值的类型。 DevTools 中是
false、undefined,还是字符串'false'?布尔、数值和对象经常在接口数据边界被悄悄转错。 - 区分初始标记和当前 property。 用户输入后的
input.value与页面源代码里的value="…"不必相同;不要只在 Elements 面板看一眼就下结论。 - 最后检查所有权。 如果第三方脚本、浏览器扩展或手写 DOM 代码也在改同一个属性,Vue 的下一次渲染会重新以状态为准。让状态保持单一来源。
一个小原则足够覆盖大多数场景:数据决定属性,元素决定语义,事件决定行为。 不要用 class 假装禁用,也不要用 aria-disabled 替代真正按钮的 disabled;它们各自有职责。
常见翻车现场
| 现象 | 原因 | 更好的做法 |
|---|---|---|
disabled="false" 仍禁用 | HTML 布尔 attribute 只要存在就为真 | 写 :disabled="false",让 Vue 移除它 |
| 打开新标签页的链接不安全 | 缺少合适的 rel | 外部且 _blank 时根据需要使用 noreferrer / noopener 策略 |
| class 字符串越来越长 | 视图状态靠拼接管理 | 用对象/数组语法,让条件可见 |
一个 v-bind 对象塞进几十项 | 组件输入边界失踪 | 显式声明 props;批量绑定只留同类的 DOM 属性 |
练习:做一枚“有礼貌”的外链按钮
创建 src/components/ExternalLinkButton.vue 与 src/App.vue。组件输入合同是:
href: string只接受以/开头的站内路径或http://、https://URL;空字符串、javascript:等协议视为无效。external?: boolean、disabled?: boolean默认false;tone?: 'primary' | 'muted'默认primary。disabled或 URL 无效时,控件保留“链接已禁用”的可访问语义,但不能通过点击或 Enter 导航;不要伪造原生disabledattribute。- 仅当 URL 有效、未禁用且
external为真时,输出target="_blank"与rel="noopener noreferrer"。
参考答案:查看第一章练习答案中的第 02 题。
验收清单:如何判断练习完成
- 用
/guide、https://example.com、javascript:alert(1)三组输入观察最终href;危险协议不得留在可导航的href中。 external为假时没有target与rel;为真时才同时得到_blank、noopener和noreferrer。- 禁用时控件仍可被键盘找到并由辅助技术读出
aria-disabled,点击或 Enter 都不导航;原生按钮若出现在你的变体中,才使用布尔:disabled。 tone和禁用外观来自对象或数组形式的:class,没有靠一长串条件拼接 class 字符串。
通关自检:你能说清“HTML 里写着什么”和“用户交互后 DOM 对象现在是什么”并不总是同一件事。