EdgeOne 边缘函数 - 如何实现边缘渲染 ESR

本文介绍边缘渲染模式的实现原理和优势,以及如何使用 EdgeOne 边缘函数,在边缘节点上实现 ESR(Edge Side Rendering)边缘渲染。

阅读本文之前,请先了解以下背景知识:
1.  Web 开发相关知识
2.  EdgeOne 边缘函数

ESR(Edge Side Rendering) 边缘渲染,对比传统的 CSR、SSR 模式,提高 Web 应用性能,提升用户体验的同时,大幅减少服务器压力,降低业务维护的成本。

一、前言

Web 开发领域,渲染网页的方法主要有两种:服务器端渲染(SSR)和客户端渲染(CSR)。虽然这两种方法服务于相同的最终目标,但它们具有不同的流程和优势。构建 Web 应用程序时,选择正确的渲染策略,对优化性能、改善用户体验、增强 SEO 有重大意义。

CSR

客户端渲染 (CSR) 中,浏览器会下载最小的 HTML 页面以及该页面所需的 JavaScript。JavaScript 用于更新 DOM 并呈现页面。

  • CSR 的优势:前后端分离;前端代码可作为静态文件使用 CDN 进行加速;

  • CSR 的不足:代码暴露在客户端,安全性差SEO 支持不好;首屏加载速度慢,出现白屏

SSR

服务器端渲染 (SSR) 是一种在服务器端渲染 Web 内容的技术。服务端生成 HTML,然后将其发送到客户端。

  • SSR 的优势:安全性好;SEO 支持好;避免加载 JS 导致的白屏;

  • SSR 的不足:回源请求耗时服务器压力大

二、边缘渲染

CSR 与 SSR 各具优势,为了整合两种渲染模式的优点,业界做出了很多技术层面的尝试(SSG、ISR、同构渲染等)。本文介绍的边缘渲染(Edge Side Rendering 简称 ESR)方案,跳出从技术层面优化渲染的局限,提出一种全新的架构用于解决页面渲染问题。

EdgeOne 边缘函数提供了边缘节点 Serverless 代码执行环境,只需编写业务函数代码并设置触发规则,无需配置和管理服务器等基础设施,即可在靠近用户的边缘节点上弹性、安全地运行代码。

有了边缘函数提供给我们的 Serverless 环境,我们就可以将原本运行在服务端的代码移动到更贴近用户的边缘节点上执行,这样既解决了 SSR 回源请求耗时长和服务器压力大的问题,又保留了首屏加载速度快、SEO 支持好的优点。

同时,由于 ESR 仍然保持客户端-服务端的模式(ESR 的服务端是边缘节点),像同构渲染等优化方案,也可以完美移植到 ESR 方案中。

依托于 EdgeOne 遍布全球的边缘节点网络和边缘函数 Serverless 环境,ESR 在靠近用户的边缘节点上进行页面的渲染,具备以下优点:

  • 页面加载速度快:分布式边缘节点靠近用户,避免回源请求带来的延迟;

  • 用户体验好:页面渲染在边缘节点完成,避免加载 JS 导致的页面白屏;

  • 服务压力小:分布式边缘节点负载均衡,可以轻松应对请求激增等极端情况;

为了验证 ESR 边缘渲染的效果,我们使用三种不同的渲染模式,渲染相同的简单页面,记录页面的性能。

1. SSR 与 CSR 代码均部署在新加坡服务器上;
2. ESR 代码分布式部署在 EdgeOne 边缘节点;
3. 客户端测试机器位于深圳市;

多次测试,取平均值,最终数据如下:

分析统计数据可以得出,ESR 大幅度提高了页面加载和渲染性能,从而使用户获得更好的使用体验。

同时分析请求回源情况,客户端每进行一次访问,不同的渲染模式下请求回源情况如下:

由此可见,ESR 边缘渲染模式下,回源请求数最小(0),请求资源体积最小,从而减轻源站服务压力。

注意:以上测试数据均使用最简单的测试用例,如需测试生产环境下的复杂代码,请联系 EdgeOne 边缘函数团队协助进行性能评估,联系方式见文章最后。

三、代码示例

了解了 ESR 边缘渲染的优势,下面我们将按照从简单到复杂的顺序,介绍如何开发和部署代码,实现 ESR 边缘渲染方案。

边缘函数在边缘 Runtime 上运行,Edge Runtime 是构建在 V8 JavaScript 引擎之上的运行时,API 设计遵循 Web Standards,因此,我们可以在边缘函数中使用多种技术栈用于 ESR 边缘渲染,包括但不限于:

1. 代码示例均使用  边缘函数脚手架 TEF 进行开发和预览,如需体验,请先了解 TEF 脚手架的使用;
2. 受限于文章篇幅,下文针对典型的渲染场景给出代码示例,在此基础上可以拓展出多种玩法;
3. 文章中仅展示关键部分代码,完整代码可在  边缘函数最佳实践仓库 查看;

3.1 HTML 直出

直接生成 HTML 内容,响应客户端:

const html = `...HTML...`;

