一文扫清对 JavaScript 中的疑惑

一文扫清对 JavaScript 中的疑惑

一、前言

1. 写作原因 :

为什么会有这么一个文章出现 ? 其实在写这篇文章的时候内心也是五味杂陈 :

  • 一方面JavaScript 的运行机制是笔者一直以来的一块短板, 以前不了解这方面的知识, 工作上班也没有问题, 就是觉得像是假的,不知道自己能不能把这么大的一个话题写好写透。
  • 另一方面当我去网上查资料时,发现论坛有位网友发了个帖子, 看过内容后笔者就陷入了思考. 想来想去也不知该如何作答, 帖子发布的时间已经过去几年了, 可就是没人能完完整整的说明白。 — 心灵的拷问 !
    => 帖子的问题如下 :
    link : 心灵的拷问 - question
    在这里插入图片描述
  • 综上种种原因, 加上上网查这部分资料的时候发现好多博文都是互相抄袭, 要不是缺乏自己的理解,要不就是教科书式的硬搬过来(或许是笔者我能力有限不明所以吧 …)。总之有一种 “上天无路, 入地无门” 的感觉, 想着将这段时间自己梳理的资料笔记以及自己的理解插上配图,来系统的说明一下, 如果文章有误, 还请及时指出, 望各路大神路过斧正 !

2. 阅读须知 :

  • 篇幅可能有些长, 耐心看下去绝对会有收获的 !
  • 要求你 :
    • 对 JavaScript 基础知识有一定了解。
    • 对 变量提升、作用域、执行上下文等概念有所了解,至少要听说过。
    • 对 JavaScript 感兴趣

3. 文章声明 :

  • 本文参考了许多资料包括 : 《红宝书》、《你不知道的 JavaScript 上卷》、《JavaScript 面向对象编程》、ES5 官方文档等及一些网络文章 !
  • 本文涉及到任何人物、事迹或者现象纯属润色文章,并无恶意 !
  • 原创文章如需转载请注明出处 !

二、荡平疑惑

1. 我的 JS 代码被浏览器加载后到发生了什么 ?

  • 当 JS 代码被加载到浏览器之后,运行代码之前要经历一个过程, 这个过程会将我们的伪代码按照一定规则转换成一些列的指令供 JS 引擎使用。而这个过程叫做 — "编译"
  • 这个编译的过程可以划分为 3 个阶段 :
    • 词法分析 :

      • 会将代码字符串转换为有意义的词法单元【token】, 例如 var name = "FruitJ"; 会被分割为 : varname=FruitJ;
    • 语法分析 :

      • 将词法单元流(数组形式)转换成一个语法树 — AST。
      • AST 有什么作用【"不是正常人玩的东西"】 ?
        • IDE 的错误提示、代码格式化、代码高亮、自动补全。
        • JSLint、JSHint 对代码错误风格的检查。
        • Webpack 对代码的打包。
        • JSX 与 TypeScript 转换为 JavaScript。
          目睹一下 tokens 的真容 :
      	[
      	    {
      	        "type": "Keyword",
      	        "value": "var"
      	    },
      	    {
      	        "type": "Identifier",
      	        "value": "name"
      	    },
      	    {
      	        "type": "Punctuator",
      	        "value": "="
      	    },
      	    {
      	        "type": "String",
      	        "value": "\"FruitJ\""
      	    },
      	    {
      	        "type": "Punctuator",
      	        "value": ";"
      	    }
      ]
      

      目睹一下 AST 语法树的真容(以 var name = "FruitJ";这句为例) :

      		{
      	    "type": "Program",
      	    "body": [
      	        {
      	            "type": "VariableDeclaration",
      	            "declarations": [
      	                {
      	                    "type": "VariableDeclarator",
      	                    "id": {
      	                        "type": "Identifier",
      	                        "name": "name"
      	                    },
      	                    "init": {
      	                        "type": "Literal",
      	                        "value": "FruitJ",
      	                        "raw": "\"FruitJ\""
      	                    }
      	                }
      	            ],
      	            "kind": "var"
      	        }
      	    ],
      	    "sourceType": "script"
      	}
      
    • 代码生成 :

      • 将 AST 语法树转换为可执行代码、指令。
      • 变量提升、为函数开辟内存、形参赋值、绑定 this …

