一、概念层:为什么要"合并"两个 <script>?
Vue 3 的单文件组件(SFC)允许同时存在两种脚本块:
xml
<script>
export default {
name: 'HelloWorld',
methods: { sayHi() { console.log('hi') } }
}
</script>
<script setup>
const msg = 'Hello Vue 3'
</script>
这两块在语义上是分开的:
- 普通
<script>用于定义传统选项式属性(name、methods、mixins等); <script setup>则对应组合式 API 的setup()逻辑。
compileScript() 的任务就是将这两部分整合成一个完整的、合法的运行时代码:
javascript
export default defineComponent({
name: 'HelloWorld',
setup() {
const msg = 'Hello Vue 3'
return { msg }
}
})
二、原理层:整体合并思路
compileScript() 中这一逻辑的核心是 "默认导出重写" + "代码块移动" + "作用域合并" 。
换句话说,它不会简单地"拼接两个字符串",而是通过 AST 分析与 MagicString 操作完成精确重构。
整体算法思路如下:
xml
if script exists:
analyze <script> AST
record default export or named exports
move <script> content before <script setup>
rewrite export default to const __default__
merge __default__ with setup() result
wrap defineComponent({...})
三、对比层:合并前后的结构变化
| 阶段 | 原始结构 | 编译后结构 |
|---|---|---|
| 输入 | 两个独立块:<script> + <script setup> |
合并为一个组件定义 |
| 处理策略 | 分别解析 AST | 通过 MagicString 操作整合 |
| 默认导出 | export default {...} |
const __default__ = {...} |
| 最终输出 | 各自独立 | export default defineComponent({...}) |
💡 Vue 编译器通过将默认导出转换为变量,再与 setup() 一起合并,保证逻辑不冲突。
四、实践层:关键代码解析
1. 检测导出与重写逻辑
ini
if (node.type === 'ExportDefaultDeclaration') {
defaultExport = node
// export default { ... } --> const __default__ = { ... }
const start = node.start! + scriptStartOffset!
const end = node.declaration.start! + scriptStartOffset!
ctx.s.overwrite(start, end, `const ${normalScriptDefaultVar} = `)
}
逐行解释:
- 检查
export default节点; - 用
MagicString.overwrite()把export default替换成const __default__ =; normalScriptDefaultVar是编译时定义的字符串"__default__";- 这样做的结果是:导出的对象被保存为一个常量,稍后会和
setup()代码组合。
2. 命名导出转换为导入或赋值
javascript
if (node.type === 'ExportNamedDeclaration') {
const defaultSpecifier = node.specifiers.find(
s => s.exported.name === 'default',
)
if (defaultSpecifier) {
defaultExport = node
if (node.source) {
ctx.s.prepend(
`import { ${defaultSpecifier.local.name} as ${normalScriptDefaultVar} } from '${node.source.value}'\n`,
)
} else {
ctx.s.appendLeft(
scriptEndOffset!,
`\nconst ${normalScriptDefaultVar} = ${defaultSpecifier.local.name}\n`,
)
}
}
}
逐步说明:
- 当发现命名导出语句如
export { foo as default }; - 如果来自外部模块 → 转为
import { foo as __default__ } from '...'; - 如果是本地变量 → 转为
const __default__ = foo; - 确保后续
__default__始终存在可引用值。
3. 代码块位置调整(move)
由于 <script setup> 通常位于 <script> 之后,
编译器需要调整顺序,使普通脚本块先出现。
scss
if (scriptStartOffset! > startOffset) {
ctx.s.move(scriptStartOffset!, scriptEndOffset!, 0)
}
含义:
- 如果
<script>出现在<script setup>之后; - 使用
MagicString.move()将它移动到文件最前; - 确保
__default__在 setup 函数之前被定义。
4. 最终合并点:运行时组件定义
到了最后阶段,compileScript() 会把两者合并为:
javascript
ctx.s.prependLeft(
startOffset,
`
export default defineComponent({
...__default__,
${runtimeOptions}
setup(${args}) {
${exposeCall}
`
)
ctx.s.appendRight(endOffset, `})`)
效果是:
- 把
__default__的内容展开放入defineComponent(); - 在同一个对象中注入 runtime props、emits、name 等;
- 将
<script setup>内容变为setup()函数体。
五、拓展层:从语法到设计哲学
这种"拆分 + 合并"机制并不是 hack,而是一种编译层抽象设计:
| 特点 | 意义 |
|---|---|
| 非侵入式兼容 | 保留传统 <script> 的语义,不破坏旧代码。 |
| 编译期融合 | 在 AST 层统一逻辑,避免运行时合并开销。 |
| 统一输出模型 | 所有组件最终都转为 defineComponent() 格式。 |
| 易于扩展 | 后续宏(如 defineModel)可直接注入 setup 层。 |
Vue 团队的核心目标是:保持语法演进与运行时模型的一致性。
六、潜在问题与边界情况
| 情况 | 处理方式 |
|---|---|
<script> 与 <script setup> 使用不同语言(如 JS/TS) |
抛出错误,强制语言一致。 |
| 两个块都导出默认对象 | 优先保留 <script> 的默认导出。 |
<script> 中手动定义 setup() |
编译器不会注入第二个 setup(),需开发者自行合并逻辑。 |
| 作用域冲突(同名变量) | 后者(<script setup>)的绑定会覆盖前者。 |
七、小结
在本篇中,我们剖析了:
- 为什么 Vue 允许两个
<script>共存; compileScript()如何重写export default;- 如何利用
MagicString调整代码顺序; - 最终如何合并成标准的
defineComponent()输出。
通过这一机制,Vue 实现了传统选项式与组合式语法的无缝过渡。
在下一篇(第 5 篇),我们将深入底层实现细节:
🧩 《AST 遍历与声明解析:walkDeclaration 系列函数详解》 ,
讲解编译器是如何递归遍历变量声明模式、识别 const/let、推断响应式特征的。
本文部分内容借助 AI 辅助生成,并由作者整理审核。