以前に書いた「Hyperapp(V2) をガッチガチのタイプセーフにした HyperTSApp を作る」から、より改善したライブラリを作りました。
Typerapp
TyperappはHyperapp V2をタイプセーフになるように変更したWebフレームワークです。TypeScriptで書かれています。
試行錯誤1の結果、CodeSandbox上で完璧に動作します。
インストール
npm install typerapp
TyperappではNodeモジュール内のTypeScriptファイルを直接使用するので、Webpackのts-loaderを使用する場合は、allowTsInNodeModulesを有効にし、tsconfig.jsonで
node_modules/typerapp
をincludeしてください。Parcelではこれらの設定は不要です。
Hyperappからの変更点
- Actionから
data
引数を削除 -
view
の引数にdispatch
を追加 - 純粋なDOMイベント
Actionからdata
引数を削除
Actionの引数は2つだけになります。
Hyperapp:
const Act = (state, { value }, data) => ({...})
Typerapp:
const Act: Action<State, { value: number }> = (state, params) => ({...})
view
の引数にdispatch
を追加
Hyperapp:
app({
view: state => ...
})
Typerapp:
app<State>({
view: (state, dispatch) => ...
})
純粋なDOMイベント
Typerappでは、VDOMのイベントにEventを引数に持つ関数を指定します。
そして、dispatch
を使用してActionを呼び出します。
Hyperapp:
const Input = (state, ev) => ({ ...state, value: ev.currentTarget.value })
app({
view: state => <div>
<input
value={state.value}
onInput={Input}
/>
</div>
})
Typerapp:
const Input: Action<State, string> = (state, value) => ({ ...state, value })
app<State>({
view: (state, dispatch) => <div>
<input
value={state.value}
onInput={ev => dispatch(Input, ev.currentTarget.value)}
/>
</div>
})
型定義
タイプセーフなActions、Effects、Subscriptions、HTML要素などなど...
Actions
型定義:
export type ActionResult<S> = S | [S, ...Effect<any, any>[]]
export type Action<S, P = Empty> = (state: S, params: P) => ActionResult<S>
使い方:
// without parameter
const Increment: Action<State> = state => ({ ...state, value: state.value + 1 })
// with parameter
const Add: Action<State, { amount: number }> = (state, params) => ({
...state,
value: state.value + params.amount
})
Effects
型定義:
export type Effect<S, P = Empty> = [(props: P, dispatch: Dispatch<S>) => void, P]
Effectの宣言:
// Delay Runner Props
export type DelayProps<S, P> = {
action: EffectAction<S, P>
duration: number
}
// Delay Effect Runner
const DelayRunner = <S, P>(props: DelayProps<S, P>, dispatch: Dispatch<S>) => {
setTimeout(() => dispatch(props.action), props.duration)
}
// Delay Effect Constructor
export function delay<S, P>(action: DelayProps<S, P>['action'], props: { duration: number }): Effect<S, DelayProps<S, P>> {
return [DelayRunner, { action, duration: props.duration }];
}
使い方:
// Increment with Delay
const DelayIncrement: Action<State> = state => [
state,
delay(Increment, { duration: 1000 })
]
// Add with Delay
const DelayAdd: Action<State, { amount: number }> = (state, params) => [
state,
delay([Add, { amount: params.amount }], { duration: 1000 })
]
Subscriptions
型定義:
export type Subscription<S, P = Empty> = [(props: P, dispatch: Dispatch<S>) => () => void, P]
Subscriptionの宣言:
// Timer Runner Props
export type TimerProps<S, P> = {
action: EffectAction<S, P>
interval: number
}
// Timer Subscription Runner
const timerRunner = <S, P>(props: TimerProps<S, P>, dispatch: Dispatch<S>) => {
const id = setInterval(() => dispatch(props.action), props.interval)
return () => clearInterval(id)
}
// Timer Subscription Constructor
export function timer<S, P>(action: TimerProps<S, P>['action'], props: { interval: number }): Subscription<S, TimerProps<S, P>> {
return [timerRunner, { action, interval: props.interval }]
}
使い方:
app<State>({
subscriptions: state => [
timer(Increment, { interval: 1000 })
]
})
HTML要素
DefinitelyTypedのReactからフォークしてHtml.d.tsを作成しました。
制限事項
TypeScriptはActionの超過プロパティをチェックしません。
const Act: Action<State> = state => ({
...state,
typo: 1 // no error!
})
回避策:
// type alias for Action/ActionResult
type MyAction<P = Empty> = Action<State, P>
type MyResult = ActionResult<State>
// explicit return type
const Act: MyAction = (state): MyResult => ({
...state,
typo: 1 // error
})
真の解決のためには、Exact Typesへ投票してください。
追加機能
Typerappにはいくつか追加機能があります。
actionCreator
actionCreator
はシンプルなモジュール化関数です。
// part.tsx
import { h, View, actionCreator } from 'typerapp'
type State = {
foo: string,
part: {
value: number
}
}
const createAction = actionCreator<State>()('part')
const Add = createAction<{ amount: number }>(state => ({
...state,
value: state.value + params.amount
}))
export const view: View<State> = ({ part: state }, dispatch) => <div>
{state.value} <button onClick={ev => dispatch(Add, { amount: 10 })}>request</button>
</div>
ActionParamOf
ActionParamOf
型はEffect/Subscription ConstructorからActionのパラメーター型を取得します。
import { ActionParamOf } from 'typerapp'
import { httpJson } from 'typerapp/fx'
// { json: unknown }
type ParamType = ActionParamOf<typeof httpJson>
const JsonReceived: Action<State, ParamType> = (state, params) => ({
...state,
text: JSON.stringify(params.json)
})
Helmet
Helmet
はDOMのhead要素にレンダリングします。
import { Helmet } from 'typerapp/helment'
app<State>({
view: (state, dispatch) => <div>
<Helmet>
<title>{state.title}</title>
</Helmet>
</div>
})
パフォーマンス向上のためLazyを使用することをオススメします。
const renderHead = (props: { title: string }) => <Helmet>
<title>{props.title}</title>
</Helmet>
app<State>({
view: (state, dispatch) => <div>
<Lazy key="head" render={renderHead} title={state.title} />
</div>
})
Router
RouterはURLベースのルーティングSubscriptionです。RouterはHistory APIによってURLからStateに反映します。
import { createRouter, Link, RoutingInfo, Redirect } from 'typerapp/router'
// Update routing
const SetRoute: Action<State, RoutingInfo<State, RouteProps> | undefined> = (state, route) => ({
...state,
routing: route,
})
// Create router
const router = createRouter<State, RouteProps>({
routes: [{
title: (state, params) => 'HOME',
path: '/',
view: (state, dispatch, params) => <div>home</div>,
}, {
title: (state, params) => 'Counter / ' + params.amount,
path: '/counter/:amount',
view: (state, dispatch, params) => {
const amount = params.amount ? parseInt(params.amount, 10) : 1
return <div>
<div>{state.value}</div>
<button onClick={ev => dispatch(Add, { amount })}></button>
</div>
},
}, {
title: (state, params) => 'Redirect!',
path: '/redirect',
view: (state, dispatch, params) => <Redirect to="/" />,
}],
matched: (routing, dispatch) => dispatch(SetRoute, routing),
})
app<State>({
view: (state, dispatch) => <div>
<div><Link to="/">Home</Link></div>
<div><Link to="/Counter/10">Count10</Link></div>
<div><Link to="/Redirect">Redirect</Link></div>
{
state.routing
? state.routing.route.view(state, dispatch, state.routing.params)
: <div>404</div>
}
</div>
})
CSS-in-JS
style
はPicostyleからフォークした関数です。
import { style } from 'typerapp/style'
// styled div
const Wrapper = style('div')({
backgroundColor: 'skyblue',
width: '50px',
})
// styled div with parameter
const StyledText = style<{ color: string }>('div')(props => ({
color: props.color,
transition: "transform .2s ease-out",
":hover": {
transform: "scale(1.5)",
},
"@media (orientation: landscape)": {
fontWeight: "bold",
},
}))
app<State>({
view: (state, dispatch) => <div>
<Wrapper>
<StyledText color="green">text</StyledText>
</Wrapper>
</div>
})
ハイフン付けされたSVG属性の別名
TypeScriptはTSX上のハイフン付けされた属性をチェックしません。
型をチェックできるCamel-caseの属性を使用するためにtyperapp/main/svg-alias
をインポートしてください。
以下では、strokeWidth
とstrokeDasharray
がstroke-width
とstroke-dasharray
に変換されます。
import "typerapp/main/svg-alias"
<svg x="0px" y="0px" width="200px" height="3" viewBox="0 0 200 1">
<line
x1="0"
y1="0.5"
x2="200px"
y2="0.5"
stroke="skyblue"
strokeWidth={3}
strokeDasharray={5}
/>
</svg>
mergeAction
TyperappではActionにdata
が無いため、Effect/SubscriptionでActionによって値を返す場合、Actionパラメーターに戻り値を合成する必要があります。
この場合、mergeAction
を使用することができます。
import { EffectAction, Dispatch, Effect } from "typerapp"
import { mergeAction } from 'typerapp/fx/utils'
export type RunnerProps<S, P> = {
action: EffectAction<S, P, { returnValue: number }>
}
const effectRunner = <S, P>(props: RunnerProps<S, P>, dispatch: Dispatch<S>) => {
dispatch(mergeAction(props.action, { returnValue: 1234 }))
}
export function effect<S, P>(action: RunnerProps<S, P>["action"]): Effect<S, RunnerProps<S, P>> {
return [effectRunner, { action }]
}
おわりに
かなりHyperappに近い形でタイプセーフになってるんじゃないかと思います。
VDOMのイベントからdispatch
を分離するだけで、Hyperappの型付け問題が8割解決しますね。(Hyperappがそうなるとは思えませんが... )
もしくはIntrinsicElementsにStateの型を渡せるようにできる天才どこかにいないかな
-
npmパッケージ内のpackage.jsonのmainにコンパイル済みのJavaScriptファイルを、typesにTypeScriptのソースコードファイルを指定するとCodeSandboxでソースコードを読み込んでくれる。Why? ↩