Vue 基础体系 · 第 24/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue SFC 与编译器:template、script setup、style 和编译产物

单文件组件(Single-File Component,SFC)是 Vue 对组件源码的一种组织格式。一个 .vue 文件可以同时包含:

  • <template>:声明组件渲染的结构;
  • <script>:编写普通 JavaScript 或 TypeScript 模块代码;
  • <script setup>:使用编译器宏和顶层组合式 API 编写组件逻辑;
  • <style>:声明组件样式,并可选择作用域隔离、CSS Modules 或动态 CSS 变量。
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <button class="counter" @click="count++">
    {{ count }}
  </button>
</template>

<style scoped>
.counter {
  color: teal;
}
</style>

浏览器不能直接执行这个文件。浏览器需要 JavaScript、CSS 和 HTML 相关的运行时代码,而 .vue 文件是供构建工具和 Vue 编译器处理的源码容器。理解 SFC,关键不是把它看成“把三种代码写在一个文件里”,而是理解它如何经过多个编译阶段,最终成为:

  1. 一个普通的 JavaScript 组件模块;
  2. 一段由 Vue 渲染器执行的渲染函数;
  3. 一份普通 CSS,或者经过作用域转换的 CSS;
  4. 可能附带 source map、热更新和类型检查相关信息。

一、从 SFC 源码到浏览器代码

1. SFC 是一个可解析的源码描述

SFC 的顶层结构通常由以下块组成:

<template>
  <!-- 模板 -->
</template>

<script lang="ts">
  // 普通模块脚本
</script>

<script setup lang="ts">
  // 组件实例上下文中的 setup 代码
</script>

<style scoped lang="scss">
  /* 样式 */
</style>

这些块不是由浏览器直接解释的标签。Vue 的 SFC 编译器首先进行解析,得到类似下面的描述对象:

interface SFCDescriptor {
  filename: string
  template?: {
    content: string
    lang: string
    loc: unknown
  }
  script?: {
    content: string
    lang: string
    loc: unknown
  }
  scriptSetup?: {
    content: string
    lang: string
    loc: unknown
  }
  styles: Array<{
    content: string
    scoped: boolean
    module?: string | boolean
    lang: string
    loc: unknown
  }>
}

这是概念结构,用于说明编译阶段如何区分各个块;真实类型和字段由 Vue 编译器版本决定。

解析阶段主要回答三个问题:

  1. 文件有哪些块?
  2. 每个块的源码范围和语言类型是什么?
  3. 块上的属性是什么,例如 scopedmodulelang

解析并不会立即把 <template> 变成 DOM,也不会执行 <script setup>。它只是把一个 SFC 文件拆成后续编译阶段可以分别处理的输入。

2. Vite 在其中扮演什么角色

现代 Vite 项目通常通过 @vitejs/plugin-vue 处理 .vue 文件:

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()]
})

一次典型的开发环境请求大致会经历:

App.vue
  │
  ├─ Vite 识别 .vue 请求
  │
  ├─ @vitejs/plugin-vue 解析 SFC
  │
  ├─ 编译 <script> / <script setup>
  │
  ├─ 编译 <template> 为 render 函数
  │
  ├─ 编译 <style>,处理 scoped、模块和预处理器
  │
  └─ 返回浏览器可执行的 JavaScript,并注入样式

Vite 不是 Vue 模板编译器本身。它负责模块图、开发服务器、转换钩子和生产构建;@vue/compiler-sfc 负责 SFC 特有的解析与编译;@vue/compiler-dom 或相关编译模块负责模板到渲染函数的转换。

生产构建时,Vite 通常还会调用 Rollup 完成模块打包、代码分割、压缩和资源输出。于是:

.vue 源码
→ SFC 编译
→ JavaScript/CSS 模块
→ Rollup 模块图
→ 浏览器资源

二、组件运行时真正需要什么

Vue 组件可以抽象为一个输入到视图的函数:

V=R(S,P,C)V = R(S, P, C)

其中:

  • SS 是组件内部状态,例如 refreactive
  • PP 是父组件传入的 props;
  • CC 是组件上下文,例如 slots、依赖注入和运行时环境;
  • RR 是编译后得到的渲染函数;
  • VV 是虚拟 DOM(VNode)树。

初次渲染时,Vue 执行 RR,得到 VNode,然后由渲染器创建真实 DOM。状态改变后,并不是重新解析模板,而是再次执行或更新渲染函数,并比较新旧 VNode,最后只对必要的 DOM 部分执行更新。

因此,<template> 的主要产物不是 HTML 字符串,而是一个渲染函数:

function render(_ctx, _cache) {
  // 返回 VNode
}

在客户端渲染中,渲染函数产生 VNode;在 SSR 编译中,模板会走另一套服务端输出路径,目标是生成 HTML 字符串或相关 SSR 代码。不能把客户端模板编译产物和 SSR 产物混为一谈。


