首页/文章/八股文

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

2025-03-07
14785 分钟
...

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

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

<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),便于框架们进行页面渲染:

window.addEventListener('hashchange', (e) => {
  e.preventDefault();
  e.stopPropagation();

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

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

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 属性:

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 函数:

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 事件,我们特殊处理一下:

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

我们改一下导航菜单:

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

其中触发的点击事件:

function showContent1() {
  
  history.pushState({}, null, "/content1");
}

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


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,让他能够触发事件:

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");

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

window.addEventListener("pushState", render);

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

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

location / { try_files $uri $uri/ /index.html; }

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

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


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

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

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
八股文2025-03-24
OPTIONS请求条件与浏览器安全策略CORS
1\. 什么时候会发送OPTIONS请求 在 Web 开发中, 请求是一种 HTTP 请求方法,通常与 CORS(跨源资源共享,Cross Origin Resource Sharing) 机制相关。它主要用于预检(preflight)请求,以检查服务器是否允许特定的跨域请求。以下是详细说明: 1.1. 发送OPTIONS请求条件 1.1.1. 跨域请求且不是简单请求 当发起的请求是跨域的(即请求的目标域名与当前页面域名不同),并且不满...
面试面试官
八股文2025-03-24
前端项目中,强缓存和协商缓存的配置
前端缓存分为HTTP缓存和 1. HTTP缓存(本文重点) 强缓存 协商缓存 2. 浏览器缓存 比较熟悉的 cookie,localstorage sessionstorage indexDB… 或者cacheStorage 请求的缓存,如果本地有取本地的 先说总结的内容 webpack配置(主要是文件名上有个hash值,便于服务器校验文件有没有修改) 使用hash的好处就是,使版本发布之后,使之前文件的强缓存失效 nginx配置 (主...
面试面试官
八股文2025-03-24
搞定前端性能优化问题
一、首屏加载优化 首屏加载是指 用户打开页面到第一个可视内容呈现出来之间的时间 。它直接影响用户对网站的第一印象,因此在前端性能优化中具有最高的优先级。 优化首屏加载的目标是 尽可能快地把关键内容展示给用户,减少白屏时间,提升首屏体验。 常见的优化方式包括资源按需加载、预加载关键资源、使用服务端渲染或静态生成等。 1\. 资源按需加载(Lazy Load) 在前端应用中,JS、CSS、图片等资源如果全部打包在一起加载,会极大拖慢页面的加...
面试面试官
八股文2025-03-19
JS宏任务,微任务,DOM渲染,requestAnimationFrame执行顺序比较
本文小结 1. 宏任务和微任务都是异步任务。 2. 优先级: 3. 慢于同步任务。本次实践中,第一次执行的时间比微任务慢,比宏任务和DOM渲染快。 来自MDN的理论部分 每个代理都是由 事件循环 驱动的,事件循环负责收集用事件(包括用户事件以及其他非用户事件等)、对任务进行排队以便在合适的时候执行回调。然后它执行所有处于等待中的 JavaScript 任务(宏任务),然后是微任务,然后在开始下一次循环之前执行一些必要的渲染和绘制操作。...
面试面试官
八股文2025-03-18
一文总结vue和react的区别
Vue.js 和 React.js 都是流行的 JavaScript 前端框架,用于构建用户界面。它们在设计理念、语法和使用方式等方面有一些区别,以下是一些比较详细的对比: 设计理念: Vue.js :Vue.js 是一款轻量级、渐进式的框架,它专注于视图层的渲染和状态管理。Vue 的核心思想是通过数据驱动视图的变化,提供了一套简洁的模板语法和响应式数据绑定机制,使得开发者可以更便捷地构建交互式的用户界面。 React.js :Reac...
面试面试官
八股文2025-03-10
最全的Map 和 WeakMap的区别
和 的区别及面试追问知识点 一、两者区别总结 (一)基本概念 和 均为 JavaScript 中用于存储键值对的数据结构,但在多个关键特性上有显著差异。 (二)键的类型限制 1. 键类型无严格限制,可接受基本数据类型(如字符串、数字、布尔值)以及引用数据类型(如对象、数组、函数)。 示例代码: 2. 键必须为对象,使用基本数据类型作为键会抛出错误。 示例代码: (三)垃圾回收机制 1. 对键和值采用强引用,只要 对象存在,其引用的键值对...
面试面试官

评论

请登录后发表评论

去登录
加载评论中...

目录