前端路由實現及 react-router v4 原始碼分析

fi3ework發表於2018-07-11

原文釋出於我的 GitHub blog,有所有文章的歸檔,歡迎 star

前言

react-router 目前作為 react 最流行的路由管理庫,已經成為了某種意義上的官方路由庫(不過下一代的路由庫 reach-router 已經蓄勢待發了),並且更新到了 v4 版本,完成了一切皆元件的升級。本文將對 react-router v4(以下簡稱 rr4) 的原始碼進行分析,來理解 rr4 是如何幫助我們管理路由狀態的。

路由

在分析原始碼之前,先來對路由有一個認識。在 SPA 盛行之前,還不存在前端層面的路由概念,每個 URL 對應一個頁面,所有的跳轉或者連結都通過 <a> 標籤來完成,隨著 SPA 的逐漸興盛及 HTML5 的普及,hash 路由及基於 history 的路由庫越來越多。

路由庫最大的作用就是同步 URL 與其對應的回撥函式。對於基於 history 的路由,它通過 history.pushState 來修改 URL,通過 window.addEventListener('popstate', callback) 來監聽前進/後退事件;對於 hash 路由,通過操作 window.location 的字串來更改 hash,通過 window.addEventListener('hashchange', callback) 來監聽 URL 的變化。

SPA 路由實現

hash 路由

class Router {
  constructor() {
    // 儲存 hash 與 callback 鍵值對
    this.routes = {};
    // 當前 hash
    this.currentUrl = '';
    // 記錄出現過的 hash
    this.history = [];
    // 作為指標,預設指向 this.history 的末尾,根據後退前進指向 history 中不同的 hash
    this.currentIndex = this.history.length - 1;
    this.backIndex = this.history.length - 1
    this.refresh = this.refresh.bind(this);
    this.backOff = this.backOff.bind(this);
    // 預設不是後退操作
    this.isBack = false;
    window.addEventListener('load', this.refresh, false);
    window.addEventListener('hashchange', this.refresh, false);
  }

  route(path, callback) {
    this.routes[path] = callback || function() {};
  }

  refresh() {
    console.log('refresh')
    this.currentUrl = location.hash.slice(1) || '/';
    this.history.push(this.currentUrl);
    this.currentIndex++;
    if (!this.isBack) {
      this.backIndex = this.currentIndex
    }
    this.routes[this.currentUrl]();
    console.log('指標:', this.currentIndex, 'history:', this.history);
    this.isBack = false;
  }
  // 後退功能
  backOff() {
    // 後退操作設定為true
    console.log(this.currentIndex)
    console.log(this.backIndex)
    this.isBack = true;
    this.backIndex <= 0 ?
      (this.backIndex = 0) :
      (this.backIndex = this.backIndex - 1);
    location.hash = `#${this.history[this.backIndex]}`;
  }
}
複製程式碼

完整實現 hash-router,參考 hash router

其實知道了路由的原理,想要實現一個 hash 路由並不困難,比較需要注意的是 backOff 的實現,包括  hash router 中對 backOff 的實現也是有 bug 的,瀏覽器的回退會觸發 hashChange 所以會在 history 中 push 一個新的路徑,也就是每一步都將被記錄。所以需要一個 backIndex 來作為返回的 index 的標識,在點選新的 URL 的時候再將 backIndex 迴歸為 this.currentIndex

基於 history 的路由實現

class Routers {
  constructor() {
    this.routes = {};
    // 在初始化時監聽popstate事件
    this._bindPopState();
  }
  // 初始化路由
  init(path) {
    history.replaceState({path: path}, null, path);
    this.routes[path] && this.routes[path]();
  }
  // 將路徑和對應回撥函式加入hashMap儲存
  route(path, callback) {
    this.routes[path] = callback || function() {};
  }

  // 觸發路由對應回撥
  go(path) {
    history.pushState({path: path}, null, path);
    this.routes[path] && this.routes[path]();
  }
  // 後退
  backOff(){
    history.back()
  }
  // 監聽popstate事件
  _bindPopState() {
    window.addEventListener('popstate', e => {
      const path = e.state && e.state.path;
      this.routes[path] && this.routes[path]();
    });
  }
}
複製程式碼

參考 H5 Router

相比 hash 路由,h5 路由不再需要有些醜陋去的去修改 window.location 了,取而代之使用 history.pushState 來完成對 window.location 的操作,使用 window.addEventListener('popstate', callback) 來對前進/後退進行監聽,至於後退則可以直接使用 window.history.back() 或者 window.history.go(-1) 來直接實現,由於瀏覽器的 history 控制了前進/後退的邏輯,所以實現簡單了很多。

react 中的路由

react 作為一個前端檢視框架,本身是不具有除了 view (資料與介面之間的抽象)之外的任何功能的,為 react 引入一個路由庫的目的與上面的普通 SPA 目的一致,只不過上面路由更改觸發的回撥函式是我們自己寫的操作 DOM 的函式;在 react 中我們不直接操作 DOM,而是管理抽象出來的 VDOM 或者說 JSX,對 react 的來說路由需要管理元件的生命週期,對不同的路由渲染不同的元件

原始碼分析

預備知識

在前面我們瞭解了建立路由的目的,普通 SPA 路由的實現及 react 路由的目的,先來認識一下 rr4 的周邊知識,然後就開始對 react-router 的原始碼分析。

history

history 庫,是 rr4 依賴的一個對 window.history 加強版的 history 庫。

match

源自 history 庫,表示當前的 URL 與 path 的匹配的結果

match: {
    path: "/", // 用來匹配的 path
	url: "/", // 當前的 URL
	params: {}, // 路徑中的引數
	isExact: pathname === "/" // 是否為嚴格匹配
}
複製程式碼
location

還是源自 history 庫,是 history 庫基於 window.location 的一個衍生。

hash: "" // hash
key: "nyi4ea" // 一個 uuid
pathname: "/explore" // URL 中路徑部分
search: "" // URL 引數
state: undefined // 路由跳轉時傳遞的 state
複製程式碼

我們帶著問題去分析原始碼,先逐個分析每個元件的作用,在最後會有回答,在這裡先舉一個 rr4 的小 DEMO

  1. 頁面初始化時的渲染過程?
  2. 點選一個 Link 跳轉及渲染更新的過程?

packages

rr4 將路由拆成了幾個包 —— react-router 負責通用的路由邏輯,react-router-dom 負責瀏覽器的路由管理,react-router-native 負責 react-native 的路由管理,通用的部分直接從 react-router 中匯入,使用者只需引入 react-router-dom 或 react-router-native 即可,react-router 作為依賴存在不再需要單獨引入。

Router

import React from 'react'
import { render } from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import App from './components/App';

render(){
    return(
		<BrowserRouter>
			<App />
		</BrowserRouter>
	)
)}
複製程式碼

這是我們呼叫 Router 的方式,這裡拿 BrowserRouter 來舉例。

BrowserRouter 的原始碼在 react-router-dom 中,它是一個高階元件,在內部建立一個全域性的 history 物件(可以監聽整個路由的變化),並將 history 作為 props 傳遞給 react-router 的 Router 元件(Router 元件再會將這個 history 的屬性作為 context 傳遞給子元件)

  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
複製程式碼

其實整個 Router 的核心是在 react-router 的 Router 元件中,如下,藉助 context 向 Route 傳遞元件,這也解釋了為什麼 Router 要在所有 Route 的外面。

  getChildContext() {
    return {
      router: {
        ...this.context.router,
        history: this.props.history,
        route: {
          location: this.props.history.location,
          match: this.state.match
        }
      }
    };
  }
複製程式碼

這是 Router 傳遞給子元件的 context,事實上 Route 也會將 router 作為 context 向下傳遞,如果我們在 Route 渲染的元件中加入

  static contextTypes = {
    router: PropTypes.shape({
      history: PropTypes.object.isRequired,
      route: PropTypes.object.isRequired,
      staticContext: PropTypes.object
    })
  };
複製程式碼

來通過 context 訪問 router,不過 rr4 一般通過 props 傳遞,將 history, location, match 作為三個獨立的 props 傳遞給要渲染的元件,這樣訪問起來方便一點(實際上已經完全將 router 物件的屬性完全傳遞了)。

在 Router 的 componentWillMount 中, 新增了

  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 <sStaticRouter>.
    this.unlisten = history.listen(() => {
      this.setState({
        match: this.computeMatch(history.location.pathname)
      });
    });
  }
複製程式碼

history.listen 能夠監聽路由的變化並執行回撥事件。

在這裡每次路由的變化執行的回撥事件為

this.setState({
    match: this.computeMatch(history.location.pathname)
});
複製程式碼

相比於在 setState 裡做的操作,setState 本身的意義更大 —— 每次路由變化 -> 觸發頂層 Router 的回撥事件 -> Router 進行 setState -> 向下傳遞 nextContext(context 中含有最新的 location)-> 下面的 Route 獲取新的 nextContext 判斷是否進行渲染。

