| |

Vue.js 94 🟢 Script Setup Compiler Inlining and Transform Rules

⏱️ Estimated read: 10 min | |

The <script setup> block is not a runtime feature. It is a compile-time transformation that rewrites the component’s script and template into a standard setup() function, with binding metadata that drives optimized code generation. The transformation has two distinct modes—inline and function—and the choice between them determines how template expressions are rewritten, how refs are accessed, and whether the render function is nested inside the setup scope or hoisted into a separate function. The rules are not arbitrary; they follow from the binding analysis that the compiler performs on every top-level declaration.

Key point: The compiler classifies every top-level binding in <script setup>—as a ref, a let, a constant, a prop, or a reactive object—and uses that classification to decide how each identifier is rewritten in the template. A ref becomes .value, a let becomes isRef(x) ? x.value : x, and a constant is used directly. The inline mode applies these rewrites inside the setup scope; the function mode applies them with a $setup. prefix in a separate render function.


Why the compiler inlines the template

The closure problem. In inline mode, the template’s render function is nested inside the setup() function. This means the render function closes over the setup scope directly, and template expressions can reference setup variables without a $setup. prefix. The generated code is smaller and faster because there is no proxy lookup and no destructuring of the setup return value. The Vue compiler uses inline mode by default when <script setup> is present, and it falls back to function mode when the template cannot be safely inlined—for example, when the component has a render function or uses features that require a separate scope .

The binding analysis problem. The compiler walks every top-level declaration in <script setup> and assigns it a BindingType. A const count = ref(0) is SETUP_REF. A let message = 'hello' is SETUP_LET. A const props = defineProps(...) is PROPS. A const state = reactive({}) is SETUP_REACTIVE_CONST. A plain const name = 'Alice' is SETUP_CONST . The template compiler reads this metadata and rewrites each identifier accordingly.


a. The BindingTypes Classification

The BindingTypes enum is the core of the transform rules. Each type determines the prefix and the access pattern in the generated code .

BindingTypeSourceNon-Inline PrefixInline Behavior
SETUP_CONSTconst name = 'Alice'$setup.nameDirect access
SETUP_REFconst count = ref(0)$setup.countcount.value
SETUP_MAYBE_REFconst x = maybeRef()$setup.xunref(x) or .value
SETUP_LETlet message = 'hi'$setup.message (with setter)isRef(x) ? x.value : x
SETUP_REACTIVE_CONSTconst state = reactive({})$setup.stateDirect access
PROPSconst props = defineProps()$props.foo__props.foo
PROPS_ALIASEDDestructured prop alias$props['alias']__props['alias']
LITERAL_CONSTLiteral constant$setup.xDirect access

The inline behavior is what the template expression transformer applies when inlineTemplate: true. The SETUP_REF case is the most common: the template expression {{ count }} becomes count.value in the generated render function. The SETUP_LET case is more complex because a let binding can be reassigned, and the compiler cannot know whether it holds a ref or a plain value. The generated code uses isRef(lett) ? lett.value : lett to handle both cases .


b. The Inline vs. Function Mode

The inlineTemplate option controls whether the template is compiled into the setup function or into a separate render function .

Inline mode is the default for <script setup>. The render function is nested inside setup(), and the template expressions are rewritten to access the setup scope directly. The generated code has no $setup prefix and no return statement for the bindings used in the template. The component’s setup() returns the render function itself .

// Conceptual inline output
setup(__props) {
  const count = ref(0)
  return () => {
    return h('button', { onClick: () => count.value++ }, count.value)
  }
}

Function mode is used when the template cannot be inlined. The setup function returns an object containing the bindings, and a separate render function accesses them through $setup. This mode is used when the component has an explicit render option, or when the compiler is configured with inlineTemplate: false .

// Conceptual function mode output
setup(__props) {
  const count = ref(0)
  return { count }
},
render(_ctx, _cache, $props, $setup) {
  return h('button', { onClick: () => $setup.count++ }, $setup.count)
}

The inline mode is faster because it avoids the proxy layer and the destructuring of the return value. The function mode is more compatible with tooling that expects a separate render function, such as some testing utilities or devtools integrations.


c. The Identifier Rewriting Rules

The transformExpression function in @vue/compiler-core is responsible for rewriting template expressions. It walks the AST of each expression, identifies every identifier, and consults the bindingMetadata to determine the correct prefix or access pattern .

