web-app-architectures

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Web Application Architectures

Web应用架构

Overview

概述

Web applications fall into three main architectural patterns, each with distinct characteristics for navigation, state management, and user experience.
Web应用主要分为三种架构模式,每种模式在导航、状态管理和用户体验方面都有独特的特点。

Multi Page Application (MPA)

多页应用(MPA)

The traditional web model - each navigation triggers a full page request to the server.
传统Web模型 - 每次导航都会触发向服务器的完整页面请求。

How It Works

工作原理

User clicks link → Browser requests new HTML → Server renders full page → Browser loads entire page
用户点击链接 → 浏览器请求新HTML → 服务器渲染完整页面 → 浏览器加载整个页面

Characteristics

特性

AspectBehavior
NavigationFull page reload on each route change
Initial LoadFast - only current page HTML
Subsequent NavigationSlower - full round trip
StateLost on navigation (unless stored in cookies/sessions)
SEOExcellent - each page is a complete HTML document
ServerHandles routing and rendering
维度表现
导航每次路由变更都会重新加载整个页面
初始加载速度快 - 仅加载当前页面的HTML
后续导航速度慢 - 需要完整的往返请求
状态导航时丢失(除非存储在Cookie或会话中)
SEO表现极佳 - 每个页面都是完整的HTML文档
服务器处理路由和渲染逻辑

When to Use MPA

适用场景

  • Content-heavy sites (blogs, documentation, news)
  • SEO is critical
  • Users have slow connections (less JS to download)
  • Simple interactivity requirements
  • Progressive enhancement is important
  • 内容密集型网站(博客、文档、新闻)
  • SEO至关重要的场景
  • 用户网络连接较慢(需下载的JS更少)
  • 交互需求简单
  • 渐进式增强很重要

Example Flow

示例流程

/home → Server renders home.html
/about → Server renders about.html (full page reload)
/products/123 → Server renders product.html (full page reload)
/home → 服务器渲染home.html
/about → 服务器渲染about.html(完整页面重载)
/products/123 → 服务器渲染product.html(完整页面重载)

Single Page Application (SPA)

单页应用(SPA)

The modern app model - one HTML shell, JavaScript handles all routing and rendering.
现代应用模型 - 仅使用一个HTML外壳,JavaScript处理所有路由和渲染逻辑。

How It Works

工作原理

Initial: Browser loads shell HTML + JS bundle
Navigation: JS intercepts clicks → Updates URL → Renders new view (no server request for HTML)
Data: Fetch API calls for JSON data only
初始阶段:浏览器加载外壳HTML + JS包
导航阶段:JS拦截点击事件 → 更新URL → 渲染新视图(无需向服务器请求HTML)
数据请求:仅通过Fetch API获取JSON数据

Characteristics

特性

AspectBehavior
NavigationInstant (no page reload)
Initial LoadSlower - must download JS bundle
Subsequent NavigationFast - only fetch data, render client-side
StatePreserved across navigation
SEOChallenging - requires additional strategies
ServerAPI endpoints only (JSON responses)
维度表现
导航即时响应(无页面重载)
初始加载速度慢 - 必须下载完整的JS包
后续导航速度快 - 仅获取数据,客户端渲染
状态导航时保持持久
SEO具有挑战性 - 需要额外的优化策略
服务器仅提供API端点(返回JSON响应)

The SPA Tradeoffs

SPA的权衡

Advantages:
  • App-like user experience
  • Smooth transitions and animations
  • Persistent UI state (music players, forms, etc.)
  • Reduced server load after initial load
Disadvantages:
  • Large initial JavaScript bundle
  • SEO requires workarounds (SSR, prerendering)
  • Memory management complexity
  • Back button/deep linking need explicit handling
  • Time to Interactive (TTI) can be slow
优势:
  • 类应用的用户体验
  • 流畅的过渡和动画效果
  • 持久的UI状态(音乐播放器、表单等)
  • 初始加载后服务器负载降低
劣势:
  • 初始JavaScript包体积大
  • SEO需要使用SSR、预渲染等解决方案
  • 内存管理复杂度高
  • 后退按钮/深度链接需要显式处理
  • 交互就绪时间(TTI)可能较长

Example Flow

示例流程

/home → JS renders Home component
/about → JS renders About component (no server request)
/products/123 → JS fetches product data → Renders Product component
/home → JS渲染Home组件
/about → JS渲染About组件(无服务器请求)
/products/123 → JS获取产品数据 → 渲染Product组件

Hybrid Architectures

混合架构

Modern meta-frameworks blur the line between SPA and MPA.
现代元框架模糊了SPA和MPA之间的界限。

Multi Page App with Islands

带交互岛的多页应用

MPA foundation with interactive "islands" of JavaScript.
Server renders full HTML → JS hydrates only interactive components
Examples: Astro, Fresh (Deno)
以MPA为基础,包含交互式的JavaScript“岛”。
服务器渲染完整HTML → JS仅对交互式组件进行水合
示例: Astro、Fresh(Deno)

SPA with Server-Side Rendering

带服务器端渲染的SPA

SPA that pre-renders on server for initial load.
First request: Server renders full HTML + hydrates to SPA
Subsequent: Client-side navigation (SPA behavior)
Examples: Next.js, Nuxt, SvelteKit, Remix
在初始加载时由服务器预渲染的SPA。
首次请求:服务器渲染完整HTML + 水合为SPA
后续请求:客户端导航(SPA行为)
示例: Next.js、Nuxt、SvelteKit、Remix

Streaming/Progressive Rendering

流式/渐进式渲染

Server streams HTML as it becomes available.
Server starts sending HTML → Browser renders progressively → JS hydrates as content arrives
服务器在内容生成时就开始流式传输HTML。
服务器开始发送HTML → 浏览器渐进式渲染 → JS随内容到达逐步水合

Architecture Decision Matrix

架构决策矩阵

RequirementRecommended
Content site, SEO criticalMPA or Hybrid with SSR
Dashboard, authenticated appSPA or Hybrid
E-commerce (SEO + interactivity)Hybrid with SSR
Minimal JS, fast initial loadMPA with Islands
Rich interactions, app-like UXSPA or Hybrid
Limited team, simple stackMPA
Offline support neededSPA with Service Workers
需求推荐架构
内容型网站,SEO至关重要MPA或带SSR的混合架构
仪表盘、认证类应用SPA或混合架构
电商网站(SEO + 交互性)带SSR的混合架构
最小化JS,快速初始加载带交互岛的MPA
丰富交互、类应用体验SPA或混合架构
团队规模小、技术栈简单MPA
需要离线支持带Service Workers的SPA

Key Concepts to Understand

需要理解的核心概念

Client-Side Routing

客户端路由

SPAs intercept navigation using the History API:
javascript
// Instead of browser navigation
window.history.pushState({}, '', '/new-route');
// App renders new view without page reload
SPA使用History API拦截导航:
javascript
// 替代浏览器默认导航
window.history.pushState({}, '', '/new-route');
// 应用无需页面重载即可渲染新视图

Code Splitting

代码分割

Breaking JS bundle into smaller chunks loaded on demand:
Initial: core.js (router, framework)
/dashboard: dashboard.chunk.js (loaded when needed)
/settings: settings.chunk.js (loaded when needed)
将JS包拆分为按需加载的较小块:
初始加载:core.js(路由、框架)
/dashboard: dashboard.chunk.js(需要时加载)
/settings: settings.chunk.js(需要时加载)

State Persistence

状态持久化

SPAs maintain state in memory; MPAs must serialize state:
SPA: Component state survives navigation
MPA: State stored in URL params, cookies, localStorage, or server sessions

SPA在内存中保持状态;MPA必须序列化状态:
SPA:组件状态在导航时保留
MPA:状态存储在URL参数、Cookie、localStorage或服务器会话中

Deep Dive: Understanding the Fundamentals

深入理解:基础原理

The Browser's Request-Response Cycle

浏览器的请求-响应周期

To truly understand SPA vs MPA, you must understand how browsers work at a fundamental level.
Traditional Web (MPA) - How the browser was designed:
1. USER ACTION: Click a link <a href="/about">
   
2. BROWSER BEHAVIOR:
   - Stops current page execution
   - Clears current DOM
   - Sends HTTP GET request to server
   - Shows loading indicator
   
3. SERVER RESPONSE:
   - Server receives request for "/about"
   - Server executes backend code (PHP, Ruby, Python, Node)
   - Server queries database if needed
   - Server generates complete HTML document
   - Server sends HTML back with Content-Type: text/html
   
4. BROWSER RENDERING:
   - Browser receives HTML
   - Parses HTML, builds DOM tree
   - Discovers CSS/JS, fetches them
   - Renders page to screen
   - Page is interactive
This is how the web worked from 1991 until ~2010. Every navigation = full cycle.
SPA - Hijacking the browser's default behavior:
SPAs work by preventing the browser's natural behavior:
javascript
// The core SPA trick: prevent default browser navigation
document.addEventListener('click', (event) => {
  const link = event.target.closest('a');
  
  if (link && link.href.startsWith(window.location.origin)) {
    // STOP the browser from doing its normal thing
    event.preventDefault();
    
    // Instead, WE handle navigation with JavaScript
    const path = new URL(link.href).pathname;
    
    // Update the URL bar (without page reload)
    window.history.pushState({}, '', path);
    
    // Render the new "page" ourselves
    renderRoute(path);
  }
});
This is why SPAs feel "app-like" - the page never actually reloads.
要真正理解SPA与MPA,必须从根本上了解浏览器的工作方式。
传统Web(MPA)- 浏览器的原生设计:
1. 用户操作:点击链接 <a href="/about">
   
2. 浏览器行为:
   - 停止当前页面执行
   - 清除当前DOM
   - 向服务器发送HTTP GET请求
   - 显示加载指示器
   
3. 服务器响应:
   - 服务器接收"/about"请求
   - 服务器执行后端代码(PHP、Ruby、Python、Node)
   - 必要时查询数据库
   - 服务器生成完整的HTML文档
   - 服务器返回HTML,Content-Type为text/html
   
4. 浏览器渲染:
   - 浏览器接收HTML
   - 解析HTML,构建DOM树
   - 发现CSS/JS,获取资源
   - 将页面渲染到屏幕
   - 页面可交互
这是1991年到2010年左右Web的工作方式。每次导航都等于完整的周期。
SPA - 劫持浏览器的默认行为:
SPA通过阻止浏览器的原生行为来工作:
javascript
// SPA的核心技巧:阻止浏览器默认导航行为
document.addEventListener('click', (event) => {
  const link = event.target.closest('a');
  
  if (link && link.href.startsWith(window.location.origin)) {
    // 阻止浏览器执行默认操作
    event.preventDefault();
    
    // 改用JavaScript处理导航
    const path = new URL(link.href).pathname;
    
    // 更新URL栏(无页面重载)
    window.history.pushState({}, '', path);
    
    // 自行渲染新“页面”
    renderRoute(path);
  }
});
这就是为什么SPA给人“类应用”的感觉——页面实际上从未重载。

Why Does SPA Navigation Feel Instant?

为什么SPA导航感觉即时?

MPA Navigation Time Breakdown:
DNS Lookup:           ~20-100ms (if not cached)
TCP Connection:       ~50-200ms (round trip)
TLS Handshake:        ~50-150ms (HTTPS)
Server Processing:    ~50-500ms (database, rendering)
Response Transfer:    ~50-200ms (HTML size dependent)
Browser Parsing:      ~50-100ms
CSS/JS Fetch:         ~100-300ms (even if cached, verification)
Render:               ~50-100ms
─────────────────────────────────
TOTAL:                ~400-1600ms minimum
SPA Navigation Time Breakdown:
JavaScript Execution: ~5-50ms (route matching, component rendering)
DOM Update:           ~5-20ms (virtual DOM diff, real DOM update)
─────────────────────────────────
TOTAL:                ~10-70ms

