Hyperappでhead内のタグを書き換えるライブラリ、Hyperapp Helmetをリリースしました!

はじめに

Hyperapp Helmetという、Hyperappフレームワーク用のライブラリをリリースしました。

Helmetとは、React Helmetが有名/はしりですが、<head>タグの中を書き換えるコンポーネントのことを言います。

昨今の検索エンジンbotJavaScriptを解釈しますので、SSRはOGP/Twitter Card対応のためにあると言っても過言ではないと思います。その時に、ページ内容に合わせた<head>内タグの書き換えが必要になりますが、Hyperappにはそれに対応するライブラリがありませんでした。これで最後のピースが埋まった!

特長

Hyperapp Helmetは以下の特長を持ちます。

  • @hyperapp/render対応。renderToString()だけじゃなく、renderToStream()SSRする時も使えます。
  • @hyperapp/router対応。JSDOMと併用することにより、サーバサイドでも、ルーティングに合った<head>内タグの書き換えができます。
  • ネスト対応。親・子・孫……コンポーネントそれぞれで定義した<Helmet>が全て<head>内タグに反映されます。
  • TypeScript対応。

インストール

$ npm install hyperapp hyperapp-helmet
or
$ yarn add hyperapp hyperapp-helmet

使い方

公開メソッドは2つしかないので、README.md読めばわかると思いますが、軽く紹介しておきます。

<Helmet>

任意のコンポーネントで使える。<Helmet>タグで囲まれた部分が<head>タグの中に挿入される。keyアトリビュートが必須。

import { h } from 'hyperapp'
import { Link } from '@hyperapp/router'
import { App as A } from '../App'
import { Helmet } from 'hyperapp-helmet'

export const Counter = (): any => (state: A.State, actions: A.Actions) => (
  <div key="counter">
    <Helmet key="counter-helmet">
      <title>Counter: {state.count}</title>
      <meta name="description" content="Number Counting Page" />
    </Helmet>
    <h1>{state.count}</h1>
    <button onclick={() => actions.down(1)}>-</button>
    <button onclick={() => actions.up(1)}>+</button>
    <div>
      <Link to="/about">About</Link>
    </div>
  </div>
)

getHelmetNodes(view, state, actions) => VNode[]

アプリケーションのビューを探索し、該当の<head>内タグのVNodeの配列を返す。

import { h, View } from 'hyperapp'
import { App as A } from './App'
import { getHelmetNodes } from 'hyperapp-helmet'

const Fragment = ''
export const Html: View<A.State, A.Actions> = (
  state: A.State,
  actions: A.Actions
) => {
  const helmetNodes = getHelmetNodes(A.view, state, actions)
  return (
    <Fragment>
      <Fragment innerHTML="<!doctype html>" />
      <html lang="ja">
        <head>
          <meta charset="utf-8" />
          <meta name="viewport" content="width=device-width, initial-scale=1" />
          <script src="/index.js" defer />
          {helmetNodes}
        </head>
        <body>
          <div id="app">{A.view}</div>
        </body>
      </html>
    </Fragment>
  )
}

こんだけ! サンプルプロジェクトを公開してあるので、動かして見てください。

開発

せっかくなので、小話をば。

経緯

  • 小さくて速いウェブアプリが作りたいな〜
  • Reactはエコシステムが大きいから開発が早いけど、サイズがデカいしな〜
  • おっしゃ、Preact挑戦してみたろ
  • このpreact-compatっての入れるとReact用のライブラリが使えるが、サイズがデカくなるぞ? う〜ん……
  • そういえばHyperappってのがあったな?
  • @hyperapp/routerでルーティングもできるし、@hyperapp/renderってやつでSSRもできる。全部小さい! ええやんけ!
  • (素振りリポジトリを作ってみる)
  • えっ、HyperappにHelmetないの? マ? 今時SSRはOGP/Twitter Card対応だから意味ないやんけ! う〜ん……
  • せっかくだし、作ってみるか!

実装

そんな難しいことはしてないのですが、迷いながら実装したところがあるので、説明させてください。

具体的には2点あり、 1. <Helmet>コンポーネントが<template>タグを返し、それがレンダリングされる。 2. <head>内タグの更新のとき、一旦全削除して、全追加している。

まず、1についてですが、Reactコンポーネントと違って、Hyperappのライフサイクルメソッドは外挿なんですよね。なので、なんらかのノードを返す必要がありました。

最初は<div style={{ display: 'none' }}>{children}</div>を返していたのですが、<div>の中に<title>とは入ってるのかっこ悪い……となって一度はchildrenを書き出さず、空の<div>だけ返してました。しかし、SSRをするに当たってノードを探索・解釈してみたんですが、実際にはマウントされないため、oncreateライフサイクルメソッドが実行されず、タグを格納するグローバル変数に入らないという問題が発生しました。

というわけで、またchildrenを書き出す実装に戻したんですが、上記の通り気味が悪いので、実際には表示されないエレメントは何かないかと探したところ、<template>が見つかりました。これなら<title>等があっても違和感が少ないと考え、これを使うという選択をしました。

現在の実装ではテンプレートとしては使ってないのですが、将来実際にテンプレートとして使って、<head>に流し込むというのもありうるのではないかなと思います。

次に、2についてですが、最初は実際に書き出したエレメントと架空のノードとの差分をとってパッチを当てる実装にしてたんですが、これだと、props/state変数をタグに使用したとき、同定が難しいという問題が発生しました。

ユーザーに逐一keyを設定してもらえば同定できるのですが、それは手間だろうということで、現在のように、<Helmet>毎に全削除&全追加という実装になってます。

おわりに

このライブラリを使うことにより、HyperappでSSRするに当たって、最後の障害がなくなったと思います。ぜひ、使ってやってください。

このライブラリを作るにあたり、@r7kamura 氏のkatatemaを参考にしました。氏にはTwitterで助言を頂いたりして大変お世話になりました。ここにお礼を申し述べます。

よし、これで次はアプリに取りかかれるぞ〜