React.js:contentEditable的onChange事件

189

如何监听基于 contentEditable 控件的更改事件?

var Number = React.createClass({
    render: function() {
        return <div>
            <span contentEditable={true} onChange={this.onChange}>
                {this.state.value}
            </span>
            =
            {this.state.value}
        </div>;
    },
    onChange: function(v) {
        // Doesn't fire :(
        console.log('changed', v);
    },
    getInitialState: function() {
        return {value: '123'}
    }
});

React.renderComponent(<Number />, document.body);

Code on JSFiddle.


20
我曾经也遇到过这个问题,而且对于推荐的答案也有问题,因此我决定将其设置为不受控制。也就是说,我将“initialValue”放入“state”中,并在“render”中使用它,但我不让React进一步更新它。 - Dan Abramov
1
你的 JSFiddle 不起作用。 - Green
1
我通过改变方法避免了与contentEditable的斗争 - 我使用了一个带有readonly属性的input,而不是一个spanparagraph - ovidiu-miu
11个回答

120

这是对我最有效的最简单的解决方法。

<div
  contentEditable='true'
  onInput={e => console.log('Text inside div', e.currentTarget.textContent)}
>
Text inside div
</div>

58
当我使用React状态更新文本时,光标会不断地移动到文本开头。 - ton1
1
这个解决方案将剥离所有标记并仅提供文本内容,从而破坏了使用可编辑div的原因。相反,使用innerHTML即 onInput={(e) =>console.log("Text inside div", e.currentTarget.innerHTML) } - Ufenei augustine
1
这个可以工作,但是正如@JuntaeKim建议的那样,插入符号始终停留在开头,不会改变其位置。有什么想法可以改变插入符号的位置吗? - Umang
@Umang React是否在div内更新状态?如果您从div修改状态,然后基于该状态更新div,则会替换DOM元素(我认为),这将删除插入符号。您需要找到一种避免react将状态插入div的方法。我只是编写了自己的函数来管理react之外的状态。 - BobtheMagicMoose
4
如果需要一个不受控制的组件,这很有效。但是对于受控制的情况来说效果不佳,正如其他人所提到的,React也通过警告来避免这种情况:一个组件是“contentEditable”的,并包含由React管理的“children”。现在您需要负责确保没有这些节点被意外修改或复制。这可能是不打算的。 - ericgio
我会将组件保持为未受控制状态,并使用 onBlur/onInput,而不是试图通过 React 控制值和光标位置。 - ggorlen

99

请查看Sebastien Lorber的答案,该答案修复了我的实现中的一个错误。


使用onInput事件,并可选地使用onBlur作为后备。您可能希望保存先前的内容以防止发送额外的事件。

我个人会将此作为我的渲染函数。

var handleChange = function(event){
    this.setState({html: event.target.value});
}.bind(this);

return (<ContentEditable html={this.state.html} onChange={handleChange} />);

jsbin

它使用简单的可编辑内容包装器。

var ContentEditable = React.createClass({
    render: function(){
        return <div
            onInput={this.emitChange}
            onBlur={this.emitChange}
            contentEditable
            dangerouslySetInnerHTML={{__html: this.props.html}}></div>;
    },
    shouldComponentUpdate: function(nextProps){
        return nextProps.html !== this.getDOMNode().innerHTML;
    },
    emitChange: function(){
        var html = this.getDOMNode().innerHTML;
        if (this.props.onChange && html !== this.lastHtml) {

            this.props.onChange({
                target: {
                    value: html
                }
            });
        }
        this.lastHtml = html;
    }
});

1
@NVI,这是shouldComponentUpdate方法。 只有在html属性与元素中实际的html不同步时,它才会跳过。例如,如果您执行this.setState({html:“something not in the editable div”}) - Brigand
1
不错,但我猜在 shouldComponentUpdate 中调用 this.getDOMNode().innerHTML 不是很优化。 - Sebastien Lorber
3
当你想将state.html设置为最后“已知”的值时,这种方法实际上存在一些缺陷。React将不会更新DOM,因为在React看来新的HTML与之前的完全相同(尽管实际的DOM已经改变)。参考jsfiddle。目前我还没有找到一个好的解决方案,欢迎提出任何想法。 - univerio
1
@dchest的shouldComponentUpdate应该是纯函数(没有副作用)。 - Brigand
在React文档的首页上,@SebastienLorber正好展示了这种模式。该“有状态组件”示例直接将计时器的ID存储在实例上:this.interval = setInterval(this.tick, 1000); - WickyNilliams
显示剩余11条评论

84

有人在NPM上使用我的解决方案做了一个项目:react-contenteditable

我遇到了另一个问题,当浏览器尝试“重新格式化”刚刚给出的HTML时,会导致组件始终重新渲染。请查看这个.

这里是我的内容可编辑实现。 它比react-contenteditable具有一些额外选项,包括:

  • 锁定
  • 命令式API允许嵌入HTML片段
  • 重新格式化内容的能力

总结:

FakeRainBrigand的解决方案一直对我很有效,直到我遇到了新问题。内容可编辑真的很棘手,而且不是在React中处理得很容易...

这个JSFiddle演示了这个问题。

如您所见,当您键入某些字符并单击清除时,内容不会被清除。 这是因为我们尝试将contenteditable重置为最后已知的虚拟DOM值。

因此,似乎:

  • 您需要使用shouldComponentUpdate来防止插入符跳跃
  • 如果您以这种方式使用shouldComponentUpdate,则不能依赖于React的VDOM差异算法。

所以您需要多加一行代码,这样每当shouldComponentUpdate返回“是”时,您可以确保DOM内容实际上已经更新。

因此,在此版本中添加了一个componentDidUpdate,变成如下:

var ContentEditable = React.createClass({
    render: function(){
        return <div id="contenteditable"
            onInput={this.emitChange}
            onBlur={this.emitChange}
            contentEditable
            dangerouslySetInnerHTML={{__html: this.props.html}}></div>;
    },

    shouldComponentUpdate: function(nextProps){
        return nextProps.html !== this.getDOMNode().innerHTML;
    },

    componentDidUpdate: function() {
        if (this.props.html !== this.getDOMNode().innerHTML) {
           this.getDOMNode().innerHTML = this.props.html;
        }
    },

    emitChange: function() {
        var html = this.getDOMNode().innerHTML;
        if (this.props.onChange && html !== this.lastHtml) {
            this.props.onChange({
                target: {
                    value: html
                }
            });
        }
        this.lastHtml = html;
    }
});

虚拟 DOM 是过时的,可能不是最高效的代码,但至少它能工作 :) 我的 bug 被解决了


详情:

  1. 如果您在 shouldComponentUpdate 中添加逻辑以避免光标跳动,则 contenteditable 永远不会重新渲染(至少在按键时不会)。

  2. 如果组件在按键时从未重新渲染,则 React 会为该 contenteditable 保留一个过时的虚拟 DOM。

  3. 如果 React 在其虚拟 DOM 树中保留了 contenteditable 的过时版本,则如果您尝试将 contenteditable 重置为虚拟 DOM 中过时的值,则在虚拟 DOM 差异期间,React 将计算出没有要应用于 DOM 的更改!

这通常发生在以下情况下:

  • 您最初有一个空的 contenteditable(shouldComponentUpdate=true,prop="",之前的 vdom=N/A),
  • 用户输入一些文本并且您阻止渲染(shouldComponentUpdate=false,prop=text,之前的 vdom="")
  • 在用户单击验证按钮后,您想清空该字段(shouldComponentUpdate=false,prop="",之前的 vdom="")
  • 由于新生成的虚拟 DOM 和旧的虚拟 DOM 都是 "",因此 React 不会触及 DOM。


1
我已经实现了一个keyPress版本,当按下回车键时会弹出文本。http://jsfiddle.net/kb3gN/11378/ - Luca Colonnello
@LucaColonnello 最好使用 {...this.props},这样客户端就可以从外部自定义此行为。 - Sebastien Lorber
1
你能解释一下shouldComponentUpdate代码是如何防止光标跳动的吗? - kmoe
1
@kmoe 因为如果 contentEditable 已经有了适当的文本(例如在按键时),组件就不会更新。使用 React 更新 contentEditable 会使插入符跳动。不要使用 contentEditable,自己试试看 ;) - Sebastien Lorber
2
很棒的实现。你的JSFiddle链接似乎有问题。 - Daniel Node.js
显示剩余4条评论

40

因为当编辑完成后,元素的焦点总是丢失的,所以你可以简单地使用一个onBlur事件处理程序。

<div
  onBlur={e => {
    console.log(e.currentTarget.textContent);
  }}
  contentEditable
  suppressContentEditableWarning={true}
>
  <p>Lorem ipsum dolor.</p>
</div>

1
这只回答了一个场景。仍然需要对内容更改采取行动(例如,在输入时更新自定义滚动条)。 - vsync
1
这在所有浏览器上都支持吗? - Sandesh Sapkota
1
聪明而简单! - undefined

21

我知道这可能不是你想要的答案,但我自己也曾为此苦苦挣扎,并且对建议的答案有些困惑,所以我决定将其设为不受控制。

editable 属性为 false 时,我直接使用 text 属性,但当它为 true 时,我切换到编辑模式,在此期间 text 不起作用(但至少浏览器不会崩溃)。此时,onChange 将由控件触发。最后,当我将 editable 改回 false 时,它将用传入的 text 填充 HTML:

/** @jsx React.DOM */
'use strict';

var React = require('react'),
    escapeTextForBrowser = require('react/lib/escapeTextForBrowser'),
    { PropTypes } = React;

var UncontrolledContentEditable = React.createClass({
  propTypes: {
    component: PropTypes.func,
    onChange: PropTypes.func.isRequired,
    text: PropTypes.string,
    placeholder: PropTypes.string,
    editable: PropTypes.bool
  },

  getDefaultProps() {
    return {
      component: React.DOM.div,
      editable: false
    };
  },

  getInitialState() {
    return {
      initialText: this.props.text
    };
  },

  componentWillReceiveProps(nextProps) {
    if (nextProps.editable && !this.props.editable) {
      this.setState({
        initialText: nextProps.text
      });
    }
  },

  componentWillUpdate(nextProps) {
    if (!nextProps.editable && this.props.editable) {
      this.getDOMNode().innerHTML = escapeTextForBrowser(this.state.initialText);
    }
  },

  render() {
    var html = escapeTextForBrowser(this.props.editable ?
      this.state.initialText :
      this.props.text
    );

    return (
      <this.props.component onInput={this.handleChange}
                            onBlur={this.handleChange}
                            contentEditable={this.props.editable}
                            dangerouslySetInnerHTML={{__html: html}} />
    );
  },

  handleChange(e) {
    if (!e.target.textContent.trim().length) {
      e.target.innerHTML = '';
    }

    this.props.onChange(e);
  }
});

module.exports = UncontrolledContentEditable;

你能详细说明一下你对其他答案存在的问题吗? - NVI
1
@NVI:我需要防止注入攻击,所以直接放置HTML不是一个选项。如果我不放置HTML并使用textContent,我会遇到各种浏览器不一致的问题,并且不能轻松实现shouldComponentUpdate,所以即使它也无法防止光标跳动。最后,我有CSS伪元素:empty:before占位符,但这个shouldComponentUpdate实现阻止了FF和Safari在用户清除字段时清理该字段。花了我5个小时才意识到我可以通过不受控制的CE绕过所有这些问题。 - Dan Abramov
我不是很理解它是如何工作的。您从未在“不受控制的可编辑内容”(UncontrolledContentEditable)中更改“editable”。您可以提供一个可运行的示例吗? - NVI
所以这只有在父组件能提供 texteditableonChange 的情况下才能工作。它不支持在 editabletrue 的情况下从外部更改 text,但你很少需要这样做。 - Dan Abramov
3
React已将escapeTextForBrowser重命名为escapeTextContentForBrowser。这段代码将用于谁,您需要使用它。 - wuct
显示剩余3条评论

6
我建议使用MutationObserver来完成这个任务。它可以让你更好地控制正在发生的事情,并提供更多关于浏览器如何解释所有按键的详细信息。
以下是TypeScript代码示例:
import * as React from 'react';

export default class Editor extends React.Component {
    private _root: HTMLDivElement; // Ref to the editable div
    private _mutationObserver: MutationObserver; // Modifications observer
    private _innerTextBuffer: string; // Stores the last printed value

    public componentDidMount() {
        this._root.contentEditable = "true";
        this._mutationObserver = new MutationObserver(this.onContentChange);
        this._mutationObserver.observe(this._root, {
            childList: true, // To check for new lines
            subtree: true, // To check for nested elements
            characterData: true // To check for text modifications
        });
    }

    public render() {
        return (
            <div ref={this.onRootRef}>
                Modify the text here ...
            </div>
        );
    }

    private onContentChange: MutationCallback = (mutations: MutationRecord[]) => {
        mutations.forEach(() => {
            // Get the text from the editable div
            // (Use innerHTML to get the HTML)
            const {innerText} = this._root;

            // Content changed will be triggered several times for one key stroke
            if (!this._innerTextBuffer || this._innerTextBuffer !== innerText) {
                console.log(innerText); // Call this.setState or this.props.onChange here
                this._innerTextBuffer = innerText;
            }
        });
    }

    private onRootRef = (elt: HTMLDivElement) => {
        this._root = elt;
    }
}

2

1
作者在 package.json 中给了我 SO 回答的功劳。这几乎是我发布的相同代码,我确认这段代码对我有效。https://github.com/lovasoa/react-contenteditable/blob/master/package.json - Sebastien Lorber
链接已损坏("404. 页面未找到")。 - Peter Mortensen
@PeterMortensen我根据我在存储库中找到的内容更新了链接。代码略有变化,但看起来思路仍然相同。 - ADTC

2
<div
    spellCheck="false"
    onInput={e => console.log("e: ", e.currentTarget.textContent}
    contentEditable="true"
    suppressContentEditableWarning={true}
    placeholder="Title"
    className="new-post-title"
/>

1
需要进行解释,例如,什么是想法/要点?它在哪些浏览器、操作系统上进行了测试,包括版本(包括React)。来自帮助中心“...始终说明你提出的解决方案为何适当以及其工作原理”。请通过编辑(更改)您的答案来回应,而不是在此处进行评论(***无需 “编辑:”,“更新:”或类似内容-答案应该看起来像今天编写的)。 - Peter Mortensen
"console.log("e: ", e.currentTarget.textContent" 附近似乎缺少一个 ")"。 - Peter Mortensen

1
这是基于Sebastien Lorber的回答的hooks版本:

hooks

const noop = () => {};
const ContentEditable = ({
  html,
  onChange = noop,
}: {
  html: string;
  onChange?: (s: string) => any;
}) => {
  const ref = useRef<HTMLDivElement>(null);
  const lastHtml = useRef<string>('');

  const emitChange = () => {
    const curHtml = ref.current?.innerHTML || '';
    if (curHtml !== lastHtml.current) {
      onChange(curHtml);
    }
    lastHtml.current = html;
  };

  useEffect(() => {
    if (!ref.current) return;
    if (ref.current.innerHTML === html) return;
    ref.current.innerHTML = html;
  }, [html]);

  return (
    <div
      onInput={emitChange}
      contentEditable
      dangerouslySetInnerHTML={{ __html: html }}
      ref={ref}
    ></div>
  );
};

我在这里看到了一个完美的XSS向量,伙计们。我猜这并不是一个适合生产环境的严肃答案。至少你可以提到在父组件中可能或可能不会发生的一些净化需求。最佳实践是完全避免使用dangerouslySetInnerHTML。 在这里查看'React的dangerouslySetInnerHTML未经HTML净化' 链接 - okram

0

我已经完成了onInput。谢谢

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>
<script src="//unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
 div{
  border: .2px solid black;
 }
</style>
# editable
<div id="root"></div>

<script type="text/babel">
const { React, ReactDOM} = window;

const App =()=>(
    <div 
       contentEditable={true}
       onInput={(e) => {
         console.log(e.target.innerText);
        }}
     >Edit me</div>
)
ReactDOM.render(<App/>, document.querySelector('#root'));
</script>


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接