В этой статье мы рассмотрим как можно ускорить первоночальную загрузку web-приложения c помощью разделения кода (code splitting). Для реализации задуманного я буду использовать webpack v1, а для демонстрации — React (не обязателен).

В большинстве своих проектов я собираю все javascript файлы (а иногда css и картинки тоже) в ОДИН ОЧЕНЬ БОЛЬШОЙ bundle.js. Возможно ты, дорогой читатель, делаешь точно так же. Это достаточно стандартная практика для современных веб-приложений.

Но этот подход имеет один (и иногда достаточно важный) недостаток: первоночальная загрузка приложения может занимать очень долгое время, так как web-браузер должен (1) загрузить огромный файл и (2) распарсить тонну js-кода. Загрузка файла может занять долгое время, если у пользователя медленный интернет. Так же этот огромный файл может содержать код компонентов, которые пользователь НИКОГДА не увидит (например, пользователь просто не откроет некоторые части вашего приложения).

Что делать?

Прогрессивная загрузка


Одно из решений для лучшего UX — это Progressive Web App. Если термин не знаком, предлагаю его быстренько погуглить, можно найти множество хороших видео и статей. Так Progressive Web App содержит в себе много интересных идей, но сейчас я хочу сфокусироваться только на Progressive Loading (Прогрессивная загрузка).

Идея прогрессивной загрузки достаточно простая:
1. Сделать первоначальную загрузку как можно быстрее
2. Загружать UI компоненты только по мере надобности

Предположим у нас есть некоторое React приложение, которое рисует график на странице:

// App.js
import React from 'react';

import LineChart from './LineChart';
import BarChart from './BarChart';

export default class App extends React.Component {
    // не показываем графики при первой загрузке
    state = {
        showCharts: false
    };

    // показываем или скрываем графики
    handleChange = () => {
        this.setState({
            showCharts: !this.state.showCharts
        });
    }

    render() {
        return (
            <div>
                Show charts:
                <input
                  type="checkbox"
                  value={this.state.showCharts}
                  onChange={this.handleChange}
                />

                {
                    this.state.showCharts ?
                    <div><LineChart/><BarChart/></div>
                    : null
                }
            </div>
        );
    }
}

Компонет для отрисовки графика очень простой:

// LineChart.js

import React from 'react';
import {Stage, Layer, Line} from 'react-konva';

export default () => (
    <Stage width={100} height={100}>
        <Layer>
            <Line stroke="green" points={[0, 0, 20, 90, 50, 20, 100, 100]}/>
        </Layer>
    </Stage>
);


// BarChart.js

import React from 'react';
import {Stage, Layer, Rect} from 'react-konva';

export default () => (
    <Stage width={100} height={100}>
        <Layer>
            <Rect fill="red" width={20} height={20}/>
            <Rect fill="blue" x={50} width={20} height={60}/>
        </Layer>
    </Stage>
);

Подобные графики могут быть очень тяжёлыми. В данном примере каждый из них имеет react-konva в зависимостях (а также же konva, как зависимость react-konva).

Обратите внимание, что графики LineChart и BarChart не видны при первой загрузке. Для того, чтобы их увидел пользователь, ему необходимо отметить checkbox:

image

Возможно пользователь никогда не тыкнет checkbox. И это достаточно частая ситуация в реальных и больших приложениях, когда пользователь обращается не ко всем частям приложения или открывает их только через некоторое время. То с текущим подходом мы компонуем все зависимости в один файл. В данном случае мы имеем: корневой компонент App, React, компоненты графиков, react-konva, konva.

Собранный и минифицированный результат:

image

Использование сети во время загрузки:

image

280кб для bundle.js и 3.5 секунды для первоночальной загрузки на 3g соединении.

Реализация прогрессивной загрузки


Как можно удалить компоненты графиков из budle.js и загрузить их позже, тем самый сделав первоночальную зугрузку намного быстрее? Скажем привет старому доброму AMD (asynchronous module definition)! Так же Webpack имеет хорошую поддержку code splitting (разделение кода).

Я предлагаю реализовать HOC (higher order component, он же компонент высшего порядка), который загрузит код графика только тогда, когда компонент будет установлен в DOM (используем componentDidMount):

// LineChartAsync.js

import React from 'react';

export default class AsyncComponent extends React.Component {
    state = {
        component: null
    }
    componentDidMount() {
        // загружаем компонент при установлении в DOM
        require.ensure([], (require) => {
            // !Важно! Мы не может здесь использовать конструкцию вида:
            //    require(this.props.path).default;
            // Потому, что webpack не сможет статически анализировать такой код
            // поэтому нужно явно импортировать необходимый модуль
            const Component = require('./LineChart').default;
            this.setState({
                component: Component
            });
        });
    }
    render() {
        if (this.state.component) {
            return <this.state.component/>
        }
        return (<div>Loading</div>);
    }
}

Далее, вместо того, чтобы писать:

import LineChart from ‘./LineChart’;

Будем писать:

import LineChart from ‘./LineChartAsync’;

Посмотрим, что мы имеем после сборки:

image

У нас есть bundle.js, который содержит в себе компонент App и React.

Файлы 1.bundle.js и 2.bundle.js сгенерированы webpack'ом и включают в себя LineChart и BarChart. Но постойте, почему суммарный размер файлов стал больше? 143kb+143kb+147kb = 433kb. В предыдущем подходе было только 280kb. Всё потому, что зависимости LineChart и BarChart включены ДВАЖДЫ (react-konva и konva определены и в 1.bundle.js, и в 2.bundle.js). Мы может это исправить с помощью webpack.optimize.CommonsChunkPlugin:

new webpack.optimize.CommonsChunkPlugin({
    children: true,
    async: true,
}),

Так мы получим:

image

Теперь зависимости LineChart и BarChart перемещены в отдельный файл 3.bundle.js, и суммарный размер остаётся практически прежним — 289kb:

Использование сети при первой загруке:

image

Использование сети после показа графиков:

image

Теперь мы имеем 1.75 секунд для первоночальной загрузки. Это уже намного лучше чем 3.5 секунд.

Рефакторинг


Чтобы сделать код несколько лучше, я предлагаю немного переписать LineChartAsync и BarChartAsync. Сначала определим базовый компонент AsyncComponent:

// AsyncComponent.js

import React from 'react';

export default class AsyncComponent extends React.Component {
    state = {
        component: null
    }
    componentDidMount() {
        this.props.loader((componentModule) => {
          this.setState({
              component: componentModule.default
          });
        });
    }
    renderPlaceholder() {
      return <div>Loading</div>;
    }
    render() {
        if (this.state.component) {
            return <this.state.component/>
        }
        return (this.props.renderPlaceholder || this.renderPlaceholder)();
    }
}

AsyncComponent.propTypes = {
    loader: React.PropTypes.func.isRequired,
    renderPlaceholder: React.PropTypes.func
};

Далее BarChartAsync (и LineChartAsync) могут быть переписанны в более простые компоненты:

// BarChartAsync.js

import React from 'react';
import AsyncComponent from './AsyncComponent';

const loader = (cb) => {
  require.ensure([], (require) => {
      cb(require('./BarChart'))
  });
}

export default (props) =>
  <AsyncComponent {...props} loader={loader}/>

Но мы можем ЕЩЕ улучшить прогрессивную загрузку. Как только приложение первоначально загрузилось, мы можем загружать дополнительные компоненты в фоновом режиме. Возможно, они будут загружены до того, как пользователь отметит checkbox.

// BarChartAsync.js

import React from 'react';
import AsyncComponent from './AsyncComponent';
import sceduleLoad from './loader';

const loader = (cb) => {
  require.ensure([], (require) => {
      cb(require('./BarChart'))
  });
}

sceduleLoad(loader);

export default (props) =>
  <AsyncComponent {...props} loader={loader}/>

И loader.js будет выглядеть примерно так:

const queue = [];
const delay = 300;

let isWaiting = false;

function requestLoad() {
    if (isWaiting) {
      return;
    }
    if (!queue.length) {
      return;
    }
    const loader = queue.pop();
    isWaiting = true;
    loader(() => {
      setTimeout(() => {
        isWaiting = false;
        requestLoad();
      }, delay)
    });
}

export default function sceduleLoad(loader) {
  queue.push(loader);
  requestLoad();
}

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

image

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

const renderPlaceholder = () =>
    <div style={{textAlign: ‘center’}}>
        <CircularProgress/>
    </div>

export default (props) =>
    <AsyncComponent
       {…props}
       loader={loader}
       renderPlaceholder={renderPlaceholder}
    />

Заключение


В результате наших улучшений мы получаем:

1. Первоночальный bundle.js имеет меньший размер. А это значит, что пользователь увидит на экране что-то осмысленное намного раньше;
2. Дополнительные компоненты могут бы загруженны асинхронно в фоне;
3. Пока комнонет загружается, мы можем показать красивую заглушку или прогресс-бар, чтобы пользователь не скучал и видел процесс загрузки;
4. Для точно такой же реализации понадобится webpack. React я использовал в качестве примера, подобное решение можно использовать и с другими фреймворами/библиотеками.

Полный исходный код примера и конфигурационные файлы можно глянуть тут: https://github.com/lavrton/Progressive-Web-App-Loading.
Поделиться с друзьями
-->

