Все это построено на React, Redux и d3. Никаких уловок для анимации; просто немного сообразительности. Вот ссылка на репозиторий GitHub: https://github.com/Swizec/react-particles-experiment

Вот общий подход:

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

Затем мы используем некоторые вычисления d3 и обнаружение событий. В D3 есть отличные генераторы случайных чисел, поэтому мы пользуемся этим. Обработчики событий мыши и касания D3 вычисляют координаты относительно нашего SVG. Они нам нужны, а React с этим не справится. Его обработчики кликов основаны на узлах DOM, которые не соответствуют координатам (x, y). D3 смотрит на позицию курсора на экране, чтобы вычислить это.

Все координаты частиц находятся в магазине Redux. У каждой частицы также есть вектор движения. Магазин также содержит некоторые полезные флаги и общие параметры. Это позволяет нам рассматривать анимацию как преобразование данных. Я немного покажу вам, что я имею в виду.

Мы используем действия для передачи пользовательских событий, таких как создание частиц, запуск анимации, изменение положения мыши и т. Д. Для каждого requestAnimationFrame мы отправляем действие «продвинутой анимации».

При каждом действии редуктор вычисляет новое состояние для всего приложения. Сюда входят новые положения частиц для каждого шага анимации.

Когда магазин обновляется, React сбрасывает изменения через свойства, потому что координаты - это состояние, а частицы перемещаются.

Результат - плавная анимация.

Продолжайте читать, чтобы узнать подробности. Код тоже есть на GitHub.

Версия этой статьи будет представлена ​​в качестве главы в моей будущей книге React + d3js ES6.

3 компонента презентации

Начнем с компонентов презентации, потому что они наименее сложные. Для рендеринга коллекции частиц нам понадобятся:

  • частица без гражданства
  • частицы без гражданства
  • подходящее приложение

Ни один из них не содержит состояния, но приложение должно быть подходящим компонентом, чтобы мы могли использовать componentDidMount. Он нужен нам для подключения слушателей событий d3.

Компонент "Частица" представляет собой круг. Выглядит это так:

// src/components/Particles/Particle.jsx
import React, { PropTypes } from ‘react’;
const Particle = ({ x, y }) => (
<circle cx={x} cy={y} r="1.8" />
);
Particle.propTypes = {
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired
};
export default Particle;

Он принимает координаты x и y и возвращает круг SVG.

Компонент Particles не намного умнее - он возвращает список кругов, заключенных в группирующий элемент, например:

// src/components/Particles/index.jsx
import React, { PropTypes } from ‘react’;
import Particle from ‘./Particle’;
const Particles = ({ particles }) => (
<g>{particles.map(particle =>
    <Particle key={particle.id}
              {...particle} />
    )}
</g>
);
Particles.propTypes = {
particles: PropTypes.arrayOf(PropTypes.shape({
    id: PropTypes.number.isRequired,
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired
}).isRequired).isRequired
};
export default Particles;

Обратите внимание, что часть key = {particle.id}. Без него React жалуется бесконечно. Я думаю, что он используется, чтобы отличать похожие компоненты друг от друга, что заставляет работать причудливые алгоритмы.

Прохладный. Имея массив объектов {id, x, y}, мы можем визуализировать наши SVG-круги. Теперь приступим к нашему первому интересному компоненту: приложению.

Приложение позаботится о рендеринге сцены и подключении слушателей событий d3. Часть рендеринга выглядит так:

// src/components/index.jsx
import React, { PropTypes, Component } from ‘react’;
import ReactDOM from ‘react-dom’;
import d3 from ‘d3’;
import Particles from ‘./Particles’;
import Footer from ‘./Footer’;
import Header from ‘./Header’;
class App extends Component {
render() {
    return (
        <div onMouseDown={e => this.props.startTicker()} style={{overflow: 'hidden'}}>
             <Header />
             <svg width={this.props.svgWidth}
                  height={this.props.svgHeight}
                  ref="svg"
                  style={{background: 'rgba(124, 224, 249, .3)'}}>
                 <Particles particles={this.props.particles} />
             </svg>
             <Footer N={this.props.particles.length} />
         </div>
    );
}
}
App.propTypes = {
svgWidth: PropTypes.number.isRequired,
svgHeight: PropTypes.number.isRequired,
startTicker: PropTypes.func.isRequired,
startParticles: PropTypes.func.isRequired,
stopParticles: PropTypes.func.isRequired,
updateMousePos: PropTypes.func.isRequired
};
export default App;

