Vue 源码阅读学习(四)

响应式原理

嘿,朋友们,本节是 Vue 源码阅读的第四讲。我很高兴Vue 源码阅读系列得到许多朋友们的反馈与意见,再接再厉!

如果没有看之前的前三讲的内容可以先去看看哦,好啦,让我们开始吧。

温故知新


VNode 类: 用于构造虚拟 DOM

getVNode 方法: 用于将真正的 DOM 变为虚拟 DOM ,该方法先当作 complier 函数来使用,因为 Vue 将真正的 DOM 作为字符串进行解析,得到 ASTAST 算法较为复杂,现不过多讨论,所以使用带有“坑”的虚拟DOM来模拟抽象语法树;

getValueByPath方法: 用于访问任意对象层级的属性;

combine 方法: 将带有“坑”的虚拟 DOM 与数据结合,得到填充数据的 VNode

JGVue 构造函数: 现只提供了数据和模板(元素转换),Vue 是把它作为字符串解析;mount方法用于挂载;

mount 方法内部调用了 createRenderFn 方法,其将获取虚拟 DOM ,有缓存作用,调用 render 方法也就获取到虚拟 DOMmount 内部有 render 方法,而不把它定义在原型上,原因是 Vue 本身是可以带有 render 成员,也就是上期 Q5的内容;

mountComponent 方法用于挂载组件,里面有一个 mount方法,负责调用 update;而为什么不直接调用 update,多写了个 mount 方法,原因是后续使用发布订阅模式,渲染和计算行为交给 watcher 来完成。

render 方法用于生成带有数据的虚拟 DOM 加入页面中,update 算法在 Vue中还是挺复杂的,这里我们简化。


完成编写 update 方法

//将虚拟 DOM 渲染到页面中,diff算法在这里
JGVue.prototype.update = function() {
    // 简化, 直接生成 HTML DOM replaceChild 到页面中
    // 父元素.replaceChild(newElement,oldElement)
    // 需要拿到父元素
}

问题:父元素和新旧元素的获取

父元素.replaceChild(newElement,oldElement)

JGVue 构造函数编写

function JGVue(options) {
    this._data = options.data;
    let elm = document.querySelector(options.el);
    this._template = elm;
    this._parent = elm.parentNode;//获取父元素
    //提供一个新的方法 挂载
    this.mount();
}

Vue 中有一个元素 elm 是真正的 DOM , 其 children 同样也是真正的 DOM

parseVNode方法用于vNode 转为真正的 DOM

tips: 写到这里,其实大家也发现了代码量非常大,看多了自己都不知道写啥,后面使用构建工具(模块化)rollup webpack...来帮忙!

继续编写 update 方法

//将虚拟 DOM 渲染到页面中,diff算法在这里
JGVue.prototype.update = function(vnode) {
    // 简化, 直接生成 HTML DOM replaceChild 到页面中
    // 父元素.replaceChild(newElement,oldElement)
    // 新元素
    let realDOM = parseVNode(vnode);
    let oldDOM = document.querySelector('#root');
    // 不太正确
    this._parent.replaceChild(realDOM,oldDOM);
    // 这样会将页面中的 DOM 全部替换 
}

完成!验证一下:

<div id="root">
    <div class="c1">
        <div title="tt1" id="id">{{ name }}--{{ gender }}--{{ age }}</div>
        </div>
</div>

let app = new JGVue({
	el: "#root",
	data: {
		name: 'zhangsan',
		age: '18',
		gender: 'male'
	}
})

页面:

在这里插入图片描述

验证成功!源码见文章末尾。


进入正题 : 响应式原理

使用 Vue 的时候,赋值属性和获取属性都是直接使用 Vue 实例,在设置属性的时候,页面数据进行更新

需要使用到 ES5 语法 Object.defineProperty(obj, prop, descriptor)

obj : 需要定义属性的对象

prop : 要定义或修改的属性的名称或 Symbol

descriptor : 要定义或修改的属性描述符

其有比如 writeable configurable enumerable value get set

返回值为被传递给函数的对象


举个例子:

/*
Object.defineProperty(obj, 'foo', {
    writeable:true,
    //...
    configurable,
    enumerable, //控制属性是否可以被枚举,能否被 for-in 遍历
    set(){},//赋值触发
    get(){},//取值触发
})
*/
 var o = {};
