Vue2的响应式原理

--------Vue2响应式原理----------

原理:通过数据劫持 defineProperty + 发布订阅者模式,当 vue 实例初始化后 observer 会针对实例中的 data 中的每一个属性进行劫持并通过 defineProperty() 设置值后在 get() 中向发布者添加该属性的订阅者,这里在编译模板时就会初始化每一属性的 watcher,在数据发生更新后调用 set 时会通知发布者 notify 通知对应的订阅者做出数据更新,同时将新的数据根性到视图上显示。
缺陷:只能够监听初始化实例中的 data 数据,动态添加值不能响应,要使用对应的 Vue.set()。

1.第一步:对数据做数据劫持:

1.1对象类型做数据劫持

使用 Object.defineProperty 方法添加对象,重写了原有的 get 和 set 方法,这就是数据劫持。
defineRective 文件通过封装 Object.defineProperty 方法,把浅层次的对象中的某个数据变成具有 get 和 set 方法的属性。因为有闭包的存在,所以不需要临时变量进行周转了。
接下来是深层次:用递归侦测对象的全部属性

八个 JS 文件互相调用(文件代码,放在文章下方):
index.js:入口文件
observe.js:用于判断某一属性是否为对象或者数组,因为 typeof(array) 返回的也是 object,算是一个局限性。普通数据就直接 return,对象(数组)就给它调用 new Observer
Observer.js:对传入的属性做类型判断,然后分别转化为可被监测的属性。
def.js:为属性添加 ob 属性,做标记,而且可以通过 value.ob 来访问 Observer 的实例
defineRective.js:给传入的属性做数据劫持(即添加 set/get 方法),因为是对属性进行操作的,不做类型判断,因此不论这个传入过来的属性是数组还是对象,都会有 get/set 方法。
array.js:该文件将 JS 中能改变数组的 7 个方法重写,并在进行数据劫持的时候将,数组的原型指向该文件加工后的新原型。
Dep.js:在依赖收集阶段,Dep 对象是 Watcher 对象和 Observer 对象之间纽带,每一个 Observer 都有一个 Dep 实例,用来存储订阅者 Watcher
Watcher.js:当解析到模板字符串 {{ }} 时,会默认去 new Watcher 实例。

过程:

  1. 首先将需要做监听的对象传入 observe 方法内,如果传进去的不是对象(第一次传入的数据毫无疑问是对象,但是后续的子属性还会再次调用 observe 函数,子属性的类型就很复杂了,因此需要有这层判断),就会直接return。如果是对象(或者数组因为 typeof(array) 返回的也是 object),就往下走。
  2. 此时已经确定了,传入的是个对象(数组),紧接着会判断这个对象(数组)有没有 ob 属性,有则代表已做过监视了,如果没有,就用它 new 一个 Observer 实例。
  3. 在new Observer 实例的过程中,会调用 def 方法给该实例添加一个 ob 属性(做个标记)。然后如果是对象则调用 walk 方法,walk 会遍历该对象中的每一项并用 defineReactive 方法加工。如果是数组则修改它的原型(里边有重写好的 API)为 arrayMethods,随即调用 observeArray 方法。
  4. defineReactive 方法用于对传入的属性做数据劫持(重写 get/set 方法)。因为是对属性进行操作的,因此即使传入的是数组,它也一定有 get/set 方法。
  5. 在 defineReactive 做数据劫持前,仍需再调用一次 observe 方法,去判断当前属性是否还是一个对象,如果是,就会再重复 1-3 的过程。此时这几个文件就形成了递归,直到某一次传入的属性不再是一个对象时,结束递归。当递归结束时,这个对象内的所有属性就都做好了数据劫持。
  6. 其中在 defineReactive 中还需要在 set 方法中将获取到的新值再一次使用 observe 方法,变成可监视的,因为这个新值也有可能是对象(或数组),如果不是,那么就会在 observe.js 文件中中直接 return 了。

图解:
在这里插入图片描述

几个文件的调用关系:
在这里插入图片描述

1.2.数组类型做数据劫持:

思路:

  1. 在 array.js 文件中以 Array.prototype 为原型,复制出一个新原型 arrayMethods,再把这个新原型上的 7 个可以改变数组的 API 方法全部重写,分别是:push、pop、shift、unshift、splice、sort、reverse。
  2. 这 7 个方法在保留原功能的基础上增加一些数据劫持的代码(也就是将数据变为可监控的),最后把 arrayMethods 暴露出去。
  3. 当在 new Observer 时判断传入的数据为数组后,使用 Object.setPrototypeOf() 方法强制让传入的那个数组的原型指向 arrayMthods,这样一来,这个数组以后就会使用我们重写好的 API,就达成响应式的目的。
  4. 随后要再一次对数组内的数据进行遍历,因为数组内部,或许还会有对象类型(或者数组类型)的数据。这就相当于用一个拦截器覆盖 Array.prototype,每当使用 Array 原型上的方法操作数组时,先执行 arrayMthods 拦截器中的数据劫持方法,再执行原生 Array 的方法去操作数组。

和上述对象的文件嵌套相比,增加了一个 array.js 文件。

2.第二步:依赖收集

需要用到数据的地方,称为依赖

Vue1.x, 细粒度依赖, 用到数据的 D0M 都是依赖;
Vue2.x, 中等粒度依赖, 用到数据的 组件 是依赖;

之所以要劫持数据,目的是当数据的属性发生变化时,可以通知那些曾经用到的该数据的地方。所以要先收集依赖,把用到这个数据的地方收集起来,等属性改变后,在之前收集好的依赖中循环触发一遍就好了,达到响应式的目的。
针对不同的类型:

getter() 中收集依赖,在 setter() 中触发依赖	// 对象类型getter() 中收集依赖,在 拦截器 中触发依赖		// 数组类型

2.1前提:

此时已经进行过数据劫持了。
把 new Watcher 这个过程看作是 Vue 解析到了 {{ }} 模板的时候。
Dep.target 的值存在时,表示正处于依赖收集阶段。
Vue 在模板编译过程中遇到的指令和数据绑定都会生成 Watcher 实例,实例中的 Watch 属性也会成生成 Watcher 实例。

2.2过程:

  1. 在创建 Observer 实例的同时还会创建 Dep 实例,用于保存依赖项。因此每个数据都有 Observer 的实例,每个 Observer 实例中又都有一个 Dep 的实例。
  2. 当 Vue 解析到 {{ }} 中数据时,就会去创建 Watcher 实例,在 constructor 时会调用自身的 get 方法,该方法不仅将当前的 Watcher 实例赋值给了 Dep.target(表示此时处于依赖收集阶段),还让这个新实例去读取一下 {{ }} 中的数据,一旦读取,就会触发这个数据的 getter 方法。因为此时正在进行收集依赖,Dep.target 一定是为 true 的,于是顺利地把当前的这个 Watcher 实例记录到了 dep 中的 subs 数组里。再然后将 Dep.target 的值重新赋值为 null,表示退出依赖收集阶段。
  3. 为什么能记录到 subs 数组呢?因为在 defineReactive 文件的 17 行新 new 了一个 Dep 实例,这个实例只是一个工具人,通过调用工具人身上的 depend 函数,就将当前时刻的 Watcher 实例添加进去。这样一来当模板解析完毕,dep 实例就掌握这个数据的所有订阅者。
  4. 当数据的 set 方法被调用时,就执工具人的 dep.notify 方法,他会遍历 dep 实例身上的 subs 数组,这个数组存放了当前数据的所有订阅者,即许多 Watcher 实例,调用每一个 Watcher 实例身上的 update 方法,执行传入过来的回调函数,然后 Vue 接下来通过这个回调函数去进行 diff 算法,对比新旧模板,然后重新渲染页面,至此算是达到了响应式的目的。

因此,这个 Watcher 实际上是 Vue 的主程序在用。更新视图的代码应该是要写在传入过去的回调函数里。

3.最后总结:

3.1前置知识:

首先要了解三个最重要的对象:
Observer 对象:将 Vue 中的数据对象在初始化过程中转换为 Observer 对象。
Watcher 对象:将模板和 Observer 对象结合在一起生成 Watcher 实例,Watcher 是订阅者中的订阅者。
Dep对象:Watcher 对象和 Observer 对象之间纽带,每一个 Observe r都有一个 Dep 实例,用来存储订阅者 Watcher。

