javascript之作用域与this指向

吐槽君 分类:javascript

最近一周一直在思考一个问题,javascript的作用域(scope)究竟是什么?我们到底该如何去定义它呢?为此查阅了很多文章,特地去买了《你不知道的javascript》阅读学习了一番,有所收获。

作用域

针对作用域是什么这个问题,结合了相关的资料,笔者主观的将作用域定义为:

从狭义的角度来讲,javascript作用域可以理解为变量,对象和函数的可访问范围。换句话说,作用域决定了代码中变量,对象以及函数的可访问性,作用域是一个独立的地盘,使得变量不会外泄。

function outFun() {
    var a = 2;
}
outFun(); 
console.log(a); // uncaught ReferenceError: a is not defined
 

从广义的角度来讲,作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用LHS查询(赋值操作的目标是谁);如果目的是获取变量的值,就会使用RHS查询(谁是赋值操作的源头)。javascript引擎首先会在代码执行前对其进行编译,在这个过程中,像var a = 2这样的声明会被分解成两个独立的步骤:

  1. 首先,var a在其作用域中声明新变量,这是在代码执行前进行的。
  2. 接下来,a = 2会查询(LHS查询)变量a并对其进行赋值。

LHS和RHS查询都会在当前执行作用域中开始,如果有需要就会向上一级作用域继续查找目标标识符,直到抵达全局作用域。但RHS和LHS的不同在于,不成功的RHS引用会导致抛出ReferenceError异常,不成功的LHS引用在非严格模式下会自动隐式的创建一个全局变量,改变量使用LHS引用的目标作为标识符,在严格模式下才会抛出ReferenceError异常。

词法作用域

词法作用域是指作用域由书写代码时函数或变量声明的位置来决定的,编译的词法分析阶段基本知道全部标识符在哪里以及是如何声明的。javascript采用的就是词法作用域,也称为静态作用域。

var value = 1;

function foo() {
    console.log(value);
}

function bar() {
    var value = 2;
    foo();
}

bar(); // 1
 

javascript中有两个机制可以“欺骗”词法作用域:eval(...)和with。eval()可以对一段包含一个或多个声明的代码直接执行,就好像代码是写在那个位置一样,并借此对所处的词法作用域进行修改。

function foo(str, a) {
  eval(str);
  console.log(a, b);
}
var b = 2;
foo("var b = 3;", 1); // 1, 3
 

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
 

可以注意到一个奇怪的现象,实际上a = 2的赋值操作创建了一个全局的变量a。这是因为尽管with块可以将一个对象处理为词法作用域,但是这个块的内部正常的var声明并不会被限制在这个快的作用域中,而是被添加到with所处的函数作用域中,在执行foo(o2)时,由于a = 2是LSH引用,在obj的词法作用域、foo(...)的作用域和全局作用域都没找到a标识符,因此a = 2执行时,自动创建了一个全局变量。

全局作用域与局部作用域

javascript的作用域分为全局作用域和局部作用域,局部作用域包含函数作用域与块级作用域。

全局作用域贯穿整个javascript文档,在任何地方都能访问到的对象拥有全局作用域。一般来说最外层函数和在最外层函数外面定义的变量、未声明直接赋值的变量以及所有window对象的属性拥有全局作用域。

局部作用域是指变量只能在函数或代码块的内部被访问。函数作用域是最常见的作用域单元,我们在任意代码片段外部添加包装函数,都可以将内部的变量和函数“隐藏起来”,外部作用域无法访问包装函数的任何内容。

但函数不是唯一的作用域单元,块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块,通常指{...}内部,ES6新增let和const关键字,所声明的变量属于块作用域,在指定块的作用域外无法被访问。

let/const声明的变量不会被提升到当前代码块的顶部,即不存在变量提升。实际上在let/const定义的变量首先会创建到TDZ,在初始化之前引用会直接报错。

function test(){
  console.log(a);
  let a;
}
test(); // Uncaught ReferenceError: Cannot access 'a' before initialization

function test1(){
  console.log(b);
  const b = 2;
}
test1(); // Uncaught ReferenceError: Cannot access 'b' before initialization

function test2(){
  let a;
  console.log(a);
}
test2(); // undefined

console.log(c);
let c; // Uncaught ReferenceError: c is not defined
 

实际上,块作用域还有其他的方式,上述提到的with关键字便是块作用域的一个例子,try/catch结构的catch分句也是具有块作用域的,其中声明的变量仅在catch内部有效。

try{
  undefined(); // 执行一个非法操作来抛出一个异常
}
catch(err) {
  console.log(err); // 能够正常执行 undefined is not a function
}

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

执行上下文

执行上下文(execution context)是评估和执行javascript代码的环境的抽象概念。每当javascript代码在运行的时候,它都是在执行上下文中运行的。我们可以将它理解为一个object,ES5中的执行上下文包括this绑定、词法环境组件(LexicalEnvironment component)和变量环境组件(VariableEnvironment component)。

