【浅入浅出】前端单页面路由实现原理

简介: 【浅入浅出】前端单页面路由实现原理

上一节了解了单页面,那么你知道前端单页面路由是怎么实现的吗?

说真的,我也不知道

9b13bb0ab4b54c7abe376839cbab2fb2.png

接下来一起学习一下。



为什么 SPA 需要路由系统


因为缺少路由系统的 SPA 框架有其存在的弊端:


  1. 用户在使用过程中,url 不会发生变化,那么用户在进行多次跳转之后,如果一不小心刷新了页面,又会回到最开始的状态,用户体验极差。
  2. 由于缺乏路由,不利于 SEO,搜索引擎进行收录。


主流的前端路由系统是通过 hash 或 history 来实现的,下面先看看 Hash 模式


Hash 路由

hash 就是指 url 后的 # 号以及后面的字符。例如:https://blog.csdn.net/#kaimo313,那么 #kaimo313 就是 hash 值。


我们知道 url 上的 hash 本意是用来作锚点的,方便用户在一个很长的文档里进行上下的导航。


为什么 hash 能实现路由?


这是因为 hash 有一点很特殊:改变 url 的 hash 时,不会刷新页面,但是会触发相应的 hashchange 回调函数,使得 hash 能用来做路由控制。



实现改变 hash 刷新页面局部


下面我们实现一个功能:通过改变 hash,去改变 body 的背景色

效果如下:

image.gif



实现的代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>hash</title>
</head>
<body>
  <ul>
    <li><a href="#/">turn white</a></li>
    <li><a href="#/blue">turn blue</a></li>
    <li><a href="#/green">turn green</a></li>
  </ul>
  <script>
    // 改变 body 颜色
    function changeBodyColor(color) {
      document.body.style.backgroundColor = color;
    }
    function Router() {
      this.routes = {};  // routes 用来存放不同路由对应的回调函数
      this.currentUrl = "";
    }
    // 每个不同的hash值,对应不同的函数调用处理。
    Router.prototype.route = function(path, callback) {
      this.routes[path] = callback || function() {};
    }
    Router.prototype.refresh = function() {
      console.log('触发一次 hashchange,hash 值为:', location.hash);
      this.currentUrl = location.hash.slice(1) || '/';
      this.routes[this.currentUrl]();
    }
    // init 用来初始化路由,在 load 事件发生后刷新页面,并且绑定 hashchange 事件,当 hash 值改变时触发对应回调函数
    Router.prototype.init = function() {
      // 页面在第一次加载时并不会触发hashchang事件,只有改变了hash值后才会触发。
      window.addEventListener("load", this.refresh.bind(this), false);
      window.addEventListener("hashchange", this.refresh.bind(this), false);
    }
    let hashRouter = new Router();
    hashRouter.init();
    hashRouter.route("/", function() {
      changeBodyColor("white");
    })
    hashRouter.route("/blue", function() {
      changeBodyColor("blue");
    })
    hashRouter.route("/green", function() {
      changeBodyColor("green");
    })
  </script>
</body>
</html>


或者使用类似发布订阅模式的方式,使用 ES6 的 class 实现:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <ul>
    <li><a href="#/">turn white</a></li>
    <li><a href="#/blue">turn blue</a></li>
    <li><a href="#/green">turn green</a></li>
  </ul>
  <script>
    class Router {
      constructor() {
        this.routes = {}; 
        this.currentUrl = "";
      }
      // 改变 body 颜色
      changeBodyColor(color) {
        document.body.style.backgroundColor = color;
      }
      route(path, callback) {
        this.routes[path] = callback || function() {};
      }
      refresh() {
        console.log('触发一次 hashchange,hash 值为:', location.hash);
        this.currentUrl = location.hash.slice(1) || '/';
        this.routes[this.currentUrl]();
      }
      // init 里面需要添加 load hashchange 事件,触发刷新方法
      init() {
        window.addEventListener("load", this.refresh.bind(this), false);
        window.addEventListener("hashchange", this.refresh.bind(this), false);
      }
    }
    let hashRouter = new Router();
    hashRouter.init();
    hashRouter.route("/", function() {
      hashRouter.changeBodyColor("white");
    })
    hashRouter.route("/blue", function() {
      hashRouter.changeBodyColor("blue");
    })
    hashRouter.route("/green", function() {
      hashRouter.changeBodyColor("green");
    })
  </script>  
</body>
</html>



25d6d67b2e024dd881de2d4155fd338b.png


