知识储备

如何操作浏览器地址?

我们可以使用下面三种方法,来修改浏览器的地址

  • location.assign(url)
  • window.location = url
  • location.href = url(常见)

修改以下location对象的属性值,会导致当前页面重新加载

// 假如当前url为:https://www.example.com/

// 把url修改为:https://www.example.com/?t=example
location.search = '?t=example';

// 把url修改为:https://example.com/?t=example
location.hostname = 'example.com';

// 把url修改为:https://www.example.com/example
location.pathname = 'example';

// 把url修改为:https://www.example.com:8080
location.port = 8080

修改hash时,浏览器历史中会新增加一条记录,但是并不会刷新页面。因此SPA应用中,hash也是一种切换路由的方式。

// 假如当前url为:https://www.example.com/

// 把url修改为:https://www.example.com/#example
location.hash = '#example';

使用location.replace(url)方法跳转的url,并不会增加历史记录。

使用location.reload()方法可以重新加载当前页面,是否传参的区别如下:

location.reload(); // 重新加载,可能是从缓存加载
location.reload(true); // 重新加载,从服务器加载

如何导航页面?

使用go(n)可以在用户记录中沿任何方向导航(即可以前进也可以后退)。正值表示在历史中前进,负值表示在历史中后退。

假如要前进1页,那么可以使用window.history.`go(1)。同时,也可以使用window.history.forward()`来做相同的事情。
假如要后退1页,那么可以使用window.history.`go(-1)。同时,也可以使用window.history.back()`来做相同的事情。

如果使用window.history.go(0)window.history.go()都会重新加载当前页面。

如何改变页面的地址,但是不会重新加载页面并且怎样监听这些改变?

使用hash

上面我们说到了,修改hash可以做到改变页面的地址,在浏览器历史中添加一条记录,但是不会重新加载页面。

我们同时可以配合hashchange事件,监听页面地址hash的变化。

使用history.pushState()、history.replaceState()

使用history.pushState(),类似是执行了location.href = url,但是并不会重新加载页面。假如用户执行了后退操作,将会触发popstate事件。

使用history.replaceState(),类似是执行了location.replace(url),但是并不会重新加载页面。

注意,执行pushState、replaceState方法后,虽然浏览器地址有改变,但是并不会触发popState事件

实现一个mini-router

开始编写构造函数

首先我们需要确定的是,我们的路由应该需要下面4个属性:

  • routes:一个数组,包含所有注册的路由对象
  • mode: 路由的模式,可以选择hashhistory
  • base:根路径
  • constructor:初始化新的路由实例
class MiniRouter {
  constructor(options) {
    const { mode, routes, base } = options;
    
    this.mode = mode || (window.history.pushState ? 'history' : 'hash');
    this.routes = routes || [];
    this.base = base || '/';
  }
}

export default MiniRouter;

增加添加路由对象方法

路由对象中包含下面两个属性

  • path:由正则表达式代表的路径地址(并不是字符串,后面会详细解释)
  • cb:路由跳转后执行的回调函数
class MiniRouter {
  constructor(options) {
    const { mode, routes, base } = options;
    
    this.mode = mode || (window.history.pushState ? 'history' : 'hash');
    this.routes = routes || [];
    this.base = base || '/';
  }
  
  // 添加路由对象 👇 新增代码
  // routerConfig示例为:
  // {path: /about/, cb(){console.log('about')}}
  addRoute(routeConfig) {
    this.routes.push(routeConfig);
  }
  /// 👆 新增代码
}

export default MiniRouter;

增加路由导航功能

添加路由导航功能,实际上是location相关方法的封装

详细内容可以回看:如何导航页面?

class MiniRouter {
  constructor(options) {
    const { mode, routes, base } = options;
    
    this.mode = mode || (window.history.pushState ? 'history' : 'hash');
    this.routes = routes || [];
    this.base = base || '/';
  }
  
  addRoute(routeConfig) {
    this.routes.push(routeConfig);
  }
  
  // 添加前进、后退功能 👇 新增代码
  go(n) {
    window.location.go(n);
  }
  
  back() {
    window.location.back();
  }
  
  forward() {
    window.location.forward();
  }
  
  /// 👆 新增代码
}

export default MiniRouter;

实现导航到新路由的功能

参照vue-router,大橙子在这里设计了push、replace两种方法。其中:
push代表跳转新页面,并在历史栈中增加一条记录,用户可以后退
replace代表跳转新页面,但是不在历史栈中增加记录,用户不可以后退

如果是hash模式下
使用location.hash = newHash来实现push跳转
使用window.location.replace(url)来实现replace跳转

如果是history模式下
使用history.pushState()来实现push跳转
使用history.replaceState()来实现replace跳转

请注意:
pushStatereplaceState添加try...catch是由于Safari的某个安全策略

有兴趣的同学可以查看
vue-router相关commit
Stack Overflow上的相关问题

class MiniRouter {
  constructor(options) {
    const { mode, routes, base } = options;
    
    this.mode = mode || (window.history.pushState ? 'history' : 'hash');
    this.routes = routes || [];
    this.base = base || '/';
  }
  
  addRoute(routeConfig) {
    this.routes.push(routeConfig);
  }
  
  go(n) {
    window.history.go(n);
  }
  
  back() {
    window.location.back();
  }
  
  forward() {
    window.location.forward();
  }
  
  // 实现导航到新路由的功能
  // push代表跳转新页面,并在历史栈中增加一条记录,用户可以后退
  // replace代表跳转新页面,但是不在历史栈中增加记录,用户不可以后退
  //👇 新增代码
  push(url) {
    if (this.mode === 'hash') {
      this.pushHash(url);
    } else {
      this.pushState(url);
    }
  }
  
  pushHash(path) {
    window.location.hash = path;
  }
  
  pushState(url, replace) {
    const history = window.history;

    try {
      if (replace) {
        history.replaceState(null, null, url);
      } else {
        history.pushState(null, null, url);
      }
      
      this.handleRoutingEvent();
    } catch (e) {
      window.location[replace ? 'replace' : 'assign'](url);
    }
  }
  
  replace(path) {
    if (this.mode === 'hash') {
      this.replaceHash(path);
    } else {
      this.replaceState(path);
    }
  }
  
  replaceState(url) {
    this.pushState(url, true);
  }
  
  replaceHash(path) {
    window.location.replace(`${window.location.href.replace(/#(.*)$/, '')}#${path}`);
  }

  /// 👆 新增代码
}

export default MiniRouter;

实现获取路由地址的功能

history模式下,我们会使用location.path来获取当前链接路径。

如果设置了base参数,将会把base路径干掉,方便后面匹配路由地址。

hash模式下,我们会使用正则匹配将#后的地址匹配出来。

当然所有操作之后,将会把/完全去掉。

class MiniRouter {
  constructor(options) {
    const { mode, routes, base } = options;
    
    this.mode = mode || (window.history.pushState ? 'history' : 'hash');
    this.routes = routes || [];
    this.base = base || '/';
  }
  
  addRoute(routeConfig) {
    this.routes.push(routeConfig);
  }
  
  go(n) {
    window.history.go(n);
  }
  
  back() {
    window.location.back();
  }
  
  forward() {
    window.location.forward();
  }

  push(url) {
    if (this.mode === 'hash') {
      this.pushHash(url);
    } else {
      this.pushState(url);
    }
  }
  
  pushHash(path) {
    window.location.hash = path;
  }
  
  pushState(url, replace) {
    const history = window.history;

    try {
      if (replace) {
        history.replaceState(null, null, url);
      } else {
        history.pushState(null, null, url);
      }
      
      this.handleRoutingEvent();
    } catch (e) {
      window.location[replace ? 'replace' : 'assign'](url);
    }
  }
  
  replace(path) {
    if (this.mode === 'hash') {
      this.replaceHash(path);
    } else {
      this.replaceState(path);
    }
  }
  
  replaceState(url) {
    this.pushState(url, true);
  }
  
  replaceHash(path) {
    window.location.replace(`${window.location.href.replace(/#(.*)$/, '')}#${path}`);
  }

