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

от автора

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"     }); });

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

ссылка на оригинал статьи http://habrahabr.ru/post/273995/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *