【Vue3】源码解析-前置

系列文章

【前端】Typescript入门
【Vue3】源码解析-前置
【Vue3】源码解析-响应式原理
【Vue3】源码解析-虚拟DOM
【Vue3】源码解析-编绎模块
【Vue3】源码解析-Runtime

首先得知道

Proxy

Proxy API对应的Proxy对象是ES6就已引入的一个原生对象,用于定义基本操作的自定义行为(如属性查找、赋值、枚举、函数调用等)。 从字面意思来理解,Proxy对象是目标对象的一个代理器,任何对目标对象的操作(实例化,添加/删除/修改属性等等),都必须通过该代理器。因此我们可以把来自外界的所有操作进行拦截和过滤或者修改等操作。 基于Proxy的这些特性,常用于:

  • 创建一个可“响应式”的对象,例如Vue3.0中的reactive方法。
  • 创建可隔离的JavaScript“沙箱”。

Proxy的基本语法如下代码所示:

const p = new Proxy(target, handler)

中,target参数表示要使用Proxy包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理),handler参数表示以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理p的行为。常见使用方法如下代码所示:

let foo = {
 a: 1,
 b: 2
}
let handler = {
    get:(obj,key)=>{
        console.log('get')
        return key in obj ? obj[key] : undefined
    }
}
let p = new Proxy(foo,handler)
console.log(p.a) // 打印1

上面代码中p就是foo的代理对象,对p对象的相关操作都会同步到foo对象上,同时Proxy也提供了另一种生成代理对象的方法Proxy.revocable(),如下代码所示:

const { proxy,revoke } = Proxy.revocable(target, handler)

该方法的返回值是一个对象,其结构为: {“proxy”: proxy, “revoke”: revoke},其中:proxy表示新生成的代理对象本身,和用一般方式new Proxy(target, handler)创建的代理对象没什么不同,只是它可以被撤销掉,revoke表示撤销方法,调用的时候不需要加任何参数,就可以撤销掉和它一起生成的那个代理对象,如下代码所示:

let foo = {
 a: 1,
 b: 2
}
let handler = {
    get:(obj,key)=>{
        console.log('get')
        return key in obj ? obj[key] : undefined
    }
}
let { proxy,revoke } = Proxy.revocable(foo,handler)
console.log(proxy.a) // 打印1
revoke()
console.log(proxy.a) // 报错信息:Uncaught TypeError: Cannot perform 'get' on a proxy that has been revoked

需要注意的是,一旦某个代理对象被撤销,它将变得几乎完全不可调用,在它身上执行任何的可代理操作都会抛出TypeError异常。 在上面代码中,我们只使用了get操作的handler,即当尝试获取对象的某个属性时会进入这个方法,除此之外Proxy共有接近14个handler也可以称作为钩子,它们分别是:

handler.getPrototypeOf():
在读取代理对象的原型时触发该操作,比如在执行 Object.getPrototypeOf(proxy) 时。

handler.setPrototypeOf():
在设置代理对象的原型时触发该操作,比如在执行 Object.setPrototypeOf(proxy, null) 时。

handler.isExtensible():
在判断一个代理对象是否是可扩展时触发该操作,比如在执行 Object.isExtensible(proxy) 时。

handler.preventExtensions():
在让一个代理对象不可扩展时触发该操作,比如在执行 Object.preventExtensions(proxy) 时。

handler.getOwnPropertyDescriptor():
在获取代理对象某个属性的属性描述时触发该操作,比如在执行 Object.getOwnPropertyDescriptor(proxy, "foo") 时。

handler.defineProperty():
在定义代理对象某个属性时的属性描述时触发该操作,比如在执行 Object.defineProperty(proxy, "foo", {}) 时。

handler.has():
在判断代理对象是否拥有某个属性时触发该操作,比如在执行 "foo" in proxy 时。

handler.get():
在读取代理对象的某个属性时触发该操作,比如在执行 proxy.foo 时。

handler.set():
在给代理对象的某个属性赋值时触发该操作,比如在执行 proxy.foo = 1 时。

handler.deleteProperty():
在删除代理对象的某个属性时触发该操作,即使用 delete 运算符,比如在执行 delete proxy.foo 时。

handler.ownKeys():
当执行Object.getOwnPropertyNames(proxy) 和Object.getOwnPropertySymbols(proxy)时触发。

handler.apply():
当代理对象是一个function函数时,调用apply()方法时触发,比如proxy.apply()。

handler.construct():
当代理对象是一个function函数时,通过new关键字实例化时触发,比如new proxy()

结合这些handler,我们可以实现一些针对对象的限制操作,例如: 禁止删除和修改对象的某个属性,如下代码所示:

let foo = {
    a:1,
    b:2
}
let handler = {
    set:(obj,key,value,receiver)=>{
        console.log('set')
        if (key == 'a') throw new Error('can not change property:'+key)
        obj[key] = value
        return true
    },
    deleteProperty:(obj,key)=>{
        console.log('delete')
        if (key == 'a') throw new Error('can not delete property:'+key)
        delete obj[key]
        return true
    }
}

let p = new Proxy(foo,handler)
// 尝试修改属性a
p.a = 3 // 报错信息:Uncaught Error
// 尝试删除属性a
delete p.a  // 报错信息:Uncaught Error

上面代码中,set方法多了一个receiver参数,这个参数通常是Proxy本身即p,场景是当有一段代码执行obj.name=“jen”,obj不是一个proxy,且自身不含name属性,但是它的原型链上有一个proxy,那么,那个proxy的handler里的set方法会被调用,而此时obj会作为receiver这个参数传进来。 对属性的修改进行校验,如下代码所示:

let foo = {
    a:1,
    b:2
}
let handler = {
    set:(obj,key,value)=>{
        console.log('set')
        if (typeof(value) !== 'number') throw new Error('can not change property:'+key)
        obj[key] = value
        return true
    }
}
let p = new Proxy(foo,handler)
p.a = 'hello' // 报错信息:Uncaught Error

Proxy也能监听到数组变化,如下代码所示:

let arr = [1]
let handler = {
    set:(obj,key,value)=>{
        console.log('set') // 打印set
        return Reflect.set(obj, key, value);
    }
}

let p = new Proxy(arr,handler)
p.push(2) // 改变数组

Reflect.set()用于修改数组的值,返回布尔类型,这也可以兼容修改数组原型上的方法对应场景,相当于obj[key] = value。

Reflect

ES6 Reflect

Symbol

ES6 Symbol

Map和Set

ES6 Set 与 Map 数据结构

WeakMap和WeakSet

WeakSet
WeakMap

diff算法

在vue update过程中在遍历子代vnode的过程中,会用不同的patch方法来patch新老vnode,如果找到对应的 newVnode 和 oldVnode,就可以复用利用里面的真实dom节点。避免了重复创建元素带来的性能开销。毕竟浏览器创造真实的dom,操纵真实的dom,性能代价是昂贵的。

patchChildren

从上文中我们得知了存在children的vnode类型,那么存在children就需要patch每一个
children vnode依次向下遍历。那么就需要一个patchChildren方法,依次patch子类vnode。

vue3.0中 在patchChildren方法中有这么一段源码

if (patchFlag > 0) {
      if (patchFlag & PatchFlags.KEYED_FRAGMENT) { 
         /* 对于存在key的情况用于diff算法 */
        patchKeyedChildren(
          c1 as VNode[],
          c2 as VNodeArrayChildren,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
        return
      } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
         /* 对于不存在key的情况,直接patch  */
        patchUnkeyedChildren( 
          c1 as VNode[],
          c2 as VNodeArrayChildren,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
        return
      }
    }

patchChildren根据是否存在key进行真正的diff或者直接patch。

既然diff算法存在patchChildren方法中,而patchChildren方法用在Fragment类型和element类型的vnode中,这样也就解释了diff算法的作用域是什么。

diff算法具体做了什么(重点)?

在正式讲diff算法之前,在patchChildren的过程中,存在 patchKeyedChildren
patchUnkeyedChildren

patchKeyedChildren 是正式的开启diff的流程,那么patchUnkeyedChildren的作用是什么呢? 我们来看看针对没有key的情况patchUnkeyedChildren会做什么。


 c1 = c1 || EMPTY_ARR
    c2 = c2 || EMPTY_ARR
    const oldLength = c1.length
    const newLength = c2.length
    const commonLength = Math.min(oldLength, newLength)
    let i
    for (i = 0; i < commonLength; i++) { /* 依次遍历新老vnode进行patch */
      const nextChild = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      patch(
        c1[i],
        nextChild,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        optimized
      )
    }
    if (oldLength > newLength) { /* 老vnode 数量大于新的vnode,删除多余的节点 */
      unmountChildren(c1, parentComponent, parentSuspense, true, commonLength)
    } else { /* /* 老vnode 数量小于于新的vnode,创造新的即诶安 */
      mountChildren(
        c2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        optimized,
        commonLength
      )
    }


我们可以得到结论,对于不存在key情况

① 比较新老children的length获取最小值 然后对于公共部分,进行从新patch工作。
② 如果老节点数量大于新的节点数量 ,移除多出来的节点。
③ 如果新的节点数量大于老节点的数量,从新 mountChildren新增的节点。

那么对于存在key情况呢? 会用到diff算法 , diff算法做了什么呢?

patchKeyedChildren方法究竟做了什么?

我们先来看看一些声明的变量。

    /*  c1 老的vnode c2 新的vnode  */
    let i = 0              /* 记录索引 */
    const l2 = c2.length   /* 新vnode的数量 */
    let e1 = c1.length - 1 /* 老vnode 最后一个节点的索引 */
    let e2 = l2 - 1        /* 新节点最后一个节点的索引 */

①第一步从头开始向尾寻找
(a b) c
(a b) d e

 /* 从头对比找到有相同的节点 patch ,发现不同,立即跳出*/
    while (i <= e1 && i <= e2) {
      const n1 = c1[i]
      const n2 = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
        /* 判断key ,type是否相等 */
      if (isSameVNodeType(n1, n2)) {
        patch(
          n1,
          n2,
          container, 
          parentAnchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
      } else {
        break
      }
      i++
    }

第一步的事情就是从头开始寻找相同的vnode,然后进行patch,如果发现不是相同的节点,那么立即跳出循环。

具体流程如图所示
在这里插入图片描述

isSameVNodeType

export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  return n1.type === n2.type && n1.key === n2.key
}

②第二步从尾开始同前diff
a (b c)
d e (b c)

 /* 如果第一步没有patch完,立即,从后往前开始patch ,如果发现不同立即跳出循环 */
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1]
      const n2 = (c2[e2] = optimized
        ? cloneIfMounted(c2[e2] as VNode)
        : normalizeVNode(c2[e2]))
      if (isSameVNodeType(n1, n2)) {
        patch(
          n1,
          n2,
          container,
          parentAnchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
      } else {
        break
      }
      e1--
      e2--
    }

经历第一步操作之后,如果发现没有patch完,那么立即进行第二部,从尾部开始遍历依次向前diff。

如果发现不是相同的节点,那么立即跳出循环。

具体流程如图所示
在这里插入图片描述
③④主要针对新增和删除元素的情况,前提是元素没有发生移动, 如果有元素发生移动就要走⑤逻辑。

③ 如果老节点是否全部patch,新节点没有被patch完,创建新的vnode
(a b)
(a b) c
i = 2, e1 = 1, e2 = 2
(a b)
c (a b)
i = 0, e1 = -1, e2 = 0

/* 如果新的节点大于老的节点数 ,对于剩下的节点全部以新的vnode处理( 这种情况说明已经patch完相同的vnode  ) */
    if (i > e1) {
      if (i <= e2) {
        const nextPos = e2 + 1
        const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
        while (i <= e2) {
          patch( /* 创建新的节点*/
            null,
            (c2[i] = optimized
              ? cloneIfMounted(c2[i] as VNode)
              : normalizeVNode(c2[i])),
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG
          )
          i++
        }
      }
    }


i > e1

如果新的节点大于老的节点数 ,对于剩下的节点全部以新的vnode处理( 这种情况说明已经patch完相同的vnode ),也就是要全部create新的vnode.

具体逻辑如图所示
在这里插入图片描述
④ 如果新节点全部被patch,老节点有剩余,那么卸载所有老节点
i > e2
(a b) c
(a b)
i = 2, e1 = 2, e2 = 1
a (b c)
(b c)
i = 0, e1 = 0, e2 = -1

else if (i > e2) {
   while (i <= e1) {
      unmount(c1[i], parentComponent, parentSuspense, true)
      i++
   }
}

对于老的节点大于新的节点的情况 ,对于超出的节点全部卸载 ( 这种情况说明已经patch完相同的vnode )

具体逻辑如图所示
在这里插入图片描述
⑤ 不确定的元素 ( 这种情况说明没有patch完相同的vnode ),我们可以接着①②的逻辑继续往下看
diff核心

在①②情况下没有遍历完的节点如下图所示。
在这里插入图片描述
剩下的节点。
在这里插入图片描述

      const s1 = i  //第一步遍历到的index
      const s2 = i 
      const keyToNewIndexMap: Map<string | number, number> = new Map()
      /* 把没有比较过的新的vnode节点,通过map保存 */
      for (i = s2; i <= e2; i++) {
        if (nextChild.key != null) {
          keyToNewIndexMap.set(nextChild.key, i)
        }
      }
      let j
      let patched = 0 
      const toBePatched = e2 - s2 + 1 /* 没有经过 path 新的节点的数量 */
      let moved = false /* 证明是否 */
      let maxNewIndexSoFar = 0 
      const newIndexToOldIndexMap = new Array(toBePatched)
       for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
      /* 建立一个数组,每个子元素都是0 [ 0, 0, 0, 0, 0, 0, ] */ 

遍历所有新节点把索引和对应的key,存入map keyToNewIndexMap中

keyToNewIndexMap 存放 key -> index 的map

D : 2
E : 3
C : 4
I : 5

接下来声明一个新的指针 j,记录剩下新的节点的索引。
patched ,记录在第⑤步patched新节点过的数量
toBePatched 记录⑤步之前,没有经过patched 新的节点的数量。
moved代表是否发生过移动,咱们的demo是已经发生过移动的。

newIndexToOldIndexMap 用来存放新节点索引和老节点索引的数组。
newIndexToOldIndexMap 数组的index是新vnode的索引 , value是老vnode的索引。

接下来

 for (i = s1; i <= e1; i++) { /* 开始遍历老节点 */
        const prevChild = c1[i]
        if (patched >= toBePatched) { /* 已经patch数量大于等于, */
          /* ① 如果 toBePatched新的节点数量为0 ,那么统一卸载老的节点 */
          unmount(prevChild, parentComponent, parentSuspense, true)
          continue
        }
        let newIndex
         /* ② 如果,老节点的key存在 ,通过key找到对应的index */
        if (prevChild.key != null) {
          newIndex = keyToNewIndexMap.get(prevChild.key)
        } else { /*  ③ 如果,老节点的key不存在 */
          for (j = s2; j <= e2; j++) { /* 遍历剩下的所有新节点 */
            if (
              newIndexToOldIndexMap[j - s2] === 0 && /* newIndexToOldIndexMap[j - s2] === 0 新节点没有被patch */
              isSameVNodeType(prevChild, c2[j] as VNode)
            ) { /* 如果找到与当前老节点对应的新节点那么 ,将新节点的索引,赋值给newIndex  */
              newIndex = j
              break
            }
          }
        }
        if (newIndex === undefined) { /* ①没有找到与老节点对应的新节点,删除当前节点,卸载所有的节点 */
          unmount(prevChild, parentComponent, parentSuspense, true)
        } else {
          /* ②把老节点的索引,记录在存放新节点的数组中, */
          newIndexToOldIndexMap[newIndex - s2] = i + 1
          if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex
          } else {
            /* 证明有节点已经移动了   */
            moved = true
          }
          /* 找到新的节点进行patch节点 */
          patch(
            prevChild,
            c2[newIndex] as VNode,
            container,
            null,
            parentComponent,
            parentSuspense,
            isSVG,
            optimized
          )
          patched++
        }
 }


这段代码算是diff算法的核心。

第一步: 通过老节点的key找到对应新节点的index:开始遍历老的节点,判断有没有key, 如果存在key通过新节点的keyToNewIndexMap找到与新节点index,如果不存在key那么会遍历剩下来的新节点试图找到对应index。

第二步:如果存在index证明有对应的老节点,那么直接复用老节点进行patch,没有找到与老节点对应的新节点,删除当前老节点。

第三步:newIndexToOldIndexMap找到对应新老节点关系。

到这里,我们patch了一遍,把所有的老vnode都patch了一遍。

如图所示
在这里插入图片描述
但是接下来的问题。