3.2过程:

  1. 在生命周期的 initState 方法中将 data,prop,method,computed,watch等所有数据全部进行数据劫持,将所有数据变为 Observer 实例,并且每个数据身上还有 Dep 实例。
  2. 然后在 initRender 方法中也就是模板编译过程,遇到的指令和数据绑定都会生成 Watcher 实例,并且把这个实例存入对应数据的 Dep 实例中的 subs 数组里。这样每一个数据的 Dep 实例里就都存放了依赖关系。
  3. 当数据变化时,数据的 setter 方法被调用,触发 dep.notify 方法,就会通知 Dep 实例依赖列表,执行 update 方法通知 Watcher,Watcher 会执行 run 方法去更新视图。
  4. 更新视图的过程,我猜是 Vue 接下来要进行 diff 算法,对比新旧模板,然后重新渲染页面。

Vue 是无法检测到对象属性的添加和删除,但是可以使用全局 Vue.set 方法(或 vm.$set 实例方法)。
Vue 无法检测利用索引设置数组,但是可以使用全局 Vue.set方法(或 vm.$set 实例方法)。
无法检测直接修改数组长度,但是可以使用 splice。

代码文件:

1.index.js:入口文件

import observe from './observe';
import Watcher from './Watcher'
// 因为 Vue 会把所有的数据都存放在 data 对象中,所以一切数据的最外层都是一个对象
let obj = {
  a: {
    m: {
      n: 5
    }
  },
  c: {
    d: {
      e: {
        f: 6666
      }
    }
  },
  g: [22, 33, 44, 55]
}
observe(obj)

// new Watcher 的过程,看作 Vue 在解析到了 {{}} 的时候, new 一次,subs 数组里就多一个数据,表示对有三处模板(可以看到每一个 watcher 的id 是不同的)用到了 a.m.n 属性
// 因此当第二次修改数据时,有两个 watcher 实例在监视它,就会输出两次值,就代表这需要重新渲染两次
// 同理第三次修改,三个 watcher 实例,渲染3次
new Watcher(obj, 'a.m.n', (val,oldValue) => {
  console.log('#######', val,oldValue);
})
console.log(obj);

// 2s后修改值
setTimeout(() => {
  obj.a.m.n = 88
}, 2000);

2.observe.js

用于判断某一属性是否为对象或者数组,因为 typeof(array) 返回的也是 object,算是一个局限性。普通数据就直接 return,对象(数组)就给它调用 new Observer

import Observer from "./Observer";

export default function (value){
    // 如果 value 不是对象或者数组,就直接返回。此处因为 typeof 的局限性,typeof(数组) 仍会返回 object
    // 因为 Vue 中不会单独存放 int、float 等类型的数据,毕竟它们没法调用 Object.defineProperty
    // 况且在 Vue 中数据都是存放在对象中的,所以根本不考虑其他数据类型
    if(typeof (value) !== 'object')  return;
    // 定义ob,存储 observe 实例
    var ob;
    if(typeof value.__ob__ !== 'undefined') {
        ob = value.__ob__;
    } else {
        ob = new Observer(value)
    }
    return ob;
}

3.Observer.js

对传入的属性做类型判断,然后分别转化为可被监测的属性。

import { def } from './def'
import defineRective from './defineReactive'
import { arrayMethods } from './array'
import observe from './observe'
import Dep from './Dep'

/**
 * 该类的作用:将一个正常的 object 的每个层级的属性都转化为可以被侦测的属性
 */

