javascript作用域

在一些语言中,声明和定义存在明确的区分。声明一个变量仅指给它指定一个标志符来宣称它的存在。而定义则代表声明后还要进行赋值。
在 javascript 中,声明和定义是可以互换的概念。因为所有变量都会在声明之后给定一个值。如果你没有显式赋值,会自动给定一个隐含值 undefined。

静态作用域

javascript 使用静态作用域。静态作用域又称词法作用域,静态作用域下函数作用域在定义(声明)时已经确定,如未找到自身作用域内的变量,则沿作用域链向上,在上级作用域中查找,直到全局。

function foo() {
  console.log(a);
}
function bar() {
  let a = 3;
  foo();
}
let a = 2;
bar(); // 输出2 详见下面的说明

静态作用域:
foo 的作用域在声明时就已经确定,当 foo 被调用时,在 foo 自身的作用域中找不到变量 a,此时以定义 foo 时的作用域链为准,向作用域链上级查找。foo 定义时的作用域链为:foo 作用域——全局作用域。而在全局作用域中存在 a = 2,故输出结果为 2。

动态作用域:
如果是动态作用域,foo 的作用域在 foo 被调用时确定,作用域链向上是 foo 被调用时的环境。在 foo 的作用域中找不到 a,则沿作用域链向上,在 foo 被调用的环境即 bar 的作用域中查找,bar 中 a = 3,故在动态作用域规则下应输出 3,而 js 使用静态作用域,只能输出 2。

总结:在函数中遇到既不是形参也不是函数内部定义的局部变量的变量时,静态作用域下是去函数定义时的环境中查询,动态作用域下是去函数调用时的环境中查询。

在函数中定义的参数和声明的局部变量,其作用域范围仅在函数内部,当函数调用完毕,参数与局部变量都会被销毁。

作用域、上下文、执行环境

作用域(Scope):编译过程中,负责收集并维护所有声明的标志符,确定当前执行代码对这些标志符的访问权限的一套规则。简而言之作用域就是变量的可见性。

上下文(Context):函数调用时的 this 值。this 来自执行环境对象。

P.S. new 关键字调用函数时,上下文 this 被设置为被调用的函数实例,即新构造的对象。

