作用域相关的知识点:闭包、执行上下文、LHS/RHS、词法作用域
最近在看大神写的专栏,很精辟,笔者想通过总结的方式加深理解,不一定准确,只是笔者自己的想法,欢迎指正。
TL;DR
- 作用域:存储和访问变量的规则
- 作用域链:寻找变量形成的路
- 变量提升:
console.log(a);var a
,编译器先var a
,之后才是 JS 引擎执行console.log(a)
- 暂时性死区:
let
命令声明变量之前,该变量都是不可用的。上面的换成let
就会报错。 - 执行上下文:常常是函数调用的时候,JS 引擎先做一些执行前的准备工作。
- 闭包:一个函数,使用了非自己作用域的变量。常用来私有化变量、柯里化
- LHS/RHS:变量出现在赋值的左边,就是变量就进行了
LHS
,否则就是RHS
(就是读取啦) - 词法作用域:作用域链沿着它定义的位置往外延伸
- 欺骗词法作用域:eval和with
作用域
每一种编程语言,它最基本的能力都是能够存储变量当中的值、并且允许我们对这个变量的值进行访问和修改。
那么有了变量之后,应该把它放在哪里、程序如何找到它们?
这就需要我们提前约定好一套存储变量、访问变量的规则?这套规则,就是我们常说的作用域。
作用域的本质:是程序存储和访问变量的规则。
举个例子说明下规则:
var a = 1; // 相当于:var a;a=1
var a
,让编译器先在当前作用域寻找,有没有 a 的变量,有则忽略,没有则增加 a 变量a=1
,让JS 执行引擎先在当前作用域寻找,有没有 a 的变量,有则赋值,没有则继续向上找,直到顶层的全局作用域,找不到就报错。
以上就是规则,也就是作用域限制了存储和赋值。
作用域链
没有则继续向上找。
这句话,一层层向上,像链条一样的,就是作用域链。
顶级作用域:是全局作用域。 局部作用域:是函数作用域和块作用域。
顶级作用域就是顶层,在局部作用域里肯定可以访问顶层的作用域的变量。局部作用域则看其嵌套关系。
举个例子说下作用域链:
function addABC() { var a = 1; var b = 2; function add() { return a + b + c; } return add; } var c = 3; var globalAdd = addABC(); console.log(globalAdd()); // 6
全局作用域:c、globalAdd => addABC 函数作用域:a、b => add 函数作用域:没有变量。
像不像链子?链子的尽头是全局作用域。
add 函数作用域的直接上层是 addABC 函数作用域,再往上是全局作用域。
所以 add 函数作用域,可以读取到a/b/c变量。
闭包
add 函数作用域,直接访问非自己作用域的a/b/c变量,这就是闭包。
在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。
写高阶函数常常用到。
执行上下文
引用不写 bug 的米公子的话:
es3 中,函数被调用,在执行具体的函数代码之前,创建了执行上下文,从而进入执行上下文的创建阶段:
- 初始化作用域链
- 创建 arguments object 检查上下文中的参数,初始化名称和值并创建引用副本
- 扫描上下文找到所有函数声明:对于每个找到的函数,用它们的原生函数名,在变量对象中创建一个属性,该属性里存放的是一个指向实际内存地址的指针。如果函数名称已经存在了,属性的引用指针将会被覆盖
- 扫描上下文找到所有 var 的变量声明:对于每个找到的变量声明,用它们的原生变量名,在变量对象中创建一个属性,并且使用 undefined 来初始化。如果变量名作为属性在变量对象中已存在,则不做任何处理并接着扫描
- 确定 this 值
做完这些准备工作之后,才开始真正执行函数中的代码。
来个稍微复杂的例子
foo(); var foo = function foo() { console.log("foo1"); }; var a = 1; function foo() { console.log(a); var a = 2; console.log(a); } foo(); console.log(a);
上面的代码,可以理解为
function foo() { var a; console.log(a); a = 2; console.log(a); } var a; // 此时foo执行的时候,第一个是undefined,第二个才是2。注意这只是局部变量。 foo(); // foo被重新赋值了 foo = function foo() { console.log("foo1"); }; // a赋值了 a = 1; // 没有悬念的foo1 foo(); // 没有悬念的1 console.log(a);
LHS/RHS
听起来就感觉好高深。
其实没啥,最开始说的编辑器查找变量,如果查找的目的是对变量进行赋值, 那么就会使用 LHS 查询; 如果目的是获取变量的值, 就会使用 RHS 查询。赋值操作会导致 LHS 查询。
快速速记法:L 就是 Left,R 就是 Read。(不过这两本身是 left hand side 和 right hand side。)
// name出现在左边,进行赋值操作,就是对name进行了LHS var name = 2; // name被读取,就是对name进行了RHS console.log(name); // name仍然是被读取,就是对name进行了RHS // newName出现在左边,进行赋值操作,就是对newName进行了LHS var newName = name;
此文写的很细致了:LHS 和 RHS----你所不知道的JavaScript系列(1)
词法作用域和动态作用域
词法作用域和动态作用域的区别其实在于划分作用域的时机:
- 词法作用域: 在代码书写的时候完成划分,作用域链沿着它定义的位置往外延伸
- 动态作用域: 在代码运行时完成划分,作用域链沿着它的调用栈往外延伸
var name = "yan"; function showName() { console.log(name); } function changeName() { var name = "BigBear"; showName(); } changeName();
js 遵循的是词法作用域,所以 showName 的外层作用域是全局作用域,会打印yan
如果是动态作用域,那么 showName 的外层作用域就是 changeName 函数作用域了,会打印BigBear
如何欺骗词法作用域
其实欺骗就是改变的意思,听起来又很玄学,主要就是下面的一句话。
eval
和with
可以修改词法作用域,但因为这种特性,所以尽可能不要用这两个语句。
举个例子:
function showName(str) { eval(str); console.log(name); } var name = "xiuyan"; var str = 'var name = "BigBear"'; showName(str); // 输出 BigBear
eval 拿到一个字符串入参后,它会把这段字符串的内容当做一段 js 代码(不管它是不是一段 js 代码),插入自己被调用的那个位置。导致了 showName 作用域多了一个变量 name,这个是在运行时才创建的变量,所以欺骗了词法作用域。
with 用法有类似的功能,不再赘述(笔者偷懒不想研究了)~
作用域的经典题目
经典的一个题目,循环体和作用域的组合:
for (var i = 0; i < 5; i++) { setTimeout(function() { console.log(i); }, 1000); } console.log(i);
结合之前的学到的,上面的代码,虚拟的等价替换下:
var i; for (i = 0; i < 5; i++) {} console.log(i); // 5个setTimeout在1s后依次执行 console.log(i); console.log(i); console.log(i); console.log(i); console.log(i);
所以很明显,打印了 6 个 5。
三种改造方式:让 i 从 0 到 4 依次被输出
本质:将 i 由全局变量变成局部变量,以此互不相关。
上面的知易行难啊。。。。。
1. setTimeout 的第三个参数
setTimeout 从第三个入参位置开始往后,是可以传 入无数个参数的。这些参数会作为回调函数的附加参数存在。
j 当前的作用域,是函数作用域。
for (var i = 0; i < 5; i++) { setTimeout( function(j) { console.log(j); }, 1000, j ); }
2. 包个函数
虽然 i 在当前的作用域没有找到变量,但是在其外层增加一个函数作用域,也是同样的道理。
for (var i = 0; i < 5; i++) { (function(i) { setTimeout(function() { console.log(i); }, 1000); })(i); }
3. let
局部作用域还有一个块级作用域,异曲同工之妙。
for (let i = 0; i < 5; i++) { setTimeout(function() { console.log(i); }, 1000); }
作用域特训题目
function test() { var num = []; var i; for (i = 0; i < 10; i++) { num[i] = function() { console.log(i); }; } return num[9]; } test()();
var test = (function() { var num = 0; return () => { return num++; }; })(); for (var i = 0; i < 10; i++) { test(); } console.log(test());
var a = 1; function test() { a = 2; return function() { console.log(a); }; var a = 3; } test()();
function foo(a, b) { console.log(b); return { foo: function(c) { return foo(c, a); } }; } var func1 = foo(0); func1.foo(1); func1.foo(2); func1.foo(3); var func2 = foo(0) .foo(1) .foo(2) .foo(3); var func3 = foo(0).foo(1); func3.foo(2); func3.foo(3);
闭包应用
变量只在特定的局部作用域,外部的作用域并不能访问到该变量。
私有化变量
密码不希望被人知道user.password
// 利用闭包生成IIFE,返回 User 类 const User = (function() { // 定义私有变量_password let _password; class User { constructor(username, password) { // 初始化私有变量_password _password = password; this.username = username; } login() { // 这里我们增加一行 console,为了验证 login 里仍可以顺利拿到密码 console.log(this.username, _password); // 使用 fetch 进行登录请求,同上,此处省略 } } return User; })(); let user = new User("xiuyan", "xiuyan123"); console.log(user.username); // undefined console.log(user.password); // undefined console.log(user._password); user.login();
偏函数和柯里化
柯里化:是把接受 n 个参数的 1 个函数改造为只接受 1 个参数的 n 个互相嵌套的函数的过程。也就是 fn(a,b,c)会变成 f(a)(b)(c) 。
function generateName(prefix) { return function(type) { return function(itemName) { return prefix + type + itemName; }; }; } // 啥也不记,直接生成一个商品名 var itemFullName = generateName("洗菜网")("生鲜")("菠菜");
偏函数:和柯里化相似,但仅仅是把函数的入参拆解为两部分。
function generateName(prefix) { return function(type, itemName) { return prefix + type + itemName; }; } // 把3个参数分两部分传入 var itemFullName = generateName("大卖网")("母婴", "奶瓶");