> makibishi throw|

Mithril.js完全ガイド(2.0.4時点)

Mithril.js とは?

Mithril.js は、クライアントサイドの UI フレームワークの一種です。React、Vue などと同様、シングルページアプリケーションや、html5 ベースのソフトウェアフレームワークの UI を作ることができます。

Mithril の特徴は、なんといってもシンプルであることです。この記事では、Mithril と React との比較から始まり、Mithril の基本的な使い方や、Mithril ベースのアプリケーションにおけるステートの管理、テストの方法などを網羅的に解説していきます。

React との比較

まずは、以下のような極めてシンプルな React アプリケーションを考えてみましょう。

const App = () => {
  const [count, setCount] = useState(0)
  return <button onClick={() => {setCount(count + 1)}}>{count}</button>
}

このアプリケーションでは、要素がクリックされるたびにカウントが+1され、その結果がレンダリングされます。ではReactはどうやって、「再レンダリングすべきだ」という判断を行っているのでしょうか?

Reactはコンポーネントが描画に用いる情報をprops,stateとして定義し、これらが更新されたタイミングを再レンダリングのタイミングとしています。したがって、開発者はステートが更新されたことを必ずReactに通知する必要があります。上記の例ではsetCount関数が通知にあたります。(class componentの場合はsetStateです)

なので、以下のように書いた場合は、ボタンをクリックしても何も起こりません。

let count = 0;
const UselessApp:React.FC<{}> = () => {
  return (<button onClick={() => {count++}}>{count}</button>);
};

本題に戻り、Mithrilで同じアプリを作った場合を見てみましょう。

let count = 0;
const countApp = {
  view: () => {
    return m("button", { onclick: () => {count++}}, count);
  },
};

まず目につくのはm(…)という記法です。Mithrilではこのように、m(タグ名,属性など,タグの中身)という形で要素を記述していきます。この記法はhyperScriptと呼ばれています。

そして、次に目につくのは、Reactでは検知できない方法でステートを更新しても動作している ということです。これがMithrilの大きな特徴で、Mithrilはビューがどのようなステートに依存しているのかに関心がありません。それでは、Mithrilはどのようにしてビューを更新するべきタイミングを検知しているのでしょうか?

Mithrilでは、ビューの更新タイミングについて面白いアプローチを取っており、ユーザーの操作・入力や外部からのデータ取得完了のタイミングで再描画を行います。ステートが変わるとすればその前にDOMイベントやAjax処理があるはずで、その処理が完了した後に再描画処理を挟めばよい、というアプローチです。

もちろん、愚直にあらゆるアクションに対して再描画を行っているのではなく、イベントが発生すると+1、イベントが終了すると-1される内部カウンタを用意し0になったタイミングで再描画する、onScroll、onchangeのような頻度の高いイベント後の更新を間引くといった高速化が施されています。

なお、上記の例ではコンポーネントの外側にステートを定義していますが、コンポーネント自体にステートを持たせることも可能です。その方法は後述します。

私が考える、Reactと比較した場合のMithrilの長所・短所を挙げてみます。

長所

  • シンプルな動作原理なのでライブラリのサイズが肥大化せず、非常にコンパクト。
  • ビューが依存しているステートをフレームワークに知らせる必要がないため、描画と切り離された形でステート管理を行うことができる。(例えば、ReactはReduxによって管理されるステートとの繋ぎこみのためにreact-reduxが必要だが、Mithrilなら不要)
  • 少ないAPIの数ながら単体でSPAを作るのに十分な機能を備えており、余計なライブラリを入れる必要がない。

短所

  • 利用者が少ない… 特に日本での利用実績は滅多に聞きません。
  • エコシステムが成熟していない。特殊なことをしようとするとある程度頑張る必要がある。
  • (上記ともかぶるが)コンポーネントの数が少ない。Reactのように、欲しいと思うコンポーネントが既に公開されているという状況は奇跡的。

MithrilのAPI

この節では、Mithrilが現在(2.0.4)時点で備えている13個のAPIを紹介します。以下がAPIの一覧です。必須、重要となっているAPIの使い方さえ覚えればアプリケーションの作成に不自由ないレベルになります。

