javascriptvue.jsvuejs2vuejs-slotsvue-render-function

Render slot with or without wrapper depending on the prop in Vue 2


I have a simple component that just adds styling to a slot. Styling part is not complicated: it just adds padding/margin/border/background color based on passed props, that's it.

Right now there's a component wrapper and the default slot inside of it. We bind classes and inline styles to the component and it's :is prop is set to div. It gets the job done, but adding an extra div is not ideal, because we use this component a lot and it clutters DOM.

What I want to do is to use that div wrapper only if needContainer prop was set to true. I want to achieve this by implementing a render function, and it works out when wrapper is needed. But I don't know how to just render slots, because it's first parameter is essentially a wrapper element and it's required, if I pass an invalid value it just renders a comment. Also I don't know if there's a way to apply classes and inline styles directly to the slot content.

So, here's my initial code:

<template>
    <component
        :is="div"
        :class="classes"
        :style="styles"
    >
        <slot />
    </component>
</template>

And here's what I've done so far (I've tried to pass template hoping Vue won't render it, but it still gets added to the DOM):

render (createElement) {
        if (this.needContainer) {
            return createElement('div', { attrs: { class: this.classes }, style: this.styles }, this.$scopedSlots.default())
        } else {
            return createElement('template', {}, this.$scopedSlots.default())
        }
    }

UPD

I've tried to use functional component instead of component wrapper. It renders the slot content correctly and without a wrapper, but I can't figure out how to apply styling directly to slot element inside render function.

New template:

<template>
    <ConditionalWrapper
        :tag="div"
        :classes="classes"
        :styles="styles"
        :add-container="addContainer"
    >
        <slot />
    </ConditionalWrapper>
</template>

Added functional component:

    components: {
        ConditionalWrapper: {
            name: 'ConditionalWrapper',
            functional: true,
            render: (h, ctx) => {
                if (ctx.props.addContainer) {
                    return h(ctx.props.tag, { class: ctx.props.classes, style: ctx.props.styles }, ctx.scopedSlots.default())
                } else {
                    return ctx.children[0]
                }
            },
        },
    },

Solution

  • template isn't needed in render function. It could be just return slots.default() in Vue 3, but a component cannot have multiple root nodes in Vue 2. There are no built-in fragments in Vue 2 to overcome this limitation, but there is vue-fragment library that allows this with some existing issues.

    For a wrapper that allows only one child, it could be:

    props: ['class', 'style'],
    render(h) {
        const children = this.$scopedSlots.default?.();
    
        if (!children) {
            return null;
        }
    
        if (this.needContainer) {
            return h('div', { class: this.class style: this.style }, children); 
        } else {
            const [child] = children;
    
            child.data.class = [child.data.class, this.class];
            child.data.style = [child.data.style, this.style];
            
            return child;
        }
    }
    

    It's not safe to patch a vnode instead of creating a new one. This can cause potential problem with reactivity and can cause bugs when the same vnode is used more than once. Vue 3 has convenient cloneVNode utility to solve this. It may be required to clone it manually in Vue 2 with trial and error, as shown here, and full clone can cause cause bugs because an object contains internal data may be specific to a particular vnode. Considering that only style and class are being changed, he solution above may work well in Vue 2, as long as the above considerations are kept in mind.

    It's conventional to use style and class props because they are perceived as fallthrough attributes.

    class and style can be wrapped with another array for merging to cover all the shapes that their values can have in Vue.