[CSS] Host Scoped Custom Propertyの提案

Angular Advent Calendar 2021 19日目の記事です。 前回は @77web@github さんの [AngularでAWS Amplify を使ってみる in 2021](https://tech.quartetcom.co.jp/2021/12/18/angular-aws-amplify/) でした。


12/3は飛び入りで書かせてもらったのに、今回は大幅に遅れてしまいました...


## あらすじ
今日は長くなるのでまとめると...

[scoped-var](https://www.npmjs.com/package/scoped-var)というSASSライブラリを作ったぜ！

これを用いると、

```scss
// child.component.scss
@use 'scoped-var' as *;

:host {
  @include property(--color, red); // `:host内にスコープが限られたCSS変数を定義
  color: var(--color); // `:host`内にスコープが限られたCSS変数を使用
}
```

こんな風に`@include property(--color, red)`で定義したCSS変数(CustomProperty)のスコープがが`:host`内に絞られて、

```scss
// parent.component.scss
@use 'scoped-var' as *;

:host {
  @include property(--color, blue);
  color: var(--color);

  child {
    --color: #{var(--color)}
  }
}
```

という感じでCSS変数を通して親コンポーネントから子コンポーネントにスタイル情報を伝播させていくことができるよ！という話です。

つまり、子コンポーネントのスタイル情報を親コンポーネントから変更したいとき、
JSから`@Input`で値を渡したり、`::ng-deep(非推奨)`を使ったりしなくて済むということです！！やったね！！


## DEMO
デモを用意したんで、それでも見ながらこれより下は読んでくれ！！

https://nontangent.github.io/angular-scoped-var-demo/

```sh
$ git clone git@github.com:nontangent/angular-scoped-var-demo.git
$ cd angular-scoped-var-demo
$ npm install
$ npm start
```

`http://localhost:4200`へアクセス！


## はじめに
皆さんはどういった観点で技術投資をしていますか？

トレンド? ユーザー数? GitHubのスターの数？

私が大切にしているのは「将来的にその技術が資産になるか」です。


Webフロントエンドの世界は技術の移り変わりが激しく、
今はReactやVueといったUIコンポーネントライブラリが流行っています。
(Angularはちょっとだけメインストリームから外れている所感。。。)

ただ、どの技術も5年後、10年後、いづれは廃れます。
そのとき残った成果物が仕様のなわからないスパゲッティコードでは、次の流れへの移行時に大きな技術的な負債を抱えるだけです。


逆に言えば、この流れが終わった時、いかに技術的な資産と呼べるものを手にしているかが重要だと考えます。


この記事では技術的な資産としてのUIコンポーネントについて考え、
その中でも実装時に課題となる「コンポーネントのスタイル情報の入力方法」に焦点を当て、
その解決策とし「擬似的に`:host`にスコープを限ったCSS変数」(Host Scoped Custom Property)を提案します。


## 技術的な資産としてのUIコンポーネント
エンジニアにとっての技術的な資産とは、再利用性が高く運用・保守が容易なシステムだと思います。
これをWebの世界のUIコンポーネントに当てはめて考えると、下記の3つの要件を満たす必要があると考えます。

- 1. コンポーネントの入力と出力が定義されている
- 2. ロジックとスタイルがコンポーネント内に閉じている(副作用がない・受けない)
- 3. WebComponent化が可能でありWeb標準の近い運用ができる

1と2を満たせば、UIコンポーネント同士が疎結合になって創発を抑えられ、
障害発生時に問題の分析がしやすくなり運用・保守を用意になるでしょう。
また、次のトレンドの予測がつかないなかで再利用性を高めるためには、
Webの標準に近くして柔軟に対応できる状態にする必要があります。


## 課題
素晴らしいことにAngularは上記の3つの要件を満たすために必要な機能を提供しています。
`@Input()`と`@Output`は明確にコンポーネントの入力と出力を定義していまし、
AngularElementsによってコンポーネントをWebComponent化した際も
HTML属性と`addEventListener`というWeb標準の技術で入出力をやり取りできます。
また、ServiceのprovideをComponent内に限定すれば
内部のロジックが入出力以外で外部からの影響を受けることを避けられますし、
`:host`スコープを用いれば容易にスタイル情報をコンポーネント内に閉じることができます。

ただ一つ大きな課題なのが、スタイル情報を入力する術が`@Input`を用いるしか無い点です。

```html
<template [color]="'red'"><template>
```

(古き良き`<font color="red"></font>`を思い出しますね。流れに逆行しているように感じます。)

普通にCSSセレクターで親コンポーネントからpropertyを上書きすれば良いと思われるかもしれませんが、
これではより深い階層のコンポーネントのスタイル情報を上書きするためには、
非推奨の`ng-deep`を用いて階層化を無視したコードを書かざるを得ません。

```scss
:host {
  template {
    color: blue;

    ::ng-deep organism {
      color: blue;

      ::ng-deep molecule {
        color: blue;

        ::ng-deep atom {
          color: blue;
        }
      }
    }
  }
}
```

HTML5の時代を生きる私としては、スタイル情報はCSSに書きたいところです。


## 目的
CSSの標準機能を用いて、UIコンポーネントのスタイル情報を渡し、それを下層のコンポーネントへと伝播できるようにする。

### 理想形(to be)
CSSには標準で「CSS変数(CustomProperties)」という機能があるので、
それを用いてWebComponent化したときにこのように値を渡せるのが理想に思います。

```css
// index.css
web-component {
  --color: blue;
}
```

そして、コンポーネント内ではこんな感じで子コンポーネントに値を伝播させていきたいです。

```scss
/* web-component.scss */
:host {
  --color: red;
  color: var(--color);

  * {
    --color: var(--color); // <- 代入先の変数名と代入する変数名が同じため動かない
  }
}
```

しかし、これではコメントのとおり代入先の変数名と代入する変数名が等しくなるため動きません。


## 解決策
SCSSで`@mixin var()`と`@function var()`を定義してこのように書けるようにした。

```scss
/* web-component.scss */
@use 'scoped-var' as *;

:host {
  @include property(--color, red);
  color: var(--color);

  * {
    --color: #{var(--color)};
  }
}
```

コンパイル後は以下のようになる。

```css
/* web-component.css */

:host {
  --color: red;
  --color-SCOPED-IN-eF4Qig8V: var(--color);
  color: var(--color-SCOPED-IN-eF4Qig8V);
}

:host * {
  --color: var(--color-SCOPED-IN-eF4Qig8V);
}
```

## なんでこうなったか
```scss
// web.component.scss
:host {
  --color: red;
  color: var(--color);

  * {
    // 下の階層のコンポーネントに値を渡すことを試みるが
    --color: var(--color); // <- 代入先の名称と代入する名称が同じためうまく動かない
  }
}
```

まず、上記で代入先の名前と代入する名称が同じためうまく動かないのを避けるため、コンポーネント名をsuffixすることにした。

```scss
// web.component.scss
:host {
  --color: red;
  --color-web: var(--color)
  color: var(--color-web);

  * {
    // 下の階層のコンポーネントに値を渡すことを試みるが
    --color: var(--color-web); // <- 代入先の名称と代入する名称が同じためうまく動かない
  }
}
```

こうするとデバッグの際にどのスコープの変数を参照しているかわかって良いけれど、
親コンポーネントと子コンポーネントの名称が同じ場合にやはりうまく動かなくなります。
そこで、おとなしくランダムな文字列をくっつけて変数名の衝突を避けることにしました。

```scss
// web.component.scss
:host {
  --color: red;
  --color-eF4Qig8V: var(--color)
  color: var(--color-eF4Qig8V);

  * {
    // 下の階層のコンポーネントに値を渡すことを試みるが
    --color: var(--color-eF4Qig8V); // <- 代入先の名称と代入する名称が同じためうまく動かない
  }
}
```

ただこんなこと手動でいちいちやっていられるわけがないので、
SASSの`@mixin`と`@function`を駆使して変数名にランダムな文字列をsuffixするようにしました。

https://github.com/nontangent/scoped-var

これを用いるとこんな風に書けます。

```scss
// web.component.scss
@use 'scoped-var' as *;

:host {
  @include property(--color, red); // CSS変数の定義
  color: var(--color); // CSS変数の呼び出し

  * {
    --color: #{var(--color)}; // var()はSassの@functionなのでインターポレーション(`#{}`)する必要がある
  }
}
```

これでWebComponent化してもCSS変数を用いてコンポーネントにスタイル情報を渡せ、
下層のコンポーネントへと値を伝播させることができそうです。

```css
/* index.css */

web-component {
  --color: blue
}
```

## おわりに
とはいえ、それでも問題はいくつか残っています。
- ①インターポレーション使うのめんどくない？
- ②SCSSのファイルサイズに影響しない？

たぶんwebpackのloaderを書いてsass-loaderの前後でゴニョゴニョすればなんとかなるかな？