folder_open

30 天打造 MERN Stack Boilerplate

arrow_right
article

Day 14 - Infrastructure - Isomorphic Routing

Day 14 - Infrastructure - Isomorphic Routing

為了實現 Isomorphic,我們的 Boilerplate 採用了 React-Router 來控制頁面的路由,另外還搭配了 react-router-reduxopen_in_new 將路由狀態同步至 Store 中。

Root Component

#

我們的 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。

Client Side Render

#

一般如果沒有搭配 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'));
});

完整程式碼:src/client/index.jsopen_in_new

Server Side Render

#

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);
});

完整程式碼:src/server/controllers/react.jsopen_in_new

差別在於 Root Component 在 Provider 之外多包了一層 Server-Only 的元件 open_in_new,因為 App Render 出來其實都只是 Html 的 Body,而非完整結構的 Html,但 Server 的 Response 理論上必須要是完整結構的 Html,所以此處使用了 元件來產生完整 Html。

Webpack Code Splitting & Lazy Loading [3]open_in_new

#

請別忘了我們一直以來都是在寫 SPA,整個 App 最終會被打包成一個 bundle.js,所以每當我們在 App 中多新增了一些 Route 或是 Component,打包出來的 bundle.js 就會變得越來越肥;另外,也許你寫出來的 App 裡有數十個 Components,但經常使用到的 Components 卻只有其中的兩三個,其餘的幾乎不會用到,但整個 App 已經被封裝起來了,無論是哪個使用者來操作 App 都得把整包 bundle.js 下載下來,非常浪費時間也浪費流量,第一次載入 App 時的體驗也會很糟。

Code Splitting 設定

#

上述情境是 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: '...',
},
};

完整程式碼:configs/env/webpack.config.dev.jsopen_in_new

使用 Code Splitting 實作頁面的 Lazy Loading

#

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,
});
});
},
});

完整程式碼:src/common/routes/index.jsopen_in_new

export default (store) => ({
path: 'todo',
getComponent(nextState, cb) {
require.ensure([], (require) => {
cb(null, require('../components/pages/todo/ListPage').default);
});
},
});

完整程式碼:src/common/routes/todo.jsopen_in_new

是 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:

14-1.png

這種 App 如果不用 Code Splitting 應該會很悲劇吧...

參考資料

#

  1. React-Router: 如果存在异步路由,服务器端和客户端都需要用 “match”open_in_new
  2. React Router Example: Server Rendering Lazy Routesopen_in_new
  3. Code Splittingopen_in_new