カスタム Module を自作して『Snabbdom』を機能拡張する - Cycle.js を学ぼう #7

wakamsha
15

カスタム Module を作ってみよう #1 - AnimationModule

まずは全体適用向けのカスタム Module です。画面遷移や表示切り替え時にアニメーションエフェクトを適用するカスタム Module を作ってみたいと思います。完成イメージはこちら。

サンプルコードはこちらから取得いただけます。

I. フックメソッドの引数の型を定義する

デフォルトでは VNode 型が渡されますが、自分で拡張することも可能です。

type ModuleNode = {
  elm: HTMLElement;
  data: {
    animations: {
      enter: boolean;
      leave: boolean;
    }
  }
} & VNode;

data を上記のように上書きしました。アニメーションエフェクトを表示時に適用するか、非表示になる時に適用するかどうかのフラグを定義しています。

II. 使用するフックメソッドを定義して雛形を作る

フックメソッドを定義します。

/**
 * 変数の初期化
 */
function pre() {}

/**
 * 表示アニメーションを適用する
 * @param _
 * @param node
 */
function create(_: ModuleNode, node: ModuleNode) {}

/**
 * 要素が更新される時 ( ※ 使用しない )
 */
function update() {}

/**
 * 要素が直接もしくは間接的に削除される時 ( ※ 使用しない )
 */
function destroy() {}

/**
 * 非表示アニメーションを適用して削除
 * @param vnode
 * @param fn
 */
function remove(vnode: ModuleNode, fn: () => void)) {}

/**
 * パッチプロセス終了後の後始末をする
 */
function post() {}

export const AnimationModule = {pre, create, update, destroy, remove, post};

updatedestroy は使用しないため引数を省略しています。メソッドの定義自体を省略することは出来ません。省略すると ( コンパイル ) エラーとなります。

III. 表示 / 非表示アニメーションを定義する

アニメーションの定義は CSS 側で行い、JavaScript 側ではクラスの付け外しだけを行うようにします。

.animation
  $duration = .2s

  transition transform $duration ease-in-out, opacity $duration
  transform none
  opacity 1

  &--enter
    transform none

  &--enter, &--leave
    opacity 0

  &--leave
    transition transform $duration ease-in-out, opacity $duration ease-in-out

  &--active
    &^[0]--enter
      transform translate3d(30px, 0, 0)
      position absolute
    &^[0]--leave
      transform translate3d(30px, 0, 0)

アニメーション対象となる要素にはベースクラスとして .animationを付与し、表示アニメーションを適用するものには .animation--enterを、非表示を適用するものには.animation--leaveをそれぞれ付与します。.animation--activeを付与するとアニメーションが実行されます。

IV. 各フックメソッドを実装する

⋮
let created: ModuleNode[] = [];

/**
 * 変数を初期化
 */
function pre() {
    created = [];
}

/**
 * 表示アニメーションを適用する
 * @param _
 * @param vnode
 */
function create(_: ModuleNode, vnode: ModuleNode) {
  if (!vnode.data.animations) return;
  vnode.elm.classList.add('animation');
  vnode.elm.classList.add('animation--active');
  if (vnode.data.animations.enter) {
    vnode.elm.classList.add('animation--enter');
  }
  created.push(vnode);
}

/**
 * 要素が更新される時 ( ※ 使用しない )
 */
function update(_: ModuleNode, vnode: ModuleNode) {
  if (!vnode.data.animations) return;
}

/**
 * 要素が直接もしくは間接的に削除される時 ( ※ 使用しない )
 * @param vnode
 */
function destroy(vnode: ModuleNode) {
  if (!vnode.data.animations) return;
}

/**
 * 非表示アニメーションを適用して削除
 * @param vnode
 * @param fn
 */
function remove(vnode: ModuleNode, fn: () => void) {
  vnode.elm.classList.add('animation--leave');
  setTimeout(() => fn(), 300);
}

