Разработка кастомных 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 | Кастомизируемый | Стандартный |







