Chapter 3

Vue 3 Design Philosophy: Tree-shakable Architecture, Cross-Platform Renderer, Type-First

Chapter 3: Vue 3's Design Philosophy — Tree-shakable Architecture, Cross-Platform Renderer, and Type-First

If you build a Vue 3 app using only ref and computed, the gzip bundle is approximately 10KB. With all features, around 22KB. Vue 2's runtime is approximately 23KB regardless of how many features you use. That 13KB difference isn't achieved through compression — it's architected in.

Core Question: Which design decisions in Vue 3 account for this size difference? How do these decisions affect day-to-day development?

After reading this chapter, you will understand:


Level 1 · What You Need to Know (1-3 Years Experience)

3.1 The Prerequisite for Tree-shaking: Modularity

Tree-shaking is a capability in bundlers (webpack, Rollup, Vite): analyze import/export relationships in code and remove code that's never used. This capability has one critical prerequisite: code must use ES Module static imports (import/export), not dynamic CommonJS (require()).

ES Module's static nature lets bundlers build a complete module dependency graph at compile time (without executing the code), enabling them to determine which code paths can never execute.

Why can't Vue 2 be tree-shaken?

All of Vue 2's APIs are mounted on the Vue global constructor's prototype, or as static methods:

// Vue 2 usage pattern
import Vue from 'vue'; // Import the entire Vue

Vue.component('MyComponent', {}); // Global registration
Vue.filter('currency', val => '$' + val); // Global filter
Vue.mixin({ /* ... */ }); // Global mixin

const app = new Vue({ /* ... */ });

When a bundler sees import Vue from 'vue', it must include the entire Vue runtime — because it cannot know which Vue.xxx methods your code will call. Even if you only use new Vue({}) and data binding, the code for Vue.component, Vue.filter, and Vue.mixin all gets bundled in.

Vue 3's solution: named exports

// Vue 3 usage pattern
import { createApp, ref, computed, watch } from 'vue';
// The bundler knows: only these four APIs were imported
// Other unimported APIs (Transition, KeepAlive, Teleport) won't be bundled

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

With named exports, each API is an independent export symbol. Bundlers can precisely track which symbols are used and which are not.

3.2 Real Bundle Size Numbers

In a project using only ref, computed, watch, and basic template syntax:

Framework Runtime size (gzip) Notes
Vue 2.7 ~23KB Same size regardless of features used
Vue 3 (full) ~22KB Using all built-in components and APIs
Vue 3 (reactivity core only) ~10KB Only ref/reactive/computed
Petite-vue ~6KB Built on Vue 3 reactivity, for simple scenarios

These numbers come from bundlephobia and Vue's official documentation (2023 data). Actual project bundle size depends on which features you use.

3.3 The Renderer: Vue's Platform Abstraction Layer

Vue 3's renderer is designed to be platform-agnostic. Core rendering logic (virtual DOM diff algorithm, component update mechanism) is implemented in @vue/runtime-core. Platform-specific DOM operations are implemented in @vue/runtime-dom.

This architecture lets the same Vue renderer render to different platforms:

// DOM rendering (official @vue/runtime-dom)
import { createApp } from 'vue';
createApp(App).mount('#app');

// Canvas rendering (the approach of third-party libraries like 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,
  // ...other nodeOps
});

// Test environment (@vue/test-utils uses a mock renderer internally)
// No real DOM needed, can run in Node.js

You don't need to understand WebGL or Canvas API details to see the value of this design: Vue's component model, reactivity system, and lifecycle management are universal. Give it different "drawing instructions" and it works in different environments.

3.4 Type-First API Design

Vue 3's TypeScript integration wasn't bolted on after the fact — it was built in from the design phase. The clearest examples are two API designs:

defineProps<T>() type inference:

// <script setup>

// Typed props with full IDE autocomplete and type checking
interface Props {
  name: string;
  age?: number;
  role: 'admin' | 'user';
}
const props = defineProps<Props>();

// TypeScript knows:
// props.name: string ✓
// props.age: number | undefined ✓
// props.role: 'admin' | 'user' ✓
// props.xyz // Type error! Property 'xyz' does not exist on type 'Props'

InjectionKey<T> gives provide/inject type safety:

// Without InjectionKey (not type-safe)
provide('theme', { color: 'red' });
const theme = inject('theme'); // Type is unknown

// With InjectionKey (type-safe)
import type { InjectionKey } from 'vue';

interface Theme {
  color: string;
  dark: boolean;
}

const themeKey: InjectionKey<Theme> = Symbol('theme');

// In parent component
provide(themeKey, { color: 'red', dark: false });

// In child component
const theme = inject(themeKey);
// TypeScript knows theme's type is Theme | undefined
console.log(theme?.color); // Type-safe access

InjectionKey uses TypeScript's generic parameters to carry type information: Symbol itself has no type parameter, but InjectionKey<T> is a type alias for a Symbol parameterized by T. When you call inject() with InjectionKey<Theme>, TypeScript extracts the Theme type from this Symbol type.

3.5 Vite and Vue 3: Co-designed

Vite and Vue 3 were both led by Evan You, and they're designed in close coordination:

Native ESM support: Vue 3's SFC (Single File Component) compilation outputs pure ES Modules. Vite in development mode lets browsers load ES Modules directly (rather than bundling), with each .vue file as an independent module. This keeps dev server startup time from growing with project size (because it loads on-demand, doesn't bundle everything upfront).

HMR granularity: Vite's HMR, when a .vue file changes, replaces only the changed portion (script/template/style), preserving component state. This requires @vue/compiler-sfc's output format to closely coordinate with Vite's HMR protocol.

Production bundling: Vite uses Rollup for production builds, which has the strongest tree-shaking capability (based on ES Module static analysis). Vue 3's named export design works perfectly with Rollup's tree-shaking.


Level 2 · How It Works Under the Hood (3-5 Years Experience)

3.6 Technical Details of Tree-shaking

Two necessary conditions for tree-shaking to work:

Condition 1: ES Module static imports

// Can be tree-shaken: static import, determined at build time
import { ref } from 'vue';

// Cannot be tree-shaken: dynamic import, determined at runtime
const { ref } = await import('vue');
const { ref } = require('vue'); // CommonJS
const api = 'ref'; import('vue').then(m => m[api]); // Dynamic key

Condition 2: Side-effect free

Even with static imports, if a module executes code with side effects when imported, bundlers cannot safely delete it:

// Module with side effects (bundler must retain)
// side-effects.js
console.log('Loading...'); // This line executes when the module is imported
window.globalVar = 1;      // Mutates global state

// Side-effect-free module (safe to tree-shake)
// pure.js
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }

Most of Vue 3's code is pure functions with no side effects, and it declares "sideEffects": false in package.json (or lists specific files that have side effects), telling bundlers "this package can be tree-shaken."

Tree-shaking workflow:

  Entry file main.js
  │
  ├── import { ref, watch } from 'vue'
  │   │
  │   ├── Includes: implementation code for ref
  │   ├── Includes: implementation code for watch
  │   │
  │   └── Excludes: Transition, KeepAlive, Teleport, etc. (not imported)
  │
  └── import App from './App.vue'
  
  Bundle output:
  ┌──────────────────────────────────────────────────────┐
  │ ref implementation + watch implementation + App code │
  │                                                      │
  │ ✗ Transition (not imported, tree-shaken away)        │
  │ ✗ KeepAlive (not imported, tree-shaken away)         │
  │ ✗ Teleport (not imported, tree-shaken away)          │
  └──────────────────────────────────────────────────────┘

3.7 Architecture of the Renderer Abstraction Layer

