◆ Directive を使って更新があったところだけを更新する
◆ CSSRule の cssText は読み取り専用なので 文字列を使ってネストするスタイルを一括更新できなかった

これまで

以前 Lit で Emotion 風に CSS-in-JS しようとしたとき Directive からホストのカスタム要素にアクセスできないので Directive を使わず少し特殊な方法で対処していました

<div class=${this.style`color: red`}>

のように書いて this.style では事前に用意しておいた CSSStyleSheet にルールを追加していくものです
Directive ではないので this.style の関数は自動生成するクラス名を返します

この方法は Directive を通さないので 再レンダリング時に同じものであることを検知できません
再利用できずレンダリングのたびにルールが増えていきます
この対処のために LitElement の更新をオーバーライドして前後に処理を追加します
そのときのレンダリングで使用したルールを保持して そのルール以外は削除します

一回のレンダリングで this.style が 100 個あったら 100 個のルールを追加して 前回の分の 100 個のルールを削除することになります

この改善版ということで レンダリングのたびに毎回新しい CSSStyleSheet にルールを追加して レンダリング完了後に CSSStyleSheet ごと置き換えてしまう方法にしたものもあります
中身を全部置き換えるならルール単位ではなく CSSStyleSheet ごと変えてしまうという考えです

ブラウザに CSS のネスト機能が追加されたので Emotion 使わなくてももっと簡単にできそうということで試してみた程度のものだったので ここで終わりのつもりでした

ですが Lit で Directive からホストのカスタム要素を参照できるようにしたので別の方法を試してみようと思います

CSSRule の更新

今回は Directive を使うので 前回と同じ場所であることを検知できます
ルールを全置き換えしたり CSSStyleSheet ごと置き換える必要はないです
必要最小限の更新に済ませられます

ということでやってみたのがこれです

<!doctype html>
<meta charset="utf-8"/>
<script type="module">
import { html, LitElement, directive, Directive } from "https://cdn.jsdelivr.net/gh/lit/dist@3.0.1/all/lit-all.min.js"

const makeStyleDirective = (element, Directive) => {
const cssss = new CSSStyleSheet()
return {
directive: directive(
class extends Directive {
host = element
cssss = cssss
}
),
cssss,
}
}

const map = new Map()
let map_key = 0
const registry = new FinalizationRegistry((key) => {
const { cssss, rule } = map.get(key)
map.delete(key)
const index = [...cssss.cssRules].indexOf(rule)
cssss.deleteRule(index)
})

class Dire extends Directive {
host = null
cssss = null
rule = null
key = ""

init() {
if (this.key) return
this.key = "k" + map_key++
const rule_index = this.cssss.insertRule(`.${this.key} {}`)
this.rule = this.cssss.cssRules[rule_index]
registry.register(this, this.key)
map.set(this.key, { cssss: this.cssss, rule: this.rule })
}

render(template, ...values) {
this.init()
const css = String.raw(template, ...values)
if (this.rule.style.cssText !== css) {
this.rule.style.cssText = css
}
return this.key
}
}

class StylitElement extends LitElement {
constructor() {
super()
const { directive, cssss } = makeStyleDirective(this, Dire)
this.styleDirective = directive
this._cssss = cssss
}
createRenderRoot() {
const root = super.createRenderRoot()
root.adoptedStyleSheets.push(this._cssss)
return root
}
}

class ExampleElement extends StylitElement {
static properties = {
n: {}
}
constructor() {
super()
this.n = 0
}
render() {
const S = this.styleDirective
return html`
<div class=${S`border: 1px solid #aaa; :is(div) { padding: 10px }`}>
<button @click=${() => this.n++}>${this.n}</button>
<div class=${S`background: #ddd`}>text</div>
${this.n % 2 == 0
? html`<div class=${S`color:red`}>text</div>`
: html`<div class=${S`color:blue`}>text</div>`
}
<div class=${this.n % 2 === 0 ? S`color:red` : S`color:blue`}>
text
</div>
<div class=${S`color:${this.n % 2 === 0 ? "red" : "blue"}`}>
text
</div>
</div>
`
}
}

customElements.define("example-element", ExampleElement)
</script>
<example-element></example-element>

makeStyleDirective で directive の関数を作るときに合わせて CSSStyleSheet も作ります
この CSSStyleSheet を更新するようにします
更新を行うのは Directive の render の中です

これでうまく動いてる と思ったのですが 問題点がありました
ネストするセレクタです

CSSStyleSheet

CSSStyleSheet の中でネストするセレクタは少し扱いが特殊です

こういうスタイルを指定してみます

const c = new CSSStyleSheet()
c.replaceSync(".foo { color: red; .bar { color: blue }}")

中のルールを見ると

c.cssRules.length
// 1

c.cssRules[0].cssText
// '.foo {\n color: red;\n .bar { color: blue; }\n}'

となってるので 1 つのルールにまとまってそうです
しかしスタイルの cssText を取得すると

c.cssRules[0].style.cssText
// 'color: red;'

ネストするセレクタは含まれません
ルールの中に cssRules プロパティが存在する構造になっています

c.cssRules[0].cssRules.length
// 1

c.cssRules[0].cssRules[0].cssText
// '.bar { color: blue; }'

つまり ルールのスタイルの cssText を更新してもネストするセレクタは反映されないです
単純に 1 つの文字列で設定できないのは不便です
自力でネストするセレクタを解析してそれぞれを登録なんてしたくないです

なら大本のルールの cssText を設定すればいいかと思いました
セレクタとカッコも文字列に追加しないといけないですが それくらなら特に問題はないです
しかし

c.cssRules[0].cssText
// '.foo {\n color: red;\n .bar { color: blue; }\n}'

c.cssRules[0].cssText = ".foo {color: green; .bar { color: yellow }}"

c.cssRules[0].cssText
// '.foo {\n color: red;\n .bar { color: blue; }\n}'

反映されません
MDN を見てみると読み取り専用と書かれてました
https://developer.mozilla.org/en-US/docs/Web/API/CSSRule/cssText

更新するなら selectorText や style のサブプロパティを変更する必要があるようです
ですが そうするとネストするセレクタを一括更新できないんですよね

結局ルールを使い回せず ネストをサポートするならルールを作り直して cssRules の中身を置き換える必要があります

最終的に

ルールごと置き換えになるのはなんだかなぁと思ったものの CSS を解析してネストするセレクタを個別に更新していくのはさすがに嫌なのでルールを置き換えることにしました

<!doctype html>
<meta charset="utf-8"/>
<script type="module">
import { html, LitElement, directive, Directive } from "https://cdn.jsdelivr.net/gh/lit/dist@3.0.1/all/lit-all.min.js"

const makeStyleDirective = (element, Directive) => {
const cssss = new CSSStyleSheet()
return {
directive: directive(
class extends Directive {
host = element
cssss = cssss
}
),
cssss,
}
}

const map = new Map()
let map_key = 0
const registry = new FinalizationRegistry((key) => {
const rule = map.get(key)
removeCSSRule(rule)
map.delete(key)
})

const removeCSSRule = (rule) => {
if (!rule) return
const stylesheet = rule.parentStyleSheet
if (!stylesheet) return
const index = [...stylesheet.cssRules].indexOf(rule)
if (index < 0) return
stylesheet.deleteRule(index)
}

class Dire extends Directive {
host = null
cssss = null
rule = null
key = ""
cache = null

render(template, ...values) {
if (!this.key) {
this.key = "k" + map_key++
registry.register(this, this.key)
}
const css = `.${this.key} {${String.raw(template, ...values)}}`
if (this.cache !== css) {
removeCSSRule(this.rule)
const rule_index = this.cssss.insertRule(css)
this.rule = this.cssss.cssRules[rule_index]
map.set(this.key, this.rule)
this.cache = css
}
return this.key
}
}

class StylitElement extends LitElement {
constructor() {
super()
const { directive, cssss } = makeStyleDirective(this, Dire)
this.styleDirective = directive
this._cssss = cssss
}
createRenderRoot() {
const root = super.createRenderRoot()
root.adoptedStyleSheets.push(this._cssss)
return root
}
}

class ExampleElement extends StylitElement {
static properties = {
n: {}
}
constructor() {
super()
this.n = 0
}
render() {
const $ = this.styleDirective
return html`
<div class=${$`border: 1px solid #aaa; :is(div) { padding: 10px }`}>
<button @click=${() => this.n++}>${this.n}</button>
<div class=${$`background: #ddd`}>text</div>
${this.n % 2 == 0
? html`<div class=${$`color:red`}>text</div>`
: html`<div class=${$`color:blue`}>text</div>`
}
<div class=${this.n % 2 === 0 ? $`color:red` : $`color:blue`}>
text
</div>
<div class=${$`color:${this.n % 2 === 0 ? "red" : "blue"}`}>
text
</div>
</div>
`
}
}

customElements.define("example-element", ExampleElement)
</script>
<example-element></example-element>

前回のより結構長めになりましたが その分更新する量を減らしてるので一部だけしか変わらないというケースは高速だと思います
コードを減らすほど高速になってほしいのにコードを増やす方が高速になるというね……