從路由原理出發,深入閱讀理解react-router 4.0的原始碼

yuxiaoliang發表於2018-09-18

  react-router等前端路由的原理大致相同,可以實現無重新整理的條件下切換顯示不同的頁面。路由的本質就是頁面的URL發生改變時,頁面的顯示結果可以根據URL的變化而變化,但是頁面不會重新整理。通過前端路由可以實現單頁(SPA)應用,本文首先從前端路由的原理出發,詳細介紹了前端路由原理的變遷。接著從react-router4.0的原始碼出發,深入理解react-router4.0是如何實現前端路由的。

  • 通過Hash實現前端路由
  • 通過H5的history實現前端路由
  • React-router4.0的使用
  • React-router4.0原始碼分析

原文的地址,在我的部落格中:github.com/fortheallli…

如有幫助,您的star是對我最好的鼓勵~

一、通過Hash實現前端路由

1、hash的原理

  早期的前端路由是通過hash來實現的:

改變url的hash值是不會重新整理頁面的。

  因此可以通過hash來實現前端路由,從而實現無重新整理的效果。hash屬性位於location物件中,在當前頁面中,可以通過:

window.location.hash='edit'
複製程式碼

來實現改變當前url的hash值。執行上述的hash賦值後,頁面的url發生改變。

賦值前:http://localhost:3000 賦值後:http://localhost:3000/#edit

在url中多了以#結尾的hash值,但是賦值前後雖然頁面的hash值改變導致頁面完整的url發生了改變,但是頁面是不會重新整理的。此外,還有一個名為hashchange的事件,可以監聽hash的變化,我們可以通過下面兩種方式來監聽hash的變化:

window.onhashchange=function(event){
   console.log(event);
}
window.addEventListener('hashchange',function(event){
   console.log(event);
})
複製程式碼

當hash值改變時,輸出一個HashChangeEvent。該HashChangeEvent的具體值為:

{isTrusted: true, oldURL: "http://localhost:3000/", newURL:   "http://localhost:3000/#teg", type: "hashchange".....}
複製程式碼

  有了監聽事件,且改變hash頁面不重新整理,這樣我們就可以在監聽事件的回撥函式中,執行我們展示和隱藏不同UI顯示的功能,從而實現前端路由。

此外,除了可以通過window.location.hash來改變當前頁面的hash值外,還可以通過html的a標籤來實現:

<a href="#edit">edit</a>
複製程式碼

2、hash的缺點

hash的相容性較好,因此在早期的前端路由中大量的採用,但是使用hash也有很多缺點。

  • 搜尋引擎對帶有hash的頁面不友好
  • 帶有hash的頁面內難以追蹤使用者行為

二、通過history實現前端路由

HTML5的History介面,History物件是一個底層介面,不繼承於任何的介面。History介面允許我們操作瀏覽器會話歷史記錄。

(1)History的屬性和方法

History提供了一些屬性和方法。

History的屬性:

  • History.length: 返回在會話歷史中有多少條記錄,包含了當前會話頁面。此外如果開啟一個新的Tab,那麼這個length的值為1
  • History.state: 儲存了會出發popState事件的方法,所傳遞過來的屬性物件(後面會在pushState和replaceState方法中詳細的介紹)

History方法:

  • History.back(): 返回瀏覽器會話歷史中的上一頁,跟瀏覽器的回退按鈕功能相同

  • History.forward():指向瀏覽器會話歷史中的下一頁,跟瀏覽器的前進按鈕相同

  • History.go(): 可以跳轉到瀏覽器會話歷史中的指定的某一個記錄頁

  • History.pushState():pushState可以將給定的資料壓入到瀏覽器會話歷史棧中,該方法接收3個引數,物件,title和一串url。pushState後會改變當前頁面url,但是不會伴隨著重新整理

  • History.replaceState():replaceState將當前的會話頁面的url替換成指定的資料,replaceState後也會改變當前頁面的url,但是也不會重新整理頁面。

上面的方法中,pushState和repalce的相同點:

就是都會改變當前頁面顯示的url,但都不會重新整理頁面。

