youngwind/blog

单页面应用路由实现原理:以 React-Router 为例

youngwind opened this issue · 8 comments

前言

2 年前我刚接触 react-router,觉得这玩意儿很神奇,只定义几个 Route 和 Link,就可以控制整个 React 应用的路由。不过那时候只想着怎么用它,也写过 2 篇与之相关的文章 #17 #73 (现在看来,那时候的文章写得实在是太差了)今天,我们来认真研究一番,希望能解决以下 3 个问题。

  1. 单页面应用路由的实现原理是什么?
  2. react-router 是如何跟 react 结合起来的?
  3. 如何实现一个简单的 react-router?

hash 的历史

最开始的网页是多页面的,后来出现了 Ajax 之后,才慢慢有了 SPA。然而,那时候的 SPA 有两个弊端:

  1. 用户在使用的过程中,url 不会发生任何改变。当用户操作了几步之后,一不小心刷新了页面,又会回到最开始的状态。
  2. 由于缺乏 url,不方便搜索引擎进行收录。

怎么办呢? → 使用 hash
url 上的 hash 本意是用来作锚点的,方便用户在一个很长的文档里进行上下的导航,用来做 SPA 的路由控制并非它的本意。然而,hash 满足这么一种特性:改变 url 的同时,不刷新页面,再加上浏览器也提供 onhashchange 这样的事件监听,因此,hash 能用来做路由控制。(这部分红宝书 P394 也有相关的说明)后来,这种模式大行其道,onhashchange 也就被写进了 HTML5 规范当中去了。

下面举个例子,演示“通过改变 hash 值,对页面进行局部刷新”,此例子出自前端路由实现与 react-router 源码分析, By joeyguo

<ul>
    <li><a href="#/">turn white</a></li>
    <li><a href="#/blue">turn blue</a></li>
    <li><a href="#/green">turn green</a></li>
</ul>
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 anything
function changeBgColor(color) {
    content.style.backgroundColor = color;
}
Router.route('/', function () {
    changeBgColor('white');
});
Router.route('/blue', function () {
    changeBgColor('blue');
});
Router.route('/green', function () {
    changeBgColor('green');
});

运行的效果如下图所示:
hash
由图中我们可以看到:的确可以通过 hash 的改变来对页面进行局部刷新。尤其需要注意的是:在第一次进入页面的时候,如果 url 上已经带有 hash,那么也会触发一次 onhashchange 事件,这保证了一开始的 hash 就能被识别。
问题:虽然 hash 解决了 SPA 路由控制的问题,但是它又引入了新的问题 → url 上会有一个 # 号,很不美观
解决方案:抛弃 hash,使用 history

history 的演进

很早以前,浏览器便实现了 history。然而,早期的 history 只能用于多页面进行跳转,比如:

// 这部分可参考红宝书 P215
history.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 的改变会触发什么事件呢? → 很遗憾,没有
怎么办呢?→ 虽然我们无法监听到 history 的改变事件,然而,如果我们能罗列出所有可能改变 history 的途径,然后在这些途径一一进行拦截,不也一样相当于监听了 history 的改变吗
对于一个应用而言,url 的改变只能由以下 3 种途径引起:

  1. 点击浏览器的前进或者后退按钮;
  2. 点击 a 标签;
  3. 在 JS 代码中直接修改路由

第 2 和第 3 种途径可以看成是一种,因为 a 标签的默认事件可以被禁止,进而调用 JS 方法。关键是第 1 种,HTML5 规范中新增了一个 onpopstate 事件,通过它便可以监听到前进或者后退按钮的点击。
要特别注意的是:调用history.pushStatehistory.replaceState并不会触发 onpopstate 事件。

总结:经过上面的分析,history 是可以用来进行路由控制的,只不过需要从 3 方面进行着手

React-Router v4

React-Router 的版本也是诡异,从 2 到 3 再到 4,每次的 API 变化都可谓翻天覆地,这次我们便以最新的 v4 进行举例。

const BasicExample = () => (
  <Router>
    <div>
      <ul>
        <li><Link to="/">Home</Link></li>
        <li><Link to="/about">About</Link></li>
        <li><Link to="/topics">Topics</Link></li>
      </ul>

      <hr/>

      <Route exact path="/" component={Home}/>
      <Route path="/about" component={About}/>
      <Route path="/topics" component={Topics}/>
    </div>
  </Router>
)