严格模式调用函数,上下文 this 为 undefined。(详见js关键字this

执行环境(Execution Context):执行环境又称执行上下文,当代码流转到可执行代码时,会进入一个执行环境,执行环境有创建和执行两个阶段。创建时会创建一个执行环境对象,其中包含作用域链,变量对象与上下文。
当前所有执行环境构成一个栈,栈底部始终是全局环境,顶部是当前活动的执行环境,随执行流的变化,执行环境入栈或出栈。全局环境的执行环境对象就是 Window 对象。

块作用域

ES6 前只有函数作用域和全局作用域,ES6 中添加了块作用域。块是由一对花括号括起来的一系列语句。块作用域指的就是那些仅仅在代码块内有效的变量。块通常是控制流语句的一部分,如 if 或 for,定义独立的块也是合法的。ES6 中 let 与 const 关键字声明的变量处在块作用域中,var 声明的变量在代码块外也可以访问到。

const bar = 1;
console.log(foo); // undefined 块内变量提升,相当于只声明,未赋值。
console.log(bar); // 1
{
  var foo = 2; // 相当于已经声明只进行赋值
  const bar = 3; // 块内声明只在块内生效,这里块内声明的变量屏蔽了块外变量,块内无法访问块外变量
  console.log(foo); // 2 undefined 赋值为2
  console.log(bar); // 3
}
console.log(foo); // 2 块外也能访问var声明的变量
console.log(bar); // 1 块外访问不到块内变量bar,只能访问到块外的变量bar

变量屏蔽

使用 let 或 const 在块作用域外与块作用域内声明相同名字的变量,块内声明的变量会屏蔽块外的,使块外的变量不可访问。这个特性可以防止变量污染。函数作用域同理。

const foo = {
  objName: "foo",
};
const bar = foo;
const x = 1;
{
  let foo = 5;
  console.log(foo); // 5 外部foo变量屏蔽,访问不到
  console.log(bar.objName); // foo
  bar.objName = "bar"; // 对象为引用型值,本身并不构成作用域,虽然在块中修改,但是在全局都生效
  let x = 10;
  console.log(x); // 10 外部x变量屏蔽,访问不到
}
console.log(foo); // bar 对象是引用型,变量bar与变量foo引用自相同对象,在bar中更改对象后,foo引用到的也是被修改后的对象
console.log(bar); // bar
console.log(x); // 1 块中变量屏蔽,块外变量不受影响

对象是引用型,更底层的解释是对象存储在一个内存位置中,将对象通过变量赋值给其它变量,这些变量都引用自相同内存位置的同一对象,因此在一个变量上操作改变对象,内存中的对象被更改,其他变量引用的也是改变后的对象。
如果将对象直接赋值给不同变量,则每个变量中的对象看起来是相同的,但其实是存储在不同内存位置的不同对象。
const 声明的基本类型值无法被更改,但 const 声明的数组和对象可以被更改,因为对象(数组)是引用型,const 固定了指向对象(数组)内存位置的引用,但对象(数组)在内存中的内容没有固定。

const foo = { num: 1 };
const foo1 = foo;
const bar = { num: 1 };
console.log(foo === foo1); // true 通过变量赋值,foo与foo1实际引用自相同对象
console.log(foo === bar); // false 直接赋值,foo与bar是存储在不同内存位置的不同对象,但对象内容相同
foo.num = 2;
console.log(foo === foo1); // true foo修改了内存中的对象,foo1引用自那个对象,所以foo1也被自动更改了
console.log(foo, foo1, bar); // {num: 2}, {num: 2}, {num: 1} foo与foo1引用自相同对象,bar和前两者引用自不同对象

变量提升

在 ES6 引入 let 和 const 之前,变量都是用 var 来声明的。使用 var 声明的变量在当前作用域中任何地方都可以使用,甚至可以在声明前使用。这是因为使用 var 声明的变量采用了提升机制,javascript 会扫描整个作用域(函数或全局作用域),任何使用 var 声明的变量都会被提升至当前作用域的顶部,被提升的只是对变量的声明,而不是赋值。

console.log(foo); // undefined 变量提升,只提升声明,默认赋值为undefined
var foo = 1; // 赋值为1
console.log(foo); // 1
// 等价于
var foo;
console.log(foo);
foo = 1;
console.log(foo);

let 与 const 没有变量提升机制,在声明前无法使用

console.log(bar); // referenceError 直接报错,无法运行
let bar = 0;
console.log(bar);

由于 var 没有块作用域,且 var 有变量提升,故在当前作用域中,尽管可以在块内再次使用 var,但并不会发生变量屏蔽,不能创建同名的新变量。

var foo = 0;
if (foo === 0) {
  var foo = 1;
  console.log(foo); // 1
}
console.log(foo); // 1
// 等价于
var foo = 0;
if (foo === 0) {
  foo = 1;
  console.log(foo); // 1
}
console.log(foo); // 1

临时死区 TDZ

由于 ES6 中 let、const 有块作用域,且 let、const 没有变量提升机制,故在块中再次声明同名变量会不小心产生临时死区。要防止临时死区应注意将所有声明都放在当前作用域顶部。

let foo = 0;
if (foo === 0) {
  console.log(foo); // referenceError 这里就是临时死区
  let foo = 1; // 因为块中用了let声明,块内的foo屏蔽了块外的foo,而上一句却在块中未声明foo时就使用foo
  console.log(foo);
}
console.log(foo);

ES5 中常用的类型检测方法,在 ES6 中就会因为临时死区而报错

if (typeof foo === "undefined") {
  // referenceError
  console.log("foo is safe to use");
}
let foo = 1;

总结 var、let、const

var:

  1. 有变量提升机制。
  2. 没有块作用域,只有函数作用域和全局作用域,块中不会产生变量屏蔽,在函数作用域中才有变量屏蔽。
  3. 虽然在同一作用域同一变量名可以多重声明,但不创建新变量,仅相当于赋值操作。
  4. 在全局环境声明的变量会自动挂载为 window 的属性,不仅可以通过变量名访问,还可以通过 Window 属性访问,Window.变量名。

let、const 相同点:

  1. 没有变量提升机制。
  2. 有块作用域,块内会产生变量屏蔽,警惕因变量屏蔽、无提升机制造成的临时死区。
  3. 同一作用域同一变量名只能声明一次。
  4. 在全局环境声明的变量不会挂载为 Window 的属性,只能通过变量名访问。

let、const 区别:

  1. let 用于声明变量、const 用于声明常量。
  2. const 声明与赋值必须同时进行,基本型值定义后无法更改,如字符串、数字等。
  3. 数组、对象为引用型值,const 声明后固定了引用的内存位置,但未固定内容,故数组与对象可更改。

函数提升

函数的声明也会被提升至当前作用域的顶部,允许函数在声明前使用。赋值给变量的函数表达式不会提升,作用域规则与变量相同。函数提升更高于变量提升,当函数声明与变量声明同名时,函数声明在前,变量声明在后。(尽量避免函数与变量同名)

f(); // f 函数提升,可以提前调用
g(); // referenceError:g未定义  函数未提升,变量未提升,无法调用,直接停止报错
h(); // typeError:h不是函数  函数未提升,变量提升只是声明提升,并未赋值,h为undefined
function f() {
  console.log("f");
}
let g = () => console.log("g");
var h = () => console.log("h");

严格模式

ES5 允许隐式全局变量,如果忘记使用 var 声明变量,Javascript 会认为你在引用全局变量,如果全局变量不存在,会自动创建一个。

function f() {
  a = 1;
  console.log(a); // 1
}
f();
console.log(a); // 1

为了防止这种情况以及 this 默认绑定到全局等情况,引入了严格模式。在全局或函数的开头写 “use strict”,单双引号都可以,就开启了严格模式。

由于在全局中开启严格模式会应用到所有脚本代码中,故使用时要谨慎。很多网站在部署前会整合脚本代码,若一个脚本代码用了全局严格模式,则所有代码在整合后都会应用严格模式,为了防止这种情况,应该将用了严格模式的脚本代码封装在一个 IIFE(立即执行函数)中,这样就不会干扰整合后的其它脚本。

(function () {
  "use strict";
  // 代码区
})();