跳转到内容

Prop 访问

WebComponent 接口的 props 属性用于方便地读写任意被观察属性的驼峰式(camelCase)对应项。

class HelloWorld extends WebComponent {
static props = {
myProp: 'World',
}
get template() {
return html` <h1>Hello ${this.props.myProp}</h1> `
}
}

为某个被观察属性的 props.camelCase 对应项赋值,将触发“属性变化”钩子。

例如,像这样赋值:

this.props.myName = 'hello'

……相当于调用:

this.setAttribute('my-name','hello');

因此,如果该属性是我们通过 static props 明确提供的组件被观察属性之一,这将告知浏览器界面需要重新渲染;

在 TypeScript 中选用带类型的 props

Section titled “在 TypeScript 中选用带类型的 props”

查看实况:编译期 prop 类型演示 ↗带类型的 props 演示 ↗

默认情况下,this.props 是一个宽松的 { [name: string]: any } 映射。在 TypeScript 中,你可以为已声明的 props 获得编译期类型检查。将形状声明为一个具名类型,将其作为类的类型参数传入,并在初始化时用它标注 static props

type CozyButtonProps = {
variant: 'primary' | 'ghost'
disabled: boolean
}
class CozyButton extends WebComponent<CozyButtonProps> {
static props: CozyButtonProps = {
variant: 'primary',
disabled: false,
}
get template() {
this.props.variant // 'primary' | 'ghost'
this.props.disabled // boolean
this.props.notAProp // ❌ 编译错误:未声明
this.props.disabled = 'yes' // ❌ 编译错误:字符串不是 boolean
this.props.variant = 'plaid' // ❌ 编译错误:不在联合类型中
return html`<button class=${this.props.variant}></button>`
}
}

这个类型标注会将类型检查应用到后续的赋值上,同时默认值本身也会依据该类型被检查,因此缺少某个键或默认值超出联合类型范围同样会产生编译错误。

布尔型 props 遵循 HTML 布尔属性的约定,与原生的 disabledrequired 完全一致:存在即为 true,不存在即为 false

查看实况:布尔 props 演示 ↗

class FlagBox extends WebComponent {
static props = { flag: false }
}
<!-- props.flag === false -->
<flag-box></flag-box>
<!-- props.flag === true -->
<flag-box flag></flag-box>
<!-- props.flag === true -->
<flag-box flag=""></flag-box>

反向反射的方式与之相同。true 会设置一个不带值的裸属性, false 会将其彻底移除:

el.props.flag = true // <flag-box flag>
el.props.flag = false // <flag-box>

因为 false 表现为属性不存在而不是 flag="false",所以平台自身的 API 和 CSS 存在性选择器都会按预期工作:

el.toggleAttribute('flag', true) // prop 同步,组件重新渲染
:host([flag]) {
/* 仅当该 prop 确实为 true 时才匹配 */
}

contenteditablearia-* 这样的枚举属性是例外情况。 它们是真正的字符串,其中 "false" 是有意义的,因此应将其声明为字符串 类型的 props 而非布尔型。字符串按字面量序列化且从不会被移除,因此在 运行时无需任何特殊处理;在 TypeScript 中,可以将其收窄为你接受的取值, 写入你的 props 类型

type ToggleProps = {
ariaChecked: 'true' | 'false'
}

查看实况:自定义属性转换器演示 ↗

以上规则覆盖了常见情形。当某个 prop 需要自己的序列化方式(Date、 分隔列表、"false" 有意义的枚举属性)时,重写 toAttributefromAttribute,并将其余情况委托给 super

class EventCard extends WebComponent {
static props = { when: new Date(0), title: '' }
toAttribute(name, value) {
if (name === 'when') return value.toISOString().slice(0, 10)
return super.toAttribute(name, value)
}
fromAttribute(name, value) {
if (name === 'when') return new Date(`${value}T00:00:00Z`)
return super.fromAttribute(name, value)
}
}
<!-- props.when 是一个 Date -->
<event-card when="2026-07-20"></event-card>

两者接受的都是驼峰式的 prop 键名,与你在 static props 中的声明 以及 onChangesproperty 一致,而不是短横线式的属性名。

toAttribute 返回 null 会移除该属性。这正是 false 布尔值 变为不存在属性的方式,且适用于任何 prop:

toAttribute(name, value) {
// 对该 prop 来说,空字符串意味着“完全没有该属性”
return value === '' ? null : super.toAttribute(name, value)
}

