vue.jsvuejs2vue-render-functionvue-functional-component

Do Vue.js render functions allow return of an array of VNodes?


I am working on extending a Vue.js frontend application. I am currently inspecting a render function within a functional component. After looking over the docs, I had the current understanding that the render function within the functional component will return a single VNode created with CreateElement aka h. My confusion came when I saw a VNode being returned as an element in an array. I could not find any reference to this syntax in the docs. Does anyone have any insight?

export default {
  name: 'OfferModule',
  functional: true,
  props: {
    data: Object,
    placementInt: Number,
    len: Number
  },
  render (h, ctx) {
    let adunitTheme = []
    const isDev = str => (process.env.dev ? str : '')

    const i = parseInt(ctx.props.placementInt)
    const isDevice = ctx.props.data.Component === 'Device'
    const Component = isDevice ? Device : Adunit

    /* device helper classes */
    const adunitWrapper = ctx.props.data.Decorate?.CodeName === 'AdunitWrapper'

    if (!isDevice /* is Adunit */) {
      const offerTypeInt = ctx.props.data.OfferType
      adunitTheme = [
        'adunit-themes',
        `adunit-themes--${type}`.toLowerCase(),
        `adunit-themes--${theme}`.toLowerCase(),
        `adunit-themes--${type}-${theme}`.toLowerCase(),
      ]
    }

    const renderOfferModuleWithoutDisplayAdContainersWithAboveTemplate =
      ctx.props.data.Decorate?.Position === 'AboveAdunit' || false

    const renderOfferModuleWithoutDisplayAdContainers =
      ctx.props.data.Decorate?.RemoveAds /* for adunits */ ||
      ctx.props.data.DeviceData?.RemoveAds /* for devices */ ||
      false

    const getStyle = (className) => {
      try {
        return ctx.parent.$style[className]
      } catch (error) {
        console.log('$test', 'invalid style not found on parent selector')
      }
    }

    const PrimaryOfferModule = (aboveAdunitSlot = {}) =>
      h(Component, {
        props: {
          data: ctx.props.data,
          itemIndex: i,
          adunitTheme: adunitTheme.join('.')
        },
        attrs: {
          class: [
            ...adunitTheme,
            getStyle('product')
          ]
            .join(' ')
            .trim()
        },
        scopedSlots: {
          ...aboveAdunitSlot
        }
      })

    if (renderOfferModuleWithoutDisplayAdContainersWithAboveTemplate) {
      return [
        PrimaryOfferModule({
          aboveAdunit (props) {
            return h({
              data () {
                return ctx.props.data.Decorate
              },
              template: ctx.props.data.Decorate?.Template.replace(
                'v-show="false"',
                ''
              )
            })
          }
        })
      ]
    } else if (renderOfferModuleWithoutDisplayAdContainers) {
      return [PrimaryOfferModule()]
    } else {
      const withAd = i > 0 && i % 1 === 0

      const adWrap = (placement, position, className) => {
        return h(
          'div',
          {
            class: 'm4d-wrap-sticky'
          },
          [
            h(Advertisement, {
              props: {
                placement,
                position: String(position)
              },
              class: getStyle(className)
            })
          ]
        )
      }

      return [
        withAd && adWrap('inline-sticky', i, 'inlineAd'),
        h('div', {
          class: 'm4d-wrap-sticky-adjacent'
        }),

        h(
          'div',
          {
            attrs: {
              id: `inline-device--${String(i)}`
            },
            class: 'inline-device'
          },
          isDev(`inline-device id#: inline-device--${String(i)}`)
        ),

        withAd &&
          i !== ctx.props.len - 1 &&
          h(EcomAdvertisement, {
            props: {
              placement: 'inline-static',
              position: String(i)
            },
            class: getStyle('inlineStaticAd')
          }),

        PrimaryOfferModule()
      ]
    }
  }
}

Solution

  • It turns out that returning an array of VNodes actually predates the scopedSlots update.

    I couldn't find it documented anywhere in the docs either, but via this comment on a Vue GitHub issue by a member of the Vue.js core team (which predates the scopedSlots commit by ~1 year), render() can return an Array of VNodes, which Vue will take and render in order. However, this only works in one, singular case: functional components.

    Trying to return an array of VNodes with greater than 1 element in a normal (non-functional, stateful) component results in an error:

    Vue.config.productionTip = false;
    Vue.config.devtools = false;
    
    Vue.component('render-func-test', {
      render(h, ctx) {
        return [
          h('h1', "I'm a heading"),
          h('h2', "I'm a lesser heading"),
          h('h3', "I'm an even lesser heading")
        ];
      },
    });
    
    new Vue({
      el: '#app',
    });
    <script src="https://unpkg.com/vue@2/dist/vue.js"></script>
    
    <div id="app">
      Test
      <render-func-test></render-func-test>
    </div>

    [Vue warn]: Multiple root nodes returned from render function. Render function should return a single root node.

    But doing this in a functional component, as your example does, works just fine:

    Vue.config.productionTip = false;
    Vue.config.devtools = false;
    
    Vue.component('render-func-test', {
      functional: true, // <--- This is the key
      render(h, ctx) {
        return [
          h('h1', "I'm a heading"),
          h('h2', "I'm a lesser heading"),
          h('h3', "I'm an even lesser heading")
        ];
      },
    });
    
    new Vue({
      el: '#app',
    });
    <script src="https://unpkg.com/vue@2/dist/vue.js"></script>
    
    <div id="app">
      Test
      <render-func-test></render-func-test>
    </div>


    If you're interested in the why, another member of the Vue core team explained this limitation further down in the thread.

    It basically boils down to assumptions made by the Vue patching and diffing algorithm, with the main one being that "each child component is represented in its parent virtual DOM by a single VNode", which is untrue if multiple root nodes are allowed.

    The increase in complexity to allow this would require large changes to that algorithm which is at the very core of Vue. This is a big deal, since this algorithm must not only be good at what it does, but also very, very performant.

    Functional components don't need to conform to this restriction, because "they are not represented with a VNode in the parent, since they don't have an instance and don't manage their own virtual DOM"– they're stateless, which makes the restriction unnecessary.

    It should be noted, however, that this is possible on non-functional components in Vue 3, as the algorithm in question was reworked to allow it.