Pull to refresh

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

Reading time6 min
Views28K
В этой статье мы рассмотрим как можно ускорить первоночальную загрузку 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.
Tags:
Hubs:
Total votes 25: ↑24 and ↓1+23
Comments17

Articles