【个人网站】本文中用到的技术ReactV16|
优采云 发布时间: 2021-08-04 18:26【个人网站】本文中用到的技术ReactV16|
欢迎访问个人网站:
前言
这个文章是我自己搭建自己的网站的过程,使用了服务端渲染,看了一些教程,踩了一些坑。我想分享这个过程。
我会尽量把每一步都解释清楚,把我理解的都说出来。
文章中的示例代码来自这个仓库,也是我正在搭建的个人网站。你可以一起分享。由于简化,示例代码与仓库代码略有不同。另外,我也在README中记录了在使用服务端渲染过程中遇到的一些坑。
本文使用的技术
React V16 |反应路由器 v4 |还原 | Redux-thunk |快递
React 服务端渲染
服务端渲染的基本套路是当用户请求时,在服务端生成一个我们想要看到的网页内容的HTML字符串返回给浏览器显示。
浏览器拿到HTML后,渲染页面,但是没有事件交互。这时候浏览器发现html里面加载了一些js文件(也就是浏览器端渲染的js),直接加载了。
加载并执行后,事件会被绑定。此时页面被浏览器接管。也就是我们熟悉的js渲染页面的过程。
要实现的目标:通用渲染方法的优缺点
服务端渲染解决了首屏加载速度慢和SEO不友好的缺点(谷歌已经可以检索浏览器渲染的网页,但不是所有搜索引擎都可以)
但它增加了项目的复杂性并增加了维护成本。
如非必要,尽量不要使用服务端渲染
总体思路
需要两端:服务器端和浏览器端(浏览器渲染的部分)
首先:打包浏览器端代码
二:打包服务端代码,启动服务
第三:用户访问,服务器读取浏览器端打包的index.html文件为字符串,将渲染的组件、样式、数据塞进html字符串,返回给浏览器
第四:浏览器直接渲染接收到的html内容,加载打包好的浏览器端js文件,进行事件绑定,初始化状态数据,完成同构
React 组件的服务端渲染
来看看最简单的 React 服务端渲染过程。
对于服务端渲染,必须需要一个根组件来生成 HTML 结构
import React from 'react';
import ReactDOM from 'react-dom';
ReactDOM.hydrate(, document.getElementById('root'));
当然,这里也可以使用 ReactDOM.render,但是 hydr 会尝试重用接收到的服务器返回的内容。
在浏览器端补充事件绑定等独特的流程
引入浏览器端需要渲染的根组件,并使用react的renderToString API进行渲染
import { renderToString } from 'react-dom/server'
import Container from '../containers'
// 产生html
const content = renderToString()
const html = `
${content}
`
res.send(html)
这里的renderToString也可以换成renderToNodeStream。区别在于前者是同步生成HTML,即如果生成HTML需要1000毫秒,
然后内容会在1000毫秒后返回给浏览器,显然时间太长了。后者是流的形式,渲染结果塞进响应对象,和出来一样多
返回给浏览器多少可以相对减少时间消耗
路由的服务端渲染
正常情况下,我们的应用不可能只有一页,肯定会有路由跳转。我们一般是这样使用的:
import { BrowserRouter, Route } from 'react-router-dom'
const App = () => (
{/*...Routes*/}
)
但这是在浏览器端渲染时的用法。做服务端渲染时,需要用StaticRouter替换BrowserRouter
不同的是,BrowserRouter 会使用 HTML5 提供的历史 API 来保持页面与 URL 同步,而 StaticRouter
网址不会改变
import { createServer } from 'http'
import { StaticRouter } from 'react-router-dom'
createServer((req, res) => {
const html = renderToString(
)
})
这里,StaticRouter 会收到两个属性:
Redux 同构
数据的预获取、脱水和注水是服务端渲染的难点。
这是什么意思?也就是说,第一屏渲染的网页一般需要请求外部数据。我们希望在生成 HTML 之前获取此页面所需的所有数据。
然后把它塞进页面。这个过程称为“脱水”,生成 HTML 并将其返回给浏览器。浏览器获取带有数据的 HTML,
去请求浏览器端js,接管页面,用这个数据初始化组件。这个过程被称为“水合”。完成服务器和浏览器数据的统一。
你为什么要这样做?试想一下,如果没有数据的预取,直接返回一个没有数据只有固定内容的HTML结构会是什么结果?
第一:由于页面上没有有效信息,不利于SEO。
第二:由于返回的页面没有内容,但是浏览器端JS接管页面,然后返回请求数据渲染数据,页面会闪退,用户体验不好。
我们使用Redux来管理状态,因为有服务端代码和浏览器端代码,那么需要两个store来分别管理服务端和浏览器端的数据。
组件配置
组件在服务端渲染时需要请求数据。可以在组件上挂载一个特殊的异步请求方法,这里称为loadData,接收服务器端的store作为参数。
然后store.dispatch在服务器端扩展store。
class Home extends React.Component {
componentDidMount() {
this.props.callApi()
}
render() {
return {this.props.state.name}
}
}
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 =
{renderRoutes(routes)}
从服务器获取数据
到达服务器时,需要判断匹配路由中的所有组件是否都有loadData方法,如果有则调用。
在服务器端传入商店,扩展服务器端的商店。同时需要注意的是,一个页面可能由多个组件组成,每个请求都会被发送,这意味着我们必须等待所有的请求发送完才能返回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 的目的是即使请求出错也能解决,不会影响 Promise.all 方法。
也就是说只有有错误请求的组件才会有数据,其他组件不受影响。
注入数据
我们的请求已经发出了,在组件的loadData方法中也扩展了服务端的存储,这样就可以把服务端的数据取出来注入HTML返回给浏览器了。
看serverRender方法
const serverRender = (req, store, context) => {
// 读取客户端生成的HTML
const template = fs.readFileSync(process.cwd() + '/public/static/index.html', 'utf8')
const content = renderToString(
)
// 注入数据
const initialState = `
window.context = {
INITIAL_STATE: ${JSON.stringify(store.getState())}
}
`
return template.replace('', content)
.replace('', initialState)
}
浏览器使用从服务器获取的数据来初始化store
经过上面的过程,我们已经可以从window.context中获取到服务端预取的数据了。这个时候我们需要做的就是利用这些数据来初始化浏览器端的存储。保证两端数据的统一。
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 = styles._getCss()
this.props.staticContext.css.push(css)
}
}
}
路由中的组件可以在props中接收staticContext,也就是StaticRouter传递过来的上下文,
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
}
}
}
在服务器端,组件渲染后,上下文中已经有内容了。这时候我们处理样式返回给浏览器,然后样式就可以在服务端渲染了
const serverRender = (req, store) => {
const context = {css: []}
const template = fs.readFileSync(process.cwd() + '/public/static/index.html', 'utf8')
const content = renderToString(
)
// 经过渲染之后,context.css内已经有了样式
const cssStr = context.css.length ? context.css.join('\n') : ''
const initialState = `
window.context = {
INITIAL_STATE: ${JSON.stringify(store.getState())}
}
`
return template.replace('', content)
.replace('server-render-css', cssStr)
.replace('', initialState)
}
到此,服务端渲染完成。
总结
React 服务端渲染的最佳解决方案是 Next.js。如果你的应用不需要SEO优化,或者不太注重首屏渲染的速度,那么尽量不要使用服务端渲染。
因为它会使项目复杂化。另外,除了服务端渲染,还有很多优化SEO的方式,比如预渲染。