banner
linzhe

linzhe

vue3 コンポーネントのマウント更新プロセス

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)
  }
}

次に、レンダリング関数にsetupRenderEffectcomponentUpdateFn(後述します)を使用して依存関係を収集し、レンダリングを行います。

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を持たないコンポーネントで、親コンポーネントが変更されるとshouldUpdateComponentfalseを返します。これにより、子コンポーネントの更新分岐は実行されず、親子間の 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
}
読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。