题干与适用场景
你需要交付一个 <account-summary> 自定义元素,宿主页面可能由 React、Vue 或服务端模板渲染。元素要显示账号状态、响应 status 属性变化、派发可监听事件,并在从文档移除后清理订阅。团队还要求样式不会泄漏,键盘和辅助技术语义不能因封装而丢失。
默认假设:先实现 autonomous custom element,浏览器支持 Custom Elements 和 Shadow DOM;若必须扩展原生 button 等元素,再单独评估 customized built-in elements 的兼容性。题目考察的是生命周期边界和可验证的资源管理,不是某个框架的组件语法。
面试官考察什么
- 能否说明 constructor、connectedCallback、disconnectedCallback 和属性观察回调的职责边界。
- 能否处理元素重复连接、移动、升级和属性初始值,而不是假设每个回调只执行一次。
- 能否把 Shadow DOM 的样式封装与可访问性、事件穿透和宿主集成一起设计。
- 能否提出注册幂等、兼容性、清理和测试方案。
只背生命周期名称的回答不够。强回答会先定义资源所有权,再说明连接状态、属性状态和渲染状态如何互相影响。
回答前要澄清的问题
- 元素是否需要扩展原生语义元素?若需要,
is语法和浏览器支持会改变方案。 - 属性是字符串配置,还是要传入对象、回调等复杂值?复杂值应通过属性值、属性或方法明确区分。
- 元素移动到同一文档的其他位置时,状态是否必须保留?这决定是否采用
connectedMoveCallback或显式状态保护。 - 事件需要跨过 Shadow DOM 边界吗?要先约定
bubbles、composed和事件载荷,再决定事件名。
30 秒回答框架
“我先把元素分成定义、连接、属性同步、渲染和清理五个阶段。constructor 只做轻量字段初始化,不依赖子节点或外部文档;connectedCallback 建立订阅并触发渲染,且必须幂等;disconnectedCallback 释放监听器、定时器和观察者。只观察真正需要的属性,在 attributeChangedCallback 中归一化字符串并统一进入渲染入口。Shadow DOM 负责内部样式边界,但公开语义、焦点和事件契约仍由宿主可验证。注册前检查全局名称,最后用重复连接、移动、属性初始化、卸载和不支持浏览器的测试覆盖生命周期。”
分步骤深入分析
1. 先定义状态和资源所有权
把“已连接”“已订阅”“已渲染”分开记录,避免用一个布尔值掩盖状态。构造函数不应读取外部 DOM,也不应启动网络请求;浏览器可能先构造元素,稍后才把它连接到文档。
class AccountSummary extends HTMLElement {
static observedAttributes = ["status"];
#connected = false;
#unsubscribe = null;
#root;
constructor() {
super();
this.#root = this.attachShadow({ mode: "open" });
}
}2. 让 connectedCallback 可重复执行
元素可能被移除后再次插入,所以 connectedCallback 不能无条件重复注册监听器。先判断连接状态,再创建订阅;渲染函数应能安全地重复调用。初始化内部节点可以放在 constructor,但依赖外部环境的动作应放在连接阶段。
connectedCallback() {
if (this.#connected) return;
this.#connected = true;
this.#unsubscribe = accountStore.subscribe(() => this.#render());
this.#render();
}3. 在 disconnectedCallback 释放所有副作用
清理必须与建立副作用成对出现:事件监听器、setInterval、ResizeObserver、AbortController 和 store 订阅都要有明确所有者。清理后把引用置空,重新连接时才能建立新的一组资源。
disconnectedCallback() {
this.#unsubscribe?.();
this.#unsubscribe = null;
this.#connected = false;
}如果元素只是被移动,老式移动方式可能依次触发断开和连接回调。若状态保护很重要,应在支持的环境中评估 connectedMoveCallback,或让重连流程从属性和持久状态重新构造,而不要依赖回调顺序的偶然性。
4. 观察属性并区分属性值
observedAttributes 只列出真正需要同步的属性。attributeChangedCallback 可能在元素解析初始属性时调用,因此不能把“变化”理解成用户刚刚操作;应统一处理初始值和后续值,并避免在回调中再次写回同一属性造成循环。
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
if (name === "status") this.#renderStatus(newValue ?? "unknown");
}字符串属性适合声明式 HTML。数组、对象和回调应通过公开属性或方法传入,并在文档中写明是否需要在连接前设置;不要把 JSON 字符串当成无约束的复杂值协议。
5. 设计 Shadow DOM 和宿主契约
Shadow DOM 可以隔离内部 CSS 和 DOM 查询,但不会自动解决语义问题。组件仍要使用正确的角色、名称、焦点顺序和可见状态;需要让宿主参与布局时,应通过 :host、插槽或公开 CSS 自定义属性建立有限契约。
:host {
display: block;
}
:host([hidden]) {
display: none;
}内部事件若需要被宿主监听,应明确设置 bubbles 和 composed,并限制事件载荷。跨边界派发事件不等于把内部节点暴露给宿主。
6. 处理注册、升级和兼容性
同一全局注册表中的名称只能定义一次。共享库应选择命名空间前缀,并在定义前检查 customElements.get(name);不能用捕获异常掩盖两个版本争抢同名元素。若元素在定义前已经出现在 HTML 中,定义后会发生升级,代码要覆盖“先解析、后注册”的顺序。
const name = "account-summary";
if (!customElements.get(name)) {
customElements.define(name, AccountSummary);
}autonomous 元素通常比 customized built-in 更容易跨浏览器部署。MDN 记录了 Safari 对 customized built-in elements 的限制;若业务必须扩展原生元素,应先确认目标浏览器矩阵,并准备降级方案。
高质量示范回答
我会先写出资源和状态的所有权表:constructor 只建立 Shadow Root 和默认字段,connectedCallback 建立订阅并渲染,disconnectedCallback 释放订阅,attributeChangedCallback 只处理列入观察列表的声明式配置。每个阶段都设计为可重复或可安全跳过,避免把“只执行一次”当作生命周期保证。
我会选择 autonomous custom element,并用 customElements.get 防止重复注册。内部样式放在 Shadow DOM,但组件仍暴露清晰的可访问名称、焦点行为和事件契约;需要跨边界的事件设置 bubbles、composed 并限制 payload。属性只承载字符串和布尔等原始配置,复杂对象通过属性或方法传入。
验证会覆盖先解析后注册、属性初始值、重复连接、移动、卸载清理、事件跨边界、键盘操作和屏幕阅读器。若必须使用 customized built-in elements,我会先按目标浏览器验证,再决定是否改用 autonomous 元素或提供框架适配层。
常见错误与改进
- 错误表现 → 在 constructor 里读取子节点、启动请求 → 失败原因 → 构造时元素可能尚未连接,外部环境也未准备好 → 修正方法 → 把依赖文档和网络的动作移到 connectedCallback,并给请求绑定 AbortController。
- 错误表现 → 每次 connectedCallback 都新增监听器 → 失败原因 → 元素重连后会重复触发处理,造成内存泄漏和重复渲染 → 修正方法 → 记录订阅句柄,建立和清理成对出现并保持幂等。
- 错误表现 → 只观察属性却在回调中无条件 setAttribute → 失败原因 → 可能形成回调循环,初始解析也会触发回调 → 修正方法 → 先比较旧值,统一归一化输入并避免回写同一属性。
- 错误表现 → 用 Shadow DOM 解决全部可访问性 → 失败原因 → 封装只处理实现边界,不会自动提供名称、焦点和语义 → 修正方法 → 用键盘、辅助技术和宿主集成测试验证公开契约。
追问及应对
元素在定义前已经由 HTML 解析出来,如何避免闪烁?
把未定义元素视为可升级状态,提供基础的可读内容或加载态;注册完成后由浏览器升级实例。若必须等待定义再操作,宿主可以等待 customElements.whenDefined("account-summary"),但不能把整个页面阻塞在组件脚本上。
复杂对象应该放在 attribute 里吗?
默认不放。attribute 天然是字符串,适合可序列化、可观察的声明式配置;对象和回调通过公开属性或方法传入,并在连接前后都定义清楚行为。若确实要序列化,应规定版本、大小和错误处理,避免把 JSON 解析失败变成静默空状态。
Shadow DOM 内部按钮点击,宿主为什么收不到事件?
事件是否跨越 Shadow 边界取决于 composed,是否向上冒泡取决于 bubbles。我会从组件派发一个稳定的语义事件,并显式设置这两个选项;事件目标在边界外可能被重定向到 shadow host,所以宿主不应依赖内部节点结构。
如何证明组件没有在重连后泄漏?
反复插入和移除同一实例,记录订阅数量、事件触发次数和定时器句柄,结合浏览器内存快照确认旧节点可回收。测试还要覆盖属性初始回调、移动和未定义到升级的路径;只测首次渲染无法证明清理正确。