// 给 o 提供属性
o.name = 'zhangsan';
Object.defineProperty(o, 'age', {
    configurable: true,//可配置(修改或删除)
    writable: true,//可写(可赋值)
    enumerable: true,//可枚举
    value: 19//值
})

//修改 enumerable 设置为 false 浏览器控制台输出无高亮
o.name //读取值 
o.name = 'foo' //赋值
for(var k in o){
    console.log(k);//name <无age> 因为是不可枚举
}

想要响应式,表示在赋值和读取值的时候,附带一些操作。

需要使用到 getset

Object.defineProperty(o,'gender',{
    configurable: true,
    enumerable: true,
    get(){// 访问gender 触发get方法(getter 读取器)
        return 'gender被读取了'
    },
    set(newValue){// 修改gender 触发set方法(setter 修改器)
        console.log('gender修改为:'+ newValue);
    }
})
// get 和 set 是一对
// value 和 writeable 是一对,他们两对不能同时出现

图示:

在这里插入图片描述

如果同时使用 getset 需要一个中间变量存储真正的数据

还是上述例子:

let _gender;
Object.defineProperty(o,'gender',{
    configurable: true,
    enumerable: true,
    get(){// 访问gender 触发get方法(getter 读取器)
        return _gender;
    },
    set(newValue){// 修改gender 触发set方法(setter 修改器)
        _gender = newValue;
    }
})

在这里插入图片描述

实际上,上述操作只是给 gender 赋值而已。


问题是 gender 被暴露在全局作用域,如何解决?

在 Vue 中使用 defineReactive(target,key,value,enumerable)

//简化版
function defineReactive(target, key, value, enumerable) {
    //函数内部就是一个局部作用域 这个 value 就只在函数内使用的变量(闭包)
    Object.defineProperty(target, key, {
        configurable: true,
        enumerable: !!enumerable,

        get() {
            console.log(`读取 o 的 ${key} 属性`);
			return value;
        },
        set(newValue) {
            console.log(`设置 o 的 ${key} 属性为:${newValue}`);
			value = newValue;
        }
    })
}

Q1: 中间变量是什么?

A1: 中间变量是 valuevalue 作为函数的参数,相当于这个函数作用域里面的局部变量。

将对象转换为响应式

let keys = Object.keys(o);
for (let i = 0; i < keys.length; i++) {
    defineReactive(o, keys[i], o[keys[i]], true);
    //该函数相当于一个闭包
    //闭包:简单理解就是函数内部调用函数外部的变量叫做闭包
}

图示:

在这里插入图片描述


现在将对象转换为响应式是远远不够的,在实际开发中对象一般是有很多级,跟之前说的对象层级类似。

let o = {
    list: [
        {}
    ],
    ads: [
        {}
    ],
    user: {

    }
}

Q2: 对象结构很复杂,有数组,有对象,还很多层,那如何处理?

A2: 递归 (除了使用递归还可以使用队列(深度优先转换为广度优先)有兴趣尝试,欢迎讨论!)


具体实现思路:

case① : 判断这个属性是否是引用类型,如果是则递归,不是则不用递归

case② : 判断是否是数组,如果是就需要循环数组,然后将数组里面的元素进行响应式化

let data = {
    name: 'zhangsan',
    age: 19,
    course: [
        { name: '数据结构' },
        { name: '操作系统' },
        { name: '计算机网络' },
        { name: '计算机汇编与组成原理' }
    ]
}
// 如果是引用类型 递归代码写在哪里
// 应该是写在 defineReactive
// 将对象 o 响应式化
function reactify(o) {
    let keys = Object.keys(o);//['name,'age','course']
    for (let i = 0; i < keys.length; i++) {
        let key = keys[i];
        let value = o[key];
        // 判断这个属性是否是引用类型
        // 判断是否是数组
        // 如果是引用类型 需要递归 
        // 不是 就不用递归  
        // 如果是数组 就需要循环数组 然后将数组里面的元素进行响应式化
        if (Array.isArray(value)){
            //是数组
            for(let j = 0; j < value.length; j++){
                reactify(value[j]);//对数组元素响应式化 递归
            }
        }else{
            //对象或值类型
            defineReactive(o,key,value,true);
        }
    }
}