Vue 3's renderer abstraction is based on the RendererOptions interface, which defines all operations that a platform must implement:

// packages/runtime-core/src/renderer.ts (simplified)
export interface RendererOptions<HostNode, HostElement> {
  // DOM element operations
  createElement(type: string, isSVG?: boolean): HostElement;
  createText(text: string): HostNode;
  createComment(text: string): HostNode;
  
  // Text operations
  setText(node: HostNode, text: string): void;
  setElementText(el: HostElement, text: string): void;
  
  // DOM tree operations
  insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void;
  remove(el: HostNode): void;
  
  // Attribute operations
  patchProp(
    el: HostElement,
    key: string,
    prevValue: any,
    nextValue: any,
    isSVG?: boolean
  ): void;
  
  // Other
  parentNode(node: HostNode): HostElement | null;
  nextSibling(node: HostNode): HostNode | null;
  querySelector(selector: string): HostElement | null;
}

The createRenderer() function accepts an implementation of this interface and returns createApp and other methods:

// Creating a custom renderer with createRenderer
import { createRenderer } from '@vue/runtime-core';

// Suppose we want to render to a hypothetical Canvas API
const canvasRenderer = createRenderer({
  createElement(type) {
    return new CanvasNode(type);
  },
  insert(el, parent) {
    parent.children.push(el);
    parent.invalidate(); // Mark as needing redraw
  },
  patchProp(el, key, prev, next) {
    el.props[key] = next;
    el.invalidate();
  },
  // ... implement other methods
});

const { createApp } = canvasRenderer;

// Now use Vue's component model, rendered to Canvas
createApp(App).mount(canvasRoot);

This architecture has several important applications in the Vue ecosystem:

3.8 The Complete Picture of Compile-Time Optimization

Vue 3's strategy of "moving runtime work to compile time" has three specific mechanisms:

Mechanism 1: Static Hoisting

// Template:
// <div class="container">
//   <p class="static">Never-changing text</p>
//   <p>{{ dynamicText }}</p>
// </div>

// Without optimization (creates all VNodes on every render)
render() {
  return createVNode('div', { class: 'container' }, [
    createVNode('p', { class: 'static' }, 'Never-changing text'),
    createVNode('p', null, ctx.dynamicText)
  ]);
}

// Optimized (static node hoisted outside render function, created only once)
const _hoisted_1 = createVNode('p', { class: 'static' }, 'Never-changing text');
//                 ↑ Module-level, created only once

render() {
  return createVNode('div', { class: 'container' }, [
    _hoisted_1, // Reused directly, not recreated
    createVNode('p', null, ctx.dynamicText)
  ]);
}

Mechanism 2: PatchFlag Marking

// Template: <div :class="cls" :style="style" @click="onClick">{{ text }}</div>

