React Hooksで保持する参照を毎回初期化しないようにする

React Hooksを使ってオブジェクトへの参照を保持する場合、初期値を伴うuseRef()を使うと次のようになる:

const MyComponent: React.FC<Props> = () => {
  const instanceRef = React.useRef(createInstance())
  return ...
}

こうするとコンポーネントが再レンダーされるたびに毎回初期化を行ってしまって無駄な処理になるほか、初期化に副作用があったりすると厄介になる。

useState()によって初期化するとこの問題が回避できる:

// useRef () will initialize a reference on every render.
// useState () allows initialization only on first render.
// https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
function useLazyInitializableRef<T>(create: () => T): T {
  const [value] = React.useState(create)
  return value
}

const MyComponent: React.FC<Props> = () => {
  const instance = useLazyInitializableRef(createInstance)
  return ...
}

(ちなみにtsx内ではアロー関数にジェネリクスが使えない)

useState()の引数に渡した関数は初回レンダー時のみ実行され、後続のレンダーでは無視される

react-useにあるuseMeasure()の実装を見ていて気づいた。

翻訳:Rich Harris「形而上学とJavaScript」に関する見解(ReactによるDOMの抽象化の不完全性について)

この記事は「Thoughts on Rich Harris’ “Metaphysics and JavaScript”」の日本語訳です。Svelteのコーディングスタイルや記事として言及している講演のスライド併せて参照しなければ理解しづらい内容です

公開にあたっては著者のJim Nielsen氏に許諾をいただいています。

要旨としては、ユーザーにとってSvelteは実際のDOMとのメンタルモデルのギャップが少なく感じられるデザインであって、結果的に、Reactより理解しやすいプログラミングモデルになっているという話です。


Svelteの考案者であるRich Harris氏は、Reactのプログラミングの側面を批判する「形而上学とJavaScript」というタイトルの最近の講演のスライド共有しました。この講演には非常に説得力があり、Reactを利用した私の経験にも当てはまるとわかりました。

この記事では彼の論点のいくつかを反芻して、Reactでのプログラミングに関する私自身の感情をさらに解明し、明確にするために役立てたいと思います。

はじめに:私はReactが大好きで毎日使っています。以降の話はそれを変えてしまうものではありません。

UIは状態からなる関数であるか?

Richは講演の冒頭で、「神聖な牛の中でもっとも神聖なもの」(訳注:ヒンドゥー教では牛は神聖な存在であり、原文のsacred cowsは神聖なるものという慣用句)を批判する計画であることを明らかにしました。UIはアプリケーションの状態からなる関数であるということについてです。

UI = F(STATE)

この公式化こそがReactのプログラミングモデルの核心ですが、しかしこれが間違いであると主張するつもりはありません。私はこれが不完全であると主張します。Reactアプリを作ったときに実際になにが起こっているかの説明というよりは、イデオロギー的な主張であるということです。そしてこれは私たちがコードについてどう考えるかについて、より深い真実を曖昧にしてしまう思考様式を表していると私は主張します。

Gut punch.

では公式UI=F(STATE)はどのように間違っているのでしょうか? ああ、でも彼はそれが「間違い」だとは言わずに「不完全」だと言いました。彼は「UIは状態からなる関数である」というのはReactアプリを作ったときになにが起こっているかを正確に説明するものではないと言います。

私はRich氏の言葉を引用していますが、彼が言ったのは、UIがアプリケーションの状態からなる関数であるという考え方は抽象化であり、うまく機能してかなりあなたを助けますが、それでもただの抽象化だということ。抽象化は便利ですが、それでも素材の上にあるレイヤーであることに変わりはありません。下層の素材の上をきれいに塗装できないとわかったときには非常に厄介になることがあります。抽象化の欠点を克服するための一貫した戦術を見つけられなければ、火事が起こり、フラストレーションで燃え尽きるまで絶えず軋轢が生じます。

