開始一個React項目(三)路由基礎(v4)

前言

react-router針對不同的使用場景衍生了不同的路由包,RN項目用react-router-native,web項目用react-router-dom。並且,不需要再重複引入react-router了。我搭建的是web項目環境,所以用的是react-router-dom

本節代碼是基於開始一個React項目(一)一個最簡單的webpack配置開始一個React項目(二) 徹底弄懂webpack-dev-server的熱更新搭建的環境開始的。

前端路由

開始今天的話題之前,讓我們先來了解一下前端路由,Ajax誕生以後,解決了每次用戶操作都要向服務器端發起請求重刷整個頁面的問題,但隨之而來的問題是無法保存Ajax操作狀態,瀏覽器的前進後退功能也不可用,當下流行的兩種解決方法是:
1. hash, hash原本的作用是爲一個很長的文檔頁添加錨點信息,它自帶不改變url刷新頁面的功能,所以自然而然被用在記錄Ajax操作狀態中了。
2. history, 應該說history是主流的解決方案,瀏覽器的前進後退用的就是這個,它是window對象下的,以前的history提供的方法只能做頁面之間的前進後退,如下:

  • history.go(number|URL) 可加載歷史列表中的某個具體的頁面
  • history.forward() 可加載歷史列表中的下一個 URL
  • history.back() 可加載歷史列表中的前一個 URL

爲了讓history不僅僅能回退到上一個頁面,還可以回到上一個操作狀態。HTML5新增了三個方法,其中兩個是在history對象裏的:

  • history.pushState(state, title, url)
    添加一條歷史記錄, state用於傳遞參數,可以爲空。title是設置歷史記錄的標題,可以爲空。url是歷史記錄的URL,不可以爲空。
  • history.replaceState(state, title, url)
    將history堆棧中當前的記錄替換成這裏的url,參數同上。

還有一個事件在window對象下:

window.onpopstate() 監聽url的變化,會忽略hash的變化(hash變化有一個onhashchange事件),但是前面的兩個事件不會觸發它。

好了,到這裏你大概猜到了單頁面應用或者Ajax操作記錄狀態用的就是hash和h5增加的history API,這就是react-router-dom 擴展的路由實現,也是web應用最常用的兩種路由。

靜態路由和動態路由

react-router v4是一個非常大的版本改動,具體體現在從“靜態路由”到“動態路由”的轉變上。一般將“靜態路由”看作一種配置,當啓動react項目時,會先生成好一個路由表,發生頁面跳轉時,react會根據地址到路由表中找到對應的處理頁面或處理方法。而動態路由不是作爲一個項目運行的配置文件存儲在外部,它在項目render的時候纔開始定義,router的作者認爲route應當和其它普通組件一樣,它的作用不是提供路由配置,而是一個普通的UI組件。而這也符合react的開發思想——一切皆組件。
由於我自己對之前版本的路由瞭解不多,這裏就不做比較了,有興趣的小夥伴可以自己去了解一下。這裏引一段router作者爲什麼要做這樣大的改動的解釋:

To be candid, we were pretty frustrated with the direction we’d taken React Router by v2. We (Michael and Ryan) felt limited by the API, recognized we were reimplementing parts of React (lifecycles, and more), and it just didn’t match the mental model React has given us for composing UI.
We ended up with API that wasn’t “outside” of React, an API that composed, or naturally fell into place, with the rest of React.
坦率地說,我們對於之前版本的Route感到十分沮喪,我和我的小夥伴意識到我們在重新實現react的部分功能,比如生命週期和其它更多的,但是這一點都不符合react的模型設計(UI組件)。我們真正想要開發出的不是脫離了react的API ,而是一個本身就屬於react一部分的API.這纔是我們想要的route(英語功底太差,大家將就着看吧)
——引自react-router的作者

安裝

正如我前面所說,對於web應用,我們只需要安裝react-router-dom

yarn add react-router-dom

不過在node_modules下你依然會看到react-router的身影,這是react-router-dom依賴的包,另外還有一個history包,這個下面會提到。

