从 0 到 1 使用 WebComponent 实现简易 Router

952 阅读1分钟

Vue Router

  • Vue Router 是 Vue.js 官方提供的路由管理器,它可以实现前端路由,使得在单页应用中不同的页面可以通过路由进行切换,而无需刷新整个页面
  1. 安装Vue Router:
npm install vue-router
  1. 创建路由实例:
import Vue from 'vue'
import Router from 'vue-router'
import Home from '@/views/Home.vue'

Vue.use(Router)

const router = new Router({
  routes: [
    {
      // path 配置路径,component 配置路径显示的组件
      path: '/',
      name: 'Home',
      component: Home
    }
  ]
})
  1. 在 Vue 实例中使用路由:
import Vue from 'vue'
import router from './router'

new Vue({
  router,
  render: h => h(App)
}).$mount('#app')
  1. 在模板中使用路由:
<template>
  <div>
		<!-- 链接:router-link 实现页面跳转,to 指定路径 -->
    <router-link to="/">Home</router-link>
		<!-- 容器:路由匹配到的组件会渲染在此处 -->
    <router-view></router-view>
  </div>
</template>

React Router

  • React Router 是一个流行的路由库,用于管理 React 应用程序中的路由
  1. 安装 React Router
npm install react-router-dom
  1. 引入 React Router 组件
import { BrowserRouter, Switch, Route, Link } from 'react-router-dom';
  1. 在 BrowserRouter 组件中定义路由规则
// BrowserRouter 容器包裹 Route 路由
<BrowserRouter>
  <Switch>
    <Route exact path="/" component={Home} />
    <Route path="/about" component={About} />
    <Route path="/contact" component={Contact} />
  </Switch>
</BrowserRouter>
  1. 在组件中使用 Link 组件来生成链接
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/contact">Contact</Link>

使用 WebComponent 实现路由

一个简单的路由需要有

  • 容器组件
  • 路由
  • 业务组件
  • 链接组件

使用 WebComponent 技术在浏览器的 URL 变化时,动态地加载对应的组件,并在页面中显示。这段代码分为以下几部分

  • 重写 pushState 方法,当浏览器的 URL 发生变化时,自定义一个 c-popstate 事件并派发
  • 自定义链接组件 <c-link>,监听点击事件,调用 history.pushState 方法实现页面跳转
  • 自定义路由组件 <c-route>,可以获取路由信息,用于匹配当前页面的路由
  • 自定义组件容器 <c-component>,可以动态加载组件并解析,将组件的模板、脚本和样式添加到当前页面中
  • 自定义路由容器 <c-router>,监听路由变化,根据当前路由渲染对应的组件内容
  • loadComponent 函数 用于动态加载组件并解析缓存

组件示意

index.js

// 保存原始的 pushState 方法
const originalPushState = history.pushState

// 重写 pushState 方法
history.pushState = function (state, title, url) {
	// 调用原始的 pushState 方法
	originalPushState.apply(history, [state, title, url])
	// 触发自定义事件
	const event = new CustomEvent('c-popstate', {
		detail: { state, title, url },
	})
	window.dispatchEvent(event)
}

// 自定义链接组件
class CustomLink extends HTMLElement {
	connectedCallback() {
		this.addEventListener('click', event => {
			event.preventDefault()
			const url = this.getAttribute('to')
			// 更新浏览器历史记录
			history.pushState('', '', url)
		})
	}
}

window.customElements.define('c-link', CustomLink)

// 自定义路由组件
class CustomRoute extends HTMLElement {
	// 获取路由信息
	getData() {
		return {
			default: this.hasAttribute('default'),
			path: this.getAttribute('path'),
			component: this.getAttribute('component'),
		}
	}
}
window.customElements.define('c-route', CustomRoute)

// 自定义组件容器
class CustomComponent extends HTMLElement {
	async connectedCallback() {
		// 获取组件的路径
		const path = this.getAttribute('path')
		// 加载组件
		const componentInfo = await loadComponent(path)
		const shadowRoot = this.attachShadow({ mode: 'closed' })
		// 添加组件内容
		this.#addElements(shadowRoot, componentInfo)
	}

	// 添加组件内容
	#addElements(shadowRoot, componentInfo) {
		// 添加模板内容
		if (componentInfo.template) {
			shadowRoot.appendChild(componentInfo.template.content.cloneNode(true))
		}
		// 添加脚本
		if (componentInfo.script) {
			// 防止全局污染,并获得根节点
			const fun = new Function(`${componentInfo.script.textContent}`)
			// 绑定脚本的 this 为当前的影子根节点
			fun.bind(shadowRoot)()
		}
		// 添加样式
		if (componentInfo.style) {
			shadowRoot.appendChild(componentInfo.style)
		}
	}
}
window.customElements.define('c-component', CustomComponent)

