サーバーサイドのみのテンプレートエンジンとしてのReact

最近の仕事ではJSがあんまりなくてページ数はそこそこあるみたいなサイトを作ってることが多い。作り方として、コンポーネントごとにPugのmixinとかNunjucksのmacroで抽象化してマークアップが壊れないようにしてるんだけど、これらだとコンポーネントを実装するための機能として微妙。具体的には、ノードを挿入できる箇所が1箇所に限定されてることとエディタの補完がない。

mixin Disclosure(params = {})
  -
    const props = Object.assign({
      initialExpanded: false,
      detailsId: ulid(),
    }, params)

  .Disclosure(role="group")&attributes(attributes)
    button.Disclosure__summary(type="button" aria-expanded=String(props.initialExpanded) aria-controls=props.detailsId)!= props.summaryContent
    .Disclosure__details(id=props.detailsId hidden=!props.initialExpanded)
      block

+Disclosure({ summaryContent: '最高の<em>コンテンツ</em>' }).u-mt5
  p 立派なインターネットコンテンツになったなあ。
{% macro Disclosure(params = {}) %}
{% set rootClass = params.rootClass %}
{% set initialExpanded = params.initialExpanded %}
{% set summaryContent = params.summaryContent %}
{% set detailsId = params.detailsId | default(ulid()) %}

<div class="Disclosure {{ rootClass }}" role="group">
  <button class="Disclosure__summary" type="button" aria-expanded="{{ initialExpanded }}" aria-controls="{{ detailsId }}">{{ summaryContent | safe }}</button>
  <div id="{{ detailsId }}" class="Disclosure__details" {% if not initialExpanded %}hidden{% endif %}>
    {{ caller() }}
  </div>
</div>
{% endmacro %}

{% call Disclosure({
  rootClass: 'u-mt5',
  summaryContent: '最高の<em>コンテンツ</em>'
}) %}
<p>立派なインターネットコンテンツになったなあ。</p>
{% endcall %}

例ではsummaryContentの中身が平坦化されたテキストだと限らないので、仕方なくそこだけ生のHTML書くとかになっちゃう。テンプレートエンジンの機能が使えなくなるのでコンポーネント入れ子になったりすると詰む。

VS Codeではシンタックスハイライトしてくれるだけで補完とかはなんも出ない。tsxでReact Componentのpropsの型までわかるのと比べるとだいぶ非効率的になる。Pugのmixinとかはそもそもコンポーネント専用の機能じゃないというのもあると思うし、言語のコミュニティの勢い的にも大きな進歩は望めなそう。自分で拡張書くほどのガッツもない。

じゃあもうtsxそのまま使えばいいじゃんって感じになってきたのでサーバーサイドテンプレートエンジンとしてReactを試した。<body>の中身が空の状態から始めるって話じゃなくて、この場合ではReactはクライアント側に一切介入しない。サーバーでしか動かさない。

似たところだとDocusaurusが同じような発想でReactを使ってる。Facebook製だからだと思うけど。

素直に長いものに巻かれるとNext.jsとかGatsbyJS使えばいいんだけど、あんまりJSを使わないサイトだとやり過ぎになったり、あと納品形態がいろいろなのでいろいろある(ビルド後のHTMLファイルを人間が編集できるようにしておきたい)みたいな理由。

最近Eleventyという静的サイトジェネレータが気に入ってるのでこれを使う。便利なのがテンプレートエンジンの選択肢が豊富なところで、ピュアなJavaScriptでテンプレートを書くこともできる。

import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { Layout } from './components/Layout'
import { Disclosure } from './components/Disclosure'

// https://www.11ty.io/docs/data/#eleventy-provided-data-variables
type DefaultProvidedData = {
  pkg: unknown
  page: {
    url: string
  }
  collections: unknown
}

type PageData = {
  title: string
}

module.exports = class {
  data(): PageData {
    return {
      title: 'Home',
    }
  }

  render({ page, title }: DefaultProvidedData & PageData) {
    return (
      '<!doctype html>' +
      ReactDOMServer.renderToStaticMarkup(
        <Layout url={page.url} title={title}>
          <Disclosure
            rootClassName="u-mt5"
            summaryContent={
              <>
                最高の<em>コンテンツ</em>
              </>
            }
          >
            <p>立派なインターネットコンテンツになったなあ。</p>
          </Disclosure>
        </Layout>,
      )
    )
  }
}

こういうのをページごとに書いていく。よさそう。

JSXの代わりにJSXっぽい構文をTagged templatesで書けるdevelopit/htmを使えばプリコンパイルもなくせそうと思ってそれも試した。

const htm = require('htm')
const vhtml = require('vhtml')
const Layout = require('./components/Layout')
const Disclosure = require('./components/Disclosure')

const html = htm.bind(vhtml)

module.exports = class {
  data() {
    return {
      title: 'Home',
    }
  }

  render({ page, title }) {
    return (
      '<!doctype html>' +
      html`
        <${Layout} url=${page.url} title=${title}>
          <${Disclosure}
            rootClass="u-mt5"
            summaryContent=${
              html`
                <span>最高の<em>コンテンツ</em></span>
              `
            }
            ><p>立派なインターネットコンテンツになったなあ。</p><//
          >
        <//>
      `
    )
  }
}

確かにプリコンパイルはなくせたけど、そもそも解決したい補完が弱い。TypeScriptじゃないというのはあるけどもうちょっとがんばって欲しい。lit-htmlの拡張を使ってて進化を期待はできそうではある。

テンプレートの構文の細かい仕様を確認したりするのが地味にめんどい。あとReact.Fragmentみたいなやつが実装されてないとか。そういう風に考えるとしばらくはReactよりいい選択肢はなさそう。

加えて別の視点だとWeb Componentsを使って解決できる線はある気がする。コンポーネントはCustom Elementsで実装して、それをNunjucksとか今あるテンプレートエンジンで普通に使う。クライアントサイドから見ればオーバースペックだけど、JSで実装する部分があればこっちの方が安心できる。

---
layout: base
title: Home
---

<x-disclosure class="u-mt5">
  <span slot="summary">最高の<em>コンテンツ</em></span>
  <p>立派なインターネットコンテンツになったなあ。</p>
</x-disclosure>

Custom Elementsの補完は微妙だけど、開いてるファイルからcustomElements.define(...)を拾っていい感じにしてくれるようになるのを期待できなくもない。というのを書きながら希望的観測過ぎる気はしてきた。

今回試したもののソース全部入りのリポジトリ

完全に問題を解決する場所を間違えている、みたいな意識はない。