资讯专栏INFORMATION COLUMN

react-router v4.x 源码拾遗1

itvincent / 1827人阅读

摘要:还是先来一段官方的基础使用案例,熟悉一下整体的代码流程中使用了端常用到的等一些常用组件,作为的顶层组件来获取的和设置回调函数来更新。

react-router是react官方推荐并参与维护的一个路由库,支持浏览器端、app端、服务端等常见场景下的路由切换功能,react-router本身不具备切换和跳转路由的功能,这些功能全部由react-router依赖的history库完成,history库通过对url的监听来触发 Router 组件注册的回调,回调函数中会获取最新的url地址和其他参数然后通过setState更新,从而使整个应用进行rerender。所以react-router本身只是封装了业务上的众多功能性组件,比如Route、Link、Redirect 等等,这些组件通过context api可以获取到Router传递history api,比如push、replace等,从而完成页面的跳转。
还是先来一段react-router官方的基础使用案例,熟悉一下整体的代码流程

import React from "react";
import { BrowserRouter as Router, Route, Link } from "react-router-dom";

function BasicExample() {
  return (
    
      
  • Home
  • About
  • Topics

); } function Home() { return (

Home

); } function About() { return (

About

); } function Topics({ match }) { return (

Topics

  • Rendering with React
  • Components
  • Props v. State

Please select a topic.

} />
); } function Topic({ match }) { return (

{match.params.topicId}

); } export default BasicExample;

Demo中使用了web端常用到的BrowserRouter、Route、Link等一些常用组件,Router作为react-router的顶层组件来获取 history 的api 和 设置回调函数来更新state。这里引用的组件都是来自react-router-dom 这个库,那么react-router 和 react-router-dom 是什么关系呢。
说的简单一点,react-router-dom 是对react-router所有组件或方法的一个二次导出,并且在react-router组件的基础上添加了新的组件,更加方便开发者处理复杂的应用业务。

1.react-router 导出的所有内容

统计一下,总共10个方法
1.MemoryRouter.js、2.Prompt.js、3.Redirect.js、4.Route.js、5.Router.js、6.StaticRouter.js、7.Switch.js、8.generatePath.js、9.matchPath.js、10.withRouter.js

2.react-router-dom 导出的所有内容

统计一下,总共14个方法
1.BrowserRouter.js、2.HashRouter.js、3.Link.js、4.MemoryRouter.js、5.NavLink.js、6.Prompt.js、7.Redirect.js、8.Route.js、9.Router.js、10.StaticRouter.js、11.Switch.js、12.generatePath.js、13.matchPath.js、14.withRouter.js
react-router-dom在react-router的10个方法上,又添加了4个方法,分别是BrowserRouter、HashRouter、Link、以及NavLink。
所以,react-router-dom将react-router的10个方法引入后,又加入了4个方法,再重新导出,在开发中我们只需要引入react-router-dom这个依赖即可。

下面进入react-router-dom的源码分析阶段,首先来看一下react-router-dom的依赖库

React, 要求版本大于等于15.x

history, react-router的核心依赖库,注入组件操作路由的api

invariant, 用来抛出异常的工具库

loose-envify, 使用browserify工具进行打包的时候,会将项目当中的node全局变量替换为对应的字符串

prop-types, react的props类型校验工具库

react-router, 依赖同版本的react-router

warning, 控制台打印警告信息的工具库

①.BrowserRouter.js, 提供了HTML5的history api 如pushState、replaceState等来切换地址,源码如下

import warning from "warning";
import React from "react";
import PropTypes from "prop-types";
import { createBrowserHistory as createHistory } from "history";
import Router from "./Router";

/**
 * The public API for a  that uses HTML5 history.
 */
class BrowserRouter extends React.Component {
  static propTypes = {
    basename: PropTypes.string, // 当应用为某个子应用时,添加的地址栏前缀
    forceRefresh: PropTypes.bool, // 切换路由时,是否强制刷新
    getUserConfirmation: PropTypes.func, // 使用Prompt组件时 提示用户的confirm确认方法,默认使用window.confirm
    keyLength: PropTypes.number, // 为了实现block功能,react-router维护创建了一个访问过的路由表,每个key代表一个曾经访问过的路由地址
    children: PropTypes.node // 子节点
  };
  // 核心api, 提供了push replace go等路由跳转方法
  history = createHistory(this.props); 
  // 提示用户 BrowserRouter不接受用户自定义的history方法,
  // 如果传递了history会被忽略,如果用户使用自定义的history api,
  // 需要使用 Router 组件进行替代
  componentWillMount() {
    warning(
      !this.props.history,
      " ignores the history prop. To use a custom history, " +
        "use `import { Router }` instead of `import { BrowserRouter as Router }`."
    );
  }
  // 将history和children作为props传递给Router组件 并返回
  render() {
    return ;
  }
}

export default BrowserRouter;

