【你不知道的JS】一共上中下三册,包含内容有:作用域和闭包、this和对象原型、类型和语法、异步和性能、ES6及更新版本等内容,整理本篇博客目的主要是整合所学基础知识
作用域和闭包
引擎、编译器、作用域
代码执行前的编译会经历的三个步骤:
- 词法分析:将字符组成的字符串分解成有意义的代码块(词法单元),会有特定步骤进行性能优化
- 语法分析:将词法单元转化成一个抽象语法树(AST),会有特定步骤进行性能优化
- 生成代码:将AST转化为可执行代码
引擎、编译器、作用域的任务:
- 引擎:负责JS的编译、执行过程
- 编译器:负责语法分析、代码生成
- 作用域:收集维护变量、有一套严格的规则(如:词法作用域)
当你给一个变量(函数)赋值时:
- 声明:编译器询问作用域是否存在该变量,如没有,则进行声明
- 查找:运行时引擎在作用域查找该变量,找到就给它赋值
引擎查找类型分为 LHS 和 RHS:
- LHS:找到变量容器并进行赋值(非严格模式下找不到变量时会自动声明,否则抛出
ReferenceError
) - RHS:找到变量的源值(找不到时会抛出
ReferenceError
、查找到变量但是进行不合理的操作时会抛出TypeError
)1
2
3
4function foo(a) {
console.log(a) // RHS
}
foo(2) // foo 使用了 RHS,2 是对变量 a 进行赋值所以使用的是 LHS
作用域嵌套:
- 引擎无法在当前作用域查找到变量时,会向上一级继续查找,直到最外层的全局作用域
作用域规则–词法作用域
作用域的两种工作模型:
- 动态作用域:运行时确定,关注在何处调用(和JS中的this很像)
- 词法作用域:定义时确定,关注在何处声明,只会查找一级标识符,如:
a.b.c
,只会查找a
(JS所使用的)
欺骗词法作用域的方法:
eval(...)
:非严格模式下:eval 所执行的代码如果包含变量(函数)声明,在运行时能对词法作用域进行修改,严格模式下 eval 有自己的作用域with(...)
:非严格模式下:通常被当作重复引用同一个对象中多个属性的快捷方式,实际是根据传递的对象创建新的词法作用域,严格模式下会被禁止1
2
3
4
5function foo(str, a) {
eval(str) // 在执行时声明了 b
console.log(a, b) // 2 3
}
foo("var b = 3", 2)
欺骗词法作用域导致的性能问题:
- 问题:引擎无法在编译时对作用域查找进行优化,导致代码运行变慢
- 原因:有些依赖于对词法进行静态分析,预先确定变量函数定义的位置,以便快速找到标识符,但是如果发现
eval
和with
,引擎只能假设标识符的位置判断都是无效的
函数作用域和块作用域
什么是函数作用域和块作用域:
- 函数作用域:有作用域气泡,这个函数的全部变量都可以在函数范围内使用及复用
- 块作用域:ES6 中的
const
和let
可声明块级作用域,在块{}
范围内使用和复用,除此之外还有try/catch
,with
函数作用域可以用来做什么?
- 隐藏代码:把变量和函数包裹在一个函数作用域中,用这个作用域隐藏它们
隐藏代码的好处:
- 规避冲突:避免同名标识符之间的冲突(规避冲突的两种解决方式:全局命名空间、模块管理工具)
函数名本身会污染所在全局作用域,怎么解决?
- 使用立即执行函数表达式
(function foo() {..})()
,将变量名隐藏在自身中(此时foo
只能在...
处被访问),也称IIFE
(也可以是匿名)
区分函数声明和函数表达式:
- 函数声明:
function
是声明的第一个词,名称标识符绑定在所在作用域 - 函数表达式:
function
不是声明的第一个词,名称标识符绑定在函数自身
变量提升
- 变量提升:即所有声明(变量和函数)被移动到各自作用域的最顶端(
const
和let
不存在变量提升) - 提升顺序:函数会先提升,然后是变量
闭包
- 什么是闭包:当一个函数记住并访问所在词法作用域时就产生了闭包,即使函数是在当前词法作用域之外执行
- 闭包的作用:保持对一个作用域的引用,使作用域一直存活
- 回调函数与闭包:定时器、事件监听器、Ajax请求、跨窗口通信、web workers或其他异步(或同步任务)中,只要使用了回调函数,实际上就是在使用闭包
- 模块与闭包:调用模块中的方法时,实际就是在使用闭包,没有闭包的模块不是真正的模块
- 循环与闭包:循环过程中每个迭代都需要一个闭包作用域
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44for(var i = 1; i < 4; i++) {
setTimeout(() => {
console.log(i) // 4 4 4
}, i*1000)
}
// 利用 IIFE 创建一个作用域,并创建一个变量用来保存每个迭代中的 i
for(var i = 1; i < 4; i++) {
(function() {
var j = i
setTimeout(() => {
console.log(j) // 1 2 3
}, j*1000)
})()
}
for(var i = 1; i < 4; i++) {
(function(j) {
setTimeout(() => {
console.log(j) // 1 2 3
}, j*1000)
})(i)
}
// 给 setTimeout 传递第三个参数,定时器到期就传递给定时器中的函数
for(var i = 1; i < 4; i++) {
setTimeout((j) => {
console.log(j) // 1 2 3
}, i*1000, i)
}
// 使用 let 劫持作用域
for(var i = 1; i < 4; i++) {
let j = i
setTimeout(() => {
console.log(j) // 1 2 3
}, j*1000)
}
for(let i = 1; i < 4; i++) {
setTimeout(() => {
console.log(i) // 1 2 3
}, i*1000)
}
this和对象原型
关于this
this
是在运行中被调用时绑定的,它不指向函数本身,也不指向函数的词法作用域- 函数如何引用自身?第一种:具名函数可在函数内部通过函数名引用自身,第二种:匿名函数可通过
arguments.callee
引用自身(被弃用了),第三种:可通过call将this绑定在自身上 - 匿名函数的缺点?第一:调试栈更难追踪,第二:自我引用(递归、事件绑定解除等)更难,第三:代码(稍微)更难理解
this的绑定规则
- 默认绑定:非严格模式下绑定到全局对象,严格模式下绑定大到
undefined
(函数体是否严格,而非调用位置) - 隐式绑定:如果调用位置有上下文对象,会绑定到这个上下文对象上。注意⚠️:隐式绑定后不能再赋值给另一个变量进行调用,否则会造成隐式丢失,如:
a.b
中b的this指向a,c = a.b
中b的this就会指向全局而不是a,解决办法:使用硬绑定c = b.bind(a)
可使b中的this指向a - 显式绑定:使用
bind
、call
、apply
硬绑定指定this,但是如果传入null`
undefined会被忽略,会进行默认绑定,所以如果显式绑定一个空对象,可以使用
Object.create(null)`创建一个没有原型的空对象传进去 - new绑定:构造函数(使用new时被调用的函数)中的this指向它的实例对象
- 如何确定this应用于哪条绑定规则?先通过调用位置判断,如果一个位置可以应用多条规则,则通过优先级确定
- 优先级:默认绑定 < 隐式绑定 < 显式绑定 < new绑定
- 注意⚠️:调用间接引用的函数会应用默认绑定的规则
- 固定this:回调函数造成的this丢失可以通过固定this来解决,第一种:词法作用域风格–箭头函数,第二种:词法作用域风格–将this赋值给一个变量,第三种:this风格的绑定bind(this),存在词法作用域风格的代码和this风格,在代码最好只保持一种风格
使用new时会发生什么?
- 创建一个新对象(空对象)
- 新对象的原型指向构造函数的原型
- 新对象绑定到构造函数中的this
- 返回新对象
关于对象
- 定义方式:声明形式、构造形式
- 属性名:永远是字符串,且可通过
[变量 + 变量]
形式计算出来 - 复制:浅拷贝(
Object.assign({}, obj)
) + 深拷贝
对象属性:
- 属性描述符:设置属性的特性,
writable: false
使属性不可修改,configurable: false
使属性不可重定义、不可删除,enumerable: false
使属性不可枚举。相关博客:Object.defineProperty和Proxy - 查看属性描述符:
Object.getOwnPropertyDescriptor(obj, 'key')
- 禁止对象扩展:
Object.preventExtensions(obj)
使对象不可添加新属性,但是现有属性可修改、可删除。判断是否可扩展:Object.isExtensible(obj)
- 密封对象:
Object.seal(obj)
使对象不可添加新属性,现有属性不可重定义、不可删除,但是可修改(相当于Object.preventExtensions(obj)
+configurable: false
)。判断是否密封:Object.isSealed(obj)
- 冻结对象:
Object.freeze(obj)
使对象不可添加新属性,现有属性不可重定义、不可删除、不可修改(相当于Object.seal(obj)
+writable: false
)。判断是否冻结:Object.isFrozen(obj)
- 获取对象的属性:
Object.keys()
返回对象自身的可枚举的属性
遍历对象:
for in
:遍历的key,检查自身 + 原型链中的属性,遍历可枚举的属性for of
:遍历的是value,向对象请求一个迭代器,使用迭代器的next()
方法进行遍历,对象没有内置迭代器,所以需要自定义对象迭代器@@iterater
(原型中存在Symbol.iterator
属性),相关博客:JS使用小技巧整理–创建一个可迭代对象
对象存取值:
- 取值:触发[Get]。查找属性步骤:存在存取操作符
getter
?获取getter
的返回值 => 在对象中找 => 在原型中找 => 返回undefined
- 赋值:触发[Put]。检查操作属性:存在存取操作符
setter
?调用setter
进行赋值 =>writable
为false
?赋值无效 => 都不是?赋值成功
检查属性是否存在:
hasOwnProperty()
:只会检查自身,不会检查原型,所以不能判断没有原型的对象如Object.create(null)
用法:Object.prototype.hasOwnProperty.call(obj,'属性名')
示例- 使用
in
操作符:检查自身 + 原型链中的属性,包括不可枚举的(如果是数组:只会检查下标)
关于类
- 类是一种设计模式,使用new可以将类实例化
- 面向类的设计模式:实例化、继承、多态
- 混入模式(mixin):可以用来模拟类的复制行为,但是有很多隐患
原型
[[prototype]]
- 对象之间通过内部的
[[prototype]]
关联 - 所有
[[prototype]]
的终点都是Object.prototype
- 修改
prototype
后,新prototype
的constructor
属性(不可枚举、可修改)不会自动获得,需要手动赋值
Object.create()
- 创建一个对象,并把这个对象的
[[prototype]]
关联到指定的对象 Object.create(null)
会创建一个没有原型链的对象,不会受原型链干扰,适合存储数据Object.create()
的polyfill
1
2
3
4
5Object.create = function(obj) {
function F() {}
F.protoptype = obj
return new F()
}
Object.getPrototypeOf()、Object.setPrototypeOf()、isPrototypeOf()1
2
3
4
5
6
7
8
9
10
11
12// Object.getPrototypeOf() 查看某个对象的原型
function A() {}
let a = new A()
Object.getPrototypeOf(a) === A.prototype
a.__proto__ === A.prototype // __proto__ 非标准
// isPrototypeOf() 测试一个对象是否存在于另一个对象的原型链上
A.prototype.isPrototypeOf(a)
// Object.setPrototypeOf() 关联两个对象的原型
a.prototype = Object.create(b.prototype) // ES5 抛弃默认的a.prototype(需要进行垃圾回收)
Object.setPrototypeOf(a.prototype, b.prototype) // ES6 直接修改现有的a.prototype
行为委托
- 内部委托比起直接委托可以让API接口设计更加清晰
- 行为委托认为对象之间的兄弟关系,相互委托,而非父子关系,[[prototype]]机制本质上就是行为委托
- 两种设计模式:面向对象风格(类风格,构造函数、原型、new)、对象关联风格(委托风格,直接穿件和关联对象,使用基于[[prototype]]的行为委托,干净简洁)
- ES6的Class是类风格的语法糖
类型和语法
我的另外几篇关于数据类型的博客:数据类型介绍 数据类型判断 数据类型转换
异步和性能
ES6及更新版本等内容
具体可参考阮一峰大大的 ECMAScript入门