Можно ли делать быстрый сервис
без Server Side Rendering

Якобчук Андрей (Ultimate Guitar)

Про меня

Ведущий Front-end Ultimate Guitar.
Не пью, не курю, не ругаюсь матом (грязно)

  • #1 в мире гитаристов
  • 20 лет на рынке
  • Более 64 миллионов визитов в месяц

Agenda

  1. Наша история
  2. Разработка через Аналитику
  3. Ограничения на страже скорости
  4. Как не грузить лишнего
  5. Как уменьшить размер бандла

Наша история

Проблемы

  • неконсистентность дизайна на web и в native
  • разработка на нативе идет медленно

Выход -
React + Redux + React native

  • Быстрее пилить
  • Фичу может делать один человек
  • Можно шарить код
  • Кроссплатформенная дизайн система

Пришла, очередь веба.

Без SSR

  1. Cлишком дорого: +30% серверных мощностей
  2. надо увеличивать команду
  3. минус скорость разработки

Новые метрики

  • Layout ready
  • Page content ready ⇒ DOM ready

На десктопе все хорошо

Когда интернет широкий, то и проблем нет

  • DOM ready - 1.2с
  • PageSpeed > 90

На мобе - боль

Первый подход

Метрика Старое Новое
Вес 84кб + 50кб (реклама) 158кб
Dom ready 3G 3.8c 5.0c
PageSpeed 99 84

Продажи — -50%

Второй подход

Метрика Старое Новое
Вес 70кб + 50кб (реклама) 144кб
Dom ready 3G 2.5c 3.4c
PageSpeed 99 89

Продажи — -10%

Третий подход

Метрика Старое Новое
Вес 70кб + 50кб (реклама) 112кб
Dom ready 3G 2.5c 2.9c
PageSpeed 99 93

Продажи — +2%

Success

По факту

  • Без SSR жить можно, но очень сильно возрастают требования к Front-end части проекта

Разработка через Аналитику

"Все должно быть измерено"

Различные метрики
загрузки страницы

  • Server response time
  • TTFB
  • DOM ready
  • и т.д

Дополнительные
метрики

  • Layout ready
  • Page content ready

Мерить под разными
разрезами

  • типы устройств
  • сети (2G, 3G, 4G...)
  • регионы

На дружбе у гугла

Сбор метрик

  • Скорость загрузки - window.performance.timing
  • Pagespeed - собираем через API v4 Google

По факту

  • Следи за метриками пользователя, чтобы оперативно реагировать на проблемы
  • Cледи за PageSpeed от Google. Это одна из метрик, от которой зависит ранжирование твоего сайта

Ограничения
на страже скорости

"Хорошая сиcтема - эта та, которая позволяет избежать ошибок"

Ты не задеплоишь, если

  • Превышен размер любого чанка в 220000 байт (60-65кб GZIP)

Ограничение
по технологическому стеку

  • 99% вещей можно решить текущими средствами
  • два похожих решения нельзя
  • проведи исследование

По факту

  • Ставь полезные ограничения, которые позволят твоему сервису оставаться более простым и быстрым
  • Периодически проводи плановый perfomance аудит своего сервиса

Как не грузить лишнего

  • Бейте код по роутам
  • Redux store splitting
  • Redux async loading actions
  • Графика в webp
  • Оптимизация графики на лету
  • Реклама. Waterfall vs Header Bidding
  • Как не грузить всю страницу целиком

Бейте код по роутам

Получаем тип страницы

const routingMap = {
    tab: {
        textTab: {
            index: PAGE_TYPE_TAB_TEXT,
        },
    },
    common: {
        system: {
            error: {
                [ROUTE_TYPE_DEFAULT]: PAGE_TYPE_ERROR,
            }
        }
    }
}

const getRouteType = ({ routingMap, props }) => { return routingMap?[props.module]?[props.controller]?[props.action] || ROUTE_TYPE_DEFAULT; }

Готовим мепинг страниц по типам

const textTabLoader = () => [
    import(/* webpackChunkName: 'tab_text' */ './TextTabContentContainer'),
    import(/* webpackChunkName: 'tab_text' */ '../dataConverter/tabDataConverterDesktop'),
]

const loadersMap = { [PAGE_TYPE_ERROR]: getErrorContainer, [PAGE_TYPE_TAB_TEXT]: textTabLoader, }