不同點:

pushState是壓入瀏覽器的會話歷史棧中,會使得History.length加1,而replaceState是替換當前的這條會話歷史,因此不會增加History.length.

(2)BOM物件history

history在瀏覽器的BOM物件模型中的重要屬性,history完全繼承了History介面,因此擁有History中的所有的屬性和方法。

這裡我們主要來看看history.length屬性以及history.pushState、history.replaceState方法。

  • history.pushState(stateObj,title,url) or history.replaceState(stateObj,title,url)

pushState和replaceState接受3個引數,分別為state物件,title標題,改變的url。

window.history.pushState({foo:'bar'}, "page 2", "bar.html");

此時,當前的url變為:

執行上述方法前:http://localhost:3000 執行上述方法後:http://localhost:3000/bar.html

如果我們輸出window.history.state:

console.log(window.history.state); // {foo:'bar'}

window.history.state就是我們pushState的第一個物件引數。

  • history.replaceState()方法不會改變hitroy的長度

      console.log(window.history.length);
      window.history.replaceState({foo:'bar'}, "page 2", "bar.html");
      console.log(window.history.length);
    複製程式碼

上述前後兩次輸出的window.history.length是相等的。

此外。

每次觸發history.back()或者瀏覽器的後退按鈕等,會觸發一個popstate事件,這個事件在後退或者前進的時候發生:

window.onpopstate=function(event){

}
複製程式碼

注意: history.pushState和history.replaceState方法並不會觸發popstate事件。

如果用history做為路由的基礎,那麼需要用到的是history.pushState和history.replaceState,在不重新整理的情況下可以改變url的地址,且如果頁面發生回退back或者forward時,會觸發popstate事件。

hisory為依據來實現路由的優點:

  • 對搜尋引擎友好
  • 方便統計使用者行為

缺點:

  • 相容性不如hash
  • 需要後端做相應的配置,否則直接訪問子頁面會出現404錯誤

三、React-router4.0的使用

瞭解了前端路由實現的原理之後,下面來介紹一下React-router4.0。在React-router4.0的程式碼庫中,根據使用場景包含了以下幾個獨立的包:

  • react-router : react-router4.0的核心程式碼
  • react-router-dom : 構建網頁應用,存在DOM物件場景下的核心包
  • react-router-native : 適用於構建react-native應用
  • react-router-config : 配置靜態路由
  • react-router-redux : 結合redux來配置路由,已廢棄,不推薦使用。

在react-router4.0中,遵循Just Component的設計理念:

所提供的API都是以元件的形式給出。

比如BrowserRouter、Router、Link、Switch等API都是以元件的形式來使用。

1、React-router-dom常用的元件API

下面我們以React-router4.0中的React-router-dom包來介紹常用的BrowserRouter、HashRouter、Link和Router等。

(1) <BrowserRouter>

用<BrowserRouter> 元件包裹整個App系統後,就是通過html5的history來實現無重新整理條件下的前端路由。

<BrowserRouter>元件具有以下幾個屬性:

  • basename: string 這個屬性,是為當前的url再增加名為basename的值的子目錄。

      <BrowserRouter basename="test"/>
    複製程式碼

如果設定了basename屬性,那麼此時的:

http://localhost:3000http://localhost:3000/test 表示的是同一個地址,渲染的內容相同。

  • getUserConfirmation: func 這個屬性,用於確認導航的功能。預設使用window.confirm

  • forceRefresh: bool 預設為false,表示改變路由的時候頁面不會重新重新整理,如果當前瀏覽器不支援history,那麼當forceRefresh設定為true的時候,此時每次去改變url都會重新重新整理整個頁面。

  • keyLength: number 表示location的key屬性的長度,在react-router中每個url下都有為一個location與其對應,並且每一個url的location的key值都不相同,這個屬性一般都使用預設值,設定的意義不大。

  • children: node children的屬性必須是一個ReactNode節點,表示唯一渲染一個元素。

與<BrowserRouter>對應的是<HashRouter>,<HashRouter>使用url中的hash屬性來保證不重新重新整理的情況下同時渲染頁面。

