Мобильный веб развивается семимильными шагами. На дворе 2017 год. Мобильный трафик превысил десктопный — больше половины всех страниц теперь открываются через телефоны или планшеты. В 2015 году Google объявил о предпочтении mobile-friendly сайтов при ранжировании выдачи, а в 2016 это сделал Яндекс. Юзеры проводят в интернете 60-70 часов в месяц с мобильных устройств и не готовы идти на компромисс и пользоваться неадаптивными сайтами. И 2ГИС — не исключение. За 2 года рост мобильного трафика 2ГИС Онлайн составил 74%, а месячная аудитория превысила 6 миллионов человек.
17 апреля мы зарелизили новый мобильный онлайн («Монлайн») — одностраничное приложение, доступное по адресу m.2gis.ru. Приложение запущено в двух городах: Уфе и Новосибирске, а в ближайшее время планируется релиз на всю Россию.
Мы знали, что при мобильной разработке столкнемся с тремя проблемами:
Мобильный интернет
Мобильный интернет (2G, 3G, 4G) или Wi-Fi медленнее и не такой стабильный, как кабельный.
Мобильные устройства
Проблема мобилок — слабый процессор. Это влияет на парсинг JS, рендеринг и анимации.
Мобильные браузеры
Некоторые популярные мобильные браузеры не поддерживают часть CSS-свойств или методов JS API. Иногда мы думали, что вернулись в темные времена разработки под IE8. Мобильный интернет и процессор не позволяли использовать полифилы, а значит, приходилось выкручиваться самостоятельно.
Мы сохранили функциональность десктопной онлайн-версии и заставили Монлайн работать даже на убитом телефоне вашего дедушки под Пензой. О том, как мы это сделали, расскажем ниже.
Дели — сокращай
В мире мобильного веба JS-разработчик следует одному кредо: дели — сокращай. Конечный бандл мобильного приложения должен мало весить и быть разбит на куски.
Выбор фреймворка и библиотек
Главным и единственным фреймворком фронтенда является Preact. Это не опечатка. Preact — легковесная альтернатива React, использующая тот же API и работающая с Virtual DOM. Преимуществами фреймворка является небольшой вес (3 kB gzip против 45 kB у React) и более высокая скорость рендеринга. Благодаря использованию Preact вместо React размер нашего вендора сократился на 90%.
Preact отличается от React. Например, у него отсутствуют propTypes, но эта проблема решилась введением статической типизации, о которой расскажем в п. 3. Подробно различия между фреймворками описаны в официальном репозитории на github.
Кроме того, мы стараемся писать самостоятельно и не подключать сторонние полифилы и тяжелые библиотеки. Необходимые полифилы грузятся асинхронно через require.ensure и не попадают в бандл. Каждый полифил подключается только в зависимости от условий. Например, в случае с полифилом для Android Browser — мы сэкономили 5 kB кода в gzip.
Ленивая загрузка
JS сформирован. Теперь пришло время его разбить. JS код делится на 3 группы:
- вендор — сторонние библиотеки;
- app-бандл, в котором хранится главный код приложения;
- либы и полифилы, подключаемые в зависимости от условий, таких как версия браузера;
Вендор минимизировали через выбор Preact, либы и полифилы подключаем асинхронно и по потребности. Остался последний герой. Гзипнутый app-бандл весом в 143 килобайта в gzip. Это роскошь для мобильной разработки. Так, если пользователь зашел в карточку организации, нет смысла моментально грузить код, отвечающий за рендеринг карточки метро или достопримечательности. Чтобы уменьшить размер app-бандла и доставлять клиенту как можно меньше кода, мы сделали ленивую загрузку.
Требования к коду № 1 в Монлайне гласит: «Код должен быть максимально простым и не знать или делать лишнего». На этом правиле базируется структура UI. В проекте 11 контейнеров и 85 «глупых» компонентов. «Глупые» компоненты не знают о существовании друг друга. Контейнеры объединяют компоненты в структуры и передают данные через пропсы. Карточка организации или отзыва, выдача зданий — примеры контейнеров. 6 контейнеров из 11 не связаны друг с другом, что позволяет разбить app-бандл на… интрига… 6 дополнительных чанков. При восстановлении браузер загружает app-бандл и нужный чанк, а затем асинхронно подгружаются остальные JS-файлы, отвечающие за неактивные контейнеры. Это не блокирует работу приложения. После релиза ленивой загрузки вес app-бандла сократился на 38%.
Хранение и нормализация данных на клиенте
В мобильном вебе критически важно быстро отображать страницы. В Монлайне, как и у 99,9% SPA, часть информации в контейнерах пересекается. Возьмем выдачу фирм и карточку отдельной организации. В выдаче выводится заголовок, адрес, расписание и т. д. Та же информация отображается и в карточке фирмы. Такая информация не меняется, пока юзер пользуется приложением. Нет смысла ждать ответа от сервера, если пользователь уже просматривал эту информацию в прошлом, ведь ее можно хранить на клиенте в единственном экземпляре и показывать данные здесь и сейчас.
Нормализация данных на клиенте — это хранение уникальных данных в стейте, переиспользуемых в контейнерах. Хранение данных на клиенте увеличивает скорость отображения контента, а нормализация убирает дубли и разделяет статические данные и данные, зависящие от контекста.
Нормализация держит стейт в чистоте. Если статические данные просмотрены, то они сохраняются в стейте в отдельной таблице. Например, если пользователь открывает одну фирму несколько раз, то в стейте она хранится в одном экземпляре с доступом по id. И информация об этой фирме используется для любых нужд: в карточке самой организации, выдачах, отзывах, фотографиях и т. д. За форматирование данных отвечают селекторы контейнеров.
Приведем еще один пример. Попасть в карточку компании можно через поисковый запрос, переход по рубрике или просто восстановившись по ссылке с ее id. Поисковый запрос или id рубрики добавляется в URL, который роутер распарсивает при восстановлении. Если в URL есть что-то помимо id фирмы, в стейт сохраняются как информация о ней, так и данные о первых 10 фирмах, соответствующих поисковому запросу или рубрике. При переходе из карточки компании в выдачу пользователь мгновенно получает список организаций и так же быстро может открывать страницу каждой фирмы.
Люди не любят ждать не только загрузку контента, но и ответа при отправке данных через формы. Хранение данных на клиенте уменьшает время ожидания при сохранении информации. Информация в стейте развязывает руки при создании оптимистичных интерфейсов. Оптимистичный UI-дизайн показывает конечное состояние до того, как приложение в действительности заканчивает (или даже начинает) операцию. После сабмита данных пользователь мгновенно получает информацию об их сохранении благодаря тому, что информация юзера сохраняется в стейте и отправляется на сервер в фоновом режиме. Только если с сервера пришел ответ с ошибкой, приложение оповестит юзера соответствующим сообщением, в остальных случаях ответ сервера не выводится на экран.
Облегчаем себе разработку
В создании Монлайна поучаствовали 34 разработчика. В мастер слили 2 000 коммитов, написали 77 000 строк кода и создали 1425 файлов. В проекте участвовали люди из других команд, ребята приходили на стажировки. Мы хотели ускорить процесс разработки, сделать код понятным и документированным. Поэтому решили отказаться от динамической типизации в JavaScript.
Статическая типизация
Клиентская часть приложения написана на TypeScript. Статическая типизация — главное преимущество TypeScript над JS. Она бьет по рукам в случае ошибок при компиляции, документирует код изнутри и облегчает рефакторинг и отладку.
Для управления состоянием приложения в проекте используется Redux. Redux сочетается с TypeScript. Разработчик знает, что передается в payload или meta и, соответственно, что приходит в редьюсер. Например:
export const setScrollTop = (payload: number) => ({
type: APPCONTEXT_CHANGE_SCROLL_TOP,
payload
});
export const setErrorToFrame = (errorCode: ErrorCodeType) => ({
type: APPCONTEXT_SET_ERROR_TO_FRAME,
payload: { errorCode }
});
TypeScript упрощает работу с редьюсерами (чистые функции, которые вычисляют новую версию стейта и возвращают ее). Например, редьюсер, отвечающий за обновления информации о текущем контексте приложения в стейте, обрабатывает 69 экшнов. На выходе получаем метод со свитчем в 100 строк кода, возвращающий новый стейт. Критически важно в таком большом полотне обрабатывать только нужные экшны.
export default function (state: AppContext = defaultState, action: AppAction): AppContext {
switch (action.type) {
case APPCONTEXT_ADD_FRAME:
return appAddFrame(state, action.payload);
case APPCONTEXT_REMOVE_ACTIVE_FRAME:
return appRemoveActiveFrame(state);
....
case APPCONTEXT_HIDE_MENU:
return { ...state, isSideMenuShown: false };
default:
return state;
Избежать путаницы с набором экшнов в редьюсере и данными в payload или meta помогает Discriminated Unions. В коде выше видно, что аргумент action описан типом AppAction, который выглядит так:
export type AppAction = AppAddFrameAction | AppRemoveActiveFrame | AppChangeFramePos | AppChangeMode | AppChangeLandscape…
AppAction объединяет в себя 60 интерфейсов (appAddFrameAction, AppRemoveActiveFrame и т. д.). Каждый интерфейс описывает экшн. Тип (type) экшна — строковый литерал — это дискриминант. Он определяет наличие и содержание внутренностей объекта, таких как payload или meta.
export interface AppAddFrameAction {
type: 'APPCONTEXT_ADD_FRAME';
payload: Frame;
}
export interface AppRemoveActiveFrame {
type: 'APPCONTEXT_REMOVE_ACTIVE_FRAME';
}
export interface AppChangeFramePos {
type: 'APPCONTEXT_CHANGE_FRAME_POS';
payload: FramePos;
}
Так TypeScript понимает, что для экшна с дискриминантом 'APPCONTEXT_ADD_FRAME' нужно передать payload с интерфейсом Frame, а в случае 'APPCONTEXT_REMOVE_ACTIVE_FRAME' ничего передавать не нужно.
Preact также сочетается с TypeScript. В Preact отсутствуют реактовские propTypes, решающие проблемы проверки типов у компонента. Но TypeScript восполняет потерю. Например, разработчик знает, что передается через пропсы компоненту и что хранится в стейте.
export interface IconProps {
icon: SVGIcon;
width?: number;
height?: number;
color?: string;
className?: string;
}
export class Icon extends React.PureComponent<IconProps, {}> {
constructor(props: IconProps) {
super(props);
}
public render() {
const { color, icon } = this.props;
const iconStyle = color ? { color: this.props.color } : undefined;
return (
<svg
width={this.props.width || icon.width}
height={this.props.height || icon.height}
style={iconStyle}
className={this.props.className}>
<use xlinkHref={icon.id} />
</svg>
);
}
}
TypeScript задокументирован и имеет песочницу. Разумеется, язык не всесилен, на июль 2017 года открыто 2200 issues. Продукт Microsoft не поддерживает часть нововведений в ES6. Но медленно и верно эти проблемы решаются в каждом новом релизе.
Создание и тестирование верстки
В мобильном онлайне — 85 «глупых» компонентов. «Глупые» компоненты — визуальные сущности, отвечающие за представление полученных данных. В первую очередь мы хотели разделить верстку и интеграцию этих компонентов в приложении. Это позволило бы ускорить code review и тестирование ресурсом разработчиков. Для достижения этих целей используется Makeup.
Makeup — графический интерфейс для быстрого и комфортного ручного регрессионного тестирования верстки. Подробно почитать об инструменте можно здесь, а потрогать — здесь.
С помощью Makeup верстка компонентов делается на отдельном хосте с замоканными данными без привязки к приложению. Это позволяет тестировать визуализацию компонента на уровне разработки, подгонять верстку под pixel perfect и уже позже заниматься интегрированием.
Так в Makeup выглядят различия между тем, что нарисовал дизайнер, и тем, что сверстал разработчик.
А так мы проверяем «одиннадцатиклассницу» и видим, что ничего не поехало:
Заключение
Подытожим. Мы проделали большую работу по уменьшению бандла и увеличению скорости загрузки страницы. Данные хранятся на клиенте. Вендор весит на 90% меньше, чем изначально мог бы. Бандл не включает лишних библиотек и полифилов, а нужные отдаются кусочками по потребности. TypeScript дает больше контроля над приложением, а Makeup упрощает работу с визуальной составляющей Монлайна.
В планах разбить «жирные» модули в зависимости от контекста (например, карточку фирмы или геообъекта разделить на отдельные чанки) и попробовать разбить редьюсеры.
Статья посвящена JS-коду. Но разработческое кредо «дели — сокращай» распространяется и на CSS-бандл. В будущем мы также планируем заняться и сплиттингом стилей.