趙聰
個人介紹:趙聰,2017 年 11 月加入去哪兒網技術團隊。目前在大住宿事業部/大前端/國際業務組,參與開發了 EB 系統、大住宿任務調度系統、國際酒店 Touch 端等項目。個人對前端及 Node.js 相關技術有濃厚興趣。
在最近接的一些新項目中都有用到 react-router,每次都是照著老工程抄過來,碰到問題也都是試來試去浪費過多的時間,因此有必要了解一下其工作原理來提高工作效率。
1 閱讀前注意React16.3 對 Context 已經正式支持,並提供了全新的API(https://reactjs.org/docs/context.html),react-router 從 18 年 1 月 29 日開始了 Context 相關代碼的逐步升級(https://github.com/ReactTraining/react-router/pull/5908),安裝時可通過 npm install react-router@next 獲取到最新非正式版。
本文中所引用的代碼來自 react-router@4.3.1 目前最新的正式版本,使用的還是老的實驗版 Context API,與 next 版可能存在些許不同,請注意。
2 什麼是 react-routerreact-router v4 是一個使用純 react 實現的路由解決方案,可以理解為是對 history 庫的 react 封裝,v4 與其之前的版本在設計方式和理念上有較大的差別,為重寫庫。
react-router 包含有兩個具體實現,react-router-dom 和 react-router-native,他們在核心庫的基礎上提供了自己平臺的專屬組件和函數。因日常開發主要圍繞 touch 端展開,所以本次源碼分析內容以 react-router-dom 為主。
2.1 history在開始源碼分析前,需要先了解一些基本概念。
history 這個概念來自瀏覽器的 history(歷史記錄),可以對用戶所訪問的頁面按時間順序進行記錄和保存. 這就不得不提一下 history 庫,history 庫借鑑了瀏覽器 history 的概念,對其進行封裝或實現,使得開發者可以在任何js運行環境中實現歷史會話操作,react-router 使用 history 庫對其路由狀態進行監聽和管理,使得他能在非瀏覽器環境下運行。
history 庫提供了三種路由的實現方式,browser history,hash history 和 memory history,無論使用的是哪一種,其所創建出來的 history 對象都包含以下屬性和方法。
{
length, // 歷史堆棧高度
action, // 當前導航動作有pushpopreplace三種
location: {
pathname, // 當前url
search, // queryString
hash, // url hash
},
push(path[state]), // 將一個新的歷史推入堆棧 (可以理解為正常跳轉)
replace(path[state]), // 替換當前棧區的內容 (可以理解為重定向)
go(number), // 移動堆棧指針
goBack(number), // 返回上一歷史堆棧指針 -1
goForward(number), // 前進到下一歷史堆棧指針 +1
block(string | (location, action) => {}) // 監聽並阻止路由變化
}
3 構成以下為 react-router 和 react-router-dom 的項目結構對比:
react-router react-router-dom
├── README.md ├── README.md
├── modules ├── modules
│ ├── MemoryRouter.js │ ├── BrowserRouter.js
│ ├── Prompt.js │ ├── HashRouter.js
│ ├── Redirect.js │ ├── Link.js
│ ├── Route.js │ ├── MemoryRouter.js
│ ├── Router.js │ ├── NavLink.js
│ ├── StaticRouter.js │ ├── Prompt.js
│ ├── Switch.js │ ├── Redirect.js
│ ├── generatePath.js │ ├── Route.js
│ ├── index.js │ ├── Router.js
│ ├── matchPath.js │ ├── StaticRouter.js
│ └── withRouter.js │ ├── Switch.js
├── package-lock.json │ ├── generatePath.j
├── package.json │ ├── index.js
├── rollup.config.js │ ├── matchPath.js
└── tools │ └── withRouter.js
├── babel-preset.js ├── package-lock.json
└── build.js ├── package.json
├── rollup.config.js
└── tools
├── babel-preset.js
└── build.js
可以發現有很多相同的組件,事實上 react-router-dom 中的同名組件就是從 react-router 核心庫中 re-export 的,所以可以從這些共用的核心組件開始分析。
4 <Router />Router 組件是所有路由組件的父級組件,為子組件提供當前路由狀態並監聽路由改變並觸發重新渲染。
4.1 propsRouter 組件接受一個必要屬性 history,不同的平臺有自己的 history 實現。
4.2 源碼分析Router 組件設置了一個 context,以供所有子組件能獲取到路由狀態,可以看到設置 context.router 的時候繼承了 this.context.router 的所有屬性,具體原因在這裡(https://github.com/ReactTraining/react-router/issues/4650),其實是因為與其他第三方庫的 context 重名了。
getChildContext() {
return {
router: {
...this.context.router,
history: this.props.history, // history 庫生成的 history 對象
route: {
location: this.props.history.location,
match: this.state.match
}
}
};
}
組件擁有一個名為 match 的 state,用來表示當前路由是否匹配(match),match 由 computeMatch() 函數計算得出,這個函數在 Route 組件中也會出現,作用相同,Router 中為默認值,設置其默認值的原因會在後文講到。
state = {
match: this.computeMatch(this.props.history.location.pathname)
};
computeMatch(pathname) {
return {
path: "/",
url: "/",
params: {},
isExact: pathname === "/"
};
}
傳入的 history,在生命周期函數 componentWillMount() 中設置了監聽,當路由發生變化的時候重新設置 match 狀態,因 react 的運行機制,父組件的 state 發生改變時,如果設置了 context,會調用 getChildContext() 重新計算 context,並重新渲染。
將監聽放置在 componentWillMount() 中是為了適配伺服器渲染,因為 componentWillMount() 會在伺服器端執行而 componentDidMount() 不會,所以 Redirect 組件在重定向時所改變的狀態會在伺服器端渲染時得到響應,如源碼中的注釋所說。有關 Redirect 組件的內容將會在後文提到。
componentWillMount() {
const { children, history } = this.props;
// 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>.
this.unlisten = history.listen(() => {
this.setState({
match: this.computeMatch(history.location.pathname)
});
});
}
componentWillUnmount() {
this.unlisten();
}
Router 組件只允許其擁有一個子元素,具體原因(https://github.com/ReactTraining/react-router/issues/5706),以下為中文解釋:
Router 組件經常會被作為頂級組件放到 ReactDOM.render() 裡,像是這樣:
ReactDOM.render(
<Router>
<div />
<div />
</Router>
)
但是其實 Router 並沒有創建任何 DOM 節點,所以等價於這樣:
ReactDOM.render(
<div />
<div />
)
這種寫法是不被 React 允許的。
5 <Route />路由組件,設置並根據當前路由來判斷是否渲染內容。
5.1 propspath :路由匹配參數;
exact strict sencitive :path 的三種匹配模式;
component render children :Route 組件提供的三種子組件渲染方式,具體區別會在後文提到。
5.2 源碼分析當初始化或是路由發生改變的時候,會調用 computedMatch() 方法來計算設置的 path 是否匹配當前路由。
由上文可知,當路由狀態改變時,context 會被重新計算. 此時會造成子組件的重新渲染。與 props 類似,context 在改變時,生命周期函數 componentWillReceiveProps() 也會被觸發,使得其 state.match 被重新計算。
與 Router 組件相同,this.state.match 依靠 this.computeMatch() 方法重新計算當前 url 是否匹配當前 Route 設置的路由。
Route 組件如果被 Switch 組件包裹,Switch 組件會為其計算好 match 信息並通過屬性的形式傳入,所以 this.computeMatch() 在第一步會判斷是否存在 computedMatch 屬性以免重複計算,有關 computedMatch 的計算方式會在後文組件源碼分析部分提到。
在默認情況下,Route 組件會選取當前 history location 與 path 做匹配,但也同時支持使用自定義 location,官方文檔中提供了一個使用過渡動畫的例子(https://reacttraining.com/react-router/web/example/animated-transitions)來描述該應用場景。
computeMatch(
{ computedMatch, location, path, strict, exact, sensitive },
router
) {
if (computedMatch) return computedMatch; // <Switch> already computed the match for us
const { route } = router;
// 如果設置了location屬性優先使用
const pathname = (location || route.location).pathname;
return matchPath(pathname, { path, strict, exact, sensitive }, route.match);
}
state = {
match: this.computeMatch(this.props, this.context.router)
};
componentWillReceiveProps(nextProps, nextContext) {
this.setState({
match: this.computeMatch(nextProps, nextContext.router)
});
}
5.2.1 matchPath()computeMath() 在對數據簡單轉換後,會調用 matchPath.js 文件中的 matchPath() 方法進行路由匹配計算。
const matchPath = (pathname, options = {}, parent) => {
if (typeof options === "string") options = { path: options };
const { path, exact = false, strict = false, sensitive = false } = options;
// 當沒有path參數的時候採用context.router也就是父級元素的路由信息
if (path == null) return parent;
const { re, keys } = compilePath(path, { end: exact, strict, sensitive });
const match = re.exec(pathname);
if (!match) return null;
const [url, ...values] = match;
const isExact = pathname === url;
if (exact && !isExact) return null;
// 匹配成功
return {
path,
url: path === "/" && url === "" ? "/" : url, // 待匹配url也就是當前pathname
isExact, // 是否完全匹配
params: keys.reduce((memo, key, index) => {
memo[key.name] = values[index];
return memo;
}, {})
};
};
compilePath() 方法調用 path-to-regexp 庫,將 path 轉換為正則表達式方便匹配,並根據匹配模式建立緩存。
const patternCache = {};
const cacheLimit = 10000;
let cacheCount = 0;
const compilePath = (pattern, options) => {
// 根據正則的生成條件分類建立緩存
const cacheKey = `${options.end}${options.strict}${options.sensitive}`;
const cache = patternCache[cacheKey] || (patternCache[cacheKey] = {});
// 如果當前生成條件下已存在生成好的匹配用正則則直接使用不再耗時重複生成
if (cache[pattern]) return cache[pattern];
// 正則計算過程
const keys = []; // 用於儲存在path中匹配出來的key
const re = pathToRegexp(pattern, keys, options);
const compiledPattern = { re, keys }; // 返回正則和匹配出來的路由參數
// 如果緩存數量到達上限(10000)則之後的新正則都不再緩存重新生成
if (cacheCount < cacheLimit) {
cache[pattern] = compiledPattern;
cacheCount++;
}
return compiledPattern;
};
生成的緩存為如下結構(舉例):
{
falsefalsefalse: {
'/routeOne': { re, keys },
'/routeThree': { re, keys },
'/routeTwo': { re, keys }
},
truefalsefalse: {
'/': { re, keys },
},
...
}
5.2.2 關於 path-to-regexp 庫pathToRegExp 提供了四種不同的正則生成方式:
sensitive:大小寫敏感模式,如 /api 和 /Api 不匹配。
strict:嚴格模式,在確切匹配的基礎上,區分 path 結尾的分隔符,如:/api 和 /api/ 不匹配。
end:匹配到尾模式:匹配到 path 字符串結尾,默認為 true 如當 start 為默認值時:/api 和 /api/userName 不匹配。
start:從頭匹配模式:從 path 字符串的頭部開始匹配,默認為 true。
react-router 選用了其中前三種匹配方式,並將 end 更名為 exact。
匹配正則的具體使用方法舉例如下:
var keys = []
// 生成正則
var re = pathToRegexp('/foo/:bar', keys)
// re = /^\/foo\/([^\/]+?)\/?$/i
// keys = [{ name: 'bar', prefix: '/', delimiter: '/', optional: false, repeat: false, pattern: '[^\\/]+?' }]
// 使用正則
var match = re.exec('/foo/aaa');
// match = ['/foo/aaa', 'aaa']
正則被調用後,從返回數組的 1 號元素開始是匹配出的參數的值,computeMath() 更進一步,將其拼接為 key: value 的形式,掛載在返回值的 params 屬性下。
5.2.3 子元素渲染方式Route 組件支持三種子元素渲染方式,component render children 三個屬性,如果存在,則優先匹配,並且都傳入 { match, location, history, staticContext } 路由信息。
render() {
const { match } = this.state;
const { children, component, render } = this.props;
const { history, route, staticContext } = this.context.router;
const location = this.props.location || route.location;
const props = { match, location, history, staticContext };
// 如果存在則優先匹配
// 傳入類型為ReactElement
if (component) return match ? React.createElement(component, props) : null;
// 傳入類型
if (render) return match ? render(props) : null;
if (typeof children === "function") return children(props);
if (children && !isEmptyChildren(children))
return React.Children.only(children);
return null;
}
component 和 render 屬性會根據是否匹配 path 來判斷是否渲染。children 則較為特殊,無論當前路由是否匹配,都會渲染傳入的內容,較為自由,讓開發者自己判斷要顯示的內容,react-router-dom 中的 NavLink 組件就是一個很好的應用例子。
6 <Switch />功能十分簡單,只渲染匹配成功的第一個路由組件。
包裹 Route 組件,同樣調用 matchPath() 方法,代理 Route 組件計算是否 match,因源碼十分簡單,不做過多解析。
render() {
const { route } = this.context.router;
const { children } = this.props;
const location = this.props.location || route.location;
let matchchild;
React.Children.forEach(children, element => {
// match一旦被賦值說明已出現匹配成功的Route組件後面的直接跳過
if (match == null && React.isValidElement(element)) {
const {
path: pathProp,
exact,
strict,
sensitive,
from // Redirect的屬性後面會提到
} = element.props;
const path = pathProp || from;
child = element;
// 計算是否匹配
match = matchPath(
location.pathname,
{ path, exact, strict, sensitive },
route.match
);
}
});
return match
// 使用cloneElement為child添加新屬性
? React.cloneElement(child, { location, computedMatch: match })
: null;
}
7 <Redirect />重定向組件,相當於 history 的 push 或是 replace 方法的組件化封裝。
7.1 props當屬性 push 為 true 時,路由切換方式改為跳轉而非重定向。
7.2 源碼分析調用 isStatic() 方法可以得知當前是否處在伺服器渲染模式下,如在在此模式下,便在 componentWillMount 生命周期函數執行時進行路由跳轉,否則在 componentDidMount 時跳轉。
isStatic() {
// 只有伺服器渲染模式下 staticContext 有值
return this.context.router && this.context.router.staticContext;
}
componentWillMount() {
if (this.isStatic()) this.perform();
}
componentDidMount() {
if (!this.isStatic()) this.perform();
}
當出現如下情況時(https://github.com/ReactTraining/react-router/issues/5003),需要組件更新後重新判斷是否跳轉:
某一 Route 組件匹配成功並開始渲染內容,其子組件中包含一個 Redirect 組件並開始執行重定向,但由於重定向地址和當前地址相同,所以只是重新渲染,這時即使再更新子組件的狀態也不會重定向了,除非重新創建 Redirect 組件。
以下代碼便是為這種情況服務的:
componentDidUpdate(prevProps) {
const prevTo = createLocation(prevProps.to);
const nextTo = createLocation(this.props.to);
if (locationsAreEqual(prevTo, nextTo)) {
warning(
false,
`You tried to redirect to the same route you're currently on: ` +
`"${nextTo.pathname}${nextTo.search}"`
);
return;
}
this.perform();
}
perform() 用來進行重定向操作,其調用 computeTo() 來計算重定向的 url,跳轉的時候分為兩種情況,如果有 computedMatch 的話說明有需要傳遞的參數,則在計算後返回 url,否則直接返回。
例如:當前 location.pathname="/user/123",所以某 path="/user/:id" 的 Route 組件匹配成功,其包含一個 Redirect 子組件 to="/id/:id",則在重定向後地址為 "/id/123"。
具體應用場景:
<Switch>
<Redirect from='/users/:id' to='/users/profile/:id'/>
<Route path='/users/profile/:id' component={Profile}/>
</Switch>
computeTo({ computedMatch, to }) {
if (computedMatch) {
if (typeof to === "string") {
return generatePath(to, computedMatch.params);
} else {
return {
...to,
pathname: generatePath(to.pathname, computedMatch.params)
};
}
}
return to;
}
7.2.1 generatePath()用於生成跳轉用 url 的方法,與 matchPath 方法類似,是其逆操作,同樣為了提高響應速度使用了緩存。
核心邏輯為調用 path-to-regexp 的 compile() 方法。
const patternCache = {};
const cacheLimit = 10000;
let cacheCount = 0;
const compileGenerator = pattern => {
// 使用待匹配url作為cache key
const cacheKey = pattern;
const cache = patternCache[cacheKey] || (patternCache[cacheKey] = {});
if (cache[pattern]) return cache[pattern];
// 核心邏輯
const compiledGenerator = pathToRegexp.compile(pattern);
if (cacheCount < cacheLimit) {
cache[pattern] = compiledGenerator;
cacheCount++;
}
return compiledGenerator;
};
/**
* Public API for generating a URL pathname from a pattern and parameters.
*/
const generatePath = (pattern = "/", params = {}) => {
if (pattern === "/") {
return pattern;
}
const generator = compileGenerator(pattern);
return generator(params, { pretty: true });
};
8 <Prompt />用來做路由攔截的組件,唯一的作用就是在路由發生改變的時候攔截它並彈窗提醒。
8.1 props屬性 when 默認為 true,如為 false 則不攔截任何路由變化。
屬性 message 為攔截時的提示信息,內容格式與 history.block() 方法參數格式相同,可為 string 或是 (location, action): (string | boolean) => {}。
8.2 源碼分析非常簡單,一看就明白了。
// 重設message如果之前設置過就先清除
enable(message) {
if (this.unblock) this.unblock();
this.unblock = this.context.router.history.block(message);
}
disable() {
if (this.unblock) {
this.unblock();
this.unblock = null;
}
}
componentWillMount() {
if (this.props.when) this.enable(this.props.message);
}
componentWillReceiveProps(nextProps) {
if (nextProps.when) { // 當when發生變化變為true或是messge改變時觸發
if (!this.props.when || this.props.message !== nextProps.message)
this.enable(nextProps.message);
} else {
this.disable();
}
}
componentWillUnmount() {
this.disable();
}
9 <Link />對 a 標籤的封裝,實現 history 控制的路由跳轉。
9.1 props繼承了 a 標籤的所有參數,使用屬性 to 來指明跳轉位置,replace 指明是否為重定向。
9.2 源碼分析因為 onClick 事件優先級比 href 跳轉的高,所以優先處理。
handleClick = event => {
if (this.props.onClick) this.props.onClick(event);
if (
!event.defaultPrevented && // 如已取消默認操作則跳過
event.button === 0 && // 忽略非左鍵單擊事件
!this.props.target && // 如果設置了target參數則跳過
!isModifiedEvent(event) // 忽略組合鍵
) {
event.preventDefault();
const { history } = this.context.router;
const { replaceto } = this.props;
if (replace) {
history.replace(to);
} else {
history.push(to);
}
}
};
如果 onClick 事件沒有執行,或是被 prevent 了,則採用 href 內的值來跳轉。
調用 history 的 createLocation() 生成跳轉後的 location,這個方法接收 4 個參數,(path,state,key,currentLocation)。因為走瀏覽器跳轉已經脫離了 history 的控制範圍,用於表示路由附加參數和當前路由表示的 state 與 key 參數無效,用 null 來佔位。
render() {
const { replace, to, innerRef, ...props } = this.props;
const { history } = this.context.router;
//
const location =
typeof to === "string"
? createLocation(to, null, null, history.location)
: to;
// 根據變化後的location生成url
const href = history.createHref(location);
return (
<a {...props} onClick={this.handleClick} href={href} ref={innerRef} />
);
}
10 withRouter()高階函數,包裹組件,用來為組件添加當前路由狀態。
10.1 源碼分析非常好理解,實際上就是使用 Route 組件對目標組件做了包裹處理。
為了避免組件被代理的時候出現靜態屬性丟失的情況(https://github.com/ReactTraining/react-router/pull/4838),使用了一個叫做 hoist-non-react-statics (字面直譯: 提升非react靜態屬性) 的庫(https://github.com/mridgway/hoist-non-react-statics)。
const withRouter = Component => {
const C = props => {
const { wrappedComponentRef, ...remainingProps } = props;
return (
<Route
children={routeComponentProps => (
<Component
{...remainingProps}
{...routeComponentProps}
ref={wrappedComponentRef} // 提供被代理組件的ref
/>
)}
/>
);
};
C.displayName = `withRouter(${Component.displayName || Component.name})`;
C.WrappedComponent = Component;
C.propTypes = {
wrappedComponentRef: PropTypes.func
};
// 將傳入組件的靜態屬性提升裡C來
return hoistStatics(C, Component);
};
參考https://reacttraining.com/react-router/core/api
https://github.com/ReactTraining/react-router