Тонкие места в React.js

в 11:49, , рубрики: React, ReactJS, Блог компании Acronis, Inc, грабли, Программирование

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

Тонкие места в React.js - 1

Момент первый — в 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"
    });
});

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

Автор: Acronis

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js