HTML 渲染演进之路
面向前后端的内部分享讲稿,写出大纲和一定的细节经过AI再次整理
一个简单的HTML页面
HTML页面从初创到现在变化不大,看了这么多年的网页,也就是多了一些标签,HTML看起来都一样。但在其背后的渲染逻辑上,却在不断的演进。
从最初的静态 HTML,到服务端预渲染,到纯客户端渲染(CSR - Client-Side Rendering),又回到到预渲染,但其中又有细化,分出了SSR(服务端渲染 - Server-Side Rendering)和SSG(静态站点生成 - Static Site Generation),并且脚步在不断前进。
第一节:Web 渲染技术是怎么演进的
1.1 最初的 Web 时代:那些简单的静态页面
早期,网站就是一堆静态的 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 加载中...;
return {data.content};
}
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 (
{posts.map((post) => (
{post.title}
{post.excerpt}
))}
);
}
SSG 的优势:
- 极致性能:纯静态文件,可以全球 CDN 分发
- 成本低廉:无服务器运行成本
- 高可用性:没有服务器单点故障
SSG 的限制:
- 内容更新延迟:需要重新构建部署
- 个性化困难:难以处理用户相关的动态内容
- 构建时间长:大型站点构建可能耗时很久
2.2 SSR (服务端渲染) - Server-Side Rendering
一个古老而有效的解决方案:在服务端渲染 HTML。其实就是让服务器把页面先渲染好,再发给浏览器。
SSR 的优势:
- 首屏渲染快:用户立即看到内容
- SEO 友好:搜索引擎能够抓取到完整的 HTML
- 更好的用户体验:特别是在慢网络或低端设备上
2.3 水合(Hydration)——让静态页面"活"过来
SSR 返回的 HTML 只是静态的,就像一张照片,要让页面能点击、能交互,需要经历水合(Hydration)过程。这个过程就是让 JavaScript 接管页面,给它"注入生命":
计数器
当前计数: 0
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 (
...文章内容... {" "}
{/* 这部分可能不需要交互,是纯静态HTML */}
{/* 使用 Suspense 包裹,优先水合视口内的组件 */}
}>
}>
);
}
效果:优先水合用户当前视口内或即将交互的关键组件,将长任务拆分为多个小任务,从而改善 TTI 和 INP。
3.2 岛屿架构——只在需要的地方加 JavaScript
思想:更进一步!默认一切皆静态,只在孤立的"岛屿"上进行水合。
代表框架:Astro
代码示例:
---
// 页面大部分可以是纯静态的 .astro 组件,构建时渲染
import StaticCard from '../components/StaticCard.astro'
// 交互组件(岛屿),可以是 React/Vue/Svelte 组件
import Counter from '../components/Counter.jsx'
import ImageCarousel from '../components/ImageCarousel.jsx'
---
核心优势:从源头上将运送到客户端的 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( , {
onShellReady() {
// 1. Shell (页面骨架) 准备就绪,立刻开始传输
res.statusCode = 200;
stream.pipe(res);
},
onShellError() {
// ... 错误处理
},
onAllReady() {
// 2. 所有内容(包括 Suspense 异步数据)都已就绪
// 但此时 Shell 已经发送,用户已经看到内容了
},
});
性能收益:
- TTFB (首字节时间) 大幅降低:用户几乎立刻就能收到页面的
head
和首屏骨架。 - FCP 与水合协同:浏览器可以边接收 HTML 边渲染,甚至可以与渐进式水合结合,让先到达的 HTML 块先进行水合。
第四节:现代渲染技术的新发展
如果说以上方案是在"优化"水合,那么接下来的架构则是在尝试"消灭"水合。
4.1 可恢复性 (Resumability) - Qwik 的魔法
核心思想:不是在客户端"重新执行"一遍来重建状态和绑定事件,而是让应用在客户端"恢复"运行。
Qwik 如何实现:
它将所有应用状态、组件关系、事件监听器都序列化为 HTML 属性。
颠覆性:实现了 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 (
{notes.map(note => (
-
{/* NotePreview 是一个客户端组件,它需要交互 */}
))}
);
}
// --- Client Component (会水合的常规组件) ---
// NotePreview.client.js
'use client'; // <-- 这个指令是关键!
import { useState } from 'react';
export default function NotePreview({ note }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
setIsExpanded(!isExpanded)}>
{note.title}
{isExpanded && {note.body}
}
);
}
革新点:
- 零水合启动时间:服务端组件的代码永远不会被发送到客户端
- 精确的代码分割:只有交互组件的代码会被下载
- 天然的流式渲染:可以先发送静态内容,再流式传输动态数据
// 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 (
{products.map(product => (
{product.name}
价格: ¥{product.price}
{/* 只有这个按钮需要客户端交互 */}
))}
);
}
// 服务端组件可以直接导入服务端模块
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 无权限访问;
}
// 服务端直接渲染,无需客户端权限检查逻辑
return (
管理仪表板
);
}
4.3 Resumability 深度解析:Qwik 的零水合架构
// Qwik 的可恢复性实现原理
const QwikResumabilityExample = {
// 1. 组件状态序列化
serializeState: `
`,
// 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 (
计数: {count.value}
{/* 只有点击时才会下载这个处理器的代码 */}
{/* 条件渲染的组件也是按需加载 */}
{count.value > 5 && }
);
});
附:Web 性能指标解析
什么是Web性能指标?
Google 和 W3C 制定了一系列标准化的性能指标,这些指标不仅影响用户体验,还直接影响 SEO 排名。
核心性能指标全景图
现代 Web 性能主要关注三个维度:加载性能、交互性能、视觉稳定性。
所有性能指标详解
指标缩写 | 中文名称 | 英文全称 | 测量内容 |
---|---|---|---|
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 的常见原因:
加载中...