Eleventyとwebpackの連携

Eleventyは静的サイトジェネレーターとして柔軟かつ欲しい機能も揃っていて、僕が携わるほぼすべての静的HTMLベースのプロジェクトで採用している。一方でアセットのビルドなどは責務外なので別途webpack等と組み合わせる必要がある。

そこで困るのがCache bustingをどのように実装するかだ。たとえばHTMLファイルが1枚しかないクライアントサイドレンダリング前提のSPAであれば、HtmlWebpackPluginでHTMLファイルも丸ごとwebpackで生成してしまえるので、パスの解決についてユーザーがケアする必要がない。

しかしEleventyをベースにする場合では、HTMLの生成はEleventy側で行うため、何かしらのやり方でEleventyにwebpackのManifestを渡してやらないといけない。この受け渡しのためには、ManifestをJSONファイルとして出力するWebpack Manifest Pluginを使うのが一般的であるようだ。このJSONファイルには、MiniCssExtractPluginが出力するCSSfile-loaderによって読み込まれた画像などのパスも含まれるので、一通りのアセット類はwebpackでビルドするようにすればCache bustingの対象にできる。

EleventyではDataディレクトリにJSONファイルなどを配置するとHTMLテンプレートから参照できるようになるので、Manifestをまずはそこに出力してみる。説明を単純にするためにかなり簡略化したビルド。

.
├── dist/
│   ├── build/
│   │   ├── assets/
│   │   │   └── background.d41d8cd9.svg
│   │   ├── main.095d67a3.css
│   │   └── main.3a6b57da.js
│   └── index.html
├── src/
│   ├── assets/
│   │   ├── background.svg
│   │   └── logo.svg
│   ├── scripts/
│   │   └── main.js
│   ├── site/
│   │   ├── _data/
│   │   │   └── assetManifest.json
│   │   ├── build/
│   │   │   ├── assets/
│   │   │   │   └── background.d41d8cd9.svg
│   │   │   ├── main.095d67a3.css
│   │   │   └── main.3a6b57da.js
│   │   └── index.njk
│   └── styles/
│       └── main.css
├── gulpfile.js
├── package-lock.json
├── package.json
└── webpack.config.js

.eleventy.js:

module.exports = (eleventyConfig) => {
  eleventyConfig.addPassthroughCopy('src/site/build')
  eleventyConfig.setUseGitIgnore(false)

  return {
    dir: {
      input: 'src/site',
      output: 'dist',
    },
  }
}

webpack.config.js:

const path = require('path')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const ManifestPlugin = require('webpack-manifest-plugin')

const srcDir = path.join(__dirname, 'src')

module.exports = {
  mode: 'production',
  context: srcDir,
  entry: ['./scripts/main.js', './styles/main.css'],
  output: {
    path: path.join(srcDir, 'site/build'),
    filename: '[name].[contenthash:8].js',
    publicPath: '/build/',
  },
  module: {
    rules: [
      {
        oneOf: [
          {
            test: /\.css$/i,
            use: [
              { loader: MiniCssExtractPlugin.loader },
              { loader: 'css-loader' },
            ],
          },
          {
            exclude: [/\.js$/, /\.json$/],
            use: {
              loader: 'file-loader',
              options: {
                name: '[path][name].[contenthash:8].[ext]',
              },
            },
          },
        ],
      },
    ],
  },
  resolve: {
    alias: {
      '~assets': path.join(srcDir, 'assets'),
      assets: path.join(srcDir, 'assets'),
    },
  },
  plugins: [
    new MiniCssExtractPlugin({ filename: 'main.[contenthash:8].css' }),
    new ManifestPlugin({
      fileName: path.join(srcDir, 'site/_data/assetManifest.json'),
    }),
  ],
}

gulpfile.js:

const spawn = require('cross-spawn')
const gulp = require('gulp')
const del = require('del')
const webpack = require('webpack')
const webpackConfig = require('./webpack.config')

const clean = () => {
  return del(['dist', 'src/site/_data/assetManifest.json', 'src/site/build'])
}

const assets = (done) => {
  webpack(webpackConfig).run((err, stats) => {
    if (err) {
      console.error(err.stack || err)

      if (err.details) {
        console.error(err.details)
      }

      done(err)
      return
    }

    console.log(stats.toString('minimal'))

    if (stats.hasErrors()) {
      done(new Error('webpack compilation errors'))
      return
    }

    done()
  })
}

const eleventy = () => {
  return spawn('./node_modules/.bin/eleventy', [], {
    stdio: 'inherit',
  })
}

const build = gulp.series(clean, assets, eleventy)
exports.build = build

src/site/index.njk:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello</title>
    <link rel="stylesheet" href="{{ assetManifest['main.css'] }}">
    <script src="{{ assetManifest['main.js'] }}"></script>
  </head>
  <body>
    <h1><img src="{{ assetManifest['assets/logo.svg'] }}" alt="Hello"></h1>
  </body>
</html>

src/styles/main.css:

body {
  background-image: url("~assets/background.svg");
}

src/scripts/main.js:

console.log(require('~assets/background.svg'))

gulp buildを実行すると次のファイルが出力されて、HTMLテンプレートはそれを読み込んでパスを解決する。

src/site/_data/assetManifest.json:

{
  "main.css": "/build/main.095d67a3.css",
  "main.js": "/build/main.3a6b57da.js",
  "assets/background.svg": "/build/assets/background.d41d8cd9.svg"
}

ただしこれだとsrc/assets/logo.svgがManifestに含まれておらず、EleventyをビルドしてもassetManifest['assets/logo.svg']の部分が空文字列になってしまう。これはwebpack側ではsrc/assets/logo.svgを読み込んでいないからだ。src/scripts/main.jssrc/styles/main.cssからファイルを指定するとwebpackのManifestに入りつつパス解決をしているが、Eleventyはその結果を参照しているだけなので、HTML側でファイルを指定していることはwebpackに関係がない。なのでHTML側だけで参照するファイルもManifestに含めるために、それらのファイルをあらかじめwebpackに読み込ませておく。

src/assets/_importer.js:

require.context('.', true)

webpack.config.js:

- entry: ['./scripts/main.js', './styles/main.css'],
+ entry: ['./assets/_importer.js', './scripts/main.js', './styles/main.css'],

これでsrc/assetsディレクトリに配置されたすべてのファイルがManifestに入るようになる。

しかしまだHTMLテンプレートに{{ assetManifest['assets/logo.svg'] }}"のように記述しないといけないのが冗長でめんどくさく、もう少し書きやすくしたい。NuxtJSではVueテンプレートが次のように変換される。

<template>
  <img src="~/assets/image.png">
</template>
<img src="/_nuxt/img/image.0c61159.png">

これを参考にEleventyでも同じようにパスが変換されるようにしてみた。

.eleventy.js:

const { JSDOM } = require('jsdom')
const importFresh = require('import-fresh')

const transformAssetAttrs = {
  video: ['src', 'poster'],
  source: ['src'],
  img: ['src'],
  image: ['xlink:href', 'href'],
  use: ['xlink:href', 'href'],
}

const transformAssetSelector = Object.entries(transformAssetAttrs)
  .map(([element, attrs]) => {
    return attrs.map((attr) => `${element}[${attr}]`).join(',')
  })
  .join(',')

module.exports = (eleventyConfig) => {
  eleventyConfig.addPassthroughCopy('src/site/build')

  eleventyConfig.addTransform('assetPath', (content, outputPath) => {
    if (!outputPath.endsWith('.html')) {
      return content
    }

    const assetManifest = importFresh('./src/site/_data/assetManifest.json')

    const dom = new JSDOM(content)
    dom.window.document
      .querySelectorAll(transformAssetSelector)
      .forEach((element) => {
        transformAssetAttrs[element.tagName.toLowerCase()].forEach((attr) => {
          const value = element.getAttribute(attr)
          if (value && value.startsWith('~assets/')) {
            const id = value.replace('~assets/', 'assets/')
            const resolved = assetManifest[id]
            if (!resolved) {
              throw new Error(`Can't resolve '${value}' for '${outputPath}'`)
            }
            element.setAttribute(attr, resolved)
          }
        })
      })
    return dom.serialize()
  })

  eleventyConfig.setUseGitIgnore(false)

  return {
    dir: {
      input: 'src/site',
      output: 'dist',
    },
  }
}

これによりHTMLテンプレートに次のように書いたパスは自動的に解決されるようになった。

<img src="~assets/logo.svg" alt="Hello">

存在しないファイルを指定するとエラーが出てくれるようにもなったので安心できる。

そういうわけでここまででビルドする方法に書いてきたが、実際にはwatchできないと使いものにならない。いい感じにwatchする方法についてもメモしておこうかと思ったが、解説量が膨大になりそうなのでリポジトリだけ置いておく。

_shifted/boilerplate-static at master · yuheiy/_shifted

「404 Not Found」は実装側の言葉であってユーザーに見せる言葉ではない

いわゆる404ページを作っていて、ふと「ページが見つかりません」という表現に違和感を覚えた。ユーザーになにの話をしているんだ?

URLに対応するリソースを見つけたり見つけられなかったりするのはあくまでサーバー、つまり実装の話で、そういった仕組みの話はどうでもいい。重要なのは実装を通してユーザーにどのような意味を感じさせたいかだ。

リンクをクリックしてページを開くとき、リンクは別の場所に移動するためのボタンに思える。移動先にはページ(またはその他のリソース)が存在していたりしなかったりして、ほぼ確実な存在を期待するが、まれにそうでない場合に出くわす。このときユーザーに関係があるのはページが存在しているか否かで、サーバーの処理内容に意識はない。「見つからなかった」と知らされてもわけがわからずトンチンカンな印象を受けてしまう。

「お探しのページは見つかりませんでした」と言っても同じ話だ。ページを探すのはサーバーの方で、ユーザーはリンクを選んだ時点で移動先を確定させている。強いて言うならユーザーが探すのはリンクであり、ページの先にあるものだ。

では「404 Not Found」をどのように表現するか。伝えるべきはページの存在についてだが、ややこしいのは404ページ自身もまたページであることだ。404ページが「ページが存在しない」と言うとき、「ページ」が指すのは「そこに存在するはずのページ」である。「この場所にあるはずのページは存在しません」とかだと冗長すぎるし、却って誤解を招きそうだ。

HTTPエラーページに意味を持たせよう」によると、以前Googleのエラーページには次のような文言があったらしい。

