preface

Recently, Vue3 introduced a Ref Sugar RFC, or Ref syntactic Sugar, which is currently in the Experimental stage. In RFC Motivation, Evan You introduced that after the introduction of Composition API, a major unsolved problem is the use of refs and Reactive objects. Using.value everywhere can be cumbersome, and it’s easy to miss if you don’t use the type system:

let count = ref(1)

function add() {
	count.value++
}
Copy the code

As a result, some users will prefer to use reactive only so they don’t have to deal with.value issues using refs. Instead of using.value to get and change the corresponding value, the ref syntax sugar allows us to create responsive variables using ref directly. In a nutshell, we can say goodbye to the.value problem with refs at the usage level:

let count = $ref(1)

function add() {
	count++
}
Copy the code

So, how is the REF syntax sugar currently used in the project? How does it work? This is the question I first saw the establishment of this RFC, and I believe it is also the question held by many students. So, let’s find out.

1 Ref Use of syntactic sugar in projects

Because ref sugar is still Experimental, it will not be supported by default in Vue3. So, let’s use Vite + Vue3 project development as an example to see how to turn on support for ref syntax sugar.

In the use of Vite + Vue3 project development, is by the @Vitejs/plugin-Vue plug-in to achieve. Vue file code transformation (Transform), hot update (HMR) and so on. So we need to pass refTransform: true to @vitejs/plugin-vue plugin Options in viet.config. js.

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue({
    refTransform: true})]})Copy the code

So, the @vitejs/ plugin-Vue plugin internally determines whether a specific code conversion is needed for the REF syntax sugar based on the value of the refTransform in the option passed in. Since we are setting true here, it is obvious that it will perform a specific code conversion on the REF syntax sugar.

Next, we can use the ref syntax sugar in our.vue file. Here’s a simple example:

<template>
	<div>{{count}}</div>
	<button @click="add">click me</button>
</template>

<script setup>
let count = $ref(1)

function add() {
	count++
}
</script>
Copy the code

Corresponding to render to page:

As you can see, we can create reactive variables using ref syntax sugar without having to worry about adding.value to them. In addition, there are other ways to write the ref syntax. Personally, I recommend the $ref syntax introduced here. Interested students can go to the RFC to learn other ways to write.

Now that we know how to use ref syntactic sugar in a project, we’ve answered the first question. Now, let’s answer the second question, how it is implemented, that is, in the source code to do what?

2 Ref syntax sugar implementation

First, let’s take a look at the Vue Playground and see what the

import { ref as _ref } from 'vue'

