# 前端框架原理

> 💡 **学习指南**：这篇文章会回答一个根本问题——**前端框架（Vue、React、Svelte 等）到底在做什么？** 如果你只学过 HTML、CSS 和一点 JavaScript，完全没问题，我们从头讲起。

在开始之前，先确认你知道这两个基础概念。如果不确定，可以先看对应章节：

- **HTML**：网页的骨架，定义页面上有哪些元素（标题、段落、按钮、图片……）。参见 [HTML 与 CSS 布局](./html-css-layout.md)。
- **JavaScript**：让网页"动起来"的编程语言，可以修改页面内容、响应用户操作。参见 [JavaScript 原理](./javascript-deep-dive.md)。

还有一个概念会在后面频繁出现，这里先做一个完整的说明。

### DOM概述

DOM 的全称是 Document Object Model，中文叫"文档对象模型"。

当你在浏览器中打开一个网页时，浏览器做的第一件事就是读取 HTML 代码。读完之后，浏览器不会直接拿 HTML 文本去显示页面，而是先把 HTML 代码**转换成一棵树形结构**，存放在内存里。这棵树就叫 DOM 树。

树上的每一个节点（Node）对应 HTML 里的一个标签。标签之间的嵌套关系，在 DOM 树里就变成了父节点和子节点的关系。

👇 **动手试试看**：
把鼠标移到左边的 HTML 代码上，右边 DOM 树中对应的节点会高亮。反过来也一样。每一行 HTML 标签都对应 DOM 树上的一个节点。

<WhatIsDomDemo />

**为什么要了解 DOM？** 因为 JavaScript 修改页面的方式，就是操作这棵 DOM 树——增加节点、删除节点、修改节点的内容。而前端框架做的核心工作，就是帮你自动化这些 DOM 操作。后面我们会反复提到 DOM，理解它是理解框架原理的基础。

---

## 0. 引言：前端框架定义

先解释"框架"这个词。在编程中，**框架（Framework）** 是一套已经写好的代码和规则，它规定了你的代码应该怎么组织、怎么运行。你按照它的方式写代码，它帮你处理大量重复、繁琐的底层工作。

**前端框架**，就是专门帮你**构建网页界面**的框架。目前最常见的有 Vue、React、Svelte、Angular 这几个。

那它们到底帮你解决了什么问题？下面这三张卡片概括了核心逻辑：

<FrameworkMotivationDemo />

接下来我们一步步展开，从最基础的问题讲起。

---

## 1. 核心问题：数据变了，界面的处理流程

### 1.1 先搞清楚"数据"和"界面"是什么

在任何一个网页应用中，都有两个东西在同时存在：

- **数据（Data / State）**：程序内部存储的信息。比如"购物车里有 3 件商品"、"用户名是张三"、"当前选中了第 2 个标签页"。这些数据存在 JavaScript 的变量里，用户看不到它们。
- **界面（UI）**：用户在屏幕上看到的东西。比如页面上显示"购物车(3)"、显示"欢迎，张三"、第 2 个标签页高亮。这些是 HTML 元素呈现出来的视觉效果。

**数据和界面之间有对应关系**：数据是"3 件商品"，界面上就应该显示"3"。如果数据变成了"4 件商品"，界面上也应该跟着变成"4"。

问题是：**这个"跟着变"的过程，谁来负责？**

👇 **动手点点看**：
点击"添加商品"按钮，注意观察：数据（左边）已经变了，但界面（右边）没有跟着更新——它们之间"断开"了。再点"同步界面"手动修复。

<DataUIGapDemo />

### 1.2 JavaScript 变量变了，界面不会自动变的原理

这是零基础最容易困惑的地方，我们把底层原理一步步讲清楚。

在 JavaScript 中，变量就是一块内存空间，用来存放数据。当你执行 `count = count + 1` 时，JavaScript 引擎做的事情非常简单：把内存中 count 这个位置的值从 3 改成 4。**做完这一步就结束了，不会再发生任何事。**

而页面上显示的内容（比如 `<span>3</span>` 这个 DOM 节点）存放在另一块完全不同的内存空间里。JavaScript 引擎在修改变量时，根本不知道页面上有一个 DOM 节点正在显示这个变量的值，也没有任何机制让它去检查。

