SODA Engineering Blog
🏗️

An adventure in Vue 3’s custom render function

2024/12/08に公開

\スニダンを開発しているSODA inc.の Advent Calendar 2024 8日目の記事です!!!/

Hi! I’m Albert, full stack engineer from SODA’s Global team. Our team works on the global version of SNKRDUNK here:

https://snkrdunk.com/en

Let me take you to an adventure I stumbled upon when working with Vue 3 custom render function.

Background

Everything began with a task to refactor the accounts page on SNKRDUNK Global app/website. Instead of showing all user listings in one page, we want to split them according to their status using tabs as shown below:

SNKRDUNK UI

SNKRDUNK Global site uses a lot of these tabs already in our design (even in our homepage!), so I thought there would be a simple way to implement those tabs. I started with looking at the existing tabs and turns out, every single one of them is manually implemented for the page.

The closest one I could use is the home page tabs, which was implemented using Swiper library. There are some problems with that, however. First, the tabs are implemented with CSS transform. This means we actually load the content of all tabs at the same time, resulting in high resource usage.

Parts of the site are loaded even offscreen

Second, and the most important thing, CSS transform breaks IntersectionObserver. I’m planning on using IntersectionObserver to implement infinite scrolling later on so I can’t use this method.

If you haven’t used IntersectionObserver before, try it! It’s great for triggering a function when something appears/disappears or for replacing scroll listeners.

And so, began our journey into the depth of custom render function.

What do we want?

From the research I did on both external libraries and frameworks, as well as our own codebase, I wrote down my requirements for the component:

  1. It should be able to display the tabs and its contents.
  2. It must support IntersectionObserver.
  3. It should only render the selected tab content to save resources.
  4. It should automatically style the tabs.
  5. It must be intuitive to use. I want this component to easy to use and make sense even for primarily backend engineers.

Let’s go through those requirements to understand them better.

Requirement number one is clear. The component should be able to work properly and should follow the design requirements. It is going to be used in production after all.

Requirement two and three are connected, as these two means only one tab content should be rendered at a time. No CSS trickery allowed here (such as hiding components off-screen using transform or using display: none) since it breaks IntersectionObserver.

Requirement four and five are on developer experience. Even though in the Global team most of us work as full stack engineers, a lot of people are more experienced in backend than frontend. By automatically styling the tabs and making it intuitive enough hopefully it will be easier for the other engineers to use the components on other parts of the site.

Designing the component

Let’s come back to the screenshot again. Since we have two sections, the tabs and the contents, we would have to handle both:

SNKRDUNK UI

I looked around other existing frameworks for references. Most of the time, they would use a custom wrapper component to help style the tabs:

<template>
  <TabbedView>
    <TabHeaders>
      <TabHeader>Listing (40)</TabHeader>
      <TabHeader>Processing (1)</TabHeader>
    </TabHeaders>
    <TabPanels>
      <TabPanel key="listing">
        <ListingPage />
      </TabPanel>
      <TabPanel key="processing">
        <ProcessingPage />
      </TabPanel>
    </TabPanels>
  </TabbedView>
</template>

This layout looks good logically: it separates the header and the content, just like in the UI. It allows anything to be passed to the tab header and tab content. However, I don’t like the use of wrapper component for every single part of the tab view. It makes it more difficult to use and harder to read.

Another method is to set the tab title as props to each of the tab content like so:

<template>
  <TabbedView>
    <TabPanel name="Listing (40)">
      <ListingPage />
    </TabPanel>
    <TabPanel name="Processing (1)">
      <ProcessingPage />
    </TabPanel>
  </TabbedView>
</template>

In this method, we’re removing a lot of the wrapper components and make it easier to use and read. However, the drawback is that this method only supports text for the tab headers.

With all of that in mind, let’s design our own. I want the component to be as simple as possible to use. I think having the header and content be separated is a logical step, seeing that the UI is split that way as well. However, I dislike the use of wrapper component like TabHeader. In addition, since I want the tab header content to be customizable, we will not be passing the title using props. This is what I came up with:

<TabbedView>
  <template #headers>
    <div>Listing (40)</div>
    <div>Processing (1)</div>
  </template>

  <ListingPage />
  <ProcessingPage />
</TabbedView>

This layout combines the best of both methods: the simplicity, while allowing for custom header content. The header and content are split, with the header being passed using headers slot and the content being laid out directly on the default slot.

Let’s implement it!

We’ll start with an empty component named TabbedView with a simple custom render function.

TabbedView.vue
<script lang="ts">
import { defineComponent, h } from 'vue'

export default defineComponent({
  setup(props, { slots }) {
    return () => h('div', 'Hello World!')
  }
})
</script>

As you can see, instead of using <template> and <script setup>, we have to use the defineComponent function if we want to use a custom render function. In the setup function, we can return a render function, which returns which nodes to render. With this setup, every time Vue performs a render, our custom render function will be called, which determines what will be rendered.

Those nodes in turn are generated using the h function. We can pass the name of the tag to render, what props it should have, as well as the children nodes.

If you’ve used React prior to JSX (React.createElement), this should be pretty familiar.

If you import and use this component now, it should return a div with the text Hello World!.

App.vue
<script setup lang="ts">
import TabbedView from "@/components/TabbedView.vue"
</script>

<template>
  <TabbedView></TabbedView>
</template>

Hello world!

🎉 Congratulations 🎉! You’ve made your first component with a custom render function!

It currently can’t do anything though. Let’s update it so that at least it displays everything we passed into it:

TabbedView.vue
<script lang="ts">
import { defineComponent, h } from 'vue'

export default defineComponent({
  setup(props, { slots }) {
    return () => h('div', [slots.headers(), slots.default()])
  }
})
</script>
App.vue
<script setup lang="ts">
import TabbedView from "@/components/TabbedView.vue"
</script>

<template>
  <TabbedView>
    <template #headers>
      <div>Listing (40)</div>
      <div>Processing (1)</div>
    </template>

    <div>Listing Page</div>
    <div>Processing Page</div>
  </TabbedView>
</template>

Note that we can call both slots.headers and slots.default as a function. Those functions will then return an array of node (VNode[]) containing the nodes passed into the slot. Doing this will show everything that we passed to TabbedView component.

Everything shows up

You should now get a hint of what we need to do to render just one element. I’ll let you think for a second.

Since we get the list of passed elements from each slot function, we can just take one element from the array and return that one element! It’s that simple!

Let’s try doing that. We’re going to add a variable to track the tab index and display only one tab.

App.vue
import { defineComponent, h, ref } from 'vue'

export default defineComponent({
  setup(props, { slots }) {
    const activeTab = ref(0)

    return () => {
      const headers = slots.headers()
      const contents = slots.default()		  

      return h('div', [slots.headers(), contents[activeTab.value]])
    }
  }
})

Now, let’s check the result…

Only one screen is rendered now

Yes! It only shows one of the tab. Now let’s change to the second tab….? Huh? Pressing the other tab header doesn’t change it…

Turns out, we forgot to add a click handler to the tab headers! Let’s add it now!

App.vue
import { defineComponent, h, ref } from 'vue'

export default defineComponent({
  setup(props, { slots }) {
    const activeTab = ref(0)

    return () => {
      const headers = slots.headers().map((el, i) => {
        return h('div', {
          onClick: () => {
            activeTab.value = i
          }
        }, el)
      })
      const contents = slots.default()		  

      return h('div', [headers, contents[activeTab.value]])
    }
  }
})

Now, instead of returning the headers directly, we wrap each in a div, with a click handler which will set the activeTab value accordingly. This is because the second parameter of h function actually allows us to set the props of the returned node. If we try it now:

Now it works!

Styling the components

