虽然做用域相关知识是 JavaScript 的基础, 但要完全理解必需要从原理入手. 从面试角度来说, 词法/动态做用域、做用域(链)、变量/函数提高、闭包、垃圾回收 实属一类题目, 打通这几个概念并熟练掌握, 面试基本就不用担忧这一块了. 这篇文章是对《JavaScript 高级程序设计 (第三版)》第四章, 一样也是 《你不知道的 JavaScript (上卷)》第一部分的学习和总结.javascript
对于大部分编程语言, 编译大体有三个步骤.html
分词/词法分析 (Tokenizing/Lexing)前端
此过程将源代码分解成 词法单元 (token)
, 如代码 const firstName = 'Yancey'
会被分解成 const
, firstName
, =
, 'Yancey'
, 空格是否会被当成词法单元, 取决于空格对这门语言的意义. 这里推荐一个网站 Parser 能够用来解析 JavaScript 的源代码. 对于这个例子, 分词结构以下.java
[
{
type: 'Keyword',
value: 'const',
},
{
type: 'Identifier',
value: 'firstName',
},
{
type: 'Punctuator',
value: '=',
},
{
type: 'String',
value: "'Yancey'",
},
];
复制代码
解析/语法分析 (Parsing)webpack
这个过程将词法单元流转换成一棵 抽象语法树 (Abstract Syntax Tree, AST). 语法分析会根据 ECMAScript 的标准来解析成 AST, 好比你写了 const new = 'Yancey'
, 就会报错 Uncaught SyntaxError: Unexpected token new.git
对于上面那个例子, 生成的 AST 以下图所示, 其中 Identifier
表明着变量名, Literal
表明着变量的值.github
代码生成web
这个阶段就是将 AST 转换为可执行代码, 像 V8 引擎会将 JavaScript 字符串编译成二进制代码(建立变量、分配内存、将一个值存储到变量里...)面试
除上面三个阶段以外, JavaScript 引擎还对 语法分析、代码生成、编译过程 进行一些优化, 这一块估计得看 v8 源码了, 先留个坑. 有个库叫作 Acorn, 用来解析 JavaScript 代码, 像 webpack、eslint 都有用到, 有时间能够玩一玩.编程
做用域有两种模型, 一种是 词法做用域(Lexical Scope), 另外一种是 动态做用域 (Dynamic Scope).
词法做用域是定义在词法阶段的做用域, 换句话说就是你写代码时将变量和块做用域写在哪里决定的. JavaScript 能够经过 eval
和 with
来改变词法做用域, 但这两种会致使引擎没法在编译时对做用域查找进行优化, 所以不要使用它们.
而动态做用域是在运行时定义的, 最典型的就是 this 了.
不论是编译阶段仍是运行时, 都离不开 引擎, 编译器, 做用域.
引擎用来负责 JavaScript 程序的编译和执行.
编译器负责语法分析、代码生成等工做.
做用域用来收集并维护全部变量访问规则.
以代码 const firstName = 'Yancey'
为例, 首先编译器遇到 const firstName
, 会询问 做用域 是否已经有一个同名变量在当前做用域集合, 若是有编译器则忽略该声明, 不然它会在当前做用域的集合中声明一个新的变量并命名为 firstName
.
接着编译器会为引擎生成运行时所需的代码, 用于处理 firstName = 'Yancey'
这个赋值操做. 引擎会先询问做用域, 在当前做用域集合中是否有个变量叫 firstName
. 若是有, 引擎就会使用这个变量, 不然继续往上查找.
引擎在做用域中查找元素时有两种方式:LHS
和 RHS
. 通常来说, LHS
是赋值阶段的查找, 而 RHS
就是纯粹查找某个变量.
看下面这个例子.
function foo(a) {
var b = a;
return a + b;
}
var c = foo(2);
复制代码
var c = foo(2);
引擎会在做用域里找是否有 foo
这个函数, 这是一次 RHS 查找, 找到以后将其赋值给变量 c
, 这是一次 LHS 查找.
function foo(a) {
这里将实参 2
赋值给形参 a
, 因此这是一次 LHS 查找.
var b = a;
这里要先找到变量 a
, 因此这是一次 RHS 查找. 接着将变量 a
赋值给 b
, 这是一次 LHS 查找.
return a + b;
查找 a
和 b
, 因此是两次 RHS 查找.
以浏览器环境为例:
最外层函数和在最外层函数外面定义的变量拥有全局做用域
全部末定义直接赋值的变量自动声明为拥有全局做用域
全部 window 对象的属性拥有全局做用域
const a = 1; // 全局变量
// 全局函数
function foo() {
b = 2; // 未定义却赋初值被认为是全局变量
const name = 'yancey'; // 局部变量
// 局部函数
function bar() {
console.log(name);
}
}
window.navigator; // window 对象的属性拥有全局做用域
复制代码
全局做用域的缺点很明显, 就是会污染全局命名空间, 所以不少库的源码都会使用 (function(){....})()
. 此外, 模块化 (ES六、commonjs 等等) 的普遍使用也为防止污染全局命名空间提供了更好的解决方案.
函数做用域指属于这个函数的所有变量均可以在整个函数范围内使用及复用.
function foo() {
const name = 'Yancey';
function sayName() {
console.log(`Hello, ${name}`);
}
sayName();
}
foo(); // 'Hello, Yancey'
console.log(name); // 外部没法访问到内部变量
sayName(); // 外部没法访问到内部函数
复制代码
值得注意的是, if、switch、while、for 这些条件语句或者循环语句不会建立新的做用域, 虽然它也有一对 {}
包裹. 能不能访问的到内部变量取决于声明方式(var 仍是 let/const)
if (true) {
var name = 'yancey';
const age = 18;
}
console.log(name); // 'yancey'
console.log(age); // 报错
复制代码
咱们知道 let 和 const 的出现改变了 JavaScript 没有块级做用域的状况(具体能够看高程三的第 76 页, 那个时候尚未块级做用域的概念). 关于 let 和 const 不去细说, 这两个再不懂的话... 不事后面会介绍到临时死区的概念.
此外, try/catch
的 catch
分句也会建立一个块级做用域, 看下面一个例子:
try {
noThisFunction(); // 创造一个异常
} catch (e) {
console.log(e); // 能够捕获到异常
}
console.log(e); // 报错, 外部没法拿到 e
复制代码
在 ES6 以前的"蛮荒时代", 变量提高在面试中常常被问到, 而 let 和 const 的出现解决了变量提高问题. 但函数提高一直是存在的, 这里咱们从原理入手来分析一下提高.
咱们回忆一下关于编译器的内容, 引擎会在解释 JavaScript 代码以前首先对其进行编译, 编译阶段的一部分工做就是找到全部的声明, 而且使用合适的做用域将它们串联起来. 换句话说, 变量和函数在内的全部声明都会在代码执行前被处理.
所以, 对于代码 var i = 2;
而言, JavaScript 实际上会将这句代码看做 var i;
和 i = 2
, 其中第一个是在编译阶段, 第二个赋值操做会原地等待执行阶段. 换句话说, 这个过程将会把变量和函数声明放到其做用域的顶部, 这个过程就叫作提高.
可能你会有疑问, 为何 let 和 const 不存在变量提高呢?这是由于在编译阶段, 当遇到变量声明时, 编译器要么将它提高至做用域顶部(var 声明), 要么将它放到 临时死区(temporal dead zone, TDZ), 也就是用 let 或 const 声明的变量. 访问 TDZ 中的变量会触发运行时的错误, 只有执行过变量声明语句后, 变量才会从 TDZ 中移出, 这时才可访问.
下面这个例子你能不能所有答对.
typeof null; // 'object'
typeof []; // 'object'
typeof someStr; // 'undefined'
typeof str; // Uncaught ReferenceError: str is not defined
const str = 'Yancey';
复制代码
第一个, 由于 null
根本上是一个指针, 因此会返回 'object'
. 深层次一点, 不一样的对象在底层都表示为二进制, 在 Javascript 中二进制前三位都为 0 的会被判断为 Object 类型, null 的二进制全为 0, 天然前三位也是 0, 因此执行 typeof 时会返回 'object'
.
第二个想强调的是, typeof 判断一个引用类型的变量, 拿到的都是 'object'
, 所以该操做符没法正确辨别具体的类型, 如 Array 仍是 RegExp.
第三个, 当 typeof 一个 未声明 的变量, 不会报错, 而是返回 'undefined'
第四个, str
先是存在于 TDZ, 上面说到访问 TDZ 中的变量会触发运行时的错误, 因此这段代码直接报错.
函数声明和变量声明都会被提高, 但值得注意的是, 函数首先被提高, 而后才是变量.
test();
function test() {
foo();
bar();
var foo = function() {
console.log("this won't run!");
};
function bar() {
console.log('this will run!');
}
}
复制代码
上面的代码会变成下面的形式: 内部的 bar
函数会被提高到顶部, 因此能够被执行到;接下来变量 foo
会被提高到顶部, 但变量没法执行, 所以执行 foo()
会报错.
function test() {
var foo;
function bar() {
console.log('this will run!');
}
foo();
bar();
foo = function() {
console.log("this won't run!");
};
}
test();
复制代码
闭包是指那些可以访问独立(自由)变量的函数(变量在本地使用, 但定义在一个封闭的做用域中). 换句话说, 这些函数能够「记忆」它被建立时候的环境. -- MDN
闭包是有权访问另外一个函数做用域的函数. -- 《JavaScript 高级程序设计(第 3 版)》
函数对象能够经过做用域链相互关联起来, 函数体内部的变量均可以保存在函数做用域内, 这种特性在计算机科学文献中称为闭包. -- 《JavaScript 权威指南(第 6 版)》
当函数能够记住并访问所在的词法做用域时, 就产生了闭包, 即便函数是在当前词法做用域以外执行. -- 《你不知道的 JavaScript(上卷)》
彷佛最后一个解释更容易理解, 因此咱们从"记住并访问"来学习闭包.
在 JavaScript 中, 若是函数被调用过了, 而且之后不会被用到, 那么垃圾回收机制(后面会说到)就会销毁由函数建立的做用域. 咱们知道, 引用类型的变量只是一个指针, 并不会把真正的值拷贝给变量, 而是把对象所在的位置传递给变量. 所以, 当函数被传递到一个还未销毁的做用域的某个变量时, 因为变量存在, 因此函数会存在, 又由于函数的存在依赖于函数所在的词法做用域, 因此函数所在的词法做用域也会存在, 这样一来, 就"记住"了该词法做用域.
看下面这个例子. 在执行 apple
函数时, 将 output
的引用做为参数传递给了 fruit
函数的 arg
, 所以在 fruit
函数执行期间, arg
是存在的, 因此 output
也是存在的, 而 output
依赖的 apple
函数产生的局部做用域也是存在. 这也就是 output
函数"记住"了 apple
函数做用域的缘由.
function apple() {
var count = 0;
function output() {
console.log(count);
}
fruit(output);
}
function fruit(arg) {
console.log('fruit');
}
apple(); // fruit
复制代码
但上面的例子并非完整的"闭包", 由于只是"记住"了做用域, 但没有去"访问"这个做用域. 咱们稍微改造一下上面这个例子, 在 fruit
函数中执行 arg
函数, 实际就是执行 output
, 而且还访问了 apple
函数中的 count
变量.
function apple() {
var count = 0;
function output() {
console.log(count);
}
fruit(output);
}
function fruit(arg) {
arg(); // 这就是闭包!
}
apple(); // 0
复制代码
下面是一道经典的面试题. 咱们但愿代码输出 0 ~ 4, 每秒一次, 每次一个. 但实际上, 这段代码在运行时会以每秒一次的频率输出五次 5.
for (var i = 0; i < 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
复制代码
由于 setTimeout 是异步执行的, 1000 毫秒后向任务队列里添加一个任务, 只有主线程上的任务所有执行完毕才会执行任务队列里的任务, 因此当主线程 for 循环执行完以后 i 的值为 5, 而用这个时候再去任务队列中执行任务, 所以 i 所有为 5. 又由于在 for 循环中使用 var
声明的 i
是在全局做用域中, 所以 timer
函数中打印出来的 i
天然是都是 5.
咱们能够经过在迭代内使用 IIFE 来给每一个迭代都生成一个新的做用域, 使得延迟函数的回调能够将新的做用域封闭在每一个迭代内部, 每一个迭代中都会含有一个具备正确值的变量供咱们访问. 代码以下所示.
for (var i = 0; i < 5; i++) {
(function(j) {
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})(i);
}
复制代码
若是你 API 看得仔细的话,还能够写成下面的形式:
for (var i = 0; i < 5; i++) {
setTimeout(function(j) {
console.log(j);
}, i * 1000, i);
}
复制代码
固然最好的方式是使用 let 声明 i, 这时候变量 i 就能做用于这个循环块, 每一个迭代都会使用上一个迭代结束的值来初始化这个变量.
for (let i = 0; i < 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
复制代码
上面提到, 函数被调用过了, 而且之后不会被用到, 那么垃圾回收机制就会销毁由函数建立的做用域. JavaScript 有两种垃圾回收机制, 即 标记清除 和 引用计数, 对于现代浏览器, 绝大多数都会采用 标记清除.
垃圾收集器在运行的时候会给存储在内存中的全部变量加上标记, 而后它会去掉环境中变量以及被环境中的变量引用的变量的标记. 而在此以后再被加上标记的变量将被视为准备删除的变量, 缘由是环境中的变量已经没法访问到这些变量了. 最后, 垃圾收集器完成内存清除工做, 销毁那些带标记的值而且回收它们所占用的内存空间.
引用计数是跟踪记录每一个值被引用的次数. 当声明了一个变量并将一个引用类型值赋给该变量时, 这个值得引用次数就是 1;相反, 若是包含对这个值引用的变量又取得了另一个值, 则这个值得引用次数减 1;下次运行垃圾回收器时就能够释放那些引用次数为 0 的值所占用的内存. 缺点:循环引用会致使引用次数永远不为 0.
Q: 什么是做用域?
A: 做用域是根据名称查找变量的一套规则.
Q: 什么是做用域链?
A: 当一个块或函数嵌套在另外一个块或另外一个函数中时, 就发生了做用域嵌套. 所以, 在当前做用域下找不到某个变量时, 会往外层嵌套的做用域继续查找, 直到找到该变量或抵达全局做用域, 若是在全局做用域中还没找到就会报错. 这种逐级向上查找的模式就是做用域链.
Q: 什么是闭包?
A: 当函数能够记住并访问所在的词法做用域时, 就产生了闭包, 即便函数是在当前词法做用域以外执行.
致使这篇文章写这么长的根本缘由就是 面试 该死的 var
关键字! 它就是一个设计错误!不要去用它!
以一道笔试题收尾:写一个函数, 第一次调用返回 0, 以后每次调用返回比以前大 1. 这道题不难, 主要是在考察闭包和当即执行函数. 我写的答案以下, 若是你有更好的方案请在评论区分享.
const add = (() => {
let num = 0;
return () => num++;
})();
复制代码
《JavaScript 高级程序设计 (第三版)》 —— Nicholas C. Zakas
《深刻理解 ES6》 —— Nicholas C. Zakas
《你不知道的 JavaScript (上卷)》—— Kyle Simpson
欢迎关注个人公众号:进击的前端