美文网首页React
细读 React | React Router 路由切换原理

细读 React | React Router 路由切换原理

作者: 越前君 | 来源:发表于2022-02-05 19:04 被阅读0次
    2022 北京冬奥会开幕式

    此前一直在疑惑,明明 pushState()replaceState() 不触发 popstate 事件,可为什么 React Router 还能挂载对应路由的组件呢?

    翻了一下 history.js 源码,终于知道原因了。

    源码

    假设项目路由设计如下:

    import { render } from 'react-dom'
    import { BrowserRouter, Routes, Route } from 'react-router-dom'
    import { Mine, About } from './routes'
    import App from './App'
    
    const rootElement = document.getElementById('root')
    
    render(
      <BrowserRouter>
        <Routes>
          <Route path="/" exact element={<App />} />
          <Route path="/mine" element={<Mine />} />
          <Route path="/about" element={<About />} />
        </Routes>
      </BrowserRouter>,
      rootElement
    )
    

    然后我们看下 <BrowserRouter /> 的源码(react-router-dom/modules/BrowserRouter.js),以下省略了一部分无关代码:

    import React from 'react'
    import { Router } from 'react-router'
    import { createBrowserHistory as createHistory } from 'history'
    
    /**
     * The public API for a <Router> that uses HTML5 history.
     */
    class BrowserRouter extends React.Component {
      // 构建 history 对象
      history = createHistory(this.props)
    
      render() {
        // 将 history 对象等传入 <Router /> 组件
        return <Router history={this.history} children={this.props.children} />
      }
    }
    
    // ...
    
    export default BrowserRouter
    

    接着我们继续看下 <Router /> 组件的源码(react-router/modules/Router.js),如下:

    import React from 'react'
    import HistoryContext from './HistoryContext.js'
    import RouterContext from './RouterContext.js'
    
    /**
     * The public API for putting history on context.
     */
    class Router extends React.Component {
      static computeRootMatch(pathname) {
        return { path: '/', url: '/', params: {}, isExact: pathname === '/' }
      }
    
      constructor(props) {
        super(props)
    
        this.state = {
          location: props.history.location
        }
        
        // 关键点:
        // 当触发 popstate 事件、
        // 或主动调用 props.history.push()、props.history.replace() 方法时,
        // 都会执行 history 对象的 listen 方法,使得执行 setState 强制更新当前组件
        this.unlisten = props.history.listen(location => {
          this.setState({ location })
        })
      }
    
      componentWillUnmount() {
        // 组件卸载时,解除监听
        if (this.unlisten) this.unlisten()
      }
    
      render() {
        return (
          // 由于 React Context 的特性,所有消费 RouterContext.Provider 的 Custom 组件
          // 在其 value 值发生变化时,都会重新渲染。
          // 当前 <Router /> 组件并没有做任何限制重新渲染的处理,
          // 因此每次 setState 都会引起 RouterContext.Provider 的 value 值发生变化。
          <RouterContext.Provider
            value={{
              history: this.props.history,
              location: this.state.location,
              match: Router.computeRootMatch(this.state.location.pathname),
              staticContext: this.props.staticContext
            }}
          >
            <HistoryContext.Provider children={this.props.children || null} value={this.props.history} />
          </RouterContext.Provider>
        )
      }
    }
    
    export default Router
    

    原因剖析

    往下之前,如果对 History API 或者 URL Fragment 不了解的,可以看下这篇文章:History 对象及事件监听详解

    react-router-dom 引用了 history.js 库 ,它主要提供了三种方法:createBrowserHistorycreateHashHistorycreateMemoryHistory

    它们用于构建对应模式的 history 对象(请注意,它有别于 window.history 对象),该对象的属性和方法可在 Devtools 中清晰地看到(如下图),也可查阅文档。这个太简单了,你们都懂,不说了。

    本文讨论的是 History 模式,因而对应 createBrowserHistory 方法。

    在构建项目路由时,选择 <BrowserRouter /> 组件,它内部是将通过 createBrowserHistory() 方法构造的 history 对象传递给 <Router /> 组件。

    我们知道,在 React 应用中切换路由,它会加载对应的组件。我们知道 createBrowserHistory() 利用了 HTML5 History API 特性,但是主动调用 window.history.pushState()window.history.replaceState() 方法都不会触发 popstate 事件,因此,如果仅通过监听 popstate 事件是不能完全实现路由切换的。

    那么 React Router 是如何解决问题的呢?

    在前面的源码部分,其实已经添加了一些注解,<Router /> 组件它内部依赖于 Context 的 Provider/Comsumer 模式。因此,它只要做到 URL 发生变化时更新 Context.Providervalue 值即可,至于后续如何加载组件就交给 React 了(当然里面还包括 React Router 的路由匹配,但非本文讨论内容,不展开讲述)。

    一般情况下,<BrowserRouter /> 都会作为整个项目的根路由,它包裹了一层 <Router /> 组件,<Router /> 组件在实例化时,设置了一个监听函数:

    // props.history 就是通过 createBrowserHistory(props) 生成的对象
    this.unlisten = props.history.listen(location => {
      // 回调函数的作用是,通过 setState 触发 Router 组件更新,
      // 使得 Provider 的 value 值发生变化,以带动 Consumer 的更新。
      this.setState({ location })
    })
    // this.unlisten 是一个函数,执行它内部会移除 popstate 事件监听器
    

    Q:history.js 是如何做到每当 URL 发生变化,会触发这个回调函数的?

    在 React 中是通过调用组件的 props.history.push()props.history.replace() 方法实现路由切换的。

    我们来看一下 history.js 的源码(history/esm/history.js):

    里面省略了一部分代码,然后分析顺序已经按顺序标注出来。

    function createTransitionManager() {
      // ...
    
      function confirmTransitionTo(location, action, getUserConfirmation, callback) {
        var result = typeof prompt === 'function' ? prompt(location, action) : prompt
    
        if (typeof result === 'string') {
          if (typeof getUserConfirmation === 'function') {
            getUserConfirmation(result, callback)
          } else {
            process.env.NODE_ENV !== 'production' ? warning(false, 'A history needs a getUserConfirmation function in order to use a prompt message') : void 0
            callback(true)
          }
        } else {
          // Return false from a transition hook to cancel the transition.
          callback(result !== false)
        }
      }
    
      var listeners = []
    
      function appendListener(fn) {
        var isActive = true
    
        function listener() {
          if (isActive) fn.apply(void 0, arguments)
        }
    
        // 添加监听器
        listeners.push(listener)
        return function () {
          isActive = false
          // 过滤重复的监听器
          listeners = listeners.filter(function (item) {
            return item !== listener
          })
        }
      }
    
      // 6️⃣ 执行 listeners 中所有的 listener 监听器,
      // 最后触发 <Router /> 中的回调函数 this.unlisten = props.history.listen(location => { this.setState({ location }) }) 逻辑
      function notifyListeners() {
        // 将类数组 arguments 转换为数组形式
        for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
          args[_key] = arguments[_key]
        }
    
        listeners.forEach(function (listener) {
          // 回调函数将获得 location、action 两个参数
          return listener.apply(void 0, args)
        })
      }
    
      return {
        setPrompt: setPrompt,
        confirmTransitionTo: confirmTransitionTo,
        appendListener: appendListener,
        notifyListeners: notifyListeners
      }
    }
    
    /**
     * Creates a history object that uses the HTML5 history API including
     * pushState, replaceState, and the popstate event.
     */
    function createBrowserHistory(props) {
      // ...
    
      // 创建 location 对象
      function getDOMLocation(historyState) {
        var _ref = historyState || {},
          key = _ref.key,
          state = _ref.state
    
        var _window$location = window.location,
          pathname = _window$location.pathname,
          search = _window$location.search,
          hash = _window$location.hash
        var path = pathname + search + hash
    
        if (basename) path = stripBasename(path, basename)
        return createLocation(path, state, key)
      }
    
      // ...
    
      // 创建 transitionManager 对象
      var transitionManager = createTransitionManager()
    
      // 5️⃣ 主要更新 history 对象,并调用 notifyListeners 方法
      function setState(nextState) {
        _extends(history, nextState)
    
        history.length = globalHistory.length
        // 执行 transitionManager 中的所有 listeners
        transitionManager.notifyListeners(history.location, history.action)
      }
    
      // 3️⃣ popstate 事件监听器的处理函数
      function handlePopState(event) {
        // getDOMLocation 方法用于生成 location 对象,location: { hash, pathname, search, state }
        // handlePop 方法,主要是用于触发 setState 方法
        handlePop(getDOMLocation(event.state))
      }
    
      // 4️⃣ 用于调用 setState 方法
      function handlePop(location) {
        var action = 'POP'
        transitionManager.confirmTransitionTo(location, action, getUserConfirmation, function (ok) {
          if (ok) {
            setState({
              action: action,
              location: location
            })
          }
        })
      }
    
      // 7️⃣
      // 这里的 push 和 replace 方法,是利用了 window.history.pushState() 和 window.history.replaceState()
      // 他们不会触发 popstate 事件,因此无法执行 handlePopState 方法,因此我们需要主动执行 setState() 方法,进而
      // 执行 notifyListeners() 以使得 <Router /> 组件的回调被执行,使得组件进行更新。
      function push(path, state) {
        // ...
        var action = 'PUSH'
        var location = createLocation(path, state, createKey(), history.location)
        // 将会执行 confirmTransitionTo 的 callback 函数
        transitionManager.confirmTransitionTo(location, action, getUserConfirmation, function (ok) {
          if (!ok) return
          var href = createHref(location)
          var key = location.key,
            state = location.state
    
          if (canUseHistory) {
            globalHistory.pushState(
              {
                key: key,
                state: state
              },
              null,
              href
            )
    
            if (forceRefresh) {
              window.location.href = href
            } else {
              var prevIndex = allKeys.indexOf(history.location.key)
              var nextKeys = allKeys.slice(0, prevIndex + 1)
              nextKeys.push(location.key)
              allKeys = nextKeys
              // 调用 setState() 方法,然后里面会执行 notifyListeners 方法,并触发 listeners 的所有监听器
              setState({
                action: action,
                location: location
              })
            }
          } else {
            window.location.href = href
          }
        })
      }
    
      function replace(path, state) {
        // 与 push 方法同理,省略...
      }
    
      // 8️⃣
      // 这里的 go()、goBack()、goForward() 全是利用了 History API 的能力,
      // 他们都会触发 popstate 事件,因此都会执行 handlePopState 方法。
      function go(n) {
        globalHistory.go(n)
      }
    
      function goBack() {
        go(-1)
      }
    
      function goForward() {
        go(1)
      }
    
      var listenerCount = 0
    
      // 2️⃣ 注册/移除 popstate 事件监听器
      function checkDOMListeners(delta) {
        listenerCount += delta
    
        if (listenerCount === 1 && delta === 1) {
          // 添加 popstate 事件监听器,执行 handlePopState 时将会触发 setState
          window.addEventListener(PopStateEvent, handlePopState)
          if (needsHashChangeListener) window.addEventListener(HashChangeEvent, handleHashChange)
        } else if (listenerCount === 0) {
          // 移除事件监听器
          window.removeEventListener(PopStateEvent, handlePopState)
          if (needsHashChangeListener) window.removeEventListener(HashChangeEvent, handleHashChange)
        }
      }
    
      // 1️⃣ 设置监听器,以触发 <Router /> 组件中的回调函数
      function listen(listener) {
        // 往 transitionManager 中的 listeners 数组添加新的监听器 listener,
        // 其中 transitionManager 对象有这些方法:{ setPrompt, confirmTransitionTo, appendListener, notifyListeners }
        var unlisten = transitionManager.appendListener(listener)
    
        // 负责添加、移除 popstate 事件监听器
        checkDOMListeners(1)
    
        // 执行回调函数移除 listener 监听器
        return function () {
          checkDOMListeners(-1)
          unlisten()
        }
      }
    
      var history = {
        length: globalHistory.length,
        action: 'POP',
        location: initialLocation,
        createHref: createHref,
        push: push,
        replace: replace,
        go: go,
        goBack: goBack,
        goForward: goForward,
        block: block,
        listen: listen
      }
      return history
    }
    

    以下是 history.js 中创建的 historylocation 对象的一些属性和方法:

    先回到 <Router /> 组件中的 history.listen(fn),它主要做几件事:

    • fn 保存在负责存储监听器的 listeners 数组中,未来它将会被 notifyListeners() 方法调用。
    • 注册 popstate 事件监听器,触发之后,会执行 notifyListeners() 方法
    • 在 React 组件中调用 props.history.push() 等方法,也将会触发 notifyListeners() 方法。
    • 执行 notifyListeners() 方法,会执行 listeners 中所有的 listener,因此 fn 将会被触发。
    • 执行 fn() 触发 Component 中的 setState() 方法更新 <Router /> 组件,即 Router.Providervalue 发生改变,那么 Router.Consumer 就会跟着更新

    所以,React Router 是利用了 Context 的 Provider/Custom 特性,解决了 pushState/replaceState 不触发 popstate 事件时实现了路由切换的问题。

    The end.

    相关文章

      网友评论

        本文标题:细读 React | React Router 路由切换原理

        本文链接:https://www.haomeiwen.com/subject/sgxcgltx.html