作用域
来看看我们的面试题 01
var b = 1;
function fn1() {
console.log(b); // ?
}
function fn2() {
var b = 2;
fn1();
}
fn2();
这道题考的就是作用域的概念,我们要做出这道题,就需要深入的理解作用域
作用域名词解释
首先来看看作用域
的名词解释
作用域是一个 规则,它定义了当前作用域及嵌套子作用域如何查找变量的规则
用我们实际中常用到的讲解就是当前函数可以访问当前函数内部的变量和外部变量,但在外部无法直接读取函数内部变量
也就是说作用域是一个规范是一个规则,它并不是一个当前作用域可以访问的数据集合
作用域生成位置
我们再来一部一部分析面试题 01,当浏览器拿到这段字符串的时候,肯定不能直接执行fn2
,它会将这段字符串编译成可执行的代码
拿v8引擎
举列,它首先会对代码进行词法分析
上面代码太长,我们只取第一行进行理解
var b = 1;
首先将这段字符串词法分析转换为记号流
然后进行语法分析形成AST
抽象语法树
词法和语法分析可以查看这个网站
然后js
进行预编译,这个时候便确定了作用域,大家要注意此时还在编译阶段,js
并没有进行执行,列子中的fn2
也没有执行
所以通过编译分析,我们知道作用域是在代码执行前就已经被确定了的
我们再来看看此时经过编译后的面试题 01,此时函数fn1
和fn2
的作用域规则被确定
fn1
的作用域规则是它只能访问fn1
中定义的变量和全局变量
fn2
的作用域规则是它只能访问fn2
中定义的变量和全局变量
当然为了解出这道题,光了解作用域是不够的,因为作用域的确定是在编译阶段,代码压根没执行怎么知道答案呢?
这里就要牵扯到另外一个概念作用域链
作用域链
同样的我们在理解作用域链
前,来看看它的名词解释
作用域链是由当前作用域的变量和上层可访问作用域的变量链式连接而成的数据集合
作用域链和作用域其实是容易混淆的,但它们有本质的区别
作用域是一个规则,它在js
的编译中确定
作用域链是一个集合,它在函数的执行中确定
换句话来说,作用域链通过作用域规则的定义形成一个可访问的变量集合[[ scope chain ]]
我们来看看fn2
执行的时候,作用域链长啥样
- fn2
- 内部变量 b = 2
- 全局变量 b = 1
- 全局变量 fn1
- 全局变量 fn2
- 其它全局变量
同样的fn2
内部执行了fn1
,当执行到fn1
的时候,fn1
的作用域链也被确定
- fn1
- 没有内部变量
- 全局变量 b = 1
- 全局变量 fn1
- 全局变量 fn2
- 其它全局变量
可以看到fn1
执行时能访问的b
的值仅有全局变量b
,虽然fn1
是在fn2
内部执行,但能访问的只有全局变量b
,所以这道题的答案是1
动态作用域
词法作用域还有一个别名叫静态作用域,因为作用域的确定并不是在执行阶段动态创建的,所以名字就相对取了个静态
有同学可能感觉到疑问,有静态作用域难道还有动态作用域吗
其实是有的,如果你写过自动发布的sh
脚本,shell
它就是动态的
我们将面试题 01 用shell
写出来如下
#!/bin/bash
b=1
fn1() {
echo $b
}
fn2() {
b=2
fn1
}
fn2
这道题的输出就是 2 而不是 1
执行上下文
执行上下文的阶段
我们来看面试题 02
这两者的执行栈区别是啥?
// 1.js
function fn1() {}
function fn2() {}
fn1();
fn2();
// 2.js
function fn1() {
function fn2() {}
fn2();
}
fn1();
这两个方法都是先执行了fn1
再执行了fn2
,看似相同,但它们的执行栈的推入和推出是不同的
js
有一个调用栈
的概念,每个栈存储的叫执行上下文
我们来看看执行上下文的名词解释
每次当控制器转到可执行代码的时候,就会进入一个执行上下文,执行上下文可以理解为当前代码的执行环境
js
中有三个执行上下文
- 全局执行上下文
- 函数执行上下文
- eval 执行上下文
每进入以上三个环境就会创建一个新的执行上下文,然后将执行上下文推入函数调用栈的顶端,当此执行上下文进行完毕,推出调用栈
我们来看看面试题 02 的前面部分
function fn1() {
debugger;
}
function fn2() {
debugger;
}
fn1();
fn2();
我们加上了断点执行这段代码,首先执行到fn1
时,触发debugger
,此时调用栈如下,可以看到调用栈顶端是fn1
函数,底部有一个匿名函数anomymous
其实就是全局执行上下文
执行完fn1
,fn1
被推出调用栈,此时执行fn2
,调用栈如下
我们再来看看面试题 02 的后半部分
function fn1() {
debugger;
function fn2() {
debugger;
}
fn2();
}
fn1();
先执行fn1
,触发debugger
,此时调用栈推入fn1
然后执行fn2
,触发debugger
,此时调用栈推入fn2
然后fn2
执行完毕,推出fn2
,fn1
执行完毕,推出fn1
这就是两者在调用栈上的区别,虽然看似都是执行的fn1
和fn2
,调用栈的推入推出确实不一样的
变量提升
我们再来看看面试题 03
// 1
console.log(a); // ?
var a;
function a() {}
// 2
function b() {}
var b;
console.log(b); // ?
// 2
var c = 1;
function c() {}
console.log(c); // ?
这套题的考点在变量提升
根据上面的讲解我们知道,每进入一个函数会生成一个执行上下文
而执行上下文的生成又分为两个阶段,一个是创建阶段,一个是执行阶段
创建阶段有三个步骤
- 生成变量对象(变量提升)
- 生成作用域链
- 确定 this 指向
执行阶段也有三个步骤
- 变量赋值
- 函数引用
- 执行其他代码
解决这个问题我们需要首先了解执行上下文创建阶段的第 1 个步骤变量提升
变量提升首先会找到当前执行上下中需要提升的变量并在当前执行上文中提升
这里需要提升的变量指的就是var定义的变量
和function定义的函数
但这两个也有先后顺序,会首先找到函数并将整个函数存入内存中
注意此时并没有对函数进行引用,换句话来说只是开辟了内存地址,函数并没有被赋值
然后寻找var
定义的变量,将它赋值为undefined
注意此时并没有对var
定义的相关变量进行具体赋值,而是赋值为默认的undefined
这里会存在一个问题,我们知道function
的提升在var
之前,如果function
已定义了一个变量,var
又去提升这个变量为默认的undefined
不是重置了之前已经定义好的function
了吗
其实这里var
提升有一个规则,当发现这个变量已存在function
定义时,会跳过默认的定义为undefined
这个步骤
我们来看面试题 03 的第 1 和第 2 部分
// 1
console.log(a); // ?
var a;
function a() {}
// 2
function b() {}
var b;
console.log(b); // ?
这两个的意思其实是一样的,它们首先都会进行变量提升,function
定义被首先提升,然后遇到var
,此时已经存在了function
所以var
定义默认undefined
这个步骤跳过
这里的输出便是function a
和function b
我们再来看面试题 03 第 3 部分
// 3
var c = 1;
function c() {}
console.log(c);
同样的function
被提升,然后提升var
此时已经定义好了function
,所以默认undefined
步骤跳过
同样和之前要加深的一样注意,这里并没有进行赋值,只是进行了提升
当执行上下文执行的时候才会赋值,此时c
被赋值为了1
,所以这里的答案是1
大家可能会有疑问,为啥上面的var a
这种写法不是赋值为undefined
,其实这根本不是一个赋值语句,这只是一个简单的定义,像var c = 1
这才会触发赋值
我们再来看看面试题 04
// 4
var a = 'a';
function fna() {
console.log(a); // ?
var a = 'aa';
console.log(a); // ?
}
fna();
这套题的考点在于我们之前强调的一句话
变量提升首先会找到当前执行上下中需要提升的变量并在当前执行上文
中提升
变量的提升仅提升到当前执行上下文
首先我们执行fna
,然后创建fna
的执行上下文,此时第一步进行变量提升,a
变量提升并赋值为默认的undefined
所以第一个console
打印的是undefined
然后a
被进行赋值,此时打印的便是aa
this 指向
我们来看面试题 05
function fn1() {
console.log(this);
}
// 1
fn1(); // ?
// 2
new fn1(); // ?
// 3
fn1.apply({ a: 1 }); // ?
fn1.bind({ a: 1 }).call({ a: 2 }); // ?
fn1.bind({ a: 1 }).bind({ a: 2 }).call({ a: 3 }); // ?
const obj1 = {
fn() {
console.log(this);
},
};
// 4
obj1.fn(); // ?
const fn2 = obj1.fn;
fn2(); // ?
我们上面讲过,this
的确定是在执行上下文创建的时候
换句话来说,this
的指向取决于函数的调用更取决于函数是怎么调用的
函数的调用分为 4 种情况,所以this
的指向也对应 4 种情况
- 当我们直接调用函数时,此时
this
指向全局window
- 当我们对构造函数用
new
操作符执行的时候,此时this
指向当前构造函数实例 - 当我们调用
apply
bind
call
方法的时候,this
指向方法的第一个参数,当然他们也是有优先级的,bind
的优先级高于call
和apply
,且bind
第一次后再bind
的this
无效 - 当函数在一个对象上调用的时候,
this
指向当前对象
我们再来看看面试题
第 1 个位置直接调用的fn1
,此时对应第一种情况,输出的this
就是全局window
第 2 个位置new
了一个构造函数,此时对应第二种情况,输出的this
指向当前构造函数实例
第 3 个位置我们调用了apply
和bind
方法,此时对应第三种情况,因为bind
的优先级最高,所以即使bind
了很多次也只取第一次bind
的对象,所以第 3 个位置所有的this
输出都是{ a: 1 }
第 4 个位置我们在对象obj1
上调用了fn
,此时对应第四种情况,输出的this
为对象obj1
但第 4 个位置还通过obj1
直接读取了fn
赋值给了一个变量fn2
,此时直接执行fn2
对应的情况是第一种,所以输出全局window