Shim-модули и ProvidePlugin

При сборке современных JavaScript-приложений часто возникает необходимость подключать библиотеки, изначально не предназначенные для модульной системы ES Modules или CommonJS. Особенно это характерно для старых браузерных библиотек, глобальных плагинов и legacy-кода, завязанного на глобальные переменные window, global, self или this.

Webpack предоставляет механизм shim-модулей, позволяющий:

  • адаптировать старые библиотеки под модульную архитектуру;
  • внедрять глобальные зависимости;
  • эмулировать наличие переменных;
  • автоматически подключать модули;
  • обеспечивать совместимость стороннего кода.

Shim в контексте Webpack — это прослойка совместимости между немодульным кодом и системой модулей Webpack.


Проблемы legacy-библиотек

Многие старые библиотеки создавались до появления модульных стандартов. Они предполагают наличие зависимостей в глобальной области видимости.

Пример типичной legacy-библиотеки:

// plugin.js

(function () {
    window.myPlugin = function () {
        console.log(jQuery.fn.jquery);
    };
})();

Проблемы такого подхода:

  • библиотека ожидает глобальный jQuery;
  • отсутствует import;
  • отсутствует export;
  • порядок подключения критичен;
  • Webpack не знает о зависимости.

В результате при сборке возникает ошибка:

ReferenceError: jQuery is not defined

Для решения подобных задач используются:

  • ProvidePlugin
  • imports-loader
  • exports-loader
  • expose-loader
  • ручные shim-модули

ProvidePlugin

Назначение ProvidePlugin

ProvidePlugin автоматически подставляет импорт модуля при обнаружении определённого идентификатора в коде.

Это позволяет:

  • не писать import вручную;
  • автоматически подключать библиотеки;
  • эмулировать глобальные зависимости;
  • упрощать миграцию старого кода.

Принцип работы

Когда Webpack встречает указанный идентификатор, он автоматически внедряет импорт.

Пример:

new webpack.ProvidePlugin({
    $: 'jquery',
    jQuery: 'jquery'
})

Теперь при использовании:

$('.menu').hide();

Webpack автоматически преобразует код примерно в:

var $ = require('jquery');
$('.menu').hide();

Базовая настройка ProvidePlugin

Установка библиотеки

npm install jquery

Конфигурация webpack.config.js

const webpack = require('webpack');

module.exports = {
    plugins: [
        new webpack.ProvidePlugin({
            $: 'jquery',
            jQuery: 'jquery'
        })
    ]
};

Использование без import

$('.app').fadeIn();

Импорт больше не требуется.


Автоматическая подстановка импортов

Подстановка нескольких идентификаторов

new webpack.ProvidePlugin({
    _: 'lodash',
    axios: 'axios',
    Vue: ['vue/dist/vue.esm.js', 'default']
})

Особенность default export

Для ES-модулей иногда требуется указывать:

['module', 'default']

Пример:

Vue: ['vue/dist/vue.esm.js', 'default']

Webpack импортирует:

import Vue from 'vue/dist/vue.esm.js'

ProvidePlugin и React

Автоматический React import

До React 17 JSX требовал наличия React в области видимости.

Без импорта:

export default function App() {
    return <div>Hello</div>;
}

Возникала ошибка:

React is not defined

Решение:

new webpack.ProvidePlugin({
    React: 'react'
})

Webpack автоматически внедрял импорт React.


ProvidePlugin и Vue

Глобальное подключение Vue

new webpack.ProvidePlugin({
    Vue: ['vue/dist/vue.esm.js', 'default']
})

После этого:

new Vue({
    el: '#app'
});

может работать без import Vue.


ProvidePlugin и lodash

Автоматическая подстановка _

new webpack.ProvidePlugin({
    _: 'lodash'
})

Использование:

const result = _.uniq([1, 1, 2, 3]);

ProvidePlugin и process/browser polyfill

В браузере объект process отсутствует.

Некоторые библиотеки Node.js ожидают его наличие:

if (process.env.NODE_ENV === 'production') {
    // ...
}

Webpack может автоматически подставить polyfill:

new webpack.ProvidePlugin({
    process: 'process/browser'
})

ProvidePlugin и Buffer

