Skip to content

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推导结果;不要直接赋值给派生结果。现在先通过修改初值观察绑定,下一页才用按钮修改它。

ts
import { computed, ref } from 'vue'
const busy = ref(false)
const label = computed(() => busy.value ? '保存中' : '保存')
busy.value = true
// 脚本读取label.value得到“保存中”;模板写 {{ label }}。

组件输入的最小前置 ​

本页练习需要父组件传配置。子文件中 defineProps 声明可接收的名字和类型,返回值 props 用来读取;它是编译宏,不需要import。withDefaults给可选输入设置默认值:

vue
<!-- src/components/LabelView.vue -->
<script setup lang="ts">
const props = withDefaults(defineProps<{ label?: string }>(), { label: '未命名' })
</script>
<template><span>{{ props.label }}</span></template>
vue
<!-- src/App.vue -->
<script setup lang="ts">
import LabelView from './components/LabelView.vue'
</script>
<template><LabelView label="查看资料" /><LabelView /></template>

两处分别显示“查看资料”和“未命名”。父模板用 :label="expression" 时先计算表达式;子组件只读输入。对象输入、类型校验和完整单向数据流在第二章深入。

最小可用模式 ​

vue
<!-- 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
vue
<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,也不应该承担业务分支;那会让模板从“看得见的界面结构”变成“运行时猜谜”。

vue
<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,别在脚本里手搓长字符串然后祈祷少漏一个空格。

vue
<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 配置。它不是“把所有东西倒进组件”的许可证:对象里有什么,读代码的人就得猜什么。

vue
<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>

排错路线:属性看着对,交互为什么不对 ​

  1. 先确认元素语义。 这是原生 <button>、<a>、自定义元素还是 Vue 组件?例如 <a> 没有原生 disabled 行为,不能指望给它一个 attribute 就自动停下。
  2. 再看绑定值的类型。 DevTools 中是 false、undefined,还是字符串 'false'?布尔、数值和对象经常在接口数据边界被悄悄转错。
  3. 区分初始标记和当前 property。 用户输入后的 input.value 与页面源代码里的 value="…" 不必相同;不要只在 Elements 面板看一眼就下结论。
  4. 最后检查所有权。 如果第三方脚本、浏览器扩展或手写 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 导航;不要伪造原生 disabled attribute。
  • 仅当 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 对象现在是什么”并不总是同一件事。

用心记录每一步成长