Есть еще кое-что, но суть в том, что мы возвращаем ‹div› с заголовком, нижним колонтитулом и ‹svg›. Внутри ‹svg› мы используем частицы для рендеринга множества кругов. Не беспокойтесь о верхнем и нижнем колонтитулах; они текст.

Обратите внимание, что ядро ​​нашей функции рендеринга только говорит «Поместите сюда все частицы, пожалуйста». Нет ничего о том, что было перемещено, что нового или что больше не нужно. Нам этого не обязательно.

Получаем список координат и наивно отрисовываем круги. Об остальном позаботится React. Если вы спросите меня, это настоящее волшебство.

Да, и мы вызываем startTicker (), когда пользователь нажимает на нашу сцену. Нет причин запускать часы до генерации каких-либо частиц.

Слушатели событий D3

Чтобы пользователи могли генерировать частицы, мы должны подключить функции, упомянутые в propTypes. Выглядит это так:

// src/components/index.jsx
class App extends Component {
componentDidMount() {
    let svg = d3.select(this.refs.svg);

    svg.on('mousedown', () => {
        this.updateMousePos();
        this.props.startParticles();
    });
    svg.on('touchstart', () => {
        this.updateTouchPos();
        this.props.startParticles();
    });
    svg.on('mousemove', () => {
        this.updateMousePos();
    });
    svg.on('touchmove', () => {
        this.updateTouchPos();
    });
    svg.on('mouseup', () => {
        this.props.stopParticles();
    });
    svg.on('touchend', () => {
        this.props.stopParticles();
    });
    svg.on('mouseleave', () => {
        this.props.stopParticles();
    });

}

updateMousePos() {
    let [x, y] = d3.mouse(this.refs.svg);
    this.props.updateMousePos(x, y);
}

updateTouchPos() {
    let [x, y] = d3.touches(this.refs.svg)[0];
    this.props.updateMousePos(x, y);
}

Есть некоторые события, о которых стоит подумать:

  • mousedown и touchstart включают генерацию частиц
  • mousemove и touchmove обновляют местоположение мыши
  • mouseup, touchend и mouseleave отключают генерацию частиц

Затем у вас есть updateMousePos и ​​updateTouchPos, которые используют магию d3 для вычисления новых координат (x, y) относительно нашего элемента SVG. На этапе генерации частицы эти данные используются в качестве начального положения каждой частицы.

Да, это сложно. Какого черта мы просто не использовали встроенную в React обработку событий?

Потому что он недостаточно умен, чтобы определить положение мыши относительно области рисования. React знает, что мы щелкнули узел DOM. D3 творит чудеса, чтобы найти точные координаты.

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

Вот и все, что касается рендеринга и пользовательских событий. 107 строк кода.

6 Действия

Действия Redux - это причудливый способ сказать «Эй, кое-что случилось!». Это функции, которые вы вызываете для получения структурированных метаданных о том, что происходит.

У нас 6 действий. Самый сложный выглядит так:

// src/actions/index.jsx
export const CREATE_PARTICLES = ‘CREATE_PARTICLES’;
export function createParticles(N, x, y) {
return {
    type: CREATE_PARTICLES,
    x: x,
    y: y,
    N: N
};
}

Он сообщает системе создать N частиц в координатах (x, y). Вы увидите, как это работает, когда мы посмотрим на Редуктор, и вы увидите, как он срабатывает, когда мы посмотрим на Контейнер.

Действия должны иметь тип. Редукторы используют тип, чтобы решить, что делать.

Другие наши действия - это tickTime, tickerStarted, startParticles, stopParticles и updateMousePos. Вы можете догадаться, что они означают :)

1 компонент контейнера

