Давайте построим 3D-карусель

Примечание. Это часть серии Путешествие к разуму, посвященной переходу на ReasonML с ReactJS. Будьте на связи. Впереди еще много всего!
‹ Предыдущая | ‹ | "Следующий >"

Нажмите здесь, чтобы опубликовать эту статью в LinkedIn »

** Обновлен синтаксис по причине 3 **

В первой статье этой серии мы рассказали о Reason и о том, почему фронтенд-разработчик, такой как я, был бы заинтересован в его изучении.

Рад поделиться с вами своим первым проектом Reason: 3D-карусель разума.

У этой статьи двоякая цель.

  1. Чтобы продемонстрировать это, с помощью ReasonML / ReasonReact мы можем создавать веб-приложения, которые выполняют довольно интересные манипуляции с DOM и анимацию. Изучив основы, я не чувствую себя ограниченным разумом по сравнению с использованием JavaScript / ReactJS / и т. Д.
  2. Представить руководство для тех, кто имеет опыт работы с JavaScript / ReactJS по созданию своего первого веб-приложения Reason. Мы вместе воссоздадим 3D-карусель и попутно объясним концепции Reason и ReasonReact.

Прежде чем продолжить, я хотел бы сказать вам огромное спасибо! Всем на канале Discord за помощь в этом проекте. Спасибо вам всем за создание такого гостеприимного и знающего сообщества!

Настройка нашей среды разработчика

Вы можете начать создавать веб-приложения с помощью Reason несколькими способами. Каждый способ требует bsb. Давайте теперь это получим:

$ npm install -g bs-platform

Затем давайте создадим шаблон проекта. В рамках этого руководства воспользуемся параметром create-react-app, но помните о других параметрах.

Вариант 1: Чистый ReasonML

bsb -init my-first-app -theme basic-reason

Вариант 2: Минимальная ReasonReact

bsb -init my-react-app -theme react

Вариант 3: Создать приложение React (выбран для этого руководства)

yarn create react-app <app-name> -- --scripts-version reason-scripts

Инструменты редактора

Если вы используете macOS / Linux, давайте настроим ваш редактор. Следуйте инструкциям здесь (после установки cause-cli).

Когда я только начинал, у меня не было большого опыта работы со статически типизированными функциональными языками. Сначала я попытался создать 3D-карусель в чистом Reason (без ReasonReact) и быстро застрял в попытках работать с DOM. Одно из преимуществ ReasonReact заключается в том, что он позволяет абстрагировать DOM от нас. На практике это означало, что вместо того, чтобы беспокоиться о типах DOM, я просто использую типы, которые предоставляет нам Reason / ReasonReact и Bucklescript.

Изучение нашего шаблона проекта

Используя третий вариант, мы можем сразу запустить:

$ npm run start

чтобы увидеть знакомую страницу приветствия.

Начнем строить

Хотя я попытаюсь объяснить важные концепции по ходу дела, я все же рекомендую сначала прочитать документацию ReasonML и ReasonReact.

Наша текущая цель: создать простую трехмерную карусель, которая еще не вращается. Мы используем Reason для расчета CSS для каждой стороны в зависимости от количества сторон.

Заменить app.css на:

#root {
  height: 100vh;
  width: 100vw;
  overflow: hidden;
  position: relative;
  background: white;
  perspective: 62.5vw; /* hard-coded for now */
}
#carousel {
  position: absolute;
  transform-style: preserve-3d;
  backface-visibility: visible;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 50vw;
  height: 60vh;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  margin: auto;
  cursor: pointer;
}
#carousel figure {
  font-size: 3vw;
  position: absolute;
  background: rgba(220, 220, 220, 0.95);
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  border: 2px solid black;
  width: 100%;
  height: inherit;
  margin: 0;
  top: 0;
  left: 0;
  user-select: none;
}

Заменить app.re на:

[%bs.raw {|require('./app.css')|}];
let component = ReasonReact.statelessComponent("App");
let create_sides = (sides, radius) =>
  Array.init(
    sides,
    (index) => {
      let css =
        "rotate3d(0, 1, 0,"
        ++ (
          string_of_float(360.0 *. float_of_int(index) /. float_of_int(sides))
          ++ ("0deg) translate3d(0, 0," ++ (string_of_float(radius) ++ "0vw)"))
        );
      <figure key=(string_of_int(index)) style=(ReactDOMRe.Style.make(~transform=css, ()))>
        (ReasonReact.stringToElement(string_of_int(index + 1)))
      </figure>
    }
  );
