Низкоуровневое управление SVG с высокоуровневым жизненным циклом React

Герберт Кинг — ноябрь 2021 г.

Наша цель: иметь возможность отображать и контролировать векторы D3 внутри компонента React.

Вещи, которые вам понадобятся:

React любит использовать JSX, и в большинстве случаев это здорово. Один из них — когда вы явно не пытаетесь использовать JSX, как с D3. D3 предпочитает использовать селекторы и данные. D3 не принадлежит нигде внутри функции рендеринга. Возникает вопрос: как вы используете D3 внутри React? Что ж, давайте решим эту проблему, создав 3 файла.

Для начала вам необходимо установить D3 в свой проект. Это так же просто, как npm install d3. Оттуда мы построим себе вектор. Просто чтобы установить сцену, это то, что будет контролировать фактический рисунок. Мы будем соблюдать правило «разделения интересов» и назначим наш вектор для рисования, обновления и изменения размера. Теперь посмотрим:

Вектор

// save in vectors/vector.js
import * as d3 from "d3"
class Vector {
  constructor(containerEl, props) {
    this.containerEl = containerEl
    this.props = props
    const { width, height } = props
    this.svg = d3.select(containerEl)
      .append('svg')
      .attr('width', width)
      .attr('height', height)
    this.svg.selectAll('path.lines') // you can draw other paths if you use classes
      .data([0])
      .enter()
        .append("path")
        .attr("class", "lines") // classes help you be specific and add more drawings
        .attr("fill", "none")
        .attr("stroke", "#000000")
        .attr("stroke-width", "0.5")
    this.update()
  }
  getDrawer() {
    const { count, amplitude, offset, multiplierX, multiplierY, width, height } = this.props
    const originX = (width/2)
    const originY = (height/2)
    const arc = Array.from({ length: count }, (_, i) =>; [
      originX + (amplitude * Math.sin(multiplierX * (i - offset))),
      originY + (amplitude * Math.cos(multiplierY * (i - offset)))
    ])
    return d3.line()(arc)
  }
  update() {
    const { svg } = this
    const drawer = this.getDrawer()
    this.svg.selectAll('path.lines').attr("d", drawer)
  }
}
export default Vector

Хорошо, у нас есть класс с именем `Vector`, внутри которого определены три метода. Если вы знакомы с объектно-ориентированным программированием, то это не должно вас удивлять. Для тех, кто этого не делает, мы хотим, чтобы наш вектор был как можно более автономным. Если мы хотим изменить какое-либо из наших свойств внутри, мы должны определить метод set___ , который присваивает значение this.___. А теперь посмотрим, что внутри.

Во-первых, это наш конструктор. Он принимает элемент контейнера, а также наши реквизиты. Мы сохраняем их оба в переменные экземпляра, чтобы использовать их позже. Затем мы выбираем элемент контейнера и добавляем svg для работы с нашими границами высоты и ширины. Наш реквизит — это основной способ получить информацию в векторе.

Следующая функция — собственно рисование. На мой взгляд, лучше всего, если это возможно, сохранить весь рисунок в одном методе. Мы используем svg, чтобы ввести несколько строк. Опять же, то, что здесь происходит, относится к тому, что вы хотите нарисовать; линии, круги, кривые, что угодно. Вы можете обойтись только этими первыми двумя функциями: построить и обновить.

Последняя функция, getDrawer, просто возвращает нам линию D3 для рисования. Это специфично для нашего вектора и будет меняться в зависимости от того, что вы хотите нарисовать. Здесь мы создаем массив точек графика, которые D3 соединит в одну линию. Мы используем простое гармоническое движение для вычисления точек данных, чтобы рисовать причудливые формы. После этой статьи, пожалуйста, поэкспериментируйте с множителями и посмотрите, что получится.

Аниматор

Теперь нам нужно что-то, что отвечает за наш вектор. Задача, как упоминалось априори, состоит в том, чтобы перенести наш вектор в функцию рендеринга React. Мы также хотим сделать некоторую абстракцию, если это возможно. Два события, сведения о которых мы хотим скрыть, будут изменение размера и таймеры. Их мы можем спрятать в компонент, но оставим это на потом. Наше решение — это компонент, который я называю Animator. Это будет место, где мы будем размещать наш повторяющийся код, например, создание экземпляров и очистку. Нашей целью было бы передать несколько переменных в наш аниматор от родителя, а затем положить этому конец.

Давайте посмотрим на код:

// components/animator.jsx
import React, { useEffect, useRef } from "react"
export default function Animator( props ) {
  const { vector, options, setVector, ...other } = props
  const refElement = useRef(null)
  const initVector = () => {
    const v = new vector(refElement.current, options)
    setVector(v)
  }
  useEffect(initVector, [])
  return (
      <div ref={refElement} {...other} />;
  )
}

Хорошо, вот у нас есть наш первый компонент React. Это кратко, но сделает для нас немного тяжелой работы. Как обычно для этих компонентов, у нас есть реквизиты, полученные от родителя. Мы также используем ref. Это вполне может быть новым для некоторых читателей, поэтому давайте быстро рассмотрим это.