async function handleEvent(event) {
  const res = new Response(html, {
    headers: { 'Content-Type': 'text/html' },
  });

  event.respondWith(res);
}

addEventListener('fetch', handleEvent);

3.2 模版引擎

使用模版引擎构建 HTML 并响应客户端,以 art-template 为例:

import template from 'art-template/lib/template-web';

const tpl = `
  ...
  <title><%= data.title %></title>
  ...
`;

function handleEvent(event) {
  const html = template.render(tpl, {
    data: {
      title: 'ESR - ART-TEMPLATE - EdgeFunctions',
      ...
    },
  });
  ...
}
...

3.3 Nginx SSI

SSI(Server Side Includes)是一种在服务器端嵌入动态内容到静态 HTML 页面的技术,它使用特殊的注释标签来表示需要插入的内容。一些存量项目可能会使用到 Nginx SSI 进行 HTML 文件的组装,同样我们也可以使用边缘函数实现基础的 SSI。

- 示例代码仅对  include 指令进行处理,可自行拓展;
- 示例代码直接使用 HTML 字符串进行演示,生产环境下,可以使用  import index from './index.html'; 的方式,导入 html 文件;
const body = `...BODY...`;

const footer = `...FOOTER...`;

async function handleSSI(html) {
  const ssiRegex = /<!--#include virtual="(.+?)" -->/g;
  const replacements = {
    '/body.html': body,
    '/footer.html': footer,
  };

  return html.replace(ssiRegex, (_, includePath) => {
    return replacements[includePath] || '';
  });
}

async function handleEvent(event) {
  const html = `
<!DOCTYPE html>
<html>
  ...
  <!--#include virtual="/body.html" -->
  <!--#include virtual="/footer.html" -->
</html>
`;

  const processedHtml = await handleSSI(html);
  ...
}
...

3.4 React

大多数 Web 开发者更习惯使用 React 或者 Vue.js 等框架来构建界面,同样的,我们也可以在边缘函数中实现。

import React from 'react';
import { renderToString } from 'react-dom/server.browser';

function Home() {
  ...HOME...
}

async function handleEvent(event) {
  const content = renderToString(<Home />);

  const html = `
<html>
  ...
  <body style="padding: 40px">
    <div>${content}</div>
  </body>
</html>
`;
  ...
}
...

Vue.js 的实现与 React 类似,不对 Vue.js 的实现进行详细介绍,如有疑问请联系作者。

3.5 React Router

下面介绍如何在边缘渲染场景下处理路由。上文介绍的场景中,HTML 直出、模版引擎、Nginx SSI 这几种模式都适用于简单的页面生成需求,因此可以手动处理路由或使用 ef-manaia framework 。

下面主要介绍 React + React Router 的方案,在边缘函数中我们可以使用 react-router-dom 的 StaticRouter 来处理静态路由。

import React from 'react';
import { renderToString } from 'react-dom/server.browser';
import { Route, Routes, Link } from 'react-router-dom';
import { StaticRouter } from 'react-router-dom/server';

function Home() { ...HOME... }

function Blog() { ...BLOG... }

function App() {
  return (
    ...
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/blog" element={<Blog />} />
    </Routes>
    ...
  );
}

async function handleEvent(event) {
  const url = new URL(event.request.url);
  const path = url.pathname;

  const html = renderToString(
    <StaticRouter location={path}>
      <App />
    </StaticRouter>,
  );
  ...
}
...

根据不同路由渲染不同组件:

3.6 流式渲染

传统的 SSR 通常会等到服务器完全渲染完整个页面后再将其发送给客户端,导致请求响应的时间较长。

流式渲染 改进了这一点,利用 HTTP 1.1 的分块传输编码(Chunked Transfer Encoding)特性以及浏览器的逐步解析和渲染特性,将 HTML Chunk 逐步发送给客户端,使得浏览器能够逐步显示页面。

这种技术充分利用了 SSR 和流式传输的优势,提供了更流畅的用户体验和更快的性能。 我们同样可以使用边缘函数实现流式渲染。

原生流式渲染

在边缘函数中,使用 TransformStream API,创建可读端与可写端,实现流式写入数据:

async function sleep() { ... }

async function streamHTMLContent(writable) {
  const writer = writable.getWriter();

  await writer.write(`
    ...
    <div>First segment</div>
`);

  console.log(await sleep());
  await writer.write(`
    <div>Second segment</div>
    ...
`);

  await writer.close();
}

async function handleEvent(event) {
  const { readable, writable } = new TransformStream();

  streamHTMLContent(writable);

  const res = new Response(readable, {
    headers: { 'Content-Type': 'text/html' },
  });

  event.respondWith(res);
}
...

流式渲染效果(GIF 动图):

React 流式渲染

在边缘函数中,使用 React 的 Suspense 以及 React-DOM 的 renderToReadableStream API 实现 React 生态下的流式渲染。

修改边缘函数入口文件代码:

import React from 'react';
import { renderToReadableStream } from 'react-dom/server.browser';
...

import Blog from './components/Blog';
import Home from './components/Home';