The page - www.google.com/dkjfhsd - does not exist.

The pageはなかなかいいと思える表現だ。エラーページが表示される瞬間にまさにユーザーの意識がある対象のページを指していて、かつ「404ページ自身が存在しない」という矛盾した表現になっていない。

しかし、訳しにくい。「そのページは存在しません」としても、「そのページ」がユーザー自身が意識していた「存在するはずのページ」を指しているとは理解されず、不自然に「その」と明言されていることで別のなにかを意図していると誤解されそうだ。「ページは存在しません」でもどのページを指しているのか一瞬理解しづらい気がする。

では「このページは存在しません」ではどうか。404ページ自身を指しているようにも取れるが、その場所に存在するはずの「存在しないページ」を指して「このページ」と呼んでいるように思えないだろうか。あるいはこれは訪問先の住所にある空っぽの土地に「売地」という看板が立っているようなイメージでも理解できるかもしれない。

そんな訳で作った404ページでは「このページは存在しません」を採用した。

ひと通り書いてみて想像してたほどうまく説明できなかったなと思った。「ページが見つかりません」的な表現はもはや記号化しているので今さらどうでもいい気もするが、それでも気持ち悪さはあったので書いてみた。

雑な日記

割と時間をかけて書いた記事を先週に公開した。特に最近はいろんな考えを言葉にしようと意識的にしていて、なんとなくぼやっとしてるものの存在を見えるところに持ってきたいみたいな意図がある。詳しく説明しようとすると疲れるのでこの記事には書かない。こっちじゃない方のブログになにか書くときは、といっても記事数的にはかなり少ないけど、僕としてはかなり丁寧に書いてるんだけど、その分煮え切ってない話を半端なまま書くとか、真面目に編集するほどでもないと自分では思うような話を雑に出すのがやりにくい。記事自体の出来がどうこうっていうか書く側のハードル的な意味で。少し前からツイッターのタイムラインで「ツイートはできるのにブログは書けない」みたいなつぶやきをいろんな人が方々でしてて、これって今までブログに書いてた雑なやつはツイートで発散されちゃってるんだろうなって思ってて、ブログってだいぶ「わざわざ」書くものになったなって。ツイートって必要以上に雑にできちゃうなって感じるときがあって、外が暑いときにアッッッッツとか書くのは別に鼻クソみたいなもんだからどうでもいいんだけど、ちょっと時間かけて連ツイしちゃうくらいの何かがあったときに、ツイッターで終わると自分の中でもそっからあんまり考えが繋がっていかないんだよね。これ前にも同じようなツイしたなって思って過去のやつ漁るとそっから大して話が進んでない。程度問題だとは思うんだけどブログだとある程度メモを一通りひとつの記事に押し込めるし忘れたら塊として取りに行ける。ツイだと検索できる程度記憶がないと拾いに行けないしいちいち連ツイにしなくて分散してるやつもあるしただテキストを放流しただけみたいなもんなんだよね。だから雑なままでもとりあえずブログにまとめる習慣を復活させないと困るなと僕としては思ってて。ブログ記事書くのに何日も掛けるとかありえなかったのにアレとか書いてなかった期間も含めてひと月くらい掛けてたような。たぶんそうやって作り込んでいくのに慣れると即席なものを出すのに躊躇するようにもなるのかもしれない。一方でとりあえずでも書いた昔の記事がいまだに読まれてる通知もたまに来たりしてやっぱり記事化の意味を感じられたりするし、逆にどんだけ丁寧に書いても興味ないテーマだと読まれないのは同じだし。そういえばこれを記事にしようと思ったのは今accrefsを作ってるからかもしれない。これはいろんな記事とかを大量に集めてるサイトで、既存のページを集めてるので当然人気があるテーマばっかりの記事が増えたりとか偏りがあって、粒度とかバラバラで体系的にまとまってるとは言えないんだけど、それでもここを見ればあるというだけで意味がある。ちなみにこれはいろんな人の協力でできてるサイトで、僕はサイト作るとかを手伝わさせてもらってる。インブラウザデザインで作ったんだけどやっぱりこうやってみると仕事で書いてるのって同じCSSなのかとか思ってしまう。ちょうど最近Every Layoutをよく読んでて本当にCSSは奥深いなとか今さらになって改めて思わされたんだけど、トレースするようにCSS書くのはCSSのあり方とまったく真逆を行ってるなとインブラウザの感覚を通して確信した。なんだったらインブラウザデザインは本当にもう10年近くやってるかもしれないんだけど、これまでに今回ほどうまくできたことがないんだよね。まさにこれもツイートしたんだけど、The Stackが革新的で、だいぶ前から他所でも氏がこのスニペットを共有してたんだけどパッと見だとなにがいいんだって感じでスルーしてたんだけど、言葉の通り騙されたと思って使ったら自分のCSS観が大幅に変わってしまったくらいで。僕は要素型セレクタが教条的と言ってもいいくらい好きで、インブラウザデザインするときはあらゆるセレクタ設計の中心にしてたんだけど、まあ実際にはあんまりうまくいかない。余白は要素同士の関係性によって決まるもので、グローバルなスタイルとして要素の余白を決めていても変更しないといけない場合がかなり多い。こうした場合の手として、すべての要素の余白はmargin-topで統一して、要素の組み合わせによるバリエーションは隣接セレクタで上書きするというのがあるんだけど、要素同士の組み合わせなんて無限にあるのでいちいちセレクタを書いていくのにはかなり無理がある。特になにかの要素に入れ子になったりとかすると……。そんなわけで余白だけを責務にするクラスを作ったりしてHTML側でコントロールできるようにするのが現実的な解だった。ただこれも方々で余白のサイズが違ってくるなら効果的とは言えるけど、特定のコンテキストにおいて一貫した余白を設けたいみたいな場合では無駄が多い。The Stackはいくつか余白のバリエーションを作っておくと、accrefsでは3つだけ、それで囲ってやるだけでいい。理屈として特定のコンテキストでは余白を一貫させるというのは理にかなっているように思えて合理的な仕組みだと思う。同じ意味でそういったコンテキストが存在しない場合はspacerクラスみたいなのを作る方が向いている。最近読んだ本で話すように書くのは作文技術としてありえないみたいなことを言ってたんだけどこの記事は雑に書くためにまったく気を遣わずに話すように書いた。

