最近在看大神写的专栏,很精辟,笔者想通过总结的方式加深理解,不一定准确,只是笔者自己的想法,欢迎指正。
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("大卖网")("母婴", "奶瓶");