jQueryにはもう頼らない!スムーズスクロールを素のJavaScriptで書いてみると…

2016/06/14

Giulio Mainardi

23

Articles in this issue reproduced from SitePoint
Copyright © 2016, All rights reserved. SitePoint Pty Ltd. www.sitepoint.com. Translation copyright © 2016, KADOKAWA ASCII Research Laboratories, Inc. Japanese syndication rights arranged with SitePoint Pty Ltd, Collingwood, Victoria,Australia through Tuttle-Mori Agency, Inc., Tokyo

jQueryって便利ですよね。特に普段JavaScriptを書かないWebデザイナーにはとっつきやすい書き方が魅力。でも素のJavaScriptを書ければ、もっと軽量で使い勝手のいいスクリプトが作れるかもしれません。

本記事はAdrian SanduChris PerryJérémy HeleineMallory van Achterbergが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者の皆さんに感謝します。

スムーズスクロールは、デフォルトでページ内のスムーズなナビゲーションを実現するユーザーインターフェイスの1つです。リンクが設定してある箇所にカーソルを合わせクリックすると、リンク先URLのハッシュフラグメントで指定した移動先までのスクロールをアニメーション付きで示します。

スムーズスクロールは随分前から知られている方法なので、何も目新しくはありませんが、2003年に投稿されたSitePointの記事「Make Internal Links Scroll Smoothly with JavaScript」に掲載された例を確認してください。話はそれますが、この記事はクライアントサイドのJavaScriptプログラミング、特にDOMについて、面倒なVanilla JavaScript(編注:jQueryなどのライブラリーを使用していない、素のJavaScriptのこと。由来は、何もトッピングしていないアイスクリームから。以降は素のJavaScriptと書きます)から新しいライブラリーやフレームワークを使ったソリューションが登場するまでの、変化と発展の歩みについて書かれた価値ある記事だと思います。

jQueryのエコシステムでは、jQueryを使用する、またはjQueryのプラグインを使用することで、たくさんのスムーズスクロールが実装されていますが、本記事では素のJavaScriptでの実装に着目。特に、素のJavaScriptで書かれたJump.jsライブラリーを例として取り上げます。

解説は次ような順序で進めます。最初にJump.jsの特徴をまとめて紹介したあとに、ニーズに合うようにオリジナルのコードを編集します。この作業を通して、関数やクロージャーなどJavaScriptの核となるスキルを高められるでしょう。そのあとでHTMLページを作成して、カスタムスクリプトとしてスムーズスクロール機能を実装します。できれば、CSSのネイティブスムーズスクロール機能のサポートを追加して、最後に閲覧履歴に関する見解で終わります。

サンプルの完成版はこちらです。

フルソースコードはGitHubで公開しています。

Jump.js

Jump.jsは、他のライブラリーとの依存関係がまったくない、素のJavaScript(ES6)で記述されています。ソースコードの行数(SLOC)はたった42行ほどなので、ユーティリティーとしては小さいですが、縮小化されたバンドルのサイズはトランスパイルが必要なので2.67KB程です。サンプルはGitHubプロジェクトページにあります。

ライブラリー名から分かるように、ページ内の座標移動だけを提供するライブラリーです。DOM要素かCSSセレクター、またはプラスかマイナスの数値で距離を指定すると、現在の値から移動先へスクロールします。つまり、スムーズスクロール機能の実装では、ブラウザーのリンク処理を奪ってから実行する必要があります。次の項でさらに説明します。

現時点では垂直方向のみで、表示域のスクロールのみサポートされている点に注意してください。

持続期間(このパラメーターは必須)、イージング関数、アニメーションの終了時に通知するコールバックなど、オプションと合わせてジャンプを設計できます。

この動作はサンプルで確認できます。詳細はドキュメントを参照してください。

Jump.jsはInternet Explorer 10以降を含む「最新の」ブラウザーでは問題なく動きます。サポートしているすべてのブラウザーはドキュメントを参照してください。「requestAnimationFrame polyfill library」にある最適なPolyfillを使うと、もっと古いブラウザーでも動くはずです。

スクリーンの背後で実行されるクイックピーク