我们可以发现 hash 模式的情况下 url 上会有一个 # 号,很不美观,有没有什么解决方案?答案就是使用 history 模式。


那么什么是 history 模式?



History 路由

我们可能使用过 history 的部分 api 比如:

history.go(-1);       // 后退一页
history.go(2);        // 前进两页
history.forward();     // 前进一页
history.back();      // 后退一页


在 HTML5 规范中,history 新增了以下几个 API

history.pushState();         // 添加新的状态到历史状态栈
history.replaceState();     // 用新的状态代替当前状态
history.state             // 返回当前状态对象

下面具体了解一下 pushState,state,replaceState,popstate 事件。



pushState

History.pushState() 方法用于在历史中添加一条记录。

window.history.pushState(state, title, url)


state:一个与添加的记录相关联的状态对象,主要用于popstate事件。该事件触发时,该对象会传入回调函数。也就是说,浏览器会将这个对象序列化以后保留在本地,重新载入这个页面的时候,可以拿到这个对象。如果不需要这个对象,此处可以填null。


title:新页面的标题。但是,现在所有浏览器都忽视这个参数,所以这里可以填空字符串。


url:新的网址,必须与当前页面处在同一个域。浏览器的地址栏将显示这个网址。


举个例子:

假定当前网址是 history.html,使用 pushState() 方法在浏览记录(History 对象)中添加一个新记录。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>history1 页面</title>
</head>
<body>
  <script>
    var stateObj = { foo: 'bar' };
    history.pushState(stateObj, 'page 2', 'history2.html');
  </script>
</body>
</html>


我们发现添加新记录后,浏览器地址栏立刻显示 history2.html,但并不会跳转到 history2.html,甚至也不会检查 history2.html 是否存在,它只是成为浏览历史中的最新记录。


df280a40164b4249aba7b34de7b9bae3.png


然后我们在 url 栏里输入 https://blog.csdn.net/kaimo313 我的博客地址,回车之后进入到我的博客之后,在点击返回上一次浏览的页面


109b42f8b29844e4818b0e88ef3c0cf5.png


我们会发现,此时的页面已经变成了 history2.html


ca56e9c9b25c48d282b39412f117a382.png



总之,pushState() 方法不会触发页面刷新,只是导致 History 对象发生变化,地址栏会有反应。

注意:

如果 pushState 想要插入一个跨域的网址,导致报错。这样设计的目的是,防止恶意代码让用户以为他们是在另一个网站上,因为这个方法不会导致页面跳转。


state

使用 pushState 方法之后,就可以用 History.state 属性读出状态对象。


var stateObj = { foo: 'bar' };
history.pushState(stateObj, 'page 2', 'history2.html');
console.log('history.state:', history.state);


3638d5bd213f4943bf8c16993dd66857.png




replaceState


History.replaceState() 方法用来修改 History 对象的当前记录,其他都与 pushState() 方法一模一样。


我们来举个例子:假定当前网页是 kaimo.html


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>kaimo</title>
</head>
<body>
</body>
</html>

f33ebfcccda44e28959593fc937005a1.png


第1步执行: URL 显示为 http://127.0.0.1:5500/kaimo.html?page=1

history.pushState({page: 1}, 'kaimo 1', '?page=1')


207ac7f97bdc4d36a171398adada4d94.png


第2步执行: URL 显示为 http://127.0.0.1:5500/kaimo.html?page=2

history.pushState({page: 2}, 'kaimo 2', '?page=2')


54ea6fd002134c1f99095a36b2c47748.png


第3步执行: URL 显示为 http://127.0.0.1:5500/kaimo.html?page=3

history.replaceState({page: 3}, 'kaimo 3', '?page=3')

9013c38c7e594e0b9013427342c597c2.png


第4步执行: URL 显示为 http://127.0.0.1:5500/kaimo.html?page=1,回退之后变成page=1了。

history.back()

661a1e24fe304059847239682bb28ac4.png


第5步执行: URL 显示为 http://127.0.0.1:5500/kaimo.html,再次回退之后变成初始页了。

history.back()

ad2f76440ff3468d9c3b4cca218c6396.png


第6步执行: URL 显示为 http://127.0.0.1:5500/kaimo.html?page=3

history.go(2) // 前进两页

6d50848e42d8445cb7d71cd117e5b4cf.png



popstate 事件


