D01 组件封装与状态管理
D01 组件封装与状态管理
摘要:原生组件需要自己解决三件事:挂载到容器、状态变化后更新、销毁时清理副作用。可复用做法是定义一个基类,统一
mount/update/destroy三段生命周期,状态变更通过订阅通知渲染层。销毁阶段用AbortController一次性移除全部监听器与定时器。本文给出基类实现、计数器组件与两个组件组合的完整示例。
关键词:组件,生命周期,状态,订阅,销毁
所属:frontend/js / D-实战 | 上一篇:D00-总结 | 下一篇:D02-表单校验与异步提交
1 引言
把 A09 的写法复制到第二个页面时,问题立刻出现:每个页面各自维护一份渲染函数,切换页面后旧页面的定时器仍在执行,事件监听器仍持有已移除的节点。
本文把挂载、更新、销毁收敛到一个基类,让每个组件只写自己的模板与事件。范围见表 1-1。
| 项 | 本文覆盖 | 本文不覆盖 |
|---|---|---|
| 生命周期 | mount、update、destroy |
虚拟 DOM 与 diff 算法 |
| 状态 | 订阅、批量通知、派生值 | 全局状态容器(可用同一机制扩展) |
| 清理 | AbortController、定时器集合 |
框架生命周期钩子 |
2 预备知识
| 项 | 要求 | 核对方式 |
|---|---|---|
| 环境 | Chrome 130+,本地服务器 | 页面经 http 打开 |
| 前置 | A09、C04 | 会事件委托与取消信号 |
| 文件 | component/index.html、component/app.js |
编码 UTF-8 |
3 原理与实现
3.1 生命周期与状态订阅
组件从挂载到销毁只走一条路径,状态变化集中在一次通知里,如图 3-1 所示。
stateDiagram-v2
[*] --> 未挂载
未挂载 --> 已挂载: mount(container)
已挂载 --> 已挂载: setState → notify → update
已挂载 --> 已销毁: destroy()
已销毁 --> [*]
已销毁 --> 已挂载: 重新 mount
图 3-1 组件的三个状态与两条迁移路径
图上可见:
setState不直接操作 DOM,只触发一次update;destroy之后必须能重新mount,否则组件无法复用。
3.2 完整实现
<!-- component/index.html;运行环境:Chrome 130+ -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>组件封装</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./app.js"></script>
</body>
</html>
// component/app.js;运行环境:Chrome 130+
// 1 基类:统一生命周期与清理
class Component {
#container = null;
#controller = null;
#timers = new Set();
#listeners = new Set();
#state = {};
constructor(initialState = {}) {
this.#state = { ...initialState };
}
get state() {
return { ...this.#state }; // 外部只读快照
}
setState(patch) {
Object.assign(this.#state, patch);
if (this.#container) this.update();
}
subscribe(fn) {
this.#listeners.add(fn);
return () => this.#listeners.delete(fn);
}
mount(container) {
this.#container = typeof container === 'string' ? document.querySelector(container) : container;
this.#controller = new AbortController();
this.#container.replaceChildren(this.render());
this.onMount(); // 子类挂事件
for (const fn of this.#listeners) fn(this.state);
return this;
}
update() {
if (!this.#container) return;
this.#container.replaceChildren(this.render());
for (const fn of this.#listeners) fn(this.state);
}
destroy() {
this.#controller?.abort(); // 一次性移除全部监听器
for (const id of this.#timers) clearTimeout(id);
this.#timers.clear();
this.#container?.replaceChildren();
this.#container = null;
this.#controller = null;
this.onDestroy();
}
/** 子类重写:返回一个 DOM 节点 */
render() { return document.createDocumentFragment(); }
/** 子类重写:绑定事件,必须使用 this.signal */
onMount() {}
onDestroy() {}
get container() {
return this.#container;
}
get signal() {
return this.#controller.signal;
}
setTimeout(fn, ms) {
const id = setTimeout(fn, ms);
this.#timers.add(id);
return id;
}
}
// 2 计数器组件
class Counter extends Component {
constructor() {
super({ count: 0 });
}
render() {
const wrap = document.createElement('div');
wrap.className = 'counter';
const value = document.createElement('output');
value.textContent = String(this.state.count);
const inc = document.createElement('button');
inc.type = 'button';
inc.textContent = '+1';
inc.dataset.action = 'inc';
const reset = document.createElement('button');
reset.type = 'button';
reset.textContent = '重置';
reset.dataset.action = 'reset';
wrap.append(value, inc, reset);
return wrap;
}
onMount() {
// 事件委托绑定在容器上,signal 随 destroy 一次性解绑
this.container.addEventListener(
'click',
(event) => {
const btn = event.target.closest('button[data-action]');
if (!btn) return;
if (btn.dataset.action === 'inc') this.setState({ count: this.state.count + 1 });
if (btn.dataset.action === 'reset') this.setState({ count: 0 });
},
{ signal: this.signal },
);
}
}
// 3 组合:两个独立容器各挂一个实例,合计由订阅计算
const host = document.querySelector('#app');
const left = document.createElement('section');
const right = document.createElement('section');
host.replaceChildren(left, right);
const total = document.createElement('p');
total.id = 'total';
document.body.append(total);
const a = new Counter().mount(left);
const b = new Counter().mount(right);
const sync = () => {
total.textContent = `合计 ${a.state.count + b.state.count}`;
};
a.subscribe(sync);
b.subscribe(sync);
sync();
// 4 卸载:监听器与定时器一并清理
window.addEventListener('pagehide', () => {
a.destroy();
b.destroy();
});
3.3 实施清单
| 序号 | 改动 | 文件 | 说明 |
|---|---|---|---|
| 1 | 定义基类与生命周期 | component/app.js |
私有字段保证外部无法直接改状态 |
| 2 | 实现计数器组件 | component/app.js |
只写 render 与事件绑定 |
| 3 | 订阅派生子视图 | component/app.js |
合计值由两个组件状态计算 |
| 4 | 销毁时清理监听器与定时器 | component/app.js |
AbortController 一次性解绑 |
4 验证与讨论
- 打开页面,点击「+1」,确认对应计数器与合计同步变化。
- 在控制台执行
a.destroy(),再次点击A的按钮,确认数值不再变化。 - 重新执行
a.mount(left),确认组件可以再次工作。 - 在
pagehide时观察控制台,确认销毁后无残留定时器日志。
| 序号 | 验收项 | 判定标准 |
|---|---|---|
| 1 | 挂载 | 初始状态正确渲染 |
| 2 | 更新 | 状态变化后视图同步,无需手工改 DOM |
| 3 | 销毁 | 监听器失效,无内存残留 |
| 4 | 重新挂载 | 销毁后可再次挂载 |
| 5 | 组合 | 两个实例状态互不干扰 |
五项取舍见表 4-1。
| 决策点 | 采用方案 | 备选方案 | 不选的理由 |
|---|---|---|---|
| 状态存储 | 基类私有字段 + 快照读取 | 直接暴露可变对象 | 外部改状态会绕过渲染,视图失真 |
| 更新方式 | 整树替换 | 差量更新节点 | 实现成本高,先在组件粒度验证 |
| 清理机制 | AbortController |
逐条 removeEventListener |
逐条清理易漏,重构后更难追踪 |
| 事件绑定 | 委托到组件容器 | 每个按钮单独绑定 | 重渲染后单独绑定会丢失 |
| 订阅 | 简单回调集合 | 发布订阅总线 | 组件级需求用不上全局总线 |
讨论与局限。 整树替换会丢失输入焦点与滚动位置,表单类组件需要保留节点或单独处理焦点。另,订阅回调里若再调用 setState,会在一次通知中触发递归更新,需在回调内判重或改为异步通知。
5 常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 销毁后按钮仍有反应 | 监听器未绑定 signal |
所有 addEventListener 传 signal |
| 组件重复渲染两次 | setState 与订阅各触发一次 |
统一由 update 驱动通知 |
| 外部改状态后视图不更新 | 直接改了快照对象 | 只通过 setState 修改 |
| 定时器在切换页面后仍在跑 | 未用基类的 setTimeout 包装 |
统一登记后由 destroy 清理 |
| 组件数量多时点击变慢 | 委托层级过高,事件穿过整棵树 | 委托到最近的组件容器 |
6 结论
| 序号 | 结论 | 依据 |
|---|---|---|
| 1 | 生命周期三段足以覆盖组件全部状态 | 图 3-1 |
| 2 | 状态修改统一走 setState,视图由订阅驱动 |
3.2 节 |
| 3 | 监听器用 signal 绑定,销毁时一次清理 |
表 4-1 |
| 4 | 整树替换实现简单,代价是焦点丢失 | 4 节讨论 |
- 上一篇:D00-总结
- 下一篇:D02-表单校验与异步提交
- 返回:D00-总结 | 科目 README
参考文献
[1] MDN Web Docs · 使用自定义元素,https://developer.mozilla.org/zh-CN/docs/Web/API/Web_components/Using_custom_elements(访问于 2026-10-31)
[2] MDN Web Docs · AbortSignal,https://developer.mozilla.org/zh-CN/docs/Web/API/AbortSignal(访问于 2026-10-31)

评论 0
还没有评论,快来抢沙发吧~