Vue.js 94 🟢 Script Setup Compiler Inlining and Transform Rules
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 .
| BindingType | Source | Non-Inline Prefix | Inline Behavior |
|---|---|---|---|
SETUP_CONST | const name = 'Alice' | $setup.name | Direct access |
SETUP_REF | const count = ref(0) | $setup.count | count.value |
SETUP_MAYBE_REF | const x = maybeRef() | $setup.x | unref(x) or .value |
SETUP_LET | let message = 'hi' | $setup.message (with setter) | isRef(x) ? x.value : x |
SETUP_REACTIVE_CONST | const state = reactive({}) | $setup.state | Direct access |
PROPS | const props = defineProps() | $props.foo | __props.foo |
PROPS_ALIASED | Destructured prop alias | $props['alias'] | __props['alias'] |
LITERAL_CONST | Literal constant | $setup.x | Direct 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
| Type | Inline | Function |
|---|---|---|
SETUP_CONST | x | $setup.x |
SETUP_REF | x.value | $setup.x.value |
SETUP_LET | isRef(x) ? x.value : x | isRef($setup.x) ? $setup.x.value : $setup.x |
PROPS | __props.x | $props.x |
PROPS_ALIASED | __props['x'] | $props['x'] |
Inline vs. Function Mode
| Aspect | Inline | Function |
|---|---|---|
| Default | Yes (<script setup>) | No |
| Render location | Inside setup() | Separate function |
| Access prefix | None | $setup. |
| Performance | Faster | Slightly slower |
Special Identifier Cases
| Case | Rule |
|---|---|
Globals (Math, Date) | No prefix |
Literals (true, false, null) | No prefix |
| Static property keys | Not 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Template expression not reactive | let binding not wrapped in ref | Use ref() or accept the isRef check |
$setup is undefined | Inline mode is active | Use direct access, not $setup |
| Destructured prop loses reactivity | Compiler rewrites to __props | Ensure propsDestructure is enabled |
Complex type in defineProps fails | Compiler cannot resolve imported type | Use 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
| Item | Value |
|---|---|
| BindingTypes | Classification of top-level declarations |
SETUP_REF | .value access |
SETUP_LET | isRef check |
SETUP_CONST | Direct access |
PROPS | __props (inline) / $props (function) |
| Inline mode | Default for <script setup>, render inside setup |
| Function mode | Separate render function, $setup prefix |
| Assignment rewrite | count++ → count.value++ |
| Shorthand | Expanded to key: value.value |
Key takeaways:
- The compiler classifies every top-level binding before compiling the template. A
refisSETUP_REF, aletisSETUP_LET, areactiveobject isSETUP_REACTIVE_CONST, and props arePROPS. This classification drives every identifier rewrite in the template . - Inline mode is the default for
<script setup>. The render function is nested insidesetup(), and template expressions access setup variables directly without a$setupprefix. Function mode is the fallback when the template cannot be inlined . - The rewrite rules are type-specific.
SETUP_REFbecomes.value,SETUP_LETbecomesisRef(x) ? x.value : x,PROPSbecomes__props.key, andSETUP_CONSTis used directly. The assignment handling forletbindings is the most complex case, using theisRefcheck to handle both ref and non-ref values . - Destructured props are rewritten to
__props. ThepropsDestructurefeature 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
definePropsrequire a plugin. Vue’s compiler only resolves simple inline type literals. Imported types, type aliases, and intersections require a build-time plugin likerolldown-plugin-vue-macro-typesto 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.
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!