拒绝“面条代码”:手撸B站同款“就地编辑”组件

47 阅读5分钟

前言

还记得B站个人主页那个“超级酷炫狂霸拽”的个性签名框吗?

image.png

平时它静静地躺在那儿,是一行文本;当你点击它,或者把鼠标放上去时,它摇身一变,成了一个可编辑的输入框。修改完一敲“回车”,由于 AJAX(fetch)技术的加持,页面不刷新就完成了更新。

image.png

image.png

这种体验叫做 就地编辑(Edit In Place)

今天我们不谈那些花里胡哨的框架,就用原生 JavaScript 和最经典的 OOP(面向对象编程)  思想,复刻这个功能。我们要做的不仅仅是写出功能,而是要写出优雅、可复用、哪怕你是刚入行的菜鸟也能看懂的模块化代码

一、 为什么我们需要 OOP?

在很多初学者的代码里,逻辑通常是这样的:

“获取DOM -> 监听点击 -> 把span变input -> 监听保存 -> 发请求 -> 变回span”

如果页面上只有一个签名框,这没问题。但如果你的页面上有标题、签名、备注、甚至评论都需要“就地编辑”呢?
复制粘贴十遍代码?那你就写出了传说中的面条代码(Spaghetti Code)——逻辑纠缠不清,改一个bug坏十个地方。

OOP(面向对象)  的出现就是为了拯救我们。我们将“就地编辑”这个功能封装成一个构造函数
你可以把它想象成一个模具。有了这个模具,你想做几个签名框,也就是一行 new EditInPlace() 的事儿。

二、 庖丁解牛:构建 EditInPlace 构造函数

我们要写的这个构造函数,就像一个精密的仪器,分为三个部分:属性(零件)、结构(组装)、逻辑(功能)

1. 属性:先把“家底”盘清楚

首先,我们需要一个构造函数。this 指向的就是未来被 new 出来的那个实例对象。在这里,我们把所有需要用到的 DOM 节点和数据都挂载到 this 上。

/**
 * @func EditInPlace 就地编辑构造函数
 * @param {string} id - 组件的唯一标识
 * @param {string} value - 初始文本值
 * @param {element} parentElement - 父级挂载点(它要显示在哪里)
 */
function EditInPlace(id, value, parentElement) {
  // 1. 核心数据
  this.id = id;
  // 经典默认文案,防止 value 为空时界面塌陷
  this.value = value || '这个家伙很懒,什么都没有留下'; 
  this.parentElement = parentElement;

  // 2. DOM 引用占位(先此时为空,等createElement来填充)
  this.containerElement = null; // 最外层容器
  this.fieldElement = null;     // 输入框 (input)
  this.staticElement = null;    // 文本显示 (span)
  this.saveButton = null;       // 保存按钮
  this.cancelButton = null;     // 取消按钮

  // 3. 启动!
  this.createElement(); // 创建节点
  this.attachEvent();   // 绑定事件
}

2. 结构:DOM 操作的封装

以前我们习惯直接在 HTML 里写好结构。但在封装组件时,为了让使用者(可能是你的同事,也可能是未来的你自己)更省心,我们选择用 JS 动态创建 DOM

