This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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(' ', ' ')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
.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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user