The rewriting rules have several special cases. Global identifiers like Math, Date, and console are never prefixed. Literals like true, false, null, and this are never prefixed. Object property keys that are not computed are not treated as references. Shorthand properties require special handling: { foo } becomes { foo: foo.value } when foo is a SETUP_REF .

The assignment handling is the most complex part. A template expression like @click="count++" where count is a SETUP_REF becomes count.value++. But if count is a SETUP_LET, the generated code must handle both the ref and non-ref case: _isRef(count) ? count.value++ : count++. The snapshot tests in the Vue repository show this exact pattern for let bindings and destructured variables .

The propsDestructure feature adds another layer. When a prop is destructured, the compiler rewrites every usage of the destructured variable to __props.key. This preserves reactivity because the prop access goes through the reactive props object rather than a local copy .


Complete Example Session

// ============================================
// PART 1: SOURCE COMPONENT
// ============================================
// <script setup>
// import { ref, reactive } from 'vue'
// const count = ref(0)
// let message = 'hello'
// const state = reactive({ name: 'Alice' })
// const props = defineProps({ title: String })
// </script>
// <template>
//   <div>
//     <span>{{ count }}</span>
//     <span>{{ message }}</span>
//     <span>{{ state.name }}</span>
//     <span>{{ props.title }}</span>
//   </div>
// </template>


// ============================================
// PART 2: BINDING ANALYSIS
// ============================================
// count   → SETUP_REF
// message → SETUP_LET
// state   → SETUP_REACTIVE_CONST
// props   → PROPS


// ============================================
// PART 3: INLINE MODE OUTPUT (CONCEPTUAL)
// ============================================
setup(__props) {
  const count = ref(0)
  let message = 'hello'
  const state = reactive({ name: 'Alice' })
  const props = __props

  return () => {
    return h('div', null, [
      h('span', null, count.value),
      h('span', null, isRef(message) ? message.value : message),
      h('span', null, state.name),
      h('span', null, props.title),
    ])
  }
}


// ============================================
// PART 4: FUNCTION MODE OUTPUT (CONCEPTUAL)
// ============================================
setup(__props) {
  const count = ref(0)
  let message = 'hello'
  const state = reactive({ name: 'Alice' })
  return { count, message, state }
},
render(_ctx, _cache, $props, $setup) {
  return h('div', null, [
    h('span', null, $setup.count.value),
    h('span', null, isRef($setup.message) ? $setup.message.value : $setup.message),
    h('span', null, $setup.state.name),
    h('span', null, $props.title),
  ])
}


// ============================================
// PART 5: PROP DESTRUCTURE REWRITE
// ============================================
// <script setup>
// const { title } = defineProps(['title'])
// </script>
// <template>{{ title }}</template>

// Inline:   __props.title
// Function: $props.title


// ============================================
// PART 6: ASSIGNMENT REWRITE
// ============================================
// @click="count++"     → count.value++
// @click="message++"   → isRef(message) ? message.value++ : message++
// @click="{ count: count.value } = val"  → destructure rewrite

The six parts trace the source, the binding classification, the two output modes, and the two most complex rewrite cases.


Quick Reference

BindingTypes and Access

TypeInlineFunction
SETUP_CONSTx$setup.x
SETUP_REFx.value$setup.x.value
SETUP_LETisRef(x) ? x.value : xisRef($setup.x) ? $setup.x.value : $setup.x
PROPS__props.x$props.x
PROPS_ALIASED__props['x']$props['x']

Inline vs. Function Mode

AspectInlineFunction
DefaultYes (<script setup>)No
Render locationInside setup()Separate function
Access prefixNone$setup.
PerformanceFasterSlightly slower

Special Identifier Cases

CaseRule
Globals (Math, Date)No prefix
Literals (true, false, null)No prefix
Static property keysNot a reference
Shorthand { foo }Expanded to { foo: foo.value }

Best Practices

✅ Do This:

// Use ref() for values that are reassigned
const count = ref(0)  // → count.value in template                     // ✅
// Use reactive() for objects that are mutated
const state = reactive({ name: 'Alice' })  // direct access            // ✅
// Let the compiler classify bindings automatically
// The BindingTypes analysis handles const, let, ref, reactive          // ✅

❌ Don’t Do This:

// Assume a let binding is treated as a plain value
let message = 'hi'  // compiler uses isRef check                       // ❌
// Expect $setup prefix in inline mode
// Inline mode uses direct access                                         // ❌
// Use complex type aliases in defineProps without a plugin
// Vue's compiler cannot resolve non-inline types                        // ❌

Common Pitfalls

