Scoped CSS に対応する
この章について
この章では,Vue の Scoped CSS 機能の実装方法を学びます.
コンポーネントごとにスタイルを分離し,スタイルの衝突を防ぐ仕組みを理解しましょう.
Scoped CSS とは
Scoped CSS は,<style scoped> で定義されたスタイルをそのコンポーネントにのみ適用する機能です.
<template>
<p class="message">Hello</p>
</template>
<style scoped>
.message {
color: red;
}
</style>このスタイルは,同じクラス名を持つ他のコンポーネントの要素には影響しません.

大規模なアプリケーションでは,異なるコンポーネントで同じクラス名を使うことがあります.
Scoped CSS がないと,スタイルが意図せず他のコンポーネントに影響してしまいます.
コンポーネントごとにスタイルを分離することで,安全にスタイリングできます!
実装の仕組み
Scoped CSS は以下のステップで実現されます:
- スコープ ID の生成: コンポーネントごとにユニークな ID を生成
- テンプレートの変換: 要素に
data-v-xxx属性を追加 - スタイルの変換: セレクタに
[data-v-xxx]を追加
変換の例
<!-- 入力 -->
<template>
<p class="message">Hello</p>
</template>
<style scoped>
.message {
color: red;
}
</style><!-- 出力 (HTML) -->
<p class="message" data-v-7ba5bd90>Hello</p>
<!-- 出力 (CSS) -->
<style>
.message[data-v-7ba5bd90] {
color: red;
}
</style>スコープ ID の生成
コンポーネントごとにユニークな ID を生成します.通常はファイルパスのハッシュを使用します.
// packages/compiler-sfc/src/parse.ts
import { createHash } from 'crypto'
export function parse(
source: string,
{ filename = DEFAULT_FILENAME }: SFCParseOptions = {},
): SFCParseResult {
const descriptor: SFCDescriptor = {
id: undefined!,
filename,
source,
template: null,
script: null,
scriptSetup: null,
styles: [],
}
// スコープ ID を生成
descriptor.id = createHash('sha256')
.update(filename + source)
.digest('hex')
.slice(0, 8)
// ... 残りのパース処理
}SFCStyleBlock の拡張
スタイルブロックに scoped 情報を追加します.
// packages/compiler-sfc/src/parse.ts
export interface SFCStyleBlock extends SFCBlock {
type: "style"
scoped?: boolean // 追加
}
function createBlock(node: ElementNode, source: string): SFCBlock {
// ...
node.props.forEach((p) => {
if (p.type === NodeTypes.ATTRIBUTE) {
attrs[p.name] = p.value ? p.value.content || true : true
if (type === "style") {
if (p.name === "scoped") {
(block as SFCStyleBlock).scoped = true
}
}
}
})
return block
}テンプレートの変換
テンプレートコンパイル時に,要素に scopeId 属性を追加します.
// packages/compiler-core/src/codegen.ts
function genVNodeCall(node: VNodeCall, context: CodegenContext) {
const { push, helper, scopeId } = context
const { tag, props, children } = node
// scopeId がある場合は props に追加
let propsWithScope = props
if (scopeId) {
const scopeIdProp = `"data-v-${scopeId}": ""`
if (props) {
// 既存の props とマージ
propsWithScope = `{ ...${props}, ${scopeIdProp} }`
} else {
propsWithScope = `{ ${scopeIdProp} }`
}
}
push(helper(CREATE_ELEMENT_VNODE) + `(`)
genNodeList(genNullableArgs([tag, propsWithScope, children]), context)
push(`)`)
}スタイルの変換
CSS セレクタにスコープ属性セレクタを追加します.
// packages/compiler-sfc/src/compileStyle.ts
import postcss from 'postcss'
export interface SFCStyleCompileOptions {
source: string
filename: string
id: string
scoped?: boolean
}
export function compileStyle(options: SFCStyleCompileOptions): string {
const { source, id, scoped } = options
if (!scoped) {
return source
}
// PostCSS を使ってセレクタを変換
const result = postcss([scopedPlugin(id)]).process(source, { from: undefined })
return result.css
}
function scopedPlugin(id: string) {
const scopeId = `data-v-${id}`
return {
postcssPlugin: 'vue-sfc-scoped',
Rule(rule) {
// セレクタに [data-v-xxx] を追加
rule.selectors = rule.selectors.map((selector) => {
return `${selector}[${scopeId}]`
})
},
}
}Vite プラグインでの統合
// packages/@extensions/vite-plugin-chibivue/src/main.ts
async function genStyleCode(descriptor: SFCDescriptor): Promise<string> {
let stylesCode = ``
for (let i = 0; i < descriptor.styles.length; i++) {
const style = descriptor.styles[i]
const src = descriptor.filename
const scoped = style.scoped ? '&scoped=true' : ''
const query = `?chibivue&type=style&index=${i}${scoped}&lang.css`
const styleRequest = src + query
stylesCode += `\nimport ${JSON.stringify(styleRequest)}`
}
return stylesCode
}
// Vite プラグインの load でスタイルをコンパイル
load(id) {
const { filename, query } = parseChibiVueRequest(id)
if (query.chibivue && query.type === "style") {
const descriptor = getDescriptor(filename, options)!
const style = descriptor.styles[query.index!]
if (query.scoped) {
return {
code: compileStyle({
source: style.content,
filename,
id: descriptor.id,
scoped: true,
})
}
}
return { code: style.content }
}
}
スタイルの変換には PostCSS を使っています.
PostCSS は CSS を AST として扱えるツールで,セレクタの変換が簡単にできます.
Vue.js も内部で PostCSS を使っています!
動作確認
<!-- ComponentA.vue -->
<template>
<p class="text">Component A</p>
</template>
<style scoped>
.text {
color: red;
}
</style><!-- ComponentB.vue -->
<template>
<p class="text">Component B</p>
</template>
<style scoped>
.text {
color: blue;
}
</style>両方のコンポーネントが同じクラス名 .text を使用していますが,それぞれ異なる色で表示されます.
特殊セレクタ
Scoped CSS では,いくつかの特殊なセレクタがサポートされています.
:deep() セレクタ
子コンポーネントのスタイルを変更したい場合に使用します.
<style scoped>
:deep(.child-class) {
color: blue;
}
</style>変換後:
[data-v-xxx] .child-class {
color: blue;
}::v-slotted() セレクタ
スロットに渡されたコンテンツにスタイルを適用します.
<style scoped>
::v-slotted(.slot-content) {
font-weight: bold;
}
</style>変換後:
.slot-content[data-v-xxx-s] {
font-weight: bold;
}-s サフィックスは「slotted(スロット)」を意味します. スロットコンテンツは親コンポーネントから渡されるため, 通常のスコープ ID ではなく,特別なスロット用のスコープ ID が使われます.
:global() セレクタ
グローバルスタイルを scoped スタイル内で定義します.
<style scoped>
:global(.global-class) {
margin: 0;
}
</style>変換後:
.global-class {
margin: 0;
}v-bind() による動的スタイル
CSS 内でコンポーネントの状態を使用できます.
<script setup>
import { ref } from 'vue'
const color = ref('red')
</script>
<style scoped>
.text {
color: v-bind(color);
}
</style>変換後:
.text[data-v-xxx] {
color: var(--xxx-color);
}v-bind() は CSS カスタムプロパティ(CSS 変数)に変換されます. 実行時に,コンポーネントのインライン style として CSS 変数の値が設定されます.
複雑な式の使用
クォートで囲むことで,複雑な式も使用できます.
<style scoped>
.box {
width: v-bind('size + "px"');
background: v-bind('theme.colors.primary');
}
</style>
v-bind() は便利な機能ですが,パフォーマンスに影響があります:
- 各
v-bind()は CSS カスタムプロパティとしてインラインスタイルに設定されます - 値が変更されるたびにスタイルの再計算がトリガーされます
- 頻繁に変更される値の場合,直接インラインスタイルを使用する方が効率的です
アニメーションや頻繁な更新には,v-bind() よりもインラインスタイルや CSS アニメーションを検討してください.
今後の拡張
以下の機能も検討できます:
- CSS Modules: クラス名の自動生成
- CSS-in-JS との統合: 動的スタイリングの強化

この章で説明した仕組みを参考に,ぜひ Scoped CSS を実装してみてください!
PostCSS の使い方を学ぶ良い機会にもなります.
ここまでのソースコード: chibivue (GitHub)
まとめ
- Scoped CSS はコンポーネントごとにスタイルを分離する機能
- ユニークな scopeId を生成してテンプレートとスタイルに適用
- テンプレートには
data-v-xxx属性,CSS には[data-v-xxx]セレクタ - PostCSS を使ってセレクタを変換
参考リンク
- Vue.js - Scoped CSS - Vue 公式ドキュメント
- PostCSS - CSS 変換ツール
