有趣的 contentEditable

简介: 以前在知乎看到一篇关于《一行代理可以做什么?》的回答:当时试了一下确实很好玩,于是每次都可以在妹子面前秀一波操作,在他们惊叹的目光中,我心里开心地笑了——嗯,又让一个不懂技术的人发现到了程序的美🐶,咳咳。一直以来,我都觉得这个属性只是为了存在而存在的,然而在今天接到的需求之后,我发现这个感觉没什么用的属性竟然完美地解决了我的需求。

以前在知乎看到一篇关于《一行代理可以做什么?》的回答:


image.png


当时试了一下确实很好玩,于是每次都可以在妹子面前秀一波操作,在他们惊叹的目光中,我心里开心地笑了——嗯,又让一个不懂技术的人发现到了程序的美🐶,咳咳。

一直以来,我都觉得这个属性只是为了存在而存在的,然而在今天接到的需求之后,我发现这个感觉没什么用的属性竟然完美地解决了我的需求。


需求




需求很简单,在输入框里添加按钮就好了。这种功能一般用于邮件群发,这里的按钮“姓名”其实就是一个变量,后端应该要自动填充真实用户的姓名,然后再把邮件发给用户的。


image.png


问题




这个需求乍一看感觉可以用 position: relative + position: absolute 来完成。但是细想就不太可能:按钮肯定会覆盖输入内容的,而且单单一个删除“姓名”按钮这个功能就很难做。


再说只用 <textarea> 也不可能实现,因为<textarea>里就不可能存在输入 button 的情况。


我想另一个可能就是以<div>为底,在<div>最后加一个宽度为1px的<input>,然后用双向绑定去实现添加按钮和修改文本功能,用这个1px宽度的<input>来实现 focus 和blur功能。但是感觉也特别难实现。


最后在一篇 stackoverflow 里找到了答案:Button inside TextArea in HTML

然后我又搜了一下看到了这个库:react-contenteditable


解决方案




看到 contentEditable 的时候还是有点震惊的,毕竟这个一直被我用来秀来秀去的属性竟然在这一天解决了我的问题。


这个库用起来也很有意思,使用函数组件的时候,它不像我们普通那里一个 value 一个 onChange 就搞定了,而它需要我们传一个 innerRef 来控制里面的文本。


function App() {
  const innerRef = useRef<HTMLElement>(null);
  const value = useRef<string>('');
  const onChange = (event: ContentEditableEvent) => {
    value.current = event.target.value;
  }
  const onAddButton = () => {
    if (!innerRef.current) {
      return;
    }
    innerRef.current.innerHTML += '&nbsp;<button contenteditable="false">姓名</button>&nbsp;'
  }
  return (
    <div className="App">
      <ContentEditable 
        style={{ border: '1px solid black', height: 100 }} 
        innerRef={innerRef} 
        html={value.current} 
        onChange={onChange} 
      />
      <button onClick={onAddButton}>添加姓名</button>
    </div>
  );
}


细看 react-contentEditable 源码




上面说到的使用 ref 来控制文本的变化让我好奇里面到底是怎么实现的,所以我把他的 github clone 了下来,发现这里面的实现确实不太简单。Github 在这里。


render 函数


因为我们使用 contentEditable 来实现输入输出,所以几乎任何元素都是可以的,因此,这个组件允许我们传入 tagName 来指定要以哪个元素为基底。


render() {
  const { tagName, html, innerRef, ...props } = this.props;
  return React.createElement(
    tagName || 'div',
    {
      ...props,
      ref: typeof innerRef === 'function' ? (current: HTMLElement) => {
        innerRef(current)
        this.el.current = current
      } : innerRef || this.el,
      onInput: this.emitChange,
      onBlur: this.props.onBlur || this.emitChange,
      onKeyUp: this.props.onKeyUp || this.emitChange,
      onKeyDown: this.props.onKeyDown || this.emitChange,
      contentEditable: !this.props.disabled,
      dangerouslySetInnerHTML: { __html: html }
    },
    this.props.children);
}


这里的 render 函数就是为了一个指定渲染哪个函数,同时绑定一些事件,是否开启 contentEditable 属性,并传入 props。



我们还观察到这里的值其实是通过 dangerouslySetInnerHTML: { __html: html } 来展示的。


