你所不知道的JavaScript②--this及周边
关键词
this关键字、apply/call/bind 原理、变量提升、执行上下文、作用域、闭包、new关键字
This
this指向调用其的对象。顺带一提,es6中的箭头函数没有this
,argument
,super
等。这些只依赖包含箭头函数最接近的函数。
先来看适用场景
1 | function foo() { |
箭头函数中的this
1 | function a() { |
- 另外对箭头函数使用bind这类函数是没有用的
- 那么说到bind 有没有考虑过一个函数多次bind的结果是什么?
1 | let a = {} |
this的绑定优先级如下:new
最高,然后是bind
,之后是obj.foo()
最后是foo()
同时this
一旦被绑定,就不会被任何方式改变。
因为满足不了业务需求 所以产生了三种方式让我们手动改变this的指向call apply bind
apply/call/bind 原理
这三个方法都是挂载在Function对象上的三个方法,调用这三个方法的必须是一个函数。
1 | func.call(thisArg, param1, param2, ...) |
- 在浏览器中,全局范围内的this指向window对象
- 在函数中,this永远指向最后调用它的那个对象
- 构造函数中,this指向被new出来的新对象
call apply bind
中的this被强绑定在指定的那个对象上- 箭头函数中的this比较特殊。箭头函数this作为父作用域的this,不是调用时的this,要知道前四种方式都是调用时确定,也就是动态的,然而箭头函数的this是静态的,声明的时候就确定了下来。
1 | let a = { |
bind 和其他两个方法作用也是一致的,只是该方法会返回一个函数。并且我们可以通过 bind 实现柯里化
判断数据类型
用Object.prototype.toStirng
来判断类型是最合适的,借用它我们几乎可以判断所有类型的数据。
1 | function getType(obj){ |
类数组借用方法
var arrayLike = {
0: ‘java’,
1: ‘script’,
length: 2
}
Array.prototype.push.call(arrayLike, ‘jack’, ‘lily’);
console.log(typeof arrayLike); // ‘object’
console.log(arrayLike);
// {0: “java”, 1: “script”, 2: “jack”, 3: “lily”, length: 4}
用call方法来借用Array原型链上面的push方法 实现一个类数组的push方法,给arrayLike添加元素
获取数组最大值/最小值
我们可以用apply来实现数组中判断最大最小值,apply直接传递数组作为调用方法的参数。也可以减少一步展开数组,直接使用Math的方法来获取最大最小。
1 | let arr = [13, 6, 10, 11, 16]; |
实现一个bind函数
对于实现以下几个函数,可以从几个方面思考。
- 不传入第一个参数,那么默认为window
- 改变了this指向,让新的对象可以执行该函数,那么思路是否可以变成给新的对象添加一个函数 最后执行完再删除。
1 | Function.prototype.mybind = function (context) { |
实现一个call函数
1 | Function.prototype.myCall = function (context){ |
实现一个apply函数
1 | Function.prototype.myapply = function (context, args) { |
什么时候要使用到bind
eg1 防止this指向到不正确的对象/永久绑定this指向1
2
3
4
5
6
7
8
9
10
11const person = {
nickname: 'jojo',
eatWatermelon() {
console.log(this.nickname + ' 吃西瓜');
}
};
person.eatWatermelon(); //jojo吃西瓜
const eatWatermelon = person.eatWatermelon;
eatWatermelon();//undefined
this 的指向变成了 eatWatermelon() 执行时所在作用域的 this。也就是window,所以找不到
解决方法: 使用bind永久绑定this指向
1
2 const eatWatermelon = person.eatWatermelon.bind(person);
eatWatermelon();//jojo吃西瓜
eg2 预置函数参数
1
2
3
4
5
6
7
8
9
10
11
12 function add(a, b, c) {
return a + b + c;
}
const addSix = add.bind(null, 6);
const addSixThenAddFour = addSix.bind(null, 4);
addSixThenAddFour(5)
// 15
addSixThenAddFour(7)
// 17
变量提升
当执行js代码的时候,会生成执行环境,只要代码不是写在函数中的,就是在全局执行环境中,函数的代码会产生函数执行环境,只此两种执行环境。
1 | b() // call b |
上面的输出是因为变量提升。通常的解释是声明的代码移动到顶部。但更准确的解释是,在生成执行环境时,会有两个阶段,一个是创建阶段:js解释器会找出需要提升的变量和函数,并且给他们提前在内存中开辟好空间,函数的话会将整个函数存入内存中,变量只声明并且赋值为undefined,所以在第二个阶段,也就是代码执行阶段,我们可以提前使用。
- 在提升的过程中,相同的函数会覆盖上一个函数,并且函数优先于变量提升
1 | b() // call b second |
var
会产生很多错误,所以在es6中引入了let
,它不能在声明前使用,但并不是说它没有提升,他有提升,且也在声明的时候开辟了内存,但因为它的这个特性导致它不能在声明前使用。
执行上下文
当执行js代码的时候,会产生三种执行上下文
- 全局执行上下文
- 函数执行上下文
eval
执行上下文
每个执行上下文都有三种重要属性
- 变量对象(VO),包含变量,函数声明和函数的形参,该属性只能在全局上下文中访问。
- 作用域链,js采用词法作用域链,也就是说变量的作用域是在定义的时候决定了。
this
1 | var a = 10 |
对于上述代码代码中,执行栈中有两个上下文:全局上下文和函数foo上下文
1 | stack = [ |
对于全局上下文来说,VO大概是这样的
1 | globalContext.VO === globe |
对于函数foo来说,VO不能访问,只能访问到活动对象AO
1 | fooContext.VO === foo.AO |
IIFE注意事项
1 | var foo = 1 |
js解释器在遇到IIFE的时候,会创建一个辅助的特定对象,然后将函数的名称(这里是foo)作为这个特定对象的属性。因此函数内部才可以访问到foo,但这个值是只读的,所以我们并不能修改 也不能像这样对他赋值改变,所以最后打印的还是这个函数,并且外部的值也没有改变
总结
执行上下文可以简单理解为一个对象
它包含三个部分
- 变量对象VO
- 作用域链 词法作用域
- this指向
它的类型
- 全局执行上下文
- 函数执行上下文
eval
执行上下文
代码执行的过程
- 创建全局执行上下文 (global EC)
- 全局执行上下文(caller)逐行 自上而下执行。遇到函数的时候,函数执行上下文(callee)被push到执行栈顶层
- 函数执行上下文被激活后,成为
active EC
开始执行函数中的代码,caller
被挂起 - 函数执行完后,
callee
被pop出执行栈,控制权还给全局上下文(caller)继续执行
作用域
- 作用域:作用域就是定义变量的区域,他有一套访问变量的规则,这套规则来管理浏览器引擎如何在当前作用域以及嵌套的作用域中根据变量(标识符)进行变量查找
- 作用域链:作用域链的作用是保证对执行环境有权访问的所有变量和函数的有序访问,通过作用域链,我们可以访问到外层环境的变量和函数
作用域链本质上是一个指向变量对象的指针列表。变量对象是一个包含了执行环境中所有变量和函数的对象。作用域链的前端始终是当前执行上下文的变量对象。全局执行上下文的变量对象(也就是全局对象)始终是作用域链的最后一个对象
- 当我们查找一个变量的时候,如果在当前执行环境中没有找到,我们可以沿着作用域链向后查找
- 作用域链的创建过程和执行上下文的建立有关。
作用域可以理解为变量的可访问性 总共分为三种类型 分别为:
- 全局作用域
- 函数作用域
- 块级作用域
全局作用域
全局变量是挂载在window对象下的变量,所以在网页中的任何位置都可以使用并且访问到这个全局变量。
1 | var globalName = 'global'; |
- 全局变量在什么地方都可以被访问到
- 但是可能会引起命名冲突的问题,所以定义变量的时候注意作用域
函数作用域
函数中定义的变量叫做函数变量,这个时候只有在函数内部才能访问到它,所以它的作用域也就是函数的内部称为函数作用域
1 | function getName () { |
除了这个函数内部,其他地方都是不能访问到它的,同时当这个函数被执行完之后,这个局部变量也会相应被销毁,所以外面访问不到这个局部变量
块级作用域
es6新增了块级作用域,直接的表现就是let关键字。使用它定义的变量只能在块级作用域中被访问。有暂时性死区的特点。也就是说这个变量在定义之前是不能被使用的。
1 | console.log(a) //a is not defined |
闭包
闭包其实就是一个可以访问其他函数内部变量的函数。创建闭包的常见方式是在一个函数内创建另外一个函数,创建的函数可以访问到当前函数的局部变量。
1 | function fun1() { |
闭包的两个常用的用途
- 闭包的第一个用途是使我们在函数外部能够访问到内部的变量。
- 另外一个用途是使得已经允许结束的函数上下文中的变量对象继续留在内存中,因为闭包函数保留了这个变量对象 的引用,所以这个变量对象不会被回收。
1 | let a = 1 |
闭包产生的原因
闭包产生的本质是:当前环境中存在指向父级作用域的引用。
1 | function fun1() { |
- 在上面这段代码中,我们知道,result会拿到父级作用域的变量输出2。因为在当前的环境中,含有对fun2函数的引用,而fun2中又引用了window和fun1,fun2,那么此时fun2可以访问到fun1中的2 那么就输出2
- 那是不是只有返回函数才算是产生了闭包?其实不是,回到闭包的本质。我们只需要让父级作用域的引用存在即可。
1 | var fun3; |
可以看出其中输出的结果还是2 因为在给fun3赋值的时候,fun3就可以访问到window fun1和本身的作用域,然后由下往上查找 找到了fun1中的2 输出2
结论:
不能通过最后有没有返回函数来判断闭包。
闭包的表现形式
- 返回一个函数
- 在定时器,事件监听,ajax请求,webworkers或者任何异步中,只要使用了回调函数,实际上就是在使用闭包。
1
2
3
4
5
6
7
8// 定时器
setTimeout(function handler(){
console.log('1');
},1000);
// 事件监听
$('#app').click(function(){
console.log('Event Listener');
}); - 作为函数参数传递的形式
1
2
3
4
5
6
7
8
9
10
11
12
13var a = 1;
function foo(){
var a = 2;
function baz(){
console.log(a);
}
bar(baz);
}
function bar(fn){
// 这就是闭包
fn();
}
foo(); // 输出2,而不是1 - IIFE创建了闭包 保存了全局作用域window和当前函数的作用域。因此可以输出全局作用域的变量。
1
2
3
4var a = 2;
(function IIFE(){
console.log(a); // 输出2
})();
IIFE 这个函数会稍微有些特殊,算是一种自执行匿名函数,这个匿名函数拥有独立的作用域。这不仅可以避免了外界访问此 IIFE 中的变量,而且又不会污染全局作用域,我们经常能在高级的 JavaScript 编程中看见此类函数。
如何解决循环输出问题?
code如下
1 | for(var i = 1; i <= 5; i ++){ |
不难发现 最后的结果是五个6 那么为什么是五个6?如何实现输出12345?
- 首先是事件循环机制 同步任务执行完之后再去执行任务栈中的宏任务微任务,settimeout是宏任务,因此循环结束后它的回调才依次执行。
- 因为settimeout是一种闭包,往上查找它的父级作用域是window,而变量i是var声明,是window对象上面的全局变量,所以开始执行settimeout的时候i已经是6了 所以最后连续输出的都是6
那么如何依次输出12345呢?
- 利用IIFE
可以利用IIFE 每次for循环的时候把此时的变量传递到定时器里面,然后执行。
1
2
3
4
5
6
7for(var i=0;i<=5;i++){
(function(j){
setTimeout(()=>{
console.log(j)
},0)
})(i);
} - 利用es6的let
let有块级作用域,代码以块级执行 相当于任务每次都是等待块级执行完再执行下一个块级
1
2
3
4
5for(let i = 1; i <= 5; i ++){
setTimeout(function() {
console.log(i)
}, 0)
} - 利用定时器传入第三个参数
定时器的第三个参数是传入的值,可以是一个function
1
2
3
4
5for(var i = 1; i <= 5; i ++){
setTimeout(function(j) {
console.log(j)
}, 0,i)
}
New的原理
常见考点
- new做了什么事情?
- new返回不同的类型时有声明表现?
- 手写new的实现过程
new关键字的主要作用就是执行一个构造函数,返回一个实例对象,在new的过程中,根据构造函数的情况,来确定是否可以接收参数的传递。下面见例子。
1 | function Person(){ |
p是通过person这个构造函数生成的一个实例对象。
new可以帮助我们构建出一个实例,并且绑定上this,执行的步骤为以下:
- 创建一个新的对象
- 将对象连接到构造函数原型上,并绑定this this指向新对象
- 执行构造函数的代码(为这个新对象添加属性)
- 返回新对象
在第四步返回新对象的时候会有一个情况例外:
如果不用new关键字会怎么样?
1 | function Person(){ |
- 从上面的代码可以看出,不用new 结果是undefined。因为默认情况下this指向window 所以name是Jack
- 那么如果构造函数中return一个对象,结果会是怎么样呢?
1 | function Person(){ |
通过这段代码可以看出,当构造函数最后return出来的是一个和this无关的对象的时候,new会直接返回这个对象,而不是通过new执行步骤生成的this对象
但如果这里构造函数返回的不是一个对象 还是会按照new的原则返回新生成的对象。
1 | function Person(){ |
总结:new关键字执行之后返回一个对象,要么是实例对象,要不是return语句指定的对象
手写new的实现过程
1 | function create(fn,...args){ |
- 使用
Object.create
创建一个空对象并将obj的proto指定为构造函数原型 - 使用apply方法 将构造函数内的this指向为obj
- 在create返回的时候使用三目运算符决定返回结果