Julio Biason氏が書いた記事「私が苦労しながら学んだ物事(ソフトウェア開発の30年で)」を思い出します。彼は、デザインパターンは解決策を見つけるためではなく、解決策を説明するために使うべきであると述べています。

(繰り返しますが個人的な意見では)デザインパターンが適用されているのを見ると、ほとんどの場合、解決策を見つけるための方法として適用されていました。そのため最終的には解決策をひねって、時には問題の方をパターンに適合させることになるでしょう。

彼は特に「デザインパターン」について語っていますが、UIは状態からなる関数であるというイデオロギーをめぐるRich氏の主張にもかなりよく当てはまるようです。言い換えれば、UIは状態からなる関数であるという考え方は非常に有用であることがわかります。複雑さを乗り切るのに役立ちます。しかしながらそれは、現実の世界でウェブアプリケーションを実際に構築する方法、つまりDOMという基礎の上にあるイデオロギー的な抽象概念です。Rich氏はこれについて時間をかけて説明します。

「UI = F(STATE)」はイデオロギー的な声明だと思いますが、私が言いたいのは、資本主義イデオロギー共産主義イデオロギーのような他のイデオロギーと同じように、個人やグループが純粋に認識論的な理由以外で保持している規範的な信念や価値観の集合であり、大まかに言うと、「人々が実際に生きている世界ではなく、人々が想像したいと思っている世界を表している」ということです。

私は「イデオロギー」という言葉を、否定的なものではなく記述的ラベルとして使用しています。

しかしイデオロギーは危険を伴う場合があります。なぜなら彼らがいつもしているように、現実に逆らって苛立ち始めると、イデオロギーの衝動は常に、理論を修正することよりも、現実を作り直すことにあるからです。

「UIは状態からなる関数」というのは複雑なウェブアプリケーションを構築するための適切な解決策に近い理想論であり、私たちはReactのプログラミングモデルを通じてそのアイデアを活用しています。ですが今日では、私たちが実際に構築する素材(DOM)に合うように、イデオロギー的な解決策に多くのものを加えなければならないという不可避的な欠陥があります。どちらかというと、むしろトレードオフになると言うべきかもしれません。それは、私たちが生きているイデオロギーが現実のミスマッチと摩擦するときに軋轢を生みます。

私が本当に称賛したいのは、フレームワークの背後にあるイデオロギーと、ウェブアプリケーション構築の現実とのミスマッチに対処しようとするRich氏の試みです。素晴らしいことです。これはReact対Vueのような話をするときに聞きたいものです。単なる「ReactはJSXを使い、VueはHTMLテンプレートを使っている」という話でなく。

話が脱線しました。Rich氏が中断したところから続けましょう。

では関数型UIに対して非常に素朴なアプローチを取ったとしたらどうなるでしょう。たとえばある状態からなにかしらのUIを生成する関数を作成します。これには、その状態が変化したときに関数を再び呼び出すイベントリスナーが含まれます。

Rich氏の講演より、状態からなる関数としてのUIの簡単な例を描いたアニメーションGIF

const render = state => {
  document.body.innerHTML = `
    <h1>Hello ${state.name}</h1>
    <input value="${state.name}">
  `;

  const input = document.querySelector('input');

  input.oninput = () => {
    render({
      name: input.value
    });
  };
};

render({ name: 'world' });

ここでなにが起こっているのかがわかりますか? 「状態からなる関数としてのUI」というアイデアを示す非常に単純でインタラクティブな体験を実装しようとしています。「Hello XXX」というテキストがありますが、ここではXXXは<input>の値で埋められます。JavaScriptのコードはinputの変更を監視するだけに過ぎず、変更が行われるたびにDOMにアクセスして<body>のすべてを消去し、inputの新しいステートフルな値に基づいてUIを再描画します。

