173 vue.js 렌더링에 관해 - slot

source: categories/study/vue-experiance/vue-experiance_9-99_74.md

173 vue.js 렌더링에 관해 - slot

vue.js에서 slot은 개념적으로 말해서 부모 컴포넌트의 template에 있는 일부 노드를 자식 컴포넌트로 전달해주는 것이라 할 수 있다.

물론 부모 컴포넌트 template 중에 모든 부분이 아니라, 보다시피 자식 컴포넌트 태그 사이에 삽입되어있는 노드들만 가능하다.
이것은 Angular.JS 1.x의 transclude와 정확히 일치하는 개념이다.

자식 컴포넌트에서는 이렇게 부모로부터 전달받은 slot 노드들을 자신의 template 내에 적당한 곳에 삽입할 수 있다.
이 slot은 자식 컴포넌트의 $slots 속성으로 전달되기 때문에 render 함수를 직접 구현하는 경우에는 $slots 속성을 통해서 사용할 수 있다.


//부모 컴포넌트

Vue.component( 'parent-comp', {
    name : 'parent-comp',
    template : `
        <div>
            <child-comp>
                <p>Slot Content</p>
            </child-comp>
        </div>
    `
});
//자식 컴포넌트를 template를 사용해서 작성한 경우
Vue.component( 'child-comp', {
    name : 'child-comp',
    template : `
        <div>
           <slot></slot>
        </div
    `
}); 
//자식 컴포넌트를 render 함수를 써서 작성한 경우
Vue.component( 'child-comp', {
    name : 'child-comp',
    render : function( createElement ) {
        return createElement( 'div', this.$slots.default );
    }
}); 

그런데 여기서 하나 주의해야할 것이 있다.
이렇게 $slots으로 전달받은 부모 컴포넌트의 template의 일부분은, 이미 부모 컴포넌트 render 함수에서 렌더링된 가상 노드(Virtual Node)라는 사실이다.
다시 말해 이미 랜더링 관련한 작업이 끝난 결과물인 가상 DOM의 노드라는 말이다.

Vue.component( 'child-comp', {
    name : 'child-comp',
    render : function( createElement ) {
        console.log( this.$slots.default ); //$slots 내용을 콘솔 출력해 본다.
        return createElement( 'div', this.$slots.default );
    }
});

이 가상 DOM의 노드는 렌더링된 View의 일부이므로 이것을 중복 사용해서 View를 구성하는 것은 잠재적인 문제를 일으킬 수 있다.
따라서 자식 컴포넌트에서는 slot으로 전달 받은 노드들 그냥 자신의 template 내에서 적당히 위치시키는 정도만 해야 한다.
노드를 v-for나 반복문 같은 것을 써서 중복해서 사용하는 것은 피해야 한다.
예를 들어 다음과 같이 사용하는 것은 일부 정상적으로 작동하더라도 피해야 하는 코드다.

//template를 사용해 작성하는 경우
Vue.component( 'child-comp', {
    name : 'child-comp',
    template : `
        <div>
            <!-- slot에 v-for를 사용하면 안된다. -->
            <slot v-for="item in items"></slot>
        </div>
    `,
    data : function() {
        return {
            items : [ 1, 2, 3 ]
        }
    }
});
//render 함수를 사용해 작성하는 경우
Vue.component( 'child-comp', {
    name : 'child-comp',
    render : function( createElement ) {
        var children = [];
        //slot을 반복해서 사용하면 안된다.
        for( var i = 0; i < this.items.lenth; i++ )
            children = children.concat( this.$slots.default );
        
        return createElement( 'div', children );
    },
    data : function() {
        return {
            items : [ 1, 2, 3 ]
        }
    }
}); 

이는 Vue.js 문서에도 나와있다.
가상 Node의 인스턴스는 전체 가상 DOM에서 유일해야 한다.

그런데 Vue.js에는 일반적인 slot 말고 scoped slot(범위 슬롯)이라고 부르는 것이 있다.
이 scoped slot의 목적은 기존의 slot 처럼 부모 컴포넌트의 template에 있는 일부 View를 자식에게 전달하지만, 추가적으로 자식 컴포넌트에서 제공하는 값을 사용해서 랜더링되도록 하는 것이다.

그림에서 보듯이 부모 컴포넌트로부터 전달받은 scoped slot을, 자식 컴포넌트는 자시느이 데이터를 전달해서 렌더링한 후 자신의 template에 삽입할 수 있다.

//부모 컴포넌트
Vue.component( 'parent-comp', {
    name : 'parent-comp',
    template : `
        <div>
            <child-comp>
                <template scope="childData">
                    <p></p>
                </template>
            </child-comp>
        </div>
    `
});
//자식 컴포넌트를 template를 이용해 작성하는 경우
Vue.component( 'child-comp', {
    name : 'child-comp',
    template : `
        <div>
            <slot :msg="myMessage"></slot>
        </div>
    `,
    data : function() {
        return {
            myMessage : 'Hello World'
        }
    }
});
//자식 컴포넌트를 render 함수를 이용해 작성하는 경우
Vue.component( 'child-comp', {
    name : 'child-comp',
    render : function( createElement ) {
        return createElement( 'div', [
            this.$scopedSlots.default({ msg : this.myMessage })
        ]);
    },
    data : function() {
        return {
            myMessage : 'Hello World'
        }
    }
});

