SSR 服务端渲染

Published on

浏览器渲染页面

在经典面试题:在浏览器输入 URL 后确认到页面呈现之间发生了什么? 中,答案的一部分肯定少不了客户端和服务端建立连接后,服务器返回用户一个 HTML 文档,然后浏览器进行后面的渲染。 后面的渲染发生了什么?

  1. HTML 解析。解析器维护一个栈结构,将 tag 解析成 DOM 节点后加入到 DOM 树。
  2. 构建渲染树。HTML 解析生成 DOM 树表达页面的布局结构,CSS 规则解析生成 CSSOM 树表达页面样式,并请求静态资源和脚本,组合后生成渲染树。
  3. 浏览器布局。根据布局和样式信息对每个元素进行计算和定位。
  4. GPU 渲染。将渲染树计算后的信息通过 GPU 图像帧来显示,会进行先后经历栅格化,分层定位,合成帧,提升到图像显示。

和客户端渲染 CSR 的区别

服务端渲染和客户端渲染的区别,关键就是在浏览器获取到的 HTML 不同这一点上。 SSR 服务端渲染时,浏览器从服务端获取到的 HTML 文档是带有数据的,也就是说服务端先完成对页面数据的请求,然后填充到 HTML 文档中,再将这个完整的 HTML 返回给客户端。 而 CSR 客户端渲染,浏览器从服务端获取的 HTML 文档是不带数据的,甚至是一个空白页面,但是会带有执行脚本,然后通过客户端通过执行 script 来显示页面内容和完成数据请求。

如参见 github 首页的请求回来的 HTML 文档就是带有数据的,而 CSR 项目比如一般的 React/Vue 项目,页面内容都是通过执行 script 后渲染到 root|app 等根节点中的,HTML 文档中不返回数据

服务端渲染需要同构,同构即指部分同样的代码需要在服务端执行一遍,再在客户端执行一遍。服务端执行拼接生成 HTML 文档,将脚本链接插入在 HTML 文档中,客户端获取 HTML 文档并加载脚本,绑定交互事件。

SSR 有什么优点和使用场景

从网站的技术发展来看,早期 web 应用技术就是服务端渲染的,服务端集数据、静态资源和服务于一身,浏览器请求的就是带有完整数据的 HTML 文档。后面才将 HTML 页面和 CSS,交互等任务拎出来单独开发,也就是前端..

到前端 React/Vue 盛行的时候,页面 SEO 就变得不友好起来,因为这样页面全都是通过 JS 执行后动态渲染出来的,搜索引擎爬虫几乎爬取不到内容。

重新回到 SSR,根据渲染和数据获取的方式,就有一些好处:

  • 更好的 SEO 特性。使用 SSR 获取带有数据内容的 HTML 文档,利于搜索引擎关键词爬取。
  • 更快的首页加载时间。数据请求在服务端完成,客户端不再需要发去异步请求获取数据后渲染。

对于对 SEO 和首页加载时间有要求的页面,比如尤其是 C 端、官网等需要高度传播的页面,使用 SSR 是正确的选择。当然 SSR 也存在一些弊端,因为数据获取、拼接文档、静态资源等几乎全是服务端进行,会造成服务端性能和压力问题,无脑 SSR 也不是一个好的方案。

SSR 的简单实现

完整学习项目地址:https://github.com/czm1290433700/ssr-server

从 express 开始,新建项目后:

const express = require('express')

const app = express()

app.get('*', (req, res) => {
  res.send(`
    <html
      <body>
        <div>hello-world-ssr</div>
      </body>
    </html>
  `)
})

app.listen(3000, () => {
  console.log('ssr-server listen on 3000')
})

打开 localhost:3000

可以看到返回的就是一个 HTML 字符串,浏览器获取到后直接渲染,这得到的就是最简易版本的 SSR Helloworld。

静态页面渲染