那既然都是 dangerously 了,那我们当然就要想到去防止脚本注入了嘛,所以源码也对值进行 normalize 了:


function normalizeHtml(str: string): string {
  return str && str.replace(/&nbsp;|\u202F|\u00A0/g, ' ');
}


事件


还有一个值得注意的点是其实除了 <input><textarea> 之外,<div> 也是可以触发 onInput 事件的。

比如我自己也尝试实现了一下:


const VarInput: FC<IProps> = (props) => {
  const { value, tag, disabled, onInput, ref, ...restProps } = props;
  const innerRef = useRef(null);
  const curtRef = ref || innerRef;
  const emitChange = () => {
    const callbackValue: string = curtRef.current ? curtRef.current.innerHTML : '';
    onInput!(callbackValue);
  }
  const varInputProps = {
    ...restProps,
    ref: curtRef,
    contentEditable: !disabled,
    onInput: emitChange,
    dangerouslySetInnerHTML: { __html: value }
  }
  return createElement(tag || 'div', varInputProps);
}


使用的时候


function App() {
  const [value] = useState('');
  const onChange = (value: string) => {
    console.log(value); // 打印 value
  }
  return (
    <div className="App">
      <VarInput value={value} onInput={onChange} />
    </div>
  );


然而,当我只绑定 onChange 的时候却不会触发事件!所以,onInput 和 onChange 在这里是有区别的!


emitChange


这里的 onChange, onInput 等回调事件其实都是调用了 emitChange 函数:


emitChange = (originalEvt: React.SyntheticEvent<any>) => {
    const el = this.getEl();
    if (!el) return;
    const html = el.innerHTML;
    if (this.props.onChange && html !== this.lastHtml) {
      // Clone event with Object.assign to avoid
      // "Cannot assign to read only property 'target' of object"
      const evt = Object.assign({}, originalEvt, {
        target: {
          value: html
        }
      });
      this.props.onChange(evt);
    }
    this.lastHtml = html;
  }


这里也很好理解,毕竟只是获取 innerHTML 并构造一个 event,再放到 onChange 里就完事了。简单。


componentDidUpdate


说实话上面的事件我自己也都实现了一次,但是有个问题我一直做不了,那就是我每次输入的时候,光标都会移到最前面!!!比如我输入 "hello",结果就会显示:"olleh",这是什么鬼?!



image.png


用法:


function App() {
  const [value, setValue] = useState('');
  const onChange = (value: string) => {
    console.log(value);
    setValue(value)
  }
  return (
    <div className="App">
      <VarInput value={value} onInput={onChange} />
    </div>
  );
}


这个是因为我在 setValue 的时候,光标会移到最前面,回到源码,它也是考虑到了这一点的。他用了一个函数放在 componentDidUpdate 里处理了这种情况:


componentDidUpdate() {
  const el = this.getEl();
  if (!el) return;
  // Perhaps React (whose VDOM gets outdated because we often prevent
  // rerendering) did not update the DOM. So we update it manually now.
  if (this.props.html !== el.innerHTML) {
    el.innerHTML = this.props.html;
  }
  this.lastHtml = this.props.html;
  replaceCaret(el);
}
function replaceCaret(el: HTMLElement) {
  // Place the caret at the end of the element
  const target = document.createTextNode('');
  el.appendChild(target);
  // do not move caret if element was not focused
  const isTargetFocused = document.activeElement === el;
  if (target !== null && target.nodeValue !== null && isTargetFocused) {
    var sel = window.getSelection();
    if (sel !== null) {
      var range = document.createRange();
      range.setStart(target, target.nodeValue.length);
      range.collapse(true);
      sel.removeAllRanges();
      sel.addRange(range);
    }
    if (el instanceof HTMLElement) el.focus();
  }
}


这个函数确保了每次更新后光标都会移动到最后一个位置上,果然我想的还是太 naive 了。


shouldComponentUpdate


最后一个部分就是 shouldComponentUpdate 了,这里主要是做一些 props 是否改变来判断是否需要重新渲染组件而已,相信大家都会就不多做介绍了。


最后




下次秀这个属性的时候可以把这篇文章也给妹子看看,一起学习🐶(逃

(完)



相关文章
|
定位技术
Echarts集成bmap的属性介绍
Echarts集成bmap的属性介绍
598 0
|
2月前
|
人工智能 JavaScript API
1个人=1个团队!OpenClaw打造一人公司:阿里云/本地搭建16个AI员工+百炼API配置,实现AI工作流自动化!
2026年的创业生态里,“一人公司”的终极形态已然到来——凭借AI工具的赋能,一个人就能掌控16个AI员工,实现从内容创作、客户运营到数据分析、财务管理的全流程公司运转。BuildShip创始人Vadim正是这一模式的实践者,他通过AI Agent工作流自动化,让16个不同分工的AI员工各司其职,自己则成为核心的战略决策者,将个人效率放大到极致。而OpenClaw(Clawdbot)作为开源且功能强大的AI生产力工具,成为搭建个性化AI Agent、落地一人公司模式的核心载体。本文将拆解16个AI员工的精准分工与优化方法,详解基于OpenClaw搭建AI工作流的实操逻辑,同时完整整理2026年
1333 3
|
4月前
|
JavaScript 前端开发 数据安全/隐私保护
【RuoYi-SpringBoot3-Pro】:拒绝“大众脸”!高颜值前端框架选型
拒绝“若依脸”!推荐两款高颜值前端框架:BearJia Vue3(Ant Design Vue 4 + Vite)打造专业现代界面,RuoYi-Vue3-Prettier 全面重构Element Plus,支持TS与Hook封装。视觉升级、代码精简,助你轻松实现差异化后台系统。
518 1
【RuoYi-SpringBoot3-Pro】:拒绝“大众脸”!高颜值前端框架选型
|
10月前
|
NoSQL MongoDB 数据库
数据库数据恢复—MongoDB数据库数据恢复案例
MongoDB数据库数据恢复环境: 一台操作系统为Windows Server的虚拟机上部署MongoDB数据库。 MongoDB数据库故障: 工作人员在MongoDB服务仍然开启的情况下将MongoDB数据库文件拷贝到其他分区,数据复制完成后将MongoDB数据库原先所在的分区进行了格式化操作。 结果发现拷贝过去的数据无法使用。管理员又将数据拷贝回原始分区,MongoDB服务仍然无法使用,报错“Windows无法启动MongoDB服务(位于 本地计算机 上)错误1067:进程意外终止。”
|
8月前
|
人工智能 Java API
Java与大模型集成实战:构建智能Java应用的新范式
随着大型语言模型(LLM)的API化,将其强大的自然语言处理能力集成到现有Java应用中已成为提升应用智能水平的关键路径。本文旨在为Java开发者提供一份实用的集成指南。我们将深入探讨如何使用Spring Boot 3框架,通过HTTP客户端与OpenAI GPT(或兼容API)进行高效、安全的交互。内容涵盖项目依赖配置、异步非阻塞的API调用、请求与响应的结构化处理、异常管理以及一些面向生产环境的最佳实践,并附带完整的代码示例,助您快速将AI能力融入Java生态。
1315 12
|
弹性计算 运维 Kubernetes
动手实操,让你的 Kubernetes 集群弹起来!
本文将对于集群自动弹性伸缩(cluster-autosclaer)进行介绍,并在 ACK 集群上进行实操。
5032 4
|
Java Spring
Spring Boot使用策略模式指定Service实现类
Spring Boot使用策略模式指定Service实现类
487 0
|
前端开发 JavaScript 虚拟化
React 树形组件 Tree View
本文从零开始构建了一个简单的React树形组件,介绍了环境准备、项目创建、基础组件构建等步骤,并探讨了常见问题及解决方案,包括层次嵌套过深、状态管理复杂、事件处理不当和样式问题,帮助读者在实际项目中更好地应用树形组件。
704 4
|
存储 安全 算法
什么是秒合约?竞猜游戏交易所app系统开发规则介绍
秒合约是一种基于区块链技术的超短期衍生品合约,交易周期以秒为单位。它通过智能合约实现交易的自动化和去信任化,优化执行流程,提高交易速度和效率。秒合约适合高风险投机者,收益和风险固定,不使用杠杆。此外,竞猜游戏交易所app系统也涉及快速交易和投机,需确保安全、稳定及合规运营。