8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 323,
|
||
"slug": "editorial-2019-01-mechanism-jquery-webpack",
|
||
"title": "jQuery в Webpack: почему legacy-плагин теряет глобальный объект",
|
||
"excerpt": "В development форма работает, а production-сборка получает undefined вместо window.jQuery. Разбираем разницу между ProvidePlugin и глобальным объектом, порядок запуска legacy-плагина и проверку фактических production-ассетов.",
|
||
"contentHtml": "<p>Рассмотрим знакомый сценарий 2019 года: разработчик открывает форму телефона на тестовом стенде, ввод проходит через старый jQuery-плагин, а после production-сборки в Console появляется <code>window.jQuery is undefined</code>. В другом варианте глобал существует, но вызов <code>$('.js-phone').legacyMask()</code> заканчивается ошибкой о неизвестном методе. Цена ошибки понятна: пользователь не может заполнить поле, а команда получает релиз, который приходится разбирать по минифицированным chunks.</p>\n<p>Первое предположение обычно звучит так: «Webpack потерял jQuery». Точнее разделить проблему на два контракта. Модуль получает импорт внутри своего scope, а старый plugin-файл может искать объект в <code>window</code> и расширять его <code>$.fn</code> во время загрузки. Поэтому наличие строки <code>jquery</code> в bundle ничего не доказывает: нужно установить, какой объект прочитал plugin, когда он это сделал и тот ли объект использует форма.</p>\n<h2>Сценарий: один плагин, два контракта</h2>\n<p>Начнём с минимальной сцены. На странице есть поле <code>.js-phone</code>, приложение собирается Webpack, а legacy-файл подключается как side effect. Разработчик сначала проверяет импорт в исходном модуле: <code>import $ from 'jquery'</code> возвращает объект. Затем он смотрит в Console и видит, что <code>window.jQuery</code> пуст. Значит, проверять нужно не пакет вообще, а границу между модульным scope и глобальной областью страницы.</p>\n<p>Учебный plugin ниже намеренно короткий. Он не утверждает, что конкретная версия Inputmask устроена так же. Он показывает класс контракта: файл читает глобальный объект сразу при выполнении и добавляет метод к его прототипу.</p>\n<pre><code>(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));</code></pre>\n<p>Если этот файл выполнится до записи в <code>window.jQuery</code>, он завершится ошибкой или не зарегистрирует метод. Если plugin расширит другую копию jQuery, метод появится у одного объекта, а приложение вызовет другой. Оба случая выглядят для пользователя одинаково: маска не работает.</p>\n<h2>Что делает ProvidePlugin</h2>\n<p><code>ProvidePlugin</code> работает на этапе анализа модулей. Когда Webpack встречает свободное имя вроде <code>$</code> или <code>jQuery</code> в разбираемом модуле, он автоматически добавляет загрузку указанного модуля. Это удобно для старого кода, который вызывает <code>jQuery('.row')</code> без явного импорта.</p>\n<pre><code>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};</code></pre>\n<p>Официальная документация также показывает запись <code>\"window.jQuery\": \"jquery\"</code> для кода, который обращается к этому выражению. Но область действия всё равно важна: Webpack может подставить модуль только там, где он анализирует исходный код. ProvidePlugin не управляет произвольным классическим <code><script></code>, CDN-ресурсом или файлом, который исключён из анализа через <code>noParse</code>. Поэтому внешний plugin может по-прежнему читать реальное свойство <code>window.jQuery</code>, которого ещё нет.</p>\n<p>Практическое правило такое: ProvidePlugin закрывает импортный контракт, а явное присваивание закрывает контракт глобального объекта. Иногда достаточно первого, иногда нужны оба. Решение зависит от того, как устроен entry конкретного plugin и кто запускает его side effect.</p>\n<h2>Почему порядок статических импортов обманывает</h2>\n<p>Такой entry выглядит последовательным, но запись глобала происходит слишком поздно:</p>\n<pre><code>import $ from 'jquery';\nimport './vendor/legacy-mask';\n\nwindow.jQuery = $;\n$('.js-phone').legacyMask();</code></pre>\n<p>Статический <code>import</code> связывается и выполняется до тела модуля-импортёра. Поэтому side effect из <code>legacy-mask</code> может прочитать <code>window.jQuery</code> раньше, чем дойдёт очередь до присваивания внизу. В development это иногда скрывает внешний тег, другой entry или порядок, случайно создающий глобал. Production не обязан повторять такое совпадение.</p>\n<p>Для старого CommonJS-совместимого plugin можно сделать небольшой bridge. Он владеет единственным импортом jQuery, проверяет конфликт и запускает legacy-файл только после публикации объекта:</p>\n<pre><code>// 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();</code></pre>\n<p>Здесь <code>require()</code> — локальный Webpack-шов для CommonJS или legacy-файла, а не совет строить новый код на глобальных переменных. Если пакет предоставляет фабрику или ESM-вход, вызовите его API после записи глобала; не копируйте путь из старого примера без сверки версии и entry.</p>\n<h2>Четыре гипотезы одного симптома</h2>\n<table><thead><tr><th scope='col'>Наблюдение</th><th scope='col'>Рабочая гипотеза</th><th scope='col'>Проверка</th><th scope='col'>Ограниченное действие</th></tr></thead><tbody><tr><td><code>window.jQuery</code> пуст перед plugin</td><td>Импорт живёт только в модуле, глобальный мост не выполнился</td><td>Breakpoint перед plugin и <code>Boolean(window.jQuery)</code></td><td>Добавить один bridge перед side effect</td></tr><tr><td>Метод есть у глобала, но отсутствует у импортированного <code>$</code></td><td>Plugin расширил другой экземпляр jQuery</td><td>Сравнить <code>window.jQuery === $</code> и тип метода у обоих объектов</td><td>Убрать второй источник или выровнять resolved-путь</td></tr><tr><td>Оба объекта есть, метода нет</td><td>Plugin не загрузился, получил ошибку или выбран не тот entry</td><td>Проверить Console, Network и фактический экспорт файла</td><td>Исправить доставку/entry, не маскировать повторным вызовом</td></tr><tr><td>В одном entry работает, в другом нет</td><td>Разные chunks, HTML или версии jQuery</td><td>Сопоставить assets, initiator и модули в stats-файле</td><td>Публиковать совместимый набор HTML и assets</td></tr></tbody></table>\n<figure><img src='/assets/editorial/2019/webpack-jquery-order-2019.svg' alt='Схема загрузки Webpack-ассетов: runtime и jQuery, bridge, legacy-плагин, форма; красная ветка показывает ранний запуск' loading='lazy'><figcaption>В рабочем порядке bridge сначала публикует один объект jQuery, затем legacy-плагин расширяет его, и только после этого форма вызывает метод. Ранний side effect закрывает глобал до bridge и даёт тот же симптом, что и отсутствующий plugin.</figcaption></figure>\n<h2>Как воспроизвести и закрыть гипотезу</h2>\n<p>Соберите маленький fixture с тремя файлами: <code>legacy-mask.js</code> из примера выше, bridge и bootstrap. Установите jQuery, Webpack и webpack-cli в отдельном каталоге. Важен не конкретный размер bundle, а наблюдаемые условия: plugin получает тот же объект, метод существует, а entry загружает все chunks без ошибки.</p>\n<pre><code>npm install jquery webpack webpack-cli\nnpx webpack --mode production --profile --json > dist/stats.json</code></pre>\n<p>После сборки не подставляйте вручную имя вроде <code>vendors~site.js</code>. Откройте сгенерированный HTML, найдите реальные script-теги, проверьте ответы в Network и посмотрите initiator для runtime и lazy-chunks. Hashed-имена и разбиение зависят от конфигурации, поэтому вчерашний filename не является контрактом.</p>\n<p>В браузере сравните ссылки, а не только версии. Одинаковая строка версии не доказывает, что это один JavaScript-объект.</p>\n<pre><code>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);</code></pre>\n<p>Ожидаемое состояние учебного fixture: <code>hasGlobal</code> и <code>sameInstance</code> равны <code>true</code>, оба поля plugin имеют значение <code>function</code>, а Network не содержит 404. Это критерии проверки примера, не результат запуска в вашем проекте.</p>\n<h2>Что искать в stats и в исходнике зависимости</h2>\n<p>Сначала прочитайте entry plugin. Найдите, откуда он получает jQuery: через свободное имя, <code>window.jQuery</code>, CommonJS-экспорт, фабрику или собственный импорт. Затем зафиксируйте момент чтения. Если файл обёрнут в самовызывающуюся функцию, его side effect происходит при выполнении модуля, а не тогда, когда форма впервые вызывает метод.</p>\n<p>Команда <code>npm ls jquery</code> показывает дерево пакетов, но не доказывает число объектов в браузере. В <code>stats.json</code> ищите resolved-пути, chunks и entry, к которым привязан модуль. Повторное упоминание имени <code>jquery</code> — повод изучить граф, но не доказательство двух runtime-экземпляров: Webpack мог объединить модуль или оставить разные копии по разным путям. Окончательную проверку дают равенство ссылок, наличие метода и Network одного выпуска.</p>\n<ol><li>Зафиксируйте URL, entry, точную ошибку, commit сборки и сценарий, в котором поле перестаёт работать.</li><li>До выполнения plugin проверьте <code>window.jQuery</code>, импортированный <code>$</code> и равенство ссылок.</li><li>Прочитайте фактический entry зависимости и определите, когда она читает глобал.</li><li>Сравните development и production HTML, runtime, chunks, initiator и ответы Network.</li><li>Соберите <code>stats.json</code> на том же lock-файле и объясните каждый resolved-путь jQuery.</li><li>Добавьте bridge только там, где старый контракт действительно требует глобал; для нового кода оставьте явный импорт.</li><li>Повторите production-сценарий после очистки кэша на каждой странице и в каждом затронутом entry.</li></ol>\n<h2>Отрицательный путь и границы решения</h2>\n<p>Успешный вызов метода не закрывает проблему доставки. Проверьте 404, старый HTML, отказ lazy-chunk, CSP и отключённый внешний script. Если optional-плагин не загрузился, форма должна перейти в понятное состояние: показать допустимый fallback или сообщить об ограничении, а не изображать активную маску.</p>\n<p>Bridge применим к браузерному legacy-коду, который действительно читает <code>window.jQuery</code>. В SSR, worker и тестовом окружении объекта <code>window</code> может не быть. Отделите браузерный адаптер от серверного модуля и не запускайте его на сервере только ради прохождения импорта.</p>\n<p>Путь <code>inputmask/dist/jquery.inputmask</code> из старых инструкций нельзя считать вечным API. Официальный проект Inputmask поддерживает vanilla JavaScript и jQuery, но структура пакета, способ подключения и версия меняются. Для конкретного релиза проверьте его README, package entry и фактический метод plugin; не выдавайте учебный <code>legacyMask</code> за гарантию поведения Inputmask.</p>\n<p>Глобальная jQuery остаётся слоем совместимости. Новые модули лучше писать с явными импортами и не смешивать CDN-объект с npm-модулем без проверки ссылок. Не отключайте <code>splitChunks</code> и не добавляйте вторую копию библиотеки как первый ответ: сначала докажите, нарушен ли порядок, доставлен ли нужный файл и действительно ли экземпляры различаются.</p>\n<h2>Критерий готовности</h2>\n<p>Разбор можно считать закрытым, когда на чистом production-профиле и для каждого затронутого entry одновременно выполняются условия: <code>window.jQuery</code> существует до запуска legacy-файла; <code>window.jQuery === $</code>; нужный метод имеет тип <code>function</code>; HTML, runtime и chunks принадлежат одному выпуску и загружаются без 404. Дополнительно проходит реальный сценарий ввода, а отказ optional-части не скрывается.</p>\n<p>Если выполнен только импорт или только проверка Console, остаётся незакрытая гипотеза. Вернитесь к моменту чтения глобала и отделите проблему scope от порядка, дублирования и доставки. Такой порядок сохраняет пользу старого plugin, но не превращает временный bridge в незаметную архитектурную зависимость.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href='https://webpack.js.org/plugins/provide-plugin/' target='_blank' rel='noopener noreferrer'>Webpack: ProvidePlugin</a> — область действия автоматической подстановки модулей и примеры для <code>$</code>, <code>jQuery</code> и <code>window.jQuery</code>.</li><li><a href='https://webpack.js.org/guides/shimming/' target='_blank' rel='noopener noreferrer'>Webpack: Shimming</a> — ограничения legacy-модулей, AST-анализ и способы подключения старого кода.</li><li><a href='https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules' target='_blank' rel='noopener noreferrer'>MDN: JavaScript modules</a> — scope импортов, граф зависимостей и выполнение side effect до тела импортирующего модуля.</li><li><a href='https://github.com/RobinHerbots/Inputmask' target='_blank' rel='noopener noreferrer'>Inputmask: официальный репозиторий</a> — поддерживаемые способы работы библиотеки и проверка актуального способа подключения.</li></ul>"
|
||
}
|