(2) <Route>

<Route> 元件十分重要,<Route> 做的事情就是匹配相應的location中的地址,匹配成功後渲染對應的元件。下面我們來看<Route>中的屬性。

首先來看如何執行匹配,決定<Route>地址匹配的屬性:

  • path:當location中的url改變後,會與Route中的path屬性做匹配,path決定了與路由或者url相關的渲染效果。

  • exact: 如果有exact,只有url地址完全與path相同,才會匹配。如果沒有exact屬性,url的地址不完全相同,也會匹配。

舉例來說,當exact不設定時:

<Route  path='/home' component={Home}/> 
<Route  path='/home/first' component={First}/> 
複製程式碼

此時url地址為:http://localhost:3000/home/first 的時候,不僅僅會匹配到 path='/home/first'時的元件First,同時還會匹配到path='home'時候的Router。

如果設定了exact:

 <Route  path='/home' component={Home}/> 
複製程式碼

只有http://localhost:3000/home/first 不會匹配Home元件,只有url地址完全與path相同,只有http://localhost:3000/home才能匹配Home元件成功。

  • strict :與exact不同,strict屬性僅僅是對exact屬性的一個補充,設定了strict屬性後,嚴格限制了但斜線“/”。

舉例來說,當不設定strict的時候:

 <Route  path='/home/' component={Home}/> 
複製程式碼

此時http://localhost:3000/home 和 http://localhost:3000/home/ 都能匹配到元件Home。匹配對於斜線“/”比較寬鬆。如果設定了strict屬性:

<Route  path='/home/' component={Home}/> 
複製程式碼

那麼此時嚴格匹配斜線是否存在,http://localhost:3000/home 將無法匹配到Home元件。

當Route元件與某一url匹配成功後,就會繼續去渲染。那麼什麼屬性決定去渲染哪個元件或者樣式呢,Route的component、render、children決定渲染的內容。

  • component:該屬性接受一個React元件,當url匹配成功,就會渲染該元件
  • render:func 該屬性接受一個返回React Element的函式,當url匹配成功,渲染覆該返回的元素
  • children:與render相似,接受一個返回React Element的函式,但是不同點是,無論url與當前的Route的path匹配與否,children的內容始終會被渲染出來。

並且這3個屬性所接受的方法或者元件,都會有location,match和history這3個引數。如果元件,那麼元件的props中會存在從Link傳遞過來的location,match以及history。

(3) <Link>

<Route>定義了匹配規則和渲染規則,而<Link> 決定的是如何在頁面內改變url,從而與相應的<Route>匹配。<Link>類似於html中的a標籤,此外<Link>在改變url的時候,可以將一些屬性傳遞給匹配成功的Route,供相應的元件渲染的時候使用。

  • to: string to屬性的值可以為一個字串,跟html中的a標籤的href一樣,即使to屬性的值是一個字串,點選Link標籤跳轉從而匹配相應path的Route,也會將history,location,match這3個物件傳遞給Route所對應的元件的props中。

舉例來說:

<Link to='/home'>Home</Link>
複製程式碼

如上所示,當to接受一個string,跳轉到url為'/home'所匹配的Route,並渲染其關聯的元件內接受3個物件history,location,match。 這3個物件會在下一小節會詳細介紹。

  • to: object to屬性的值也可以是一個物件,該物件可以包含一下幾個屬性:pathname、seacth、hash和state,其中前3個引數與如何改變url有關,最後一個state引數是給相應的改變url時,傳遞一個物件引數。

舉例來說:

 <Link to={{pathname:'/home',search:'?sort=name',hash:'#edit',state:{a:1}}}>Home</Link>
複製程式碼

在上個例子中,to為一個物件,點選Link標籤跳轉後,改變後的url為:'/home?sort=name#edit'。 但是在與相應的Route匹配時,只匹配path為'/home'的元件,'/home?sort=name#edit'。在'/home'後所帶的引數不作為匹配標準,僅僅是做為引數傳遞到所匹配到的元件中,此外,state={a:1}也同樣做為引數傳遞到新渲染的元件中。

