ネイティブのJavaScriptで汎用的なスムーススクロールを実装する

※2017/8/23 加筆
htmlのカスタムデータ属性からオプションの上書きをできるようにしました。

# 概要
これまでjQueryで実装することの多かったスムーススクロール。
すでにネイティブのJSで書かれた高機能なライブラリは複数存在するのですが、学習のためにライブラリを使わずに書いてみました。

[デモページはこちら](https://codepen.io/KDE_SPACE/pen/jLzBPx)

## 実装に当たって試してみたかったこと
- 基本をすっ飛ばして、業務ではES2015のclass構文で書いているので、従来の**「関数（コンストラクタ）定義」＋「.prototypeへのメソッド定義」**で書いてみたかった（そのためES5の構文で書いています）。
- これまでアニメーション実装はCSSアニメーション、JSではライブラリ頼りだったので、**基本的なところを試しながらアニメーションの実装**をしてみたかった。
- **requestAnimationFrame**を使ってみたかった。
- プラグインのようにできる限り**汎用的に使えるように**したかった。


## 検証済みブラウザ
PC：（windows10）IE10以上、最新版Edge・Chrome・Firefox
モバイル：（iOS10.3.2）最新版Safari・Chrome

※その他のデバイス・環境では未確認ですので、実際の業務では下記のようなライブラリを使用して頂くのが良いと思います。
- [Jump.js](http://callmecavs.com/jump.js/)
- [smooth-scroll](https://github.com/cferdinandi/smooth-scroll)

# コード
## HTML

※2017/8/23 修正

```html:html
<nav id="nav">
	<ul>
		<li><a href="#section01">section01</a></li>
		<li><a href="#section02" data-smScroll-positioning="-100">section02<small>（カスタムデータ属性で遷移先のスクロール位置指定）</small></a></li>
		<li><a href="#section03" data-smScroll-duration="2500" data-smScroll-easing="easeInOutExpo">section03<small>（カスタムデータ属性でdurationとイージング指定）</small></a></li>
		<li><span data-scroll="#section04">section04<small>（新たなインスタンス生成）</small></span></li>
	</ul>
</nav>
<section id="section01">section01のエリア</section>
<a href="#nav" class="backToTop">↑ 上に戻る</a>
<section id="section02">section02のエリア</section>
<a href="#nav" class="backToTop">↑ 上に戻る</a>
<section id="section03">section03のエリア</section>
<a href="#nav" class="backToTop">↑ 上に戻る</a>
<section id="section04">section04のエリア</section>
<a href="#nav" class="backToTop">↑ 上に戻る</a>

```

## JavaScript

※2017/8/23修正

```js:JavaScript
'use strict';

/**
 * イージング関数を取得
 * @param {string} name イージング名
 */
var getEasingFunc = function(name) {
	switch (name) {
		case 'linear':
			return function(t, b, c, d) {
				return c * t / d + b;
			}
			break;
        /**
         * 中略
         */
	}
};

/**
 * スムーススクロール
 * @constructor
 * @param {object} option オプション設定
 */
var SmScroll = function(option) {
	this.option = {
		trigger: 'a', // イベント発火となるセレクタ
		attr: 'href', // リンク先を示す属性値
		prefixOverRide: 'data-smScroll-',
		duration: 600, // アニメーション完了までの時間（ミリ秒）
		positioning: 0, // 遷移先位置の調整値
		easing: 'easeOutQuart', // イージング名
		beforeFunc: null, // スクロール開始前の実行する関数
		afterFunc: null // スクロール完了後に実行する関数
	};
	this.baseElement = null; 

	// オプションのマージ
	Object.assign(this.option, option);
	
	// 最初のオプション値をコピー
	this.firstOption = Object.assign({}, this.option);
}

/**
 * 初期設定・実行
 */
SmScroll.prototype.init = function() {
	var self     = this;
	var triggers = document.querySelectorAll(self.option.trigger);	
	if (triggers.length < 1) {
		return;
	}
	// 現在地と遷移先のスクロール位置のオブジェクト定義
	var posScroll = {
		from: null,
		to: null
	}
	
	/**
	 * 属性値からオプション設定を上書き
	 * @param {Node} target 属性値を持った要素
	 */
	var overRideOptionFromAttr = function(target) {
		var ary = ['duration', 'positioning', 'easing'];
		var selfOption = self.option;
		var prefixOverRide = selfOption.prefixOverRide;
		
		ary.forEach(function(v) {
			if (target.getAttribute(prefixOverRide + v)) {
				selfOption[v] = target.getAttribute(prefixOverRide + v);
			}
		});
	}

	// webkit系であれば位置情報の取得をbody要素から、その他はhtml要素から行う
	self.baseElement = (function() {
		var webkitFlg = navigator.userAgent.toLowerCase().match(/webkit/) ? true : false;
		return webkitFlg ? document.body : document.documentElement;
	})();
	
	// トリガークリック時のイベント設定
	Object.keys(triggers).forEach(function(v) {
		triggers[v].addEventListener('click', function(e) {
			// ターゲット要素取得
			var attrStr = this.getAttribute(self.option.attr).substr('1');
			var target  = document.getElementById(attrStr);
			if (attrStr === '' || !target) {
				return;
			}

			e.preventDefault();

			// 属性値からオプション設定を上書き
			overRideOptionFromAttr(this);
			
			// 遷移先のスクロール位置調整の値設定
			var positioning = (function(pos) {
				// 関数であれば実行し、それ以外は数値として返す				
				if (typeof pos === 'function') {
					return pos();
				} else {
					return parseInt(pos, 10);
				}
			})(self.option.positioning);
			
			
			// 現在地と遷移先のスクロール位置取得
			posScroll.from = self.baseElement.scrollTop;
			posScroll.to = (function() {
				var clientRect = target.getBoundingClientRect();
				return self.baseElement.scrollTop + clientRect.top + positioning;
			})();
			
			// スクロール開始前の関数が設定されていた場合、実行
			if (typeof self.option.beforeFunc === 'function') {
				self.option.beforeFunc();
			}
			
			// スクロール実行
			self.move(posScroll);
		});
	});
}

/**
 * スクロール実行
 * @param {object} offsetTop
 * 	offsetTop.nowに現在地、offsetTop.tagetに遷移先のスクロール位置を設定
 */
SmScroll.prototype.move = function(posScroll) {
	var self          = this;
	var startTime     = Date.now(); // 開始時間
	var duration      = self.option.duration; // 継続時間
	var posScrollFrom = posScroll.from; // 初期位置
	var posScrollTo   = posScroll.to < 0 ? 0 : posScroll.to; // 終了位置（マイナスになるとアニメーション完了の時差が生じるのでマイナス時には0を代入）
	var changeVal     = posScrollTo - posScrollFrom; // 変動値
	var easing        = getEasingFunc(self.option.easing); // イージング関数
	var myReq         = null; // requestAnimationFrameID用
	
	/**
	 * スクロールアニメーション
	 */
	var scrollAnime = function() {
		var currentTime = Date.now() - startTime; // 経過時間
		var pos         = posScrollFrom; // スクロール位置
		
		if (currentTime > duration) {
			// タイミングによって最後の位置が変わるのでスクロール完了時に目的の位置にスクロールさせる
			scrollTo(0, posScrollTo);
			
			cancelAnimationFrame(myReq);

			// 上書きされたオプション設定を初期値に戻す
			self.option = Object.assign({}, self.firstOption);
			
			// スクロール終了後の関数が設定されていた場合、実行
			if (typeof self.option.afterFunc === 'function') {
				setTimeout(self.option.afterFunc);
			}
			return;
		}
		
		// スクロール位置設定
		pos = easing(currentTime, posScrollFrom, changeVal, duration);
		scrollTo(0, pos);
		myReq = requestAnimationFrame(scrollAnime);
	};
	
	myReq = requestAnimationFrame(scrollAnime);
}

/**
 * 実行
 */
const smScroll = new SmScroll();
smScroll.init();

const smScroll2 = new SmScroll({
	trigger: 'span',
	attr: 'data-scroll',
	duration: 300,
	positioning: function() {
		return -document.getElementById('nav').clientHeight;
	},
	easing: 'easeInExpo',
	beforeFunc: function() {
		alert('scroll start!');
	},
	afterFunc: function() {
		alert('scroll end!');
	}
});
smScroll2.init();
```

# 補足
## Object.assign()
「ES5の構文で書きます」と言っておきながら、オブジェクト同士をマージするObject.assign()メソッドはES2015からのメソッドになります。
しかしES5でも使えるようにする[ポリフィル](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Polyfill)がありますのでそちらを使用しています。


## イージングの指定
ポイントというほどでもないのですが、イージングの指定をインタンス作成時に設定できるようにしてあります。
イージング用の関数は下記サイトから拝借させて頂きました。
http://gizma.com/easing/

デモページのJSを見て頂くと分かるように、JSファイル上部にずらっと書いてあるのですが、実際はこんなに必要ないと思うので適宜取捨選択して使うのが望ましいと思います。

# 実装からの考察
- できる限り汎用化を目指してみたものの実際に汎用的になったのか疑問が残ります。
たとえば任意のトリガーのカスタムデータ属性でオプションをさらに上書きできるようにすれば、押したトリガーによってdurationやイージングが変更できたりなど、より柔軟な使い方ができたのではないかと今は思います（CSSでいうとインラインCSSで上書き的なイメージ）。

※2017/8/23加筆
**上記についてカスタムデータ属性で指定できるようにしました。**
指定できるオプションは「duration」「positioning」「easing」です。
positiningはインスタンス生成時には関数を設定できますが、カスタムデータ属性からは数値のみの指定ができます。

- prototypeメソッドで、一応機能として分けるために初期設定としてのprototype.init()と、スクロールアニメーションするprototype.move()で分けているのですが、こういった用途でのprototypeメソッドの分け方は果たしてどうなんでしょうか。
ただ可読性をあげるため、メンテナンス性をあげるため、という目的であるならばprotorypeメソッドとしてはinit()だけにしておいて、そのinit()メソッド内部で、初期設定処理、アニメーション処理の関数で分ければ事足りるような気がしています。  
本来は、インスタンス作成後に任意の時・場所でmove()メソッドを実行してスクロールを実行できる、というように切り分けるのがベストな使い方なのかなと感じています。
そういった意味では、一応、下記のような実行文でinit()を実行せずともスクロールをさせることは可能なので、やはり切り分けとしては問題ないかな…とも思っていたりします。

```js
// ページ読み込み後すぐスクロールが実行される
const smScroll = new SmScroll();
smScroll.move({
  from: 0,
  to: 500,
});
```

# 参考
- [JavaScriptで要素の位置座標を取得する方法](https://lab.syncer.jp/Web/JavaScript/Snippet/10/)
- [スクロール量を取得する－JavaScript入門](http://www.pori2.net/js/window/9.html)
- [スクロールの位置（量）を取得・設定 | JavaScript逆引き](http://shanabrian.com/web/javascript/get-scroll-position.php)
- [第17回　アニメーションの基礎：これでできる！ クロスブラウザJavaScript入門](http://gihyo.jp/dev/serial/01/crossbrowser-javascript/0017)
- [JavaScriptでアニメーションを書く初歩の初歩](http://nmi.jp/archives/510)