IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> JavaScript知识库 -> Vue3.X中 ref属性剖析(一看就懂) -> 正文阅读

[JavaScript知识库]Vue3.X中 ref属性剖析(一看就懂)

Vue3中的setup有什么优缺点?

诱因

首先 setup 函数有冗长返回语句的问题已经在

至于我们为什么要用 setup,我想既然同学们要了解 Vue 3,应该对这个大版本要做和已经的事情有一个更全面的认知:

Vue 3 的任务是:补短板 + 提上限

短板何在?

在今天我们看到的大部分较大的国内互联网公司所提供的 Web 产品中,使用的构建框架都是 React,我自己是字节,而我惊奇的发现 VueConf 2021 上这么多位分享的大佬竟是出自我们这个用 React 更多的公司,有同学玩笑戏称“ 字节把懂 Vue 的都抓去写 React 了 ”。

而我之前在腾讯微信支付数据中心实习过一小段时间,那里有一些内部平台系统为了开发快速简单,选择了容易上手的 Vue,但普遍项目量级都还不算特别大。

一个令人好奇的,与此相关的问题出现了:为什么大公司不敢用 Vue ?

据我自己的体验来看,可以分为以下两点,这应该就是 Vue 过去的短板:

  1. 2.x 版本对 TypeScript 的支持是硬伤,而 TypeScript 对大型项目的保障能力是被普遍认可的,看到 Vue 没法支持,在选择技术栈时很容易放弃它。
  2. 一旦项目体量变大,Vue 的代码会变得更难以维护,真正在实践中 Options API 虽然在组件层面上,每个内容的职责都很清晰,这是 data,那是 method,但是从跨组件的角度来看就没那么好了,因此 Vue 2 缺少一种真正更好的抽象逻辑的办法,而非将代码搬到独立的 .js 文件里来缩减 SFC 代码行数。

解决这两个问题的思路也很明确,尤大也在很多视频演讲中提到了:

  1. 函数是对类型最友好的,输入、输出的类型都是确定的,易推导的。若要这样做,显然是用 TypeScript 重写一遍 Vue 更好。
  2. 虽然我们的页面内容被划分成了一个个的组件,但是我们思考的逻辑却不应该僵死地被他们框住,不需要在每个组件中罗列他们本身的职责,而是让组件去适配、去载入我们可多处复用的逻辑。Vue 需要为开发者提供一套新的框架内 API,使得程序员们能创造出更容易复用的 “ 业务 API ”。若要这样做,显然需要一套新的函数式响应式 API,这些东西将成为我们书写业务逻辑的 “原语”

这些更新内容都在 Vue 3 中完成了,通过一些社区中新的插件对 TypeScript 集成,我们还获得了更好的 DX 体验。

总结

所以请题主莫要纠结于 setup 本身,因为把所有逻辑不加抽象、不加简化地都写在它其中,本就是不对的。setup 只是为我上面说的 组件载入逻辑 提供了一个入口,而你不应该把所有东西都摆在门口。

Options API 对一些小的,功能简单纯粹且独特的组件还是能用上的。另外如果你喜欢它,Vue 也没有删掉对其的支持。

Vue3 Ref 语法糖,告别 .value 的写法

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.js
import { 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>

对应渲染到页面上:

img

可以看到,我们可以使用 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.ts
export 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 } = sfc
const s = new MagicString(source)
const startOffset = scriptSetup.loc.start.offset
const 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.ts
const 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.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]
  }
}

可以看到 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.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 节点和作用域相关处理等。

操作 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 节点和作用域相关处理等。

  JavaScript知识库 最新文章
ES6的相关知识点
react 函数式组件 & react其他一些总结
Vue基础超详细
前端JS也可以连点成线(Vue中运用 AntVG6)
Vue事件处理的基本使用
Vue后台项目的记录 (一)
前后端分离vue跨域,devServer配置proxy代理
TypeScript
初识vuex
vue项目安装包指令收集
上一篇文章      下一篇文章      查看所有文章
加:2021-11-29 16:13:51  更:2021-11-29 16:15:49 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/24 7:50:36-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码