前端路由(手写路由)

1,751 阅读7分钟

1、历史

路由最早兴起于后端,路由就是:用户端发起一个HTTP请求,后台用一个函数进行处理,即 函数 => 路由。到后来前端出现ajax,页面可以进行局部刷新,出现SPA应用。

2、SPA

单页面应用仅在初始化时,加载页面相应的HTML、CSS、JavaScript。一旦页面加载完成,SPA不会因为用户的操作,而进行页面的重新加载和跳转,而是利用路由机制在实现HTML的切换,UI与用户交互,避免重复的加载。

优点:

  • 用户体验好、快、内容改变不需要重新加载页面,避免了不必要的跳转和重复渲染。并不需要处理HTML文件的请求,这样节约了HTTP发送延迟。
  • 基于上面一点,SPA相对服务器压力小很多。
  • 前后端职责分离,架构清楚,前端进行交互逻辑,后端服务数据处理

缺点:

  • 初始化时耗时比较多,为实现单页面web应用功能及显示效果,需要将JavaScript和CSS统一加载,虽然可以部分按需加载。
  • SPA为单页面,但是浏览器的前进后台不能使用,所有页面的切换需要自己建立一个堆栈进行管理。
  • 所有内容都在一个页面,SEO天然处于弱势。

3、前端路由

为了解决SPA应用的缺点,前端路由出现。前端路由结合SPA更新视图但是浏览器不刷新页面,只是重新渲染部门子页面,加载速度快,页面反应灵活等优点实现。

目前浏览器前端路由实现的方式有两种

  • URL #hash
  • H5的history interface

4、URL #hash

在最开始H5还没有流行其他的时候,SPA采用URL 的hash值作为锚点,获取锚点值,监听其改变,在进行对应的子页面渲染。window对象有一个专门监听hash变化的时候,那就是onhashchange。 监听到URL的变化,我们如何进行页面的载入了。当页面发送改变了,在执行页面载入有三种方式 查询节点内容并改变。 使用import导出js文件,js文件export模板字符串。 利用Ajax加载不同hash的HTML模块。

4.1 查询节点内容并改变
<h1 id="page"></h1>
<a href="#/page1">page1</a>
<a href="#/page2">page2</a>
<a href="#/page3">page3</a>
<script>
    window.addEventListener('hashchange', e => {
        e.preventDefault();
        document.querySelector('#page').innerHTML = location.hash;
    })
</script>
4.2 import方法
const str = `
  <div>
    我是import进来的JS文件
  </div>
`
export default str
<body>
    <h1 id="page"></h1>
    <a href="#/page1">page1</a>
    <a href="#/page2">page2</a>
    <a href="#/page3">page3</a>
    <script type="module">
        import demo1 from './import.js'
        document.querySelector('#page').innerHTML = demo1;
        window.addEventListener('hashchange', e => {
            e.preventDefault();
            document.querySelector('#page').innerHTML = location.hash;
        })
    </script>
</body>
4.3 Ajax请求,例如使用jQuery封装好的ajax请求方式。
<body>
  <h1 id="id"></h1>
  <a href="#/id1">id1</a>
  <a href="#/id2">id2</a>
  <a href="#/id3">id3</a>
</body>
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script type="module">
  // import demo1 from './demo1.js'
  // document.querySelector('#id').innerHTML = demo1
  $.ajax({
    url: './demo2.html',
    success: (res) => {
      document.querySelector('#id').innerHTML = res
    }
  });
  window.addEventListener('hashchange', e => {
    e.preventDefault();
    document.querySelector('#id').innerHTML = location.hash;
  })
</script>
4.4 通用方式
<body>
    <h1 id="page">空白页</h1>
    <a href="#/page1">page1</a>
    <a href="#/page2">page2</a>
    <a href="#/page3">page3</a>
