ResizeObserver и реакция на изменение контейнера

При построении визуализаций на D3.js размер SVG-контейнера часто зависит не от фиксированных значений, а от структуры интерфейса: ширины боковой панели, размеров окна браузера, сетки CSS Grid или flex-контейнера. Обычный обработчик window.onresize отслеживает только изменение окна браузера, но не реагирует на локальные изменения размеров отдельных DOM-элементов.

API ResizeObserver решает эту проблему, позволяя наблюдать за изменением размеров конкретного контейнера.

Основные сценарии применения:

  • адаптивные графики;
  • реакция на изменение размеров панели;
  • перестроение шкал;
  • обновление SVG;
  • responsive dashboard;
  • автоматическая подстройка canvas и SVG;
  • интеграция D3.js с flexbox и CSS Grid.

Проблема фиксированных размеров

Простейшая визуализация часто создаётся так:

const width = 800;
const height = 400;

const svg = d3
  .select("#chart")
  .append("svg")
  .attr("width", width)
  .attr("height", height);

Подход работает только при фиксированной вёрстке. Если контейнер становится уже:

#chart {
  width: 100%;
}

SVG останется прежнего размера, что приведёт к:

  • выходу графика за границы;
  • неправильным шкалам;
  • обрезке элементов;
  • смещению подписей;
  • деформации интерактивности.

Ограничения window.resize

Классический подход:

window.addEventListener("resize", redraw);

Недостатки:

  1. Срабатывает только при изменении окна браузера.

  2. Не реагирует на:

    • изменение flex-layout;
    • открытие sidebar;
    • появление scroll;
    • изменение размеров родителя;
    • анимации layout.
  3. Может вызывать лишние перерисовки.


Базовый синтаксис ResizeObserver

const observer = new ResizeObserver(entries => {
  for (const entry of entries) {
    console.log(entry.contentRect.width);
    console.log(entry.contentRect.height);
  }
});

observer.observe(element);

Объект entry

Содержит информацию о размере элемента:

entry.contentRect.width
entry.contentRect.height

Простая адаптивная SVG-визуализация

HTML

<div id="chart"></div>

CSS

#chart {
  width: 100%;
  height: 400px;
}

Javascript

const container = document.getElementById("chart");

const svg = d3
  .select(container)
  .append("svg");

function render(width, height) {
  svg
    .attr("width", width)
    .attr("height", height);

  svg.selectAll("*").remove();

  svg
    .append("circle")
    .attr("cx", width / 2)
    .attr("cy", height / 2)
    .attr("r", 80)
    .attr("fill", "steelblue");
}

const observer = new ResizeObserver(entries => {
  const { width, height } = entries[0].contentRect;

  render(width, height);
});

observer.observe(container);

Архитектура responsive D3-графиков

Обычно адаптивная визуализация состоит из нескольких этапов:

  1. Получение размеров контейнера.
  2. Перестроение шкал.
  3. Обновление SVG.
  4. Обновление осей.
  5. Пересчёт координат.
  6. Обновление интерактивности.

Разделение init() и render()

Правильная архитектура:

const svg = d3.select("#chart")
  .append("svg");

const g = svg.append("g");

Инициализация

function init() {
  g.append("g").attr("class", "x-axis");
  g.append("g").attr("class", "y-axis");
}

Перерисовка

function render(width, height) {

}

Такой подход предотвращает:

  • повторное создание DOM;
  • утечки памяти;
  • дублирование элементов;
  • падение производительности.

Полноценный responsive line chart

Данные

const data = [
  { x: 0, y: 10 },
  { x: 1, y: 30 },
  { x: 2, y: 20 },
  { x: 3, y: 50 },
  { x: 4, y: 40 }
];

Создание SVG

const container = document.getElementById("chart");

const svg = d3
  .select(container)
  .append("svg");

const g = svg.append("g");

const xAxisGroup = g.append("g");
const yAxisGroup = g.append("g");

const path = g.append("path");

Render-функция

function render(width, height) {

  const margin = {
    top: 20,
    right: 20,
    bottom: 40,
    left: 50
  };

  const innerWidth =
    width - margin.left - margin.right;

  const innerHeight =
    height - margin.top - margin.bottom;

  svg
    .attr("width", width)
    .attr("height", height);

  g.attr(
    "transform",
    `translate(${margin.left}, ${margin.top})`
  );

  const x = d3
    .scaleLinear()
    .domain(d3.extent(data, d => d.x))
    .range([0, innerWidth]);

  const y = d3
    .scaleLinear()
    .domain([0, d3.max(data, d => d.y)])
    .range([innerHeight, 0]);

  const line = d3
    .line()
    .x(d => x(d.x))
    .y(d => y(d.y));

  path
    .datum(data)
    .attr("fill", "none")
    .attr("stroke", "steelblue")
    .attr("stroke-width", 3)
    .attr("d", line);

  xAxisGroup
    .attr(
      "transform",
      `translate(0, ${innerHeight})`
    )
    .call(d3.axisBottom(x));

  yAxisGroup
    .call(d3.axisLeft(y));
}