Контейнеры - это компоненты React, очень похожие на биты представления. В отличие от компонентов представления, контейнеры взаимодействуют с хранилищем данных redux.

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

Вы можете думать о них как о монадах хранилища данных, если это помогает.

Суть нашего AppContainer выглядит так:

// src/containers/AppContainer.jsx
import { connect } from ‘react-redux’;
import React, { Component } from ‘react’;
import App from ‘../components’;
import { tickTime, tickerStarted, startParticles, stopParticles, updateMousePos, createParticles } from ‘../actions’;
class AppContainer extends Component {
componentDidMount() {
    const { store } = this.context;
    this.unsubscribe = store.subscribe(() =>
        this.forceUpdate()
    );
}

componentWillUnmount() {
    this.unsubscribe();
}
// …
render() {
    const { store } = this.context;
    const state = store.getState();

    return (
        <App {...state}
             startTicker={::this.startTicker}
             startParticles={::this.startParticles}
             stopParticles={::this.stopParticles}
             updateMousePos={::this.updateMousePos}
        />
    );
}
};
AppContainer.contextTypes = {
store: React.PropTypes.object
};
export default AppContainer;

Мы импортируем материал, а затем определяем AppContainer как соответствующий компонент React. Нам нужно использовать методы жизненного цикла, которые недоступны в компонентах без состояния.

Этот код состоит из трех важных частей:

  1. Подключаем магазин в componentDidMount и componentWillUnmount. Подпишитесь на изменение данных при монтировании, отпишитесь на размонтирование.
  2. При рендеринге мы предполагаем, что хранилище является нашим контекстом, используем getState (), а затем рендерим компонент, который мы упаковываем. В этом случае мы визуализируем компонент приложения.
  3. Чтобы получить хранилище в качестве нашего контекста, мы должны определить contextTypes. Иначе не пойдет. Это глубокая магия React.

Контексты хороши тем, что позволяют нам неявно передавать свойства. Контекст может быть любым, но Redux предпочитает, чтобы это был магазин. Он содержит все о состоянии приложения - как пользовательский интерфейс, так и бизнес-данные.

Отсюда и сравнение монад. Мой набег на Haskell мог сломать меня. Я везде вижу монады.

Если вам интересно, синтаксис {:: this.startTicker} взят из ES2016. Это эквивалент {this.startTicker.bind (this)}. Включите stage-0 в вашей конфигурации Babel, чтобы использовать его.

AppContainer обращается к магазину

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

// src/containers/AppContainer.jsx
startParticles() {
    const { store } = this.context;
    store.dispatch(startParticles());
}

stopParticles() {
    const { store } = this.context;
    store.dispatch(stopParticles());
}

updateMousePos(x, y) {
    const { store } = this.context;
    store.dispatch(updateMousePos(x, y));
}

Это шаблон. Функция действия дает нам этот объект {type: ..}, и мы отправляем его в магазин.

Когда это происходит, Redux использует наш редуктор для создания нового экземпляра дерева состояний. Мы поговорим об этом подробнее в следующем разделе.

Во-первых, мы должны взглянуть на обратный вызов startTicker. Здесь начинается наша магия.

startTicker() {
    const { store } = this.context;

    let ticker = () => {
        if (store.getState().tickerStarted) {
            this.maybeCreateParticles();
            store.dispatch(tickTime());

            window.requestAnimationFrame(ticker);
        }
    };

    if (!store.getState().tickerStarted) {
        console.log("Starting ticker");
        store.dispatch(tickerStarted());
        ticker();
    }
}

Уф. Не волнуйтесь, если вы этого не «поймете» сразу. На создание у меня ушло несколько часов.

Это запускает наш цикл анимации. Некоторые могут назвать это игровым циклом.

Он отправляет действие tickTime при каждом requestAnimationFrame. Это означает, что каждый раз, когда браузер готов к рендерингу, мы получаем возможность обновить хранилище данных Redux. Теоретически это 60 раз в секунду, но это зависит от многих факторов. Поищи это.