运行的实际结果如下图所示:
rrv4
由图中我们可以看出:所谓的局部刷新,其本质是:三个 comppnent 一直都在。当路由发生变化时,跟当前 url 匹配的 component 正常渲染;跟当前 url 不匹配的 component 渲染为 null,仅此而已,这其实跟 jQuery 时代的 show 和 hide 是一样的道理。现象我们已经观察到了,下面讨论实现思路。

思路分析

react router

代码实现

本文的思路分析和代码实现,参考了这篇文章:build-your-own-react-router-v4, By Tyler;也可以对照着看译文版本:由浅入深地教你开发自己的 React Router v4, By 胡子大哈。相对于参考文章而言,我主要做了以下两处改动:

  1. 原文在每个 Route 里面进行 onpopstate 的事件绑定,为了简单化,我把这部分去掉了,只给 onpopstate 绑定唯一一个事件,在该事件中循环 instance 数组,依次调用每个 Route 的 forceUpdate 方法;
  2. 导出了一个 jsHistory 对象,调用jsHistory.pushState方法就可以在 JS 中控制路由导航。
// App.js
import React, {Component} from 'react'
import {
    Route,
    Link,
    jsHistory
} from './mini-react-router-dom'

const App = () => (
    <div>
        <ul className="nav">
            <li><Link to="/">Home</Link></li>
            <li><Link to="/about">About</Link></li>
            <li><Link to="/topics">Topics</Link></li>
        </ul>

        <BtnHome/>
        <BtnAbout/>
        <BtnTopics/>
        <hr/>

        <Route exact path="/" component={Home}/>
        <Route path="/about" component={About}/>
        <Route path="/topics" component={Topics}/>
    </div>
);

const Home = () => (
    <div>
        <h2>Home</h2>
    </div>
);

const About = () => (
    <div>
        <h2>About</h2>
    </div>
);

const Topics = ({match}) => (
    <div>
        <h2>Topics</h2>
    </div>
);

class BtnHome extends Component {
    render() {
        return (
            <button onClick={jsHistory.pushState.bind(this, '/')}>Home</button>
        )
    }
}

class BtnAbout extends Component {
    render() {
        return (
            <button onClick={jsHistory.pushState.bind(this, '/about')}>About</button>
        )
    }
}

class BtnTopics extends Component {
    render() {
        return (
            <button onClick={jsHistory.pushState.bind(this, '/topics')}>Topics</button>
        )
    }
}

export default App
// mini-react-router-dom.js
import React, {Component, PropTypes} from 'react';

let instances = [];  // 用来存储页面中的 Router
const 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 (
            <a href={to} onClick={this.handleClick}>
                {children}
            </a>
        )
    }
}

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
};

实现的效果如下图所示:
demo

参考资料

本文涉及到代码可以参考这个仓库

  1. Build your own React Router v4, By Tyler
  2. 由浅入深地教你开发自己的 React Router v4, By 胡子大哈
  3. 前端路由实现与 react-router 源码分析, By joeyguo
  4. react-router 2.7.0源码深度分析, By 朱建

---------- 完 -------------

“在第一次进入页面的时候,也会触发一次 onhashchange 事件,这保证了一开始的 hash 就能被识别。” 这句应该是错误的,页面在第一次加载时并不会触发hashchang事件,只有改变了hash值后才会触发。

@helloyxw 此处确实没有表述清楚,多谢指正。

最近在用preact做东西发现preact-router不支持路由切换动画,不支持子路由。
preact-router的方式是用Router把children遍历匹配url然后过滤掉。
博主的模式是全靠Route这个组件来匹配路由,但是这样就完全无法支持路由切换动画,子路由了。
动画组件是通过children的减少增加来触发动画的,除非Route内置动画功能。

现在我是把preact-router抄到项目里改造了,其中的children过滤感觉还是不够给力,顺便帮忙看看怎么优化。后面是打算把这个抽出来作为一个单独的项目,也不知道要不要去搞个pr,源码全部用typescript写过,还做了react兼容。

“三个 comppnent 一直都在...”
打错词了,应该是component吧

window.addEventListener('popstate', () => {
// 遍历所有 Route,强制重新渲染所有 Route
instances.forEach(instance => instance.forceUpdate());
});

这个监听是为了做什么?原来以为是为了监听pushState,但MDN上提到:Note that just calling history.pushState() or history.replaceState() won't trigger a popstate event. 所以有点不理解这个监听是为了哪种场景? @youngwind