(4) React-router中傳遞給元件props的history物件

介紹了 <BrowserRouter> 、 <Route> 和 <Link> 之後,使用這3個元件API就可以構建一個簡單的React-router應用。這裡我們之前說,每當點選Link標籤跳轉或者在js中使用React-router的方法跳轉,從當前渲染的元件,進入新元件。在新元件被渲染的時候,會接受一個從舊元件傳遞過來的引數。

我們前面提到,Route匹配到相應的改變後的url,會渲染新元件,該新元件中的props中有history、location、match3個物件屬性,其中hisotry物件屬性最為關鍵。

同樣以下面的例子來說明:

<Link to={{pathname:'/home',search:'?sort=name',hash:'#edit',state:{a:1}}}>Home</Link>

<Route exact path='/home' component={Home}/>
複製程式碼

我們使用了<BrowserRouter>,該元件利用了window.history物件,當點選Link標籤跳轉後,會渲染新的元件Home,我們可以在Home元件中輸出props中的history:

// props中的history
action: "PUSH"
block: ƒ block()
createHref: ƒ createHref(location)
go: ƒ go(n)
goBack: ƒ goBack()
goForward: ƒ goForward()
length: 12
listen: ƒ listen(listener)
location: {pathname: "/home", search: "?sort=name", hash: "#edit", state: {…}, key: "uxs9r5"}
push: ƒ push(path, state)
replace: ƒ replace(path, state)
複製程式碼

從上面的屬性明細中:

  • push:f 這個方法用於在js中改變url,之前在Link元件中可以類似於HTML標籤的形式改變url。push方法對映於window.history中的pushState方法。

  • replace: f 這個方法也是用於在js中改變url,replace方法對映於window.history中的replaceState方法。

  • block:f 這個方法也很有用,比如當使用者離開當前頁面的時候,給使用者一個文字提示,就可以採用history.block("你確定要離開當前頁嗎?")這樣的提示。

  • go / goBack / goForward

在元件props中history的go、goBack、goForward方法,分別window.history.go、window.history.back、window.history.forward對應。

  • action: "PUSH" || "POP" action這個屬性左右很大,如果是通過Link標籤或者在js中通過this.props.push方法來改變當前的url,那麼在新元件中的action就是"PUSH",否則就是"POP".

action屬性很有用,比如我們在做翻頁動畫的時候,前進的動畫是SlideIn,後退的動畫是SlideOut,我們可以根據元件中的action來判斷採用何種動畫:

function newComponent (props)=>{
   return (
     <ReactCSSTransitionGroup
          transitionAppear={true}
          transitionAppearTimeout={600}
          transitionEnterTimeout={600}
          transitionLeaveTimeout={200}
          transitionName={props.history.action==='PUSH'?'SlideIn':'SlideOut'}
         >
           <Component {...props}/>
    </ReactCSSTransitionGroup>
   )
}
複製程式碼
  • location:object 在新元件的location屬性中,就記錄了從就元件中傳遞過來的引數,從上面的例子中,我們看到此時的location的值為:

      hash: "#edit"
      key: "uxs9r5"
      pathname: "/home"
      search: "?sort=name"
      state: {a:1}
    複製程式碼

除了key這個用作唯一表示外,其他的屬性都是我們從上一個Link標籤中傳遞過來的引數。

四、React-router4.0原始碼分析

在第三節中我們介紹了React-router的大致使用方法,讀一讀React-router4.0的原始碼。

這裡我們主要分析一下React-router4.0中是如何根據window.history來實現前端路由的,因此設計到的元件為BrowserRouter、Router、Route和Link

1、React-router中的history

從上一節的介紹中我們知道,點選Link標籤傳遞給新渲染的元件的props中有一個history物件,這個物件的內容很豐富,比如:action、goBack、go、location、push和replace方法等。

React-router構建了一個History類,用於在window.history的基礎上,構建屬性更為豐富的例項。該History類例項化後具有action、goBack、location等等方法。

React-router中將這個新的History類的構建方法,獨立成一個node包,包名為history。

npm install history -s 
複製程式碼

