五、JavaScript相关面试题讲解
(一)let、var、const 的区别
1.经典真题
- let const var 的区别?什么是块级作用域?如何用?
2.声明变量关键字汇总
- 在 JavaScript 中,一共存在 3 种声明变量的方式
- var
- let
- const
- 这是历史原因造成的
- 最初声明变量的关键字就是 var
- 但是为了解决作用域的问题,新增了 let 和 const 的方式
3.作用域
- ES5 中的作用域有:全局作用域、函数作用域
- ES6 中新增了块级作用域
- 块作用域由
{ }
包括 - if 语句和 for 语句里面的
{ }
也属于块作用域
- 块作用域由
4.var 关键字
1)没有块级作用域的概念
// Global Scope
{
var a = 10;
console.log(a); // 10
}
console.log(a); // 10
2)有全局作用域、函数作用域的概念
// Global Scope
var a = 10;
function checkScope() {
// Local Scope
var b = 20;
console.log(a); //10
console.log(b); //20
}
checkScope();
console.log(b); // ReferenceError: b is not defined
3)不初始化值默认为 undefined
// Global Scope
var a;
console.log(a); // undefined
4)存在变量提升
// Global Scope
console.log(a); // undefined
var a = 10;
checkScope();
function checkScope() {
// Local Scope
console.log(a); // undefined
var a;
}
- 变量提升是因为 js 需要经历编译和执行阶段
- 而 js 在编译阶段的时候,会搜集所有的变量声明并且提前声明变量
5)全局作用域用 var 声明的变量会挂载到 window 对象下
// Global Scope
var a = 10;
console.log(a); // 10
console.log(window.a); // 10
console.log(this.a); // 10
6)同一作用域中允许重复声明
// Global Scope
var a = 10;
var a = 20;
console.log(a); // 20
checkScope();
function checkScope() {
// Local Scope
var b = 10;
var b = 20;
console.log(b); // 20
}
5.let 关键字
1)有块级作用域的概念
{
// Block Scope
let a = 10;
}
console.log(a); // ReferenceError: a is not defined
2)不存在变量提升
{
// Block Scope
console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 10;
}
3)暂时性死区
{
// Block Scope
console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 20;
}
if (true) {
// TDZ开始
console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a; // TDZ结束
console.log(a); // undefined
a = 123;
console.log(a); // 123
}
ES6 标准中对 let/const 声明中的解释 第 13 章,有如下一段文字:
The variables are created when their containing Lexical Environment is instantiated but may not be accessed in any way until the variable’s LexicalBinding is evaluated.
当程序的控制流程在新的作用域(module、function 或 block 作用域)进行实例化时,在此作用域中用 let/const 声明的变量会先在作用域中被创建出来,但因此时还未进行词法绑定,所以是不能被访问的,如果访问就会抛出错误。因此,在这运行流程进入作用域创建变量,到变量可以被访问之间的这一段时间,就称之为暂时死区
简单理解
ES6 规定,let/const 命令会使区块形成封闭的作用域。若在声明之前使用变量,就会报错
总之,在代码块内,使用 let/const 命令声明变量之前,该变量都是不可用的
这在语法上,称为 暂时性死区(Temporal Dead Zone,TDZ)
- 上面不存在变量提升的例子中,其实也是暂时性死区
- 因为它有暂时性死区的概念,所以就不存在变量提升了
4)同一块作用域中不允许重复声明
{
// Block Scope
let A;
var A; // SyntaxError: Identifier 'A' has already been declared
}
{
// Block Scope
var A;
let A; // SyntaxError: Identifier 'A' has already been declared
}
{
// Block Scope
let A;
let A; // SyntaxError: Identifier 'A' has already been declared
}
6.const 关键字
1)必须立即初始化,不能留到以后赋值
// Block Scope
const a; // SyntaxError: Missing initializer in const declaration }
2)常量的值不能改变
// Block Scope
{
const a = 10;
a = 20; // TypeError: Assignment to constant variable
}
- const 保证的并不是变量的值不得改动,而是变量指向的那个 内存地址 所保存的数据不得改动
7.特点总结
1)var 关键字
- 没有块级作用域的概念
- 有全局作用域、函数作用域的概念
- 不初始化值默认为 undefined
- 存在变量提升
- 全局作用域用 var 声明的变量会挂载到 window 对象下
- 同一作用域中允许重复声明
2)let 关键字
- 有块级作用域的概念
- 不存在变量提升
- 暂时性死区
- 同一块作用域中不允许重复声明
3)const 关键字
- 与 let 特性一样,仅有 2 个差别
- 区别 1:必须立即初始化,不能留到以后赋值
- 区别 2:常量的值不能改变
8.真题解答
1)let const var 的区别?什么是块级作用域?如何用?
- var 定义的变量,没有块的概念,可以跨块访问, 不能跨函数访问,有变量提升
- let 定义的变量,只能在块作用域里访问,不能跨块访问,也不能跨函数访问,无变量提升,不可以重复声明
- const 用来定义常量,使用时必须初始化(即必须赋值),只能在块作用域里访问,而且不能修改,无变量提升,不可以重复声明
最初在 JS 中作用域有:全局作用域、函数作用域。没有块作用域的概念
ES6 中新增了块级作用域。块作用域由 { } 包括,if 语句和 for 语句里面的 { } 也属于块作用域
在以前没有块作用域的时候,在 if 或者 for 循环中声明的变量会泄露成全局变量,其次就是 { } 中的内层变量可能会覆盖外层变量。块级作用域的出现解决了这些问题
(二)值和引用
1.经典真题
- JS 的基本数据类型有哪些?基本数据类型和引用数据类型的区别
2.JS 中的数据类型
- 在 JavaScript 中,数据类型整体上来讲可以分为基本类型和引用数据类型
1)基本数据类型
- 又被称为原始值或简单值
- 一共有 6 种
- string
- symbol
- number
- boolean
- undefined
- null
- 其中 symbol 类型是在 ES6 里面新添加的基本数据类型
2)引用数据类型
- 又被称为复杂值或引用值
- 只有 1 种
- object
- 包括数组、对象、函数
3.简单值(原始值)
- 是表示 JavaScript 中可用的数据或信息的最底层形式或最简单形式
- 简单类型的值被称为简单值,是因为它们是 不可细化 的
- 数字是数字
- 字符串是字符串
- 布尔值是 true 或 false
- null 和 undefined 就是 null 和 undefined
- 由于简单值的数据大小是固定的,所以简单值的数据是存储于 内存中的栈区
var str = "Hello World";
var num = 10;
var bol = true;
var myNull = null;
var undef = undefined;
console.log(typeof str); // string
console.log(typeof num); // number
console.log(typeof bol); // boolean
console.log(typeof myNull); // object
console.log(typeof undef); // undefined
1)null 是 object 类型
- 这是历史原因所遗留下来的问题
- 来源于 JavaScript 从第一个版本开始时的一个 bug,并且这个 bug 无法被修复,因为修复会破坏现有的代码
- 因为不同的对象在底层都表现为二进制,在 JavaScript 中,如果二进制前三位都为 0 则会被判断为 object 类型,null 的二进制全部为 0,自然前三位也是 0,所以执行 typeof 值会返回 object
例外,打印
null == undefined
时,返回的是 true,这也是面试时经常会被问到的一个问题这两个值都表示“无”的意思
2)null 和 undefined
- 通常情况下, 访问某个不存在的或者没有赋值的变量时,就会得到一个 undefined 值
- JavaScript 会自动将声明时没有进行初始化的变量设为 undefined
- 而 null 值表示空,null 不能通过 JavaScript 来自动赋值
- 即:必须要手动给某个变量赋值为 null
为什么 JavaScript 要设置两个表示"无"的值呢?
这其实也是因为历史原因
1995 年 JavaScript 诞生时,最初像 Java 一样,只设置了 null 作为表示"无"的值。根据 C 语言的传统,null 被设计成可以自动转为 0
但是,JavaScript 的设计者,觉得这样做还不够,主要有以下两个原因
- null 像在 Java 里一样,被当成一个对象。但是,JavaScript 的数据类型分成原始类型(primitive)和复合类型(complex)两大类,作者觉得表示“无”的值最好不是对象
- JavaScript 的最初版本没有包括错误处理机制,发生数据类型不匹配时,往往是自动转换类型或者默默地失败。作者觉得,如果 null 自动转为 0,很不容易发现错误
因此,作者又设计了一个 undefined,先有 null 后有 undefined,是为了填补之前的坑
- null 是一个表示“无”的 对象(空对象指针) ,转为数值时为 0
- 典型用法
- 作为函数的参数,表示该函数的参数不是对象
- 作为对象原型链的终点
- undefined 是一个表示"无"的 原始值,转为数值时为 NaN
- 典型用法
- 变量被声明但没有赋值时,就等于 undefined
- 调用函数时,应该提供的参数没有提供,该参数等于 undefined
- 对象没有赋值的属性,该属性的值为 undefined
- 函数没有返回值时,默认返回 undefined
4.复杂值(引用值)
- 在 JavaScript 中,对象就是一个复杂值
- 因为对象可以向下拆分,拆分成多个简单值或者复杂值
- 复杂值在内存中的 大小是未知的
- 因为复杂值可以包含任何值,而不是一个特定的已知值,所以复杂值的数据都是存储于 堆区
// 简单值
var a1 = 0;
var a2 = "this is str";
var a3 = null;
// 复杂值
var c = [1, 2, 3];
var d = {
m: 20,
};
5.访问方式
1)按值访问
- 简单值是作为不可细化的值进行存储和使用的
- 引用它们会转移其值(值传递)
var str = "Hello";
var str2 = str;
str = null;
console.log(str, str2); // null "Hello"
2)引用访问
- 复杂值是通过引用进行存储和操作的,而不是实际的值
- 创建一个包含复杂对象的变量时,其值是内存中的一个引用地址
- 引用一个复杂对象时,使用它的名称(即变量或对象属性)通过内存中的引用地址获取该对象值(地址传递)
var obj = {};
var obj2 = obj;
obj.name = "zhangsan";
console.log(obj.name); // zhangsan
console.log(obj2.name); // zhangsan
6.比较方式
- 简单值采用值比较,而复杂值采用引用比较
- 复杂值只有在引用相同的对象(即有相同的地址)时才相等
- 即使是包含相同对象的两个变量也彼此不相等
- 因为它们并不指向同一个对象
1)示例 1
var a = 10;
var b = 10;
var c = new Number(10);
var d = c;
console.log(a === b); // true
console.log(a === c); // false
console.log(a === c); // false
console.log(a == c); // true
d = 10;
console.log(d == c); // true
console.log(d === c); // false
2)示例 2
var obj = {
name: "zhangsan",
};
var obj2 = {
name: "zhangsan",
};
console.log(obj == obj2); // false
console.log(obj === obj2); // false
var obj3 = {
name: "zhangsan",
};
var obj4 = obj3;
console.log(obj3 == obj4); // true
console.log(obj3 === obj4); // ture
7.动态属性
- 可以为复杂值添加、修改、删除属性和方法
- 但简单值不可以
var str = "test";
str.abc = true;
console.log(str.abc); // undefined
var obj = {};
obj.abc = true;
console.log(obj.abc); // true
- 复杂值支持动态对象属性
- 因为可以定义对象,然后创建引用,再更新对象,并且所有指向该对象的变量都会获得更新
- 一个新变量指向现有的复杂对象,并没有复制该对象
- 这就是复杂值有时被称为引用值的原因
- 复杂值可以根据需求有任意多个引用,即使对象改变,它们也总是指向同一个对象
var obj = {
name: "zhangsan",
};
var obj2 = obj;
var obj3 = obj2;
obj.name = "abc";
console.log(obj.name, obj2.name, obj3.name);
// abc abc abc
8.变量赋值
- 可以分为直接赋值和引用赋值
1)直接赋值
- 将简单值赋值给变量
var a = 3;
var b = a;
b = 5;
console.log(a); // 3
2)引用赋值
- 将一个复杂值的引用赋值给变量,这个引用指向堆区实际存在的数据
var a = {
value : 1
};
var b = a;
b.value = 10;
console.log(a.value); // 10
9.真题解答
1)JS 的基本数据类型有哪些?基本数据类型和引用数据类型的区别
在 JavaScript 中,数据类型整体上来讲可以分为两大类:基本类型和引用数据类型
- 基本数据类型,一共有 6 种:
string,symbol,number,boolean,undefined,null
其中 symbol 类型是在 ES6 里面新添加的基本数据类型
- 引用数据类型,就只有 1 种:
object
基本数据类型的值又被称之为原始值或简单值,而引用数据类型的值又被称之为复杂值或引用值
- 两者的区别在于:
原始值是表示 JavaScript 中可用的数据或信息的最底层形式或最简单形式。被称为原始值是因为它们不可细化
也就是说,数字是数字,字符是字符,布尔值是 true 或 false,null 和 undefined 就是 null 和 undefined。这些值本身很简单,不能够再进行拆分。由于原始值的数据大小是固定的,所以原始值的数据是存储于内存中的栈区里面的
在 JavaScript 中,对象就是一个引用值。因为对象可以向下拆分,拆分成多个简单值或者复杂值。引用值在内存中的大小是未知的,因为引用值可以包含任何值,而不是一个特定的已知值,所以引用值的数据都是存储于堆区里面
最后总结一下两者的区别:
- 访问方式
- 原始值:访问到的是值
- 引用值:访问到的是引用地址
- 比较方式
- 原始值:比较的是值
- 引用值:比较的是地址
- 动态属性
- 原始值:无法添加动态属性
- 引用值:可以添加动态属性
- 变量赋值
- 原始值:赋值的是值
- 引用值:赋值的是地址
(三)包装类型
1.经典真题
- 是否了解 JavaScript 中的包装类型?
2.包装类型
1)背景
- 按照最新 ES 标准定义
- 基本数据类型(primitive value)包括 undefined、null、boolean、number、symbol、string
- 引用类型包括 Object、Array、Date、RegExp
- 这两个类型其中一个很明显的区别
- 引用类型有自己内置的方法,也可以自定义其他方法用来操作数据
- 而基本数据类型不能像引用类型那样有自己的内置方法对数据进行更多的操作
2)原理
- ES 为 3 个基本数据类型提供了对应的特殊引用类型(包装类型)
- Boolean、Number、String
- 基本包装类型和其他引用类型一样,拥有内置的方法可以对数据进行额外操作
var str = "hello"; // string 基本类型
var s2 = str.charAt(0);
console.log(s2); // h
- string 是一个基本类型,却能调用 charAt 的方法
- 是因为在执行第二行代码时,后台会自动进行下面的步骤
- 自动创建 String 类型的一个实例
- 和基本类型的值不同,这个实例就是一个基本包装类型的对象
- 调用实例(对象)上指定的方法
- 销毁这个实例
- 自动创建 String 类型的一个实例
// 平常写程序的过程
var str = "hello"; // string 基本类型
var s2 = str.charAt(0); // 执行到这一句时,后台会自动完成以下动作
{
// 1.找到对应的包装对象类型,然后通过包装对象创建出一个和基本类型值相同的对象
var _str = new String("hello");
// 2.然后这个对象就可以调用包装对象下的方法,并且返回结果给 s2
var s2 = _str.charAt(0);
// 3.之后这个临时创建的对象就被销毁了
_str = null;
}
console.log(s2); // h
console.log(str); // hello
- 基本类型的值虽然没有方法可以调用,但是后台临时创建的包装对象上有内置方法可以调用
- 可以对字符串、数值、布尔值这三种基本数据类型的数据进行更多操作
- 后台什么时候自动创建一个对应的基本包装类型的对象
- 取决于当前执行的代码是否是为了 获取值
- 即: 读取一个基本类型的值/需要从内存中获取值
- 这个访问过程称为读取模式
var test = "hhh";
console.log(test); // 读取模式,后台自动创建基本包装类型对象
var test2 = test; // 赋值给变量 test2,也需要读取 test 的值,同上
3)区别
- 基本包装类型的对象和引用类型的对象最大的一个区别
- 对象的生存期 不同
- 导致基本包装类型无法自定义自己的方法
- 对于引用类型的数据,在执行流离开当前作用域之前都会保存在内存中
- 对于自动创建的基本包装类型的对象,只存在于一行代码的执行瞬间,执行完毕就会立即被销毁
var str = "test";
str.test = "hhh";
console.log(str.test); // undefined
上面第二行代码给自动创建的 String 实例对象添加了 test 属性
虽然此刻代码执行时是生效的,但是在这行代码执行完毕后该 String 实例就会立刻被销毁,String 实例的 test 属性也就不存在了
当执行第三行代码时,由于是读取模式,又重新创建了新的 String 实例,而这个新创建的 String 实例没有 test 属性,结果就是 undefined
var str = "hello";
str.number = 10; // 假设我们想给字符串添加一个属性 number ,后台会有如下步骤
{
// 1.找到对应的包装对象类型,然后通过包装对象创建出一个和基本类型值相同的对象
var _str = new String("hello");
// 2.通过这个对象调用包装对象下的方法 但结果并没有被任何东西保存
_str.number = 10;
// 3.这个对象又被销毁
_str = null;
}
// undefined 当执行到这一句的时候,因为基本类型本来没有属性,后台又会重新重复上面的步骤
console.log(str.number);
{
// 1.找到基本包装对象,然后又新开辟一个内存,创建一个值为 hello 对象
var str = new String("hello");
// 2.因为包装对象下面没有 number 这个属性,所以又会重新添加,因为没有值,所以值是未定义;然后弹出结果
str.number = undefined;
// 3.这个对象又被销毁
str = null;
}
4)添加属性和方法
- 在基本包装对象的原型下面添加,每个对象都有原型
// 给字符串添加方法 要写到对应的包装对象的原型下才行
var str = "hello";
String.prototype.last = function () {
return this.charAt(this.length);
};
// 执行到这一句,后台依然会偷偷的干这些事
str.last();
{
// 找到基本包装对象,new一个和字符串值相同的对象,
var _str = new String("hello");
// 通过这个对象找到了包装对象下的方法并调用
_str.last();
// 这个对象被销毁
_str = null;
}
3.真题解答
1)是否了解 JavaScript 中的包装类型?
包装对象,就是当基本类型以对象的方式去使用时,JavaScript 会转换成对应的包装类型,相当于 new 一个对象,内容和基本类型的内容一样,然后当操作完成再去访问的时候,这个临时对象会被销毁,然后再访问时候就是 undefined
number、string、boolean 都有对应的包装类型
因为有了基本包装类型,所以 JavaScript 中的基本类型值可以被当作对象来访问
基本类型特征:
- 每个包装类型都映射到同名的基本类型
- 在读取模式下访问基本类型值时,就会创建对应的基本包装类型的一个对象,从而方便了数据操作
- 操作基本类型值的语句一经执行完毕,就会立即销毁新创建的包装对象
(四)数据类型的转换
1.经典真题
- JavaScript 中如何进行数据类型的转换?
2.数据类型转换介绍
- JavaScript 是一种动态类型语言
- 变量没有类型限制,可以随时赋予任意值
var x = y ? 1 : "a";
- 变量 x 到底是数值还是字符串,取决于另一个变量 y 的值
- y 为 true 时,x 是一个数值
- y 为 false 时,x 是一个字符串
- 说明 x 的类型无法在编译阶段确定,必须等到运行时才能确定
- 变量的数据类型是不确定的,但是各种运算符对数据类型是有要求的
- 如果运算符发现,运算数据的类型与预期不符,就会自动转换类型
console.log("4" - "3"); // 1
3.强制转换(显式转换)
- 主要指使用
Number()
、String()
和Boolean()
三个函数 - 手动将各种类型的值,分别转换成数字、字符串或者布尔值
1)Number()
- 可以将任意类型的值转化成数值
a)参数是原始类型值
// 数值:转换后还是原来的值
Number(324); // 324
// 字符串:如果可以被解析为数值,则转换为相应的数值
Number("324"); // 324
// 字符串:如果不可以被解析为数值,返回 NaN
Number("324abc"); // NaN
// 空字符串转为0
Number(""); // 0
// 布尔值:true 转成 1,false 转成 0
Number(true); // 1
Number(false); // 0
// undefined:转成 NaN
Number(undefined); // NaN
// null:转成0
Number(null); // 0
Number
函数将字符串转为数值,要比parseInt
函数严格很多- 只要有一个字符无法转成数值,整个字符串就会被转为
NaN
parseInt("42 cats"); // 42
Number("42 cats"); // NaN
parseInt
和Number
函数都会自动过滤一个字符串前导和后缀的空格
parseInt("\t\v\r12.34\n"); // 12
Number("\t\v\r12.34\n"); // 12.34
b)参数是对象类型
Number
方法的参数是对象时,将返回NaN
- 除非是包含单个数值的数组
Number({ a: 1 }); // NaN
Number([1, 2, 3]); // NaN
Number([5]); // 5
- 调用对象自身的
valueOf
方法- 如果返回原始类型的值,则直接对该值使用
Number
函数,不再进行后续步骤
- 如果返回原始类型的值,则直接对该值使用
- 如果
valueOf
方法返回的还是对象,则改为调用对象自身的toString
方法- 如果
toString
方法返回原始类型的值,则对该值使用Number
函数,不再进行后续步骤
- 如果
- 如果
toString
方法返回的是对象,就报错
var obj = {
x: 1,
};
Number(obj); // NaN
// 等同于
if (typeof obj.valueOf() === "object") {
Number(obj.toString());
} else {
Number(obj.valueOf());
}
首先调用
obj.valueOf
方法, 结果返回对象本身于是,继续调用
obj.toString
方法,返回字符串[object Object]
对这个字符串使用
Number
函数,得到NaN
- 默认情况下,对象的
valueOf
方法返回对象本身 - 所以一般总是会调用
toString
方法 - 而
toString
方法返回对象的类型字符串(比如[object Object]
) - 所以会有下面的结果
Number({}); // NaN
- 如果
toString
方法返回的不是原始类型的值,结果就会报错
var obj = {
valueOf: function () {
return {};
},
toString: function () {
return {};
},
};
Number(obj);
// TypeError: Cannot convert object to primitive value
valueOf
和toString
方法,都是可以自定义的
Number({
valueOf: function () {
return 2;
},
});
// 2
Number({
toString: function () {
return 3;
},
});
// 3
Number({
valueOf: function () {
return 2;
},
toString: function () {
return 3;
},
});
// 2
2)String()
String
函数可以将任意类型的值转化成字符串
a)参数是原始类型值
- 数值:转为相应的字符串
- 字符串:转换后还是原来的值
- 布尔值:
true
转为字符串"true"
,false
转为字符串"false"
- undefined:转为字符串
"undefined"
- null:转为字符串
"null"
String(123); // "123"
String("abc"); // "abc"
String(true); // "true"
String(undefined); // "undefined"
String(null); // "null"
b)参数是对象
- 如果是对象,返回一个类型字符串
- 如果是数组,返回该数组的字符串形式
String({ a: 1 }); // "[object Object]"
String([1, 2, 3]); // "1,2,3"
String
方法背后的转换规则与Number
方法基本相同- 只是 互换了
valueOf
方法和toString
方法的执行顺序
- 只是 互换了
- 先调用对象自身的
toString
方法- 如果返回原始类型的值,则对该值使用
String
函数,不再进行以下步骤
- 如果返回原始类型的值,则对该值使用
- 如果
toString
方法返回的是对象,再调用原对象的valueOf
方法- 如果
valueOf
方法返回原始类型的值,则对该值使用String
函数,不再进行以下步骤
- 如果
- 如果
valueOf
方法返回的是对象,就报错
String({ a: 1 });
// "[object Object]"
// 等同于
String({ a: 1 }.toString());
// "[object Object]"
先调用对象的
toString
方法,发现返回的是字符串[object Object]
,就不再调用valueOf
方法了
- 如果
toString
和valueOf
方法,返回的都是对象,就会报错
var obj = {
valueOf: function () {
return {};
},
toString: function () {
return {};
},
};
String(obj);
// TypeError: Cannot convert object to primitive value
valueOf
和toString
方法,都是可以自定义的
String({
toString: function () {
return 3;
},
});
// "3"
String({
valueOf: function () {
return 2;
},
});
// "[object Object]"
String({
valueOf: function () {
return 2;
},
toString: function () {
return 3;
},
});
// "3"
3)Boolean()
Boolean()
函数可以将任意类型的值转为布尔值- 除了以下值的转换结果为
false
,其他的值全部为true
false
undefined
null
0
(包含-0
和+0
)NaN
''
(空字符串)
Boolean(undefined); // false
Boolean(null); // false
Boolean(0); // false
Boolean(NaN); // false
Boolean(""); // false
Boolean(true); // true
Boolean(false); // false
- 所有对象(包括空对象)的转换结果都是
true
false
对应的布尔对象new Boolean(false)
也是true
Boolean({}); // true
Boolean([]); // true
Boolean(new Boolean(false)); // true
因为 JavaScript 语言设计的时候,出于性能的考虑,如果对象需要计算才能得到布尔值,对于
obj1 && obj2
这样的场景,可能会需要较多的计算为了保证性能,就统一规定,对象的布尔值为
true
4.自动转换(隐式转换)
- 以强制转换为基础
- 转换是自动完成的,用户不可见
1)转换规则
a)不同类型的数据互相运算
123 + "abc"; // "123abc"
b)对非布尔值类型的数据求布尔值
if ("abc") {
console.log("hello");
} // "hello"
+
和 -
)
c)对非数值类型的值使用一元运算符(即 +{ foo: "bar" } - // NaN
[1, 2, 3]; // NaN
- 预期什么类型的值,就调用该类型的转换函数
- 如:某个位置预期为字符串,就调用
String()
函数进行转换 - 如果该位置既可以是字符串,也可能是数值,那么默认转为 数值
- 如:某个位置预期为字符串,就调用
自动转换具有不确定性,而且不易除错
建议在预期为布尔值、数值、字符串的地方,全部使用 Boolean()
、 Number()
和 String()
函数进行显式转换
2)自动转换为布尔值
- JavaScript 遇到预期为布尔值的地方(如:
if
语句的条件部分),会将非布尔值的参数自动转换为布尔值 - 系统内部会自动调用
Boolean()
函数 - 仅有以下值转换为 false
undefined
null
+0
或-0
NaN
''
(空字符串)false
if (!undefined && !null && !0 && !NaN && !"") {
console.log("true");
} // true
- 将一个表达式转为布尔值
- 内部调用的也是
Boolean()
函数
// 写法一
expression ? true : false;
// 写法二
!!expression;
3)自动转换为字符串
- JavaScript 遇到预期为字符串的地方,会将非字符串的值自动转为字符串
- 先将复合类型的值转为原始类型的值,再将原始类型的值转为字符串
- 字符串的自动转换,主要发生在 字符串的加法运算 时
"5" + 1; // '51'
"5" + true; // "5true"
"5" + false; // "5false"
"5" + {}; // "5[object Object]"
"5" + []; // "5"
"5" + function () {}; // "5function (){}"
"5" + undefined; // "5undefined"
"5" + null; // "5null"
- 这种自动转换很容易出错
var obj = {
width: "100",
};
obj.width + 20; // "10020"
4)自动转换为数值
- JavaScript 遇到预期为数值的地方,会将参数值自动转换为数值
- 系统内部会自动调用
Number()
函数 - 除了加法运算符(+)有可能把运算子表达式转为字符串,其他运算符都会把运算子自动转成数值
"5" - "2"; // 3
"5" * "2"; // 10
true - 1; // 0
false - 1; // -1
"1" - 1; // 0
"5" * []; // 0
false / "5"; // 0
"abc" - 1; // NaN
null + 1; // 1
undefined + 1; // NaN
警告
null
转为数值时为 0
,而 undefined
转为数值时为 NaN
- 一元运算符也会转成数值
+"abc"; // NaN
-"abc"; // NaN
+true; // 1
-false; // 0
5.真题解答
1)JavaScript 中如何进行数据类型的转换?
类型转换可以分为两种,隐性转换和显性转换
- 隐性转换
当不同数据类型之间进行相互运算,或者当对非布尔类型的数据求布尔值的时候,会发生隐性转换
预期为数字的时候:算术运算时,结果和运算的数都是数字,数据会转换为数字来进行计算
类型 转换前 转换后 number 4 4 string "1" 1 string "abc" NaN string "" 0 boolean true 1 boolean false 0 undefined undefined NaN null null 0 预期为字符串的时候:有一个操作数为字符串时,使用
+
符号做相加运算时,会自动转换为字符串预期为布尔的时候:前面在介绍布尔类型时所提到的 9 个值会转为 false,其余转为 true
- 显性转换
所谓显性转换,是指程序员强制将一种类型转换为另外一种类型,往往会使用到一些转换方法
常见的转换方法如下:
- 转换为数值类型:
Number()
,parseInt()
,parseFloat()
- 转换为布尔类型:
Boolean()
- 转换为字符串类型:
toString()
,String()
当然,除了使用上面的转换方法,我们也可以通过一些快捷方式来进行数据类型的显性转换,如下:
- 转换字符串:直接和一个空字符串拼接,例如:
a = "" + 数据
- 转换布尔:!!数据类型,例如:
!!"Hello"
- 转换数值:数据*1 或 /1,例如:
"Hello * 1"
(五)运算符
1.经典真题
- 下面代码中,a 在什么情况下会执行输出语句打印 1?
var a = ?;
if(a == 1 && a == 2 && a == 3){
console.log(1);
}
2.算术运算符
- JavaScript 共提供 10 个算术运算符,用来完成基本的算术运算
分类 | 举例 |
---|---|
加法运算符 | x + y |
减法运算符 | x - y |
乘法运算符 | x * y |
除法运算符 | x / y |
指数运算符 | x ** y |
余数运算符 | x % y |
自增运算符 | ++x 或者 x++ |
自减运算符 | --x 或者 x-- |
数值运算符 | +x |
负数值运算符 | -x |
3.算术运算符:加法运算符
1)基本规则
- 求两个数值的和
- JavaScript 允许非数值相加
// 布尔值都会自动转成数值,然后再相加。
true + true; // 2
1 + true; // 2
- 如果两个字符串相加,加法运算符会变成连接运算符
- 返回一个新的字符串,将两个原字符串连接在一起
'a' + 'bc'; // "abc"
- 如果字符串和非字符串相加,非字符串会转成字符串,再连接在一起
1 + "a"; // "1a"
false + "a"; // "falsea"
- 加法运算符是在 运行时 决定,是执行相加还是执行连接
- 运算子的不同,导致了不同的语法行为
- 这种现象称为“重载”(overload)
- 由于加法运算符存在重载,可能执行两种运算
"3" + 4 + 5; // "345"
3 + 4 + "5"; // "75"
- 其他算术运算符(如:减法、除法和乘法)都不会发生重载
- 规则是:所有运算子一律转为数值,再进行相应的数学运算
1 - "2"; // -1
1 * "2"; // 2
1 / "2"; // 0.5
2)对象相加
- 必须先转成原始类型的值,然后再相加
var obj = { p: 1 };
obj + 2; // "[object Object]2"
- 可以自己定义
valueOf
方法或toString
方法,得到想要的结果
var obj = {
valueOf: function () {
return 1;
},
};
obj + 2; // 3
var obj = {
toString: function () {
return "hello";
},
};
obj + 2; // "hello2"
- 如果
Date
对象的实例相加,会优先执行toString
方法
var obj = new Date();
obj.valueOf = function () {
return 1;
};
obj.toString = function () {
return "hello";
};
obj + 2; // "hello2"
4.算术运算符:余数运算符
- 余数运算符(
%
)返回前一个运算子被后一个运算子除,所得的余数
12 % 5; // 2
- 运算结果的正负号由第一个运算子的正负号决定
-1 % 2; // -1
1 % -2; // 1
- 为了得到负数的正确余数值,可以先使用绝对值函数
// 错误的写法
function isOdd(n) {
return n % 2 === 1;
}
isOdd(-5); // false
isOdd(-4); // false
// 正确的写法
function isOdd(n) {
return Math.abs(n % 2) === 1;
}
isOdd(-5); // true
isOdd(-4); // false
- 可以用于浮点数的运算
- 由于浮点数不是精确的值,无法得到完全准确的结果
6.5 % 2.1; // 0.19999999999999973
5.算术运算符:自增和自减运算符
1)基础使用
- 是一元运算符
- 作用是将运算子首先转为数值,然后加上 1 或者减去 1
- 会修改原始变量
var x = 1;
++x; // 2
x; // 2
--x; // 1
x; // 1
- 运算之后,变量的值发生变化,这种效应叫做运算的副作用(side effect)
- 自增和自减运算符是仅有的两个具有副作用的运算符,其他运算符都不会改变变量的值
2)运算顺序
- 自增和自减运算符放在变量之后,会先返回变量操作前的值,再进行自增/自减操作
- 放在变量之前,会先进行自增/自减操作,再返回变量操作后的值
var x = 1;
var y = 1;
x++; // 1
++y; // 2
6.算术运算符:数值运算符,负数值运算符
+
)
1)数值运算符(- 同样使用加号,但它是一元运算符(只需要一个操作数)
- 而加法运算符是二元运算符(需要两个操作数)
- 可以将任何值转为数值(与
Number
函数的作用相同) - 返回一个新的值,不会改变原始变量的值
+true; // 1
+[]; // 0
+{}; // NaN
-
)
2)负数值运算符(- 将一个值转为数值,只不过得到的值正负相反
- 连用两个负数值运算符,等同于数值运算符
- 返回一个新的值,不会改变原始变量的值
var x = 1;
-x; // -1
-(-x); // 1
7.算术运算符:指数运算符
- 指数运算符(
**
)完成指数运算,前一个运算子是底数,后一个运算子是指数
2 ** 4; // 16
- 指数运算符是右结合,而不是左结合
- 即:多个指数运算符连用时,先进行最右边的计算
// 相当于 2 ** (3 ** 2)
2 ** (3 ** 2); // 512
8.算术运算符:赋值运算符
- 赋值运算符(Assignment Operators)用于给变量赋值
- 最常见的赋值运算符是等号(
=
)
// 将 1 赋值给变量 x
var x = 1;
// 将变量 y 的值赋值给变量 x
var x = y;
- 赋值运算符还可以与其他运算符结合,形成变体
// 等同于 x = x + y
x += y;
// 等同于 x = x - y
x -= y;
// 等同于 x = x * y
x *= y;
// 等同于 x = x / y
x /= y;
// 等同于 x = x % y
x %= y;
// 等同于 x = x ** y
x **= y;
// 等同于 x = x >> y
x >>= y;
// 等同于 x = x << y
x <<= y;
// 等同于 x = x >>> y
x >>>= y;
// 等同于 x = x & y
x &= y;
// 等同于 x = x | y
x |= y;
// 等同于 x = x ^ y
x ^= y;
- 先进行指定运算,然后将得到值返回给左边的变量
9.比较运算符
- 用于比较两个值的大小
- 返回一个布尔值,表示是否满足指定的条件
2 > 1; // true
- 比较运算符可以比较各种类型的值,不仅仅是数值
- JavaScript 一共提供了 8 个比较运算符
运算符 | 含义 |
---|---|
> | 大于运算符 |
< | 小于运算符 |
<= | 小于或等于运算符 |
>= | 大于或等于运算符 |
== | 相等运算符 |
=== | 严格相等运算符 |
!= | 不相等运算符 |
!== | 严格不相等运算符 |
- 分成两类:相等比较和非相等比较
- 对于非相等的比较,算法是先看两个运算子是否都是字符串
- 如果是,就按照字典顺序比较(实际上是比较 Unicode 码点)
- 否则,将两个运算子都转成数值,再比较数值的大小
10.比较运算符:非相等运算符
1)字符串的比较
- 字符串按照字典顺序进行比较
"cat" > "dog"; // false
"cat" > "catalog"; // false
- JavaScript 引擎内部首先比较首字符的 Unicode 码点
- 如果相等,再比较第二个字符的 Unicode 码点
- 以此类推
"cat" > "Cat"; // true'
"大" > "小"; // false
2)非字符串的比较
- 如果两个运算子中至少有一个不是字符串,需要分成以下两种情况
a)原始类型值
- 如果两个运算子都是原始类型的值,则是先转成数值再比较
5 > "4"; // true
// 等同于 5 > Number('4')
// 即 5 > 4
true > false; // true
// 等同于 Number(true) > Number(false)
// 即 1 > 0
2 > true; // true
// 等同于 2 > Number(true)
// 即 2 > 1
- 与
NaN
的比较 - 任何值(包括
NaN
本身)与NaN
使用非相等运算符进行比较,返回的都是false
1 > NaN; // false
1 <= NaN; // false
"1" > NaN; // false
"1" <= NaN; // false
NaN > NaN; // false
NaN <= NaN; // false
b)对象
- 如果运算子是对象,会转为原始类型的值,再进行比较
var x = [2];
x > "11"; // true
// 等同于 [2].valueOf().toString() > '11'
// 即 '2' > '11'
x.valueOf = function () {
return "1";
};
x > "11"; // false
// 等同于 [2].valueOf() > '11'
// 即 '1' > '11'
- 两个对象之间的比较也是如此
[2] > [1]; // true
// 等同于 [2].valueOf().toString() > [1].valueOf().toString()
// 即 '2' > '1'
[2] > [11]; // true
// 等同于 [2].valueOf().toString() > [11].valueOf().toString()
// 即 '2' > '11'
{ x: 2 } >= { x: 1 }; // true
// 等同于 { x: 2 }.valueOf().toString() >= { x: 1 }.valueOf().toString()
// 即 '[object Object]' >= '[object Object]'
11.比较运算符:严格相等运算符
- JavaScript 提供两种相等运算符:
==
和===
- 相等运算符(
==
)比较两个值是否相等- 严格相等运算符(
===
)比较它们是否为“同一个值”
- 严格相等运算符(
- 如果两个值不是同一类型,严格相等运算符(
===
)直接返回false
- 而相等运算符(
==
)会将它们转换成同一个类型,再用严格相等运算符进行比较
- 而相等运算符(
1)不同类型的值
- 如果两个值的类型不同,直接返回
false
1 === "1"; // false
true === "true"; // false
2)同一类的原始类型值
- 同一类型的原始类型的值(数值、字符串、布尔值)比较时
- 值相同就返回
true
,值不同就返回false
1 === 0x1; // true
NaN
与任何值都不相等(包括自身)- 正
0
等于负0
NaN === NaN; // false
+0 === -0; // true
3)复合类型值
- 两个复合类型(对象、数组、函数)的数据比较时,不是比较它们的值是否相等
- 而是比较它们是否指向同一个地址
{} === {}; // false
[] === []; // false
(function () {} === function () {}); // false
- 如果两个变量引用同一个对象,则它们相等
var v1 = {};
var v2 = v1;
v1 === v2; // true
注意
对于两个对象的比较,严格相等运算符比较的是地址,而大于或小于运算符比较的是值
var obj1 = {};
var obj2 = {};
obj1 > obj2; // false
obj1 < obj2; // false
obj1 === obj2; // false
4)undefined 和 null
undefined
和null
与自身严格相等
undefined === undefined; // true
null === null; // true
- 由于变量声明后默认值是
undefined
,因此两个只声明未赋值的变量是相等的
var v1;
var v2;
v1 === v2; // true
12.比较运算符:严格不相等运算符
- 严格不相等运算符(
!==
)的算法是先求严格相等运算符的结果,然后返回相反值
1 !== "1"; // true
// 等同于
!(1 === "1");
13.比较运算符:相等运算符
- 相等运算符用来比较相同类型的数据时,与严格相等运算符完全一样
1 == 1.0;
// 等同于
1 === 1.0;
- 比较不同类型的数据时先将数据进行类型转换,然后再用严格相等运算符比较
1)原始类型值
- 原始类型的值会转换成数值再进行比较
1 == true; // true
// 等同于 1 === Number(true)
0 == false; // true
// 等同于 0 === Number(false)
2 == true; // false
// 等同于 2 === Number(true)
2 == false; // false
// 等同于 2 === Number(false)
"true" == true; // false
// 等同于 Number('true') === Number(true)
// 等同于 NaN === 1
"" == 0; // true
// 等同于 Number('') === 0
// 等同于 0 === 0
"" == false; // true
// 等同于 Number('') === Number(false)
// 等同于 0 === 0
"1" == true; // true
// 等同于 Number('1') === Number(true)
// 等同于 1 === 1
"\n 123 \t" == 123; // true
// 因为字符串转为数字时,省略前置和后置的空格
2)对象与原始类型值比较
- 对象转换成原始类型的值,再进行比较
// 数组与数值的比较
[1] == 1; // true
// 数组与字符串的比较
[1] == "1"; // true
[1, 2] == "1,2"; // true
// 对象与布尔值的比较
[1] == true; // true
[2] == true; // false
const obj = {
valueOf: function () {
console.log("执行 valueOf()");
return obj;
},
toString: function () {
console.log("执行 toString()");
return "foo";
},
};
obj == "foo";
// 执行 valueOf()
// 执行 toString()
// true
3)undefined 和 null
undefined
和null
只有与自身比较,或者互相比较时,才会返回true
- 与其他类型的值比较时,结果都为
false
undefined == undefined; // true
null == null; // true
undefined == null; // true
false == null; // false
false == undefined; // false
0 == null; // false
0 == undefined; // false
4)相等运算符的缺点
- 相等运算符隐藏的类型转换,会带来一些违反直觉的结果
0 == ""; // true
0 == "0"; // true
2 == true; // false
2 == false; // false
false == "false"; // false
false == "0"; // true
false == undefined; // false
false == null; // false
null == undefined; // true
" \t\r\n " == 0; // true
- 这些表达式都不同于直觉,很容易出错
- 建议不要使用相等运算符(
==
),最好只使用严格相等运算符(===
)
14.比较运算符:不相等运算符
- 不相等运算符(
!=
)的算法是先求相等运算符的结果,然后返回相反值
1 != "1"; // false
// 等同于
!(1 == "1");
15.布尔运算符(逻辑运算符)
- 用于将表达式转为布尔值,一共包含四个运算符
运算符 | 符号 |
---|---|
取反运算符 | ! |
且(并)运算符 | && |
或运算符 | || |
三元运算符 | ?: |
16.布尔运算符:取反运算符
- 取反运算符(!)是一个感叹号,用于将布尔值变为相反值
- 即:
true
变成false
,false
变成true
!true; // false
!false; // true
- 对于非布尔值,取反运算符会将其转为布尔值
- 以下六个值取反后为
true
,其他值都为false
undefined
null
false
0
NaN
- 空字符串(
''
)
!undefined; // true
!null; // true
!0; // true
!NaN; // true
!""; // true
!54; // false
!"hello"; // false
![]; // false
!{}; // false
- 如果对一个值连续做两次取反运算,等于将其转为对应的布尔值
- 与
Boolean
函数的作用相同 - 这是一种常用的类型转换的写法
!!x;
// 等同于
Boolean(x);
17.布尔运算符:且运算符
- 且运算符(
&&
)往往用于多个表达式的求值 - 如果第一个运算子的布尔值为
true
,则返回 第二个运算子的值(注意是值,不是布尔值) - 如果第一个运算子的布尔值为
false
,则直接返回 第一个运算子的值,且不再对第二个运算子求值
"t" && ""; // ""
"t" && "f"; // "f"
"t" && 1 + 2; // 3
"" && "f"; // ""
"" && ""; // ""
var x = 1;
1 - 1 && (x += 1); // 0
x; // 1
- 这种跳过第二个运算子的机制,被称为“短路”
- 有时可以取代
if
结构
if (i) {
doSomething();
}
// 等价于
i && doSomething();
- 且运算符可以多个连用,这时返回 第一个布尔值为
false
的表达式的值 - 如果所有表达式的布尔值都为
true
,则返回 最后一个表达式的值
true && "foo" && "" && 4 && "foo" && true; // ''
1 && 2 && 3; // 3
18.布尔运算符:或运算符
- 或运算符(
||
)也用于多个表达式的求值 - 如果第一个运算子的布尔值为
true
,则返回 第一个运算子的值 ,且不再对第二个运算子求值 - 如果第一个运算子的布尔值为
false
,则返回 第二个运算子的值
"t" || ""; // "t"
"t" || "f"; // "t"
"" || "f"; // "f"
"" || ""; // ""
- 短路规则对这个运算符也适用
var x = 1;
true || (x = 2); // true
x; // 1
- 这种只通过第一个表达式的值,控制是否运行第二个表达式的机制,就称为“短路”(short-cut)
- 或运算符可以多个连用,这时返回 第一个布尔值为
true
的表达式的值 - 如果所有表达式都为
false
,则返回 最后一个表达式的值
false || 0 || "" || 4 || "foo" || true; // 4
false || 0 || ""; // ''
- 或运算符常用于为一个变量设置默认值
function saveText(text) {
text = text || "";
// ...
}
// 或者写成
saveText(this.text || "");
19.布尔运算符:三元条件运算符
- 三元条件运算符(?:)由问号(?)和冒号(:)组成,分隔三个表达式
- 是唯一一个需要三个运算子的运算符
- 如果第一个表达式的布尔值为
true
,则返回第二个表达式的值,否则返回第三个表达式的值
"t" ? "hello" : "world"; // "hello"
0 ? "hello" : "world"; // "world"
- 三元条件表达式与
if...else
语句具有同样表达效果 if...else
是语句,没有返回值- 三元条件表达式是表达式,具有返回值
console.log(true ? "T" : "F"); // T
20.位运算符
- 按位运算符是将操作数换算成 32 位的二进制整数,然后按每一位来进行运算
5 的 32 位为:
00000000000000000000000000000101
100 的 32 位为:
00000000000000000000000001100100
15 的 32 位为:
00000000000000000000000000001111
21.位运算符:按位非
- 按位非运算符
~
会把数字转为 32 位二进制整数,然后反转每一位 - 所有的 1 变为 0,所有的 0 变为 1
5 的 32 位为:
00000000000000000000000000000101
~5 的 32 位为:
11111111111111111111111111111010
- 转换出来就为 -6
- 按位非,实质上是对操作数求负,然后减去 1
22.位运算符:按位与
- 按位或运算符
&
会把两个数字转为 32 位二进制整数,并对两个数的每一位执行按位与运算 - 全 1 为 1
第一个数字 | 第二个数字 | 结果 |
---|---|---|
1 | 1 | 1 |
1 | 0 | 0 |
0 | 1 | 0 |
0 | 0 | 0 |
console.log(12 & 10); // 8
- 12 的 32 位二进制表示为:1100
- 10 的 32 位二进制表示为:1010
- 按位与的结果为:1000
23.位运算符:按位或
- 按位或运算符
|
会把两个数字转为 32 位二进制整数,并对两个数的每一位执行按位或运算 - 全 0 为 0
第一个数字 | 第二个数字 | 结果 |
---|---|---|
1 | 1 | 1 |
1 | 0 | 1 |
0 | 1 | 1 |
0 | 0 | 0 |
console.log(12 | 10); // 14
- 12 的 32 位二进制表示为:1100
- 10 的 32 位二进制表示为:1010
- 按位或的结果为:1110
24.位运算符:按位异或
- 按位或运算符
^
会把两个数字转为 32 位二进制整数,并对两个数的每一位执行按位异或运算 - 异 1 同 0
第一个数字 | 第二个数字 | 结果 |
---|---|---|
1 | 1 | 0 |
1 | 0 | 1 |
0 | 1 | 1 |
0 | 0 | 0 |
console.log(12 ^ 10); // 6
- 12 的 32 位二进制表示为:1100
- 10 的 32 位二进制表示为:1010
- 按位异或的结果为:0110
// Hello 被转换为了 NaN
console.log(true ^ "Hello"); // 1
console.log(false ^ "Hello"); // 0
console.log(true ^ true); // 0
console.log("Hello" ^ "Hello"); // 0
console.log(false ^ false); // 0
console.log(true ^ false); // 1
- 如果是非整数值,两个操作数中只有一个为真,就返回 1
- 如果两个操作数都是真,或者都是假,就返回 0
25.位运算符:按位移位
- 按位移位运算符
<<
和>>
会将所有位向左或者向右移动指定的数量 - 实际上就是高效率地将数字乘以或者除以 2 的指定数的次方
<<
:乘以 2 的指定数次方
console.log(2 << 2); // 8
// 2 乘以 2 的 2 次方
// 00000010 转换为 00001000
>>
:除以 2 的指定数次方
console.log(16 >> 1); // 8
// 16 除以 2 的 1 次方
// 00010000转换为00001000
26.其他运算符
1)void 运算符
- 执行一个表达式,然后不返回任何值,或者说返回 undefined
void 0; // undefined
// void (0); // undefined
- 推荐括号写法
- void 运算符的优先性很高,如果不使用括号,容易造成错误的结果
- 如:“void 4 + 7” 实际上等同于 “(void 4) + 7”
var x = 3;
void (x = 5); // undefined
x; // 5
- 这个运算符的主要用途是浏览器的书签工具(Bookmarklet),以及在超级链接中插入代码防止网页跳转
<script>
function f() {
console.log("Hello World");
}
</script>
<!-- <a href="http://example.com" onclick="f(); return false;">点击</a> -->
<a href="javascript: void(f())">点击</a>
- 用户点击链接提交表单,但是不产生页面跳转
<a href="javascript: void(document.form.submit())">提交</a>
2)逗号运算符
- 对两个表达式求值,并返回后一个表达式的值
"a", "b"; // "b"
var x = 0;
var y = (x++, 10);
x; // 1
y; // 10
- 逗号运算符的一个用途是,在返回一个值之前,进行一些辅助操作
var value = (console.log("Hi!"), true); // Hi!
value; // true
27.运算顺序
1)优先级
- 各种运算符的优先级别(Operator Precedence)是不一样的
- 优先级高的运算符先执行,优先级低的运算符后执行
4 + 5 * 6; // 34
// 4 + (5 * 6); // 34
- 乘法运算符(
*
)的优先性高于加法运算符(+
) - 先执行乘法,再执行加法
var x = 1;
var arr = [];
var y = arr.length <= 0 || arr[0] === undefined ? x : arr[0];
// var y = ((arr.length <= 0) || (arr[0] === undefined)) ? x : arr[0];
- 根据语言规格,这五个运算符的优先级从高到低依次为
- 小于等于( <= )
- 严格相等( === )
- 或( || )
- 三元( ?: )
- 等号( = )
- 记住所有运算符的优先级,是非常难的,也是没有必要的
2)圆括号的作用
- 用来提高运算的优先级
- 因为它的优先级是最高的,即圆括号中的表达式会第一个运算
(4 + 5) * 6; // 54
- 圆括号不是运算符,而是一种语法结构
- 一共有两种用法
- 把表达式放在圆括号之中,提升运算的优先级
- 跟在函数的后面,作用是调用函数
- 因为圆括号不是运算符,所以不具有求值作用,只改变运算的优先级
var x = 1;
// (x) = 2;
- 如果整个表达式都放在圆括号之中,那么不会有任何效果
// (expression);
// 等同于
expression;
- 函数放在圆括号中,会返回函数本身
- 如果圆括号紧跟在函数的后面,就表示调用函数
function f() {
return 1;
}
// (f); // function f(){return 1;}
f(); // 1
- 圆括号中只能放置表达式
- 如果将语句放在圆括号之中,就会报错
(var a = 1)
// SyntaxError: Unexpected token var
3)左结合和右结合
- 对于优先级别相同的运算符,同时出现时就会有计算顺序的问题
// 方式一
(a OP b) OP c
// 方式二
a OP (b OP c)
- 方式一是将左侧两个运算数结合在一起
- 采用这种解释方式的运算符,称为“左结合”(left-to-right associativity)运算符
- 方式二是将右侧两个运算数结合在一起
- 这样的运算符称为“右结合”运算符(right-to-left associativity)
- JavaScript 语言的大多数运算符是“左结合”
x + y + z; // 引擎解释如下
// (x + y) + z;
- 少数运算符是“右结合”
- 最主要的是赋值运算符( = )和三元条件运算符( ?: )
w = x = y = z;
q = a ? b : c ? d : e ? f : g;
// 引擎解释如下
// w = (x = (y = z));
// q = a ? b : (c ? d : (e ? f : g));
- 指数运算符(
**
)也是右结合
2 ** (3 ** 2);
// 相当于 2 ** (3 ** 2)
// 512
28.真题解答
1)下面代码中,a 在什么情况下会执行输出语句打印 1?
var a = "?";
if (a == 1 && a == 2 && a == 3) {
console.log(1);
}
方法一:利用 toString() 方法
var a = { i: 1, toString() { return a.i++; }, }; if (a == 1 && a == 2 && a == 3) { console.log("1"); }
方法二:利用 valueOf() 方法
var a = { i: 1, valueOf() { return a.i++; }, }; if (a == 1 && a == 2 && a == 3) { console.log("1"); }
(六)原型和原型链
1.经典真题
- 说一说你对 JavaScript 中原型与原型链的理解?(美团 2019 年)
- 对一个构造函数实例化后,它的原型链指向什么?
2.原型与原型链介绍
- 在 Brendan Eich 设计 JavaScript 时,借鉴了 Self 和 Smalltalk 这两门基于原型的语言
- 之所以选择基于原型的对象系统,是因为作者一开始就没有打算在 JavaScript 中加入类的概念
- 设计初衷就是为非专业的开发人员(例如网页设计者)提供一个方便的工具
- 由于大部分网页设计者都没有任何的编程背景,所以在设计 JavaScript 时也是尽可能使其简单、易学
- 所以 JavaScript 中的原型以及原型链成为了这门语言最大的一个特点
- JavaScript 是一门基于原型的语言,对象是通过原型对象产生的
2.克隆对象
- ES5 中提供了 Object.create 方法,可以用来克隆对象
1)原型对象
const person = {
arms: 2,
legs: 2,
walk() {
console.log("walking");
},
};
const zhangsan = Object.create(person);
console.log(zhangsan.arms); // 2
console.log(zhangsan.legs); // 2
zhangsan.walk(); // walking
console.log(zhangsan.__proto__ === person); // true
通过 Object.create 方法对 person 对象进行克隆,克隆出来一个名为 zhangsan 的对象
所以 person 对象就是 zhangsan 这个对象的原型对象
person 对象上面的属性和方法,zhangsan 这个对象上面都有
- 通过
__proto__
属性可以访问到一个对象的原型对象
2)新属性
- 可以传入第 2 个参数,是一个 JSON 对象
- 该对象可以书写新对象的新属性以及属性特性
- 通过这种方式,基于对象创建的新对象,可以 继承 祖辈对象的属性和方法
const person = {
arms: 2,
legs: 2,
walk() {
console.log("walking");
},
};
const zhangsan = Object.create(person, {
name: {
value: "zhangsan",
},
age: {
value: 18,
},
born: {
value: "chengdu",
},
});
const zhangxiaosan = Object.create(zhangsan, {
name: {
value: "zhangxiaosan",
},
age: {
value: 1,
},
});
console.log(zhangxiaosan.name); // zhangxiaosan
console.log(zhangxiaosan.age); // 1
console.log(zhangxiaosan.born); // chengdu
console.log(zhangxiaosan.arms); // 2
console.log(zhangxiaosan.legs); // 2
zhangxiaosan.walk(); // walking
console.log(zhangsan.isPrototypeOf(zhangxiaosan)); // true
console.log(person.isPrototypeOf(zhangxiaosan)); // true
zhangsan 这个对象是从 person 这个对象克隆而来的,而 zhangxiaosan 这个对象又是从 zhangsan 这个对象克隆而来,以此 形成了一条原型链
无论是 person 对象,还是 zhangsan 对象上面的属性和方法,zhangxiaosan 这个对象都能继承到
- 这就是 JavaScript 中最原始的创建对象的方式
- 一个对象是通过克隆另外一个对象所得到的
- 通过克隆可以创造一个一模一样的对象
- 被克隆的对象是新对象的原型对象
3.构造函数模拟类
随着 JavaScript 语言的发展,这样创建对象的方式还是太过于麻烦了
开发者还是期望 JavaScript 能够像 Java、C# 等标准面向对象语言一样,通过类来批量的生成对象
于是出现了通过构造函数来模拟类的形式
function Computer(name, price) {
// 属性写在类里面
this.name = name;
this.price = price;
}
// 方法挂在原型对象上面
Computer.prototype.showSth = function () {
console.log(`这是一台${this.name}电脑`);
};
const apple = new Computer("苹果", 12000);
console.log(apple.name); // 苹果
console.log(apple.price); // 12000
apple.showSth(); // 这是一台苹果电脑
const huawei = new Computer("华为", 7000);
console.log(huawei.name); // 华为
console.log(huawei.price); // 7000
huawei.showSth(); // 这是一台华为电脑
- Computer 函数称为构造函数,为了区分普通函数和构造函数,一般构造函数的函数名会 首字母大写
- 构造函数一般配合 new 关键字使用
- 每 new 一次,就会生成一个新的对象
- 在构造函数中的 this 就指向这个新生成的对象
- Computer 构造函数的实例方法并没有书写在构造函数里面,而是写在了 Computer.prototype 上
- 实际上就是 Computer 实例对象的原型对象
1)重要的三角关系
- JavaScript 中每个对象都有一个原型对象
- 可以通过
__proto__
属性来访问到对象的原型对象
- 可以通过
- 构造函数的
prototype
属性指向一个对象- 这个对象是该构造函数实例化出来的对象的原型对象
- 原型对象的
constructor
属性也指向其构造函数 - 实例对象的
constructor
属性访问的是原型对象上的属性
function Computer(name, price) {
// 属性写在类里面
this.name = name;
this.price = price;
}
// 方法挂在原型对象上面
Computer.prototype.showSth = function () {
console.log(`这是一台${this.name}电脑`);
};
const apple = new Computer("苹果", 12000);
console.log(apple.__proto__ === Computer.prototype); // true
console.log(apple.__proto__.constructor === Computer); // true
function Computer(name, price) {
// 属性写在类里面
this.name = name;
this.price = price;
}
// 方法挂在原型对象上面
Computer.prototype.showSth = function () {
console.log(`这是一台${this.name}电脑`);
};
const apple = new Computer("苹果", 12000);
console.log(apple.__proto__ === Computer.prototype); // true
console.log(apple.__proto__.constructor === Computer); // true
// 数组的三角关系
var arr = [];
console.log(arr.__proto__ === Array.prototype); // true
// 其实所有的构造函数的原型对象都相同
console.log(Computer.__proto__ === Array.__proto__); // true
console.log(Computer.__proto__ === Date.__proto__); // true
console.log(Computer.__proto__ === Number.__proto__); // true
console.log(Computer.__proto__ === Function.__proto__); // true
console.log(Computer.__proto__ === Object.__proto__); // true
console.log(Computer.__proto__); // {}
所有的构造函数,无论是自定义的还是内置的,它们的原型对象都是同一个对象
2)原型对象的原型链
- 在 JavaScript 中,每一个对象,都有一个原型对象
- 而原型对象上面也有一个自己的原型对象,一层一层向上找,最终会到达 null
function Computer(name, price) {
// 属性写在类里面
this.name = name;
this.price = price;
}
// 方法挂在原型对象上面
Computer.prototype.showSth = function () {
console.log(`这是一台${this.name}电脑`);
};
var apple = new Computer("苹果", 12000);
console.log(apple.__proto__.__proto__); // [Object: null prototype] {}
console.log(apple.__proto__.__proto__.__proto__); // null
console.log(apple.__proto__.__proto__ === Object.prototype); // true
// 自定义构造函数函数
function Computer() {}
console.log(Computer.__proto__.__proto__.__proto__); // null
console.log(Computer.__proto__.constructor.__proto__ === Computer.__proto__); // true
console.log(Computer.__proto__.__proto__.constructor.__proto__ === Computer.__proto__); // true
4.真题解答
1)说一说你对 JavaScript 中原型与原型链的理解?(美团 2019 年)
- 每个对象都有一个
__proto__
属性,该属性指向自己的原型对象- 每个构造函数都有一个 prototype 属性,该属性指向实例对象的原型对象
- 原型对象里的 constructor 指向构造函数本身
每个对象都有自己的原型对象,而原型对象本身,也有自己的原型对象,从而形成了一条原型链条
当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及该对象的原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾
2)对一个构造函数实例化后,它的原型链指向什么?
指向该构造函数实例化出来对象的原型对象
对于构造函数来讲,可以通过 prototype 访问到该对象
对于实例对象来讲,可以通过隐式属性
__proto__
来访问到
(七)执行栈和执行上下文
1.经典真题
- 谈谈你对 JavaScript 执行上下文栈理解
2.执行上下文介绍
- Execution Context,代码(全局代码、函数代码)执行前进行的准备工作,也称为执行上下文环境
- 运行 JavaScript 代码,当代码执行进入一个环境时,就会为该环境创建一个执行上下文
- 会在运行代码前做一些准备工作
- 如:确定作用域,创建局部变量对象等
1)JavaScript 中的执行环境
- 全局环境 => 全局执行上下文
- 函数环境 => 函数执行上下文
- eval 函数环境(已不推荐使用) => eval 函数执行上下文
2)顺序
- JavaScript 运行时首先会进入全局环境,对应会生成全局上下文
- 程序代码中基本都会存在函数,调用函数就会进入函数执行环境,对应就会生成该函数的执行上下文
- 声明多个函数,对应会生成多个函数执行上下文
- 在 JavaScript 中,通过 栈的存取方式 来管理执行上下文
- 称为执行栈,或函数调用栈(Call Stack)
3)栈数据结构
- 栈遵循“先进后出,后进先出”的规则,LIFO(Last In First Out)规则
- 栈中放入/取出的操作,也可称为入栈/出栈
- 栈数据结构的特点
- 后进先出,先进后出
- 出口在顶部,且仅有一个
3.执行栈(函数调用栈)
- 程序执行进入一个执行环境时,它的执行上下文就会被创建,并被推入执行栈中(入栈)
- 程序执行完成时,它的执行上下文就会被销毁,并从栈顶被推出(出栈),控制权交由下一个执行上下文
- 因为 JavaScript 在执行代码时最先进入全局环境
- 处于栈底的永远是全局环境的执行上下文
- 处于栈顶的是当前正在执行函数的执行上下文
- 当函数调用完成后,就会从栈顶被推出
- 理想的情况下,闭包会阻止该操作
- 全局环境只有一个,对应的全局执行上下文也只有一个
- 只有当页面被关闭之后它才会从执行栈中被推出,否则一直存在于栈底
function foo() {
function bar() {
return "I am bar";
}
return bar();
}
foo();
4.执行上下文的数量限制(堆栈溢出)
- 执行上下文可存在多个
- 虽然没有明确的数量限制,但如果超出栈分配的空间,会造成堆栈溢出
- 常见于递归调用,没有终止条件造成死循环的场景
// 递归调用自身
function foo() {
foo();
}
foo();
// 报错: Uncaught RangeError: Maximum call stack size exceeded
5.执行上下文的生命周期
- 有两个阶段
- 创建阶段(进入执行上下文)
- 函数被调用时,进入函数环境,为其创建一个执行上下文
- 执行阶段(代码执行)
- 执行函数中的代码时
- 创建阶段(进入执行上下文)
1)创建阶段
- 创建变量对象(VO,Variable Object)
- 确定函数的形参并赋值
- 函数环境会初始化创建 Arguments 对象并赋值
- 确定普通字面量形式的函数声明并赋值
- 变量声明,函数表达式声明,未赋值
- 确定 this 指向
- 由调用者确定
- 确定作用域
- 词法环境决定
- 哪里声明定义,就在哪里确定
2)变量对象
- 当处于执行上下文的建立阶段时,可以将整个上下文环境看作是一个对象
- 该对象拥有 3 个属性
- 变量对象
- 作用域链
- this 指向
executionContextObj = {
// 变量对象,里面包含 Arguments 对象,形式参数,函数和局部变量
variableObject: {},
// 作用域链,包含内部上下文所有变量对象的列表
scopeChain: {},
// 上下文中 this 的指向对象
this: {},
};
- 在函数的建立阶段,首先会建立 Arguments 对象
- 然后确定形式参数,检查当前上下文中的函数声明
- 每找到一个函数声明,就在 variableObject 下面用函数名建立一个属性
- 属性值就指向该函数在内存中的地址的一个引用
- 如果上述函数名已经存在于 variableObject(简称 VO)中
- 对应的属性值会被新的引用给覆盖
- 最后确定当前上下文中的局部变量
- 如果遇到和函数名同名的变量,则会忽略该变量
3)执行阶段
- 变量对象赋值
- 变量赋值
- 函数表达式赋值
- 调用函数
- 顺序执行其它代码
4)生命周期
const foo = function (i) {
var a = "Hello";
var b = function privateB() {};
function c() {}
};
foo(10);
- 建立阶段的变量对象
- 除了 Arguments、函数的声明、形式参数被赋予了具体的属性值外
- 其它的变量属性默认的都是 undefined
- 并且普通形式声明的函数的提升在变量的上面
fooExecutionContext = {
variableObject: {
// 确定 Arguments 对象
arguments: {
0: 10,
length: 1,
},
// 确定形式参数
i: 10,
// 确定函数引用
c: "pointer to function c()",
// 局部变量 初始值为 undefined
a: undefined,
// 局部变量 初始值为 undefined
b: undefined,
},
scopeChain: {},
this: {},
};
- 一旦建立阶段结束,引擎就会进入代码执行阶段
- 只有在代码执行阶段,局部变量才会被赋予具体的值
- 这其实也就解释了变量提升的原理
fooExecutionContext = {
variableObject: {
arguments: {
0: 10,
length: 1,
},
i: 10,
c: "pointer to function c()",
// a 变量被赋值为 Hello
a: "Hello",
// b 变量被赋值为 privateB() 函数
b: "pointer to function privateB()",
},
scopeChain: {},
this: {},
};
5)IIFE 函数中的生命周期
(function () {
console.log(typeof foo);
console.log(typeof bar);
var foo = "Hello";
var bar = function () {
return "World";
};
function foo() {
return "good";
}
console.log(foo, typeof foo);
})();
- 该函数在建立阶段的变量对象
- 首先确定 Arguments 对象
- 然后是形式参数(本例中不存在形式参数)
- 接下来开始确定函数的引用
- 找到 foo 函数后,创建 foo 标识符来指向 foo 函数
- 之后同名的 foo 变量不会再被创建,会直接被忽略
- 然后创建 bar 变量,初始值为 undefined
fooExecutionContext = {
variableObject: {
arguments: {
length: 0,
},
foo: "pointer to function foo()",
bar: undefined,
},
scopeChain: {},
this: {},
};
- 建立阶段完成之后,进入代码执行阶段,开始一句一句的执行代码
(function () {
console.log(typeof foo); // function
console.log(typeof bar); // undefined
var foo = "Hello"; // foo 被重新赋值 变成了一个字符串
var bar = function () {
return "World";
};
function foo() {
return "good";
}
console.log(foo, typeof foo); //Hello string
})();
6.真题解答
1)谈谈你对 JavaScript 执行上下文栈理解
- 什么是执行上下文?
简而言之,执行上下文是评估和执行 JavaScript 代码的环境的抽象概念。每当 JavaScript 代码在运行的时候,它都是在执行上下文中运行
- 执行上下文的类型
JavaScript 中有三种执行上下文类型
- 全局执行上下文:这是默认或者说基础的上下文,任何不在函数内部的代码都在全局上下文中。它会执行两件事,创建一个全局的 window 对象(浏览器的情况下),并且设置 this 的值等于这个全局对象。一个程序中只会有一个全局执行上下文
- 函数执行上下文:每当一个函数被调用时, 都会为该函数创建一个新的上下文。每个函数都有它自己的执行上下文,不过是在函数被调用时创建的。函数上下文可以有任意多个。每当一个新的执行上下文被创建,它会按定义的顺序(将在后文讨论)执行一系列步骤。
- Eval 函数执行上下文:执行在 eval 函数内部的代码也会有它属于自己的执行上下文
- 调用栈
调用栈是解析器(如浏览器中的的 JavaScript 解析器)的一种机制,可以在脚本调用多个函数时,跟踪每个函数在完成执行时应该返回控制的点(如什么函数正在执行,什么函数被这个函数调用,下一个调用的函数是谁)
- 当脚本要调用一个函数时,解析器把该函数添加到栈中并且执行这个函数
- 任何被这个函数调用的函数会进一步添加到调用栈中,并且运行到它们被上个程序调用的位置
- 当函数运行结束后,解释器将它从堆栈中取出,并在主代码列表中继续执行代码
- 如果栈占用的空间比分配给它的空间还大,那么则会导致“栈溢出”错误
(八)作用域和作用域链
1.经典真题
- 谈谈你对作用域和作用域链的理解?
2.作用域(Scope)
- 作用域是在运行时代码中的某些特定部分中变量、函数和对象的可访问性
- 即:作用域决定了代码区块中变量和其他资源的可见性
function outFun2() {
var inVariable = "内层变量2";
}
outFun2();
console.log(inVariable); // Uncaught ReferenceError: inVariable is not defined
变量 inVariable 在全局作用域没有声明,所以在全局作用域下取值会报错
- 作用域就是一个独立的地盘,让变量不会外泄、暴露出去
- 作用域最大的用处就是 隔离变量 ,不同作用域下同名变量不会有冲突
- ES6 之前 JavaScript 只有全局作用域和函数作用域
- ES6 提供了块级作用域,可通过 let 和 const 体现
3.全局作用域
- 在代码中任何地方都能访问到的对象拥有全局作用域
1)拥有全局作用域的情况
- 最外层函数和在最外层函数外面定义的变量拥有全局作用域
var outVariable = "我是最外层变量"; // 最外层变量
function outFun() {
// 最外层函数
var inVariable = "内层变量";
function innerFun() {
// 内层函数
console.log(inVariable);
}
innerFun();
}
console.log(outVariable); // 我是最外层变量
outFun(); // 内层变量
console.log(inVariable); // inVariable is not defined
innerFun(); // innerFun is not defined
- 所有未定义直接赋值的变量自动声明为拥有全局作用域
function outFun2() {
variable = "未定义直接赋值的变量";
var inVariable2 = "内层变量2";
}
outFun2(); // 要先执行这个函数,否则根本不知道里面是啥
console.log(variable); // 未定义直接赋值的变量
console.log(inVariable2); // inVariable2 is not defined
- 所有 window 对象的属性拥有全局作用域
- 如:window.name、window.location、window.top 等等
2)全局作用域弊端
- 会污染全局命名空间,容易引起命名冲突
// 张三写的代码中
var data = { a: 100 };
// 李四写的代码中
var data = { x: true };
- 因此 jQuery、Zepto 等库的源码,所有的代码都会放在
(function(){....})()
中 - 放在里面的所有变量,都不会被外泄和暴露,不会污染到全局,不会对其他的库或者 JS 脚本造成影响
- 这是函数作用域的一个体现
4.函数作用域
- 指声明在函数内部的变量
- 和全局作用域相反,局部作用域一般只在 固定的代码片段内可访问到
- 最常见的如:函数内部 => 函数作用域
function doSomething() {
var stuName = "zhangsan";
function innerSay() {
console.log(stuName);
}
innerSay();
}
console.log(stuName); // 脚本错误
innerSay(); // 脚本错误
- 作用域是分层的,内层作用域可以访问外层作用域的变量,反之则不行
输出的结果为 2、4、12
1 是全局作用域,有标识符 foo
2 是作用域 foo,有标识符 a、bar、b
3 是作用域 bar,仅有标识符 c
- 块语句(大括号
{}
中间的语句,如:if、switch 条件语句或 for、while 循环语句),不像函数 - 不会创建一个新的作用域
- 在块语句中定义的变量将保留在它们已经存在的作用域中
if (true) {
// 'if' 条件语句块不会创建一个新的作用域
var name = "Hammad"; // name 依然在全局作用域中
}
console.log(name); // logs 'Hammad'
5.块级作用域
- 块级作用域可通过 let 和 const 声明
- 所声明的变量在指定块的作用域外无法被访问
1)创建块级作用域
- 在一个函数内部
- 在一个代码块(由一对花括号包裹)内部
2)块级作用域特点
a)声明变量不会提升到代码块顶部
- 需要手动将 let、const 声明放置到顶部,以便让变量在整个代码块内部可用
function getValue(condition) {
if (condition) {
let value = "blue";
return value;
} else {
// value 在此处不可用
return null;
}
// value 在此处不可用
}
b)禁止重复声明
- 如果一个标识符已经在代码块内部被定义,那么在此代码块内使用同一个标识符进行 let 声明就会抛出错误
var count = 30;
let count = 40; // Uncaught SyntaxError: Identifier 'count' has already been declared
- 如果在嵌套的作用域内使用 let 声明一个同名的新变量,则不会抛出错误
var count = 30;
// 不会抛出错误
if (condition) {
let count = 40;
// 其他代码
}
c)循环中绑定块级作用域
- 可以把声明的计数器变量限制在循环内
- 需求: 点击某个按钮, 提示“点击的是第 n 个按钮”
<button>测试1</button>
<button>测试2</button>
<button>测试3</button>
var btns = document.getElementsByTagName("button");
for (var i = 0; i < btns.length; i++) {
btns[i].onclick = function () {
console.log("第" + (i + 1) + "个");
};
}
- 点击任意一个按钮,都是弹出“第四个”
- 因为 i 是全局变量,执行到点击事件时 i 的值为 3
- 最简单的是用 let 声明 i
for (let i = 0; i < btns.length; i++) {
btns[i].onclick = function () {
console.log("第" + (i + 1) + "个");
};
}
6.作用域链
1)自由变量
- 当前作用域没有定义的变量
- 自由变量的值需要向父级作用域寻找
- 这种说法并不严谨
- 要到创建这个函数的作用域中寻找
var a = 100;
function fn() {
var b = 200;
console.log(a); // 这里的 a 在这里就是一个自由变量
console.log(b);
}
fn();
2)作用域链
- 如果父级没找到自由变量的值
- 再一层一层向上寻找,直到找到全局作用域还是没找到,就返回 undefined
- 这种一层一层的关系,就是作用域链
var a = 100;
function f1() {
var b = 200;
function f2() {
var c = 300;
console.log(a); // 100 自由变量,顺作用域链向父作用域找
console.log(b); // 200 自由变量,顺作用域链向父作用域找
console.log(c); // 300 本作用域的变量
}
f2();
}
f1();
7.关于自由变量的取值
上文提到要到父作用域中取,其实有时候这种解释会产生歧义
1)示例 1
var x = 10;
function fn() {
console.log(x);
}
function show(f) {
var x = 20;
(function () {
f(); // 10,而不是 20
})();
}
show(fn);
- 在 fn 函数中取自由变量 x 的值时,要到创建 fn 函数的那个作用域中取
- 无论 fn 函数将在哪里调用
- 这里强调的是“创建”,而不是“调用”
- 这就是所谓的“静态作用域”
2)示例 2
const food = "rice";
const eat = function () {
console.log(`eat ${food}`);
};
(function () {
const food = "noodle";
eat(); // eat rice
})();
- 对于
eat()
函数来说,创建该函数时的父级上下文为全局上下文 - 所以 food 的值为 rice
const food = "rice";
(function () {
const food = "noodle";
const eat = function () {
console.log(`eat ${food}`);
};
eat(); // eat noodle
})();
- 对于
eat()
函数来说,创建时的父级上下文为 IIFE - 所以 food 的值为 noodle
8.作用域与执行上下文
- JavaScript 属于解释型语言
- 执行分为:解释和执行两个阶段
1)解释阶段
- 词法分析
- 语法分析
- 作用域规则确定
2)执行阶段
- 创建执行上下文
- 执行函数代码
- 垃圾回收
3)区分
- 作用域在函数定义时确定
- 而不是在函数调用时确定
- 因为解释阶段会确定作用域规则
- 执行上下文在函数执行前创建
- this 的指向在函数执行时确定
- 作用域访问的变量由编写代码的结构确定
作用域和执行上下文之间的最大区别
- 执行上下文在运行时确定,随时可能改变
- 作用域在定义时就确定,并且不会改变
9.真题解答
1)谈谈你对作用域和作用域链的理解?
- 作业域
ES5 中只存在两种作用域:全局作用域和函数作用域,ES6 新增了块级作用域
在 JavaScript 中将作用域定义为一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套子作用域中根据标识符名称查找变量(变量名或者函数名)
- 作用域链
当访问一个变量时,编译器在执行这段代码时,会首先从当前的作用域中查找是否有这个标识符,如果没有找到,就会去父作用域查找,如果父作用域还没找到继续向上查找,直到全局作用域为止
而作用域链,就是由当前作用域与上层作用域的一系列变量对象组成,保证了当前执行的作用域对符合访问权限的变量和函数的有序访问
作用域链有一个非常重要的特性,那就是作用域中的值是在函数创建的时候,就已经被存储了,是静态的
所谓静态,就是说作用域中的值一旦被确定了,永远不会变。函数可以永远不被调用,但是作用域中的值在函数创建的时候就已经被写入了,并且存储在函数作用域链对象里面
(九)this 指向
1.经典真题
- this 的指向有哪几种?
2.this 指向的规律
- this 总是返回一个对象
关于 this 的指向,有一种广为流传的说法就是“谁调用它,this 就指向谁”
这样的说法没有太大的问题,但是并不是太全面
- 在函数体中,非显式或隐式地简单调用函数时
- 在严格模式下,函数内的 this 会被绑定到 undefined 上
- 在非严格模式下,会被绑定到全局对象 window/global 上
- 一般使用 new 方法调用构造函数时
- 构造函数内的 this 会被绑定到新创建的实例对象上
- 一般通过 call/apply/bind 方法显式调用函数时
- 函数体内的 this 会被绑定到指定参数的对象上
- 一般通过上下文对象调用函数时
- 函数体内的 this 会被绑定到该对象上
- 在箭头函数中
- this 的指向由外层(函数或全局)作用域决定
3.全局环境中的 this
1)示例 1
function f1() {
console.log(this);
}
function f2() {
"use strict";
console.log(this);
}
f1(); // window or global
f2(); // undefined
2)示例 2
const foo = {
bar: 10,
fn: function () {
console.log(this); // window or global
console.log(this.bar); // undefined
},
};
var fn1 = foo.fn;
fn1();
this 仍然指向 window
fn 函数在 foo 对象中作为该对象的一个方法,但是在赋值给 fn1 之后,fn1 仍然是在 window 的全局环境下执行的
因此上面的代码仍然会输出 window 和 undefined
3)示例 3
const foo = {
bar: 10,
fn: function () {
console.log(this); // { bar: 10, fn: [Function: fn] }
console.log(this.bar); // 10
},
};
foo.fn();
this 指向的是最后调用它的对象
在 foo.fn() 语句中,this 指向的是 foo 对象
4.上下文对象调用中的 this
1)示例 1
const student = {
name: "zhangsan",
fn: function () {
return this;
},
};
console.log(student.fn() === student); // true
this 指向当前的对象 student,所以最终会返回 true
2)示例 2
const student = {
name: "zhangsan",
son: {
name: "zhangxiaosan",
fn: function () {
return this.name;
},
},
};
console.log(student.son.fn()); // zhangxiaosan
this 指向最后调用它的对象(son)
3)示例 3
const o1 = {
text: "o1",
fn: function () {
return this.text;
},
};
const o2 = {
text: "o2",
fn: function () {
return o1.fn();
},
};
const o3 = {
text: "o3",
fn: function () {
var fn = o1.fn;
return fn();
},
};
console.log(o1.fn()); // o1
console.log(o2.fn()); // o1
console.log(o3.fn()); // undefined
第三个是 undefined
这里将 o1.fn 赋值给了 fn,所以
fn === function () { return this.text; }
该函数在调用的时候,是直接 fn() 的形式调用的,并不是以对象的形式
相当于还是全局调用,指向 window,所以打印出 undefined
5.this 指向绑定事件的元素
- DOM 元素绑定事件时,事件处理函数里面的 this 指向 绑定事件的元素
- 事件处理函数里面的
e.target
指向 触发事件的元素
<ul id="color-list">
<li>red</li>
<li>yellow</li>
<li>blue</li>
<li>green</li>
<li>black</li>
<li>white</li>
</ul>
// this 是绑定事件的元素
// target 是触发事件的元素 和 srcElement 等价
let colorList = document.getElementById("color-list");
colorList.addEventListener("click", function (event) {
console.log("this:", this);
console.log("target:", event.target);
console.log("srcElement:", event.srcElement);
});
- 在 div 节点的事件函数内部,有一个局部的 callback 方法
- 该方法被作为普通函数调用时,callback 内部的 this 是指向全局对象 window 的
<div id="div1">我是一个div</div>
window.id = "window";
document.getElementById("div1").onclick = function () {
console.log(this.id); // div1
const callback = function () {
console.log(this.id); // 因为是普通函数调用,所以 this 指向 window
};
callback();
};
- 此时有一种简单的解决方案
- 可以用一个变量 保存 div 节点的引用
window.id = "window";
document.getElementById("div1").onclick = function () {
console.log(this.id); // div1
const that = this; // 保存当前 this 的指向
const callback = function () {
console.log(that.id); // div1
};
callback();
};
相关信息
- this 的指向受函数运行环境的影响,指向经常改变,使得开发变得困难和模糊
- 所以在封装 sdk 或者写一些复杂函数的时候经常会用到 this 指向绑定,以避免出现不必要的问题
6.call、apply、bind 方法修改 this 指向
1)Function.prototype.call()
a)指定 this 的指向
- 即:指定函数执行时所在的的作用域
- 然后在指定的作用域中执行函数
var obj = {};
var f = function () {
return this;
};
console.log(f() === window); // this 指向 window
console.log(f.call(obj) === obj); // 改变 this 指向 obj
b)一个参数:this 新指向
- call 方法的参数应该是对象 obj
- 如果参数为空或 null、undefined,则默认指向全局对象
var n = 123;
var obj = { n: 456 };
function a() {
console.log(this.n);
}
a.call(); // 123
a.call(null); // 123
a.call(undefined); // 123
a.call(window); // 123
a.call(obj); // 456
- 如果参数不是以上类型
- 则转化成对应的包装对象,然后传入方法
var f = function () {
return this;
};
f.call(5); // Number {[[PrimitiveValue]]: 5}
c)多个参数
- 第一个参数是 this 指向的对象
- 之后的是函数回调所需的参数
function add(a, b) {
return a + b;
}
add.call(this, 1, 2); // 3
d)应用
- 调用对象的原生方法
var obj = {};
obj.hasOwnProperty("toString"); // false
// 覆盖掉继承的 hasOwnProperty 方法
obj.hasOwnProperty = function () {
return true;
};
obj.hasOwnProperty("toString"); // true
Object.prototype.hasOwnProperty.call(obj, "toString"); // false
2)Function.prototype.apply()
- 作用和 call 类似,也是改变 this 指向,然后调用该函数
- 唯一区别是 apply 接收 数组 作为函数执行时的参数
func.apply(thisValue, [arg1, arg2, ...])
a)参数
- 第一个参数是 this 所要指向的那个对象
- 如果设为 null 或 undefined,则等同于指定全局对象
- 第二个参数是一个数组
- 该数组的所有成员依次作为参数,传入原函数
function f(x, y) {
console.log(x + y);
}
f.call(null, 1, 1); // 2
f.apply(null, [1, 1]); // 2
b)应用
- 输出数组的最大值
var a = [24, 30, 2, 33, 1];
Math.max.apply(null, a); //33
- 利用 Array 构造函数将数组中的空值转化成 undefined
var a = ["a", , "b"];
Array.apply(null, a); //['a',undefined,'b']
空元素与 undefined 的差别
数组的 forEach 方法会跳过空元素,但是不会跳过 undefined
- 配合数组对象的 slice 方法可以将一个类似数组的对象(如:arguments 对象)转为真正的数组
- 被处理的对象必须要有 length 属性,以及相对应的数字键
Array.prototype.slice.apply({ 0: 1, length: 1 }); // [1]
Array.prototype.slice.apply({ 0: 1 }); // []
Array.prototype.slice.apply({ 0: 1, length: 2 }); // [1, undefined]
Array.prototype.slice.apply({ length: 1 }); // [undefined]
3)Function.prototype.bind()
- bind 用于将函数体内的 this 绑定到某个对象,然后返回一个新函数
var d = new Date();
d.getTime(); // 1481869925657
var print = d.getTime;
print(); // Uncaught TypeError: this is not a Date object.
报错是因为 d.getTime 赋值给 print 后,getTime 内部的 this 指向发生变化,不再指向 date 对象实例
var print = d.getTime.bind(d);
print(); // 1481869925657
a)一个参数
- 接收的参数就是所要绑定的对象
var counter = {
count: 0,
inc: function () {
this.count++;
},
};
var func = counter.inc.bind(counter);
func();
console.log(counter.count); // 1
- 绑定到其他对象
var counter = {
count: 0,
inc: function () {
this.count++;
},
};
var obj = {
count: 100,
};
var func = counter.inc.bind(obj);
func();
console.log(obj.count); // 101
b)多个参数
- 将这些参数绑定到原函数的参数
var add = function (x, y) {
return x * this.m + y * this.n;
};
var obj = {
m: 2,
n: 2,
};
var newAdd = add.bind(obj, 5); // x
newAdd(5); // 20
- 如果第一个参数是 null 或 undefined,即是将 this 绑定到全局对象
- 函数运行时 this 指向顶层对象(浏览器为 window,Node 为 global)
function add(x, y) {
return x + y;
}
var plus5 = add.bind(null, 5);
plus5(10); // 15
主要目的是绑定参数 x,以后每次运行新函数 plus5 只需要提供另一个参数 y
c)每一次返回一个新函数
- 会产生一些问题
- 如:监听事件的时候,不能写成下面这样
element.addEventListener("click", o.m.bind(o));
click 事件绑定 bind 方法生成的一个匿名函数
这样会导致无法取消绑定
- 正确的写法
var listener = o.m.bind(o);
element.addEventListener("click", listener);
// ...
element.removeEventListener("click", listener);
d)结合回调函数使用
- 使用回调模式时的常见错误,是将包含 this 的方法直接当作回调函数
- 解决方法就是使用 bind 方法,将 counter.inc 绑定 counter
var counter = {
count: 0,
inc: function () {
"use strict";
this.count++;
},
};
function callIt(callback) {
callback();
}
callIt(counter.inc.bind(counter));
counter.count; // 1
callIt 方法会调用回调函数
如果直接把 counter.inc 传入,调用时 counter.inc 内部的 this 就会指向全局对象
使用 bind 方法将 counter.inc 绑定 counter 后,this 总是指向 counter
- 某些数组方法接受一个函数当作参数
- 这些函数内部的 this 指向很可能也会出错
var obj = {
name: "张三",
times: [1, 2, 3],
print: function () {
this.times.forEach(function (n) {
console.log(this.name);
});
},
};
obj.print(); // 没有任何输出
obj.print 内部 this.times 的 this 指向 obj
但是,forEach 方法的回调函数内部的 this.name 却是指向全局对象,导致没有办法取到值
obj.print = function () {
this.times.forEach(function (n) {
console.log(this === window);
});
};
obj.print();
// true
// true
// true
- 通过 bind 方法绑定 this
obj.print = function () {
this.times.forEach(
function (n) {
console.log(this.name);
}.bind(this),
);
};
obj.print();
// 张三
// 张三
// 张三
e)结合 call 方法使用
- 可以改写一些 JavaScript 原生方法的使用形式
- 以数组的 slice 方法为例
[1, 2, 3].slice(0, 1); // [1]
// 等同于
Array.prototype.slice.call([1, 2, 3], 0, 1); // [1]
数组的 slice 方法从 [1, 2, 3] 里面,按照指定位置和长度切分出另一个数组
本质是在 [1, 2, 3] 上调用 Array.prototype.slice 方法,因此可以用 call 方法表达这个过程,得到同样的结果
call 方法实质上是调用 Function.prototype.call 方法,因此上面的表达式可以用 bind 方法改写
var slice = Function.prototype.call.bind(Array.prototype.slice);
slice([1, 2, 3], 0, 1); // [1]
将 Array.prototype.slice 变成 Function.prototype.call 方法所在的对象
调用时就变成了 Array.prototype.slice.call
类似的写法还可以用于其他数组方法
var push = Function.prototype.call.bind(Array.prototype.push);
var pop = Function.prototype.call.bind(Array.prototype.pop);
var a = [1, 2, 3];
push(a, 4);
console.log(a); // [1, 2, 3, 4]
pop(a);
console.log(a); // [1, 2, 3]
再进一步,将 Function.prototype.call 方法绑定到 Function.prototype.bind 对象
意味着 bind 的调用形式也可以被改写
function f() {
console.log(this.v);
}
var o = { v: 123 };
var bind = Function.prototype.call.bind(Function.prototype.bind);
bind(f, o)(); // 123
将 Function.prototype.bind 方法绑定在 Function.prototype.call 上
所以 bind 方法就可以直接使用,不需要在函数实例上使用
7.箭头函数的 this 指向
- 当 this 是以函数的形式调用时,指向的是全局对象
- 箭头函数的 this 始终指向外层的作用域
1)示例 1
- 普通函数
const obj = {
x: 10,
test: function () {
console.log(this); // 指向 obj 对象
console.log(this.x); // 10
},
};
obj.test();
// { x: 10, test: [Function: test] }
// 10
- 箭头函数
var x = 20;
const obj = {
x: 10,
test: () => {
console.log(this); // {}
console.log(this.x); // undefined
},
};
obj.test();
// {}
// undefined
- this 实际上指向的是全局对象
- 将这段代码放入浏览器运行
- 在浏览器中用 var 所声明的变量会成为全局对象 window 的一个属性
2)示例 2
- 普通函数
var name = "JavaScript";
const obj = {
name: "PHP",
test: function () {
const i = function () {
console.log(this.name);
// i 是以函数的形式被调用的,所以 this 指向全局
// 在浏览器环境中打印出 JavaScript,node 里面为 undefined
};
i();
},
};
obj.test(); // JavaScript
- 箭头函数
var name = "JavaScript";
const obj = {
name: "PHP",
test: function () {
const i = () => {
console.log(this.name);
// 由于 i 为一个箭头函数,所以 this 是指向外层的
// 所以 this.name 将会打印出 PHP
};
i();
},
};
obj.test(); // PHP
3)箭头函数不能作为构造函数
const Test = (name, age) => {
this.name = name;
this.age = age;
};
const test = new Test("xiejie", 18);
// TypeError: Test is not a constructor
8.真题解答
1)this 的指向有哪几种?
this 的指向规律有如下几条:
- 在函数体中,非显式或隐式地简单调用函数时,在严格模式下,函数内的 this 会被绑定到 undefined 上,在非严格模式下则会被绑定到全局对象 window/global 上
- 一般使用 new 方法调用构造函数时,构造函数内的 this 会被绑定到新创建的对象上
- 一般通过 call/apply/bind 方法显式调用函数时,函数体内的 this 会被绑定到指定参数的对象上
- 一般通过上下文对象调用函数时,函数体内的 this 会被绑定到该对象上
- 在箭头函数中,this 的指向是由外层(函数或全局)作用域来决定的
(十)垃圾回收与内存泄漏
1.经典真题
- 请介绍一下 JavaScript 中的垃圾回收站机制
2.内存泄露
- 程序的运行需要内存
- 只要程序申请内存空间,操作系统或者运行时(runtime)就必须供给内存
- 对于持续运行的服务进程(daemon),必须及时释放不再用到的内存
- 否则,内存占用越来越高
- 轻则影响系统性能,重则导致进程崩溃
- 如果没有及时释放不再用到的内存,就会造成内存泄漏(memory leak)
3.JavaScript 中的垃圾回收
- 浏览器的 JavaScript 具有自动垃圾回收机制(GC,Garbage Collection)
- 执行环境会负责管理代码执行过程中使用的内存
1)原理
- 垃圾收集器会定期(周期性)找出那些不再继续使用的变量,然后释放其内存
2)GC 不是实时的
- 因为其开销比较大
- 且 GC 过程中会停止响应其他操作
- 所以垃圾回收器会按照固定的时间间隔,周期性地执行
3)不再使用的变量
- 也就是生命周期结束的变量,当然只可能是局部变量
- 全局变量的生命周期直至浏览器卸载页面才会结束
- 局部变量只在函数的执行过程中存在
- 在这个过程中会为局部变量在栈或堆上分配相应的空间,以存储它们的值
- 然后在函数中使用这些变量,直至函数结束
- 而闭包中由于内部函数的原因,外部函数并不能算是结束
- 所以闭包中的变量不会被回收
function fn1() {
var obj = {
name: "zhangsan",
age: 10,
};
}
function fn2() {
var obj = {
name: "zhangsan",
age: 10,
};
return obj;
}
var a = fn1();
var b = fn2();
当 fn1 被调用时,进入 fn1 的执行环境,会开辟一块内存,用于存放对象
{ name: 'zhangsan', age: 10 }
而当调用结束后,离开 fn1 的执行环境,那么该块内存会被 JavaScript 引擎中的垃圾回收器自动释放
在 fn2 被调用的过程中,返回的对象被全局变量 b 所指向,所以该块内存并不会被释放
这里问题就出现了:到底哪个变量是没有用的?
- 所以垃圾收集器必须跟踪到底哪个变量没用
- 对于不再有用的变量打上标记,以备将来收回其内存
- 用于标记的无用变量的策略可能因实现而有所区别
- 通常情况下有两种实现方式
- 标记清除【常用】
- 引用计数
4.标记清除
- JavaScript 中最常用的垃圾回收方式
- 当变量进入环境时,就将这个变量标记为“进入环境”
- 如:在函数中声明一个变量
- 当变量离开环境时,则将其标记为“离开环境”
从逻辑上讲,永远不能释放进入环境的变量所占用的内存
因为只要执行流进入相应的环境,就可能会用到它们
function test() {
var a = 10; // a被标记,进入环境
var b = 20; // b被标记,进入环境
}
test(); // 执行完毕后,a、b 又被标记为离开环境,被回收
- 垃圾回收器在运行的时候会给存储在 内存中的所有变量 都加上标记
- 然后去掉 环境中的变量 以及 被环境中的变量引用的变量的标记(闭包)
- 之后再被加上标记的变量将被视为准备删除的变量
- 因为环境中的变量已经无法访问到这些变量了
- 最后,垃圾回收器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间
相关信息
到目前为止,IE9+、Firefox、Opera、Chrome、Safari 的 JS 实现使用的都是标记清除的垃圾回收策略或类似的策略,只不过垃圾收集的时间间隔互不相同
5.引用计数
- 跟踪记录每个值被引用的次数
1)流程
- 当声明了一个变量并将一个 引用类型值 赋给该变量时,这个值的引用次数就是 1
- 如果同一个值又被赋给另一个变量,则该值的引用次数 +1
- 如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数 -1
- 当这个值的引用次数变成 0 时,说明没有办法再访问这个值了
- 就可以回收其占用的内存空间
- 当垃圾回收器下次再运行时,就会释放那些引用次数为 0 的值所占用的内存
function test() {
var a = {}; // a 指向对象的引用次数为 1
var b = a; // a 指向对象的引用次数加 1,为 2
var c = a; // a 指向对象的引用次数再加 1,为 3
var b = {}; // a 指向对象的引用次数减 1,为 2
}
相关信息
Netscape Navigator3 是最早使用引用计数策略的浏览器,但很快就遇到一个严重的问题 —— 循环引用
2)循环引用
- 指的是对象 A 中包含一个指向对象 B 的指针,而对象 B 中也包含一个指向对象 A 的引用
function fn() {
var a = {};
var b = {};
a.pro = b;
b.pro = a;
}
fn();
a 和 b 的引用次数都是 2
fn 执行完毕后,两个对象都已经离开环境,在标记清除方式下是没有问题的
但是在引用计数策略下,因为 a 和 b 的引用次数不为 0,所以不会被垃圾回收器回收
如果 fn 函数被大量调用,就会造成内存泄露
在 IE7 与 IE8 上,内存直线上升
6.真题解答
1)请介绍一下 JavaScript 中的垃圾回收站机制
JavaScript 具有自动垃圾回收机制。垃圾收集器会按照固定的时间间隔周期性的执行
JavaScript 常见的垃圾回收方式:标记清除、引用计数方式
- 标记清除
- 工作原理:当变量进入环境时,将这个变量标记为“进入环境”。当变量离开环境时,则将其标记为“离开环境”。标记“离开环境”的就回收内存
- 工作流程:
- 垃圾回收器,在运行的时候会给存储在内存中的所有变量都加上标记;
- 去掉环境中的变量以及被环境中的变量引用的变量的标记;
- 被加上标记的会被视为准备删除的变量;
- 垃圾回收器完成内存清理工作,销毁那些带标记的值并回收他们所占用的内存空间
- 引用计数
- 工作原理:跟踪记录每个值被引用的次数
- 工作流程:
- 声明了一个变量并将一个引用类型的值赋值给这个变量,这个引用类型值的引用次数就是 1;
- 同一个值又被赋值给另一个变量,这个引用类型值的引用次数加 1;
- 当包含这个引用类型值的变量又被赋值成另一个值了,那么这个引用类型值的引用次数减 1;
- 当引用次数变成 0 时,说明没办法访问这个值了;
- 当垃圾收集器下一次运行时,它就会释放引用次数是 0 的值所占的内存
(十一)闭包
1.经典真题
- 闭包是什么?闭包的应用场景有哪些?怎么销毁闭包?
2.前置知识
1)JavaScript 中的作用域和作用域链
- 作用域就是一个独立的地盘,让变量不会外泄、暴露出去,不同作用域下同名变量不会有冲突
- 作用域在定义时就确定,并且不会改变
- 如果在当前作用域中没有查到值,就会向上级作用域查找,直到查到全局作用域,这么一个查找过程形成的链条就叫做作用域链
2)JavaScript 中的垃圾回收
- JavaScript 执行环境会负责管理代码执行过程中使用的内存,其中就涉及到一个垃圾回收机制
- 垃圾收集器会定期(周期性)找出那些不再继续使用的变量,只要该变量不再使用了,就会被垃圾收集器回收,然后释放其内存。如果该变量还在使用,那么就不会被回收
3.闭包介绍
- 闭包不是一个具体的技术,而是一种现象
- 指在定义函数时,周围环境中的信息可以在函数中使用
- 即:执行函数时,只要在函数中使用了外部的数据,就创建了闭包
- 作用域链是实现闭包的手段
1)创建闭包
在函数 a 中定义了一个变量 i,然后打印这个 i 变量
对于 a 函数,函数作用域中存在 i 这个变量
所以在调试时可以看到 Local 中存在变量 i
将声明 i 变量的动作放到了 a 函数外面,也就是说 a 函数在自己的作用域已经找不到这个 i 变量了
会顺着作用域链一层一层往外找,如果出现了这种情况,也就是函数使用了外部数据的情况,就会创建闭包
- “闭”可以理解为“封闭,闭环”,“包”可以理解为“一个类似于包裹的空间”
- 闭包实际上可以看作是一个封闭的空间,用来存储变量
2)闭包中的变量
- 一个函数下所有的变量声明是否会被放入到闭包
- 要看其他地方有没有对这个变量进行引用
函数 c 中一个变量都没有创建,却要打印 i、j、k 和 x,这些变量分别存在于 a、b 函数以及全局作用域中,因此创建了 3 个闭包
全局闭包里面存储了 i 的值,闭包 a 中存储了变量 j 和 k 的值,闭包 b 中存储了变量 x 的值
但是函数 b 中的 y 变量并没有被放在闭包中,所以要不要放入闭包取决于该变量有没有被引用
3)销毁闭包
- 自动形成的闭包会被销毁
在第 16 行尝试打印输出变量 k,显然会报错
此时已经没有任何闭包存在,垃圾回收器会自动回收没有引用的变量,不会有任何内存占用的情况
4)手动创建闭包
function eat() {
var food = "鸡翅";
console.log(food);
}
eat(); // 鸡翅
console.log(food); // 报错
声明了一个名为 eat 的函数,并对它进行调用
JavaScript 引擎会创建一个 eat 函数的执行上下文,其中声明 food 变量并赋值
当该方法执行完后,上下文被销毁,food 变量也会跟着消失
因为 food 变量属于 eat 函数的局部变量,作用于 eat 函数中,会随着 eat 的执行上下文创建而创建,销毁而销毁
所以再次打印 food 变量时,就会报错,该变量不存在
function eat() {
var food = "鸡翅";
return function () {
console.log(food);
};
}
var look = eat();
look(); // 鸡翅
look(); // 鸡翅
eat 函数返回一个函数,并在这个内部函数中访问 food 这个局部变量。调用 eat 函数并将结果赋给 look 变量,这个 look 指向了 eat 函数中的内部函数,然后调用它,最终输出 food 的值
垃圾回收器只会回收没有被引用到的变量,但是一旦一个变量还被引用着的,垃圾回收器就不会回收此变量
向外部返回了 eat 内部的匿名函数,而这个匿名函数有引用了 food,所以垃圾回收器不会对其进行回收
4.闭包应用
- 通过闭包可以让外部环境访问到函数内部的局部变量
- 通过闭包可以让局部变量持续保存下来,不随着它的上下文环境一起销毁
1)解决全局变量污染的问题
早期在 JavaScript 还无法进行模块化的时候,在多人协作时,如果定义过多的全局变量,有可能造成全局变量命名冲突
- 使用闭包来解决功能对变量的调用
- 将变量写到一个独立的空间里面
var name = "GlobalName";
// 全局变量
var init = (function () {
var name = "initName";
function callName() {
console.log(name);
// 打印 name
}
return function () {
callName();
// 形成接口
};
})();
init(); // initName
var initSuper = (function () {
var name = "initSuperName";
function callName() {
console.log(name);
// 打印 name
}
return function () {
callName();
// 形成接口
};
})();
initSuper(); // initSuperName
5.闭包总结
- 闭包是一个封闭的空间,存储了在其他地方会引用到的该作用域的值
- 在 JavaScript 中是通过作用域链来实现闭包
- 只要在函数中使用了外部的数据,就创建了闭包
- 这种情况下所创建的闭包,编码时不需要关心
- 还可以通过一些手段手动创建闭包,从而让外部环境访问到函数内部的局部变量,让局部变量持续保存下来,不随着它的上下文环境一起销毁
6.闭包经典问题
for (var i = 1; i <= 3; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
预期的结果是过 1 秒后分别输出 i 变量的值为 1,2,3。但是,执行的结果是:4,4,4
问题就出在闭包身上,循环中的 setTimeout 访问了外部变量 i,形成闭包
而 i 变量只有 1 个,所以循环 3 次的 setTimeout 中都访问的是同一个变量。循环到第 4 次,i 变量增加到 4,不满足循环条件,循环结束,代码执行完后上下文结束。但是,那 3 个 setTimeout 等 1 秒钟后才执行,由于闭包的原因,所以它们仍然能访问到变量 i,不过此时 i 变量值已经是 4 了
要解决这个问题,可以让 setTimeout 中的匿名函数不再访问外部变量,而是访问自己内部的变量
for (var i = 1; i <= 3; i++) {
(function (index) {
setTimeout(function () {
console.log(index);
}, 1000);
})(i);
}
这样 setTimeout 中就可以不用访问 for 循环声明的变量 i 了,而是采用调用函数传参的方式把变量 i 的值传给了 setTimeout,这样就不再创建闭包,因为在自己的作用域里面能够找到 i 这个变量
还可以使用 ES6 中的 let 关键字
let 声明的变量有块作用域,如果放在循环中,那么每次循环都会有一个新的变量 i,这样即使有闭包也没问题,因为每个闭包保存的都是不同的 i 变量
for (let i = 1; i <= 3; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
7.真题解答
1)闭包是什么?闭包的应用场景有哪些?怎么销毁闭包?
闭包是一个封闭的空间,里面存储了在其他地方会引用到的该作用域的值,在 JavaScript 中是通过作用域链来实现的闭包
只要在函数中使用了外部的数据,就创建了闭包,这种情况下所创建的闭包在编码时是不需要去关心的
还可以通过一些手段手动创建闭包,从而让外部环境访问到函数内部的局部变量,让局部变量持续保存下来,不随着它的上下文环境一起销毁
使用闭包可以解决一个全局变量污染的问题
如果是自动产生的闭包,无需操心闭包的销毁,而如果是手动创建的闭包,可以把被引用的变量设置为 null,即手动清除变量,这样下次 JavaScript 垃圾回收器在进行垃圾回收时,发现此变量已经没有任何引用了,就会把设为 null 的量给回收了
(十二)DOM 事件的注册和移除
1.经典真题
- 总结一下 DOM 中如何注册事件和移除事件
2.DOM 注册事件
1)HTML 元素中注册事件
- HTML 元素中注册的事件,又被称为行内事件监听器
- 是在浏览器中处理事件最原始的方法
<button onclick="test('张三')">点击我</button>
function test(name) {
console.log(`我知道你已经点击了,${name}`);
}
- 在 JavaScript 中只需要书写对应的 test 事件处理函数即可
- 但是这种方法已经过时了
- JavaScript 代码与 HTML 标记混杂在一起,破坏了结构和行为分离的理念
- 每个元素只能为每种事件类型绑定一个事件处理器
- 事件处理器的代码隐藏于标记中,很难找到事件是在哪里声明的
- 现在多用于简单的事件测试
2)DOM0 级方式注册事件
- 首先取到要为其绑定事件的元素节点对象
- 然后给这些节点对象的事件处理属性赋值一个函数
- 可以达到 JavaScript 代码和 HTML 代码相分离的目的
<button id="test">点击我</button>
var test = document.getElementById("test");
test.onclick = function () {
console.log("this is a test");
};
- 每个元素只能绑定一个函数
var test = document.getElementById("test");
test.onclick = function () {
console.log("this is a test");
};
test.onclick = function () {
console.log("this is a test,too");
};
- 当为该 DOM 元素绑定 2 个相同类型的事件时
- 后面的事件处理函数就会把前面的事件处理函数给覆盖掉
2)DOM2 级方式注册事件
- 通过 addEventListener 方法来为一个 DOM 元素添加多个事件处理函数
- 接收 3 个参数:事件名、事件处理函数、布尔值
- 如果这个布尔值为 true,则在捕获阶段处理事件
- 如果为 false,则在冒泡阶段处理事件
- 若最后的布尔值不填写,则默认为 false
var test = document.getElementById("test");
test.addEventListener(
"click",
function () {
console.log("this is a test");
},
false,
);
test.addEventListener(
"click",
function () {
console.log("this is a test,too");
},
false,
);
在 IE 中与 addEventListener 方法对应的是 attachEvent 方法
3.DOM 移除事件
- 通过 DOM0 级来添加的事件,只需要将 DOM 元素的事件处理属性赋值为 null 即可
var test = document.getElementById("test");
// 该事件只会生效一次
test.onclick = function () {
console.log("this is a test");
test.onclick = null;
};
- 通过 DOM2 级来添加的事件,可以使用 removeEventLister 方法删除
- 如果要通过该方法移除 某一类事件类型的一个事件
- 需要先单独书写将绑定的处理函数
- 然后 addEventListener 绑定时第 2 个参数传入要绑定的函数名
- 再通过该函数名移除当前事件
var test = document.getElementById("test");
//DOM 2级添加事件
function fn1() {
console.log("this is a test");
test.removeEventListener("click", fn1); // 只删除第一个点击事件
}
function fn2() {
console.log("this is a test,too");
}
test.addEventListener("click", fn1, false);
test.addEventListener("click", fn2, false);
4.真题解答
1)总结一下 DOM 中如何注册事件和移除事件
注册事件的方式常见的有 3 种方式:
- HTML 元素中注册的事件
这种方式又被称之为行内事件监听器。这是在浏览器中处理事件最原始的方法
- DOM0 级方式注册事件
这种方式是首先取到要为其绑定事件的元素节点对象,然后给这些节点对象的事件处理属性赋值一个函数
- DOM2 级方式注册事件
DOM2 级通过 addEventListener 方法来为一个 DOM 元素添加多个事件处理函数
该方法接收 3 个参数:事件名、事件处理函数、布尔值
如果这个布尔值为 true,则在捕获阶段处理事件,如果为 false,则在冒泡阶段处理事件。若最后的布尔值不填写,则和 false 效果一样,也就是说默认为 false,在冒泡阶段进行事件的处理
关于移除注册的事件,如果是 DOM0 级方式注册的事件,直接将值设置为 null 即可。如果是 DOM2 级注册的事件,可以使用 removeEventListener 方法来移除事件
(十三)DOM 事件的传播机制
1.经典真题
- 谈一谈事件委托以及冒泡原理
2.事件
事件最早是在 IE3 和 NetscapeNavigator2 中出现的,当时是作为分担服务器运算负担的一种手段
要实现和网页的互动,就需要通过 JavaScript 里面的事件来实现
每次用户与一个网页进行交互,例如点击链接,按下一个按键或者移动鼠标时,就会触发一个事件。程序可以检测这些事件,然后对此作出响应。从而形成一种交互
这样可以使页面变得更加的有意思,而不仅仅像以前一样只能进行浏览
在早期拨号上网的年代,如果所有的功能都放在服务器端进行处理的话,效率是非常低的
JavaScript 最初被设计出来就是用来解决这些问题的。通过允许一些功能在客户端处理,以节省到服务器的往返时间
- JavaScript 中采用事件监听器监听事件是否发生
- 类似于一个通知,当事件发生时,事件监听器会让程序知道,然后程序就可以做出相应的响应
- 通过这种方式,就可以避免让程序不断地去检查事件是否发生,让程序在等待事件发生的同时,可以继续做其他的任务
3.事件流
当浏览器发展到第 4 代时(IE4 及 Netscape4),浏览器开发团队遇到了一个很有意思的问题:页面的哪一部分会拥有某个特定的事件?
想象在一张纸上的一组同心圆。如果把手指放在圆心上,那么手指指向的不是一个圆,而是纸上的所有圆。
好在两家公司的浏览器开发团队在看待浏览器事件方面还是一致的
如果单击了某个按钮,他们都认为单击事件不仅仅发生在按钮上,甚至也单击了整个页面
但有意思的是,IE 和 Netscape 开发团队居然提出了差不多是完全相反的事件流的概念
- IE 的事件流是事件冒泡流
- Netscape 的事件流是事件捕获流
4.事件冒泡流
- IE 的事件流叫做事件冒泡(event bubbling)
- 即:事件开始时由最具体的元素(文档中嵌套层次最深的那个节点)接收
- 然后逐级向上传播到较为不具体的节点(文档)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Document</title>
</head>
<body>
<div></div>
</body>
</html>
- 如果单击了页面中的 div 元素,那么这个 click 事件沿 DOM 树向上传播
- 在每一级节点上都会发生,按照如下顺序进行传播
- div
- body
- html
- document
- 所有现代浏览器都支持事件冒泡
- IE9、Firefox、Chrome、Safari 将事件一直冒泡到 window 对象
- IE8 以下浏览器将事件冒泡到 document 对象就停止
1)查看文档具体的冒泡顺序
<div id="box" style="height:100px;width:300px;background-color:pink;"></div>
<button id="reset">还原</button>
// IE8 以下浏览器返回 div body html document
// 其他浏览器返回 div body html document window
reset.onclick = function () {
history.go();
};
box.onclick = function () {
box.innerHTML += "div\n";
};
document.body.onclick = function () {
box.innerHTML += "body\n";
};
document.documentElement.onclick = function () {
box.innerHTML += "html\n";
};
document.onclick = function () {
box.innerHTML += "document\n";
};
window.onclick = function () {
box.innerHTML += "window\n";
};
5.事件捕获流
- Netscape Communicator 团队提出的另一种事件流叫做事件捕获(event capturing)
- 事件捕获的思想是不太具体的节点应该更早接收到事件,而最具体的节点应该最后接收到事件
- 在事件到达预定目标之前就捕获它
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Document</title>
</head>
<body>
<div></div>
</body>
</html>
- 在事件捕获过程中,document 对象首先接收到 click 事件,然后事件沿 DOM 树依次向下,一直传播到事件的实际目标,即 div 元素
- document
- html
- body
- div
- IE9、Firefox、Chrome、Safari* 等现代浏览器都支持事件捕获
- 也是从 window 对象开始捕获
- IE8 以下浏览器不支持
<div id="box" style="height:100px;width:300px;background-color:pink;"></div>
<button id="reset">还原</button>
// IE8 以下浏览器不支持
// 其他浏览器返回 window document html body div
reset.onclick = function () {
history.go();
};
box.addEventListener(
"click",
function () {
box.innerHTML += "div\n";
},
true,
);
document.body.addEventListener(
"click",
function () {
box.innerHTML += "body\n";
},
true,
);
document.documentElement.addEventListener(
"click",
function () {
box.innerHTML += "html\n";
},
true,
);
document.addEventListener(
"click",
function () {
box.innerHTML += "document\n";
},
true,
);
window.addEventListener(
"click",
function () {
box.innerHTML += "window\n";
},
true,
);
- 使用的 addEventListener 的方式来绑定的事件,并将第 2 个参数设置为了 true 表示使用事件捕获的方式来触发事件
6.标准 DOM 事件流
- DOM 标准采用的是 捕获 + 冒泡 的方式
- 两种事件流都会触发 DOM 的所有对象,从 document 对象开始,也在 document 对象结束
- 即:起点和终点都是 document 对象
- 很多浏览器可以一直捕获 + 冒泡到 window 对象
1)DOM 事件流示意图
- DOM 标准规定事件流包括三个阶段
- 事件捕获阶段
- 处于目标阶段
- 事件冒泡阶段
2)事件捕获阶段
- 实际目标 div 在捕获阶段不会触发事件
- 捕获阶段从 window 开始,然后到 document、html,最后到 body 意味着捕获阶段结束
3)处于目标阶段
- 事件在 div 上发生并处理
- 但是本次事件处理会被看成是冒泡阶段的一部分
4)冒泡阶段
- 事件又传播回文档 document 对象
7.事件委托
- 事件冒泡一个最大的好处就是可以实现事件委托
- 事件委托,又被称为事件代理
在 JavaScript 中,添加到页面上的事件处理程序数量将直接关系到页面整体的运行性能,导致这一问题的原因是多方面的
首先,每个函数都是对象,都会占用内存,内存中的对象越多,性能就越差
其次,必须事先指定所有事件处理程序而导致的 DOM 访问次数,会延迟整个页面的交互就绪时间
- 对事件处理程序过多问题的解决方案就是事件委托
- 事件委托利用了事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件
- 如:click 事件会一直冒泡到 document 层次
- 可以为整个页面指定一个 onclick 事件处理程序
- 不必给每个可单击的元素分别添加事件处理程序
<ul id="color-list">
<li>red</li>
<li>yellow</li>
<li>blue</li>
<li>green</li>
<li>black</li>
<li>white</li>
</ul>
- 将事件监听器绑定到父元素 ul 上,即可对所有的 li 元素添加事件
var colorList = document.getElementById("color-list");
colorList.addEventListener("click", function () {
alert("Hello");
});
- 如果之后再为这个 ul 添加新的 li 元素的话,新的 li 元素也会自动添加上相同的事件
- 虽然我们使用事件代理避免了为每一个 li 元素添加相同的事件,但是如果用户没有点击 li,而是点击的 ul,同样也会触发事件
- 可以对点击的节点进行判断,保证用户只在点击 li 的时候才触发事件
var colorList = document.getElementById("color-list");
colorList.addEventListener("click", function (event) {
if (event.target.nodeName === "LI") {
alert("点击 li");
}
});
8.真题解答
1)谈一谈事件委托以及冒泡原理
事件委托,又被称之为事件代理。在 JavaScript 中,添加到页面上的事件处理程序数量将直接关系到页面整体的运行性能,导致这一问题的原因是多方面的
首先,每个函数都是对象,都会占用内存,内存中的对象越多,性能就越差,其次,必须事先指定所有事件处理程序而导致的 DOM 访问次数,会延迟整个页面的交互就绪时间
对事件处理程序过多问题的解决方案就是事件委托
事件委托利用了事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件。例如,click 事件会一直冒泡到 document 层次。也就是说,我们可以为整个页面指定一个 onclick 事件处理程序,而不必给每个可单击的元素分别添加事件处理程序
事件冒泡(event bubbling),是指事件开始时由最具体的元素(文档中嵌套层次最深的那个节点)接收,然后逐级向上传播到较为不具体的节点(文档)
(十四)阻止事件默认行为
1.经典真题
- 如何阻止默认事件?
2.默认行为
- 一般是指 HTML 元素所自带的行为
- 如:点击一个 a 元素表示的是跳转
<a href="https://www.baidu.com">百度一下</a>
- 如:一个带有 action 属性的 form 元素,点击表单元素中嵌套的提交按钮时,就会进行默认的提交
<form action=""></form>
- 有些时候是不需要这些默认行为的
- 如:用户在填写了一个表单后,提交信息时采用 ajax 异步发送到服务器
- 此时就不需要表单 form 元素默认的提交跳转
- 所以需要阻止默认行为
3.cancelable 属性
- 该属性返回一个布尔值,表示事件是否可以取消
- 该属性为只读属性
- 返回 true 时,表示可以取消
- 否则,表示不可取消
<a id="test" href="https://www.baidu.com">百度</a>
var test = document.getElementById("test");
test.onclick = function (event) {
test.innerHTML = event.cancelable; // true,能够阻止当前事件
};
4.preventDefault 方法
- DOM 中最常见,也是最标准的取消浏览器默认行为的方式,无返回值
var test = document.getElementById("test");
test.onclick = function (event) {
event.preventDefault();
};
5.returnValue 属性
- 是一个 event 对象上的属性
- 该属性可读可写,默认值是 true
- 将其设置为 false 就可以取消事件的默认行为
- 与 preventDefault 方法的作用相同
该属性最早是在 IE 的事件对象中,实现了这种取消默认行为的方式,但是现在大多数浏览器都实现了该方式
var test = document.getElementById("test");
test.onclick = function (event) {
event.returnValue = false;
};
6.return false
- 是一条语句,该语句写在事件处理函数中也可以阻止默认行为
- 如果该条语句写在 jQuery 代码中,能够同时阻止默认行为和阻止冒泡
- 但是在原生 JavaScript 中 只能阻止默认行为
var test = document.getElementById("test");
test.onclick = function () {
return false;
};
7.defaultPrevented 属性
- 是 event 对象上的一个属性
- 该属性表示默认行为是否被阻止
- 返回 true 表示被阻止
- 返回 false 表示未被阻止
var test = document.getElementById("test");
test.onclick = function (event) {
// 采用两种不同的方式来阻止浏览器默认行为,这是为了照顾其兼容性
if (event.preventDefault) {
event.preventDefault();
} else {
event.returnValue = false;
}
// 将是否阻止默认行为的结果赋值给 <a> 标签的文本内容
test.innerHTML = event.defaultPrevented;
};
8.真题解答
1)如何阻止默认事件?
// 方法一:全支持 event.preventDefault(); // 方法二:该特性已经从 Web 标准中删除,虽然一些浏览器目前仍然支持它,但也许会在未来的某个时间停止支持,请尽量不要使用该特性。 event.returnValue = false; // 方法三:不建议滥用,jQuery 中可以同时阻止冒泡和默认事件 return false;
(十五)递归
1.经典真题
- 使用递归完成 1 到 100 的累加
2.递归
A recursive method is a method that calls itself.
- 递归调用是一种特殊的调用形式
- 指的是方法自己调用自己的形式
function neverEnd() {
console.log("This is the method that never ends!");
neverEnd();
}
3.递归条件
- 递归调用必须有结束条件
- 每次调用的时候都需要根据需求改变传递的参数内容
// 求某个数的阶乘
function factorial(x) {
if (x === 1) {
return 1;
} else {
return x * factorial(x - 1);
}
}
console.log(factorial(5)); // 120
- 整个递归的计算过程如下
===> factorial(5)
===> 5 * factorial(4)
===> 5 * (4 * factorial(3))
===> 5 * (4 * (3 * factorial(2)))
===> 5 * (4 * (3 * (2 * factorial(1))))
===> 5 * (4 * (3 * (2 * 1)))
===> 5 * (4 * (3 * 2))
===> 5 * (4 * 6)
===> 5 * 24
===> 120
4.递归注意事项
1)递归函数的优点是定义简单,逻辑清晰
- 理论上,所有的递归函数都可以用循环的方式来实现
2)使用递归时需要注意防止栈溢出
- 在计算机中,函数调用是通过栈(stack)这种数据结构实现的
- 每当一个函数调用,栈就会加一层
- 每当函数返回,栈就会减一层
- 由于栈的大小不是无限的,所以递归调用的次数过多,会导致栈溢出
5.递归示例
1)计算从 x 加到 y 的结果
function calc(i, j) {
if (i === j) {
return i;
}
return calc(i, j - 1) + j;
}
console.log(calc(1, 100)); // 5050
2)计算斐波那契数列
function calc(i) {
if (i === 1) {
return 1;
} else if (i === 2) {
return 2;
} else {
return calc(i - 1) + calc(i - 2);
}
}
console.log(calc(7)); // 21
6.真题解答
1)使用递归完成 1 到 100 的累加
function calc(i, j) { if (i === j) { return i; } return calc(i, j - 1) + j; } console.log(calc(1, 100)); // 5050
(十六)属性描述符
1.经典真题
- JavaScript 中对象的属性描述符有哪些?分别有什么作用?
2.对象的属性
1)数据属性
- 本质就是一个数据
2)存取器属性
- 本质是一个函数
- 可以将它当作普通属性来使用
- 当给该属性赋值时,会运行相应的 setter 函数
- 当获取该属性的值时,会运行相应的 getter 函数
除了存取器,还有一些其他的关键字,用以表示当前属性是否可写、是否有默认值、是否可枚举等,这些关键字就是属性描述符
3.属性描述符
- 是 ECMAScript 5 新增的语法
- 其实就是一个内部对象,用来描述对象的属性的特性
1)属性描述符的结构
- 属性描述符实际上就是一个对象
- 属性描述符一共有 6 个
属性描述符 | 说明 |
---|---|
value | 设置属性值,默认值为 undefined |
writable | 设置属性值是否可写,默认值为 true |
enumerable | 设置属性是否可枚举,默认为 true 即是否允许使用 for...in 语句或 Object.keys() 函数遍历访问 |
configurable | 设置是否可设置属性特性,默认为 true 如果为 false,将无法删除该属性,不能够修改属性值,也不能修改属性的属性描述符 |
get | 取值函数,默认为 undefined |
set | 存值函数,默认为 undefined |
2)设置示例
- 这几个属性不是都可以同时设置
- 使用 value 读写属性值的基本用法
// 定义空对象
var obj = {};
// 添加属性x,值为100
Object.defineProperty(obj, "x", { value: 100 });
// 返回100
console.log(Object.getOwnPropertyDescriptor(obj, "x").value);
- 使用 writable 属性禁止修改属性 x
- 在正常模式下,如果 writable 为 false,重写属性值不会报错,但是操作失败
- 在严格模式下则会抛出异常
var obj = {};
Object.defineProperty(obj, "x", {
// 设置属性默认值为1
value: 1,
// 禁止修改属性值
writable: false,
});
// 修改属性x的值
obj.x = 2;
// 1 说明修改失败
console.log(obj.x);
- configurable 可以禁止修改属性描述符
- 当其值为 false 时,value、writable、enumerable 和 configurable 禁止修改
- 同时禁止删除属性
- 当 configurable 为 false 时,如果把
writable: true
改为 false 是允许的- 只要 writable 或 configurable 有一个为 true,则 value 也允许修改
var obj = Object.defineProperty({}, "x", {
configurable: false, // 禁止配置
});
obj.x = 5; // 试图修改其值
console.log(obj.x); // 修改失败,返回undefined
// 抛出异常
Object.defineProperty(obj, "x", { value: 2 });
// 抛出异常
Object.defineProperty(obj, "x", { writable: true });
// 抛出异常
Object.defineProperty(obj, "x", { enumerable: true });
// 抛出异常
Object.defineProperty(obj, "x", { configurable: true });
4.get 和 set 函数
- set 函数可以设置 value 属性值
- get 函数可以读取 value 属性值
- 借助访问器,可以为属性的 value 设计高级功能
- 如:禁用部分特性、设计访问条件、利用内部变量或属性进行数据处理等
1)示例 1
- 设计对象 obj 的 x 属性值必须为数字
var obj = Object.create(Object.prototype, {
_x: {
// 数据属性
value: 1, // 初始值
writable: true,
},
x: {
// 访问器属性
get: function () {
// getter
return this._x; // 返回_x属性值
},
set: function (value) {
// setter
if (typeof value !== "number") {
throw new Error("请输入数字");
}
this._x = value; // 赋值
},
},
});
console.log(obj.x); // 1
obj.x = "2"; // 抛出异常
2)示例 2
- JavaScript 也支持一种简写方法
- 针对示例 1,通过以下方式可以快速定义属性
- 取值函数 get 不能接收参数
- 存值函数 set 只能接收一个参数,用于设置属性的值
var obj = {
_x: 1, // 定义 _x 属性
get x() {
return this._x;
}, // 定义 x 属性的 getter
set x(value) {
// 定义 x 属性的 setter
if (typeof value !== "number") {
throw new Error("请输入数字");
}
this._x = value; // 赋值
},
};
console.log(obj.x); // 1
obj.x = 2;
console.log(obj.x); // 2
5.操作属性描述符
- 属性描述符是一个内部对象,无法直接读写
- 可以通过下面几个函数进行操作
API | 说明 |
---|---|
Object.getOwnPropertyDescriptor() | 读出指定对象私有属性的属性描述符 |
Object.defineProperty() | 通过定义属性描述符来定义或修改一个属性,然后返回修改后的描述符 |
Object.defineProperties() | 同时定义多个属性描述符 |
Object.getOwnPropertyNames() | 获取对象的所有私有属性 |
Object.keys() | 获取对象的所有本地可枚举的属性 |
propertyIsEnumerable() | 对象实例方法,直接调用,判断指定的属性是否可枚举 |
1)示例 1
- 定义 obj 的 x 属性允许配置特性
- 然后获取对象 obj 的 x 属性的属性描述符
- 修改属性描述符的 set 函数,重设检测条件,允许非数值型数字赋值
var obj = Object.create(Object.prototype, {
_x: {
// 数据属性
value: 1, // 初始值
writable: true,
},
x: {
// 访问器属性
configurable: true, // 允许修改配置
get: function () {
// getter
return this._x; // 返回_x属性值
},
set: function (value) {
if (typeof value !== "number") {
throw new Error("请输入数字");
}
this._x = value; // 赋值
},
},
});
// 获取属性x的属性描述符
var des = Object.getOwnPropertyDescriptor(obj, "x");
// 修改属性x的属性描述符set函数
des.set = function (value) {
// 允许非数值型的数字,也可以进行赋值
if (typeof value !== "number" && isNaN(value * 1)) {
throw new Error("请输入数字");
}
this._x = value;
};
obj = Object.defineProperty(obj, "x", des);
console.log(obj.x); // 1
obj.x = "2"; // 把一个给数值型数字赋值给属性x
console.log(obj.x); //2
2)示例 2
- 先定义一个扩展函数,使用它可以把一个对象包含的属性以及丰富的信息复制给另一个对象
function extend(toObj, fromObj) {
// 扩展对象
for (var property in fromObj) {
// 遍历对象属性
// 过滤掉继承属性
if (!fromObj.hasOwnProperty(property)) continue;
// 复制完整的属性信息
Object.defineProperty(
toObj, // 目标对象
property, // 私有属性
Object.getOwnPropertyDescriptor(fromObj, property), // 获取属性描述符
);
}
return toObj; // 返回目标对象
}
var obj = {}; // 新建对象
obj.x = 1; // 定义对象属性
extend(obj, {
get y() {
return 2;
},
}); // 定义读取器对象
console.log(obj.y); // 2
6.控制对象状态
- JavaScript 提供了 3 种方法,用来精确控制一个对象的读写状态,防止对象被改变
API | 说明 |
---|---|
Object.preventExtensions | 阻止为对象添加新的属性 |
Object.seal | 阻止为对象添加新的属性,同时也无法删除旧属性 等价于属性描述符的 configurable: false 该方法不影响修改某个属性的值 |
Object.freeze | 阻止为一个对象添加新属性、删除旧属性、修改属性值 |
- 同时提供了 3 个对应的辅助检查函数
API | 说明 |
---|---|
Object.isExtensible | 检查一个对象是否允许添加新的属性 |
Object.isSealed | 检查一个对象是否使用了 Object.seal 方法 |
Object.isFrozen | 检查一个对象是否使用了 Object.freeze 方法 |
var obj1 = {};
console.log(Object.isExtensible(obj1)); // true
Object.preventExtensions(obj1);
console.log(Object.isExtensible(obj1)); // false
var obj2 = {};
console.log(Object.isSealed(obj2)); // true
Object.seal(obj2);
console.log(Object.isSealed(obj2)); // false
var obj3 = {};
console.log(Object.isFrozen(obj3)); // true
Object.freeze(obj3);
console.log(Object.isFrozen(obj3)); // false
7.真题解答
1)JavaScript 中对象的属性描述符有哪些?分别有什么作用?
属性描述符一共有 6 个,可以选择使用
- value:设置属性值,默认值为 undefined
- writable:设置属性值是否可写,默认值为 true
- enumerable:设置属性是否可枚举,即是否允许使用 for/in 语句或 Object.keys() 函数遍历访问,默认为 true
- configurable:设置是否可设置属性特性,默认为 true。如果为 false,将无法删除该属性,不能够修改属性值,也不能修改属性的属性描述符
- get:取值函数,默认为 undefined
- set:存值函数,默认为 undefined
使用属性描述符的时候,get 和 set 以及 value 和 writable 这两组是互斥的,设置了 get 和 set 就不能设置 value 和 writable,反之设置了 value 和 writable 也就不可以设置 get 和 set
(十七)class 和构造函数区别
1.经典真题
- 根据下面 ES6 构造函数的书写方式,要求写出 ES5 的
class Example {
constructor(name) {
this.name = name;
}
init() {
const fun = () => {
console.log(this.name);
};
fun();
}
}
const e = new Example("Hello");
e.init();
2.回顾 class 的写法
class Computer {
// 构造器
constructor(name, price) {
this.name = name;
this.price = price;
}
// 原型方法
showSth() {
console.log(`这是一台${this.name}电脑`);
}
// 静态方法
static conStruct() {
console.log("电脑由显示器,主机,键鼠组成");
}
}
- 实例化一个对象
var apple = new Computer("苹果", 15000);
console.log(apple.name); // 苹果
console.log(apple.price); // 15000
apple.showSth(); // 这是一台苹果电脑
Computer.conStruct(); // 电脑由显示器,主机,键鼠组成
3.回顾构造函数的写法
- 通过构造函数来模拟类
- 实例方法挂在原型上面
- 静态方法就挂在构造函数上
function Computer(name, price) {
this.name = name;
this.price = price;
}
Computer.prototype.showSth = function () {
console.log(`这是一台${this.name}电脑`);
};
Computer.conStruct = function () {
console.log("电脑由显示器,主机,键鼠组成");
};
var apple = new Computer("苹果", 15000);
console.log(apple.name); // 苹果
console.log(apple.price); // 15000
apple.showSth(); // 这是一台苹果电脑
Computer.conStruct(); // 电脑由显示器,主机,键鼠组成
4.class 和构造函数的区别
1)调用方式
- 构造函数也是函数
- 可以通过函数调用的形式来调用该函数
var i = Computer2();
console.log(i); // undefined
- 代码不会报错,因为没有使用 new 的方式来调用,所以不会生成一个对象,返回值就为 undefined
- 但是如果用同样方式调用 ES6 书写的类,会直接报错
Computer1();
// TypeError: Class constructor Computer1 cannot be invoked without 'new'
- ES6 所书写的 class 必须通过 new 关键字调用
2)实例化对象
// 构造函数
var apple = new Computer2("苹果", 15000);
for (var i in apple) {
console.log(i);
}
console.log("-------");
// 类
var huawei = new Computer1("华为", 12000);
for (var i in huawei) {
console.log(i);
}
/*
name
price
showSth
-------
name
price
*/
- ES6 中的原型方法是不可被枚举的
3)严格模式
- ES6 的 class 中的所有代码均处于严格模式之下
class Computer1 {
// ...
// 原型方法
showSth(i, i) {
console.log(`这是一台${this.name}电脑`);
}
// ...
}
function Computer2(name, price) {
// ...
}
Computer2.prototype.showSth = function (j, j) {
i = 10;
console.log(`这是一台${this.name}电脑`);
};
// ...
- 在严格模式中方法不允许重复形参
- ES6 的 class 声明方式会报错
// SyntaxError: Duplicate parameter name not allowed in this context
4)new 调用原型上的方法
- ES6 形式所声明的类,原型上的方法不允许通过 new 调用
function Computer2(name, price) {
this.name = name;
this.price = price;
}
Computer2.prototype.showSth = function () {
i = 10;
console.log(`这是一台${this.name}电脑`);
};
Computer2.conStruct = function () {
console.log("电脑由显示器,主机,键鼠组成");
};
var apple = new Computer2("苹果", 15000);
var i = new apple.showSth(); // 这是一台undefined电脑
console.log(i); // {}
class Computer1 {
// 构造器
constructor(name, price) {
this.name = name;
this.price = price;
}
// 原型方法
showSth() {
console.log(`这是一台${this.name}电脑`);
}
// 静态方法
static conStruct() {
console.log("电脑由显示器,主机,键鼠组成");
}
}
var huawei = new Computer1("华为", 12000);
var i = new huawei.showSth(); // TypeError: huawei.showSth is not a constructor
console.log(i);
5.Babel 中具体的实现
- ES6 中的 class 实现虽然本质上是构造函数,但是内部是做了各种处理的
- 使用 Babel 对下面的代码进行转义
// 转义之前的代码
class Computer {
// 构造器
constructor(name, price) {
this.name = name;
this.price = price;
}
// 原型方法
showSth() {
console.log(`这是一台${this.name}电脑`);
}
// 静态方法
static conStruct() {
console.log("电脑由显示器,主机,键鼠组成");
}
}
// 转义后的代码
"use strict";
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
function _defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor) descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
function _createClass(Constructor, protoProps, staticProps) {
if (protoProps) _defineProperties(Constructor.prototype, protoProps);
if (staticProps) _defineProperties(Constructor, staticProps);
return Constructor;
}
var Computer = /*#__PURE__*/ (function () {
// 构造器
function Computer(name, price) {
_classCallCheck(this, Computer);
this.name = name;
this.price = price;
} // 原型方法
_createClass(
Computer,
[
{
key: "showSth",
value: function showSth() {
console.log("\u8FD9\u662F\u4E00\u53F0".concat(this.name, "\u7535\u8111"));
}, // 静态方法
},
],
[
{
key: "conStruct",
value: function conStruct() {
console.log("电脑由显示器,主机,键鼠组成");
},
},
],
);
return Computer;
})();
var apple = new Computer("苹果", 15000);
console.log(apple.name); // 苹果
console.log(apple.price); // 15000
apple.showSth(); // 这是一台苹果电脑
Computer.conStruct(); // 电脑由显示器,主机,键鼠组成
1)_classCallCheck
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
- 核对构造方法的调用形式的
- 接收两个参数,一个是实例对象,另一个是构造函数
- 通过 instanceof 判断参数 instance 是否是 Constructor 的实例
- 如果不是就抛出错误
2)_defineProperties
- 设置对象方法的属性描述符
- 包含是否可遍历、是否可写等信息
- 设置完成后将方法挂在 target 对象上
function _defineProperties(target, props) {
console.log("target:::",target);
console.log("props:::",props);
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor)
descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
target::: {}
props::: [ { key: 'showSth', value: [Function: showSth] } ]
target::: [Function: Computer]
props::: [ { key: 'conStruct', value: [Function: conStruct] } ]
3)_createClass
- 接收的三个参数
- 构造函数
- 原型上的方法
- 静态方法
function _createClass(Constructor, protoProps, staticProps) {
console.log("Constructor::",Constructor);
console.log("protoProps::",protoProps);
console.log("staticProps::",staticProps);
if (protoProps)
_defineProperties(Constructor.prototype, protoProps);
if (staticProps)
_defineProperties(Constructor, staticProps);
return Constructor;
}
Constructor:: [Function: Computer]
protoProps:: [ { key: 'showSth', value: [Function: showSth] } ]
staticProps:: [ { key: 'conStruct', value: [Function: conStruct] } ]
- 构造函数
var Computer = /*#__PURE__*/ (function () {
// 构造器
function Computer(name, price) {
// 进行调用确认
_classCallCheck(this, Computer);
// 添加实例属性
this.name = name;
this.price = price;
} // 原型方法
// 将实例方法和静态方法添加到构造函数上面
_createClass(
Computer,
[
{
key: "showSth",
value: function showSth() {
console.log("\u8FD9\u662F\u4E00\u53F0".concat(this.name, "\u7535\u8111"));
}, // 静态方法
},
],
[
{
key: "conStruct",
value: function conStruct() {
console.log("电脑由显示器,主机,键鼠组成");
},
},
],
);
return Computer;
})();
6.真题解答
1)根据下面 ES6 构造函数的书写方式,要求写出 ES5 的
class Example {
constructor(name) {
this.name = name;
}
init() {
const fun = () => {
console.log(this.name);
};
fun();
}
}
const e = new Example("Hello");
e.init();
"use strict"; function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } var Example = /*#__PURE__*/ (function () { function Example(name) { _classCallCheck(this, Example); this.name = name; } _createClass(Example, [ { key: "init", value: function init() { var _this = this; var fun = function fun() { console.log(_this.name); }; fun(); }, }, ]); return Example; })(); var e = new Example("Hello"); e.init();
这里可以解释出
_classCallCheck
、_defineProperties
、_createClass
这几个方法各自的作用是什么
(十八)浮点数精度问题
1.经典真题
- 为什么
console.log(0.2+0.1==0.3)
得到的值为 false?
2.浮点数精度常见问题
- 在 JavaScript 中整数和浮点数都属于 number 数据类型
- 所有数字都是以 64 位浮点数形式储存,即便是整数
- 所以在打印 1.00 这样的浮点数的结果是 1 而非 1.00
- 在一些特殊的数值表示中,如:金额,这样看上去有点别扭,但是至少值是正确的
- 但是当浮点数做数学运算的时候,会发现一些问题
1)场景一:进行浮点值运算结果的判断
// 加法
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.7 + 0.1); // 0.7999999999999999
console.log(0.2 + 0.4); // 0.6000000000000001
console.log(2.22 + 0.1); // 2.3200000000000003
// 减法
console.log(1.5 - 1.2); // 0.30000000000000004
console.log(0.3 - 0.2); // 0.09999999999999998
// 乘法
console.log(19.9 * 100); // 1989.9999999999998
console.log(19.9 * 10 * 10); // 1990
console.log(9.7 * 100); // 969.9999999999999
console.log(39.7 * 100); // 3970.0000000000005
// 除法
console.log(0.3 / 0.1); // 2.9999999999999996
console.log(0.69 / 10); // 0.06899999999999999
2)场景二:将小数乘以 10 的 n 次方取整
- 如:将钱币的单位,从元转化成分
console.log(parseInt(0.58 * 100, 10)); // 57
3)场景三:四舍五入保留 n 位小数
console.log((1.335).toFixed(2)); // 1.33
3.精度问题成因
- 计算机底层只有 0 和 1,所以所有的运算最后实际上都是 二进制运算
- 十进制整数利用辗转相除的方法可以准确地转换为二进制数
1)IEEE 754 标准
- JavaScript 里的数字是采用 IEEE 754 标准的 64 位双精度浮点数
- 该规范定义了浮点数的格式
- 对于 64 位的浮点数在内存中的表示,最高的 1 位是符号位,接着的 11 位是指数,剩下的 52 位为有效数字
- 符号位 S
- 第 1 位是正负数符号位(sign)
- 0 代表正数,1 代表负数
- 指数位 E
- 中间的 11 位存储指数(exponent)
- 用来表示次方数
- 尾数位 M
- 最后的 52 位是尾数(mantissa)
- 储存小数部分,超出的部分自动进一舍零
- 符号位 S
- 浮点数最终在运算的时候实际上是一个符合该标准的二进制数
- 符号位决定了一个数的正负,指数部分决定了数值的大小,小数部分决定了数值的精度
IEEE 754 规定,有效数字第一位默认总是 1,不保存在 64 位浮点数之中
也就是说,有效数字总是
1.xx…xx
的形式,其中xx…xx
的部分保存在 64 位浮点数之中,最长可能为 52 位因此,JavaScript 提供的有效数字最长为 53 个二进制位(64 位浮点的后 52 位 + 有效数字第一位的 1)
既然限定位数,必然有截断的可能
4.浮点数转换为二进制
- 整数可以用除 2 取余的方式
- 小数可以用乘 2 取整的方式
1)示例 1
console.log(0.1 + 0.2); // 0.30000000000000004
0.1 转换为二进制:
0.1 * 2 = 0.2 => 小数部分 0.2,整数部分 0
0.2 * 2 = 0.4 => 小数部分 0.4,整数部分 0
0.4 * 2 = 0.8 => 小数部分 0.8,整数部分 0
0.8 * 2 = 1.6 => 小数部分 0.6,整数部分 1
0.6 * 2 = 1.2 => 小数部分 0.2,整数部分 1
0.2 * 2 = 0.4 => 小数部分 0.4,整数部分 0
从 0.2 开始循环
- 最终能得到两个循环的二进制数
0.1:0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1100 ...
0.2:0.0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 ...
- 这两个的和的二进制
sum:0.0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 ...
- 最终只能得到和的近似值
- 按照 IEEE 754 标准保留 52 位,按 0 舍 1 入来取值
- 然后转换为十进制数
sum ≈ 0.30000000000000004
2)示例 2
console.log((1.335).toFixed(2)); // 1.33
- 因为 1.335 其实是 1.33499999999999996447286321199
- toFixed 虽然是四舍五入,但是是对 1.33499999999999996447286321199 进行四五入,所以得出 1.33
5.整数精度问题
console.log(19571992547450991); // 19571992547450990
console.log(19571992547450991 === 19571992547450992); // true
- 在 JavaScript 中 number 类型统一按浮点数处理
- 整数是按最大 54 位来算
- 最大:253 - 1
- Number.MAX_SAFE_INTEGER
- 9007199254740991
- 最小:-253 - 1
- Number.MIN_SAFE_INTEGER
- -9007199254740991
- 只要超过这个范围,就会存在被舍去的精度问题
几乎所有的编程语言都采用了 IEEE-754 浮点数表示法,任何使用二进制浮点数的编程语言都会有这个问题
只不过在很多其他语言中已经封装好了方法来避免精度的问题,而 JavaScript 是一门弱类型的语言,从设计思想上就没有对浮点数有个严格的数据类型,所以精度误差的问题就显得格外突出
6.使用第三方库解决精度问题
- 通常这种对精度要求高的计算都应该交给后端去计算和存储,因为后端有成熟的库来解决这种计算问题
- 前端也有几个不错的类库
1)Math.js
- 专门为 JavaScript 和 NodeJS 提供的一个广泛的数学库
- 具有灵活的表达式解析器,支持符号计算,配有大量内置函数和常量,并提供集成解决方案来处理不同的数据类型
- 如:数字,大数字(超出安全数的数字),复数,分数,单位和矩阵
- 功能强大,易于使用
2)decimal.js
- 为 JavaScript 提供十进制类型的任意精度数值
3)big.js
- 能够支持处理 Long 类型的数据
- 也能够准确的处理小数的运算
7.真题解答
console.log(0.2+0.1==0.3)
得到的值为 false?
1)为什么 因为浮点数的计算存在 round-off 问题,也就是浮点数不能够进行精确的计算
- 不仅 JavaScript,所有遵循 IEEE 754 规范的语言都是如此
- 在 JavaScript 中,所有的 Number 都是以 64-bit 的双精度浮点数存储的
- 双精度的浮点数在这 64 位上划分为 3 段,而这 3 段也就确定了一个浮点数的值,64bit 的划分是“1-11-52”的模式,具体来说:
- 就是 1 位最高位(最左边那一位)表示符号位,0 表示正,1 表示负
- 11 位表示指数部分
- 52 位表示尾数部分,也就是有效域部分
(十九)严格模式
1.经典真题
- use strict 是什么意思? 使用它区别是什么?
2.严格模式介绍
- 严格模式是从 ES5 开始新增的一种方式
- 是采用具有限制性 JavaScript 变体的一种方式,从而使代码隐式地脱离“马虎模式/稀松模式/懒散模式“(sloppy)模式
- 在“严格模式下”,同样的代码,可能会有不一样的运行结果
- 一些在“正常模式”下可以运行的语句,在“严格模式”下将不能运行
1)设立"严格模式"的目的
- 消除 JavaScript 语法的一些不合理、不严谨之处,减少一些怪异行为
- 消除代码运行的一些不安全之处,保证代码运行的安全
- 提高编译器效率,增加运行速度
- 为未来新版本的 JavaScript 做好铺垫
2)兼容性
- “严格模式”体现了 JavaScript 更合理、更安全、更严谨的发展方向
- 支持严格模式的浏览器有
- Internet Explorer 10+
- Firefox 4+
- Chrome 13+
- Safari 5.1+
- Opera 12+
3.开启严格模式
"use strict";
- 老版本的浏览器会把它当作一行普通字符串,直接忽略
1)调用方式 1:针对整个脚本文件
- 将 “use strict” 放在 脚本文件的第一行,则整个脚本都将以“严格模式”运行
- 如果不在第一行,则无效,整个脚本以“正常模式”运行
"use strict";
console.log("这是严格模式。");
<script>
"use strict";
console.log("这是严格模式。");
</script>
<script>
console.log("这是正常模式。");
</script>
2)调用方式 2:针对单个函数
- 将 “use strict” 放在 函数体的第一行,则整个函数以“严格模式”运行
function strict() {
"use strict";
return "这是严格模式。";
}
function notStrict() {
return "这是正常模式。";
}
3)脚本文件的变通写法
- 因为第一种调用方法不利于文件合并
- 所以更好的做法是,借用第二种方法
- 将整个脚本文件放在一个立即执行的匿名函数之中
(function () {
"use strict";
// some code here
})();
4.严格模式和普通模式区别
1)不能使用未声明的变量
- 在普通模式下,我们可以使用一个未声明的变量,此时该变量会成为一个全局变量
- 在严格模式下会报错
"use strict";
a = 10; // ReferenceError: a is not defined
console.log(a);
function sum() {
var a = 10;
console.log(a);
}
sum();
2)删除变量和不存在的属性会报错
- 在普通模式下,删除变量或者不允许删除的属性虽然也会失败,但是是“静默失败”
- 即:虽然失败了,但是不会给出任何提示
- 会产生很多隐藏问题,也给程序员的调错带来了难度
- 在严格模式下则会报错
"use strict";
var i = 10;
delete i; // SyntaxError: Delete of an unqualified identifier in strict mode.
console.log(i); // 10
3)函数中相同的形参名会报错
- 在普通模式下,函数中两个形参名相同也不会报错,只不过后面的形参所接收到的值会覆盖前面的同名形参
function a(b, b) {
console.log(b); // 2
}
a(1, 2);
- 但是在严格模式下,相同的形参名会报错
"use strict";
// SyntaxError: Duplicate parameter name not allowed in this context
function a(b, b) {
console.log(b);
}
a(1, 2);
4)对象不能有重名的属性
- 正常模式下,如果对象有多个重名属性,最后赋值的那个属性会覆盖前面的值
- 严格模式下,这属于语法错误
"use strict";
var o = {
p: 1,
p: 2,
}; // 语法错误
5)禁止八进制表示法
- 正常模式下,整数的第一位如果是 0,表示这是八进制数
- 如:010 等于十进制的 8
var i = 010;
console.log(i); // 8
- 严格模式禁止这种表示法,整数第一位为 0,将报错
"use strict";
var i = 010; // SyntaxError: Octal literals are not allowed in strict mode.
console.log(i);
6)函数内部 this 值为 undefined
- 在普通模式下,函数中的 this 在以函数的形式被调用时,指向全局对象
- 在严格模式中,得到的值为 undefined
"use strict";
function a() {
console.log(this); // undefined
}
a();
7)创设 eval 作用域
- 正常模式下,JavaScript 语言有两种变量作用域(scope)
- 全局作用域
- 函数作用域
- 严格模式创设了第三种作用域
- eval 作用域
- 正常模式下,eval 语句的作用域取决于它处于全局作用域,还是处于函数作用域
- 严格模式下,eval 语句本身就是一个作用域,不再生成全局变量,它所生成的变量只能用于 eval 内部
"use strict";
var x = 2;
console.info(eval("var x = 5; x")); // 5
console.info(x); // 2
8)保留字
- 为了向将来的 JavaScript 新版本过渡,严格模式新增了一些保留字
- implements
- interface
- let
- package
- private
- protected
- public
- static
- yield
- 使用这些词作为变量名将会报错
"use strict";
var public = "hello world"; // SyntaxError: Unexpected strict mode reserved word
console.log(public);
更多关于严格模式的内容,可以参阅:
MDN:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Strict_mode
《JavaScript 严格模式详解 By 阮一峰》:http://www.ruanyifeng.com/blog/2013/01/javascript_strict_mode.html
5.真题解答
1)use strict 是什么意思? 使用它区别是什么?
use strict 代表开启严格模式,这种模式使得 JavaScript 在更严格的条件下运行,实行更严格解析和错误处理
开启“严格模式”的优点:
- 消除 JavaScript 语法的一些不合理、不严谨之处,减少一些怪异行为
- 消除代码运行的一些不安全之处,保证代码运行的安全
- 提高编译器效率,增加运行速度
- 为未来新版本的 JavaScript 做好铺垫
回答一些具体的严格模式下和普通模式之间的区别
(二十)函数防抖和节流
1.经典真题
- 防抖,节流是什么,如何实现 (字节)
2.函数防抖和节流介绍
JavaScript 中的函数大多数情况下都是由用户主动调用触发的,除非是函数本身的实现不合理,否则一般不会遇到跟性能相关的问题
但是在一些少数情况下,函数的触发不是由用户直接控制的
在这些场景下,函数有可能被非常频繁地调用,而造成大的性能问题
- 解决性能问题的处理办法有
- 函数防抖
- 函数节流
- 函数被频繁调用的常见场景
1)mousemove 事件
- 如果要实现一个拖拽功能,需要一路监听 mousemove 事件
- 在回调中获取元素当前位置,然后重置 DOM 的位置来进行样式改变
- 如果不加以控制,每移动一定像素而触发的回调数量非常惊人
- 回调中又伴随着 DOM 操作,继而引发浏览器的重排与重绘,性能差的浏览器可能就会直接假死
2)window.onresize 事件
- 为 window 对象绑定了 resize 事件,当浏览器窗口大小被拖动而改变的时候,这个事件触发的频率非常之高
- 如果在 window.onresize 事件函数里有一些跟 DOM 节点相关的操作,而跟 DOM 节点相关的操作往往是非常消耗性能的,这时候浏览器可能就会吃不消而造成卡顿现象
3)射击游戏的 mousedown/keydown 事件
- 单位时间只能发射一颗子弹
4)keyup 事件
- 搜索联想
5)scroll 事件
- 监听滚动事件判断是否到页面底部自动加载更多
对于这些情况的解决方案就是函数防抖(debounce)或函数节流(throttle)
其核心就是 限制某一个方法的频繁触发
3.函数防抖 debounce
- 指防止函数在极短的时间内反复调用,造成资源的浪费
真正的搜索行为,并不是每次按键都会触发的
只有当用户停止按键一段事件后才会触发
1)通用函数功能
- 调用该函数后,不立即做事,而是一段时间后去做事
- 如果在等待时间内调用了该函数,重新计时
2)通用函数参数
- 一段时间后要做什么事
- 应该是一个回调函数,即:函数作为参数
- 要等待多长时间
- 通常是毫秒
3)封装防抖函数
/**
* 函数防抖
* @param {function} func 一段时间后,要调用的函数
* @param {number} wait 等待的时间,单位毫秒
*/
function debounce(func, wait) {
// 设置变量,记录 setTimeout 得到的 id
var timerId = null;
return function (...args) {
if (timerId) {
// 如果有值,说明目前正在等待中,清除它
clearTimeout(timerId);
}
// 重新开始计时
timerId = setTimeout(() => {
func(...args);
}, wait);
};
}
- 测试
<input id="txt" type="text" />
var txt = document.getElementById("txt");
// 调用 debounce 函数来将事件处理函数变为一个防抖函数
var debounceHandle = debounce(function (event) {
console.log(event.target.value);
}, 500);
txt.onkeyup = (event) => {
debounceHandle(event);
};
4.函数节流 throttle
- 防止一个函数短时间内被频繁的触发
- 和函数防抖的原理不同,函数节流的核心思想是 让连续的函数执行,变为固定时间段间断地执行
函数防抖,是指频繁触发的情况下,只有足够的空闲时间,才执行代码一次
比如生活中的坐公交,就是一定时间内,如果有人陆续刷卡上车,司机就不会开车。只有别人没刷卡了,司机才开车
函数节流,指一定时间内函数只执行一次
比如人的眨眼睛,就是一定时间内眨一次
- 实现节流有 2 种主流方式
- 使用时间戳
- 设置定时器
1)使用时间戳
- 触发事件时,取出当前的时间戳,然后减去之前的时间戳(初始设为 0)
- 如果大于设置的时间周期,就执行函数,然后更新时间戳为当前的时间戳
- 如果小于,就不执行
/**
* 函数节流(使用时间戳)
* @param {function} func 要节流的函数
* @param {timestamp} wait 间隔时间
*/
function throttle(func, wait) {
var args; // 存储函数参数
var previous = 0; // 一开始的默认时间
return function () {
var now = new Date(); // 获取最新的时间戳
args = arguments; // 获取参数
// 进行时间戳的判断,如果超出规定时间,则执行
if (now - previous > wait) {
func.apply(null, args);
previous = now;
}
};
}
- 测试
<input id="txt" type="text" />
var txt = document.getElementById("txt");
// 调用 throttle 函数来将事件处理函数变为一个节流函数
var throttleHandle = throttle(function (event) {
console.log(event.target.value);
}, 1000);
txt.onkeyup = (event) => {
throttleHandle(event);
};
2)设置定时器
- 触发事件时设置一个定时器
- 再触发事件的时候,如果定时器存在,就不执行
- 直到定时器执行,然后执行函数,清空定时器,再设置下个定时器
/**
* 函数节流(设置定时器)
* @param {function} func 要节流的函数
* @param {timestamp} wait 节流的时间间隔
*/
function throttle(func, wait) {
// timeout 存储计时器返回值
// args 存储参数
var timeout, args;
return function () {
args = arguments;
// 如果 timeout 有值,说明上一次的执行间隔时间还没过
if (!timeout) {
// 进入此 if 说明时间间隔已经过了
// 先执行一次要执行的函数
func.apply(null, args);
// 然后重新设置时间间隔
timeout = setTimeout(function () {
timeout = null;
}, wait);
}
};
}
5.真题解答
1)防抖,节流是什么,如何实现 (字节)
在平时开发的时候,会有很多场景会频繁触发事件,比如说搜索框实时发请求,onmousemove、resize、onscroll 等,有些时候,并不能或者不想频繁触发事件,这时候就应该用到函数防抖和函数节流
- 函数防抖(debounce),指的是短时间内多次触发同一事件,只执行最后一次,或者只执行最开始的一次,中间的不执行
function debounce(func, wait) { // 设置变量,记录 setTimeout 得到的 id var timerId = null; return function (...args) { if (timerId) { // 如果有值,说明目前正在等待中,清除它 clearTimeout(timerId); } // 重新开始计时 timerId = setTimeout(() => { func(...args); }, wait); }; }
- 函数节流(throttle),指连续触发事件但是在 n 秒中只执行一次函数,即: 2n 秒内执行 2 次...
节流如字面意思,会稀释函数的执行频率
function throttle(func, wait) { var args; // 存储函数参数 var previous = 0; // 一开始的默认时间 return function () { var now = new Date(); // 获取最新的时间戳 args = arguments; // 获取参数 // 进行时间戳的判断,如果超出规定时间,则执行 if (now - previous > wait) { func.apply(null, args); previous = now; } }; }
(二十一)WeakSet 和 WeakMap
1.经典真题
- 是否了解 WeakMap、WeakSet(美团 2019 年)
2.对象的简单使用
const algorithm = {
site: "leetcode",
};
console.log(algorithm.site); // leetcode
for (const key in algorithm) {
console.log(key, algorithm[key]);
}
// site leetcode
delete algorithm.site;
console.log(algorithm.site); // undefined
- 对象的 key 和 value 是一个字符串类型的值
- 通过点(
.
)访问值
- 通过点(
for...in
循环可以在对象中循环- 通过中括号(
[]
)访问键对应的值
- 通过中括号(
- 不能使用
for...of
循环- 因为对象是不可迭代的
- 对象的属性可以用 delete 关键字删除
3.Map
- Map 是 JavaScript 中新的集合对象,功能类似于对象
- 但是与常规对象相比,存在一些主要差异
- 通过 Map 构造函数,可以创建一个 Map 实例对象
const map = new Map();
// Map(0) {}
1)添加属性
- set 方法可以为 Map 添加属性
- 有两个参数
- 参数 1:键
- 参数 2:值
map.set("name", "john");
// Map(1) {"name" => "john"}
- 不允许添加 Map 现有数据
- 如果 Map 对象中已经存在与新数据的键对应的值,则不会添加新数据
- 会直接覆盖该键对应的值
map.set("phone", "iPhone");
// Map(2) {"name" => "john", "phone" => "iPhone"}
map.set("phone", "iPhone");
// Map(2) {"name" => "john", "phone" => "iPhone"}
map.set("phone", "Galaxy");
// Map(2) {"name" => "john", "phone" => "Galaxy"}
- 二维数组和 Map 对象之间可以相互转换
var arr = [
[1, 2],
[3, 4],
[5, 6],
];
var map = new Map(arr);
console.log(map);
// Map { 1 => 2, 3 => 4, 5 => 6 }
console.log(Array.from(map));
// [ [ 1, 2 ], [ 3, 4 ], [ 5, 6 ] ]
2)获取属性和长度
- get 方法可以获取 Map 对象某一个属性的值
const map = new Map();
map.set("name", "john");
map.set("phone", "iPhone");
console.log(map.get("phone")); // iPhone
- has 方法查询是否具有某一个属性
const map = new Map();
map.set("name", "john");
map.set("phone", "iPhone");
console.log(map.has("phone")); // true
- size 属性获取 Map 对象的长度
const map = new Map();
map.set("name", "john");
map.set("phone", "iPhone");
console.log(map.size); // 2
3)遍历 Map 对象
- Map 是一个可迭代的对象
- 即:可以使用
for...of
语句映射
- 即:可以使用
- Map 以数组形式提供数据,要获取键或值则需要 解构数组 或 以索引的方式访问
for (const item of map) {
console.dir(item);
}
// Array(2) ["name", "john"]
// Array(2) ["phone", "Galaxy"]
- 仅获取键或值
map.keys();
// MapIterator {"name", "phone"}
map.values();
// MapIterator {"john", "Galaxy"}
map.entries();
// MapIterator {"name" => "john", "phone" => "Galaxy"}
- 也可以使用 forEach 方法
const map = new Map();
map.set("name", "john");
map.set("phone", "iPhone");
map.forEach((item) => {
console.log(item);
});
// john
// iPhone
- 可以使用展开操作符(
...
)获取 Map 的全部数据- 因为展开操作符可以在幕后与可迭代对象一起工作
const simpleSpreadedMap = [...map];
// [Array(2), Array(2)]
4)删除属性
- 从 Map 对象中删除数据只需要调用 delete
- 返回布尔值,该布尔值指示 delete 函数是否成功删除了数据
- 如果是,则返回 true,否则返回 false
map.delete("phone");
// true
map.delete("fake");
// false
- 如果要清空整个 Map 对象,可以使用 clear 方法
const map = new Map();
map.set("name", "john");
map.set("phone", "iPhone");
console.log(map); // Map(2) { 'name' => 'john', 'phone' => 'iPhone' }
map.clear();
console.log(map); // Map(0) {}
4.Map 和 Object 的区别
5.WeakMap
- 起源于 Map,非常相似又有明显不同
- WeakMap 与它的引用链接所指向的数据对象的连接或关系没有 Map 的连接或关系那么强,所以是弱(weak)的
1)差异 1:key 必须是对象
- 可以将任何值作为键传入 Map 对象
- 但 WeakMap 只接受一个对象作为键,否则将返回一个错误
const John = { name: "John" };
const weakMap = new WeakMap();
weakMap.set(John, "student");
// WeakMap {{...} => "student"}
weakMap.set("john", "student");
// Uncaught TypeError: Invalid value used as weak map key
2)差异 2:并非 Map 中的所有方法都支持
- WeakMap 可以使用以下方法
- delete
- get
- has
- set
- 不支持迭代对象的方法
3)差异 3:当 GC 清理引用时,数据会被删除
- 这是和 Map 相比最大的不同
let John = { major: "math" };
const map = new Map();
const weakMap = new WeakMap();
map.set(John, "John");
weakMap.set(John, "John");
John = null;
/* John 被垃圾收集 */
当 John 对象被垃圾回收时,Map 对象将保持引用链接
而 WeakMap 对象将丢失链接
6.Set
- 非常类似于 Map
- 但是 Set 对于单个值更有用
1)添加属性
- 使用 add 方法可以添加属性
const set = new Set();
set.add(1);
set.add("john");
set.add(BigInt(10));
// Set(4) {1, "john", 10n}
- 不允许添加相同的值
set.add(5);
// Set(1) {5}
set.add(5);
// Set(1) {5}
- 对于原始数据类型(boolean、number、string、null、undefined)
- 如果储存相同值则只保存一个
- 对于引用类型
- 引用地址完全相同则只会存一个
+0 与 -0 在存储判断唯一性的时候是恒等的,所以不可以重复
undefined 和 undefined 是恒等的,所以不可以重复
NaN 与 NaN 是不恒等的,但是在 Set 中只能存一个,不能重复
2)遍历对象
- Set 是一个可迭代的对象
- 可以使用
for...of
或 forEach 语句
for (const val of set) {
console.dir(val);
}
// 1
// 'John'
// 10n
// 5
set.forEach((val) => console.dir(val));
// 1
// 'John'
// 10n
// 5
3)删除属性
- 和 Map 的删除完全一样
- 如果数据被成功删除,返回 true,否则返回 false
- 也可以使用 clear 方法清空 Set 集合
set.delete(5);
// true
set.delete(function () {});
// false;
set.clear();
- 如果不想将相同的值添加到数组表单中,可以使用 Set
/* With Set */
const set = new Set();
set.add(1);
set.add(2);
set.add(2);
set.add(3);
set.add(3);
// Set {1, 2, 3}
// Converting to Array
const arr = [...set];
// [1, 2, 3]
Object.prototype.toString.call(arr);
// [object Array]
/* Without Set */
const hasSameVal = (val) => ar.some(v === val);
const ar = [];
if (!hasSameVal(1)) ar.push(1);
if (!hasSameVal(2)) ar.push(2);
if (!hasSameVal(3)) ar.push(3);
4)应用场景
// 数组去重
...new Set([1,1,2,2,3])
// 并集
var arr1 = [1, 2, 3]
var arr2 = [2, 3, 4]
var newArr = [...new Set([...arr1, ...arr2])]
// 交集
var arr1 = [1, 2, 3]
var arr2 = [2, 3, 4]
var set1 = new Set(arr1)
var set2 = new Set(arr2)
var newArr = []
set1.forEach(item => {
set2.has(item) ? newArr.push(item) : ''
})
console.log(newArr)
// 差集
var arr1 = [1, 2, 3]
var arr2 = [2, 3, 4]
var set1 = new Set(arr1)
var set2 = new Set(arr2)
var newArr = []
set1.forEach(item => {
set2.has(item) ? '' : newArr.push(item)
})
set2.forEach(item => {
set1.has(item) ? '' : newArr.push(item)
})
console.log(newArr)
7.WeakSet
1)WeakSet 和 Set 的区别
- WeakSet 只能储存 对象引用,不能存放值
- 而 Set 对象都可以
- WeakSet 对象中储存的对象值都是 被弱引用 的
- 即:垃圾回收机制不考虑 WeakSet 对该对象的引用
- 如果没有其他的变量或者属性引用这个对象值,则这个对象将会被垃圾回收掉(不考虑该对象还存在与 WeakSet 中)
- 所以 WeakSet 对象里有多少个成员元素,取决于垃圾回收机制有没有运行,运行前后成员个数可能不一致
- 遍历结束之后,有的成员可能取不到,被垃圾回收了
- 因此 ES6 规定,WeakSet 对象是 无法被遍历 的,也没有办法拿到它包含的所有元素
2)WeakSet 能够使用的方法
- add(value) 方法
- 在 WeakSet 中添加一个元素
- 如果添加的元素已存在,则不会进行操作
- delete(value) 方法
- 删除元素 value
- has(value) 方法
- 判断 WeakSet 对象中是否包含 value
- clear() 方法
- 清空所有元素
3)应用场景
- WeakSet 将丢失对内部数据的访问链接(如果内部数据已被垃圾收集)
let John = { major: "math" };
const set = new Set();
const weakSet = new WeakSet();
set.add(John);
// Set {{...}}
weakSet.add(John);
// WeakSet {{...}}
John = null;
/* John 被垃圾收集 */
一旦对象 John 被垃圾回收,WeakSet 就无法访问其引用 John 的数据
而且 WeakSet 不支持
for...of
或 forEach,因为它不可迭代
8.比较总结
1)Map
- 键名唯一不可重复
- 类似于集合,键值对的集合,任何值都可以作为一个键或者一个值
- 可以遍历,可以转换各种数据格式,方法 get、set、has、delete
2)WeakMap
- 只接受对象为键名,不接受其他类型的值作为键名,键值可以是任意
- 键名是拖引用,键名所指向的对象,会被垃圾回收机制回收
- 不能遍历,方法 get、set、has、delete
3)Set
- 成员唯一,无序且不会重复
- 类似于数组集合,键值和键名是一致的(只有键值。没有键名)
- 可以遍历,方法有 add、delete、has
4)WeakSet
- 只能存储对应引用,不能存放值
- 成员都是弱引用,会被垃圾回收机制回收
- 不能遍历,方法有 add、delete、has
9.真题解答
1)是否了解 WeakMap、WeakSet(美团 2019 年)
WeakSet 对象是一些对象值的集合, 并且其中的每个对象值都只能出现一次,在 WeakSet 的集合中是唯一的
它和 Set 对象的区别有两点:
- 与 Set 相比,WeakSet 只能是对象的集合,而不能是任何类型的任意值
- WeakSet 持弱引用:集合中对象的引用为弱引用。 如果没有其他的对 WeakSet 中对象的引用,那么这些对象会被当成垃圾回收掉。 这也意味着 WeakSet 中没有存储当前对象的列表,正因为这样,WeakSet 是不可枚举的
WeakMap 对象也是键值对的集合,它的键必须是对象类型,值可以是任意类型。它的键被弱保持,也就是说,当其键所指对象没有其他地方引用的时候,它会被 GC 回收掉。WeakMap 提供的接口与 Map 相同
与 Map 对象不同的是,WeakMap 的键是不可枚举的。不提供列出其键的方法。列表是否存在取决于垃圾回收器的状态,是不可预知的
(二十二)深浅拷贝
1.经典真题
- 深拷贝和浅拷贝的区别?如何实现
2.深拷贝和浅拷贝的概念
1)浅拷贝
- 只拷贝基本类型的数据
- 拷贝引用类型的数据后也会发生引用
- 这种拷贝叫做浅拷贝(浅复制)
- 浅拷贝只复制指向某个对象的指针(引用地址),而不复制对象本身
- 新旧对象还是共享同一块内存
2)深拷贝
- 在堆中重新分配内存,并把源对象所有属性都进行新建拷贝
- 以保证深拷贝的对象的引用图不包含任何原有对象或对象图上的任何对象
- 拷贝后的对象与原来的对象完全隔离,互不影响
3.浅拷贝方法
1)直接赋值
- 最常见的一种浅拷贝方式
var stu = {
name: "xiejie",
age: 18,
};
// 直接赋值
var stu2 = stu;
stu2.name = "zhangsan";
console.log(stu); // { name: 'zhangsan', age: 18 }
console.log(stu2); // { name: 'zhangsan', age: 18 }
2)Object.assign 方法
- 该方法用于将所有可枚举属性的值从一个或多个源对象分配到目标对象
a)基本用法
var stu = {
name: "xiejie",
};
var stu2 = Object.assign(stu, { age: 18 }, { gender: "male" });
console.log(stu2); // { name: 'xiejie', age: 18, gender: 'male' }
b)拷贝对象
const stu = {
name: "xiejie",
age: 18,
};
const stu2 = Object.assign({}, stu);
stu2.name = "zhangsan";
console.log(stu); // { name: 'xiejie', age: 18 }
console.log(stu2); // { name: 'zhangsan', age: 18 }
- 当对象的属性值对应的是一个对象时,该方法拷贝的是 对象的属性的引用,而不是对象本身
const stu = {
name: "xiejie",
age: 18,
stuInfo: {
No: 1,
score: 100,
},
};
const stu2 = Object.assign({}, stu);
stu2.name = "zhangsan";
stu2.stuInfo.score = 90;
console.log(stu); // { name: 'xiejie', age: 18, stuInfo: { No: 1, score: 90 } }
console.log(stu2); // { name: 'zhangsan', age: 18, stuInfo: { No: 1, score: 90 } }
3)ES6 扩展运算符
a)基本用法
- ES6 扩展运算符可以将数组表达式或者 string 在语法层面展开
- 还可以在构造字面量对象时,将对象表达式按 key-value 的方式展开
var arr = [1, 2, 3];
var arr2 = [3, 5, 8, 1, ...arr]; // 展开数组
console.log(arr2); // [3, 5, 8, 1, 1, 2, 3]
var stu = {
name: "xiejie",
age: 18,
};
var stu2 = { ...stu, score: 100 }; // 展开对象
console.log(stu2); // { name: 'xiejie', age: 18, score: 100 }
b)拷贝对象
const stu = {
name: "xiejie",
age: 18,
};
const stu2 = { ...stu };
stu2.name = "zhangsan";
console.log(stu); // { name: 'xiejie', age: 18 }
console.log(stu2); // { name: 'zhangsan', age: 18 }
- 如果对象中某个属性对应的值为引用类型,那么直接拷贝的是 引用地址
const stu = {
name: "xiejie",
age: 18,
stuInfo: {
No: 1,
score: 100,
},
};
const stu2 = { ...stu };
stu2.name = "zhangsan";
stu2.stuInfo.score = 90;
console.log(stu); // { name: 'xiejie', age: 18, stuInfo: { No: 1, score: 90 } }
console.log(stu2); // { name: 'zhangsan', age: 18, stuInfo: { No: 1, score: 90 } }
4)数组的 slice 和 concat 方法
- 数组也是一种对象
- 在 Array 中的 slice 和 concat 方法,不修改原数组
- 只会返回一个 浅复制了原数组中的元素 的一个 新数组
a)基本类型
// concat 拷贝数组
var arr1 = [1, true, "Hello"];
var arr2 = arr1.concat();
console.log(arr1); // [ 1, true, 'Hello' ]
console.log(arr2); // [ 1, true, 'Hello' ]
arr2[0] = 2;
console.log(arr1); // [ 1, true, 'Hello' ]
console.log(arr2); // [ 2, true, 'Hello' ]
// slice 拷贝数组
var arr1 = [1, true, "Hello"];
var arr2 = arr1.slice();
console.log(arr1); // [ 1, true, 'Hello' ]
console.log(arr2); // [ 1, true, 'Hello' ]
arr2[0] = 2;
console.log(arr1); // [ 1, true, 'Hello' ]
console.log(arr2); // [ 2, true, 'Hello' ]
b)引用类型
- 如果数组里的元素是引用类型,那么这两个方法是 直接拷贝的引用地址
// concat 拷贝数组
var arr1 = [1, true, "Hello", { name: "xiejie", age: 18 }];
var arr2 = arr1.concat();
console.log(arr1); // [ 1, true, 'Hello', { name: 'xiejie', age: 18 } ]
console.log(arr2); // [ 1, true, 'Hello', { name: 'xiejie', age: 18 } ]
arr2[0] = 2;
arr2[3].age = 19;
console.log(arr1); // [ 1, true, 'Hello', { name: 'xiejie', age: 19 } ]
console.log(arr2); // [ 2, true, 'Hello', { name: 'xiejie', age: 19 } ]
// slice 拷贝数组
var arr1 = [1, true, "Hello", { name: "xiejie", age: 18 }];
var arr2 = arr1.slice();
console.log(arr1); // [ 1, true, 'Hello', { name: 'xiejie', age: 18 } ]
console.log(arr2); // [ 1, true, 'Hello', { name: 'xiejie', age: 18 } ]
arr2[0] = 2;
arr2[3].age = 19;
console.log(arr1); // [ 1, true, 'Hello', { name: 'xiejie', age: 19 } ]
console.log(arr2); // [ 2, true, 'Hello', { name: 'xiejie', age: 19 } ]
$.extend
5)jQuery 中的 - 在 jQuery 中,
$.extend(deep, target, object1, ..., objectN)
方法可以进行深浅拷贝 - deep
- 设为 true 为深拷贝
- 默认 false 为浅拷贝
- target
- 要拷贝的目标对象
- object1
- 待拷贝到第一个对象的对象
- objectN
- 待拷贝到第 N 个对象的对象
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script>
const obj = {
name: "wade",
age: 37,
friend: {
name: "james",
age: 34,
},
};
const cloneObj = {};
// deep 默认为 false 为浅拷贝
$.extend(cloneObj, obj);
obj.friend.name = "rose";
console.log(obj);
console.log(cloneObj);
</script>
5.深拷贝方法
1)JSON.parse(JSON.stringify)
- 用 JSON.stringify 将对象转成 JSON 字符串
- 再用 JSON.parse 方法把字符串解析成对象
- 产生了新的对象,而且对象会开辟新的栈,实现深拷贝
const stu = {
name: "xiejie",
age: 18,
stuInfo: {
No: 1,
score: 100,
},
};
const stu2 = JSON.parse(JSON.stringify(stu));
stu2.name = "zhangsan";
stu2.stuInfo.score = 90;
console.log(stu); // { name: 'xiejie', age: 18, stuInfo: { No: 1, score: 100 } }
console.log(stu2); // { name: 'zhangsan', age: 18, stuInfo: { No: 1, score: 90 } }
- 缺点:不能处理函数
- 因为 JSON.stringify 方法是将一个 JavaScript 值(对象或者数组)转换为一个 JSON 字符串
- 而 JSON 字符串是不能够接受函数的
- 正则对象也一样,在 JSON.parse 解析时会发生错误
const stu = {
name: "xiejie",
age: 18,
stuInfo: {
No: 1,
score: 100,
saySth: function () {
console.log("我是一个学生");
},
},
};
const stu2 = JSON.parse(JSON.stringify(stu));
stu2.name = "zhangsan";
stu2.stuInfo.score = 90;
console.log(stu); // { name: 'xiejie', age: 18, stuInfo: { No: 1, score: 100, saySth: [Function: saySth] }}
console.log(stu2); // { name: 'zhangsan', age: 18, stuInfo: { No: 1, score: 90 } }
2)$.extend(deep,target,object1,objectN)
- 只需要将第一个参数设置为 true 即可
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script>
const obj = {
name: "wade",
age: 37,
friend: {
name: "james",
age: 34,
},
};
const cloneObj = {};
// deep 设为 true 为深拷贝
$.extend(true, cloneObj, obj);
obj.friend.name = "rose";
console.log(obj);
console.log(cloneObj);
</script>
3)手写递归方法
function deepClone(target) {
var result;
// 判断是否是对象类型
if (typeof target === "object") {
if (Array.isArray(target)) {
// 判断是否是数组类型
// 如果是数组,创建一个空数组
result = [];
// 遍历数组的键
for (var i in target) {
// 递归调用
result.push(deepClone(target[i]));
}
} else if (target === null) {
// 再判断是否是 null
// 如果是,直接等于 null
result = null;
} else if (target.constructor === RegExp) {
// 判断是否是正则对象
// 如果是,直接赋值拷贝
result = target;
} else if (target.constructor === Date) {
// 判断是否是日期对象
// 如果是,直接赋值拷贝
result = target;
} else {
// 否则是对象
// 创建一个空对象
result = {};
// 遍历该对象的每一个键
for (var i in target) {
// 递归调用
result[i] = deepClone(target[i]);
}
}
} else {
// 表示不是对象类型,则是简单数据类型,直接赋值
result = target;
}
// 返回结果
return result;
}
- 测试 1
const stu = {
name: "xiejie",
age: 18,
stuInfo: {
No: 1,
score: 100,
saySth: function () {
console.log("我是一个学生");
},
},
};
const stu2 = deepClone(stu);
stu2.name = "zhangsan";
stu2.stuInfo.score = 90;
console.log(stu); // { name: 'xiejie', age: 18, stuInfo: { No: 1, score: 100, saySth: [Function: saySth] }}
console.log(stu2); // { name: 'xiejie', age: 18, stuInfo: { No: 1, score: 90, saySth: [Function: saySth] }}
- 测试 2
var arr1 = [1, true, "Hello", { name: "xiejie", age: 18 }];
var arr2 = deepClone(arr1);
console.log(arr1); // [ 1, true, 'Hello', { name: 'xiejie', age: 18 } ]
console.log(arr2); // [ 1, true, 'Hello', { name: 'xiejie', age: 18 } ]
arr2[0] = 2;
arr2[3].age = 19;
console.log(arr1); // [ 1, true, 'Hello', { name: 'xiejie', age: 18 } ]
console.log(arr2); // [ 2, true, 'Hello', { name: 'xiejie', age: 19 } ]
6.真题解答
1)深拷贝和浅拷贝的区别?如何实现
- 浅拷贝:只是拷贝了基本类型的数据,而引用类型数据,复制后也是会发生引用,我们把这种拷贝叫做浅拷贝(浅复制)
浅拷贝只复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存
- 深拷贝:在堆中重新分配内存,并且把源对象所有属性都进行新建拷贝,以保证深拷贝的对象的引用图不包含任何原有对象或对象图上的任何对象,拷贝后的对象与原来的对象是完全隔离,互不影响
- 浅拷贝方法
- 直接赋值
- Object.assign 方法:可以把任意多个的源对象自身的可枚举属性拷贝给目标对象,然后返回目标对象。当拷贝的 object 只有一层的时候,是深拷贝,但是当拷贝的对象属性值又是一个引用时,换句话说有多层时,就是一个浅拷贝
- ES6 扩展运算符,当 object 只有一层的时候,也是深拷贝。有多层时是浅拷贝
- Array.prototype.concat 方法
- Array.prototype.slice 方法
- jQuery 中的 .extend:在 jQuery 中,.extend(deep,target,object1,objectN) 方法可以进行深浅拷贝。deep 设为 true 为深拷贝,默认是 false 浅拷贝
- 深拷贝方法
- $.extend(deep,target,object1,objectN),将 deep 设置为 true
- JSON.parse(JSON.stringify):用 JSON.stringify 将对象转成 JSON 字符串,再用 JSON.parse 方法把字符串解析成对象,一去一来,新的对象产生了,而且对象会开辟新的栈,实现深拷贝。这种方法虽然可以实现数组或对象深拷贝,但不能处理函数
- 手写递归
function deepCopy(oldObj, newobj) { for (var key in oldObj) { var item = oldObj[key]; if (item instanceof Object) { // 判断是否是对象 if (item instanceof Function) { newobj[key] = oldObj[key]; } else { newobj[key] = {}; // 定义一个空的对象来接收拷贝的内容 deepCopy(item, newobj[key]); // 递归调用 } } else if (item instanceof Array) { // 判断是否是数组 newobj[key] = []; // 定义一个空的数组来接收拷贝的内容 deepCopy(item, newobj[key]); // 递归调用 } else { newobj[key] = oldObj[key]; } } }
(二十三)函数柯里化
1.经典真题
- 什么是函数柯里化?
2.函数柯里化介绍
- 在计算机科学中,柯里化(Currying),又译为卡瑞化或加里化
- 把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数
- 并且返回接受余下的参数而且返回结果的新函数
这个技术由克里斯托弗·斯特雷奇以逻辑学家哈斯凯尔·加里命名的
尽管它是 Moses Schönfinkel 和戈特洛布·弗雷格发明的
- 在直觉上,柯里化声称如果固定某些参数,将得到接受余下参数的一个函数(返回函数)
- 在调用返回函数时,将判断当前的参数和之前被柯里化函数固定的参数拼起来之后,是否达到了原本函数的参数个数
- 如果是,则执行原本的函数,得到结果
- 如果没有达到,则要继续调用柯里化函数来固定目前的参数
- 在理论计算机科学中,柯里化提供了在简单的理论模型中
- 如:只接受一个单一参数的 lambda 演算中,研究带有多个参数的函数的方式
- 函数柯里化的对偶是 Uncurrying
- 一种使用匿名单参数函数来实现多参数函数的方法
3.柯里化快速入门
1)入门示例
- 有一个求取两个数之和的函数
- 接收两个形参,返回两形参的和
function add(x, y) {
return x + y;
}
console.log(add(1, 2)); // 3
console.log(add(5, 7)); // 12
- 在调用的时候,每次都需要传递两个参数
- 对该函数柯里化
- 只接受一个参数
- 返回一个函数,也接收一个参数
- 利用闭包的特性,可以访问到最开始传入的 x 的值
- 最终返回 x 和 y 的和
function add(x) {
return function (y) {
return x + y;
};
}
console.log(add(1)(2)); // 3
console.log(add(5)(7)); // 3
2)柯里化函数的特点
- 一个柯里化的函数首先会接受一些参数
- 接受了这些参数之后,该函数并不会立即求值
- 而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来
- 待到函数被真正需要求值的时候,之前传入的所有参数都会被一次性用于求值
4.函数柯里化实际应用
1)参数复用
- 将相同的参数固定下来
// 正常正则验证字符串
reg.test(txt);
// 函数封装后
function check(reg, txt) {
return reg.test(txt);
}
// 即使是相同的正则表达式,也需要重新传递一次
console.log(check(/\d+/g, "test1")); // true
console.log(check(/\d+/g, "testtest")); // false
console.log(check(/[a-z]+/g, "test")); // true
// Currying后
function curryingCheck(reg) {
return function (txt) {
return reg.test(txt);
};
}
// 正则表达式通过闭包保存了起来
var hasNumber = curryingCheck(/\d+/g);
var hasLetter = curryingCheck(/[a-z]+/g);
console.log(hasNumber("test1")); // true
console.log(hasNumber("testtest")); // false
console.log(hasLetter("21212")); // false
2)提前确认
/**
* 给DOM元素绑定事件
* @param {HTMLElement} element 要绑定事件的 DOM 元素
* @param {Event} event 绑定什么事件
* @param {Function} handler 事件处理函数
*/
var on = function (element, event, handler) {
if (document.addEventListener) {
if (element && event && handler) {
element.addEventListener(event, handler, false);
}
} else {
if (element && event && handler) {
element.attachEvent("on" + event, handler);
}
}
};
on(div, "click", function () {});
// Currying
var on = (function () {
if (document.addEventListener) {
return function (element, event, handler) {
if (element && event && handler) {
element.addEventListener(event, handler, false);
}
};
} else {
return function (element, event, handler) {
if (element && event && handler) {
element.attachEvent("on" + event, handler);
}
};
}
})();
on(div, "click", function () {});
// 换种写法,上面其实是把 isSupport 这个参数先确定下来了
var on = function (isSupport, element, event, handler) {
isSupport = isSupport || document.addEventListener;
if (isSupport) {
return element.addEventListener(event, handler, false);
} else {
return element.attachEvent("on" + event, handler);
}
};
on(true, div, "click", function () {});
on(true, div, "click", function () {});
on(true, div, "click", function () {});
在做项目的过程中,封装一些 DOM 操作可以说再常见不过
上面第一种写法也是比较常见
但是第二种写法相对于第一种写法就是自执行然后返回一个新的函数
这样其实就是提前确定了会走哪一个方法,避免每次都进行判断
5.封装通用柯里化函数
function curry() {
// 获取要执行的函数
var fn = arguments[0];
// 获取传递的参数,构成一个参数数组
var args = [].slice.call(arguments, 1);
// 如果传递的参数已经等于执行函数所需的参数数量
if (args.length === fn.length) {
return fn.apply(this, args);
}
// 参数不够向外界返回的函数
function _curry() {
// 将新接收到的参数推入到参数数组中
args.push(...arguments);
if (args.length === fn.length) {
return fn.apply(this, args);
}
return _curry;
}
return _curry;
}
- 测试
// 测试 1
function add(a, b, c) {
return a + b + c;
}
console.log(curry(add)(1)(2)(3)); // 6
console.log(curry(add, 1)(2)(3)); // 6
console.log(curry(add, 1, 2, 3)); // 6
console.log(curry(add, 1)(3, 4)); // 8
var addCurrying = curry(add)(2);
console.log(addCurrying(7)(8)); // 17
// 测试 2
function check(reg, txt) {
return reg.test(txt);
}
var hasNumber = curry(check)(/\d+/g);
console.log(hasNumber("test1")); // true
6.一道经典的柯里化面试题
- 实现一个 add 方法,使计算结果能够满足如下预期
add(1)(2)(3) = 6;
add(1, 2, 3)(4) = 10;
add(1)(2)(3)(4)(5) = 15;
- 要完成上面的需求,就可以使用柯里化函数
function add() {
// 第一次执行时,定义一个数组专门用来存储所有的参数
var _args = Array.prototype.slice.call(arguments);
// 在内部声明一个函数,利用闭包的特性保存 _args 并收集所有的参数值
var _adder = function () {
_args.push(...arguments);
return _adder;
};
// 这个是最后输出的时候被调用的,return 后面如果是函数体,
// 为了输出函数体字符串会自动调用 toString 方法
// 利用 toString 隐式转换的特性,当最后执行时隐式转换,并计算最终的值返回
_adder.toString = function () {
return _args.reduce(function (a, b) {
return a + b;
});
};
// 这个 return 是第一次调用的时候返回上面的函数体,
// 这样后面所有的括号再执行的时候就是执行 _adder 函数体
return _adder;
}
console.log(add(1)(2)(3).toString()); // 6
console.log(add(1, 2, 3)(4).toString()); // 10
console.log(add(1)(2)(3)(4)(5).toString()); // 15
console.log(add(2, 6)(1).toString()); // 9
7.真题详解
1)什么是函数柯里化?
柯里化(currying)又称部分求值。一个柯里化的函数首先会接受一些参数,接受了这些参数之后,该函数并不会立即求值,而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来。待到函数被真正需要求值的时候,之前传入的所有参数都会被一次性用于求值
举个例子,就是把原本:
- function(arg1,arg2) 变成 function(arg1)(arg2)
- function(arg1,arg2,arg3) 变成 function(arg1)(arg2)(arg3)
- function(arg1,arg2,arg3,arg4) 变成 function(arg1)(arg2)(arg3)(arg4)
总而言之,就是将:
function(arg1,arg2,…,argn) 变成 function(arg1)(arg2)…(argn)
(二十四)Node 事件循环
1.经典真题
- 请简述一下 Node.js 中的事件循环,和浏览器环境的事件循环有何不同?
- Node.js 与浏览器的事件循环有何区别?
2.进程与线程
- JavaScript 是一门单线程语言,指的是一个进程里只有一个主线程
- 进程是 CPU 资源分配的最小单位
- 线程是 CPU 调度的最小单位
进程好比图中的工厂,有单独的专属自己的工厂资源
线程好比图中的工人,多个工人在一个工厂中协作工作
工厂与工人是 1:n 的关系,工厂的空间是工人们共享的,多个工厂之间独立存在
- 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线
- 一个进程的内存空间是共享的,每个线程都可用这些共享内存
- 多个进程间独立存在
3.多进程和多线程
1)多进程
- 在同一个时间里,同一个计算机系统中允许两个或两个以上的进程处于运行状态
2)多线程
- 程序中包含多个执行流,即在一个程序中可以同时运行多个不同的线程来执行不同的任务
- 即:允许单个程序创建多个并行执行的线程来完成各自的任务
3)以 Chrome 浏览器为例
- 当打开一个 Tab 页时,其实就是创建了一个进程
- 一个进程中可以有多个线程
- 如:渲染线程、JS 引擎线程、HTTP 请求线程等等
- 当发起一个请求时,其实就是创建了一个线程
- 当请求结束后,该线程可能就会被销毁
4.浏览器内核
- 浏览器内核通过取得页面内容、整理信息(应用 CSS)、计算和组合,最终输出可视化的图像结果
- 通常也被称为渲染引擎
- 浏览器内核是 多线程
- 在内核控制下各线程相互配合以保持同步
- 一个浏览器通常由以下常驻线程组成
- GUI 渲染线程
- JavaScript 引擎线程
- 定时触发器线程
- 事件触发线程
- 异步 HTTP 请求线程
1)GUI 渲染线程
- 主要负责页面的渲染,解析 HTML、CSS,构建 DOM 树,布局和绘制等
- 当界面需要重绘或者由于某种操作引发回流时,将执行该线程
- 该线程与 JS 引擎线程 互斥
- 当执行 JS 引擎线程时,GUI 渲染会被挂起
- 当任务队列空闲时,主线程才会去执行 GUI 渲染
2)JavaScript 引擎线程
- 主要负责处理 JavaScript 脚本,执行代码
- 也负责执行准备好待执行的事件
- 即定时器计数结束,或者异步请求成功并正确返回时
- 将依次进入任务队列,等待 JS 引擎线程的执行
- 该线程与 GUI 渲染线程 互斥
- 当 JS 引擎线程执行 JavaScript 脚本时间过长,将导致页面渲染的阻塞
3)定时触发器线程
- 负责执行异步定时器一类的函数的线程
- 如:setTimeout、setInterval
- 主线程依次执行代码时,遇到定时器,会将定时器交给该线程处理
- 当计数完毕后,事件触发线程会将计数完毕后的事件加入到任务队列的尾部
- 等待 JS 引擎线程执行
4)事件触发线程
- 主要负责将准备好的事件交给 JS 引擎线程执行
- 如:setTimeout 定时器计数结束,ajax 等异步请求成功并触发回调函数,或者用户触发点击事件时
- 该线程会将整装待发的事件依次加入到任务队列的队尾
- 等待 JS 引擎线程的执行
5)异步 HTTP 请求线程
- 负责执行异步请求一类的函数的线程
- 如:Promise、fetch、ajax 等
- 主线程依次执行代码时,遇到异步请求,会将函数交给该线程处理
- 当监听到状态码变更,如果有回调函数,事件触发线程会将回调函数加入到任务队列的尾部
- 等待 JS 引擎线程执行
5.浏览器中的事件循环
1)宏任务和微任务
- 事件循环中的异步队列有两种
- 宏任务 macro 队列
- 微任务 micro 队列
- 宏任务队列和微任务队列都只有一个
- 常见的宏任务
- setTimeout
- setInterval
- requestAnimationFrame
- script 等
- 常见的微任务
- new Promise().then(回调)
- MutationObserver 等
2)事件循环流程
a)一开始执行栈空
- 可以把执行栈认为是 一个存储函数调用的栈结构,遵循先进后出的原则
- 微任务队列空
- 宏任务队列里有且只有一个 script 脚本(整体代码)
b)全局上下文(script 标签)被推入执行栈,同步代码执行
- 在执行的过程中,会判断是同步任务还是异步任务
- 通过对一些接口的调用,可以产生新的宏任务与微任务
- 会分别被推入各自的任务队列里
- 同步代码执行完了,script 脚本会被移出宏任务队列
- 这个过程本质上是队列的宏任务的执行和出队的过程
c)处理微任务
- 当一个宏任务执行完毕后,会执行所有的微任务
- 即:将整个微任务队列清空
d)执行渲染操作,更新界面
e)检查是否存在 Web worker 任务
- 如果有,则对其进行处理
f)上述过程循环往复,直到两个队列都清空
g)宏任务和微任务的执行流程
- 当某个宏任务执行完后,会查看是否有微任务队列
- 如果有,先执行微任务队列中的所有任务
- 如果没有,会读取宏任务队列中排在最前的任务
- 执行宏任务的过程中,遇到微任务,依次加入微任务队列
- 栈空后,再次读取微任务队列里的任务,依次类推
console.log("script start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
Promise.resolve()
.then(function () {
console.log("promise1");
})
.then(function () {
console.log("promise2");
});
console.log("script end");
/*
script start
script end
promise1
promise2
setTimeout
*/
首先执行同步的任务,输出 script start 以及 script end
接下来是处理异步任务,异步任务分为宏任务队列和微任务队列,在执行宏任务队列中的每个宏任务之前先把微任务清空一遍
由于 promise 是微任务,所以会先被执行,而 setTimeout 由于是一个宏任务,会在微任务队列被清空后再执行
Promise.resolve().then(() => {
console.log("Promise1");
setTimeout(() => {
console.log("setTimeout2");
}, 0);
});
setTimeout(() => {
console.log("setTimeout1");
Promise.resolve().then(() => {
console.log("Promise2");
});
}, 0);
/*
Promise1
setTimeout1
Promise2
setTimeout2
*/
一开始执行栈的同步任务(宏任务),执行完毕会查看是否有微任务队列
然后执行微任务队列中的所有任务输出 Promise1,同时会生成一个宏任务 setTimeout2
然后去查看宏任务队列,宏任务 setTimeout1 在 setTimeout2 之前,先执行宏任务 setTimeout1,输出 setTimeout1
在执行宏任务 setTimeout1 时会生成微任务 Promise2,放入微任务队列中
接着先去清空微任务队列中的所有任务,输出 Promise2
清空完微任务队列中的所有任务后,就又会去宏任务队列取一个,这回执行的是 setTimeout2
6.Node.js 中的事件循环
1)Node.js 事件循环介绍
- Node.js 采用 V8 作为 JS 的解析引擎,而 I/O 处理方面使用了自己设计的 libuv
- libuv 是一个基于事件驱动的跨平台抽象层
- 封装了不同操作系统一些底层特性
- 对外提供统一的 API
- 事件循环机制也是它里面的实现
2)Node.js 的运行机制
- V8 引擎解析 JavaScript 脚本
- 解析后的代码,调用 Node API
- libuv 库负责 Node API 的执行
- 将不同的任务分配给不同的线程,形成一个事件循环
- 以异步的方式将任务的执行结果返回给 V8 引擎
- V8 引擎再将结果返回给用户
- 整个架构图
3)事件循环的 6 个阶段
- libuv 引擎中的事件循环分为 6 个阶段
- 按照顺序反复运行
- 每当进入某一个阶段的时候,都会从对应的回调队列中取出函数执行
- 当队列为空或者执行的回调函数数量到达系统设定的阈值时,就会进入下一阶段
- 外部输入数据 => 轮询阶段(poll) => 检查阶段(check) => 关闭事件回调阶段(close callback) => 定时器检测阶段(timer) => I/O 事件回调阶段(I/O callbacks) => 闲置阶段(idle、prepare) => 轮询阶段(按照该顺序反复运行)...
阶段 | 工作 |
---|---|
timers 阶段 | 执行 timer(setTimeout、setInterval)的回调 |
I/O callbacks 阶段 | 处理一些上一轮循环中的少数未执行的 I/O 回调 |
idle、prepare 阶段 | 仅 Node.js 内部使用 |
poll 阶段 | 获取新的 I/O 事件, 适当的条件下 Node.js 将阻塞在这里 |
check 阶段 | 执行 setImmediate() 的回调 |
close callbacks 阶段 | 执行 socket 的 close 事件回调 |
注意
上面六个阶段都不包括 process.nextTick()
- 绝大部分异步任务都是在 timers、poll、check 这 3 个阶段处理
4)timer 阶段
- 会执行 setTimeout 和 setInterval 回调
- 由 poll 阶段控制
- ==在 Node.js 中定时器指定的时间也不是准确时间,只能是尽快执行
- 取不到 0ms,最少是 1ms
5)poll 阶段
- 这一阶段中,系统会做两件事情
- 回到 timer 阶段执行回调
- 执行 I/O 回调
- 在进入该阶段时如果没有设定 timer
- 如果 poll 队列不为空
- 会遍历回调队列并同步执行
- 直到队列为空或者达到系统限制
- 如果 poll 队列为空
- 如果有 setImmediate 回调需要执行
- poll 阶段会停止并且进入到 check 阶段执行回调
- 如果没有 setImmediate 回调需要执行
- 会等待回调被加入到队列中并立即执行回调
- 会有个超时时间设置,防止一直等待下去
- 如果有 setImmediate 回调需要执行
- 如果 poll 队列不为空
- 如果设定了 timer 且 poll 队列为空
- 会判断是否有 timer 超时
- 如果有的话会回到 timer 阶段执行回调
- 如果 poll 被堵塞,即使 timer 已经到时间了也只能等着
- 所以定时器指定的时间并不是准确的时间
const start = Date.now();
setTimeout(function f1() {
console.log("setTimeout", Date.now() - start);
}, 200);
const fs = require("fs");
fs.readFile("./index.js", "utf-8", function f2() {
console.log("readFile");
const start = Date.now();
// 强行延时 500 毫秒
while (Date.now() - start < 500) {}
});
6)check 阶段
- setImmediate() 的回调会被加入 check 队列中
- check 阶段的执行顺序在 poll 阶段之后
console.log("start");
setTimeout(() => {
console.log("timer1");
Promise.resolve().then(function () {
console.log("promise1");
});
}, 0);
setTimeout(() => {
console.log("timer2");
Promise.resolve().then(function () {
console.log("promise2");
});
}, 0);
Promise.resolve().then(function () {
console.log("promise3");
});
console.log("end");
/*
start
end
promise3
timer1
promise1
timer2
promise2
*/
一开始执行同步任务,依次打印出 start end,并将 2 个 timer 依次放入 timer 队列
之后会立即执行微任务队列,所以打印出 promise3
然后进入 timers 阶段,执行 timer1 的回调函数,打印 timer1,发现有一个 promise.then 回调
将其加入到微任务队列并且立即执行,之后同样的步骤执行 timer2,打印 timer2 以及 promise2
7)setTimeout 和 setImmediate 区别
- 区别主要在于 调用时机不同
- setImmediate 设计在 poll 阶段 完成时 执行,即 check 阶段
- setTimeout 设计在 poll 阶段为 空闲时,且设定时间到达后 执行,但在 timer 阶段执行
setTimeout(function timeout() {
console.log("timeout");
}, 0);
setImmediate(function immediate() {
console.log("immediate");
});
对于以上代码来说,setTimeout 可能执行在前,也可能执行在后
首先 setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的
进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调
如果准备时间花费小于 1ms,那么就是 setImmediate 回调先执行
- 二者在异步 I/O callback 内部调用时,总是先执行 setImmediate,再执行 setTimeout
const fs = require("fs");
fs.readFile(__filename, () => {
setTimeout(() => {
console.log("timeout");
}, 0);
setImmediate(() => {
console.log("immediate");
});
});
// immediate
// timeout
setImmediate 永远先执行
因为两个代码写在 I/O 回调中,I/O 回调是在 poll 阶段执行
当回调执行完毕后队列为空,发现存在 setImmediate 回调,直接跳转到 check 阶段执行回调
8)process.nextTick
- 这个函数其实是独立于事件循环之外的,有一个自己的队列
- 当每个阶段完成后,如果存在 nextTick 队列
- 会清空队列中的所有回调函数
- 且优先于其他 micro task 执行
setTimeout(() => {
console.log("timer1");
Promise.resolve().then(function () {
console.log("promise1");
});
}, 0);
process.nextTick(() => {
console.log("nextTick");
process.nextTick(() => {
console.log("nextTick");
process.nextTick(() => {
console.log("nextTick");
process.nextTick(() => {
console.log("nextTick");
});
});
});
});
/*
nextTick
nextTick
nextTick
nextTick
timer1
promise1
*/
9)Promise.then
- 独立于事件循环之外,有一个自己的队列
- 优先级比 process.nextTick 要低
- 所以当微任务中同时存在 process.nextTick 和 Promise.then 时,会优先执行 nextTick
setTimeout(() => {
console.log("timer1");
Promise.resolve().then(function () {
console.log("promise1");
});
process.nextTick(() => {
console.log("nextTick");
});
}, 0);
setTimeout(() => {
console.log("timer2");
Promise.resolve().then(function () {
console.log("promise2");
});
}, 0);
/*
timer1
nextTick
promise1
timer2
promise2
*/
7.Node.js 与浏览器的事件队列的差异
- 浏览器环境下只有两个队列,宏任务队列 + 微任务队列
- 微任务的任务队列是 每个宏任务执行完之后 执行
- 在 Node.js 中, 每个任务队列的每个任务执行完毕之后 ,就会清空这个微任务队列
8.真题解答
1)请简述一下 Node.js 中的事件循环,和浏览器环境的事件循环有何不同?
Node.JS 的事件循环分为 6 个阶段:
- timers 阶段:这个阶段执行 timer( setTimeout、setInterval )的回调
- I/O callbacks 阶段:处理一些上一轮循环中的少数未执行的 I/O 回调
- idle、prepare 阶段:仅 Node.js 内部使用
- poll 阶段:获取新的 I/O 事件, 适当的条件下 Node.js 将阻塞在这里
- check 阶段:执行 setImmediate( ) 的回调
- close callbacks 阶段:执行 socket 的 close 事件回调
事件循环的执行顺序为:
外部输入数据 –-> 轮询阶段( poll )-–> 检查阶段( check )-–> 关闭事件回调阶段( close callback )–-> 定时器检测阶段( timer )–-> I/O 事件回调阶段( I/O callbacks )-–>闲置阶段( idle、prepare )–->轮询阶段(按照该顺序反复运行)...
浏览器和 Node.js 环境下,微任务任务队列的执行时机不同
- 在 Node.js 中,每个任务队列的每个任务执行完毕之后,就会清空这个微任务队列
- 浏览器环境下,就两个队列,一个宏任务队列,一个微任务队列。微任务的任务队列是每个宏任务执行完之后执行
(二十五)eval
1.经典真题
- JavaScript 中的 eval 方法是什么?一般什么场景下使用?
2.eval 的基本用法
- eval() 函数接收一个字符串作为参数
- 该字符串可以是 JavaScript 表达式、语句或一系列语句
- 表达式可以包含变量与已存在对象的属性
eval('console.log("Hello!")'); // Hello!
var str = `
var a = 1;
var b = 2;
if(a > b) {
console.log('a > b');
} else {
console.log('a < b');
}
`;
eval(str); // a < b
console.log(eval("2 + 2")); // 4( number 类型 )
console.log(eval(new String("Hello"))); // [String: 'Hello']
console.log(eval("2 + 2") === eval("4")); // true
console.log(eval("2 + 2") === eval(new String("2 + 2"))); // false
- eval() 会将传入的字符串作为 JavaScript 执行
- 如果 eval() 的参数不是字符串,会将参数原封不动地返回
console.log(eval(true)); // true
console.log(eval(5)); // 5
- 如果传入的字符串不是 JavaScript 代码,也会将此字符串原封不动的返回
var Hello = 5;
console.log(eval("Hello")); // 5
3.eval 作用域
- eval 里面的代码在 当前词法环境 中执行
- 可以使用外部变量
let a = 1;
function f() {
let a = 2;
eval("console.log(a)"); // 2
}
f();
let x = 5;
eval("x = 10");
console.log(x); // 10, value modified
- 在严格模式下,eval 有自己的词法环境
- 在 eval 内部声明的函数和变量在外部不可见
"use strict";
eval("let x = 5; function f() {}");
console.log(typeof x); // undefined (no such variable)
4.永远不要使用 eval
在现代编程中,eval 的使用非常谨慎。人们常说“eval is evil”
原因很简单:很久很久以前,JavaScript 是一种弱得多的语言,很多事情只能用 eval 来完成,但那段时间已经过去十年了
现在,几乎没有理由使用 eval
如果有人使用了它,那么一个更好的选择是用现代语言结构或 JavaScript 模块替换
- eval 是一个危险的函数,使用与调用者相同的权限执行代码
- 如果用 eval 运行的字符串代码被恶意方修改,最终可能会在网页/扩展程序的权限下,在用户计算机上运行恶意代码 —— 不安全
- eval 通常比其他替代方法更慢,因为必须调用 JS 解释器
- 而许多其他结构则可被现代 JS 引擎进行优化
- 使用 eval 往往比普通 JavaScript 代码慢几个数量级 —— 性能不好
- 产生混乱的代码逻辑
5.真题解答
1)JavaScript 中的 eval 方法是什么?一般什么场景下使用?
eval 是 JavaScript 中的一个全局函数,它将指定的字符串计算为 JavaScript 代码并执行它
在现代 JavaScript 编程中,我们应该尽量避免使用 eval,之前所有使用 eval 的地方都有更好的方式来进行代替,所以在现代 JavaScript 编程中,eval 没有什么使用场景存在,也就是说,并不存在某些场景必须要使用 eval 才能实现
(二十六)尺寸和位置
- 总结使用 JavaScript 操作 DOM 时,尺寸和宽高相关的属性
- DOM 对象相关尺寸和位置属性
- 只读属性
- clientWidth 和 clientHeight 属性
- offsetWidth 和 offsetHeight 属性
- clientTop 和 clientLeft 属性
- offsetLeft 和 offsetTop 属性
- scrollHeight 和 scrollWidth 属性
- 可读可写属性
- scrollTop 和 scrollLeft 属性
- domObj.style.xxx 属性
- 只读属性
- event 事件对象相关尺寸和位置属性
- clientX 和 clientY 属性
- screenX 和 screenY 属性
- offsetX 和 offsetY 属性
- pageX 和 pageY 属性
1.DOM 对象相关尺寸和位置属性 —— 只读属性
- 指的是 DOM 节点的固有属性
- 该属性只能通过 JavaScript 去获取而不能设置
- 且获取的值是只有数字并不带单位的(px、em 等)
- 常见的只读属性
- clientWidth 和 clientHeight 属性
- offsetWidth 和 offsetHeight 属性
- clientTop 和 clientLeft 属性
- offsetLeft 和 offsetTop 属性
- scrollHeight 和 scrollWidth 属性
2.只读属性:clientWidth 和 clientHeight 属性
- 指的是元素的可视部分宽度和高度
- 即:padding + content
<div id="container" class="container"></div>
.container {
width: 200px;
height: 200px;
background-color: red;
border: 1px solid;
overflow: auto;
padding: 10px;
margin: 20px;
}
var container = document.getElementById("container");
console.log("clientWidth:", container.clientWidth); // 220
console.log("clientHeight:", container.clientHeight); // 220
3.只读属性:offsetWidth 和 offsetHeight 属性
- 指的是元素的 border + padding + content 的宽度和高度
var container = document.getElementById("container");
console.log("offsetWidth:", container.offsetWidth); // 222
console.log("offsetWidth:", container.offsetWidth); // 222
4.只读属性:clientTop 和 clientLeft 属性
- 用于读取元素的 border 的宽度和高度
var container = document.getElementById("container");
console.log("clientTop:", container.clientTop); // 1
console.log("clientLeft:", container.clientLeft); // 1
5.只读属性:offsetLeft 和 offsetTop 属性
1)offsetParent 属性
- 获取当前元素的 离自己最近的并且定了位的 祖先元素
- 该祖先元素就是当前元素的 offsetParent
- 如果从该元素向上寻找,找不到这样一个祖先元素
- 那么当前元素的 offsetParent 就是 body 元素
var container = document.getElementById("container");
console.log(container.offsetParent); // body
2)距离属性
- offsetLeft 和 offsetTop 指的是当前元素相对于其 offsetParent 左边距离和上边距离
- 即:当前元素的 border 到包含它的 offsetParent 的 border 的距离
<div id="container" class="container">
<div id="item" class="item"></div>
</div>
.container {
width: 200px;
height: 200px;
background-color: red;
border: 1px solid;
overflow: auto;
padding: 10px;
margin: 20px;
position: relative;
}
.item {
width: 50px;
height: 50px;
background-color: blue;
position: absolute;
left: 100px;
top: 100px;
}
var container = document.getElementById("container");
var item = document.getElementById("item");
console.log(item.offsetParent); // container 盒子 dom 对象
console.log(item.offsetLeft); // 100
console.log(item.offsetTop); // 100
- 不对 item 子元素定位,而是使用 margin 的方式来设置子盒子的位置
.item {
width: 50px;
height: 50px;
background-color: blue;
margin: 50px;
}
var container = document.getElementById("container");
var item = document.getElementById("item");
console.log(item.offsetParent); // container 盒子 dom 对象
console.log(item.offsetLeft); // 60
console.log(item.offsetTop); // 60
因为设置的 margin 值为 50,但是其定了位的父元素还设置了 10 像素的 padding,所以加起来就是 60
6.只读属性:scrollHeight 和 scrollWidth 属性
- 指的是当元素内部的内容超出其宽度和高度的时候,元素内部内容的实际宽度和高度
- 如果当前元素的内容没有超过其高度或者宽度,那么返回的就是元素的可视部分宽度和高度
- 即:和 clientWidth 和 clientHeight 属性值相同
<div id="container" class="container">
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Nulla repellat porro atque culpa rem sunt sed! Voluptates vel incidunt accusamus
reiciendis aut, adipisci ut. Hic, impedit officia.Quis, animi beatae. Facere dolorum quasi laborum, rem facilis illum necessitatibus sint doloribus
beatae exercitationem sapiente! Quod vel cupiditate quam libero, delectus natus.
</div>
.container {
width: 200px;
height: 200px;
background-color: red;
border: 1px solid;
overflow: auto;
padding: 10px;
margin: 20px;
}
var container = document.getElementById("container");
console.log("scrollWidth:", container.scrollWidth); // scrollWidth: 220
console.log("scrollHeight", container.scrollHeight); // scrollHeight 372
- 如果 container 盒子不具备滚动的条件
- 返回的值和 clientWidth 和 clientHeight 属性值相同
<div id="container" class="container"></div>
var container = document.getElementById("container");
console.log("scrollWidth:", container.scrollWidth); // scrollWidth: 220
console.log("scrollHeight", container.scrollHeight); // scrollHeight 220
7.DOM 对象相关尺寸和位置属性 —— 可读可写属性
- 指的是不仅能通过 JavaScript 获取的值,还能够通过 JavaScript 为该属性赋值
8.可读可写属性:scrollTop 和 scrollLeft 属性
- 指的是当元素其中的内容超出其宽高的时候,元素被卷起的高度和宽度
<div id="container" class="container">
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Nulla repellat porro atque culpa rem sunt sed! Voluptates vel incidunt accusamus
reiciendis aut, adipisci ut. Hic, impedit officia.Quis, animi beatae. Facere dolorum quasi laborum, rem facilis illum necessitatibus sint doloribus
beatae exercitationem sapiente! Quod vel cupiditate quam libero, delectus natus.
</div>
.container {
width: 200px;
height: 200px;
background-color: red;
border: 1px solid;
overflow: auto;
padding: 10px;
margin: 20px;
}
var container = document.getElementById("container");
container.onscroll = function () {
console.log("scrollTop:", container.scrollTop);
console.log("scrollLeft", container.scrollLeft);
};
- 可以通过赋值来让内容自动滚动到某个位置
- 如:网站右下角回到顶部的按钮,背后对应的 JavaScript 代码就是通过该属性实现
9.可读可写属性:domObj.style.xxx 属性
- DOM 元素的 style 属性返回的是一个对象
- 这个对象中的任意一个属性都是可读写的
- 如:domObj.style.top、domObj.style.wdith 等
- 读取值时,返回的值常常是带有单位的(如:px)
- 只能够获取到该元素的行内样式,而并不能获取到该元素最终计算好的样式
- 如果想要获取计算好的样式,需要使用
obj.currentstyle
(IE)和getComputedStyle
(IE 之外的浏览器) - JavaScript 控制 DOM 元素运动的原理
- 通过不断修改这些属性的值而改变其位置
- ==给这些属性赋值的时候需要带单位的要带上单位,否则不生效
10.event 事件对象相关尺寸和位置属性
- 操作元素的运动时,通常会涉及到事件的 event 对象
- event 对象存在很多位置属性
- 由于浏览器兼容性问题会导致这些属性间相互混淆
11.clientX 和 clientY 属性
- 事件发生时鼠标点击位置相对于浏览器(可视区)的坐标
- 浏览器左上角的坐标(0, 0)
- 该属性以浏览器左上角坐标为原点,计算鼠标点击位置距离其左上角的位置
- 浏览器窗口大小的变化,不会影响点击位置的坐标
var container = document.getElementById("container");
container.onclick = function (ev) {
var evt = ev || event;
console.log(evt.clientX + ":" + evt.clientY);
};
12.screenX 和 screenY 属性
- 事件发生时鼠标相对于屏幕的坐标
- 以设备屏幕的左上角为原点,事件发生时鼠标点击的地方即为该点的 screenX 和 screenY 值
var container = document.getElementById("container");
container.onclick = function (ev) {
var evt = ev || event;
console.log(evt.screenX + ":" + evt.screenY);
};
13.offsetX 和 offsetY 属性
- 事件发生时鼠标点击位置相对于该事件源的位置
- 即:点击该 DOM 元素,以该 DOM 元素的左上角为原点来计算鼠标点击位置的坐标
var container = document.getElementById("container");
container.onclick = function (ev) {
var evt = ev || event;
console.log(evt.offsetX + ":" + evt.offsetY);
};
14.pageX 和 pageY 属性
- 事件发生时鼠标点击位置相对于页面的位置
- 通常浏览器窗口没有出现滚动条时,该属性和 clientX 及 clientY 是等价的
var container = document.getElementById("container");
container.onclick = function (ev) {
var evt = ev || event;
console.log(evt.pageX + ":" + evt.pageY);
console.log(evt.clientX + ":" + evt.clientY);
};
- 当浏览器出现滚动条时,pageX 通常会大于 clientX
- 因为页面还存在被卷起来的部分的宽度和高度
(二十七)更多知识(ES6+)
- 面试有较低的机率被问到
- 实际编码中用得不是太多
1.符号
- 消除魔法字符
- 避免一个复杂对象中含有多个属性时,某个属性名覆盖掉、模拟类的私有方法
2.迭代器和生成器
- 实现异步的一种方式
- React 中大量使用到了生成器,Koa 第一代也是大量用到了生成器
3.代理与反射
- 属于元编程的知识,写框架的时候会用到
4.增强的数组功能
- JavaScript 类型化数组是一种类似数组的对象
- 提供了一种用于访问原始二进制数据的机制
- JavaScript 引擎会做一些内部优化,以便快速操作数组