Github地址: https://github.com/chikara-chan/react-isomorphic-boilerplate
目录
- 前言
- 服务端渲染好处
- 思考
- 原理
- 同构方案
- 状态管理方案
- 路由方案
- 静态资源处理方案
- 动态加载方案
- 优化方案
- 部署方案
- 其它
- 结尾
前言
前段时间公司有一个产品需求要求使用Node.js中间层来做服务端渲染,于是翻遍了整个技术社区,没有找到一个特别合适的脚手架,作为一个有追求的前端攻城狮,决定自己去搭建一套最完美的服务端渲染开发环境,期间踩过无数的坑,前前后后差不多折腾了三周时间。
服务端渲染好处
- SEO,让搜索引擎更容易读取页面内容
- 首屏渲染速度更快(重点),无需等待js文件下载执行的过程
- 更易于维护,服务端和客户端可以共享某些代码
思考
- 如何实现组件同构?
- 如何保持前后端应用状态一致?
- 如何解决前后端路由匹配问题?
- 如何处理服务端对静态资源的依赖?
- 如何配置两套不同的环境(开发环境和产品环境)?
- 如何划分更合理的项目目录结构?
由于服务端渲染配置的复杂性,大部分人望而止步,而本文的目的就在于教你如何搭建一套优雅的服务端渲染开发环境,从开发打包部署优化到上线。
原理

一个服务端渲染的同构web应用架构图大致如上图所示,得力于Node.js的发展与流行,Javascript成为了一门同构语言,这意味着我们只需写一套代码,可以同时在客户端与服务端执行。
同构方案
这里我们采用React技术体系做同构,由于React本身的设计特点,它是以Virtual DOM的形式保存在内存中,这是服务端渲染的前提。
对于客户端,通过调用ReactDOM.render方法把Virtual DOM转换成真实DOM最后渲染到界面。
import { render } from 'react-dom'
import App from './App'
render(<App />, document.getElementById('root'))
对于服务端,通过调用ReactDOMServer.renderToString方法把Virtual DOM转换成HTML字符串返回给客户端,从而达到服务端渲染的目的。
import { renderToString } from 'react-dom/server'
import App from './App'
async function(ctx) {
await ctx.render('index', {
root: renderToString(<App />)
})
}
状态管理方案
我们选择Redux来管理React组件的非私有组件状态,并配合社区中强大的中间件Devtools、Thunk、Promise等等来扩充应用。当进行服务端渲染时,创建store实例后,还必须把初始状态回传给客户端,客户端拿到初始状态后把它作为预加载状态来创建store实例,否则,客户端上生成的markup与服务端生成的markup不匹配,客户端将不得不再次加载数据,造成没必要的性能消耗。
服务端
import { renderToString } from 'react-dom/server'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import App from './App'
import rootReducer from './reducers'
const store = createStore(rootReducer)
async function(ctx) {
await ctx.render('index', {
root: renderToString(
<Provider store={store}>
<App />
</Provider>
),
state: store.getState()
})
}
HTML
<body>
<div id="root"><%- root %></div>
<script>
window.REDUX_STATE = <%- JSON.stringify(state) %>
</script>
</body>
客户端
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import App from './App'
import rootReducer from './reducers'
const store = createStore(rootReducer, window.REDUX_STATE)
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
路由方案
�客户端路由的好处就不必多说了,�客户端可以不依赖服务端,根据hash方式或者调用history API,不同的URL渲染不同的视图,实现无缝的页面切换,用户体验极佳。但服务端渲染不同的地方在于,在渲染之前,必须根据URL正确找到相匹配的组件返回给客户端。
React Router为服务端渲染提供了两个API:
-
match
在渲染之前根据URL匹配路由组件 -
RoutingContext
以同步的方式渲染路由组件
服务端
import { renderToString } from 'react-dom/server'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import { match, RouterContext } from 'react-router'
import rootReducer from './reducers'
import routes from './routes'
const store = createStore(rootReducer)
async function clientRoute(ctx, next) {
let _renderProps
match({routes, location: ctx.url}, (error, redirectLocation, renderProps) => {
_renderProps = renderProps
})
if (_renderProps) {
await ctx.render('index', {
root: renderToString(
<Provider store={store}>
<RouterContext {..._renderProps} />
</Provider>
),
state: store.getState()
})
} else {
await next()
}
}
客户端
import { Route, IndexRoute } from 'react-router'
import Common from './Common'
import Home from './Home'
import Explore from './Explore'
import About from './About'
const routes = (
<Route path="/" component={Common}>
<IndexRoute component={Home} />
<Route path="explore" component={Explore} />
<Route path="about" component={About} />
</Route>
)
export default routes
静态资源处理方案
在客户端中,我们使用了大量的ES6/7语法,jsx语法,css资源,图片资源,最终通过webpack配合各种loader打包成一个文件最后运行在浏览器环境中。但是在服务端,不支持import、jsx这种语法,并且无法识别对css、image资源后缀的模块引用,那么要怎么处理这些静态资源呢?我们需要借助相关的工具、插件来使得Node.js解析器能够加载并执行这类代码,下面分别为开发环境和产品环境配置两套不同的解决方案。
开发环境
- 首先引入babel-polyfill这个库来提供regenerator运行时和core-js来模拟全功能ES6环境。
- 引入babel-register,这是一个require钩子,会自动对require命令所加载的js文件进行实时转码,需要注意的是,这个库只适用于开发环境。
- 引入css-modules-require-hook,同样是钩子,只针对样式文件,由于我们采用的是CSS Modules方案,并且使用SASS来书写代码,所以需要node-sass这个前置编译器来识别扩展名为.scss的文件,当然你也可以采用LESS的方式,通过这个钩子,自动提取className哈希字符注入到服务端的React组件中。
- 引入asset-require-hook,来识别图片资源,对小于8K的图片转换成base64字符串,大于8k的图片转换成路径引用。
// Provide custom regenerator runtime and core-js
require('babel-polyfill')
// Javascript required hook
require('babel-register')({presets: ['es2015', 'react', 'stage-0']})
// Css required hook
require('css-modules-require-hook')({
extensions: ['.scss'],
preprocessCss: (data, filename) =>
require('node-sass').renderSync({
data,
file: filename
}).css,
camelCase: true,
generateScopedName: '[name]__[local]__[hash:base64:8]'
})
// Image required hook
require('asset-require-hook')({
extensions: ['jpg', 'png', 'gif', 'webp'],
limit: 8000
})
产品环境
�对于产品环境,我们的做法是使用webpack�分别对客户端和服务端代码进行打包。客户端代码打包这里不多说,对于服务端代码,需要指定运行环境为node,并且提供polyfill,设置__filename和__dirname为true,由于是采用CSS Modules,服务端只需获取className,而无需加载样式代码,所以要使用css-loader/locals替代css-loader加载样式文件
// webpack.config.js
{
target: 'node',
node: {
__filename: true,
__dirname: true
},
module: {
loaders: [{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel',
query: {presets: ['es2015', 'react', 'stage-0']}
}, {
test: /\.scss$/,
loaders: [
'css/locals?modules&camelCase&importLoaders=1&localIdentName=[hash:base64:8]',
'sass'
]
}, {
test: /\.(jpg|png|gif|webp)$/,
loader: 'url?limit=8000'
}]
}
}
动态加载方案
对于大型Web应用程序来说,将所有代码�打包成一个文件不是一种优雅的做法,特别是�对于单页面应用,用户有时候并不想得到其余路由模块的内容,加载全部模块内容,不仅增加用户等待时间,而且会增加服务器负荷。Webpack提供一个功能可以拆分模块,每一个模块称为chunk,这个功能叫做Code Splitting。你可以在你的代码库中定义分割点,调用require.ensure,实现按需加载,而对于服务端渲染,require.ensure是不存在的,因此需要判断运行环境,提供钩子函数。
重构后的路由模块为
// Hook for server
if (typeof require.ensure !== 'function') {
require.ensure = function(dependencies, callback) {
callback(require)
}
}
const routes = {
childRoutes: [{
path: '/',
component: require('./common/containers/Root').default,
indexRoute: {
getComponent(nextState, callback) {
require.ensure([], require => {
callback(null, require('./home/containers/App').default)
}, 'home')
}
},
childRoutes: [{
path: 'explore',
getComponent(nextState, callback) {
require.ensure([], require => {
callback(null, require('./explore/containers/App').default)
}, 'explore')
}
}, {
path: 'about',
getComponent(nextState, callback) {
require.ensure([], require => {
callback(null, require('./about/containers/App').default)
}, 'about')
}
}]
}]
}
export default routes
优化方案
提取第三方库,命名为vendor
vendor: ['react', 'react-dom', 'redux', 'react-redux']
所有js模块以chunkhash方式命名
output: {
filename: '[name].[chunkhash:8].js',
chunkFilename: 'chunk.[name].[chunkhash:8].js',
}
提取公共模块,manifest文件起过渡作用
new webpack.optimize.CommonsChunkPlugin({
names: ['vendor', 'manifest'],
filename: '[name].[chunkhash:8].js'
})
提取css文件,以contenthash方式命名
new ExtractTextPlugin('[name].[contenthash:8].css')
模块排序、去重、压缩
new webpack.optimize.OccurrenceOrderPlugin(), // webpack2 已移除
new webpack.optimize.DedupePlugin(), // webpack2 已移除
new webpack.optimize.UglifyJsPlugin({
compress: {warnings: false},
comments: false
})
使用babel-plugin-transform-runtime取代babel-polyfill,可节省大量文件体积
需要注意的是,你不能使用最新的内置实例方法,例如数组的includes方法
{
presets: ['es2015', 'react', 'stage-0'],
plugins: ['transform-runtime']
}
最终打包结果

部署方案
对于客户端代码,将全部的静态资源上传至CDN服务器
对于服务端代码,则采用pm2部署,这是一个带有负载均衡功能的Node应用的进程管理器,支持监控、日志、0秒重载,并可以根据有效CPU数目以cluster的方式启动最大进程数目
pm2 start ./server.js -i 0

