1. 程式人生 > >開始一個React專案(三)路由基礎(v4)

開始一個React專案(三)路由基礎(v4)

前言

前端路由

開始今天的話題之前,讓我們先來了解一下前端路由,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擴展出來的和,這兩個就是我前面提到的前端路由的兩種解決辦法的各自實現。

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

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上面的。