Vue.js整体架构与设计理念

从 2.6.14 的目录划分入手:源码为什么按「跑在哪」切而不是按功能切,Vue 为什么是个函数不是 class,$mount 为什么被覆盖两次,以及 Vue 3 把这条边界画进了包名里。

位置
第 02 篇 / 共 12 篇
预计
14 分钟

你写的 template 没生效,因为这份 Vue 里没有编译器

拿 vue-cli 起一个 Vue 2 项目,把下面这段贴进 main.js

main.js · npm 装的 Vue 2
import Vue from 'vue';
new Vue({
el: '#app',
template: '<div>{{ msg }}</div>',
data: { msg: 'hi' }
});

页面上什么都没有,控制台里是这一句:

开发构建下的控制台输出
[Vue warn]: You are using the runtime-only build of Vue where the template
compiler is not available. Either pre-compile the templates into render
functions, or use the compiler-included build.

同一段代码,改成从 CDN 引 <script src="https://unpkg.com/vue@2.6.14"></script> 就正常渲染。 代码一个字没改,差别在 package.json 里:

字段 指向 谁在读它
main dist/vue.runtime.common.js Node 和打包器的 CommonJS 解析
module dist/vue.runtime.esm.js 打包器优先读的 ESM 入口
unpkg / jsdelivr dist/vue.js CDN

前两个是运行时版本,第三个是完整版。同一个 npm 包,对打包器和对 CDN 给的是两份不同的 Vue —— 「为什么我本地写 template 好好的,装进项目就白屏」这类问题,源头就是这三行。

(下面的路径都以 Vue 2.6.14 为准。2.7 起源码整个换成了 TypeScript,同名文件的后缀是 .ts。 Vue 3 的部分单独标版本。)

那句警告出现的位置本身也是一条线索。它不在 $mount 里,在 src/core/instance/lifecycle.jsmountComponent 里,触发条件是 !vm.$options.render:核心根本不知道世上有「编译」这回事, 它只发现自己该执行的渲染函数是空的,于是拿 createEmptyVNode 兜底,再报一句警告。 所以页面不会崩,只会安静地渲染出一个空注释节点。

$mount 被覆盖了两次,编译器是最外面那一层

Vue.prototype.$mountsrc/platforms/web/runtime/index.js 里定义了一次,四行:

Vue 2 · platforms/web/runtime/index.js 里的 $mount
Vue.prototype.$mount = function (el, hydrating) {
el = el && inBrowser ? query(el) : undefined;
return mountComponent(this, el, hydrating);
};

它只把选择器字符串换成 DOM 元素,然后交给 mountComponent。运行时版本到此为止, 入口文件 entry-runtime.js 全文只有一句 import Vue from './runtime/index' 加一句导出。

完整版的入口多干了一件事:把上面这个 $mount 存下来,再盖一个新的上去。

Vue 2 · entry-runtime-with-compiler.js(节选)
const mount = Vue.prototype.$mount; // 先把 runtime 那份存下来
Vue.prototype.$mount = function (el, hydrating) {
el = el && query(el);
// 省略:el 是 <body> 或 <html> 时警告一句,然后 return this
const options = this.$options;
// resolve template/el and convert to render function
if (!options.render) {
let template = options.template;
if (template) {
// 省略:字符串以 # 开头就取那个元素的 innerHTML;是 DOM 节点就取 innerHTML
} else if (el) {
template = getOuterHTML(el); // 没写 template,就把 el 那个元素连自己一起当模板
}
if (template) {
const { render, staticRenderFns } = compileToFunctions(template, {
// 省略:outputSourceRange、delimiters、comments 等五个选项
}, this);
options.render = render;
options.staticRenderFns = staticRenderFns;
}
}
return mount.call(this, el, hydrating); // 编译完,交回给 runtime 那份 $mount
};
Vue.compile = compileToFunctions;

新的 $mount 只负责「凑出一个 options.render」,凑好之后调 mount.call(this, ...) 回到老的那份。 编译器不是运行时的一部分,是套在运行时外面的一层壳 —— 这个结构决定了两件事: 去掉这层壳,剩下的运行时是完整可用的;而 Vue.compile 这个方法是在这个文件的末尾挂上去的, 运行时版本里它压根不存在。

那个 else if (el) 分支还解释了一个日常现象:什么模板都不写,只给一个 el,页面照样渲染。 因为 getOuterHTML(el) 把那个元素自己当成了模板 —— 这就是 in-DOM 模板。

graph LR
A[模板编译] -->|构建时| B[优化的渲染函数]
A -->|运行时| C[动态渲染函数]
B --> D[更好的性能]
C --> E[更大的灵活性]

