Простой паттерн — хранение методов на разделяемых прототипах вместо объектных литералов — сократил потребление памяти с 272 MB до 27 MB для больших таблиц. Как это работает и как применить в своих проектах.
TanStack Table V9 разрабатывался несколько лет, принеся переписанную с нуля систему управления состоянием, более лёгкую плагинную архитектуру и модель выполнения «плати за то, чем пользуешься». Но один из самых значительных выигрышей в производительности пришёл из неожиданного места: рефакторинга, настолько незаметного, что большинство разработчиков пролистали бы его в PR.
Суть изменения: вместо того чтобы определять методы прямо на каждом объекте
Row, Column, Cell и Header, TanStack Table V9 хранит их на разделяемых
прототипах и создаёт экземпляры через Object.create(proto).
Результат — до 90% снижения расхода памяти для больших таблиц:
с 272 MB до 27 MB для 100 000 строк с постраничной навигацией.
Эта статья — глубокий разбор того, как работает этот рефакторинг, паттерны кода «до» и «после», бенчмарки и почему эту технику можно применить в любом JavaScript-проекте, создающем множество однотипных объектов.
В TanStack Table V8 каждый Row, Column, Cell и Header был простым объектным литералом.
Каждый нёс не только свойства с данными (вроде id, index,
original), но и все методы — getValue,
getUniqueValues, renderValue, getLeafRows,
getParentRow, getAllCells и ещё с десяток методов
от плагинов.
const row = {
// данные
id,
index: rowIndex,
original,
depth,
parentId,
_valuesCache: {},
_uniqueValuesCache: {},
// методы — дублируются на КАЖДОЙ строке
getValue: (columnId) => {
// ...реализация...
},
getUniqueValues: (columnId) => {
// ...реализация...
},
renderValue: (columnId) =>
row.getValue(columnId) ?? table.options.renderFallbackValue,
getLeafRows: () => flattenBy(row.subRows, (d) => d.subRows),
getParentRow: () =>
row.parentId ? table.getRow(row.parentId, true) : undefined,
getAllCells: memo(/* ... */),
_getAllCellsByColumnId: memo(/* ... */),
// и ещё с дюжину методов от плагинов...
}
При 100 000 строк и 10+ методах каждая — это более миллиона объектов-функций, созданных для одних только методов. И каждый — независимое замыкание со своей цепочкой областей видимости. V8 не дедуплицирует их, потому что каждый объектный литерал создаёт свежие экземпляры функций.
Исправление элегантно и концептуально просто: создайте один объект-прототип для каждого
типа (Row, Column, Cell, Header) со всеми методами, затем используйте
Object.create(prototype) для создания экземпляров, наследующих методы
от общего прототипа. Свойства с данными устанавливаются через
Object.assign на каждый экземпляр.
// Разделяемый прототип — создаётся один раз
const rowPrototype = {
getValue(columnId) {
// ...this работает — ссылается на экземпляр...
},
getUniqueValues(columnId) {
// ...реализация...
},
renderValue(columnId) {
return this.getValue(columnId) ??
this.table.options.renderFallbackValue;
},
getLeafRows() {
return flattenBy(this.subRows, (d) => d.subRows);
},
getParentRow() {
return this.parentId
? this.table.getRow(this.parentId, true)
: undefined;
},
getAllCells: memo(/* ... */),
_getAllCellsByColumnId: memo(/* ... */),
};
// Создание экземпляра — одна строка, минимум памяти
function createRow(data) {
const row = Object.create(rowPrototype);
Object.assign(row, {
id: data.id,
index: data.rowIndex,
original: data.original,
depth: data.depth,
parentId: data.parentId,
_valuesCache: {},
_uniqueValuesCache: {},
});
return row;
}
Теперь каждая строка хранит только свои поля с данными (~6 свойств). 10+ методов
живут на общем rowPrototype — по одной копии каждого, независимо от
количества строк. Экономия накапливается на типах Row, Column, Cell и Header,
у каждого из которых был свой набор методов.
Цифры говорят сами за себя. TanStack опубликовал замеры retained JS heap по четырём сценариям: постраничная разбивка, виртуализация строк, виртуализация колонок и комбинированный «kitchen sink».
| Сценарий | Ячеек (строк × колонок) | V8 Память | V9 Память | Сэкономлено | % Улучшения |
|---|---|---|---|---|---|
| Пагинация | 800K (100K × 8) | 272.58 MB | 27.28 MB | 245.30 MB | 90.0% |
| Пагинация | 8M (1M × 8) | 2 710.06 MB | 257.19 MB | 2 452.87 MB | 90.5% |
| Виртуализация строк | 800K (100K × 8) | 273.42 MB | 28.12 MB | 245.30 MB | 89.7% |
| Виртуализация строк | 8M (1M × 8) | 2 714.32 MB | 261.46 MB | 2 452.86 MB | 90.4% |
| Виртуализация колонок | 1M (100 × 10K) | 230.47 MB | 80.24 MB | 150.23 MB | 65.2% |
| Kitchen sink | 800K (100K × 8) | 272.83 MB | 36.91 MB | 235.92 MB | 86.5% |
Паттерн стабилен: чем больше ячеек обрабатывает TanStack Table, тем значительнее экономия. Для маленьких таблиц (80 ячеек) разница незаметна (1-2%). Но в масштабе — а именно там память критична — улучшение трансформирующее.
Естественный вопрос: если методы на прототипе так хороши, почему не взять ES6-классы? Ведь методы классов тоже живут на прототипе.
Проблема в том, что TanStack Table передаёт методы по ссылке в мемоизированные функции,
обработчики событий и плагины. При этом контекст this теряется.
Стандартное решение — .bind(this), стрелочные функции или геттеры,
возвращающие привязанные методы — создаёт новый экземпляр функции на каждую
строку, сводя на нет всю экономию:
class Row {
constructor(data) {
this.id = data.id;
this.index = data.rowIndex;
// ...
// Если привязать методы здесь — они снова на каждый экземпляр:
this.getValue = this.getValue.bind(this); // новая функция на строку!
this.renderValue = this.renderValue.bind(this); // то же самое!
}
getValue(columnId) { /* ... */ }
renderValue(columnId) { /* ... */ }
}
С Object.create вы получаете лучшее из двух миров: разделение методов
на уровне прототипа, отсутствие накладных расходов на привязку и динамическое
разрешение this через цепочку прототипов в момент вызова. Методы
на прототипе используют this так же, как методы классов — но им никогда
не нужна привязка, потому что вызывающий код получает экземпляр напрямую.
Для разработчиков, не знакомых с механизмом, — краткое пояснение того, что делает
Object.create:
const proto = {
greet() { return `Привет, я ${this.name}`; }
};
const alice = Object.create(proto);
alice.name = 'Алиса';
const bob = Object.create(proto);
bob.name = 'Боб';
console.log(alice.greet()); // "Привет, я Алиса"
console.log(bob.greet()); // "Привет, я Боб"
// Обе используют ОДНУ функцию greet():
console.log(alice.greet === bob.greet); // true
// Но у каждой своё 'name':
console.log(alice.hasOwnProperty('name')); // true
console.log(alice.hasOwnProperty('greet')); // false — унаследовано
Ключевой момент: alice.greet === bob.greet даёт true,
потому что greet живёт на proto, а не на каждом
экземпляре. Метод хранится в памяти один раз, но оба объекта могут вызывать его
со своим собственным this.
PR с прототипным разделением был создан несколько лет назад Майклом Лейбманом. Его так и не приняли из-за одной критической проблемы: перечислимые методы прототипа.
В JavaScript методы, определённые на прототипе, по умолчанию имеют флаг
enumerable: true. Это значит, что они появляются в
Object.keys(), циклах for...in и spread-операциях:
const row = Object.create(rowPrototype);
Object.assign(row, { id: '1', index: 0 });
// Код V8 может делать так и получать неожиданные методы:
console.log(Object.keys(row));
// ['id', 'index'] — ОК, только собственные свойства
// Но spread и for...in перечисляют методы прототипа:
console.log({ ...row });
// { id: '1', index: 0, getValue: fn, renderValue: fn, ... } — ПЛОХО!
Исправление всех потребителей на использование hasOwnProperty или
Object.keys (который перечисляет только собственные свойства) было
обратно несовместимым изменением. V8 не могла его принять. V9, с её мажорным
номером и другими ломающими изменениями (новая система состояния, плагинная
архитектура), стала подходящим моментом.
Красота этой техники — в её универсальности. Любая библиотека или приложение, создающее множество однотипных объектов с одинаковым набором методов, может выиграть. Вот когда это имеет смысл:
Object.keys.Универсальный паттерн реализации:
// 1. Определяем разделяемый прототип
const entityPrototype = {
update(deltaTime) {
this.x += this.vx * deltaTime;
this.y += this.vy * deltaTime;
},
getPosition() {
return { x: this.x, y: this.y };
},
isOutOfBounds(width, height) {
return this.x < 0 || this.x > width ||
this.y < 0 || this.y > height;
},
};
// 2. Фабричная функция через Object.create + Object.assign
export function createEntity({ x, y, vx, vy, type }) {
const entity = Object.create(entityPrototype);
Object.assign(entity, { x, y, vx, vy, type });
return entity;
}
В этом примере с системой частиц из 10 000 сущностей каждая хранит только
5 числовых свойств. 3 метода разделяются. Без прототипного разделения каждая
частица несла бы свои копии update, getPosition
и isOutOfBounds — 30 000 объектов-функций против 3.
Ни один паттерн не универсален. Вот когда прототипное разделение не является правильным выбором:
Object.create(prototype), вы создаёте объекты, наследующие методы от общего прототипа, так что каждый экземпляр хранит только свои данные. Это радикально снижает расход памяти при создании тысяч однотипных объектов.История с памятью в TanStack Table V9 — напоминание о том, что самые эффективные оптимизации не всегда самые сложные. Глубокое понимание прототипной системы JavaScript — языковой возможности, которую многие разработчики используют ежедневно не задумываясь — открыло 90% сокращение памяти, от которого выигрывает каждый пользователь библиотеки.
Этот паттерн переносим. Строите ли вы дата-грид, виртуализированный список, игровой
движок или любую систему, создающую множество однотипных объектов —
Object.create с разделяемыми прототипами — это простое и эффективное
улучшение, почти ничего не стоящее при внедрении в мажорном релизе.
Если вы создаёте производительные JavaScript-приложения и нуждаетесь в экспертной помощи, свяжитесь со мной. Я специализируюсь на оптимизации React и Node.js с 20-летним опытом создания production-приложений, работающих в масштабе.
Работаете над JavaScript-приложением с проблемами памяти или производительности? Могу помочь оптимизировать компоненты с большими данными. Бесплатная консультация.