其它
提升开发体验
对于客户端代码,可以使用Hot Module Replacement技术,并配合koa-webpack-dev-middleware,koa-webpack-hot-middleware两个中间件,与传统的BrowserSync不同的是,它可以使我们不用通过刷新浏览器的方式,让js和css改动实时更新反馈至浏览器界面中。
app.use(convert(devMiddleware(compiler, {
noInfo: true,
publicPath: config.output.publicPath
})))
app.use(convert(hotMiddleware(compiler)))
对于服务端代码,则使用nodemon监听代码改动,来自动重启node服务器,相比supervisor,更加灵活轻量,内存占用更少,可配置性更高。
nodemon ./server.js --watch server
对于React组件状态管理,使用Redux DevTools这个中间件,它可以跟踪每一个状态和action,监控数据流,由于采用纯函数的编程思想,还具备状态回溯的能力。需要注意的是,React组件在服务端生命周期只执行到componentWillMount,因此要把该中间件挂载到componentDidMount方法上,避免在服务端渲染而报错。
class Root extends Component {
constructor() {
super()
this.state = {isMounted: false}
}
componentDidMount() {
this.setState({isMounted: true})
}
render() {
const {isMounted} = this.state
return (
<div>
{isMounted && <DevTools/>}
</div>
)
}
}
代码风格约束
推荐使用时下最为流行的ESLint,相比其它QA工具,拥有更多,更灵活,更容易扩展的配置,无论是对个人还是团队协作,引入代码风格检查工具,百益而无一害,建议你花个一天时间尝试一遍ESLint每一项配置,再决定需要哪些配置,舍弃哪些配置,而不是直接去使用Airbnb规范,Google规范等等。
Tips: 使用fix参数可快速修复一些常见错误,在某种程度上,可以取代编辑器格式化工具
eslint test.js --fix
�开发环境演示
Youtubee视频,自备梯子
https://www.youtube.com/watch?v=h3n3-v81PqY
结尾
时至今日,开源社区中并没有一个完美的服务端渲染解决方案,而当初搭建这个脚手架的目的就是从易用性出发,以最清晰的配置,用最流行的栈,组最合理的目录结构,给开发者带来最完美的开发体验,从开发打包部署优化到上线,一气呵成。即使你毫无经验,也可轻松入门服务端渲染开发。
附上源码: https://github.com/chikara-chan/react-isomorphic-boilerplate
网友评论
test:/\.html$/,
loader: 'html?minimize=false'
}
let
store=createStore(rootReducer,initialState,composeWithDevTools(applyMiddleware(...middleware)));
//热加载配置
if(module.hot) {
console.log(module.hot.status())
module.hot.accept('./reducers/index.js', () => {
store.replaceReducer(require('./reducers/index.js').default)
});
}
return store;
在更改reducer初始值时console.log中有
[HMR] bundle rebuilding
client.js?89b1:222 [HMR] bundle rebuilt in 193ms
process-update.js?6336:41 [HMR] Checking for updates on the server...
index.js?3239:3 reducer
process-update.js?6336:114 [HMR] Updated modules:
process-update.js?6336:116 [HMR] - ./client/store/reducers/home.js
process-update.js?6336:116 [HMR] - ./client/store/reducers/index.js
process-update.js?6336:121 [HMR] App is up to date.
但就是无法更新,这个我的demo地址,麻烦作者帮我看下。https://github.com/wd2010/webpack-demo
打包后的页面部分样式不正常,路由切换页面跳转基本都不能用了
报错如下:
manifest.304b6a77.js:1 Uncaught TypeError: Cannot read property 'call' of undefined
at c (manifest.304b6a77.js:1)
at Object.<anonymous> (bundle.be14aa0e.js:1)
at c (manifest.304b6a77.js:1)
at window.webpackJsonp (manifest.304b6a77.js:1)
at bundle.be14aa0e.js:1
Uncaught TypeError: Super expression must either be null or a function, not undefined
at a (vendor.71c36975.js:6)
at vendor.71c36975.js:6
at s (vendor.71c36975.js:6)
at Object.<anonymous> (vendor.71c36975.js:6)
at c (manifest.304b6a77.js:1)
at Object.<anonymous> (vendor.71c36975.js:1)
at c (manifest.304b6a77.js:1)
at Object.<anonymous> (vendor.71c36975.js:1)
at c (manifest.304b6a77.js:1)
at window.webpackJsonp (manifest.304b6a77.js:1)
推荐下,源码圈 300 胖友的书单整理:http://t.cn/R0Uflld
杰
Warning: React attempted to reuse markup in a container but the checksum was invalid. This generally means that you are using server rendering and the markup generated on the server was not what the client was expecting. React injected new markup to compensate which works but you have lost many of the benefits of server rendering. Instead, figure out why the markup being generated is different on the client or server:
(client) ta-reactid="15">Home123</section></div><
(server) ta-reactid="15">Home</section></div></ma
Error: @providesModule naming collision:
请问怎么解决呢?
重新npm install也不好使
这个是怎么调用到后台api的,在哪里配置的
例如 :https://github.com/erikras/react-redux-universal-hot-example
但是没有解决异步加载的问题。