mizdra's blog

ぽよぐらみんぐ

150B で動く strictly-typed な Event Emitter を作った

超軽量で strictly-typed な (強く型付けされた) Event Emitter を作りました。

github.com

背景

従来 Event Emitter は ブラウザでは EventTarget として、Node.js では EventEmitter として、それぞれ独自に実装されていました。これらは機能的には変わりがありませんが、インターフェイスの互換性がありません。そのため、両方の環境で同じ API で動作する universal な Event Emitter が欲しければ、eventemitter3 のようなライブラリを利用する他ありませんでした。しかし、最近になってブラウザで実装されている EventTarget が Node.js に実装され始め *1*2EventTarget が Universal な Event Emitter としての地位を確立しつつあります。

EventTarget はネイティブな API であるため、eventemitter3 のような 3rd-party ライブラリと比較してサイズが小さい *3 という利点があります。しかし、一方で EventTargeteventemitter3 のように dispatch するイベントの種類を制限したり、addEventListener に渡したイベント名からリスナーで受け取れるイベントの値の型を推論することができません。そのため、予期せぬ種類のイベントが dispatch される可能性を考慮してコードを記述する必要があったり、明示的に型チェックを行わなければならないといった問題がありました。

@mizdra/strictly-typed-event-target の紹介

そこで、@mizdra/strictly-typed-event-target というライブラリを作りました。EventTargetCustomEvent をベースに作られているため、とても軽量です。ES Module 形式と UMD 形式の2つのビルドが用意されていて、この内 ES Module 形式はなんとたったの 150 B (厳密にいうと 152 B) で構成されています。

使い方

FooEventMap のようなインターフェイスにイベント名とイベントの値の型のリストを記述し、createSTEventTarget に型パラメータとして渡すと、強く型付けされた CustomEventEventTarget が返り値で得られます。簡単ですね。eventemitter3 を触ったことのある人なら分かると思うのですが、EventMap インターフェイスを渡して型付けする部分は eventemitter3 を真似てます。

import { createSTEventTarget } from '@mizdra/strictly-typed-event-target';

interface FooEventMap {
  onmessage: string;
  onerror: Error;
  oninstall: undefined;
}

const [FooCustomEvent, FooEventTarget] = createSTEventTarget<FooEventMap>();

FooCustomEvent / FooEventTarget はネイティブのものと同じ API が生えていて、全く同じように使えます。ただし、FooCustomEventdetail フィールドが EventMap に応じて型付けされていたり、EventMap で定義されていないイベントの dispatch が禁止されていたりと、より強く型付けされた APIとなっています。

const fooEventTarget = new FooEventTarget();

/** `addEventListener` */

// error: Argument of type '"invalid-event"' is not assignable
// to parameter of type '"onmessage" | "onerror" | "oninstall"'.
fooEventTarget.addEventListener('invalid-event', () => {});
fooEventTarget.addEventListener('onmessage', (event) => {
  // `event.detail` is infered `string` type.
});
fooEventTarget.addEventListener('onerror', (event) => {
  // `event.detail` is infered `Error` type.
});

/** `dispatchEvent` */

fooEventTarget.dispatchEvent(
  new FooCustomEvent('onmessage', { detail: 'hello' })
);
// error: Type 'Error' is not assignable to type 'string'.
fooEventTarget.dispatchEvent(
  new FooCustomEvent('onmessage', { detail: new Error() }),
);
fooEventTarget.dispatchEvent(new FooCustomEvent('oninstall'));

152 B のトリック

実装をよく見てもらえると分かるのですが、ライブラリの大部分は型情報で、JS にトランスパイルされた時に残る部分は createSTEventTarget の実装部分だけです。createSTEventTarget も本当に素朴なことしかしていなくて、ネイティブの CustomEvent / EventTarget をただ strictly-typed な CustomEvent / EventTarget へと type assertion しているだけです。ズルいですね。

export function createSTEventTarget<EventMap extends {}>() {
  const STCustomEvent = (CustomEvent as unknown) as STCustomEvent<EventMap>;
  const STEventTarget = EventTarget as STEventTarget<EventMap>;
  return [STCustomEvent, STEventTarget] as const;
}

そしてこれをトランスパイルし、ES Modules 形式で出力したものが以下になります。これで丁度 152 B です。

export function createSTEventTarget() {
    var STCustomEvent = CustomEvent;
    var STEventTarget = EventTarget;
    return [STCustomEvent, STEventTarget];
}
//# sourceMappingURL=index.js.map

ちなみにより踏み込んだ話をするなら、minify すればもっと小さくなりますし、何なら以下のように型定義だけ使えば、Tree-Shaking が効いて 0 B になります。まあたった 152 B を嫌って長ったらしい型注釈を書きたい人は居ないはずなので、ただの面白テクニックという感じです。

import {
  STCustomEvent,
  STEventTarget,
} from '@mizdra/strictly-typed-event-target';

interface FooEventMap {
  onmessage: string;
  onerror: Error;
  oninstall: undefined;
}
const FooCustomEvent = (CustomEvent as unknown) as STCustomEvent<FooEventMap>;
const FooEventTarget = EventTarget as STEventTarget<FooEventMap>;