// Compiled (PatchFlag tells runtime to only check class, style, text — skip onClick comparison)
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 is not in PatchFlag (it's stable, cached with cacheHandlers)

Mechanism 3: Block Tree Structure

// Template:
// <div>
//   <p>Static</p>
//   <p>{{ a }}</p>
//   <p :class="b">Text</p>
// </div>

// Traditional diff: deep traversal of the entire tree
// Block Tree diff: div is a block, tracking all its "dynamic descendants"
// Updates only traverse dynamicChildren array, skipping static nodes

// Compiled:
createElementBlock('div', null, [
  createElementVNode('p', null, 'Static'),          // Static, tracked but not in dynamicChildren
  createElementVNode('p', null, _ctx.a, 1 /* TEXT */),     // In dynamicChildren
  createElementVNode('p', { class: _ctx.b }, 'Text', 2 /* CLASS */) // In dynamicChildren
])
// Runtime's patchBlockChildren only compares [p(a), p(b)]
// Static <p>Static</p> is completely skipped

Level 3 · Design Documents and Source Code (Senior Developers)

3.9 Core Rendering Logic in @vue/runtime-core

Vue 3's renderer core entry point is in packages/runtime-core/src/renderer.ts. The createRenderer() function internally defines the complete patch algorithm:

// packages/runtime-core/src/renderer.ts (core excerpt)
function baseCreateRenderer(options: RendererOptions): Renderer {
  // Destructure platform-specific operations from options
  const {
    insert: hostInsert,
    remove: hostRemove,
    patchProp: hostPatchProp,
    createElement: hostCreateElement,
    createText: hostCreateText,
    // ...
  } = options;

  // patch is the renderer's core dispatch function
  const patch: PatchFn = (
    n1,      // Old VNode (null means first mount)
    n2,      // New VNode
    container,
    anchor = null,
    parentComponent = null,
    // ...
  ) => {
    if (n1 === n2) return; // Same node, skip
    
    // Different type: unmount old, mount new
    if (n1 && !isSameVNodeType(n1, n2)) {
      unmount(n1, parentComponent, parentSuspense, true);
      n1 = null;
    }
    
    const { type, ref, shapeFlag } = n2;
    
    // Dispatch based on VNode type
    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,   // Render to container
    hydrate,  // SSR hydration
    createApp // Create application instance
  };
}

3.10 PatchFlag Optimization Path in patchElement

// 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 optimization: if dynamicChildren exist, only patch dynamic children
  if (dynamicChildren) {
    patchBlockChildren(n1.dynamicChildren!, dynamicChildren, el, parentComponent, /* ... */);
  } else if (!optimized) {
    // Full diff (fallback when no PatchFlag)
    patchChildren(n1, n2, el, null, parentComponent, /* ... */);
  }
  
  // Handle props
  if (patchFlag > 0) {
    // Has PatchFlag: only check flagged portions
    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) {
    // No PatchFlag, full comparison (fallback)
    patchProps(el, n2, oldProps, newProps, parentComponent, /* ... */);
  }
};

This code clearly demonstrates how PatchFlag reduces attribute comparison from O(all attributes) to O(dynamic attributes count).

3.11 The Type System Implementation of InjectionKey

InjectionKey<T> is an elegant TypeScript type trick. Understanding it requires knowing TypeScript's "nominal" vs "structural" typing:

// packages/runtime-core/src/apiInject.ts
export interface InjectionKey<T> extends Symbol {}

// provide function type signature
export function provide<T>(key: InjectionKey<T> | string | number, value: T): void;

// inject function type signature (overloads)
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> extends Symbol but adds type parameter T. TypeScript's generic system tracks this T: when you pass InjectionKey<Theme> to inject(), TypeScript infers T = Theme from the function signature, and the return type is Theme | undefined.

The key to this trick: InjectionKey<T> is just a plain Symbol at runtime. T is purely compile-time type information. This is an application of TypeScript's "phantom type" technique.

3.12 Vue 3's Package Structure and Dependencies

@vue/compiler-sfc          ← Full compiler for .vue files
       │
       ├── @vue/compiler-core    ← Template compilation core (platform-agnostic)
       │         │
       │         └── @vue/shared    ← Shared utility functions
       │
       └── @vue/compiler-dom     ← DOM platform compiler extensions

@vue/runtime-dom           ← Full runtime for browsers
       │
       ├── @vue/runtime-core     ← Runtime core (platform-agnostic)
       │         │
       │         └── @vue/reactivity   ← Reactivity system
       │                   │
       │                   └── @vue/shared
       │
       └── @vue/shared

vue                        ← Complete package, exports all APIs from above

This layered design has an important implication: you can import only the layer you need. If you're building a Node.js service, you can import only @vue/reactivity for reactive capabilities, with no DOM-related code needed at all.


Level 4 · Edge Cases and Traps (Everyone Should Read)

Trap 1: Incorrect Tree-shaking Due to sideEffects: false

