文章

同构应用设计深度解析

同构应用设计深度解析

一句话概括

同构应用(Isomorphic Application),也称为通用应用(Universal Application),是指同一套代码可以同时在服务端和客户端运行的 Web 应用架构。其核心设计围绕三个关键问题展开:数据如何在服务端和客户端之间传递(数据脱水与注水)、如何让静态 HTML 在客户端变为可交互的 UI(客户端激活/Hydration)、如何确保服务端和客户端的渲染结果一致(差异处理)。同构架构是 SSR 框架(Next.js、Nuxt.js、Remix)的底层基础,理解同构设计原理,等于掌握了现代全栈框架的”内功心法”。

背景与意义

同构架构的前世今生

同构的思想并非始于前端框架。早在 PHP/JSP/ASP 时代,服务端输出完整 HTML 是唯一的选择。单页应用(SPA)兴起后,前端独占了渲染层,却带来了 SEO 和白屏问题。开发者们想要”两全其美”——既有 SPA 的流畅交互,又有服务端渲染的 SEO 和首屏速度。

最早的尝试是预渲染(Prerendering):用 PhantomJS 或 headless Chrome 在构建时渲染页面并导出静态 HTML。但这种方式仅适用于内容固定的页面,无法处理用户特定内容。

2015-2016 年,React 和 Vue 等组件化框架开始在服务端运行,催生了同构架构。核心思路是:服务端运行组件生成 HTML → 客户端在相同 DOM 上”接续”运行。这就是同构。

为什么同构设计如此重要?

在面试中,同构相关的问题通常出现在高级前端/全栈开发的场景:

  • “SSR 数据注水是如何实现的?”
  • “Hydration mismatch 的原因和解决方案?”
  • “如何设计一个同构的状态管理方案?”

这些问题考察的不是对某个框架 API 的记忆,而是对”代码跨越两个执行环境”这个核心挑战的理解深度。

在实际项目中,正确理解同构设计的意义在于:

  1. 避免不必要的 bug:大部分 SSR 线上问题(如闪烁、白屏、事件绑定失败)都源于同构设计中的细节疏忽。
  2. 做出正确的架构选型:理解同构的代价,才能判断一个项目是否真的需要 SSR。
  3. 优化性能:了解注水数据的大小和 hydration 的时机,能做更精准的性能优化。

概念与定义

什么是同构应用?

同构应用是指源代码可以在两个或以上执行环境中运行的应用。在 Web 上下文中,特指服务端(Node.js)和客户端(浏览器) 两个环境。

同构的核心假设是:同一个组件函数,在服务端接收 props 返回 HTML,在客户端接收同样的 props 绑定事件

核心术语

术语英文定义
同构Isomorphic / Universal代码在多个环境中运行的能力
数据脱水Data Dehydration将服务端获取的数据序列化后嵌入 HTML 的过程
数据注水Data Hydration客户端读取嵌入数据并恢复为状态的过程
客户端激活Client Hydration客户端将静态 DOM 转化为可交互 UI 的过程
差异处理Diff Mitigation处理服务端和客户端渲染结果不一致的策略
同构组件Isomorphic Component能在两个环境中渲染的组件

同构的分层

同构设计不是全有或全无的选择。可以根据需求在不同的层级实现同构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Graph: 同构的分层模型
┌─────────────────────────────────────────┐
│  第四层:业务逻辑同构                      │
│  例:表单验证规则、数据转换函数             │
│  收益:避免前端+后端维护两份逻辑            │
├─────────────────────────────────────────┤
│  第三层:数据获取同构                      │
│  例:useAsyncData、React Query            │
│  收益:服务端预取数据,客户端复用            │
├─────────────────────────────────────────┤
│  第二层:路由同构                          │
│  例:文件路由系统                          │
│  收益:客户端的 SPA 导航不刷新页面          │
├─────────────────────────────────────────┤
│  第一层:渲染同构                          │
│  例:renderToString + hydrateRoot         │
│  收益:SEO + 首屏加速                     │
└─────────────────────────────────────────┘

第一层最基础也最难——它要求组件的渲染结果在两个环境中完全一致。

核心知识点拆解

1. 数据脱水与注水的完整链路

数据脱水/注水是同构应用中最关键的环节。它解决的是:服务端获取的数据如何传递到客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// === 服务端数据脱水 ===

// 1. 服务端渲染时获取数据
async function handleSSRRequest(req, res) {
  // 获取页面数据
  const user = await db.users.findById(req.params.id);
  const posts = await db.posts.findByUser(req.params.id);

  // 2. 渲染组件
  const appHtml = ReactDOMServer.renderToString(
    React.createElement(UserProfile, { user, posts })
  );

  // 3. 数据脱水:将数据序列化为 JSON
  // ⚠️ 注意转义:防止 XSS(用户数据中可能包含恶意脚本)
  const dehydratedState = JSON.stringify({ user, posts })
    .replace(/</g, '\\u003c')
    .replace(/>/g, '\\u003e')
    .replace(/&/g, '\\u0026');

  // 4. 将脱水数据嵌入 HTML
  return `
    <!DOCTYPE html>
    <html>
    <head>
      <title>${user.name} 的主页</title>
      <script>
        // 服务端脱水数据,客户端将在 Hydration 时使用
        window.__INITIAL_STATE__ = ${dehydratedState};
      </script>
    </head>
    <body>
      <div id="root">${appHtml}</div>
      <script src="/bundle.js"></script>
    </body>
    </html>
  `;
}

