前言
2 年前我刚接触 react-router,觉得这玩意儿很神奇,只定义几个 Route 和 Link,就可以控制整个 React 应用的路由。不过那时候只想着怎么用它,也写过 2 篇与之相关的文章 (现在看来,那时候的文章写得实在是太差了)今天,我们来认真研究一番,希望能解决以下 3 个问题。
- 单页面应用路由的实现原理是什么?
- react-router 是如何跟 react 结合起来的?
- 如何实现一个简单的 react-router?
hash 的历史
最开始的网页是多页面的,后来出现了 Ajax 之后,才慢慢有了 SPA。然而,那时候的 SPA 有两个弊端:
- 用户在使用的过程中,url 不会发生任何改变。当用户操作了几步之后,一不小心刷新了页面,又会回到最开始的状态。
- 由于缺乏 url,不方便搜索引擎进行收录。
怎么办呢? → 使用
url 上的 hash 本意是用来作锚点的,方便用户在一个很长的文档里进行上下的导航,用来做 SPA 的路由控制并非它的本意。然而,hash 满足这么一种特性:改变 url 的同时,不刷新页面,再加上浏览器也提供 这样的事件监听,因此,hash 能用来做路由控制。(这部分红宝书 P394 也有相关的说明)后来,这种模式大行其道,onhashchange 也就被写进了 HTML5 规范当中去了。下面举个例子,演示“通过改变 hash 值,对页面进行局部刷新”,此例子出自, By joeyguo
function Router() { this.routes = {}; this.currentUrl = '';}Router.prototype.route = function (path, callback) { this.routes[path] = callback || function () { };};Router.prototype.refresh = function () { console.log('触发一次 hashchange,hash 值为', location.hash); this.currentUrl = location.hash.slice(1) || '/'; this.routes[this.currentUrl]();};Router.prototype.init = function () { window.addEventListener('load', this.refresh.bind(this), false); window.addEventListener('hashchange', this.refresh.bind(this), false);};window.Router = new Router();window.Router.init();var content = document.querySelector('body');// change Page anythingfunction changeBgColor(color) { content.style.backgroundColor = color;}Router.route('/', function () { changeBgColor('white');});Router.route('/blue', function () { changeBgColor('blue');});Router.route('/green', function () { changeBgColor('green');});
运行的效果如下图所示:
由图中我们可以看到:的确可以通过 hash 的改变来对页面进行局部刷新。尤其需要注意的是:在第一次进入页面的时候,如果 url 上已经带有 hash,那么也会触发一次 onhashchange 事件,这保证了一开始的 hash 就能被识别。 问题:虽然 hash 解决了 SPA 路由控制的问题,但是它又引入了新的问题 → url 上会有一个 # 号,很不美观 解决方案:抛弃 hash,使用 historyhistory 的演进
很早以前,浏览器便实现了 history。然而,早期的 history 只能用于多页面进行跳转,比如:
// 这部分可参考红宝书 P215history.go(-1); // 后退一页history.go(2); // 前进两页history.forward(); // 前进一页history.back(); // 后退一页
在 HTML5 规范中,history 新增了以下几个 API
history.pushState(); // 添加新的状态到历史状态栈history.replaceState(); // 用新的状态代替当前状态history.state // 返回当前状态对象
通过history.pushState
或者history.replaceState
,也能做到:改变 url 的同时,不会刷新页面。所以 history 也具备实现路由控制的潜力。然而,还缺一点:hash 的改变会触发 onhashchange 事件,history 的改变会触发什么事件呢? → 很遗憾,没有。
- 点击浏览器的前进或者后退按钮;
- 点击 a 标签;
- 在 JS 代码中直接修改路由
第 2 和第 3 种途径可以看成是一种,因为 a 标签的默认事件可以被禁止,进而调用 JS 方法。关键是第 1 种,HTML5 规范中新增了一个 事件,通过它便可以监听到前进或者后退按钮的点击。
要特别注意的是:调用history.pushState
和history.replaceState
并不会触发 onpopstate 事件。 总结:经过上面的分析,history 是可以用来进行路由控制的,只不过需要从 3 方面进行着手。
React-Router v4
React-Router 的版本也是诡异,从 2 到 3 再到 4,每次的 API 变化都可谓翻天覆地,这次我们便以最新的 进行举例。
const BasicExample = () => ()
- Home
- About
- Topics
运行的实际结果如下图所示:
由图中我们可以看出:所谓的局部刷新,其本质是:三个 comppnent 一直都在。当路由发生变化时,跟当前 url 匹配的 component 正常渲染;跟当前 url 不匹配的 component 渲染为 null,仅此而已,这其实跟 jQuery 时代的 show 和 hide 是一样的道理。现象我们已经观察到了,下面讨论实现思路。思路分析
代码实现
本文的思路分析和代码实现,参考了这篇文章:, By Tyler;也可以对照着看译文版本:, By 胡子大哈。相对于参考文章而言,我主要做了以下两处改动:
- 原文在每个 Route 里面进行 onpopstate 的事件绑定,为了简单化,我把这部分去掉了,只给 onpopstate 绑定唯一一个事件,在该事件中循环 instance 数组,依次调用每个 Route 的 forceUpdate 方法;
- 导出了一个 jsHistory 对象,调用
jsHistory.pushState
方法就可以在 JS 中控制路由导航。
// App.jsimport React, {Component} from 'react'import { Route, Link, jsHistory} from './mini-react-router-dom'const App = () => ();const Home = () => (
- Home
- About
- Topics
);const About = () => (Home
);const Topics = ({match}) => (About
);class BtnHome extends Component { render() { return ( ) }}class BtnAbout extends Component { render() { return ( ) }}class BtnTopics extends Component { render() { return ( ) }}export default AppTopics
// mini-react-router-dom.jsimport React, {Component, PropTypes} from 'react';let instances = []; // 用来存储页面中的 Routerconst register = (comp) => instances.push(comp);const unRegister = (comp) => instances.splice(instances.indexOf(comp), 1);const historyPush = (path) => { window.history.pushState({}, null, path); instances.forEach(instance => instance.forceUpdate())};window.addEventListener('popstate', () => { // 遍历所有 Route,强制重新渲染所有 Route instances.forEach(instance => instance.forceUpdate());});// 判断 Route 的 path 参数与当前 url 是否匹配const matchPath = (pathname, options) => { const {path, exact = false} = options; const match = new RegExp(`^${path}`).exec(pathname); if (!match) return null; const url = match[0]; const isExact = pathname === url; if (exact && !isExact) return null; return { path, url }};export class Link extends Component { static propTypes = { to: PropTypes.string }; handleClick = (event) => { event.preventDefault(); const {to} = this.props; historyPush(to); }; render() { const {to, children} = this.props; return ( {children} ) }}export class Route extends Component { static propTypes = { path: PropTypes.string, component: PropTypes.func, exact: PropTypes.bool }; componentWillMount() { register(this); } render() { const {path, component, exact} = this.props; const match = matchPath(window.location.pathname, {path, exact}); // Route 跟当前 url 不匹配,就返回 null if (!match) return null; if (component) { return React.createElement(component); } } componentWillUnMount() { unRegister(this); }}// 这里之所以要导出一个 jsHistory,// 是为了方便使用者在 JS 中直接控制导航export const jsHistory = { pushState: historyPush};
实现的效果如下图所示:
参考资料
本文涉及到代码可以参考。
- , By Tyler
- , By 胡子大哈
- , By joeyguo
- , By 朱建
---------- 完 -------------