Edited at
Vue.js #3Day 18

styleつけるだけのコンポーネントで共通スタイルを抽象化する

この記事は Vue.js #3 Advent Calendar 2018 の18日目の記事です。


TL;DR


はじめに

まずはこちらをごらんください。

よくあるボタン1です。

左から3つはよくあるバリエーションの default, primary, disabled のスタイルです。

primary かつ disabled の場合は disabled のスタイルになるとイケてますね。

こういったボタンのコンポーネントを作成することは大した難しさもなく、エリートマークアッパーの諸兄であればコーヒーを1杯ドリップする前にPRが出ているのではないかと思います。

ですが世の中には使うときの都合2で、下記のようなマークアップでこの見た目を実現しなければならない場合があります。

<!-- なぜかボタンの見た目なaタグ -->

<a href="/foo" target="_blank" rel="noopenner noreferrer" class="button">ボタン</a>

<!-- フォームならよくあるprimaryなinput[type=submit] -->
<input type="submit" value="ボタン" class="button button--primary">

<!-- わりとあるdisabledされてるbuttonタグ -->
<button type="button" disabled class="button button--disabled">ボタン</button>

<!-- お前もか〜〜。なぜかボタンの見た目なrouter-linkとかnuxt-linkとか -->
<router-link to="/hoge" class="button">ボタン</router-link>

バリエーション豊かですね。

今日はこのマークアップと向き合って行きたいと思います。


とりまボタンをマークアップする

まずはボタンのスタイルを作らないことには話が進まないので、一旦マークアップをしていきます。

サッとSingle File ComponentとBEM命名で作ってしまいましょう。

※ この記事に大きく関係する話ではないので、興味がなければ読み飛ばしてください。


MyButton.vue

<template>

<button
class="button"
:class="{
// primaryとdisabledはdisabled優先
'button--primary': !disabled && primary,
'button--disabled': disabled
}"
:disabled="disabled"
type="button"
>
{{ label }}
</button>
</template>

<script>
import 'focus-visible'; // focus-visibleのpolyfill