PitfallWhy It HappensFix
Template expression not reactivelet binding not wrapped in refUse ref() or accept the isRef check
$setup is undefinedInline mode is activeUse direct access, not $setup
Destructured prop loses reactivityCompiler rewrites to __propsEnsure propsDestructure is enabled
Complex type in defineProps failsCompiler cannot resolve imported typeUse rolldown-plugin-vue-macro-types

Real-World Examples

1. Ref Binding

// const count = ref(0)
// {{ count }} → count.value

2. Let Binding

// let msg = 'hi'
// {{ msg }} → isRef(msg) ? msg.value : msg

3. Reactive Const

// const state = reactive({})
// {{ state.name }} → state.name

4. Props

// const props = defineProps(...)
// {{ props.title }} → __props.title (inline)

5. Destructured Prop

// const { title } = defineProps(...)
// {{ title }} → __props.title

6. Assignment

// @click="count++" → count.value++

7. Shorthand Property

// { count } → { count: count.value }

8. Global Identifier

// {{ Math.PI }} → Math.PI (no prefix)

9. Inline Mode

// Render function nested inside setup()

10. Function Mode

// Separate render(_ctx, _cache, $props, $setup)

Visual

Binding Classification

┌──────────────────────────────────────────────────────────────┐
│  <script setup>                                              │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ const count = ref(0)   → SETUP_REF                      ││
│  │ let message = 'hi'     → SETUP_LET                      ││
│  │ const state = reactive → SETUP_REACTIVE_CONST           ││
│  │ const props = defineProps → PROPS                       ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             │                                │
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ TEMPLATE EXPRESSION TRANSFORM                           ││
│  │ {{ count }} → count.value                               ││
│  │ {{ message }} → isRef(message) ? ... : ...              ││
│  │ {{ state.name }} → state.name                           ││
│  │ {{ props.title }} → __props.title                       ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  The binding analysis drives every rewrite.                  │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Inline vs. Function Mode

┌──────────────────────────────────────────────────────────────┐
│  INLINE MODE                   FUNCTION MODE                 │
│                                                              │
│  setup() {                     setup() {                     │
│    const count = ref(0)          const count = ref(0)        │
│    return () => {                return { count }            │
│      return h(..., count.value) }                            │
│    }                           },                            │
│                                render(_ctx, _cache,          │
│                                       $props, $setup) {      │
│                                  return h(...,               │
│                                    $setup.count.value)       │
│                                }                             │
│                                                              │
│  No prefix. Faster.            $setup prefix. Compatible.    │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
BindingTypesClassification of top-level declarations
SETUP_REF.value access
SETUP_LETisRef check
SETUP_CONSTDirect access
PROPS__props (inline) / $props (function)
Inline modeDefault for <script setup>, render inside setup
Function modeSeparate render function, $setup prefix
Assignment rewritecount++ → count.value++
ShorthandExpanded to key: value.value

Key takeaways:

  • The compiler classifies every top-level binding before compiling the template. A ref is SETUP_REF, a let is SETUP_LET, a reactive object is SETUP_REACTIVE_CONST, and props are PROPS. This classification drives every identifier rewrite in the template .
  • Inline mode is the default for <script setup>. The render function is nested inside setup(), and template expressions access setup variables directly without a $setup prefix. Function mode is the fallback when the template cannot be inlined .
  • The rewrite rules are type-specific. SETUP_REF becomes .value, SETUP_LET becomes isRef(x) ? x.value : x, PROPS becomes __props.key, and SETUP_CONST is used directly. The assignment handling for let bindings is the most complex case, using the isRef check to handle both ref and non-ref values .
  • Destructured props are rewritten to __props. The propsDestructure feature preserves reactivity by rewriting every usage of a destructured prop to a property access on the reactive props object, rather than a local copy .
  • Complex TypeScript types in defineProps require a plugin. Vue’s compiler only resolves simple inline type literals. Imported types, type aliases, and intersections require a build-time plugin like rolldown-plugin-vue-macro-types to resolve the type before Vue processes the SFC .

Remember: The <script setup> compiler is not a simple sugar that transforms the script into a setup() function. It is an analysis pass that classifies every binding, a template transform that rewrites every expression based on that classification, and a code generator that chooses between inline and function modes. The rules are deterministic: a ref becomes .value, a let becomes an isRef check, a prop becomes __props. The generated code is smaller and faster than the equivalent manual setup() because the compiler knows the types and can eliminate the proxy layer. The only case where the compiler cannot determine the type is the complex TypeScript type in defineProps, and that is what the external plugin exists to resolve.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!