一、什么是JavaScript
1.1 JavaScript的诞生 👶
1995年,网景公司发布了navigator浏览器0.9版,开启了web世界的全新大门。此时的网景公司急需一种网页脚本语言用于实现浏览器与网页的互动,由此,Brendan Eich两周之内就开发出了JavaScript,融合了c语言和self语言风格的JavaScript便由此诞生。
虽然JavaScript名字中带有Java,但是和Java却几乎毫不相关,除了部分语法相似之外。取名为JavaScript仅仅只是网景公司希望能借Java的名气来推广这门语言,事实上也做到了,现在仍然有很多人把Java和JavaScript混为一谈。
1.2 JavaScript是什么 🐱👓
如果说HTML是网页的骨骼、css是网页的皮囊,那么JavaScript就是整个网页的灵魂,它驱动着整个网页与用户的交互。它可以写在 HTML 中,在页面加载的时候会自动执行,不需要特殊的准备或编译即可运行。所以JavaScript是一种运行在浏览器中的解释型的、弱类型的编程语言。
1.3 JavaScript的实现 🧩
JavaScript常常和ECMAScript同时出现在我们的视野中,这是因为这俩基本就是同义词。但是JavaScript却不限于ECMA-262 所定义,完整的JavaScript包含以下几个部分:
-
核心(ECMAScript)
-
文档对象模型(DOM)
-
浏览器对象模型(BOM)
二、为什么需要JavaScript
对于为什么前端程序员需要JavaScript,原因可以概括为以下几点:
-
所有设备中的网页都由JavaScript驱动完成。
-
只有Javascript能跨平台、跨浏览器驱动网页,与用户交互。
-
在JavaScript中能够嵌入动态文本到HTML页面中,也能读取HTML页面内容。
-
数据可视化、页面内容实时更新,交互式地图,2D/3D动画,滚动播放音视频等等,都可以由JavaScript实现。
-
JavaScript的掌握程度很大程度上决定了前端程序员的定位。
在了解了为什么需要JavaScript之后,就让我们向着JavaScript的大门前进吧!
三、进入JavaScript的世界
在进入JavaScript的世界之前,需要有一些重要的前置知识作为支撑。如果你有编程基础,那么请毫不犹豫地进入后面内容的学习,如果没有基础也没有任何关系,看完for、if等基本前置知识之后,相信你会有非常大的收获,之后再进入JavaScript的世界就会非常轻松。
3.1 javascript世界的规则 📋
在JavaScript的世界中,也有着相应的规则,每一个前端程序员都应该尽力去遵守,良好的编程习惯会让人受益匪浅。
3.1.1 区分大小写
ECMAScript中的一切都区分大小写,无论是变量、函数名还是操作符,我们在命名时应当有意去进行大小写区分,如构造函数名的首字母和类名的首字母习惯大写等等。
3.1.2 标识符的规范
标识符就是变量、函数、属性或者函数的名称。标识符可以由一个或多个下列字符组成:
- 第一个字母必须是字母、下划线(_)或美元符号($)。
- 剩下其他的字符无特殊要求
在JavaScript的世界里,长串的标识符使用驼峰命名法来进行命名更为推荐,即第一个单词的首字母小写,后面每个字母的首字母大写,如:firstDay、myCar...
同时关键字(如break、typeof、instanceof、new...)、保留字(public、private、let...)、true、false和null都不能作为标识符
3.1.3 注释
优秀的前端程序员的代码通常都是可读的,所以使用注释的方法让代码可读至关重要。
js的注释采用c语言的注释风格,单行注释以两个斜杠开头,多行注释以一个斜杠一个星号开头,一个星号一个斜杠结尾。
// 单行注释
/*
多行注释
多行注释
多行注释
*/
tips:vscode中鼠标选中要注释的代码,同时按住CTRL和 / 即可一键注释(部分编译器可能不同)
3.1.4 严格模式
JavaScript的世界整体来说语法还是比较松散的,但是进入严格模式之后,对语法的限制就变得更加严格。
进入严格模式的方法就是在脚本的开头写上:
'use strict';
在严格模式下,不规范的写法会被拦截并处理,对于不安全的活动将会抛出错误,会影响JavaScript执行的很多方面。
3.1.5 结尾分号
JavaScript的世界中,所有的语句都以分号结尾。虽然分号结尾不是必须,就算不写分号解析器也会自己确定语句在哪里结尾并隐式补上分号,但是却强烈建议遵守这个语法。原因如下:
- 部分情况不加分号代码可能会和本意有出入。
- 多条语句写在同一行不加分号会报错
使用分号的好处在于:
- 加上分号可以避免很多错误,开发人员可以放心的通过删除多余的空格来压缩代码;
- 加上分号在某些情况下可以增进代码的性能,因为这样解析器就不用花多余的时间去推测在哪里添加分号了。
3.2 变量声明三兄弟 👦
JavaScript是弱类型的一门语言可是名不虚传的,变量可以用于保存任何类型的数据。在JavaScript的世界中,有三个关键字可以声明变量:var、let和const。
3.2.1 var
语法:var+变量名
var text
这样就定义了一个名为text的变量,可以用它保存任何类型的数据。但是值得注意的是,在变量未赋值的情况下,变量会默认保存一个特殊类型的数据 undefined 。
在变量声明之后,就可以进行变量的初始化。
var text = 'firstTest';
text = 13; //合法,但是在js中修改变量保存值类型的操作是不被推荐的
在这个例子中,text首先被初始化为'firstTest'的字符串,然后又被重写为一个13的数值,虽然有效,但是变量的数据类型发生了改变,是不推荐的。
扩展 :var的声明提升
使用var时,变量的声明会自动提升到作用域的顶部。
function foo(){
console.log(age); //undefined
var age = 21;
}
显然,变量age出现了变量提升,因为如果不提升,那么在打印age时就会报错,但是奇怪的是为什么打印的结果是undefined呢?原因在于,提升的只有变量,而没有赋值操作。下面的代码和上面等价。
function foo(){
var age;
console.log(age); //undefined
age = 21;
}
tips: 上面我们第一次使用console.log()进行了控制台的输出。在代码中键入console.log()可以在浏览器的控制台实现信息输出,浏览器的控制台可以按住F12快捷打开(部分电脑可能不能这样),也可以点击右键、再检查打开。
3.2.2 let
语法:let+变量名
let和var的作用差不多,都是用于声明变量,但是有几点主要的区别。
1、let声明的范围是块级作用域,而var声明范围是函数作用域。
if(true){
var name = 'Billy';
console.log (name); //Billy
}
console.log (name); //Billy
if(true){
let age = 20;
console.log (age); //20
}
console.log (age); //Uncaught ReferenceError: age is not defined
在这里,if外部的age不能被打印出来,原因就是let声明的变量被限制在了if块内,而var没有这样的限制,所以在外部能正常输出信息。
2、使用let不能重复声明同一变量,而var可以。
var name = 'Billy';
var name = 'Lucy';
var name = 'Jack';
console.log (name); //Jack
let age = 18;
let age = 21;
console.log (age); //Uncaught SyntaxError: Identifier 'age' has already been declared
由上面可知重复使用let声明同一变量会报错,但是使用let在不同作用域下声明相同名称的变量不会报错。
if(true){
let name = 'Jack'
}
let name = 'Billy'
console.log (name); //Billy
能够重复声明相同名称的变量的原因也容易猜到,因为受作用域的影响,两个相同名称的变量根本就不是同一变量,所以没有报错。
3、全局作用域中,let创建的变量不会加在window对象身上,而var会。
var name = 'Billy';
let age = 21;
console.log (window.name); //Billy
console.log (window.age); //undefined
在全局作用域中,经var声明的所有变量都会加到window对象头上,而let声明的变量不会。
扩展:let的声明“提升”
有的小伙伴已经察觉到了上面的“提升”两个字打了引号,而var的提升没有打引号。这是因为let的变量提升存在争议。
要探讨这个问题,需要先引入“暂时性死区”这个概念。“暂时性死区”就是变量声明之前的区域(let、const... ),在这个区域内引用后面才声明的变量会抛出错误。
console.log (val); //undefined
var val = 'test';
console.log (foo); //Uncaught ReferenceError: Cannot access 'foo' before initialization
let foo = 'test';
显然,var声明的变量不存在暂时性死区,let声明的变量存在,这样似乎可以说明let声明的变量不会提前,但事实真的如此吗。我们看下面代码。
const val = 'outer';
function test(){
console.log (val); //Uncaught ReferenceError: Cannot access 'val' before initialization
let val = 'inner';
}
test();
奇怪的事情发生了,理论上let声明的变量不会提前,即在打印前不会存在,函数在执行时会优先引用外部的变量。但是执行时却报错,这就是因为let在块级作用域创建的变量发生了提升,所以才会报错。
总结:
- let的“创建”过程提升,但是初始化未提升。(“创建”提升,所以在上面的打印中打印的是创建了而未初始化的变量,所以报错)
- var的“创建”和初始化都提升。(初始化为undefined)
3.2.3 const
语法:const+变量名 = 初始化的值
const和let基本相同,有一个重要的区别就是变量必须初始化,并且修改使用const声明的变量的值会报错。
const age = 26;
age = 36; //Uncaught TypeError: Assignment to constant variable.
const name; //Uncaught SyntaxError: Missing initializer in const declaration
使用const声明的变量一定是个常量,常量的值不可修改。
3.2.4 推荐的声明风格
1、不使用var ❌
三兄弟中的var是年龄最大的一个,在ES6之前只有它一个人来做变量声明的活,但是由于年代悠久,行为怪异,使得JavaScript社区苦恼了很多年。拥有明确作用域和声明位置的let和const两兄弟显然更受开发者喜爱。
2、const优先、let次之 ✅
使用const声明可以让浏览器运行时保持变量不变,改变时会报错,这样就能让分析工具提前发现不合法的赋值操作。因此应当优先使用const来声明变量,只有提前知道未来会修改变量时才使用let。
3.3 数据类型大家族 👨👩👧👦
在JavaScript的世界中,一共有7中基本数据类型:Undefined、Null、Boolean、Number、string、Symbol和BigInt,还有一种引用数据类型Object。这八种数据类型撑起了整个JavaScript数据领域的天空,相比于其他一些编程语言五花八门的数据类型,js的数据类型还是相对友好的。
3.3.1 Undefined
undefined,字面意思就是没有定义,很好理解。Undefined这个类型只有一个值,就是undefined。(Undefined以类型出现,首字母大写,以值出现不用,后面的数据类型同理)
当使用var和let声明了变量而不初始化时,相当于默认给变量赋予了undefined。
let message;
console.log(message); //undefined
3.3.2 Null
Null这个类型也只有唯一值null。null值表示一个空对象,使用typeof进行类型检测null时,会返回Object也就可以这么理解(但是事实上这是一个从JavaScript第一版一直遗留下来的bug🐛,基本数据类型是不应该返回引用数据类型这个结果的)。
let name = null;
console.log(typeof name); // object
Null有一个重要的作用,在定义将要保存对象值的变量时,可以先设置为null,null表示一个空对象(前面提到过),这样只要检查这个变量的值是不是null就可以知道这个变量有没有被重新赋予一个新对象的引用。
扩展:Null和Undefined的血缘关系
undefined值是由null派生而来,通俗来说,null是undefined的爸爸。看下面的代码:
console.log(null == undefined); //true
这里使用==操作符进行比较之后,js引擎会在后台暗箱操作进行了操作数的转换,所以相同,但是===操作符就不会进行操作数的转换。
3.3.3 Boolean
Boolean有两个字面量:true和false。Boolean在js中使用非常频繁
3.3.4 Number
数据类型家族中的Number可就非常有意思了,Number类型使用IEEE754格式表示整数和浮点数,在js中,几乎所有的数字都用Number表示(BigInt没出现之前),省去了其他语言众多数据类型的繁琐,像啥int、float、long、short这些都没有。虽然简单、上手容易,但是这也给使用js的开发者造成了巨大的麻烦,在扩展中我们会提到。
值得注意的是,在ECMAScript中不支持保存世界上所有的数值,它也有一个最大最小范围的限制。ECMAScript中最大最小的数分别保存在Number.MAX_VALUE和Number.MIN_VALUE中,这个值为± Math.pow(2, 53) ,超出这个有效值会发生截断,仍然等于 JS 能支持的最大(小)数字。
有一个特殊的数值是NaN,表示"Not a Number",字面意思就是不是数值,用来表示本来要返回数值的操作失败了的返回值。
console.log(0/0) //NaN
扩展:典中典之 0.3 !== 0.1 + 0.2
在我们的印象中,0.1+0.2 等于0.3这是毫无疑问的,但是在js的世界中,它却显示为不等于,怎么搞的。
- 按照IEEE 754转成相应二进制
- 然后进行对阶运算
在第一步的时候其实就已经脱离了正常的运算,遵循IEEE754 通过64位表示一个数的标准,0.1和0.2在转为二进制时其实已经发生了异变,它们在内存中的存储变为了以下形式:
0.1 =>
(图片摘自掘金用户Gladyu)
0.2 =>
(图片摘自掘金用户Gladyu)
进制转换之后,0.1 ->0.0001100110011001...(无限循环) , 0.2 -> 0.0011001100110011...(无限循环),由于IEEE754的尾数限制,导致了精度损失,0.1和0.2在第一步就已经异变。在第二步的对阶运算中,同样因为这个原因,导致了精度损失,因此0.3!==0.1+0.2。这是Number类型存在的一个重要问题之一(但是不能尬黑,不能说只有js会有这样的事情发生,所有采用IEEE754格式的语言都有这样的通病)。
3.3.5 String
String数据类型表示多个16位Unicode字符序列。可以使用双引号(" ")、单引号(' ')或者反引号( )都是合法的。
const name = "Billy";
const val = 'test';
const gender = `male`;
但是使用不成对的引号会抛出语法错误。
const name = 'Billy` //Uncaught SyntaxError: Invalid or unexpected token
字符串是不可变的,一旦创建了,它的值就不会再改变,若要修改这个变量的字符串值,就会先销毁原始字符串,再保存新值。
3.3.6 Symbol
Symbol是ES6新增的数据类型,用来确保对象属性使用唯一标识符,不会发生属性冲突的危险。
基本用法:使用Symbol函数进行初始化,然后使用中括号语法插入到对象属性中(后面会提到中括号语法)
let s = Symbol('foo');
let obj = {
[s]:'foo val'
}
console.log (obj); // {Symbol(foo): 'foo val'}
3.3.7 BigInt
前面我们提到过JavaScript世界中的最大最小安全整数,超过这个数的数会被截断,使用BigInt就不会发生这个问题。
基本用法:直接在整数末尾加上n/使用BigInt函数进行创建。
console.log(9999999999999999); // 10000000000000000
console.log(9999999999999999n); //9999999999999999n
const bigNum = BigInt('9999999999999999');
console.log (bigNum); //9999999999999999n
这样就能有效解决超大数造成的精度丢失问题。
3.3.8 Object
ECMAScript中的对象是一组数据和功能的集合。对象通过new操作符后跟对象类型的名称创建。
创建对象有五种方法:
-
- 使用new操作符进行创建
- 使用对象字面量的方式创建
-
- 构造函数方式创建
- 工厂模式创建
-
- Object.create()方式创建
对象的知识在JavaScript的世界中非常重要,我们会在后面展开详细叙述。
3.4 变量们和它们的作用域 🌎
在JavaScript的世界中,变量是松散的,因此变量也不过只是一个特定称谓的名字。由于没有规则定义变量必须包含什么数据类型(像c语言就需要),所以你想让变量在任何时候变成任何元素都没有关系,这样对于开发者而言很轻松,也很危险。
3.4.1 原始值和引用值
ECMAScript中变量分为两类:原始值和引用值。原始值就是简单的数据(基本数据类型),而引用值就是由多个值构成的对象(引用数据类型)。
值得注意的是,保存原始值的变量是按值访问的,我们操作的就是存储在内存中的实际值,而保存引用值的变量是按引用访问的。学过c++等类似其他编程语言的同学可能会有疑问,访问对象不是应该访问的是地址吗?原因在于,在JavaScript的世界中,禁止我们直接访问内存地址,因此也不能直接操作对象所在的内存空间。
3.4.2 复制一个值
原始值和引用值除了存储的方式不同,在进行复制的时候的动作也有所不同。
在将一个原始值赋值于另一变量时,原始值会被复制到新变量的位置,这两个变量完全独立,互不干扰。
let num1 = 1;
let num2 = num1;
它们在内存中的变化过程可以这样理解:
但是在将一个变量的引用值赋值给另一变量时,存储在变量中的值也会被复制到新变量所在的位置,不同的在于,复制的值是一个指向原变量的指针,因此两个变量实际上指向同一个对象。当一个对象发生改变时,另一个对象也会发生变化。
// 对象字面量新建一个空对象
let obj1 = {
}
let obj2 = obj1;
obj1.name = 'Billy';
console.log (obj2.name); //Billy
明明只给obj1添加了name属性,为什么打印obj2会有输出呢,下面的过程图有助于我们去深入理解。
可以看出,两个变量共享一个对象,当向对象中添加name属性时,object发生更新,因此两个指向同一个object的变量同时同步更新。
小试牛刀:典中典之连续赋值问题(有点绕)
var a = {n:1}, ref = a;
a.x = a = {n:2};
console.log("a:", a); //?
console.log("ref:",ref); //?
console.log("a.x:",a.x); //?
结果:
(和我第一次想的刚好相反)
解析:
在上面的案例中,a是一个含有属性n的对象,ref指向对象a的引用,这是第一行做的事。接着就是最难理解的一步了,第二行做了一个连续赋值,在JavaScript世界中有这样的规则,要理解连续赋值就必须先理解这个规则。
A = B = C 等价于 A = (B = C)
在JavaScript的世界里,连续赋值需要先执行右边的赋值,再执行左边的赋值,所以在上面的例子中,第二行先执行的是a = {n:2}这个操作,这个操作更改了a的地址,导致了a和ref不再指向同一内存。接着再执行a.x = a,此时的a = {n:2} ,而在赋值时,a.x仍然还是原来没有改变指向的a的内存,因此原来的a身上的x属性为{n:2},这也就是为什么ref和a的输出是这样的了,而为什么最后输出a.x的值是undefined呢,原因也简单,因为a在赋值时更改了地址,所以身上没有x这个属性,自然也就打印出undefined了。下面的图更有助于我们理解。
步骤一
步骤二
3.4.3 不一样的参数传递
ECMAScript中所有函数的参数都是按值传递的。什么叫按值传递,按值传递的意思就是函数外的值会被复制到函数内部的参数中。有的小伙伴就会说,访问都有按值访问和按引用访问,传递参数为什么只有按值传递呢?JavaScript与其他语言不一样的地方就来了。
按值传递,值是直接复制到形参之中,而按引用传递参数,值在内存中的位置会保存在形参之中,因此函数内部的变化会影响到函数外部的变化(因为内外变量都指向同一个内存地址),这在JavaScript的世界里是不可能的,我们看下面的例子。
function test(a){
a = a + 10;
console.log (a); //20
}
let a = 10;
// 调用函数,并将上一行声明的变量a传入函数。
test(a);
console.log (a); //10
如果是按引用传递,那么函数内部打印的a和函数外部打印的a应该都是指向同一内存地址,即结果都应该为20,但是结果是内部的a为20,外部的a没变,函数内部的变化没有影响到函数外部,因此可以证明参数不是按引用进行传递的。
3.4.4 变量们的作用域
3.4.4.1 作用域的作用
什么是作用域?简单来说,作用域就是一个变量的作用范围,使用作用域可以防止内存泄漏、命名冲突等种种问题。我们需要牢记一点的是:
内层作用域可以访问外层作用域的变量,外层作用域不能访问内层作用域。
下面我们来看个例子:
function test(){
const a = 'inner';
}
console.log (a); //Uncaught ReferenceError: a is not defined
显然外层不能读取到函数内部变量,目前它在函数作用域中的变量是私有的,不会内存外泄。当然要读取到它也可以通过闭包等方法获取。
作用域的最主要作用就是隔离变量,在不同的作用域下同名变量不会有冲突。
tips:另一个重要概念是执行上下文,执行上下文就是js被执行和解析时的环境,作用域和执行上下文这俩概念容易混淆。主要区别在于作用域在解释阶段确定的,不会改变;执行上下文在运行阶段确定的,随时可能改变。
3.4.4.2 全局、函数和块级作用域
作用域可以分为全局作用域、函数作用域和块级作用域。
有以下几种情形变量拥有全局作用域:
- 最外层函数自身和在最外层函数外面定义的变量
- 不声明直接赋值的变量自动声明为拥有全局作用域(不声明的变量自动作为window对象的属性)
- 作为window对象属性的变量。
function outerFn(){
console.log ('outerFn in global');
a = 1;
}
//调用全局作用域中的函数outerFn,若有输出则证明为全局.
outerFn();
let variable = 'outer';
console.log (variable);
console.log (window.a); //证明a属于window的属性,且window对象属性属于全局变量。
//全部正常输出
//outerFn in global
//outer
//1
全局作用域的弊端在于容易污染命名空间,导致命名冲突,尤其是在项目庞大的情况下。
函数作用域就是声明在函数内部的变量的作用范围,函数作用域中的变量就只有函数内部可以访问到(这类变量被称为私有变量),外界要访问需要使用闭包等手段。
下面我们使用构造函数的方式实现一个闭包(构造函数后面我们也会讲到)。
function Person(){
// 创建私有变量
name = 'Billy';
// 因为此处的函数属于Person构造函数的内层函数,所以可以访问变量name
// 这里的this指向创建的实例p,所以可以通过p访问到,后面我们也会讲到
this.getName = function(){
return name
}
}
const p = new Person();
const val = p.getName();
console.log (val); //Billy
块级作用域使用大括号创建的作用域 {},使用let和const声明的变量在块级作用域中声明,外层仍然也无法访问。
{
let a = 'test';
}
console.log (a); // Uncaught ReferenceError: a is not defined
JavaScript的世界中,作用域的知识深入而复杂,还有复杂的作用域链标记清理等等重要知识,出于篇幅考虑,这里我们就简单了解一下作用域就好啦。
3.5 来new一个对象吧 🐮
每当春节回家,还在总是为被七大姑八大姨问有没有对象而烦恼吗?学了本章内容,让你可以从0创建一个对象,再也不用为没有对象而烦恼了。
在JavaScript的世界中,几乎所有事物都是对象。Boolean(new定义)、Number(new定义)、String(new定义)、date、正则表达式...除了基本数据类型以外,其余的都是对象。
我们前面提到的对象,其实就是某个特定应引用类型的实例。实例可以通过new操作符后跟构造函数来创建(构造函数就是用来创建对象的函数,通常需要大写)。
let obj = new Object();
上面就是使用构造函数Object()创建对象的一种常见方法。
3.5.1 创建对象的五种方法
上面我们已经介绍了第一种创建对象的方式,包含上面那种,一共常见的有五种方式,建议牢记。
方法一、使用Object()构造函数创建
let obj = new Object();
obj.name = 'Billy';
obj.age = 21;
console.log (obj); //{name: 'Billy', age: 21}
上面我们就成功创建了一个对象,且对象的名字叫做Billy,年龄为21岁,大家可以根据自己的喜好创建对象。
方法二、使用对象字面量创建对象
let obj = {
name: 'Billy',
age: 21
}
console.log(obj); //{name: 'Billy', age: 21}
上面创建的对象和使用方法一创建的对象是一样的,值得注意的是使用对象字面的语法,多个属性之间应该使用逗号隔开。
方法三、使用Object.create()方法创建对象
function Person(){
this.name = 'Billy';
this.age = 21;
}
let p1 = new Person();
console.log (p1); //Person {name: 'Billy', age: 21}
let p2 = Object.create(p1);
console.log (p2.__proto__); //Person {name: 'Billy', age: 21}
Object.create()的方式可以使用现有的对象来提供新创建的对象的原型。这句话可能有点绕,简单来说从上面的例子看来就是加在了新创建的对象p2的原型上,上面案例中我们隐式访问了p2,拿到了结果,所以也不难看出(这儿有点超纲了,原型我们也会在后面提及)。
方法四、工厂方法创建对象
function create(name,age,gender){
let obj = new Object();
obj.name = name;
obj.age = age;
obj.gender = gender;
return obj;
}
let p1 = create('Billy',21,'male');
let p2 = create('Lucy',18,'female');
console.log (p1); //{name: 'Billy', age: 21, gender: 'male'}
console.log (p2); //{name: 'Lucy', age: 18, gender: 'female'}
工厂模式是一种经典的设计模式。使用工厂方法可以快速创建大量对象,但是创建的对象没有明确的标识,即无法得知新创建的对象是什么类型,使用instanceOf无法准确查明。使用构造函数创建对象可以解决。
方法五、使用构造函数创建对象(很重要,但是如果这儿看得太累就跳到结论吧!)
function Person(){
this.name = 'Billy';
this.age = 21;
}
let p = new Person();
console.log (p); //Person {name: 'Billy', age: 21}
ES中的构造函数是用于创建特定类型对象的,如Object和Array这样的原生构造函数,可以直接使用。当然使用像上面案例中的自定义构造函数也是很常见的。使用构造函数创建的对象有明确的标识。
这样看似创建的对象天衣无缝,既能批量创建、又有标识。但是如果使用构造函数创建对象需要在对象上捆绑方法时,就会存在极大缺陷。我们看下面的代码:
function Person() {
this.name = 'Billy';
this.age = 21;
this.sayName = function () {
console.log(this.name);
}
}
let p = new Person();
console.log(p); //Person {name: 'Billy', age: 21}
粗略一看,似乎没有任何问题,实际上构造函数定义的方法会在每个实例上都创建一遍,即每次定义函数时都会初始化一个对象。可以近似认为:
const Person = function(name,age){
this.name=name;
this.age=age;
this.sayName = new Function("console.log(this.name)");
}
const p1 = new Person('Billy',21);
const p2 = new Person('Mike',19);
console.log (p1); //Person {name: 'Billy', age: 21, sayName: ƒ}
console.log (P1.sayName===p2.sayName); //false
因此以这种方法添加的函数会带来不同的作用域链和标识符解析,不同实例上函数虽然同名但是却不相等。因为都是一样的效能,所以没有必要定义两个不同的函数。
要解决这个问题,可以把函数定义在全局,即从外部插入函数,这也就不会每次在生成一个新实例时都重新创建new一个函数了。
function sayName(){
console.log(this.name)
}
const Person = function(name,age){
this.name=name;
this.age=age;
this.sayName = sayName;
}
const p1 = new Person('Billy',21);
const p2 = new Person('Mike',19);
console.log (p1); //Person {name: 'Billy', age: 21, sayName: ƒ}
console.log (p1.sayName===p2.sayName); //true
这样创建的对象确实没有问题,但是大量的全局变量会污染全局作用域,仍是不妥的。反复讲述了创建对象时的缺陷,其实只是想要引出一个观点,就是原型很重要。使用原型可以彻底解决这个问题,我们会在后面讲到this和原型的一些基础概念。
3.5.2 对象属性的增删改查
属性添加
在JavaScript的世界中,对象的属性是灵活的,在对象中添加属性我们可以直接使用点 . 语法。
let obj = {
name:'Billy',
age:21
}
obj.gender = 'male';
console.log (obj); //{name: 'Billy', age: 21, gender: 'male'}
还有另外一种添加属性的方式,就是使用中括号 [] 语法。
const a = 'name'
let obj = new Object();
obj[a] = 'Billy';
console.log (obj); //{name: 'Billy'}
开发人员在动态添加对象属性的时候,更倾向于使用点方法,因为它更简单。但是如果遇到需要使用非字符串的数据作为属性的话,就只能选择中括号语法。中括号中可以穿任何类型的数据,甚至传递一个对象都是合法的。因为传递的数据会被toString()方法进行转换。
let a = 'name'
let b = 1;
let c = {
name:"test"
}
let obj = new Object();
obj[a] = 'Billy';
obj[b] = 'test1';
obj[c] = 'test2';
console.log (obj); //{1: 'test1', name: 'Billy', [object Object]: 'test2'}
值得注意的是,如果使用中括号语法进行属性添加,那么读取时也必须要使用中括号语法。
属性删除
js中,属性的删除需要使用操作符delete来进行实现。
let obj = {
x:1,
y:2
}
delete obj.x;
console.log (obj); //{y: 2}
使用delete删除属性,不是将属性设置为undefined,而是彻底清空属性,在使用for-in等方法进行枚举时将不会显示删除的属性。
tips:使用delete删除属性的性能饱受诟病,实际开发中我们很少使用。
属性修改
直接通过点语法同样能够实现属性的修改。
let obj = {
x:1,
y:2
}
obj.y = 10;
console.log (obj); //{x: 1, y: 10}
要修改的前提是这个属性原本不存在,如果原本存在那么就是实现的添加属性的操作。
属性查找
仍然是通过点语法和中括号语法。
let obj = {
x:1,
y:2
}
console.log (obj.y); //2
本节主要提及的增删改查的方式主要是点语法和中括号语法,增删改查的方式当然远不止这些,还有
Object.getOwnPropertyNames()、Object.keys()、Object.getOwnPropertyDescriptor()等等方法,这些都等着我们在后面的学习中进行深入的探索。
3.5.3 再也不怕this了(课上不会讲,但是感兴趣的话可以预习一下)
前面在构造函数创建对象的那儿我们看见了大量的this出现,但是我们却对this的概念仍然不是很清楚,只知道是代指一个实例。这节我们会简单讲解this的概念。
第一个问题:this是什么?
this就是指向当前代码运行时所处的上下文环境,在不同的上下文中,this的指向不同。
第二个也是最后一个问题:this怎么指?
【全局上下文】
全局上下文中,this指向顶层对象window。
console.log(this === window); //true
this.name = 'Billy';
console.log (window.name); //Billy
【函数上下文】
函数上下文中this的指向就要复杂很多。
普通函数调用,this指向window。
var val = 'global';
function test(){
console.log (this.val); //global
}
test();
普通方法调用,this指向调用函数的方法。 (方法也是普通的函数,只不过作为对象属性保存在对象中)
function sayName(){
console.log (this.name);
}
let obj = {
name:'Billy',
//sayName作为方法保存在对象属性中
sayName:sayName
}
//sayName作为obj的方法调用,所以sayName中的this指向obj,故能拿到obj中的name属性的值。
obj.sayName(); //Billy
构造函数调用,this指向新创建的对象。
function Person(){
this.name = 'Billy';
this.age = 21;
}
let p = new Person();
console.log (p); //Person {name: 'Billy', age: 21}
使用构造函数在进行对象实例化时,有一个关键步骤就是进行this的指向转换,将this指向转向将要创建的对象身上。所以this指向新创建的对象。
call、apply和bind调用,this指向传入的对象。
let obj = {
name:'Billy',
}
function sayName(){
Object.call(obj)
console.log (this.name); //Billy
}
sayName();
方法中传入谁,this就指向谁。上面案例中函数sayName原本应该指向window的this转为了指向对象obj,所以拿到了name属性的值。
箭头函数中的this始终指向父级元素。
var name = 'Global';
let p = {
name: 'Billy',
age:18,
sayName:()=>{
console.log (this.name); //Global
}
}
p.sayName();
箭头函数的自身是没有this的,箭头函数的 this其实使用的是父级元素的this, 所以箭头函数的this指向父级元素。这也是上面案例中打印this.name返回Global的原因,因为this指向window。
3.5.4 好玩的原型(课上不会讲,但是感兴趣的话可以预习一下)
每个函数在创建之初都有一个prototype属性,这个属性是一个对象,用来包含实例共享的属性和方法,在上面创建的方法所有的实例都可以共享,而就解决了使用构造函数创建的实例内部方法每次新创建一个实例都会重新创建一个函数的问题。
function Person(){
}
Person.prototype.name = 'Lucy';
Person.prototype.age = 18;
Person.prototype.sayName = function(){
console.log (this.name); //Lucy
};
let p1 = new Person();
let p2 = new Person();
p1.sayName();
console.log (p1.__proto__); //{name: 'Lucy', age: 18, sayName: ƒ, constructor: ƒ}
console.log (p1.sayName===p2.sayName); //true
这样绑定在原型上的方法就能被所有的实例共享到。
按照规定,只要创建一个函数,这个函数就会创建一个prototype属性(指向原型对象)。同时这个原型对象又会自动获得一个名为constructor的属性,这个属性指回构造函数。即:
构造函数 === 构造函数.prototype.constructor
//延续上面的代码
console.log (Person === Person.prototype.constructor); //true
在定义构造函数中,原型对象默认只会获得constructor属性,其余的方法都继承自Object。每次创建一个新实例,实例内部的[[prototype]]指针就会被赋值为构造函数的原型对象,我们可以通过__proto__属性访问到实例的原型对象。即:
实例.proto === 构造函数.prototype
//延续上面的代码
console.log (p1.__proto__ === Person.prototype); //true
tips:访问实例的原型对象的属性使用到了双短杠__,这样意味着不希望我们访问实例的原型,我们可以将访问实例的原型对象的方式称为隐式访问,将访问构造函数的原型对象的方式称为显式访问。
对于原型的概念,可能略有一点抽象,但是大家只需要牢记一点,函数可以显式访问prototype,实例等其他对象可以隐式访问__proto__ 。记住这一点,我们就再深一步去了解原型。
既然构造函数有一个原型对象,那么构造函数的原型对象也是对象,那它有原型吗?我们打印构造函数Person看看。
猜的没错,构造函数的原型对象也有原型对象,这个原型对象就是Object的原型。记得我们前面曾说过,构造函数除了constructor属性是自己的,其余的属性都是继承的Object,而Object是一个构造函数,Object构造函数的原型贮存着那些属性,所以构造函数的原型的原型就是对象的原型(有一点点绕,尽力理解就好)。
当然,到了这一层,我们的原型之旅的探索可以说就到此为止了,因为再往上层继续找原型,就是null了。
总结:
- 构造函数的原型和实例原型等同(但是构造函数和实例之间可以说是毫无关系)。
- 构造函数的原型的构造器(constructor)指回构造函数自身(形成一个环)。
- 构造函数的原型的原型(也是一个对象)指向Object(构造函数)的原型。
- 构造函数的原型的原型的原型是原型链的终点,返回null(有一点绕)。
原型链是原型对象创建过程的历史记录(就是像链条的那一串原型),在访问实例属性时,会先查找实例自身属性,如果没有,就沿着原型链去找,一直找到Object,如果Object都没有,这个属性就是不存在的,访问时会报错。
上面这张图非常有助于我们深入理解原型!
原型的知识当然远不止这些,这节我们浅浅了解一下原型的基础知识就好啦,但是后面一定还要深入去学,因为原型的知识在面试中出现的频率相当高(虽然实际开发中我们使用的频率不高)。
3.6 Object的两个儿子 👨👦👦
我们在使用typeof进行数据类型检测时,除了基本数据类型会返回基本数据类型的结果,按照前面我们在3.3节中所给出的八种数据类型的划分,难道其他的都是返回Object结果吗?其实不然,在检测函数(Function)时,它是独立的,虽然都是Object。我们看下面的代码。
// 基本数据类型
const num = 1;
let flag = true;
const str = 'string';
const u = undefined;
const n = null;
const s = Symbol();
const big = BigInt('99999999999999');
// 引用数据类型
let obj = {
}
// 创建一个函数
function test(){
}
// 创建一个数组
const arr = [];
console.log (typeof num); //number
console.log (typeof flag); //boolean
console.log (typeof str); //string
console.log (typeof u); //undefined
console.log (typeof n); //object (版本遗留问题)
console.log (typeof s); //symbol
console.log (typeof big); //bigint
console.log (typeof obj); //object
console.log (typeof test); //function
console.log (typeof arr); //object
函数是一个对象,毋庸置疑,但是返回的结果却是function,这是因为函数属于对象子类型。在JavaScript的世界中,除了Function,还有另一个对象子类型,就是数组(Array)。
const arr = [];
console.log (arr instanceof Array); //true
显然数组也是有自己的类型的,因此这两种类型(Function和Array)都是对象子类型,我们可以把它们称为Object的儿子们。下面我们会详细介绍它们。
数组
数组是一组有序的数据,在JavaScript的世界中,数组可以存储任何类型的数据且数组可以动态添加,自动增长(太舒服了)。
3.6.1 创建数组
创建数组的常见方法有三种,一种是使用构造函数Array()来创建。
let animals = new Array(3); //创建一个包含三个元素的数组
let cars = new Array('benz','BMW'); //创建一个包含两个元素的数组,分别保存字符串benz和BMW
还有一种方法是使用数组字面量。
let arr = ['benz','BMW',1,2,3];
使用数组字面量的方式创建数组是使用的最多的,因为简单方便。
最后一种方式是使用ES6新增的方法from()来创建。
let arr = ['benz','BMW',1,2,3];
let copyArr = Array.from(arr);
console.log (copyArr); //['benz', 'BMW', 1, 2, 3]
可以看出,使用Array.from()实际上是通过传入一个数组,对一个数组执行浅复制来创建的,这种创建对象的方式不太常用。
3.6.2 数组空位
使用数组字面量初始化数组时,可以使用一串逗号来创建空位。
let a1 =[,,,,,];
console.log(a1.length); //5
console.log(a1[0]); //undefined
在JavaScript的世界中,这种空位是被认可为存在的元素的,只不过值为undefined。
3.6.3 数组索引
要取得或设置数组的值,需要使用中括号并提供数组的索引。
let arr = ['red','yellow','blue'];
console.log (arr[1]); //yellow
中括号中提供的索引表示要访问的对应的值。值得注意的有两点:
第一点是数组索引在读取和设置时都是从0开始的,这一点对于很多第一次学习编程语言的同学来说会很不习惯,但是在其他编程语言如c、c++里面都是一样的。
第二点是如果写入的索引超过了数组的最大索引,那么数组长度会自动扩展到索引的位置,不会报错。这一点和别的编程语言就不同了。
let arr = ['red','yellow','blue'];
console.log (arr[10]); //undefined
arr[0] = 'black;
console.log(arr) //['black', 'yellow', 'blue']
数组中的元素的数量保存在length属性中。
let arr = ['red','yellow','blue'];
console.log(arr.length); //3
使用length有个小技巧,就是通过它可以向数组末尾添加元素。
let arr = ['red','yellow','blue'];
arr[arr.length] = 'black';
console.log(arr); //['red', 'yellow', 'blue', 'black']
3.6.4 检测数组
检测数组的方式有两种,可以通过instanceof操作符和isArray()方法来检测。
let arr = ['red','yellow','blue'];
console.log (arr instanceof Array); //true
console.log (Array.isArray(arr)); //true
3.6.5 数组方法(重要)
前面提到过,前端的核心工作就是处理数据,渲染数据,因此对于数组中数据的熟练处理至关重要,要处理这些数据,就必须要用到数组的方法(api)。
数组元素的增删
- push:向数组末尾添加一个或多个元素,并返回数组的新的长度。
语法:arr. push(insertData1, insertData2, ......)
- pop:删除数组末尾的最后一个元素,并将被删除元素作为返回值返回。
语法:arr.pop()
- shift:删除数组开头一个或多个元素,并返回新的数组长。
语法:arr.shift()
- unshift:向数组末尾添加一个或多个元素。
语法:arr. unshift(insertData1, insertData2, ......)
let arr = ['red','yellow','blue'];
//向末尾添加元素last
arr.push('last');
//向开头添加元素first
arr.unshift('first');
console.log (arr); //['first', 'red', 'yellow', 'blue', 'last']
//删除末尾元素last
arr.pop();
//删除开头元素first
arr.shift();
console.log (arr); //['red', 'yellow', 'blue']
数组的循环
- forEach() 用于代替普通for循环的方法,但是用起来比for循环更方便。
语法:arr.forEach(callback);
callback默认有三个参数,分别为遍历的值,索引,数组自身。
- map() 同样是用于实现数组的循环的,但是相比于forEach更常用。
语法:arr.map(callback);
callback默认有三个参数,分别为遍历的值,索引,数组自身。
- filter() 用于过滤一些不合格“元素”, 如果回调函数返回true,该元素就留下来。
语法:arr.filter(callback);
callback默认有三个参数,分别为遍历的值,索引,数组自身。
- reduce() 可以用来计算数组的和或者阶层。
语法:arr.reduce(callback);
callback默认有三个参数,分别为累加器,当前遍历的元素,索引。
- find() 找到第一个符合条件的数组成员,如果没有找到,返回undefined
语法:arr.find(callback);
callback默认有三个参数,分别为遍历的值,索引,数组自身。
- ...
/**
* forEach
*/
let arr = ['red','yellow','blue'];
arr.forEach(function(val, index, arr){
console.log(val, index, arr);
});
//arr中有三个元素,循环三次,方法内传入的回调可以设置三个参数,遍历的值、索引、和遍历的数组。
//red 0 (3) ['red', 'yellow', 'blue']
//yellow 1 (3) ['red', 'yellow', 'blue']
//blue 2 (3) ['red', 'yellow', 'blue']
/**
* map
*/
arr.map((item, index, arr)=>{
console.log (item, index, arr);
})
//map和forEach几乎一样,但是map在开发中更常见
//red 0 (3) ['red', 'yellow', 'blue']
//yellow 1 (3) ['red', 'yellow', 'blue']
//blue 2 (3) ['red', 'yellow', 'blue']
/**
* filter
*/
const res = arr.filter((val)=>{
return val === 'red'
})
console.log (res);
//通过判断条件过滤出数组中符合条件的项并以数组形式返回
//['red']
/**
* reduce
*/
let res = arr.reduce((total, cur, index) =>{
return total+cur;
});
console.log (res);
//reduce作为参数的回调函数中可以设置累加器,当前遍历的元素,和索引三个参数,使用reduce可以实现用来计算数组的和或者阶层。
//reduce和react中的redux有异曲同工之妙,建议在学习redux之前一定要将reduce理解透彻
//打印的结果做了一个字符串的拼接,就为:
//redyellowblue
/**
* find
*/
let res = arr.find((item)=>{
return item === 'red';
})
console.log (res);
//和filter不同,find方法只会返回匹配的值,所以值为:
//red
数组元素的截取
- slice可从已有的数组中返回选定的元素。
语法:arr.slice(startIndex, endIndex)
slice方法不会改变原数组,会将截取到的新数组返回。
- splice() 强大的api,可以实现数组元素的插入、删除、替换。
语法:arr.splice( start,num,data1,data2,... )
值得注意的是,和slice不同,splice会直接对原数组造成影响,并且传入的参数的意义也完全不同。需要我们进行区分。
slice的第一个参数为截取数组的起始位置索引,第二个参数为结束位置的索引,而splice的第一个参数为截取数组的起始位置索引,第二参数为删除的数量,后面的参数为添加的元素。
let arr = ['red','yellow','blue','black','blue'];
console.log (arr.slice(1,3)); //['yellow', 'blue'];
arr.splice(1,3);
console.log (arr); //['red', 'blue']
//使用splice实现插入
let arr2 = [1,2,3,4,5];
//splice的第一个槽位插入数组末尾的索引(这个位置我们用来准备插入元素),接着第二个
//槽位设置为0,表示删除0个元素,第三个参数设置为数组索引+1作为插入值插入。
arr2.splice(arr2.length,0,arr2.length+1);
console.log (arr2); //[1, 2, 3, 4, 5, 6]
//使用splice实现删除
arr2.splice(arr2.length-1,1);
console.log (arr2); //[1, 2, 3, 4, 5]
//使用splice实现替换
arr2.splice(arr2.length-1,1,'add');
console.log (arr2); //[1, 2, 3, 4, 'add']
//删除掉元素然后再在这个位置插入新元素就可以做到替换
- 数组的拼接
concat() 用于实现一个或多个数组的拼接
语法:arr1.concat(arr2);
let arr1 = [1,2,3];
let arr2 = [4,5,6];
console.log (arr1.concat(arr2)); //[1, 2, 3, 4, 5, 6]
- 数组转字符串
join() 将数组中的所有元素放入一个字符串,并返回这个字符串。
语法:arr.join('可选字符串');
join方法中的参数是有可选的,不设置则默认以 , 隔开,设置了就以设置的字符串隔开
let arr1 = [1,2,3];
let arr2 = [4,5,6];
// console.log (arr1.concat(arr2));
console.log (arr1.join()); //1,2,3
console.log (arr2.join('@')); //4@5@6
- toString() 将数组中所有元素都放入一个字符串并返回这个字符串。
语法:arr.toString();
toString方法类似于没有参数的join方法。
let arr1 = [1,2,3];
let arr2 = [4,5,6];
console.log (arr1.toString()); //1,2,3
console.log (arr2.toString()); //4,5,6
数组的排序
- reverse() 颠倒数组排列顺序
语法:arr.reverse()
reverse虽然可以将数组顺序颠倒,但是不如sort灵活。
let arr = [1,2,3,4,5];
console.log (arr.reverse()); //[5, 4, 3, 2, 1]
- sort() 重新排列数组元素,最小的值排在前面,最大的值排在后面
语法:arr.sort()
sort()会在每一项上调用String()转型函数,然后比较字符串来决定顺序(按照Unicode编码来排)。这样在比较时会存在隐患。
let arr = [1,2,3,4,5,10];
arr.sort();
console.log (arr); //[1, 10, 2, 3, 4, 5]
//显然这不是理想的排列结果,10应该放置于数组末尾,但是字符串"10"在字符串"2"之前,所以会这样排
要解决这个问题,我们需要手写一个比较函数,然后传入sort()中。
let arr = [1,5,10,7,3];
function compare(val1,val2){
if(val1>val2){
return 1;
}
else if(val1<val2){
return -1;
}
else{
return 0;
}
}
arr.sort(compare);
console.log (arr); //[1, 3, 5, 7, 10]
3.6.6 追根溯源之数组遍历
我们在上面已经了解了数组的大致内容,但是对于数组为何能够遍历,大家一定都充满了疑惑,在这儿我们就简单介绍一下数组遍历的原理。
要理解数组为何能够遍历,就必须要认识一个概念——迭代。
何为迭代?迭代就是重复执行某段程序,显然我们遍历数组就是一种迭代。
能够实现迭代的对象,身上一定要有支持迭代的接口,这个接口暴露一个Symbol.iterator属性,只有当对象拥有Symbol.iterator这一属性,这一对象才可以实现迭代。
因此小结看来,正是因为数组的身上有Symbol.iterator这一属性,所以才能遍历。
let arr = [];
console.log (arr.__proto__.hasOwnProperty(Symbol.iterator)); //true
这里我们浅浅了解了一下迭代这个概念,如果想要深入迭代,也可以看看我的另一篇文章:javascriptRemake之深入迭代。
函数
函数是JavaScript中最有意思的部分之一,主要是因为函数也是对象。
3.6.7 函数的创建
我们可以使用四方式来创建函数,函数声明、函数表达式、箭头函数和Function构造函数。
在js中,使用函数声明的方式来创建函数是最频繁的。
语法:function 函数名(参数1,参数2){ 函数体 }
function sum(num1,num2){
return num1+num2;
}
const res = sum(1,2);
console.log (res); //3
另一种创建函数的方式是使用函数表达式来创建。
语法:let 变量 = function(参数1,参数2){ 函数体 }
//赋值操作右边的函数因为没有名称,所以我们称它为匿名函数
let sum = function(num1,num2){
return num1+num2;
}
const res = sum(1,2);
console.log (res); //3
还有一种和函数表达式很相似的创建函数的方式,我们称为箭头函数。
语法:let 变量 = (参数1,参数2)=>{ 函数体 }
let sum = (num1,num2)=>{
return num1+num2;
}
const res = sum(1,2);
console.log (res); //3
最后一种方法是使用构造函数,就和创建一个对象、数组相似。这种方式并不常用而且会影响性能。
let sum = new Function("num1","num2","return num1+num2");
const res = sum(1,2);
console.log (res); //3
使用最后一种方法会影响性能的原因在于创建的过程中代码会执行两次,第一次是将它作为常规代码,第二次是解释传给构造的字符串。但是把函数想象成对象,把函数名想成指针的思想非常重要,最后一种方法就是这种概念的经典诠释。
3.6.8 箭头函数(后面会有学姐来讲哦,可以预习一下)
ES6新增了箭头语法(=>)定义函数表达式的能力,从某种程度上讲,箭头函数实例化的函数对象和正式的函数表达式创建的函数对象行为相同。任何使用函数表达式的地方都可以使用箭头函数。
let arrowSum = (num1,num2)=>{
return num1+num2;
}
function fnSum(num1,num2){
return num1+num2;
}
console.log (arrowSum(2,3)); //5
console.log (fnSum(3,5)); //8
箭头函数因为写法简单,经常用于作为参数嵌入函数作为回调,我们在上面数组的方法中看到了大量需要函数作为回调的情况,使用箭头函数就非常适合。
let arr = [1,2,3,4,5,6];
arr.map((item)=>{
console.log (item); //1 //2 //3 //4 //5 //6
})
let filterItem = arr.filter((item)=>{
return item == 1;
})
console.log (filterItem); //[1]
对于箭头函数,需要牢记几个重要的特性:
1、箭头函数不能使用arguments、super和new.target,也不能作为构造函数。
2、箭头函数没有prototype属性。
3.6.9 函数名
大多数的函数都是有自己的名字的,这个名字和函数的功能息息相关,没有名字的函数被称为匿名函数,箭头函数就是经典的匿名函数。
函数名是一个指向函数的指针,所以它跟其他包含对象指针的变量具有相同的行为。这也就意味着一个函数可以有多个名称。
function sum(a, b) {
return a + b;
}
let copySum = sum;
let res1 = sum(1,2);
let res2 = copySum(2,3);
console.log ('res1=',res1); //res1=3
console.log ('res2=',res2); //res2=5
//就算将原来指向函数的指针置为空,但是函数仍然存在。所以副本函数还是可以运行。
sum = null;
console.log (copySum(5,6)); //11
3.6.10 实参们的集合——arguments
JavaScript的世界中,传递参数是非常的自由的,任何的数据类型、任何的参数数量都是合法的,就算设置两个形参,只传入一个实参或者甚至不传都不会报错。出现这种情况的原因是因为传入函数的参数在函数内部会被接收并汇集成一个类数组(类数组和数组相似,但却不具备数组的api),而函数并不关心这个类数组中包含什么,所以传递参数非常自由。这个类数组,就是赫赫有名的arguments对象。
函数在调用时,浏览器都会传两个隐藏的参数,一个是this,另一个就是arguments,通过在函数内部打印arguments对象可以显示传入函数的实参,当然也是以类数组的形式返回。
function sum(a, b) {
console.log (arguments);//Arguments(5) [1, 2, 3, 4, 5, callee: ƒ, Symbol(Symbol.iterator): ƒ]
return a + b;
}
sum(1,2,3,4,5); //显然传入的实参超过了设置的形参,但运行正常
3.6.11 函数的属性和方法
每个函数都有两个属性,length和prototype。
prototype的知识前面我们已经讲过,而对于length想必大家都充满了疑惑,函数有啥length呢,不应该只有数组才有吗?确实函数中是由length的情况较为少见,它是用来保存函数定义的形参个数的。
function sum(a, b) {
return a + b;
}
function test1(a) {
return a;
}
function test2() {
return;
}
console.log (sum.length); //2
console.log (test1.length); //1
console.log (test2.length); //0
函数的方法就不想数组那么多了,就只有两个:call和apply。用来转函数体内this的指向的。this会指向传入小括号内的对象。
call的语法:函数名.call(对象,Data1,Data2,...);
apply的语法:函数名.apply(对象,[Data1,Data2,...]);
let obj = {
name: "Billy",
age: 21
}
function test(a,b) {
console.log ('a=',a,'b=',b);
console.log(this);
}
test(); //a= undefined b= undefined //Window {window: Window, self: Window, …}
test.call(obj,1,2); //a= 1 b= 2 //{name: 'Billy', age: 21}
test.apply(window,[3,4]); //a= 3 b= 4 //Window {window: Window, self: Window, …}
值得注意的是,在使用call和apply的时候,函数内部的代码执行一次。
3.6.12 立即调用函数
立即调用函数又称立即调用的匿名函数(IIFE,Immediately Involked Function Expression,需要要记得英文的简写形式)。
基本语法:
//基本语法
(function(){
块级作用域
})()
//运用
(function(){
console.log (111); //111
})()
使用立即执行函数可以有效防止变量外泄,这一点运用得非常广泛(比如webpack的底层)。
四、推荐的资源
推荐的学习资料有(难度从易到难,建议按顺序来):
【尚硅谷】JavaScript基础&实战 推荐指数:⭐⭐⭐⭐⭐⭐
廖雪峰老师的JavaScript教程 推荐指数:⭐⭐⭐⭐
JavaScript高级程序设计(第4版).pdf 推荐指数:⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
你不知道的JavaScript 中_LT.pdf 推荐指数:⭐⭐⭐⭐⭐