export default {
props: {
label: {
type: String,
default: '',
},
primary: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
};
</script>

<style scoped>
/* .button */
.button {
box-sizing: border-box;
border-radius: 6px;
padding: 1em 2.5em 1.2em;
background-color: #fff;
color: #51bf87;
text-align: center;
line-height: 1;
font-size: 16px;
font-weight: bold;
box-shadow: 0 3px 0 0 #e9e9e9, 0 1px 1px 0 #e9e9e9;
transform: translateY(-3px);
transition: background-color 100ms ease-out;
}
.button:hover {
color: #51bf87;
background-color: #f2f2f2;
cursor: pointer;
}
.button:active {
background-color: #fff;
box-shadow: none;
transform: translateY(0);
}

/* .button--primary */
.button--primary {
color: #fff;
background-color: #51bf87;
box-shadow: 0 3px 0 0 #009a68, 0 1px 1px 0 #009a68;
}
.button--primary:hover {
background-color: #48a877;
color: #fff;
}
.button--primary:active {
background-color: #51bf87;
}

/* .button--disabled */
.button--disabled {
color: #fff;
background-color: #d3dfe1;
box-shadow: none;
transform: translateY(0);
}
.button--disabled:hover {
background-color: #d3dfe1;
color: #fff;
cursor: not-allowed;
}

/* focus-visible のpolyfill */
.button:focus:not(.focus-visible) {
outline: none;
}
</style>


エリートマークアッパーではないので、コーヒー1杯のドリップには間に合いませんが、なんとかそこそこの時間で出来ました。

がっちりコメントをつけなくてもわかる3くらいにシンプルかなと思います。

さて、このコンポーネントを同じスタイル違ったマークアップでコンポーネント化して行こうと思います。


スタイルを共通化する


1. スタイルの外部ファイル化

スタイルを共通化するということであれば、一番最初に思いつくのはスタイルの外部ファイル化だと思います。

楽だしコスパいいですよね。


buttonタグ

というわけでまずは上のボタンのソースをスタイルの共通化していきます。


MyButton.css

.button {

/* 上と同じなので中略 */
}


MyButton.vue

<template>

<!-- 上と同じなので中略 -->
</template>

<script>
// 上と同じなので中略
</script>

<style scoped>
@import './MyButton.css';
</style>


バリバリ使いまわしでできました。


aタグ

次はaタグを作っていきます。

要件としては



  1. href を受け取ってattributeに突っ込む(しなくてもいいけど他のコンポーネントで補完が効くので便利)


  2. targetBlanktrue だったら target とか rel とかをいい感じにする


  3. disable の場合はリンクが効かないようにする

です。

buttonタグとは違った要件があってちょっと複雑ですね


MyButtonLink.vue

<template>

<!-- aタグでdisableするときはspanに変えてしまう -->
<a
v-if="!disabled"
class="button"
:class="{ 'button--primary': primary }"
:href="href"
:target="target"
:rel="rel"
>
{{ label }}
</a>
<span
v-else
class="button button--disabled"
>
{{ label }}
</span>
</template>

<script>
import 'focus-visible' // focus-visibleのpolyfill

export default {
props: {
label: {
type: String,
default: '',
},
href: {
type: String,
required: true,
},
targetBlank: {
type: Boolean,
default: false,
},
primary: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
computed: {
// targetがblankかどうかでattributesを出し分けるUIロジック
// 本当は `AnchorLink.vue` とか別コンポーネントで抽象化したいもの
target() {
return this.targetBlank ? '_blank' : '_self';
},
rel() {
// 本当はorigin見たりして複雑に出し分けたいもの
return this.targetBlank ? 'noopenner noreferrer' : ''
}
}
};
</script>

<style scoped>
@import './MyButton.css';
</style>


テンプレートがガラッと変わって複雑になりました。


外部CSSで共通化のしんどい点

基本的に大きな問題は無いんですが、テンプレートとスタイルが一緒に管理できるSFCのメリットを全捨てしているのがウッとなります。

というのも、cssファイル単体で置いておくと、このクラスがどういったテンプレートでの使われ方をするのかという情報が完全に欠落するためです。「primary よりも disabled のほうが優先的にスタイルをかける」などというUIロジックを知りながら利用する必要があります。

また、 import 'focus-visible' でpolyfillを呼ばなければ、意図した挙動をしないこともわかりません。手厚くコメントを書くのも手ではありますが、こんな情報知らないでも使えるに越したことはありません。

さらに、パフォーマンスを気にする方は各コンポーネントがそれぞれ共通スタイルをjsに内包することでファイルサイズが増えるのも見逃せないデメリットになります。


2. extendを使う

上述のpolyfill呼び出しとファイルサイズに関しては、共通スタイルをVueコンポーネントにまとめて extend することで解決することが可能です4。(注釈も参照のこと)


MyButtonBase.vue

<script>

import 'focus-visible'; // polyfillも共通化できてうれしい!

export default {
props: {
// 上と同じなので中略
},
};
</script>

<style scoped>
/* スタイルを再利用するコンポーネント間で共通化できて、ファイルサイズを削減できてうれしい! */
.button {
/* 上と同じなので中略 */
}
</style>



MyButton.vue

<template>

<!-- 上と同じテンプレート -->
<button
class="button"
:class="{
'button--primary': !disabled && primary,
'button--disabled': disabled
}"
:disabled="disabled"
type="button"
>
{{ label }}
</button>
</template>

<script>
import MyButtonBase from './MyButtonBase.vue';

export default {
extend: MyButtonBase,
};
</script>


いい感じになりました。

extendsはextendsする側がされる側に完全に依存するので、使い方次第では辛さを生む割れ窓ですが、これくらいのコンポーネントのスタイルの共通化には大変便利です。

しかし、クラスがどういったテンプレートでの使われ方をするのかという情報が完全に欠落するというデメリットが解決できていません。

これをなんとかしてみたいと思います。


3. 今回ご紹介するstyleだけ当てるコンポーネント

まず使い方から説明します。


MyButton.vue

<template>

<!-- transitionのように自分のテンプレートを持たず、
子に適切なクラスを付けるだけの動きをする -->

<my-button-style
:primary="primary"
:disabled="disabled"
>
<!-- ここがめんどいんですが、listenerをbuttonに付け替えます -->
<button
v-on="$listeners"
:disabled="disabled"
>
{{ label }}
</button>
</my-button-style>
</template>

<script>
import MyButtonStyle from './MyButtonStyle.vue';

export default {
inheritAttrs: false,
components: {
MyButtonStyle,
},
props: {
// 上と同じなので中略
},
};
</script>


簡単ですね。

やっていることは transition の共通化と同じで、テンプレートを持たず、子に適切なクラスを付与するだけのコンポーネントを作っただけです。

Vueの内部APIである abstracttrue にしても同じことはもっと簡単に実現できるのですが、Evan You氏も公開APIにはしないよ、と発言しているし、 @vue/test-utils で動かないっぽい5し、インスタンスも必要ないシンプルなものなので functional component で作ってみたいと思います。

結論からなんですが、こうなります。


MyButtonStyle.vue

<script>

import 'focus-visible';
// あとで解説します
import { transformVNodeClassToObject } from '@/lib/utilities/vnode';

export default {
functional: true,
props: {
primary: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
render(_, { props, children }) {
// 長男要素を抜き出す
const child = children[0];
// 長男がいなければ何もしない
if (!child) {
return undefined;
}

// 長男に生やすスタイルのクラス
const injectingClasses = {
'button': true,
'button--primary': !props.disabled && props.primary,
'button--disabled': props.disabled,
};

// dataが無かったりする場合もあるのでガード文はちゃんと書く
const classes = !child.data
? injectingClasses
: {
// 次で説明しますが、 data.class には数パターンの型で
// 値が入ってくるので、全部Objectに直す関数を通す
...transformVNodeClassToObject(child.data.class),
...injectingClasses,
};

// 長男にクラスを生やす
if (!child.data) {
child.data = { class: classes };
} else {
child.data.class = classes;
}
// いじった長男を返す
return child;
},
};
</script>


大したことはしてないんですが、VueのVNode インターフェースの値をよしなに正規化してくれるわけではないのでそこらへんのあれこれが多いです。

解説を飛ばした例の関数は以下です。こいつは data.classテンプレートやrenderの書き方次第でいろいろな型でデータを渡してくるので、それをオブジェクトに正規化し直してくれる偉い関数です。

今回の話じゃなくても使い所が多いので御リポジトリにも置いておくと良いです。

調べてないですが、npm packageもあるんじゃないのかな?


@/lib/utilities/vnode.js

export const transformVNodeClassToObject = source => {

// voidだったら空オブジェクトを返す
if (source === undefined && source === null) {
return {};
}
// stringだったら決め打ちのクラス文字列
if (typeof source === 'string') {
return { [source]: true };
}
// 配列の場合はitemが string と object の可能性がある
if (Array.isArray(source)) {
return source.reduce((accumulator, item) => {
// stringの場合は決め打ちクラス文字列
if (typeof item === 'string') {
return {
...accumulator,
[item]: true,
};
}
// objectの場合は分割代入しちゃう
return {
...accumulator,
...item,
};
}, {});
}
return source;
};

VNodeとちょっと向き合いましたが、いい感じに状態から適切なクラスを振る部分までカプセル化できたのではないでしょうか?

かなり上で出てきたaタグは以下のようになります。


MyButtonLink.vue

<template>

<my-button-style
:primary="primary"
:disabled="disabled"
>
<a
v-if="!disabled"
:href="href"
:target="target"
:rel="rel"
v-on="$listeners"
>
{{ label }}
</a>
<span
v-else
>
{{ label }}
</span>
</my-button-style>
</template>

<script>
import MyButtonStyle from './MyButtonStyle.vue';

export default {
inheritAttrs: false,
components: {
MyButtonStyle,
},
props: {
// 上と同じなので中略
},
};
</script>


スタイルにまつわるUIロジックとaタグのattributeに関するUIロジックが完全に分離できているのがおわかりでしょうか?!


まとめ

今回はクラスがどういったテンプレートでの使われ方をするのか知らなくても使えるようなスタイルの抽象化をしてみました。

副次的なメリットとして


  1. propsが補完に乗る

  2. tsxであれば型検査ができる

などが挙げられます。

個人的には Abstract Styling Component と心の中で呼んでいるんですが、どなたか名前がついていたら教えていただけると幸いです。また、ここをこうしたらいいよというマサカリなどもどうぞ!

では良いマークアップを!


(追記)

v-onで$listenersを植える部分、Styleコンポーネント側でやればいいのか〜





  1. これはAkrYmstさんデザインの世界に一つだけの最高のボタンです。記事化快諾いただきありがとうございます〜〜〜 



  2. なるべく各マークアップに沿った動作があるので、それに従ったデザインのUIがあるべきだと思います。しかし、世の中には既存資産の利用だとか実装の都合だとか大人の事情だったり、UX観点でこうなることもあるのだ。 



  3. 完全に本筋の話では無いんですが、コメントをつけた focus-visible の話はyamanokuさんの こちら が明快にまとまっていてよいです。 



  4. scoped styleをextendsするのは、vue-loader v14までは動きましたが、vue-loader v15でアーキテクチャのリニューアルをしてから動かないっぽいです。 https://github.com/vuejs/vue-loader/issues/1003 



  5. 自分は前やったときにテストできなかったんですが、最近は確認してないです。当時回避法も探ってないです。