**总结:BrowserRouter组件非常简单,它本身其实就是对Router组件的一个包装,将HTML5的history api封装好再赋予 Router 组件。BrowserRouter就好比一个容器组件,由它来决定Router的最终api,这样一个Router组件就可以完成多种api的实现,比如HashRouter、StaticRouter 等,减少了代码的耦合度
②. Router.js, 如果说BrowserRouter是Router的容器组件,为Router提供了html5的history api的数据源,那么Router.js 亦可以看作是子节点的容器组件,它除了接收BrowserRouter提供的history api,最主要的功能就是组件本身会响应地址栏的变化进行setState进而完成react本身的rerender,使应用进行相应的UI切换,源码如下**

import warning from "warning";
import invariant from "invariant";
import React from "react";
import PropTypes from "prop-types";

/**
 * The public API for putting history on context.
 */
class Router extends React.Component {
    // react-router 4.x依然使用的使react旧版的context API
    // react-router 5.x将会作出升级
  static propTypes = {
    history: PropTypes.object.isRequired,
    children: PropTypes.node
  };
  // 此处是为了能够接收父级容器传递的context router,不过父级很少有传递router的
  // 存在的目的是为了方便用户使用这种潜在的方式,来传递自定义的router对象
  static contextTypes = {
    router: PropTypes.object
  };
  // 传递给子组件的context api router, 可以通过context上下文来获得
  static childContextTypes = {
    router: PropTypes.object.isRequired
  };
  // router 对象的具体值
  getChildContext() {
    return {
      router: {
        ...this.context.router,
        history: this.props.history, // 路由api等,会在history库进行讲解
        route: {
          location: this.props.history.location, // 也是history库中的内容
          match: this.state.match // 对当前地址进行匹配的结果
        }
      }
    };
  }
  // Router组件的state,作为一个顶层容器组件维护的state,存在两个目的
  // 1.主要目的为了实现自上而下的rerender,url改变的时候match对象会被更新
  // 2.Router组件是始终会被渲染的组件,match对象会随时得到更新,并经过context api
  // 传递给下游子组件route等
  state = {
    match: this.computeMatch(this.props.history.location.pathname)
  };
  // match 的4个参数
  // 1.path: 是要进行匹配的路径可以是 "/user/:id" 这种动态路由的模式
  // 2.url: 地址栏实际的匹配结果
  // 3.parmas: 动态路由所匹配到的参数,如果path是 "/user/:id"匹配到了,那么
  // params的内容就是 {id: 某个值}
  // 4.isExact: 精准匹配即 地址栏的pathname 和 正则匹配到url是否完全相等
  computeMatch(pathname) {
    return {
      path: "/",
      url: "/",
      params: {},
      isExact: pathname === "/"
    };
  }

  componentWillMount() {
    const { children, history } = this.props;
    // 当 子节点并非由一个根节点包裹时 抛出错误提示开发者
    invariant(
      children == null || React.Children.count(children) === 1,
      "A  may have only one child element"
    );

    // Do this here so we can setState when a  changes the
    // location in componentWillMount. This happens e.g. when doing
    // server rendering using a .
    // 使用history.listen方法,在Router被实例化时注册一个回调事件,
    // 即location地址发生改变的时候,会重新setState,进而rerender
    // 这里使用willMount而不使用didMount的原因时是因为,服务端渲染时不存在dom,
    // 故不会调用didMount的钩子,react将在17版本移除此钩子,那么到时候router应该如何实现此功能?
    this.unlisten = history.listen(() => {
      this.setState({
        match: this.computeMatch(history.location.pathname)
      });
    });
  }
   // history参数不允许被更改
  componentWillReceiveProps(nextProps) {
    warning(
      this.props.history === nextProps.history,
      "You cannot change "
    );
  }
  // 组件销毁时 解绑history对象中的监听事件
  componentWillUnmount() {
    this.unlisten();
  }
  // render的时候使用React.Children.only方法再验证一次
  // children 必须是一个由根节点包裹的组件或dom
  render() {
    const { children } = this.props;
    return children ? React.Children.only(children) : null;
  }
}

export default Router;

总结:Router组件职责很清晰就是作为容器组件,将上层组件的api进行向下的传递,同时组件本身注册了回调方法,来满足浏览器环境下或者服务端环境下location发生变化时,重新setState,达到组件的rerender。那么history对象到底是怎么实现对地址栏进行监听的,又是如何对location进行push 或者 replace的,这就要看history这个库做了啥。

createBrowserHistory.js 使用html5 history api封装的路由控制器

createHashHistory.js 使用hash方法封装的路由控制器

createMemoryHistory.js 针对native app这种原生应用封装的路由控制器,即在内存中维护一份路由表

createTransitionManager.js 针对路由切换时的相同操作抽离的一个公共方法,路由切换的操作器,拦截器和订阅者都存在于此

DOMUtils.js 针对web端dom操作或判断兼容性的一个工具方法集合

LocationUtils.js 针对location url处理等抽离的一个工具方法的集合

PathUtils.js 用来处理url路径的工具方法集合

这里主要分析createBrowserHistory.js文件

import warning from "warning"
import invariant from "invariant"
import { createLocation } from "./LocationUtils"
import {
  addLeadingSlash,
  stripTrailingSlash,
  hasBasename,
  stripBasename,
  createPath
} from "./PathUtils"
import createTransitionManager from "./createTransitionManager"
import {
  canUseDOM,
  addEventListener,
  removeEventListener,
  getConfirmation,
  supportsHistory,
  supportsPopStateOnHashChange,
  isExtraneousPopstateEvent
} from "./DOMUtils"

const PopStateEvent = "popstate"
const HashChangeEvent = "hashchange"

const getHistoryState = () => {
  // ...
}

/**
 * Creates a history object that uses the HTML5 history API including
 * pushState, replaceState, and the popstate event.
 */
const createBrowserHistory = (props = {}) => {
  invariant(
    canUseDOM,
    "Browser history needs a DOM"
  )

  const globalHistory = window.history
  const canUseHistory = supportsHistory()
  const needsHashChangeListener = !supportsPopStateOnHashChange()

  const {
    forceRefresh = false,
    getUserConfirmation = getConfirmation,
    keyLength = 6
  } = props
  const basename = props.basename ? stripTrailingSlash(addLeadingSlash(props.basename)) : ""

  const getDOMLocation = (historyState) => {
     // ...
  }

  const createKey = () =>
    Math.random().toString(36).substr(2, keyLength)

  const transitionManager = createTransitionManager()

  const setState = (nextState) => {
     // ...
  }

  const handlePopState = (event) => {
    // ...
  }

  const handleHashChange = () => {
    // ...
  }

  let forceNextPop = false

  const handlePop = (location) => {
     // ...
  }

  const revertPop = (fromLocation) => {
    // ...
  }

  const initialLocation = getDOMLocation(getHistoryState())
  let allKeys = [ initialLocation.key ]

  // Public interface

  const createHref = (location) =>
    basename + createPath(location)

  const push = (path, state) => {
    // ...
  }

  const replace = (path, state) => {
    // ...
  }

  const go = (n) => {
    globalHistory.go(n)
  }

  const goBack = () =>
    go(-1)

  const goForward = () =>
    go(1)

  let listenerCount = 0

  const checkDOMListeners = (delta) => {
    // ...
  }

  let isBlocked = false

  const block = (prompt = false) => {
    // ...
  }

  const listen = (listener) => {
    // ...
  }

  const history = {
    length: globalHistory.length,
    action: "POP",
    location: initialLocation,
    createHref,
    push,
    replace,
    go,
    goBack,
    goForward,
    block,
    listen
  }

  return history
}

export default createBrowserHistory

createBrowserHistory.js 总共300+行代码,其原理就是封装了原生的html5 的history api,如pushState,replaceState,当这些事件被触发时会激活subscribe的回调来进行响应。同时也会对地址栏进行监听,当history.go等事件触发history popstate事件时,也会激活subscribe的回调。

由于代码量较多,而且依赖的方法较多,这里将方法分成几个小节来进行梳理,对于依赖的方法先进行简短阐述,当实际调用时在深入源码内部去探究实现细节

1. 依赖的工具方法

import warning from "warning"  // 控制台的console.warn警告
import invariant from "invariant" // 用来抛出异常错误信息
// 对地址参数处理,最终返回一个对象包含 pathname,search,hash,state,key 等参数
import { createLocation } from "./LocationUtils" 
import { 
  addLeadingSlash,  // 对传递的pathname添加首部`/`,即 "home" 处理为 "/home",存在首部`/`的不做处理
  stripTrailingSlash,  // 对传递的pathname去掉尾部的 `/`
  hasBasename, // 判断是否传递了basename参数
  stripBasename, // 如果传递了basename参数,那么每次需要将pathname中的basename统一去除
  createPath // 将location对象的参数生成最终的地址栏路径
} from "./PathUtils"
import createTransitionManager from "./createTransitionManager" // 抽离的路由切换的公共方法
import {
  canUseDOM,  // 当前是否可使用dom, 即window对象是否存在,是否是浏览器环境下
  addEventListener, // 兼容ie 监听事件
  removeEventListener, // 解绑事件
  getConfirmation,   // 路由跳转的comfirm 回调,默认使用window.confirm
  supportsHistory, // 当前环境是否支持history的pushState方法
  supportsPopStateOnHashChange, // hashChange是否会触发h5的popState方法,ie10、11并不会
  isExtraneousPopstateEvent // 判断popState是否时真正有效的
} from "./DOMUtils"

const PopStateEvent = "popstate"  // 针对popstate事件的监听
const HashChangeEvent = "hashchange" // 针对不支持history api的浏览器 启动hashchange监听事件

// 返回history的state
const getHistoryState = () => {
  try {
    return window.history.state || {}
  } catch (e) {
    // IE 11 sometimes throws when accessing window.history.state
    // See https://github.com/ReactTraining/history/pull/289
    // IE11 下有时会抛出异常,此处保证state一定返回一个对象
    return {} 
  }
}

creareBrowserHistory的具体实现

const createBrowserHistory = (props = {}) => {
  // 当不在浏览器环境下直接抛出错误
  invariant(
    canUseDOM,
    "Browser history needs a DOM"
  )

  const globalHistory = window.history          // 使用window的history
  // 此处注意android 2. 和 4.0的版本并且ua的信息是 mobile safari 的history api是有bug且无法解决的
  const canUseHistory = supportsHistory()      
  // hashChange的时候是否会进行popState操作,ie10、11不会进行popState操作 
  const needsHashChangeListener = !supportsPopStateOnHashChange()

  const {
    forceRefresh = false,                     // 默认切换路由不刷新
    getUserConfirmation = getConfirmation,    // 使用window.confirm
    keyLength = 6                             // 默认6位长度随机key
  } = props
  // addLeadingSlash 添加basename头部的斜杠
  // stripTrailingSlash 去掉 basename 尾部的斜杠
  // 如果basename存在的话,保证其格式为 ‘/xxx’
  const basename = props.basename ? stripTrailingSlash(addLeadingSlash(props.basename)) : ""

  const getDOMLocation = (historyState) => {
       // 获取history对象的key和state
    const { key, state } = (historyState || {})
     // 获取当前路径下的pathname,search,hash等参数
    const { pathname, search, hash } = window.location 
      // 拼接一个完整的路径
    let path = pathname + search + hash               

    // 当传递了basename后,所有的pathname必须包含这个basename
    warning(
      (!basename || hasBasename(path, basename)),
      "You are attempting to use a basename on a page whose URL path does not begin " +
      "with the basename. Expected path "" + path + "" to begin with "" + basename + ""."
    )
    
    // 去掉path当中的basename
    if (basename)
      path = stripBasename(path, basename)
    
    // 生成一个自定义的location对象
    return createLocation(path, state, key)
  }

  // 使用6位长度的随机key
  const createKey = () =>
    Math.random().toString(36).substr(2, keyLength)

  // transitionManager是history中最复杂的部分,复杂的原因是因为
  // 为了实现block方法,做了对路由拦截的hack,虽然能实现对路由切时的拦截功能
  // 比如Prompt组件,但同时也带来了不可解决的bug,后面在讨论
  // 这里返回一个对象包含 setPrompt、confirmTransitionTo、appendListener
  // notifyListeners 等四个方法
  const transitionManager = createTransitionManager()
  
  const setState = (nextState) => {
    // nextState包含最新的 action 和 location
    // 并将其更新到导出的 history 对象中,这样Router组件相应的也会得到更新
    // 可以理解为同react内部所做的setState时相同的功能
    Object.assign(history, nextState)
    // 更新history的length, 实实保持和window.history.length 同步
    history.length = globalHistory.length
    // 通知subscribe进行回调
    transitionManager.notifyListeners(
      history.location,
      history.action
    )
  }
  // 当监听到popState事件时进行的处理
  const handlePopState = (event) => {
    // Ignore extraneous popstate events in WebKit.
    if (isExtraneousPopstateEvent(event))
      return 
    // 获取当前地址栏的history state并传递给getDOMLocation
    // 返回一个新的location对象
    handlePop(getDOMLocation(event.state))
  }

  const handleHashChange = () => {
      // 监听到hashchange时进行的处理,由于hashchange不会更改state
      // 故此处不需要更新location的state
    handlePop(getDOMLocation(getHistoryState()))
  }
   // 用来判断路由是否需要强制
  let forceNextPop = false
   // handlePop是对使用go方法来回退或者前进时,对页面进行的更新,正常情况下来说没有问题
   // 但是如果页面使用Prompt,即路由拦截器。当点击回退或者前进就会触发histrory的api,改变了地址栏的路径
   // 然后弹出需要用户进行确认的提示框,如果用户点击确定,那么没问题因为地址栏改变的地址就是将要跳转到地址
   // 但是如果用户选择了取消,那么地址栏的路径已经变成了新的地址,但是页面实际还停留再之前,这就产生了bug
   // 这也就是 revertPop 这个hack的由来。因为页面的跳转可以由程序控制,但是如果操作的本身是浏览器的前进后退
   // 按钮,那么是无法做到真正拦截的。
  const handlePop = (location) => {
    if (forceNextPop) {
      forceNextPop = false
      setState()
    } else {
      const action = "POP"

      transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
        if (ok) {
          setState({ action, location })
        } else {
            // 当拦截器返回了false的时候,需要把地址栏的路径重置为当前页面显示的地址
          revertPop(location)
        }
      })
    }
  }
   // 这里是react-router的作者最头疼的一个地方,因为虽然用hack实现了表面上的路由拦截
   // ,但也会引起一些特殊情况下的bug。这里先说一下如何做到的假装拦截,因为本身html5 history
   // api的特性,pushState 这些操作不会引起页面的reload,所有做到拦截只需要不手懂调用setState页面不进行render即可
   // 当用户选择了取消后,再将地址栏中的路径变为当前页面的显示路径即可,这也是revertPop实现的方式
   // 这里贴出一下对这个bug的讨论:https://github.com/ReactTraining/history/issues/690
  const revertPop = (fromLocation) => {
      // fromLocation 当前地址栏真正的路径,而且这个路径一定是存在于history历史
      // 记录当中某个被访问过的路径,因为我们需要将地址栏的这个路径重置为页面正在显示的路径地址
      // 页面显示的这个路径地址一定是还再history.location中的那个地址
      // fromLoaction 用户原本想去但是后来又不去的那个地址,需要把他换位history.location当中的那个地址      
    const toLocation = history.location

    // TODO: We could probably make this more reliable by
    // keeping a list of keys we"ve seen in sessionStorage.
    // Instead, we just default to 0 for keys we don"t know.
     // 取出toLocation地址再allKeys中的下标位置
    let toIndex = allKeys.indexOf(toLocation.key)

    if (toIndex === -1)
      toIndex = 0
     // 取出formLoaction地址在allKeys中的下标位置
    let fromIndex = allKeys.indexOf(fromLocation.key)

    if (fromIndex === -1)
      fromIndex = 0
     // 两者进行相减的值就是go操作需要回退或者前进的次数
    const delta = toIndex - fromIndex
     // 如果delta不为0,则进行地址栏的变更 将历史记录重定向到当前页面的路径   
    if (delta) {
      forceNextPop = true // 将forceNextPop设置为true
      // 更改地址栏的路径,又会触发handlePop 方法,此时由于forceNextPop已经为true则会执行后面的
      // setState方法,对当前页面进行rerender,注意setState是没有传递参数的,这样history当中的
      // location对象依然是之前页面存在的那个loaction,不会改变history的location数据
      go(delta) 
    }
  }

  // 返回一个location初始对象包含
  // pathname,search,hash,state,key key有可能是undefined
  const initialLocation = getDOMLocation(getHistoryState())
  let allKeys = [ initialLocation.key ]

  // Public interface

  // 拼接上basename
  const createHref = (location) =>
    basename + createPath(location)

  const push = (path, state) => {
    warning(
      !(typeof path === "object" && path.state !== undefined && state !== undefined),
      "You should avoid providing a 2nd state argument to push when the 1st " +
      "argument is a location-like object that already has state; it is ignored"
    )

    const action = "PUSH"
    const location = createLocation(path, state, createKey(), history.location)

    transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
      if (!ok)
        return

      const href = createHref(location)  // 拼接basename
      const { key, state } = location

      if (canUseHistory) {
        globalHistory.pushState({ key, state }, null, href) // 只是改变地址栏路径 此时页面不会改变

        if (forceRefresh) {
          window.location.href = href // 强制刷新
        } else {
          const prevIndex = allKeys.indexOf(history.location.key) // 上次访问的路径的key
          const nextKeys = allKeys.slice(0, prevIndex === -1 ? 0 : prevIndex + 1)

          nextKeys.push(location.key) // 维护一个访问过的路径的key的列表
          allKeys = nextKeys

          setState({ action, location }) // render页面
        }
      } else {
        warning(
          state === undefined,
          "Browser history cannot push state in browsers that do not support HTML5 history"
        )

        window.location.href = href
      }
    })
  }

  const replace = (path, state) => {
    warning(
      !(typeof path === "object" && path.state !== undefined && state !== undefined),
      "You should avoid providing a 2nd state argument to replace when the 1st " +
      "argument is a location-like object that already has state; it is ignored"
    )

    const action = "REPLACE"
    const location = createLocation(path, state, createKey(), history.location)

    transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
      if (!ok)
        return

      const href = createHref(location)
      const { key, state } = location

      if (canUseHistory) {
        globalHistory.replaceState({ key, state }, null, href)

        if (forceRefresh) {
          window.location.replace(href)
        } else {
          const prevIndex = allKeys.indexOf(history.location.key)

          if (prevIndex !== -1)
            allKeys[prevIndex] = location.key

          setState({ action, location })
        }
      } else {
        warning(
          state === undefined,
          "Browser history cannot replace state in browsers that do not support HTML5 history"
        )

        window.location.replace(href)
      }
    })
  }

  const go = (n) => {
    globalHistory.go(n)
  }

  const goBack = () =>
    go(-1)

  const goForward = () =>
    go(1)

  let listenerCount = 0
   // 防止重复注册监听,只有listenerCount == 1的时候才会进行监听事件
  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)
    }
  }
  // 默认情况下不会阻止路由的跳转
  let isBlocked = false
  // 这里的block方法专门为Prompt组件设计,开发者可以模拟对路由的拦截
  const block = (prompt = false) => {
      // prompt 默认为false, prompt可以为string或者func
      // 将拦截器的开关打开,并返回可关闭拦截器的方法
    const unblock = transitionManager.setPrompt(prompt)
      // 监听事件只会当拦截器开启时被注册,同时设置isBlock为true,防止多次注册
    if (!isBlocked) {
      checkDOMListeners(1)
      isBlocked = true
    }
     // 返回关闭拦截器的方法
    return () => {
      if (isBlocked) {
        isBlocked = false
        checkDOMListeners(-1)
      }

      return unblock()
    }
  }

  const listen = (listener) => {
    const unlisten = transitionManager.appendListener(listener) // 添加订阅者
    checkDOMListeners(1) // 监听popState pushState 等事件

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

  const history = {
    length: globalHistory.length,
    action: "POP",
    location: initialLocation,
    createHref,
    push,
    replace,
    go,
    goBack,
    goForward,
    block,
    listen
  }

  return history
}

由于篇幅过长,所以这里抽取push方法来梳理整套流程

  const push = (path, state) => {
      // push可接收两个参数,第一个参数path可以是字符串,或者对象,第二个参数是state对象
      // 里面是可以被浏览器缓存的数据,当path是一个对象并且path中的state存在,同时也传递了
      // 第二个参数state,那么这里就会给出警告,表示path中的state参数将会被忽略
      
    warning(
      !(typeof path === "object" && path.state !== undefined && state !== undefined),
      "You should avoid providing a 2nd state argument to push when the 1st " +
      "argument is a location-like object that already has state; it is ignored"
    )

     const action = "PUSH" // 动作为push操作
     //将即将访问的路径path, 被缓存的state,将要访问的路径的随机生成的6位随机字符串,
     // 上次访问过的location对象也可以理解为当前地址栏里路径对象,  
     // 返回一个对象包含 pathname,search,hash,state,key
    const location = createLocation(path, state, createKey(), history.location)
     // 路由的切换,最后一个参数为回调函数,只有返回true的时候才会进行路由的切换
    transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
      if (!ok)
        return
      
      const href = createHref(location)  // 拼接basename
      const { key, state } = location  // 获取新的key和state

      if (canUseHistory) {
          // 当可以使用history api时候,调用原生的pushState方法更改地址栏路径
          // 此时只是改变地址栏路径 页面并不会发生变化 需要手动setState从而rerender
        // pushState的三个参数分别为,1.可以被缓存的state对象,即刷新浏览器依然会保留
        // 2.页面的title,可直接忽略 3.href即新的地址栏路径,这是一个完整的路径地址
        globalHistory.pushState({ key, state }, null, href) 
        
        if (forceRefresh) { 
          window.location.href = href // 强制刷新
        } else {
          // 获取上次访问的路径的key在记录列表里的下标
          const prevIndex = allKeys.indexOf(history.location.key)
          // 当下标存在时,返回截取到当前下标的数组key列表的一个新引用,不存在则返回一个新的空数组
          // 这样做的原因是什么?为什么不每次访问直接向allKeys列表中直接push要访问的key
          // 比如这样的一种场景, 1-2-3-4 的页面访问顺序,这时候使用go(-2) 回退到2的页面,假如在2
          // 的页面我们选择了push进行跳转到4页面,如果只是简单的对allKeys进行push操作那么顺序就变成了
          // 1-2-3-4-4,这时候就会产生一悖论,从4页面跳转4页面,这种逻辑是不通的,所以每当push或者replace
          // 发生的时候,一定是用当前地址栏中path的key去截取allKeys中对应的访问记录,来保证不会push
          // 连续相同的页面
          const nextKeys = allKeys.slice(0, prevIndex === -1 ? 0 : prevIndex + 1)

          nextKeys.push(location.key) // 将新的key添加到allKeys中
          allKeys = nextKeys // 替换

          setState({ action, location }) // render页面
        }
      } else {
        warning(
          state === undefined,
          "Browser history cannot push state in browsers that do not support HTML5 history"
        )

        window.location.href = href
      }
    })
  }

createLocation的源码

export const createLocation = (path, state, key, currentLocation) => {
  let location
  if (typeof path === "string") {
    // Two-arg form: push(path, state)
    // 分解pathname,path,hash,search等,parsePath返回一个对象
    location = parsePath(path)
    location.state = state 
  } else {
    // One-arg form: push(location)
    location = { ...path }

    if (location.pathname === undefined)
      location.pathname = ""

    if (location.search) {
      if (location.search.charAt(0) !== "?")
        location.search = "?" + location.search
    } else {
      location.search = ""
    }

    if (location.hash) {
      if (location.hash.charAt(0) !== "#")
        location.hash = "#" + location.hash
    } else {
      location.hash = ""
    }

    if (state !== undefined && location.state === undefined)
      location.state = state
  }

  // 尝试对pathname进行decodeURI解码操作,失败时进行提示
  try {
    location.pathname = decodeURI(location.pathname)
  } catch (e) {
    if (e instanceof URIError) {
      throw new URIError(
        "Pathname "" + location.pathname + "" could not be decoded. " +
        "This is likely caused by an invalid percent-encoding."
      )
    } else {
      throw e
    }
  }

  if (key)
    location.key = key

  if (currentLocation) {
    // Resolve incomplete/relative pathname relative to current location.
    if (!location.pathname) {
      location.pathname = currentLocation.pathname
    } else if (location.pathname.charAt(0) !== "/") {
      location.pathname = resolvePathname(location.pathname, currentLocation.pathname)
    }
  } else {
    // When there is no prior location and pathname is empty, set it to /
    // pathname 不存在的时候返回当前路径的根节点
    if (!location.pathname) {
      location.pathname = "/"
    }
  }

  // 返回一个location对象包含
  // pathname,search,hash,state,key
  return location
}

