React — это, безусловно, прорывная технология, которая упрощает создание сложных интерфейсов, но, как у любой абстракции, у неё есть свои мелкие проблемки и особенности. Я в своей практике столкнулся с четырьмя не очень очевидными вещами. Багами это назвать сложно — это просто особенности работы библиотеки. О них сегодня и поговорим.


Момент первый — в 0.14 поменялся алгоритм, решающий перерисовывать ли root-элемент или нет


Есть вот такой тонкий момент, не описанный в документации. До версии 0.14 вызов React.render() всегда перерисовывал то, что в него передано. Можно было сохранить ссылку на корневой элемент…

const element = <MyComponent />;

… и каждый вызов React.render(element) перерисовывал приложение.

В 0.14 работа с props улучшена, и алгоритм стал «умнее». Теперь, если приходит тот же объект, проверяют соответствие props и state уже отрисованному. Иными словами, сохранив ссылку на элемент, нужно или менять его state, или делать копию, или делать setProps() перед отрисовкой.

import React from "react";
import ReactDOM from "react-dom";

class MyComponent extends React.Component {

    render() {
        const date = Date.now();
        return <div>The time is {date}</div>;
    }
}

const app = document.getElementById("app");

const element = <MyComponent />;
const ref = ReactDOM.render(element, app);
ReactDOM.render(element, app);  //повторный вызов не запустит render()

ref.forceUpdate();  //а так запустит

Альтернативный вариант — всегда создавать новый элемент:

const app = document.getElementById("app");

const ref = ReactDOM.render(<MyComponent />, app);
ReactDOM.render(<MyComponent />, app);


Момент второй — если вы работаете с контролами, вызов ReactDOM.render() должен идти синхронно с событиями контрола


Если вы используете <input>, <select> и т. п., то после обработки событий изменения данных в них вы должны синхронно делать ReactDOM.render().

Предположим, у нас есть такой компонент, это обычный <select>, вызывающий какую-то внешнюю бизнес-логику при переключении.

import React from "react";
import ReactDOM from "react-dom";

class MyComponent extends React.Component {

    handleChange(e) {
        bizLogic1(e.currentTarget.value);
        bizLogic2(e.currentTarget.value);
        bizLogic3(e.currentTarget.value);
    }

    render() {
        return (
            <select size="3" value={this.props.selectedId} onChange={this.handleChange.bind(this)}>
                <option value="1">1</option>
                <option value="2">2</option>
                <option value="3">3</option>
                <option value="4">4</option>
                <option value="5">5</option>
            </select>
        );
    }
}

… и есть типичный для FLUX-приложений код, когда то, какая опция выбрана хранится отдельно, в переменной selectedId, и некая бизнес-логика bizLogic1-3, каждая требующая перерисовку приложения. Умный разработчик сделает перерисовку не три раза, на каждый вызов bizLogic*, а один, игнорируя повторные запросы, и перерисовывая приложение асинхронно.

let selectedId = 1;
const app = document.getElementById("app");

function bizLogic1(newValue) {
    selectedId = newValue;
    renderAfterwards();
}

function bizLogic2(newValue) {
    //...
    renderAfterwards();
}

function bizLogic3(newValue) {
    //...
    renderAfterwards();
}

let renderRequested = false;
function renderAfterwards() {
    if (!renderRequested) {
        //не смотря на то, что такой паттерн выглядит логичным, так делать нельзя
        //асинхронный render() заставит <select> мигать и прыгать скроллом
        window.setTimeout(() => {
            ReactDOM.render(<MyComponent selectedId={selectedId} />, app, () => {
                renderRequested = false;
            });
        }, 0);
    }
}

//initial render
ReactDOM.render(<MyComponent selectedId={selectedId} />, app);

Так вот, при таком подходе, при переключении select'а начинается смешная чехарда — поскольку наш <select> не имеет собственного состояния, то при его переключении происходит запуск события 'onchange', которое вызовет bizLogic1-3, но не поменяет props компонента и не вызовет его перерисовку в процессе обработки события. Однако браузер покажет это переключение, синяя полоска выделения перепрыгнет. Дальше React вернёт обратно правильное (с его точки зрения) предыдущее состояние <select'а>. Затем асинхронно сработает наш ReactDOM.render(), который вызовет перерисовку компонента, и синяя полоска выделения снова прыгнет, на этот раз уже туда, куда нужно.

Чтобы предотвратить такое поведение, перерисовывать UI с помощью ReactDOM.render() нужно сразу при обработке события.

С этой задачей хорошо справляется код на подобие паттерна Dispatcher из FLUX:

class MyComponent extends React.Component {

    handleChange(e) {
        dispatch({action: "ACTION_OPTION_SELECT", value: e.currentTarget.value});
    }

   ...
}

function dispatch(action) {
    if (action.action === "ACTION_OPTION_SELECT") {
        bizLogic1(action);
        bizLogic2(action);
        bizLogic3(action);
    }

    ReactDOM.render(<MyComponent selectedId={selectedId} />, app);
}

Две засады с тестами


Не все свойства объекта события можно подменить в TestUtils.Simulate.change

Первая проблема заключается в том, что, читая документацию на React TestUtils, создаётся впечатление, что можно сгенерировать поддельное событие и передать его тестируемому компоненту. На самом деле это действительно можно сделать, но на базе переданного события ReactUtils сделает своё, заменяя некоторые свойства. Это не написано в документации и неочевидно, но подделать target и currentTarget нельзя:

describe("MyInput", function() {

    it("refuses to accept DEF", function() {
        var ref = ReactDOM.render(<MyComponent value="abc" />, app);
        var rootNode = ReactDOM.findDOMNode(ref);

        var fakeInput = {value: "DEF"};
        TestUtils.Simulate.change(rootNode, {currentTarget: fakeInput});  //а вот не сработает, TestUtils выставит настоящий currentTarget
        expect($(rootNode).val()).toEqual("abc");  //тест неверен, т.к. handleChange увидит настоящий <input> в currentTarget
    });

});

Контролы (текстовые поля, чекбоксы итд) работают хитрее, чем вы думаете

Вторая частая засада с тестами близко связана с описанной выше проблемой номер 2 — при работе с контролами React после обработки событий сам восстанавливает значение, которое он считает текущим для контрола. Если вы где-то поменяли значение, но не вызвали перерисовку компонента, то после обработки события значение восстановится.

Поясняющий код:

import {$} from "commonjs-zepto";
import React from "react";
import ReactDOM from "react-dom";


class MyComponent extends React.Component {

    handleChange(e) {
        let value = e.currentTarget.value;
        if (!value.match(/[0-9]/)) bizLogic(value);
    }

    render() {
        return <input type="text" value={this.props.value} onChange={this.handleChange.bind(this)} />;
    }
}

const app = document.getElementById("app");

describe("MyInput", function() {

    it("refuses to accept digits", function() {
        var ref = ReactDOM.render(<MyComponent value="abc" />, app);
        var rootNode = ReactDOM.findDOMNode(ref);

        $(rootNode).val("abc1");  //руками поменяем значение
        TestUtils.Simulate.change(rootNode);  //handleChange увидит <input value="abc1">
        //здесь React сам вернет обратно значение "abc"
        expect($(rootNode).val()).toEqual("abc");  //тест пройдет успешно, но он неверен, т.к. value перезаписан React'ом
        //то есть при условии, что bizLogic не вызывает перерисовку компонента, впиши мы что угодно, все равно будет "abc"
    });
});

Спасибо за внимание, надеюсь, теперь ваши тесты будут гладкими и шелковистыми!

Комментарии (8)


  1. YChebotaev
    29.12.2015 23:42
    +3

    Статью можно назвать «как не стоит делать в react, если не хотите чтобы вам оторвал руки тимлид».


  1. erlyvideo
    30.12.2015 00:54

    Тесты это круто.

    Мы ещё не дошли до них в яваскрипте


    1. avdept
      30.12.2015 11:45
      -1

      Джаваскрипт, ява — это мотоцикл такой был.


      1. 404
        30.12.2015 12:36
        +1

        И на бензобаке у него было написано «JAWA»

        image


        1. avdept
          30.12.2015 16:23

          Java != Jawa


      1. Ashot
        30.12.2015 15:48
        +1

        Ума не приложу, неужели ещё всем не надоело холиварить на тему произношения?

        Название JavaScript было выбрано, т.к. язык Java был одним из, так сказать, вдохновителей при создании JavaScript. Название языку Java было выбрано в честь сорта кофе java, который по русски звучит как ява.
        Лично я не вижу ничего криминального в использовании произношения «яваскрипт»(как и просто «ява») на равне с англоязычным произношением «джаваскрипт».


      1. erlyvideo
        30.12.2015 23:15

        если вы не в курсе, то для этого звука традиционно есть оба произношения: йокающее и джокающее.

        Правильного нет, оба годятся.


  1. KeepYourMind
    30.12.2015 13:50

    XEK на встрече BeerJS мы обсуждали, что target переопределить можно. Ты точно уверен что нельзя?