Riot.js v4 自分でRouterを作る

[Riot.js Advent Calendar 2019](https://qiita.com/advent-calendar/2019/riotjs) の8日目が空いていたので埋めます。

## Riot Router
[前回](https://qiita.com/nekijak/items/769f0e349f977a2cbebe)と[前々回](https://qiita.com/nekijak/items/bb87e1eec71f4b95190c)でRiot Routerについて触れてきましたが、トリガーの部分で[Rawth](https://github.com/GianlucaGuarini/rawth)が出てきました。
Riot.js v3の時もRouterをカスタマイズして使っていたのですが、このRawthのコードを見ているとRiot Routerを使わずに出来そうだったのでチャレンジしました。

## 気に入らない点
v3のRouterからどうもしっくりこないのが、ページが複数あるときに全部importやmountをしないといけない点です。
複数人でページを分けて開発していると、これがとてもメンドクサイ。

## 要件
* `index.js`には`import`や`register`をたくさん書きたくない。ページが増えても変更したくない。
* URLを解析して最初の`/`までの文字をRiot.jsのモジュール名 (モジュール名.riot) とする。
* タグ名の決まりは特にない。
* モジュール名以降のパスは`/`区切りでパラメータとしてタグに渡す。
* クエリパラメータは使わない。(メンドクサイから)
* モジュールが見つからない場合にはエラーページを出す。
* トップページは`home.riot`を表示する。
* ブラウザバックも使えるようにする。

## ポイント
* dynamic imports
* async / await
* history.pushState

## 作ってみた

```javascript:index.js
import { component } from 'riot'

// ベースURL
const loc = window.location;
const base = `${ loc.protocol }//${ loc.host }/`;

// 読み込み済みコンポーネント
let comp;

// リンククリック時の動作
const linkclick = e => {
  // 左クリック以外は何もしない
  if (e.which && event.which !== 1) {
    return;
  }

  // 対象ページ読み込み
  route(e.target.href);

  // ブラウザでのページ遷移をキャンセル
  event.preventDefault();
};

// 動的ページ読み込み
const pageload = async (collection, params) => {
  try {
    // 動的インポート
    const page = (await import(`./${ collection || "home" }.riot`)).default;

    // マウント済みの場合はアンマウント
    const root = document.getElementById('root');
    comp && comp.unmount(root);

    // グローバルに登録せずに直接コンポーネントを生成＆マウント
    comp = component(page)(root, { 'params': params });

    // リンクイベント
    var links = comp.$$('a[data-router][href]');
    links.forEach(el => el.addEventListener('click', linkclick, false));

  } catch (ex) {
    // エラーが発生した場合はエラーページへ
    collection !== 'notfound' && pageload('notfound', ex);
  }
};

// ルーティング処理
const route = path => {
  // 現在のページとURLが異なる場合はブラウザヒストリーに追加
  loc.href !== path && history.pushState(null, document.title, path);

  // URL分割:最初のパスをページ名とする
  const [ collection, ...params ] = path.replace(base, '').split('/');

  // ページ読み込み開始
  pageload(collection, params);
};

// ブラウザ戻るボタン押下時の処理
window.addEventListener('popstate', e => route(loc.href), false);

// 初回読み込み
route(loc.href);
```
```html:home.riot
<my-home>
  <nav>
    <ul>
      <li><a data-router href="/hello">Hello</a></li>
      <li><a data-router href="/goodbye">Goodbye</a></li>
      <li><a data-router href="/greeting/hola">Hola</a></li>
      <li><a data-router href="/greeting/adios">Adios</a></li>
      <li><a data-router href="/hoge">hoge</a></li>
      <li><a href="https://qiita.com/advent-calendar/2019/riotjs">通常リンク</a></li>
    </ul>
  </nav>
</my-home>
```
```html:hello.riot
<my-hello>
  <p>Hello World!!</p>
</my-hello>
```
```html:goodbye.riot
<my-goodbye>
  <p>Goodbye World!!</p>
</my-goodbye>
```
```html:greeting.riot
<my-message>
  <p>{ props.params[0].slice(0, 1).toUpperCase() }{ props.params[0].slice(1) } World!!</p>
</my-message>
```
```html:notfound.riot
<my-notfound>
  <h3>Oops!!</h3>
  <h4>{ props.params.message }</h4>
  <pre>{ props.params.stack }</pre>
</my-notfound>
```
```html:index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Riot Router</title>
</head>
<body>
  <div id="root"></div>
  <script src="/scripts/bundle.js"></script>
</body>
</html>
```
ここからはお好みで。

```json:package.json
{
  "name": "riotv4-router-sample",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "webpack --mode production --devtool source-map",
    "start": "webpack-dev-server --inline --watch --hot --colors --content-base app/ --open-page index.html --historyApiFallback true -d --port 4500"
  },
  "keywords": [],
  "author": "KAJIKEN <kentaro@kajiken.jp> (http://kajiken.jp)",
  "license": "MIT",
  "dependencies": {},
  "devDependencies": {
    "@babel/core": "^7.7.4",
    "@babel/polyfill": "^7.7.0",
    "@babel/preset-env": "^7.7.4",
    "@riotjs/compiler": "^4.5.2",
    "@riotjs/hot-reload": "^4.0.0",
    "@riotjs/webpack-loader": "^4.0.1",
    "babel-loader": "^8.0.6",
    "riot": "^4.7.0",
    "webpack": "^4.41.2",
    "webpack-cli": "^3.3.10",
    "webpack-dev-server": "^3.9.0"
  }
}
```
```javascript:webpack.config.js
const path = require('path')

module.exports = {
  mode: 'development',
  //mode: 'production',

  entry: ['@babel/polyfill', './src/scripts/index.js'],
  output: {
    path: path.resolve(__dirname, 'app/scripts'),
    filename: 'bundle.js',
    publicPath: '/scripts/',
  },

  devtool: 'inline',
  //devtool: 'source-map',

  module: {
    rules: [
      {
        test: /\.riot$/,
        exclude: /node_modules/,
        use: [{
          loader: '@riotjs/webpack-loader',
          options: {
            hot: true, // set it to true if you are using hmr
            // add here all the other @riotjs/compiler options riot.js.org/compiler
            // template: 'pug' for example
          }
        }]
      },
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        }
      }
    ]
  }
}
```
```json:.babelrc
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules": false,
        "targets": [
          ">0.25%",
          "not ie 11",
          "not op_mini all"
        ]
      }
    ]
  ]
}
```
```:ディレクトリ構成
.
│  .babelrc
│  package.json
│  webpack.config.js
│  
├─app
│  │  index.html
│  │  
│  └─scripts
│          bundle.js
│          bundle.js.map
│          
├─node_modules
│              
└─src
    └─scripts
            goodbye.riot
            greeting.riot
            hello.riot
            home.riot
            index.js
            notfound.riot
```
![rrc_2019_12_06_210631(slt)(raw).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27443/bd3c1d58-b69e-ca23-0308-8aacc965b86d.gif)
突貫工事なのでこのままでは実用には耐えられないだろうが、思い描くSPAの動作になりました！
今後育てていこう。