КатегорииReactУроки

React урок 3 Архитектура приложения

В этой статье мы рассмотрим как структурировать сложный пользовательский интерфейс, выполненный из вложенных компонентов, рассмотрим потоки данных, зачем нужен propTypes и многое другое.

Проверка props

При создании компонентов, помните, что они могут быть составлены в более крупные компоненты и повторно использованы. Таким образом, хорошей практикой будет указать какие типы имеют реквизиты компонента. Это можно сделать путем объявления propTypes. Что нам это даст:

  • Документирование типов реквизитов;
  • Если все же мы перепутаем и укажем не тот тип, React нас предупредит сообщением в консоли.

Рассмотрим на примере:

...
import React, { Component } from 'react';
import { render } from 'react-dom';
class Greeter extends Component {
  render() {
    return (
      <h1>{this.props.salutation}</h1>
    )
  }
}
render(<Greeter salutation="Hello World" />, document.getElementById('root'));
...

Этот компонент принимает атрибут salutation и имеет тип строки, давайте укажем тип в коде явно и сделаем его обязательным:

import React, {Component, PropTypes} from 'react';
import {render} from 'react-dom';

class Greeter extends Component {
    render() {
        return (
            <h1>{this.props.salutation}</h1>
        )
    }
}
Greeter.propTypes = {
    salutation: PropTypes.string.isRequired
};
render(<Greeter salutation="Hello World" />, document.getElementById('root'));

Если убрать атрибут salutation, в консоле появиться предупреждение:

...
React.render(<Greeter />, document.getElementById('root'));
...

type_required

Значения по умолчанию

С помощью свойства класса defaultProps можно указать для не обязательных атрибутов значение по умолчанию.

Для этого давайте сначала сделаем атрибут salutation необязательным удалив  isRequired и добавим значение по умолчанию:

...
class Greeter extends Component {
    render() {
        return (
            <h1>{this.props.salutation}</h1>
        )
    }
}
Greeter.propTypes = {
    salutation: PropTypes.string
};

Greeter.defaultProps = {
    salutation: "Hello World"
}

render(<Greeter  />, document.getElementById('root'));
...

Теперь если не указан атрибут salutation ему будет присвоено значение по  умолчанию «Hello World».

Типы PropTypes

В PropTypes определен набор валидаторов:

JS примитивы 
PropTypes.array массив
PropTypes.bool true/false
PropTypes.func функция
PropTypes.number число
PropTypes.object объект
PropTypes.string строка
 Комбинированные типы
PropTypes.oneOfType Любой из типов, например:

PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.instanceOf(Message)
])

PropTypes.arrayOf Массив определенного типа, например:

PropTypes.arrayOf(PropTypes.number)

PropTypes.objectOf Объект определенного типа, например:

PropTypes.objectOf(PropTypes.number)

PropTypes.shape Объект определенной формы,  например:

PropTypes.shape({
color: PropTypes.string,
fontSize: PropTypes.number
})

Специальные типы
PropTypes.node Может быть любым из: число, строка, элемент или массив.
PropTypes.element Элемент
PropTypes.instanceOf Значение должно быть экземпляром данного класса: PropTypes.instanceOf(Message)
PropTypes.oneOf Перечисление возможно одно из значений PropTypes.oneOf([‘News’, ‘Photos’])

Канбан доска: PropTypes

Давайте добавим типы в наши компоненты:

import React, {Component, PropTypes} from 'react';
import List from './List';

class KanbanBoard extends Component {
    render() {..}
}

KanbanBoard.propTypes = {
    cards: PropTypes.arrayOf(PropTypes.object)    
};

export default KanbanBoard;
import React, { Component, PropTypes } from 'react';
import Card from './Card';

class List extends Component {
    render() {...);
    }
}

List.propTypes = {
    title: PropTypes.string.isRequired,
    cards: PropTypes.arrayOf(PropTypes.object)
};

export default List;
import React, {Component, PropTypes} from 'react';
import CheckList from './CheckList';
import marked from 'marked';

class Card extends Component {
    constructor() {...}
    toggleDetails() {...}
    render() {...}
}

Card.propTypes = {
    id: PropTypes.number,
    title: PropTypes.string,
    description: PropTypes.string,
    color: PropTypes.string,
    tasks: PropTypes.arrayOf(PropTypes.object)
};

export default Card;
import React, {Component, PropTypes} from 'react';

class CheckList extends Component {
    render() {..}
}

CheckList.propTypes = {
    cardId: PropTypes.number,
    tasks: PropTypes.arrayOf(PropTypes.object)
};

export default CheckList;

Пользовательские валидаторы

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

Канбан Доска: Создание пользовательского валидатора

Давай создадим валидатор для поля title компонента Card. В нем мы будем проверять title на длину если она превышает 80 символов будем возвращать ошибку:

import React, {Component, PropTypes} from 'react';
import CheckList from './CheckList';
import marked from 'marked';

let titlePropType = (props, propName, componentName) => {
    if (props[propName]) {
        let value = propName[propName];
        if (typeof value !== 'string' || value.length > 80) {
            return new Error(
                `${propName} in ${componentName} is longer than 80 characters`
            );
        }
    }
};

