Hyperappでタスク管理アプリを作った
1. はじめに
表題の通りアプリを作ったので記事にしておこうと思います! 初めてHyperappで開発を始める方は 基本的な考え方を是非こちらでご確認ください。 https://qiita.com/hajime-nohara/items/888aae1c4e553f3cec86
実際にアプリを触りながら読んでもらえると良いと思います。 https://www.sharpen.tokyo/gantt.html
本記事はアプリの実装内容についてザックリ記し、開発環境設定については触れません。
※ 以降、本記事で紹介するアプリをsharpenと呼びます ※ 発展途上アプリです、ご指摘やアドバイスお願いします😉
1-1. sharpenはどんなアプリ?
シンプルなガントチャートUIをベースにした タスク管理アプリです。 デザイン、UX、共にシンプルを追求。
1-2. 設計思想
・サンプルと呼べるほどのライトさ ・堅実な振る舞いの永続性 ・mobile deviceにも対応
2. ソースコード解説
2-1. ディレクトリ構造
Hyperappの基本概念であるState, View, Action ごとに
ディレクトリを分けています。MVC風に言うとSVAですね。
その他、付随的なディレクトリがありますがここでは説明を省きます。
src/index.jsがエントリポイントになっていて、
そこでid="sharpen"
のDOMの中にHyperappで生成したDOMを展開します。
基本構造
▾ src/ ▾ actions/ index.js table.js ▾ state/ index.js ▾ views/ detailModal.js ganttBar.js index.js ... index.js index.pug
実際のディレクトリ構造 https://github.com/hajime-nohara/sharpen/tree/develop/src
2-2. State
sharpenで作成されるデータのひな形になる部分です。 sharpenのStateの中身は、大きく2つに分類できます。
https://github.com/hajime-nohara/sharpen/blob/master/src/state/index.js
分類 | 内容 |
---|---|
タスクを纏めるプロジェクトの共通データ | プロジェクト名、locale etc |
タスクに関するデータ | タスク名、開始/終了日付、todoリスト etc |
sharpenは、1つのプロジェクトのデータを1つのStateの変数で管理し、 ユーザが複数のState(プロジェクト)を管理できるように設計しています。 state/index.jsには初期データとしてStateが定義されています。 ユーザ操作によって変更されたStateは永続的に保存できる領域に Storeし、以降は保存したStateをロードします。
sharpenの特徴は、 個々のデータ項目のためにDBカラムを定義せず、Stateをまるごと保存するところです。 その為、State内のレイアウトを変更したい場合は、jsでState内のレイアウト変更する処理を行います。 簡素化&スピーディな実装のための設計なんですが、 State(jsonオブジェクト)をベースに動くHyperappだから採用できたと言えます。
2-3. View
ganttBar.jsとdetailModal.jsの2つが中心的なViewです。 現在、タスクは全てGantt chart形式のBarで表現しています。 ganttBar.jsで一本のBarを表現します。 Barをタップした際にでモーダルダイアログを表示し、 詳細情報の表示/編集を行います。それはdetailModal.jsで実装されています。 その他のViewは、日付のヘッダ、ツールバー、モーダルダイアログ etcです。 これら付随機能も同じようにコンポーネント化して切り出し、別ファイルで書いています。
Viewでは以下の2つを実装しています ① JSX syntax をインターフェースとして VirtualDOMを生成 ② onClickなどのイベント属性に指定する処理(関数化して実装。大抵の関数で何らかのActionを呼んでStateを更新しています)
JSXの詳細は割愛しますが、
一般的なMVCのテンプレートエンジンのようなノリでVirtualDOMを定義できる優れものです。
一見HTMLですが、
実態はh
関数で、あくまでもjavascript言語であり、具体的にはHashオブジェクトになります。
ganttBar.js https://github.com/hajime-nohara/sharpen/blob/develop/src/views/ganttBar.js detailModal.js https://github.com/hajime-nohara/sharpen/blob/develop/src/views/detailModal.js
2-4. Action
現在、表(gantt chart大枠)のデータを編集するActionは、 action/table.jsと言う名前で切り出しています。 taskに関するActionもaction/task.jsと行った感じで切り出す予定です。 Actionは多数実装するので1ファイルだと見辛くなります。 カテゴリ分けして切り出した方が良いと思います。 action/table.js https://github.com/hajime-nohara/sharpen/blob/develop/src/actions/table.js
sharpenではStateの直接更新をしています。
ReactではNG行為ですがHyperappではNGではないようです。
Hyperappにおいても、
Actionは新しいState(Hashオブジェクト)をreturnしてStateを更新するのが基本的な実装方法ですが、State内に、配列サイズが可変、階層が深い、といったオブジェクトがある為、
そのような方法を用いています。
Hyperappは、ActionがreturnしたObjectと現在のStateを比較し、両者が
異なる場合にDOMを更新しますので、Stateを直接更新する多くのActionで
DOMを更新するために、return {}
しています。
Stateの階層が深くなるとその更新が面倒になります。コンテンツ内容が充実している場合、 全てのDOMをHyperappで実装するのは 課題があると思います。これは後に触れます。
3. ガントチャートBarの動き
ここはjsの実装に一番時間を使った箇所です。 仕組みは単純ですが、DOMのイベントハンドラを結構使うし クロスブラウザを意識しゴニョゴニョとトライ&エラーがありました。
基本的にはマウスポインタが移動したx軸の距離を取得し、
その値をBarのwidth
OR position left
に反映させるだけです。
https://github.com/hajime-nohara/sharpen/blob/develop/src/views/ganttBar.js
3-1. 例)Barの右端(終了日位置)を右側に伸ばす
順序 | ユーザ挙動 | プログラム挙動 |
---|---|---|
① | Barの右端をクリック | onmousedown or ontouchstart した際の座標位置を変数に保持 |
② | 掴んだままマウスを右に移動 | mousemove でマウス移動中にリアルタイムでBarのwidthを、「現在のwidth + (現在のマウスポインタのx軸の位置-①)」 の値に変更 |
③ | マウスアップ | mouseup or touchend した際の座標位置を取得 |
④ | マウスアップ | 現在のwidth + (③ - ①) の値でStateを更新する(Action実行) |
⑤ | - | BarのDOMを更新(再描画) |
4. Semantic UI
このデザインフレームワークは素敵です。 デザインはオーソドックスでありながらオシャレ。凝った動きもしてくれます。 ドキュメントが見やすく、実はこのSemantic UIを用いた デザイン実装が一番楽しい作業でした!
ダイナミックな動きをするSemantic UIコンポーネントは、
使う際に$('.classname').xxxx()
といった形で初期化するので、
oncreate={(e)=>$(e).xxxx()}
という風にHyperappのライフサイクルイベントで初期化を行なっています。
Semantic UIはjQueryベースのフレームワークですが今のところ問題なくHyperappと共存しております。
しかし、
一部のケースで注意が必要です。
4-1. Hyperappと一緒だとすんなり使えないコンポーネント
Multiple Search Selection
これは、Semantic UIのjsの処理で派手にDOMの追加削除を行なっているので、 使えないです。 State管理のHyperappは勝手にDOMの追加や削除はNGです。 例えば、Multiple Search Selection を使うと Hyperappでリレンダーできなくなります。
Selection系の一部のSemantic UIコンポーネントは、初期化の時にinputタグなんかを追加するので、 そのまま使うとよろしくないですが、 初期化時に生成されるDOMを予め書いておいてから初期化すると使えます。
あと、
modal dialog
これも派手にDOMの追加削除を行うため、
涙し、Semantic UI採用を断念しかけたのですが、
detachable: false
このオプションで回避できました。
それでもlatest versionのSemantic UI だとデザインが崩れましたが、
諦めず頑張ってたら素敵なナイスガイのアドバイスに救われました。
ナイスガイのコメント
version 2.2.14 にしてみとの事
ダウングレードなので嫌でしたがそれでも私は semantic ui を使いたいのです! そんなこんなで、Semantic UIをフル活用です。
5. 採用してしているその他の Library
名前 | 役割 | 公式page | 備考 |
---|---|---|---|
mobile-drag-drop | Mobile deviceでD&Dできる | https://github.com/timruffles/mobile-drag-drop | ガントチャートバーの編集をMobileで実現する為に採用 |
flatpickr | Calendar Library | https://github.com/flatpickr/flatpickr | 美しいデザインのCalendar UI |
dateformat | 日付データの書式を楽に設定できる | https://github.com/felixge/node-dateformat | 便利なフォーマッター |
clipboard | クリップボードコピーができる | https://github.com/zenorocha/clipboard.js | 発行したプロジェクト共有用のURLをコピーする際に使用 |
normalize.css | cssのnormalizeに | https://github.com/necolas/normalize.css |
6. 課題
前述でも少し触れましたが、いくつか課題を抱えています。
6-1. StateやViewをパーシャル化したい
ReactはViewコンポーネント毎にStateを持っていてコンポーネントが完全にパーシャルなんですが、 Hyperappの場合は、Stateは全体で1つしか持てずViewコンポーネント毎にStateを持つ事はできません。 (Reactの時は、Lift upやだな。Stateを共通で持ちたいなと思っていたものですが。。。) 理想としては、Stateは1つで、DOM更新の処理だけをパーシャル化できると完璧なんですが、今のところやり方がわかりません。
何が問題かと言うと、
コンテンツ内容が豊富なページでViewをたくさん実装すると、たった一箇所のDOM更新のためでも、結構な処理コストがかかると言う事です。Hyperappは、VirtualDOM(Hashオブジェクト)の中身をすべて見直して、どの部分が更新されてるかなとチェックするので、Hyperappで描画するDOMの数が多ければ多いほどCPUを使います。 USERの操作感をサクサクさせたいので、State更新を気軽に実装しちゃいますが、Stateがデカイ状態で State更新をバンバン行うと、chromeのCPU使用率がすぐマックスになります。 なので、VirtualDOMの中身を全部チェックしないで、部分的にチェックしてDOM更新したいなと言う事です。
とりあえず、良さそうなテクニックがあるっぽいのでそれを試すつもりです。 hypercraft
6-2. middlewareを実装したいなぁ
やはり、Callback的な共通処理を実装したいケースはあるもので。。。 私の場合は、Stateを更新する際に共通処理を実行したいケースがありました。 middlewareのようにやろうとすると、reduce関数でActionを強引に作り直す、 みたいな事をしなければいけない感じかなと思っています。 Reactはどうやってるんだっけ?と完全に忘れてるので、 もう一度Reactのソースを確認してみようと思う今日この頃です。 とりあえず、最上位のviewの中で共通処理を実装してます。 Hyperappの生みの親である、jorgebucaranさんがslackで親切にその方法を教えてくれました。 ありがとうございます😊
6-3. 深くネストしているStateを扱う場合
これも少々厄介です。 階層深いStatenの奥深くの値の更新や削除は、pertial stateをnestしたアクションで更新するのもきついので強引に直接更新するしかないのか? と言う状態です。
これらの課題に直面すると、1ページ全てをHyperappでやらずに シンプルなSteteでミニマルに使いなさいという事かなと 思うのですが、私は、Hyperappでシングルページアプリケーションを作るために、 引き続き、色々勉強してみます。
hypeappで実装されたアプリを集めて紹介しているサイトなんかもあるので この辺でお勉強してみます。 hyperapp.rocks
7. eslint
オレオレな見やすさを重視にコーディングしていましたが、 派手に怒られました。私が怒られた内容だけメモっておきます。
$ ./node_modules/.bin/eslint ./src/* /Users/hanohara/Desktop/private/develop/sharpen/src/views/messageModal.js 1:19 error Strings must use singlequote quotes 2:8 warning 'utils' is defined but never used. Allowed unused vars must match /h/ no-unused-vars 19:22 error Unexpected usage of doublequote jsx-quotes 21:11 error Empty components are self-closing react/self-closing-comp ✖ 1537 problems (1436 errors, 101 warnings) 1397 errors, 16 warnings potentially fixable with the `--fix` option.
err message | 意味 |
---|---|
Empty components are self-closing | コンテンツがない空のタグはself closingして(例: \) |
A space is required before closing bracket | self closing してるスラッシュの前にはスペースをいれて |
Block must not be padded by blank lines | 関数のブロック{}内の最初に改行を入れないで |
Multiple spaces found before 'from' | 'from'の前に複数にスペースがあるよ |
Strings must use singlequote | String型は シングルクゥオートを使って |
Extra semicolon | 余計なセミコロンがあるよ |
Do not use 'new' for side effects | 副作用狙いでnew使うな |
Unexpected usage of doublequote | ダブルクォートはなしで |
Missing space before => | =>の前にはスペースを |
Missing space after => | =>の後にはスペースを |
Expected indentation of 12 spaces but found 10 | スペース12個分のインデントかと思いますが、10個分しかないっすよ |
Expected '===' and instead saw '==' | '===' じゃなくて '==' になってますよ |
A space is required after ',' | カンマの後ろにはスペースは必須です |
Missing space before function parentheses | functionの括弧の前にはスペースが必要です |
There should be no spaces inside this paren | 括弧の中にスペースは記入すべきじゃない |
Trailing spaces not allowed | 行末のスペースはいけません |
More than 1 blank line not allowed | 改行が2行以上あるのはだめです |
Unexpected space between function name and paren | ファンクション名と括弧の間に不要なスペースがあります |
Expected space or tab after '//' in comment | コメントの//の後ろに不要な空白かタブがあります |
Infix operators must be spaced | 中置演算子の前後は空白が必要です。(a=bとかa+"b"とかで出る) |
Arrow function should not return assignment | アローファンクションは割り当てステートメントを返すべきではない |
Hyperappを理解する
1. はじめに
Hyperappでタスク管理アプリの開発をし、 ある程度理解する事ができたので、思いのままに語ります。
1-1. I love Hyperapp
React,Reduxを愛しState管理以外は考えられない私。 2018年、Hyperappと出会いました。 なんというシンプルさでしょう。 まさに、この1KBのソースコードに出会うため 血と汗を流しながらシステムエンジニアを続けてきたと言っても過言ではありません。 React,Reduxの公式ページを頑張って読んでいた頃が懐かしい。 今はHyperappの1KBのソースを何度も読み返すのです。 是非Hyperappについて語り合いましょう。
1-2. 基本的な考え方を知っておく
State管理が基本です。
先ず、State管理によるDOM更新を考えた天才に、改めて拍手を送りたい👏 おかげで本当に楽しくコーディングが出来るようになりました。
データをStateと言う「状態」として一元的に扱い、 それが必ずDOMに反映される仕組み、なんと素晴らしいシンプルな考え方だ。 ブラウザ上で鮮やかに実行されるDOM更新、まさに芸術的🎨
このState管理がHyperappを使う上で知っておくべき基本的な考え方です。
一言で言うと、
hello world
と描画したければ、Stateをhello world
にしなはれ、
DOMのwidthを100px
に変更したいなら、Stateを100px
に変更しなはれ言う事です。
Stateは 例えるならばお料理の献立レシピです。 私の大好きな献立
主食:コメ(玄米) 主菜:ハンバーグ(牛100%, 手作りデミグラスソース) 副菜:サラダ(豆やレタスなど)
これを奥さんに渡すと晩御飯がテーブルに並びます。 ここで大事なルールがあります。 出てきた料理に勝手に直接手を加える事は禁止です。 例えばハンバーグに市販のケッチャップをかけるとか。 ケチャップが欲しいなら、献立レシピをケッチャップに変更して再度ワイフにお願いするのです。 するとワイフは魔法のごときスピードでトマトを煮てケチャップを作り出し、 あっと言う間に手作りケチャップがかかっています。 ご飯ではなくパンにしたい場合も同様。 つまり、DOMをHyperapp以外の方法で直接いじるな言う事です。 レシピに書いてない変更を加えるのはご法度。 上記のレシピをStateぽく表現するとこんな感じです。
{ name: 'dinner', attributes: {type: 'western', volume: 'large'} chilidren: [ {name: 'staple', attributes{type: 'original'}, children:['rice']} {name: 'mainDish', attributes{sauce: 'demi'}, children:['hanburge']} {name: 'sideDish', attributes{sauce: 'italian'}, children:['bean', 'tomato'...]} ] }
StateはJSONオブジェクトです。データを構造的に管理します。 そして、Stateの中のどの値をどのDOMに反映させるかは、 一般的なMVCフレームワークのviewテンプレートと同じノリで書けます。
描画の拠り所となるデータ群がStateと言う一箇所に集約され、 常に整理整頓された状態を保ちながら実装が出来るのです。
さあ、npmが入っていれば実行環境が整います。お好きなエディタで 衝動的に無計画に右脳で書きまくるのです。 見た目と動きを思いのままに実装すれば、右脳コーディングドリブンで、 データレイアウトは自然と決まっていくのです。 なんと楽しいフレームワーク!
2. 具体的に見ていく
State管理について長くなり恐縮です。 ここからはもう少し具体的な内容です。
2-1. State, View, Action
これがHyperappを使いこなす上で知っておくべき フレームワークの要素です。 昔はMVCフレームワークなんて言葉をよく耳にしましたが、SVAですね。
要素 | 役割 |
---|---|
State | 前述の通り。Model的なデータ |
View | 画面に表示する見た目部分の定義情報 |
Action | User操作からState更新処理を行う部分。画面上操作をStateに反映させる処理部分 |
公式Github 公式ページ記載されているシンプルなサンプル
import { h, app } from "hyperapp" const state = { count: 0 } const actions = { down: value => state => ({ count: state.count - value }), up: value => state => ({ count: state.count + value }) } const view = (state, actions) => ( <div> <h1>{state.count}</h1> <button onclick={() => actions.down(1)}>-</button> <button onclick={() => actions.up(1)}>+</button> </div> ) app(state, actions, view, document.body)
当たり前の事ですが、この3要素すべてjavascriptの変数(JSONオブジェクト)です。 state, actionsは見ての通り、ザ・JSONオブジェクトなので良いとして、 これから始める方は、viewで記述されている、一見HTMLのようなソースである、JSX syntaxがjavascriptには見えないと思います。でもこれも実態は結局jsの変数(JSONオブジェクト)になります。
各ポイントを説明
import { h, app } from "hyperapp"
ソース上ではh
関数は使われていない様に見えますが、これは次の項で触れます。
actions
JSONオブジェクトの中で関数が定義されています。 これはユーザ操作(DOMのイベントハンドラ)と紐づけるべき処理たちです。 最新のStateをreturnしてStateの更新を行なっています。 アロー関数と言うsyntaxなので見慣れていない人は気持ち悪いかもしれませんが、 babelでトランスパイルして見慣れたsyntaxに変換してみると普通の関数です。
Hyperappの中でこのactionsの関数をベースに関数を再定義しています。 ここの部分です。 https://github.com/jorgebucaran/hyperapp/blob/master/src/index.js#L113
view
HTMLの様な物を記述している様に見えますが、HTMLっぽく見えるjsのsyntaxというだけの事です。
実際、view
変数に入ってくる値はJSONオブジェクト(VirtualDOM)です。
app(state, actions, view, document.body)`
このapp
関数がHyperappの処理を実行するjavaでいうmain関数的なものです。
この記事を読むのを即刻やめて、上記サンプルコードを実行しながら、 Hyperappのソースを頑張って読むのが、もっとも確実にHyperappを理解する方法です。 Hyperappソース
ですが、以降でapp
関数とh
関数、私なりに解説します。
2-2. h
関数
私がHyperappの好きなところは、
Hyperappは関数を2つ提供します。以上。と言うところ。
関数名はシンプル。
h
とapp
h
から見ていきます。
h(nodeName, attributes, children)
nodeNem
: String (DOMのnode名です。)
attributes
: JSON (id, class、onclickなどのDOMの属性情報)
children
: Array (h関数で作成したJSONオブジェクトのVirtualDOM、または単純な文字列を持つArray)
このh関数の役割はDOMの情報をJSONオブジェクトで表現する事です。 なぜなら、Hyperappは描画するDOMに関する情報を すべてJSONオブジェクトで持っているからです。
繰り返しですがViewと呼ばれている、JSX syntaxで表現されたVirtualDOMは、 JSONオブジェクトです。
2-2-1. DOM ➡︎ JSONオブジェクト
DOMはidやclassなどの属性、そしてchildren(タグ内にあるDOM) を持っています。例えば、
<div id="father"> <main id="son"> Boy </main> </div>
このようなDOMをJSONオブジェクトで表現すると、こうです。
{ nodeName: "div", attributes: {id: "father"}, children: [ { nodeName: "main", attributes: {id: "son"}, children: [ "Boy" ], key: null } ], key: null }
h
関数は、3つの要素(nodeName, attributes, children)を引数に
渡せば、上記のようなJSONオブジェクトを作って返してくれるのです。
2-2-2. JSX から h関数に
前項で紹介した公式Githubのサンプルソースでは、h
関数は見当たりませんでしたが、hyperappで実装したソースコードはトランスパイルしてからブラウザに読ませる必要があります。
babelでソースをトランスパイルすると、JSXで定義された情報がh
関数で表現されます。
const view = (state, actions) => ( <div> <h1>{state.count}</h1> <button onclick={() => actions.down(1)}>-</button> <button onclick={() => actions.up(1)}>+</button> </div> )
このJSXをトランスパイルすると
return h("div", null, [ h("h1", null, [state.count]), h("button", { onclick: function onclick() { return actions.down(1); } }, ["-"]), h("button", { onclick: function onclick() { return actions.up(1); } },["+"]) ])
このように変換されます。
h
関数が使われていますね。
上のソースの様に自分でh
関数を書いてもOKです。
実際h
関数で書く必要があるケースは開発してるとあります。
babel トランスパイル試す
このページの左メニューで、PLUGINにbabel-plugin-transform-h-jsx
を追加すると試せます。
h関数に関してまとめると、
① VirtualDOMの実態はJSONオブジェクトで、これがないとhyperappはDOMを描画できません、 ② そして、h関数はVirtualDOM(JSONオブジェクト)を作ってくれる関数で、 ③ JSX syntaxはh関数をHTMLっぽく書く為のsyntaxと言う事です。
2-3. app
関数
app(state, actions, view, DOM)
これまで説明してきた3要素の、state, view, actionsとリアルDOMを引数にとっています。
app
関数の中では、ザックリ2つの事をしています。
① actionsの中にある関数のオリジナルの処理を保ちつつ、「Stateが更新された場合に再描画する」という処理を追加して関数を再定義してます。 ↓ここでやってます。 https://github.com/jorgebucaran/hyperapp/blob/master/src/index.js#L113
② 第4引数のリアルDOMの中に、viewで定義したVirtualDOMをリアルなDOMにして展開します。
そして①で再定義したactionsをreturnしています。ですので、actionをHyperappのライブラリの外からも実行する事が可能です。