iT邦幫忙

2017 iT 邦幫忙鐵人賽
DAY 14
0
Modern Web

30 天打造 MERN Stack Boilerplate系列 第 14

Day 14 - Infrastructure - Isomorphic Routing

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

Root Component

我們的 App 有數十個頁面,但最終必須要整合為一個 Root Component 才能掛在 React 的 Root Node 上面,因此 App 的雛形會用 Router 包住各個頁面的 Route,並且由於我們使用了 Redux,最外面還要再包一層 Provider 來注入 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-intl 這個模組,因此要再夾上一層 LocaleProvider

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] [2],所以在 Client Side Render 時必須要再呼叫一次 match

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.js

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.js

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

Webpack Code Splitting & Lazy Loading [3]

請別忘了我們一直以來都是在寫 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 設定 chunkFilename 就可以切割 bundle.js 了:

module.exports = {
  entry: '...',
  output: {
    path: '...',
    filename: 'bundle.js',
    chunkFilename: '[id].chunk.js', // <-- 加上這個設定
    publicPath: '...',
  },
};

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

使用 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.js

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

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

getChildRoutesgetIndexRoutegetComponent 是 React-Router 的非同步處理機制,我們在這裡面呼叫 require.ensure 來訂定切割點,require.ensure 在 Callback 中會傳入 require 參數,任何你想 Lazy Load 的模組請必須使用這個 require 載入。

require.ensure 是 Webpack 為了讓開發者實現 Code Splitting 而提供的 Function,不是 Node 內建的 Function,Client Side 是經由 Webpack 打包,所以可以順利執行 require.ensure,但 Server Side Render 時是使用 Node 在運行,Node 並不知道 require.ensure 是什麼東西,所以我們在使用它之前必須先進行 Polyfill,也就是第一段程式碼第一行的 import '../utils/ensure-polyfill';

最後補充說明一下,React-Router 在官方 Github 中也提供了 Huge Apps 範例,Boilerplate 中的寫法正是參考此範例而得的。

筆者心情小補帖

在我自己開發的一個實際專案中,一共切出了 34 個 Chunk:

14-1.png

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

參考資料

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

上一篇
Day 13 - Infrastructure - Error Handling
下一篇
Day 15 - Infrastructure - Form
系列文
30 天打造 MERN Stack Boilerplate30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言