{"id":17683366,"url":"https://github.com/haixiangyan/my-react-contenteditable","last_synced_at":"2026-04-24T16:39:56.161Z","repository":{"id":106425395,"uuid":"355013784","full_name":"haixiangyan/my-react-contenteditable","owner":"haixiangyan","description":"手把手实现一个简单的文本编辑器","archived":false,"fork":false,"pushed_at":"2021-04-28T04:59:52.000Z","size":364,"stargazers_count":3,"open_issues_count":0,"forks_count":1,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-03-30T19:49:15.819Z","etag":null,"topics":["contenteditable","react","react-contenteditable"],"latest_commit_sha":null,"homepage":"http://yanhaixiang.com/my-react-contenteditable/","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/haixiangyan.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2021-04-06T00:52:21.000Z","updated_at":"2024-09-03T03:08:14.000Z","dependencies_parsed_at":null,"dependency_job_id":"c8db97aa-8a4e-4f96-a586-024d2cd8b5c2","html_url":"https://github.com/haixiangyan/my-react-contenteditable","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/haixiangyan/my-react-contenteditable","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/haixiangyan%2Fmy-react-contenteditable","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/haixiangyan%2Fmy-react-contenteditable/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/haixiangyan%2Fmy-react-contenteditable/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/haixiangyan%2Fmy-react-contenteditable/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/haixiangyan","download_url":"https://codeload.github.com/haixiangyan/my-react-contenteditable/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/haixiangyan%2Fmy-react-contenteditable/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32231587,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-24T13:21:15.438Z","status":"ssl_error","status_checked_at":"2026-04-24T13:21:15.005Z","response_time":64,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["contenteditable","react","react-contenteditable"],"created_at":"2024-10-24T09:45:14.396Z","updated_at":"2026-04-24T16:39:56.138Z","avatar_url":"https://github.com/haixiangyan.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# 造一个 react-contenteditable 轮子\n\n\u003e 文章源码：https://github.com/Haixiang6123/my-react-contenteditable\n\u003e\n\u003e 预览链接：http://yanhaixiang.com/my-react-contenteditable/\n\u003e\n\u003e 参考轮子：https://www.npmjs.com/package/react-contenteditable\n\n\n以前在知乎看到一篇关于《一行代理可以做什么？》的回答：\n\n![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e2042a68c44a4af6986858c442edd006~tplv-k3u1fbpfcp-zoom-1.image)\n\n当时试了一下确实很好玩，于是每次都可以在妹子面前秀一波操作，在他们惊叹的目光中，我心里开心地笑了——嗯，又让一个不懂技术的人发现到了程序的美🐶，咳咳。\n\n一直以来，我都觉得这个属性只是为了存在而存在的，然而在今天接到的需求之后，我发现这个感觉没什么用的属性竟然完美地解决了我的需求。\n\n## 一个需求\n\n需求很简单，在输入框里添加按钮就好了。这种功能一般用于邮件群发，这里的按钮“姓名”其实就是一个变量，后端应该要自动填充真实用户的姓名，然后再把邮件发给用户的。\n\n![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/21248d1ac3ff49e791ce9ab6b0e2445b~tplv-k3u1fbpfcp-zoom-1.image)\n\n这个需求第一感觉像是 textarea 里加入一个 button，但是想想又不对：textarea 里加不了 button。那用 div 包裹呢？也不对：div 不能输入啊，唉，谁说不能输入的？`contentEditable` 属性就是可以让用户手动输入的。\n\n下面就带大家手写一个 `react-contenteditable` 的轮子吧。\n\n## 用例\n\n参考 input 元素的受控组件写法，可以想到肯定得有 `value` 和 `onChange` 两个 props，使用方法大概像这样：\n\n```ts\nfunction App() {\n  const [value, setValue] = useState('');\n\n  const onChange = (e: ContentEditableEvent) =\u003e {\n    console.log('change', e.target.value)\n    setValue(e.target.value)\n  }\n\n  return (\n    \u003cdiv style={{ border: '1px solid black' }}\u003e\n      \u003cContentEditable style={{ height: 300 }} value={value} onChange={onChange} /\u003e\n    \u003c/div\u003e\n  );\n}\n```\n\n重新再认识一下 `contentEditable` 属性：一个枚举属性，表示元素是否可被用户编辑。浏览器会修改元素的部件以允许编辑。详情可看 [MDN 文档](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Global_attributes/contenteditable)。\n\n为了可以插入 html，需要用到 `dangerouslySetInnerHTML` 这个属性来设置 `innerHTML`，并通过 `onInput` 来执行 `onChange` 回调。一个简单的实现如下：\n\n```ts\n// 修改后的 onChange 事件\nexport type ContentEditableEvent = SyntheticEvent\u003cany, Event\u003e \u0026 {\n  target: { value: string }\n};\n\ninterface Props {\n  value?: string // 值\n  onChange?: (e: ContentEditableEvent) =\u003e void // 值改动的回调\n}\n\nclass ContentEditable extends Component\u003cProps\u003e {\n  lastHtml = this.props.value // 记录上一次的值\n  ref = createRef\u003cHTMLDivElement\u003e() // 当前容器\n\n  emitEvent = (originalEvent: SyntheticEvent\u003cany\u003e) =\u003e {\n    if (!this.ref.current) return\n\n    const html = this.ref.current.innerHTML\n    if (this.props.onChange \u0026\u0026 html !== this.lastHtml) { // 与上次的值不一样才回调\n      const event = { // 合并事件，这里主要改变 target.value 的值\n        ...originalEvent,\n        target: {\n          ...originalEvent.target,\n          value: html || ''\n        }\n      }\n\n      this.props.onChange(event) // 执行回调\n    }\n  }\n\n  render() {\n    const { value } = this.props\n\n    return (\n      \u003cdiv\n        ref={this.ref}\n        contentEditable\n        onInput={this.emitEvent}\n        dangerouslySetInnerHTML={{__html: value || ''}}\n      /\u003e\n    )\n  }\n}\n```\n\n但是很快你会发现一个问题：怎么打出来的字都是倒着输出的？比如打个 \"hello\"，会变成：\n\n![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8060aca938244736861526b753ae85bd~tplv-k3u1fbpfcp-zoom-1.image)\n\n## 解决倒序输出的问题\n\n如果你把 `onChange` 里的 `setValue(e.target.value)` 去掉，会发现这个 bug 又没了，又可以正常输出了。\n\n这是因为每次 `setValue` 的时候组件会重新渲染，每次渲染的时候光标会跑到最前面，所以当 `setValue` 的时候会出现倒序输出的问题。\n\n解决方法是在 `componentDidUpdate` 里把光标重新放到最后就可以了，每次渲染后光标回到最后的位置。\n\n```ts\nconst replaceCaret = (el: HTMLElement) =\u003e {\n  // 创建光标\n  const cursor = document.createTextNode('')\n  el.appendChild(cursor)\n\n  // 判断是否选中\n  const isFocused = document.activeElement === el\n  if (!cursor || !cursor.nodeValue || !isFocused) return\n\n  // 将光标放到最后\n  const selection = window.getSelection()\n  if (selection !== null) {\n    const range = document.createRange()\n    range.setStart(cursor, cursor.nodeValue.length)\n    range.collapse(true)\n\n    selection.removeAllRanges()\n    selection.addRange(range)\n  }\n\n  // 重新 focus\n  if (el instanceof HTMLElement) el.focus()\n}\n\nclass ContentEditable extends Component\u003cProps\u003e {\n  lastHtml = this.props.value\n  ref = createRef\u003cHTMLDivElement\u003e()\n\n  componentDidUpdate() {\n    if (!this.ref.current) return\n\n    this.lastHtml = this.props.value\n\n    replaceCaret(this.ref.current) // 把光标放到最后\n  }\n\n  ...\n}\n```\n\n这里要注意的是：对于 Range，可以是选区，也可以是光标。上面创建了一个 Range，`setCollapse(true)` 把 Range 设置为 [空选区](https://developer.mozilla.org/en-US/docs/Web/API/Range/collapse) 也就变成了光标的了。然后把 Range 放到创建的 Node 里，这个 Node 又放到容器最后。这就实现了 **“把光标放到最后”** 的效果了。\n\n## checkUpdate\n\n有人可能会有疑问：一般使用 `input` 之类输入组件的时候，如果没在 `onChange` 里 `setValue`，值都是不会改变的呀。上面提到不加 `setValue` 也可以再次输入，也就说我设置 `value` 就好了，不用手动再去更新 `value` 了，这里是不是可以做输入性能的优化呢？\n\n答案是可以的，在 [react-contentedtiable 源码](https://github.com/lovasoa/react-contenteditable/blob/master/src/react-contenteditable.tsx#L58) 里就做了性能的优化。\n\n```ts\n  shouldComponentUpdate(nextProps: Props): boolean {\n    const { props } = this;\n    const el = this.getEl();\n\n    // We need not rerender if the change of props simply reflects the user's edits.\n    // Rerendering in this case would make the cursor/caret jump\n\n    // Rerender if there is no element yet... (somehow?)\n    if (!el) return true;\n\n    // ...or if html really changed... (programmatically, not by user edit)\n    if (\n      normalizeHtml(nextProps.html) !== normalizeHtml(el.innerHTML)\n    ) {\n      return true;\n    }\n\n    // Handle additional properties\n    return props.disabled !== nextProps.disabled ||\n      props.tagName !== nextProps.tagName ||\n      props.className !== nextProps.className ||\n      props.innerRef !== nextProps.innerRef ||\n      !deepEqual(props.style, nextProps.style);\n  }\n```\n\n但是随之而来的是由于阻止更新而引发的 Bug：https://github.com/lovasoa/react-contenteditable/issues/161。\n\n在这个 Issue 里说到因为没有对 `onBlur` 进行更新判断，因此，每次改变了值之后，再触发 blur 事件，值都不会改变。那加个 `onBlur` 的检查是否可行呢？如果要这么做，那别的 `onInput`，`onClick` 等回调也要加判断才可以，其实这么下来还不如在 `shouldComponentUpdate` 里 `return true` 就好了。完全起不到性能优化的作用。\n\n一个比较折中的方案是添加一个 `checkUpdate` 的 props 给使用的人去做性能优化。源码是对每次的值以及一些 `props` 更新进行判定是否需要更新。\n\n```ts\ninterface Props extends HTMLAttributes\u003cHTMLElement\u003e {\n  value?: string\n  onChange?: (e: ContentEditableEvent) =\u003e void\n  checkUpdate?: (nextProps: Props, thisProps: Props) =\u003e boolean // 判断是否应该更新\n}\n```\n\n在 `shouldComponentUpdate` 里返回这个函数的返回值即可：\n\n```ts\nclass ContentEditable extends Component\u003cProps\u003e {\n  ...\n\n  shouldComponentUpdate(nextProps: Readonly\u003cProps\u003e): boolean {\n    if (this.props.checkUpdate) {\n      return this.props.checkUpdate(nextProps, this.props)\n    }\n    return true\n  }\n\n  ...\n}\n```\n\n## innerRef\n\n上面通过 ref 获取容器元素的代码比较冗余，而且还没有向外暴露 ref。这一步优化获取容器元素代码，并向外暴露 ref 参数。\n\n```ts\ninterface Props extends HTMLAttributes\u003cHTMLElement\u003e {\n  disabled?: boolean\n  value?: string\n  onChange?: (e: ContentEditableEvent) =\u003e void\n  innerRef?: React.RefObject\u003cHTMLDivElement\u003e | Function // 向外暴露的 ref\n  checkUpdate?: (nextProps: Props, thisProps: Props) =\u003e boolean\n}\n```\n\n需要注意的是，ref 可能为 Ref 对象，也可能为一个函数，要兼容这两种情况。\n\n```ts\nclass ContentEditable extends Component\u003cProps\u003e {\n  private lastHtml: string = this.props.value || ''\n  private el: HTMLElement | null = null\n\n  componentDidUpdate() {\n    const el = this.getEl()\n\n    if (!el) return\n\n    this.lastHtml = this.props.value || ''\n\n    replaceCaret(el)\n  }\n\n  getEl = (): HTMLElement | null =\u003e { // 获取容器的方法\n    const {innerRef} = this.props\n\n    if (!!innerRef \u0026\u0026 typeof innerRef !== 'function') {\n      return innerRef.current\n    }\n\n    return this.el\n  }\n\n  emitEvent = (originalEvent: SyntheticEvent\u003cany\u003e) =\u003e {\n    const el = this.getEl()\n\n    if (!el) return\n\n    const html = el.innerHTML\n    if (this.props.onChange \u0026\u0026 html !== this.lastHtml) {\n      const event = {\n        ...originalEvent,\n        target: {\n          value: html || ''\n        }\n      }\n      // @ts-ignore\n      this.props.onChange(event)\n    }\n\n    this.lastHtml = html\n  }\n\n  render() {\n    const { disabled, value, innerRef, ...passProps } = this.props\n\n    return (\n      \u003cdiv\n        {...passProps}\n        ref={typeof innerRef === 'function' ? (node: HTMLDivElement) =\u003e {\n          innerRef(node)\n          this.el = node\n        }: innerRef || null}\n        contentEditable\n        onInput={this.emitEvent}\n        onBlur={this.props.onBlur || this.emitEvent}\n        onKeyUp={this.props.onKeyUp || this.emitEvent}\n        onKeyDown={this.props.onKeyDown || this.emitEvent}\n        dangerouslySetInnerHTML={{__html: value || ''}}\n      \u003e\n        {this.props.children}\n      \u003c/div\u003e\n    )\n  }\n}\n```\n\n上面添加了 `getEl` 函数，用于获取当前容器。\n\n## 补充 props\n\n除了上面一些比较重要的 props，还有一些增强扩展性的 props，如 `disabled`, `tagName`。\n\n```ts\nclass ContentEditable extends Component\u003cProps\u003e {\n  ...\n\n  render() {\n    const {tagName, value, innerRef, ...passProps} = this.props\n\n    return createElement(\n      tagName || 'div',\n      {\n        ...passProps,\n        ref: typeof innerRef === 'function' ? (node: HTMLDivElement) =\u003e {\n          innerRef(node)\n          this.el = node\n        } : innerRef || null,\n        contentEditable: !this.props.disabled,\n        onInput: this.emitEvent,\n        onBlur: this.props.onBlur || this.emitEvent,\n        onKeyUp: this.props.onKeyUp || this.emitEvent,\n        onKeyDown: this.props.onKeyDown || this.emitEvent,\n        dangerouslySetInnerHTML: {__html: value || ''}\n      },\n      this.props.children\n    )\n  }\n}\n```\n\n## 总结\n\n至此，一个 react-contenteditable 的组件就完成了，主要实现了：\n\n* value 和 onChange 的数据流\n* 在 `componentDidUpdate` 里处理光标总是被放在最前面的问题\n* 在 `shouldComponentUpdate` 里添加 `checkUpdate`，开发者用于优化渲染性能\n* 向外暴露  ref，disabled，tagName 的 props\n\n虽然这个 react-contenteditable 看起来还不错，但是看了源码之后发现这个库的很多兼容性的问题都没有考虑到，比如 [这篇 Stackoverflow 上的讨论](https://stackoverflow.com/questions/17890568/contenteditable-div-backspace-and-deleting-text-node-problems)，再加上上面提到的蛋疼 Issue，如果真要在生产环境实现富文本最好不要用这个库，推荐使用 [draft.js](https://draftjs.org/)。当然简单的功能用这个库实现还是比较轻量的。\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhaixiangyan%2Fmy-react-contenteditable","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fhaixiangyan%2Fmy-react-contenteditable","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhaixiangyan%2Fmy-react-contenteditable/lists"}