在SPA单页面模式盛行,前后端分离的背景下,我们要弄清楚路由到底是个什么玩意,它可以帮助我们加深对于前端项目线上运作的理解。
而现在我们常见的路由实现方式,主要有两种,分别是history和hash模式。
一、引出
如何理解路由的概念,其实还是要从单页面着手,进行剖析。
单页面,说白了,就是指我们的服务只有一个index.html静态文件,这个静态文件前端生成后,丢到服务器上面。用户访问的时候,就是访问这个静态页面。而静态页面中所呈现出来的所有交互,包括点击跳转,数据渲染等,都是在这个唯一的页面中完成的。
这里我用一张常见的单页应用图,可能更方便我们理解。
例子中可以看到有四个模块,点击每个模块,内容区域都展示对应的内容,并且浏览器在点击的时候并没有发出实际的http请求(不包括其他静态资源,只是针对页面层面),只有第一次发出的请求获得的静态文件index.html。那么问题来了,内容区域是如何认识到用户点击了不同的模块,从而更新内容区域,并且做到不需要再次发出请求呢?
这个其实就是路由做的工作:
通过一定的机制,监听用户的行为动作,从而做出对应的变化。
二、hash模式
我们都知道一个URL是由很多部分组成,包括协议
、域名
、路径
、query
、hash
等,比如上面的例子,我们点击不同模块的时候可能看到是这样的URL
https://domain.xxx.com/index.html/#/ //首页
https://domain.xxx.com/index.html/#/news //新闻
https://domain.xxx.com/index.html/#/articles // 文章
https://domain.xxx.com/index.html/#/chat // 聊天
#号后面的,就是一个URL中关于hash的组成部分,可以看到,不同路由对应的hash是不一样的,但是它们都是在访问同一个静态资源index.html。我们要做的,就是如何能够监听到URL中关于hash部分发生的变化,从而做出对应的改变。
通过监听hashchange
方法,在hash改变的时候,触发该事件。有了监听事件,且改变hash页面并不刷新,这样我们就可以在监听事件的回调函数中,执行我们展示和隐藏不同UI显示的功能,从而实现前端路由。
下面是关于hash路由的核心实现,可以看出来,主要就是监听hash的变化,渲染不同的组件代码
class HashRouter {
constructor(routes = []) {
this.routes = routes
this.currentHash = '/'
this.refresh = this.refresh.bind(this)
window.addEventListener('load', this.refresh, false)
window.addEventListener('hashchange', this.refresh, false)
}
getUrlHash(url) {
return url.indexOf('#') > -1 ? url.slice(url.indexOf('#') + 1) : '/'
}
refresh(event) {
const { newURL = '', oldURL = '' } = event
const newHash = this.getUrlHash(newURL ? newURL : window.location.hash)
this.currentHash = newHash
this.mountComponent()
}
mountComponent() {
const currentRoute = this.routes.find(route => route.path === this.currentHash)
const route = currentRoute ?? this.routes[0]
document.querySelector('#content').innerHTML = route.component
}
}
const router = new HashRouter([
{
path: '/',
name: 'Home',
component: '<div>首页-Home</div>'
},
{
path: '/news',
name: 'News',
component: '<div>新闻-News</div>'
},
{
path: '/articles',
name: 'Articles',
component: '<div>文章-Artices</div>'
},
{
path: '/chat',
name: 'Chat',
component: '<div>聊天-Chat</div>'
}
])
结论:
- hash模式所有的工作都是在前端完成的,不需要后端服务的配合
- hash模式的实现方式就是通过监听URL中hash部分的变化,从而做出对应的渲染逻辑
- hash模式下,URL中会带有#,看起来不太美观
三、history模式
history路由模式的实现,是要归功于HTML5提供的一个history
全局对象,可以将它理解为其中包含了关于我们访问网页(历史会话)的一些信息。同时它还暴露了一些有用的方法,比如:
window.history.go // 可以跳转到浏览器会话历史中的指定的某一个记录页
window.history.forward // 指向浏览器会话历史中的下一页,跟浏览器的前进按钮相同
window.history.back // 返回浏览器会话历史中的上一页,跟浏览器的回退按钮功能相同
window.history.pushState // 可以将给定的数据压入到浏览器会话历史栈中
window.history.replaceState // 将当前的会话页面的url替换成指定的数据
而history路由的实现,主要就是依靠于pushState
与replaceState
实现的,这里我们先总结下它们的一些特点
- 都会改变当前页面显示的url,但都不会刷新页面
- pushState是压入浏览器的会话历史栈中,会使得history.length加1,而replaceState是替换当前的这条会话历史,因此不会增加history.length
- 但是原生JavaScript无法监听到通过pushState或replaceState导致的state变化
既然已经能够通过pushState或replaceState实现改变URL而不刷新页面,那么是不是如果我们能够监听到改变URL这个动作,就可以实现前端渲染逻辑的处理呢?这个时候,我们还要了解一个事件处理程序popstate
,先看下它的官方定义
每当激活同一文档中不同的历史记录条目时,popstate 事件就会在对应的 window 对象上触发。如果当前处于激活状态的历史记录条目是由 history.pushState() 方法创建的或者是由 history.replaceState() 方法修改的,则 popstate 事件的 state 属性包含了这个历史记录条目的 state 对象的一个拷贝。
调用 history.pushState() 或者 history.replaceState() 不会触发 popstate 事件。popstate 事件只会在浏览器某些行为下触发,比如点击后退按钮(或者在 JavaScript 中调用 history.back() 方法)。即,在同一文档的两个历史记录条目之间导航会触发该事件。
也就是说:
- history.pushState和history.replaceState方法是不会触发popstate事件的,但是浏览器的某些行为会导致popstate,比如go、back、forward
- popstate事件对象中的state属性,可以理解是我们在通过history.pushState或history.replaceState方法时,传入的指定的数据
重写history.pushState
和history.replaceState
方法,在这个方法也能够暴露出自定义的全局事件,然再监听自定义的事件
const rewrite = function(type) {
let origin = history[type]
return function() {
const res = origin.apply(this, arguments)
const e = new Event(type)
e.arguments = arguments
window.dispatchEvent(e)
return res
}
}
history.pushState = rewrite('pushState')
history.replaceState = rewrite('replaceState')
执行完上面两个方法后,相当于将pushState和replaceState这两个监听器注册到了window上面,具体的定义可参考EventTarget.dispatchEvent
简易实现
const nav1 = document.querySelector('#a1')
const nav2 = document.querySelector('#a2')
const nav3 = document.querySelector('#a3')
const nav4 = document.querySelector('#a4')
nav1.addEventListener('click', () => {
history.pushState({ page_id: 1 }, '', '/home')
})
nav2.addEventListener('click', () => {
history.pushState({ page_id: 2 }, '', '/news')
})
nav3.addEventListener('click', () => {
history.pushState({ page_id: 3 }, '', '/articles')
})
nav4.addEventListener('click', () => {
history.pushState({ page_id: 4 }, '', '/chat')
})
const routes = [
{
path: '/',
name: 'Home',
component: '<div>首页-Home</div>'
},
{
path: '/news',
name: 'News',
component: '<div>新闻-News</div>'
},
{
path: '/articles',
name: 'Articles',
component: '<div>文章-Artices</div>'
},
{
path: '/chat',
name: 'Chat',
component: '<div>聊天-Chat</div>'
}
]
function mountComponent(path) {
const currentRoute = routes.find(route => route.path === path)
const route = currentRoute ?? routes[0]
document.querySelector('#content').innerHTML = route.component
}
window.addEventListener('pushState', e => {
// 监听pushState自定义事件,根据参数做出对应的页面挂载
const [state, unused, url] = e.arguments
console.log(url);
mountComponent(url)
})
重点
hash模式是不需要后端服务配合的。但是history模式下,如果你再跳转路由后再次刷新会得到404的错误,这个错误说白了就是浏览器会把整个地址当成一个可访问的静态资源路径进行访问,然后服务端并没有这个文件 (回答错误(╥﹏╥),-10分)
没刷新时,只是通过pushState改变URL,不刷新页面
http://127.0.0.1:5500/ ==》 http://127.0.0.1:5500/index.html // 默认访问路径下的index.html文件,没问题
http://127.0.0.1:5500/home ==》 http://127.0.0.1:5500/index.html // 仍然访问路径下的index.html文件,没问题
...
http://127.0.0.1:5500/chat ==》 http://127.0.0.1:5500/index.html // 所有的路由都是访问路径下的index.html,没问题
一旦在某个路由下刷新页面的时候,想当于去该路径下寻找可访问的静态资源index.html,无果报错
http://192.168.30.161:5500/mine === http://192.168.30.161:5500/mine/index.html文件,出问题了,服务器上并没有这个资源,404😭
所以一般情况下,我们都需要配置下nginx,告诉服务器,当我们访问的路径资源不存在的时候,默认指向静态资源index.html
location / {
try_files $uri $uri/ /index.html;
}
四、 MemoryHistory (V4 前称为 abstract history)
相对于 hash 和 history 路由模式,memory 模式并不会改变浏览器地址栏的 URL,而是将路径信息保存在内存中。这意味着:
- 不会修改 URL:用户在导航时,浏览器地址栏不会发生变化。
- 不依赖浏览器历史记录:没有浏览器的前进和后退按钮的支持,因为路由状态仅存在于内存中。
- 适合在非浏览器环境中运行:如 React Native、Electron 应用、Node.js 服务端渲染等场景。
简易实现
<!DOCTYPE html>
<html>
<head>
<title>Memory Router</title>
<meta charset="UTF-8" />
</head>
<body>
<a class="link" href="/index">首页</a>
<a class="link" href="/news">新闻</a>
<a class="link" href="/articles">文章</a>
<a class="link" href="/chat">聊天</a>
<div id="app"></div>
<div id="div404" style="display: none;">
你要找的页面不存在
</div>
<script>
const app = document.querySelector("#app");
// components
const IndexComponent = document.createElement("div");
IndexComponent.innerHTML = "Index";
const NewsComponent = document.createElement("div");
NewsComponent.innerHTML = "News";
const ArticlesComponent = document.createElement("div");
ArticlesComponent.innerHTML = "Articles";
const ChatComponent = document.createElement("div");
ChatComponent.innerHTML = "Chat";
const routeMap = {
"/index": IndexComponent,
"/news": NewsComponent,
"/articles": ArticlesComponent,
"/chat": ChatComponent
};
function route(container) {
let url = window.localStorage.getItem("memory-router");
if (!url) {
url = "/index";
}
let component = routeMap[url];
// 404
if (!component) {
component = document.querySelector("#div404");
}
component.style.display = "block";
//展示页面
container.innerHTML = "";
container.appendChild(component);
}
const allA = document.querySelectorAll("a.link");
for (let a of allA) {
a.addEventListener("click", (e) => {
// 阻止默认事件,不要自动刷新
e.preventDefault();
const href = a.getAttribute("href");
window.localStorage.setItem("memory-router", href);
// 切换组件
route(app);
});
}
route(app);
</script>
</body>
</html>
优点:
- 独立于浏览器环境:不依赖浏览器的地址栏或历史记录,适用于多种非浏览器环境。
- 测试友好:在测试路由相关逻辑时不影响实际浏览器行为。
- 安全性:不暴露路径信息到浏览器地址栏,适用于敏感路径场景。
缺点: - 不可见的路由:用户无法通过地址栏直接查看或修改 URL。
- 无法使用浏览器的导航按钮:因为内存中的路径无法与浏览器的前进、后退按钮同步。
五、使用场景
一般场景下,hash
和 history
都可以,除非你更在意颜值,# 符号夹杂在 URL 里看起来确实有些不太美观。
如果不想要很丑的 hash,我们可以用路由的 history 模式,这种模式充分利用 history.pushState API 来完成
URL 跳转而无须重新加载页面。—— Vue-router 官网。
另外,根据 Mozilla Develop Network 的介绍,调用 history.pushState() 相比于直接修改 hash,存在以下优势
:
- pushState() 设置的新 URL 可以是与
当前 URL 同源的任意 URL
;而 hash 只可修改 # 后面的部分,因此只能设置与当前 URL 同文档的 URL
; - pushState() 设置的新 URL
可以与当前 URL 一模一样,这样也会把记录添加到栈中;而 hash 设置的新值必须与原来
不一样才会触发动作将记录添加到栈中`; - pushState() 通过 state
Object 参数可以添加任意类型的数据
到记录中;而 hash 只可添加短字符串; - pushState() 可额外设置 title 属性供后续使用。
当然啦,history 也有不足。SPA 虽然在浏览器里游刃有余,但真要通过 URL 向后端发起 HTTP 请求时,两者的差异就来了。尤其在用户手动输入 URL 后回车,或者刷新(重启)浏览器的时候。
- hash 模式下,仅 hash 符号之前的内容会被包含在请求中,如 http://www.abc.com,因此对于后端来说,即使没有做到对路由的全覆盖,也不会返回 404 错误。
- history 模式下,前端的 URL 必须和实际向后端发起请求的 URL 一致,如 http://www.abc.com/book/id。如果后端缺少对 /book/id 的路由处理,将返回 404 错误。Vue-Router 官网里如此描述:“不过这种模式要玩好,还需要后台配置支持……所以你要在服务端增加一个覆盖所有情况的候选资源:
如果 URL 匹配不到任何静态资源,则应该返回同一个 index.html 页面
,这个页面就是你 app 依赖的页面。”
location / {
try_files $uri $uri/ /index.html;
}
而Memory 模式
适合在非浏览器环境中运行:
- React Native 应用:
由于 React Native 并非运行在浏览器环境下,因此不支持 hash 或 history 模式,而 memory 模式在这种环境下非常适用。 - Electron 桌面应用:
Electron 应用是基于 Chromium 和 Node.js 的桌面应用,使用 memory 模式可以避免操作系统的 URL 栏和历史记录,同时保持应用的路由管理。
六、总结
- 一般路由实现主要有
history
和hash
两种方式 - hash的实现全部在前端,不需要后端服务器配合,兼容性好,主要是通过监听hashchange事件,处理前端业务逻辑
- history的实现,需要服务器做以下简单的配置,通过监听pushState及replaceState事件,处理前端业务逻辑
- Memory 模式 适合在非浏览器环境中运行:如 React Native、Electron 应用、Node.js 服务端渲染等场景