スタイルを隔離してHTMLを埋め込むにはShadowDOMを使うとスマートかもしれない

管理画面があるようなWebサイト・Webアプリを作るとき、稀によくHTMLテキストを値として入力したいという要件があります。

Webサイトの運用者がある程度自由に内容を書き換えられる必要があるコンテンツ、例えばWordPressの記事本文などがそうですが、管理画面ではWYSIWYGエディタのようなものだったり、場合によってはmarkdownを書く仕様だったりすると思います。

これを表示する側の実装ではその部分にも当然スタイルを当てなければなりませんが、多くの場合は出力されるHTMLに自由にクラス名などを付与することができないため、 <h1><p> など、クラス名のついていない素のタグに対してスタイルを適用することになります。

例えば、下記のような。

<div class="post_content">
  <h1>見出し1</h1>
  <p>ほげほげ</p>
  <h2>見出し2</h2>
  <ul>
    <li>リスト1</li>
    <li>リスト2</li>
  </ul>
</div>
.post_content h1 {
  font-size: 24px;
  font-weight: bold;
}
.post_content h2 {
  font-size: 20px;
  font-weight: bold;
}

.post_content p {
  margin-top: 12px;
  margin-bottom: 12px;
}

.post_content ul {
  list-style: disc;
}

...

私はこういうスタイルを書くとき、このサイトがベースのスタイルとしてtailwindやリセットCSSを使っているとしたら、わりと気を使うんですよね。

h1やpのようなよく使うタグはもちろんデザインに合わせてスタイルを書いておきますが、 <blockquote> みたいな普段使わないような、デザインの想定にもないようなタグを使われた場合、スタイルを用意できておらず見た目ではdivと同じになってしまいます。

normalize.cssをベースにしていれば少なくともデフォルトのスタイルが存在するので、スタイルがないよりマシだと思いますが、ベースがリセットCSSだと前後の余白すらなくなってしまって酷い見た目になります。

上記の例で言うところの .post_content 以下だけCSS的に隔離ができて、normalize.cssもしくは独自のCSSを適用できるのだとしたら、外からの影響を受けずに堅牢なスタイルが作れるのではないかと思いました。

親要素からの隔離だったら ShadowDOM が使えるのでは? というのが今回の話の趣旨になります。

ShadowDOMとは?

詳しい説明はみんなだいすき MdN を見てもらうとして、一言でいうならばDOMツリーの中に独立したDOMツリーを構築する機能のこと。

その影響としてCSSのスコーピングが可能になります。

ShadowDOMの外はtailwind、中はnormalizeができるわけですね。

<div class="js-dom-prison">
  <h1>見出し1</h1>
  <p>ほげほげ</p>
  <h2>見出し2</h2>
  <ul>
    <li>リスト1</li>
    <li>リスト2</li>
  </ul>
</div>

.js-dom-prison をShadowDOMのrootにして、内容物をすべてShadowDOMの中に配置し直します。

const container = document.querySelector('.js-dom-prison');
const children = container.childNodes;
const shadowRoot = container.attachShadow({mode: 'open'});
shadowRoot.append(...children);

ShadowDOMの中にnormalize.css(もちろん、独自のCSSでも可)を適用したい場合は、こう↓

const container = document.querySelector('.js-dom-prison');
const children = container.childNodes;
const shadowRoot = container.attachShadow({mode: 'open'});
const stylesheet = document.createElement('link');
stylesheet.setAttribute('href', 'https://necolas.github.io/normalize.css/8.0.1/normalize.css');
stylesheet.setAttribute('rel', 'stylesheet');
shadowRoot.append(stylesheet, ...children);

これをDevToolsでみるとこうなります↓

あえてinnerHTMLなどを使わずに最初から .js-dom-prison にHTMLを展開しておくことで、たぶんSEO的にも問題にならない。(たぶん)

querySelector等で探せなくなるなど、デメリットがないわけではないので使い所が選ぶ必要がありそうですが、iframeなどと違ってオーバーヘッドも小さいですし、もののJS数行でCSS的に隔離ができるので、ハマる状況ならとてもスマートなんじゃいかと思ってます

Alpine.jsの$storeはいつ使うのか?

Alpine.jsでは x-data="{open: false}" というように x-data 属性を付与することで、その要素の内側で x-show="open" のように参照することができます。

<div x-data="{open: false}">
  <button @click="open = !open">開閉するよ</button>
  <div x-show="open">
    開いてるね
  </div>
</div>

とまあこれはAlpine.jsを知っている人なら全員知ってることなんですが、 x-data でなくても $store で同じようなことができます。

例えばこんな感じですね

<button x-data @click="$store.menu.open = !$store.menu.open">開閉するよ</button>

