前端路由原理及实践
在单页应用出现之前,页面的跳转会导致整个页面刷新,体验很不友好。ajax的出现可以让页面进行局部刷新,而单页的前端路由可以做到“页面跳转”时同样是页面级别的局部刷新。
前端路由的基本原理是:匹配不同的 url 路径,进行解析,然后动态的渲染出区域 html 内容。
实现方式有2种:hash实现和history实现。
# hash模式
我们知道,从a.html
通过location.href
的方式跳转到b.html
,页面会重新向服务器发出页面请求,导致重新加载页面。但是,如果只是hash值变化,浏览器不发出请求,页面也就不会刷新,这个通常是a标签进行锚点跳转的时候的行为。利用这一特性,可以应用于前端路由,当hash发生变化时,可以被hashchange事件或popstate事件(需要html5 API支持)监听到,之后就可以根据配置的路由页面进行页面的局部更新。
# hash原理
提取hash相关代码,如下
setupListeners () {
if (this.listeners.length > 0) {
return
}
const handleRoutingEvent = () => {
this.transitionTo(getHash(), route => {
if (!supportsPushState) {
replaceHash(route.fullPath)
}
})
}
const eventType = supportsPushState ? 'popstate' : 'hashchange'
window.addEventListener(
eventType,
handleRoutingEvent
)
this.listeners.push(() => {
window.removeEventListener(eventType, handleRoutingEvent)
})
}
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const { current: fromRoute } = this
this.transitionTo(
location,
route => {
pushHash(route.fullPath)
onComplete && onComplete(route)
},
onAbort
)
}
function pushHash (path) {
if (supportsPushState) {
pushState(getUrl(path))
} else {
window.location.hash = path
}
}
function pushState (url?: string, replace?: boolean) {
// try...catch the pushState call to get around Safari
// DOM Exception 18 where it limits to 100 pushState calls
const history = window.history
try {
if (replace) {
// preserve existing history state as it could be overriden by the user
const stateCopy = extend({}, history.state)
stateCopy.key = getStateKey()
history.replaceState(stateCopy, '', url)
} else {
history.pushState({ key: setStateKey(genStateKey()) }, '', url)
}
} catch (e) {
window.location[replace ? 'replace' : 'assign'](url)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
可以看到,vue-router会优先选择popstate监听,如果浏览器不支持,则使用hashchange兜底。
# hash实现
由于只探讨路由原理,此处不包含模板编译、路由守卫、路由记录、滚动记录等功能。
<style>
#app {
text-align: center;
}
#nav {
padding: 20px;
}
</style>
<div id="app">
<div id="nav">
<a href="#/">Home</a>
|
<a href="#/about">About</a>
</div>
<div id="nav">
<button class="hash-push-home">hash push Home</button>
<button class="hash-push-about">hash push About</button>
</div>
<div id="nav">
<button class="history-push-home">history push Home</button>
<button class="history-push-about">history push About</button>
</div>
<div class="router"></div>
</div>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const homeView = `
<div class="home">
<h1>This is home page</h1>
</div>
`
const aboutView = `
<div class="about">
<h1>This is about page</h1>
</div>
`
const routerView = document.querySelector('.router')
// 监听hashchange事件
// window.addEventListener('hashchange', e => {
window.addEventListener('popstate', e => {
console.log('popstate/hashchange')
const hash = window.location.hash
// 根据hash变化,加载对应模块
if (hash === '#/') {
routerView.innerHTML = homeView
} else if (hash === '#/about') {
routerView.innerHTML = aboutView
}
})
// 模拟编程导航API,如push,使用hash相关api
const hashPushHome = document.querySelector('.hash-push-home')
const hashPushAbout = document.querySelector('.hash-push-about')
hashPushHome.addEventListener('click', () => {
location.hash = '/'
})
hashPushAbout.addEventListener('click', () => {
location.hash = '/about'
})
// 模拟编程导航API,如push,使用pushstate相关api
const historyPushHome = document.querySelector('.history-push-home')
const historyPushAbout = document.querySelector('.history-push-about')
historyPushHome.addEventListener('click', () => {
// 让地址栏变化
history.pushState({ key: Date.now() }, '', '#/')
// 因为pushState不会再次触发popstate/hashchange
// 所以手动替换视图
routerView.innerHTML = homeView
})
historyPushAbout.addEventListener('click', () => {
history.pushState({ key: Date.now() }, '', '#/about')
routerView.innerHTML = aboutView
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# history模式
# history原理
使用了html5的api,即history.pushState
,需要注意的是,使用该方式后地址栏会变化,但不会再触发popstate,所以需要手动替换视图。
setupListeners () {
if (this.listeners.length > 0) {
return
}
const handleRoutingEvent = () => {
const current = this.current
// Avoiding first `popstate` event dispatched in some browsers but first
// history route not updated since async guard at the same time.
const location = getLocation(this.base)
if (this.current === START && location === this._startLocation) {
return
}
this.transitionTo(location)
}
window.addEventListener('popstate', handleRoutingEvent)
this.listeners.push(() => {
window.removeEventListener('popstate', handleRoutingEvent)
})
}
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const { current: fromRoute } = this
this.transitionTo(location, route => {
pushState(cleanPath(this.base + route.fullPath))
handleScroll(this.router, route, fromRoute, false)
onComplete && onComplete(route)
}, onAbort)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
该方式和上述hash模式的pushState没有太大区别,唯一区别在于路由对“#”的处理。
history模式没有了"#",但直接访问非首页页面时会出现404,需要服务端进行配置。见官方文档 (opens new window)。
# history实现
<div id="app">
<div id="nav">
<button class="history-push-home">history push Home</button>
<button class="history-push-about">history push About</button>
</div>
<div class="router"></div>
</div>
2
3
4
5
6
7
const routerView = document.querySelector('.router')
// 模拟编程导航API,如push,使用history相关api
const historyPushHome = document.querySelector('.history-push-home')
const historyPushAbout = document.querySelector('.history-push-about')
init()
historyPushHome.addEventListener('click', () => {
// 让地址栏变化
history.pushState({ key: Date.now() }, '', '/')
// 因为pushState不会再次触发popstate/hashchange
// 所以手动替换视图
routerView.innerHTML = homeView
})
historyPushAbout.addEventListener('click', () => {
history.pushState({ key: Date.now() }, '', '/about')
routerView.innerHTML = aboutView
})
// 初始化页面
// 注意刷新页面后为404的场景
// 需要服务器配置页面重定向
function init() {
const { pathname } = location
switch (pathname) {
case '/about':
routerView.innerHTML = aboutView
break
default:
routerView.innerHTML = homeView
break
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
如果使用http-server开启本地服务,可以直接访问http://127.0.0.1:8080
,如下
案例是在windows上测试的,于是下载windows版本的nginx进行调试。
安装:将zip文件解压到目标目录。
切换到安装目录下,运行nginx命令。
nginx命令集合
# 启动
start nginx
# 重启
nginx -s reload
# 停止
nginx -s stop
2
3
4
5
6
启动后,可以配置nginx.config文件。如下:
server {
listen 9090;
location / {
root xxx\vue-router-study/src/history;
try_files $uri $uri/ /index.html;
}
}
2
3
4
5
6
7
然后重启nginx,访问页面http://127.0.0.1:9090
,搞定。
# 路由基础
安装和使用参考vue-router (opens new window)。
# 简单示例
以下是一个基本示例。
- 路由配置
// router.js
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
export default new Router({
mode: 'history',
base: process.env.BASE_URL, // 对应到vue.config.js中的publicPath
routes: [
{
// 重定向
path: '/',
redirect: '/home'
},
{
path: '/home',
name: 'home',
meta: {
title: '首页'
},
// 路由懒加载,减小chunk
component: () => import(/* webpackChunkName: "home" */ '@/views/home/index.vue')
},
{
path: '/about',
name: 'about',
meta: {
title: '关于'
},
component: () => import(/* webpackChunkName: "about" */ '@/views/about/index.vue')
},
{
path: '/404',
name: 'page404',
meta: {
title: '404'
},
component: () => import(/* webpackChunkName: "page404" */ '@/views/page404/index.vue')
},
{
// 放在最后,用于404页面显示
path: '*',
redirect: '/404'
}
]
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
- Vue使用路由
// main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
new Vue({
render: h => h(App),
router
}).$mount('#app')
2
3
4
5
6
7
8
9
10
使用
<router-view>
来渲染匹配到的组件。<!-- App.vue --> <template> <router-view></router-view> </template>
1
2
3
42种方式导航
<router-link :to="{ name: 'about'}">goto about</router-link>
1this.$router.push({ path: `/about?num=${this.num}` })
1
# 导航方式
Vue Router提供2种导航方式:
<router-link>
声明式导航。平时用这种方式较少,详细使用见官方文档。
router编程式导航。
router实例上的方法,如下
router.push
// 字符串 router.push('home') // 对象 router.push({ path: 'home' }) // 命名的路由 router.push({ name: 'user', params: { userId: '123' }}) // 带查询参数,变成 /register?plan=private router.push({ path: 'register', query: { plan: 'private' }})
1
2
3
4
5
6
7
8
9
10
11router.replace
用法同
router.push
,区别在于它不会向history添加新记录。router.go
用于前进和后退,类似于
window.history.go(n)
。
# history模式
当配置mode: 'history',
时,就摆脱了颜值不高的#
,但会带来一个问题:直接访问非首页的页面时,服务器返回404,因为url匹配不到任何资源。
解决方式官网 (opens new window)已给出,只需要在匹配不到资源时返回index.html即可。如下:
location / {
try_files $uri $uri/ /index.html;
}
2
3
# 路由进阶
# 导航守卫
守卫即拦截,当从一个页面进入到下一个页面时,将会触发路由导航钩子,实现我们的需求,比如权限和条件控制。包括3类守卫:
- 全局守卫:前置守卫,解析守卫,后置钩子
- 路由(配置)守卫
- 组件内守卫
回调结构基本相似,比如
router.beforeEach((to, from, next) => {
// ...
})
2
3
to: Route
: 即将要进入的目标from: Route
: 当前导航正要离开的路由next: Function
: 一定要调用该方法来 resolve 这个钩子。
# 登录鉴权
我们可以配置meta字段,配合全局守卫来进行登录鉴权。
router.beforeEach((to, from, next) => {
if (to.matched.some(record => record.meta.requiresAuth)) {
// this route requires auth, check if logged in
// if not, redirect to login page.
if (!store.state.isLogin) {
next({
path: '/login',
query: { redirect: to.fullPath }
})
} else {
next()
}
} else {
next() // 确保一定要调用 next()
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这里之所以使用to.matched.some
方法判断,是因为二级路由会全部匹配到,只要一级路由设置了需要登录态即可。
# 进入页面前加参数
这个常见于分享链接需求中,要求每次链接进行跳转时带上比如渠道id等信息,实现方式如下:
router.beforeEach((to, from, next) => {
const { channel: fromChannel } = from.query
const { channel: toChannel } = to.query
if (fromChannel && !toChannel) { // 只有当前页面有channel,下一页面无channel时,才透传
const { query, path } = to
query.channel = fromChannel
next({
path,
query
})
} else {
next()
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 过渡动画
实际是<transition>
组件的效果之一,路由在切换时会切换类名,此时可以加上转场动画。
// App.vue
<template>
<div class="App-wrap">
<transition :name="transitionName" mode="out-in">
<router-view />
</transition>
</div>
</template>
<script>
export default {
name: 'App',
data() {
return {
transitionName: 'slide-right'
}
},
watch: {
$route(to, from) {
const toDepth = to.path.split('/').length
const fromDepth = from.path.split('/').length
this.transitionName = toDepth < fromDepth ? 'slide-right' : toDepth > fromDepth ? 'slide-left' : ''
}
},
}
</script>
<style lang="scss" scoped>
.App-wrap {
.slide-left-enter {
transform: translate3d(100%, 0, 0);
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: transform 0.4s ease;
}
.slide-right-leave-to {
transform: translate3d(100%, 0, 0);
}
.slide-right-enter-active,
.slide-right-leave-active {
transition: transform 0.4s ease;
}
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45