React躬行記(13)——React Router
在網路工程中,路由能保證資訊從源地址傳輸到正確地目的地址,避免在網際網路中迷失方向。而前端應用中的路由,其功能與之類似,也是保證資訊的準確性,只不過來源變成URL,目的地變成HTML頁面。
在傳統的前端應用中,每個HTML頁面都會對應一條URL地址,當訪問某個頁面時,會先請求伺服器,然後伺服器根據傳送過來的URL做出處理,再把響應內容回傳給瀏覽器,最終渲染整個頁面。這是典型的多頁面應用的訪問過程,由伺服器控制頁面的路由,而其中最令人詬病的是整頁重新整理,不僅存在著資源的浪費(像導航欄、側邊欄等通用部分不需要每次載入),並且讓使用者體驗也變得不再流暢。
為了彌補多頁面應用的不足,有人提出了另一種網站模型:單頁面應用(Single Page Application,簡稱SPA)。SPA類似於一個桌面應用程式,能根據URL分配控制器(即由JavaScript負責路由),動態載入適當的內容到頁面中,減少與伺服器之間的通訊次數,不再因為頁面切換而打斷使用者體驗。雖然名稱中包含“單頁”兩字,但瀏覽器中的URL地址還是會發生改變,在視覺上與多頁面保持同步。而實現SPA的關鍵就是路由系統,在React的技術棧中,官方給出了支援的路由庫:React Router,後文將會著重分析該庫。
當然,SPA也存在著自身的缺陷,例如不利於SEO、增加開發成本等,使用與否還是得看具體專案。
一、版本
在2015年的11月,官方釋出了React Router的第一個版本,實現了宣告式的路由。隨後在2016年,主版本號進行了兩次升級,一次是在2月的v2;另一次是在10月的v3。v3能夠相容v2,刪除了一些會引起警告的棄用程式碼,在未來只修復錯誤,所有的新功能都被新增到了2017年3月釋出的v4版本中。
v4不能相容v3,在內部完全重寫,推崇元件式應用開發,放棄了之前的靜態路由而改成動態路由的設計思路。所謂靜態路由是指事先定義好一堆路由配置,在應用啟動時,再將其載入,從而構建出一張路由表,記錄URL和元件之間的對映關係。雖然v4版本精簡了許多API,降低了學習成本,但是增加了專案升級的難度。
目前最新的版本已到v5,但官方團隊本來只是想釋出v4.4版本。由於人為的操作失誤,導致不得不撤銷v4.4,直接改成v5,因此其API能完全相容v4.x版本。React Router被拆分成了4個庫(包),如表3所列。
表3 React Router的四個庫
庫 | 描述 |
react-router | 提供核心的路由元件、物件與函式 |
react-router-dom | 提供瀏覽器所需的特定元件 |
react-router-native | 提供React Native所需的特定元件 |
react-router-config | 提供靜態路由的配置 |
當執行在瀏覽器環境中時,只需要安裝react-router-dom即可。因為react-router-dom會依賴react-router,所以預設就能使用react-router提供的API。
v5版本的React Router提供了三大類元件:路由器、路由和導航,將它們組合起來就能實現一套完整的路由系統,如圖11所示。首先根據URL導航到路由器中相應的路由,然後再渲染出指定的元件。
圖11 路由系統
二、路由器
Router是React Router提供的基礎路由器元件,一般不會直接使用。在瀏覽器執行環境中,通常引用的是封裝了Router的高階路由器元件:BrowserRouter或HashRouter。以BrowserRouter為例,其部分原始碼如下所示。
class BrowserRouter extends React.Component { history = createBrowserHistory(this.props); render() { return <Router history={this.history} children={this.props.children} />; } }
在v4.x的版本中,路由器元件可以包裹任意型別的子元素,但數量只能是一個,而在v5.0版本中已經解除了這個限制。下面的BrowserRouter元件包含了兩個子元素,如果將其執行於v4.x中,那麼將丟擲錯誤。
<BrowserRouter> <div>1</div> <div>2</div> </BrowserRouter>
1)history
每個路由器元件都會建立一個history物件,由它來管理會話歷史。history不但會監聽URL的變化,還能將其解析成location物件,觸發路由的匹配和相應元件的渲染。
history有三種形式,各自對應一種建立函式,應用於不同的路由器元件,具體如表4所示。其中MemoryRouter適用於非瀏覽器環境,例如React Native。
表4 history的三種形式
形式 | 建立函式 | 路由器元件 |
browserHistory | createBrowserHistory() | BrowserRouter |
hashHistory | createHashHistory() | HashRouter |
memoryHistory | createMemoryHistory() | MemoryRouter |
history會將瀏覽過的頁面組織成有序的堆疊,無論使用哪種history,其屬性和方法大部分都能保持一致。表5列出了history通用的API。
表5 history的屬性和方法
屬性和方法 | 描述 |
length | 堆疊長度 |
action | 執行的動作,例如PUSH、REPLACE等 |
location | 一個物件,儲存著當前頁面的狀態和URL資訊,形式如下程式碼所示,其中state屬性來自於push()或replace()的state引數。注意,在hashHistory中沒有key和state屬性 |
push(path, [state]) | 在棧頂新增一條新的頁面記錄 |
replace(path, [state]) | 替換當前的頁面記錄 |
go(number) | 跳轉到指定頁 |
goBack() | 上一頁,相當於go(-1) |
goForward() | 下一頁,相當於go(1) |
block(prompt) | 阻止跳轉 |
{ key: "z4ihbf", //唯一標識 pathname: "/libs/d.html" //路徑和檔名 search: "?page=1", //查詢字串 hash: "#form", //錨點 state: { //狀態物件 count: 10 } }
2)BrowserRouter
此元件會通過HTML5提供的History來保持頁面和URL的同步,其建立的URL格式如下所示。
http://pwstrick.com/page.html
如果使用BrowserRouter元件,那麼需要伺服器配合部署。以上面的URL為例,當頁面重新整理時,瀏覽器會向伺服器請求根目錄下的page.html,但根本就沒有這個檔案,於是頁面就會報404的錯誤。若要避免這種情況,就需要配置Web伺服器軟體(例如Nginx、自建的Node伺服器等),具體引數的配置可參考網上的資料。
BrowserRouter元件包含5個屬性,接下來將一一講解。
(1)basename屬性用於設定根目錄,URL的首部需要一個斜槓,而尾部則省略,例如“/pwstrick”,如下所示。
<BrowserRouter basename="/pwstrick" /> <Link to="/article" /> //渲染為<a href="/pwstrick/article">
(2)forceRefresh是一個布林屬性,只有當瀏覽器不支援HTML5的History時,才會設為true,從而可重新整理整個頁面。
(3)keyLength屬性是一個數字,表示location.key的長度。
(4)children屬性儲存著元件的子元素,這是所有的React元件都自帶的屬性。
(5)getConfirmation屬性是一個確認函式,可攔截Prompt元件,注入自定義邏輯。以下面程式碼為例,當點選連結企圖離開當前頁面時,會執行action()函式,彈出裡面的確認框,其提示就是Prompt元件message屬性的值,只有點選確定後才能進行跳轉(即導航)。
const action = (message, callback) => { const allowTransition = window.confirm(message); callback(allowTransition); } <BrowserRouter getUserConfirmation={action}> <div> <Prompt message="確認要離開嗎?" /> <Link to="page.html">首頁</Link> </div> </BrowserRouter>
3)HashRouter
此元件會通過window.location.hash來保持頁面和URL的同步,其建立的URL格式比較特殊,需要包含井號(#),如下所示。
http://pwstrick.com/#/page.html
在使用HashRouter時,不需要配置伺服器。因為伺服器會忽略錨點(即#/page.html),只會處理錨點之前的部分,所以重新整理上面的URL也不會報404的錯誤。
HashRouter元件包含4個屬性,其中3個與BrowserRouter元件相同,分別是basename、children和getUserConfirmation。獨有的hashType屬性用來設定hash型別,有三個關鍵字可供選擇,如下所列。
(1)slash:預設值,井號後面跟一個斜槓,例如“#/page”。
(2)noslash:井號後面沒有斜槓,例如“#page”。
(3)hashbang:採用Google風格,井號後面跟感嘆號和斜槓,例如“#!/page”。
三、路由
Route是一個配置路由資訊的元件,其職責是當頁面的URL能匹配Route元件的path屬性時,就渲染出對應的元件,而渲染方式有三種。接下來會講解Route元件的屬性、渲染方式以及其它的相關概念。
1)路徑
與路徑相關的屬性有3個,分別是path、exact和strict,接下來會一一講解。
(1)path是一個記錄路由匹配路徑的屬性,當路由器是BrowserRouter時,path會匹配location中的pathname屬性;而當路由器是HashRouter時,path會匹配location中的hash屬性。
path屬性的值既可以是普通字串,也可以是能被path-to-regexp解析的正則表示式。下面是一個示例,如果沒有特殊說明,預設使用的路由器是BrowserRouter。
<Route path="/main" component={Main} /> <Route path="/list/:page+" component={List} />
第一個Route元件能匹配“/main”或以“/main”為字首的pathname屬性,下面兩條URL能正確匹配。
http://www.pwstrick.com/main http://www.pwstrick.com/main/article
第二個Route元件能匹配以“/list”為字首的pathname屬性,下面兩條URL只能匹配第二條。
http://www.pwstrick.com/list http://www.pwstrick.com/list/1
React Router內部依賴了path-to-regexp庫,此庫定義了一套正則語法,例如命名引數、修飾符(*、+或?)等,具體規則可參考官方文件,本文不做展開。
在“/list/:page+”中,帶冒號字首的“:page”是命名引數,類似於一個函式的形參,可以傳遞任何值;正則末尾的加號要求至少匹配一個命名引數,沒有命名引數就匹配失敗。
注意,如果省略path屬性,那麼路由將總是匹配成功。
(2)exact是一個布林屬性,當設為true時,路徑要與pathname屬性完全匹配,如表6所示。
表6 exact屬性匹配說明
路徑 | pathname屬性 | exact屬性 | 是否匹配 |
/main | /main/article | true | 否 |
/main | /main/article | false | 是 |
(3)strict也是一個布林屬性,當設為true時,路徑末尾如果有斜槓,那麼pathname屬性匹配到的部分也得包含斜槓。在表7的第三行中,雖然pathname屬性的末尾沒有斜槓,但是依然能正確匹配。
表7 strict屬性匹配說明
路徑 | pathname屬性 | strict屬性 | 是否匹配 |
/main/ | /main | true | 否 |
/main/ | /main/ | true | 是 |
/main/ | /main/article | true | 是 |
如果將strict和exact同時設為true,那麼就可強制pathname屬性的末尾不能包含斜槓。例如pathname屬性的值為“/main/”,路徑為“/main”,此時匹配會失敗。
2)渲染方式
Route元件提供了3個用來渲染元件的屬性:component、render和children,每個屬性對應一種渲染方式,每種方式傳遞的props都會包含3個路由屬性:match、location和history。
(1)component屬性的值是一個元件(如下程式碼所示),當路由匹配成功時,會建立一個新的React元素(呼叫了React.createElement()方法)。
<Route path="/name" component={Name} />
如果元件以行內函數的方式傳給component屬性,那麼會產生不必要的重新掛載。對於內聯渲染,可以用render屬性替換。
(2)render屬性的值是一個返回React元素的行內函數,當路由匹配成功時,會呼叫這個函式,此時可以傳遞額外的引數進來,如下程式碼所示。由於React元素不會被反覆建立,因此不會出現重新掛載的情況。
<Route path="/name" render={(props) => { return <Name {...props} age="30">Strick</Name> }}/>
(3)children屬性的值也是一個返回React元素的行內函數,它的一大特點是無論路由是否匹配成功,這個函式都會被呼叫,該屬性的工作方式與render屬性基本一致。注意,當匹配不成功時,props的match屬性的值為null。
不要將3個渲染屬性應用於同一個Route元件,因為三種渲染方式有先後順序,component的優先順序最高,其次是render,最後是children。
三個路由屬性除了match之外,另外兩個location和history已在前文做過講解,接下來將重點分析match屬性。
Route會將路由匹配後的資訊記錄到match物件中,然後將此物件作為props的match屬性傳遞給被渲染的元件。match物件包含4個屬性,在表8中,不僅描述了各個屬性的作用,還在第三列記錄了點選read連結後,各個屬性被賦的值。
<Link to="/list/article/1">read</Link> <Route path="/list/:type" component={Name} />
表8 match物件的屬性
屬性 | 描述 | 示例中的值 |
params | 由路徑的引數名和解析URL匹配到的值組成的物件 | {type: "article"} |
isExact | 是否完全匹配,等同於Route的exact屬性 | false |
path | 要匹配的路徑,等同於Route的path屬性 | "/list/:type" |
url | 匹配到的URL部分 | "/list/article" |
3)Switch
如果將一堆Route元件放在一起(如下程式碼所示),那麼會對每個Route元件依次進行路由匹配,例如當前pathname的屬性值是“/age”,那麼被渲染的元件是Age1和Age3。
<Route path='/' component={Age1} /> <Route path='/article' component={Age2} /> <Route path='/:list' component={Age3} />
而如果將這三個Route用Switch元件包裹(如下程式碼所示),那麼只會對第一個路徑匹配的元件進行渲染。
<Switch> <Route path='/' component={Age1} /> <Route path='/article' component={Age2} /> <Route path='/:list' component={Age3} /> </Switch>
Switch的子元素既可以是Route,也可以是Redirect。其中Route元素匹配的是path屬性,而Redirect元素匹配的是from屬性。
4)巢狀路由
從v4版本開始,巢狀路由不再通過多個Route元件相互巢狀實現,而是在被渲染的元件中宣告另外的Route元件,以這種方式實現巢狀路由。下面用一個例子來演示巢狀路由,首先用Switch元件包裹兩個Route元件,第一個只有當處在根目錄時才會渲染Main元件,第二個路徑匹配成功渲染的是Children元件。
<Switch> <Route exact path='/' component={Main} /> <Route path='/list/:article' component={Children} /> </Switch>
然後定義Children元件,它也包含一個Route元件,從而形成了巢狀路由。注意,其路徑讀取了match物件的path屬性,通過沿用父路由中要匹配的路徑,可減少許多重複程式碼。
let Children = (props) => { return <Route path={`${props.match.path}/:id`} component={Article} />; }; let Article = (props) => { return <h5>文章內容</h5>; };
當pathname的屬性值是“/list/article/1”時,就能成功渲染出Article元件。
四、導航
當需要在頁面之間進行切換時,就該輪到Link、NavLink和Redirect三個導航元件登場了。其中Link和NavLink元件最終會被解析成HTML中的<a>元素。
1)Link
當點選Link元件時會渲染匹配路由中的元件,並且能在更新URL時,不過載頁面。它有兩個屬性:to和replace,其中to屬性用於定義導航地址,其值的型別既可以是字串,也可以是location物件(包含pathname、search等屬性),如下所示。
<Link to="/main">字串</Link> <Link to={{pathname: "/main", search: "?type=1"}}>物件</Link>
replace是一個布林屬性,預設值為false,當設為true時,能用新地址替換掉會話歷史裡的原地址。
2)NavLink
它是一個封裝了的Link元件,其功能包括定義路徑匹配成功後的樣式、限制匹配規則、優化無障礙閱讀等,接下來將依次講解多出的屬性。
首先是activeClassName和activeStyle,兩個屬性都會在路徑匹配成功時,賦予元素樣式(如下程式碼所示)。其中前者定義的是CSS類,預設值為“active”;後者定義的是內聯樣式,書寫規則可參照React元素的style屬性。
<style> .btn { color: blue; } </style> <NavLink to="/list" activeClassName="btn">CSS類</NavLink> <NavLink to="/list" activeStyle={{color: "blue"}}>內聯樣式</NavLink>
然後是exact和strict,兩個布林屬性的功能可分別參考Route元素的exact和strict,它們的用法相同。如果將exact和strict設為true(如下程式碼所示),那麼匹配規則會改變,其中前者要路徑完全匹配,後者得符合strict的路徑匹配規則。只有當匹配成功時,才能將activeClassName或activeStyle屬性的值賦予元素。
<NavLink to="/list" exact>完全</NavLink> <NavLink to="/list" strict>斜槓</NavLink>
接著是函式型別的isActive屬性,此函式能接收2個物件引數:match和location,返回一個布林值。在函式體中可新增路徑匹配時的額外邏輯,當返回值是true時,才能賦予元素定義的匹配樣式。注意,無論匹配是否成功,isActive屬性中的函式都會被回撥一次,因此如果要使用match引數,那麼需要做空值判斷(如下程式碼所示),以免出錯。
let fn = (match, location) => { if (!match) { return false } return match.url.indexOf("article") >= 0; }; <NavLink to="/list" isActive={fn}>函式</NavLink>
最後是兩個特殊功能的屬性:location和aria-current,前者是一個用於比對的location物件;後者是一個為存在視覺障礙的使用者服務的ARIA屬性,用於標記螢幕閱讀器可識別的導航型別,例如頁面、日期、位置等。可供選擇的關鍵字包括page、step、location、date、time和true,預設值為page。
3)Redirect
此元件用於導航到一個新地址,類似於服務端的重定向(HTTP的狀態碼為3XX),其屬性如表9所示。
表9 Redirect元素的屬性
屬性 | 描述 |
to | 重定向的目標地址,既可以是字串,也可以是location物件 |
from | 要重定向的路徑,只有匹配成功時,才能跳轉到to屬性中的目標地址 |
push | 布林屬性,當設為true時,重定向的新地址將會加入到會話歷史中 |
Redirect可與Switch搭配使用,如下程式碼所示,當URL與“/main”匹配時,重定向到“/page”,並渲染Page元件。
<Switch> <Redirect from="/main" to="/page" /> <Route path="/page" component={Page} /> </Switch>
五、整合Redux
第11篇中對Redux做過詳細講解,本節將通過一個示例分三步來描述React Router整合Redux的過程,第一步是建立Redux的三個組成部分:Action、Reducer和Store,如下所示。
function caculate(previousState = {digit: 0}, action) { //Reducer let state = Object.assign({}, previousState); switch (action.type) { case "ADD": state.digit += 1; break; case "MINUS": state.digit -= 1; } return state; } function add() { //Action建立函式 return {type: "ADD"}; } let store = createStore(caculate); //Store
1)withRouter
在說明第二步之前,需要先了解一下React Router提供的一個高階元件:withRouter。它能將history、location和match三個路由物件傳遞給被包裝的元件,其中match物件來自於離它最近的父級Route元件的match屬性。
正常情況下,只有Route要渲染的元件(例如下面的List)會自帶這三個物件,但如果List元件還有一個子元件,那麼這個子元件就無法自動獲取到這三個物件了,除非顯式地傳遞。
<Route path="/" component={List} />
在使用withRouter後,就能避免逐級傳遞。並且當把withRouter應用於react-redux庫中的connect()函式後(如下程式碼所示),就能讓函式返回的容器元件監聽到路由的變化。
withRouter(connect(...)(MyComponent))
2)路由
第二步就是建立路由,並自定義三個元件:Btn、List和Article。在Btn元件中聲明瞭Link和Route兩個元件,其中路由匹配成功後會渲染List元件;在List元件中聲明瞭WithArticle元件,而WithArticle就是通過withRouter包裝後的Article元件。
class Btn extends React.Component { render() { return ( <div> <Link to="/list">列表</Link> <Route path="/list" component={List} /> <button onClick={this.props.add}>提交</button> </div> ); } } let List = (props) => { return <WithArticle content="內容"/>; }; let Article = (props) => { const { match, location, history } = props; return <h5>{props.content}</h5>; }; let WithArticle = withRouter(Article); //withRouter包裝後的Article元件
3)渲染
第三步就是用react-redux庫中的Provider元件包裹BrowserRouter元件(即連線路由器),並注入Store,最後將眾元件渲染到頁面中。
let Smart = connect(state => state, { add })(Btn); //容器元件 let Router = <Provider store={store}> <BrowserRouter> <Smart /> </BrowserRouter> </Provider>; ReactDOM.render(Router, document.getElementById("container"));
&n