Mithrilとは

Mithril is a modern client-side Javascript framework for building Single Page Applications. It's small (< 8kb gzip), fast and provides routing and XHR utilities out of the box.

MithrilJSはシングルページを作成するためのフレームワークです。ReactやAngularなんかと似ていますが、たった8kbというファイルサイズと他よりもレンダリング速度が早いことを売りにしています。

Mithrilを使い始める

MithrilJSはCDNで読み込んでもいいですが、どうせjsxの環境を整えたくなったり、npmライブラリを使いたくなった時のことを考えてWebpackでビルドして使いたいと思います。

依存パッケージのインストール

以下のコマンドでMithrilJSとWebpackをインストールします。

npm i -S mithril;
npm i -D webpack \
         webpack-dev-server
         html-webpack-plugin \
         babel-core \
         babel-loader \
         babel-preset-env \
         babel-plugin-transform-runtime \
         babel-plugin-transform-react-jsx

これを書いている段階では環境はこんな感じです。

npm list --depth 0
# ├── babel-core@6.22.1
# ├── babel-loader@6.2.10
# ├── babel-plugin-transform-react-jsx@6.22.0
# ├── babel-plugin-transform-runtime@6.22.0
# ├── babel-preset-env@1.1.8
# ├── babel-runtime@6.22.0
# ├── html-webpack-plugin@2.28.0
# ├── mithril@1.0.0
# ├── node-pre-gyp@0.6.33 extraneous
# ├── webpack@2.2.1
# └── webpack-dev-server@2.3.0

Webpackの設定

ここではBabelのbabel-plugin-transform-react-jsxを使ってJSX記法を使える設定まで行います。

const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    path: 'dist',
    filename: 'bundle.js'
  },
  plugins: [
    new HtmlWebpackPlugin()
  ],
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['env'],
          plugins: [
            'transform-runtime',
            ['transform-react-jsx', {pragma: 'm'}]
          ]
        }
      }
    ]
  }
}

ちなみに、transform-react-jsxのpragmaっていうのは、そういう「名前のメソッドでhyperscriptの形へ変換します」という意味です。これは本来react-jsxと名前にある通り、デフォルトではpragmaReact.createElementになっているのでこれを変える必要があります。

そしたら、ビルドコマンドをnpm run-scriptへ登録します。package.jsonscriptsを以下のように書きます。

"scripts": {
  "start": "webpack-dev-server"
}

WebpackDevServerで、Webpackのコンパイルとサーバー起動を両方をやってくれます。 これで環境の準備はできました。

「Hello Mithril」と表示する

エントリーポイントとなる./src/index.jsファイルを編集して以下のようにします。

import m, {mount} from 'mithril';

class HelloMithril {
  view() {
    return <h1>Hello Mithril !</h1>
  }
}

mount(document.body, new HelloMithril());

mountメソッドは、1つ目にElement、2つ目にviewメソッドを持つobjectを期待します。以下のコマンドを叩いてビルドとサーバーを起動します。

npm start

コマンドが終了して、'dist/'以下がこんな感じの構造になっていたら大丈夫です。

./dist
├── bundle.js
└── index.html

その後ブラウザで「Hello Mithril!」と表示されたら完璧です💮(webpack-dev-serverはデフォルトでlocalhost:8080で起動されます)

Todoアプリを作る

Todoアプリを作る前に

json-serverというJSONファイルだけで簡単にRESTなサーバーを起動できるパッケージがあるのでインストールしといてください。

npm i -g json-server

json-serverは以下のように起動できます。後ろに対象の.jsonファイルを指定します。

json-server db.json

デフォルトで3000で起動しますが、もしポートを変えたくなったらjson-server.jsonというファイルを作って以下のようにしてください。

{
  "port": 3333
}

JSONファイルを作る

db.jsonを作ります。こんな感じでファイルを作っといてください。

