Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React服务端渲染(SSR)入门 #28

Open
wqhui opened this issue Jun 15, 2022 · 0 comments
Open

React服务端渲染(SSR)入门 #28

wqhui opened this issue Jun 15, 2022 · 0 comments

Comments

@wqhui
Copy link
Owner

wqhui commented Jun 15, 2022

前言

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

早先的服务端渲染

服务端渲染并不是什么新兴的技术,动态网页技术(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 官方中文文档

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant