SSR 服务端渲染
- Published on
浏览器渲染页面
在经典面试题:在浏览器输入 URL 后确认到页面呈现之间发生了什么?
中,答案的一部分肯定少不了客户端和服务端建立连接后,服务器返回用户一个 HTML 文档,然后浏览器进行后面的渲染。 后面的渲染发生了什么?
- HTML 解析。解析器维护一个栈结构,将 tag 解析成 DOM 节点后加入到 DOM 树。
- 构建渲染树。HTML 解析生成 DOM 树表达页面的布局结构,CSS 规则解析生成 CSSOM 树表达页面样式,并请求静态资源和脚本,组合后生成渲染树。
- 浏览器布局。根据布局和样式信息对每个元素进行计算和定位。
- 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 的简单实现
从 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/其他模板的方式。
模板页面。
例如一个 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 />);
路由匹配
React 的路由还是
react-router-dom
,客户端定义路径和组件的对应关系,用 BrowserRouter 改造 服务端可以定义静态路由 StaticRouterimport { 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> `) })
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 流程之一也是请求在服务端完成,供给客户端直接渲染。
建立 store
基于同构,服务端和客户端一样都建立一套 store,遵循的还是 View->Action->Store->View 的更新过程。服务端可以开启 post,get 请求服务,然后到客户端通过 axios 等方式请求,数据请求在 Action 这一步,如 redux 可以通过中间件的方式支持异步函数。请求完成后再将结果加入 store,通过 props 注入到页面中显示。
关联路由请求数据
在服务端进行初始化,就需要服务端调用数据请求的方法,可以使用路由,服务端对路由进行遍历,数据填入到 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)); } });
脱水和注水
在服务端和客户端都建立一套 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 。