所以本质原因是：**JavaScript 的变量和 DOM 节点是两块独立的内存，它们之间没有任何自动联动机制。** 修改变量只改变了变量所在的内存，DOM 节点所在的内存不会受到任何影响。

```javascript
let count = 3

// 页面上有一个 DOM 节点显示着 count 的值：
// <span id="counter">3</span>

count = 4
// JavaScript 引擎做了什么？
//   → 把变量 count 在内存中的值从 3 改成 4
//   → 结束。没了。
// 页面上 <span> 里显示的仍然是 "3"
```

如果你想让页面上的显示也变成"4"，你必须**额外写代码**，手动找到那个 DOM 节点，然后修改它的内容：

```javascript
count = 4  // 第 1 步：改变量

// 第 2 步：你必须自己写——找到 DOM 节点，把它的文字改成新值
document.getElementById('counter').textContent = count
```

如果页面上有 5 个地方显示着 count 的值（购物车数量、商品列表、总价、小计、状态提示），你就需要写 5 段这样的代码。**漏掉任何一段，那个位置显示的就还是旧值，用户看到的就是错误信息。**

### 1.3 框架做了什么：两步建立自动连接

框架能自动同步，靠的是**两步配合**——缺一不可。

**第一步：你在模板里"登记"哪些地方要显示这个变量**

框架的 HTML 模板里，你用 `{{ count }}` 这样的语法来标记"这里要显示 count 的值"：

```html
<!-- Vue 模板 -->
<span>购物车：{{ count }} 件</span>    <!-- 位置 A：我要显示 count -->
<span>总价：¥{{ count * 99 }}</span>   <!-- 位置 B：我也用了 count -->
<span>{{ count > 5 ? '过多' : '正常' }}</span>  <!-- 位置 C：我也用了 count -->
```

框架第一次渲染页面时，会把这个"登记关系"记录下来：**位置 A、B、C 都依赖 count**。

**第二步：框架监视变量，变了就查登记表、自动更新**

框架用 JavaScript 内置的 `Proxy`（代理）把你的变量"包裹"起来，让它变成一个"被监视的变量"。当你修改这个变量时，Proxy 会在赋值的同时悄悄多做一件事：通知框架"count 变了"。框架收到通知后，去查第一步的登记表，把 A、B、C 三个位置全部更新。

```
原生 JS：
  你写 HTML → <span id="counter">3</span>（和变量无任何连接）
  你改变量 → count = 4 → 结束，界面毫无反应
  你手动补 → document.getElementById('counter').textContent = 4 → 界面才更新

Vue 框架：
  你写模板 → <span>{{ count }}</span>（框架记住：这里依赖 count）
  你改变量 → count = 4 → Proxy 拦截 → 通知框架 → 框架查登记表 → 自动更新 A/B/C
```

这就是为什么"只有框架才能自动同步"——原生 HTML 里的 `<span>` 和 JS 变量之间根本没有任何连接，框架的模板语法（`{{ }}`）才是建立这条连接的关键。你写了 `{{ count }}`，框架才知道这里要显示 count；框架才能在 count 变化时，精准找到这里并更新它。

👇 **动手点点看**：
先选"原生 JavaScript"，点"执行"后注意观察——变量改了但界面纹丝不动，你要一步步手动同步每个位置。再切换到"使用框架"，同样点"执行"——变量一改，框架自动完成所有步骤，界面立刻跟上。

<WhyNoAutoSyncDemo />

### 1.4 对比：手动同步 vs 自动同步的实际效果

理解了原理之后，我们来看看在一个稍微复杂一点的场景下，手动同步和自动同步的区别有多大。

👇 **动手点点看**：
左边是没有框架时的"手动同步"方式——每个显示区域你都需要单独点"同步"按钮来更新。右边是有框架时的"自动同步"方式——你只管点"添加商品"，所有显示区域自动更新。试试在左边故意不同步某个区域，看看会发生什么。

<ManualVsAutoSyncDemo />

**这就是前端框架存在的根本原因：给 JavaScript 变量加上"被修改时自动通知界面更新"的能力，消灭手动同步带来的错误。**

---

## 2. 框架的核心思想：用数据描述界面

### 2.1 两种写法的区别

理解了"自动同步"的价值之后，我们来看框架具体是怎么实现的。