// === 客户端数据注水 ===

// client.js
async function bootstrap() {
  // 1. 读取服务端脱水数据
  const dehydratedData = window.__INITIAL_STATE__;

  // 2. 安全性:使用后立即清除,防止 XSS 利用
  delete window.__INITIAL_STATE__;

  // 3. 使用脱水数据初始化客户端状态
  const store = createStore({
    preloadedState: {
      user: dehydratedData.user,
      posts: dehydratedData.posts
    }
  });

  // 4. hydrate 代替 render,复用已有 DOM
  const root = hydrateRoot(
    document.getElementById('root'),
    React.createElement(ReduxProvider, {
      store,
      children: React.createElement(App)
    })
  );
}

// 注水数据的压缩优化
// 如果数据量很大,可以考虑压缩
function compressDehydratedState(state) {
  // 方案 1:移除冗余字段
  // 比如只在服务端需要的 __typename 字段

  // 方案 2:使用更紧凑的编码
  // 方案 3:使用 MessagePack 替代 JSON(需要额外运行时)

  // 方案 4:选择性注水——只注水需要的字段
  // ❌ 注水全部数据
  // window.__INITIAL_STATE__ = fullState;

  // ✅ 选择性注水
  const selectiveState = {
    users: state.users.map(u => ({
      id: u.id,
      name: u.name,
      avatar: u.avatar
      // 省略: posts, email, phone, address 等
    }))
  };
  return JSON.stringify(selectiveState);
}

2. 客户端激活(Hydration)的深层机制

激活(Hydration)是将服务端生成的静态 HTML 转变为可交互 UI 的过程。但这个过程远比想象中复杂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// === 高级激活模式 ===

// 模式 1:渐进式激活(Progressive Hydration)
// 不是一次性激活整个页面,而是按优先级分块激活
class ProgressiveHydrator {
  constructor(rootElement) {
    this.root = rootElement;
    this.hydrated = new Set();
    this.queue = [];
  }

  // 注册需要激活的区块
  registerBlock(id, component, props) {
    this.queue.push({ id, component, props, priority: this.getPriority(id) });
  }

  // 按优先级排序
  getPriority(id) {
    // 视口内的组件优先
    const el = document.getElementById(id);
    if (!el) return 0;
    const rect = el.getBoundingClientRect();
    const inViewport = rect.top < window.innerHeight;
    // 可交互的组件优先
    const isInteractive = el.querySelector('button, input, a, select');
    return (inViewport ? 2 : 0) + (isInteractive ? 1 : 0);
  }

  // 开始渐进式激活
  start() {
    // 按优先级降序排列
    this.queue.sort((a, b) => b.priority - a.priority);

    // 高优先级立即激活
    const highPriority = this.queue.filter(q => q.priority >= 3);
    const lowPriority = this.queue.filter(q => q.priority < 3);

    highPriority.forEach(block => this.hydrateBlock(block));

    // 低优先级延迟激活
    requestIdleCallback(() => {
      lowPriority.forEach(block => this.hydrateBlock(block));
    }, { timeout: 3000 });
  }

  hydrateBlock({ id, component, props }) {
    if (this.hydrated.has(id)) return;
    const container = document.getElementById(id);
    if (!container) return;

    // 创建独立的根节点进行局部激活
    const root = createRoot(container);
    root.render(React.createElement(component, props));
    this.hydrated.add(id);
  }
}

// 模式 2:选择性激活(Selective Hydration)
// React 18 Suspense 实现了选择性激活
function App() {
  return (
    <html>
      <body>
        {/* 主要内容优先激活 */}
        <MainContent />

        {/* 次要内容延迟激活 */}
        <Suspense fallback={<LoadingSpinner />}>
          <Sidebar />
        </Suspense>

        {/* 非关键内容甚至不需要激活 */}
        <StaticFooter />
      </body>
    </html>
  );
}

// 模式 3:部分激活(Partial Hydration)
// 某些组件只在客户端渲染,永远不参与服务端激活
// 比如:复杂的可视化图表
function ChartComponent({ data }) {
  // 使用 useEffect 确保只在客户端运行
  useEffect(() => {
    initializeChart(data);
  }, []);

  // 渲染一个空的容器,等待客户端填充
  return <div className="chart-container" ref={containerRef} />;
}

// ========== 激活过程的事件绑定细节 ==========
// 激活不仅仅是"绑定事件",还包含:
// 1. 遍历 DOM 树,将 Fiber 节点与 DOM 节点一一对应
// 2. 设置事件委托(React 在根节点绑定所有事件)
// 3. 建立 ref 引用
// 4. 运行 useLayoutEffect(同步执行)

// React 18 激活时需要做的检查
function performHydrationChecks() {
  const domNode = document.getElementById('root');

  // 检查 1: 服务端渲染的 DOM 是否存在
  if (!domNode || !domNode.hasChildNodes()) {
    console.warn('[Hydration] 服务端未渲染内容,回退到客户端渲染');
    return false;
  }

  // 检查 2: DOCTYPE 完整性
  // 检查 3: 根节点数量
  // 检查 4: 节点类型匹配

  return true;
}

3. 差异处理(Mismatch Handling)

服务端和客户端环境差异是同类应用中最常见的 bug 来源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// === 常见差异原因与解决方案 ===

// 原因 1:浏览器特有 API 调用
function WindowSize({ children }) {
  // ❌ 服务端没有 window 对象
  // const width = window.innerWidth;

  // ✅ 方案:使用 useEffect 包裹客户端特有代码
  const [width, setWidth] = useState(1024); // 服务端默认值

  useEffect(() => {
    setWidth(window.innerWidth);
    const handler = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handler);
    return () => window.removeEventListener('resize', handler);
  }, []);

  return children(width);
}

// 原因 2:时间相关的不一致性
function TimeAgo({ timestamp }) {
  // ❌ 以下代码服务端和客户端结果不同
  // const diff = Date.now() - new Date(timestamp).getTime();
  // const minutes = Math.floor(diff / 60000);
  // return <span>{minutes} 分钟前</span>;

  // ✅ 方案 1:使用服务端确定的时间(通过 props 传入)
  const [display, setDisplay] = useState(''); // 初始为空

  useEffect(() => {
    const diff = Date.now() - new Date(timestamp).getTime();
    const minutes = Math.floor(diff / 60000);
    setDisplay(`${minutes} 分钟前`);
  }, [timestamp]);

  // 服务端渲染时不显示时间差异,客户端再填充
  // 避免 hydration mismatch
  if (!display) return <span>刚刚</span>;
  return <span>{display}</span>;
}

// 原因 3:随机值
function RandomId({ children }) {
  // ❌ Math.random() 在服务端和客户端结果不同
  // const id = Math.random().toString(36);

  // ✅ 使用稳定的 ID
  const [id, setId] = useState('');

  useEffect(() => {
    setId(Math.random().toString(36));
  }, []);

  return <div id={id}>{children}</div>;
}

// 原因 4:CSS-in-JS 的动态样式
function DynamicStyle({ theme }) {
  // ❌ 如果服务端未提取样式,客户端动态注入会导致 DOM 差异
  // styled.button`color: ${theme.primary}`;

  // ✅ 确保服务端正确提取样式
  // 使用 styled-components 的 ServerStyleSheet
  // 或者使用 CSS-in-JS 库的 SSR 支持模式
}

// 原因 5:第三方组件的 SSR 兼容性
// 某些第三方库只在客户端工作
// 解决方案:ssr:false(Next.js dynamic)或 ClientOnly(Nuxt.js)

4. 状态管理的同构设计

状态管理是同构应用中最复杂的环节之一。服务端和客户端必须共享同一份初始状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
// === 同构状态管理方案:以 Redux 为例 ===

// 1. 服务端:创建 Store 并注水
// server/store.js
import { createStore } from 'redux';
import { rootReducer } from './reducers';

// 工厂函数:每个请求创建独立的 store
export function createServerStore(preloadedState) {
  return createStore(rootReducer, preloadedState);
}

// 2. 服务端渲染逻辑
// server/render.js
async function renderPage(url) {
  // 为每个请求创建独立的 store
  const store = createServerStore();

  // 获取页面所需数据并 dispatch 到 store
  await loadPageData(url, store.dispatch);

  // 获取当前 store 状态
  const state = store.getState();

  // 渲染组件(传入 store)
  const appHtml = ReactDOMServer.renderToString(
    React.createElement(Provider, { store },
      React.createElement(StaticRouter, { location: url },
        React.createElement(App)
      )
    )
  );

  // 数据脱水:将 state 序列化
  return {
    html: appHtml,
    state: state
  };
}

// 3. 客户端:使用注水数据初始化 Store
// client/store.js
import { createStore } from 'redux';
import { rootReducer } from './reducers';

// 读取服务端注水的数据
const dehydratedState = window.__INITIAL_STATE__;

// 使用注水数据初始化 store
// 确保客户端初始状态与服务端渲染时的状态完全一致
const store = createStore(
  rootReducer,
  dehydratedState  // preloadedState
);

// 清除全局数据
delete window.__INITIAL_STATE__;

// 4. 增量更新问题
// 客户端获取新数据时,不要覆盖整个 store
// 而是正常 dispatch action
function UserProfile({ userId }) {
  useEffect(() => {
    // 客户端额外的数据获取
    fetch(`/api/users/${userId}/details`)
      .then(res => res.json())
      .then(details => {
        // ✅ 增量更新:只更新 details 子状态
        dispatch({ type: 'USER_DETAILS_LOADED', payload: details });
      });
  }, [userId]);
}

// 5. 状态规范化(Normalization)
// 避免将嵌套数据直接注入 store
// ❌ 嵌套结构
const badState = {
  users: [
    {
      id: 1,
      name: '张三',
      posts: [
        { id: 101, title: '帖子 1', comments: [...] }
      ]
    }
  ]
};

// ✅ 扁平化结构
const goodState = {
  users: {
    1: { id: 1, name: '张三', postIds: [101] }
  },
  posts: {
    101: { id: 101, title: '帖子 1', userId: 1, commentIds: [] }
  },
  comments: {}
};

实战案例

构建一个完整的同构应用

从零搭建一个同构的 Todo 应用,展示完整的脱水-注水-Hydration 流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// === shared/App.jsx - 同构组件 ===
// 这段代码既在服务端运行,也在客户端运行
import React, { useState, useEffect } from 'react';

// 同构的数据获取函数
// 在服务端:返回 Promise,框架等待它完成
// 在客户端:检查注水数据,如果没有则发起 AJAX
export async function fetchTodos(api) {
  // 服务端:使用 fetch(Node 18+ 原生支持)
  // 客户端:使用 fetch(浏览器原生支持)
  const res = await fetch(api);
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json();
}

