В Dexie.js вычисляемые поля реализуются не как отдельный слой ORM-логики, а через систему хуков таблиц, позволяющую вмешиваться в жизненный цикл операций над объектами. Такой подход обеспечивает прозрачную денормализацию, синхронизацию производных значений и контроль целостности данных без необходимости пересчитывать их вручную в бизнес-логике приложения.
Ключевая идея заключается в том, что объект перед сохранением, обновлением или после чтения может быть модифицирован, дополнен или преобразован. Это позволяет формировать поля, которые не хранятся напрямую как пользовательский ввод, но всегда доступны как часть модели данных.
Вычисляемое поле — это свойство объекта, которое не является исходным источником данных, но формируется на основе других свойств.
Типичные примеры:
fullName как конкатенация firstName и
lastNameage как вычисление из birthDatesearchIndex как нормализованный текст для поискаisActive как производное от статуса и даты последнего
входаВ контексте IndexedDB и Dexie.js такие поля нельзя полагаться на вычисление “на лету” в UI, поскольку запросы могут выполняться на уровне индексов, где требуется физически присутствующее значение. Поэтому вычисляемые поля часто материализуются в записи.
Dexie.js предоставляет несколько типов хуков на уровне таблиц:
creating — перед добавлением записиupdating — перед обновлением записиdeleting — перед удалениемreading — при чтении записиИменно creating и updating используются для
вычисляемых полей, поскольку они позволяют модифицировать данные до их
записи в хранилище.
Хуки привязываются к таблице:
db.users.hook('creating', (primKey, obj, transaction) => {
// модификация объекта перед сохранением
});
Для обновления:
db.users.hook('updating', (modifications, primKey, obj, transaction) => {
// модификация partial update
});
Наиболее простой и распространённый пример — формирование полного имени.
db.users.hook('creating', (primKey, obj) => {
obj.fullName = `${obj.firstName} ${obj.lastName}`;
});
db.users.hook('updating', (modifications) => {
if (modifications.firstName || modifications.lastName) {
const firstName = modifications.firstName;
const lastName = modifications.lastName;
// если обновляется только часть данных, полное имя пересчитывается частично
modifications.fullName = `${firstName ?? ''} ${lastName ?? ''}`.trim();
}
});
Особенность обновляющего хука заключается в том, что он работает с
патчем, а не с полным объектом, поэтому требуется аккуратная логика
обработки undefined.
Один из наиболее важных кейсов — подготовка данных для индексации поиска. IndexedDB эффективно работает с индексами, но не поддерживает сложные поисковые выражения. Поэтому создаются вспомогательные поля.
function normalizeText(text) {
return text
.toLowerCase()
.replace(/\s+/g, ' ')
.trim();
}
db.products.hook('creating', (primKey, obj) => {
obj.searchIndex = normalizeText(`${obj.title} ${obj.description}`);
});
db.products.hook('updating', (modifications, primKey, obj) => {
const title = modifications.title ?? obj.title;
const description = modifications.description ?? obj.description;
if (title || description) {
modifications.searchIndex = normalizeText(`${title} ${description}`);
}
});
Такой подход позволяет выполнять быстрые запросы через индекс
searchIndex, не прибегая к full scan с фильтрацией на
клиенте.
Все хуки Dexie получают доступ к текущей транзакции. Это позволяет выполнять дополнительные операции над связанными таблицами.
Пример: автоматическое обновление агрегированных данных.
db.orders.hook('creating', async (primKey, order, transaction) => {
const user = await transaction.table('users').get(order.userId);
order.userSnapshot = {
name: user.fullName,
email: user.email
};
});
Здесь создаётся снимок пользователя на момент создания заказа, что позволяет сохранять историческую консистентность данных.
Сложность возрастает, когда одно поле зависит от нескольких других, и обновления происходят частично.
db.profile.hook('updating', (modifications, primKey, obj) => {
const newFirst = modifications.firstName ?? obj.firstName;
const newLast = modifications.lastName ?? obj.lastName;
const newNick = modifications.nickname ?? obj.nickname;
modifications.displayName =
newNick?.length > 0
? newNick
: `${newFirst} ${newLast}`.trim();
});
Здесь вычисляемое поле имеет приоритетную логику: nickname перекрывает составное имя.
Хуки часто используются не только для вычислений, но и для поддержания бизнес-инвариантов.
db.accounts.hook('updating', (modifications, primKey, obj) => {
if (modifications.balance != null) {
const newBalance = obj.balance + modifications.balance;
if (newBalance < 0) {
throw new Error('Баланс не может быть отрицательным');
}
modifications.balance = newBalance;
}
});
Хотя это не вычисляемое поле в строгом смысле, механизм идентичен: значение выводится из текущего состояния и входящих изменений.
Основная сложность заключается в том, что вычисляемые поля дублируют данные. Это создаёт риск рассинхронизации.
Типовые проблемы:
creating и
updatingДля решения применяется единая функция расчёта:
function computeUserFields(obj) {
return {
fullName: `${obj.firstName} ${obj.lastName}`.trim(),
searchIndex: normalizeText(`${obj.firstName} ${obj.lastName} ${obj.email}`)
};
}
И использование её в хуках:
db.users.hook('creating', (primKey, obj) => {
Object.assign(obj, computeUserFields(obj));
});
db.users.hook('updating', (modifications, primKey, obj) => {
const merged = { ...obj, ...modifications };
Object.assign(modifications, computeUserFields(merged));
});
Такой подход снижает вероятность расхождений логики.
Вычисляемые поля не всегда нужно сохранять в базе. Иногда они могут формироваться динамически при чтении.
db.users.hook('reading', (obj) => {
obj.isAdult = obj.age >= 18;
return obj;
});
Этот подход полезен, когда:
Однако reading не подходит для полей, участвующих в
запросах через индексы.
На практике вычисляемые поля делятся на два класса:
Материализованные (persisted):
creating/updatingВиртуальные (runtime):
readingПример комбинирования:
db.products.hook('creating', (primKey, obj) => {
obj.searchIndex = normalizeText(obj.title);
});
db.products.hook('reading', (obj) => {
obj.isPopular = obj.views > 1000;
return obj;
});
При использовании bulkPut, bulkAdd и
bulkUpdate хуки вызываются для каждого объекта отдельно.
Это важно учитывать при вычислениях.
Проблема возникает при дорогих вычислениях:
db.items.hook('creating', (primKey, obj) => {
obj.hash = expensiveHash(obj.data);
});
При массовой вставке это может привести к значительным затратам.
Оптимизация:
Dexie выполняет хуки в строгом порядке:
creatingupdatingreading (при извлечении)Это означает, что любые вычисляемые поля, требующие индексации, должны формироваться до записи. Любые поля, предназначенные только для отображения, могут формироваться после.
1. Денормализованный индекс
Используется для ускорения поиска:
2. Snapshot pattern
Фиксация состояния связанного объекта:
3. Derived state pattern
Вычисление агрегатов:
Распространённые проблемы:
reading для индексируемых значенийОсобенно критична ситуация, когда логика расчёта распределена по нескольким хукам без централизованной функции.
Наиболее устойчивый подход заключается в том, чтобы все вычисляемые поля формировались через единый слой функций:
creating, updating,
batch-операцияхТакой подход делает систему предсказуемой и упрощает миграции схемы данных при изменении логики вычислений.