function App() { ...APP... }

async function handleEvent(event) {
  ...
  
  const stream = await renderToReadableStream(
    <StaticRouter location={path}>
      <App />
    </StaticRouter>,
  );

  const res = new Response(stream, {
    headers: { 'Content-Type': 'text/html' },
  });

  event.respondWith(res);
}
...

将 Blog 组件拆分为独立文件,并修改为:

import React, { Suspense, lazy } from 'react';
...

const LazyContent = lazy(() => import('./Content'));

function Blog() {
  return (
    ...
    <Suspense fallback={<div>Loading...</div>}>
      <LazyContent />
    </Suspense>
    ...
  );
}

export default Blog;

新建 Content.jsx 组件:

import React from 'react';

async function sleep() { ... }

let data;
const getData = () => {
  if (!data) {
    data = sleep();
    throw data;
  }

  if (data?.then) {
    throw data;
  }

  const result = data;
  data = undefined;
  return result;
};

function Content() {
  const data = getData();
  ...
}

export default Content;

流式渲染效果(GIF 动图):

从上图可以看到,对于 blog 请求,我们在 207ms 时即可看到内容,异步的 blog content 片段在 1s 左右流式响应,整个 Content Download 动作持续了1.15s。

3.7 同构渲染

同构渲染 指的是服务器端和客户端使用相同的代码来渲染应用。这种方式结合了服务器端渲染(SSR)和客户端渲染(CSR)的优点。

在同构渲染方案中,首次访问页面时,服务器会生成完整的 HTML 页面,这样可以更快地向用户展示页面内容,提高首屏加载速度,同时也有利于搜索引擎优化(SEO)。一旦页面被送到浏览器,JavaScript 会接管页面,将其转变为一个单页面应用(SPA),用户在此后的交互都会在客户端完成。

React 和 Vue.js 等现代 JavaScript 框架都支持同构渲染。在 React 中,我们可以使用 renderToString API 在服务器端渲染组件,然后在客户端使用 ReactDOM.hydrateRoot 方法将服务器渲染的静态 HTML 注水为可交互的应用。在边缘函数中,同样可以利用 hydrate 特性实现同构渲染。

首先我们需要拆分出 App 组件:

import React from 'react';
...

function App() {
  return (
    <html>
      ...
      <script src="/client/index.js"></script>
    </html>
  );
}

export default App;

同时,修改 Blog 代码,可以使用 Hook :

import React, { useEffect, useState } from 'react';
...

async function sleep() { ... }

function Blog() {
  const [data, setData] = useState('Loading');

  const asyncData = async () => {
    await sleep();
    setData('Blog Content');
  };

  useEffect(() => {
    asyncData();
  }, []);

  return (
    ...
    <div>{data}</div>
    ...
  );
}

export default Blog;

同构渲染的模式下,我们需要同时打包出服务端代码和客户端代码,服务端使用 renderToString 输出 HTML,客户端使用 hydrateRoot 进行水合。

server.jsx

import React from 'react';
import { renderToString } from 'react-dom/server.browser';
import { StaticRouter } from 'react-router-dom/server';

import App from './App';

async function handleEvent(event) {
  ...
  const html = renderToString(
    <StaticRouter location={path}>
	 	<App />
	</StaticRouter>,
  );
  ...
}
...

client.jsx

import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';

import App from './App';

hydrateRoot(
  document,
  <BrowserRouter>
    <App />
  </BrowserRouter>,
);

由于需要构建两份代码,因此需要修改 TEF 配置进行自定义打包。

tef.toml 配置:

name = "esr-react-hydrate"
main = "./src/server.jsx"
zone_id = "YOUR_ZONE_ID"
create_date = ""

...

[build]
command = "npm run dev"
target_file = "./dist/edgefunction.js"

[site]
assets_path = "/client"
assets_dir = "./dist/"

同构渲染模式下,服务端只负责首屏渲染:

服务端渲染首屏,同时加载 index.js ,后续的异步请求(sleep)在 index.js 加载完成后,在客户端发起:

3.8 vite-plugin-ssr

除了上面介绍的几种边缘渲染场景外,边缘函数目前还支持使用完整的服务端渲染框架。

以 vite-plugin-ssr 为例,使用 TEF 脚手架可以一键生成适用于边缘函数的 vite-plugin-ssr 项目,集成了 vite 配置、TEF 配置、路由配置、同构渲染等开发必备能力,开箱即用。

通过 tef generate 命令生成项目 tef generate esr-vps-react vps-react :

运行 tef dev,访问 http://localhost:8080:

四、总结

EdgeOne 边缘函数拥有 分布式部署贴近用户超低延迟弹性扩容Serverless 环境 等优势,借助 EdgeOne 边缘函数,我们提出并实现了 ESR 边缘渲染方案。

ESR 边缘渲染方案具有 较低的延迟更快的页面加载速度减轻服务器负担更好的缓存策略 和 更好的可扩展性 等优点。使得 ESR 边缘渲染方案成为 提高 Web 应用程序性能 和 用户体验 的有效方法。

  • 32
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值