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 существует, но у поля нет метода старого плагина. Локальная форма работает, релизная — нет. Цена ошибки — сломанная форма на реальном маршруте и повторная сборка с догадками о порядке скриптов.

\n

Тезис простой: доступный в модуле идентификатор $ и свойство window.jQuery — разные контракты. Webpack может подставить модуль в код, который обращается к свободному имени. Legacy-плагин может читать глобальный объект сразу при загрузке. Тогда важны не только пакет и конфигурация, но и момент выполнения каждого файла.

\n

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

\n

Проверьте один и тот же сценарий в development и production: открыть страницу, найти поле, дождаться загрузки, ввести значение и посмотреть консоль. Запишите точное место отказа. Ошибка window.jQuery is undefined указывает на глобальный контракт. Ошибка $(...).inputmask is not a function может означать ранний запуск плагина, вторую копию jQuery или отсутствие регистрации метода.

\n

Учебный пример ниже использует старый jQuery-плагин, который выполняется во время загрузки файла. Это не утверждение о поведении каждой версии inputmask. Конкретный пакет нужно проверить в своей версии: прочитать entry файла, поставить остановку перед инициализацией и посмотреть, какое имя он читает.

\n

Механизм: два слоя видимости

\n

Модуль получает свои импорты через граф зависимостей. Глобальный объект живёт в окружении страницы. Когда код пишет import $ from 'jquery', он получает локальную переменную. Эта строка сама по себе не обязана создать window.$ или window.jQuery. Присваивание в window делает отдельный мост.

\n

ProvidePlugin работает на этапе сборки. Webpack подставляет модуль, когда встречает свободный идентификатор в анализируемом модуле. Поэтому конфигурация с $ и jQuery помогает исходному коду, который вызывает их без явного импорта. Если библиотека ищет именно window.jQuery, задайте этот контракт явно или создайте его в bootstrap-модуле.

\n

Порядок тоже имеет значение. Статический импорт объявляет зависимость до тела текущего модуля. Если импортированный плагин выполняет проверку сразу, он может прочитать глобал до строки, которая должна его создать. Вызов CommonJS require() в учебном примере расположен после присваивания, чтобы граница была видна в runtime. Это приём для legacy-шва, а не рекомендация строить новый код на глобальных переменных.

\n
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 и версии зависимости.

\n

Что делает ProvidePlugin

\n
const 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-граф.

\n

Если зависимость действительно требует глобальное свойство, ProvidePlugin можно настроить на window.jQuery. На старых сборках всё равно полезно оставить явный bootstrap: он показывает владельца глобала, позволяет проверить конфликт экземпляров и задаёт точку перед запуском plugin-кода. Оба подхода требуют проверки конкретного пакета и собранного результата.

\n

Симптомы и минимальные проверки

\n
СимптомПричинаПроверкаДействие
В development работает, в production — undefinedВ dev глобал случайно создаёт layout или внешний scriptСравнить production HTML, Network и initiatorУбрать случайный источник или назначить глобал в явном bootstrap
В модуле есть $, плагин не видит window.jQueryProvidePlugin подставил локальный идентификатор, но не выполнен нужный глобальный контрактПоставить остановку перед загрузкой plugin и проверить window.jQueryСоздать глобал до runtime-загрузки плагина или настроить точное сопоставление
После splitChunks метод пропалHTML, runtime и chunks выпущены не одним набором или изменился порядок исполненияСверить имена фактических ассетов и stats-файлПубликовать HTML и assets как один выпуск; не угадывать имя vendor-файла
На странице две копии jQueryОдна пришла из layout или CDN, вторая — из bundleПроверить window.jQuery === $ и модули с именем jqueryОставить одного владельца и объяснить каждую копию в графе
Глобал есть, но метода нетПлагин не загрузился, выполнился до моста или подключён не тот entryПроверить Network, экспорт plugin и момент регистрации методаИсправить точку подключения; не маскировать отказ повторным вызовом
\n
Порядок загрузки production-ассетов: runtime, chunk с jQuery, bootstrap, legacy-плагин и форма
Иллюстрация показывает учебную модель: runtime и общий chunk загружаются, bootstrap назначает window.jQuery, затем запускается legacy-плагин. Красная ветка обозначает ранний запуск до создания глобала.
\n

Проверяю собранный граф, а не только исходники

\n

Исходный файл показывает намерение, но не фактический порядок production-страницы. После сборки откройте HTML и перечислите стартовые script-теги. Затем в DevTools проверьте запросы, initiator и ошибки выполнения. Если используется runtime Webpack, убедитесь, что он подгружает нужные chunks до вызова bootstrap. Не подставляйте вручную вчерашнее имя vendors~site.js: hashed-имя и набор chunks зависят от конфигурации.

\n

Для учебной проверки можно получить stats-файл и найти в нём модули jQuery:

\n
webpack --mode production --profile --json > dist/stats.json
\n

Команда не выдаёт готовый диагноз. В stats-файле ищите все вхождения jQuery, связь с entry и причины появления chunks. Повторное вхождение требует объяснения, но само число строк не доказывает наличие двух runtime-экземпляров. Сопоставьте граф с проверкой объектов в браузере.

\n

Порядок действий

\n
  1. Зафиксируйте production-симптом на одном URL и одном сценарии формы.
  2. Прочитайте исходный entry legacy-плагина и определите, читает ли он window.jQuery, свободное имя или экспорт функции.
  3. Сравните development и production HTML, script-теги, runtime и chunks.
  4. До запуска плагина проверьте window.jQuery, window.$ и равенство глобала импортированному объекту.
  5. Проверьте Network и initiator: все стартовые ассеты должны прийти без 404 и из одного выпуска.
  6. Соберите stats-файл и найдите все модули jQuery, их entry и причины дублирования.
  7. Если плагин читает глобал при загрузке, назначьте его в bootstrap и вызывайте runtime require() после присваивания.
  8. После фикса очистите кэш, повторите открытие страницы и проверьте регистрацию метода, ввод в поле и отрицательный путь загрузки.
\n

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

\n

Проверяйте не только успешную форму. Если legacy-плагин не загрузился, приложение не должно тихо показать видимость исправной маски. Добавьте явную ошибку в development, fallback для поля и сообщение, которое не блокирует ввод без необходимости. Если загрузка optional-части падает, основная форма должна сохранить понятное состояние.

\n

Глобальный jQuery остаётся техническим долгом. Новые модули лучше писать с явными импортами и локальными зависимостями. Не отключайте splitChunks только потому, что после миграции проявился сбой. Сначала докажите, что нарушен порядок, дублируется библиотека или HTML ссылается на несовместимый набор ассетов.

\n

Версии Webpack, формат пакета, loader и способ генерации HTML меняют детали. Поэтому статья не обещает фиксированное имя chunk и не утверждает конкретный production-результат. Учебная проверка применима только после сверки с версией проекта, исходником плагина и фактическим dist.

\n

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

\n

Исправление готово, когда один и тот же production-сценарий проходит после очистки кэша, window.jQuery равен ожидаемому экземпляру, legacy-плагин регистрирует нужный метод, а форма работает без внешнего случайного script. В Network нет 404, HTML и chunks принадлежат одному выпуску, а stats-файл объясняет каждую копию jQuery. Отказ optional-плагина не скрывает состояние формы.

\n

Если хотя бы один факт не подтверждён, результатом остаётся гипотеза. Не называйте её исправлением. Сначала вернитесь к моменту чтения глобала и отделите проблему видимости от проблемы порядка, графа или DOM.

\n

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

\n" + "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" }