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,关键不是把它看成“把三种代码写在一个文件里”,而是理解它如何经过多个编译阶段,最终成为:
- 一个普通的 JavaScript 组件模块;
- 一段由 Vue 渲染器执行的渲染函数;
- 一份普通 CSS,或者经过作用域转换的 CSS;
- 可能附带 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 编译器版本决定。
解析阶段主要回答三个问题:
- 文件有哪些块?
- 每个块的源码范围和语言类型是什么?
- 块上的属性是什么,例如
scoped、module、lang?
解析并不会立即把 <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 组件可以抽象为一个输入到视图的函数:
其中:
- 是组件内部状态,例如
ref、reactive; - 是父组件传入的 props;
- 是组件上下文,例如 slots、依赖注入和运行时环境;
- 是编译后得到的渲染函数;
- 是虚拟 DOM(VNode)树。
初次渲染时,Vue 执行 ,得到 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-if、v-for、v-bind、v-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 排列。
核心关系是:
- 静态标签
button成为元素 VNode; @click成为事件属性;{{ count }}成为动态文本;count被记录为动态依赖;- 运行时渲染器根据 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-if 和 v-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> 的顶层绑定,识别:
- 导入的
computed和ref是脚本内部使用的依赖; firstName、lastName、fullName是可能被模板引用的顶层绑定;- 模板中的
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> 宏包括:
definePropsdefineEmitsdefineExposedefineSlotsdefineModel
这些宏通常不需要从 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>
defineProps 和 defineEmits 不是运行时函数调用。编译器会提取它们的声明,并生成组件的 props、emits 选项以及 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>
这里有两个独立问题:
- 网络请求是否成功;
- 父树是否提供了处理异步组件的机制。
不能因为 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 }},但 step 是 props 对象上的属性,不是自动独立暴露的顶层变量。正确写法有两种。
第一种,直接使用 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.step 或 toRefs 是更明确的方式。
修正后的完整稳定写法如下:
<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。
这带来几个重要边界:
scoped不是浏览器级封装;- CSS 继承仍然存在;
- 更高优先级的全局规则仍可能影响结果;
- 组件根节点有特殊的属性合并行为;
- 深层子组件节点不会自动被当前组件的普通 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 类名没有通过
$style或useCssModule使用; - 全局样式优先级更高;
- 组件被 Teleport 到其他 DOM 位置,但样式作用域属性仍由 Vue 生成;
- 动态 CSS 值不是合法的 CSS 值。
诊断时应在 DevTools 中同时检查:
- 元素是否有预期的
data-v-*属性; - 最终 class 是否是 CSS Modules 映射后的名字;
- 目标规则是否被匹配;
- 被覆盖的规则来自哪里;
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
预期可以看到两类内容:
- setup 脚本经过处理后的 JavaScript/TypeScript 代码;
- 引入 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 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 生产交付:环境配置、静态资源、缓存、灰度和回滚
- 下一篇:Vue 组件生命周期:挂载、更新、卸载、副作用和父子顺序
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论