前端路由学习探索

756 阅读12分钟

1. 传统路由和前端路由

1.1 什么是传统路由:

传统路由也可以叫做后端路由,简单来理解,在传统的网站设计中,每个HTML文件都是一个完整的页面,涵盖了全部的HTML结构。当我们访问某一个具体的网址的时候,实际上访问的是与这个网址相对应的一个HTML文件,该文件又会加载自己依赖的资源,然后组成一个新的页面。如图所示:

时序图如下:

image.png

这样的方式更像是页面流,所有的页面相当于一个一个文档组成,前端只负责处理展示,不存在数据和管理状态的概念;然后后端直接根据数据库的一些数据按照模板进行html的拼接。数据的更新就是文档的更新,这样的模式叫做Java Server Pages(JSP)。存在的问题:

  • 页面切换都需要刷新页面,会产生白屏的现象;
  • 模块之间很难相互共享状态;
  • 前后端不容易分离;

随着前端框架的成熟和稳定以及Ajax技术的不断发展、前后端的分离已经是当前架构开发的主流模式,所以JSP的开发模式已经辉煌不再了。

1.2 什么是SPA:

之前的JSP,的P只的是page,一个页面相当于一个文档;SPA的全称叫做single page web application,单页网页应用,越来越趋向于把页面做成一个应用。而在一个应用中,不会有路由的概念。而我们这个应用是内置在浏览器中,浏览器的地址栏的存在为一个前端单页应用提供了一个访问途径,也是各个应用的入口。

单页网页应用的出现大大提高了 WEB 应用的交互体验。在与用户的交互过程中,不再需要重新刷新页面,获取数据也是通过 Ajax 异步获取,页面显示变的更加流畅。

1.3 什么是前端路由

在保证只有一个HTML页面的前提下,为应用内的每个视图都匹配一个特殊的URL,在页面刷新、前进、后退都能够通过这个特殊的URL来实现。他的实质是,通过js动态渲染页面的内容。

时序图如下:

image.png

2. 如何实现前端路由

要实现上述的这个目标,我们需要做到两点:

  • 改变浏览器中的url不会让浏览器向服务器发送资源请求;
  • 可以监听到url的变化

目前有两种主流的方式:

  1. hash模式
  2. history模式

2.1 Hash模式

hash指的是url后面的#号后面的字符,如www.baidu.com/#/hello,后面的/hello就是我们设定的hash值。

利用hash模式天然可以实现前端路由:

  • hash值的变化不会导致浏览器向服务器发送请求
  • hash的改变会触发hashchange事件,可以支持我们去监听
  • 浏览器的前进后退也能对其进行控制

核心实现逻辑:

  1. 通过创建一个路由对象routers,记录每个hash值对应的页面渲染方法;
  2. 监听hashchange事件,然后通过routers[newHash]去执行页面的渲染方法;
  3. 添加使用者手动注册视图的方法;

核心API:

-   window.location.hash = 'new hash' // 用于设置hash值
-   let hash = window.location.hash // 获取当前hash值
-   window.addEventListener('hashchange'function(event) {
        let newURL = event.newURL;
        let oldURL = event.oldURL
    }, false)

实现代码如下:

// JS
class HashRouter {
  constructor() {
    //用于存储不同hash值对应的回调函数
    this.routers = {};
    window.addEventListener('hashchange', this.load, false)
  }

  //用于注册每个视图
  register(hash, callback = function () { }) {
    this.routers[hash] = callback;
  }

  //用于调用不同视图的回调函数
  load() {
    let hash = window.location.hash.slice(1),
      handler;
    if (hash) {
      handler = this.routers[hash];
      //执行注册的回调函数
    	handler(); 
    }
  }
}

//HTML 
<body>
    <div id="nav">
        <a href="#/page1">page1</a>
        <a href="#/page2">page2</a>
        <a href="#/page3">page3</a>
    </div>
    <div id="container"></div>
</body>

<script>  
    let router = new HashRouter();
    let container = document.getElementById('container');

    //注册首页回调函数
    router.registerIndex(()=> container.innerHTML = '我是首页');
	
    //注册其他视图回到函数
    router.register('/page1',()=> container.innerHTML = '我是page1');
    router.register('/page2',()=> container.innerHTML = '我是page2');
    router.register('/page3',()=> container.innerHTML = '我是page3');

    //加载视图
    router.load(); 
</script>

2.2 History模式

在HTML5之前,浏览器已经有了history对象,但在早期的history中只能用于多页面的跳转:

  • history.go(-1); // 后退一页
  • history.forward(); // 前进一页
  • history.back(); // 后退一页

