30 天打造 MERN Stack Boilerplate
Day 14 - Infrastructure - Isomorphic Routing
為了實現 Isomorphic,我們的 Boilerplate 採用了 React-Router 來控制頁面的路由,另外還搭配了 react-router-reduxopen_in_new 將路由狀態同步至 Store 中。
我們的 App 有數十個頁面,但最終必須要整合為一個 Root Component 才能掛在 React 的 Root Node 上面,因此 App 的雛形會用 包住各個頁面的 ,並且由於我們使用了 Redux,最外面還要再包一層 來注入 Store:
import React from 'react';import { render } from 'react-dom';
// ...取得 render 所必需的變數
render( <Provider store={store}> <Router history={history}> <Route path="/" component={App}> <Route path="/another" component={AnotherApp}> // ... </Router> </Provider>), document.getElementById('root'));
另外,我們的 App 實做了 i18n,使用的是 react-intlopen_in_new 這個模組,因此要再夾上一層 :
render( <Provider store={store}> <LocaleProvider> <Router history={history}> <Route path="/" component={App}> <Route path="/another" component={AnotherApp}> // ... </Router> </LocaleProvider> </Provider>), document.getElementById('root'));
這個 Render 順序是固定的,先有 Redux Store,才能注入 Locale,並且 Providers 的順序優先於整個 App,所以要先 Render Provider,其次才是 App 的 Router。
一般如果沒有搭配 SSR,只有純粹的 Client Side Render 的話,只需要像下方這麼寫即可:
render( <Provider store={store}> <Router history={history}> {routes} </Router> </Provider>), document.getElementById('root'));
但是我們在 Boilerplate 中搭配了 Code Splitting 的技術(文末將會說明),這在 React-Router 上會產生一些 Issue [1]open_in_new [2]open_in_new,所以在 Client Side Render 時必須要再呼叫一次 :
import { match } from 'react-router';
// ...取得 render 所必需的變數
match({ history, routes,}, (error, redirectLocation, renderProps) => { render( <Provider store={store}> <LocaleProvider> <Router history={history} {...renderProps} > {routes} </Router> </LocaleProvider> </Provider> , document.getElementById('root'));});
Server Side 按照 React-Router 官方教學,本來就要使用 match,所以其實程式碼和 Client Side 相似:
match({ routes, history: req.history,}, (error, redirectLocation, renderProps) => { const finalState = req.store.getState(); const markup = '<!doctype html>\n' + renderToString( <Html initialState={finalState}> <Provider store={req.store}> <LocaleProvider> <RouterContext {...renderProps} /> </LocaleProvider> </Provider> </Html> ); res.send(markup);});
差別在於 Root Component 在 Provider 之外多包了一層 Server-Only 的元件 open_in_new,因為 App Render 出來其實都只是 Html 的 Body,而非完整結構的 Html,但 Server 的 Response 理論上必須要是完整結構的 Html,所以此處使用了 元件來產生完整 Html。
請別忘了我們一直以來都是在寫 SPA,整個 App 最終會被打包成一個 bundle.js,所以每當我們在 App 中多新增了一些 Route 或是 Component,打包出來的 bundle.js 就會變得越來越肥;另外,也許你寫出來的 App 裡有數十個 Components,但經常使用到的 Components 卻只有其中的兩三個,其餘的幾乎不會用到,但整個 App 已經被封裝起來了,無論是哪個使用者來操作 App 都得把整包 bundle.js 下載下來,非常浪費時間也浪費流量,第一次載入 App 時的體驗也會很糟。
上述情境是 SPA 的通病,所以無論你是寫 Angular 還是 React,都會有這樣的問題,不過讀者們不必擔心,Webpack 有所謂 Code Splitting 的技術,能夠將 App 切割成不同模組,只要在 webpack config 的 output 設定 就可以切割 bundle.js 了:
module.exports = { entry: '...', output: { path: '...', filename: 'bundle.js', chunkFilename: '[id].chunk.js', // <-- 加上這個設定 publicPath: '...', },};
React-Router 提供了非同步載入 Component、IndexRoute 還有 ChildRoutes 的功能,搭配 Code Splitting 的設定,我們就能透過程式碼將切割點切在 Route 與 Route 之間,讓每一個 Route 都打包成獨立的 Chunk。所以當使用者在 Client Side 路由到某個頁面前,React-Router 會先以非同步的方式從 Server 下載即將進入的頁面的 Chunk,如此就能實現頁面的 Lazy Loading。
讓我們看看 Boilerplate 中是怎麼切割 HomePage、Todolist 相關頁面還有 NotFoundPage 的吧!
import '../utils/ensure-polyfill';import AppLayout from '../components/layouts/AppLayout';
export default (store) => ({ path: '/', component: AppLayout, getChildRoutes(location, cb) { require.ensure([], (require) => { cb(null, [ require('./todo').default(store), require('./notFound').default(store), ]); }); }, getIndexRoute(location, cb) { require.ensure([], (require) => { cb(null, { component: require('../components/pages/HomePage').default, }); }); },});
export default (store) => ({ path: 'todo', getComponent(nextState, cb) { require.ensure([], (require) => { cb(null, require('../components/pages/todo/ListPage').default); }); },});
、、 是 React-Router 的非同步處理機制,我們在這裡面呼叫 來訂定切割點,require.ensure 在 Callback 中會傳入 參數,任何你想 Lazy Load 的模組請必須使用這個 require 載入。
require.ensure 是 Webpack 為了讓開發者實現 Code Splitting 而提供的 Function,不是 Node 內建的 Function,Client Side 是經由 Webpack 打包,所以可以順利執行 require.ensure,但 Server Side Render 時是使用 Node 在運行,Node 並不知道 require.ensure 是什麼東西,所以我們在使用它之前必須先進行 Polyfill,也就是第一段程式碼第一行的 。
最後補充說明一下,React-Router 在官方 Github 中也提供了 Huge Appsopen_in_new 範例,Boilerplate 中的寫法正是參考此範例而得的。
在我自己開發的一個實際專案中,一共切出了 34 個 Chunk:
這種 App 如果不用 Code Splitting 應該會很悲劇吧...