/**
 * パッチプロセス終了後の後始末をする
 */
function post() {
  created.forEach(vnode => {
    if (vnode.data.animations.enter) {
      setTimeout(() => vnode.elm.classList.remove('animation--enter'), 300);
    }
  });
  created = [];
}

ポイントは二点です。createで表示アニメーションを適用のためのクラスを付与していますが、アニメーション終了後は .animation--enterを削除したいです。削除のタイミングは post でするのがベターですが、post は VNode を引数として受け取ることが出来ません。そのため、生成した DOM ノードを変数 created に格納しておくことで関節的に参照出来るようにします。削除処理が終わったら created を空にしておきます。

二点目は animations プロパティが設定されていない DOM ノードには一連の処理を適用しないために if 文で設定されているかを判定していることです。Module は全体適用すると全ての DOM ノードからこれらのメソッドが呼び出されるわけですが、animations が設定されていないと未定義エラーとなってしまいます。それを防ぐためにもきちんと判定処理を入れておくことが大切です。

これで AnimationModule が完成しました。

V. カスタム Module を取り込み、使用する

AnimationModule をアプリケーション層に取り込んで使えるようにします。表示 / 非表示の動きが分かりやすいところで『タブメニュー』を作成します。

type Sources = {
  DOM: DOMSource;
}

type Sinks = {
  DOM: Observable<vnode>;
}

/**
 * アプリケーション
 * @param so
 * @returns {{DOM: any}}
 */
function main(so: Sources): Sinks {
  const picture$ = so.DOM.select('#nav-picture').events('click').mapTo('picture');
  const messages$ = so.DOM.select('#nav-messages').events('click').mapTo('messages');

  const action$: Observable<string> = Observable.merge(picture$, messages$);

  const vdom$ = action$.startWith('picture').map(action => {
    return div([
      ul('.nav.nav-tabs', [
        li({
          class: {
            'active': action === 'picture'
          }
        }, [
          a('#nav-picture', 'Picture')
        ]),
        li({
          class: {
            'active': action === 'messages'
          }
        }, [
          a('#nav-messages', 'Messages')
        ])
      ]),
      action === 'picture'
        ? div('.well', {
          key: Math.random(),
          animations: { enter: true, leave: true }
        }, [
          img('.img-thumbnail', {attrs: {src: 'http://lorempixel.com/480/270/sports/'}})
        ])
        : div('.well', {
          key: Math.random(),
          animations: { enter: true }
        }, 'Curabitur aliquet quam id dui ...')
      ])
  });

  return {
    DOM: vdom$
  };
}

const drivers = {
  DOM: makeDOMDriver('#app', {
    modules: [
      PropsModule,
      StyleModule,
      ClassModule,
      AttrsModule,
      AnimationModule
    ]
 })
};

run(main, drivers);

タブのスタイルは Bootstrap を使用します。表示 / 非表示するコンテンツ部分には animations プロパティを指定します。また、毎回 create が実行されてアニメーションが適用されるように key プロパティに乱数を使って毎回異なる値を適用しています。

カスタム Module の取り込みは makeDOMDriver の第二引数の modules プロパティに含めれば OK です。あとは attrsclassといったデフォルトの Module と同じように使えます。

カスタム Module を作ってみよう #2 - PulseHook

canvas 内の要素に対して外から pulse の変化量を操作するデモです。完成イメージはこちら。

canvas を扱うとなると Driver で実装していまいがちですが、そうすると完全にアプリケーション層の外側の世界となってしまっていささか大袈裟です。画面のごく一部に適用したいという用途にも向きません。そんな時はカスタム Module として実装し、Hook として部分適用すると上手くいきます。

I. フックメソッドの引数の型を定義する

pulse の変化量を外から渡せるようにします。

type ModuleNode = {
  elm: HTMLElement;
  data: {
    range: number;
  }
} & VNode;