经过这些步骤后, 就可以准备执行了 !
实际上代码在浏览器后台运行离不开三位老哥 :

  • JS 引擎【JS 代码的编译和执行的过程都是这位老哥一手督战的】
  • 编译器【上面所说的编译时的那三个阶段就是编译器来完成的】
  • 作用域【是一种规定了如何访问变量的一些列规则和约定 -> 如果这句话不明白没关系,在下文会具体解释】
    三位老哥是怎样协同工作的呢 ?
    就用上面的 var name = "FruitJ"; 来说 JS 引擎会处理两次, 编译时处理一次、运行时处理一次, 也就是在编译阶段当编译器遇到了 var name = "FruitJ" 这句代码会去当前的作用域【作用域不是什么空间什么的不要混淆概念,下文会详述】中查找, 如果有则忽视该条声明, 如果没有就会重新声明这个变量, JS 引擎在运行阶段会去执行 name = "FruitJ" 这个赋值操作。所以 JS 引擎就会沿着作用域链去查找该变量,而使用的具体查找机制就是下面的 RHSLHS 查询
    在这里插入图片描述

2. 什么是 RHS、LHS 查询 ?

-1). 为什么要学习 RHS 和 LHS 查询 ?
  • 一是为了了解浏览器内部 JS 引擎查找变量的机制。
  • 二是学之前我们知道该变量在这种情况下会报错,学完之后我们就知道该变量在那种情况下为什么会报错以及报什么类型的错!!!

譬如说 :
=> RHS 查询在所有嵌套作用域中遍寻不到需要的变量,引擎就会抛出 ReferenceError 异常。
=> LHS 查询,如果在顶层作用域中也无法找到目标变量赋值的话,就会在全局作用域下隐式的创建一个具有该名称的变量,并将其返还给 JS 引擎。
=> 严格模式 如果是在严格模式下, 是禁止隐式的创建全局变量的,所以在严格模式下 LHS 查询失败后会抛出与 RHS 查询失败类似的 ReferenceError 异常。
=> 如果 RHS 查询到了一个变量,但是对该变量值进行了不合理操作就会抛出 TypeError 异常(就比如 -> undefined())。

-2). RHS 与 LHS 的定义
  • LHS : 不关心当前被赋值的目标是什么只想为将要赋的值找一个目标赋出去。在非严格模式下,如果一直到全局作用域中都找不到这个 “目标”,就会在全局作用域下声明一个,而在严格模式下就不会。a = 9 此时 a 就是一个 LHS 引用。
  • RHS : 要使用这个变量,当前作用域没有就像上查找,如果到了全局作用域中还没找到直接不开心的抛出异常。console.log(a); 此时 a 就是 RHS 引用。
  • 简单理解就是 LHS 就是往出 "给", RHS 是往回
    => 生硬的概念不如几道题来的直接, 理解的深刻。
-3). 借助几道小题深入理解 RHS 与 LHS
  • 下面代码哪一行会报错 ? 报的什么错 ?
x = 1;
console.log(y);

答案 : 第二行会抛出 ReferenceError 异常。
解析 : x = 1 就相当于进行了一次 RHS 查询, 在全局环境中没有找到, ok 创建一个好了。而 console.log(y); 是对 y 进行了一次 RHS 查询。结果全局环境下没有此变量直接抛出 ReferenceError 类型的异常。

  • 如下代码变量 a 是否会抛出异常 ?
a = (function add(num) {
  return function(num) {
    a = num
  }
})(5);
a(6);
console.log(a);

答案 : 不会, 分析如下 :

a = (function add(num) { // 这里对 a 进行 LHS 查询, 当前上下文没有变量 a 所以在全局上下文下隐式的造了一个。同时对形参 num 进行 RHS 查询 -> num = 5
  return function(num) { // 此处对 num 进行 RHS 查询, 上层函数的上下文中有 num (没问题)
    a = num; // 对 a 进行 LHS 查询, 此时全局上下文中已经有全局变量 a ,这一步直接赋值,对 num 进行 RHS 查询, 当前上下文有 num 变量(没问题)。
  }
})(5); // 对 add 进行 RHS 查询 -> add(5)
a(6); // 这里对 a 进行 RHS 查询, 此时 a 存储的引用地址存在(没有问题)
console.log(a); // 所有的 RHS 查询均正常找到对应变量,此刻又非是在严格模式下,故不会抛出异常, 运行代码 a 为 6。
  • 趁热打铁 : (下面代码对否会抛出异常 ?)
