你不知道的JS-上(五)

你不知道的 JS-上

作用域和闭包

闭包的定义

闭包的定义为:

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

用一些代码来解释这个定义。

js 复制代码
function foo() {
  var a = 2;

  function bar() {
    console.log(a); // 2
  }

  bar();
}

foo();

基于词法作用域的查找规则,函数 bar()可以访问外部作用域中的变量 a(RHS 引用查询)。

这是闭包吗?

技术上来讲,也许是。但根据前面的定义,确切地说并不是。我认为最准确地用来解释 bar()对 a 的引用的方法是词法作用域的查找规则,而这些规则只是闭包的一部分。(但却是非常重要的一部分!)

从纯学术的角度说,在上面的代码片段中,函数 bar()具有一个涵盖 foo()作用域的闭包(事实上,涵盖了它能访问的所有作用域,比如全局作用域)。也可以认为 bar()被封闭在了 foo()的作用域中。

这种方式定义的闭包并不能直接进行观察,也无法明白在这个代码片段中闭包是如何工作的。

看下面一段代码,清晰地展示了闭包:

js 复制代码
function foo() {
  var a = 2;

  function bar() {
    console.log(a);
  }

  return bar;
}

var baz = foo();

baz(); // 2 --这就是闭包的效果

bar 所引用的函数被当作返回值,在 foo()执行后赋值给 baz,并通过 baz()执行,它在自己定义的词法作用域以外的地方执行。

引擎的垃圾回收机制通常会在 foo()执行后将其整个内部的作用域销毁,但由于 bar()任然在使用其内部作用域,使得 foo()作用域一直存活,以供 bar()在之后的任何时间进行引用,而这个引用叫做闭包。

无论使用何种方式对函数类型的值进行传递,都能在函数在别处调用时观察到闭包。

js 复制代码
function foo() {
  var a = 2;

  function baz() {
    console.log(a); // 2
  }

  bar(baz);
}

function bar(fn) {
  fn(); // 这就是闭包!
}

foo();

把内部函数 baz 传递给 bar,当调用这个内部函数时(现在叫做 fn),它涵盖的 foo()内部作用域的闭包就可以观察到了,因为它能够访问 a。

传递函数也可以是间接的。

js 复制代码
var fn;

function foo() {
  var a = 2;

  function baz() {
    console.log(a);
  }

  fn = baz; // 将baz分配给全局变量
}

function bar() {
  fn(); // 这就是闭包!
}

foo();

bar(); // 2

无论通过何种手段将内部函数传递到所在的词法作用域以外,他都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

闭包的应用

我们写过的代码中很多都有闭包的身影。

js 复制代码
function wait(message) {
  setTimeout(function timer() {
    console.log(message);
  }, 1000);
}

wait("Hello, closure");

将一个内部函数(名为 timer)传递给 setTimeout(..)。timer 具有涵盖 wait(..)作用域的闭包,因此还保有对变量 message 的引用。

wait(..)执行 1000 毫秒后,它的内部作用域并不会消失,timer 函数依然保有 wait(..)作用域的闭包。

深入到引擎的内部原理中,内置的工具函数 setTimeout(..)持有对一个参数的引用,这个参数也许叫 fn 或者 func,或者其他类似的名字。引擎会调用这个函数,而词法作用域在这个过程中保持完整。

js 复制代码
function setupBot(name, selector) {
  $(selector).click(function activator() {
    console.log("Activating:" + name);
  });
}

setupBot("Closure Bot 1", "#bot_1");
setupBot("Closure Bot 2", "#bot_2");

本质上无论何时何地,如果将(访问它们各自词法作用域的)函数当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers 或者其他异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!

第三章介绍了 IIFE 模式。通常认为 IIFE 是典型的闭包例子,但根据先前对闭包的定义,我并不是很同意这个观点。

js 复制代码
var a = 2;

(function IIFE() {
  console.log(a);
})();

