WebComponents でルーターを書いてみる。


History API があるんだから、WebComponents ベースで SPA 作ってるときはルーターは自前で書いちゃえばいいんじゃない？

ということで、約100行のシンプルなルーターを書いてみました。

* Lazy Loading
* Inner HTML

にも対応しています

# 例

```index.html
<li route=/>Home</li>
<li route=/img/https%3A%2F%2Fupload.wikimedia.org%2Fwikipedia%2Fcommons%2Fthumb%2Fa%2Fac%2FJordens_inre.jpg%2F230px-Jordens_inre.jpg>Sample image</li>

<jp-route
    path=/
    title=Home
    data="<b>HOME</b>"
    spec=html
></jp-route>

<jp-route
    path=/img/:src
    title=Image
    data="img"
    spec=tag
></jp-route>

<script type=module src=./jp-router.js></script>
<jp-router></jp-router>
```

こんな html を書いておいて

* `Home` をクリックしたら

![スクリーンショット 2020-05-09 7.28.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73493/bbb50691-60a9-d16f-2a1a-e07a5b00364c.png)

* `Sample image` をクリックしたら

![スクリーンショット 2020-05-09 7.28.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73493/8ae183cb-0581-52c0-a9df-26d314d93bcb.png)

となるようにしたい。また<b>ブラウザのバック／フォワードボタンが効く</b>ようにしたい。

# 実装

```jp-router.js
const
PathSegs = uri => uri.split( '/' ).filter( $ => $.length )

const
Key = $ => {
	const _ = /^:(.+)/.exec( $ )
	return _ ? _[ 1 ] : _
}

const
Match = ( route, data ) => {
	const rSegs = PathSegs( route )
	const dSegs = PathSegs( data )

	if ( rSegs.length != dSegs.length ) return null

	const _ = rSegs.flatMap( ( $, _ ) => $ == dSegs[ _ ] ? [] : [ [ Key( $ ), decodeURIComponent( dSegs[ _ ] ) ] ] )
	return _.every( $ => $[ 0 ] ) ? Object.fromEntries( _ ) : null
}

class
Router extends HTMLElement {

	connectedCallback() {
		this.UpdateLinks()
		window.onpopstate = ev => this.Navigate( location.pathname )
		window.onpopstate()
	}

	disconnectedCallback() {
		window.onpopstate = null
	}

	UpdateLinks() {
		document.querySelectorAll( '[route]' ).forEach(
			$ => $.onclick = ev => {
				const route = $.getAttribute( 'route' )
				this.Navigate( route )
				history.pushState( null, null, route )
			}
		)
	}

	Navigate( url ) {

		const routes = Array.from( document.querySelectorAll( 'jp-route' ) ).map(
			$ => (
				{	path	: $.getAttribute( 'path'	)
				,	title	: $.getAttribute( 'title'	)
				,	data	: $.getAttribute( 'data'	)
				,	spec	: $.getAttribute( 'spec'	)
				}
			)
		)
		routes.forEach( $ => $.params = Match( $.path, url.split( '?' )[ 0 ] ) )
		const _ = routes.filter( $ => $.params )

		switch ( _.length ) {
		case 1:
			{	const { path, title, data, spec, params } = _[ 0 ]

				while ( this.firstChild ) this.removeChild( this.firstChild )

				document.title = title || url

				const
				AttachView = view => {
					for ( let key in params ) view.setAttribute( key, params[ key ] )
					this.appendChild( view )
					this.UpdateLinks()
				}

				switch ( spec ) {
				case 'html':
					this.innerHTML = data
					AttachView( this.firstChild )
					break
				case 'tag':
					AttachView( document.createElement( data ) )
					break
				case 'source':
					import( data ).then( $ => AttachView( new $.default() ) )
					break
				}
			}
			break
		case 0:
			document.title = '404'
			this.innerHTML = '404 Page not found. URL: ' + url
			break
		default:
			document.title = 'MULTIPLE ROUTE'
			this.innerHTML = 'Internal logic error: MULTIPLE ROUTE, see console'
			console.error( 'MULTIPLE ROUTE', JSON.stringify( _ ) )
			break
		}
	}
}

customElements.define( 'jp-router', Router )
```

# 解説

`window`の`onpopstate`をトラップします。

`Document`中の`route`という属性を持ってる`HTML`要素の`onclick`を以下のように設定します。

* `route`属性とマッチする`path`属性を持つ`jp-route`を探し、その`jp-route`の内容を作成して、自分の子エレメントにします。
* `History API`の`pushState`を使って、ブラウザの履歴に登録します。（`pushState`の2番目のパラメータは履歴に現れる`title`ですが、`null`にしておくと、その時の`title`を設定してくれます。）


### マッチとパラメータ

|route属性|path属性|マッチ|パラメータ|
|---|---|---|---|
|/a/b|/a/b/|true|{}|
|/a/b|/a/|false|-|
|/img/:src|/img/sample|true|{src:'sample'}|

パラメータは作成された`HTML`要素の属性としてセットします。


# NPM

これで十分って方のために、NPM に登録しておきました。
https://www.npmjs.com/package/@satachito/jp-router

```sh
$ npm i @satachito/jp-router --save
```

`node_modules/@satachito/jp-router/demo/index.html`に`Lazy Loading`など他のパターンも入れてありますので、よかったら参考にしてください。