a = (function add(num) { 
  var b;
  return function(num) { 
    b = num; 
  }
})(5); 
a(6);
console.log(b); 

答案 : 会, 分析如下

a = (function add(num) { // 此处对 a 进行 LHS 查询, 当前上下文没有变量 a 所以在全局上下文下隐式的造了一个。同时对形参 num 进行 RHS 查询 -> num = 5
  var b;
  return function(num) { // 此处对 num 进行 RHS 查询, 上层函数的上下文中有 num (没问题)
    b = num; // 对 b 进行 LHS 查询, 在上层函数的上下文中找到了 b ,故直接赋值, 对 num 进行 RHS 查询, 当前上下文有 num 变量(没问题)。
  }
})(5); // 对 add 进行 RHS 查询 -> add(5)
a(6); // 这里对 a 进行 RHS 查询, 此时 a 存储的引用地址存在(没有问题)
console.log(b); // 对 b 进行 RHS 查询, 当前全局上下文中没有 b 变量, 故抛出 ReferenceError 异常
  • 再来验证下在严格模式下进行 LHS 查询失败是否会抛出异常。
    非严格模式下 :
a = 12;
console.log(a); // 12

在非严格模式下不会抛出异常。

严格模式下 :

'use strict';
a = 12;
console.log(a); // Uncaught ReferenceError: a is not defined

在严格模式下抛出 ReferenceError 异常。

  • 最后一道巩固(找出所有的 LHS 查询和 RHS 查询) :
function foo(a) {
var b = a;
return a + b;
}
var c = foo( 2 ); 

答案 :

function foo(a) { // 对 a 进行 LHS 查询 -> a = 2
var b = a; // 对 b 进行 LHS 查询, 对 a 进行 RHS 查询 
return a + b; // 对 a, b 都进行一次 RHS 查询
}
var c = foo( 2 ); // 对 c 进行 LHS 查询, 对 foo 进行 RHS 查询

所以本题 : RHS 查询一共有 4 次, LHS 一共有 3 次。

3. EC(Execution Context)【执行环境 / 执行上下文】 和 Scope【作用域】 是一个东西吗 ? 如果不是区别在哪 ?

执行上下文与作用域不是一个东西, 网络上有很多文章包括笔者以前都分不清二者之间的区别,一直认为二者就是一个东西, 现在看来并不是的。

  • 作用域 : 实际上就是一套规则, 规定了访问变量时候的权限, 并对其行为进行严格限制,作用域只是一个“空地盘”,其中并没有真实的变量,但是却定义了变量如何访问的规则和定义了当变量访问不到的时候如何向上查询的一套规则。并不是我们想象中的作用域是一个 { }, 也不是我们想象中的与执行上下文, 作用域就是一套规则, 一个抽象概念,仅此而已。 JS 引擎在运行阶段查找变量的时候也是通过作用域的辅助来进行的。

  • 执行上下文 : 也可以叫做执行环境。这个执行环境通常是函数即将在进栈执行之前开始创建的,里面包含着活动对象。在函数执行完毕后, 通常如果在全局上下文中没有引用着该函数的变量,该函数的执行上下文将会出栈销毁, 当然里面的变量也一并销毁。如果被引用着呢 … 就会将该函数向栈底的方向压,给其他待执行函数提供执行空间。

所以二者的区别还是很大的, 包括有些时候总是能听到函数执行完毕作用域就被销毁了云云, 如果了解了这些就会知道这个表述是有问题的,你可以说执行上下文销毁了,但是不能说作用域被销毁,作用域在编译阶段就产生了,说的更夸张一些,在你写代码的时候就基本确定了(这就要涉及到下面的词法作用域了) !
基于这个知识, 在以后面对代码的时候如果涉及作用域,一定一定要站在它本来的角度去看待,去探索 !!!
当然不理解这些也没问题, 可以做对题也可以写代码, 但是笔者有洁癖 …emm
========== 2020-03-20 补充===============
还需要补充一点就是这个函数执行并不是把存在堆里面的实例整体的剪切过来执行, 只是创建了当前函数的执行上下文, 让这个执行上下文进栈执行, 而且这个执行上下文也可以创建多个 : 譬如说你同一个函数分别调用两次就会形成不同的执行上下文, 这一点传个不同参数体验一下即可, 虽然执行上下文可以有一个但需要注意的是作用域从始至终就只有一个, 还是那句话二者是有区别的 !

