똑같은 삽질은 2번 하지 말자

Vue3에서 VNode를 조작하면서 삽질했던 이야기 본문

카테고리 없음

Vue3에서 VNode를 조작하면서 삽질했던 이야기

곽빵 2023. 7. 8. 18:03

개요

vue2에서 vue3로 마이그레이션중 기존 코드에 VNode를 이용해 props를 셋팅하거나 listener를 등록하는 로직을 가진 컴포넌트가 있어서 그걸 vue3용으로 바꾸면서 일어났던 일을 기록하고자 한다.

결론

Vue3 부터는 VNode를 직접 조작하는 일을 가급적 지양하고 있으므로 가급적 사용하지 말자

하지만, 그럼에도 불구하고 마이그레이션중에 파괴적인 변경을 피하기 위해서 Vue3에서 VNode를 사용해 보려고 한다.


RadioButtonGroup 사용예

<template lang="pug">
RadioButtonGroup(v-model="$data.value")
  RadioButton(value="A") A
  RadioButton(value="B") B
  RadioButton(value="C") C
</template>

이렇게 그룹에 v-model만 셋팅하고 각각의 RadioButton에 props와 이벤트 리스너(@select="onSelect")를 등록하지 않고  value만 설정해도 라디오 버튼들을 사용할 수 있게 하는 headless한 RadioButtonGroup.jsx 라는 컴포넌트가 있다. 

(RadioButton을 쉽게 사용할 수 있게 리스너 등록이나 props 설정을 생략할 수 있게 해주는 목적을 가진 컴포넌트이다.)

 

RadioButtonGroup.jsx

/**
 * 리스너 객체에 리스너를 추가한다.
 * @param {Object} listeners - 리스너 객체
 * @param {string} eventName - 이벤트 이름
 * @param {function} listener - 콜백 함수
 */
function _addListener(listeners, eventName, listener) {
  // 리스너가 등록되지 않았다면 등록하고 종료
  if (!listeners[eventName]) {
    listeners[eventName] = [listener];
    return;
  }

  // 이미 단독 리스너가 등록되어 있다면 배열 형태로 바꾼다
  if (typeof listeners[eventName] === "function") {
    listeners[eventName] = [listeners[eventName]];
  }

  // 배열 형태의 리스너는 단순히 추가한다
  listeners[eventName].push(listener);
}

/**
 * vnode에 리스너를 추가한다.
 * @param {Object} vnode - vnode
 * @param {string} eventName - 이벤트 이름
 * @param {function} listener - 콜백 함수
 */
function addListener(vnode, eventName, listener) {
  // 리스너의 프로퍼티 자체가 없을 때는 초기화한다
  if (!vnode.componentOptions.listeners) {
    vnode.componentOptions.listeners = {};
  }

  _addListener(vnode.componentOptions.listeners, eventName, listener);
}

/**
 * componentName에 일치하는 vnode에 applyFunc를 적용한다.
 * @param {Array} vnodes - vnode 리스트
 * @param {string} componentName - 컴포넌트 이름
 * @param {function} applyFunc - 일치하는 vnode에 대한 처리
 */
function applyMatchedNodes(vnodes, componentName, applyFunc) {
  const regex = new RegExp(`${componentName}$`);
  for (let i = 0; i < vnodes.length; i++) {
    const vnode = vnodes[i];
    if (regex.test(vnode.tag)) {
      applyFunc(vnode);
    }
    // 자식이 있다면 재귀한다
    if (vnode.children) {
      applyMatchedNodes(vnode.children, componentName, applyFunc);
    }
  }
}

