上一篇讲了
ReactiveElement的整体结构,这篇我们钻进它最精妙的部分——更新调度。v10 怎么做到「同一个 tick 里改十个属性,只渲染一次」?答案是 Promise 门控 + async/await 产生的微任务批处理。这套机制看着简单,但有几个细节特别讲究。
先说问题:为什么需要批处理
Web Components 有个性能陷阱:如果你在同一个同步代码块里改了十个响应式属性,每个 setter 都触发一次更新(重渲染),就会渲染十次——极其浪费。
正确做法是「批处理」:把这些变更收集起来,等当前同步代码跑完(微任务边界),一次性渲染。Lit 做到了,v10 的 element 也做到了,但实现方式有个细节差异——08 篇提过,它不靠 queueMicrotask,而是靠 async/await。
构造函数:构建 Promise 门控
一切从构造函数开始。reactive-element.ts:95-119:
// L98-103 — 构建门控
this.#updatePromise = new Promise<boolean>(
(res) => (this.enableUpdating = res as (requestedUpdate: boolean) => void)
);
这一段我反复读了三遍才理解。它干了什么?
构造函数创建一个未 resolve 的 Promise,把这个 Promise 的 resolve 函数赋值给实例属性 this.enableUpdating。注意这覆盖了 L126 行原型上的 no-op 方法 protected enableUpdating(_requestedUpdate: boolean): void {}。
所以构造之后,this.enableUpdating 就是那个门控 Promise 的 resolve 函数。这个 Promise 一开始是 pending 的——像一扇关着的门。
注释(L98-100)写得很明确:「初始化为未解决的 Promise,以确保元素在首次更新前已连接。」
为什么要这扇门
接着构造函数还做了两件事:
实例属性升级(L105-114):
const { props } = resolve(this.constructor);
for (const name of props.keys()) {
if (Object.hasOwn(this, name)) { // 类字段在实例上设了值
(this.#instanceProperties ??= new Map()).set(name, this[name]); // 捕获
delete this[name]; // 删除实例值,让原型 getter/setter 重新可达
}
}
类字段初始化器(或连接前赋值)会把值放在实例上,这会遮蔽原型访问器。构造函数把这些自身值捕获到 #instanceProperties,然后 delete 掉实例值,让原型 getter/setter 重新可达。捕获的值稍后在 performUpdate 里恢复。
入队首次更新(L116-118):this.requestUpdate();。
但这一步什么都跑不了——因为门控 Promise 还没 resolve。
connectedCallback:开门
reactive-element.ts:152-158:
connectedCallback() {
this.enableUpdating(true); // ← 开门!resolve 那个门控 Promise
for (const c of this.#controllers) c.hostConnected?.();
}
元素挂到 DOM 时,connectedCallback 调用 enableUpdating(true)——这 resolve 了构造时创建的门控 Promise。于是之前卡在 await this.#updatePromise 的首次更新得以继续。
这就是「保证元素挂到 DOM 后才渲染」的机制。第一次 update 必须等到 connectedCallback。在它之前,所有 requestUpdate 都被门控拦住,不会真跑。
requestUpdate:去重 + 入队
reactive-element.ts:202-209:
requestUpdate(name?: string, oldValue?: unknown): void {
if (name !== undefined) {
this.#changedProperties.set(name, oldValue); // L204 — 记录旧值
}
if (this.isUpdatePending) return; // L207 — 去重守卫
this.#updatePromise = this.#enqueueUpdate(); // L208
}
这是批处理的核心:
- 如果传了属性名,记到
#changedProperties(L204),供willUpdate/update里查changed.has(...)。 - L207 去重守卫:已经有更新挂起了,就直接 return。这是批处理的关键——同一个 tick 里后续的属性变更只记 changedProperties,不再调度。
- 只有第一次变更(没有挂起更新时),才
#updatePromise = this.#enqueueUpdate()(L208)。
所以你连改十个属性:第一次进 L208 入队,后九次都走 L207 直接 return。最后只渲染一次。
#enqueueUpdate:串行化 + 门控(最精妙的部分)
reactive-element.ts:216-238:
async #enqueueUpdate(): Promise<boolean> {
this.isUpdatePending = true; // L217
try {
await this.#updatePromise; // L222
} catch (e) {
Promise.reject(e); // L226
}
const result = this.scheduleUpdate(); // L229
if (result != null) { // L233
await result; // L234
}
return !this.isUpdatePending; // L237
}
L222 的 await this.#updatePromise 一身二职:既是门控,也是串行化器。我花了点时间才想明白为什么。
关键看 requestUpdate 的 L208 是怎么求值的:this.#updatePromise = this.#enqueueUpdate()。右侧 this.#enqueueUpdate() 会先被求值——它的函数体在第一个 await(L222)之前是同步运行的。所以当 L222 读 this.#updatePromise 时,它读到的是旧值(L208 的重新赋值还没提交)。
两种情况:
- 构造时调的
requestUpdate:旧值是 L98-103 的未 resolve 门控 Promise。所以#enqueueUpdate卡在 L222,直到connectedCallback的enableUpdating(true)resolve 它。 - 后续更新:旧值是上一次更新的
#enqueueUpdatePromise。所以每次更新都 await 上一次更新——实现串行化。
这一行同时实现了「首次更新等连接」和「更新之间串行化」。非常优雅。
L237 的返回值:驱动 updateComplete
return !this.isUpdatePending(L237)决定了 updateComplete 的布尔值。
正常情况:performUpdate 把 isUpdatePending 清零(L294)后,没人再请求更新,L237 返回 !false === true。
但如果 updated()/firstUpdated() 里又改了属性呢?requestUpdate 会再次把 isUpdatePending 翻回 true 并重新赋值 #updatePromise。于是 L237 返回 !true === false——这正是 updateComplete 为 false 的文档化行为(L350-354):表示「又触发了新更新,你得再等」。
performUpdate:真正干活
reactive-element.ts:268-306:
performUpdate() {
if (!this.isUpdatePending) return; // L272
if (!this.hasUpdated && this.#instanceProperties) { // L275-280 — 恢复实例属性
for (const [name, value] of this.#instanceProperties) {
this[name] = value; // 走 setter → 记到 changedProperties
}
this.#instanceProperties = undefined;
}
const changed = this.#changedProperties; // L282
this.willUpdate(changed); // L282-284
for (const c of this.#controllers) c.hostUpdate?.(); // L286-288 — 广播 controller
this.update(changed); // L290 — 子类在这里渲染
this.#changedProperties = new Map(); // L293
this.isUpdatePending = false; // L294
for (const c of this.#controllers) c.hostUpdated?.(); // L296-298
if (!this.hasUpdated) { // L300
this.hasUpdated = true; // L300 — 先置 true
this.firstUpdated(changed); // L303 — 再调
}
this.updated(changed); // L305
}
几个我读时留意的点:
- L275-280 实例属性恢复:把构造时捕获的
#instanceProperties通过 setter 赋值回去。因为旧值是 undefined,所以触发requestUpdate,但isUpdatePending已经是 true,所以只记 changedProperties 不重新入队。只在首次更新做(!this.hasUpdated)。 - L293-294 清理:重置 changedProperties 和 isUpdatePending。在这之后调的 requestUpdate 会开始新的入队。
- L300 hasUpdated 顺序:
hasUpdated在firstUpdated之前置 true。符合 Lit 语义。
scheduleUpdate:可覆盖的调度点
reactive-element.ts:256-258:
protected scheduleUpdate(): void | Promise<unknown> {
this.performUpdate();
}
这是个可覆盖的调度点。JSDoc(L240-255)甚至给了示例:子类可以返回 requestAnimationFrame 的 Promise 来推迟渲染到下一帧。如果子类返回 Promise,#enqueueUpdate 的 L233-234 会 await 它。
这是个很好的扩展点——比如你想要「所有更新都 rAF 对齐」,覆盖 scheduleUpdate 就行。
updateComplete:等更新完成的句柄
reactive-element.ts:355-357:
get updateComplete(): Promise<boolean> {
return this.#updatePromise;
}
返回当前的 #updatePromise。因为它每次 requestUpdate(L208)都被重新赋值,所以始终反映最新更新的状态。controller 和外部代码 await element.updateComplete 就能等到当前更新批次完成。
串个完整流程
把全流程串一遍,假设你连改三个属性 a、b、c:
this.a = 1→ setterrequestUpdate('a', undefined)→ L207 没挂起更新 → L208#updatePromise = #enqueueUpdate()→#enqueueUpdate同步跑到 L222await this.#updatePromise(旧门控)挂起。this.b = 2→ setterrequestUpdate('b', undefined)→ L204 记 changedProperties → L207isUpdatePending已 true → 直接 return。this.c = 3→ 同上,直接 return。changedProperties 现在有 a/b/c。- 当前同步代码跑完,微任务边界。门控 Promise 早已被 connectedCallback resolve,
#enqueueUpdate的 L222 继续 → L229scheduleUpdate→performUpdate→ 一次性处理 a/b/c 三个变更。 - 渲染一次。
如果 updateComplete 在这期间被 await,它会在步骤 4 完成后 resolve。
为什么不用 queueMicrotask
回到 08 篇提的那个点。Lit 实际上也是用类似机制(async performUpdate + microtask),不是显式 queueMicrotask。v10 的 element 文档(README)说「queueMicrotask 批处理」是不准确的——真正的机制是 async/await 在 L222 产生的微任务挂起。
区别在哪?queueMicrotask(fn) 是显式把 fn 排进微任务队列。而 async function 遇到 await 会立即返回一个 Promise,await 后续代码作为该 Promise 的回调执行——回调本身就是微任务。两者都利用微任务队列,但 async/await 是隐式的、和 Promise 集成更自然(门控、串行化、updateComplete 全靠它)。
小结
这套调度机制我读下来,觉得最值得学的是:
- 一个 Promise 同时做门控和串行化——L222 的
await this.#updatePromise一身二职,代码极简但语义丰富。 - 门控保证「挂载后才渲染」——构造时建未 resolve Promise,connectedCallback resolve 它。
- 实例属性升级——捕获类字段、删实例值、首次 performUpdate 恢复。
isUpdatePending去重——批处理的核心,同一 tick 多次变更只入队一次。scheduleUpdate可覆盖——子类能改调度策略(如 rAF 对齐)。
下一篇我们看 DestroyMixin——它在 ReactiveElement 之上加了「永久销毁」语义,那个双重 rAF 的设计是防误销毁的关键。
8301



被折叠的 条评论
为什么被折叠?



