Tags: JavaScript, React
本文会讨论react生态下的常用路由库,React-router的版本迭代与源码架构,并尝试探讨路由思维的变化与未来。
路由是一种向用户显示不同页面的能力。 这意味着用户可以通过输入URL或单击页面元素在WEB应用的不同部分之间切换。
为了探究react-router设计思维,从v3开始有这几个版本:
让我们逐个参与讨论。
静态路由的设计如下图所示:
React.render((
<Router>
<Route path="/" component={Wrap}>
<Route path="a" component={App} />
<Route path="b" component={Button} />
</Route>
</Router>
), document.body)
特点:
Route
组件的嵌套而来v3静态路由的设计对前端工程师来说,相对更易接受,因为前端工程师很多都接触过类似的路由配置设计,比如express、rails等框架。
虽然细节各有不同,但是思路大致相同——将path静态映射为渲染模块。
虽然v3以一种质朴无华的方式完成了基本的路由工作,但react-router的几个核心成员感觉现有的实现严重受ReactAPI的制约,并且实现方式也不够优雅。
于是,经过了激烈的思考与讨论,他们大胆地在v4中做出了比较激进的更迭。
React-router4不再提倡静态路由的集中化架构,取之的是路由存在于布局和 UI 之间:
const App = () => (
<BrowserRouter>
<div>
<Route path="/a" component={A}/>
</div>
</BrowserRouter>
);
const A = ({ match }) => (
<div>
<span>A</span>
<Route
path={match.url + '/b'}
component={B}
/>
</div>
);
const B = () => <div>B</div>;
我们来看以上代码的逻辑
/a
/a
时,渲染A
组件,浏览器上出现字母A,然后子路由/b
被定义/a/b
时,渲染B
组件,浏览器上出现字母B我们可以看到,在v4中:
<Route>
组件控制,<Route>
与组件为替换的关系这被称之为「动态路由」。
传统静态路是在程序渲染前就定义好。
而动态意味着路由功能在应用渲染时才动态生成,这需要把路由看成普通的 React 组件,传递 props
来正常使用,借助它来控制组件的展现。这样,没有了静态配置的路由规则,取而代之的是程序在运行渲染过程中动态控制的展现。
动态路由将带来很大的好处。比如代码分割,也就是react常说的code splitting
,由于不需要在渲染前决定结果,动态路由可以满足代码块的按需加载,这对于大型在线应用非常有帮助。
但是,毕竟路由对一个应用的架构来说非常重要,这么大的改变显得过于激进,这会改变以前开发者比较习惯的一些模式,由于这次的更新过于激进,遭到了开发者们的一些负面反馈:
这就要讨论到动态路由的缺点了:
由于React-router团队保证v3会持续维护,所以当时很多开发者没有选择升级。
原本只是计划发布 React Router 4.4 版本,但由于不小心误用了^
字符,将依赖错误地写成 "react-router": "^4.3.1"
,导致报错。于是最后团队决定撤销 4.4 版本,直接改为发布 React Router v5。
react-router5延续了动态路由的模式,但是提供了更加直观的写法:
export default function App() {
return (
<Router>
<Switch>
<Route path="/about">
<About />
</Route>
<Route path="/topics">
<Topics />
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
</Router>
);
}
以上的写法,/about
显示<About>
组件,/topics
显示<Topic>
组件,根路由显示<Home>
组件。
同时,v5还允许你将路由配置作为一个config的json数据,写在组件外引入。
<Route>
将作为父组件用于匹配路由,同时还有一系列辅助组件,比如<Switch>
可以限制子元素进行单一的路由匹配。当然,这也会带来一定的
Reach-Router 是前 ReactRouter 成员 Ryan Florence 开发的一套基于 react 的路由控件。
那么已经有比较成熟的 ReactRouter 了, 为什么要”再”做一套 Router 呢?
优点:小而简
react-router
小40kb左右,同时有更少的配置history
, react-router-dom
, react-router-redux
),reach-router
只需要一个store
配置router
相关信息history
2021年11月,react-router 6.0.0 正式版发布:
v6的设计可以说很大程度参照了@reach/router,API和@reach/router v1.3非常相似。因此,官方也宣称v6可以被看做@reach/router的v2。
总体来说,v6更像是一个以前版本的完善和整合,相对路径与嵌套分散的选择方式,让大家能够按个人喜好去构建路由。
探讨完设计哲学与版本更迭,我们正式进入从0到1的源码学习。
本文对源码的探讨,就是以v6为基础(中间存在各种简化)。
我们先从V6的简易的实例开始:
import { render } from "react-dom";
import {
BrowserRouter,
Routes,
Route,
Link,
} from "react-router-dom";
import App from "./App";
import Expenses from "./routes/expenses";
import Invoices from "./routes/invoices";
const rootElement = document.getElementById("root");
render(
<BrowserRouter>
<Routes>
<Route path="/" element={<App />}>
<Route path="expenses" element={<Expenses />} />
<Route path="invoices" element={<Invoices />} />
</Route>
</Routes>
<Link to="/invoices">Check Invoices</Link>
</BrowserRouter>,
rootElement
);
React-router的结构主要分为四个模块:
history
<Router>
<Route>
<Link />
、<Navigate />
让我们分别对各部分的源码进行拆分与讨论。
每个<Router>
都会创建一个history
对象,它记录了当前以及历史的路由位置。
react-router使用了history
库作为路由历史状态的管理模块:
history
这个库可以让你在 JavaScript 运行的任何地方都能轻松地管理回话历史,history
对象抽象化了各个环境中的差异,并提供了最简单易用的的 API 来给你管理历史堆栈、导航,并保持会话之间的持久化状态。——React Training 文档
这部分值得关注的源码:
工厂函数createBrowserHistory
等
它们代码差别很小,不同的router
只有parsePath
的入参不同。还有其它的差别,比如hashHistory
增加了hashchange
事件的监听等
由于篇幅所限,这里我们只讨论createBrowserHistory
history.push
,用于基本的切换路由
go
/replace
/forward
/back
也类似,不过push
是history
栈变化的基础
history.listen
添加路由监听器,每当路由切换可以收到最新的action
和location
,从而做出不同的判断,BrowserRouter
中就是通过history.listen(setState)
来监听路由的变化,从而管理所有的路由
history.block
添加阻塞器,会阻塞push
等行为和浏览器的前进后退,阻止离开当前页面。且只要判断有 blockers
,那么同时会阻止浏览器刷新、关闭等默认行为。且只要有blocker
,会阻止上面listener
的监听
我们先看工厂函数:
工厂函数的用途是创建一个history
对象,后面的listen
和unlisten
都是挂载在这个API的返回对象上面的。
history.listen
:这个是用在Router组件里面的,用来监听路由变化history.unlisten
:这个也是在Router组件里面用的,是listen
方法的返回值,用来在清理的时候取消监听的export function createBrowserHistory(
options: BrowserHistoryOptions = {}
): BrowserHistory {
// -----------------------------第一部分--------------------------------
const [index, location] = getIndexAndLocation();
function getIndexAndLocation(): [number, Location] {
const { pathname, search, hash } = window.location;
const state = window.history.state || {};
return [
state.idx,
readOnly<Location>({
pathname,
search,
hash,
state: state.usr || null,
key: state.key || 'default'
})
];
}
if (index == null) {
index = 0;
window.history.replaceState({ ...window.history.state, idx: index }, '');
}
function handlePop() {
const [nextIndex, nextLocation] = getIndexAndLocation();
const delta = index - nextIndex;
go(delta)
}
window.addEventListener('popstate', handlePop);
// ----------------------------第二部分-------------------------------
const listeners = createEvents<Listener>();
const blockers = createEvents<Blocker>();
function createEvents<F extends Function>(): Events<F> {
let handlers: F[] = [];
return {
get length() {
return handlers.length;
},
push(fn: F) {
handlers.push(fn);
return function() {
handlers = handlers.filter(handler => handler !== fn);
};
},
call(arg) {
handlers.forEach(fn => fn && fn(arg));
}
};
}
listeners.call({ action, location });
blockers.call({ action, location, retry });
// ----------------------------第三部分------------------------------—
const history: BrowserHistory = {
get action() {
return action;
},
get location() {
return location;
},
createHref,
push, // 重点
replace,
go(delta: number) {
window.history.go(delta);
},
back() {
go(-1);
},
forward() {
go(1);
},
listen(listener) { // 重点
return listeners.push(listener);
},
block(blocker) { // 重点
const unblock = blockers.push(blocker);
if (blockers.length === 1) {
window.addEventListener('beforeunload', promptBeforeUnload);
}
return function() {
unblock();
if (!blockers.length) {
window.removeEventListener('beforeunload', promptBeforeUnload);
}
};
}
};
return history
}
我们可以将源码分为三部分:
第一部分「初始化和绑定」
通过getIndexAndLocation
获取初始当前路径的index
和location
,初始index为空,对应history.state.idx为0。
同时,handlePop
在window
监听url
的变化,在handleState
里面进行触发。
第二部分「发布订阅」
我们看到这部分是个标准的发布订阅模式:
createEvents
是创建listeners
与blockers
的工厂函数,其返回了一个对象,通过push
添加每个listener
,通过call
通知每个 listener
,代码中叫做handler
listeners
通过call
传入action
和location
,这样每个listener
在路由变化时就能接收到,从而做出对应的判断
blockers
,比listeners
多了传入了一个retry
,从而判断是否要阻塞路由,不阻塞的话需要调用函数retry
第三部分「构建history」
我们可以看看得到的history
对象
这里我们重点关注:push
、listen
、block
action
代表上一个修改当前location
的action
,POP
/PUSH
/REPLACE
等action
与location
这两个属性都通过修饰符get
获取,那么我们每次要获取就可以通过history.action
或history.location
。避免了只能拿到第一次创建的值,可以每次调用函数才能拿到。createHref
作用是通过location
返回新的href
, to
为字符串则返回to
,否则返回pathname
+search
+hash
back
和forward
都通过go
实现replace
和push
非常相似,区别在于replace
将历史堆栈中当前location
替换为新的,被替换的将不再存在,所以我们着重关注push
function push(to: To, state?: State) {
const nextAction = Action.Push;
const nextLocation = getNextLocation(to, state);
function getNextLocation(to: To, state: State = null): Location {
return readOnly<Location>({
...location,
...(typeof to === 'string' ? parsePath(to) : to),
state,
key: createKey()
});
}
function retry() {
push(to, state);
}
if (allowTx(nextAction, nextLocation, retry)) { // blockers的限制
const [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);
function getHistoryStateAndUrl(
nextLocation: Location,
index: number
): [HistoryState, string] {
return [
{
usr: nextLocation.state,
key: nextLocation.key,
idx: index
},
createHref(nextLocation)
];
}
window.history.pushState(historyState, '', url);
try {
globalHistory.pushState(historyState, '', url);
} catch (error) {
window.location.assign(url);
} // 用try-catch的原因是因为ios限制了100次pushState的调用,catch后只能选择刷新页面
applyTx(nextAction); // 调用listeners
}
}
allowTx
下面blockers
会讲到,用于阻塞路由
applyTx
下面listeners
会讲到,用于调用监听器
getNextLocation
路由还没切换的时候,根据history.push
的to
和state
(新的path和状态)获取到新的 location
to
是字符串的话,会通过parsePath
解析对应的pathname
、search
、hash
(三者都是可选的,不一定会出现在返回的对象中)
getHistoryStateAndUrl
根据新的location
获取新的state
和url
因为是push
,这里的index
自然是加一
再调用createHref
,根据location
生成url
最后调用history.pushState
成功跳转页面,这个时候路由也就切换了
const history: HashHistory = {
// ...
listen(listener) {
return listeners.push(listener);
},
// ...
}
function applyTx(nextAction: Action) {
const [index, location] = getIndexAndLocation();
listeners.call({ action: nextAction, location });
}
function push(to: To, state?: State) { // replace
// ...
if (allowTx(nextAction, nextLocation, retry)) {
// ...
applyTx(nextAction);
}
}
function handlePop() {
if (blockedPopTx) {
// ...
} else {
// ...
if (blockers.length) {
// ...
} else {
applyTx(nextAction);
}
}
}
function allowTx(action: Action, location: Location, retry: () => void): boolean {
return (
!blockers.length || (blockers.call({ action, location, retry }), false)
);
}
history.listen
是一个标准的发布订阅模式,可以往history
中添加listener
,返回一个取消监听的可调用方法
listener
在push
、replace
和handlePop
三个函数中成功切换路由后调用applyTx(nextAction)
来通知每个listener
allowTx
的作用是判断是否允许路由切换,有blockers
就不允许,也即是说,listener
能否监听到路由变化,取决于当前页面是否被blockers
阻塞了const history: BrowserHistory = {
// ...
block(blocker) {
const unblock = blockers.push(blocker);
if (blockers.length === 1) {
window.addEventListener('beforeunload', promptBeforeUnload);
}
return function() {
unblock();
if (!blockers.length) {
window.removeEventListener('beforeunload', promptBeforeUnload);
}
};
}
};
blocker
s与listeners
类似,区别在于:
添加第一个blocker
时会添加beforeunload
事件
只要block
了,那么我们刷新、关闭页面,通过修改地址栏输入url
后enter
都会触发
移除的时候发现blockers
空了,那么就移除beforeunload
事件
应用顶层使用,为后代的Route
提供Context
的数据传递。
Router
有很多种,区别在于路由在url上面存在的方式:
BrowserRouter
「完整路由」,路由路径在url上完整对应,需要服务端支持HashRouter
「哈希路由」,路径为url里#
后面的部分StaticRouter
「静态路由」,无状态:不改变路径地址、不记录历史栈还有MemoryRouter
(在内存中保存)、NativeRouter
(在ReactNative
中使用)等,他们使用的history
状态机也不一样。
篇幅所限,这里我们主要讨论最通用的BrowserRouter
:
browserHistory
BrowserRouter
的应用,服务端渲染完成后,之后的路由由BrowserRouter
独立完成解析作为应用的最外层的容器组件,BrowserRouter
源码如下:
export function BrowserRouter({
basename,
children,
window
}) {
const history = useRef<BrowserHistory>();
if (historyRef.current == null) {
historyRef.current = createBrowserHistory({ window });
}
const history = historyRef.current;
const [state, setState] = useState({
action: history.action,
location: history.location
});
useLayoutEffect(() => {
history.listen(setState)
}, [history]);
return (
<Router
basename={basename}
children={children}
action={state.action}
location={state.location}
navigator={history}
/>
);
}
可以看到是,是一个构建了history
的<Router>
组件的封装
Router
初始化会生成history
实例,history
一般变化的就是action
和location
,并把setState
放入对应的listeners
,那么路由切换就会setState
了。Router
其接收的属性的变化的,就是路由相关的变化(action
、location
),这部分路由被存到Context
。子组件作为消费者,就可以对页面进行修改,跳转,获取这些数值。我们来看Router
:
export function Router({
action = Action.Pop,
basename: basenameProp = "/",
children = null,
location: locationProp,
navigator,
static: staticProp = false
}: RouterProps): React.ReactElement | null {
// ...
return (
<NavigationContext.Provider value={navigationContext}>
<LocationContext.Provider
children={children}
value={{ action, location }}
/>
</NavigationContext.Provider>
);
}
const { basename, navigator } = React.useContext(NavigationContext);
const { location } = React.useContext(LocationContext);
export function useLocation(): Location {
return React.useContext(LocationContext).location;
}
Router
最后返回了两个Context.Provider
,中间就是针对于location
的处理
我们直接看Routes
和Route
的源码:
export function Routes({
children,
location
}: RoutesProps): React.ReactElement | null {
return useRoutes(createRoutesFromChildren(children), location);
}
export function Route(
_props: PathRouteProps | LayoutRouteProps | IndexRouteProps
): React.ReactElement | null {
invariant(
false,
`A <Route> is only ever to be used as the child of <Routes> element, ` +
`never rendered directly. Please wrap your <Route> in a <Routes>.`
);
}
可以发现
Routes
实际上就是useRoutes
的包装Route
实际上没有render
,只是作为Routes
的子组件存在我们只需要着重研究createRoutesFromChildren
与useRoutes
:
export function createRoutesFromChildren(
children: React.ReactNode
): RouteObject[] {
const routes: RouteObject[] = [];
React.Children.forEach(children, element => {
if (!React.isValidElement(element)) return;
if (element.type === React.Fragment) {
routes.push.apply(
routes,
createRoutesFromChildren(element.props.children)
);
return;
}
const route: RouteObject = {
caseSensitive: element.props.caseSensitive,
element: element.props.element,
index: element.props.index,
path: element.props.path
};
if (element.props.children) {
route.children = createRoutesFromChildren(element.props.children);
}
routes.push(route);
});
return routes;
}
我们看到,createRoutesFromChildren
作用如下:
Route
上的属性,最终返回一个嵌套数组React.Fragment
routes
路由配置export function useRoutes(
routes: RouteObject[],
): React.ReactElement | null {
// --------------------------------第一段------------------------------------
const { matches: parentMatches } = React.useContext(RouteContext);
const routeMatch = parentMatches[parentMatches.length - 1];
const parentParams = routeMatch ? routeMatch.params : {};
const parentPathnameBase = routeMatch ? routeMatch.pathnameBase : "/";
// --------------------------------第二段------------------------------------
let location = useLocation();
const pathname = location.pathname || "/";
const remainingPathname =
parentPathnameBase === "/"
? pathname
: pathname.slice(parentPathnameBase.length) || "/";
const matches = matchRoutes(routes, { pathname: remainingPathname });
// --------------------------------第三段-------------------------------------
return _renderMatches(
matches &&
matches.map(match =>
Object.assign({}, match, {
params: Object.assign({}, parentParams, match.params),
pathname: joinPaths([parentPathnameBase, match.pathname]),
pathnameBase: joinPaths([parentPathnameBase, match.pathnameBase])
})
),
parentMatches
);
}
useRoutes
参数的routes
嵌套数组就是createRoutesFromChildren
返回的路由配置,通过路由配置匹配到对应的route元素进行渲染:
第一段:获取parentMatches
最后一项「routeMatch」
Routes
中,上一次useRoutes
匹配后得到的matches
会作为下一层的parentMatches
,如果match了,获取匹配的params
、pathname
等各种信息
第二段:通过当前Routes的相对路径remainingPathname
和routes
匹配到对应的matches
这里最复杂的部分,也是react-router最精华的部分,就是匹配路由,而这部分的逻辑在matchRoutes
上:
export function matchRoutes(
routes: RouteObject[],
locationArg: Partial<Location> | string,
basename = "/"
): RouteMatch[] | null {
const location =
typeof locationArg === "string" ? parsePath(locationArg) : locationArg;
const pathname = stripBasename(location.pathname || "/", basename);
if (pathname == null) {
return null;
}
const branches = flattenRoutes(routes);
rankRouteBranches(branches);
let matches = null;
for (let i = 0; matches == null && i < branches.length; ++i) {
matches = matchRouteBranch(branches[i], pathname);
}
return matches;
}
matchRoutes
的作用是通过当前相对路径和路由配置匹配到对应的matches
routes
有可能是多维路由配置,那么扁平化的过程中,会收集每个路由的属性作为 routeMeta
,收集过程是一个深度优先遍历,routesMeta
的长度等于路由嵌套自身所处层数
对扁平后之后的路由进行排序,根据权重排序每个分支,如果权重相等才去比较 routesMeta
的每个自权重
直到matches
有值(意味着匹配到,那么自然不用再找了)或遍历完才跳出循环
而matchRouteBranch
会通过每个部分的routesMeta
,来看看是否能从头到尾匹配到相应的路由,只要有一个不匹配,就返回 null
routesMeta
最后一项是该次路由自己的路由信息,前面项都是parentMetas
第三段:通过_renderMatches
渲染上面得到的匹配元素
终于拿到「路由匹配元素」matches了,那么就要根据匹配项来渲染。
function _renderMatches(
matches: RouteMatch[] | null,
parentMatches: RouteMatch[] = []
): React.ReactElement | null {
if (matches == null) return null;
return matches.reduceRight((_, match, index) => {
return (
<RouteContext.Provider
children={match.route.element}
value={{
outlet,
matches: parentMatches.concat(matches.slice(0, index + 1))
}}
/>
);
}, null as React.ReactElement | null);
}
_renderMatches
会根据匹配项和父级匹配元素parentMatches
RouteContext.Provider
Link
组件功能就是实现一次跳转a
标签,会使页面刷新,所以需要借助history
history.pushState
只会改变history
状态,不会刷新页面history.pushState
的时候,不会触发popstate
事件,所以history
里面的回调不会自动调用,当用户使用history.push
的时候,我们需要手动调用回调函数我们来看看源码:
export default function Link({
to,
...rest
}) {
return (
<RouterContext.Consumer>
{context => {
const { history } = context;
const props = {
...rest,
href: to,
onClick: event => {
event.preventDefault();
history.push(to);
}
};
return <a {...props} />;
}}
</RouterContext.Consumer>
);
}
我们看到,<Link>
只是渲染了一个没有默认行为的a
标签,其跳转行为由context
传入的history.push
实现。
remix是由react-router原班人马打造,并获得三百万美元融资的ts全栈明星开发框架,笔者认为remix作为一个全新的全栈的解决方案值得关注,其路由功能非常灵活高效。
“我们经常将 Remix 描述为"React Router 的编译器",因为有关 Remix 的所有内容都利用了嵌套路由。”
官网对remix的介绍如下:
remix可以干掉骨架屏等加载状态,所有资源都可预加载,而且管理后台,对于数据的加载、嵌套数据或者组件的路由、并发加载优化做得很好,并且异常的处理已经可以精确到局部级别:
remix告别瀑布式的方式来获取数据,数据获取在服务端并行获取,生成完整 HTML 文档,类似 React 并发特性:
相比之下,Next.js 更像是一个静态网站生成器。Gatsby相比下则门槛过高,需要一定的 GraphQL 基础。
同时,客户端与服务端能有一致的开发体验,客户端代码与服务端代码写在一个文件里,无缝进行数据交互,同时基于 TypeScript,类型定义可以跨客户端与服务端共用,路由也可以同步,实现一个组件化、路由为首的全栈模型。
我们看到,随着Web技术思维的变革,最早的渐进式应用正在走向越来越强的一体化,大前端、泛前端的思维性质越来越浓厚。
而服务端技术则通过云技术,走向了SaaS,容器化这样更灵活、成本更低的道路上,旨在为应用端提供更便捷的开发。
在未来的Web3浪潮下,由于公链的存在,「胖协议+瘦应用」会是大势所趋,越来越敏捷和低成本的开发会更为重要。
路由作为前后端,交互最紧密的桥梁,会是一个关键的变革区域,或许有天我们可以看到,Web技术通过路由,实现了真正的前后端的统一,走向了人人都可开发的大全栈未来。
- END -
奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。