此前一直在疑惑,明明 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 库 ,它主要提供了三种方法: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.
网友评论