82
61

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Hyperapp のソースコードを読む

Last updated at Posted at 2018-01-02

Hyperappとは

Hyperapp GitHub ページ
GitHub - hyperapp/hyperapp: 1 KB JavaScript library for building frontend applications.

Web アプリのフロントエンド用 JavaScript ライブラリ。React, Preact, Vue といった代表的なものよりもずっと小さく、1 KB という超軽量サイズ。他のライブラリに依存することなく使えて、さらにスピードもある :fire:

Elmアーキテクチャーに基づいてて、アプリケーション設計はElmやReact、Reduxと似てるけど、ボイラープレートは少ないし、TypeScriptにも対応して、とにかくシンプル。
2018 年は Hyperapp の年だ - Qiita

実装サンプル

hyperapp/hello-world.md at master · hyperapp/hyperapp · GitHub

const state = {
  count: 0
}

const actions = {
  down: value => state => ({ count: state.count - value }),
  up: value => state => ({ count: state.count + value })
}

const view = (state, actions) =>
  h("div", {}, [
    h("h1", {}, state.count),
    h("button", { onclick: () => actions.down(1) }, ""),
    h("button", { onclick: () => actions.up(1) }, "+")
  ])

window.main = app(state, actions, view, document.body)

実行すると、下記のようなHTMLが表示されて、ボタンを押すと数値を増減する。

<div>
  <h1>0</h1>
  <button>-</button>
  <button>+</button>
</div>

どことなく react などで見たような形だが、より少ないコードで書かれている。

Hyperapp のコンセプト

hyperapp/README.md at master · hyperapp/hyperapp · GitHub

コンセプト メモ
Virtual Nodes 仮想Nodeは h 関数もしくはJSXなどを使う。DOMイベント、ライフサイクルイベント、キーを含める事が出来る。
Keys Nodeに一意のキーを付けて管理できる。
Components 仮想Nodeのコンポーネント化が可能。
Lifecycle Events 仮想Nodeの作成・更新・削除時のライフサイクルイベントを設定できる。
Sanitation 仮想Node使わない時はXSSに気を付けて。
Hydration ハイドレーション。描画を全てJSで行うとSEO的に不利なので、初期表示のHTMLがソースコードに書いてあってもOK。

これらの事を最小のコードで実現しているのが Hyperapp と考えて良い。

Hyperappのソースコードを読む

ソースコードはこちら。
hyperapp/index.js at master · hyperapp/hyperapp · GitHub

export されている関数はh()app()の2つだけ。

h() 関数

仮想Nodeを作る。
↓こう書くと

h("div", {id: 'app'}, [
  h("h1", {class: 'title'}, 'Title'),
  h("button", { onclick: () => {hoge: 'hoge'} }, ""),
  h("button", { onclick: () => {hoge: 'fuga'} }, "+"),
  'some text'
]);

↓こうしてくれる。

{
  name: "div",
  props: {
    id: "app"
  },
  children: [
    {
      name: "h1",
      props: { class : "title" },
      children: ["Title"]
    },
    {
      name: "button",
      props: { onclick: () => {hoge: 'hoge'} },
      children: ["-"]
    },
    {
      name: "button",
      props: { onclick: () => {hoge: 'fuga'} },
      children: ["+"]
    },
    "some text"
  ]
}

あんまり解説するポイントがない。

h() 関数をネストさせた場合、まずは内側(第三引数の配列内)の h() 関数が順番に実行され、一番外側の h() 関数は一番最後に実行される。

尚、JSXの場合はそのままではJavaScriptとして実行できないのでトランスパイルが必要だが、h() 関数はそのまま動く。

app() 関数

app() 関数の冒頭

export function app(state, actions, view, container) {
  var patchLock
  var lifecycle = []
  var root = container && container.children[0]
  var node = vnode(root, [].map)
  
  repaint(init([], (state = copy(state)), (actions = copy(actions))))
  
  return actions

  // 内部で利用する関数群
}

幾つかの変数を宣言して、init() しつつ repaint() を呼び出して終了。

この時、var node = vnode(root, [].map) でDOMを仮想Node化してハイドレーションを実現している。

app() の引数及び、冒頭で宣言された変数が、内部で利用する関数群におけるクロージャの変数として利用されている。

init() 関数

actions 関数の実行時に state が返却されたら state を更新して repaint() するように登録している。

 function init(path, slice, actions) {
    for (var key in actions) {
      typeof actions[key] === "function"
        ? (function(key, action) {
            actions[key] = function(data) {
              slice = get(path, state)

              if (typeof (data = action(data)) === "function") {
                data = data(slice, actions)
              }

              if (data && data !== slice && !data.then) {
                repaint((state = set(path, copy(slice, data), state, {})))
              }

              return data
            }
          })(key, actions[key])
        : init(
            path.concat(key),
            (slice[key] = slice[key] || {}),
            (actions[key] = copy(actions[key]))
          )
    }
  }

まず、最後の方のコードについて解説する。

      typeof actions[key] === "function"
        ? // 省略
        : init(
            path.concat(key),
            (slice[key] = slice[key] || {}),
            (actions[key] = copy(actions[key]))
          )

