前端路由的原理及自定义

# 背景

在之前用户的每次更新操作都需要重新刷新页面,非常的影响交互体验,后来,为了解决这个问题,便有了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 包括:协议、域名、端口、虚拟目录、文件名、参数、锚。

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())
# 流程图示

image-20201123001014350

# History 模式

# HTML5的History API 为浏览器的全局history对象增加的扩展方法。window对象提供了onpopstate事件来监听历史栈的改变,一旦历史栈信息发生改变,便会触发该事件。

需要特别注意的是,调用history.pushState()或history.replaceState()不会触发popstate事件。只有在做出浏览器动作时,才会触发该事件。

window.onpopstate = function(){}// 历史栈 信息改变
1

history提供了两个操作历史栈的API:

history.pushStatehistory.replaceState

history.pushState(data[,title][,url]);//向历史记录中追加一条记录
history.replaceState(data[,title][,url]);//替换当前页在历史记录中的信息。
1
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 同源,不能跨域
# 流程图示

image-20201123215205775

# 两种模式对比[要点]

对比 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)
1
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>
        )
    }
}
1
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
        }]
    }]
})
1
2
3
4
5
6
7
8
9
10
11
12
13

nest.vue

<div class="nest">
    一级路由 <router-view></router-view>
</div>
1
2
3

first.vue

<div class="nest">
    二级路由 <router-view></router-view>
</div>
1
2
3

/nest 下设置了二级路由 /first,二级对应的组件渲染在一级路由匹配的组件 <router-view/> 标识的地方。在配置子路由时,path 只需要是当前路径即可。

image-20201123234443023

2. react-router 子路由

react-router 根组件会被渲染到 <Router/> 指定的位置,子路由则会作为子组件,由父组件指定该对象的渲染位置。如果想要实现上述 vue-router 嵌套的效果,需要这样设置:

route.js

const Route = () => (
    <HashRouter>
        <Switch>
            <Route path="/nest" component={Nest}/>
        </Switch>
    </HashRouter>
);
1
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>
        )
    }
}
1
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>
        )
    }
}
1
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

# 路由信息

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),
})
1
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>
);
1
2
3
4
5
6
7
8
9
10

如果想要获得 history、location 一定是<Route />包裹的组件。所以在 <HeaderNav/> 中是无法获取这两个对象的,而 <Home/> 组件是可以的。

vue-router 是全局配置方式,react-router 是全局组件方式,但两者呈现给开发者的功能实际上是大同小异的。说到底,不管用什么样的方式实现,前端路由的实现原理都是不会变的。

# 自己封装实现

base基类处理:待优化;目前只做了渲染,可做抽象处理;

image-20201018215704133

# Hash

  • 监听hashchange
  • window.location.hashwindow.location.replace
<a href="#/a">a页面</a>
<a href="#/b">b页面</a>
1
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);
  }
}
1
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>
1
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);
  }
}
1
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>
1
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

上次更新: 2022/04/15, 05:41:28
×