API 重要度 概要
m 必須 ビューを作る
m.mount 重要 アプリケーションのマウント
m.route 重要 ルーティング
m.request 重要 XMLHttpRequest
m.redraw 重要 強制再描画
m.fragment 時々使う ビューのグループ化
m.buildQueryString 稀に使う クエリストリングの生成
m.parseQueryString 稀に使う クエリストリングのパース
m.buildPathname 稀に使う パスの生成
m.parsePathname 稀に使う パスのパース
m.trust 稀に使う html文字列の描画
m.jsonp 稀に使う パスのパース
m.render 稀に使う ビューの描画

m

全ての始まりとなるAPIです。このAPIを使ってビューを構築していきます。コンポーネントの作成にもこのAPIを使用します。

子を持たないビュー

下記の例はプレーンなdom要素を作成する例です。第一引数にはタグ名、もしくはスペースが入らないセレクタを記述します。div要素はタグ名の記載を省略することができます。

m("div"); // <div></div>
m("div.className#id");  // <div class="className" id="id"></div>
m(".className#id");  // <div class="className" id="id"></div>
m("div","divタグの中身"); // <div>divタグの中身</div>

DOMにイベントを紐付けたい場合はon+イベント名で関数を指定します。これらは元々のイベントが受け取ることの出来るパラメータを同じように受け取ることができます。

m("div",{
  // イベントはon+イベント名
  onclick: (e) =>{..},
  ondblclick: (e) =>{..},
  onmouseenter: (e) => {..},
},"txt");

HTMLElementのプロパティ、DOM APIのsetAttributeで設定できるものは第二引数から設定することができます。指定したkeyは全てlowercaseで設定されます。

// プロパティで設定できる値
m("input",{
  readOnly:true
}); // <input readonly>
m("button",{
  autofocus:true
}); // <button autofocus>
m("div",{
  className: "wonder"
}); // <div class="wonder">

// setAttributeで設定できる値
m("div",{
  id:"divman",
  class: "wonder",
  mithrilis: "ultimate"
}); // <div id="divman" class="wonder" mithrilis="ultimate">

ライフサイクルメソッド

第二引数で設定できる要素には、mithrilが予約しているライフサイクルメソッドが含まれています。ライフサイクルメソッドを使うと、nodeの作成後、アップデート後、remove後などのタイミングに何らかの処理を挟むことができます。

  • oninit: 初期化時に呼ばれる。
  • oncreate 実DOMの作成が完了した時に呼ばれる。
  • onupdate DOMに更新が発生した場合、更新反映後に呼ばれる。
  • onremove DOMが画面から消えた後に呼ばれる。

これらのメソッドは引数としてvnodeオブジェクトを受け取ることができます。詳しくは後述しますが、vnodeオブジェクトからコンポーネント自身が持つstate、他のコンポーネントから渡されたattributeを取得できます。

m("div",{
  oninit: (vnode) => {console.log("init")},
  oncreate: (vnode) => {console.log("create")},
  onupdate: (vnode) => {console.log("update")},
  onremove: (vnode) => {console.log("remove")},
});

また、ビューの更新を行うかどうかを制御したり、非同期処理を挟んだりする特殊なライフサイクルメソッドも用意されています。

  • onbeforeupdate: メソッドが返すbooleanによってビューの更新を行うかどうかを制御できる。trueなら 更新し、falseなら更新しない。
  • onbeforeremove: メソッドがpromiseを返す場合、promiseがresolveされるまでremoveを待つ。
