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
scopeto 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 dynamickeyis markedNEED_PATCHand always has to join theDiff. - Elements using
ref:refcan obtain the element instance, which means it affectsstateand may be assigned to differentrefvariables at different render stages - Elements using custom directives (except v-pre/v-cloak): like
key, they are markedNEED_PATCHand 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);