前言
最近將公司專案的 react-router 從 v3 版本升到了 v4 版本,react-router v4 跟 v3 完全不相容,是一次徹底的重寫。這也給升級造成了極大的困難,與其說升級不如說是對 router 層重寫。之前我也將專案的 react 從 v15 版本升級到了 v16 版本,相較而言升級 react-router 比升級 react 困難多了。升級過程中踩了不少的坑,也有一些值得分享的點。寫成一篇小文,供大家參考。
依賴升級
react-router v4 跟 react 一樣拆成了兩部分,核心的 react-router 和依執行環境而定的 react-router-dom 或 react-router-native(跟 react-dom 和 react-native 一樣)。本文要說的是瀏覽器環境,也就是 react-router + react-router-dom
先安裝依賴(推薦使用 yarn)
1 |
yarn add react-router react-router-dom history |
為什麼要安裝 history 後面會解釋。
元件外導航與 react-router-redux
之前我們專案中使用了 react-router-redux 你有很多理由使用它,但對於我們來說唯一的理由或者用處就是用於在頁面元件之外導航,react-router-redux 讓你可以在任何地方通過 dispatch 處理頁面跳轉,如:store.dispatch(push(‘/’))。因為這個我們就必須使用 react-router-redux 嗎?當然不需要,有更簡單的辦法實現這個需求。所以這次升級我移除了react-router-redux, 寫作此文時支援 react-router v4 的 react-router-redux 還處於 v5.0.0-alpha.7 也是原因之一。
還記得之前安裝的 history 嗎?history 是 react-router 唯二的主要依賴之一,之所以要顯式安裝,是因為我們要使用它來實現頁面元件外導航。以下以 browser history 為例(hash history 和 memory history 都是一樣的):
我們不使用 react-router-dom 提供的 BrowserRouter 而是自己實現一個
1 2 3 4 5 6 |
// history.js import createHistory from 'history/createBrowserHistory'; const history = createHistory(); export default history; |
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// index.js import React from 'react'; import ReactDOM from 'react-dom'; import { Router } from 'react-router'; import history from './history'; import App from './app'; ReactDOM.render( <Router history={history}> <App /> </Router>, document.getElementById('app') ); |
搞定!就這麼簡單,這樣在任何地方只要引用 history 就可以使用它進行導航操作,如 history.push(‘/’),更多使用方式請參考 history 文件。其實 react-router-dom 的 BrowserRouter 跟我們做了同樣的事,區別在於我們這麼做能把 history 暴露出來。這個 history 就是頁面元件 props 裡面的 history 自然也就能做同樣的事情。
靜態配置
react-router v3 是面向配置的,元件寫法只是一種語法糖。而 react-router v4 是完全面向元件的,提供的 Route Switch 等都是真正的元件。這也就導致只能按元件的方式寫路由,不能寫配置。但是 v3 那樣的配置確實有一些方便之處,如統一管理、使用方便等。
多虧 JSX 靈活的語法,我們依然有辦法按配置的方式寫 react-router v4 的路由。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// routes.js import Home form './home'; import About form './about'; import Help form './help'; export default [{ path: '/', exact: true, component: Home }, { path: '/about', component: About }, { path: '/help', component: Help }]; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// app.js import React from 'react'; import { Switch, Route } from 'react-router'; import routes from './routes'; import NotFound from './not-found'; class App extends React.Component { render() { return ( <Switch> {routes.map((route, i) => <Route key={i} exact={!!route.exact} path={route.path} component={route.component} />)} <Route component={NotFound} /> </Switch> ); } } export default App; |
這樣我們就用配置的方式寫出了面向元件的路由,兼顧兩者的優點。如果有巢狀路由需求,可以參考官方示例。官方也提供了一個 react-router-config, 不過我沒有使用,一來覺得沒必要,二來寫作此文時它還處於 v1.0.0-beta.4 版本。
非同步元件與 Code Splitting
Web 應用最大的一個優勢就是不必下載整個應用,只用下載需要的部分就可以使用。要達到這樣的目標,就需要對程式碼進行分片,非同步載入元件。可惜 react-router v4 沒有像 v3 一樣提供載入非同步元件的介面。這部分工作就需要我們自己來處理。
我們可以建立一個高階元件 Bundle,專門用來載入非同步元件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// bundle.js import React from 'react'; class Bundle extends React.Component { constructor(props) { super(props); this.state = { Component: null }; props.load().then(Component => this.setState({ Component: Component.default })); } render() { const { load, ...props } = this.props; const Component = this.state.Component; return Component ? <Component {...props}/> : null; } } export default Bundle; |
然後修改一下 routes.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// routes.js import React from 'react'; import Bundle from './bundle'; export default [{ path: '/', exact: true, component(props) { // 這裡的 component 函式也是一個高階元件 return <Bundle {...props} load={() => import('./home')} />; } }, { path: '/about', component(props) { return <Bundle {...props} load={() => import('./about')} />; } }, { path: '/help', component(props) { return <Bundle {...props} load={() => import('./help')} />; } }]; |
這樣每個頁面都會打包成單獨的 JS,訪問相應頁面才會去非同步載入對應的元件。這樣也可以做精細化快取控制。
需要注意的是 import() 語法在寫作本文時還處於 Stage 2 的狀態,需給 Babel 新增 syntax-dynamic-import 外掛才能正常工作,另外需 webpack 2 及以上才支援。
查詢引數
因為各種原因 react-router v4 不再解析 ?key=value 這樣的 URL 的查詢引數,頁面元件 props.location 中只有 search 字串。這跟 v3 不相容,而且很不方便。我們有辦法相容一下嗎?當然有,這時候之前寫的 histroy.js 又有新的用處了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// history.js import qs from 'qs'; import createHistory from 'history/createBrowserHistory'; function addQuery(history) { const location = history.location; history.location = { ...location, query: qs.parse(location.search, { ignoreQueryPrefix: true }) }; } const history = createHistory(); addQuery(history); export const unlisten = history.listen(() => { // 每次頁面跳轉都會執行 addQuery(history); }); export default history; |
這樣我們就能在頁面元件 props.location.query 拿到解析好的 URL 查詢引數了,跟 v3 完美相容。還有個額外的好處是在任何地方引用 history 都可以拿到解析好的 URL 查詢引數。需要注意的是,在 history 的設計中,history 物件是 Mutable 的,所以我們可以直接修改 history。但是 history.location 是 Immutable 的,所以我們要確保每一個 location 物件都是全新的。
搭配 Redux
react-router v4 跟 redux 搭配有一個大坑(mobx 應該也有同樣的問題),詳情請看這篇文章,這裡就不再贅述。簡單來說,如果一個元件用 redux 的 connect 包裝過,又️不是 Route 的子元件,那麼 history 的變更就不會觸發這個元件的更新,它的子元件自然也不會更新。比如應用的根元件(上文的 App)。
解決方案也很簡單,可以用 react-router v4 提供的 withRouter 再包裝一遍:withRouter(connect(…)(App)),或者讓 App 做為 Router 的子元件,原理都一樣。我採用的後者。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// app.js import React from 'react'; import { connect } from 'react-redux'; import { Switch, Route } from 'react-router'; import routes from './routes'; import NotFound from './not-found'; class App extends React.Component { render() { return ( <Switch> {routes.map((route, i) => <Route key={i} exact={!!route.exact} path={route.path} component={route.component} />)} <Route component={NotFound} /> </Switch> ); } } function mapStateToProps(state) { return { someState: state.someState }; } export default connect(mapStateToProps)(App); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// index.js import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import { Router, Route } from 'react-router'; import store from './store'; import history from './history'; import App from './app'; ReactDOM.render( <Provider store={store}> <Router history={history}> <Route component={App} /> {/* 沒有 path 就會匹配所有路由 */} </Router> </Provider>, document.getElementById('app') ); |
最後
不得不說升級 react-router 很困難,坑也很多。但是把坑一個個填完,最終完美升級也是一件很有意思,很有成就感的事。希望這篇文章能對你有所幫助。
另外完整的 Demo 請戳我的 GitHub,喜歡的話點個 Star 吧 :P