前言

近期,Vue3 提了一个 Ref Sugar 的 RFC,即 ref 语法糖,目前还解决实验性的(Experimental)阶段。在 RFC 的动机(Motivation)中,Evan You 介绍到在 Composition API 引入后,一个次要未解决的问题是 refsreactive 对象的应用。而到处应用 .value 可能会很麻烦,如果在没应用类型零碎的状况下,也会很容易错过:

let count = ref(1)function add() {    count.value++}

所以,一些用户会更偏向于只应用 reactive,这样就不必解决应用 refs.value 问题。而 ref 语法糖的作用是让咱们在应用 ref 创立响应式的变量时,能够间接获取和更改变量自身,而不是应用 .value 来获取和更改对应的值。简略的说,站在应用层面,咱们能够辞别应用 refs 时的 .value 问题:

let count = $ref(1)function add() {    count++}

那么,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

// vite.config.jsimport { defineConfig } from 'vite'import vue from '@vitejs/plugin-vue'export default defineConfig({  plugins: [vue({    refTransform: true  })]})

那么,这样一来 @vitejs/plugin-vue 插件外部会依据传入的选项中 refTransform 的值判断是否须要对 ref 语法糖进行特定的代码转换。因为,这里咱们设置的是 true,显然它是会对 ref 语法糖执行特定的代码转换。

接着,咱们就能够在 .vue 文件中应用 ref 语法糖,这里咱们看一个简略的例子:

<template>    <div>{{count}}</div>    <button @click="add">click me</button></template><script setup>let count = $ref(1)function add() {    count++}</script>

对应渲染到页面上:

能够看到,咱们能够应用 ref 语法糖的形式创立响应式的变量,而不必思考应用的时候要加 .value 的问题。此外,ref 语法糖还反对其余的写法,集体比拟举荐的是这里介绍的 $ref 的形式,有趣味的同学能够去 RFC 上理解其余的写法。

那么,在理解完 ref 语法糖在我的项目中的应用后,咱们算是解答了第一个疑难(怎么在我的项目中应用)。上面,咱们来解答第二个疑难,它又是怎么实现的,也就是在源码中做了哪些解决?

2 Ref 语法糖的实现

首先,咱们通过 Vue Playground 来直观地感受一下,后面应用 ref 语法糖的例子中的 <script setup> 块(Block)在编译后的后果:

import { ref as _ref } from 'vue'const __sfc__ = {  setup(__props) {  let count = _ref(1)  function add() {    count.value++  }}

能够看到,尽管咱们在应用 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-nextpackages/compiler-sfc/src/compileScript.ts 文件中,它次要负责对 <script><script setup> 块内容的编译解决,它会接管 2 个参数:

  • sfc 蕴含 .vue 文件的代码被解析后的内容,蕴含 scriptscriptSetupsource 等属性
  • options 蕴含一些可选和必须的属性,例如组件对应的 scopeId 会作为 options.id、后面提及的 refTransform

compileScript() 函数的定义(伪代码):

// packages/compiler-sfc/src/compileScript.tsexport function compileScript(  sfc: SFCDescriptor,  options: SFCScriptCompileOptions): SFCScriptBlock {  // ...  return {    ...script,    content,    map,    bindings,    scriptAst: scriptAst.body  }} 

对于 ref 语法糖而言,compileScript() 函数首先会获取选项(Option)中 refTransform 的值,并赋值给 enableRefTransform

const enableRefTransform = !!options.refTransform

enableRefTransform 则会用于之后判断是否要调用 ref 语法糖相干的转换函数。那么,后面咱们也提及要应用 ref 语法糖,须要先给 @vite/plugin-vue 插件选项的 refTransform 属性设置为 true,它会被传入 compileScript() 函数的 options,也就是这里的 options.refTransform

接着,会从 sfc 中解构出 scriptSetupsourcefilename 等属性。其中,会先用源文件的代码字符串 source 创立一个 MagicString 实例 s,它次要会用于后续代码转换时对源代码字符串进行替换、增加等操作,而后会调用 parse() 函数来解析 <script setup> 的内容,即 scriptSetup.content,从而生成对应的形象语法树 scriptSetupAst

let { script, scriptSetup, source, filename } = sfcconst s = new MagicString(source)const startOffset = scriptSetup.loc.start.offsetconst scriptSetupAst = parse(  scriptSetup.content,  {    plugins: [      ...plugins,      'topLevelAwait'    ],    sourceType: 'module'  },  startOffset)

parse() 函数外部则是应用的 @babel/parser 提供的 parser 办法进行代码的解析并生成对应的 AST。对于下面咱们这个例子,生成的 AST 会是这样:

{  body: [ {...}, {...} ],  directives: [],  end: 50,  interpreter: null,  loc: {    start: {...},     end: {...},     filename: undefined,     identifierName: undefined  },  sourceType: 'module',  start: 0,  type: 'Program'}
留神,这里省略了 bodystartend 中的内容

而后,会依据后面定义的 enableRefTransform 和调用 shouldTransformRef() 函数的返回值(truefalse)来判断是否进行 ref 语法糖的代码转换。如果,须要进行相应的转换,则会调用 transformRefAST() 函数来依据 AST 来进行相应的代码转换操作:

if (enableRefTransform && shouldTransformRef(scriptSetup.content)) {  const { rootVars, importedHelpers } = transformRefAST(    scriptSetupAst,    s,    startOffset,    refBindings  )}

在后面,咱们曾经介绍过了 enableRefTransform。这里咱们来看一下 shouldTransformRef() 函数,它次要是通过正则匹配代码内容 scriptSetup.content 来判断是否应用了 ref 语法糖:

// packages/ref-transform/src/refTransform.tsconst transformCheckRE = /[^\w]\$(?:\$|ref|computed|shallowRef)?\(/export function shouldTransform(src: string): boolean {  return transformCheckRE.test(src)}

所以,当你指定了 refTransformtrue,然而你代码中理论并没有应用到 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() 函数的定义(伪代码):

// packages/ref-transform/src/refTransform.tsexport function transformAST(  ast: Program,  s: MagicString,  offset: number = 0,  knownRootVars?: string[]): {  // ...  walkScope(ast)  (walk as any)(ast, {    enter(node: Node, parent?: Node) {      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          }        }      }    }  })    return {    rootVars: Object.keys(rootScope).filter(key => rootScope[key]),    importedHelpers: [...importedHelpers]  }}

能够看到 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 节点 typeVariableDeclaration 会命中这样的逻辑:

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.tsconst 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.tsfunction 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 节点和作用域相干解决等。

最初,如果文中存在表白不当或谬误的中央,欢送各位同学提 Issue ~

点赞

通过浏览本篇文章,如果有播种的话,能够点个赞,这将会成为我继续分享的能源,感激~

我是五柳,喜爱翻新、捣鼓源码,专一于源码(Vue3、Vite)、前端工程化、跨端等技术学习和分享。此外,我的所有文章都会收录在 https://github.com/WJCHumble/Blog,欢送 Watch Or Star!