ExcutionContext = {
  ThisBinding = <this value>,
  LexicalEnvironment = {...},
  VariableEnvironment = {...},
}
 

This Binding

  • 全局执行上下文中,this的值指向window对象,而在nodejs中指向这个文件的module对象。
  • 函数执行上下文中,this的值取决于函数的调用方式,后续将会介绍。

词法环境(Lexical Environment)

词法环境包括两个部分:

  1. 环境记录:存储变量和函数声明的实际位置
  2. 对外部环境的引用:可以访问其外部环境

词法环境有两种类型

  1. 全局环境:是一个没有外部环境的词法环境。其外部引用为null,拥有一个全局对象window及其关联的方法和属性以及任何用户自定义的全局变量
  2. 函数环境:用户在函数中定义的变量被存储在环境记录中,包括了arguments对象。对外部环境的引用为全局环境或包含内部函数的外部函数环境。

变量环境(Variable Environment)

变量环境也是一个词法环境,ES6中,词法环境和变量环境的区别在于前者用于存储函数声明和let/const声明的变量,后者仅用于存储var声明的变量。

执行栈,也就是其他语言所说的“调用栈”,是一种拥有LIFO(后进先出)数据结构的栈,用来存储代码运行时创建的所有执行上下文。每当引擎遇到一个函数调用,就会为该函数创建一个新的执行上下文并压入执行栈的顶部,由于javascript引擎是单线程的,它会执行那些执行上下文位于栈顶的函数,当函数执行结束后执行上下文从栈中弹出,开始执行栈中的下一个上下文。

javascript属于解释型语言,它的执行可以分为编译阶段和执行阶段,作用域和执行上下文最大的区别在于:作用域在定义时就确定了,并不会改变,即javascript在编译阶段便会确定作用域规则;执行上下文在运行时确定,随时可能改变。同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值。

作用域链

前面说过,作用域是根据标识符查找变量的一套规则,当一个块或函数嵌套在另一个块或函数中时,就发生了作用域嵌套,这样就形成了作用域链,引擎会根据作用域链来查找变量,从当前的执行作用域开始查找,如果找不到,就向上一级继续查找,直到最外层的全局作用域。

闭包

针对闭包的定义,是指有权访问另一个函数作用域中的变量的函数。《你不知道的javascript》中定义闭包:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。由此可见,闭包发生的对象是函数,而产生闭包的条件是对外部词法环境的访问。闭包的示例:

function foo() {
  var a = 2;
  function bar() {
    console.log(a);
  }
  
  return bar;
}

var baz = foo();

baz(); // 2
 
for(var i = 0; i <= 5; i++) {
	setTimeout(function timer() {
    console.log(i);
  },i*1000)
} 
// 会打印6次6

// 利用let创建块作用域,timer函数访问块作用域中的i,产生闭包
for(let i = 0; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i)
  },i*1000);
}
// 0
// 1
// 2
// 3
// 4
// 5
 
利用闭包实现斐波那契数列
function fibonache() {
  let fn1 = 1;
  let fn2 = 1;
  let current;
  return function() {
    current = fn1;
    fn1 = fn2;
    fn2 = current + fn2;
    return current;
  }
}
const f = fibonache();
console.log(f()); // 1
console.log(f()); // 1
console.log(f()); // 2
console.log(f()); // 3
console.log(f()); // 5
console.log(f()); // 8
console.log(f()); // 13
 
利用闭包实现函数缓存
const memorize = function(fn) {
  const cache = {};
  return function(...args) {
    const _args = JSON.stringify(args);
    return cache[_args] || (cache[_args] = fn.apply(fn, args));
  }
}

const add = function(a) {
  console.log('call 1 time');
  return a + 1;
}

const adder = memorize(add);

console.log(adder(1)); 
console.log(adder(1));
console.log(adder(2));

// call 1 time
// 2
// 2
// call 1 time
// 3
 

this指向

javascript中的this指向问题是其特别重要的知识点,我们需要深入理解并掌握。判断一个运行中函数的this绑定,就需要找到这个函数的直接调用位置,调用位置决定了this的绑定对象。javascript中有四种this绑定规则:默认绑定、隐式绑定、显示绑定和new绑定。

默认绑定

默认绑定作用于独立函数直接调用的情况下,此时this指向全局对象,但严格模式下this指向undefined。

function foo() {
  console.log(this.a);
}

var a = 2;

foo(); // 2

// 严格模式
function foo() {
  "use strict";
  console.log(this.a)
}

var a = 2;

foo(); // TypeError: Cannot read property 'a' of undefined
 

隐式绑定

如果函数的调用位置有上下文对象,或者函数被某个对象拥有或包含,那么this绑定符合隐式绑定的规则,谁调用它指向谁。

function foo() {
    console.log(this.a);
}

var a = 42;

var obj = {
    a: 2,
    foo: foo
};

obj.foo(); // 2 
 

foo函数调用时有上下文对象,隐式绑定规则会将函数调用中的this绑定到这个上下文对象,即obj调用的foo,故this指向obj,因此函数中的this.a = obj.a。对象属性引用链中只有最顶层会影响调用位置。

