1、什么是前端路由?
路由的概念来源于服务端,在服务端中路由描述的是 URL 与处理函数之间的映射关系。 在 Web 前端单页应用 SPA(Single Page Application)中,路由描述的是 URL 与 UI 之间的映射关系,这种映射是单向的,即 URL 变化 引起 UI 更新(无需刷新页面)。
2、如何实现前端路由?
a.hash 实现
hash 是 URL 中 hash (#) 及后面的那部分,常用作锚点在页面内进行导航,改变 URL 中的 hash 部分不会引起页面刷新。通过 hashchange 事件监听 URL 的变化,改变 URL 的方式只有这几种:
- 通过浏览器前进后退改变 URL、
- 通过a标签改变 URL
- 通过window.location改变URL。
如上这几种情况改变 URL 都会触发 hashchange 事件
b.history 实现
history 提供了 pushState 和 replaceState 两个方法,这两个方法改变 URL 的 path 部分不会引起页面刷新。
- history 提供类似 hashchange 事件的 popstate 事件,但 popstate 事件有些不同:通过浏览器前进后退改变 URL 时会触发 popstate 事件,通过pushState/replaceState或a标签改变 URL 不会触发 popstate 事件。好在我们可以拦截 pushState/replaceState的调用和a标签的点击事件来检测 URL 变化,所以监听 URL 变化可以实现,只是没有 hashchange 那么方便。
3、hash 与 history 模式的对比
hash路由优缺点
优点
- 实现简单,兼容性好(兼容到ie8)
- 绝大多数前端框架均提供了给予hash的路由实现
- 不需要服务器端进行任何设置和开发
- 除了资源加载和ajax请求以外,不会发起其他请求
缺点
- 对于部分需要重定向的操作,后端无法获取hash部分内容,导致后台无法取得url中的数据,典型的例子就是微信公众号的oauth验证
- 服务器端无法准确跟踪前端路由信息
- 对于需要锚点功能的需求会与目前路由机制冲突
History(browser)路由 优缺点
优点
- 对于重定向过程中不会丢失url中的参数。后端可以拿到这部分数据
- 绝大多数前段框架均提供了browser的路由实现
- 后端可以准确跟踪路由信息
- 可以使用history.state来获取当前url对应的状态信息
缺点
- 兼容性不如hash路由(只兼容到IE10)
- 需要后端支持,每次返回html文档
4、react-router 实现原理
a.首先,我们先来了解一下Router组件
Router 组件是所有路由组件的父级组件,为子组件提供当前路由状态并监听路由改变并触发重新渲染。 history.listen 能够监听路由的变化并执行回调事件。 每次的路由变化,触发回调事件 this.computeMatch, 相比于在 setState 里做的操作,setState 本身的意义更大 —— 每次路由变化 -> 触发顶层 Router 的回调事件 -> Router 进行 setState -> 向下传递 nextContext(context 中含有最新的 location)-> 下面的 Route 获取新的 nextContext 判断是否进行渲染。
componentWillMount() {
const { children, history } = this.props;
invariant(
children == null || React.Children.count(children) === 1,
"A <Router> may have only one child element"
);
// 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)
});
});
}
b.再来看下 Route ,Route 的作用是匹配路由,并传递给要渲染的组件 props。
Route 接受上层的 Router 传入的 context,Router 中的 history 监听着整个页面的路由变化,当页面发生跳转时,history 触发监听事件,Router 向下传递 nextContext,就会更新 Route 的 props 和 context 来判断当前 Route 的 path 是否匹配 location,如果匹配则渲染,否则不渲染。是否匹配的依据就是 computeMatch 这个函数,在下文会有分析,这里只需要知道匹配失败则 match 为 null,如果匹配成功则将 match 的结果作为 props 的一部分,在 render 中传递给传进来的要渲染的组件。
componentWillReceiveProps(nextProps, nextContext) {
warning(
!(nextProps.location && !this.props.location),
'<Route> elements should not change from uncontrolled to controlled (or vice versa). You initially used no "location" prop and then provided one on a subsequent render.'
);
warning(
!(!nextProps.location && this.props.location),
'<Route> elements should not change from controlled to uncontrolled (or vice versa). You provided a "location" prop initially but omitted it on a subsequent render.'
);
this.setState({
match: this.computeMatch(nextProps, nextContext.router)
});
}
c.接下来看一下 Route 的 render 部分。
提供了三种渲染组件的方法:component props,render props 和 children props,渲染的优先级也是依次按照顺序,如果前面的已经渲染后了,将会直接 return。
- component (props) —— 由于使用 React.createElement 创建,所以可以传入一个 class component。
- render (props) —— 直接调用 render() 展开子元素,所以需要传入 stateless function component。
- children (props) —— 其实和 render 差不多,区别是不判断 match,总是会被渲染。 children(子元素)—— 如果以上都没有,那么会默认渲染子元素,但是只能有一个子元素。
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 };
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;
}
d. 再来看下 matchPath,matchPath 返回的是一个如下结构的对象,这些信息将作为匹配的参数传递给 Route
return {
path, // the path pattern used to match
url: path === "/" && url === "" ? "/" : url, // the matched portion of the URL
isExact, // whether or not we matched exactly
params: keys.reduce((memo, key, index) => {
memo[key.name] = values[index];
return memo;
}, {})
};
e. 最后,我们来看下 Link 标签代码
render() {
const { replace, to, innerRef, ...props } = this.props; // eslint-disable-line no-unused-vars
invariant(
this.context.router,
"You should not use <Link> outside a <Router>"
);
invariant(to !== undefined, 'You must specify the "to" property');
const { history } = this.context.router;
const location =
typeof to === "string"
? createLocation(to, null, null, history.location)
: to;
const href = history.createHref(location);
// 最终创建的是一个 a 标签
return (
<a {...props} onClick={this.handleClick} href={href} ref={innerRef} />
);
}
可以看到 Link 最终还是创建一个 a 标签来包裹住要跳转的元素,但是如果只是一个普通的带 href 的 a 标签,那么就会直接跳转到一个新的页面而不是 SPA 了,所以在这个 a 标签的 handleClick 中会 preventDefault 禁止默认的跳转,所以这里的 href 并没有实际的作用,但仍然可以标示出要跳转到的页面的 URL 并且有更好的 html 语义。
整理一,整个路由跳转的过程有两件事需要完成:
- 路由的改变
- 页面的渲染部分的改变
1、在最一开始 mount Router 的时候,Router 在 componentWillMount 中 listen 了一个回调函数,由 history 库管理,路由每次改变的时候触发这个回调函数。这个回调函数会触发 setState。
2、当点击 Link 标签的时候,实际上点击的是页面上渲染出来的 a 标签,然后通过 preventDefault 阻止 a 标签的页面跳转。
3、Link 中也能拿到 Router -> Route 中通过 context 传递的 history,执行 hitsory.push(to),这个函数实际上就是包装了一下 window.history.pushState(),是 HTML5 history 的 API,但是 pushState 之后除了地址栏有变化其他没有任何影响,到这一步已经完成了目标1:路由的改变。
4、第1步中,路由改变是会触发 Router 的 setState 的,在 Router 那章有写道:每次路由变化 -> 触发顶层 Router 的监听事件 -> Router 触发 setState -> 向下传递新的 nextContext(nextContext 中含有最新的 location)
5、下层的 Route 拿到新的 nextContext 通过 matchPath 函数来判断 path 是否与 location 匹配,如果匹配则渲染,不匹配则不渲染,完成目标2:页面的渲染部分的改变
5、备注:
react-router 主要是利用底层 history 模块的机制,通过结合 react 的架构机制做一层包装,实际自身的内容并不多,但其包装的思想很值得学习
history 库借鉴了浏览器 history 的概念,对其进行封装或实现,使得开发者可以在任何js运行环境中实现历史会话操作,react-router 使用 history 库对其路由状态进行监听和管理,使得他能在非浏览器环境下运行。