Two years ago, shortly after vue3 was released, an sfc proposal was created that allowed all top-level variables in scripts to be bound to templates by default to close one of the gaps in the developer experience with react.

Similar to this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<script setup>
// imported components are also directly usable in template
import Foo from './Foo.vue'
import { ref } from 'vue'

// write Composition API code just like in a normal setup()
// but no need to manually return everything
const count = ref(0)
const inc = () => {
  count.value++
}
</script>

<template>
  <Foo :count="count" @click="inc" />
</template>

It does solve some inherent problems

  • template can’t access js value fields
  • defineProps/defineEmits does not reuse type definitions

After two years, it does work in production, even if it may still not be perfect, and the two main problems it solves and how it differs from other approaches are described below.

template access to js value fields

In react, we can access any js value field directly in jsx. For example, import a variable and use it in jsx.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { List } from 'antd'
import { uniq } from 'lodash-es'
import { useState } from 'react'

export function App() {
  const [list] = useState([1, 2, 1])
  return (
    <List
      dataSource={uniq(list)}
      renderItem={(item) => <List.Item.Meta key={item} title={item} />}
    />
  )
}

In vue2, the values that the template can access must be registered to vue, regardless of data/methods/components.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script lang="ts">
import { defineComponent } from 'vue'
import { List, ListItemMeta } from 'ant-design-vue'
import { uniq } from 'lodash-es'

export default defineComponent({
  components: { List, ListItemMeta },
  data: () => ({
    list: [1, 2, 1],
  }),
  methods: {
    uniq,
  },
})
</script>

<template>
  <List :data-source="uniq(list)">
    <template #renderItem="{ item }">
      <ListItemMeta :title="item" />
    </template>
  </List>
</template>

In vue3, although hooks are available, the problem of js value fields is still not solved, only that they can be exposed to the template uniformly through setup.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<script lang="ts">
import { defineComponent, ref } from 'vue'
import { List, ListItemMeta } from 'ant-design-vue'
import { uniq } from 'lodash-es'

export default defineComponent({
  components: { List, ListItemMeta },
  setup() {
    const list = ref([1, 2, 1])
    return { list, uniq }
  },
})
</script>

<template>
  <List :data-source="uniq(list)">
    <template #renderItem="{ item }">
      <ListItemMeta :title="item" />
    </template>
  </List>
</template>

In vue3 setup script, however, this problem is partially solved by making all values in the script accessible in the template.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<script lang="ts" setup>
import { ref } from 'vue'
import { List, ListItemMeta } from 'ant-design-vue'
import { uniq } from 'lodash-es'
const list = ref([1, 2, 1])
</script>

<template>
  <List :data-source="uniq(list)">
    <template #renderItem="{ item }">
      <ListItemMeta :title="item" />
    </template>
  </List>
</template>

As you can see, the setup script is close to the experience of using react jsx

Of course, you can see the actual compilation results of the setup script at https://sfc.vuejs.org/, for example, the above code will be compiled as follows.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/* Analyzed bindings: {
  "ref": "setup-const",
  "List": "setup-maybe-ref",
  "ListItemMeta": "setup-maybe-ref",
  "uniq": "setup-maybe-ref",
  "list": "setup-ref"
} */
import { defineComponent as _defineComponent } from 'vue'
import {
  unref as _unref,
  createVNode as _createVNode,
  withCtx as _withCtx,
  openBlock as _openBlock,
  createBlock as _createBlock,
} from 'vue'

import { ref } from 'vue'
import { List, ListItemMeta } from 'ant-design-vue'
import { uniq } from 'lodash-es'

const __sfc__ = /*#__PURE__*/ _defineComponent({
  __name: 'App',
  setup(__props) {
    const list = ref([1, 2, 1])

    return (_ctx, _cache) => {
      return (
        _openBlock(),
        _createBlock(
          _unref(List),
          {
            'data-source': _unref(uniq)(list.value),
          },
          {
            renderItem: _withCtx(({ item }) => [
              _createVNode(
                _unref(ListItemMeta),
                { title: item },
                null,
                8 /* PROPS */,
                ['title'],
              ),
            ]),
            _: 1 /* STABLE */,
          },
          8 /* PROPS */,
          ['data-source'],
        )
      )
    }
  },
})
__sfc__.__file = 'App.vue'
export default __sfc__

