LoginSignup
7
5

More than 5 years have passed since last update.

Typerapp: タイプセーフなHyperapp V2

Posted at

以前に書いた「Hyperapp(V2) をガッチガチのタイプセーフにした HyperTSApp を作る」から、より改善したライブラリを作りました。

Typerapp

TyperappはHyperapp V2をタイプセーフになるように変更したWebフレームワークです。TypeScriptで書かれています。

GitHubリポジトリ

サンプル: Edit typerapp-sample
最小サンプル: Edit typerapp-minimum-sample

試行錯誤1の結果、CodeSandbox上で完璧に動作します。:v:

インストール

npm install typerapp

TyperappではNodeモジュール内のTypeScriptファイルを直接使用するので、Webpackのts-loaderを使用する場合は、allowTsInNodeModulesを有効にし、tsconfig.jsonでnode_modules/typerappをincludeしてください。Parcelではこれらの設定は不要です。

Hyperappからの変更点

  1. Actionからdata引数を削除
  2. viewの引数にdispatchを追加
  3. 純粋な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

stylePicostyleからフォークした関数です。

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をインポートしてください。

以下では、strokeWidthstrokeDasharraystroke-widthstroke-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がそうなるとは思えませんが... :sob:
もしくはIntrinsicElementsにStateの型を渡せるようにできる天才どこかにいないかな:innocent:


  1. npmパッケージ内のpackage.jsonのmainにコンパイル済みのJavaScriptファイルを、typesにTypeScriptのソースコードファイルを指定するとCodeSandboxでソースコードを読み込んでくれる。Why? 

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5