概况
前端路由的重点就是不刷新页面,现有的解决方案有 history.pushState 和 history.replaceState 两种。 react-router也是基于history这个第三方库进行封装的。采用的是发布订阅的模式,让浏览器地址发生变化时,添加并发布订阅。Router组件包裹这Route组件,Router组件主要是监听浏览器变化,并将变化后的数据通过context传递给Route组件,Route组件则负责匹配Router传过来的路径,并渲染相应的组件。
内部具体实现
下面解析的react-router版本是v5版本,因为我们使用的Router一般就BrowserRouter和HashRouter,但它们却是由react-router-dom提供的。所以我们先整体看一下react-router-dom中index.js代码提供了哪些API?
//来源:modules/index.js
export {
MemoryRouter,
Prompt,
Redirect,
Route,
Router,
StaticRouter,
Switch,
generatePath,
matchPath,
withRouter,
useHistory,
useLocation,
useParams,
useRouteMatch
} from "react-router";
export { default as BrowserRouter } from "./BrowserRouter.js";
export { default as HashRouter } from "./HashRouter.js";
export { default as Link } from "./Link.js";
export { default as NavLink } from "./NavLink.js";
从上面代码中可以看出,react-router-dom主要提供了BrowserRouter、HashRouter、 Link 和 NavLink 四个组件,其余都是从react-router中导出的。 再看一下react-router中 index.js 代码,可以看出暴露了10个方法(hooks方法未算在内)。
//来源:modules/index.js
export { default as MemoryRouter } from "./MemoryRouter";
export { default as Prompt } from "./Prompt";
export { default as Redirect } from "./Redirect";
export { default as Route } from "./Route";
export { default as Router } from "./Router";
export { default as StaticRouter } from "./StaticRouter";
export { default as Switch } from "./Switch";
export { default as generatePath } from "./generatePath";
export { default as matchPath } from "./matchPath";
export { default as withRouter } from "./withRouter";
接下来具体分析BrowersRouter怎么实现的。
//来源:modules/BrowserRouter.js
import { Router } from "react-router";
import { createBrowserHistory as createHistory } from "history";
class BrowserRouter extends React.Component {
history = createHistory(this.props);
render() {
return <Router history={this.history} children={this.props.children} />;
}
}
代码很少,可以看到核心是history这个库提供的函数。BrowserRouter组件在render前先执行了 createHistory 这个函数,返回一个history的对象实例,然后通过props传给Router这个路由器,另外将所有子组件传给Router。
接下来就很清晰了,我们看看 Router 和 history,Router是怎么使用history对象的?history对象和window.history又有何差别?Come on!
Router
上面我们已经看到BrowsRouter传递给它了一个history对象。接下来具体分析下Router的实现,先看下Router.js文件。
//来源:modules/Router.js
import HistoryContext from "./HistoryContext.js";
import RouterContext from "./RouterContext.js";
首先引入了两个context,这里其实就是创建的普通context,只不过拥有特定的名称而已,不具体分析了。 再看看它的constructor函数:
constructor(props) {
super(props);
this.state = {
location: props.history.location
};
this.unlisten = props.history.listen(location => {
this.setState({ location });
});
}
可以看出 Router组件维护了一个自状态(location 对象),初始值是上面提到的在 BrowserRouter 中创建的 history 对象。 之后,执行了 history 对象提供的 listen 函数,这个函数以一个回调函数作为入参,回调函数就是用来更新当前Router自状态中(location 对象)的,关于什么时候会执行这个回调,以及listen函数具体干了啥,后面会详细剖析。
componentWillUnmount() {
if (this.unlisten) {
this.unlisten();
}
}
等这个Router组件将要卸载时,就取消对history的监听。
render() {
return (
<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>
);
}
总结:整个Router就是一个传入了history、location和其它一些数据的context的提供者,然后它的子组件Route作为消费者,就可以共享使用这些数据,来完成后面的路由跳转、UI更新等动作。
history
history基本用法是这样的:
import { createBrowserHistory } from 'history';
const history = createBrowserHistory();
// 获取当前location
const location = history.location;
// 监听当前location的更改
const unlisten = history.listen((location, action) => {
// location是一个类似window.location的对象
console.log(action, location.pathname, location.state);
});
// 使用push、replace和go来导航
history.push('/home', { some: 'state' });
// 若要停止监听,请调用listen()返回的函数
unlisten();
其实history是对 window.history 的二次封装。Router内部状态 location 的初始数据其实是 window.location 与 window.history.state 的重组。 history里面最重要的两个方法是 pushState 和 replaceState 这两个API,它们提供的能力就是可以增加新的 window.history 中的历史记录和浏览器地址栏上的url,但是又不会发起真正的网络请求,这是实现单页面应用的关键点。 下面我们来具体分析下createHashHistory源码,版本是v4版本。
先整体看一下createHashHistory抛出的方法,也就是我们平时用的props.history常用的方法:
//来源:modules/createHashHistory.js
const history = {
length: globalHistory.length,
action: 'POP',
location: initialLocation,
createHref,
push,
replace,
go,
goBack,
goForward,
block,
listen
};
接下来我们具体分析下push方法:
function push(path, state) {
const action = 'PUSH';
const location = createLocation(path, state, createKey(), history.location);
transitionManager.confirmTransitionTo( location, action, getUserConfirmation, ok => {
if (!ok) return;
const href = createHref(location);
const { key, state } = location;
if (canUseHistory) {
// 在push方法内使用pushState
globalHistory.pushState({ key, state }, null, href);
if (forceRefresh) {
window.location.href = href;
} else {
const prevIndex = allKeys.indexOf(history.location.key);
const nextKeys = allKeys.slice(0, prevIndex + 1);
nextKeys.push(location.key);
allKeys = nextKeys;
setState({ action, location });
}
} else {
window.location.href = href;
}
}
);
}
push中的入参path,是接下来准备要跳转的路由地址。createLocation 方法先将这个path,与当前的 location 做一个合并,返回一个更新的location。 然后就是重头戏,transitionManager 这个对象,我们先关注下成功回调里面的内容。 通过更新后的location,创建出将要跳转的href,然后调用pushState方法,来更新window.history中的历史记录。 如果你在BrowserRouter中传了 forceRefresh 这个属性,那么之后就会直接修改window.lcoation.href,来实现页面跳转,但这样就相当于要重新刷新,需要重新发起请求了。 如果没有传的话,就是调用 setState 这个函数,注意这个 setState 并不是react提供的那个,而是history库自己实现的。
function setState(nextState) {
Object.assign(history, nextState);
history.length = globalHistory.length;
transitionManager.notifyListeners(history.location, history.action);
}
这里用到了 transitionManager 对象的一个方法。 另外当我们执行了 pushState 后,接下来所获取到的 window.history 都是已经更新的了。 最后就剩下 transitionManager 这个点了。 transitionManager 是通过 createTransitionManager 这个函数实例出的一个对象,源码如下:
function createTransitionManager() {
let listeners = [];
function appendListener(fn) {
let isActive = true;
function listener(...args) {
if (isActive) fn(...args);
}
listeners.push(listener);
return () => {
isActive = false;
listeners = listeners.filter(item => item !== listener);
};
}
function notifyListeners(...args) {
listeners.forEach(listener => listener(...args));
}
return {
appendListener,
notifyListeners
};
}
还记的开始时我们在Router组件中用过的那个 history.listen 方法,它其实就是调用了transitionManager.appendListener方法,源码如下:
//路由监听
function listen(listener) {
const unlisten = transitionManager.appendListener(listener);
checkDOMListeners(1);
return () => {
checkDOMListeners(-1);
unlisten();
};
}
listen函数的执行过程是,把入参函数 listener(也就是Router中的回调函数)传入appendListener中;执行 appendListener 方法,会将回调函数推入 listeners 这个数组,并返回一个函数,用来取消监听( 即删除listeners数组中该回调函数 )。
小结:当我们使用push切换路由时,它会执行setState方法( history里面的setState )。setState方法里面执行了 transitionManager.notifyListeners 方法,这个方法会遍历listeners,执行我们在 history.listen 里面的回调函数。而这个回调函数是用来通过 setState( react里面的setState )来更新Router组件自状态(location 对象),然后又因为这个location 对象传入了RouterContext的value中,所以当它发生变化时,所有的消费组件,都会重新render,以此来达到更新UI的目的。
Route
源码中是这样介绍:"用于匹配单个路径和呈现的公共API"。简单理解为找到 location 和的path匹配的组件并渲染。
class Route extends React.Component {
render() {
return (
<RouterContext.Consumer>
{context => {
const location = this.props.location || context.location;
const match = this.props.computedMatch
? this.props.computedMatch
: this.props.path
? matchPath(location.pathname, this.props)
: context.match;
const props = { ...context, location, match };
let { children, component, render } = this.props;
// 提前使用一个空数组作为children默认值,如果是这样,就使用null
if (Array.isArray(children) && isEmptyChildren(children)) {
children = null;
}
return (
<RouterContext.Provider value={props}>
//对应三种渲染方式children、component、render,只能使用一种
{props.match
? children
? typeof children === "function"
? __DEV__
? evalChildrenDev(children, props, this.props.path)
: children(props)
: children
: component
? React.createElement(component, props)
: render
? render(props)
: null
: typeof children === "function"
? __DEV__
? evalChildrenDev(children, props, this.props.path)
: children(props)
: null}
</RouterContext.Provider>
);
}}
</RouterContext.Consumer>
);
}
}
Route 接受上层的 Router 传入的 context,当路由发生变化时,通过判断当前 Route 的 path和更新后的context 中的location是否匹配,匹配则渲染,不匹配则不渲染。
是否匹配的依据就是 matchPath 这个函数,下文会有分析,这里只需要知道匹配失败则 match 为 null,如果匹配成功则将 match 的结果作为 props 的一部分,在 render 中传递给传进来的要渲染的组件。
从render 方法可以知道有三种渲染组件的方法(children、component、render)渲染的优先级也是依次按照顺序,如果前面的已经渲染了,将会直接 return。
- children (如果children 是一个方法,则执行这个方法,如果只是一个子元素,则直接render 这个元素)
- component (直接传递一个组件,然后去render 组件)
- render(render 是一个方法,通过方法去render 这个组件)
最后,我们简单看下matchPath 是如何判断 location 是否符合 path 的。
function matchPath(pathname, options = {}) {
if (typeof options === "string" || Array.isArray(options)) {
options = { path: options };
}
const { path, exact = false, strict = false, sensitive = false } = options;
const paths = [].concat(path);
return paths.reduce((matched, path) => {
if (!path && path !== "") return null;
if (matched) return matched;
const { regexp, keys } = compilePath(path, {
end: exact,
strict,
sensitive
});
const match = regexp.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
isExact, // 是否是全匹配
// 返回的是一个键值对的映射
// 比如你的 path 是 /users/:id,然后匹配的 pathname 是 /user/123
// 那么 params 的返回值就是 {id: '123'}
params: keys.reduce((memo, key, index) => {
memo[key.name] = values[index];
return memo;
}, {})
};
}, null);
}
总结
第一次解析源码,参考了很多文档,终于梳理清楚了,觉得好用就点个赞哦~
参考文档: