LoginSignup
11
5

More than 5 years have passed since last update.

Hyperapp with TypeScriptプロジェクトのサンプル

Last updated at Posted at 2018-11-28

はじめに

Hyperappバージョン2が着々と作られる昨今、今更ながら入門をしたんですが、「TypeScriptの型付けどうしよう?」とか「ディレクトリ構造どうしよう?」とか、色々悩むことがありました。格闘した結果、「とりあえず、こんくらいでええやろ」というのができたので共有します。

公式のGetting StartedをJavaScriptからTypeScriptに替えて整理した簡単なものです。

能書きはいいからさっさとソース見せろという人はこちらをご覧ください。
https://github.com/babie/hyperapp-typescript-sample/tree/61a81b547d3734673f633e3d67cd19d702d2b693
Gitのログがあっさりしてるけども、プライベートリポジトリからファイルコピーしてきたんでこうなってます。あしからず。

ターゲット

HyperappウェブアプリをTypeScriptで書きたい人向けで、基本的なHyperappやTypeScriptの知識はあるものとします。

環境

macOS Mojave: 10.14.1(18B75)
Node.js: 10.13.0(lts/dubnium)
Yarn: 1.12.3

で、お送りします。Nodeはv6でも動くやろ、きっと。

概観

ざっと、プロジェクトのディレクトリ構造でも見ときましょうか。

$ tree -a -I '.git|node_modules'
.
├── .cache
│   ├── 00
│   ├── ...
│   └── ff
├── .gitignore
├── LICENSE
├── README.md
├── build
│   ├── index.html
│   └── src.214b09e4.js
├── dist
│   ├── index.html
│   ├── src.77de5100.js
│   └── src.77de5100.map
├── package.json
├── src
│   ├── Root.tsx
│   ├── components
│   │   ├── About.tsx
│   │   ├── Home.tsx
│   │   └── index.ts
│   ├── index.html
│   ├── index.ts
│   └── modules
│       ├── counter.ts
│       ├── index.ts
│       └── locator.ts
├── tsconfig.json
├── tslint.json
├── types
│   └── @hyperapp-logger
│       └── index.d.ts
└── yarn.lock

これ見せるだけで、解説の8割終わった。要は、state/actionsの詰まったmodulesと、JSXの入ったcomponentsに分けたってことですね。componentsviewsになったり、Atomic Designの配置になったりはあるでしょうね。まぁ、ざっくりこんな感じにしましたという報告です。

作業してるとこんな感じになります。buildがプロダクション出力、distが開発用出力、.cacheはバンドラーのキャッシュ入れです。これらは.gitignoreに追加した方が良い。

TypeScriptに慣れてない方は「おやおや〜、typesってなんだ〜?」と思われるでしょうか。@hyperapp/loggerにTypeScript用型定義ファイルがないので、自分のところに置いてます。中身はこのプルリクエストから取ってきた。うんうん、これもまたアイカツだね。

設定ファイル

パッケージ

いきなりpackage.jsonを貼る。

package.json
{
  "name": "hyperapp-typescript-sample",
  "scripts": {
    "start": "parcel src/index.html",
    "build": "parcel build src/index.html --out-dir build --no-source-maps --experimental-scope-hoisting",
    "clean": "rm -rf .cache dist build",
    "lint": "lynt src"
  },
  "dependencies": {
    "@hyperapp/logger": "^0.5.0",
    "@hyperapp/router": "^0.7.1",
    "hyperapp": "^1.2.9"
  },
  "devDependencies": {
    "@types/parcel-env": "^0.0.0",
    "lynt": "^0.5.4",
    "parcel": "^1.10.3",
    "prettier": "^1.15.2",
    "typescript": "^3.1.6"
  },
  "private": true,
  "lynt": {
    "typescript": true,
    "react": true,
    "ignore": ["tests/**/*.*", "fixtures/**/*.*"],
    "rules": {
      "jsx-no-lambda": "off"
    }
  },
  "prettier": {
    "semi": false,
    "singleQuote": true
  }
}

バンドラーにParcel、リントにLynt、整形にPrettierを使用してますね。趣味です。まぁ、Parcelで動けばWebpackでも動きます。逆はそうでもない。

Parcel/Lyntはどちらも設定ファイル要らないんですが、外観にあるように、tsconfig.jsontslint.jsonを置いてあります。これはコードで型などを間違ったときにVSCodeの拡張機能から怒ってもらいたいからです。怒られると、気持ちいい。

肝心のHyperapp関連ですが、本体hyperappの他に@hyperapp/router@hyperapp/loggerを入れてます。その名の通り、ルーティングとログのためですね。

Yarnスクリプトですが、

  • yarn startが開発用ビルドとサーバーの起動
  • yarn buildがプロダクション用ビルド
  • yarn cleanが上記の掃除(yarn.lock/node_modulesは消さない)
  • yarn lintがリント

です。

Lyntの設定でjsx-no-lambdaがありますが、Reactの場合JSXで無名関数使うと、無名関数は毎回違うidを差し出し差分が発生するため怒られるんですが、Hyperappの場合はまぁ、無名関数使わずに関数内関数?かなんか書いてbindかなんかして?なんとかする方が良いのでしょうけど、公式のGetting Startedに書いてあるし、ダルいのでオフにしてあります。

Prettierの設定では、セミコロンを無しに、文字列はシングルクォートになるようにしてます。JavaScript Standard Style派なので。これからの正義の話をしよう。

まぁ、何はなくとも、yarn installしてください。

TypeScriptの設定

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "lib": ["es5", "dom"],
    "jsx": "react",
    "jsxFactory": "h",
    "outDir": "./build",
    "strict": true,
    "baseUrl": "./",
    "paths": { "@hyperapp/*": ["types/@hyperapp-*"] },
    "typeRoots": ["types", "node_modules/@types"],
    "esModuleInterop": true 
  },
  "compileOnSave": true,
  "include": ["src/**/*.ts", "src/**/*.tsx"],
  "exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts"]
}

Hyperappに関するポイントだけ。

jsxreactjsxFactoryhにすれば、TypeScriptでJSXが使えます。jsxはまぁデフォルトのpreserveでもいいんですが、これだと変換された後のファイル拡張子が.jsxになってナウくないので.jsになるようにしてます。

baseUrl/paths/typeRootsはさっきちょろっと書いた@hyperapp/loggerが型定義を持ってないからですね。この中でもpathsが特に大事でimportfrom句で指定されるパッケージ名の書き換えみたいなもんですね。この場合、import { foo } from '@hyperapp/bar'したときに、types/@hyperapp-barに型定義を探しに行ってくれます。普通はこんな設定しなくても、プロジェクトのどこかにtypesってディレクトリ作ったら勝手に見てくれるんですけど、定義ファイルをtypes/@hyperapp/logger/index.d.tsにディレクトリ深く置いたら、「@hyperappの定義がねーよ」って怒られるので、こういう形になりました。

あとは今回の件とはあんま関係ないので、気になったら別で調べてください。

ソース解説

エントリポイント

HTMLです。

src/index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Welcome to Hyperapp</title>
    <meta name="description" content="Hyperapp with TypeScript Sample Page." />
  </head>
  <body>
    <script src="./index.ts"></script>
  </body>
</html>

なんてことはないHTMLですね。src/index.tsを取り込みます。相対パスになってますが、ビルド時にバンドラーがよろしくやってくれます。便利ですね。

ソースのルートです。

src/index.ts
import { app } from 'hyperapp'
import { location } from '@hyperapp/router'

import { Modules as M } from './modules'
import { Root as R } from './Root'

let main
if (process.env.NODE_ENV === 'development' && module.hot) {
  main = require('@hyperapp/logger').withLogger(app)(
    M.state,
    M.actions,
    R.view,
    document.body
  )
} else {
  main = app(M.state, M.actions, R.view, document.body)
}
const unsubscribe = location.subscribe(main.location)

まぁ、細かく解説はしなくてもええやろ。ここではTypeScriptらしさとかないし。HyperappもHyperapp Routerも公式の使い方通りです。

問題は、ModulesRootやね。ModulesはHyperappに渡すステートとアクションが詰めたものです。次見る。Rootの方はJSXのルートノードを詰めたものです。Root.viewがCreate React Appなんかでは、<App />相当のやつ。AppだとHyperappのappやその型Appと被るのでRootとしました。まぁ、何でも良い。

モジュール

src/modules/index.ts
import { Counter } from './counter'
import { Locator } from './locator'

export namespace Modules {
  export type AppState = Counter.IState & Locator.IState
  export type AppActions = Counter.IActions & Locator.IActions
  export const state: AppState = { ...Counter.state, ...Locator.state }
  export const actions: AppActions = { ...Counter.actions, ...Locator.actions }
}

ゲーッ!namespace!交差型!オブジェクトスプレッド……は普通か。

交差型は複数のインターフェースやタイプエイリアスをマージするやつね。メンバが合成される。ちなみに同じ名前・同じ型のメンバがそれぞれにあっても、コンパイラに怒られない。なにそれこわい。IPropsはその名の通りinterfaceなんだけど、typeにしても別に怒られは発生しない。んー、どっかで見たTypeScript情報ではエラーが発生するって書いてあった気がするんだがな。なので注意なのです。

