服务器端渲染
服务器端渲染除了要解决对浏览器环境的依赖,还要解决两个问题:
- 前后端可以共享代码
- 前后端路由可以统一处理
React 生态提供了很多选择方案,这里我们选用 和 react-router 来做说明。
提供了一套类似 Flux 的单向数据流,整个应用只维护一个 Store,以及面向函数式的特性让它对服务器端渲染支持很友好。
关于 Store:
- 整个应用只有一个唯一的 Store
- Store 对应的状态树(State),由调用一个 reducer 函数(root reducer)生成
- 状态树上的每个字段都可以进一步由不同的 reducer 函数生成
- Store 包含了几个方法比如
dispatch
,getState
来处理数据流 - Store 的状态树只能由
dispatch(action)
来触发更改
Redux 的数据流:
- action 是一个包含
{ type, payload }
的对象 - reducer 函数通过
store.dispatch(action)
触发 - reducer 函数接受
(state, action)
两个参数,返回一个新的 state - reducer 函数判断
action.type
然后处理对应的action.payload
数据来更新状态树
所以对于整个应用来说,一个 Store 就对应一个 UI 快照,服务器端渲染就简化成了在服务器端初始化 Store,将 Store 传入应用的根组件,针对根组件调用 renderToString
就将整个应用输出成包含了初始化数据的 HTML。
react-router
react-router 通过一种声明式的方式匹配不同路由决定在页面上展示不同的组件,并且通过 props 将路由信息传递给组件使用,所以只要路由变更,props 就会变化,触发组件 re-render。
假设有一个很简单的应用,只有两个页面,一个列表页 /list
和一个详情页 /item/:id
,点击列表上的条目进入详情页。
可以这样定义路由,./routes.js
Store 是由 reducer 产生的,所以 reducer 实际上反映了 Store 的状态树结构
./reducers/index.js
import listReducer from './list';
import itemReducer from './item';
export default function rootReducer(state = {}, action) {
return {
list: listReducer(state.list, action),
item: itemReducer(state.item, action)
};
}
rootReducer
的 state
参数就是整个 Store 的状态树,状态树下的每个字段对应也可以有自己的
reducer,所以这里引入了 listReducer
和 itemReducer
,可以看到这两个 reducer
的 state 参数就只是整个状态树上对应的 list
和 item
字段。
具体到 ./reducers/list.js
list 就是一个包含 items 的简单数组,可能类似这种结构:[{ id: 0, name: 'first item'}, {id: 1, name: 'second item'}]
,从 'FETCH_LIST_SUCCESS'
的 action.payload
获得。
然后是 ./reducers/item.js
,处理获取到的 item 数据
const initialState = {};
export default function listReducer(state = initialState, action) {
switch(action.type) {
case 'FETCH_ITEM_SUCCESS': return [...action.payload];
}
}
Action
对应的应该要有两个 action 来获取 list 和 item,触发 reducer 更改 Store,这里我们定义 fetchList
和 fetchItem
两个 action。
是一个前后端通用的 Ajax 实现,前后端要共享代码这点很重要。
我们用一个独立的 ./store.js
,配置(比如 Apply Middleware)生成 Store
import { createStore } from 'redux';
import rootReducer from './reducers';
// Apply middleware here
// ...
export default function configureStore(initialState) {
const store = createStore(rootReducer, initialState);
return store;
}
react-redux
接下来就是实现 <List>
,<Item>
组件,然后把 Redux 和 React 组件关联起来,具体细节参见 react-redux
./app.js
至此,客户端部分结束。
接下来的服务器端就比较简单了,获取数据可以调用 action,routes 在服务器端的处理参考 ,在服务器端用一个 match
方法将拿到的 request url 匹配到我们之前定义的 routes,解析成和客户端一致的 props 对象传递给组件。
./server.js
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { RoutingContext, match } from 'react-router';
import { Provider } from 'react-redux';
import routes from './routes';
import configureStore from './store';
const app = express();
function renderFullPage(html, initialState) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<body>
<div id="root">
<div>
${html}
</div>
</div>
window.__INITIAL_STATE__ = ${JSON.stringify(initialState)};
</script>
</body>
</html>
`;
}
app.use((req, res) => {
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 store = configureStore();
const state = store.getState();
Promise.all([
store.dispatch(fetchList()),
store.dispatch(fetchItem(renderProps.params.id))
])
.then(() => {
const html = renderToString(
<Provider store={store}>
<RoutingContext {...renderProps} />
</Provider>
);
res.end(renderFullPage(html, store.getState()));
});
} else {
res.status(404).end('Not found');
}
});
});
服务器端渲染部分可以直接通过共用客户端 store.dispatch(action)
来统一获取 Store 数据。另外注意 renderFullPage
生成的页面 HTML 在 React 组件 mount 的部分(<div id="root">
),前后端的 HTML 结构应该是一致的。然后要把 store
的状态树写入一个全局变量(__INITIAL_STATE__
),这样客户端初始化 render 的时候能够校验服务器生成的 HTML 结构,并且同步到初始化状态,然后整个页面被客户端接管。
最后关于页面内链接跳转如何处理?
react-router 提供了一个 <Link>
组件用来替代 <a>
标签,它负责管理浏览器 history,从而不是每次点击链接都去请求服务器,然后可以通过绑定 onClick
事件来作其他处理。
比如在 /list
页面,对于每一个 item 都会用 <Link>
绑定一个 route url:/item/:id
,并且绑定 onClick
去触发 获取数据,显示详情页内容。