class Card extends Component {
    constructor() {...}
    toggleDetails() {...}
    render() {...}
}

Card.propTypes = {
    id: PropTypes.number,
    title: titlePropType,
    description: PropTypes.string,
    color: PropTypes.string,
    tasks: PropTypes.arrayOf(PropTypes.object)
};

export default Card;

custom_validator

Лучшие практики составления компонентов

Здесь мы рассмотрим лучшие практики создания React приложений путем композиции компонентов.

До сих пор мы видели что компоненты получают и управляют данными через реквизиты и состояние.

  • Реквизиты по сути это конфигурация компонентов. Компонент получат из через атрибуты от родительского компонента.
  • Состояние начинается со значений по умолчанию определенного в конструкторе, а затем изменяемые в основном через события генерируемых пользователем. Компонент сам управляет своим состоянием и каждый раз когда оно изменяется вызывается рендеринг компонента.

В React компонентах состояние не является обязательным. На самом деле, в большинстве приложений компоненты разделены на два типа: те у который есть состояние, а также те, которые не имеют внутреннего состояния и имеют дело только с отображением данных.

Чистые призваны только отобразить данные, что упрощает из тестирование и повторное использование.

Тем не менее, нам иногда нужно реагировать на действия пользователя, на ответ сервера.  Для этого нам нужно изменять состояние. Компоненты с внутренним состоянием, как правило выше по иерархии компонентов.

Хорошей практикой  является создание большинства компонентов без состояния. Если создать множество компонентов с состоянием, которые будет разбросаны по приложению, то вы получиться проблемы в поддержки и другие побочные эффекты в виде снижения предсказуемости, так как логика работы будет становиться все менее прозрачной.

Какие компоненты должны иметь состояние?

По началу бывает сложно определить какие компоненты должны иметь состояние, здесь мы рассмотрим список рекомендаций:

  • Определите возможные компоненты, которые будут измениться при каком либо действии пользователя.
  • Найдите общего владельца этих компонентов.
  • Общий владелец или другой компонент выше по иерархии должен иметь состояние.
  • Если вы не можете найти компонент для объединения и содержания состояния, создайте его специально для этих целей.

Давайте рассмотрим эти принципы на примере, создадим простое приложение контактов:

contact_app

Иерархия компонентов

ContactsApp: Главные компонент

SearchBar: Поиск по контактам

ContactList: Список контактов

ContactItem: Контакт

Данные списка контактов будут храниться в глобальной переменной. В реальном приложении данные вероятно будут загружены удаленно, но для простоты они будут описаны в коде:

import React, {Component, PropTypes} from 'react';
import {render} from 'react-dom';

class ContactsApp extends Component {
    render() {
        return (
            <div>
                <SearchBar />
                <ContactList contacts={this.props.contacts}/>
            </div>
        );
    }
}

ContactsApp.propTypes = {
    contacts: PropTypes.arrayOf(PropTypes.object)
};

class SearchBar extends Component {
    render() {
        return <input type="search" placeholder="search"/>
    }
}

class ContactList extends Component {
    render() {
        return (
            <ul>
                {this.props.contacts.map(
                    (contact) => <ContactItem key={contact.email}
                                              name={contact.name}
                                              email={contact.email} />
                )}
            </ul>
        )
    }
}

ContactList.propTypes = {
    contacts: PropTypes.arrayOf(PropTypes.object)
};

class ContactItem extends Component {
    render() {
        return <li>{this.props.name} - {this.props.email}</li>
    }
}

ContactItem.propTypes = {
    name: PropTypes.string.isRequired,
    email: PropTypes.string.isRequired
};

let contacts = [
    {name: "Cassio Zen", email: "cassiozen@gmail.com"},
    {name: "Dan Abramov", email: "gaearon@somewhere.com"},
    {name: "Pete Hunt", email: "floydophone@somewhere.com"},
    {name: "Paul O’Shannessy", email: "zpao@somewhere.com"},
    {name: "Ryan Florence", email: "rpflorence@somewhere.com"},
    {name: "Sebastian Markbage", email: "sebmarkbage@here.com"}
];


render(<ContactsApp contacts={contacts}/>, document.getElementById('root'));

Сейчас все компоненты являются «чистыми» они только отображают данные. Тем не менее нам надо реализовать работу фильтра. Давайте пройдемся по контрольному списку, что бы определить где мы будет хранить состояние.

  1. Какие компоненты будут изменять свое представление при фильтрации?

Это SearchBar, он будет отображать текст поиска и ContactList ответственный за отображение списка.

2. Общий владелец это — ContactsApp, значит он и будет содержать состояние.

import React, {Component, PropTypes} from 'react';
import {render} from 'react-dom';

class ContactsApp extends Component {
    constructor() {
        super();
        this.state = {
            filterText: ''
        }
    }

    render() {
        return (
            <div>
                <SearchBar filterText={this.state.filterText}/>
                <ContactList contacts={this.props.contacts}
                             filterText={this.state.filterText}/>
            </div>
        );
    }
}

ContactsApp.propTypes = {
    contacts: PropTypes.arrayOf(PropTypes.object)
};

class SearchBar extends Component {
    render() {
        return <input type="search" placeholder="search"
                        value={this.props.filterText}/>
    }
}

