Hyperapp & TypeScriptを試す

こんにちは、@binarytaです。
最近ちょくちょく噂を聞くHyperappに興味を持ったのでTypescriptと組み合わせて実践してみようと思います。
Vue.jsやReactもよく触れているのでこの辺との違いについても言及できたらと思ってます。

まずHyperappが提唱するコンセプトがこちら。

  • Minimal — Hyperapp was born out of the attempt to do more with less. We have aggressively minimized the concepts you need to understand while remaining on par with what other frameworks can do.
  • Functional — Hyperapp's design is inspired by The Elm Architecture. Create scalable browser-based applications using a functional paradigm. The twist is you don't have to learn a new language.
  • Batteries-included — Out of the box, Hyperapp combines state management with a Virtual DOM engine that supports keyed updates & lifecycle events — all with no dependencies.

超軽量で関数型、状態更新等の効率化を謳っています。
Hyperapp自体のソースコードはそれほど多くなく、機能も最小限に抑えていそうなのであまり複雑な概念とかはなさそうです。
ということで早速動かしてみます。

環境構築

今回はwebpackでビルドしていくので、package.jsonとwebpack.config.jsの内容を貼っておきます。

package.json

{
  "name": "hyperapp_typescript_introduction",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "devDependencies": {
    "awesome-typescript-loader": "^3.4.1",
    "babel": "^6.23.0",
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-plugin-transform-react-jsx": "^6.24.1",
    "babel-preset-env": "^1.6.1",
    "babel-preset-es2015": "^6.24.1",
    "ts-loader": "^3.5.0",
    "tslint": "^5.9.1",
    "tslint-loader": "^3.5.3",
    "typescript": "^2.7.2",
    "webpack": "^3.11.0"
  },  
  "scripts": {
    "tsc": "tsc",
    "build:watch": "webpack -w --config webpack.config.js"
  },  
  "dependencies": {
    "hyperapp": "^1.1.2"
  }
}  

  

webpack.config.js

const path = require('path');

module.exports = { 
  entry: [path.resolve(__dirname, './src/app.ts')],
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'public/')
  },  
  module: {
    rules: [
      {   
        test: /\.tsx?$/,
        exclude: /node_modules/,
        enforce: 'pre',
        loader: 'tslint-loader',
        options: {
          failOnHint: true
        }   
      },  
      {   
        test: /\.tsx?$/,
        exclude: /node_modules/,
        loader: 'ts-loader'
      }   
    ]   
  },  
  resolve: {
    extensions: ['.ts', '.tsx']
  }
}

現状で全て最新のものを使用してます。
完成後のディレクトリ構成はこんな感じ。

.
├── package.json
├── public
│   ├── bundle.js
│   └── index.html
├── src
│   ├── actions.ts
│   ├── app.ts
│   ├── state.ts
│   └── view.tsx
├── tsconfig.json
├── webpack.config.js
└── yarn.lock

htmlの中身はこんな感じです。

public/index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8"/>
    <title>hyperapp + typescript sample</title>
  </head>
  <body>
    <script src="bundle.js"></script>
  </body>
</html>

実践

Hyperapp公式リポジトリに載っているサンプルをTypescriptで実装してみることにします。
Hyperappの主な概念は、View, Action, Stateの3種類です。

  • View: DOMの記述(JSXかh関数)
  • Action: 状態を更新するための関数.
  • State: プレーンなObject. Actionから状態を更新する.
    ViewはStateとActionを受け取り、Viewの中で各Nodeに適宜StateとActionを割り当てていくだけです。
    今回は以下のようにファイルを複数に分割して書いていきます。
src/
├── actions.ts   # Actions定義ファイル
├── app.ts
├── state.ts     # State定義ファイル
└── view.tsx     # View定義ファイル

ではまずStateから実装していきます。

Stateの実装

Typescriptで書くためObjectに対する型定義としてinterfaceを実装します。
そしてstateオブジェクトはそのinterfaceを型として実装します。

state.ts

export interface State {
  count: number;
}
export const state: State = { 
  count: 0
}

Actionsの実装

actionsオブジェクトはActionsTypeという型で定義します。
ActionsTypeはジェネリクス引数に自分で実装する2つの型、 StateActionsを期待します。
各Action (up, down) の戻り値はActionResult<State>という形で型定義ファイルに実装されているので、その通り戻り値に指定します。
なのでまずこちらも同様にinterfaceでActionsの型を定義し、actionsオブジェクトはActionsTypeにStateActionsをジェネリクス 引数に与えた型として定義します。

actions.ts

import { ActionsType, ActionResult } from "hyperapp"
import { State } from "./state"

