Rendering Patterns 渲染模式

前端性能优化是指通过各种技术和方法,提高网站或应用在前端的性能,包括加载速度、响应速度、用户体验等方面。前端性能优化的目标是提高用户满意度,减少用户等待时间,提高网站的流量和转化率。SSR是一种常见的优化方式,但是也有一些局限性和挑战。在实际应用中,需要根据具体情况权衡利弊,选择最适合的技术方案

Server-side Rendering(服务端渲染)

简介

服务器端渲染(SSR)是最古老的Web内容渲染方法之一。SSR响应用户请求为要渲染的页面内容生成完整的超文本标记语言。内容可能包括来自数据存储或外部API的数据。

服务端渲染,利用内网的网速优势,在服务端就完成数据的获取并把数据填充到html中,目的是可以获得更好的FCP(首次内容绘制)。但是用于交互的js,以script标签的形式添加到html中。只有浏览器解析到对应的script标签,浏览器才会去请求、加载、解析这些js,导致这期间我们的页面是不可交互的。

那么有什么办法来优化这些场景呢?

Progressive Hydration渐进式注水

对应页面上低优的部分,延迟加载它们的js

SSR的过程中很重要的一环是用js把事件处理器绑定到页面元素上,我们称之为注水(hydration)。但是在传统的SSR中,我们需要遍历整棵react 节点树来添加事件处理器,随着页面变大这一时间开销会变得很大。

用户看着不可交互UI的时间也被称为恐怖谷:虽然用户认为他们可以和网站进行交互,但是事件处理器还没有被附加到组件上。这对于用户是让人沮丧的体验,因为界面看起来像是被冻结了一样!

对于这一问题,渐进式注水的处理方式的:分批注水。优先对那些重要的、出现在初始视口内的内容进行注水,随着触发器的触发(如页面滚动)再对后续内容进行注水。

具体实现

下面是一个渐进式注水的demo,在注水前,页面上会展示一个button,但是点击事件不会生效。只有点击左上角的按钮触发注水逻辑后,该组件才会真正进入可以交互的环节

渐进式注水demo代码

React.renderToString

用于将一个 React 元素渲染成其初始的 HTML,通过这个函数生成的html将是一份静态html,不会绑定事件处理器,不会运行hook,但对于钩子外的js依然会被执行,猜想是在hook中跟事件处理层做了处理

React.hydrateRoot

render() 相同,但它用于在 ReactDOMServer 渲染的容器中对 HTML 的内容进行 hydrate 操作。React 会尝试在已有标记上绑定事件监听器。

IntersectionObserver

Intersection Observer API 提供了一种异步检测目标元素与祖先元素或 viewport 相交情况变化的方法。简单来说就是能检测到元素什么时候出现在视口内。

那么具体的实现就很简单了,在进行SSR的时候,对于需要优先注水的部分,我们可以使用hydrateRoot进行处理,而对于需要推迟注水的部分,则使用renderToString进行处理。

然后在触发器(比如Intersection Observer中触发手动注水,那么就可以让页面的TTI大幅度缩短了)

结合SSR的渐进式注水

上面的demo没有做服务端渲染,但是使用开箱即用的api实现了渐进式注水,以此说明了渐进式注水的可行性。接下来我们来看一个使用了渐进式注水的服务端渲染demo

在这个demo中,先由服务端渲染出HTML并返回交给浏览器进行渲染。页面可以分为几个元素:顶部的计数器以及需要滚动才可见的列表。计数器由于一开始就出现在了视图中,因此渲染结束即可点击。但是下方的列表,需要被滚动到视图中才会触发注水(在这里体现为紫色动画)

我们可以对比一下完全注水和渐进式注水的性能区别

渐进式注水的优点和缺点

渐进式注水通过在客户端进行注水,实现了服务端渲染,并且减少了注水的开销。以下是我们可以从中获得的一些收益:

  1. 促进代码拆分:代码分割是渐进式注水中不可或缺的部分,因为需要为懒加载的组件而创建独立的块
  2. 允许按需加载页面不常用的部分:页面上可能有部分几乎完全静态的部分,经常在视口以外或不被需要。这些组件是非常适合使用懒加载的。在页面加载时,这些组件注水的代码不会被发送。相反的,它们需要基于触发器来进行注水。
  3. 减少bundle的大小:代码拆分自然让bundle变小。加载时执行更少的代码可以减少FCP和TTI之间的时间

对于只有内容范围只有一屏的应用不太适合渐进式注水,特别是屏幕上所有元素都要对用户可用,并且需要在加载阶段就可交互的应用。这是因为如果开发者不知道用户会先点击哪部分,开发者也许无法判断哪些组件需要优先注水。

Streaming Render流式渲染

在服务端生成要渲染的html并流式传输到浏览器

在SSR获取数据的过程中,难免出现部分接口耗时长的情况,此时页面的TTI(可交互时间)仍然不容乐观,我们可以如何处理这种情况呢?

流式渲染

比起生成一个包含全部标签的大HTML文件,我们可以把它拆成一个一个的小块。Node的流允许我们将数据流式传输到响应对象中,这意味着我们可以持续向客户端发送数据。当客户端接收到一个块的数据时,就可以开始渲染内容。

与渐进水合一样,流式传输是另一种可用于提高 SSR 性能的渲染机制。顾名思义,流意味着 HTML 块在生成时从节点服务器流式传输到客户端。由于客户端更早地开始接收"字节"的 HTML,即使对于大页面也是如此,TTFB(首字节时间) 会减少并且相对稳定。所有主流浏览器都会更早地开始解析和呈现流式内容或部分响应。由于渲染是渐进式的,因此会产生快速的 FP(首次绘制) 和 FCP(首次内容绘制)。

具体实现

流式传输的过程中,浏览器接受到的html是不完整的,可能会包含一些未闭合的html标签,但浏览器会替我们做临时处理

  1. 使用React的流式渲染api获取流
javascript 复制代码
import ReactDOMServer from 'react-dom/server';
export default async function ssr() {
    return ReactDOMServer.renderToNodeStream(<App {...props} />);
}
  1. 设置header和文档头部
vbscript 复制代码
response.useChunkedEncodingByDefault = true;
response.writeHead(200, {
"content-type": "text/html",
"content-transfer-encoding": "chunked",
"x-content-type-options": "nosniff",
});
response.write(`<html><body><div id='app'>`);
response.flushHeaders();
  1. 把流填充到response中
arduino 复制代码
// 请注意,这里的ssr函数是步骤1中声明的
const stream = await ssr();
stream.pipe(response, { end: false });
  1. 在流结束的时候结束请求
vbscript 复制代码
response.write("</div></body></html>");
response.end();
  • [ReactDOMServer.renderToNodeStream(element)] 此函数的输出 HTML 与 [ReactDOMServer.renderToString(element)] 相同,但采用 Node.js 可读流格式而不是字符串。该函数将仅在服务器上工作以将 HTML 以流的格式渲染html。接收此流的客户端随后可以调用 ReactDOM.hydrate() 来水合页面并使其具有交互性。
  • [ReactDOMServer.renderToStaticNodeStream(element)] :这对应于 [ReactDOMServer.renderToStaticMarkup(element)] HTML 输出是相同的,但采用流格式。它可用于在服务器上呈现静态、非交互式页面,然后将它们流式传输到客户端。

具体demo

在下面这个DEMO里面,通过流式渲染分批渲染页面,首先渲染了一个列表,隔了5秒之后渲染了另外一个组件,再隔了10秒结束请求。具体的渲染流程可以看react-streaming-ssr/server.js

demo

流式渲染的优点和缺点

Streaming 旨在通过 React 提高 SSR 的速度,并提供以下好处

  1. 性能提升:由于第一个字节在服务器开始渲染后很快到达客户端,因此 TTFB(首字节时间) 优于 SSR。无论页面大小如何,它也更加一致。由于客户端一收到HTML就可以开始解析,所以FP(首次绘制)和FCP(首次内容绘制)也比较低。
  2. 对背压(backpressure)的处理:流媒体对网络背压或拥塞反应良好,即使在具有挑战性的条件下也能产生响应迅速的网站。
  3. 支持SEO:流式响应可以被搜索引擎爬虫读取,从而允许在网站上进行 SEO。

但是流式渲染通用具有一些缺点:

  1. 要注意流式传输的实现不是简单的从 renderToString 到 renderToNodeStream() 的查找替换,在某些情况下,适用于 SSR 的代码可能无法按原样适用于流式传输。比如下面这个例子
xml 复制代码
res.write("<!DOCTYPE html>"); 