可以通過上述方法來引入,我們來看看這個History類的實現。

const createBrowserHistory = (props = {}) => {
    const globalHistory = window.history;
    ......
    //預設props中屬性的值
    const {
      forceRefresh = false,
      getUserConfirmation = getConfirmation,
      keyLength = 6,
      basename = '',
    } = props;
    const history = {
        length: globalHistory.length,
        action: "POP",
        location: initialLocation,
        createHref,
        push,
        replace,
        go,
        goBack,
        goForward,
        block,
        listen
    };                                         ---- (1)
    const basename = props.basename;   
    const canUseHistory = supportsHistory();   ----(2)
            
    const createKey = () =>Math.random().toString(36).substr(2, keyLength);    ----(3)
    
    const transitionManager = createTransitionManager();  ----(4)
    const setState = nextState => {
        Object.assign(history, nextState);
    
        history.length = globalHistory.length;
    
        transitionManager.notifyListeners(history.location, history.action);
    };                                      ----(5)
    
    const handlePopState = event => {
        handlePop(getDOMLocation(event.state));
    };
    const handlePop = location => {
    if (forceNextPop) {
      forceNextPop = false;
      setState();
    } else {
      const action = "POP";
      
      transitionManager.confirmTransitionTo(
            location,
            action,
            getUserConfirmation,
            ok => {
              if (ok) {
                setState({ action, location });
              } else {
                revertPop(location);
              }
            }
          );
        }
    };                                    ------(6)
    const initialLocation = getDOMLocation(getHistoryState());
    let allKeys = [initialLocation.key]; ------(7)
    
  
    // 與pop相對應,類似的push和replace方法
    const push ... replace ...            ------(8)
    
    return history                        ------ (9)
    
}
複製程式碼
  • (1) 中指明瞭新的構建方法History所返回的history物件中所具有的屬性。

  • (2)中的supportsHistory的方法判斷當前的瀏覽器對於window.history的相容性,具體方法如下:

     export const supportsHistory = () => {
       const ua = window.navigator.userAgent;
     
       if (
         (ua.indexOf("Android 2.") !== -1 || ua.indexOf("Android 4.0") !== -1) &&
         ua.indexOf("Mobile Safari") !== -1 &&
         ua.indexOf("Chrome") === -1 &&
         ua.indexOf("Windows Phone") === -1
       )
         return false;
     
       return window.history && "pushState" in window.history;
     };
    複製程式碼

從上述判別式我們可以看出,window.history在chrome、mobile safari和windows phone下是絕對支援的,但不支援安卓2.x以及安卓4.0

  • (3)中用於建立與history中每一個url記錄相關聯的指定位數的唯一標識key, 預設的keyLength為6位

  • (4)中 createTransitionManager方法,返回一個整合物件,物件中包含了關於history地址或者物件改變時候的監聽函式等,具體程式碼如下:

       const createTransitionManager = () => {
           const setPrompt = nextPrompt => {
             
           };
     
           const confirmTransitionTo = (
             location,
             action,
             getUserConfirmation,
             callback
           ) => {
              if (typeof getUserConfirmation === "function") {
                   getUserConfirmation(result, callback);
                 } else {
                   callback(true);
                 }
               } 
           };
           
           
           let listeners = [];
           const appendListener = fn => {
             let isActive = true;
         
             const listener = (...args) => {
               if (isActive) fn(...args);
             };
         
             listeners.push(listener);
         
             return () => {
               isActive = false;
               listeners = listeners.filter(item => item !== listener);
             };
           };
         
           const notifyListeners = (...args) => {
             listeners.forEach(listener => listener(...args));
           };
         
           return {
             setPrompt,
             confirmTransitionTo,
             appendListener,
             notifyListeners
           };
    複製程式碼

};

setPrompt函式,用於設定url跳轉時彈出的文字提示,confirmTransaction函式,會將當前生成新的history物件中的location,action,callback等引數,作用就是在回撥的callback方法中,根據要求,改變傳入的location和action物件。

接著我們看到有一個listeners陣列,儲存了一系列與url相關的監聽事件陣列,通過接下來的appendListener方法,可以往這個陣列中增加事件,通過notifyListeners方法可以遍歷執行listeners陣列中的所有事件。

  • (5) setState方法,發生在history的url或者history的action發生改變的時候,此方法會更新history物件中的屬性,同時會觸發notifyListeners方法,傳入當前的history.location和history.action。遍歷並執行所有監聽url改變的事件陣列listeners。

  • (6)這個getDOMLocation方法就是根據當前在window.state中的值,生成新history的location屬性物件,allKeys這是始終保持了在url改變時候的歷史url相關聯的key,儲存在全域性,allKeys在執行生“POP”或者“PUSH”、“Repalce”等會改變url的方法時,會保持一個實時的更新。

  • (7) handlePop方法,用於處理“POP”事件,我們知道在window.history中點選後退等會觸發“POP”事件,這裡也是一樣,執行action為“POP”,當後退的時候就會觸發該函式。

  • (8)中包含了與pop方法類似的,push和replace方法,push方法同樣做的事情就是執行action為“PUSH”(“REPLACE”),該變allKeys陣列中的值,唯一不同的是actio為“PUSH”的方法push是往allKeys陣列中新增,而action為“REPLACE”的方法replace則是替換掉當前的元素。

  • (9)返回這個新生成的history物件。

2、React-router中Link元件

其實最難弄懂的是React-router中如何重新構建了一個history工廠函式,在第一小節中我們已經詳細的介紹了history生成函式createBrowserHistory的原始碼,接著來看Link元件就很容易了。

首先Link元件類似於HTML中的a標籤,目的也很簡單,就是去主動觸發改變url的方法,主動改變url的方法,從上述的history的介紹中可知為push和replace方法,因此Link元件的原始碼為:

class Link extends React.Component {

    
   handleClick = event => {
   ...

     const { history } = this.context.router;
     const { replace, to } = this.props;
     if (replace) {
       history.replace(replace);
     } else {
      history.push(to);
     }
   }
  };
  render(){
    const { replace, to, innerRef, ...props } = this.props;
     <a {...props} onClick={this.handleClick}/>
  }
}
複製程式碼

上述程式碼很簡單,從React的context API全域性物件中拿到history,然後如果傳遞給Link元件的屬性中有replace為true,則執行history.replace(to),to 是一個包含pathname的物件,如果傳遞給Link元件的replace屬性為false,則執行history.push(to)方法。

3、React-router中Route元件

Route元件也很簡單,其props中接受一個最主要的屬性path,Route做的事情只有一件:

當url改變的時候,將path屬性與改變後的url做對比,如果匹配成功,則渲染該元件的componet或者children屬性所賦值的那個元件。

具體原始碼如下:

class Route extends React.Component {


  ....
  constructor(){
  
  
  }
  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;
  }

}
複製程式碼

state中的match就是是否匹配的標記,如果匹配當前的Route的path,那麼根據優先順序順序component屬性、render屬性和children屬性來渲染其所指向的React元件。

4、React-router中Router元件

Router元件中,是BrowserRouter、HashRouter等元件的底層元件。該元件中,定義了包含匹配規則match函式,以及使用了新history中的listener方法,來監聽url的改變,從而,當url改變時,更改Router下不同path元件的isMatch結果。

class Router extends React.Component {
    componentWillMount() {
        const { children, history } = this.props
        
        //呼叫history.listen監聽方法,該方法的返回函式是一個移除監聽的函式
        
        this.unlisten = history.listen(() => {
          this.setState({
            match: this.computeMatch(history.location.pathname)
          });
        });
    }
    componentWillUnmount() {
      this.unlisten();
    }
    render() {
    
    }
}
複製程式碼

上述首先在元件建立前呼叫了listener監聽方法,來監聽url的改變,實時的更新isMatch的結果。

5、總結

本文從前端路由的原理出發,先後介紹了兩種前端路由常用的方法,接著介紹了React-router的基本元件API以及用法,詳細介紹了React-router的元件中新構建的history物件,最後結合React-router的API閱讀了一下React-router的原始碼。

相關文章