Vue3 のソースコードで単体テストを作成し、vscodeのvitestプラグイン
のブレークポイントデバッグ機能を使用することで、Vue コンポーネントの全体の流れを把握できます。以下の単体テストでは、2 つのコンポーネントが含まれており、1 つは親コンポーネントApp
、もう 1 つは子コンポーネントComp
で、コンポーネントのマウントと更新をテストしています。
test('基本コンポーネント', 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>`
)
})
マウントプロセス#
render(h(App, null), root)
のブレークポイントデバッグ中に、最初にコンポーネントmountComponent
がマウントされることがわかります。これは初回のレンダリングであり、setupComponent
関数に入ります。この関数は props や slots の処理、いくつかの初期化作業を行います。例えば、setup 関数の戻り値がオブジェクトの場合、setup の戻り値をプロキシ化します (proxyRefs(setupResult)
) が、現在のテストケースではこのステップは実行されません。なぜなら、現在の戻り値はレンダリング関数だからです。
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
}
子コンポーネントを初期化する際、親コンポーネントから props が渡されます。{ value: innerNumber.value }
、これは数字であり、ref ではありません。そのため、initProps では親コンポーネントから渡された props をshallowReactive
なリアクティブデータに変換します。子コンポーネント内で props を変更すべきではなく、props の変更を防ぐ操作は前述のsetupStatefulComponent
内で実装されています (instance.proxy = markRaw(new Proxy(instance.ctx, PublicInstanceProxyHandlers))
)。
export function initProps(
instance: ComponentInternalInstance,
rawProps: Data | null,
isStateful: number, // ビットフラグ比較の結果
isSSR = false
) {
const props: Data = {}
if (isStateful) {
// ステートフル
// なぜshallowReactiveでpropsをラップする必要があるのか?後述します
instance.props = isSSR ? props : shallowReactive(props)
}
}
次に、レンダリング関数にsetupRenderEffect
のcomponentUpdateFn(後述します)
を使用して依存関係を収集し、レンダリングを行います。
expect(innerStr).toBe(
`<div id="test-id" class="test-class"><span>number 1</span></div>`
)
更新プロセス#
number.value = 3
を変更すると、依存関係の収集により、最初に App コンポーネントの render が再実行され、その後 patch が行われます。子コンポーネントに patch が適用される際、props が変更されたため、子コンポーネントのインスタンスは副作用関数を再更新します。
const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
const instance = (n2.component = n1.component)!
// なぜVueはコンポーネントレベルの更新なのか?後述します
if (shouldUpdateComponent(n1, n2, optimized)) {
...
// propsが変更されたため、子コンポーネントのインスタンスは副作用関数を再更新します
instance.effect.dirty = true
instance.update()
} else {
// 更新は不要です。プロパティをコピーするだけです
n2.el = n1.el
instance.vnode = n2
}
...
}
子コンポーネントの更新を再実行すると、Props と Slots が更新され、子コンポーネントの render が再実行されて最新の vnode を取得し、patch 更新操作が実行され、子コンポーネントの更新が完了します。
// 子コンポーネントの更新 instance.update()
const componentUpdateFn = ()=>{
...
updateComponentPreRender(instance, next, optimized)
...
// 更新が完了したら子コンポーネントのvnodeを再取得し、子コンポーネントのrenderを再実行します
const nextTree = renderComponentRoot(instance)
// patch更新操作を実行します
patch(
prevTree,
nextTree,
// 親が変更された場合、テレポート内にいる場合
hostParentNode(prevTree.el!)!,
// アンカーが変更された場合、フラグメント内にいる場合
getNextHostNode(prevTree),
instance,
parentSuspense,
namespace,
)
}
const updateComponentPreRender = (
instance: ComponentInternalInstance,
nextVNode: VNode,
optimized: boolean
) => {
...
// propsを更新します
updateProps(instance, nextVNode.props, prevProps, optimized)
updateSlots(instance, nextVNode.children, optimized)
...
}
なぜ props を shallowReactive でラップする必要があるのか#
レンダリング関数以外でも props が使用されるため、computed なども含まれます。もし props がリアクティブオブジェクトでなければ、レンダリング関数のみが再実行され、他の副作用関数は再実行されません。これは非常に深刻なバグです。したがって、props はリアクティブオブジェクトでなければならず、浅いものでなければなりません。なぜなら、子コンポーネントはprops.xの変化のみを気にし、props.x.aの変化には関心がないからです
。しかし、場合によっては、次のようなコードが存在します。オブジェクトを直接渡す場合、実際には props.value は更新されず、innerNumber に依存関係が収集され、子コンポーネントのレンダリング関数が再実行されます。公式ドキュメントではこの書き方は推奨されていません。
test('基本コンポーネント', 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>`
)
})
なぜ Vue はコンポーネントレベルの更新なのか#
以下のケースpure component
を考えてみましょう。このCompはpropsを持たないコンポーネント
で、親コンポーネントが変更されるとshouldUpdateComponent
はfalse
を返します。これにより、子コンポーネントの更新分岐は実行されず、親子間の render 関数のみが再実行され、子コンポーネントの render 関数は再実行されません。したがって、Vueはコンポーネントレベルの更新
です。
test('pure component', async () => {
const number = ref(1)
const App = {
setup() {
const innerNumber = number
return () => {
// number.value = 3 の後、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 () => {
// number.value = 3 の後、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
...
// ここでVueがコンポーネントレベルの更新である理由が説明されます
// 上述のtest('pure component')の単体テストのように
// prevPropsとnextPropsはどちらもnullであるため、falseを返します。
// したがって、子コンポーネントの更新分岐は実行されず、instance.update()は実行されません。
if (prevProps === nextProps) {
return false
}
...
return false
}