[翻訳]Web Componentsに祝杯を?

Any Holy Grail for Web Components?の翻訳。


Web Componentsの歴史は輝かしいものではなかった:

  • それらの宣言的な性質は静的なHTMLの世界に限定されている
  • スロットとShadow DOMを介したJSカウンターの宣言はぎこちない
  • Shadow DOMは重く、確実にポリフィルするのが困難
  • Shadow DOMはすべての問題は解決できず、必須の設定が必要であり、SSR(そうすべきではないとわかっているが…)を介して配信できない
  • HTML Modulesはまだどこにも実装がないが、Vue.jsや他のフレームワークが何年も前から提供しているものとあまり変わらない

Shadow DOMを必要としない、または使用しない場合、これまでに成功してきたライブラリやプロジェクトで採用されてきたように、コンポーネントが広く採用される可能性が高くなる。しかしまだ他の弱みが残る:

  • 宣言はまだ静的なHTMLの世界に限定されている
  • CSSはあらゆる場所にも存在し、Shadow DOM内のものより脆弱である
  • 冗長なノードの数があまりにも多くなるため、複雑なページではダウンロードと処理の両方に負荷がかかる
  • 古き良き標準DOMとテーマの統合はまだぎこちないだろう

DOMの冗長性について

ホスティングしているページからテーマを継承する可能性のあるParagraphを表現したいとしよう。そして、JSを経由して魔法のようなことができる「Special paragraph」として配信したいとすれば、おそらく次のようにするだろう:

// the JS side
class SpecialP extends HTMLElement {
  constructor() { /* setup it once */ }
}
customElements.define('special-p', SpecialP);

// the HTML side
<special-p>
  <p>
    The actual content
  </p>
</special-p>

代償として、どのような形であれShadow DOMを使用すると、ホスティングしているサイトのテーマを反映できる<p>タグをつけなくてもロジックとレイアウトの両方が肥大化する。

グレースフルエンハンスメントについて言うと、カスタム要素をサポートしていないブラウザには<spacial-p>タグをどう処理すればいいのかさえわからない。

私たちが望んでいたのは、Paragraphを簡単に設定する方法だけだった。

標準ベースのアプローチ

2KBの小さなWicked Elementsライブラリは、すでに説明したような問題を解決する素晴らしいソリューションになっている。それはひとつのポリフィルも必要とせずにIE9までサポートする。カスタム要素をまったく使用せずに、DOMに到達するあらゆる要素に、カスタム要素のようなイベントを別のAPIを介して公開する。

// the JS side
wickedElements.define('p.special', {
  init() { /* setup it once */ }
});

// the HTML side
<p class="special">
  The actual content
</p>

頭を使う必要がないほど簡単なので、ぜひこの小さなライブラリを試してみてほしい。しかしまだいくつかの問題点が残っている:

  • wickedElementsライブラリはそのスタックをより理解している人にはうまく機能するが、初心者には少し混乱を招くかもしれない(普通のパラダイムとはまったく異なるので)
  • 要素はライブになるとその場でアップグレードされるが、DOMはアップグレードされるまでこれらの要素について何も知らない。これはカスタム要素にも当てはまるが、内部または将来のブラウザパフォーマンス最適化に関しては、カスタム要素などのネイティブのプリミティブを使用する方が常に安全である
  • HTMLコンテンツの宣言はデフォルトではまだHTMLの世界に限定されている。これはSSRとグレースフルエンハンスメントについては素晴らしいことだが、Reactエコシステムは多くの開発者がロジックをひとつの場所——つまりJSファイルとJSX(または似たような解決策)だけに限定したがることを示している

簡単なまとめ:React開発のエクスペリエンス

Web標準についてさらに説明する前に、私が考えるReactの人気を高めるために役立ったことを要約したい:

  • 素晴らしいドキュメント
  • great tooling around
  • 依存関係をインポートし、1つのファイルでコンポーネントの意図を宣言する

Web標準もこのリストの最初の2つの点を、より良くはないにしても確実にカバーしている。しかし3つ目の点を超えることはできない:

// the React JS side
class Special extends React.Component {
  render() {
    return <p>{this.props.text}</p>;
  }
}

// still the React JS side ...
const el = <Special text="The actual content" />;

