前端框架的本质
💡 学习指南:这篇文章会回答一个根本问题——前端框架(Vue、React、Svelte 等)到底在做什么? 如果你只学过 HTML、CSS 和一点 JavaScript,完全没问题,我们从头讲起。
在开始之前,先确认你知道这两个基础概念。如果不确定,可以先看对应章节:
- HTML:网页的骨架,定义页面上有哪些元素(标题、段落、按钮、图片……)。参见 HTML 与 CSS 布局。
- JavaScript:让网页"动起来"的编程语言,可以修改页面内容、响应用户操作。参见 JavaScript 深度指南。
还有一个概念会在后面频繁出现,这里先做一个完整的说明。
什么是 DOM?
DOM 的全称是 Document Object Model,中文叫"文档对象模型"。
当你在浏览器中打开一个网页时,浏览器做的第一件事就是读取 HTML 代码。读完之后,浏览器不会直接拿 HTML 文本去显示页面,而是先把 HTML 代码转换成一棵树形结构,存放在内存里。这棵树就叫 DOM 树。
树上的每一个节点(Node)对应 HTML 里的一个标签。标签之间的嵌套关系,在 DOM 树里就变成了父节点和子节点的关系。
👇 动手试试看: 把鼠标移到左边的 HTML 代码上,右边 DOM 树中对应的节点会高亮。反过来也一样。每一行 HTML 标签都对应 DOM 树上的一个节点。
<h1>、<p>)都对应一个节点。<body> 里包含 <h1>,所以 body 是 h1 的父节点。为什么要了解 DOM? 因为 JavaScript 修改页面的方式,就是操作这棵 DOM 树——增加节点、删除节点、修改节点的内容。而前端框架做的核心工作,就是帮你自动化这些 DOM 操作。后面我们会反复提到 DOM,理解它是理解框架原理的基础。
0. 引言:什么是"前端框架"?
先解释"框架"这个词。在编程中,框架(Framework) 是一套已经写好的代码和规则,它规定了你的代码应该怎么组织、怎么运行。你按照它的方式写代码,它帮你处理大量重复、繁琐的底层工作。
前端框架,就是专门帮你构建网页界面的框架。目前最常见的有 Vue、React、Svelte、Angular 这几个。
那它们到底帮你解决了什么问题?下面这三张卡片概括了核心逻辑:
- 数据变化时,手动更新 DOM 容易遗漏
- 页面越复杂,需要同步的地方越多,越容易出 bug
- 多人协作时,DOM 操作散落各处,维护成本高
- 浏览器不知道"数据"和"界面"的对应关系
- 原生 DOM API 只提供底层操作,没有"数据变了就更新 UI"的能力
- 开发者被迫充当"人肉同步器"
- 建立数据到 UI 的映射关系(UI = f(State))
- 自动检测数据变化(响应式系统)
- 自动计算最小 DOM 更新(虚拟 DOM / 编译优化)
接下来我们一步步展开,从最基础的问题讲起。
1. 核心问题:数据变了,界面怎么办?
1.1 先搞清楚"数据"和"界面"是什么
在任何一个网页应用中,都有两个东西在同时存在:
- 数据(Data / State):程序内部存储的信息。比如"购物车里有 3 件商品"、"用户名是张三"、"当前选中了第 2 个标签页"。这些数据存在 JavaScript 的变量里,用户看不到它们。
- 界面(UI):用户在屏幕上看到的东西。比如页面上显示"购物车(3)"、显示"欢迎,张三"、第 2 个标签页高亮。这些是 HTML 元素呈现出来的视觉效果。
数据和界面之间有对应关系:数据是"3 件商品",界面上就应该显示"3"。如果数据变成了"4 件商品",界面上也应该跟着变成"4"。
问题是:这个"跟着变"的过程,谁来负责?
👇 动手点点看: 点击"添加商品"按钮,注意观察:数据(左边)已经变了,但界面(右边)没有跟着更新——它们之间"断开"了。再点"同步界面"手动修复。
1.2 为什么 JavaScript 变量变了,界面不会自动变?
这是零基础最容易困惑的地方,我们把底层原理一步步讲清楚。
在 JavaScript 中,变量就是一块内存空间,用来存放数据。当你执行 count = count + 1 时,JavaScript 引擎做的事情非常简单:把内存中 count 这个位置的值从 3 改成 4。做完这一步就结束了,不会再发生任何事。
而页面上显示的内容(比如 <span>3</span> 这个 DOM 节点)存放在另一块完全不同的内存空间里。JavaScript 引擎在修改变量时,根本不知道页面上有一个 DOM 节点正在显示这个变量的值,也没有任何机制让它去检查。
所以本质原因是:JavaScript 的变量和 DOM 节点是两块独立的内存,它们之间没有任何自动联动机制。 修改变量只改变了变量所在的内存,DOM 节点所在的内存不会受到任何影响。
let count = 3
// 页面上有一个 DOM 节点显示着 count 的值:
// <span id="counter">3</span>
count = 4
// JavaScript 引擎做了什么?
// → 把变量 count 在内存中的值从 3 改成 4
// → 结束。没了。
// 页面上 <span> 里显示的仍然是 "3"如果你想让页面上的显示也变成"4",你必须额外写代码,手动找到那个 DOM 节点,然后修改它的内容:
count = 4 // 第 1 步:改变量
// 第 2 步:你必须自己写——找到 DOM 节点,把它的文字改成新值
document.getElementById('counter').textContent = count如果页面上有 5 个地方显示着 count 的值(购物车数量、商品列表、总价、小计、状态提示),你就需要写 5 段这样的代码。漏掉任何一段,那个位置显示的就还是旧值,用户看到的就是错误信息。
1.3 框架做了什么?两步建立自动连接
框架能自动同步,靠的是两步配合——缺一不可。
第一步:你在模板里"登记"哪些地方要显示这个变量
框架的 HTML 模板里,你用 这样的语法来标记"这里要显示 count 的值":
<!-- 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 变化时,精准找到这里并更新它。
👇 动手点点看: 先选"原生 JavaScript",点"执行"后注意观察——变量改了但界面纹丝不动,你要一步步手动同步每个位置。再切换到"使用框架",同样点"执行"——变量一改,框架自动完成所有步骤,界面立刻跟上。
count = 4 时,JavaScript 引擎只是把内存中 count 的值从 3 改成 4,仅此而已。它不会通知任何人,不会触发任何回调,不会去检查页面上哪里显示了 count。所以界面不会有任何变化——除非你自己写代码去更新 DOM。1.4 对比:手动同步 vs 自动同步的实际效果
理解了原理之后,我们来看看在一个稍微复杂一点的场景下,手动同步和自动同步的区别有多大。
👇 动手点点看: 左边是没有框架时的"手动同步"方式——每个显示区域你都需要单独点"同步"按钮来更新。右边是有框架时的"自动同步"方式——你只管点"添加商品",所有显示区域自动更新。试试在左边故意不同步某个区域,看看会发生什么。
这就是前端框架存在的根本原因:给 JavaScript 变量加上"被修改时自动通知界面更新"的能力,消灭手动同步带来的错误。
2. 框架的核心思想:用数据描述界面
2.1 两种写法的区别
理解了"自动同步"的价值之后,我们来看框架具体是怎么实现的。
在没有框架的时代(比如使用 jQuery),代码是这样写的——你一步一步告诉浏览器该做什么:
// 第 1 步:找到页面上 id 为 counter 的元素
var element = document.getElementById('counter')
// 第 2 步:把这个元素的文字内容改成新的值
element.textContent = '4'
// 第 3 步:找到另一个元素,也改掉
document.getElementById('total').textContent = '¥396'
// 第 4 步:如果数量大于 5,还要改状态提示……这种写法叫命令式(Imperative)——你在"命令"浏览器一步步执行操作。
有了框架之后,代码变成这样——你只描述"界面应该长什么样":
<!-- 我不管这个值怎么更新到页面上的 -->
<!-- 我只说:这里应该显示 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) 的直观体现。
{
"username": "(空)",
"count": 2,
"darkMode": false
}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。它要求你必须通过一个专门的函数来修改数据:
// React 的写法
const [count, setCount] = useState(0)
// 不能直接写 count = 4(React 不会感知到)
// 必须调用 setCount:
setCount(4)只有当你调用 setCount() 时,React 才知道数据变了,才会去更新界面。如果你直接写 count = 4,React 完全不知道,界面不会更新。
这种方式更"显式"——每一次数据变化都是你主动告诉框架的,不会有意外的更新。
方式三:编译器分析(Svelte 的做法)
Svelte 采用了完全不同的路线。它有一个编译器(Compiler),在你的代码运行之前,编译器会先分析你的源代码。
当编译器看到你写了 count += 1 这样的赋值语句时,它会自动在这行代码后面插入一段"通知界面更新"的代码。也就是说,在代码运行的时候,"通知"这个动作已经被编译器提前安排好了。
你的代码看起来就是普通的 JavaScript 赋值,但编译后的代码里多了更新界面的逻辑。
👇 动手点点看: 选择不同的框架标签,点击"修改数据",观察每种框架在"引擎盖下"经历了哪些步骤来完成数据变化的检测和界面更新。
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 组件被复用了多次,每次显示不同的数据。
4.3 组件在代码里长什么样?
以 Vue 为例,一个组件就是一个 .vue 文件,里面包含三部分:
<!-- 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 标签:
<!-- 在其他地方使用 ProductCard 组件 -->
<ProductCard name="无线耳机" price="299" />
<ProductCard name="机械键盘" price="599" />
<ProductCard name="显示器" price="1999" />三行代码就渲染出了三张不同的商品卡片。
5. DOM 操作的代价:为什么框架要费这么大力气?
5.1 什么是 DOM 操作?
前面提到过 DOM——浏览器把 HTML 解析后生成的树形结构。DOM 操作就是用 JavaScript 去修改这棵树上的节点。比如改一段文字、增加一个元素、删除一个元素、修改一个样式。
这些操作本身不复杂,但是浏览器在执行 DOM 操作之后,需要做很多额外的工作才能让屏幕上的显示更新:
- 重新计算样式:这个节点以及它的子节点的 CSS 样式是否需要变化?
- 重新布局(Layout / Reflow):页面上所有元素的位置和大小需要重新计算。因为一个元素的改变可能影响到其他元素的位置。
- 重新绘制(Paint):把计算好的内容画到屏幕上。
这三个步骤每一个都有计算成本。如果你的代码频繁触发 DOM 操作,浏览器就会反复执行这些步骤,页面就会变卡。
👇 动手点点看: 观察直接操作 DOM 和批量操作 DOM 的耗时对比。当修改次数增多时,"逐个操作"的耗时会急剧上升。
5.2 框架怎么解决这个问题?
既然直接操作 DOM 很昂贵,框架就想办法减少 DOM 操作的次数。具体有两种策略:
策略一:虚拟 DOM + 差异比较(Vue、React 的做法)
虚拟 DOM(Virtual DOM)是一个 JavaScript 对象,它的结构和真实 DOM 树一一对应,但它只存在于内存中,不会触发浏览器的布局和绘制。
当数据变化时,框架的处理流程是:
- 用 JavaScript 对象创建一棵"新的虚拟 DOM 树",描述数据变化后界面应该长什么样
- 把这棵新树和旧树做对比(这个过程叫 Diff,即差异比较),找出哪些节点发生了变化
- 只把真正变化的部分应用到真实 DOM 上(这个过程叫 Patch,即打补丁)
这样一来,不管数据怎么变化,最终对真实 DOM 的操作总是最少的。
👇 动手点点看: 点击"修改数据",观察虚拟 DOM 如何对比新旧两棵树,找出变化的节点。注意看最右边的"真实 DOM"——只有真正变化的部分才会闪烁。
- 学习 Vue
- 写作业
- 打游戏
策略二:编译时精确定位(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 更新指令。运行时几乎没有框架代码——最终打包出来只有你自己的业务代码。包体积最小。
👇 动手点点看: 点击不同的框架标签,查看它们在"运行时 ↔ 编译时"光谱上的位置,以及各自在打包体积、运行性能、开发体验上的权衡。
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。 |