createTransitionManager.js的源码

import warning from "warning"

const createTransitionManager = () => {
  // 这里使一个闭包环境,每次进行路由切换的时候,都会先进行对prompt的判断
  // 当prompt != null 的时候,表示路由的上次切换被阻止了,那么当用户confirm返回true
  // 的时候会直接进行地址栏的更新和subscribe的回调
  let prompt = null // 提示符
  
  const setPrompt = (nextPrompt) => {
      // 提示prompt只能存在一个
    warning(
      prompt == null,
      "A history supports only one prompt at a time"
    )

    prompt = nextPrompt
     // 同时将解除block的方法返回
    return () => {
      if (prompt === nextPrompt)
        prompt = null
    }
  }
  // 
  const confirmTransitionTo = (location, action, getUserConfirmation, callback) => {
    // TODO: If another transition starts while we"re still confirming
    // the previous one, we may end up in a weird state. Figure out the
    // best way to handle this.
    if (prompt != null) {
      // prompt 可以是一个函数,如果是一个函数返回执行的结果
      const result = typeof prompt === "function" ? prompt(location, action) : prompt
       // 当prompt为string类型时 基本上就是为了提示用户即将要跳转路由了,prompt就是提示信息
      if (typeof result === "string") {
          // 调用window.confirm来显示提示信息
        if (typeof getUserConfirmation === "function") {
            // callback接收用户 选择了true或者false
          getUserConfirmation(result, callback)
        } else {
            // 提示开发者 getUserConfirmatio应该是一个function来展示阻止路由跳转的提示
          warning(
            false,
            "A history needs a getUserConfirmation function in order to use a prompt message"
          )
          // 相当于用户选择true 不进行拦截
          callback(true)
        }
      } else {
        // Return false from a transition hook to cancel the transition.
        callback(result !== false)
      }
    } else {
        // 当不存在prompt时,直接执行回调函数,进行路由的切换和rerender
      callback(true)
    }
  }
   // 被subscribe的列表,即在Router组件添加的setState方法,每次push replace 或者 go等操作都会触发
  let listeners = []
  // 将回调函数添加到listeners,一个发布订阅模式
  const appendListener = (fn) => {
    let isActive = true
     // 这里有个奇怪的地方既然订阅事件可以被解绑就直接被从数组中删除掉了,为什么这里还需要这个isActive
     // 再加一次判断呢,其实是为了避免一种情况,比如注册了多个listeners: a,b,c 但是在a函数中注销了b函数
     // 理论上来说b函数应该不能在执行了,但是注销方法里使用的是数组的filter,每次返回的是一个新的listeners引用,
     // 故每次解绑如果不添加isActive这个开关,那么当前循环还是会执行b的事件。加上isActive后,原始的liteners中
     // 的闭包b函数的isActive会变为false,从而阻止事件的执行,当循环结束后,原始的listeners也会被gc回收
    const listener = (...args) => {
      if (isActive)
        fn(...args)
    }

    listeners.push(listener)
     
    return () => {
      isActive = false
      listeners = listeners.filter(item => item !== listener)
    }
  }
  // 通知被订阅的事件开始执行
  const notifyListeners = (...args) => {
    listeners.forEach(listener => listener(...args))
  }

  return {
    setPrompt,
    confirmTransitionTo,
    appendListener,
    notifyListeners
  }
}

export default createTransitionManager