export function TodoApp({ initialTodos, onToggle }) {
  const [todos, setTodos] = useState(initialTodos || []);
  const [newTodo, setNewTodo] = useState('');
  const [filter, setFilter] = useState('all');

  async function handleAddTodo(e) {
    e.preventDefault();
    if (!newTodo.trim()) return;

    const res = await fetch('/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: newTodo.trim() })
    });

    if (res.ok) {
      const created = await res.json();
      setTodos(prev => [...prev, created]);
      setNewTodo('');
    }
  }

  async function handleToggle(id) {
    const res = await fetch(`/api/todos/${id}`, { method: 'PATCH' });
    if (res.ok) {
      setTodos(prev =>
        prev.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
      );
    }
  }

  async function handleDelete(id) {
    const res = await fetch(`/api/todos/${id}`, { method: 'DELETE' });
    if (res.ok) {
      setTodos(prev => prev.filter(t => t.id !== id));
    }
  }

  const filteredTodos = todos.filter(t => {
    if (filter === 'active') return !t.completed;
    if (filter === 'completed') return t.completed;
    return true;
  });

  const remaining = todos.filter(t => !t.completed).length;

  return (
    <div className="todo-app">
      <h1>同构 Todo 应用</h1>

      {/* 服务端渲染时,这个表单是静态的 */}
      {/* 客户端激活后,表单变得可交互 */}
      <form onSubmit={handleAddTodo} className="add-todo">
        <input
          value={newTodo}
          onChange={e => setNewTodo(e.target.value)}
          placeholder="添加新任务..."
          className="todo-input"
        />
        <button type="submit" className="add-btn">添加</button>
      </form>

      {/* Todo 列表 */}
      <ul className="todo-list">
        {filteredTodos.map(todo => (
          <li key={todo.id} className={`todo-item ${todo.completed ? 'completed' : ''}`}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => handleToggle(todo.id)}
              className="todo-checkbox"
            />
            <span className="todo-text">{todo.text}</span>
            <button
              onClick={() => handleDelete(todo.id)}
              className="delete-btn"
            >
              删除
            </button>
          </li>
        ))}
      </ul>

      {/* 底部状态栏 */}
      <div className="todo-footer">
        <span className="remaining">{remaining} 项未完成</span>
        <div className="filters">
          {['all', 'active', 'completed'].map(f => (
            <button
              key={f}
              onClick={() => setFilter(f)}
              className={`filter-btn ${filter === f ? 'active' : ''}`}
            >
              {f === 'all' ? '全部' : f === 'active' ? '进行中' : '已完成'}
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
// === server/index.js - 同构服务端 ===
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import path from 'path';

const app = express();

// 模拟数据库
const todos = [
  { id: 1, text: '学习同构应用设计', completed: true },
  { id: 2, text: '实践 SSR + Hydration', completed: true },
  { id: 3, text: '理解数据脱水注水的实现', completed: false },
  { id: 4, text: '解决 hydration mismatch', completed: false },
];

// API 路由
app.get('/api/todos', (req, res) => {
  res.json(todos);
});

app.post('/api/todos', express.json(), (req, res) => {
  const todo = {
    id: Date.now(),
    text: req.body.text,
    completed: false
  };
  todos.push(todo);
  res.status(201).json(todo);
});

app.patch('/api/todos/:id', (req, res) => {
  const todo = todos.find(t => t.id === parseInt(req.params.id));
  if (todo) {
    todo.completed = !todo.completed;
    res.json(todo);
  } else {
    res.status(404).end();
  }
});

app.delete('/api/todos/:id', (req, res) => {
  const idx = todos.findIndex(t => t.id === parseInt(req.params.id));
  if (idx >= 0) {
    todos.splice(idx, 1);
    res.status(204).end();
  } else {
    res.status(404).end();
  }
});

// SSR 核心路由
app.get('/', async (req, res) => {
  try {
    // 1. 服务端数据获取
    const data = todos;

    // 2. 服务端渲染
    const appHtml = ReactDOMServer.renderToString(
      React.createElement('div', { id: 'todo-app-root' },
        // 这里我们使用模板方式模拟组件渲染
        React.createElement(TodoAppSSR, { initialTodos: data })
      )
    );

    // 3. 数据脱水
    const dehydratedState = JSON.stringify({ todos: data })
      .replace(/</g, '\\u003c')
      .replace(/>/g, '\\u003e');

    // 4. 组装完整 HTML
    const html = `
      <!DOCTYPE html>
      <html lang="zh-CN">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>同构应用 Demo</title>
        <link rel="stylesheet" href="/styles.css">
      </head>
      <body>
        <div id="root">${appHtml}</div>
        <script>
          window.__INITIAL_STATE__ = ${dehydratedState};
        </script>
        <script src="/client.js"></script>
      </body>
      </html>
    `;

    res.send(html);
  } catch (error) {
    console.error('SSR 渲染失败:', error);
    res.status(500).send('Server error');
  }
});

// 服务端专用的 Todo 组件(模拟)
function TodoAppSSR({ initialTodos }) {
  const items = initialTodos.map(todo => `
    <li class="todo-item ${todo.completed ? 'completed' : ''}">
      <input type="checkbox" class="todo-checkbox" ${todo.completed ? 'checked' : ''} />
      <span class="todo-text">${todo.text}</span>
      <button class="delete-btn">删除</button>
    </li>
  `).join('');

  return React.createElement('div', {
    className: 'todo-app',
    dangerouslySetInnerHTML: {
      __html: `
        <h1>同构 Todo 应用</h1>
        <form class="add-todo">
          <input class="todo-input" placeholder="添加新任务..." />
          <button type="submit" class="add-btn">添加</button>
        </form>
        <ul class="todo-list">${items}</ul>
        <div class="todo-footer">
          <span class="remaining">${todos.filter(t => !t.completed).length} 项未完成</span>
          <div class="filters">
            <button class="filter-btn active">全部</button>
            <button class="filter-btn">进行中</button>
            <button class="filter-btn">已完成</button>
          </div>
        </div>
      `
    }
  });
}

app.listen(3000);
console.log('同构应用运行在 http://localhost:3000');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
// === client/client.js - 同构客户端激活 ===
(function() {
  'use strict';

  // 1. 读取服务端脱水数据
  const initialData = window.__INITIAL_STATE__ || { todos: [] };

  // 2. 安全清除
  delete window.__INITIAL_STATE__;

  // 3. 客户端激活——将静态 DOM 转变为 SPA
  function hydrate() {
    const root = document.getElementById('root');
    if (!root) return;

    // 找到 todo 列表和表单
    const form = root.querySelector('.add-todo');
    const input = root.querySelector('.todo-input');
    const list = root.querySelector('.todo-list');
    const footer = root.querySelector('.todo-footer');

    let currentTodos = initialData.todos;

    // 渲染 todo 列表
    function render() {
      const activeFilter = footer?.querySelector('.filter-btn.active');
      const filter = activeFilter?.textContent || '全部';

      const filtered = currentTodos.filter(t => {
        if (filter === '进行中') return !t.completed;
        if (filter === '已完成') return t.completed;
        return true;
      });

      if (!list) return;
      list.innerHTML = filtered.map(todo => `
        <li class="todo-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
          <input type="checkbox" class="todo-checkbox" ${todo.completed ? 'checked' : ''} />
          <span class="todo-text">${todo.text}</span>
          <button class="delete-btn">删除</button>
        </li>
      `).join('');

      // 更新剩余数量
      const remaining = currentTodos.filter(t => !t.completed).length;
      const remainingEl = footer?.querySelector('.remaining');
      if (remainingEl) {
        remainingEl.textContent = `${remaining} 项未完成`;
      }
    }

    // 绑定事件——这是 hydration 的核心
    if (form) {
      form.addEventListener('submit', async (e) => {
        e.preventDefault();
        const text = input?.value.trim();
        if (!text) return;

        // 发起 HTTP 请求
        const res = await fetch('/api/todos', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ text })
        });

        if (res.ok) {
          const newTodo = await res.json();
          currentTodos.push(newTodo);
          if (input) input.value = '';
          render();
        }
      });
    }

    // 事件委托:处理列表项的事件
    if (list) {
      list.addEventListener('change', async (e) => {
        if (e.target.classList.contains('todo-checkbox')) {
          const li = e.target.closest('.todo-item');
          const id = parseInt(li?.dataset.id || '0');
          await fetch(`/api/todos/${id}`, { method: 'PATCH' });
          const todo = currentTodos.find(t => t.id === id);
          if (todo) todo.completed = !todo.completed;
          render();
        }
      });

      list.addEventListener('click', async (e) => {
        if (e.target.classList.contains('delete-btn')) {
          const li = e.target.closest('.todo-item');
          const id = parseInt(li?.dataset.id || '0');
          await fetch(`/api/todos/${id}`, { method: 'DELETE' });
          currentTodos = currentTodos.filter(t => t.id !== id);
          render();
        }
      });
    }

    // 绑定过滤按钮事件
    if (footer) {
      footer.addEventListener('click', (e) => {
        if (e.target.classList.contains('filter-btn')) {
          footer.querySelectorAll('.filter-btn').forEach(btn => {
            btn.classList.remove('active');
          });
          e.target.classList.add('active');
          render();
        }
      });
    }
  }

  // 4. DOM 就绪后执行 hydration
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', hydrate);
  } else {
    hydrate();
  }
})();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
/* public/styles.css - 同构应用的样式 */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  min-height: 100vh;
  display: flex;
  justify-content: center;
  padding-top: 50px;
}

