diff --git a/editorial/agent-rewrites/322.json b/editorial/agent-rewrites/322.json index 966bd2d..e2bef33 100644 --- a/editorial/agent-rewrites/322.json +++ b/editorial/agent-rewrites/322.json @@ -3,5 +3,5 @@ "slug": "editorial-2019-01-field-jquery-webpack", "title": "jQuery и Webpack: как вернуть legacy-плагин после production-сборки", "excerpt": "Старый jQuery-плагин работает в development, но пропадает после production-сборки. Разбираем порядок исполнения, глобальный window.jQuery и второй экземпляр зависимости, затем проверяем исправление в браузере и stats.json.", - "contentHtml": "

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

\n

Главный тезис прост: production не «ломает» jQuery сам по себе. Сборка выявляет скрытый контракт старого плагина. Плагин может ожидать глобальный window.jQuery, запуститься до создания этого глобала или расширить другой экземпляр jQuery. Поэтому нужно проверять не только наличие файла в bundle, но и три факта в рантайме: какой объект получил плагин, когда он выполнился и тем ли объектом пользуется приложение.

\n

Что именно теряется

\n

Большинство старых jQuery-плагинов добавляет метод в $.fn. Для диагностики важен не общий вопрос «загрузился ли inputmask», а конкретная проверка: typeof $.fn.legacyMask равен function или нет. Если метод отсутствует, плагин не установил расширение на тот объект, который вызывает приложение.

\n

Модульный импорт и глобальная переменная — разные механизмы. Строка import $ from 'jquery' даёт модулю ссылку на экспорт пакета. Она не обязана записывать ту же ссылку в window.jQuery. ProvidePlugin тоже решает более узкую задачу: Webpack подставляет модуль вместо свободного идентификатора в обработанных модулях. Это не универсальная команда присвоить значение свойству window.

\n

Разница проявляется на границе legacy-кода. Старый файл часто написан как самовызывающаяся функция и читает глобал при выполнении:

\n
(function installLegacyMask(root) {\n  var $ = root.jQuery;\n\n  if (!$ || !$.fn) {\n    throw new Error('legacyMask expects window.jQuery');\n  }\n\n  $.fn.legacyMask = function legacyMask() {\n    return this.addClass('has-legacy-mask');\n  };\n}(window));
\n

Это учебный пример. Он не сообщает результат конкретного production-проекта. Он показывает контракт: к моменту выполнения файла в window.jQuery должен лежать объект с прототипом fn. Если объект появится позже, плагин уже не узнает о нём.

\n

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

\n

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

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

Статические импорты образуют граф зависимостей. Тело модуля не является сценой, на которой Webpack выполняет все строки сверху вниз до разбора следующего импорта. Legacy-файл может выполниться до присваивания в window. В development это иногда скрывает внешний тег script или другой entry, который случайно создаёт глобал раньше.

\n

Безопаснее ограничить старую зависимость маленьким адаптером. Он импортирует один объект jQuery, публикует его в глобальной области и только потом запускает side effect старого файла:

\n
// src/legacy-jquery-bridge.js\nimport $ from 'jquery';\n\nwindow.jQuery = $;\nwindow.$ = $;\nrequire('./vendor/legacy-mask');\n\nexport default $;\n\n// src/bootstrap.js\nimport $ from './legacy-jquery-bridge';\n\n$('.js-phone').legacyMask();
\n

Вызов require() здесь учебный и локальный. Он нужен, чтобы явно показать порядок запуска legacy-файла. Это не рекомендация смешивать CommonJS и ES-модули во всём новом коде. Новый компонент должен принимать зависимость импортом и не менять глобальную область.

\n

Три причины одного симптома

\n
СимптомПричинаПроверкаДействие
window.jQuery пуст до запуска плагинаИмпорт существует только внутри модуляПоставить остановку перед legacy-файлом и вывести window.jQueryСоздать один bridge и загрузить плагин после записи глобала
Метод есть у глобала, но отсутствует у импортированного $Плагин расширил другой экземпляр jQueryСравнить window.jQuery === $ и оба значения typeof $.fn.legacyMaskУбрать второй путь зависимости или выровнять его разрешение
Метода нет ни у глобала, ни у импортаФайл не попал в чанк, получил ошибку или выполнился до bridgeПроверить Console, Network и modules в stats-файлеИсправить entry/порядок, затем повторить production-проверку
Сбой появляется только на странице со вторым entryОбщий модуль попал в разные графы или версии jQuery различаютсяНайти resolved-пути и chunks для jqueryДедуплицировать зависимость, настроить общую часть и проверить рантайм
\n

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

