SPA 两种路由模式的实现原理

Web 应用的路由跳转是通过判断 url 的变化来识别的,浏览器根据不同的 url 来选择加载不同的页面资源。现有的路由工具,也都是从不同的角度来解析 url 来实现其功能的。

单页应用 (SPA) 大致有 hash路由 和 history路由 两种路由模式。前者通过识别浏览器自带的锚点来判断资源,后者利用 h5 的 history API,通过解析嵌套子路径来识别资源位置。

Hash

一个完整的网页 URL 包括:协议、域名、端口、虚拟目录、文件名、参数、锚点。其中锚点以 # 开始,一般放在 URL 的最后,其后所有字符串全部都是锚点内容。比如:

https://uswelcome.dadmin.com:80/#/welcome/hello

其锚点部分就是 #/welcome/hello

我们实现 hash 路由的思路如下:

  1. 监听页面 hash 变化,拿到新旧路由值及其组件资源
  2. 卸载旧路由组件
  3. 挂载新路由组件
  4. 初始化页面时判断是否带有 hash

浏览器从比较原始的版本就已经支持同页面的锚点滚动:

html 复制代码
<header>
    <a href="/">首页</a>
    <a href="#content1">内容1</a>
    <a href="#content2">内容2</a>
    <a href="#content3">内容3</a>
</header>

Hash 路由演示
<section id="content1">
我是内容1
</section>
<section id="content2">
我是内容2
</section>
<section id="content3">
我是内容3
</section>

在点击 a 标签时,页面会定位到 id 是该锚点的地方。hash 路由利用了这个使用习惯,但这不是重点,重点是他会触发一个事件:hashchange,每次路由改变都会触发该事件,并提示新旧路由信息(oldURL,newURL),便于框架们进行页面渲染:

js 复制代码
window.addEventListener('hashchange', (e) => {
  e.preventDefault();
  e.stopPropagation();

  render(e.oldURL, e.newURL);
});

为了演示路由组件卸载和挂载,我们加一个过渡的动画:

css 复制代码
header {
  height: 50px;
  width: 100vw;
  border-bottom: 1px solid black;
}

section {
  display: none;
}

.show-router {
  animation: show 1s;
}

@keyframes show {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}

使用 display: none; 来表示未挂载的组件,挂载后就取消其 display 属性:

js 复制代码
const routerLeave = hash => {
  const hideDOM = document.querySelector(hash);

  if (hideDOM) {
    hideDOM.classList.remove('show-router');
    hideDOM.style.display = 'none'
  }
}

const routerEnter = hash => {
  const showDOM = document.querySelector(hash);

  if (showDOM) {
    showDOM.style.display = 'unset'
    showDOM.classList.add('show-router');
  }
}

有了挂载和卸载功能后,我们就写一下 render 函数:

js 复制代码
const render = (oldURL, newURL) => {
  const oldHash = oldURL.split('#')[1];
  const newHash = newURL.split('#')[1];

  if (oldHash) {
    routerLeave(`#${oldHash}`);
  }

  if (newHash) {
    routerEnter(`#${newHash}`);
  }
}

到这里,切换路由时,组件就能正确渲染了。我们将该文件命名为 index.html, 本地启动一个 http-server查看:

组件正确显示,过渡动画也上去了。

现在还有一个问题,就是刷新页面组件丢失,因为刷新页面不会触发 hashchange 事件,我们特殊处理一下:

js 复制代码
window.onload = () => {
  const hash = window.location.hash;
  if (hash) {
    routerEnter(hash);
  }
}

到这里,hash 路由的实现就完整了。

History

History 路由实现原理类似,主要是用了 pushStatereplaceState API 来实现。思路如下:

  1. 调用 history API 实现路由变化
  2. 维护路由历史
  3. 监听路由变化
  4. 卸载旧路由组件
  5. 挂载新路由组件
  6. 初始化页面时设置服务器拦截,引导到 index.html

我们改一下导航菜单:

html 复制代码
<header>
    <a href="/">首页</a>
    <a href="javascript:showContent1();">content1</a>
    <a href="javascript:showContent2();">content2</a>
    <a href="javascript:showContent3();">content3</a>
</header>

其中触发的点击事件:

js 复制代码
function showContent1() {
  // 第一个参数使用 history.state 可以访问. 但刷新页面会丢失
  history.pushState({}, null, "/content1");
}

此时页面 url 就变化了。但是页面却不会渲染,我们要自己写渲染函数:

js 复制代码
// 维护一个路由栈
const routerStack = [];

const render = () => {
  const router = window.location.pathname.slice(1);
  if (router) {
    // 让上个路由隐藏
    const routerLength = routerStack.length;
    if (routerLength) {
      routerLeave(`#${routerStack[routerLength - 1]}`);
    }

    // 让当前路由显示
    routerEnter(`#${router}`);
    routerStack.push(router);
  }
}

window.addEventListener("popstate", render);

但是你会发现,在路由切换时不起作用,因为 pushState 不会触发事件。popstate 事件仅在浏览器使用前进/后退按钮或调用 history.back / history.forward / history.go 方法时触发。所以我们需要劫持一下pushState,让他能够触发事件:

js 复制代码
const wrapState = (action) => {
  // 获取原始定义
  const raw = history[action];
  return function () {
    const wrapper = raw.apply(this, arguments);

    const e = new Event(action);

    e.stateInfo = { ...arguments };
    window.dispatchEvent(e);
    return wrapper;
  }
}

history.pushState = wrapState("pushState");

此时,我们就可以这样写了:

js 复制代码
window.addEventListener("pushState", render);

当 pushState 的时候,触发渲染,隐藏旧的组件,显示新的组件:

接下来还剩一个问题:原地刷新路由丢失 404 的问题。这个就是老生常谈的问题了,通用的解决方案是配置服务器代理,以 nginx 为例:

js 复制代码
location / { try_files $uri $uri/ /index.html; }

由于是单页应用,只有一个入口,告诉服务器在找不到页面时使用 index.html。

上面代码有很多可扩展的地方,比如在 routerLeave 和 routerEnter 的地方做路由守卫等。


上面的简易实现,没有考虑 hash/router 的复杂情况,没有考虑参数,他只是对第一级 hash 或路由 进行拆分来说明原理,routerStack 也没有清理机制,组件有样式的污染、浏览器兼容处理等。

在具体的 SPA 框架实现时要复杂得多,他是一个统一的入口 <div id="app">,通过路由机制获取到要显示在页面上的资源,最后 app.innerHTML = '资源',在构建资源的过程中,可能需要使用虚拟 DOM,并在自定义的调度周期的特定时刻统一渲染、处理 popstate 异步顺序问题等。

相关推荐
前端OnTheRun13 天前
新闻客户端案例的实现,使用axios获取数据并渲染页面,路由传参(查询参数,动态路由),使用keep-alive实现组件缓存
vue.js·axios·vue2·路由·vue-router
萌萌哒草头将军1 个月前
😡😡😡早知道有这两个 VueRouter 增强插件,我还加什么班!🚀🚀🚀
前端·vue.js·vue-router
前端开发同学1 个月前
Vue路由三体法则:query是青铜,params像王者,而props才是隐藏的降维打击!
vue-router
申小兮1 个月前
Vue Router(二)
前端·vue.js·vue-router
申小兮1 个月前
Vue Router
前端·vue.js·vue-router
谎言西西里2 个月前
掌握 Vue Router:构建动态单页应用的导航利器🫡
前端·vue-router
86Eric2 个月前
Vue 使用 vue-router 时,多级嵌套路由缓存问题处理
前端·vue.js·vue-router·vue 路由缓存·多级菜单缓存
小刀飘逸2 个月前
vue-router到底有什么用?(解惑篇)
vue.js·vue-router
belldeep2 个月前
vue3:初学 vue-router 路由配置
前端·javascript·vue.js·vue-router
Aphasia3113 个月前
Vue全家桶之一——Vue Router🧑🏻‍💻
前端·vue-router