Комментарии (15)


  1. skyline405
    15.08.2016 10:52
    +2

    Спасибо за статью! Достаточно актуальная тема, я считаю.


  1. arvitaly
    15.08.2016 11:11
    -9

    Нужно просто реализовать web-компоненты не в виде html, а в виде JS-модулей. И вообще отказаться от html и css. И создать 2 вида загрузки модуля: со всеми рекурсивными зависимостями и без. Кэширование, CDN, возможность использовать многократно npm-пакеты и что угодно. Один язык (пусть не JS, пусть будет webassembly). Возможно разделять отображение, стили и подгружать все это динамично, жду, когда же возьмется кто-то из крупняков.
    А следующий шаг — вообще нативная поддержка таких модулей браузерами, т.е. браузер будет поддерживать не только html, txt и xml, но и JS-компонент.
    В общем-то webpack мелкими шагами что-то частично делает, но нужна масштабная работа всех крупных вендоров и стандартизация, а все эти bundle — это костыли.


    1. hmspns
      15.08.2016 11:55
      +3

      Ага, а вместо вебсайтов качать экзешники, где всё то, что вы предлагаете уже может быть реализовано на java/c#/c++/100500 других языков программирования.


    1. stardust_kid
      15.08.2016 12:53
      +2

      А векторный гипертекст будем поддерживать?


    1. PaulMaly
      25.08.2016 14:13

      Если вам так не нравится html/css делайте интерфейсы через js, кто ж запрещает? Элементы через createElement, стили через element.style. Будет получаться очень многословно, так ведь можете это все обернуть во фреймверк и будет вам счастье)))


  1. kashey
    15.08.2016 13:37

    Одно только плохо — никакого нормального способа это делать в «родном» синтаксисе js почему-то не придумали.
    Такая необходимая фича, и ни намека про нее в стандарте :(
    Как будто не видели придумыватели стандартов решения на AMD и аналогах. Не знали что сервер по другую сторону WiFi, браузеры не резиновые, трава не зеленая.


    1. RubaXa
      15.08.2016 14:03
      +1

      Во-первых всё видели. Во-вторых, ES Modules ещё не стандартизированы окончательно, как раз в этом месте, а именно SystemJS.


      1. kashey
        15.08.2016 14:11
        -1

        Да знаю что видели. Говорят просто о чем-то другом, о возвышенном, думали когда мысли свои на бумагу переносили.
        Что «старый» importScripts невозможно использовать, что новый import/require почему-то думает что все исходники лежат в разных файлах и доступны надежно и с нулевым латенси.
        В ES6 Modules есть только один плюс — возможность статического анализа. Все остальное как-то не о том.
        А все эти webpack, browserify, requirejs, systemjs — это не от жизни хорошей. У нас вот тоже «своя» модульная система есть — ymb/yms.


    1. Laney1
      15.08.2016 15:00
      +1

      Одно только плохо — никакого нормального способа это делать в «родном» синтаксисе js почему-то не придумали

      потому что проблема на самом деле не в js, а в необходимости собирать все компоненты в один большой bundle. Которая, в свою очередь, возникает из-за того что протокол HTTP изначально не был оптимизирован для юзкейса «куча мелких запросов к одному сайту за небольшой промежуток времени». В HTTP/2 с этим уже все гораздо лучше, и возможно через пару лет все забудут про browserify, webpack, и т.п.


      1. arvitaly
        16.08.2016 12:16
        +1

        HTTP/2 не решает это. Проблема в иерархической структуре зависимостей. Мы не можем параллельно загружать все модули (требующиеся, например, стартовому) одновременно. Загружаем один, считываем его зависимости, их грузим параллельно, у каждого из них опять считываем зависимости, либо подгружаем модули во время исполнения, но это рождаем зависимость интерфейса от их загрузки, нам придется как-то договариваться об этом с пользователем (бесконечными лоадерами) или иметь сверх-скоростной. надежный в 99% случаях, интернет (возможно, в будущем так и будет).


  1. sneakyfildy
    16.08.2016 03:59

    Железная воля и неудержимая решимость требуется для того, чтобы озвученную технику внедрить в готовое приложение. Являюсь сторонником упаковки в большой файл, уповая на кэш и в ожидании мифического HTTP/2, который магически решит все мои проблемы (останется только поменять один конфиг и приложение пойдёт грузиться по зависимостям, а не пачкой)


  1. AlexanderY
    16.08.2016 03:59

    Я то думал, что-то новенькое придумали :)
    Не в обиду автору, подход нормальный и в чём-то правильный, но я бы ещё раз подумал, а точно ли хуже, когда мы один раз грузим bundle, зато потом приложение работает отзывчиво? А то таких «1.bundle» со временем накопятся десятки, и все они будут грузиться не сразу, а только после действия юзера, тем самым снижая отзывчивость вашего приложения. И получится, пожалуй, даже хуже, чем загружать сразу N файлов при первоначальной загрузке страницы.


    1. lavrton
      16.08.2016 04:02
      +1

      Не обязательно всё выпиливать на отдельные части. Можно только некоторые, особо тяжелые модули. И кстати в статье описано что не обязательно ждать дейсвтвий пользователя. Я описал как загружать модуль в фоне сразу после первоначальной загрузки.


      1. AlexanderY
        16.08.2016 07:31

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


  1. sergeyakozlov
    16.08.2016 14:51
    +1

    Большое человеческое спасибо! — Именно то, над чем сам думал )
    Дополнительный вопрос: Для уменьшения объёма продакшен-кода не пробовали использовать что-нибудь типа react-lite?:
    https://www.npmjs.com/package/react-lite
    Как-то очень фантастично звучит: +25Kb и React на борту — неужели правда?