const __sfc__ = {
  setup(__props) {
  let count = _ref(1)

  function add() {
    count.value++
  }
}
Copy the code

As you can see, although we don’t need to deal with.value when we use the ref syntax sugar, it is still compiled to use.value. This process certainly does a lot of compile-related transcoding. Because we need to find declarations and variables that use $ref, rewrite the former to _ref and add.value to the latter.

As mentioned above, the @vitejs/plugin-vue plug-in will convert the code from the.vue file. This process uses the @vue/ Compiler-SFC Package (Package) provided by Vue3. It provides compile-related functions for blocks such as

So the obvious thing to focus on here is the

2.1 compileScript () function

The compileScript() function is defined in the packages/ Compiler-sFC/SRC/compilescript.ts file of VUE – Next, It is responsible for compiling the contents of the

  • sfccontains.vueThe contents of the file after the code is parsed are includedscript,scriptSetup,sourceAttributes such as
  • optionsContains optional and required properties, such as those for componentsscopeIdasoptions.idAs mentioned aboverefTransform

Definition of compileScript() :

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

For ref syntax sugars, the compileScript() function first takes the refTransform value from Option and assigns it to enableRefTransform:

constenableRefTransform = !! options.refTransformCopy the code

The enableRefTransform is then used to determine whether the ref syntax sugar related conversion function should be called. To use the ref sugar, set the refTransform property of @vite/plugin-vue to true, which will be passed to compileScript() options. So options.refTransform here.

Next, the properties scriptSetup, source, filename, and so on are deconstructed from the SFC. Parse () is called to parse the contents of

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

Parse () uses the parser method provided by @babel/parser to parse the code and generate the AST. For our example above, the generated AST would look like this:

{
  body: [{...}, {...}],directives: [].end: 50.interpreter: null.loc: {
    start: {... },end: {... },filename: undefined.identifierName: undefined
  },
  sourceType: 'module'.start: 0.type: 'Program'
}
Copy the code

Notice that body, start, and end are omitted

The ref syntax sugar conversion is then determined based on the enableRefTransform defined earlier and the return value (true or false) of calling shouldTransformRef(). If a conversion is required, the transformRefAST() function is called to perform the conversion based on the AST:

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

Earlier, we introduced the enableRefTransform. ShouldTransformRef () is a function that matches scriptsetup.content to determine if the ref sugar is used:

// packages/ref-transform/src/refTransform.ts
const transformCheckRE = /[^\w]\$(? :\$|ref|computed|shallowRef)? \ [/

export function shouldTransform(src: string) :boolean {
  return transformCheckRE.test(src)
}
Copy the code

So, if you specify refTransform to true, but your code does not actually use ref sugar, then no transcoding operations related to ref sugar will be performed during compilation of

So, for our example (using ref syntax sugar), we hit the transformRefAST() function above. And transformRefAST () function is the corresponding packages/ref – transform/SRC/refTransform transformAST of ts () function.

So, let’s look at how the transformAST() function transforms the ref syntax sugar code based on the AST.

2.2 transformAST () function

The transformAST() function iterates through the AST corresponding to the source code that is passed in, and then transforms the source code by manipulating the MagicString instance S generated by the source code string, such as rewriting $ref to _ref and adding.value.

Definition of the transformAST() function (pseudocode) :

// packages/ref-transform/src/refTransform.ts
export 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]
  }
}
Copy the code

As you can see transformAST() first calls walkScope() to process the root scope and then calls walk() to process the AST node layer by layer. The walk() function is estree- Walker written by Rich Haris.

Let’s take a look at what the walkScope() and walk() functions do.

WalkScope () function

Let count = $ref(1) let count = $ref(1)

As you can see, the AST node type of the let will be a VariableDeclaration and the AST nodes corresponding to the rest of the code will be placed in declarations. The AST node of the count variable is declarations. Id and the AST node of $ref(1) is declarations.

So, going back to the walkScope() function, it performs specific processing according to the type type of the AST node. For our example, let’s correspond to the AST node type VariableDeclaration matches this logic:

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
          )
        }
      }
    }
  }
}
Copy the code

STMT is the let’s AST node, and stmt.declarations are traversed where decl.init.callee.name refers to $ref, The isToVarCall() function is then called and assigned to 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
}
Copy the code

We also mentioned earlier that the ref syntax sugar can be written in other ways, and since we’re using $ref, So the logic of callee[0] === TO_VAR_SYMBOL && shorthands.includes(callee.slice(1)) will be hit, i.e. toVarCall will be assigned $ref.

The processRefDeclaration() function is then called, which operates on the source code’s corresponding MagicString instance S based on the location information provided by the passed decl.init, rewriting $ref to 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)))}// ...
}
Copy the code

The position information refers to the position of the AST node in the source code, usually represented by start and end. For example, let count = $ref(1), then the start and end of the AST node corresponding to count will be 4 and 9.

Because the passed ID corresponds to the AST node of count, it would look like this:

{
  type: "Identifier".start: 4.end: 9.name: "count"
}
Copy the code

So, this hits the logic of id.type === ‘Identifier’ above. First, the registerRefBinding() function is called, which actually calls a registerBinding(), RegisterBinding will bind the variable id.name to the currentScope and set it to true, indicating that it is a variable created with the ref syntax sugar. This will be used later to determine whether to add.value to a variable:

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
    )
  }
}
Copy the code

As you can see, registerBinding() also adds the AST node to excludedIds, which is a WeekMap, It will be used later to skip AST nodes of type Identifier that do not require ref syntax sugar processing.

