单页应用(SPA)实现原理:深入解析hash模式与history模式的技术细节

0. 摘要
本文介绍了单页应用的概念,还介绍了路由的概念。同时,介绍了如何利用两种技术,即 hash 模式和 history 模式,以实现单页应用的效果。
1. 内容2. 什么是单页应用
单页应用的英文表述为 single page application,其简称是 SPA。从字面意思来看,一个应用仅仅只有一个页面,这是一个极具颠覆性的概念,甚至有些违背常识。按照我们通常的理解,一个应用肯定是需要有多个页面的。比如,一个新闻网站必然要有主页、列表页、详情页等。我们或许很难想象出一个只有一个页面的应用到底是什么样子。“就一个主页”,“就一个静态的个人说明页面”。
肯定不是。我们多花点时间看看历史。
早先的网站多为多页形式,即从字面上理解为多页:其主页或许是 index.html,列表页是 list.html,详情页是 detail.html 等。这样做的好处在于开发较为清晰,像 3P(asp, jsp, php)技术就是典型的代表。移动设备兴起后,越来越多的页面是通过移动设备来访问的,于是多页面应用的天然缺陷便显现出来了:
页面跳转时会出现白屏,这是不可避免的;从 a 页面跳转到 b 页面时,难免会有重复部分,比如页头和页脚通常是通用的,而这些重复部分显然会造成不必要的流量浪费。这些问题在 pc 端不存在问题,然而在移动设备上却成了大问题。为了提升用户的体验,使他们能更快地看到页面并且节约流量,程序员们研发出了单页应用技术。
单页应用的主要特点在于:以一个页面(其名字为 index.html)来达成所有(涵盖多个页面的)功能。
其实,SPA、MPA 这些名称是随着技术的发展和应用而被提出的。就如同智能手机和功能手机这一对概念,在诺基亚风靡世界的时期(2010 年左右),它们并未被创造出来。那时我们讨论的话题都是关于和弦的数量。然而,当出现了一批更强大、更智能的手机后,我们需要进行区分,于是就给诺基亚们取了名字叫功能机。类似的概念还有服务器端渲染和客户端渲染。
3. 单页应用的特点
单页应用的特点较为明显:地址栏发生变化时,内容会随之改变,然而整个页面却不会进行刷新。以下是一张示意图:
它的好处在于首次获取到 index.html 这个页面后,就无需再请求其他页面了。并且,所有的功能操作都能在这一个页面里得以完成。
4. 单页应用的实现方式-路由
现在我们暂且不考虑单页应用这个名词,将注意力集中在如何达成具体功能上。地址栏中的内容发生了变化,主体内容会相应地进行更新,然而整个页面不会进行刷新。
就是我们说的前端路由技术。
路由是啥?可不是路由器哈。
路由是一套规则,它依据地址栏中的地址来确定页面主体上的内容显示。比如,若地址是/list,那么内容就是列表页的内容;若地址是/detail,内容就是详情页的内容,如此类推。
实现路由功能由两种方式:
都是借助浏览器的相关 api 特性去实现目标的,从严格意义上来说,都不属于 js 的范畴。接下来我们逐一介绍这两种技术。
5. hash模式
与 history 模式相比,用 hash 模式来实现这个功能会较为简单。
5.1 原理
页面中的地址栏发生了变化,从 index.html/#/detail 变成了 index.html/#/list 。
5.2 代码
<html lang="zh"><head><meta charset="UTF-8" /><meta name="viewport" content=宽度为设备宽度,初始缩放比例为 1.0 。 /><title>Documenttitle><style>body {background-color: #eee;}#app {background-color: #fff;margin: 2px auto;padding: 2em;}#content{padding:0.5em}style>head><body><div id="app"><nav><a href="#/">主页a><a href="#/list">列表页a><a href="#/detail">详情页a>nav><button id="btn">跳到详情页button><div id="content">div>div><script>let content = null页面加载完不会引发 hashchange 事件,在此处会主动促使一次 hashchange 事件的发生。window.addEventListener('DOMContentLoaded', function(){content = document.querySelector("#content")onHashChange()})// 页面跳转:直接设置hash值的变化document.getElementById('btn').addEventListener('click',()=>{location.hash='/detail';})// 监听路由变化window.addEventListener('hashchange', onHashChange)function onHashChange(e) {console.log(e)switch (location.hash) {case "#/":content.innerHTML = "主页的内容"break;case "#/list":content.innerHTML = "列表页的内容"break;case "#/detail":content.innerHTML = "详情页的内容"break;default:content.innerHTML = "404"break;}}script>body>html>
5.4 小结
hash 模式在地址栏上存在一个较为奇特的符号。这个符号是 # 。它使得地址看起来显得比较怪异。
6. history模式
6.1 原理
pushState方法 + popstate事件
pushState方法
它是在 history 对象上的一个方法,其签名为:history.pushState(state, title, [url])。它的作用具体如下:
向当前浏览器会话的历史堆栈添加一个状态,此状态会对历史记录进行改变;
(2)改变地址栏的信息;
(3)它不会刷新页面去发请求。
你现在能够随意打开一个浏览器,接着在控制台里,依次把以下三句代码写进去,并且敲击回车键让其运行。
history.pushState(null,null,'/a')history.pushState(null,null,'/b')history.pushState(null,null,'/c')
效果图如下:
请注意如下几点细节:
地址栏发生了变化。先是从 baidu.com/a 变为 baidu.com/b,接着又变为 baidu.com/c。
2. 页面并没有刷新。
浏览器的前进后退按钮可以使用了。
但是,一定不要点击刷新按钮。因为点击刷新按钮会使浏览器真的去请求 baidu.com/a 这个页面,然而这个页面是不存在的。
replaceState方法
它的功能和 pushState 相近,区别在于 replace 所产生的替换效果。
举例来说:若地址的变化是依序通过 pushState 方法按照 a、b、c 的顺序来修改地址栏,在此情形下,历史记录为:/a 到 /b 再到 /c ,此时借助前进后退按钮能够在这三条记录间进行切换。但是,在此时使用 replaceState 并跳转到 /d 时,会把当前的访问记录给替换掉。其结果是历史记录变为 /a->/b->/d ,而最近的那条记录 /c 就被 /d 所替换了。具体效果可参考下图。
事件-popstate
当活动历史记录条目更改时,将触发popstate事件。
window.addEventListener('popstate',()=>{console.log('popState事件',location.pathname)})
我们凭借上面的代码能够去对 popstate 事件进行监听。那么在哪些情况下会引发这个事件呢?存在以下两类操作:
点击浏览器的前进,回退按钮
调用用于操作历史记录的方法,如 history.back()、history.forward()、history.go()。
需要注意的是,调用 history.pushState() 这个行为不会引发 popstate 事件。
如果历史记录条目被激活是通过调用 history.pushState()实现的,或者受到调用 history.replaceState()的影响,那么 popstate 事件的 state 属性就会包含历史条目的状态对象的副本。
不同的浏览器在加载页面时对 popstate 事件的处理形式不一样。页面加载时,Chrome 通常会触发 popstate 事件,Safari 也通常会触发 popstate 事件,而 Firefox 则不会触发 popstate 事件。
6.2 目标
有了上述的理论基础之后,我们接着来看我们即将要达成的目标。具体情况如下:
点击 a 标签后会产生地址栏的变化,并且主体内容也会随之变化。需要注意的是,在这个过程中地址栏里没有#号。
6.3 思路
可以分成两个部分来完成这个目标:
劫持a标签的跳转动作,在回调中:
监听 popstate 事件,当浏览器有前进或后退动作时,能够依据当前的 pathname 的内容去展示内容。
window.addEventListener('popstate',()=>根据 location.pathname 的变化情况来展示相应的内容。)
6.4 代码
<html lang="zh"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Documenttitle><style>body {background-color: #eee;}#app {background-color: #fff;margin: 2px auto;padding: 2em;}#content{padding:0.5em}style>head><body><div id="app"><nav><a href="/">主页a><a href="/list">列表页a><a href="/detail">详情页a>nav><button id="btn">跳到详情页button><div id="content">div>div><script>let content = nullwindow.addEventListener('DOMContentLoaded', onLoad)// 直接跳转页面document.getElementById('btn').addEventListener('click',()=>{history.pushState(null,null,'/detail')onPopStateChange()})function onLoad() {// 更新页面内容content = document.querySelector("#content")onPopStateChange()// 拦截a标签var aLinks = document.querySelectorAll('a[href]')aLinks.forEach((a,idx)=> {a.addEventListener('click',e => {e.preventDefault()history.pushState(null,null, e.target.getAttribute('href'))onPopStateChange()})})}// 监听popstate事件变化window.addEventListener('popstate', onPopStateChange)// 实现具体的路由功能:根据地址完成页面内容切换function onPopStateChange(e) {console.log(e,Date.now())switch (location.pathname) {case "/":content.innerHTML = "这里是主页的内容"break;case "/list":content.innerHTML = "这里是列表页的内容"break;case "/detail":content.innerHTML = "这里是详情页的内容"break;default:content.innerHTML = `您访问的${location.pathname}不存在`break;}}script>body>html>
6.5 小结
在 history 模式下,地址栏中不存在#,地址的外观较为正常。它运用了一些在 H5 中相对较为高级的 API(pushState)。需要留意浏览器的兼容性问题。并且,它无法承受刷新页面的操作,因为在刷新时,浏览器会按照这个地址去请求页面资料,然而很有可能服务器上并没有该页面资源。
7. 总结
单页应用在移动互联时代诞生。其目标是不刷新整体页面,而是依据地址栏的变化来确定内容区域显示的内容。要实现这一目标,需用到前端路由技术。具体而言,有两种方式可实现,即 hash 模式和 history 模式。history 模式是通过 pushState 方法与 popstate 事件相结合来实现的。