图上这两条路的分岔点,就是构建时你的打包器解析到了哪个文件。代价写在体积上:

dist 里的文件 带编译器 2.6.14 发布的字节数
vue.js 344,009
vue.runtime.js 240,172
vue.min.js 94,151
vue.runtime.min.js 65,270

压不压缩都是差三成左右,和 Vue 2 官方文档 installation 页那句 「roughly 30% lighter-weight」对得上(这是未 gzip 的字节数)。 dist/ 底下一共十四个 .js 文件,其中 vue.common.js 只有 157 字节、vue.runtime.common.js 只有 173 字节 —— 这两个不是构建产物,是按 process.env.NODE_ENV 转手 require 对应 dev / prod 文件的壳。

编译到底在哪一步做了什么、运行时编译还有哪些别的代价,是模板编译那一篇的事。 这里只要一个结论:编译器和运行时是两个能拆开的东西,你在构建时就已经替它做了选择。

源码按「跑在哪」分目录,不是按功能分

Vue 2.6.14 · src/
src/
├── compiler/ 模板 → AST → render 函数字符串
├── core/ 平台无关的内核
│ ├── instance/ Vue 构造函数、_init、生命周期
│ ├── observer/ 响应式:Observer / Dep / Watcher
│ ├── vdom/ VNode 与 patch
│ ├── global-api/ Vue.use / Vue.mixin / Vue.extend / Vue.component
│ ├── components/ 内置组件,只有 KeepAlive 一个
│ └── util/
├── platforms/
│ ├── web/ 浏览器:五个入口文件、patch 的 DOM 实现、平台指令
│ └── weex/ 原生渲染
├── server/ 服务端渲染
├── sfc/ 只有一个 parser.js,把 .vue 拆成三块
└── shared/ core 和 compiler 共用的常量与工具

coreplatforms 之间只有一条线:core 里的渲染代码不直接调用任何 DOM API。 src/core/vdom/patch.js 导出的是一个 createPatchFunction(backend),建节点、插节点、删节点 用的全是 backend 里传进来的 nodeOps;浏览器那份 nodeOpsweb/runtime/node-ops.js, 由 platforms/web/runtime/patch.js 一行接上:createPatchFunction({ nodeOps, modules })

平台那一侧要装的东西装在 platforms/web/runtime/index.js 里,一次四类:

  • Vue.prototype.__patch__ = inBrowser ? patch : noop —— 把 VNode 落到真实 DOM 上的那个函数。 非浏览器环境直接是空函数,这是同一份 core 能跑在服务端的原因。
  • Vue.config 上的五个平台判定函数:mustUseProp / isReservedTag / isReservedAttr / getTagNamespace / isUnknownElement。「div 是标签还是组件」这种问题,core 自己答不上来。
  • 两个平台指令:v-modelv-show。它们不在 core 里 —— 换个平台就得另写一份。
  • 两个平台组件:TransitionTransitionGroup

core 那边只有一个内置组件 KeepAlive,因为缓存组件实例这件事和渲染到哪儿无关。 一个指令归 core 还是归 platform,判据就是这条:它需不需要知道自己在往什么东西上写。

graph TB
subgraph "编译时 Compiler"
A[Template 模板] --> B[Parser 解析器]
B --> C[AST 抽象语法树]
C --> D[Optimizer 优化器]
D --> E[Code Generator 代码生成器]
E --> F[Render Function 渲染函数]
end
subgraph "运行时 Runtime"
G[Vue Instance Vue实例] --> H[Observer 观察者]
H --> I[Dep 依赖管理]
I --> J[Watcher 观察器]
J --> K[Virtual DOM 虚拟DOM]
K --> L[Diff & Patch 差异对比与更新]
L --> M[Real DOM 真实DOM]
F --> K
G --> N[Component 组件系统]
N --> G
end
subgraph "响应式系统 Reactivity"
O[Data 数据] --> H
J --> P[Update Queue 更新队列]
P --> K
end

图上三块在源码里各有落点:编译时那一列是 src/compiler,响应式那一块是 src/core/observer, 中间的 VNode 到真实 DOM 是 src/core/vdomplatforms/web/runtime。 三块之间的接口很窄 —— 编译器交出一个渲染函数,响应式系统交出一句「该重渲染了」, 剩下的全是 vdom 的事。窄接口就是这套源码敢按平台切开的底气。

Vue 是个函数,不是 class

src/core/instance/index.js 全文二十三行,是整个框架的装配现场:

Vue 2 · src/core/instance/index.js(全文)
import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'
function Vue (options) {
if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)
) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
export default Vue

构造函数体只有一句有用的:this._init(options)。剩下的全在文件末尾那五行, 五个 mixin 依次往 Vue.prototype 上挂东西:

mixin 文件 挂上去的
initMixin init.js _init
stateMixin state.js $data$props(只读 getter)、$set / $delete / $watch
eventsMixin events.js $on / $once / $off / $emit
lifecycleMixin lifecycle.js _update / $forceUpdate / $destroy
renderMixin render.js $nextTick_render,外加 installRenderHelpers 一口气装的十七个下划线短名

不用 class 的理由就摆在这张表里:一个 class 的方法只能写在一个类体里,也就是一个文件里。 这五组方法分属响应式、事件、生命周期和渲染四件事,各自几百行,拆成五个文件之后才谈得上分开维护。 换成 class Vuestate.js 里那些方法就没地方待了。

第 12 行那句警告是另一个副作用。ES class 不带 new 调用是语法层面的 TypeError, 语言自己会拦;普通函数漏写 new 只会静默地把属性挂到别处,所以这里得自己补一句提示。 补的还只是提示 —— warn 之后没有 return,第 14 行的 this._init(options) 照样往下跑。

那十七个下划线短名值得单说一句:_v / _s / _l / _m 这些不是内部黑话, 是编译产物直接调用的函数。渲染函数里满屏的下划线,就是在这一步从原型上取到的。

_init 里那八行,是这个系列的装配线

Vue.prototype._init 的主体,去掉性能埋点和选项合并那一段之后是这样:

Vue 2 · _init 的主体(src/core/instance/init.js)
initLifecycle(vm); // $parent / $root / $children / $refs
initEvents(vm); // _events,以及父组件传下来的 listeners
initRender(vm); // $slots / $scopedSlots / $attrs / _c
callHook(vm, 'beforeCreate');
initInjections(vm); // 源码注释:resolve injections before data/props
initState(vm); // props → methods → data → computed → watch
initProvide(vm); // 源码注释:resolve provide after data/props
callHook(vm, 'created');
if (vm.$options.el) {
vm.$mount(vm.$options.el);
}

第 5 行和第 7 行那两句注释是源码里原本就有的,它俩把 initState 夹在中间的原因说得很清楚: inject 要在 data / props 之前拿到(否则 data() 里读不到注入的值),provide 要在之后 (否则提供出去的值里还没有 data)。哪个钩子里能拿到什么,是组件系统那一篇的题目。

末尾那三行解释了一个常被当成两种写法的东西:el 选项和手动 $mount 是同一件事, 写了 el 只是让 _init 在最后替你调一次。

这八行也是这个系列剩下部分的目录 —— 每一步展开都是一整篇:

_init 里的这一步 它做的事 展开在哪一篇
initStateinitData 遍历 data,每个 key 装一对 getter/setter 响应式系统核心原理
initStateinitComputed / initWatch 造 Watcher,读一次就把依赖记下来 依赖收集与追踪机制
initRender 挂上 _c,渲染函数的产物是一棵 VNode 树 Virtual DOM 实现详解
$mount(完整版那一层) 模板变成渲染函数 模板编译全流程
mountComponent 造的渲染 watcher 每次重渲染比一遍新旧 VNode Diff 算法深度剖析
watcher 收到通知后不立刻执行 进队列,nextTick 时统一 flush 异步更新与 nextTick
initLifecycle / initEvents $parent 链、_events 表、父组件传下来的 listeners 组件系统架构

顺着这张表读下去,每一篇都在拆其中一行。

Vue 和 React 分岔在「谁知道该更新什么」

graph TB
subgraph "Vue.js架构"
V1[模板/Template] --> V2[编译器/Compiler]
V2 --> V3[渲染函数/Render]
V3 --> V4[Virtual DOM]
V5[响应式数据/Reactive] --> V6[自动依赖追踪]
V6 --> V3
end
subgraph "React架构"
R1[JSX] --> R2[Babel转换]
R2 --> R3[React.createElement]
R3 --> R4[Virtual DOM]
R5[State/Props] --> R6[手动setState]
R6 --> R7[重新渲染]
R7 --> R4
end

两条链最后都走到 Virtual DOM,分开的地方在起点:左边那条从数据出发,右边那条从组件出发。

Vue 的依赖是在 getter 里记下来的。initStatedata 走一遍装上 getter/setter, 渲染函数执行时读到哪个属性,那个属性就把当前的渲染 watcher 收进自己的订阅表。 改一个属性,被通知的只有订阅过它的那几个 watcher,没订阅的组件框架碰都不碰。

