Smart Communication Design Company
ホーム > ナレッジ > Blog > フロントエンドBlog > 2019年6月 > JavaScriptで操作するCSS TransitionとCSSOMの関係

JavaScriptで操作するCSS TransitionとCSSOMの関係


リードUI開発者 宇賀

みなさんこんにちは!UI開発者の宇賀です🍣🍵

あっという間に時間が過ぎて、気がつけばもう第1クオーターも終わりを迎えます。梅雨入りから低気圧が続いて嫌になっちゃいますね...😔🌧。

さて、さっそく本題です。
まずは今回のテーマで記事を書くことになったきっかけについてご紹介したいと思います。

かつて「jQuery無しでイージングアニメーションを伴う機能をつくろう!」というシリーズの中でディスクロージャーウィジェットを取り上げた記事を公開しましたが、この記事に関して個人的にお便りをいただきました📧。

jQuery無しでイージングアニメーションを伴う機能をつくろう!その2(トグル編)の「CSS Transition を考慮したコードに修正してみる」のセクションで書かれている内容について質問です。

何度か検証してみたのですが、このコードのif文の中の処理が走ることがなさそうでした。
にもかかわらず、if文を削除してしまうと正しく処理が走りません。これはどういう状態でしょうか?

var open = function () {
    if (content.offsetHeight !== 0) {
        requestAnimationFrame(open);
        return;
    }

    content.style.height = height + 'px'; // コンテンツの高さに取得しておいた値を指定する
};
var close = function () {
    if (content.offsetHeight !== height) {
        requestAnimationFrame(close);
        return;
    }

    content.style.height = 0; // コンテンツの高さに取得しておいた値を指定する
};

ご質問ありがとうございます!さっそく回答していきたいと思います。

※ 本記事は、基本的に私なりの調査結果です。実際に想定されている仕様とは異なる可能性があります。

前提

この記事で取り扱っていたディスクロージャーウィジェットは、隠された要素を表示する際にdisplay: none;の状態からblockへ変化させた後に、heightプロパティを0からautoと同値までTransitionさせることを想定して書かれたものです。

CSS Transitionは、計算済みの値から計算済みの値までの数値的な変化をアニメーションさせます。高さを指定せず成り行きになっているheightプロパティも、autoから計測した値を実数で指定しなければなりませんから、実数を設定した後に要素のCSS layout boxが計算されるまでrequestAnimationFrameで待とう、という考えのもとこういった処理になっています。displayプロパティもnoneからblockに変化した後に実数値が計算されますから、同様の理由と手段で待機時間を設けようと考えていました。

非表示からの表示を1度だけ行えればいいケースならCSS Animationで実現できますが、今回は折り畳みのアニメーションも必要だったり、始まりと終わりが絶対値ではない(静的に@keyframesを用意できない)ことからCSS Transitionを用いています。

ちなみに、JavaScriptとCSS Transitionの組み合わせについてMDNでは次のように書かれています。

次のような場合の直後にトランジションを使用する場合は注意してください。

  • .appendChild()を使用して DOM に要素を追加したとき
  • 要素のdisplay: none;プロパティを外したとき

この場合、初期の状態が発生せず、要素が常に最後の状態であるかのように扱われます。この制限を解決する簡単な方法は、トランジションを行いたい CSS プロパティを変更する前に、数ミリ秒のwindow.setTimeout()を適用することです。

CSS トランジションの使用 - CSS: カスケーディングスタイルシート | MDN
https://developer.mozilla.org/ja/docs/Web/CSS/CSS_Transitions/Using_CSS_transitions

setTimeoutを推奨しているようですが「数ミリ秒」という曖昧な値よりも、当時の私はrequestAnimationFrameによる必要最低限の待機時間で解決するほうがスマートだと考えていました。

しかし質問内容に書かれていた通り、当該のコードでは1度たりともrequestAnimationFrameは呼び出されていませんでした。

なぜ条件式がtrueになることがないにもかかわらず、当該のif文を削ると動作しなくなるのか

