Files
progcode/web/scripts/upgrade-2018-10-2019-01.mjs
huncode c72a72b8f3
Build and deploy / deploy (push) Successful in 14s
revise late 2018 editorial articles
2026-07-31 10:09:53 +03:00

552 lines
54 KiB
JavaScript
Raw Permalink 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.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(code) {
return '<pre><code>' + escapeHtml(String(code).trim()) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function bulletList(items) {
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
}
function dataTable(headers, rows) {
const head = headers.map((header) => '<th scope="col">' + header + '</th>').join('');
const body = rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('');
return '<div class="table-scroll"><table><thead><tr>' + head + '</tr></thead><tbody>' + body + '</tbody></table></div>';
}
function sourceList(items) {
return '<ul>' + items.map(({ title, url, note }) => (
'<li><a href="' + url + '" target="_blank" rel="noopener noreferrer">' + title + '</a> — ' + note + '</li>'
)).join('') + '</ul>';
}
function visibleText(html) {
return html
.replace(/<[^>]*>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function createRevision(meta, bodyParts, sources) {
const bodyHtml = bodyParts.join('\n');
const bodyLength = visibleText(bodyHtml).length;
if (bodyLength < 5000 || bodyLength > 15000) {
throw new Error(meta.slug + ': body length must be 5000–15000, got ' + bodyLength);
}
const contentHtml = [
bodyHtml,
heading('Проверяемые источники'),
sourceList(sources),
].join('\n');
const requiredFragments = [
'<figure>',
'<figcaption>',
'<table>',
'<thead>',
'<pre><code>',
'<ol>',
'<h2>Проверяемые источники</h2>',
];
for (const fragment of requiredFragments) {
if (!contentHtml.includes(fragment)) {
throw new Error(meta.slug + ': missing required fragment ' + fragment);
}
}
if ((contentHtml.match(/<h2>/g) || []).length < 6) {
throw new Error(meta.slug + ': fewer than six sections');
}
if (sources.length < 2) {
throw new Error(meta.slug + ': at least two primary sources are required');
}
return { ...meta, contentHtml, bodyLength };
}
const bitrixFileInput = {
title: '1С-Битрикс: FileInput',
url: 'https://dev.1c-bitrix.ru/api_d7/bitrix/main/ui/fileinput/index.php',
note: 'описание контрола, его методов createInstance, prepareFile и show, а также параметров загрузки',
};
const bitrixSaveFile = {
title: '1С-Битрикс: CFile::SaveFile',
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cfile/savefile.php?print=Y',
note: 'метод сохраняет файловый массив и регистрирует его в b_file, возвращая числовой идентификатор',
};
const bitrixMakeFileArray = {
title: '1С-Битрикс: CFile::MakeFileArray',
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cfile/makefilearray.php?print=Y',
note: 'формирует файловый массив, в том числе по ID существующего файла',
};
const bitrixUpdate = {
title: '1С-Битрикс: CIBlockElement::Update',
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/update.php?print=Y',
note: 'Update принимает массив полей, возвращает true или false, а текст ошибки остаётся в LAST_ERROR',
};
const phpFiles = {
title: 'PHP Manual: $_FILES',
url: 'https://www.php.net/manual/en/reserved.variables.files.php',
note: 'структура данных, переданных HTTP POST с файлом',
};
const phpUploadedFile = {
title: 'PHP Manual: move_uploaded_file',
url: 'https://www.php.net/manual/en/function.move-uploaded-file.php',
note: 'функция работает только с файлом, который PHP признал HTTP POST upload; источник нужен для границы временного файла',
};
const jqueryOn = {
title: 'jQuery API: .on()',
url: 'https://api.jquery.com/on/',
note: 'делегированный обработчик работает на потомках существующего контейнера и может быть привязан с namespace',
};
const jqueryReady = {
title: 'jQuery API: .ready()',
url: 'https://api.jquery.com/ready/',
note: 'обработчик запускается, когда DOM готов к безопасному изменению',
};
const webpackShimming = {
title: 'webpack: Shimming',
url: 'https://webpack.js.org/guides/shimming/',
note: 'Webpack понимает модули, но старые библиотеки могут ожидать глобальные зависимости; globals следует оставлять только для нужной совместимости',
};
const webpackProvide = {
title: 'webpack: ProvidePlugin',
url: 'https://webpack.js.org/plugins/provide-plugin/',
note: 'ProvidePlugin подставляет модуль для свободного идентификатора в скомпилированном модуле; отдельно показано сопоставление window.jQuery',
};
const webpackV4Migration = {
title: 'webpack: migration to v4',
url: 'https://webpack.js.org/migrate/4/',
note: 'в webpack 4 CommonsChunkPlugin заменён настройкой optimization.splitChunks',
};
const webpackSplitChunks = {
title: 'webpack: SplitChunksPlugin',
url: 'https://webpack.js.org/plugins/split-chunks-plugin/',
note: 'в webpack 4 общие модули извлекаются правилами splitChunks, что меняет состав начальных ассетов',
};
const imageOwnershipArticle = createRevision(
{
slug: 'editorial-2018-10-mechanism-image-workflow',
title: 'Bitrix API. Форма с изображением: где заканчивается редактор и начинается файл',
categories: ['Bitrix', 'PHP', 'Формы'],
cover: '/assets/editorial/2018/bitrix-file-state-ownership-2018.svg',
excerpt: 'Картинка появляется в форме, но после сохранения карточка остаётся прежней. Разбираем четыре состояния файла и ставим проверку на границе между контролом, b_file и элементом инфоблока.',
readingMinutes: 10,
},
[
paragraph('В форме картинка уже видна, а после сохранения у товара остаётся старая обложка. Цена ошибки не только в пустом поле: оператор уверен, что обновил карточку, а каталог продолжает показывать не тот товар.'),
paragraph('В октябрьском проекте я бы не начинал с повторного вызова редактора. Сначала разделил бы четыре состояния: выбор файла в браузере, данные формы, запись файла в Bitrix и ссылка на неё в элементе инфоблока. Пока они названы одним словом «картинка», причина прячется между двумя успешными шагами.'),
heading('Один экран формы не означает один объект'),
paragraph('Контрол <code>\\Bitrix\\Main\\UI\\FileInput</code> формирует интерфейс выбора и загрузки. Его <code>show()</code> возвращает разметку и JavaScript для страницы, но сам показ контрола не доказывает, что файл уже связан с нужным элементом. Официальная документация отдельно называет <code>prepareFile()</code> как способ получить файловый массив для дальнейшей обработки. Значит, после интерфейса всё равно остаётся серверный путь.'),
paragraph('Для диагностики я записываю не красивый preview, а идентификаторы и границы. У выбранного в браузере файла нет постоянного Bitrix ID. У строки из <code>b_file</code> уже есть ID, но она может быть ни с чем не связана. У поля <code>PREVIEW_PICTURE</code> элемента есть отдельное состояние, и оно изменится только после успешного <code>CIBlockElement::Update()</code>.'),
dataTable(
['Состояние', 'Кто им владеет', 'Что считаю доказательством', 'Следующий шаг'],
[
['Файл выбран в диалоге', 'браузер и DOM формы', 'видно имя, размер или preview до отправки', 'не считать это сохранением'],
['Файловый массив в запросе', 'PHP-обработчик', '<code>$_FILES</code> содержит ожидаемое поле и <code>UPLOAD_ERR_OK</code>', 'проверить лимит и передать в Bitrix'],
['Файл зарегистрирован', 'таблица <code>b_file</code>', '<code>CFile::SaveFile()</code> вернул положительный ID', 'получить файл по ID и сохранить связь'],
['Изображение карточки изменено', 'элемент инфоблока', '<code>CIBlockElement::Update()</code> вернул <code>true</code>', 'запросить элемент заново и открыть карточку'],
],
),
figure(
'/assets/editorial/2018/bitrix-file-state-ownership-2018.svg',
'Карта состояний изображения: браузерный File, поле формы, зарегистрированный CFile ID и PREVIEW_PICTURE элемента инфоблока. Между этапами показаны POST, CFile SaveFile и CIBlockElement Update.',
'Preview нужен пользователю, но подтверждённой считается только связь между ID файла и полем элемента.',
),
heading('Контрол показываю с явными ограничениями'),
paragraph('Ниже не универсальный шаблон, а минимальная точка проверки для формы с одной картинкой. Идентификатор текущего файла приходит из уже сохранённой карточки. Поле формы получает осмысленное имя, а ограничения задаются рядом с контролом. Если на старой установке отсутствует этот класс или часть источников FileInput отключена, сначала проверяю версию модуля main и права пользователя, а не копирую настройки вслепую.'),
codeBlock([
'<?php',
'use Bitrix\\\\Main\\\\UI\\\\FileInput;',
'',
'$currentFileId = (int) $arResult[\'PREVIEW_PICTURE\'];',
'',
'echo FileInput::createInstance(array(',
' \'id\' => \'catalog_preview\',',
' \'name\' => \'CATALOG[PREVIEW_PICTURE]\',',
' \'upload\' => true,',
' \'allowUpload\' => FileInput::UPLOAD_IMAGES,',
' \'medialib\' => false,',
' \'fileDialog\' => true,',
' \'cloud\' => false,',
' \'delete\' => true,',
' \'edit\' => true,',
' \'maxCount\' => 1,',
' \'maxSize\' => 5 * 1024 * 1024,',
'))->show($currentFileId);',
].join('\n')),
paragraph('Значение <code>maxSize</code> помогает пользователю раньше увидеть предел, но не заменяет серверную проверку. DOM может быть создан старым шаблоном, обновлён Ajax-ом или отправлен вручную. Поэтому имя поля и фактический массив, пришедший на сервер, я сверяю в тестовом запросе. В этом месте удобнее увидеть несовпадение <code>CATALOG[PREVIEW_PICTURE]</code> и обработчика, чем позже искать «потерянный» файл.'),
heading('Сохраняю файл до привязки и проверяю оба ответа'),
paragraph('В учебном обработчике ниже файл приходит из обычного multipart-поля. В проекте с FileInput вместо <code>$_FILES</code> может оказаться результат его подготовки, но контракт одинаковый: на входе — файловый массив, на выходе — подтверждённый ID или понятная ошибка. Я не сохраняю путь из браузера и не записываю имя файла как связь с карточкой.'),
codeBlock([
'function saveCatalogImage(array $upload)',
'{',
' if (($upload[\'error\'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {',
' throw new RuntimeException(\'Image upload did not finish\');',
' }',
'',
' if ((int) ($upload[\'size\'] ?? 0) < 1 || (int) $upload[\'size\'] > 5 * 1024 * 1024) {',
' throw new RuntimeException(\'Image size is outside the form limit\');',
' }',
'',
' $upload[\'MODULE_ID\'] = \'catalog\';',
' $fileId = (int) CFile::SaveFile($upload, \'catalog\');',
'',
' if ($fileId < 1 || !CFile::GetFileArray($fileId)) {',
' throw new RuntimeException(\'Bitrix did not register the uploaded file\');',
' }',
'',
' return $fileId;',
'}',
'',
'$fileId = saveCatalogImage($_FILES[\'CATALOG_PREVIEW\']);',
].join('\n')),
paragraph('Метод <code>CFile::SaveFile()</code> регистрирует файл в <code>b_file</code> и возвращает числовой ID. Проверка через <code>CFile::GetFileArray()</code> здесь не украшение: она отделяет случай «обработчик получил форму» от случая «у нас есть объект, на который можно ссылаться». На старом проекте я также сохраняю в закрытый лог ID элемента, ID файла и текст <code>LAST_ERROR</code>, но не складываю в журнал сам файл или персональные поля формы.'),
codeBlock([
'$element = new CIBlockElement();',
'$picture = CFile::MakeFileArray($fileId);',
'',
'$updated = $element->Update($elementId, array(',
' \'PREVIEW_PICTURE\' => $picture,',
'));',
'',
'if (!$updated) {',
' throw new RuntimeException($element->LAST_ERROR);',
'}',
].join('\n')),
paragraph('Для обновления изображения инфоблока нужен файловый массив. <code>CFile::MakeFileArray()</code> умеет собрать его по существующему ID, а <code>CIBlockElement::Update()</code> возвращает <code>false</code> и оставляет текст ошибки в <code>LAST_ERROR</code>. Это две разные проверки; положительный <code>$fileId</code> не делает обновление элемента успешным сам по себе.'),
heading('Проверяю путь в том порядке, в котором он ломается'),
orderedList([
'Открываю карточку с известным текущим ID картинки и отмечаю его до изменения.',
'Выбираю небольшой тестовый JPEG и в браузерной сетевой вкладке сверяю имя поля и ответ отправки формы.',
'На сервере временно фиксирую только код upload-ошибки, ID элемента и ID созданного файла.',
'После <code>Update()</code> повторно читаю <code>PREVIEW_PICTURE</code> у этого же элемента, а не доверяю старому <code>$arResult</code>.',
'Открываю карточку новым запросом в браузере и проверяю, что URL изображения указывает на ожидаемый файл.',
'Только после этого удаляю временную диагностику или оставляю безопасный лог ошибки для следующего случая.',
]),
heading('Не путаю замену с удалением'),
paragraph('Самый неприятный крайний случай — форма отправлена без нового файла. Для одной карточки это может означать «сохранить старую картинку», а флаг удаления означает противоположное. Нельзя получать это решение из пустого preview в DOM: пустой preview может появиться из-за перерисовки формы. Политику формулирую явно: нет нового файла и нет флага удаления — поле остаётся как было; новый файл — заменяем после успешного сохранения; удаление — передаём в Bitrix отдельным согласованным полем.'),
paragraph('Если загрузка и редактирование сделаны в два HTTP-запроса, появляется ещё одна граница. Пользователь может закрыть вкладку после первого запроса. Тогда созданный ID не должен автоматически становиться картинкой чужого элемента. В старой системе достаточно хранить ID в сессии или в черновике, сверять владельца при финальном сохранении и отдельно убирать неиспользованные файлы по согласованному регламенту. Это не повод усложнять маленькую форму очередями; это повод не считать временный файл завершённым результатом.'),
heading('Ограничения примера'),
paragraph('Здесь не задан общий список допустимых MIME-типов и размеров картинки: он зависит от каталога, старой версии Bitrix и требований редакторов. Ограничение в контроле не защищает сервер, поэтому реальные проверки типа, размера, прав и пределов PHP надо добавлять в обработчик. Также не стоит переносить пример в свойство типа файл без проверки формата <code>PROPERTY_VALUES</code>: документация <code>CIBlockElement::Update()</code> отдельно оговаривает работу с файловыми свойствами.'),
heading('Итог'),
paragraph('Редактор отвечает за выбор и preview, <code>CFile</code> — за зарегистрированный файл, а элемент инфоблока — за ссылку на него. Если проверить каждую передачу отдельно, «картинка была в форме» перестаёт быть ложным признаком готовности. В следующей правке достаточно повторить один тестовый POST и сравнить ID файла с <code>PREVIEW_PICTURE</code> после свежего чтения элемента.'),
],
[bitrixFileInput, bitrixSaveFile, bitrixMakeFileArray, bitrixUpdate, phpFiles, phpUploadedFile],
);
const imageFormArticle = createRevision(
{
slug: 'editorial-2018-10-field-image-workflow',
title: 'Bitrix API. Legacy-форма с редактором изображения: разбор типичной ошибки',
categories: ['Bitrix', 'PHP', 'jQuery', 'Формы'],
cover: '/assets/editorial/2018/legacy-photo-form-contract-2018.svg',
excerpt: 'Разбор формы, где preview обновляется, а в карточку попадает старый файл. Фиксируем контракт между jQuery, FileInput и серверным сохранением, чтобы не путать DOM с подтверждённым ID.',
readingMinutes: 11,
},
[
paragraph('Редактор изображения показывает новую фотографию, но после отправки формы карточка снова открывается со старой. Цена ошибки заметна не сразу: менеджер повторяет загрузку, а в файловом хранилище появляются лишние записи без понятной связи с товаром.'),
paragraph('В такой legacy-форме я не пытаюсь заставить jQuery «запомнить картинку». Нужно решить, что именно передаёт форма: новый бинарный файл, ID уже существующего файла или команду удалить старый. Preview — только экранный сигнал. Сервер должен получить одно из этих состояний и вернуть результат, который форма может показать без догадок.'),
heading('Фиксирую контракт формы до правки плагина'),
paragraph('Перед работой я выписываю имена полей и один реальный POST. Старые шаблоны часто держат одновременно обычный <code>input type=file</code>, скрытый <code>PHOTO_ID</code> и HTML редактора. Если после Ajax-перерисовки в документе остаются два поля с одинаковым <code>name</code>, браузер отправит оба, а обработчик выберет не то значение. Поэтому в контракте у каждого поля одна роль.'),
dataTable(
['Поле или сигнал', 'Значение', 'Владелец', 'Что означает на сервере'],
[
['<code>CATALOG_PREVIEW</code>', 'бинарный файл из multipart POST', 'браузер до отправки', 'кандидат на новую картинку'],
['<code>KEEP_PICTURE</code>', '0 или 1', 'форма и сценарий редактирования', 'сохраняем существующую картинку, если нового файла нет'],
['<code>DELETE_PICTURE</code>', '0 или 1', 'явное действие пользователя', 'запрашиваем удаление, не выводим его из пустого DOM'],
['<code>.js-photo-state</code>', 'текст статуса', 'jQuery', 'не попадает в модель данных и не является ID файла'],
],
),
figure(
'/assets/editorial/2018/legacy-photo-form-contract-2018.svg',
'Контракт legacy-формы: выбранный файл живёт в multipart POST, preview и статус принадлежат DOM, а сервер возвращает зарегистрированный ID и результат обновления карточки.',
'У preview нет права менять карточку; его задача — показать пользователю, какой файл выбран до подтверждения сервера.',
),
heading('Не прячу имена полей внутри редактора'),
paragraph('Сначала оставляю контрол и обработчик читаемыми. В примере ниже FileInput рисует интерфейс для одного изображения, а вокруг него есть контейнер для статуса. Если конкретная версия Bitrix возвращает файл через свой формат, я проверяю его отдельным тестовым POST и передаю дальше как файловый массив. Не подменяю это знание строкой из скрытого поля.'),
codeBlock([
'<?php',
'use Bitrix\\\\Main\\\\UI\\\\FileInput;',
'',
'$currentId = (int) $arResult[\'PREVIEW_PICTURE\'];',
'?>',
'<form id="catalog-photo-form" method="post" enctype="multipart/form-data">',
' <div class="js-photo-editor">',
' <?php',
' echo FileInput::createInstance(array(',
' \'id\' => \'catalog_preview\',',
' \'name\' => \'CATALOG_PREVIEW\',',
' \'upload\' => true,',
' \'allowUpload\' => FileInput::UPLOAD_IMAGES,',
' \'maxCount\' => 1,',
' \'maxSize\' => 5 * 1024 * 1024,',
' \'delete\' => true,',
' ))->show($currentId);',
' ?>',
' </div>',
' <label><input type="checkbox" name="KEEP_PICTURE" value="1" checked> оставить текущую картинку</label>',
' <label><input type="checkbox" name="DELETE_PICTURE" value="1"> удалить картинку</label>',
' <p class="js-photo-state" aria-live="polite"></p>',
' <button type="submit">Сохранить</button>',
'</form>',
].join('\n')),
paragraph('Контрол FileInput доступен в D7-ядре и умеет формировать HTML и JavaScript. Но значение <code>name</code> — часть договора с PHP, а не косметика. После первой подстановки шаблона я смотрю исходный HTML и реальный Request Payload: имя, число file-полей, код ответа и наличие <code>enctype=multipart/form-data</code>. Если форма отправляется Ajax-ом, проверяю, что код строит <code>FormData</code>, а не сериализует только текстовые inputs.'),
heading('jQuery показывает состояние, но не делает файл сохранённым'),
paragraph('Устаревший шаблон может переотрисовать блок формы через <code>.html()</code>. Прямой обработчик на старом input после этого исчезнет, а повторная инициализация способна добавить второй обработчик. Я привязываю событие к стабильному контейнеру и использую namespace: перед повторной инициализацией снимаю именно свой обработчик. В браузере это даёт один статус выбора и не меняет серверную модель.'),
codeBlock([
'(function ($) {',
' function bindPhotoForm(root) {',
' var $root = $(root);',
'',
' $root.off(\'change.photoWorkflow\', \'input[type=file][name=CATALOG_PREVIEW]\')',
' .on(\'change.photoWorkflow\', \'input[type=file][name=CATALOG_PREVIEW]\', function () {',
' var file = this.files && this.files[0];',
' var message = file',
' ? \'Выбран файл: \' + file.name + \'. Сохранение ещё не выполнено.\'',
' : \'Новый файл не выбран.\';',
'',
' $root.find(\'.js-photo-state\').text(message);',
' $root.find(\'input[name=KEEP_PICTURE]\').prop(\'checked\', !file);',
' $root.find(\'input[name=DELETE_PICTURE]\').prop(\'checked\', false);',
' });',
' }',
'',
' $(function () {',
' bindPhotoForm(document);',
' });',
'}(jQuery));',
].join('\n')),
paragraph('Метод <code>.on()</code> с селектором делегирует событие от потомка к уже существующему контейнеру. Это подходит для полей, которые появятся после перерисовки. Обработчик выше намеренно не кладёт имя или data URL в <code>PHOTO_ID</code>: такой ID существует только после серверного шага. Если нужны размеры картинки для preview, их можно показать рядом, но финальную проверку и привязку оставляю обработчику.'),
heading('На сервере выбираю ровно один путь'),
paragraph('Для формы редактирования полезно свести три пользовательских действия к трём веткам. Новый файл важнее флага «оставить»; явное удаление нельзя смешивать с новым файлом в одном запросе. При конфликте возвращаю ошибку формы, а не выбираю вариант по порядку полей. Это проще объяснить оператору и проще проверить через один POST.'),
codeBlock([
'function updatePreviewPicture($elementId, array $post, array $files)',
'{',
' $hasNewFile = isset($files[\'CATALOG_PREVIEW\'])',
' && ($files[\'CATALOG_PREVIEW\'][\'error\'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK;',
' $deleteRequested = ($post[\'DELETE_PICTURE\'] ?? \'\') === \'1\';',
'',
' if ($hasNewFile && $deleteRequested) {',
' throw new RuntimeException(\'Choose a new picture or deletion, not both\');',
' }',
'',
' if (!$hasNewFile && !$deleteRequested) {',
' return; // карточка сохраняет текущую картинку',
' }',
'',
' $fields = array();',
' if ($hasNewFile) {',
' $fields[\'PREVIEW_PICTURE\'] = $files[\'CATALOG_PREVIEW\'];',
' } else {',
' $fields[\'PREVIEW_PICTURE\'] = array(\'del\' => \'Y\');',
' }',
'',
' $element = new CIBlockElement();',
' if (!$element->Update((int) $elementId, $fields)) {',
' throw new RuntimeException($element->LAST_ERROR);',
' }',
'}',
].join('\n')),
paragraph('Это минимальный сценарий именно для поля изображения элемента. Если приложение сначала создаёт отдельную запись в <code>b_file</code>, я храню возвращённый ID в серверной сессии или черновике и при финальном сохранении снова проверяю права на элемент. Для повторного использования существующего файла документация Bitrix предлагает <code>CFile::MakeFileArray()</code>, принимающий и ID файла. Важно не смешивать эту ветку с «пользователь выбрал файл, но ещё не отправил форму».'),
heading('Проверяю не только счастливый путь'),
orderedList([
'Открываю существующую карточку, фиксирую текущий ID изображения и отправляю форму без нового файла: ID должен остаться прежним.',
'Выбираю небольшой тестовый файл, проверяю один POST с multipart-частью и убеждаюсь, что серверный ответ содержит успешный результат обновления.',
'Перезагружаю страницу отдельным запросом; preview и URL картинки должны соответствовать новому значению элемента.',
'Нажимаю «удалить» без нового файла и проверяю, что обработчик получает явный флаг, а не выводит удаление из пустого preview.',
'Имитирую Ajax-перерисовку контейнера и ещё раз меняю файл: статус должен смениться один раз, без двойного обработчика.',
'Отправляю новый файл вместе с удалением и ожидаю понятную ошибку формы, а не неявный выбор одной ветки.',
]),
heading('Где legacy-интеграция чаще всего обманывает'),
paragraph('Первый обман — вызывать <code>serialize()</code> для формы с файлом. Метод собирает текстовые поля и не переносит бинарное содержимое; для Ajax нужен <code>FormData</code> и правильные параметры запроса. Второй — считать, что серверный <code>200</code> подтвердил картинку: обработчик мог вернуть HTML ошибки или не обновить элемент. Третий — брать «последний созданный файл» из базы. В форме одновременно работают люди, поэтому связь должна идти из конкретного запроса и конкретного элемента.'),
paragraph('Ещё один риск связан с повторной инициализацией. Namespace в <code>change.photoWorkflow</code> даёт узкую очистку только нашего события; нельзя заменять его общим <code>off(\'change\')</code>, потому что так легко сломать чужой плагин на той же форме. В 2018 году это особенно важно для старых шаблонов, где порядок подключения JavaScript не документирован.'),
heading('Ограничения и следующий шаг'),
paragraph('Пример не определяет политику доступа, допустимые расширения и обработку больших изображений. Их надо связать с ролью пользователя, настройками PHP и правилами каталога на конкретной установке. Если FileInput уже загружает файл отдельным действием, не копируйте ветку с <code>$_FILES</code>: сначала посмотрите, какое значение возвращает контрол и кто владеет временным ID.'),
paragraph('Критерий готовности простой: после новой загрузки форма делает один понятный запрос, <code>Update()</code> проходит, а свежая страница показывает новое изображение. После сохранения без файла старая картинка остаётся. Этого достаточно, чтобы следующая правка формы не превратила DOM-preview в ложное подтверждение данных.'),
],
[bitrixFileInput, bitrixUpdate, bitrixMakeFileArray, jqueryOn, jqueryReady, phpFiles],
);
const jqueryWebpackArticle = createRevision(
{
slug: 'editorial-2019-01-mechanism-jquery-webpack',
title: 'jQuery в Webpack. Почему legacy-плагин ломается только в production',
categories: ['JavaScript', 'Webpack', 'jQuery'],
cover: '/assets/editorial/2019/webpack-jquery-order-2019.svg',
excerpt: 'В development маска работает, а после production-сборки читает undefined вместо window.jQuery. Разбираем порядок выполнения модуля, назначение ProvidePlugin и проверку графа ассетов.',
readingMinutes: 11,
},
[
paragraph('В development старая маска телефона работает, а после production-сборки браузер сообщает, что <code>window.jQuery</code> не определён. Цена ошибки — релиз с формой, которую нельзя заполнить, хотя локальный сервер и привычный исходный код выглядели исправными.'),
paragraph('В январе 2019 года я бы не лечил этот сбой таймаутом. У legacy-плагина есть жёсткое ожидание: в момент его выполнения должна существовать конкретная глобальная переменная. У Webpack другая модель: он собирает модули и может вынести общую зависимость в отдельный chunk. Нужно проверить, кто создаёт <code>window.jQuery</code>, когда это происходит и какой production-asset выполняет плагин.'),
heading('Почему development даёт ложную уверенность'),
paragraph('На локальном сервере плагин нередко случайно получает jQuery из старого тега <code>script</code>, из общего layout или из более простого bundle. Production меняет условия: включается минификация, меняется имя файлов, а Webpack 4 может выделить общие зависимости через <code>optimization.splitChunks</code>. Само выделение не является ошибкой. Ошибка появляется, когда HTML подключает ассеты не тем способом или bootstrap-код запускает плагин до назначения глобала.'),
paragraph('Поэтому фиксирую минимальный пример из трёх частей: entry, который создаёт глобал; legacy-плагин, который читает его при загрузке; и production HTML с фактическими тегами script. Пока в диагностике есть только исходники, а собранного <code>dist</code> нет, порядок исполнения остаётся предположением.'),
dataTable(
['Наблюдение', 'Вероятная причина', 'Короткая проверка', 'Действие'],
[
['В dev всё работает, в production — <code>undefined</code>', 'layout подключает локальный jQuery раньше bundle', 'открыть Network и сравнить production script tags', 'убрать случайный CDN-скрипт или сделать порядок явным'],
['В модуле доступен <code>$</code>, но плагин читает <code>window.jQuery</code>', 'ProvidePlugin обслуживает свободный идентификатор, а не скрытую договорённость плагина', 'поставить остановку перед загрузкой плагина и проверить <code>window.jQuery</code>', 'назначить глобал до runtime require плагина'],
['После splitChunks два разных jQuery', 'одна копия пришла из layout, другая — из bundle', 'проверить modules со словом <code>jquery</code> в stats.json', 'оставить один владелец и проверить граф production-сборки'],
['Форма ломается после кеша', 'HTML ссылается на старый набор hashed-ассетов', 'сравнить HTML и имена файлов текущего dist', 'публиковать HTML и ассеты как один артефакт'],
],
),
figure(
'/assets/editorial/2019/webpack-jquery-order-2019.svg',
'Временная шкала production-загрузки: runtime, vendors chunk с jQuery, bootstrap назначает window.jQuery, затем require запускает legacy-плагин и форму. Красным отмечен плохой вариант со статическим импортом плагина до тела bootstrap.',
'Для старого плагина важен не только факт установки jQuery, но момент, в который он читает window.jQuery.',
),
heading('Разделяю свободный идентификатор и глобальный объект'),
paragraph('Webpack описывает <code>ProvidePlugin</code> как способ автоматически подставить модуль, когда компилятор встречает свободный идентификатор в скомпилированном модуле. Конфигурация с <code>$</code> и <code>jQuery</code> помогает нашему исходному коду, где они используются без import. Но старый плагин может вообще не иметь свободного идентификатора: он может обратиться именно к <code>window.jQuery</code> во время выполнения своего файла. Это другой контракт, его нельзя проверить поиском по собственным модулям.'),
paragraph('Документация Webpack отдельно показывает сопоставление <code>window.jQuery</code> для библиотек с такой зависимостью. На старом проекте я всё равно предпочитаю маленький bootstrap-модуль с явным присваиванием: в нём видно владельца глобала и точку, после которой разрешено запускать plugin. Это не идеальная модульная архитектура, а ограниченный шов для уже существующего кода.'),
codeBlock([
'const webpack = require(\'webpack\');',
'',
'module.exports = {',
' mode: \'production\',',
' entry: {',
' site: \'./src/bootstrap-legacy.js\',',
' },',
' plugins: [',
' new webpack.ProvidePlugin({',
' $: \'jquery\',',
' jQuery: \'jquery\',',
' }),',
' ],',
' optimization: {',
' splitChunks: { chunks: \'all\' },',
' },',
'};',
].join('\n')),
paragraph('Эта настройка не даёт права подключить plugin в любом месте и ожидать правильный момент. Она лишь делает <code>$</code> и <code>jQuery</code> доступными там, где Webpack анализирует их как свободные имена. Глобальный объект, порядок загрузки документа и исполнение файлов остаются отдельной частью расследования.'),
heading('Не ставлю статический import перед созданием глобала'),
paragraph('В этом месте легко написать читающийся, но неверный код. Статические <code>import</code> описывают зависимости модуля до того, как выполнится его тело. Если legacy-плагин читает <code>window.jQuery</code> сразу при инициализации, он может сработать раньше строки с присваиванием. В примере ниже сначала настраивается глобал, затем плагин подключается через runtime <code>require()</code>.'),
codeBlock([
'import $ from \'jquery\';',
'',
'function exposeLegacyJQuery(jq) {',
' if (window.jQuery && window.jQuery !== jq) {',
' throw new Error(\'Two jQuery instances reached the page\');',
' }',
'',
' window.$ = jq;',
' window.jQuery = jq;',
'}',
'',
'exposeLegacyJQuery($);',
'require(\'inputmask/dist/jquery.inputmask\');',
'require(\'./legacy-form\');',
].join('\n')),
paragraph('Здесь <code>require()</code> выбран не потому, что он современнее import, а потому, что его вызов стоит после <code>exposeLegacyJQuery()</code> в runtime-порядке текущего модуля. Если плагин не читает глобал при загрузке, такой шов может быть не нужен. Но в конкретном production-сбое сначала проверяю это предположение на минимальном плагине и только затем меняю конфигурацию всего проекта.'),
heading('Собираю production-доказательство, а не только скрин консоли'),
paragraph('После исправления я запускаю production-сборку с JSON-статистикой. В stats-файле ищу модули jQuery и связь с entry, а в браузере смотрю Network и порядок инициаторов. Цель не в том, чтобы вручную угадать хеш vendor-файла: порядок должен обеспечивать runtime Webpack или генератор HTML, а не строка с именем вчерашнего chunk.'),
codeBlock([
'{',
' "scripts": {',
' "build:profile": "webpack --mode production --profile --json > dist/stats.json",',
' "serve:dist": "npx http-server dist -c-1"',
' }',
'}',
'',
'npm run build:profile',
'npm run serve:dist',
].join('\n')),
paragraph('На тестовой странице перед отправкой формы достаточно временно проверить два факта: <code>window.jQuery === $</code> в bootstrap-коде и наличие метода, который добавляет плагин. Затем эту диагностику убираю или оставляю за отдельным development-флагом. Production-страница должна открываться со свежим HTML, потому что новый bundle с прошлым списком тегов script проверяет не нашу конфигурацию, а несовместимый набор артефактов.'),
heading('Порядок проверки после сборки'),
orderedList([
'Собираю чистый production <code>dist</code> и сохраняю имя entry, runtime и общих chunks из фактического вывода.',
'Открываю страницу без локального CDN-скрипта jQuery и проверяю Network: все стартовые assets пришли без 404 и с одной версией артефакта.',
'Ставлю временную проверку в <code>bootstrap-legacy.js</code>: до runtime require <code>window.jQuery === $</code> должно быть <code>true</code>.',
'После require проверяю ровно один ожидаемый метод legacy-плагина, затем инициализирую форму через <code>$(function () { ... })</code>.',
'Открываю stats.json и ищу modules, содержащие <code>jquery</code>; повторная копия требует объяснения, а не только сравнения общего размера bundle.',
'Повторяю сценарий после очистки кеша и после прямого открытия страницы, чтобы не перепутать рабочий старый asset с новым релизом.',
]),
heading('Инициализирую форму после DOM, а не до него'),
paragraph('Даже правильный глобал не создаёт input в DOM. jQuery <code>.ready()</code> выполняет обработчик, когда документ готов к безопасному изменению, поэтому конечная инициализация формы должна жить после bootstrap-пути. Это отдельная проверка от загрузки plugins: если <code>window.jQuery</code> есть, а селектор не находит поля, причина лежит уже в разметке или в моменте появления формы.'),
codeBlock([
'require(\'./bootstrap-legacy\');',
'',
'jQuery(function ($) {',
' var $phone = $(\'#order-phone\');',
'',
' if ($phone.length !== 1 || typeof $phone.inputmask !== \'function\') {',
' throw new Error(\'Legacy mask is not ready for #order-phone\');',
' }',
'',
' $phone.inputmask(\'+7 (999) 999-99-99\');',
'});',
].join('\n')),
paragraph('Синтаксис с аргументом <code>$</code> внутри <code>jQuery(function ($) { ... })</code> снимает зависимость этой конкретной функции от внешнего alias. Но он не отменяет bootstrap: плагин всё ещё должен быть загружен и зарегистрирован до вызова <code>inputmask()</code>. Так граница проблемы остаётся видимой: глобал для старого плагина, локальный alias для кода формы, реальный DOM для финальной инициализации.'),
heading('Ограничения решения'),
paragraph('Глобальный jQuery — технический долг, а не совет для нового модуля. Новые компоненты лучше импортировать явно и не делать <code>window</code> общим API. Также не следует выключать <code>splitChunks</code> только ради одного плагина: сначала нужно показать, что именно нарушено — порядок тегов, два экземпляра библиотеки или статический import.'),
paragraph('Число chunks и их хеши зависят от версии Webpack, loaders и графа зависимостей. Поэтому статья не обещает фиксированное имя <code>vendors~site.js</code>. Её критерий другой: production HTML ссылается на выпуск одной сборки, глобал назначен до legacy-плагина, а проверка формы пройдена без случайного script из layout.'),
heading('Итог'),
paragraph('Production-сбой с jQuery в Webpack обычно не лечится ещё одним alias. Нужно назвать, какой код читает <code>window.jQuery</code>, создать его в bootstrap до runtime require и проверить фактический граф assets. После этого legacy-граница остаётся маленькой, а следующая миграция может заменить её модулем без скрытой глобальной зависимости.'),
],
[webpackShimming, webpackProvide, webpackV4Migration, webpackSplitChunks, jqueryReady, jqueryOn],
);
export const revisions = [imageOwnershipArticle, imageFormArticle, jqueryWebpackArticle]
.map(({ bodyLength, ...revision }) => revision);
const isDirectInvocation = process.argv[1]
&& path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectInvocation) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2018-10-2019-01.mjs --print-revisions\n');
process.exitCode = 1;
}
}