前言
我们每天写vue
代码时都在用defineProps
,但是你有没有思考过下面这些问题。为什么defineProps
不需要import
导入?为什么不能在非setup
顶层使用defineProps
?defineProps
是如何将声明的 props
自动暴露给模板?
举几个例子
我们来看几个例子,分别对应上面的几个问题。
先来看一个正常的例子,common-child.vue
文件代码如下:
<template>
<div>content is {{ content }}</div>
</template>
<script setup lang="ts">
defineProps({
content: String,
});
</script>
我们看到在这个正常的例子中没有从任何地方import
导入defineProps
,直接就可以使用了,并且在template
中渲染了props
中的content
。
我们再来看一个在非setup
顶层使用defineProps
的例子,if-child.vue
文件代码如下:
<template>
<div>content is {{ content }}</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const count = ref(10);
if (count.value) {
defineProps({
content: String,
});
}
</script>
代码跑起来直接就报错了,提示defineProps is not defined
通过debug搞清楚上面几个问题
在我的上一篇文章 vue文件编译成js文件 中已经带你搞清楚了vue
文件中的<script>
模块是如何编译成浏览器可直接运行的js
代码,其实底层就是依靠vue/compiler-sfc
包的compileScript
函数。
当我们import
一个vue
文件时会触发@vitejs/plugin-vue包的transform
钩子函数,在这个函数中会调用一个transformMain
函数。
transformMain
函数中会调用genScriptCode
、genTemplateCode
、genStyleCode
,分别对应的作用是将vue
文件中的<script>
模块编译为浏览器可直接运行的js
代码、将<template>
模块编译为render
函数、将<style>
模块编译为导入css
文件的import
语句。genScriptCode
函数底层调用的就是vue/compiler-sfc
包的compileScript
函数。
一样的套路,首先我们在vscode的打开一个debug
终端。
然后在node_modules
中找到vue/compiler-sfc
包的compileScript
函数打上断点,compileScript
函数位置在/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js
。
在debug
终端上面执行yarn dev
后在浏览器中打开对应的页面,比如:http://localhost:5173/ 。此时断点就会走到compileScript
函数中,我们在debug
中先来看看compileScript
函数的第一个入参sfc
。
compileScript
函数
我们再来回忆一下common-child.vue
文件中的script
模块代码如下:
<script setup lang="ts">
defineProps({
content: String,
});
</script>
我们接着来看compileScript
函数的入参sfc
,在上一篇文章 vue文件编译成js文件 中我们已经讲过了sfc
是一个descriptor
对象,descriptor
对象是由vue
文件编译来的。
descriptor
对象拥有template
属性、scriptSetup
属性、style
属性,分别对应vue
文件的<template>
模块、<script setup>
模块、<style>
模块。
在我们这个场景只关注scriptSetup
属性,sfc.scriptSetup.content
的值就是<script setup>
模块中code
代码字符串,sfc.source
的值就是vue
文件中的源代码code字符串。详情查看下图:
compileScript
函数内包含了编译script
模块的所有的逻辑,代码很复杂,光是源代码就接近1000行。这篇文章我们不会去通读compileScript
函数的所有功能,只会讲处理defineProps
相关的代码。下面这个是我简化后的代码:
function compileScript(sfc, options) {
const ctx = new ScriptCompileContext(sfc, options);
const startOffset = ctx.startOffset;
const endOffset = ctx.endOffset;
const scriptSetupAst = ctx.scriptSetupAst;
for (const node of scriptSetupAst.body) {
if (node.type === "ExpressionStatement") {
const expr = node.expression;
if (processDefineProps(ctx, expr)) {
ctx.s.remove(node.start + startOffset, node.end + startOffset);
}
}
if (node.type === "VariableDeclaration" && !node.declare || node.type.endsWith("Statement")) {
// ....
}
}
ctx.s.remove(0, startOffset);
ctx.s.remove(endOffset, source.length);
let runtimeOptions = ``;
const propsDecl = genRuntimeProps(ctx);
if (propsDecl) runtimeOptions += `\n props: ${propsDecl},`;
const def =
(defaultExport ? `\n ...${normalScriptDefaultVar},` : ``) +
(definedOptions ? `\n ...${definedOptions},` : "");
ctx.s.prependLeft(
startOffset,
`\n${genDefaultAs} /*#__PURE__*/${ctx.helper(
`defineComponent`
)}({${def}${runtimeOptions}\n ${
hasAwait ? `async ` : ``
}setup(${args}) {\n${exposeCall}`
);
ctx.s.appendRight(endOffset, `})`);
return {
//....
content: ctx.s.toString(),
};
}
在compileScript
函数中首先调用ScriptCompileContext
类生成一个ctx
上下文对象,然后遍历vue
文件的<script setup>
模块生成的AST抽象语法树
。
如果节点类型为ExpressionStatement
表达式语句,那么就执行processDefineProps
函数,判断当前表达式语句是否是调用defineProps
函数。如果是那么就删除掉defineProps
调用代码,并且将调用defineProps
函数时传入的参数对应的node
节点信息存到ctx
上下文中。
然后从参数node
节点信息中拿到调用defineProps
宏函数时传入的props
参数的开始位置和结束位置。再使用slice
方法并且传入开始位置和结束位置,从<script setup>
模块的代码字符串中截取到props
定义的字符串。然后将截取到的props
定义的字符串拼接到vue
组件对象的字符串中,最后再将编译后的setup
函数代码字符串拼接到vue
组件对象的字符串中。
ScriptCompileContext
类
ScriptCompileContext
类中我们主要关注这几个属性:startOffset
、endOffset
、scriptSetupAst
、s
。先来看看他的constructor
,下面是我简化后的代码。
import MagicString from 'magic-string'
class ScriptCompileContext {
source = this.descriptor.source
s = new MagicString(this.source)
startOffset = this.descriptor.scriptSetup?.loc.start.offset
endOffset = this.descriptor.scriptSetup?.loc.end.offset
constructor(descriptor, options) {
this.s = new MagicString(this.source);
this.scriptSetupAst = descriptor.scriptSetup && parse(descriptor.scriptSetup.content, this.startOffset);
}
}
在前面我们已经讲过了descriptor.scriptSetup
对象就是由vue
文件中的<script setup>
模块编译而来,startOffset
和endOffset
分别就是descriptor.scriptSetup?.loc.start.offset
和descriptor.scriptSetup?.loc.end.offset
,对应的是<script setup>
模块在vue
文件中的开始位置和结束位置。
descriptor.source
的值就是vue
文件中的源代码code字符串,这里以descriptor.source
为参数new
了一个MagicString
对象。magic-string
是由svelte的作者写的一个库,用于处理字符串的JavaScript
库。
它可以让你在字符串中进行插入、删除、替换等操作,并且能够生成准确的sourcemap
。MagicString
对象中拥有toString
、remove
、prependLeft
、appendRight
等方法。s.toString
用于生成返回的字符串,我们来举几个例子看看这几个方法你就明白了。
s.remove( start, end )
用于删除从开始到结束的字符串:
const s = new MagicString('hello word');
s.remove(0, 6);
s.toString(); // 'word'
s.prependLeft( index, content )
用于在指定index
的前面插入字符串:
const s = new MagicString('hello word');
s.prependLeft(5, 'xx');
s.toString(); // 'helloxx word'
s.appendRight( index, content )
用于在指定index
的后面插入字符串:
const s = new MagicString('hello word');
s.appendRight(5, 'xx');
s.toString(); // 'helloxx word'
我们接着看constructor
中的scriptSetupAst
属性是由一个parse
函数的返回值赋值,parse(descriptor.scriptSetup.content, this.startOffset)
,parse
函数的代码如下:
import { parse as babelParse } from '@babel/parser'
function parse(input: string, offset: number): Program {
try {
return babelParse(input, {
plugins,
sourceType: 'module',
}).program
} catch (e: any) {
}
}
我们在前面已经讲过了descriptor.scriptSetup.content
的值就是vue
文件中的<script setup>
模块的代码code
字符串,parse
函数中调用了babel
提供的parser
函数,将vue
文件中的<script setup>
模块的代码code
字符串转换成AST抽象语法树
。
现在我们再来看compileScript
函数中的这几行代码你理解起来就没什么难度了,这里的scriptSetupAst
变量就是由vue
文件中的<script setup>
模块的代码转换成的AST抽象语法树
。
const ctx = new ScriptCompileContext(sfc, options);
const startOffset = ctx.startOffset;
const endOffset = ctx.endOffset;
const scriptSetupAst = ctx.scriptSetupAst;
流程图如下:
processDefineProps
函数
我们接着将断点走到for
循环开始处,代码如下:
for (const node of scriptSetupAst.body) {
if (node.type === "ExpressionStatement") {
const expr = node.expression;
if (processDefineProps(ctx, expr)) {
ctx.s.remove(node.start + startOffset, node.end + startOffset);
}
}
}
遍历AST抽象语法树
,如果当前节点类型为ExpressionStatement
表达式语句,并且processDefineProps
函数执行结果为true
就调用ctx.s.remove
方法。这会儿断点还在for
循环开始处,在控制台执行ctx.s.toString()
看看当前的code
代码字符串。
从图上可以看见此时toString
的执行结果还是和之前的common-child.vue
源代码是一样的,并且很明显我们的defineProps
是一个表达式语句,所以会执行processDefineProps
函数。我们将断点走到调用processDefineProps
的地方,看到简化过的processDefineProps
函数代码如下:
const DEFINE_PROPS = "defineProps";
function processDefineProps(ctx, node, declId) {
if (!isCallOf(node, DEFINE_PROPS)) {
return processWithDefaults(ctx, node, declId);
}
ctx.propsRuntimeDecl = node.arguments[0];
return true;
}
在processDefineProps
函数中首先执行了isCallOf
函数,第一个参数传的是当前的AST语法树
中的node
节点,第二个参数传的是"defineProps"
字符串。从isCallOf
的名字中我们就可以猜出他的作用是判断当前的node
节点的类型是不是在调用defineProps
函数,isCallOf
的代码如下:
export function isCallOf(node, test) {
return !!(
node &&
test &&
node.type === "CallExpression" &&
node.callee.type === "Identifier" &&
(typeof test === "string"
? node.callee.name === test
: test(node.callee.name))
);
}
isCallOf
函数接收两个参数,第一个参数node
是当前的node
节点,第二个参数test
是要判断的函数名称,在我们这里是写死的"defineProps"
字符串。我们在debug console
中将node.type
、node.callee.type
、node.callee.name
的值打印出来看看。
从图上看到node.type
、node.callee.type
、node.callee.name
的值后,可以证明我们的猜测是正确的这里isCallOf
的作用是判断当前的node
节点的类型是不是在调用defineProps
函数。
我们这里的node
节点确实是在调用defineProps
函数,所以isCallOf
的执行结果为true
,在processDefineProps
函数中是对isCallOf
函数的执行结果取反。也就是!isCallOf(node, DEFINE_PROPS)
的执行结果为false
,所以不会走到return processWithDefaults(ctx, node, declId);
。
我们接着来看processDefineProps
函数:
function processDefineProps(ctx, node, declId) {
if (!isCallOf(node, DEFINE_PROPS)) {
return processWithDefaults(ctx, node, declId);
}
ctx.propsRuntimeDecl = node.arguments[0];
return true;
}
如果当前节点确实是在执行defineProps
函数,那么就会执行ctx.propsRuntimeDecl = node.arguments[0];
。将当前node
节点的第一个参数赋值给ctx
上下文对象的propsRuntimeDecl
属性,这里的第一个参数其实就是调用defineProps
函数时给传入的第一个参数。
为什么写死成取arguments[0]
呢?是因为defineProps
函数只接收一个参数,传入的参数可以是一个对象或者数组。比如:
const props = defineProps({
foo: String
})
const props = defineProps(['foo', 'bar'])
记住这个在ctx
上下文上面塞的propsRuntimeDecl
属性,后面生成运行时的props
就是根据propsRuntimeDecl
属性生成的。
至此我们已经了解到了processDefineProps
中主要做了两件事:判断当前执行的表达式语句是否是defineProps
函数,如果是那么将解析出来的props
属性的信息塞的ctx
上下文的propsRuntimeDecl
属性中。
我们这会儿来看compileScript
函数中的processDefineProps
代码你就能很容易理解了:
for (const node of scriptSetupAst.body) {
if (node.type === "ExpressionStatement") {
const expr = node.expression;
if (processDefineProps(ctx, expr)) {
ctx.s.remove(node.start + startOffset, node.end + startOffset);
}
}
}
遍历AST语法树
,如果当前节点类型是ExpressionStatement
表达式语句,再执行processDefineProps
判断当前node
节点是否是执行的defineProps
函数。
如果是defineProps
函数就调用ctx.s.remove
方法将调用defineProps
函数的代码从源代码中删除掉。此时我们在debug console
中执行ctx.s.toString()
,看到我们的code
代码字符串中已经没有了defineProps
了:
现在我们能够回答第一个问题了:
为什么defineProps
不需要import
导入?
因为在编译过程中如果当前AST抽象语法树
的节点类型是ExpressionStatement
表达式语句,并且调用的函数是defineProps
,那么就调用remove
方法将调用defineProps
函数的代码给移除掉。既然defineProps
语句已经被移除了,自然也就不需要import
导入了defineProps
了。
genRuntimeProps
函数
接着在compileScript
函数中执行了两条remove
代码:
ctx.s.remove(0, startOffset);
ctx.s.remove(endOffset, source.length);
这里的startOffset
表示script
标签中第一个代码开始的位置, 所以ctx.s.remove(0, startOffset);
的意思是删除掉包含<script setup>
开始标签前面的所有内容,也就是删除掉template
模块的内容和<script setup>
开始标签。这行代码执行完后我们再看看ctx.s.toString()
的值:
接着执行ctx.s.remove(endOffset, source.length);
,这行代码的意思是将</script >
包含结束标签后面的内容全部删掉,也就是删除</script >
结束标签和<style>
模块。这行代码执行完后我们再来看看ctx.s.toString()
的值:
由于我们的common-child.vue
的script
模块中只有一个defineProps
函数,所以当移除掉template
模块、style
模块、script
开始标签和结束标签后就变成了一个空字符串。如果你的script
模块中还有其他js
业务代码,当代码执行到这里后就不会是空字符串,而是那些js
业务代码。
我们接着将compileScript
函数中的断点走到调用genRuntimeProps
函数处,代码如下:
let runtimeOptions = ``;
const propsDecl = genRuntimeProps(ctx);
if (propsDecl) runtimeOptions += `\n props: ${propsDecl},`;
从genRuntimeProps
名字你应该已经猜到了他的作用,根据ctx
上下文生成运行时的props
。我们将断点走到genRuntimeProps
函数内部,在我们这个场景中genRuntimeProps
主要执行的代码如下:
function genRuntimeProps(ctx) {
let propsDecls;
if (ctx.propsRuntimeDecl) {
propsDecls = ctx.getString(ctx.propsRuntimeDecl).trim();
}
return propsDecls;
}
还记得这个ctx.propsRuntimeDecl
是什么东西吗?我们在执行processDefineProps
函数判断当前节点是否为执行defineProps
函数的时候,就将调用defineProps
函数的参数node
节点赋值给ctx.propsRuntimeDecl
。
换句话说ctx.propsRuntimeDecl
中拥有调用defineProps
函数传入的props
参数中的节点信息。我们将断点走进ctx.getString
函数看看是如何取出props
的:
getString(node, scriptSetup = true) {
const block = scriptSetup ? this.descriptor.scriptSetup : this.descriptor.script;
return block.content.slice(node.start, node.end);
}
我们前面已经讲过了descriptor
对象是由vue
文件编译而来,其中的scriptSetup
属性就是对应的<script setup>
模块。我们这里没有传入scriptSetup
,所以block
的值为this.descriptor.scriptSetup
。同样我们前面也讲过scriptSetup.content
的值是<script setup>
模块code
代码字符串。请看下图:
这里传入的node
节点就是我们前面存在上下文中ctx.propsRuntimeDecl
,也就是在调用defineProps
函数时传入的参数节点,node.start
就是参数节点开始的位置,node.end
就是参数节点的结束位置。所以使用content.slice
方法就可以截取出来调用defineProps
函数时传入的props
定义。请看下图:
现在我们再回过头来看compileScript
函数中的调用genRuntimeProps
函数的代码你就能很容易理解了:
let runtimeOptions = ``;
const propsDecl = genRuntimeProps(ctx);
if (propsDecl) runtimeOptions += `\n props: ${propsDecl},`;
这里的propsDecl
在我们这个场景中就是使用slice
截取出来的props
定义,再使用\n props: ${propsDecl},
进行字符串拼接就得到了runtimeOptions
的值。如图:
看到runtimeOptions
的值是不是就觉得很熟悉了,又有name
属性,又有props
属性。其实就是vue
组件对象的code
字符串的一部分。name
拼接逻辑是在省略的代码中,我们这篇文章只讲props
相关的逻辑,所以name
不在这篇文章的讨论范围内。
现在我们能够回答前面提的第三个问题了。
defineProps
是如何将声明的 props
自动暴露给模板?
编译时在移除掉defineProps
相关代码时会将调用defineProps
函数时传入的参数node
节点信息存到ctx
上下文中。遍历完AST抽象语法树后
,然后从上下文中存的参数node
节点信息中拿到调用defineProps
宏函数时传入props
的开始位置和结束位置。
再使用slice
方法并且传入开始位置和结束位置,从<script setup>
模块的代码字符串中截取到props
定义的字符串。然后将截取到的props
定义的字符串拼接到vue
组件对象的字符串中,这样vue
组件对象中就有了一个props
属性,这个props
属性在template
模版中可以直接使用。
拼接成完整的浏览器运行时js
代码
我们再来看compileScript
函数中的最后一坨代码;
const def =
(defaultExport ? `\n ...${normalScriptDefaultVar},` : ``) +
(definedOptions ? `\n ...${definedOptions},` : "");
ctx.s.prependLeft(
startOffset,
`\n${genDefaultAs} /*#__PURE__*/${ctx.helper(
`defineComponent`
)}({${def}${runtimeOptions}\n ${
hasAwait ? `async ` : ``
}setup(${args}) {\n${exposeCall}`
);
ctx.s.appendRight(endOffset, `})`);
return {
//....
content: ctx.s.toString(),
};
这里先调用了ctx.s.prependLeft
方法给字符串开始的地方插入了一串字符串,这串拼接的字符串看着脑瓜子痛,我们直接在debug console
上面看看要拼接的字符串是什么样的:
看到这串你应该很熟悉,除了前面我们拼接的name
和props
之外还有部分setup
编译后的代码,其实这就是vue
组件对象的code
代码字符串的一部分。
当断点执行完prependLeft
方法后,我们在debug console
中再看看此时的ctx.s.toString()
的值是什么样的:
从图上可以看到vue
组件对象上的name
属性、props
属性、setup
函数基本已经拼接的差不多了,只差一个})
结束符号,所以执行ctx.s.appendRight(endOffset,
}));
将结束符号插入进去。
我们最后再来看看compileScript
函数的返回对象中的content
属性,也就是ctx.s.toString()
,content
属性的值就是vue
组件中的<script setup>
模块编译成浏览器可执行的js
代码字符串。
为什么不能在非setup
顶层使用defineProps
?
同样的套路我们来debug
看看if-child.vue
文件,先来回忆一下if-child.vue
文件的代码。
<template>
<div>content is {{ content }}</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const count = ref(10);
if (count.value) {
defineProps({
content: String,
});
}
</script>
将断点走到compileScript
函数的遍历AST抽象语法树
的地方,我们看到scriptSetupAst.body
数组中有三个node
节点。
从图中我们可以看到这三个node
节点类型分别是:ImportDeclaration
、VariableDeclaration
、IfStatement
。很明显这三个节点对应的是我们源代码中的import
语句、const
定义变量、if
模块。我们再来回忆一下compileScript
函数中的遍历AST抽象语法树
的代码:
function compileScript(sfc, options) {
// 省略..
for (const node of scriptSetupAst.body) {
if (node.type === "ExpressionStatement") {
const expr = node.expression;
if (processDefineProps(ctx, expr)) {
ctx.s.remove(node.start + startOffset, node.end + startOffset);
}
}
if (
(node.type === "VariableDeclaration" && !node.declare) ||
node.type.endsWith("Statement")
) {
// ....
}
}
// 省略..
}
从代码我们就可以看出来第三个node
节点,也就是在if
中使用defineProps
的代码,这个节点类型为IfStatement
,不等于ExpressionStatement
,所以代码不会走到processDefineProps
函数中,也不会执行remove
方法删除掉调用defineProps
函数的代码。
当代码运行在浏览器时由于我们没有从任何地方import
导入defineProps
,当然就会报错defineProps is not defined
。
总结
现在我们能够回答前面提的三个问题了。
为什么
defineProps
不需要import
导入?因为在编译过程中如果当前
AST抽象语法树
的节点类型是ExpressionStatement
表达式语句,并且调用的函数是defineProps
,那么就调用remove
方法将调用defineProps
函数的代码给移除掉。既然defineProps
语句已经被移除了,自然也就不需要import
导入了defineProps
了。为什么不能在非
setup
顶层使用defineProps
?因为在非
setup
顶层使用defineProps
的代码生成AST抽象语法树
后节点类型就不是ExpressionStatement
表达式语句类型,只有ExpressionStatement
表达式语句类型才会走到processDefineProps
函数中,并且调用remove
方法将调用defineProps
函数的代码给移除掉。当代码运行在浏览器时由于我们没有从任何地方
import
导入defineProps
,当然就会报错defineProps is not defined
。defineProps
是如何将声明的props
自动暴露给模板?编译时在移除掉
defineProps
相关代码时会将调用defineProps
函数时传入的参数node
节点信息存到ctx
上下文中。遍历完AST抽象语法树后
,然后从上下文中存的参数node
节点信息中拿到调用defineProps
宏函数时传入props
的开始位置和结束位置。再使用
slice
方法并且传入开始位置和结束位置,从<script setup>
模块的代码字符串中截取到props
定义的字符串。然后将截取到的props
定义的字符串拼接到vue
组件对象的字符串中,这样vue
组件对象中就有了一个props
属性,这个props
属性在template
模版中可以直接使用。