1. 尤雨溪与Vue:一个前端框架是怎么"长"出来的
第一次认真读 Vue 源码的时候,我脑子里冒出来的问题是:一个学设计出身、在 Google 做过创意技术的人,为什么会去做前端框架,而且还做成了全球使用量最大的那一档?后来顺着尤雨溪这些年的演讲稿、RFC 文档、GitHub issue 记录一路翻下来,我发现他的路径其实特别"前端"——不是先想清楚一套宏大架构再动手,而是在真实的业务里被恶心到了,然后顺手做一个自己想用的工具,再一步步被社区推着往前走。
这篇文章我打算把"前端男神"这四个字拆开看:一部分讲人,讲他每个阶段的技术选择背后在想什么;另一部分讲代码,讲 Vue 的响应式、编译优化、Vite 的启动原理、Vapor Mode 这些实打实的东西;最后一部分是我自己踩过的坑、面试里被问过的问题、以及一个普通前端能从这条路径里抄走什么。不管你是刚开始学前端,还是已经写了几年业务、准备往架构或者开源方向走,这些内容应该都能直接用上。
1.1 艺术设计背景,怎么就成了写框架的
很多人第一次知道尤雨溪的经历都会愣一下:他在国外读的本科是艺术方向,研究生读的是设计学院的设计与技术专业(Design & Technology)。这个专业听着像"设计",实际上课程里要写不少代码——做交互装置、做数据可视化、做动态图形,代码是表达工具,不是目的。这段经历对他的影响,我觉得比想象中大得多。
传统计算机专业出身的框架作者,思考起点往往是"抽象的完备性":API 怎么设计才正交、类型系统怎么推导、边界情况怎么覆盖。而设计背景的人,思考起点是"使用者的手感":我第一次打开这个文档,能不能在十分钟内跑出一个能看的页面;我写这段代码的时候,心智负担重不重;出错了提示信息能不能让我一眼看懂。Vue 从第一天起就带着这种气质——它的官网永远有一个"在浏览器里直接改代码"的演练区,它的报错信息会直接告诉你"你大概是忘了写 key",它的 API 命名尽量用自然语言而不是符号。
这个差异不是玄学,落到工程上就是很具体的取舍。比如 Vue 的模板语法<div v-if="ok">对设计师、后端、刚转行的人几乎没有门槛,而 JSX 需要你接受"HTML 在 JS 里"这套心智模型。再比如 Vue 早期的文档里,几乎每个 API 都会配一段"这东西什么时候用、什么时候别用"的说明,这在当年的框架文档里是很少见的。我自己带过几个刚入行的同学,从 Vue 开始学的人,通常两周内就能交付一个完整的后台页面,这个转化效率确实跟"低门槛优先"的设计哲学有关。
1.2 "渐进式"这三个字,到底解决了什么真实问题
"渐进式框架"(The Progressive Framework)是 Vue 最出名的定位,但很多人只是把它当成一句宣传语。我理解的"渐进",本质上是在解决一个特别现实的矛盾:项目的复杂度是会随时间增长的,但框架的复杂度是固定的。
举个我亲身经历的场景。早些年我们做一个内部运营后台,最初需求就是三个表格加两个表单,如果这时候让你上手 React + Redux + Webpack 全家桶,光是搭环境、定目录、写 action 和 reducer,代码量就比业务本身多了。但问题是,半年后这个后台会长出一堆权限控制、实时消息、图表看板、多标签页缓存,如果最初选的是一个"只能做小东西"的方案,后期就要推倒重来。
Vue 的答案是把复杂度切成层:核心只负责"数据变了,视图跟着变";路由交给 Vue Router;状态管理交给 Pinia(早期是 Vuex);构建交给 Vite 或者别的方案。你可以只用最上面一层写个页面,也可以逐层加进来。这种设计的价值在决策可以被推迟——你不需要在项目第一天就为一年后的复杂度买单。
注意:渐进式不等于"可以一直不做架构决策"。我见过太多项目,从 Vue CDN 引入一路裸奔到几万行代码,没有任何模块划分,最后比一开始就上全家桶还难维护。渐进的意思是"按需引入",不是"永远不引入"。
1.3 几个真正改变走向的决策点
回顾这条路径,有几个节点的选择我觉得相当关键。
第一是 2014 年把项目开源,而不是做成商业产品。当时 React 已经在往前跑了,一个个人项目想在生态里活下来,唯一的办法是让更多人用起来。开源换来的不是钱,是反馈速度和贡献者数量,这两样东西在框架早期比什么都值钱。
第二是坚持"文档驱动开发"。Vue 的文档质量一直是被公认的强项,而且是多语言的。我后来看一些采访才知道,他对文档的投入程度几乎不亚于代码本身。这件事的收益是长尾的:开发者遇到问题第一反应是翻文档而不是提 issue,社区维护成本直接降下来了。
第三是选择全职做开源,靠赞助和后来的公司化维持。这条路在当年风险很大,但也正因为全职投入,Vue 的版本节奏、废弃策略、迁移工具(比如那个自动迁移脚本)才能做得这么完整。一个兼职维护的框架,是没法在两年内完成从 Vue 2 到 Vue 3 这种体量迁移的。
第四是 2020 年前后开始做 Vite 和后面的 VoidZero 工具链。这个决策的野心明显更大:从"做一个框架"变成"重做一遍 JS 的构建基础设施"。做框架的人很多,但敢去动打包器和编译器这一层的人很少,因为这块是脏活累活,收益还不直观。后面我会细讲这块的技术含量。
2. Vue 的核心设计拆解:响应式、编译与运行时
聊完人和决策,得回到代码本身。Vue 的架构其实可以粗暴地分成三块:响应式系统负责知道"数据变了",模板编译负责把声明式模板翻译成可执行代码,运行时负责把变化高效地刷到真实 DOM 上。理解这三块的分工,基本上就理解了 Vue 的大半。
2.1 响应式系统的两次"换心脏"
Vue 2 用的是Object.defineProperty。原理不复杂:遍历 data 对象上的每个属性,把它们都改写成 getter/setter,读取的时候收集依赖,赋值的时候通知更新。手写一版核心逻辑大概长这样:
function defineReactive(obj, key, val) { const dep = []; Object.defineProperty(obj, key, { get() { if (currentWatcher && !dep.includes(currentWatcher)) { dep.push(currentWatcher); // 收集依赖:谁读了我 } return val; }, set(newVal) { if (newVal === val) return; val = newVal; dep.forEach(w => w.update()); // 通知依赖:我变了 } }); }这套方案在当年够用,但有几个硬伤我实际项目里都撞过。一是数组的索引赋值和length修改监听不到,所以 Vue 2 才需要Vue.set和那一堆重写的数组方法(push、pop、splice 等)。二是新增属性监听不到,给对象动态加个字段,视图就是不更新,新手最容易在这个坑里卡半天。三是初始化时要递归遍历整个 data,一个几千字段的大对象,页面初始化那一下就能感觉到卡顿。
Vue 3 换成了Proxy,这些问题的根源被一次性解决了:
function reactive(target) { return new Proxy(target, { get(obj, key, receiver) { track(obj, key); // 收集依赖,支持任意层级 return Reflect.get(obj, key, receiver); }, set(obj, key, value, receiver) { const result = Reflect.set(obj, key, value, receiver); trigger(obj, key); // 触发更新 return result; }, deleteProperty(obj, key) { const result = Reflect.deleteProperty(obj, key); trigger(obj, key); return result; } }); }Proxy代理的是整个对象而不是单个属性,所以新增、删除、数组索引赋值全都能拦下来。更关键的是惰性代理:只有真正访问到的深层对象才会被代理,没用的字段完全不付出代价,这直接改善了大对象的初始化性能。
提示:
Proxy也不是没有边界。基本类型(string、number)没法代理,所以 Vue 3 才需要ref()把值包一层对象;另外Proxy无法拦截Object.getOwnPropertyNames之外的一些内部操作,日常业务基本遇不到,但写工具库的时候要留意。
2.2 模板编译加虚拟 DOM:为什么两个都要
经常有人问:都有模板编译了,为什么不直接生成 DOM 操作代码,还要搞一层虚拟 DOM?反过来也有人问:都有虚拟 DOM 了,为什么还要编译模板,直接写 render 函数不行吗?
我的理解是,这两个东西解决的是不同维度的问题。虚拟 DOM 解决的是"运行时怎么最小代价更新",编译解决的是"构建时怎么把信息提前算掉"。
纯运行时方案(比如早期的一些框架)在数据变化时,只能靠遍历整棵虚拟 DOM 树做 diff 来找出差异。Vue 3 的编译器会把模板静态分析一遍,把节点打上 PatchFlag:
<div> <!-- 静态节点,永远不会变,编译时直接提升到外面 --> <header class="title">用户列表</header> <!-- 只有 text 是动态的,打标记 1 = TEXT --> <p>{{ userName }}</p> <!-- 只有 class 是动态的,打标记 2 = CLASS --> <div :class="activeClass">...</div> </div>编译后,静态的<header>会被提升到 render 函数外面只创建一次;动态节点带着标记,运行时 diff 就能直接跳过那些不可能变化的比较。实测下来,这个优化在列表渲染场景里能砍掉相当一部分无意义的比较。
虚拟 DOM 的另一个价值是跨平台。因为 render 函数的产物是一棵普通的 JS 对象树,这套逻辑可以喂给浏览器 DOM、小程序、原生渲染层,甚至服务端拼接字符串。这就是为什么 uni-app、NativeScript-Vue、Vue 的 SSR 都能复用同一套组件代码。纯编译生成 DOM 操作的话,跨平台就得再写一套编译器。
2.3 单文件组件:一个被低估的工程决策
.vue文件这个设计,我觉得是 Vue 工程化里最有价值的一块,很多人把它当成语法糖,其实它解决的是"一个组件的三份资产怎么放在一起"这个问题。
<template> <button class="btn" @click="handleClick">{{ label }}</button> </template> <script setup> import { ref, computed } from 'vue' const props = defineProps({ label: String }) const count = ref(0) const doubled = computed(() => count.value * 2) const handleClick = () => { count.value++ } </script> <style scoped> .btn { padding: 8px 16px; border-radius: 4px; } </style>好处很直接:模板、逻辑、样式写在一个文件里,改一个按钮的样式不用在三个目录之间来回跳;scoped让样式默认隔离,不用再为命名冲突吵架;<script setup>又把写法压到最简,省掉了一堆return和setup()样板代码。
这套设计能成立的前提是构建期介入。浏览器不认识.vue,必须靠构建工具把它拆成 JS 模块和 CSS 模块,样式要有作用域隔离就要编译期给选择器加哈希。这也就是为什么 Vue 从很早就跟构建工具深度绑定——不是它想绑定,是这套文件格式天然需要编译。
3. 从 Vue 2 到 Vue 3,再到 Vite 与 Vapor
框架的迭代比人想象中难得多。你每改一个 API,背后是几百万个项目要跟着改。所以看 Vue 这几次大版本的动作,重点不是"加了什么新东西",而是"为什么必须现在加、怎么让老项目少痛"。
3.1 Composition API 到底治的是什么病
Vue 2 的 Options API 把代码按"类型"分区:data 一块、methods 一块、computed 一块、生命周期一块。写小页面特别舒服,但写复杂组件就出问题了。我维护过一个三百多行的表单组件,一个"提交"功能涉及 data 里的字段、methods 里的三个函数、watch 里的校验、mounted 里的初始化,全散在不同区块,改一个需求要上下翻五遍。
社区早期的解法是 mixin,但 mixin 又带来新问题:属性来源不透明。组件里用到一个loading,你得去翻是哪几个 mixin 塞进来的;两个 mixin 定义了同名属性,谁覆盖谁完全靠引入顺序,这种隐式依赖在多人协作里就是灾难。
Composition API 的思路是按功能组织代码,而不是按选项类型:
// composables/useUserList.js import { ref, onMounted } from 'vue' export function useUserList(api) { const list = ref([]) const loading = ref(false) const error = ref(null) async function fetchList(params) { loading.value = true error.value = null try { list.value = await api.getUsers(params) } catch (e) { error.value = e } finally { loading.value = false } } onMounted(() => fetchList({ page: 1 })) return { list, loading, error, fetchList } }这段逻辑可以在任何组件里const { list, loading, fetchList } = useUserList(api),来源一目了然,复用靠的是普通函数而不是框架魔法,还能顺便测。我后来把项目里所有请求列表的逻辑都抽成了类似的 composable,重复代码少了差不多三成,而且排查问题时不用再去猜某个变量是谁塞的。
注意:Composition API 不是"更高级所以更好"。一个只有两个 prop、一个点击事件的展示组件,用 Options API 写反而更清楚。我一般的判断标准是:当一个组件的逻辑开始围绕"功能块"而不是"选项块"聚集时,就该拆 composable 了。
3.2 Vite 为什么能把冷启动从几十秒压到一秒内
Vite 解决的问题特别具体:项目大了以后,npm run dev要等半分钟甚至更久,改一行代码热更新又要等几秒,一天下来浪费的时间非常可观。
传统打包式开发服务器(比如早期的 Webpack Dev Server)必须先把整个应用的模块图构建完,把所有模块打成 bundle,才能启动服务。项目有几千个模块的时候,这个前置过程就是硬成本。
Vite 的做法是开发时不打包:
- 利用浏览器原生 ES Module。现代浏览器已经支持
import,所以 Vite 的 dev server 直接把源码以 ESM 形式返回,浏览器按需请求哪个模块就给哪个,没被访问到的页面代码根本不会被处理。 - 依赖预构建用 esbuild。第三方依赖(node_modules 里的那些)通常是 CommonJS 格式,而且一个 lodash 内部可能有几百个小文件。Vite 启动时用 Go 写的 esbuild 把这些依赖打包成单个 ESM 文件并缓存,这一步比 JS 写的打包器快一个数量级。
- 热更新只沿着模块边界走。改一个组件,只需要让这个模块及其引用链失效,浏览器重新请求这一个文件,不涉及整包重建。
配置起来也确实简单,一个后台项目的vite.config.js大概这样:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'node:path' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }, build: { rollupOptions: { output: { manualChunks: { vendor: ['vue', 'vue-router', 'pinia'], ui: ['element-plus'] } } } } })生产构建仍然用 Rollup(后面换成 Rust 写的 Rolldown),因为打包阶段要考虑 tree-shaking、代码分割、资源压缩,这些还是需要完整的静态分析。所以 Vite 的策略是开发用快的方式,生产用稳的方式,两者的产物行为对齐。
实测一个四百多个组件的中后台项目,冷启动从原来的四十多秒降到两秒以内,热更新基本在百毫秒级。这个体验差别用过就回不去了。
3.3 Vapor Mode:把虚拟 DOM 这一层也省掉
如果说 Vue 3 是在虚拟 DOM 上做优化,那 Vapor Mode 的思路就激进多了:编译期直接生成精确的 DOM 操作指令,运行时不再有虚拟 DOM 树。
原理其实前面已经埋了伏笔。既然编译器已经知道哪个节点是动态的、动态的是文本还是属性,那为什么不直接生成"创建这个元素、挂上去、内容变化时改这个文本节点"的代码呢?这样运行时就不用创建 VNode 对象、不用做 diff、不用维护两棵树,内存和耗时都能降下来。
// 概念示意:编译器针对 {{ count }} 生成的响应式绑定 import { effect } from 'vue/vapor' const el = document.createElement('span') effect(() => { el.textContent = count.value // 只更新这一处,没有 diff })代价也很明显:跨平台能力会被削弱,因为不再有中间的 VNode 树可以喂给别的渲染器;另外模板必须是编译期可见的,纯手写 render 函数的场景用不上。所以这不是要取代现有模式,而是给"纯浏览器端、性能敏感"的场景提供一个更快的选项。这种"保留旧路径、并行开新路径"的策略,其实也是框架演进时比较稳妥的做法。
4. 男神标签之外:一个开源项目怎么活下来
技术之外,这部分可能对更多人有用。因为大部分前端不会去写框架,但几乎所有人都会维护内部组件库、写工具包、做技术分享,这些事背后的运营逻辑是相通的。
4.1 开源项目的可持续性,钱和精力从哪来
一个全职维护的开源项目,最大敌人不是技术难题,是持续投入的能力。早期 Vue 靠赞助来维持,后来 Vue 和 Vite 相关的工具链逐渐汇入了公司化的组织形态(比如后来的 VoidZero 工具链团队),把 Rolldown、Oxc 这些底层项目放在一起做。这个变化背后是一个很现实的判断:零散赞助能养活一两个人,但养不起一个要跟商业公司拼性能的编译器团队。
对普通开发者的启示是:如果你想把开源当长期事业,得想清楚价值从哪兑现。常见路径有几种——靠赞助和捐赠、靠开源项目带来的咨询与培训、靠把核心能力产品化。三条路都有人在走,但都要求项目本身有足够大的使用面。反过来,如果你只是想通过开源提升技术能力和影响力,那目标可以朴素一点:解决一个自己真实遇到的问题,把文档写清楚,坚持维护两年以上,收益自然会出现。
4.2 文档、生态和"不做什么"的取舍
我看 Vue 的 RFC 流程学到一件事:重大改动先写提案,公开讨论,再动手。这个流程看起来很慢,但它把"社区为什么反对"这个问题提前暴露了,避免了写完再推倒。Vue 3 的 Composition API 当年争议非常大,正是因为 RFC 阶段的讨论足够充分,最后落地的方案才带上了<script setup>这种折中选项。
另一件事是明确不做什么。Vue 生态里有 Router、Pinia、测试工具、构建工具,但官方一直克制着不去做"全家桶里什么都管"的东西——比如表单方案、请求库、UI 组件库,基本都是社区在做。克制的好处是生态能繁荣,坏处是新手会纠结选型。
提示:如果你在做内部组件库,建议同样先想清楚边界。我见过一个团队把请求封装、权限、埋点、表单校验全塞进组件库,结果组件库每升一次级,业务侧就要跟着改一轮,最后没人敢升级。库的职责越单一,活得越久。
4.3 一个普通前端能抄走的成长路径
把这条路径抽象一下,其实就三步,我觉得挺可复制。
第一步,把一件事做到"有手感"。不是学完所有知识点,而是在一个具体场景里反复打磨,比如列表渲染性能、表单校验的抽象、组件通信的边界。我认识的很多技术好的人,都有一个自己被反复问到的"绝活领域"。
第二步,把解决方案沉淀成可复用的东西。一开始不需要做成开源项目,先在自己团队里做成 composable、工具函数、代码片段。写着写着你就会发现哪些设计真的通用,哪些只是当时凑合。
第三步,把过程写出来。这一步被严重低估。写文档、写分享、写复盘,逼着你把模糊的经验变成清晰的判断。而且这类内容本身就是你的作品集——比简历上写"熟悉 Vue 源码"有用得多。
5. 常见问题与避坑清单
这几年被问过最多的问题,基本集中在这几类。我整理成表,配上我自己踩过的坑,方便直接对照。
5.1 学 Vue 常见的三个认知误区
误区一:觉得响应式是"自动的",所以可以随便写。实际情况是,reactive解构之后就丢失响应式,这是最常见的 bug 之一。
const state = reactive({ count: 0 }) const { count } = state // 错误:count 是普通数字,后续更新视图不会变 const count2 = toRef(state, 'count') // 正确:保持引用 // 或者干脆用 ref const count3 = ref(0)误区二:把watch当成万能胶水。我见过一个组件里塞了七个 watch,互相触发,最后成了一个"改 A 动 B、B 又反过来改 A"的循环。能用computed推导出来的就不该用 watch 同步,computed有缓存、有依赖追踪、没有时序问题。
误区三:认为性能问题都是框架的锅。大部分渲染卡顿来自"一次更新触发了几千个组件的重新渲染"。这时候该做的是把大列表虚拟化、把频繁变化的状态往下推、把不相关的响应式数据拆出去,而不是急着换框架。
5.2 面试里 Vue 高频考点的答题思路
我面过也被人面过,说实话背答案是没用的,面试官一追问就露馅。比较稳的方式是讲清楚"它怎么实现的、为什么这么实现、边界在哪"。下面这张表是我总结的常见考点和答法方向。
| 高频考点 | 答不到位的说法 | 比较稳的答法方向 |
|---|---|---|
| Vue 2 和 Vue 3 响应式区别 | "Vue 3 用 Proxy 更快" | 从 defineProperty 的三个限制(新增属性、数组索引、深度递归)说起,再讲 Proxy 的拦截粒度和惰性代理 |
| diff 算法 | "就是双端比较" | 说明编译期 PatchFlag 如何缩小比较范围,key 的作用是让节点复用而不是重建 |
v-if和v-show | "一个销毁一个隐藏" | 补充初始渲染成本、切换成本、以及和v-for同时用时的优先级问题 |
| keep-alive 原理 | "缓存组件" | 讲 component 实例的缓存 Map、激活/停用钩子的触发时机、和路由多标签页的配合 |
| Vite 为什么快 | "用了 esbuild" | 分开讲开发态不打包、依赖预构建、HMR 边界三块,再说明生产为什么还是打包 |
5.3 实操踩坑速查表
下面这些是我和同事真实遇到过的问题,按现象、原因、解法整理。
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 数据改了视图不更新 | reactive被解构、数组整体替换、动态新增属性未走响应式 API | 用toRef/toRefs保留引用,或用ref包一层 |
| 组件缓存后数据不刷新 | keep-alive里的组件没有在onActivated里重新拉数据 | 把初始化逻辑从onMounted挪到onActivated,或加 key 强制重建 |
| 微前端子应用样式污染主应用 | 子应用样式没有加沙箱隔离 | 用 qiankun 的样式隔离配置,或子应用统一加前缀、用 CSS Module |
| 大文件上传卡死页面 | 直接在主线程读文件、算哈希 | 用 Web Worker 算哈希,分片上传并限制并发数 |
| 打包体积莫名变大 | 全量引入了组件库、重复打包了同一依赖 | 按需引入、配置manualChunks拆分 vendor |
| 开发环境接口 404 | 代理没配或路径重写不对 | 检查server.proxy的target和rewrite,注意前缀是否被重复拼接 |
关于"AI 时代前端的出路"这个话题,我个人的看法是:能做出来和能做好之间的差距在拉大。生成一个后台页面越来越容易,但知道为什么要做虚拟列表、为什么微前端要划分边界、为什么这个接口该做幂等,这些判断依然稀缺。把框架的"为什么"吃透,比多背几个 API 更抗周期。
最后分享一个我自己用了几年的小习惯:读框架源码时别从入口一路硬啃,先挑一个你天天用但说不清原理的 API(比如computed或者nextTick),顺着它把整条调用链走一遍,走完之后你对整个框架的感觉会完全不一样。这比看完一本源码解析书的效果都要直接。