<div x-data x-show="$store.menu.open">
  開いてるね
</div>

<script>
  document.addEventListener('alpine:init', () => {
    Alpine.store('menu', {
      open: false,
    })
  })
</script>

コードでみると結構違いますが、やってることは一緒で、openという変数の定義場所が違うだけなんですよね。

$store はコンポーネントを跨いで参照することができます。便利。

ただし、v3から x-data入れ子になっていても変数名がカブらない限り祖先の変数も参照できるという仕様なので、

<body x-data="{open: false}">
  ...
  <button @click="open = !open">開閉するよ</button>
  ...
  <div x-show="open">
    開いてるね
  </div>
  ...
</body>

このように <body>x-data しちゃえばどこからでも参照できるグローバル変数を定義できちゃうんですよね。これでは$storeの立場がありません。

じゃあ $store っていつ使うの?って話なんですが、おそらく主に下記2点に注目すべきポイントがあります

外部のJSと連携したい!

UI系のライブラリを利用する場合など、Alpine.js外のJSとステートの共有をしたい場合は往々にしてあると思います。

そんな場合には x-dataAlpine.data で頑張るより素直にstoreを使ったほうが良いでしょう。

<script>
    Alpine.store('menu').open = true;
</script>

パフォーマンスへの影響

x-data を付与すると Alpine.start() を実行したときに、その要素以下がコンポーネントとして初期化されます。

もしその要素が巨大なツリーだった場合、初期化にかかるコストが大きいものになるため、できる限りコンポーネントは小さくするのがベター。

ちょっとした状態管理のためだけにbodyで x-data をしてしまうのはコストに見合わないので、$storeを使うのがよいでしょう。

Google Map APIの高度なマーカーのクリックイベントに関するリファレンスが間違ってる

下記はリファレンスのイベントから抜粋したもの。

イベント 説明
click (前略) addEventListener() では使用できません(代わりに gmp-click を使用してください)。
gmp-click (前略) (addListener() ではなく)addEventListener() で使用することをおすすめします。

これを見ると addEventListener('gmp-click') とするのが正しそうだが、これは動かない。
ドキュメントを見ると addListener('click')addListener('gmp-click') で良さそう。(欲しい引数の型でどちらか好きな方を選ぶ)

いい感じの色を作りたいならOklchを使うといい感じになる

時折、明度と彩度を一定に保ったまま色相を変えていくつかの色を作りたいときがあります。

なすびのウェブサイトで例えると、制作事例のタグは頻繁な増減があり、数も多く、色自体に拘りたいわけではないので、すべての色をひとつずつ決めるのは結構な手間になります。
そのため、ここでは codePointAt() を使ってタグ名を数値に変換し、色相に当てはめてランダムではない動的な色を作ることにしました。

このような場合、これまではhsl()を使うことで簡単に明度彩度を固定したまま色相だけ違う色をたくさん作ることができましたが、hslによる指定では明度彩度を一定にしても視覚的な均一性はありませんでした(特に黄色系の色味が明るすぎるなど)。

最近のブラウザではhslに似た指定で視覚的均一性のあるカラー指定oklch()が使えるようになっています。

hslとoklchをわかりやすく並べて比較するとこのくらい違いがありました。

上下のどちらの色が均一に見えるでしょうか。

色相だけでなく、例えばデザインシステムのカラー設計で色相と明度(+彩度)を軸にしたカラーグリッドを作る場合でも、Oklchを使うことで視覚的に統一された色を簡単に作ることができます。

使用する上で注意点を挙げるとするならば、oklchはカラースペースがsRGBではなく、より広い範囲の色を指定することができることです。

極彩色に近い色だとディスプレイによっては色の差がわからないことがあるかもしれません。

とはいえ一般的なスマートフォンやMacbookのようなdisplay-p3対応を謳っているディスプレイであれば、ほぼ正確に出力できます。


参考にさせていただいたサイト
https://griponminds.jp/blog/relearn-css-color/

ウェブサイトでSVGを配置して、cssでドロップシャドウを付ける話

以前の投稿では、SVGの色をcssを使って変更する方法についてお話ししました。今回は、SVGに影(ドロップシャドウ)を追加する方法に焦点を当てたいと思います。

divにドロップシャドウを付ける場合は、以下のようにcssのfilterプロパティでdrop-shadow関数を使用することができます。

.divShape {
  display: block;
  width: 500px;
  height: 500px;
  background: #222;
  filter: drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4)); 
}

前回のカスタムシェイプの場合、この方法ではドロップシャドウが適用できません。

この場合、擬似要素を使用します。::before擬似要素をカスタムシェイプに適用し、その親であるdivにドロップシャドウを追加します。

このようにすることで、カスタムシェイプのdivにドロップシャドウを効果的に付けることができます。

.svgShape {
  position: relative;
  width: 606px;
  height: 514px;
  filter: drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4)); 
}

.svgShape::before {
  display: block;
  height: 100%;
  content: "";
  background: linear-gradient(45deg, black, transparent);
  mask-size: contain;
  mask-image: url('/img/sample.svg');   
}

ウェブサイトでSVGを配置して、cssで色を変更する話

SVGをウェブページに追加する最も簡単な方法は、<svg>タグをインラインで直接記述するか、<img>要素を追加してsrc属性でSVG画像を参照することです。
SVGファイル直接に使用すると、SVGの色を変えたい時は少しややこしいです。(編集ソフトを使用して、書き直す必要があります。)
(<img> 要素を使用してインポートした場合、CSSだけで色を変更することはできません。)

単色を変えたい場合:
css実装例:

#svgID path{   
  fill: red; 
}

このようにすればcssでpath要素の色を簡単に変更できるだと思いますが、グラデーションをかけたい場合はSVGファイル自体を修正しなければなりません。そこでもう一つの方法を紹介します。

DIV要素をカスタムシェイプに:

SVGインラインで直接記述するより、DIV要素の形をSVGにすれば、色々カスタマイズができます。今回は外部SVGを参照して、cssで色をグラデーションにします。

divといえば、多くの人のイメージには長方形や正方形しかありません。しかしdivを他の形にすることができます。この時はmask-imageを使います。
(単純にdivの形を変えたい場合はclip-pathも出来ますが、今回の主旨と違いますので詳細は割愛させていただきます)

HTML実装例:
<div class=”svgShape”></div>
css実装例:
(widthとheightはsvgのサイズに合わせてください)

.svgShape {
  width: 500px; 
  height: 500px;
  background: linear-gradient(45deg, black, transparent);
  mask-image: url('/img/sample.svg');
}


このように実装すれば、divはsvgの形に変えられ、色はcssでカスタマイズできます。
実装例:

refrence:

https://developer.mozilla.org/ja/docs/Web/CSS/mask-image

aspidaのRestClientで多重送信を抑制する

OpenAPIでAPIが定義されているプロジェクトにおいて @aspida/fetch を使ってRestClientを生成・利用しています。

とあるGETリクエストを複数のコンポーネントからほぼ同時に呼び出す必要があり、リクエストの回数を1回にまとめたい状況がありました。
これをaspidaでやろうと思ったときにちょっと思ったようにできなかったのでメモ。

最初にやろうとした実装は、aspidaに渡すfetchをラップする方法。

const promises = {};
function customFetch(url, option) {
if (option.method === 'GET') {
const key = `GET__${url}`;
if (!promises[key]) {
promises[key] = fetch(url, option);
}
return promises[key];
} else {
return fetch(url, option);
}
}

promiseを再利用したいわけですが、こうすると、ResponseのStreamが使用済みなのでエラーになります。そりゃそうだ。

Response.clone() で複製してから使えばよいわけですが、aspidaの生成するtsコードで Response.json() を実行してしまうので、外からcloneを挟むようなことは無理そう…

色々考えた結果、こうなりました

const promises = {};
function dedupe(api, option) {
const key = `GET__${api.$path(option)}`;
if (!promises[key]) {
promises[key] = api.$get(option);
}
return promises[key];
}

使う側では

const result = await client.path.to.hoge.$get({ query: { fuga: 'piyo' } });

これを、

const result = await dedupe(client.path.to.hoge, { query: { fuga: 'piyo' } });

こうする。

関数で囲わなければいけなくなったのがちょっとスマートじゃないですが、まあ重複排除したいところだけ使えてポータブルな関数になったので良いということにしよう。

CSSのfont-synthesisとtext-renderingが原因でAndroidで太字にならない

Viteで新規にプロジェクトを作るとapp.cssに font-synthesistext-rendering が指定されています。

:root {
  ...
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  ...
}

これらのプロパティがあると、 Android Chrome で見たときにfont-weightで700以上を指定しても太字にならない場合があります。

font-synthesis は、フォントに太字やイタリックの字体が含まれていない場合にブラウザが合成してよいかどうかを制御するもの。

text-rendering は、合字やカーニングの処理を行うか否かを制御し、読みやすさと速度のどちらを優先するか決めることができます。

これら2つの両方もしくはどちらかと、おそらく font-family によるフォント指定の組み合わせによって太字書体が使える環境にも関わらず太字にならないことがあるようです。

基本的に欧文フォントのためのプロパティなので、日本語環境では、少なくとも現段階では使わないほうが無難なようです。

JSで(-8)**(2/3)を計算するとNaNになる

