Прогрессивная загрузка web-приложения с помощью разделения кода

В этой статье мы рассмотрим как можно ускорить первоночальную загрузку 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 (
            
Show charts: { this.state.showCharts ?
: null }
); } }

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

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

export default () => (
    
        
            
        
    
);
// BarChart.js

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

export default () => (
    
        
            
            
        
    
);

Подобные графики могут быть очень тяжёлыми. В данном примере каждый из них имеет 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 
        }
        return (
Loading
); } }

Далее, вместо того, чтобы писать:
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 
Loading
; } render() { if (this.state.component) { return } 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) =>
  

Но мы можем ЕЩЕ улучшить прогрессивную загрузку. Как только приложение первоначально загрузилось, мы можем загружать дополнительные компоненты в фоновом режиме. Возможно, они будут загружены до того, как пользователь отметит 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) =>
  

И 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 = () =>
    
export default (props) =>

Заключение


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

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

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

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

  • 15 августа 2016 в 10:52

    +2

    Спасибо за статью! Достаточно актуальная тема, я считаю.
  • 15 августа 2016 в 11:11

    –4

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

      +1

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

      0

      А векторный гипертекст будем поддерживать?
  • 15 августа 2016 в 13:37

    0

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

      0

      Во-первых всё видели. Во-вторых, ES Modules ещё не стандартизированы окончательно, как раз в этом месте, а именно SystemJS.
      • 15 августа 2016 в 14:11

        0

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

© Habrahabr.ru