skip to content

In a Vue SFC, when do you still need a normal <script> block beside <script setup>, and what rules govern combining the two?

level: middleimportance: should knowfreq 45%

answer

  1. once per module versus per instance
  2. exports are forbidden in setup
  3. options before 3.3
  4. same lang on both blocks

basics

~20 s

Use a normal <script> beside <script setup> for named exports, module-scope code that must run once, and, before Vue 3.3, options like inheritAttrs. Both blocks must share the same lang, and props or emits never go in the normal block.

solid answer

~40 s

A normal `<script>` runs **once, in module scope**, while `<script setup>` runs **per instance**. You add one for three cases the docs list: **named exports**, because `<script setup>` cannot contain ES module exports and the compiler rejects them; **one-time side effects or shared objects**, such as a module-level cache or an `Intl.NumberFormat` shared by all instances; and **options with no macro**, like `inheritAttrs` or custom plugin options, which since 3.3 are better written with `defineOptions()`. The rules: both blocks must use the same `lang`; do not declare `props` or `emits` in the normal block when macros exist for them; and `<script setup>` variables are not added to the instance, so Options API code in the normal block cannot see them. If you need more than that, switch to an explicit `setup()`.

code

vue · 17 lines
vue
<script lang="ts">
export interface Column {
  key: string
  label: string
}
export const DEFAULT_COLUMNS: Column[] = [{ key: 'name', label: 'Name' }]
</script>

<script setup lang="ts">
import { ref } from 'vue'

const columns = ref<Column[]>([...DEFAULT_COLUMNS])
</script>

<template>
  <th v-for="c in columns" :key="c.key">{{ c.label }}</th>
</template>

go deeper

for a junior

Recall that a normal <script> runs once per module while <script setup> runs per instance, and that named exports must go in the normal block.

for a middle

Explain the supported reasons for combining the blocks, the same-lang rule, and why defineOptions in 3.3+ removes the need for many normal blocks.

for a senior

Catch module-scope state that is accidentally shared across instances or SSR requests, and Options API code that expects to see setup variables.

for a principal

Set a codebase rule for when a second script block is acceptable, keeping shared module state explicit and reviewed.

## Two blocks, two execution models A Vue single-file component (SFC) may contain one normal `<script>` block and one `<script setup>` block. They are not two halves of the same function: | Block | Scope | Runs | |---|---|---| | normal `<script>` | the module | once, when the SFC module is first imported | | `<script setup>` | inside the compiled `setup()` | once per component instance | The compiler merges them into one module: the normal block's code stays at module level, its `export default` object is merged into the component options, and the `<script setup>` body becomes `setup()`. Module-level bindings from the normal block are visible to the setup code because it is nested inside the same module. ## The three supported reasons The Vue docs limit the combination to these scenarios: 1. **Named exports.** `<script setup>` cannot contain ES module exports; the compiler errors if you try. A constant, a type or a helper that other files import from the `.vue` file has to live in the normal block, for example `export const TABLE_PAGE_SIZE = 25`. 2. **One-time side effects and shared objects.** Code that must run once for the whole application, not once per instance: creating an expensive formatter, a module-level cache, or a registry that every instance shares. 3. **Options with no dedicated macro.** `inheritAttrs: false`, `name`, or custom options read by a plugin. Before Vue 3.3 this was the only way; since 3.3 the `defineOptions()` macro declares them inside `<script setup>`, which is now the preferred form. ## The rules for combining - **Same language.** The compiler requires `<script>` and `<script setup>` to have the same `lang`; mixing `lang="ts"` with plain JavaScript is an error. - **No duplicated declarations.** Do not declare `props` or `emits` in the normal block's options; the macros own them. - **No crossing into the instance.** Variables created inside `<script setup>` are not added as properties of the component instance, so Options API code in the normal block, such as `mounted() { this.items }`, cannot reach them. The docs strongly discourage mixing APIs this way. - **No `src` on either block while `<script setup>` exists.** `<script setup>` cannot use the `src` attribute, and neither can a normal `<script>` beside it. - **Outside these cases, use `setup()`.** The docs recommend switching to an explicit `setup()` function when you need a combination that is not supported. ## A worked example ```vue <script lang="ts"> // module scope: runs once for every instance of this component const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }) export const PAGE_SIZE = 25 </script> <script setup lang="ts"> // setup scope: runs once per instance import { ref } from 'vue' defineOptions({ inheritAttrs: false }) const page = ref(1) </script> <template> <p>{{ currency.format(19.99) }} · page {{ page }} of size {{ PAGE_SIZE }}</p> </template> ``` - `currency` is created once and shared, which is the point. - `PAGE_SIZE` can be imported by other files as a named export. - `inheritAttrs` uses `defineOptions`, so no options object is needed in the normal block. ## Bindings from the normal block in the template Top-level bindings of the normal block are also visible to the template, not only those of `<script setup>`. The compiler collects the bindings of both blocks and makes them available to the template, which is why `currency` and `PAGE_SIZE` in the example above can be used in `{{ }}` directly. The difference is lifetime, not visibility: a normal-block binding is one shared value for the module, a setup binding is a fresh value for each instance. ## Pitfalls - **Accidentally shared state.** A `ref` created in the normal block is created once and shared by every instance, and under server-side rendering by every request. Per-instance state belongs in `<script setup>`. - **Stale guidance.** Older code keeps a normal block only for `inheritAttrs` or `name`; in 3.3+ that block can usually be replaced by `defineOptions()`. - **Options API leftovers.** A `methods` object in the normal block that tries to call `<script setup>` functions through `this` will not find them. ## Summary Keep a normal `<script>` for what must be module-level: named exports and run-once code, plus legacy options. Everything per-instance belongs in `<script setup>`, both blocks must share a `lang`, and the two cannot reach into each other through the instance.

  • What happens if you write export const PAGE_SIZE = 25 inside <script setup>?
    The SFC compiler rejects it with an error that `<script setup>` cannot contain ES module exports. Move the export into a normal `<script>` block in the same file, where module-level code belongs, and use the constant from `<script setup>` directly.
  • A ref declared in the normal <script> block leaks values between users in server-side rendering. Why?
    The normal block runs once per module, so the `ref` is a single object shared by every component instance, and on a server that means every request. Per-instance state must be created inside `<script setup>`, which runs for each instance.

saying these in an interview costs you the question

  • A normal <script> block runs once per component instance, just like <script setup>.
  • Named exports can be written directly inside <script setup>.
  • You still need a normal <script> for inheritAttrs in Vue 3.3 and later.
  • The two blocks can use different lang values, such as ts and js.
  • Options API methods in the normal block can call <script setup> functions through this.