这篇文章是我本身在搭建我的网站的过程当中,用到了服务端渲染,看了一些教程,踩了一些坑。想把这个过程分享出来。 我会尽力把每一个步骤讲明白,将我理解的所有讲出来。css
文中的示例代码来自于这个仓库,也是我正在搭建的我的网站,你们能够一块儿交流一下。示例代码由于简化,因此与仓库代码有些许出入。另外,本身使用服务端渲染过程当中遇到的一些坑我也都记录在README里了。html
本文中用到的技术 React V16 | React-Router v4 | Redux | Redux-thunk | expressreact
服务端渲染的基本套路就是用户请求过来的时候,在服务端生成一个咱们但愿看到的网页内容的HTML字符串,返回给浏览器去展现。 浏览器拿到了这个HTML以后,渲染出页面,可是并无事件交互,这时候浏览器发现HTML中加载了一些js文件(也就是浏览器端渲染的js),就直接去加载。 加载好并执行完之后,事件就会被绑定上了。这时候页面被浏览器端接管了。也就是到了咱们熟悉的js渲染页面的过程。git
服务端渲染解决了首屏加载速度慢以及seo不友好的缺点(Google已经能够检索到浏览器渲染的网页,但不是全部搜索引擎均可以) 但增长了项目的复杂程度,提升维护成本。github
若是非必须,尽可能不要用服务端渲染express
须要两个端:服务端、浏览器端(浏览器渲染的部分) 第一: 打包浏览器端代码 第二: 打包服务端代码并启动服务 第三: 用户访问,服务端读取浏览器端打包好的index.html文件为字符串,将渲染好的组件、样式、数据塞入html字符串,返回给浏览器 第四: 浏览器直接渲染接收到的html内容,而且加载打包好的浏览器端js文件,进行事件绑定,初始化状态数据,完成同构redux
让咱们来看一个最简单的React服务端渲染的过程。 要进行服务端渲染的话那必然得须要一个根组件,来负责生成HTML结构数组
import React from 'react';
import ReactDOM from 'react-dom';
ReactDOM.hydrate(<Container />, document.getElementById('root'));
复制代码
固然这里用ReactDOM.render也是能够的,只不过hydrate会尽可能复用接收到的服务端返回的内容, 来补充事件绑定和浏览器端其余特有的过程promise
引入浏览器端须要渲染的根组件,利用react的 renderToString API进行渲染浏览器
import { renderToString } from 'react-dom/server'
import Container from '../containers'
// 产生html
const content = renderToString(<Container/>)
const html = `
<html>
<body>${content}</body>
</html>
`
res.send(html)
复制代码
在这里,renderToString也能够替换成renderToNodeStream,区别在于前者是同步地产生HTML,也就是若是生成HTML用了1000毫秒, 那么就会在1000毫秒以后才将内容返回给浏览器,显然耗时过长。然后者则是以流的形式,将渲染结果塞给response对象,就是出来多少就 返回给浏览器多少,能够相对减小耗时
通常场景下,咱们的应用不可能只有一个页面,确定会有路由跳转。咱们通常这么用:
import { BrowserRouter, Route } from 'react-router-dom'
const App = () => (
<BrowserRouter>
{/*...Routes*/}
<BrowserRouter/>
)
复制代码
但这是浏览器端渲染时候的用法。在作服务端渲染时,须要使用将BrowserRouter 替换为 StaticRouter 区别在于,BrowserRouter 会经过HTML5 提供的 history API来保持页面与URL的同步,而StaticRouter 则不会改变URL
import { createServer } from 'http'
import { StaticRouter } from 'react-router-dom'
createServer((req, res) => {
const html = renderToString(
<StaticRouter
location={req.url}
context={{}}
>
<Container />
<StaticRouter/>)
})
复制代码
这里,StaticRouter要接收两个属性:
数据的预获取以及脱水与注水我认为是服务端渲染的难点。
这是什么意思呢?也就是说首屏渲染的网页通常要去请求外部数据,咱们但愿在生成HTML以前,去获取到这个页面须要的全部数据, 而后塞到页面中去,这个过程,叫作“脱水”(Dehydrate),生成HTML返回给浏览器。浏览器拿到带着数据的HTML, 去请求浏览器端js,接管页面,用这个数据来初始化组件。这个过程叫“注水”(Hydrate)。完成服务端与浏览器端数据的统一。
为何要这么作呢?试想一下,假设没有数据的预获取,直接返回一个没有数据,只有固定内容的HTML结构,会有什么结果呢?
第一:因为页面内没有有效信息,不利于SEO。
第二:因为返回的页面没有内容,但浏览器端JS接管页面后回去请求数据、渲染数据,页面会闪一下,用户体验很差。
咱们使用Redux来管理状态,由于有服务端代码和浏览器端代码,那么就分别须要两个store来管理服务端和浏览器端的数据。
组件要在服务端渲染的时候去请求数据,能够在组件上挂载一个专门发异步请求的方法,这里叫作loadData,接收服务端的store做为参数, 而后store.dispatch去扩充服务端的store。
class Home extends React.Component {
componentDidMount() {
this.props.callApi()
}
render() {
return <div>{this.props.state.name}</div>
}
}
Home.loadData = store => {
return store.dispatch(callApi())
}
const mapState = state => state
const mapDispatch = {callApi}
export default connect(mapState, mapDispatch)(Home)
复制代码
由于服务端要根据路由判断当前渲染哪一个组件,能够在这个时候发送异步请求。因此路由也须要配置一下来支持loadData方法。服务端渲染的时候, 路由的渲染可使用react-router-config这个库,用法以下(重点关注在路由上挂载loadData方法):
import { BrowserRouter } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import Home from './Home'
export const routes = [
{
path: '/',
component: Home,
loadData: Home.loadData,
exact: true,
}
]
const Routers = <BrowserRouter>
{renderRoutes(routes)}
<BrowserRouter/>
复制代码
到了服务端,须要判断匹配的路由内的全部组件各自都有没有loadData方法,有就去调用, 传入服务端的store,去扩充服务端的store。*同时还要注意到,一个页面多是由多个组件组成的,*会发各自的请求,也就意味着咱们要等全部的请求都发完,再去返回HTML。
import express from 'express'
import serverRender from './render'
import { matchRoutes } from 'react-router-config'
import { routes } from '../routes'
import serverStore from "../store/serverStore"
const app = express()
app.get('*', (req, res) => {
const context = {css: []}
const store = serverStore()
// 用matchRoutes方法获取匹配到的路由对应的组件数组
const matchedRoutes = matchRoutes(routes, req.path)
const promises = []
for (const item of matchedRoutes) {
if (item.route.loadData) {
const promise = new Promise((resolve, reject) => {
item.route.loadData(store).then(resolve).catch(resolve)
})
promises.push(promise)
}
}
// 全部请求响应完毕,将被HTML内容发送给浏览器
Promise.all(promises).then(() => {
// 将生成html内容的逻辑封装成了一个函数,接收req, store, context
res.send(serverRender(req, store, context))
})
})
复制代码
细心的同窗可能注意到了上边我把每一个loadData都包了一个promise。
const promise = new Promise((resolve, reject) => {
item.route.loadData(store).then(resolve).catch(resolve)
console.log(item.route.loadData(store));
})
promises.push(promise)
复制代码
这是为了容错,一旦有一个请求出错,那么下边Promise.all方法则不会执行,因此包一层promise的目的是即便请求出错,也会resolve,不会影响到Promise.all方法, 也就是说只有请求出错的组件会没数据,而其余组件不会受影响。
咱们请求已经发出去了,而且在组件的loadData方法中也扩充了服务端的store,那么能够从服务端的数据取出来注入到要返回给浏览器的HTML中了。 来看 serverRender 方法
const serverRender = (req, store, context) => {
// 读取客户端生成的HTML
const template = fs.readFileSync(process.cwd() + '/public/static/index.html', 'utf8')
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path} context={context}>
<Container/>
</StaticRouter>
</Provider>
)
// 注入数据
const initialState = `<script>
window.context = {
INITIAL_STATE: ${JSON.stringify(store.getState())}
}
</script>`
return template.replace('<!--app-->', content)
.replace('<!--initial-state-->', initialState)
}
复制代码
通过上边的过程,咱们已经能够从window.context中拿到服务端预获取的数据了,此时须要作的事就是用这份数据去初始化浏览器端的store。保证两端数据的统一。
import { createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import rootReducer from '../reducers'
const defaultStore = window.context && window.context.INITIAL_STATE
const clientStore = createStore(
rootReducer,
defaultStore,// 利用服务端的数据初始化浏览器端的store
compose(
applyMiddleware(thunk),
window.devToolsExtension ? window.devToolsExtension() : f=>f
)
)
复制代码
至此,服务端渲染的数据统一问题就解决了,再来回顾一下整个流程:
这里还有个点,也就是当咱们从路由进入到其余页面的时候,组件内的loadData方法并不会执行,它只会在刷新,服务端渲染路由的时候执行。 这时候会没有数据。因此咱们还须要在componentDidMount中去发请求,来解决这个问题。由于componentDidMount不会在服务端渲染执行, 因此不用担忧请求重复发送。
以上咱们所作的事情只是让网页的内容通过了服务端的渲染,可是样式要在浏览器加载css后才会加上,因此最开始返回的网页内容没有样式,页面依然会闪一下。为了解决这个问题,咱们须要让样式也一并在服务端渲染的时候返回。
首先,服务端渲染的时候,解析css文件,不能使用style-loader了,要使用isomorphic-style-loader。
{
test: /\.css$/,
use: [
'isomorphic-style-loader',
'css-loader',
'postcss-loader'
],
}
复制代码
可是,如何在服务端获取到当前路由内的组件样式呢?回想一下,咱们在作路由的服务端渲染时,用到了StaticRouter,它会接收一个context对象,这个context对象能够做为一个载体来传递一些信息。咱们就用它!
思路就是在渲染组件的时候,在组件内接收context对象,获取组件样式,放到context中,服务端拿到样式,插入到返回的HTML中的style标签。
来看看组件是如何读取样式的吧:
import style from './style/index.css'
class Index extends React.Component {
componentWillMount() {
if (this.props.staticContext) {
const css = style._getCss()
this.props.staticContext.css.push(css)
}
}
}
复制代码
在路由内的组件能够在props里接收到staticContext,也就是经过StaticRouter传递过来的context, isomorphic-style-loader 提供了一个 _getCss() 方法,让咱们能读取到css样式,而后放到staticContext里。 不在路由以内的组件,能够经过父级组件,传递props的方法,或者用react-router的withRouter包裹一下
其实这部分提取css的逻辑能够写成高阶组件,这样就能够作到复用了
import React, { Component } from 'react'
export default (DecoratedComponent, styles) => {
return class NewComponent extends Component {
componentWillMount() {
if (this.props.staticContext) {
const css = styles._getCss()
this.props.staticContext.css.push(css)
}
}
render() {
return <DecoratedComponent {...this.props}/>
}
}
}
复制代码
在服务端,通过组件的渲染以后,context中已经有内容了,咱们这时候把样式处理一下,返回给浏览器,就能够作到样式的服务端渲染了
const serverRender = (req, store) => {
const context = {css: []}
const template = fs.readFileSync(process.cwd() + '/public/static/index.html', 'utf8')
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path} context={context}>
<Container/>
</StaticRouter>
</Provider>
)
// 通过渲染以后,context.css内已经有了样式
const cssStr = context.css.length ? context.css.join('\n') : ''
const initialState = `<script>
window.context = {
INITIAL_STATE: ${JSON.stringify(store.getState())}
}
</script>`
return template.replace('<!--app-->', content)
.replace('server-render-css', cssStr)
.replace('<!--initial-state-->', initialState)
}
复制代码
至此,服务端渲染就所有完成了。
React的服务端渲染,最好的解决方案就是Next.js。若是你的应用没有SEO优化的需求,又或者不太注重首屏渲染的速度,那么尽可能就不要用服务端渲染。 由于会让项目变得复杂。此外,除了服务端渲染,SEO优化的办法还有不少,好比预渲染(pre-render)。