DarkRiDDeR11 мин

jQuery в Webpack. Почему legacy-плагин ломается только в production

JavaScriptWebpackjQuery

В development старая маска телефона работает, а после production-сборки браузер сообщает, что window.jQuery не определён. Цена ошибки — релиз с формой, которую нельзя заполнить, хотя локальный сервер и привычный исходный код выглядели исправными.

В январе 2019 года я бы не лечил этот сбой таймаутом. У legacy-плагина есть жёсткое ожидание: в момент его выполнения должна существовать конкретная глобальная переменная. У Webpack другая модель: он собирает модули и может вынести общую зависимость в отдельный chunk. Нужно проверить, кто создаёт window.jQuery, когда это происходит и какой production-asset выполняет плагин.

Почему development даёт ложную уверенность

На локальном сервере плагин нередко случайно получает jQuery из старого тега script, из общего layout или из более простого bundle. Production меняет условия: включается минификация, меняется имя файлов, а Webpack 4 может выделить общие зависимости через optimization.splitChunks. Само выделение не является ошибкой. Ошибка появляется, когда HTML подключает ассеты не тем способом или bootstrap-код запускает плагин до назначения глобала.

Поэтому фиксирую минимальный пример из трёх частей: entry, который создаёт глобал; legacy-плагин, который читает его при загрузке; и production HTML с фактическими тегами script. Пока в диагностике есть только исходники, а собранного dist нет, порядок исполнения остаётся предположением.

НаблюдениеВероятная причинаКороткая проверкаДействие
В dev всё работает, в production — undefinedlayout подключает локальный jQuery раньше bundleоткрыть Network и сравнить production script tagsубрать случайный CDN-скрипт или сделать порядок явным
В модуле доступен $, но плагин читает window.jQueryProvidePlugin обслуживает свободный идентификатор, а не скрытую договорённость плагинапоставить остановку перед загрузкой плагина и проверить window.jQueryназначить глобал до runtime require плагина
После splitChunks два разных jQueryодна копия пришла из layout, другая — из bundleпроверить modules со словом jquery в stats.jsonоставить один владелец и проверить граф production-сборки
Форма ломается после кешаHTML ссылается на старый набор hashed-ассетовсравнить HTML и имена файлов текущего distпубликовать HTML и ассеты как один артефакт
Временная шкала production-загрузки: runtime, vendors chunk с jQuery, bootstrap назначает window.jQuery, затем require запускает legacy-плагин и форму. Красным отмечен плохой вариант со статическим импортом плагина до тела bootstrap.
Для старого плагина важен не только факт установки jQuery, но момент, в который он читает window.jQuery.

Разделяю свободный идентификатор и глобальный объект

Webpack описывает ProvidePlugin как способ автоматически подставить модуль, когда компилятор встречает свободный идентификатор в скомпилированном модуле. Конфигурация с $ и jQuery помогает нашему исходному коду, где они используются без import. Но старый плагин может вообще не иметь свободного идентификатора: он может обратиться именно к window.jQuery во время выполнения своего файла. Это другой контракт, его нельзя проверить поиском по собственным модулям.

Документация Webpack отдельно показывает сопоставление window.jQuery для библиотек с такой зависимостью. На старом проекте я всё равно предпочитаю маленький bootstrap-модуль с явным присваиванием: в нём видно владельца глобала и точку, после которой разрешено запускать plugin. Это не идеальная модульная архитектура, а ограниченный шов для уже существующего кода.

const webpack = require('webpack');

module.exports = {
  mode: 'production',
  entry: {
    site: './src/bootstrap-legacy.js',
  },
  plugins: [
    new webpack.ProvidePlugin({
      $: 'jquery',
      jQuery: 'jquery',
    }),
  ],
  optimization: {
    splitChunks: { chunks: 'all' },
  },
};

Эта настройка не даёт права подключить plugin в любом месте и ожидать правильный момент. Она лишь делает $ и jQuery доступными там, где Webpack анализирует их как свободные имена. Глобальный объект, порядок загрузки документа и исполнение файлов остаются отдельной частью расследования.

Не ставлю статический import перед созданием глобала

В этом месте легко написать читающийся, но неверный код. Статические import описывают зависимости модуля до того, как выполнится его тело. Если legacy-плагин читает window.jQuery сразу при инициализации, он может сработать раньше строки с присваиванием. В примере ниже сначала настраивается глобал, затем плагин подключается через runtime require().

import $ from 'jquery';

function exposeLegacyJQuery(jq) {
  if (window.jQuery && window.jQuery !== jq) {
    throw new Error('Two jQuery instances reached the page');
  }

  window.$ = jq;
  window.jQuery = jq;
}

exposeLegacyJQuery($);
require('inputmask/dist/jquery.inputmask');
require('./legacy-form');