三、<template>:从声明式模板到渲染函数

1. 模板表达式不是完整 JavaScript

Vue 模板允许表达式:

<template>
  <h1>{{ title }}</h1>
  <button @click="count++">增加</button>
</template>

但模板并不是任意 JavaScript 文件。它有自己的语法和编译上下文:

  • 插值:{{ expression }}
  • 指令:v-ifv-forv-bindv-on 等;
  • 组件标签和原生 HTML 标签;
  • 插槽、动态组件、模板引用等。

例如:

<template>
  <div :class="{ active: enabled }">
    {{ enabled ? '启用' : '停用' }}
  </div>
</template>

这里的 enabled 必须能够在组件渲染上下文中找到。对于 <script setup>,顶层声明会被编译器暴露给模板;对于普通 setup(),则必须通过返回值暴露:

import { ref } from 'vue'

export default {
  setup() {
    const enabled = ref(true)

    return {
      enabled
    }
  }
}

如果没有返回 enabled,模板中的 enabled 就无法访问这个局部变量。

2. 一个模板如何对应渲染函数

考虑如下组件:

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

const count = ref(0)
</script>

<template>
  <button class="counter" @click="count++">
    {{ count }}
  </button>
</template>

编译器不会简单地把它转换成:

document.querySelector('button')!.textContent = String(count.value)

更接近真实概念的产物是:

import {
  openBlock,
  createElementBlock,
  createTextVNode,
  toDisplayString
} from 'vue'

function render(_ctx, _cache) {
  return (
    openBlock(),
    createElementBlock(
      'button',
      {
        class: 'counter',
        onClick: _cache[0] || (_cache[0] = () => _ctx.count++)
      },
      toDisplayString(_ctx.count),
      1
    )
  )
}

这段代码只是示意。实际输出会受 Vue 版本、开发/生产模式、缓存策略和编译选项影响,不能依赖具体变量名或 helper 排列。

核心关系是:

  1. 静态标签 button 成为元素 VNode;
  2. @click 成为事件属性;
  3. {{ count }} 成为动态文本;
  4. count 被记录为动态依赖;
  5. 运行时渲染器根据 VNode 创建或更新 DOM。

3. 编译器为什么要区分静态和动态内容

模板编译器会尽量识别静态节点、动态属性和动态文本。例如:

<template>
  <section class="panel">
    <h2>固定标题</h2>
    <p>{{ message }}</p>
  </section>
</template>

其中:

  • section 的标签名固定;
  • class="panel" 固定;
  • h2 和文字“固定标题”固定;
  • p 的文本依赖 message

编译器可以对静态内容做提升(hoisting),也可以为动态节点生成 patch flag。patch flag 是编译器和运行时之间的优化信息,用于表达“这个节点哪些部分可能变化”。

例如,概念上可以表示为:

const _hoisted = createElementVNode(
  'h2',
  null,
  '固定标题'
)

function render(_ctx) {
  return createElementBlock('section', { class: 'panel' }, [
    _hoisted,
    createElementVNode('p', null, toDisplayString(_ctx.message), 1)
  ])
}

这里的目标不是减少模板代码,而是减少每次更新时需要检查的范围。Vue 运行时仍然要完成正确性判断;编译器优化不能改变模板的语义。

4. v-ifv-for 会转换为不同的控制结构

<template>
  <ul v-if="visible">
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>

其逻辑关系是:

visible 为真
  └─ 创建 ul
       └─ 遍历 items
            └─ 为每个 item 创建 li
visible 为假
  └─ 不创建 ul

v-if 是条件分支,v-for 是列表映射。:key 不只是为了消除警告,它参与列表子节点的身份匹配:

  • key 稳定时,旧节点可以与新节点对应;
  • key 改变时,Vue 可能认为这是不同节点;
  • 没有合适的 key,列表重排时可能产生状态复用或 DOM 复用不符合预期。

错误示例:

<li v-for="item in items" :key="item.name">
  {{ item.name }}
</li>

如果 name 可能重复,两个不同项目会拥有相同身份。更可靠的是使用稳定且唯一的数据库 ID:

<li v-for="item in items" :key="item.id">
  {{ item.name }}
</li>

5. 模板中的 ref 自动解包有边界

<script setup> 中:

import { ref } from 'vue'

const count = ref(0)

模板可以直接写:

<span>{{ count }}</span>

模板编译和运行时会对暴露到模板上下文的 ref 做适当的自动解包,因此不需要写 count.value

但这不是所有 JavaScript 位置都成立:

console.log(count.value) // JavaScript 中需要 .value

对象嵌套也有边界:

const state = {
  count: ref(0)
}

