Desmond

Desmond

An introvert who loves web programming, graphic design and guitar
github
bilibili
twitter

Tricks for Vue Composition API

Generically Typed Vue Components#

  • Defining components in Setup
  • Generic props
  • Using SFC as a base component
  • Extracting Prop Types from SFC components
  • Generic Slots
  • Generic Component Events

BaseGenericComponent.vue

<template>
  <pre>
    <slot :current-value="value" :old-value="oldValue">
      {{ value }}
    </slot>
  </pre>
</template>
<script setup lang="ts">
import { watch, defineProps, ref, PropType } from 'vue';
const props = defineProps({
  value: {
    type: null as PropType<unknown>,
    required: true,
  },
});
const oldValue = ref<unknown>();
watch(
  () => props.value,
  (_, oldVal) => {
    oldValue.value = oldVal;
  }
);
</script>

types.ts

export type ExtractComponentProps<TComponent> =
  TComponent extends new () => {
    $props: infer P;
  }
    ? P
    : never;

useGenericComponent.ts

import { defineComponent, h, VNode } from 'vue';
import BaseGenericComponent from './BaseGenericComponent.vue';
import { ExtractComponentProps } from './types';
// We also omit the `onChanged` event so we can overwrite it, same case as the `value` prop
// This is because events and props are treated the same in Vue 3 except that events have `on` prefix
// So if you want to convert an event to a prop you can follow that convention (`changed` => `onChanged`)
type NonGenericProps = Omit<ExtractComponentProps<typeof BaseGenericComponent>, 'value' | 'onChanged'>
interface GenericProps<TValue> extends NonGenericProps {
  value: TValue;
}
interface GenericSlotProps<TValue> {
  currentValue: TValue;
  oldValue: TValue;
}
export function useGenericComponent<TValue = unknown>() {
  // remember to grab the slots object off the second argument
  const wrapper = defineComponent((props: GenericProps<TValue>, { slots }) => {
    // Returning functions in `setup` means this is the render function
    return () => {
      // We pass the slots and event handlers through
      return h(BaseGenericComponent, props, slots);
    };
  });
  // Cast the wrapper as itself so we do not lose existing component type information
  return wrapper as typeof wrapper & {
    // we augment the wrapper type with a constructor type that overrides/adds
    // the slots type information by adding a `$slots` object with slot functions defined as properties
    new (): {
      // Same trick as `$slots`, we override the emit information for that component
      $emit: {
        (e: 'changed', value: TValue): void;
      };
      $slots: {
        // each function correspond to a slot and its arguments are the slot props available
        // this is the default slot definition, it offers the `GenericSlotProps` properties as slot props.
        // it should return an array of `VNode`
        default: (arg: GenericSlotProps<TValue>) => VNode[];
      };
    };
  };
}

How to extract prop types from SFC (you may need to turn on the dark mode to see the image here)
image

Here is an example of how this would work:

<script lang="ts" setup>
import { useGenericComponent } from './genericComponent';
const StringComponent = useGenericComponent<string>();
interface User {
  id: number;
  name: string;
}
const ObjectComponent = useGenericComponent<User>();
function onChange(value: User) {
  console.log(value);
}
</script>
<template>
  <!-- 🛑  This should complain now in Volar due to type error -->
  <StringComponent
    :value="str"
    v-slot="{ currentValue, oldValue }"
    @changed="onChange"
  >
    <div>current: {{ currentValue }}</div>
    <div>old: {{ oldValue }}</div>
  </StringComponent>
  <ObjectComponent
    :value="userObj"
    v-slot="{ currentValue, oldValue }"
    @changed="onChange"
  >
    <div>current: {{ currentValue }}</div>
    <div>old: {{ oldValue }}</div>
  </ObjectComponent>
</template>

Practical Generic Components#

This SelectInput component should:

  • Accept a list of options.
  • Emit the selected value and support v-model API (accepts modeValue prop and emits update:modelValue event).
    Expose an item slot to customize each option’s UI in the floating menu.
    Expose a selected slot to customize the selected option UI.

https://stackblitz.com/edit/vitejs-vite-wemb2u?embed=1

Reducing component noise with Composition API#

  • We can construct components on the fly in the setup function and use it in our template.
  • We can create component wrappers that act as generic components
    This helps in a few ways ways:
  • Predefine some Props
  • Expose clearer APIs with explicit behavior
  • Hide some complexity away

features/modal.ts

import { ref, defineComponent, h } from 'vue';
import ModalDialog from '@/components/ModalDialog.vue';
export function useModalDialog<TData = unknown>(
  onConfirmProp: (data: TData) => void
) {
  // The contextual data
  const data = ref<TData>();
  function onClose() {
    data.value = undefined;
  }
  function show(value: TData) {
    data.value = value;
  }
  function hide() {
    data.value = undefined;
  }
  const DialogComponent = defineComponent({
    inheritAttrs: false,
    setup(_, { slots, emit }) {
      function onConfirmed() {
        if (data.value !== undefined) {
          onConfirmProp(data);
        }
      }
      return () =>
        h(
          ModalDialog, 
          {
            onClose,
            onConfirmed,
            visible: data.value !== undefined,
          },
          {
            default: () => slots.default?.({ data: data.value }),
          }
        );
    },
  });
  return {
    DialogComponent:
      DialogComponent as typeof DialogComponent & {
        // we augment the wrapper type with a constructor type that overrides/adds
        // the slots type information by adding a `$slots` object with slot functions defined as properties
        new (): {
          $emit: {
            (e: 'confirmed', data: TData): void;
          };
          $slots: {
            default: (arg: { data: TData }) => VNode[];
          };
        };
      },
    show,
    hide
  };
}

Example Usage#

<script setup lang="ts">
import { ref } from 'vue';
import { useModalDialog } from '@/features/modal';
interface Item {
  id: number;
  name: string;
}
const items: Item[] = ref([
  //...
]);
const {
  show: onDeleteClick,
  DialogComponent: DeleteItemDialog,
  hide,
} = useModalDialog<Item>((item) => {
  const idx = items.value.findIndex((i) => i.id === item.id);
  items.value.splice(idx, 1);
  console.log('Deleted', item);
  hide();
});
</script>
<template>
  <ul>
    <li
      v-for="item in items"
      :key="item.id"
      @click="onDeleteClick(item)"
    >
      {{ item.name }}
    </li>
  </ul>
  <DeleteItemDialog
    v-slot="{ data: itemToDelete }"
  >
    Are you sure you want to delete {{ itemToDelete.name }}?
  </DeleteItemDialog>
</template>

“When I click an item, open a dialog for it, and when the action is confirmed let me know so I can delete the item”.

You could create some sort of a “shared” modal dialog that many components can reach out to and use. Maybe with the provide/inject API:

// In a parent page or component
const { DialogComponent, ...modalApi } = useModalDialog();
// make modal api available to child components
provide('modal', modalApi);
// Somewhere else in a child component:
const modal = inject('modal');
modal.show(data);

References:

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.