Files
progcode/editorial/agent-rewrites/323.json
T

8 lines
20 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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>&lt;script&gt;</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 &amp;&amp; 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 &gt; 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>"
}