.todo-app {
  background: white;
  border-radius: 12px;
  padding: 30px;
  width: 500px;
  box-shadow: 0 20px 60px rgba(0,0,0,0.15);
}

.todo-app h1 {
  text-align: center;
  color: #333;
  margin-bottom: 20px;
  font-size: 1.8rem;
}

.add-todo {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

.todo-input {
  flex: 1;
  padding: 10px 15px;
  border: 2px solid #e0e0e0;
  border-radius: 8px;
  font-size: 1rem;
  transition: border-color 0.2s;
}

.todo-input:focus {
  outline: none;
  border-color: #667eea;
}

.add-btn {
  padding: 10px 20px;
  background: #667eea;
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  font-size: 1rem;
  transition: background 0.2s;
}

.add-btn:hover {
  background: #5a67d8;
}

.todo-list {
  list-style: none;
  margin-bottom: 20px;
}

.todo-item {
  display: flex;
  align-items: center;
  padding: 12px 0;
  border-bottom: 1px solid #f0f0f0;
  gap: 10px;
}

.todo-item.completed .todo-text {
  text-decoration: line-through;
  color: #999;
}

.todo-checkbox {
  width: 20px;
  height: 20px;
  cursor: pointer;
}

.todo-text {
  flex: 1;
  font-size: 1rem;
  color: #333;
}

.delete-btn {
  padding: 4px 12px;
  background: #ff4757;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.8rem;
  opacity: 0;
  transition: opacity 0.2s;
}

.todo-item:hover .delete-btn {
  opacity: 1;
}

.todo-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-top: 15px;
  border-top: 2px solid #f0f0f0;
}