HTML5版本出现之后,针对history新增了一下几个API:

  • history.pushState(); // 添加新的状态到历史状态栈
  • history.replaceState(); // 用新的状态代替当前状态
  • history.state; // 返回当前状态对象

因为pushState和replaceState都能在改变url的同时不会刷新页面,所以在HTML5中的history具备了实现前端路由的能力。

pushState和replaceState的异同:

  • 相同点:
    • 两者都可以接受三个参数
      • state: 一个合法的js对象,可以用在popstate事件中
      • title:设置当前页面的标题,但是被大多数浏览器忽略,可用null代替
      • url:任意有效的URL,用于更新浏览器的地址栏
    • 两者都可以改变地址栏的url,并且不会向服务端发起请求
  • 不同点
    • pushState在保留现有历史记录的同时,将url追加到历史记录中,历史记录长度+1
    • repalceState会将历史记录中的当前页面替换为传入的url,历史记录长度不变

popstate事件是当同一个文档的浏览历史,即history对象出现变化时,会触发popstate事件;pushState或者replaceState并不会触发该事件,只有用户点击浏览器倒退按钮和前进按钮,或者使用go、back、forward方法时才会触发。

相较于hash路由,在hash变化时,可以通过hashchange事件来监听到页面的变化,但是在history模式中,history模式的路由变化并不会触发任何事件,所以我们很难直接监听history的路由改变。

所以我们可以换一个思路:

枚举history对象改变的情况,然后根据这些引发改变的方式进行一一手动更新:

  1. 点击浏览器的前进或者后退 => history对象改变 => popstate
  2. 点击a标签 =>  阻止默认行为,使用history.pushState
  3. 在JS代码中触发history.pushState、replaceState => 主动更新
class HistoryRouter {
  constructor() {
    this.routers = {};
    // 在初始化时监听popstate事件
    this.bindPopState();
    this.listenLink();
  }

  register(path, callback = function () { }) {
    this.routers[path] = callback;
  }

  push(path) {
    window.history.pushState({ path: path }, '', path);
    if (this.routers[path]) {
      this.routers[path]()
    }
  }

  replace(path) {
    window.history.replaceState({ path: path }, '', path);
    if (this.routers[path]) {
      this.routers[path]()
    }
  }

  bindPopState() {
    window.addEventListener('popstate', (e) => {
      const path = e.state && e.state.path;
      this.routers[path] && this.routers[path].call(this);
    }, false);
  }

  //全局监听A链接
  listenLink() {
    window.addEventListener('click', (e) => {
      let dom = e.target;
      if (dom.tagName.toUpperCase() === 'A' && dom.getAttribute('href')) {
        e.preventDefault()
        console.log(11111);
        this.push(dom.getAttribute('href'));
      }
    }, false)
  }
}

2.3 两者更新对比

标题应用主动跳转地址浏览器操作(前进、后退等)组件更新的手段
history模式history.pushState(); history.replaceState()popstate事件1. 首先注册对应路由的回调函数;在浏览器和操作和页面主动触发的时候,都会去主动调用回调函数
hash模式window.location.hash = '/newHash'; window.location.replace('www.baidu.com/#/world')hashchange事件1. 注册对应路由函数; 2. 当hashchange事件触发时调用回调函数

3. 探究History库

react-router路由离不开history库,在history专注于记录路由的history状态,以及path变更之后,我们需要如何处理,在history模式下用popstate监听路由变化,在hash模式下利用hashchange监听路由变化。

3.1 react-router-dom和react-router和history库三者之间的关系

  • history是react-router的核心,也是整个路由原理的核心,里面集成了popState、history.pushState等底层路由实现的原理
  • react-router可以理解是react-router-dom的核心,里面封装了Router,Route, Switch等核心组件,实现了从路由实现了从路由的改变到组件的更新的核心功能,在我们的项目里面只需要一次性引入react-router-dom就可以了
  • react-router-dom在react-router的基础之上,添加了用于跳转的Link组件,和history模式下的BroserRouter和Hash模式下的HashRouter组件,但是这两个组件都是用了history库中的createBrowserHistory和createHashHistory方法。

image.png

3.2 History库的实现

3.2.1 createBrowserHistory

History模式的路由运行,是从createBrowserHistory函数开始,虽然版本可能有迭代但是整体的逻辑的还是相同的,我们重点关注setState, push, handlePopState, listen方法

首先我们先从两个最常见的使用场景入手,探寻在内部的一些奥秘:

场景一:用户主动调用push方法的流程

