前言
這幾個月身邊有好多的 projects 開始使用 React, 這一兩個月開始接觸到 Redux,覺得簡直驚為天人 XD。仔細讀了 Redux 的 source code 就發現他好強阿!覺得好像有更上一層樓的感覺。
這陣子用 Redux 做的東西有:
- 一個 desktop app (with Electron)
- 一個 universal webapp
- 把一個用 fluxxor 做的 universal webapp 接上 Redux, 讓之後的功能可以用 Redux 做,但又不用把之前的重寫。
我打算把一些東西整理一下寫幾篇文章, 目前預計把 Redux 相關的東西整理成三篇:
- Hello Redux: 關於 Redux 的一些基本介紹,包括我認知的 redux 的一些概念,和我覺得他設計得很棒的地方。
- Server Rendering: 介紹如何使用 Redux 和 react-router 做 server rendering
- Unit Test: 在測 Redux 的 code 的時候我遇到的問題和解決的方法,還有怎麼在測試的時候和 webpack loaders 和平共處。
本文來了
我打算從第二個部份 Server Rendering 開始,原因是我覺得 Redux 的介紹應該很多了。然後 Server rendering 這個部份比較有趣一點。
Server Rendering (universal/isomorphic)?
在講 Server Rendering 之前,我們應該先了解在這個東西出現之前我們遇到了什麼問題。
Single Page App
當瀏覽器的效能越來越好之後,漸漸地在做網站的時候,越來越多的邏輯從 server side 移到 client side, 也就是我們常聽到的 webapp 或者是 single-page app,javascript 的明星也由早期的 jQuery 變成了 Backbone.js, AngularJS, EmberJS 到最近的 React。
Single Page App 讓網站的角色變成一個獨立的 app,就像是 iOS 或者是 android app 那樣,用 API 來跟 server 溝通。如此一來有很多好處:從 user 的角度切入,user 不再需要一直 reload 網頁,大大增進的 user experience。從 loading 的角度切入,本來集中在 server 的 rendering work 現在移到了每一個 end user 的瀏覽器裡面。從開發流程的角度切入,server 和 client 從此有了統一的介面(API),不會再因為改動了 database 裡面的一個欄位就讓前端的 code 大壞。前端的 app 變成一個 self-contained 的東西,在維護上也大大地減低了困難。
但是 Single Page App 並不是完全沒有缺點。最主要的問題有二:
SEO 的困難。因為 single page app 的 html element 都是由前端的 javascript render 出來的,搜尋引擎的 crawer 無法看到這些,所以對於 crawer 來說,看到的總是一個空空如也的 html body。(但最近這個問題好像有了轉機)。
initial loading 很慢。Single Page App 要等 javascript 先下來之後,才由 javascript 再把 html render 出來。所以 user 一開始要等比較久才可以看到網頁的內容。
所以我們才要 Server Rendering 阿!
在這邊我們把整個 architecture 分三塊:一塊是 API server 負責提供 data, 再者是 web server 負責和 client share code 和 render html(就是我們下面指的 server), 最後是 client,也就是在瀏覽器裡面執行的 code。
architecture 可以參考 airbnb 的 blog
在這邊先用一張圖來呈現:
(圖片來源:airbnb)
Server Rendering 的意思是說,讓一部份的 code 先在 server 執行,然後當 server 把畫出一開始的畫面所需要的 data 拿到之後,把 initial page 的 html 先 render 好,並且把這包 html 和剛才拿到的 data 打包起來一起送給 client。
當 client 拿到了 initial page html (所以同時解決了 SEO 和一開始畫面要等很久的問題)和這包 data 之後,再接續著剛才 server 完成的進度繼續讓 app 運作下去。這個概念其實相當直覺,但 React 出現之後才重新被認真討論的原因是,因為 React 本身的設計可以讓這件事情可以更優雅地被完成。
簡而言之呢,要做到 Server Rendering 可以分成三個部份:
- 讓 server 拿到 render initial page 所需要的 data
- 利用這些 data 把 html render 出來
- 把這些 data 本身打包送到 client 去
終於要進入 Redux 了的主題惹
在下面我不會帶到全部的 code, 但下面的 code 都放在這邊
情境設定
假設我們現在有一個動態的頁面叫作 Question
,我們要 call 一個 API 把目前的 questions
拿下來然後把他 render 在畫面上。
我們有:
- 一個
questions
reducer - 一個
loadQuestions
action,會在 load 成功之後把 data 放進questions
reducer
Redux app 的進入點大概長這樣:
ReactDOM.render((
<Provider store={store}>
<Question />
</Provider>
), document.getElementById('root'));
containers/Question.js
大概長得像這樣:
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { loadQuestions } from 'actions/questions';
import _ from 'lodash';
class Question extends Component {
componentDidMount() {
this.props.loadQuestions();
}
render() {
return (
<div>
<h2>Question</h2>
{
_.map(this.props.questions, (q)=> {
return (
<p key={q.id}> { q.content }</p>
);
})
}
</div>
);
}
}
function mapStateToProps (state) {
return { questions: state.questions };
}
export { Question };
export default connect(mapStateToProps, { loadQuestions })(Question);
actions/questions.js
大概長這樣:
export const LOADED_QUESTIONS = 'LOADED_QUESTIONS';
export function loadQuestions() {
return function(getState, dispatch) {
request.get('http://localhost:3000/questions')
.end(function(err, res) {
if (!err) {
dispatch({ type: LOADED_QUESTIONS, response: res.body });
}
})
}
}
於是當 Question
mount 之後,就會送一個 API 給 server 把 data 拉下來, 進而 update view 了。
On Server Side
但是當我們要在 server 上 render 的時候,事情就變得比較複雜了。
我們預期最後做好的樣子是,server 的架構完成之後,是不需要隨著前端的邏輯改動而改變的。也就是說,這邊的 server 是不知道 component 的 business logic 的。
回顧一下我們要在 server 上做的三件事:
- 讓 server 拿到 render initial page 所需要的 data
- 利用這些 data 把 html render 出來
- 把這些 data 本身打包送到 client 去
首先要解決的問題是,當一個 request 進來的時候,我們怎麼知道要 call 哪一個 api 或者是要怎麼準備 state 呢?
第二個問題是,我們在 call 了 async 的 API 之後,要怎麼知道什麼時候 data 才準備好了呢?
第一個問題其實是和 routing 有關。以上面的 Question
component 為例,真正拿 data 的點是在它的 componentDidMount()
裡面。在大部份的情況,我們會需要一個 router 來控制 url 和對應 component 的關係。所以我的做法是,在每個 routing 的 leaf node (如果有巢狀 routing 的話,就是最裡面的那個) 放一個 static method fetchData()
, 這樣的話,在 server 我們就可以用 react-router 去 match 最後被 route 到的 component, 進而得到 data 的進入點。
第二個問題我的解法是用 promise, 意思是說,讓上述的 fetchData()
return 一個 promise, 當這個 promise resolve 的時候就代表所有 async 的動作都完成了,也就是 data 都 ready 了的意思。
這個部份在 server 的 code 大概長這樣:
import { RoutingContext, match } from 'react-router'
import createMemoryHistory from 'history/lib/createMemoryHistory';
import Promise from 'bluebird';
import Express from 'express';
let server = new Express();
server.get('*', (req, res)=> {
let history = createMemoryHistory();
let store = configureStore();
let routes = crateRoutes(history);
let location = createLocation(req.url)
match({ routes, location }, (error, redirectLocation, renderProps) => {
if (redirectLocation) {
res.redirect(301, redirectLocation.pathname + redirectLocation.search)
} else if (error) {
res.send(500, error.message)
} else if (renderProps == null) {
res.send(404, 'Not found')
} else {
let [ getCurrentUrl, unsubscribe ] = subscribeUrl();
let reqUrl = location.pathname + location.search;
getReduxPromise().then(()=> {
let reduxState = escape(JSON.stringify(store.getState()));
let html = ReactDOMServer.renderToString(
<Provider store={store}>
{ <RoutingContext {...renderProps}/> }
</Provider>
);
res.render('index', { html, reduxState });
});
function getReduxPromise () {
let { query, params } = renderProps;
let comp = renderProps.components[renderProps.components.length - 1].WrappedComponent;
let promise = comp.fetchData ?
comp.fetchData({ query, params, store, history }) :
Promise.resolve();
return promise;
}
}
});
});
server view template index.ejs
<!DOCTYPE html>
<html>
<head>
<title>Redux real-world example</title>
</head>
<body>
<div id="root"><%- html %></div>
<script type="text/javascript" charset="utf-8">
window.__REDUX_STATE__ = '<%= reduxState %>';
</script>
<script src="http://localhost:3001/static/bundle.js"></script>
</body>
</html>
然後在 containers/Question.js
裡的加一個 fetchData()
static method:
class Question extends Component {
static fetchData({ store }) {
// return a promise here
}
// ...
}
然後到這裡,我們就遇到了第三個問題…
就是在 fetchData()
裡面,我們要怎麼 reuse 本來的 action (loadQuestions()
) 同時又 return 一個 promise 呢?
這個問題我的解法是做一個 middleware, 在這個 middleware 裡面去 call api,然後從這個 middleware return 一個 promise 給 fetchData()
。
這個 middleware 大概長這樣:
middleware/api.js
import { camelizeKeys } from 'humps';
import superAgent from 'superagent';
import Promise from 'bluebird';
import _ from 'lodash';
export const CALL_API = Symbol('CALL_API');
export default store => next => action => {
if ( ! action[CALL_API] ) {
return next(action);
}
let request = action[CALL_API];
let { getState } = store;
let deferred = Promise.defer();
// handle 401 and auth here
let { method, url, successType } = request;
superAgent[method](url)
.end((err, res)=> {
if ( !err ) {
next({
type: successType,
response: res.body
});
if (_.isFunction(request.afterSuccess)) {
request.afterSuccess({ getState });
}
}
deferred.resolve();
});
return deferred.promise;
};
如此一來,本來的 action 就可以改成:
actions/questions.js
export const LOADED_QUESTIONS = 'LOADED_QUESTIONS';
export function loadQuestions() {
return {
[CALL_API]: {
method: 'get',
url: 'http://localhost:3000/questions',
successType: LOADED_QUESTIONS
}
};
}
然後 fetchData()
就變成這樣:
containers/Question.js
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { loadQuestions } from 'actions/questions';
import _ from 'lodash';
class Question extends Component {
static fetchData({ store }) {
return store.dispatch(loadQuestions());
}
//...
}
然後我們就搞定了,YA!
總結
這陣子做 universal rendering 的感想是,universal rendering 的效果確實是蠻不錯的。就是在一開始 load 網頁的時候真的會有 "哇好快!"的感覺。但是另一方面,在開發的時候確實也因為這樣,會要在很多地方多花一些工夫設計和繞開一些東西 (像是 webpack 的各種帥氣 loader 等等)。
我個人的感覺是,綜合各種 trade-off 下來之後,做 universal rendering 還算是一個划算的選擇。
註:上面的 code 可以到這邊去看,跑起來之後請找 http://localhost://3000/q/1/hello
。耶!