export default class Observer {
    constructor(value) {

        this.dep = new Dep();
        // 构造函数的this不是类本身,而是表示实例。
        // 添加 __ob__ 属性,值是这次 new 的 Observer 的实例,不可枚举
        // _ob__的作用可以用来标记当前value是否已经被Observer转换成了响应式数据了;而且可以通过value.__ob__来访问Observer的实例
        console.log('我是 Observer 构造器,接下来要用 def 方法去给传入的对象值添加 __ob__ 属性', value)
        def(value, '__ob__', this, false)

        // 检查这个数据是数组还是对象
        if (Array.isArray(value)) {
            console.log('传入的是数组,我将改变它的原型为 arrayMethods ')
            // 是数组,就让他的原型指向 arrayMethods。到此,数组的监控已经加工完毕
            Object.setPrototypeOf(value, arrayMethods)
            // 随后再遍历这个数组,因为数组内部,或许还会有对象类型的数据,肯定也要变为可监控的
            this.observeArray(value)

        } else {
            console.log('def 方法执行完毕,接下来要用 walk 方法去遍历这个对象', value)
            // 让对象数据变为可监控的
            this.walk(value)
        }

    }
    // 对象的特殊遍历
    walk(value) {
        for (let key in value) {
            console.log(`我是 walk 方法,这次遍历了对象中的 ${key} 属性,并用 defineReactive 方法给它加工一下 `);
            defineRective(value, key)
        }
    }
    // 数组的特殊遍历
    observeArray(arr) {
        // 逐项进行 observe,因为数组内部,或许还会有对象类型的数据,肯定也要变为可监控的
        for (let i = 0, l = arr.length; i < l; i++) {
            // console.log(arr[i]);
            observe(arr[i])
        }
    }
}

4.def.js

为属性添加 ob 属性,做标记,而且可以通过 value.ob 来访问 Observer 的实例

// 对传入过来的数据添加指定的属性
/**
 * 定义一个对象属性
 * @param {*} obj 
 * @param {*} key 
 * @param {*} value 
 * @param {*} enumerable 
 */
export const def = function (obj, key, value, enumerable){
    Object.defineProperty(obj, key,{
        value,
        enumerable,
        writable: true,
        configurable: true
    })
}

5.defineRective.js

给传入的属性做数据劫持(即添加 set/get 方法),因为是对属性进行操作的,不做类型判断,因此不论这个传入过来的属性是数组还是对象,都会有 get/set 方法。

// 该文件将传入过来的属性加工成具有 get 和 set 方法的响应式数据 
// 因为是对属性进行操作的,不做类型判断,因此不论这个传入过来的属性是数组还是对象,都会有 get/set 方法

/**
 * 给对象data的属性key定义监听
 * @param {*} data 传入的数据
 * @param {*} key 监听的属性
 * @param {*} val 闭包环境提供的周转变量
 */

import observe from "./observe"
import Dep from "./Dep"

export default function defineRective(data, key, val) {
    // 这里 new Dep 实际上是个工具人,只起到一个调用 depend 函数的作用,他不保存在任何数据身上。
    // 调用 depend 函数时,是将代码正在运行的那个时刻的 Watcher 实例添加进去,因此不会影响。
    const dep = new Dep()

    // 如果传入两个参数,则直接取出值给 val
    if (arguments.length == 2) {
        val = data[key]
    }

    // 对传过去的每一项还要 observe 一下,如果不是对象(数组)了就会直接 return,代码往下走。
    // 如果仍是对象(数组),那么就形成了递归,直到不是某一层不是对象(数组)为止。这个递归比较特殊不是函数自己调用自己,而是多个函数循环调用
    let childOb = observe(val)      // 这里接收的是子代属性创建的 Observer 的实例对象,用于后续做依赖收集

    // val 构成了闭包:后续代码有调用到 val 的地方,因此 val 不会消失。
    Object.defineProperty(data, key, {
        // 可枚举
        enumerable: true,
        // 可以被配置,比如可以被delete
        configurable: true,
        // getter 触发这个方法,就会将数据添加到依赖中
        get() {
            console.log(`访问了 obj 的 ${key} 属性,值为${val}`)
            // 如果现在处于依赖收集阶段,即在模板解析的时候,就会调用 setter 方法,就会往 subs 里添加东西 
            if(Dep.target){
                console.log('访问了watcher');
                // console.log(data.__ob__.dep.depend);
                // debugger;
                // 将此时的 Watcher 实例对象添加 dep 中的 subs 数组里
                // 在这里为什么要重新 new Dep
                dep.depend()

                // 这里为什么执行不了 depend 函数???
                // 既然17行 new的实例只是工具人,起到调用 depend 函数的作用,那我这里随便访问一个 dep 实例并调用他身上的 depend 不可以吗?
                // data.__ob__.dep.depend()

                // 给子元素也添加依赖
                if(childOb){
                    childOb.dep.depend();
                }
            }
            return val
        },
        // setter 
        set(newValue) {
            console.log(`改变了 obj 的 ${key} 属性,新的值为${newValue}`)
            if (val === newValue) {
                return
            }
            val = newValue
            // 当设置了新值,这个新值可能也包含对象或者数组,因此也要被 observe 
            childOb = observe(newValue)     // 这里我不理解为什么还要用 childOb 接收一下

            // 发布订阅模式,通知 dep 对依赖进行修改
            dep.notify()
        }

    })
}