Data Fetch (if needed): +100-500ms (but can show skeleton immediately)
The SPA is 10-100x faster for navigation because it skips the entire HTTP round-trip.
MPA导航时间分解:
DNS查找:           ~20-100ms(如果未缓存)
TCP连接:           ~50-200ms(往返时间)
TLS握手:        ~50-150ms(HTTPS)
服务器处理:    ~50-500ms(数据库、渲染)
响应传输:    ~50-200ms(取决于HTML大小)
浏览器解析:      ~50-100ms
CSS/JS获取:         ~100-300ms(即使缓存,也需要验证)
渲染:               ~50-100ms
─────────────────────────────────
总计:                ~400-1600ms(最小值)
SPA导航时间分解:
JavaScript执行: ~5-50ms(路由匹配、组件渲染)
DOM更新:           ~5-20ms(虚拟DOM diff、真实DOM更新)
─────────────────────────────────
总计:                ~10-70ms

数据请求(如需): +100-500ms(但可立即显示骨架屏)
SPA的导航速度比MPA快10-100倍,因为它跳过了整个HTTP往返过程。

The Cost of SPA Speed: Initial Load

SPA速度的代价:初始加载

But there's a tradeoff. Before ANY navigation can happen, the SPA must:
1. Download the HTML shell (small, ~5KB)
2. Download the JavaScript bundle (often 200KB-2MB+)
3. Parse the JavaScript (CPU intensive)
4. Execute the JavaScript (initialize framework, router, stores)
5. Render the initial route

MPA First Page:  ~400-1600ms to content
SPA First Page:  ~800-3000ms to content (must wait for JS)
This is why "Time to Interactive" (TTI) is a problem for SPAs.
但这是有代价的。在进行任何导航之前,SPA必须:
1. 下载HTML外壳(体积小,约5KB)
2. 下载JavaScript包(通常200KB-2MB+)
3. 解析JavaScript(CPU密集型操作)
4. 执行JavaScript(初始化框架、路由、存储)
5. 渲染初始路由

MPA首屏加载:  ~400-1600ms可见内容
SPA首屏加载:  ~800-3000ms可见内容(必须等待JS加载完成)
这就是为什么“交互就绪时间(TTI)”是SPA的一个问题。

Understanding Browser APIs That Enable SPAs

支撑SPA的浏览器API

The History API (HTML5, 2010):
Before HTML5, the only way to change the URL was to trigger a page load. The History API changed everything:
javascript
// Push a new entry to browser history (URL changes, no reload)
history.pushState(stateObject, title, '/new-url');

// Replace current entry (URL changes, no reload, no new history entry)
history.replaceState(stateObject, title, '/new-url');

// Listen for back/forward button clicks
window.addEventListener('popstate', (event) => {
  // event.state contains the stateObject from pushState
  // Your app must now render the appropriate content
  renderRoute(window.location.pathname);
});
Without the History API, SPAs would only work with hash URLs (
/#/about
).
The Fetch API:
SPAs separate data from presentation. Instead of getting HTML, they get JSON:
javascript
// MPA: Server returns complete HTML page
// <html><body><h1>Product: Shoes</h1><p>Price: $99</p>...</body></html>

// SPA: Server returns just data
// {"name": "Shoes", "price": 99, "description": "..."}

// SPA renders data into components
const response = await fetch('/api/products/123');
const product = await response.json();
renderProduct(product);  // JavaScript creates the HTML
History API(HTML5,2010年):
在HTML5之前,更改URL的唯一方法是触发页面加载。History API改变了一切:
javascript
// 向浏览器历史记录添加新条目(URL更改,无重载)
history.pushState(stateObject, title, '/new-url');

// 替换当前历史条目(URL更改,无重载,不添加新历史记录)
history.replaceState(stateObject, title, '/new-url');

// 监听后退/前进按钮点击
window.addEventListener('popstate', (event) => {
  // event.state包含pushState中的stateObject
  // 应用现在必须渲染相应的内容
  renderRoute(window.location.pathname);
});
没有History API,SPA只能使用哈希URL(
/#/about
)。
Fetch API:
SPA将数据与展示分离。它获取的不是HTML,而是JSON:
javascript
// MPA: 服务器返回完整的HTML页面
// <html><body><h1>Product: Shoes</h1><p>Price: $99</p>...</body></html>

// SPA: 服务器仅返回数据
// {"name": "Shoes", "price": 99, "description": "..."}

// SPA将数据渲染到组件中
const response = await fetch('/api/products/123');
const product = await response.json();
renderProduct(product);  // JavaScript创建HTML

Memory and State: The Fundamental Difference

内存与状态:根本差异

MPA State Management:
Page Load → JavaScript runs → State created in memory
Navigation → Page destroyed → ALL MEMORY FREED → New page loads → Fresh state

Each page is a clean slate. No memory leaks possible (page is destroyed).
State that must persist: cookies, localStorage, URL parameters, server sessions.
SPA State Management:
App Load → JavaScript runs → State created in memory
Navigation → State PERSISTS → Components mount/unmount → State grows
... hours later ...
Navigation → State still in memory → Potential memory leaks

The app NEVER gets a clean slate. Memory management is YOUR responsibility.
This is why SPAs can have memory leaks and why tools like React DevTools have memory profilers.
MPA状态管理:
页面加载 → JavaScript运行 → 在内存中创建状态
导航 → 页面销毁 → 所有内存释放 → 新页面加载 → 全新状态

每个页面都是干净的环境。不会出现内存泄漏(页面已销毁)。
必须持久化的状态:Cookie、localStorage、URL参数、服务器会话。
SPA状态管理:
应用加载 → JavaScript运行 → 在内存中创建状态
导航 → 状态保留 → 组件挂载/卸载 → 状态增长
... 数小时后 ...
导航 → 状态仍在内存中 → 可能出现内存泄漏

应用永远不会获得干净的环境。内存管理是开发者的责任。
这就是为什么SPA可能存在内存泄漏,以及React DevTools等工具包含内存分析器的原因。