4. 什么是词法作用域 ? 和作用域有什么关系 ?

  • 这个问题应该是最好解答的了, 词法作用域 仅仅只是作用域的工作模型之一, 另一个工作模型是 动态作用域
    那既然这样二者的工作机制是怎样的呢 ?
    下面以一段代码来说清楚 :
    下面这段代码会输出什么 ?
let name = "FruitJ";
function sayHello() {
    
    console.log(`Hello ${ name }`);
}
function foo() {
    
    let name = "XXY";
    sayHello();
}
foo();

会输出 : "Hello FruitJ"
因为在 JavaScript 中作用域采取的是 词法作用域 这种工作模型, 作用域早在词法阶段就已经被确定好的。所以 sayHello 的上一级作用域就是全局作用域, 与 foo 的作用域没关系。
换句话说和函数在哪调用的没关系, 但是如果是动态作用域呢 ? 如果是动态作用域就会输出 "Hello XXY" ,因为动态作用域的标准就是与函数在哪调用有关。

  • 那是不是作用域在词法阶段确定了就不能再更改了呢 ?
    不是的, 虽然大部分情况是这样的, 但是 evalwith 是可以 “欺骗词法作用域” 的, with 基本弃用主要说下 eval。
    通过一段代码来解释清楚 :
    看看下面这段代码会输出什么 ?
function foo(str, a) {
eval( str ); // eval 大骗子!
console.log( a, b ); // 1, 3
}
var b = 2;
foo( "var b = 3;", 1 );

会输出 1 和 3 , 如果没有 eval 那么 b 应该是 2 。
上述提到过 JavaScript 是采用的词法作用域的工作模式,就是说 “一切均发生在被定义的时候”。但是 eval 这个臭小子把它骗了。
eval 可以接收一个字符串参数,并将其转换成程序代码来运行。
再看看下面这段代码是不是也 “欺骗了词法” 呢 ?

function foo(str, a) {
var b = 3;
console.log( a, b ); // 1, 3
}
var b = 2;
foo( "var b = 3;", 1 );

不是的, 因为在词法阶段确定了 foo 被定义在全局,同时自己本身已经定义了 b 了, 就不再去自己被定义的地方去找了,因为自己已经有了,但是 eval 那种情况不一样, eval 是在词法阶段,没有检测到 foo 内部是否有这个 b 只有到 eval 执行的时候才确定,所以说 eval 是个大骗子它会欺骗词法也同时改变机制,所以在工作时尽量少用 eval, 否则对代码的健壮性是个考验。

如果使用 eval 性能会怎样 ?
性能不怎么样,本来浏览器可以根据词法阶段的状态给出最优的优化方案,但一看到 eval 就犯愁不知道 eval 里面是个啥,贸然优化可能会导致结果不准确,所以就不做优化,对用户而言,不做优化就相当于性能退化!

5. VO(Variable Object)【变量对象】 和 AO(Active Object)【活动对象】 的区别在哪 ?

  • VO 是 JS 引擎实现的,并不能由 JS 环境直接访问, 换句话说变量对象是在函数被调用, 但函数尚未执行的时刻创建的【执行上下文形成的时候】,而创建这个变量对象的过程就是( 函数参数、作用域链、内部变量、this 绑定、内部函数初始化的过程 )。
  • AO 未进入执行阶段之前,变量对象中的属性都不可访问 , 这时候活动对象上的各种属性才能被访问, 但是进入执行阶段之后,变量对象就转换为了活动对象,里面的属性就都能被访问了,然后开始执行阶段的操作。

6. 什么是作用域链 ? 和作用域有什么关系 ? 和执行上下文有什么关系 ? 我可以看到它吗 ?

  • 作用域链和作用域是肯定有关系的,实际上不仅是作用域链和作用域有关系, 在笔者看来变量对象与作用域也有关系, 甚至说作用域是抽象概念则变量对象就是其具体的实体化(虽然这并不完全正确)。然后引用《红宝书》上的一句话就是

