はじめに
最近ときめきアイドルスコアランキングサイトを作りなおそうとしている @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 propertiesprojectID
anduserID
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
難易度の情報がない。
つまり以下のようにボタンを並べられず、
このようにデータがあるボタンだけ出したい。
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_
)という一つの情報しかない。
いっそのことクラスじゃなくて関数にして毎回状態を渡して生成するみたいなダイナミックさを受け入れたらもっとシンプルになった気がしないでもない。
まあ今からはやらないけど。
もしくはステート管理ライブラリを導入するとなんかいい感じに解決できるのかもしれない(あんま良く知らない)
できたもの
アンケート
#アンケート
— ときドルスコアランキング管理者「神御田」からのお知らせ (@tokidolrank) October 20, 2019
難易度ボタン、どっちのデザインがいい?
投票は下にあります pic.twitter.com/Au3gk20dBM
投票はこちら
— ときドルスコアランキング管理者「神御田」からのお知らせ (@tokidolrank) October 20, 2019