II. 使用するフックメソッドを定義して雛形を作る

全体適用の場合はフックメソッドを省略することが出来ませんでしたが、部分適用の場合は必要なメソッドだけを定義することが出来ます。今回はinsertupdatedestroyだけを使用します。

/**
 * 初期化処理
 * @param vnode
 */
function insert(vnode: ModuleNode) {}

/**
 * 変化量を更新する
 * @param _
 * @param vnode
 */
function update(_: ModuleNode, vnode: ModuleNode) {}

/**
 * 後片付け処理
 * @param _
 */
function destroy(_: ModuleNode) {}

export const PulseHook = {insert, update, destroy};

III. 各フックメソッドを実装する

実装自体はカスタム Module 解説の本質から離れるため、ここでは割愛します。サンプルコードを参照ください。ちなみに Canvas の操作に CreateJS を使用しております。

IV. カスタム Module を取り込み、Hook として使用する

PulseHook を取り込んで使ってみます。

type Sources = {
  DOM: DOMSource;
}

type Sinks = {
  DOM: Observable<vnode>;
}

/**
 * アプリケーション
 * @param so
 * @returns {{DOM: any}}
 */
function main(so: Sources): Sinks {
  const rangeInput$: Observable<event> = so.DOM.select('.range-slider').events('input');
  const changeVisibility$: Observable<event> = so.DOM.select('.visibility-checkbox').events('change');

  const range$ = rangeInput$.map((ev: Event) => +(ev.target as HTMLInputElement).value);
  const visibility$ = changeVisibility$.map((ev: Event) => (ev.target as HTMLInputElement).checked);

  const vdom$ = Observable.combineLatest(
    range$.startWith(1),
    visibility$.startWith(true),
    (range, visibility) => {
      return div([
        div('.module-controller', [
          input('.visibility-checkbox', { attrs: { type: 'checkbox', checked: visibility } }),
          input('.range-slider', { attrs: { type: 'range', min: 0, max: 4, step: .01, value: range } }),
        ]),
        visibility ?
          canvas('#my-canvas.stage', {
            hook: PulseHook,
            range,
          }) :
          null
      ])
    }
);

  return {
    DOM: vdom$
  };
}

const drivers = {
  DOM: makeDOMDriver('#cycleApp')
};

run(main, drivers);

使い方は対象となる仮想DOMノードの data 引数に Hook プロパティとして カスタム Module オブジェクトを渡し、それと並んでカスタム Module へ渡したい引数を指定します。

締め

以上、長くなりましたがカスタム Module を自作して Snabbdom を拡張する方法をご紹介しました。Cycle.js 同様、Snabbdom 自体も非常に軽量で薄ーく作られたライブラリであり、これ自体が提供している機能はさほど多くありません。しかし Module を自作して容易に拡張できることから可能性は無限大です。フックメソッドは充分に揃っており、全体適用だけでなく Hook を使うことで部分適用も可能となっているあたり、非常に完成度の高いライブラリと言えます ( 天才か?作者は天才なのか!? ) 。

今回のエントリを執筆するにあたっていろいろとググってみたものの、現状まともな情報は公式リポジトリの README で、それも微妙に情報が抜けていたりとなかなかどうして適当 だったりします。加えて日本語の情報はゼロということで執筆随分と時間がかかってしまいました1)2017年5月現在、Qiita に3本ほどエントリが挙がっていますが、公式 README を機械翻訳しただけのものが一本と『Hello World』程度のものが二本だけで、ちっとも役に立ちませんでした ('A`)。。完成度のわりにいまいち人気のないライブラリですが ( 失礼 ) 、どなたかの参考になれば幸いです。

脚注

脚注
1 2017年5月現在、Qiita に3本ほどエントリが挙がっていますが、公式 README を機械翻訳しただけのものが一本と『Hello World』程度のものが二本だけで、ちっとも役に立ちませんでした ('A`)。