Skip to content
Back to blog

Vue3 Knowledge Points

Notes on behavioral differences between Vue3 and Vue2, plus reactivity, compile-time optimization, fast diff, and the component instance exposure mechanism.

Updated

Differences from Vue2

mount

When mounting, Vue2 replaces the target node, while Vue3 inserts into it as a child element — or in other words, replaces its innerHTML.

For example, given this HTML structure:

<body>
  <div id="app">
    Some app content
  </div>
</body>

The mounting code is as follows:

const app = new Vue({
  data() {
    return {
      message: 'Hello Vue!'
    }
  },
  template: `
    <div id="rendered">{{ message }}</div>
  `
})

app.$mount('#app')

The result in Vue2:

<body>
  <div id="rendered">Hello Vue!</div>
</body>

The result in Vue3:

<body>
  <div id="app" data-v-app="">
    <div id="rendered">Hello Vue!</div>
  </div>
</body>

mixin

When Vue2 mixins merge data, they deep merge the data objects, while Vue3 only performs a shallow merge. For example:

const Mixin = {
  data() {
    return {
      user: {
        name: 'Jack',
        id: 1
      }
    }
  }
}

const CompA = {
  mixins: [Mixin],
  data() {
    return {
      user: {
        id: 2
      }
    }
  }
}

In Vue2, CompA's $data is:

{
  "user": {
    "id": 2,
    "name": "Jack"
  }
}

In Vue3, CompA's $data is:

{
  "user": {
    "id": 2
  }
}

props

In Vue2, when defining the default field of a prop, you can use this to access the component instance.

Vue3 no longer supports this access; instead, the complete raw props are passed into the default function, and inject is supported for prop default values

import { inject } from 'vue'

export default {
  props: {
    theme: {
      default (props) {
        // `props` is the raw value passed to the component,
        // before any type/default coercion,
        // and `inject` can also be used to access injected properties
        return inject('theme', 'default-theme')
      }
    }
  }
}

directive

Vue3 changed the directive lifecycle to be basically the same as the component lifecycle, except that there is no setup or beforeCreate

const MyDirective = {
  created(el, binding, vnode, prevVnode) {}, // Added
  beforeMount() {},
  mounted() {},
  beforeUpdate() {}, // Added
  updated() {},
  beforeUnmount() {}, // Added
  unmounted() {}
}

Lifecycle Event Names

In Vue2, lifecycle events can be listened to with something like @hook:updated; in Vue3 this was changed to @vue:updated

v-if / v-for

In Vue2, v-for has higher priority than v-if; in Vue3, v-if has higher priority than v-for.

v-bind

In Vue2, attributes in v-bind have lower priority and are overridden by individually defined attributes with the same name. For example:

<!-- Template -->
<div id="red" v-bind="{ id: 'blue' }"></div>
<!-- Result -->
<div id="red"></div>

In Vue3, the priority of v-bind attributes versus individually defined attributes with the same name is decided by the order of definition. For example:

<!-- Template -->
<div id="red" v-bind="{ id: 'blue' }"></div>
<!-- Result -->
<div id="blue"></div>

<!-- Template -->
<div v-bind="{ id: 'blue' }" id="red"></div>
<!-- Result -->
<div id="red"></div>

ref

ref Field Names

In Vue2, the name defined by a ref is directly mounted into $refs and is never changed (alias styles like vue-class are not considered)

In Vue3, a ref can be a field name or a function; when passing a function, the ref object in its argument can be assigned to any ref

ref on Component Instances

In Vue2, you can access the complete component data, computed, and methods

In Vue3, you can only access what is exposed via expose or exported by default, such as the Options API; and Options API content must be accessed indirectly through fields like $data and $methods, rather than being flattened onto the component.

Using ref in v-for

In Vue2, using ref inside v-for turns the ref into an array whose order matches the rendered component order.

Vue3 only supports rendering refs as arrays starting from 3.5, and it does not guarantee that the element order in the ref matches the v-for list order. To achieve the Vue2 behavior, you need to assign manually via a function ref.

Reactivity Principles

Problems with the Vue2 Approach

  • Cannot detect the addition and deletion of object properties
  • Array APIs are hard to extend and override
  • Every level of an object needs to be traversed and observed — deep nesting poses a performance risk
  • The reactivity feature is strongly coupled with Vue and cannot run without it (without Vue there is no scope to run the reactivity system)

Implementation in Vue3

Abstractly, it is the following pseudocode:

const proxyHandler = {
  get(target, key, receiver) {
    const result = Reflect.get(target, key, receiver)
    track(target, key) // Dependency collection
    return isObject(result) ? reactive(result) : result // Lazily handle nested objects
  },
  set(target, key, value, receiver) {
    const oldValue = target[key]
    const result = Reflect.set(target, key, value, receiver)
    if (hasChanged(value, oldValue)) {
      trigger(target, key) // Trigger updates
    }
    return result
  }
}
  • track: when a property is accessed (e.g. during template rendering or computed evaluation), records the current dependency (such as a component) into the dependency list.
  • trigger: when a property is modified, notifies all related dependencies to update.

Optimizations of the Vue3 Approach

  • The whole object is proxied, with no need to traverse and hijack properties one by one
  • Adding and deleting properties is detected automatically, without extra functions
  • Supports reactive arrays, Set, Map, etc.
  • Better performance: initialization does not require recursively traversing all fields — handling happens lazily on access.
  • Independent architecture that does not depend on a Vue instance and can run standalone.

References

Performance Improvements

Block Tree and PatchFlags

PatchFlags are used to mark node types, such as:

  • Contains dynamic text
  • Contains dynamic class

With these presets, nodes can be updated in a targeted way, without comparing whether static properties have changed.

At the same time, PatchFlags can be used to distinguish static nodes, which do not need diff at all.

All dynamic nodes can be hoisted and stored together at the same level.

This way, when updating VNodes, we know which nodes will update which properties, allowing targeted updates.

The node that stores dynamic nodes is called a Block; multiple nested Blocks form a tree — the Block Tree.

The overall structure of a Block is as follows:

const block = {
    tag: 'div',
    dynamicChildren: [
        { tag: 'section', { key: 0 }, dynamicChildren: [...]}, /* Block(Section v-if) */
        { tag: 'section', { key: 1 }, dynamicChildren: [...]}  /* Block(Section v-else) */
    ]
}

Static Hoisting

Introduction

Take the following HTML as an example:

<div>
  <p>text</p>
</div>

Without the hoistStatic option enabled, the compiled render function looks like this:

function render() {
  return (
    openBlock(),
    createBlock(
      'div',
      null,
      [createVNode('p', null, 'text')],
    )
  )
}

From the HTML structure, the p and its content are obviously static, so the entire static node can be cached — the following approach:

const host1 = createVNode('p', null, 'text')

function render() {
  return (
    openBlock(),
    createBlock(
      'div',
      null,
      [host1],
    )
  )
}

The benefit is reduced VNode creation overhead; in purely static code, everything except the root node is hoisted.

<div>
  <!-- section is not hoisted, because it contains dynamicText -->
  <section>
    {{ dynamicText }}
    <!-- p is hoisted, because p and all its descendants are static -->
    <p>
      <span>abc</span>
    </p>
  </section>
</div>

Cases That Are Not Statically Hoisted

  • Having a dynamic key: e.g. <div :key="foo"></div>. Any node with a dynamic key is marked NEED_PATCH and always has to join the Diff.
  • Elements using ref: ref can obtain the element instance, which means it affects state and may be assigned to different ref variables at different render stages
  • Elements using custom directives (except v-pre/v-cloak): like key, they are marked NEED_PATCH and always need to be patched.

Static Prop Hoisting

Since the DOM can be hoisted, the props on the DOM can be hoisted too. Take this template:

<div>
  <p foo="bar" a="b">{{ text }}</p>
</div>

Although the p cannot be hoisted, all its attributes are static, so the hoisted render function looks like this:

const hoistProp = { foo: 'bar', a: 'b' }

function render(ctx) {
  return ([
    openBlock(),
    createBlock(
      'div',
      null,
      [createVNode('p', hoistProp, ctx.text)],
    ),
  ])
}

At an even finer granularity, there is string hoisting, for example:

<p :foo="10" :bar="'abc' + 'def'">{{ text }}</p>

'abc' + 'def' is a constant and can also be hoisted — there is no need to create two strings and concatenate them into a new one every time.

Pre-stringification

If there is a large number of consecutive static tags, further hoisting is possible, for example:

<div>
  <p></p>
  <p></p>
  ...20 p tags
  <p></p>
</div>

After static hoisting, it becomes:

const hoist1 = createVNode('p', null, null, PatchFlags.HOISTED)
const hoist2 = createVNode('p', null, null, PatchFlags.HOISTED)
// ... 20 hoistx variables
const hoist20 = createVNode('p', null, null, PatchFlags.HOISTED)

function render() {
  return ([
    openBlock(),
    createBlock(
      'div',
      null,
      [
        hoist1,
        hoist2, // ...20 variables, hoist20
      ]
    )
  ])
}

Pre-stringification merges the nodes into a VNode of type Static, that is:

const hoistStatic = createStaticVNode('<p></p><p></p><p></p>...20 of them...<p></p>')

function render() {
  return ([
    openBlock(),
    createBlock(
      'div',
      null,
      [
        hoistStatic
      ]
    )
  ])
}

