LoginSignup
1
1

More than 3 years have passed since last update.

mithril.jsのrouteでSPAを作るときにhrefにつまずいた件

Posted at

はじめに

最近ときめきアイドルスコアランキングサイトを作りなおそうとしている @AinoMegumi から依頼を受けてASP.NET CoreでのWebページ制作に協力している。

その中でランキングページをどう実装するかという話になった。

まずランキングの種類が

  • 総合
  • 月間(今月)
  • 月間(先月)

とあり、またときめきアイドルというゲームには

  • Easy
  • Normal
  • Hard
  • Extreme

という難易度があるらしく、最大で3×4通りのページが各曲ごとに必要になることがわかった。

同じ曲の複数のランキングを切り替えてみたいというユーザーの行動が予想されたが、ASP.NET Coreで実装するとアクセス回数が増えてしまう。ここはクライアントサイドで切り替えを実装するべきだ。

一方で単にdisplay: none;を切り替えるような実装では3×4通りのページが一意なURLにならないのでURLを共有することで見ているものを伝えられなくなる。

つまりルーティングが必要になってきた。

ライブラリ選定

こういうSPAを作るライブラリはvue.jsとかReactとかとか山程あるらしいのだが、自分はmithril.jsしか使ったことがない。

mithril.jsはJSXみたいなIntelliSenseに優しくない謎な拡張には頼らず、pureなJavaScriptで記述できるライブラリで、ライブラリが小さく、描画も高速である。

See the Pen Mithril Component Example by Pat Cavit (@tivac) on CodePen.

ちなみについ先日、公式Twitterができた(これまでは開発者個人のTwitterで宣伝とかしていた)
@mithrildotjs

mithril jsでのルーティング

route(root, defaultRoute, routes) - Mithril.js

m.routeを使うことでできるらしい。

var Edit = {
    view: function(vnode) {
        return [
            m(Menu),
            m("h1", "Editing " + vnode.attrs.id)
        ]
    }
}
m.route(document.body, "/edit/1", {
    "/edit/:id": Edit,
})

今回のように複数パラメータがあるルーティングももちろんできる。

It's possible to have multiple arguments in a route, for example /edit/:projectID/:userID would yield the properties projectID and userID on the component's vnode attributes object.

ルーティングへのリンクを貼るにはm.route.Linkを使う。ちなみにm.route.LINKと書いて動かないとわめいて公式チャットに質問投げて秒殺されたのは内緒だ。

通常aタグを使うリンクを

m('a', {href: "/test"})

のように書くところを置き換えて次のようになる。

m(m.route.Link, {href: "/test"})

dataの欠損に対応したデザイン

最初に

最大で3×4通りのページ

と書いたのは、ランキングへデータが投稿されていないことがあるからだ。例えばExtreme難易度のデータ投稿が3ヶ月前が最後だったら総合ランキングにしかExtreme難易度の情報がない。

つまり以下のようにボタンを並べられず、

image.png

このようにデータがあるボタンだけ出したい。

image.png

hrefをどう作るか問題

ランキングの種類ボタンと難易度ボタンは別のクラスにして分離することにした。ただし実装が似かよるので継承を使った。

  class RankingType extends NavBase {
    /**
     * @param {string} rankingType
     */
    constructor(rankingType) {
      super(
        rankingTypeList,
        decideDefault(rankingTypeList, rankingType, 'total'),
        'ranking-type',
        v => `${rankingTypeDescriptionMap.get(v)}ランキング`
      );
    }
  }
  class GameMode extends NavBase {
    /**
     * @param {string} rankingType
     * @param {string} gameMode
     */
    constructor(rankingType, gameMode) {
      const list = createGameModeList(input[rankingType]);
      const selected = decideDefault(list, gameMode, 'extreme');
      super(list, selected, 'game-mode', v => v.charAt(0).toUpperCase() + v.slice(1));
      console.log(`${this.constructor.name}#constructor(): selected=${selected}`, list);
      this.prefix_ = rankingType;
    }
  }

今選択されているボタンはNavBaseクラスに

    /**
     * @returns {string}
     */
    get current() {
      return this.current_;
    }
    set current(selected) {
      console.log(`${this.constructor.name}#set current(${selected})`);
      this.current_ = selected;
    }

こんなのがあるのでとってこれる。

ルーティングはまずこれらを持つMainクラスを作る。

  class Main {
    constructor() {
      this.rankingType = new RankingType();
      this.gameMode = new GameMode(this.rankingType.current);
      ???
    }
    view(vnode) {
      if (
        Object.prototype.hasOwnProperty.call(vnode.attrs, 'rankingType') &&
        Object.prototype.hasOwnProperty.call(vnode.attrs, 'gameMode')
      ) {
        if (vnode.attrs['rankingType'] !== this.rankingType.current) {
          this.gameMode = new GameMode(vnode.attrs['rankingType'], vnode.attrs['gameMode']);
          this.rankingType.current = vnode.attrs['rankingType'];
          ???
        }
        if (vnode.attrs['gameMode'] !== this.gameMode.current) {
          this.gameMode.current = vnode.attrs['gameMode'];
        }
      }
      return [m(this.rankingType), m(this.gameMode), createRanking(this.rankingType.current, this.gameMode.current)];
    }
  }

