React服务端渲染(SSR)入门及项目搭建

代码已经关联到github: 链接地址 文章有更新也会优先在这,觉得不错可以顺手点个star,这里会持续分享自己的开发经验(:

前言

服务端渲染是什么?我们什么情况下需要使用它?想要了解这些,需要简单聊聊前端渲染的发展史。

早先的服务端渲染

服务端渲染并不是什么新兴的技术,动态网页技术(PHPjsp等)其实就是服务端渲染,网页都是由后端获取数据并将其放入到网页模板中,然后返回完整的HTML到浏览器渲染。这样做的渲染方式有明显的缺点,每次数据改变都需要重新再去获取数据并组装新的HTML、网页和后端逻辑耦合等。直到AJAX的出现。

客户端渲染爆发

AJAX的出现,使得前后端得以分离。在该模式下,后端依然会返回一个HTML页面,后续通过AJAX来动态获取数据,利用DOM操作动态更新网页内容。这意味着可以在不重载整个页面的情况下,对网页的某些部分进行更新,减少带宽,提高性能,也赋予了页面更丰富的展示的雏形,越来越复杂的前端工程也催生了MVC渲染框架(ReactVueAngularJS),后端可以返回一个空白HTML页面,通过JS脚本进行动态生成内容(单页面应用SPA),这就是客户端渲染

新的服务端渲染

SPA看起来已经是最优解,但是它对SEO不是很友好,且随着应用越来越复杂,JS代码文件也越来越大,导致首屏渲染的速度明显下降。所以聪明的人们又想到了服务端渲染的方式,那我们直接又使用以前的服务端渲染的方式?显然是不可能,一个是前后端分离解耦是大势不可逆转(不当切图仔!!!),而且我们有了新的技术:Node和渲染框架(ReactVueAngularJS),前者让前端开发可以在服务端编写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服务端渲染项目。

  1. 首先使用vite创建项目,这里可以选择React+TS的模板,跟着命令操作即可
 npm create vite@latest
  1. 创建完项目后,删除main.tsx,然后按照结构创建修改文件
    1. server.js
    2. entry-client.tsx
    3. entry-server.tsx
    4. 修改index.html为服务提供占位标记并引用entry-client.tsx
<div id="root"><!--app-html--></div>
<script type="module" src="/src/entry-client.tsx"></script>
  1. 设置开发服务器

我们需要在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()
  1. 双端入口文件entry-client.tsxentry-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>
  )
}
  1. 修改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没有包含在项目内,可考虑全局安装

  1. 编写React组件代码

这部分代码较多就不贴了,可直接参考完整项目:https://github.com/wqhui/vite-react-ssr
最后,一个简单的服务端渲染的项目就搭好了,我们可以运行命令npm run dev查看效果。

功能完善

页面404处理

上面我们虽然有处理服务器转换HTML的异常,但是没有处理访问404页面的情况。这里我想到的有两种方案:

  1. 服务端直接返回一个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页面
}
//...省略部分代码
  1. 适配React-Router未查找到的路由

可以使用 path="*" 适配“未查找到”的路由,这里的做法很多,可查看官网,下面是用的是useRoutes方式去适配。

useRoutes([
  {
    path: "/",
    element: <RouteNav />,
    children:[
      { index: true, element: <Home /> },
      { path: "about", element: <About /> },
      { path: "*", element: <NoMatch /> },//未匹配的路由
    ]
  },
])
数据获取
  • 客户端

客户端获取数据,一般是在组件挂载(componentDidMountuseEffect)时发送请求,获取到数据后更新组件状态。

  • 服务端

因为组件挂载回调不会在服务端执行,所以不能采用客户端的获取方式,所以我们可以先获取数据,渲染组件时候传入数据,然后在转化成HTML。具体实现如下:

  1. 配置组件的静态加载数据方法getInitialProps
Home.getInitialProps = () => {
  return Promise.resolve([
    {
      id:'1',
      word: 'accordion'
    },
    {
      id:'2',
      word:'agile'
    },
    {
      id:'3',
      word:'arbitrary'
    }
  ])
}
  1. 服务端渲染入口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
}

最后,我们还需要解决服务端和客户端数据的同步问题,也就是服务端请求过的数据应该缓存起来,客户端直接使用这份缓存数据,不需要再去请求,否则界面会出现闪动。

  1. 服务端数据注水,也就是在获取数据后,缓存到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)
//...省略部分代码
  1. 客户端数据脱水,也就是获取服务端缓存的数据初始化
//初始化客户端数据
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.jsonscript中增加如下命令,对于想要预渲染的路由界面生成静态的html。

  "scripts": {
    "prerender": "vite build --outDir dist/static && npm run build:server && node prerender"
  }

完整项目链接

https://github.com/wqhui/vite-react-ssr

参考

React服务端渲染入门 - 掘金
服务端渲染 | Vite 官方中文文档

  • 3
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值