终于到作用域和闭包这一块了,这一块应该是JavaScript语言里面最难以学习和理解的了.
也许在日常编码中会经常接触到作用域和闭包,但是对于其原理和产生一系列的问题得不到一个深度的了解.作为一个[合格]的前端工程师,这一块的知识是一定要夯实的.
我在整理这一块的答案时,也重新理解了一遍作用域和闭包的知识,感觉对于自己来说,这又是一个提升,希望这篇文章的答案能够和大家共勉.
原文地址: 一名【合格】前端工程师的自检清单
1.理解词法作用域和动态作用域
-
词法作用域: 词法作用域(也就是静态作用域)就是定义在词法阶段的作用域,是由写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变;无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定.
-
动态作用域: 动态作用域并不关心函数和作用域是如何声明以及在任何处声明的,只关心它们从何处调用.换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套.
-
JavaScript采用词法作用域.
举个例子:
var value = 1;
function foo() {
console.log(value);
}
function bar() {
var value = 2;
foo();
}
bar();
// 结果是 ???
假设JavaScript采用静态作用域,让我们分析下执行过程:
执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value,如果没有,就根据书写的位置,查找上面一层的代码,也就是 value 等于 1,所以结果会打印 1.
假设JavaScript采用动态作用域,让我们分析下执行过程:
执行 foo 函数,依然是从 foo 函数内部查找是否有局部变量 value.如果没有,就从调用函数的作用域,也就是 bar 函数内部查找 value 变量,所以结果会打印2.
前面我们已经说了,JavaScript采用的是静态作用域,所以这个例子的结果是1.
2.理解JavaScript的作用域和作用域链
- 作用域就是一个独立的地盘,让变量不会外泄、暴露出去.
让我们用一段简单的代码来理解一下:
function fn() {
var innerVar = "内部变量";
}
fn();//要先执行这个函数,否则根本不知道里面是啥
console.log(innerVar); // Uncaught ReferenceError: innerVar is not defined
从上面的例子可以体会到作用域的概念,变量 innerVar在全局作用域没有声明,所以在全局作用域下取值会报错.
ES6之前JavaScript没有块级作用域,只有全局作用域和函数作用域,可以通过let和const来实现.
- 作用域链
- 什么是自由变量
首先认识一下什么叫做自由变量 .如下代码中,console.log(a)要得到a变量,但是在当前的作用域中没有定义 a(可对比一下b).当前作用域没有定义的变量,这成为自由变量.自由变量的值如何得到 -- 向父级作用域寻找(注意:这种说法并不严谨,下文会重点解释).
var a = 100
function fn() {
var b = 200
console.log(a) // 这里的a在这里就是一个自由变量
console.log(b)
}
fn()
- 什么是作用域链
如果父级也没呢?再一层一层向上寻找,直到找到全局作用域还是没找到,就宣布放弃.这种一层一层的关系,就是作用域链.
var a = 100
function fn() {
var b = 200
function fn2() {
var c = 300
console.log(a) // 自由变量,顺作用域链向父作用域找
console.log(b) // 自由变量,顺作用域链向父作用域找
console.log(c) // 本作用域的变量
}
fn2()
}
fn()
- 关于自由变量的取值
关于自由变量的值,上文提到要到父作用域中取,其实有时候这种解释会产生歧义.
var x = 10
function fn() {
console.log(x)
}
function show(f) {
var x = 20
(function() {
f() //10,而不是20
})()
}
show(fn)
在 fn 函数中,取自由变量x的值时,要到哪个作用域中取?
要到创建fn函数的那个作用域中取,无论fn函数将在哪里调用.
所以,不要再用以上说法了.相比而言,用这句话描述会更加贴切:要到创建这个函数的那个域. 作用域中取值,这里强调的是“创建”,而不是“调用”,其实这就是所谓的静态作用域.
再看一个例子:
var a = 10
function fn() {
var b = 20
function bar() {
console.log(a + b) //30
}
return bar
}
var x = fn(),
b = 200
x() //bar()
fn()返回的是bar函数,赋值给x.执行x(),即执行bar函数代码.取b的值时,直接在fn作用域取出.取a的值时,试图在fn作用域取,但是取不到,只能转向创建fn的那个作用域中去查找,结果找到了,所以最后的结果是30.
3.理解JavaScript的执行上下文栈,可以应用堆栈信息快速定位问题
执行上下文总共有三种类型:
- 全局执行上下文: 这是默认的、最基础的执行上下文。不在任何函数中的代码都位于全局执行上下文中。它做了两件事:
- 创建一个全局对象,在浏览器中这个全局对象就是
window对象。 - 将
this指针指向这个全局对象。一个程序中只能存在一个全局执行上下文。
-
函数执行上下文: 每次调用函数时,都会为该函数创建一个新的执行上下文。每个函数都拥有自己的执行上下文,但是只有在函数被调用的时候才会被创建。一个程序中可以存在任意数量的函数执行上下文。每当一个新的执行上下文被创建,它都会按照特定的顺序执行一系列步骤。
-
Eval函数执行上下文: 运行在eval函数中的代码也获得了自己的执行上下文,但由于eval函数不建议使用,所以在这里不再讨论。
执行栈,在其他编程语言中也被叫做调用栈,具有 LIFO(后进先出)结构,用于存储在代码执行期间创建的所有执行上下文。
当 JavaScript 引擎首次读取你的脚本时,它会创建一个全局执行上下文并将其推入当前的执行栈。每当发生一个函数调用,引擎都会为该函数创建一个新的执行上下文并将其推到当前执行栈的顶端。
引擎会运行执行上下文在执行栈顶端的函数,当此函数运行完成后,其对应的执行上下文将会从执行栈中弹出,上下文控制权将移到当前执行栈的下一个执行上下文。
4.this的原理以及几种不同使用场景的取值
this 既不指向函数自身,也不指函数的词法作用域。如果仅通过 this 的英文解释,太容易产生误导了。它实际是在函数被调用时才发生的绑定,也就是说 this 具体指向什么,取决于你是怎么调用的函数。
this 的 4 种绑定规则分别是:默认绑定、隐式绑定、显式绑定、new 绑定。优先级从低到高。
- 默认绑定:
什么叫默认绑定,即没有其他绑定规则存在时的默认规则。这也是函数调用中最常用的规则。 来看这段代码:
function foo() {
console.log( this.a );
}
var a = 2;
foo(); //打印的是什么?
foo() 打印的结果是2。
因为foo()是直接调用的(独立函数调用),没有应用其他的绑定规则,这里进行了默认绑定,将全局对象绑定 this 上,所以 this.a 就解析成了全局变量中的 a ,即2。
注意:在严格模式下(strict mode),全局对象将无法使用默认绑定,即执行会报undefined的错误
function foo() {
"use strict";
console.log(this.a);
}
var a = 2;
foo(); // Uncaught TypeError: Cannot read property 'a' of undefined
- 隐式绑定:
除了直接对函数进行调用外,有些情况是,函数的调用是在某个对象上触发的,即调用位置上存在上下文对象。
function foo() {
console.log(this.a);
}
var a = 2;
var obj = {
a: 3,
foo: foo
};
obj.foo(); // ?
obj.foo() 打印的结果是3。
这里foo函数被当做引用属性,被添加到obj对象上。这里的调用过程是这样的:
获取obj.foo属性 -> 根据引用关系找到foo函数,执行调用
所以这里对foo的调用存在上下文对象obj,this进行了隐式绑定,即this绑定到了obj上,所以this.a被解析成了obj.a,即3。
- 多层调用链
function foo() {
console.log(this.a);
}
var a = 2;
var obj1 = {
a: 4,
foo: foo
};
var obj2 = {
a: 3,
obj1: obj1
};
obj2.obj1.foo(); //?
obj2.obj1.foo() 打印的结果是4。
同样,我们看下函数的调用过程:
先获取obj1.obj2 -> 通过引用获取到obj2对象,再访问 obj2.foo -> 最后执行foo函数调用
这里调用链不只一层,存在obj1、obj2两个对象,那么隐式绑定具体会绑哪个对象。这里原则是获取最后一层调用的上下文对象,即obj1,所以结果显然是4(obj1.a)。
- 隐式丢失(函数别名)
注意:这里存在一个陷阱,大家在分析调用过程时,要特别小心
先看个代码:
function foo() {
console.log(this.a);
}
var a = 2;
var obj = {
a: 3,
foo: foo
};
var bar = obj.foo;
bar(); //?
bar() 打印的结果是2。
为什么会这样,obj.foo 赋值给bar,那调用bar()为什么没有触发隐式绑定,使用的是默认绑定呢。
这里有个概念要理解清楚,obj.foo 是引用属性,赋值给bar的实际上就是foo函数(即:bar指向foo本身)。
那么,实际的调用关系是:通过bar找到foo函数,进行调用。整个调用过程并没有obj的参数,所以是默认绑定,全局属性a。
- 隐式丢失(回调函数)
function foo() {
console.log(this.a);
}
var a = 2;
var obj = {
a: 3,
foo: foo
};
setTimeout(obj.foo, 100); // ?
打印的结果是2。
同样的道理,虽然参传是obj.foo,因为是引用关系,所以传参实际上传的就是foo对象本身的引用。对于setTimeout的调用,还是 setTimeout -> 获取参数中foo的引用参数 -> 执行 foo 函数,中间没有obj的参与。这里依旧进行的是默认绑定。
- 显式绑定:
相对隐式绑定,this值在调用过程中会动态变化,可是我们就想绑定指定的对象,这时就用到了显式绑定。
显式绑定主要是通过改变对象的prototype关联对象,这里不展开讲。具体使用上,可以通过这两个方法call或apply来实现(大多数函数及自己创建的函数默认都提供这两个方法)。
call与apply是同样的作用,区别只是其他参数的设置上.
function foo() {
console.log(this.a);
}
var a = 2;
var obj1 = {
a: 3,
};
var obj2 = {
a: 4,
};
foo.call(obj1); // ?
foo.call(obj2); // ?
打印的结果是3, 4。
这里因为显示的申明了要绑定的对象,所以this就被绑定到了obj上,打印的结果自然就是obj1.a 和 obj2.a。
- 硬绑定:
function foo() {
console.log(this.a);
}
var a = 2;
var obj1 = {
a: 3,
};
var obj2 = {
a: 4,
};
var bar = function(){
foo.call(obj1);
}
setTimeout(bar, 100); // 3
bar.call(obj2); // 这是多少
前面两个(函数别名、回调函数)打印3,因为显示绑定了,没什么问题。
最后一个打印是3。
这里需要注意下,虽然bar被显示绑定到obj2上,对于bar,function(){…} 中的this确实被绑定到了obj2,而foo因为通过foo.call(obj1)已经显示绑定了obj1,所以在foo函数内,this指向的是obj1,不会因为bar函数内指向obj2而改变自身。所以打印的是obj1.a(即3)。
- new绑定:
js中的new操作符,和其他语言中(如JAVA)的new机制是不一样的。js中,它就是一个普通函数调用,只是被new修饰了而已。
使用new来调用函数,会自动执行如下操作:
- 创建一个空的简单
JavaScript对象(即{}); - 链接该对象(即设置该对象的构造函数)到另一个对象 ;
- 将步骤1新创建的对象作为
this的上下文 ; - 如果该函数没有返回对象,则返回
this。
从第三点可以看出,this指向的就是对象本身。
看个代码:
function foo(a) {
this.a = a;
}
var a = 2;
var bar1 = new foo(3);
console.log(bar1.a); // ?
var bar2 = new foo(4);
console.log(bar2.a); // ?
最后一个打印是3, 4。
因为每次调用生成的是全新的对象,该对象又会自动绑定到this上,所以答案显而易见。
最后要注意箭头函数,它的this绑定取决于外层(函数或全局)作用域。
5.闭包的实现原理和作用,可以列举几个开发中闭包的实际应用
-
闭包的概念:指有权访问另一个函数作用域中的变量的函数,一般情况就是在一个函数中包含另一个函数。
-
闭包的作用:访问函数内部变量、保持函数在环境中一直存在,不会被垃圾回收机制处理.
-
闭包的优点:
- 方便调用上下文中声明的局部变量
- 逻辑紧密,可以在一个函数中再创建个函数,避免了传参的问题
-
闭包的缺点: 因为使用闭包,可以使函数在执行完后不被销毁,保留在内存中,如果大量使用闭包就会造成内存泄露,内存消耗很大
防抖和节流就是典型的闭包实际应用,还有IIFE也是一个闭包
6.理解堆栈溢出和内存泄漏的原理,如何防止
-
内存泄露:是指申请的内存执行完后没有及时的清理或者销毁,占用空闲内存,内存泄露过多的话,就会导致后面的程序申请不到内存。因此内存泄露会导致内部内存溢出
-
堆栈溢出:是指内存空间已经被申请完,没有足够的内存提供了
-
常见的内存泄露的原因
- 全局变量引起的内存泄露
- 闭包
- 没有被清除的计时器
-
解决方法
- 减少不必要的全局变量
- 减少闭包的使用(因为闭包会导致内存泄露)
- 避免死循环的发生
7.如何处理循环的异步操作
- 如何确保循环的所有异步操作完成之后执行某个其他操作
- 方法一:设置一个flag,在每个异步操作中对flag进行检测
let flag = 0;
for(let i = 0; i < len; i++) {
flag++;
Database.save_method().exec().then((data) => {
if(flag === len) {
// your code
}
})
}
- 方法二:将所有的循环放在一个promise中,使用then处理
new Promise(function(resolve){
resolve()
}).then(()=> {
for(let i = 0; i < len; i++) {
Database.save_method().exec()
}
}).then(() => {
// your code
})
- 循环中的下一步操作依赖于前一步的操作,如何解决
- 方法一:使用递归,在异步操作完成之后调用下一次异步操作
function loop(i){
i++;
Database.save_method().exec().then(() => {
loop(i)
})
}
- 方法二:使用
async和await(注意: 不能在forEach中使用await)
async function loop() {
for(let i = 0; i < len; i++) {
await Database.save_method().exec();
}
}
8.理解模块化解决的实际问题,可列举几个模块化方案并理解其中原理
Module模式
在模块化规范形成之前,JS开发者使用Module设计模式来解决JS全局作用域的污染问题。Module模式最初被定义为一种在传统软件工程中为类提供私有和公有封装的方法。在JavaScript中,Module模式使用匿名函数自调用 (闭包)来封装,通过自定义暴露行为来区分私有成员和公有成员。
let myModule = (function (window) {
let moduleName = 'module' // private
// public
function setModuleName(name) {
moduleName = name
}
// public
function getModuleName() {
return moduleName
}
return { setModuleName, getModuleName } // 暴露行为
})(window)
CommonJS
CommonJS主要用在Node开发上,每个文件就是一个模块,没个文件都有自己的一个作用域。通过module.exports暴露public成员。例如:
// 文件名:x.js
let x = 1;
function add() {
x += 1;
return x;
}
module.exports.x = x;
module.exports.add = add;
此外,CommonJS通过require()引入模块依赖,require函数可以引入Node的内置模块、自定义模块和npm等第三方模块。
// 文件名:main.js
let xm = require('./x.js');
console.log(xm.x); // 1
console.log(xm.add()); // 2
console.log(xm.x); // 1
- AMD
// 定义AMD规范的模块
define([function() {
return 模块
})
区别于CommonJS,AMD规范的被依赖模块是异步加载的,而定义的模块是被当作回调函数来执行的,依赖于require.js模块管理工具库。当然,AMD规范不是采用匿名函数自调用的方式来封装,我们依然可以利用闭包的原理来实现模块的私有成员和公有成员:
define(['module1', 'module2'], function(m1, m2) {
let x = 1;
function add() {
x += 1;
return x;
}
return { add };
})
- CMD
CMD 是 SeaJS 在推广过程中对模块定义的规范化产出。AMD 推崇依赖前置,CMD 推崇依赖就近。
define(function(require, exports, module) {
// 同步加载模块
var a = require('./a');
a.doSomething();
// 异步加载一个模块,在加载完成时,执行回调
require.async(['./b'], function(b) {
b.doSomething();
});
// 对外暴露成员
exports.doSomething = function() {};
});
// 使用模块
seajs.use('path');
CMD集成了CommonJS和AMD的特点,支持同步和异步加载模块。CMD加载完某个依赖模块后并不执行,只是下载而已,在所有依赖模块加载完成后进入主逻辑,遇到require语句的时候才执行对应的模块,这样模块的执行顺序和书写顺序是完全一致的。因此,在CMD中require函数同步加载模块时没有HTTP请求过程。
ES6 module
ES6的模块化已经不是规范了,而是JS语言的特性。随着ES6的推出,AMD和CMD也随之成为了历史。ES6模块与模块化规范相比,有两大特点:
- 模块化规范输出的是一个值的拷贝,
ES6模块输出的是值的引用。 - 模块化规范是运行时加载,
ES6模块是编译时输出接口。
模块化规范输出的是一个对象,该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,ES6 module 是一个多对象输出,多对象加载的模型。从原理上来说,模块化规范是匿名函数自调用的封装,而ES6 module则是用匿名函数自调用去调用输出的成员。
结语
因为时间和篇幅有限,所以每一项列举的答案都不算特别详细.
如果有这需要的同学欢迎给我留言,我可以另开文章,详细讲一讲其中具体的部分.
当然也可以多看看《JavaScript高级程序设计》,基础才是重中之重啊.
系列链接: