Waarom gedraagt ​​`setState` zich in ReactJS anders als het synchroon wordt aangeroepen?

Ik probeer de onderliggende oorzaak te begrijpen van een enigszins “magisch” gedrag dat ik zie dat ik niet volledig kan verklaren, en dat niet duidelijk wordt uit het lezen van de ReactJS-broncode.

Als de methode setStatesynchroon wordt aangeroepen als reactie op een onChange-gebeurtenis op een invoer, werkt alles zoals verwacht. De “nieuwe” waarde van de invoer is al aanwezig, en dus wordt de DOM niet echt bijgewerkt. Dit is zeer wenselijk omdat het betekent dat de cursor niet naar het einde van het invoervak ​​springt.

Bij het uitvoeren van een component met exact dezelfde structuur maar die setStateasynchroonaanroept, lijkt de “nieuwe” waarde van de invoer niet aanwezig te zijn, waardoor ReactJS om de DOM daadwerkelijk aan te raken, waardoor de cursor naar het einde van de invoer springt.

Blijkbaar grijpt er iets in om de invoer te “resetten” naar zijn eerdere valuein het asynchrone geval, wat het niet doet in het synchrone geval. Wat is deze monteur?

Synchroon voorbeeld

var synchronouslyUpdatingComponent =
    React.createFactory(React.createClass({
      getInitialState: function () {
        return {value: "Hello"};
      },
      changeHandler: function (e) {
        this.setState({value: e.target.value});
      },
      render: function () {
        var valueToSet = this.state.value;
        console.log("Rendering...");
        console.log("Setting value:" + valueToSet);
        if(this.isMounted()) {
            console.log("Current value:" + this.getDOMNode().value);
        }
        return React.DOM.input({value: valueToSet,
                                onChange: this.changeHandler});
    }
}));

Houd er rekening mee dat de code zich aanmeldt bij de render-methode, waarbij de huidige valuevan het daadwerkelijke DOM-knooppunt wordt afgedrukt.

Bij het typen van een “X” tussen de twee L’s van “Hallo”, zien we de volgende console-uitvoer en blijft de cursor waar verwacht:

Rendering...
Setting value:HelXlo
Current value:HelXlo

Asynchroon voorbeeld

var asynchronouslyUpdatingComponent =
  React.createFactory(React.createClass({
    getInitialState: function () {
      return {value: "Hello"};
    },
    changeHandler: function (e) {
      var component = this;
      var value = e.target.value;
      window.setTimeout(function() {
        component.setState({value: value});
      });
    },
    render: function () {
      var valueToSet = this.state.value;
      console.log("Rendering...");
      console.log("Setting value:" + valueToSet);
      if(this.isMounted()) {
          console.log("Current value:" + this.getDOMNode().value);
      }
      return React.DOM.input({value: valueToSet,
                              onChange: this.changeHandler});
    }
}));

Dit is precies hetzelfde als het bovenstaande, behalve dat de aanroep naar setStatezich in een setTimeout-callback bevindt.

In dit geval levert het typen van een X tussen de twee L’s de volgende console-uitvoer op en springt de cursor naar het einde van de invoer:

Rendering...
Setting value:HelXlo
Current value:Hello

Waarom is dit?

Ik begrijp het concept van React van een Controlled Component, en daarom is het logisch dat gebruikerswijzigingen in de valueworden genegeerd. Maar het lijkt erop dat de valuein feite is gewijzigd en vervolgens expliciet opnieuw wordt ingesteld.

Blijkbaar zorgt het synchroon aanroepen van setStateervoor dat het vóórde reset van kracht wordt, terwijl het aanroepen van setStateop elk ander moment na gebeurt de reset, waardoor opnieuw renderen wordt geforceerd.

Is dit echt wat er gebeurt?

JS Bin-voorbeeld

http://jsbin.com/sognutoyi/1/


Antwoord 1, autoriteit 100%

Dit is wat er aan de hand is.

Synchroon

  • je drukt op X
  • input.value is ‘HelXlo’
  • je roept setState({value: 'HelXlo'})
  • aan

  • de virtuele dom zegt dat de invoerwaarde ‘HelXlo’ moet zijn
  • input.value is ‘HelXlo’
    • geen actie ondernomen

Asynchroon

  • je drukt op X
  • input.value is ‘HelXlo’
  • je doet niets
  • de virtuele DOM zegt dat de invoerwaarde ‘Hallo’ moet zijn
    • react maakt input.value ‘Hallo’.

Later…

  • je setState({value: 'HelXlo'})
  • de virtuele DOM zegt dat de invoerwaarde ‘HelXlo’ moet zijn
    • react maakt input.value ‘HelXlo’
    • de browser verspringt de cursor naar het einde (het is een neveneffect van het instellen van .value)

Magie?

Ja, er is hier een beetje magie. Reageer-oproepen worden synchroon weergegeven na uw gebeurtenishandler. Dit is nodig om flikkeringen te voorkomen.


Antwoord 2, autoriteit 7%

Het gebruik van defaultValue in plaats van value loste het probleem voor mij op. Ik weet echter niet zeker of dit de beste oplossing is, bijvoorbeeld:

Van:

return React.DOM.input({value: valueToSet,
    onChange: this.changeHandler});

Aan:

return React.DOM.input({defaultValue: valueToSet,
    onChange: this.changeHandler});

JS Bin-voorbeeld

http://jsbin.com/xusefuyucu/edit?js,output


Antwoord 3, autoriteit 6%

Zoals eerder vermeld, zal dit een probleem zijn bij het gebruik van gecontroleerde componenten, omdat React de waarde van de invoer bijwerkt, in plaats van andersom (React onderschept het wijzigingsverzoek en werkt de status bij zodat deze overeenkomt).

FakeRainBrigand’s antwoord is geweldig, maar ik heb gemerkt dat het niet helemaal of een update synchroon of asynchroon is, ervoor zorgt dat de invoer zich op deze manier gedraagt. Als u iets synchroon doet, zoals het toepassen van een masker om de geretourneerde waarde te wijzigen, kan dit er ook toe leiden dat de cursor naar het einde van de regel springt. Helaas (?) is dit precies hoe React werkt met betrekking tot gecontroleerde ingangen. Maar het kan handmatig worden omzeild.

Er is een geweldige uitleg en discussie hieroverover de react github-problemen, die een link bevat naar een JSBin-oplossingdoor Sophie Alpert[die er handmatig voor zorgt dat de cursor blijft waar hij zou moeten zijn]

Dit wordt bereikt met behulp van een <Input>-component zoals deze:

var Input = React.createClass({
  render: function() {
    return <input ref="root" {...this.props} value={undefined} />;
  },
  componentDidUpdate: function(prevProps) {
    var node = React.findDOMNode(this);
    var oldLength = node.value.length;
    var oldIdx = node.selectionStart;
    node.value = this.props.value;
    var newIdx = Math.max(0, node.value.length - oldLength + oldIdx);
    node.selectionStart = node.selectionEnd = newIdx;
  },
});

Antwoord 4, autoriteit 3%

Dit is niet echt een antwoord, maar een mogelijke benadering om het probleem te verminderen. Het definieert een wrapper voor React-invoer die waarde-updates synchroon beheert via een lokale status-shim; en versies de uitgaande waarden zodat alleen de laatste geretourneerde van asynchrone verwerking ooit wordt toegepast.

Het is gebaseerd op wat werk van Stephen Sugden (https://github.com/grncdr) dat ik heb bijgewerkt voor modern React en verbeterd door versiebeheer van de waarden, waardoor de raceconditie wordt geëlimineerd.

Het is niet mooi 🙂

http://jsfiddle.net/yrmmbjm1/1/

var AsyncInput = asyncInput('input');

Hier is hoe componenten het moeten gebruiken:

var AI = asyncInput('input');
var Test = React.createClass({
    // the controlling component must track
    // the version
    change: function(e, i) {
      var v = e.target.value;
      setTimeout(function() {
        this.setState({v: v, i: i});
      }.bind(this), Math.floor(Math.random() * 100 + 50));
    },
    getInitialState: function() { return {v: ''}; },
    render: function() {
      {/* and pass it down to the controlled input, yuck */}
      return <AI value={this.state.v} i={this.state.i} onChange={this.change} />
    }
});
React.render(<Test />, document.body);

Een andere versie die probeert de impact op de code van de controlerende component minder onaangenaam te maken, is hier:

http://jsfiddle.net/yrmmbjm1/4/

Dat ziet er uiteindelijk zo uit:

var AI = asyncInput('input');
var Test = React.createClass({
    // the controlling component must send versionedValues
    // back down to the input
    change: function(e) {
      var v = e.target.value;
      var f = e.valueFactory;
      setTimeout(function() {
        this.setState({v: f(v)});
      }.bind(this), Math.floor(Math.random() * 100 + 50));
    },
    getInitialState: function() { return {v: ''}; },
    render: function() {
      {/* and pass it down to the controlled input, yuck */}
      return <AI value={this.state.v} onChange={this.change} />
    }
});
React.render(<Test />, document.body);

¯\_(ツ)_/¯


Antwoord 5, autoriteit 2%

Ik heb hetzelfde probleem gehad bij het gebruik van Reflux. Status is opgeslagen buiten een React Component, wat een soortgelijk effect veroorzaakte als het inpakken van setStatein een setTimeout.

@dule suggereerde dat we onze statusverandering synchroon en asynchroon tegelijk zouden moeten maken. Dus ik heb een HOC voorbereid die ervoor zorgt dat waardeverandering synchroon is – dus het is cool om invoer in te pakken die lijdt aan asynchrone statusverandering.

Opmerking: dit HOC werkt alleen voor componenten die vergelijkbaar zijn met de <input/>API, maar ik denk dat het eenvoudig is om het generieker te maken als er zo’n behoefte.

import React from 'react';
import debounce from 'debounce';
/**
 * The HOC solves a problem with cursor being moved to the end of input while typing.
 * This happens in case of controlled component, when setState part is executed asynchronously.
 * @param {string|React.Component} Component
 * @returns {SynchronousValueChanger}
 */
const synchronousValueChangerHOC = function(Component) {
    class SynchronousValueChanger extends React.Component {
        static propTypes = {
            onChange: React.PropTypes.func,
            value: React.PropTypes.string
        };
        constructor(props) {
            super(props);
            this.state = {
                value: props.value
            };
        }
        propagateOnChange = debounce(e => {
            this.props.onChange(e);
        }, onChangePropagationDelay);
        onChange = (e) => {
            this.setState({value: e.target.value});
            e.persist();
            this.propagateOnChange(e);
        };
        componentWillReceiveProps(nextProps) {
            if (nextProps.value !== this.state.value) {
                this.setState({value: nextProps.value});
            }
        }
        render() {
            return <Component {...this.props} value={this.state.value} onChange={this.onChange}/>;
        }
    }
    return SynchronousValueChanger;
};
export default synchronousValueChangerHOC;
const onChangePropagationDelay = 250;

En dan kan het op zo’n manier worden gebruikt:

const InputWithSynchronousValueChange = synchronousValueChangerHOC('input');

Door het HOC te maken, kunnen we het laten werken voor invoer, tekstgebied en waarschijnlijk ook voor anderen. Misschien is de naam niet de beste, dus als iemand van jullie een suggestie heeft om te verbeteren, laat het me weten 🙂

Er is een hack met debounce, want soms, als het typen heel snel was gedaan, verscheen de bug weer.


Antwoord 6

We hebben een soortgelijk probleem en in ons geval moeten we asynchrone statusupdates gebruiken.

Dus we gebruiken defaultValue, envoegen een keyparam toe aan de invoer die is gekoppeld aan het model dat de invoer weerspiegelt. Dit zorgt ervoor dat voor elk model de invoer gesynchroniseerd blijft met het model, maar als de daadwerkelijke modelwijzigingen een nieuwe invoer dwingen te worden gegenereerd.

Other episodes