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

vue-loaderでは、styleタグにscopedを指定して、コンポーネントにCSSスコープを閉じることができます。

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

基本形

下記コードでは、親コンポーネントが、子コンポーネントにborderとmarginを付与しています。

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などのプロパティが、子に適用されます。

scopedcss01

子が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;
}

つまり子に.fooclassを持たせて、親側で.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>

なぜかと言えば、こうなるからです。

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

セレクタが.child h2[data-v-6a9a0e7a]になっています。
先ほど見た通り、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;
}

>>>/deep/はコンポーネントのCSS管理を煩雑にすることが多いので、むやみに使用しない方が良いです。

また、子が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がなかった場合です。
子にもscoped CSSが適用されている場合はどうなるのでしょうか。

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を変えたので、そちらが適用されています。

scopedcss02fix

一方で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から身を守る仕組みの一つにもなっています。

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として渡す方法などもありますが、現実的でないです。
レイアウト調整用のwrppaerで挟めるなら、それが無難かと思います…。

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

最後にslotです。
slotでは、本来は孫となる要素にもstyleを適用することができます。

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要素があって、さらにstyleをつけています。
この場合のスコープは親側になり、孫となる要素にもdata-v-7ba5bd90がマージされ、styleが適用されます。

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>

親にstyleがない場合は、そのまま子のstyleが適用されます。

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属性がマージされないので、親のstyleは適用されません。

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属性をマージする