変更履歴

  • 2020/09/21
    • 本ライブラリが他のライブラリと比較して高速であるという旨の説明をしていましたが、実際には割と遅いほうだということが判明したため、速度に関する表記を削除しました (参考1, 参考2)
    • 当初既存の EventTarget が型安全ではないという説明がなされていましたが、これは誤りでした。実際には EventTarget は型安全であり、本ライブラリはそれをより強く型付けしたライブラリという位置づけです。そこでそれを反映すべくライブラリを strictly-typed-event-target から strictly-typed-event-target に rename しました。
    • サイズがgzip後のものを指していたため、gzip前のサイズになるよう本文や記事タイトルを書き換えました (151 B => 158 B)
    • 合わせて関数名の rename を行った結果、サイズが少し減りました (158 B => 152 B)

*1:2020/09 現在ではまだ一般ユーザに公開されていない、internal で実験的な機能です。

*2:IE11及びSafariでは一部のAPIがサポートされていません。

*3:むしろ一切バンドル不要なので、実質 0 B

趣味で創作する時は常に何かしら新しいことに挑戦する

  • 普段趣味プログラミングで何か作る時、何かしら新しいことに挑戦するということを意識している
    • 例えば何か触ったことのない技術を導入してみるとか、採用したことのない開発手法を取り入れてみるとか
    • より具体的に言うとHeroku導入してみるとか、TDDで開発してみるとか
  • 折角何か創作活動をするので、ついでに新たなことに挑戦し、新たな学びやスキルの向上へと繋げようという狙い
  • ここまではよくある話だと思うけど、mizdraの場合は更に踏み込んで、「新しいことは数を絞って注力できるようにする」ということも意識している
    • 新しいことに挑戦するのは多くの場合、非常に負荷が掛かる
      • 例えばHeroku導入するにしても、HerokuのCLIやダッシュボードの使い方を学ぶ必要があるし、PaaSを触ったことが無ければそもそもPaaSとは、一体何が出来てどこまで面倒を見てくれるのか、ということから学ぶ必要がある
    • 挑戦する数が多いと、それだけ1つあたりに避ける学習時間が減ることになる
      • 学習時間が減って新しいことを上手くモノに出来ない、となるのは勿体ない
      • 折角挑戦するので、1つあたりの学習時間を確保し、より多くのことをモノにできるようにすると良い
    • そこでmizdraがプログラミングする時は新しいことは基本1〜2個、多くて4〜5個になるように努めている
      • 少なければ少ないほど1つあたりに割く学習時間が増え、より深い学びへと繋げられる
    • 学ばなければならないことが多すぎてこれに挑戦するのは大変そうだなと思ったら、無理して挑戦せず、フレームワークなどを導入して楽をすると良い
      • 分からないことには蓋をする
    • 回数を積み重ねれば、どんどん見識が広がっていく
      • 無理ないペースで段階的に学んでいくことができる
      • 見識が広がっていけば、最初はフレームワークを使って見てみぬフリをしてきた部分にもいつしか学習の目を向けられるようになるはず
      • mizdraの話だと、以前はWebフロントエンドに閉じこもり、バックエンド触るの回避し続けてたけど、サークルの雑務や業務などを通じて少しずつ知識が備わってきて、最近バックエンドを触り始めている
      • SQLはまだよく分からないのでORMで蓋をしている
  • また、もうちょっと踏み込んで「新しいことには丁寧に取り組む」「時間を割くことに躊躇しない」ということも意識している
    • もう少し別の言い方をすると「力を入れる」とか
    • 折角挑戦するのでより深く学び、使いこなせるようになると良い
    • 例えばあるツールのオプションが存在して、ググって出てきた記事にオススメですと書かれていたので真似して利用する、ではなく、オプションを付けると何が起こるのか、どういう時に使われることを想定しているのか、オプションが実装された背景は何か、今回利用するのは本来の用途的にはふさわしいのか、といったことを必要に応じて徹底的に調べていく
    • 例: React 副作用分割パターン - mizdra
      • React Hooksを触った際に「React Hooksはどう使うのか」から更に深く思考して、「そもそもReact Hooksって副作用を分割するものだよね」、「そういえば副作用を分割する技術には継承・mixin・HOC・render propsと過去に色々発明されてきたよね」、「それと違うところはなんだろう」、「既に副作用を分割する技術があるのに発明が続いたということはそれぞれに欠点があってそれを改善しようとした意図があるはずだよね。だって技術は螺旋だから。その意図はなんだろう。」「そもそもそれぞれの技術の利点・欠点や生まれた背景は?」「それらを踏まえてReact Hooksでは何が良くなった?」とどんどん深堀りしていった
      • 深堀りした結果、React Hooksが任意の副作用の発生する場で使えることが分かったり、過去の発明の利点や欠点が分かったり、React Hooksの優れている点が分かったり、React Hooksは偉大な発明ですねと自信を持って言えるようになった
    • とはいえ全てを深堀りしていっては時間が足りなくなる
      • これも数を絞って狙い撃ちすると良い
      • mizdraは興味のある場所や、深堀りすると勉強になりそうな場所、ググって出てきた記事で分かったようで分からないような解説がなされている場所などを狙い撃ちして深堀りしています
    • 新しいことを学ぶ機会はとにかく大切にする
  • 「趣味で創作する時は常に何かしら新しいことに挑戦する」という姿勢はプログラミングだけでなく執筆活動でも意識している
    • 「普段は動機の項を雑に書くけど今回は丁寧に書こう」、「普段は生真面目な記事を書いているけど今回は緩い感じで笑ってもらえるような記事を書こう」、「本書いたことないので本書こう」、「『はじめに』を丁寧に書いて読者が釘付けになるようにしよう」「普段はプログラマー向けの記事を書くけど、今回は対象読者を広げてデザイナーなどにも読んでもらおう。そのためにそうした人々にとって分かりやすい表現を心がけよう。」 などなど
    • プログラミングや執筆活動に限らず、色々な場面で適用できるはず

過去の実例たち

ポートフォリオに載っけられるよう、学んだ知見はできるだけ何らかの形にしてオープンインターネットに放流しているので色々ある。何が言いたいかと言うと挑戦した結果をついでにアウトプットしておくと武器になってお得ということです。

  • 乱数調整 入門 - mizdra's blog
    • Advent Calendar参加したことないので参加してみた (そもそも初参加 & 初主催だった)
    • 長文ブログ書いたことないので書いてみた
    • 教材的なものを書いたことがないので書いてみた
  • Headless Chrome を使って自動車学校の技能教習の予約が空いたら通知するスクリプトを書いた - mizdra's blog
    • puppeteer出たばかりだったので触ってみようということで触ってみた
  • Emtimerの紹介 - mizdra's blog
    • 今まで開発動機やプログラムの説明を丁寧に書いたことなかったので、今回は丁寧に書いてみようということで書いてみた
  • 遅刻可視化ツールの紹介 - mizdra's blog
    • リッチなUIのWebアプリ作ったことなかったので作ってみた
    • あと裏テーマとして、今まで雑に技術選定してきたので、今回はそれぞれのフレームワークやライブラリを比較・検討し、ちゃんと良し悪しに自信を持って採用しましょう、という挑戦もしていた
  • WebAssembly 開発環境構築の本を公開しました - mizdra's blog
    • wasm初導入/電子書籍初執筆/VuePress初導入など色々挑戦した
    • あと裏テーマとして宣伝方法を工夫して、ブクマを集めてみましょうという挑戦もしていた
      • ブログで丁寧な紹介エントリも書きましょうとか、タイトルは「執筆しました」ではなく無料で読めることが分かるよう「公開しました」にしましょうとか気をつけていた
  • Google Play Music 向けの #NowPlaying 拡張機能を公開しました - mizdra's blog
    • ブラウザ拡張初開発/WebExtensions API導入
  • WebAssemblyを使って乱数調整ツールをWebに移植した話 - mizdra's blog
    • 初めてのwasmを使ったアプリケーション
    • 真面目にアプリケーションの性能評価したことがなかったので、この際wams導入で速くなりました、ではなくどれくらい速くなったのかをグラフを使って分析した
    • あとはプログラムのアーキテクチャ図を書いたことがなかったので、drawioを使って頑張って書いてみた
  • 日向縁さんの誕生日をお祝いしてゲームを作った話 - mizdra's blog
    • ゲーム作ったことなかったのでゲーム作った
    • 当時React Hooks登場したばかりだったのでReact Hooks導入してみた
  • React 副作用分割パターン - mizdra
    • ゆかりスロットを実装した際の知見をアウトプットしたもの
    • 過去の技術を比較してどう良くなったのか、というのを丁寧に分析したことがなかったので、丁寧に分析してみた
    • scrapboxでちゃんとした記事を書いたことがなかったので、使用感を把握するためscrapboxで記事を書いてみた
    • 鋭い洞察を連続して繰り出すことで、長文だけど読者が飽きずに最後まで読み進められるよう心がけた
      • ネタに走らず読者の関心を保つ、という1つの挑戦
      • 結果として多くのブクマを集められた
  • mizdra on Twitter: "ゆゆ式 Advent Calendar 2019 21日目の「今日のゆゆ式」の動画です #ゆゆ式ac https://t.co/tezNbs9JTK… "
    • ハードウェアプログラミングっぽいことしたことないなと思って、レシートプリンタ触ってみた
      • PDFやPostScriptのこと、ESC/POSのこと、ネットワーク越しにプリンタを操作する方法、lpコマンド、cups、ドライバの仕組みなどについて学ぶことができた
      • その後知見はTM-T88IV 接続メモ - mizdraにまとめた
    • Raspberry Pi触ったのも初めてだった
    • 当然4コマ漫画裁断して画像処理するのも初めてだった
  • ebith/Switch-Fightstick - mizdra
    • 工作したことがなかったので工作した
    • ハードウェアプログラミングの素振りはレシートプリンタでしていたので、結構すんなりできた
      • 見識の広がりによって段階的に学ぶことができた
  • iTerm2 で `cat /dev/urandom` すると印刷ダイアログが出ることがある - mizdra's blog
    • 面白技術ネタ書いたことがなかったので書いてみた
    • 文体も普段の生真面目なものから趣向を変えて、陽気な人間を意識して書いてみた
      • 普段は「20へえ」とか書かない
      • これも文章による表現方法を増やすという、1つの挑戦です
  • 画像による Layout Shift が無くなる Web がやって来る - mizdra's blog
    • 『はじめに』を丁寧に書いて読者の注意を引くことで、スクロールバーから文章量を察して読者が離れてしまわないよう心がけた
    • 論文執筆で学んだ姿勢をブログでも活かしてみようと思って活かしてみた
    • 普段はプログラマー向けの記事を書くけど、対象読者を広げてデザイナーなどにも読んでもらおうと思って丁寧に書いた
    • jxckさんっぽい技術記事書いてみたいなと思って真似してみた
    • React 副作用分割パターン - mizdraの時と同様に、鋭い洞察を連続して繰り出すことで、長文だけど読者が飽きずに最後まで読み進められるよう心がけている
      • 身につけたスキルを活用し、段階的に学んでいっている
    • React 副作用分割パターン - mizdraではReactという関心を集めやすい技術を扱っていて、対してこの記事ではそうではないものをテーマとしてブクマを集めるという挑戦にもなっていた

