При построении визуализаций на D3.js размер SVG-контейнера часто
зависит не от фиксированных значений, а от структуры интерфейса: ширины
боковой панели, размеров окна браузера, сетки CSS Grid или
flex-контейнера. Обычный обработчик window.onresize
отслеживает только изменение окна браузера, но не реагирует на локальные
изменения размеров отдельных DOM-элементов.
API ResizeObserver решает эту проблему, позволяя
наблюдать за изменением размеров конкретного контейнера.
Основные сценарии применения:
Простейшая визуализация часто создаётся так:
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);
Недостатки:
Срабатывает только при изменении окна браузера.
Не реагирует на:
Может вызывать лишние перерисовки.
ResizeObserverconst 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
<div id="chart"></div>
#chart {
width: 100%;
height: 400px;
}
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);
Обычно адаптивная визуализация состоит из нескольких этапов:
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) {
}
Такой подход предотвращает:
const data = [
{ x: 0, y: 10 },
{ x: 1, y: 30 },
{ x: 2, y: 20 },
{ x: 3, y: 50 },
{ x: 4, y: 40 }
];
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");
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));
}
ResizeObserverconst observer = new ResizeObserver(entries => {
const { width, height } =
entries[0].contentRect;
render(width, height);
});
observer.observe(container);
Плохой подход:
svg.remove();
И повторное создание:
d3.select("#chart")
.append("svg");
Недостатки:
Гораздо эффективнее обновлять существующие элементы.
viewBoxИногда адаптивность достигается через viewBox.
svg.attr("viewBox", `0 0 ${width} ${height}`);
ResizeObserver и viewBoxНаиболее гибкий вариант:
svg
.attr("width", "100%")
.attr("height", "100%")
.attr("viewBox", `0 0 ${width} ${height}`);
preserveAspectRatiosvg.attr(
"preserveAspectRatio",
"xMidYMid meet"
);
Популярные режимы:
| Значение | Назначение |
|---|---|
meet |
Сохраняет пропорции |
slice |
Заполняет контейнер |
none |
Игнорирует пропорции |
Часто высота фиксирована.
const observer = new ResizeObserver(entries => {
const width =
entries[0].contentRect.width;
render(width, 400);
});
function render(width) {
const height = width * 0.6;
svg
.attr("width", width)
.attr("height", height);
}
.wrapper {
display: flex;
}
.sidebar {
width: 300px;
}
.chart {
flex: 1;
}
При изменении sidebar график автоматически перестраивается.
.dashboard {
display: grid;
grid-template-columns:
1fr 2fr;
}
График реагирует на изменение grid-layout без участия
window.resize.
ResizeObserver может срабатывать очень часто.
render(width, height);
будет вызываться десятки раз.
let timeout;
const observer = new ResizeObserver(entries => {
clearTimeout(timeout);
timeout = setTimeout(() => {
const { width, height } =
entries[0].contentRect;
render(width, height);
}, 100);
});
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);
});
});
Преимущества:
Плохой пример:
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();
Без этого возникают:
useEffect(() => {
const observer =
new ResizeObserver(entries => {
const { width, height } =
entries[0].contentRect;
render(width, height);
});
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
onMounted(() => {
observer = new ResizeObserver(entries => {
const { width, height } =
entries[0].contentRect;
render(width, height);
});
observer.observe(container.value);
});
onBeforeUnmount(() => {
observer.disconnect();
});
onMount(() => {
const observer =
new ResizeObserver(entries => {
const { width, height } =
entries[0].contentRect;
render(width, height);
});
observer.observe(container);
return () => observer.disconnect();
});
При изменении размеров требуется:
circles
.attr("cx", d => x(d.x))
.attr("cy", d => y(d.y));
При изменении ширины необходимо:
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;
}
if (width < 300) {
svg.selectAll("*").remove();
svg.append("text")
.text("Недостаточно места");
return;
}
clipPathПри адаптивности элементы могут выходить за границы.
svg.append("clipPath")
Позволяет:
После resize необходимо обновлять:
zoom.extent([
[0, 0],
[width, height]
]);
Иначе zoom-area будет некорректной.
Brush также зависит от размеров.
brush.extent([
[0, 0],
[width, height]
]);
Плавное изменение:
path
.transition()
.duration(300)
.attr("d", line);
Ошибка:
ResizeObserver loop completed with undelivered notifications
Возникает, когда resize вызывает изменение размеров, которое снова вызывает resize.
observer => render => resize => observer
Плохо:
container.style.width = width + 10 + "px";
requestAnimationFramerequestAnimationFrame(() => {
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);
});
function observeResize(
element,
callback
) {
const observer =
new ResizeObserver(entries => {
const rect =
entries[0].contentRect;
callback(rect);
});
observer.observe(element);
return observer;
}
observeResize(container, rect => {
render(rect.width, rect.height);
});
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();
}
};
}
При десятках графиков resize может быть дорогим.
Стратегии оптимизации:
| API | Назначение |
|---|---|
ResizeObserver |
Изменение размеров |
MutationObserver |
Изменение DOM |
IntersectionObserver |
Видимость элемента |
const resizeObserver =
new ResizeObserver(...);
const intersectionObserver =
new IntersectionObserver(...);
Например:
График можно строить только при появлении на экране.
if (entry.isIntersecting) {
render();
}
D3 может использоваться не только с SVG.
canvas.width = width;
canvas.height = height;
ResizeObserver особенно полезен для canvas-графиков.
Для canvas:
const ratio =
window.devicePixelRatio;
canvas.width = width * ratio;
canvas.height = height * ratio;
| SVG | Canvas |
|---|---|
| Легче адаптировать | Требует полной перерисовки |
| DOM-based | Bitmap-based |
| Удобнее для UI | Лучше для больших данных |
Плохо:
function render() {
new ResizeObserver(...);
}
Нужно наблюдать контейнер layout, а не SVG.
Может уничтожить производительность.
Особенно критично для SPA.
Современная архитектура D3.js практически всегда предполагает:
ResizeObserver становится центральным механизмом
синхронизации размеров контейнера и состояния визуализации, позволяя
строить масштабируемые и производительные графические интерфейсы на
D3.js.