前言
前端崛起后,Vue,React等框架大受欢迎,但是他们构建的单页应用有以下缺点
- 由于单页应用是一次性加载所有资源,所以首屏白屏时间会比较长
- 由于数据通过异步请求加载,所以不利于SEO
为了解决这些问题,我们可以采用服务端渲染的方式。使用服务端渲染,我们不能走回老路,所以产生了Vue的next.js和React的next.js等框架。但是,所谓“授人以鱼不如授人以渔”,我们不仅要学会使用第三方框架,还要学习其中的原理!
目标
- 简单服务端渲染
- 路由同构
- store同构
- css样式处理
- 404错误处理
简单服务端渲染
服务端渲染,服务端将HTML以字符串的形式返回给前端,前端去渲染。老式服务端渲染像jsp php那样,每次请求则刷新页面。而现在服务端渲染是使用node中间层去代替客户端请求数据渲染HTML,再发送内容给客户端
server
这里我们可以使用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搭建后台服务,处理请求
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配置分为服务端和客户端,这里我们先配置服务端,同时把两者相同部分抽离到webpack.base.js,使用webpack-merge插件进行合并
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,就可以监听并动态编译
"dev:build:server": "webpack --config ./webpack.server.js --watch"
至此,我们npm run dev:build:server便可得到编译后的bundle.js,此时我们的目录结构如下
node bundle.js启动项目,客户端访问3000端口,可以看到结果,但是点击按钮控制台并没有输出结果
client
后端无法处理事件绑定,这需要由客户端来处理。我们使用React16新提出的hydrate来完成这项任务,此方法由react-dom提供。他能代替之前的render方法,复用服务端传来内容,并绑定好事件
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进行以下配置
"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,可以看到以下结果
store同构
为了实现的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样式处理
webpack配置
一般我们处理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变为了红色
404错误处理
前面,我们同构好了路由,但是当我们访问/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:项目地址