从原始 DOM 操作到 Vue 响应式驱动:一次彻底的进化史实操讲解
大家好,今天带大家从「最原始的后端套模板」一步步走到「现代 Vue3 响应式驱动界面」的全过程,用真实代码带你看清每一次架构升级背后的底层逻辑和痛点。 我们会用 5 个真实可运行的阶段代码,带你穿越前端 15 年的进化史。
第 1 阶段:远古时代:纯后端套模板(Node + 原生 HTTP)
// 2010 年的典型写法
const http = require("http");
const url = require("url");
const users = [/* 假数据 */];
function generateUserHTML(users) {
const userRows = users.map(user => `
<tr>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
</tr>
`).join('');
return `...${userRows}...`;
}
http.createServer((req, res) => {
if (req.url === '/users') {
res.setHeader('Content-type', 'text/html;charset=utf-8');
res.end(generateUserHTML(users));
}
}).listen(1314);
这是十多年前最常见的做法,典型 MVC(其实是 MTV)。
优点:简单、直观、一文件搞定
致命缺点:
- 前端一点交互都做不了(加个删除按钮?重新刷新整页)
- 后端工程师要写 HTML、CSS,前端工程师失业
- SEO 友好,但用户体验极差
我们把它称为「页面 = 数据 + 模板字符串硬拼接」
第 2 阶段2014 年:前后端分离 + 原生 fetch + DOM 编程
<tbody id="tbody"></tbody>
fetch('http://localhost:3000/users')
.then(res => res.json())
.then(data => {
const tbody = document.querySelector('tbody');
tbody.innerHTML = data.map(user => `
<tr>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
</tr>
`).join('');
})
后端只提供 JSON 接口(json-server 或 Express):
// db.json
{
"users": [
{
"id": 1,
"name": "小明",
"email": "123@qq.com"
},...]
}
这时候前后端终于解耦了!
前端终于可以专注做交互了,但代价是:
- 每次数据变了都要手动操作 DOM
- 找节点、拼接 HTML 字符串、innerHTML 赋值… 这些都不是业务代码
- 代码里一大堆 document.querySelector、createElement
我们把它称为「页面 = 静态 HTML + 手动 DOM 编程」
第 3 阶段2020 年后:Vue3 响应式 + 数据驱动模板(我们今天的主角)
import { ref, onMounted } from 'vue'
const users = ref([]) // ← 关键!响应式数据
onMounted(() => {
fetch('http://localhost:3000/users')
.then(r => r.json())
.then(data => {
users.value = data // ← 赋值给 .value 才会触发更新
})
})
<table>
<tr>
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
</tr>
</table>
只改了这几行代码,页面就从「手动 DOM 操作」进化成了「声明式、自动更新」!
为什么这一步是质的飞跃?
| 对比项 | 原始 DOM 操作 | Vue 响应式驱动 |
|---|---|---|
| 数据改变后 | 手动重新 render | 自动更新视图 |
| 主要工作 | 找节点、拼接字符串 | 只关心数据和业务逻辑 |
| 代码量 | 随着功能指数级增长 | 几乎线性增长 |
| 可维护性 | 灾难 | 丝滑 |
| 是否需要理解 Virtual DOM、diff 算法 | 不用管 | Vue 帮你搞定 |
响应式到底是怎么工作的?(底层逻辑揭秘)
很多人会背「ref 把普通变量包装成响应式对象」,但到底怎么包装的?
const count = ref(0)
// 实际结构大概是这样的(简化版):
count = {
value: 0,
__v_isRef: true,
dep: new Set(), // 依赖集合
get value() {
// 依赖收集:把当前正在渲染的组件 effect 收集起来
track(this, 'value')
return this._value
},
set value(newVal) {
this._value = newVal
// 触发更新:通知所有依赖了 count 的地方重新渲染
trigger(this, 'value')
}
}
当模板里出现 {{ count }} 时:
- 触发 count 的 getter → 依赖收集(track)
- 当你执行 count.value++ 时 → 触发 setter → trigger()
- Vue 自动把所有依赖了 count 的组件重新 render
通俗来讲:
Vue 的响应式不是在监视 DOM,也不是在监视变量, 而是在监视『哪段渲染代码(render 函数/effect)读了这个数据』。
get 阶段完成『登记』:把正在执行的 render 函数记下来;
set 阶段完成『通知』:把之前登记的所有 render 函数重新执行一遍。
整个过程就是一次精准的『函数级订阅-发布』, 你只改数据,Vue 负责把所有用过这个数据的渲染函数自动再跑一遍。
这就是「数据驱动视图」的底层魔法!
重要提醒(踩坑合集)
- 忘记写 .value(最常见新手错误!)
// 错误:直接赋值普通数组,Vue 检测不到变化
users = data
// 正确
users.value = data
- 直接替换整个数组对象可以,但 push/splice 更推荐
// 这两种都能触发更新
users.value = [...users.value, newUser]
users.value.push(newUser) // Vue3 对数组变异方法做了代理
- 如果数据是嵌套对象,也要用 ref 或 reactive
const state = reactive({
user: { name: '小明', info: { age: 18 } }
})
// 直接改深层属性也能触发更新
state.user.info.age = 20 // OK!
阶段对比总结(强烈建议收藏)
| 阶段 | 代表技术 | 核心思想 | 痛点 | 适合场景 |
|---|---|---|---|---|
| 1. 后端模板 | JSP/PHP/ThinkPHP/Node 字符串拼接 | 数据 + 模板字符串 | 前后端强耦合、无交互 | 管理后台、博客 |
| 2. 前后端分离+DOM | jQuery + 原生 fetch | 手动 DOM 操作 | 维护成本高、易错 | 中小型项目 |
| 3. 响应式框架 | Vue3 / React / Svelte | 数据驱动视图 + 自动 diff | 学习成本(once) | 99% 的现代前端项目 |
最后做个小实验(强烈建议自己敲一遍)
把 App.vue 里的 onMounted 注释掉,改成:
// 模拟 3 秒后数据更新
setTimeout(() => {
users.value.push({
id: 999,
name: '新用户',
email: 'new@163.com'
})
}, 3000)
你会看到页面上自动多了一行!
这就是响应式最直观的魅力——你只管改数据,视图自动跟上。
几个细节知识点
1. MVC 到底是什么?为什么和前端一点交互都做不了?
- M = Model(数据层)→ 操作数据库、定义数据结构
- V = View(视图层)→ HTML 模板
- C = Controller(控制层)→ 接收请求 → 查 Model → 把数据塞进 View → 返回一整个 HTML
典型流程(以前面第一段代码为例): 浏览器请求 → Node 服务器 → 查询 users 数组 → 把数据塞进 HTML 字符串 → 返回完整页面 → 浏览器渲染
为什么“一点交互都做不了”?
因为每次你点按钮、删一条数据、本质上都是发起一个新的 HTTP 请求 → 服务端重新跑一遍 MVC → 重新生成一整个新 HTML → 浏览器把旧页面整个扔掉,换上新页面。
相当于:
- 你在 Excel 里点“删除一行”
- Excel 把整个文件关掉,重新打开一个新文件给你看
用户体验:页面白一下、闪一下、滚动条回到顶部、输入框内容全没了……
这才是真正的“一点交互都做不了”——不是不能做,而是做了也超级烂。
2.三种方式应对数据改变的对比(以前后端分离展开来讲)
举个最经典的例子:
JavaScript
let users = [];
// 第一步:发请求拿到数据
fetch('/users').then(r => r.json()).then(data => {
users = data; // 数据已经变了!!
render(); // 你必须手动调用 render 才能看到变化
});
function render() {
const tbody = document.querySelector('tbody');
tbody.innerHTML = users.map(user => `
<tr><td>${user.id}</td><td>${user.name}</td></tr>
`).join('');
}
你改了 users 变量,但页面根本不知道! 除非你手动再去操作 DOM(find节点、改innerHTML、删节点、加节点……
也就是再执行一次render()
这就叫“数据变了,视图没变,必须手动同步”。
我们来看看三种方式对比(以删除数据为例):
-
传统 MVC 方式: 用户点删除 → 提交表单 → 浏览器发新请求 → Node 重新执行 render() → 返回一整个新 HTML → 浏览器整个页面替换 → 页面闪白、滚动条回到顶部、标题动画重来一遍…
-
手动 DOM 编程方式: 用户点删除 → fetch DELETE → users = users.filter(...) → 你手动调用 render() → 只有 tbody 内容变了 → 页面不闪白、导航栏不动、搜索框里的字还在 → 用户体验大幅提升!
-
Vue 方式: users.value.splice(i, 1) → 连 render() 都不用你写 → 只有那一行淡出消失 → 其他完全不动
总结来说:
- MVC(后端渲染) :数据变了 → 整个页面重生
- 手动 render(早期 SPA) :数据变了 → 局部重绘(你得自己喊 render)
- Vue(现代 SPA) :数据变了 → 局部自动重绘(你只管改数据)
3.fetch + 轮询
以前后端分离展开: 在我们第一段代码中,fetch执行一次就结束了,后端再怎么改数据,如果不再次调用fetch数据永远不会再动! 那我们怎么实时实现更新数据呢?
那是因为,在我们的后端文件里的 json-server 其实加了个“轮询”功能:
"scripts": {
"dev": "json-server --watch db.json"
},
它会在文件变化时自动广播(通过 WebSocket),很多前端工具(vite、vue-cli)会自动监听这个广播,然后热更新页面。
所以你改 数据后页面自动刷新,并不是 fetch 在监视,而是开发工具在监视!
3. 三个易错点详细拆解
(1)忘记给 tr 加 :key,后续增删动画会出大问题
vue
<tr> ... </tr>
<tr> ... </tr>
Vue 是靠 key 来判断“这一行到底是新建、删除、还是移动”的。
没 key 时,Vue 只能按“位置”来比对:
原来:A B C
现在:A C D
Vue 会傻傻地认为:B 改成了 C,C 改成了 D,新增一个 D → 全部重绘 + 动画错乱
有 key 时:
原来:id1(A) id2(B) id3(C)
现在:id1(A) id3(C) id4(D)
Vue 立刻知道:id2 被删了,id4 新增,id3 移动了位置 → 精准动画
实际效果你自己试试就知道,列表删除时会“集体抽风”。
(2)innerHTML 有 XSS 风险(生产必须转义)
JavaScript
// 用户恶意输入 name 为:alert('你被黑了
`<tr><td>${user.name}</td></tr>`
// 直接变成:
<tr><td>alert('你被黑了')</td></tr>
// 浏览器直接执行!网站被黑
正确做法(手动防 XSS):
JavaScript
function escapeHtml(str) {
return str.replace(/[&<>"']/g, match => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
})[match]);
}
Vue 模板会自动转义,React 也自动转义,唯独你自己用 innerHTML 的时候要自己记得!
(3)多次 render 会造成性能浪费(没做 diff)
你自己写 render() 函数时,一般是:
JavaScript
tbody.innerHTML = '' // 先清空
tbody.innerHTML = 新内容 // 再整个写回去
就算只有第 10 行变了,你也把 1000 行全部删掉重写 → 浏览器全部重排重绘 → 卡!
diff 长什么样?(Vue 的真实做法)
Vue 会先在内存里造一个「假 DOM」(叫 Virtual DOM),然后拿新旧两份假 DOM 去比:
text
旧 Virtual DOM: [用户1, 用户2, 用户3, 用户4]
新 Virtual DOM: [用户1, 用户2, 小红, 用户4]
↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
Vue diff 算法只发现:第 3 项变了
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
真实 DOM 操作: 只改第 3 个 <tr> 的内容,其他不动!
这就是 diff 的全部意义:最小化真实 DOM 操作
Vue/React 会在内存里先做 Virtual DOM diff,只更新真正在 DOM 上变化的那几行,性能提升几十倍。
4. 为什么不写 .value 就检测不到变化?
因为 ref() 返回的不是普通对象,而是一个“炼金术士”:
JavaScript
const count = ref(0)
// 实际长这样(简化版):
count = {
_value: 0,
get value() { // 有人读我的时候触发依赖收集
track()
return this._value
},
set value(newVal) { // 有人改我的时候触发更新
this._value = newVal
trigger()
}
}
你如果直接写成:
JavaScript
const users = ref([])
users = data // 你把整个炼金术士对象整个替换成了普通数组!!
这时候 Vue 的“依赖收集-触发更新”机制完全失效了,因为你已经不是那个带 getter/setter 的 ref 对象了。
正确做法永远是:
JavaScript
users.value = data // 只改内部的 _value,getter/setter 还在,机制正常
这就是为什么模板里可以直接写 {{ users }}(Vue 自动帮你读 .value),但在 < script > 里必须手动写 .value。
5. 深层嵌套对象用 ref 只需要包装最外层?
完全正确!这是 Vue3 最爽的地方之一。
JavaScript
const state = ref({
user: {
info: {
profile: {
age: 18
}
}
}
})
// 你可以随便改 100 层深的地方改!
state.value.user.info.profile.age = 99
// 页面也会立刻更新!!!
原理:Vue3 的 ref + reactive 使用了 ES6 Proxy,对整个对象(包括所有嵌套属性)的 get/set 都做了代理,所以无论多深,只要是通过 ref/reactive 包过的最外层对象,内部随便改都响应式。
对比 Vue2: Vue2 必须一层层 Object.defineProperty,如果新增属性(obj.newProp = xxx)还不触发更新,要用 Vue.set
Vue3:直接干就完事儿了
写在最后
从「手动拼接 HTML 字符串」→「手动操作 DOM」→「只改数据,视图自动更新」,这三次架构升级,每一次都让开发者幸福感翻倍。
Vue3 的 ref/reactive + + 模板语法,堪称现代前端工程化的巅峰之一。它把最复杂的「视图如何高效更新」问题彻底封装,让我们这些普通开发者可以真正「聚焦业务」。
所以,下次有人问你:「Vue 到底比 jQuery 强在哪?」
你就可以甩出这张图:
jQuery 时代:我要改数据 → 找节点 → 改 innerHTML → 祈祷没 bug
Vue 时代 :我要改数据 → users.value.push(...) → 喝茶等自动更新