  // 实现获取路径功能
  //👇 新增代码
  getPath() {
      let path = '';
      if (this.mode === 'history') {
        path = this.clearSlashes(decodeURI(window.location.pathname));
        path = this.base !== '/' ? path.replace(this.base, '') : path;
      } else {
        const match = window.location.href.match(/#(.*)$/);

        path = match ? match[1] : '';
      }

      // 可能还有多余斜杠,因此需要再清除一遍
      return this.clearSlashes(path);
    };


  clearSlashes(path) {
    return path.toString().replace(/\/$/, '').replace(/^\//, '');
  }
  
  /// 👆 新增代码
}

export default MiniRouter;

实现监听路由事件+执行路由回调

在实例化路由时,我们将会按照mode的不同,在页面上挂载不同的事件监听器:

  • hash:对hashchange事件进行监听
  • history:对popstate事件进行监听

在监听到变化后,回调方法将会遍历我们的路由表,如果符合路由的正则表达式,就执行相关路由的回调方法。

class MiniRouter {
  constructor(options) {
    const { mode, routes, base } = options;
    
    this.mode = mode || (window.history.pushState ? 'history' : 'hash');
    this.routes = routes || [];
    this.base = base || '/';
    
    this.setupListener(); // 👈 新增代码
  }
  
  addRoute(routeConfig) {
    this.routes.push(routeConfig);
  }
  
  go(n) {
    window.history.go(n);
  }
  
  back() {
    window.location.back();
  }
  
  forward() {
    window.location.forward();
  }

  push(url) {
    if (this.mode === 'hash') {
      this.pushHash(url);
    } else {
      this.pushState(url);
    }
  }
  
  pushHash(path) {
    window.location.hash = path;
  }
  
  pushState(url, replace) {
    const history = window.history;

    try {
      if (replace) {
        history.replaceState(null, null, url);
      } else {
        history.pushState(null, null, url);
      }
      
      this.handleRoutingEvent();
    } catch (e) {
      window.location[replace ? 'replace' : 'assign'](url);
    }
  }
  
  replace(path) {
    if (this.mode === 'hash') {
      this.replaceHash(path);
    } else {
      this.replaceState(path);
    }
  }
  
  replaceState(url) {
    this.pushState(url, true);
  }
  
  replaceHash(path) {
    window.location.replace(`${window.location.href.replace(/#(.*)$/, '')}#${path}`);
  }

  getPath() {
      let path = '';
      if (this.mode === 'history') {
        path = this.clearSlashes(decodeURI(window.location.pathname));
        path = this.base !== '/' ? path.replace(this.base, '') : path;
      } else {
        const match = window.location.href.match(/#(.*)$/);

        path = match ? match[1] : '';
      }

      // 可能还有多余斜杠,因此需要再清除一遍
      return this.clearSlashes(path);
    };


  clearSlashes(path) {
    return path.toString().replace(/\/$/, '').replace(/^\//, '');
  }
  // 实现监听路由,及处理回调功能
  //👇 新增代码
  setupListener() {
    this.handleRoutingEvent();

    if (this.mode === 'hash') {
      window.addEventListener('hashchange', this.handleRoutingEvent.bind(this));
    } else {
      window.addEventListener('popstate', this.handleRoutingEvent.bind(this));
    }
  }

  handleRoutingEvent() {
    if (this.current === this.getPath()) return;
    this.current = this.getPath();

    for (let i = 0; i < this.routes.length; i++) {
      const match = this.current.match(this.routes[i].path);
      if (match) {
        match.shift();
        this.routes[i].cb.apply({}, match);

        return;
      }
    }
  }
  /// 👆 新增代码
}

export default MiniRouter;

试试刚刚实现的路由

实例化之前实现的MiniRouter,是不是和平常写的router很像(除了功能少了很多😝)?

相关代码如下:

import MiniRouter from './MiniRouter';

const router = new MiniRouter({
    mode: 'history',
    base: '/',
    routes: [
        {
            path: /about/,
            cb() {
                app.innerHTML = `<h1>这里是关于页面</h1>`;
            }
        },
        {
            path: /news\/(.*)\/detail\/(.*)/,
            cb(id, specification) {
                app.innerHTML = `<h1>这里是新闻页</h1><h2>您正在浏览id为${id}<br>渠道为${specification}的新闻</h2>`;
            }
        },
        {
            path: '',
            cb() {
                app.innerHTML = `<h1>欢迎来到首页!</h1>`;
            }
        }
    ]
});

完整的代码,请跳转至:github传送门

下载代码后,执行下面的代码,进行调试:

npm i
npm run dev

如何优化路由

path-to-regexp

常见的react-routervue-router传入的路径都是字符串,而上面实现的例子中,使用的是正则表达式。那么如何才能做到解析字符串呢?

看看这两个开源路由,我们都不难发现,它们都使用了path-to-regexp这个库。假如我们传入了一个路径:

/news/:id/detail/:channel

使用match方法

import { match } from "path-to-regexp";

const fn = match("/news/:id/detail/:channel", {
  decode: decodeURIComponent
});

// {path: "/news/122/detail/baidu", index: 0, params: {id: "122", channel: "baidu"}}
console.log(fn("/news/122/detail/baidu")); 
// false
console.log(fn("/news/122/detail"));

是不是很眼熟?和我们平常使用路由库时,使用相关参数的路径一致。有兴趣的同学,可以沿着这个思路将路由优化一下

我们发现,当满足我们越来越多需求的时候,代码库也变得越来越庞大。但是最核心的内容,永远只有那一些,主要抓住了主线,实际上分支的理解就会简单起来。

写在最后

本文的代码主要参考自开源作者navigo的文章,在此基础上,为了贴合vue-router的相关配置。做了一些改动,由于水平受限,文内如有错误,还望大家在评论区内提出,以免误人子弟。

参考资料


修仙大橙子
108 声望16 粉丝

前端工程师