是實現路由最外層的容器,一般情況下我們不再需要直接使用它,而是使用在它基礎之上封裝的幾個適用於不同環境的組件,react-router-dom的Router有四種:

| Router | 適用情況
| ——– | :—– |
| BrowserRouter | react-router-dom擴展,利用HTML5 新增的history API (pushState, replaceState),是web應用最常用的路由組件|
| HashRouter | react-router-dom擴展,利用window.location.hash,適用於低版本瀏覽器或者一些特殊情境 |
| MemoryRouter | 繼承自react-router ,用戶在地址欄看不到任何路徑變化,一般用在測試或者非瀏覽器環境開發中 |
| StaticRouter | 繼承自react-router,某些頁面從渲染出來以後沒有多的交互,所以沒有狀態的變化需要存儲,就可以使用靜態路由,靜態路由適用於服務器端 |

備註一:有別於上面四個組件,這裏沒有列出來。

備註二:一般我們很少會用到,在web應用中更多的是用react-router-dom擴展出來的,這兩個就是我前面提到的前端路由的兩種解決辦法的各自實現。

爲了不被後面的一些配置弄迷糊,我們從的實現源碼來看看路由到底傳了些什麼東西。

Router.js

class Router extends React.Component {
  //檢測接收的參數
  static propTypes = {
    history: PropTypes.object.isRequired, //必須傳入
    children: PropTypes.node
  }

  //設置傳遞給子組件的屬性
  getChildContext() {
    return {
      router: {
        ...this.context.router, 
        history: this.props.history, //核心對象
        route: {
          location: this.props.history.location, //history裏的location對象
          match: this.state.match //當路由路徑和當前路徑成功匹配,一些有關的路徑信息會存放在這裏,嵌套路由會用到它。
        }
      }
    }
  }
    state = {
      match: this.computeMatch(this.props.history.location.pathname)
    }

  computeMatch(pathname) {
    return {
      path: '/',
      url: '/', 
      params: {}, //頁面間傳遞參數
      isExact: pathname === '/'
    }
  }
}

這裏面最重要的就是需要我們傳入的history對象,我前面提到過我們一般不會直接使用<Router>組件,因爲這個組件要求我們手動傳入history對象,但這個對象又非常重要,而且不同的開發環境需要不同的history,所以針對這種情況react-router才衍生了兩個插件react-router-domreact-router-native(我認爲這是比較重要的原因,瀏覽器有一個history對象,所以web應用的路由都是在此對象基礎上擴展的)。
接着讓我們來看一下react-router-dom用到的來自history的兩個方法:

  • createBrowserHistory 適用於現代瀏覽器(支持h5 history API)
  • createHashHistory 適用於需要兼容老版本瀏覽器的情況

這兩個方法就分別對應了兩個組件:<BrowserRouter><HashRouter>,它倆返回的history對象擁有的屬性是一樣的,但是各自的實現不同。

//createHashHistory.js
var HashChangeEvent = 'hashchange'; //hash值改變時會觸發該事件
var createHashHistory = function createHashHistory() {
  var globalHistory = window.history; //全局的history對象
  var handleHashChange = function handleHashChange() {} //hash值變化時操作的方法
}
//createBrowserHistory.js
var PopStateEvent = 'popstate'; //監聽url的變化事件
var HashChangeEvent = 'hashchange'; //依然監聽了hash改變的事件,但是多加了一個判斷是是否需要監聽hash改變,如果不需要就不綁定該事件。
var createBrowserHistory = function createBrowserHistory() {
  var globalHistory = window.history; //全局的history對象
  var handlePop = function handlePop(location) {} //出棧操作
}

//createHashHistory.js,createBrowserHistory.js導出的history對象
const history = {
    length: globalHistory.length, //globalHistory就是window.history
    action: "POP", //操作歷史狀態都屬於出棧操作
    location: initialLocation, //最重要的!!前面的Router.js源碼向子組件單獨傳遞了這個對象,因爲路由匹配會用到它。
    createHref, //生成的url地址樣式,如果是hash則加一個'#'
    push, //擴展history.pushState()方法
    replace, //擴展history.replaceState()方法
    go, //history.go()方法
    goBack, //history.back()方法
    goForward, //history.forward()方法
    block,
    listen
}

