路由实现原理基本上每个人都能说出一点。最近也是被问到了回答的不是很好,所以准备好好整理一下。
SPA路由实现基本原理
前端单页应用实现路由的方式有两种。一种是基于hash,一种是基于History API。
基于hash
通过将一个URL path部分用 # (Hash符号) 拆分。浏览器将 # 后面的部分视作虚拟片段。
早期的前端路由实现是基于 location.hash来实现的。他有如下特性:
- URL 中hash值的改变不会被触发页面的重载。
- 页面发送请求时, hash 部分不会被发送。
- hash 值的改变,会记录在浏览器的历史记录,可使用浏览器的“后退”,“前进”触发页面跳转。
- 可以利用 hashchange 事件来监听 hash 的变化。
触发hash变化的方式
- 通过a标签的 href 属性,用户点击后,URL 就会发生改变,进而触发 hashchange 事件
- 直接对 location.hash 赋值,从而改变 URL, 触发hashchange 事件。
下面是一个简易实现。设定了一个路由数组,有一个方法locationHandler,根据hash,通过路由数组,找到对应页面的内容。
监听hashchange事件,当hash改变时触发。并且在页面打开时也同样触发一次。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>spa route</title>
</head>
<body>
<nav>
<a href="#">Home</a>
<a href="#about">About</a>
<a href="#contact">Contact</a>
<a href="#other">Other</a>
</nav>
<div id="content"></div>
</body>
<script>
const routes = {
404: {
content: "404 Not Found",
title: "404",
},
"/": {
content: "Home Page",
title: "Home",
},
"about": {
content: "This is a route demo",
title: "About Us",
},
"contact": {
content: "This is a contact",
title: "Contact Us",
}
};
const locationHandler = async() => {
var location = window.location.hash.replace("#", "");
if (location.length == 0) {
location = "/";
}
const route = routes[location] || routes["404"];
const html = route.content;
document.getElementById("content").innerHTML = html;
document.title = route.title;
};
window.addEventListener("hashchange", locationHandler);
locationHandler();
</script>
</html>
基于History API
普通的URL path (无 # 拆分) ,服务器需要拦截路径请求返回入口index.html文件。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>spa route</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
<a href="/other">Other</a>
</nav>
<div id="content"></div>
</body>
<script>
const routes = {
404: {
content: "404 Not Found",
title: "404",
},
"/": {
content: "Home Page",
title: "Home",
},
"/about": {
content: "This is a route demo",
title: "About Us",
},
"/contact": {
content: "This is a contact",
title: "Contact Us",
}
};
const route = (event) => {
event = event || window.event;
event.preventDefault();
window.history.pushState({}, "", event.target.href);
locationHandler();
};
document.addEventListener("click", (e) => {
const {
target
} = e;
if (!target.matches("nav a")) {
return;
}
e.preventDefault();
route();
});
const locationHandler = async() => {
var location = window.location.pathname.;
if (location.length == 0) {
location = "/";
}
const route = routes[location] || routes["404"];
const html = route.content;
document.getElementById("content").innerHTML = html;
document.title = route.title;
};
window.onpopstate = locationHandler;
window.route = route;
locationHandler();
</script>
</html>
基础实现对比
对比两种实现,其实代码逻辑基本上是一致的
基于location.hash的实现比较简单,直接通过监听hashchange来改变页面内容。
基于History API 的实现,主要是利用了 h5 提供的 pushState, replaceState方法。去改变当前页面的 URL, 同时,利用点击事件 结合 window.onpopstate监听事件触发页面的更新渲染逻辑。
此外History API的实现服务器通常需要做一些配置。
因为由于单页应用路由的实现是前端实现的, 可以理解为是 “伪路由”, 路由的跳转逻辑都是前端代码完成的,这样就存在一个问题, 例如上面的实现中, http://127.0.0.1:5500/about 这个页面用户点击了页面刷新,就会找不到页面。 因为浏览器会向服务器 “http://127.0.0.1:5500/about” 这个地址发送 GET 请求, 希望请求到一个单独的 index.html 文件, 而实际上这个文件我们服务器上是不存在的。 我们需要将其处理为:
http://127.0.0.1:5500/ server 返回首页
http://127.0.0.1:5500/about server 返回首页, 然后前端路由跳转到 about 页
http://127.0.0.1:5500/contact server 返回首页, 然后前端路由跳转到 contact 页
为了做到这点,所以我们需要对服务器做一些转发处理。
总结
基于Hash
优势:
- 浏览器不会将 URL.path 中 # hash 后面的部分视作一个分页,因此默认的就不会触发页面的重载。
- 在前端定义带有 hash 的链接总是安全的,因为它不会触发页面的重载。
- 服务端不需要额外配置。
- 实现起来更加简单。
劣势:
- SEO 并不友好
- 用户体验不好
基于History API
优势:
- URL 看起来和普通的url 一样, 更加美观简洁。
- 在 SEO 方面, 普通 url 会有更多的优势。
- 现代框架通常默认支持该模式。
劣势:
-
客户端刷新时,会把 SPA 的路由误当作 资源请求链接,所以需要配置 web 服务器以处理这些 “路由形式的URL” 以统一放回入口 index.html 文件。
-
通常为了让服务器区分这些 “路由形式的URL”, 所以通常需要用一些前缀以区分和普通 请求的区别,如 /api/*
-
通过这种方式实现时,定义路由的时候需要特别注意, 因为不当的链接跳转可能会导致全页面重载。
Angular路由实现
已经了解了基本原理,那么Angular的路由又是怎么实现的呢。
我到github上下载了angular路由实现的源码。
我们直接在router目录下搜索路由跳转的方法navigate。
commands是命令数组,比较常见的用法是在里面填写要导航到的路由,extras里设置路由的参数,以及其他扩展属性,第一步是校验数组里的成员是否均合法。
不是null即是合法。
值得注意的是Navigation这个类里,触发方式有三种,imperative即通过router.navigate触发,popstate event即history api,hashchange就是hash改变。
下一步构建UrlTree,queryParams即路由参数,会根据路由方式选择是否和原路由的参数合并。
最终返回是一个构建完成的Url。通过构建的url和扩展参数开始导航。值得一提的是这个NgZone。之前做过一个前端获取ip的需求,封装的getUserIP方法入参是一个回调函数,我在回调函数里调用navigate调用失败,后面也是通过设置ngZone.run()来解决的,这下原理终于搞清楚了,原来是执行上下文的问题。
后面实际处理路由请求时,还会对路由进行合并,路由守卫校验,设置活动路由等操作。这些都是angular提供的进阶的路由能力。基本的路由功能的实现看起来还是非常简单清晰的。