SSR
什么是 SSR
在 IT 行业内,SSR 全称是 Server-side Rendering,即是服务端渲染。它是指在服务端完成完整 HTML 结构拼接,发送给客户端后再做事件绑定到最终页面达到可交互的流程。
我们可以从一个相对的概念,CSR,来更好地理解。
CSR 全称是 Client-side Rendering,即客户端渲染。客户端一般是指浏览器。现代的前端项目,或者说用上三大框架------Angular,React,Vue------做的前端项目,不论是在大公司还是小公司,几乎全是属于 CSR 的范畴。大家应该都很熟悉这套的开发流程,基本就是写组件,写获取数据的逻辑,把数据设置给组件,剩下的事情就交给框架去完成。我们写的组件、数据获取、事件绑定等流程,都是在浏览器上跑的,最终也在浏览器完成 HTML 渲染。
而 SSR,则是在服务器上完成数据获取,组件构建和 HTML 渲染,最终将 HTML 成品发送给客户端。
下面通过图直观的感受一下两者的对比。
与 CSR 的对比
上图是一个典型的 CSR 流程。浏览器在接收服务器发送的 HTML 文件后,并不能渲染出完整页面来,因为此时的 HTML 里 body 一般就是一个空元素。当页面 JS 下载好之后,页面仍然会处于加载中的空白状态。接着 JS 代码开始运行,页面使用的框架开始拉取数据和构建 DOM 节点,绑定事件,替换空 DOM 树。此时,完整的可交互的页面才会呈现给用户。
上图则是一个 SSR 流程。在浏览器结束了第一个 HTML 请求后,页面已经是完整(或者几乎完整)地呈现给用户了。而同时的 JS 代码下载和运行,则是可以异步地为页面补上交互。最终也会达到可交互的状态。
可以看出最大的差别是,SSR 能更快地呈现页面内容。
回到过去?非也
如果是前端从业时间比较长的工程师,会知道以前的网页开发,页面就是由后端来输出的。比如使用 PHP、JSP、ASP 等的语言来输出网页,前端的 JS 则主要负责页面上的交互和动画。后来,随着 JavaScript 的发展和浏览器的增强,出现了好多 CSR 框架和方案,页面渲染的能力就被挪到浏览器上做,由此也算开启了前端开发的繁荣发展。
现在又提出服务端渲染,是走回头路吗?并非如此。事实上本文叙述的 SSR,是在 CSR 框架的基础上发展而来的新型 SSR。
为方便区分,我们把以前的服务器渲染改称为旧 SSR。
上图描述了旧 SSR,CSR 和现代 SSR 三者的位置。让我们想像一条座标轴,越向左是越接近后端的范畴,向右则是接近前端的范畴,中间有个泾渭分明的点,就是 HTML 的传输。旧 SSR 是后端输出页面,只能占据了这个点靠左的地方;CSR 则是前端构筑页面,占据了这个点靠右的地方。我们看到两个方案的功能其实是有互相重叠的部分的,但囿于各自所处的位置没法合作。而现代 SSR 则是通过语言的统一和开发流程的统一,将这个点附近的概念都整合到一个范畴内,合并了重复的功能,以此来既获得了旧 SSR 的好处,也获得 CSR 的便利。
下文的 SSR,均指现代形式的 SSR。
为何用 SSR
更好的 SEO 表现
记得很久以前,面试前端职位,除了会考察 jQuery 的使用,也会询问对一些新兴(对那个时候而言!)框架例如 React 的了解。其中一个问题就是,使用了这些框架后,搜索引擎在响应中得不到很多有用的信息,影响到 SEO 怎么解决?这对于一些以内容为主、依靠广告、搜索引擎高权重来盈利的企业是最关心的事情。虽然 SEO 本身是个涉及多个领域的问题,不可能完全取决于某一个环节,但就从当时的技术角度来谈,比较标准的回答有但不限于:
-
在 header 里动态输出重点 keyword、tag,尽量让搜索引擎更好了解你这个页面
-
使用当时最新最酷炫的 headless browser(无头浏览器),预先或者即时为搜索爬虫生成真实页面
-
使用骨架屏技术,让 JS 框架更快地把页面的大致样子输出,提升搜索引擎对页面响应速度的评分
不一而足。但其实以上的解决方案基本都有对应的缺陷,与传统直出相比都稍逊一筹。这是 CSR 技术一直比较尴尬的地方。SSR 的完整页面输出则可以完美解决这些问题。
更早的 FP / FCP
FP(First Paint)和 FCP(First Contentful Paint)都是从用户体验角度来评价页面的指标。FP 关注的是第一个像素点出现的时间点,FCP 则是关注第一个文本或者图像等出现的时间点。
得益于 SSR 输出了完整的 HTML 结构,浏览器接收到第一个响应,即 HTML 响应的时候,就可以将页面渲染出来。作为对比,CSR 则需要推迟到下载完必要的 JS 文件,框架执行后,才有可能渲染出来。这一点,在客户端是低端机器或者网络不好的情况下,尤为明显。
更早的 TTI
因为 SSR 将数据获取和 HTML 结构布局的逻辑移到了服务端,可以令下发的 JS 代码减少代码体积,从而被更快下载到客户端,并且要执行的逻辑减少了,那么就能更早地完成事件绑定等逻辑,最终获得更好的 TTI 指标。
通用性强
由于服务器下发的是完整的 HTML,因此对客户端的要求变得非常低。这就让 SSR 能做到对低端设备非常友好,因为低端手机,非智能手机,kindle 等设备的性能或许没法很好地支持现今越来越庞大的客户端 JS 的计算量。甚至在无 JavaScript 运行时的环境中,SSR 也能勉强一用。
可选
SSR 功能不会强要求开发者对原有的项目结构、业务流程、工程方案做很大甚至彻底的改动。很多前端框架和 SSR 解决方案,都支持对页面进行渐进增强甚至局部应用。这让 SSR 成为了一个适应性很强的方案。对于旧项目而言,SSR 可以作为一个新的性能指标改进,一个新的业务提升点;对于新项目而言,完全可以直接在框架支持的情况下,直接无缝使用。
为何不用 SSR
当然,任何事情都没有银弹,SSR 也有不擅长的场景。
更关注 TTFB
TTFB 是指客户端和服务端开始交互后,服务端开始向客户端发送数据的时间点,可以用来反映服务器的响应速度。SSR 让服务器承担了数据获取和渲染等工作,对比 CSR,肯定会在更迟的时间点发送数据,因此 TTFB 数据一般不会很好。如果对于这个指标比较敏感,就不要用。
重 UI 交互
在一些有复杂交互的网页上,SSR 就显得不那么合适了。或者网页需要 canvas 来呈现内容,那就根本不合适。本来 SSR 将复杂的数据获取搬到了服务器上,客户端可以减少甚至不需要 JS 代码。但如果交互逻辑复杂,就会造成 JS 文件过大,下载时间过长。一旦下载加执行时间超过一定时间(比如 1s) ,用户就会产生困惑,觉得页面出问题了,因为此时的网页是有内容的,但任何操作(除了滑动或者链接点击)都没反应。用户即使多次刷新也无济于事,因为每次走的都是 SSR。
复杂的用户授权
有些页面的访问是需要用户授权的,这在网站设计中非常常见。而某些用户授权的机制,是依赖浏览器上提供的特殊功能,比如 cookie,localstorage,验证码等等,这部分是完全没法放在服务端做的,因此 SSR 也就不适用。
如何用 SSR
SSR 离不开两个方面的支持:JavaScript 运行时的支持,前端框架的支持。而且两个方面是相互联系的,运行时可能考虑补充前端框架需求的 API,前端框架也可能出适配不同运行时的包。
JS 运行时
目前比较有名而且可行的运行时。
-
node
-
Cloudflare Workers
-
Deno
前端框架
目前各种流行的前端框架都能支持 SSR 功能。
前端框架 | 提供 SSR 的框架 |
---|---|
Angular | Angular Universal |
React | Remix, next.js |
Vue | Nuxt |
Svelte | SvelteKit |
例子
这里使用 Remix 来简单演示 SSR 的大概流程。Remix 是由 React Router 原班团队打造的 Web 全栈框架。
注意这里的代码关注点在比较与 CSR 的不同,仅供概念演示用,具体编写需要参阅对应的文档。
假设某个页面使用 SSR,则需要编写内容如下:
TypeScript
import { json } from "@remix-run/cloudflare";
import { useLoaderData } from "@remix-run/react";
export const loader = async () => {
return json({
data: await serverGetData();
});
};
export default function Posts() {
const { data } = useLoaderData<typeof loader>();
return (
<main>
<h1>{data.xxx}</h1>
</main>
);
}
代码跟 CSR 的编写方式完全一致。但要注意,这段代码现在是在服务器上面运行的。
留意有一个导出成 loader 的函数,remix 要求需要有这么一个函数为组件提供数据,因此查询数据存储等逻辑,需要放在这个函数里。而当需要在 React Component 中取回数据时,则需要使用 Remix 提供的useLoaderData来取得数据。
各种支持 SSR 的框架中,几乎都或多或少采取数据获取和组件渲染分离的写法和套路。
Remix 的例子属于在比较高的层次来看 SSR。接下来再来看看 React 从底层框架是怎么支持 SSR 的。
首先是客户端 JS 需要改写,可以看到无需太多改动:
TypeScript
import React from "react";
import { hydrate } from "react-dom";
import App from "./components/App";
// 使用了 hydrate 而不是 render
hydrate(<App />, document.getElementById("root"));
服务端的代码,注意省略了创建 http 服务器等无关代码。
TypeScript
import { renderToString } from 'react-dom/server';
app.get('/', (req, res) => {
const app = renderToString(<App />);
res.send(`<html><body>${app}</body></html>`);
})
服务器改动是需要引入 React 和使用 React 编写的组件,通过使用 React 专门提供的方法来输出结果到 http response 中。
综上结合上层框架和底层 React 各自的原理来看,整体方案是这样的:
-
底层框架提供在服务器上运行框架代码的方式和格式
-
上层框架约定好用户代码的写法,然后通过编译器/打包器/插件等将用户代码打包出一份服务器运行的代码,一份客户端运行的代码。有些框架适配做得很强,甚至可以直接无缝支持原有的 CSR 写法。
大部分框架使用都需要注意的点
-
服务器的状态累积
-
不兼容的特性
Worker
上面的 Remix 例子里,可以看到 SSR 的主要代码,本身就来自于 CSR,并不会强制要求运行时使用 node。在介绍 JS 运行时时,也可以看到出现了除 node 以外的运行时。实际上 SSR 并不是一定要运行在 node 上的,也是可以运行在所谓的 Worker 上的。那什么是 Worker?
大家可以回想一下 Service Worker。在 Service Worker 可以通过监听fetch事件,对浏览器请求返回响应。这个流程是不是很像是普通 HTTP 服务器做的事情?那是不是可以将这个流程,从浏览器器拿出来单独运行呢?
于是 WinterCG 就出现了。
WinterCG
WinterCG,全称 Web-interoperable Runtimes Community Group,可以译作 Web 通用运行时社区组,是一个由 Cloudflare,Deno 等组织发起的 W3C 工作组,致力于推动 Web API 在通用 JavaScript 运行时中的发展。我们不仅能在 node 和浏览器中运行 JavaScript,也能通过约定标准实现更多运行在其他地方的通用运行时。
参阅官网 wintercg.org/ 获取更多信息。
目前该组织在国内已经有阿里巴巴和字节跳动加入,在国外也有 Cloudflare、Deno 和 Vercel 等知名企业的支持。这些公司基本都建设了自己的 JavaScript 运行时,并在公司业务中积极使用。大家最熟悉的应该就是 Cloudflare Workers 了。我们就简称此类运行时叫 Worker 吧。
我们字节跳动基础架构团队除了参与 WinterCG 的会议讨论外,也自研了一个合乎规范的 JavaScript 运行时。该运行时在字节跳动内部是 Serverless 高密度部署系统的一部分。
有关字节跳动高密度部署与 Web-interoperable Runtime 实践的文章,可以扫描下方二维码跳转阅读。
顺便说一声,这个二维码就是简单引入了个生成 QRCode 的 npm 包实现的,在内网直接基于 Worker 运行。从在部署平台创建函数到编写完不到二十分钟,不到一分钟完成发布即可访问。
整个开发过程完全不需要考虑申请服务器,也不需要考虑服务弹性伸缩,大部分维护工作都是由高密度部署系统自动解决。
为方便,以下都简称这些运行时为 Worker。
Worker 的优势
大厂和开源组织关注和发展 Worker,肯定是有其原因的。在此列出几个 Worker 的优势,供大家参考。
Web 标准的 JS 运行时
JavaScript 作为前端唯一通用语言,在前端占着绝对的领导地位。能独立跑 JavaScript 的环境,就意味着可以拥抱整个前端丰富的资源。通过使用 JS 运行时去落地合适的业务,可以利用上前端领域庞大的生态和从业者。
Worker 遵循的是都是 WHATWG 等 web 标准,对于前端开发者来说是更加熟悉的一套编程方式,这大大降低了他们参与服务端开发的门槛。
自主可控
从原理上来讲,基于 V8 内核进行开发,补上 WHATWG 等标准的实现,就能构建出适合自己需要的特定的 Worker。这赋予了开发团队极高的自由度,可以深入到运行时的 API 层面进行完全的掌控,让运行时能适应公司或者组织内部的各种场景。比如运行时本身作为公共设施的底层依赖的同时,也可以通过稍作修改就能提供给私有化部署使用。如果有业务需要,技术团队还可以为 Worker 添加特殊的 API 和内部特性。
Deno 甚至还提供的封装 V8 的 Rust 库,还写了两篇博文专门介绍 JS Runtime 的编写,见 deno.com/blog/roll-y... 和 deno.com/blog/roll-y...
轻量
对比 node ,Worker 没有也不需要支持非标准 API,可以做到十分轻量。Deno 甚至能做到单可执行文件即可运行 ts 代码。
轻量意味着:
-
对服务器资源占用小,单机就能拉起更多实例,有可能得到更好的性能
-
快速部署。相比 node 需要在系统中安装,项目需要 npm install,基于 Worker 的开发模式可以做到即写即发。字节内部某 node 服务改成 worker 架构后部署时间缩短了 90%。
通用 / 同构
遵循统一的标准意味着 Worker 能够在多种不同的环境中实现 API 互操作性。开发人员可以使用相同的 Web 技术栈在不同的环境中开发、部署同一套应用程序,而无需为每个运行时环境单独学习和使用不同的技术栈。功能和代码的相同也意味着跨平台的开发成本的降低和可靠性稳定性的提高。
Worker 运行时,为业务代码提供了在 IDC 场景、边缘场景、CDN 场景之间平滑迁移的可能性,极大的拓宽了服务架构的想像空间。有了 Worker,我们完全可以做到,同一个 web 项目的同一份代码,既能部署在 IDC,又能部署在边缘,还能部署在 CDN。
Worker 的局限
node 包兼容性
前端的生态繁荣很大程度上是 node 的生态繁荣。很多库是完全依赖 node 的 API 的,然而 Worker 不可能完全兼容 node,因此无法支持这些库的使用。在某些特殊场景比如文件系统支持,puppeteer 支持,Worker 是不适用的。
在引入库代码的方式上,Deno 曾经做过大胆的尝试,摒弃了通过 npm 安装包的形式,直接在代码中引入文件 url,并另外开辟了另一种库的生态形式。个人认为这是一个十分适合 Worker 和 Serverless 的库引入方式,但 Deno 最后还是选择了兼容 npm,不得不让人深思其中的原因。
触发方式单一
目前标准的请求触发方式,应该只有一个,就是监听fetch事件,相当于是 HTTP 请求。但是在实际应用场景,可能触发代码的入口种类太多了,比如可能有计时器类请求,RPC 请求等。另外fetch事件能承载的数据类型,数据结构,也不够丰富。这些都直接限制了 Worker 的使用范围。
Hello World
Worker 的 Hello World 代码非常简单,只需一行。太过于简单,不再解释了。
TypeScript
addEventListener('fetch', e => e.respondWith(new Response('Hello World')))
SSR on Worker
我们再来聊聊在 Worker 上跑 SSR。从 SSR 的特性来看,Worker 是很适合的一个运行时:
-
代码运行需要一个 JS Runtime,对应了 Worker 本身。
-
SSR 是从前端 CSR 框架发展而来,与 Web 标准是最贴合的。Worker 正好就是遵循 Web 标准。
-
SSR 最主要的代码逻辑,就是数据获取和字符串操作(文本渲染)。这部分一般不需要特定 API,Worker 完全可以胜任。
而借助 Worker 的同构,SSR 则可以做到一次编写,多环境部署。
使用场景
理论上,所有 SSR 使用场景,都可以使用 SSR on Worker。我认为有以下几个场景可以优先尝试:
-
偏静态页面,对 SEO 要求高,比如:产品官网,博客文章页,文档站
-
首屏加载速度有较高要求,比如:电商产品页,垂直社区内容页,新闻页
-
结构固定,用户体验要求高,比如:音视频播放页
以上的场景,大多可以将 worker 部署在边缘网络 / CDN,获得缓存,缩短网络距离等优点。
SSR 和 Worker 在字节
字节内部一些框架已经支持将 SSR 跑在 Worker 上了。
以下是这些框架的一些特性和建设方向,供大家参考和拓宽思路。
-
NSR:NSR 是 Native Server Rendering,主要是利用了 Worker 的同构性。通过在移动端上嵌入 Worker Runtime,就可以运行与服务端相同的 SSR 项目。移动端可以提前拉取数据做超前渲染,或者在离线情况下仍然提供网站服务。
-
流式 SSR:通过支持流式渲染,优化 TTFB 指标。
-
CSR 兜底:作为业务兜底保障,框架可以在数据接口超时或者出错时,自动的降级成静态路由,输出普通的 CSR 方案需要的全部文件。
-
SSR 结果缓存:有一些 SSR 输出的在比较长的一段时间都是有效的,那么就可以将这些结果存到存储里。在有效期内的下一次请求就可以立刻响应,省下带宽成本和 CPU 成本。
参考
javascript-conference.com/blog/server...