.remaining {
  color: #666;
  font-size: 0.9rem;
}

.filters {
  display: flex;
  gap: 5px;
}

.filter-btn {
  padding: 5px 12px;
  border: 1px solid #ddd;
  background: white;
  border-radius: 20px;
  cursor: pointer;
  font-size: 0.8rem;
  transition: all 0.2s;
}

.filter-btn.active {
  background: #667eea;
  color: white;
  border-color: #667eea;
}

/* 服务端渲染时的加载提示 */
.ssr-note {
  text-align: center;
  color: #999;
  font-size: 0.8rem;
  margin-top: 10px;
  padding: 8px;
  background: #f8f9fa;
  border-radius: 4px;
}

底层原理

数据脱水到注水的完整传输路径

理解从数据脱水到客户端注水的完整链路,是同构应用设计的核心。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
// === 脱水/注水协议的完整实现 ===

// 1. 脱水管线(服务端)
class DehydrationPipeline {
  constructor() {
    this.payload = {
      data: {},      // 页面数据
      context: {},   // 请求上下文(用户信息、区域等)
      timestamp: 0   // 渲染时间戳
    };
    this.scripts = [];
  }

  // 添加数据
  addData(key, value, options = {}) {
    const {
      compress = false,     // 是否压缩
      maxDepth = 3,         // 最大序列化深度
      excludeFields = []    // 排除的字段
    } = options;

    let processed = value;

    // 深度限制
    if (maxDepth > 0) {
      processed = this.limitDepth(processed, maxDepth);
    }

    // 字段排除(移除敏感字段)
    if (excludeFields.length > 0) {
      processed = this.excludeFields(processed, excludeFields);
    }

    // 序列化
    let serialized = JSON.stringify(processed);

    // XSS 防护
    serialized = this.sanitizeXSS(serialized);

    // 压缩(可以使用 lz-string 等算法)
    if (compress) {
      serialized = this.compressString(serialized);
    }

    // 生成注水脚本
    this.scripts.push(
      `window.__nuxt_data=${JSON.stringify(serialized)};`
    );

    this.payload.data[key] = processed;
    return this;
  }

  limitDepth(obj, maxDepth, currentDepth = 0) {
    if (currentDepth >= maxDepth) {
      // 超出深度:如果是对象,返回类型标记
      if (typeof obj === 'object' && obj !== null) {
        return `[Truncated: ${obj.constructor.name}]`;
      }
      return obj;
    }

    if (Array.isArray(obj)) {
      return obj.map(item => this.limitDepth(item, maxDepth, currentDepth + 1));
    }

    if (obj && typeof obj === 'object') {
      const result = {};
      for (const [key, value] of Object.entries(obj)) {
        result[key] = this.limitDepth(value, maxDepth, currentDepth + 1);
      }
      return result;
    }

    return obj;
  }

  excludeFields(obj, fields, path = '') {
    if (Array.isArray(obj)) {
      return obj.map(item => this.excludeFields(item, fields, path));
    }

    if (obj && typeof obj === 'object') {
      const result = {};
      for (const [key, value] of Object.entries(obj)) {
        const currentPath = path ? `${path}.${key}` : key;
        // 跳过敏感字段
        if (fields.includes(key) || fields.includes(currentPath)) {
          result[key] = '[REDACTED]';
        } else {
          result[key] = this.excludeFields(value, fields, currentPath);
        }
      }
      return result;
    }

    return obj;
  }

  sanitizeXSS(json) {
    return json
      .replace(/</g, '\\u003c')
      .replace(/>/g, '\\u003e')
      .replace(/&/g, '\\u0026');
  }

  compressString(str) {
    // 简化版:实际使用 LZ-String 或类似算法
    return btoa(encodeURIComponent(str));
  }

  // 生成最终注水 HTML
  toHTML() {
    return `<script>${this.scripts.join('\n')}</script>`;
  }
}

// 2. 注水管线(客户端)
class HydrationPipeline {
  constructor() {
    this.data = {};
  }

  // 从全局对象读取数据
  extract() {
    const globals = ['__INITIAL_STATE__', '__NEXT_DATA__', '__NUXT__', '__REACT_QUERY_STATE__'];

    for (const key of globals) {
      if (window[key] !== undefined) {
        this.data = window[key];
        // 安全清除
        try {
          delete window[key];
        } catch {
          // 某些环境下 delete 可能失败
          window[key] = undefined;
        }
        break;
      }
    }

    return this.data;
  }

  // 选择性注水——只恢复需要的部分
  select(path) {
    const keys = path.split('.');
    let current = this.data;
    for (const key of keys) {
      if (current === undefined || current === null) return undefined;
      current = current[key];
    }
    return current;
  }