では肝心のview()メソッドはどういう実装になるのか。

    view() {
      console.log(`${this.constructor.name}#view() this.current=${this.current}`);
      return m(
        'nav',
        { class: this.cssClassPrefix_ },
        m(
          'div',
          { class: `${this.cssClassPrefix_}-container` },
          this.list_.map(v =>
            m(
              'button',
              {
                class:
                  this.current_ === v
                    ? `${this.cssClassPrefix_}-container__item is-current`
                    : `${this.cssClassPrefix_}-container__item`,
              },
              m(
                m.route.Link,
                {
                  href: ???,
                  options: { replace: true },
                },
                this.valueConverter_(v)
              )
            )
          )
        )
      );
    }

さて、ボタンのリンク先であるhrefはどのように書けばいいだろうか。

ルーティング後のURLは例えば#!/cmonth/easyのようになる。#!の部分は勝手にm.route.Linkがやってくれるからいいが、問題は/cmonth/easyの部分だ。

ランキングの種類選択ボタンはデータのあるゲーム難易度を知ってさらにどの難易度を表示するかをボタンがクリックされる前に知る必要がある。ところがランキングの種類とゲーム難易度はクラスを分けてしまったから、ランキングの種類を管理するクラスはゲームの難易度のことは知らない。

結局コールバックしかないのか?

これに対してEventを使ってメッセージングみたいなことを実装しようとしたがコードが膨れ上がりすぎた。

Mainクラスはすべての情報を知っているのだからここから教えてあげればいい。

つまりhrefを生成する関数を注入すればいい。

    /**
     * @param {(s: string) => string} hrefCreater
     */
    injectHrefCreater(hrefCreater) {
      this.hrefCreater_ = hrefCreater;
    }

view()では

              m(
                m.route.Link,
                {
                  href: this.hrefCreater_(v),
                  options: { replace: true },
                },
                this.valueConverter_(v)
              )

のように呼び出す。

あとはMainクラスで

  class Main {
    constructor() {
      this.rankingType = new RankingType();
      this.gameMode = new GameMode(this.rankingType.current);
+     this.rankingType.injectHrefCreater(rankTypeSelected => {
+       const gameMode = decideDefault(createGameModeList(input[rankTypeSelected]), this.gameMode.current, 'extreme');
+       console.log(`this.rankingType.injectHrefCreater:: /${rankTypeSelected}/${gameMode}`);
+       return `/${rankTypeSelected}/${gameMode}`;
+     });
+     this.gameMode.injectHrefCreater(gameModeSelected => `/${this.rankingType.current}/${gameModeSelected}`);
    }
    view(vnode) {
      if (
        Object.prototype.hasOwnProperty.call(vnode.attrs, 'rankingType') &&
        Object.prototype.hasOwnProperty.call(vnode.attrs, 'gameMode')
      ) {
        if (vnode.attrs['rankingType'] !== this.rankingType.current) {
          this.gameMode = new GameMode(vnode.attrs['rankingType'], vnode.attrs['gameMode']);
          this.rankingType.current = vnode.attrs['rankingType'];
+         this.gameMode.injectHrefCreater(gameModeSelected => `/${this.rankingType.current}/${gameModeSelected}`);
        }
        if (vnode.attrs['gameMode'] !== this.gameMode.current) {
          this.gameMode.current = vnode.attrs['gameMode'];
        }
      }
      return [m(this.rankingType), m(this.gameMode), createRanking(this.rankingType.current, this.gameMode.current)];
    }
  }

のように書き換えればいい。

もっといい方法はなかったのか

そもそもランキングの種類とゲーム難易度のクラスが持っている状態は何だったか。

    /**
     * @param {readonly string[]} list target list
     * @param {string} selectedValue
     * @param {string} cssClassPrefix
     * @param {(v: string) => string} valueConverter
     */
    constructor(list, selectedValue, cssClassPrefix, valueConverter) {
      this.list_ = list;
      this.current_ = selectedValue;
      this.cssClassPrefix_ = cssClassPrefix;
      this.hrefCreater_ = () => '';
      this.valueConverter_ = valueConverter;
    }

ごちゃごちゃあるが、本質的には今どの種類か(this.current_)という一つの情報しかない。

いっそのことクラスじゃなくて関数にして毎回状態を渡して生成するみたいなダイナミックさを受け入れたらもっとシンプルになった気がしないでもない。

まあ今からはやらないけど。

もしくはステート管理ライブラリを導入するとなんかいい感じに解決できるのかもしれない(あんま良く知らない)

できたもの

image.png

アンケート

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1