Jump.jsソースの内部では、スクロールアニメーションの各フレームで、表示域の垂直位置の更新をスケジュールする、ウィンドウオブジェクトのrequestAnimationFrameメソッドが使われています。この更新はイージング関数で計算された次の位置の値がwindow.scrollToメソッドに渡されたときに実行されます。詳細はソースを確認してください。

少しだけカスタマイズする

Jump.jsの使い方をデモで示す前に、オリジナルコードにちょっとした変更を加えますが、スクロールアニメーションの働きは変えないようにします。

Jump.jsのソースコードはES6で記述されているので、モジュールのトランスパイルとバンドル用のJavaScriptのビルドツールを合わせて利用する必要があります。このままだと過剰なプロジェクトもあるので、どこでも使えるように、コードをES5に変換するリファクタリングをいくつか適用していきます。

優先度の高い順にまず、ES6構文と拡張を外しましょう。スクリプトはES6 classを定義しています。

import easeInOutQuad from './easing'

export default class Jump {
  jump(target, options = {}) {
    this.start = window.pageYOffset

    this.options = {
      duration: options.duration,
      offset: options.offset || 0,
      callback: options.callback,
      easing: options.easing || easeInOutQuad
    }

    this.distance = typeof target === 'string'
      ? this.options.offset + document.querySelector(target).getBoundingClientRect().top
      : target

    this.duration = typeof this.options.duration === 'function'
      ? this.options.duration(this.distance)
      : this.options.duration

    requestAnimationFrame(time => this._loop(time))
  }

  _loop(time) {
    if(!this.timeStart) {
      this.timeStart = time
    }

    this.timeElapsed = time - this.timeStart
    this.next = this.options.easing(this.timeElapsed, this.start, this.distance, this.duration)

    window.scrollTo(0, this.next)

    this.timeElapsed < this.duration
      ? requestAnimationFrame(time => this._loop(time))
      : this._end()
  }

  _end() {
    window.scrollTo(0, this.start + this.distance)

    typeof this.options.callback === 'function' && this.options.callback()
    this.timeStart = false
  }
}

コンストラクタ関数と一連のプロトタイプのメソッドと合わせてこれをES5「class」に変換できますが、このclassに複数のインスタンスは不要なので、オブジェクトリテラルとともに実装されたシングルトンは次のようになります。

var jump = (function() {

    var o = {

        jump: function(target, options) {
            this.start = window.pageYOffset

            this.options = {
              duration: options.duration,
              offset: options.offset || 0,
              callback: options.callback,
              easing: options.easing || easeInOutQuad
            }

            this.distance = typeof target === 'string'
              ? this.options.offset + document.querySelector(target).getBoundingClientRect().top
              : target

            this.duration = typeof this.options.duration === 'function'
              ? this.options.duration(this.distance)
              : this.options.duration

            requestAnimationFrame(_loop)
        },

        _loop: function(time) {
            if(!this.timeStart) {
              this.timeStart = time
            }

            this.timeElapsed = time - this.timeStart
            this.next = this.options.easing(this.timeElapsed, this.start, this.distance, this.duration)

            window.scrollTo(0, this.next)

            this.timeElapsed < this.duration
              ? requestAnimationFrame(_loop)
              : this._end()
        },

        _end: function() {
            window.scrollTo(0, this.start + this.distance)

            typeof this.options.callback === 'function' && this.options.callback()
            this.timeStart = false
        }

    };

    var _loop = o._loop.bind(o);

    // Robert Penner's easeInOutQuad - http://robertpenner.com/easing/
    function easeInOutQuad(t, b, c, d)  {
        t /= d / 2
        if(t < 1) return c / 2 * t * t + b
        t--
        return -c / 2 * (t * (t - 2) - 1) + b
    }

    return o;

})();

classを外す以外にも、いくつかの変更が必要です。各フレームでのスクロールバーの位置の更新に使われるrequestAnimationFrameのコールバックは、元のコードではES6のアロー関数(Arrow function)で呼び出されるもので、初期段階でジャンプシングルトンに事前にバウンドします。また、同じソースファイル内で、デフォルトのイーシング関数をバンドルします。最後に、名前空間の汚染を避けるために、IIFE(即時関数:Immediately-invoked Function Expressions参照)でコードをラップしています。

もう一段階リファクタリングを進めましょう。入れ子になった関数やクロージャは使えないので、オブジェクトの代わりに関数だけを使います。