Грузим контент страницы

class PageContainer extends Component {
    Content = null

    update = chunks => {
        const [ Content, convert ] = chunks.map(chunk => chunk.default);

        this.Content = Content;
        this.props.convertPageData({ 
            payload: this.props.data, 
            convert,
        });
    }

    loadPage() {
        const routeType = getRouteType({ 
            props: this.props.routeProps,
            routingMap: this.props.routungMap,
        });
        this.props.loadersMap[routeType]().then(this.update);
    }

    render() {
        return (
            <LayoutContainer>{this.Content}</LayoutContainer>
        )
    }
}

Грузим контент страницы

class PageContainer extends Component {
    Content = null

    update = chunks => {
        const [ Content, convert ] = chunks.map(chunk => chunk.default);

        this.Content = Content;
        this.props.convertPageData({ 
            payload: this.props.data, 
            convert,
        });
    }

    loadPage() {
        const routeType = getRouteType({
            props: this.props.routeProps,
            routingMap: this.props.routungMap,
        });
        this.props.loadersMap[routeType]().then(this.update);
    }

    render() {
        return (
            <LayoutContainer>{this.Content}</LayoutContainer>
        )
    }
}

Грузим контент страницы

class PageContainer extends Component {
    Content = null

    update = chunks => {
        const [ Content, convert ] = chunks.map(chunk => chunk.default);

        this.Content = Content;
        this.props.convertPageData({ 
            payload: this.props.data, 
            convert,
        });
    }

    loadPage() {
        const routeType = getRouteType({
            props: this.props.routeProps,
            routingMap: this.props.routungMap,
        });
        this.props.loadersMap[routeType]().then(this.update);
    }

    render() {
        return (
            <LayoutContainer>{this.Content}</LayoutContainer>
        )
    }
}

Грузим контент страницы

class PageContainer extends Component {
    Content = null

    update = chunks => {
        const [ Content, convert ] = chunks.map(chunk => chunk.default)

        this.Content = Content;
        this.props.convertPageData({ 
            payload: this.props.data, 
            convert,
        });
    }

    loadPage() {
        const routeType = getRouteType({
            props: this.props.routeProps,
            routingMap: this.props.routungMap,
        });
        this.props.loadersMap[routeType]().then(this.update);
    }

    render() {
        return (
            <LayoutContainer>{this.Content}</LayoutContainer>
        )
    }
}

Грузим контент страницы

class PageContainer extends Component {
    Content = null

    update = chunks => {
        const [ Content, convert ] = chunks.map(chunk => chunk.default);

        this.Content = Content;
        this.props.convertPageData({ 
            payload: this.props.data, 
            convert,
        });
    }

    loadPage() {
        const routeType = getRouteType({
            props: this.props.routeProps,
            routingMap: this.props.routungMap,
        });
        this.props.loadersMap[routeType]().then(this.update);
    }

    render() {
        return (
            <LayoutContainer>{this.Content}</LayoutContainer>
        )
    }
}

Redux store splitting

Создаем store

import { compose, applyMiddleware, combineReducers } from 'redux'

const createStore = ({ initialState, reducers }) => {
    const middlewares = applyMiddleware(thunk);
    const createStore = compose(middlewares)(createStore);
    const store = createStore(combineReducers(reducers), initialState);

    store.reducers = reducers;
    store.asyncReducers = {};

    return store
}

Хелпер для обновления store

const injectAsyncReducer = ({ store, name, asyncReducer }) => {
    store.asyncReducers[name] = asyncReducer;
    store.replaceReducer(combineReducers({
        ...store.reducers,
        ...store.asyncReducers,
    }));
}

Хелпер для обновления store

const injectAsyncReducer = ({ store, name, asyncReducer }) => {
    store.asyncReducers[name] = asyncReducer;
    store.replaceReducer(combineReducers({
        ...store.reducers,
        ...store.asyncReducers,
    }));
}

Обновление store

class PageContainer {
    update = chunks => {
        const [ Content, convert, asyncReducer ] = chunks.map(chunk.default);
        
        injectAsyncReducer({
            store: this.context.store,
            name: STORE_REDUCER_NAME_CONTENT,
            asyncReducer,
        });
        //...
    }
}

-100кб сжатого кода

Redux async
loading actions

Хелпер для получения
async loading actions

// @ug/helpers/redux.js

const getLoadableAction =
    getLoader =>
        ({ name, async }) =>
            (...args) => (dispatch, getState) =>
                getLoader().then(({ [name]: action }) => {
                    const result = action(...args)
                    async ? result(dispatch, getState) : dispatch(result)
                })
}

Хелпер для получения
async loading actions

// @ug/helpers/redux.js

const getLoadableAction =
    getLoader =>
        ({ name, async }) =>
            (...args) => (dispatch, getState) =>
                getLoader().then(({ [name]: action }) => {
                    const result = action(...args)
                    async ? result(dispatch, getState) : dispatch(result)
                })
}

Хелпер для получения
async loading actions

// @ug/helpers/redux.js

const getLoadableAction =
    getLoader =>
        ({ name, async }) =>
            (...args) => (dispatch, getState) =>
                getLoader().then(({ [name]: action }) => {
                    const result = action(...args)
                    async ? result(dispatch, getState) : dispatch(result)
                })
}

Хелпер для получения
async loading actions

// @ug/helpers/redux.js

const getLoadableAction =
    getLoader =>
        ({ name, async }) =>
            (...args) => (dispatch, getState) =>
                getLoader().then(({ [name]: action }) => {
                    const result = action(...args)
                    async ? result(dispatch, getState) : dispatch(result)
                })
}

Сoздание
async loading actions

import { getLoadableAction } from '@ug/helpers/redux';
                        
const getCommentsAction = getLoadableAction(() => import('./commentsActions'));

export const addComment = getCommentsAction({ name: 'addComment', async: true });
export const editComment = getCommentsAction({ name: 'editComment', async: true });
//...

Графика. WebP

Оптимизация графики
на лету

  • микросервис на Go
  • умеет сжимать картинки и ресайзить их на лету
  • 50 картинок в секунду
const MyComponent = ({ url, title }) => ( <ResponsiveImage url={url} size={40} alt={title} /> )

https://github.com/ultimate-guitar/reImage

Кастомная типографика

  • грузим асинхронно
  • стили храним у себя
  • не используем на мобе)))

Load
15s => 2s

http://prebid.org/

WIP: Prebid Server

Все то же самое, но торги на сервере

Как не грузить
всю страницу целиком

загружай только то,
что видно во viewport

  • React Async Components
  • Cтили по частям
  • Aсинхронная реклама
  • Асинхронная графика

React Async Components

import Loadable from 'react-loadable'; import logHelper from './logHelper'; const createLoadable => Loadable({ loader, loading(props) { if (props.error) { logHelper.error(props.error) } return null }, }); const AsyncFooterContainer = createLoadable(() => import('./FooterContainer'));
import Loadable from 'react-loadable'; import logHelper from './logHelper'; const createLoadable => Loadable({ loader, loading(props) { if (props.error) { logHelper.error(props.error) } return null }, }); const AsyncFooterContainer = createLoadable(() => import('./FooterContainer'));

Стили грузим по частям

  • Только те стили, которые нужны
  • Без дополнительных запросов

Асинхронно
подгружаемая реклама

Ленивая подгрузка картинок

https://github.com/aFarkas/lazysizes

По факту

  • State managment (redux) тоже можно засплитить
  • Используй webp для браузеров, которые это поддерживают
  • Оптимизируй графику на лету
  • Используй более оптимальный способ загрузки рекламы (Header Bidding)
  • Загружай только то, что нужно в данный момент

Как уменьшить
размер бандла

  • React, который не React
  • Аглификация имен классов
  • Не все сладости одинаково полезны...
  • Babel 7
  • Webpack 4

React, который не React

Preact https://preactjs.com/

  • 32 кб GZIP кода (React + React DOM)
  • 6кб GZIP кода (Preact + Preact-Compat)

На фронте без изменений

module.exports = {
    resolve: {
        alias: {
            react: 'preact-compat',
            'react-dom': 'preact-compat',
            // ...
        },
    }
}

Аглификация имен css классов

Конфиг webpack

const createCssLoader = ({ isDevelopment, cssLoader }) => ({
    loader: 'css-loader',
    options: {
        minimize: !isDevelopment,
        sourceMap: false, // isDevelopment,
        modules: true,
        camelCase: true,
        url: true,
        importLoaders: 1,
        localIdentName: isDevelopment ? '[name]--[local]--[hash:base64:5]' : '[hash:base64:5]',
        ...(cssLoader || {}),
    },
});

