HTML 渲染演进之路
面向前后端的内部分享讲稿,写出大纲和一定的细节经过AI再次整理
一个简单的HTML页面
HTML页面从初创到现在变化不大,看了这么多年的网页,也就是多了一些标签,HTML看起来都一样。但在其背后的渲染逻辑上,却在不断的演进。
从最初的静态 HTML,到服务端预渲染,到纯客户端渲染(CSR - Client-Side Rendering),又回到到预渲染,但其中又有细化,分出了SSR(服务端渲染 - Server-Side Rendering)和SSG(静态站点生成 - Static Site Generation),并且脚步在不断前进。
第一节:Web 渲染技术是怎么演进的
1.1 最初的 Web 时代:那些简单的静态页面
早期,网站就是一堆静态的 HTML 文件:
<!-- 1990年代的网站 -->
<html>
<head>
<title>我的主页</title>
</head>
<body>
<h1>欢迎来到我的网站</h1>
<p>这是一个静态页面</p>
<a href="about.html">关于我</a>
</body>
</html>
特点:
- 服务器直接返回 HTML 文件
- 没有 JavaScript 交互
- 页面跳转需要重新加载整个页面
- 但是速度很快,SEO 友好
1.2 客户端渲染(CSR)的兴起
随着 JavaScript 的发展,特别是 Ajax 技术的出现,客户端渲染(Client-Side Rendering, CSR) 开始流行。
// 典型的CSR应用
function App() {
const [data, setData] = useState(null);
useEffect(() => {
// 在客户端获取数据
fetch("/api/data")
.then((res) => res.json())
.then(setData);
}, []);
if (!data) return <div>加载中...</div>;
return <div>{data.content}</div>;
}
flowchart TD
A["用户访问网站"] --> B["下载HTML骨架"]
B --> C["下载JavaScript Bundle"]
C --> D["JavaScript执行"]
D --> E["发起API请求"]
E --> F["获取数据"]
F --> G["渲染页面内容"]
G --> H["页面可交互"]
CSR 的优势:
- 路由切换无需重新加载页面
- 丰富的交互体验
- 减轻服务器压力
CSR 的问题:
- 白屏时间长:用户需要等待 JavaScript 下载、解析、执行完成
- SEO 不友好:搜索引擎爬虫难以获取动态生成的内容
- 设备性能依赖:低端设备上 JavaScript 执行缓慢
第二节:预渲染的回归 - SSR、SSG
2.1 静态站点生成(SSG)——提前把页面做好
静态站点生成(Static Site Generation, SSG) 将渲染提前到构建时:
// Next.js的SSG示例
export async function getStaticProps() {
const posts = await getBlogPosts();
return {
props: { posts },
// 60秒后重新生成
revalidate: 60,
};
}
export default function Blog({ posts }) {
return (
<div>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
SSG 的优势:
- 极致性能:纯静态文件,可以全球 CDN 分发
- 成本低廉:无服务器运行成本
- 高可用性:没有服务器单点故障
SSG 的限制:
- 内容更新延迟:需要重新构建部署
- 个性化困难:难以处理用户相关的动态内容
- 构建时间长:大型站点构建可能耗时很久
2.2 SSR (服务端渲染) - Server-Side Rendering
一个古老而有效的解决方案:在服务端渲染 HTML。其实就是让服务器把页面先渲染好,再发给浏览器。
flowchart TD
A["用户请求"] --> B["服务器处理"]
B --> C["获取数据"]
C --> D["渲染HTML"]
D --> E["返回完整HTML"]
E --> F["浏览器显示内容"]
F --> G["下载JavaScript"]
G --> H["水合(Hydration)"]
H --> I["页面可交互"]
SSR 的优势:
- 首屏渲染快:用户立即看到内容
- SEO 友好:搜索引擎能够抓取到完整的 HTML
- 更好的用户体验:特别是在慢网络或低端设备上
2.3 水合(Hydration)——让静态页面"活"过来
SSR 返回的 HTML 只是静态的,就像一张照片,要让页面能点击、能交互,需要经历**水合(Hydration)**过程。这个过程就是让 JavaScript 接管页面,给它"注入生命":
<body>
<div id="counter-container">
<h1>计数器</h1>
<p>当前计数: <span id="count">0</span></p>
<button id="incrementButton">增加</button>
</div>
<script src="client-hydration.js"></script>
</body>
document.addEventListener('DOMContentLoaded', () => {
// 服务端渲染出已经存在的 DOM 元素
const countSpan = document.getElementById('count');
const incrementButton = document.getElementById('incrementButton');
// 服务端渲染时就已经计算好的初始值
// 这个值可能会从服务端传递过来,或者通过HTML属性内联传递
let currentCount = parseInt(countSpan.textContent);
console.log('--- 客户端脚本开始水合 ---');
console.log('初始 DOM 状态:', countSpan.textContent);
// 水合:为现有 DOM 元素添加交互能力(事件监听器)
incrementButton.addEventListener('click', () => {
currentCount++;
countSpan.textContent = currentCount; // 更新现有 DOM 元素的内容
console.log('按钮被点击,计数更新为:', currentCount);
});
});
水合过程中发生了什么?
现代的主流开发框架都是基于虚拟DOM的,水合逻辑大部分是基于此场景继续做深入的优化,达到既有便捷的编程范式,又能达到更优的性能。
- 重建虚拟 DOM:客户端 JavaScript 重新执行组件代码
- DOM 比对:将虚拟 DOM 与服务端渲染的 DOM 进行比较
- 事件绑定:为 DOM 元素附加事件监听器
- 状态恢复:恢复应用的客户端状态
// 水合的简化实现
function hydrate(vdom, domElement) {
// 1. 遍历服务端DOM
const walker = document.createTreeWalker(domElement);
// 2. 比对虚拟DOM和真实DOM
while (walker.nextNode()) {
const node = walker.currentNode;
const vdomNode = findMatchingVdomNode(vdom, node);
// 3. 绑定事件监听器
if (vdomNode?.onClick) {
node.addEventListener("click", vdomNode.onClick);
}
}
}
水合的性能代价深度分析:
问题 | 原因 | 性能影响 | 量化数据 | 优化方案 |
---|---|---|---|---|
JavaScript 下载 | 需要下载完整的应用代码 | TTI (可交互时间) 延迟 | 每增加100KB延迟~200ms | 代码分割、懒加载 |
重复执行 | 组件在服务端和客户端都要执行 | CPU 占用 | 相同组件执行2次 | 选择性水合 |
主线程阻塞 | 水合过程阻塞主线程 | 交互延迟 | 水合期间FID (首次输入延迟) 增加3-5倍 | 时间分片、并发模式 |
全量处理 | 默认水合整个页面 | 资源浪费 | 非交互内容也被水合 | Islands (孤岛) 架构 |
DOM 重构 | 重新构建虚拟DOM树 | 内存开销 | 内存使用增加50-100% | 流式水合 |
2.2.1 水合过程的详细分解
// 水合过程的技术细节
class DetailedHydrationProcess {
constructor(rootElement, vdom) {
this.rootElement = rootElement;
this.vdom = vdom;
this.metrics = {
vdomReconstruction: 0,
domReconciliation: 0,
eventBinding: 0,
stateHydration: 0
};
}
async hydrate() {
const startTime = performance.now();
// 阶段1: 重构虚拟DOM (最耗时的部分)
console.time('VDOM重构');
const virtualDOM = await this.reconstructVirtualDOM();
console.timeEnd('VDOM重构');
this.metrics.vdomReconstruction = performance.now() - startTime;
// 阶段2: DOM对比和协调
console.time('DOM协调');
await this.reconcileWithServerDOM(virtualDOM);
console.timeEnd('DOM协调');
// 阶段3: 事件监听器绑定
console.time('事件绑定');
await this.bindEventListeners(virtualDOM);
console.timeEnd('事件绑定');
// 阶段4: 状态恢复
console.time('状态恢复');
await this.restoreClientState();
console.timeEnd('状态恢复');
return this.metrics;
}
async reconstructVirtualDOM() {
// 模拟组件树重新执行的开销
const componentCount = this.countComponents(this.vdom);
// 每个组件平均需要执行约0.5-2ms (取决于复杂度)
await this.simulateComponentExecution(componentCount);
return { componentCount };
}
}
第三节:怎么解决水合的性能问题
水合根据不同的场景和性能问题,会出现样式重组、页面展示但不能点击等。这些称之为水合税。为了降低"水合税",社区探索出了一系列优化方案。
3.1 渐进式水合——分批次进行
思想:不再一次性水合整个页面,而是按需、分块地进行。
实现:React 18 通过 Suspense
和 React.lazy
提供了原生的支持。
// React 18 选择性水合示例
import { lazy, Suspense } from "react";
// 使用 lazy 动态导入重组件
const HeavyComments = lazy(() => import("./HeavyComments"));
const InteractiveMap = lazy(() => import("./InteractiveMap"));
function BlogPost() {
return (
<div>
<article>...文章内容...</article>{" "}
{/* 这部分可能不需要交互,是纯静态HTML */}
{/* 使用 Suspense 包裹,优先水合视口内的组件 */}
<Suspense fallback={<Spinner />}>
<HeavyComments />
</Suspense>
<Suspense fallback={<LoadingMap />}>
<InteractiveMap />
</Suspense>
</div>
);
}
效果:优先水合用户当前视口内或即将交互的关键组件,将长任务拆分为多个小任务,从而改善 TTI 和 INP。
3.2 岛屿架构——只在需要的地方加 JavaScript
思想:更进一步!默认一切皆静态,只在孤立的"岛屿"上进行水合。
代表框架:Astro
graph TD
subgraph "传统全量水合 (Monolithic)"
A["整个页面是一个大型水合单元"] --> B["所有组件都被打包进客户端JS"]
B --> C["客户端整体水合<br/>主线程长时间占用"]
end
subgraph "岛屿架构 (Islands)"
D["页面主体是零JS的静态HTML"] --> E["交互组件,即岛屿"]
E --> F["仅岛屿组件的JS被发送"]
D --> G["静态内容,无水合开销"]
F --> H["进入视口时<br/>独立、小范围水合"]
end
代码示例:
---
// 页面大部分可以是纯静态的 .astro 组件,构建时渲染
import StaticCard from '../components/StaticCard.astro'
// 交互组件(岛屿),可以是 React/Vue/Svelte 组件
import Counter from '../components/Counter.jsx'
import ImageCarousel from '../components/ImageCarousel.jsx'
---
<!-- 这个组件纯粹是 HTML+CSS,零客户端 JS -->
<StaticCard />
<!-- 这个计数器组件,在页面加载后立即水合 -->
<Counter client:load />
<!-- 这个图片轮播组件,只有当它滚动到视口内才开始水合 -->
<ImageCarousel client:visible />
核心优势:从源头上将运送到客户端的 JavaScript 量降到最低,对于内容密集型网站(如博客、电商、新闻门户)是革命性的。
3.3 流式 SSR——边做边发
思想:不等整个 HTML 在服务端生成完毕,而是生成一块、发送一块。
// Node.js 流式响应核心代码 (配合 React 18)
import { renderToPipeableStream } from "react-dom/server";
res.setHeader("Content-Type", "text/html");
res.setHeader("Transfer-Encoding", "chunked");
const stream = renderToPipeableStream(<App />, {
onShellReady() {
// 1. Shell (页面骨架) 准备就绪,立刻开始传输
res.statusCode = 200;
stream.pipe(res);
},
onShellError() {
// ... 错误处理
},
onAllReady() {
// 2. 所有内容(包括 Suspense 异步数据)都已就绪
// 但此时 Shell 已经发送,用户已经看到内容了
},
});
性能收益:
- TTFB (首字节时间) 大幅降低:用户几乎立刻就能收到页面的
head
和首屏骨架。 - FCP 与水合协同:浏览器可以边接收 HTML 边渲染,甚至可以与渐进式水合结合,让先到达的 HTML 块先进行水合。
第四节:现代渲染技术的新发展
如果说以上方案是在"优化"水合,那么接下来的架构则是在尝试"消灭"水合。
4.1 可恢复性 (Resumability) - Qwik 的魔法
核心思想:不是在客户端"重新执行"一遍来重建状态和绑定事件,而是让应用在客户端"恢复"运行。
graph TD
subgraph "Hydration (水合)"
H1["服务端: 运行App,生成HTML"] --> H2["客户端: 下载HTML + JS"]
H2 --> H3["重新运行App<br/>构建VDOM,比对DOM<br/>附加所有事件监听器"]
H3 --> H4["App 可交互"]
end
subgraph "Resumability (可恢复性)"
R1["服务端: 运行App,生成HTML"] --> R2["序列化状态和<br/>事件监听器到HTML中"]
R2 --> R3["客户端: 下载HTML,JS极小"]
R3 --> R4["App 立即可交互 ✨"]
R4 --> R5["用户点击时<br/>按需下载并执行事件处理器"]
end
Qwik 如何实现: 它将所有应用状态、组件关系、事件监听器都序列化为 HTML 属性。
<!-- Qwik 输出的 HTML -->
<button q:host on:click="./chunk-a.js#handler_symbol">Click me</button>
<!--
没有传统的 onclick="..."
所有信息都被编码了。点击时,Qwik的微型加载器才知道
要去加载哪个JS文件里的哪个函数来执行。
-->
颠覆性:实现了 O(1) 的启动时间。无论应用多大,它的可交互时间都是恒定的、几乎是瞬时的。
4.2 React Server Components (RSC)
核心思想:从组件层面区分"服务端"和"客户端",服务端组件的代码永远不会被下载到浏览器。
// --- Server Component (在服务端运行, 永不进入客户端 JS 包) ---
// Note.js (无 "use client" 指令)
import db from './db';
import NotePreview from './NotePreview.client.js'; // 导入一个客户端组件
export default async function NoteList() {
const notes = await db.query('SELECT * FROM notes');
return (
<ul>
{notes.map(note => (
<li key={note.id}>
{/* NotePreview 是一个客户端组件,它需要交互 */}
<NotePreview note={note} />
</li>
))}
</ul>
);
}
// --- Client Component (会水合的常规组件) ---
// NotePreview.client.js
'use client'; // <-- 这个指令是关键!
import { useState } from 'react';
export default function NotePreview({ note }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div onClick={() => setIsExpanded(!isExpanded)}>
<h3>{note.title}</h3>
{isExpanded && <p>{note.body}</p>}
</div>
);
}
革新点:
- 零水合启动时间:服务端组件的代码永远不会被发送到客户端
- 精确的代码分割:只有交互组件的代码会被下载
- 天然的流式渲染:可以先发送静态内容,再流式传输动态数据
// RSC 的性能优势示例
const PerformanceComparison = {
traditional: {
bundleSize: '1.2MB',
ttiTime: '3.2s',
description: '所有组件代码都需要下载和水合'
},
withRSC: {
bundleSize: '400KB', // 减少67%
ttiTime: '1.1s', // 减少66%
description: '只有客户端组件需要下载和水合'
}
};
// RSC 实际应用示例
// --- 服务端组件 (永不进入客户端) ---
async function ProductList() {
// 在服务端直接查询数据库
const products = await db.products.findMany({
where: { featured: true },
include: { images: true, reviews: true }
});
return (
<div className="product-grid">
{products.map(product => (
<div key={product.id}>
<h3>{product.name}</h3>
<img src={product.images[0].url} alt={product.name} />
<p>价格: ¥{product.price}</p>
<ReviewSummary reviews={product.reviews} />
{/* 只有这个按钮需要客户端交互 */}
<AddToCartButton productId={product.id} />
</div>
))}
</div>
);
}
// 服务端组件可以直接导入服务端模块
import { authenticateUser } from './auth-server'; // 这个不会被打包到客户端
import { validatePermissions } from './permissions-server';
async function AdminDashboard() {
const user = await authenticateUser();
const permissions = await validatePermissions(user);
if (!permissions.canViewDashboard) {
return <div>无权限访问</div>;
}
// 服务端直接渲染,无需客户端权限检查逻辑
return (
<div>
<h1>管理仪表板</h1>
<UserStats userId={user.id} />
<RevenueChart permissions={permissions} />
</div>
);
}
4.3 Resumability 深度解析:Qwik 的零水合架构
// Qwik 的可恢复性实现原理
const QwikResumabilityExample = {
// 1. 组件状态序列化
serializeState: `
<!-- Qwik 在 HTML 中序列化状态 -->
<div q:host="./chunk-a.js" q:state='{"count": 5, "user": {"name": "张三"}}'>
<button q:onClick="./handlers.js#increment">
点击次数: 5
</button>
</div>
`,
// 2. 事件处理器延迟加载
lazyEventHandlers: `
// handlers.js - 只在需要时才下载
export const increment = (event, element) => {
const state = JSON.parse(element.getAttribute('q:state'));
state.count++;
element.setAttribute('q:state', JSON.stringify(state));
element.querySelector('button').textContent = \`点击次数: \${state.count}\`;
};
`,
// 3. 微型运行时加载器
tinyLoader: `
// Qwik 的加载器只有 ~1KB
window.qwikLoader = {
async handleEvent(event) {
const element = event.target.closest('[q:onClick]');
if (element) {
const handlerPath = element.getAttribute('q:onClick');
const [modulePath, functionName] = handlerPath.split('#');
// 动态导入处理器
const module = await import(modulePath);
module[functionName](event, element);
}
}
};
document.addEventListener('click', window.qwikLoader.handleEvent);
`
};
// Qwik vs 传统框架的启动对比
const StartupComparison = {
react: {
steps: [
'1. 下载React运行时 (~45KB)',
'2. 下载应用代码 (~200KB)',
'3. 解析和编译JS (~300ms)',
'4. 重建虚拟DOM (~150ms)',
'5. 水合DOM元素 (~200ms)',
'6. 绑定事件监听器 (~100ms)'
],
totalTime: '~750ms + 网络时间',
mainThreadBlocking: '750ms'
},
qwik: {
steps: [
'1. 下载微型加载器 (~1KB)',
'2. 解析事件映射 (~5ms)',
'3. 立即可交互 ✨'
],
totalTime: '~5ms + 网络时间',
mainThreadBlocking: '5ms'
}
};
// Qwik 的智能代码分割
export default component$(() => {
// 这个状态会被序列化到HTML中
const count = useSignal(0);
return (
<div>
<p>计数: {count.value}</p>
{/* 只有点击时才会下载这个处理器的代码 */}
<button onClick$={() => count.value++}>
增加
</button>
{/* 条件渲染的组件也是按需加载 */}
{count.value > 5 && <Confetti />}
</div>
);
});
附:Web 性能指标解析
什么是Web性能指标?
Google 和 W3C 制定了一系列标准化的性能指标,这些指标不仅影响用户体验,还直接影响 SEO 排名。
核心性能指标全景图
现代 Web 性能主要关注三个维度:加载性能、交互性能、视觉稳定性。
graph TD
A["Web 性能指标"] --> B["加载性能"]
A --> C["交互性能"]
A --> D["视觉稳定性"]
B --> B1["TTFB (首字节时间)"]
B --> B2["FCP (首次内容绘制)"]
B --> B3["LCP (最大内容绘制)"]
C --> C1["FID (首次输入延迟)"]
C --> C2["INP (交互到绘制)"]
C --> C3["TTI (可交互时间)"]
C --> C4["TBT (总阻塞时间)"]
D --> D1["CLS (累积布局偏移)"]
style B1 fill:#e3f2fd
style B2 fill:#e8f5e8
style B3 fill:#fff3e0
style C1 fill:#fce4ec
style C2 fill:#f3e5f5
style C3 fill:#e0f2f1
style C4 fill:#fff8e1
style D1 fill:#ffebee
所有性能指标详解
指标缩写 | 中文名称 | 英文全称 | 测量内容 |
---|---|---|---|
TTFB | 首字节时间 | Time to First Byte | 服务器响应第一个字节的时间 |
FCP | 首次内容绘制 | First Contentful Paint | 首次渲染任何内容的时间 |
LCP | 最大内容绘制 | Largest Contentful Paint | 主要内容加载完成的时间 |
FID | 首次输入延迟 | First Input Delay | 用户首次交互的响应延迟 |
INP | 交互到绘制 | Interaction to Next Paint | 所有交互的响应时间 |
TTI | 可交互时间 | Time to Interactive | 页面完全可交互的时间 |
TBT | 总阻塞时间 | Total Blocking Time | 主线程被阻塞的总时间 |
CLS | 累积布局偏移 | Cumulative Layout Shift | 页面布局稳定性 |
Core Web Vitals - Google 的重点关注指标
Google 特别强调三个指标,称为 Core Web Vitals(核心网页指标):
- LCP (最大内容绘制):衡量加载性能
- FID/INP (首次输入延迟/交互到绘制):衡量交互性能
- CLS (累积布局偏移):衡量视觉稳定性
这三个指标直接影响 Google 搜索排名
加载性能指标
1.1 TTFB (首字节时间) - Time to First Byte
简单理解:从点击链接到收到服务器第一个字节数据的时间。
影响因素:
- 服务器处理速度
- 网络延迟
- CDN 配置
- 数据库查询时间
1.2 FCP (首次内容绘制) - First Contentful Paint
简单理解:用户看到页面上第一个内容(文字、图片等)的时间。
1.3 LCP (最大内容绘制) - Largest Contentful Paint
简单理解:页面上最大的内容元素(通常是主图或主要文字)加载完成的时间。
常见 LCP 元素:
- 首屏的大图片或背景图
- 视频元素的首帧
- 包含大量文本的标题或段落
交互性能指标
2.1 FID (首次输入延迟) - First Input Delay
简单理解:用户第一次点击或输入时,浏览器开始响应的延迟时间。
2.2 INP (交互到绘制) - Interaction to Next Paint
简单理解:用户每次交互(点击、输入、滚动)到页面更新的时间。
注意:INP 将在 2025 年完全取代 FID 成为 Core Web Vitals 指标。
2.3 TTI (可交互时间) - Time to Interactive
简单理解:页面完全"活"过来,能够流畅响应用户操作的时间。
2.4 TBT (总阻塞时间) - Total Blocking Time
简单理解:FCP 和 TTI 之间,主线程被阻塞的总时间。
视觉稳定性指标
3.1 CLS (累积布局偏移) - Cumulative Layout Shift
简单理解:页面加载过程中,内容"跳来跳去"的程度。
导致 CLS 的常见原因:
<!-- 问题:没有尺寸的图片 -->
<img src="banner.jpg" alt="Banner">
<!-- 推荐:预设尺寸的图片 -->
<img src="banner.jpg" alt="Banner" width="800" height="400">
<!-- 问题:动态插入内容 -->
<div id="ads-container"></div>
<script>
// 稍后插入广告内容,导致布局偏移
loadAds().then(content => {
document.getElementById('ads-container').innerHTML = content;
});
</script>
<!-- 推荐:预留空间 -->
<div id="ads-container" style="min-height: 200px;">
<!-- 加载占位符 -->
<div class="skeleton">加载中...</div>
</div>