そしてstyled-componentsを追加する……。標準技術を通して私たちがそのような経験に近づき、楽になる可能性はあるだろうか?

カスタム要素に組み込まれているアプローチ

残念ながらまだすべてのブラウザで利用できるわけではないが、すでにカスタム要素をサポートしている場合(例:Safari)ならたった1Kのポリフィルを使用すれば、このプリミティブはカスタム要素の標準的な表現力だけでwickedElementの能力を得るための最善の妥協策になる。

// the JS side
class SpecialP extends HTMLParagraphElement {
  constructor() { /* setup it once */ }
}
customElements.define('special-p', SpecialP, {extends: 'p'});

// the HTML side
<p is="special-p">
  The actual content
</p>

結果として、これまでに説明したいくつかの課題が解決された:

  • DOMはまったく膨張しない
  • ページはグレースフルエンハンスメントされる
  • ページは今すぐSSRできる
  • カスタム要素のすべてのライフサイクルを利用できる

したがって、不足しているのは次の通り:

  • JSファイル内でも内容を宣言する方法
  • JSファイル内でもコンポーネントのスタイルを定義する方法
  • 素晴らしいアプリを簡単に開発するために役立ついつもの特別な魔法

簡単に言うと、これまで押し戻されてきた組み込みの拡張とユーザーランドの標準ベースのソリューションを組み合わせてJSで宣言的レイアウトを作成することは、標準的な世界でReactエクスペリエンスをシミュレートするための完璧な組み合わせかもしれないということだ。

Heresyとは何か

このライブラリ名(和訳すると異教、異端、異説)は、一般に受け入れられている概念とは大きく異なる概念を組み合わせているため、これ以上適切なものはないと思う。

Special Pコンポーネントの最も基本的な例を次に示す:

// the JS side
import {render, html, define} from 'heresy';
define(class Special extends HTMLParagraphElement {
  static tagName = 'p';
});

// the HTML side (SSR ready)
<p is="special-heresy">
  The actual content
</p>

// or ... the JS side
render(document.body, () => html`
  <Special>
    The actual content
  </Special>
`);

🤯
待って……。何が起こっている?

  • 静的クラス.nameを使用してレジストリ名を定義し、末尾に-heresyを付けて、カスタム要素レジストリに対して常に有効な名前にする
  • 静的クラス.tagNameを使用して、表すべき実際のDOM要素の種類を指定する。組み込みのカスタム要素であればどんな要素でも構わない(optiontrtdselectliinputlabel……)
  • もしDOMにis=属性が検出されればすぐにアップグレードされる
  • lighterhtmlを使用してクラス.nameを記述した場合、テンプレートリテラルでレイアウトを宣言するために使用されるエンジンは、<p is="...">として自動的に変換されて膨張しない。宣言されたノードは実際にはDOM上に存在し(これらを参照することもできる)、仮想的なものは存在しない。つまりカスタム要素はユーザーがハンドリングできるインスタンスである

その結果、JSXと非常によく似た方法で宣言することができる。

CSSについてはどうか?

import {render, html, define} from 'heresy';
define(class Special extends HTMLParagraphElement {
  static style = selector => `
    ${selector} {
      transition: background 300ms;
    }
    ${selector}:hover {
      background-color: silver;
    }
  `;
  static tagName = 'p';
});

このCSSは、クラスの静的styleメソッドを介してクラス宣言ごとに1回だけ注入され、スタイル設定に使用される名前を含む文字列であるセレクタを引数として渡す。

この例では、引数はp[is="special-heresy"]になる。

styleは単なる静的メソッドであるため、実行時にSASSやLESSのようなトランスフォームを介して変換された文字列を返すこともできる。

propsについてはどうか?

import {render, html, define} from 'heresy';
define(class Special extends HTMLParagraphElement {
  static tagName = 'p';
  #props = {};
  get props() { return this.#props; }
  set props(props) {
    this.#props = props;
    this.render();
  }
  render() {
    this.html`${this.props.text}`;
  }
});

render(document.body, () => html`
  <Special props=${{text: 'The actual content'}}/>
`);

lighterhtmlのおかげで、カスタム要素には任意のゲッターまたはセッターを指定できるので、任意の種類の値を即座に渡すことができる。

互換性についてはどうか?

ポリフィルとツールを適切に組み合わせることで、HeresyはIE9でも動く。

その方法を理解するには、基本的なライブデモまたはそのソースコードを参考にできる。

では…?

私に休憩させてください😂

このプロジェクトはまだ初期の段階で実験的なものだが、すでにいくつか有望な成果が出ている。たとえばこの古典的なTodoデモソース)は現在Chrome Canaryでしか動作しないが、これはトランスパイルを行わずに最新のJSの機能をベースにしているからだ。

次のようなもの:

// ...

render() {
  this.html`
  <input placeholder="type item" onkeydown=${this}>
  <Hide onchange=${this}/>
  <ul>${this.items.map(
    data => html`<Item data=${data}/>`
  )}</ul>`;
}

このカスタム要素ベースのアプローチを使うのはとても楽しいが、私はまだこれを使って具体的なアプリを作っていないことを覚えておくように。そしてlightterhtmlよりもhyperHTMLの方が、その.bindカニズムのおかげでより適切なのではないかと考えている。

