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

# はじめに

最近[ときめきアイドル](https://www.konami.com/games/tokimeki-idol/)スコアランキングサイトを作りなおそうとしている @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しか使ったことがない。

https://mithril.js.org/

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

<p class="codepen" data-height="265" data-theme-id="0" data-default-tab="js,result" data-user="tivac" data-slug-hash="rmBOQV" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="Mithril Component Example">
  <span>See the Pen <a href="https://codepen.io/tivac/pen/rmBOQV">
  Mithril Component Example</a> by Pat Cavit (<a href="https://codepen.io/tivac">@tivac</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

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

# mithril jsでのルーティング

[route(root, defaultRoute, routes) - Mithril.js](https://mithril.js.org/route.html)

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

```javascript
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タグを使うリンクを

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

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

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

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

最初に

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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94177/53cc59c0-eebe-f750-e8c1-75b6938543bf.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94177/f43ed56f-f984-c620-6709-0e7bb2494959.png)

# hrefをどう作るか問題

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

```javascript
  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`クラスに

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

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

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

```javascript
  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()`メソッドはどういう実装になるのか。

```javascript
    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を生成する関数を注入すればいい。

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

`view()`では

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

のように呼び出す。

あとはMainクラスで

```diff
  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)];
    }
  }
```

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

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

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

```javascript
    /**
     * @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_`)という一つの情報しかない。

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

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

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

# できたもの

https://yumetodo.github.io/kamioda20191018task/index2.html#!/total/extreme

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94177/5f8cc7cb-9e14-33f2-4126-03f81ccec326.png)

# アンケート

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr"><a href="https://twitter.com/hashtag/%E3%82%A2%E3%83%B3%E3%82%B1%E3%83%BC%E3%83%88?src=hash&amp;ref_src=twsrc%5Etfw">#アンケート</a> <br><br>難易度ボタン、どっちのデザインがいい？<br>投票は下にあります <a href="https://t.co/Au3gk20dBM">pic.twitter.com/Au3gk20dBM</a></p>&mdash; ときドルスコアランキング管理者「神御田」からのお知らせ (@tokidolrank) <a href="https://twitter.com/tokidolrank/status/1185890787025801216?ref_src=twsrc%5Etfw">October 20, 2019</a></blockquote>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">投票はこちら</p>&mdash; ときドルスコアランキング管理者「神御田」からのお知らせ (@tokidolrank) <a href="https://twitter.com/tokidolrank/status/1185890790158942209?ref_src=twsrc%5Etfw">October 20, 2019</a></blockquote>