react-router知多少(一)

Cris_冷崢子發表於2018-04-12

pre-notify

取名字真難!

測試用例搭建

首先是入口檔案,

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.js';

ReactDOM.render(
	<App/>
    ,window.root
);
複製程式碼

我們只讓入口檔案幹一件事,即渲染真實DOM到掛載元素上。

注意即使只幹這麼一件事,react庫也是必須引入的,否則會報

 'React' must be in scope when using JSX  react/react-in-jsx-scope
複製程式碼

接下來我們把路由和導航都統一放在App元件裡,只求一目瞭然

import React from 'react';
import {HashRouter as Router,Route} from './react-router-dom'; //引入我們自己的router庫

export default class App extends React.Component{
	render(){
    return (
          <Router>
            <div className='container'>
              <ul className='Nav'>
                <li><Link to='/home'>首頁</Link></li>
                <li><Link to='/user'>使用者</Link></li>
                <li><Link to='/profile'>個人設定</Link></li>
              </ul>
              <div className='View'>
                <Route path='/home' component={Home}/>
                <Route path='/user'component={User}/>
                <Route path='/profile' component={Profile}/>
              </div>
            </div>
          </Router>
        )
  }
}
複製程式碼

Router實現

import React from 'react';
import PropTypes from 'prop-types';

export default class Router extends React.Component{
    static childContextTypes = {
    	location:PropTypes.object
        ,history:PropTypes.object
    }
    
    constructor(props){
    	...
    }
    
    getChildContext(){
    	...
    }
    
    componentDidMount(){
    	...
    }
    
    render(){
    	...
    }
}
複製程式碼

Router返回值

Router元件只是一個路由容器,它並不會生成一個div什麼的,它會直接返回它包裹住的子元素們children

render(){
    return this.props.children; //注意children只是props下的一個屬性
}
複製程式碼

需要注意的是Router依然遵循JSX tag巢狀時的單一入口規則,So如果有多個平級的子元素需要用一層div或則其它什麼的給包起來。

<Router>
    <div>
    	<Route ... />
        <Route ... />
        ...
    </div>
</Router>
複製程式碼

hash值的初始化

