◆ URL に応じてページの切り替え

SPA ページを WebComponents で作ることはそこまでないのですが 作るたびに Router 機能をどう作るかで悩みます
URL との連携とかコンポーネントの中でやるべきか外でやるべきかとか リダイレクト処理とか色々作り方に悩む部分があります

そろそろ使い回せるものを作っておこうかなということで 外部ライブラリなどに依存しないシンプルな WebComponents で作りました
普通に HTMLElement を継承して 特別な BaseElement とかも使ってません

Router 部分コードは長くなるので最後にして 先に使い方の例です

<!DOCTYPE html>

<script type="module">
import "/router.js"

customElements.define("elem-1", class extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: "open" }).innerHTML = `
<h1>This is foo-bar element</h1>
<p>"name" parameter is "<b id="name"></b>"</p>
`
}

set name(value) {
this.shadowRoot.getElementById("name").textContent = value
}
})
</script>

<client-router>
<route-def path="/foo/{name}" element="elem-1"></route-def>
<route-def path="/foo" redirect="/foo/default"></route-def>
<route-def path="/">
<h1>path is /</h1>
<ul>
<li>
<router-link href="/foo/example">foo/example</router-link>
</li>
<li>
<router-link href="/foo">foo</router-link>
</li>
<li>
<router-link href="/no-page">invalid link</router-link>
</li>
</ul>
</route-def>
<route-def>
<h1>Page not Found</h1>
<router-link href="/">To Top</router-link>
</route-def>
</client-router>

こんな感じです

router.js をインポートすると client-router と route-def と router-link の 3 つの Custom Element が定義されます

client-router 要素の中に route-def をルートの数だけ並べます
route-def の path に条件のパスを書いて 現在の URL にマッチした route-def がアクティブになります
route-def は上から順にチェックしていくので 複数マッチするものがあると最初のものになります
path を省略すると絶対にマッチするので 最後に path を書かない route-def を用意してページが見つからない場合の表示に使えます

path には 「{name}」 のようなものを含められて そこが任意の文字にマッチします
name の部分はパラメータ名として使われます
通常は {...} の部分は 「/」 にマッチしません
「/{path1}/{path2}」 のように使えます
「/」 も含めて残り全部としたい場合は 「{name*}」 のように最後に 「*」 を入れます
「*」 があると 「/」 も含めた全てにマッチします
また {...} がマッチするのは 1 文字以上である必要があります
「/aaa/{bbb}」 で bbb が空文字の 「/aaa/」 にもマッチさせるなら 「{name?}」 のように最後に 「?」 を入れます
「*」 と 「?」 を両方使う場合は順番はどっちが先でも大丈夫です

アクティブな route-def が決まると その route-def に element 属性がある場合は そこに指定された名前の要素を表示します
route-def に redirect 属性がある場合は 指定された URL にリダイレクトします
route-def に子要素がある場合はそれを表示します
element 属性を使う場合のみ その要素のプロパティにルートにマッチしたパラメータがセットされます

router-link は a タグの代わりに使います
href に a タグ同様リンク先を指定します
これを使ったリンクはページ遷移が発生せずに history.pushState で URL 書き換え後に client-router が再度マッチするルートの確認と変更を行います

JavaScript の処理でページを切り替えたいなら router.js から navigate をインポートして呼び出せば使えます

import { navigate } from "./router.js"

button1.addEventListener("click", () => {
navigate("/foo/bar")
})

1 つしかない URL と対応してるものなので client-router ごとに navigate はありません
全体で一つです
navigate を実行後は connected な client-router すべてに通知されます

router.js

router.js のコードはこれです

const navigator = {
redirect(url) {
history.replaceState(null, "", url)
this.notify()
},
navigate(url) {
history.pushState(null, "", url)
this.notify()
},
notify() {
for (const router of this.routers) {
router.switch()
}
},
listen(router) {
this.routers.add(router)
},
unlisten(router) {
this.routers.delete(router)
},
routers: new Set(),
}

window.addEventListener("popstate", () => {
navigator.notify()
})

export const navigate = (url) => navigator.navigate(url)

export class ClientRouter extends HTMLElement {
connectedCallback() {
navigator.listen(this)
Promise.resolve().then(() => {
this.switch()
})
}

disconnectedCallback() {
navigator.unlisten(this)
}

switch = (reload) => {
let route, params
for (const r of this.children) {
const p = r.match(location.pathname)
if (p) {
route = r
params = p
break
}
}

if (!reload && this.route === route) return

if (this.route && this.contains(this.route)) {
this.route.deactivate()
}
this.route = route
if (route) {
route.activate(params)
}
}
}

customElements.define("client-router", ClientRouter)

export class RouteDef extends HTMLElement {
get path() {
return this.getAttribute("path")
}

get redirect() {
return this.getAttribute("redirect")
}

get element() {
return this.getAttribute("element")
}

match(path) {
if (!this.path) return {}
const q = this.path[0] === "/" ? this.path : "/" + this.path

let re_str = ""
const names = []
for (const [index, part] of q.split(/\{(.*?)\}/).entries()) {
if (index % 2 === 0) {
re_str += part.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")
} else {
const [, name, flags] = part.match(/^\s*(.*?)([\?\*]*)\s*$/)

if (flags.includes("*")) {
if (flags.includes("?")) {
re_str += "(.*)"
} else {
re_str += "(.+)"
}
} else {
if (flags.includes("?")) {
re_str += "([^/]*?)"
} else {
re_str += "([^/]+?)"
}
}
names.push(name)
}
}
const result = new RegExp("^" + re_str + "$").exec(path)
if (!result) return null

return Object.fromEntries(names.map((e, i) => [e, result[i + 1]]))
}

activate(params) {
if (this.element) {
const elem = document.createElement(this.element)
this.shadowRoot.innerHTML = ""
this.shadowRoot.append(elem)
Object.assign(elem, params)
} else if (this.redirect) {
navigator.redirect(this.redirect)
} else {
this.shadowRoot.innerHTML = `<slot></slot>`
}
}

deactivate() {
this.shadowRoot.innerHTML = ""
}

constructor() {
super()
this.attachShadow({ mode: "open" })
}
}

customElements.define("route-def", RouteDef)

export class RouterLink extends HTMLElement {
get href() {
return this.getAttribute("href")
}

set href(value) {
this.setAttribute("href", value)
this.a.href = value
}

constructor() {
super()
this.attachShadow({ mode: "open" })
this.shadowRoot.innerHTML = `
<a><slot></slot></a>
`
this.a = this.shadowRoot.querySelector("a")
this.a.href = this.href
this.a.addEventListener("click", (eve) => {
eve.preventDefault()
navigator.navigate(this.href)
})
}
}

customElements.define("router-link", RouterLink)