React 不记这些。state 变了,这个组件的函数重新执行一遍,产出新的元素树,再和上一棵比。 它不需要知道你读过哪个字段,它知道的是「这个组件要重跑」。

两边的账记在不同地方。Vue 记在初始化那一遍遍历上:data 有多少个 key 就装多少对 getter/setter,对象有多深就递归多深(Vue 3 换成 Proxy 之后变成惰性的,读到哪层才代理哪层)。 React 记在更新那一侧:组件重跑一遍是常态,想少跑就得在组件边界上另做文章。

还有一条差别落在编译期。模板的写法是有限的,编译器在构建时就能看出一个节点上哪个属性是绑定的, 把结论直接写进产物(Vue 3 的 PatchFlag,模板编译那一篇讲)。JSX 是普通 JavaScript, 编译器拿不到同等的静态信息。用 JSX 换来的是表达能力,换掉的正是这一层。

Vue 3 把这条边界画进了包名里

Vue 2 靠目录分模块,Vue 3 直接分成了包。3.5 的 packages/ 底下:

Vue 3.5 · packages/
compiler-core 与平台无关的模板编译
compiler-dom 浏览器那部分编译
compiler-sfc .vue 单文件的解析与编译
compiler-ssr 为服务端渲染生成产物
reactivity 响应式,只依赖 @vue/shared
runtime-core 与平台无关的渲染内核
runtime-dom 浏览器渲染器
runtime-test 测试用渲染器
server-renderer 服务端渲染
shared 公用工具
vue 把上面这些打包成一个面向用户的包
vue-compat Vue 2 兼容构建

runtime-core 的 README 开头就把自己的定位写死了: 「This package is published only for typing and building custom renderers. It is NOT meant to be used in applications.」它不知道 DOM 的存在,只导出一个 createRenderer, 等着别人把平台能力注入进来。runtime-dom 就是那个「别人」:

Vue 3.5 · runtime-dom/src/index.ts(节选)
const rendererOptions = extend({ patchProp }, nodeOps);
// lazy create the renderer - this makes core renderer logic tree-shakable
// in case the user only imports reactivity utilities from Vue.
let renderer;
function ensureRenderer() {
return renderer || (renderer = createRenderer(rendererOptions));
}
export const render = ((...args) => {
ensureRenderer().render(...args);
});
export const createApp = ((...args) => {
const app = ensureRenderer().createApp(...args);
// 省略:开发环境的两处校验,以及对 app.mount 的包装
return app;
});

注入进去的只有两样东西:nodeOps(建节点、插入、删除这类 DOM 操作)和 patchProp(属性怎么写)。 把这两样换成别的,同一个 runtime-core 就能渲染到别的地方去 —— Vue 2 里要新开一个 platforms/ 目录才能做的事,Vue 3 变成了传一个参数。

那两行注释还顺带说出了这套划分真正的收益:渲染器是懒创建的,只用响应式不用渲染的话, 整个渲染内核会被打包器摇掉。这不是理论上的可能,@vue/reactivity 是单独发布的包, dependencies 里只有一个 @vue/shared。装它,不用装 Vue:

不装 vue,只装 @vue/reactivity
// npm i @vue/reactivity
import { reactive, computed, effect } from '@vue/reactivity';
const cart = reactive({ items: [{ price: 10, n: 2 }] });
const total = computed(() => cart.items.reduce((s, i) => s + i.price * i.n, 0));
effect(() => console.log('总价', total.value));
cart.items[0].n = 3;

跑起来是两行:

输出
总价 20
总价 30

第一行来自 effect 自己 —— 它构造完就同步跑一遍传进去的函数,那一遍同时把依赖收齐了。 第二行来自最后那次赋值:改的是数组里某个对象的一个字段,computed 因此失效,effect 重跑。 从头到尾没有组件、没有模板、没有 DOM。

代价在 README 的同一段里写着:单独装的这份不能和已经打包好的渲染器构建混用, 两边的响应式连接存在各自的内部存储里,混用的症状是数据改了视图不动,而且不报任何错。 另外它只观测 Array / Map / WeakMap / Set / WeakSet 这几种内置对象,别的内置类型不管。

至于 reactive 到底把那个对象改成了什么样、effect 里那次读取又是怎么被记下来的 —— 那是响应式系统和依赖收集两篇要拆的东西。

这篇是 Vue.js 内部机制深度解析的第 2 篇。前一篇是 Vue.js 内部机制深度解析系列,后一篇是 Vue.js 响应式系统核心原理