Pre-stringification has the following advantages:

  • Reduces the size of the generated code
  • Reduces the overhead of creating VNodes
  • Reduces memory usage

Static nodes create real nodes via innerHTML at runtime, so not all static nodes can be pre-stringified. A static node must meet the following conditions to be pre-stringified:

  • Not a table-related tag
  • Tag attributes must be standard HTML attributes or data- / aria- attributes
  • Attribute-less static nodes must appear 20+ times consecutively to trigger pre-stringification
  • Nodes with attributes need to appear 5+ times consecutively to trigger pre-stringification
  • Consecutive static nodes do not have to be siblings — they can be parent-child, as long as the threshold is met.
  • If a constant is dynamically bound, its value is computed during pre-stringification — for example <div :id="'a-' + 1"></div> is automatically computed as <div id="a-1"></div>

Cache Event handler

Simply put, if a component is bound to a function — whether as an event or a prop — it is best not to pass a standalone arrow function, but to define a new named function, for example:

<template>
  <Comp @change="a + b" />
</template>

This will be transformed into the following structure:

export default {
  props: {
    a: {
      type: Number,
      default: 0,
    },
    b: {
      type: Number,
      default: 0,
    },
  },
  render(ctx) {
    return h(Comp, {
      onChange: () => (ctx.a + ctx.b)
    })
  }
}

This way, every time props.a or props.b updates, the onChange prop updates as well, and Comp gets updated along with it.

Cache Event handler solves this problem by maintaining a cache table in the component. The code above is transformed into:

function render(ctx, cache) {
  return h(Comp, {
    onChange: cache[0] || (cache[0] = $event => (ctx.a + ctx.b))
  })
}

This way, even if render is called multiple times, it will not repeatedly trigger Comp's update

v-once

Refer to the Block Tree section: v-once is directly hoisted as a static node — it is never re-rendered and is not collected into a Block to participate in Diff.

References

Bundle Size Optimization

tree-shaking

In Vue3, global APIs are split into independent files; if the corresponding function is not imported, the corresponding API is automatically removed from the final bundle.

For example, if the project does not use computed, the built Vue runtime will not include the implementation of the computed function.

In Vue2, all features are strongly coupled with VNode and already reference each other internally; with no lower-level abstraction, tree-shaking is impossible.

Feature Flags

Vue3 provides some feature flags. For example, if the options API is not used, it can be turned off via a feature flag, so the built Vue runtime will not include any of the options API code.

The diff Algorithm

The underlying array diff was changed from double-ended diff to fast diff.

The processing steps of fast diff are as follows:

---
title: Fast Diff Workflow
---
graph TD
  subgraph B[Preprocessing]
    B1[Compare from the head] --> B2[Compare from the tail]
    B2 --> B3[Record identical nodes]
  end

  subgraph E[Find the longest increasing subsequence]
    E1[Build the new node index] --> E2[Compute the longest increasing subsequence]
    E2 --> E3[Determine which nodes need to move]
  end

  subgraph F[Update operations]
    F1[Move nodes] --> F2[Add new nodes]
    F2 --> F3[Remove old nodes]
  end

  A[Start] --> B
  B --> C{Identical?}
  C -->|Yes| D[No update needed]
  C -->|No| E
  E --> F
  F --> G[End]

Preprocessing

function patchKeyedChildren(n1, n2, container) {
  // Get the two groups of Children nodes
  const newChildren = n2.children
  const oldChildren = n1.children
  // Use j as the head index
  let j = 0
  let oldVNode = oldChildren[j]
  let newVNode = newChildren[j]

  // Start scanning from the head
  // The while loop walks forward until it hits nodes with different keys
  while (oldVNode.key === newVNode.key) {
    // Call the patch function to update
    patch(oldVNode, newVNode, container)
    j++
    oldVNode = oldChildren[j]
    newVNode = newChildren[j]
  }

  // ========================================
  // Start processing the tail
  // Since the tail differs from the head, they may not match.
  // So separate indexes are needed
  let oldEnd = oldChildren.length - 1
  let newEnd = newChildren.length - 1

  oldVNode = oldChildren[oldEnd]
  newVNode = newChildren[newEnd]

  // Start scanning from the tail
  // The while loop walks backward until it hits nodes with different keys
  while (oldVNode.key === newVNode.key) {
    // Call the patch function to update
    patch(oldVNode, newVNode, container)
    oldEnd--
    newEnd--
    oldVNode = oldChildren[oldEnd]
    newVNode = newChildren[newEnd]
  }
  // ...
}

Adding Nodes

// If both conditions hold, nodes need to be added
if (j > oldEnd && j <= newEnd) {
  // The anchor index
  const anchorIndex = newEnd + 1
  // The anchor element
  const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
  // Use a while loop, calling patch to mount the new nodes one by one
  while (j <= newEnd) {
    patch(null, newChildren[j++], container, anchor)
  }
}

Removing Nodes

if (j > oldEnd && j <= newEnd) {
  // The code for adding nodes is omitted
} else if (j > newEnd && j <= oldEnd) {
  // The nodes between j and oldEnd should be unmounted
  while (j <= oldEnd) {
    unmount(oldChildren[j++])
  }
}

Diffing Nodes

// The number of remaining unprocessed nodes in the new group of children
const count = newEnd - j + 1
// Build the source array, filled with -1 by default
const source = new Array(count)
source.fill(-1)

// The index starts from j after preprocessing
const oldStart = j
const newStart = j
// moved flags whether re-sorting is needed
let moved = false
// pos records the position of the last found node, to help set moved
let pos = 0
// keyIndex is a lookup table recording the relationship between new node keys and indexes
const keyIndex = {}
// Put all new nodes into keyIndex first
for (let i = newStart; i <= newEnd; i++) {
  keyIndex[newChildren[i].key] = i
}
// Record how many matches were found from the old nodes
let patched = 0
for (let i = oldStart; i <= oldEnd; i++) {
  oldVNode = oldChildren[i]

  if (patched < count) {
    // k is the index of the node found in keyIndex within the new children group
    const k = keyIndex[oldVNode.key]
    if (typeof k !== 'undefined') {
      newVNode = newChildren[k]
      patch(oldVNode, newVNode, container)
      patched++
      // Update the corresponding entry in source
      source[k - newStart] = i
      // Determine whether movement is needed
      if (k < pos) {
        moved = true
      } else {
        pos = k
      }
    } else {
      // Not found
      unmount(oldVNode)
    }
  } else {
    // If all nodes have already been matched, the rest should all be removed
    unmount(oldVNode)
  }
}
if (moved) {
  // lis is a sorting function that turns source into an increasing array.
  const seq = lis(source)
  // s points to the last value of the longest increasing subsequence
  let s = seq.length - 1
  let i = count - 1
  for (i; i >= 0; i--) {
    if (source[i] === -1) {
      // The node at index i is brand new and should be mounted
      // The node's real position index in the new children
      const pos = i + newStart
      const newVNode = newChildren[pos]
      // The position index of the node's next sibling
      const nextPos = pos + 1
      // The anchor
      const anchor = nextPos < newChildren.length
        ? newChildren[nextPos].el
        : null
      // Mount
      patch(null, newVNode, container, anchor)
    } else if (i !== seq[s]) {
      // The node needs to move
      // The node's real position index in the new group of children
      const pos = i + newStart
      const newVNode = newChildren[pos]
      // The position index of the node's next sibling
      const nextPos = pos + 1
      // The anchor
      const anchor = nextPos < newChildren.length
        ? newChildren[nextPos].el
        : null
      // Move
      insert(newVNode.el, container, anchor)
    } else {
      // When i === seq[s], the node at this position does not need to move
      // and s advances to the next position
      s--
    }
  }
}

The Secret of expose

What Does a Component Expose

From the implementation of setupComponent, we can see that it eventually calls setupStatefulComponent to initialize the component.

But this is only the Component initialization flow. To understand what gets exposed, we also need to look at the ref feature, so let's go through ref

By tracing backward from the directives in the test cases, we can find that Vue3's compatH function actually creates the h function originally used for the Options API. Tracing further, we find directives are controlled via withDirectives, and the component instance obtained is essentially retrieved by getComponentPublicInstance.

At this point we can see that what is ultimately returned is the exposeProxy or proxy field used on the created component object.

exposeProxy essentially only exposes the properties on publicPropertiesMap, with data mapping handled by the specified callback function — this is one part of expose.

For the other part related to expose, the proxy field, we need to go back to setupStatefulComponent, where the proxy field is defined, focusing on PublicInstanceProxyHandlers

Tracing to the end, we come back to publicPropertiesMap again. If there are custom exposed fields (which can be updated via the expose function or defineExpose), the custom exposed is used

Why Can't ref Read data

See the publicPropertiesMap section above.

State returned from the setup function will not be accessible through the proxy unless it is passed to expose.

As for data defined via the Options API, it is not actually inaccessible — it just needs to be accessed through $data.

Component-as-API

Take the Modal component as an example:

// Import the component
import Modal from './Modal.vue';

// Create a mount container
const container = document.createElement('div');
// Instantiate the VNode
const vnode = createVNode(Modal);
// Mount the VNode onto the container via render
render(vnode, container);

// Get the component instance, which can be used to control the component
const instance = vnode.component;

// Mount the component onto body
document.body.appendChild(container);