Некоторые пакеты используют Buffer.

Для браузера требуется polyfill:

npm install buffer

Настройка:

new webpack.ProvidePlugin({
    Buffer: ['buffer', 'Buffer']
})

Использование:

const data = Buffer.from('hello');

Отличие ProvidePlugin от import

Обычный import

import $ from 'jquery';

Преимущества:

  • явные зависимости;
  • лучшая читаемость;
  • прозрачность архитектуры;
  • удобство поддержки.

ProvidePlugin

Преимущества:

  • меньше boilerplate-кода;
  • совместимость со старым кодом;
  • упрощение миграции legacy-проектов.

Недостатки:

  • скрытые зависимости;
  • менее очевидный код;
  • усложнение отладки;
  • трудности анализа импортов.

Когда использовать ProvidePlugin

Подходящие сценарии

Legacy-проекты

Старые jQuery-проекты:

$('.button').click(...)

Глобальные библиотеки

new Vue(...)

Polyfill-переменные

process.env
Buffer
global

Миграция больших проектов

Когда переписывание тысяч импортов невозможно.


Когда не следует использовать ProvidePlugin

Современные ES-модули

В современных приложениях предпочтительнее явный импорт:

import axios from 'axios';

TypeScript-проекты

Скрытые импорты осложняют:

  • типизацию;
  • анализ зависимостей;
  • tree shaking;
  • IDE-подсказки.

Библиотеки

Для библиотек особенно важны явные зависимости.


Shim-модули

Что такое shim-модуль

Shim-модуль — это промежуточный модуль, адаптирующий несовместимый код.

Пример:

// jquery-shim.js

import $ from 'jquery';

window.$ = $;
window.jQuery = $;

export default $;

Использование shim-модуля

import './jquery-shim';
import './legacy-plugin';

Теперь старый плагин получает глобальный jQuery.


Ручной shim для legacy-библиотеки

Исходная библиотека

(function () {
    function LegacyLib() {}

    window.LegacyLib = LegacyLib;
})();

Shim-модуль

// legacy-shim.js

import './legacy-lib';

export default window.LegacyLib;

Использование

import LegacyLib from './legacy-shim';

const lib = new LegacyLib();

imports-loader

Назначение imports-loader

Позволяет внедрять зависимости внутрь legacy-модулей.


Установка

npm install imports-loader --save-dev

Конфигурация

module.exports = {
    module: {
        rules: [
            {
                test: require.resolve('./legacy.js'),
                loader: 'imports-loader',
                options: {
                    imports: [
                        'default jquery $'
                    ]
                }
            }
        ]
    }
};

Что делает imports-loader

Webpack преобразует:

(function () {
    console.log($);
})();

в:

import $ from 'jquery';

(function () {
    console.log($);
})();

exports-loader

Назначение exports-loader

Позволяет экспортировать значения из legacy-скриптов.


Пример legacy-кода

var MyLibrary = {
    version: '1.0'
};

Конфигурация

{
    test: require.resolve('./legacy.js'),
    loader: 'exports-loader',
    options: {
        exports: 'default MyLibrary'
    }
}

Использование

import MyLibrary from './legacy';

console.log(MyLibrary.version);

expose-loader

Назначение expose-loader

Делает модуль глобальной переменной.


Установка

npm install expose-loader --save-dev

Конфигурация

{
    test: require.resolve('jquery'),
    loader: 'expose-loader',
    options: {
        exposes: ['$', 'jQuery']
    }
}

Результат

После сборки:

window.$
window.jQuery

становятся доступны глобально.


ProvidePlugin и tree shaking

Влияние на оптимизацию

ProvidePlugin может ухудшать tree shaking.

Причина:

  • Webpack автоматически внедряет импорт;
  • анализ зависимостей становится менее прозрачным;
  • возможно подключение лишнего кода.

Пример проблемы

new webpack.ProvidePlugin({
    _: 'lodash'
})

При неаккуратной конфигурации может подключиться весь lodash.


Более оптимальный вариант

import debounce from 'lodash/debounce';

ProvidePlugin и side effects

Некоторые модули выполняют код при импорте:

console.log('module loaded');

Автоматическая подстановка может вызывать:

  • неожиданные side effects;
  • раннюю инициализацию;
  • увеличение bundle size.