  // 验证数据完整性
  validate(expectedKeys) {
    const missing = expectedKeys.filter(key => {
      return this.select(key) === undefined;
    });

    if (missing.length > 0) {
      console.warn(`[Hydration] 缺少注水数据: ${missing.join(', ')}`);
      return false;
    }

    return true;
  }
}

// 3. 使用示例
class IsomorphicDataBridge {
  constructor() {
    this.isServer = typeof window === 'undefined';
    this.pipeline = this.isServer
      ? new DehydrationPipeline()
      : new HydrationPipeline();
  }

  // 服务端:准备数据
  prepareData(key, data) {
    if (this.isServer) {
      this.pipeline.addData(key, data, {
        maxDepth: 5,
        excludeFields: ['password', 'token', 'creditCard']
      });
    }
  }

  // 客户端:读取数据
  getData(key) {
    if (!this.isServer) {
      if (Object.keys(this.pipeline.data).length === 0) {
        this.pipeline.extract();
      }
      return this.pipeline.select(key);
    }
    return null;
  }
}

激活过程的底层实现

激活的底层是 React 的 Fiber 架构在服务端和客户端之间的桥梁作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
// === 简化的 React Hydration 实现 ===

// 服务端生成带标记的 HTML
function renderWithHydrationMarkers(Component, props) {
  // React 会在 HTML 中嵌入 data-reactroot 等标记
  // 这些标记帮助客户端识别服务端渲染的 DOM 节点
  const html = ReactDOMServer.renderToString(
    React.createElement(Component, props)
  );

  // 输出示例:
  // <div id="root" data-reactroot="">
  //   <h1>Hello SSR</h1>
  //   <!-- react-text: 2 -->Hello<!-- /react-text -->
  //   <button data-reactid="3">Click</button>
  // </div>

  return html;
}

// 客户端激活的核心逻辑
function hydrateFiberTree(container, reactNode) {
  // 1. 获取现有 DOM 的根节点
  const existingRoot = container.firstChild;

  // 2. 创建 Fiber 树的"影子",指向现有 DOM
  // 每个 Fiber 节点的 stateNode 指向对应的 DOM 节点
  const fiberRoot = {
    containerInfo: container,
    current: {
      // 第一个子节点对应 container.firstChild
      child: createFiberFromDOM(existingRoot)
    }
  };

  // 3. 创建新的 Fiber 树(从组件定义出发)
  const newFiberTree = createFiberFromComponent(reactNode);

  // 4. 比较两棵树
  function reconcile(existingFiber, newFiber) {
    if (!existingFiber) {
      // 没有对应的 DOM 节点:新增
      const dom = createDOMFromFiber(newFiber);
      return dom;
    }

    if (existingFiber.type !== newFiber.type) {
      // 节点类型不匹配:需要替换
      // React 会输出 warning
      console.warn(
        `Hydration mismatch: expected ${newFiber.type}, got ${existingFiber.type}`
      );
      // 丢弃整个子树,用新的替换
      const newDOM = createDOMFromFiber(newFiber);
      existingFiber.parentNode.replaceChild(newDOM, existingFiber);
      return newDOM;
    }

    // 节点类型匹配:复用 DOM,只绑定事件
    // 1. 设置属性(不会触发布局重排)
    updateDOMProperties(existingFiber, newFiber);
    // 2. 注册事件(使用事件委托)
    registerEventDelegation(existingFiber);

    // 递归处理子节点
    const existingChildren = existingFiber.childNodes;
    const newChildren = newFiber.childNodes;

    const maxLen = Math.max(existingChildren.length, newChildren.length);
    for (let i = 0; i < maxLen; i++) {
      reconcile(existingChildren[i], newChildren[i]);
    }

    return existingFiber;
  }

  // 5. 执行调和
  reconcile(existingRoot, fiberRoot);

  // 6. 激活完成标记
  container.setAttribute('data-hydrated', 'true');
}

// 事件绑定—使用事件委托而非直接绑定
function registerEventDelegation(container) {
  // React 在根节点注册所有事件
  const events = ['click', 'change', 'submit', 'keydown', 'keyup', 'focus', 'blur'];

  events.forEach(eventType => {
    container.addEventListener(eventType, (e) => {
      // 根据事件目标冒泡路径,找到对应的 Fiber 节点
      const fiberNode = findFiberFromDOM(e.target);
      if (fiberNode && fiberNode.eventHandlers[eventType]) {
        fiberNode.eventHandlers[eventType](e);
      }
    });
  });
}

高频面试题解析

面试题 1:同构应用中的数据脱水(Dehydration)和注水(Hydration)是如何实现的?

答案要点

数据脱水(服务端)

  1. 服务端完成数据获取(数据库、API 等)。
  2. 将数据序列化为 JSON 字符串(注意 XSS 转义)。
  3. 将 JSON 嵌入 HTML 的 <script> 标签中,赋值给 window.__INITIAL_STATE__
  4. 可选:压缩、深度限制、排除敏感字段。

数据注水(客户端)

  1. 客户端 JS 启动时读取 window.__INITIAL_STATE__
  2. 将数据作为 store 或 context 的初始状态。
  3. 立即清除全局引用(delete window.__INITIAL_STATE__)。
  4. 组件使用注水数据渲染时,结果与服务器端一致,激活成功。

关键原则

  • 服务端渲染使用的数据必须与客户端初始化的数据完全一致。
  • 注意序列化过程中的安全问题(XSS 转义)。
  • 避免注水不必要的数据(只注水客户端需要的数据)。

