ShadowDOM なしで module 化したいとき
- カテゴリ:
- JavaScript
- コメント数:
- Comments: 0
◆ ShadowDOM がないとコンポーネント内が別空間にならない
◆ class 名が被ってスタイルが影響受けたり サブコンポーネントの中までクエリセレクタで取得できたり
◆ CSS は parcel の css modules が便利
◆ JavaScript も css modules に任せると JavaScript の都合で css ファイルまで変更必要になる
◆ ▶ JavaScript でも似たことをする機能を作った
◆ class 名が被ってスタイルが影響受けたり サブコンポーネントの中までクエリセレクタで取得できたり
◆ CSS は parcel の css modules が便利
◆ JavaScript も css modules に任せると JavaScript の都合で css ファイルまで変更必要になる
◆ ▶ JavaScript でも似たことをする機能を作った
ShadowDOM は便利ですし WebComponents を使うときは基本使うかと思います
ただ 全部に同じスタイルタグを入れないとダメでムダが多かったりもしますし 外から見れないというのは対応してないライブラリがツリー全体をパースしないといけないようなケースには使えません
なので ShadowDOM をつかわないというケースもときどきあるのですが ShadowDOM がないとコンポーネントの内側も全部見えてしまいます
困るのは CSS と JavaScript のセレクタ関係です
CSS では特定コンポーネントだけを対象にしたいのに 内側にあるコンポーネントの内側も対象になります
例えば
とすれば x-elem タグの内側という条件をつけれるので x-elem 内に限定はできます
しかし x-elem の中に y-elem があった場合に y-elem の中の .foo まで影響します
少し面倒ですが クラス名をそのコンポーネント専用のクラス名にして他コンポーネントと重複しないようにすれば セレクタの最後のスタイルをあてる対象をクラス名で指定しているなら大丈夫です
という感じです
クラス名が長くなりますし 冗長感があってなんか嫌です
JavaScript の場合はスクリプトなのでセレクタにマッチしてもカスタム要素の内側は無視するという処理を入れることはできます
x-elem 以外の - がつく要素は別のカスタムエレメントなので 親をたどって x-elem より先にそれが出たらマッチしないものとします
1 つほしいなら querySelectorAll の結果から最初にマッチしたもので 複数欲しいならマッチするものだけにフィルタした結果になります
ちょっと面倒ですがなんとかなりはします
JavaScript では
という風に CSS ファイルをインポートします
css modules が有効だと CSS ファイルの id とクラス名部分が自動で他と重複しない文字列に置き換えられて import ではその変換情報を取得できます
css 変数の中身はこういう感じになります
使うときは
みたいに文字列リテラルでクラス名を入れる代わりにインポートした css オブジェクトのプロパティを設定します
バンドルされた CSS ファイルの方には 「_active_x03jk_380」 みたいな変換済みの名前で定義されているのでこうすることでスタイルが適用できます
body や active みたいなよくある名前にしても CSS ファイル上では複雑な重複しない名前に変換されているのでそのコンポーネント専用のクラス名として扱えます
別コンポーネントに body や active を書いてもバンドルされた CSS ファイル上では別の名前です
ですが CSS ファイルなのでスタイル定義がないものはインポートした css オブジェクトに含まれません
例えば top に特に必要なスタイルがなくて CSS ファイルに .top を書いてないと css.top は undefined です
他にも undefined があるとセレクタでは最初の undefined を取得するので目的のものを取得できません
JavaScript で使うために
のように JavaScript でしか使わないクラス名を CSS ファイルに書いていくのもなんか変な話です
JavaScript の処理の都合で CSS ファイルの編集が必要になるわけですしね
現時点で JavaScript 専用だろうとコンポーネント内に存在するクラス名ならスタイルが空でもすべて CSS ファイルにあってもおかしくないといえばそうなのかもですが個人的には CSS と JavaScript は分けたいです
CSS ファイルからインポートしたものは HTML を作るときの埋め込みと classList のメソッドの引数のみに使います
BaseElement はこの機能を使うための親クラスです
これを継承したクラスで static な getter の key_names を作ります
返す値はクラス名の文字列の配列です
コードにあるように this.names.xxx のようにするとクラス名を取得できます
クラス名は css modules に少し似せていて元のクラス名とランダムな文字列の組み合わせです
ただ毎回同じになったほうがデバッグ時に扱いやすいので 見た目はランダムな文字列ですが実際はクラス名とタグ名を btoa して前から 8 文字取り出したものです
長めかつ前半が重複する名前だと別のコンポーネントでも一致してしまう可能性はあるのでもう少しちゃんとしたものにするなら hash 関数通すとかしたほうが良いと思います
JavaScript 内のコンポーネント内で定義してるクラス名なのでコンポーネントの JavaScript ファイルで完結するのがいいところです
セレクタとして使うなら
という感じです
ただ少し長くなって面倒です
コンポーネント内の機能なのでコンポーネントのメソッドとして
こういうのを作っておけば
という感じで便利な使い方もできます
ただ 全部に同じスタイルタグを入れないとダメでムダが多かったりもしますし 外から見れないというのは対応してないライブラリがツリー全体をパースしないといけないようなケースには使えません
なので ShadowDOM をつかわないというケースもときどきあるのですが ShadowDOM がないとコンポーネントの内側も全部見えてしまいます
困るのは CSS と JavaScript のセレクタ関係です
CSS では特定コンポーネントだけを対象にしたいのに 内側にあるコンポーネントの内側も対象になります
例えば
x-elem .foo {
margin: 10px;
}
とすれば x-elem タグの内側という条件をつけれるので x-elem 内に限定はできます
しかし x-elem の中に y-elem があった場合に y-elem の中の .foo まで影響します
少し面倒ですが クラス名をそのコンポーネント専用のクラス名にして他コンポーネントと重複しないようにすれば セレクタの最後のスタイルをあてる対象をクラス名で指定しているなら大丈夫です
x-elem .x-elem--foo {
margin: 10px;
}
という感じです
クラス名が長くなりますし 冗長感があってなんか嫌です
JavaScript の場合はスクリプトなのでセレクタにマッチしてもカスタム要素の内側は無視するという処理を入れることはできます
const matches = document.body.querySelectorAll("x-elem .foo")
const elem = [...matches].find(e => {
let x = e.parentElement
while(x){
if(x.tagName === "X-ELEM") return true
if(x.tagName.includes("-")) return false
x = x.parentElement
}
})
x-elem 以外の - がつく要素は別のカスタムエレメントなので 親をたどって x-elem より先にそれが出たらマッチしないものとします
1 つほしいなら querySelectorAll の結果から最初にマッチしたもので 複数欲しいならマッチするものだけにフィルタした結果になります
ちょっと面倒ですがなんとかなりはします
css modules
parcel を使っていて知ったのですが css modules を使えば CSS も楽に解決できそうですJavaScript では
import css from "./x-elem.css"
という風に CSS ファイルをインポートします
css modules が有効だと CSS ファイルの id とクラス名部分が自動で他と重複しない文字列に置き換えられて import ではその変換情報を取得できます
css 変数の中身はこういう感じになります
{
active: "_active_x03jk_380",
body: "_body_x03jk_121",
clear: "_clear_x03jk_492",
container: "_container_x03jk_333",
delete: "_delete_x03jk_306",
footer: "_footer_x03jk_118",
main: "_main_x03jk_109",
top: "_top_x03jk_16",
}
使うときは
this.innerHTML = `
<div class="${css.body}"></div>
<footer class="${css.footer}">
<input type="button" class="${css.delete}" value="delete">
</footer>
`
this.addEventListener("click", eve => {
this.classList.add(css.active)
})
みたいに文字列リテラルでクラス名を入れる代わりにインポートした css オブジェクトのプロパティを設定します
バンドルされた CSS ファイルの方には 「_active_x03jk_380」 みたいな変換済みの名前で定義されているのでこうすることでスタイルが適用できます
body や active みたいなよくある名前にしても CSS ファイル上では複雑な重複しない名前に変換されているのでそのコンポーネント専用のクラス名として扱えます
別コンポーネントに body や active を書いてもバンドルされた CSS ファイル上では別の名前です
JavaScript に使うと
便利なので JavaScript のセレクタでも使ったりしたくなりますthis.querySelector(`.${css.top}`).innerHTML = "aaa"
ですが CSS ファイルなのでスタイル定義がないものはインポートした css オブジェクトに含まれません
例えば top に特に必要なスタイルがなくて CSS ファイルに .top を書いてないと css.top は undefined です
他にも undefined があるとセレクタでは最初の undefined を取得するので目的のものを取得できません
JavaScript で使うために
.top, .clear, .content, .footer, .save, .cancel, .submit, .main {}
のように JavaScript でしか使わないクラス名を CSS ファイルに書いていくのもなんか変な話です
JavaScript の処理の都合で CSS ファイルの編集が必要になるわけですしね
現時点で JavaScript 専用だろうとコンポーネント内に存在するクラス名ならスタイルが空でもすべて CSS ファイルにあってもおかしくないといえばそうなのかもですが個人的には CSS と JavaScript は分けたいです
JavaScript 用のクラスリストを作る
そういうわけで 似たような仕組みを JavaScript 側でも作って JavaScript のセレクタとして扱うものはこっちで作ったクラス名にしますCSS ファイルからインポートしたものは HTML を作るときの埋め込みと classList のメソッドの引数のみに使います
class BaseElement extends HTMLElement {
constructor() {
super()
const ctor = this.constructor
if (!ctor.names) {
const obj = {}
const elem_key = btoa(this.tagName + this.constructor.name)
.replace(/[\+\/\=]/g, x => ({ "+": "-", "/": "_", "=": "" }[x]))
.slice(0, 8)
for (const x of ctor.key_names) {
obj[x] = "__" + x + "__" + elem_key
}
Object.freeze(obj)
ctor.names = obj
}
this.names = ctor.names
}
static get key_names() {
return []
}
}
customElements.define(
"x-elems",
class extends BaseElement {
connectedCallback() {
this.innerHTML = `
<div id="${this.names.id}" class="${this.names.container}">
<div class="${this.names.xxx_host}">
<input class="${this.names.xxx}">
</div>
<div class="${this.names.yyy_host}">
<input class="${this.names.yyy}">
</div>
<div class="${this.names.zzz_host}">
<input class="${this.names.zzz}">
</div>
</div>
`
}
static get key_names() {
return ["id", "container", "xxx_host", "xxx", "yyy_host", "yyy", "zzz_host", "zzz"]
}
}
)
document.body.innerHTML = `<x-elems></x-elems><hr><x-elems></x-elems>`
BaseElement はこの機能を使うための親クラスです
これを継承したクラスで static な getter の key_names を作ります
返す値はクラス名の文字列の配列です
コードにあるように this.names.xxx のようにするとクラス名を取得できます
クラス名は css modules に少し似せていて元のクラス名とランダムな文字列の組み合わせです
ただ毎回同じになったほうがデバッグ時に扱いやすいので 見た目はランダムな文字列ですが実際はクラス名とタグ名を btoa して前から 8 文字取り出したものです
長めかつ前半が重複する名前だと別のコンポーネントでも一致してしまう可能性はあるのでもう少しちゃんとしたものにするなら hash 関数通すとかしたほうが良いと思います
JavaScript 内のコンポーネント内で定義してるクラス名なのでコンポーネントの JavaScript ファイルで完結するのがいいところです
セレクタとして使うなら
this.querySelector(`.${this.names.button}`)
という感じです
ただ少し長くなって面倒です
コンポーネント内の機能なのでコンポーネントのメソッドとして
qs(strs, ...values){
const names = values.map(e => this.names[e])
const selector = strs.reduce((a, e, i) => a + names[i - 1] + e)
return this.querySelector(selector)
}
classQuery(cond){
if(typeof cond === "string"){
return this.querySelector(`.${this.names[cond]}`)
}else if(cond instanceof Object){
const entries = Object.entries(cond).map(([key, value]) => [key, this.querySelector(`.${this.names[value]}`)])
const result = {}
for(const [key, value] of entries) result[key] = value
return result
}
}
こういうのを作っておけば
this.qs`#${"form"} .${"button"}`
this.classQuery({date: "date", time: "time", name: "name", submit: "submit"})
という感じで便利な使い方もできます