export interface Actions {
  down: (value: number) => (state: State) => ActionResult<State>;
  up:   (value: number) => (state: State) => ActionResult<State>;
}

export const actions: ActionsType<State, Actions> = {
  down: (value: number) => (state) => {
    return { count: state.count - value }
  },

  up: (value: number) => (state) => {
    return { count: state.count + value }
  }
}

Viewの定義

HyperappではReactのようなJSX構文を用いてViewを実装できます。
別の方法としてHyperscriptのh関数記法でも実装できるみたいです。
JSXの方が楽なので、JSXで実装していきます。
単にJSXでviewを実装するだけなのであまり説明がいらないですね。
しっかり型チェックも効いていて、

<button onclick={() => actions.down("1")}>-</button>
<button onclick={() => actions.up("1")}>+</button>

のように、number型を期待しているactionにnumber型以外を与えてしまうとエラーを吐いてくれます。
Vue.jsだとSingleFieComponent(単一ファイルコンポーネント) のtemplate部分はTypescriptを使っていても型チェックがされないので、JSXはその点すごくいいとおもいます。

view.tsx

import { h, app, View } from "hyperapp"
import { state, State } from "./state"
import { actions, Actions } from "./actions"

export const view: View<State, Actions> = (state, actions) => (
  <div>
    <h1>{state.count}</h1>
    <button onclick={() => actions.down(1)}>-</button>
    <button onclick={() => actions.up(1)}>+</button>
  </div>
);

State, Actions, ViewをDOMに割り当てる

仮想DOMを実際のDOMに割り当てるための最後の実装です。
今回実装したState, Actions, Viewをapp関数に引数として渡して、どのノードに割り当てるかという情報を与えてあげるだけです。

import { h, app } from "hyperapp"
import { state, State } from "./state"
import { actions, Actions } from "./actions"
import { view } from "./view.tsx"

app<State, Actions>(state, actions, view, document.body);

Hyperappの型定義ファイルについて

VueやReact, Angularなど有名なフレームワークは型定義ファイルもそれなりに膨らんでいて、Typescriptで型を明示するときにそのフレームワークの型定義ファイルを読み解くのに時間がかかったりすることがあります。
型定義ファイルがシンプルに保たれているのは魅力のひとつです。
ちょっとだけ内部の型定義ファイルを覗いて見ます。

hyperapp.d.ts

/* ~~~~~~~~~~ */

export type ActionsType<State, Actions> = { 
  [P in keyof Actions]:
    | ActionType<State, Actions>
    | ActionsType<any, Actions[P]>
}

/* ~~~~~~~~~~ */

export interface View<State, Actions> {
  (state: State, actions: Actions): VNode<object>
}

/* ~~~~~~~~~~ */

export function app<State, Actions>(
  state: State,
  actions: ActionsType<State, Actions>,
  view: View<State, Actions>,
  container: Element | null
): Actions

/* ~~~~~~~~~~ */

Stateという型パラメータは内部で定義されていないので自身でinterfaceまたはclassで型を実装してあげる必要があります。
次にactionオブジェクトはActionsTypeという型で定義していました。
これはapp関数の第2引数のactionsがActionsType<State, Actions>を期待してるためです。
またActionsという型パラメータも自分で実装してあげる必要があります。
最後にviewオブジェクトは<State, Actions>をジェネリクス引数で受け取りJSXを実装する関数でした。
JSX (またはh関数) は最終的にVNode<object>型となるためです。

パフォーマンス比較

f:id:AdwaysEngineerBlog:20180720144551p:plain

Hyperappが他の2つより最も優れているのはstartup time (読み込み、解析、起動) でした。
逆に他の2つに比べてパフォーマンスが劣化しているのは更新系処理の partial update, replace all rowsです。

 

f:id:AdwaysEngineerBlog:20180720144545p:plain

軽量なだけあって、VueやReactに比べて操作に必要なメモリが少なくて済んでいます。

まとめ

Reactを使用したときにPropsの型定義などで時間を費やしたこともあったので、やはり型定義ファイルがシンプルなのは魅力ですね。
一方Vue.jsはvue-class-componentというライブラリを使用すると型をアノテーションで注釈してあげるだけでいいので楽です。
Hyperappは他のJavaScriptフレームワークと比較すると非常に学習コストも低く習得しやすいです。また、多くのJavaScriptフレームワークのコンポーネント思考や仮想DOMの概念なども取り込んでいるので他のフレームワークに移行する際の障壁もかなり低くなるはずです。
Hyperappはちらほら耳にするようになったので今後が期待です!