{
  "tasks": [
    {"id": 1, "content": "foo", "done": false},
    {"id": 2, "content": "bar", "done": false}
  ]
}

これで起動して、localhost:3000/tasksへアクセスすると上記のデータが帰ってくるはずです。

json-server db.json
# ...
curl http://localhost:3000/tasks
# [
#   {
#     "id": 1,
#     "content": "foo",
#     "done": false
#   },
#   {
#     "id": 2,
#     "content": "bar",
#     "done": false
#   }
# ]

Viewを作る

src/todo-view.jsを作ります。JSXでゴリゴリ書いていきます。1つ以下で使われているoninitはライフサイクルメソッドです。要素が作られる前に勝手に実行されます。

import m from 'mithril';

class TodoView {
  constructor() {
    this.tasks = null;
  }

  oninit(vnode) {
    const {model} = vnode.attrs;
    model.getTasks().then(tasks => {
      this.tasks = tasks;
    });
  }

  handleSubmit(model) {
    return async e => {
      e.preventDefault();
      const contentElem = e.target[0]
      const content = contentElem.value;

      if (!content) {
        return;
      }

      const latestTask = await model.addTask({
        content,
        done: false
      });
      console.log(latestTask);
      this.tasks.push(latestTask);

      contentElem.value = ''
    };
  }

  removeTask(model, task, idx) {
    return () => {
      model.removeTask(task);
      this.tasks.splice(idx, 1);
    };
  }

  view(vnode) {
    const {model} = vnode.attrs;

    if (this.tasks === null) {
      return <section>Loading...</section>;
    }

    return (
      <section>
        {this.count}
        <ul>
          {(this.tasks || []).map((task, idx) => (
            <li>
              <span>{task.content}</span>
              <input type="checkbox"
                     onclick={model.toggleComplete(task)}
                     checked={task.done ? true : false}/>
              <button onclick={this.removeTask(model, task, idx)}
                      style={task.done ? 'display:inline' : 'display:none'}>
                Delete
              </button>
            </li>
          ))}
        </ul>
        <form onsubmit={this.handleSubmit(model)}>
          <input type="text"/>
          <input type="submit"/>
        </form>
      </section>
    )
  }
}

export default new TodoView();

Modelを作る

src/todo-model.jsを作ります。RESTへのアクセスなどの処理をまとめます。

Mithrilにはrequestという外部とデータをやり取りできるメソッドが組み込まれてるのでこれを使います。

import {request} from 'mithril';

function api([path], id) {
  if (id) {
    return `http://localhost:3000/${path}${id}`;
  }
  return `http://localhost:3000/${path}`;
}

class TodoModel {

  // タスクを全部取ってくる
  async getTasks() {
    const tasks = await request(api`tasks`);
    return tasks;
  }

  // タスクを追加
  async addTask(task) {
    const latestTask = await request({
      method: 'POST',
      url: api`tasks`,
      data: task
    });
    return latestTask;
  }

  // タスクの状態を変更
  toggleComplete(task) {
    return () => {
      task.done = !task.done;
      request({
        method: 'PUT',
        url: api`tasks/${task.id}`,
        data: task
      });
    };
  }

  // タスクを削除
  removeTask(task) {
    request({
      method: 'DELETE',
      url: api`tasks/${task.id}`
    });
  }
}

export default new TodoModel();

ちなみに、MithrilがViewを更新するタイミングですが、onclickなどのユーザーアクションが起きた場合か、requestが実行された時?に更新されます。それ以外で更新したい場合は、m.redrawを実行します。

エントリーファイルを作る

src/index.jsでTodoViewとTodoModelをがっちゃんこします。

import m, {mount} from 'mithril';
import todoModel from './todo-model';
import TodoView from './todo-view';

class TodoApp {
  view() {
    return (
      <div>
        <h1>Todo</h1>
        <TodoView model={todoModel}></TodoView>
      </div>
    )
  }
}

mount(document.body, new TodoApp());

Todoアプリ完成!