1 虽然已经patch过所有的老节点。可以对于已经发生移动的节点,要怎么真正移动dom元素。
2 对于新增的节点,(图中节点I)并没有处理,应该怎么处理。

      /*移动老节点创建新节点*/
     /* 根据最长稳定序列移动相对应的节点 */
      const increasingNewIndexSequence = moved
        ? getSequence(newIndexToOldIndexMap)
        : EMPTY_ARR
      j = increasingNewIndexSequence.length - 1
      for (i = toBePatched - 1; i >= 0; i--) {
        const nextIndex = s2 + i
        const nextChild = c2[nextIndex] as VNode
        const anchor =
          nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
        if (newIndexToOldIndexMap[i] === 0) { /* 没有老的节点与新的节点对应,则创建一个新的vnode */
          patch(
            null,
            nextChild,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG
          )
        } else if (moved) {
          if (j < 0 || i !== increasingNewIndexSequence[j]) { /*如果没有在长*/
            /* 需要移动的vnode */
            move(nextChild, container, anchor, MoveType.REORDER)
          } else {
            j--
          }    

⑥最长稳定序列
首选通过getSequence得到一个最长稳定序列,对于index === 0 的情况也就是新增节点(图中I) 需要从新mount一个新的vnode,然后对于发生移动的节点进行统一的移动操作

什么叫做最长稳定序列?

对于以下的原始序列
0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15
最长递增子序列为
0, 2, 6, 9, 11, 15.

为什么要得到最长稳定序列?

因为我们需要一个序列作为基础的参照序列,其他未在稳定序列的节点,进行移动。

总结

经过上述我们大致知道了diff算法的流程

  1. 从头对比找到有相同的节点 patch ,发现不同,立即跳出。
  2. 如果第一步没有patch完,立即,从后往前开始patch ,如果发现不同立即跳出循环。
  3. 如果新的节点大于老的节点数 ,对于剩下的节点全部以新的vnode处理( 这种情况说明已经patch完相同的vnode )。
  4. 对于老的节点大于新的节点的情况 , 对于超出的节点全部卸载 ( 这种情况说明已经patch完相同的vnode )。
  5. 不确定的元素( 这种情况说明没有patch完相同的vnode ) 与 3 ,4对立关系。

1 把没有比较过的新的vnode节点,通过map保存
记录已经patch的新节点的数量 patched
没有经过 path 新的节点的数量 toBePatched
建立一个数组newIndexToOldIndexMap,每个子元素都是[ 0, 0, 0, 0, 0, 0, ] 里面的数字记录老节点的索引 ,数组索引就是新节点的索引
开始遍历老节点
① 如果 toBePatched新的节点数量为0 ,那么统一卸载老的节点
② 如果,老节点的key存在 ,通过key找到对应的index
③ 如果,老节点的key不存在
1 遍历剩下的所有新节点
2 如果找到与当前老节点对应的新节点那么 ,将新节点的索引,赋值给newIndex
④ 没有找到与老节点对应的新节点,卸载当前老节点。
⑤ 如果找到与老节点对应的新节点,把老节点的索引,记录在存放新节点的数组中,
1 如果节点发生移动 记录已经移动了
2 patch新老节点 找到新的节点进行patch节点
遍历结束

如果发生移动
① 根据 newIndexToOldIndexMap 新老节点索引列表找到最长稳定序列
② 对于 newIndexToOldIndexMap -item =0 证明不存在老节点 ,从新形成新的vnode
③ 对于发生移动的节点进行移动处理。

在这里插入图片描述

composition-api

Composition API简介:一组基于函数的附加API,能够灵活地组成组件逻辑,Composition API希望将通过当前组件属性、可用的机制公开为JavaScript函数来解决这个问题。Vue核心团队将组件Composition API描述为“一套附加的、基于函数的api,允许灵活地组合组件逻辑”。使用Composition API编写的代码更易读,并且场景不复杂,这使得阅读和学习变得更容易

Options API

在vue2中,我们会在一个vue文件中定义methods,computed,watch,data中等等属性和方法,共同处理页面逻辑,我们称这种方式为Options API
在这里插入图片描述

优缺点

  • 一个功能往往需要在不同的vue配置项中定义属性和方法,比较分散,项目小还好,清晰明了,但是项目大了后,一个methods中可能包含很多个方法,你往往分不清哪个方法对应着哪个功能
  • 条例清晰,相同的放在相同的地方;但随着组件功能的增大,关联性会大大降低,组件的阅读和理解难度会增加;

Composition-API

为了解决在vue2中出现的问题,在vue3 Composition API 中,我们的代码是根据逻辑功能来组织的,一个功能所定义的所有api会放在一起(更加的高内聚,低耦合这样做,即使项目很大,功能很多,我们都能快速的定位到这个功能所用到的所有API
在这里插入图片描述
Composition-API将每个功能模块所定义的所有的API都放在一个模块,这就解决了Vue2中因为模块分散而造成的问题
在这里插入图片描述

  • Composition API 是根据逻辑相关性组织代码的,提高可读性和可维护性
  • 基于函数组合的 API 更好的重用逻辑代码(在vue2 Options API中通过Mixins重用逻辑代码,容易发生命名冲突且关系不清)

API介绍

Setup函数

使用setup 函数时,它将接受两个参数:props,context

props:父组件传递给子组件的数据,context: 包含三个属性attrs, slots, emit
(1)attrs:所有的非prop的attribute;

(2)slots:父组件传递过来的插槽

(3)emit:当我们组件内部需要发出事件时会用到emit

props: {
    message: {
        type: String,
        required: true
        default:'长夜将至'
    }
},
setup(props,context) {
    // Attribute (非响应式对象)
    console.log(context.attrs)
    // 插槽 (非响应式对象)
    console.log(context.slots)
    // 触发事件 (方法)
    console.log(context.emit)
     //因为setup函数中是没有this这个东西的, 然而当我们需要拿到父组件所传递过来的数据时, setup函数的第一个参数props便起作用了
    console.log(this)// undefined
    console.log(props.message);//长夜将至
    return {} // 我们可以通过setup的返回值来替代data选项(但是当setup和data选项同时存在时,使用的是setup中的数据),并且这里返回的任何内容都可以用于组件的其余部分
},

  • setup函数是处于 生命周期函数 beforeCreate 和 Created 两个钩子函数之前的函数
  • 执行 setup 时,组件实例尚未被创建(在 setup() 内部,this 不会是该活跃实例的引用,即不指向vue实例,Vue为了避免我们错误的使用,直接将 setup函数中的this修改成了 undefined)
ref

ref 用于为数据添加响应式状态。由于reactive只能传入对象类型的参数,而对于基本数据类型要添加响应式状态就只能用ref了,同样返回一个具有响应式状态的副本。

<template>
<h1>{{newObj}}</h1>
<button @click="change">点击按钮</button>
 </template>
 
 <script>
import {ref} from 'vue';
export default {
  name:'App',
  setup(){
    let obj = {name : 'alice', age : 12};
    let newObj= ref(obj.name);
    function change(){
      newObj.value = 'Tom';
      console.log(obj,newObj)
    }
    return {newObj,change}
  }
}

在这里插入图片描述

  • ref函数只能监听简单类型的变化,不能监听复杂类型的变化,比如对象和数组
  • ref的本质是拷贝,与原始数据没有引用关系。
  • ref修改响应式数据不会影响原始数据,界面会更新
toRef

toRef 用于为源响应式对象上的属性新建一个ref,从而保持对其源对象属性的响应式连接

<template>
<h1>{{newObj}}</h1>
<button @click="change">点击按钮</button>
 </template>
 
 <script>

import {toRef} from 'vue';
export default {
  name:'App',
  setup(){
    let obj = {name : 'alice', age : 12};
    let newObj= toRef(obj, 'name');
    function change(){
      newObj.value = 'Tom';
      console.log(obj,newObj)
    }
    return {newObj,change}
  }
}

在这里插入图片描述

  • 获取数据值的时候需要加.value
  • toRef后的ref数据不是原始数据的拷贝,而是引用,改变结果数据的值也会同时改变原始数据
  • toRef接收两个参数,第一个参数是哪个对象,第二个参数是对象的哪个属性
  • toRef一次仅能设置一个数据
toRefs

有的时候,我们希望将对象的多个属性都变成响应式数据,并且要求响应式数据和原始数据关联,并且更新响应式数据的时候不更新界面,就可以使用toRefs,用于批量设置多个数据为响应式数据。

<template>
<h1>{{newObj}}</h1>
<button @click="change">点击按钮</button>
 </template>
 
 <script>

import {toRefs} from 'vue';
export default {
  name:'App',
  setup(){
    let obj = {name : 'alice', age : 12};
    let newObj= toRefs(obj);
    function change(){
      newObj.name.value = 'Tom';
      newObj.age.value = 18;
      console.log(obj,newObj)
    }
    return {newObj,change}
  }
}

在这里插入图片描述
从上图可以明显看出,点击按钮之后,原始数据和响应式数据更新,但界面不发生变化,

  • toRefs接收一个对象作为参数,它会遍历对象身上的所有属性,然后挨个调用toRef执行
  • 获取数据值的时候需要加.value
  • toRefs后的ref数据不是原始数据的拷贝,而是引用,改变结果数据的值也会同时改变原始数据
带 ref 的响应式变量

setup()内使用响应式数据时,需要通过.value获取,但从 setup() 中返回的对象上的 property 返回并可以在模板中被访问时,它将自动展开为内部值。不需要在模板中追加 .value

<template>
<h1>{{count}}</h1>
 </template>
 
 <script>
  import { ref } from 'vue' // ref函数使任何变量在任何地方起作用
  export default {
    setup(){
      const count= ref(0)
      console.log(count)
      console.log(count.value) // 0
      return {count } 
    }
  }
 </script>

在这里插入图片描述

概览

目录结构

vue3
 ├── packages        # 所有包(此目录只保持一部分包)
 │   ├── compiler-core           # 编译核心包 
 │   │   ├── api-extractor.json  # 用于合并.d.ts, api-extractor  API Extractor是一个TypeScript分析工具
 │   │   ├── src                 # 包主要开发目录
 │   │   ├── index.js            # 包入口,导出的都是dist目录的文件
 │   │   ├── LICENSE             # 开源协议文件
 │   │   ├── package.json        
 │   │   ├── README.md           # 包描述文件
 │   │   └── __tests__           # 包测试文件
 ├── scripts                      # 一些工程化的脚本,本文重点
 │   ├── bootstrap.js            # 用于生成最小化的子包
 │   ├── build.js                # 用于打包所有packages下的包
 │   ├── checkYarn.js            # 检查是否是yarn进行安装
 │   ├── dev.js                  # 监听模式开发
 │   ├── release.js              # 用于发布版本
 │   ├── setupJestEnv.ts         # 设置Jest的环境
 │   ├── utils.js                # 公用的函数包
 │   └── verifyCommit.js         # git提交验证message
 ├── test-dts                     # 验证类型声明
 │   ├── component.test-d.ts
 |   ├── .....-d.ts
 ├── api-extractor.json          # 用于合并.d.ts
 ├── CHANGELOG.md                # 版本变更日志
 ├── jest.config.js              # jest测试配置
 ├── LICENSE
 ├── package.json
 ├── README.md
 ├── rollup.config.js            # rollup配置
 ├── tsconfig.json               # ts配置
 └── yarn.lock                   # yarn锁定版本文件

其中,Vue 3和核心源码都在packages里面,并且是基于RollUp构建,其中每个目录代表的含义,如下所示:

├── packages              
│   ├── compiler-core    // 核心编译器(平台无关)
│   ├── compiler-dom     // dom编译器
│   ├── compiler-sfc     // vue单文件编译器
│   ├── compiler-ssr     // 服务端渲染编译
│   ├── global.d.ts      // typescript声明文件
│   ├── reactivity       // 响应式模块,可以与任何框架配合使用
│   ├── runtime-core     // 运行时核心实例相关代码(平台无关)
│   ├── runtime-dom      // 运行时dom 关api,属性,事件处理
│   ├── runtime-test     // 运行时测试相关代码
│   ├── server-renderer   // 服务端渲染
│   ├── sfc-playground    // 单文件组件在线调试器
│   ├── shared             // 内部工具库,不对外暴露API
│   ├── size-check          // 简单应用,用来测试代码体积
│   ├── template-explorer  // 用于调试编译器输出的开发工具
│   └── vue                 // 面向公众的完整版本, 包含运行时和编译器
│   └── vue-compat     //针对vue2的兼容版本

通过上面源码结构,可以看到有下面几个模块比较特别:

  • compiler-core
  • compiler-dom
  • runtime-core
  • runtime-dom
    可以看到core, dom 分别出现了两次,那么compiler和runtime它们之间又有什么区别呢?
  • compile:我们可以理解为程序编绎时,是指我们写好的源代码在被编译成为目标文件这段时间,可以通俗的看成是我们写好的源代码在被构建工具转换成为最终可执行的文件这段时间,在这里可以理解为我们将.vue文件编绎成浏览器能识别的.js文件的一些工作。
  • runtime:可以理解为程序运行时,即是程序被编译了之后,在浏览器打开程序并运行它直到程序关闭的这段时间的系列处理。

CreateApp

顾名思义,CreateApp 作为 vue 的启动函数,返回一个应用实例

从一个例子开始

const HelloVueApp = {
  data() {
    return {
      message: 'Hello Vue!'
    }
  }
}

Vue.createApp(HelloVueApp).mount('#hello-vue')

那么 createApp 里面都干了什么呢?我们接着往下看

export const createApp = ((...args) => {
  const app = ensureRenderer().createApp(...args)

  if (__DEV__) {
    injectNativeTagCheck(app)
  }

  const { mount } = app
  app.mount = (containerOrSelector: Element | string): any => {
    const container = normalizeContainer(containerOrSelector)
    if (!container) return
    const component = app._component
    if (!isFunction(component) && !component.render && !component.template) {
      component.template = container.innerHTML
    }
    // clear content before mounting
    container.innerHTML = ''
    const proxy = mount(container)
    container.removeAttribute('v-cloak')
    return proxy
  }

  return app
}) as CreateAppFunction<Element>

我们可以看到重点在于 ensureRenderer ,

const rendererOptions = {
  patchProp,  // 处理 props 属性 
  ...nodeOps // 处理 DOM 节点操作
}

// lazy create the renderer - this makes core renderer logic tree-shakable
// in case the user only imports reactivity utilities from Vue.
let renderer: Renderer | HydrationRenderer

let enabledHydration = false

function ensureRenderer() {
  return renderer || (renderer = createRenderer(rendererOptions))
}

调用 createRenderer

export function createRenderer<
  HostNode = RendererNode,
  HostElement = RendererElement
>(options: RendererOptions<HostNode, HostElement>) {
  return baseCreateRenderer<HostNode, HostElement>(options)
}

调用 baseCreateRenderer, baseCreateRenderer 这个函数简直可以用庞大来形容,vnode diff patch均在这个方法中实现,回头我们再来细看实现,现在我们只需要关心他最后返回的什么

function baseCreateRenderer(
  options: RendererOptions,
  createHydrationFns?: typeof createHydrationFunctions
): any {
  const {
    insert: hostInsert,
    remove: hostRemove,
    patchProp: hostPatchProp,
    createElement: hostCreateElement,
    createText: hostCreateText,
    createComment: hostCreateComment,
    setText: hostSetText,
    setElementText: hostSetElementText,
    parentNode: hostParentNode,
    nextSibling: hostNextSibling,
    setScopeId: hostSetScopeId = NOOP,
    cloneNode: hostCloneNode,
    insertStaticContent: hostInsertStaticContent
  } = options

  // ....此处省略两千行,我们先不管

  return {
    render,
    hydrate,
    createApp: createAppAPI(render, hydrate)
  }
}

从源码中我们看到 baseCreateRenderer 最终返回 render hydrate createApp 3个函数, 但在 createApp 这个函数中我们本质上只需要返回 createApp 这个函数就好,这里返回了3个,说明其它两个会在别处用到,具体哪里能用到,后面我们再回头看

接着将生成的 render 传给 createAppAPI 这个真正的 createApp 方法,hydrate 为可选参数,ssr 的场景下会用到,这边我们也先跳过

看了 baseCreateRenderer 这个函数,再看 createAppAPI 就会觉得太轻松了。。。毕竟不是一个量级的

createAppAPI 首先判断

export function createAppAPI<HostElement>(
  render: RootRenderFunction,
  hydrate?: RootHydrateFunction
): CreateAppFunction<HostElement> {
  return function createApp(rootComponent, rootProps = null) {
    if (rootProps != null && !isObject(rootProps)) {
      __DEV__ && warn(`root props passed to app.mount() must be an object.`)
      rootProps = null
    }

    // 创建默认APP配置
    const context = createAppContext()
    const installedPlugins = new Set()

    let isMounted = false

    const app: App = {
      _component: rootComponent as Component,
      _props: rootProps,
      _container: null,
      _context: context,

      get config() {
        return context.config
      },

      set config(v) {
        if (__DEV__) {
          warn(
            `app.config cannot be replaced. Modify individual options instead.`
          )
        }
      },

      // 都是一些眼熟的方法
      use() {},
      mixin() {},
      component() {},
      directive() {},

      // mount 我们拎出来讲
      mount() {},
      unmount() {},
      // ...
    }

    return app
  }
}

createAppContext 实现

export function createAppContext(): AppContext {
  return {
    config: {
      isNativeTag: NO,
      devtools: true,
      performance: false,
      globalProperties: {},
      optionMergeStrategies: {},
      isCustomElement: NO,
      errorHandler: undefined,
      warnHandler: undefined
    },
    mixins: [],
    components: {},
    directives: {},
    provides: Object.create(null)
  }
}

到这里,整个createApp 流程就结束了

defineComponent

实现方式的 defineComponent 只是返回传递给它的对象。但是,在类型方面,返回的值具有一个合成类型的构造函数,用于手动渲染函数、 TSX 和 IDE 工具支持

import { defineComponent } from 'vue'

const MyComponent = defineComponent({
  data() {
    return { count: 1 }
  },
  methods: {
    increment() {
      this.count++
    }
  }
})

console.log(`MyComponent:${MyComponent}`)

h

h 其实代表的是 hyperscript 。它是 HTML 的一部分,表示的是超文本标记语言,当我们正在处理一个脚本的时候,在虚拟 DOM 节点中去使用它进行替换已成为一种惯例。这个定义同时也被运用到其他的框架文档中

Hyperscript 它本身表示的是 “生成描述 HTML 结构的脚本”

好了,了解了什么是 h,现在我们来看官方对他的一个定义

定义: 返回一个“虚拟节点” ,通常缩写为 VNode: 一个普通对象,其中包含向 Vue
描述它应该在页面上呈现哪种节点的信息,包括对任何子节点的描述。用于手动编写render

语法

// type only
h('div')

// type + props
h('div', {})

// type + omit props + children
// Omit props does NOT support named slots
h('div', []) // array
h('div', 'foo') // text
h('div', h('br')) // vnode
h(Component, () => {}) // default slot

// type + props + children
h('div', {}, []) // array
h('div', {}, 'foo') // text
h('div', {}, h('br')) // vnode
h(Component, {}, () => {}) // default slot
h(Component, {}, {}) // named slots

// named slots without props requires explicit `null` to avoid ambiguity
h(Component, null, {})

举个栗子

const App = {
    render() {
      return Vue.h('h1', {}, 'Hello Vue3js.cn')
    }
}
Vue.createApp(App).mount('#app')

都干了些啥

h 接收三个参数

  • type 元素的类型
  • propsOrChildren 数据对象, 这里主要表示(props, attrs, dom props, class 和 style)
  • children 子节点
export function h(type: any, propsOrChildren?: any, children?: any): VNode {
  if (arguments.length === 2) {
    if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
      // single vnode without props
      if (isVNode(propsOrChildren)) {
        return createVNode(type, null, [propsOrChildren])
      }
      // props without children
      return createVNode(type, propsOrChildren)
    } else {
      // omit props
      return createVNode(type, null, propsOrChildren)
    }
  } else {
    if (isVNode(children)) {
      children = [children]
    }
    return createVNode(type, propsOrChildren, children)
  }
}

_createVNode 做的事情也很简单

function _createVNode(
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
  props: (Data & VNodeProps) | null = null,
  children: unknown = null,
  // 更新标志
  patchFlag: number = 0,
  // 自定义属性
  dynamicProps: string[] | null = null,
  // 是否是动态节点,(v-if v-for)
  isBlockNode = false 
): VNode {
  // type必传参数
  if (!type || type === NULL_DYNAMIC_COMPONENT) {
    if (__DEV__ && !type) {
      warn(`Invalid vnode type when creating vnode: ${type}.`)
    }
    type = Comment
  }

  // Class 类型的type标准化
  // class component normalization.
  if (isFunction(type) && '__vccOpts' in type) {
    type = type.__vccOpts
  }

  // class & style normalization.
  if (props) {
    // props 如果是响应式,clone 一个副本
    if (isProxy(props) || InternalObjectKey in props) {
      props = extend({}, props)
    }
    let { class: klass, style } = props

    // 标准化class, 支持 string , array, object 三种形式
    if (klass && !isString(klass)) {
      props.class = normalizeClass(klass)
    }

    // 标准化style, 支持 array ,object 两种形式 
    if (isObject(style)) {
      // reactive state objects need to be cloned since they are likely to be
      // mutated
      if (isProxy(style) && !isArray(style)) {
        style = extend({}, style)
      }
      props.style = normalizeStyle(style)
    }
  }

  // encode the vnode type information into a bitmap
  const shapeFlag = isString(type)
    ? ShapeFlags.ELEMENT
    : __FEATURE_SUSPENSE__ && isSuspense(type)
      ? ShapeFlags.SUSPENSE
      : isTeleport(type)
        ? ShapeFlags.TELEPORT
        : isObject(type)
          ? ShapeFlags.STATEFUL_COMPONENT
          : isFunction(type)
            ? ShapeFlags.FUNCTIONAL_COMPONENT
            : 0

  if (__DEV__ && shapeFlag & ShapeFlags.STATEFUL_COMPONENT && isProxy(type)) {
    type = toRaw(type)
    warn(
      `Vue received a Component which was made a reactive object. This can ` +
        `lead to unnecessary performance overhead, and should be avoided by ` +
        `marking the component with \`markRaw\` or using \`shallowRef\` ` +
        `instead of \`ref\`.`,
      `\nComponent that was made reactive: `,
      type
    )
  }

  // 构造 VNode 模型
  const vnode: VNode = {
    __v_isVNode: true,
    __v_skip: true,
    type,
    props,
    key: props && normalizeKey(props),
    ref: props && normalizeRef(props),
    scopeId: currentScopeId,
    children: null,
    component: null,
    suspense: null,
    dirs: null,
    transition: null,
    el: null,
    anchor: null,
    target: null,
    targetAnchor: null,
    staticCount: 0,
    shapeFlag,
    patchFlag,
    dynamicProps,
    dynamicChildren: null,
    appContext: null
  }

  normalizeChildren(vnode, children)

  // presence of a patch flag indicates this node needs patching on updates.
  // component nodes also should always be patched, because even if the
  // component doesn't need to update, it needs to persist the instance on to
  // the next vnode so that it can be properly unmounted later.

  // patchFlag 标志存在表示节点需要更新,组件节点一直存在 patchFlag,因为即使不需要更新,它需要将实例持久化到下一个 vnode,以便以后可以正确卸载它
  if (
    shouldTrack > 0 &&
    !isBlockNode &&
    currentBlock &&
    // the EVENTS flag is only for hydration and if it is the only flag, the
    // vnode should not be considered dynamic due to handler caching.
    patchFlag !== PatchFlags.HYDRATE_EVENTS &&
    (patchFlag > 0 ||
      shapeFlag & ShapeFlags.SUSPENSE ||
      shapeFlag & ShapeFlags.TELEPORT ||
      shapeFlag & ShapeFlags.STATEFUL_COMPONENT ||
      shapeFlag & ShapeFlags.FUNCTIONAL_COMPONENT)
  ) {
    // 压入 VNode 栈
    currentBlock.push(vnode)
  }

  return vnode
}

到这里,h 函数已经全部看完了,我们现在知道 h 叫法的由来,其函数内部逻辑只做参数检查,真正的主角是 _createVNode

_createVNode 做的事情有

  1. 标准化 props class
  2. 给 VNode 打上编码标记
  3. 创建 VNode
  4. 标准化子节点

nextTick

定义: 在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM

看完是不是有一堆问号?我们从中找出来产生问号的关键词

  • 下次 DOM 更新循环结束之后?
  • 执行延迟回调?
  • 更新后的 DOM?

我们从上面三个疑问大胆猜想一下

  • vue 更新DOM是有策略的,不是同步更新
  • nextTick 可以接收一个函数做为入参
  • nextTick 后能拿到最新的数据

好了,我们问题都抛出来了,先来看一下如何使用

import { createApp, nextTick } from 'vue'
const app = createApp({
  setup() {
    const message = ref('Hello!')
    const changeMessage = async newMessage => {
      message.value = newMessage
      // 这里获取DOM的value是旧值
      await nextTick()
      // nextTick 后获取DOM的value是更新后的值
      console.log('Now DOM is updated')
    }
  }
})

那么 nextTick 是怎么做到的呢?为了后面的内容更好理解,这里我们得从 js 的执行机制说起

JS执行机制
我们都知道 JS 是单线程语言,即指某一时间内只能干一件事,有的同学可能会问,为什么 JS 不能是多线程呢?多线程就能同一时间内干多件事情了

是否多线程这个取决于语言的用途,一个很简单的例子,如果同一时间,一个添加了 DOM,一个删除了 DOM, 这个时候语言就不知道是该添还是该删了,所以从应用场景来看 JS 只能是单线程

单线程就意味着我们所有的任务都需要排队,后面的任务必须等待前面的任务完成才能执行,如果前面的任务耗时很长,一些从用户角度上不需要等待的任务就会一直等待,这个从体验角度上来讲是不可接受的,所以JS中就出现了异步的概念

概念

  • 同步 在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务
  • 异步 不进入主线程、而进入"任务队列"(task
    queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行

运行机制
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。

(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。

(4)主线程不断重复上面的第三步
在这里插入图片描述
nextTick

现在我们回来vue中的nextTick

实现很简单,完全是基于语言执行机制实现,直接创建一个异步任务,那么nextTick自然就达到在同步任务后执行的目的

 const p = Promise.resolve()
export function nextTick(fn?: () => void): Promise<void> {
  return fn ? p.then(fn) : p
}

看到这里,有的同学可能又会问,前面我们猜想的 DOM 更新也是异步任务,那他们的这个执行顺序如何保证呢?

别急,在源码中nextTick还有几个兄弟函数,我们接着往下看

queueJob and queuePostFlushCb

queueJob 维护job列队,有去重逻辑,保证任务的唯一性,每次调用去执行 queueFlush queuePostFlushCb 维护cb列队,被调用的时候去重,每次调用去执行 queueFlush

const queue: (Job | null)[] = []
export function queueJob(job: Job) {
  // 去重 
  if (!queue.includes(job)) {
    queue.push(job)
    queueFlush()
  }
}

export function queuePostFlushCb(cb: Function | Function[]) {
  if (!isArray(cb)) {
    postFlushCbs.push(cb)
  } else {
    postFlushCbs.push(...cb)
  }
  queueFlush()
}

queueFlush

开启异步任务(nextTick)处理 flushJobs

function queueFlush() {
  // 避免重复调用flushJobs
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true
    nextTick(flushJobs)
  }
}

flushJobs

处理列队,先对列队进行排序,执行queue中的job,处理完后再处理postFlushCbs, 如果队列没有被清空会递归调用flushJobs清空队列

function flushJobs(seen?: CountMap) {
  isFlushPending = false
  isFlushing = true
  let job
  if (__DEV__) {
    seen = seen || new Map()
  }

  // Sort queue before flush.
  // This ensures that:
  // 1. Components are updated from parent to child. (because parent is always
  //    created before the child so its render effect will have smaller
  //    priority number)
  // 2. If a component is unmounted during a parent component's update,
  //    its update can be skipped.
  // Jobs can never be null before flush starts, since they are only invalidated
  // during execution of another flushed job.
  queue.sort((a, b) => getId(a!) - getId(b!))

  while ((job = queue.shift()) !== undefined) {
    if (job === null) {
      continue
    }
    if (__DEV__) {
      checkRecursiveUpdates(seen!, job)
    }
    callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
  }
  flushPostFlushCbs(seen)
  isFlushing = false
  // some postFlushCb queued jobs!
  // keep flushing until it drains.
  if (queue.length || postFlushCbs.length) {
    flushJobs(seen)
  }
}

好了,实现全在上面了,好像还没有解开我们的疑问,我们需要搞清楚 queueJob 及 queuePostFlushCb 是怎么被调用的

//  renderer.ts
function createDevEffectOptions(
  instance: ComponentInternalInstance
): ReactiveEffectOptions {
  return {
    scheduler: queueJob,
    onTrack: instance.rtc ? e => invokeArrayFns(instance.rtc!, e) : void 0,
    onTrigger: instance.rtg ? e => invokeArrayFns(instance.rtg!, e) : void 0
  }
}

// effect.ts
const run = (effect: ReactiveEffect) => {
  ...

  if (effect.options.scheduler) {
    effect.options.scheduler(effect)
  } else {
    effect()
  }
}

看到这里有没有恍然大悟的感觉?原来当响应式对象发生改变后,执行 effect 如果有 scheduler 这个参数,会执行这个 scheduler 函数,并且把 effect 当做参数传入

绕口了,简单点就是 queueJob(effect),嗯,清楚了,这也是数据发生改变后页面不会立即更新的原因

为什么要nextTick
一个例子让大家明白,如果没有 nextTick 更新机制,那么 num 每次更新值都会触发视图更新,有了nextTick机制,只需要更新一次,所以为什么有nextTick存在,相信大家心里已经有答案了。

{{num}}
for(let i=0; i<100000; i++){
	num = i
}

总结

nextTick 是 vue 中的更新策略,也是性能优化手段,基于JS执行机制实现

vue 中我们改变数据时不会立即触发视图,如果需要实时获取到最新的DOM,这个时候可以手动调用 nextTick

来源

Vue3源码解析–目录结构
Vue 3源码解析–响应式原理
Vue3源码解析–虚拟DOM
Vue3 源码解析(一)—— 包管理
vue3.0 diff算法详解(超详细)
Composition-API
Vue3源码

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

软泡芙

给爷鞠躬!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值