在没有框架的时代（比如使用 jQuery），代码是这样写的——你一步一步告诉浏览器该做什么：

```javascript
// 第 1 步：找到页面上 id 为 counter 的元素
var element = document.getElementById('counter')
// 第 2 步：把这个元素的文字内容改成新的值
element.textContent = '4'
// 第 3 步：找到另一个元素，也改掉
document.getElementById('total').textContent = '¥396'
// 第 4 步：如果数量大于 5，还要改状态提示……
```

这种写法叫**命令式（Imperative）**——你在"命令"浏览器一步步执行操作。

有了框架之后，代码变成这样——你只描述"界面应该长什么样"：

```html
<!-- 我不管这个值怎么更新到页面上的 -->
<!-- 我只说：这里应该显示 count 的值 -->
<span>{{ count }}</span>
<span>总价：¥{{ count * 99 }}</span>
<span v-if="count > 5">商品过多！</span>
```

这种写法叫**声明式（Declarative）**——你在"声明"界面的最终状态，至于怎么达到这个状态，框架自己处理。

### 2.2 核心公式：UI = f(State)

所有现代前端框架——不管是 Vue、React 还是 Svelte——都遵循同一个核心思想，可以用一个公式来表达：

> **UI = f(State)**

这个公式的意思是：

- **State（状态）**：你的应用数据。就是 JavaScript 里的那些变量：购物车里有几件商品、用户有没有登录、当前页面是哪个……
- **f（函数）**：框架的渲染机制。它知道怎么把数据变成界面。
- **UI（界面）**：用户在屏幕上看到的最终结果。

**含义**：给定一组数据（State），经过框架的处理（f），就能确定性地得到对应的界面（UI）。数据变了，界面就跟着变。开发者只需要关心数据，不需要关心界面怎么更新。

👇 **动手点点看**：
在左边修改数据（State），观察右边的界面（UI）如何自动跟着变化。这就是 `UI = f(State)` 的直观体现。

<DeclarativeFormulaDemo />

### 2.3 声明式比命令式好的原理

声明式写法的优势在于：

| 对比维度 | 命令式（没有框架） | 声明式（有框架） |
| :--- | :--- | :--- |
| **代码量** | 每个更新都要写具体操作代码 | 只写一次模板，框架自动处理 |
| **出错概率** | 容易漏更新某个地方 | 框架保证所有地方都更新 |
| **可读性** | 代码里混杂着大量 DOM 操作 | 代码清晰地描述界面结构 |
| **维护成本** | 修改一个功能要改很多地方 | 修改数据逻辑即可，界面自动跟随 |

简单说：声明式让你把精力集中在"业务逻辑"（数据怎么变化）上，不用操心"界面怎么更新"这个重复且容易出错的事情。

---

## 3. 响应式系统：数据变更检测机制

### 3.1 响应式概述

前面说了"数据变了，界面自动更新"。但这里有一个技术问题：**JavaScript 本身并没有"变量被修改时自动通知别人"的能力**。

你写 `count = 4`，JavaScript 只是把 `count` 的值从 3 改成 4，不会自动告诉任何人。框架需要一种机制来"发现"你修改了数据。

**响应式（Reactivity）** 就是这种机制的总称：当数据发生变化时，系统能自动感知到变化，并执行相应的更新操作。

### 3.2 三种不同的实现方式

不同的框架采用了不同的技术方案来实现响应式。这也是 Vue、React、Svelte 之间最根本的区别。

**方式一：代理拦截（Vue 的做法）**

Vue 使用 JavaScript 内置的 `Proxy`（代理）机制。`Proxy` 可以在你读取或修改一个对象的属性时，自动执行一段你指定的代码。

Vue 把你的数据对象用 `Proxy` 包裹起来。当你执行 `count = 4` 时，`Proxy` 会拦截这次写入操作，通知 Vue："count 的值变了"，然后 Vue 去更新所有用到 `count` 的界面部分。

你作为开发者不需要做任何额外的事情——直接赋值就行，Vue 自动感知。

**方式二：显式调用（React 的做法）**

React 不使用 `Proxy`。它要求你必须通过一个专门的函数来修改数据：

```javascript
// React 的写法
const [count, setCount] = useState(0)

// 不能直接写 count = 4（React 不会感知到）
// 必须调用 setCount：
setCount(4)
```

