scoped CSSと子コンポーネントの関係

JavaScript, Vue

vue-loader では、style 要素に scoped を指定して、コンポーネントに CSS のスコープを閉じられる。

ただ、レイアウトの調整目的で、親コンポーネントから子コンポーネントにスタイルを当てたいと思う時がある。
その時の scoped CSS の挙動を確認するため、少し検証した。

基本形

下記コードでは、親コンポーネントが、子コンポーネントに bordermargin スタイルを設定している。

child.vue

<template>
  <section>
    <h2>子コンポーネントです</h2>
    <p>子コンポーネントの文章です</p>
  </section>
</template>

parent.vue

<template>
  <div>
    <h1>子コンポーネント集</h1>
    <child-component class="child" />
    <child-component class="child" />
    <child-component class="child" />
  </div>
</template>

<script>
import ChildComponent from "./components/child.vue";

export default {
  components: {
    ChildComponent
  }
};
</script>

<style scoped>
.child {
  border-bottom: 1px solid #ccc;
  padding-bottom: 30px;
  margin-bottom: 30px;
}
</style>

生成されるHTMLは以下。

html

<div data-v-6a9a0e7a>
  <h1 data-v-6a9a0e7a>子コンポーネント集</h1>
  <section data-v-6a9a0e7a class="child">...</section>
  <section data-v-6a9a0e7a class="child">...</section>
 <section data-v-6a9a0e7a class="child">...</section>
</div>

また、下記のstyleが追加される。

css

.child[data-v-6a9a0e7a] {
  border-bottom: 1px solid #ccc;
  padding-bottom: 30px;
  margin-bottom: 30px;
}

vue-loader は scoped なファイルに対して、 hash-sum でハッシュ値を生成し、data-v-を合わせた data 属性を要素に追加する。
それを CSS セレクタとした style 要素を追加することで、擬似的なスコープを実現している。
また、子のルートとなる要素に data 属性や class がマージされる。

上のコードでは、子のルートとなる section 要素に、data-v-6a9a0e7a 属性と .child が追加される。
そのため、親で指定したmarginやborderなどのプロパティが、子に適用される。

子コンポーネントにも親のプロパティが適用されている画像

子がFunctional Componentの場合

Functional Component の場合は、親の class がマージされない。
前セクションの例だと、親で指定した .child が子のルートから消えるので、スタイルが適用されなくなる。

ただし、親から Functional Component に渡されるデータは、context を通して取得できる。
その中に、属性や listener を受け取る data がある。data.staticClass を子のルートにバインドすることで、親側で付けた class を追加できる。

child.vueのテンプレート

<template functional>
  <section :class="data.staticClass">
    <h2>子コンポーネントです</h2>
    <p>子コンポーネントの文章です</p>
  </section>
</template>

描画関数とJSX — Vue.js
Functional Componentとscoped css - ぬまのそこ

子のルートにclassがある場合

子コンポーネントを、一旦普通のコンポーネントに戻し、ルートとなる section 要素に child を追加する。

child.vueのtemplate

<template>
  <section class="child">
    <h2>子コンポーネントです</h2>
    <p>子コンポーネントの文章です</p>
  </section>
</template>

子供が増えてしまった。

html

<div data-v-6a9a0e7a>
  <h1 data-v-6a9a0e7a>子コンポーネント集</h1>
  <section data-v-6a9a0e7a class="child child"></section>
  <section data-v-6a9a0e7a class="child child"></section>
  <section data-v-6a9a0e7a class="child child"></section>
</div>

ただ、子コンポーネントに適用されている data 属性は同じなので、スタイルは適用される。

css

/* .styleにはdata-v-6a9a0e7aが含まれているので適用 */
.child[data-v-6a9a0e7a] {
  border-bottom: 1px solid #ccc;
  padding-bottom: 30px;
  margin-bottom: 30px;
}

つまり子に .foo を持たせて、親側にも .foo セレクタを追加すれば、親で指定したスタイルが適用される。

child.vueのtemplate

<template>
  <section class="child foo">
    <h2>子コンポーネントです</h2>
    <p>子コンポーネントの文章です</p>
  </section>
</template>

parent.vueのstyle

<style scoped>
.child {
  border-bottom: 1px solid #ccc;
  padding-bottom: 30px;
  margin-bottom: 30px;
}

/* foo */
.foo {
  display: none;
}
</style>

html

<div data-v-6a9a0e7a>
  <h1 data-v-6a9a0e7a>子コンポーネント集</h1>
  <section data-v-6a9a0e7a class="child foo child">...</section>
  <section data-v-6a9a0e7a class="child foo child">...</section>
  <section data-v-6a9a0e7a class="child foo child">...</section>
</div>

css

.child[data-v-6a9a0e7a] {
  border-bottom: 1px solid #ccc;
  padding-bottom: 30px;
  margin-bottom: 30px;
}

/* .fooの要素もdata-v-6a9a0e7aなので、スタイルが適用される */
.foo[data-v-6a9a0e7a] {
  display: none;
}

親から子の中の要素にスタイルを適用する場合

子コンポーネントには h2 要素があるけど、主力される HTML には data-v-6a9a0e7a が付いてない。

