본문 바로가기

FrontEnd

Vue.js plugin(플러그인) 만들어보기

반응형

Vue.js 플러그인을 만들어봅니다.

원문 링크입니다 : https://snipcart.com/blog/vue-js-plugin

 

Create a Custom Vue.js Plugin in < 1 Hour [Code Included]

Vue.js plugins are essential for developers working with this JS framework. This post explains what they are, and how to craft a production-ready Vue plugin in < 1 hour.

snipcart.com

Directive와 Plugin은 주로 프레임워크 개발자가 사용하는 기능입니다.

하지만 좀 더 깊은 이해를 위해 스스로 플러그인을 만들어 보는것은 큰 도움이 됩니다.

  • 플러그인이란 뭘까요?
  • 플러그인은 무엇에 유용한가요?
  • 인기 있는 Vue.js 플러그인은 무엇인가요?

Vue.js와 플러그인에 관한 짧은 이야기

플러그인이 뭘까요?

플러그인은 Vue.js에만 국한된 것이 아니며 일반적으로 다양한 소프트웨어에서 찾을 수 있습니다.
정의에 따르면 확장을 허용하기 위해 제공된 인터페이스를 나타냅니다.

간단히 말해서 앱에 전역 기능을 추가하는 방법입니다.
Vue.js 플러그인은 두 개의 매개변수를 사용하는 install 메서드를 노출해야 합니다.

  1. 전역 Vue 객체
  2. 사용자 정의 옵션을 통합하는 객체

왜 플러그인을 써야 하나요?

간단하지만 강력하기 때문입니다.

Vue 기술을 연마하고 싶다면 플러그인을 활용하지 않는 것이 큰 실수입니다.

 

공식 Vue.js 문서(Vue.js documentation)에 따르면 
플러그인을 사용하여 다음과 같은 기능을 달성할 수 있습니다.

  • 전역 메서드 또는 속성을 추가합니다.
  • 하나 이상의 글로벌 에셋(direcive, filter, transition 등)을 추가합니다.
  • 전역 믹스인을 통해 컴포넌트 옵션을 추가합니다.
  • Vue.prototype를 Vue 인스턴스 메소드를 통해 추가합니다.
  • 위의 조합을 inject하면서 자체적인 API를 provide하는 라이브러리를 만듭니다.

유명한 Vue.js 플러그인

새 Vue 프로젝트를 시작하기 전에 다음 플러그인의 존재를 아는 것이 중요하다고 생각합니다.

Vue-router

단일 페이지 애플리케이션을 구축하는 경우 의심할 여지 없이 Vue-router가 필요합니다.
Vue.js의 공식 라우터로서 코어와 긴밀하게 통합되어 컴포넌트 매핑 및 경로 중첩과 같은 작업을 수행합니다.

Vuex

애플리케이션의 모든 컴포넌트에 대한 중앙 집중식 저장소 역할을 하는 Vuex는
유지보수가 많이 필요한 대규모 앱을 빌드하려는 경우 간단합니다.

Vee-validate

우아한 양식 유효성 검사 기능을 제공합니다.
irective를 사용한 로컬라이징을 염두에 두고 구축되었습니다.

이 외에도 다양한 플러그인들이 있습니다.

Vue.js 개발자를 돕기 위해 기다리고 있는 다른 플러그인이 많이 있습니다.
그러나 언젠가는 발견되지 않은 사용 사례를 우연히 발견하게 될 수 있습니다.
운 좋게도 이를 지원하는 커스텀 Vue.js 플러그인을 만드는 것은 생각만큼 어려운 작업이 아닙니다.


상용 Vue.js 플러그인 만들기

Context