由于篇幅太长,自己都看的蒙圈了,现在就简单做一下总结,描述router工作的原理。
1.首先BrowserRouter通过history库使用createBrowserHistory方法创建了一个history对象,并将此对象作为props传递给了Router组件
2.Router组件使用history对的的listen方法,注册了组件自身的setState事件,这样一样来,只要触发了html5的popstate事件,组件就会执行setState事件,完成整个应用的rerender
3.history是一个对象,里面包含了操作页面跳转的方法,以及当前地址栏对象的location的信息。首先当创建一个history对象时候,会使用props当中的四个参数信息,forceRefresh、basename、getUserComfirmation、keyLength 来生成一个初始化的history对象,四个参数均不是必传项。首先会使用window.location对象获取当前路径下的pathname、search、hash等参数,同时如果页面是经过rerolad刷新过的页面,那么也会保存之前向state添加过数据,这里除了我们自己添加的state,还有history这个库自己每次做push或者repalce操作的时候随机生成的六位长度的字符串key
拿到这个初始化的location对象后,history开始封装push、replace、go等这些api。
以push为例,可以接收两个参数push(path, state)----我们常用的写法是push("/user/list"),只需要传递一个路径不带参数,或者push({pathname: "/user", state: {id: "xxx"}, search: "?name=xxx", hash: "#list"})传递一个对象。任何对地址栏的更新都会经过confirmTransitionTo 这个方法进行验证,这个方法是为了支持prompt拦截器的功能。正常在拦截器关闭的情况下,每次调用push或者replace都会随机生成一个key,代表这个路径的唯一hash值,并将用户传递的state和key作为state,注意这部分state会被保存到 浏览器 中是一个长效的缓存,将拼接好的path作为传递给history的第三个参数,调用history.pushState(state, null, path),这样地址栏的地址就得到了更新。
地址栏地址得到更新后,页面在不使用foreceRefrsh的情况下是不会自动更新的。此时需要循环执行在创建history对象时,在内存中的一个listeners监听队列,即在步骤2中在Router组件内部注册的回调,来手动完成页面的setState,至此一个完整的更新流程就算走完了。
在history里有一个block的方法,这个方法的初衷是为了实现对路由跳转的拦截。我们知道浏览器的回退和前进操作按钮是无法进行拦截的,只能做hack,这也是history库的做法。抽离出了一个路径控制器,方法名称叫做createTransitionManager,可以理解为路由操作器。这个方法在内部维护了一个prompt的拦截器开关,每当这个开关打开的时候,所有的路由在跳转前都会被window.confirm所拦截。注意此拦截并非真正的拦截,虽然页面没有改变,但是地址栏的路径已经改变了。如果用户没有取消拦截,那么页面依然会停留在当前页面,这样和地址栏的路径就产生了悖论,所以需要将地址栏的路径再重置为当前页面真正渲染的页面。为了实现这一功能,不得不创建了一个用随机key值的来表示的访问过的路径表allKeys。每次页面被拦截后,都需要在allKeys的列表中找到当前路径下的key的下标,以及实际页面显示的location的key的下标,后者减前者的值就是页面要被回退或者前进的次数,调用go方法后会再次触发popstate事件,造成页面的rerender。
正式因为有了Prompt组件才会使history不得不增加了key列表,prompt开关,导致代码的复杂度成倍增加,同时很多开发者在开发中对此组件的滥用也导致了一些特殊的bug,并且这些bug都是无法解决的,这也是作者为什么想要在下个版本中移除此api的缘由。讨论地址在链接描述

。下篇将会进行对Route Switch Link等其他组件的讲解

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/109660.html

相关文章

  • react-router v4.x 源码拾遗1

    摘要:还是先来一段官方的基础使用案例,熟悉一下整体的代码流程中使用了端常用到的等一些常用组件,作为的顶层组件来获取的和设置回调函数来更新。 react-router是react官方推荐并参与维护的一个路由库,支持浏览器端、app端、服务端等常见场景下的路由切换功能,react-router本身不具备切换和跳转路由的功能,这些功能全部由react-router依赖的history库完成,his...

    Joyven 评论0 收藏0
  • react-router v4.x 源码拾遗2

    摘要:如果将添加到当前组件,并且当前组件由包裹,那么将引用最外层包装组件的实例而并非我们期望的当前组件,这也是在实际开发中为什么不推荐使用的原因,使用一个回调函数是一个不错的选择,也同样的使用的是回调函数来实现的。 回顾:上一篇讲了BrowserRouter 和 Router之前的关系,以及Router实现路由跳转切换的原理。这一篇来简短介绍react-router剩余组件的源码,结合官方文...

    luoyibu 评论0 收藏0
  • react-router v4.x 源码拾遗2

    摘要:如果将添加到当前组件,并且当前组件由包裹,那么将引用最外层包装组件的实例而并非我们期望的当前组件,这也是在实际开发中为什么不推荐使用的原因,使用一个回调函数是一个不错的选择,也同样的使用的是回调函数来实现的。 回顾:上一篇讲了BrowserRouter 和 Router之前的关系,以及Router实现路由跳转切换的原理。这一篇来简短介绍react-router剩余组件的源码,结合官方文...

    CoorChice 评论0 收藏0

发表评论

0条评论

itvincent

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<