// This pattern can be incorrectly tree-shaken away when sideEffects: false is set
import './my-plugin'; // Only has side effects, imports no symbols
// If my-plugin.js isn't marked as having side effects, it may be deleted

// Correct approach: configure precisely in your project's package.json
// Or explicitly import meaningful symbols
import { setupPlugin } from './my-plugin';
setupPlugin();

Concrete example: A CSS-in-JS library where only import './global-styles' is used without any named imports may be deleted under sideEffects: false in Vite/Webpack, causing styles to disappear. This bug is notoriously hard to find because development mode typically doesn't tree-shake.

Trap 2: Using DOM-Bound Built-in Components with Custom Renderers

// Problem: Transition and TransitionGroup are bound to the DOM
// If you've created a custom renderer (non-DOM), these components don't work

import { createRenderer } from '@vue/runtime-core';
// @vue/runtime-core does NOT include Transition
// Transition lives in @vue/runtime-dom because it depends on CSS transitions

const { createApp } = createRenderer(myCustomOptions);

// Wrong: using Transition in a custom renderer
createApp({
  template: `<Transition><div>Content</div></Transition>` // Won't work
});

Solution: Custom renderers need to implement their own transition mechanism. Some KeepAlive and Teleport functionality is also platform-dependent.

Trap 3: Type Loss When Using InjectionKey Across Modules

// Wrong pattern
// provider.ts
const key: InjectionKey<User> = Symbol('user');
provide(key, user);

// consumer.ts (different file)
// If you use a string instead of the same Symbol, types are lost
const user = inject('user'); // Type is unknown!

// Correct approach: put InjectionKey in a shared file
// 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); // Type correct: User | undefined

Root cause: Two different Symbol('user') calls produce different values (Symbol('user') !== Symbol('user')). Even if both files write Symbol('user'), they're two different Symbols. You must share the same Symbol reference.

Trap 4: Tree-shaking Limitations with Dynamic Features

// Dynamic component names cannot be tree-shaken
const componentName = 'MyComponent';
const DynamicComponent = resolveComponent(componentName);
// The bundler doesn't know what componentName will be at runtime
// Must register all possible components, all included in the bundle

// Compare: static imports can be tree-shaken
import { MyComponent } from './components';
// Bundler knows MyComponent was imported, other unimported components are excluded

Practical impact: If you use string names with dynamic components (<component :is="...">), ensure these components are globally registered or locally registered in the component's components option. Otherwise, production environments may encounter "component not found" errors due to tree-shaking.


Chapter Summary

  1. Tree-shaking is not magic — it's architecture: Vue 3's change from "mounting APIs on global objects" to "named export APIs" is the fundamental change that makes tree-shaking possible. import { ref } from 'vue' tells the bundler you don't use Transition, so it doesn't bundle it.

  2. The renderer abstraction layer is Vue 3's most underrated design: The separation of @vue/runtime-core and @vue/runtime-dom lets the same component model render to browser DOM, Canvas, server-side HTML, native mobile controls, and any other platform. Third-party renderers only need to implement approximately 10 interface methods.

  3. The three compile-time optimization mechanisms stack on each other: static hoisting (fewer VNode creations) + PatchFlag (fewer property comparisons) + Block Tree (narrower diff traversal scope). These three combined produce the measured 133% update speed improvement, and all of this is completely transparent to developers.

  4. Type-first isn't about writing annotations — it's about API contracts: InjectionKey<T>, defineProps<T>(), and defineEmits<T>() are designed so that "using the API incorrectly causes a compile-time error, not a runtime crash." This design requires deep understanding of TypeScript generics, but the developer experience improvement it delivers is orders of magnitude.

  5. Vue 3's package layering makes selective inclusion possible: from @vue/reactivity (10KB reactive core) to vue (22KB full framework), each layer can be used independently. When building lightweight tools or test environments, you don't need to import the entire framework.

Rate this chapter
4.8  / 5  (86 ratings)

💬 Comments