Прототипы TanStack Table V9 — экономия памяти 90%
Технический разбор · Июнь 2026

Незаметный рефакторинг, сэкономивший
90% памяти в TanStack Table V9

Простой паттерн — хранение методов на разделяемых прототипах вместо объектных литералов — сократил потребление памяти с 272 MB до 27 MB для больших таблиц. Как это работает и как применить в своих проектах.

Олег Максимов 25 июня 2026 12 мин чтения

Введение

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 и ещё с десяток методов от плагинов.

V8: объектный литерал — каждый экземпляр несёт ВСЕ методы До
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 не дедуплицирует их, потому что каждый объектный литерал создаёт свежие экземпляры функций.

Решение: разделяемые прототипы с Object.create

Исправление элегантно и концептуально просто: создайте один объект-прототип для каждого типа (Row, Column, Cell, Header) со всеми методами, затем используйте Object.create(prototype) для создания экземпляров, наследующих методы от общего прототипа. Свойства с данными устанавливаются через Object.assign на каждый экземпляр.

V9: разделяемый прототип — методы определены ОДИН РАЗ После
// Разделяемый прототип — создаётся один раз
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».

272.58 MB → 27.28 MB Строки с пагинацией (100 000 × 8 колонок) — экономия 90%
Сценарий Ячеек (строк × колонок) 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%). Но в масштабе — а именно там память критична — улучшение трансформирующее.

Почему не использовать классы JavaScript?

Естественный вопрос: если методы на прототипе так хороши, почему не взять 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 так же, как методы классов — но им никогда не нужна привязка, потому что вызывающий код получает экземпляр напрямую.

Как это работает: механика цепочки прототипов JavaScript

Для разработчиков, не знакомых с механизмом, — краткое пояснение того, что делает 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.

Почему не сделали это в Table V8?

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, с её мажорным номером и другими ломающими изменениями (новая система состояния, плагинная архитектура), стала подходящим моментом.

Как применить этот паттерн в своих проектах

Красота этой техники — в её универсальности. Любая библиотека или приложение, создающее множество однотипных объектов с одинаковым набором методов, может выиграть. Вот когда это имеет смысл:

Универсальный паттерн реализации:

// 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.

Компромиссы и ограничения

Ни один паттерн не универсален. Вот когда прототипное разделение не является правильным выбором:

Часто задаваемые вопросы

Что такое разделение прототипов (prototype sharing) в JavaScript?
Это техника, при которой методы хранятся на общем объекте-прототипе вместо определения на каждом экземпляре. Используя Object.create(prototype), вы создаёте объекты, наследующие методы от общего прототипа, так что каждый экземпляр хранит только свои данные. Это радикально снижает расход памяти при создании тысяч однотипных объектов.
Сколько памяти сэкономил TanStack Table V9 с прототипным разделением?
До 90% для больших таблиц. В бенчмарке с пагинацией (100 000 строк × 8 колонок) память снизилась с 272.58 MB (V8) до 27.28 MB (V9) — экономия 245.30 MB. При миллионе строк V8 использовал 2.7 GB, а V9 — всего 257 MB. Результат стабилен для всех сценариев.
Почему не использовать классы JavaScript вместо Object.create?
Методы классов работают нормально при прямом вызове, но TanStack Table передаёт методы по ссылке в мемоизированные функции и плагины. Привязка контекста (.bind(), стрелочные функции) создаёт замыкание на каждый экземпляр, теряя преимущество в памяти. Object.create с разделяемыми прототипами решает это — this разрешается динамически через цепочку прототипов во время вызова.
Можно ли использовать прототипное разделение в своих проектах?
Да. Любая библиотека или приложение, создающее множество однотипных объектов (строки таблиц, узлы деревьев, сущности игр), может выиграть. Особенно эффективно для дата-гридов, виртуализированных списков и библиотек с плагинной архитектурой. Паттерн: Object.create(prototype) + Object.assign(data) в фабричной функции.
Какие недостатки у прототипного разделения?
Основной минус — ломающее изменение: код с Object.keys(), for...in или spread получит методы прототипа, если они не помечены как non-enumerable через Object.defineProperty. Решение — использовать Object.keys или hasOwnProperty. Также V8 чуть сложнее оптимизирует цепочки прототипов, но экономия памяти значительно перевешивает.
Почему эту оптимизацию не внедрили в TanStack Table V8?
PR с прототипным разделением создали несколько лет назад, но не приняли из-за ломающего изменения — перечислимые методы прототипа ломали код с for...in и spread. V9, с её мажорным номером и другими ломающими изменениями, стала подходящим моментом для внедрения.
Сколько строк может обработать TanStack Table V9?
Согласно бенчмаркам, V9 может работать с 10-16 миллионами строк до лимита памяти браузера (4 GB) — 10-кратное улучшение по сравнению с V8 (1-1.5 млн строк). Для большинства приложений ограничением станет рендеринг DOM, но запас для анализа данных и админ-панелей значительно вырос.

Заключение

История с памятью в TanStack Table V9 — напоминание о том, что самые эффективные оптимизации не всегда самые сложные. Глубокое понимание прототипной системы JavaScript — языковой возможности, которую многие разработчики используют ежедневно не задумываясь — открыло 90% сокращение памяти, от которого выигрывает каждый пользователь библиотеки.

Этот паттерн переносим. Строите ли вы дата-грид, виртуализированный список, игровой движок или любую систему, создающую множество однотипных объектов — Object.create с разделяемыми прототипами — это простое и эффективное улучшение, почти ничего не стоящее при внедрении в мажорном релизе.

Если вы создаёте производительные JavaScript-приложения и нуждаетесь в экспертной помощи, свяжитесь со мной. Я специализируюсь на оптимизации React и Node.js с 20-летним опытом создания production-приложений, работающих в масштабе.

Контакты

Нужна помощь с производительностью?

Работаете над JavaScript-приложением с проблемами памяти или производительности? Могу помочь оптимизировать компоненты с большими данными. Бесплатная консультация.