写点什么

Vue3.5 中解构 props,让父子组件通信更加丝滑

  • 2024-09-19
    福建
  • 本文字数:8244 字

    阅读完需:约 27 分钟

前言


在 Vue3.5 版本中响应式 Props 解构终于正式转正了,这个功能之前一直是试验性的。这篇文章来带你搞清楚,一个 String 类型的 props 经过解构后明明应该是一个常量了,为什么还没丢失响应式呢?本文中使用的 Vue 版本为欧阳写文章时的最新版Vue3.5.5


看个 demo


我们先来看个解构 props 的例子。


父组件代码如下:


<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应该是个常量会丢失响应式的,其实不会丢失。


我们在浏览器中来看一下编译后的子组件代码,很简单,直接在 network 中过滤子组件的名称即可,如下图:



从上面可以看到原本的console.log(localName)经过编译后就变成了console.log(__props.name),这样当然就不会丢失响应式了。


我们再来看一个另外一种方式解构的例子,这种例子解构后就会丢失响应式,子组件代码如下:


<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就确实是一个普通的常量了,当然会丢失响应式。


这是为什么呢?为什么这种解构写法就会丢失响应式呢?别着急,我接下来的文章会讲。


从哪里开下手?


既然这个是在编译时将localName处理成__props.name,那我们当然是在编译时 debug 了。


还是一样的套路,我们在 vscode 中启动一个debug终端。



在之前的 通过debug搞清楚.vue文件怎么变成.js文件文章中我们已经知道了vue文件中的<script>模块实际是由vue/compiler-sfc包的compileScript函数处理的。


compileScript函数位置在/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js

找到compileScript函数就可以给他打一个断点了。


compileScript 函数


debug终端上面执行yarn dev后在浏览器中打开对应的页面,比如: http://localhost:5173/  。此时断点就会走到compileScript函数中。


在我们这个场景中简化后的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); } } } }
// 3 props destructure transform if (ctx.propsDestructureDecl) { transformDestructuredProps(ctx); }
return { //.... content: ctx.s.toString(), };}
复制代码


在之前的 为什么defineProps宏函数不需要从vue中import导入?文章中我们已经详细讲解过了compileScript函数中的入参sfc、如何使用ScriptCompileContext类 new 一个ctx上下文对象。所以这篇文章我们就只简单说一下他们的作用即可。


  • 入参sfc对象:是一个descriptor对象,descriptor对象是由 vue 文件编译来的。descriptor对象拥有 template 属性、scriptSetup 属性、style 属性,分别对应 vue 文件的<template>模块、<script setup>模块、<style>模块。


  • ctx上下文对象:这个ctx对象贯穿了整个 script 模块的处理过程,他是根据 vue 文件的源代码初始化出来的。在compileScript函数中处理 script 模块中的内容,实际就是对ctx对象进行操作。最终ctx.s.toString()就是返回 script 模块经过编译后返回的 js 代码。


搞清楚了入参sfc对象和ctx上下文对象,我们接着来看ctx.scriptSetupAst。从名字我想你也能猜到,他就是 script 模块中的代码对应的 AST 抽象语法树。如下图:



从上图中可以看到body属性是一个数组,分别对应的是源代码中的两行代码。


数组的第一项对应的 Node 节点类型是VariableDeclaration,他是一个变量声明类型的节点。对应的就是源代码中的第一行:const { name: localName } = defineProps(["name"])


数组中的第二项对应的 Node 节点类型是ExpressionStatement,他是一个表达式类型的节点。对应的就是源代码中的第二行:console.log(localName)


我们接着来看compileScript函数中的外层 for 循环,也就是遍历前面讲的 body 数组,代码如下:


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宏函数。


processDefineProps 函数


接着将断点走进processDefineProps函数,在我们这个场景中简化后的代码如下:


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 个参数。


  • 第一个参数ctx,表示当前上下文对象。

  • 第二个参数node,这个节点对应的是变量声明语句中的初始化值的部分。也就是源代码中的defineProps(["name"])

  • 第三个参数declId,这个对应的是变量声明语句中的变量名称。也就是源代码中的{ name: localName }


在 为什么defineProps宏函数不需要从vue中import导入?文章中我们已经讲过了这里的第一个 if 语句就是用于判断当前是否在执行defineProps函数,如果不是那么就直接return false

我们接着来看第二个 if 语句,这个 if 语句就是判断当前变量声明是不是“对象解构赋值”。很明显我们这里就是解构出的localName变量,所以代码将会走到processPropsDestructure函数中。


processPropsDestructure函数


接着将断点走进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表示变量声明语句中的变量名称。

首先定义了一个名为registerBinding的箭头函数。


接着就是使用 for 循环遍历declId.properties变量名称,为什么会有多个变量名称呢?


答案是解构的时候我们可以解构一个对象的多个属性,用于定义多个变量。


prop属性如下图:



从上图可以看到prop中有两个属性很显眼,分别是keyvalue


其中key属性对应的是解构对象时从对象中要提取出的属性名,因为我们这里是解构的name属性,所以上面的值是name


其中value属性对应的是解构对象时要赋给的目标变量名称。我们这里是赋值给变量localName,所以上面他的值是localName


接着来看 for 循环中的代码。


执行const propKey = resolveObjectKey(prop.key)拿到要从props对象中解构出的属性名称。

将断点走进resolveObjectKey函数,代码如下:


function resolveObjectKey(node: Node) {  switch (node.type) {    case "Identifier":      return node.name;  }  return undefined;}
复制代码


如果当前是标识符节点,也就是有 name 属性。那么就返回 name 属性。


最后就是执行registerBinding函数。


registerBinding(propKey, prop.value.name)
复制代码


第一个参数为传入解构对象时要提取出的属性名称,也就是name。第二个参数为解构对象时要赋给的目标变量名称,也就是localName


接着将断点走进registerBinding函数,他就在processPropsDestructure函数里面。


function processPropsDestructure(ctx, declId) {  const registerBinding = (    key: string,    local: string,    defaultValue?: Expression  ) => {    ctx.propsDestructuredBindings[key] = { local, default: defaultValue };  };  // ...省略}
复制代码


ctx.propsDestructuredBindings是存在 ctx 上下文中的一个属性对象,这个对象里面存的是需要解构的多个 props。


对象的 key 就是需要解构的 props。


key 对应的 value 也是一个对象,这个对象中有两个字段。其中的local属性是解构 props 后要赋给的变量名称。default属性是 props 的默认值。


在 debug 终端来看看此时的ctx.propsDestructuredBindings对象是什么样的,如下图:



从上图中就有看到此时里面已经存了一个name属性,表示props中的name需要解构,解构出来的变量名为localName,并且默认值为undefined


经过这里的处理后在 ctx 上下文对象中的ctx.propsDestructuredBindings中就已经存了有哪些 props 需要解构,以及解构后要赋值给哪个变量。


有了这个后,后续只需要将 script 模块中的所有代码遍历一次,然后找出哪些在使用的变量是 props 解构的变量,比如这里的localName变量将其替换成__props.name即可。


transformDestructuredProps 函数


接着将断点层层返回,走到最外面的compileScript函数中。再来回忆一下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); } } } }
// 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