作用域链的前端,始终都是当前执行的代码所
在环境的变量对象。如果这个环境是函数,则将其活动对象(activation object)作为变量对象 — 《JavaScript 高级程序设计第三版》

所以这个作用域链大概就是一个又一个的变量对象或是活动对象来组成的, 而作用域链的用途则是保证对执行上下文有权访问的所有变量和函数的有序访问。
而这个作用域链的最后一个对象则始终是全局执行环境的变量对象。
作用域链的向 上 / 后 逐级查找的这个机制叫做作用域链的回溯机制。

  • 作用域链与执行上下文之间的关系就是在执行上下文创建的时候会建立作用域链,。
  • 我们可以看到这个作用域链 : 每个函数都有一个属性 [[scope]]
    函数的作用域链就保存在了这个里面(当然在 JavaScript 中凡是被 [[ ]] 包裹的属性都是不可访问的, 但是我们可以在控制台看到他) :

下面这段代码可以直观的帮助我们观察到作用链。

let a = 1212;
function fun() {
    let b = 456;
    var name = "FruitJ";
    
    function foo() {
        let c = 789;
        var age = 22;
        console.dir(foo);
        function fn() {
            var f = 99;
            console.dir(fn);
        }
        fn();
    }
    foo();
}
fun();

对于函数 foo 来说他的作用域链就是 :
在这里插入图片描述
在这里插入图片描述

7. 函数中的 this 到底是什么 ? this 绑定的 4 种方式 ?

  • this 是当前执行代码的环境对象, 或者说执行的每个 JavaScript 函数都有对其当前执行上下文的引用, 通俗理解就是,谁触发的我, 谁引用着我呢我就指向谁。

  • this 绑定的四种方式 :

    • 默认绑定 :
    	function foo() {
    		console.log(this.a);
    	}
    	var a = 2;
    	foo();
    

    在全局作用域下 foo() 是直接使用不带任何修饰的函数引用进行调用的所以 foo() 的 this 就指向了 window 。

    • 隐式绑定 :
    var a = 2;
    function fun() {
    	console.log(this.a); // 100
    }
    var obj = {
    	a: 100,
    	fun,
    };
    obj.fun();
    

    虽然这个函数严格上来说并不是属于 obj 对象。但是当函数被调用时会使用 obj 的上下文来引用该函数, 所以 this 指向了 obj。

    • 显示绑定 :
      • apply
      • call
      • bind
    var a = 456;
    function fun() { console.log(this.a); // 123 }
    var obj = { a: 123 };
    fun.apply(obj);
    

    通过 apply、call、bind 方法可以改变函数的 this 指向使其指向参数一位置的实例。

    • new 绑定 :
    function Person(name) {	
    	this.name = name;
    }
    var alice = new Person("alice");
    console.log(alice.name); // alice
    

    也就是说 当我们使用 new 来构造函数调用的时候会自动执行下面的操作 :

    • 构造一个新对象
    • 将这个新对象的 __proto__ 属性指向其构造函数的 .prototype 原型对象。
    • 将这个新对象绑定到函数调用的 this。
    • 如果该函数没有明确返回别的实例,那么默认会将该新对象返回。

8. 为什么有的文章提到过 词法环境 和 变量环境 ?

因为 VO 和 AO 是 ES3 提到的概念已经算是比较老了, 而词法环境、变量环境是 ES5 提出来的说法。

9. 闭包的定义到底是什么 ? 网上同仁的看法不一,甚至各主流的 JavaScript 书籍介绍的也都不一样 …

先来看看各个权威来源对闭包的介绍 :

  • 《红宝书》 : 闭包是指有权访问另一个函数作用域中的变量的函数。
  • 《你不知道的 JavaScript》 : 当函数可以记住并访问所在的词法作用域时,就产生了闭包, 即使函数是在当前词法作用域之外执行。
  • 闭包 - MDN : 函数与对其状态即词法环境(lexical environment)的引用共同构成闭包(closure)。也就是说,闭包可以让你从内部函数访问外部函数作用域。
  • 闭包函数 - 百度百科 : 即函数定义和函数表达式位于另一个函数的函数体内。而且,这些内部函数可以访问它们所在的外部函数中声明的所有局部变量、参数和声明的其他内部函数。当其中一个这样的内部函数在包含它们的外部函数之外被调用时,就会形成闭包。
  • 闭包 - 百度百科 : 在javascript中,只有函数内部的子函数才能读取局部变量,所以闭包可以理解成“定义在一个函数内部的函数“。在本质上,闭包是将函数内部和函数外部连接起来的桥梁。

笔者比较不认为可以将闭包视为一个函数, 笔者认为闭包应该是一个抽象的概念, 譬如说我们不能直接说某个函数是闭包, 而应该说某个函数在某种条件下形成了闭包。

对此, 对闭包这个概念的看法主要分两种 :

  • 从理论角度 : 所有函数在创建的时候就将其上级的上下文的数据保存起来了, 即使是全局变量。这个时候每个函数都相当于是一个闭包。

  • 从实践角度 :
    -1). 即使创建它的上下文已经销毁, 它仍然存在。
    -2). 在代码中引用了自由变量。

所以这两种看法不能说谁对谁错,也说不清楚。
在笔者看来对闭包的定义更倾向于 《你不知道的 JavaScript》 的表述。

function fun() {
	var name = "FruitJ";
	function foo() {
		console.log(name);
	}
	foo();
}
fun();

实际上此函数就已经形成了闭包, 因为 foo 这个函数可以访问到自由变量 name
有人说闭包的形式必须是函数套函数, 实际上函数套函数的原因只是为了造出来一个自由变量。
也有人说闭包的形式是函数套函数再将内部函数返回, 实际上这段表述中确实形成了闭包, 至于需要将内部函数返回则无疑是为了使用 "闭包" 罢了。
实际上不仅仅非得 return , 直接暴露出去也可以 : 代码如下
return

function fun() {
	var name = "FruitJ";
	function foo() {	
		console.log(name);
	}
	return foo;
}
var f = fun();
f();

直接暴露

(function fun() {
	var name = "FruitJ";
	function foo() {
		console.log(name);
	}
	window.foo = foo;
})()
foo();

这两种方式都成功的保存了引用。

三、一段 JS 代码一幅图了解堆栈内存及函数执行过程

var i = 5;
function fn(i) {
	return function(n) {
		console.log(n + (++i));
	}
}
var f = fn(1);
f(2); // 4
fn(3)(4); // 8
fn(5)(6); // 12
f(7); // 10
console.log(i); // 5

在这里插入图片描述

四、补充关于 [[scope]] 中的 global 对象和 script 对象

通过一段代码我们就可以看到传说中的 global 对象长啥样以及
window 对象与 global 对象真正的关系以及鲜有耳闻的 script 对象。

  • 实际上 浏览器内有 global 对象, 笔者原来以为没有,以为浏览器只有 window 对象, 但事实并不是这样的, window 对象是被包含与 global 对象中的 , 只不过 global 对象平常手段是访问不到的。还有 script 对象, 这里面存储的都是在全局上下文用 let / const 声明的变量, 并且多个 script 域共用一个 script 对象。
 <body>
  
  <script>
	let name = "FruitJ";
	var action = "running";
	function fun() {
		let age = 22;
		function foo() {
			console.dir(foo);
		}
		foo();
	}
	fun();

  </script>

    <script>
	let hobby = "打代码";
	target = "成为大神";
	function fun() {
		let age = 23;
		function foo() {
			console.dir(foo);
		}
		foo();
	}
	fun();

  </script>
 </body>

在这里插入图片描述
展开后 :
在这里插入图片描述
展开 global 对象(我们会看到我们使用非 let / const 声明的变量)。
在这里插入图片描述
在这里插入图片描述
找到 window 对象。
在这里插入图片描述
展开 window 对象。
在这里插入图片描述
在这里插入图片描述
在 global 和 window 对象里我们都分别找到了我们使用非 let / const 声明的变量。

五、后语

写到这里终于算是写完了, 以上就是最近搜集整理并且稍稍理解一点的东西, 感觉好像还是没有把该说的该表达的讲清楚。目前功力暂时就是这些了, 如果本篇文章可以扫清你的一些疑虑那是再好不过了, 因为那正符合本文的主旨 ! 如果本文给你带来的疑虑增加了甚至没有减少一些那将是笔者的失败,没有更好的去理解和阐述,希望在下方留言区进行指正,笔者将不胜感激 !!!

六、参考资料

  • 24
    点赞
  • 124
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值