렌더리스 Vue.js Frame 컴포넌트를 이용한 코드 재사용

Kim Kyeseung·2021년 1월 9일
2
post-thumbnail

로직을 재사용하고 DRY(역자 주: Don't repeat yourself, 중복을 배제하라는 개발원리 중 하나)한 코드를 작성하는 것은 개발자가 가장 먼저 신경써야하는 것들 중 하나입니다. Vue.js 어플리케이션에서는 컴포넌트는 코드 재사용의 주요 수단입니다. 종종 컴포넌트를 마크업, 로직, CSS의 결합으로만 생각하기 쉬운데요, 아무것도 렌더링하지 않은채 오직 로직만을 위해 컴포넌트를 사용하는 것은 직관성이 떨어져 보이니까요.

마크업을 렌더하지 않는 컴포넌트를 렌더리스 컴포넌트라고 부릅니다. 이미 그와 관련하여 렌더리스 컴포넌트를 통한 CRUD 사용법렌더리스 reCAPTCHA 컴포넌트 구성하는 법에 대한 포스팅을 몇 번 썼습니다. '렌더리스'라는 용어는 전체적인 패턴의 이름에 가깝기 때문에 제 코드에서는 'Frame 컴포넌트' 라는 말을 쓰기로 하였습니다. 다른 컴포넌트를 아울러 유용한 함수와 data를 전달하는 프레임을 만들기 때문이죠.

이제부터 Frame 컴포넌트 몇 개를 이용해서 우리 같이 <향상된 Vue.js 어플리케이션 아키텍쳐 설계하기> 포스팅 시리즈에서 단계적으로 차근차근 데모 어플리케이션을 만들어봅시다.

선언적인(declarative) 방식을 통한 promise 사용

우리가 만들어볼 첫번째 컴포넌트는 vue-promised랑 비슷한 것입니다. 이 라이브러리가 맘에 들면 사용해보는 것도 괜찮지만, 직접 만들어보길 원한다면 우리 같이 계속해서 진행합시다.

export default {
  props: {
    promise: {
      default: null,
      type: Promise,
    },
  },
  data() {
    return {
      data: null,
      error: null,
      pending: false,
      resolved: null,
    };
  },
  watch: {
    promise: {
      immediate: true,
      async handler() {
        if (!this.promise) return;

        try {
          this.status({ pending: true });
          const { data } = await this.promise;
          this.status({ data, resolved: true });
        } catch (error) {
          this.status({
            data: null,
            error,
            resolved: false,
          });
        }
      },
    },
  },
  methods: {
    status({
      data = this.data,
      error = null,
      pending = false,
      resolved = null,
    }) {
      this.data = data;
      this.error = error;
      this.pending = pending;
      this.resolved = resolved;
    },
  },
  render() {
    return this.$scopedSlots.default({
      data: this.data,
      status: {
        error: this.error,
        pending: this.pending,
        resolved: this.resolved,
      },
    });
  },
};

위의 코드에 FramePromise를 보세요. promise prop을 받고 Promise를 실행하는 단계마다 적절한 상태값을 설정합니다. 먼저 pending 상태가 설정됩니다. 그리고 promise 요청들이 완료되어 this.data 에 데이터가 입력되면 resolved 상태가 설정됩니다. 만약 에러가 발생한다면 error 상태가 설정되겠지요.

렌더 함수에서 기본 slot을 렌더하고 data와 우리의 세가지 상태(역자 주: pending, resolved, rejected)를 prop으로 전달합니다.

<template>
  <FramePromise
    v-slot="{ data: articles, status: { error, pending } }"
    :promise="articleListPromise"
  >
    <div class="MyArticleListComponent">
      <div v-if="pending">
        Loading ...
      </div>
      <div v-else-if="error">
        Error! Please try again.
      </div>
      <article
        v-else
        v-for="article in articles"
        :key="article.id"
      >
        <h2>{{ article.title }}</h2>
        <p>{{ article.body }}</p>
      </article>
    </div>
  </FramePromise>
</template>

우리의 FramePromise 컴포넌트는 Promise를 받고 그 Promise가 처리한 하위 컴포넌트에 data들을 제공합니다. errorpending prop을 통해서 Promise의 resolve나 reject된 결과에 따라 로딩 컴포넌트나 에러처리를 할 수 있습니다.

선언적인 데이터 fetching

지난 포스팅들 중에, 렌더리스 컴포넌트를 통한 CRUD 사용법에서 데이터를 전부 가져오는 렌더리스 Frame 컴포넌트의 개념을 이야기했습니다. 이 포스팅에서는 일반적이고 높은 재사용성을 위해 코드를 심플하게 유지하고 디자인을 약간 개선하였습니다.

export default {
  props: {
    endpoint: {
      required: true,
      type: Function,
    },
    immediate: {
      default: false,
      type: Boolean,
    },
  },
  data() {
    return {
      response: undefined,
    };
  },
  created() {
    if (this.immediate) this.query();
  },
  methods: {
    query(...params) {
      this.response = this.endpoint(...params);
    },
  },
  render() {
    return this.$scopedSlots.default({
      query: this.query,
      response: this.response,
    });
  },
};

FrameApi 컴포넌트 보이시나요? API 엔드포인트를 prop으로 받아서 자신의 query 함수로 실행합니다. API가 리턴한 Promise는 this.response에 값이 저장됩니다. 렌더 함수에서 query()response를 가지는 컴포넌트를 렌더하는 모습을 볼 수 있습니다.

<template>
  <FrameApi
    v-slot="{ response }"
    :endpoint="listArticles"
    immediate
  >
    <FramePromise
      v-slot="{ data: articles, status: { error, pending } }"
      :promise="response"
    >
      <div class="MyArticleListComponent">
        <div v-if="pending">
          Loading ...
        </div>
        <div v-else-if="error">
          Error! Please try again.
        </div>
        <article
          v-else
          v-for="article in articles"
          :key="article.id"
        >
          <h2>{{ article.title }}</h2>
          <p>{{ article.body }}</p>
        </article>
      </div>
    </FramePromise>
  </FrameApi>
</template>

이 예시에서 데이터를 받아오고 로딩이나 에러 상태를 보여주는 컴포넌트를 만들기 위해서 어떻게 두 Frame 컴포넌트들(역자 주: FrameApi, FrameApi)을 결합하는지 볼 수 있습니다. 완전 선언적이고(declarative) 뷰이시(Vue-ish)하게요.

당장은 약간 혼란스럽고 복잡해 보이지만 어떻게 두 frame 컴포넌트들을 하나로 합쳐서 우리의 어플리케이션에 쉽게 사용할 수 있는지 봅시다.

Frame 컴포넌트 결합하기

이 두 컴포넌트들은 언제나 같이 결합되어 사용되어지기에 우리는 FrameApiFramePromise로 이루어진 세 번째 Frame 컴포넌트를 만들 수 있습니다. 하지만 제 경험상 FrameApi는 절대 FramePromise없이 혼자 사용되지 않아요. 그러므로 우린 FrameApi 컴포넌트를 리팩토링하여 FramePromise 컴포넌트와 통합할 수 있을것입니다.

+ import FramePromise from './FramePromise';
  
  export default {
    props: {
      endpoint: {
        required: true,
        type: Function,
      },
      immediate: {
        default: false,
        type: Boolean,
      },
    },
    data() {
      return {
        response: undefined,
      };
    },
    created() {
      if (this.immediate) this.query();
    },
    methods: {
      query(...params) {
        this.response = this.endpoint(...params);
      },
    },
-   render() {
-     return this.$scopedSlots.default({
-       query: this.query,
-       response: this.response,
+   render(h) {
+     return h(FramePromise, {
+       props: { promise: this.response },
+       scopedSlots: {
+         default: props => {
+           return this.$scopedSlots.default({
+             data: props.data,
+             methods: {
+               query: this.query,
+             },
+             status: {
+               error: props.status.error,
+               loading: props.status.pending,
+               success: props.status.resolved,
+             },
+           });
+         },
+       },
      });
    },
  };

우리는 이제 FrameApi 컴포넌트의 render() 함수에서 FramePromise 컴포넌트를 렌더하고 관련 props를 default scoped slot에 넘겨버립니다. 그리고 pendingresolved로 수정했습니다. API에서 데이터를 가져올 때의 명칭과 통일하기 위해서죠.

일단은 FramePromise 컴포넌트를 왜 따로 갖고 있는지에 대해서 궁금해 하실거라고 생각합니다. 왜냐면 FrameApi 컴포넌트와는 달리 FramePromise 컴포넌트는 지금 이대로 여러 컴포넌트에서 재사용하기가 유용하기 때문입니다.

<template>
  <FrameApi
    v-slot="{ data: articles, status: { error, loading } }"
    :endpoint="listArticles"
    immediate
  >
    <div class="MyArticleListComponent">
      <div v-if="loading">
        Loading ...
      </div>
      <div v-else-if="error">
        Error! Please try again.
      </div>
      <article
        v-else
        v-for="article in articles"
        :key="article.id"
      >
        <h2>{{ article.title }}</h2>
        <p>{{ article.body }}</p>
      </article>
    </div>
  </FrameApi>
</template>

위의 코드를 보시면 새로운 FrameApi 컴포넌트가 훨씬 사용하기 쉽고 status 정보를 제공하는 자연스러운 형태입니다. 우리가 사용하는 FramePromise 컴포넌트는 API 요청보다는 Promise와 훨씬 가까운 모습이니까요.

CodeSandBox

form 데이터 제출하기

이젠 contact 폼을 만들어본다고 가정합시다. 심플 contact form을 위한 form 컴포넌트를 만들기 위해 FrameApi 컴포넌트를 재사용할 수 있습니다. 하지만 form을 제출한 뒤에 사용자에게 새로운 페이지를 redirect 해주기를 원하기 때문에, 먼저 코드를 한 줄 추가해야합니다.

    methods: {
-     query(...params) {
+     async query(...params) {
-       this.response = this.endpoint(...params);
+       this.response = await this.endpoint(...params);
+       this.$emit('success');
      },
    },
    render() {

이젠 API 엔드포인트를 성공적으로 요청한 후, success 이벤트를 발생시킵니다. FramePromise 컴포넌트에서 모든 status 변화에 따른 이벤트를 호출하고 싶으실 지도 모르겠지만 우선은 심플하게 FrameApi 컴포넌트에서 success 이벤트만 발생시키도록 합시다.

<template>
  <FrameApi
    v-slot="{ methods: { query: submit }, status: { error, loading } }"
    :endpoint="contactPost"
    @success="$router.push({ name: 'thank-you' })"
  >
    <form
      class="MyContactForm"
      @submit.prevent="submit(formData)"
    >
      <div v-if="loading">
        Sending ...
      </div>
      <div v-else-if="error">
        Error! Please try again.
      </div>

      <input v-model="formData.name">
      <textarea v-model="formData.text"/>

      <button :disabled="loading">
        Submit
      </button>
    </div>
  </FrameApi>
</template>

<script>
import * as contactService from '../services/contact';

export default {
  // ...
  data() {
    return {
      formData: {
        name: '',
        text: '',
      },
    };
  },
  created() {
    this.contactPost = contactService.post;
  },
  // ...
};
</script>

위에서 구현한 예시에서 form 컴포넌트를 만들기 위해 FrameApi 컴포넌트를 어떻게 사용하고 성공적으로 form 데이터를 전송한 뒤 redirect를 발생시키기 위해 새로 추가한 success 이벤트를 어떻게 활용하는지 보실 수 있습니다.

끝으로

렌더리스 컴포넌트 패턴은 재사용 가능한 코드를 만들기 쉽게 해줍니다. 일반적인 뷰 컴포넌트이기 때문에 뷰이시(Vue-ish)하게 느껴질거에요. Frame 컴포넌트들을 사용하지 않는다면 이 패턴을 이용해서 당신의 코드를 리팩토링해볼 수 있는 기회입니다.

이 포스팅은 <향상된 Vue.js 어플리케이션 아키텍쳐 설계하기> 포스팅 시리즈의 두번째 장입니다. 다은 포스팅은 처음 포스팅에서 만든 UI 컴포넌트를 이 포스팅의 Frame 컴포넌트와 어떻게 결합할 수 있는지에 대해서 좀 더 자세히 다루겠습니다. 제품 목록과 기사 목록을 보여주는 어플리케이션으로요. 뿐만 아니라 어플리케이션을 어떻게 설계하고 컴포넌트 구성에 따라 테스트 사용성이 어떻게 영향을 받는지도 살펴봅시다.

참고


원문: Reusing Logic With Renderless Vue.js Frame Components

profile
웹 프론트엔드 개발자입니다.

0개의 댓글