最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 造轮子-如何实现一个router?|项目复盘

    正文概述 掘金(修仙大橙子)   2021-03-20   503

    背景

    经常能看到各种关于路由的文章,当中都会提到现代路由主要通过historypushState等api来实现。笔者就觉得非常好奇,内部的原理究竟是个啥?

    最好探究原理的办法,实际上就是实现一个简易版本,再大的代码库也是由一个个小问题累积而来。所以实现一个最小版本的路由器实际上可以便于理解作者的初衷。当再阅读更多代码的时候,就会豁然开朗。

    知识储备

    如何操作浏览器地址?

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

    • 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),但是并不会重新加载页面。

    实现一个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跳转

    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的相关配置。做了一些改动,由于水平受限,文内如有错误,还望大家在评论区内提出,以免误人子弟。

    参考资料

    • A modern JavaScript router in 100 lines:本文基础代码是基于该模板进行改写,该作者是开源库navigo的作者。建议可以读一读原文,同样可以启发你的思维。
    • vue-router
    • JavaScript高级程序设计(第四版)第12章:知识储备的部分内容摘抄来源
    • MDN-History
    • MDN-Location

    本文正在参与「掘金 2021 春招闯关活动」, 点击查看 活动详情


    起源地下载网 » 造轮子-如何实现一个router?|项目复盘

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元