IT/언어

[Vue.js] Vue를 사용한다면 알아두면 좋은 Vue 패턴과 잔기술

개발자 두더지 2023. 3. 26. 18:55
728x90

일본의 한 블로그 글을 번역한 포스트입니다. 오역 및 의역, 직역이 있을 수 있으며 틀린 내용은 지적해주시면 감사하겠습니다.
 

개요


 이번 포스트에서는 Vue 미경험이었던 상태에서 약 5개월이 된 지금, 그 동안 축적해 온 Vue의 패턴이나 잔기술에 대해 공유하고자 한다.
 
 

v-model의 커스터마이즈


<some-component v-model="isExpanded" />

 v-model은 기본적으로 아래와 같이 바꿔 쓸 수 있다.

<some-component @input="someFunction" :value="isExpanded" />

 그러나, 이를 아래를 이용해 v-model  커스터마이스할 수 있다.

  model: {
    prop: "isExpanded",
    event: "toggle"
  },
  props: {
    isExpanded: {
      type: Boolean,
      default: false
    }
  },

 폼과 같은 컴포넌트라면 v-model과 연결시켜 prop명과 event명을 그대로 value, input으로 해도 좋으나, 폼이외의 컴포넌트의 경우, 그 컴포넌트의 동작에 따른 prpo명과 event명을 위와 같이 할당해주면 좋을 것 같다.
 
 

$once('hook:beforeDestory')


<script>
export default {
  name: "SampleComponent",
  created () {
    this.someEventHandler = () => {
      console.log("실제 개발로 이벤트를 도출해내자!");
    };
    document.addEventListener("mousemove", this.someEventHandler);
  },
  beforeDestroy () {
    document.removeEventListener("mousemove", this.someEventHandler);
  }
};
</script>

  가끔 이처럼 created의 타이밍에서 어떤 이벤트에 특정 처리를 연결하고, beforeDestory의 타이밍에서 동일 이벤트에 연결한 처리를 제거하고 싶은 경우가 있을 것이라고 생각한다. 
 그럴 경우 아래와 같이 쓰면, 보다 심플한 코드로 쓸 수 있다.

<script>
export default {
  name: "SampleComponent",
  created() {
    const eventHandler = () => {
      console.log("실제 개발로 이벤트를 도출해내자!");
    };
    document.addEventListener("mousemove", eventHandler);
    this.$once("hook:beforeDestroy", () => {
      document.removeEventListener("mousemove", eventHandler);
    });
  }
};
</script>

 포인트는 created메소드안에서 한번 실행되는 $once 메소드를 호출해, Vue의 hook:beforeDestroy이벤트에 대해 beforeDestroy메소드 속에 하고 실행하고 싶은 처리를 기재하는 것이다. 
 
 

watch 속성의 immediate:true


 Vue에서 특정 prop의 값이 변했을 때에, 어떠한 처리를 실행하고 싶은 경우, watch 속성을 사용한다. 그러나, watch 속성은 보통 사용하면 감시 대상의 prop 값이 변화했을 때에만, 감시 대상의 prop에 연결된 메소드가 실행된다.

watch: {
  isOpen () {
    this.count = this.count + 1
  }
}

 예를 들어, 이 경우는 부모 컴포넌트에서 받은 prop으로 isOpen의 값이 변화했을 때에만 이 컴포넌트가 가진 count가 증가하게 된다.

watch: {
  isOpen: {
    immediate: true,
    handler() {
      this.count = this.count + 1;
    }
  }
}

 그러나 다음과 같이 바꿔 쓰게 되면 이 컴포넌트가 처음 마운트됐을 타이밍에서도 count가 증가하게 된다.
 
 

Render Function


 대부분의 경우 Vue에서는 <template> 태그만으로 충분하지만, render function이라는 VNode를 프로그마틱하게 생성하는 API를 사용하는 것으로 코드가 보다 깔끔하게 쓸 수 있거나, 유연하게 처리할 수 있다.

<template>
  <p :style="{ color: 'red' }">Hello World</p>
</template>

