一、什么是前端路由?
路由是根据不同的 url 地址展示不同的内容或页面。路由的概念来源于服务端,在服务端中路由描述的是 URL 与处理函数之间的映射关系。
而在 Web 前端单页应用中,路由描述的是 URL 与 UI 之间的映射关系,这种映射是单向的,即 URL 变化引起 UI 更新(无需刷新页面)。
前端路由的兴起就是把不同路由对应不同的内容或页面的任务交给前端来做,之前是通过服务端根据 url 的不同返回不同的页面来实现的。
二、技术准备
故事从名叫 Oliver
的大虾说起,这位大虾酷爱社交网站,一天他打开了 Twitter
,从发过的 tweets
的选项卡一路切到 followers
选项卡,Oliver
发现页面的内容变化了,URL也变化了,但为什么页面没有闪烁刷新呢?于是Oliver
打开的网络监控器,他惊讶地发现在切换选项卡时,只有几个XHR请求发生,但页面的URL却在对应着变化,这让 Oliver
不得不去思考这一机制的原因。叙事体故事讲完,进入正题。
首先,我们知道传统而经典的 Web 开发中,服务器端承担了大部分业务逻辑,但随着 2.0 时代 ajax 的到来,前端开始担负起更多的数据通信和与之对应的逻辑。
在过去,Server
端处理来自浏览器的请求时,要根据不同的 Url 路由,拼接出对应的视图页面,通过 Http 返回给浏览器进行解析渲染。Server
不得不承担这份艰巨的责任。为了让 Server 端更好地把重心放到实现核心逻辑和看守数据宝库,把部分数据交互的逻辑交给前端担负,让前端来分担 Server 端的压力显得尤为重要,前端也有这个责任和能力。
大部分复杂的网站,都会把业务解耦为模块进行处理。这些网站中又有很多的网站会把适合的部分应用 Ajax 进行数据交互,展现给用户,很明显处理这样的数据通信交互,不可避免的会涉及到跟 URL 打交道,让数据交互的变化反映到 URL 的变化上,进而可以给用户机会去通过保存的 URL 链接,还原刚才的页面内容板块的布局,这其中包括 Ajax 局部刷新的变化。
通过记录 URL 来记录 web 页面板块上 Ajax 的变化,我们可以称之为 Ajax 标签化 ,比较好实现可以参考 Pjax 等。而对于较大的 framework
,我们称之为 路由系统。
我们先熟悉几个新的 H5 history Api
:
/*Returns the number of entries in the joint session history.*/
window . history . length
/*Returns the current state object.*/
window . history . state
/*Goes back or forward the specified number of steps in the joint session history.A zero delta will reload the current page.If the delta is out of range, does nothing.*/
window . history . go( [ delta ] )
/*Goes back one step in the joint session history.If there is no previous page, does nothing.*/
window . history . back()
/*Goes forward one step in the joint session history.If there is no next page, does nothing.*/
window . history . forward()
/*Pushes the given data onto the session history, with the given title, and, if provided and not null, the given URL.*/
window . history . pushState(data, title [url] )
/*Updates the current entry in the session history to have the given data, title, and,if provided and not null, URL.*/
window . history . replaceState(data, title [url] )
上边是 Mozilla
在 HTML5 中实现的几个 History api
的官方文档描述,我们先来关注下最后边的两个api
, history.pushState
和 history.replaceState
,这两个 history
新增的 api
,为前端操控浏览器历史栈提供了可能性:
/**
*parameters
*@data {object} state对象,这是一个javascript对象,一般是JSON格式的对象
*字面量。
*@title {string} 可以理解为document.title,在这里是作为新页面传入参数的。
*@url {string} 增加或改变的记录,对应的url,可以是相对路径或者绝对路径,
*url的具体格式可以自定。
*/
history.pushState(data, title, url) //向浏览器历史栈中增加一条记录。
history.replaceState(data, title, url) //替换历史栈中的当前记录。
这两个 Api 都会操作浏览器的历史栈,而不会引起页面的刷新。不同的是,pushState
会增加一条新的历史记录,而 replaceState
则会替换当前的历史记录。所需的参数相同,在将新的历史记录存入栈后,会把传入的 data
(即 state
对象)同时存入,以便以后调用。同时,这俩 api 都会更新或者覆盖当前浏览器的 title
和 url
为对应传入的参数。
url 参数可以为绝对路径,如: http://tonylee.pw?name=tonylee
,https://www.tonylee.pw/name/tonylee ;
也可以为相对路径:?name=tonylee , /name/tonylee
;等等的形式。
url 作为一个改变当前浏览器地址的参数,用法是很灵活的,replaceState
和 pushState
具有和上边测试相同的特性,传入的url如果可能,总会被做适当的处理,这种处理默以”/”相隔,也可以自己指定为”?”等。要注意,这两个 api 都是不能跨域的!比如在 http://tonylee.pw
下,只能在同域下进行调用,如二级域名http://www.tonylee.pw
就会产生错误。没错,我想你已经猜到了前边讲到的 Oliver
看到 URL 变化,页面板块变化,页面发出 XHR 请求,页面没有 reload
等等特性,都是因此而生!
至于 api 中的 data
参数,实际上是一个 state
对象,也即是 JavaScript
对象。Firefox 的实现中,它们是存在用户的本地硬盘上的,最大支持到 640k,如果不够用,按照 FF 的说法你可以用 sessionStorage or localStorage
。如:
var stateObj = { foo: "bar" };
history.pushState(stateObj, "the blog", "name = next");
如果当前页面经过这样的过程,历史栈对应的条目,被存入了stateObj
,那么我们可以随时主动地取出它,如果页面只是一个普通的历史记录,那么这个 state
就是 null
。
了解这俩 api 还不够,还需要看下 popstate
事件,我担心解释的不到位,所以看看 mozilla
官方文档的解释:
An event handler for the popstate event on the window. A popstate event is dispatched to the window every time the active history entry changes
between two history entries for the same document.
If the history entry being activated was created by a call to history.pushState() or was affected by a call to history.replaceState(),
the popstateevent's state property contains a copy of the history entry's state object. Note that just calling history.pushState() or history.replaceState() won't trigger apopstate event.
The popstate event is only triggered by doing a browser action such as clicking on the back button (or calling history.back() in JavaScript).
And the event is only triggered when the user navigates between two history entries for the same document. Browsers tend to handle the popstate event differently on page load.
Chrome (prior to v34) and Safari always emit a popstate event on page load, but Firefox doesn't. Syntax window.onpopstate = funcRef; //funcRef is a handler function.
简而言之,就是说当同一个页面在历史记录间切换时,就会产生 popstate
事件。正常情况下,如果用户点击后退按钮或者开发者调用:history.back() or history.Go()
,页面根本就没有处理事件的机会,因为这些操作会使得页面 reload
。所以 popstate
只在不会让浏览器页面刷新的历史记录之间切换才能触发,这些历史记录一般由 pushState/replaceState
或者是由 hash 锚点等操作产生。并且在事件的句柄中可以访问 state 对象的引用副本!而且单纯的调用 pushState/replaceState
并不会触发 popstate
事件。页面初次加载时,是否会主动触发 popstate
事件,不同的浏览器实现也不一样。下边是官方的一个 demo:
window.onpopstate = function(event) {
alert("location: " + document.location + ", state: " + JSON.stringify(event.state));
};
history.pushState({page: 1}, "title 1", "?page=1");
history.pushState({page: 2}, "title 2", "?page=2");
history.replaceState({page: 3}, "title 3", "?page=3");
history.back(); // alerts "location: http://example.com/example.html?page=1, state: {"page":1}"
history.back(); // alerts "location: http://example.com/example.html, state: null
history.go(2); // alerts "location: http://example.com/example.html?page=3, state: {"page":3}
这里便是通过event.state
拿到的 state
的引用副本!
H5还新增了一个 hashchange
事件,也是很有用途的一个新事件:
The 'hashchange' event is fired when the fragment identifier of the URL has changed
(the part of the URL that follows the # symbol, including the # symbol).
当页面hash(#)
变化时,即会触发 hashchange
。锚点Hash起到引导浏览器将这次记录推入历史记录栈顶的作用, window.location
对象处理“#”的改变并不会重新加载页面,而是将之当成新页面,放入历史栈里。并且,当前进或者后退或者触发 hashchange
事件时,我们可以在对应的事件处理函数中注册 ajax 等操作!
但是 hashchange
这个事件不是每个浏览器都有,低级浏览器需要用轮询检测URL是否在变化,来检测锚点的变化。当锚点内容(location.hash
)被操作时,如果锚点内容发生改变浏览器才会将其放入历史栈中,如果锚点内容没发生变化,历史栈并不会增加,并且也不会触发 hashchange
事件。
想必你猜到了,这里说的低级浏览器,指的就是可爱的IE了。比如我有一个url从 http://tonylee.pw#hash_start=1
变化到 http://tonylee.pw#hash_start=2
,实现良好的浏览器是会触发一个名为 hashchange
的事件,但是对于低版本的IE(稍后我会对具体的兼容性做个总结),我们只能通过设置一个 Inerval
来不断的轮询url是否发生变化,来判断是否发生了类似 hashchange
的事件,同时可以声明对应的事件处理函数,从而模拟事件的处理。
到这里,说了这么多 api, 其实我们对标签化/路由系统应该有了一个大概的了解。如果考虑H5的api,过去 facebook
和 twitter
实现路由系统时,约定用”#!”实现,这估计也是一个为了照顾搜索引擎的约定。毕竟前端路由系统涉及到大量的 ajax,而这些 ajax 对应 url 路径对于搜索引擎来说,是很难匹配起来的。
路由大概的实现过程可以这么理解, 对于高级浏览器,利用 H5 的新 Api 做好页面上不同板块 ajax 等操作与 url 的映射关系,甚至可以自己用 javascript 书写一套历史栈管理模块,从而绕过浏览器自己的历史栈。而当用户的操作触发 popstate 时,可以判断此时的 url 与板块的映射关系,从而加载对应的 ajax 板块。这样你就可以把一个具有很复杂 ajax 版面结构页面的 url 发送给你的朋友了,而你的朋友在浏览器中打开这个链接时,前端路由系统 url 和板块映射关系会解析并还原出整个页面的原貌!一般 SPA(单页面应用)和一些复杂的社交站应用,会普遍拥有自己的前端路由系统。
三、如何实现前端路由?
要实现前端路由,需要解决两个核心问题:
(1)如何改变 URL 却不引起页面刷新?
(2)如何检测 URL 变化了?
下面分别使用 hash 和 history 两种实现方式回答上面的两个核心问题。
1、hash 实现
hash 是 URL 中 hash (#) 及后面的那部分,常用作锚点在页面内进行导航,改变 URL 中的 hash 部分不会引起页面刷新。
通过 hashchange 事件监听 URL 的变化,改变 URL 的方式只有这几种:通过浏览器前进后退改变 URL、通过标签改变 URL、通过window.location改变URL,这几种情况改变 URL 都会触发 hashchange 事件
2、history 实现
history 提供了 pushState 和 replaceState 两个方法,这两个方法改变 URL 的 path 部分不会引起页面刷新。
history 提供类似 hashchange 事件的 popstate 事件,但 popstate 事件有些不同:
通过浏览器前进后退改变 URL 时会触发 popstate 事件,通过pushState/replaceState或标签改变 URL 不会触发 popstate 事件。
好在我们可以拦截 pushState/replaceState的调用和标签的点击事件来检测 URL 变化,所以监听 URL 变化可以实现,只是没有 hashchange 那么方便。
四、原生JS版前端路由实现
基于上节讨论的两种实现方式,分别实现 hash 版本和 history 版本的路由,示例使用原生 HTML/JS 实现,不依赖任何框架。
1、基于 hash 实现
url链接:localhost/#/home
<body>
<ul>
<!-- 定义路由 -->
<li><a href="#/home">home</a></li>
<li><a href="#/about">about</a></li>
<!-- 渲染路由对应的 UI -->
<div id="routeView"></div>
</ul>
</body>
// 页面加载完不会触发 hashchange,这里主动触发一次 hashchange 事件
window.addEventListener('DOMContentLoaded', onLoad)
// 监听路由变化
window.addEventListener('hashchange', onHashChange)
// 路由视图
var routerView = null
function onLoad () {
routerView = document.querySelector('#routeView')
onHashChange()
}
// 路由变化时,根据路由渲染对应 UI
function onHashChange () {
switch (location.hash) {
case '#/home':
routerView.innerHTML = 'Home'
return
case '#/about':
routerView.innerHTML = 'About'
return
default:
return
}
}
2、基于 history 实现
url链接:localhost/about
<body>
<ul>
<li><a href='/home'>home</a></li>
<li><a href='/about'>about</a></li>
<div id="routeView"></div>
</ul>
</body>
// 页面加载完不会触发 hashchange,这里主动触发一次 hashchange 事件
window.addEventListener('DOMContentLoaded', onLoad)
// 监听路由变化
window.addEventListener('popstate', onPopState)
// 路由视图
var routerView = null
function onLoad () {
routerView = document.querySelector('#routeView')
onPopState()
// 拦截 <a> 标签点击事件默认行为, 点击时使用 pushState 修改 URL并更新手动 UI,从而实现点击链接更新 URL 和 UI 的效果。
var linkList = document.querySelectorAll('a[href]')
linkList.forEach(el => el.addEventListener('click', function (e) {
e.preventDefault()
history.pushState(null, '', el.getAttribute('href'))
onPopState()
}))
}
// 路由变化时,根据路由渲染对应 UI
function onPopState () {
switch (location.pathname) {
case '/home':
routerView.innerHTML = 'Home'
return
case '/about':
routerView.innerHTML = 'About'
return
default:
return
}
}
五、Vue 版本前端路由实现
1、基于hash实现
使用方式和 vue-router 类似(vue-router 通过插件机制注入路由,但是这样隐藏了实现细节,为了保持代码直观,这里没有使用 Vue 插件封装):
<div>
<ul>
<li><router-link to="/home">home</router-link></li>
<li><router-link to="/about">about</router-link></li>
</ul>
<router-view></router-view>
</div>
const routes = {
'/home': {
template: '<h2>Home</h2>'
},
'/about': {
template: '<h2>About</h2>'
}
}
const app = new Vue({
el: '.vue.hash',
components: {
'router-view': RouterView,
'router-link': RouterLink
},
beforeCreate () {
this.$routes = routes
}
})
router-view 实现
<template>
<component :is="routeView" />
</template>
<script>
import utils from '~/utils.js'
export default {
data () {
return {
routeView: null
}
},
created () {
this.boundHashChange = this.onHashChange.bind(this)
},
beforeMount () {
window.addEventListener('hashchange', this.boundHashChange)
},
mounted () {
this.onHashChange()
},
beforeDestroy() {
window.removeEventListener('hashchange', this.boundHashChange)
},
methods: {
onHashChange () {
const path = utils.extractHashPath(window.location.href)
this.routeView = this.$root.$routes[path] || null
console.log('vue:hashchange:', path)
}
}
}
</script>
router-link 实现
<template>
<a @click.prevent="onClick" href=''><slot></slot></a>
</template>
<script>
export default {
props: {
to: String
},
methods: {
onClick () {
window.location.hash = '#' + this.to
}
}
}
</script>
我们看到就是在router-link点击的时候改变url的hash,然后在router-view里监听了hashchange事件,取出hash的值,然后去routes里取是对应的那个模板
2、基于history实现
使用方式和 vue-router 类似:
<div>
<ul>
<li><router-link to="/home">home</router-link></li>
<li><router-link to="/about">about</router-link></li>
</ul>
<router-view></router-view>
</div>
const routes = {
'/home': {
template: '<h2>Home</h2>'
},
'/about': {
template: '<h2>About</h2>'
}
}
const app = new Vue({
el: '.vue.history',
components: {
'router-view': RouterView,
'router-link': RouterLink
},
created () {
this.$routes = routes
this.boundPopState = this.onPopState.bind(this)
},
beforeMount () {
window.addEventListener('popstate', this.boundPopState)
},
beforeDestroy () {
window.removeEventListener('popstate', this.boundPopState)
},
methods: {
onPopState (...args) {
this.$emit('popstate', ...args)
}
}
})
router-view 实现:
<template>
<component :is="routeView" />
</template>
<script>
import utils from '~/utils.js'
export default {
data () {
return {
routeView: null
}
},
created () {
this.boundPopState = this.onPopState.bind(this)
},
beforeMount () {
this.$root.$on('popstate', this.boundPopState)
},
beforeDestroy() {
this.$root.$off('popstate', this.boundPopState)
},
methods: {
onPopState (e) {
const path = utils.extractUrlPath(window.location.href)
this.routeView = this.$root.$routes[path] || null
console.log('[Vue] popstate:', path)
}
}
}
</script>
router-link 实现
<template>
<a @click.prevent="onClick" href=''><slot></slot></a>
</template>
<script>
export default {
props: {
to: String
},
methods: {
onClick () {
history.pushState(null, '', this.to)
this.$root.$emit('popstate')
}
}
}
</script>
这里就是用到了事件发射和接收去监听的popstate事件,然后取出url里的path,再去routes里取对应的模板。可以看下前几天写的这篇博客:vue事件派发和广播、发射与接收(可实现页面传值和非父子组件传值)
前端路由的核心实现原理很简单,但是结合具体框架后,框架增加了很多特性,如动态路由、路由参数、路由动画等等,这些导致路由实现变的复杂。本文去粗取精只针对前端路由最核心部分的实现进行分析,并基于 hash 和 history 两种模式,分别提供原生JS/Vue 实现,希望对你有所帮助。