Пишем первое приложение на React Native. Часть 2.
Прошло довольно много времени с выхода первой части этой статьи. К сожалению, я был очень занят, что не является оправданием, но всё же я надеюсь на некоторое понимание :)
Итак, мы остановились на том, что создали приложение, выводящее на экран фразу «Привет, мир». В этой статье мы попробуем отрисовать полноценный интерфейс. Так же мы разберёмся, что такое состояние приложения, где и как его хранить (спойлер, мы будем использовать Redux). Наш прототип (пока) будет состоять из двух вкладок: статьи и подкасты.
По старой iOS-традиции кнопки переключения вкладок будут расположены внизу экрана.
Совершенно не удивительно, что в React Native есть готовый компонент для такой раскладки приложения. Он называется TabBarIOS
. Удивительно другое: такого компонента нет для Android. А, значит, в дальнейшем мы получим проблемы с портированием.
Как этого избежать? Мы можем сами реализовать необходимые нам компоненты или скачать готовый набор компонент. В этом сила компонентного подхода!
Подключаем стороннюю библиотеку компонент
Один из наиболее популярных наборов компонент это NativeBase.io
. В этой библиотеке представлен огромный выбор базовых компонент на любой вкус. И есть то, что нам нужно — FooterTab
.
Устанавливаем библиотеку в наш проект и пробуем.
$ npm install native-base@2.1.5 --save
$ npx react-native link
$ npm install @expo/vector-icons --save
Здесь мы видим использование команды react-native link
. Эта команда нужна для того, чтобы подтянуть в ваше приложение нативные реализации компонент, зависимости от которых указаны в package.json
.
Небольшое уточнение по поводу версии native-base
. В мире React Native всё течёт и изменяется каждый день. Выходящие новые версии компонент зачастую содержат ошибки и нестабильность. На момент написания этой статьи хорошо работала связка:
"@expo/vector-icons": "^5.0.0",
"expo": "^18.0.3",
"native-base": "^2.1.5",
"react": "16.0.0-alpha.12",
"react-native": "0.45.1"
По слухам, неплохо работает связка
"native-base": "2.2.0",
"react-native": "0.46.0"
Но вы же понимаете, что так дела не делаются :) Будем надеяться, что совместными усилиями разработчиков ситуация быстро придёт в норму.
Пишем первые функциональные компоненты
Попробуем написать наше первое приложение с использованием полноценных компонент (а не просто текста, как в первой части). Для начала перепишем всё на функциональные компоненты. Что же это такое?
В
React 0.14
появился новый способ определения компоненты, позволяющий создавать её с помощью компактной стрелочной функции вместо громоздкого класса. Этот способ хорош практически всегда, кроме тех случаев, когда вам нужна компонента с внутренним состоянием (а так как мы будем использоватьRedux
, то скорее всего нам такие компоненты не понадобятся).
Функциональные React-компоненты проще и надёжней. В них нет состояния, их легко читать и тестировать и в них сложней допустить ошибку. Рассмотрим на примере App.js, который мы получили из Create React Native App
:
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';export default class App extends React.Component {
render() {
return (
<View style={styles.container}>
<Text>Open up App.js to start working on your app!</Text>
<Text>Changes you make will automatically reload.</Text>
<Text>Shake your phone to open the developer menu.</Text>
</View>
);
}
}const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});
В синтаксисе функциональных компонент это будет так:
import React from 'react';
import {Container, Content} from 'native-base';
import { StyleSheet, Text, View } from 'react-native';const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});const App = () => (
<View style={styles.container}>
<Text>Open up App.js</Text>
</View>
);export default App;
Так гораздо понятней! Можно заметить, что практически чистая компонента создаётся в данном случае нечистой функцией: у нас есть зависимость от внешней переменной styles
. Попробуем избавиться от этой зависимости позже.
Вернёмся к нашему приложению. Перепишем App.js
, используя компоненты из библиотеки native-base
:
import React from 'react';
import {Container, Content} from 'native-base';
import {StyleSheet, Text, View} from 'react-native';
import AppFooter from './components/AppFooter.js';const styles = StyleSheet.create({
container: {
padding: 20
},
});const App = () => (
<Container>
<Content>
<View style={styles.container}>
<Text>
Lorem ipsum...
</Text>
</View>
</Content>
<AppFooter/>
</Container>
);export default App;
Мы видим новый компонент <AppFooter/>
, который нам предстоит создать. Идём в папку ./components/
и создаём файл AppFooter.js
со следующим содержимым:
import React from 'react';
import {Footer, FooterTab, Button, Text} from 'native-base';const AppFooter = () => (
<Footer>
<FooterTab>
<Button active>
<Text>Статьи</Text>
</Button>
<Button>
<Text>Подкасты</Text>
</Button>
</FooterTab>
</Footer>
);export default AppFooter;
Всё готово для того, чтобы попробовать собрать наше приложение! Открываем Expo XDE
, выбираем наш проект, запускаем и открываем в симуляторе. Ого, похоже на наш скетч!
Подключаем Redux
Наши кнопки пока не умеют переключаться. Пора их научить. Для этого нужно сделать две вещи: научиться обрабатывать событие клика и научиться хранить состояние (state). Начнём с состояния. Так как мы отказались от хранения состояния в компоненте, сделав выбор в пользу чистых компонент и глобального стора, то будем использовать Redux.
Что такое Redux? Это потрясающе маленькая библиотека, разработанная петербургским (на тот момент) программистом Дэном Абрамовым и ставшая de facto стандартом в React-мире, вытеснив популярный ранее Flux от Facebook.
Основная идея Redux в том, что в приложении существует один глобальный стор (store) — хранилище состояния. Стор является иммутабельным — он никогда не изменяется, вместо этого на каждое изменение создаётся новая копия стора и распространяется сверху вниз по всем компонентам.
Компоненты никогда не пишут в стор напрямую, они создают экшены — события, которые должны повлиять на стор. Эти события обрабатываются редьюсерами, которые и порождают новый стор. Звучит пугающе, но на деле очень просто.
Прежде всего, мы должны создать наш стор.
import {createStore} from 'redux';
const initialState = {};
const store = createStore(reducers, initialState);
Мы видим, что в данном случае на вход функции createStore
передаётся два параметра: reducers
— набор редьюсеров и initialState
— объект начального состояния.
Давайте создадим заготовку для редьюсеров. В папке reducers
создаём файл index.js
со следующим содержимым:
export default (state = [], action) => {
switch (action.type) {
default:
return state
}
};
Подключаем редьюсеры к App.js:
import reducers from './reducers';
Теперь нам необходимо распространить наш стор по компонентам. Делается это с помощью специально компоненты <Provider>
. Подключаем её в проект:
import {Provider} from 'react-redux';
И оборачиваем все компоненты в <Provider store={store}>
. Обновленный App.js
выглядит так:
import React from 'react';
import {Container, Content} from 'native-base';
import {StyleSheet, Text, View} from 'react-native';
import AppFooter from './components/AppFooter.js';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducers from './reducers';const initialState = {};
const store = createStore(reducers, initialState);const styles = StyleSheet.create({
container: {
padding: 20
},
});const App = () => (
<Provider store={store}>
<Container>
<Content>
<View style={styles.container}>
<Text>
Lorem ipsum...
</Text>
</View>
</Content>
<AppFooter/>
</Container>
</Provider>
);export default App;
Теперь наше приложение может хранить своё состояние. Давайте воспользуемся этим. Добавляем состояние mode
, по умолчанию установленное в ARTICLES
. Это означает, что при первом рендере наше приложение будет установлено в состояние показа списка статей.
const initialState = {
mode: 'ARTICLES'
};
Неплохо, но ручное написание строковых значений ведёт к потенциальным ошибкам. Давайте заведём константы. Создаём файл ./constants/index.js
со следющим содержимым:
export const MODES = {
ARTICLES: 'ARTICLES',
PODCAST: 'PODCAST'
};
И переписываем App.js
:
import {MODES} from './constants';const initialState = {
mode: MODES.ARTICLES
};
Отлично, состояние есть, пора передать его в компоненту футера. Давайте ещё раз посмотрим наш ./components/AppFooter.js
:
import React from 'react';
import {Footer, FooterTab, Button, Text} from 'native-base';const AppFooter = () => (
<Footer>
<FooterTab>
<Button active>
<Text>Статьи</Text>
</Button>
<Button>
<Text>Подкасты</Text>
</Button>
</FooterTab>
</Footer>
);export default AppFooter;
Как мы видим, состояние переключателя определяется с помощью свойства active
у компоненты <Button>
. Прокинем до <Button>
текущее состояние приложения. Делается это не сложно, основную подкапотную работу берёт на себя компонент <Provider>
, который мы подключили ранее. Остаётся только взять из него текущее состояние и положить в свойcтва (props) компоненты <AppFooter>
.
Есть одна загвоздка — идеологическая. По принятому соглашению мы не можем напрямую работать с состоянием приложения в компоненте. Это связано с тем, что компонента должна быть независимой и отделяемой, а следовательно, не должна знать ничего о нашем приложении.
Всё общение с компонентой происходит через
props
. Это так называемая «глупая компонента», которая отвечает только за отображение. За связь с приложением отвечает «умная компонента», в терминах Redux — контейнер.Иначе говоря, если мы хотим, чтобы наша компонента была связана с состоянием приложения, то мы должны положить её в контейнер.
Первым делом, модифицируем наш <AppFooter>
так, чтобы состоянием кнопок можно было управлять, передавая mode
через props
:
import React from 'react';
import {Footer, FooterTab, Button, Text} from 'native-base';
import {MODES} from "../constants";const AppFooter = ({mode = MODES.ARTICLES}) => (
<Footer>
<FooterTab>
<Button active={mode === MODES.ARTICLES}>
<Text>Статьи</Text>
</Button>
<Button active={mode === MODES.PODCAST}>
<Text>Подкасты</Text>
</Button>
</FooterTab>
</Footer>
);export default AppFooter;
Теперь приступим к созданию контейнера. Создадим файл ./containers/AppFooterContainer.js
.
import React from 'react';
import AppFooter from '../components/AppFooter.js';
import {MODES} from "../constants";const AppFooterContainer = () => (
<AppFooter mode={MODES.ARTICLES} />
);export default AppFooterContainer;
И подключим контейнер <AppFooterContainer>
в App.js
вместо компоненты <AppFooter>
. Пока наш контейнер ничем не отличается от компоненты, но всё изменится как только мы подключим его к состоянию приложения. Сделаем это!
import React from 'react';
import AppFooter from '../components/AppFooter.js';
import {connect} from 'react-redux';const mapStateToProps = (state) => ({
mode: state.mode
});const AppFooterContainer = ({mode}) => (
<AppFooter mode={mode} />
);export default connect(
mapStateToProps
)(AppFooterContainer);
Весьма функционально! Все функции стали чистыми. Что тут происходит? Мы подключаем наш контейнер к состоянию с помощью функции connect
и соединяем его props
с содержимым глобального state
с помощью функции mapStateToProps
. Очень чисто и красиво.
Итак, мы научились распространять данные сверху вниз. Теперь нужно научиться изменять наш глобальный state
снизу вверх. Для порождения событий о необходимости изменения глобального состояния предназначены actions
. Давайте создадим action
, возникающий при событии нажатия на кнопку.
Создадим файл ./actions/index.js
:
import {
SET_MODE
} from './actionTypes';export const setMode = (mode) => ({type: SET_MODE, mode});
И файл ./actions/actionTypes
, в котором будем хранить константы с именами экшенов:
export const SET_MODE = 'SET_MODE';
Экшен создаёт объект с именем события и набором данных, которые это событие сопровождают, и ничего больше. Теперь научимся порождать это событие. Возвращаемся в контейнер <AppFooterContainer>
и подключаем функцию mapDispatchToProps
которая подключит диспатчеры событий к props
контейнера.
import React from 'react';
import AppFooter from '../components/AppFooter.js';
import {connect} from 'react-redux';
import {setMode} from '../actions';const mapStateToProps = (state) => ({
mode: state.mode
});const mapDispatchToProps = (dispatch) => ({
setMode(mode) {
dispatch(setMode(mode));
}
});const AppFooterContainer = ({mode, setMode}) => (
<AppFooter mode={mode} setMode={setMode} />
);export default connect(
mapStateToProps,
mapDispatchToProps
)(AppFooterContainer);
Отлично у нас есть функция, порождающая событие SET_MODE
и мы прокинули её до компонента <AppFooter>
. Осталось две проблемы:
- Эту функцию никто не вызывает
- Никто не слушает событие
Разберёмся с первой проблемой. Идём в компонент <AppFooter>
и подключаем вызов функции setMode
.
import React from 'react';
import {Footer, FooterTab, Button, Text} from 'native-base';
import {MODES} from "../constants";const AppFooter = ({mode = MODES.ARTICLES, setMode = () => {}}) => (
<Footer>
<FooterTab>
<Button
active={mode === MODES.ARTICLES}
onPress={
() => setMode(MODES.ARTICLES)}>
<Text>Статьи</Text>
</Button>
<Button
active={mode === MODES.PODCAST}
onPress={
() => setMode(MODES.PODCAST)}>
<Text>Подкасты</Text>
</Button>
</FooterTab>
</Footer>
);export default AppFooter;
Теперь по нажатии на кнопку будет порождаться событие SET_MODE
. Осталось научиться изменять глобальный state
по его возникновению. Идём в ранее созданный ./reducers/index.js
и создаём редьюсер для этого события:
import {
SET_MODE
} from '../actions/actionTypes';export default (state = [], action) => {
switch (action.type) {
case SET_MODE: {
return Object.assign({}, state, {
mode: action.mode
});
}
default:
return state
}
};
Шикарно! Теперь клик по кнопке порождает событие, изменяющее глобальное состояние, а футер, получив эти изменения, перерисовывает кнопки.
Подключаем PropTypes
Осталась одна небольшая деталь, которую хотелось бы сделать прежде, чем завершить этот урок. Давайте пропишем компоненте <AppFooter>
типы её входящих параметров. Это упростит отладку в будущем и сделает наше приложение надёжнее.
import PropTypes from 'prop-types';const AppFooter = ({mode = MODES.ARTICLES, setMode = () => {}}) => (
...
);AppFooter.propTypes = {
mode: PropTypes.string,
setMode: PropTypes.func
};export default AppFooter;
И аналогично для <AppFooterContainer>
import PropTypes from 'prop-types';const AppFooterContainer = ({mode, setMode}) => (
<AppFooter mode={mode} setMode={setMode}/>
);AppFooterContainer.propTypes = {
mode: PropTypes.string,
setMode: PropTypes.func
};
Итак, мы научились подключать сторонние компоненты, разрабатывать свои собственные, а так же подключили Redux и разобрались с глобальным состоянием приложения. Более того, сами того не заметив мы отделили бизнес-логику от представления и данных. Наше будущее приложение уже неплохо структурировано и имеет хороший каркас. Весьма неплохо!
Все файлы к уроку доступны на GitHub. Встретимся в следующей статье.
Слушайте наш подкаст в iTunes и SoundCloud, читайте нас на Medium, контрибьютьте на GitHub, общайтесь в группе Telegram, следите в Twitter и канале Telegram, рекомендуйте в VK и Facebook.