<script>
export default {
  name: "HelloWorld"
};
</script>

 위 예는 단순히 Hello World를 빨간 글자로 표시하는 것이다.

<script>
export default {
  name: "HelloWorld",
  render(createElement) {
    return createElement("p", 
      { style: { color: 'red' } },
      "Hello World"
    );
  }
};
</script>

 방금의 코드를 render function을 사용하여 쓰면 다음과 같이 쓸 수 있다.

  render(createElement) {
    return createElement("p", 
      { style: { color: 'red' } },
      "Hello World"
    );
  }

 render function의 인수에는 createElement이라는 함수가 전달된다. 이 createElement를 사용하여, VNode를 생성한다.
 createElement의 첫 번째 인수에는 요소명이나 컴포넌트명을 전달하고 두 번째 인수에는 props나 class등의 설정 오브젝트를 임의로 전달한다. 그리고 세 번째 인수에는 자식 요소인 VNode나 문자열을 전달한다.

<script>
export default {
  name: "HelloWorld",
  props: {
    level: {
      type: Number,
      default: 1,
      validator(value) {
        return value > 1 && value <= 6;
      }
    }
  },
  render(createElement) {
    return createElement(
      `h${this.level}`,
      { style: { color: "red" } },
      "Hello World"
    );
  }
};
</script>

 render function을 사용하면 위 예와 같이, 부모에서 level이라는 prop을 통해 1~6 헤드라인 레벨을 받아 받은 헤드라인 레벨에 대응하는 H태그를 통적으로 createElement의 첫 번째 인수로 건네주는 것도 같다.
 이것으로 <template>태그를 이용하면, <template>태그의 안에 대응하고 싶은 표제어 레벨수 만을 이용해 조건 분기를 실시해야하지만, render function을 사용하면 간결하게 코드를 쓸 수 있게 된다.
 
 

Funtional Wrapper Component


 조건마다 다른 컴포넌트를 표시하고 싶은 경우 Functional Component으로 랩핑한 조건에 대응하는 컴포넌트를 표시해두면, 코드가 클린해진다.
 아래는 배열 안에 데이터가 있는 경우는 데이터가 있는 경우의 컴포넌트가 표시되고, 데이터가 없는 경우는 데이터가 없는 경우의 폴 백용 컴포넌트는 표시하는 간단한 예이다. 

<template>
  <div id="app">
    <smart-item-list :items="items" />
  </div>
</template>

<script>
import SmartItemList from "./components/SmartItemList";

export default {
  name: "App",
  components: {
    SmartItemList
  },
  data() {
    return {
      items: [{ id: 1, name: "apple" }, { id: 2, name: "banana" }]
    };
  }
};
</script>

 여기서는 단순히 SmartItemList 컴포넌트의 items prop에 두 개의 오브젝트를 가진 배열을 전달하고 있을 뿐이다.

<script>
import ItemList from "./ItemList";
import EmptyData from "./EmptyData";

export default {
  functional: true,
  props: {
    items: {
      type: Array,
      default() {
        return [];
      }
    }
  },
  render(createElement, { props }) {
    const Component = props.items.length > 0 ? ItemList : EmptyData;
    return createElement(Component, {
      props: {
        items: props.items
      }
    });
  }
};
</script>

 여기에서는 부모에서 전달받은 items 배열 안의 오브젝트의 수를 체크하여, 배열이 비어있는 경우는 EmptyData 컴포넌트를 표시하고, 배열에 무언가가 있는 경우는 ItemList 컴포넌트를 표시하고 있다.

<template>
  <ul>
    <li v-for="item in items" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

<script>
export default {
  name: "ItemList",
  props: {
    items: {
      type: Array,
      default() {
        return [];
      }
    }
  }
};
</script>

 데이터가 있는 경우는 위의 컴포넌트가 표시되고 데이터가 없는 경우는 No Data...만 표시하는 컴포넌트가 표시된다.

<template>
  <div>No Data...</div>
</template>

<script>
export default {
  name: "EmptyData"
};
</script>

 
 

