什么是流式渲染?
流式渲染的核心理念是將 HTML 文檔分割成小塊(chunk),并逐步地發送給客戶端,而非等待整個頁面完整生成后再進行傳輸。這種方式能夠極大地提升用戶的初始加載體驗,特別是在網絡條件不佳或者頁面內容復雜的情況下。
流式渲染并非新興技術,早在 90 年代,網頁瀏覽器就已開始運用這種模式來處理 HTML 文檔。不過,在 SPA(單頁應用)大行其道的時期,由于其核心在于客戶端動態渲染內容,流式渲染未能引起廣泛關注。然而,現今隨著服務端渲染技術的日臻成熟,流式渲染已成為顯著優化首屏加載性能的有力手段。
Node.js 實現簡單流式渲染
HTTP 是 Node.js 中的一等公民,其在設計時就充分考慮了流式傳輸和低延遲特性。這使得 Node.js 極為適合作為 Web 庫或框架的構建基礎。
———— Node.js 官網
Node.js 從設計之初就將流式傳輸數據納入考量,以下是一個簡單的示例代碼:
const Koa = require('koa');
const app = new Koa();// 假設數據需要 5 秒的時間來獲取
renderAsyncString = async () => {return new Promise((resolve, reject) => {setTimeout(() => {resolve('<h1>Hello World</h1>');}, 5000);})
}app.use(async (ctx, next) => {ctx.type = 'html';ctx.body = await renderAsyncString();await next();
});app.listen(3000, () => {console.log('App is listening on port 3000');
});
這是一個簡化的業務場景,運行之后,會出現長達 5 秒的白屏,然后才顯示出"Hello World"這段文字。
毫無疑問,沒有用戶會愿意忍受一個長達 5 秒的白屏網頁!在?web.dev?對于 TTFB(Time To First Byte,首字節時間)的介紹中提到,加載第一個字節的時間應當控制在 800ms 以內,才能稱得上是優質的 Web 網站服務。
為了改善這種情況,我們可以借助流式渲染技術。比如,先向用戶呈現一個加載中的提示或者骨架屏,以此來優化用戶體驗。下面是改進后的代碼:
const Koa = require('koa');
const app = new Koa();
const Stream = require('stream');// 假設數據需要 5 秒的時間來獲取
renderAsyncString = async () => {return new Promise((resolve, reject) => {setTimeout(() => {resolve('<h1>Hello World</h1>');}, 5000);})
}app.use(async (ctx, next) => {const rs = new Stream.Readable();rs._read = () => {};ctx.type = 'html';rs.push('<h1>loading...</h1>');ctx.body = rs;renderAsyncString().then((string) => {rs.push(`<script>document.querySelector('h1').innerHTML = '${string}';</script>`);})
});app.listen(3000, () => {console.log('App is listening on port 3000');
});
采用流式渲染后,頁面最初會顯示"loading…",然后在 5 秒后更新為"Hello World"。
需要特別注意的是,Safari 瀏覽器對于何時觸發流式傳輸可能存在一些限制(以下內容未找到官方說明,而是通過實踐總結得出):
- 傳輸的 chunk 大小需大于 512 字節。若小于此值,可能無法有效觸發流式傳輸,影響用戶體驗。
- 傳輸的內容必須能夠在屏幕上實際渲染。例如,傳輸
<div style="display:none;">...</div>
這樣隱藏的內容可能是無效的,無法實現流式渲染的預期效果。
聲明式 Shadow DOM,不依賴 javascript 實現
在上述的代碼中,我們運用了一定的 JavaScript 代碼。本質上,我們需要預先渲染一部分 HTML 標簽作為占位,隨后再用新的 HTML 標簽對其進行替換。使用 JavaScript 來實現這一過程相對容易,但如果禁用了 JavaScript 呢?
這就可能需要借助一些?Shadow DOM?的技巧!眾多組件化設計的前端框架都包含了 slot(插槽)的概念,在 Shadow DOM 中也提供了 slot 標簽,其可用于創建可插入的 Web Components。在 Chrome 111 及以上版本中,我們能夠使用聲明式 Shadow DOM,無需依賴 JavaScript,在服務器端就能實現 shadow DOM 的功能。以下是一個聲明式 Shadow DOM 的示例:
<template shadowrootmode="open"><header>Header</header><main><slot name="hole"></slot></main><footer>Footer</footer></template><div slot="hole">插入一段文字!</div>
從中可以清晰地看到,我們的文字成功插入到了 slot 標簽之中。利用聲明式 Shadow DOM,我們能夠對之前的示例進行改寫:
const Koa = require('koa');
const app = new Koa();
const Stream = require('stream');// 假設數據需要 5 秒的時間來獲取
renderAsyncString = async () => {return new Promise((resolve, reject) => {setTimeout(() => {resolve('<h1>Hello World</h1>');}, 5000);})
}app.use(async (ctx, next) => {const rs = new Stream.Readable();rs._read = () => {};ctx.type = 'html';rs.push(`<template shadowrootmode="open"><slot name="hole"><h1>loading</h1></slot></template>`);ctx.body = rs;renderAsyncString().then((string) => {rs.push(`<h1 slot="hole">${string}</h1>`);rs.push(null);})
});app.listen(3000, () => {console.log('App is listening on port 3000');
});
運行這段改寫后的代碼,其結果與之前完全相同。更為重要的是,即便我們禁用了瀏覽器的 JavaScript,代碼依然能夠正常運行!
聲明式 Shadow DOM 是一個相對較新的特性,您可以在這篇文檔中獲取更多詳細信息。
react 實現流式渲染
現在讓我們轉換視角,來看看 React 框架中的流式渲染。自 React 18 版本之后,在框架層面上開始支持流式渲染。下面是使用 nextjs 對之前的示例進行改寫的代碼:
import { Suspense } from 'eact'const renderAsyncString = async () => {return new Promise((resolve, reject) => {setTimeout(() => {resolve('Hello World!');}, 5000);})
}async function Main() {const string = await renderAsyncString();return <h1>{string}</h1>
}export default async function App() {return (<Suspense fallback={<h1>loading...</h1>} ><Main /></Suspense>)
}
運行這段代碼,其效果與之前的示例完全一致,并且同樣無需運行任何客戶端的 JavaScript 代碼。
關于 React 的流式渲染,您可以在官方的技術層面解釋中獲取更深入的信息。在本文中,僅作為對流式渲染的概要介紹,不對其進行更為細致的講解。
總結
本文從理論層面深入探討了流式渲染的相關實現方案。理論上,流式渲染的概念和實現相對簡單。HTTP 標準和 Node.js 早在很久以前就對這一特性提供了支持。然而,在實際的工程應用中,流式渲染并非易事。以 React 為例,要實現流式渲染,不僅需要 React 自身作為用戶界面(UI)框架提供支持,還需要借助像 nextjs 這樣的元框架(meta framework)來賦予服務端相應的能力。