The s.overwrite() function is then called to rewrite $ref to _ref, which takes three arguments, the start position of the rewrite, the end position, and the string to rewrite to. Call corresponds to the AST node of $ref(1), which would look like this:

{
  type: "Identifier".start: 12.end: 19.callee: {... }arguments: {... },optional: false
}
Copy the code

Also, I think you should have noticed that offset is used when calculating the starting position of the overwrite, which represents the offset position of the operating string in the source string. For example, if the string is at the beginning of the source string, the offset will be 0.

The helper() function returns the string _ref and adds ref to importedHelpers, which are used to generate import statements when compileScript() :

function helper(msg: string) {
  importedHelpers.add(msg)
  return ` _${msg}`
}
Copy the code

So, at this point, we have finished rewriting $ref to _ref, so our code will look like this:

let count = _ref(1)

function add() {
	count++
}
Copy the code

Next, count++ is converted to count.value++ by the walk() function. Next, let’s look at the walk() function.

Walk () function

Earlier, we mentioned that the Walk () function uses Estree-Walker, written by Rich Haris, which is used to walk through AST packages conforming to estree specifications.

The walk() function would look like this:

import { walk } from 'estree-walker'

walk(ast, {
  enter(node, parent, prop, index) {
    // ...
  },
  leave(node, parent, prop, index) {
    // ...}});Copy the code

As you can see, options can be passed in the walk() function, where Enter () is called every time the AST node is accessed and leave() is called every time the AST node is left.

So, going back to the previous example, the walk() function does two things:

1. Maintain scopeStack, parentStack, and currentScope

ScopeStack is used to store the scope chain of the AST node. The initial stack top is the rootScope. ParentStack Stores the parent AST node during traversal of AST nodes (the AST node at the top of the stack is the parent AST node of the current AST node). CurrentScope points to the currentScope, initially equal to the rootScope rootScope:

const scopeStack: Scope[] = [rootScope]
const parentStack: Node[] = []
let currentScope: Scope = rootScope
Copy the code

The AST node type is a function or block. If it is, the scopeStack is pushed:

parent && parentStack.push(parent)
if (isFunctionType(node)) {
  scopeStack.push((currentScope = {}))
  // ...
  return
}
if (node.type === 'BlockStatement'&&! isFunctionType(parent!) ) { scopeStack.push((currentScope = {}))// ...
  return
}
Copy the code

Then, in the leave() phase, check whether the AST node type is function or block. If yes, the stack scopeStack will be updated, and the top element of scopeStack will be updated.

parent && parentStack.pop()
if (
  (node.type === 'BlockStatement'&&! isFunctionType(parent!) ) || isFunctionType(node) ) { scopeStack.pop() currentScope = scopeStack[scopeStack.length -1] | |null
}
Copy the code

2. Process AST nodes of the Identifier type

Since, in our example, the AST node type of the ref syntax sugar that creates the count variable is Identifier, this hits logic like this at the Enter () stage:

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}}}Copy the code

In the if judgment, for excludedIds we have introduced earlier, IsReferencedIdentifier () uses parenStack to determine whether an AST node of the current type Identifier is a node that refers to a previous AST node.

We then go down the scope chain by accessing the scopeStack to see if a scope has an id.name (variable name count) property and its value is true, which means it is a variable created using the ref syntactic sugar. Finally, we add.value to the variable by operating s (s.appendLeft) :

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
}
Copy the code

conclusion

By looking at the ref syntactic sugar implementation, I’m sure you’ll have a different understanding of the term syntactic sugar, which essentially iterates through the AST at compile time to perform specific transcoding operations. Also, some of the toolkits used in this implementation are very clever, such as MagicString manipulating source code strings, Estree-Walker traversing AST nodes, and scope-specific handling.

Finally, if there is any improper expression or mistake in the article, please make an Issue

give a like

If you get something from this post, please give me a “like”. This will be my motivation to keep sharing. Thank you

My name is Wu Liu. I like innovation and source Code manipulation, and I focus on source Code (Vue 3, Vite), front-end engineering, cross-end and other technology learning and sharing. Welcome to follow my wechat official account: Code Center.