export default {
  name: 'RadioButtonGroup',
  model: {
    prop: "selectedValue",
    event: "select",
  },
  props: {
    selectedValue: { type: String },
  },
  render() {
    const vnodes = this.$slots.default;
    // 라디오 버튼 컴포넌트 전체에 프로퍼티를 주입한다
    applyMatchedNodes(vnodes, RADIO_BUTTON_NAME, (vnode) => {
      if (!vnode.componentOptions || !vnode.componentOptions.propsData) {
        return;
      }
      // 주입할 파라미터를 넣는다 (v-model의 대상이 되는 props 이름을 selectedValue로 하고 있으므로, 그 값을 넣는다)
      vnode.componentOptions.propsData.selectedValue = this.$props.selectedValue;
      // 리스너를 설정한다
      addListener(vnode, "select", (value) => {
        this.$emit("select", value);
      });
    });
    return <div>{vnodes}</div>;
  },
};

 

자, 이제 RadioButtonGroup을 vue3의 SFC script setup으로 마이그레이션을 해보자.

(참고로 nuxt3이기 때문에 defineProps등을 import하지 않아도 사용할 수 있다.  )

<script setup lang="ts">
import { h } from 'vue'

type ListnerFunc = (vnode: any) => void

const props = defineProps<{
  selectedValue: string
}>()

const emit = defineEmits(['update:selectedValue'])

const RADIO_BUTTON_NAME = 'Radio'

const _addListener = (listeners: any, eventName: string, listener: ListnerFunc) => {
  if (!listeners[eventName]) {
    listeners[eventName] = [listener]
    return
  }

  if (typeof listeners[eventName] === 'function') {
    listeners[eventName] = [listeners[eventName]]
  }

  listeners[eventName].push(listener)
}

const addListener = (vnode: any, eventName: string, listener: ListnerFunc) => {
  if (!vnode.componentOptions.listeners) {
    vnode.componentOptions.listeners = {}
  }

  _addListener(vnode.componentOptions.listeners, eventName, listener)
}

const applyMatchedNodes = (vnodes: any, componentName: string, applyFunc: ListnerFunc) => {
  const regex = new RegExp(`${componentName}$`)
  for (let i = 0; i < vnodes.length; i++) {
    const vnode = vnodes[i]
    if (regex.test(vnode.type.__name)) {
      applyFunc(vnode)
    }
    if (vnode.children) {
      applyMatchedNodes(vnode.children, componentName, applyFunc)
    }
  }
}

const slots = useSlots()

const render = () => {
  const vnodes = slots.default()
  applyMatchedNodes(vnodes, RADIO_BUTTON_NAME, (vnode) => {
    vnode.props = vnode.props || {}
    vnode.props.selectedValue = props.selectedValue
    addListener(vnode, 'change', (value: string) => {
      emit('update:selectedValue', value)
    })
  })
  return h('div', {}, vnodes)
}
</script>

<template>
  <render />
</template>

와 같이 되는데 여기서 제일 문제점은 listener 등록 (addListener) 자체가 안되는 것이었다.

 

그 이유는 

https://v3-migration.vuejs.org/breaking-changes/listeners-removed.html

 

$listeners removed | Vue 3 Migration Guide

 

v3-migration.vuejs.org

$listeners 자체가 사라짐으로써 VNode를 직접 조작해 listener를 등록하는 일 자체가 불가능하게 된거라고 생각한다.

그 대신에 mergeProps로 이벤트 리스너를 연결시켜 주었다.

import { mergeProps, h } from 'vue'

const render = () => {
  const vnodes = slots.default()
  applyMatchedNodes(vnodes, RADIO_BUTTON_NAME, (vnode) => {
    vnode.props = vnode.props || {}
    vnode.props.selectedValue = props.selectedValue
    vnode.props = mergeProps(vnode.props, {
      onChange(value: string) {
        emit('update:selectedValue', value)
      },
    })
  })
  return h('div', {}, vnodes)
}

 

라디오 버튼 자체는 어차피 무조건 여러개의 라디오 버튼의 조합으로 사용되어 질꺼라고 생각하기 때문에 애초에 라디오 버튼 컴포넌트를 만들 때 부터 그 자체에 그룹의 성질을 부여하는게 조금 더 사용하기 쉬운 컴포넌트가 됬을 지도 모르겠다.

 

Comments