이때 부모 컴포넌트에 있는 <template> 태그에 scope 속성을 빼먹지 않도록 주의하자.
이 scope 속성이 없으면 그냥 <template> 태그로 취급되어 앞선글에서 언급했듯이 그냥 사라지고 scoped slot으로 처리되지 않는다.

그런데 자식 컴포넌트의 render 함수에서 scoped slot을 사용하는 코드를 보면 알겠지만 기존의 $slots를 사용할 때와는 다르다는 것을 볼 수 있을 것이다.
$slots.default는 VNode의 배열이기 때문에 createElement에 바로 전달했지만, $scopedSlots.default는 함수로서 인수와 함께 호출해서 그 리턴된 값을 createElement에 사용하고 있다.

이유는 생각해보면 금방 알 수 있다.
$slots은 모든 데이터가 부모 컴포넌트에게서 전달받는 것이므로 부모 컴포넌트가 렌더링될 때 이미 렌더링이 된 상태가 될 수 있지만, $scopedSlots은 자식 컴포넌트의 데이터가 전달되어야만 제대로 렌더링이 될 수 있기 때문에 부모 컴포넌트는 VNode로 인스턴스화 시킬 수 있는 팩토리 함수 형태로만 만들어서 자식 컴포넌트에 전달하는 것이다.
그러면 자식 컴포넌트는 자신의 데이터를 인자로 해서 이 팩토리 함수를 호출해서 실제 VNode 인스턴스들을 만들어내어 사용하게 된다.

실제로 다음과 같은 코드는


<child>
    <template scope="childData">
        <p>{{childData.msg}}</p>
    </template>
</child>

컴파일 후에 다음과 같이 변한다.

 _c( 'child', {
    scopedSlots : _vm._u( [
        {   key : "default",
            fn : function( childData ) {  //<- function 이다
                return [ _c( 'p',  [ _vm._v( _vm._s( childData.msg ) ) ] ) ]
            }
        }
    ])
})

//여기서 _c는 createElement, _vm._u는 resolveScopedSlots이고 슬롯이름에 따른 함수를 매핑해 주는 역할을 한다. _vm._v는 createTextNode이고 _vm._s는 toString을 나타낸다.

보다시피 부모 컴포넌트는 scoped slot에 대해 바로 VNode를 생성하는 것이 아니라 VNode를 생성할 수 있는 함수를 만들어 전달한다.
실제로 $scopedSlots의 값을 출력해보면 함수임을 알 수 있다.

Vue.component( 'child-comp', {
    name : 'child-comp',
    render : function( createElement ) {
        console.log( this.$scopedSlots.default ) //$scopedSlots를 출력해 본다.
        return createElement( 'div', ... )
    }
}); 

이렇게 부모 컴포넌트가 팩토리 함수를 전달하기 때문에 자식 컴포넌트에서는 이 팩토리 함수를 사용해서 임의로 여러개의 VNode를 생성할 수 있다.
그래서 scoped slot에 관해서는 반복문을 통해서 다음처럼 코딩할 수 있게 된다.

//부모 컴포넌트
Vue.component( 'parent-comp', {
    name : 'parent-comp',
    template : `
        <div>
           <template scope="childData">
               <p></p>
           </template>
        </div>
    `
});
//자식 컴포넌트를 template를 이용해 작성하는 경우
Vue.component( 'child-comp', {
    name : 'child-comp',
    template : `
        <div>
            <!-- scoped slot에는 v-for를 사용할 수 있다. -->
            <slot v-for="item in items"  :msg="item"></slot>
        </div>
    `,
    data : function() {
        return {
            items : [ "Hello", "World", "Vue" ]
        }
    }
});
//자식 컴포넌트를 render 함수를 통해 작성하는 경우
Vue.component( 'child-comp', {
    name : 'child-comp',
    render : function( createElement ) {
        var nodes = [];
        //scoped slot은 반복해서 호출해서 사용한다.
        for( var i = 0; i < this.items.length; i++ )
            nodes.push( this.$scopedSlots.default(
               { msg : this.items[ i ] })
            );
        return createElement( 'div', nodes );
    },
    data : function() {
        return {
            items : [ "Hello", "World", "Vue" ]
        }
    }
}); 

이 코드는 VNode를 매번 새로 생성하기 때문에 VNode 인스턴스를 중복해서 사용하지 않는다. 따라서 문제없이 작동하는 코드가 된다.
이 scoped slot이 Vue.js에서 template의 일부를 로직에 따라 임의로 생성하는 유일한 경우라고 할 수 있겠다.

이 scoped slot을 이용하면 구조적 컴포넌트를 만들어 낼 수 있다. 예를 들어 다음처럼 말이다.

<div>
    <my-awesome-comp>
        <template scope="_">    <!-- scope 속성은 꼭 있어야 한다 -->
            <p>Hello</p>
            <p>World</p>
        </template>
    </my-awesome-comp>
</div>

그러나 사용방법 모양새가 그리 좋아보이지는 않는다.

더군다나 scoped slot의 특성상 v-forv-if같은 구조적 디렉티브는 만들 수가 없다.

개인적으로 Angular 4 처럼 <template> 태그를 다루는 일반적인 방법이 마련되어 이런 구조적 디렉티브를 만들 수 있는 방법이 Vue.js에도 있었으면 좋겠다.

[출처] https://m.blog.naver.com/jjoommnn/221087514101

  • 구조적 컴포넌트가 뭐지?