◆ CSSStyleSheet を document や shadowRoot に設定できる
◆ ShadowDOM で CSS 空間が別になって無駄が多かった部分がいい感じに書けるようになった

Chrome 73 で adoptedStyleSheets が使えるようになりました
これを使えば WebComponents を使うときに CustomElements 内のスタイルをいい感じに設定できます

adoptedStyleSheets が追加された

document や shadowRoot のプロパティに adoptedStyleSheets が追加されています

Object.getOwnPropertyDescriptor(Document.prototype, "adoptedStyleSheets")
// {get: ƒ, set: ƒ, enumerable: true, configurable: true}

Object.getOwnPropertyDescriptor(ShadowRoot.prototype, "adoptedStyleSheets")
// {get: ƒ, set: ƒ, enumerable: true, configurable: true}

getter/setter が用意されてます
adoptedStyleSheets の値は配列で CSSStyleSheet のインスタンスを複数指定できます
配列ですが push などで追加はできず配列自体を代入して上書きする必要があります
setter の処理で更新されるようです

基本の使いかた

こういう感じで使えます

const css = new CSSStyleSheet()
css.replaceSync("body {background: black;}")
document.adoptedStyleSheets = [css]

CSSStyleSheet のインスタンスを作って replace や replaceSync を使って CSS を記述します
それを adoptedStyleSheets に入れると適用されます
これで指定したスタイルは Chrome の devtools でみると 「user agent stylesheet」 という扱いで devtools 上で変更はできなくなっていました

あとから CSSStyleSheet の追加もできます

const css2 = new CSSStyleSheet()
css2.replace("@import url(colors.css)")
document.adoptedStyleSheets = [...document.adoptedStyleSheets, css2]

上にも書いたとおり配列の push はできないので新規配列を作る必要があります
push した場合は 「object is not extensible」 とエラーになります

replace と replaceSync

これまでも CSSStyleSheet のインスタンスは存在しましたが css ファイルをロードしたときに自動で作られるもので new を使って自分では作れませんでした
今回の adoptedStyleSheets が使えるのと同時に作れるようになり replace と replaceSync メソッドが追加されました

どちらも css ファイルの中身を文字列から設定するものです
replace は非同期処理で Promise を返します
replaceSync は同期処理です

非同期にするとロード前に描画されることがあり 中途半端なものが見えないように replaceSync を使いたいのですが @import を使って css ファイルをロードする場合には replaceSync は使えませんでした

@import rules are not allowed when creating stylesheet synchronously.

というエラーになります
replace の返り値を await して ロード完了してから DOM を作ったり hidden を外すなどして ロード前に画面が表示されないようにしないと一瞬ロード前のものがみえることがあります

WebComponents

この機能で一番助かるのが WebComponents を使った場合です
ShadowDOM の中は CSS 空間も別になるのでページ全体で設定したものは影響しません
コンポーネント内のスタイルは shadowRoot の innerHTML 内に style タグや link タグを書いて CSS を設定します

これは コンポーネントの定義が CSS 部分だけですごく長くなってしまい あまり見やすいものではないです
さらに ページ全体で設定したいボタンのスタイルみたいなものを全部のコンポーネントに書くのはすごく無駄な感じがします
innerHTML に直接書かずに css ファイルにまとめて それをロードすることもできますが 毎回非同期でロードされるからか 作成直後の一瞬 表示が乱れることがあり style に直接書くのが安定でした

どちらにしても CustomElements の要素 1 つ 1 つが同じスタイルの定義を別々に持っていて 同じ種類の要素なのに共有してくれません
1000 個の要素があったら 同じスタイルが定義された style 要素や link 要素も 1000 個存在します
内部ではパースされて rule とかに分けていろいろ情報を持っているので気軽に 100 や 1000 とか作るものではないと思います
大量に作られる要素だとパフォーマンスの面で ShadowDOM なしにすることも考えたほうが良いとも言われてました


それが adoptedStyleSheets を使えば内部の CSSStyleSheet 自体は 1 つだけにして同じ種類の各要素に同じスタイルを指定できます

const css = new CSSStyleSheet()
css.replaceSync(`
div { border: 4px dotted blue; }
span { color: green; font-size: 32px; }
`)

class AbCd extends HTMLElement {
constructor(){
super()
const sr = this.attachShadow({mode: "open"})
sr.adoptedStyleSheets = [css]
sr.innerHTML = this.template
}

get template(){
return `
<div>
<span>TEST</span>
</div>
`
}
}

customElements.define("ab-cd", AbCd)

document.body.append(...Array.from(Array(10), e => document.createElement("ab-cd")))

<ab-cd>
#shadow-root (open)
<div>
<span>TEST</span>
</div>
</ab-cd>
...

constructor で各要素の adoptedStyleSheets に同じ CSSStyleSheet を設定しています
どの要素も div や span にスタイルが適用されてます

css ファイルをロードすると非同期処理になる部分は同じですが JavaScript の Promise で管理できるようになったのでロード後に表示という処理もできるようになりました

ES Modules と使うと

CSSStyleSheet の replace で @import url() を使う場合に相対 URL は JavaScript ファイルからではなく HTML ファイルからのパスになります
ES Modules を使ってるとコンポーネントの JavaScript ファイルと同じフォルダに CSS を置きたくなります
自分からの相対パスだと同じ階層なのでファイル名だけでいいのですが HTML からとなるとどういうパスなのかがわからず困ることがあります

sheet.replace("@import url(dir/file.css)")

dir に何を入れればいいのかが 今のページによって変わるので相対パスでは難しいです
ES Modules では import.meta.url で自身の URL が取得できるのでそこからの相対パスを絶対パスに変換して import できます

const url = new URL("./file.css", import.meta.url)
sheet.replace(`@import url(${url})`)

これで 1 つのコンポーネント用として JavaScript と CSS ファイルをまとめて管理できます