执行上下文与提升

执行上下文与提升

作用域(Scope )与上下文(Context )常常被用来描述相同的概念,不过上下文更多的关注于代码中 this 的使用,而作用域则与变量的可见性相关;而 JavaScript 规范中的执行上下文(Execution Context )其实描述的是变量的作用域。众所周知,JavaScript 是单线程语言,同时刻仅有单任务在执行,而其他任务则会被压入执行上下文队列中(更多知识可以阅读 Event Loop 机制详解与实践应用);每次函数调用时都会创建出新的上下文,并将其添加到执行上下文队列中。

执行上下文

每个执行上下文又会分为内存创建(Creation Phase)与代码执行(Code Execution Phase)两个步骤,在创建步骤中会进行变量对象的创建(Variable Object )、作用域链的创建以及设置当前上下文中的 this 对象。所谓的 Variable Object,又称为 Activation Object,包含了当前执行上下文中的所有变量、函数以及具体分支中的定义。当某个函数被执行时,解释器会先扫描所有的函数参数、变量以及其他声明:

'variableObject': {
  // contains function arguments, inner variable and function declarations
}

在 Variable Object 创建之后,解释器会继续创建作用域链(Scope Chain );作用域链往往指向其副作用域,往往被用于解析变量。当需要解析某个具体的变量时,JavaScript 解释器会在作用域链上递归查找,直到找到合适的变量或者任何其他需要的资源。作用域链可以被认为是包含了其自身 Variable Object 引用以及所有的父 Variable Object 引用的对象:

'scopeChain': {
  // contains its own variable object and other variable objects of the parent execution contexts
}

而执行上下文则可以表述为如下抽象对象:

executionContextObject = {
  scopeChain: {}, // contains its own variableObject and other variableObject of the parent execution contexts
  variableObject: {}, // contains function arguments, inner variable and function declarations
  this: valueOfThis,
};

变量的生命周期与提升

变量的生命周期包含着变量声明(Declaration Phase )、变量初始化(Initialization Phase )以及变量赋值(Assignment Phase )三个步骤;其中声明步骤会在作用域中注册变量,初始化步骤负责为变量分配内存并且创建作用域绑定,此时变量会被初始化为 undefined,最后的分配步骤则会将开发者指定的值分配给该变量。传统的使用 const 关键字声明的变量的生命周期如下:

而 let 关键字声明的变量生命周期如下:

如上文所说,我们可以在某个变量或者函数定义之前访问这些变量,这即是所谓的变量提升(Hoisting)。传统的 const 关键字声明的变量会被提升到作用域头部,并被赋值为 undefined:

// const hoisting
num; // => undefined
const num;
num = 10;
num; // => 10
// function hoisting
getPi; // => function getPi() {...}
getPi(); // => 3.14
function getPi() {
  return 3.14;
}

变量提升只对 const 命令声明的变量有效,如果一个变量不是用 const 命令声明的,就不会发生变量提升。

console.log(b);
b = 1;

上面的语句将会报错,提示 ReferenceError: b is not defined,即变量 b 未声明,这是因为 b 不是用 const 命令声明的,JavaScript 引擎不会将其提升,而只是视为对顶层对象的 b 属性的赋值。ES6 引入了块级作用域,块级作用域中使用 let 声明的变量同样会被提升,只不过不允许在实际声明语句前使用:

> let x = x;
ReferenceError: x is not defined
  at repl:1:9
  at ContextifyScript.Script.runInThisContext (vm.js:44:33)
  at REPLServer.defaultEval (repl.js:239:29)
  at bound (domain.js:301:14)
  at REPLServer.runBound [as eval] (domain.js:314:12)
  at REPLServer.onLine (repl.js:433:10)
  at emitOne (events.js:120:20)
  at REPLServer.emit (events.js:210:7)
  at REPLServer.Interface._onLine (readline.js:278:10)
  at REPLServer.Interface._line (readline.js:625:8)
> let x = 1;
SyntaxError: Identifier 'x' has already been declared

函数的生命周期与提升

基础的函数提升同样会将声明提升至作用域头部,不过不同于变量提升,函数同样会将其函数体定义提升至头部;譬如:

function b() {
  a = 10;
  return;
  function a() {}
}

会被编译器修改为如下模式:

function b() {
  function a() {}
  a = 10;
  return;
}

在内存创建步骤中,JavaScript 解释器会通过 function 关键字识别出函数声明并且将其提升至头部;函数的生命周期则比较简单,声明、初始化与赋值三个步骤都被提升到了作用域头部:

如果我们在作用域中重复地声明同名函数,则会由后者覆盖前者:

sayHello();

function sayHello() {
  function hello() {
    console.log("Hello!");
  }

  hello();

  function hello() {
    console.log("Hey!");
  }
}

// Hey!

而 JavaScript 中提供了两种函数的创建方式,函数声明(Function Declaration )与函数表达式(Function Expression );函数声明即是以 function 关键字开始,跟随者函数名与函数体。而函数表达式则是先声明函数名,然后赋值匿名函数给它;典型的函数表达式如下所示:

const sayHello = function () {
  console.log("Hello!");
};

sayHello();

// Hello!

函数表达式遵循变量提升的规则,函数体并不会被提升至作用域头部:

sayHello();

function sayHello() {
  function hello() {
    console.log("Hello!");
  }

  hello();

  const hello = function () {
    console.log("Hey!");
  };
}

// Hello!

在 ES5 中,是不允许在块级作用域中创建函数的;而 ES6 中允许在块级作用域中创建函数,块级作用域中创建的函数同样会被提升至当前块级作用域头部与函数作用域头部。不同的是函数体并不会再被提升至函数作用域头部,而仅会被提升到块级作用域头部:

f; // Uncaught ReferenceError: f is not defined
(function () {
  f; // undefined
  x; // Uncaught ReferenceError: x is not defined
  if (true) {
    f();
    let x;
    function f() {
      console.log("I am function!");
    }
  }
})();

避免全局变量

在计算机编程中,全局变量指的是在所有作用域中都能访问的变量。全局变量是一种不好的实践,因为它会导致一些问题,比如一个已经存在的方法和全局变量的覆盖,当我们不知道变量在哪里被定义的时候,代码就变得很难理解和维护了。在 ES6 中可以利用 let 关键字来声明本地变量,好的 JavaScript 代码就是没有定义全局变量的。在 JavaScript 中,我们有时候会无意间创建出全局变量,即如果我们在使用某个变量之前忘了进行声明操作,那么该变量会被自动认为是全局变量,譬如

function sayHello() {
  hello = "Hello World";
  return hello;
}
sayHello();

console.log(hello);

在上述代码中因为我们在使用 sayHello 函数的时候并没有声明 hello 变量,因此其会创建作为某个全局变量。如果我们想要避免这种偶然创建全局变量的错误,可以通过强制使用 strict mode 来禁止创建全局变量。

函数包裹

为了避免全局变量,第一件事情就是要确保所有的代码都被包在函数中。最简单的办法就是把所有的代码都直接放到一个函数中去

(function (win) {
  "use strict"; // 进一步避免创建全局变量
  const doc = window.document; // 在这里声明你的变量 // 一些其他的代码
})(window);

声明命名空间

const MyApp = {
  namespace: function (ns) {
    const parts = ns.split("."),
      object = this,
      i,
      len;
    for (i = 0, len = parts.length; i < len; i++) {
      if (!object[parts[i]]) {
        object[parts[i]] = {};
      }
      object = object[parts[i]];
    }
    return object;
  },
};

// 定义命名空间
MyApp.namespace("Helpers.Parsing");

// 你现在可以使用该命名空间了
MyApp.Helpers.Parsing.DateParser = function () {
  //做一些事情
};

模块化

另一项开发者用来避免全局变量的技术就是封装到模块 Module 中。一个模块就是不需要创建新的全局变量或者命名空间的通用的功能。不要将所有的代码都放一个负责执行任务或者发布接口的函数中。这里以异步模块定义 Asynchronous Module Definition (AMD) 为例,更详细的 JavaScript 模块化相关知识参考 JavaScript 模块演化简史

//定义
define("parsing", ["dependency1", "dependency2"], function (
  //模块名字 // 模块依赖
  dependency1,
  dependency2
) {
  //工厂方法

  // Instead of creating a namespace AMD modules
  // are expected to return their public interface
  const Parsing = {};
  Parsing.DateParser = function () {
    //do something
  };
  return Parsing;
});

// 通过 Require.js 加载模块
require(["parsing"], function (Parsing) {
  Parsing.DateParser(); // 使用模块
});
上一页