//简化版
function defineReactive(target, key, value, enumerable) {
    //函数内部就是一个局部作用域 这个 value 就只在函数内使用的变量(闭包)
    if(typeof value === 'Object' && value != null && !Array.isArray(value)){
        //非数组的引用类型
        reactify(value);//递归
    }
    //值类型走这
    Object.defineProperty(target, key, {
        configurable: true,
        enumerable: !!enumerable,

        get() {
            console.log(`读取 ${target}${key} 属性`);
			return value;
        },
        set(newValue) {
            console.log(`设置 ${target}${key} 属性为:${newValue}`);
			value = newValue;
        }
    })
}

验证:

在这里插入图片描述

验证成功!


现在又有一个问题,如图:

在这里插入图片描述

新添加的数组元素等对数组元素操作,是没有响应式化的。

对象可以使用递归来响应式化,同时数组也需要处理。

对于数组使用-push -pop -shift -unshift -reverse -sort -splice…方法,对其实现响应化。

那需要做什么?

Ⅰ. 在改变数组数据的时候,发出“通知”

​ 比如 > data.name 会有 读取[Object object]的 name 属性的“通知”

Vue2 中的缺陷是数组发生变化,设置 length 无法通知,Vue3 使用 Proxy语法(es6 语法)解决了问题。

Ⅱ. 加入的元素应该变成响应式

技巧:如果一个函数已经定义了,但是需要扩展其功能,一般处理办法:

㈠ 使用一个临时的函数名存储函数

㈡ 重新定义原来的函数

㈢ 定义扩展的功能

㈣ 使用临时的函数

来个例子:

function func() {
    console.log('原始功能');
}
//1.使用一个临时的函数名存储函数
let _tmpFn = func;
//2.重新定义原来的函数
func = function () {
    //4.使用临时的函数
    _tmpFn();
    //3.定义扩展的功能
    console.log('新的扩展功能');
}
func();//打印 
//原始功能 
//新的扩展功能

上述就是在函数原有的基础上添加额外的操作:函数的拦截

Q3 : 扩展数组的方法(现只考虑 pushpop)如何处理?

