-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Vue3 Ref 语法糖,告别 .value 的写法 #33
Labels
Comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
前言
近期,Vue3 提了一个 Ref Sugar 的 RFC,即
ref
语法糖,目前还处理实验性的(Experimental)阶段。在 RFC 的动机(Motivation)中,Evan You 介绍到在 Composition API 引入后,一个主要未解决的问题是refs
和reactive
对象的使用。而到处使用.value
可能会很麻烦,如果在没使用类型系统的情况下,也会很容易错过:所以,一些用户会更倾向于只使用
reactive
,这样就不用处理使用refs
的.value
问题。而ref
语法糖的作用是让我们在使用ref
创建响应式的变量时,可以直接获取和更改变量本身,而不是使用.value
来获取和更改对应的值。简单的说,站在使用层面可以告别使用refs
时的.value
问题:那么,
ref
语法糖目前要怎么在项目中使用?它又是怎么实现的?这是我第一眼看到这个 RFC 建立的疑问,相信这也是很多同学持有的疑问。所以,下面让我们来一一揭晓。1 Ref 语法糖在项目中的使用
由于
ref
语法糖目前还处于实验性的(Experimental)阶段,所以在 Vue3 中不会默认支持ref
语法糖。那么,这里我们以使用 Vite + Vue3 项目开发为例,看一下如何开启对ref
语法糖的支持。在使用 Vite + Vue3 项目开发时,是由
@vitejs/plugin-vue
插件来实现对.vue
文件的代码转换(Transform)、热更新(HMR)等。所以,我们需要在vite.config.js
中给@vitejs/plugin-vue
插件的选项(Options)传入refTransform: true
:那么,这样一来
@vitejs/plugin-vue
插件内部会根据传入的选项中refTransform
的值判断是否需要对ref
语法糖进行特定的代码转换。由于,这里我们设置的是true
,显然它是会对ref
语法糖执行特定的代码转换。接着,我们就可以在
.vue
文件中使用ref
语法糖,这里我们看一个简单的例子:对应渲染到页面上:
可以看到,我们可以使用
ref
语法糖的方式创建响应式的变量,而不用思考使用的时候要加.value
的问题。此外,ref
语法糖还支持其他的写法,个人比较推荐的是这里介绍的$ref
的方式,有兴趣的同学可以去 RFC 上了解其他的写法。那么,在了解完
ref
语法糖在项目中的使用后,我们算是解答了第一个疑问(怎么在项目中使用)。下面,我们来解答第二个疑问,它又是怎么实现的,也就是在源码中做了哪些处理?2 Ref 语法糖的实现
首先,我们通过 Vue Playground 来直观地感受一下,前面使用
ref
语法糖的例子中的<script setup>
块(Block)在编译后的结果:可以看到,虽然我们在使用
ref
语法糖的时候不需要处理.value
,但是它经过编译后仍然是使用的.value
。那么,这个过程肯定不难免要做很多编译相关的代码转换处理。因为,我们需要找到使用$ref
的声明语句和变量,给前者重写为_ref
,给后者添加.value
。而在前面,我们也提及
@vitejs/plugin-vue
插件会对.vue
文件进行代码的转换,这个过程则是使用的 Vue3 提供的@vue/compiler-sfc
包(Package),它分别提供了对<script>
、<template>
、<style>
等块的编译相关的函数。那么,显然这里我们需要关注的是
<script>
块编译相关的函数,这对应的是@vue/compiler-sfc
中的compileScript()
函数。2.1 compileScript() 函数
compileScript()
函数定义在vue-next
的packages/compiler-sfc/src/compileScript.ts
文件中,它主要负责对<script>
或<script setup>
块内容的编译处理,它会接收 2 个参数:sfc
包含.vue
文件的代码被解析后的内容,包含script
、scriptSetup
、source
等属性options
包含一些可选和必须的属性,例如组件对应的scopeId
会作为options.id
、前面提及的refTransform
等compileScript()
函数的定义(伪代码):对于
ref
语法糖而言,compileScript()
函数首先会获取选项(Option)中refTransform
的值,并赋值给enableRefTransform
:enableRefTransform
则会用于之后判断是否要调用ref
语法糖相关的转换函数。那么,前面我们也提及要使用ref
语法糖,需要先给@vite/plugin-vue
插件选项的refTransform
属性设置为true
,它会被传入compileScript()
函数的options
,也就是这里的options.refTransform
。接着,会从
sfc
中解构出scriptSetup
、source
、filename
等属性。其中,会先用源文件的代码字符串source
创建一个MagicString
实例s
,它主要会用于后续代码转换时对源代码字符串进行替换、添加等操作,然后会调用parse()
函数来解析<script setup>
的内容,即scriptSetup.content
,从而生成对应的抽象语法树scriptSetupAst
:而
parse()
函数内部则是使用的@babel/parser
提供的parser
方法进行代码的解析并生成对应的 AST。对于上面我们这个例子,生成的 AST 会是这样:然后,会根据前面定义的
enableRefTransform
和调用shouldTransformRef()
函数的返回值(true
或false
)来判断是否进行ref
语法糖的代码转换。如果,需要进行相应的转换,则会调用transformRefAST()
函数来根据 AST 来进行相应的代码转换操作:在前面,我们已经介绍过了
enableRefTransform
。这里我们来看一下shouldTransformRef()
函数,它主要是通过正则匹配代码内容scriptSetup.content
来判断是否使用了ref
语法糖:所以,当你指定了
refTransform
为true
,但是你代码中实际并没有使用到ref
语法糖,则在编译<script>
或<script setup>
的过程中也不会执行和ref
语法糖相关的代码转换操作,这也是 Vue3 考虑比较细致的地方,避免了不必要的代码转换操作带来性能上的开销。那么,对于我们这个例子而言(使用了
ref
语法糖),则会命中上面的transformRefAST()
函数。而transformRefAST()
函数则对应的是packages/ref-transform/src/refTransform.ts
中的transformAST()
函数。所以,下面我们来看一下
transformAST()
函数是如何根据 AST 来对ref
语法糖相关代码进行转换操作的。2.2 transformAST() 函数
在
transformAST()
函数中主要是会遍历传入的原代码对应的 AST,然后通过操作源代码字符串生成的MagicString
实例s
来对源代码进行特定的转换,例如重写$ref
为_ref
、添加.value
等。transformAST()
函数的定义(伪代码):可以看到
transformAST()
会先调用walkScope()
来处理根作用域(root scope
),然后调用walk()
函数逐层地处理 AST 节点,而这里的walk()
函数则是使用的 Rich Haris 写的estree-walker
。下面,我们来分别看一下
walkScope()
和walk()
函数做了什么。walkScope() 函数
首先,这里我们先来看一下前面使用
ref
语法糖的声明语句let count = $ref(1)
对应的 AST 结构:可以看到
let
的 AST 节点类型type
会是VariableDeclaration
,其余的代码部分对应的 AST 节点则会被放在declarations
中。其中,变量count
的 AST 节点会被作为declarations.id
,而$ref(1)
的 AST 节点会被作为declarations.init
。那么,回到
walkScope()
函数,它会根据 AST 节点的类型type
进行特定的处理,对于我们这个例子let
对应的 AST 节点type
为VariableDeclaration
会命中这样的逻辑:这里的
stmt
则是let
对应的 AST 节点,然后会遍历stmt.declarations
,其中decl.init.callee.name
指的是$ref
,接着是调用isToVarCall()
函数并赋值给toVarCall
。isToVarCall()
函数的定义:在前面我们也提及
ref
语法糖可以支持其他写法,由于我们使用的是$ref
的方式,所以这里会命中callee[0] === TO_VAR_SYMBOL && shorthands.includes(callee.slice(1))
的逻辑,即toVarCall
会被赋值为$ref
。然后,会调用
processRefDeclaration()
函数,它会根据传入的decl.init
提供的位置信息来对源代码对应的MagicString
实例s
进行操作,即将$ref
重写为ref
:因为,此时传入的
id
对应的是count
的 AST 节点,它会是这样:所以,这会命中上面的
id.type === 'Identifier'
的逻辑。首先,会调用registerRefBinding()
函数,它实际上是调用的是registerBinding()
,而registerBinding
会在当前作用域currentScope
上绑定该变量id.name
并设置为true
,它表示这是一个用ref
语法糖创建的变量,这会用于后续判断是否给某个变量添加.value
:可以看到,在
registerBinding()
中还会给excludedIds
中添加该 AST 节点,而excludeIds
它是一个WeekMap
,它会用于后续跳过不需要进行ref
语法糖处理的类型为Identifier
的 AST 节点。然后,会调用
s.overwrite()
函数来将$ref
重写为_ref
,它会接收 3 个参数,分别是重写的起始位置、结束位置以及要重写为的字符串。而call
则对应着$ref(1)
的 AST 节点,它会是这样:并且,我想大家应该注意到了在计算重写的起始位置的时候用到了
offset
,它代表着此时操作的字符串在源字符串中的偏移位置,例如该字符串在源字符串中的开始,那么偏移量则会是0
。而
helper()
函数则会返回字符串_ref
,并且在这个过程会将ref
添加到importedHelpers
中,这会在compileScript()
时用于生成对应的import
语句:那么,到这里就完成了对
$ref
到_ref
的重写,也就是此时我们代码的会是这样:接着,则是通过
walk()
函数来将count++
转换成count.value++
。下面,我们来看一下walk()
函数。walk() 函数
前面,我们提及
walk()
函数使用的是 Rich Haris 写的 estree-walker,它是一个用于遍历符合 ESTree 规范的 AST 包(Package)。walk()
函数使用起来会是这样:可以看到,
walk()
函数中可以传入options
,其中enter()
在每次访问 AST 节点的时候会被调用,leave()
则是在离开 AST 节点的时候被调用。那么,回到前面提到的这个例子,
walk()
函数主要做了这 2 件事:1.维护 scopeStack、parentStack 和 currentScope
scopeStack
用于存放此时 AST 节点所处的作用域链,初始情况下栈顶为根作用域rootScope
;parentStack
用于存放遍历 AST 节点过程中的祖先 AST 节点(栈顶的 AST 节点是当前 AST 节点的父亲 AST 节点);currentScope
指向当前的作用域,初始情况下等于根作用域rootScope
:所以,在
enter()
的阶段会判断此时 AST 节点类型是否为函数、块,是则入栈scopeStack
:然后,在
leave()
的阶段判断此时 AST 节点类型是否为函数、块,是则出栈scopeStack
,并且更新currentScope
为出栈后的scopeStack
的栈顶元素:2.处理 Identifier 类型的 AST 节点
由于,在我们的例子中
ref
语法糖创建count
变量的 AST 节点类型是Identifier
,所以这会在enter()
阶段命中这样的逻辑:在
if
的判断中,对于excludedIds
我们在前面已经介绍过了,而isReferencedIdentifier()
则是通过parenStack
来判断当前类型为Identifier
的 AST 节点node
是否是一个引用了这之前的某个 AST 节点。然后,再通过访问
scopeStack
来沿着作用域链来判断是否某个作用域中有id.name
(变量名count
)属性以及属性值为true
,这代表它是一个使用ref
语法糖创建的变量,最后则会通过操作s
(s.appendLeft
)来给该变量添加.value
:结语
通过了解
ref
语法糖的实现,我想大家应该会对语法糖这个术语会有不一样的理解,它的本质是在编译阶段通过遍历 AST 来操作特定的代码转换操作。并且,这个实现过程的一些工具包(Package)的配合使用也是非常巧妙的,例如MagicString
操作源代码字符串、estree-walker
遍历 AST 节点等。最后,如果文中存在表达不当或错误的地方,欢迎各位同学提 Issue ~
The text was updated successfully, but these errors were encountered: