有点东西,template 可以直接使用 setup 语法糖中的变量原来是因为这个
前言
我们每天写 vue3 代码的时候都会使用到 setup 语法糖,那你知道为什么 setup 语法糖中的顶层绑定可以在 template 中直接使用的呢?setup 语法糖是如何编译成 setup 函数的呢?本文将围绕这些问题带你揭开 setup 语法糖的神秘面纱。注:本文中使用的 vue 版本为3.4.19
。
看个 demo
看个简单的 demo,代码如下:
在上面的 demo 中定义了四个顶层绑定:Child
子组件、从util.js
文件中导入的format
方法、使用 ref 定义的msg
只读常量、使用 let 定义的title
变量。并且在 template 中直接使用了这四个顶层绑定。
由于innerContent
是在 if 语句里面的变量,不是<script setup>
中的顶层绑定,所以在 template 中是不能使用innerContent
的。
但是你有没有想过为什么<script setup>
中的顶层绑定就能在 template 中使用,而像innerContent
这种非顶层绑定就不能在 template 中使用呢?
我们先来看看上面的代码编译后的样子,在之前的文章中已经讲过很多次如何在浏览器中查看编译后的 vue 文件,这篇文章就不赘述了。编译后的代码如下:
从上面的代码中可以看到编译后已经没有了<script setup>
,取而代之的是一个 setup 函数,这也就证明了为什么说 setup 是一个编译时语法糖。
setup 函数的参数有两个,第一个参数为组件的 props
。第二个参数为 Setup 上下文对象,上下文对象暴露了其他一些在 setup
中可能会用到的值,比如:expose
等。
再来看看 setup 函数中的内容,其实和我们的源代码差不多,只是多了一个 return。使用 return 会将组件中的那四个顶层绑定暴露出去,所以在 template 中就可以直接使用<script setup>
中的顶层绑定。
值的一提的是在 return 对象中title
变量和format
函数有点特别。title
、format
这两个都是属于访问器属性,其他两个msg
、Child
属于常见的数据属性。
title
是一个访问器属性,同时拥有get
和 set
,读取title
变量时会走进get
中,当给title
变量赋值时会走进set
中。
format
也是一个访问器属性,他只拥有get
,调用format
函数时会走进get
中。由于他没有set
,所以不能给format
函数重新赋值。其实这个也很容易理解,因为format
函数是从util.js
文件中 import 导入的,当然不能给他重新赋值。
至于在 template 中是怎么拿到setup
函数返回的对象可以看我的另外一篇文章: Vue 3 的 setup语法糖到底是什么东西?
看到这里有的小伙伴会有疑问了,不是还有一句import { ref } from "vue"
也是顶层绑定,为什么里面的ref
没有在 setup 函数中使用 return 暴露出去呢?还有在 return 对象中是如何将title
、format
识别为访问器属性呢?
在接下来的文章中我会逐一解答这些问题。
compileScript
函数
在之前的 通过debug搞清楚.vue文件怎么变成.js文件文章中已经讲过了 vue 的 script 模块中的内容是由@vue/compiler-sfc
包中的compileScript
函数处理的,当然你没看过那篇文章也不会影响这篇文章的阅读。
首先我们需要启动一个 debug 终端。这里以vscode
举例,打开终端然后点击终端中的+
号旁边的下拉箭头,在下拉中点击Javascript Debug Terminal
就可以启动一个debug
终端。
然后在node_modules
中找到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
函数代码如下:
首先我们来看看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 字符串。sfc.scriptSetup.loc.start.offset
为<script setup>
中内容开始位置,sfc.scriptSetup.loc.end.offset
为<script setup>
中内容结束位置。详情查看下图:
我们再来看compileScript
函数中的内容,在compileScript
函数中包含了从<script setup>
语法糖到 setup 函数的完整流程。乍一看可能比较难以理解,所以我将其分为七块。
根据
<script setup>
中的内容生成一个 ctx 上下文对象。遍历
<script setup>
中的内容,处理里面的 import 语句、顶层变量、顶层函数、顶层类、顶层枚举声明等。移除 template 和 style 中的内容,以及 script 的开始标签和结束标签。
将
<script setup>
中的顶层绑定的元数据存储到ctx.bindingMetadata
对象中。根据
<script setup>
中的顶层绑定生成 return 对象。生成 setup 函数定义
插入 import vue 语句
在接下来的文章中我将逐个分析这七块的内容。
生成 ctx 上下文对象
我们来看第一块的代码,如下:
在这一块的代码中主要做了一件事,使用ScriptCompileContext
构造函数 new 了一个 ctx 上下文对象。在之前的 为什么defineProps宏函数不需要从vue中import导入?文章中我们已经讲过了ScriptCompileContext
构造函数里面的具体代码,这篇文章就不赘述了。
本文只会讲用到的ScriptCompileContext
类中的startOffset
、endOffset
、scriptSetupAst
、userImports
、helperImports
、bindingMetadata
、s
等属性。
startOffset
、endOffset
属性是在ScriptCompileContext
类的constructor
构造函数中赋值的。其实就是sfc.scriptSetup.loc.start.offset
和sfc.scriptSetup.loc.end.offset
,<script setup>
中内容开始位置和<script setup>
中内容结束位置,只是将这两个字段塞到 ctx 上下文中。scriptSetupAst
是在ScriptCompileContext
类的constructor
构造函数中赋值的,他是<script setup>
模块的代码转换成的 AST 抽象语法树。在ScriptCompileContext
类的constructor
构造函数中会调用@babel/parser
包的parse
函数,以<script setup>
中的 code 代码字符串为参数生成 AST 抽象语法树。userImports
在 new 一个 ctx 上下文对象时是一个空对象,用于存储 import 导入的顶层绑定内容。helperImports
同样在 new 一个 ctx 上下文对象时是一个空对象,用于存储需要从 vue 中 import 导入的函数。bindingMetadata
同样在 new 一个 ctx 上下文对象时是一个空对象,用于存储所有的 import 顶层绑定和变量顶层绑定的元数据。s
属性是在ScriptCompileContext
类的constructor
构造函数中赋值的,以vue
文件中的源代码 code 字符串为参数new
了一个MagicString
对象赋值给s
属性。
magic-string
是由svelte的作者写的一个库,用于处理字符串的JavaScript
库。它可以让你在字符串中进行插入、删除、替换等操作,并且能够生成准确的sourcemap
。
MagicString
对象中拥有toString
、remove
、prependLeft
、appendRight
等方法。s.toString
用于生成返回的字符串,我们来举几个例子看看这几个方法你就明白了。
s.remove( start, end )
用于删除从开始到结束的字符串:
s.prependLeft( index, content )
用于在指定index
的前面插入字符串:
s.appendRight( index, content )
用于在指定index
的后面插入字符串:
除了上面说的那几个属性,在这里定义了一个setupBindings
变量。初始值是一个空对象,用于存储顶层声明的变量、函数等。
遍历<script
setup>
body 中的内容
将断点走到第二部分,代码如下:
在这一部分的代码中使用 for 循环遍历了两次scriptSetupAst.body
,scriptSetupAst.body
为 script 中的代码对应的 AST 抽象语法树中 body 的内容,如下图:
从上图中可以看到scriptSetupAst.body
数组有 6 项,分别对应的是 script 模块中的 6 块代码。
第一个 for 循环中使用 if 判断node.type === "ImportDeclaration"
,也就是判断是不是 import 语句。如果是 import 语句,那么 import 的内容肯定是顶层绑定,需要将 import 导入的内容存储到ctx.userImports
对象中。注:后面会专门写一篇文章来讲如何收集所有的 import 导入。
通过这个 for 循环已经将所有的 import 导入收集到了ctx.userImports
对象中了,在 debug 终端看看此时的ctx.userImports
,如下图:
从上图中可以看到在ctx.userImports
中收集了三个 import 导入,分别是Child
组件、format
函数、ref
函数。
在里面有几个字段需要注意,isUsedInTemplate
表示当前 import 导入的东西是不是在 template 中使用,如果为 true 那么就需要将这个 import 导入塞到 return 对象中。
isType
表示当前 import 导入的是不是 type 类型,因为在 ts 中是可以使用 import 导入 type 类型,很明显 type 类型也不需要塞到 return 对象中。
我们再来看第二个 for 循环,同样也是遍历scriptSetupAst.body
。如果当前是变量定义、函数定义、类定义、ts 枚举定义,这四种类型都属于顶层绑定(除了 import 导入以外就只有这四种顶层绑定了)。需要调用walkDeclaration
函数将这四种顶层绑定收集到setupBindings
对象中。
从前面的scriptSetupAst.body
图中可以看到 if 模块的 type 为IfStatement
,明显不属于上面的这四种类型,所以不会执行walkDeclaration
函数将里面的innerContent
变量收集起来后面再塞到 return 对象中。这也就解释了为什么非顶层绑定不能在 template 中直接使用。
我们在 debug 终端来看看执行完第二个 for 循环后setupBindings
对象是什么样的,如下图:
从上图中可以看到在setupBindings
对象中收集msg
和title
这两个顶层变量。其中的setup-ref
表示当前变量是一个 ref 定义的变量,setup-let
表示当前变量是一个 let 定义的变量。
移除 template 模块和 style 模块
接着将断点走到第三部分,代码如下:
这块代码很简单,startOffset
为<script setup>
中的内容开始位置,endOffset
为<script setup>
中的内容结束位置,ctx.s.remove
方法为删除字符串。
所以ctx.s.remove(0, startOffset)
的作用是:移除 template 中的内容和 script 的开始标签。
ctx.s.remove(endOffset, source.length)
的作用是:移除 style 中的内容和 script 的结束标签。
我们在 debug 终端看看执行这两个remove
方法之前的 code 代码字符串是什么样的,如下图:
从上图中可以看到此时的 code 代码字符串和我们源代码差不多,唯一的区别就是那几个 import 导入已经被提取到 script 标签外面去了(这个是在前面第一个 for 循环处理 import 导入的时候处理的)。
将断点走到执行完这两个 remove 方法之后,在 debug 终端看看此时的 code 代码字符串,如下图:
从上图中可以看到执行这两个remove
方法后 template 模块、style 模块(虽然本文 demo 中没有写 style 模块)、script 开始标签、script 结束标签都已经被删除了。唯一剩下的就是 script 模块中的内容,还有之前提出去的那几个 import 导入。
将顶层绑定的元数据存储到ctx.bindingMetadata
接着将断点走到第四部分,代码如下:
上面的代码主要分为三块,第一块为 for 循环遍历前面收集到的ctx.userImports
对象。这个对象里面收集的是所有的 import 导入,将所有 import 导入塞到ctx.bindingMetadata
对象中。
第二块也是 for 循环遍历前面收集的setupBindings
对象,这个对象里面收集的是顶层声明的变量、函数、类、枚举,同样的将这些顶层绑定塞到ctx.bindingMetadata
对象中。
为什么要多此一举存储一个ctx.bindingMetadata
对象呢?
答案是 setup 的 return 的对象有时会直接返回顶层变量(比如 demo 中的msg
常量)。有时只会返回变量的访问器属性 get(比如 demo 中的format
函数)。有时会返回变量的访问器属性 get 和 set(比如 demo 中的title
变量)。所以才需要一个ctx.bindingMetadata
对象来存储这些顶层绑定的元数据。
将断点走到执行完这两个 for 循环的地方,在 debug 终端来看看此时收集的ctx.bindingMetadata
对象是什么样的,如下图:
最后一块代码也很简单进行字符串拼接生成 setup 函数的参数,第一个参数为组件的 props、第二个参数为expose
方法组成的对象。如下图:
生成 return 对象
接着将断点走到第五部分,代码如下:
这部分的代码看着很多,其实逻辑也非常清晰,我也将其分为三块。
在第一块中首先使用扩展运算符...setupBindings
将setupBindings
对象中的属性合并到allBindings
对象中,因为setupBindings
对象中存的顶层声明的变量、函数、类、枚举都需要被 return 出去。
然后遍历ctx.userImports
对象,前面讲过了ctx.userImports
对象中存的是所有的 import 导入(包括从 vue 中 import 导入 ref 函数)。在循环里面执行了 if 判断!ctx.userImports[key].isType && ctx.userImports[key].isUsedInTemplate
,这个判断的意思是如果当前 import 导入的不是 ts 的 type 类型并且 import 导入的内容在 template 模版中使用了。才会去执行allBindings[key] = true
,执行后就会将满足条件的 import 导入塞到allBindings
对象中。
后面生成 setup 函数的 return 对象就是通过遍历这个allBindings
对象实现的。这也就解释了为什么从 vue 中 import 导入的 ref 函数也是顶层绑定,为什么他没有被 setup 函数返回。因为只有在 template 中使用的 import 导入顶层绑定才会被 setup 函数返回。
将断点走到遍历ctx.userImports
对象之后,在 debug 终端来看看此时的allBindings
对象是什么样的,如下图:
从上图中可以看到此时的allBindings
对象中存了四个需要 return 的顶层绑定。
接着就是执行 for 循环遍历allBindings
对象生成 return 对象的字符串,这循环中有三个 if 判断条件。我们先来看第一个,代码如下:
if 条件判断是:如果当前 import 导入不是从 vue 中,并且也不是 import 导入一个 vue 组件。那么就给 return 一个只拥有 get 的访问器属性,对应我们 demo 中的就是import { format } from "./util.js"
中的format
函数。
我们再来看第二个 else if 判断,代码如下:
这个 else if 条件判断是:如果当前顶层绑定是一个 let 定义的变量。那么就给 return 一个同时拥有 get 和 set 的访问器属性,对应我们 demo 中的就是let title"
变量。
最后就是 else,代码如下:
这个 else 中就是普通的数据属性了,对应我们 demo 中的就是msg
变量和Child
组件。
将断点走到生成 return 对象之后,在 debug 终端来看看此时生成的 return 对象是什么样的,如下图:
从上图中可以看到此时已经生成了 return 对象啦。
前面我们只生成了 return 对象,但是还没将其插入到要生成的 code 字符串中,所以需要执行ctx.s.appendRight
方法在末尾插入 return 的代码。
将断点走到执行完ctx.s.appendRight
方法后,在 debug 终端来看看此时的 code 代码字符串是什么样的,如下图:
从上图中可以看到此时的 code 代码字符串中多了一块 return 的代码。
生成 setup 函数定义
接着将断点走到第六部分,代码如下:
这部分的代码很简单,调用ctx.s.prependLeft
方法从左边插入一串代码。插入的这串代码就是简单的字符串拼接,我们在 debug 终端来看看要插入的代码是什么样的,如下图:
是不是觉得上面这块需要插入的代码看着很熟悉,他就是编译后的_sfc_main
对象除去 setup 函数内容的部分。将断点走到ctx.s.appendRight
方法执行之后,再来看看此时的 code 代码字符串是什么样的,如下图:
从上图中可以看到此时的 setup 函数基本已经生成完了。
插入 import vue 语句
上一步生成的 code 代码字符串其实还有一个问题,在代码中使用了_defineComponent
函数,但是没有从任何地方去 import 导入。
第七块的代码就会生成缺少的 import 导入,代码如下:
将断点走到ctx.s.prepend
函数执行后,再来看看此时的 code 代码字符串,如下图:
从上图中可以看到已经生成了完整的 setup 函数啦。
总结
整个流程图如下:
遍历
<script setup>
中的代码将所有的 import 导入收集到ctx.userImports
对象中。遍历
<script setup>
中的代码将所有的顶层变量、函数、类、枚举收集到setupBindings
对象中。调用
ctx.s.remove
方法移除 template、style 模块以及 script 开始标签和结束标签。遍历前面收集的
ctx.userImports
和setupBindings
对象,将所有的顶层绑定元数据存储到bindingMetadata
对象中。遍历前面收集的
ctx.userImports
和setupBindings
对象,生成 return 对象中的内容。在这一步的时候会将没有在 template 中使用的 import 导入给过滤掉,这也就解释了为什么从 vue 中导入的 ref 函数不包含在 return 对象中。调用
ctx.s.prependLeft
方法生成 setup 的函数定义。调用
ctx.s.prepend
方法生成完整的 setup 函数。
文章转载自:前端欧阳
评论