Ссылка — это, по сути, то, как React позволяет помещать элементы HTML в переменные. Здесь мы передаем ссылку в div внизу. Это указывает, на какой элемент мы ссылаемся, и теперь мы можем использовать refElement.current для ссылки на него. Если вы помните предыдущий раздел, мы приняли containerEl в качестве аргумента конструктора. Вот как мы создаем этот элемент и можем использовать для него D3.

Другим основным свойством этого компонента, которое выполняет некоторую тяжелую работу, является раздел initVis. Мы используем здесь побочный эффект, обозначенный useEffect, для запуска некоторой функции только в самом начале жизненного цикла этого компонента (обозначается пустым массивом в качестве аргумента). Функция, которую мы используем, создаст экземпляр нашего вектора и создаст его. Кроме того, он соединяет вектор с родительским контейнером, обратите внимание на это в следующем разделе. Теперь обо всех сложных вещах позаботятся!

Родительский компонент

И последнее, но не менее важное: у нас будет автономный родительский контейнер, который передаст наш вектор и свойства в аниматор, который отрендерит наш милый рисунок! Об этом особо нечего сказать, поэтому давайте посмотрим на код:

// containers/vector.jsx
import React from "react"
import vector from "../vectors/vector.js"
import Animator from "../components/Animator.jsx"
let _vector
const setVector = (v) => { _vector = v }
export default function Vector( props )  {
  const multiplierX = 3
  const multiplierY = 1
  const options = {
    count: 1000,
    height: 700,
    width: 1300,
    amplitude: 300,
    length: 5,
    offset: 0,
    // play around with these numbers
    multiplierX,
    multiplierY,
  }
  return (
    <Animator
      vector={vector}
      setVector={setVector}
      options={options} />
  )
}

Итак… если вы понимаете React, то это будет довольно просто. Единственное, что следует упомянуть, это связанный с вектором код, расположенный в верхней части файла. Это небольшая хитрость, но это умный способ получить ссылку на наш вектор. Таким образом, мы можем делать все, что угодно, например, обновлять и устанавливать поля внутри этого родительского контейнера. Пригодится, когда вы захотите добавить кнопки и другие формы ввода.

Собираем все вместе

Если бы вы скопировали и вставили все приведенные выше фрагменты кода в правильные файлы, назвали бы их в соответствии с комментариями вверху, а затем отобразили бы родительский компонент где-то в вашем проекте, вы должны увидеть сюрприз!

Иди глубже

Хорошо, теперь, когда у нас есть рисунок на экране, есть несколько способов, которыми вы могли бы пойти дальше. Я предоставлю читателю две проблемы и рекомендуемые подходы для расширения возможностей этого шаблона.

Входы компонентного уровня

Наш рисунок довольно аккуратный, не поймите меня неправильно. Но на статическом рисунке мы далеко не продвинемся. Нас не так легко развлечь. Что делает эти рисунки круче, так это то, что ваши пользователи могут изменять их и видеть, как что-то меняется. Давайте добавим некоторые входные данные для этого случая. Для этого попробуйте выполнить следующие действия:

  • Давайте добавим внутрь нашего вектора D3 методы setMultiplierX и setMultiplierY. Они просто изменят реквизит следующим образом: this.props.multiplier_ = multiplier_. Также вызовите метод update (это важно!)
  • Добавьте к нашему родительскому компоненту две переменные состояния для наших множителей x и y, заменив уже существующие объявления.
  • Также добавьте два числовых ввода для нашего множителя x и y в раздел HTML нашего родительского компонента. Это будет выглядеть так: <input type=”number” value={multiplierY} onChange={setMultiplierYHandler} />.
  • Наконец, мы напишем обработчики множителей. Они должны вызвать метод вектора setMultiplier_ и обновить переменную состояния.
  • Попробуйте и, надеюсь, это сработает!

Таймеры

Теперь, возможно, мы хотим оживить эти рисунки. Возможно установить частоту кадров и обновлять вектор каждый кадр. Это можно сделать с помощью нескольких небольших шагов. Я перечислю их здесь:

  • Решите, что вы хотите изменить в своем векторе для каждого кадра. В нашем случае мы будем использовать свойство offset, так как оно играет роль переменной времени t в наших простых функциях гармонического движения.
  • Добавьте метод setOffset к нашему вектору D3. Он просто примет значение и выполнит this.props.offset = offset, а затем вызовет функцию обновления вектора.
  • Добавьте переменную состояния смещения к нашему родительскому компоненту, а также переменную time = 10, которая будет нашей частотой кадров, и step = 1, которая будет нашей дельтой.
  • Нам нужно что-то для вызова этой функции, поэтому давайте добавим функцию intervalHandler в наш родительский компонент. Это установит новое смещение (offset + step) с использованием как переменной состояния, так и векторного метода setFunction.
  • Передайте intervalHandler и time в аниматор в качестве реквизита.
  • Внимательно прочитайте следующий код, а затем вставьте его в Animator:
const setupTimer = () => {
  if ( !intervalCallback )  { return }
  const interval = setInterval(intervalCallback, time)
  return () => clearInterval(interval)
}
useEffect(setupTimer, [])