Error Boundary


 React에서는 Error Boundary이라는 자식 컴포넌트에서 에러가 발생했을 때 충돌하는 UI를 표시하는 대신, 폴백용 UI를 표시하는 방법이 존재한다.
 이것을 Vue에서 구현하기 위해서는 Vue의 errorCaptured 를 사용한다. 아래는 그와 관련한 간단한 예이다. 먼저, Error Bound의 예에대해서 살펴보자.

<template>
  <div id="app">
    <ul>
      <template v-for="item in items">
        <error-boundary :fallback="fallbackItem" :key="item.id">
          <dummy-item :item="item" />
        </error-boundary>
      </template>
    </ul>
  </div>
</template>

<script>
import ErrorBoundary from "./components/ErrorBoundary";
import DummyItem from "./components/DummyItem";
import FallbackItem from "./components/FallbackItem";

export default {
  name: "App",
  components: {
    ErrorBoundary,
    DummyItem,
    FallbackItem
  },
  data() {
    return {
      items: [
        { id: 1, name: "apple" },
        { id: 2, name: "banana" },
        { id: 3, name: null }
      ]
    };
  },
  computed: {
    fallbackItem() {
      return FallbackItem;
    }
  }
};
</script>

 여기에서는 에러가 발생하고 있는 컴포넌트를 ErrorBoundary라고 이름을 붙인 컴포넌트로 랩핑하고 있다. ErrorBoundary 컴포넌트의 props인 fallback에서는 에러가 발생했을 때에 대신 표시하고 싶은 폴백용의 컴포넌트를 전달하고 있다.
 props를 통해서 폴백용의 컴포넌트를 설정할 수 있도록 하는 것으로, ErrorBoundary컴포넌트의 이용자가 에러 발생시에 표시하고 싶은 컴포넌트를 자유롭게 자유롭게 선택할 수 있게 된다.

<script>
export default {
  name: "ErrorBoundary",
  props: {
    fallback: {
      type: Object
    }
  },
  data() {
    return {
      hasError: false
    };
  },
  errorCaptured() {
    this.hasError = true;
  },
  render(createElement) {
    return this.hasError
      ? createElement(this.fallback)
      : this.$slots.default[0];
  }
};
</script>

 다음은 ErrorBoundary 텀포넌트를 살펴보자. 여기서 하고 있는 것은 단순히 자식 컴포너틑에서 발생한 에러를 errorCaptured 메소드를 이용해 잡아서 에러가 있을 경우는 폴백용의 컴포너틑를 표시하고, 그렇지 않은경우는 자신의 slots 컨텐츠를 표시한다.

<template>
  <li>{{ item.name.toUpperCase() }}</li>
</template>

<script>
export default {
  name: "DummyItem",
  props: {
    item: {
      type: Object
    }
  }
};
</script>

 이 컴포넌트가 에러가 발생가 발생할 수 있는 가능성이 있는 컴포넌트이다. 부모 컴포넌트에서 props를 통해 받는 item 오브젝트의 name 속성은 문자열이기 때문에, <template>안에서 예로서 item.name.toUpperCase()를 실행하여 문자열을 대문자로 변환하고 있다.
 그러나, API에서 획득한 데이터에 이상값이 포함되어 있는 경우 등을 상정한 경우, item 오브젝틔 name 속성이 결손됐거나 null인 경우가 있을지도 모른다. 그러한 경우에 문자열이 아닌 데이터형에 이번과 같이 toUpperCase 메소드를 실행하면, 렌더링 에러가 발생해버리고 만다.

<template functional>
  <li>nah...</li>
</template>

<script>
export default {
  name: "FallbackItem"
};
</script>

 에러가 발생했을 때는 ErrorBoundary 컴포넌트의 props의 fallback에 전달된 FallbackItem 컴포넌트가 대신 표시된다.
 
 

Higher Order Component


 Higher Order Component는 데이터나 동작을 공통화하고 싶은 경우가 있다. 이는 React에서 친숙한 패턴이며, Vue에서는 render function을 이용하면 가능하다.