</body>
<script type="text/javascript">
import demo1 from './import.js';
// 创建路由类
class Router {
    constructor() {
        // 路由对象
        this.routes = {};
        // 当前URL
        this.curUrl = '';
    }
    // 根据RUL或者对应URL的回调函数
    route(path, callback = () => {}) {
        this.routes[path] = callback;
    }
    // 刷新
    refresh() {
        // 当前hash值
        this.curUrl = window.location.hash.slice(1) || '/';
        // 执行方法
        this.routes[this.curUrl] && this.routes[this.curUrl]();
    }
    // 初始化监听函数
    initPage() {
        window.addEventListener('load', this.refresh.bind(this), false);
        window.addEventListener('hashchange', this.refresh.bind(this), false);
    }
}
// 实例化路由
window.router = new Router();
// 初始化
window.router.init();
// 获取节点
const content = document.getElementById('page');
// 直接改变
Router.route('/page1', () => {
    content.innerHTML = 'page1'
});
// import改变
Router.route('/page2', () => {
    content.innerHTML = demo1
});
// AJAX改变
Router.route('/page3', () => {
    $.ajax({
        url: './demo2.html',
        success: (res) => {
            content.innerHTML = res
        }
    })
});
</script>

5、h5 interface

5.1 向历史记录栈中添加记录:pushState(state, title, url);
  • state: 一个 JS 对象(不大于640kB),主要用于在 popstate 事件中作为参数被获取。如果不需要这个对象,此处可以填 null
  • title: 新页面的标题,部分浏览器(比如 Firefox )忽略此参数,因此一般为 null
  • url: 新历史记录的地址,可为页面地址,也可为一个锚点值,新 url 必须与当前 url 处于同一个域,否则将抛出异常,此参数若没有特别标注,会被设为当前文档 url
// 现在是 localhost/1.html
const stateObj = { foo: 'bar' };
history.pushState(stateObj, 'page 2', '2.html');

// 浏览器地址栏将立即变成 localhost/2.html
// 但!!!
// 不会跳转到 2.html
// 不会检查 2.html 是否存在
// 不会在 popstate 事件中获取
// 不会触发页面刷新
// 这个方法仅仅是添加了一条最新记录
5.2 改变当前历史记录而不是添加新纪录:replaceState(state, title, url);
PS:
    将URL设置为锚点时将不回触发hashchange。
    URL设置为不同域名、端口号、协议,将会报错,这是由于浏览器的同源策略的限制。
5.3 popState(state, title, url);

当浏览器历史栈出现变化时,就会触发popState事件。

PS:
    调用pushState和replaceState时不会触发popState,只有用户点击前进后退
    或者使用JavaScript的go、back、forword方法时才会触发,并且对于不同文件
    的切换也是不会触发的

h5 interface 手写一个路由类

<body>
    <h1 id="page">空白页</h1>
    <a class="route" href="#/page1">page1</a>
    <a class="route" href="#/page2">page2</a>
    <a class="route" href="#/page3">page3</a>
</body>
<script type="text/javascript">
import demo1 from './import.js';
// 创建路由类
class Router {
    constructor() {
        // 路由对象
        this.routes = {};
        // 当前URL
        this.curUrl = '';
    }
    // 根据RUL或者对应URL的回调函数
    route(path, callback = () => {}) {
        this.routes[path] = (type) => {
            if (type === 1) {
                history.pushState({ path }, path, path);
            }
            if (type === 2) {
                history.replaceState({ path }, path, path);
            }
            callback();
        };
    }
    // 刷新
    refresh(path, type) {
        this.routes[this.curUrl] && this.routes[this.curUrl](type);
    }
    // 初始化监听函数
    initPage() {
        window.addEventListener('load', () => {
            // 获取当前 URL 路径
            this.curUrl = location.href.slice(location.href.indexOf('/', 8));
            this.refresh(this.curUrl, 2)
        }, false);
        window.addEventListener('popstate', () => {
            this.curUrl = history.state.path;
            this.refresh(this.curUrl, 2);
        }, false);
        const links = document.querySelectorAll('.route')
        links.forEach((item) => {
            // 覆盖 a 标签的 click 事件,防止默认跳转行为
            item.onclick = (e) => {
                e.preventDefault();
                // 获取修改之后的 URL
                this.curUrl = e.target.getAttribute('href');
                // 渲染
                this.refresh(this.curUrl, 2);
            }
        });
    }
}
// 实例化路由
window.router = new Router();
// 初始化
window.router.init();
// 获取节点
const content = document.getElementById('page');
// 直接改变
Router.route('/page1', () => {
    content.innerHTML = 'page1'
});
// import改变
Router.route('/page2', () => {
    content.innerHTML = demo1
});
// AJAX改变
Router.route('/page3', () => {
    $.ajax({
        url: './demo2.html',
        success: (res) => {
            content.innerHTML = res
        }
    })
});
</script>