虽然这段代码可以正常工作,但严格来说它并不是闭包。函数并不是在它本身的词法作用域以外执行的。它在定义时所处的作用域中执行。a 是通过普通的词法作用域查找而非闭包被发现的。

循环和闭包

js 复制代码
for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

正常情况下,我们预期这段代码输出为数字 1~5,每秒一次,每次一个。 但实际,这段代码运行时会以每秒一次的频率输出五次 6。

延迟函数的回调会在循环结束时才执行。同时,尽管循环中的五个函数是在各个迭代中分别定义的,但它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。

因此我们需要在循环的过程中每个迭代都创建一个闭包作用域。

我们了解 IIFE 会通过声明并立即执行一个函数来创建作用域。

js 复制代码
for (var i = 1; i <= 5; i++) {
  (function () {
    setTimeout(function timer() {
      console.log(i);
    }, i * 1000);
  })();
}

这样处理任然不行,虽然每个延迟函数都会将 IIFE 在每次跌倒中创建的作用域封闭起来,但如果作用域是空的,仅仅将它们封闭还是不够。

它需要有自己的变量,用来在每个迭代中存储 i 的值:

js 复制代码
for (var i = 1; i <= 5; i++) {
  (function () {
    var j = i;
    setTimeout(function timer() {
      console.log(j);
    }, j * 1000);
  })();
}

重返块作用域

第 3 章介绍了 let 声明,可以用来劫持块作用域,并且在块作用域中声明一个变量。本质上这是将一个块转换成一个可以被关闭的作用域。

同时 for 循环头部的 let 声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。