With all that finished, the basic functionalities of the component is also finished! However, the design leaves a lot to be desired, so we’ll start styling the component. Since CSS is out of the scope of this article, I’ll provide the CSS but not explain it in detail.

First, let’s tackle the tab headers. We want the tabs to be shown side by side. To do that, let’s wrap the tabs with a div to separate them from the contents. We’ll want to style both the section and the tabs as well, so let’s assign a class name while we’re at it. As h function accepts props, we can just add class props there.

TabbedView.vue
<script lang="ts">
import { defineComponent, h, ref } from 'vue'

export default defineComponent({
  setup(props, { slots }) {
    const activeTab = ref(0)

    return () => {
      const headers = slots.headers().map((el, i) => {
        return h('div', {
          onClick: () => {
            activeTab.value = i
          },
          class: 'tab',
        }, el)
      })
      const headerSection = h('div', { class: 'tab-headers' }, headers)

      const contents = slots.default()

      return h('div', [headerSection, contents[activeTab.value]])
    }
  }
})
</script>

<style scoped>
.tab-headers {
  display: flex;
  background: #fff;
}

.tab {
  width: 100%;
  padding: 0.75rem 1rem;
  color: #aaa;
  font-weight: bold;
  text-align: center;
  cursor: pointer;
  border-bottom: 0.2rem solid transparent;
}

.tab.selected {
  color: #000;
  border-bottom-color: #000;
}
</style>

It's looking better now!

It’s looking a lot nicer now! However, while we have .tab.selected defined, we have not used it yet. Let’s apply the selected class to the active tab:

TabbedView.vue
const headers = slots.headers().map((el, i) => {
  return h('div', {
    onClick: () => {
      activeTab.value = i
    },
    class: ['tab', { selected: i === activeTab.value}],
  }, el)
})

Just like with the normal Vue template, we can use an array, object, or string to define the class. Since we only want to apply the selected class to the active tab, we return true only if the active tab value is equal to the tab index.

The component styling is finished

Nice! We’re done with the component now!

Handling the bugs

While manually passing in the pages and tabs work just fine, we often want to do it programmatically. With this example, since those tab contents differ only in the endpoint, usually we define those as a list of tab title and URL like so:

const tabs = [
  { title: 'Listing', url: 'https://snkrdunk.test/listings?status=active' },
  { title: 'Processing', url: 'https://snkrdunk.test/listings?status=processing' },
]

Then, we’ll just iterate the list and show the tab labels and contents accordingly.

Let’s try that with our new component:

App.vue
<script setup lang="ts">
import TabbedView from "@/components/TabbedView.vue"
const tabs = [
  { title: 'Listing', url: 'https://snkrdunk.test/listings?status=active' },
  { title: 'Processing', url: 'https://snkrdunk.test/listings?status=processing' },
]
</script>

<template>
  <TabbedView>
    <template #headers>
      <div v-for="t in tabs" :key="`tab-${t.title}`">{{ t.title }}</div>
    </template>

    <div v-for="t in tabs" :key="`content-${t.title}`">
      <div>{{ t.title}} Page</div>
      <div>Fetching data from <code>{{ t.url }}</code></div>
    </div>
  </TabbedView>
</template>

It renders incorrectly

Eh? It doesn't show up correctly. We want to have multiple tabs, but we have everything on one page.

Turns out, v-for behaves differently compared to normal element. Instead of receiving the list of children generated by the v-for directly, we receive a fragment containing the actual children.

v-for produces fragments

This means every time we receive a fragment node, we have to get the children of that node instead. We’ll have to do this for both the headers and default slot. Therefore, let’s create a utility function so that we can reuse it:

TabbedView.vue
const hoistFragmentChildren = (nodes: VNode[]): VNode[] => {
  const hoisted = nodes.map((el) => {
    const isFragment = el.type === Symbol.for('v-fgt')
    if (isFragment) {
      return el.children as VNode[]
    }
    return el
  })
  return hoisted.flat(1)
}

This function will receive a list of VNode (returned from slots) and iterates through them. If any of those nodes are actually fragments, we’ll add the children of the fragment instead. Lastly, we flatten the list to remove any nested arrays.

We can then call the function in our render function as follows:

TabbedView.vue
export default defineComponent({
  setup(props, { slots }) {
    const activeTab = ref(0)

    return () => {
      const headerNodes = hoistFragmentChildren(slots.headers())
      const headers = headerNodes.map((el, i) => {
        return h('div', {
          onClick: () => {
            activeTab.value = i
          },
          class: ['tab', { selected: i === activeTab.value}],
        }, el)
      })
      const headerSection = h('div', { class: 'tab-headers' }, headers)

      const contentNodes = hoistFragmentChildren(slots.default())
      const content = contentNodes[activeTab.value]

      return h('div', [headerSection, content])
    }
  }
})

Now, when we run it, it works normally!

The final version of our component

End of our adventure

With the main tab component done, we’re finished with our adventure. We have used the custom render function technique to create a tab view component. The component can automatically style the tabs, assign click handler, and render only one screen based on which tab is active.

While our adventure ends here, it’s not the end though. We can improve our component a lot more. There are still many things we can add, such as:

  • Validate that the default slot cannot be empty.
  • Adding props for the default selected tab index.
  • Implement swipe gesture to switch tabs.
  • Tab switching animation.
  • Implement KeepAlive to prevent the tabs being reloaded when user switches tab.
  • Restore scroll position on tab switch (this depends on your CSS/styling; you might not need it).

However, all of those will be your own adventure to take. Hopefully you’ve learned something from reading this article!

If you want to see the final version of the code, you can access it here:

Tying up loose ends

If you’re an experienced front-end (especially Vue) developer, I’m sure you had noticed certain things or had some questions when reading the article. While I can’t answer all of them in this article, I would like to try answer some of those here:

Can’t you just use slot and template instead?

With our desired structure? No, it’s because we can’t easily modify the content of a slot without using the custom render function. This is unfortunately needed to style and assign the click handler to those tab selectors.

Technically, we would approach this kind of problem by passing the change tab function through slot to the header combined with custom slot name, as follows:

<template>
  <tabbed-view>
    <template #header="{ changeTo }">
      <div @click="changeTo(0)">Tab A</div>
      <div @click="changeTo(1)"><img src="tab-b.png" alt="Tab B"/></div>
    </template>

    <template #tab-0>
      <div>Tab A content</div>
    </template>
    <template #tab-1>
      <div>Tab B content</div>
    </template>
  </tabbed-view>
</template>

However, using this structure means the component user needs to:

  • understand how to utilize slots and its passed function,
  • assign correct key for the slots,
  • apply the styling to the tabs, and
  • manually apply the click handler to the tabs.

In the component itself, we can then render the correct slot by using the active index and key name.

No CSS transform? Really?

Well… not exactly. This is out of scope of this article, but I’m planning on adding back CSS transform during a swipe gesture to make the gesture more natural. However, it will be used strictly in those transitional period where IntersectionObserver is not going to be triggered. After the swipe animation is done, only the active tab will be rendered and transform is removed.

Why do you use h()? Can’t we just use JSX?

Yes, we can use JSX! The first time I worked on a Vue component with custom render function, I used JSX instead of h function. However, since this is the first component using custom render function in SNKRDUNK global codebase, we don’t have the Vue JSX compiler setup yet. In addition, this kind of component is very rare in our codebase so adding JSX compiler is not a priority.

Do we need to handle nested fragments (nested v-for)?

Since we’re just trying to make sure we can use v-for to create multiple tabs and tab contents, we only need to handle fragments passed directly to the component. If the fragment contains another fragments, it will be rendered as a part of the tab (inside, not as a separate tab).

SODA Engineering Blog
SODA Engineering Blog

Discussion