6、Vue-router的路由实现

项目地址:github.com/vuejs/vue-r… 先看一段Vue-router源码(其他已省略)

if (!inBrowser) {
  mode = 'abstract'
}
this.mode = mode

switch (mode) {
  case 'history':
    this.history = new HTML5History(this, options.base)
    break
  case 'hash':
    this.history = new HashHistory(this, options.base, this.fallback)
    break
  case 'abstract':
    this.history = new AbstractHistory(this, options.base)
    break
  default:
    if (process.env.NODE_ENV !== 'production') {
      assert(false, `invalid mode: ${mode}`)
    }
}

我们发现在Vue-router中,history实例和mode的类型有关。不同的类型实现不一样。 首先我们来看看HTML5History源码实现(/src/index.js)

push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    // $flow-disable-line
    if (!onComplete && !onAbort && typeof Promise !== 'undefined') {
      return new Promise((resolve, reject) => {
        this.history.push(location, resolve, reject)
      })
    } else {
      this.history.push(location, onComplete, onAbort)
    }
  }

  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    // $flow-disable-line
    if (!onComplete && !onAbort && typeof Promise !== 'undefined') {
      return new Promise((resolve, reject) => {
        this.history.replace(location, resolve, reject)
      })
    } else {
      this.history.replace(location, onComplete, onAbort)
    }
  }

  go (n: number) {
    this.history.go(n)
  }

  back () {
    this.go(-1)
  }

  forward () {
    this.go(1)
  }

我们发现其实我们在日常项目中使用的push和replace方法就是调用的this.history中的方法,也就是 HTML5History类的方法,我们找到HTML5History这个类。在这里/src/history/html5.js

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)
  }

  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const { current: fromRoute } = this
    this.transitionTo(location, route => {
      replaceState(cleanPath(this.base + route.fullPath))
      handleScroll(this.router, route, fromRoute, false)
      onComplete && onComplete(route)
    }, onAbort)
  }

在这个HTML5History类中定义了push和replace方法,原来如此,在内部的使用又使用了上文提到 h5 interface的pushState和replaceState,哈哈哈,其实这里的pushState和replaceState并不是h5 interface的pushState和replaceState。而是调用了import { pushState, replaceState, supportsPushState } from '../util/push-state'中的pushState和replaceState。来看看push-state源文件的实现。

export function pushState (url?: string, replace?: boolean) {
  saveScrollPosition()
  // 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) {
      history.replaceState({ key: _key }, '', url)
    } else {
      _key = genKey()
      history.pushState({ key: _key }, '', url)
    }
  } catch (e) {
    window.location[replace ? 'replace' : 'assign'](url)
  }
}

export function replaceState (url?: string) {
  pushState(url, true)
}

在这里才是真正的使用了h5 interface的pushState和replaceState,真是兜兜转转一圈终于找到了呀

总结:
    router 实例调用的 push 实际是 history 的方法,通过 mode 来确定匹配 history 
    的实现方案,从代码中我们看到,push 调用了 src/util/push-state.js 中被改写过
    的 pushState 的方法,改写过的方法会根据传入的参数 replace?: boolean 来进行
    判断调用 pushState 还是 replaceState ,同时做了错误捕获,如果,history 无刷
    新修改访问路径失败,则调用 window.location.replace(url) ,有刷新的切换用户访
    问地址 ,同理 pushState 也是这样。这里的 transitionTo 方法主要的作用是做视图
    的跟新及路由跳转监测,如果 url 没有变化(访问地址切换失败的情况),在 
    transitionTo 方法内部还会调用一个 ensureURL 方法,来修改 url。 
    transitionTo 方法中应用的父方法比较多,这里不做长篇赘述。



其他两种方式hash模式abstract模式内部其实也是比较简单的,我这里就不一一为大家来解析源码。

参考:zhuanlan.zhihu.com/p/27588422

参考:juejin.cn/post/684490…

参考:juejin.cn/post/684490…

可读:juejin.cn/post/684490…