Интеграция D3 с Vue: ref, onMounted, watch

Интеграция D3.js с Vue 3 строится вокруг разделения ответственности: Vue управляет состоянием и реактивностью, D3 отвечает за низкоуровневую отрисовку и манипуляции SVG/DOM. Основная архитектурная ошибка при работе с этими инструментами — попытка позволить им одновременно управлять одними и теми же DOM-узлами. Правильный подход заключается в том, что Vue предоставляет контейнер и данные, а D3 полностью контролирует содержимое внутри этого контейнера.

В Vue 3 доступ к реальному DOM-элементу осуществляется через ref. Для D3 это ключевая точка входа: библиотека не должна «искать» элементы через селекторы глобально, а должна работать строго внутри выделенного узла.

<template>
  <div ref="chartContainer"></div>
</template>

<script setup>
import { ref } from 'vue'
import * as d3 from 'd3'

const chartContainer = ref(null)
</script>

chartContainer остаётся пустым до момента монтирования компонента. Любые операции D3 до этого этапа невозможны, так как DOM-узел ещё не существует.

Инициализация графика через onMounted

Жизненный цикл Vue требует инициализации D3-рендера в onMounted. Это гарантирует наличие DOM и предотвращает ошибки доступа к несуществующим элементам.

<script setup>
import { ref, onMounted } from 'vue'
import * as d3 from 'd3'

const chartContainer = ref(null)

onMounted(() => {
  const width = 600
  const height = 300

  const svg = d3.select(chartContainer.value)
    .append('svg')
    .attr('width', width)
    .attr('height', height)

  svg.append('circle')
    .attr('cx', 100)
    .attr('cy', 100)
    .attr('r', 40)
    .attr('fill', 'steelblue')
})
</script>

На этом этапе создаётся базовая структура визуализации. D3 получает полный контроль над содержимым SVG внутри контейнера Vue.

Реактивные данные и watch

Основная сложность интеграции — обновление графика при изменении данных. Vue предоставляет реактивность, а watch используется для отслеживания изменений и перерисовки визуализации.

<script setup>
import { ref, onMounted, watch } from 'vue'
import * as d3 from 'd3'

const chartContainer = ref(null)

const data = ref([10, 40, 25, 70, 50])

let svg

onMounted(() => {
  svg = d3.select(chartContainer.value)
    .append('svg')
    .attr('width', 600)
    .attr('height', 300)

  renderChart(data.value)
})

watch(data, (newData) => {
  renderChart(newData)
}, { deep: true })

function renderChart(dataset) {
  const circles = svg.selectAll('circle')
    .data(dataset)

  circles
    .enter()
    .append('circle')
    .merge(circles)
    .attr('cx', (d, i) => i * 60 + 50)
    .attr('cy', 150)
    .attr('r', d => d / 2)
    .attr('fill', 'teal')

  circles.exit().remove()
}
</script>

Механизм watch позволяет синхронизировать реактивное состояние Vue с императивной моделью D3. При изменении массива данных запускается функция перерисовки.

Полный цикл data join в связке Vue + D3

Ключевая концепция D3 — data join: enter, update, exit. В связке с Vue это становится основным способом обновления визуализации без пересоздания SVG.

Обновление данных без пересоздания SVG

function renderChart(dataset) {
  const selection = svg.selectAll('rect')
    .data(dataset)

  selection.enter()
    .append('rect')
    .merge(selection)
    .attr('x', (d, i) => i * 50)
    .attr('y', d => 300 - d)
    .attr('width', 40)
    .attr('height', d => d)
    .attr('fill', 'orange')

  selection.exit().remove()
}

Vue отвечает за изменение dataset, D3 — за минимально необходимое обновление DOM.

watch vs watchEffect в контексте D3

Хотя watchEffect может автоматически отслеживать зависимости, в связке с D3 предпочтительнее watch, так как он даёт контроль над источником изменений и предотвращает лишние перерисовки.

watch(data, (newVal, oldVal) => {
  renderChart(newVal)
})

При использовании сложных структур данных важно учитывать глубокое наблюдение:

watch(data, renderChart, { deep: true })

Глубокий watch полезен, но может приводить к избыточным вызовам отрисовки при частых изменениях вложенных объектов.

Очистка и пересоздание графика

При изменении размеров контейнера или смене набора данных иногда требуется полная очистка SVG. Это делается через D3 внутри реактивного контекста Vue.

function clearChart() {
  d3.select(chartContainer.value).selectAll('*').remove()
}

Комбинация с watch позволяет полностью пересобирать визуализацию при необходимости:

watch(data, (newData) => {
  clearChart()
  renderChart(newData)
})

Такой подход менее эффективен, но проще при сложной логике.

Разделение слоёв ответственности

Корректная архитектура интеграции:

  • Vue:

    • хранение данных
    • реактивные вычисления
    • управление состоянием интерфейса
  • D3:

    • построение SVG
    • масштабирование
    • оси, графики, геометрия
    • обновление DOM через data join

Нарушение этого разделения приводит к конфликтам: Vue может перерисовать DOM, который D3 уже модифицировал, что вызывает нестабильное поведение.

Масштабирование и пересчёт при изменении данных

Реактивные данные часто требуют пересчёта шкал D3:

const xScale = d3.scaleLinear().range([0, 600])
const yScale = d3.scaleLinear().range([300, 0])

watch(data, (dataset) => {
  xScale.domain([0, dataset.length])
  yScale.domain([0, d3.max(dataset)])

  renderChart(dataset)
})

Шкалы не должны храниться в Vue, так как они являются частью визуализационной логики D3.

Связка с несколькими реактивными источниками

При наличии нескольких ref-источников данные объединяются внутри watch:

watch([data, selectedIndex], ([newData, index]) => {
  renderChart(newData, index)
})

Это позволяет синхронизировать визуализацию с интерфейсными состояниями Vue.

Типовая структура компонента

<script setup>
import { ref, onMounted, watch } from 'vue'
import * as d3 from 'd3'

const chartContainer = ref(null)
const data = ref([5, 15, 25, 10])

let svg

onMounted(() => {
  svg = d3.select(chartContainer.value)
    .append('svg')
    .attr('width', 600)
    .attr('height', 300)

  renderChart(data.value)
})

watch(data, (val) => {
  renderChart(val)
})

function renderChart(dataset) {
  const bars = svg.selectAll('rect')
    .data(dataset)

  bars.enter()
    .append('rect')
    .merge(bars)
    .attr('x', (d, i) => i * 50)
    .attr('y', d => 300 - d * 10)
    .attr('width', 40)
    .attr('height', d => d * 10)

  bars.exit().remove()
}
</script>

<template>
  <div ref="chartContainer"></div>
</template>

Такая структура обеспечивает стабильную синхронизацию реактивного состояния Vue и императивного рендеринга D3 без конфликтов владения DOM.