之所以把這個 subscribe 的函式寫在 componentWillMount 裡,就像原始碼中給出的註釋:是為了 SSR 的時候,能夠使用 Redirect。

Route

Route 的作用是匹配路由,並傳遞給要渲染的元件 props。

在 Route 的 componentWillReceiveProps 中

  componentWillReceiveProps(nextProps, nextContext) {
    ...
    this.setState({
      match: this.computeMatch(nextProps, nextContext.router)
    });
  }
複製程式碼

Route 接受上層的 Router 傳入的 context,Router 中的 history 監聽著整個頁面的路由變化,當頁面發生跳轉時,history 觸發監聽事件,Router 向下傳遞 nextContext,就會更新 Route 的 props 和 context 來判斷當前 Route 的 path 是否匹配 location,如果匹配則渲染,否則不渲染。

是否匹配的依據就是 computeMatch 這個函式,在下文會有分析,這裡只需要知道匹配失敗則 match 為 null,如果匹配成功則將 match 的結果作為 props 的一部分,在 render 中傳遞給傳進來的要渲染的元件。

接下來看一下 Route 的 render 部分。

  render() {
    const { match } = this.state; // 布林值,表示 location 是否匹配當前 Route 的 path
    const { children, component, render } = this.props; // Route 提供的三種可選的渲染方式
    const { history, route, staticContext } = this.context.router; // Router 傳入的 context
    const location = this.props.location || route.location;
    const props = { match, location, history, staticContext };

    if (component) return match ? React.createElement(component, props) : null; // Component 建立

    if (render) return match ? render(props) : null; // render 建立

    if (typeof children === "function") return children(props); // 回撥 children 建立

    if (children && !isEmptyChildren(children)) // 普通 children 建立
      return React.Children.only(children);

    return null;
  }
複製程式碼

rr4 提供了三種渲染元件的方法:component props,render props 和 children props,渲染的優先順序也是依次按照順序,如果前面的已經渲染後了,將會直接 return。

  • component (props) —— 由於使用 React.createElement 建立,所以可以傳入一個 class component。
  • render (props) —— 直接呼叫 render() 展開子元素,所以需要傳入 stateless function component。
  • children (props) —— 其實和 render 差不多,區別是不判斷 match,總是會被渲染。
  • children(子元素)—— 如果以上都沒有,那麼會預設渲染子元素,但是隻能有一個子元素。

這裡解釋一下官網的 tips,component 是使用 React.createElement 來建立新的元素,所以如果傳入一個行內函數,比如

<Route path='/' component={()=>(<div>hello world</div>)}
複製程式碼

的話,由於每次的 props.component 都是新建立的,所以 React 在 diff 的時候會認為進來了一個全新的元件,所以會將舊的元件 unmount,再 re-mount。這時候就要使用 render,少了一層包裹的 component 元素,render 展開後的元素型別每次都是一樣的,就不會發生 re-mount 了(children 也不會發生 re-mount)。

Switch

我們緊接著 Route 來看 Switch,Switch 是用來巢狀在 Route 的外面,當 Switch 中的第一個 Route 匹配之後就不會再渲染其他的 Route 了。

  render() {
    const { route } = this.context.router;
    const { children } = this.props;
    const location = this.props.location || route.location;

    let match, child;
    React.Children.forEach(children, element => {
      if (match == null && React.isValidElement(element)) {
        const {
          path: pathProp,
          exact,
          strict,
          sensitive,
          from
        } = element.props;
        const path = pathProp || from;

        child = element;
        match = matchPath(
          location.pathname,
          { path, exact, strict, sensitive },
          route.match
        );
      }
    });

    return match
      ? React.cloneElement(child, { location, computedMatch: match })
      : null;
  }
複製程式碼

Switch 也是通過 matchPath 這個函式來判斷是否匹配成功,一直按照 Switch 中 children 的順序依次遍歷子元素,如果匹配失敗則 match 為 null,如果匹配成功則標記這個子元素和它對應的 location、computedMatch。在最後的時候使用 React.cloneElement 渲染,如果沒有匹配到的子元素則返回 null

接下來我們看下 matchPath 是如何判斷 location 是否符合 path 的。

matchPath

matchPath 返回的是一個如下結構的物件

