前端路由原理及实现
现代前端项目多为单页Web应用(SPA),在单页Web应用中路由是其中的重要环节。
在 SPA 的应用设计中,一个应用只有一个HTML文件,在HTML文件中包含一个占位符(即图中的 container),占位符对应的内容由每个视图来决定,对于 SPA 来说,页面的切换就是视图之间的切换。
最开始的网页是多页面的,直到 Ajax 的出现,才慢慢有了 SPA。
SPA 的出现大大提高了 WEB 应用的交互体验。在与用户的交互过程中,不再需要重新刷新页面,获取数据也是通过 Ajax 异步获取,页面显示变的更加流畅。
但由于 SPA 中用户的交互是通过 JS 改变 HTML 内容来实现的,页面本身的 url 并没有变化,这导致了两个问题:
- SPA 无法记住用户的操作记录,无论是刷新、前进还是后退,都无法展示用户真实的期望内容。
- SPA 中虽然由于业务的不同会有多种页面展示形式,但只有一个 url,对 SEO 不友好,不方便搜索引擎进行收录。
前端路由就是为了解决上述问题而出现的。
什么是前端路由
简单的说,就是在保证只有一个 HTML 页面,且与用户交互时不刷新和跳转页面的同时,为 SPA 中的每个视图展示形式匹配一个特殊的 url。在刷新、前进、后退和SEO时均通过这个特殊的 url 来实现。
为实现这一目标,我们需要做到以下二点:
- 改变 url 且不让浏览器像服务器发送请求
- 可以监听到 url 的变化
hash 路由
hash
值的变化不会导致浏览器向服务器发送请求hash
的改变会触发 hashchange 事件,浏览器的前进后退也能对其进行控制
所以在 H5 的 history 模式出现之前,基本都是使用 hash 模式来实现前端路由。
hash-router.js
class HashRouter {
constructor() {
this.routers = {}
// 挂载事件
window.addEventListener('hashchange', this.load.bind(this), false)
}
// 用于注册每个视图
register(hash, cb) {
if (typeof cb !== 'function') cb = () => { }
this.routers[hash] = cb
}
// 不存在hash值时,认为是首页
registerIndex(cb) {
if (typeof cb !== 'function') cb = () => { }
this.routers['index'] = cb
}
//用于处理视图未找到的情况
registerNotFound(cb) {
if (typeof cb !== 'function') cb = () => { }
this.routers['404'] = cb
}
//用于处理异常情况
registerError(cb) {
if (typeof cb !== 'function') cb = () => { }
this.routers['error'] = cb
}
load() {
// 去掉开头 # 号,如果没有 hash 说明是首页
let hash = location.hash.slice(1)
let cb
console.log('hash: ', hash)
if (hash === '') {
cb = this.routers['index']
} else if (!this.routers.hasOwnProperty(hash)) {
cb = this.routers['404'] || (() => { })
} else {
cb = this.routers[hash]
}
try {
console.log(cb)
cb()
} catch (error) {
console.dir(error.message)
let errorCb = this.routers['error'] || function (e) { }
errorCb(error)
}
}
}
index.html
<body>
<div id="nav">
<a href="#/page1">page1</a>
<a href="#/page2">page2</a>
<a href="#/page3">page3</a>
<a href="#/404">404</a>
<a href="#/error">Error</a>
</div>
<div id="container"></div>
</body>
<script src="hash-router.js"></script>
<script>
let router = new HashRouter()
let container = document.getElementById('container')
// 注册首页
router.registerIndex(() => (container.innerHTML = '我是首页'))
// 注册其他视图
router.register('/page1', () => (container.innerHTML = '我是page1'))
router.register('/page2', () => (container.innerHTML = '我是page2'))
router.register('/page3', () => (container.innerHTML = '我是page3'))
router.register('/error', () => {
throw new Error('我是报错信息')
})
// 注册 404 事件
router.registerNotFound(() => (container.innerHTML = '404,页面找不到了'))
// 注册hash回调函数异常
router.registerError(
err => (container.innerHTML = '页面异常,错误消息:<br>' + err.message)
)
// 先执行一次,加载出首页
window.onload = router.load()
</script>
body>
history 路由
HTML5 引入了
history.pushState()
和history.replaceState()
方法,它们分别可以添加和修改历史记录条目。这些方法通常与window.onpopstate
配合使用。
都不会触发跳转,但是区别在于:
history.pushState()
在保留现有历史记录的同时,将 url 加入到历史记录中。history.replaceState()
会将历史记录中的当前页面历史替换为 url。
由于 pushState()
和 replaceState()
可以改变 url 同时,不会刷新页面,所以在 HTML5 中的 histroy 具备了实现前端路由的能力。
不同于 hash,history 的改变并不会触发任何事件,所以我们无法直接监听 history 的改变而做出相应的改变。
要换个思路,我们可以罗列出所有可能触发 history 改变的情况,并且将这些方式一一进行拦截,变相地监听 history 的改变。
对于单页应用的 history 模式而言,url 的改变只能由下面四种方式引起:
- 点击浏览器的前进或后退按钮
- 点击
a
标签 - 在 JS 代码中触发
history.pushState
函数 - 在 JS 代码中触发
history.replaceState
函数
class HistoryRouter {
constructor() {
//用于存储不同path值对应的回调函数
this.routers = {}
this.listenPopState()
this.listenLink()
}
// 监听返回按钮
listenPopState() {
window.addEventListener('popstate', (e) => {
console.log('popstate', e)
let state = e.state || {}
let path = state.path || ''
this.handleCallback(path)
}, false)
}
// 阻止 a 跳转,调用我们自己的 go 函数改变 dom 内容
listenLink() {
window.addEventListener('click', (e) => {
let dom = e.target
if (dom.tagName.toUpperCase() === 'A' && dom.getAttribute('href')) {
console.log('click a', e)
e.preventDefault()
this.go(dom.getAttribute('href'))
}
}, false)
}
// 用于首次load
load() {
let path = location.pathname
this.handleCallback(path)
}
//用于注册每个视图
register(path, cb = function () { }) {
this.routers[path] = cb
}
//用于注册首页
registerIndex(cb = function () { }) {
this.routers['/'] = cb
}
//用于处视图未找到的情况
registerNotFound(cb = function () { }) {
this.routers['404'] = cb
}
//用于处理异常情况
registerError(cb = function () { }) {
this.routers['error'] = cb
}
// 跳转到 path
go(path) {
// 关键是 pushState 函数
history.pushState({ path }, null, path)
this.handleCallback(path)
}
handleCallback(path) {
let cb
if (!this.routers.hasOwnProperty(path)) {
cb = this.routers['404'] || function () { }
} else {
cb = this.routers[path]
}
try {
cb()
} catch (error) {
let errorCb = this.routers['error'] || function (e) { }
errorCb(error)
}
}
}
index.html
<body>
<body>
<div id="nav">
<a href="/page1">page1</a>
<a href="/page2">page2</a>
<a href="/page3">page3</a>
<a href="/404">404</a>
<a href="/error">Error</a>
<button id="btn">page2</button>
</div>
<div id="container"></div>
</body>
<script src="history-router.js"></script>
<script>
let router = new HistoryRouter()
let container = document.getElementById('container')
// 注册首页
router.registerIndex(() => (container.innerHTML = '我是首页'))
// 注册其他视图
router.register('/page1', () => (container.innerHTML = '我是page1'))
router.register('/page2', () => (container.innerHTML = '我是page2'))
router.register('/page3', () => (container.innerHTML = '我是page3'))
router.register('/error', () => {
throw new Error('我是报错信息')
})
document.getElementById('btn').onclick = () => router.go('/page2')
// 注册 404 事件
router.registerNotFound(() => (container.innerHTML = '404,页面找不到了'))
// 注册hash回调函数异常
router.registerError(
err => (container.innerHTML = '页面异常,错误消息:<br>' + err.message)
)
// 先执行一次,加载出首页
window.onload = router.load()
</script>
</body>
hash、history 如何抉择
hash 模式优点:
- 兼容性更好,可以兼容到IE8
- 无需服务端配合处理非单页的url地址
hash 模式缺点:
- 看起来更丑。
- 会导致锚点功能失效。
- 相同 hash 值不会触发动作将记录加入到历史栈中,而 pushState 则可以。
另外,使用 history 可能产生刷新404的问题。
综上所述,当我们不需要兼容老版本IE浏览器,并且可以控制服务端覆盖所有情况的候选资源时,我们可以愉快的使用 history 模式了。
总结
那么,前端路由本质上,就是去改变url,但是不让游览器跳转,而是使用我们自己的方法来改变部分DOM。
因此有了两种解决方案:hash 和 history。
对于hash,原理是
hash
值的变化不会导致浏览器向服务器发送请求hash
的改变会触发hashchange
事件,浏览器的前进后退也能对其进行控制
对于 history,关键是 history.pushState
函数、popstate
事件和屏蔽 <a>
的默认跳转
pushState
用于在浏览历史中添加历史记录,但是并不触发跳转
state
:一个与指定网址相关的状态对象,popstate
事件触发时,该对象会传入回调函数。如果不需要这个对象,此处可以填null
。title
:新页面的标题,但是所有浏览器目前都忽略这个值,因此这里可以填null
。url
:新的网址,必须与当前页面处在同一个域。浏览器的地址栏将显示这个网址。
popstate
每当同一个文档的浏览历史(即history对象)出现变化时,就会触发popstate事件。
需要注意的是,仅仅调用pushState
方法或replaceState
方法 ,并不会触发该事件,只有用户点击浏览器倒退按钮和前进按钮,或者使用 JavaScript 调用back
、forward
、go
方法时才会触发。
屏蔽 a
标签跳转,或者直接使用 button 来做,或者其他标签挂 onclick
也可以的。