第 3 章

Vue 3 设计哲学:Tree-shakable 架构、跨平台渲染器与类型优先

第3章:Vue 3 设计哲学——Tree-shakable 架构、跨平台渲染器与类型优先

Vue 3 的核心运行时如果只用 ref + computed,打包后 gzip 体积约 10KB。用完整特性,约 22KB。Vue 2 运行时约 23KB,且无论你用多少特性都是这个体积。这 13KB 的差距不是压缩出来的,是架构设计出来的。

本章核心问题:Vue 3 的哪些设计决策造就了这个体积差异?这些决策对实际开发有什么影响?

读完本章你将理解


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.componentVue.filterVue.mixin 的代码也全部打包进去了。

Vue 3 的解决方案:命名导出

// Vue 3 的使用方式
import { createApp, ref, computed, watch } from 'vue';
// 打包工具知道:只导入了这四个 API
// 其他未导入的 API(如 Transition、KeepAlive、Teleport)不会打包

const app = createApp(/* ... */);

通过命名导出,每个 API 都是一个独立的导出符号,打包工具可以精确追踪哪些符号被使用了,哪些没有。

3.2 体积对比的实际数字

在一个只使用 refcomputedwatch 和基础模板语法的项目中:

框架 运行时体积(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 生态中有几个重要应用:

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.tscreateRenderer() 函数内部定义了完整的 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>` // 不会工作
});

解决方案:自定义渲染器需要自己实现过渡机制,或者只使用与平台无关的内置组件(KeepAliveTeleport 的某些功能也是平台相关的)。

陷阱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 导致"组件未找到"错误。


本章小结

  1. Tree-shaking 不是魔法,是架构设计:Vue 3 从"全局对象挂载 API"改为"命名导出 API",是让 tree-shaking 成为可能的根本改变。import { ref } from 'vue' 让打包工具知道你不用 Transition,所以不打包它。

  2. 渲染器抽象层是 Vue 3 最被低估的设计@vue/runtime-core@vue/runtime-dom 的分离,让同一套组件化模型可以渲染到浏览器 DOM、Canvas、服务端 HTML、原生移动端控件等任何平台,第三方渲染器只需要实现约 10 个接口方法。

  3. 编译时优化的三层机制相互叠加:静态提升(减少 VNode 创建次数)+ PatchFlag(减少属性比对次数)+ Block Tree(减少 diff 遍历范围),三者叠加产生了更新速度提升 133% 的效果,且这些优化对开发者完全透明。

  4. 类型优先不是写注释,是 API 契约InjectionKey<T>defineProps<T>()defineEmits<T>() 的设计目标是"API 使用错误在编译时报错,而不是在运行时崩溃"。这种设计需要深入理解 TypeScript 泛型,但带来的开发体验改善是量级的。

  5. Vue 3 的包分层使得按需引入成为可能:从 @vue/reactivity(10KB 响应式核心)到 vue(22KB 完整框架),每一层都可以独立使用。在构建轻量工具或测试环境时,不必引入整个框架。

本章评分
4.8  / 5  (86 评分)

💬 留言讨论