Vue 3 设计哲学:Tree-shakable 架构、跨平台渲染器与类型优先
第3章:Vue 3 设计哲学——Tree-shakable 架构、跨平台渲染器与类型优先
Vue 3 的核心运行时如果只用
ref+computed,打包后 gzip 体积约 10KB。用完整特性,约 22KB。Vue 2 运行时约 23KB,且无论你用多少特性都是这个体积。这 13KB 的差距不是压缩出来的,是架构设计出来的。
本章核心问题:Vue 3 的哪些设计决策造就了这个体积差异?这些决策对实际开发有什么影响?
读完本章你将理解:
- Tree-shaking 为什么在 Vue 2 中无法工作,Vue 3 的架构如何使其成为可能
- Vue 3 的渲染器抽象层设计,以及如何用它渲染到非 DOM 环境
- TypeScript 优先的 API 设计如何影响使用体验:
defineProps<T>()、InjectionKey<T>背后的设计意图
Level 1 · 你需要知道的(1-3年经验)
3.1 Tree-shaking 的前提:模块化
Tree-shaking(摇树优化)是打包工具(webpack、Rollup、Vite)的一项功能:分析代码的导入导出关系,移除从未被使用的代码。这个功能有一个关键前提:代码必须使用 ES Module 的静态导入(import/export),而不是动态的 CommonJS(require())。
ES Module 的静态性让打包工具可以在编译时(不运行代码的情况下)构建出完整的模块依赖图,从而知道哪些代码路径永远不会被执行。
Vue 2 为什么无法 Tree-shake?
Vue 2 的所有 API 都挂在 Vue 这个全局构造函数的原型上,或者作为静态方法:
// Vue 2 的使用方式
import Vue from 'vue'; // 导入整个 Vue
Vue.component('MyComponent', {}); // 全局注册
Vue.filter('currency', val => '$' + val); // 全局过滤器
Vue.mixin({ /* ... */ }); // 全局 mixin
const app = new Vue({ /* ... */ });
当打包工具看到 import Vue from 'vue' 时,它必须包含整个 Vue 运行时——因为它无法知道你的代码会调用哪些 Vue.xxx 方法。即使你只用了 new Vue({}) 和数据绑定,Vue.component、Vue.filter、Vue.mixin 的代码也全部打包进去了。
Vue 3 的解决方案:命名导出
// Vue 3 的使用方式
import { createApp, ref, computed, watch } from 'vue';
// 打包工具知道:只导入了这四个 API
// 其他未导入的 API(如 Transition、KeepAlive、Teleport)不会打包
const app = createApp(/* ... */);
通过命名导出,每个 API 都是一个独立的导出符号,打包工具可以精确追踪哪些符号被使用了,哪些没有。
3.2 体积对比的实际数字
在一个只使用 ref、computed、watch 和基础模板语法的项目中:
| 框架 | 运行时体积(gzip) | 说明 |
|---|---|---|
| Vue 2.7 | ~23KB | 无论使用多少特性都是这个体积 |
| Vue 3(完整) | ~22KB | 使用所有内置组件和所有 API |
| Vue 3(仅响应式核心) | ~10KB | 只用 ref/reactive/computed |
| Petite-vue | ~6KB | 基于 Vue 3 响应式,面向简单场景 |
这些数字来自 bundlephobia 和 Vue 官方文档(2023年数据)。实际项目体积取决于你用到了哪些特性。
3.3 渲染器:Vue 的平台抽象层
Vue 3 的渲染器被设计成平台无关的。核心渲染逻辑(虚拟 DOM diff 算法、组件更新机制)在 @vue/runtime-core 中实现;平台相关的 DOM 操作在 @vue/runtime-dom 中实现。
这个架构让同一个 Vue 渲染器可以渲染到不同平台:
// DOM 渲染(官方 @vue/runtime-dom)
import { createApp } from 'vue';
createApp(App).mount('#app');
// Canvas 渲染(三方库 vue-pixi-renderer 的思路)
import { createRenderer } from '@vue/runtime-core';
const { createApp } = createRenderer({
createElement: (type) => new PIXI.Container(),
insert: (el, parent) => parent.addChild(el),
setElementText: (el, text) => el.text = text,
// ...其他 nodeOps
});
// 测试环境(@vue/test-utils 内部的 mock renderer)
// 不需要真实 DOM,可以在 Node.js 中运行
你不需要了解 WebGL 或 Canvas API 的细节,就能看到这个设计的价值:Vue 的组件化模型、响应式系统、生命周期管理是通用的,只需要给它提供不同的"绘制指令",它就能在不同的环境中工作。
3.4 类型优先的 API 设计
Vue 3 的 TypeScript 集成不是事后追加的——它是从设计阶段就内置的。最直观的体现是两个 API 的设计:
defineProps<T>() 的类型推断:
// <script setup>
// 有类型的 props,完整的 IDE 自动补全和类型检查
interface Props {
name: string;
age?: number;
role: 'admin' | 'user';
}
const props = defineProps<Props>();
// TypeScript 知道:
// props.name: string ✓
// props.age: number | undefined ✓
// props.role: 'admin' | 'user' ✓
// props.xyz // 类型错误!Property 'xyz' does not exist on type 'Props'
InjectionKey<T> 让 provide/inject 有类型:
// 没有 InjectionKey 的写法(类型不安全)
provide('theme', { color: 'red' });
const theme = inject('theme'); // 类型是 unknown
// 使用 InjectionKey 的写法(类型安全)
import type { InjectionKey } from 'vue';
interface Theme {
color: string;
dark: boolean;
}
const themeKey: InjectionKey<Theme> = Symbol('theme');
// 在父组件
provide(themeKey, { color: 'red', dark: false });
// 在子组件
const theme = inject(themeKey);
// TypeScript 知道 theme 的类型是 Theme | undefined
console.log(theme?.color); // 类型安全的访问
InjectionKey 是一个利用 TypeScript 泛型参数携带类型信息的技巧:Symbol 本身没有类型参数,但 InjectionKey<T> 是一个被 T 参数化的 Symbol 的类型别名。当你用 InjectionKey<Theme> 调用 inject() 时,TypeScript 从这个 Symbol 类型中提取出 Theme 类型。
3.5 Vite 与 Vue 3 的协同
Vite 和 Vue 3 都由 Evan You 主导开发,两者在设计层面高度协同:
ESM 原生支持:
Vue 3 的 SFC(单文件组件)编译输出是纯 ES Module,Vite 在开发模式下直接让浏览器加载 ES Module(而不是打包),每个 .vue 文件作为独立的模块。这让开发服务器启动时间不随项目规模增加(因为只按需加载,不全量打包)。
HMR 粒度:
Vite 的 HMR 在 .vue 文件变化时,只替换发生变化的部分(script/template/style),保持组件状态不丢失。这需要 @vue/compiler-sfc 输出格式与 Vite 的 HMR 协议紧密配合。
生产打包: Vite 生产模式使用 Rollup 打包,Rollup 的 Tree-shaking 能力最强(基于 ES Module 静态分析)。Vue 3 的命名导出设计与 Rollup 的 Tree-shaking 完美配合。
Level 2 · 它是怎么运行的(3-5年经验)
3.6 Tree-shaking 的技术细节
Tree-shaking 工作的两个必要条件:
条件一:ES Module 静态导入
// 可以 tree-shake:静态导入,打包时确定
import { ref } from 'vue';
// 无法 tree-shake:动态导入,运行时才确定
const { ref } = await import('vue');
const { ref } = require('vue'); // CommonJS
const api = 'ref'; import(`vue`).then(m => m[api]); // 动态 key
条件二:无副作用(Side-effect free)
即使是静态导入,如果模块在被导入时会执行有副作用的代码,打包工具就不敢删除它:
// 有副作用的模块(打包工具必须保留)
// side-effects.js
console.log('加载中...'); // 这行代码在模块被导入时执行
window.globalVar = 1; // 修改全局状态
// 无副作用的模块(可以安全 tree-shake)
// pure.js
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
Vue 3 的大部分代码是无副作用的纯函数,并且在 package.json 中声明了 "sideEffects": false(或列出有副作用的文件),告诉打包工具"这个包可以被 tree-shake"。
Tree-shaking 工作流程:
入口文件 main.js
│
├── import { ref, watch } from 'vue'
│ │
│ ├── 包含:ref 的实现代码
│ ├── 包含:watch 的实现代码
│ │
│ └── 不包含:Transition、KeepAlive、Teleport 等未导入的 API
│
└── import App from './App.vue'
打包结果:
┌──────────────────────────────────────────┐
│ ref 实现 + watch 实现 + App 组件代码 │
│ │
│ ✗ Transition(未导入,被摇掉) │
│ ✗ KeepAlive(未导入,被摇掉) │
│ ✗ Teleport(未导入,被摇掉) │
└──────────────────────────────────────────┘
3.7 渲染器抽象层的架构
Vue 3 的渲染器抽象层基于 RendererOptions 接口,定义了所有需要平台实现的操作:
// packages/runtime-core/src/renderer.ts(简化版)
export interface RendererOptions<HostNode, HostElement> {
// DOM 元素操作
createElement(type: string, isSVG?: boolean): HostElement;
createText(text: string): HostNode;
createComment(text: string): HostNode;
// 文本操作
setText(node: HostNode, text: string): void;
setElementText(el: HostElement, text: string): void;
// DOM 树操作
insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void;
remove(el: HostNode): void;
// 属性操作
patchProp(
el: HostElement,
key: string,
prevValue: any,
nextValue: any,
isSVG?: boolean
): void;
// 其他
parentNode(node: HostNode): HostElement | null;
nextSibling(node: HostNode): HostNode | null;
querySelector(selector: string): HostElement | null;
}
createRenderer() 函数接受这个接口的实现,返回 createApp 等方法:
// 使用 createRenderer 创建自定义渲染器
import { createRenderer } from '@vue/runtime-core';
// 假设我们要渲染到一个虚构的 Canvas API
const canvasRenderer = createRenderer({
createElement(type) {
// 创建 Canvas 绘图对象
return new CanvasNode(type);
},
insert(el, parent) {
parent.children.push(el);
parent.invalidate(); // 标记需要重绘
},
patchProp(el, key, prev, next) {
el.props[key] = next;
el.invalidate();
},
// ... 实现其他方法
});
const { createApp } = canvasRenderer;
// 现在可以用 Vue 的组件模型,渲染到 Canvas
createApp(App).mount(canvasRoot);
这个架构在 Vue 生态中有几个重要应用:
- @vue/server-renderer:服务端渲染,渲染到 HTML 字符串
- @vue/test-utils:测试工具,在 Node.js 中模拟 DOM 操作
- NativeScript-Vue:渲染到原生 iOS/Android 控件
- vue-pixi-renderer:渲染到 Pixi.js(WebGL 2D 渲染引擎)
3.8 编译时优化的完整图景
Vue 3 的"把运行时工作移到编译时"策略有三个具体机制:
机制一:静态提升(Static Hoisting)
// 模板
// <div class="container">
// <p class="static">永远不变的文本</p>
// <p>{{ dynamicText }}</p>
// </div>
// 未优化(每次渲染都创建所有 VNode)
render() {
return createVNode('div', { class: 'container' }, [
createVNode('p', { class: 'static' }, '永远不变的文本'),
createVNode('p', null, ctx.dynamicText)
]);
}
// 优化后(静态节点提升到 render 函数外部,只创建一次)
const _hoisted_1 = createVNode('p', { class: 'static' }, '永远不变的文本');
// ↑ 模块级别,只创建一次
render() {
return createVNode('div', { class: 'container' }, [
_hoisted_1, // 直接复用,不重新创建
createVNode('p', null, ctx.dynamicText)
]);
}
机制二:PatchFlag 标记
// 模板:<div :class="cls" :style="style" @click="onClick">{{ text }}</div>
// 编译后(PatchFlag 告知运行时只检查 class、style、text,跳过 onClick)
createElementVNode(
"div",
{
class: _ctx.cls,
style: _ctx.style,
onClick: _ctx.onClick
},
_toDisplayString(_ctx.text),
// PatchFlags.CLASS | PatchFlags.STYLE | PatchFlags.TEXT = 1 | 2 | 4 = 7
7 /* CLASS, STYLE, TEXT */
)
// 事件 onClick 不在 PatchFlag 中(因为它是稳定的,用 cacheHandlers 缓存)
机制三:Block Tree 结构
// 模板:
// <div>
// <p>静态</p>
// <p>{{ a }}</p>
// <p :class="b">文字</p>
// </div>
// 传统 diff:对整棵树做深度遍历比较
// Block Tree diff:div 是 block,记录了它所有的"动态后代"
// 更新时只遍历 dynamicChildren 数组,不遍历静态节点
// 编译后:
createElementBlock('div', null, [
createElementVNode('p', null, '静态'), // 静态,被追踪但不在 dynamicChildren
createElementVNode('p', null, _ctx.a, 1 /* TEXT */), // 在 dynamicChildren
createElementVNode('p', { class: _ctx.b }, '文字', 2 /* CLASS */) // 在 dynamicChildren
])
// 运行时的 patchBlockChildren 只对 [p(a), p(b)] 做比较
// 完全跳过静态的 <p>静态</p>
Level 3 · 设计文档与源码(资深开发者)
3.9 @vue/runtime-core 的核心渲染逻辑
Vue 3 渲染器的核心入口在 packages/runtime-core/src/renderer.ts,createRenderer() 函数内部定义了完整的 patch 算法:
// packages/runtime-core/src/renderer.ts(核心片段)
function baseCreateRenderer(options: RendererOptions): Renderer {
// 从 options 解构出平台相关操作
const {
insert: hostInsert,
remove: hostRemove,
patchProp: hostPatchProp,
createElement: hostCreateElement,
createText: hostCreateText,
// ...
} = options;
// patch 是渲染器的核心分发函数
const patch: PatchFn = (
n1, // 旧 VNode(null 表示首次挂载)
n2, // 新 VNode
container,
anchor = null,
parentComponent = null,
// ...
) => {
if (n1 === n2) return; // 相同节点,直接跳过
// 类型不同,直接卸载旧节点,挂载新节点
if (n1 && !isSameVNodeType(n1, n2)) {
unmount(n1, parentComponent, parentSuspense, true);
n1 = null;
}
const { type, ref, shapeFlag } = n2;
// 根据 VNode 类型分发处理
switch (type) {
case Text:
processText(n1, n2, container, anchor);
break;
case Comment:
processCommentNode(n1, n2, container, anchor);
break;
case Static:
if (n1 == null) mountStaticNode(n2, container, anchor);
break;
case Fragment:
processFragment(/* ... */);
break;
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container, anchor, parentComponent, /* ... */);
} else if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(n1, n2, container, anchor, parentComponent, /* ... */);
} else if (shapeFlag & ShapeFlags.TELEPORT) {
type.process(/* ... */);
} else if (shapeFlag & ShapeFlags.SUSPENSE) {
type.process(/* ... */);
}
}
};
return {
render, // 渲染到容器
hydrate, // SSR 水合
createApp // 创建应用实例
};
}
3.10 patchElement 中的 PatchFlag 优化路径
// packages/runtime-core/src/renderer.ts
const patchElement = (
n1: VNode,
n2: VNode,
parentComponent: ComponentInternalInstance | null,
// ...
) => {
const el = (n2.el = n1.el)!;
const { patchFlag, dynamicChildren, dirs } = n2;
// Block Tree 优化:如果有 dynamicChildren,只 patch 动态子节点
if (dynamicChildren) {
patchBlockChildren(n1.dynamicChildren!, dynamicChildren, el, parentComponent, /* ... */);
} else if (!optimized) {
// Full diff(没有 PatchFlag 的情况,兜底方案)
patchChildren(n1, n2, el, null, parentComponent, /* ... */);
}
// 处理 props
if (patchFlag > 0) {
// 有 PatchFlag:只检查标记的部分
if (patchFlag & PatchFlags.FULL_PROPS) {
patchProps(el, n2, oldProps, newProps, parentComponent, /* ... */);
} else {
if (patchFlag & PatchFlags.CLASS) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, 'class', null, newProps.class);
}
}
if (patchFlag & PatchFlags.STYLE) {
hostPatchProp(el, 'style', oldProps.style, newProps.style);
}
if (patchFlag & PatchFlags.PROPS) {
const propsToUpdate = n2.dynamicProps!;
for (let i = 0; i < propsToUpdate.length; i++) {
const key = propsToUpdate[i];
const prev = oldProps[key];
const next = newProps[key];
if (next !== prev || key === 'value') {
hostPatchProp(el, key, prev, next, /* ... */);
}
}
}
}
if (patchFlag & PatchFlags.TEXT) {
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string);
}
}
} else if (!optimized && dynamicChildren == null) {
// 无 PatchFlag,全量比较(兜底)
patchProps(el, n2, oldProps, newProps, parentComponent, /* ... */);
}
};
这段代码清晰地展示了 PatchFlag 如何把 O(所有属性) 的对比降低到 O(动态属性数量) 的对比。
3.11 InjectionKey 的类型系统实现
InjectionKey<T> 是一个精妙的 TypeScript 类型技巧,理解它需要知道 TypeScript 的"名义类型"和"结构类型":
// packages/runtime-core/src/apiInject.ts
export interface InjectionKey<T> extends Symbol {}
// provide 函数的类型签名
export function provide<T>(key: InjectionKey<T> | string | number, value: T): void;
// inject 函数的类型签名(重载)
export function inject<T>(key: InjectionKey<T> | string): T | undefined;
export function inject<T>(key: InjectionKey<T> | string, defaultValue: T, treatDefaultAsFactory?: false): T;
InjectionKey<T> 继承自 Symbol,但添加了类型参数 T。TypeScript 的泛型系统会追踪这个 T:当你把 InjectionKey<Theme> 传给 inject() 时,TypeScript 从函数签名推断 T = Theme,返回值就是 Theme | undefined。
这个技巧的关键在于:InjectionKey<T> 在运行时只是一个普通的 Symbol,T 纯粹是编译时的类型信息。这是 TypeScript"phantom type"(幽灵类型)技术的一种应用。
3.12 Vue 3 的包结构与依赖关系
@vue/compiler-sfc ← 编译 .vue 文件的完整编译器
│
├── @vue/compiler-core ← 模板编译核心(平台无关)
│ │
│ └── @vue/shared ← 共享工具函数
│
└── @vue/compiler-dom ← DOM 平台的编译器扩展
@vue/runtime-dom ← 面向浏览器的完整运行时
│
├── @vue/runtime-core ← 运行时核心(平台无关)
│ │
│ └── @vue/reactivity ← 响应式系统
│ │
│ └── @vue/shared
│
└── @vue/shared
vue ← 完整包,导出以上所有包的 API
这个分层设计有一个重要含义:你可以只引入你需要的层次。如果你在构建一个 Node.js 服务,你可以只引入 @vue/reactivity 来获得响应式能力,完全不需要 DOM 相关的代码。
Level 4 · 边界与陷阱(全体适用)
陷阱1:sideEffects: false 引起的误摇
// 这种写法可能在开启 sideEffects: false 时被错误地 tree-shake 掉
import './my-plugin'; // 只有副作用,没有导入符号
// 如果 my-plugin.js 没有被标记为 sideEffect,可能被删除
// 正确做法:在自己项目的 package.json 中精确配置
// 或者明确导入有意义的符号
import { setupPlugin } from './my-plugin';
setupPlugin();
具体案例:一个 CSS-in-JS 库,如果只是 import './global-styles' 而没有任何命名导入,在 sideEffects: false 配置下可能被 Vite/Webpack 删除,导致样式消失。这个 bug 极难排查,因为开发模式下通常不会 tree-shake。
陷阱2:跨渲染器使用内置组件
// 问题:Transition、TransitionGroup 是绑定到 DOM 的
// 如果你创建了自定义渲染器(非 DOM),这些组件不可用
import { createRenderer } from '@vue/runtime-core';
// @vue/runtime-core 中没有 Transition
// Transition 在 @vue/runtime-dom 中,因为它依赖 CSS transition
const { createApp } = createRenderer(myCustomOptions);
// 错误:在自定义渲染器中使用 Transition
createApp({
template: `<Transition><div>内容</div></Transition>` // 不会工作
});
解决方案:自定义渲染器需要自己实现过渡机制,或者只使用与平台无关的内置组件(KeepAlive、Teleport 的某些功能也是平台相关的)。
陷阱3:InjectionKey 跨模块使用的类型丢失
// 错误的使用方式
// provider.ts
const key: InjectionKey<User> = Symbol('user');
provide(key, user);
// consumer.ts(不同文件)
// 如果你用字符串而不是同一个 Symbol,类型会丢失
const user = inject('user'); // 类型是 unknown!
// 正确做法:将 InjectionKey 放在共享文件中
// keys.ts
export const userKey: InjectionKey<User> = Symbol('user');
// provider.ts
import { userKey } from './keys';
provide(userKey, user);
// consumer.ts
import { userKey } from './keys';
const user = inject(userKey); // 类型正确:User | undefined
根本原因:两个不同的 Symbol('user') 是不同的值(Symbol('user') !== Symbol('user'))。即使两个文件都写 Symbol('user'),它们是两个不同的 Symbol。必须共享同一个 Symbol 引用。
陷阱4:Tree-shaking 对动态特性的限制
// 动态组件名称无法被 tree-shake
const componentName = 'MyComponent';
const DynamicComponent = resolveComponent(componentName);
// 打包工具不知道 componentName 在运行时是什么值
// 必须把所有可能的组件都注册,全部包含在打包结果中
// 对比:静态导入可以 tree-shake
import { MyComponent } from './components';
// 打包工具知道导入了 MyComponent,其他未导入的组件不包含
实际影响:如果你在动态组件(<component :is="...">)中使用的是字符串名称,需要确保这些组件被全局注册或者局部注册在组件的 components 选项中。否则在生产环境可能因 tree-shaking 导致"组件未找到"错误。
本章小结
-
Tree-shaking 不是魔法,是架构设计:Vue 3 从"全局对象挂载 API"改为"命名导出 API",是让 tree-shaking 成为可能的根本改变。
import { ref } from 'vue'让打包工具知道你不用Transition,所以不打包它。 -
渲染器抽象层是 Vue 3 最被低估的设计:
@vue/runtime-core与@vue/runtime-dom的分离,让同一套组件化模型可以渲染到浏览器 DOM、Canvas、服务端 HTML、原生移动端控件等任何平台,第三方渲染器只需要实现约 10 个接口方法。 -
编译时优化的三层机制相互叠加:静态提升(减少 VNode 创建次数)+ PatchFlag(减少属性比对次数)+ Block Tree(减少 diff 遍历范围),三者叠加产生了更新速度提升 133% 的效果,且这些优化对开发者完全透明。
-
类型优先不是写注释,是 API 契约:
InjectionKey<T>、defineProps<T>()、defineEmits<T>()的设计目标是"API 使用错误在编译时报错,而不是在运行时崩溃"。这种设计需要深入理解 TypeScript 泛型,但带来的开发体验改善是量级的。 -
Vue 3 的包分层使得按需引入成为可能:从
@vue/reactivity(10KB 响应式核心)到vue(22KB 完整框架),每一层都可以独立使用。在构建轻量工具或测试环境时,不必引入整个框架。