javascript

Дели — сокращай, или как мы делали мобильный 2ГИС Онлайн

  • суббота, 22 июля 2017 г. в 03:13:07
https://habrahabr.ru/company/2gis/blog/333016/
  • JavaScript
  • Блог компании 2ГИС




Мобильный веб развивается семимильными шагами. На дворе 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.


Ленивая загрузка


image

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 выглядят различия между тем, что нарисовал дизайнер, и тем, что сверстал разработчик.


image

А так мы проверяем «одиннадцатиклассницу» и видим, что ничего не поехало:


image

Заключение


Подытожим. Мы проделали большую работу по уменьшению бандла и увеличению скорости загрузки страницы. Данные хранятся на клиенте. Вендор весит на 90% меньше, чем изначально мог бы. Бандл не включает лишних библиотек и полифилов, а нужные отдаются кусочками по потребности. TypeScript дает больше контроля над приложением, а Makeup упрощает работу с визуальной составляющей Монлайна.


В планах разбить «жирные» модули в зависимости от контекста (например, карточку фирмы или геообъекта разделить на отдельные чанки) и попробовать разбить редьюсеры.


Статья посвящена JS-коду. Но разработческое кредо «дели — сокращай» распространяется и на CSS-бандл. В будущем мы также планируем заняться и сплиттингом стилей.