The Document Object Model (DOM) and Why It Matters

文档对象模型(DOM)及其重要性

The DOM is a tree structure representing your HTML:
document
└── html
    ├── head
    │   ├── title
    │   └── link (CSS)
    └── body
        ├── header
        │   └── nav
        ├── main
        │   ├── h1
        │   └── p
        └── footer
MPA DOM Lifecycle:
1. Browser builds DOM from HTML
2. User interacts with page
3. Navigation → ENTIRE DOM DESTROYED
4. New DOM built from new HTML
SPA DOM Lifecycle:
1. Browser builds initial DOM (shell only)
2. JavaScript modifies DOM to add content
3. Navigation → JavaScript MODIFIES DOM (adds/removes nodes)
4. DOM is never destroyed, only mutated
SPAs must be careful about DOM manipulation efficiency. This is why frameworks use:
  • Virtual DOM (React)
  • Compiler-based reactivity (Svelte)
  • Fine-grained reactivity (Solid)
DOM是表示HTML的树状结构:
document
└── html
    ├── head
    │   ├── title
    │   └── link (CSS)
    └── body
        ├── header
        │   └── nav
        ├── main
        │   ├── h1
        │   └── p
        └── footer
MPA DOM生命周期:
1. 浏览器从HTML构建DOM
2. 用户与页面交互
3. 导航 → 整个DOM被销毁
4. 从新HTML构建新DOM
SPA DOM生命周期:
1. 浏览器构建初始DOM(仅外壳)
2. JavaScript修改DOM以添加内容
3. 导航 → JavaScript修改DOM(添加/删除节点)
4. DOM永远不会被销毁,仅会被修改
SPA必须注意DOM操作的效率。这就是为什么框架使用:
  • 虚拟DOM(React)
  • 基于编译器的响应式(Svelte)
  • 细粒度响应式(Solid)

HTTP/2 and Why It Changed the Equation

HTTP/2如何改变格局

HTTP/1.1 had a major limitation: one request at a time per connection (or 6 parallel connections max).
HTTP/1.1 MPA:
Request 1: HTML ────────────────►
Request 2: CSS  ─────────────────► (waits or new connection)
Request 3: JS   ──────────────────► (waits or new connection)
Request 4: Image ───────────────────► (max 6 parallel)
HTTP/2 introduced multiplexing: unlimited parallel requests on one connection.
HTTP/2 MPA:
Request 1: HTML  ───►
Request 2: CSS   ───►
Request 3: JS    ───►  All sent simultaneously!
Request 4: Image ───►
This made MPAs much faster and reduced the SPA advantage for initial load.
HTTP/1.1有一个主要限制:每个连接一次只能处理一个请求(或最多6个并行连接)。
HTTP/1.1 MPA:
请求1: HTML ────────────────►
请求2: CSS  ─────────────────►(等待或新建连接)
请求3: JS   ──────────────────►(等待或新建连接)
请求4: Image ───────────────────►(最多6个并行)
HTTP/2引入了多路复用:一个连接上可以同时处理无限个并行请求。
HTTP/2 MPA:
请求1: HTML  ───►
请求2: CSS   ───►
请求3: JS    ───►  所有请求同时发送!
请求4: Image ───►
这使得MPA的速度大幅提升,降低了SPA在初始加载方面的优势。

Server Load: Understanding the Scale Implications

服务器负载:规模影响

MPA Server Load:
Each request:
- Parse request
- Route to handler
- Query database
- Execute template engine
- Generate HTML string
- Send response

CPU: High (template rendering per request)
Memory: Moderate (per-request state)
Bandwidth: High (sending full HTML each time)
SPA Server Load:
Initial request:
- Serve static HTML file (cached by CDN)
- Serve static JS bundle (cached by CDN)

API requests:
- Parse request
- Query database
- Return JSON (smaller than HTML)

CPU: Lower (no template rendering)
Memory: Lower (stateless API)
Bandwidth: Lower (JSON smaller than HTML)
SPAs can scale more easily because static assets are cached at the edge (CDN).
MPA服务器负载:
每个请求:
- 解析请求
- 路由到处理程序
- 查询数据库
- 执行模板引擎
- 生成HTML字符串
- 发送响应

CPU: 高(每个请求都要渲染模板)
内存: 中等(每个请求的状态)
带宽: 高(每次发送完整HTML)
SPA服务器负载:
初始请求:
- 提供静态HTML文件(由CDN缓存)
- 提供静态JS包(由CDN缓存)

API请求:
- 解析请求
- 查询数据库
- 返回JSON(比HTML体积小)

CPU: 低(无需渲染模板)
内存: 低(无状态API)
带宽: 低(JSON比HTML体积小)
SPA更容易扩展,因为静态资源在边缘节点(CDN)缓存。

SEO: Why Crawlers Struggle with SPAs

SEO:为什么爬虫难以处理SPA

Google's crawler has two phases:
Phase 1: Crawling (fast, cheap)
- HTTP request to URL
- Receive HTML response
- Extract links for further crawling
- Index the text content

Phase 2: Rendering (slow, expensive)
- Load page in headless Chrome
- Execute JavaScript
- Wait for content to appear
- Index the rendered content
MPA Crawling:
GET /products/shoes → Receives complete HTML with all content
                    → Indexed immediately in Phase 1
SPA Crawling:
GET /products/shoes → Receives: <div id="root"></div>
                    → No content to index in Phase 1
                    → Must wait for Phase 2 (delayed, not guaranteed)
Google DOES execute JavaScript, but:
  • It's delayed (crawl budget prioritization)
  • It's expensive (limited render budget)
  • Some pages may never get rendered
  • Dynamic content may timeout
This is why SSR exists: serve complete HTML to crawlers, hydrate to SPA for users.
Google的爬虫分为两个阶段:
阶段1:爬取(快速、低成本)
- 向URL发送HTTP请求
- 接收HTML响应
- 提取链接用于进一步爬取
- 索引文本内容