我們從控制檯打印一下看看這個history
image.png

所以,我們直接用<BrowserRouter>與使用<Router>搭配createBrowserHistory()方法是一樣的效果。

import {
    Router,
} from 'react-router-dom'
import createBrowserHistory from 'history/createBrowserHistory';

const history = createBrowserHistory();

const App = () => (
    <Router history={history}>
        <div>{/*其它*/}</div>
    </Router>
)

就等於:

import {
    BrowserRouter,
} from 'react-router-dom'

const App = () => (
    <BrowserRouter>
        <div>{/*其它*/}</div>
    </BrowserRouter>
)

和使用注意點
生成的url路徑看起來是這樣的:

http://localhost:8080/#/user

我們知道hash值是不會傳到服務器端的,所以使用hash記錄狀態不需要服務器端配合,但是生成的路徑是這樣的:

http://localhost:8080/user

這時候在此目錄下刷新瀏覽器會重新向服務器發起請求,服務器端沒有配置這個路徑,所以會出現can't GET /user這種錯誤,而解決方法就是,修改devServer的配置(前面我們配置了熱替換,其實就是用webpack-dev-server搭了一個本地服務器):
webpack.config.js

    devServer: {
        publicPath: publicPath,
        contentBase: path.resolve(__dirname, 'build'),
        inline: true,
        hot: true,  
        historyApiFallback: true, //增加
    },

還有一點需要注意的是只能有一個子孩子,這也符合React的規則。

小結:這裏講了這麼多還扯到了源碼估計你會覺得煩了,但是請相信,這些東西很有用,我自己在學習router的時候,一開始的狀態就是好像我知道怎麼用,咦?path是什麼?match是什麼?exact在不同的地方效果怎麼不一樣?match.url和match.path看起來一模一樣爲什麼用法不一樣?這麼多東西都是從哪裏來的?等我把router到底用的什麼在操作歷史狀態搞清楚了,接下來要學的知識就完全清晰了,到這裏爲止我其實已經斷斷續續花了一週多時間了,但這非常值得。

是路由配置的具體實現,它指定當路徑匹配的時候渲染哪一個UI,一個基本的路由配置如下:

    <Router>
        <div>
            <Route exact path="/" component={Home}/>
            <Route strict path="/login" render={() => <h1>Login</h1>} />
            <Route path="/user" children={() => <h1>User</h1>}/>
        </div>
    </Router>

path,exact,strict
path是用於指定路徑名的,exactstrict是匹配路徑名時指定更爲嚴格的匹配規則,其匹配原則用的是path-to-regexp

  1. 如果不寫path則總是能被匹配。
  2. 當exact爲true時只有path等於location.pathname時纔會匹配成功。location就是前面Router提到的location對象,我也在圖中框出來了。
  3. 當strict爲true時會嚴格驗證尾隨線,path和location.pathname都有或者都沒有才會匹配成功。

讓我們看幾個例子理解一下,注意以下例子exactstrict都是寫在<Route>裏的,<NavLink>也有這兩個值,寫在這兩個地方效果是不一樣的,後面會講<NavLink>.

path location.pathname exact match?
/ /user false yes
/ /user true no
/user /user/:name false yes
/user /user/:name true no

注:第三、四行是帶參數路由的寫法,後面會講。

總結:從表中可以看出,當一個路徑包含某一個路徑,暫且稱它們爲子路徑和父路徑,如果exact爲false(默認),那麼“子路徑”會渲染出“父路徑”的UI(所有的路徑都是’/’的子路徑)如果不想子路徑渲染出父路徑的UI,那麼就給父路徑添加exact屬性。所以表中一二行的exact是加在‘/’的裏,三四行是加在’/user’的裏。

path location.pathname strict match?
/user/ /user false yes
/user/ /user true no
/user /user/ true yes
/user/ /user/logout true or false yes

注意:表中第二三行的區別,即多餘的尾隨線加在location.pathname裏,那麼依然會匹配成功。

從第四行可以看出,path有尾隨線,location.pathname有二級路由,會被認爲也是有尾隨線的,所以會匹配成功,不過只需要再添加exact,那麼就無法匹配成功了。

component, render, children
component, render, children是渲染UI方法,它們的區別如下:
- component (最常用)當路徑匹配時渲染UI,內部實現用的是React.createElement()方法,即每一次都會觸發卸載和創建組件,如果渲染的UI沒有多餘的內容,推薦使用render。
- render 當路徑匹配時渲染UI,與component不同的是,它只調用render()方法去渲染組件,不會去重新創建元素,所以速度更快,只適用於行內渲染。
- children 與render類似,唯一的區別是不管路徑是否匹配都會渲染,所以它最適合用於做轉場動畫

這三個方法在渲染組件的同時還傳遞了幾個參數過去,這些參數也不是它的,是從前面傳下來的:

const props = { match, location, history, staticContext }

除了最後一個其它三個我們已經見過了,match來自Router.js,前面我也貼過源碼,historyloaction來自history插件的createBrowserHistory(或createHashHistory)方法,最後一個我暫時還不清楚怎麼用。現在,這幾個UI組件都可以訪問到這幾個對象了:

//component
<Route path="/user" component={User} />
//User.js
class User extends Component {
        let { match, location, history } = this.props;
    render() {
        return(
            <div className="user"></div>    
        ) 
    }
}
//render, children
<Route path="/user" render={(match, location, history) => <div></div>} />

當然,有個最簡單的方式就是直接傳一個props屬性過去,這幾個對象可以直接通過props屬性訪問:

//render, children
<Route path="/user" render={(props) => <User {...props}/>} />

它們有啥用?後面就知道了。

前面的<Route>提供了路由配置,<NavLink><Link>就是可以訪問這些路由的組件,也就是:
“`
Route path => path
//to可以是對象也可以是字符串
NavLink(Link) to => location or location.pathname

> 總結:整個路由棧匹配就是在圍繞`path`和`location.pathname`這兩個東西,其中,`<Route>`組件負責`path`, `<NavLink>`(`<Link>`)組件負責`location.pathname`。

一個簡單的`<NavLink>`示例:
  • Home

**<NavLink>和 <Link>的區別**
它倆都是`react-router-dom`提供的組件,`<NavLink>`是在`<Link>`上面擴展了當路由匹配時添加樣式屬性,而這更常用,所以建議直接使用`<NavLink>`.

`<Link>`提供的屬性及方法:
- `to [string]`: 路徑名
- `to [object]`: location對象,值如下:

{
pathname: ‘/’, //路徑名,
search: ”, //參數,會添加到url裏面,形如”?name=melody&age=20”
hash: ”, //參數,會添加到url裏面,形如”#tab1”
state: {},//參數,不會添加到url裏面
}

- `replace[bool]`: `false`, //是否替換當前路由,正常情況下是往路由棧裏新增一條數據,如果將此參數設置爲true,則會替換當前路由。

`<NavLink>`擴展的屬性及方法:
-  `activeClassName[string]`:` 'active'`, //路由匹配時添加的class,默認是active
-  `activeStyle[object]`: {}, //路由匹配時的樣式
- `exact[bool]`: 是否開啓嚴格模式
- `strict[bool]`:是否嚴格驗證尾隨線

**exact和strict**
我對於`<NavLink>`設置這兩個參數非常困惑,比如我遇到的一個坑:
![image.png](http://upload-images.jianshu.io/upload_images/5807862-dc9299246278a63e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
我已經設置了`<Route exact path="/" component={Home}/>`,並且在login頁也不會渲染出home頁的UI,但是我卻非常驚訝的發現當我使用了`<NavLink>`的選中樣式屬性時,在二級路由(圖中的User和Login)裏卻始終顯示着Home頁的選中樣式。後來我發現需要給匹配Home頁的`<NavLink>`也添加`exact`。而`strict`參數我也並沒有驗證出加與不加有何區別。

然後我又去源碼裏面找答案了:
[NavLink.js](https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/NavLink.js)

return (

我發現這倆參數依然是添加在了`<Route>`組件上,那爲什麼和之前`<Route>`的`exact`和`strict`參數表現會不一樣呢?這裏有一個關鍵屬性就是`isActive`,源碼中可以看到,某一個路由是否匹配完全取決於這個屬性。

前面我沒有提到`<NavLink>`還可以傳入一個方法:`isActive()`,源碼中的`getIsActive`對應的就是我們傳入的`isActive`方法,源碼中的`isActive`僅僅是一個布爾值。官網對`isActive()`方法的解釋是:
>` isActive[func]`:添加額外邏輯以確定路由是否處於被匹配狀態。 如果你想要做的不僅僅是驗證鏈接的路徑名是否與當前URL的路徑名相匹配,那麼應該使用它。

從源碼中可以看到:當不傳入`isActive()`方法時,`isActive`的取值就是`match`,`match`就比較好玩了,我在最前面提到過它最常用在嵌套路由中,當路由不匹配的時候,它的值爲null。當路由匹配時,它會長這樣:

match = {
isExact: true, //沒研究過,不知道幹啥用的
params: {}, //參數
path: “/”, //值就是的path值
url: “/” //值就是location.pathname
}

也就是說,假如你當前在`Login`頁下面,那麼`Login`頁的`match`對象有值,而別的頁面`Login`頁的`match`是null,但是這個別的頁面不包括首頁,如下:
![image.png](http://upload-images.jianshu.io/upload_images/5807862-bc8b8e4398c730c5.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

解決辦法就是給`<NavLink to="/">` 添加`exact`參數。

*注:exact和strict都是對正則匹配添加了別的驗證條件,react-router的路由匹配使用的是[path-to-regexp](https://www.npmjs.com/package/path-to-regexp)插件,我的正則一向很爛,這個東西我也沒去研究,所以這裏就不誤導大家了。*

### <Switch>
顧名思義`<Switch>`就是一個“開關”,它會在多個路由配置都可以匹配成功的時候只選擇第一個匹配上的渲染其UI,有的時候它也需要和`exact`配合使用,否則會有永遠匹配不上某個路由的情況發生。比如:




`List`的`<Route>`配置沒有加`exact`參數,所以在`ListDetails`頁也會渲染出`List`頁面,添加了`<Switch>`以後,根據`<Switch>`的工作原則,它只渲染第一個匹配成功的UI,這就會導致`ListDetails`永遠不會被渲染,而正確做法是給`List`添`exact`:





我覺得`<Switch>`最大的作用就是可以實現當所有路由都匹配不上的時候,可以顯示一個404頁面,也就是代碼中的`Error`頁。
> 注意:使用<Switch>路由配置的順序非常重要,因爲它會渲染第一個匹配上的,所以應該將最詳細的路由寫在前面,容易被配上的路由寫在後面。
### <Redirect>
重定向組件,它會從路由棧裏將當前路由替換爲它的路徑名,這也是它和`<NavLink>`的最大區別。


**to和push**
`to`屬性和`<NavLink>`的`to`一樣,可以爲`string`,也可以爲`object`,`string`時就是`location.pathname`,`object`時就是`location`對象。

`push`屬性對應`<NavLink>`的`replace`,`<Redirect>`默認行爲是替換路由,而`<NavLink>`默認行爲是新增一個路由,`push`和`replace`就是改變它們的默認行爲的參數。

**from**
指定一個路由名,當匹配到該路由時重定向到另一個路由上:




小結

大致上路由的知識點就這些了,還有一些不常用到的沒有提到,路由看着簡單但妙用很多,我自己也沒有研究得特別深入,只不過可以將自己開發過程中的一些經驗分享出來,本身也是和大家一起交流學習的嘛~
下一篇我會分享一些實用的例子,路由的代碼也會等下一篇文章完結後上傳到github上面的。

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