前言
背景
- 被问道的引用问题
- 1 一次会议问道的引用类型问题,同学想使用惰性加载,希望在网页中fetch得到的数据保存下了,如果某些不改变参数,就不在发起请求,前提是会对fetch返回的数据进行修改,但还有使用之前的fetchdata
if(window.fetchData&&window.fetchDataSomeKeyLength=== window.fetchData.length) return const data = fetch(`${url}` window.fetchData = data window.fetchDataSomeKeyLength=data.someKey.length processData(data)//data.someKey的length做了处理
- 要get的点
- 1 js基本类型和引用类型的区别
- 2 栈存储和堆存的区别
- 3 js垃圾回收机制
- 4 活动对象、执行上下文、this
- 5 闭包的形成
- 6 深copy的实现
基本类型和引用类型的区别
6种基本类型
- string
- number
- bool
- null
- undefined
- symbol
通俗易懂的话来讲,js的基本类型使用用来存储值得,它们分配大小是有限度 在定义基本类型变量的时候它们的内存都被分配完成,
- 数字有最大值和最小值
- null undefined的是固定的值
- bool 值为 true和false
string
、number
、boolean
和 symbol
这四种类型统称为原始类型(Primitive) ,表示不能再细分下去的基本类型;symbol
表示独一无二的值,通过 Symbol
函数调用生成,由于生成的 symbol
值为原始类型,所以 Symbol
函数不能使用 new
调用;null
和 undefined
通常被认为是特殊值,这两种类型的值唯一,就是其本身。
引用类型
-
对象
-
数组
-
函数
和基本类型区分开来。对象在逻辑上是属性的无序集合或者有序集合,是存放各种值的容器。对象值存储的是引用地址,所以和基本类型值不可变的特性不同,对象值是可变的。
包装对象
我们知道对象拥有属性和方法。但比如字符串这种基本类型值不属于对象为什么还拥有属性和方法呢?实际上在引用字符串的属性或方法时,会通过调用 new String()
的方式转换成对象,该对象继承了字符串的方法来处理属性的引用,一旦引用结束,便会销毁这个临时对象,这就是包装对象的概念。
不仅仅只是字符串有包装对象的概念,数字和布尔值也有相对应的 new Number()
和 new Boolean()
包装对象。null
和 undefined
没有包装对象,访问它们的属性会报类型错误。
字符串、数字和布尔值通过构造函数显式生成的包装对象,既然属于对象,和基本类型的值必然是有区别的,这点可以通过 typeof
检测出来。
typeof 'seymoe' // 'string'
typeof new String('seymoe') // 'object'
数据类型的判断
-
typeof
-
instanceof
-
Object.prototype.toString()
typeof
`typeof` 操作符来判断一个值属于哪种基本类型,返回值是一个string,对null判断有误,认为null是个空指针typeof 'seymoe' // 'string' typeof true // 'boolean' typeof 10 // 'number' typeof Symbol() // 'symbol' typeof null // 'object' 无法判定是否为 null typeof undefined // 'undefined'
如果使用
typeof
操作符对对象类型及其子类型,譬如函数(可调用对象)、数组(有序索引对象)等进行判定,则除了函数都会得到object
的结果。typeof {} // 'object' typeof [] // 'object' typeof(() => {})// 'function'
由于无法得知一个值到底是数组还是普通对象,显然通过
typeof
判断具体的对象子类型远远不够。instanceof
通过 `instanceof` 操作符也可以对对象类型链上的构造函数进行判定,其原理就是测试构造函数的 `prototype` 是否出现在被检测对象的原型链上。 ``` [] instanceof Array // true ({}) instanceof Object // true (()=>{}) instanceof Function // true ```注意:
instanceof
也不是万能的。其原理就是测试构造函数var a={} a.__proto__=[] a instanceof Array //true a instanceof Object //true
Object.prototype.toString()
`Object.prototype.toString()` 可以说是判定 JavaScript 中数据类型的终极解决方法了,具体用法请看以下代码:Object.prototype.toString.call({}) // '[object Object]' Object.prototype.toString.call([]) // '[object Array]' Object.prototype.toString.call(() => {}) // '[object Function]' Object.prototype.toString.call('seymoe') // '[object String]' Object.prototype.toString.call(1) // '[object Number]' Object.prototype.toString.call(true) // '[object Boolean]' Object.prototype.toString.call(Symbol()) // '[object Symbol]' Object.prototype.toString.call(null) // '[object Null]' Object.prototype.toString.call(undefined) // '[object Undefined]' Object.prototype.toString.call(new Date()) // '[object Date]' Object.prototype.toString.call(Math) // '[object Math]' Object.prototype.toString.call(new Set()) // '[object Set]' Object.prototype.toString.call(new WeakSet()) // '[object WeakSet]' Object.prototype.toString.call(new Map()) // '[object Map]' Object.prototype.toString.call(new WeakMap()) // '[object WeakMap]'
数据类型转换
ToPrimitive
JavaScript 对象转换到基本类型值时,会使用 ToPrimitive 算法,这是一个内部算法,是编程语言在内部执行时遵循的一套规则。ToPrimitive 算法在执行时,会被传递一个参数 hint
,表示这是一个什么类型的运算(也可以叫运算的期望值),根据这个 hint
参数,ToPrimitive 算法来决定内部的执行逻辑。
hint
参数的取值只能是下列 3 者之一:
string
number
default
转换算法
当对象与到基本类型值发生转换时,会按照下面的逻辑调用对象上的方法: **为了进行转换,JavaScript 会尝试查找并调用三个对象方法:**-
调用
obj[Symbol.toPrimitive](hint)
- 带有符号键Symbol.toPrimitive
(系统符号)的方法,如果存在这样的方法, -
否则如果提示是
"string"
- 尝试
obj.toString()
和obj.valueOf()
,无论存在什么。
- 尝试
-
否则,如果提示是
"number"
或"default"
- 尝试
obj.valueOf()
和obj.toString()
,无论存在什么。
- 尝试
确定 hint
我们提到了 ToPrimitive 算法中用到的 hint
参数,那怎样确定一次运算场景下的 hint
取值是什么呢?很简单----新建一个对象,打印各个运算场景下的 hint
值:
let obj = {
name: "John",
money: 1000,
[Symbol.toPrimitive](hint) {
console.log(`hint: ${hint}`);
}
};
alert(obj) // hint: string
+obj // hint: number
obj + 500 // hint: default
// 一个没有提供 Symbol.toPrimitive 属性的对象,参与运算时的输出结果
var obj1 = {};
console.log(+obj1); // NaN
console.log(`${obj1}`); // "[object Object]"
console.log(obj1 + ""); // "[object Object]"
// 接下面声明一个对象,手动赋予了 Symbol.toPrimitive 属性,再来查看输出结果
var obj2 = {
[Symbol.toPrimitive](hint) {
if (hint == "number") {
return 10;
}
if (hint == "string") {
return "hello";
}
return true;
}
};
console.log(+obj2); // 10 -- hint 参数值是 "number"
console.log(`${obj2}`); // "hello" -- hint 参数值是 "string"
console.log(obj2 + ""); // "true" -- hint 参数值是 "default"
## Symbol.toPrimitive 和 toString/valueOf 方法
并不要求 `Symbol.toPrimitive` 和 `toString/valueOf` 方法必须返回 `hint` 参数值所暗示的类型值。但要注意下面两点:
Symbol.toPrimitive
和toString
方法的返回值必须是基本类型值。valueOf
方法除了可以返回基本类型值,也可以返回其他类型值。
当我们创建一个普通对象时({}
或 new Object()
的方式等),对象上是不具备 [Symbol.toPrimitive]
(方法)属性的。所以,对于普通对象的到基本类型值的运算,一般按照具体场景:
hint
值为"string"
时,先调用toString
,toString
如果返回一个基本类型值了,则返回、终止运算;否则接着调用valueOf
方法。- 否则,先调用
valueOf
,valueOf
如果返回一个基本类型值了,则返回、终止运算;否则接着调用toString
方法。
2 栈存储和堆存的区别
栈数据结构
栈是一种特殊的列表,栈内的元素只能通过列表的一端访问,这一端称为栈顶。 栈被称为是一种后入先出(LIFO,last-in-first-out)的数据结构。 由于栈具有后入先出的特点,所以任何不在栈顶的元素都无法访问。 为了得到栈底的元素,必须先拿掉上面的元素。
在这里,为方便理解,通过类比乒乓球盒子来分析栈的存取方式。
这种乒乓球的存放方式与栈中存取数据的方式如出一辙。 处于盒子中最顶层的乒乓球 5,它一定是最后被放进去,但可以最先被使用。 而我们想要使用底层的乒乓球 1,就必须将上面的 4 个乒乓球取出来,让乒乓球1处于盒子顶层。 这就是栈空间先进后出,后进先出的特点。
堆数据结构
堆是一种经过排序的树形数据结构,每个结点都有一个值。 通常我们所说的堆的数据结构,是指二叉堆。 堆的特点是根结点的值最小(或最大),且根结点的两个子树也是一个堆。 由于堆的这个特性,常用来实现优先队列,堆的存取是随意,这就如同我们在图书馆的书架上取书, 虽然书的摆放是有顺序的,但是我们想取任意一本时不必像栈一样,先取出前面所有的书, 我们只需要关心书的名字。变量类型与内存的关系
基本数据类型保存在栈内存中,因为基本数据类型占用空间小、大小固定,通过按值来访问,属于被频繁使用的数据。 为了更好的搞懂基本数据类型变量与栈内存,我们结合以下例子与图解进行理解:
let num1 = 1;
let num2 = 1;
引用数据类型存储在堆内存中,因为引用数据类型占据空间大、大小不固定。 如果存储在栈中,将会影响程序运行的性能; 引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。 当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体
// 基本数据类型-栈内存
let a1 = 0;
// 基本数据类型-栈内存
let a2 = 'this is string';
// 基本数据类型-栈内存
let a3 = null;
// 对象的指针存放在栈内存中,指针指向的对象存放在堆内存中
let b = { m: 20 };
// 数组的指针存放在栈内存中,指针指向的数组存放在堆内存中
let c = [1, 2, 3];
因此当我们要访问堆内存中的引用数据类型时,实际上我们首先是从变量中获取了该对象的地址指针, 然后再从堆内存中取得我们需要的数据。
从内存角度来看变量复制
let a = 20;
let b = a;
b = 30;
console.log(a); // 此时a的值是50
在这个例子中,a、b 都是基本类型,它们的值是存储在栈内存中的,a、b 分别有各自独立的栈空间, 所以修改了 b 的值以后,a 的值并不会发生变化。
引用数据类型的复制
let m = { a: 10, b: 20 };
let n = m;
n.a = 15;
console.log(m.a) //此时m.a的值是多少,是10?还是15?
在这个例子中,m、n都是引用类型,栈内存中存放地址指向堆内存中的对象, 引用类型的复制会为新的变量自动分配一个新的值保存在变量中, 但只是引用类型的一个地址指针而已,实际指向的是同一个对象, 所以修改 n.a 的值后,相应的 m.a 也就发生了改变。
栈内存和堆内存的优缺点
在JS中,基本数据类型变量大小固定,并且操作简单容易,所以把它们放入栈中存储。 引用类型变量大小不固定,所以把它们分配给堆中,让他们申请空间的时候自己确定大小,这样把它们分开存储能够使得程序运行起来占用的内存最小。
栈内存由于它的特点,所以它的系统效率较高。 堆内存需要分配空间和地址,还要把地址存到栈中,所以效率低于栈。
3 js垃圾回收机制
为什么要有垃圾回收
在C语言和C++语言中,我们如果想要开辟一块堆内存的话,需要先计算需要内存的大小,然后自己通过malloc函数去手动分配,在用完之后,还要时刻记得用free函数去清理释放,否则这块内存就会被永久占用,造成内存泄露。
但是我们在写JavaScript的时候,却没有这个过程,因为人家已经替我们封装好了,V8引擎会根据你当前定义对象的大小去自动申请分配内存。
不需要我们去手动管理内存了,所以自然要有垃圾回收,否则的话只分配不回收,岂不是没多长时间内存就被占满了吗,导致应用崩溃。
垃圾回收的好处是不需要我们去管理内存,把更多的精力放在实现复杂应用上,但坏处也来自于此,不用管理了,就有可能在写代码的时候不注意,造成循环引用等情况,导致内存泄露。
垃圾回收机制
标记清除
当变量进入环境(例如,在函数中声明一个变量)时,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。而当变量离开环境时,则将其标记为“离开环境”。
可以使用任何方式来标记变量。比如,可以通过翻转某个特殊的位来记录一个变量何时进入环境,或者使用一个“进入环境的”变量列表及一个“离开环境的”变量列表来跟踪哪个变量发生了变化。如何标记变量并不重要,关键在于采取什么策略。
- (1)垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记(当然,可以使用任何标记方式)。
- (2)然后,它会去掉运行环境中的变量以及被环境中变量所引用的变量的标记
- (3)此后,依然有标记的变量就被视为准备删除的变量,原因是在运行环境中已经无法访问到这些变量了。
- (4)最后,垃圾收集器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。
目前,IE、Firefox、Opera、Chrome和Safari的JavaScript实现使用的都是标记清除式的垃圾回收策略(或类似的策略),只不过垃圾收集的时间间隔互有不同。
引用计数
引用计数的垃圾收集策略不太常见。含义是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是1。如果同一个值又被赋给另一个变量,则该值的引用次数加1。相反,如果包含对这个值引用的变量改变了引用对象,则该值引用次数减1。
当这个值的引用次数变成0时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。
这样,当垃圾收集器下次再运行时,它就会释放那些引用次数为0的值所占用的内存。
循环引用是指对象A中包含一个指向对象B的指针,而对象B中也包含一个指向对象A的引用,看个例子:
function foo () {
var objA = new Object();
var objB = new Object();
objA.otherObj = objB;
objB.anotherObj = objA;
}
这个例子中,objA和objB通过各自的属性相互引用,也就是说,这两个对象的引用次数都是2。
在采用标记清除策略的实现中,由于函数执行后,这两个对象都离开了作用域,因此这种相互引用不是问题。
但在采用引用次数策略的实现中,当函数执行完毕后,objA和objB还将继续存在,因为它们的引用次数永远不会是0。加入这个函数被重复多次调用,就会导致大量内存无法回收
还要注意的是,我们大部分人时刻都在写着循环引用的代码,看下面这个例子,相信大家都这样写过:
var el = document.getElementById('#el');
el.onclick = function (event) {
console.log('element was clicked');
}
我们为一个元素的点击事件绑定了一个匿名函数,我们通过event参数是可以拿到相应元素el的信息的。
大家想想,这是不是就是一个循环引用呢? el有一个属性onclick引用了一个函数(其实也是个对象),函数里面的参数又引用了el,这样el的引用次数一直是2,即使当前这个页面关闭了,也无法进行垃圾回收。
如果这样的写法很多很多,就会造成内存泄露。我们可以通过在页面卸载时清除事件引用,这样就可以被回收了
var el = document.getElementById('#el');
el.onclick = function (event) {
console.log('element was clicked');
}
// ...
// ...
// 页面卸载时将绑定的事件清空
window.onbeforeunload = function(){
el.onclick = null;
}
V8垃圾回收策略
自动垃圾回收有很多算法,由于不同对象的生存周期不同,所以无法只用一种回收策略来解决问题,这样效率会很低。所以,V8采用了一种代回收的策略,将内存分为两个生代:新生代(new generation)和老生代(old generation) 。
新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象,分别对新老生代采用不同的垃圾回收算法来提高效率,对象最开始都会先被分配到新生代(如果新生代内存空间不够,直接分配到老生代),新生代中的对象会在满足某些条件后,被移动到老生代,这个过程也叫晋升,后面我会详细说明。
分代内存
默认情况下,32位系统新生代内存大小为16MB,老生代内存大小为700MB,64位系统下,新生代内存大小为32MB,老生代内存大小为1.4GB。
新生代平均分成两块相等的内存空间,叫做semispace,每块内存大小8MB(32位)或16MB(64位)。
分配方式
新生代存的都是生存周期短的对象,分配内存也很容易,只保存一个指向内存空间的指针,根据分配对象的大小递增指针就可以了,当存储空间快要满时,就进行一次垃圾回收。
算法
新生代采用Scavenge垃圾回收算法,在算法实现时主要采用Cheney算法。 Cheney算法将内存一分为二,叫做semispace,一块处于使用状态,一块处于闲置状态。
处于使用状态的semispace称为From空间,处于闲置状态的semispace称为To空间。
接下来我会结合流程图来详细说明Cheney算法是怎么工作的。 垃圾回收在下面我统称为 GC(Garbage Collection) 。 step1. 在From空间中分配了3个对象A、B、C
step2. GC进来判断对象B没有其他引用,可以回收,对象A和C依然为活跃对象
step3. 将活跃对象A、C从From空间复制到To空间
step4. 清空From空间的全部内存
step5. 交换From空间和To空间
step6. 在From空间中又新增了2个对象D、E
step7. 下一轮GC进来发现对象D没有引用了,做标记
step8. 将活跃对象A、C、E从From空间复制到To空间
step9. 清空From空间全部内存
step10. 继续交换From空间和To空间,开始下一轮
通过上面的流程图,我们可以很清楚的看到,进行From和To交换,就是为了让活跃对象始终保持在一块semispace中,另一块semispace始终保持空闲的状态。
Scavenge由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的体现。Scavenge的缺点是只能使用堆内存的一半,这是由划分空间和复制机制所决定的。
由于Scavenge是典型的牺牲空间换取时间的算法,所以无法大规模的应用到所有的垃圾回收中。但我们可以看到,Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰适合这个算法。
晋升
当一个对象经过多次复制仍然存活时,它就会被认为是生命周期较长的对象。这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。对象从新生代移动到老生代的过程叫作晋升。
对象晋升的条件主要有两个:
- 对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收。如果已经经历过了,会将该对象从From空间移动到老生代空间中,如果没有,则复制到To空间。总结来说,如果一个对象是第二次经历从From空间复制到To空间,那么这个对象会被移动到老生代中。
- 当要从From空间复制一个对象到To空间时,如果To空间已经使用了超过25%,则这个对象直接晋升到老生代中。设置25%这个阈值的原因是当这次Scavenge回收完成后,这个To空间会变为From空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配
老生代
在老生代中,存活对象占较大比重,如果继续采用Scavenge算法进行管理,就会存在两个问题:- 由于存活对象较多,复制存活对象的效率会很低。
- 采用Scavenge算法会浪费一半内存,由于老生代所占堆内存远大于新生代,所以浪费会很严重。
所以,V8在老生代中主要采用了Mark-Sweep和Mark-Sweep相结合的方式进行垃圾回收。
Mark-Sweep
Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段。与Scavenge不同,Mark-Sweep并不会将内存分为两份,所以不存在浪费一半空间的行为。Mark-Sweep在标记阶段遍历堆内存中的所有对象,并标记活着的对象,在随后的清除阶段,只清除没有被标记的对象。
也就是说,Scavenge只复制活着的对象,而Mark-Sweep只清除死了的对象。活对象在新生代中只占较少部分,死对象在老生代中只占较少部分,这就是两种回收方式都能高效处理的原因。
step1. 老生代中有对象A、B、C、D、E、F
step2. GC进入标记阶段,将A、C、E标记为存活对象
step3. GC进入清除阶段,回收掉死亡的B、D、F对象所占用的内存空间
可以看到,Mark-Sweep最大的问题就是,在进行一次清除回收以后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题。
如果出现需要分配一个大内存的情况,由于剩余的碎片空间不足以完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。
Mark-Compact
为了解决Mark-Sweep的内存碎片问题,Mark-Compact就被提出来了。**Mark-Compact是标记整理的意思,**是在Mark-Sweep的基础上演变而来的。Mark-Compact在标记完存活对象以后,会将活着的对象向内存空间的一端移动,移动完成后,直接清理掉边界外的所有内存。如下图所示: step1. 老生代中有对象A、B、C、D、E、F(和Mark—Sweep一样)
step2. GC进入标记阶段,将A、C、E标记为存活对象(和Mark—Sweep一样)
step3. GC进入整理阶段,将所有存活对象向内存空间的一侧移动,灰色部分为移动后空出来的空间
step4. GC进入清除阶段,将边界另一侧的内存一次性全部回收
两者结合
在V8的回收策略中,Mark-Sweep和Mark-Conpact两者是结合使用的。
由于Mark-Conpact需要移动对象,所以它的执行速度不可能很快,在取舍上,V8主要使用Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时,才使用Mark-Compact。
总结
V8的垃圾回收机制分为新生代和老生代。
新生代主要使用Scavenge进行管理,主要实现是Cheney算法,将内存平均分为两块,使用空间叫From,闲置空间叫To,新对象都先分配到From空间中,在空间快要占满时将存活对象复制到To空间中,然后清空From的内存空间,此时,调换From空间和To空间,继续进行内存分配,当满足那两个条件时对象会从新生代晋升到老生代。
老生代主要采用Mark-Sweep和Mark-Compact算法,一个是标记清除,一个是标记整理。两者不同的地方是,Mark-Sweep在垃圾回收后会产生碎片内存,而Mark-Compact在清除前会进行一步整理,将存活对象向一侧移动,随后清空边界的另一侧内存,这样空闲的内存都是连续的,但是带来的问题就是速度会慢一些。在V8中,老生代是Mark-Sweep和Mark-Compact两者共同进行管理的。
以上就是本文的全部内容,书写过程中参考了很多中外文章,参考书籍包括朴大大的《深入浅出NodeJS》以及《JavaScript高级程序设计》等。我们这里并没有对具体的算法实现进行探讨,感兴趣的朋友可以继续深入研究一下。
最后,谢谢大家能够读到这里,如果文中有任何不明确或错误的地方,欢迎给我留言~~
4 执行环境、执行上下文、活动对象、this
执行环境
执行环境是js中重要的一个概念。执行环境定义了变量和函数有权访问其他变量,决定了他们的各自行为,每个函数执行都有自己的执行环境,当执行流入一个函数,函数的执行环境就会给推到当前执行栈中,函数执行完毕,函数的执行环境就会被弹出,执行权交给当前栈,这就是js的执行流变量对象
每个执行环境都有一个与之关联变量对象,环境中定义的所有的变量和函数都保存在这个变量中,虽然我们编写代码无法访问这个对象,但是解析器能够在处理数据的时会在后台使用。全局执行环境
全局执行环境,是最外围的一个执行环境。根据ecmascript实现所在的宿主不同,表示执行环境也不一样,web全局执行环境被认为是window,因此全局所有的变量和函数都被认为是window的属性和函数被创建,某个执行环境的中的代码执行完毕后,该环境就会给销毁,该环境变量对象也会被销毁作用域链
当代码在执行环境中执行时,会创建变量对象的一个作用域链,作用域链的用途,是保证执行环境有权访问所有的变量和函数有序访问,作用域的最前端是当前执行环境的的变量对象,如果这个环境是函数,就将其 **活动对象** ,作为变量对象,活动对象刚开始就只包含一个变量就是arguments对象(这个对象在全局是不存在的),作用域的下一个变量对象来之与当前函数所在的执行栈的变量对象(可以理解为当前函数的执行栈),下一个的下一个就是当前函数执行栈的执行栈,这样一直延续到全局执行环境中的变量对象,为作用域的末端。标识符解析(变量查找),是按照作用域链一级一级的操作,查找顺序是从当前变量对象开始,知道找到为止,如果找不到就会通常会有异常
var color = "blue";
function changeColor() {
var otherColor = "red";
function swapColor() {
var tempColor = otherColor;
otherColor = color;
color = tempColor;
// 这里可以访问 tempColor otherColor color
}
swapColor();
// 这里可以访问 otherColor color swapColor
}
changeColor();
// 这里可以访问 changeColor color
看图
this是什么
一般对this的误解分为两个方面- 1 this是指向当前函数的本身
- 2 this 指向的是当前函数的 作用域
this是指向当前函数的本身
下面代码中大家要理解函数的多面性,多个身份
- 普通的函数
- 普通的对象
- 构造函数
接下来讲用到函数的是两个身份普通函数、普通对象, 看代码()
function foo(){
this.count++
}
var count=0;
foo.count=0;
for(var i=0;i<5;i++){
foo()
}
console.log(foo.count)//0
console.log(count)//5
从打印的结果上来看显然,this指向的不是本身函数,当然咱们一般看到这类的问题咱们就会绕道而行,看代码
function foo(){
this.count++
}
var bar={
count:0
}
foo.count=0;
for(var i=0;i<5;i++){
foo.call(bar)
}
console.log(bar.count)//5
console.log(count)//0
虽然这种解决方案很好,也会有其他的解决方案,但是我们还是不理解this的问题,心里还是有种不安之感
this 指向的是当前函数的 作用域
接下来讲用到函数的是两个身份普通函数、普通对象, 看代码()
function foo(){
var num=2;
console.log(this.num)
}
var num=0;
foo()//0
咱们看到代码的执行结果后,发现this指向的并不是该函数的作用域。
this到底是什么
this是在函数调用的时候绑定,不是在函数定义的时候绑定。它的上下文取决于函数调用时的各种条件,函数执行的时候会创建一个活动记录,这个记录里面包含了该函数中定义的参数和参数,包含函数在哪里被调用(调用栈)...,this就是其中的一个属性。 来看图图中咱们看到this是在函数执行的时候创建的。
全面解析this
前面几步咱们已经确定的this的创建和this的指向的误区,接下啦咱们要看看this的绑定的规则,分为4个规则。
- 默认绑定
- 隐式绑定(上下文绑定)
- 显式绑定
- new 绑定
默认绑定
默认绑定的字面意思就是,不满足其他的绑定方式,而执行的绑定规则。默认绑定会把this绑定到全局对象(是一个危险的操作,文章后面会说为什么) 看代码
function foo(){
var num=2;
this.num++
console.log(this.num)
}
var num=0;
foo()//1
上面代码中就实现了默认绑定,在foo方法的代码块中操作的是window.num++。
隐式绑定(上下文绑定)
定义:
函数被调用的位置有上下文,或者是该函数的引用地址是不是被某个对象的属性引用,并通过对象的属性直接运行该函数。如果出现上述的情况,就会触发this的隐式绑定,this就会被绑定成当前对象 看代码
function foo(){
console.log(this.name)
}
var bar={
name:'shiny',
foo:foo
}
bar.foo()//shiny
要需要补充一点,不管你的对象嵌套多深,this只会绑定为直接引用该函数的地址属性的对象,看代码
function foo(){
console.log(this.name)
}
var shiny={
name:'shiny',
foo:foo
}
var red={
name:'red',
obj:shiny
}
red.obj.foo()//shiny
隐式绑定的丢失
先看代码
function foo(){
console.log(this.name)
}
var shiny={
name:'shiny',
foo:foo
}
function doFoo(fn){
fn()
}
doFoo(shiny.foo)//undefind
大家知道函数参数在函数执行的时候,其实有一个赋值的操作,我来解释一下上面的,当函数doFoo执行的时候会开辟一个新的栈并被推入到全局栈中执行,在执行的过程中会创建一个活动对象,这个活动对象会被赋值传入的参数以及在函数中定义的变量函数,在函数执行时用到的变量和函数直接从该活动对象上面取值使用。 看图 doFoo的执行栈
fn的执行栈
看下面原理和上面一样通过赋值,导致隐式绑定的丢失,看代码
function foo(){
console.log(this.name)
}
var shiny={
name:'shiny',
foo:foo
}
var bar = shiny.foo
bar()//undefined
大家是不是已经明白了为什么是undefined,来解释一波,其实shiny的foo属性是引用了foo函数的引用内存地址,那么有把foo的引用地址赋值给了 bar 那么现在的bar的引用地址个shiny.foo的引用地址是一个,那么执行bar的时候也会触发默认绑定规则因为没有其他规则可以匹配,bar函数执行时,函数内部的this绑定的是全局变量。
看下满的引用地址赋值是出现的,奇葩 隐式绑定丢失,看代码
function foo(){
console.log(this.name)
}
var shiny={
name:'shiny',
foo:foo
}
var red={
name:'red'
}
(red.foo=shiny.foo)()//undefined
赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,因此调用位置是 foo() 而不是 p.foo() 或者 o.foo()。根据我们之前说过的,这里会应用默认绑定。
显式绑定
call、apply绑定
javascript,在Function的porpertype上提供了3个方法来强行修改this,分别是 call、apply、bind,大家经常用的莫过于call和apply了,这两个函数的第一个参数,都是需要执行函数绑定的this,对于apply只有连个参数,第二个参数是一个数组,这个数组是要传入执行函数的参数,而call可以跟很多参数,从第二个参数起都会被传入到要执行函数的参数中
看代码
function foo(){
console.log(this.age)
}
var shiny={
age:20
}
foo.call(shiny)//20
function bar(){
console.log(this.age)
}
var red={
age:18
}
bar.apply(red)//18
这两个方法都是显式的绑定了tihs
硬绑定:
类似与 bind方法行为,是显式绑定的一种方式
function foo(b){
return this.a+b
}
var obj={
a:2
}
function bind(fn,obj){
return function(){
return fn.apply(obj,arguments)
}
}
bind(foo,obj)(3)//5
语言解释: 通过apply + 闭包机制 实现bind方法,实现强行绑定规则
API调用的“上下文” 第三方库或者寄生在环境,以及js内置的一些方法都提供了一下 content 上下文参数,他的作用和 bind一样,就是确保回调函数的this被绑定
function foo (el){
console.log(el,this.id)
}
var obj ={
id:'some one'
};
[1,2,4].forEach(foo,obj)
// 1 some one 2 some one 4 some one
new 绑定
说道new 大家都会想到js的构造函数,咱们想不用着急new 绑定this的问题,咱们先看看咱们对js的构造函数的误解,传统面向类的语言中的构函数和js的构造函数时不一样
-
传统面向类的语言中的构函数,是在使用new操作符实例化类的时候,会调用类中的一些特殊方法(构造函数)
-
很多人认为js中的new操作符和传统面向类语言的构造函数是一样的,其实有很大的差别
-
从新认识一下js中的构造函数,js中的构造函数 在被new操作符调用时,这个构造函数不属于每个类,也不会创造一个类,它就是一个函数,只是被new操作符调用。
-
使用new操作符调用 构造函数时会执行4步
- 创建一个全新的对象
- 对全新的对象的__proto__属性地址进行修改成构造函数的原型(prototype)的引用地址
- 构造函数的this被绑定为这个全新的对象
- 如果构造函数有返回值并且这个返回值是一个对象,则返回该对象,否则返回当前新对象
咱们了解了js new 操作符调用构造函数时都做了些什么,哪么咱们就知道构造函数里面的this是谁了
代码实现
function Foo(a){
this.a=a
}
var F = new Foo(2)
console.log(F.a)//2
绑定规则的顺序
咱们在上面了解this绑定的4大规则,那么咱们就看看这4大绑定规则的优先级。
默认绑定
咱们根据字面意思,都能理解只有其余的3个绑定规则无法触发的时候就会触发默认绑定,没有比较意义显式绑定 VS 隐式绑定
看代码
function foo(){
console.log(this.name)
}
var shiny={
name:'shiny',
foo:foo
}
var red={
name:'red'
}
shiny.foo()//shiny
shiny.foo.call(red)// red
shiny.foo.apply(red)// red
shiny.foo.bind(red)()//red
显然在这场绑定this比赛中,显式绑定赢了隐式绑定
隐式绑定 VS new 操作符绑定
看代码
function foo(name){
this.name=name
}
var shiny={
foo:foo
}
shiny.foo('shiny')
console.log(shiny.name)//shiny
var red = new shiny.foo('red')
console.log(red.name)//red
显然在这场绑定this比赛中new 操作符绑定赢了隐式绑定
显式绑定(硬绑定) VS new 操作符绑定
使用call、apply方法不能结合new操作符会报错误
但是咱们可以是bind绑定this来比较 显式绑定和new操作符的绑定this优先级。 看代码
function foo(){
console.log(this.name)
}
var shiny={
name:'shiny'
}
var bar = foo.bind(shiny)
var obj = new bar();
console.log(obj.name)// undefind
显然 new操作符绑定 战胜了 显式绑定
this的判断
咱们在上面已经了解 4个绑定this的优先级。咱们可以列举出来
- 1 判断该函数是不是被new操作符调用,有的话 this就是 构造函数运行时创建的新对象 var f = new foo()
- 2 判断 函数是不是使用显式绑定 call、apply、bind,如果有,那么该函数的this就是 这个三个方法的第一个参数
foo.call(window)
- 3 判断该函数是不是被一个对象的属性引用了地址,该函数有上下文(隐式绑定),在函数执行的时候是通过该对象属性的引用触发,这个函数的this就是当前对象的。
obj.foo();
- 4 上面的三种都没有的话,就是默认绑定,该函数的this就是全局对象或undefined(严格模式下)
绑定例外
? 规则总是会有意外的,this绑定也是会有的,某些场面的绑定也是会出乎意料的,有可能触发了默认绑定 看代码
function foo(){
console.log(name)
}
var name ='shiny'
foo.call(null)//shiny
foo.call(undefined)//shiny
var bar = foo.bind(null)
var baz = foo.bind(undefined)
bar()//siny
baz()//siny
把 null、undefined通过 apply、call、bind 显式绑定,虽然实现可默认绑定,但是建议这么做因为在非严格的模式下会给全局对象添加属性,有时候会造成不可必要的bug。
更安全的this
咱们从上面知道在非严格模式下 默认绑定是并操作this的话会该全局对象添加属性,这样的操作是有风险性的
function foo(a,b) {
console.log( "a:" + a + ", b:" + b );
}
// 我们的空对象
var ø = Object.create( null );
// 把数组展开成参数
foo.apply( ø, [2, 3] ); // a:2, b:3
// 使用 bind(..) 进行柯里化
var bar = foo.bind( ø, 2 );
bar( 3 ); // a:2, b:3
es6中的this
在es5及一下版本,我们被this深深的困惑,但是看完了上面的文章,应该判断this没有关系,但是 重点来了 es6的this可以通过箭头函数直接绑定在该函数的执行的作用域上。 看代码
function foo(){
return ()=>{
console.log(this.name)
}
}
var obj ={
name:'obj'
}
var shiny ={
name:'shiny'
}
var bar = foo.call(obj);
bar.call(shiny)// foo
我们看到箭头函数的this被绑定到该函数执行的作用域上。
咱们在看看 js内部提供内置函数使用箭头函数
function foo() {
setTimeout(() => {
// 这里的 this 在此法上继承自 foo()
console.log( this.a );
},100);
}
var obj = {
a:2
};
foo.call( obj ); // 2
箭头函数可以像 bind(..) 一样确保函数的 this 被绑定到指定对象,此外,其重要性还体 现在它用更常见的词法作用域取代了传统的 this 机制。实际上,在 ES6 之前我们就已经 在使用一种几乎和箭头函数完全一样的模式。
function foo() {
var self = this; // lexical capture of this
setTimeout( function(){
console.log( self.a );
}, 100 );
}
var obj = {
a: 2
};
foo.call( obj ); // 2
虽然 self = this 和箭头函数看起来都可以取代 bind(..),但是从本质上来说,它们想替 代的是 this 机制。 如果你经常编写 this 风格的代码,但是绝大部分时候都会使用 self = this 或者箭头函数。 如果完全采用 this 风格,在必要时使用 bind(..),尽量避免使用 self = this 和箭头函数。
5 闭包的形成
闭包
有关如何创建作用域链以及作用域链有什么作用的细节,对彻底 理解闭包至关重要。当某个函数被调用时,会创建一个执行环境(execution context)及相应的作用域链。 然后,使用 arguments 和其他命名参数的值来初始化函数的活动对象(activation object)。但在作用域 链中,外部函数的活动对象始终处于第二位,外部函数的外部函数的活动对象处于第三位,……直至作为作用域链终点的全局执行环境在函数执行过程中,为读取和写入变量的值,就需要在作用域链中查找变量。来看下面的例子。
function compare(value1, value2) {
if (value1 < value2) {
return -1;
} else if (value1 > value2) {
return 1;
} else {
return 0;
}
}
var result = compare(5, 10);
以上代码先定义了 compare()函数,然后又在全局作用域中调用了它。当调用 compare()时,会 创建一个包含 arguments、value1 和 value2 的活动对象。全局执行环境的变量对象(包含 result 和 compare)在 compare()执行环境的作用域链中则处于第二位。图片 展示了包含上述关系的 compare()函数执行时的作用域链。
后台的每个执行环境都有一个表示变量的对象——变量对象。全局环境的变量对象始终存在,而像 compare()函数这样的局部环境的变量对象,则只在函数执行的过程中存在。在创建 compare()函数 时,会创建一个预先包含全局变量对象的作用域链,这个作用域链被保存在内部的[[Scope]]属性中。 当调用 compare()函数时,会为函数创建一个执行环境,然后通过复制函数的[[Scope]]属性中的对 象构建起执行环境的作用域链。此后,又有一个活动对象(在此作为变量对象使用)被创建并被推入执 行环境作用域链的前端。对于这个例子中 compare()函数的执行环境而言,其作用域链中包含两个变 量对象:本地活动对象和全局变量对象。显然,作用域链本质上是一个指向变量对象的指针列表,它只 引用但不实际包含变量对象。 无论什么时候在函数中访问一个变量时,就会从作用域链中搜索具有相应名字的变量。一般来讲, 当函数执行完毕后,局部活动对象就会被销毁,内存中仅保存全局作用域(全局执行环境的变量对象)。 但是,闭包的情况又有所不同。
在看一个案例
function createComparisonFunction(propertyName) {
return function (object1, object2) {
var value1 = object1[propertyName];
var value2 = object2[propertyName];
if (value1 < value2) {
return -1;
} else if (value1 > value2) {
return 1;
} else {
return 0;
}
};
}
在这个例子中,object1[propertyName] object2[propertyName] 两行代码是内部函数(一个匿名函数)中的代码,这两行代码访问了外部 函数中的变量 propertyName。即使这个内部函数被返回了,而且是在其他地方被调用了,但它仍然可 以访问变量 propertyName。之所以还能够访问这个变量,是因为内部函数的作用域链中包含 createComparisonFunction()的作用域。要彻底搞清楚其中的细节,必须从理解函数被调用的时候 都会发生什么入手。
当某个函数被调用时,会创建一个执行环境(execution context)及相应的作用域链。 然后,使用 arguments 和其他命名参数的值来初始化函数的活动对象(activation object)。但在作用域 链中,外部函数的活动对象始终处于第二位,外部函数的外部函数的活动对象处于第三位,……直至作为作用域链终点的全局执行环境
看图
闭包与变量
作用域链的这种配置机制引出了一个值得注意的副作用,即闭包只能取得包含函数中任何变量的最 后一个值。别忘了闭包所保存的是整个变量对象,而不是某个特殊的变量。下面这个例子可以清晰地说 明这个问题。function createFunctions() {
var result = new Array();
for (var i = 0; i < 10; i++) {
result[i] = function () {
return i;
};
}
return result;
}
这个函数会返回一个函数数组。表面上看,似乎每个函数都应该返自己的索引值,即位置 0 的函数 返回 0,位置 1 的函数返回 1,以此类推。但实际上,每个函数都返回 10。因为每个函数的作用域链中 都保存着 createFunctions() 函数的活动对象,所以它们引用的都是同一个变量 i 。 当 createFunctions()函数返回后,变量 i 的值是 10,此时每个函数都引用着保存变量 i 的同一个变量 对象,所以在每个函数内部 i 的值都是 10。但是,我们可以通过创建另一个匿名函数强制让闭包的行为 符合预期,如下所示。
function createFunctions() {
var result = new Array();
for (var i = 0; i < 10; i++) {
result[i] = (function (num) {
return function () {
return num;
};
})(i);
}
return result;
}
在重写了前面的 createFunctions()函数后,每个函数就会返回各自不同的索引值了。在这个版 本中,我们没有直接把闭包赋值给数组,而是定义了一个匿名函数,并将立即执行该匿名函数的结果赋 给数组。这里的匿名函数有一个参数 num,也就是最终的函数要返回的值。在调用每个匿名函数时,我 们传入了变量 i。由于函数参数是按值传递的,所以就会将变量 i 的当前值复制给参数 num。而在这个 匿名函数内部,又创建并返回了一个访问 num 的闭包。这样一来,result 数组中的每个函数都有自己 num 变量的一个副本,因此就可以返回各自不同的数值了。
关于this
在闭包中使用 this 对象也可能会导致一些问题。我们知道,this 对象是在运行时基于函数的执 行环境绑定的:在全局函数中,this 等于 window,而当函数被作为某个对象的方法调用时,this 等 于那个对象。不过,匿名函数的执行环境具有全局性,因此其 this 对象通常指向 window。但有时候 由于编写闭包的方式不同,这一点可能不会那么明显。下面来看一个例子。var name = "The Window";
var object = {
name: "My Object",
getNameFunc: function () {
return function () {
return this.name;
};
},
};
alert(object.getNameFunc()()); //"The Window"
每个函数在被调用时都会自动取得两个特殊变量:this 和 arguments。内部函 数在搜索这两个变量时,只会搜索到其活动对象为止,因此永远不可能直接访问外部函数中的这两个变 量。不过,把外部作用域中的 this 对象保存在一个闭包能够访问 到的变量里,就可以让闭包访问该对象了。
var name = "The Window";
var object = {
name: "My Object",
getNameFunc: function () {
var that = this;
return function () {
return that.name;
};
},
};
alert(object.getNameFunc()()); //"My Object"
在几种特殊情况下,this 的值可能会意外地改变。比如,下面的代码是修改前面例子的结果。
var name = "The Window";
var object = {
name: "My Object",
getName: function () {
return this.name;
},
};
第一行代码跟平常一样调用了 object.getName(),返回的是"My Object",因为 this.name 就是 object.name。第二行代码在调用这个方法前先给它加上了括号。虽然加上括号之后,就好像只 是在引用一个函数,但 this 的值得到了维持,因为 object.getName 和(object.getName)的定义 是相同的。第三行代码先执行了一条赋值语句,然后再调用赋值后的结果。因为这个赋值表达式的值是 函数本身,所以 this 的值不能得到维持,结果就返回了"The Window"。 当然,你不大可能会像第二行和第三行代码一样调用这个方法。不过,这个例子有助于说明即使是 语法的细微变化,都有可能意外改变 this 的值。
6 深copy的实现
深拷贝和浅拷贝的定义
深拷贝已经是一个老生常谈的话题了,也是现在前端面试的高频题目,但是令我吃惊的是有很多同学还没有搞懂深拷贝和浅拷贝的区别和定义浅拷贝:
创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。
深拷贝:
将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象
乞丐版
在不使用第三方库的情况下,我们想要深拷贝一个对象,用的最多的就是下面这个方法。JSON.parse(JSON.stringify());
这种写法非常简单,而且可以应对大部分的应用场景,但是它还是有很大缺陷的,比如拷贝其他引用类型、拷贝函数、循环引用等情况。
基础版本
function clone(target) {
if (typeof target === 'object') {
let cloneTarget = {};
for (const key in target) {
cloneTarget[key] = clone(target[key]);
}
return cloneTarget;
} else {
return target;
}
};
这是一个最基础版本的深拷贝,这段代码可以让你向面试官展示你可以用递归解决问题,但是显然,他还有非常多的缺陷,比如,还没有考虑数组。
考虑数组
在上面的版本中,我们的初始化结果只考虑了普通的object
,下面我们只需要把初始化代码稍微一变,就可以兼容数组了:
module.exports = function clone(target) {
if (typeof target === 'object') {
let cloneTarget = Array.isArray(target) ? [] : {};
for (const key in target) {
cloneTarget[key] = clone(target[key]);
}
return cloneTarget;
} else {
return target;
}
};
const target = {
field1: 1,
field2: undefined,
field3: {
child: 'child'
},
field4: [2, 4, 8]
};
OK,没有问题,你的代码又向合格迈进了一小步。
循环引用
const target = {
field1: 1,
field2: undefined,
field3: {
child: 'child'
},
field4: [2, 4, 8]
};
target.target = target;
很明显,因为递归进入死循环导致栈内存溢出了。
原因就是上面的对象存在循环引用的情况,即对象的属性间接或直接的引用了自身的情况:
解决循环引用问题,我们可以额外开辟一个存储空间,来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝,这样就巧妙化解的循环引用的问题。
这个存储空间,需要可以存储key-value
形式的数据,且key
可以是一个引用类型,我们可以选择Map
这种数据结构:
- 检查
map
中有无克隆过的对象 - 有 - 直接返回
- 没有 - 将当前对象作为
key
,克隆对象作为value
进行存储 - 继续克隆
function clone(target, map = new Map()) {
if (typeof target === 'object') {
let cloneTarget = Array.isArray(target) ? [] : {};
if (map.get(target)) {
return map.get(target);
}
map.set(target, cloneTarget);
for (const key in target) {
cloneTarget[key] = clone(target[key], map);
}
return cloneTarget;
} else {
return target;
}
};
接下来,我们可以使用,WeakMap
提代Map
来使代码达到画龙点睛的作用。
function clone(target, map = new WeakMap()) {
// ...
};
为什么要这样做呢?,先来看看WeakMap
的作用:
什么是弱引用呢?
举个例子:
let obj = { name : 'ConardLi'}
const target = new Map();
target.set(obj,'code秘密花园');
obj = null;
虽然我们手动将obj
,进行释放,然是target
依然对obj
存在强引用关系,所以这部分内存依然无法被释放。
再来看WeakMap
:
let obj = { name : 'ConardLi'}
const target = new WeakMap();
target.set(obj,'code秘密花园');
obj = null;
如果是WeakMap
的话,target
和obj
存在的就是弱引用关系,当下一次垃圾回收机制执行时,这块内存就会被释放掉。
设想一下,如果我们要拷贝的对象非常庞大时,使用Map
会对内存造成非常大的额外消耗,而且我们需要手动清除Map
的属性才能释放这块内存,而WeakMap
会帮我们巧妙化解这个问题。
- 前端进阶」JS中的栈内存堆内存
- 聊聊V8引擎的垃圾回收
- 如何写出一个惊艳面试官的深拷贝?
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!