You can write unit tests in the Vue 3 source code and use the breakpoint debugging feature of the vscode vitest plugin
to understand the entire flow of Vue components. For example, in the following unit test, there are two components, one as the parent component App
and one as the child component Comp
, used to test the mounting and updating of components.
test('basic component', async () => {
const number = ref(1)
const App = {
setup() {
const innerNumber = number
return () => {
console.log('app render')
return h('div', { id: 'test-id', class: 'test-class' }, [
h(Comp, { value: innerNumber.value }),
])
}
},
}
const Comp = {
props: ['value'],
setup(props: any) {
const x = computed(() => props.value)
return () => {
console.log('son render')
return h('span', null, 'number ' + x.value)
}
},
}
const root = nodeOps.createElement('div')
render(h(App, null), root)
let innerStr = serializeInner(root)
expect(innerStr).toBe(
`<div id="test-id" class="test-class"><span>number 1</span></div>`
)
number.value = 3
await nextTick()
innerStr = serializeInner(root)
expect(innerStr).toBe(
`<div id="test-id" class="test-class"><span>number 3</span></div>`
)
})
Mounting Process#
During the breakpoint debugging of render(h(App, null), root)
, it is found that the component mountComponent
is first mounted because this is the first render. When entering the setupComponent
function, it is used to handle props and slots and some initialization work. For example, when the return value of the setup function is an object, it proxies the return value of setup (proxyRefs(setupResult)
), but the current test case will not go through this step because the current return is a render function.
const mountComponent = () => {
...
setupComponent(...);
...
setupRenderEffect(...);
...
};
export function setupComponent(
instance: ComponentInternalInstance,
isSSR = false
) {
// ...
const { props, children } = instance.vnode
const isStateful = isStatefulComponent(instance)
initProps(instance, props, isStateful, isSSR)
initSlots(instance, children)
const setupResult = isStateful
? setupStatefulComponent(instance, isSSR)
: undefined
isSSR && setInSSRSetupState(false)
return setupResult
}
When initializing the child component, since props are passed from the parent component, { value: innerNumber.value }
, note that this is a number, not a ref. Therefore, in initProps
, the props passed from the parent component will be converted into a shallowReactive
reactive data. Note that users should not modify props inside the child component, and the interception of modifying props is implemented in the aforementioned setupStatefulComponent
(instance.proxy = markRaw(new Proxy(instance.ctx, PublicInstanceProxyHandlers))
).
export function initProps(
instance: ComponentInternalInstance,
rawProps: Data | null,
isStateful: number, // result of bitwise flag comparison
isSSR = false
) {
const props: Data = {}
if (isStateful) {
// stateful
// Why use shallowReactive to wrap props? This will be explained later
instance.props = isSSR ? props : shallowReactive(props)
}
}
Next, the render function uses setupRenderEffect
's componentUpdateFn (to be used later)
for dependency collection and rendering.
expect(innerStr).toBe(
`<div id="test-id" class="test-class"><span>number 1</span></div>`
)
Updating Process#
When modifying number.value = 3
, since dependency collection will first re-execute the render of the App component, then patching occurs. When patching to the child component, since props have changed, the child component instance will update the effect function again.
const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
const instance = (n2.component = n1.component)!
// Why is Vue an update at the component level? This will be explained later
if (shouldUpdateComponent(n1, n2, optimized)) {
...
// Since props have changed, the child component instance will update the effect function again
instance.effect.dirty = true
instance.update()
} else {
// no update needed. just copy over properties
n2.el = n1.el
instance.vnode = n2
}
...
}
When the child component update is re-executed, it will update Props and Slots, re-execute the child component render to get the latest vnode, and perform the patch update operation, completing the update of the child component.
// Child component update instance.update()
const componentUpdateFn = ()=>{
...
updateComponentPreRender(instance, next, optimized)
...
// After the update is complete, get the child component's vnode again, which will re-execute the child component's render
const nextTree = renderComponentRoot(instance)
// Execute patch update operation
patch(
prevTree,
nextTree,
// parent may have changed if it's in a teleport
hostParentNode(prevTree.el!)!,
// anchor may have changed if it's in a fragment
getNextHostNode(prevTree),
instance,
parentSuspense,
namespace,
)
}
const updateComponentPreRender = (
instance: ComponentInternalInstance,
nextVNode: VNode,
optimized: boolean
) => {
...
// Update props
updateProps(instance, nextVNode.props, prevProps, optimized)
updateSlots(instance, nextVNode.children, optimized)
...
}
As for why to use shallowReactive to wrap props#
Because besides the render function, other effects will also use props, such as computed, etc. If props do not use a reactive object, then only the render function will re-execute, and other effect functions will not re-execute, which is a serious bug. Therefore, props must be a reactive object and can only be shallow because the child component only cares about changes in props.x, not changes in props.x.a
. However, in some cases, there may be code like this, directly passing an object, which actually means that props.value has not been updated, equivalent to innerNumber again relying on the collection of the child component's render function, and the official documentation does not recommend this writing.
test('basic component', async () => {
const App = {
setup() {
const innerNumber = reactive({ data: 1 })
return () => {
console.log('app render')
return h('div', { id: 'test-id', class: 'test-class' }, [
h(Comp, { value: innerNumber }),
])
}
},
}
const Comp = {
props: ['value'],
setup(props: any) {
onMounted(async () => {
props.value.data = 3
await nextTick()
innerStr = serializeInner(root)
expect(innerStr).toBe(
`<div id="test-id" class="test-class"><span>number 3</span></div>`
)
})
return () => {
console.log('son render')
return h('span', null, 'number ' + props.value.data)
}
},
}
const root = nodeOps.createElement('div')
render(h(App, null), root)
let innerStr = serializeInner(root)
expect(innerStr).toBe(
`<div id="test-id" class="test-class"><span>number 1</span></div>`
)
})
As for why Vue is an update at the component level#
For example, in the following case pure component
, where Comp is a component without props
, when the parent component changes, shouldUpdateComponent
will return false
, and the update branch for this child component will not be executed. Therefore, only the render functions between the parent and child will be re-executed, and the render function of the child component will not be re-executed. Thus, Vue is an update at the component level
.
test('pure component', async () => {
const number = ref(1)
const App = {
setup() {
const innerNumber = number
return () => {
// After number.value = 3, it will print app render
console.log('app render')
return h(
'div',
{ id: 'test-id-' + innerNumber.value, class: 'test-class' },
[h(Comp)],
)
}
},
}
const Comp = {
setup(props: any) {
return () => {
// After number.value = 3, it will not print son render
console.log('son render')
return h('span', null, 'number')
}
},
}
const root = nodeOps.createElement('div')
render(h(App, null), root)
number.value = 3
await nextTick()
})
export function shouldUpdateComponent(
prevVNode: VNode,
nextVNode: VNode,
optimized?: boolean,
): boolean {
const { props: prevProps } = prevVNode
const { props: nextProps } = nextVNode
...
// Here explains why Vue is an update at the component level
// For example, in the aforementioned test('pure component') unit test
// Both prevProps and nextProps are null, so return false,
// and the update branch for this child component will not be executed, and instance.update() will not be executed.
if (prevProps === nextProps) {
return false
}
...
return false
}