在模板中访问 state.count 是否自动解包,取决于模板上下文和访问路径;不要把模板便利语法推广为普通 JavaScript 规则。明确写作组件逻辑时,应遵循 ref 在 JavaScript 中使用 .value 的规则。


四、<script setup>:编译时语法与运行时 setup()

1. 它不是新的运行时 API

<script setup> 是 SFC 编译器支持的语法。下面的代码:

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

const count = ref(0)
</script>

不会以“一个叫 script setup 的浏览器机制”存在。编译器会把它转换为组件选项中的 setup() 内容,并将适合模板使用的绑定连接到生成的 render 函数。

概念上接近:

import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)

    return {
      count
    }
  },

  render
}

真实产物还会包含模块导入、辅助函数、组件标识、开发环境信息和模板绑定元数据,因此这里只能说明语义,不能当作精确输出。

2. 顶层变量为什么能在模板中使用

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

const firstName = ref('Ada')
const lastName = ref('Lovelace')

const fullName = computed(() => `${firstName.value} ${lastName.value}`)
</script>

<template>
  <p>{{ fullName }}</p>
</template>

编译器会分析 <script setup> 的顶层绑定,识别:

  • 导入的 computedref 是脚本内部使用的依赖;
  • firstNamelastNamefullName 是可能被模板引用的顶层绑定;
  • 模板中的 fullName 应连接到 setup 执行所产生的上下文。

这就是为什么不需要显式写:

return {
  firstName,
  lastName,
  fullName
}

如果变量只在脚本内部使用,编译器可以把它当作普通局部变量:

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

const count = ref(0)

function logCount() {
  console.log(count.value)
}
</script>

<template>
  <button @click="logCount">记录</button>
</template>

logCount 被模板引用,因此需要进入模板可访问的绑定集合;count 既被 logCount 使用,也可能通过闭包访问。

3. 编译器宏是特殊的编译时标记

常见的 <script setup> 宏包括:

  • defineProps
  • defineEmits
  • defineExpose
  • defineSlots
  • defineModel

这些宏通常不需要从 vue 导入。以 props 和 emits 为例:

<script setup lang="ts">
const props = defineProps<{
  title: string
  count?: number
}>()

const emit = defineEmits<{
  change: [value: number]
}>()

function increase() {
  emit('change', (props.count ?? 0) + 1)
}
</script>

definePropsdefineEmits 不是运行时函数调用。编译器会提取它们的声明,并生成组件的 propsemits 选项以及 setup 作用域中的对应变量。

TypeScript 类型参数的情况尤其重要:

const props = defineProps<{
  title: string
}>()

类型本身在运行时不存在。编译器可以根据有限的类型语法生成运行时 props 声明,但 TypeScript 的所有复杂类型信息都不一定能完整转换成运行时验证逻辑。因此:

  • TypeScript 类型检查由 vue-tsc 等工具完成;
  • Vue 运行时 props 校验是另一套机制;
  • 不能认为写了 TypeScript 类型就等价于完整的运行时校验。

需要运行时校验时,可以使用运行时声明:

const props = defineProps({
  title: {
    type: String,
    required: true
  }
})

4. 普通 <script><script setup> 的职责不同

一个 SFC 可以同时包含普通脚本和 <script setup>

<script lang="ts">
export default {
  inheritAttrs: false,
  customOptions: {
    feature: true
  }
}
</script>

<script setup lang="ts">
const message = 'hello'
</script>

普通 <script> 是标准 ES 模块作用域,适合:

  • 导出普通模块内容;
  • 编写无法放入 setup 实例执行逻辑的模块级代码;
  • 提供兼容性选项或某些组件选项。

<script setup> 中的顶层代码则会进入组件实例的 setup 执行语义。每个组件实例创建时,setup 逻辑会按组件实例执行;模块顶层的普通 <script> 代码则遵循 ES 模块的单次求值规则。

这两者不能任意复制相同职责。尤其不要在普通 <script> 中放置本应属于每个组件实例的可变状态:

<script lang="ts">
let count = 0 // 所有组件实例共享这个模块变量
</script>

<script setup lang="ts">
// 组件实例状态应放在这里
import { ref } from 'vue'
const localCount = ref(0)
</script>

如果页面上创建多个组件实例,count 是模块共享的,而 localCount 通常是每个实例独立的。

5. 顶层 await 会改变 setup 的异步语义

在支持的 Vue 3 和工具链版本中,<script setup> 可以使用顶层 await

<script setup lang="ts">
const response = await fetch('/api/profile')
const profile = await response.json()
</script>

编译器会保留异步 setup 的语义,使组件的 setup 结果成为异步结果。此时通常需要配合 <Suspense> 处理异步依赖:

<template>
  <Suspense>
    <Profile />
    <template #fallback>
      加载中……
    </template>
  </Suspense>
</template>

这里有两个独立问题:

  1. 网络请求是否成功;
  2. 父树是否提供了处理异步组件的机制。

