引言
之前总结了浏览器的异步处理和EventLoop机制,对于js代码的执行机制有了一定的了解,但是仍存在一定的疑惑,比如:js代码在call stack中调用的时候,压入栈中的执行上下文究竟是什么呢?函数内部的变量从哪里寻找呢?词法环境是什么呢?
执行上下文是什么
之前介绍js执行机制的时候提到过,js代码在执行时会先被压入执行栈,执行完毕之后就会被弹出执行栈,而存放在执行栈中的内容,就是执行上下文。
//示例代码:
var name;
function f2(){
name ='yzh';
console.log(name);
}
function f1(){
f2()
}
f1()
整个代码执行过程:
- 开始执行代码之前,先创建全局上下文压入栈底
- 创建词法环境,登记变量声明和函数声明(下面会详细说明词法环境)
- 运行到f1时,便把f1的执行上下文压入执行栈
- f1调用f2,便把f2的执行上下文压入执行栈
- f2调用console.log(name)函数,便把其也要入执行栈
- console执行完毕之后弹出执行栈
- f2执行完毕后弹出执行栈
- f1执行完毕后弹出执行栈
- 整个js代码执行完毕后,全局执行上下文也会被弹出
从上面可知:function自定义函数,console等js内置函数和整个js文件被看作全局执行上下文可以被压入执行栈,还有两种也可以:
- module:模块代码
- eval:放在eval的代码也会
上面这四种能放在执行上下文中的代码有一个统称,就是可执行代码(Executable)
执行上下文组成?
- 词法环境(Lexical Environment)
- 变量环境(Variable Environment)
- this指向(ThisBinding)
变量环境和词法环境的区别是什么呢?
前者用于登记var和function变量声明,后者用来登记let,const,class变量声明。
在ES6之前没有块级作用域,有这两个词法环境作用就是为了既能实现块级作用域,也能实现var的变量与函数声明,具体操作如下:
词法环境是什么
介绍词法环境之前,先看一下V8引擎如何解析js代码
-
V8引擎拿到执行上下文之后,会将代码逐行进行分词和词法分析
分词(Tokenizing)即将代码字符串拆分为一个个原子符号,词法分析(Lexing)即登记量声明、函数声明、函数声明的形参。
-
在分词结束以后,会做代码解析,引擎将 token 解析翻译成一个AST(抽象语法树),如果发现语法错误,就会直接报错不会再往下执行。
-
引擎生成CPU可以执行的机器码
在词法分析这一步中,就会把变量,函数等登记到词法环境中。
词法环境主要包含以下两方面:
-
环境记录 (变量登记的地方)
- 声明式环境记录(用来记录有标识符定义的元素,比如let,const,module,import以及函数声明)
- 函数环境记录(用于函数作用域)
- 模块环境记录
- 对象式环境记录(用于with和global的词法环境,全局函数和全局变量)
- 声明式环境记录(用来记录有标识符定义的元素,比如let,const,module,import以及函数声明)
-
对外部词法环境的引用(作用域链的关键,稍后介绍)
词法环境与我们自己写的代码结构相对应,也就是我们自己代码写成什么样子,词法环境就是什么样子。词法环境是在代码定义的时候决定的,跟代码在哪里调用没有关系。也即JavaScript采用的是静态作用域
作用域:是根据名称来查找变量的一套规则。前面说到了词法环境的确定是依据静态作用域,与之相对的动态作用域是在运行时确定的,静态作用域关心的是函数在何处声明,动态作用域关心的是函数从何处调用。js中this的实现就是采用动态作用域,所以this在js中只有执行了才能确定其指向。
举个例子:
var a = 2;
let x = 1;
const y = 5;
function foo() {
console.log(a); //2
function bar() {
var b = 3;
console.log(a * b);//2*3
}
bar();
}
function baz() {
var a = 10;
foo();
}
baz();
它的词法环境可以划分为下图:
使用伪代码模拟上面代码的词法环境:
// 全局词法环境
GlobalEnvironment = {
outer: null, //全局环境的外部环境引用为null
GlobalEnvironmentRecord: {
//全局this绑定指向全局对象
[[GlobalThisValue]]: ObjectEnvironmentRecord[[BindingObject]],
//声明式环境记录,除了全局函数和var,其他声明都绑定在这里
DeclarativeEnvironmentRecord: {
x: 1,
y: 5
},
//对象式环境记录,绑定对象为全局对象
ObjectEnvironmentRecord: {
a: 2,
foo:<< function>>,
baz:<< function>>,
...
}
}
}
//foo函数词法环境
fooFunctionEnviroment = {
outer: GlobalEnvironment,//外部词法环境引用指向全局环境
FunctionEnvironmentRecord: {
[[ThisValue]]: GlobalEnvironment,//this绑定指向全局环境
bar:<< function>>
}
}
//bar函数词法环境
barFunctionEnviroment = {
outer: fooFunctionEnviroment,//外部词法环境引用指向foo函数词法环境
FunctionEnvironmentRecord: {
[[ThisValue]]: GlobalEnvironment,//this绑定指向全局环境
b: 3
}
}
//baz函数词法环境
bazFunctionEnviroment = {
outer: GlobalEnvironment,//外部词法环境引用指向全局环境
FunctionEnvironmentRecord: {
[[ThisValue]]: GlobalEnvironment,//this绑定指向全局环境
a: 10
}
}
上面可以看到outer就是每层作用域之间的连接,当一个函数在其词法环境中没有找到要使用的变量时,就会根据outer到上一层的词法环境中寻找,直到到全局词法环境,outer为null,找不到就报错。
值得注意的是:outer和this指向不是一个东西,需要区分(虽说两者都有指向的意思...)
变量提升 vs 函数提升
所谓变量和函数提升,即V8引擎在进行词法分析时,会先将变量声明,然后赋值为undefined保存在其所属的词法环境中,对于函数,会在内存中创建函数对象,并且直接初始化为该函数对象。
举个例子:
var v='Hello World';
(function(){
alert(v);
})()
这个输出结果为Hello World没有什么问题
var v='Hello World';
(function(){
alert(v);
var v='I love you';
})()
这个会输出undefined
为什么呢?
我一开始认为其会输出Hello World,就是忽略了变量提升,认为虽然函数体内的v没有被赋值,但是全局变量v被赋值了,所以会输出Hello World
但其实函数体内的v由于变量提升,会被赋值为undefined,而函数在寻找变量v时,会先在自己的词法环境中寻找,于是就找到了值为undefined的v,故输出undefined
this
ECMAScript规范: 严格模式时,函数内的this绑定严格指向传入的thisArgument。非严格模式时,若传入的thisArgument不为undefined或null时,函数内的this绑定指向传入的thisArgument;为undefined或null时,函数内的this绑定指向全局的this。
而thisArgument是在调用时才能确定的。
下面针对不同的情况,说明一下传入的thisArgument:
- 普通函数调用,thisArgument就是undefined
- 对象函数调用(也即作为对象的方法调用),thisArgument就是该对象
- 构造函数调用,thisArgument就是构造出的实例化对象
- call,apply,bind调用,显示的传递thisArgument
- call:
- apply
- bind
- 箭头函数调用
箭头函数调用时不会绑定this,他会去词法环境链上寻找this,所以箭头函数的this取决于它定义的位置,也即箭头函数会跟包裹着它的作用域共享一个作用域。但是箭头函数的this指向不能视作静态的,因为其共享的作用域的this指向是动态的。
所以确定箭头函数this指向需要两步:
- 找到其共享的作用域
- 确定该作用域的this指向
//所以对于箭头函数使用call显示绑定thisArgument没有用
//test1.js
var a=10;
const test =()=>{
console.log(this.a)
}
test() //10
test().call({a:20}) //10
function testArrow(){
var a=20;
test();
}
testArrow() //10
//test2.js
var a =10;
function testArrow(){
var test =()=>{
console.log(this.a)
};
test();
}
testArrow() //10
//test3.js
var a =10;
let obj ={
a:20,
testArrow:testArrow
}
function testArrow(){
var test =()=>{
console.log(this.a)
};
test();
}
obj.testArrow() //20
- 回调函数调用
window.a = 10
let obj = {
a: 20,
foo: function () {
console.log(this.a)
}
}
setTimeout(obj.foo, 0) //10
作为回调函数时,传递的是函数体,而不是函数名(引用类型...),obj.foo已经执行完成被弹出执行栈,此时执行栈中只有全局上下文,所以setTimeout函数体执行的时候this为全局对象。
避免这种情况可以采用两种方法:
- 使用bind绑定this
- 使用箭头函数
window.a = 10
function foo() {
return () => {
console.log(this.a) //该箭头函数与foo函数的作用域绑定
}
}
const arrowFn = foo.call({a: 20})
arrowFn() // 20
setTimeout(arrowFn, 0) //20
后记
理清楚了这些概念之后,以后再看一些文档碰到这些名词就没那么懵逼了
当然,可能对于某些概念的理解仍然存在问题,欢迎各位指正。