本文尝试把 Vue(以 Vue 3 为主,兼顾与 Vue 2 的对比)从"会用"讲到"理解设计动机",覆盖架构分层、响应式与渲染原理、关键技术实现、常用技巧与高阶用法、与 React 的本质差异,以及生态搭配建议。
Vue 3 相比 Vue 2 最大的架构变化是从单体框架拆分为一组可独立使用、可摇树优化(tree-shakable)的包。理解这层拆分,是理解后续一切原理的前提。
这种拆分带来几个直接好处:
@vue/reactivity 可以脱离 Vue 单独使用——这也是 Vue Reactivity 能被 Preact Signals、Solid 等外部生态引用、甚至被 Vue 团队用来做 @vue/reactivity 独立包的原因。h() 渲染函数,两条路径共享同一套运行时。createRenderer)让 Vue 可以渲染到非 DOM 目标(如小程序 @vue/uni-app、@tresjs(Three.js 渲染器)),这是 React Fiber 的 host config 思路的对应设计。组件实例的核心结构(简化):
interface ComponentInternalInstance {
vnode: VNode // 当前组件对应的虚拟节点
subTree: VNode // render() 产生的子树
update: () => void // 触发重新渲染的调度函数(实际是一个 effect)
render: Function // 编译产物或用户写的渲染函数
setupState: object // setup() 返回值(响应式代理)
props: object
emit: Function
isMounted: boolean
// ...
}
理解这张图的关键在于:渲染更新的入口不是"数据变了所以重新渲染",而是"渲染函数本身是一个响应式副作用(effect),数据变化只是触发了这个 effect 重新执行"。这句话是理解下一节原理的钥匙。
Object.defineProperty 的局限Vue 2 通过递归遍历对象属性,用 defineProperty 把每个属性改写为 getter/setter,在 getter 中收集依赖(Dep.depend()),在 setter 中触发更新(Dep.notify())。
局限非常明确:
Vue.set / Vue.delete 打补丁);length 修改无法拦截(需要重写数组的 7 个变异方法);Vue 3 用 Proxy 整体代理对象,天然解决了上述问题:
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
track(target, key) // 依赖收集
const res = Reflect.get(target, key, receiver)
// 惰性递归:只有访问到嵌套对象时才转换为响应式,而不是初始化时全量递归
return isObject(res) ? reactive(res) : res
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
if (oldValue !== value) trigger(target, key) // 触发更新
return result
},
deleteProperty(target, key) {
const hadKey = hasOwn(target, key)
const result = Reflect.deleteProperty(target, key)
if (hadKey) trigger(target, key)
return result
}
})
}
关键设计点:
get 访问到的嵌套对象才会被包一层 Proxy,避免了 Vue 2 那种"无论用不用都递归一遍"的初始化成本。Reflect 保证 this 指向正确:如果对象内部有 getter 访问 this.other,用 Reflect.get(target, key, receiver) 能保证 this 指向 Proxy 本身而非原始对象,从而继续触发依赖收集。WeakMap 结构存依赖:targetMap: WeakMap<target, Map<key, Set<effect>>>,这是一个三层结构——目标对象 → 属性名 → 依赖该属性的副作用集合。WeakMap 保证目标对象被回收时依赖关系也能被 GC。一个常被忽视但非常关键的机制是调度器(scheduler):数据变化不会同步触发 DOM 更新,而是把对应的 effect 放入一个去重队列,在微任务(Promise.then)中批量执行。这就是为什么:
state.a = 1
state.b = 2
state.c = 3
// DOM 只会更新一次,而不是三次
这也是 nextTick() 的实现基础——nextTick 本质是往同一个微任务队列后面再插一个回调,从而保证在 DOM 更新之后执行。
ref 与 reactive 的本质区别ref 并不是"给基本类型套一层响应式"这么简单,它是一个独立的、带 .value 访问器的响应式单元:
class RefImpl {
constructor(value) {
this._value = isObject(value) ? reactive(value) : value
}
get value() {
track(this, 'value')
return this._value
}
set value(newVal) {
this._value = isObject(newVal) ? reactive(newVal) : newVal
trigger(this, 'value')
}
}
ref 存在的根本原因是JS 没有"引用传递基本类型"的能力——如果不用一个对象包一层,let count = 0 这种基本类型变量从函数中解构出来后就彻底失去响应性。这是 Vue 3 Composition API 设计中一个不得不面对的语言层限制,也是很多人觉得"到处要写 .value 好烦"的根源。模板中会自动解包 ref(编译期做的),但在 reactive 对象内部访问 ref 属性时也会自动解包,这两条规则经常让新手困惑,值得专门记一下。
Vue 2 的虚拟 DOM diff 是全量 diff:不管模板里哪部分是动态的,patch 时都要逐层比较整棵树。
Vue 3 编译器在编译阶段就知道模板里哪些节点是静态的、哪些是动态的、动态在哪个维度(是文本变了还是 class 变了),并把这些信息编码为 PatchFlag:
// 模板:<div :class="cls">{{ msg }}</div>
// 编译产物(简化):
h('div', { class: cls }, msg, 2 /* PatchFlags.CLASS */)
2 这个数字告诉运行时:"这个节点只有 class 可能变化,其他属性和结构不用比较"。运行时 diff 时看到 PatchFlag 就直接跳到对应比较逻辑,跳过全部静态部分——这叫靶向更新(targeted updates)。
比 PatchFlag 更进一步的优化是 Block。编译器会把一个模板中所有的动态节点收集到一个扁平数组里(而不管它们在真实 DOM 树里嵌套多深),diff 时只遍历这个数组,而不是递归遍历整棵树:
<div>
<p>静态文本</p>
<div v-if="show"> <!-- 动态:结构 -->
<span>{{ a }}</span> <!-- 动态:文本 -->
<p>静态</p>
<span :class="c">{{ b }}</span> <!-- 动态:class + 文本 -->
</div>
</div>
编译后动态节点被"拍平"进一个 dynamicChildren 数组,patch 阶段直接线性遍历这个数组,静态子树被完全跳过(连"访问"都不需要)。这是 React 无法做到的优化,因为 JSX 没有模板编译这一步,React 拿到手的已经是纯运行时的树,无法在编译阶段静态分析出"这块永远不变"。
// 静态提升:不变的 vnode 只创建一次,多次渲染复用同一个引用
const _hoisted_1 = h('p', null, '静态文本')
function render() {
return h('div', null, [_hoisted_1, /* 动态部分 */])
}
v-once、内联事件处理函数缓存(cacheHandler)等都是同一类思路:能在编译期确定不变的东西,就不要留到运行时重复计算。
shouldComponentUpdate 的隐式版本Vue 的响应式系统天然做到了细粒度依赖追踪:一个组件的 render effect 只会收集它在渲染过程中实际访问过的响应式属性。这意味着:
const state = reactive({ a: 1, b: 2 })
// 组件 A 的模板只用了 state.a
// 组件 B 的模板只用了 state.b
// 修改 state.b 只会触发 B 重新渲染,A 完全不受影响
这不需要像 React 那样手动 memo/useMemo/useCallback 去"防止不必要的重渲染"——依赖收集本身就是精确的。这也是本文第五节要展开的、Vue 和 React 最本质的分歧点。
完整流程:parse(模板字符串 → AST)→ transform(AST 遍历,标记静态/动态、做静态提升、生成 PatchFlag)→ generate(AST → 渲染函数源码字符串)。
transform 阶段是插件化的,compiler-core 只提供通用转换逻辑,compiler-dom 注入浏览器特有的转换(比如 v-model 在 <input> 上要编译成不同的东西取决于 type)。这也是 Vue 能支持自定义渲染平台、同时保持模板语法统一的原因。
provide/inject + 组合式函数是 Vue 3 替代 Vue 2 mixin 的核心方案。mixin 的根本问题是命名冲突不可追溯(两个 mixin 都定义了 data.loading,覆盖谁完全看合并顺序),组合式函数用普通的 JS 函数作用域天然解决了这个问题:
function useMouse() {
const x = ref(0), y = ref(0)
const update = (e) => { x.value = e.pageX; y.value = e.pageY }
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
// 组件里:
const { x, y } = useMouse() // 变量名可以随意重命名,无冲突
组合式函数能拿到 onMounted 等生命周期钩子,靠的是一个"当前组件实例"的模块级变量(currentInstance),setup() 执行期间这个变量被设置好,函数内部调用生命周期 API 时会读取它——这就是为什么组合式函数只能在 setup() 同步执行期间调用,不能放在 setTimeout 或异步函数的 await 之后(await 之后 currentInstance 已经被恢复成别的值或 null 了)。这是一个非常容易踩的坑,本质是 Vue 用一个模块级可变状态模拟了"当前渲染上下文",而不是像 React Hooks 那样靠调用顺序(链表)隐式关联。
<script setup><script setup> 中的 defineProps、defineEmits 不是真正的运行时函数,而是编译器识别的宏,编译时会被整个替换掉:
// 你写的:
const props = defineProps({ title: String })
// 编译后大致变成:
export default {
props: { title: String },
setup(props) {
// ...
}
}
理解"宏"这个概念很重要:defineProps 在你的源码里"看起来"是一个函数调用,但它从不会真正在运行时被执行——如果你试图把它赋值给一个变量再调用,编译器会报错。这跟 React 里 Hooks 是"真正会执行的函数"是完全不同的机制。
Teleport 解决的是"逻辑上属于组件树内、渲染上要挂到别处 DOM"的问题(弹窗、Toast),实现原理是渲染器在 patch 时对 Teleport 类型节点做特殊处理,把子树 mount 到指定 target 而不是当前父节点,但组件的响应式上下文、provide/inject 链路仍然保持在原本的组件树位置——这是它和"直接用 createPortal"看起来相似但概念更清晰的地方。
Suspense 用于处理异步 setup()(返回 Promise)或异步组件,在 resolve 之前展示 #fallback 插槽内容。
computed 的惰性求值与缓存const double = computed(() => heavyCalc(state.count))
computed 内部也是一个 effect,但带有 lazy: true 标记——它不会在创建时立即执行,只有被访问 .value 时才求值,并且求值结果会被缓存,只有依赖的响应式数据变化后才会标记为"脏"(dirty = true),下次访问时才重新计算。这意味着:一个 computed 如果从未被访问,它的计算函数永远不会执行,也不要在 computed 里做有副作用的操作(比如发请求),因为它的执行时机不可控。
watch vs watchEffect// watch:显式声明依赖,能拿到 oldValue,支持 deep/immediate
watch(() => state.count, (newVal, oldVal) => { /* ... */ }, { immediate: true })
// watchEffect:自动收集依赖(运行时访问到什么就依赖什么),没有 oldValue
watchEffect(() => { console.log(state.count) })
选择原则:当你需要精确控制"到底监听谁"、需要新旧值对比时用 watch;当副作用逻辑本身就是"访问什么就该响应什么"时用 watchEffect(更少样板代码,但依赖关系不够显式,团队协作中可读性稍差)。
customRef 实现防抖输入function useDebouncedRef(value, delay = 300) {
let timeout
return customRef((track, trigger) => ({
get() {
track()
return value
},
set(newVal) {
clearTimeout(timeout)
timeout = setTimeout(() => {
value = newVal
trigger()
}, delay)
}
}))
}
customRef 把 track/trigger 的调用时机完全交给用户控制,是实现防抖、节流、localStorage 同步等场景的标准方式。
shallowRef + triggerRef:大数据量场景的性能优化对于第三方库返回的大对象(比如地图 SDK 的实例、图表库的数据),用 reactive 会导致 Vue 递归代理整个对象树,代价很高。这种场景应该用 shallowRef,只在顶层建立响应式,内部变更需要手动 triggerRef() 通知更新:
const bigData = shallowRef(fetchHugeObject())
function updateSomeDeepField() {
bigData.value.deep.nested.field = 'x' // 不会触发更新
triggerRef(bigData) // 手动触发
}
高阶组件、动态生成大量结构相似但差异点很多的组件时,直接写渲染函数比模板更灵活:
export default defineComponent({
props: ['items'],
setup(props) {
return () => h('ul', props.items.map(item =>
h('li', { key: item.id }, item.name)
))
}
})
代价是失去了模板编译期的所有优化(PatchFlag、静态提升都不存在了,因为没有编译分析这一步),所以只在真正需要动态生成结构的场景使用,不要为了"看起来像 React"而放弃模板。
Pinia 相比 Vuex 去掉了 mutation 这一层,本质原因是 Vue 3 的 reactive 已经能让"直接修改状态"变得可追踪、可调试,Vuex 的 mutation 是为了在 Vue 2 的响应式限制和 Flux 单向数据流理念下提供一个"可预测的修改入口",但在 Vue 3 时代这层间接性变得没有必要——Pinia store 本质就是一个用 defineStore 包装的、天然响应式的 composable。
表面差异(模板 vs JSX、.value vs 无、单文件组件 vs 纯 JS 文件)都是次要的。真正本质的分�器在于**"更新的驱动模型"不同**。
具体展开几个维度:
1. 状态与视图的关系模型不同。 Vue 的 reactive/ref 是可变的(mutable),state.count++ 直接改。React 的 state 是不可变的(immutable),必须 setCount(count + 1) 生成新引用,因为 React 依赖引用比较(Object.is)来判断"是否需要重渲染这个组件"。这不是审美偏好,而是两套完全不同的变更检测(change detection)机制的必然结果:Vue 靠 Proxy 拦截"写"这个动作本身,React 靠比较"前后引用是否相同"。
2. 组件函数的"重新执行"含义完全不同。 React 函数组件在每次渲染时整个函数体重新执行一遍(这也是为什么闭包陷阱、useEffect 依赖数组、useCallback 这些概念存在的根本原因——都是在应对"函数体会反复执行"这件事)。Vue 的 setup() 只在组件创建时执行一次,之后的更新只是重新运行由模板编译出的那个纯粹的 render 函数(一个不涉及 setup 逻辑重新执行的轻量操作),因此 Vue 组合式 API 里天然不存在"闭包过期"的问题,也不需要 useCallback/useMemo 这类心智负担。
3. 编译器介入的深度不同(历史上)。 Vue 的模板编译一直是框架能力的核心组成部分。React 传统上坚持 JSX 只是语法糖到 createElement 的直译,运行时不做任何编译期分析——这也是 React 团队近两年推出 React Compiler(自动为组件插入类 memo 优化)的原因:本质上是在向 Vue 已经用了多年的"编译期分析驱动运行时优化"的方向靠拢,但起点和历史包袱不同。
4. Hooks 的顺序依赖 vs 组合式函数的作用域依赖。 React Hooks 靠调用顺序在链表中定位状态(这就是为什么不能在 if 里调用 Hooks),Vue 组合式函数靠闭包作用域天然持有各自的响应式变量,没有顺序约束,可以自由地写在条件判断里。
一句话总结本质区别:Vue 是"数据驱动、编译器辅助的精确更新系统";React 是"函数式、不可变数据驱动的粗粒度重渲染 + 手动/编译器优化的裁剪系统"。两者都能达到高性能,但达成路径和背后的编程模型完全不同——这也是为什么"Vue 写起来像在写配置,React 写起来像在写函数式代码"这种直觉是对的,它背后有扎实的实现原因。
| 领域 | 首选 | 说明 |
|---|---|---|
| 构建工具 | Vite | Vue 团队亲自维护,ESM 原生 + esbuild 预构建,是当前事实标准 |
| 状态管理 | Pinia | Vuex 官方继任者,天然 TS 友好,支持 devtools 时间旅行 |
| 路由 | Vue Router | 官方维护,与 <script setup>、Suspense 集成度最高 |
| 全栈框架 | Nuxt 3/4 | 基于 Nitro 引擎,SSR/SSG/混合渲染、文件路由、自动导入 |
| 组件库 | Element Plus / Naive UI / shadcn-vue | 企业中后台首选 Element Plus;追求现代设计系统可用 shadcn-vue |
| 表单校验 | VeeValidate + Zod | Zod 做 schema 校验,VeeValidate 做表单状态绑定 |
| 测试 | Vitest + Vue Test Utils | Vitest 与 Vite 共享配置和转换管线,速度远超 Jest |
| 移动端/跨端 | Uni-app / Quasar | 基于 Vue 语法编译到小程序、App、H5 多端 |
| 3D/可视化 | TresJS(Three.js)/ ECharts + vue-echarts | TresJS 是 Vue 生态对 React Three Fiber 的对应方案 |
| 类型体操 | TypeScript + volar (Vue Language Tools) | <script setup lang="ts"> + volar 提供完整的模板类型检查 |
推荐组合(中大型项目起手式):
Vite + Vue 3 (<script setup> + TS)
+ Pinia(状态)
+ Vue Router(路由)
+ Nuxt(如需 SSR/SEO)
+ Element Plus / shadcn-vue(UI)
+ Vitest(测试)
+ VeeValidate + Zod(表单)
如果项目形态更偏"内容型/营销站/需要 SEO",直接从 Nuxt 起手而不是"Vite + Vue Router"手搭 SSR,能省掉大量样板配置(自动路由、自动导入、useFetch/useAsyncData 的服务端数据获取、<NuxtImage> 图片优化等都是开箱即用的)。
Vue 的设计哲学始终围绕一个核心:把"什么时候该更新"这件事尽可能地交给框架自己去精确计算,而不是让开发者手动标注。响应式系统负责"精确知道谁依赖了谁",编译器负责"精确知道哪里是动态的",两者结合起来,才是 Vue 3 性能和开发体验的真正来源——而不是简单的"Proxy 比 defineProperty 快"这种表面认知。理解到这一层,再回头看 API 设计(为什么 ref 需要 .value、为什么 computed 要惰性、为什么组合式函数不能在 setTimeout 里用生命周期钩子),会发现每一个"反直觉"的设计都是在某个约束下的必然选择。