Hyperapp V2を使いたいけど公式ルーターがない!でも欲しい!!ということで自作しました。TypeScriptで書いています。型定義はこちら。
サンプル
JavaScriptにしたサンプル
https://codesandbox.io/embed/zkml2q59j3
router.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
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します。
import { createRouter, Route, pushHistory, Link } from './router';
StateにRoute
型のroute
を作っておきます。
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
が渡されます。
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です。
const SetRoute: MainAction<{ route: Route }> = (state, props) => ({ ...state, route: props.route });
app
のview
でStateに設定されたroute
のview
を呼び出します。URLがroute
のpath
に一致しない場合はStateのroute
がundefined
になるので、404ページなどの表示に使えます。
{state.route ? state.route.view(state) : <div>404</div>}
Link
でリンク(a要素)を生成します。
<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で使えます。
const MoveTo: MainAction<{ pathname: string }> = (state, props) => [state, pushHistory({ pathname: props.pathname })];
<button onClick={[MoveTo, { pathname: '/abc' }]}>abc</button>
createRouter
で作成したSubscriptionを、subscriptions
で返して完了です。
app({
subscriptions: state => router
});
まとめ
そこそこシンプルなルーターができました。path
のパターンマッチング機能はありませんが、必要最低限のルーターということでまあまあ使えそうです。
Hyperappのモジュール仕様が決まれば公式ルーターも作られることでしょう。