6.array.js

该文件将 JS 中能改变数组的 7 个方法重写,并在进行数据劫持的时候将,数组的原型指向该文件加工后的新原型。

import { def } from './def.js'
// 得到 Array.prototype
const arrayPrototype = Array.prototype

// 以 Array.prototype 为原型对象创建 arrayMethods 对象,并暴露出去
export const arrayMethods = Object.create(arrayPrototype)

console.log(arrayMethods)

// 需要改写的7个方法
const methodsNeedChange = [
    'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'
]

// 通过遍历给新原型的7个方法内部都添加一些新的内容
methodsNeedChange.forEach((methodName) => {
    // 备份原来的方法,因为劫持后仍然需要原生的 API 去改变数据
    const original = arrayPrototype[methodName]

    // 定义新的方法
    def(arrayMethods, methodName, function () {
        // 这个重写函数分为两步走:
        // 1.使用原来的功能去操作数组:
        const result = original.apply(this, arguments)
        // 把类数组 arguments 变为数组,类数组没有 slice 方法,要不然下边无法正常切割了
        const args = [...arguments]

        // 2. 把新添加的值(或修改后的值)都加工成可监控的:
        // 把这个数组身上的 __ob__ 取出来,此时 __ob__ 已经被添加了
        /** 为什么这里会有 __ob__ 属性呢?  
         *  因为当 walk 函数遍历到数组 g 时,会继续按照流程走: defineReactive 文件把 g 交给 observe 文件,observe 判断 g 没有 __ob__,就会再交给 Observer 文件
         *  紧接着 Observer 在 16 行用 def 方法给 g 添加 __ob__ 属性。一直到这里才做是否为数组的判断,到这里才用到了 array 文件
         *  因此在这里完全可以取到 __ob__ 属性,它的值就是 g 的 Observer 实例本体
        */
        const ob = this.__ob__

        // 有三种方法 push/unshift/splice 能够插入新项,现在ob__要把插入的新项也要变为 observe 的
        let inserted = [];

        switch (methodName) {
            case 'push':
            case 'unshift':
                inserted = args;
                break;
            case 'splice':
                // 因为 splice 方法的三个参数代表:(下标, 数量, 插入的新项) 因此用 slice 取到插入进去的那个数据
                inserted = args.slice(2);
                break;
        }

        // 判断有没有要插入的新项,如果有,就调用 observeArray 方法(来自数组的 Observer 实例身上),因为新的数据可能也包含对象类型的
        if (inserted) {
            ob.observeArray(inserted);
        }

        // 能输出这句话代表重写方法成功
        console.log('能输出这句话代表重写你所使用的那个数组 API 重写成功');

        // 通知依赖进行数据的更新
        ob.dep.notify()

        // 必须要有返回值,因为一些 pop、splice 的方法会返回被操作的值
        return result
    }, false);
})

// export default arrayMethods 

7.Dep.js

在依赖收集阶段,Dep 对象是 Watcher 对象和 Observer 对象之间纽带,每一个 Observer 都有一个 Dep 实例,用来存储订阅者 Watcher

var uid = 0
export default class Dep {
    constructor() {
        console.log('我是 Dep 构造器');
        this.id = uid++;

        // 用数组存储自己的订阅者 实际存放的是许多的 Watcher 实例
        this.subs = []
    }
    // 添加订阅
    addSub(sub) {
        this.subs.push(sub)
    }
    // 删除订阅
    removeSub(sub) {
        remove(this.subs, sub);
    }

    // 添加依赖
    depend() {
        // 在调用这个函数时,一定是出于依赖收集阶段,因此 Dep.target 是存在的
        if (Dep.target) {
            this.addSub(Dep.target)
        }
    }

    // 通知更新
    notify() {
        console.log('我是 notify');
        // 浅克隆一份
        const subs = this.subs.slice()
        // 遍历
        for (let i = 0, l = subs.length; i < l; i++) {
            subs[i].update()
        }
    }
}