只有当你调用 `setCount()` 时，React 才知道数据变了，才会去更新界面。如果你直接写 `count = 4`，React 完全不知道，界面不会更新。

这种方式更"显式"——每一次数据变化都是你主动告诉框架的，不会有意外的更新。

**方式三：编译器分析（Svelte 的做法）**

Svelte 采用了完全不同的路线。它有一个编译器（Compiler），在你的代码运行之前，编译器会先分析你的源代码。

当编译器看到你写了 `count += 1` 这样的赋值语句时，它会自动在这行代码后面插入一段"通知界面更新"的代码。也就是说，在代码运行的时候，"通知"这个动作已经被编译器提前安排好了。

你的代码看起来就是普通的 JavaScript 赋值，但编译后的代码里多了更新界面的逻辑。

👇 **动手点点看**：
选择不同的框架标签，点击"修改数据"，观察每种框架在"引擎盖下"经历了哪些步骤来完成数据变化的检测和界面更新。

<ReactivityMechanismDemo />

### 3.3 三种方式的对比

| 对比维度 | Vue（Proxy 代理） | React（显式调用） | Svelte（编译器） |
| :--- | :--- | :--- | :--- |
| **开发者写法** | 直接赋值 `count = 4` | 必须用 `setCount(4)` | 直接赋值 `count = 4` |
| **感知变化的时机** | 运行时自动拦截 | 开发者主动通知 | 编译时提前插入通知代码 |
| **运行时性能开销** | Proxy 有少量拦截开销 | setState 调度有少量开销 | 几乎没有额外开销 |
| **调试难度** | 中等 | 数据流清晰，较容易 | 需要理解编译后的代码 |
| **适合场景** | 追求开发效率和自然写法 | 追求可预测的数据流 | 追求极致运行性能 |

三种方式没有绝对的好坏。Vue 写起来最自然，React 的数据流最可控，Svelte 的运行性能最好。选择哪个取决于项目的具体需求。

---

## 4. 组件：把界面拆成可复用的小块

### 4.1 拆的动机

一个完整的网页可能有导航栏、侧边栏、内容区、搜索框、用户头像、各种按钮……如果所有代码写在一个文件里，这个文件会变得非常长、非常难维护。

**组件（Component）** 就是把界面拆分成一个个独立的小块，每个小块管自己的数据、自己的界面、自己的逻辑。

比如一个电商页面可以拆成这些组件：

- `NavBar` 组件：负责顶部导航栏
- `SearchBox` 组件：负责搜索框
- `ProductCard` 组件：负责一张商品卡片
- `ShoppingCart` 组件：负责购物车

每个组件都是独立的。`ProductCard` 不需要知道 `NavBar` 里写了什么代码，它只需要管好自己。

### 4.2 组件的三个好处

**好处一：复用。** 一个 `ProductCard` 组件写好之后，可以在页面上用 100 次——每次传入不同的商品数据，就会渲染出不同的商品卡片。不需要复制粘贴 100 份 HTML 代码。

**好处二：封装。** 组件内部的数据和逻辑是独立的。修改 `SearchBox` 组件的代码，不会影响到 `ProductCard` 组件。多人协作时，不同的人可以同时开发不同的组件，互不干扰。

**好处三：可维护。** 当某个功能出了问题，你可以直接定位到对应的组件去修复，不需要在一个几千行的大文件里翻找。

👇 **动手点点看**：
点击左边的组件名称，查看它在页面上对应的区域。注意观察：同一个 `ProductCard` 组件被复用了多次，每次显示不同的数据。

<ComponentTreeDemo />

### 4.3 组件在代码里长什么样

以 Vue 为例，一个组件就是一个 `.vue` 文件，里面包含三部分：

```html
<!-- ProductCard.vue -->
<template>
  <!-- 这里写 HTML 结构 —— 组件的"外观" -->
  <div class="card">
    <h3>{{ name }}</h3>
    <p>价格：¥{{ price }}</p>
    <button @click="addToCart">加入购物车</button>
  </div>
</template>

<script setup>
// 这里写 JavaScript 逻辑 —— 组件的"行为"
const props = defineProps(['name', 'price'])

function addToCart() {
  // 处理"加入购物车"的逻辑
}
</script>

<style scoped>
/* 这里写 CSS 样式 —— 组件的"样式" */
.card {
  border: 1px solid #ccc;
  padding: 16px;
}
</style>
```