Scrapboxのページ内に埋め込まれているアイコンをsuggestして挿入できるUserScript作った

Scrapboxで会議の議事録を取っていると、誰の発言かを記録するために他人のアイコンを挿入したい、という場面が出てくる。Scrapboxでは ctrl+i と入力すれば簡単に自分のアイコンは挿入できるけど、他人のアイコンを簡単に挿入する方法は意外にない。一応以下のような手順で任意のページのアイコンを挿入できるというテクニックもあるけど、キーボード上を結構ダイナミックに指を動かす必要があってお手軽ではない & そもそもアイコンが無いページもポップアップに出てくるのでノイズが多い。

  1. [ キーを押す
  2. scrap と入力
  3. scrap にマッチするページ一覧がポップアップで出てくるので、Tabキーを数回押して scrapbox にカーソルを合わせる
  4. ctrl+i[scrapbox.icon] を挿入

そこでもっとお手軽に他人のアイコンを挿入できるUserScriptを作った。

gyazo.com

ctrl+l と押すとsuggest-boxが表示されて、ページ内に埋め込まれているアイコンがポップアップで表示される。boxにキーワードを入力すると、そのキーワードにマッチするアイコンが絞り込まれる。Scrapboxネイティブのポップアップと同じように、Tabキーでカーソルを移動し、Enterで確定できる。

パフォーマンス上の制約から、初めにsuggest-boxを押したタイミングでページ内を解析し、ヒットしたアイコン一覧がページごとにキャッシュするようになっている。そのため、一度suggest-boxを表示した後そのページに無かった種類のアイコンを挿入すると、そのアイコンはsuggest-boxには出てこない。もし後から追加したアイコンもsuggest-boxに出したければブラウザリロードするか、suggest-boxの表示中に ctrl+r を押すと良い。キャッシュが破棄・更新され、アイコンが出るようになる。

導入方法

UserScriptを有効化した上で自分のページに以下のスクリプトを追加する.

// ref: https://scrapbox.io/mizdra/icon-suggestion
import { registerIconSuggestion } from '/api/code/mizdra/icon-suggestion/script.js';
registerIconSuggestion();

おまけ

icon-suggestionを作る過程でいくつか有用なコード片が生まれたので、ライブラリとして切り出して公開しておきました。良ければお使い下さい。

画像による Layout Shift が無くなる Web がやって来る

はじめに

Web では古来より <img> タグを用いて画像を読み込んでいました. しかし <img> タグにはアスペクト比に関する情報を埋め込むための属性が用意されていません. そのため, ブラウザが画像をネットワークから fetch して読み込みが完了するまで, レスポンシブな img 要素の寸法を決定できず, ページにガタツキ (Layout Shift) が生じる問題がありました.

この問題を解決するため以前より, アスペクト比を埋め込むための新たな属性の導入が提案されていました. しかし最近議論に動きがあり, 既存の属性を利用する方法が提案され, ブラウザに実装され始めています. ここでは問題の背景, 提案と議論の変遷, そして開発者が取るべき対応について紹介します.

img タグと Layout Shift

Web ページにおいて, できるだけ早く意味のあるものをユーザに表示することはとても重要です. ページの表示の遅れはユーザに不快感を与え, ページから離脱する可能性を高めます. そのためブラウザは初期のページの表示に不要なリソースの読み込みを遅延させ, できるだけ早く意味のあるページを表示します.

例えば画像はテキストと比較し, 非常に容量の大きいリソースです. HTML ファイルのダウンロードは数十ミリ秒でできても png ファイルの場合は数秒, 3G のような低速回線では数十秒掛かるかもしれません. そこでブラウザは画像のダウンロードを待たずにページのレンダリングを開始します. img 要素は初回のレンダリングでは 0x0 で描画され, ダウンロードが完了したら正しい寸法でレンダリングし直されます.

しかしこれには画像のダウンロード前後でページにガタツキ, いわゆる Layout Shift が発生するという問題があります. 試しに以下のサンプルページをタブで開いて, リロードして見て下さい. はじめに ページタイトル, パラグラフ とだけ書かれたページがまず表示され, ページタイトル, パラグラフ の間に画像が表示されます. この画像のダウンロード前後に発生するズレが Layout Shift です. *1.

See the Pen oNjaQVr by mizdra (@mizdra) on CodePen.

こうした問題を回避するため, img タグの width / height 属性や CSS の width / height プロパティで img 要素の寸法を指定することが推奨されています. これにより, ブラウザは画像をダウンロードする前に余白を確保し, ダウンロードされたらその余白に画像を描画できるようになります.

See the Pen oNjaQVr by mizdra (@mizdra) on CodePen.

しかし, レスポンシブな img タグの場合は一筋縄ではいきません. というのも, img タグで HTML の width / height 属性と CSS の width / height プロパティを同時に使用した場合, img タグの寸法の算出にはCSSプロパティのみが利用されるからです. 例えば以下のページではHTML属性で width="200" height="100" を指定していますが, 実際に img タグの寸法の算出に利用されるのはCSSプロパティの width: 100%; height: auto; です. そのため, img 要素は初回のレンダリングでは 400x0 で描画され, ダウンロードが完了したら 400x200 でレンダリングし直され, Layout Shift が発生してしまいます.

<div>
  <h1>ページタイトル</h1>
  <img
    class="image"
    src="https://placehold.jp/200x100.png"
    width="200"
    height="100"
    alt="画像"
  />
  <p>パラグラフ</p>
</div>
div {
  box-sizing: border-box;
  border: 4px solid red;
  width: 400px;
}
.image {
  box-sizing: border-box;
  border: 4px solid #333;
  width: 100%;
  height: auto;
}

intrinsicsize 属性

この問題の根本的なレスポンシブな img 要素の寸法はその画像のアスペクト比が分からないと計算できないにも関わらず, 画像をダウンロードする以外にアスペクト比を知る手段が無いことでした. 裏を返せば, ブラウザが画像をダウンロードする前にアスペクト比を知る手段を提供すれば問題を解決できるということです. そこに着目し, 提案されたのが intrinsicsize 属性です.

github.com

googlechrome.github.io

intrinsicsize はブラウザに画像のオリジナルの寸法を無視し, この属性で指定された寸法であると見せかけるよう指示する属性です *2. ブラウザはこの属性で指定された値を画像のオリジナルの寸法としてレイアウトの計算を行います. これにより, ブラウザは画像のダウンロード前に属性値から画像の寸法 (およびアスペクト比) を算出し, レスポンシブな画像であっても適切な余白を確保できるようになります.

例えば200x100の画像を読み込む img タグには以下のように intrinsicsize="200x100" と記述すると, Layout Shift の発生を抑えつつ レスポンシブに画像を表示することができます.

<div>
  <h1>ページタイトル</h1>
  <img
    class="image"
    src="https://placehold.jp/200x100.png"
    intrinsicsize="200x100"
    alt="画像"
  />
  <p>パラグラフ</p>
</div>
div {
  box-sizing: border-box;
  border: 4px solid red;
  width: 400px;
}
.image {
  box-sizing: border-box;
  border: 4px solid #333;
  width: 100%;
  height: auto;
}

intrinsicsize 属性の問題点

しかし, intrinsicsize 属性には 2 つの問題点があります. 1 つは画像のオリジナルの寸法を取得する API (naturalWidth / naturalHeight) に影響を与えてしまうことです. これは属性の仕様上妥当な挙動ではあるのですが, そうした API に依存しているコードの挙動を変えてしまう恐れがあります. もう 1 つは width / height 属性と名前が紛らわしいことです. intrinsicsize 属性は画像オリジナルの寸法を意味する属性ですが, 歴史的には width / height 属性が画像オリジナルの寸法を表していたはずです.

width / height 属性による解決策

そこで intrinsicsize 属性の代替として提案されたのが WICG/intrinsicsize-attribute#16 (以下, 本提案と呼びます) です.

github.com

本提案は CSS により上書きされ無視されていた HTMLの width / height 属性をアスペクトの算出に利用します. intrinsicsize 属性のように画像オリジナルの寸法を指定すると, それを元にブラウザが画像のアスペクト比を算出し, レイアウトを計算します.

Issue ではこれは以下の CSS がブラウザのデフォルトのスタイルシートに追加されるものとみなせると紹介しています.

img {
  aspect-ratio: attr(width) / attr(height);
}

実際には上記の CSS は現在の仕様やブラウザの実装では利用できない機能 *3 *4 を使っており, 本提案の仕様とも厳密には異なっています (詳しくは後述). しかし本提案の概念を説明するための擬似コードとしては良くできているため, 本記事でもこの擬似コードを使って説明することにします.

例えば, この擬似コードからは以下のような特徴が分かります.

  • アスペクト比は既存の width / height 属性から算出されます
    • 古来の Web から存在する慣習に沿うだけで, Layout Shift を抑えられます
    • セマンティクス的にも優れています
  • アスペクト比の事前算出を指示するような特別なマークを付ける必要はありません
    • 上記の CSS はブラウザのデフォルトのスタイルシートに追加されるため, 全ての img 要素に自動的に適用されます
  • 画像のオリジナルの寸法を取得する API に一切影響を与えません
    • ただアスペクト比が width / height 属性から算出されるだけで, 画像のオリジナルの寸法を取得する API に影響はありません
  • 馴染みのある機能や既に標準化団体で議論されている機能から成り立っています
    • aspect-ratio プロパティや attr() 関数, width / height 属性を組み合わせているだけです
    • intrinsicsize のような新たな属性の導入は一切ありません

つまり, 馴染みのある機能などを元に intrinsicsize 属性で問題とされていたことを解決しつつ, Layout Shift を解消している訳です. めっちゃすごい. すごくないですか? 僕はすごいと思いました. とはいえ本提案がすごいのはこれだけに留まりません. Breaking Changes や前方互換性といった面でも優れた仕様を持つ提案となっているのです. どういうことか詳しく見ていきましょう.

Breaking Changes

intrinsicsize 属性と提案とは異なり, 本提案では新規に属性を追加せず, 既存の属性をアスペクト比の算出に利用します. そのため, width / height 属性を使っている要素の挙動が変わる可能性があります. つまり, Breaking Changes が発生する可能性があります. 本提案の導入により予期せぬ表示の崩れや挙動が発生する可能性があることを考えると, どのような Breaking Changes が発生するか知っておくことは重要です.

しかしそれほど心配する必要はありません. 本提案は画像がレスポンシブで表示される場合に, 属性を余白の寸法の決定に利用するものです. つまり, width="200" height="100" のように固定値で要素の寸法を指定している場合や, width="100%" height="100%" のように親要素の寸法から要素の寸法が分かる場合の挙動は変わりません. これらは元からダウンロード前に余白の寸法を知ることができており, Layout Shift も発生していません. Breaking Changes はありません.

また, 本提案は CSS プロパティで上書きされ, 無意味になっていた HTML 属性をアスペクト比の算出に利用するものです. そのため, どちらか片方を指定している場合の挙動は変わりません. CSS には width: 100%; height: auto; が指定されているものの, HTML には width 属性も height 属性もない, といった場合の挙動は変わりません. このケースでは本提案であっても Layout Shift は発生しますが, Layouyt Shift 自体は元より発生しています. Breaking Changes はありません.

ではどういう時に Breaking Changes が発生するのでしょうか. 1 つは width / height 属性に正しいアスペクト比が設定されていて, CSS でレスポンシブに画像を表示しようとしている時です. この場合, ブラウザは width / height 属性を元にアスペクト比を算出し, 余白の寸法の計算に用います. よって, このケースでは本提案により Layout Shift が発生しなくなります. これは Breaking Changes です.

しかし, 元々発生していた Layout Shift が, 本提案により発生しなくなるのでこれは良い Breaking Changes と言えるでしょう. そもそもこれが本提案の目的ですからね. とはいえ画像の読込中の HTMLImageElement.height に依存して何かをしているページでは, この Breaking Changes の影響を受ける可能性があります. 例えば, width / height が 0 であることを利用して画像が読込中かどうかを判定しているページは, この提案の導入により壊れてしまうでしょう. とは言っても画像の読込中の width / height に依存しているコードはそうそうないはず*5で, 代替手段が存在する場合もある *6 ので, Breaking Changes の影響は僅かなはずです.

もう 1 つ, Breaking Changes が起こるケースがあります. それは width / height が不正なアスペクト比で, width / height プロパティに auto が指定されている時です.

See the Pen oNjaQVr by mizdra (@mizdra) on CodePen.

例えば, 上記のようなページがある時, ブラウザは以下のようにページをレンダリングします.

  • 従来
    1. 400x0 の余白を確保した状態でページが表示されます (HTML 属性が無視されるため)
    2. 画像がダウンロードされると, 400x200 で画像が表示されます (画像からアスペクト比が算出されるため)
  • 本提案
    1. 400x400 の余白を確保した状態でページが表示されます (HTML 属性から間違ったアスペクト比を算出して利用するため)
    2. 画像がダウンロードされると, 400x400 で画像が表示されます (前段の間違ったアスペクト比を再利用するため)

1, 2 どちらの段階でも Breaking Changes が発生していますが, 1 は元から壊れていたものの壊れ方が変わっただけで, 多くの場合問題にはならないはずです. 一方 2 は従来は正しい寸法で表示されていた画像が本提案では間違った寸法で表示されてしまっています. これは良くない Breaking Changes です. 不正なアスペクト比が width / height で指定されている箇所はそうないとは思いますが, Breaking Changes が起きないことに越したことはないでしょう.

そこで本提案では 1 の段階では aspect-ratio: attr(width) / attr(height); に基づきアスペクト比を計算し, 2 の段階では画像オリジナルの寸法からアスペクト比を計算するよう拡張が施されています. これが先程チラッと言っていた擬似コードと挙動が異なる部分です. この工夫のおかげで width / height が不正なアスペクト比であっても, Breaking Changes が発生しないようになっています.

前方互換性

しかし, 実際にこのテクニックを使用するにあたって気になってくるのは前方互換性でしょう. ページを訪問するユーザの環境は様々であり, 新しいブラウザを使用する人もいれば, 古いブラウザ (IE11 など) を使用する人もいます. そのため古いブラウザで本提案を使用したページが壊れないか (前方互換性はあるのか) という点が重要になってきます.

これは Web に限らない話ですが, 前方互換性を保ったまま機能追加を行うには古い環境で無視される方法で実装するのが常套手段です. 例えば intrinsicsize 属性は新規に属性を追加する方式を採用しています. ブラウザは未知の属性を無視するようになっているため, 古いブラウザでは単に無視され, 前方互換性が保たれています.

一方, 本提案は古いブラウザにも実装されている属性を利用します. これは一見すると前方互換性がないかのように見えますが, そうではありません. というのも, 本提案は HTML 属性と CSS プロパティ両方を指定してはじめて機能します. 両方が指定されている場合はブラウザは CSS プロパティの値を実際の img 要素のレンダリングに使うため, 古いブラウザでは HTML 属性は無視されます. そのため本提案は前方互換性が保たれており, 古いブラウザのアクセスがあるページでも安心して本提案を使用できます.

標準化と実装

2019/10 に WHATWG が本提案を承認し, 標準化しています.

また, Chrome, Edge, Firefox, Safari 全てのメジャーブラウザで実装が始まっています. Safari については Safari TP 99+で実験的機能として実装されている状態ですが, その他のブラウザについては安定化され (Chrome 79+, Edge 79+, Firefox 69+), 既に Stable で利用できる状態になっています.

開発者が取るべき対応

Breaking Changes の影響を確認する

本提案は既にブラウザに実装されています. つまり, 上記で説明したような Breaking Changes が既に発生しています. そのためまずはあなたの関わっている Web サイトを見て, Breaking Changes の影響を受けていないか確認すると良いでしょう. とはいえ上記で説明したようにレイアウト上の Breaking Changes は僅かです. あったとしても元からあった Layout Shift が無くなるか, 元からガタついている箇所のガタつき方が変わるだけです. 大きな影響を受けるのは JS で HTMLImageElement#widthHTMLImageElement#getAttribute('width') を利用している箇所くらいです. TypeScriptを使用しているなら, Find all references のような機能で当該のコードを探すと良いでしょう.

新規の img タグは width/height 属性を付けて書く

いきなり既存のコードを見ていって Layout Shift を解消するのも良いですが, まずは新規に書く img タグで Layout Shift が発生しないよう書くよう心がけることが重要です.

これからはレスポンシブな img タグを以下のように書くことが推奨されます.

<style>
  div {
    width: 800px;
  }
  img {
    width: 100%;
    height: auto;
  }
</style>
<div>
  <img
    alt="バナー"
    src="https://placehold.jp/200x100.png"
    width="200"
    height="100"
  />
</div>

レスポンシブではない場合でも, 以下のように常に width / height 属性を記述するスタイルでコードを統一するのも良いかもしれません.

<div>
  <img
    alt="バナー"
    src="https://placehold.jp/200x100.png"
    width="200"
    height="100"
    style="width: 100px; height: 50px;"
  />
</div>

これは厳格な規約であり賛否両論あるとは思いますが, Linter などで問題を検知しやすいといった利点があります. 既存のプロジェクトへの導入は難しいかもしれませんが, 1 からプロジェクトを立ち上げる場合はこの規約を採用するのも良いでしょう.

実際に JSX 内の width / height 属性が設定されていない img 要素を警告する ESLint rules を作ってみたので, 良かったら使ってみて下さい.

github.com

既存の Layout Shift を解消する

気が向いたら既存の Layout Shift を解消してみましょう. 既存のコード全てを見て直すのは大変なので, ページにアクセスした直後にユーザから見える領域 (Above the Fold) など重要なところから対応するのがオススメです.

intrinsicsize 属性のその後

intrinsicsize 属性の提案は放棄されました. 実験的に実装されていた Chrome Canary 本体からも既に実装が削除されています. 今後は width / height 属性を使用しましょう.

bugs.chromium.org

まとめ

  • 画像による Layout Shift を回避する仕組みが標準化された
  • 新たな属性を使わず既存の属性を用いて対応でき, セマンティクス的にも優れている
  • 今まで意味の無かった属性を利用することで, 前方互換性がほぼ保たれている
  • 発生する Breaking Changes はごく僅かになるよう, aspect-ratio の考えを拡張している
  • 既にいくつかのブラウザで導入され始めており, すぐにでも試すことができる

参考

*1:DevTools の Network タブから Disable cache を ON に, Throttling を Fast 3G にして意図的に回線速度を遅くすると分かりやすいです

*2:https://developer.mozilla.org/ja/docs/Web/HTML/Element/img#attr-intrinsicsize より

*3:aspect-ratioCSS Box Sizing Module Level 4 で提案されているドラフト段階のプロパティです.

*4:attr() 関数の content プロパティ以外での利用は実験的であり, 多くのブラウザではサポートされていません.

*5:一般に, 取得したいのは画像が読み込まれた後の要素の寸法であり, 壊れた余白の寸法ではないはずです.

*6:画像が読込中かどうかを判断するのであれば onload が使えます.

OK Google, 今日のゆゆ式

この記事はゆゆ式 Advent Calendar 2019 21日目の記事です. 20日目はJonah Wilsonさんのゆゆ式BDを持って星を見に行った話でした.

これはなに

Google Homeに話しかけるとオススメのゆゆ式の1コマを印刷できるIoT, 「今日のゆゆ式」です.

開発者からのコメント

普段の暮らしにゆゆ式を, そんな思いで作ってみました. 11年分のゆゆ式のコンテンツから1回につき1コマだけを, 一期一会のコマたちに思いを馳せて下さい.

どうやって作ってるの

お店で使われているようなレシートプリンタを使って, コマ画像を印刷しています. 予めリクエストを受けるとランダムでコマを選び, レシートプリンタで印刷するWebhookサーバを用意しておき, そのWebhookをGoogle Home経由で叩くようにしています. Google Home<=>Webhookの連携はIFTTTで, Webhookサーバはadnanh/webhook + ngrok で雑に作ってます. 印刷に使うコマの画像は自炊したデータをyonkoma2dataでコマごとに分割して用意しました. 本当はもっと細かく説明したいのですが, 結構複雑なことをやっていて全部説明しようとすると1冊本が出来てしまうので, 作ってみたい人は自分で調べるなりWebhookサーバのソースコードを見て頑張ってみて下さい.

github.com

スペシャルサンクス

  • えすじさん
    • 単行本の取り込み, コマの分割で大変お世話になりました
  • 三上小又先生
    • いつもお世話になっております

22日目は@Lavendelstraussさんです.

iTerm2 で `cat /dev/urandom` すると印刷ダイアログが出ることがある

皆さんは /dev/urandom と呼ばれるUnixデバイスをご存知でしょうか. /dev/urandom は一言でいうと擬似乱数を出力する疑似デバイスで, catすると以下のようにランダムなバイト列を逐次的に出力してくれます. 出力するバイトの値域に特に制限は無いため, ターミナルに印字不可能な文字が表示されたりします.

良い具合にバイト列が揃うと漢字が流れてきたり, 異国の文字が流れてきたりと眺めているだけでも結構面白いです. 皆さんも是非お試し下さい. 私は絵文字が流れてきたのを見て大喜びしてました.

ところでこの cat /dev/urandom ですが, iTerm2でひたすら動かしていると稀に印刷ダイアログが開くことがあります.

f:id:mizdra:20191204011544j:plain
印刷するともれなく異国の文字たちがお出迎えしてくれます

実は cat /dev/urandom しなくても echo コマンドで簡単に再現できます. 試しにお手元のiTerm2で以下のコマンドを実行してみて下さい.

echo '\e[i'

印刷ダイアログは出ましたか? 出た方は記念に1枚プリントしておきましょう (しなくても良いです). 印刷はともかくとして, \e[i という文字列, これは一体何なのでしょうか.

ANSI Escape Sequence

ターミナルにはANSI Escape Sequenceと呼ばれる一部の文字列をターミナルを制御する特別な文字列として扱う機能が存在し, これを用いることでプログラムからターミナルを制御できるようになっています. 代表的なANSI Escape Sequenceには文字色の変更, スクリーンの消去, カーソルの移動などがあります. vimやemacsでカーソルを移動できたり, シンタックスハイライトが効いたりするのはこうした文字列のおかげなんですね.

もちろん \e[i もそうした文字列の1つで, 「表示されている画面を印刷する」よう指示する文字列として機能します. また \e[i には, カーソルのある行を印刷するよう指示する \e[1i, 入力された文字列をプリンタへとechoするモードに切替える \e[5i, モードを元に戻す \e[4i などの亜種があります.

## Hello World が印刷される
echo '\e[5iHello World!\e[4i'

iTerm2における \e[i

元々ANSI Escape Sequenceは1978年に登場したビデオ端末「VT100」を操作するために開発された仕組みで, 現代の端末エミュレータの多くがこれをエミュレートするよう設計されています*1. iTerm2もその内の1つで, リポジトリを「VT100」で検索するとそれっぽいコードがヒットします.

実際に \e[i を処理しているコードを追ってみると, VT100CSIParser.m:673\e[ni (n は任意の数) をトークンへとパースし, VT100Terminal.m:1791でトークンと n を見て印刷ダイアログなどを呼び出しています. よくよく見るとiTerm2では \e[i \e[5i \e[4i の3つの文字列しかエミュレートしていないことが分かったりします.

端末エミュレータによって対応はまちまちのようで, MacOS標準のTerminal.appでは亜種どころか \e[i すらサポートされていませんでした. というか \e[i をサポートしているiTerm2以外の端末エミュレータはそもそも存在するのでしょうか(Macだとこの2つくらいしか動作確認できなかった). 皆さんからの情報提供をお待ちしています.

活用法

Meta+P が壊れて動かなかない時や, どうしても Meta+P を使いたくない時に活用できそうです (本当に?). ダイアログが出て邪魔という点に注目すると, CUIゲームに登場する敵キャラの妨害攻撃とかにも使えそうですね (本当に?). 真面目なケースだとターミナルブラウザにおける window.print() のエミュレートあたりでしょうか.

おまけ

ダイアログ出せると知ったら当然やりますよね.

ちゃんと対策されてました. 20へえ.

*1:https://ja.wikipedia.org/wiki/%E7%AB%AF%E6%9C%AB%E3%82%A8%E3%83%9F%E3%83%A5%E3%83%AC%E3%83%BC%E3%82%BF より. 平成生まれの人間なので詳しくは知りません.

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.