m("div",{
  onbeforeupdate: (newVnode, oldVnode) => { return newVnode.state.update ? true:false }, // 新旧のvnodeを引数にとることができる
  onbeforeremove: (vnode) => {new Promise((resolve) => {setTimeout(resolve, 1000)})
});

子を持つビュー

入れ子にする場合、単に第二(第三)引数を子の配列にすれば良いです。

m("ul",[
  m("li", "ワニ"),
  m("li", "クジラ"),
  m("li", "サンショウウオ")
]);

コンポーネント

コンポーネントとは、再利用可能な部品です。頻繁に使われるひとまとまりのビューをコンポーネントとしてまとめることで、重複した記述を防ぐことができます。コンポーネントは以下のように、少なくともviewというプロパティを持ったオブジェクトとして定義します。

const component = {
  view: (vnode) => {
    return m(
      "button",
      {style: {color: "#f00"}},
      "Submit"
    );
  },
};

以下のようにしてコンポーネントを使います。

m(component);

コンポーネントに対してattribute(Reactでいうprops)を持たせて利用時に指定させたり、子ノードをコンポーネントに受け渡すことも可能です。

// attributeを利用時に注入するコンポーネント
// attribute名は既存のプロパティと被らないようにする
const attrComponent = {
  view: (vnode) => {
    return m(
      "button",
      {
        style: {color: vnode.attrs.color},
        onclick: vnode.attrs.handleClick,
      },
      "Submit"
    );
  },
};

// attributeを指定してコンポーネントを使う
m(attrComponent, {color: "#f00",handleClick: () => {console.log("click")}});

// 子ノードを持つコンポーネント
const modal = {
  view: (vnode: any) => {
    return m("div.modal", vnode.children);
  },
};

// 子ノードの指定 
m(modal, [m("div", "Hello"), m("div", "everyone")]);

コンポーネントはviewメソッドの他、ライフサイクルメソッドも持つことができます。

const component: m.Component = {
  view: () => {
    return m("div");
  },
  oninit: (vnode) => {...},
  oncreate: (vnode) => {...},
  onupdate: (vnode) => {...},
  onremove: (vnode) => {...},
  onbeforeupdate: (newVnode, oldVnode) => {...},
  onbeforeremove: (vnode) => {...},
};

vnode.stateを使うと、コンポーネントに状態を持たせることができます。stateは完全に自由にデータを格納できます。

const countAppState = {
  // oninitでstateを定義
  oninit: (vnode) => {vnode.state.count = 0;},
  view: (vnode) => {
    return m(
      "button",
      {
        onclick: () => {
          vnode.state.count += 1;
        },
      },
      vnode.state.count
    );
  },
};

コンバーターを使うと、htmlからm()を使った形式に変換できるので便利です。

m.mount

実際のDOM elementに対してMithril コンポーネントをマウントします。マウントすることで初めてアプリケーションが動きます。

const component = {
  view: () => {
    return m("button", "submit");
  }
}

m.mount(document.body, component);

m.route

ルーティングのためのAPIです。パス毎に異なるコンポーネントをマウントすることができます。m.routeは内部的にHistory APIを利用しており、遷移履歴が適切に保存されるため、ブラウザの戻る/進むが正しく動作します。

下記の例では、/#!/でhomeコンポーネント、/#!/listでlistコンポーネントがマウントされます。

const list = {
  view: () => {
    return m("ul", [m("li", "a"), m("li", "b"), m("li", "c")]);
  },
};

const home = {
  view: () => {
    return m("span", "home");
  },
};

// /listが初期状態となる
m.route(root, "/list", { "/": home, "/list": list });

デフォルトでパスのprefixに#!がつきますが、これはm.route.prefixから変更できます。

m.route.prefix = '#!' // デフォルト
m.route.prefix = ''   // 何もつかない https://domain/page
m.route.prefix = '?'  // https://domain/?/page のようになる

ページ遷移

要素名にm.route.Linkを指定し、href属性にパスを指定すると、そのパスへのリンクを生成できます。

const home = {
  view: () => {
    return m(m.route.Link, { href: "/list" }, "home");
  },
};

m.route.setでパスを指定することでも、そのパスに移動できます。

const gohome = {
  view: () => {
    return m("button",
      {onclick: () => {m.route.set("/")}},
      "go home")
  },
};

パスパラメータ・クエリストリング

パスには、:attribute名という形でパスパラメータを定義できます。パスパラメータはvnode.attrsから参照できます。

const numberPage = {
  view: (vnode: any) => {
    const number = vnode.attrs.num;
    return m("span", `great number ${number}`);
  },
};

m.route(root, "/number/10", {
  "/number/:num": numberPage,
});

クエリストリングの場合もvnode.attrsから参照できます。

const queryPage = {
  view:(vnode) => {
    const random = vnode.attrs.random;
    return m("span", `random = ${random}`);
  }
};
const goToRandomQuery = {
  view:() => {
    return m(m.route.Link, {href:`/query?random=${Math.random()}`},"go to adventure");
  }
};

m.route(root, "/gotoquery", {
  "/query": queryPage,
  "/gotoquery": goToRandomQuery
});

パスパラメータ、クエリストリングは、m.route.paramを使うことでも取得できます。

const queryPage = {
  view:(vnode) => {
    const random = m.route.param("random"); // vnode.attrs.randomと同じ。
    console.log(m.route.param());           // {random:0.992121} 全てのパラメータが入ったオブジェクト 
    return m("span", `random = ${random}`);
  }
};

ルートリゾルバ

レンダリングするコンポーネントを動的に変えたい場合は、ルートリゾルバを利用します。ルートリゾルバはonmatch(必須)、render(任意)という2つの関数によって構成する必要があり、コンポーネントと同じように扱うことができます。

const A = {
  view: (vnode) => {
    const attachment =  m.route.param("attachment");
    return m("button",{onclick: () => {
      m.route.set("/");
    }},`A ${attachment}`);
  }
}

const B = {
  view: (vnode) => {
    const attachment =  m.route.param("attachment");
    return m("button",{onclick: () => {
      m.route.set("/");
    }},`B ${attachment}`)
  }
}

const routeResolver = {
  onmatch: () => {
    return Math.random() < 0.5 ? A:B;
  },
  render: (vnode) => {
    vnode.attrs.attachment = "attached by resolver";
    return vnode;
  }
}

onmatchはどのコンポーネントを使用するのかを選択するコンポーネントで、何らかのコンポーネントを返す関数を定義します。 onmatchは現在のパスに変更があるたびに呼び出され、renderするコンポーネントを確定します。返す値はPromiseにすることもでき、その場合はPromiseの解決後にコンポーネントが決まります。(これは認証失敗、成功のようなルーティングでよく使われます)

renderはvnodeを引数に取る関数で、どのようにレンダリングするのかを定義します。指定しない場合はそのままのコンポーネントを返します。vnode.attrsに変更を加えることができます。

const routeResolver = {
  onmatch: () => {
    return Math.random() < 0.5 ? A: m.route.SKIP;
  }
}

onmatchでm.route.SKIPを返すとレンダリングすべきコンポーネントがないという状態になり、m.routeのデフォルトで設定したパスに飛ばされます。

m.request

このAPIはfetch APIと出来ることはほとんど変わりません。 なぜこのAPIが存在するかというと、MithrilがAjax処理完了後を更新タイミングの一つとしているため、ユーザーがこのAPIを使えばビュー更新タイミングをMithrilが検知出来るからです。 ただし、Ajax処理を行う場合必須というわけではなく、Mithril以外が提供しているAjax処理+後述の再描画を行うAPIという組み合わせでも問題ありません。

m.request({
    url: "https://sample.com",  // URL 必須はこれだけ
    method: "PUT",             // リクエストメソッド名。デフォルトはGET
    params: {id: 1},           // URLの:{変数名}となっているパラメータの実値
    body: {name: "user"}       // リクエストボディ
})
.then((result) =>  {
    console.log(result);
})

m.requestのプロパティの例

プロパティ 必須かどうか 説明
url リクエストするURL https://www.google.com/
method リクエストメソッド “PATCH”
params パラメータ {id:1}
body リクエストボディ {name:“user”}
async 非同期で通信するかどうか(デフォルトはtrue) false
withCredentials Cookieを送信するかどうか(デフォルトはfalse) true
headers リクエストヘッダ {ApiKey:“dummy”,version:2}
serialize リクエストボディのシリアライズ関数 (requestBody) => {serializedRequestBody}
deserialize リクエストボディのデシリアライズ関数 (serializedRequestBody) => {requestBody}
background リクエスト完了後に再描画するかどうか(デフォルトはtrue) false

m.redraw

強制的に再描画します。オーソドックスなSPAではあまり使われません。setIntervalやfirestoreのAPIのような、Mithrilに検知できない形でステートが更新される場合に利用します。

let random = Math.random();

setInterval(() => {
  random = Math.random();
  m.redraw();
},200);

const dispRandom = {
  view: () => {
    return m("div", random);
  }
}

m.mount(root, dispRandom);

m.redraw.sync()で同期的に再描画することもできます。

m.fragment

複数のコンポーネントに対してライフサイクルメソッドを付与したい場合に使います。m()の入れ子でも同じことが実現できますが、それよりも小コストになります。

const fragmentComponent = {
  view:() => {
    return m.fragment({oninit: () => {
      console.log("init fragment");
    }}, [
      m("div","A"),
      m("div","B")
    ])
  }
}

m.mount(root, fragmentComponent);

m.buildQueryString, m.parseQueryString

クエリストリングの生成とパースを行います。m.routeとセットで使うことが多いです。

const querystring = m.buildQueryString({a: 1, b: 2}); // a=1&b=2
const parse = m.parseQueryString(querystring); // {a:"1",b:"2"}

m.buildPathname, m.parsePathname

パスパラメータとクエリストリングの情報からパスを生成する、またはパースします。

const path = m.buildPathname("/page/:pageNumber",{pageNumber:2,order:"asc"}); // /page/2?order=asc
const pathObject = m.parsePathname(path); // {path:"/page/2",params:{order: "asc"}}

m.trust

通常時、MithrilはHTMLインジェクションを防ぐために文字列にエスケープ処理を加えますが、m.trustでは与えられた文字列を信頼し、htmlとしてレンダリングします。

m.trust("<button>Button</button>");

m.jsonp

jsonpリクエストを作ります。

m.render

m.render()はm.mount、m.route()などのAPIの中で内部的に使われています。m.render()はm.mountと同じくm()によって構成されたビューを描画しますが、イベントやrequest、m.redraw()などによって再描画されません。

コンポーネントテスト

mithrilのコンポーネントテストには、mithril-queryが使えます。実はmithrilにはospecというテストフレームワークも付属しているのですが、TypeScriptで開発されたコードの評価などに対応していないので現状はjestなどを使うのがおすすめです。

ここでは、jest+mithril-queryを使う前提でコンポーネントテストの例を示します。

以下のようなコンポーネントをテストしたいとします。

const functionalComponent: m.Component<{ label: string }, {}> = {
  view: (vnode) => {
    return m("h3", vnode.attrs.label);
  },
};

export default functionalComponent;

まずは、m()と同じ感覚でmq()を呼び出します。

import mq from "mithril-query";
import fc from "../component";

describe("sample", () => {
  it("component test", () => {
    const result = mq(fc, { label: "test" });
  });
});

mq()関数が返したオブジェクトのshouldプロパティを使ってテスト項目を書くことができます。

it("component test", () => {
  const result = mq(component, { label: "test", title: "test title" });
  result.should.have(1, "h2"); // コンポーネントはh2要素を1つ持つ
  result.should.contain("test title"); // コンポーネントは"test title"文字列を含んだエレメントを持つ
  result.should.not.have("h1"); // コンポーネントはh1要素を含まない

  result.should.have(1, "h3");
  result.should.contain("test");
});

have()はセレクタを使って指定します。 上記のメソッドはshouldを経由しないresult.has,result.containsのようなバージョンも存在しますが、そちらは単にbooleanの値を返します。should配下のものは例外をスローするため、テストフレームワークでエラーとして検知されます。

続いては、以下のクリック可能なコンポーネントをテストする場合を見てみましょう。

const switchComponent: m.Component<
  {
    onMessage: string;
    offMessage: string;
  },
  {
    on: boolean;
  }
> = {
  oninit: (vnode) => {vnode.state.on = false;},
  view: (vnode) => {
    return m(
      "button",
      {onclick: () => {vnode.state.on = !vnode.state.on;}},
      vnode.state.on ? vnode.attrs.onMessage : vnode.attrs.offMessage
    );
  },
};

export default switchComponent;

このコンポーネントは、スイッチの初期状態はオフで、ボタンをクリックするとスイッチのオン/オフが切り替わります。 スイッチが切り替わることと、オン/オフのそれぞれの状態でonMessageoffMessageの文字が表示されていることを確かめたいです。このようなケースでは、click()でコンポーネントの状態を切り替えることができます。

it("switch test", () => {
    const result = mq(switchComponent, {
      onMessage: "Switch ON",
      offMessage: "Switch OFF",
    });

    result.should.contain("Switch OFF"); // 最初はオフ
    result.should.not.contain("Switch ON");

    result.click("button"); // セレクタで指定された箇所をクリックする

    result.should.contain("Switch ON"); // オンになっている
  });

以下の3種類の方法でコンポーネントの状態を変更することができます。

  • .click(“セレクタ名”) // クリックイベント
  • .setValue(“セレクタ名”, セットする値) // フォーム入力
  • .trigger(“セレクタ名”,“指定したセレクタの要素が持っているイベント名”,イベントオブジェクト) // 指定要素が持っている特定のイベントを起動させる

.trigger()はあらゆるイベントを起動でき、例えば上述のresult.click()は以下のように書けます。

result.trigger("button", "onclick", new Event("onclick"));

stream

ストリームはmithrilに同梱されているデータ構造で、作りたいアプリによっては便利に使うことができます。以下のようにインポートして使います。

import stream from "mithril/stream";

まずは、stream()でストリームを作成します。ストリーム型はgetとsetの機能を持っています。

const value = stream(1);
console.log(value()); // 1
value(7);
console.log(value()); // 7

これだけでは、何が嬉しいのか分かりません。ストリームは複数のストリームをつなげることで真価を発揮します。stream.lift()を使って2つのストリームを合成することができます。

// かけられる数
const multiplicand = stream(3);

// かける数
const multiplier = stream(2);

// 積
const product = stream.lift((a, b => {return a * b;},multiplicand,multiplier);

stream.lift()によって作られた変数もまたストリーム型になります。ストリーム型は公式ドキュメントで、「スプレッドシートのセルのようなデータ構造」と説明されています。合成によって作られたストリームは、合成元のストリームの値が変化すると自動的に値が変化します。

console.log(product()); // 6 (3*2 = 6)
multiplicand(4);
console.log(product()); // 8 (4*2 = 8)
multiplier(5);
console.log(product()); // 20 (4*5 = 20)

stream.mergeでは、任意の数のstreamを合成することができます。

const formula = stream
  .merge([multiplicand, multiplier, product])
  .map((values) => {
    // 配列のmapとは感覚が違い、任意個のstreamから一つの値を生成
    return values[0] + "*" + values[1] + "=" + values[2];
  });

TypeScriptでのMithril開発

Mithrilは型定義ファイルが公開されているため、TypeScriptで開発することができます。ここでは簡単にMithril型定義の使い方を説明します。

TypeScriptでMIthrilを利用するために、型をインストールしておきます。

npm install -D @types/mithril

コンポーネントの型

m.Component<attrsの型,stateの型>という型を実装すればコンポーネントとして使えます。

const switchComponent: m.Component<
  {
    onMessage: string;
    offMessage: string;
  },
  {
    on: boolean;
  }
> = {
  oninit: (vnode) => {vnode.state.on = false;},
  view: (vnode) => {
    return m(
      "button",
      {onclick: () => {vnode.state.on = !vnode.state.on;}},
      vnode.state.on ? vnode.attrs.onMessage : vnode.attrs.offMessage
    );
  },
};

クラスによるコンポーネント定義

m.Component<attrsの型,stateの型>インターフェースをimplementsすれば、componentをクラスで記述できます。

class ClassComponent implements m.Component<{}, {}> {
  private count;
  constructor() {
    this.count = 1;
  }

  public view() {
    return m(
      "div",
      {
        onclick: () => {
          console.log("click class", this.count);
          this.count += 1;
        },
      },
      this.count
    );
  }
}

利用する場合は、classをそのまま使っても、インスタンスを作っても構いません。

m(ClassComponent); // OK

const componentFromClass = new ClassComponent();
m(componentFromClass) // OK

m(new ClassComponent()) // NG 描画のたびに毎回新しいコンポーネントが作成されてしまう

streamの型

単にstreamで定義すればOKです。

const multiplier: stream<number> = stream(2);

Mithrilを使った作例

最後に、Mithrilを実戦投入しているアプリケーションの例を紹介します。

  • VISDOM 家計簿管理ツール
  • Tutanota カレンダーサービス
  • Komiflo コミックリーダー(*18禁サービス)
  • DeNAのブラウザゲーム タイトル不明