SearchBar.propTypes = {
    filterText: PropTypes.string.isRequired
};

class ContactList extends Component {
    render() {
        let filteredContacts = this.props.contacts.filter(
            (contact) => contact.name.indexOf(this.props.filterText) !== -1
        );
        return (
            <ul>
                {filteredContacts.map(
                    (contact) => <ContactItem key={contact.email}
                                              name={contact.name}
                                              email={contact.email}/>
                )}
            </ul>
        )
    }
}

ContactList.propTypes = {...};

class ContactItem extends Component {...}

ContactItem.propTypes = {..};

let contacts = [...];

render(<ContactsApp contacts={contacts}/>, document.getElementById('root'));

Теперь наше приложение имеет только один компонент с состоянием, остальные остались «чистыми». Сейчас если попробовать что написать в фильтре к сожалению у нас это не получиться, но в следующем разделе мы узнаем как могут взаимодействовать ребенок и родитель, что бы получить данные от ребенка к родителю.

Потоки данные и коммуникация компонентов

В React приложение, данные стекают с верху вниз по иерархии компонентов. Но в не тривиальных приложениях дочерние компоненты должны взаимодействовать с родительскими. Одним из способов достижения это возможности является обратный вызов, переданный из родительского компонента дочернему в качестве реквизита, давайте рассмотрим на примере ContactsApp:

Для начало создадим обратный вызов для изменения состояния:

...
class ContactsApp extends Component {
    constructor() {
        super();
        this.state = {
            filterText: ''
        }
    }

    handleUserInput(searchTerm) {
        this.setState({filterText: searchTerm});
    }

    render() {
        return (
            <div>
                <SearchBar filterText={this.state.filterText}
                           onUserInput={this.handleUserInput.bind(this)}/>
                <ContactList contacts={this.props.contacts}
                             filterText={this.state.filterText}/>
            </div>
        );
    }
}
...

Теперь давайте добавим обработку переданного обратного вызова в SearchBar:

...
class SearchBar extends Component {
    handleChange(event) {
        this.props.onUserInput(event.target.value)
    }

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

SearchBar.propTypes = {
    onUserInput: PropTypes.func.isRequired,
    filterText: PropTypes.string.isRequired
};
...

Теперь все готово, фильтр работает

contact_app_done

Жизненный цикл компонента

При создании компонентов, можно объявлять методы которые будут автоматически вызваны в некоторых случаях на протяжении всего жизненного цикла компонентов. Методы позволяют выполнять, какие либо действия например при создании или уничтожении компонента.

Жизненный цикл этапы и методы

Монтирование компонента

mounting

componentWillMount — вызывается один раз, до вызова рендеринг. Важный момент, изменение состояния внутри данного метода не вызывает повторный рендеринг.

render — отвечает за рендеринг компонента, с этим методом мы уже знакомы.

componentDidMount — выполняется один раз, после рендеринга. На данный момент компонент имеет представление в DOM.

Удаление компонента

unmound

componentWillUnmount — вызывается перед непосредственным удалением компонента из DOM. Этот метод может быть полезен для очистки: удаление слушателей событий, таймеров.

Изменение реквизитов

chage_props

componentWillReceiveProps —  Вызывается когда компонент получает новые реквизиты. Вызов this.setState() внутри метода, не вызовет повторный рендеринг.

shouldComponentUpdate — Специальный метод вызываемая перед функцией render, который дает возможность определить, требуется перерисовка или может быть пропущена. Что полезно для оптимизации производительности и будет подробно рассмотрено в 9 уроке.

componentWillUpdate — Вызывается непосредственно перед тем, как новые реквизиты или состояние получено. Любые изменения состояния через this.setState() не допускаются, так как эта функция должна строго использоваться для подготовки к предстоящему обновлению и не вызывать обновление.

componentDidUpdate — Вызывается сразу после обновления компонента когда изменения отобразились в DOM.

Изменение состояния

state_changes

Изменение состояний имей почти такой же жизненный цикл как у реквизитов, за одним исключением: там нет аналога метода componentWillReceiveProps. Если вам необходимо выполнить операции в ответ на изменение состояния, используйте componentWillUpdate.

Функции Жизненного цикла на практике: выборка данных

Чтобы проиллюстрировать использование методов жизненного цикла на практике, представьте, что вы хотите получать данные контактов удаленно. Поскольку эта глава посвящена стратегии и надлежащей практики для композиции компонентов, так же стоит отметить, что стоит избегать добавления выборки данных к компоненту, который уже имеет другую обязанность. Хорошая практика в место этого является создание нового компонента, единственная ответственность которого обмениваться данными с удаленным API, а также передача данных и функции обратного вызова в низ в качестве реквизитов. Некоторые называют такой компонент контейнером. Мы будем использовать идею компонента контейнера в нашем приложении Контакты. Для этого мы создадим новый компонент под названием ContactsAppContainer поверх ContactsApp.

Примечание: В следующем примере мы будем использовать новую функцию window.fetch, которая является более простым способом сделать запрос к серверу, чем XMLHttpRequest. Она может не поддерживаться в старых браузерах поэтому лучше использовать polyfill библиотеку из npm.

npm install —save whatwg-fetch

Давайте начнем с перемещения жестко закодированных данных в файл в формате JSON.

contacts.json

Теперь давайте создадим новый компонент:

import React, {Component, PropTypes} from 'react';
import {render} from 'react-dom';
import 'whatwg-fetch';

class ContactsAppContainer extends Component {
    constructor(){
        super();
        this.state={
            contacts: []
        };
    }
    componentDidMount(){
        fetch('./contacts.json')
            .then((response) => response.json())
            .then((responseData) => {
                this.setState({contacts: responseData});
            })
            .catch((error) => {
                console.log('Error fetching and parsing data', error);
            });
    }
    render(){
        return (
            <ContactsApp contacts={this.state.contacts} />
        );
    }
}

class ContactsApp extends Component {...}

ContactsApp.propTypes = {...};

class SearchBar extends Component {...}

SearchBar.propTypes = {...};

class ContactList extends Component {...}

ContactList.propTypes = {...};

class ContactItem extends Component {...}

ContactItem.propTypes = {...};


render(<ContactsAppContainer />, document.getElementById('root'));

Добавился только новый компонент, а остальные компоненты остались без изменения, плюс изменился рендеринг в конце листинга, теперь главным компонентом стал наш новый компонент ContactsAppContainer.

Немного поговорим об неизменяемости

Как вы уже знаете в React есть метод setState для изменения внутреннего состояния компонента. Будьте осторожны не используйте this.state на прямую. Существует ряд причин почему так не стоит делать. С одной стороны, путем манипулирования this.state непосредственно вы обходите механизмы управления React, что работает не только против парадигмы React, но и может быть опасно, потому что вызов setState() впоследствии может затереть ваши изменения. Более того манипулирование this.state сводит к минимуму возможные будущие улучшения производительности.

Об улучшении производительности вы узнаете в следующих статьях, но во многих случаях речь идет об сравнении объектов, что может быть довольно дорогостоящей операцией. Идея в том что бы заменять объект, а не изменять его, тогда достаточно будет сравнить объект по ссылке, что на порядок быстрее.

Неизменяемость в простых компонентах

Основная идея неизменяемости в замене объекта, а не в его изменении. Если вы не будите осторожны, вы можете не преднамеренно мутировать объекты, в место того, чтобы заменить их. Например, предположим, что у вас компонент с состоянием, который отображает данные о ваучерах на поездки авиакомпании (метод визуализации опущен в данном примере, потому что мы исследуем только состояние компонента):

import React, { Component } from 'react';
import { render } from 'react-dom';
class Voucher extends Component {
  constructor() {
    super(...arguments)
    this.state = {
      passengers:[
        'Simmon, Robert A.',
        'Taylor, Kathleen R.'
      ],
      ticket:{
        company: 'Dalta',
        flightNo: '0990',
        departure: {
          airport: 'LAS',
          time: '2016-08-21T10:00:00.000Z'
        },
        arrival: {
          airport: 'MIA',
          time: '2016-08-21T14:41:10.000Z'
        },
        codeshare: [
          {company:'GL', flightNo:'9840'},
          {company:'TM', flightNo:'5010'}
        ]
      }
    }
  }
  render() {...}
}

Теперь предположим, что вы захотели добавить пассажира в массив пассажиров. Если вы не будите осторожны, вы можете непреднамеренно мутировать состояние компонента непосредственно, например:

...
let updatedPassengers = this.state.passengers;
updatedPassengers.push('Mitchell, Vincent M.');
this.setState({passengers:updatedPassengers});
...

Проблема в этом коде, как вы уже догадались, в том, что в JS объекты и массивы передаются по ссылке. Это означает, что когда вы говорите updatedPassengers=this.state.passengers, вы не передаете копию массива, вы получаете ссылку на массив. Кроме того, при помощи метода push, вы мутируете массив пассажиров. Что бы такого не происходило, надо использовать методы возвращающие новый массив или объект. Например так:

...
// updatedPassengers is a new array, returned from concat
let updatedPassengers = this.state.passengers.concat('Mitchell, Vincent M.');
this.setState({passengers:updatedPassengers});
...

Здесь с помощью метода concat мы получили совершенно новый массив с новым пассажиром.

Есть также альтернативы для создания новых объектов без с мутациями в JS Object.assign.

Object.assign(target, source_1, …, source_n)

Сначала он копирует все перечисленные свойства к target из source_1, затем из source_2 и так далее.

Например, чтобы изменить flightNo, можно сделать так:

...
// updatedTicket is a new object with the original properties of this.state.ticket
// merged with the new flightNo.
let updatedTicket = Object.assign({}, this.state.ticket, {flightNo:'1010'});
this.setState({ticket:updatedTicket});
...

Примечание: Object.assign, является новым методом и не поддерживаться старыми браузерами, но хорошая новость в том что Babel (компилятор ES6 который мы используем вместе с Webpack) уже обеспечивает polyfill для старых браузеров.

Вложенные объекты

Хотя использование Object.assign может помочь, но в случае с вложенными объектами и массивами этот метод уже не кажется таким привлекательным.

Давайте посмотрим на примере:

let originalTicket={
  company: 'Dalta',
  flightNo: '0990',
  departure: {
    airport: 'LAS',
    time: '2016-08-21T10:00:00.000Z'
  },
  arrival: {
    airport: 'MIA',
    time: '2016-08-21T14:41:10.000Z'
  },
  codeshare: [
    {company:'GL', flightNo:'9840'},
    {company:'TM', flightNo:'5010'}
  ]
}

Если мы изменим flightNo с помощью Object.assign:

let newTicket = Object.assign({}, originalTicket, {flightNo '5690'})

update_flightNo

Как видно все хорошо, значение поменялось.

Однако, если изменить newTicket.arrival.airport=»MCO» в новом объекте, это же изменения отобразиться в оригинальном объекте.

arrival_chenge

Все потому что объекты и массивы передаются по ссылке.

Хорошая новость заключается в том, что есть простое решение: библиотека предоставляющая функции, которые помогают избежать мутации объектов, в том числе и сложных объектов.

React Immutability Helper

react-addons-update пакет обеспечивает функции обновления данных позволяющие избежать мутации данных.

Для начала, давайте его установим:

npm install –save react-addons-update

Затем можно импортировать:

import update from ‘react-addons-update’;

Метод обновление принимает два параметра. Первый объект или массив, который вы хотите обновить. Второй параметр является объектом, который описывает, где мутация должна происходить и какого рода мутации вы хотите сделать.  Давайте посмотрим на библиотеку в деле, предположим у нас есть простой объект описывающий студента:

...
let student = {name:'John Caster', grades:['A','C','B']}
...

что бы создать копию этого объекта добавлением нового элемента в массив grades, можно сделать так:

...
let newStudent = update(student, {grades:{$push: ['A']}})
...

Этот объект {grades:{$push: [‘A’]}} описывает действия, поле grades — это место где мы хотим что-то сделать, $push это команда добавить в массив новый элемент. Если мы захотим изменить массив полностью, то можно использовать $set в замен $push:

...
let newStudent = update(student, {grades:{$set: ['A','A','B']}})
...

А еще библиотека позволяет не обращать внимание на глубину вложенности свойств объекта, давайте посмотрим на примере с которым у нас уже были проблемы:

...
let originalTicket={
  company: 'Dalta',
  flightNo: '0990',
  departure: {
    airport: 'LAS',
    time: '2016-08-21T10:00:00.000Z'
  },
  arrival: {
    airport: 'MIA',
    time: '2016-08-21T14:41:10.000Z'
  },
  codeshare: [
    {company:'GL', flightNo:'9840'},
    {company:'TM', flightNo:'5010'}
  ]
}
...

информацию которую мы хотим изменить — airport, который достаточно глубоко засел, вот таким не хитрым способом можно его изменить:

let newTicket = update(originalTicket, {
                                          arrival: {
                                            airport: {$set: 'MCO'}
                                          }
                                       });

arrival_chenge_update

Индексы массивов 

Кроме того можно использовать индексы массива для указания где должно произойти изменения:

...
let newTicket = update(originalTicket,{
  codeshare: {
    0: { $set: {company:'AZ', flightNo:'7320'} }
  }
});
...

array_change

Доступные команды

$push — добавляет в конец массива один или несколько элементов.

let initialArray = [1, 2, 3];
let newArray = update(initialArray, {$push: [4]});
// => [1, 2, 3, 4]

$unshift — добавляет в начало массив одни или несколько элементов.

let initialArray = [1, 2, 3];
let newArray = update(initialArray, {$unshift: [0]});
// => [0,1, 2, 3]

$splice — аналог splice в JS:

let initial Array = [1, 2, 'a'];
let newArray = update(initialArray, {$splice: [[2,1,3,4]]});
// => [1, 2, 3, 4]

$set — заменяет значение полностью.

$merge — объединяет заданный объект с изменяемым:

let ob. = {a: 5, b: 3};
let newObj = update(obj, {$merge: {b: 6, c: 7}});
// => {a: 5, b: 6, c: 7}

$apply — это функция в которую передается изначальное значение, вы что с ним делаете и возвращаете изменения в качестве результата.

let obj = {a: 5, b: 3};
let newObj = update(obj, {b: {$apply: (value) => value*2 }});
// => {a: 5, b: 6}

Канбан доска: Добавим (немного) сложности

Для того, чтобы получать данные приложения на мы будем использовать удаленные запросы к API.

Получение данных карт от внешнего источника

Начнем с создания нового компонента в верхней части иерархии. Этот компонент контейнер будет использоваться для получения данных. Создадим новый файл KanbanBoardContainer.js

import React, { Component } from 'react';
import KanbanBoard from './KanbanBoard';

class KanbanBoardContainer extends Component {
    constructor(){
        super(...arguments);
        this.state = {
            cards:[],
        };
    }