ProvidePlugin и TypeScript

Проблема типов

TypeScript не знает о скрытых глобальных переменных.

Пример:

$('.app')

Ошибка:

Cannot find name '$'

Решение

Установка типов

npm install @types/jquery --save-dev

Глобальное объявление

declare const $: JQueryStatic;

ProvidePlugin и ESLint

ESLint также не знает о скрытых переменных.

Ошибка:

'$' is not defined

Решение

module.exports = {
    globals: {
        $: 'readonly',
        jQuery: 'readonly'
    }
};

Polyfills и shim-модули

Webpack 5 и удаление automatic polyfills

Webpack 4 автоматически подключал polyfill для Node.js API.

Webpack 5 прекратил это поведение.

Теперь необходимо явно подключать:

  • buffer
  • process
  • stream
  • crypto
  • path

Настройка fallback

resolve: {
    fallback: {
        buffer: require.resolve('buffer/'),
        process: require.resolve('process/browser')
    }
}

Совместное использование с ProvidePlugin

plugins: [
    new webpack.ProvidePlugin({
        Buffer: ['buffer', 'Buffer'],
        process: 'process/browser'
    })
]

Глобальные переменные и window

Прямое присваивание

Иногда используется:

window.$ = require('jquery');

Недостатки:

  • загрязнение глобальной области;
  • сложность поддержки;
  • конфликты версий;
  • проблемы изоляции.

Более безопасный shim

import $ from 'jquery';

if (!window.$) {
    window.$ = $;
}

Проблемы shim-подхода

Скрытые зависимости

Код становится менее очевидным:

$('.menu')

Невозможно понять источник $.


Усложнение поддержки

При большом количестве shim-модулей:

  • растёт сложность конфигурации;
  • ухудшается предсказуемость;
  • появляются циклические зависимости.

Проблемы тестирования

Тестовая среда может не содержать:

window.$
process
Buffer

Требуется отдельная настройка.


Миграция legacy-кода

Этап 1. Shim-совместимость

Подключение:

  • ProvidePlugin
  • expose-loader
  • imports-loader

Этап 2. Стабилизация сборки

Устранение ошибок:

is not defined

Этап 3. Постепенная замена глобальных зависимостей

Переход от:

$('.app')

к:

import $ from 'jquery';

Этап 4. Удаление shim-слоя

После полной миграции:

  • удаляются глобальные переменные;
  • убираются shim-модули;
  • упрощается архитектура.

Практический пример полной настройки

Установка

npm install jquery process buffer
npm install expose-loader imports-loader exports-loader --save-dev

webpack.config.js

const webpack = require('webpack');

module.exports = {
    resolve: {
        fallback: {
            buffer: require.resolve('buffer/'),
            process: require.resolve('process/browser')
        }
    },

    module: {
        rules: [
            {
                test: require.resolve('jquery'),
                loader: 'expose-loader',
                options: {
                    exposes: ['$', 'jQuery']
                }
            }
        ]
    },

    plugins: [
        new webpack.ProvidePlugin({
            $: 'jquery',
            jQuery: 'jquery',
            Buffer: ['buffer', 'Buffer'],
            process: 'process/browser'
        })
    ]
};

Архитектурные рекомендации

Предпочтительный подход

Для современных приложений:

  • использовать ES Modules;
  • применять явные import;
  • минимизировать глобальные зависимости;
  • избегать скрытых shim-механизмов.

Допустимое использование shim-модулей

Shim-модули оправданы:

  • при миграции legacy-кода;
  • для старых jQuery-проектов;
  • при интеграции сторонних плагинов;
  • при поддержке старых библиотек.

Нежелательные практики

Массовое использование глобальных переменных

window.app = {}
window.utils = {}
window.API = {}

Глобальное внедрение всех библиотек

new webpack.ProvidePlugin({
    _: 'lodash',
    axios: 'axios',
    React: 'react',
    Vue: 'vue',
    moment: 'moment'
})

Полная зависимость проекта от shim-слоя

Такая архитектура затрудняет:

  • поддержку;
  • рефакторинг;
  • tree shaking;
  • миграцию;
  • тестирование;
  • анализ зависимостей.