上面直接返回的是一个 HTML 字符串,如何来做到渲染完整的静态页面呢。服务端需要将拼接好的 HTML 返回,但开发过程也很难将目标页面通过纯字符串拼接完成,依然需要借助 React/Vue/其他模板的方式。

  1. 模板页面。

    例如一个 React 组件,如何将其转换成 HTML 字符串呢,这就需要用到 react-dom 中的 renderToString 方法。将模板元素转为 HTML,底层依然是建立虚拟 DOM 再转化成真实 DOM 的过程。

    renderToString 方法只能渲染出页面,而 React 中的事件是没办法在服务端进行的。这里就需要用到同构,同样的代码在服务端执行一遍渲染静态 DOM,也就是页面,在客户端执行一遍进行事件的绑定。

    怎么做到呢?ReactDom.hydrateRoot,对服务端静态渲染的节点,通过 hydrateRoot 方法对模板中的事件进行处理。如下(<Client/>就是客户端入口组件):

    import { hydrateRoot } from "react-dom/client";
    hydrateRoot(document.getElementById("root") as Document | Element, <Client />);
    
  2. 路由匹配

    React 的路由还是react-router-dom,客户端定义路径和组件的对应关系,用 BrowserRouter 改造 服务端可以定义静态路由 StaticRouter

    import { StaticRouter } from 'react-router-dom/server'
    //...
    
    app.get('*', (req, res) => {
      const content = renderToString(
        <StaticRouter location={req.path}>
          <Routes>
            {router?.map((item, index) => {
              return <Route {...item} key={index} />
            })}
          </Routes>
        </StaticRouter>
      )
    
      res.send(`
        <html
          <body>
            <div id="root">${content}</div>
            <script src="/index.js"></script>
          </body>
        </html>
      `)
    })
    
  3. header 标签

    在不同的路由下,页面的标题经常会不一样,而多媒体适配,或者修改 description,keywords 关键字也需要改到 meta,这里就需要用 react-helmet 来完成。客户端的使用就是<Helmet>包裹起 title 和 meta,服务端是这样:

    import { Helmet } from "react-helmet";
    // ...
    
    app.get("*", (req, res) => {
      const content = renderToString(//...);
    
      const helmet = Helmet.renderStatic();
      res.send(`
        <html
          <head>
            ${helmet.title.toString()}
            ${helmet.meta.toString()}
          </head>
          <body>
            <div id="root">${content}</div>
            <script src="/index.js"></script>
          </body>
        </html>
      `);
    });
    

    重点:同构的使用,服务端和客户端都执行一遍。在静态页面、路由、数据请求上都需要用到。

数据请求

一个页面仅仅只有静态页面肯定是不够的,SSR 流程之一也是请求在服务端完成,供给客户端直接渲染。

  1. 建立 store

    基于同构,服务端和客户端一样都建立一套 store,遵循的还是 View->Action->Store->View 的更新过程。服务端可以开启 post,get 请求服务,然后到客户端通过 axios 等方式请求,数据请求在 Action 这一步,如 redux 可以通过中间件的方式支持异步函数。请求完成后再将结果加入 store,通过 props 注入到页面中显示。

  2. 关联路由请求数据

    在服务端进行初始化,就需要服务端调用数据请求的方法,可以使用路由,服务端对路由进行遍历,数据填入到 store 后进行分发。

    Demo 页面:

    const Demo: FC<IProps> = (data) => { return (...) }
    const storeDemo: any = connect(mapStateToProps, mapDispatchToProps)(Demo);
    
    // 定义到组件上的方法
    storeDemo.getInitProps = (store: any, data?: string) => {
      return store.dispatch(getDemoData(data || "这是初始化的demo"));
    };
    
    export default storeDemo;
    

    路由配置加入 loadData 函数:

    const Router: Array<IRouter> = [
      {
        path: '/',
        element: <Home />,
      },
      {
        path: '/demo',
        element: <Demo />,
        loadData: Demo.getInitProps,
      },
    ]
    
    export default Router
    

    服务端对路由遍历,遍历后根据 path 匹配路由,将 loadData 的结果(Promise)加入数组,Promise.all 之后再调用 res.send 返回字符串:

    const routeMap = new Map<string, () => Promise<any>>(); // path - loaddata 的map
    router.forEach((item) => {
      if (item.path && item.loadData) {
        routeMap.set(item.path, item.loadData(serverStore));
      }
    });
    
  3. 脱水和注水

    在服务端和客户端都建立一套 store 之后,服务端对请求初始化并将解雇填入到 store 中,再拼入字符串模板返回给客户端,但是客户端也执行一遍 store 时,会将数据重新初始化。这就需要客户端对页面“脱水”,移除数据(水就指数据),然后服务端请求完成数据加入到 store 后,再对数据进行注入,也就是“注水”。

    //  ...
    res.send(`
        <html
          <head>
            ${helmet.title.toString()}
            ${helmet.meta.toString()}
          </head>
          <body>
            <div id="root">${content}</div>
            <script>
              window.context = {
                state: ${JSON.stringify(serverStore.getState())}
              }
            </script>
            <script src="/index.js"></script>
          </body>
        </html>
      `)
    

    注水将数据写到 window.context 中,拼接好的 HTML 字符串返回给客户端。客户端在初始化 store 和 reducer 时,判断环境后 initialState 的默认数据从 window.context.state 中取出。如此客户端 store 初始化用的就是服务端请求好后返回的数据了。

    initialState:
        typeof window !== "undefined"
          ? (window as any)?.context?.state?.demo
          : {
              content: "默认数据",
            },
    

这里 demo 对服务端渲染框架的静态渲染、水合和数据请求部分处理,通过 express 做了一个尝试。完整的 SSR 框架还应该有更多细节如 CSS 支持,主题化,SEO 等等。对于目前前端常用框架 React/Vue,对应成熟的 SSR 方案是 - Nextjs / NuxtJs