DarkRiDDeR11 мин

jQuery + Webpack: почему legacy-плагин исчезает только в production

JavaScriptWebpackjQueryРазбор

В development старый плагин маски ввода работает, а в production вызов $(input).legacyMask() падает: метода нет в $.fn. Цена ошибки выше сломанного поля — сборка может уйти в релиз с нерабочим оформлением или проверкой номера, а срочная правка начнёт менять Webpack вслепую.

Разберём один вопрос: как доказать, что плагин потерялся именно на границе window.jQuery, порядка исполнения или второго экземпляра jquery, а не из-за минификации. Это учебный сценарий для Webpack 4 и jQuery; он показывает форму проверки. Ни production-бандл конкретного проекта, ни браузерную трассу я здесь не выдаю за выполненные.

Сначала фиксируем, что именно исчезло

Плагин jQuery обычно добавляет функцию в $.fn. Значит, выражение typeof $.fn.legacyMask отвечает не на вопрос «плагин подключён вообще», а на более полезный: установил ли он метод на тот экземпляр jQuery, которым пользуется код страницы. Официальный API описывает jQuery.fn.extend() как расширение прототипа jQuery. Если в окне живут два разных объекта jQuery, метод может оказаться только на одном из них.

Development часто скрывает границу. На локальной странице jQuery мог приехать отдельным тегом script раньше vendor-файла, а dev-сервер мог не собрать второй entry в тот же путь. Production собирает зависимости в модули и чанки. Это не делает Webpack виновником: меняется способ доставки, и скрытая зависимость плагина от глобального объекта становится видна.

НаблюдениеВероятная причинаКороткая проверкаДействие после проверки
window.jQuery пустой до старого плагинаИмпорт есть только в области модуляОстановиться перед загрузкой плагина и вывести window.jQueryСоздать явный legacy-мост до выполнения плагина
Метод есть у window.jQuery.fn, но нет у $ .fn приложенияПлагин и приложение держат разные экземплярыСравнить window.jQuery === $Убрать второй путь резолвации и оставить одну зависимость
Метода нет у обоих объектовФайл плагина не выполнился или выполнился до мостаПроверить порядок модулей, ошибку в Console и включение файла в statsПодключить адаптер после моста, затем проверить production-артефакт
Сбой только при втором entryjQuery попал в несколько entry или загрузка чанков не согласованаПосмотреть modules/chunks в stats.jsonНастроить общую зависимость и проверить все точки входа

В таблице есть важное ограничение: это дерево гипотез, не диагноз вашего сайта. Один и тот же текст ошибки может появиться при 404 vendor-файла, при CSP или при версии плагина, которая не совместима с закреплённой jQuery. Поэтому сначала собираем идентичности объектов и факт выполнения кода, а уже затем трогаем оптимизацию.

Учебный пример: плагин читает window.jQuery

Ниже минимальный legacy-файл. Он намеренно не импортирует jQuery: автор плагина рассчитывает, что глобальная переменная уже существует. Такой код неудобен для модулей, но он встречается в старых виджетах. Ошибка в примере воспроизводима без сервера: если в момент выполнения root.jQuery отсутствует, модуль бросает понятное исключение; если объект есть, метод появляется именно на его fn.

// src/vendor/legacy-mask.js
(function installLegacyMask(root) {
  var $ = root.jQuery;

  if (!$ || !$.fn) {
    throw new Error('legacy-mask ожидает window.jQuery до своего запуска');
  }

  $.fn.legacyMask = function legacyMask() {
    return this.addClass('has-legacy-mask');
  };
}(window));

Соблазнительный вариант ниже выглядит как правильный порядок сверху вниз: сначала импорт, затем присваивание, затем импорт плагина. Но статический import описывает зависимость модуля, а не вызов, который исполняется на этой строке после предыдущего оператора. Зависимый legacy-mask.js должен быть подготовлен и выполнен до тела модуля bootstrap.bad.js; присваивание в window для него опаздывает.

// src/bootstrap.bad.js
import $ from 'jquery';
import './vendor/legacy-mask';

window.jQuery = window.$ = $; // для legacy-mask это уже поздно

$('.js-phone').legacyMask();

Для одного старого плагина не обязательно переводить весь код назад на CommonJS. Достаточно сделать маленький адаптер с явной границей. В нём require() вызывается после записи в window; поэтому legacy-файл получает именно тот объект, который импортировал bootstrap. Этот приём выбран здесь ради порядка выполнения, а не как общий стиль нового кода.

// src/legacy-jquery-bridge.js
import $ from 'jquery';

window.jQuery = window.$ = $;
require('./vendor/legacy-mask');

export default $;

// src/bootstrap.js
import $ from './legacy-jquery-bridge';

$('.js-phone').legacyMask();

После такого моста минимальная проверка не должна ограничиваться отсутствием исключения. Нужно удостовериться, что глобальный объект и импорт имеют одну ссылку, а метод лежит на том же $.fn, с которым работает приложение. Так мы отделяем проблему порядка от ситуации, когда плагин успешно установился, но на чужой копии jQuery.

// В Console страницы или во временном диагностическом модуле.
import $ from './legacy-jquery-bridge';

var report = {
  hasGlobal: Boolean(window.jQuery),
  sameInstance: window.jQuery === $,
  pluginOnImport: typeof $.fn.legacyMask,
  pluginOnGlobal: window.jQuery && typeof window.jQuery.fn.legacyMask,
};

console.table(report);
console.assert(report.sameInstance, 'jQuery резолвится в разные экземпляры');
console.assert(report.pluginOnImport === 'function', 'плагин не установлен на импорт');

Почему ProvidePlugin не заменяет мост

У ProvidePlugin другая задача. Документация Webpack 4 описывает его как способ сделать пакет переменной в модулях, которые компилирует Webpack, когда встречается нужный идентификатор. Это полезно для legacy-модуля, который обращается к свободной переменной jQuery, но не является автоматическим присваиванием свойства window.jQuery.

Поэтому решение должно следовать из самого плагина. Если файл принимает jQuery как параметр или использует свободную переменную, можно проверить настройку ProvidePlugin. Если он явно читает window.jQuery, нужен мост в window либо отдельная настройка loader, которая документированно передаёт зависимость. Подменять оба варианта одной конфигурацией опасно: в development симптом может исчезнуть из-за случайного порядка тегов, а в production вернуться.

Схема диагностики legacy jQuery-плагина: неправильный статический импорт запускает плагин до назначения window.jQuery, а проверка равенства выявляет второй экземпляр jQuery
Проверка идёт от порядка исполнения к идентичности экземпляра: сначала мост, затем плагин, затем сравнение объектов.

Как искать второй экземпляр без догадки по размеру bundle

Два пакета не обязаны выглядеть как два одинаковых больших файла. Один экземпляр мог войти в entry страницы, другой — в зависимость виджета по другому пути или версии. Поэтому размер итогового JavaScript — слабое доказательство. Надёжнее сравнить объекты в рантайме и посмотреть список модулей в статистике именно той production-сборки, которая будет доставлена пользователю.

Webpack 4 документирует JSON-статистику как представление assets, chunks и modules. В реальном репозитории её можно запросить отдельной командой. Команду ниже не запускали для этой статьи: она является маршрутом диагностики, который нужно выполнять в ветке проекта и сохранять рядом с версией lock-файла. Source map включается только на время разбора и только если правила публикации исходников это позволяют.

# Команды для реального проекта, а не результат этого учебного примера.
npx webpack --mode production --devtool source-map --profile --json > dist/stats.json
npm ls jquery

# В stats.json ищем пути и чанки, где встречается jquery.
# Затем в браузере сравниваем window.jQuery с импортом из legacy-моста.

