В архитектуре чистого функционального программирования и управления состоянием React/Redux соблюдение строгой иммутабельности (Immutability) данных является фундаментальным инвариантом. Изменение любого узла состояния требует создания нового ссылочного объекта для всех его родительских контейнеров, чтобы алгоритмы поверхностного сравнения (Shallow Equality Check) в React Fiber и селекторах могли зафиксировать мутацию.
При работе с глубоко вложенными структурами данных (Domain Trees, AST, конфигурации сложных форм) классический синтаксис spread-оператора (...) превращается в нечитаемый и подверженный ошибкам антипаттерн «Spread Hell»:
// Хрупкое, громоздкое ручное копирование 4 уровней вложенности:
const nextState = {
...state,
workspace: {
...state.workspace,
project: {
...state.workspace.project,
settings: {
...state.workspace.project.settings,
theme: 'dark',
},
},
},
};Математическим решением этой проблемы выступает теория Оптик (Optics) — компонуемых функциональных абстракций для фокусировки, чтения и обновления изолированных подструктур внутри сложных неизменяемых данных. Ключевым примитивом оптик является Линза (Lens), дополняемая Призмами (Prisms) для опциональных значений и Траверсалами (Traversals) для коллекций.
+-------------------------------------------------------------------------+
| АРХИТЕКТУРА ФОКУСИРОВКИ ЧЕРЕЗ ЛИНЗЫ |
+-------------------------------------------------------------------------+
| Исходная глубокая структура: S (RootState) |
+-------------------------------------------------------------------------+
|
| lensUser = prop('user')
v
| Фокус: User |
|
| lensAddress = prop('address')
v
| Фокус: Address |
|
| lensCity = prop('city')
v
+-------------------------------------------------------------------------+
| Композитная линза: cityLens = compose(lensUser, lensAddress, lensCity) |
| |
| |-- Чтение: cityLens.get(state) -> 'Berlin' |
| `-- Запись: cityLens.set('Munich')(state) -> Новый RootState |
| (Все промежуточные ссылки обновлены иммутабельно!) |
+-------------------------------------------------------------------------+В категорной теории оптики разделяются по типу целевого фокуса:
Иерархия функциональных оптик:
+------------+
| Optic |
+------------+
/ \
v v
+------------+ +------------+
| Lens | | Prism |
| (Ровно 1) | | (0 или 1) |
+------------+ +------------+
\ /
v v
+----------------+
| Traversal |
| (От 0 до N) |
+----------------+Найдите участки кода с множественными вложенными spread-операторами (...state.a.b.c).
Внедрите интерфейс Lens<S, A>, хелперы prop(), over() и compose() в архитектурный слой утилит.
Объявите атомарные линзы для каждого уровня доменной модели (userLens, settingsLens, themeLens).
Сформируйте составные линзы для типовых операций обновления (например, updateProjectThemeLens).
Замените громоздкие редьюсеры на чистые вызовы lens.set(value)(state), гарантируя ссылочную стабильность.
В чем фундаментальное различие между Линзой (Lens) и Призмой (Prism) в теории функционального программирования?
Organization -> Department -> Team -> Permissions без разрыва ссылочной целостности.Traversal без прямого перебора массивов в UI-компонентах.Lens<S, A> на чистом TypeScript#Создадим строго типизированный примитив линзы без использования сторонних библиотек и с абсолютной защитой от any:
/**
* Контракт чистой функциональной линзы.
* S — тип внешнего исходного объекта (Source).
* A — тип внутреннего целевого значения в фокусе (Target).
*/
export interface Lens<S, A> {
readonly get: (source: S) => A;
readonly set: (value: A) => (source: S) => S;
}
/**
* Вспомогательный метод трансформации значения через функцию-модификатор.
*/
export function over<S, A>(lens: Lens<S, A>, fn: (val: A) => A): (source: S) => S {
return (source: S) => lens.set(fn(lens.get(source)))(source);
}
/**
* Создает линзу, сфокусированную на конкретном свойстве объекта.
*/
export function prop<S, K extends keyof S>(key: K): Lens<S, S[K]> {
return {
get: (source: S) => source[key],
set: (value: S[K]) => (source: S) => {
// Защита от лишних аллокаций: если значение не изменилось, возвращаем прежний source
if (Object.is(source[key], value)) {
return source;
}
return {
...source,
[key]: value,
};
},
};
}
/**
* Композиция двух линз: Lens<A, B> + Lens<B, C> = Lens<A, C>
*/
export function compose<A, B, C>(lensAB: Lens<A, B>, lensBC: Lens<B, C>): Lens<A, C> {
return {
get: (source: A) => lensBC.get(lensAB.get(source)),
set: (value: C) => (source: A) => {
const currentB = lensAB.get(source);
const nextB = lensBC.set(value)(currentB);
return lensAB.set(nextB)(source);
},
};
}Призма позволяет безопасно модифицировать поля, которые могут иметь значение undefined или null:
export interface Prism<S, A> {
readonly preview: (source: S) => A | undefined;
readonly set: (value: A) => (source: S) => S;
}
/**
* Создает призму для опционального свойства объекта.
*/
export function optionalProp<S, K extends keyof S>(
key: K
): Prism<S, NonNullable<S[K]>> {
return {
preview: (source: S) => {
const val = source[key];
return (val === null || val === undefined ? undefined : val) as NonNullable<S[K]>;
},
set: (value: NonNullable<S[K]>) => (source: S) => {
if (source[key] === null || source[key] === undefined) {
return source; // Если узел отсутствует, мутация не производится
}
if (Object.is(source[key], value)) {
return source;
}
return {
...source,
[key]: value,
};
},
};
}Продемонстрируем лаконичность и типобезопасность модификации 4 уровней вложенности:
import { prop, compose, over } from '../optics/lens';
export interface Address {
readonly city: string;
readonly zipCode: string;
}
export interface UserSettings {
readonly theme: 'light' | 'dark';
readonly address: Address;
}
export interface UserAccount {
readonly id: string;
readonly settings: UserSettings;
}
export interface RootAppState {
readonly account: UserAccount;
readonly version: number;
}
// 1. Создаем атомарные переиспользуемые линзы
export const accountLens = prop<RootAppState, 'account'>('account');
export const settingsLens = prop<UserAccount, 'settings'>('settings');
export const addressLens = prop<UserSettings, 'address'>('address');
export const cityLens = prop<Address, 'city'>('city');
// 2. Компонуем путь фокуса: RootAppState -> city
export const accountCityLens = compose(
compose(accountLens, settingsLens),
compose(addressLens, cityLens)
);
// 3. Создаем тестовое состояние
const initialState: RootAppState = {
version: 1,
account: {
id: 'acc_777',
settings: {
theme: 'dark',
address: {
city: 'Hamburg',
zipCode: '20095',
},
},
},
};
// 4. Иммутабельное чтение:
const currentCity = accountCityLens.get(initialState); // 'Hamburg'
// 5. Иммутабельная модификация одной строкой:
const updatedState = accountCityLens.set('Munich')(initialState);
// 6. Модификация через функцию over (приведение к верхнему регистру):
const capitalizedState = over(accountCityLens, (c) => c.toUpperCase())(initialState);
// Проверка ссылочной целостности:
console.log(updatedState.account.settings.address.city); // 'Munich'
console.log(initialState.account.settings.address.city); // 'Hamburg' (исходный стейт не тронут!)
console.log(initialState.version === updatedState.version); // true (незатронутые узлы сохранили ссылки)Инженер соединяет цепочку мелких линз в единую композитную линзу, направленную на глубоко вложенное свойство.
Линза последовательно спускается по структуре объекта, возвращая атомарное значение без промежуточных аллокаций.
Если новое значение равно старому, линза мгновенно возвращает исходный source, предотвращая лишние ререндеры.
Линза создает обновленные копии только тех узлов, которые лежат на пути к измененному свойству, сохраняя старые ссылки для всех соседей.
import { Lens, prop, compose } from './lens';
/**
* Фабрика линзы глубины 2 со строгой проверкой ключей
*/
export function path2<
TSource,
K1 extends keyof TSource,
K2 extends keyof TSource[K1]
>(k1: K1, k2: K2): Lens<TSource, TSource[K1][K2]> {
const l1 = prop<TSource, K1>(k1);
const l2 = prop<TSource[K1], K2>(k2);
return compose(l1, l2);
}
/**
* Фабрика линзы глубины 3 со строгой проверкой ключей
*/
export function path3<
TSource,
K1 extends keyof TSource,
K2 extends keyof TSource[K1],
K3 extends keyof TSource[K1][K2]
>(k1: K1, k2: K2, k3: K3): Lens<TSource, TSource[K1][K2][K3]> {
const l12 = path2<TSource, K1, K2>(k1, k2);
const l3 = prop<TSource[K1][K2], K3>(k3);
return compose(l12, l3);
}Почему качественная реализация метода set в линзе обязана выполнять проверку Object.is(source[key], value) перед созданием поверхностной копии?
Некачественно написанная линза создает поверхностную копию { ...source, [k]: value }, даже если value идентично старому значению. Это ломает мемоизацию React.memo и селекторы useSyncExternalStore.
// ❌ АНТИПАТТЕРН: Создание новой ссылки при отсутствии изменений
set: (value) => (source) => ({ ...source, [key]: value });
// ✅ ПРАВИЛЬНО: Защитная проверка Object.is
set: (value) => (source) => {
if (Object.is(source[key], value)) return source;
return { ...source, [key]: value };
};Если поле имеет тип address?: Address, вызов lensAddress.get(state) вернет undefined, а последующая линза cityLens.get(undefined) упадет с фатальной ошибкой Cannot read properties of undefined. Для таких полей обязательно использовать Призмы (Prism).
// ❌ АНТИПАТТЕРН: Использование обычной линзы для nullable-поля
const badLens = compose(prop('optionalUser'), prop('name')); // Опасность падения!
// ✅ ПРАВИЛЬНО: Использование Призмы с безопасным previewПри попытке модифицировать элемент массива нельзя использовать array[index] = next. Необходимо использовать .map() или создание новой копии массива.
// ❌ АНТИПАТТЕРН: Прямая мутация индекса массива
source.items[index] = nextItem; // Мутация старого стейта!
// ✅ ПРАВИЛЬНО: Иммутабельная замена через map
source.items.map((item, i) => (i === index ? nextItem : item));При возврате нового объекта важно сохранять Readonly<T>, иначе внутренние методы могут случайно допустить мутацию полей в рантайме.
// ✅ ПРАВИЛЬНО: Строгое сохранение Readonly контрактов
export interface Lens<S, A> {
readonly get: (source: S) => A;
readonly set: (value: A) => (source: S) => S;
}