しかしプロトタイプ作成にはlighterhtmlが最も簡単なツールだ。当面私が理解したいのは、コミュニティがこのようなソリューションを気に入って、興味を持っているかどうか。そうすればおそらくいつかSafariの開発者もネイティブの組み込み機能を提供して、誰もがポリフィルをドロップできるようになるだろう。

だからあなたがこのheresyについて何を思うかtwitterで教えて欲しい。最後まで読んでくれてありがとう❤️

特定のコンテンツのためのコンポーネントとそうでないコンポーネント

CSS Utility Classes and "Separation of Concerns"というTailwind CSS作者の記事を読んだ。そこでは「セマンティック」なコンポーネントと「コンテンツに依存しない」コンポーネントについて言及されていた。

セマンティックなコンポーネントは、

  • author-bio
  • article-preview

のように取り扱うコンテンツの種類をそのまま命名に反映させたものだ。コンポーネントの用途は特定のコンテンツのためだけに限定される。これを特定のコンテンツのためのコンポーネントと呼ぼう。

コンテンツに依存しないコンポーネントは、

  • media-card
  • card-list
  • button

のようにUIパターンを区別するだけの命名がされる。コンポーネントの用途として取り扱うコンテンツの種類を制限しない。これはUIパターンとしてのコンポーネントと呼ぼう。

以下私見

普通は特定のコンテンツのためのコンポーネントの方が専用的だ。同じ種類のコンテンツの表示を別々のコンポーネントで行うことはあまりない。というかそうなるように分ける。たとえば商品詳細と商品属性概要だと、productproduct-teaserのようにそれぞれ別コンポーネントにする。

デザインの個別性が高いサイトほど特定のコンテンツのためのコンポーネントが増える。それは何かしらの理由により共通化が進められていない状態とも言える。だから影響範囲が予測しやすく捨てやすい。反面新しいページで再利用できるコンポーネントは少なくなる。言うまでもなく、実情を見ずにUIパターンとしてのコンポーネントを増やしてしまったりすると、結果的には名前通りの使い方ができない嘘の設計になってしまう。

一方でUIパターンとしてのコンポーネントは汎用的だ。media-cardを使えば、商品でも著者でも記事でも好きなコンテンツを表示できる。

デザインの共通化が進むとUIパターンとしてのコンポーネントが増える。すると再利用できるコンポーネントが増える一方で、個別の違いを持たせるのが難しくなる。汎用性を目的としたUIフレームワークはUIパターンとしてのコンポーネントを中心に構成されていることが多く、スケールさせやすい。逆に特定のプロダクトに特化したPrimerのようなUIフレームワークは特定のコンテンツのためのコンポーネントも多く含む(ただしできるだけ避けるべきと言っている)。

両者には優劣があるわけではなく、必要に応じて併有するものだ。そうしてコンポーネントの役割を明らかにする。やがてサイトが成長するにつれて(あるいは実装が進むにつれて)、それぞれのコンポーネント本当のところはどちら側なのかが浮き彫りになってくる。その段階で改めて命名し直す。この作業によりガイドが適切に引き直される。最初に戻る。

多くの場合、早すぎる共通化は失敗する。つまり特定のコンポーネントが、UIパターンとしてのコンポーネントであるかは慎重に判断すべきだ。

間違った共通化は意図しない再利用を生む。意図しない再利用は非合理的な拡張のきっかけになり、不必要に複雑性を高める。さらには使用箇所を把握しにくくなる。

通化はすぐにできても、個別化は適切な影響範囲の理解と構成要素の分解を要する。それなら安直な共通化は避けるべきだ。

だから僕は必要と思えるタイミングまで、UIパターンとしてのコンポーネントの作成を見送ることが多い。

SPAじゃないプロジェクトのための控えめなJavaScriptフレームワーク「Stimulus」

ReactやAngularのように、アプリ内のすべてのHTMLをJavaScript側で管理して描画するアプローチは大げさすぎる。ほとんどの場合において。

もちろん一定以上の複雑さがあればその辺のJavaScriptフレームワークを使った方が良い。が、それがやり過ぎになるプロジェクトは多い。というかその方が多い。それでも、それらのアプローチではやり過ぎになってしまう場合でも、秩序のあるコードを書き続けられないと当然苦しい状態へ向かう。

この微妙な(そして多数派であるはずの)環境を適度に管理できる枠組みが必要だ。これまでちょうどいい解決策を見つけられずに苦労してきた。Web Componentsは課題を解決するかもしれないがまだ来ない。そこで出会ったのがStimulusだ。

いわく、Stimulusは既存のHTMLのための控えめなJavaScriptフレームワーク。サーバーサイドレンダリングされたHTML(これはIsomorphicとかじゃなくて普通の)に対して、特定の属性が付与されていれば対応するコントローラを初期化する。

まずHTMLにはdata-controller属性、必要に応じてdata-targetdata-action属性を追加する。

<!-- HTML片は好きなところに -->
<div data-controller="hello">
  <input data-target="hello.name" type="text">

  <button data-action="click->hello#greet">
    Greet
  </button>

  <span data-target="hello.output">
  </span>
</div>

対応するコントローラを作成すると、Stimulusはその振る舞いを登録する。

// hello_controller.js
import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "name", "output" ]

  greet() {
    this.outputTarget.textContent =
      `Hello, ${this.nameTarget.value}!`
  }
}