Подключение ResizeObserver

const observer = new ResizeObserver(entries => {

  const { width, height } =
    entries[0].contentRect;

  render(width, height);
});

observer.observe(container);

Почему нельзя полностью удалять SVG

Плохой подход:

svg.remove();

И повторное создание:

d3.select("#chart")
  .append("svg");

Недостатки:

  • дорогостоящие операции DOM;
  • потеря transition;
  • потеря обработчиков событий;
  • мерцание;
  • проблемы с производительностью.

Гораздо эффективнее обновлять существующие элементы.


Использование viewBox

Иногда адаптивность достигается через viewBox.

svg.attr("viewBox", `0 0 ${width} ${height}`);

Преимущества

  • масштабирование SVG;
  • простая responsive-модель;
  • меньше перерасчётов.

Недостатки

  • шрифты масштабируются;
  • толщина линий изменяется;
  • сложнее контролировать layout;
  • оси могут выглядеть неаккуратно.

Комбинация ResizeObserver и viewBox

Наиболее гибкий вариант:

svg
  .attr("width", "100%")
  .attr("height", "100%")
  .attr("viewBox", `0 0 ${width} ${height}`);

Работа с preserveAspectRatio

svg.attr(
  "preserveAspectRatio",
  "xMidYMid meet"
);

Популярные режимы:

Значение Назначение
meet Сохраняет пропорции
slice Заполняет контейнер
none Игнорирует пропорции

Реакция на изменение только ширины

Часто высота фиксирована.

const observer = new ResizeObserver(entries => {

  const width =
    entries[0].contentRect.width;

  render(width, 400);
});

Автоматическая высота через aspect ratio

function render(width) {

  const height = width * 0.6;

  svg
    .attr("width", width)
    .attr("height", height);
}

ResizeObserver внутри Flexbox

CSS

.wrapper {
  display: flex;
}

.sidebar {
  width: 300px;
}

.chart {
  flex: 1;
}

При изменении sidebar график автоматически перестраивается.


ResizeObserver внутри CSS Grid

.dashboard {
  display: grid;

  grid-template-columns:
    1fr 2fr;
}

График реагирует на изменение grid-layout без участия window.resize.


Избежание лишних перерисовок

ResizeObserver может срабатывать очень часто.

Проблема

render(width, height);

будет вызываться десятки раз.


Debounce

let timeout;

const observer = new ResizeObserver(entries => {

  clearTimeout(timeout);

  timeout = setTimeout(() => {

    const { width, height } =
      entries[0].contentRect;

    render(width, height);

  }, 100);
});

Throttle

let waiting = false;

const observer = new ResizeObserver(entries => {

  if (waiting) return;

  waiting = true;

  requestAnimationFrame(() => {

    const { width, height } =
      entries[0].contentRect;

    render(width, height);

    waiting = false;
  });
});

Использование requestAnimationFrame

Наиболее эффективный способ:

const observer = new ResizeObserver(entries => {

  requestAnimationFrame(() => {

    const { width, height } =
      entries[0].contentRect;

    render(width, height);
  });
});

Преимущества:

  • синхронизация с repaint;
  • меньше layout thrashing;
  • плавный UI.

Layout Thrashing

Плохой пример:

const width =
  container.offsetWidth;

svg.attr("width", width);

const height =
  container.offsetHeight;

Постоянное чтение и запись layout может вызывать forced reflow.


Правильная стратегия

Сначала чтение:

const width = rect.width;
const height = rect.height;

Потом запись:

svg.attr("width", width);

Отписка от наблюдения

Важно при SPA-приложениях.

observer.disconnect();

Без этого возникают:

  • memory leaks;
  • лишние обработчики;
  • удержание DOM в памяти.

Интеграция с React

useEffect(() => {

  const observer =
    new ResizeObserver(entries => {

      const { width, height } =
        entries[0].contentRect;

      render(width, height);
    });

  observer.observe(ref.current);

  return () => observer.disconnect();

}, []);

Интеграция с Vue

onMounted(() => {

  observer = new ResizeObserver(entries => {

    const { width, height } =
      entries[0].contentRect;

    render(width, height);
  });

  observer.observe(container.value);
});

onBeforeUnmount(() => {
  observer.disconnect();
});

Интеграция с Svelte

onMount(() => {

  const observer =
    new ResizeObserver(entries => {

      const { width, height } =
        entries[0].contentRect;

      render(width, height);
    });

  observer.observe(container);

  return () => observer.disconnect();
});

Responsive scatterplot

При изменении размеров требуется:

  • пересчёт шкал;
  • обновление осей;
  • изменение координат точек.
circles
  .attr("cx", d => x(d.x))
  .attr("cy", d => y(d.y));

Responsive bar chart

При изменении ширины необходимо:

bars
  .attr("x", d => x(d.name))
  .attr("width", x.bandwidth());

Адаптивные подписи