let make = (_children) => {
  ...component,
  render: (_self) => {
    let sides = 8;
    let radius = 25.0 /. Js.Math.tan(180.0 /. float_of_int(sides) *. (Js.Math._PI /. 180.0));
    let transform =
      "translate3d(0, 0, -"
      ++ (string_of_float(radius) ++ ("0vw) rotateY(" ++ (string_of_float(0.0) ++ "0deg)")));
    <section id="carousel" style=(ReactDOMRe.Style.make(~transform, ()))>
      (ReasonReact.arrayToElement(create_sides(sides, radius)))
    </section>
  }
};

Большое спасибо Явару Амину за помощь с функцией create_sides в канале Discord.

Краткое описание app.re:

  • требуется файл CSS (если этот файл CSS не существует, будет выдана ошибка компилятора)
  • объявлен компонент без состояния (строка, переданная в ReasonReact.statelessComponent, используется только для целей отладки)
  • объявлена ​​функция с именем create_sides, которая использует Array.init и имеет тип int => float => array ReasonReact.reactElement, что означает, что она имеет два аргумента (int и float) и возвращает массив элементов React
  • объявлена ​​make функция, которая вызывается, когда index.re вызывает <App /> (аналогично ReactJS ’class App extends React.Component, за исключением того, что в нем используется композиция вместо наследования - как вы можете догадаться, заметив …component в следующей строке)
  • функция make имеет поле render
  • Единицы CSS, такие как vw и deg, имеют префикс 0, потому что числа с плавающей запятой всегда содержат десятичную дробь - 10 становится 10., а 10.deg недействителен CSS.

Ошибки компилятора

Первое, что мы замечаем после сохранения, - это ошибка компилятора.

Проблема здесь в том, что index.re включает опору message в компоненте приложения, но компонент приложения не принимает опору message. Удаление свойства unusedmessage исправляет ошибку, и наша 3D-карусель отрисовывается!

Примечание: в ReactJS неиспользуемые реквизиты не вызывают ошибок или предупреждений.

Совместимость Reason с JavaScript

При осмотре app.re мы видим:

[%bs.raw {|require('./app.css')|}];

Мы можем взаимодействовать с JavaScript, используя Bucklescript. Также в коде мы видим, что используются Js.Math.tan и Js.Math._PI. Bucklescript имеет множество встроенных типобезопасных привязок к JavaScript, но мы также можем написать свои собственные - как мы увидим позже в этой статье.

Некоторые отличия ReactJS

Мы уже видим довольно много общего между ReasonReact и ReactJS. В этом компоненте без состояния при изменении свойств компонент будет повторно отрисован.

Конечно, в этом примере есть только неиспользуемое свойство _children. Из документации:

Последней опорой должны быть дети. Если вы не используете его, просто проигнорируйте его, назвав _ или _children. Имена, начинающиеся с подчеркивания, не вызывают предупреждений компилятора, если они не используются.

На момент написания этой статьи Reason JSX требует пробелов и может содержать только «элементы» - <div>foo</div> становится <div> (ReasonReact.stringToElement "foo") </div>.

В Reason файлы - это модули. Обратите внимание, что в index.re нам не нужно включать app.re перед рендерингом в DOM. <App /> соответствует app.re.

Установка атрибута style также немного отличается:

<element style=(ReactDOMRe.Style.make(~color="red", ()))

Примечание. ReactDOMre.Style.make принимает unit - также известный как () - в качестве последнего аргумента. Исходя из JavaScript, сначала это не имело для меня смысла. Я думаю, что мы можем многое узнать о Reason, проанализировав эту функцию.

  1. Единица похожа на без аргумента в JavaScript.
  2. Все функции Reason принимают один аргумент. Функции с несколькими аргументами - это просто синтаксический сахар для каррирования функций с одним аргументом.
  3. Функции Reason могут иметь необязательные помеченные аргументы.
  4. color - необязательный помеченный аргумент - фактически, все свойства CSS реализованы как необязательные помеченные аргументы.
  5. Поскольку все свойства CSS являются необязательными помеченными аргументами для ReasonDOMRe.Style.make, вполне возможно, что ни один из них не используется.
  6. Передача unit в ReasonDOMRe.Style.make необходима для вызова функции, когда все аргументы являются необязательными.

