はじめに
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
に分けたってことですね。components
がviews
になったり、Atomic Designの配置になったりはあるでしょうね。まぁ、ざっくりこんな感じにしましたという報告です。
作業してるとこんな感じになります。build
がプロダクション出力、dist
が開発用出力、.cache
はバンドラーのキャッシュ入れです。これらは.gitignore
に追加した方が良い。
TypeScriptに慣れてない方は「おやおや〜、types
ってなんだ〜?」と思われるでしょうか。@hyperapp/logger
にTypeScript用型定義ファイルがないので、自分のところに置いてます。中身はこのプルリクエストから取ってきた。うんうん、これもまたアイカツだね。
設定ファイル
パッケージ
いきなり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.json
とtslint.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に関するポイントだけ。
jsx
をreact
にjsxFactory
をh
にすれば、TypeScriptでJSXが使えます。jsx
はまぁデフォルトのpreserve
でもいいんですが、これだと変換された後のファイル拡張子が.jsx
になってナウくないので.js
になるようにしてます。
baseUrl
/paths
/typeRoots
はさっきちょろっと書いた@hyperapp/logger
が型定義を持ってないからですね。この中でもpaths
が特に大事でimport
のfrom
句で指定されるパッケージ名の書き換えみたいなもんですね。この場合、import { foo } from '@hyperapp/bar'
したときに、types/@hyperapp-bar
に型定義を探しに行ってくれます。普通はこんな設定しなくても、プロジェクトのどこかにtypes
ってディレクトリ作ったら勝手に見てくれるんですけど、定義ファイルをtypes/@hyperapp/logger/index.d.ts
にディレクトリ深く置いたら、「@hyperapp
の定義がねーよ」って怒られるので、こういう形になりました。
あとは今回の件とはあんま関係ないので、気になったら別で調べてください。
ソース解説
エントリポイント
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
を取り込みます。相対パスになってますが、ビルド時にバンドラーがよろしくやってくれます。便利ですね。
ソースのルートです。
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も公式の使い方通りです。
問題は、Modules
とRoot
やね。Modules
はHyperappに渡すステートとアクションが詰めたものです。次見る。Root
の方はJSXのルートノードを詰めたものです。Root.view
がCreate React Appなんかでは、<App />
相当のやつ。App
だとHyperappのapp
やその型App
と被るのでRoot
としました。まぁ、何でも良い。
モジュール
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
ぐらいしかなかったというわけ。まぁ、字面的にもオブジェクトよりはこれを使うのがふさわしいやろ。おっけー。
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)
}
}
なんか解説しようと思ったけど、型付けて送り出すと間違った使い方したときに怒られが発生して良い以上のことはないな。普通の型付け。interface
をtype
にしてもいいんだけど、TSLintに[tslint] Use an interface instead of a type literal. [interface-over-type-literal]
って怒られます。どうしてもタイプエイリアス使いたい人はTSLint設定で、interface-over-type-literal
をオフにしてください。
あっ、そういえば、Hyperappの機能に、Nested Actionsってのがあるんですけど、TypeScriptで型付けたらできないから。それだけ。
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
が書いてあるやつ。
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
の型付けかな。型テンプレートを使ってあって、なんでも入るようになってるんですが、ここに先ほど作った交差型AppState
とAppActions
をぶち込んで、それら以外の違うメンバの入ったものが渡されたらエラーにするわけですな。
次は、コンポーネントのルートです。
import { Home } from './Home'
import { About } from './About'
export const Components = {
Home,
About
}
受け渡ししてるだけですね。大事な流通業者です。
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>
)
../modules
をimport
するのがちょっと微妙だと思ってて、ページスペシフィックなstate/actionsはこのファイルに直書きした方が良いかもしれない。一応、counterのstate/actionsはこれから作る別のページでも使うって体で別にした。
JSXノードを返す関数を返す関数って作りになってるけど、これはHyperappのLazy Componentsって機能です。これを使うと、グローバルのステートとアクションが渡されてくるので、バケツリレーをしなくてよくなる。エッジで静的なやつはprops: any
を受け取ってJSXノードを返すだけでいいと思う。
返り値の型を特に指定してないわけですが、あんま変なの送ったらHyperappの方の型定義がちゃんと怒ってくれるという寸法です。楽したいんや。できるならany
も省略したいんや。しかし、CLIプログラムやAPI作る時の原則に「入力は寛容に、出力は厳密に」って標語があるんだけど、それとは逆になってるな……。でも、めんどくささが勝った!
これで全部晒したかな?終わり?終わり?
結果
開発サーバーで動かした時のDevToolsのAudits(LightHouse)はこんな感じです。
Best Practicesはhttp2じゃないからってやつで、ホスティング次第ですので、実質100点です!ということに。
PWAはそういう風に作ってないので、また今度Workboxかなんか使ってリポジトリを進めてみましょうかね。
とりあえず、今回はここまで。お疲れ様でした。