Добро пожаловать всем желающим взглянуть на примеры работы с React.js! Ранее мы узнали как API-интерфейсы React позволяют нам создавать компоненты с проверкой состояния, как использовать их на практике и как работает Facebook Flux архитектура.
Сегодня мы собираемся собрать все это вместе, чтобы проиллюстрировать работу корзины интернет-магазина. В типичном сайте электронной коммерции страница выбора товара имеет несколько взаимодействующих друг с другом компонентов и React.js существенно помогает организовать и упростить зависимости между ними.
Начало работы с React.js
Нашим первым шагом в проектировании приложения является осознание того, что же оно должно делать. Мы хотим:
- Показывать товар с несколькими опциями.
- Изменить цену при выборе опции.
- Добавлять товары в корзины.
- Удалять товары из корзины.
- Показывать число товаров в корзине.
- Отображать итоговую стоимость покупок.
- Отображать итоговую стоимость для каждого товара в корзине, исходя из количества.
- Изменять кнопку "Добавить в корзину" на "Продано" и отключать ее, когда нет товаров для данной позиции.
- Показать корзину после добавления продукта или при нажатии на кнопку "Посмотреть корзину".
Вот как должен выглядеть наш конечный продукт:
Это приложение находится на стороне клиента, поэтому нам не понадобится отдельный сервер. Вместо этого мы будем использовать некий API и "левые" данные, чтобы можно было сконцентрироваться на самих компонентах. Давайте взглянем на структуру каталогов:
Структура каталогов
css/
---- app.css
img/
---- scotch-beer.png
js/
---- actions/
-------- FluxCartActions.js // Создание действий в приложении
---- components/
-------- FluxCart.react.js // Компонент Cart (Корзина)
-------- FluxCartApp.react.js // Вид основного контроллера
-------- FluxProduct.react.js // Компонент Product (Продукция)
---- constants/
-------- FluxCartConstants.js // Константы действий в приложении
---- dispatcher/
-------- AppDispatcher.js // Диспетчер в приложении
---- stores/
-------- CartStore.js // Корзина
-------- ProductStore.js // Магазин
---- utils/
-------- CartAPI.js // API для работы магазина
---- app.js // Основной файл app.js
---- ProductData.js // Наши "левые" данные
index.html
package.json
Проверим наш файл package.json. Мы будем использовать следующие модули: Browserify, Reactify, React, Flux, Watchify, Uglify, Underscore, Envify.
Мы должны запустить npm install
, чтобы установить все наши зависимости, а затем использовать команду npm start
, чтобы начать процесс, который просматривает наш проект и отсылает исходный код для сохранения.
package.json
{
"name": "flux-pricing",
"version": "0.0.1",
"description": "Компонент pricing с flux",
"main": "js/app.js",
"dependencies": {
"flux": "^2.0.0",
"react": "^0.12.0",
"underscore": "^1.7.0"
},
"devDependencies": {
"browserify": "~6.2.0",
"envify": "~3.0.0",
"react": "^0.12.0",
"reactify": "^0.15",
"watchify": "~2.1.0"
},
"scripts": {
"start": "watchify -o js/bundle.js -v -d .",
"build": "browserify . | uglifyjs -cm > js/bundle.min.js"
},
"author": "Daima",
"browserify": {
"transform": [
"reactify",
"envify"
]
}
}
API и фиктивные данные
Для того, чтобы сосредоточиться на Flux и React.js, мы будем использовать использовать некий API и произвольные данные продукта, который мы собираемся отобразить. Тем не менее, работа с данными и API похожа на работу с настоящими API интернет-магазина.
Давайте посмотрим как выглядят наши данные о товаре (файл ProductData.js):
module.exports = {
// Загружаем фиктивные данные товара в localStorage
init: function() {
localStorage.clear();
localStorage.setItem('product', JSON.stringify([
{
id: '0011001',
name: 'Монт Марсаль АУРЕУМ Брют Натур Резерва',
image: 'scotch-beer.png',
description: 'Вино игристое, географического указания выдержанное, категории DO.',
variants: [
{
sku: '123123',
type: '40 бутылок',
price: 4.99,
inventory: 1
},
{
sku: '123124',
type: '6 ящиков',
price: 12.99,
inventory: 5
},
{
sku: '1231235',
type: '30 ящиков',
price: 19.99,
inventory: 3
}
]
}
]));
}
};
Как вы можете видеть выше, мы определяем товар, который имеет опции, называемые variants. Мы получили некие данные о товаре и загрузили их в localStorage
, поэтому наш API может собрать эти данные и загрузить их в приложение.
Ниже показано как наш API подхватывает данные из localStorage
, а затем использует Flux экшены, чтобы отправить эти данные ProductStore (файл CartAPI.js):
var FluxCartActions = require('../actions/FluxCartActions');
module.exports = {
// Загрузка данных о товаре из localStorage в ProductStore
getProductData: function() {
var data = JSON.parse(localStorage.getItem('product'));
FluxCartActions.receiveProduct(data);
}
};
Теперь, когда у нас есть пример данных о продукте и пример API-запроса, как нам использовать их для начальной загрузки данных с «сервера» в наше приложение?
На самом деле, это просто, надо лишь инициализировать данные, создать API-запрос и установить вид контроллера. Наш главный файл app.js
, как показано ниже, отвечает за этот процесс:
window.React = require('react');
var ProductData = require('./ProductData');
var CartAPI = require('./utils/CartAPI')
var FluxCartApp = require('./components/FluxCartApp.react');
// Загрузка данных о продукте в localStorage
ProductData.init();
// Загрузка API-запроса
CartAPI.getProductData();
// Рендер вида контроллера FluxCartApp
React.render(
<FluxCartApp />,
document.getElementById('flux-cart')
);
Диспетчер
Так как мы используем Flux архитектуру для этого приложения, нам необходимо создать наш собственный экземпляр Диспетчера библиотеки Facebook. Мы также добавим вспомогательный метод handleAction
, чтобы можно было определить, откуда идет действие.
Хотя это и не требуется для нашего текущего приложения, если мы хотели бы подключиться к настоящему интерфейсу API, или обрабатывать действия из мест помимо страницы просмотров, хорошо было бы разместить эту архитектуру там, где мы бы хотели обрабатывать действия способом, отличным от оригинального (файл AppDispatcher.js):
var Dispatcher = require('flux').Dispatcher;
// Создаем экземпляр диспетчера
var AppDispatcher = new Dispatcher();
// Удобный метод обработки запросов диспетчера
AppDispatcher.handleAction = function(action) {
this.dispatch({
source: 'VIEW_ACTION',
action: action
});
}
module.exports = AppDispatcher;
В нашем методе handleAction
, мы получаем действие от его создателя, а затем диспетчер его направляет с помощью свойства source
и действия, которое являлось отдельной переменной.
Действия
Теперь, когда у нас есть свои зависимости, данные и настроенный диспетчер, пришло время, чтобы начать работать над функциональными требования нашего проекта. С действий и надо начинать. Определим некоторые константы действий для того, чтобы определить, какие действия будет выполнять наше приложение (файл FluxCartConstants.js):
var keyMirror = require('react/lib/keyMirror');
// Определить константы действий
module.exports = keyMirror({
CART_ADD: null, // Добавить товар в корзину
CART_REMOVE: null, // Удалить товар из корзины
CART_VISIBLE: null, // Показать/Скрыть корзину
SET_SELECTED: null, // Выбор опций продукта
RECEIVE_DATA: null // Загрузить некие данные
});
После определения наших констант, мы должны создать свои соответствующие методы создания действий. Эти методы, которые мы можем вызывать с просмотров/компонентов, сообщают диспетчеру о необходимости трансляции действия магазинам.
Само действие будет состоять из объекта, содержащего константу желаемого действия и полезной нагрузки данных. Затем наши магазины будут обновлять и изменять события, которые вид контроллера просматривает для того, чтобы знать, когда начинать обновлять состояние.
Ниже вы можете увидеть как использовать метод handleAction диспетчера, чтобы передать константу actionType и связанные данные диспетчеру (файл FluxCartActions.js):
var AppDispatcher = require('../dispatcher/AppDispatcher');
var FluxCartConstants = require('../constants/FluxCartConstants');
// Определить действия объекта
var FluxCartActions = {
// Получение начальных данных товара
receiveProduct: function(data) {
AppDispatcher.handleAction({
actionType: FluxCartConstants.RECEIVE_DATA,
data: data
})
},
// Установить выбранный вариант продукта
selectProduct: function(index) {
AppDispatcher.handleAction({
actionType: FluxCartConstants.SELECT_PRODUCT,
data: index
})
},
// Добавить товар в корзину
addToCart: function(sku, update) {
AppDispatcher.handleAction({
actionType: FluxCartConstants.CART_ADD,
sku: sku,
update: update
})
},
// Удалить товар из корзины
removeFromCart: function(sku) {
AppDispatcher.handleAction({
actionType: FluxCartConstants.CART_REMOVE,
sku: sku
})
},
// Обновление статуса видимости корзины
updateCartVisible: function(cartVisible) {
AppDispatcher.handleAction({
actionType: FluxCartConstants.CART_VISIBLE,
cartVisible: cartVisible
})
}
};
module.exports = FluxCartActions;
Магазины
Теперь, когда действия определены, пришло время создавать магазины. Каждый из них управляет состоянием приложения для домена приложения, поэтому мы создадим один домен для продукта, и один для корзины. Давайте начнем с нашего ProductStore
(файл ProductStore.js):
var AppDispatcher = require('../dispatcher/AppDispatcher');
var EventEmitter = require('events').EventEmitter;
var FluxCartConstants = require('../constants/FluxCartConstants');
var _ = require('underscore');
// Определить начальные данные
var _product = {}, _selected = null;
// Метод загрузки данных продукта из API
function loadProductData(data) {
_product = data[0];
_selected = data[0].variants[0];
}
// Метод для установки выбранного варианта товара
function setSelected(index) {
_selected = _product.variants[index];
}
// Расширить ProductStore с помощью EventEmitter,
// чтобы добавить возможность обработки событий
var ProductStore = _.extend({}, EventEmitter.prototype, {
// Возвращаем данные о продукте
getProduct: function() {
return _product;
},
// Возвращаем выбранный товар
getSelected: function(){
return _selected;
},
// Создаем изменение события
emitChange: function() {
this.emit('change');
},
// Добавить слушатель изменений
addChangeListener: function(callback) {
this.on('change', callback);
},
// Удалить слушатель изменений
removeChangeListener: function(callback) {
this.removeListener('change', callback);
}
});
// Регистрируем обратный вызов с помощью AppDispatcher
AppDispatcher.register(function(payload) {
var action = payload.action;
var text;
switch(action.actionType) {
// Ответить на действие RECEIVE_DATA
case FluxCartConstants.RECEIVE_DATA:
loadProductData(action.data);
break;
// Ответить на действие SELECT_PRODUCT
case FluxCartConstants.SELECT_PRODUCT:
setSelected(action.data);
break;
default:
return true;
}
// Если на действие был получен ответ, создать изменение события
ProductStore.emitChange();
return true;
});
module.exports = ProductStore;
Выше мы определяем два приватных метода: loadProductData
и setSelected
. Мы используем loadProductData
чтобы, конечно же, загрузить данные продукта в наш объект _product
. Метод setSelected
используется для установки выбранного варианта продукта.
Мы раскрываем эти данные с использованием публичных методов getProduct
и getSelected
, которые возвращают свои собственные внутренние объекты. Эти методы могут быть вызваны после require
-запросов нашего магазина.
В заключение, регистрируем обратный вызов к AppDispatcher
, который использует переключение состояний, чтобы определить, совпадает ли поставляемая нагрузка с действием, на которое мы хотим ответить. Если да, обращаемся к методам с поставленными данными и изменяем событие, заставляя вид создавать новое состояние и обновлять его отображение.
Далее, давайте создадим CartStore
:
var AppDispatcher = require('../dispatcher/AppDispatcher');
var EventEmitter = require('events').EventEmitter;
var FluxCartConstants = require('../constants/FluxCartConstants');
var _ = require('underscore');
// Определим начальные данные
var _products = {}, _cartVisible = false;
// Добавить продукт в корзину
function add(sku, update) {
update.quantity = sku in _products ? _products[sku].quantity + 1 : 1;
_products[sku] = _.extend({}, _products[sku], update)
}
// Сделать корзину видимой
function setCartVisible(cartVisible) {
_cartVisible = cartVisible;
}
// Удалить товар из корзины
function removeItem(sku) {
delete _products[sku];
}
// Расширяем содержимое корзины с помощью EventEmitter,
// чтобы добавить возможность обработки событий
var CartStore = _.extend({}, EventEmitter.prototype, {
// Возвращаем объекты из корзины
getCartItems: function() {
return _products;
},
// Возвращаем число объектов в корзине
getCartCount: function() {
return Object.keys(_products).length;
},
// Возвращаем общую стоимость покупок
getCartTotal: function() {
var total = 0;
for(product in _products){
if(_products.hasOwnProperty(product)){
total += _products[product].price * _products[product].quantity;
}
}
return total.toFixed(2);
},
// Возвращаем состояние видимости корзины
getCartVisible: function() {
return _cartVisible;
},
// Создаем изменения события
emitChange: function() {
this.emit('change');
},
// Добавляем слушатель изменений
addChangeListener: function(callback) {
this.on('change', callback);
},
// Удаляем слушатель изменений
removeChangeListener: function(callback) {
this.removeListener('change', callback);
}
});
// Регистрируем обратный вызов с помощью AppDispatcher
AppDispatcher.register(function(payload) {
var action = payload.action;
var text;
switch(action.actionType) {
// Ответить на действие CART_ADD
case FluxCartConstants.CART_ADD:
add(action.sku, action.update);
break;
// Ответить на действие CART_VISIBLE
case FluxCartConstants.CART_VISIBLE:
setCartVisible(action.cartVisible);
break;
// Ответить на действие CART_REMOVE
case FluxCartConstants.CART_REMOVE:
removeItem(action.sku);
break;
default:
return true;
}
// Если на действие был получен ответ,
// создать изменение события
CartStore.emitChange();
return true;
});
module.exports = CartStore;
Выше мы разложили наш магазин подобно тому, как мы это сделали с ProductStore
. Мы используем объект _products
для хранения товаров, которые в настоящее время в нашей корзине, и булевскую _cartVisibility
, определяющую текущее состояние видимости нашей корзины.
Мы добавили некоторые более сложные публичные методы, которые позволяют нашим видам контроллера получать состояние приложения:
getCartItems
– отображает предметы в корзине
getCartCount
– отображает количество предметов в корзине
getCartTotal
– отображает общую стоимость предметов в корзине
Теперь, когда магазины созданы, настало время углубиться в построение Вида.
Вид контроллера
Вид контроллера – сложный компонент, который отслеживает изменения в магазине, а затем обновляет состояние приложения путем запроса к методам магазина. Это состояние разветвляется на два компонента с помощью реквизита.
Вид контроллера отвечает за:
- Установку состояния приложения путем запроса к методам магазина
- Составление компонентов и передачу состояния с помощью свойств
- Отслеживание изменения состояния магазина
Вот наш FluxCartApp.react.js
var React = require('react');
var CartStore = require('../stores/CartStore');
var ProductStore = require('../stores/ProductStore');
var FluxProduct = require('./FluxProduct.react');
var FluxCart = require('./FluxCart.react');
// Метод получения состояния магазина
function getCartState() {
return {
product: ProductStore.getProduct(),
selectedProduct: ProductStore.getSelected(),
cartItems: CartStore.getCartItems(),
cartCount: CartStore.getCartCount(),
cartTotal: CartStore.getCartTotal(),
cartVisible: CartStore.getCartVisible()
};
}
// Определение главного вида контроллера
var FluxCartApp = React.createClass({
// Получение начального состояния магазина
getInitialState: function() {
return getCartState();
},
// Добавление отслеживания изменений магазинов
componentDidMount: function() {
ProductStore.addChangeListener(this._onChange);
CartStore.addChangeListener(this._onChange);
},
// Удаление отслеживания изменений магазинов
componentWillUnmount: function() {
ProductStore.removeChangeListener(this._onChange);
CartStore.removeChangeListener(this._onChange);
},
// Создание компонентов, передача состояния через свойства
render: function() {
return (
<div className="flux-cart-app">
<FluxCart products={this.state.cartItems} count={this.state.cartCount} total={this.state.cartTotal} visible={this.state.cartVisible} />
<FluxProduct product={this.state.product} cartitems={this.state.cartItems} selected={this.state.selectedProduct} />
</div>
);
},
// Метод setState, основанный на изменении состояния
_onChange: function() {
this.setState(getCartState());
}
});
module.exports = FluxCartApp;
Мы начнем с создания публичного метода по имени getCartState
. Мы используем этот метод для вызова публичных методов магазина чтобы получить их текущее состояние и установить статус нашего приложения в соответствии с полученными результатами. Сначала этот метод вызывается в методе getInitialState
, а также при получении изменения магазина.
Для того, чтобы отслеживать изменения событий, мы добавим отслеживание к нашему магазину с помощью обычного установочного процесса так, чтобы мы могли знать, когда есть изменения. Если компонент не установлен, мы просто удаляем эти события.
В нашем методе render
, мы составляем наш компонент с помощью компонентов FluxCart
и FluxProduct
. На этом этапе, мы передаем реквизиты о состоянии этим компонентам с помощью свойств или реквизитов.
Представление товара
Мы можем получить свойства, которые переданы от вида контроллера и создать интерактивное отображение продукта.
Приступим (FluxProduct.react.js):
var React = require('react');
var FluxCartActions = require('../actions/FluxCartActions');
// Flux-вид продукта
var FluxProduct = React.createClass({
// Добавить товар в корзину с помощью Actions
addToCart: function(event){
var sku = this.props.selected.sku;
var update = {
name: this.props.product.name,
type: this.props.selected.type,
price: this.props.selected.price
}
FluxCartActions.addToCart(sku, update);
FluxCartActions.updateCartVisible(true);
},
// Выберите товар с помощью Actions
selectVariant: function(event){
FluxCartActions.selectProduct(event.target.value);
},
// Рендерим отображение (представление) товара
render: function() {
var ats = (this.props.selected.sku in this.props.cartitems) ?
this.props.selected.inventory - this.props.cartitems[this.props.selected.sku].quantity :
this.props.selected.inventory;
return (
<div className="flux-product">
<img src={'img/' + this.props.product.image}/>
<div className="flux-product-detail">
<h1 className="name">{this.props.product.name}</h1>
<p className="description">{this.props.product.description}</p>
<p className="price">Price: ${this.props.selected.price}</p>
<select onChange={this.selectVariant}>
{this.props.product.variants.map(function(variant, index){
return (
<option key={index} value={index}>{variant.type}</option>
)
})}
</select>
<button type="button" onClick={this.addToCart} disabled={ats > 0 ? '' : 'disabled'}>
{ats > 0 ? 'Add To Cart' : 'Sold Out'}
</button>
</div>
</div>
);
},
});
module.exports = FluxProduct;
До метода render
мы определяем Action-методы, которые связываем с элементами нашего компонента. Благодаря импорту наших экшенов мы можем затем вызвать их из этих методов и начать процесс обновления:
selectProduct
– Показывает, какой предмет выбран в данный момент
addToCart
– Добавляет выбранный товар в корзину и открывает ее
Внутри нашего метода визуализации мы вычисляем какое колличество выбранного изделия можно продать, проводя инвентаризацию корзины. Это используется для переключения состояние кнопки "Добавить в корзину".
Отображение корзины
Раз нужна корзина, так давайте ее сделаем. Когда предмет добавляется в корзину в нашем приложении, каждая отдельная строка представляет выбранный вариант. Количество может увеличиваться, но при этом не будут созданы дополнительные строки. Вместо этого, будет изменяться только количество.
За дело (FluxCart.react.js):
var React = require('react');
var FluxCartActions = require('../actions/FluxCartActions');
// Flux-вид корзины
var FluxCart = React.createClass({
// Скрыть корзину с помощью объектов действий
closeCart: function(){
FluxCartActions.updateCartVisible(false);
},
// Показать корзину с помощью объектов действий
openCart: function(){
FluxCartActions.updateCartVisible(true);
},
// Удалить элемент из корзины с помощью объектов действий
removeFromCart: function(sku){
FluxCartActions.removeFromCart(sku);
FluxCartActions.updateCartVisible(false);
},
// Рендерим отображение (представление) корзины
render: function() {
var self = this, products = this.props.products;
return (
<div className={"flux-cart " + (this.props.visible ? 'active' : '')}>
<div className="mini-cart">
<button type="button" className="close-cart" onClick={this.closeCart}>×</button>
<ul>
{Object.keys(products).map(function(product){
return (
<li key={product}>
<h1 className="name">{products[product].name}</h1>
<p className="type">{products[product].type} x {products[product].quantity}</p>
<p className="price">${(products[product].price * products[product].quantity).toFixed(2)}</p>
<button type="button" className="remove-item" onClick={self.removeFromCart.bind(self, product)}>Remove</button>
</li>
)
})}
</ul>
<span className="total">Total: ${this.props.total}</span>
</div>
<button type="button" className="view-cart" onClick={this.openCart} disabled={Object.keys(this.props.products).length > 0 ? "" : "disabled"}>View Cart ({this.props.count})</button>
</div>
);
},
});
module.exports = FluxCart;
Теперь у нас есть корзина для покупок! Наша компонент cart имеет три событийных метода:
closeCart
– Закрыть корзину
openCart
– Открыть корзину
removeFromCart
– Удаляет товары из корзины и закрывает ее
Отрендерим нашу корзину. Мы используем метод map, чтобы отрендерить список покупок. Обратите внимание на тег <li>, мы добавили атрибут key
. Это специальный атрибут, используемый при добавлении динамических детей к компоненту. Он используется внутри React.js, чтобы однозначно определить заявленных детей, так что они сохранят надлежащий порядок. Если же вы удалите его и откройте консоль, то увидете, что React.js будет посылать предупреждение, сообщающее, что атрибут key
не был установлен.
Для скрытия и отображения функциональности нашей корзины мы добавляем или удаляем класс active, позволяя CSS сделать все остальное.
Заключение
Если скачали архив с файлами для данного урока, нажмите index.html, чтобы увидеть наше приложение в действии, либо нажмите на ссылку пример. Добавляйте товары в корзину, пока они не закончатся, чтобы увидеть как меняется кнопки добавления товара, и как меняется итоговая стоимость покупок в корзине.
Не останавливайтесь на достигнутом, попробуйте добавить новые функции для нашей корзины, например, каталог товаров с помощью реакт-роутера или добавьте еще одну опцию для каждого продукта. Нет предела совершенству).