阶段2:渲染(缓慢、高成本)
- 在无头Chrome中加载页面
- 执行JavaScript
- 等待内容出现
- 索引渲染后的内容
MPA爬取:
GET /products/shoes → 接收包含所有内容的完整HTML
                    → 立即在阶段1索引
SPA爬取:
GET /products/shoes → 接收:<div id="root"></div>
                    → 阶段1无内容可索引
                    → 必须等待阶段2(延迟,不保证执行)
Google确实会执行JavaScript,但:
  • 执行延迟(爬取预算优先级)
  • 成本高(渲染预算有限)
  • 某些页面可能永远不会被渲染
  • 动态内容可能超时
这就是SSR存在的原因:向爬虫提供完整HTML,向用户水合为SPA。

Progressive Enhancement: The MPA Philosophy

渐进式增强:MPA的理念

MPAs embrace progressive enhancement:
Layer 1: HTML (content, accessible to all)
Layer 2: CSS (styling, enhances presentation)
Layer 3: JavaScript (interactivity, enhances experience)
Each layer is optional. The page works without JS.
SPAs invert this:
Layer 1: JavaScript (required for anything to work)
Layer 2: Content rendered by JavaScript
Layer 3: Everything depends on JS
If JS fails (network error, parsing error, old browser), SPA shows nothing.
MPA采用渐进式增强:
第一层:HTML(内容,所有设备均可访问)
第二层:CSS(样式,增强展示)
第三层:JavaScript(交互,增强体验)
每一层都是可选的。即使没有JS,页面也能正常工作。
SPA则相反:
第一层:JavaScript(必须加载才能正常工作)
第二层:由JavaScript渲染的内容
第三层:所有内容都依赖JS
如果JS加载失败(网络错误、解析错误、旧浏览器),SPA将无法显示任何内容。

When to Choose What: The Real Decision Framework

如何选择:实际决策框架

Choose MPA when:
  • Content is the product (blogs, news, documentation)
  • SEO is non-negotiable
  • Users may have JS disabled
  • Team is small/unfamiliar with frontend complexity
  • Server-side languages are the team's strength
Choose SPA when:
  • It's an "application" not a "website" (dashboards, tools)
  • Users are authenticated (SEO irrelevant)
  • Rich interactivity is core to the experience
  • Offline support is needed
  • Real-time updates are frequent
Choose Hybrid when:
  • You need both SEO and interactivity (e-commerce)
  • Different parts of the site have different needs
  • Performance is critical for both initial and subsequent loads
  • You want the best of both worlds (most modern apps)

选择MPA的场景:
  • 内容是核心产品(博客、新闻、文档)
  • SEO是硬性要求
  • 用户可能禁用JS
  • 团队规模小/不熟悉前端复杂技术
  • 团队擅长后端语言
选择SPA的场景:
  • 是“应用”而非“网站”(仪表盘、工具)
  • 用户需要认证(SEO无关)
  • 丰富交互是核心体验
  • 需要离线支持
  • 实时更新频繁
选择混合架构的场景:
  • 同时需要SEO和交互性(电商)
  • 网站不同部分有不同需求
  • 初始加载和后续导航的性能都很关键
  • 想要兼顾两者的优势(大多数现代应用)

For Framework Authors: Building Web Architectures

面向框架开发者:构建Web架构

Implementation Note: The patterns and code examples below represent one proven approach to building these systems. There are many valid ways to implement web architectures—the direction shown here is based on patterns used by popular frameworks like React Router, Vue Router, and Astro. Use these as a starting point and adapt based on your framework's specific requirements, constraints, and design philosophy.
实现说明:以下模式和代码示例代表了构建这些系统的一种成熟方法。实现Web架构有多种有效方式——此处展示的方向基于React Router、Vue Router和Astro等流行框架使用的模式。以此为起点,根据框架的特定需求、约束和设计理念进行调整。

Implementing a Minimal SPA Router

实现极简SPA路由

If you're building a framework, here's how to implement client-side routing:
javascript
// MINIMAL SPA ROUTER IMPLEMENTATION

class Router {
  constructor() {
    this.routes = new Map();
    this.currentRoute = null;
    this.outlet = null;
    
    // Listen for browser back/forward
    window.addEventListener('popstate', () => this.handleNavigation());
    
    // Intercept link clicks
    document.addEventListener('click', (e) => {
      const link = e.target.closest('a');
      if (link && link.href.startsWith(location.origin)) {
        e.preventDefault();
        this.navigate(link.pathname);
      }
    });
  }
  
  // Register a route with pattern and handler
  route(pattern, handler) {
    // Convert /users/:id to regex with named groups
    const paramNames = [];
    const regexPattern = pattern.replace(/:([^/]+)/g, (_, name) => {
      paramNames.push(name);
      return '([^/]+)';
    });
    
    this.routes.set(new RegExp(`^${regexPattern}$`), {
      handler,
      paramNames,
    });
  }
  
  // Programmatic navigation
  navigate(path, { replace = false } = {}) {
    if (replace) {
      history.replaceState({ path }, '', path);
    } else {
      history.pushState({ path }, '', path);
    }
    this.handleNavigation();
  }
  
  // Match current URL and render
  async handleNavigation() {
    const path = location.pathname;
    
    for (const [regex, { handler, paramNames }] of this.routes) {
      const match = path.match(regex);
      if (match) {
        // Extract route params
        const params = {};
        paramNames.forEach((name, i) => {
          params[name] = match[i + 1];
        });
        
        // Call route handler
        const content = await handler({ params, path });
        
        // Render to outlet
        if (this.outlet) {
          this.outlet.innerHTML = '';
          this.outlet.appendChild(content);
        }
        return;
      }
    }
    
    // 404 handling
    console.error('No route matched:', path);
  }
  
  // Set render target
  mount(element) {
    this.outlet = element;
    this.handleNavigation();
  }
}

// Usage
const router = new Router();
router.route('/', () => createElement('h1', 'Home'));
router.route('/users/:id', ({ params }) => 
  createElement('h1', `User ${params.id}`)
);
router.mount(document.getElementById('app'));
如果你正在构建框架,以下是实现客户端路由的方法:
javascript
// MINIMAL SPA ROUTER IMPLEMENTATION