これのよくできてるところは、単に読み込み時にだけコントローラの初期化が行われるわけでなく、ドキュメントへコントローラに対応するDOMが挿入されるたびに自動的に初期化される。削除されたら勝手に破棄される。さらにそのためのライフサイクルフックがある(initialize/connect/disconnect)

// 好きなタイミングでDOMを挿入すれば勝手に初期化される
document.body.innerHTML = `<div data-controller="hello">
  <input data-target="hello.name" type="text">

  <button data-action="click->hello#greet">
    Greet
  </button>

  <span data-target="hello.output">
  </span>
</div>`

// 好きなタイミングでDOMを削除すれば勝手に破棄される
document.body.innerHTML = ''

これは、StimulusはそもそもTurbolinksと協調するために作られたからだ。だからTurbolinksと組み合わせて使うこともできる。

これだけ書けば求める人にはグッと来るものがあるはずだ。詳しい使い方は公式ハンドブックのサンプルコードを見れば大体わかる。中途半端にまとめてもすぐに腐るので触らない(APIの変更が多いわけではないが)。思想的な序文だけ訳しておくことにする。

次からStimulusの起源(The Origin of Stimulus)の翻訳。


私たちはBasecampでたくさんのJavaScriptを書いているが、現代的な意味での「JavaScriptアプリケーション」は作成していない。すべてのアプリケーションはサーバーサイドレンダリングされたHTMLをベースにして、それらに付与する形でJavaScriptを追加している。

これが雄大なモノリスのやり方である。Basecampは、Ruby on Railsを使って作成されたコントローラ、ビュー、モデルの単一セットを持ち、ネイティブモバイルアプリを含むいくつかのプラットフォームで動作する。多くのプラットフォームがあるにも関わらず、小さなチームでやっていくためには、1つの共有インターフェイスを1箇所で更新できることが重要だ。

すると昔のように生産性の高いパーティーができる。1人のプログラマーが間接的なシステムや分散システムのレイヤーに縛られることなく、貪欲に進歩できる時代に逆戻りする。誰もにとっての究極の目標は、サーバーサイドアプリケーションがクライアントサイドアプリケーションのためにJSONを生成するだけのものになる前に戻ることだ。

そのようなアプローチに価値がないというわけではない。しかしそれは多くのアプリケーションにとって、そしてBasecampにとって、複雑性と生産性の低下をもたらす。

シングルページのJavaScriptアプリケーションにメリットはある。ページ全体を更新せずにより高速で流動的なインターフェースを実装できることだ。

Basecampもそのようだと感じて欲しかった。まるで流れに乗って、レンダリングをクライアントサイドで行うようにすべて書き直したか、完全なネイティブアプリになったかのように。

この要求が私たちをTurbolinksとStimulusという強力な組み合わせに導いた。

Turbolinkは高く、Stimulusは低く

私たちのこの新しい控えめなJavaScriptフレームワークであるStimulusを紹介する前に、これを使ってTurbolinksの命題を要約しておこう。

TurbolinksはGitHubで開発されたpjaxと呼ばれるアプローチから派生している。基本的な概念は同じままだ。

ページ全体のリフレッシュに時間がかかるのは、ブラウザがサーバーから送信される大量のHTMLを処理しなければならないからではない。それに関してブラウザは非常に優れており、非常に高速だ。そしてほとんどの場合、HTMLペイロードJSONペイロードよりも大きくなる傾向がある事実も問題にはならない。要因は、CSSJavaScriptを再初期化して再度ページに適用する必要があることだ。ファイル自体がキャッシュされているかどうかに関係なく。CSSJavaScriptのサイズによってはかなり遅くなる。

この再初期化を回避するためにTurbolinksは、シングルページアプリケーションと同じように永続的なプロセスを維持する。リンクのクリックイベントを乗っとり、Ajaxによって新しいページをロードする。サーバーは完全な形式のHTMLドキュメントを返す。

この戦略だけでも多くのアプリケーションを非常に速く感じさせられる。Basecampではページからページへの移動が約3倍速くなった。この高速化はアプリケーションに反応性とシングルページアプリケーションの魅力の大部分を占めていた流動性を与える。

しかしTurbolinksだけでは話の半分にしかならない。ページの読み込み以降、1つのページ内にはあらゆる振る舞いが登録される。要素の表示と非表示の切り替え、クリップボードへのコンテンツのコピー、リストへの新しいTodoの追加、および昨今のウェブアプリケーションに関連づけられているその他すべての操作を行うインタラクション。

Stimulus以前、Basecampはこれらをさまざまなスタイルやパターンを使って実装していた。コードの中にはjQueryを使ったちょっとしたものもあれば、同じようなサイズの単純なバニラJavaScriptもあり、また、より大きなオブジェクト指向のサブシステムであるものもあった。これらすべてはいつも、明示的なイベント処理によってdata-behavior属性を無効にしていた。

data-behaviorJavaScriptによる振る舞いをビューに依存せずに管理するための方法論。詳しくはCode Spelunking in the all new Basecamp – Signal v. Noiseを参照)

このような新しいコードを追加するのは簡単だったが、包括的な解決策ではなく、社内のスタイルやパターンが多すぎた。そのためコードの再利用が難しくなり、新しい開発者が一貫したアプローチを習得することが困難だった。

