前言
我们每天写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
。sfc.filename
的值为当前编译的vue
文件路径。由于每编译一个vue
文件都要走到这个debug中,现在我们只想debug
看看common-child.vue
文件,所以为了方便我们在compileScript
中加了下面这样一段代码,并且去掉了在compileScript
函数中加的断点,这样就只有编译common-child.vue
文件时会走进断点。
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
模版中可以直接使用。
关注公众号:前端欧阳,解锁我更多vue
干货文章,并且可以免费向我咨询vue
相关问题。