每当同一个文档的浏览历史(即history对象)出现变化时,就会触发popstate事件。


   注意,仅仅调用 pushState() 方法或 replaceState() 方法 ,并不会触发该事件,只有用户点击浏览器倒退按钮和前进按钮,或者使用 JavaScript 调用 History.back()、History.forward()、History.go() 方法时才会触发。另外,该事件只针对同一个文档,如果浏览历史的切换,导致加载不同的文档,该事件也不会触发。


使用的时候,可以为popstate事件指定回调函数。


window.onpopstate = function (event) {
  console.log('location: ' + document.location);
  console.log('state: ' + JSON.stringify(event.state));
};
// 或者
window.addEventListener('popstate', function(event) {
  console.log('location: ' + document.location);
  console.log('state: ' + JSON.stringify(event.state));
});


举个例子:比如从 kaimo.html 跳转到 kaimo.html?page=1

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>kaimo</title>
</head>
<body>
  <script>
    window.addEventListener('popstate', function(event) {
      console.log('location: ' + document.location);
      console.log('state: ' + JSON.stringify(event.state));
      console.log('state2: ' + JSON.stringify(history.state));
    });
  </script>
</body>
</html>


27bb37f17be74138b2c94db81a5b311a.png



回调函数的参数是一个 event 事件对象,它的 state 属性指向 pushState 和 replaceState 方法为当前 URL 所提供的状态对象(即这两个方法的第一个参数)。这个 state 对象也可以直接通过 history.state 对象读取。


注意,页面第一次加载的时候,浏览器不会触发popstate事件。


思路分析


通过上面的分析我们知道 history.pushState 或者 history.replaceState 也能做到改变 url 的同时,不会刷新页面。


hash 的改变会触发 onhashchange 事件,但是我们无法监听到 history 的改变事件。


那有没有什么方法实现?


答案是有的,如果我们能罗列出所有可能改变 history 的途径,然后在这些途径 一 一 进行拦截,也一样相当于监听了 history 的改变。


url 的改变只能由以下 3 种途径引起:


   点击浏览器的前进或者后退按钮;

   点击 a 标签;

   在 JS 代码中直接修改路由


对于第 1 种,HTML5 规范中新增了一个 onpopstate 事件,通过它便可以监听到前进或者后退按钮的点击。


对于第 2 和 第 3 种,可以看成是一种,因为 a 标签的默认事件可以被禁止,进而调用 JS 方法。

思路图:


c98d4af275e9417fb4042c912901f31c.png



代码实现

下面是我写的简易版本,实现了三种途径修改 url 渲染页面的效果

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>history 路由</title>
</head>
<body>
  <ul>
    <li><a href="/kaimo1">kaimo 1</a></li>
    <li><a href="/kaimo2">kaimo 2</a></li>
    <li><a href="/kaimo3">kaimo 3</a></li>
  </ul>
  <hr>
  <button onclick="kaimoBtnClick('/kaimo1')">kaimo 1</button>
  <button onclick="kaimoBtnClick('/kaimo2')">kaimo 2</button>
  <button onclick="kaimoBtnClick('/kaimo3')">kaimo 3</button>
  <hr>
  <div id="route-view"></div>
  <script>
    class HistoryRouter {
      constructor() {
        this.routeView = document.getElementById('route-view');
      }
      init() {
        // 处理途径1
        window.addEventListener("DOMContentLoaded",() => {
          // 获取所有带 href 属性的 a 标签节点
          var aList = document.querySelectorAll('a[href]')
          // 遍历 a 标签节点数组,阻止默认事件,添加点击事件回调函数
          aList.forEach(aNode => aNode.addEventListener('click', e => {
            e.preventDefault() // 阻止a标签的默认事件
            var href = aNode.getAttribute('href')
            // 通过 history.pushState 手动修改地址栏
            window.history.pushState(null, '', href)
            console.log('处理途径1');
            this.forceUpdate();
          }));
        });
        // 处理途径3
        window.addEventListener('popstate', () => {
        console.log('处理途径3');
          this.forceUpdate();
        });
      }
      // 强制渲染
      forceUpdate() {
        console.log('强制渲染', location.pathname);
        switch(location.pathname) {
          case '/kaimo2':
            this.routeView.innerHTML = '这里是 kaimo2 对应的页面'
            return
          case '/kaimo3':
            this.routeView.innerHTML = '这里是 kaimo3 对应的页面'
            return
          default:
            this.routeView.innerHTML = '这里是 kaimo1 对应的页面'
            return
        }
      }
      historyPush(path) {
        window.history.pushState({}, null, path);
        this.forceUpdate();
      }
    }
    let historyRouter = new HistoryRouter();
    historyRouter.init();
    // 处理途径2
    function kaimoBtnClick(path) {
      // 通过 history.pushState 手动修改地址栏
      window.history.pushState(null, '', path);
      console.log('处理途径2');
      historyRouter.forceUpdate();
    }
  </script>
</body>
</html>



555ff23041d24d53b5d3bb10d7c61e73.png



注意

这里需要注意的是:不能在浏览器直接打开静态文件,需要通过 web 服务,启动端口去浏览网址。默认打开的协议是 file 协议,它是不会被 popstate 监听的。


d6a17da3481346bdab948a44729cd670.png



拓展




参考资料



目录
相关文章
|
8天前
|
前端开发 安全 UED
【项目实战】从终端到浏览器:实现 ANSI 字体在前端页面的彩色展示
在学习和工作中,我们经常需要使用日志来记录程序的运行状态和调试信息。而为了更好地区分不同的日志等级,我们可以使用不同的颜色来呈现,使其更加醒目和易于阅读。 在下图运行结果中,我们使用了 colorlog 库来实现彩色日志输出。通过定义不同日志等级对应的颜色,我们可以在控制台中以彩色的方式显示日志信息。例如,DEBUG 级别的日志使用白色,INFO 级别的日志使用绿色,WARNING 级别的日志使用黄色,ERROR 级别的日志使用红色,CRITICAL 级别的日志使用蓝色。
|
17天前
|
缓存 前端开发 jenkins
Serverless 应用引擎产品使用合集之前端的项目部署在镜像里时,页面总是自动刷新,是什么导致的
阿里云Serverless 应用引擎(SAE)提供了完整的微服务应用生命周期管理能力,包括应用部署、服务治理、开发运维、资源管理等功能,并通过扩展功能支持多环境管理、API Gateway、事件驱动等高级应用场景,帮助企业快速构建、部署、运维和扩展微服务架构,实现Serverless化的应用部署与运维模式。以下是对SAE产品使用合集的概述,包括应用管理、服务治理、开发运维、资源管理等方面。
|
1月前
|
资源调度 监控 前端开发
第七章(原理篇) 微前端技术之依赖管理与版本控制
第七章(原理篇) 微前端技术之依赖管理与版本控制
|
7天前
|
存储 前端开发 搜索推荐
Web前端网站(一) - 登录页面及账号密码验证
页面背景动态是烟花和文字特效与缓缓下落的雪花相结合,在登录表单的旁边还有五个白色光圈以不规则的方式环绕,当鼠标靠近时,会发出彩色的光芒~~~
24 1
Web前端网站(一) - 登录页面及账号密码验证
|
4天前
|
JSON 前端开发 JavaScript
SSMP整合案例第五步 在前端页面上拿到service层调数据库里的数据后列表
SSMP整合案例第五步 在前端页面上拿到service层调数据库里的数据后列表
10 2
|
4天前
|
缓存 负载均衡 前端开发
SSMP整合案例第八步 前端页面的分页功能完善与维护和实现条件查询
SSMP整合案例第八步 前端页面的分页功能完善与维护和实现条件查询
9 1
|
4天前
|
前端开发 JavaScript Java
SSMP整合案例第六步 在前端页面上利用axios和element-ui与后端交互实现增删改
SSMP整合案例第六步 在前端页面上利用axios和element-ui与后端交互实现增删改
9 1
|
14天前
|
前端开发 持续交付 开发工具
详细介绍Git的基本原理、在前端开发中的应用以及如何使用Git来优化团队协作
【6月更文挑战第14天】Git是前端开发中的必备工具,它通过分布式版本控制管理代码历史,支持分支、合并和冲突解决,促进团队协作。在前端开发中,Git用于代码追踪、版本控制、代码审查和持续集成部署,优化团队协作。制定分支策略、编写清晰提交信息、定期合并清理分支以及使用Git钩子和自动化工具能进一步提升效率。理解并善用Git,能有效提升前端项目的质量和开发效率。
28 3
|
22天前
|
前端开发
前端React篇之React setState 调用的原理、React setState 调用之后发生了什么?是同步还是异步?
前端React篇之React setState 调用的原理、React setState 调用之后发生了什么?是同步还是异步?
|
1天前
|
前端开发 JavaScript 程序员
程序员必知:完成登录与注册页面的前端
程序员必知:完成登录与注册页面的前端