タイトルの通りなのですが、なぜかとある条件のべき乗を計算するとNaNが返ってくるので不思議に思って調べてみました(調べてくれたのは神谷)。

その条件は、基数が負なおかつ指数が分数 であること。

たとえば (-8)**(2/3) という計算は、答えは 4 なのですが、JSで計算をするとNaNが返ります。

(-8)**(2/3) // NaN

一体全体何が起きているのか?
答えは MdNのMath.powのページ に書いてありました。

// due to "even" and "odd" roots laying close to each other,
// and limits in the floating number precision,
// negative bases with fractional exponents always return NaN

最初の一行の意味はわからなかったのですが、要するに浮動小数点の精度の問題で 基数が負なおかつ指数が分数のときは静的にNaNを返すのがJSの仕様 ということみたいです

実際にMath.powで同じことをしても結果はNaNでした。

Math.pow(-8, 2/3) // NaN

試しに、指数の分数を分解して (3√(-8))^2 で計算してみると

Math.pow(Math.cbrt(-8), 2) // 4

正しく4が返ります。


そういえば、正の数の平方根の計算結果って±になりますよね。

√4 の答えは ±2 と学校で習った記憶がある。

Math.sqrt(4) // 2

この辺の計算って結構曖昧なのかもしれません

iOS Safari ではXHRで巨大なHTMLを読み込めない事がある

古き良き、サーバーサイドレンダリングなマルチページで一部分だけ動的に書き換える際に、HTML全体を非同期で取得して必要なDOMだけ差し替えるような実装をすることがある。

たとえば WordPress サイトなんかで、サーバーサイドは変更せずに簡易的な非同期遷移を実装しようと思ったらこういう実装になる。

const xhr = new XMLHttpRequest();
xhr.open('GET', 'http://192.168.x.x/hoge/fuga', true);
xhr.responseType = 'document';
xhr.addEventListener('readystatechange', () => {
    if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
        const html = xhr.response.querySelector('.hogehoge-selector').innerHTML;
        container.innerHTML = html;
    }
});
xhr.send();

注目スべきはここ

xhr.responseType = 'document';

document を指定することで xhr.response がDocumentで返ってくるので、いきなり querySelector なんかを使うことができるのだ。

こんな感じで実装されていたわりと古いコードがあったのだが、iPhone実機でエラーに遭遇した。

TypeError: null is not an object (evaluating 'xhr.response.querySelector')

readystatechange リスナー内の xhr.responsenull だというのだ。
xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200 という条件式の中にあるにも関わらず。

Webインスペクタのネットワークパネルでレスポンス内容を見ると正しくHTMLがレスポンスされているのも確認できる。

しかもこのエラー、毎回出るのではなく、5回に1回程度の割合で出るのでたちが悪い。

試しにDocumentではなく文字列で取得してみる

    if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
        console.log(xhr.responseText);
    }

InvalidStateError: The object is in an invalid state.

xhr.responseText にアクセスすらできない状態で、エラー内容もよくわからない。

エラー文でググってみると、どうやらガーベジコレクションが行われたオブジェクトに対してアクセスしたときに出るエラーのようだ。まだ使ってないのにGCが発火してしまったらしい。

今回この事象に遭遇したのは2000行超あるかなり巨大なHTMLだったので、試しに500行くらいまで減らしてみると、案の定エラーが出ることはなくなって、正常動作が確認できた。

少しずつ行数を増やしていくと、徐々にエラーになる確率が増えていくような感じがあるので、やはりメモリ管理にバグがあるらしい。

手元の環境では、下記で発生することが確認できた

  • iPhone X : iOS 15.0, 15.0.1
  • iPad Pro 11インチ (第1世代) : iPadOS 15.0

Mac Safari や iOS シミュレータでは再現しなかったが、母艦の搭載メモリや起動しているアプリなど、条件によっては発生するんじゃないかとおもう。

行った対処法としては、

xhr.responseType = 'document';

これを使うのをやめて、textで取得し、 DOMParser なりなんなりで自分でDocumentに変換してやる。

xhr.responseType = 'text';
xhr.addEventListener('readystatechange', () => {
    if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
        const parser = new DOMParser();
        const doc = parser.parseFromString(xhr.response, 'text/html');
        console.log(doc);
    }
});

もしくはレガシーコードは窓から投げ捨てて、素直に fetch で書き直してればこんなバグには遭遇しないのだ。

fetch('http://192.168.x.x/hoge/fuga')
    .then(response => response.text())
    .then(text => {
        const parser = new DOMParser();
        const doc = parser.parseFromString(text, 'text/html');
        console.log(doc);
    });

いまどきXHRなんて使ってんのが悪い。