单页应用实现原理
本篇文章主要讲解常见单页应用路由库的实现思路。通过其实现方法我们可以了解到一些浏览器的工作原理,并且深入学习理解一些平时不常用的API。
由于业务需要,我们要监控页面的访问次数。对于传统的多页应用来说,很简单,我们只需要在页面加载完毕后上报就可以了。但是对于单页应用来说就没那么简单了。首先我们要去搞清楚单页应用的一些原理,了解它是怎么实现的,以及我们如何去监听页面的变化。
SPA & MPA
SPA
SPA(Single Page Applications),单页应用。指的是应用的不同模块/功能之间的切换都是在同一页面里面的。
通常不同模块之间的URL的hash会不一样。比如https://test.com/#/login
,https://test.com/#/user
MPA
MAP(Multiple Page Applications),多页应用。指的是应用的不同模块/功能都是不同页面之间呈现的。
通常不同页面都是不同的html,比如https://test.com/login.html
,https://test.com/user.html
区别
一般单页应用拥有更好的用户体验,更快的页面切换速度。而多页应用的SEO更友好,拥有更快的首页加载速度。
SPA的实现原理
了解了单页应用之后,我们可以明确一点,就是单页应用的路由切换,是在同一页面下进行的。也就是说浏览器不会刷新/跳转页面。
但是它的URL确实是变化了(hash改变了)。也正是因为hash变化不会引起浏览器的刷新行为,所以SPA才依赖于它。
1. 直接改变hash
所以第一个我们能自己想到的SPA的实现方法就是手动更改hash,然后监听hash变化改变路由视图。
原理
window.addEventListener('hashchange', renderView)
window.location.hash = '/login'
DIY
<div>
<a href="#/login">登录</a>
<a href="#/about">关于</a>
</div>
<div class="view"></div>
const $view = document.querySelector(".view");
const routes = {
"/login": "LOGIN",
"/about": "ABOUT",
};
window.addEventListener("hashchange", (e) => {
const route = getHash(e.newURL);
if (route) {
renderView(route);
}
});
function renderView(route) {
const content = routes[route];
$view.innerHTML = content;
}
function getHash(url) {
const href = window.location.href;
const i = href.indexOf("#");
const hash = i >= 0 ? href.slice(i + 1) : "";
return hash;
}
2. pushState
原理
当然直接修改hash已经能很好的实现SPA了,但是我们还能做得更好,更elegant,那就是使用浏览器为我们提供的更强大的方法,也就是History
API。
history.pushState
和history.replaceState
都可以用来改变浏览器的URL而不造成刷新。
DIY
我们只需改变部分代码
<div>
<a href="/login">登录</a>
<a href="/about">关于</a>
</div>
<div class="view"></div>
const $links = document.querySelectorAll("a");
$links.forEach((element) => {
element.addEventListener("click", (e) => {
e.preventDefault();
const path = e.target.getAttribute("href");
renderView(path);
window.history.pushState({}, "", "#" + path);
});
});
注意
这里需要注意的是,pushState
和replaceState
不会触发hashchange
,popstate
事件。也就是我们需要在pushState
/replaceState
的同时去更改视图。
3. 区别
不管是hash改变
还是pushstate
,都会向历史记录里插入一条记录。
但还有很多不同点:
- 使用
history.pushState()
可以改变referrer
。 - 使用
history.pushState()
可以改变任意同源URL而不局限于hash
值。
也就是说你在https://test.com/foo.html
可以调用history.pushState(null, '', '/bar.html')
将URL改为https://test.com/bar.html
而不刷新页面,也不会去请求/bar.html
(假设其不存在)。有什么用呢?你可以看到这样的URL已经和MPA
的URL一样了,这很有利于网站的SEO。但是如果用户在https://test.com/bar.html
刷新页面,那就糟了,因为根本不存在bar.html
这个资源。所以这也是为什么如果SPA
要使用这种URL展示模式需要后端的支持。服务器需要把所有页面都代理到入口页面https://test.com/foo.html
上去。再由入口页面做路由跳转。 - 使用
history.pushState(state, title, url)
可以传入一个状态对象state
。
也就是你可以附加一些数据到跳转的页面上,而hash模式的话你需要将数据放到URL上。
Vue Router实现原理
首先可以确定的一点的是,它的无刷新路由切换最基础的支持就是我们上面提到的hash和history两种实现方式。
这里我们简单扼要的介绍下它是如何改变视图的。
// ...
this._router = this.$options.router
// ...
Vue.util.defineReactive(this, '_route', this._router.history.current)
首先来看下这两行代码,用过VueRouter
的都知道this.$options.router
就是VueRouter
的实例。
但是this._router.history.current
表示的是什么呢?它表示的就是当前路由,在每次你this.$router.push
/this.$router.replace
的时候,current
都会更新。
最关键的是这里新定义了一个响应式属性_route
。当响应式属性更新时,依赖这个属性的组件都会更新。
你该想到了吧,RouterView
就依赖了这个属性_route
。它会根据_route
的改变而更新组件渲染内容(重新执行render函数)。而_route
又会对应有当前路由匹配的组件,这些匹配的组件就是RouterView
要渲染的内容。
可以说VueRouter
将Vue的响应式设计运用得十分到位了。
同样的原理,也适用于ReactRouterDom
上。
如何监听
实现是知道了,那么我们咋监听这玩意?还没有个statechange
事件!hashchange
倒是有,但是我们需要更完善的解决方案。
不能监听变化,我们只能拦截调用了。就像最开始学习window.onload
时使用的方法。
window.onload = () => {
console.log("1");
};
const _onload = window.onload;
window.onload = (e) => {
_onload(e);
console.log(2);
};
不过现在我们有更高级的方法Proxy
,以下是一个简单示例。
history.pushState = new Proxy(history.pushState, {
apply: function (target, thisBinding, args) {
console.log('就这?');
return target.apply(thisBinding, args);
},
});
同样的方法,我们还可以代理console
,xhr
。从而监听网页上的任何活动!
缺点是Proxy
是ES6内容,只适用于现代浏览器。古老浏览器需要polyfill。