Higher Order Component는 인수에 Component를 얻어 다른 Component을 반환하는 고층함수이다.
 아래에서는 임의로 클라이언트쪽에서 인증을 하는 SPA라면 상정한 경우에는 필요할 것 같은 클라이언트 인증용 로직이나 데이터를 제공하는 Higher Order Compnent의 예이다.
 

Higher Order Component예

const requireAuth = WrappedComponent => {
  return {
    name: `${WrappedComponent.name}-protected`,
    computed: {
      isAuthenticated() {
        return this.$store.state.isAuthenticated;
      }
    },
    created() {
      // JWT토근이 존재, 혹은 무효화되어 있는지를 확인
      // 토큰이 없음 혹은 무효라면 로그인 페이지에 리다이렉트
    },
    render(createElement) {
      return createElement(WrappedComponent, {
        props: {
          isAuthenticated: this.isAuthenticated
        }
      });
    }
  };
};

export default requireAuth;

 

쓰는 쪽의 예

export default new Router({
  mode: "history",
  base: process.env.BASE_URL,
  routes: [
    {
      path: "/",
      name: "home",
      component: requireAuth(HomePage)
    },
    {
      path: "/about",
      name: "about",
      component: requireAuth(AboutPage)
    }
  ]
});

 클라이언트 인증 로직을 적용하고 싶은 컴포넌트를 requireAuth 함수의 인수에 전달하면 인수에 전달된 컴포넌틑는 클라이언트 인증 로직을 가지게 된다.
 
 

Container Component, Presentational Component


 React 커뮤니티에서는 자주 보이는 패턴의 하나로 데이터와 동작에 관심을 가지는 Container Component와 표시에 관심을 가지는 Presentational Component로 나눠서 구현하는 경우가 있다.
 render function과 Higher Order Component의 패턴을 사용하면 Vue도 동일한 패턴으로 구현이 가능하다.

// Presentational Componentをimport
import SamplePage from "./SamplePage.vue";

/* 
  아래의`connect`는, Presentational Component를 인수로 가지며, 그 컴포넌트에 관심을 가진다.
  Vuex의 module데이터와 Vue Router의 메소드로의 액세스가 부여된Container Component를반환하는 고차원 함수.
*/
const connect = WrappedComponent => {
  return {
    name: `${WrappedComponent.name}Container`,
    computed: {
      count() {
        return this.$store.state.count;
      }
    },
    methods: {
      handlePageChange({ to }) {
        this.$router.push(to);
      }
    },
    render(createElement) {
      return createElement(WrappedComponent, {
        props: {
          count: this.count
        },
        on: {
          pageChange: this.onChangePage
        }
      });
    }
  };
};

/*
  아래의 두줄은 코드를 Container의 설명을 위해 보다 명확히한 것이다.
  export default connect(SamplePage);
*/
const SamplePageContainer = connect(SamplePage);
export default SamplePageContainer;

export { SamplePage };

 
 

Renderless Compoent(Scoped Slots)


 Vue에는 Higher Order Compoent 그외에도 데이터나 로직을 공통화하는 것으로 Scoped Slots를 이용하는 경우가 있다.
Scoped Slots는 React 커뮤니케이션에서 꽤 등장하는 Render Children이나 Render Props패턴과 같은 것이다. 아래는 Scoped Slots을 이용한 Conatiner Component의 예이다.

<script>
export default {
  name: "DataProvider",
  props: {
    url: {
      type: String
    }
  },
  created() {
    fetch(this.url)
      .then(response => response.json())
      .then(json => (this.data = json))
      .catch(console.error);
  },
  data() {
    return {
      data: []
    };
  },
  render(createElement) {
    return this.$scopedSlots.default({
      data: this.data
    })[0];
  }
};
</script>

 위 코드에서는 예로서 props로 전달된 URL에 Get 리퀘스트를 실행해, 성공했을 때에는 반환된 데이터를 호출한 곳에 scoped slots를 통해서 전달한다. 