// 自定义路由容器
class CustomRouter extends HTMLElement {
	// 私有变量
	#routes

	connectedCallback() {
		// 获取所有c-route子节点
		const routeNodes = this.querySelectorAll('c-route')

		// 获取子节点的路由信息
		this.#routes = Array.from(routeNodes).map(node => node.getData())

		// 查找默认的路由
		const defaultRoute = this.#routes.find(r => r.default) || this.#routes[0]

		// 渲染对应的路由
		this.#onRenderRoute(defaultRoute)

		// 监听路由变化
		this.#listenHistory()
	}

	// 渲染路由对应的内容
	#onRenderRoute(route) {
		const el = document.createElement('c-component')
		el.setAttribute('path', `/${route.component}.html`)
		el.id = '_route_'
		this.append(el)
	}

	// 卸载路由清理工作
	#onUnloadRoute() {
		const el = this.querySelector('#_route_')
		if (el) {
			el.remove()
		}
	}

	// 监听路由变化
	#listenHistory() {
		// 导航的路由切换
		window.addEventListener('popstate', ev => {
			const url = location.pathname.endsWith('.html') ? '/' : location.pathname
			const route = this.#getRoute(this.#routes, url)
			this.#onUnloadRoute()
			this.#onRenderRoute(route)
		})

		// pushState或者replaceState
		window.addEventListener('c-popstate', ev => {
			const detail = ev.detail
			const route = this.#getRoute(this.#routes, detail.url)
			this.#onUnloadRoute()
			this.#onRenderRoute(route)
		})
	}

	// 路由查找
	#getRoute(routes, url) {
		return routes.find(r => {
			const path = r.path
			const strPaths = path.split('/')
			const strUrlPaths = url.split('/')

			let match = true
			for (let i = 0; i < strPaths.length; i++) {
				if (strPaths[i].startsWith(':')) {
					continue
				}
				match = strPaths[i] === strUrlPaths[i]
				if (!match) {
					break
				}
			}
			return match
		})
	}
}
window.customElements.define('c-router', CustomRouter)

// 动态加载组件并解析
async function loadComponent(path, name) {
	// 初始化缓存对象
	loadComponent.caches = loadComponent.caches || {}

	// 缓存存在,直接返回
	if (loadComponent.caches[path]) {
		return loadComponent.caches[path]
	}

	// 获取组件内容
	const res = await fetch(path).then(res => res.text())

	// 利用DOMParser解析HTML
	const parser = new DOMParser()
	const doc = parser.parseFromString(res, 'text/html')

	// 解析模板、脚本和样式
	const template = doc.querySelector('template')
	const script = doc.querySelector('script')
	const style = doc.querySelector('style')
	// 缓存内容
	loadComponent.caches[path] = {
		template,
		script,
		style,
	}
	// 返回内容
	return loadComponent.caches[path]
}

测试使用

index.html

<!DOCTYPE html>
<html>
  <head>
    <style>
      c-link {
        text-decoration: underline;
        cursor: pointer;
        margin-right: 10px;
      }
    </style>
  </head>
  <body>
    <ul>
      <c-link to="/">首页</c-link>
      <c-link to="/about">关于</c-link>
    </ul>

    <c-router>
      <c-route path="/" component="home" default></c-route>
      <c-route path="/about" component="about"></c-route>
      <c-route path="/detail/:id" component="detail"></c-route>
    </c-router>

    <script src="./index.js"></script>
  </body>
</html>

about.html

<template>
   About me!
</template>

home.html

<template>
  <h3>商品清单</h3>
  <div id="product-list">
    <a data-id="10" class="product-item c-link">香蕉</a>
    <a data-id="11" class="product-item c-link">苹果</a>
    <a data-id="12" class="product-item c-link">香蕉</a>
  </div>
</template>

<script>
  // this 指向自身的 shadow-root
  let container = this.querySelector('#product-list')

  // 代码触发更新历史
  container.addEventListener('click', function (e) {
    if (e.target.classList.contains('product-item')) {
      const id = +e.target.dataset.id
      history.pushState(
        {
          id
        },
        '',
        `/detail/${id}`
      )
    }
  })
</script>

<style>
  .product-item {
    cursor: pointer;
    color: blue;
  }
</style>

detail.html

<template>
  <h3>商品详情</h3>
  <div>商品ID:<span id="product-id"></span></div>
</template>

<script>
  this.querySelector('#product-id').textContent = history.state.id
</script>

<style>
  #product-id {
    color: green;
  }
</style>