前言
此前一直在疑惑,明明 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
原因剖析
react-router-dom
引用了 history.js 库 ,它主要提供了三种方法:createBrowserHistory
、createHashHistory
、createMemoryHistory
。
它们用于构建对应模式的 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.Provider
的 value
值即可,至于后续如何加载组件就交给 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 中创建的 history
、location
对象的一些属性和方法:
先回到 <Router />
组件中的 history.listen(fn)
,它主要做几件事:
- 将
fn
保存在负责存储监听器的listeners
数组中,未来它将会被notifyListeners()
方法调用。- 注册
popstate
事件监听器,触发之后,会执行notifyListeners()
方法- 在 React 组件中调用
props.history.push()
等方法,也将会触发notifyListeners()
方法。- 执行
notifyListeners()
方法,会执行listeners
中所有的listener
,因此fn
将会被触发。- 执行
fn()
触发 Component 中的setState()
方法更新<Router />
组件,即Router.Provider
的value
发生改变,那么Router.Consumer
就会跟着更新
所以,React Router 是利用了 Context 的 Provider/Custom 特性,解决了 pushState/replaceState 不触发 popstate
事件时实现了路由切换的问题。
The end.