רשימות KeystoneJS מותאמות אישית: הימנעות ממלכודות נפוצות
כשעבדנו עם KeystoneJS בחנות מקוונת עם 100,000 מוצרים, נתקלנו במצב: ממשק הניהול טען את רשימת המוצרים ב-8 שניות. הסיבה הייתה תצורת 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'], }, }); . שגיאת N+1 גרמה למפולת של שאילתות לטבלאות קשורות. הלקוח איבד עד 15% מההזמנות בגלל ניהול קטלוג איטי. אם יש לכם בעיה דומה, צרו איתנו קשר—אנחנו יכולים לעזור לייעל את ה-Lists שלכם.
ללא hooks ואינדקסים מתאימים, כל הרחבת מודל הופכת לכאובה. הוספת שדה חדש מובילה לשכתוב קוד לקוח, ונתונים לא עקביים גורמים לבאגים בחנות. הצוות שלנו, עם 5 שנות ניסיון ב-KeystoneJS, אסף פתרונות: מהפקת slugs אוטומטית ועד mutations מותאמות אישית לעדכוני מחירים המוניים. פרקטיקות אלה חוסכות עד 40% מהזמן בעת יישום שינויים.
במאמר זה נראה כיצד להגדיר גישה ברמת שדה, להוסיף שדות וירטואליים לערכים מחושבים, וליישם hooks של ולידציה הדוחים מוצרים עם מחירים שליליים או SKUs חסרים. בסוף, נשווה את הביצועים של List מותאם לפתרון טיפוסי.
אילו בעיות אנחנו פותרים
שאילתות N+1 בעת שליפת קשרים. אם List מצהיר על מספר קשרים ותצוגת הרשימה בממשק הניהול אינה מגדירה graphql.cacheHint או אסטרטגיית טעינה, כל פריט ברשימה שולף נתונים קשורים בנפרד. הפתרון הוא לשלב ui.listView.pageSize עם graphql.cacheHint או שאילתות מותאמות אישית. בפועל, זה מקצר את זמן טעינת הרשימה מ-2 שניות ל-200 אלפיות השנייה (חיסכון של 90%).
חוסר ולידציה ברמת ה-hook. בדיקות שדה סטנדרטיות מכסות רק תחביר. כללים עסקיים—לדוגמה, "לא ניתן למחוק קטגוריה עם מוצרים שפורסמו"—דורשים hooks של validateInput ו-beforeOperation. אנחנו תמיד מוסיפים שרשראות כאלה—זה מונע כניסת נתונים שגויים למסד הנתונים.
מודל גישה חלש. כברירת מחדל, כל ה-Lists נגישים לכל המשתמשים המאומתים. אבל בפרויקט טיפוסי, יש צורך בהדרגה: מנהלים רואים רק את המוצרים שלהם, עורכים רואים טיוטות, אדמינים רואים הכל. KeystoneJS תומך בכך דרך access ברמת List, פעולה ושדה. הגדרת גישה נכונה מגנה על נתונים ומפשטת ביקורת.
איך אנחנו בונים Lists מותאמים אישית
ניקח חנות מקוונת טיפוסית. List אחד הוא Product. הוא מקושר ל-Category, Tag, ProductVariant ו-Order. אנחנו צריכים לא רק שדות אלא גם hooks, שדות וירטואליים ו-mutations מותאמים אישית.
דוגמה לרשימת Product מלאה
// 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), אבטחה (hooks שבודקים סטטוס) ושימושיות (auto-slug, שדה וירטואלי).
למה Hooks חשובים יותר ממה שחושבים
Hooks הם המקום היחיד שבו אפשר להבטיח עקביות נתונים ברמת האפליקציה. לדוגמה, בעת מחיקת Category, צריך לבדוק אם יש מוצרים שפורסמו. ה-hook של beforeOperation תופס את המחיקה וזורק שגיאה—זה אמין יותר מבדיקה בצד הלקוח.
"Hooks הם המקום היחיד שבו אפשר להבטיח עקביות נתונים ברמת האפליקציה" — תיעוד KeystoneJS.
איך להימנע מ-N+1 בעבודה עם קשרים
KeystoneJS טוען קשרים בעצלתיים כברירת מחדל. כדי להימנע מ-N+1, השתמשו ב:
- אינדקס על מפתחות זרים (ודאו שלשדה הקשר יש
isIndexed: true). -
graphql.cacheHintעבור שדות שנשאלים לעתים קרובות. - הגדרות
ui.listView.initialColumnsברורות—אל תפלטו את כל הישויות הקשורות בבת אחת. - במידת הצורך, שמרו במטמון עם graphql.cacheHint.
טעויות טיפוסיות בעבודה עם Lists
-
אינדקסים חסרים. אם שדה משמש לעתים קרובות בסינון או מיון, הוסיפו
isIndexed: true. אחרת, כל שאילתה סורקת את כל הטבלה. - חוסר ב-validateInput hook. בלעדיו, נתונים שגויים יכולים להיכנס למסד הנתונים. תמיד בדקו אילוצים עסקיים.
- קשרים מוגזמים. אל תיצרו חיבורים שאינם נחוצים בגרסה הנוכחית—קשרים מיותרים מאטים את ממשק הניהול.
אילו סוגי שדות להשתמש
| סוג שדה | תיאור | דוגמת שימוש |
|---|---|---|
| text | מחרוזת עד N תווים | שם מוצר |
| relationship | קישור ל-List אחר | קטגוריית מוצר |
| virtual | שדה מחושב | מחיר כולל מע"מ |
| select | בחירה מרשימה | סטטוס מוצר |
תהליך עבודה למודל נתונים
- ניתוח דרישות עסקיות — ישויות, קשרים, זכויות גישה.
- עיצוב הסכמה — דיאגרמת ER, סוגי שדות, אינדקסים, hooks.
- יישום — כתיבת Lists, הגדרת גישה, hooks.
- בדיקות אינטגרציה — בדיקת פעולות GraphQL, טעינת נתונים.
- פריסה וניטור — פריסה לשרת, הגדרת לוגים.
לוחות זמנים משוערים
List אחד עם שדות סטנדרטיים — בין 0.5 ליום עבודה אחד. List מורכב עם hooks, שדות וירטואליים ו-mutations מותאמים אישית — 1–2 ימים. מודל נתונים מלא לחנות מקוונת (10–15 Lists) — בין 5 ל-8 ימים. צרו איתנו קשר להערכה מדויקת לפרויקט שלכם.
מה כלול בתוצאה
אנחנו מספקים:
- קוד מקור ל-Lists עם הערות.
- תיעוד למודל (טבלה עם תיאורי שדות וקשרים).
- ממשק ניהול מוגדר עם עמודות ומסננים נדרשים.
- סקריפטי מיגרציה (באמצעות Prisma).
- הוראות פריסה ואינטגרציה.
בנוסף, אחריות ל-30 יום: אם נמצאו באגים, נתקן אותם בחינם. ניסיון עם KeystoneJS — מעל 5 שנים, יותר מ-50 פרויקטים שהושלמו. הזמינו פיתוח List מותאם אישית — נעריך את מודל הנתונים שלכם. קבלו ייעוץ לפרויקט שלכם.
השוואה עם חלופות
| קריטריון | KeystoneJS (הגישה שלנו) | פתרון טיפוסי (ללא אופטימיזציה) |
|---|---|---|
| מהירות טעינת List | < 200 אלפיות השנייה | 2–5 שניות בגלל N+1 |
| יכולת הרחבה | Hooks, שדות וירטואליים, mutations מותאמים אישית | רק CRUD |
| אבטחה | גישה ברמת שדה ופעולה | הכל או כלום |
| ממשק ניהול | ניתן להתאמה אישית | סטנדרטי |







