代码已经关联到github: 链接地址 文章有更新也会优先在这,觉得不错可以顺手点个star,这里会持续分享自己的开发经验(:
前言
服务端渲染是什么?我们什么情况下需要使用它?想要了解这些,需要简单聊聊前端渲染的发展史。
早先的服务端渲染
服务端渲染并不是什么新兴的技术,动态网页技术(PHP
、jsp
等)其实就是服务端渲染,网页都是由后端获取数据并将其放入到网页模板中,然后返回完整的HTML到浏览器渲染。这样做的渲染方式有明显的缺点,每次数据改变都需要重新再去获取数据并组装新的HTML、网页和后端逻辑耦合等。直到AJAX
的出现。
客户端渲染爆发
AJAX
的出现,使得前后端得以分离。在该模式下,后端依然会返回一个HTML页面,后续通过AJAX来动态获取数据,利用DOM操作动态更新网页内容。这意味着可以在不重载整个页面的情况下,对网页的某些部分进行更新,减少带宽,提高性能,也赋予了页面更丰富的展示的雏形,越来越复杂的前端工程也催生了MVC
渲染框架(React
、Vue
和AngularJS
),后端可以返回一个空白HTML页面,通过JS脚本进行动态生成内容(单页面应用SPA
),这就是客户端渲染。
新的服务端渲染
SPA
看起来已经是最优解,但是它对SEO
不是很友好,且随着应用越来越复杂,JS
代码文件也越来越大,导致首屏渲染的速度明显下降。所以聪明的人们又想到了服务端渲染的方式,那我们直接又使用以前的服务端渲染的方式?显然是不可能,一个是前后端分离解耦是大势不可逆转(不当切图仔!!!),而且我们有了新的技术:Node
和渲染框架(React
、Vue
和AngularJS
),前者让前端开发可以在服务端编写JS
代码,而后者让前端可以一套代码运行在客户端和服务端(同构,后面会细讲),减少了代码量,果然事物的发展是螺旋上升的。
现在可以回答上面提出的两个问题了,服务端渲染不是新概念,就是后端组装完整的HTML页面返回到浏览器渲染;之所以需要它是客户端渲染存在缺陷,是否使用该技术需要评估项目的适用性。下面总结一下服务端渲染的优缺点:
- 优点:
- 利于
SEO
,爬虫更容易爬取 - 加快首屏渲染
- 利于
- 缺点:
- 代码复杂性增大,代码需兼容服务端和客户端运行两种情况。
- 服务器压力变大,需要动态获取数据和渲染HTML。
举一个简单的SSR
例子:
const Koa = require('koa')
const app = new Koa()
const data = 'hello world'
app.use((ctx,next)=>{
ctx.body = `
<html>
<head>
<title>SSR</title>
</head>
<body>
<p>${data}</p>
</body>
</html>
`
})
app.listen(8001,()=>{
console.log('koa服务器启动成功~,链接为:http://localhost:8001')
})
React服务端渲染
实现React服务端渲染
通过前言,我们了解到服务端渲染其实就是将动态JS生成页面转化为静态HTML输出到浏览器,也就是说我们需要将React组件转为HTML,且需处理绑定在JSX代码上的事件等交互,因为我们的应用还是SPA
模式,所以还需要考虑兼容客户端渲染的情况。
将React组件转换为HTML字符串
首先是转化成HTML,使用的API为: ReactDOMServer.renderToString(element)
,使用该方法可以将React元素转化为HTML字符串,这样我们就可以在服务端组装成HTML。
import Koa from 'koa'
import ReactDOMServer from 'react-dom/server'
import App from './src/App'
const app = new Koa()
const data = renderToString(<App />)
app.use((ctx,next)=>{
ctx.body = `
<html>
<head>
<title>SSR</title>
</head>
<body>
<p>${data}</p>
</body>
</html>
`
})
app.listen(8001,()=>{
console.log('koa初体验服务器启动成功~,链接为:http://localhost:8001')
})
注意,在Node.js中使用import关键字,需要在package.json增如键值对: “type”: “module”
同构
同构,简单点说就是一份代码,双端运行,即我们的前端代码,既支持在服务端中组装HTML的,也支持在客户端中动态渲染HTML。
如上文通过renderToString
方法完成了服务端渲染的第一步,但是该方法不会处理事件点击等交互行为,这时候就需要通过客户端来完成了。同构就了解决这一问题,比如上面的App
组件,我们还需要引入客户端处理的JS代码:
ReactDOM.hydrate(<App />, document.getElementById('root'));
ReactDOM.hydrate
也被叫做“注水”,意思就是在服务端渲染后,React 将保留页面渲染的内容,只对事件绑定等客户端内容进行特殊处理。
除了渲染交互同构,我们还要实现双端的路由同构,即页面可以通过点击页面按钮跳转,也支持输入链接进行跳转:
- 浏览器路由:支持用户点击按钮跳转页面,使用
BrowserRouter
- 服务端路由:支持用户输入浏览器链接访问对应页面,使用
StaticRouter
如上面同构的App
例子,可以这样修改:
//服务端
renderToString(
<StaticRouter location={url}>
<App />
</StaticRouter>
)
//客户端
ReactDOM.hydrate(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('root')
)
小结一下:新的服务端渲染,采用同构的的方式,由服务端完成页面的 HTML 结构拼接,发送到浏览器,然后再进行一次客户端的处理,为其绑定状态与事件,成为完全可交互页面。
创建React SSR项目
接下来,就开始创建我们的SSR项目了。
按照上文的描述,我们项目的基础结构呼之欲出:
- index.html # html模板页面
- server.js # 具有服务端渲染的应用服务器
- src/
- entry-client.tsx # 客户端渲染入口,将应用挂载到一个 DOM 元素上
- entry-server.tsx # 服务端渲染入口,使用某框架的 SSR API 渲染该应用
- App.tsx # React应用主入口
- pages # 不同页面的JSX文件
下面我们将采用vite
+koa
的方式创建我们的React服务端渲染项目。
- 首先使用
vite
创建项目,这里可以选择React+TS
的模板,跟着命令操作即可
npm create vite@latest
- 创建完项目后,删除
main.tsx
,然后按照结构创建修改文件server.js
entry-client.tsx
entry-server.tsx
- 修改
index.html
为服务提供占位标记并引用entry-client.tsx
<div id="root"><!--app-html--></div>
<script type="module" src="/src/entry-client.tsx"></script>
- 设置开发服务器
我们需要在server.js
控制服务端渲染,这里采用koa
做为服务器,且对代码进行了开发和生产模式的区分。
- 开发模式下,使用
koa
服务器,以中间件的模式创建vite
应用,去加载对应的开发代码。 - 生产模式下,先将代码打包成生产包,然后启动一个
koa
的应用并开启静态服务器,去加载对应生产代码。
二者的思路都是,先由服务端进行静态页面渲染,再进行客户端的渲染对事件绑定等交互处理。
const fs = require('fs')
const path = require('path')
const Koa = require('koa')
const koaConnect = require('koa-connect')
const colors = require('colors')
const SERVER_PORT = 3000
const SERVER_HTML_ERROR = 'server_html_error'
//区分集成生产环境
const IS_PROP = process.env.NODE_ENV === 'production';
async function createAppServer(){
const resolve = (p) => path.resolve(__dirname, p)
const app = new Koa()
let vite
//启动服务
if(!IS_PROP){
//开发模式使用 vite 服务器
// 以中间件模式创建 Vite 服务器
vite = await (
require('vite')
).createServer({
server: { middlewareMode: 'ssr' }
})
//使用vite服务端渲染中间件
app.use(koaConnect(vite.middlewares))
}else{
//生产模式使用 静态 服务器
//压缩代码
app.use(require('koa-compress')())
//启动静态服务器
app.use(require('koa-static')(
resolve('dist/client'), {
index: false
}
))
}
//处理返回到客户端的html页面
app.use(async (ctx, next) => {
const { req } = ctx
const { url } = req
try {
let template, render
if(!IS_PROP){
//开发模式
// 1. 读取 index.html
// 开发模式总是读取最新的html
template = fs.readFileSync(
path.resolve(__dirname, 'index.html'),
'utf-8'
)
// 2. 应用 Vite HTML 转换。
// 这将会注入 Vite HMR 客户端,
// 同时也会从 Vite 插件应用 HTML 转换。
// 例如:@vitejs/plugin-react 中的 global preambles
template = await vite.transformIndexHtml(url, template)
// 3. 加载服务端入口。
// vite.ssrLoadModule 将自动转换
// 你的 ESM 源码使之可以在 Node.js 中运行!无需打包
// 并提供类似 HMR 的根据情况随时失效。
render = (await vite.ssrLoadModule('/src/entry-server.tsx')).render
}else{
//生产模式
//读取打包的模板
template = fs.readFileSync(resolve('dist/client/index.html'), 'utf-8')
//读取打包的服务端入口
render = (await require(resolve('dist/server/entry-server.js'))).render
}
// 4. 渲染应用的 HTML。这假设 entry-server.js 导出的 `render`
// 函数调用了适当的 SSR 框架 API。
// 例如 ReactDOMServer.renderToString()
const context = {}
const appHtml = await render(url, context)
// 5. 注入渲染后的应用程序 HTML 到模板中。
const html = template.replace(`<!--app-html-->`, appHtml)
// 6. 返回渲染后的 HTML。
ctx.body = html
ctx.status = 200
// if(context.status===404){
// ctx.status = 404
// }
} catch (e) {
if(!IS_PROP){
// 如果捕获到了一个错误,让 Vite 来修复该堆栈,这样它就可以映射回
// 你的实际源码中。
vite.ssrFixStacktrace(e)
}
ctx.app.emit('error',new Error(SERVER_HTML_ERROR), ctx, e)
}
})
app.on('error',(err, ctx, e)=>{
if(err.message===SERVER_HTML_ERROR){
//打印错误
const msg = `[返回HTML页面异常]: ${e.stack}`
console.error(colors.red(msg))
ctx.status = 500
ctx.body = msg
}else{
const msg = `[服务器异常]: ${e}`
console.error(colors.red(msg))
ctx.status = 500
ctx.body = msg
}
})
app.listen(SERVER_PORT,()=>{
console.log(
colors.green('[React SSR]启动成功, 地址为:'),
colors.green.underline(`http://localhost:${SERVER_PORT}`),
)
})
}
createAppServer()
- 双端入口文件
entry-client.tsx
和entry-server.tsx
//entry-client.tsx
import ReactDOM from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
ReactDOM.hydrate(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('root')
)
//entry-server.tsx
import ReactDOMServer from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'
import App from './App'
export function render(url: string, context : any) {
return ReactDOMServer.renderToString(
<StaticRouter location={url}>
<App />
</StaticRouter>
)
}
- 修改
package.json
"scripts": {
"dev": "nodemon server",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --outDir dist/server --ssr src/entry-server.tsx ",
"serve": "npm run build && cross-env NODE_ENV=production node server",
"serve:local": "cross-env NODE_ENV=production nodemon server",
},
dev
:就是我们的开发模式,只需启动服务即可。build
:这里有三个build
命令,第一个无后缀的表示执行客户端和服务端的代码打包,另外两个有后缀的是分别进行客户端和服务端的代码打包。其中--ssr
标志表明这将会是一个 SSR 构建。同时需要指定 SSR 的入口。serve
:这里有两个serve
命令,前者无后缀的表示重新打包并启动服务,后者有后缀表示启动服务,需要先手动进行打包。
nodemon没有包含在项目内,可考虑全局安装
- 编写React组件代码
这部分代码较多就不贴了,可直接参考完整项目:https://github.com/wqhui/vite-react-ssr
最后,一个简单的服务端渲染的项目就搭好了,我们可以运行命令npm run dev
查看效果。
功能完善
页面404处理
上面我们虽然有处理服务器转换HTML的异常,但是没有处理访问404
页面的情况。这里我想到的有两种方案:
- 服务端直接返回一个
404
的 HTML 页面。
我们在服务器获取渲染的HTML时,有传入一个context
的属性,在React-Router V5
时可以简单的把这个属性传入到StaticRouter
中,React-Router
在匹配不到路由时会修改context.status=404
,但是在React-Router V6
已经不存在这个属性,所以我们要自己特殊处理,下面使用的是react-router-dom
中的matchRoutes
去适配。
//entry-server.tsx
export async function render(url: string, context : any) {
const routeMatch = matchRoutes(routes, url)
updateContext(routeMatch, context)
//...省略部分代码
}
function updateContext(routeMatch: RouteMatch<string>[] | null, context: any){
context.status = routeMatch ? 200 : 400
}
//server.js
//...省略部分代码
const context = {}
const appHtml = await render(url, context)
//...
if(context.status===404){
ctx.status = 404
ctx.body = '404 not found html' //404页面
}
//...省略部分代码
- 适配
React-Router
未查找到的路由
可以使用 path="*"
适配“未查找到”的路由,这里的做法很多,可查看官网,下面是用的是useRoutes
方式去适配。
useRoutes([
{
path: "/",
element: <RouteNav />,
children:[
{ index: true, element: <Home /> },
{ path: "about", element: <About /> },
{ path: "*", element: <NoMatch /> },//未匹配的路由
]
},
])
数据获取
- 客户端
客户端获取数据,一般是在组件挂载(componentDidMount
或useEffect
)时发送请求,获取到数据后更新组件状态。
- 服务端
因为组件挂载回调不会在服务端执行,所以不能采用客户端的获取方式,所以我们可以先获取数据,渲染组件时候传入数据,然后在转化成HTML。具体实现如下:
- 配置组件的静态加载数据方法
getInitialProps
Home.getInitialProps = () => {
return Promise.resolve([
{
id:'1',
word: 'accordion'
},
{
id:'2',
word:'agile'
},
{
id:'3',
word:'arbitrary'
}
])
}
- 服务端渲染入口
entry-server
上处理数据获取
export async function render(url: string, context : any) {
const routeMatch = matchRoutes(routes, url)
const data = await getServerData(routeMatch)
//...省略部分代码
}
async function getServerData(routeMatch: RouteMatch<string>[] | null){
if(routeMatch){
const {route, pathname} = routeMatch[routeMatch.length-1]
const { element } = route
const getInitialProps = (element as any)?.type?.getInitialProps
if(getInitialProps){
const ctx = {}
const data = await getInitialProps(ctx)
return data
}
}
return null
}
最后,我们还需要解决服务端和客户端数据的同步问题,也就是服务端请求过的数据应该缓存起来,客户端直接使用这份缓存数据,不需要再去请求,否则界面会出现闪动。
- 服务端数据注水,也就是在获取数据后,缓存到
window
上
//entry-server.tsx
export async function render(url: string, context : any) {
const routeMatch = matchRoutes(routes, url)
const data = await getServerData(routeMatch)
updateContext(context, routeMatch, data)
//...省略部分代码
}
function updateContext(context: any, routeMatch: RouteMatch<string>[] | null,data){
context.status = routeMatch ? 200 : 400
context.preloadedState = data
}
//server.js
//...省略部分代码
const context = {}
const appHtml = await render(url, context)
let html = template //读取模板html字符串
if(context.preloadedState){
//服务端数据注水
//注意需要在模板字符串中增加一个空的script标签并在内部增加//--script-paclcehoder--//
html = html.replace(`//--script-paclcehoder--//`, `window.PRE_LOADED_STATE = ${JSON.stringify(context.preloadedState)}`)
}
html = html.replace(`<!--app-html-->`, appHtml)
//...省略部分代码
- 客户端数据脱水,也就是获取服务端缓存的数据初始化
//初始化客户端数据
export const getClientStore = () => {
//客户端数据脱水,获取服务端缓存的数据,然后进行其他处理
const preloadedState = window.PRE_LOADED_STATE
//...
}
//组件中判断是否存在数据,不存在则请求
function Home({
getData, data
}) {
useEffect(()=>{
if(!data){
getData()
}
},[])
}
预渲染 / SSG
如果预先知道某些路由所需的路由和数据,我们可以使用与生产环境 SSR 相同的逻辑将这些路由页面预先渲染成静态 的HTML 。这也被视为一种静态站点生成(SSG)的形式。
const fs = require('fs')
const path = require('path')
const absolute = (p) => path.resolve(__dirname, p)
const template = fs.readFileSync(absolute('dist/static/index.html'), 'utf-8')
const { render } = require(absolute('dist/server/entry-server.js'))
// 判断那些页面是需要预渲染的,这里直接全部渲染了...
const routesToPrerender = fs
.readdirSync(absolute('src/pages'))
.map(file => {
const name = file.replace(/\.tsx$/, '').toLowerCase()
return name === 'home' ? `/` : `/${name}`
})
async function prerender(){
// 遍历需要预渲染的页面
for (const url of routesToPrerender) {
const context = {}
const appHtml = await render(url, context)
const html = template.replace(`<!--app-html-->`, appHtml)
const filePath = `dist/static${url === '/' ? '/index' : url}.html`
fs.writeFileSync(absolute(filePath), html)
console.log('pre-rendered:', filePath)
}
}
prerender()
然后在package.json
的script
中增加如下命令,对于想要预渲染的路由界面生成静态的html。
"scripts": {
"prerender": "vite build --outDir dist/static && npm run build:server && node prerender"
}
完整项目链接
https://github.com/wqhui/vite-react-ssr