使用这个组件时，就像使用一个自定义的 HTML 标签：

```html
<!-- 在其他地方使用 ProductCard 组件 -->
<ProductCard name="无线耳机" price="299" />
<ProductCard name="机械键盘" price="599" />
<ProductCard name="显示器" price="1999" />
```

三行代码就渲染出了三张不同的商品卡片。

---

## 5. 框架要费这么大力气的原理

### 5.1 DOM 操作概述

前面提到过 DOM——浏览器把 HTML 解析后生成的树形结构。**DOM 操作**就是用 JavaScript 去修改这棵树上的节点。比如改一段文字、增加一个元素、删除一个元素、修改一个样式。

这些操作本身不复杂，但是浏览器在执行 DOM 操作之后，需要做很多额外的工作才能让屏幕上的显示更新：

1. **重新计算样式**：这个节点以及它的子节点的 CSS 样式是否需要变化？
2. **重新布局（Layout / Reflow）**：页面上所有元素的位置和大小需要重新计算。因为一个元素的改变可能影响到其他元素的位置。
3. **重新绘制（Paint）**：把计算好的内容画到屏幕上。

这三个步骤每一个都有计算成本。如果你的代码频繁触发 DOM 操作，浏览器就会反复执行这些步骤，页面就会变卡。

👇 **动手点点看**：
观察直接操作 DOM 和批量操作 DOM 的耗时对比。当修改次数增多时，"逐个操作"的耗时会急剧上升。

<DomOperationCostDemo />

### 5.2 框架的问题解决方式

既然直接操作 DOM 很昂贵，框架就想办法**减少 DOM 操作的次数**。具体有两种策略：

**策略一：虚拟 DOM + 差异比较（Vue、React 的做法）**

虚拟 DOM（Virtual DOM）是一个 JavaScript 对象，它的结构和真实 DOM 树一一对应，但它只存在于内存中，不会触发浏览器的布局和绘制。

当数据变化时，框架的处理流程是：

1. 用 JavaScript 对象创建一棵"新的虚拟 DOM 树"，描述数据变化后界面应该长什么样
2. 把这棵新树和旧树做对比（这个过程叫 **Diff**，即差异比较），找出哪些节点发生了变化
3. 只把真正变化的部分应用到真实 DOM 上（这个过程叫 **Patch**，即打补丁）

这样一来，不管数据怎么变化，最终对真实 DOM 的操作总是最少的。

👇 **动手点点看**：
点击"修改数据"，观察虚拟 DOM 如何对比新旧两棵树，找出变化的节点。注意看最右边的"真实 DOM"——只有真正变化的部分才会闪烁。

<VirtualDomDiffDemo />

**策略二：编译时精确定位（Svelte 的做法）**

Svelte 不使用虚拟 DOM。它的编译器在你写代码时就分析好了："当 `count` 变化时，需要更新第 3 行的 `<span>` 元素"。运行时直接定位到那个元素去更新，完全不需要对比新旧树。

这种做法跳过了 Diff 步骤，理论上性能更好。但它依赖编译器的分析能力——编译器需要足够聪明才能正确识别出所有需要更新的地方。

---

## 6. 运行时 vs 编译时：框架设计的核心权衡

### 6.1 两个阶段

前端代码从你写下到最终在浏览器里运行，会经过两个阶段：

- **编译时（Compile-time / Build-time）**：你的源代码被构建工具（如 Vite、Webpack）处理，转换成浏览器能直接执行的代码。这个过程发生在你的电脑上，在用户打开网页之前。
- **运行时（Runtime）**：转换后的代码在用户的浏览器中执行。框架的核心逻辑（比如虚拟 DOM 的 Diff、响应式的追踪）就在这个阶段工作。

### 6.2 框架在这两个阶段的工作分配

不同框架在这两个阶段分配的工作量不同，这决定了它们的性能特征和包体积：

