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"
});
});
Спасибо за внимание, надеюсь, теперь ваши тесты будут гладкими и шелковистыми!
Автор: Acronis