ES6 class实战:用面向对象思维封装一个可维护的Tab组件
1. 从能用到好维护为什么tab栏是检验面向对象思维的试金石很多人学完ES6的class、extends、constructor之后第一反应是我会写面向对象了。但真到动手写一个带增删改查的tab栏代码往往又退回到面向过程的老路——一堆全局变量、散落的document.querySelector、事件回调里到处改状态。我自己早期也这样功能能跑但加一个删除后自动切换激活项的需求就得翻遍整个文件找哪里改了activeIndex。tab栏这个组件看着简单实际上麻雀虽小五脏俱全它同时涉及状态管理当前选中哪个、DOM渲染列表怎么画、事件处理点击、删除、编辑、数据与视图的同步增删改之后界面怎么更新。这四件事如果混在一起写代码超过一百行就开始失控。而用面向对象的思路去拆恰好能把它们分到不同的职责里各管一摊。这篇文章面向的是已经了解ES6基础语法、但还没真正把class用起来的同学。我会带着你从零设计一个Tab类把增删改查四个操作全部实现重点不是代码能跑而是每一步为什么这么设计。你会看到状态该放哪、DOM操作该封装到哪一层、事件怎么绑定才不会内存泄漏以及那些只有真正写过才踩得到的坑。读完你拿到的不只是一个tab栏而是一套可以套用到任何列表类组件的面向对象套路。需要先说明的是本文所有代码基于原生JavaScript和ES6 class语法不依赖任何框架。之所以不用框架是因为框架帮你把数据驱动视图这层屏蔽了而你恰恰需要亲手体会一次手动同步的痛才能真正理解面向对象封装的价值。等你以后用React或Vue回头看这段经历会格外清晰。2. 动手之前先想清楚Tab组件的状态、行为与边界2.1 一个tab栏到底有哪些状态写面向对象代码第一步永远是问自己这个对象需要记住什么状态就是那些会随时间变化、并且变化后需要反映到界面上的数据。对tab栏来说最核心的状态其实只有两个tabs数据列表每一项包含标题、内容可能还有唯一id。这是数据源增删改查都是围绕它进行的。当前激活项的标识可以用索引index也可以用id。用索引简单但删除中间项时索引会错位用id更稳但查找稍麻烦。我后面会讲怎么选。除了这两个还有一些派生状态和配置项容易被忽略。比如容器元素挂载点、激活时的类名、是否允许删除等。这些不算严格意义上的状态更像是配置但它们决定了组件的行为边界。把它们在constructor里一次性接收比散落在各处硬编码要清晰得多。我见过不少初学者把所有东西都塞进全局变量结果页面上有两个tab栏就冲突了。面向对象的第一层价值就在这里每个实例拥有自己独立的状态互不干扰。你new Tab(...)两次就是两个独立的组件。2.2 把操作翻译成方法状态确定之后接下来是把用户能做的操作翻译成对象的方法。tab栏的增删改查对应四个动作用户操作对应方法核心职责点击某个tabswitchTo(id)更新激活标识刷新视图新增一个tabaddTab(data)往数据列表push重新渲染删除某个tabremoveTab(id)从数据列表移除处理激活项归属修改某个tabupdateTab(id, data)找到并更新数据刷新视图这里有个关键设计决策方法只改数据不直接改DOM。所有DOM更新统一交给一个render()方法。这就是所谓的单向数据流雏形——数据是唯一真相来源视图是数据的投影。为什么这么做因为如果addTab里手动appendChildremoveTab里手动removeChildupdateTab里手动改textContent那么一旦需求变化比如要加动画、要排序你得改三个地方。而统一render()的话只改一处。代价是每次操作都全量重绘性能上对几十个tab来说完全无所谓。等数据量大了再考虑局部更新但那是优化阶段的事不要过早纠结。2.3 事件绑定为什么推荐事件委托还有一个绕不开的问题tab项是动态生成的新增的项怎么绑定点击事件新手常见做法是在render()里给每个新生成的元素addEventListener。这能跑但有两个隐患一是每次重绘都要重新绑定二是删除元素时如果忘了移除监听理论上会有内存泄漏风险虽然现代浏览器对已移除DOM的监听回收得不错。更优雅的方案是事件委托把监听绑在父容器上利用事件冒泡通过event.target判断点的是哪个tab。这样无论tab怎么增删监听始终只有父容器上那一个。这也是我实际项目里几乎必用的手法。后面实现部分我会给出完整代码。3. 从零搭建Tab类构造、渲染与激活逻辑3.1 构造函数该接收什么先看构造函数的设计。我倾向于用一个配置对象作为参数而不是一长串位置参数因为位置参数一多就容易记错顺序配置对象则一目了然还方便以后扩展。class Tab { constructor(options) { // 挂载容器可以是选择器字符串或DOM元素 this.container typeof options.el string ? document.querySelector(options.el) : options.el; // 数据源每项形如 { id, title, content } this.tabs options.tabs || []; // 当前激活项的id默认取第一项 this.activeId this.tabs.length ? this.tabs[0].id : null; // 可配置的类名避免和项目里其他样式冲突 this.activeClass options.activeClass || tab-active; // 初始化 this.init(); } }这里有几个细节值得说。第一el同时支持选择器字符串和DOM元素这样调用方更灵活。第二activeId默认取第一项的id而不是硬编码0因为如果传进来的tabs是空数组取tabs[0].id会报错所以要先判断长度。第三类名做成可配置是因为实际项目里你很可能已经有自己的CSS命名规范硬编码active容易撞车。init()方法负责首次渲染和事件绑定把它单独抽出来而不是全塞进构造函数是为了逻辑清晰——构造函数只做赋值init做启动。3.2 render方法数据如何变成DOMrender()是整个组件的核心它负责把this.tabs和this.activeId翻译成界面。我习惯用模板字符串拼接HTML然后一次性赋值给容器的innerHTML。为什么用innerHTML而不是createElement逐个创建因为代码量少、可读性高对tab这种结构固定的组件来说性能差异可以忽略。render() { const navHtml this.tabs.map(tab { const active tab.id this.activeId ? ${this.activeClass} : ; return li classtab-item${active}>switchTo(id) { if (id this.activeId) return; this.activeId id; this.render(); }if (id this.activeId) return;这行看着不起眼但能避免大量无意义的DOM重建。用户手快连点同一个tab时这个判断能省下不少开销。这是典型的防御性编程——不是功能需要而是体验和性能需要。3.4 事件委托的完整绑定事件绑定放在init里只绑一次绑在容器上init() { this.render(); this.bindEvents(); } bindEvents() { this.container.addEventListener(click, (e) { const li e.target.closest(.tab-item); if (!li) return; const id Number(li.dataset.id); // 如果点的是关闭按钮 if (e.target.dataset.action remove) { this.removeTab(id); return; } // 否则视为切换 this.switchTo(id); }); }closest(.tab-item)这个方法很关键它能从被点击的元素向上找最近的匹配祖先。因为用户可能点的是span或i而不是li本身用closest就不用一层层判断target是谁。dataset.id取出来是字符串用Number()转成数字和我们的数据id类型保持一致——类型不一致是新手最容易踩的坑之一1 1永远是false会导致切换失效。4. 增删改三个操作数据一致性与激活项归属4.1 addTab新增时要不要自动激活新增tab的逻辑核心是往this.tabs里push一条数据然后重绘。但有个产品层面的决策新增之后要不要自动切换到新tab我的经验是要因为用户刚创建完大概率想立刻看到它。所以addTab里顺手把activeId也更新了。addTab(data) { const newTab { id: Date.now(), // 用时间戳做临时唯一id title: data.title || 新标签, content: data.content || }; this.tabs.push(newTab); this.activeId newTab.id; // 新增后自动激活 this.render(); return newTab.id; }用Date.now()做id是个取巧但实用的办法毫秒级时间戳在单用户操作下几乎不会重复。如果对唯一性要求更高可以用一个自增计数器this._seed或者引入crypto.randomUUID()。返回新id是为了让调用方能拿到方便后续操作。4.2 removeTab删除激活项时的接盘逻辑删除是四个操作里最烧脑的因为它牵扯到一个问题如果删掉的正好是当前激活项那接下来该激活谁这个逻辑不处理界面就会出现没有任何tab高亮的尴尬状态。我的处理策略是删掉激活项后优先激活它右边那一项如果没有右边删的是最后一个就激活左边那一项如果全删光了activeId置为null。removeTab(id) { const index this.tabs.findIndex(t t.id id); if (index -1) return; this.tabs.splice(index, 1); // 如果删的不是激活项激活项不变 if (id ! this.activeId) { this.render(); return; } // 删的是激活项需要重新决定激活谁 if (this.tabs.length 0) { this.activeId null; } else { // 优先取原位置上的项即原来的右边一项越界则取最后一项 const next this.tabs[index] || this.tabs[this.tabs.length - 1]; this.activeId next.id; } this.render(); }这段代码里this.tabs[index] || this.tabs[this.tabs.length - 1]是个很精炼的写法。splice删除后原来index位置上的元素变成了右边那一项如果index已经越界说明删的是最后一个就取新的最后一项。一行代码覆盖了两种边界情况。注意这里用findIndex而不是indexOf因为tabs里存的是对象indexOf比较的是引用除非你传的就是同一个对象引用否则找不到。这是对象数组操作的常见误区。4.3 updateTab改数据之后别忘了重绘修改操作相对简单找到对应项合并新数据重绘。但要注意只更新传入的字段而不是整个替换否则调用方只想改标题却把内容清空了。updateTab(id, data) { const tab this.tabs.find(t t.id id); if (!tab) return false; // 只覆盖传入的字段 Object.assign(tab, data); this.render(); return true; }Object.assign(tab, data)会把data里的字段合并进tab没传的字段保持原样。返回布尔值是为了让调用方知道操作是否成功——找不到对应id时返回false方便上层做提示。4.4 数据与视图同步的唯一真相原则把四个方法放在一起看你会发现一个共同模式先改this.tabs或this.activeId最后统一调this.render()。没有任何一个方法直接操作DOM。这就是数据驱动视图的最小实现。为什么强调这一点因为一旦你允许某个方法顺手改一下DOM就会埋下隐患。比如removeTab里如果手动li.remove()然后render()又重建一遍就会出现重复操作甚至状态错乱。坚持数据是唯一真相代码的可预测性会高很多。这也是从面向过程迈向面向对象最关键的一步思维转变。5. 那些只有真正写过才会遇到的坑5.1 id类型不一致导致的点击没反应这个坑我在前面提过但值得单独拎出来讲因为它太常见了。dataset.id读出来永远是字符串而你的数据id可能是数字。3 3是false于是switchTo里find找不到界面毫无反应控制台也不报错新手能盯着看半小时。解决办法有两个要么统一用字符串id要么读取时Number()转换。我倾向于后者因为数字id在排序、比较时更直观。关键是在数据进入组件时就统一类型而不是在每个用到的地方临时转换。5.2 事件委托里this指向丢失如果你用普通函数而不是箭头函数写事件回调this会指向触发事件的DOM元素而不是Tab实例于是this.switchTo就报不是一个函数。这是ES6 class里非常经典的坑。// 错误写法this指向container或触发元素 this.container.addEventListener(click, function(e) { this.switchTo(...); // 报错 }); // 正确写法箭头函数继承外层的this this.container.addEventListener(click, (e) { this.switchTo(...); // this是Tab实例 });箭头函数没有自己的this会沿着作用域链找到bindEvents方法里的this也就是实例。如果非要用普通函数就得在构造函数里this.bindEvents this.bindEvents.bind(this)麻烦且容易忘。所以事件回调我一律用箭头函数。5.3 重复绑定事件导致的操作翻倍如果你的render()里不小心也调用了bindEvents()那么每次重绘都会往容器上再绑一个监听。点一次tab回调执行两次、三次……表现出来就是切换闪一下又跳回去或者删除一次删俩。根源在于事件绑定应该只做一次和渲染解耦。我的做法是把bindEvents放在init里render只负责画界面绝不碰事件。这个职责划分要刻在脑子里。5.4 空数据状态下的边界处理当所有tab都被删光activeId变成nullrender里this.tabs.find(...)返回undefined如果直接取.content就会报错。所以内容区渲染前必须判断const activeTab this.tabs.find(t t.id this.activeId); const contentHtml activeTab ? activeTab.content : ;空状态是测试时最容易漏掉的场景。我养成的习惯是每写完一个增删方法立刻手动把所有数据删光看看界面会不会崩。这个删到空的测试动作帮我提前发现过无数次空指针问题。6. 把组件用起来一个完整的调用示例光有类不够得看看实际怎么用。假设页面上有个div idapp/div初始化并绑定几个按钮const tab new Tab({ el: #app, tabs: [ { id: 1, title: 首页, content: 这是首页内容 }, { id: 2, title: 消息, content: 这是消息内容 }, { id: 3, title: 设置, content: 这是设置内容 } ], activeClass: is-active }); // 新增 document.querySelector(#addBtn).addEventListener(click, () { tab.addTab({ title: 新标签, content: 新内容 }); }); // 修改当前激活项 document.querySelector(#editBtn).addEventListener(click, () { if (tab.activeId null) return; tab.updateTab(tab.activeId, { title: 已修改 }); }); // 删除当前激活项 document.querySelector(#delBtn).addEventListener(click, () { if (tab.activeId null) return; tab.removeTab(tab.activeId); });调用方完全不需要知道内部怎么渲染、怎么找元素只需要调方法、传数据。这就是封装带来的好处——使用者和实现者之间只通过方法签名沟通。以后你想把innerHTML换成虚拟DOM或者加个过渡动画只要方法签名不变调用方一行都不用改。配套的CSS这里给个最简版本重点是激活态的样式.tab-nav { display: flex; list-style: none; padding: 0; border-bottom: 1px solid #ddd; } .tab-item { padding: 8px 16px; cursor: pointer; position: relative; } .tab-item.is-active { color: #1677ff; border-bottom: 2px solid #1677ff; } .tab-close { margin-left: 8px; font-style: normal; } .tab-content { padding: 16px; }7. 从tab栏延伸出去这套思路还能怎么用写完这个组件你会发现它的骨架其实适用于一大类列表详情的界面导航菜单、折叠面板、步骤条、甚至简易的路由切换。核心模式都是维护一个数据数组 一个当前选中标识 一个统一的render。如果你想继续深挖有几个方向可以练手。第一把render里的innerHTML全量替换改成局部更新只改动变化的那一项体会一下性能优化的思路。第二给addTab和removeTab加上过渡动画这需要你在数据变化和DOM变化之间插入一个动画中的中间状态是理解状态机的好练习。第三把事件系统抽出来做成发布订阅模式让组件在状态变化时对外派发事件这样外部就能监听tab切换了tab被删了。我个人在实际项目里最常用到的其实是这套数据是唯一真相、方法只改数据、render统一同步的三段式。它不依赖任何框架却能在你后来接触React、Vue时提供极好的直觉——你会突然明白为什么它们都强调不要直接操作DOM。所以别小看这个tab栏把它写透比囫囵吞枣看十篇框架教程都管用。