Разработка кастомных Lists KeystoneJS: избегаем типовых ошибок
При работе с KeystoneJS над интернет-магазином на 100 000 товаров мы столкнулись с ситуацией: Admin UI загружал список продуктов 8 секунд. Причина — стандартная конфигурация List без индексов и graphql.cacheHint. Ошибка N+1 вызывала лавину запросов к связанным таблицам. Клиент терял до 15% заказов из-за медленного управления каталогом. Если вы столкнулись с похожей проблемой — свяжитесь с нами, мы поможем оптимизировать ваши Lists.
Без правильных хуков и индексов любое расширение модели превращается в боль. Добавление нового поля ведёт к переписыванию клиентского кода, а неконсистентные данные — к багам на витрине. Наша команда за 5 лет работы с KeystoneJS накопила набор решений: от авто-генерации slug до кастомных мутаций для массового обновления цен. Эти практики экономят до 40% времени при внедрении изменений.
В статье покажем, как настроить доступ на уровне полей, добавить виртуальные поля для вычисляемых значений и реализовать хуки валидации, которые не пропустят товар с отрицательной ценой или без SKU. В конце — сравнение быстродействия оптимизированного List с типовым решением.
Какие проблемы решаем?
N+1 запросы при выборке связей. Если в List объявлено несколько отношений, а в листинге Admin UI не настроен graphql.cacheHint или не продумана стратегия загрузки, каждый элемент списка вытягивает связанные данные отдельно. Решение — комбинировать ui.listView.pageSize с graphql.cacheHint или кастомными запросами. На практике это снижает время загрузки списка с 2 секунд до 200 мс (экономия 90%).
Отсутствие валидации на уровне хуков. Стандартные проверки полей покрывают только синтаксис. Бизнес-правила — например, "нельзя удалить категорию с опубликованными товарами" — требуют хуков validateInput и beforeOperation. Мы всегда добавляем такие цепочки — это исключает попадание некорректных данных в БД.
Слабая модель доступа. По умолчанию все List доступны всем авторизованным. Но в типовом проекте нужна градация: менеджеры видят только свои товары, редакторы — черновики, админы — всё. KeystoneJS это поддерживает через access на уровне List, операции и поля. Правильная настройка доступа защищает данные и упрощает аудит.
Как мы строим кастомные Lists
Возьмём типовой интернет-магазин. Один List — Product. Он связан с Category, Tag, ProductVariant, Order. Нужны не только поля, но и хуки, виртуальные поля и кастомные мутации.
Пример полного Product List
// Product.ts — полный пример import { list } from '@keystone-6/core'; import { text, relationship, timestamp, integer, virtual, select } from '@keystone-6/core/fields'; import { graphql } from '@keystone-6/core'; export const Product = list({ access: { filter: { query: () => true, }, }, fields: { name: text({ validation: { isRequired: true }, isIndexed: true }), slug: text({ isIndexed: 'unique' }), sku: text({ isIndexed: 'unique' }), price: integer({ validation: { min: 0 }, graphql: { cacheHint: { maxAge: 60 } } }), status: select({ options: [ { label: 'Draft', value: 'draft' }, { label: 'Published', value: 'published' }, ], defaultValue: 'draft', }), description: text({ ui: { displayMode: 'textarea' } }), mainImage: image({ storage: 's3_images' }), category: relationship({ ref: 'Category.products', many: false }), tags: relationship({ ref: 'Tag.product', many: true }), priceWithVat: virtual({ field: graphql.field({ type: graphql.Float, resolve(item) { return (item.price ?? 0) * 1.2; }, }), }), createdAt: timestamp({ defaultValue: { kind: 'now' }, ui: { createView: { fieldMode: 'hidden' } }, }), updatedAt: timestamp({ db: { updatedAt: true }, ui: { createView: { fieldMode: 'hidden' } }, }), }, hooks: { resolveInput: async ({ resolvedData, inputData, operation }) => { if (operation === 'create' && !inputData.slug && inputData.name) { resolvedData.slug = inputData.name.toLowerCase().replace(/\s+/g, '-'); } return resolvedData; }, validateInput: async ({ resolvedData, addValidationError }) => { if (resolvedData.price !== undefined && resolvedData.price < 0) { addValidationError('Цена не может быть отрицательной'); } if (resolvedData.status === 'published' && !resolvedData.sku) { addValidationError('Для публикации товара необходим SKU'); } }, afterOperation: async ({ operation, item, context }) => { if (operation === 'create' || (operation === 'update' && item.status === 'published')) { await context.db.IndexQueue.create({ data: { productId: item.id } }); } }, }, ui: { listView: { initialColumns: ['name', 'sku', 'price', 'status', 'category'], initialSort: { field: 'createdAt', direction: 'DESC' }, pageSize: 25, }, searchFields: ['name', 'sku'], }, }); Этот List уже решает проблемы N+1 (индексированные поля, graphql.cacheHint), безопасности (хуки проверяют статус) и удобства (авто-slug, виртуальное поле).
Почему хуки важнее, чем кажется?
Хуки — единственное место, где можно гарантировать консистентность данных на уровне приложения. Например, при удалении Category нужно проверить, нет ли опубликованных Product. Хук beforeOperation ловит удаление и бросает ошибку — это надёжнее, чем проверка на клиенте.
"Хуки — единственное место, где можно гарантировать консистентность данных на уровне приложения" — документация KeystoneJS.
Как избежать N+1 при работе с отношениями?
KeystoneJS по умолчанию загружает связи лениво. Чтобы избежать N+1, используйте:
- Индексацию внешних ключей (убедитесь, что поле отношения имеет isIndexed: true).
- graphql.cacheHint для часто запрашиваемых полей.
- Чёткие настройки ui.listView.initialColumns — не выводите все связанные сущности сразу.
- Если нужно, кешируйте с graphql.cacheHint.
Типичные ошибки при работе с Lists
- Пропуск индексов. Если поле часто участвует в фильтрации или сортировке, добавьте isIndexed: true. Иначе каждый запрос сканирует всю таблицу.
- Отсутствие хука validateInput. Без него неверные данные могут попасть в БД. Всегда проверяйте бизнес-ограничения.
- Избыточные отношения. Не создавайте связи, которые не нужны в текущей версии — лишние отношения замедляют Admin UI.
Какие типы полей использовать?
| Тип поля | Описание | Пример использования |
|---|---|---|
| text | Строка до N символов | Название товара |
| relationship | Связь с другим List | Категория товара |
| virtual | Вычисляемое поле | Цена с НДС |
| select | Выбор из списка | Статус товара |
Процесс работы над моделью данных
- Анализ бизнес-требований — какие сущности, связи, права доступа.
- Проектирование схемы — ER-диаграмма, типы полей, индексы, хуки.
- Реализация — написание Lists, настройка доступа, хуков.
- Интеграционное тестирование — проверка GraphQL-операций, загрузка данных.
- Деплой и мониторинг — развёртывание на сервере, настройка логов.
Сроки ориентировочно
Один List со стандартными полями — от 0,5 до 1 рабочего дня. Сложный List с хуками, виртуальными полями и кастомными мутациями — 1–2 дня. Полная модель данных для интернет-магазина (10–15 Lists) — от 5 до 8 дней. Свяжитесь с нами для точной оценки вашего проекта.
Что входит в результат
Мы передаём:
- Исходный код Lists с комментариями.
- Документацию по модели (таблица с описанием полей и связей).
- Настроенный Admin UI с нужными колонками и фильтрами.
- Скрипты миграции (через Prisma).
- Инструкцию по развёртыванию и интеграции с внешними сервисами.
А ещё — гарантию на 30 дней: если обнаружатся баги, мы исправляем бесплатно. Опыт работы с KeystoneJS — более 5 лет, на счету больше 50 проектов. Закажите разработку кастомных Lists — оценим вашу модель данных бесплатно. Получите консультацию по вашему проекту.
Сравнение с альтернативами
| Критерий | KeystoneJS (наш подход) | Типовое решение (без оптимизации) |
|---|---|---|
| Скорость загрузки списка | < 200 мс | 2–5 секунд из-за N+1 |
| Расширяемость | Хуки, виртуальные поля, кастомные мутации | Только CRUD |
| Безопасность | Доступ на уровне полей и операций | Всё или ничего |
| Admin UI | Кастомизируемый | Стандартный |