Здесь require() выбран не потому, что он современнее import, а потому, что его вызов стоит после exposeLegacyJQuery() в runtime-порядке текущего модуля. Если плагин не читает глобал при загрузке, такой шов может быть не нужен. Но в конкретном production-сбое сначала проверяю это предположение на минимальном плагине и только затем меняю конфигурацию всего проекта.

Собираю production-доказательство, а не только скрин консоли

После исправления я запускаю production-сборку с JSON-статистикой. В stats-файле ищу модули jQuery и связь с entry, а в браузере смотрю Network и порядок инициаторов. Цель не в том, чтобы вручную угадать хеш vendor-файла: порядок должен обеспечивать runtime Webpack или генератор HTML, а не строка с именем вчерашнего chunk.

{
  "scripts": {
    "build:profile": "webpack --mode production --profile --json > dist/stats.json",
    "serve:dist": "npx http-server dist -c-1"
  }
}

npm run build:profile
npm run serve:dist

На тестовой странице перед отправкой формы достаточно временно проверить два факта: window.jQuery === $ в bootstrap-коде и наличие метода, который добавляет плагин. Затем эту диагностику убираю или оставляю за отдельным development-флагом. Production-страница должна открываться со свежим HTML, потому что новый bundle с прошлым списком тегов script проверяет не нашу конфигурацию, а несовместимый набор артефактов.

Порядок проверки после сборки

  1. Собираю чистый production dist и сохраняю имя entry, runtime и общих chunks из фактического вывода.
  2. Открываю страницу без локального CDN-скрипта jQuery и проверяю Network: все стартовые assets пришли без 404 и с одной версией артефакта.
  3. Ставлю временную проверку в bootstrap-legacy.js: до runtime require window.jQuery === $ должно быть true.
  4. После require проверяю ровно один ожидаемый метод legacy-плагина, затем инициализирую форму через $(function () { ... }).
  5. Открываю stats.json и ищу modules, содержащие jquery; повторная копия требует объяснения, а не только сравнения общего размера bundle.
  6. Повторяю сценарий после очистки кеша и после прямого открытия страницы, чтобы не перепутать рабочий старый asset с новым релизом.

Инициализирую форму после DOM, а не до него

Даже правильный глобал не создаёт input в DOM. jQuery .ready() выполняет обработчик, когда документ готов к безопасному изменению, поэтому конечная инициализация формы должна жить после bootstrap-пути. Это отдельная проверка от загрузки plugins: если window.jQuery есть, а селектор не находит поля, причина лежит уже в разметке или в моменте появления формы.

require('./bootstrap-legacy');

jQuery(function ($) {
  var $phone = $('#order-phone');

  if ($phone.length !== 1 || typeof $phone.inputmask !== 'function') {
    throw new Error('Legacy mask is not ready for #order-phone');
  }

  $phone.inputmask('+7 (999) 999-99-99');
});

Синтаксис с аргументом $ внутри jQuery(function ($) { ... }) снимает зависимость этой конкретной функции от внешнего alias. Но он не отменяет bootstrap: плагин всё ещё должен быть загружен и зарегистрирован до вызова inputmask(). Так граница проблемы остаётся видимой: глобал для старого плагина, локальный alias для кода формы, реальный DOM для финальной инициализации.

Ограничения решения

Глобальный jQuery — технический долг, а не совет для нового модуля. Новые компоненты лучше импортировать явно и не делать window общим API. Также не следует выключать splitChunks только ради одного плагина: сначала нужно показать, что именно нарушено — порядок тегов, два экземпляра библиотеки или статический import.

Число chunks и их хеши зависят от версии Webpack, loaders и графа зависимостей. Поэтому статья не обещает фиксированное имя vendors~site.js. Её критерий другой: production HTML ссылается на выпуск одной сборки, глобал назначен до legacy-плагина, а проверка формы пройдена без случайного script из layout.

Итог

Production-сбой с jQuery в Webpack обычно не лечится ещё одним alias. Нужно назвать, какой код читает window.jQuery, создать его в bootstrap до runtime require и проверить фактический граф assets. После этого legacy-граница остаётся маленькой, а следующая миграция может заменить её модулем без скрытой глобальной зависимости.

Проверяемые источники

  • webpack: Shimming — Webpack понимает модули, но старые библиотеки могут ожидать глобальные зависимости; globals следует оставлять только для нужной совместимости
  • webpack: ProvidePlugin — ProvidePlugin подставляет модуль для свободного идентификатора в скомпилированном модуле; отдельно показано сопоставление window.jQuery
  • webpack: migration to v4 — в webpack 4 CommonsChunkPlugin заменён настройкой optimization.splitChunks
  • webpack: SplitChunksPlugin — в webpack 4 общие модули извлекаются правилами splitChunks, что меняет состав начальных ассетов
  • jQuery API: .ready() — обработчик запускается, когда DOM готов к безопасному изменению
  • jQuery API: .on() — делегированный обработчик работает на потомках существующего контейнера и может быть привязан с namespace