所以这里执行rootScope[local] = true后,currentScope对象中的localName属性也会被赋值 true。如下图:



接着就是执行propsLocalToPublicMap[local] = key,这里的local存的是解构 props 后赋值给子组件中的变量名称,key为解构了哪个 props。经过这行代码的处理后我们就形成了一个映射,后续根据这个映射就能轻松的将 script 模块中使用解构后的localName的地方替换为__props.name


propsLocalToPublicMap对象如下图:



经过这个 for 循环的处理后,我们已经知道了有哪些变量其实是经过 props 解构来的,以及这些解构得到的变量和 props 的映射关系。


接下来就是使用walk函数去递归遍历 script 模块中的所有代码,这个递归遍历就是遍历 script 模块对应的 AST 抽象语法树。


在这里是使用的walk函数来自于第三方库estree-walker


在遍历语法树中的某个节点时,进入的时候会触发一次enter回调,出去的时候会触发一次leave回调。

walk函数的执行代码如下:


walk(ast, {  enter(node: Node) {    if (node.type === "Identifier") {      if (currentScope[node.name]) {        rewriteId(node);      }    }  },});
复制代码


我们这个场景中只需要enter进入的回调就行了。


enter回调中使用外层 if 判断当前节点的类型是不是IdentifierIdentifier类型可能是变量名、函数名等。


我们源代码中的console.log(localName)中的localName就是一个变量名,当递归遍历 AST 抽象语法树遍历到这里的localName对应的节点时就会满足外层的 if 条件。


在 debug 终端来看看此时满足外层 if 条件的 node 节点,如下图:



从上面的代码可以看到此时的 node 节点中对应的变量名为localName。其中startend分别表示localName变量的开始位置和结束位置。


我们回忆一下前面讲过了currentScope对象中就是存的是有哪些本地的变量是通过 props 解构得到的,这里的localName变量当然是通过 props 解构得到的,满足里层的 if 条件判断。


最后代码会走进rewriteId函数中,将断点走进rewriteId函数中,简化后的代码如下:


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方法,这个方法接收三个参数。


第一个参数是:开始位置,对应的是变量localName在源码中的开始位置。


第二个参数是:结束位置,对应的是变量localName在源码中的结束位置。


第三个参数是想要替换成的新内容。


第三个参数是由genPropsAccessExp函数返回的,执行这个函数时传入的是propsLocalToPublicMap[id.name]


前面讲过了propsLocalToPublicMap存的是 props 名称和解构到本地的变量名称的映射关系,id.name是解构到本地的变量名称。如下图:



所以propsLocalToPublicMap[id.name]的执行结果就是name,也就是名为name的 props。


接着将断点走进genPropsAccessExp函数,简化后的代码如下:


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)}]


很明显我们这里的name当然满足条件,所以genPropsAccessExp函数会返回__props.name

那么什么情况下不会满足条件呢?


比如这样的 props:


const { "first-name": firstName } = defineProps(["first-name"]);console.log(firstName);
复制代码


这种 props 在这种情况下就会返回__props["first-name"]

执行完genPropsAccessExp函数后回到ctx.s.overwrite方法的地方,此时我们已经知道了第三个参数的值为__props.name。这个方法的执行会将localName重写为__props.name

ctx.s.overwrite方法执行之前我们来看看此时的 script 模块中的 js 代码是什么样的,如下图:



从上图中可以看到此时的代码中console.log里面还是localName


执行完ctx.s.overwrite方法后,我们来看看此时是什么样的,如下图:



从上图中可以看到此时的代码中console.log里面已经变成了__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对象中。


接下来就会去递归遍历 script 模块中的所有代码,如果发现使用的localName变量能够在ctx.propsDestructuredBindings对象中找的到。那么就说明这个localName变量是由 props 解构得到的,就会将其替换为__props.name,所以使用解构后的 props 依然不会丢失响应式。


文章转载自:前端欧阳

原文链接:https://www.cnblogs.com/heavenYJJ/p/18416088

体验地址:http://www.jnpfsoft.com/?from=infoq

用户头像

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
Vue3.5中解构props,让父子组件通信更加丝滑_Vue_快乐非自愿限量之名_InfoQ写作社区