A3 : 修改要进行响应式化的数组的原型(_proto_

思路:原型式继承:修改原型链的结构

let arr = [];//字面量创建
//继承关系: arr --> Array.prototype --> Object.prototype --> ...
//继承关系: arr --> 改写的方法 --> Array.prototype --> Object.prototype --> ...
let arr = [];
let ARRAY_METHOD = [
    'push',
    'pop',
    'shift',
    'unshift',
    'reverse',
    'sort',
    'splice'
];
/*
Object.create()方法是ES5中方法,这个方法用于创建一个新对象。被创建的对象继承另一个对象的原型,在创建新对象时可以指定一些属性
*/
let array_methods = Object.create(Array.prototype);
ARRAY_METHOD.forEach(method => {
    //改写数组里的方法
    array_methods[method] = function () {
        //调用原来的方法
        //调用拦截的method方法
        console.log('调用拦截的' + method + '方法');
        //改变上下文
        let res = Array.prototype[method].apply(this, arguments);
        return res;//数组方法是有返回值的
    }
});
arr.__proto__ = array_methods;
//对 arr 操作试试看

图示操作:

在这里插入图片描述

tips : 关于 apply bind call prototype __proto__ 等相关知识我会在 你应该掌握的 javascript 高阶技能 专栏中更新!


Q4 : __proto__ 是否有兼容性问题?

A4 : Vue 源码中做出了判断,如果浏览器支持 __proto__ , 那么操作如上述,如果不支持, Vue 使用的是混入(简单理解就是不挂载原型上,而是挂载到当前对象上)

Vue 部分源码

/**
 * Augment a target Object or Array by intercepting
 * the prototype chain using __proto__
 */

//通过拦截来增强目标对象或数组,使用__proto__的原型链
function protoAugment (target, src) {
  /* eslint-disable no-proto */
  target.__proto__ = src;
  /* eslint-enable no-proto */
}

// protoAugment 方法 类似于 arr.__proto__ = array_methods;

现在对数组的元素进行响应式化,也就是拦截中进行操作。

function reactify(o) {
    //todo...
    if (Array.isArray(value)) {
        //是数组
        value.__proto__ = array_methods;//数组响应式化
        for (let j = 0; j < value.length; j++) {
            reactify(value[j]);
        }
    } else {
        //对象或值类型
        defineReactive(o, key, value, true);
    }
}

ARRAY_METHOD.forEach(method => {
    //改写数组里的方法
    array_methods[method] = function () {
        //调用原来的方法
        //调用拦截的method方法t
        //将数据进行响应化
        //todo...reactify the data
        for(let i = 0; i < arguments.length; i++){
            reactify(argument[i]);
        }
        //todo...
    }
});

验证:

在这里插入图片描述

数组元素修改和新添加的数组元素都是响应式的。验证成功。


现有又有一个问题 Q4 来了,如图:

在这里插入图片描述

此时的 course 不是响应式的。

Q4 : 现在已经将对象改成响应式了,但是 course 是数组,如果直接赋值为对象,那么就不是响应式的。如何解决?

A4 : set 方法中将新值进行响应式化处理

//todo...
set(newValue) {
    console.log(`设置 ${target}${key} 属性为:${newValue}`);
    value = reactify(newValue);
    //现在 newValue 是对象
    //reactify处理的是对象的属性
    that.mountComponent();
}
//...

这里还是没有解决,如果你有很好的想法,欢迎在评论区讨论!

Vue2 中比如说直接为数组元素赋值、修改数组长度等等不能触发视图更新,数据也不是响应式的,Vue3使用 Proxy代理解决了这个问题,实际上,现在写的reactify 并没有对数组进行处理


现在需要将响应式化的需求代码与上期的 JGVue 构造函数进行合并。

function JGVue(options) {
    this._data = options.data;
    let elm = document.querySelector(options.el);
    this._template = elm;
    this._parent = elm.parentNode;//获取父元素
    //提供一个新的方法 挂载
    reactify(this._data);
    this.mount();
}

如图:

在这里插入图片描述


Q5 : 现在又有一个需求,修改数据的时候,模板更新?

A5 : 调用 mountComponent,在 defineReactiveset()中进行模板更新就可以了。(现在这里需要传入 Vue 实例,但是还有问题的,实际上是使用 watcher来完成的,后期会讲)

function JGVue(options) {
    this._data = options.data;
    let elm = document.querySelector(options.el);
    this._template = elm;
    this._parent = elm.parentNode;//获取父元素
    //提供一个新的方法 挂载
    reactify(this._data,this);//传入Vue实例 折中处理
    this.mount();
}

//简化版
function defineReactive(target, key, value, enumerable) {
    //折中处理以后 this 就是 vue 
    let that = this;
    //todo...
    Object.defineProperty(target, key, {
        //todo...
        set(newValue) {
            console.log(`设置 ${target}${key} 属性为:${newValue}`);
            //模板更新
            //todo? 传入 Vue 实例
            value = newValue;
            that.mountComponent();
			//先赋值再更新
        }
    })
}

function reactify(o,vm) {
    //todo...
    if (Array.isArray(value)) {
        //是数组
        value.__proto__ = array_methods;//数组响应式化
        for (let j = 0; j < value.length; j++) {
            reactify(value[j],vm);
        }
    } else {
        //对象或值类型
        defineReactive.call(vm, o, key, value, true);
    }
}

验证:更新数据,页面更新重新渲染

在这里插入图片描述

注意:模板使用到的 _data 都会触发 getter , combine 方法中 getValueByPath 也会触发 getter , 因为访问了数据,模板更新和渲染(数据改变),同样道理。还记得 update 方法嘛?this._parent.replaceChild(...)是直接替换整个 DOM ,而 Vue 中是使用 diff 对页面进行局部替换,现在写的还是有很多瑕疵,但是重点是要有这种思想!


JGVue 源码如下:

let r = /\{\{(.+?)\}\}/g;
//虚拟 DOM 构造函数
class VNode {
    //tag 标签 data 描述属性  value 文本内容 type  (elm)
    constructor(tag, data, value, type) {
        this.tag = tag && tag.toLowerCase();
        this.data = data;
        this.value = value;
        this.type = type;
        this.children = [];
    }

    appendChild(vnode) {
        this.children.push(vnode);
    }

}

//由 HTML DOM 生成虚拟 DOM (VNode)
//将这个函数当作 compiler 函数
//vue 把 vnode 作为 字符串 进行解析 得到 ast
//这个带有坑的模板 模拟 ast
function getVNode(node) {
    //获取虚拟DOM节点
    let nodeType = node.nodeType;//类型值
    let _vnode = null;
    if (nodeType === 1) {//这里看过源码阅读(一)估计有印象吧
        //元素
        let nodeName = node.nodeName;
        let attrs = node.attributes;
        //attrs 返回所有属性构成的伪数组,而我们要把它包装成 data
        //伪数组转换成对象
        let _attrObj = {};
        for (let i = 0; i < attrs.length; i++) {
            //attrs[i]属性节点(nodeType == 2)
            //用 nodeName 和 nodeValue 来描述这样的结构
            _attrObj[attrs[i].nodeName] = attrs[i].nodeValue;
        }
        //来初始化
        _vnode = new VNode(nodeName, _attrObj, undefined, nodeType);
        //考虑 node(真正的DOM) 子元素
        let childNodes = node.childNodes;
        for (let i = 0; i < childNodes.length; i++) {
            //对每一个childNodes进行生成虚拟DOM 加入VNode
                    _vnode.appendChild(getVNode(childNodes[i]));//递归
                }
            } else if (nodeType === 3) {
                //todo...
                //文本节点
                _vnode = new VNode(undefined, undefined, node.nodeValue, nodeType);
            }
            return _vnode;
        }

function JGVue(options) {
	this._data = options.data;
	let elm = document.querySelector(options.el);
	this._template = elm;
	this._parent = elm.parentNode;//获取父元素
	//提供一个新的方法 挂载
	reactify(this._data, this);
	this.mount();
	

	tion getValueByPath(obj, path) {
	let paths = path.split('.');// [foo,bar,baz]
	let res = obj;
	let prop;
	while (prop = paths.shift()) {
	    // paths.shift() 返回它的第0项 删除
	    // 每次取一项
	    res = res[prop];
	}
	return res;
	//我的思路 递归 循环 ?... reduce方法可尝试...
	//如果有 undefined ?需要解决一下... 
}

    // 将 AST 和 data 结合 生成 VNode
    // 现简化:待有“坑”的 VNode + data ==> 含有数据的VNode
    // 模拟 AST --> VNode 行为
    function combine(vnode, data) {
		let _type = vnode.type;
		let _data = vnode.data;
		let _value = vnode.value;
		let _tag = vnode.tag;
		let _children = vnode.children;

		let _vnode = null;
		if (_type === 3) {
		    //文本结点
		    //填“坑”
		    _value = _value.replace(r, function (_, g) {
                return getValueByPath(data, g.trim());
                });
                _vnode = new VNode(_tag, _data, _value, _type);

            } else if (_type === 1) {
                //元素结点
                _vnode = new VNode(_tag, _data, _value, _type);
                _children.forEach((_subvnode) => _vnode.appendChild(combine(_subvnode, data)));
            }
            return _vnode;
        }

    JGVue.prototype.mount = function () {
            //调用 mountComponent()之前需要提供一个render方法
            //作用生成 虚拟 DOM
            //需要提供一个 render
            this.render = this.createRenderFn();
            this.mountComponent();
        }

        JGVue.prototype.mountComponent = function () {

            //执行mountComponent()函数
            let mount = () => {
                this.update(this.render());
                //render 用于生成虚拟 DOM update 负责渲染到页面上
            }

            mount.call(this);//本质上应该交给 watcher 来调用
        }

        //生成 render 函数 目的是为了缓存AST (使用虚拟 DOM 来模拟)
        JGVue.prototype.createRenderFn = function () {

            let ast = getVNode(this._template);
            //ast 用虚拟 DOM 模拟
            return function render() {
                // 将 AST 和 data 结合 生成 VNode
                // 现简化:待有“坑”的 VNode + data ==> 含有数据的VNode
                //todo... combine
                let _tmp = combine(ast, this._data);
                return _tmp;
            }
        }


        //将 vNode 转换为真正的 DOM
        function parseVNode(vnode) {
            //创建真正的 DOM
            let type = vnode.type;
            let _node = null;//真正的 DOM 结点
            if (type === 3) {
                //文本节点
                return document.createTextNode(vnode.value);
            } else if (type === 1) {
                _node = document.createElement(vnode.tag);
                //标签属性
                let data = vnode.data;
                //data是键值对 比如 data: {class:"1"}
                Object.keys(data).forEach((item) => {
                    let attrName = item;
                    let attrValue = data[item];
                    _node.setAttribute(attrName, attrValue);
                });
                //该结点的子元素
                let children = vnode.children;
                children.forEach((subvnode) => {
                    // appendChild 添加子节点 跟 VNode 类中方法 appendChild 不是同一个方法
                    _node.appendChild(parseVNode(subvnode));
                     递归转换子元素 ( 虚拟 DOM )
                });
                return _node;
            }
        }

        //在真正的 Vue 中使用了二次提交的设计结构

        //将虚拟 DOM 渲染到页面中,diff算法在这里
        JGVue.prototype.update = function (vnode) {
            // 简化, 直接生成 HTML DOM replaceChild 到页面中
            // 父元素.replaceChild(newElement,oldElement)
            // 需要拿到父元素
            let realDOM = parseVNode(vnode);
            let oldDOM = document.querySelector('#root');
            // 不太正确
            this._parent.replaceChild(realDOM, oldDOM);
            // 这样会将页面中的 DOM 全部替换
        }

        let ARRAY_METHOD = [
            'push',
            'pop',
            'shift',
            'unshift',
            'reverse',
            'sort',
            'splice',
        ];

        let array_methods = Object.create(Array.prototype);

        ARRAY_METHOD.forEach(method => {
            //改写数组里的方法
            array_methods[method] = function () {
                //调用原来的方法
                //调用拦截的method方法
                //将数据进行响应化
                //todo...reactify the data
                for (let i = 0; i < arguments.length; i++) {
                    reactify(arguments[i]);
                }
                console.log('调用拦截的' + method + '方法');
                let res = Array.prototype[method].apply(this, arguments);
                return res;
            }
        })

        function defineReactive(target, key, value, enumerable) {
            let that = this;
            //函数内部就是一个局部作用域 这个 value 就只在函数内使用的变量(闭包)
            if (typeof value === 'Object' && value != null && !Array.isArray(value)) {
                //非数组的引用类型
                reactify(value);//递归
            }
            Object.defineProperty(target, key, {
                configurable: true,
                enumerable: !!enumerable,

                get() {
                    console.log(`读取 ${target}${key} 属性`);
                    return value;
                },
                set(newValue) {
                    console.log(`设置 ${target}${key} 属性为:${newValue}`);
                    value = reactify(newValue);
                    that.mountComponent();
                }
            })
        }

function reactify(o, vm) {
    let keys = Object.keys(o);//['name,'age','course']
    for (let i = 0; i < keys.length; i++) {
        let key = keys[i];
        let value = o[key];
        // 判断这个属性是否是引用类型
        // 判断是否是数组
        // 如果是引用类型 需要递归 
        // 不是 就不用递归  
        // 如果是数组 就需要循环数组 然后将数组里面的元素进行响应式化
        if (Array.isArray(value)) {
            value.__proto__ = array_methods;
            //是数组
            for (let j = 0; j < value.length; j++) {
                reactify(value[j], vm);
            }
        } else {
            //对象或值类型
            defineReactive.call(vm, o, key, value, true);
                }
            }
        }

let app = new JGVue({
    el: "#root",
    data: {
        name: 'zhangsan',
        age: '18',
        gender: 'male',
        course: [
            { name: '数据结构' },
            { name: '操作系统' },
            { name: '计算机网络' },
            { name: '计算机汇编与组成原理' }
        ]
    }
})

这代码好乱,明天再看:这是我写的?后面需要构建工具帮帮忙了。

下期内容:代理方法与事件模型、vueObserver Watcher Dep

学习心得:源码学习过程一定是艰难的,源码好比一辆车繁多的零件配置与复杂的组装图纸,而使用Vue好比开车,阅读源码的益处就不用我多说了吧。阅读源码的过程中我也翻看了许多资料,大多数都是直接上手源码,从合并策略,数据代理检测,完整的渲染流程等等讲解,可能看了半天什么也没懂,而此次Vue源码阅读专栏只要你对 Vue有一定的基础,我相信你能看明白!

随着 Vue 源码深入学习,同时难度也在增大,所以说要求我们 javascript 打下良好的基础。为此,后面开一专题你应该掌握的 javascript 高阶技能,为大家提供更多的优质学习内容,希望大家多多支持!


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值