startTicker обновляет магазин в два этапа:

  1. Установите флажок tickerStarted и запускайте тикер только в том случае, если он еще не запущен. Это гарантирует, что мы не будем пытаться запускать несколько кадров анимации на кадр рендеринга. В результате мы можем быть наивными относительно привязки startTicker к onMouseDown.
  2. Создайте функцию тикера, которая генерирует частицы, отправляет действие tickTime и рекурсивно вызывает себя при каждом requestAnimationFrame. Мы проверяем флаг tickerStarted каждый раз, чтобы потенциально остановить время.

Да, это означает, что мы асинхронно отправляем redux-действия. Все нормально; подобные вещи Just Working (tm) - одно из главных преимуществ неизменяемых данных.

Сама функция mightCreateParticles не слишком интересна. Он получает координаты (x, y) из store.mousePos, проверяет флаг generateParticles и отправляет действие createParticles.

Это контейнер. 83 строки кода.

1 Редуктор

Сладкий. После запуска действий и рисования пора взглянуть на всю логику нашего генератора частиц. Мы сделаем это всего за 33 строки кода и некоторые изменения.

В порядке. Честно? Это много изменений. Но наиболее интересны 33 строки, которые составляют изменения CREATE_PARTICLES и TIME_TICK.

Вся наша логика заключена в редукторе. Дэн Абрамов говорит думать о редукторах как о функции, которую вы вставили в .reduce (). Это действительно самый простой способ подумать об этом - учитывая состояние и набор изменений, как мне создать новое состояние?

Упрощенный пример мог бы выглядеть так:

let sum = [1,2,3,4].reduce((sum, i) => sum+i, 0);

Для каждого числа возьмите предыдущую сумму и сложите число.

Наш генератор частиц - более сложная версия этого. Он берет текущее состояние приложения, включает действие и возвращает новое состояние приложения. Для простоты мы поместим все в один и тот же редуктор и воспользуемся большим оператором switch, чтобы решить, что делать, на основе action.type.

В более крупных приложениях мы бы разбили это на несколько редукторов, но основные принципы остались прежними.

Начнем с основ:

// src/reducers/index.js
const Gravity = 0.5,
randNormal = d3.random.normal(0.3, 2),
  randNormal2 = d3.random.normal(0.5, 1.8);
const initialState = {
particles: [],
particleIndex: 0,
particlesPerTick: 5,
svgWidth: 800,
svgHeight: 600,
tickerStarted: false,
generateParticles: false,
mousePos: [null, null]
};
function particlesApp(state = initialState, action) {
switch (action.type) {
default:
        return state;
}
}
export default particlesApp;

Это наш редуктор.

Мы начали с гравитационной постоянной и двух случайных генераторов. Затем мы определили состояние по умолчанию:

  • пустой список частиц
  • индекс частиц, который я немного объясню
  • количество частиц, которые мы хотим генерировать на каждом тике
  • размер svg по умолчанию
  • и два флага и mousePos для генератора

Наш редуктор пока ничего не меняет. Важно всегда возвращать хотя бы неизменное состояние.

Обновите состояние, чтобы оживить

Для большинства действий наш редуктор обновляет одно значение. Нравится:

// src/reducers/index.js
switch (action.type) {
    case 'TICKER_STARTED':
        return Object.assign({}, state, {
            tickerStarted: true
        });
    case 'START_PARTICLES':
        return Object.assign({}, state, {
            generateParticles: true
        });
    case 'STOP_PARTICLES':
        return Object.assign({}, state, {
            generateParticles: false
        });
    case 'UPDATE_MOUSE_POS':
        return Object.assign({}, state, {
            mousePos: [action.x, action.y]
        });

Несмотря на то, что мы меняем только значения логических флагов и двузначных массивов, мы должны создать новое состояние. Всегда создавайте новое состояние. Redux полагается на неизменность состояния приложения.

Вот почему мы используем Object.assign ({},… каждый раз. Он создает новый пустой объект, заполняет его текущим состоянием, а затем перезаписывает определенные значения новыми.

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

Два наиболее важных обновления состояния - тик анимации и создание частиц - выглядят следующим образом:

case 'CREATE_PARTICLES':
        let newParticles = state.particles.slice(0),
            i;

        for (i = 0; i < action.N; i++) {
            let particle = {id: state.particleIndex+i,
                            x: action.x,
                            y: action.y};

            particle.vector = [particle.id%2 ? -randNormal() : randNormal(),
                               -randNormal2()*3.3];

            newParticles.unshift(particle);
        }

        return Object.assign({}, state, {
            particles: newParticles,
            particleIndex: state.particleIndex+i+1
        });
    case 'TIME_TICK':
        let {svgWidth, svgHeight} = state,
            movedParticles = state.particles
                                  .filter((p) =>
                                      !(p.y > svgHeight || p.x < 0 || p.x > svgWidth))
                                  .map((p) => {
                                      let [vx, vy] = p.vector;
                                      p.x += vx;
                                      p.y += vy;
                                      p.vector[1] += Gravity;
                                      return p;
                                  });

        return Object.assign({}, state, {
            particles: movedParticles
        });

Похоже, это связка кода. Вроде, как бы, что-то вроде. Он разложен.

Первая часть - CREATE_PARTICLES - копирует все текущие статьи в новый массив и добавляет в начало новые частицы action.N. В моих тестах это оказалось более плавным, чем добавление частиц в конце. Каждая частица начинает жизнь в (action.x, action.y) и получает случайный вектор движения.

Это плохо с точки зрения Redux. Редукторы должны быть чистыми функциями, а случайность по своей природе нечиста. Думаю, в этом случае все в порядке.

Другой возможный подход - внедрить эту логику в действие. Это имеет свои преимущества, но усложняет возможность увидеть всю логику в одном месте. Так или иначе …

Вторая часть - TIME_TICK - не копирует частицы (хотя, возможно, и должна). Массивы передаются по ссылке, поэтому мы в любом случае изменяем существующие данные. Это плохо, но не слишком. Однозначно работает шустрее :)

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

Это простой способ реализовать ускорение.

Вот и все. Наш редуктор готов. Наш генератор частиц работает. Наша вещь плавно оживляет. \ o /

Важные открытия

Создавая этот генератор частиц в React и Redux, я сделал три важных открытия:

  1. Redux намного быстрее, чем я думал. Вы могли подумать, что создавать новую копию дерева состояний для каждого цикла анимации - это безумие, но это работает хорошо. Вероятно, мы по большей части создаем только неглубокую копию, что и объясняет скорость.
  2. Добавление в массивы JavaScript происходит медленно. Как только мы удаляем около 300 частиц, добавление новых становится заметно медленным. Прекратите добавлять частицы, и вы получите плавную анимацию. Это указывает на то, что создание частиц происходит медленно: либо добавление в массив, либо создание экземпляров компонентов React, либо создание узлов SVG DOM.
  3. SVG тоже работает медленно. Чтобы проверить вышеприведенную гипотезу, я заставил генератор создавать 3000 частиц при первом щелчке. Скорость анимации сначала ужасная и становится нормальной примерно при 1000 частицах. Это говорит о том, что неглубокое копирование больших массивов и перемещение существующих узлов SVG происходит быстрее, чем добавление нового. Вот гифка:

Плавник

Вот и все. Анимации сделаны с помощью React, Redux и d3. Новая сверхдержава? Может быть.

Вот резюме:

  • React обрабатывает рендеринг
  • d3 вычисляет вещи
  • Redux обрабатывает состояние
  • координаты элемента - состояние
  • менять координаты при каждом запросе
  • магия

Не забывайте: это и другие интересные вещи появятся в новой версии моей книги React + d3, которая выходит в этом месяце. :)

дальнейшее чтение

Где мы подталкиваем генератор частиц к плавному восприятию 20 000 элементов. Даже на мобильном телефоне.











Нравится то, что вы читаете?

Я Swizec, помешанный на шляпе. Я стараюсь каждый день публиковать что-то новое (например, этот пост) в своем блоге. Обычно речь идет о JavaScript, React и программировании. Если это похоже на вашу чашку чая, зарегистрируйтесь или подпишитесь на меня здесь, на Medium.