this.$scopedSlots.default() 자체는 VNX를 포함한 배열을 반환하므로, 이 메서드의 결과를 그대로 render function 안에서 return 해주면 return createElement('div', [this.$scopedSlots.default()]와 같이 어떤 DOM 요소로 랩해 줄 필요도 없다.

<template>
  <div id="app">
    <data-provider url="https://jsonplaceholder.typicode.com/todos">
      <template slot-scope="{ data: todos }">
        <ul>
          <li v-for="todo in todos" :key="todo.id">{{ todo.title }}</li>
        </ul>
      </template>
    </data-provider>
  </div>
</template>

<script>
import DataProvider from "./components/DataProvider";

export default {
  name: "App",
  components: {
    DataProvider
  }
};
</script>

호출처에서는 DataProvider 컴포넌트 중 fetch한 데이터를 slot-scope를 통해 전달받고 받은 데이터를 slot 컨텐츠로 전달하여 나타내고 있다.

이러한 접근 방식을 취하면 DataProvider 컴포넌트가 가지고 있는 로직을 사용할 수 있으며 DataProvider 컴포넌트에 내포된 컨텐츠는 교체 가능하게 된다.
 
 

Provide / Inject


 Vue에는 Plugin 개발용, 컴포넌트 라이브러리 개발용 API로서 provide & inject API가 준비되어 있다.
 일반적으로 컴포넌트에서 다른 컴포넌트로 데이터를 전달할 때는 '부모 컴포넌트에서 그 아이 컴포넌트로', '그 아이 컴포넌트에서 그 아이 컴포넌트로' 이런 식으로 양동이 릴레이처럼 데이터를 주고받아야 한다.
 하지만 provide & inject API를 사용하면 부모 컴포넌트에서 손자 컴포넌트로 데이터를 직접 전달받을 수도 있는데,React에서는 Context API가 이에 해당합니다.

<template>
  <div>
    <h1>Parent</h1>
    <child />
  </div>
</template>

<script>
import Child from "./Child.vue";

export default {
  name: "Parent",
  components: { Child },
  data() {
    return {
      //provide 대상 데이터를 reactive하게 하기 위해서는 이와 같이 별도의 객체로 내포할 필요가 있다.
      sharedState: {
        message: "Hello World"
      }
    };
  },
  mounted() {
    // 여기에서는 provide 대상의 데이터가 갱신되었을 때에 단지 확인하고 있을 뿐이다.
    setTimeout(() => {
      this.sharedState.message = "Hello Everyone";
    }, 1000);
  },
  provide() {
    return {
      providedState: this.sharedState
    };
  }
};
</script>

 provided State라는 이름으로 this.shared State를 이후 손자 컴포넌트로 받을 수 있게 된다.

<template>
  <div>
    <h1>Child</h1>
    <grand-child />
  </div>
</template>

<script>
import GrandChild from "./GrandChild.vue";

export default {
  name: "Child",
  components: { GrandChild }
};
</script>

 보시다시피 GrandChild 컴포넌트에는 props에서 버킷릴레이로 데이터를 전달하지는 않았다. 그러나 이후 손자 컴포넌트에서는 조금 전 부모 컴포넌트에서 provide 대상으로 한 데이터를 받을 수 있다.

<template>
  <div>
    <h1>Grand Child</h1>
    <p>{{ message }}</p>
  </div>
</template>

<script>
export default {
  name: "GrandChild",
  inject: ["providedState"],
  computed: {
    message() {
      return this.providedState.message;
    }
  }
};
</script>

 위와 같이 상위 계층의 컴포넌트에서 provide로 공개된 데이터를 inject로 받는 것이 가능해진다. provide & inject는 양동이 릴레이 횟수가 많을 경우 편리하다.

 단, 주의점으로는 아마도 React의 Context API과 같이 Vue에서도 이 API는 사양이 변경될 것으로 예상되는 것과, 또 Presentational Component 중에서 직접 Vux의 Store를 참조하고 있을 때와 마찬가지로 provide측과 inject측에서 강한 의존관계가 생겨나기 때문에 Higher Order Component 등을 이용하여 추상화해 주는 것이 API 사양의 변경에도 강하고 또한 소결합도 되기 때문에 그러한 대응이 필요하다.


참고자료
https://qiita.com/HayatoKamono/items/5958d8648007adf6881b

728x90