Hyperapp V2 のルーターを自作する

Hyperapp V2を使いたいけど公式ルーターがない！でも欲しい！！ということで自作しました。TypeScriptで書いています。[型定義はこちら](https://qiita.com/diontools/items/074dd32f86ecd995aff5)。

# サンプル
JavaScriptにしたサンプル
https://codesandbox.io/embed/zkml2q59j3

# router.ts

```ts
import { h, SubscriptionEffectRunner, SubscriptionEffect, VNode, DispatchType, Effect, Action } from "hyperapp";

export interface RouterProps {
    routes: Route[],
    matched: (route: Route | undefined, dispatch: DispatchType<any, any, any>) => void,
}

export interface Route {
    path: string;
    view: (state: any) => VNode;
}

const routerRunner: SubscriptionEffectRunner<RouterProps> = (props, dispatch) => {
    console.log('routing');
    function onLocationChanged() {
        console.log(window.location.pathname);
        for (const r of props.routes) {
            if (r.path === window.location.pathname) {
                props.matched(r, dispatch);
                return;
            }
        }
        props.matched(undefined, dispatch);
    }

    const push = window.history.pushState;
    const replace = window.history.replaceState;
    window.history.pushState = function (data, title, url) {
        push.call(this, data, title, url);
        onLocationChanged();
    }
    window.history.replaceState = function (data, title, url) {
        replace.call(this, data, title, url);
        onLocationChanged();
    }
    window.addEventListener("popstate", onLocationChanged);

    onLocationChanged();

    return () => {
        console.log('unrouting');
        window.history.pushState = push;
        window.history.replaceState = replace;
        window.removeEventListener("popstate", onLocationChanged);
    };
}

export const createRouter: SubscriptionEffect<RouterProps> = props => ({
    effect: routerRunner,
    ...props,
});

export const pushHistory: Effect<{ pathname: string }> = props => ({
    effect: (props, dispatch) => {
        window.history.pushState(null, '', props.pathname);
    },
    ...props,
});

export interface LinkProps {
    to: string;
}

export function Link(props: LinkProps, children: any) {
    return h('a', { onClick: [MoveTo, props.to], href: props.to }, children);
}

const MoveTo: Action<any, string, Event> = (state, to, ev) => {
    ev.preventDefault();
    return [state, pushHistory({ pathname: to })]
}
```

# index.tsx

```tsx
import { h, app, DispatchableType, Action } from "hyperapp";
import { createRouter, Route, pushHistory, Link } from './router';

function act<S extends object, P, D>(value: DispatchableType<S, P, D>) {
  return value;
}

const mainState = {
  count: 0,
  route: undefined as Route | undefined,
};

type MainState = typeof mainState;
type MainAction<P = {}, D = {}> = Action<MainState, P, D>;

const MoveTo: MainAction<{ pathname: string }> = (state, props) => [state, pushHistory({ pathname: props.pathname })];

const SetCount: MainAction<{ count: number }> = (state, props) => ({ ...state, count: props.count });
const SetRoute: MainAction<{ route: Route }> = (state, props) => ({ ...state, route: props.route });

const router = createRouter({
  routes: [{
    path: '/',
    view: (state: MainState) => <div>home</div>
  }, {
    path: '/abc',
    view: (state: MainState) => (
      <div>
        <button onClick={act([SetCount, { count: state.count + 1 }])}>increment</button>
        <div>count: {state.count}</div>
      </div>
    )
  }, {
    path: '/xyz',
    view: (state: MainState) => <div>xyz</div>
  }],
  matched: (route, dispatch) => dispatch([SetRoute, { route: route }]),
});

app({
  init: mainState,
  view: state => (
    <div>
      <ul>
        <li><Link to="/">home</Link></li>
        <li><Link to="/abc">abc</Link></li>
        <li><Link to="/xyz">xyz</Link></li>
        <li><Link to="/unknown">unknown</Link></li>
      </ul>
      <button onClick={act([MoveTo, { pathname: '/' }])}>home</button>
      <button onClick={act([MoveTo, { pathname: '/abc' }])}>abc</button>
      <button onClick={act([MoveTo, { pathname: '/xyz' }])}>xyz</button>
      <button onClick={act([MoveTo, { pathname: '/unknown' }])}>unknown</button>
      {state.route ? state.route.view(state) : <div>404</div>}
    </div>
  ),
  subscriptions: state => router,
  container: document.body
});
```

# 説明
ルーターをimportします。

```tsx
import { createRouter, Route, pushHistory, Link } from './router';
```

Stateに**`Route`**型の`route`を作っておきます。

```tsx
const mainState = {
  count: 0,
  route: undefined as Route | undefined,
};
```

**`createRouter`**はルーター機能を提供するSubscriptionです。

**`routes`**プロパティに、**`Route`**型の配列を設定します。**`Route`**は、URLの`location.pathname`に一致する**`path`**と、VDOM作成関数**`view`**を持ちます。

**`matched`**プロパティに、現在のURLが変化したときに呼び出される関数を設定します。第1引数に**`routes`**で指定した**`path`**と現在のURLが一致した**`Route`**（一致しない場合は`undefined`）が、第2引数に`dispatch`が渡されます。

```tsx
const router = createRouter({
  routes: [{
    path: '/',
    view: (state: MainState) => <div>home</div>
  }, {
    path: '/abc',
    view: (state: MainState) => (
      <div>
        <button onClick={act([SetCount, { count: state.count + 1 }])}>increment</button>
        <div>count: {state.count}</div>
      </div>
    )
  }, {
    path: '/xyz',
    view: (state: MainState) => <div>xyz</div>
  }],
  matched: (route, dispatch) => dispatch([SetRoute, { route: route }]),
});
```

`SetRoute`は**`matched`**で与えられる`route`をStateに設定するActionです。

```tsx
const SetRoute: MainAction<{ route: Route }> = (state, props) => ({ ...state, route: props.route });
```

`app`の`view`でStateに設定された`route`の**`view`**を呼び出します。URLが`route`の**`path`**に一致しない場合はStateの`route`が`undefined`になるので、404ページなどの表示に使えます。

```tsx
{state.route ? state.route.view(state) : <div>404</div>}
```

**`Link`**でリンク（a要素）を生成します。

```tsx
<ul>
  <li><Link to="/">home</Link></li>
  <li><Link to="/abc">abc</Link></li>
  <li><Link to="/xyz">xyz</Link></li>
  <li><Link to="/unknown">unknown</Link></li>
</ul>
```

**`pushHistory`**は`history.pushState`を実行するEffectです。Actionで使えます。

```tsx
const MoveTo: MainAction<{ pathname: string }> = (state, props) => [state, pushHistory({ pathname: props.pathname })];

<button onClick={[MoveTo, { pathname: '/abc' }]}>abc</button>
```

**`createRouter`**で作成したSubscriptionを、`subscriptions`で返して完了です。

```tsx
app({
  subscriptions: state => router
});
```

# まとめ
そこそこシンプルなルーターができました。`path`のパターンマッチング機能はありませんが、必要最低限のルーターということでまあまあ使えそうです。
Hyperappのモジュール仕様が決まれば公式ルーターも作られることでしょう。:bow: