React-формы, основанные на схемах: отрисовка и проверка данных по JSON Schema
Как отрисовывать проверенные формы React непосредственно из JSON Schema, обрабатывать элементы $ref, oneOf и ветви if/then, подключать пользовательские виджеты и избегать распространённых ловушек проверки.
Ручно написанный формуляр на React обычно является копией уже существующего контракта. Схема запросов API предусматривает, что поле email обязательно и должно соответствовать формату электронной почты, что age — это ненегативное целое число, а role может принимать только три значения. Повторное указание этих правил в JSX, затем в библиотеке валидации и снова в сообщениях об ошибках создает три независимых источника информации для одной и той же структуры данных, которые начинают расходиться при малейших изменениях на стороне бэкенда.
В этом руководстве формуляр рассматривается как производный от JSON Schema, причем в качестве конкретной реализации используется open-source-пакет react-simple-schema-form. Вы увидите, как элементы $ref, allOf, oneOf и конструкции if/then превращаются в динамические поля, как подключать пользовательские компоненты и какие правила валидации делают генерированный формуляр похожим на ручно созданный.
Почему схема должна управлять формой
Изменения в структуре предсказуемы: новые поля на серверной части никогда не попадают в форму, а запросы вроде «показать только адрес для оплаты счета» превращаются в флаги типа useState, условную отрисовку и блоки проверки, которые со временем теряют синхронизацию.
JSON Schema уже позволяет описать все эти правила: типы данных, ограничения, обязательные поля и условную логику. Часто это тот же документ, с помощью которого серверная часть проверяет запросы и который встраивается в спецификации OpenAPI. Если форма генерируется на основе этой схемы, изменение схемы автоматически обновляет интерфейс и механизмы его проверки.
Минимальная генерируемая форма
react-simple-schema-form принимает JSON Schema, составленное согласно стандарту draft-07, и отображает проверенную форму. Согласно его документации, у него нет зависимостей во время выполнения, кроме React 18; он поставляется с собственными типами TypeScript и предоставляет необязательный стильовой файл. Установка осуществляется с помощью одного пакета:
npm install react-simple-schema-form
В приведённом ниже примере показан небольшой объект пользователя: имя, адрес электронной почты с параметром format: 'email', целое число возраста, не меньшее нуля, и роль, ограниченная с помощью enum. Поля name и email отмечены как обязательные. Компонент получает схему и функцию-обратный вызов onSubmit, и ничего больше.
import { SchemaForm } from 'react-simple-schema-form';
import 'react-simple-schema-form/styles.css';
const schema = {
type: 'object',
properties: {
name: { type: 'string', title: 'Name' },
email: { type: 'string', format: 'email', title: 'Email' },
age: { type: 'integer', minimum: 0, title: 'Age' },
role: { type: 'string', enum: ['Admin', 'Editor', 'Viewer'], title: 'Role' },
},
required: ['name', 'email'],
};
<SchemaForm schema={schema} onSubmit={(data) => save(data)} />
На основе этой схемы библиотека отображает поле ввода текста, поле ввода электронной почты, поле ввода чисел и выпадающий список для выбора значений из перечня, отмечает обязательные поля, показывает ошибки прямо в поле ввода и вызывает метод onSubmit только тогда, когда данные являются корректными. Компонент работает в обоих режимах React: его можно управлять путем передачи параметров value и onChange, либо позволить ему самостоятельно управлять своим состоянием с помощью параметра defaultValue.
Любой генератор справляется с такими плоскими объектами; настоящее испытание — это вложенные и разветвленные схемы.
Обработка неплоских схем
В производственных схемах используются повторяющиеся определения, комбинируются фрагменты и происходит разветвление в зависимости от данных. Библиотека решает все эти задачи на основе текущих данных формы перед каждой отрисовкой, поэтому каждое поле получает лишь упрощенную схему.
Повторное использование определений с помощью $ref и allOf
Общее определение, такое как address, может быть использовано в двух местах и будет отображаться как две независимые секции. Ключевые слова, размещенные рядом с $ref, переопределяют ссылочное определение, поэтому { "$ref": "#/definitions/address", "title": "Shipping address" } генерирует блок с адресом с меткой «Адрес доставки». С использованием allOf части объединяются глубоко: вложенные свойства сливаются рекурсивно, а массивы required объединяются в их объединение.
Использование oneOf как дискриминированного союза
Многие генераторы сталкиваются с трудностями при работе с oneOf. Эффективным решением является использование дискриминированного союза: каждая ветвь присваивает общее поле фиксированному значению с помощью const, а форма использует это поле для выбора активной ветви.
В приведённой ниже схеме оплаты method представляет собой перечисление значений card или bank. Первый вариант устанавливает method в значение card и требует наличия поля number; второй устанавливает его в значение bank и требует поля iban.
{
"type": "object",
"properties": { "method": { "type": "string", "enum": ["card", "bank"] } },
"required": ["method"],
"oneOf": [
{ "title": "Card", "properties": { "method": { "const": "card" }, "number": { "type": "string" } }, "required": ["number"] },
{ "title": "Bank", "properties": { "method": { "const": "bank" }, "iban": { "type": "string" } }, "required": ["iban"] }
]
}
При изменении method с card на bank поле номера карты заменяется на поле IBAN. В вашем коде отсутствуют состояния компонентов и условные элементы JSX; изменения происходят исключительно на основе схемы. Элемент oneOf, ветви которого содержат только константу const, отображается в виде маркированного списка с выбором.
Условные разделы с использованием if/then/else и dependencies
Условные ключевые слова переоцениваются каждый раз, когда меняются данные, включая при каждом нажатии клавиши. Полезным решением является использование необязательного раздела, который проверяется только тогда, когда пользователь включает его. Приведённый ниже фрагмент определяет объект schedule с логическим флагом enabled (по умолчанию false) и двумя полями с датами. Условие if выполняется, когда enabled равно true, и в этом случае условие then делает поля monday и tuesday обязательными.
"schedule": {
"type": "object",
"properties": {
"enabled": { "type": "boolean", "title": "Enable schedule", "default": false },
"monday": { "type": "string", "title": "Monday" },
"tuesday": { "type": "string", "title": "Tuesday" }
},
"if": { "properties": { "enabled": { "const": true } }, "required": ["enabled"] },
"then": { "required": ["monday", "tuesday"] }
}
Когда переключатель выключен, ничего внутри раздела не является обязательным, и отправка формы не блокируется. Когда он включен, оба поля с датой получают маркеры обязательности, и форму нельзя отправить до тех пор, пока они не заполнены. Поскольку переключатель является частью данных, а не локального состояния интерфейса, сервер может проверять тот же пакет данных с использованием той же схемы и приходить к тому же выводу.
Строка "required": ["enabled"] внутри if легко можно пропустить, но её наличие крайне важно. В JSON Schema свойства properties ограничивают только те ключи, которые действительно присутствуют. Поэтому объект без ключа enabled соответствует формату { "properties": { "enabled": { "const": true } } }, активируется ветка then, и поля становятся обязательными, хотя раздел никогда не был включён. Указание обязательности ключа в условии устраняет эту проблему.
Выбор и настройка виджетов
Сгенерированная форма будет полезна только в том случае, если вы можете контролировать, какой элемент ввода используется для каждого поля. Библиотека содержит реестр встроенных виджетов, включая text, email, number, select, radio, checkboxes, textarea и date, и предлагает три способа их присвоения:
- Свойство
uiSchemaс ключом, определяемым путем, с поддержкой глобальных шаблонов.tags.*относится ко всем элементам массива, а**.postalCode— к каждому почтовому индексу на любой глубине, даже внутри$ref, используемого в двух местах. При совпадении нескольких ключей применяется наиболее специфичный из них.
ui:*, а родительский узел может хранить вложенную структуру uiSchema, обращение к которой осуществляется по именам дочерних узлов; таким образом, тот, кто использует общее определение, может изменить внешний вид своих дочерних элементов.resolveWidget для принятия решений на основе правил, например: «каждое целое число с параметром format: epoch использует виджет epoch». Она получает полностью разрешенную схему и может вернуть либо имя виджета, либо соответствующий компонент.Порядок приоритетов фиксирован: uiSchema приложения имеет преимущество перед подсказками, встроенными в схему; эти подсказки имеют преимущество перед правилами resolveWidget; последние, в свою очередь, имеют преимущество перед значениями по умолчанию. Такая предсказуемость важна, когда схему предоставляет другая команда: клиент всегда может переопределить её подсказки.
Написание собственного виджета
Виджет — это компонент, который получает текущее значение и обратный вызов функции onChange, а также свойства вроде id, required, disabled и onBlur. В приведённом ниже примере время хранится в виде секунд по стандарту Unix, но пользователю отображается встроенный выбрасыватель значений типа datetime-local. Для отображения секунды преобразуются в строку с датой, а при изменении данных они снова парсируются: миллисекунды делятся на 1000, и при пустом или недопустимом значении передаётся undefined. Виджет регистрируется под именем epoch и привязывается к полю startsAt через uiSchema.
import type { Widget } from 'react-simple-schema-form';
const EpochWidget: Widget<number | undefined> = ({ id, value, onChange, onBlur, required, disabled }) => (
<input
type="datetime-local"
id={id}
required={required}
disabled={disabled}
value={value === undefined ? '' : new Date(value * 1000).toISOString().slice(0, 16)}
onBlur={onBlur}
onChange={(e) => {
const ms = new Date(e.target.value).getTime();
onChange(Number.isNaN(ms) ? undefined : Math.floor(ms / 1000));
}}
/>
);
<SchemaForm schema={schema} widgets={{ epoch: EpochWidget }} uiSchema={{ startsAt: { widget: 'epoch' } }} />
В схеме указано integer, пользователь видит поле выбора, а данные хранятся в формате секунд Unix. Есть один нюанс: функция toISOString() возвращает время в формате UTC, тогда как использование поля типа datetime-local и операция new Date(e.target.value) работают с учётом локального часового пояса пользователя. При отклонении от формата UTC отображаемое время смещается на величину часового пояса, и при каждой правке меняется значение в хранилище. Чтобы обеспечить согласованность в обоих направлениях, необходимо форматировать отображаемое значение на основе локальных данных о дате.
Виджет также может хранить целый объект или массив, получая всё значение вместе со всеми вложенными ошибками, и отображать его содержимое с помощью экспортируемого компонента <Field>. Именно так раздел расписания получает возможность включения/отключения без необходимости, чтобы библиотека знала о наличии расписаний.
Если схема ссылается на виджет, который никогда не регистрировался, библиотека записывает одно предупреждение и переходит к стандартному вводу. Ошибка в схеме, предоставленной другой командой, должна приводить к плавному снижению функциональности, а не к сбою страницы.
Проверка, соответствующая ожиданиям пользователя
Встроенный валидатор небольшой и не требует дополнительных зависимостей; основная часть его конструкции посвящена определению момента сообщения об ошибках, а не факту их наличия.
Отображение ошибок в подходящий момент
Ошибки появляются после того, как пользователь покидает поле, или все сразу после попытки отправки, но никогда при первой отрисовке. При неудачной отправке фокус перемещается на первое невалидное поле.
Неиспользованные факультативные объекты считаются отсутствующими
Чтобы отобразить поля ввода для вложенных объектов, форма заполняет их с помощью {}. Примитивный валидатор тогда потребует наличия полей street и city для необязательного адреса, к которому пользователь вообще не обращался. Решение заключается в том, чтобы считать необязательный объект, все значения которого пусты, отсутствующим, тем самым избегая ошибок. Обязательный объект всегда проверяется, и вместо расплывчатого сообщения «Требуется адрес» выводится список ошибок с указанием отсутствующих дочерних элементов.
Ошибки атрибута oneOf для активной ветви
Когда ни один из вариантов oneOf не проходит проверку, общее сообщение «данные должны полностью соответствовать одной схеме» бесполезно для пользователя. Вместо этого проверщик определяет, к какому варианту относятся данные, сопоставляя их по дискриминаторам и типам, но игнорируя параметр required, и сообщает об ошибках на уровне полей этого варианта. При оплате с методом method: card без указания номера карты ошибка отображается в поле с номером карты, куда и будет смотреть пользователь.
Никогда не позволяйте скрытым полям мешать отправке
Оставшиеся, неполностью введенные значения в разделе, который отключен, не должны приводить к сбою при проверке pattern, которую пользователь не может увидеть. Правило находится в схеме, но решение заключается в самом виджете: он очищает раздел при его отключении, а свойство errors сообщает виджету о наличии ошибок в той части, которую он скрывает.
Используйте правила вне React
Валидатор также экспортируется отдельно. Функция validate(schema, data) возвращает список из элементов { path, keyword, message }, поэтому те же правила могут применяться в сервисе Node.js, в тестах на единицы или до отрисовки любого контента. Альтернативу на основе TypeScript смотрите в статье об общем использовании одной схемы Zod в React и Node.
Документация, предназначенная для помощников по программированию
Формы часто создаются с помощью ИИ-помощников по программированию, поэтому пакет сопровождается документацией, ориентированной как на людей, так и на машины:
- Файл с описанием навыков агента по адресу
skills/react-simple-schema-form/SKILL.mdвнутри пакета npm, который инструменты, поддерживающие формат навыков агента, могут загрузить из папкиnode_modules. В нем описан API, порядок приоритета виджетов, вышеупомянутые примеры реализации и известные проблемы; объем файла на момент написания составляет примерно 7 кБ. - Файлы
llms.txtиllms-full.txtна демо-сайте, объединяющие файл README, описание навыков и все примеры схем в один файл, который можно скопировать в чат или индексировать с помощью сервера MCP для документации. - JSDoc с примерами к каждому экспортируемому элементу; при наведении курсора мыши на объявления типов в редакторе отображается информация об использовании.
- Файл
context7.json, позволяющий корректно индексировать репозиторий в системе Context7.
Это не заставит модель выбирать библиотеку, но повысит вероятность того, что первая попытка ассистента сработает; такой подход стоит использовать и для внутренних библиотек.
Попробовать
живая демонстрация размещает редактор схем рядом с сгенерированной формой, а под ней отображаются актуальные данные и ошибки. В ней приведены примеры использования $ref, allOf, oneOf, if/then/else, dependencies и выбора виджетов. Пакет опубликован на npm, а исходный код и трекер задач находятся на GitHub. Это молодой проект, поэтому протестируйте его с собственными схемами перед тем, как полагаться на него.
Основные выводы
- Если API уже публикует JSON Schema, генерация формы на его основе устраняет дублирующиеся правила и обеспечивает синхронизацию проверок на стороне интерфейса и сервера.
- Обрабатывайте элементы
$ref,allOf,oneOfи условные элементы с использованием актуальных данных, чтобы каждое поле имело упрощённую структуру схемы. - Моделируйте варианты форм как дискриминированные союзы с использованием ключевого слова
const, и всегда добавляйте атрибутrequiredвнутрь условийif. - Обеспечивайте возможность переопределения выбора компонентов с чётким порядком приоритетов, особенно для схем, принадлежащих другой команде.
- Качественные генерируемые формы зависят от момента выполнения проверок: сообщайте об ошибках при уходе с поля или отправке формы, игнорируйте незатронутые необязательные объекты, а указывайте ошибки типа
oneOfна активную ветвь схемы.