【SSR】漫谈服务端渲染
theme: channing-cyan
大家好,我是Laffery,本文同步发表在我的个人博客「 Kuqiochi | 谈谈服务端渲染「SSR」」。
SSR(服务端渲染,Server Side Render),顾名思义就是在服务端渲染出页面。与之相对应的是CSR(客户端渲染,Client Side Render),即在浏览器上渲染完整的页面。
Web页面渲染发展历程
在介绍SSR之前,我们先来看看历来Web页面是如何渲染的。
纯HTML时代
在网络初开的远古时期,一切皆文件,网页本质上是 托管在服务器上的HTML文件,甚至最开始是完全静态的页面,直到后来有了JavaScript和CSS,网页的交互性和内容的动态丰富性才有了保障。
模板引擎 「前后端分离」
模板引擎将视图层与数据层分离开来,是前后端分离的开端。
- 服务端为模板绑定数据
举个最直观的例子,flask渲染一个页面的方式就是向预设的HTML模板中传入数据,并在服务端渲染出最终的HTML(string / stream)。
flask. render_template( template_name_or_list, **context)
render_template(
'index.html',
labels=['Name', 'Description', 'Confidence'],
records=records
)
<table>
<thead>
<tr>
{% for label in labels %}
<th>{{ label }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for record in records %}
<tr>
{% for part in record %}
<td>{{ part }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
- 客户端为模板绑定数据
上面仍然是一个在服务端渲染的例子,那么ejs无疑是典型的客户端渲染。在客户端渲染很显然能够减轻服务器的渲染压力。
<script src="ejs.js"></script>
<script>
let people = ['geddy', 'neil', 'alex'],
html = ejs.render('<%= people.join(", "); %>', { people: people });
</script>
- 客户端请求数据更新视图
自从 ajax技术兴起,可以在浏览器上加载服务端数据,并且可以在不刷新页面的情况下更新视图。这时,彻底拉开了前后端分离的帷幕,前端和后端可以分开开发,解除耦合。
再后来有了我们熟知的“御三家” Angular / React / Vue,前端开发彻底繁荣。
这个时候,Web应用最初从服务端获取的HTML是一个白页,依赖构建生成的
index.[hash].js
在客户端执行并渲染出页面
黑魔法:CSS Database Queries
服务端渲染
好不容易前后端分离了,在客户端渲染大家也都很受用,怎么开了历史的倒车,又放到服务端上渲染呢?
先从几个概念说起
- **TTFB(Time to First Byte):**首个字节数据到达的时间
- **FP(First Paint):**首次绘制的时间
- **FMP(First Meaningful Paint):**首次有意义的内容绘制的时间
- **TTI(Time to Interactive):**页面变得可交互的时间
服务端渲染主要针对:
- 需要更高SEO(Search Engine Optimization),便于搜索引擎爬虫抓取网站内容的,如博客网站。
截至目前,Google 和 Bing 可以很好地对同步 JavaScript 应用进行索引。这里的“同步”是关键词。如果你的应用以一个 loading 动画开始,然后通过 Ajax 获取内容,爬虫并不会等到内容加载完成再抓取。也就是说,如果 SEO 对你的页面至关重要,而你的内容又是异步获取的,那么 SSR 可能是必需的。
- 提高首屏渲染速度(FMP),减少白屏时间,提升用户体验。
这一点在慢网速或者运行缓慢的设备上尤为重要。服务端渲染的 HTML 无需等到所有的 JavaScript 都下载并执行完成之后才显示,所以你的用户将会更快地看到完整渲染的页面。除此之外,数据获取过程在首次访问时在服务端完成,相比于从客户端获取,可能有更快的数据库连接。这通常可以带来更高的 Core Web Vitals评分、更好的用户体验,而对于那些“首屏加载速度与转化率直接相关”的应用来说,这点可能至关重要。
- 提升低网络和低配置设备上的性能
有些设备不支持 JavaScript 或 JavaScript 执行得很差,导致用户体验不可接受。对于这些情况,你可能会需要该应用的服务端渲染的、无 JavaScript 的版本。虽然有一些限制,不过这个版本可能是那些完全没办法使用该应用的人的唯一选择。
现在的服务端渲染主要分为两种:
-
Pre-render
,在服务端预渲染; -
Static render
,在服务端渲染出最终的HTML。
预渲染的服务端渲染流程如下:
边缘计算渲染
一方面,服务端渲染依赖中心的服务器(集群)请求动态数据并渲染HTML;另一方面,存在一个事实是服务端渲染的页面存在静态部分,没有必要重复渲染,可以考虑托管在CDN上。于是有人考虑使用边缘计算节点渲染。
简单地说, 边缘计算是将计算资源部署靠近用户和数据源的网络边缘侧,通过更靠近数据源的位置(如路由器、基站)执行计算。
边缘计算渲染将需要服务端渲染的页面分为静态和动态两部分。
- 静态部分缓存在边缘计算节点上(相当于CDN),相比传统的客户端向服务端请求,更快渲染出内容(FMP)
- 边缘计算节点与后端服务器长连接,动态部分的数据请求无需再建立TCP连接,响应更快
- 边缘计算节点将动态数据拼接到静态部分上
现代SSR技术
-
CSR``Client Side Render
客户端渲染 -
SSR``Server Side Render
服务端渲染(✅,现在说的服务端渲染默认是上述pre render的) -
SSG``Static Site Generate
静态页面生成
基本原理
- 服务端根据路由找到要渲染的component
- 服务端将页面渲染成HTML(string / stream)
- 服务端根据component中预定义的数据预取方法请求数据
- 服务端将数据序列化拼接到HTML中
- 客户端接收到服务器响应,渲染收到的HTML(以及CSS等)
- 用户可正常浏览
- 客户端执行hydration(水化),激活页面element的事件监听方法
- 用户可正常交互
同构
同构(Isomorphic)是SSR的核心理念。单纯实现SSR很简单,任何传统的服务端语言都能做到,但是SSR希望一套代码能在双端渲染,最大限度地重用代码,并抹除差异性,这是传统的SSR无法做到的。
路由同构
双端使用同一套路由规则。
服务端根据 request.url
查找要渲染的组件。
- 路由配置文件
routes.json|ts
- 约定式路由(如根据目录结构)
数据同构
双端使用同一套数据请求方法获取数据。
- 逻辑一致
- 方法一致(node-fetch)
渲染同构
双端渲染出来的结果是一致的。
- 浏览器有时会在解析HTML时自动优化,如
<!-- server side -->
<p><div>hi</div></p>
<!-- client side -->
<p></p>
<div>hi</div>
<p></p>
- 随机数 / hash,在双端要保持使用相同的随机数种子
React SSR demo
在React下,基于react-dom/server下的 renderToString
等方法,将Component渲染成HTML string或使用 renderToXxxStream
渲染成byte stream。
import App from 'path/to/client/App';
import RenderDOMServer from 'render-dom/server';
app.get('/*', (req, res) => {
const html = RenderDOMServer.renderToString(<App />);
return res.end(html);
});
Data Fetching
renderToString
是一个同步的函数,换句话说,服务端渲染的一系列API,并不能等到将组件中的异步数据加载完成后才执行渲染,所以一个直观的问题就是,前端渲染出来的HTML是没有数据的。
可以在 renderToString
执行前,提前加载需要的数据,并作为参数传递给组件。
约定服务端渲染的页面,其入口文件除了默认导出的组件,还导出一个命名为 getServerSideProps
的函数,用来定义数据获取的逻辑--由服务端执行。
type AppProps = Record<string, any>;
export default App(props: AppProps) {
return <>...</>
}
export const getServerSideProps = async () => {
const res = await fetch('xxx');
const data = await res.json() as AppProps;
return { props: data }
}
服务端在加载这个页面的时候,识别出 getServerSideProps
的定义存在,则执行其数据获取逻辑:
import RenderDOMServer from 'render-dom/server';
app.get('/*', async (req, res) => {
const { default: Page, getServerSideProps } = require('path/to/client/some-page');
if (getServerSideProps) {
const { props } = await getServerSideProps();
res.end(RenderDOMServer.renderToString(<Page {...props}/>));
return;
}
res.end(RenderDOMServer.renderToString(<Page />))
});
SSR Data Context
当页面上用到了服务端获取的数据时,客户端上调用 hydrate
方法后会察觉到渲染出来的差别,比如如下的SSR页面
import HelloWorld from "@/components/hello-world";
export default function Homepage(props: { mode?: "CSR" | "SSR" }) {
return <div>{props.mode ?? "CSR"}</div>;
}
export const getServerSideProps = async () => {
return { props: { mode: "SSR" } };
};
服务端渲染的结果是“SSR”,而 hydrate
又将组件渲染一遍之后页面就会变成“CSR”。
那么如何将SSR加载的数据也提供给浏览器呢,可以考虑为HTML模板添加脚本,
<script defer type="text/javascript">
(function() {
window.SSR = true;
window.SSR_DATA = data;
})()
</script>
这样在客户端执行hydrate前,从window中获取SSR_DATA对象,并为之创建上下文,为页面组件包裹一层,并在其中向页面组件注入上下文中的数据:
import { createContext, useContext } from "react";
import ReactDOM from "react-dom/client";
import Page from "./pages/xx";
const Context = createContext({});
function App() {
const { props } = useContext(Context);
return (
<Context.Provider value={window.SSR_DATA || {}}>
<Page {...props} />
</Context.Provider>
);
}
ReactDOM.createRoot(rootNode).render(<App />);
这样客户端hydrate时就能渲染出和服务端一致的数据。
Hydration
客户端虽然拿到了正确的数据并拼接到HTML中,但还没有为各个element绑定事件处理函数,此时SSR页面无法响应用户的交互。
这时,在客户端React会将页面重新解析生成VDOM,为每个节点绑定事件处理方法,这样就可以像CSR一样正常的响应各个事件。传统的 render
方法在首次调用时会清除SSR渲染的容器中的已有节点,而使用 hydrate
方法则会对容器中的节点进行解析,并为之绑定事件监听器。
function render() {
const rootNode = document.getElementById("root") as HTMLElement;
if (window && window.SSR) {
ReactDOM.hydrateRoot(rootNode, <App />);
} else {
ReactDOM.createRoot(rootNode).render(<App />);
}
}
render();
值得注意的是, hydrate
会比较解析出的VDOM与SSR渲染结果,如果存在差异,则会重新渲染VDOM,退化成CSR。
约定式路由
约定式路由无需routers配置,根据路由文件结构与路径一一映射。例如Next.js的约定:
-
pages/index.js
→/
-
pages/blog/index.js
→/blog
-
pages/blog/first-post.js
→/blog/first-post
-
pages/dashboard/settings/username.js
→/dashboard/settings/username
-
pages/blog/[slug].js
→/blog/:slug (/blog/hello-world)
-
pages/[username]/settings.js
→/:username/settings (/foo/settings)
-
pages/post/[...all].js
→/post/* (/post/2020/id/title)
因为服务端只能根据用户请求的url确定渲染哪个页面,所以还是需要在构建时生成一个manifest文件,告诉服务端改如何找到对应的页面。
Prospect
Qwik
- resumable: 将序列化的数据挂在element上
- fine-grained lazy loading:事件处理函数懒加载
- prefetch:根据用户行为分析常用区域,优先预加载事件处理函数
<button q:obj="1" on:click="./chunk-a.js#Counter_button_onClick[0]">
0
</button>
React Server Components(RSC)
数据加载的三种方案:
- waterfalls:客户端js加载完后发出大量数据请求
- pre fetch:先请求所有数据,拼接到HTML上客户端再渲染
- render as you fetch:不是所有的数据都是需要立刻展示的,请求优先展示的数据,剩余的放到客户端慢慢加载
组件级的服务端渲染,客户端组件不能依赖服务端组件,服务端组件使用 Suspense
API包裹。在客户端,JavaScript不包含服务端渲染部分的代码,客户端组件仍会在客户端hydrate。
import { Suspense } from 'react';
import Profile from '../components/profile.server';
import Content from '../components/content.client';
export default function Home() {
return (
<div>
<h1>Welcome to React Server Components</h1>
<Suspense fallback={'Loading...'}>
<Profile />
</Suspense>
<Content />
</div>
)
}