JS的内置类型
解答:
js的内置类型分为两大类:基本类型和引用类型。
基本类型共有七种:number、string、boolean、null、undefined,以及ES6新加的symbol类型和ES2020新加的bigInt类型。
引用类型统称为Object类型,细分的话有Object类型、Array类型、Date类型、RegExp 类型、Function 类型 等。
延伸:
两种数据类型的区别?
两种数据类型的主要区别就是他们存储的方式和形式不同。
- 原始数据类型占据空间较小、大小固定,属于被频繁使用的数据,直接存储于栈内存中。
- 引用数据类型占据空间大、大小不固定,如果存储在栈中将影响程序运行的性能。所以采用在栈内存中存储指针,在堆内存中存储实体对象的方式。使用时根据栈内存中存放的地址取得堆内存中的实体。
为什么基本数据类型存储在栈中,引用数据类型存储在堆中
JavaScript引擎需要用栈来维护程序执行期间的上下文的状态,如果栈空间大了的话,所有数据都存放在栈空间里面,会影响到上下文切换的效率,进而影响整个程序的执行效率。
关于Symbol
Symbol是ES6新增的一种数据类型,代表创建后独一无二且不可变的数据类型,Symbol最大的用途是用来定义对象的唯一属性名和解决全局变量冲突的问题。
关于BigInt
BigInt是一种数字类型的数据,它可以表示任意精度格式的整数,使用 BigInt 可以安全地存储和操作大整数,即使这个数已经超出了 Number 能够表示的安全整数范围。
null 和undefined的区别
首先null和undefined都是基本数据类型且都只有一个值,就是undefined和null。
-
从语义上区分,null更多的表示引用语义 而 undefined更多的表示值语义。
如果定义的变量在将来用于保存对象,那么最好将该变量初始化为null。如果声明了变量或者属性但是没有赋值,该变量或属性的值就等于undefined,函数没有返回值是也默认返回undefined。
-
null表示的是一个值被定义了,但undefined根本不存在定义。
设置一个值为null是合理的,但设置一个值为undefined就是不合理的。因此判断一个值存在应该用是否严格等于undefined。 不能使用null判断因为 null==undefined而null表示的是一个空值。
typeof 判断类型的原理
原理:
不同的对象在底层都表示为二进制,在Javascript中二进制前 (低) 三位存储其类型信息
关于typeof:
typeof 是一个操作符而不是函数,用来检测给定变量的数据类型
// typeof的输出值只有八种
typeof 1 // number
typeof '' // string
typeof true // boolean
typeof undefined // undefined
typeof symbol() // symbol
typeof 1n // bigInt
typeof Array // function
typeof {} // object
typeof null 为"object"
null是唯一一个用typeof检测会返回object的基本类型值
原因是因为不同的对象在底层都表示为二进制,在Javascript中二进制前三位都为0的话会被判断为Object类型,null的二进制表示全为0,所以执行typeof时会返回"object"。这可能是设计上的一种失误
000 对象
010 浮点数
100 字符串
110 布尔值
1 整数
null:所有码都是0
undefined:用 -2^30 表示
instanceof 判断数据类型的原理
原理:
原型链的查询,检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。
关于instanceof:
instanceof 用来比较一个对象是否为某一个构造函数的实例。注意,instanceof运算符只能用于对象,不适用原始类型的值。
手写代码:
const newInstanceof = (left, right) => {
// 左边实例对象 右边构造函数
let leftProto = left.__proto__;
const rightPrototype = right.prototype;
while(true) {
if (leftProto === rightPrototype) {
return true;
}
if (leftProto === null) {
return false;
}
leftProto = leftProto.__proto__;
}
}
Object instanceof Object // true
Function instanceof Function // true
Function instanceof Object // true
Object instanceof Function // true
function Foo() {};
Foo instanceof Foo // false
new Foo() instanceof Foo // true
Foo instanceof Object // true
Foo instanceof Function // true
数据类型转换
基本类型的转换
除了typeof null ==== Object之外,其他所有的基本类型经过typeof转换后都和其本身相同。
引用类型的转换
1. 引用类型中的函数
typeof除了能判断基本类型和object之外,还能判断function类型,函数也属于对象
typeof Function; // 'function'
typeof new Function(); // 'function'
typeof function() {}; // 'function'
2. 引用类型的子类型
typeof Array; // 'function'
typeof Array(); // 'object'
typeof new Array(); // 'object'
typeof []; // 'object'
Array是个构造函数,所以直接打印出function
但构造出来的Array()却又是另一回事了,构造出来的结果是个数组,自然属于引用类型,所以也就打印出了‘object’
构造函数 Array(..) 不要求必须带 new 关键字。不带时,它会被自动补上。 因此 Array(1,2,3) 和 new Array(1,2,3) 的效果是一样的
3、引用类型中的基本包装类型
typeof Boolean; // "function"
typeof Boolean(); // "boolean"
typeof new Boolean(); // "object"
Boolean是个构造函数,第一句没问题
Boolean()直接执行,得出了布尔值,所以得到了‘boolean’
而new出来的是个Boolean对象,具体来说就是:通过构造函数创建出来的是封装了基本类型值的封装对象。
Math window等
Math和window都是内置的对象,并不是引用类型的一种。不是函数,不是构造器。
typeof Math; // 'object'
typeof Math(); // Math is not a function
typeof new Math(); // Math is not a constructor
扩展
undefined 与 undeclared 的区别
已经在作用域内声明但是还没有赋值的变量,是undefined。 还没有在作用域中声明过的变量是undeclared。
在浏览器中引用undeclared,会报错: ReferenceError: b is not defined
并且typeof 对undefined 和 undeclared 变量返回的都是undefined
typeof的安全防范机制
typeof的容错机制可以用来检查undeclared(未声明)变量,而不是直接报错
我们经常会先判断一个变量存不存在,然后再来使用这个变量:
if(debug) {
console.log('Debugging is starting!');
}
冒昧的去声明一个变量并不是一个很好的解决方法。这个时候就可以利用typeof的防范机制:如果一个变量a未声明,那么typeof a不会报语法错误,而是会返回undefined。
对JavaScript 值 的理解
JS的执行上下文生成之后,会创建一个叫做变量对象的特殊对象,JS的基础类型都保存在变量对象中。
严格意义上来说,变量对象也是存放于堆内存中,但是由于变量对象的特殊职能,我们在理解时仍然需要将其于堆内存区分开来。
但引用数据类型的值是保存在堆内存中的对象。JavaScript不允许直接访问堆内存中的位置,因此我们不能直接操作对象的堆内存空间。
在操作对象时,实际上是在操作对象的引用而不是实际的对象。因此,引用类型的值都是按引用访问的。
引用可以理解为保存在变量对象中的一个地址,该地址与堆内存的实际值相关联。
强制类型转换
类型转换发生在静态类型语言的编译阶段,而强制类型转换则发生在动态类型语言的运行时(runtime)。
然而在 JavaScript 中通常将它们统称为强制类型转换,我个人则倾向于用“隐式强制类型转换”(implicit coercion)和“显式强制类型转换”(explicit coercion)来区分。
理解显式和隐式强制类型转换之前,需要先掌握字符串、数字和布尔值之间类型转换的基本规则
字符串、数字和布尔值之间类型转换的基本规则
ToString
toString() 可以被显式调用,或者在需要字符串化时自动调用
null 转换为 "null",undefined 转换为 "undefined",true 转换为 "true"。
数字的字符串化则遵循通用规则
极小和极大的 数字使用指数形式:
// 1.07 连续乘以七个 1000
var a = 1.07 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000;
// 七个1000一共21位数字
a.toString(); // "1.07e21"
数组的默认 toString() 方法经过了重新定义,将所有单元字符串化以后再用 "," 连接起来
var a = [1,2,3];
a.toString(); // "1,2,3"
ToNumber
其中 true 转换为 1,false 转换为 0。undefined 转换为 NaN,null 转换为 0。
处理失败 时返回 NaN(处理数字常量失败时会产生语法错误)
ToBoolean
假值:假值的布尔强制类型转换结果为 false。
• undefined
• null
• false
• +0、-0 和 NaN
• ""
并不是所有的对象都是真值,存在假值对象
var a = new Boolean(false);
var b = new Number(0);
var c = new String('');
var d1 = Boolean( a && b && c ); // true
var d2 = a && b && c; // ''
显式强制类型转换
显示强制类型转换是指代码有明确意图的转换,是显而易见的。
显示转换主要指使用Number() 、String() 和Boolean() 三个函数.
手动将各种类型的值,分别转换成数字、字符串或者布尔值。注意,它们前面并没有new,并不会封装为对象。
除了这三个函数之外,还有以下方法是显示转换
// 字符串转换
var a = 42;
var b = String(a);
// 数字转换
var c = '3.14';
var d = Number(c);
// 布尔值转换
var e = [];
var f = Boolean(e)
- toString() toString()方法能显式地将非字符串类型转换为字符串。一个原始类型能调用toString()方法,是因为在这个过程中,原始类型会自动装箱(boxing) ,被封装成一个对象,从而有调用其包装对象函数的能力,但是原来那个变量的值不会有任何变化。
(23).toString() // "23"
(function f(){}).toString() // "function f(){}"
- 一元运算符 +和 - 0 能显式地将非数字转换为数字。
+'3.14' // 3.14
+-0 // -0
+[] // 0
{} - 0 // NaN
- parseInt()和parseFloat()
这两个方法是解析字符串中的数字,和将字符串强制类型转换为数字还是有明显区别的。
解析允许字符串中含有非数字字符,解析按从左到右的顺序,如果遇到非数字字符就停止;而转换不允许出现非数字字符,否则会失败并返回NaN。
var a = '42px'
Number(a) // NaN
parseInt(a) // 42
非字符串参数会被隐式转换为字符串,会有各种bug,强烈不建议这么做。传参前请进行类型检查。另外,parseInt一般会指定转换的基数,即使几进制。
- !! 这是显式强制转换为布尔值最常用的方法
!!'' // false
!!{} // true
var a
!!a // false
隐式强制类型转换
字符串和数字之间的隐式转换
如果 + 的其中一个操作数是字符串(或者通过以上步骤可以得到字符串), 则执行字符串拼接, 否则执行数字加法。
除了加法运算符(+)有可能把运算子转为字符串,其他运算符都会把运算子自动转成数值。
'5' - '2' // 3
'5' * '2' // 10
false - 1 // -1
'1' - 1 // 0
'5' * [] // 0
false / '5' // 0
'abc' - 1 // NaN
null + 1 // 1
undefined + 1 // NaN
隐式强制类型转换为布尔值
- (1)if (..)语句中的条件判断表达式。
- (2)for ( .. ; .. ; .. )语句中的条件判断表达式(第二个)。
- (3) while (..) 和 do..while(..) 循环中的条件判断表达式。
- (4)? :中的条件判断表达式。
- (5) 逻辑运算符 ||(逻辑或)和 &&(逻辑与)左边的操作数(作为条件判断表达式)。
扩展
关于 == 和 ===
== 允许在相等比较中进行强制类型转换,而 === 不允许
[]+{} 和 {} + []
表面上看 + 运算符根据第一个操作数([] 或 {})的不同会产生不同的结果,实则不然。
第一行代码中,{} 出现在 + 运算符表达式中,因此它被当作一个值(空对象)来处理。
[] 会被强制类型转换为 "",而 {} 会被强制类型转换为 "[object Object]"。
但在第二行代码中,{} 被当作一个独立的空代码块(不执行任何操作)。
代码块结尾不需要分号,所以这里不存在语法上的问题。
最后 + [] 将 [] 显式强制类型转换(参见第 4 章) 为 0。
一元运算符的力量
+和 - 0 能显式地将非数字转换为数字。
进阶
对原型的理解
原型是JavaScript继承的基础,JavaScript的继承就是基于原型的继承。
什么是原型?
我们创建的每一个函数都有一个prototype属性,它是一个指针,指向一个对象,而这个对象的用途是可以包含由特定类型创建的所有实例共享属性和方法。 那么prototype就是调用构造函数创建的对象实例的原型对象
使用原型对象的好处就是可以让所有的对象实例共享它所包含的属性和方法
构造函数、实例、原型对象的关系:
每个构造函数都有一个原型对象
每个原型对象都有一个指向构造函数的指针
每个实例都由一个指向原型对象的指针
proto 通常称为隐式原型,prototype 通常称为显式原型,那我们可以说一个对象的隐式原型指向了该对象的构造函数的显式原型。在显式原型上定义的属性方法,通过隐式原型传递给了构造函数的实例。这样一来实例就能很容易的访问到构造函数原型上的方法和属性了。
function Foo() {};
const a = new Foo();
a.__proto__ === Foo.prototype; // true
原型链:
prototype机制就是存在于对象中的一个内部链接,它会引用其他对象。 如果在当前对象没有找到需要的属性或者方法引用,引擎就会沿着prototype关联的对象继续查找,以此类推。这一系列对象的链接被称为原型链。
Object.prototype是原型链的最顶端,它的__proto__仍然存在,值为null
延伸:
构造函数
构造函数上可以附带 实例成员 和 静态成员
实例成员: 实例成员就是在构造函数内部,通过this添加的成员。实例成员只能通过实例化的对象来访问。 静态成员: 在构造函数本身上添加的成员,只能通过构造函数来访问。
function Star(name,age) {
//实例成员
this.name = name;
this.age = age;
}
//静态成员
Star.sex = '女';
let stars = new Star('小红',18);
console.log(stars); // Star {name: "小红", age: 18}
console.log(stars.sex); // undefined 实例无法访问sex属性
console.log(Star.name); //Star 通过构造函数无法直接访问实例成员
console.log(Star.sex); //女 通过构造函数可直接访问静态成员
new一个新对象的过程发生了什么?
- 创建一个空对象son {}
- 为该对象准备原型链连接 son.proto = Father.prototype
- 重新绑定this,使构造函数的this指向新对象
- 为新对象属性赋值
- 返回this,此时新对象就拥有了构造函数的属性和方法
通过构造函数创建的每个实例的方法是共享的吗?
不一定。分两种情况讨论:
- 在构造函数上直接定义的方法不共享。
function Star() {
this.sing = function () {
console.log('我爱唱歌');
}
this.name = 'leemo';
}
let stu1 = new Star();
let stu2 = new Star();
stu1.sing();//我爱唱歌
stu2.sing();//我爱唱歌
console.log(stu1.sing === stu2.sing); // false
console.log(stu1.name === stu2.name); // true
stu1和stu2为两个不同的实例,指向的不是同一块内存。每次生成实例都会新开辟一块内存存储属性和方法。
如果实例的属性是基本类型,不存在共享问题,是否相同要看具体值的内容是否相同。
- 通过原型添加的方法是共享的。
function Star(name) {
this.name = name;
}
Star.prototype.sing = function () {
console.log('我爱唱歌', this.name);
};
let stu1 = new Star('小红');
let stu2 = new Star('小蓝');
stu1.sing();//我爱唱歌 小红
stu2.sing();//我爱唱歌 小蓝
console.log(stu1.sing === stu2.sing); //true
构造函数通过原型分配的函数,是所有对象共享的。
综上所述得出定义构造函数的规则: 公共属性定义到构造函数里面,公共方法放到原型对象上。
todo 原型系列未完待续...
segmentfault.com/a/119000002… juejin.cn/post/684490…
Event Loop
event loo指的是计算机系统的一种运行机制,JavaScript采用这种机制来解决单线程运行带来的一些问题。
JavaScript为什么设计成单线程?
JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
拓展: 为了利用多核cpu的计算能力,HTML5提出了Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程的控制且不得操作DOM,所以并没有改变JavaScript单线程的本质。
任务队列
为了防止单一任务执行时间过长导致其他任务被阻塞,JavaScript的设计者将所有的任务分为两种,同步任务和异步任务
同步任务: 在主线程上排队执行的任务,只有前一个任务执行完毕,才执行下一个任务。
异步任务: 不进入主线程,进入任务队列,只有任务队列通知主线程某个异步任务执行完成了,该任务才会进入主线程执行。
总体来看,JavaScript所有任务执行机制如下: 所有的同步任务都在主线程执行,形成一个执行栈。 主线程之外还有一个任务队列,只要异步任务执行完毕,就会在任务队列中放置一个事件。 执行栈中的所有的同步任务执行完毕后,就会读取任务队列,将对应的任务放入执行栈开始执行,循环往复。
Event loop
主线程从任务队列中读取任务,执行任务,整个过程是循环往复的,整个运行机制又被称为Event Loop。
// 两分钟视频理解event loop www.bilibili.com/video/BV1kf…
宏任务和微任务
浏览器的事件循环中分为宏任务和微任务。
宏任务 macro task:
可以理解为每次执行栈执行的任务就是一个宏任务(包括每次从任务队列中取回事件并放到执行栈中执行)。
JS 中主栈执行的大多数的任务,例如:定时器,事件绑定,ajax,回调函数等。
主代码块也算宏任务
微任务 micro task:
microtask,可以理解是在当前 task 执行结束后立即执行的任务。 也就是说,在当前task任务后,下一个task之前,在渲染之前。
所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染。也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)。
微任务包含:
Promise.then
Object.observe
MutationObserver
时间循环中的执行步骤
- 执行一个宏任务(栈中没有就从事件队列中获取)
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
- 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
拓展:
进程和线程
进程可以包含多个线程。
比如打开一个页面,这个页面占用了计算机的一个进程,页面加载时,浏览器会分配多个线程去计算DOM树、执行JS脚本、加载资源文件等。
宏任务和页面渲染的顺序关系
浏览器为了能够使得JS内部(macro)task与DOM任务能够有序的执行,会在一个(macro)task执行结束后,在下一个(macro)task 执行开始前,对页面进行重新渲染。流程如下:
(macro)task->渲染->(macro)task->...
为什么要引入微任务,只有宏任务可以吗?
微任务的引入是为了解决异步回调的问题,假设只有宏任务,那么每一个宏任务执行完后回调函数也放入宏任务队列,这样会造成队列多长,回调的时间变长,这样会造成页面的卡顿,所以引入了微任务。
await后面的代码会进入到promise队列中的微任务
async/await 只是操作 promise 的语法糖,最后的本质还是promise。
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
// 上面的代码等价于 ==>
async function async1() {
console.log('async1 start');
Promise.resolve(async2()).then(() => {
console.log('async1 end')
})
}
例题
例题1
console.log("start");
setTimeout(() => {
console.log("children2")
Promise.resolve().then(() =>{
console.log("children3")
})
}, 0)
new Promise(function(resolve, reject){
console.log("children4")
setTimeout(function(){
console.log("children5")
resolve("children6")
}, 0)
}).then(res =>{ // flag
console.log("children7")
setTimeout(() =>{
console.log(res)
}, 0)
})
// start children4 children2 children3 children5 children7 children6
上面的.then() (注释的flag处) 不是第一轮宏任务循环的微任务。因为resolve都没有执行,promise的状态都还没有从pending改变,就不是第一轮的微任务。
例题2
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(function () {
console.log('setTimeout')
}, 0)
async1()
new Promise((resolve) => {
console.log('promise1')
resolve()
}).then(function () {
console.log('promise2')
})
console.log('script end')
//输出
//script start
//async1 start
//async2
//promise1
//script end
//async1 end
//promise2
//setTimeout
例题3
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
new Promise(function (resolve) {
console.log('promise1');
resolve();
}).then(function () {
console.log('promise2');
});
}
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0)
async1();
new Promise(function (resolve) {
console.log('promise3');
resolve();
}).then(function () {
console.log('promise4');
});
console.log('script end');
//script start,
// async1 start,
// promise1,
// promise3,
// script end,
// promise2,
// async1 end,
// promise4,
// setTimeout
例题4
async function async1() {
console.log('async1 start');
await async2();
setTimeout(function() {
console.log('setTimeout1')
},0)
}
async function async2() {
setTimeout(function() {
console.log('setTimeout2')
},0)
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout3');
}, 0)
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
// script start, async1 start, promise1, script end, promise2, setTimeout3, setTimeout2, setTimeout1
执行上下文
执行上下文就是当前代码的执行环境
执行上下文的三种类型:
- 全局执行上下文: 全局执行环境是最外围的一个执行环境,在浏览器的全局对象是 window, this指向这个对象。
- 函数执行上下文 可以有无数个,函数被调用的时候会被创建。每次调用函数都会创建一个新的执行上下文。
- eval执行上下文,很少使用
执行上下文的三个重要属性:
-
变量对象: 每个执行环境都有一个与之关联的变量对象,环境中定义的所有变量和函数都保存在这个对象中。我们编写的代码无法方法访问,解析器在处理数据时在后台使用。 在函数上下文中,用活动对象表示变量对象,只有当进入一个执行环境时,这个执行上下文的变量对象才会被激活,此时成为活动对象,只有活动对象上的属性才能被访问。
-
作用域链 当代码在一个环境中执行时,会创建变量对象的一个作用域链。
作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。
-
this
执行上下文的生命周期:
创建 -> 执行 -> 回收
- 创建阶段: a. 创建变量对象: 初始化函数参数 -> 函数声明 -> 变量声明 b. 创建作用域链: 函数的作用域在函数定义的时候就确定了。作用域链本身包含变量对象,当查找变量时,会先从当前上下文中的变量对象中查找,如果没有找到,就会从父级执行上下文的变量对象中查找,一直找到全局执行上下文的变量对象 c. 确定this的指向
- 执行阶段: 执行变量赋值,代码执行。
- 回收阶段: 执行上下文出栈被垃圾回收机制进行回收。
执行上下文栈
执行上下文栈是用来管理执行上下文的。在执行上下文创建好后,JavaScript引擎会将执行上下文压入到栈中,通常把这种用来管理执行上下文的栈称为执行上下文栈,又称调用栈。
进阶
作用域
什么是作用域
指变量的可访问性和可见性。也就是说整个程序中哪些部分可以访问这个变量。
作用域决定这个变量的生命周期及其可见性。 当我们创建了一个函数或者 {} 块,就会生成一个新的作用域。需要注意的是,通过 var 创建的变量只有函数作用域,而通过 let 和 const 创建的变量既有函数作用域,也有块作用域。
作用域的价值
- 安全,变量只能在特定的区域被访问,可以避免程序在其他位置意外修改变量。
- 减轻变量命名压力。
作用域的类型
- 全局作用域 全局作用域下声明的变量可以在程序的任意位置访问。任何不在函数中或大括号中声明的变量都在全局作用域下。
- 函数作用域 函数作用域也叫局部作用域,如果一个变量是在函数内部声明的它就在一个函数作用域下面。这些变量只能在函数内部访问,不能在函数以外去访问。
- 块级作用域
ES6引入了
let和const关键字,和var关键字不同,在大括号中使用let和const声明的变量存在于块级作用域中。在大括号之外不能访问这些变量。
作用域链
使用一个变量时,首先js引擎会在当前作用域寻找该变量,如果没找到就会到它的上层作用域查找,直到找到该变量或已到全局作用域。 如果在全局作用域仍找不到,会在全局范围内隐式声明该变量(非严格模式)或直接报错。
扩展:
词法作用域
词法作用域也叫静态作用域,从字面意义上看是说作用域在词法化阶段(通常是编译阶段)确定而非执行阶段确定的,即在函数定义的时候就决定了其外部作用域。
使用词法作用域,我们可以仅仅看源代码就可以确定一个变量的作用范围,但如果是动态作用域,代码执行之前我们没法确定变量的作用范围。
作用域与执行上下文
许多开发人员经常混淆作用域和执行上下文的概念,误认为它们是相同的概念,但事实并非如此。
我们知道 JavaScript 属于解释型语言,JavaScript 的执行分为:解释和执行两个阶段,这两个阶段所做的事并不一样:
解释阶段:
- 词法分析
- 语法分析
- 作用域规则确定
执行阶段:
- 创建执行上下文
- 执行函数代码
- 垃圾回收
JavaScript 解释阶段便会确定作用域规则,因此作用域在函数定义时就已经确定了,而不是在函数调用时确定,但是执行上下文是函数执行之前创建的。执行上下文最明显的就是 this 的指向是执行时确定的。而作用域访问的变量是编写代码的结构确定的。
作用域和执行上下文之间最大的区别是:
执行上下文在运行时确定,随时可能改变;作用域在定义时就确定,并且不会改变。
一个作用域下可能包含若干个上下文环境。有可能从来没有过上下文环境(函数从来就没有被调用过);有可能有过,现在函数被调用完毕后,上下文环境被销毁了;有可能同时存在一个或多个(闭包)。同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值。
for循环的作用域
for循环设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。
for (let i = 0; i < 3; i++) {
let i = 'abc';
console.log(i);
}
// abc
// abc
// abc
闭包
概念:
闭包是一个可以访问外部作用域的内部函数,即使这个外部作用域已经执行结束。
- 闭包允许函数访问并操作函数外部的变量。
- 闭包是指有权访问另外一个函数作用域中的变量的函数。
- 闭包是指那些能够访问自由变量的函数。这里的自由变量是外部函数作用域中的变量。
- 函数和函数内部能访问到的变量的总和,就是一个闭包
- 函数和对其词法环境的引用捆绑在一起,这样的组合就是闭包
总结:
闭包是指有权访问另一个函数作用域中变量的函数
内部的函数存在外部作用域的引用就会导致闭包
本质:
·内部函数总是可以访问其所在的外部函数中声明的变量和参数,即使在其外部函数被返回了之后。
·上级作用域内变量的生命周期,因为被下级作用域内引用而没有被释放。从而导致上级作用域内的变量需要等到下级作用域执行完之后才能得到释放。
作用:
- 保护函数的私有变量不受外部的干扰,形成不销毁的栈内存。
- 保存函数内的值,实现方法和属性的私有化。
例子
var n = 10
function fn(){
var n =20
function f() {
n++;
console.log(n)
}
f()
return f
}
var x = fn()
x()
x()
console.log(n)
/* 输出
* 21
22
23
10
/
fn 的返回值是什么变量 x 就是什么,这里 fn 的返回值是函数名 f 也就是 f 的堆内存地址,x() 也就是执行的是函数 f(),而不是 fn(),输出的结果显而易见
经典使用场景:
- 模块封装防止变量污染全局
var Yideng = (function () {
// 这样声明为模块私有变量,外界无法直接访问
var foo = 0;
function Yideng() {}
Yideng.prototype.bar = function bar() {
return foo;
};
return Yideng;
}());
- 函数作为参数
var a = '林一一'
function foo(){
var a = 'foo'
function fo(){
console.log(a)
}
return fo
}
function f(p){
var a = 'f'
p()
}
f(foo())
/* 输出
* foo
/
使用 return fo 返回回来,fo() 就是闭包,f(foo()) 执行的参数就是函数 fo,因为 fo() 中的 a 的上级作用域就是函数foo(),所以输出就是foo
- IIFE 自执行函数
var n = '林一一';
(function p(){
console.log(n)
})()
/* 输出
* 林一一
/
同样也是产生了闭包p(),存在 window下的引用 n。
- 循环赋值。 在循环中创建闭包,防止取到意外的值
for(var i = 0; i<10; i++){
(function(j){
setTimeout(function(){
console.log(j)
}, 1000)
})(i)
}
// 0 1 2 3 4 5 6 7 8 9
for (var i = 0; i < 10; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
// 10 10 ^10
- 使用回调函数就是在使用闭包
window.name = '林一一'
setTimeout(function timeHandler(){
console.log(window.name);
}, 100)
- 手写节流防抖
// 节流
function throttle(fn, timeout) {
let timer = null
return function (...arg) {
if(timer) return
timer = setTimeout(() => {
fn.apply(this, arg)
timer = null
}, timeout)
}
}
// 防抖
function debounce(fn, timeout){
let timer = null
return function(...arg){
clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, arg)
}, timeout)
}
}
- 手写函数柯里化 略
经典思考题
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}
data[0]();
data[1]();
data[2]()
/* 输出
3
3
3
/
这里的 i 是全局下的 i,共用一个作用域,当函数被执行的时候这时的 i=3,导致输出的结构都是3。
Q:如何改善此场景?
- 自执行函数形成闭包
var data = [];
for (var i = 0; i < 3; i++) {
(function(j){
setTimeout( data[j] = function () {
console.log(j);
}, 0)
})(i)
}
data[0]();
data[1]();
data[2]()
// 0 1 2
- 使用let
var data = [];
for (let i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}
data[0]();
data[1]();
data[2]()
// 0 1 2
let 具有块级作用域,形成的3个私有作用域都是互不干扰的。
进阶:
var result = [];
var a = 3;
var total = 0;
function foo(a) {
for (var i = 0; i < 3; i++) {
result[i] = function () {
total += i * a;
console.log(total);
}
}
}
foo(1);
result[0]();
result[1]();
result[2]();
// 输出
//
//
//3 6 9
扩展
闭包变量的存储位置
闭包只存储外部变量的引用,而不会拷贝这些外部变量的值。
闭包中变量的存储位置是堆内存。
假如闭包中的变量存储在栈内存中,那么栈的回收 会把处于栈顶的变量自动回收。所以闭包中的变量如果处于栈中那么变量被销毁后,闭包中的变量就没有了。所以闭包引用的变量是出于堆内存中的。
使用闭包需要注意什么
内存泄漏 闭包会携带包含其它的函数作用域,因此会比其他函数占用更多的内存。过度使用闭包会导致内存占用过多,所以要谨慎使用闭包。
栈内存释放
一般当函数执行完后函数的私有作用域就会被释放掉。
但栈内存的释放也有特殊情况
- 函数执行完,但是函数的私有作用域内有内容被栈外的变量还在使用的,栈内存就不能释放里面的基本值也就不会被释放。
- 全局下的栈内存只有页面被关闭的时候才会被释放。
补充
变量提升与函数提升
变量提升
-
只有声明被提升,变量初始化不会被提升。
-
声明会被提升到当前作用域的最顶端。
function hoistVariable() {
if (!foo) {
var foo = 5;
}
console.log(foo);
}
hoistVariable();
预编译结果↓
function hoistVariable() {
var foo // 将if语句内的声明提升
if (!foo) { // !undefined = true
foo = 5;
}
console.log(foo); // 5
}
hoistVariable();
函数提升
- 函数声明和初始化都会被提升
- 函数表达式不会被提升
优先级
函数提升在变量提升之前
- 变量的声明和赋值这两个步骤是分开的。
- 函数声明被提升时,声明和赋值两个步骤都会被提升;普通变量却只能提升声明步骤,而不能提升赋值步骤。
- 变量被提升过后,先对提升上来的所有对象统一执行一遍声明步骤,然后再对变量执行一次赋值步骤。而执行赋值步骤时,会优先执行函数变量的赋值步骤,再执行普通变量的赋值步骤。
例题
例子1
function b(){};
var b = 11;
typeof b;
预编译后↓
function b; // => 声明一个function b
var b; // =》 声明一个变量 b
b = (){}; // =》 function b 初始化
b = 11; // =》 变量 b 初始化 =》变量初始化没有被提升,还在原位
typeof b; // number
例子2
var foo = 'hello';
(function(foo){
console.log(foo);
var foo = foo || 'world';
console.log(foo);
})(foo);
console.log(foo);
// 依次输出 hello hello hello
预编译后↓
var foo = 'hello';
(function (foo) {
var foo; // undefined;
foo= 'hello'; //传入的foo的值
console.log(foo); // hello
foo = foo || 'world';// 因为foo有值所以没有赋值world
console.log(foo); //hello
})(foo);
console.log(foo);// hello,打印的是var foo = 'hello' 的值(变量作用域)
例子3
console.log(a, b)
var a =12, b ='666'
function foo(){
console.log(a, b)
var a = b =13
console.log(a, b)
}
foo()
console.log(a, b)
// 输出:
//
/*
undefined undefined
undefined "666"
13 13
12 13
*/
例子4
fn();
console.log(v1);
console.log(v2);
console.log(v3);
function fn() {
var v1 = v2 = v3 = 2019;
console.log(v1);
console.log(v2);
console.log(v3);
}
/*
输出
2019
2019
2019
Uncaught ReferenceError: v1 is not defined
/
例子5
console.log('1',fn())
function fn(){
console.log(1)
}
console.log('2',fn())
function fn(){
console.log(2)
}
console.log('3',fn())
var fn = '林一一'
console.log('4',fn())
function fn(){
console.log(3)
}
/* 输出
* 3
* 1 undefined
* 3
* 2 undefined
* 3
* 3 undefined
* Uncaught TypeError: fn is not a function
/
例子6
let a = 0, b = 0;
function fn(a) {
fn = function fn2(b) {
console.log(a, b)
console.log(++a+b)
}
console.log('a', a++)
}
fn(1); // a, 1
fn(2); // 2, 2 5
例子7
var a = 10;
(function () {
console.log(a)
a = 5
console.log(window.a)
var a = 20;
console.log(a)
})()
var b = {
a,
c: b
}
console.log(b.c);
//
//
// undefined 10 20 undefined
例子8
var a = 1;
function foo(a, b) {
console.log(a);
a = 2;
arguments[0] = 3;
var a;
console.log(a, this.a, b);
}
foo(a);
//
//
// 1
// 3, 1, undefined
扩展
带var和不带var的区别
全局作用域中不带var声明变量相当于给window对象设置一个属性。
私有作用域(函数作用域),带 var 的是私有变量。不带 var 会沿作用域链查找。
函数的形参也会进行一次变量提升
var foo = '666';
(function(f){
console.log(foo);
var foo = f || 'hello';
console.log(foo)
})(foo);
console.log(foo)
//
//
// undefined, 666,666
if语句中的变量提升
- 在当前作用域中不管条件成立都会进行变量提升
- if 中
()内的表达式不会变量提升 - JS 执行到条件语句,判断条件是成立的才会对条件内的函数
赋值,不成立不被赋值只被定义成undefined1没看懂
var y = 1
if(function f(){}){
console.log(typeof f) // undefined
y = y + typeof f
}
console.log(y) // 1undefined
2
console.log(print()) // == window.print()
if(true){
function print() {
console.log('666')
}
}
console.log(print())
/* 输出
undefined
666
undefined
*/
匿名自执行函数在自己的作用域内存在正常的变量提升
var a = 10;
(function(){
console.log(a)
a = 20
console.log(a)
})()
console.log(a)
// 10, 20, 20
非匿名自执行函数的函数名在自己的作用域内变量提升,且修改函数名的值无效
var a = 10;
(function a(){
console.log(a)
a = 20
console.log(a)
})()
// ƒ a(){a = 20 console.log(a)} ƒ a(){a = 20 console.log(a)}
进阶
juejin.cn/post/693337… juejin.cn/post/685041…
var let const 区别
var let 区别
1. 作用域
var 声明的变量的作用域只能是全局或者整个函数块的。
let 声明的变量的作用域则是它当前所处代码块,即它的作用域既可以是全局或者整个函数块,也可以是 if、while、switch等用{}限定的代码块。
var 和 let 的作用域规则都是一样的,其声明的变量只在其声明的块或子块中可用
举例
function varTest() {
var a = 1;
{
var a = 2; // 函数块中,同一个变量
console.log(a); // 2
}
console.log(a); // 2
}
function letTest() {
let a = 1;
{
let a = 2; // 代码块中,新的变量
console.log(a); // 2
}
console.log(a); // 1
}
varTest();
letTest();
2. 重复声明
var 允许在同一作用域中重复声明。
let 不允许在同一作用域中重复声明,否则将抛出异常:
SyntaxError: Identifier 'a' has already been declared
例子同上
3. 绑定全局对象
var在全局声明会在全局对象内新建属性,而let不会。
var foo = 'global'
let bar = 'global'
console.log(this.foo) // global
console.log(this.bar) // undefined
4. 变量提升
var 会正常进行变量提升
let 也会进行变量提升,但是由于暂时性死区的存在,在声明之前使用变量,就会报错。
let const 区别
const 声明的是一个只读变量,声明之后不允许改变其值。因此,const 一旦声明必须初始化,否则会报错。
其实 const 其实保证的不是变量的值不变,而是保证变量指向的内存地址所保存的数据不允许改动(即栈内存在的值和地址)。
扩展
暂时性死区 TDZ
当程序的控制流程在新的作用域进行实例化时,在此作用域中用let/const声明的变量会先在作用域中被创建出来,但因此时还未进行词法绑定,所以是不能被访问的,如果访问就会抛出错误。因此,在这运行流程进入作用域创建变量,到变量可以被访问之间的这一段时间,就称之为暂时死区。
进阶
this 问题
this与执行上下文绑定,每个执行上下文都有一个this。
如何确定this指向
this分三类:
- 全局执行上下文中的this
- 函数中的this
- eval中的this
函数的this是在调用时绑定的,this的取值完全取决于函数的调用位置
this指向确认规则:
1、如果一个函数中有this,但是它没有被上一级的对象所调用,那么this指向的就是window(严格模式下为undefined)
2、如果一个函数中有this,这个函数有被上一级的对象所调用,那么this指向的就是上一级的对象。
3、如果一个函数中有this,这个函数中包含多个对象,尽管这个函数是被最外层的对象所调用,this指向的也只是它上一级的对象,
var o = {
a:10,
b:{
a:12,
fn:function(){
console.log(this.a); //12
}
}
}
o.b.fn();
4、 this永远指向的是最后调用它的对象
var o = {
a:10,
b:{
a:12,
fn:function(){
console.log(this.a); //undefined
console.log(this); //window
}
}
}
var j = o.b.fn;
j();
构造函数中的this
new关键字可以改变this的指向 (详见new一个新对象的过程发生了什么?)
function Fn(){
this.user = "追梦子";
}
var a = new Fn();
console.log(a.user); //追梦子
this碰到return时的指向
如果返回值是一个对象,那么this指向的就是那个返回的对象;(该规则对null无效)
如果返回值不是一个对象,那么this还是指向函数的实例。
function fn(params) {
this.user = 'leemo'
return {}
}
var a = new fn
console.log(a.user) // undefined
function fn(params) {
this.user = 'leemo'
return { user: 'ltc'}
}
var a = new fn
console.log(a.user) // ltc
function fn2(params) {
this.user = 'leemo'
return function(){}
}
var b = new fn2
console.log(b.user) // undefined
function fn3(){
this.user = 'leemo'
return undefined
}
var c = new fn3
console.log(c.user) // leemo
延时函数的this
超时调用(setTimeout回调)的代码都是在全局作用域环境中执行的,因此(setTimeout回调)函数中this的值在非严格模式下指向window对象,在严格模式下是undefined
function foo() {
console.log("id1:", this.id);
console.log("this1:", this);
setTimeout(function() {
console.log("id2:", this.id);
console.log("this2:", this);
}, 0);
}
var id = 21;
foo();
// Chrome
// id1: 21
// this1: window
// id2: 21
// this2: window
foo.call({id: 42});
// Chrome
// id1: 42
// this1: {id: 42}
// id2: 21
// this2: window
箭头函数的this
箭头函数最大的特色就是没有自己的this、arguments、super、new.target,且箭头函数没有原型对象prototype,不能用作构造函数(new一个箭头函数会报错)。
因为没有自己的this,所以箭头函数中的this其实指的是包含函数中的this。
无论是点调用,还是call调用,都无法改变箭头函数中的this。
function foo() {
console.log("id1:", this.id);
console.log("this1:", this);
setTimeout(() => {
console.log("id2:", this.id);
console.log("this2:", this);
}, 0);
}
var id = 21;
foo();
// Chrome
// id1: 21
// this1: window
// id2: 21
// this2: window
foo.call({id: 42});
// Chrome
// id1: 42
// this1: {id: 42}
// id2: 42
// this2: {id: 42}
因为箭头函数(setTimeout回调)没有自己的this,导致其内部的this引用了外层代码块的this,即foo函数的this,
注意:在定义阶段(调用函数前),foo函数的this的值并不确定,但箭头函数的this自定义阶段开始就指向foo函数的this了
又因为使用call方法改变了foo函数运行(调用)时其函数体内this的指向(重新指向对象{id: 42})从而使箭头函数中this的指向发生变化,最后输出了例子所示结果。
改变this的指向
- 使用
new - 使用
bind
function fn(){
console.log(this.name);
};
var obj={
name:'jack',
};
var b=fn.bind(obj);
b();
- 使用
call()或apply()方法
他们也可以用来调用函数,这两个方法都接受一个对象作为参数,用来指定本次调用时函数中this的指向。
call方法使用的语法规则
函数名称.call(obj,arg1,arg2...argN);
参数说明:
obj:函数内this要指向的对象,
arg1,arg2...argN :参数列表,参数与参数之间使用一个逗号隔开
function fn(name){
this.name=name;
this.fn1=function(){
console.log(this.name);
}
};
var obj={};
fn.call(obj,'jack');
console.log(obj.name);
obj.fn1();
apply方法使用的语法规则
函数名称.apply(obj,[arg1,arg2...,argN])
参数说明:
obj :this要指向的对象
[arg1,arg2...argN] : 参数列表,要求格式为数组
call和apply的作用一致,区别仅仅在函数实参参数传递的方式上
这个两个方法的最大作用基本就是用来强制指定函数调用时this的指向
function fn(name,age){
this.name=name;
this.age=age;
this.fn1=function(){
console.log(this.name);
}
};
var obj={};
fn.apply(obj,['jack',18]);
console.log(obj.age);
obj.fn1();
call、apply、bind 区别用法手写
call,apply,bind的作用都是用来改变this指向的。
call、apply和bind是挂在Function对象上的三个方法,只有函数才有这些方法。
语法:
fun.call(thisArg, param1, param2, ...)
fun.apply(thisArg, [param1,param2,...])
fun.bind(thisArg, param1, param2, ...)
返回值:
call/apply:fun执行的结果
bind:返回fun的拷贝,并拥有指定的this值和初始参数
call和apply的区别:
区别只在传参方式上,传给fun的参数写法不同:
apply是第2个参数,这个参数是一个数组或类数组:传给fun参数都写在数组中。call从第2~n的参数都是传给fun的。
apply是以a开头,它传给fun的参数是Array,也是以a开头的。
call接受任意多个参数,以逗号分隔
call/apply与bind的区别
执行:
- call/apply改变了函数的this上下文后马上执行该函数
- bind则是返回改变了上下文后的函数,不执行该函数
返回值:
- call/apply 返回
fun的执行结果 - bind返回fun的拷贝,并指定了fun的this指向,保存了fun的参数。
call/apply/bind的实际价值:借用方法
如何理解?
A对象有个方法,B对象因为某种原因也需要用到同样的方法,那么这时候我们是单独为 B 对象扩展一个方法呢,还是借用一下 A 对象的方法呢?
当然是借用 A 对象的方法啦,既达到了目的,又节省了内存。
借助已实现的方法,改变方法中数据的this指向,减少重复代码,节省内存
- 借用数组的push方法
var arrayLike = {
0: 'OB',
1: 'Koro1',
length: 2
} // 类数组
Array.prototype.push.call(arrayLike, '添加元素1', '添加元素2');
console.log(arrayLike) // {"0":"OB","1":"Koro1","2":"添加元素1","3":"添加元素2","length":4}
- apply获取数组的最大值、最小值
const arr = [15, 6, 12, 13, 16];
const max = Math.max.apply(Math, arr); // 16
const min = Math.min.apply(Math, arr); // 6
- 继承 借用父类的构造方法来实现父类方法/属性的继承
function superClass () {
this.a = 1;
this.print = function () {
console.log(this.a);
}
}
function subClass () {
superClass.call(this);
this.print();
}
subClass();
// 1
call、apply的选择
call,apply的效果完全一样,它们的区别也在于
- 参数数量/顺序确定就用call,参数数量/顺序不确定的话就用apply。
- 考虑可读性:参数数量不多就用call,参数数量比较多的话,把参数整合成数组,使用apply。
- 参数集合已经是一个数组的情况,用apply,比如上文的获取数组最大值/最小值。
bind的应用场景
- 保存变量参数
for (var i = 1; i <= 5; i++) {
setTimeout(function test() {
console.log(i) // 依次输出:6 6 6 6 6
}, i * 1000);
}
使用bind进行改造
for (var i = 1; i <= 5; i++) {
// 缓存参数
setTimeout(function (i) {
console.log('bind', i) // 依次输出:1 2 3 4 5
}.bind(null, i), i * 1000);
}
实际上这里也用了闭包,我们知道bind会返回一个函数,这个函数也是闭包。
它保存了函数的this指向、初始参数,每次i的变更都会被bind的闭包存起来,所以输出1-5。
- 回调函数this丢失 使用bind绑定回调函数的this指向
this.pageClass = new Page(this.handleMessage.bind(this)) // 绑定回调函数的this指向
使用箭头函数的this指向定义的时候外层第一个普通函数的this也可以解决此问题
this.pageClass = new Page(() => this.handleMessage()) // 箭头函数绑定this指向
手写 call
手写 aplly
手写 bind
拓展
关于类数组
类数组 arrayLike 可以通过角标进行调用,具有length属性,同时也可以通过 for 循环进行遍历。
类数组,还是比较常用的,只是我们平时可能没注意到。比如,我们获取 DOM 节点的方法,返回的就是一个类数组。再比如,在一个方法中使用 arguments 获取到的所有参数,也是一个类数组。
但是需要注意的是:类数组无法使用 forEach、splice、push 等数组原型链上的方法,毕竟它不是真正的数组。
let arrayLike = {
0: 1,
1: 2,
2: 3,
length: 3
};
趣味理解call apply bind
猫吃鱼,狗吃肉,奥特曼打小怪兽。
有天狗想吃鱼了
猫.吃鱼.call(狗,鱼)
狗就吃到鱼了
猫成精了,想打怪兽
奥特曼.打小怪兽.call(猫,小怪兽)
猫也可以打小怪兽了
进阶 segmentfault.com/a/119000001…