<template> <ChildDemo name="ouyang" /> </template> <script setup lang="ts"> import ChildDemo from "./child.vue"; </script>父组件代码很简单,给子组件传了一个名为name的prop,name的值为字符串“ouyang”。
<template> {{ localName }} </template> <script setup lang="ts"> const { name: localName } = defineProps(["name"]); console.log(localName); </script>在子组件中我们将name给解构出来了并且赋值给了localName,讲道理解构出来的localName应该是个常量会丢失响应式的,其实不会丢失。
<template> {{ localName }} </template> <script setup lang="ts"> const props = defineProps(["name"]); const { name: localName } = props; console.log(localName); </script>在上面的例子中我们不是直接解构defineProps的返回值,而是将返回值赋值给props对象,然后再去解构props对象拿到localName。
从上图中可以看到这种写法使用解构的localName时,就不会在编译阶段将其替换为__props.name,这样的话localName就确实是一个普通的常量了,当然会丢失响应式。这是为什么呢?为什么这种解构写法就会丢失响应式呢?别着急,我接下来的文章会讲。
找到compileScript函数就可以给他打一个断点了。
function compileScript(sfc, options) { const ctx = new ScriptCompileContext(sfc, options); const scriptSetupAst = ctx.scriptSetupAst; // 2.2 process <script setup> body for (const node of scriptSetupAst.body) { if (node.type === "VariableDeclaration" && !node.declare) { const total = node.declarations.length; for (let i = 0; i < total; i++) { const decl = node.declarations[i]; const init = decl.init; if (init) { // defineProps const isDefineProps = processDefineProps(ctx, init, decl.id); } } } } // 堆代码 duidaima.com // 3 props destructure transform if (ctx.propsDestructureDecl) { transformDestructuredProps(ctx); } return { //.... content: ctx.s.toString(), }; }在之前的 为什么defineProps宏函数不需要从vue中import导入?文章中我们已经详细讲解过了compileScript函数中的入参sfc、如何使用ScriptCompileContext类new一个ctx上下文对象。所以这篇文章我们就只简单说一下他们的作用即可。
从上图中可以看到body属性是一个数组,分别对应的是源代码中的两行代码。数组的第一项对应的Node节点类型是VariableDeclaration,他是一个变量声明类型的节点。对应的就是源代码中的第一行:const { name: localName } = defineProps(["name"])
function compileScript(sfc, options) { // ...省略 // 2.2 process <script setup> body for (const node of scriptSetupAst.body) { if (node.type === "VariableDeclaration" && !node.declare) { const total = node.declarations.length; for (let i = 0; i < total; i++) { const decl = node.declarations[i]; const init = decl.init; if (init) { // defineProps const isDefineProps = processDefineProps(ctx, init, decl.id); } } } } // ...省略 }我们接着来看外层for循环里面的第一个if语句:
if (node.type === "VariableDeclaration" && !node.declare)这个if语句的意思是判断当前的节点类型是不是变量声明并且确实有初始化的值。
const { name: localName } = defineProps(["name"]);
很明显我们这里是满足这个if条件的。
接着在if里面还有一个内层for循环,这个for循环是在遍历node节点的declarations属性,这个属性是一个数组。declarations数组属性表示当前变量声明语句中定义的所有变量,可能会定义多个变量,所以他才是一个数组。在我们这里只定义了一个变量localName,所以 declarations数组中只有一项。
在内层for循环,会去遍历声明的变量,然后从变量的节点中取出init属性。我想聪明的你从名字应该就可以看出来init属性的作用是什么。没错,init属性就是对应的变量的初始化值。在我们这里声明的localName变量的初始化值就是defineProps(["name"])函数的返回值。接着就是判断init是否存在,也就是判断变量是否是有初始化值。如果为真,那么就执行processDefineProps(ctx, init, decl.id)判断初始化值是否是在调用defineProps。换句话说就是判断当前的变量声明是否是在调用defineProps宏函数。
function processDefineProps(ctx, node, declId) { if (!isCallOf(node, DEFINE_PROPS)) { return processWithDefaults(ctx, node, declId); } // handle props destructure if (declId && declId.type === "ObjectPattern") { processPropsDestructure(ctx, declId); } return true; }processDefineProps函数接收3个参数。
第三个参数declId,这个对应的是变量声明语句中的变量名称。也就是源代码中的{ name: localName }。
在 为什么defineProps宏函数不需要从vue中import导入?文章中我们已经讲过了这里的第一个if语句就是用于判断当前是否在执行defineProps函数,如果不是那么就直接return false
我们接着来看第二个if语句,这个if语句就是判断当前变量声明是不是“对象解构赋值”。很明显我们这里就是解构出的localName变量,所以代码将会走到processPropsDestructure函数中。
function processPropsDestructure(ctx, declId) { const registerBinding = ( key: string, local: string, defaultValue?: Expression ) => { ctx.propsDestructuredBindings[key] = { local, default: defaultValue }; }; for (const prop of declId.properties) { const propKey = resolveObjectKey(prop.key); registerBinding(propKey, prop.value.name); } }前面讲过了这里的两个入参,ctx表示当前上下文对象。declId表示变量声明语句中的变量名称。
function resolveObjectKey(node: Node) { switch (node.type) { case "Identifier": return node.name; } return undefined; }如果当前是标识符节点,也就是有name属性。那么就返回name属性。
registerBinding(propKey, prop.value.name)第一个参数为传入解构对象时要提取出的属性名称,也就是name。第二个参数为解构对象时要赋给的目标变量名称,也就是localName。
function processPropsDestructure(ctx, declId) { const registerBinding = ( key: string, local: string, defaultValue?: Expression ) => { ctx.propsDestructuredBindings[key] = { local, default: defaultValue }; }; // ...省略 }ctx.propsDestructuredBindings是存在ctx上下文中的一个属性对象,这个对象里面存的是需要解构的多个props。
有了这个后,后续只需要将script模块中的所有代码遍历一次,然后找出哪些在使用的变量是props解构的变量,比如这里的localName变量将其替换成__props.name即可。
function compileScript(sfc, options) { const ctx = new ScriptCompileContext(sfc, options); const scriptSetupAst = ctx.scriptSetupAst; // 2.2 process <script setup> body for (const node of scriptSetupAst.body) { if (node.type === "VariableDeclaration" && !node.declare) { const total = node.declarations.length; for (let i = 0; i < total; i++) { const decl = node.declarations[i]; const init = decl.init; if (init) { // defineProps const isDefineProps = processDefineProps(ctx, init, decl.id); } } } } // 3 props destructure transform if (ctx.propsDestructureDecl) { transformDestructuredProps(ctx); } return { //.... content: ctx.s.toString(), }; }经过processDefineProps函数的处理后,ctx.propsDestructureDecl对象中已经存了有哪些变量是由props解构出来的。这里的if (ctx.propsDestructureDecl)条件当然满足,所以代码会走到transformDestructuredProps函数中。接着将断点走进transformDestructuredProps函数中,在我们这个场景中简化后的transformDestructuredProps函数代码如下:
import { walk } from 'estree-walker' function transformDestructuredProps(ctx) { const rootScope = {}; let currentScope = rootScope; const propsLocalToPublicMap: Record<string, string> = Object.create(null); const ast = ctx.scriptSetupAst; for (const key in ctx.propsDestructuredBindings) { const { local } = ctx.propsDestructuredBindings[key]; rootScope[local] = true; propsLocalToPublicMap[local] = key; } walk(ast, { enter(node: Node) { if (node.type === "Identifier") { if (currentScope[node.name]) { rewriteId(node); } } }, }); function rewriteId(id: Identifier) { // x --> __props.x ctx.s.overwrite( id.start! + ctx.startOffset!, id.end! + ctx.startOffset!, genPropsAccessExp(propsLocalToPublicMap[id.name]) ); } }
在transformDestructuredProps函数中主要分为三块代码,分别是for循环、执行walk函数、定义rewriteId函数。我们先来看第一个for循环,他是遍历ctx.propsDestructuredBindings对象。前面我们讲过了这个对象中存的属性key是解构了哪些props,比如这里就是解构了name这个props。
接着就是使用const { local } = ctx.propsDestructuredBindings[key]拿到解构的props在子组件中赋值给了哪个变量,我们这里是解构出来后赋给了localName变量,所以这里的local的值为字符串"localName"。
由于在我们这个demo中只有两行代码,分别是解构props和console.log。没有其他的函数,所以这里的作用域只有一个。也就是说rootScope始终等于currentScope。
经过这个for循环的处理后,我们已经知道了有哪些变量其实是经过props解构来的,以及这些解构得到的变量和props的映射关系。接下来就是使用walk函数去递归遍历script模块中的所有代码,这个递归遍历就是遍历script模块对应的AST抽象语法树。
walk(ast, { enter(node: Node) { if (node.type === "Identifier") { if (currentScope[node.name]) { rewriteId(node); } } }, });我们这个场景中只需要enter进入的回调就行了。在enter回调中使用外层if判断当前节点的类型是不是Identifier,Identifier类型可能是变量名、函数名等。
我们源代码中的console.log(localName)中的localName就是一个变量名,当递归遍历AST抽象语法树遍历到这里的localName对应的节点时就会满足外层的if条件。
我们回忆一下前面讲过了currentScope对象中就是存的是有哪些本地的变量是通过props解构得到的,这里的localName变量当然是通过props解构得到的,满足里层的if条件判断。
function rewriteId(id: Identifier) { // x --> __props.x ctx.s.overwrite( id.start + ctx.startOffset, id.end + ctx.startOffset, genPropsAccessExp(propsLocalToPublicMap[id.name]) ); }这里使用了ctx.s.overwrite方法,这个方法接收三个参数。
const identRE = /^[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*$/; function genPropsAccessExp(name: string): string { return identRE.test(name) ? `__props.${name}` : `__props[${JSON.stringify(name)}]`; }使用正则表达式去判断如果满足条件就会返回__props.${name},否则就是返回__props[${JSON.stringify(name)}]。
const { "first-name": firstName } = defineProps(["first-name"]); console.log(firstName);这种props在这种情况下就会返回__props["first-name"]
执行完genPropsAccessExp函数后回到ctx.s.overwrite方法的地方,此时我们已经知道了第三个参数的值为__props.name。这个方法的执行会将localName重写为__props.name 。
<script setup lang="ts"> const props = defineProps(["name"]); const { name: localName } = props; console.log(localName); </script>
在处理defineProps宏函数时,发现是直接解构了返回值才会进行处理。上面这个例子中没有直接进行解构,而是将其赋值给props,然后再去解构props。这种情况下ctx.propsDestructuredBindings对象中什么都没有。
后续在递归遍历script模块中的所有代码,发现ctx.propsDestructuredBindings对象中什么都没有。自然也不会将localName替换为__props.name,这样他当然就会丢失响应式了。
在编译阶段首先会处理宏函数defineProps,在处理的过程中如果发现解构了defineProps的返回值,那么就会将解构的name属性,以及name解构到本地的localName变量,都全部一起存到ctx.propsDestructuredBindings对象中。