单页应用实现原理

单页应用实现原理

本篇文章主要讲解常见单页应用路由库的实现思路。通过其实现方法我们可以了解到一些浏览器的工作原理,并且深入学习理解一些平时不常用的API。

由于业务需要,我们要监控页面的访问次数。对于传统的多页应用来说,很简单,我们只需要在页面加载完毕后上报就可以了。但是对于单页应用来说就没那么简单了。首先我们要去搞清楚单页应用的一些原理,了解它是怎么实现的,以及我们如何去监听页面的变化。

SPA & MPA

SPA

SPA(Single Page Applications),单页应用。指的是应用的不同模块/功能之间的切换都是在同一页面里面的。

通常不同模块之间的URL的hash会不一样。比如https://test.com/#/loginhttps://test.com/#/user

MPA

MAP(Multiple Page Applications),多页应用。指的是应用的不同模块/功能都是不同页面之间呈现的。

通常不同页面都是不同的html,比如https://test.com/login.htmlhttps://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,那就是使用浏览器为我们提供的更强大的方法,也就是HistoryAPI。

history.pushStatehistory.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); }); });

注意

这里需要注意的是,pushStatereplaceState不会触发hashchangepopstate事件。也就是我们需要在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); }, });

同样的方法,我们还可以代理consolexhr。从而监听网页上的任何活动!

缺点是Proxy是ES6内容,只适用于现代浏览器。古老浏览器需要polyfill。

上一篇 Performance API
下一篇 GO踩坑集锦