res.write(renderToStaticMarkup( <html>   <head>     <title>My Page</title>   </head>   <body>     <div id="content">       { renderToString(<MyPage/>) }     </div>   </body> </html>);

React Server Component React服务端组件

由服务端生成AST并返回给浏览器渲染

SSR弊端

SSR通常只能生成静态HTML,如果需要使页面支持交互,我们常常需要在这之后对页面进行注水(Hydration),只有注水结束后,页面才进入可交互的状态。

假如页面的交互依赖了某个体积较大的第三方库,那么用户需要等待这个库的代码完成传输并且加载完毕后,页面才可用。也许我们可以通过渐进式注水来加载权重较低的bundle,但假如这个bundle用于页面最重要内容的绘制呢?

下面这个demo中是一个简单的笔记app,用户可以在页面上增删改查笔记。这些笔记会以markdown的形式存储在本地的文件中。该应用依赖了体积较大的第三方库(marked 442KB、sanitize-html 80KB),如果采用普通的SSR来做优化,仍然无法降低TTI。由于该页面只有一屏的内容,显然不适合通过渐进式注水来缩短TTI,那么我们还有什么优化的手段?

服务端组件

传统SSR无法解决这种场景下TTI耗时过高的问题,是因为传统的SSR需要把bundle拉取到浏览器,由v8引擎加载并且解析后才能开始交互。

众所周知,React内部维护了一套vDom(fiber),vDom的更新流程如下图所示。

由于fiber架构不依赖平台,因此可以把依赖第三方库的这部分工作放在服务端,由服务端根据入参计算出组件的DSL(fiber node的字符串),返回给浏览器,那么浏览器只需要对计算好的DSL进行渲染即可。避免把整个第三方库拉取到前端,从而节省带宽,这就是服务端组件

可以看一下服务端组件的返回

M - client component chunk的引用信息。client component chunk就是我们前面启动服务时webpack(更准确地说是react-server-dom-webpack)通过遍历所有的client component文件所打包出来chunk文件;

J - server component在服务端渲染的结果。这个结果是一个类react element格式的字符串。

S - Suspense component。

E - 服务端渲染过程所发生的错误信息。它的内容会被react-server-dom-webpack类库解析出来,并使用组件的FallbackComponent(<Error />组件)显示出来。

服务端组件的实现方式

javascript 复制代码
// 组件
import {createFromFetch} from 'react-server-dom-webpack';
function Content() {
  return (
   <>
      {createFromFetch(
          fetch('/react?location=' + encodeURIComponent(key))
      ).readRoot()}
      </>
  );
}

// 服务器
const {pipeToNodeWritable} = require('react-server-dom-webpack/writer');
const ReactApp = require('../src/App.server').default;
app.get('/react', function(req, res) {
  const location = JSON.parse(req.query.location);
  res.set('X-Location', JSON.stringify(location));

  const manifest = readFileSync(
  // react-server-dom-webpack/plugin 生成的json,用于描述模块之间的依赖关系
    path.resolve(__dirname, '../build/react-client-manifest.json'),
    'utf8'
  );
  const moduleMap = JSON.parse(manifest);
  // 返回服务端组件
  pipeToNodeWritable(React.createElement(ReactApp, {
    selectedId: location.selectedId,
    isEditing: location.isEditing,
    searchText: location.searchText,
  }), res, moduleMap);
});

// webpack.config.js
const ReactServerWebpackPlugin = require('react-server-dom-webpack/plugin');
plugins: [
      // ...其他插件
      new ReactServerWebpackPlugin({isServer: false}),
    ],

源码目录: codesandbox.io/p/github/OJ...

适用场景

服务端组件适用的场景需要满足以下几个特点

  • 组件位于页面中权重较高的位置,不适合使用渐进式注水
  • 组件依赖了某些体积较大的第三方库
  • 组件dsl的体积要明显小于第三方库
  • 该第三方库可以在服务端运行(不依赖state、effect、浏览器API等)

服务器和客户端组件的能力和限制

注意:本部分可能让人感到害怕,但您无需记住所有这些规则即可使用服务器组件。 React 将为任何违规行为提供明确的 lint、构建和运行时错误。虽然规则列表看起来很长,但直觉很简单:客户端组件无法访问文件系统等服务器专用功能,服务器组件无法访问客户端专用功能,如state,客户端组件只能导入其他客户端组件。

此提案中引入的主要新概念是服务器组件。相比之下,Client Components 是开发人员已经熟悉的标准 React 组件:"Client Component"这个名称并没有什么新意,纯粹是为了区别于 Server Components。在本节中,我们将讨论这两种类型组件的功能之间的一些重要差异。

  • Server Components: 作为一般规则,服务器组件在服务器上针对每个请求运行一次,因此它们没有state,也不能使用仅存在于客户端的功能。具体来说,服务器组件:

    • ❌不能使用state,因为它们在服务器上每个请求(概念上)只执行一次。所以不支持 useState() 和 useReducer()。
    • ❌ 不得使用渲染生命周期(effect)。所以不支持useEffect() 和useLayoutEffect()。
    • ❌ 不得使用仅限浏览器的 API,例如 DOM(除非您在服务器上填充它们)。
    • ❌ 不得使用依赖于state或effect的自定义hook,或依赖于仅浏览器 API 的实用程序函数。
    • ❌ 入参中不可以包含无法序列化的类型(如函数)
    • ✅ 可以使用 async / await 与仅限服务器的数据源,如数据库、内部(微)服务、文件系统等。
    • ✅ 可以呈现其他服务器组件、本机元素(div、span 等)或客户端组件。
    • **Server Hooks/Utilities:**开发人员还可以创建专为服务器设计的自定义hook或utils。适用于服务器组件的所有规则。例如,服务器hook的一个用例是为访问服务器端数据源提供帮助程序。
  • Client Components: 这些是标准的 React 组件,所以你习惯应用的所有规则。要考虑的主要新规则是他们不能对服务器组件做什么。客户端组件:

    • ❌ 不能导入服务器组件hook/utils或调用服务器hook/utils,因为它们只能在服务器上工作

      • 但是,服务器组件可以将另一个服务器组件作为子组件传递给客户端组件:。从客户端组件的角度来看,它的子组件将是一棵已经呈现的树,例如 ServerTabContent 输出。这意味着服务器和客户端组件可以在任何级别的树中嵌套和交错。
    • ❌ 不得使用仅服务器数据源。

    • ✅可以使用state

    • ✅可以使用effect

    • ✅可以使用仅限浏览器的API

    • ✅ 可以使用基于state、effect或仅限浏览器的 API 的自定义hook和utils。

  • Sharing Code Between Server and Client: 除了纯服务器组件和纯客户端组件,开发人员还可以创建同时在服务器和客户端上工作的组件和hook。只要组件满足服务器和客户端组件的所有约束,这就允许跨环境共享逻辑。因此,共享组件和钩子:

    • ❌ 不得使用state。

    • ❌ 不得使用渲染生命周期hook,例如effect。

    • ❌ 不得使用仅限浏览器的 API。

    • ❌ 不得使用依赖于state、effect或浏览器 API 的自定义hook或utils。

    • ❌ 不得使用服务器端数据源。

    • ❌ 不得呈现服务器组件或使用服务器hook。

    • ✅ 可用于服务器和客户端。

    • 尽管共享组件的限制最多,但我们发现在实践中许多组件已经遵守这些规则并且可以在不修改的情况下跨服务器和客户端使用。许多组件只是根据某些条件简单地转换一些 props,而不使用状态或加载额外的数据。这就是为什么共享组件是默认的。

附录

相关推荐
答案answer6 分钟前
three.js 实现几个好看的文本内容效果
前端·webgl·three.js
Running_C14 分钟前
一文读懂跨域
前端·http·面试
南囝coding18 分钟前
这个Web新API让任何内容都能画中画!
前端·后端
起这个名字25 分钟前
Vue2/3 v-model 使用区别详解,不了解的来看看
前端·javascript·vue.js
林太白25 分钟前
VitePress项目工程化应该如何做
前端·后端
七夜zippoe27 分钟前
Chrome 插件开发实战
前端·chrome·插件开发
ScottePerk31 分钟前
css之再谈浮动定位float(深入理解篇)
前端·css·float·浮动布局·clear
RiemannHypo39 分钟前
Vue3.x 全家桶 | 12 - Vue 的指令 : v-bind
前端
弹简特43 分钟前
【Java web】HTTP 与 Web 基础教程
java·开发语言·前端
海拥1 小时前
AI 编程实践:用 Trae 快速开发 HTML 贪吃蛇游戏
前端·trae