Как сделать мобильное приложение с помощью JS. Путь React Native

by

Привет! Я работаю с Front-end уже 4 года, люблю JavaScript и современный рэп. Если вам это кажется странным, то, пожалуйста, не читайте дальше.

Свою карьеру в IT я начал с PHP и верстки, потом мне на пути повстречалась поддержка серверов на nginx и Apache, работа с SQL-базами данных, Yii 2 и Symfony. Затем увидел jQuery и мне показалось, что это магия (в этом месте фронтенд-разработчики должны надорвать животики). С того момента начал больше времени уделять фронтенду и со временем решил стать JS-разработчиком. Сейчас я умею в ReactJS, Redux, React Native (как вы поняли), имел опыт с монструозной CMS на Java и лидингом других разработчиков. Именно во время работы в AB Soft я максимально погрузился в мир Front-end и JS.

Если вы хотите связать свою жизнь с разработкой приложений, то лучше обратиться к Swift, Kotlin, Objective-C, Java. В общем, к чему-то более нативному. Ну и выбрать приоритетную для себя платформу для разработки (потому что дешевле будет писать для того же Android). Но! Если вы уже и так плывете по IT-миру на волне JS и его хайповых фреймворков, то велкам! Почему бы не попробовать React Native?

Дисклеймер. Спешу расстроить: Snapchat вы на нем не напишете :)

На данный момент React Native не перешел к версиям, большим нуля, как и водится у почти всего, что содержит в своем названии «React».

В то же время у вас есть в npm куча библиотек, которые могут помочь в решении разных задач (но не всех, об этом чуть позже). Также на GitHub есть много библиотек, реализующих компоненты, функции, UI, роутинг, и не только для вашего приложения. А еще здесь довольно живое комьюнити. Так что все это может значительно облегчить разработку.

https://s.dou.ua/storage-files/photo_2020-01-22_22-56-00.jpg

Самый легкий путь — юзать Expo

Expo — это фреймворк (фреймворк для фреймворка, прикинь о_0) и платформа, которая во многом облегчает жизнь начинающему разработчику или разработчице на React Native:

  1. Дает возможность разрабатывать и тестировать приложение без использования Xcode или Android SDK и их экосистем.
  2. Дает стартовый кит с готовым приложением и парой экранов. Здесь вы можете поковыряться и закрасить пробел в резюме.
  3. Имеет обширную и понятную документацию (на английском).
  4. Дает очень удобную систему тестирования своего кода и UI-приложения без необходимости создавать установочный файл. Можно даже открывать свое приложение по ссылке.
  5. Предоставляет огромное количество уже готовых инструментов и АПИ для работы с модулями устройств (акселерометр, камера, файловая система, уведомления и т. д). Полный список можно посмотреть здесь.
  6. Предоставляет свои сервера и окружение для сборки приложения и компиляции его в нативный исполняемый файл.
  7. Автоматически менеджерит ваши сертификаты и подписи Play Market и Apple Store.

Ограничения Expo:

  1. Не поддерживает много нативных библиотек (написанных на Objective-C, Swift, Kotlin и т. д.).
  2. Жестко привязан к определенной версии React Native.
  3. Ваше приложение будет иметь относительно большой размер.
  4. Если захотите отсоединить свое приложение от Expo (например, чтобы использовать парочку нативных модулей), то готовьтесь просидеть над этой задачей не один час.

Итог: если вы только знакомитесь с RN и хотите порадоваться работающему приложению на своем устройстве, то используйте Expo.

Прежде чем начать

Необходимо иметь предустановленное ПО:

Далее устанавливаем интерфейс командной строки для Expo.

npm install -g expo-cli

Если вы счастливый обладатель компьютера от Apple, то лучше установить в придачу watchman. Хотите тестировать свое приложение на физическом девайсе — установите на него приложение Expo: iOS или Android. Также можно использовать в качестве тестовой платформы эмуляторы. С их установкой разбирайтесь сами: iOS (для эмулятора iOS понадобится macOS) и Android.

«Ну давайте уже кодить!» — скажет нетерпеливый читатель, и будет прав

Напишем небольшое приложение наподобие Pinterest. В нем будет разбивка по категориям, галереи, картинки и API-коллы.

Запускаем команду expo cli для создания пустого проекта.

expo init

Далее необходимо ввести некоторые параметры для начальной настройки проекта:

https://s.dou.ua/storage-files/image2_Ccs5jiC.png

При выборе шаблона начального приложения выбираем blank template. Если вы знакомы с TS, то можете применять шаблон на TypeScript. Но мы же тут собрались не для того, чтобы что-то типизировать!

https://s.dou.ua/storage-files/image3_Nqz1EbL.png

Пишем название проекта и его текстовый идентификатор. Если у вас установлен Yarn, то Expo предложит использовать его вместо npm. В этой статье я буду приводить все примеры на базе npm, чтобы сделать «технологичный зоопарк» статьи минималистичным.

Дальше Expo создаст папку с проектом под тем названием, которые вы указали в slug, и сам установит необходимые зависимости. Если вы все сделали правильно, то при запуске команды npm run start у вас в консоли отобразится информация о том, что приложение сбилдилось, и появится QR-код для открытия его в приложении Expo. В браузере откроется соответствующая страница, в которой также будет консоль live reload и UI для запуска эмуляторов.

Если хотите запустить приложение сразу на эмуляторе, то можно воспользоваться командами npm run ios и npm run android соответственно.

Полный список команд можно просмотреть в ./package.json и здесь.

В симуляторе вы должны будете увидеть что-то типа этого:

https://s.dou.ua/storage-files/image4_rMkKeSi.png

Экраны

Немного псевдоархитектуры. До того, как вы начнете писать приложение, нужно продумать, из каких экранов оно будет состоять. Экран — это компонент, являющийся контейнером для всего, что будет на нем отображаться.

В нашем приложении будет 2 экрана:

  1. Главный со списком всех категорий картинок.
  2. Галерея картинок соответствующей категории.

Создаем папку Screens. В нее будем класть файлы с компонентами экранов.

Перед тем как начнем писать код для UI-компонентов, хочу рассказать вам о самом лучшем букмекере (шутка!)... о библиотеке с кросс-платформенными компонентами для RN.

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

npm install native-base

Первым будет заглавный экран CategoriesList.

Он будет выводить список категорий картинок. В реальном приложении вы будете запрашивать этот список у своего API. Мы же храним его, как и прочие утильные файлы, в папке ./data.

data/categories.js

const list = [
   {
       name: 'Architecture',
       alias: 'architecture',
       collection: 'https://firebasestorage.googleapis.com/v0/b/learn-rn-bb59c.appspot.com/o/architecture.json?alt=media&token=b5583dd0-2dbd-4cb9-a91f-76f8e7a0538a'
   },
   {
       name: 'Food',
       alias: 'food',
       collection: 'https://firebasestorage.googleapis.com/v0/b/learn-rn-bb59c.appspot.com/o/food.json?alt=media&token=8b97fcc5-b802-459a-9080-9bd3d49032a6'
   },
   {
       name: 'Abstract',
       alias: 'abstract',
       collection: 'https://firebasestorage.googleapis.com/v0/b/learn-rn-bb59c.appspot.com/o/abstract.json?alt=media&token=c7012570-af3f-4f9b-acd9-ad1a9827d837'
   },
   {
       name: 'Pets',
       alias: 'pets',
       collection: 'https://firebasestorage.googleapis.com/v0/b/learn-rn-bb59c.appspot.com/o/pets.json?alt=media&token=f866e7c7-ed4b-46e9-b7c7-ef4b2242b245'
   }
];
 
export default list;

Свойство collection — это ссылка на JSON-файл с набором картинок с сайта. Позже мы будем к нему обращаться по API и выводить эти прекрасные картинки.

screens/CategoriesList.js

import React from 'react';
import { List, ListItem, Text } from 'native-base';
import { ScrollView } from 'react-native';
import list from '../data/categories';
 
class Categorieslist extends React.Component {
   constructor(props) {
       super(props);
       this.state.categories = list;
   }
 
   state = {
       categories: []
   }
 
   render() {
       return (
           <ScrollView>
               <List>
                   {
                       this.state.categories.map((item) => {
                           return (
                               <ListItem key={item.alias}>
                                   <Text>{item.name}</Text>
                               </ListItem>
                           )
                       })
                   }
               </List>
           </ScrollView>
       );
   }
}
 
export default Categorieslist;
 

Как видите, главное отличие от React’а, на первый взгляд, заключается в тегах jsx. Здесь мы не используем теги HTML (из которых в большинстве случаев состоят наши реактовские компоненты). Вместо них применяем нативные компоненты, предоставленные библиотекой React Native, которые компилируются в нативные представления платформ.

Несколько слов о готовых компонентах, которые мы использовали в основе UI.

<ScrollView /> — контейнер для отображения контента, который, возможно, необходимо скроллить.

 <List /> и <ListItem /> — список и элемент списка соответственно из библиотеки Native Base. Они уже имеют стилизацию в соответствии с iOS- и Android-гайдами.

Думаю, название тега <Text /> говорит само за себя.

Список категорий

Чтобы насладиться результатом своей кропотливой работы, убираем все лишнее из App.js и импортируем туда свой экран:

import React from 'react';
import CategoriesList from './screens/CategoriesList';
 
export default function App() {
 return (
     <CategoriesList />
 );
}

Если вы не выключали команду npm run, то уже можете лицезреть результат на экране эмулятора. Выключали? Тогда запустите еще раз: npm run ios или npm run android.

Если вы установили на свой девайс Expo client и хотите открыть приложение в нем, просто отсканируйте QR-код из терминала. На экране вы должны увидеть это:

https://s.dou.ua/storage-files/image5_SKhMLeF.png

У вас все так и выглядит? Я вас поздравляю! А если нет, то просто сравните свой код с тем, который написан в статье.

Делаем сетку картинок

У Pinterest очень интересная сетка с картинками. Ее аналог в простонародье известен под названием Masonry. Добрые люди уже запилили его в виде npm-пакета. Но пока мы с ним заморачиваться не будем, лучше погрузимся в работу и стилизацию нативных компонентов и компонентов NativeBase.

В конечном итоге в наш компонент галереи мы будем передавать массив с элементами картинок. Он будет представлять собой просто сетку с картинками, у которых закруглены края (по последним гайдам Apple, это очень модно).

Компонент галереи положим в папку components. Он будет отвечать только за рендер нашего массива картинок. Позже обернем его в компонент высшего порядка, который будет передавать ему в пропсах данные и функции для выполнения при взаимодействии с ним. Таким образом мы немного отделим логику и данные от представления.

components/ImageGrid.js

import React from 'react';
import {Card, CardItem} from 'native-base';
import {View, ScrollView, StyleSheet, Dimensions, Image} from 'react-native';
 
/**
* Получаем объект экрана через API RN
* чтобы в дальнейшем использовать для расчетов ширину
*/
const window = Dimensions.get('window');
const imagesWidth = window.width - 20;
 
export default class ImageGrid extends React.Component {
   render() {
       const { list } = this.props;
 
       return (
           <ScrollView>
               {list.map((item) => {
                   const imageRatio = imagesWidth/item.width;
 
                   return(
                       <View key={item.url} style={styles.cardWrapper}>
                           <Card style={styles.card}>
                               <CardItem style={styles.cardItem}>
                                   <Image source={{uri: item.url}}
                                       style={{
                                           ...styles.image,
                                           height: item.height * imageRatio
                                       }}
                                   />
                               </CardItem>
                           </Card>
                       </View>
                   );
               })}
           </ScrollView>
       );
   }
}
 
const styles = StyleSheet.create({
   cardWrapper: {
       borderRadius: 20,
       marginBottom: 10
   },
   card: {
       borderRadius: 20,
       marginLeft: 10,
       marginRight: 10
   },
   cardItem: {
       paddingLeft: 0,
       paddingRight: 0,
       paddingTop: 0,
       paddingBottom: 0
   },
   image: {
       height: 300,
       width: '100%',
       borderRadius: 20,
       width: imagesWidth
   }
});

Стилизация

Из react-native мы здесь импортируем StyleSheet. Это класс, который обеспечивает доступ к абстракции стилизации (как CSS). С помощью метода Create создаем объект, содержащий ссылки на конкретные наборы стилей. Имена ссылок соответствуют названиям свойств объекта. Сами же стили присваиваем компоненту с помощью пропса style. Также мы можем передавать в style просто объект.

<Text style={{color: #ccc}}>Some text</Text>

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

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

Также вы наверняка обратили внимание на компоненты Card и CardItem. Это компоненты из библиотеки NativeBase, которые мы берем для того, чтобы не заморачиваться со стилизацией и позиционированием элементов картинок. Также в этих компонентах можно размещать множество разных наборов контента: текст, заголовки, кнопки, фоновые изображения и т. д. За подробностями идите в доку. В CardItem мы передаем параметр button={true}, что позволит карточке вести себя как кнопка.

Картинки

Теперь необходимо отдельно упомянуть компонент Image. Он стилизуется так же, как и остальные компоненты, имеет обязательный параметр source, с помощью которого указываем путь картинки, которую хотим вывести. Есть два способа это сделать:

  1. В source прокидывать require (‘image/path.png’) для локальных картинок. Однако если вы попытаетесь динамически генерировать строку внутри require, у вас ничего не получится: RN будет в этом месте ломаться.
  2. Указывать объект типа {uri: ‘https://remote.image/path.png’}. В этом случае вы указываете URL удаленной картинки, которую ваше приложение должно будет подгрузить. Также в uri можно передавать строку формата base64, однако если у вас там большая строка, то на этапе компиляции сервер Expo может просто отказаться компилировать ваше приложение, сославшись на слишком большой размер js-файла.

В RN нельзя указывать и 100-процентную высоту картинки, нужно передать конкретное значение. Если картинка находится на удаленном сервере, можете воспользоваться нативным методом getSize, который сделает предзагрузку файла изображения.

Навигация

Для реализации навигации между экранами воспользуемся пакетом react-navigation.

npm install react-navigation

Так как наш проект базируется на Expo, требуется установить дополнительные зависимости, чтобы навигация смогла завестись.

expo install react-native-gesture-handler react-native-reanimated react-native-screens react-native-safe-area-context @react-native-community/masked-view
npm install react-navigation-stack

Нужно отметить, что навигация в приложениях организована иначе, чем в браузерах. В приложении можно хранить историю всего роутинга вместе с параметрами и даже частично состояние приложения. Сами элементы истории организованы по принципу стека. Переходя на новый экран, пользователь как бы кладет его (экран) поверх стека. Кнопка «Назад», которая есть в интерфейсе как iOS, так и Android, удаляет элемент стека.

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

  1. Screen — компонент экрана для роута;
  2. navigationOptions — настройки навигатора, в которых можно указать заголовок, название для кнопки «Назад» и прочие настройки. Также в качестве этого свойства можно указывать callback, который принимает параметры из навигатора, динамически формирует и возвращает нужные нам значения (этим мы будем активно пользоваться).

Вторым параметром он принимает объект со значениями для настройки навигации. Пока мы воспользуемся им для указания экрана по умолчанию с помощью свойства initialRouteName.

Вообще единственным обязательным значением для элемента коллекции является свойство screen.

./navigation.js

import { createStackNavigator } from 'react-navigation-stack';
import { createAppContainer } from 'react-navigation';
 
import CategoriesList from './screens/CategoriesList';
import GridScreen from './screens/GridScreen';
 
const Stack = createStackNavigator({
   MainList: {
       screen: CategoriesList,
       navigationOptions: {
           title: 'Photo Categories'
       }
   },
   ImagesGrid: {
       screen: GridScreen,
       navigationOptions: ({ navigation }) => {
           return {
               title: navigation.state.params.title
           };
       }
   }
},
{
   initialRouteName: 'MainList'
});
 
export default createAppContainer(Stack);

В нашем приложении будет всего два экрана: общий список и экран для вывода контента выбранной коллекции.

Роут MainList будет вызывать компонент CategoriesList, а с помощью navigationOptions мы укажем заголовок для экрана. Роут ImagesGrid будет, в свою очередь, вызывать компонент экрана GridScreen.

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

createStackNavigator возвращает компонент. Но мы не можем просто взять и использовать его в приложении. Для этого необходим createAppContainer, который связывает все, что мы можем сотворить с помощью реакт-навигации, с нативными API платформ. createAppContainer возвращает компонент, который будем использовать как корневой в своем проекте. Для этого немного изменим файл App.js.

./App.js

import React from 'react';
import Navigator from './navigation';
 
export default function App() {
 return (
     <Navigator/>
 );
}

Внедряем навигацию в компоненты.

./screens/CategoriesList.js

import React from 'react';
import { List, ListItem, Text } from 'native-base';
import { ScrollView } from 'react-native';
import categories from '../data/categories';
 
class CategoriesList extends React.Component {
   constructor(props) {
       super(props);
       this.state.categories = categories;
   }
 
   state = {
       categories: []
   };
 
   render() {
       const { navigation: { navigate } } = this.props;
 
       return (
           <ScrollView>
               <List>
                   {
                       categories.map((item) => {
 
                           const { collection, alias, name } = item;
 
                           return (
                               <ListItem
                                   key={alias}
                                   onPress={() => navigate('ImagesGrid', { collection, title: name })}
                               >
                                   <Text>{name}</Text>
                               </ListItem>
                           )
                       })
                   }
               </List>
           </ScrollView>
       );
   }
}
 
export default CategoriesList;

На экран с перечнем категорий теперь нужно добавить какой-нибудь способ для навигации между нашими экранами.

Когда мы в файле navigation.js передали в качестве аргумента экран CategoriesList, то создали вокруг него HOC (компонент высшего порядка), который в пропсы этого компонента передал некоторые данные и методы для реализации навигации. Нас интересует в первую очередь метод navigation.navigate(). Как вы уже поняли по его названию, он осуществляет навигацию между разными экранами; какой именно экран нужно использовать, определяем в первом аргументе. Название экрана совпадает с названиями объектов в коллекции, которую мы передавали в createStackNavigator (navigation.js); вторым параметром можем передать объект с дополнительными данными, которые будут передаваться в createStackNavigator и на следующий открытый экран. Сейчас с помощью второго аргумента мы передаем идентификатор коллекции (collection) и заголовок экрана (title).

А теперь давайте создадим экран просмотра коллекции, в котором будет реализована логика навигации и загрузки изображений.

Дисклеймер. Я знаю, что с точки зрения современного модного молодежного фронтенда, в котором в основном используется redux и какая-нибудь saga для манипуляций с состоянием приложения, обращения к АПИ находятся в коде подальше от представлений. Поэтому если вы вдруг решили создавать серьезное приложение, то лучше отнестись к его архитектуре максимально серьезно (насколько вообще можно говорить о серьезности в контексте приложения на JS :). Прочитайте об однонаправленном потоке данных, Flux, Redux и прочем.

./screens/GridScreen.js

import React from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import ImageGrid from '../components/ImageGrid';
 
export default class GridScreen extends React.Component {
 
   state = {
       isLoaded: false,
       imagesList: []
   }
 
   async componentDidMount() {
       /**
        * navigation - прокидвывает react-navigation в пропсы.
        * В navigation.state.params находятся наши дополнительные данные,
        * которые мы передавали в главном экране
        */
       const { navigation } = this.props;
 
       try {
           const response = await fetch(
               navigation.state.params.collection,
               { method: 'GET', redirect: 'follow'}
           );
           const data = await response.json();
           this.setState({
               imagesList: data,
               isLoaded: true
           });
       } catch (e) {
           console.log(e);
       }
   }
 
   render() {
       if (!this.state.isLoaded) {
           return (
               <View style={styles.loaderContainer}>
                   <ActivityIndicator size="small" style={styles.loader} />
               </View>
           );
       }
 
       return (<ImageGrid list={this.state.imagesList}/>);
   }
}
 
const styles = StyleSheet.create({
   loader: {
       flex: 1,
       justifyContent: "center",
       alignItems: "center"
   },
   loaderContainer: {
       flex: 1
   }
});

Немного выше в экране CategoriesList мы передавли через метод navigate параметр collection. Теперь в этом экране мы достаем его из props. Collection является урлом для получения массива в json. Его мы будем парсить, получать картинки и выводить их на экран.

Метод componentDidMount у нас асинхронный. В нем мы будем подгружать json с информацией и ссылками на картинки, обрабатывать его и уже на основе данных в нем выводить контент. Тут для людей, которые работают с реактом, ничего нового нет, но на всякий случай поясню. Когда только открывается экран и мы еще не запросили данные по API, у нашего приложения состояние загрузки state.isLoading = true. В этом случае компонент выводит спинер загрузки (он же нативный компонент ActivityIndicator). Когда же загрузка заканчивается, мы обновляем state распарсенным массивом картинок и свойством isLoaded = false, компонент перерисовывается, но на этот раз у него уже есть массив, который можно передать в компонент ImageGrid, чтобы он отрисовал нам картинки. Обратите внимание на стили loader и loaderContainer — да, в RN есть всеми нами любимый flex. Такая комбинация стилизаций дает возможность отображать объекты строго посередине экрана.

Если вы все сделали правильно, то после запуска npm run ios или npm run android в эмуляторе вы должны увидеть что-то похожее на это:

Метод componentDidMount у нас асинхронный. В нем мы будем подгружать json с информацией и ссылками на картинки.

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

На этом абзаце, скорее всего, 50% читателей закрыли статью и пошли дописывать резюме (еще 40% сделали это наверняка раньше), но мне еще есть что вам сказать.

Дебагинг

Наверняка вы заметили, что в консоли приложение показывает различный вывод. Мы можем пользоваться привычным console.log() для вывода там значений переменных.

Помимо этого, Expo предоставляет мощную (как сын маминой подруги) утилиту для дебагинга в тестовом режиме. На mac в эмуляторе нужно нажать сочетание клавиш Command + D. Откроется окно, в котором нужно нажать Toggle Element Inspector.

https://s.dou.ua/storage-files/image7_kmHN3JA.png

Перейдем в режим дебага, который функционально похож на инспектор в Chrome: там можно просмотреть структуру компонентов и примененные к ним стили, подсветить touchable-элементы и т. д.

https://s.dou.ua/storage-files/image1_QwBryCi.png

Используйте нижнюю панель для переключения между режимами инспектора. Чтобы закрыть его, нажмите сочетание клавиш Command + D и снова — Toggle Element Inspector.

Компиляция в установочный файл

Затронем тему компиляции в установочный файл и подготовку к релизу на платформы App Store и Google Play. Для начала нужно будет подготовить конфигурационный файл app.json. В нем надо указать данные для релиза: ID бандла, ссылку на иконку приложения, версию и прочее. После этого нужно будет лишь запустить команду Expo для билда expo build:android или expo build:ios, в процессе он запросит у вас необходимую для компиляции информацию. В случае с приложениями для iOS Expo предлагает заменеджерить сертификаты и электронные подписи, которые используются в процессе дистрибуции, что довольно удобно (думаю, не надо бояться, что разработчики Expo украдут ваши креды (лично я побаивался!)).

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

Ура, это все!

Ты дочитал до конца, а значит ты — молодец. Поэтому вот тебе ссылка на GitHub с проектом, который мы рожали всю эту статью.

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

В общем, как-то так. Жду ваших вопросов, проклятий и шуток в комментариях. Кому интересен процесс релиза в Apple Store, тоже пишите: если желающих будет много, превозмогу себя и расскажу о подготовке к релизу.

Темы: frontend, JavaScript, junior, react, tech, разработка