diff --git a/editorial/agent-rewrites/323.json b/editorial/agent-rewrites/323.json index e420e23..cbc8051 100644 --- a/editorial/agent-rewrites/323.json +++ b/editorial/agent-rewrites/323.json @@ -3,5 +3,5 @@ "slug": "editorial-2019-01-mechanism-jquery-webpack", "title": "jQuery в Webpack: почему legacy-плагин теряет глобальный объект", "excerpt": "В development форма работает, а production-сборка получает undefined вместо window.jQuery. Разбираем разницу между ProvidePlugin и глобальным объектом, порядок запуска legacy-плагина и проверку фактических production-ассетов.", - "contentHtml": "
В development поле телефона принимает ввод, а после production-сборки браузер сообщает, что window.jQuery не определён. Иногда ошибка выглядит иначе: jQuery существует, но у поля нет метода старого плагина. Локальная форма работает, релизная — нет. Цена ошибки — сломанная форма на реальном маршруте и повторная сборка с догадками о порядке скриптов.
Тезис простой: доступный в модуле идентификатор $ и свойство window.jQuery — разные контракты. Webpack может подставить модуль в код, который обращается к свободному имени. Legacy-плагин может читать глобальный объект сразу при загрузке. Тогда важны не только пакет и конфигурация, но и момент выполнения каждого файла.
Проверьте один и тот же сценарий в development и production: открыть страницу, найти поле, дождаться загрузки, ввести значение и посмотреть консоль. Запишите точное место отказа. Ошибка window.jQuery is undefined указывает на глобальный контракт. Ошибка $(...).inputmask is not a function может означать ранний запуск плагина, вторую копию jQuery или отсутствие регистрации метода.
Учебный пример ниже использует старый jQuery-плагин, который выполняется во время загрузки файла. Это не утверждение о поведении каждой версии inputmask. Конкретный пакет нужно проверить в своей версии: прочитать entry файла, поставить остановку перед инициализацией и посмотреть, какое имя он читает.
\nМодуль получает свои импорты через граф зависимостей. Глобальный объект живёт в окружении страницы. Когда код пишет import $ from 'jquery', он получает локальную переменную. Эта строка сама по себе не обязана создать window.$ или window.jQuery. Присваивание в window делает отдельный мост.
ProvidePlugin работает на этапе сборки. Webpack подставляет модуль, когда встречает свободный идентификатор в анализируемом модуле. Поэтому конфигурация с $ и jQuery помогает исходному коду, который вызывает их без явного импорта. Если библиотека ищет именно window.jQuery, задайте этот контракт явно или создайте его в bootstrap-модуле.
Порядок тоже имеет значение. Статический импорт объявляет зависимость до тела текущего модуля. Если импортированный плагин выполняет проверку сразу, он может прочитать глобал до строки, которая должна его создать. Вызов CommonJS require() в учебном примере расположен после присваивания, чтобы граница была видна в runtime. Это приём для legacy-шва, а не рекомендация строить новый код на глобальных переменных.
import $ from 'jquery';\n\nfunction exposeJQuery(jq) {\n if (window.jQuery && window.jQuery !== jq) {\n throw new Error('Two jQuery instances reached the page');\n }\n\n window.$ = jq;\n window.jQuery = jq;\n}\n\nexposeJQuery($);\nrequire('inputmask/dist/jquery.inputmask');\nrequire('./legacy-form');\nПример учебный. Он предполагает, что плагин имеет CommonJS-совместимый вход и читает глобал во время загрузки. Если пакет экспортирует фабрику, требует вызова инициализации или использует другой путь, адаптируйте только точку подключения. Не переносите этот код в проект без проверки entry и версии зависимости.
\nconst webpack = require('webpack');\n\nmodule.exports = {\n mode: 'production',\n entry: {\n site: './src/bootstrap-legacy.js',\n },\n plugins: [\n new webpack.ProvidePlugin({\n $: 'jquery',\n jQuery: 'jquery',\n }),\n ],\n};\nВ таком варианте Webpack обслуживает свободные имена внутри модулей, которые он анализирует. Конфигурация не доказывает, что в момент загрузки внешнего legacy-файла уже существует window.jQuery. Она также не доказывает, что HTML подключил одну копию jQuery. Поэтому после настройки проверяйте три факта отдельно: какое имя читает плагин, какой объект назначен глобалу и сколько экземпляров попало в production-граф.
Если зависимость действительно требует глобальное свойство, ProvidePlugin можно настроить на window.jQuery. На старых сборках всё равно полезно оставить явный bootstrap: он показывает владельца глобала, позволяет проверить конфликт экземпляров и задаёт точку перед запуском plugin-кода. Оба подхода требуют проверки конкретного пакета и собранного результата.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
В development работает, в production — undefined | В dev глобал случайно создаёт layout или внешний script | Сравнить production HTML, Network и initiator | Убрать случайный источник или назначить глобал в явном bootstrap |
В модуле есть $, плагин не видит window.jQuery | ProvidePlugin подставил локальный идентификатор, но не выполнен нужный глобальный контракт | Поставить остановку перед загрузкой plugin и проверить window.jQuery | Создать глобал до runtime-загрузки плагина или настроить точное сопоставление |
| После splitChunks метод пропал | HTML, runtime и chunks выпущены не одним набором или изменился порядок исполнения | Сверить имена фактических ассетов и stats-файл | Публиковать HTML и assets как один выпуск; не угадывать имя vendor-файла |
| На странице две копии jQuery | Одна пришла из layout или CDN, вторая — из bundle | Проверить window.jQuery === $ и модули с именем jquery | Оставить одного владельца и объяснить каждую копию в графе |
| Глобал есть, но метода нет | Плагин не загрузился, выполнился до моста или подключён не тот entry | Проверить Network, экспорт plugin и момент регистрации метода | Исправить точку подключения; не маскировать отказ повторным вызовом |
window.jQuery, затем запускается legacy-плагин. Красная ветка обозначает ранний запуск до создания глобала.Исходный файл показывает намерение, но не фактический порядок production-страницы. После сборки откройте HTML и перечислите стартовые script-теги. Затем в DevTools проверьте запросы, initiator и ошибки выполнения. Если используется runtime Webpack, убедитесь, что он подгружает нужные chunks до вызова bootstrap. Не подставляйте вручную вчерашнее имя vendors~site.js: hashed-имя и набор chunks зависят от конфигурации.
Для учебной проверки можно получить stats-файл и найти в нём модули jQuery:
\nwebpack --mode production --profile --json > dist/stats.json\nКоманда не выдаёт готовый диагноз. В stats-файле ищите все вхождения jQuery, связь с entry и причины появления chunks. Повторное вхождение требует объяснения, но само число строк не доказывает наличие двух runtime-экземпляров. Сопоставьте граф с проверкой объектов в браузере.
\nwindow.jQuery, свободное имя или экспорт функции.window.jQuery, window.$ и равенство глобала импортированному объекту.require() после присваивания.Проверяйте не только успешную форму. Если legacy-плагин не загрузился, приложение не должно тихо показать видимость исправной маски. Добавьте явную ошибку в development, fallback для поля и сообщение, которое не блокирует ввод без необходимости. Если загрузка optional-части падает, основная форма должна сохранить понятное состояние.
\nГлобальный jQuery остаётся техническим долгом. Новые модули лучше писать с явными импортами и локальными зависимостями. Не отключайте splitChunks только потому, что после миграции проявился сбой. Сначала докажите, что нарушен порядок, дублируется библиотека или HTML ссылается на несовместимый набор ассетов.
Версии Webpack, формат пакета, loader и способ генерации HTML меняют детали. Поэтому статья не обещает фиксированное имя chunk и не утверждает конкретный production-результат. Учебная проверка применима только после сверки с версией проекта, исходником плагина и фактическим dist.
Исправление готово, когда один и тот же production-сценарий проходит после очистки кэша, window.jQuery равен ожидаемому экземпляру, legacy-плагин регистрирует нужный метод, а форма работает без внешнего случайного script. В Network нет 404, HTML и chunks принадлежат одному выпуску, а stats-файл объясняет каждую копию jQuery. Отказ optional-плагина не скрывает состояние формы.
Если хотя бы один факт не подтверждён, результатом остаётся гипотеза. Не называйте её исправлением. Сначала вернитесь к моменту чтения глобала и отделите проблему видимости от проблемы порядка, графа или DOM.
\nРассмотрим знакомый сценарий 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.