執筆した当時は認識していなかったのですが、このコードが想定通り動作している要因はif文の中にあるrequestAnimationFrameではなく、条件式の中でoffsetHeightが参照されていることにあります。 そのため、if文自体を削除してしまうとうまく動作しなくなってしまっていました。

実際の挙動を確認するために、3つのサンプルを用意しました(WAI-ARIAは省略しています)。displayプロパティがnoneから切り替わった後の処理がそれぞれ次のように異なります。

なお、対象ブラウザはWindow10環境に置いて現時点での最新版であるGoogle Chrome、Firefox、Edge、IE11とします。

<!-- 共通マークアップ -->
<style>
#hook {
  background: #ebebeb;
  color: #000;
  font-weight: bold;
  padding: 10px;
  width: 100%;
}
#panel {
  transition: .2s height ease-out; /* アニメーションの設定 */
  overflow: hidden;
}
#panel .inner {
  padding: 10px;
  border: 2px solid #ebebeb;
}
</style>

<button id="hook">開く</button>

<div id="panel" hidden>
<div class="inner">
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ipsa dicta, sapiente id voluptatem officia molestias eum corrupti sunt perspiciatis, temporibus, magni voluptates impedit aperiam praesentium culpa ut minima nulla delectus.</p>
<!-- /.inner --></div>
<!-- /#panel --></div>
記事執筆時点では動作が不安定、または動作しないディスクロージャーウィジェット

displayプロパティがnoneから変化した後、requstAnimationFrameで1フレームずらすことを狙ったものです。

(function () {
    'use strict';

    const button = document.getElementById('hook');
    const panel = document.getElementById('panel');
    let height = 0;
    let isClose = panel.hidden;
    let isSliding = false; // 連打対策

    button.addEventListener('click', function () {
        if (isSliding) {
            return;
        }

        isSliding = true;
        isClose = panel.hidden;

        // 閉じている場合
        if (isClose) {
            button.textContent = '閉じる';
            panel.hidden = false;
            height = panel.offsetHeight;
            panel.style.height = 0;

            requestAnimationFrame(function () {
                panel.style.height = height + 'px';
            });

            return;
        }

        // 開いている場合
        button.textContent = '開く';
        height = panel.offsetHeight;
        panel.style.height = height + 'px';

        requestAnimationFrame(function () {
            panel.style.height = 0;
        });
    });
    panel.addEventListener('transitionend', function () {
        if (!isClose) {
            panel.hidden = true;
        }

        panel.style.height = '';
        isSliding = false;
    });

    button.textContent = panel.hidden ? '開く' : '閉じる';
}());
対象ブラウザで動作するディスクロージャーウィジェット1

displayプロパティがnoneから変化した後、setTimeoutで30msずらしたものです。

(function () {
    'use strict';

    const button = document.getElementById('hook');
    const panel = document.getElementById('panel');
    let height = 0;
    let isClose = panel.hidden;
    let isSliding = false; // 連打対策

    button.addEventListener('click', function () {
        if (isSliding) {
            return;
        }

        isSliding = true;
        isClose = panel.hidden;

        // 閉じている場合
        if (isClose) {
            button.textContent = '閉じる';
            panel.hidden = false;
            height = panel.offsetHeight;
            panel.style.height = 0;

            setTimeout(function () {
                panel.style.height = height + 'px';
            }, 30);

            return;
        }

        // 開いている場合
        button.textContent = '開く';
        height = panel.offsetHeight;
        panel.style.height = height + 'px';

        setTimeout(function () {
            panel.style.height = 0;
        }, 30);
    });
    panel.addEventListener('transitionend', function () {
        if (!isClose) {
            panel.hidden = true;
        }

        panel.style.height = '';
        isSliding = false;
    });

    button.textContent = panel.hidden ? '開く' : '閉じる';
}());
対象ブラウザで動作するディスクロージャーウィジェット2

displayプロパティがnoneから変化した後、offsetHeightを1度参照しただけのものです