    render() {
        return <KanbanBoard cards={cards} />
    }
}
export default KanbanBoardContainer;

Для запросов данных мы будем использовать window.fetch и для того что бы он работал в старых браузерах рекомендую установить polyfill:

npm install —save whatwg-fetch

Для удобства получения данных можно воспользоваться http://kanbanapi.pro-ract.com.

Давайте начнем:

import React, {Component} from 'react';
import KanbanBoard from './KanbanBoard';
import 'whatwg-fetch';

// If you're running the server locally, the URL will be, by default, localhost:3000
// Also, the local server doesn't need an authorization header.
const API_URL = 'http://kanbanapi.pro-react.com';
const API_HEADERS = {
    'Content-Type': 'application/json',
    Authorization: 'any-string-you-like'// The Authorization is not needed for local server
};

class KanbanBoardContainer extends Component {
    constructor() {
        super(...arguments);
        this.state = {
            cards: []
        };
    }

    componentDidMount() {
        fetch(API_URL + '/cards', {headers: API_HEADERS})
            .then((response) => response.json())
            .then((responseData) => {
                this.setState({cards: responseData});
            })
            .catch((error) => {
                console.log('Error fetching and parsing data', error);
            });
    }

    render() {
        return <KanbanBoard cards={this.state.cards}/>
    }
}
export default KanbanBoardContainer;

Вот мы и создали новый компонент контейнер, который извлекает данных удаленно. Теперь надо изменить главный компонент в файле App.js:

import React from 'react';
import {render} from 'react-dom';
import KanbanBoardContainer from './KanbanBoardContainer';

render(<KanbanBoardContainer />, document.getElementById('root'));

Если сейчас запустить проект, мы получим данных уже удаленно.

Методы управления даными

Теперь давайте создадим три функции для управления задачами: addTask, deleteTask и toggleTask. Еще мы должны передать эти три функции вниз по всей иерархии компонентов в качестве реквизита. И для этого сделаем небольшой трюк, в место того что бы создавать по одному реквизиту для каждой функции, мы создадим объект, который будет ссылаться на три функции и передавать его в качестве реквизита:

...
class KanbanBoardContainer extends Component {
    constructor() {...}

