{ "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.
Первое предположение обычно звучит так: «Webpack потерял jQuery». Точнее разделить проблему на два контракта. Модуль получает импорт внутри своего scope, а старый plugin-файл может искать объект в window и расширять его $.fn во время загрузки. Поэтому наличие строки jquery в bundle ничего не доказывает: нужно установить, какой объект прочитал plugin, когда он это сделал и тот ли объект использует форма.
Начнём с минимальной сцены. На странице есть поле .js-phone, приложение собирается Webpack, а legacy-файл подключается как side effect. Разработчик сначала проверяет импорт в исходном модуле: import $ from 'jquery' возвращает объект. Затем он смотрит в Console и видит, что window.jQuery пуст. Значит, проверять нужно не пакет вообще, а границу между модульным scope и глобальной областью страницы.
Учебный 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, метод появится у одного объекта, а приложение вызовет другой. Оба случая выглядят для пользователя одинаково: маска не работает.
ProvidePlugin работает на этапе анализа модулей. Когда Webpack встречает свободное имя вроде $ или jQuery в разбираемом модуле, он автоматически добавляет загрузку указанного модуля. Это удобно для старого кода, который вызывает jQuery('.row') без явного импорта.
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, которого ещё нет.
Практическое правило такое: ProvidePlugin закрывает импортный контракт, а явное присваивание закрывает контракт глобального объекта. Иногда достаточно первого, иногда нужны оба. Решение зависит от того, как устроен entry конкретного plugin и кто запускает его side effect.
\nТакой entry выглядит последовательным, но запись глобала происходит слишком поздно:
\nimport $ from 'jquery';\nimport './vendor/legacy-mask';\n\nwindow.jQuery = $;\n$('.js-phone').legacyMask();\nСтатический import связывается и выполняется до тела модуля-импортёра. Поэтому side effect из legacy-mask может прочитать window.jQuery раньше, чем дойдёт очередь до присваивания внизу. В development это иногда скрывает внешний тег, другой entry или порядок, случайно создающий глобал. Production не обязан повторять такое совпадение.
Для старого 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.
| Наблюдение | Рабочая гипотеза | Проверка | Ограниченное действие |
|---|---|---|---|
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 |
Соберите маленький fixture с тремя файлами: legacy-mask.js из примера выше, bridge и bootstrap. Установите jQuery, Webpack и webpack-cli в отдельном каталоге. Важен не конкретный размер bundle, а наблюдаемые условия: plugin получает тот же объект, метод существует, а entry загружает все chunks без ошибки.
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 не является контрактом.
В браузере сравните ссылки, а не только версии. Одинаковая строка версии не доказывает, что это один JavaScript-объект.
\nconst 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. Это критерии проверки примера, не результат запуска в вашем проекте.
Сначала прочитайте entry plugin. Найдите, откуда он получает jQuery: через свободное имя, window.jQuery, CommonJS-экспорт, фабрику или собственный импорт. Затем зафиксируйте момент чтения. Если файл обёрнут в самовызывающуюся функцию, его side effect происходит при выполнении модуля, а не тогда, когда форма впервые вызывает метод.
Команда npm ls jquery показывает дерево пакетов, но не доказывает число объектов в браузере. В stats.json ищите resolved-пути, chunks и entry, к которым привязан модуль. Повторное упоминание имени jquery — повод изучить граф, но не доказательство двух runtime-экземпляров: Webpack мог объединить модуль или оставить разные копии по разным путям. Окончательную проверку дают равенство ссылок, наличие метода и Network одного выпуска.
window.jQuery, импортированный $ и равенство ссылок.stats.json на том же lock-файле и объясните каждый resolved-путь jQuery.Успешный вызов метода не закрывает проблему доставки. Проверьте 404, старый HTML, отказ lazy-chunk, CSP и отключённый внешний script. Если optional-плагин не загрузился, форма должна перейти в понятное состояние: показать допустимый fallback или сообщить об ограничении, а не изображать активную маску.
\nBridge применим к браузерному legacy-коду, который действительно читает window.jQuery. В SSR, worker и тестовом окружении объекта window может не быть. Отделите браузерный адаптер от серверного модуля и не запускайте его на сервере только ради прохождения импорта.
Путь inputmask/dist/jquery.inputmask из старых инструкций нельзя считать вечным API. Официальный проект Inputmask поддерживает vanilla JavaScript и jQuery, но структура пакета, способ подключения и версия меняются. Для конкретного релиза проверьте его README, package entry и фактический метод plugin; не выдавайте учебный legacyMask за гарантию поведения Inputmask.
Глобальная jQuery остаётся слоем совместимости. Новые модули лучше писать с явными импортами и не смешивать CDN-объект с npm-модулем без проверки ссылок. Не отключайте splitChunks и не добавляйте вторую копию библиотеки как первый ответ: сначала докажите, нарушен ли порядок, доставлен ли нужный файл и действительно ли экземпляры различаются.
Разбор можно считать закрытым, когда на чистом production-профиле и для каждого затронутого entry одновременно выполняются условия: window.jQuery существует до запуска legacy-файла; window.jQuery === $; нужный метод имеет тип function; HTML, runtime и chunks принадлежат одному выпуску и загружаются без 404. Дополнительно проходит реальный сценарий ввода, а отказ optional-части не скрывается.
Если выполнен только импорт или только проверка Console, остаётся незакрытая гипотеза. Вернитесь к моменту чтения глобала и отделите проблему scope от порядка, дублирования и доставки. Такой порядок сохраняет пользу старого plugin, но не превращает временный bridge в незаметную архитектурную зависимость.
\n$, jQuery и window.jQuery.