Snipcart의 모회사인 Spektrum(Snipcart's mother agency)에서는 모든 디자인 작업이 승인 프로세스를 거칩니다.
클라이언트는 디자인에 대해 의견을 제시하고 제안할 수 있으며 최종적으로 승인할 수 있습니다.
이 협업 워크플로우를 지원하기 위해 InVision(InVision) 플랫폼을 사용합니다.

 

댓글 시스템은 InVision의 핵심 요소입니다.
이를 통해 사람들은 디자인의 아무 부분이나 클릭하고
공동 작업자를 위한 댓글을 남길 수 있습니다.

그러면 댓글은 댓글 작성자가 클릭한 위치에 배지로 표시됩니다.

바로 그 일을 하는 완전한 Vue.js 플러그인을 개발해 봅시다!
모든 HTML 요소에 연결 가능해야 하고 호스트 응용 프로그램을 가능한 한 방해하지 않아야 합니다.

데모를 먼저 경험해 보세요 > 데모 보기
반응형

1. Prepare the codebase

Vue CLI 3 덕분에 Vue.js 코드베이스 초기화가 그 어느 때보다 쉬워졌습니다.
CLI가 설치된 상태에서 다음을 실행하기만 하면 됩니다.

$ vue create vue-comments-overlay
# Answer the few questions
$ cd vue-comments-overlay
$ npm run serve
클래식 Vue.js "Hello World" 앱이 실행됩니다. 당신의 테스트 앱이 될 것입니다.

2. Develop the Vue.js plugin

일부 컴포넌트가 있으므로 단일 폴더에 모두 넣는 것이 좋습니다.
$ mkdir src/plugins
$ mkdir src/plugins/CommentsOverlay
$ cd src/plugins/CommentsOverlay

2.1 Basic wiring

Vue.js plugins은 기본적으로 install 함수가 있는 객체입니다.
Vue.use()를 통해 플러그인을 포함하여 Vue 앱을 실행합니다.

 

install 함수는 전역 Vue 객체와 Option 객체를 매개변수로 받습니다.

이 전역  Vue 개체를 사용하면 아래와 같이 무한하게 Vue의 기능을 확장할 수 있습니다.

  • Vue의 프로토타입을 확장
  • 사용자 지정 directive을 추가
  • 플러그인 내부에서 새로운 Vue 인스턴스를 부팅
플러그인의 골격을 만드는 것으로 시작해 보겠습니다.
// src/plugins/CommentsOverlay/index.js
// 
export default {
    install(vue, opts){   
        console.log('Installing the CommentsOverlay plugin!')
        // Fun will happen here
    }
}
이제 이것을 테스트 애플리케이션에 연결해 보겠습니다.
// src/main.js

import Vue from 'vue'
import App from './App.vue'
import CommentsOverlay from './plugins/CommentsOverlay'

Vue.use(CommentsOverlay)

Vue.config.productionTip = false

new Vue({ render: createElement => createElement(App)}).$mount('#app')

2.2 Support for options

플러그인은 install 함수의 두 번째 인수인 Option을 사용하여 설정할 수 있습니다.
플러그인의 default 동작,
즉 사용자 지정 옵션이 지정되지 않은 경우의 동작을 나타내는 디폴트 옵션을 만들어 봅시다.

// src/plugins/CommentsOverlay/index.js

const optionsDefaults = {
    // Retrieves the current logged in user that is posting a comment
    commenterSelector() {
        return {
            id: null,
            fullName: 'Anonymous',
            initials: '--',
            email: null
        }
    },
    data: {
        // Hash object of all elements that can be commented on
        targets: {},
        onCreate(created) {
            this.targets[created.targetId].comments.push(created)
        },
        onEdit(editted) {
            // This is obviously not necessary
            // It's there to illustrate what could be done in the callback of a remote call
            let comments = this.targets[editted.targetId].comments
            comments.splice(comments.indexOf(editted), 1, editted);
        },
        onRemove(removed) {
            let comments = this.targets[removed.targetId].comments
            comments.splice(comments.indexOf(removed), 1);
        }
    }
}

install 메서드 내부에서 해당 디폴트 옵션을 병합합니다.

// src/plugins/CommentsOverlay/index.js

export default {
    install(vue, opts){

        // Merge options argument into options defaults
        const options = { ...optionsDefaults, ...opts }
        
        ...
    }
}

2.3 Vue instance for the commenting layer

코멘트 컴포넌트가 기존 앱의 스타일과 돔을 간섭하지 않고 싶습니다.
이런 일이 발생할 가능성을 최소화하기 위한 한 방법은,
플러그인을 기본 앱의 컴포넌트 트리 외부에 있는 다른 루트 Vue 인스턴스에 만드는 것입니다.
 
install 함수를 수정합니다.
// src/plugins/CommentsOverlay/index.js

export default {
    install(vue, opts){

        ...

    // Create plugin's root Vue instance
        const root = new Vue({
            data: { targets: options.data.targets },
            render: createElement => createElement(CommentsRootContainer)
        })
        
        // Mount root Vue instance on new div element added to body
        root.$mount(document.body.appendChild(document.createElement('div')))

        // Register data mutation handlers on root instance
        root.$on('create', options.data.onCreate)
        root.$on('edit', options.data.onEdit)
        root.$on('remove', options.data.onRemove)
        
        // Make the root instance available in all components
        vue.prototype.$commentsOverlay = root
        
        ...
        
    }
}
위 스니펫의 핵심 포인트!:
  • 앱은 본문(body) 끝에 있는 새 div에 있습니다.
  • options 객체에 정의된 이벤트 핸들러는 루트 인스턴스의 일치하는 이벤트에 연결됩니다.
    • 튜토리얼이 끝나면 이해가 될 것입니다.
  • Vue의 프로토타입에 추가된 $commentsOverlay 속성은 애플리케이션의 모든 Vue 컴포넌트에 루트 인스턴스를 노출합니다.

2.4 Custom Vue.js directive

마지막으로 컨슈머 앱이 코멘트를 활성화할 요소를 플러그인에 알리는 방법이 필요합니다.
바로 사용자 정의 Vue.js directive에 딱 맞는 케이스 입니다.
플러그인은 전역 Vue 객체에 액세스할 수 있으므로 새 directive를 정의할 수 있습니다.

해당 플러그인의 이름은 comment-enabled로 지정되며 다음과 같이 구현합니다.

// src/plugins/CommentsOverlay/index.js

export default {
    install(vue, opts){

        ...

        // Register custom directive tha enables commenting on any element
        vue.directive('comments-enabled', {
            // bind – directive가 엘리먼트에 바인딩될 때 한 번 호출됩니다.
            bind(el, binding) {

                // Add this target entry in root instance's data
                //root.targets[binding.value] = 아래객체;
                // 루트에 바인딩된 targets 객채의 반응성을 통해 코멘트 컴포넌트를 추적합니다.
                root.$set(
                    root.targets,
                    binding.value,
                    {
                        id: binding.value,
                        comments: [],
                        getRect: () => el.getBoundingClientRect(),
                    });

                el.addEventListener('click', (evt) => {
                    root.$emit(`commentTargetClicked__${binding.value}`, {
                        id: uuid(),
                        commenter: options.commenterSelector(),
                        clientX: evt.clientX,
                        clientY: evt.clientY
                    })
                })
            }
        })
    }
}

directive는 다음 두 가지 작업을 수행합니다.
이제 install 함수가 완성되었습니다!

2.5 components

CommentsRootContainer는 플러그인 UI의 루트 컴포넌트입니다.
// src/plugins/CommentsOverlay/CommentsRootContainer.vue

<template>
  <div>
    <comments-overlay
        v-for="target in targets"
        :target="target"
        :key="target.id">
    </comments-overlay>
  </div>

</template>

<script>
import CommentsOverlay from "./CommentsOverlay";

export default {
  components: { CommentsOverlay },
  computed: {
    targets() {
      return this.$root.targets;
    }
  }
};
</script>

target computed 속성이 루트 컴포넌트의 data에서 어떻게 파생되는지 확인합니다.
CommentsOverlay 컴포넌트는 모든 마술이 일어나는 곳입니다.

CommentsOverlay component

// src/plugins/CommentsOverlay/CommentsOverlay.vue

<template>
  <div class="comments-overlay">

    <div class="comments-overlay__container" v-for="comment in target.comments" :key="comment.id" :style="getCommentPostition(comment)">
      <button class="comments-overlay__indicator" v-if="editting != comment" @click="onIndicatorClick(comment)">
        {{ comment.commenter.initials }}
      </button>
      <div v-else class="comments-overlay__form">
        <p>{{ getCommentMetaString(comment) }}</p>
        <textarea ref="text" v-model="text" />        
        <button @click="edit" :disabled="!text">Save</button>
        <button @click="cancel">Cancel</button>
        <button @click="remove">Remove</button>
      </div>
    </div>

    <div class="comments-overlay__form" v-if="this.creating" :style="getCommentPostition(this.creating)">
      <textarea ref="text" v-model="text" />
      <button @click="create" :disabled="!text">Save</button>
      <button @click="cancel">Cancel</button>
    </div>

  </div>
</template>

<script>
export default {
  props: ['target'],

  data() {
    return {
      text: null,
      editting: null,
      creating: null
    };
  },

  methods: {
    onTargetClick(payload) {
      this._resetState();
      const rect = this.target.getRect();

      this.creating = {
        id: payload.id,
        targetId: this.target.id,
        commenter: payload.commenter,
        ratioX: (payload.clientX - rect.left) / rect.width,
        ratioY: (payload.clientY - rect.top) / rect.height
      };
    },
    onIndicatorClick(comment) {
      this._resetState();
      this.text = comment.text;
      this.editting = comment;
    },
    getCommentPostition(comment) {
      const rect = this.target.getRect();
      const x = comment.ratioX * rect.width + rect.left;
      const y = comment.ratioY * rect.height + rect.top;
      return { left: `${x}px`, top: `${y}px` };
    },
    getCommentMetaString(comment) {
      return `${
        comment.commenter.fullName
      } - ${comment.timestamp.getMonth()}/${comment.timestamp.getDate()}/${comment.timestamp.getFullYear()}`;
    },
    edit() {
      this.editting.text = this.text;
      this.editting.timestamp = new Date();
      this._emit("edit", this.editting);
      this._resetState();
    },
    create() {
      this.creating.text = this.text;
      this.creating.timestamp = new Date();
      this._emit("create", this.creating);
      this._resetState();
    },
    cancel() {
      this._resetState();
    },
    remove() {
      this._emit("remove", this.editting);
      this._resetState();
    },
    _emit(evt, data) {
      this.$root.$emit(evt, data);
    },
    _resetState() {
      this.text = null;
      this.editting = null;
      this.creating = null;
    }
  },

  mounted() {
    this.$root.$on(`commentTargetClicked__${this.target.id}`, this.onTargetClick
    );
  },
  
  beforeDestroy() {
    this.$root.$off(`commentTargetClicked__${this.target.id}`, this.onTargetClick
    );
  }
};
</script>
주의할 몇 가지 사항:
  • 컴포넌트는 전체 target Object를 prop으로 받습니다. 코멘트 배열과 위치 정보가 저장되는 곳입니다.
  • 앞에서 본 commentTargetClicked 이벤트에 대한 핸들러는 마운트된 후크와 beforeDestroy 훅 내에서 관리됩니다.
  • 루트 인스턴스는 이벤트 버스로 사용됩니다.

이 접근 방식이 종종 권장되지 않더라도,
컴포넌트가 공개적으로 노출되지 않으며 단일 단위로 볼 수 있기 때문에 이 맥락에서 합리적이라고 판단했습니다. 
이제 모든것이 준비되었습니다!

흐름 정리

원문은 이게 끝인데 이해가 잘 안되는것 같아서 읽으면서 정리했습니다.

1. 옵션을 통해 디폴트 메서드 구현

2. install(vue,option) 시그니처와 directive를 구현한 객체 리턴

  • 코멘트 오버레이의 별도의 root는 이벤트 버스 역할을 하며, emit된 이벤트에 따라 option 객체의 메서드를 호출합니다.
  • prototype을 통해 코멘트오버레이의 root를 노출합니다. ($commentsOverlay)
  • root의 targets 객체의 반응성을 통해 오버레이 컴포넌트를 관리합니다.
  • root의 targets는 디렉티브를 가진 컴포넌트(comments-enabled)가 바인딩되면 메서드를 추가해줍니다.
  • 아래 파일의 default export 부분을 참고하세요

3. CommentsRootContainer는 퍼사드 역할로 targets을 순회하며 target을 그립니다

4.CommentsOverlay.vue는 target을 편칩 추가 제거하는 역할을 수행합니다.

Live demo & GitHub repo

See the live demo here

See GitHub repo here

마치며

간결함을 위해 크기 조정은 생략했습니다.
컴포넌트가 렌더링될 때의 위치를 계산하기 위한 완벽한 ratioX 및 ratioY를 저장하고 있지만
초기 로드 후 페이지 크기를 조정하면 코멘트의 레이아웃이 손상됩니다.

이것은 window.onresize 또는 ResizeObserver를 사용하여 수정할 수 있습니다.

반응형