Не все сладости
одинаково полезны

Async/await

До

const getSymbolContent = async filePath => { const content = await readFileAsync(filePath); return unwrap(content); }

После

function _asyncToGenerator(fn) { return function () { var gen = fn.apply(this, arguments); r return new Promise(function (resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function (value) { step("next", value); }, function (err) { step("throw", err); }); } } return step("next"); }); }; } var getSymbolContent = function () { var _ref = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee(filePath) { var content; return regeneratorRuntime.wrap(function _callee$(_context) { while (1) { switch (_context.prev = _context.next) { case 0: _context.next = 2; return readFileAsync(filePath); case 2: content = unwrap(_context.sent); return _context.abrupt("return"); case 4: case "end": return _context.stop(); } } }, _callee, undefined); })); return function getSymbolContent(_x) { return _ref.apply(this, arguments); }; }();

То же самое на Promise

const getSymbolContent = filePath => readFileAsync(filePath).then(unwrap)

То же самое на Promise

const getSymbolContent = function(filePath) { return readFileAsync(filePath).then(unwrap); }

А теперь пожмем

var r=function(){var r=a(m.mark(function r(e){var n;return m.wrap(function(r){for(;;)switch(r.prev=r.next){ case 0:return r.next=2,y(x);case 2:return n=r.sent ,r.abrupt("return",u(n));case 4:case"end":return r.stop()}}, r,void 0)}));return function(e){return r.apply(this,arguments)}}();
var m=function(n){return b(x).then(e)};

247 БАЙТ
vs
39 БАЙТ

Классы против функций

@connect(state => ({ isOpen: state.notifications.isOpen, })) class NotificationMobilePageContainer extends Component { render() { return this.props.isOpen && <AsyncUserNotificationsContainer /> } }
function e() { return function(n, t) { if (!(n instanceof t)) throw new TypeError("Cannot call a class as a function") }(this, e), function(n, t) { if (!n) throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); return !t || "object" != typeof t && "function" != typeof t ? n : t }(this, t.apply(this, arguments)) } return function(n, t) { if ("function" != typeof t && null !== t) throw new TypeError("Super expression must either be null or a function, not " + typeof t); n.prototype = Object.create(t && t.prototype, { constructor: { value: n, enumerable: !1, writable: !0, configurable: !0 } }), t && (Object.setPrototypeOf ? Object.setPrototypeOf(n, t) : n.__proto__ = t) }(e, t),

+649 байт
на класс

var NotificationMobilePageContainer = (function(_Component) {
  _inherits(NotificationMobilePageContainer, _Component);

  function NotificationMobilePageContainer() {
    _classCallCheck(this, NotificationMobilePageContainer);

    return _possibleConstructorReturn(this, _Component.apply(this, arguments));
  }

  NotificationMobilePageContainer.prototype.render = function render() {
    return (
      this.props.isOpen &&
      React.createElement(AsyncUserNotificationsContainer, null)
    );
  };

  return NotificationMobilePageContainer;
})(Component);

connect(function(state) {
  return {
    isOpen: state.notifications.isOpen
  };
})(NotificationMobilePageContainer);

То же самое
в функциональном стиле

compose(
      connect(state => ({
          isOpen: state.notifications.isOpen,
      })),
      branch(props => props.isOpen, renderNothing)
)(NotificationMobilePageContainer)

355 байт
vs
94 байта

Стиль программирования
влияет на вес приложения

Babel 7

  • -20% к весу бандла
  • Держим сборку в loose режиме нам дало еще -3%
  • Babel runtime тащит core.js по частям

Webpack 4

  • Более быстрая сборка — деплой 1:40 ⇒ 1:10 мин
  • -13% к общему весу сборки
  • Tree shaking, работает но с оговорками

По факту

  • При выборе технологических решений, всегда смотри на сколько они увеличат вес твоего приложения
  • При минификации стилей, дополнительно минифицируй и названия классов
  • Cтиль программирования влияет на вес приложения
  • Самый большой прирост дал Babel 7, за счет более аккуратной транспиляции

Заключение

  • Можно жить без SSR, но требования к Front-end в этом случае еще более высокие
  • Комплексный поход к аналитике позволит тебе лучше понимать своих пользователей и оперативно реагировать на проблемы
  • Оптимизаци это не прекращающийся процесс