Пишем первое приложение на React Native. Часть 2.

Andrey Melikhov
Jul 27, 2017 · 9 min read

Прошло довольно много времени с выхода первой части этой статьи. К сожалению, я был очень занят, что не является оправданием, но всё же я надеюсь на некоторое понимание :)

Итак, мы остановились на том, что создали приложение, выводящее на экран фразу «Привет, мир». В этой статье мы попробуем отрисовать полноценный интерфейс. Так же мы разберёмся, что такое состояние приложения, где и как его хранить (спойлер, мы будем использовать 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.

Прежде всего, мы должны создать наш стор.

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>.

Первым делом, модифицируем наш <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>. Осталось две проблемы:

  1. Эту функцию никто не вызывает
  2. Никто не слушает событие

Разберёмся с первой проблемой. Идём в компонент <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.

Статья на GitHub

devSchacht

Подкаст. Переводы. Веб-разработка.

Andrey Melikhov

Written by

Web-developer in big IT company Перевожу всё, до чего дотянусь. Иногда (но редко) пишу сам.

devSchacht

Подкаст. Переводы. Веб-разработка.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade