D01 组件封装与状态管理

2026-10-31 17:000 阅读约 16 分钟JavaScript

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. 打开页面,点击「+1」,确认对应计数器与合计同步变化。
  2. 在控制台执行 a.destroy(),再次点击 A 的按钮,确认数值不再变化。
  3. 重新执行 a.mount(left),确认组件可以再次工作。
  4. 在 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 节讨论

参考文献

[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

0/2000

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