当为 prop 赋值时,wcb 不会通过 fromAttribute 反向检查和解析属性。 你所赋的 prop 值本身就是真源,而文本形式的属性可能是精度较低的表示。 在上面的示例中,即使属性只携带日期,props.when 也会保留完整的时间戳。 render()onChanges 仍会照常触发。fromAttribute 转换只会针对从组件 外部通过标记、setAttributetoggleAttribute 写入的属性被调用。

默认的转换器会将 prop 值通过 JSON 进行往返转换,这能精确还原数字、 布尔值和普通对象/数组。JSON 无法表示的类型则无法在往返中存活: Date 会变回一个普通字符串,MapSet 会坍缩为 "{}"

这类类型仍然可以作为一等的 props 使用。你可以为其声明一个该类型的 真实默认值(见上方的警告),并重写转换器。上面的 EventCard 示例正是 通过为其 Date prop 使用自定义转换逻辑来处理这一点。

另一种做法是改用专为此设计的库。你可以选择将像 devalue 这样的序列化库作为组件的依赖, 它可以序列化和解析 MapSetDateRegExpBigIntundefined, 甚至循环引用。这样一对通用的重写就能覆盖所有结构化的 props。

下面是用它重写后的 EventCard,并新增了一个类型为 Settags prop:

import { parse, stringify } from 'devalue'
class EventCard extends WebComponent {
static props = {
when: new Date(0),
tags: new Set(),
title: '',
}
toAttribute(name, value) {
// 处理 `when` 和 `tags`
if (value instanceof Object) return stringify(value)
return super.toAttribute(name, value)
}
fromAttribute(name, value) {
if (this.constructor.props[name] instanceof Object) return parse(value)
return super.fromAttribute(name, value)
}
}

这两个判断是故意不对称的:toAttribute 看到的是实时的值,但 fromAttribute 只能看到一个字符串,因此它需要参考所声明的默认值 (使用 this.constructor.props)来判断该属性是否是 devalue 编码的。 title 不满足这两个判断条件,因此会照常按普通字符串反射。

编码后的属性不像手写的 when="2026-07-20" 那样易读,但它仍然是纯文本:

<!-- props.when 是一个真正的 Date,props.tags 是一个真正的 Set -->
<event-card
when='[["Date","2026-07-20T09:30:00.000Z"]]'
tags='[["Set",1,2],"alpha","bravo"]'
></event-card>

转换器可以覆盖任何具有合理文本形式的值:可以是像 ISO 日期这样定制化的, 也可以是像 devalue 编码这样通用的。而完全没有文本形式的值(一个函数、 一个元素引用、一个诸如 AbortController 之类的活动句柄)并不是转换器 能解决的问题:即便是 devalue 也会拒绝它(Cannot stringify a function), 它本来就不应该出现在 static props 中。应将其保留为普通的类属性。 参见下方的 未被观察的属性。wcb 正是出于这个原因, 在检测到某个声明的默认值是 functionsymbol 时,会针对每个类警告一次。

static props 中声明的一切都会被观察和反射:每个键都会对应一个属性, 写入会触发 render()onChanges(),且默认值会在首次连接时出现在 DOM 中。 这对于组件面向 DOM 的公共 API 而言是正确的约定,但对内部状态来说则是不必要的。

对于不需要出现在 DOM 中的状态,应使用普通的类属性。WebComponent 本质上 仍是一个继承自 HTMLElement 的类,因此普通属性的工作方式与任何元素上的 普通属性完全一样,且对 props 机制完全不可见(没有属性、不会被观察、 不会自动渲染):

class DataTable extends WebComponent {
static props = { compact: false } // 公共 API:<data-table compact>
rows = [] // 内部状态,永远不会成为属性
#controller = new AbortController() // 不可序列化的值在这里没问题
async onInit() {
const res = await fetch('/rows', { signal: this.#controller.signal })
this.rows = await res.json()
this.render() // 未被观察的变化需要你自己触发渲染
}
onDestroy() {
this.#controller.abort()
}
}

因为没有任何机制在监视普通属性,所以当某个变化应当更新视图时, 需要你自己调用 this.render()

经验法则是:static props 用于消费者会通过标记设置、或通过属性选择器进行 样式匹配的值;类属性(公有或 #private)则用于其他一切情况:大型数据、 函数,以及像计时器或 AbortController 这样本来就无法经由属性往返的句柄。 如果你发现自己在想“我不想让它出现为一个属性”,那正是它应该是类属性而不是 prop 的信号。

目前的替代方案是使用 HTMLElement 开箱即用提供的能力,包括:

  1. 对于以 data-* 为前缀的属性,使用 HTMLElement.dataset。更多内容参见 MDN 文档
  2. 读写属性值的方法:setAttribute(...)getAttribute(...);请注意,随着代码增长,将属性名以字符串形式管理会变得困难。