Теперь, когда вы уже постигли все основы создания пользовательских React-компонентов (и использования встроенных компонентов), применения технологии JSX (или работы без нее) для определения пользовательских интерфейсов, а также сборки и развертывания результатов своей работы, настало время потрудиться над созданием более совершенного приложения.
Оно будет называться Whinepad (что-то вроде карты отзывов) и позволит пользователям делать заметки и давать оценку всем дегустируемым винам (на самом деле это не обязательно должны быть вина, можно оценивать что угодно, о чем захочется оставить отзыв). Это должно быть CRUD-приложение, умеющее делать все, что от него ожидается, то есть создавать, считывать, обновлять и удалять (create, read, update и delete — CRUD). Оно также должно быть приложением, выполняемым на стороне клиента и сохраняющим на его же стороне свои данные. Цель его создания — изучение React, поэтому информация, не относящаяся к React (например, хранение, презентация), представлена в минимальном объеме.
В процессе работы над приложением вы узнаете:
• о сборке приложения из небольших пригодных к многократному использованию компонентов;
• об обмене данными между компонентами и организации их совместной работы.
Основываясь на стандартах, усвоенных в предыдущей главе, запустим процесс создания Whinepad — приложения выставления оценок, где оставляются заметки относительно новых вещей. Вы ведь не против, если в качестве экрана приветствия будет список уже прошедших оценку вещей в форме привлекательной таблицы? Это означает, что нужно заново воспользоваться компонентом <Excel> из главы 3.
Сначала скопируйте шаблонное приложение reactbook-boiler, на основе которого будет строиться работа (копию можете получить по адресу /), и переименуйте его в whinepad v0.0.1. Затем запустите сценарий watch, чтобы сборка могла производиться при внесении любых изменений:
$ cd ~/reactbook/whinepad\ v0.0.1/
$ sh scripts/watch.sh
Обновите в index.html содержимое тега title и установите идентификатор id="pad", чтобы данные элементы соответствовали новому приложению:
<!DOCTYPE html>
<html>
<head>
<title>Whinepad v.0.0.1</title>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="bundle.css">
</head>
<body>
<div id="pad"></div>
<script src="bundle.js"></script>
</body>
</html>
Воспользуемся JSX-версией компонента Excel (показанной в конце главы 4) и скопируем ее в файл js/source/components/Excel.js:
import React from 'react';
var Excel = React.createClass({
// Реализация...
render: function() {
return (
<div className="Excel">
{this._renderToolbar()}
{this._renderTable()}
</div>
);
},
// продолжение реализации ...
});
export default Excel
Здесь можно увидеть ряд отличий от прежнего вида Excel:
• инструкции import и export;
• у основной части компонента теперь имеется атрибут className="Excel", чтобы соответствовать недавно принятому соглашению.
По той же причине используются префиксы и во всех элементах CSS:
.Excel table {
border: 1px solid black;
margin: 20px;
}
.Excel th {
/* и т.д. */
}
/* и т.д. */
Теперь осталось только включить <Excel>, обновив основной файл app.js. Чтобы ничего не усложнять и не выходить за рамки клиентской стороны, воспользуемся хранилищем на стороне клиента (localStorage). Для начала установим ряд исходных значений:
var headers = localStorage.getItem('headers');
var data = localStorage.getItem('data');
if (!headers) {
headers = ['Title', 'Year', 'Rating', 'Comments'];
data = [['Test', '2015', '3', 'meh']];
}
Теперь передадим данные компоненту <Excel>:
ReactDOM.render(
<div>
<h1>
<Logo /> Welcome to Whinepad!
</h1>
<Excel headers={headers} initialData={data} />
</div>,
document.getElementById('pad')
);
И, немного подправив Logo.css, завершим работу над версией 0.0.1 (рис. 6.1).
Рис. 6.1. Whinepad v.0.0.1
Повторное использование компонента <Excel> позволило упростить начало работы, но этот компонент перегружен задачами. К нему лучше применить принцип «разделяй и властвуй» — разбить на небольшие пригодные для многократного использования компоненты. Например, кнопки должны стать самостоятельными компонентами, чтобы их можно было использовать вне контекста таблицы Excel.
Кроме того, приложение нуждается в других специализированных компонентах, например в виджете рейтинга, показывающего не число, а ряд звездочек.
Настроимся на работу с новым приложением и воспользуемся еще одним вспомогательным средством — исследователем компонентов.
Его задачи:
• предоставление возможности разработки и тестирования компонентов в изолированной среде. Зачастую использование компонента в приложении привязывает его к приложению, сокращая возможности многократного использования. Обособление компонента заставляет принимать более рациональные решения о его развязке с окружающей средой;
• предоставление возможности другим разработчикам вашей команды исследовать и повторно использовать существующие компоненты. Растет объем приложения, соответственно увеличивается количество разработчиков в команде. Чтобы свести к минимуму риск того, что два разработчика будут работать над совершенно одинаковыми компонентами, и поспособствовать повторному использованию компонента (что приведет к ускорению разработки приложений), разумнее держать все компоненты в одном месте (наряду с примерами замысла их применения).
Воспользуйтесь комбинацией клавиш Ctrl+C, чтобы остановить работу ранее запущенного сценария отслеживателя и получить возможность запуска нового отслеживателя. Скопируйте исходный минимально жизнеспособный продукт — minimum viable product (MVP) whinepad v.0.0.1 в новую папку whinepad:
$ cp -r ~/reactbook/whinepad\ v0.0.1/ ~/reactbook/whinepad
$ cd ~/reactbook/whinepad
$ sh scripts/watch.sh
> Watching js/source/
> Watching css/
js/source/app.js -> js/build/app.js
js/source/components/Excel.js -> js/build/components/Excel.js
js/source/components/Logo.js -> js/build/components/Logo.js
Sun Jan 24 11:10:17 PST 2016
Назовем средство исследования компонентов discovery.html и поместим его в корневой каталог:
$ cp index.html discovery.html
Целиком приложение загружать не нужно, поэтому вместо файла app.js воспользуемся файлом discover.js, который содержит все примеры компонентов. Следовательно, вместо принадлежащего приложению файла bundle.js требуется включить отдельный пакет с названием discover-bundle.js:
<!DOCTYPE html>
<html>
<!-- аналогично index.html -->
<body>
<div id="pad"></div>
<script src="discover-bundle.js"></script>
</body>
</html>
Попутное создание нового пакета особого труда не представляет, нужно лишь добавить к сценарию build.sh еще одну строку:
# пакет js
browserify js/build/app.js -o bundle.js
browserify js/build/discover.js -o discover-bundle.js
И наконец, добавим к средству исследования (js/build/discover.js) пример <Logo>:
'use strict';
import Logo from './components/Logo';
import React from 'react';
import ReactDOM from 'react-dom';
ReactDOM.render(
<div style={ {padding: '20px'} }>
<h1>Component discoverer</h1>
<h2>Logo</h2>
<div style={ {display: 'inline-block', background:
'purple'} }>
<Logo />
</div>
{/* сюда помещаются дополнительные компоненты... */}
</div>,
document.getElementById('pad')
);
Ваше новое средство исследования компонентов (рис. 6.2) является местом запуска новых компонентов по мере их создания. Приступим к работе и поэтапно создадим нужные нам компоненты.
Рис. 6.2. Средства исследования компонентов для приложения Whinepad
Скажу без преувеличения: кнопки нужны всем приложениям. Зачастую это красиво стилизованные обычные элементы <button>, но иногда в качестве кнопки должна выступать гиперссылка <a> (именно этот элемент был необходим в главе 3 для создания кнопок скачивания файлов). А что, если создать новую привлекательную кнопку Button, принимающую необязательное свойство href? Если оно присутствует, то в основе отображения будет фигурировать гиперссылка <a>.
В духе разработки на основе тестирования — test-driven development (TDD) — можно приступить к работе, выбрав обратное направление и определив пример использования компонента в средстве discovery.js.
До:
import Logo from './components/Logo';
{/* ... */}
{/* сюда помещаются дополнительные компоненты... */}
После:
import Button from './components/Button';
import Logo from './components/Logo';
{/* ... */}
<h2>Buttons</h2>
<div>Button with onClick: <Button onClick={() =>
alert('ouch')}>Click me</Button></div>
<div>A link: <Button href="
me</Button></div>
<div>Custom class name: <Button className="custom">I do
nothing</Button></div>
{/* сюда помещаются дополнительные компоненты... */}
(А нельзя ли тогда назвать это разработкой на основе исследований — discovery-driven development, DDD?)
Обратили внимание на новый шаблон () => alert(‘ouch’)? Это пример использования функции стрелки из спецификации ES2015.
Вот другие варианты применения этой функции:
• выражение () => {} является пустой функцией (наподобие function() {});
• выражение (what, not) => console.log(what, not) является функцией с параметрами;
• выражение (a, b) => { var c = a + b; return c;} используется, когда в теле функции больше одной строки, в этом случае нужны фигурные скобки {};
• выражение let fn = arg => {} применяется при получении только одного аргумента, круглые скобки () можно не использовать.
Согласно требованиям принятого соглашения код, определяющий стиль компонента <Button>, должен размещаться в файле /css/components/Button.css. В нем нет ничего необычного, там просто свойства CSS, которые придадут кнопке дополнительную привлекательность. Рассмотрим их здесь полностью и договоримся, что не станем тратить время на код CSS для других компонентов:
.Button {
background-color: #6f001b;
border-radius: 28px;
border: 0;
box-shadow: 0px 1px 1px #d9d9d9;
color: #fff;
cursor: pointer;
display: inline-block;
font-size: 18px;
font-weight: bold;
padding: 5px 15px;
text-decoration: none;
transition-duration: 0.1s;
transition-property: transform;
}
.Button:hover {
transform: scale(1.1);
}
Теперь рассмотрим полную версию кода в файле /js/source/components/Button.js:
import classNames from 'classnames';
import React, {PropTypes} from 'react';
function Button(props) {
const cssclasses = classNames('Button', props.className);
return props.href
? <a {...props} className={cssclasses} />
: <button {...props} className={cssclasses} />;
}
Button.propTypes = {
href: PropTypes.string,
};
export default Button
Код этого компонента изложен кратко, но при этом полон новых идей и передового синтаксиса. Проведем исследования, начиная с самой верхней строчки!
import classNames from 'classnames';
Пакет classnames (устанавливается с помощью команды npm i --save-dev classnames) предоставляет полезную функцию для работы с именами классов CSS. Задача использования вашим компонентом своих собственных классов при сохранении достаточной гибкости, позволяющей проводить настройки посредством имен классов, передаваемых родительским компонентом, ставится довольно часто. Ранее в пакете дsополнительных средств React для этого была специальная утилита, но она вышла из употребления, и ее место занял этот более удачный пакет от стороннего производителя. Из данного пакета используется только одна функция:
const cssclasses = classNames('Button', props.className);
Она предназначена для объединения имени класса Button с любыми (если таковые будут) именами классов, переданными в виде свойств при создании компонента (рис. 6.3).
Рис. 6.3. <Button> с пользовательским именем класса
Объединить имена классов можно и самостоятельно, но classnames очень маленький пакет, который удобно применять для решения этой часто встречающейся задачи. Он также позволяет выполнять такую полезную операцию, как условное присваивание имен классам:
<div className={classNames({
'mine': true, // безусловное присваивание
'highlighted': this.state.active,
// присваивание в зависимости
// от состояния компонента…
'hidden': this.props.hide, // ... или его
// свойств
})} />
Код:
import React, {PropTypes} from 'react';
является просто лаконичным способом следующего объявления:
import React from 'react';
const PropTypes = React.PropTypes;
Когда компонент настолько прост (и такое бывает!) и не нуждается в поддержке состояния, для его определения можно воспользоваться функцией. Тело этой функции станет заменой вашего метода render(). Функция в качестве своего первого аргумента получает все свойства, поэтому в ее теле используется не this.props.href, как в версии класса или объекта, а props.href.
Используя форму определения с помощью стрелки, эту функцию можно переписать следующим образом:
const Button = props => {
// ...
};
И если есть стойкое желание выразить тело одной строкой, можно даже отказаться от использования {}, ; и return:
const Button = props =>
props.href
? <a {...props} className={classNames('Button',
props.className)} />
: <button {...props} className={classNames('Button',
props.className)} />
Если используются синтаксис классов, специфицированный в ES2015, или функциональные компоненты, вы должны после определения компонента определить любые свойства (такие как propTypes) как статичные. Иными словами, до (ES3, ES5):
var PropTypes = React.PropTypes;
var Button = React.createClass({
propTypes: {
href: PropTypes.string
},
render: function() {
/* отображение */
}
});
После (класс ES2015):
import React, {Component, PropTypes} from 'react';
class Button extends Component {
render() {
/* отображение */
}
}
Button.propTypes = {
href: PropTypes.string,
};
То же самое при работе функционального компонента, не использующего состояние:
import React, {Component, PropTypes} from 'react';
const Button = props => {
/* отображение */
};
Button.propTypes = {
href: PropTypes.string,
};
На данный момент компонент <Button> нас вполне устраивает. Перейдем к выполнению другой задачи, важной для любого приложения, в котором вводятся данные, — к работе с формами. Разработчиков приложений редко устраивает внешний вид встроенных в браузер форм ввода данных, что побуждает их создавать собственные версии. Не исключение в этом плане и приложение Whinepad.
Создадим универсальный компонент <FormInput> с методом getValue(), предоставляющим вызывающему коду доступ к записи в поле ввода. В зависимости от значения свойства type этот компонент должен делегировать создание поля ввода более узкоспециализированным компонентам, например компонентам ввода данных <Suggest>, <Rating> и т.д.
Начнем с компонентов более низкого уровня, которым нужны лишь методы render() и getValue().
Необычные поля ввода с автопредложением (известные как поля с упреждающим заполнением) встречаются в веб-приложениях довольно часто, но не будем ничего усложнять (рис. 6.4) и позаимствуем то, что уже предлагается браузером, а именно HTML-элемент <datalist>.
Рис. 6.4. Компонент ввода данных <Suggest> в работе
Прежде всего обновим наше исследовательское приложение:
<h2>Suggest</h2>
<div><Suggest options={['eenie', 'meenie', 'miney', 'mo']} /></div>
Теперь приступим к реализации компонента в файле /js/source/components/Suggest.js:
import React, {Component, PropTypes} from 'react';
class Suggest extends Component {
getValue() {
return this.refs.lowlevelinput.value;
}
render() {
const randomid = Math.random().toString(16).substring(2);
return (
<div>
<input
list={randomid}
defaultValue={this.props.defaultValue}
ref="lowlevelinput"
id={this.props.id} />
<datalist id={randomid}>{
this.props.options.map((item, idx) =>
<option value={item} key={idx} />
)
}</datalist>
</div>
);
}
}
Suggest.propTypes = {
options: PropTypes.arrayOf(PropTypes.string),
};
export default Suggest
Как видно из предыдущего кода, в этом компоненте нет абсолютно ничего особенного, он является всего лишь оболочкой вокруг прикрепленных к нему (с использованием randomid) элементов <input> и <datalist>.
Что касается нового ES-синтаксиса, можно увидеть появившуюся возможность использования реструктуризующего присваивания для назначения переменной более одного свойства:
// до
import React from 'react';
const Component = React.Component;
const PropTypes = React.PropTypes;
// после
import React, {Component, PropTypes} from 'react';
А что касается нововведений, появившихся в React, можно увидеть использование атрибута ref.
Атрибут ref. Рассмотрим следующий код:
<input ref="domelement" id="hello">
/* и чуть ниже... */
console.log(this.refs.domelement.id === 'hello'); // true
Атрибут ref позволяет присваивать имя конкретному экземпляру компонента React, после чего ссылаться на него по этому имени. Атрибут ref можно добавить к любому компоненту, но обычно он используется для ссылки на DOM-элементы, когда действительно нужно обратиться к исходной DOM-модели. Зачастую использование ref является обходным маневром и должны быть другие способы выполнения той же самой задачи.
В предыдущем случае нужно было получить возможность забирать при необходимости значение поля ввода <input>. Учитывая, что изменения в поле ввода могут считаться изменениями состояния компонента, вы можете переключиться на использование с целью отслеживания ситуации объекта this.state:
class Suggest extends Component {
constructor(props) {
super(props);
this.state = {value: props.defaultValue};
}
getValue() {
return this.state.value; // 'ref' больше не используется
}
render() {}
}
Тогда полю ввода <input> атрибут ref больше не понадобится, но для обновления состояния нужен будет обработчик события onChange:
<input
list={randomid}
defaultValue={this.props.defaultValue}
onChange={e => this.setState({value: e.target.value})}
id={this.props.id} />
Обратите внимание на применение в методе constructor() выражения this.state = {};: это замена для метода getInitialState(), который использовался до появления ES6.
Наше приложение предназначено для оценки вещей, подвергающихся испытаниям. Проще всего давать оценку, используя показатель рейтинга в виде ряда звезд, скажем, от одной до пяти.
Этот компонент, предназначенный для многократного использования, должен быть настроен:
• на применение любого количества звезд (их исходное количество равно пяти, но почему бы не использовать, скажем, 11 звезд?);
• применение компонента только для чтения, поскольку вам не захочется, чтобы важные рейтинговые данные могли измениться из-за случайного щелчка на звездах.
Протестируйте компонент в нашем исследовательском приложении (рис. 6.5):
<h2>Rating</h2>
<div>No initial value: <Rating /></div>
<div>Initial value 4: <Rating defaultValue={4} /></div>
<div>This one goes to 11: <Rating max={11} /></div>
<div>Read-only: <Rating readonly={true}
defaultValue={3} /></div>
Рис. 6.5. Виджет для отображения рейтинга
Минимальные потребности реализации включают установки типов свойств и обслуживаемого состояния:
import classNames from 'classnames';
import React, {Component, PropTypes} from 'react';
class Rating extends Component {
constructor(props) {
super(props);
this.state = {
rating: props.defaultValue,
tmpRating: props.defaultValue,
};
}
/* другие методы... */
}
Rating.propTypes = {
defaultValue: PropTypes.number,
readonly: PropTypes.bool,
max: PropTypes.number,
};
Rating.defaultProps = {
defaultValue: 0,
max: 5,
};
export default Rating
Названия свойств говорят сами за себя: max представляет собой количество звезд, а readonly делает виджет, как нетрудно догадаться, доступным только для чтения. В состоянии фигурируют rating, текущее значение количества присвоенных звезд, и tmpRating, значение, используемое при перемещении пользователем указателя мыши над компонентом, когда еще не выражена готовность щелкнуть кнопкой мыши и отправить выбранный показатель рейтинга.
Далее следуют вспомогательные методы, занимающиеся обслуживанием актуальности состояния в ходе работы пользователя с компонентом:
getValue() { // предоставляется всеми нашими полями ввода
return this.state.rating;
}
setTemp(rating) { // при проходе над элементом указателя мыши
this.setState({tmpRating: rating});
}
setRating(rating) { // при щелчке
this.setState({
tmpRating: rating,
rating: rating,
});
}
reset() { // возвращение к реальному рейтингу
// при уводе указателя мыши
this.setTemp(this.state.rating);
}
componentWillReceiveProps(nextProps) {
// реагирование на внешние изменения
this.setRating(nextProps.defaultValue);
}
И наконец, метод render(). У него имеются:
• цикл, создающий звезды от одной и до this.props.max. Все звезды отображаются с помощью символа с кодом ☆. Когда применяется стиль RatingOn, звезды становятся желтыми;
• скрытое поле ввода для работы в качестве настоящего, принадлежащего форме поля ввода (позволяет задавать значение в общем виде — подобно любому полю ввода <input>):
render() {
const stars = [];
for (let i = 1; i <= this.props.max; i++) {
stars.push(
<span
className={i <= this.state.tmpRating ?
'RatingOn' : null}
key={i}
onClick={!this.props.readonly &&
this.setRating.bind(this, i)}
onMouseOver={!this.props.readonly &&
this.setTemp.bind(this, i)}
>
☆
</span>);
}
return (
<div
className={classNames({
'Rating': true,
'RatingReadonly': this.props.readonly,
})}
onMouseOut={this.reset.bind(this)}
>
{stars}
{this.props.readonly || !this.props.id
? null
: <input
type="hidden"
id={this.props.id}
value={this.state.rating} />
}
</div>
);
}
Можно заметить еще одну особенность — использование метода привязки bind. В цикле отображения звезд имеет смысл запомнить текущее значение i, но зачем используется выражение this.reset.bind(this)? Его необходимо задействовать при использовании синтаксиса классов, определенного в ES. При создании привязки можно выбрать один из трех вариантов:
• как видно из предыдущего примера, выражение this.method.bind(this);
• функция стрелки, выполняющей автопривязку, например (_unused_event_) => this.method();
• однократная привязка в конструкторе.
Для использования третьего варианта нужно сделать следующее:
class Comp extents Component {
constructor(props) {
this.method = this.method.bind(this);
}
render() {
return <button onClick={this.method}>
}
}
Одно из преимуществ заключается в том, что вы, как и раньше, используете ссылку this.method (как с компонентами, созданными с помощью React.createClass({})). Еще одно преимущество заключается в том, что метод привязывается раз и навсегда, в отличие от привязки при каждом вызове render(). К недостаткам можно отнести наличие более объемного шаблона в контроллере.
Настал черед универсального компонента <FormInput>, способного создавать различные компоненты ввода на основе заданных свойств. Все создаваемые компоненты ввода ведут себя согласованно (когда нужно, предоставляется метод getValue()).
Протестируйте компонент в нашем исследовательском приложении (рис. 6.6):
<h2>Form inputs</h2>
<table><tbody>
<tr>
<td>Vanilla input</td> // Обычный ввод
<td><FormInput /></td>
</tr>
<tr>
<td>Prefilled</td> // С предварительным заполнением
<td><FormInput defaultValue="it's like a default" /></td>
</tr>
<tr>
<td>Year</td> // Год
<td><FormInput type="year" /></td>
</tr>
<tr>
<td>Rating</td> // Рейтинг
<td><FormInput type="rating" defaultValue={4} /></td>
</tr>
<tr>
<td>Suggest</td> // Предложение
<td><FormInput
type="suggest"
options={['red', 'green', 'blue']}
defaultValue="green" />
</td>
</tr>
<tr>
<td>Vanilla textarea</td> // Обычная текстовая область
<td><FormInput type="text" /></td>
</tr>
</tbody></table>
Рис. 6.6. Элементы ввода, используемые в форме
Реализация <FormInput> (js/source/components/FormInput.js) требует для корректности обычного шаблонного использования import, export и propTypes:
import Rating from './Rating';
import React, {Component, PropTypes} from 'react';
import Suggest from './Suggest';
class FormInput extends Component {
getValue() {}
render() {}
}
FormInput.propTypes = {
type: PropTypes.oneOf(['year', 'suggest', 'rating', 'text',
'input']),
id: PropTypes.string,
options: PropTypes.array,
// как в вариантах автозаполнения <option>
defaultValue: PropTypes.any,
};
export default FormInput
Метод render() представляет собой одну большую инструкцию switch, которая делегирует создание отдельно взятого элемента ввода узкоспециализированному компоненту или же прибегает к использованию встроенных DOM-элементов <input> и <textarea>:
render() {
const common = { // свойства, применимые для всех компонентов
id: this.props.id,
ref: 'input',
defaultValue: this.props.defaultValue,
};
switch (this.props.type) {
case 'year':
return (
<input
{...common}
type="number"
defaultValue={this.props.defaultValue ||
new Date().getFullYear()} />
);
case 'suggest':
return <Suggest {...common}
options={this.props.options} />;
case 'rating':
return (
<Rating
{...common}
defaultValue={parseInt(this.props.defaultValue,
10)} />
);
case 'text':
return <textarea {...common} />;
default:
return <input {...common} type="text" />;
}
}
Заметили использование свойства ref? Оно может оказаться полезным при присваивании значений элемента ввода:
getValue() {
return 'value' in this.refs.input
? this.refs.input.value
: this.refs.input.getValue();
}
Здесь this.refs.input является ссылкой на исходный элемент DOM-модели. Для обычных DOM-элементов вроде <input> и <textarea> вы получаете DOM-значение с помощью this.refs.input.value (как при использовании традиционно применяемого в DOM выражения document.getElementById('some-input').value). В иных случаях для необычных пользовательских компонентов ввода (вроде <Suggest> и <Rating>) вы добираетесь до их собственных методов getValue().
Теперь у вас имеются:
• пользовательские компоненты ввода (например, <Rating>);
• встроенные элементы ввода (например, <textarea>);
• <FormInput> — фабрика, создающая компоненты ввода на основе значения свойства type.
Настало время заставить их всех работать вместе в составе компонента <Form> (рис. 6.7).
Компонент <Form> должен быть пригодным к многократному использованию, также в нем не должно быть ничего жестко заданного относительно приложения для составления рейтинга вин. (Если копнуть глубже, ничего не должно быть жестко задано относительно вина, чтобы приложение могло быть перенацелено на оценку любой другой категории вещей.) Компонент <Form> может быть настроен через массив полей, где каждое поле определяется:
• типом ввода (по умолчанию "input");
• идентификатором, чтобы это поле ввода потом можно было найти;
• надписью, чтобы ее можно было поставить рядом с полем ввода;
• дополнительными вариантами для передачи их полю ввода с автопредложением.
Рис. 6.7. Формы
Компонент <Form> также получает отображение исходных значений; он может отображаться в режиме только для чтения, чтобы пользователь не мог воспользоваться редактированием:
import FormInput from './FormInput';
import Rating from './Rating';
import React, {Component, PropTypes} from 'react';
class Form extends Component {
getData() {}
render() {}
}
Form.propTypes = {
fields: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
type: PropTypes.string,
options: PropTypes.arrayOf(PropTypes.string),
})).isRequired,
initialData: PropTypes.object,
readonly: PropTypes.bool,
};
export default Form
Обратите внимание на использование выражения PropTypes.shape. Оно позволяет вам конкретизировать свои ожидания относительно содержимого отображения. Оно более строгое, чем обобщение вроде fields: PropTypes.arrayOf(PropTypes.object) или fields: PropTypes.array и, конечно же, позволит отловить больше ошибок еще до того, как они проявятся (в самом начале использования ваших компонентов другими разработчиками).
Свойство initialData является отображением вида {имя_поля: значение}; это также формат данных, возвращенных принадлежащим компоненту методом getData().
Пример использования компонента <Form> для исследовательского приложения имеет следующий вид:
<Form
fields={[
{label: 'Rating', type: 'rating', id: 'rateme'},
{label: 'Greetings', id: 'freetext'},
]}
initialData={ {rateme: 4, freetext: 'Hello'} } />
Теперь вернемся к реализации. Компоненту нужны методы getData() и render():
getData() {
let data = {};
this.props.fields.forEach(field =>
data[field.id] = this.refs[field.id].getValue()
);
return data;
}
Как видите, вам надо лишь пройтись в цикле по всем методам getValue(), принадлежащим компонентам ввода данных, воспользовавшись для этого свойствами ref, установленными в методе render().
Сам метод render() особой сложностью не отличается, и в нем не используются какой-либо не встречавшийся вам до этого синтаксис или другие шаблоны:
render() {
return (
<form className="Form">{this.props.fields.map(field => {
const prefilled = this.props.initialData &&
this.props.initial
Data[field.id];
if (!this.props.readonly) {
return (
<div className="FormRow" key={field.id}>
<label className="FormLabel"
htmlFor={field.id}>{field.label}:</label>
<FormInput {...field} ref={field.id}
defaultValue={prefilled} />
</div>
);
}
if (!prefilled) {
return null;
}
return (
<div className="FormRow" key={field.id}>
<span className="FormLabel">{field.label}:</span>
{
field.type === 'rating'
? <Rating readonly={true}
defaultValue={parseInt(prefilled, 10)} />
: <div>{prefilled}</div>
}
</div>
);
}, this)}</form>
);
}
Рядом с каждой строкой в таблице данных должны быть действия (actions) (рис. 6.8), которые можно предпринять в отношении этой строки: удаление, редактирование, просмотр (когда не вся информация может поместиться в строке).
Рис. 6.8. Компонент Actions
Тестируемый в исследовательском средстве Discovery компонент Actions имеет следующий вид:
<h2>Actions</h2>
<div><Actions onAction={type => alert(type)} /></div>
А вот его весьма простая реализация:
import React, {PropTypes} from 'react';
const Actions = props =>
<div className="Actions">
<span
tabIndex="0"
className="ActionsInfo"
title="More info"
onClick={props.onAction.bind(null,
'info')}>ℹ</span>
<span
tabIndex="0"
className="ActionsEdit"
title="Edit"
onClick={props.onAction.bind(null,
'edit')}>✐</span>
<span
tabIndex="0"
className="ActionsDelete"
title="Delete"
onClick={props.onAction.bind(null, 'delete')}>x</span>
</div>
Actions.propTypes = {
onAction: PropTypes.func,
};
Actions.defaultProps = {
onAction: () => {},
};
export default Actions
Actions — очень простой компонент, требующий лишь отображения и не поддерживающий состояния. Поэтому он может быть определен как функциональный компонент, не имеющий состояния, с использованием функции стрелки с самым емким из всех возможных синтаксисом: без возвращаемого значения, без {}, без инструкции function (в прежние времена такую форму записи трудно было бы даже посчитать функцией!).
Код, вызывающий компонент, может подписаться на событие «действия», используя свойство onAction. Это простая схема для дочернего компонента, чтобы оповестить свой родительский компонент об изменении внутри компонента. Как видите, применять пользовательские события (например, на случай действия — onAction, на случай нападения пришельцев — onAlienAttack и т.д.) проще простого.
Следующий на очереди универсальный компонент диалога, предназначенный для сообщений или для использования диалоговых окон любого вида (вместо alert()) (рис. 6.9). Например, все формы добавления и редактирования данных могут быть представлены в модальном диалоговом окне в верхней части таблицы данных.
Использование:
<Dialog
header="Out-of-the-box example"
onAction={type => alert(type)}>
Hello, dialog!
</Dialog>
<Dialog
header="No cancel, custom button"
hasCancel={false}
confirmLabel="Whatever"
onAction={type => alert(type)}>
Anything goes here, see:
<Button>A button</Button>
</Dialog>
Рис. 6.9. Диалоги
Реализация очень похожа на реализацию компонента Actions — без состояния (нужен лишь метод render()) и с функцией обратного вызова onAction, инициируемой при нажатии пользователем кнопки в нижней части диалогового окна:
import Button from './Button';
import React, {Component, PropTypes} from 'react';
class Dialog extends Component {
}
Dialog.propTypes = {
header: PropTypes.string.isRequired,
confirmLabel: PropTypes.string,
modal: PropTypes.bool,
onAction: PropTypes.func,
hasCancel: PropTypes.bool,
};
Dialog.defaultProps = {
confirmLabel: 'ok',
modal: false,
onAction: () => {},
hasCancel: true,
};
export default Dialog
Но этот компонент объявляется как класс, а не как функция стрелки, поскольку ему требуется определить два дополнительных метода управления жизненным циклом:
componentWillUnmount() {
document.body.classList.remove('DialogModalOpen');
}
componentDidMount() {
if (this.props.modal) {
document.body.classList.add('DialogModalOpen');
}
}
Они понадобятся при создании модального диалогового окна: компонент добавляет имя класса к телу документа, следовательно, документ может получить стилевое оформление (обесцветиться).
И наконец, воспользуемся методом render(), чтобы составить модальную оболочку для заголовка, тела и нижней части. Тело вмещает любой другой компонент (или простой текст); когда речь заходит о его содержимом, диалоговое окно демонстрирует свой абсолютно неприхотливый характер:
render() {
return (
<div className={this.props.modal ?
'Dialog DialogModal' : 'Dialog'}>
<div className={this.props.modal ?
'DialogModalWrap' : null}>
<div className="DialogHeader">{this.props.header}</div>
<div className="DialogBody">{this.props.children}</div>
<div className="DialogFooter">
{this.props.hasCancel
? <span
className="DialogDismiss"
onClick={this.props.onAction.bind(this,
'dismiss')}>
Cancel
</span>
: null
}
<Button onClick={this.props.onAction.bind(this,
this.props.hasCancel ? 'confirm' : 'dismiss')}>
{this.props.confirmLabel}
</Button>
</div>
</div>
</div>
);
}
Альтернативы:
• вместо использования одного вызова onAction есть другой вариант: предоставление вызова onConfirm (по щелчку пользователя на кнопке OK) и вызова onDismiss;
• можно улучшить работу с диалоговым окном, заставив его исчезать после нажатия пользователем клавиши Esc. (А как бы вы смогли реализовать эту функцию?);
• у контейнера div имеется условное и безусловное имя класса. В компоненте можно воспользоваться модулем classnames, сделав это следующим образом.
До:
<div className={this.props.modal ? 'Dialog DialogModal' :
'Dialog'}>
После:
<div className={classNames({
'Dialog': true,
'DialogModal': this.props.modal,
})}>
На данный момент в нашем распоряжении уже имеются все низкоуровневые компоненты, осталось получить еще два компонента: новую, усовершенствованную таблицу данных Excel и самый высокоуровневый родительский компонент Whinepad. Оба они настраиваются через объект «схемы» — описание типа данных, с которыми нужно работать в приложении. Вот как выглядит пример (js/source/schema.js), позволяющий приступить к формированию приложения, ориентированного на предоставление отзывов о дегустируемых винах:
import classification from './classification';
export default [
{
id: 'name',
label: 'Name',
show: true, // показать в таблице 'Excel'
sample: '$2 chuck',
align: 'left', // выравнивание в 'Excel'
},
{
id: 'year',
label: 'Year',
type: 'year',
show: true,
sample: 2015,
},
{
id: 'grape',
label: 'Grape',
type: 'suggest',
options: classification.grapes,
show: true,
sample: 'Merlot',
align: 'left',
},
{
id: 'rating',
label: 'Rating',
type: 'rating',
show: true,
sample: 3,
},
{
id: 'comments',
label: 'Comments',
type: 'text',
sample: 'Nice for the price',
},
]
Это пример одного из самых простых ECMAScript-модулей, которые только можно себе представить (один из тех, что экспортирует только одну переменную). Он также импортирует еще один простой модуль, содержащий некоторые длинные варианты предварительного заполнения форм (js/source/classification.js):
export default {
grapes: [
'Baco Noir',
'Barbera',
'Cabernet Franc',
'Cabernet Sauvignon',
// ....
],
}
Теперь с помощью модуля schema вы можете настроить тип данных, с которыми сможете работать в приложении.
Компонент Excel из главы 3 обладает избыточными для нашей новой задачи возможностями. Его усовершенствованная версия должна стать более пригодной для многократного использования. Избавимся от функции поиска (переместив ее в компонент самого верхнего уровня Whinepad) и от функций загрузки (эти функции вы можете добавить в Whinepad самостоятельно). Компонент должен заниматься только RUD-частью из набора функций, обозначаемого акронимом CRUD (то есть только чтением, обновлением и удалением) (рис. 6.10). Это редактируемая таблица, и она должна предоставить своему родительскому компоненту Whinepad возможность получения уведомлений при изменении данных, используя для этого метод onDataChange.
Компонент Whinepad должен позаботиться о поиске, о функции C (создании новой записи) из набора функций, обозначаемого акронимом CRUD, и о постоянном хранении данных с использованием localStorage. (В версии приложения, предназначенной для реальной работы, должно быть предусмотрено хранение данных на сервере.)
Рис. 6.10. Excel
Для настройки на типы данных оба компонента используют коллекцию из модуля schema.
Итак, приготовьтесь к полноценной реализации компонента Excel (за исключением некоторых особенностей он близок к тому компоненту, который вам знаком по главе 3):
import Actions from './Actions';
import Dialog from './Dialog';
import Form from './Form';
import FormInput from './FormInput';
import Rating from './Rating';
import React, {Component, PropTypes} from 'react';
import classNames from 'classnames';
class Excel extends Component {
constructor(props) {
super(props);
this.state = {
data: this.props.initialData,
sortby: null, // schema.id
descending: false,
edit: null, // [row index, schema.id],
dialog: null, // {type, idx}
};
}
componentWillReceiveProps(nextProps) {
this.setState({data: nextProps.initialData});
}
_fireDataChange(data) {
this.props.onDataChange(data);
}
_sort(key) {
let data = Array.from(this.state.data);
const descending = this.state.sortby === key &&
!this.state.descending;
data.sort(function(a, b) {
return descending
? (a[column] < b[column] ? 1 : -1)
: (a[column] > b[column] ? 1 : -1);
});
this.setState({
data: data,
sortby: key,
descending: descending,
});
this._fireDataChange(data);
}
_showEditor(e) {
this.setState({edit: {
row: parseInt(e.target.dataset.row, 10),
key: e.target.dataset.key,
}});
}
_save(e) {
e.preventDefault();
const value = this.refs.input.getValue();
let data = Array.from(this.state.data);
data[this.state.edit.row][this.state.edit.key] = value;
this.setState({
edit: null,
data: data,
});
this._fireDataChange(data);
}
_actionClick(rowidx, action) {
this.setState({dialog: {type: action, idx: rowidx}});
}
_deleteConfirmationClick(action) {
if (action === 'dismiss') {
this._closeDialog();
return;
}
let data = Array.from(this.state.data);
data.splice(this.state.dialog.idx, 1);
this.setState({
dialog: null,
data: data,
});
this._fireDataChange(data);
}
_closeDialog() {
this.setState({dialog: null});
}
_saveDataDialog(action) {
if (action === 'dismiss') {
this._closeDialog();
return;
}
let data = Array.from(this.state.data);
data[this.state.dialog.idx] = this.refs.form.getData();
this.setState({
dialog: null,
data: data,
});
this._fireDataChange(data);
}
render() {
return (
<div className="Excel">
{this._renderTable()}
{this._renderDialog()}
</div>
);
}
_renderDialog() {
if (!this.state.dialog) {
return null;
}
switch (this.state.dialog.type) {
case 'delete':
return this._renderDeleteDialog();
case 'info':
return this._renderFormDialog(true);
case 'edit':
return this._renderFormDialog();
default:
throw Error('Unexpected dialog type
${this.state.dialog.type}');
}
}
_renderDeleteDialog() {
const first = this.state.data[this.state.dialog.idx];
const nameguess = first[Object.keys(first)[0]];
return (
<Dialog
modal={true}
header="Confirm deletion"
confirmLabel="Delete"
onAction={this._deleteConfirmationClick.bind(this)}
>
{'Are you sure you want to delete "${nameguess}"?'}
</Dialog>
);
}
_renderFormDialog(readonly) {
return (
<Dialog
modal={true}
header={readonly ? 'Item info' : 'Edit item'}
confirmLabel={readonly ? 'ok' : 'Save'}
hasCancel={!readonly}
onAction={this._saveDataDialog.bind(this)}
>
<Form
ref="form"
fields={this.props.schema}
initialData={this.state.data[this.state.dialog.idx]}
readonly={readonly} />
</Dialog>
);
}
_renderTable() {
return (
<table>
<thead>
<tr>{
this.props.schema.map(item => {
if (!item.show) {
return null;
}
let title = item.label;
if (this.state.sortby === item.id) {
title += this.state.descending ? '
\u2191' : ' \u2193';
}
return (
<th
className={'schema-${item.id}'}
key={item.id}
onClick={this._sort.bind(this, item.id)}
>
{title}
</th>
);
}, this)
}
<th className="ExcelNotSortable">Actions</th>
</tr>
</thead>
<tbody onDoubleClick={this._showEditor.bind(this)}>
{this.state.data.map((row, rowidx) => {
return (
<tr key={rowidx}>{
Object.keys(row).map((cell, idx) => {
const schema = this.props.schema[idx];
if (!schema || !schema.show) {
return null;
}
const isRating = schema.type === 'rating';
const edit = this.state.edit;
let content = row[cell];
if (!isRating && edit && edit.row === rowidx &&
edit.key === schema.id) {
content = (
<form onSubmit={this._save.
bind(this)}>
<FormInput ref="input" {...schema}
defaultValue={content} />
</form>
);
} else if (isRating) {
content = <Rating readonly={true}
defaultValue={Number(content)} />;
}
return (
<td
className={classNames({
['schema-${schema.id}']: true,
'ExcelEditable': !isRating,
'ExcelDataLeft': schema.align ===
'left',
'ExcelDataRight': schema.align ===
'right',
'ExcelDataCenter': schema.align !==
'left' &&
schema.align !== 'right',
})}
key={idx}
data-row={rowidx}
data-key={schema.id}>
{content}
</td>
);
}, this)}
<td className="ExcelDataCenter">
<Actions onAction={this._actionClick.
bind(this, rowidx)} />
</td>
</tr>
);
}, this)}
</tbody>
</table>
);
}
}
Excel.propTypes = {
schema: PropTypes.arrayOf(
PropTypes.object
),
initialData: PropTypes.arrayOf(
PropTypes.object
),
onDataChange: PropTypes.func,
};
export default Excel
Кое-что здесь требует более подробного разбора…
render() {
return (
<div className="Excel">
{this._renderTable()}
{this._renderDialog()}
</div>
);
}
Компонент выводит таблицу и (возможно) диалоговое окно. Диалоговое окно может быть окном подтверждения с вопросом наподобие «Вы действительно хотите выполнить удаление?», или формой для редактирования, или формой только для чтения, позволяющей лишь прочитать информацию об элементе. Или же может не быть никакого диалогового окна (исходное состояние). Когда устанавливается свойство dialog состояния this.state, данные отображаются заново, в результате чего, если это необходимо, появляется диалоговое окно.
А свойство dialog в состоянии устанавливается, когда пользователь выполняет щелчок на одной из кнопок компонента <Action>:
_actionClick(rowidx, action) {
this.setState({dialog: {type: action, idx: rowidx}});
}
Когда данные в таблице изменяются (с использованием инструкции this.setState({data: /**/})), вы инициируете выдачу события изменения, уведомляющего родительский компонент, что он может обновить содержимое постоянного хранилища:
_fireDataChange(data) {
this.props.onDataChange(data);
}
Обмен данными в обратном направлении (от родительского компонента Whinepad к дочернему Excel) осуществляется путем изменения родительским компонентом свойства initialData. Компонент Excel готов реагировать на такие изменения, используя код:
componentWillReceiveProps(nextProps) {
this.setState({data: nextProps.initialData});
}
А как создается форма ввода данных (рис. 6.11)? Или окно просмотра данных (рис. 6.12)? Вы открываете компонент Dialog с компонентом Form внутри него. Эти настройки для формы берутся из schema, а данные для полей ввода — из this.state.data:
_renderFormDialog(readonly) {
return (
<Dialog
Рис. 6.11. Диалоговое окно редактирования данных (U из акронима CRUD)
Рис. 6.12. Диалоговое окно просмотра данных (R из акронима CRUD)
modal={true}
header={readonly ? 'Item info' : 'Edit item'}
confirmLabel={readonly ? 'ok' : 'Save'}
hasCancel={!readonly}
onAction={this._saveDataDialog.bind(this)}
>
<Form
ref="form"
fields={this.props.schema}
initialData={this.state.data[this.state.dialog.idx]}
readonly={readonly} />
</Dialog>
);
}
Когда пользователь выполняет редактирование, вам следует всего лишь обновить состояние и оповестить об этом подписчиков:
_saveDataDialog(action) {
if (action === 'dismiss') {
this._closeDialog();
// всего лишь устанавливает this.state.dialog в null
return;
}
let data = Array.from(this.state.data);
data[this.state.dialog.idx] = this.refs.form.getData();
this.setState({
dialog: null,
data: data,
});
this._fireDataChange(data);
}
Если применять новый ES-синтаксис, то все сводится к более широкому использованию шаблона строк:
// До
"Are you sure you want to delete " + nameguess + "?"
// После
{'Are you sure you want to delete "${nameguess}"?'}
Также обратите внимание на использование шаблонов в именах классов, поскольку приложение позволяет вам настраивать таблицу данных с помощью идентификаторов из схемы. Итак:
// До
<th className={"schema-" + item.id}}>
// После
<th className={'schema-${item.id}'}>
Возможно, самым странным синтаксисом будет тот, в котором строка шаблона заключается в квадратные скобки [] и используется как имя свойства в объекте. Это не имеет отношения к React, но вы можете посчитать любопытным тот факт, что следующий код также можно использовать со строками шаблона:
{
['schema-${schema.id}']: true,
'ExcelEditable': !isRating,
'ExcelDataLeft': schema.align === 'left',
'ExcelDataRight': schema.align === 'right',
'ExcelDataCenter': schema.align !== 'left' &&
schema.align !== 'right',
}
Настал черед последнего компонента, являющегося родительским для всех других (рис. 6.13). Он проще табличного компонента Excel и имеет меньше зависимостей:
import Button from './Button';
// <- для добавления новой записи: "add new item"
import Dialog from './Dialog';
// <- для вывода на экран формы добавления новой
// записи: "add new item"
import Excel from './Excel'; // <- таблица со всеми записями
import Form from './Form'; // <- форма для добавления
// новой записи: "add new item"
import React, {Component, PropTypes} from 'react';
Компонент получает только два свойства — схему данных и существующие записи:
Whinepad.propTypes = {
schema: PropTypes.arrayOf(
PropTypes.object
),
initialData: PropTypes.arrayOf(
PropTypes.object
),
};
export default Whinepad;
Рис. 6.13. Компонент Whinepad в работе с операцией C из акронима CRUD
После того как вы досконально изучили весь код реализации компонента Excel, освоение этого кода не должно вызвать у вас никаких затруднений:
class Whinepad extends Component {
constructor(props) {
super(props);
this.state = {
data: props.initialData,
addnew: false,
};
this._preSearchData = null;
}
_addNewDialog() {
this.setState({addnew: true});
}
_addNew(action) {
if (action === 'dismiss') {
this.setState({addnew: false});
return;
}
let data = Array.from(this.state.data);
data.unshift(this.refs.form.getData());
this.setState({
addnew: false,
data: data,
});
this._commitToStorage(data);
}
_onExcelDataChange(data) {
this.setState({data: data});
this._commitToStorage(data);
}
_commitToStorage(data) {
localStorage.setItem('data', JSON.stringify(data));
}
_startSearching() {
this._preSearchData = this.state.data;
}
_doneSearching() {
this.setState({
data: this._preSearchData,
});
}
_search(e) {
const needle = e.target.value.toLowerCase();
if (!needle) {
this.setState({data: this._preSearchData});
return;
}
const fields = this.props.schema.map(item => item.id);
const searchdata = this._preSearchData.filter(row => {
for (let f = 0; f < fields.length; f++) {
if (row[fields[f]].toString().toLowerCase().
indexOf(needle) > -1) {
return true;
}
}
return false;
});
this.setState({data: searchdata});
}
render() {
return (
<div className="Whinepad">
<div className="WhinepadToolbar">
<div className="WhinepadToolbarAdd">
<Button
onClick={this._addNewDialog.bind(this)}
className="WhinepadToolbarAddButton">
+ add
</Button>
</div>
<div className="WhinepadToolbarSearch">
<input
placeholder="Search..."
onChange={this._search.bind(this)}
onFocus={this._startSearching.bind(this)}
onBlur={this._doneSearching.bind(this)} />
</div>
</div>
<div className="WhinepadDatagrid">
<Excel
schema={this.props.schema}
initialData={this.state.data}
onDataChange={this._onExcelDataChange.
bind(this)} />
</div>
{this.state.addnew
? <Dialog
modal={true}
header="Add new item"
confirmLabel="Add"
onAction={this._addNew.bind(this)}
>
<Form
ref="form"
fields={this.props.schema} />
</Dialog>
: null}
</div>
);
}
}
Обратите внимание на подписку компонента на изменение данных в Excel с использованием метода onDataChange, а также на то, что все данные сохраняются в локальном хранилище localStorage:
_commitToStorage(data) {
localStorage.setItem('data', JSON.stringify(data));
}
В данном случае для сохранения данных не только на стороне клиента, но и на сервере можно было бы воспользоваться асинхронными запросами (также известными как технологии XHR, XMLHttpRequest, Ajax).
Как было показано в начале главы, основная точка входа в приложение — app.js. Сценарий app.js не является ни компонентом, ни модулем, и он ничего не экспортирует. На него возлагается лишь работа по инициализации — считывание имеющихся данных из localStorage и настройка компонента <Whinepad>:
'use strict';
import Logo from './components/Logo';
import React from 'react';
import ReactDOM from 'react-dom';
import Whinepad from './components/Whinepad';
import schema from './schema';
let data = JSON.parse(localStorage.getItem('data'));
// исходные данные примера, считывание из схемы
if (!data) {
data = {};
schema.forEach(item => data[item.id] = item.sample);
data = [data];
}
ReactDOM.render(
<div>
<div className="app-header">
<Logo /> Welcome to Whinepad!
</div>
<Whinepad schema={schema} initialData={data} />
</div>,
document.getElementById('pad')
);
И на этом создание приложения завершается. Вы можете поработать с ним на сайте и просмотреть его код по адресу /.