HTML渲染演进之路

25 阅读12分钟

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的,水合逻辑大部分是基于此场景继续做深入的优化,达到既有便捷的编程范式,又能达到更优的性能。

  1. 重建虚拟 DOM:客户端 JavaScript 重新执行组件代码
  2. DOM 比对:将虚拟 DOM 与服务端渲染的 DOM 进行比较
  3. 事件绑定:为 DOM 元素附加事件监听器
  4. 状态恢复:恢复应用的客户端状态
// 水合的简化实现
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 通过 SuspenseReact.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>
  );
}

革新点

  1. 零水合启动时间:服务端组件的代码永远不会被发送到客户端
  2. 精确的代码分割:只有交互组件的代码会被下载
  3. 天然的流式渲染:可以先发送静态内容,再流式传输动态数据
// 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>