Vue.js整体架构与设计理念
从 2.6.14 的目录划分入手:源码为什么按「跑在哪」切而不是按功能切,Vue 为什么是个函数不是 class,$mount 为什么被覆盖两次,以及 Vue 3 把这条边界画进了包名里。
你写的 template 没生效,因为这份 Vue 里没有编译器
拿 vue-cli 起一个 Vue 2 项目,把下面这段贴进 main.js:
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 templatecompiler is not available. Either pre-compile the templates into renderfunctions, 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.js 的
mountComponent 里,触发条件是 !vm.$options.render:核心根本不知道世上有「编译」这回事,
它只发现自己该执行的渲染函数是空的,于是拿 createEmptyVNode 兜底,再报一句警告。
所以页面不会崩,只会安静地渲染出一个空注释节点。
$mount 被覆盖了两次,编译器是最外面那一层
Vue.prototype.$mount 在 src/platforms/web/runtime/index.js 里定义了一次,四行:
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 存下来,再盖一个新的上去。
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 文件的壳。
编译到底在哪一步做了什么、运行时编译还有哪些别的代价,是模板编译那一篇的事。 这里只要一个结论:编译器和运行时是两个能拆开的东西,你在构建时就已经替它做了选择。
源码按「跑在哪」分目录,不是按功能分
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 共用的常量与工具core 和 platforms 之间只有一条线:core 里的渲染代码不直接调用任何 DOM API。
src/core/vdom/patch.js 导出的是一个 createPatchFunction(backend),建节点、插节点、删节点
用的全是 backend 里传进来的 nodeOps;浏览器那份 nodeOps 在 web/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-model和v-show。它们不在core里 —— 换个平台就得另写一份。 - 两个平台组件:
Transition和TransitionGroup。
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/vdom 加 platforms/web/runtime。
三块之间的接口很窄 —— 编译器交出一个渲染函数,响应式系统交出一句「该重渲染了」,
剩下的全是 vdom 的事。窄接口就是这套源码敢按平台切开的底气。
Vue 是个函数,不是 class
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 Vue,state.js 里那些方法就没地方待了。
第 12 行那句警告是另一个副作用。ES class 不带 new 调用是语法层面的 TypeError,
语言自己会拦;普通函数漏写 new 只会静默地把属性挂到别处,所以这里得自己补一句提示。
补的还只是提示 —— warn 之后没有 return,第 14 行的 this._init(options) 照样往下跑。
那十七个下划线短名值得单说一句:_v / _s / _l / _m 这些不是内部黑话,
是编译产物直接调用的函数。渲染函数里满屏的下划线,就是在这一步从原型上取到的。
_init 里那八行,是这个系列的装配线
Vue.prototype._init 的主体,去掉性能埋点和选项合并那一段之后是这样:
initLifecycle(vm); // $parent / $root / $children / $refsinitEvents(vm); // _events,以及父组件传下来的 listenersinitRender(vm); // $slots / $scopedSlots / $attrs / _ccallHook(vm, 'beforeCreate');initInjections(vm); // 源码注释:resolve injections before data/propsinitState(vm); // props → methods → data → computed → watchinitProvide(vm); // 源码注释:resolve provide after data/propscallHook(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 里的这一步 |
它做的事 | 展开在哪一篇 |
|---|---|---|
initState → initData |
遍历 data,每个 key 装一对 getter/setter |
响应式系统核心原理 |
initState → initComputed / 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 里记下来的。initState 把 data 走一遍装上 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/ 底下:
compiler-core 与平台无关的模板编译compiler-dom 浏览器那部分编译compiler-sfc .vue 单文件的解析与编译compiler-ssr 为服务端渲染生成产物reactivity 响应式,只依赖 @vue/sharedruntime-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 就是那个「别人」:
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:
// npm i @vue/reactivityimport { 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 响应式系统核心原理。