[\s\S]*?<\/div>/g, ''),
);
}
function createRevision(meta, bodyParts, sources) {
const bodyHtml = bodyParts.join('\n');
const proseLength = proseText(bodyHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': prose length must be 5000–15000, got ' + proseLength);
}
if (sources.length < 2) {
throw new Error(meta.slug + ': at least two primary sources are required');
}
return {
...meta,
contentHtml: [
bodyHtml,
heading('Проверяемые источники'),
sourceList(sources),
].join('\n'),
proseLength,
};
}
const ecmascript2019Modules = {
title: 'ECMAScript 2019: Modules',
url: 'https://262.ecma-international.org/10.0/#sec-modules',
note: 'нормативная модель Module Record, статических import/export и выполнения связанного графа модулей',
};
const htmlScriptElement = {
title: 'HTML Living Standard: the script element',
url: 'https://html.spec.whatwg.org/multipage/scripting.html#the-script-element',
note: 'тип module, загрузка графа зависимостей, отличие async и nomodule, CORS для внешних модулей',
};
const htmlModuleScripts = {
title: 'HTML Living Standard: JavaScript module scripts',
url: 'https://html.spec.whatwg.org/multipage/webappapis.html#javascript-module-scripts',
note: 'module map, URL-идентичность модуля и разрешение module specifier в браузере',
};
const fetchCors = {
title: 'Fetch Standard: CORS protocol and credentials',
url: 'https://fetch.spec.whatwg.org/#cors-protocol-and-credentials',
note: 'ограничения межсайтовой загрузки, которые относятся и к импортам модулей',
};
const urlStandard = {
title: 'URL Standard',
url: 'https://url.spec.whatwg.org/',
note: 'модель URL, на которой основано разрешение относительных адресов модулей',
};
const practiceArticle = createRevision(
{
slug: 'editorial-2019-02-practice-es-modules',
title: 'ES-модули в браузере: старт без сборщика',
categories: ['JavaScript', 'ES modules', 'Браузер', 'Практика'],
cover: '/assets/editorial/2019/es-modules-native-graph-2019.svg',
excerpt: 'Страница открывается, но модуль не запускается или импорт отвечает 404. Собираем минимальный нативный граф, проверяем URL, MIME и порядок запуска без предположений о Webpack.',
readingMinutes: 11,
},
[
paragraph('Страница уже отдала HTML, но кнопка остаётся пустой: модуль не стартовал, а Console показывает ошибку загрузки или разрешения пути. Цена ошибки — не одна сломанная кнопка. Если сразу подменить модуль готовым bundle, причина останется в сервере или адресах и вернётся при следующем экране.'),
paragraph('Разберём один вопрос: как запустить небольшой граф ES-модулей прямо в браузере и доказать, что его загрузил именно браузер, а не логика сборщика. Ниже учебная страница с тремя файлами. Она не является трассой настоящего сайта и не обещает поддержку любого старого браузера.'),
heading('Граница задачи: модульный тег, а не новый bundle'),
paragraph('Нативный модуль начинается с тега <script type="module">. Для браузера это другой вид скрипта: он получает entry-файл, находит его статические import, строит граф и загружает зависимости. Внутри модуля верхнеуровневые объявления не становятся случайными свойствами window; связь между файлами описывается импортом и экспортом.'),
paragraph('Здесь полезно отделить две вещи. Спецификация ECMAScript задаёт синтаксис и связи модулей. Браузерная среда HTML задаёт, как получить файлы по URL, когда начать вычисление и как применить CORS. Webpack может подготовить другой набор файлов заранее, но его правила не появляются в браузере только от слова import.'),
dataTable(
['Наблюдение', 'Причина в нативном сценарии', 'Проверка', 'Действие'],
[
['После загрузки HTML интерфейс пустой', 'entry-модуль не запросился или не вычислился', 'Network: есть ли запрос app.js; Console: есть ли ошибка модуля', 'Проверить тег type="module" и точный URL entry'],
['Виден 404 на импорт', 'Относительный адрес указывает не в ту папку', 'Открыть в Network фактический Request URL', 'Исправить путь в файле-импортёре, не в HTML наугад'],
['Вместо JavaScript приходит документ', 'Маршрут сервера вернул HTML fallback', 'Сверить Response и заголовок Content-Type', 'Отделить путь ассета от маршрута приложения'],
['Импорт с другого origin не загружается', 'У модуля действует CORS-проверка', 'Посмотреть Console и CORS-заголовки ответа', 'Настроить разрешённый origin или отдать модуль с того же origin'],
],
),
paragraph('Таблица не заменяет чтение ошибки. Например, 404 и CORS иногда выглядят как одинаковый «модуль не работает». Сначала сохраняем фактический URL и текст Console. Только после этого меняем путь или заголовок.'),
heading('Минимальная учебная страница'),
paragraph('Соберём каталог, который можно отдать любым локальным HTTP-сервером. Открывать index.html через file:// не стоит: у файлового URL другой origin, а диагностика CORS и путей станет не похожа на доставку сайта. Пример использует только относительные URL и не требует пакета из node_modules.'),
codeBlock([
'',
'',
'',
'ES modules: учебная страница',
'',
'
Статус загрузки
',
' ',
'',
'',
'',
'// assets/message.js',
'export function message(name) {',
' return "модуль " + name + " получен";',
'}',
'',
'// assets/app.js',
'import { message } from "./message.js";',
'',
'const status = document.querySelector("#status");',
'status.textContent = message("app.js");',
'console.log("Учебный entry:", import.meta.url);',
]),
paragraph('Проверка воспроизводима: после ответа сервера браузер должен запросить assets/app.js, затем assets/message.js, а в output появится строка из экспорта. В Console выводится URL самого app.js. Это учебная отметка, а не запись Network конкретного проекта.'),
heading('Что браузер делает с type="module"'),
paragraph('У entry без async браузер загружает сам модуль и его зависимости параллельно с разбором HTML, а вычисляет entry после завершения разбора документа. Поэтому в примере main уже существует к моменту обращения к document.querySelector. Атрибут defer для module-скрипта не добавляет отдельного режима: без async модуль уже ведёт себя как отложенный относительно разбора документа.'),
paragraph('Это не значит, что любое действие разрешено писать в верхнем уровне. Модуль зависит от доступности всего его графа: если message.js вернул 404 или неподходящий ответ, app.js не должен считать себя готовым. Поэтому признаком успеха служат и DOM-результат, и два сетевых ответа, и отсутствие ошибки загрузки. Один console.log без Network не доказывает правильный путь.'),
paragraph('Если добавить async к module-скрипту, браузер сможет вычислить граф, как только он готов, потенциально до конца разбора HTML. Для виджета, которому нужен элемент в документе, это меняет условие запуска. Не ставим async как ускоритель, пока не проверили, что код не обращается к ещё не разобранной разметке.'),
heading('Относительный путь читается от файла-импортёра'),
paragraph('В примере ./message.js находится внутри assets/app.js, поэтому браузер ищет assets/message.js. Он не считает путь от адреса страницы index.html и не перебирает расширения. Запись ./message означает URL без добавленного .js; если сервер не имеет такого ресурса, запрос закончится 404 или HTML fallback.'),
codeBlock([
'// Структура учебного каталога',
'// /demo/index.html',
'// /demo/assets/app.js',
'// /demo/assets/message.js',
'',
'// В /demo/assets/app.js:',
'import { message } from "./message.js";',
'',
'// Браузер запросит /demo/assets/message.js.',
'// Не /demo/message.js и не /demo/assets/message автоматически.',
]),
paragraph('Такая проверка полезнее правки с несколькими ../ наугад. Открываем ошибочный Request URL, находим в нём путь entry-файла и вычисляем рядом с ним адрес импорта. Если ожидаемый файл лежит в другом месте, меняем спецификатор или структуру каталога. Если файл есть, но ответ содержит HTML, исправляем правило раздачи статических файлов.'),
heading('Сервер тоже входит в минимальный контракт'),
paragraph('Браузер получает модуль через HTTP. Для своего origin ответ должен быть JavaScript-ресурсом, а для другого origin дополнительно пройти CORS. На практике проверяем статус, итоговый URL после redirect и Content-Type. Ошибка MIME или CORS не лечится перестановкой импортов: это уже граница между модулем и серверной конфигурацией.'),
paragraph('Не подставляем в native import имя пакета вроде date-fns, если сервер не сделал для него URL-маршрут. В учебной странице используем только ./, ../, абсолютный путь или полный URL. Правила пакетов, aliases и автоматических расширений принадлежат сборщику и будут разобраны отдельно.'),
figure('/assets/editorial/2019/es-modules-native-graph-2019.svg', 'Учебный граф нативных ES-модулей: HTML загружает app.js, тот запрашивает message.js, после готовности графа app.js меняет output', 'Нативный запуск проверяем по трём наблюдениям: entry и зависимость запрошены по правильным URL, а код меняет ожидаемый элемент страницы.'),
heading('Порядок ручной проверки'),
orderedList([
'Отдать учебный каталог через локальный HTTP-сервер и открыть адрес страницы, а не файл на диске.',
'В HTML оставить ровно один <script type="module" src="./assets/app.js">; не добавлять bundle «для надёжности».',
'В Network включить сохранение записей, перезагрузить страницу и записать статус, Request URL и Content-Type для app.js и message.js.',
'Сверить Console: не должно быть ошибки разрешения, MIME или CORS; затем проверить строку в элементе #status.',
'Если импорт не найден, считать относительный путь от app.js, а не от адреса вкладки. Исправить один спецификатор и повторить тот же сценарий.',
'Только после успешного нативного примера переносить схему в страницу приложения и отдельно решать, нужен ли ей fallback для старых браузеров.',
]),
heading('Совместимость и ограничения'),
bulletList([
'Пример рассчитан на браузер с поддержкой module scripts. Для старого браузера можно держать отдельный classic-артефакт с nomodule; этот fallback надо тестировать как отдельный путь.',
'Код не измеряет скорость и не доказывает порядок в чужом приложении. Он показывает только минимальную форму загрузки и точки наблюдения.',
'Cross-origin модуль требует корректной CORS-политики. Отключение защиты в браузере не является проверкой production-конфигурации.',
'Маршрутизатор SPA может возвращать index.html на неизвестный URL. Для модуля это ошибка раздачи ассета, даже если ответ имеет статус 200.',
'Пример не использует import map, aliases и package resolution. Для 2019 года это намеренное ограничение: путь должен быть виден в самом спецификаторе.',
]),
heading('Итог'),
paragraph('Нативный ES-модуль стартует не после «подключения JavaScript вообще», а после успешной загрузки графа URL. В первую очередь проверяем тег entry, адреса двух файлов, их HTTP-ответы и DOM-результат. Когда этот маршрут работает без сборщика, становится понятно, где заканчивается браузерный контракт и где начинаются правила конкретного инструмента доставки.'),
],
[ecmascript2019Modules, htmlScriptElement, htmlModuleScripts, fetchCors],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2019-02-mechanism-es-modules',
title: 'ES-модули: почему import в браузере не равен import в Webpack',
categories: ['JavaScript', 'ES modules', 'Webpack', 'Разбор'],
cover: '/assets/editorial/2019/es-modules-resolution-boundary-2019.svg',
excerpt: 'Сборщик принял import по имени пакета, а браузер не знает, куда идти. Разделяем URL-резолвацию, построение графа и дополнительные правила Webpack, не смешивая их в один диагноз.',
readingMinutes: 12,
},
[
paragraph('Сборка проходит с import { format } from "date-kit", а страница с тем же исходным файлом отвечает Failed to resolve module specifier. Цена ошибки — двойная конфигурация: разработчик меняет Webpack, хотя браузер в этот момент вообще не получил URL зависимости.'),
paragraph('Разберём один вопрос: почему одинаковый синтаксис import имеет разную доставку в нативном браузере и после Webpack. Учебные файлы ниже показывают причинную модель. Они не являются логом конкретной сборки и не утверждают, что любой bundler работает одинаково.'),
heading('Один синтаксис, две разные границы'),
paragraph('ECMAScript описывает, как модуль объявляет зависимость и что он импортирует из другого модуля. Браузер получает этот спецификатор и должен превратить его в URL ресурса. Webpack читает исходный граф раньше браузера, применяет свою конфигурацию и отдаёт уже созданные assets. В браузере после сборки часто нет исходной строки import "date-kit": есть entry-файл и чанки, адреса которых сгенерировал bundler.'),
paragraph('Поэтому вопрос «почему import не работает» нужно разрезать. Если в исходнике страницы используется native module, проверяем URL-спецификатор, сервер и браузер. Если исходник прошёл через сборку, проверяем resolved-путь в конфигурации и выданный asset. Нельзя объявлять алиас Webpack свойством браузера или считать ошибку URL следствием tree shaking.'),
dataTable(
['Запись import', 'Что видит браузер без дополнительного отображения', 'Что может сделать Webpack', 'Проверка границы'],
[
['import "./format.js"', 'Относительный URL от файла-импортёра', 'Оставить путь или включить файл в bundle', 'Network показывает URL от importer.js'],
['import "/assets/format.js"', 'URL от origin сайта', 'Обработать как путь проекта по своей конфигурации', 'Сравнить URL в браузере с public path сборщика'],
['import "date-kit"', 'Не URL-спецификатор для учебного native-сценария', 'Найти пакет, alias или поле package.json', 'Не открывать Network до появления реального URL'],
['import "./format"', 'Запросить URL без автоматического .js', 'Может подобрать расширение по настройке resolve', 'Проверить точный URI и правило resolve отдельно'],
],
),
paragraph('В последней колонке специально нет универсального рецепта. Конфигурация Webpack может подставлять aliases, расширения, loader или путь к пакету; браузер без отдельного механизма таких правил не получает. Для исторического сценария 2019 года в нативных примерах используем URL-подобные спецификаторы с расширением.'),
heading('Учебная папка: URL считается от импортёра'),
paragraph('Посмотрим на дерево, в котором адрес страницы и адрес модуля лежат в разных папках. Главная ошибка здесь — мысленно вычислять ./config.js от index.html. Его базой служит URL файла main.js, потому что именно он содержит import.'),
codeBlock([
'',
'',
'',
'// /demo/assets/app/main.js',
'import { apiRoot } from "./config.js";',
'console.log("API:", apiRoot);',
'',
'// /demo/assets/app/config.js',
'export const apiRoot = "/api/v1";',
'',
'// Browser request: /demo/assets/app/config.js',
]),
paragraph('Если заменить строку на import { apiRoot } from "./config", нативный браузер не обязан угадать суффикс. Он идёт за URL /demo/assets/app/config. Сервер может честно вернуть 404 или ошибочно вернуть HTML страницы. Webpack, напротив, может по своей настройке найти config.js ещё на этапе сборки. Это два разных момента времени и два разных набора правил.'),
paragraph('Такую разницу удобно видеть в двух коротких проверках. Для native-страницы открываем Network и копируем Request URL импорта. Для Webpack-сценария смотрим, какой модуль попал в статистику сборки и какой asset выдан в HTML. Результаты не спорят друг с другом: они отвечают на разные вопросы.'),
heading('Порядок вычисления задаёт граф, а не строка после import'),
paragraph('Статический import не является вызовом, который выполняется в середине тела модуля. Сначала среда подготавливает связи графа, затем зависимые модули вычисляются до модуля, который от них зависит. В учебном примере сообщение из config.js появится до сообщения из main.js, даже если строка import записана первой.'),
codeBlock([
'// config.js',
'console.log("1. вычисляется config.js");',
'export const apiRoot = "/api/v1";',
'',
'// main.js',
'import { apiRoot } from "./config.js";',
'console.log("2. main.js получил " + apiRoot);',
]),
paragraph('Это не приглашение строить приложение на побочных эффектах верхнего уровня. Если два независимых entry-модуля меняют один window-объект, порядок их завершения может зависеть от способа подключения и готовности графов. Для общего состояния лучше оставить один entry или передать явную функцию инициализации. Важно другое: строка ниже статического import не может подготовить зависимость, которую тот import уже потребовал.'),
paragraph('У module-скрипта без async вычисление ждёт конца разбора документа и готового графа. С async момент запуска меняется. Webpack может добавить свой runtime, динамические чанки и порядок подключения assets, но это его доставка; стандарт браузера не знает о splitChunks, alias или ProvidePlugin.'),
heading('Что происходит после Webpack'),
paragraph('Сборщик строит граф на машине разработки или CI: разрешает имена пакетов, применяет конфигурацию и записывает результат в один или несколько файлов. Затем браузер загружает уже эти файлы по URL из HTML или runtime. Поэтому диагностировать нужно на правильной стороне границы. Ошибка вида «модуль не найден при сборке» живёт в решении Webpack. Ошибка native module в Console живёт в URL, MIME, CORS или исходном спецификаторе страницы.'),
paragraph('Полезный практический приём — записать в issue четыре поля: исходная строка import, кто её читает первым, какой URL или asset получился и на каком шаге появился сбой. Этого достаточно, чтобы не смешать причины. Фраза «браузер не нашёл пакет в node_modules» почти всегда указывает, что в разговор незаметно попала логика bundler.'),
figure('/assets/editorial/2019/es-modules-resolution-boundary-2019.svg', 'Схема границы: браузер переводит URL-подобный import в сетевой запрос от URL импортёра, а Webpack заранее разрешает пакет и создаёт asset', 'Одинаковая строка import проходит разные этапы: нативная страница идёт к URL, а сборщик сначала строит свой граф и отдаёт готовые assets.'),
heading('Диагностический маршрут без смешения инструментов'),
orderedList([
'Зафиксировать, запускается ли файл напрямую как type="module" или входит в production-bundle. Не использовать оба сценария одновременно для одной проверки.',
'Для native-страницы выписать полный URL entry и каждого failed import из Network. Считать относительный адрес от модуля-импортёра.',
'Проверить, что спецификатор содержит нужный .js и не является bare-именем пакета, если для него не создан отдельный URL-маршрут.',
'Для Webpack-сценария получить stats или сообщение компилятора на той же версии lock-файла. Найти resolved-путь, alias и asset, не переносить эти правила в browser Console.',
'Если порядок важен, сделать учебный лог из зависимого модуля и entry. Затем убрать побочный эффект из верхнего уровня либо оставить один явный bootstrap.',
'Повторить после одной правки. Готовность: у native-страницы есть корректный URL и ответ, у сборки есть осмысленный resolved-путь и загруженный asset.',
]),
heading('Ограничения и исторический контекст'),
bulletList([
'Текст описывает статические import 2019 года и намеренно не предлагает современные import maps как решение для исторической страницы. В нативном примере путь должен быть виден в исходнике.',
'Webpack — конкретный пример bundler. Другой инструмент может иначе называть chunks и по-другому разрешать aliases, но это всё равно отдельный этап до браузера.',
'Порядок зависимость → потребитель не освобождает от ошибок циклического импорта. Если два файла ждут инициализации друг друга, нужно упростить граф или вынести запуск в явную функцию.',
'CORS, MIME и rewrite сервера способны прервать native-модуль до вычисления. Правильный alias в сборщике их не исправит.',
'Учебные console-сообщения показывают форму наблюдения. Они не являются измерением задержки или browser trace чужого приложения.',
]),
heading('Итог'),
paragraph('Синтаксис import общий, но ответственность разная. Браузер резолвит URL от импортёра и загружает модульный граф. Webpack заранее находит пакеты и выпускает assets. Если сначала назвать сторону границы, ошибка перестаёт быть загадочным «ES-модули не работают»: остаётся конкретная проверка URL, графа, конфигурации или порядка инициализации.'),
],
[ecmascript2019Modules, htmlScriptElement, htmlModuleScripts, urlStandard],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2019-02-field-es-modules',
title: 'ES-модули в браузере: диагностика двойного запуска и неверного пути',
categories: ['JavaScript', 'ES modules', 'Диагностика', 'Браузер'],
cover: '/assets/editorial/2019/es-modules-diagnostic-identity-2019.svg',
excerpt: 'Инициализация срабатывает дважды или импорт уходит не туда. Учебный диагностический модуль показывает фактический URL, различает две module identity и не выдаёт Console за реальную трассу.',
readingMinutes: 12,
},
[
paragraph('Виджет подписывается на событие два раза, либо импорт получает HTML вместо JavaScript по неожиданному адресу. Цена ошибки — дублированные запросы и ложный ремонт: удаляют обработчик, хотя в страницу попали два разных URL модуля.'),
paragraph('Разберём один вопрос: как в браузере отличить повторную инициализацию от двух разных module identity и как связать это с неверным путём. Пример учебный: он добавляет временные отметки в window и Console, но не выдаёт их за Network-трассу реального проекта.'),
heading('Сначала определяем, что именно считается тем же модулем'),
paragraph('Для module script важен разрешённый URL. HTML-среда хранит уже загруженные модули в module map документа; повторный статический импорт одного и того же разрешённого URL обычно использует тот же модульный результат, а не заново вычисляет исходный файл. Поэтому одинаковая строка import "./init.js" в нескольких местах сама по себе ещё не доказывает двойной запуск.'),
paragraph('Но URL с отличающимся путём или query — другой адрес. /assets/init.js и /assets/init.js?entry=admin могут загрузиться как разные ресурсы и выполнить один и тот же код независимо. Так же к двойной инициализации приводят два разных entry, classic bundle рядом с module entry или второй документ в iframe. Диагноз начинается с URL, а не с количества тегов в шаблоне.'),
dataTable(
['Симптом', 'Факт, который нужно получить', 'Что он означает', 'Следующее действие'],
[
['Событие обработано дважды', 'Два лога с разными import.meta.url', 'Страница получила разные module identity', 'Найти, кто добавил query, другой base-путь или второй entry'],
['Два тега указывают на один URL', 'В логах один URL и одна отметка модуля', 'По тегам нельзя объявить модуль выполненным дважды', 'Искать второй bootstrap или другой документ'],
['Импорт вернул HTML', 'Request URL, Response preview и MIME-ошибка', 'Путь попал в правило SPA fallback или не существует', 'Исправить спецификатор либо раздачу assets'],
['Одна страница работает, вложенная — нет', 'Разные base URL у импортёров', 'Относительный путь считается от разных файлов', 'Сделать путь явным в каждом entry и сравнить запросы'],
],
),
paragraph('Здесь важно не обещать абсолютную модель кеша одной строкой. Правила module map учитывают тип модуля и настройки загрузки. Для прикладной проверки достаточно увидеть фактические URL и контекст документа, в котором возникла отметка. Это надёжнее, чем угадывать по имени файла на диске.'),
heading('Учебный модуль с отметкой URL'),
paragraph('Сделаем временный файл init.js. Он фиксирует собственный import.meta.url и число запусков именно для этого URL. В проект этот код не нужно оставлять навсегда: его цель — собрать наблюдения в тестовой странице, затем удалить или заменить осмысленной инициализацией.'),
codeBlock([
'// assets/init.js — учебный диагностический модуль',
'const url = import.meta.url;',
'const registry = window.__moduleRuns || (window.__moduleRuns = {});',
'const count = (registry[url] || 0) + 1;',
'',
'registry[url] = count;',
'console.log("[module-check]", { url, count });',
'',
'export function startWidget(root) {',
' root.dataset.moduleUrl = url;',
' root.textContent = "widget started";',
'}',
'',
'// assets/main.js',
'import { startWidget } from "./init.js";',
'startWidget(document.querySelector("#widget"));',
]),
paragraph('При одном entry в учебной странице мы ожидаем одну запись с URL .../assets/init.js и значение count: 1. Если появилось два URL, Console уже даёт гипотезу: один из импортёров считает путь от другой папки либо к URL добавлена версия. Если один URL отмечен дважды, не надо сразу винить module map: проверяем iframe, повторную загрузку документа, classic-код с тем же побочным эффектом и собственный bootstrap.'),
paragraph('Сам код не имитирует браузерную загрузку и не меняет правила кэширования. Он лишь показывает идентификатор, который среда передала текущему модулю. Поэтому в отчёте рядом с этим логом сохраняем вкладку, точный адрес документа и Request URL из Network. Без этого Console остаётся неполным доказательством.'),
heading('Как неверный путь превращается в похожий симптом'),
paragraph('Относительный import разрешается от URL модуля-импортёра. В следующем учебном каталоге два entry лежат в разных папках. Одинаковая строка ./init.js не означает один и тот же запрос: для каждого файла базовый URL свой.'),
codeBlock([
'',
'',
'',
'',
'// /demo/catalog/main.js',
'import { startWidget } from "./init.js";',
'',
'// /demo/admin/main.js',
'import { startWidget } from "./init.js";',
'',
'// Requests are different:',
'// /demo/catalog/init.js',
'// /demo/admin/init.js',
]),
paragraph('В таком примере две инициализации могут быть ожидаемы, если это два независимых экрана. Они становятся ошибкой, когда оба entry живут в одной странице и пишут в один контейнер. Решение зависит от причины: либо оставить один entry, либо вынести общий код в один путь, либо передать элемент в чистую функцию без верхнеуровневой подписки. Нельзя лечить это удалением query, пока не понятно, служит ли он намеренной версией ресурса.'),
paragraph('Неверный путь иногда маскируется успешным HTTP-статусом. SPA-сервер может ответить 200 и отдать index.html на /demo/admin/init.js. Для module script это всё равно неправильный ресурс: браузер ожидает JavaScript и сообщает о MIME или синтаксисе. Поэтому статус 200 не закрывает проверку; открываем Response и Content-Type.'),
heading('Учебная страница с намеренно разными URL'),
paragraph('Следующая страница нужна только для демонстрации различия URL. Она не должна попадать в production: query в примере показывает отдельную identity, а не рекомендуемый способ запускать виджет дважды. После запуска сравниваем два значения data-module-url у элементов.'),
codeBlock([
'',
'',
'',
'',
]),
paragraph('Так браузер видит два разных адреса и два отдельных экземпляра учебного модуля. В настоящем расследовании такой результат не означает, что query надо запретить. Он означает, что его владелец должен быть назван: cache busting, вариант entry или ошибочно склеенный путь. Затем проверяем, какая из этих причин допустима для текущей страницы.'),
figure('/assets/editorial/2019/es-modules-diagnostic-identity-2019.svg', 'Схема диагностики: одинаковый URL init.js ведёт к одной module identity, а URL с query или другой папкой ведут к разным отметкам import.meta.url', 'Сначала сравниваем фактические URL модулей, затем решаем, является ли второй entry штатным или дублирует инициализацию.'),
heading('Порядок полевой диагностики'),
orderedList([
'Зафиксировать внешний симптом: какой обработчик, запрос или DOM-элемент повторился; сохранить адрес страницы и момент перезагрузки.',
'В тестовой ветке добавить учебную отметку с import.meta.url в модуль с побочным эффектом. Не помещать в неё данные пользователя или production-логи.',
'Открыть Network, перезагрузить страницу и для каждого JS-ответа записать Initiator, Request URL, статус, redirect и Content-Type.',
'Сгруппировать Console-отметки по URL. Разные адреса — повод найти разные importers, query или entry; один адрес — повод проверить второй документ и bootstrap.',
'Для ответа с HTML или MIME-ошибкой проверить относительный путь от файла-импортёра и правило SPA fallback. Исправить либо import, либо серверный маршрут assets.',
'Убрать учебную отметку после решения. Критерий готовности: один владелец инициализации, понятный URL каждого модуля и воспроизводимое поведение после чистой перезагрузки.',
]),
heading('Ограничения и безопасные выводы'),
bulletList([
'Результат зависит от документа. Та же module identity в iframe и в основной странице не делает их одним runtime; контекст окна надо записывать отдельно.',
'Query в URL не является автоматически ошибкой: его может добавлять версия или осознанный вариант entry. Ошибкой является неучтённая вторая инициализация.',
'Идентичный URL обычно переиспользуется через module map, но не следует использовать это как замену явной архитектуре запуска. Побочный эффект верхнего уровня остаётся сложнее проверять.',
'CORS, redirect, service worker и серверный rewrite могут менять наблюдаемую доставку. В каждом случае смотрим фактический Network-ответ, а не только исходный import.',
'Пример создан для учебной страницы и не содержит настоящей browser trace. Его лог нужен для построения гипотезы, а окончательный диагноз закрывают URL, ответ сервера и владелец entry.',
]),
heading('Итог'),
paragraph('Двойной запуск нельзя надёжно доказать количеством строк в шаблоне. Сначала смотрим, какие URL модулей реально получил документ и кто их импортировал. Затем различаем две identity, второй bootstrap и ошибку раздачи пути. Такой порядок превращает «модуль выполнился дважды» в проверяемую цепочку: URL → entry → побочный эффект → решение.'),
],
[ecmascript2019Modules, htmlModuleScripts, htmlScriptElement, fetchCors, urlStandard],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
.map(({ proseLength, ...revision }) => revision);
const isDirectRun = process.argv[1]
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectRun) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2019-02.mjs --print-revisions\n');
process.exitCode = 1;
}
}