构建你的第一个UI代码模组:从写死HTML到可复用实例

构建你的第一个UI代码模组:从写死HTML到可复用实例 开头先说明白一件事这一篇真正要解决的不是“抄一段好看的UI代码”而是怎么把一个自己在页面里写死的界面抽成一段可多次实例化、可更新状态、可独立销毁的UI代码模组。标题是“P2——构建你的第一个UI代码模组上”所以内容定位也明确先不讲打包发布、框架接入、复杂动效重点是在浏览器里把一个最小模组跑通并理解它为什么这样设计。如果你之前写界面基本靠复制粘贴一个按钮改样式要翻好几个文件或者页面里有三四块结构相似的面板想统一管理却不知道怎么下手这一篇适合你。下面我按“先搞清楚问题再定范围再写最小实现最后做边界排查”的顺序拆开讲。1. UI代码模组不是越复杂越好先看它解决什么问题很多初学者听到“代码模组”就想到组件库、源码架构、大型前端工程其实没必要。UI代码模组的概念核心只有一句话把一段界面和它的状态、交互逻辑封装起来让它在不同位置可以被重复使用。1.1 从“复制粘贴开发”到“状态失控”的典型过程我见过很多界面开发的新手最开始都是从复制粘贴开始的。比如要做一个任务面板先复制第一个面板改标题改列表数据再复制第二个面板改另一个数据。界面刚做完时看起来没问题因为静态数据和写死的HTML是一样的。问题出在改动阶段。需求一变要调整面板内部的按钮文案、颜色、空状态显示规则。你发现所有复制出来的面板都要单独改一遍。漏改一个页面效果不统一。如果面板里还涉及输入框、选中状态、折叠展开这些交互那问题更明显同一个页面上三个同类面板状态彼此独立但它们的HTML结构几乎一样散落在不同位置根本没有办法统一处理。UI代码模组解决的就是这个痛点把“这段界面长什么样”和“这块数据怎么变化”封装在一起。页面需要几个面板就创建几个模组实例。改一处模组代码所有实例同步生效。状态更新只改属于自己的数据不污染其他实例。1.2 判断要不要自己构建UI模组的三个条件不是所有界面都需要做成模组。如果一个按钮只用一次一块提示文字永远不变化把它强行抽象成模组反而增加阅读成本。我建议按这三个条件判断复用次数同一结构在页面上出现两次以上或者明显预见到其他页面会用到。状态复杂度界面内部有展开折叠、选中切换、数据加载、错误提示等状态变化。改动频率后续很可能要调整样式、文案、交互逻辑或接入新的数据。三个条件满足一个就值得把界面往模组方向改。三个都不满足直接写普通HTML反而更省事。这一篇做的是最小设计用原生 JavaScript 写一个类内部维护属性和状态外部通过new创建实例。不依赖任何框架保证在任何前端项目里都能迁移过去。2. 动手前先把范围定清楚技术选型、环境与运行约束2.1 为什么这次用纯HTML/CSS/JavaScript做最小演示标题里没有指定具体框架所以这篇内容没有绑定任何第三方库。这样选择有几点考虑零基础读者可以只用一个浏览器和一段代码就跑通不需要安装依赖。不引入框架时UI模组的本质会更清楚实例、容器、状态、事件、清理。后续无论你转到 Vue、React 还是其他环境这套“模组”概念都能迁移。运行环境也很普通一个现代浏览器即可Chrome、Edge、Firefox 都可以。不需要打包工具不需要本地服务器直接打开 HTML 文件也能验证。代码采用 ES6 语法方便阅读也便于后续接入现代前端工程。如果你电脑上已经装了 Node.js也可以用http-server做一个本地静态服务器但这不是必须条件。2.2 模组需要具备的最低能力清单第一次构建UI代码模组不要贪多建议只做三件事能把一段HTML渲染到指定容器。能接收配置参数例如标题、列表数据、默认状态。能根据状态变化更新界面并提供销毁方法。分别对应模组的“初始化”“参数化”“生命周期”三个能力。我一开始做模组时犯过一个错总想一次性把主题、国际化、异步请求、动画全部规划好。结果代码写了非常长连最简单的运行都经常出现问题。后来把范围缩小到“先跑通再扩展”反而顺畅很多。这也是这一篇作为上篇只做最小模组的原因。2.3 不建议在第一版就做掉的内容请先忍住这几个想法不要引入复杂的事件总线先用实例方法通知外部。不要写通用的抽象基类你的第一个模组越具体越好。不要做配置深度合并先支持一层配置就足够。不要过早考虑服务端渲染或跨端先把浏览器里的运行跑稳。这些功能不是没用而是会干扰你对模组核心机制的理解。等最小版本跑通后再逐个补充才是更稳妥的做法。3. 核心设计骨架把状态、渲染和事件拆开UI代码模组看起来是“一段界面代码”但真正写起来时不能把HTML和逻辑全部混在一起。为了后续维护方便至少要拆成三个部分状态、渲染、事件通信。3.1 状态state界面数据只保留一份模组内部需要维护的数据比如标题、列表数据、加载中标记、选中项都应该集中放在一个状态对象里。用状态驱动界面而不是直接操作DOM来改变界面。比如要更新列表流程是状态todos更新。模组内部调用一次渲染。渲染结果写进容器。如果你直接修改某个DOM节点的文本虽然界面立刻变了但状态对象里仍是旧数据。下次其他操作触发整块刷新时刚刚手动修改的内容会被旧数据覆盖。这个现象非常容易踩到所以初期就养成“先改状态再触发渲染”的习惯很重要。3.2 渲染render每次只更新变化部分渲染是一个模组的核心方法。最简单的方式是在创建实例时生成整块DOM结构后续状态变化时只更新局部节点。很多初学者会把状态变化和整块HTML重新拼接画等号。为了省事直接在每次更新时执行container.innerHTML renderContent()这种做法短期内能跑数据量也不大但容易造成两个隐患输入框失焦如果模板里包含输入框重新innerHTML会重建DOM正在聚焦的元素丢失。事件重复绑定如果每次重建后还要重新绑定事件很容易出现重复监听。建议第一版就采用“初始生成结构一次更新时只改对应节点文本或属性”的方式。具体怎么让DOM结构可定位可以在内部元素上使用>const panel new TaskPanel({ container: #taskRoot, title: 今日任务, events: { onToggle: function(taskId, done) { console.log(任务状态变化, taskId, done); } } });外部不用关心模组内部怎么操作DOM只需要通过回调接收通知。模组内部也不直接操作外部数据只把事件结果抛出去。这样双方解耦后期改动成本低。4. 最小可运行示例从写死HTML到创建第一个UI代码模组下面进入实际操作阶段。我会用“任务面板”作为例子因为它结构足够简单一个标题、一个任务列表、一个添加按钮。所有代码都是原生JS不依赖库。4.1 准备页面容器HTML里先准备一个空容器。这个容器是模组的挂载点。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleUI代码模组演示/title /head body div idtaskRoot/div script src./task-panel.js/script script // 后续在这里创建实例 /script /body /html为什么要单独准备一个挂载容器而不是让模组自己创建整个页面结构因为模组应当是一个“嵌入单元”它不知道页面周围是什么内容。如果你把模组写死成body子节点一旦想在同一页面放两个模组第二个实例就会出现干扰。把挂载方式暴露给外部是模组复用的前提。4.2 用JavaScript实现TaskPanel模组下面是一个最小实现。代码故意写得直接方便理解每个阶段的职责。// task-panel.js class TaskPanel { /** * param {Object} options * param {string|HTMLElement} options.container 挂载容器 * param {string} options.title 面板标题 * param {Array} options.tasks 初始任务列表 * param {Object} options.events 事件回调 */ constructor(options) { this.container typeof options.container string ? document.querySelector(options.container) : options.container; this.title options.title || 默认标题; this.tasks options.tasks || []; this.events options.events || {}; // 保存关键DOM节点避免重复查询 this.root null; this.listEl null; this.inputEl null; this._init(); } _init() { this._renderContainer(); this._renderList(); this._bindEvents(); } _renderContainer() { const root document.createElement(div); root.className task-panel; const header document.createElement(h3); header.className task-panel__title; header.textContent this.title; const input document.createElement(input); input.className task-panel__input; input.type text; input.placeholder 输入任务内容; const addBtn document.createElement(button); addBtn.className task-panel__add-btn; addBtn.type button; addBtn.textContent 添加任务; const list document.createElement(ul); list.className task-panel__list; root.appendChild(header); root.appendChild(input); root.appendChild(addBtn); root.appendChild(list); this.root root; this.inputEl input; this.listEl list; this.container.appendChild(root); } // 只负责把任务列表渲染到 ul 中 _renderList() { this.listEl.innerHTML ; if (!this.tasks.length) { const emptyEl document.createElement(li); emptyEl.className task-panel__empty; emptyEl.textContent 暂无任务; this.listEl.appendChild(emptyEl); return; } this.tasks.forEach((task, index) { const li document.createElement(li); li.className task-panel__item; const span document.createElement(span); span.textContent task.text; const finishBtn document.createElement(button); finishBtn.type button; finishBtn.textContent task.done ? 恢复 : 完成; finishBtn.addEventListener(click, () this._toggleTask(index)); li.appendChild(span); li.appendChild(finishBtn); this.listEl.appendChild(li); }); } _bindEvents() { this.inputEl.addEventListener(keydown, (event) { if (event.key Enter) { this._addTask(); } }); // 添加按钮 const btn this.root.querySelector(.task-panel__add-btn); btn.addEventListener(click, () { this._addTask(); }); } _addTask() { const value this.inputEl.value.trim(); if (!value) return; this.tasks.push({ text: value, done: false }); this.inputEl.value ; this._renderList(); if (typeof this.events.onAdd function) { this.events.onAdd(value); } } _toggleTask(index) { if (!this.tasks[index]) return; this.tasks[index].done !this.tasks[index].done; this._renderList(); if (typeof this.events.onToggle function) { this.events.onToggle(this.tasks[index].text, this.tasks[index].done); } } // 对外暴露向模组追加一条外部任务 addTask(text) { if (!text) return; this.tasks.push({ text: text, done: false }); this._renderList(); } // 对外暴露获取当前任务数量 getTaskCount() { return this.tasks.length; } // 销毁模组清空容器并解绑引用 destroy() { if (this.root this.root.parentNode) { this.root.parentNode.removeChild(this.root); } this.root null; this.listEl null; this.inputEl null; this.tasks []; } }代码并不复杂。有几个细节说明一下_init()分成三步先建容器结构再渲染列表最后绑定事件。任务数据的唯一来源是this.tasks列表DOM只是它的展示。每次状态变化后调用_renderList()只重建列表部分不会把整个面板输入框一起重建。destroy()方法必须存在否则动态创建多个模组时旧实例会残留在页面里。4.3 验证生成实例的基本运行在HTML页面里创建两个实例测试互不干扰const panelA new TaskPanel({ container: #taskRoot, title: 工作任务, tasks: [{ text: 整理接口文档, done: false }], events: { onToggle: function(text, done) { console.log(面板A回调:, text, done); }, onAdd: function(text) { console.log(面板A新增:, text); } } }); const panelB new TaskPanel({ container: #taskRoot, title: 生活任务, tasks: [ { text: 预约体检, done: true }, { text: 买水果, done: false } ], events: { onToggle: function(text, done) { console.log(面板B回调:, text, done); } } });验证时看这几点页面上是否出现两个并列面板互不覆盖。面板A输入“写周报”点击添加面板B的任务列表不会变化。面板B点击任务状态按钮面板A不会感知变化。控制台能收到对应实例的回调日志。如果这几点都正常说明你的第一个UI代码模组已经具备实例隔离能力。这是模组最基本、也最关键的验证标准。这里提醒一下事件回调里出现“undefined”往往不是模组逻辑问题而是events配置对象里漏传了对应回调。代码里执行前都已经用typeof做了判断所以不会直接报错但你可能看不到预期日志。先检查函数名是否匹配。5. 参数化、边界与排查运行正常不等于代码健康5.1 高频错误和排查顺序即使上面的DEMO能跑通换到你自己的页面时也会遇到各种问题。以下是我实际使用中比较常见的情况现象优先检查常见原因处理方式页面没出现任何面板容器是否存在、脚本是否加载document.querySelector返回null确认容器ID和HTML结构创建两个实例只显示一个container是否相同且另一个无需覆盖两个模组写进了同一容器为每个实例准备独立容器点击添加按钮没有反应输入内容、事件绑定输入框内容前后空格先trim()再判断为空事件回调收不到options.events函数名用了普通对象但没传某回调在events默认对象里加上空函数兜底销毁后点页面其他按钮还能操作是否调用实例的destroy实例引用没被置空销毁后把变量设为null列表刷新后输入框值丢失更新逻辑是否重建整个根节点在根节点上执行innerHTML只更新列表不要重建外层结构排查要按顺序来不要一上来就怀疑模组核心代码先看浏览器控制台有没有报错。再确认容器和脚本加载顺序。脚本放在body内且在容器后面更稳妥。查看实例创建时传入的tasks数据有没有正确打印。再检查事件回调名称和类型。最后才看render逻辑是否覆盖了所有状态分支。5.2 状态更新后一定要进入“手动验证”而不是只看一次效果很多界面问题不是首次运行出错而是多次操作后状态不一致。建议你在写模组后保持一个固定的手动验证步骤添加三条任务。删除一条或者标记第一条为完成。再添加一条空内容。反复创建和销毁实例三次观察内存或控制台日志。如果这些操作每次结果都一致说明模组内部状态基本是可控的。这里不需要精确测内存占用只要确认控制台没有累积报错、页面没有重复残留节点就可以。5.3 把参数边界暴露清楚别让模组成为黑洞一个合格模组不只处理正常数据还要对异常输入有清晰反应。初期可以简单定一套规则标题为空时使用默认标题。任务列表为空时显示空状态。container找不到时直接抛错比默默失败好。constructor(options) { const container typeof options.container string ? document.querySelector(options.container) : options.container; if (!container) { throw new Error(TaskPanel: 找不到挂载容器); } }有人会觉得“抛错”显得不够友好但早期阶段抛错是好事。它能让你第一时间发现配置写错而不是页面空白卡住半天。真正运行时对用户隐藏错误那是后期封装UI组件库才需要考虑的事情。6. 从“能运行”到“可复用”的下一步规划完成上面的最小模组后你已经走过了最关键的阶段。接下来要不要扩展取决于你的实际场景。6.1 把默认配置和外部配置拆开当你的模组配置项越来越多建议单独建一个DEFAULT_OPTIONS对象const DEFAULT_OPTIONS { title: 任务面板, tasks: [], emptyText: 暂无任务, events: {} }; class TaskPanel { constructor(options) { const settings Object.assign({}, DEFAULT_OPTIONS, options); // ... } }这样外部传入少量配置即可覆盖默认值。不要把默认值散落在各个方法里否则后期维护时要逐个查。如果配置项里包含嵌套对象第一版先不要做深度合并。Object.assign只处理第一层足够大多数场景。真出现嵌套配置时再考虑深合并工具不迟。6.2 把数据层和渲染层进一步隔离当前示例里tasks直接保存在实例内部外部要读取只能通过getTaskCount()这类方法或者你自己主动暴露属性。到数据量变大、多个模组需要共享同一份数据时可以把数据操作单独抽成一个控制器。模组只负责展示数据操作由外部管理器统一处理。这个阶段并不急但写代码时要有意识不要在模组内部直接写死深层业务逻辑。比如从某个接口加载任务、计算任务积分、调用后端保存这些都应该留在外部。模组只接收“已经处理好的数据”再通过事件回调把用户操作通知出去。6.3 下一步再搭建生命周期、协议和批量使用能力这里说的生命周期不只是init和destroy还包括beforeMount挂载前适合校验数据。mounted挂载后适合做额外测量或请求。beforeDestroy销毁前适合解绑全局事件、清理定时器。如果你的模组里有用到setTimeout、addEventListener绑定到window、ResizeObserver之类的全局对象务必在destroy时全部清理干净。否则页面长时间运行后会出现界面已经销毁、但回调仍在执行的问题。这一篇作为“上”到这里刚好合适你已经理解了UI代码模组解决的问题也知道怎么用原生JavaScript做一个最小可用版本同时掌握了状态、渲染、事件三者的基本分离方式。后面如果想继续深入可以从两个方向延伸一是把TaskPanel样式抽成皮肤系统支持不同主题二是把模组改造成自定义事件协议让它可以被现有前端框架自动管理。等真正进入批量使用还需要考虑实例命名、状态同步和打包导出的问题。那些内容放到“下”篇再展开更合适。