본문 바로가기

FrontEnd

[Vue3] defineProps를 컴포저블에서 사용할 수 없는 이유

반응형

<script setup>사용 시 props, emit 정의를 대체하는 defineProps와 defineEmits은

왜 컴포저블을 이용해 컴포넌트 밖에서 선언할 수 없을까요?

Vue3

과 같이 defineProps는 타입스크립트 제네릭 사용과 같이 제약 사항이 많습니다.

그 이유는 무엇일까요?

defineProps의 정체

vue3 core의 packages/runtime-core/src/apiSetupHelpers.ts 파일을 열어보면 다음과 같은 내용이 있습니다.

말 그대로 아무 역할도 안하는 친구입니다.

export function defineProps() {
  if (__DEV__) {
    warnRuntimeUsage(`defineProps`)
  }
  return null as any
}

단지 우리가 이 함수를 import하여 사용할 수 있다고 착각하게끔 만드는 가상의 함수입니다.

 

packages/compiler-sfc/src/compileScript.ts 파일의 1186줄을 보겠습니다.

    if (node.type === 'ExpressionStatement') {
      // process `defineProps` and `defineEmit(s)` calls
      if (
        processDefineProps(node.expression) ||
        processDefineEmits(node.expression) ||
        processWithDefaults(node.expression)
      ) {
        s.remove(node.start! + startOffset, node.end! + startOffset)
      } else if (processDefineExpose(node.expression)) {
        // defineExpose({}) -> expose({})
        const callee = (node.expression as CallExpression).callee
        s.overwrite(
          callee.start! + startOffset,
          callee.end! + startOffset,
          'expose'
        )
      }
    }
    
    if (node.type === 'VariableDeclaration' && !node.declare) {
      const total = node.declarations.length
      let left = total
      for (let i = 0; i < total; i++) {
        const decl = node.declarations[i]
        if (decl.init) {
          // defineProps / defineEmits
          const isDefineProps =
            processDefineProps(decl.init, decl.id, node.kind) ||
            processWithDefaults(decl.init, decl.id, node.kind)
          const isDefineEmits = processDefineEmits(decl.init, decl.id)
          if (isDefineProps || isDefineEmits) {
            if (left === 1) {
              s.remove(node.start! + startOffset, node.end! + startOffset)
            } else {
              let start = decl.start! + startOffset
              let end = decl.end! + startOffset
              if (i === 0) {
                // first one, locate the start of the next
                end = node.declarations[i + 1].start! + startOffset
              } else {
                // not first one, locate the end of the prev
                start = node.declarations[i - 1].end! + startOffset
              }
              s.remove(start, end)
              left--
            }
          }
        }
      }
    }

해당 코드 블록은 바벨과 @vue/comller-dom을 이용해 .vue 파일을 파싱한 결과인 Vue3 ast를 순회하면서,

해당 ast를 js 코드로 변환하는 기능을 수행합니다.

 

위 코드는 아래 두 가지 사용사례를 처리하기 위함입니다.

const props = defineProps()
defineProps()

 