namespaceはTypeScript使ってる界隈でもあんま推奨されてないんだが、私はJSでexportするときにobjectをコンテナとして詰めて送り出すのが好きというのがまずあり、TypeScriptでそれをやろうと思ったら、インターフェースを詰められるものがnamespaceぐらいしかなかったというわけ。まぁ、字面的にもオブジェクトよりはこれを使うのがふさわしいやろ。おっけー。

src/modules/counter.ts
export namespace Counter {
  export interface IState {
    count: number
  }
  export const state: IState = {
    count: 0
  }

  export interface IActions {
    down: (value: number) => (state: IState) => IState
    up: (value: number) => (state: IState) => IState
  }
  export const actions: IActions = {
    down: (value: number) => (state: IState) =>
      ({ count: state.count - value } as IState),
    up: (value: number) => (state: IState) =>
      ({ count: state.count + value } as IState)
  }
}

なんか解説しようと思ったけど、型付けて送り出すと間違った使い方したときに怒られが発生して良い以上のことはないな。普通の型付け。interfacetypeにしてもいいんだけど、TSLintに[tslint] Use an interface instead of a type literal. [interface-over-type-literal]って怒られます。どうしてもタイプエイリアス使いたい人はTSLint設定で、interface-over-type-literalをオフにしてください。

あっ、そういえば、Hyperappの機能に、Nested Actionsってのがあるんですけど、TypeScriptで型付けたらできないから。それだけ。

src/modules/locator.ts
import { location, LocationState, LocationActions } from '@hyperapp/router'

export namespace Locator {
  export interface IState {
    location: LocationState
  }
  export const state: IState = {
    location: location.state
  }

  export interface IActions {
    location: LocationActions
  }
  export const actions: IActions = {
    location: location.actions
  }
}

Hyperapp Router公式のUsageをファイル配置を変えて型付けただけです。あ、importで型持ってきてるね。型ファイルはさっき言ってたプルリクエストのやつ。

コンポーネント

んで、まずはルーティングです。ソースのルートで呼び出してたRootが書いてあるやつ。

src/Root.tsx
import { h, View } from 'hyperapp'
import { Switch, Route } from '@hyperapp/router'
import { Modules as M } from './modules'
import { Components as C } from './components'

const view: View<M.AppState, M.AppActions> = () => (
  <Switch>
    <Route path="/" render={C.Home} />
    <Route path="/about" render={C.About} />
  </Switch>
)

export const Root = {
  view
}

@hyperapp/routerを使って、パスによって表示を切り替えしてますね。React Routerとか使ってる人にはお馴染みの書き方だと思う。

ポイントはViewの型付けかな。型テンプレートを使ってあって、なんでも入るようになってるんですが、ここに先ほど作った交差型AppStateAppActionsをぶち込んで、それら以外の違うメンバの入ったものが渡されたらエラーにするわけですな。

次は、コンポーネントのルートです。

src/components/index.ts
import { Home } from './Home'
import { About } from './About'

export const Components = {
  Home,
  About
}

受け渡ししてるだけですね。大事な流通業者です。

src/components/Home.tsx
import { h } from 'hyperapp'
import { Link } from '@hyperapp/router'
import { Modules as M } from '../modules'

export const Home = (): any => (state: M.AppState, actions: M.AppActions) => (
  <div>
    <h1>{state.count}</h1>
    <button onclick={() => actions.down(1)}>-</button>
    <button onclick={() => actions.up(1)}>+</button>
    <div>
      <Link to="/about">About</Link>
    </div>
  </div>
)

../modulesimportするのがちょっと微妙だと思ってて、ページスペシフィックなstate/actionsはこのファイルに直書きした方が良いかもしれない。一応、counterのstate/actionsはこれから作る別のページでも使うって体で別にした。

JSXノードを返す関数を返す関数って作りになってるけど、これはHyperappのLazy Componentsって機能です。これを使うと、グローバルのステートとアクションが渡されてくるので、バケツリレーをしなくてよくなる。エッジで静的なやつはprops: anyを受け取ってJSXノードを返すだけでいいと思う。

返り値の型を特に指定してないわけですが、あんま変なの送ったらHyperappの方の型定義がちゃんと怒ってくれるという寸法です。楽したいんや。できるならanyも省略したいんや。しかし、CLIプログラムやAPI作る時の原則に「入力は寛容に、出力は厳密に」って標語があるんだけど、それとは逆になってるな……。でも、めんどくささが勝った!

これで全部晒したかな?終わり?終わり?

結果

開発サーバーで動かした時のDevToolsのAudits(LightHouse)はこんな感じです。

スクリーンショット 2018-11-28 17.24.57.png

Best Practicesはhttp2じゃないからってやつで、ホスティング次第ですので、実質100点です!ということに。

PWAはそういう風に作ってないので、また今度Workboxかなんか使ってリポジトリを進めてみましょうかね。

とりあえず、今回はここまで。お疲れ様でした。

11
5
3

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
11
5