(function () {
    'use strict';

    const button = document.getElementById('hook');
    const panel = document.getElementById('panel');
    let height = 0;
    let isClose = panel.hidden;
    let isSliding = false; // 連打対策

    button.addEventListener('click', function () {
        if (isSliding) {
            return;
        }

        isSliding = true;
        isClose = panel.hidden;

        // 閉じている場合
        if (isClose) {
            button.textContent = '閉じる';
            panel.hidden = false;
            height = panel.offsetHeight;
            panel.style.height = 0;
            // CSSOMへの問い合わせ
            panel.offsetHeight; // eslint-disable-line
            panel.style.height = height + 'px';

            return;
        }

        // 開いている場合
        button.textContent = '開く';
        height = panel.offsetHeight;
        panel.style.height = height + 'px';
        // CSSOMへの問い合わせ
        panel.offsetHeight; // eslint-disable-line
        panel.style.height = 0;
    });
    panel.addEventListener('transitionend', function () {
        if (!isClose) {
            panel.hidden = true;
        }

        panel.style.height = '';
        isSliding = false;
    });

    button.textContent = panel.hidden ? '開く' : '閉じる';
}());

いかがでしょうか?現時点ではどのブラウザでもoffsetHeightの参照をするだけでCSS Transitionが動作していることが確認できるかと思います。

なぜ、要素のoffsetHeightの参照をするだけでTransitionが動作するのでしょうか。

HTMLElement interface 'offsetHeight'

offsetHeightの振る舞いを確認してみると、次のように書かれています。

  1. If the element does not have any associated CSS layout box return zero and terminate this algorithm.
  2. Return the border edge height of the first CSS layout box associated with the element, ignoring any transforms that apply to the element and its ancestors.
CSSOM View Module
https://www.w3.org/TR/cssom-view/#dom-htmlelement-offsetheight

要素がCSSレイアウトボックスを持っていなければ0を返し、持っていればCSSレイアウトボックスの高さを返すとあります。

そもそもouterHeightはCSSOM View Moduleの仕様の1つですから、それを参照するということはJavaScriptによってCSSOMへ問い合わせが発生するということなのでしょう。問い合わせの結果、実際の高さが計算されるためtransitionbefore-change styleが判明し、無事Transitionが行われるという結果につながったように感じます。

その他のinterfaceでの動作

CSSOM View Moduleで定義されているElement及びHTMLElementのinterfaceは次の通りです。

いずれのプロパティもCSS layout boxを確認して計算する仕様のため、displayプロパティの値をnoneから切り替えた後にどれか1つを1度参照(または実行)するだけでTransitionが働くことを確認できました。

Element系のinterfaceだけでなく、document.elementFromPoint(0, 0)を1度実行するだけも同じ結果が得られたため、とにかくCSSOMへの問い合わせが1度でも行われればTransitionが動作するようです。

まとめ

requestAnimationFrameで正常に動作が行われるかと思っていましたが、CSS layout boxが解釈されるまでの時間とFPSは実際のところ関係がないため、誤解をしていたようです。。。

結果的に私が見た範囲では、非表示の状態からCSS Transitionを動作させる方法として特別何かが紹介されている節を見つけ出すことができませんでした。

とはいえ今回確認できた振る舞いから分かった通り、事実上displayプロパティがnone以外に変わった後、CSSOM View Moduleで定義されている仕様の中から、CSSOMに問い合わせが発生する手段を1つとるだけでTransitionを動作させることはできるようです。

しかしながら参照するだけではエンジンによる最適化でCSSOMへの問い合わせが発生しなくなる可能性もありますし、明確にそのような仕様になっているという一文を見つけることもできなかったことから、現状では実際の実装がそのようになっているからと言って、手放しにこの方法でTransitionを実現することはあまりお勧めできないと考えます。偶然そのような振る舞いにそろったのか、あるいはあらゆるドキュメントを網羅的に読めば分かる当然の実装なのか...まだまだ学ぶことは多いですね。

確実なのはsetTimeoutで一定のミリ秒待機することかもしれませんが、待機するべき秒数も明確ではないためこちらも不安が残ります。

これ以外にもスライドアップ、スライドダウンを実装するには冒頭でご紹介した記事に掲載している手段以外にも様々な方法がありますから、他の選択肢も含めて学びを続けていきたいと思います。

(記事を読んでお問い合わせくださった方、ありがとうございました...!)

参考文献