Stimulusの3つの核となるコンセプト

社内の良いパターンから抽出した次の3つが主要な概念となった。

  • コントローラ
  • アクション
  • ターゲット

Stimulusはこれらを控えめで小さなフレームワークとしてまとめたもの。

対応付けられたHTMLを見ると、Stimulusはプログレッシブエンハンスメントとして読み込まれるよう設計されていることがわかる。これにより、どの振る舞いが作用しているのかをテンプレートを見て理解できる。例を示そう。

<div data-controller="clipboard">
  PIN: <input data-target="clipboard.source" type="text" value="1234" readonly>
  <button data-action="clipboard#copy">Copy to Clipboard</button>
</div>

例を読むと何が起きているのかよくわかる。Stimulusについて何も知らなくても、あるいはコントローラのコードを見なくても。これは対応するイベントハンドラーが外部JavaScriptファイルに存在するHTML片を読むことと大きく異なる。また、現代の多くのJavaScriptフレームワークで失われている関心の分離も維持している。

ご覧のように、StimulusはわざわざHTMLを作成するのではなく既存のHTMLドキュメントに取り付けられる。ほとんどの場合、HTMLはページ読み込み時(最初の読み込みまたはTurbolinks経由)に、またはDOMに作用する起点になるAjaxリクエストによってサーバー上でレンダリングされる。

Stimulusはその既存のHTMLを操作することに関心がある。例えば、要素を非表示にしたりアニメーションさせたり、ハイライトさせたりするCSSクラスを追加すること。グループ内の要素を再配置させたりすること。UTCを地方時の日時表記に変換するように要素の中身を操作することなど。

Stimulusに新しいDOM要素を作成させたいこともあるが、それも自由に行える。将来的には簡単にできる方法を提供するかもしれない。しかしこれはごく少数のユースケースだ。焦点は要素の作成でなく操作にある。

Stimulusが主流のJavaScriptフレームワークとどのように違うのか

そのため、Stimulusは現代のJavaScriptフレームワークの大部分とはかなり異なっている。ほとんどのフレームワークは何らかのテンプレート言語を介してJSONをDOM要素に変換することに焦点を当てている。それらのフレームワークでは多くの場合、空のページを作成し、そうしてJSONからレンダリングした要素だけをそのページに追加する。

主流のJavaScriptフレームワークとStimulusでは状態の扱いも異なる。ほとんどのフレームワークには、JavaScriptオブジェクト内に保持した状態に基づいてHTMLをレンダリングする機能がある。Stimulusはその正反対だ。状態はHTMLに格納されるため、ページが変更されるたびにコントローラを破棄できる。そしてキャッシュされたHTMLから再び表示されてすぐの状態から再初期化できる。

これはまったく異なるパラダイムだ。現代のフレームワークに慣れているベテランのJavaScript開発者の多くはこれをあざ笑うに違いない。React+Reduxのような巨大な渦巻きの中で、アプリケーションを維持するために必要な複雑さと労力に満足しているなら、Turbolinks+Stimulusには魅力を感じないだろう。

一方で、現代の技術が示唆するような複雑さとアプリケーションの分離が不要だと感じている場合は、このアプローチに退避することになる。

Stimulusや関連するアイデアは現実世界から抽出された

Basecampではこのアーキテクチャをいくつかの異なるバージョンのBasecampや他のアプリケーションで長年使用してきた。GitHubも同様のアプローチを使って大きな効果を得ている。これは「現代」における、ウェブアプリケーションがどのようなものであるかの主流の理解に対する有効な代替手段である。それも非常に説得力のある。

実際、Stimulusは私たちがRuby on Railsを開発したときにBasecampで使った秘伝のタレに似ている。現代の主流のアプローチは不必要に複雑になっている感覚がある。私たちは本当はより多くのことをより速く、より少ないリソースで実現できるように思う。

あなたはそのいずれかだけを選択する必要はない。StimulusやTurbolinksはよりヘビーな他のアプローチと組み合わせて使うと効果的だ。もしあなたのアプリケーションの80%がヘビーな仕組みを必要としないのであれば、私たちのようにStimulus+Turbolinksを使うことを検討して欲しい。そしてアプリケーションの中の、実際に恩恵を受けられる部分でヘビーな仕組みを展開すると良い。

Basecampでは必要に応じていくつかの強力なアプローチを採用している。例えば、私たちのカレンダーはクライアント側のレンダリングを使用する傾向にある。私たちのテキストエディタTrixという完全な形のテキストプロセッサで、Stimulusコントローラの位置付けとしては意味を成さない。

一連の代替フレームワークはできるだけ重労働を回避することを目的としている。この単純なモデルでうまく機能する多くのインタラクションに対して、リクエスト・レスポンスパラダイムの範囲内に留まる。そして必要性が真に迫っていれば高価なツールに手を伸ばす。

何よりもこれは、より労力がかかる主流のアプローチを使う大きなチームと、忠実さで競争したいと思っている小さなチームのためのツールキットである。

試してみてごらん。

デイヴィッド・ハインマイアー・ハンソン

サーバーサイドのみのテンプレートエンジンとしての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(...)を拾っていい感じにしてくれるようになるのを期待できなくもない。というのを書きながら希望的観測過ぎる気はしてきた。

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

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