太原网站制作_太原网站建设_太原做网站_秘密武器开发者中心
历史搜索

Vue3 Ref 语法糖,告别使用 refs 时的 .value写法

游客2025-01-25 14:30:01
目录文章目录
  1. 前言
  2. 1. Ref 语法糖在项目中的使用
  3. 2. Ref 语法糖的实现
  4. 结语

Vue3 Ref 语法糖,告别使用 refs 时的 .value写法 1

可以看到 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 节点在源代码中的位置,通常会用 startend 表示,例如这里的 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 节点所处的作用域链,初始情况下栈顶为根作用域 rootScopeparentStack 用于存放遍历 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 语法糖创建的变量,最后则会通过操作 ss.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 节点和作用域相关处理等。

原文链接:点击这里

标签:$ref

本文是由用户"游客"发布,所有内容的版权归原作者所有。没有经过书面许可,任何单位或个人不得以任何形式复制、转载、引用本网站的内容。否则将追究法律责任。

相关专题