actions[key]function じゃない場合は path にキーを追加して、再度 init() を呼び出している。これは、actionsstate で同じキーを使ってグループ化できる様にしている模様。(今のところ公式ドキュメントのサンプルには載ってない)
このため、init() 内でのstateactions は全体的に、その時のキーを処理するような形になっている。例えば slice 変数は、state からその時のキーに対応する連想配列を取り出したものだ。

次に下記のコードについて。

              if (typeof (data = action(data)) === "function") {
                data = data(slice, actions)
              }

直訳だと「アクションの実行で関数が戻ってきたら、slice, actionsを引数にしてその関数を実行する」。
これはサンプルの所々に出てきた、アロー関数が2つ繋がっている書き方(関数を返す関数)に対応している。外側の関数は呼び出した時の引数、内側の関数は slice, actions を引数として実行される。これにより、ビュー側では stateactions を引数に渡さなくても良くなっている。

例えばサンプルに出てくる、この関数を返す関数が書かれているアクション。

const actions = {
  down: value => state => ({ count: state.count - value }),
}

このアクションに対応するビュー側は onclick: () => actions.down(1) という呼び出しで、引数は1つだけだ。しかし、Hyperappが自動的に stateactions を引数にして二回目の関数を実行するので、アクション内ではこれらの変数を利用できる。

補足 : アロー関数に慣れてない方の為に、上記のアクションを今までの書き方にするとこうなる。

const actions = {
  down: function(value) {
    return function(state) {
      return { count: state.count - value };
    }
  }
}

最後にこのコード。

              if (data && data !== slice && !data.then) {
                repaint((state = set(path, copy(slice, data), state, {})))
              }

              return data

関数の返却値が slice では無かったら、state を更新、そして repaint() している。また、!data.then という条件により、プロミスや async の時は repaint() は実行されず、return dataしてコールバックを繋げている。

repaint() 関数と render() 関数

まず、patchLock 周りについて。

  function render(next) {
    patchLock = !patchLock
    // 処理
  }

  function repaint() {
    if (!patchLock) {
      patchLock = !patchLock
      setTimeout(render)
    }
  }

この様に2つの関数に分けられているのは、無駄な処理実行を減らす為だ。repaint() 内で setTimeout して render() を別スレッドで実行する事で、repaint() を呼び出した関数の一連の処理を先に終わらせている。また、patchLock を使うことで、一連の処理内で repaint() が何度も呼び出されても render() が呼び出されるのは1回だけとなる。

render() の処理を追っていく。

  function render(next) {
    next = view(state, actions)

    if (container && !patchLock) {
      root = patch(container, root, node, (node = next))
    }

    while ((next = lifecycle.pop())) next()
  }

next = view(state, actions) で、最新の state を引数にして、新しい仮想Node next を作成している。尚、view() は仮想Nodeを作成する関数だ。サンプルでは下記のようになっている。

const view = (state, actions) =>
  h("div", {}, [
    h("h1", {}, state.count),
    h("button", { onclick: () => actions.down(1) }, ""),
    h("button", { onclick: () => actions.up(1) }, "+")
  ])

次に root = patch(container, root, node, (node = next)) で、node を最新のものに入れ替えつつ、patch() 関数を実行してビューを更新。
最後の while ((next = lifecycle.pop())) next() は、patch() で追加・更新されたNodeのライフサイクルイベントを実行している。

ライフサイクルイベントの公式ドキュメントはこちら。
hyperapp/lifecycle-events.md at master · hyperapp/hyperapp · GitHub

patch() 関数

新旧の仮想Nodeの差分を見ながら適切に実DOMに反映している。
また、ライフサイクルイベントの実行、Nodeによって一意な Key による処理なども行っている。

  1. 新旧Nodeが同じなら何もしない
  2. 旧Nodeが null なら新Node挿入
  3. 新旧NodeのNode名が同じなら Keys を考慮しつつ良きようにアップデート
  4. テキストNodeなら nodeValue にセット
  5. それ以外なら新Nodeを挿入しつつ旧要素を削除

Keys の公式ドキュメントはこちら。
hyperapp/keys.md at master · hyperapp/hyperapp · GitHub

その他関数

上記のメイン関数から呼ばれる補助的な関数。

  • copy()
  • set()
  • get()
  • getKey()
  • setElementProp()
  • createElement()
  • updateElement()
  • removeChildren()
  • removeElement()

以上。


感想など

作者が意図した通り、学習コストの低さはとても良いと思った。

オープンソースの世界ではソースはパブリックだからもちろん誰でも読めるけど、読めるのと「全部把握している」のはまた別だ。使いながらも、いつもブラックボックスの部分がある。でも 300行なら、バックヤードの全てを理解した上で使うことができる。それを実現したかった。
2018 年は Hyperapp の年だ - Qiita

何が起きるか分かった上で、自分のコードを実装できる。また、シンプルで最小限であるが故に、Hyperapp で実装したアプリを他のもっと手厚いフレームワークへ移行するのは難易度が低いと思う。(逆の場合は、削ぎ落とされる部分をどう補うか考えねばならず難しいかもしれない。)
モックアップなどを手軽に試したい時は、先ずは Hyperapp で作ってみても良いのではないだろうか。

僅か1kbのコードでコアコンセプトを実現するのは、素直に凄い。細かなテクニックも勉強になった。

82
61
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
82
61

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?