不能因为 await fetch() 写在组件顶层,就认为错误会自动显示在模板中。应显式处理 HTTP 状态和异常:

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

const error = ref<string | null>(null)
const profile = ref<{ name: string } | null>(null)

try {
  const response = await fetch('/api/profile')

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`)
  }

  profile.value = await response.json()
} catch (reason) {
  error.value = reason instanceof Error ? reason.message : '未知错误'
}
</script>

<template>
  <p v-if="error">加载失败:{{ error }}</p>
  <p v-else-if="profile">{{ profile.name }}</p>
  <p v-else>加载中……</p>
</template>

顶层 await 的可用性和 SSR、Suspense 细节与 Vue、Vite 版本有关,项目应以对应版本文档和类型检查结果为准。


五、<script setup> 的完整可运行示例

下面是一个可由 Vue 3、Vite、TypeScript 项目运行的组件。

1. src/components/Counter.vue

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

const props = withDefaults(
  defineProps<{
    initial?: number
    step?: number
  }>(),
  {
    initial: 0,
    step: 1
  }
)

const emit = defineEmits<{
  change: [value: number]
}>()

const count = ref(props.initial)
const doubled = computed(() => count.value * 2)

function increment() {
  count.value += props.step
  emit('change', count.value)
}
</script>

<template>
  <section class="counter">
    <p>当前值:{{ count }}</p>
    <p>两倍值:{{ doubled }}</p>

    <button type="button" @click="increment">
      +{{ step }}
    </button>
  </section>
</template>

<style scoped>
.counter {
  padding: 1rem;
  border: 1px solid #ccc;
}

button {
  cursor: pointer;
}
</style>

这里有一个容易忽略的错误:模板写了 {{ step }},但 stepprops 对象上的属性,不是自动独立暴露的顶层变量。正确写法有两种。

第一种,直接使用 props.step

<button type="button" @click="increment">
  +{{ props.step }}
</button>

第二种,使用响应式解构能力:

const { initial, step } = withDefaults(
  defineProps<{
    initial?: number
    step?: number
  }>(),
  {
    initial: 0,
    step: 1
  }
)

然后模板可以写:

<button type="button" @click="increment">
  +{{ step }}
</button>

响应式 props 解构属于版本敏感能力。Vue 3.5 及相关编译器中,编译器对 <script setup> 中的 props 解构提供了响应式语义;较早版本不能假定普通解构会自动保持响应式。为了兼容不同版本,使用 props.steptoRefs 是更明确的方式。

修正后的完整稳定写法如下:

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

const props = withDefaults(
  defineProps<{
    initial?: number
    step?: number
  }>(),
  {
    initial: 0,
    step: 1
  }
)

const emit = defineEmits<{
  change: [value: number]
}>()

const count = ref(props.initial)
const doubled = computed(() => count.value * 2)

function increment() {
  count.value += props.step
  emit('change', count.value)
}
</script>

<template>
  <section class="counter">
    <p>当前值:{{ count }}</p>
    <p>两倍值:{{ doubled }}</p>

    <button type="button" @click="increment">
      +{{ props.step }}
    </button>
  </section>
</template>

<style scoped>
.counter {
  padding: 1rem;
  border: 1px solid #ccc;
}

button {
  cursor: pointer;
}
</style>

父组件可以这样使用:

<script setup lang="ts">
import Counter from './components/Counter.vue'

function handleChange(value: number) {
  console.log('新值:', value)
}
</script>

<template>
  <Counter :initial="10" :step="2" @change="handleChange" />
</template>

一次点击的状态路径是:

props.initial = 10
  ↓
count = ref(10)
  ↓
点击按钮
  ↓
count.value += 2
  ↓
响应式系统触发更新
  ↓
重新计算 doubled
  ↓
组件渲染函数产生新 VNode
  ↓
渲染器更新两个 p 的文本
  ↓
触发 change 事件,父组件执行 handleChange(12)

props 的输入方向是父组件到子组件;emit 的事件方向是子组件到父组件。事件并不会直接修改父组件状态,父组件是否响应取决于它是否监听并处理该事件。


六、SFC 编译产物的组成

1. <script setup> 和模板通常合并为一个组件模块

概念上,前面的组件可能生成如下结构:

import { defineComponent, ref, computed } from 'vue'

const __sfc__ = defineComponent({
  setup(__props, { emit }) {
    const props = __props
    const count = ref(props.initial)
    const doubled = computed(() => count.value * 2)

    function increment() {
      count.value += props.step
      emit('change', count.value)
    }

    return {
      props,
      count,
      doubled,
      increment
    }
  }
})

__sfc__.render = render

export default __sfc__

这仍然是概念化代码。实际编译结果可能使用:

  • defineComponent
  • setup 返回对象;
  • 编译器生成的 render
  • __isScriptSetup 等内部标记;
  • 开发环境的组件名和 HMR 信息;
  • CSS 注入或 CSS import;
  • scope ID。

重要的是,<script setup> 中的代码不是被模板直接复制粘贴,而是被纳入组件 setup 与渲染上下文的连接过程。

2. 模板编译产物包含运行时 helper

模板:

<template>
  <div>
    <span>{{ message }}</span>
  </div>
</template>

可能形成类似:

import {
  openBlock,
  createElementBlock,
  createElementVNode,
  toDisplayString
} from 'vue'

function render(_ctx, _cache) {
  return (
    openBlock(),
    createElementBlock('div', null, [
      createElementVNode(
        'span',
        null,
        toDisplayString(_ctx.message),
        1
      )
    ])
  )
}

这些 helper 是 Vue 运行时的接口。编译器生成代码时必须与运行时版本保持兼容。因此:

  • vue@vue/compiler-sfc 应保持兼容版本;
  • 不应随意单独升级编译器包;
  • 精确的 helper 名称和 patch flag 数字属于实现细节,不能在业务代码中依赖。

3. 开发构建和生产构建不同

开发环境通常保留:

  • 更丰富的错误信息;
  • source map;
  • 组件文件名;
  • HMR 连接;
  • 更容易定位源码的编译结果。

生产构建通常会:

  • 删除开发断言;
  • 压缩 JavaScript;
  • 压缩或提取 CSS;
  • 进行模块合并和代码分割;
  • 尽可能消除未使用代码。

所以在浏览器 DevTools 中看到的代码不一定与源码一一对应。出现模板错误时,优先查看 Vite 编译错误、source map 映射和组件栈,而不是根据压缩后的 helper 名称推断源码。


七、<style>:普通 CSS、作用域 CSS 和 CSS Modules

1. 普通 <style> 是全局 CSS

<style>
body {
  margin: 0;
}
</style>

不带 scoped 时,这些规则通常作为普通 CSS 注入或提取,影响整个页面。它与组件实例没有天然的样式边界。

因此下面的选择器可能影响组件外部元素:

<style>
.button {
  color: red;
}
</style>

CSS 的全局性来自 CSS 规则本身,而不是 Vue 组件系统。SFC 只是提供了一个把 CSS 与组件源码放在一起的编译入口。

2. scoped 如何实现样式隔离

<template>
  <button class="button">保存</button>
</template>

<style scoped>
.button {
  color: red;
}
</style>

Vue 编译器通常会为组件分配一个 scope ID,例如:

data-v-7a3f2c1

然后把模板和 CSS 分别转换为:

<button class="button" data-v-7a3f2c1>保存</button>
.button[data-v-7a3f2c1] {
  color: red;
}

因此,隔离关系是通过“属性选择器匹配”实现的,而不是创建 Shadow DOM。

这带来几个重要边界:

  1. scoped 不是浏览器级封装;
  2. CSS 继承仍然存在;
  3. 更高优先级的全局规则仍可能影响结果;
  4. 组件根节点有特殊的属性合并行为;
  5. 深层子组件节点不会自动被当前组件的普通 scoped 规则匹配。

3. 子组件根节点是一个常见例外

父组件:

<template>
  <Child class="from-parent" />
</template>

<style scoped>
.from-parent {
  color: red;
}
</style>

子组件的根元素通常会同时带有父组件和子组件相关的 scope 属性,以便父组件能够布局或定位子组件根节点。这是 Vue 对组件根节点的特殊处理,不意味着父组件的 scoped CSS 可以任意穿透子组件内部。

4. 深度选择器、插槽选择器和全局选择器

当确实需要影响子组件内部内容时,可以使用深度选择器:

<style scoped>
.wrapper :deep(.third-party-button) {
  border-radius: 4px;
}
</style>

编译后会把当前组件的 scope 条件放在外层,而不要求 .third-party-button 自身具有当前 scope 属性。具体深度选择器语法以 Vue 当前文档和编译器版本为准,常用形式是 :deep(...)

插槽内容由父组件提供,不能简单按照子组件普通 DOM 内容处理。需要针对插槽内容时可以使用:

<style scoped>
:slotted(.external-content) {
  color: gray;
}
</style>

如果只想在 scoped 样式块中放置全局规则:

<style scoped>
:global(.global-title) {
  font-weight: bold;
}
</style>

这些伪选择器是 Vue SFC 编译器提供的转换语法,不是浏览器原生 CSS 选择器。

5. CSS Modules 改变的是类名绑定方式

<template>
  <button :class="$style.button">
    保存
  </button>
</template>

<style module>
.button {
  color: red;
}
</style>

CSS Modules 会把局部类名映射为构建后的唯一类名,例如:

.button {
  color: red;
}

可能产生:

const $style = {
  button: '_button_1a2b3_1'
}

模板中的 $style.button 最终会得到映射后的字符串。也可以命名模块:

<template>
  <button :class="styles.button">保存</button>
</template>

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

const styles = useCssModule('styles')
</script>

<style module="styles">
.button {
  color: red;
}
</style>

scoped 与 CSS Modules 是两种不同机制:

  • scoped:编译选择器和模板属性;
  • CSS Modules:编译类名并提供类名映射对象。

二者可以组合使用,但通常没有必要同时用于同一组规则。

6. v-bind() 在 CSS 中如何工作

Vue SFC 支持在 CSS 中绑定组件状态:

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

const color = ref('teal')
</script>

<template>
  <button class="button" @click="color = 'tomato'">
    改变颜色
  </button>
</template>

<style scoped>
.button {
  color: v-bind(color);
}
</style>

这不是把 JavaScript 值直接拼成 CSS 文本。编译器会把它转换为一个 CSS 自定义属性的使用,并生成与组件状态关联的运行时代码。概念上接近:

.button[data-v-xxx] {
  color: var(--xxx-color);
}

组件运行时会通过 CSS 自定义属性更新颜色。因果链是:

color.value 改变
  ↓
响应式更新触发
  ↓
useCssVars 相关运行时代码更新自定义属性
  ↓
浏览器重新计算 CSS
  ↓
按钮颜色改变

这适合主题色、尺寸等少量动态样式。大量高频变化的布局属性应谨慎使用,因为它仍然会触发样式重新计算,不能把它当成零成本的 CSS 静态替换。


八、lang 属性与预处理器边界

<script setup lang="ts">
const count: number = 0
</script>

<style lang="scss">
$accent: teal;

.button {
  color: $accent;
}
</style>

lang="ts"lang="scss" 只是告诉工具链如何处理对应块:

  • TypeScript 需要经过 TypeScript 或相关转换;
  • SCSS 需要安装并调用 Sass 预处理器;
  • 预处理器输出的 CSS 之后仍可能继续经过 scoped 或模块转换。

例如使用 SCSS 时,需要满足项目依赖条件:

npm install -D sass
npm run dev

如果没有安装 Sass,常见失败表现是 Vite 报告无法加载或处理 scss。这不是 Vue 模板语法错误,而是预处理器依赖缺失。

lang 不会改变浏览器能力。浏览器仍然只接收编译后的 JavaScript 和 CSS。TypeScript 类型也不会在运行时保留,除非另有代码显式实现运行时验证。


九、编译时错误、运行时错误和样式错误

不同阶段的失败,诊断路径不同。

1. SFC 解析或模板编译错误

错误示例:

<template>
  <div>
</template>

标签不匹配时,Vite 通常在转换 .vue 模块阶段直接报错,应用可能无法启动或该模块无法加载。此时:

  • 组件的 setup 可能尚未执行;
  • 浏览器运行时不是主要诊断位置;
  • 应查看终端和浏览器开发服务器错误覆盖层;
  • 根据行列号检查模板结构。

2. TypeScript 类型错误

<script setup lang="ts">
const count: number = 'wrong'
</script>

Vite 的转译阶段可能只负责去除类型语法,并不等同于完整类型检查。项目通常需要单独运行:

npx vue-tsc --noEmit

如果命令报告类型错误,说明 TypeScript 类型系统拒绝这段代码;即使开发服务器能够转译,也不代表代码符合类型约束。

3. setup 或渲染阶段运行时错误

<script setup lang="ts">
const user = null
</script>

<template>
  <p>{{ user.name }}</p>
</template>

模板编译可能成功,因为语法合法;但渲染时访问 null.name 会失败。应根据数据状态建立条件:

<template>
  <p v-if="user">{{ user.name }}</p>
  <p v-else>暂无用户</p>
</template>

如果错误发生在异步请求之后,还要区分:

  • 请求失败;
  • 返回数据结构不符合预期;
  • 模板访问了尚未准备好的字段。

4. CSS 编译成功但视觉结果错误

CSS 没有语法错误,不代表选择器匹配到了目标元素。常见原因包括:

  • 使用 scoped 后,选择器无法穿透子组件;
  • CSS Modules 类名没有通过 $styleuseCssModule 使用;
  • 全局样式优先级更高;
  • 组件被 Teleport 到其他 DOM 位置,但样式作用域属性仍由 Vue 生成;
  • 动态 CSS 值不是合法的 CSS 值。

诊断时应在 DevTools 中同时检查:

  1. 元素是否有预期的 data-v-* 属性;
  2. 最终 class 是否是 CSS Modules 映射后的名字;
  3. 目标规则是否被匹配;
  4. 被覆盖的规则来自哪里;
  5. v-bind() 生成的自定义属性是否存在。

十、生命周期:编译阶段不等于组件生命周期

编译阶段发生在构建或模块转换时,组件生命周期发生在运行时。两者必须分开:

构建/转换时:
  解析 SFC
  → 编译 script setup
  → 编译 template
  → 编译 style

组件实例创建时:
  setup()
  → onBeforeMount
  → render
  → onMounted

响应式状态改变时:
  effect 调度
  → 重新执行更新流程
  → patch VNode
  → onUpdated

例如:

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

const width = ref(window.innerWidth)

function updateWidth() {
  width.value = window.innerWidth
}

onMounted(() => {
  window.addEventListener('resize', updateWidth)
})

onUnmounted(() => {
  window.removeEventListener('resize', updateWidth)
})
</script>

<template>
  <p>窗口宽度:{{ width }}</p>
</template>

onMounted 不会在编译时执行。编译器只负责把调用关系保留到组件 setup 执行中;只有组件实例挂载到客户端 DOM 后,生命周期钩子才会触发。

这段代码在 SSR 环境还有一个边界:window 在服务端不存在。更安全的写法是把浏览器对象访问放入客户端生命周期中,并让初始值不依赖 window

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

const width = ref(0)

function updateWidth() {
  width.value = window.innerWidth
}

onMounted(() => {
  updateWidth()
  window.addEventListener('resize', updateWidth)
})

onUnmounted(() => {
  window.removeEventListener('resize', updateWidth)
})
</script>

十一、容易混淆的几个边界

1. SFC 编译不是模板字符串替换

以下理解是不准确的:

<template> → HTML 字符串
<script setup> → 原样拼接
<style scoped> → 自动创建 Shadow DOM

更准确的关系是:

template
  → 模板 AST
  → VNode 渲染函数

script setup
  → 编译时宏处理
  → setup 组件代码

style
  → CSS 解析/预处理
  → scoped/module/变量转换后的 CSS

2. scoped 不等于完整组件隔离

scoped 通过属性选择器工作,不能阻止所有继承、层叠、全局样式和第三方样式影响组件。需要真正的浏览器封装时,应研究 Shadow DOM 与 Web Components,而不是把 scoped 当作 Shadow DOM。

3. defineProps 不等于网络请求或运行时函数

const props = defineProps<{ id: string }>()

这段语法在编译时被特殊处理。不能把它抽取到普通 .ts 文件中继续期望 Vue 编译器识别:

// utils.ts
export function createProps() {
  return defineProps<{ id: string }>() // 不能这样使用
}

宏需要出现在支持它的 SFC <script setup> 编译上下文中,并且受到 Vue 编译器规则约束。

4. 编译产物不是稳定的公共 API

可以观察编译产物来学习机制,但不应:

  • 依赖生成的 _hoisted_1 名称;
  • 依赖某个 patch flag 数字;
  • 直接修改 Vite 缓存中的转换文件;
  • 假设不同 Vue 版本生成完全相同的 helper 结构。

稳定的公共接口是 Vue 文档公开的组件、响应式和编译配置 API;具体代码生成布局属于实现细节。


十二、如何查看实际编译结果

要观察某个组件的真实产物,可以使用 Vue 编译器包进行实验。首先在 Vue 3 项目中安装:

npm install -D @vue/compiler-sfc

创建 inspect-sfc.mjs

import fs from 'node:fs'
import {
  parse,
  compileScript,
  compileTemplate
} from '@vue/compiler-sfc'

const filename = 'Example.vue'
const source = fs.readFileSync(filename, 'utf8')

const parsed = parse(source, { filename })

if (parsed.errors.length > 0) {
  console.error(parsed.errors)
  process.exitCode = 1
  process.exit()
}

const { descriptor } = parsed

const script = descriptor.script || descriptor.scriptSetup
  ? compileScript(descriptor, {
      id: 'example-scope'
    })
  : null

const template = descriptor.template
  ? compileTemplate({
      source: descriptor.template.content,
      filename,
      id: 'example-scope',
      scoped: descriptor.styles.some(style => style.scoped)
    })
  : null

console.log('--- script ---')
console.log(script?.content ?? '(none)')

console.log('--- template ---')
console.log(template?.code ?? '(none)')

if (template?.errors?.length) {
  console.error('template errors:', template.errors)
}

准备一个最小的 Example.vue

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

const count = ref(0)
</script>

<template>
  <button @click="count++">
    {{ count }}
  </button>
</template>

<style scoped>
button {
  color: teal;
}
</style>

执行:

node inspect-sfc.mjs

预期可以看到两类内容:

  1. setup 脚本经过处理后的 JavaScript/TypeScript 代码;
  2. 引入 Vue helper 的 render 函数代码。

实际输出会因安装的 @vue/compiler-sfc 版本而不同。若脚本包含 lang="ts",这个示例主要展示 SFC 层面的编译代码,完整的 TypeScript 转换和 Vite 插件集成仍可能由工具链的其他阶段完成。

生产排障时更应使用项目真实的 Vite 配置观察结果,因为插件还可能参与:

  • CSS 注入;
  • HMR;
  • source map;
  • define 常量替换;
  • JSX 或预处理器转换;
  • SSR 编译。

十三、从源码到结果的完整心智模型

可以把一个 Vue SFC 看成四种输入经过四条编译路径,最后在组件模块处汇合:

flowchart TD
    A[.vue SFC 源码] --> B[parse 解析]
    B --> C1[template 块]
    B --> C2[script 块]
    B --> C3[script setup 块]
    B --> C4[style 块]

    C1 --> D1[模板 AST 与代码生成]
    D1 --> E1[render 函数]

    C2 --> D2[普通模块转换]
    C3 --> D3[宏分析与 setup 合并]
    D2 --> E2[组件模块代码]
    D3 --> E2
    E1 --> E2

    C4 --> D4[预处理器]
    D4 --> D5[scoped / CSS Modules / v-bind 转换]
    D5 --> E3[CSS 资源或注入代码]

    E2 --> F[Vite/Rollup 构建]
    E3 --> F
    F --> G[浏览器运行时]

运行时的关键路径则是:

sequenceDiagram
    participant App as 应用
    participant Comp as 组件实例
    participant Setup as setup
    participant Render as render
    participant Renderer as Vue 渲染器
    participant DOM as 浏览器 DOM

    App->>Comp: 创建组件实例
    Comp->>Setup: 执行 script setup 生成的 setup
    Setup-->>Comp: 返回模板可访问绑定
    Comp->>Render: 执行编译后的 render
    Render-->>Renderer: 返回 VNode 树
    Renderer->>DOM: 创建初始 DOM
    Note over Comp,DOM: 响应式状态发生变化
    Comp->>Render: 生成新的 VNode
    Render-->>Renderer: 新旧 VNode
    Renderer->>DOM: patch 最小必要变更

这个模型解释了几个常见事实:

  • 修改 <template> 通常触发模板重新编译;
  • 修改 <script setup> 通常触发组件模块重新转换;
  • 修改 <style> 通常只需更新样式资源;
  • 运行时不会把模板当作字符串反复解析;
  • 状态更新影响的是渲染函数执行和 VNode patch,而不是重新编译整个 .vue 文件。

十四、生产环境中的取舍

1. 组件内聚不等于所有内容都应放在 SFC 中

SFC 适合表达一个组件的模板、逻辑和局部样式,但以下内容仍可能适合放到独立模块:

  • 可复用的业务逻辑;
  • API 客户端;
  • 类型声明;
  • 大型全局主题;
  • 设计系统变量;
  • 与组件无关的纯函数。

<script setup> 解决的是组件代码表达和编译问题,不会自动解决模块边界、状态归属或业务耦合问题。

2. scoped 的便利伴随选择器成本

scoped 会改写选择器:

.card .title {
  color: red;
}

概念上变为:

.card[data-v-xxx] .title[data-v-xxx] {
  color: red;
}

复杂嵌套选择器会带来更复杂的最终 CSS,也可能影响调试和覆盖策略。应避免为了“保险”而无限嵌套选择器,尤其是结合 :deep() 时,要明确实际影响范围。

3. 动态样式、CSS Modules 和 scoped 解决不同问题

选择方式取决于问题本身:

  • 要防止普通类名污染:可以使用 CSS Modules;
  • 要让模板结构和 CSS 具有关联范围:可以使用 scoped
  • 要根据响应式值变化:可以使用 v-bind()
  • 要统一控制整个应用:应使用全局 CSS、主题变量或设计系统层。

把三者混合使用并不会自动获得更强的隔离,反而可能增加最终选择器和调试复杂度。


结语

Vue SFC 是一种编译入口,不是浏览器原生文件格式。其核心转换关系可以压缩为:

<template>
  声明式模板
→ 编译为 render 函数
→ 产生 VNode
→ Vue 渲染器更新 DOM

<script setup>
  顶层组件逻辑与编译器宏
→ 合并为组件 setup
→ 建立状态、props、事件与模板的连接

<style>
  CSS 或预处理器源码
→ 普通 CSS、scoped CSS、CSS Modules 或 CSS 变量运行时代码

理解这些边界后,很多问题会有明确的定位方式:

  • 模板语法问题,检查 SFC 编译阶段;
  • 宏和类型问题,检查 <script setup> 编译与 vue-tsc
  • 状态更新问题,沿着响应式系统、render 和 patch 路径排查;
  • 样式不生效问题,检查最终选择器、scope 属性、类名映射和层叠关系;
  • 编译产物差异问题,区分 Vue 编译器的公共语义与版本相关实现细节。

系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。