    componentDidMount() {...}

    addTask(cardId, taskName) {

    }

    deleteTask(cardId, taskId, taskIndex) {

    }

    toggleTask(cardId, taskId, taskIndex) {

    }

    render() {
        return <KanbanBoard cards={this.state.cards}
                            taskCallbacks={{
                                toggle: this.toggleTask.bind(this),
                                delete: this.deleteTask.bind(this),
                                add: this.addTask.bind(this)
                            }}/>
    }
}
export default KanbanBoardContainer;

Теперь надо передать taskCallbacks в низ по иерархии компонентов:

import React, {Component, PropTypes} from 'react';
import List from './List';

class KanbanBoard extends Component {
    render() {
        return (
            <div className="app">
                <List taskCallbacks={this.props.taskCallbacks} id='todo' title="To Do" cards={this.props.cards.filter((card) => card.status === "todo")}/>
                <List taskCallbacks={this.props.taskCallbacks} id='in-progress' title="In Progress" cards={this.props.cards.filter((card) => card.status === "in-progress")}/>
                <List taskCallbacks={this.props.taskCallbacks} id='done' title='Done' cards={this.props.cards.filter((card) => card.status === "done")}/>
            </div>
        );
    }
}

KanbanBoard.propTypes = {
    cards: PropTypes.arrayOf(PropTypes.object),
    taskCallbacks: PropTypes.object
};

export default KanbanBoard;
import React, { Component, PropTypes } from 'react';
import Card from './Card';

class List extends Component {
    render() {
        var cards = this.props.cards.map((card) => {
            return <Card key={card.id}
                         id={card.id}
                         title={card.title}
                         description={card.description}
                         color={card.color}
                         tasks={card.tasks}
                         taskCallbacks={this.props.taskCallbacks}
                    />
        });
        return (
            <div className="list">
                <h1>{this.props.title}</h1>
                {cards}
            </div>
        );
    }
}

List.propTypes = {
    title: PropTypes.string.isRequired,
    cards: PropTypes.arrayOf(PropTypes.object),
    taskCallbacks: PropTypes.object,
};

export default List;
...
class Card extends Component {
    constructor() {
        super(...arguments);
        this.state = {
            showDetails: false
        };
    }

    toggleDetails() {
        this.setState({showDetails: !this.state.showDetails});
    }

    render() {
        let cardDetails;
        if (this.state.showDetails) {
            cardDetails = (
                <div className="card__details">
                    <span dangerouslySetInnerHTML={{__html:marked(this.props.description)}} />
                    <CheckList cardId={this.props.id} tasks={this.props.tasks} taskCallbacks={this.props.taskCallbacks}/>
                </div>
            )
        }
        ...

        return (...)
    }
}

Card.propTypes = {
    id: PropTypes.number,
    title: titlePropType,
    description: PropTypes.string,
    color: PropTypes.string,
    tasks: PropTypes.arrayOf(PropTypes.object),
    taskCallbacks: PropTypes.object
};

export default Card;
import React, {Component, PropTypes} from 'react';

class CheckList extends Component {
    checkInputKeyPress(evt){
        if(evt.key === 'Enter'){
            this.props.taskCallbacks.add(this.props.cardId, evt.target.value);
            evt.target.value = '';
        }
    }

    render() {
        let tasks = this.props.tasks.map((task, taskIndex) => (
            <li key={task.id} className="checklist__task">
                <input type="checkbox"
                       defaultChecked={task.done}
                       onChange={
                            this.props.taskCallbacks.toggle.bind(null, this.props.cardId, task.id, taskIndex)
                       }
                />
                {task.name}
                <a href="#"
                   className="checklist__task--remove"
                   onClick={
                        this.props.taskCallbacks.delete.bind(null, this.props.cardId, task.id, taskIndex)
                   }
                />
            </li>
        ));
        return (
            <div className="checklist">
                <ul>{tasks}</ul>
                <input type="text"
                       className="checklist--add-task"
                       placeholder="Type then hit Enter to add a task"
                       onKeyPress={this.checkInputKeyPress.bind(this)}
                />
            </div>
        );
    }
}

CheckList.propTypes = {
    cardId: PropTypes.number,
    tasks: PropTypes.arrayOf(PropTypes.object),
    taskCallbacks: PropTypes.object
};

export default CheckList;

Манипулирование задачами

А теперь давайте добавим реализацию в наши методы манипуляции. Есть одна проблема: при разбиении исходного массива cards на группы по текущему состоянию, мы теряем изначальный индекс в массиве. Но можно воспользоваться методом findIndex, который поможет найти исходный индекс.

метод findIndex может не поддерживаться в старых браузерах, так что советую установить babel-polyfill

npm install —save babel-polyfill

Давайте начнем, первым делом возьмемся за метод deleteTask. Найдем текущий индекс в массиве, создадим новое состояния, применим его и отравим изменения на сервер:

...   
     deleteTask(cardId, taskId, taskIndex) {
        // Find the index of the card
        let cardIndex = this.state.cards.findIndex((card)=>card.id == cardId);
        // Create a new object without the task
        let nextState = update(this.state.cards, {
            [cardIndex]: {
                tasks: {$splice: [[taskIndex,1]] }
            }
        });
        // set the component state to the mutated object
        this.setState({cards:nextState});
        // Call the API to remove the task on the server
        fetch(`${API_URL}/cards/${cardId}/tasks/${taskId}`, {
            method: 'delete',
            headers: API_HEADERS
        });
    }
...

Теперь настало время toogleTask:

...
    toggleTask(cardId, taskId, taskIndex) {
        // Find the index of the card
        let cardIndex = this.state.cards.findIndex((card)=>card.id == cardId);
        // Save a reference to the task's 'done' value
        let newDoneValue;
        // Using the $apply command, you will change the done value to its opposite
        let nextState = update(this.state.cards, {
            [cardIndex]: {
                tasks: {
                    [taskIndex]: {
                        done: {
                            $apply: (done) => {
                                newDoneValue = !done;
                                return newDoneValue;
                            }
                        }
                    }
                }
            }
        });
        // set the component state to the mutated object
        this.setState({cards: nextState});
        // Call the API to toggle the task on the server
        fetch(`${API_URL}/cards/${cardId}/tasks/${taskId}`, {
            method: 'put',
            headers: API_HEADERS,
            body: JSON.stringify({done: newDoneValue})
        });
    }
...

Добавление новой задачи будет работать аналогичным образом, за одним исключением: Так как всем задачам нужен идентификатор, мы сделаем временный (текущая дата в миллисекундах), затем после сохранения мы получим от сервера новый идентификатор и мы заменим временный на новый.

...
    addTask(cardId, taskName) {
        // Find the index of the card
        let cardIndex = this.state.cards.findIndex((card)=>card.id == cardId);
        // Create a new task with the given name and a temporary ID
        let newTask = {id: Date.now(), name: taskName, done: false};
        // Create a new object and push the new task to the array of tasks
        let nextState = update(this.state.cards, {
            [cardIndex]: {
                tasks: {$push: [newTask]}
            }
        });
        // set the component state to the mutated object
        this.setState({cards: nextState});
        // Call the API to add the task on the server
        fetch(`${API_URL}/cards/${cardId}/tasks`, {
            method: 'post',
            headers: API_HEADERS,
            body: JSON.stringify(newTask)
        })
            .then((response) => response.json())
            .then((responseData) => {
                // When the server returns the definitive ID
                // used for the new Task on the server, update it on React
                newTask.id = responseData.id;
                this.setState({cards: nextState});
            });
    }
...

Базовая оптимизация работы с удаленным серверам

Когда пользователи, что-то делает он не хочет ждать, все должно работать мгновенно, и его не волнует что надо сходить на сервер, что-то туда положить и т.д. Потому мы с начало применяем изменения для клиента он видит их сразу же, а затем отправляем изменения на сервер. Но что произойдет если сервер выйдет из строя? По хорошему, надо сделать несколько попыток отправки. И если все же не удалось достучаться до сервера, хорошо бы было вернуться к предыдущему состоянию.

План такой, мы сохраняем начальное состояние и в случаи ошибки возвращаем все как было, что справедливо для всех трех методов:

...
// Keep a reference to the original state prior to the mutations
// in case you need to revert the optimistic changes in the UI
let prevState = this.state;
...

Тут мы сохранили предыдущее состояние, а теперь добавим обработку ошибок от сервера:

          fetch(...)
            .then((response) => {
                if (!response.ok) {
                    throw new Error("Server response wasn't OK");
                }
            })
            .catch((error) => {
                console.error("Fetch error:", error);
                this.setState(prevState);
            });

Что бы проверить, можно локально выключить доступ в Интернет и попытаться сделать какие-либо изменения.

Полный листинг компонента KanbanAppContainer:

import React, {Component} from 'react';
import KanbanBoard from './KanbanBoard';
import update from 'react-addons-update';
import 'whatwg-fetch';
import 'babel-polyfill';

// If you're running the server locally, the URL will be, by default, localhost:3000
// Also, the local server doesn't need an authorization header.
const API_URL = 'http://kanbanapi.pro-react.com';
const API_HEADERS = {
    'Content-Type': 'application/json',
    Authorization: 'any-string-you-like'// The Authorization is not needed for local server
};

class KanbanBoardContainer extends Component {
    constructor() {
        super(...arguments);
        this.state = {
            cards: []
        };
    }

    componentDidMount() {
        fetch(API_URL + '/cards', {headers: API_HEADERS})
            .then((response) => response.json())
            .then((responseData) => {
                this.setState({cards: responseData});
            })
            .catch((error) => {
                console.log('Error fetching and parsing data', error);
            });
    }

    addTask(cardId, taskName) {
        // Keep a reference to the original state prior to the mutations
        // in case you need to revert the optimistic changes in the UI
        let prevState = this.state;

        // Find the index of the card
        let cardIndex = this.state.cards.findIndex((card)=>card.id == cardId);
        // Create a new task with the given name and a temporary ID
        let newTask = {id: Date.now(), name: taskName, done: false};
        // Create a new object and push the new task to the array of tasks
        let nextState = update(this.state.cards, {
            [cardIndex]: {
                tasks: {$push: [newTask]}
            }
        });
        // set the component state to the mutated object
        this.setState({cards: nextState});
        // Call the API to add the task on the server
        fetch(`${API_URL}/cards/${cardId}/tasks`, {
            method: 'post',
            headers: API_HEADERS,
            body: JSON.stringify(newTask)
        })
            .then((response) => {
                if (!response.ok) {
                    throw new Error("Server response wasn't OK");
                }
                return response.json();
            })
            .then((responseData) => {
                // When the server returns the definitive ID
                // used for the new Task on the server, update it on React
                newTask.id = responseData.id;
                this.setState({cards: nextState});
            })
            .catch((error) => {
                console.error("Fetch error:", error);
                this.setState(prevState);
            });
    }

    deleteTask(cardId, taskId, taskIndex) {
        let prevState = this.state;
        // Find the index of the card
        let cardIndex = this.state.cards.findIndex((card)=>card.id == cardId);
        // Create a new object without the task
        let nextState = update(this.state.cards, {
            [cardIndex]: {
                tasks: {$splice: [[taskIndex, 1]]}
            }
        });
        // set the component state to the mutated object
        this.setState({cards: nextState});
        // Call the API to remove the task on the server
        fetch(`${API_URL}/cards/${cardId}/tasks/${taskId}`, {
            method: 'delete',
            headers: API_HEADERS
        })
            .then((response) => {
                if (!response.ok) {
                    throw new Error("Server response wasn't OK");
                }
            })
            .catch((error) => {
                console.error("Fetch error:", error);
                this.setState(prevState);
            });
    }

    toggleTask(cardId, taskId, taskIndex) {
        let prevState = this.state;
        // Find the index of the card
        let cardIndex = this.state.cards.findIndex((card)=>card.id == cardId);
        // Save a reference to the task's 'done' value
        let newDoneValue;
        // Using the $apply command, you will change the done value to its opposite
        let nextState = update(this.state.cards, {
            [cardIndex]: {
                tasks: {
                    [taskIndex]: {
                        done: {
                            $apply: (done) => {
                                newDoneValue = !done;
                                return newDoneValue;
                            }
                        }
                    }
                }
            }
        });
        // set the component state to the mutated object
        this.setState({cards: nextState});
        // Call the API to toggle the task on the server
        fetch(`${API_URL}/cards/${cardId}/tasks/${taskId}`, {
            method: 'put',
            headers: API_HEADERS,
            body: JSON.stringify({done: newDoneValue})
        })
            .then((response) => {
                if (!response.ok) {
                    throw new Error("Server response wasn't OK");
                }
            })
            .catch((error) => {
                console.error("Fetch error:", error);
                this.setState(prevState);
            });
    }

    render() {
        return <KanbanBoard cards={this.state.cards}
                            taskCallbacks={{
                                toggle: this.toggleTask.bind(this),
                                delete: this.deleteTask.bind(this),
                                add: this.addTask.bind(this)
                            }}/>
    }
}
export default KanbanBoardContainer;

Итоги

В этой главе мы узнали, как построить комплекс UIs с React. Мы узнали, что в React приложении данные текут всегда в одном направлении, от родителя к ребенку. Для связи, родитель может передавать функцию обратного вызова ребенку и с помощью нее ребенок может отчитаться о каких либо изменениях. Еще мы узнали, что компоненты гораздо проще применять повторно, если разделить их на две категории: динамические компоненты (с состоянием) и чистые компоненты (без состояния). Это хорошая практика, чтобы динамических компонентов было меньше чем чистых компонентов. И как правило динамические компоненты находятся в верхней части иерархии компонентов. И наконец мы узнали почему важно не допустить мутации данных при обновлении состояния и узнали о неоценимой помощи библиотеки react-addons-update в работе с вложенными объектами.

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

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