Если npm ls jquery показывает несколько веток, это ещё не приговор: сборщик может дедуплицировать совместимые зависимости. Если в stats.json видны несколько модулей jQuery или разные resolved-пути, это также не доказывает, что два объекта дошли до страницы. Диагноз закрывается только вместе с равенством window.jQuery === $, фактом появления метода и сетевой записью тех чанков, которые фактически загрузил браузер.

Порядок проверки production-сценария

  1. Зафиксировать точный симптом: имя метода, страницу, один input и версию собранных ассетов. Не менять конфигурацию до записи исходной ошибки.
  2. В точке перед вызовом плагина вывести window.jQuery, импорт $, их равенство и тип $.fn.legacyMask. Это отделяет отсутствие глобала от второго экземпляра.
  3. Открыть Network и убедиться, что нужные entry и lazy-chunk действительно получили ответ, а не старую страницу из кеша. Записать имена файлов и hash, не делать вывод по одному названию bundle.
  4. Собрать production-статистику на той же версии lock-файла. Найти resolved-пути jquery, их chunks и причины подключения.
  5. Если плагин читает глобал, создать один изолированный bridge до его загрузки; если он принимает импорт, удалить глобальную зависимость вместо добавления нового shim.
  6. Повторить проверку на чистой странице и в каждом entry, где живёт виджет. Готовность — одинаковый объект jQuery и функция плагина на его fn, а не только исчезнувшая ошибка в Console.

Что считать исправлением

Исправлением является не строка window.$ = $ сама по себе. Нужен маленький контракт: bridge импортирует одну зависимость, публикует её в global до legacy-файла, подключает нужный side effect и экспортирует тот же объект приложению. Так следующий разработчик видит, где заканчивается старый API и почему этот файл нельзя переставить среди обычных импортов.

Если обнаружен второй экземпляр, сначала устраняем причину его попадания в граф: разный resolved-путь, несовместимая версия или отдельный entry без общей оптимизации. Webpack 4 отдельно предупреждает, что несколько entry могут дублировать общий импорт; для этого есть SplitChunksPlugin. Но включить splitChunks «для лечения плагина» недостаточно: после изменения всё равно проверяем идентичность объектов и метод в runtime.

Ограничения и границы примера

  • Пример предполагает браузерный window и Webpack 4. В SSR, worker или тестовой среде global-объект другой; мост надо либо не исполнять на сервере, либо изолировать адаптером окружения.
  • Вызов require() оставлен только внутри legacy-моста ради наблюдаемого порядка. Он не является рекомендацией смешивать системы модулей во всём новом коде.
  • Source map может раскрывать пути и исходный код. Его нельзя включать в публичную production-доставку без проверки политики проекта; для расследования подойдёт защищённый артефакт.
  • CSP, 404 чанка, несовместимая версия плагина и кеш CDN могут дать похожий внешний симптом. Этот текст не заменяет Network, Console и точную версию зависимостей.
  • Глобальная jQuery — совместимость с конкретным legacy-файлом, а не архитектурная цель. Новый виджет лучше получает зависимость импортом и не меняет window.

Итог

Когда $.fn.legacyMask пропадает только после production-сборки, не стоит первым делом отключать минификацию. Сначала проверяем порядок: есть ли window.jQuery до плагина. Затем проверяем идентичность: тот ли это объект, который импортирует приложение. После этого production-статистика и Network показывают, какой модуль и какой чанк нужно исправлять. Такой маршрут оставляет одно проверяемое условие готовности: плагин установлен на том же $.fn, которым пользуется экран.

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

  • Webpack 4: Shimming — назначение ProvidePlugin, legacy-модули с глобальными зависимостями и границы shim-подхода
  • Webpack 4: Code Splitting — entry, динамические импорты и дублирование модуля между несколькими entry
  • Webpack 4: Stats Data — структура JSON-статистики сборки: assets, chunks, modules, errors и warnings
  • Webpack 4: devtool — варианты source map и компромиссы между удобством диагностики, скоростью и раскрытием исходного кода
  • jQuery API: jQuery.fn.extend() — плагин добавляет метод в прототип конкретного объекта jQuery, то есть в конкретный $.fn