15
头图

Preface

Recently, Vue3 proposed an Ref Sugar, which is ref syntactic sugar. Currently, it is still processing experimental (Experimental) stage. In the Motivation of the RFC, Evan You introduced that after the introduction of the Composition API, a major unresolved problem is the use of the refs and reactive .value may be troublesome to use 061414caba7dbf everywhere. If you don’t use the type system, you can easily miss it:

let count = ref(1)

function add() {
    count.value++
}

Therefore, some users will prefer to use reactive only, so that they do not have to deal with the refs of .value using 061414caba7e18. ref syntactic sugar is to allow us to directly obtain and change the variable itself when ref .value to obtain and change the corresponding value. Simply put, standing use level , we can say goodbye to use refs when .value question:

let count = $ref(1)

function add() {
    count++
}

So, ref syntactic sugar be used in the project? How is it achieved? This is the first question I saw about the establishment of this RFC. I believe this is also a question held by many students. So, let us reveal one by one below.

1 The use of Ref syntactic sugar in the project

Since ref syntactic sugar is currently in the experimental (Experimental) stage, ref syntactic sugar will not be supported by default in Vue3. So, here we take the use of Vite + Vue3 project development as an example to see how to enable support for ref syntactic sugar.

When using the Vite + Vue3 project development, the @vitejs/plugin-vue plug-in is used to implement the code conversion (Transform) and hot update (HMR) .vue Therefore, we need to vite.config.js in @vitejs/plugin-vue the options of the refTransform: true :

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

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

Then, in this way, the @vitejs/plugin-vue plug-in will judge whether it is necessary to perform a specific code conversion ref refTransform Because, here we set true , obviously it will perform a specific code conversion ref

Then, we can use the ref .vue file. Here we look at a simple example:

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

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

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

Correspondingly rendered on the page:

As you can see, we can use the ref to create responsive variables, instead of thinking about adding .value when using it. In addition, the ref syntax sugar also supports other writing $ref method introduced here. If you are interested, you can go to the RFC to learn about other writing methods.

Then, after understanding the use of ref syntactic sugar in the project, we can be regarded as answering the first question (how to use it in the project). Next, let's answer the second question, how is it implemented, that is, what processing is done in the source code?

2 Implementation of Ref syntax sugar

First, we through Vue Playground intuitively feel, previously used ref example of the syntax of sugars <script setup> block (Block) in the compiled results:

import { ref as _ref } from 'vue'

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

  function add() {
    count.value++
  }
}

It can be seen that although we do not need to deal with .value ref is still used .value after being compiled. So, this process is certainly not inevitable to do a lot of compilation related code conversion processing. Because we need to find the declaration statement and variables that $ref _ref , and add .value to the latter.

In the previous section, we also mentioned that the @vitejs/plugin-vue plug-in will .vue code of the 061414caba802e file. This process uses the @vue/compiler-sfc package provided by Vue3, which provides compilation related blocks of <script> , <template> , <style> function.

Well, obviously we need to focus on here is <script> block function is compiled relevant, which corresponds to @vue/compiler-sfc in compileScript() function.

2.1 compileScript() function

compileScript() function is defined in vue-next of packages/compiler-sfc/src/compileScript.ts file, which is mainly responsible for <script> or <script setup> compile processing block content, it receives two parameters:

  • sfc contains the .vue content of the code of the 061414caba80cb file, including attributes such as script , scriptSetup , source
  • options contains some optional and required attributes. For example, the scopeId corresponding to the component will be used as options.id , the aforementioned refTransform etc.

compileScript() function definition (pseudo code):

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

For ref syntactic sugar, the compileScript() function will first get refTransform in the option (Option) and assign it to enableRefTransform :

const enableRefTransform = !!options.refTransform

enableRefTransform will be used to determine whether to call the conversion function related to the syntactic sugar of ref So, we also mentioned earlier to use ref syntactic sugar, you need to give @vite/plugin-vue plug-in options refTransform property to true , it will be passed compileScript() function options , is here options.refTransform .

Then, from sfc , scriptSetup , source , filename and other attributes will be deconstructed. Among them, the code string source source file will be used to create a MagicString instance s , which is mainly used for the subsequent code conversion to replace and add the source code string , and then call the parse() function to parse the <script setup> The content is scriptSetup.content , which generates the corresponding abstract syntax tree scriptSetupAst :

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
)

The parse() function internally uses the @babel/parser method provided by parser to analyze the code and generate the corresponding 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'
}
Note that the content body , start , end is omitted here

Then, according to the previously defined enableRefTransform and the return value of the shouldTransformRef() true or false ), it is judged whether to perform the code conversion of the ref If the corresponding conversion is required, the transformRefAST() function will be called to perform the corresponding code conversion operation according to the AST:

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

In the previous, we have introduced enableRefTransform . Here we take a look at the shouldTransformRef() function, which mainly uses the regular matching code content scriptSetup.content to determine whether the ref syntactic 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)
}

Therefore, when you specify refTransform as true , but you do not actually use the ref syntactic sugar in your code, during the compilation of <script> or <script setup> , will not perform and ref syntactic sugar, which is also Vue3 considers more detailed aspects and avoids the performance overhead caused by unnecessary code conversion operations.

So, for our example (using ref syntactic sugar), it will hit the above transformRefAST() function. And transformRefAST() function is the corresponding packages/ref-transform/src/refTransform.ts the transformAST() function.

So, let's take a look at how the transformAST() function converts the code related ref according to the AST.

2.2 transformAST() function

In the transformAST() function, it mainly traverses the AST corresponding to the incoming source code, and then performs a specific conversion on the source code MagicString instance s $ref to _ref , adding .value etc.

