單頁面應用路由實現原理:以 React-Router 爲例

前言

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章