前言
上个月公司进行官网改版,考虑需要seo,故无法使用纯单页模式,早期工作模式是前端提供静态页面,后端工作人员来套模版,但会导致代码不同步,且每一次的改动增加双方的改动量。也因为Jquery这种比较老的模式如今也被MVVM锁代替,因此打算直接上node来支撑前端服务去调用后端的restfulAPI,本文将着重讲解React服务端渲染( Server-Side Rendering )搭建要点。
服务端同构渲染好处
- 完整可索引的HTML页面,用于SEO,解决single-page中搜索引擎无法抓取页面内容
- 加速首屏渲染,无需像单页一样等待js全部加载
- 同构将使得代码更加易于维护,客户端和服务端共享部分代码
- 真正意义上的前端后分离,指责分工上更加明确
需要解决的问题
- 如何规划目录结构
- 如何实现同构
- 前后端路由如何匹配
- 如何设置不同的title, meta tags, keywords等
- 静态资源部署
- 服务端log日志
技术栈
- Node
- Express
- React/Redux/React-router
- React-helmet
- morgan
- webpack
原理
(注:图片来自网络)
目录结构
src
├── client 前端开发文件夹
│ └── browser.js 客户端渲染
├── components react组件
├── page page目录
│ ├── Home 页面相关资源
│ │ ├── less
│ │ ├── img
│ │ └── js
│ ├── About
│ └── ...
├── static 前端静态资源
│ ├── less
│ └── img
├── store 前端静态资源
│ └── store.js 前端redux状态控制存放
└── server 后端开发目录夹
├── api.js 后端restfulAPI的包装
├── routes.js 路由管理
└── temple.js 页面模版
webpack配置
公共
const webpack = require('webpack');
const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const nodeExternals = require('webpack-node-externals');
const isProduction = process.env.NODE_ENV === 'production';
const clientLoaders = isProduction ? [
new ExtractTextPlugin({
filename: 'index.css',
allChunks: true
}),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV)
}),
new webpack.optimize.OccurrenceOrderPlugin(),
new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false }, sourceMap: false })
] : [
new ExtractTextPlugin({
filename: 'index.css',
allChunks: true
})
];
server端
{
entry: './src/server.js',
output: {
path: path.resolve(__dirname, 'dist/'),
filename: 'server.js',
libraryTarget: 'commonjs2',
publicPath: '/'
},
target: 'node',
node: {
console: false,
global: false,
process: false,
Buffer: false,
__filename: false,
__dirname: false
},
externals: nodeExternals(),
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader'
},
{
test: /\.json$/,
loader: 'json-loader'
},
{
test: /\.less$/,
loader: 'ignore-loader'
}
]
}
}
client端
{
entry: {
app: './src/client/browser.js'
},
output: {
path: path.resolve(__dirname, '../static/resource/developer/webresource/'),
publicPath: "/developer/webresource/",
filename: '[name].js'
},
plugins: clientLoaders,
module: {
rules: [
{
test: /\.json$/,
loader: 'json-loader'
},{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},{
test: /\.less$/,
loader: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: ['css-loader?minimize','less-loader?minimize']
})
},{
test: /\.(png|jpe?g|gif)(\?.*)?$/,
loader: 'file-loader?limit=2048&name=assets/images/[name]_[hash:4].[ext]'
}, {
test: /\.(woff|eot|ttf|otf|svg)(\?.*)?$/,
loader: 'url',
query: {
limit: 10000,
name: 'images/fonts/[name].[hash:4].[ext]'
}
}
]
},
resolve: {
extensions: ['.js', '.jsx']
}
}
同构实现
在客户端,通过调用ReactDOM.hydrate方法把Virtual DOM转换成真实DOM最后渲染到界面。
ReactDOM.hydrate((<App />, document.getElementById('root'));
在服务端,通过调用ReactDOMServer.renderToString方法把Virtual DOM转换成HTML字符串返回给客户端,从而达到服务端渲染的目的。
const server = express();
server.use('*',(req,res,next)=>{
const jsx = (
<App />
);
const reactDom = renderToString( jsx );
res.end( renderPage( reactDom, reduxState, helmetData ) );
});
路由以及服务端异步获取数据实现
客户端路由可以不依赖服务端,根据hash方式或者调用history API,不同的URL渲染不同的视图,实现无缝的页面切换,用户体验极佳。但服务端渲染不同的地方在于,在渲染之前,必须根据URL正确找到相匹配的组件返回给客户端。
在服务端异步获取数据的实现中,我们在服务端上进行 API 调用,将结果存储在 Redux 中,然后使用再渲染携带着相关数据的完整的 HTML 渲染给客户端。
我们先创建一个routes文件
//routes.js
export default [
{
path: "/",
component: Home,
exact: true,
},
{
path: "/intro",
component: Intro,
exact: true,
},
{
path: "/intro/:id",
component: Intro,
exact: true,
},
{
path: "/agreement",
component:Agreement,
exact: true,
},
{
path: "/case",
component:Case,
exact: true,
}
];
然后我们静态声明每个组件的 data requirements:
import { fetchData } from "../store";
class Home extends React.Component {
/* ... */
render( ) {
const { circuits } = this.props;
return (
/* ... */
);
}
}
Home.serverFetch = fetchHome;
const mapStateToProps = ( state ) => ( {
circuits: state.data,
} );
const mapDispatchToProps = {
fetchHome,
};
export default connect(mapStateToProps, mapDispatchToProps)(Home);
fetchHome是自由命名,每个页面可对应不同的name。
注意,fetchHome是一个 Redux thunk action,当它被 dispatched 时,返回一个 Promise。
const server = express();
server.get('*',(req,res)=>{
const context = {};
const store = createStore();
store.dispatch(initializeSession());
const dataRequirements =
routes.filter( route => matchPath( req.url, route ) )
.map( route => route.component )
.filter( comp => comp.serverFetch )
.map( comp => store.dispatch( comp.serverFetch() ) );
Promise.all( dataRequirements ).then( ( ) => {
const jsx = (
<ReduxProvider store={ store }>
<StaticRouter context={ context } location={ req.url }>
<Layout />
</StaticRouter>
</ReduxProvider>
);
const reactDom = renderToString( jsx );
const reduxState = store.getState();
const helmetData = Helmet.renderStatic();
res.writeHead( 200, { "Content-Type": "text/html; charset=utf-8" } );
res.end( renderPage( reactDom, reduxState, helmetData ) );
});
});
可以看到,最终是收集了dataRequirements,并且等待所有 API 调用返回数据。最后,我们继续进行服务端渲染,这时 Redux 中已有数据可用了。
Helmet自定义<head>标签
既然要优化seo,因此路由到不同页面的时候必须去针对<head>标签配置不同的title, meta tags, keywords等。
react-helmet提供了很好的解决方案。并且对SSR支持友好。
import Helmet from "react-helmet";
export default class About extends Component {
render() {
return (
<Helmet>
<title>关于我们</title>
</Helmet>
)
}
}
/* ... */
const server = express();
server.get('*',(req,res)=>{
/*...*/
const dataRequirements =
routes.filter( route => matchPath( req.url, route ) )
.map( route => route.component )
.filter( comp => comp.serverFetch )
.map( comp => store.dispatch( comp.serverFetch() ) );
Promise.all( dataRequirements ).then( ( ) => {
const jsx = (
<ReduxProvider store={ store }>
<StaticRouter context={ context } location={ req.url }>
<Layout />
</StaticRouter>
</ReduxProvider>
);
const reactDom = renderToString( jsx );
const reduxState = store.getState();
const helmetData = Helmet.renderStatic();
res.writeHead( 200, { "Content-Type": "text/html; charset=utf-8" } );
res.end( renderPage( reactDom, reduxState, helmetData ) );
});
});
function renderPage( reactDom, reduxState, helmetData ) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
${ helmetData.title.toString( ) }
${ helmetData.meta.toString( ) }
<title>React SSR</title>
</head>
/* ... */
`;
}
资源静态化部署
考虑到静态资源需要进行CDN,因此静态资源单独抽离,进行部署,然后在模版中单独引入即可。
//template.js
const renderPage = (body,reduxState,helmetData) => {
return `
<!DOCTYPE html>
<html>
<head>
${helmetData.title.toString()}
<link rel="shortcut icon" href="//static.bimface.com/favicon.ico" type="image/x-icon"/>
<link rel="icon" href="//static.bimface.com/favicon.ico" type="image/x-icon"/>
<link rel="stylesheet" href="http://static.bimface.com/developer/webresource/index.css" />
</head>
<body>
<div id="root">${body}</div>
<script>
window.REDUX_DATA = ${JSON.stringify(reduxState)}
</script>
<script src="http://static.bimface.com/developer/webresource/app.js"></script>
</body>
</html>
`;
};
export default renderPage;
log日志引入
morgan是express默认的日志中间件,也可以脱离express,作为node.js的日志组件单独使用。
直接引用
const server = express();
server.use(morgan('combined'));
日志切割
线上应用的日志如果都存在同一个文件,时间久文件会变得很大,影响性能的同时也增加的查看成本。所以可以使用日志分割将每天的日志生成一个文件。
使用rotating-file-stream即可。
import rfs from 'rotating-file-stream';
const server = express();
var accessLogStream = rfs(`${new Date().getFullYear()}-${new Date().getMonth() + 1}-${new Date().getDate()}.log`, {
interval: '1d',
path: '/data/logs'
});
server.use(morgan('combined', { stream: accessLogStream }));