使用者只需要提供一个挂载点(比如 #app),剩下的 HTML 结构,我们自己生成。这叫做封装细节

EditInPlace.prototype.createElement = function () {
  // 创建容器 div
  this.containerElement = document.createElement('div');
  this.containerElement.id = this.id;
  
  // A面:静态文本状态 (span)
  this.staticElement = document.createElement('span');
  this.staticElement.innerHTML = this.value;
  this.containerElement.appendChild(this.staticElement);

  // B面:编辑状态 (input + 按钮)
  // 输入框
  this.fieldElement = document.createElement('input');
  this.fieldElement.type = 'text';
  this.fieldElement.value = this.value;
  this.containerElement.appendChild(this.fieldElement);

  // 保存按钮
  this.saveButton = document.createElement('input');
  this.saveButton.type = 'button';
  this.saveButton.value = '保存';
  this.containerElement.appendChild(this.saveButton);

  // 取消按钮
  this.cancelButton = document.createElement('input');
  this.cancelButton.type = 'button';
  this.cancelButton.value = '取消';
  this.containerElement.appendChild(this.cancelButton);

  // 最后,把整个做好的组件挂载到页面上
  this.parentElement.appendChild(this.containerElement);
  
  // 初始化视图状态:默认只显示文本
  this.convertToText(); 
};

3. 逻辑:状态切换的艺术

这个组件的核心灵魂在于状态切换。它就像变相怪杰,有两幅面孔:

  1. Text 模式:显示 span,隐藏 input 和按钮。
  2. Field 模式:隐藏 span,显示 input 和按钮。

我们把这两个逻辑封装成两个方法,拒绝直接操作 style 带来的混乱

EditInPlace.prototype.convertToText = function () {
  this.fieldElement.style.display = 'none';
  this.saveButton.style.display = 'none';
  this.cancelButton.style.display = 'none';
  // 只有文本侠登场
  this.staticElement.style.display = 'inline';
};

EditInPlace.prototype.convertToField = function () {
  this.staticElement.style.display = 'none';
  // 这一步很关键:点击编辑时,input里的值必须是最新的
  this.fieldElement.value = this.value; 
  this.fieldElement.style.display = 'inline';
  this.saveButton.style.display = 'inline';
  this.cancelButton.style.display = 'inline';
};

4. 交互:让组件“活”起来

最后一步,绑定事件。
注意这里用了箭头函数 () => {} 或者需要在外部保存 var that = this,这是为了保证在点击事件的回调函数中,this 依然指向我们的 EditInPlace 实例,而不是被点击的那个 DOM 按钮。

EditInPlace.prototype.attachEvent = function () {
  // 1. 点击文本 -> 变身输入框
  this.staticElement.addEventListener('click', () => {
    this.convertToField();
  });

  // 2. 点击保存 -> 更新数据,切回文本
  this.saveButton.addEventListener('click', () => {
    this.save();
  });

  // 3. 点击取消 -> 啥也不改,直接切回文本
  this.cancelButton.addEventListener('click', () => {
    this.cancel();
  });
};

EditInPlace.prototype.save = function () {
  var value = this.fieldElement.value;
  // 【模拟后端交互】
  // 在真实项目中,这里会是一个 fetch/axios 请求
  // fetch('/api/signature', { method: 'POST', body: ... })
  
  // 更新内部数据
  this.value = value;
  // 更新界面显示
  this.staticElement.innerHTML = value;
  // 变身回去
  this.convertToText();
};

EditInPlace.prototype.cancel = function () {
  this.convertToText();
};

三、 为什么说这是“高质量”的代码?

微信图片_20251207162456_36_93.jpg

1. 模块化与独立性

看我们的 HTML 使用端:

<body>
  <div id="app"></div>
  <script src="./edit_in_place.js"></script>
  <script>
    // 使用者只需要这一行!
    new EditInPlace("slogan", "今日风儿甚是喧嚣", document.getElementById("app"));
  </script>
</body>

构造函数的编写者(造轮子的人)和使用者(用轮子的人)实现了完美分离。

  • 使用者不需要知道内部怎么创建 input,怎么切换 display。
  • 编写者可以随意优化内部逻辑(比如加个动画效果),只要不改动构造函数的参数,使用者的代码完全不需要动。

2. 可维护性 (Memory & Prototype)

我们把方法写在了 EditInPlace.prototype 上,而不是写在构造函数里。
这意味着,如果你在页面上 new 了 100 个 EditInPlace 组件:

  • 写在构造函数里:会创建 100 个 createElement 函数副本,占用 100 份内存。
  • 写在 prototype 上:100 个实例共用同一个原型链上的函数,只占用 1 份内存

3. 缜密的逻辑

  • 数据驱动:所有的 DOM 变化来源于 this.value 的改变,而不是随意的 innerHTML 赋值。
  • 容错性:考虑到用户可能不传初始值,我们给了默认的“懒人”文案。
  • 状态管理:明确分离了“查看态”和“编辑态”,逻辑清晰,绝不拖泥带水。

四、 总结

从简单的 DOM 操作,到封装成类,这不仅是代码量的增加,更是思维方式的跃迁:

  1. 面向过程:像记流水账,通过一步步指令指挥浏览器干活(适合一次性脚本)。
  2. 面向对象:像造物主,创造一个个有血有肉(有属性)、有技能(有方法)的对象,让它们自己管理自己。

下次当你看到 B站、知乎或者任何网站上炫酷的交互组件时,不妨试着在脑海里给它建个模:它的属性是什么?它的状态有几种?它的方法该挂在哪里?

这就叫,编程思维