面试题 2:Hydration Mismatch(激活不匹配)的常见原因有哪些?如何定位和修复?

答案要点

常见原因

  1. 浏览器特定 API 在服务端返回默认值(window.innerWidthlocalStorage)。
  2. 时间相关函数(Date.now()new Date().toLocaleString())。
  3. 随机值(Math.random()crypto.randomUUID())。
  4. 第三方库在客户端动态修改 DOM 结构。
  5. CSS-in-JS 未正确提取服务端样式。
  6. 服务端和客户端数据来源不同(如 API 响应时间差)。

定位方法

  • 在开发模式下,框架会在控制台输出详细的 mismatch 警告,包括节点路径和预期/实际值。
  • 通过 element inspector 检查服务端渲染的 HTML 结构。
  • 使用 suppressHydrationWarning 作为临时修复(不推荐)。

修复策略

1
2
3
4
5
6
7
8
9
10
// 1. useEffect 中执行客户端特有代码
useEffect(() => {
  setClientOnlyValue(computeSomething());
}, []);

// 2. 服务端提供确定性的默认值
const [width] = useState(1024); // 服务端默认值

// 3. 使用 suppressHydrationWarning(最后手段)
<div suppressHydrationWarning={true}>{Math.random()}</div>

面试题 3:什么是同构组件?编写同构组件需要注意哪些问题?

答案要点

同构组件是指既能在服务端也能在客户端渲染的组件。

编写同构组件的注意事项

  1. 避免直接访问浏览器 API
    • typeof window !== 'undefined' 守卫。
    • useEffect / onMounted 中执行客户端代码。
  2. 保持数据确定性
    • 确保服务端和客户端渲染使用相同数据。
    • 避免在渲染函数中使用随机值或时间。
  3. 注意第三方库的兼容性
    • 检查库是否支持 SSR。
    • 不支持时使用动态导入或条件渲染。
  4. 资源加载策略
    • 图片、字体等静态资源应在服务端正确标记。
    • 避免服务端加载仅客户端需要的资源。
  5. 性能考虑
    • 服务端渲染的函数应尽量轻量。
    • 避免在服务端渲染阶段执行耗时操作。

面试题 4:如何实现局部激活(Partial Hydration)和渐进式激活(Progressive Hydration)?

答案要点

局部激活:只对页面的部分区域执行 hydration,其他区域保持静态。实现方式:

  • 将页面分为可交互区域和静态区域。
  • 静态区域使用纯 HTML,不绑定 JavaScript 事件。
  • 可交互区域各自独立激活。

渐进式激活:按照优先级逐步激活页面元素。策略包括:

  • 视口内优先激活(用户可见区域)。
  • 交互元素优先激活(按钮、表单)。
  • 低优先级元素使用 requestIdleCallback 延迟激活。
  • React 18 的 Selective Hydration:<Suspense> 包裹的内容可以延迟激活。

技术实现

  • React 18:hydrateRoot 配合 Suspense。
  • 框架层面:Qwik 的”可恢复性”(Resumability)模式、Astro 的岛屿架构。
  • 手动实现:将页面分割为独立容器,每个容器单独调用 hydrateRoot

面试题 5:同构应用的性能优化策略有哪些?

答案要点

  1. 减少注水数据量
    • 选择性注水:只序列化客户端需要的数据。
    • 数据裁剪:移除客户端不需要的字段(如 __typename_id)。
    • 数据压缩:使用紧凑格式(MessagePack)或压缩算法。
  2. 优化 hydration 速度
    • 延迟 hydration:只有进入视口的组件才激活。
    • 部分 hydration:静态部分永远不需要激活。
    • 使用更轻量的框架(Preact、Solid.js)替代重型框架。
  3. 缓存策略
    • 服务端缓存渲染结果(Redis、内存缓存)。
    • ISR(增量静态再生)减少服务端渲染频率。
    • CDN 缓存静态页面。
  4. 流式渲染
    • React 18 的 renderToPipeableStream
    • 尽早发送页面骨架,渐进式填充。
    • 结合 Suspense 处理慢速 API 依赖。
  5. 性能监控
    • 监控 FCP、LCP、TTI 等核心 Web 指标。
    • 跟踪注水数据大小。
    • 使用框架自带的性能分析工具。

总结与扩展

知识体系

同构应用设计的知识体系:

  • 数据传输层:脱水协议、注水恢复、序列化安全、数据压缩。
  • 渲染执行层:renderToString、hydrateRoot、流式渲染、选择性激活。
  • 一致性保障:Mismatch 检测、环境差异处理、确定性渲染。
  • 架构模式:渐进式增强、岛屿架构、可恢复性。
  • 框架实现对比:React + Next.js vs Vue + Nuxt vs Qwik vs Astro。

同构的未来

同构设计正在经历一次重要的范式转变:

  • 从 Hydration 到 Resumability:Qwik 框架提出的”可恢复性”模式,无需序列化事件处理器的状态,大大减少了注水数据量。
  • 从全量激活到岛屿架构:Astro 等框架倡导的”零 JS 默认 + 按需激活”模式,同构设计不再是全有或全无的选择。
  • 从客户端重到服务端重:React Server Components 将更多计算放回服务端,客户端需要下载的代码量持续减少。

延伸阅读

同构设计看似是技术选择,实则是工程哲学的体现——在服务端和客户端之间找到最合理的职责划分。理解同构的原理,比记住某个框架的 API 更有长远的价值。

本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量