Подписи часто начинают пересекаться.

Подходы

Поворот текста

.selectAll("text")
.attr("transform", "rotate(-45)");

Сокрытие части подписей

.tickValues(filteredTicks)

Динамический размер шрифта

.attr("font-size", width / 50)

Реакция на минимальные размеры

Иногда график нельзя корректно показать на маленькой ширине.

if (width < 300) {
  return;
}

Placeholder для мобильных размеров

if (width < 300) {

  svg.selectAll("*").remove();

  svg.append("text")
    .text("Недостаточно места");

  return;
}

Использование clipPath

При адаптивности элементы могут выходить за границы.

svg.append("clipPath")

Позволяет:

  • скрывать overflow;
  • корректно анимировать графики;
  • ограничивать zoom.

ResizeObserver и zoom

После resize необходимо обновлять:

zoom.extent([
  [0, 0],
  [width, height]
]);

Иначе zoom-area будет некорректной.


ResizeObserver и brush

Brush также зависит от размеров.

brush.extent([
  [0, 0],
  [width, height]
]);

Анимация при resize

Плавное изменение:

path
  .transition()
  .duration(300)
  .attr("d", line);

Потенциальная проблема Resize Loop

Ошибка:

ResizeObserver loop completed with undelivered notifications

Возникает, когда resize вызывает изменение размеров, которое снова вызывает resize.


Причина бесконечного цикла

observer => render => resize => observer

Как избежать Resize Loop

Не изменять размеры контейнера внутри observer

Плохо:

container.style.width = width + 10 + "px";

Использование requestAnimationFrame

requestAnimationFrame(() => {
  render(width, height);
});

Проверка изменений

let prevWidth = 0;

const observer = new ResizeObserver(entries => {

  const width =
    entries[0].contentRect.width;

  if (width === prevWidth) return;

  prevWidth = width;

  render(width, 400);
});

Использование нескольких графиков

document
  .querySelectorAll(".chart")
  .forEach(container => {

    const observer =
      new ResizeObserver(entries => {

        const rect =
          entries[0].contentRect;

        renderChart(
          container,
          rect.width,
          rect.height
        );
      });

    observer.observe(container);
  });

Создание универсального helper

function observeResize(
  element,
  callback
) {

  const observer =
    new ResizeObserver(entries => {

      const rect =
        entries[0].contentRect;

      callback(rect);
    });

  observer.observe(element);

  return observer;
}

Использование helper

observeResize(container, rect => {

  render(rect.width, rect.height);
});

Паттерн responsive chart factory

function createChart(container) {

  const svg = d3
    .select(container)
    .append("svg");

  function render(width, height) {

  }

  const observer =
    new ResizeObserver(entries => {

      const rect =
        entries[0].contentRect;

      render(rect.width, rect.height);
    });

  observer.observe(container);

  return {
    destroy() {
      observer.disconnect();
    }
  };
}

Производительность больших dashboard

При десятках графиков resize может быть дорогим.

Стратегии оптимизации:

  • debounce;
  • throttle;
  • virtual rendering;
  • уменьшение числа transition;
  • reuse DOM;
  • отказ от полного redraw;
  • batching обновлений.

ResizeObserver против MutationObserver

API Назначение
ResizeObserver Изменение размеров
MutationObserver Изменение DOM
IntersectionObserver Видимость элемента

Совместное использование observers

const resizeObserver =
  new ResizeObserver(...);

const intersectionObserver =
  new IntersectionObserver(...);

Например:

  • resize отвечает за layout;
  • intersection — за lazy rendering.

Lazy rendering графиков

График можно строить только при появлении на экране.

if (entry.isIntersecting) {
  render();
}

Responsive Canvas с D3

D3 может использоваться не только с SVG.

canvas.width = width;
canvas.height = height;

ResizeObserver особенно полезен для canvas-графиков.


HiDPI и devicePixelRatio

Для canvas:

const ratio =
  window.devicePixelRatio;

canvas.width = width * ratio;
canvas.height = height * ratio;

SVG против Canvas при resize

SVG Canvas
Легче адаптировать Требует полной перерисовки
DOM-based Bitmap-based
Удобнее для UI Лучше для больших данных

Частые ошибки

Повторное создание observer

Плохо:

function render() {
  new ResizeObserver(...);
}

Наблюдение не за тем элементом

Нужно наблюдать контейнер layout, а не SVG.


Полный redraw на каждый пиксель resize

Может уничтожить производительность.


Игнорирование cleanup

Особенно критично для SPA.


ResizeObserver как основа responsive-визуализаций

Современная архитектура D3.js практически всегда предполагает:

  • динамические контейнеры;
  • адаптивный layout;
  • responsive SVG;
  • реакцию на flex/grid;
  • поддержку мобильных устройств;
  • обновление шкал и осей в реальном времени.

ResizeObserver становится центральным механизмом синхронизации размеров контейнера и состояния визуализации, позволяя строить масштабируемые и производительные графические интерфейсы на D3.js.