Despite some improvements, it does raise a few issues

  • Can’t use export in setup, because all code is compiled into the setup function

defineProps reuse type definitions

Another useful feature in tsx is the ability to reuse ts type definitions directly without having to define a separate PropType. in fact, in the early days of react, it also included built-in PropType support, but eventually they adopted the ts type definitions, while vue kept them and had to live with the problem of ts types not being shared with props types, even though they were both in ts.

For example, in tsx we would write the type like this

1
2
3
4
5
6
7
8
9
interface WindowMeta {
  id: string
  title: string
  url: string
}

export function Window(props: { meta: WindowMeta }) {
  return <pre>{JSON.stringify(props.meta, null, 2)}</pre>
}

Before vue3 had defineProps:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script lang="ts">
import { defineComponent, PropType } from 'vue'

interface WindowMeta {
  id: string
  title: string
  url: string
}

export default defineComponent({
  props: {
    meta: {
      type: Object as PropType<WindowMeta>,
      required: true,
    },
  },
})
</script>

<template>
  <pre>{{ JSON.stringify($props.meta, null, 2) }}</pre>
</template>

After using defineProps:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<script lang="ts" setup>
interface WindowMeta {
  id: string
  title: string
  url: string
}

defineProps<{
  meta: WindowMeta
}>()
</script>

As you can see, you can reuse the ts type directly and no longer need to define a separate vue PropType. you may find that defineProps are not imported, yes, they are actually macros, they don’t actually exist after compilation and don’t even get compiled as vue PropType. here is the compilation result.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/* Analyzed bindings: {
  "meta": "props"
} */
import { defineComponent as _defineComponent } from 'vue'

const __sfc__ = /*#__PURE__*/ _defineComponent({
  __name: 'App',
  props: {
    meta: null,
  },
  setup(__props) {
    return () => {}
  },
})
__sfc__.__file = 'App.vue'
export default __sfc__

While it looks good, it does have limitations, such as

defineEmit use types

There are no emits/attrs/slots in react, they are all integrated into props (which is a very elegant and powerful design). In vue3, emits can define types, but the types are still derived by defining values before defineEmits.

For example.

1
2
3
4
5
6
7
interface WindowMeta {
  id: string
  title: string
  url: string
}

export function App(props: { onClick(item: WindowMeta): void }) {}

Before vue3 this is how you would normally write it, defining the object and having vue infer the type based on the value.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<script lang="ts">
import { defineComponent } from 'vue'

interface WindowMeta {
  id: string
  title: string
  url: string
}

export default defineComponent({
  // Note that this is a js object
  emits: {
    onClick(item: WindowMeta): void {},
  },
})
</script>

After using the setup script, the ts type can be used.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<script lang="ts" setup>
interface WindowMeta {
  id: string
  title: string
  url: string
}

defineEmits<{
  // And here is a type
  (type: 'onClick', item: WindowMeta): void
}>()
</script>

Of course, it will all be removed at compile time and used only for code hints and checksums at development time.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/* Analyzed bindings: {} */
import { defineComponent as _defineComponent } from 'vue'

const __sfc__ = /*#__PURE__*/ _defineComponent({
  __name: 'App',
  emits: ['onClick'],
  setup(__props) {
    return () => {}
  },
})
__sfc__.__file = 'App.vue'
export default __sfc__

It is also possible to write more intuitive interfaces using some tool types.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script lang="ts" setup>
import { UnionToIntersection } from 'utility-types'

type ShortEmits<T> = UnionToIntersection<
  {
    [P in keyof T]: T[P] extends (...args: any[]) => any
      ? (type: P, ...args: Parameters<T[P]>) => ReturnType<T[P]>
      : never
  }[keyof T]
>

interface WindowMeta {
  id: string
  title: string
  url: string
}

defineEmits<
  ShortEmits<{
    onClick(item: WindowMeta): void
  }>
>()
</script>

Problems

It does fix some bad development experiences, but it also raises some issues

  • You have to use @vue/compiler-dom to handle vue script code, because setup script means it’s not really ts code anymore
  • Some of the more radical community solutions add a variety of custom macros, see unplugin-vue-macros

Ref