window.addEventListener('popstate', () => {
// 遍历所有 Route,强制重新渲染所有 Route
instances.forEach(instance => instance.forceUpdate());
});

这个监听是为了做什么?原来以为是为了监听pushState,但MDN上提到:Note that just calling history.pushState() or history.replaceState() won't trigger a popstate event. 所以有点不理解这个监听是为了哪种场景? @youngwind

这就是为了监听,手动点击浏览器的回退和前进按钮 这两种情况的,MDN说明的这个,结合题主说的,我的理解是:popState 能触发回调,你在回调里面处理路由变化的逻辑;但是你调用 history.pushState 和 replaceState,是不会触发 popState的,不会形成环路

最近在用preact做东西发现preact-router不支持路由切换动画,不支持子路由。
preact-router的方式是用Router把children遍历匹配url然后过滤掉。
博主的模式是全靠Route这个组件来匹配路由,但是这样就完全无法支持路由切换动画,子路由了。
动画组件是通过children的减少增加来触发动画的,除非Route内置动画功能。

现在我是把preact-router抄到项目里改造了,其中的children过滤感觉还是不够给力,顺便帮忙看看怎么优化。后面是打算把这个抽出来作为一个单独的项目,也不知道要不要去搞个pr,源码全部用typescript写过,还做了react兼容。

前言

2 年前我刚接触 react-router,觉得这玩意儿很神奇,只定义几个 Route 和 Link,就可以控制整个 React 应用的路由。不过那时候只想着怎么用它,也写过 2 篇与之相关的文章 #17 #73 (现在看来,那时候的文章写得实在是太差了)今天,我们来认真研究一番,希望能解决以下 3 个问题。

  1. 单页面应用路由的实现原理是什么?
  2. react-router 是如何跟 react 结合起来的?
  3. 如何实现一个简单的 react-router?

hash 的历史

最开始的网页是多页面的,后来出现了 Ajax 之后,才慢慢有了 SPA。然而,那时候的 SPA 有两个弊端:

  1. 用户在使用的过程中,url 不会发生任何改变。当用户操作了几步之后,一不小心刷新了页面,又会回到最开始的状态。
  2. 由于缺乏 url,不方便搜索引擎进行收录。

怎么办呢? → 使用 hash
url 上的 hash 本意是用来作锚点的,方便用户在一个很长的文档里进行上下的导航,用来做 SPA 的路由控制并非它的本意。然而,hash 满足这么一种特性:改变 url 的同时,不刷新页面,再加上浏览器也提供 onhashchange 这样的事件监听,因此,hash 能用来做路由控制。(这部分红宝书 P394 也有相关的说明)后来,这种模式大行其道,onhashchange 也就被写进了 HTML5 规范当中去了。

下面举个例子,演示“通过改变 hash 值,对页面进行局部刷新”,此例子出自前端路由实现与 react-router 源码分析, By joeyguo

<ul>
    <li><a href="#/">turn white</a></li>
    <li><a href="#/blue">turn blue</a></li>
    <li><a href="#/green">turn green</a></li>
</ul>
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 anything
function changeBgColor(color) {
    content.style.backgroundColor = color;
}
Router.route('/', function () {
    changeBgColor('white');
});
Router.route('/blue', function () {
    changeBgColor('blue');
});
Router.route('/green', function () {
    changeBgColor('green');
});

运行的效果如下图所示:
hash
由图中我们可以看到:的确可以通过 hash 的改变来对页面进行局部刷新。尤其需要注意的是:在第一次进入页面的时候,如果 url 上已经带有 hash,那么也会触发一次 onhashchange 事件,这保证了一开始的 hash 就能被识别。
问题:虽然 hash 解决了 SPA 路由控制的问题,但是它又引入了新的问题 → url 上会有一个 # 号,很不美观
解决方案:抛弃 hash,使用 history

history 的演进

很早以前,浏览器便实现了 history。然而,早期的 history 只能用于多页面进行跳转,比如:

// 这部分可参考红宝书 P215
history.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 的改变会触发什么事件呢? → 很遗憾,没有
怎么办呢?→ 虽然我们无法监听到 history 的改变事件,然而,如果我们能罗列出所有可能改变 history 的途径,然后在这些途径一一进行拦截,不也一样相当于监听了 history 的改变吗
对于一个应用而言,url 的改变只能由以下 3 种途径引起:

  1. 点击浏览器的前进或者后退按钮;
  2. 点击 a 标签;
  3. 在 JS 代码中直接修改路由

第 2 和第 3 种途径可以看成是一种,因为 a 标签的默认事件可以被禁止,进而调用 JS 方法。关键是第 1 种,HTML5 规范中新增了一个 onpopstate 事件,通过它便可以监听到前进或者后退按钮的点击。
要特别注意的是:调用history.pushStatehistory.replaceState并不会触发 onpopstate 事件。

总结:经过上面的分析,history 是可以用来进行路由控制的,只不过需要从 3 方面进行着手

React-Router v4

React-Router 的版本也是诡异,从 2 到 3 再到 4,每次的 API 变化都可谓翻天覆地,这次我们便以最新的 v4 进行举例。

const BasicExample = () => (
  <Router>
    <div>
      <ul>
        <li><Link to="/">Home</Link></li>
        <li><Link to="/about">About</Link></li>
        <li><Link to="/topics">Topics</Link></li>
      </ul>

      <hr/>

      <Route exact path="/" component={Home}/>
      <Route path="/about" component={About}/>
      <Route path="/topics" component={Topics}/>
    </div>
  </Router>
)

运行的实际结果如下图所示:
rrv4
由图中我们可以看出:所谓的局部刷新,其本质是:三个 comppnent 一直都在。当路由发生变化时,跟当前 url 匹配的 component 正常渲染;跟当前 url 不匹配的 component 渲染为 null,仅此而已,这其实跟 jQuery 时代的 show 和 hide 是一样的道理。现象我们已经观察到了,下面讨论实现思路。

思路分析

react router

代码实现

本文的思路分析和代码实现,参考了这篇文章:build-your-own-react-router-v4, By Tyler;也可以对照着看译文版本:由浅入深地教你开发自己的 React Router v4, By 胡子大哈。相对于参考文章而言,我主要做了以下两处改动:

  1. 原文在每个 Route 里面进行 onpopstate 的事件绑定,为了简单化,我把这部分去掉了,只给 onpopstate 绑定唯一一个事件,在该事件中循环 instance 数组,依次调用每个 Route 的 forceUpdate 方法;
  2. 导出了一个 jsHistory 对象,调用jsHistory.pushState方法就可以在 JS 中控制路由导航。
// App.js
import React, {Component} from 'react'
import {
    Route,
    Link,
    jsHistory
} from './mini-react-router-dom'

const App = () => (
    <div>
        <ul className="nav">
            <li><Link to="/">Home</Link></li>
            <li><Link to="/about">About</Link></li>
            <li><Link to="/topics">Topics</Link></li>
        </ul>

        <BtnHome/>
        <BtnAbout/>
        <BtnTopics/>
        <hr/>

        <Route exact path="/" component={Home}/>
        <Route path="/about" component={About}/>
        <Route path="/topics" component={Topics}/>
    </div>
);

const Home = () => (
    <div>
        <h2>Home</h2>
    </div>
);

const About = () => (
    <div>
        <h2>About</h2>
    </div>
);

const Topics = ({match}) => (
    <div>
        <h2>Topics</h2>
    </div>
);

class BtnHome extends Component {
    render() {
        return (
            <button onClick={jsHistory.pushState.bind(this, '/')}>Home</button>
        )
    }
}

class BtnAbout extends Component {
    render() {
        return (
            <button onClick={jsHistory.pushState.bind(this, '/about')}>About</button>
        )
    }
}

class BtnTopics extends Component {
    render() {
        return (
            <button onClick={jsHistory.pushState.bind(this, '/topics')}>Topics</button>
        )
    }
}

export default App
// mini-react-router-dom.js
import React, {Component, PropTypes} from 'react';

let instances = [];  // 用来存储页面中的 Router
const 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 (
            <a href={to} onClick={this.handleClick}>
                {children}
            </a>
        )
    }
}

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
};

实现的效果如下图所示:
demo

参考资料

本文涉及到代码可以参考这个仓库

  1. Build your own React Router v4, By Tyler
  2. 由浅入深地教你开发自己的 React Router v4, By 胡子大哈
  3. 前端路由实现与 react-router 源码分析, By joeyguo
  4. react-router 2.7.0源码深度分析, By 朱建

---------- 完 -------------