class Router {
  constructor() {
    this.routes = new Map();
    this.currentRoute = null;
    this.outlet = null;
    
    // 监听浏览器后退/前进
    window.addEventListener('popstate', () => this.handleNavigation());
    
    // 拦截链接点击
    document.addEventListener('click', (e) => {
      const link = e.target.closest('a');
      if (link && link.href.startsWith(location.origin)) {
        e.preventDefault();
        this.navigate(link.pathname);
      }
    });
  }
  
  // 注册路由(模式和处理程序)
  route(pattern, handler) {
    // 将/users/:id转换为带命名组的正则表达式
    const paramNames = [];
    const regexPattern = pattern.replace(/:([^/]+)/g, (_, name) => {
      paramNames.push(name);
      return '([^/]+)';
    });
    
    this.routes.set(new RegExp(`^${regexPattern}$`), {
      handler,
      paramNames,
    });
  }
  
  // 编程式导航
  navigate(path, { replace = false } = {}) {
    if (replace) {
      history.replaceState({ path }, '', path);
    } else {
      history.pushState({ path }, '', path);
    }
    this.handleNavigation();
  }
  
  // 匹配当前URL并渲染
  async handleNavigation() {
    const path = location.pathname;
    
    for (const [regex, { handler, paramNames }] of this.routes) {
      const match = path.match(regex);
      if (match) {
        // 提取路由参数
        const params = {};
        paramNames.forEach((name, i) => {
          params[name] = match[i + 1];
        });
        
        // 调用路由处理程序
        const content = await handler({ params, path });
        
        // 渲染到出口
        if (this.outlet) {
          this.outlet.innerHTML = '';
          this.outlet.appendChild(content);
        }
        return;
      }
    }
    
    // 404处理
    console.error('No route matched:', path);
  }
  
  // 设置渲染目标
  mount(element) {
    this.outlet = element;
    this.handleNavigation();
  }
}

// 使用示例
const router = new Router();
router.route('/', () => createElement('h1', 'Home'));
router.route('/users/:id', ({ params }) => 
  createElement('h1', `User ${params.id}`)
);
router.mount(document.getElementById('app'));

Building a File-Based Router (Build Time)

构建基于文件的路由(构建时)

Meta-frameworks use file system as routing config:
javascript
// FILE-BASED ROUTING IMPLEMENTATION (build tool)

import { glob } from 'glob';
import path from 'path';

function generateRoutes(pagesDir) {
  // Find all page files
  const files = glob.sync('**/*.{js,jsx,ts,tsx}', { cwd: pagesDir });
  
  const routes = files.map(file => {
    // Remove extension
    let route = file.replace(/\.(js|jsx|ts|tsx)$/, '');
    
    // Handle index files
    route = route.replace(/\/index$/, '') || '/';
    
    // Convert [param] to :param
    route = route.replace(/\[([^\]]+)\]/g, ':$1');
    
    // Convert [...slug] to *
    route = route.replace(/\[\.\.\.([^\]]+)\]/g, '*');
    
    return {
      path: '/' + route,
      component: path.join(pagesDir, file),
      // Generate regex for matching
      regex: pathToRegex('/' + route),
    };
  });
  
  // Sort routes: static before dynamic, specific before catch-all
  return routes.sort((a, b) => {
    const aScore = routeScore(a.path);
    const bScore = routeScore(b.path);
    return bScore - aScore;
  });
}

function routeScore(path) {
  let score = 0;
  // Static segments are worth more
  const segments = path.split('/').filter(Boolean);
  for (const seg of segments) {
    if (seg.startsWith(':')) score += 1;      // Dynamic: low
    else if (seg === '*') score += 0;          // Catch-all: lowest
    else score += 10;                          // Static: high
  }
  return score;
}

// Generate route manifest at build time
const routes = generateRoutes('./src/pages');
writeFileSync('./dist/routes.json', JSON.stringify(routes, null, 2));
元框架使用文件系统作为路由配置:
javascript
// FILE-BASED ROUTING IMPLEMENTATION (build tool)

import { glob } from 'glob';
import path from 'path';

function generateRoutes(pagesDir) {
  // 查找所有页面文件
  const files = glob.sync('**/*.{js,jsx,ts,tsx}', { cwd: pagesDir });
  
  const routes = files.map(file => {
    // 移除扩展名
    let route = file.replace(/\.(js|jsx|ts|tsx)$/, '');
    
    // 处理index文件
    route = route.replace(/\/index$/, '') || '/';
    
    // 将[param]转换为:param
    route = route.replace(/\[([^\]]+)\]/g, ':$1');
    
    // 将[...slug]转换为*
    route = route.replace(/\[\.\.\.([^\]]+)\]/g, '*');
    
    return {
      path: '/' + route,
      component: path.join(pagesDir, file),
      // 生成匹配用的正则表达式
      regex: pathToRegex('/' + route),
    };
  });
  
  // 排序路由:静态路由优先于动态路由,具体路由优先于通配路由
  return routes.sort((a, b) => {
    const aScore = routeScore(a.path);
    const bScore = routeScore(b.path);
    return bScore - aScore;
  });
}

function routeScore(path) {
  let score = 0;
  // 静态段权重更高
  const segments = path.split('/').filter(Boolean);
  for (const seg of segments) {
    if (seg.startsWith(':')) score += 1;      // 动态路由:低权重
    else if (seg === '*') score += 0;          // 通配路由:最低权重
    else score += 10;                          // 静态路由:高权重
  }
  return score;
}

// 在构建时生成路由清单
const routes = generateRoutes('./src/pages');
writeFileSync('./dist/routes.json', JSON.stringify(routes, null, 2));

Implementing Nested Layouts

实现嵌套布局

Layouts require component composition:
javascript
// NESTED LAYOUT SYSTEM

// Layout discovery at build time
function buildLayoutTree(routePath, pagesDir) {
  const segments = routePath.split('/').filter(Boolean);
  const layouts = [];
  
  // Walk up the tree finding layouts
  let currentPath = pagesDir;
  
  // Check root layout
  if (existsSync(path.join(currentPath, '_layout.tsx'))) {
    layouts.push(path.join(currentPath, '_layout.tsx'));
  }
  
  // Check each segment
  for (const segment of segments) {
    currentPath = path.join(currentPath, segment);
    if (existsSync(path.join(currentPath, '_layout.tsx'))) {
      layouts.push(path.join(currentPath, '_layout.tsx'));
    }
  }
  
  return layouts;  // Ordered from root to leaf
}

// Runtime rendering with layouts
async function renderWithLayouts(layouts, pageComponent, props) {
  // Start from innermost (page) and wrap outward
  let content = await pageComponent(props);
  
  // Wrap with each layout, inside-out
  for (let i = layouts.length - 1; i >= 0; i--) {
    const Layout = await import(layouts[i]);
    content = await Layout.default({ children: content, ...props });
  }
  
  return content;
}
布局需要组件组合:
javascript
// NESTED LAYOUT SYSTEM

// 构建时发现布局
function buildLayoutTree(routePath, pagesDir) {
  const segments = routePath.split('/').filter(Boolean);
  const layouts = [];
  
  // 向上遍历树查找布局
  let currentPath = pagesDir;
  
  // 检查根布局
  if (existsSync(path.join(currentPath, '_layout.tsx'))) {
    layouts.push(path.join(currentPath, '_layout.tsx'));
  }
  
  // 检查每个段
  for (const segment of segments) {
    currentPath = path.join(currentPath, segment);
    if (existsSync(path.join(currentPath, '_layout.tsx'))) {
      layouts.push(path.join(currentPath, '_layout.tsx'));
    }
  }
  
  return layouts;  // 从根到叶排序
}

// 带布局的运行时渲染
async function renderWithLayouts(layouts, pageComponent, props) {
  // 从最内层(页面)开始向外包裹
  let content = await pageComponent(props);
  
  // 从内到外包裹每个布局
  for (let i = layouts.length - 1; i >= 0; i--) {
    const Layout = await import(layouts[i]);
    content = await Layout.default({ children: content, ...props });
  }
  
  return content;
}

State Preservation Across Navigation

导航时的状态保留

SPAs must preserve state during navigation:
javascript
// STATE PRESERVATION STRATEGIES

class NavigationStateManager {
  constructor() {
    this.componentStates = new Map();
    this.scrollPositions = new Map();
  }
  
  // Save state before navigation
  saveState(routeKey, componentTree) {
    // Serialize component state
    const state = this.extractState(componentTree);
    this.componentStates.set(routeKey, state);
    
    // Save scroll position
    this.scrollPositions.set(routeKey, {
      x: window.scrollX,
      y: window.scrollY,
    });
  }
  
  // Restore state after navigation
  restoreState(routeKey) {
    const state = this.componentStates.get(routeKey);
    const scroll = this.scrollPositions.get(routeKey);
    
    return { state, scroll };
  }
  
  // Extract serializable state from component tree
  extractState(tree) {
    // Framework-specific: walk component tree
    // Extract useState values, refs, etc.
    // Must handle circular references
  }
}

// Integration with router
router.beforeNavigate((from, to) => {
  stateManager.saveState(from.path, currentComponentTree);
});

router.afterNavigate((to) => {
  const { state, scroll } = stateManager.restoreState(to.path);
  if (state) {
    restoreComponentState(state);
  }
  if (scroll) {
    window.scrollTo(scroll.x, scroll.y);
  }
});
SPA必须在导航时保留状态:
javascript
// STATE PRESERVATION STRATEGIES

class NavigationStateManager {
  constructor() {
    this.componentStates = new Map();
    this.scrollPositions = new Map();
  }
  
  // 导航前保存状态
  saveState(routeKey, componentTree) {
    // 序列化组件状态
    const state = this.extractState(componentTree);
    this.componentStates.set(routeKey, state);
    
    // 保存滚动位置
    this.scrollPositions.set(routeKey, {
      x: window.scrollX,
      y: window.scrollY,
    });
  }
  
  // 导航后恢复状态
  restoreState(routeKey) {
    const state = this.componentStates.get(routeKey);
    const scroll = this.scrollPositions.get(routeKey);
    
    return { state, scroll };
  }
  
  // 从组件树中提取可序列化状态
  extractState(tree) {
    // 框架特定:遍历组件树
    // 提取useState值、refs等
    // 必须处理循环引用
  }
}

// 与路由集成
router.beforeNavigate((from, to) => {
  stateManager.saveState(from.path, currentComponentTree);
});

router.afterNavigate((to) => {
  const { state, scroll } = stateManager.restoreState(to.path);
  if (state) {
    restoreComponentState(state);
  }
  if (scroll) {
    window.scrollTo(scroll.x, scroll.y);
  }
});

Memory Management for Long-Running SPAs

长期运行SPA的内存管理

javascript
// MEMORY LEAK PREVENTION

class ComponentRegistry {
  constructor() {
    this.mounted = new Set();
    this.cleanupFns = new Map();
  }
  
  mount(component, cleanup) {
    this.mounted.add(component);
    if (cleanup) {
      this.cleanupFns.set(component, cleanup);
    }
  }
  
  unmount(component) {
    // Run cleanup functions
    const cleanup = this.cleanupFns.get(component);
    if (cleanup) {
      cleanup();
      this.cleanupFns.delete(component);
    }
    
    this.mounted.delete(component);
  }
  
  // Called on route change
  unmountRoute(routeComponents) {
    for (const component of routeComponents) {
      this.unmount(component);
    }
    
    // Force garbage collection hint
    if (global.gc) global.gc();
  }
}

// Event listener cleanup pattern
class EventManager {
  constructor() {
    this.listeners = new WeakMap();
  }
  
  addListener(element, event, handler) {
    element.addEventListener(event, handler);
    
    // Track for cleanup
    if (!this.listeners.has(element)) {
      this.listeners.set(element, []);
    }
    this.listeners.get(element).push({ event, handler });
  }
  
  removeAllListeners(element) {
    const handlers = this.listeners.get(element);
    if (handlers) {
      for (const { event, handler } of handlers) {
        element.removeEventListener(event, handler);
      }
      this.listeners.delete(element);
    }
  }
}
javascript
// MEMORY LEAK PREVENTION

class ComponentRegistry {
  constructor() {
    this.mounted = new Set();
    this.cleanupFns = new Map();
  }
  
  mount(component, cleanup) {
    this.mounted.add(component);
    if (cleanup) {
      this.cleanupFns.set(component, cleanup);
    }
  }
  
  unmount(component) {
    // 执行清理函数
    const cleanup = this.cleanupFns.get(component);
    if (cleanup) {
      cleanup();
      this.cleanupFns.delete(component);
    }
    
    this.mounted.delete(component);
  }
  
  // 路由变更时调用
  unmountRoute(routeComponents) {
    for (const component of routeComponents) {
      this.unmount(component);
    }
    
    // 提示垃圾回收
    if (global.gc) global.gc();
  }
}

// 事件监听器清理模式
class EventManager {
  constructor() {
    this.listeners = new WeakMap();
  }
  
  addListener(element, event, handler) {
    element.addEventListener(event, handler);
    
    // 跟踪以便清理
    if (!this.listeners.has(element)) {
      this.listeners.set(element, []);
    }
    this.listeners.get(element).push({ event, handler });
  }
  
  removeAllListeners(element) {
    const handlers = this.listeners.get(element);
    if (handlers) {
      for (const { event, handler } of handlers) {
        element.removeEventListener(event, handler);
      }
      this.listeners.delete(element);
    }
  }
}

Building MPA with Partial Hydration

构建带部分水合的MPA

javascript
// PARTIAL HYDRATION IMPLEMENTATION

// 1. Mark interactive components at build time
// <Button client:load>Click me</Button>

// 2. Extract islands during SSR
function extractIslands(html, components) {
  const islands = [];
  
  // Find island markers in HTML
  const regex = /<island-(\w+) props="([^"]+)">/g;
  let match;
  
  while ((match = regex.exec(html)) !== null) {
    islands.push({
      id: match[1],
      props: JSON.parse(decodeURIComponent(match[2])),
      component: components[match[1]],
    });
  }
  
  return islands;
}

// 3. Hydrate only islands on client
function hydrateIslands(islands) {
  for (const island of islands) {
    const element = document.querySelector(`[data-island="${island.id}"]`);
    if (element) {
      // Load component code
      const Component = await import(island.component);
      
      // Hydrate this specific element
      hydrateRoot(element, <Component {...island.props} />);
    }
  }
}

// 4. Island web component wrapper
class IslandElement extends HTMLElement {
  async connectedCallback() {
    // Defer hydration based on strategy
    const strategy = this.getAttribute('client');
    
    switch (strategy) {
      case 'load':
        await this.hydrate();
        break;
      case 'idle':
        requestIdleCallback(() => this.hydrate());
        break;
      case 'visible':
        const observer = new IntersectionObserver(async ([entry]) => {
          if (entry.isIntersecting) {
            observer.disconnect();
            await this.hydrate();
          }
        });
        observer.observe(this);
        break;
    }
  }
  
  async hydrate() {
    const component = this.getAttribute('component');
    const props = JSON.parse(this.getAttribute('props') || '{}');
    
    const Component = await import(`/components/${component}.js`);
    hydrateRoot(this, createElement(Component.default, props));
  }
}

customElements.define('island-component', IslandElement);
javascript
// PARTIAL HYDRATION IMPLEMENTATION

// 1. 构建时标记交互式组件
// <Button client:load>Click me</Button>

// 2. SSR期间提取交互岛
function extractIslands(html, components) {
  const islands = [];
  
  // 在HTML中查找交互岛标记
  const regex = /<island-(\w+) props="([^"]+)">/g;
  let match;
  
  while ((match = regex.exec(html)) !== null) {
    islands.push({
      id: match[1],
      props: JSON.parse(decodeURIComponent(match[2])),
      component: components[match[1]],
    });
  }
  
  return islands;
}

// 3. 客户端仅水合交互岛
function hydrateIslands(islands) {
  for (const island of islands) {
    const element = document.querySelector(`[data-island="${island.id}"]`);
    if (element) {
      // 加载组件代码
      const Component = await import(island.component);
      
      // 水合特定元素
      hydrateRoot(element, <Component {...island.props} />);
    }
  }
}

// 4. 交互岛Web组件包装器
class IslandElement extends HTMLElement {
  async connectedCallback() {
    // 根据策略延迟水合
    const strategy = this.getAttribute('client');
    
    switch (strategy) {
      case 'load':
        await this.hydrate();
        break;
      case 'idle':
        requestIdleCallback(() => this.hydrate());
        break;
      case 'visible':
        const observer = new IntersectionObserver(async ([entry]) => {
          if (entry.isIntersecting) {
            observer.disconnect();
            await this.hydrate();
          }
        });
        observer.observe(this);
        break;
    }
  }
  
  async hydrate() {
    const component = this.getAttribute('component');
    const props = JSON.parse(this.getAttribute('props') || '{}');
    
    const Component = await import(`/components/${component}.js`);
    hydrateRoot(this, createElement(Component.default, props));
  }
}

customElements.define('island-component', IslandElement);

Related Skills

相关技能

  • See rendering-patterns for SSR, SSG, CSR, ISR
  • See seo-fundamentals for SEO strategies
  • See hydration-patterns for hydration concepts
  • See meta-frameworks-overview for framework comparisons
  • 查看 rendering-patterns 了解SSR、SSG、CSR、ISR
  • 查看 seo-fundamentals 了解SEO策略
  • 查看 hydration-patterns 了解水合概念
  • 查看 meta-frameworks-overview 了解框架对比