react-router v4.x 源码拾遗1

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官方的基础使用案例,熟悉一下总体的代码流程html

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

function BasicExample() {
  return (
    <Router>
      <div>
        <ul>
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/about">About</Link>
          </li>
          <li>
            <Link to="/topics">Topics</Link>
          </li>
        </ul>

        <hr />

        <Route exact path="/" component={Home} />
        <Route path="/about" component={About} />
        <Route path="/topics" component={Topics} />
      </div>
    </Router>
  );
}

function Home() {
  return (
    <div>
      <h2>Home</h2>
    </div>
  );
}

function About() {
  return (
    <div>
      <h2>About</h2>
    </div>
  );
}

function Topics({ match }) {
  return (
    <div>
      <h2>Topics</h2>
      <ul>
        <li>
          <Link to={`${match.url}/rendering`}>Rendering with React</Link>
        </li>
        <li>
          <Link to={`${match.url}/components`}>Components</Link>
        </li>
        <li>
          <Link to={`${match.url}/props-v-state`}>Props v. State</Link>
        </li>
      </ul>

      <Route path={`${match.path}/:topicId`} component={Topic} />
      <Route
        exact
        path={match.path}
        render={() => <h3>Please select a topic.</h3>}
      />
    </div>
  );
}

function Topic({ match }) {
  return (
    <div>
      <h3>{match.params.topicId}</h3>
    </div>
  );
}

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组件的基础上添加了新的组件,更加方便开发者处理复杂的应用业务。html5

1.react-router 导出的全部内容node

clipboard.png

统计一下,总共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.jsreact

2.react-router-dom 导出的全部内容android

clipboard.png

统计一下,总共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这个依赖便可。git

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

clipboard.png

  1. React, 要求版本大于等于15.x
  2. history, react-router的核心依赖库,注入组件操做路由的api
  3. invariant, 用来抛出异常的工具库
  4. loose-envify, 使用browserify工具进行打包的时候,会将项目当中的node全局变量替换为对应的字符串
  5. prop-types, react的props类型校验工具库
  6. react-router, 依赖同版本的react-router
  7. warning, 控制台打印警告信息的工具库

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

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 <Router> 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,
      "<BrowserRouter> ignores the history prop. To use a custom history, " +
        "use `import { Router }` instead of `import { BrowserRouter as Router }`."
    );
  }
  // 将history和children做为props传递给Router组件 并返回
  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
}

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切换,源码以下**api

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 <Router> may have only one child element"
    );

    // Do this here so we can setState when a <Redirect> changes the
    // location in componentWillMount. This happens e.g. when doing
    // server rendering using a <StaticRouter>.
    // 使用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 <Router history>"
    );
  }
  // 组件销毁时 解绑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这个库作了啥。数组

clipboard.png

  1. createBrowserHistory.js 使用html5 history api封装的路由控制器
  2. createHashHistory.js 使用hash方法封装的路由控制器
  3. createMemoryHistory.js 针对native app这种原生应用封装的路由控制器,即在内存中维护一份路由表
  4. createTransitionManager.js 针对路由切换时的相同操做抽离的一个公共方法,路由切换的操做器,拦截器和订阅者都存在于此
  5. DOMUtils.js 针对web端dom操做或判断兼容性的一个工具方法集合
  6. LocationUtils.js 针对location url处理等抽离的一个工具方法的集合
  7. 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方法,ie十、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操做,ie十、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等其余组件的讲解

相关文章
相关标签/搜索