[置顶] 深入理解ECMAScript中的声明提升、this关键字及作用域(链)

时间:2022-10-17 00:42:37

声明提升

大部分编程语言都是先声明变量再使用,但在JavaScript中,事情有些不一样:

console.log(a);//undefined
var a = 0;
上面是合法的JavaScript代码,正常输出undefined而不是报错Uncaught ReferenceError: a is not defined。为什么?就是因为声明提升(hoisting)。

1.1 变量声明

参考: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var

语法:

var varname1 [= value1 [, varname2 [, varname3 ... [, varnameN]]]]
变量名可以是任意合法标识符;值可以是任意合法表达式。

重点:

  • 变量声明,不管在哪里发生,都会在任意代码执行前处理;
  • 声明的变量的作用域就是当前执行上下文,即某个函数或者全局作用域;
  • 赋值给未声明的变量,当执行时会隐式创建全局变量;
声明的变量和未声明的变量的区别:
  • 声明的变量通常是局部的,未声明的变量通常是全局的;
  • 声明变量在任意代码执行前创建,未声明的变量直到赋值时才存在;
  • 声明的变量是执行上下文(function/global)的non-configurable属性,未声明的变量则是configurable;
  • 在es5 strict mode,赋值给未声明的变量将报错;

1.2 定义函数

定义一个函数有两种方式:函数声明和函数表达式

函数声明:

语法:function name(arguments){}

函数表达式:

语法:var fun = function(arguments){}

函数表达式中函数可以不需要名字,即匿名函数。

其它

还可以用Function构造函数来创建函数。

语法:var function_name = new Function(arg1, arg2, ..., argN, function_body);

在函数内部引用函数本身有3中方式。比如var foo = function bar(){};

  • 函数名字,即bar();
  • arguments.callee();
  • foo();

1.3 声明提升

声明的变量会在任意代码执行前处理,这意味着在任意地方声明变量都等同于在顶部声明-即声明提升。生命提升中,需要综合考虑一般变量和函数。

在JavaScript中,一个变量名进入作用域的方式有四种:

  1. Language-defined:所有的作用域默认都会给出this和arguments两个变量名(global没有arguments);
  2. Formal parameters(形参):函数有形参,形参会添加到函数的作用域中;
  3. Function declarations(函数声明):如function foo(){};
  4. Variable declarations(变量声明):如var foo,包括函数表达式;
函数声明和变量声明总是会被移动(即hoist)到它们所在的作用域的顶部(这对你是透明的)。 而变量的解析顺序(优先级),与变量进入作用域的4中方式的顺序一致。 一个详细的例子:
function testOrder(arg) {
console.log(arg); //arg是形参,不会被重新定义
console.log(a); //因为函数声明比变量声明优先级高,所以这里a是函数
var arg = 'hello'; //var arg;变量声明被忽略,arg = 'hello'被执行
var a = 10; //var a;被忽视;a = 10被执行,a变成number
function a() {
console.log('fun');
} //被提升到作用域顶部
console.log(a); //输出10
console.log(arg); //输出hello
};
testOrder('hi');
> hi
function a() {
console.log('fun');
}
10
hello
变量进入函数作用域的顺序为this,arguments --> 形参 --> 函数声明 --> 变量声明。如果重新定义与形参名字相同的变量,则变量声明部分会被忽略,形参会被重新赋值。代码执行前,声明提升部分已完成,函数的声明会比变量的声明先进入作用域。所以,在变量赋值前打印变量,对于函数则输出函数声明(包括函数体),对于变量则输出undefined。

this关键字

this关键字是JavaScript中最令人疑惑的机制之一。this是非常特殊的关键词标识符,在每个函数的作用域中被自动创建,但它到底指向什么(对象),很多人弄不清。
当函数被调用,一个activation record(即execution context)被创建。这个record包含信息:函数在哪调用(call-stack),函数怎么调用的,参数等等。record的一个属性就是this,指向函数执行期间的this对象。
  • this不是author-time binding,而是runtime binding(即this的值不是在代码解释阶段确定的,而是在函数被调用时确定的);
  • this的上下文基于函数调用的情况。和函数在哪定义无关,而和函数怎么调用有关。

this在具体情况下的分析

2.1 Global context

在全局上下文,this指向全局对象。

console.log(this === window);    //true

2.2 Function context

在函数内部时,this由函数怎么调用来确定。

2.2.1 Simple call

简单调用,即独立函数调用。由于this没有通过call来指定,且this必须指向对象,那么默认就指向全局对象。

function f1(){
return this;
}

f1() === window; // global object
在严格模式下,this保持进入execution context时被设置的值。如果没有设置,那么默认是undefined。它可以被设置为任意值(包括null/undefined/1等等基础值,不会被转换为对象)。

function f2(){
"use strict";
return this;
}

console.log(f2() === undefined); //true

2.2.2 Arrow functions

在箭头函数中,this有词法/静态作用域设置(set lexically)。它被设置为包含它的execution context的this,并且不再被调用方式影响(call/apply/bind)。

var globalObject = this;
var foo = (() => this);
console.log(foo() === globalObject); //true

//Call as a method of a object
var obj = {foo: foo};
console.log(obj.foo() === globalObject); //true

//Attempt to set this using call
console.log(foo.call(obj) === globalObject); //true