js 复制代码
for (let i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

模块

js 复制代码
function CoolModule() {
  var something = "cool";
  var another = [1, 2, 3];

  function doSomething() {
    console.log(something);
  }

  function doAnother() {
    console.log(another.join("!"));
  }

  return {
    doSomething: doSomething,
    doAnother: doAnother,
  };
}

var foo = CoolModule();

foo.doSomething(); // cool
foo.doAnother(); // 1!2!3

这个模式在 JS 中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露,这里展现的是其变体。

该代码通过调用 CoolModule()函数来创建一个模块实例。CoolModule()返回一个用对象字面量语法{ key: value, ...} 来表示的对象。这个返回的对象中含有对内部函数而不是内部数据变量的引用。我们保持内部数据变量是隐藏且私有的状态。这个对象类型的返回值最终被赋值给外部的变量 foo,然后就通过它来访问 API 中的属性方法。

从模块中返回一个实际的对象并不是必须的,也可以直接返回一个内部函数。JQuery 就是一个很好的例子。JQuery 和$标识符就是 JQuery 模块的公共 API,但它们本身都是函数(由于函数也是对象,它们本身也可以拥有属性)。

可总结为,实现模块模式需要具备两个必要条件。

  • 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。

  • 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。

模块也是普通函数,因此可以接受参数:

js 复制代码
function CoolModule(id) {
  function identify() {
    console.log(id);
  }

  return {
    identify: identify,
  };
}

var foo1 = CoolModule("foo1");
var foo2 = CoolModule("foo2");

foo1.identify(); // "foo1"
foo2.identify(); // "foo2"

模块模式另一个简单但强大的变化用法是,命名将要作为公共 API 返回的对象:

js 复制代码
var foo = (function CoolModule(id) {
  function change() {
    // 修改公共 API
    publicAPI.identify = identify2;
  }

  function identify1() {
    console.log(id);
  }

  function identify2() {
    console.log(id.toUpperCase());
  }

  var publicAPI = {
    change: change,
    identify: identify1,
  };

  return publicAPI;
})("foo module");

foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE

通过在模块实例的内部保留对公共 API 对象的内部引用,可以从内部对模块实例进行修改,包括添加或删除方法和属性,以及修改它们的值。

现代的模块机制

大多数的模块依赖加载器/管理器本质上都是将这种模块定义封装进一个友好的 API。我们不研究某个具体的库,为了宏观了解简单地介绍一些核心概念:

js 复制代码
var MyModules = (function Manager() {
  var modules = {};

  function define(name, deps, impl) {
    for (var i = 0; i < deps.length; i++) {
      deps[i] = modules[deps[i]];
    }
    modules[name] = impl.apply(impl, deps);
  }

  function get(name) {
    return modules[name];
  }

  return { define: define, get: get };
})();

这段代码地核心是 modules[name] = impl.apply(impl,deps)。为了模块的定义引入了包装函数(可以传入任何依赖),并且将返回值,也就是模块的 API,储存在一个根据名字来管理的模块列表中。

下面来使用它来定义模块:

js 复制代码
MyModules.define("bar", [], function () {
  function hello(who) {
    return "Let me introduce: " + who;
  }
  return { hello: hello };
});

MyModules.define("foo", ["bar"], function (bar) {
  var hungry = "hippo";
  function awesome() {
    console.log(bar.hello(hungry).toUpperCase());
  }
  return { awesome: awesome };
});

var bar = MyModules.get("bar");
var foo = MyModules.get("foo");
console.log(bar.hello("hippo")); // Let me introduce: hippo
foo.awesome(); // LET ME INTRODUCE: HIPPO

"foo"和"bar"模块都是通过一个返回公共 API 的函数来定义的。"foo"甚至接受"bar"的示例作为依赖参数,并能相应地使用它。

模块就是模块,即使在它们外层加上一个友好的包装工具也不会发生任何变化。

未来的模块机制

ES6 中为模块增加了一级语法支持。但通过模块系统进行加载时,ES6 会将文件当作独立的模块来处理。每个模块都可以导入其他模块或特定的 API 成员,同样也可以导出自己的 API 成员。

基于函数的模块并不是一个能被稳定识别的模式(编译器无法识别),它们的 API 语义只有在运行时才被考虑进来。因此可以在运行时修改一个模块的 API。
相比之下,ES6 模块 API 更加稳定(API 不会在运行时改变)。由于编译器知道这一点,因此可以在(的确也这样做了)编译期检查对导入模块的 API 成员的引用是否真实存在。如果 API 引用不存在,编译器会在编译时就抛出"早期"错误,而不会等到运行期再动态解析。

ES6 的模块没有"行内"格式,必须被定义在独立的文件中(一个文件一个模块)。浏览器或引擎有一个默认的"模块加载器"可以在导入模块时异步地加载模块文件。

js 复制代码
bar.js

function hello(who) {
  return "Let me introduce:" + who;
}

export { hello };
//---------------------
foo.js

// 仅从"bar"模块导入hello()
import { hello } from "bar";

var hungry = "hippo";

function awesome() {
  console.log(hello(hungry).toUpperCase()); // LET ME INTRODUCE: HIPPO
}

export { awesome };
//---------------------
baz.js

//导入完整的"foo"和"bar"模块
import { awesome } from "./foo.js";
import * as bar from "./bar.js";

console.log(bar.hello("rhino")); // Let me introduce: rhino

awesome(); // LET ME INTRODUCE: HIPPO
相关推荐
UIUV31 分钟前
JavaScript中instanceof运算符的原理与实现
前端·javascript·代码规范
我叫张小白。40 分钟前
Vue3 插槽:组件内容分发的灵活机制
前端·javascript·vue.js·前端框架·vue3
脾气有点小暴1 小时前
uniapp通用递进式步骤组件
前端·javascript·vue.js·uni-app·uniapp
蜗牛攻城狮1 小时前
JavaScript 尾递归(Tail Recursion)详解
开发语言·javascript·ecmascript
坐吃山猪1 小时前
Electron04-系统通知小闹钟
开发语言·javascript·ecmascript
小飞侠在吗1 小时前
vue toRefs 与 toRef
前端·javascript·vue.js
董世昌412 小时前
箭头函数和普通函数有什么区别
开发语言·javascript·ecmascript
A24207349302 小时前
js流程控制语句
开发语言·前端·javascript
yngsqq3 小时前
二维异形排版、二维装箱(NPF碰撞检测)——CAD c#二次开发
开发语言·javascript·c#