為了效仿原版(沒有hash值時,自動補上/

react-router知多少(一)
首先我們在Router的建構函式中先建立一個location的狀態物件,並這物件中的pathname屬性賦一個初始值,這個pathname就是我們以後的hash值了(除去#部分

 constructor(props){
    super(props);
    this.state = {
        location:{
            pathname:window.location.hash.slice(1)||'/'
        }
    }
}
複製程式碼

接著我們需要在Router元件掛載完畢時對location.hash進行賦值

window.location.hash = this.state.location.pathname;
複製程式碼

這樣就完成了/的自動補全功能。

監聽hash

Router最重要的功能之一就是監聽hash值,一旦hash值發生改變,Router應該讓路由Route重新渲染。

那麼,怎麼才能讓路由元件重新匹配和渲染呢?

嗯,只需要呼叫this.setState即可,React中的setState方法只要被呼叫,就會重新渲染呼叫這個方法的元件,理所當然,也包括其子元件。

[danger] 注意: 雖然setState只要呼叫就會重新渲染,但有一種情況例外,則是setState()什麼也不傳的時。而只要有傳參,哪怕是一個{},也會重啟渲染。

我們選在在元件渲染完畢時開啟監聽

componentDidMount(){
   ...
    window.addEventListener('hashchange',()=>{
      	this.setState({location:{pathname:window.location.hash.slice(1)||'/'}});
    });
}
複製程式碼

關於setState:

setState雖然有自動合併state的功能,但若這個state裡還巢狀了一層,它是不會自動合併的,比如你有一個location的state,它長這樣{location:{pathname:xxx,other:yyy}},然後你像這樣更新了一下state {location:{pathname:xxx}},那麼location中的other將不再保留,因為setState並不支援第二層巢狀的自動合併。

快取

Router在監聽hash的時會實時的把hash值同步快取在state上,這樣我們就不用在每一次的路由匹配中都重頭獲取這個hash值而只需要從Router中拿即可。

那麼我們怎麼在route中從一個父元件(router)中拿取東東呢?

React中提供了一個叫context的東東,在一個元件類中新增這個東東,就相當於開闢了一塊作用域,讓子孫元件能夠輕易的通過這個作用域拿到父元件共享出的屬性和方法,我稱之為React中的任意門

這個門有兩側,一側在開通這個context的根元件(相對於其子孫元件的稱謂)這邊

// 申明父元件要在context作用域裡放哪些東東
...
static childContextTypes = {
    location:PropTypes.object
    ,history:PropTypes.object
};

// 定義要放這些東東的具體細節
getChildContext(){
    return {
        location:this.state.location
        ,history:{
            push(path){
                window.location.hash = path;
            }
        }
    }
}
...
複製程式碼

一側在要從根元件拿取東東的子孫元件這邊

[danger] 注意: 這裡的的靜態屬性不再帶child字樣

...
// 和根元件相比 去除了child字樣
// 要用哪些東東就需要申明哪些東東
static contextTypes = {
    location:propTypes.object
    ,history:propTypes.object
}

// 在宣告完要從根元件中拿取哪些東東後,可以在任意地方獲取到這些東東
fn(){
	...
    console.log(this.context.location);
}
...
複製程式碼

Route實現

從上一節中我們已經知道,Router元件最後返回的其實是它的children們,So,也就是一條條Route.

<Router>
    <Route path='/a' component={myComponent1} />
    <Route path='/b' component={myComponent2} />
    <Route path='/c' component={myComponent3} />
</Router>
複製程式碼

其中每一條<Route .. />都代表一次Route類的例項化,並且返回這個類中render函式所返回的東東。

我們通過將準備要渲染的元件作為屬性傳遞給<Route ../>元件,以求Route元件能幫我們控制住我們真正想要渲染的那些元件的渲染。(路由Route的角色就類似於編輯,需要對要渲染的內容進行審稿)

路由的匹配

實際中,我們只有當url中的pathname和我們在Route中設定的path相匹配時才會讓Route元件渲染我們傳遞給它的那些個真正想要渲染在頁面上的可視元件。

像這樣

...
// 接收根元件(Router)Context作用域中的 location 和 history
static contextTypes = {
    location:propTypes.object
    ,history:propTypes.object
}
...
// class Route の render方法中
...
let {component:Component,path} = this.props;
let {location} = this.context;
let pathname = location.pathname;

if(path==pathname||pathname.startsWith(path)){
  return <Component />
}else{
  return null;
}
...
複製程式碼

路由的傳參

當路由真正被匹配上時,會傳遞三個引數給真正要渲染的可視元件

// class Route の render方法中
....
...
static contextTypes = {
    location:PropTypes.object
    ,history:PropTypes.object
}
...
let props = {
  location
  ,history:this.context.history
  ,match:{}
};
...
if(path==pathname||pathname.startsWith(path)){
      return <Component {...props}/>
      ...
複製程式碼

如上所示,這三個引數屬性分別是:

  • location:主要存放著當前實時pathname
  • history:主要存放著各種跳轉路由的方法
  • match:存放著url 和 給route指定的path 以及動態路由引數params物件

pathname、path、url三者的區別

pathname在hashrotuer中是指#後面那一串,是url的子集。

path是我們給Route元件手動指定的匹配路徑,和pathname進行匹配的,但不一定等於pathname,有startsWith匹配。除此之外path還可能是一個/user/:id這樣的動態路由。

最後url,在react中它並不是我們的url地址,而是pathname經過path轉換成的正則匹配後的結果,它不一定等於path(因為還有動態路由)。

react-router知多少(一)

路由的渲染

React中路由的渲染有三種方式

  1. component
<Route path=.. compoent={Component1}/>
複製程式碼

這種就是最常見的,會根據路徑是否匹配決定是否渲染傳遞過來的元件。

  1. render (多用於許可權驗證)
<Route path=.. render={(props)=>{...}}>
複製程式碼

採用render方式渲染時,元件是否渲染不僅要看路徑是否匹配,還要由render屬性所接受的函式來共同決定。

注意,此時render函式會接受一個引數props,即當前Route元件的props物件。

  1. children (多用於選單)
<Route path=.. children={(props)=>{...}}>
複製程式碼

貌似和render沒區別,實則區別挺大!因為這貨不論路由的路徑是否匹配都會呼叫children這個回撥函式。

So,分清楚了三種渲染方式的區別後,我們來大概寫下如何實現

// Routeのrender函式中
...
if(result){ //表示路由匹配得上
    if(this.props.render){
    	return this.props.render(this.props);
    }else{
    	return <Component {...this.props}/>
    }
}else{
    if(this.props.children){ //如果children存在,就算路徑沒有匹配也會呼叫
    	return this.props.children(this.props);
    }else{
    	return null;
    }
}
...
複製程式碼

動態路由

要實現動態路由,需要我們將給Route設定的/xxx/:xxx們替換成正則用以匹配路徑,為了程式碼的清晰我門使用path-to-regexp模組對所有路由(包括非動態路由)都進行正則替換。

path-to-regexp 模組的實現在我的這篇文章中講過 Express原始碼級實現の路由全解析(下闋)

而這一步需要在路由初始化的時候就完成

constructor(props){
    super(props);
    let {path} = props; //user/detail/:id
    this.keys = [];
    this.regexp = pathToRegexp(path,this.keys,{end:false}); //false表示只要開頭匹配即可
    this.keys = this.keys.map(key=>key.name); //即是傳遞給渲染元件的match物件中的params物件
}
複製程式碼

這樣路由規則就不會在每次render重繪時都進行一次計算

接下來我們需要在每次render中對路徑重新進行匹配

// render()中
...
let result = location.pathname.match(this.regexp);
...
複製程式碼

如果匹配上了,有結果,還要準備一個params物件傳放進match物件中傳遞給渲染元件

if(result){
    let [url,...values] = result;

    props.match = {
        url //匹配上的路徑(<=pathname)
        ,path //route上的path
        ,params:this.keys.reduce((memo,key,idx)=>{
          memo[key] = values[idx];
          return memo;
        },{})
    };
}
複製程式碼

最後再判斷是根據三種渲染方式中的哪一種來渲染

if (result) {
      ...
  if (render) {
    return render(props);
  } else if (Component) {
    return <Component {...props}/>
  } else if (children) {
    return children(props);
  }
  return null;

} else {
  if (children) {
    return children(props);
  } else {
    return null;
  }
}
複製程式碼

Link 元件

Link元件能讓我們通過點選連線來達到切換顯示路由元件的效果

export default class xxx extends React.Component{
  static contextTypes = {
    history:PropTypes.object
  };
  render(){
    return (
      <a onClick={()=>this.context.history.push(this.props.to)}>
        {this.props.children}
      </a>
    )
  }
}
複製程式碼

MenuLink 元件

export default ({to,children})=>{
  return <Route path={to} children={props=>(
    <li className={props.match?"active":""}>
      <Link to={to}>{children}</Link>
    </li>
  )}/>
}
複製程式碼
<ul className='Nav'>
    <MenuLink to='/home'>首頁</MenuLink>
    <MenuLink to='/user'>使用者</MenuLink>
    <MenuLink to='/profile'>詳情</MenuLink>
</ul>
複製程式碼

這元件的作用即是讓匹配得上當前路由的link高亮

登入驗證與重定向

react-router知多少(一)

在介紹三個相關元件之前需要對Router中儲存的push方法做出調整,以便儲存Redirect跳轉前的路徑

...
push(path){
	if(typeof path === 'object'){
        let {pathname,state} = path;
        that.setState({location:{...that.state.location,state}},()=>{
          window.location.hash = pathname;
        })
    }else{
    	window.location.hash = path; //會自動新增'#'
    }
}
...
複製程式碼

Redirect 元件

export default class xxx extends React.Component {
  static contextTypes = {
    history:PropTypes.object
  }

  componentWillMount() {
    this.context.history.push(this.props.to);
  }

  render() {
    return null;
  }
}
複製程式碼

Protected 元件

export default function({component:Component,...rest}){
  return <Route {...rest} render={props=>(
    localStorage.getItem('login')?<Component {...props}/>:<Redirect to={{pathname:'/login',state:{from:props.location.pathname}}}/>
  )}/>;
}
複製程式碼

Login 元件

import React from 'react';

export default class xxx extends React.Component{
  handleClick=()=>{
    localStorage.setItem('login',true);
    this.props.history.push(this.props.location.state.from);
  }
  render(){
    return (
      <div>
        <button onClick={this.handleClick} className="btn btn-primary">登入</button>
      </div>
    )
  }
}
複製程式碼

Switch元件

<Router>
    <Route path='/a' component={myComponent1} />
    <Route path='/b' component={myComponent2} />
    <Route path='/c' component={myComponent3} />
</Router>
複製程式碼

通常情況下我們這樣寫Route有一點不好的是,不管第一個路由匹配沒匹配上,Router都會接著往下匹配,這樣就增加運算量。

So,Switch元件就是為了解決這個問題

<Router>
    <Switch>
        <Route path='/a' component={myComponent1} />
        <Route path='/b' component={myComponent2} />
        <Route path='/c' component={myComponent3} />
    </Switch>
</Router>
複製程式碼
export default class xxx extends React.Component{
  static contextTypes = {
    location:PropTypes.object
  }
  render(){
    console.log('Router render'); //只會列印一次
    let {pathname} = this.context.location;
    let children = this.props.children;
    for(let i=0;i<children.length;++i){
      let child = children[i]; //一個route
      let {path} = child.props;
      if(pathToRegexp(path,[],{end:false}).test(pathname)){
        return child;
      }
    }
    return null;
  }
}
複製程式碼

這樣只有一個Route會被初始化以及渲染

但,有一個bug,我們上面寫Route時,是將path轉正則的部分放在constructor裡的,這意味著只有在這個Route初始化的時候才會將path轉換為正則,這樣很好,只用計算一次,但和Switch搭配使用時就不好了,因為React的複用機制,即使路由路徑已經不一樣了,它仍然把上次的Route拿過來進行渲染,So此時的正則還是上一次的,也就不會被匹配上,嗯,bug。

解決方案:

  • 第一種,給Route增加key
  • 第二種,將正則替換的部分放在render中

獲取demo程式碼

倉庫地址:點我~點我!


推薦:

=== ToBeContinue ===

相關文章