可以看到 let
的 AST 节点类型 type
会是 VariableDeclaration
,其余的代码部分对应的 AST 节点则会被放在 declarations
中。其中,变量 count
的 AST 节点会被作为 declarations.id
,而 $ref(1)
的 AST 节点会被作为 declarations.init
。
那么,回到 walkScope()
函数,它会根据 AST 节点的类型 type
进行特定的处理,对于我们这个例子 let
对应的 AST 节点 type
为 VariableDeclaration
会命中这样的逻辑:
function walkScope(node: Program | BlockStatement) { for (const stmt of node.body) { if (stmt.type === 'VariableDeclaration') { for (const decl of stmt.declarations) { let toVarCall if ( decl.init && decl.init.type === 'CallExpression' && decl.init.callee.type === 'Identifier' && (toVarCall = isToVarCall(decl.init.callee.name)) ) { processRefDeclaration( toVarCall, decl.init as CallExpression, decl.id, stmt ) } } } } }
这里的 stmt
则是 let
对应的 AST 节点,然后会遍历 stmt.declarations
,其中 decl.init.callee.name
指的是 $ref
,接着是调用 isToVarCall()
函数并赋值给 toVarCall
。
isToVarCall()
函数的定义:
// packages/ref-transform/src/refTransform.ts const TO_VAR_SYMBOL = '$' const shorthands = ['ref', 'computed', 'shallowRef'] function isToVarCall(callee: string): string | false { if (callee === TO_VAR_SYMBOL) { return TO_VAR_SYMBOL } if (callee[0] === TO_VAR_SYMBOL && shorthands.includes(callee.slice(1))) { return callee } return false }
在前面我们也提及 ref
语法糖可以支持其他写法,由于我们使用的是 $ref
的方式,所以这里会命中 callee[0] === TO_VAR_SYMBOL && shorthands.includes(callee.slice(1))
的逻辑,即 toVarCall
会被赋值为 $ref
。
然后,会调用 processRefDeclaration()
函数,它会根据传入的 decl.init
提供的位置信息来对源代码对应的 MagicString
实例 s
进行操作,即将 $ref
重写为 ref
:
// packages/ref-transform/src/refTransform.ts function processRefDeclaration( method: string, call: CallExpression, id: VariableDeclarator['id'], statement: VariableDeclaration ) { // ... if (id.type === 'Identifier') { registerRefBinding(id) s.overwrite( call.start! + offset, call.start! + method.length + offset, helper(method.slice(1)) ) } // ... }
位置信息指的是该 AST 节点在源代码中的位置,通常会用
start
、end
表示,例如这里的let count = $ref(1)
,那么count
对应的 AST 节点的start
会是 4、end
会是 9。
因为,此时传入的 id
对应的是 count
的 AST 节点,它会是这样:
{ type: "Identifier", start: 4, end: 9, name: "count" }
所以,这会命中上面的 id.type === 'Identifier'
的逻辑。首先,会调用 registerRefBinding()
函数,它实际上是调用的是 registerBinding()
,而 registerBinding
会在当前作用域 currentScope
上绑定该变量 id.name
并设置为 true
,它表示这是一个用 ref
语法糖创建的变量,这会用于后续判断是否给某个变量添加 .value
:
const registerRefBinding = (id: Identifier) => registerBinding(id, true) function registerBinding(id: Identifier, isRef = false) { excludedIds.add(id) if (currentScope) { currentScope[id.name] = isRef } else { error( 'registerBinding called without active scope, something is wrong.', id ) } }
可以看到,在 registerBinding()
中还会给 excludedIds
中添加该 AST 节点,而 excludeIds
它是一个 WeekMap
,它会用于后续跳过不需要进行 ref
语法糖处理的类型为 Identifier
的 AST 节点。
然后,会调用 s.overwrite()
函数来将 $ref
重写为 _ref
,它会接收 3 个参数,分别是重写的起始位置、结束位置以及要重写为的字符串。而 call
则对应着 $ref(1)
的 AST 节点,它会是这样:
{ type: "Identifier", start: 12, end: 19, callee: {...} arguments: {...}, optional: false }
并且,我想大家应该注意到了在计算重写的起始位置的时候用到了 offset
,它代表着此时操作的字符串在源字符串中的偏移位置,例如该字符串在源字符串中的开始,那么偏移量则会是 0
。
而 helper()
函数则会返回字符串 _ref
,并且在这个过程会将 ref
添加到 importedHelpers
中,这会在 compileScript()
时用于生成对应的 import
语句:
function helper(msg: string) { importedHelpers.add(msg) return `_${msg}` }
那么,到这里就完成了对 $ref
到 _ref
的重写,也就是此时我们代码的会是这样:
let count = _ref(1) function add() { count++ }
接着,则是通过 walk()
函数来将 count++
转换成 count.value++
。下面,我们来看一下 walk()
函数。
walk() 函数
前面,我们提及 walk()
函数使用的是 Rich Haris 写的 estree-walker,它是一个用于遍历符合 ESTree 规范的 AST 包(Package)。
walk()
函数使用起来会是这样:
import { walk } from 'estree-walker' walk(ast, { enter(node, parent, prop, index) { // ... }, leave(node, parent, prop, index) { // ... } });
可以看到,walk()
函数中可以传入 options
,其中 enter()
在每次访问 AST 节点的时候会被调用,leave()
则是在离开 AST 节点的时候被调用。
那么,回到前面提到的这个例子,walk()
函数主要做了这 2 件事:
1. 维护 scopeStack、parentStack 和 currentScope
scopeStack
用于存放此时 AST 节点所处的作用域链,初始情况下栈顶为根作用域 rootScope
;parentStack
用于存放遍历 AST 节点过程中的祖先 AST 节点(栈顶的 AST 节点是当前 AST 节点的父亲 AST 节点);currentScope
指向当前的作用域,初始情况下等于根作用域 rootScope
:
const scopeStack: Scope[] = [rootScope] const parentStack: Node[] = [] let currentScope: Scope = rootScope
所以,在 enter()
的阶段会判断此时 AST 节点类型是否为函数、块,是则入栈 scopeStack
:
parent && parentStack.push(parent) if (isFunctionType(node)) { scopeStack.push((currentScope = {})) // ... return } if (node.type === 'BlockStatement' && !isFunctionType(parent!)) { scopeStack.push((currentScope = {})) // ... return }
然后,在 leave()
的阶段判断此时 AST 节点类型是否为函数、块,是则出栈 scopeStack
,并且更新 currentScope
为出栈后的 scopeStack
的栈顶元素:
parent && parentStack.pop() if ( (node.type === 'BlockStatement' && !isFunctionType(parent!)) || isFunctionType(node) ) { scopeStack.pop() currentScope = scopeStack[scopeStack.length - 1] || null }
2. 处理 Identifier 类型的 AST 节点
由于,在我们的例子中 ref
语法糖创建 count
变量的 AST 节点类型是 Identifier
,所以这会在 enter()
阶段命中这样的逻辑:
if ( node.type === 'Identifier' && isReferencedIdentifier(node, parent!, parentStack) && !excludedIds.has(node) ) { let i = scopeStack.length while (i--) { if (checkRefId(scopeStack[i], node, parent!, parentStack)) { return } } }
在 if
的判断中,对于 excludedIds
我们在前面已经介绍过了,而 isReferencedIdentifier()
则是通过 parenStack
来判断当前类型为 Identifier
的 AST 节点 node
是否是一个引用了这之前的某个 AST 节点。
然后,再通过访问 scopeStack
来沿着作用域链来判断是否某个作用域中有 id.name
(变量名 count
)属性以及属性值为 true
,这代表它是一个使用 ref
语法糖创建的变量,最后则会通过操作 s
(s.appendLeft
)来给该变量添加 .value
:
function checkRefId( scope: Scope, id: Identifier, parent: Node, parentStack: Node[] ): boolean { if (id.name in scope) { if (scope[id.name]) { // ... s.appendLeft(id.end! + offset, '.value') } return true } return false }
结语
通过了解 ref
语法糖的实现,我想大家应该会对语法糖这个术语会有不一样的理解,它的本质是在编译阶段通过遍历 AST 来操作特定的代码转换操作。并且,这个实现过程的一些工具包(Package)的配合使用也是非常巧妙的,例如 MagicString
操作源代码字符串、estree-walker
遍历 AST 节点和作用域相关处理等。
原文链接:点击这里