function jump(target, options) {
    var start = window.pageYOffset;

    var opt = {
      duration: options.duration,
      offset: options.offset || 0,
      callback: options.callback,
      easing: options.easing || easeInOutQuad
    };

    var distance = typeof target === 'string' ? 
        opt.offset + document.querySelector(target).getBoundingClientRect().top : 
        target
    ;

    var duration = typeof opt.duration === 'function'
          ? opt.duration(distance)
          : opt.duration
    ;

    var 
        timeStart = null,
        timeElapsed
    ;

    requestAnimationFrame(loop);

    function loop(time) {
        if (timeStart === null)
            timeStart = time;

        timeElapsed = time - timeStart;

        window.scrollTo(0, opt.easing(timeElapsed, start, distance, duration));

        if (timeElapsed < duration)
            requestAnimationFrame(loop)
        else
            end();
    }

    function end() {
        window.scrollTo(0, start + distance);

        typeof opt.callback === 'function' && opt.callback();
        timeStart = null;
    }

    // ...

}

シングルトンはここで、スクロールをアニメーションにするために呼び出されるjump関数になり、loopendのコールバックは関数の入れ子に、オブジェクトのプロパティーはローカル変数(クロージャ)になります。すべてのコードが安全に1つの関数でラップされたので、IIFEはもう必要ありません。

リファクタリングの最終段階として、ループのコールバックが呼び出されるたびにtimeStartのリセットチェックが繰り返されるのを避けるため、初めてrequestAnimationFrame()が呼び出されるとき、ループ関数を呼び出す前に、timerStart変数をリセットするように無名関数に渡します。

requestAnimationFrame(function(time) { timeStart = time; loop(time); });

function loop(time) {
    timeElapsed = time - timeStart;

    window.scrollTo(0, opt.easing(timeElapsed, start, distance, duration));

    if (timeElapsed < duration)
        requestAnimationFrame(loop)
    else
        end();
}

前にも書きましたが、リファクタリングの流れの中で、核となるスクロールアニメーションのコードは変更しなかったことを覚えておいてください。

テストページ

必要に応じてスクリプトをカスタマイズしたら、サンプルの作成準備がようやくできました。この項では、次の項で紹介するスクリプトを使って、スムーズスクロール機能を強化したWebページを紹介します。

サンプルはドキュメント内の該当箇所へのページ内リンクが貼られた目次(TOC)で構成され、目次に戻るリンクも追加されています。他のページに移動する外部リンクもいくつか設定します。サンプルの基本的な構造は以下のとおりです。

<body>
    <h1>Title</h1>
    <nav id="toc">
        <ul>
            <li><a href="#sect-1">Section 1</a></li>
            <li><a href="#sect-2">Section 2</a></li>
            ...
        </ul>
    </nav>
    <section id="sect-1">
        <h2>Section 1</h2>
        <p>Pellentesque habitant morbi tristique senectus et netus et <a href="http://www.example.net/">a link to another page</a> ac turpis egestas. <a href="http://www.example.net/index.html#foo">A link to another page, with an anchor</a> quam, feugiat vitae, ...</p>
        <a href="#toc">Back to TOC</a>
    </section>
    <section id="sect-2">
        <h2>Section 2</h2>
        ...
    </section>
    ...
    <script src="jump.js"></script>
    <script src="script.js"></script>
</body>

head部分に最小限の基本的なレイアウトを設定するCSSルールを少し加え、body要素の最後に2つのJavaScriptファイルを入れます。Jump.jsのリファクタリングしたファイルと、次の項で説明するスクリプトのファイルの2つです。

マスタースクリプト

スムーズなスクロールを実現するスクリプトで、Jump.jsライブラリーをカスタマイズして、ジャンプ表現をアニメーションにします。もちろん、このコードもJavaScriptのES5で記述します。

この項では、ブラウザーのデフォルト動作(クリックしたリンクのhref属性のハッシュフラグメントで指定されたターゲット要素に即座にジャンプする動作)を無効にして、ページ内リンクをクリックジャックする必要があります。

最初は、ページ内リンクのクリックの監視です。監視方法には2通りあって、1つはイベントデリゲート、もう1つは各関連リンクにハンドラをアタッチすることです。

イベントデリゲート

イベントデリゲートのアプローチは、クリックリスナーをdocument.bodyだけに追加します。これで、Webページ内で発生するクリックイベントごとに、document.bodyにたどりつくまでDOMツリーをさかのぼります。

document.body.addEventListener('click', onClick, false);

もちろん、登録したイベントリスナー(onClick)内で、通知されるクリックイベントオブジェクトのターゲットが、ページ内リンク要素に関連しているか精査しなければなりません。その方法はいくつかありますが、ヘルパー関数のisInPageLink()で説明します。この関数の仕組みは次のようになっています。

通知されるクリックがページ内リンクの場合、イベントのバブリングを停止し、連携するブラウザーのデフォルト動作を止めます。次に、href属性のハッシュフラグメントで指定されたターゲット要素と、動きに合ったアニメーションを構成するパラメーターを含むjump関数を呼び出します。

イベントハンドラーは次のとおりです。

function onClick(e) {
    if (!isInPageLink(e.target))
        return;

    e.stopPropagation();
    e.preventDefault();

    jump(e.target.hash, {
        duration: duration
    });
}

個別にハンドラーを実行する

リンククリックを監視する2つ目の方法は、先に紹介したイベントハンドラーを少し修正したものを各ページ内リンク要素に設定するので、イベントのバブリングは発生しません。

[].slice.call(document.querySelectorAll('a'))
    .filter(isInPageLink)
    .forEach(function(a) { a.addEventListener('click', onClick, false); });

すべてのa要素を要求し、返されたDOMのNodeList[].slice() hackでJavaScript配列に変換します(もしターゲットブラウザーがサポートしていれば、ES6のArray.from()メソッドを使うほうが良いでしょう)。変換した配列のメソッドを使って、先に定義された同じヘルパー関数を再利用しながらページ内リンクをフィルターし、最後にリスナーを残っているリンク要素にアタッチします。

イベントハンドラーはこれまでとほとんど同じですが、クリック先を確認する必要は当然ですがありません。

function onClick(e) {
    e.stopPropagation();
    e.preventDefault();

    jump(hash, {
        duration: duration
    });
}

どちらの方法が良いかは使用するコンテキストによります。たとえば、もし新しいリンク要素が初期ページロードの後に動的に追加されるなら、イベントデリゲートを使う必要があります。

ページ内リンクのサンプルで使う前述のイベントハンドラーで使ったヘルパー関数、isInPageLink()を実装します。これまで説明してきたように、この関数は変数としてDOMノードを取得して、ノードがページ内リンク要素を示すとブール値を返します。渡されたノードは<a>タグで、ハッシュフラグメントがあるかを確認するだけでは不十分です。リンク先が他のページのこともあり、この場合、デフォルトのブラウザー動作が無効にならないからです。そこで、href属性に格納された値からハッシュフラグメントを取り除いた値が、ページのURLと同じかどうか確認します。

function isInPageLink(n) {
    return n.tagName.toLowerCase() === 'a' 
        && n.hash.length > 0
        && stripHash(n.href) === pageUrl
    ;
}

stripHash()もスクリプトを初期化するときに、変数pageUrlの値を設定するヘルパー関数です。

var pageUrl = location.hash
        ? stripHash(location.href)
        : location.href
    ;

function stripHash(url) {
    return url.slice(0, url.lastIndexOf('#'));
}

一般的なURLの構造ではハッシュフラグメント部分はURLの後につくので、このハッシュフラグメントを切り取る方法なら、クエリ文字列を含むURLにも対応できます。

前にも書きましたが、これはサンプルを実行する方法の1つに過ぎません。たとえば、最初に紹介した他の記事では違う方法を使って、locationオブジェクトを含むリンクhref要素ごとの比較をしています。

この関数はどちらの方法でもイベントサブスクリプションに使っていることを覚えておいてください。しかし、2番目の方法では、すでにわかっている要素のフィルターは<a>タグであり、はじめのtagName属性の確認は不要なので、この関数を使っています。これは読者のみなさんの演習用においておきます。

アクセシビリティの検討

現在のコードでは、キーボードユーザーに影響を与える既知のバグ(実際に確認されているBlink、WebKit、KHTMLにある一組の関連のないバグとIEにある1つのバグ)が発生しやすいです。目次(TOC)リンクをtabキーで選択するとき、目次の1つをクックすると、選択した章へとスムーズに移動しますが、フォーカスは目次リンクに残ります。つまり、次にtabキーを押したときは、選んだ章のはじめのリンクではなく、元の目次に戻ります。

修正のために、もう1つ関数をマスタースクリプトに追加しましょう。

// Adapted from:
// https://www.nczonline.net/blog/2013/01/15/fixing-skip-to-content-links/
function setFocus(hash) {
    var element = document.getElementById(hash.substring(1));

    if (element) {
        if (!/^(?:a|select|input|button|textarea)$/i.test(element.tagName)) {
            element.tabIndex = -1;
        }

        element.focus();
    }
}

スクロール先のハッシュ値を渡しながら、jump関数に渡すコールバックで実行されます。

jump(e.target.hash, {
    duration: duration,
    callback: function() {
        setFocus(e.target.hash);
    }
});

この関数の働きは、ハッシュ値が対応するDOM要素をフェッチすることと、フォーカスを受け取れる要素(たとえばアンカーやbutton要素)かテストすることです。もし要素が(<section>コンテナのように)デフォルトでフォーカスを受け取れない場合は、そのtabIndex属性を-1に設定します(キーボード経由ではなく、プログラムでフォーカスの受け取りを許可します)。フォーカスはその要素に設定され、ユーザーが次にtabキーを押すとフォーカスが次の有効なリンクに移動するようになります。

ここまで説明した変更を加えた、マスタースクリプトの完成版のソースはこちらから確認してください。

CSSのスムーズスクロールをネイティブでサポートする

CSS Object Model View module仕様は、スムーズスクロール「scroll-behavior」をネイティブで実装するために新しいプロパティを導入しています。

scroll-behaviorは2つの値を指定できます。デフォルトのautoとスクロールアニメーション用のsmoothです。

この仕様はdurationやtiming-function(イージング)などのスクロールアニメーションを設定する手段はありません。

残念ながら、この記事の執筆時点ではサポート対象のブラウザーはとても限られています。Chrome向けにはまだ開発中で、chrome://flagsで作動させると一部実装が可能です。CSSのプロパティはまだ実装されていないので、リンククリックでスムーズスクロールは動きません。

いずれにしてもマスタースクリプトを少し変更すれば、ユーザーエージェントを利用して検出でき、残りのコードを実行せずに済みます。表示域でスムーズスクロールを使うには、CSSプロパティをルート要素のHTMLに適用します(サンプルではbody要素でも適用できます)。

html {
    scroll-behavior: smooth;
}

スクリプトのはじめに簡単な特徴検出テストを追加します。

function initSmoothScrolling() {
    if (isCssSmoothSCrollSupported()) {
        document.getElementById('css-support-msg').className = 'supported';
        return;
    }
    //...
}

function isCssSmoothSCrollSupported() {
    return 'scrollBehavior' in document.documentElement.style;
}

もしブラウザーがネイティブスクロールをサポートしている場合、スクリプトは何もせずに停止するか、以前のまま実行を継続し、サポートされていないCSSプロパティはブラウザーが無視します。

最後に

今回取り上げたCSSソリューションの優れたメリットは、実装が簡単なことや、パフォーマンス以上にブラウザーのデフォルトのスクロールを利用するときの閲覧履歴と実際に行った行動が一致することです。ページ内ジャンプは履歴のスタックに毎回記憶され、それぞれのボタンを押して、戻ったり次に進んだりすることができます。

記述したコード(CSSサポートが対応していないときのフォールバックとして検討できます)では、閲覧履歴に対するスクリプトの動きは考慮していませんでした。ケースバイケースですが、デフォルトスクロールの使い勝手を高めるべきだと考えるならば、CSSでも一貫した動きが実現されることを期待しましょう。

(原文:How to Implement Smooth Scrolling in Vanilla JavaScript

[翻訳:和田麻紀子
[編集:Livit

Copyright © 2016, Giulio Mainardi All Rights Reserved.

Giulio Mainardi

Giulio Mainardi

WordPressの元フルスタック・エンジニアで、現在はGiulio(mgiulio.github.io)のフロントエンドエンジニア。ReactのエコシステムやRedux、ユニバーサルアプリに強い関心を寄せています。

Loading...