{ "index": 323, "slug": "editorial-2019-01-mechanism-jquery-webpack", "title": "jQuery в Webpack: почему legacy-плагин теряет глобальный объект", "excerpt": "В development форма работает, а production-сборка получает undefined вместо window.jQuery. Разбираем разницу между ProvidePlugin и глобальным объектом, порядок запуска legacy-плагина и проверку фактических production-ассетов.", "contentHtml": "

Рассмотрим знакомый сценарий 2019 года: разработчик открывает форму телефона на тестовом стенде, ввод проходит через старый jQuery-плагин, а после production-сборки в Console появляется window.jQuery is undefined. В другом варианте глобал существует, но вызов $('.js-phone').legacyMask() заканчивается ошибкой о неизвестном методе. Цена ошибки понятна: пользователь не может заполнить поле, а команда получает релиз, который приходится разбирать по минифицированным chunks.

\n

Первое предположение обычно звучит так: «Webpack потерял jQuery». Точнее разделить проблему на два контракта. Модуль получает импорт внутри своего scope, а старый plugin-файл может искать объект в window и расширять его $.fn во время загрузки. Поэтому наличие строки jquery в bundle ничего не доказывает: нужно установить, какой объект прочитал plugin, когда он это сделал и тот ли объект использует форма.

\n

Сценарий: один плагин, два контракта

\n

Начнём с минимальной сцены. На странице есть поле .js-phone, приложение собирается Webpack, а legacy-файл подключается как side effect. Разработчик сначала проверяет импорт в исходном модуле: import $ from 'jquery' возвращает объект. Затем он смотрит в Console и видит, что window.jQuery пуст. Значит, проверять нужно не пакет вообще, а границу между модульным scope и глобальной областью страницы.

\n

Учебный plugin ниже намеренно короткий. Он не утверждает, что конкретная версия Inputmask устроена так же. Он показывает класс контракта: файл читает глобальный объект сразу при выполнении и добавляет метод к его прототипу.

\n
(function installLegacyMask(root) {\n  var jq = root.jQuery;\n\n  if (!jq || !jq.fn) {\n    throw new Error('legacyMask expects window.jQuery');\n  }\n\n  jq.fn.legacyMask = function legacyMask() {\n    return this.attr('data-mask-ready', 'true');\n  };\n}(window));
\n

Если этот файл выполнится до записи в window.jQuery, он завершится ошибкой или не зарегистрирует метод. Если plugin расширит другую копию jQuery, метод появится у одного объекта, а приложение вызовет другой. Оба случая выглядят для пользователя одинаково: маска не работает.

\n

Что делает ProvidePlugin

\n

ProvidePlugin работает на этапе анализа модулей. Когда Webpack встречает свободное имя вроде $ или jQuery в разбираемом модуле, он автоматически добавляет загрузку указанного модуля. Это удобно для старого кода, который вызывает jQuery('.row') без явного импорта.

\n
const webpack = require('webpack');\n\nmodule.exports = {\n  mode: 'production',\n  entry: './src/bootstrap.js',\n  plugins: [\n    new webpack.ProvidePlugin({\n      $: 'jquery',\n      jQuery: 'jquery',\n    }),\n  ],\n};
\n

Официальная документация также показывает запись \"window.jQuery\": \"jquery\" для кода, который обращается к этому выражению. Но область действия всё равно важна: Webpack может подставить модуль только там, где он анализирует исходный код. ProvidePlugin не управляет произвольным классическим <script>, CDN-ресурсом или файлом, который исключён из анализа через noParse. Поэтому внешний plugin может по-прежнему читать реальное свойство window.jQuery, которого ещё нет.

\n

Практическое правило такое: ProvidePlugin закрывает импортный контракт, а явное присваивание закрывает контракт глобального объекта. Иногда достаточно первого, иногда нужны оба. Решение зависит от того, как устроен entry конкретного plugin и кто запускает его side effect.

\n

Почему порядок статических импортов обманывает

\n

Такой entry выглядит последовательным, но запись глобала происходит слишком поздно:

\n
import $ from 'jquery';\nimport './vendor/legacy-mask';\n\nwindow.jQuery = $;\n$('.js-phone').legacyMask();
\n

Статический import связывается и выполняется до тела модуля-импортёра. Поэтому side effect из legacy-mask может прочитать window.jQuery раньше, чем дойдёт очередь до присваивания внизу. В development это иногда скрывает внешний тег, другой entry или порядок, случайно создающий глобал. Production не обязан повторять такое совпадение.

\n

Для старого CommonJS-совместимого plugin можно сделать небольшой bridge. Он владеет единственным импортом jQuery, проверяет конфликт и запускает legacy-файл только после публикации объекта:

\n
// src/legacy-jquery-bridge.js\nimport $ from 'jquery';\n\nif (window.jQuery && window.jQuery !== $) {\n  throw new Error('Two jQuery instances reached the page');\n}\n\nwindow.$ = $;\nwindow.jQuery = $;\nrequire('./vendor/legacy-mask');\n\nexport default $;\n\n// src/bootstrap.js\nimport $ from './legacy-jquery-bridge';\n\n$('.js-phone').legacyMask();
\n

Здесь require() — локальный Webpack-шов для CommonJS или legacy-файла, а не совет строить новый код на глобальных переменных. Если пакет предоставляет фабрику или ESM-вход, вызовите его API после записи глобала; не копируйте путь из старого примера без сверки версии и entry.

\n

Четыре гипотезы одного симптома

\n
НаблюдениеРабочая гипотезаПроверкаОграниченное действие
window.jQuery пуст перед pluginИмпорт живёт только в модуле, глобальный мост не выполнилсяBreakpoint перед plugin и Boolean(window.jQuery)Добавить один bridge перед side effect
Метод есть у глобала, но отсутствует у импортированного $Plugin расширил другой экземпляр jQueryСравнить window.jQuery === $ и тип метода у обоих объектовУбрать второй источник или выровнять resolved-путь
Оба объекта есть, метода нетPlugin не загрузился, получил ошибку или выбран не тот entryПроверить Console, Network и фактический экспорт файлаИсправить доставку/entry, не маскировать повторным вызовом
В одном entry работает, в другом нетРазные chunks, HTML или версии jQueryСопоставить assets, initiator и модули в stats-файлеПубликовать совместимый набор HTML и assets
\n
Схема загрузки Webpack-ассетов: runtime и jQuery, bridge, legacy-плагин, форма; красная ветка показывает ранний запуск
В рабочем порядке bridge сначала публикует один объект jQuery, затем legacy-плагин расширяет его, и только после этого форма вызывает метод. Ранний side effect закрывает глобал до bridge и даёт тот же симптом, что и отсутствующий plugin.
\n

Как воспроизвести и закрыть гипотезу

\n

Соберите маленький fixture с тремя файлами: legacy-mask.js из примера выше, bridge и bootstrap. Установите jQuery, Webpack и webpack-cli в отдельном каталоге. Важен не конкретный размер bundle, а наблюдаемые условия: plugin получает тот же объект, метод существует, а entry загружает все chunks без ошибки.

\n
npm install jquery webpack webpack-cli\nnpx webpack --mode production --profile --json > dist/stats.json
\n

После сборки не подставляйте вручную имя вроде vendors~site.js. Откройте сгенерированный HTML, найдите реальные script-теги, проверьте ответы в Network и посмотрите initiator для runtime и lazy-chunks. Hashed-имена и разбиение зависят от конфигурации, поэтому вчерашний filename не является контрактом.

\n

В браузере сравните ссылки, а не только версии. Одинаковая строка версии не доказывает, что это один JavaScript-объект.

\n
const report = {\n  hasGlobal: Boolean(window.jQuery),\n  sameInstance: window.jQuery === $,\n  pluginOnImport: typeof $.fn.legacyMask,\n  pluginOnGlobal: window.jQuery\n    ? typeof window.jQuery.fn.legacyMask\n    : 'no-global',\n};\n\nconsole.table(report);\nconsole.assert(report.sameInstance, 'different jQuery instances');\nconsole.assert(\n  report.pluginOnImport === 'function',\n  'legacy plugin is not installed on the app instance',\n);
\n

Ожидаемое состояние учебного fixture: hasGlobal и sameInstance равны true, оба поля plugin имеют значение function, а Network не содержит 404. Это критерии проверки примера, не результат запуска в вашем проекте.

\n

Что искать в stats и в исходнике зависимости

\n

Сначала прочитайте entry plugin. Найдите, откуда он получает jQuery: через свободное имя, window.jQuery, CommonJS-экспорт, фабрику или собственный импорт. Затем зафиксируйте момент чтения. Если файл обёрнут в самовызывающуюся функцию, его side effect происходит при выполнении модуля, а не тогда, когда форма впервые вызывает метод.

\n

Команда npm ls jquery показывает дерево пакетов, но не доказывает число объектов в браузере. В stats.json ищите resolved-пути, chunks и entry, к которым привязан модуль. Повторное упоминание имени jquery — повод изучить граф, но не доказательство двух runtime-экземпляров: Webpack мог объединить модуль или оставить разные копии по разным путям. Окончательную проверку дают равенство ссылок, наличие метода и Network одного выпуска.

\n
  1. Зафиксируйте URL, entry, точную ошибку, commit сборки и сценарий, в котором поле перестаёт работать.
  2. До выполнения plugin проверьте window.jQuery, импортированный $ и равенство ссылок.
  3. Прочитайте фактический entry зависимости и определите, когда она читает глобал.
  4. Сравните development и production HTML, runtime, chunks, initiator и ответы Network.
  5. Соберите stats.json на том же lock-файле и объясните каждый resolved-путь jQuery.
  6. Добавьте bridge только там, где старый контракт действительно требует глобал; для нового кода оставьте явный импорт.
  7. Повторите production-сценарий после очистки кэша на каждой странице и в каждом затронутом entry.
\n

Отрицательный путь и границы решения

\n

Успешный вызов метода не закрывает проблему доставки. Проверьте 404, старый HTML, отказ lazy-chunk, CSP и отключённый внешний script. Если optional-плагин не загрузился, форма должна перейти в понятное состояние: показать допустимый fallback или сообщить об ограничении, а не изображать активную маску.

\n

Bridge применим к браузерному legacy-коду, который действительно читает window.jQuery. В SSR, worker и тестовом окружении объекта window может не быть. Отделите браузерный адаптер от серверного модуля и не запускайте его на сервере только ради прохождения импорта.

\n

Путь inputmask/dist/jquery.inputmask из старых инструкций нельзя считать вечным API. Официальный проект Inputmask поддерживает vanilla JavaScript и jQuery, но структура пакета, способ подключения и версия меняются. Для конкретного релиза проверьте его README, package entry и фактический метод plugin; не выдавайте учебный legacyMask за гарантию поведения Inputmask.

\n

Глобальная jQuery остаётся слоем совместимости. Новые модули лучше писать с явными импортами и не смешивать CDN-объект с npm-модулем без проверки ссылок. Не отключайте splitChunks и не добавляйте вторую копию библиотеки как первый ответ: сначала докажите, нарушен ли порядок, доставлен ли нужный файл и действительно ли экземпляры различаются.

\n

Критерий готовности

\n

Разбор можно считать закрытым, когда на чистом production-профиле и для каждого затронутого entry одновременно выполняются условия: window.jQuery существует до запуска legacy-файла; window.jQuery === $; нужный метод имеет тип function; HTML, runtime и chunks принадлежат одному выпуску и загружаются без 404. Дополнительно проходит реальный сценарий ввода, а отказ optional-части не скрывается.

\n

Если выполнен только импорт или только проверка Console, остаётся незакрытая гипотеза. Вернитесь к моменту чтения глобала и отделите проблему scope от порядка, дублирования и доставки. Такой порядок сохраняет пользу старого plugin, но не превращает временный bridge в незаметную архитектурную зависимость.

\n

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

\n" }