React Redux Sever Rendering(Isomorphic JavaScript)
前言
由于可能有些读者没听过 Isomorphic JavaScript 。因此在进到开发 React Redux Sever Rendering 应用程式的主题之前我们先来聊聊 Isomorphic JavaScript 这个议题。
根据 Isomorphic JavaScript 这个网站的说明:
Isomorphic JavaScript
Isomorphic JavaScript apps are JavaScript applications that can run both client-side and server-side.
The backend and frontend share the same code.
Isomorphic JavaScript 系指浏览器端和伺服器端共用 JavaScript 的程式码。
另外,除了 Isomorphic JavaScript 外,读者或许也有听过 Universal JavaScript 这个用词。那什麽是 Universal JavaScript 呢?它和 Isomorphic JavaScript 是指一样的意思吗?针对这个议题网路上有些开发者提出了自己的观点: Universal JavaScript、Isomorphism vs Universal JavaScript。其中 Isomorphism vs Universal JavaScript 这篇文章的作者 Gert Hengeveld 指出 Isomorphic JavaScript
主要是指前后端共用 JavaScript 的开发方式,而 Universal JavaScript
是指 JavaScript 程式码可以在不同环境下运行,这当然包含浏览器端和伺服器端,甚至其他环境。也就是说 Universal JavaScript
在意义上可以涵盖的比 Isomorphic JavaScript
更广泛一些,然而在 Github 或是许多技术讨论上通常会把两者视为同一件事情,这部份也请读者留意。
Isomorphic JavaScript 的好处
在开始真正撰写 Isomorphic JavaScript 前我们在进一步探讨使用 Isomorphic JavaScript 有哪些好处?在谈好处之前,我们先看看最早 Web 开发是如何处理页面渲染和 state 管理,还有遇到哪些挑战。
最早的时候我们谈论 Web 很单纯,都是由 Server 端进行模版的处理,你可以想成 template 是一个函数,我们传送资料进去,template 最后产生一张 HTML 给浏览器显示。例如:Node 使用的(EJS、Jade)、Python/Django 的 Template 或替代方案 Jinja、PHP 的 Smarty、Laravel 使用的 Blade,甚至是 Ruby on Rails 用的 ERB。都是由后端去 render 所有资料和页面,前端处理相对单纯。
然而随著前端工程的软体工程化和使用者体验的要求,开始出现各式前端框架的百花齐放,例如:Backbone.js、Ember.js 和 Angular.js 等前端 MVC (Model-View-Controller) 或 MVVM (Model-View-ViewModel) 框架,将页面于前端渲染的不刷页单页式应用程式(Single Page App)也因此开始流行。
后端除了提供初始的 HTML 外,还提供 API Server 让前端框架可以取得资料用于前端 template。複杂的逻辑由 ViewModel/Presenter 来处理,前端 template 只处理简单的是否显示或是元素迭代的状况,如下图所示:
然而前端渲染 template 虽然有它的好处但也遇到一些问题包括效能、SEO 等议题。此时我们就开始思考 Isomorphic JavaScript 的可能性:为什麽我们不能前后端都使用 JavaScript 甚至是 React?
事实上,React 的优势就在于它可以很优雅地实现 Server Side Rendering 达到 Isomorphic JavaScript 的效果。在 react-dom/server
中有两个方法 renderToString
和 renderToStaticMarkup
可以在 server 端渲染你的 components。其主要都是将 React Component 在 Server 端转成 DOM String,也可以将 props 往下传,然而事件处理会失效,要到 client-side 的 React 接收到后才会把它加上去(但要注意 server-side 和 client-side 的 checksum 要一致不然会出现错误),这样一来可以提高渲染速度和 SEO 效果。renderToString
和 renderToStaticMarkup
最大的差异在于 renderToStaticMarkup
会少加一些 React 内部使用的 DOM 属性,例如:data-react-id
,因此可以节省一些资源。
使用 renderToString
进行 Server 端渲染:
import ReactDOMServer from 'react-dom/server';
ReactDOMServer.renderToString(<HelloButton name="Mark" />);
渲染出来的效果:
<button data-reactid=".7" data-react-checksum="762752829">
Hello, Mark
</button>
总的来说使用 Isomorphic JavaScript 会有以下的好处:
- 有助于 SEO
- Rendering 速度较快,效能较佳
- 放弃蹩脚的 Template 语法拥抱 Component 元件化思考,便于维护
- 尽量前后端共用程式码节省开发时间
不过要注意的是如果有使用 Redux 在 Server Side Rendering 中,其流程相对複杂,不过大致流程如下:
由后端预先载入需要的 initialState,由于 Server 渲染必须全部都转成 string,所以先将 state 先 dehydration(脱水),等到 client 端再 rehydration(覆水),重建 store 往下传到前端的 React Component。
而要把资料从伺服器端传递到客户端,我们需要:
- 把取得初始 state 当做参数并对每个请求建立一个全新的 Redux store 实体
- 选择性地 dispatch 一些 action
- 把 state 从 store 取出来
- 把 state 一起传到客户端
接下来我们就开始动手实作一个简单的 React Server Side Rendering app
专案成果截图
Server Rendering
获取数据可以调用 action,routes 在服务器端的处理参考 react-router server rendering,在服务器端用一个 match 方法将拿到的 request url 匹配到我们之前定义的 routes,解析成和客户端一致的 props 对象传递给组件。
./devServer.js
var express = require('express');
var webpack = require('webpack');
var config = require('./webpack.config.dev');
import React from 'react';
import { renderToString } from 'react-dom/server';
import { RouterContext, match } from 'react-router';
import { Provider } from 'react-redux';
import createRouter from './client/routes';
import configureStore from './client/store';
var app = express();
var compiler = webpack(config);
import comments from './client/data/comments';
import posts from './client/data/posts';
// create an object for the default data
const defaultState = {
posts,
comments
};
app.use(require('webpack-dev-middleware')(compiler, {
noInfo: true,
publicPath: config.output.publicPath
}));
app.use(require('webpack-hot-middleware')(compiler));
function renderFullPage(html, initialState) {
return `
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>isomorphic-redux-app</title>
<link rel="shortcut icon" type="image/png" href="http://obl1r1s1x.bkt.clouddn.com/bitbug_favicon.ico"/>
</head>
<body>
<div id="root">${html}</div>
<script>
window.__INITIAL_STATE__ = ${JSON.stringify(initialState)};
</script>
<script src="/static/bundle.js"></script>
</body>
</html>
`;
}
app.use((req, res) => {
const store = configureStore(defaultState);
const routes = createRouter();
const state = store.getState();
match({ routes, location: req.url }, (err, redirectLocation, renderProps) => {
if (err) {
res.status(500).end(`Internal Server Error ${err}`);
} else if (redirectLocation) {
res.redirect(redirectLocation.pathname + redirectLocation.search);
} else if (renderProps) {
const html = renderToString(
<Provider store={store}>
<RouterContext {...renderProps} />
</Provider>
);
res.end(renderFullPage(html, store.getState()));
} else {
res.status(404).end('Not found');
}
});
});
app.listen(7770, 'localhost', function(err) {
if (err) {
console.log(err);
return;
}
console.log('Listening at http://localhost:7770');
});
服务器端渲染部分可以直接通过共用客户端 store.dispatch(action) 来统一获取 Store 数据。另外注意 renderFullPage 生成的页面 HTML 在 React 组件 mount 的部分(<div id="root">),前后端的 HTML 结构应该是一致的。然后要把 store 的状态树写入一个全局变量(INITIAL_STATE),这样客户端初始化 render 的时候能够校验服务器生成的 HTML 结构,并且同步到初始化状态,然后整个页面被客户端接管。