{
    path, // 用來進行匹配的路徑,其實是直接匯出的傳入 matchPath 的 options 中的 path
    url: path === "/" && url === "" ? "/" : url, // 整個的 URL
    isExact, // url 與 path 是否是 exact 的匹配
    // 返回的是一個鍵值對的對映
    // 比如你的 path 是 /users/:id,然後匹配的 pathname 是 /user/123
    // 那麼 params 的返回值就是 {id: '123'}
    params: keys.reduce((memo, key, index) => {
      memo[key.name] = values[index];
      return memo;
    }, {}) 
  }
複製程式碼

這些資訊將作為匹配的引數傳遞給 Route 和 Switch(Switch 只是一個代理,它的作用還是渲染 Route,Switch 計算得到的 computedMatch 會傳遞給要渲染的 Route,此時 Route 將直接使用這個 computedMatch 而不需要再自己來計算)。

在 matchPath 內部 compilePath 時,有個

const patternCache = {};
const cacheLimit = 10000;
let cacheCount = 0;
複製程式碼

作為 pathToRegexp 的快取,因為 ES6 的 import 模組匯出的是值的引用,所以將 patternCache 可以理解為一個全域性變數快取,快取以 {option:{pattern: }} 的形式儲存,之後如果需要匹配相同 pattern 和 option 的 path,則可以直接從快取中獲得正規表示式和 keys。

加快取的原因是路由頁面大部分情況下都是相似的,比如要訪問 /user/123/users/234,都會使用 /user/:id 這個 path 去匹配,沒有必要每次都生成一個新的正規表示式。SPA 在頁面整個訪問的過程中都維護著這份快取。

Link

實際上我們可能寫的最多的就是 Link 這個標籤了,我們從它的 render 函式開始看

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 語義。

在 handleClick 中,對沒有被 “preventDefault的 && 滑鼠左鍵點選的 && 非 _blank 跳轉 的&& 沒有按住其他功能鍵的“ 單擊進行 preventDefault,然後 push 進 history 中,這也是前面講過的 —— 路由的變化 與 頁面的跳轉 是不互相關聯的,rr4 在 Link 中通過 history 庫的 push 呼叫了 HTML5 history 的 pushState,但是這僅僅會讓路由變化,其他什麼都沒有改變。還記不記得 Router 中的 listen,它會監聽路由的變化,然後通過 context 更新 props 和 nextContext 讓下層的 Route 去重新匹配,完成需要渲染部分的更新。

  handleClick = event => {
    if (this.props.onClick) this.props.onClick(event);

    if (
      !event.defaultPrevented && // onClick prevented default
      event.button === 0 && // ignore everything but left clicks
      !this.props.target && // let browser handle "target=_blank" etc.
      !isModifiedEvent(event) // ignore clicks with modifier keys
    ) {
      event.preventDefault();

      const { history } = this.context.router;
      const { replace, to } = this.props;

      if (replace) {
        history.replace(to);
      } else {
        history.push(to);
      }
    }
  };
複製程式碼

withRouter

withRouter 的作用是讓我們在普通的非直接巢狀在 Route 中的元件也能獲得路由的資訊,這時候我們就要 WithRouter(wrappedComponent) 來建立一個 HOC 傳遞 props,WithRouter 的其實就是用 Route 包裹了 SomeComponent 的一個 HOC。

建立 Route 有三種方法,這裡直接採用了傳遞 children props 的方法,因為這個 HOC 要原封不動的渲染 wrappedComponent(children props 比較少用得到,某種程度上是一個內部方法)。

在最後返回 HOC 時,使用了 hoistStatics 這個方法,這個方法的作用是保留 SomeComponent 類的靜態方法,因為 HOC 是在 wrappedComponent 的外層又包了一層 Route,所以要將 wrappedComponent 類的靜態方法轉移給新的 Route,具體參見 Static Methods Must Be Copied Over

理解

現在回到一開始的問題,重新理解一下點選一個 Link 跳轉的過程。

有兩件事需要完成:

  1. 路由的改變
  2. 頁面的渲染部分的改變

過程如下:

  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:頁面的渲染部分的改變。

總結

看到這裡相信你已經能夠理解前端路由的實現及 react-router 的實現,但是 react-router 有很多的不足,這也是為什麼 reach-router 的出現的原因。

在下篇文章,我會介紹如何做一個可以快取的 Route —— 比如在列表頁跳轉到詳情頁再後退的時候,恢復列表頁的模樣,包括狀態及滾動位置等。

倉庫的地址: react-live-route,喜歡可以 star,歡迎提出 issue。

原文釋出於我的 GitHub blog,有所有文章的歸檔,歡迎 star。

相關文章