前端崛起后,Vue,React等框架大受欢迎,可是他们构建的单页应用有如下缺点php
为了解决这些问题,咱们能够采用服务端渲染的方式。使用服务端渲染,咱们不能走回老路,因此产生了Vue的next.js
和React的next.js
等框架。可是,所谓“授人以鱼不如授人以渔”,咱们不只要学会使用第三方框架,还要学习其中的原理!css
服务端渲染,服务端将HTML以字符串的形式返回给前端,前端去渲染。老式服务端渲染像jsp
php
那样,每次请求则刷新页面。而如今服务端渲染是使用node中间层去代替客户端请求数据渲染HTML,再发送内容给客户端html
这里咱们可使用renderToString,这是由react-dom
提供的方法,它存在react-dom/server
下,它将组件以字符串形式返回。与renderToStaticMarkup
不一样的是,renderToString
返回的HTML会带有data-reactid
,而renderToStaticMarkup
没有。但在React16开始,为了HTML更加简洁,取消了全部标记,因此跟正常HTML相同前端
import React from 'react';
import { renderToString } from 'react-dom/server';
import Header from '../components/Header';
export default () => {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="app">${renderToString(<Header />)}</div>
</body>
</html>
`
}
复制代码
而后使用express
搭建后台服务,处理请求node
import express from 'express';
import render from './render';
const app = new express();
app.get('*', (req, res) => {
const html = render();
res.send(html)
})
app.listen(3000, () => {
console.log('server is running on port 3000');
})
复制代码
从上图能够看出,webpack配置分为服务端
和客户端
,这里咱们先配置服务端,同时把二者相同部分抽离到webpack.base.js
,使用webpack-merge
插件进行合并react
const path = require('path');
const webpackMerge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const baseConfig = require('./webpack.base.js');
const serverConfig = {
target: 'node', // 排除node内置模块,fs、path
mode: 'development',
entry: './src/server/index.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'build')
},
externals: [nodeExternals()] // 排除node_modules模块
}
module.exports = webpackMerge(baseConfig, serverConfig)
复制代码
另外,配置一下.babelrc
和package.json
。为pakage.json加上如下scripts
,就能够监听并动态编译webpack
"dev:build:server": "webpack --config ./webpack.server.js --watch"
复制代码
至此,咱们npm run dev:build:server
即可获得编译后的bundle.js,此时咱们的目录结构以下git
node bundle.js
启动项目,客户端访问3000端口,能够看到结果,可是点击按钮控制台并无输出结果
后端没法处理事件绑定,这须要由客户端来处理。咱们使用React16新提出的hydrate来完成这项任务,此方法由react-dom
提供。他能代替以前的render
方法,复用服务端传来内容,并绑定好事件github
import React from 'react';
import ReactDom from 'react-dom';
import Header from '../components/Header';
const App = function() {
return (
<Header /> ) } ReactDom.hydrate(<App />, document.getElementById('app')); 复制代码
而后添加客户端的webpack配置,经过webpack编译能够获得public
文件夹及内部index.js
。这里为了可以实时编译和编译后及时重启服务器,咱们须要对package.json
进行如下配置web
"scripts": {
"dev": "npm-run-all --parallel dev:**",
"dev:start": "nodemon --watch build --exec node \"./build/bundle.js\"",
"dev:build:server": "webpack --config ./webpack.server.js --watch",
"dev:build:client": "webpack --config ./webpack.client.js --watch"
},
复制代码
为了客户端能实现功能,咱们须要在server/render.js
内经过脚本引用客户端编译好的index.js
,以及让服务端响应静态资源请求
<script src="/index.js"></script>
复制代码
app.use(express.static('public'));
复制代码
至此,咱们npm run dev
即可并行编译及开启服务,请求3000端口,点击按钮就能够看到输出结果了!
这里咱们采用配置的方式构建路由
export default [
{
path: '/',
component: App,
routes: [
{
path: '/',
component: Home,
exact: true // 默认路由配置
},
{
path: '/login',
component: Login
}
]
}
]
复制代码
这种形式生成路由须要借助react-router-config
提供的renderRoutes方法,此方法最终会将路由配置文件转为如下形式
<Switch>
<Route path="/" component={App} />
const App = () => {
<div>
<Route exact path="/" component={Home} />
<Route path="/login" component={Login} />
</div>
}
</Switch>
复制代码
React中,通常客户端渲染时使用BrowserRouter,而服务端渲染,咱们须要使用react-router-dom
提供的无状态的StaticRouter。BrowserRouter会根据url来保持页面同步,而StaticRouter只会传入服务器提供的url,以便路由匹配
const App = (
<StaticRouter location={req.path}> <div> {renderRoutes(routes)} </div> </StaticRouter>
)
复制代码
固然,服务端修改了,为了达到hydrate
复用效果,那么客户端应该保持一致
const App = function() {
return (
<BrowserRouter> <div> { renderRoutes(routes) } </div> </BrowserRouter>
)
}
复制代码
到此,咱们路由同构完成,客户端访问http://127.0.0.1:3000/login
,能够看到如下结果
为了实现的SEO功能,服务端须要返回带有数据HTML字符串。首先,咱们先按老套路,构建好store
export
出构建好的store,而须要对其再包一层,这样就
不会是单例模式了。
export const getClientStore = () => {
return createStore(
reducer,
applyMiddleware(thunk)
)
}
export const getServerStore = () => {
return createStore(
reducer,
applyMiddleware(thunk)
)
}
复制代码
而后,将clientStore
与serverStore
分别经过Provider传给客户端和服务端的子组件。接着经过connect将容器组件与Home展现组件链接。npm run dev
后获得以下结果
componentDidMount
生命周期在服务端并无执行。因此咱们须要手动去触发
dispatch
,去给予
serverStore
数据。这里咱们经过将
loadData变量挂载到Home组件上,loadData方法返回的都是
Promise对象
Home.loadData = function(store) {
return store.dispatch(getCommentList())
}
复制代码
但是,这须要怎么去触发此方法呢?咱们能够在接收到相应的请求时去触发,那就把他放到路由配置上吧
{
path: '/',
component: Home,
loadData: Home.loadData,
exact: true // 默认路由配置
}
复制代码
接着,咱们须要根据路由去触发loadData
。这里咱们须要使用到react-router-config
提供的matchRoutes方法。此方法能够根据请求路径,配置到相应的路由,须要注意的是此处使用的是req.path而不是req.url
,由于req.url会带有query
参数。而后,咱们使用Promise.all去执行全部请求,全部请求结束后,此时store已经有数据了,再响应HTML给客户端
app.get('*', (req, res) => {
const store = getServerStore()
const matchedRoutes = matchRoutes(routes, req.path)
const promises = []
matchedRoutes.forEach(mRouter => {
if(mRouter.route.loadData) {
promises.push(mRouter.route.loadData(store))
}
})
Promise.all(promises)
.then(resArr => {
const html = render(req,store);
return res.send(html)
})
.catch(err => {
console.log('服务端出错:', err)
})
})
复制代码
此时,咱们能够看到服务端响应HTML中已经存在列表数据了
有数据 -> 空白 -> 有数据
。为了解决它,咱们须要
初始化clientStore。首先,咱们在HTML字符串中埋好数据
<script>
window.__context__ = {state: ${JSON.stringify(store.getState())}}
</script>
复制代码
而后在getClientStore
时,初始化store。createStore
能够传入三个参数,第二个参数用于初始化state,在使用了combineReducers时,其结构要和reducer结构一致
export const getClientStore = () => {
const defaultStore = window.__context__ || {}
return createStore(
reducer,
defaultStore.state,
applyMiddleware(thunk)
)
}
复制代码
OK,这样就不会存在空白闪烁间隔了。
通常咱们处理css样式,须要使用的插件是style-loader
,可是此插件在服务端的node环境是没法愉快玩耍的。咱们须要使用一个专门为服务端渲染而生的插件,即isomorphic-style-loader,具体用法可参见其官方文档。首先配置webpack.client.js
和webpack.server.js
,注意:此处须要开启CSS Modules
module:{
rules:[{
test:/\.css$/,
use: [
'isomorphic-style-loader',
{
loader: 'css-loader',
options: {
modules: true // 开启css模块化
}
}]
}]
}
复制代码
而后,修改一下render.js
,第一步引入StyleContext
import StyleContext from 'isomorphic-style-loader/StyleContext';
复制代码
第二步使用StyleContext
包裹住App,StyleContext.Provider
的value属性接收一个包含insertCss
的上下文对象,它主要是提供给后面所提到的Withstyles
const css = new Set()
const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss()))
const context = { insertCss }
const App = (
<StyleContext.Provider value={context}> <Provider store={store}> <StaticRouter location={req.path}> <div> {renderRoutes(routes)} </div> </StaticRouter> </Provider> </StyleContext.Provider> ) 复制代码
第三步,须要将css样式插入返回的HTML模板字符串
<style>${[...css].join('')}</style>
复制代码
既然服务端修改了,那么客户端也要跟上,咱们修改一下client/index.jsx
。此处的insertCss
与服务端的有点不一样,node环境下只能使用_getCss
方法,而此处使用的是_insertCss
,它相似于style.loader
的addStylesToDom
import StyleContext from 'isomorphic-style-loader/StyleContext';
const App = function() {
const insertCss = (...styles) => {
const removeCss = styles.map(style => style._insertCss())
return () => removeCss.forEach(dispose => dispose())
}
const context = { insertCss }
return (
<StyleContext.Provider value={context}> <Provider store={getClientStore()}> <BrowserRouter> <div> { renderRoutes(routes) } </div> </BrowserRouter> </Provider> </StyleContext.Provider> ) } 复制代码
全部配置完成,咱们能够开始使用了!首先,咱们引入withStyles
,这是一个高阶组件,内部有上文提到的_insertCss
方法
import withStyles from 'isomorphic-style-loader/withStyles';
复制代码
而后,引入css样式
并使用,须要注意的是此处不是直接import './Home.css'
,而是以模块的形式引入,这就是上文为什么要指明css须要开启模块化的缘由
import style from './Home.css';
<h3 className={style.title}>Home</h3>
复制代码
接着,咱们使用withStyles
包裹一下Home
组件,此处以柯里化的形式,第一个参数能够传入style序列
,第二参数传入组件
export default connect(mapStateToProps,
mapDispatchToProps)(withStyles(style)(Home));
复制代码
至此,咱们能够获得以下结果,能够看到Home title变为了红色
前面,咱们同构好了路由,可是当咱们访问/home
时,子页面为空白,并且响应状态是200,这就不对了!咱们并无设置/home
路由,虽然在/
时会出现Home页面内容,但路由是/
。因此,咱们须要处理一下这个问题,当没有路由匹配时,须要响应404
并返回404 not found
提示内容。
那么如何判断请求页面不存在呢?这时,咱们须要借助StaticRouter
的context属性。传入的context
能够在路由组件内获取到,咱们须要将404
页面放到最后,当路由匹配到此,咱们将NOT_FOUND变量挂载到context
。因此,咱们就能够经过context
上是否有NOT_FOUND
变量来判断请求页面是否存在
首先,配置404
页面,在路由最后位置添加
{
path: '*',
render: ({staticContext}) => {
if (staticContext) staticContext.NOT_FOUND = true
return <div>404 not found</div>
}
}
复制代码
而后,给render.js
内的StaticRouter
传入context
<StaticRouter location={req.path} context={ctx}>
<div>
{renderRoutes(routes)}
</div>
</StaticRouter>
复制代码
接着,在server/index.js
根据是否有NOT_FOUND
变量来判断是否响应404错误
const context = {}
const html = render(req, store, context);
if (context.NOT_FOUND) res.status(404)
return res.send(html)
复制代码
最后,咱们请求http://127.0.0.1:3000/home
能够看到页面显示以下
服务端渲染虽然能优化首屏加载速度,但若是数据请求时间较长也不会有显著效果。所以,是否采用服务端渲染还须要根据实际应用考虑。通常服务端渲染用在注重SEO的网站,或者增改删查等业务场景较多的后台管理系统等。
ps:项目地址