前端路由的原理及自定义
# 背景
在之前用户的每次更新操作都需要重新刷新页面,非常的影响交互体验,后来,为了解决这个问题,便有了Ajax(异步加载方案),Ajax给体验带来了极大的提升。
虽然Ajax解决了用户交互时体验的痛点,但是多页面之间的跳转一样会有不好的体验,所以便有了spa(single-page application)使用的诞生。而spa应用便是基于前端路由实现的,所以便有了前端路由。
如今比较火的vue-router/react-router 也是基于前端路由的原理实现的
# 原理解析
前端路由实现原理很简单,本质上就是检测 URL 的变化,截获 URL 地址,通过解析、匹配路由规则实现 UI 更新。
路由描述了 URL 与 UI 之间的映射关系,这种映射是单向的,即 URL 变化引起 UI 更新(无需刷新页面)。前端路由最主要的展示方式有 2 种:
- 带有 hash 的前端路由:地址栏 URL 中有 #,即 hash 值,不好看,但兼容性高。
- 不带 hash 的前端路由:地址栏 URL 中没有 #,好看,但部分浏览器不支持,还需要后端服务器支持。
在 vue-router 和 react-router 中,这两种展示形式,被定义成两种模式,即 Hash 模式与 History 模式。路由切换可以重新渲染组件,但是不刷新页面;
# 两种实现原理
# Hash模式
# window对象提供了onhashchange事件来监听hash值的改变,一旦url中的hash值发生改变,便会触发该事件。
一个完整的 URL 包括:协议、域名、端口、虚拟目录、文件名、参数、锚。
hash 值指的是 URL 地址中的锚部分,也就是 # 后面的部分。hash 也称作锚点,是用来做页面定位的,与 hash 值对应的 DOM id 显示在可视区内。在 HTML5 的 history 新特性出现前,基本都是使用监听 hash 值来实现前端路由的。hash 值更新有以下几个特点:
- hash 值是网页的标志位,HTTP 请求不包含锚部分,对后端无影响
- 因为 HTTP 请求不包含锚部分,所以 hash 值改变时,不触发网页重载
- 改变 hash 值会改变浏览器的历史记录
- 改变 hash 值会触发 window.onhashchange() 事件
而改变 hash 值的方式有 3 种:【要点】
- a 标签使锚点值变化,例:
<a href='#/home'></a>
- 通过设置 window.location.hash 的值
- 浏览器前进键(history.forword())、后退键(history.back())
# 流程图示
# History 模式
# HTML5的History API 为浏览器的全局history对象增加的扩展方法。window对象提供了onpopstate事件来监听历史栈的改变,一旦历史栈信息发生改变,便会触发该事件。
需要特别注意的是,调用history.pushState()或history.replaceState()不会触发popstate事件。只有在做出浏览器动作时,才会触发该事件。
window.onpopstate = function(){}// 历史栈 信息改变
history提供了两个操作历史栈的API:
history.pushState
和 history.replaceState
history.pushState(data[,title][,url]);//向历史记录中追加一条记录
history.replaceState(data[,title][,url]);//替换当前页在历史记录中的信息。
2
参数说明
- data: 一个JavaScript对象,与用pushState()方法创建的新历史记录条目关联。无论何时用户导航到新创建的状态,popstate事件都会被触发,并且事件对象的state属性都包含历史记录条目的状态对象的拷贝。
- title: FireFox浏览器目前会忽略该参数,虽然以后可能会用上。考虑到未来可能会对该方法进行修改,传一个空字符串会比较安全。或者,你也可以传入一个简短的标题,标明将要进入的状态。
- url: 新的历史记录条目的地址。浏览器不会在调用pushState()方法后加载该地址,但之后,可能会试图加载,例如用户重启浏览器。新的URL不一定是绝对路径;如果是相对路径,它将以当前URL为基准;传入的URL与当前URL应该是同源的,否则,pushState()会抛出异常。该参数是可选的;不指定的话则为文档当前URL。
简单描述
- state:用于存储该 URL 对应的状态对象,可以通过 history.state 获取
- title:标题,目前浏览器并不支持
- URL:定义新的历史 URL 记录,需要注意,新的 URL 必须与当前 URL 同源,不能跨域
# 流程图示
# 两种模式对比[要点]
对比 | Hash | History |
---|---|---|
观赏性 | 丑 | 美 |
兼容性 | >ie8 | >ie10 |
实用性 | 直接使用 | 需后端配合 |
命名空间 | 同一document | 同源 |
说明:
- History新的 URL 可以是任意同源的 URL,而 window.location只能通过改变 hash 值才能保证留在当前 document 中,浏览器不发起请求;
- 新的 URL 可以是当前 URL,不改变,就可以创建一条新的历史记录项,而 window.location 必须设置不同的 hash 值,才能创建。假如当前URL为
/home.html#foo
,使用 window.location 设置 hash 时,hash值必须不能是#foo
,才能创建新的历史记录; - 可以通过 data/state 参数在新的历史记录项中添加任何数据,而通过 window.location 改变 hash 的方式,只能将相关的数据转成一个很短的字符串,以 query 的形式放到 hash 值后面;
- History模式:仅仅将浏览器的 URL 变成了新的 URL,页面不会加载、刷新。pushState 在不刷新页面的情况下修改浏览器 URL 链接,单页面路由的实现也就是利用了这一个特性。【要点】
# history模式404
当使用history模式时,如果没有进行配置,刷新页面会出现404。
原因是因为history模式的url是真实的url, 服务器会对url的文件路径进行资源查找,找不到资源就会返回404
在以下demo使用webpack-dev-server的里的historyApiFallback属性来支持HTML5 History Mode。
//historyApiFallback // 依赖HTML5 history API, 如果设置为true,所有的页面跳转指向index.html devServer:{ contentBase: './src' // 本地服务器所加载的页面所在的目录 historyApiFallback: true, // 不跳转,默认是true,可不用配置 inline: true // 实时刷新 } //正式环境下nginx配置:try_files $uri $uri/ /index.html; server { listen 8888; root /home/npm/deploy/bdp-website/dist; index index.html index.htm index.nginx-debian.html; client_max_body_size 500M; proxy_connect_timeout 500s; proxy_send_timeout 500s; proxy_read_timeout 500s; send_timeout 500s; location / { try_files $uri $uri/ =404; #try_files $uri $uri/ /index.html; } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 前端SPA路由应用/比较
其实 history 和 hash 都是浏览器自有的特性,单页面路由只是利用了这些特性。在不跳出当前 document 的情况下,除了 history 自身的兼容性之外,各个浏览器都不会存在差异,而单页面开发就是在一个 document 中完成所有的交互,这两者的完美结合,将前端开发提升到了一个新的高度。
vue-router 和 react-router 是现在最流行的路由状态管理工具。两者实现原理虽然是一致的,但由于所依赖的技术栈不同,使用方式也略有不同。在 react 技术栈开发时,大部分的童鞋还是喜欢使用 react-router-dom ,它基于 react-router,加入了在浏览器运行环境下的一些功能。
# 注入方式
1. vue-router
vue-router 可以在 vue 项目中全局使用,vue.use()
功不可没。通过 vue.use()
,向 VueRouter 对象注入了 Vue 实例,也就是根组件。根组件将 VueRouter 实例一层一层的向下传递,让每个渲染的子组件拥有路由功能。
import VueRouter from 'vue-router'
const routes = [
{ path: '/',name: 'home',component: Home,meta:{title:'首页'} }
]
const router = new myRouter({
mode:'history',
routes
})
Vue.use(VueRouter)
2
3
4
5
6
7
8
9
2. react-router-dom
react-router 的注入方式是在组件树顶层放一个 Router 组件,然后在组件树中散落着很多 Route 组件,顶层的 Router 组件负责分析监听 URL 的变化,在其下面的 Route 组件渲染对应的组件。在完整的单页面项目中,使用 Router 组件将根组件包裹,就能完成保证正常的路由跳转。
import { BrowserRouter as Router, Route } from 'react-router-dom';
class App extends Component {
render() {
return (
<Router>
<Route path='/' exact component={ Home }></Route>
</Router>
)
}
}
2
3
4
5
6
7
8
9
10
# 基础组件
1. vue-router 提供的组件主要有 <router-link/>
和<router-view/>
<router-link/>
可以操作 DOM 直接进行跳转,定义点击后导航到哪个路径下;对应的组件内容渲染到<router-view/>
中。
2. react-router-dom 常用到的是 <BrowserRouter/>
、<HashRouter/>
、<Route/>
、<Link/>
、<Switch/>
<BrowserRouter/>
、<HashRouter/>
组件看名字就知道,用于区分路由模式,并且保证 React 项目具有页面跳转能力。<Link />
组件与 vue-router 中的<router-link/>
组件类似,定义点击后的目标导航路径,对应的组件内容通过<Route />
进行渲染。<Switch/>
用来将 react-router 由包容性路由转换为排他性路由,每次只要匹配成功就不会继续向下匹配。vue-router 属于排他性路由。
# 路由模式
1. vue-router 主要分为 hash 和 history 两种模式。在 new VueRouter() 时,通过配置路由选项 mode 实现。
Hash 模式:地址栏 URL 中有 #。vue-router 优先判断浏览器是否支持 pushState,若支持,则通过 pushState 改变 hash 值,进行目标路由匹配,渲染组件,popstate 监听浏览器操作,完成导航功能,若不支持,使用 location.hash 设置 hash 值,hashchange 监听 URL 变化完成路由导航。
History 模式:地址栏 URL 中没有 #。与 Hash 模式实现导航的思路是一样的。不同的是,vue-router 提供了 fallback 配置,当浏览器不支持 history.pushState 控制路由是否应该回退到 hash 模式。默认值为 true。【兼容处理】
网上资料对 Hash 路由模式的原理分析大都是通过 location.hash 结合 hashchange 实现,与上述描述的 hash 路由模式的实现方式不同,这也是小编最近阅读 vue-router 源码发现的,鼓励小伙伴们读一下,肯定会收获满满!
2. react-router-dom 常用的 2 种模式是 browserHistory、hashHistory,直接用 <BrowserRouter>
或 <HashHistory>
将根组件(通常是 <App>
)包裹起来就能实现。
- react-router 的实现依赖 history.js,history.js 是 JavaScript 库。
<BrowserRouter>
、<HashHistory>
分别基于 history.js 的 BrowserHistory 类、HashHistory 类实现。 - BrowserHistory 类通过 pushState、replaceState 和 popstate 实现,但并没有类似 vue-router 的兼容处理。HashHistory 类则是直接通过 location.hash、location.replace 和 hashchange 实现,没有优先使用 history 新特性的处理。
# 嵌套路由与子路由
1. vue-router 嵌套路由
在 new VueRouter() 配置路由表时,通过定义 Children 实现嵌套路由,无论第几层的路由组件,都会被渲染到父组件 <router-view/>
标识的地方。
router.js
const router = new Router({
mode:'history',
routes: [{
path: '/nest',
name: 'nest',
component: Nest,
children:[{
path:'first',
name:'first',
component:NestFirst
}]
}]
})
2
3
4
5
6
7
8
9
10
11
12
13
nest.vue
<div class="nest">
一级路由 <router-view></router-view>
</div>
2
3
first.vue
<div class="nest">
二级路由 <router-view></router-view>
</div>
2
3
在 /nest
下设置了二级路由 /first
,二级对应的组件渲染在一级路由匹配的组件 <router-view/>
标识的地方。在配置子路由时,path 只需要是当前路径即可。
2. react-router 子路由
react-router 根组件会被渲染到 <Router/>
指定的位置,子路由则会作为子组件,由父组件指定该对象的渲染位置。如果想要实现上述 vue-router 嵌套的效果,需要这样设置:
route.js
const Route = () => (
<HashRouter>
<Switch>
<Route path="/nest" component={Nest}/>
</Switch>
</HashRouter>
);
2
3
4
5
6
7
nest.js
export default class Nest extends Component {
render() {
return (
<div className="nest">
一级路由
<Switch>
<Route path="/nest/first" component={NestFirst}/>
</Switch>
</div>
)
}
}
2
3
4
5
6
7
8
9
10
11
12
first.js
export default class NestFirst extends Component {
render() {
return (
<div className="nest">
二级路由
<Switch>
<Route exact path="/nest/first/second" component={NestSecond}/>
</Switch>
</div>
)
}
}
2
3
4
5
6
7
8
9
10
11
12
其中,/nest
为一级路由,/fitst
二级路由匹配的组件,作为一级路由的子组件。react-router 定义子路由 path 时,需要写完整的路径,即父路由的路径要完整。
# 路由守卫【要点】
1. vue-router 导航守卫分为全局守卫、路由独享守卫、组件内的守卫三种。主要用来通过跳转或取消的方式守卫导航。
确保要调用 next
方法,否则钩子就不会被 resolved。参数: 有to(去的那个路由)、from(离开的路由)、**next(一定要用这个函数才能去到下一个路由,如果不用就拦截)**最常用就这几种。
a. 全局守卫
beforeEach
— 全局前置钩子(每个路由调用前都会触发,根据 from 和 to 来判断是哪个路由触发)beforeResolve
— 全局解析钩子(和 router.beforeEach 类似,区别是在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后,解析守卫就被调用)afterEach
— 全局后置钩子【常用】router.afterEach(() => { NProgress.done() })
1
2
3
b. 路由独享守卫
- 路由配置上可以直接定义
beforeEnter
守卫。
c. 组件内守卫
beforeRouteEnter
— 在渲染该组件的对应路由被 confirm 前调用,不能获取组件实例this
,因为当守卫执行前,组件实例还没被创建。beforeRouteUpdate
— 当前路由改变,但是该组件被复用时调用beforeRouteLeave
— 导航离开该组件的对应路由时调用
2. react-router 4.0 版本之前,提供了 onEnter 和 onLeave 钩子,实现类似 vue-router 导航守卫的功能,但 4.0 版本后取消了该方法。
router.beforeEach((to,from,next)=>{})
# 路由信息
1. vue-router 中router、route 对象
vue-router 在注册时,为每个 vue 实例注入了router、route 对象。router
为router实例信息,利用push和replace方法实现路由跳转,route
提供当前激活的路由信息。
import router from './router'
export default new Vue({
el: '#app',
router,
render: h => h(App),
})
2
3
4
5
6
2. react-router 中 history、location 对象
在每个由 <Route/>
包裹的组件中提供了 history、location 对象。利用 this.props.history
的 push、replace 方法实现路由导航,this.props.location
获取当前激活的路由信息。
const BasicRoute = () => (
<div>
<HeaderNav></HeaderNav>
<HashRouter>
<Switch>
<Route exact path="/" component={Home}/>
</Switch>
</HashRouter>
</div>
);
2
3
4
5
6
7
8
9
10
如果想要获得 history、location 一定是<Route />
包裹的组件。所以在 <HeaderNav/>
中是无法获取这两个对象的,而 <Home/>
组件是可以的。
vue-router 是全局配置方式,react-router 是全局组件方式,但两者呈现给开发者的功能实际上是大同小异的。说到底,不管用什么样的方式实现,前端路由的实现原理都是不会变的。
# 自己封装实现
base基类处理:待优化;目前只做了渲染,可做抽象处理;
# Hash
- 监听
hashchange
window.location.hash
,window.location.replace
<a href="#/a">a页面</a>
<a href="#/b">b页面</a>
2
import { BaseRouter } from "./base.js";
export class HashRouter extends BaseRouter {
constructor(list) {
super(list);
this.handler();//第一次要主动渲染
//监听hash变化事件,hash变化重新渲染
window.addEventListener("hashchange", (e) => {
this.handler();
});
}
//渲染
handler() {
this.render(this.getState());
}
//获取当前hash
getState() {
const hash = window.location.hash;
return hash ? hash.slice(1) : "/";
}
//获取完整url
getUrl(path) {
const href = window.location.href;
const i = href.indexOf("#");
const base = i >= 0 ? href.slice(0, i) : href;
return `${base}#${path}`;
}
//改变hash值 实现压入 功能
push(path) {
window.location.hash = path;
}
//使用location.replace实现替换 功能
replace(path) {
window.location.replace(this.getUrl(path));
}
go(n) {
//window.history.go(n);
}
}
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
# History
- 监听
popstate
window.location.pathname
,history.pushState({},null,'/x')
,history.replaceState
- pushState不会触发popstate事件,所以需要手动调用渲染函数;
<a onClick="goA()">a页面</a>
<a onClick="goB()">b页面</a>
2
import { BaseRouter } from "./base.js";
export class HistoryRouter extends BaseRouter {
constructor(list) {
super(list);
this.handler();
//监听历史栈信息变化,变化时重新渲染
window.addEventListener("popstate", (e) => {
this.handler();
});
}
//渲染
handler() {
this.render(this.getState());
}
//获取路由路径
getState() {
const path = window.location.pathname;
return path ? path : "/";
}
//使用pushState方法实现压入功能
//PushState不会触发popstate事件,所以需要手动调用渲染函数
push(path) {
history.pushState(null, null, path);
this.handler();
}
//使用replaceState实现替换功能
//replaceState不会触发popstate事件,所以需要手动调用渲染函数
replace(path) {
history.replaceState(null, null, path);
this.handler();
}
go(n) {
window.history.go(n);
}
}
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
# 简单示例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<a href="#/a">hash a页面</a>
<a href="#/b">hash b页面</a> <br />
<!-- <a onClick="goA()">history a页面</a>
<a onClick="goB()">history b页面</a> -->
<div id="app"></div>
<div id="app2"></div>
<script>
// hash模式
let fn = function () {
app.innerHTML = window.location.hash;
};
fn();
window.addEventListener("hashchange", fn);
// history模式; 要本地服务器模式下,才有用
let fn2 = function () {
app2.innerHTML = window.location.pathname;
};
function goA() {
history.pushState({}, null, "/a");
fn2();
}
function goB() {
history.pushState({}, null, "/b");
fn2();
}
// 只对浏览器的前进后退进行处理
window.addEventListener("popstate", function () {
fn2();
});
</script>
</body>
</html>
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
# 参考链接
https://developer.mozilla.org/en-US/docs/Web/API/History