\n
\"Схема
Сначала создайте общий объект jQuery, затем выполните legacy-плагин и только после этого проверяйте метод на $.fn.
\n

Как доказать, что экземпляр один

\n

Проверки в браузерной консоли должны сравнивать ссылки, а не только версии. Два объекта могут иметь одну и ту же строку версии и разные прототипы. Плагин добавит метод одному объекту, а приложение вызовет другой.

\n
import $ from './legacy-jquery-bridge';\n\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

Ожидаемый результат учебной схемы: hasGlobal и sameInstance равны true, оба поля плагина имеют значение function. Это проверяемое условие, а не обещание производительности. В конкретном проекте нужно также убедиться, что браузер загрузил именно новые entry и lazy-чаны.

\n

Как найти дубликат в графе

\n

Команда npm ls jquery показывает дерево пакетов, но не доказывает, что браузер создал два объекта. Совместимые зависимости сборщик может объединить. Обратная ситуация тоже возможна: один и тот же пакет войдёт в разные chunks по разным resolved-путям. Для точного ответа нужен production-статс того же lock-файла и ссылка на фактически загруженные чанки.

\n
# Учебный маршрут диагностики. Это не результат запуска в статье.\nnpx webpack --mode production --profile --json > dist/stats.json\nnpm ls jquery\n\n# В stats.json ищите resolved-пути, modules и chunks с jquery.\n# В браузере сравните window.jQuery и импорт из bridge.
\n

Статистика Webpack содержит assets, chunks и modules. Если она показывает несколько путей к jQuery, это повод изучить граф, но не окончательное доказательство дубликата в рантайме. Закрыть гипотезу можно только вместе с равенством объектов, наличием метода и Network-проверкой загруженных файлов. Если различаются версии, сначала закрепите совместимую версию и проверьте плагин на ней. Если различаются entry, настройте общую зависимость только после проверки всех страниц.

\n

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

\n
  1. Зафиксируйте точный симптом: страницу, селектор, имя метода, текст ошибки, версию ассетов и commit сборки.
  2. Перед вызовом плагина выведите window.jQuery, импортированный $, результат сравнения ссылок и тип метода в $.fn.
  3. Откройте Network. Проверьте ответы entry и lazy-чанов, hash файлов и отсутствие старого HTML или кешированного bundle.
  4. Соберите production-статистику на том же lock-файле. Найдите все resolved-пути, chunks и причины подключения jQuery.
  5. Если плагин читает глобал, добавьте один bridge до его side effect. Если плагин поддерживает импорт, уберите глобальную зависимость.
  6. Повторите проверку на чистой странице, в каждом entry и в сценарии, где виджет загружается лениво.
\n

Ограничения

\n

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

\n

Глобальная jQuery остаётся техническим слоем совместимости. Она увеличивает связанность и усложняет порядок загрузки. Для нового кода лучше использовать явный импорт и передавать зависимость через модульный интерфейс. Не добавляйте ProvidePlugin, если проблема вызвана только отсутствующим window.jQuery: он может скрыть свободный идентификатор, но не исправить внешний контракт.

\n

Source map помогает расследованию, но может раскрыть пути и исходный код. Храните диагностический stats-файл и карты в защищённом месте, если политика проекта не разрешает их публикацию. Не объявляйте учебную проверку результатом production-мониторинга: без запуска на конкретной сборке нельзя утверждать, что её chunks загружены или что пользовательский сценарий исправлен.

\n

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

\n

Исправление готово, когда в production-сборке, на чистом браузерном профиле и для каждого затронутого entry одновременно выполняются четыре условия: window.jQuery существует до запуска legacy-файла; window.jQuery === $; typeof $.fn.legacyMask === 'function'; Network показывает актуальные chunks без 404. Дополнительно проверяется реальное поле, а не только Console.

\n

Если любое условие не выполнено, проблема не закрыта. Ошибка могла исчезнуть из-за случайного порядка или кеша. Если все условия выполнены, граница между модульным кодом и legacy-плагином стала явной: один объект создаётся, bridge публикует его, плагин расширяет его прототип, приложение вызывает тот же объект.

\n

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

" + "contentHtml": "

В development поле с маской номера работает. После production-сборки вызов $('.js-phone').legacyMask() падает: метода нет в $.fn. В реальном проекте вместо учебного legacyMask здесь может быть метод старого inputmask или другого плагина. Цена ошибки — не только красная строка в Console: пользователь не вводит номер, форма отклоняет корректное значение, а релиз приходится откатывать или срочно пересобирать.

\n

Главный вопрос — не «сломал ли production jQuery», а какой контракт нарушен на границе модулей. Старый файл может читать window.jQuery, выполниться до создания этого глобала или расширить другой экземпляр jQuery. Поэтому проверяем три факта: какой объект получил плагин, когда он выполнился и тем ли объектом пользуется приложение. Ни production-бандл конкретного проекта, ни браузерную трассу этой статьи нельзя выдавать за выполненные результаты: ниже учебная схема и воспроизводимый маршрут проверки.

\n

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

\n

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

\n

Модульный импорт и глобальная переменная — разные механизмы. Строка import $ from 'jquery' даёт модулю ссылку на экспорт пакета, но сама по себе не обязана записывать её в window.jQuery. ProvidePlugin решает более узкую задачу: Webpack подставляет импорт вместо свободного идентификатора в обработанных модулях. Это не универсальная команда присвоить значение свойству window.

\n

Для 2019 года это типичная граница между знакомым jQuery и модульной сборкой. В development глобал мог появляться из отдельного тега script или другого entry. В production зависимости попадают в граф модулей и chunks, поэтому случайный порядок перестаёт маскировать скрытое ожидание старого файла.

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

Таблица задаёт гипотезы, а не готовый диагноз. Тот же внешний симптом дают 404 чанка, CSP, старый кеш CDN и несовместимая версия плагина. Поэтому сначала подтверждаем объект и порядок, а уже затем меняем минификацию или оптимизацию.

\n

Учебный сценарий: старый файл читает window.jQuery

\n

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

\n
(function installLegacyMask(root) {\n  var $ = root.jQuery;\n\n  if (!$ || !$.fn) {\n    throw new Error('legacyMask expects window.jQuery');\n  }\n\n  $.fn.legacyMask = function legacyMask() {\n    return this.addClass('has-legacy-mask');\n  };\n}(window));
\n

Это учебный код, а не результат конкретного production-проекта. Он показывает границу: к моменту выполнения файла в window.jQuery должен лежать объект с прототипом fn. Если объект появится позже, уже выполненный плагин не узнает о нём.

\n

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

\n

Следующий код выглядит как последовательность сверху вниз:

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

Но статический import — это объявление зависимости, а не вызов в данной строке. Импортированные модули связываются и дают свои побочные эффекты до выполнения тела текущего модуля. Поэтому legacy-mask.js может прочитать window.jQuery до присваивания ниже. Webpack здесь не «переставляет строки»: он следует семантике модулей, которую исходный код ошибочно принял за обычный императивный сценарий.

\n

Разделим старый side effect и новый код маленьким bridge. Он импортирует один объект jQuery, публикует его в глобальной области и только потом загружает legacy-файл:

\n
// src/legacy-jquery-bridge.js\nimport $ from 'jquery';\n\nwindow.jQuery = $;\nwindow.$ = $;\nrequire('./vendor/legacy-mask');\n\nexport default $;\n\n// src/bootstrap.js\nimport $ from './legacy-jquery-bridge';\n\n$('.js-phone').legacyMask();
\n

Вызов require() здесь локальный и учебный: он делает момент выполнения старого файла видимым. Он не является советом смешивать CommonJS и ES-модули во всём новом коде. Если конкретный плагин экспортирует функцию, лучше импортировать её явно и передать тот же объект; bridge нужен именно для файла, который действительно читает глобал.

\n

Проверяем идентичность экземпляра

\n

Наличие одинаковой строки версии не доказывает, что объекты совпадают. Два экземпляра могут иметь одну версию и разные прототипы. Плагин расширит один $.fn, а экран вызовет другой. Поэтому проверяем ссылки и метод после выполнения bridge:

\n
import $ from './legacy-jquery-bridge';\n\nvar 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

Для учебной схемы ожидаем true в hasGlobal и sameInstance, а также function в обоих полях плагина. Это проверяемое условие, а не обещание, что пользовательский сценарий уже исправлен: на конкретной странице ещё нужно проверить реальное поле и загруженные chunks.

\n

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

\n

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

\n

Решение следует из исходного файла. Для свободного идентификатора проверяем настройку ProvidePlugin и результат сборки. Для явного window.jQuery создаём bridge или применяем loader только с документированным контрактом. Не подменяем оба случая одной конфигурацией: она может скрыть ошибку в development и оставить её в production.

\n
\"Схема
Проверка идёт от порядка исполнения к идентичности экземпляра: сначала bridge, затем плагин, затем сравнение объектов.
\n

Как искать второй экземпляр в production-графе

\n

Команда npm ls jquery показывает дерево пакетов, но не доказывает, что браузер создал два объекта. Совместимые зависимости сборщик может объединить. Обратная ситуация тоже возможна: один и тот же пакет попадёт в разные chunks по разным resolved-путям. Размер итогового JavaScript — слабое доказательство, потому что на него влияют общие chunks, минификация, сжатие и кеш.

\n

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

\n
# Учебный маршрут диагностики для реального проекта.\nnpx webpack --mode production --devtool source-map --profile --json > dist/stats.json\nnpm ls jquery\n\n# В stats.json ищем пути и chunks, где встречается jquery.\n# Затем в браузере сравниваем window.jQuery с импортом из bridge.
\n

Несколько веток в npm ls jquery — повод изучить резолвинг, но не окончательный диагноз. Несколько путей в stats.json — также только гипотеза о дубликате. Её закрывает связка из трёх наблюдений: window.jQuery === $, метод есть на нужном $.fn, а Network показывает фактически загруженные актуальные chunks без 404.

\n

Порядок проверки

\n
  1. Зафиксируйте страницу, один селектор, имя метода, текст ошибки, версию ассетов и commit сборки. Не меняйте конфигурацию до записи исходного симптома.
  2. Перед вызовом плагина выведите window.jQuery, импортированный $, равенство ссылок и тип метода в $.fn.
  3. Откройте Network. Проверьте entry и lazy-chunk, статус ответа, hash файлов и отсутствие старого HTML или кешированного bundle.
  4. Соберите production-статистику на той же версии lock-файла. Найдите resolved-пути jquery, их chunks и причины подключения.
  5. Если плагин читает глобал, создайте один изолированный bridge до его side effect. Если плагин принимает импорт, уберите глобальную зависимость.
  6. Повторите проверку на чистой странице, в каждом entry и в сценарии ленивой загрузки. Успешно исчезнувшая ошибка без совпадения объектов не считается исправлением.
\n

Ограничения решения

\n

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

\n

Глобальная jQuery остаётся техническим слоем совместимости. Она увеличивает связанность и усложняет порядок загрузки. Для нового кода лучше использовать явный импорт и не менять глобальную область. Если найден 404 чанка, CSP, несовместимая версия плагина или старый кеш CDN, bridge не устраняет эту причину — её нужно проверить отдельно.

\n

Source map помогает сопоставить production-код с исходником, но может раскрыть пути и код. Не публикуйте его без проверки политики проекта. Также не объявляйте учебную проверку результатом production-мониторинга: без запуска на конкретной сборке нельзя утверждать, что её chunks загружены или что пользовательский сценарий исправлен.

\n

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

\n

Исправление готово, когда для каждого затронутого entry на production-сборке одновременно выполняются четыре условия: window.jQuery существует до запуска legacy-файла; window.jQuery === $; typeof $.fn.legacyMask === 'function'; Network показывает актуальные chunks без 404. Дополнительно проверяется само поле, а не только Console.

\n

Если любое условие не выполнено, проблема не закрыта. Если все условия выполнены, граница стала явной: один объект создаётся, bridge публикует его, плагин расширяет его прототип, а приложение вызывает тот же объект. Это локальное исправление для legacy-контракта, а не универсальное правило для любой jQuery-сборки.

\n

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

" }