transformAST() function definition (pseudo code):

// 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]
  }
}

It can be seen that transformAST() will first call walkScope() to process the root scope ( root scope ), and then call the walk() function to process the AST node layer by layer, and the walk() estree-walker written by Rich Haris.

Next, let's take a look at what the walkScope() and walk() functions do.

walkScope() function

First, let's look at where previously used ref syntactic sugar declaration let count = $ref(1) corresponding AST structure:

You can see let the AST node type type will be VariableDeclaration , and the AST nodes corresponding to the rest of the code part will be placed in declarations . Wherein the variables count the AST node is used as declarations.id , and $ref(1) the AST node is used as declarations.init .

Then, back walkScope() function, which depending on the type of node AST type for specific processing, for our example let corresponding AST node type is VariableDeclaration will hit this kind of 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
          )
        }
      }
    }
  }
}

Here stmt is the AST node corresponding to let stmt.declarations , where decl.init.callee.name refers to $ref , and then calls the isToVarCall() function and assigns it to toVarCall .

isToVarCall() function definition:

// 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
}

In front of us also mention ref syntactic sugar can support other wording, because we are using the $ref way, so there will be a hit callee[0] === TO_VAR_SYMBOL && shorthands.includes(callee.slice(1)) logic, that toVarCall will be assigned $ref .

Then, it calls processRefDeclaration() function, which according to the incoming decl.init location information provided to source code corresponding MagicString example s operation, i.e. $ref rewritten as 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))
    )
  } 
  // ...
}
Refers to the location information of the position of the node in the AST source code, typically using start , end represents, for example, where let count = $ref(1) , then count AST node corresponding start be. 4, end be 9.

id passed in at this time corresponds to count , it will look like this:

{
  type: "Identifier",
  start: 4,
  end: 9,
  name: "count"
}

So, this will hit the logic id.type === 'Identifier' First of all, we will call registerRefBinding() function, it is actually called is registerBinding() , and registerBinding will current scope currentScope bind the variables id.name and set true , it indicates that this is a use ref variable syntactic sugar created, This will be used to subsequently 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
    )
  }
}

It can be seen that in registerBinding() , the AST node will be excludedIds excludeIds is a WeekMap , which will be used to skip the AST node of type Identifier ref

Then, the s.overwrite() function will be called to rewrite $ref _ref , it will receive 3 parameters, which are the starting position, ending position and the string to be rewritten to. And call corresponds to $ref(1) , it will look like this:

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

And I think we should note that in the calculation of the starting position when rewriting uses offset , it represents a string operation at this time in the source string offset , for example, the string of characters in the source The beginning of the string, then the offset will be 0 .

And helper() function will return a string _ref , and in the process will ref added to importedHelpers , which will compileScript() for generating a corresponding time import statement:

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

So, here we have completed the $ref to _ref , that is, at this time our code will look like this:

let count = _ref(1)

function add() {
    count++
}

Then, it walk() function 061414caba88ae to convert count++ to count.value++ . Next, let's take a look at the walk() function.

walk() function

Earlier, we mentioned that the walk() estree-walker written by Rich Haris, which is an AST package (Package) ESTree

walk() function will be used like this:

import { walk } from 'estree-walker'

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

It can be seen that walk() can be passed in the function options , among which enter() will be called every time the AST node is visited, and leave() is called when leaving the AST node.

So, going back to the example mentioned earlier, the walk() function mainly does two things:

1. Maintain scopeStack, parentStack and currentScope

scopeStack used to store the scope chain where the AST node is located at this time. Initially, the top of the stack is the root scope rootScope ; parentStack used to store the ancestor AST node in the process of traversing the AST node (the AST node at the top of the stack is the current AST node Father AST node); currentScope points to the current scope, which is equal to the root scope rootScope initial case:

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

Therefore, at enter() , it will be judged whether the AST node type is a function or block at this time. If yes, then pushed into the stack scopeStack :

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

Then, at leave() , judge whether the AST node type is a function or block at this time. If yes, then pops scopeStack , and updates currentScope to the stack top element scopeStack

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

2. Process AST nodes of Identifier type

Since, in our example ref create syntactic sugar count variable AST node type is Identifier , so it will enter() hit this logic stages:

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
      }
    }
  }

In if judgment, for excludedIds we have already introduced, and isReferencedIdentifier() is through parenStack to determine the current type Identifier AST node node whether it is a reference to a certain AST node before that.

Then, by accessing scopeStack to follow the scope chain to determine whether there is a id.name (variable name count ) attribute in a scope and the attribute value is true , which means it is a variable created with the syntax sugar of ref .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
}

Concluding remarks

By understanding ref syntactic sugar, I think everyone should have a different understanding of the term syntactic sugar. Its essence is to operate specific code conversion operations by traversing the AST during the compilation phase. In addition, the use of some packages of this implementation process is also very clever, such as MagicString manipulate source code strings, estree-walker traverse AST nodes and scope related processing.

Finally, if there are improper or wrong expressions in the text, you are welcome to mention Issue~

like

After reading this article, if you have any gains, you can , this will become my motivation to continue to share, thank you~

I’m Wuliu, I like to innovate and tinker with the source code, focusing on the source code (Vue3, Vite), front-end engineering, cross-end technology learning and sharing. In addition, all my articles will be included in https://github.com/WJCHumble/Blog , welcome to Watch Or Star!

五柳
1.1k 声望1.4k 粉丝

你好,我是五柳,希望能带给大家一些别样的知识和生活感悟,春华秋实,年年长茂。