위 코드의 상단에서 아래 코드를 만날 수 있으며, 주석을 통해 무슨 일을 하는지 알 수 있습니다.

  • <script setup>블록을
  • ast로 변환한 뒤 
  • 해당 블록 내의 defineProps을 처리함
  // 2.2 process <script setup> body
  for (const node of scriptSetupAst.body) {
    // (Dropped) `ref: x` bindings
    // TODO remove when out of experimental
    if (
      node.type === 'LabeledStatement' &&
      node.label.name === 'ref' &&
      node.body.type === 'ExpressionStatement'
    ) {
      error(
        `ref sugar using the label syntax was an experimental proposal and ` +
          `has been dropped based on community feedback. Please check out ` +
          `the new proposal at https://github.com/vuejs/rfcs/discussions/369`,
        node
      )
    }

컴파일 단계는 상당히 복잡합니다만,

결과는 아래와 같은 Vue3 컴포넌트가 될 것입니다. (예시)

(vue3 playground의 파싱 결과를 보시면 아시겠지만, 사실 setup 메서드의 리턴 값은 렌더 함수로 변경됩니다.)

export default defineComponent({
  props: {
    message: String
  },
  setup(props) {
    props.message
  }
})

결론

아래 4가지 함수들은 컴파일러 매크로로, 실제 코드 구현은 없는 도우미 함수일 뿐입니다.

const DEFINE_PROPS = 'defineProps'
const DEFINE_EMITS = 'defineEmits'
const DEFINE_EXPOSE = 'defineExpose'
const WITH_DEFAULTS = 'withDefaults'

즉, 실제 기능은 컴파일 타임에만 동작합니다.

따라서 해당 메서드는 <script setup> 내부에서만 동작합니다.

만약 다른 js,ts 파일에서 임포트해서 사용하면, 빈 껍데기 함수만 사용하는 꼴이기에 아무 기능도 동작하지 않습니다.

js, ts파일은 vue 파일이 아니기에 vue 컴파일러의 타겟이 아닙니다.

 

가장 큰 문제점은 Vue3 컴파일러 매크로의 타입스크립트 지원이 아직까지도 좋지 않다는 것입니다.

 

현재는 해당 파일 내의 리터럴 타입(말 그대로 단 하나의 타입 선언)이나, 리터럴 타입의 선언 참조, 인터페이스의 참조만 사용 가능합니다.

즉 유니언 타입이나 인터섹션 타입은 리터럴 타입이 아니기에 사용할 수 없으며,

인터섹션이나 마찬가지인 extends도 제대로 동작 안합니다.

 

해당 규칙을 지키지 않으면 아래 라인의 오류를 만나게 됩니다.

      if (!propsTypeDecl) {
        error(
          `type argument passed to ${DEFINE_PROPS}() must be a literal type, ` +
            `or a reference to an interface or literal type.`,
          propsTypeDeclRaw
        )
      }
    }

참고로 해당 파일에는 ts extends를 처리하는 기능과 유사한 라인이 존재하지만,

테스트 코드를 읽어보면 Vue2의 컴포넌트 extend를 처리하기 위한 기능임을 파악할 수 있습니다. (--;)

현재 interface에 extends가 존재하면 extends 뒤는 싸그리 무시합니다.

 

이유는 ts를 파싱하여 prop을 만드는 기능을 한땀 한땀 넣어줘야 하기 때문인것 같습니다.

babel 자체가 하나의 파일이 처리된 ast 처리에 특화되어 있지 다른 소스에서 뭔가를 가져와 처리하기에 좋지 않은 구조이기도 하고,

ts, vue, js 문법의 짬뽕인 무언가를 한번에 컴파일하는 게 생각보다 쉽지는 않아보이긴 합니다만,

임포트한 타입과 제네릭을 제대로 사용 못한다는 것은 매우 불편합니다.

 

 

 

타입스크립트 기능을 좀 더 강력하게 사용하려면

  • 코어 팀의 누군가가 기능을 추가해주길 기다립니다.
  • defineComponent와 같은 저수준 API를 사용합니다.
  • nuxt3의 기능을 사용하는 방법도 있다고 합니다.(아래 참고 깃헙 이슈 링크 참고)

참고

본문 내용은 직접 작성한 내용이지만, 타입스크립트 문제와 관련된 깃헙 이슈 링크를 첨부합니다.

https://github.com/vuejs/core/issues/4294

 

How to import interface for defineProps · Issue #4294 · vuejs/core

update: please see: #7394 #4294 (comment) Version 3.2.1 Reproduction link https://github.com/Otto-J/vue3-setup-interface-errors/blob/master/src/components/HelloWorld.vue Steps to reproduce clone: g...

github.com

typescript extend가 동작하지 않는 모습

 

Vue SFC Playground

 

sfc.vuejs.org

 

반응형