function foo() {
    console.log(this.a);
}

var obj2 = {
    a: 42,
    foo: foo
}

var obj1 = {
    a: 2,
    obj2: obj2
}

obj1.obj2.foo(); // 42
 

隐式绑定需要特别注意的一个问题是,它会存在隐式丢失的现象,即被隐式绑定的函数会丢失绑定对象,应用默认绑定,从而将this绑定到全局对象。

function foo() {
    console.log(this.a);
}

var obj = {
    a: 2,
    foo: foo
};

var bar = obj.foo;
var a = 42;
bar(); // 42
 

上例中,虽然bar是obj.foo的一个引用,但实际上bar引用的是foo函数本身,相当于foo函数的一个别名,故bar() = foo(),因此此时bar()相当于直接调用foo()函数,此时将应用默认绑定,this指向全局变量

显示绑定

之前介绍Function对象时已经提过,javascript的所有函数都是Function对象,他们都具有实例方法call()和apply()。这两个方法的第一个参数都是一个对象,函数调用call()或apply()会将函数执行时的this绑定到这个对象上。因为我们可以直接指定this的绑定对象,因此将之称之为显示绑定规则。

function foo() {
    console.log(this.a);
}

var a = 42;

var obj = {
    a: 2
}

foo.call(obj); // 2
 

new绑定

javascript中构造函数可以通过new操作符来被调用并实例化一个对象,它们不属于某个类,只是被new操作符调用的普通函数。在使用new调用函数时,会自动执行下面的步骤:

  1. 创建一个新对象
  2. 将构造函数的作用域赋值给新对象(this指向了这个新对象)
  3. 执行构造函数的代码(为这个新对象添加属性)
  4. 返回该对象
function foo(a) {
    this.a = a;
}

var a = 42;

var bar = new foo(2);

console.log(bar.a); // 2
 
// js实现new
function myNew(fn) {
  return function(){
    // 创建一个新对象,并将其隐式原型指向构造函数的原型
    let obj = {
      __proto__: fn.prototype
    };
    // this指向新对象,并执行构造函数的代码
    fn.call(obj, ...arguments);
    // 返回新对象
    return obj;
  }
}

function Person(name, age) {
  this.name = name;
  this.age = age;
}

let obj = myNew(Person)('zhang', 18);

console.log(obj); // {name: 'zhang', age: 18}
 

最后,this绑定的优先级为:new > 显式 > 隐式 > 默认

箭头函数

ES6中,箭头函数是其中最有趣的新增特性,也是前端面试环节的一个高频考点,它的语法比一般的函数更加简洁,所以也是我们日常开发中经常使用的。箭头函数是一种使用箭头(=>)定义函数的新语法。

let reflect = value => value;

// 实际相当于:
let reflect = function(value) {
  return value;
}
 

箭头函数与普通函数的主要区别有:

1.箭头函数本身没有this,箭头函数的this指向其代码外层所在的词法作用域(父作用域)

let obj = {
  a: 2,
  foo: () => {
    console.log(this);
  }
}

obj.foo(); // window 此时this指向父作用域,而在js中,只有创建函数才能开辟一块作用域,obj只是对象,故此时的父级作用域:window
 

2.箭头函数没有arguments对象,取而代之用rest参数...代替arguments对象,来访问箭头函数的参数列表

// 普通函数
function foo(a) {
  console.log(arguments);
}

foo(1,2,3,4); // [1, 2, 3, 4, callee: ƒ, Symbol(Symbol.iterator): ƒ]

// 箭头函数

let foo = (b) => {
  console.log(arguments);
}

foo(1,2,3,4); // ReferenceError: arguments is not defined

// rest参数...
let foo = (...c) => {
  console.log(c);
}

foo(1,2,3,4); // [1,2,3,4]
 

3.不能通过new关键字调用,箭头函数没有[[Construct]]方法,所以箭头函数不能被用作构造函数。其实从上面的内容可以知道,new关键字的其中步骤是将函数中的this指向新对象,而箭头函数本身是没有this的,因此也可以看出其不能被new关键字调用。

let Person = (name, age) => {
  this.name = name;
  this.age = age;
}

let obj = new Person('zhang', 18); // TypeError: Person is not a constructor
 

4.箭头函数没有原型,由于不可以通过new关键字调用箭头函数,因此没有构建原型的需求,所以箭头函数不存在prototype这个属性。

// 箭头函数
let foo = () => {};
console.log(foo.prototype); // undefined

// 普通函数
function foo(){};
console.log(foo.prototype); // {constructor: ƒ}
 

5.不可以通过call|apply|bind改变箭头函数的this指向,箭头函数的内部this值在定义时已经确定了,在函数的生命周期内始终保持一致。

var a = 10;
let foo = () => {
  console.log(this.a);
}

foo();               // 10
foo.call({a: 20});   // 10
foo.apply({a: 20});  // 10
foo.bind({a: 20})(); // 10
 

回复

我来回复
  • 暂无回复内容