html

<section data-v-6a9a0e7a class="child">
  <h2>子コンポーネントです</h2>
  <p>子コンポーネントの文章です</p>
</section>

これにより、親コンポーネントからスタイルを適用することはできない。

parent.vueのstyle

<style scoped>
/* 親からの攻撃 */
.child h2 {
  font-size: 100px;
}
</style>

なぜかと言えば、セレクタが .child h2[data-v-6a9a0e7a] になるため。

css

.child h2[data-v-6a9a0e7a] {
  font-size: 100px;
}

h2 要素には data 属性が付けられていないので、このスタイルは適用されない。

逆にセレクタが .child[data-v-6a9a0e7a] h2 であれば、スタイルが適用される。
それを実現するのが >>> セレクタです。
(SCSS など一部のプリプロセッサを用いる場合、かわりに /deep/ を使用する)

parent.vueのstyle

<style scoped>
/* >>> を追加 */
.child >>> h2 {
  font-size: 100px;
}
</style>

こうするとスタイルが適用され、親から子の要素に干渉できる。

css

.child[data-v-6a9a0e7a] h2 {
  font-size: 100px;
}

子が Functional Component で、ルートとなる要素に .child がある場合、その子要素にも data 属性がマージされる。
つまり、先ほどの >>> 同様、style が適用されてしまう。

child.vue

<template functional>
  <section class="child">
    <h2>子コンポーネントです</h2>
    <p>子コンポーネントの文章です</p>
  </section>
</template>

html

<div data-v-6a9a0e7a>
  <!-- 子要素にもdata-v-6a9a0e7aが付く -->
  <section data-v-6a9a0e7a class="child">
    <h2 data-v-6a9a0e7a>子コンポーネントです</h2>
    <p data-v-6a9a0e7a>子コンポーネントの文章です</p>
  </section>
</div>

子にもscoped CSSがある場合

子にも scoped が設定されている場合、どうなるのか。

child.vue

<template>
  <section>
    <h2>子コンポーネントです</h2>
    <p>子コンポーネントの文章です</p>
  </section>
</template>

<style scoped>
/* scopedなh2のstyle */
h2 {
  font-size: 20px;
}
</style>

parent.vue

<template>
  <div>
    <h1>子コンポーネント集</h1>
    <child-component class="child"/>
    <child-component class="child"/>
    <child-component class="child"/>
  </div>
</template>

<script>
import ChildComponent from "./components/child.vue";

export default {
  components: {
    ChildComponent
  },
};
</script>

<style scoped>
.child {
  border-bottom: 1px solid #ccc;
  padding-bottom: 30px;
  margin-bottom: 30px;
}
</style>

上のコードでは、親と子の両方に、それぞれ別の scoped CSS が付与されている。
見た目は問題ない。子コンポーネント見出しの font-size を変えたので、そちらが適用される。

親と子は別の Scoped CSS が適用されている

一方で HTML や CSS を見ると、子コンポーネントのルートや子要素に、新たな data 属性が増えている。

html

<!-- data-v-efbcda1eが増えている -->
<section data-v-efbcda1e data-v-6a9a0e7a class="child">
  <h2 data-v-efbcda1e>子コンポーネントです</h2>
  <p data-v-efbcda1e>子コンポーネントの文章です</p>
</section>

css

/* 親コンポーネント */
.child[data-v-6a9a0e7a] {
  border-bottom: 1px solid #ccc;
  padding-bottom: 30px;
  margin-bottom: 30px;
}

/* 子コンポーネント(data属性が親と違う) */
h2[data-v-efbcda1e] {
  font-size: 20px;
}

子コンポーネントのルートには親の data 属性が追加されていながら、孫となる要素には追加されていない。
これは、scoped でない style から身を守る仕組みの 1 つにもなっている。

css

/*
 * scopedでないCSSの猛攻
 * 優先度が低くなるので効かない
 */
h2 {
  font-size: 100px;
}
section h2 {
  font-size: 100px;
}

/* 優先度が高く適用される */
h2[data-v-efbcda1e] {
  font-size: 14px;
}

ただし、先述した >>>/deep/ セレクターの影響を受ける。

css

// 強い
.child[data-v-6a9a0e7a] h2 {
  font-size: 100px;
}

// 弱い
h2[data-v-efbcda1e] {
  font-size: 12px;
}

scoped を指定した単一ファイルコンポーネントには、その子要素(と子コンポーネントのルート)に data 属性が付与される。

子がFunctional Componentで、scoped CSSが適用されている場合

Functional Component に scoped を付けた場合はどうなるのか。

child.vue

<template functional>
  <section :class="data.staticClass">
    <h2>子コンポーネントです</h2>
    <p>子コンポーネントの文章です</p>
  </section>
</template>

<style scoped>
  /* scopedを作成 */
</style>

この場合は、親要素の data 属性がマージされず、独自の data 属性だけが追加される。
そのため、親コンポーネントからの style が適用されなくなる。

html

<div data-v-6a9a0e7a>
  <!-- 親コンポーネントのdata-v-6a9a0e7aがつかない -->
  <section data-v-efbcda1e class="child">
    <h2 data-v-efbcda1e>子コンポーネントです</h2>
    <p data-v-efbcda1e>子コンポーネントの文章です</p>
  </section>
</div>

css

/* 子コンポーネントにclassはついているが、data属性がマージされていないので適用されない */
.child[data-v-6a9a0e7a] {
  border-bottom: 1px solid #ccc;
  padding-bottom: 30px;
  margin-bottom: 30px;
  margin: 0;
}

対処としては親の data 属性をセレクタに乗せるか、親から data 属性をマージするか、などが挙げられる。

イレギュラーな形ながら、>>>をルートにして。child を指定すると、
親にあたる data-v-6a9a0e7a の .child にスタイルを適用できる。

parent.vueのstyle

<style scoped>
>>> .child {
  border-bottom: 1px solid #ccc;
  padding-bottom: 30px;
  margin-bottom: 30px;
}
</style>

css

/* data-v-6a9a0e7a(親)の配下にある.childなので、これは適用される */
[data-v-6a9a0e7a] .child {
  border-bottom: 1px solid #ccc;
  padding-bottom: 30px;
  margin-bottom: 30px;
}

また、更にイレギュラーだけど、親の _scopedId を参照して属性を追加できる。(lint に引っかかる)

child.vue

<template functional>
  <section :[parent.$options._scopeId]="true" :class="data.staticClass">
    <h2>子コンポーネントです</h2>
    <p>子コンポーネントの文章です</p>
  </section>
</template>

<style scoped></style>

$el から dataset を取得し props として渡す方法などもあるけど、現実的でない。
template 層で調整するのが無難。

親がslotからstyleを当てる場合

slot では、本来は孫となる要素にもスタイルを適用できる。

child.vue

<template>
  <section>
    <h2>子コンポーネントです</h2>
    <slot name="paragraph"></slot>
  </section>
</template>

<style scoped></style>

parent.vue

<template>
  <div>
    <h1>子コンポーネント集</h1>
    <child-component class="child">
      <template #paragraph>
        <p>最初の子コンポーネントですよ</p>
      </template>
    </child-component>
  </div>
</template>

<style scoped>
p {
  font-size: 100px;
}
</style>

上のコードでは、親で paragraph スロットの中に p 要素があって、さらにスタイルを設定している。
この場合のスコープは親側になり、孫となる要素にも data-v-7ba5bd90 がマージされ、スタイルが適用される。

html

<div data-v-7ba5bd90>
  <section data-v-efbcda1e data-v-7ba5bd90 class="child">
    <h2 data-v-efbcda1e>子コンポーネントです</h2>
    <!-- 親のdata属性がついている -->
    <p data-v-7ba5bd90 data-v-efbcda1e>最初の子コンポーネントですよ</p>
  </section>
</div>

親にスタイルがない場合は、そのまま子のスタイルが適用される。

child.vue

<template>
  <section>
    <h2>子コンポーネントです</h2>
    <slot name="paragraph"></slot>
  </section>
</template>

<style scoped>
p {
  font-size: 12px;
}
</style>

css

p[data-v-efbcda1e] {
  font-size: 12px;
}

p 要素を子に置いて slot を囲む場合、data 属性がマージされないので、親のスタイルは適用されない。

child.vue

<template>
  <section>
    <h2>子コンポーネントです</h2>
    <p>
      <slot name="paragraph"></slot>
    </p>
  </section>
</template>

html

<section data-v-efbcda1e data-v-7ba5bd90 class="child">
  <h2 data-v-efbcda1e>子コンポーネントです</h2>
  <p data-v-efbcda1e>最初の子コンポーネントですよ</p>
</section>

ちょっとわかりづらいのでまとめた。

  1. 親の slot 内に要素があり、style を指定している場合、親の style が適用される
  2. 親の slot 内に要素があるが style がない場合、子の style が適用される
  3. 子の中で slot を囲んだ要素には、子の style が適用される

ちなみに親子で同じセレクタがあると、いったん style を生成しつつ、子の style を親が打ち消す。

html

<style type="text/css">
/* 子 */
p[data-v-71a7d33c] {
  font-size: 12px;
}
</style>

<style type="text/css">
/* 親が打ち消す */
p[data-v-7ba5bd90] {
  font-size: 100px;
}
</style>

HMR で scoped CSS を追加した時に、この順番が入れ替わってしまうことがあった(執筆時点)。

html

<style type="text/css">
/* 本来下に来るもの */
p[data-v-7ba5bd90] {
  font-size: 100px;
}
</style>

<style type="text/css">
/* 子に追加したstyleが当たってしまっている */
p[data-v-71a7d33c] {
  font-size: 12px;
}
</style>

まとめ

  1. scoped CSS は、ハッシュ値を要素と style に紐付けて、擬似的なスコープを生成する
  2. class、ハッシュ値などの属性は子要素と子コンポーネントのルートに渡され、孫には渡されない
  3. >>>でセレクタの順番を変えて、子コンポーネントの子要素に style を適用できる
  4. Functional Component は、基本的に class や data 属性がマージされない
  5. slot は、親で指定した style をもとに、孫となる要素に data 属性をマージする