image.png

当我们在调用一个history.push方法的时候,在内部做了哪些事情:

  1. 我们能够调用history.push方法,说明该方法返回了一个对象,里面含有push方法;
  2. 看到上述的流程图,在调用之后,会记录action为PUSH,并且生成一个新的location对象,即下一个即将跳转过去的地址;
  3. 将新的跳转对象传入一个跳转管理器,通过跳转管理器判断最新的地址对象是否能够跳转;
  4. 如果不能够跳转则直接返回;如果能够进行过跳转,则调用window.history.push修改浏览器地址,并且再次调用一次setState方法
  5. 在setState方法内部接受一个location和action,location即为刚才的新的地址对象,action也是之前创建的action: 'PUSH',最终将传入的对象和动作类型和方法中的history对象合并
  6. 最后,在合并完成之后,调用跳转管理器中的通知方法,通知各个监听器页面发生变化。

在第一个场景中,我们能够预见,在createBrowserHistory中会有以下几点:

  • 该方法会返回一个history对象,内部会有一个方法属性;
  • 该方法会调用一个跳转管理器;
  • 会有一个push方法,里面去调用window.history.push并且调用setState方法;
  • 有一个setState方法
  • ...

场景二:用户通过浏览器行为刷新、前进、后退页面

image.png

第二个场景在于,用户通过浏览器的一些方法比如前进后退来修改地址栏的改变。此时我们可以看一下在createBrowserHistory中会有哪些措施。

  1. 当浏览器地址改变时,如果添加了时间监听事件即 history.listen(),则会在路由地址改变时触发popstate方法,否则不会触发;
  2. 触发popstate方法则会创建一个action为pop的一个新的location对象
  3. 将该对象传入到setState中去

所以在当前场景下会有一下几个方法:

  • 监听浏览器地址栏的变化的方法:history.listen()
  • 地址变更触发的方法popstate

所以从上面两个场景来入手我们能够看到createBrowserHistory方法中的一下关键代码:

主要逻辑:

  • 通过调用createBrowserHistory创建一个history对象,来替代window.history对象;里面包含push、listen以及window.history等常用的方法
const PopStateEvent = 'popstate'
const HashChangeEvent = 'hashchange'
/* 这里简化了createBrowserHistory,列出了几个核心api及其作用 */
function createBrowserHistory(){
    /* 全局history  */
    const globalHistory = window.history
    
    /* 处理路由转换,记录了listens信息。 */
    const transitionManager = createTransitionManager()
    
    /* 改变location对象,通知组件更新 */
    const setState = () => { /* ... */ }
    
    /* 处理当path改变后,处理popstate变化的回调函数 */
    const handlePopState = () => { /* ... */ }
   
    /* history.push方法,改变路由,通过全局对象history.pushState改变url, 通知router触发更新,替换组件 */
    const push = () => { /*...*/ }
    
    /* 底层应用事件监听器,监听popstate事件 */
    const listen=()=>{ /*...*/ } 
    return {
       push,
       listen,
       action: 'POP', // POP => 浏览器 PUSH => pushState REPLACE => replaceState
       location
       /* .... */ 
    }
}

核心方法:

1. 跳转管理器实例

生成过度管理器实例,支持以下几种方法:

  1. confirmTransitionTo 判断能否进行跳转,该版本默认支持
  2. notifyListener 通知监听器,已经发生跳转
  3. appendListener 添加监听器

2. setState:

  • 合并history信息
  • 通知每一个监听事件 路由已经发生变化
const setState = (nextState) => {
  // 合并信息
  Object.assign(history, nextState)
  history.length = globalHistory.length;
  transitjionManger.notifyListener(
    history.location,
    history.action
  )
}

3. listen

  • 添加自定义监听事件
  • 添加成功之后,返回一个方法,可供用户自行销毁
const listen = (listener) => {
    /* 添加listen */
    const unlisten = transitionManager.appendListener(listener)
    checkDOMListeners(1)

    return () => {
      checkDOMListeners(-1)
      unlisten()
    }
}

4. checkDOMListeners

  • 允许用户通过传参来绑定或者解绑popstate事件,当路由发生改变时,调用处理函数hanslePopState方法
const checkDOMListeners = (delta) => {
    listenerCount += delta
    if (listenerCount === 1) {
      addEventListener(window, PopStateEvent, handlePopState)
      if (needsHashChangeListener)
        addEventListener(window, HashChangeEvent, handleHashChange)
    } else if (listenerCount === 0) {
      removeEventListener(window, PopStateEvent, handlePopState)
      if (needsHashChangeListener)
        removeEventListener(window, HashChangeEvent, handleHashChange)
    }
  }

5. push

  1. 生成一个最新的location对象
  2. 通过window.history.pushState来改变路由器当前路由
  3. 通过setState方法通知路由变更,并传递当前的location对象
  4. 这次的url是由history.pushState产生的,不会触发popState方法,需要手动setState,触发组件更新
const push = (path, state) => {
    const action = 'PUSH'
    /* 1 创建location对象 */
    const location = createLocation(path, state, createKey(), history.location)
    /* 确定是否能进行路由转换,还在确认的时候又开始了另一个转变 ,可能会造成异常 */
    transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
      // 当前版本一定走ok的逻辑
      if (!ok)
        return
      const href = createHref(location)
      const { key, state } = location
      if (canUseHistory) {
        setState({ action, location })
      } else {
        window.location.href = href
      }
    })
  }
  1. handlePopState
  • 监听popstate函数,当path改变时的处理

    • 判断一下action的类型,然后setState,重新加载组件
/* 我们简化一下handlePopState */ 
const handlePopState = (event)=>{
  const location = getDOMLocation(event.state)
  const action = 'POP'
  setState({ action, location })
}

3.2.2 createHashHistory

基本原理同上,此处不再叙述。

4. React-Router源码探查

4.1 Router - 用来接受location变化,派发更新

Router组件的作用是,在初始化时绑定history的listen方法,当路由变更之后,通过setState来触发组件的变更。路由状态通过Context来维护在react组件上下文中。

/* Router 作用是把 history location 等路由信息 传递下去  */
class Router extends React.Component {
  static computeRootMatch(pathname) {
    return { path: '/', url: '/', params: {}, isExact: pathname === '/' };
  }
  constructor(props) {
    super(props);
    this.state = {
      location: props.history.location
    };
  }
  
  componentDidMount() {
    /* 此时的history,是history创建的history对象 */
    /* 这里判断 componentDidMount 和 history.listen 执行顺序 然后把 location复制 ,防止组件重新渲染 */
    this.unlisten = props.history.listen(location => {
      /* 创建监听者 */
      this.setState({ location }); 
     });
  }
  
  componentDidUnmount() {
    if (this.unlisten) this.unlisten();
  }
 
  render() {
    return (
      /*  这里可以理解 react.createContext 创建一个 context上下文 ,保存router基本信息。children */
      <RouterContext.Provider
          // ...
          children={this.props.children || null}
          value={{ location: this.state.location }}
          match: Router.computeRootMatch(this.state.location.pathname),
      />
    );
  }
}

4.2 Switch - 匹配正确的唯一的路由

Switch组件的作用是找到与当前path,匹配的组件进行渲染。 通过pathname和组件的path进行匹配。找到符合path的router组件。

/* switch组件 */
class Switch extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {/* 含有 history location 对象的 context */}
        {context => {
          const location = this.props.location || context.location;
          let element, match;
          React.Children.forEach(this.props.children, child => {
            if (match == null && React.isValidElement(child)) {
              element = child;
              // 子组件 也就是 获取 Route中的 path 或者 rediect 的 from
              const path = child.props.path || child.props.from;
              match = path
                ? matchPath(location.pathname, { ...child.props, path })
                : context.match;
            }
          });
          return match
            ? React.cloneElement(element, { location, computedMatch: match })
            : null;
        }}
      </RouterContext.Consumer>
    );
  }
}

4.3 Route - 组件页面承载容器

这个地方的简单理解,可以将Route组件作为组件的容器,然后在Switch组件匹配之后,可以渲染其匹配的Route组件,当然Route组件也可以脱离Switch组件直接当做Router组件的子组件,其内部也有匹配path的操作。

class Route extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          return (
            <RouterContext.Provider value={props}>
              { /** 暂时这么理解 */}
              { children }
            </RouterContext.Provider>
          );
        }}
      </RouterContext.Consumer>
    );
  }
}

总的来说,history提供了核心的一些API,如监听路由变更,保存路由状态等;然后react-router提供路由渲染的容器、路由匹配等组件功能。

5. umi-router

在umi的history中,基本上都是从history和react-router-dom中集成过来,没有针对这两个库做更多的修改。

export {
  createBrowserHistory,
  createHashHistory,
  createMemoryHistory,
} from 'history-with-query';
export {
  Link,
  // ...
} from 'react-router-dom';
...

6. 学习参考文章:

juejin.cn/post/688629…

juejin.cn/post/684490…

github.com/remix-run/h…