- **React**：大部分工作在运行时完成。虚拟 DOM 的创建、Diff、Patch 都发生在浏览器中。好处是灵活性高；代价是需要把整个框架的运行时代码（约 40KB）发送给浏览器。
- **Vue**：混合方式。模板在编译时被优化（编译器标记出哪些节点是静态的、不会变化的），但最终的界面更新仍然通过运行时的虚拟 DOM 完成。运行时代码约 30KB。
- **Svelte**：大部分工作在编译时完成。编译器分析你的代码，直接生成精确的 DOM 更新指令。运行时几乎没有框架代码——最终打包出来只有你自己的业务代码。包体积最小。

👇 **动手点点看**：
点击不同的框架标签，查看它们在"运行时 ↔ 编译时"光谱上的位置，以及各自在打包体积、运行性能、开发体验上的权衡。

<FrameworkSpectrumDemo />

### 6.3 行业趋势

近几年框架的发展方向很明确：**把越来越多的工作从运行时移到编译时**。因为编译时的计算不占用用户的设备资源，不影响页面加载速度。

- **Vue** 正在开发 Vapor Mode（蒸汽模式），可以跳过虚拟 DOM，在编译时直接生成 DOM 操作代码
- **React** 推出了 React Compiler，在编译时自动优化组件的重渲染行为
- **Svelte 5** 引入了 Runes 系统，进一步增强编译时的分析能力

---

## 7. 总结

回顾这篇文章的核心要点：

**前端框架解决的根本问题**：当应用中的数据发生变化时，自动、高效、可靠地更新界面，不需要开发者手动操作 DOM。

**它们共同遵循的核心思想**：UI = f(State)——界面是数据的函数，开发者只需关注数据的变化，框架负责把数据的变化反映到界面上。

**它们的关键技术差异**：

| 技术点 | 含义 |
| :--- | :--- |
| **响应式系统** | 框架如何检测数据变化。Vue 用 Proxy 拦截、React 用显式 setState、Svelte 用编译器分析。 |
| **虚拟 DOM** | Vue 和 React 用一个 JavaScript 对象来模拟 DOM 树，通过对比新旧两棵树（Diff）来找出最小更新量，减少真实 DOM 操作。 |
| **组件化** | 把界面拆成独立的、可复用的小块，每个组件管理自己的数据和界面。 |
| **编译时优化** | 在代码构建阶段提前做分析和优化，减少运行时的计算量。Svelte 在这方面走得最远。 |

**一句话**：前端框架的本质工作就是——接管"数据到界面"的同步过程，让开发者只需要思考数据逻辑，不再需要手动操作界面。

---

## 名词对照表

| 英文术语 | 中文对照 | 解释 |
| :--- | :--- | :--- |
| **Framework** | 框架 | 一套预先编写好的代码和规则，为开发者提供应用的基础结构和常用功能。 |
| **DOM** | 文档对象模型 | 浏览器把 HTML 解析后生成的树形数据结构，JavaScript 通过操作它来修改页面。 |
| **Virtual DOM** | 虚拟 DOM | 用 JavaScript 对象模拟 DOM 树，通过 Diff 算法找出最小更新路径，减少真实 DOM 操作次数。 |
| **State** | 状态 | 应用中的数据，比如用户信息、购物车内容、页面当前状态等。 |
| **Reactivity** | 响应式 | 当数据变化时，系统能自动感知并执行对应的界面更新操作。 |
| **Proxy** | 代理 | JavaScript 内置机制，可以拦截对一个对象的读取和写入操作。Vue 3 用它来实现响应式。 |
| **Component** | 组件 | 一段独立的、可复用的界面代码，包含自己的 HTML 结构、JavaScript 逻辑和 CSS 样式。 |
| **Declarative** | 声明式 | 一种编程方式：你描述"最终想要什么结果"，由框架来决定怎么实现。 |
| **Imperative** | 命令式 | 一种编程方式：你一步一步告诉程序"具体怎么做"。 |
| **Diff** | 差异比较 | 对比新旧两棵虚拟 DOM 树，找出哪些节点发生了变化。 |
| **Patch** | 打补丁 | 把 Diff 找到的变化部分，应用到真实 DOM 上。 |
| **Compile-time** | 编译时 | 代码在构建阶段被处理的时期，发生在用户打开网页之前。 |
| **Runtime** | 运行时 | 代码在用户浏览器中执行的时期。 |
| **Compiler** | 编译器 | 一个程序，把源代码转换成另一种形式的代码。Svelte 的编译器把 `.svelte` 文件转换成高效的 JavaScript。 |