Давай заставим его вращаться

Поздравьте себя с тем, что вы зашли так далеко. Я думаю, это окупится.

Наша текущая цель: узнать, как компоненты с отслеживанием состояния работают в ReasonReact и как работать с событиями DOM.

Чтобы повернуть карусель, нам просто нужно изменить значениеrotateY в элементе #carousel:

style="transform: translate3d(...) rotateY(0deg);"

Уборка

Вместо жесткого кодирования sides, radius & transform давайте примем их как реквизиты.

let make = (~sides, ~radius, ~transform, _children) => {
  ...component,
  render: (_self) =>
    <section id="carousel" style=(ReactDOMRe.Style.make(~transform, ()))>
      (ReasonReact.arrayToElement(create_sides(sides, radius)))
    </section>
};

Поскольку мы ранее жестко запрограммировали perspective в app.css, давайте удалим стили для #root в app.css.

Давайте переименуем app.re в carousel.re (аналогично app.css). Мы создадим container.re для управления состоянием 3D-карусели. Тогда у нас будет index.re рендеринг <Container sides=... friction=... /> вместо <App />.

container.css

#container {
  height: 100vh;
  width: 100vw;
  overflow: hidden;
  position: relative;
  background: white;
}

container.re

[%bs.raw {|require('./container.css')|}];
type action =
  | StartInteraction(int)
  | MoveInteraction(int)
  | EndInteraction(int)
  | Spin(float);
type values = {
  initial: ref(float),
  final: ref(float),
  current: ref(float),
  previous: ref(float)
};
type velocity = {
  current: ref(float),
  list: ref(list(float))
};
type css = {
  perspective: string,
  transform: string
};
type state = {
  requestAnimationFrameID: ref(int),
  rotation: ref(float),
  radius: float,
  isMouseDown: ref(bool),
  position: values,
  time: values,
  velocity,
  css
};
[@bs.val] external requestAnimationFrame : (unit => unit) => int = "requestAnimationFrame";
[@bs.val] external cancelAnimationFrame : int => unit = "cancelAnimationFrame";
[@bs.val] [@bs.scope "performance"] external now : unit => float = "now";
[@bs.val] [@bs.scope "Math"] external abs : float => float = "abs";
[@bs.val] [@bs.scope "Math"] external round : float => int = "round";
external unsafeAnyToArray : 'a => array('a) = "%identity";
let component = ReasonReact.reducerComponent("Container");
let findAndFilter = (~list, ~n, ~f) => {
  let rec aux = (list', n', acc) =>
    if (n' == 0) {
      acc
    } else {
      switch list' {
      | [] => []
      | [item, ...rest] =>
        if (f(item)) {
          aux(rest, n' - 1, [item, ...acc])
        } else {
          aux(rest, n', acc)
        }
      }
    };
  aux(list, n, [])
};
let rec sum = (list) =>
  switch list {
  | [] => 0.0
  | [item, ...rest] => item +. sum(rest)
  };
let averageLatestNonzeroVelocities = (velocities, n) => {
  let latestNonzeroVelocities = findAndFilter(~list=velocities, ~n, ~f=(item) => item != 0.0);
  sum(latestNonzeroVelocities) /. float_of_int(n)
};
let spinWithFriction = (state, reduce, friction, sides) => {
  let rec onAnimationFrame = (velocity', ()) => {
    state.velocity.current := velocity';
    if (state.isMouseDown^) {
      Js.log("The carousel has previously been spun.")
    } else if (abs(velocity') < friction) {
      cancelAnimationFrame(state.requestAnimationFrameID^);
      let degreesPerSide = 360.0 /. float_of_int(sides);
      let currentSide =
        if (state.rotation^ < 0.0) {
          round(abs(state.rotation^) /. degreesPerSide) mod sides
        } else {
          (sides - round(abs(state.rotation^) /. degreesPerSide) mod sides) mod sides
        };
      Js.log("You've landed on side " ++ (string_of_int(currentSide + 1) ++ "."))
    } else {
      Js.log(velocity');
      reduce((_) => Spin(velocity'), ());
      state.requestAnimationFrameID :=
        requestAnimationFrame(
          onAnimationFrame(velocity' > 0.0 ? velocity' -. friction : velocity' +. friction)
        )
    }
  };
  state.requestAnimationFrameID := requestAnimationFrame(onAnimationFrame(state.velocity.current^))
};
let make = (~sides, ~friction, _children) => {
  ...component,
  initialState: () => {
    let radius = 25.0 /. Js.Math.tan(180.0 /. float_of_int(sides) *. (Js.Math._PI /. 180.0));
    {
      requestAnimationFrameID: ref(0),
      radius,
      rotation: ref(0.0),
      isMouseDown: ref(false),
      position: {initial: ref(0.0), final: ref(0.0), current: ref(0.0), previous: ref(0.0)},
      time: {initial: ref(0.0), final: ref(0.0), current: ref(0.0), previous: ref(0.0)},
      css: {
        perspective: string_of_float(500.0 /. float_of_int(sides)) ++ "0vw",
        transform:
          "translate3d(0, 0, -"
          ++ (string_of_float(radius) ++ ("0vw) rotateY(" ++ (string_of_float(0.0) ++ "0deg)")))
      },
      velocity: {current: ref(0.0), list: ref([])}
    }
  },
  reducer: (action, state) =>
    switch action {
    | StartInteraction(clientX) =>
      state.isMouseDown := true;
      state.position.previous := float_of_int(clientX);
      state.time.initial := now();
      state.time.previous := now();
      state.velocity.current := 0.0;
      state.velocity.list := [];
      ReasonReact.NoUpdate
    | MoveInteraction(clientX) =>
      state.isMouseDown^ ?
        {
          state.position.current := float_of_int(clientX);
          state.time.current := now();
          state.rotation :=
            state.rotation^
            -. (state.position.previous^ -. state.position.current^)
            /. float_of_int(sides * 2);
          let transform =
            "translate3d(0, 0, -"
            ++ (
              string_of_float(state.radius)
              ++ ("0vw) rotateY(" ++ (string_of_float(state.rotation^) ++ "0deg)"))
            );
          let dx = state.position.current^ -. state.position.previous^;
          let dt = state.time.current^ -. state.time.previous^;
          state.velocity.current := dx /. dt;
          state.velocity.list := [state.velocity.current^, ...state.velocity.list^];
          state.position.previous := state.position.current^;
          state.time.previous := state.time.current^;
          ReasonReact.Update({...state, css: {perspective: state.css.perspective, transform}})
        } :
        ReasonReact.NoUpdate
    | EndInteraction(clientX) =>
      state.isMouseDown := false;
      state.position.final := float_of_int(clientX);
      state.time.final := now();
      state.velocity.current := averageLatestNonzeroVelocities(state.velocity.list^, 3);
      ReasonReact.SideEffects(
        ((self) => spinWithFriction(self.state, self.reduce, friction, sides))
      )
    | Spin(velocity) =>
      state.rotation := state.rotation^ +. velocity;
      let transform =
        "translate3d(0, 0, -"
        ++ (
          string_of_float(state.radius)
          ++ ("0vw) rotateY(" ++ (string_of_float(state.rotation^) ++ "0deg)"))
        );
      ReasonReact.Update({...state, css: {perspective: state.css.perspective, transform}})
    },
  render: (self) =>
    <div
      id="container"
      onMouseDown=(self.reduce((event) => StartInteraction(ReactEventRe.Mouse.clientX(event))))
      onMouseMove=(self.reduce((event) => MoveInteraction(ReactEventRe.Mouse.clientX(event))))
      onMouseUp=(self.reduce((event) => EndInteraction(ReactEventRe.Mouse.clientX(event))))
      onTouchStart=(
        self.reduce(
          (event) =>
            StartInteraction(
              unsafeAnyToArray(ReactEventRe.Touch.changedTouches(event))[0]##clientX
            )
        )
      )
      onTouchMove=(
        self.reduce(
          (event) =>
            MoveInteraction(unsafeAnyToArray(ReactEventRe.Touch.changedTouches(event))[0]##clientX)
        )
      )
      onTouchEnd=(
        self.reduce(
          (event) =>
            EndInteraction(unsafeAnyToArray(ReactEventRe.Touch.changedTouches(event))[0]##clientX)
        )
      )
      style=(ReactDOMRe.Style.make(~perspective=self.state.css.perspective, ()))>
      <Carousel sides radius=self.state.radius transform=self.state.css.transform />
    </div>
};

carousel.css

#carousel {
  position: absolute;
  transform-style: preserve-3d;
  backface-visibility: visible;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 50vw;
  height: 60vh;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  margin: auto;
  cursor: pointer;
}
#carousel figure {
  font-size: 3vw;
  position: absolute;
  background: rgba(220, 220, 220, 0.95);
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  border: 2px solid black;
  width: 100%;
  height: inherit;
  margin: 0;
  top: 0;
  left: 0;
  user-select: none;
}

carousel.re

[%bs.raw {|require('./carousel.css')|}];
let component = ReasonReact.statelessComponent("Carousel");
let create_sides = (sides, radius) =>
  Array.init(
    sides,
    (index) => {
      let css =
        "rotate3d(0, 1, 0,"
        ++ (
          string_of_float(360.0 *. float_of_int(index) /. float_of_int(sides))
          ++ ("0deg) translate3d(0, 0," ++ (string_of_float(radius) ++ "0vw)"))
        );
      <figure key=(string_of_int(index)) style=(ReactDOMRe.Style.make(~transform=css, ()))>
        (ReasonReact.stringToElement(string_of_int(index + 1)))
      </figure>
    }
  );
let make = (~sides, ~radius, ~transform, _children) => {
  ...component,
  render: (_self) =>
    <section id="carousel" style=(ReactDOMRe.Style.make(~transform, ()))>
      (ReasonReact.arrayToElement(create_sides(sides, radius)))
    </section>
};

index.re

ReactDOMRe.renderToElementWithId
  <Container sides=8 friction=0.0175 /> "root";

В браузере мы видим, что можем крутить нашу 3D-карусель!

Кусок за куском

Давайте разберемся с компонентом с отслеживанием состояния: container.re. Из документации:

Компоненты с отслеживанием состояния ReasonReact похожи на компоненты с отслеживанием состояния ReactJS, за исключением того, что в них встроена концепция редуктора (например, Redux).

Действия

Состояние container.re's может измениться только следующим образом.

type action =
  | StartInteraction(int)
  | MoveInteraction(int)
  | EndInteraction(int)
  | Spin(float);

Состояние

Следующие типы используются для формирования состояния container.re's. ref означает «изменчивый».

type values = {
  initial: ref(float),
  final: ref(float),
  current: ref(float),
  previous: ref(float)
};
type velocity = {
  current: ref(float),
  list: ref(list(float))
};
type css = {
  perspective: string,
  transform: string
};
type state = {
  requestAnimationFrameID: ref(int),
  rotation: ref(float),
  radius: float,
  isMouseDown: ref(bool),
  position: values,
  time: values,
  velocity,
  css
};

Важно отметить следующее различие между ReactJS и ReasonReact. В ReactJS каждый раз, когда состояние компонента изменяется, он повторно отображается. По этой причине в ReactJS у нас есть переменные вне состояния компонента. Из docs ReasonReact:

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

Таким образом, обновление state.isMouseDown не приведет к повторному рендерингу. Фактически, единственными полями, которые могут вызвать повторную визуализацию, являются state.radius & state.css. Остальное - переменные экземпляра.

Пользовательские привязки JavaScript

Как упоминалось ранее, с помощью Bucklescript мы можем создавать собственные привязки к JavaScript. Мы скоро увидим, как они используются.

[@bs.val] external requestAnimationFrame : (unit => unit) => int = "requestAnimationFrame";
[@bs.val] external cancelAnimationFrame : int => unit = "cancelAnimationFrame";
[@bs.val] [@bs.scope "performance"] external now : unit => float = "now";
[@bs.val] [@bs.scope "Math"] external abs : float => float = "abs";
[@bs.val] [@bs.scope "Math"] external round : float => int = "round";
external unsafeAnyToArray : 'a => array('a) = "%identity";

Последний используется для преобразования TouchList в array. Я не уверен в более безопасном способе сделать это, но если кто-нибудь знает, поделитесь, пожалуйста!

Начальное состояние

Из документации:

getInitialState ReactJS называется initialState в ReasonReact.

В Reason возвращается последний оператор функции. Здесь возвращается запись типа state. Это тип state, потому что он соответствует форме type state, определенной выше. В Reason выводятся типы.

initialState: () => {
    let radius = 25.0 /. Js.Math.tan(180.0 /. float_of_int(sides) *. (Js.Math._PI /. 180.0));
    {
      requestAnimationFrameID: ref(0),
      radius,
      rotation: ref(0.0),
      isMouseDown: ref(false),
      position: {initial: ref(0.0), final: ref(0.0), current: ref(0.0), previous: ref(0.0)},
      time: {initial: ref(0.0), final: ref(0.0), current: ref(0.0), previous: ref(0.0)},
      css: {
        perspective: string_of_float(500.0 /. float_of_int(sides)) ++ "0vw",
        transform:
          "translate3d(0, 0, -"
          ++ (string_of_float(radius) ++ ("0vw) rotateY(" ++ (string_of_float(0.0) ++ "0deg)")))
      },
      velocity: {current: ref(0.0), list: ref([])}
    }
  },

Редуктор

Редуктор сопоставляет шаблон со всеми возможными действиями и определяет, как каждое действие должно обновлять состояние. Здесь мы видим одну из наших пользовательских привязок JavaScript, now (которая привязана к performance.now).

Синтаксис := используется для обновления ref.

reducer: (action, state) =>
    switch action {
    | StartInteraction(clientX) =>
      state.isMouseDown := true;
      state.position.previous := float_of_int(clientX);
      state.time.initial := now();
      state.time.previous := now();
      state.velocity.current := 0.0;
      state.velocity.list := [];
      ReasonReact.NoUpdate
    ...

Важно отметить, что каждое действие должно возвращать одно из следующих значений (из документации):

  • ReasonReact.NoUpdate: не обновлять состояние.
  • ReasonReact.Update(state): обновить состояние.
  • ReasonReact.SideEffects((self) => unit): нет обновления состояния, но вызывает побочный эффект, например ReasonReact.SideEffects((_self) => Js.log("hello!")).
  • ReasonReact.UpdateWithSideEffects((state, self) => unit): обновить состояние, затем вызвать побочный эффект.

«Побочный эффект» примерно означает «что-то изменить». Js.log - это побочный эффект, потому что он записывает некоторые данные в консоль. Запись данных в базу данных также является побочным эффектом. Даже чтение данных из базы данных является побочным эффектом, поскольку выполнение функции, считывающей данные из базы данных, может давать разные результаты при многократном вызове.

Можно с уверенностью сказать, что StartInteraction не запускает повторный рендеринг. Но как мы запускаем действие?

Запуск действия

Функции render передается аргумент с именем self. Из документации:

  • Текущее значение state доступно через self.state, когда self передается вам в качестве аргумента некоторой функции.
  • В render вместо self.handle (который не позволяет обновлять состояние) вы должны использовать self.reduce. reduce принимает обратный вызов, передает ему событие (или любую другую полезную нагрузку обратного вызова, которую _107 _ / _ 108 _ / _ 109_ дает вам от MyDialog) и запрашивает действие в качестве возвращаемого значения.

В нашем коде полезная нагрузка обратного вызова равна clientX.

Помните, что StartInteraction принимает int. Это int это clientX.

render: (self) =>
    <div
      id="container"
      onMouseDown=(self.reduce((event) => StartInteraction(ReactEventRe.Mouse.clientX(event))))
      onMouseMove=(self.reduce((event) => MoveInteraction(ReactEventRe.Mouse.clientX(event))))
      onMouseUp=(self.reduce((event) => EndInteraction(ReactEventRe.Mouse.clientX(event))))
      ...
    </div>

У меня также могло быть что-то вроде:

type clientX = int;
type action =
  | StartInteraction(clientX)
  | MoveInteraction(clientX)
  | EndInteraction(clientX)
  | Spin(float);

Как запускается действие Spin?

Вы можете заметить, что выше показано только то, как могут запускаться StartInteraction, MoveInteraction и EndInteraction.

Spin используется для поворота карусели на основе вычисленной скорости перетаскивания пользователем. Как только пользователь перестает взаимодействовать с каруселью (т.е. onMouseUp), запускается действие Spin.

Итак, как нам поместить это в код?

Давайте рассмотрим случай EndInteraction в reducer.

| EndInteraction(clientX) =>
  state.isMouseDown := false;
  state.position.final := float_of_int(clientX);
  state.time.final := now();
  state.velocity.current := averageLatestNonzeroVelocities(state.velocity.list^, 3);
  ReasonReact.SideEffects(
    ((self) => spinWithFriction(self.state, self.reduce, friction, sides))
  )

Важная часть здесь:

ReasonReact.SideEffects((self) => spinWithFriction(self.state, self.reduce, friction, sides))

Spin - результат побочного эффекта! Вспомогательная функция с именем spinWithFriction принимает state (текущее состояние) и reduce (функция, которая может запускать действие - то же самое, что использовалось в render).

Примечание: ReasonReact.SideEffects передается self, а ReasonReact.Update - нет. Поскольку у нас есть доступ к self, мы можем обновлять состояние внутри вспомогательной функции.

Давайте проверим вспомогательную функцию spinWithFriction.

Примечание:

  • Здесь мы используем некоторые из наших пользовательских привязок JavaScript, например requestAnimationFrame и cancelAnimationFrame.
  • синтаксис ^ используется для получения текущего значения ref
  • rec используется для определения рекурсивной функции
  • velocity' - это просто имя переменной (произносится как простое число скорости)
  • reduce (() => Spin(velocity’), ()); используется для запуска действия из вспомогательной функции
let spinWithFriction = (state, reduce, friction, sides) => {
  let rec onAnimationFrame = (velocity', ()) => {
    state.velocity.current := velocity';
    if (state.isMouseDown^) {
      Js.log("The carousel has previously been spun.")
    } else if (abs(velocity') < friction) {
      cancelAnimationFrame(state.requestAnimationFrameID^);
      let degreesPerSide = 360.0 /. float_of_int(sides);
      let currentSide =
        if (state.rotation^ < 0.0) {
          round(abs(state.rotation^) /. degreesPerSide) mod sides
        } else {
          (sides - round(abs(state.rotation^) /. degreesPerSide) mod sides) mod sides
        };
      Js.log("You've landed on side " ++ (string_of_int(currentSide + 1) ++ "."))
    } else {
      Js.log(velocity');
      reduce((_) => Spin(velocity'), ());
      state.requestAnimationFrameID :=
        requestAnimationFrame(
          onAnimationFrame(velocity' > 0.0 ? velocity' -. friction : velocity' +. friction)
        )
    }
  };
  state.requestAnimationFrameID := requestAnimationFrame(onAnimationFrame(state.velocity.current^))
};

Давайте теперь рассмотрим Spin дело reducer.

| Spin(velocity) =>
  state.rotation := state.rotation^ +. velocity;
  let transform =
    "translate3d(0, 0, -"
    ++ (
      string_of_float(state.radius)
      ++ ("0vw) rotateY(" ++ (string_of_float(state.rotation^) ++ "0deg)"))
    );
  ReasonReact.Update({...state, css: {perspective: state.css.perspective, transform}})
},

Мы обновляем state новым CSS!

WithRetainedProps

Пример, размещенный на GitHub, включает третий компонент, который используется для отображения элементов управления для sides, friction и roundToNearestSide. Это очень похоже на то, как работает container.re в настоящее время. Это просто ReasonReact.reducerComponent.

Однако в примере с элементами управления container.re становится ReasonReact.reducerComponentWithRetainedProps.

Чтобы сравнить текущий и следующий реквизиты, компонент может быть:

  • a ReasonReact.statelessComponentWithRetainedProps
  • a ReasonReact.reducerComponentWithRetainedProps

Взгляните на документы и пример на GitHub. Обратите особое внимание на:

Основная идея состоит в том, что если container.re's реквизиты изменились, cancelAnimationFrame и сделать новый вызов spinWithFriction.

Резюме

Надеюсь, я достаточно объяснил ReasonReact, чтобы помочь вам начать работу с собственным приложением. Если что-то останется неясным, присоединяйтесь к каналу Discord, чтобы задать вопрос экспертам.

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

Пожалуйста, также дайте мне знать, могу ли я лучше объяснить концепцию или вы знаете, как лучше что-то сделать.

Спасибо за чтение. Надеюсь, вам понравится Reason так же, как и мне!