この例は、UIはアプリケーションの状態からなる関数であるという考え方をよく表しています。ではここでの問題はなんでしょうか。なぜこれが機能しないのでしょうか? UIには、nameというアプリケーションの状態だけでなく、より多くの状態があります。Rich氏は言う。

古いinputにはフォーカスがあり、新しいinputにはフォーカスがないため、機能しないことがわかります。

コードが実行されるたびにDOMにアクセスし、<body>のすべてを新しいHTMLに置き換えます。しかしおそらく気がついていなかったのは、DOMにはすでになにかしらの状態、つまりinputにフォーカスがあるということです。新しい状態で再描画するために<body>のすべてを削除すると、特定のinputにフォーカスがあるというDOMにあらかじめ含まれている暗黙の状態が失われます。Reactは制御された(controlled)inputとともにこの問題を処理(解決)してくれますが、もしかするとあなたはご存知なかったでしょうか? Rich氏の指摘に沿って簡単な例を作るまで、私はそれを完全には理解していませんでした。「クソッ、ブラウザがデフォルトでどの状態を処理するか把握していなかった」。したがってUIは必ずしもアプリケーションの状態からなる関数ではないようです。

Rich氏は、DOMの暗黙的な状態をアプリケーションのコードに引き上げることでこの問題を解決できることを示しています。なにが起こるのでしょう? 特にアプリとそのインタラクションが複雑になるにつれて、同じ種類の問題が次々と発生します。

つまりDOMはステートフルであることがわかります。しかし私たちがReactのようなフレームワークから逃れようとしているのはそれだと思いました。そしてRich氏が指摘しているミスマッチがあります。フレームワークには必ずしも現実と一致しないイデオロギー的な傾向があります。このようにして、私たちは現実に合わせてイデオロギーを再構築するのではなく、イデオロギーに合わせて現実を再構築しようとすることがよくあるのです。

Rich氏が続けて指摘しているように、DOMの暗黙の状態(inputのフォーカス、要素がマウントされた時間など)は、ユーザー(開発者)が画面になにを表示するかを決定する上でいつでも重要な要素です。この暗黙の状態の多くは簡単にはアプリケーションに持ち込めません。

純粋な関数を使ってUIを表現したいという願望はDOMの本質と真っ向から対立しています。状態→ピクセル変換を記述するには最適な方法であり、ゲームレンダリングやジェネレーティブアートには最適です。しかしウェブ上でアプリを構築していると、そのアイデアはステートフルなメディアの実際に逆らってしまいます。

あるレベルでは、ReactはDOMの多くの面倒を見てくれていることをご存知でしょう。知っています。しかし「UI=F(STATE)」という基本的なアイデアを取り入れたアプリをJavaScriptで(Rich氏が提供した例のように)構築しようとしたとき、Reactが私のためにどれだけのことをしているのかが分かり始めました。

そしてそれが、私がRich氏の議論の頂点として見いだしたものへと導いてくれるのです。

もちろんReactが「DOMの暗黙的な状態の競合」をどのように処理するかはわかっています。Reactは仮想DOMツリーの新しいノードをDOMの既存のノードにマップします。つまり、Reactは明らかに機能していない基盤を機能的に抽象化したものです。

私の経験では、抽象化と抽象化の間のギャップが大きければ大きいほど、プログラマーが「インピーダンスミスマッチ」と呼ぶものに悩まされる可能性が高く、Reactでもそれを経験しているように思うのです。

つまりもっとも基本的なレベルでは、Reactのプログラミングモデルとウェブの命令型APIとの間にミスマッチがあります。ウェブには性質があり、Rich氏は「UIは状態からなる関数」という考えがそれに反することをうまく説明しているように思います。

ではここからどこへ向かうのでしょうか。私にはわかりません。しかしこれを書くことで、私が頻繁に感じる摩擦をよりよく理解できるようになりました。そして「Svelteが存在する理由」という講演のための完璧な準備です。

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パターンとしてのコンポーネントの作成を見送ることが多い。