/**
 * 从arr数组中删除元素item
 * @param {*} arr 
 * @param {*} item
 * @returns 
 */
function remove(arr, item) {
    if (arr.length) {
        const index = arr.indexOf(item);
        if (index > -1) {
            return arr.splice(index, 1);
        }
    }
}

8.Watcher.js

当解析到模板字符串 {{ }} 时,会默认去 new Watcher 实例。

/**
 * 每一次的 new Watcher 都是独立的,因此构造器接收的三个参数,虽然名字一样但确实不同的数据,就像是 vm.$watch() 接收的参数一样,
 * @param {*} target 需要监视的对象,当做修改时,他就是
 * @param {*} expression 这个对象中的某个属性,它是一个表达式 比如 obj.a.b.c
 * @param {*} callback 回调函数,需要执行的操作 
 */

import Dep from "./Dep";

// 这个 uid 用于对每一个的 Watcher 实例添加唯一的 id
var uid = 0

// 在这里哪一步算是调用了 get 方法???????,解析到模板的时候

export default class Watcher {
    constructor(target, expression, callback) {
        console.log('我是 Watcher 构造器');
        this.id = uid++;

        // 模板字符串中的整个表达式
        this.target = target;

        // 通过拆分表达式(对象中的对象...),获得需要 Watch 的那个数据。比如传入的是 a.b.c.d 我们需要监视属性 d,就需要拆分
        this.getter = parsePath(expression)  // 有两种方法供使用 parsePath 会返回一个函数;如果用 reduce 方法,那么 getter 就会是一个具体的值,此时一定要修改下边的 get 方法!!!

        this.callback = callback

        // 调用该方法,进入依赖收集阶段
        this.value = this.get()
    }
    // 当更新 dep 中的依赖项时,会调用每一个 Watcher 实例身上的 update 方法
    update() {
        console.log('我是Watcher实例身上的update方法');
        this.run()
    }

    // 进入依赖收集阶段,让全局的 Dep.target 设置为 Watcher 本身
    get(){
        // Webpack 在打包的时候 Dep 是全局唯一的,不管多少个JS 文件在用 dep 的时候,都是这一个文件
        // 因此执行到这里
        console.log(this);  // Watcher 实例
        Dep.target = this;   
        // debugger;
        const obj = this.target;

        var value;

        // 防止找不到,用try catch一下,只要能找,就一直找
        try {
            value = this.getter(obj)    // 获取需要监视的那个值。这里因为constructor 的时候 this.get() 返回的是一个函数
        } finally {
            Dep.target = null   // 清空全局 target 的指向,同时也表示退出依赖收集阶段
        }
        return value        
    }

    // 其实可以直接 getAndInvoke,但是 Vue 源码时这样写的
    run(){
        this.getAndInvoke(this.callback)
    }

    //
    getAndInvoke(callback){
        // 获取到修改后的新值   旧值是 this.value
        const value = this.get()
        if(value !== this.value || typeof value == 'object'){
            const oldValue = this.value;
            this.value = value;
            callback.call(this.target, value, oldValue)
        }
    }
}





// 拆分表达式:
// 方法一:将 str 用 . 分割成数组 segments,然后循环数组,一层一层去读取数据,最后拿到的 obj 就是 str 中想要读的数据
// 假设 let o = {a:{b:{c:{d:55}}}},我想要取得 d 的值,经过拆分后的 segments 数组的值为 ['a', 'b', 'c', 'd']
// 第一次循环后 obj = {b:{c:{d:55}}}, 第二次 obj = {c:{d:55}}, 第三次 obj = {d:55}, 第四次 obj = 55
function parsePath(str) {
    let segments = str.split(".");
    return function (obj) {
        for (let key of segments) {
            if (!obj) return;   // 当没有传入 obj 时,直接 return
            obj = obj[key];
        }
        return obj;
    };
}
// 方法二 用 reduce 方法实现
// function parsePathReduce(str) {
//     let segments = str.split(".");
//     let result = segments.reduce((total, item) => {
//         total = total[item]
//         return total
//     }, str)
//     return result
// }
  • 7
    点赞
  • 64
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值