この記事は、 ドワンゴ Advent Calendar 2019 の21日目の記事です。
はじめに
2017年はエンジニアさんで埋められるカレンダーの中、わりと緊張しながらデザイナーの僕も「コンポーネント指向フロントエンド開発におけるデザイナーの参画について」という記事を書くことができました。
記事としても反響を頂き、登壇までさせていただくなど、大変大きな一歩になる記事でした。
さて、あれから2年経ったわけですが、 今年の9月にニコニコ生放送の開発を離れ、特設ランディングページ(以下LPと表記)やポータルサイトなどを作る部署に異動しました。
異動による環境/技術スタックの変化
部署の異動により、環境が大きく変化しました
ニコニコ生放送
- 規模の大きなプロダクト・チーム
- プラットフォームのUIデザイン
- React/TypeScript/scss/css-modules
- 堅牢な機能開発・ワークフロー
現在の部署
- ペライチ ~ 3ページ、多くても5テンプレートの軽量~ポータル程度のページを2人作業
- グラフィックデザイン、演出、アニメーションやインタラクションを重視
- レスポンシブが標準
- ejs/scss/vanila js + babel/jQuery ~ Nuxt(Vue)
- イベント合わせの納期・期間限定の時間の制約、変更が大きい短期開発
のようになり、大きく環境が変わりました。
環境が新しくなったことにより、まだ異動してわずか3ヶ月満たないぐらいですが、非常に多くの知見を得ました。
今回はそのなかでも特に有用そうな技術的Tipsを、実例とともに細々と紹介していきます。
Intersection Observer API
LPでは、要素がウィンドウ内に表示されたときにアニメーションする というようなことがとても多くあります。
今まで、これを書くには「jQueryなどでscrollTopとウィンドウサイズとその要素の位置関係を計算」したり、in-view.jsなどのライブラリを使うことが多かったと思います。
よく出てくる割には、計算が面倒だったり、ライブラリを探す手間、scroll/resizeイベントの抜け漏れ、イベントの発生回数によるパフォーマンスの低下など厄介なことが多くありました。
今ではこれは、Intersection Observer APIというブラウザ標準のAPIとして提供されており、これによってかなり楽に実装できるようになり、イベントの発生回数もscroll/resizeと比べるとかなり少なく、パフォーマンスも良くなります。
基本的にIE以外はすでに実装されている上、polyfillも準備されており、スムーズに導入できます。
- ドキュメント
- Polyfill
使用例: 「画面内に入ってきたらフェードインさせる」をサクッと書く
HTML
<div class="foo-section">...</div>
<div class="bar-section">...</div>
SCSS
will-change
を入れる必然性はないですが、LPでは面積の大きな要素を動かすことも多く、なめらかなアニメーションによる快適な体験が求められるので、僕は will-change
も書いておくことが多いです。
@mixin fade {
transition: opacity .4s ease, transition .4s ease;
will-change: opacity, transition;
&[data-intersecting="false"],
&:not([data-intersecting="true"] {
opacity: 0;
transform: translateY(32px);
}
&[data-intersecting="true"] {
opacity: 1;
transform: translateY(0);
}
}
.foo-section {
@include fade;
}
.bar-section {
@include fade;
}
JavaScript(es6/babel環境)
polyfillは npm install intersection-observer
などで入れておくと便利です
// polyfillの読み込み
require('intersection-observer');
// observeしたときのaction定義
const action = (entry) => {
// entry.target に検出したDOMが入ってくる
// entry.isIntersecting に画面内かどうかが入ってくる
entry.target.setAttribute('data-intersecting', entry.isIntersecting);
}
// IntersectionObserverの利用
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
action(entry);
});
});
// observeするDOMを取得
// 他の要素にも適応したい際は、ここにどんどん追加しておくだけでOK
const targets = [
document.querySelector('.foo-section'),
document.querySelector('.bar-section')
];
// observeする
targets.forEach(target => {
observer.observe(target);
});
これだけでよくあるアニメーションをサクッと実装することができます。なれると何も見ずに5分程度で作ることができ、要素の追加/削除にも柔軟に適応できるため非常に重宝しています。
LPに限らず、Reactのようなコンポーネント指向な世界でも、scrollやresizeはwindowのようなglobalなものを持ちがちになってしまうので、このようなAPIがあるのは非常に嬉しいことだと思います。
ECSSによるHTML/CSSのワークフロー
LPでは、書き捨てで納期も短いものも多く、要素の変更にも柔軟に対応する必要があるため、厳密で堅牢や再利用性というよりは、終盤で最低限秩序が崩れない(namespaceがある/変更で崩れないよう要素とスタイルが1:1対応である)、理解が容易なものが良いです。
そのため、BEMよりラフ(だと思っている)で冗長なECSSを採用しています。
ECSSの概要と考え方のまとめ が非常にわかりやすいですが、要点は以下の1行です。
namespace-ComponentName_ChildNode-variant
現在は namespace
= ページ名を2文字にしたprefixとして採用しています。
使用例: カンプからHTMLをおこし、SCSSファイルを作成する
僕がコーディングを担当したページではないため、実際とは異なりますが、良いサンプルだったので ハレスタ の以下の部分(大人の事情で画像はぼかしました)をHTMLに起こしてみようと思います。
topページなので、namespaceは tp-
とします。
RootのComponentNameは ScheduleSection
とします。
<section class="tp-ScheduleSection">
<div class="tp-ScheduleSectionBackground" aria-hidden="true">
<div class="tp-ScheduleSectionBackground_DecorationText">Pick up</div>
<img class="tp-ScheduleSectionBackground_Image" src="" alt="">
<div>
<header class="tp-ScheduleSectionHeader">
<h2 class="tp-ScheduleSectionHeader_Heading-en">Schedule</h2>
<div class="tp-ScheduleSectionHeader_Heading-ja" aria-hidden="true">スケジュール</div>
</header>
<div class="tp-ScheduleContent">
<section class="tp-SchedulePickUpContainer">
<div class="tp-SchedulePickUpThumbnail">
<img class="tp-SchedulePickUpThumbnail_Image" src="<%= list[i].imageUrl %>" alt="">
<div class="tp-SchedulePickUpThumbnail_Filter">
</div>
<div class="tp-SchedulePickUpInfo">
<time class="tp-SchedulePickUpDateTime" datatime="略">
<span class="tp-SchedulePickUpDateTime_Date">
<span class="tp-SchedulePickUpDateTime_Month"><%= list[0].date.month %></span>
<span class="tp-SchedulePickUpDateTime_DateSeparator">/</span>
<span class="tp-SchedulePickUpDateTime_Day"><%= list[0].date.day %></span>
</span>
<span class="tp-SchedulePickUpDateTime_WeekDay">(<%= list[0].date.weekday %>)</span>
<span class="tp-SchedulePickUpDateTime_Time"><%= list[0].date.startTime %>〜<%= list[0].date.endTime %></span>
</time>
<h3 class="tp-SchedulePickUpTitle"><%= list[0].title %></h3>
</div>
</section>
<div class="tp-ScheduleSubContainer">
<% for(let i = 1; i < list.length; i++) { %>
<section class="tp-ScheduleSubContainer">
<div class="tp-ScheduleSubThumbnail">
<img class="tp-ScheduleSubThumbnail_Image" src="<%= list[i].imageUrl %>" alt="">
<div class="tp-ScheduleSubThumbnail_Filter">
</div>
<div class="tp-ScheduleSubInfo">
<time class="tp-ScheduleSubDateTime" datatime="略">
<span class="tp-ScheduleSubDateTime_Date">
<span class="tp-ScheduleSubDateTime_Month"><%= list[i].date.month %></span>
<span class="tp-ScheduleSubDateTime_DateSeparator">/</span>
<span class="tp-ScheduleSubDateTime_Day"><%= list[i].date.day %></span>
</span>
<span class="tp-ScheduleSubDateTime_WeekDay">(<%= list[i].date.weekday %>)</span>
<span class="tp-ScheduleSubDateTime_Time"><%= list[i].date.startTime %>〜<%= list[i].date.endTime %></span>
</time>
<h3 class="tp-ScheduleSubTitle"><%= list[i].title %></h3>
</div>
</section>
<% } %>
</div>
</div>
</section>
長くなりましたが、上記のような感じになります。
命名/HTML設計の際のポイント
class名とHTML要素は1:1の関係にする
後述するJSでのDOMの拾い方にも影響しますが、基本的にclass名は1文書でユニークになるようにします(liなど繰り返すもの以外)。また、1つのelementに複数のclass名が当たらないようにします。
仮になにか属性を付けたいときはVariantか、変化するものであればdata属性やaria属性を利用します。
レイアウトやスタイルで繰り返すパターンが有る際は、mixinやextendを利用するようにします。(スタイルの話をclass名に持ち込まず、スタイルの話はスタイルファイルで解決するようにする。)
ChildNode
よりも ComponentName
がたくさんあるような状態になるようどんどん区切ることを心がける
単純に書いていくとたった2階層で ChildNode
になってしまうため、ComponentNameを意識して増やしていくことが大切です。
divが必要になったが命名に迷ったらとりあえず {ChildName}Container
としておいて、あとからリネーム
上の ComponentName
の積極利用と合わせての話ですが、 ComponentName
の名付けは結構難しいです。これは、全体を書いたあとで分かるというケースが経験的には多く、全部書いてみてあとからリファクタするほうが早いので、ひとまず Container
という名前を便利に使って {ChildName}Container
としてしまって、あとから全置換で対応するほうが早くできる事が多いです。
また、LPだと特に、以下のような名付け/HTML設計のパターンを予めやっておくと有効です。
Backgroundでも要素として作り、Sectionの直下に持ってくる
LPでは演出を盛ることが多く、Backgroundにも演出を入れることが多いため、cssの background
プロパティや、疑似要素では限界になることが多いです。そのため、最初からBackgroundという要素を持っておくと柔軟に対応できます。
h1~h6タグには Header
ではなく Heading
これらのタグに Header
を指定すると、 <header>
がきたときに困ることが多いので、 Heading
という名前が有効です
サムネイルの <img>
はとりあえず <div>
で囲っておく
UI要素としてのサムネイルもフィルターやhoverで拡大のような装飾、インタラクションをもたせることが多いため、 Thumbnail > Thumbnail_Image / Thumbnail_Filter
のように、常に <img>
にWrapperをもたせるようにしておくと、あとからアニメーションを入れるなどのときに有効になります。
ECSSで命名した後のSCSSの生成
OneClickCSS という、HTMLを投げるとセレクタを吐き出してくれるという便利なサービスがあります。
画像のようにHTMLをペーストし、「SimpleCSS」 ボタンで一発でセレクタを吐き出してくれ、これをコピーして.scssとして保存すればそれであとはスタイルを追加していくだけのscssの雛形が完成します。
ECSSで書くことによるJSでのアクセス
今まで、jsでアクセスするHTML要素は js-
prefixをつけたり、idをつけたりすることが多かったとおもいます。
しかし、これではHTMLがjsに依存を持ってしまいます。
例えば、jsで扱う要素の数が増えると、必ずHTMLになにか手を加えることになります。
変更の多いLPでは地味に手数が増える作業になります。
これは、jsはHTMLに依存するが、HTMLはjsに依存しないようにして、依存の方向性を単方向にするほうが良いと思います。
そこで、ECSSではclass名と要素が基本的に1:1の関係になっているため、そのまま document.querySelector('.ns-ComponentName_ChildName')
で呼び出しています。
HTMLのclass名をインターフェースとし、JSとCSSはそれを参照する、HTMLはJSとCSSに依存しないようにすることで、あまり複雑にならずに保つことができます。
(querySelectorにはパフォーマンス上の懸念があるため、場合によっては最適化することはあります)
ECSSの冗長性とclass名と要素が1:1の関係になっているおかげで、このようなスピーディで、最後までclass名のスコープで悩まないワークフローを成立させることができます。
all: unset
普段、reset.css, normalize.css, sanitize.cssなどを使うことが多いと思いますが、詳細度への配慮や、環境によってreset, normalize, sanitizeどれになっているかの確認、また作るときの選定にも地味に時間がかかります。
そこで、cssには all
というプロパティがあります。
この all: unset
は デフォルトの値がinheritでないものはすべてinitializeしてくれる便利なプロパティで、reset.cssに近いものですが、上書きなどをあまり考えずに使うことができ、cssも見やすくなるため有用だと思います。
使用例: <a>
と <button>
が交じるけど同じスタイルのmixinで共通化したい
all: unset;
だと以下のように書けます
@mixin blue-button-style() {
display: inline-block;
padding: 12px 24px;
min-width: 120px;
font-size: 14px;
line-height: 1.25;
text-align: center;
white-space: nowrap;
color: blue;
border: 2px solid blue;
border-radius: 4px;
&:hover {
cursor: pointer;
}
}
.anchor {
all: unset;
@include blue-button-style;
}
.button {
all: unset;
@include blue-button-style;
}
というように書くと、 <button>
と <a>
に対して同じボタンのスタイルを適応させることができます。
all: unsetとのセットで、他のページやプロジェクトにも使い回すことができて非常に便利だと思います。
使用上の注意
IE, Edgeが非対応の上、Safariで意図通りに動かないケースがあります(特にcolor周り)。
polyfillがあるのですが、これでも意図通りに動かないケースを確認しています。
https://www.npmjs.com/package/postcss-all-unset
ですので、現在では以下のようにmixinを作る方法が現実的です。
mixinの作り方( @supports
を使わない)
上記のpolyfillから、以下のようなmixinをつくり、いらない/使わないものを軽量化するというのが一つの手です。
ひとまずそのままコピペ
@mixin all-unset() {
animation: none 0s ease 0s 1 normal none running;
backface-visibility: visible;
background: transparent none repeat 0% 0% / auto auto padding-box border-box scroll;
border: medium none currentColor;
border-radius: 0;
border-collapse: separate;
//(...以下略)
}
出典を書いておき、使わないプロパティをコメントアウトしておくと、ある程度保守性の高い、コントロールされたmixinにできるかと思います。
@mixin all-unset() {
// polyfill: https://www.npmjs.com/package/postcss-all-unset から改変
background: transparent none repeat 0% 0% / auto auto padding-box border-box scroll;
border: medium none currentColor;
border-radius: 0;
border-collapse: separate;
// (...以下略)
// ==== 以下は使わない ====
// animation: none 0s ease 0s 1 normal none running;
// backface-visibility: visible;
// (...以下略)
}
mixinの作り方( @supports
を使う / 少し未来の話)
将来的にsafariがきれいに動けば、chromeやfirefoxではうまく動くので、 @supports
構文をつかったほうが、インスペクタに大量のプロパティが出てこなくなって良いと思います。
単純に考えると、mixinは以下のようになると思います。
@mixin all-unset() {
@supports (all: unset) {
all: unset;
}
@supports not (all: unset) {
background: transparent none repeat 0% 0% / auto auto padding-box border-box scroll;
border: medium none currentColor;
border-radius: 0;
border-collapse: separate;
// (...以下略)
}
}
がこれでは
.anchor {
@include all-unset;
color: red;
}
と書いたとき、展開されるcssは
.anchor {
color: red;
}
@supports (all: unset) {
.anchor {
all: unset;
}
}
@supports not (all: unset) {
.anchor {
background: transparent none repeat 0% 0% / auto auto padding-box border-box scroll;
border: medium none currentColor;
border-radius: 0;
border-collapse: separate;
// (...以下略)
}
}
となってしまい、あとに書いたはずのcolorが打ち消されてしまいます。
そのため、mixinとそれのincludeは以下のように書く必要があります。
@mixin all-unset() {
@supports (all: unset) {
all: unset;
@content;
}
@supports not (all: unset) {
background: transparent none repeat 0% 0% / auto auto padding-box border-box scroll;
border: medium none currentColor;
border-radius: 0;
border-collapse: separate;
// (...以下略)
@content;
}
}
.anchor {
@include all-unset() {
color: red;
}
}
この書き方は将来的にall: unsetが実用段階になったときに、もしかしたらちょっと置換がしづらいかもしれません。
WAI-ARIAの利用
.isShow
や .hide
など、表示非表示のクラス名は人によってぶれがちですが、 WAI-ARIA の利用によって割と楽になります。
こちらは Webアクセシビリティ Advent Calendar 2019 の 14日目の記事として以下ページに書きました。合わせてご参照ください。
終わりに
非常に長くなってしまいましたが、以上がTipsの紹介になります。
部署を移って期間が短いため、まだ正直いろんな感覚になれていないところがあるのですが、ぐいぐいコードを書くことが多くなり、ロゴなどのグラフィックデザインをすることも増え、非常に充実した日々を送っています。
現在すこし規模の大きいページも受けるようになったため、ejs/scss/jsからNuxt(Vue).jsでの開発にも挑戦しています。
エフェクトやモーションについても、シェーダ、パーティクル、WebGL、pixi.jsやGLSLやなど、演出手法、そしてその実現方法についてもたくさん勉強を重ねる必要を感じています。
また、そこで知見が溜まってきたら小出しに紹介できるように頑張りたいと思います。
ここまで読んでいただきありがとうございました。