//Attempt to set this using bind
foo = foo.bind(obj);
console.log(foo() === globalObject); //true

2.2.3 As an object method

当函数作为对象方法调用时,this指向该对象。

var o = {
prop: 37,
f: function() {
return this.prop;
}
};
console.log(o.f()); //logs 37
this on the object's prototype chain

原型链上的方法跟对象方法一样,作为对象方法调用时this指向该对象。

2.2.4 构造函数

在构造函数(函数用new调用)中,this指向要被constructed的新对象。

2.2.5 call和apply

Function.prototype上的call和apply可以指定函数运行时的this。

function add(c, d) {
return this.a + this.b + c +d;
}

var o = {a:1, b:3};
add.call(o, 5, 7); // 1 + 3 + 5 + 7 = 16
add.apply(o, [10, 20]); // 1 + 3 + 10 + 20 = 34
注意,当用call和apply而穿进去作为this的不是对象时,将会调用内置的ToObject操作转换成对象。所以4将会转换成new Number(4),而null/undefined由于无法转换成对象,全局对象将作为this。

2.2.6 bind


ES5引进了Function.prototype.bind。f.bind(someObject)会创建新的函数(函数体和作用域与原函数一致),但this被永久绑定到someObject,不论你怎么调用。

2.2.7 As a DOM event handler

this自动设置为触发事件的dom元素。

作用域(Scope)和闭包(closure)

3.1 Scope是什么?

先尝试从几个方面描述下:

  • Scope这个术语被用来描述在某个代码块可见的所有实体(或有效的所有标识符),更精准一点,叫做上下文(context)或环境(environment)。
  • 当前执行的上下文(The current context of execution)。
综合一下,Scope即上下文,包含当前所有可见的变量。 Scope分为Lexical Scope和Dynamic Scope。Lexical Scope正如字面意思,即词法阶段定义的Scope。换种说法,作用域是根据源代码中变量和块的位置,在词法分析器(lexer)处理源代码时设置。 让我们考虑下面的代码来分析Lexical Scope:
function foo(a) {
// inner scope 'foo'
// defined argument a, and look-up b upwards
console.log( a + b );
}

// outmost/global scope
// defined b
var b = 2;
foo(2); //4
Scope是分层的,内层的Scope可以访问外层Scope的变量,反之则不行。上面的代码中即有嵌套Scope。
Scope在我们写代码的时候就被定义好了,比如谁嵌套在谁里面。

3.2 JavaScript Scope

JavaScript采用Lexical Scope。
于是,我们仅仅通过查看代码(因为JavaScript采用Lexical Scope),就可以确定各个变量到底指代哪个值。 另外,变量的查找是从里往外的,直到最顶层(全局作用域),并且一旦找到,即停止向上查找。所以内层的变量可以shadow外层的同名变量。

3.2.1 Cheating Lexical

如果Scope仅仅由函数在哪定义的决定(在写代码时决定),那么还有方式更改Scope吗?JavaScript有eval和with两种机制,但两者都会导致代码性能差。

3.2.1.1 eval

eval接受字符串为参数,把这些字符串当做真的在程序的这个点写下的代码--意味着可以编码方式来在某个点生成代码,就像真的在程序运行前在这里写了代码。
function foo(str, a) {
eval( str ); // cheating
console.log( a, b );
}

var b = 2;
foo("var b = 3;", 1); // 1, 3
默认情况下,eval会动态执行代码,并改变当前Scope。但非直接(indirectly)调用eval可以让代码执行在全局作用域,即修改全局Scope。
function bar(str) {
(0, eval)(str); // cheating in global!
}
bar("var hello = 'hi'");

window.hello; //"hi"
另外,严格模式下,eval运行在它自己的Scope下,即不会修改包含它的Scope。
function foo(str) {
"use strict";
eval(str);
console.log(a); // ReferenceError: a is not defined
}

foo("var a = 2");

3.2.1.2 with

function foo(obj) {
with(obj){
a = 2;
}
}

var o1 = {
a: 3
};

var o2 = {
b: 3
};

foo(o1);
console.log(o1.a); // 2

foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2 -- Oops, leaked global!
with以对象为参数,并把这个对象当做完全独立的Lexical Scope(treats that object as if it is a wholly separate lexical scope),然后这个对象的属性就被当做定义的变量了。
注意:尽管把对象当做Scope,var定义的变量仍然scoped到包含with的函数中。 不像eval可以改变当前Scope,with凭空创建了全新的Scope,并把对象传禁区。所以o1传进去时可以正确更改o1.a,而o2传进去时,创建了全局变量a。

3.3 Dynamic Scope?

JavaScript没有Dynamic Scope。JavaScript中的this机制跟Dynamic Scope很像,都是运行时绑定。

3.4 Function vs. Block Scope

JavaScript没有Block Scope
除了Global Scope,只有function可以创建新作用域(Function Scope)。不过这已经是老黄历了,ES6引入了Block Scope。
{
let x = 0;
}
console.log(x); // Uncaught ReferenceError: x is not defined
另外,with和try catch都可以创建Block Scope
try {
undefined(); // illegal operation to force an exception!
}
catch(err){
console.log(err); //works!
}

console.log(err); // ReferenceError: 'err' is not defined