creeperyang / blog

前端博客,关注基础知识和性能优化。
MIT License
2.63k stars 211 forks source link

深入理解JS中声明提升、作用域(链)和`this`关键字 #16

Open creeperyang opened 8 years ago

creeperyang commented 8 years ago

这个issue试图阐述JavaScript这门语言的3个难点:声明提升作用域(链)this

首先推荐https://github.com/getify/You-Dont-Know-JS,这是一本非常棒的JavaScript书籍,几乎所有的JS知识点都包括并且详细解释了。看一遍相信必有大收获。

1. 声明提升

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

console.log(a); // undefined
var a = 1;

上面是合法的JS代码,正常输出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]]]];

变量名可以是任意合法标识符;值可以是任意合法表达式。

重点:

声明变量和未声明变量的区别

es5 strict mode,赋值给未声明的变量将报错。

1.2 定义函数(Defining functions

定义一个函数有两种方式:函数声明(function definition/declaration/statement)和函数表达式( function expression)。

1.2.1 function definition

语法:function name(arguments) {}

对参数而言,primitive parameter是传值,对象是传引用。

1.2.2 function expression

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

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

1.2.3 其它

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

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

1.1提到,var 声明的变量会在任意代码执行前处理,这意味着在任意地方声明变量都等同于在顶部声明——即声明提升。1.2特意强调了函数定义,因为声明提升中,需要综合考虑一般变量和函数。

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

  1. Language-defined:所有的作用域默认都会给出 thisarguments 两个变量名(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 
*/
creeperyang commented 8 years ago

2. this

this关键词是JavaScript中最令人疑惑的机制之一。this是非常特殊的关键词标识符,在每个函数的作用域中被自动创建,但它到底指向什么(对象),很多人弄不清。

当函数被调用,一个activation record(即 execution context)被创建。这个record包涵信息:函数在哪调用(call-stack),函数怎么调用的,参数等等。record的一个属性就是this,指向函数执行期间的this对象。

2.1 this在具体情况下的分析

2.1.1 Global context

在全局上下文(任何函数以外),this指向全局对象。

console.log(this === window); // true
2.1.2 Function context

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

2.1.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"; // see strict mode
  return this;
}

f2() === undefined;
2.1.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.1.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.1.2.4 构造函数

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

2.1.2.5 call和apply

Function.prototype上的callapply可以指定函数运行时的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

注意,当用callapply而传进去作为this的不是对象时,将会调用内置的ToObject操作转换成对象。所以4将会装换成new Number(4)null/undefined由于无法转换成对象,全局对象将作为this

2.1.2.6 bind

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

2.1.2.7 As a DOM event handler

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

creeperyang commented 8 years ago

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

在第2部分对this的探讨中,我们已经部分涉及到了作用域,只是没有展开说,或者从作用域角度来说。

3.1 Scope是什么?

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

综合一下,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可能好理解一点:

fig2

  1. 泡泡1是全局作用域,有标识符foo
  2. 泡泡2是作用域foo,有标识符a,bar,b
  3. 泡泡3是作用域bar,仅有标识符c

Scope在我们写代码的时候就被定义好了,比如谁嵌套在谁里面。

3.2 JavaScript Scope

JavaScript采用Lexical Scope

于是,我们仅仅通过查看代码(因为JavaScript采用Lexical Scope),就可以确定各个变量到底指代哪个值。

另外,变量的查找是从里往外的,直到最顶层(全局作用域),并且一旦找到,即停止向上查找。所以内层的变量可以shadow外层的同名变量。

3.2.1 Cheating Lexical

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

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.1 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?

上一节讲到,JS采用Lexical Scope,这里再明确一下:

JavaScript没有Dynamic Scope。

那么为什么又单开一节讲一下?

一是强调,二是,JS中的this机制跟Dynamic Scope很像,都是运行时绑定。

3.4 Function vs. Block Scope

上面的内容有意无意似乎应该表明了,JS没有Block Scope。

除了Global Scope,只有function可以创建新作用域(Function Scope)。 不过这已经是老黄历了,ES6引入了Block Scope。

{
    let x = 0;
}
console.log(x); // Uncaught ReferenceError: x is not defined

另外,withtry catch都可以创建Block Scope。

try {
    undefined(); // illegal operation to force an exception!
}
catch (err) {
    console.log( err ); // works!
}

console.log( err ); // ReferenceError: `err` not found
cdll commented 8 years ago

长文必火!前排挤挤~

yernsun commented 8 years ago

男神起飞

For-me commented 8 years ago

_围观男神装逼!!!!_吓得我打字都歪了

Power-kxLee commented 8 years ago

不明觉厉

keifergu commented 8 years ago

写的好,感觉理解又深了一些

moahmn commented 8 years ago

文章很好啊谢谢

oychao commented 8 years ago

为何要发到issue里面,直接写成文档多好~

creeperyang commented 8 years ago

@CharlesOy 写issue里可以互动啊 😃

lilywang711 commented 7 years ago

好棒~,很详细。复习了一遍,查缺补漏理解更深入了,谢谢 ~

liu3042 commented 7 years ago

那如果函数的形参和函数声明的名字一样,该如何理解呢?

function test(arg){
    console.log(arg);  // [function : arg]
    var arg = 'hello';
    function arg(){
        console.log('hello world') 
    }
    console.log(arg);    // hello
}
test('hi');
creeperyang commented 7 years ago

@liu3042 可以看 变量的解析顺序(优先级),与变量进入作用域的4种方式的顺序一致。 相关部分。

function test(arg){
        // 1. 形参 arg 是 "hi"
        // 2. function arg声明提升,arg 是 function
    console.log(arg);  // [function : arg]
    var arg = 'hello';
        // 3. arg 此时是 "hello"
    function arg(){
        console.log('hello world') 
    }
    console.log(arg);    // hello
}
test('hi');
oychao commented 7 years ago

@liu3042 ,你的代码等价于:

function test(arg){
  var arg;
  arg = function(){
    console.log('hello world');
  }
  console.log(arg);
  arg = 'hello';
  console.log(arg);
}
test('hi');
jackchoumine commented 7 years ago

好文,然而上下文和作用域并不是同一个概念。

creeperyang commented 7 years ago

@JackZhouMine

2017-04-10 1 42 39

上面是MDN对Scope的定义,第一句就是 The current context of execution

The scope of a variable declared with var is its current execution context, which is either the enclosing function or, for variables declared outside any function, global. If you re-declare a JavaScript variable, it will not lose its value.

上面是 var 相关部分截取的,可以看到MDN这样定义:一个声明的变量的作用域,是它当前的执行上下文。

所以我觉得把 execution context 和 scope 等同起来理解没有问题(从实际使用来说,或者说至少从理解作用域这些来说没有问题)。

比如 http://davidshariff.com/blog/what-is-the-execution-context-in-javascript/ 这篇文章也把两者等同。

但上下文和作用域是不是同一个概念?我觉得这要看定义。有些规范上这两个概念应该是有区别的,所以你的话没有错。


补充:

http://stackoverflow.com/questions/7493936/is-there-a-difference-between-the-terms-execution-context-and-scope 是一个不错的解释。

creeperyang commented 7 years ago

稍微看了下ES6规范 Executable Code and Execution Contexts,可以看到,scope和规范中的 Lexical Environment 比较接近,而Execution Context要超出scope的概念,但scope是其重要的组成部分。

2017-04-10 4 59 21

Lexical Environment

Lexical Environment 是规范里的一种类型,用于定义 Identifiers 和 指定variables/functions 的关联(基于代码的词法嵌套结构lexical nesting structure of ECMAScript code)。

Lexical Environment由 Environment Record 和 一个可能为null的指向外层Lexical Environment的引用 组成。

通常,Lexical Environment和ECMAScript代码的特定词法结构由关,比如FunctionDeclaration, BlockStatement, Catch clause of a TryStatement,一旦这种代码执行,新的Lexical Environment被创建。

一个 Environment Record 记录了它所属 Lexical Environment 的 scope 内的 identifier bindings。

Environment Record 有两种基本的类别:declarative Environment Records 和 object Environment Records。

  1. Declarative Environment Records

    一个 Declarative Environment Record 对应一个 ECMAScript program scope (scope包含 variable, constant, let, class, module, import, and/or function declarations)。Declarative Environment Record绑定了这个scope里所有声明定义的identifiers。

  2. Object Environment Records

  3. Function Environment Records

    一个 function Environment Record 是用于表示一个函数的顶层scope的 Declarative Environment Record,并且,如果这个函数不是箭头函数,还提供 this 的绑定。

  4. Global Environment Records

Execution Context

An execution context is a specification device that is used to track the runtime evaluation of code by an ECMAScript implementation.

Execution Context的组成:

Component Purpose
code evaluation state Any state needed to perform, suspend, and resume evaluation of the code associated with this execution context.
Function ...
Realm ...
LexicalEnvironment Identifies the Lexical Environment used to resolve identifier references made by code within this execution context.
VariableEnvironment Identifies the Lexical Environment whose EnvironmentRecord holds bindings created by VariableStatements within this execution context.
Generator The GeneratorObject that this execution context is evaluating.
creeperyang commented 7 years ago

发现第 3 点中闭包被漏掉了,这里补上。

闭包(closure)

A closure is the combination of a function and the lexical environment within which that function was declared.

在 JavaScript 中,函数形成闭包。闭包就是函数和函数声明时的词法作用域的组合。

下面用例子说明:

function outer () {
  var name = 'Jerry'
  function inner () {
    console.log(name)
  }
  return inner
}

var fn = outer()
fn() // Jerry

在部分编程语言中,函数内部的局部变量仅仅存在于函数执行期间,一旦函数执行完毕,变量就销毁(不再能访问)。

但在 JavaScript 中,由于闭包的原因,fn 仍可以访问 name 变量。

leirt97 commented 7 years ago

闭包就是函数和函数声明的词法作用域的组合,这句话有点难懂...

creeperyang commented 7 years ago

函数和函数声明时的词法作用域形成闭包 ——这样可能好理解一点。

Hibop commented 6 years ago

大神您好,构造函数的call调用比较复杂 能请教下吗?

function add(c, d){
  this.d=d;  // set调用对象o
  return this.a + this.b + c + d;  // get调用对象o
}

var o = {a:1, b:3};
add.call(o, 5, 7);
console.log(o);
creeperyang commented 6 years ago

@Hibop 不是很明白你的问题。没有所谓的构造函数和非构造函数,在用new 调用函数时,函数就是构造函数。

这里 add.call(o, 5, 7); 时,this 指向 o,就是这样的。

HarryLit commented 5 years ago

写得很好,赞一下作者。

hrpc commented 5 years ago

大佬好! 关于第一点,声明提升中的函数声明提升,有一个这个问题

console.log(fn)
fn()
if (true) {
  function fn() {
    console.log('fn inner')
  }
}
// undefined
// TypeError:fn is not a function

把函数fn的调用移到if块里面,就正常控制台输出fn inner;

严格模式下 console.log(fn)这一行代码也要报错,提示fn is not defined,也就是说函数名的提升只到了if块里面。

这个情况我理解的是函数提升的时候是分为两步的,先是把函数名定义提升到广义的作用域顶部,然后再把函数体的定义提升到狭义的作用域顶部。

这里解释一下我自己定义的广义的作用域和狭义的作用域,广义的指的是ES6之前的作用域,即全局作用域和函数作用域,狭义的除了广义的作用域之外加上了这个if,for等块级作用域。

大佬看我这个理解是否正确呢?

creeperyang commented 5 years ago

@hrpc 你注意到ES6开始作用域有所不同,这是对的。

853210384 commented 4 years ago

你是哪里知道我的邮箱的

---原始邮件--- 发件人: "西门吹喵"<notifications@github.com> 发送时间: 2020年4月4日(周六) 晚上10:33 收件人: "creeperyang/blog"<blog@noreply.github.com>; 抄送: "Subscribed"<subscribed@noreply.github.com>; 主题: Re: [creeperyang/blog] 深入理解JS中声明提升、作用域(链)和this关键字 (#16)

有个疑问,请求大佬解惑!:关于作用域链的确认的

function a(){ let num = 0; return function(){ console.log(++num) } } const b =a() b() 当我们调用这个函数a的时候,会生成函数a的执行上下文,然后先预编译(我不知道这个“预编译”用的准确不?),然后创建了这个函数执行上下文的变量对象(变量num,不知道这个里面有没有匿名函数🤔️,return的值在这个阶段咋个处理?🤔️)和作用域链(a函数的作用域和=》全局作用域),创建完毕。再执行里面的代码。

当我们执行函数b()的时候,上面同理,然后创建作用域链(函数b =》 函数a =》 全局)。 提问:这里并不是因为函数b里面引用了a的变量count才将函数a放在这条作用域链上的,而是因为函数a嵌套了函数a,所以对于函数b的执行上下文里面的作用域链来说,他们在一条作用域链上,我这样理解对吗?🤔️

— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub, or unsubscribe.

qyang-pp commented 4 years ago

有个疑问,请求大佬解惑!:关于作用域链的确认的

function a(){ 
let num = 0; 
   return function(){ 
        console.log(++num)
   } 
} 
const b =a() 
b()

当我们调用这个函数a的时候,会生成函数a的执行上下文,然后先预编译(我不知道这个“预编译”用的准确不?),然后创建了这个函数执行上下文的变量对象(变量num,不知道这个里面有没有匿名函数🤔️,return的值在这个阶段咋个处理?🤔️)和作用域链(a函数的作用域和=》全局作用域),创建完毕。再执行里面的代码。

当我们执行函数b()的时候,上面同理,然后创建作用域链(函数b =》 函数a =》 全局)。 提问:这里并不是因为函数b里面引用了a的变量count才将函数a放在这条作用域链上的,而是因为函数a嵌套了函数a,所以对于函数b的执行上下文里面的作用域链来说,他们在一条作用域链上,我这样理解对吗?🤔️

creeperyang commented 4 years ago

有个疑问,请求大佬解惑!:关于作用域链的确认的

function a(){ 
let num = 0; 
   return function(){ 
        console.log(++num)
   } 
} 
const b =a() 
b()

当我们调用这个函数a的时候,会生成函数a的执行上下文,然后先预编译(我不知道这个“预编译”用的准确不?),然后创建了这个函数执行上下文的变量对象(变量num,不知道这个里面有没有匿名函数🤔️,return的值在这个阶段咋个处理?🤔️)和作用域链(a函数的作用域和=》全局作用域),创建完毕。再执行里面的代码。

当我们执行函数b()的时候,上面同理,然后创建作用域链(函数b =》 函数a =》 全局)。 提问:这里并不是因为函数b里面引用了a的变量count才将函数a放在这条作用域链上的,而是因为函数a嵌套了函数a,所以对于函数b的执行上下文里面的作用域链来说,他们在一条作用域链上,我这样理解对吗?🤔️

没什么问题