JavaScript那些不应该被忽视的细节
2023-05-20 08:50
### 一、JavaScript作为解释型语言如何运行
- **引擎**:负责整个JavaScript代码的编译和执行
- **编译器**:做分词、语法解析析、生成引擎的执行代码(虽然JavaScript是解释型语言,但也是有编译过程的)
- **作用域**:管理和维护所有的声明和变量,并确定当前执行的代码对哪些变量有访问权限
> 作用域像一个辅助,编译时收集和管理变量,引擎执行时又要决定哪些变量能被访问
### 二、词法作用域
作用域分两种,**词法作用域**和**动态作用域**。大部分编程语言都使用第一种,包括JavaScript。词法作用域决定于变量和块作用域写在哪,它是在编译器解析词法的过程中就确定的。这个作用域的目的是辅助引擎编译和执行代码。
我的理解就是通过花括号来判断词法作用域,所以它是由代码书写的范围来决定的。
当然在JavaScript中可以通过with和eval来动态改变词法作用域,但不推荐,因为动态的作用域让引擎执行时不能更好地进行优化,所以会对性能造成影响。
### 三、函数作用域与闭包
是JavaScript最常见的作用域,一个函数的内部信息是对外隐藏的,但却能访问到外部信息,所以嵌套函数也会形成作用域链。利用这个特性可以使用自执行函数和闭包。
### 四、块作用域
不同于函数作用域,块作用域在JS中比较少见,以前只有在with和catch中会形成块作用域,内部的声明不能被外部引用。后来出现了let和const,它们声明的变量可以**绑定**在当前的花括号为块的作用域内,但并非生成块作用域。
### 五、声明提升
因为在编译器运行的过程中,会根据词法定义好作用域,在这个过程中一个变量的声明和赋值会被挺升至作用域顶部,而函数声明则更加厉害,声明和赋值都被提升。
这么做的目的个人猜测是因为JS作为一个灵活的脚本语言,这种设置能够智能地减少报错,但随着JS程序的扩大这种“小聪明”式的特性会带来一些意想不到的bug,与此类似的还有重复使用var声明同一变量,因为编译器会首先去查找在本作用域内是否早有声明,有则直接覆盖,并不是真正意义上的重复声明。为了解决这些副作用,es6推出了let和const来进行变量声明,摒弃了上面这些特性。
### 六、this指向
函数调用时,会创建一个**执行上下文**来记录所有信息,包括调用栈、调用方式等,而this就是其中一个属性,指向调用对象。这也意味着,this是在函数调用时决定的。
这也是为什么初学者会对this指向有疑惑,因为它不同于作用域链是在词法分析阶段就确认的,this是动态的,所以会让人困扰。而箭头函数正是为了解决这个问题,箭头函数不绑定调用对象,而是通过作用域链找到上一次层的this。
四个确定this指向的操作,按优先级顺序排列:
1. new绑定对象;
2. call、apply、bind显式绑定;
3. 对象属性直接调用;
4. 普通函数调用,隐式绑定全局对象,如果是严格模式则绑定undefined。
### 七、对象
以前我一直有两个疑惑,一是为什么JS有string、number等基础类型,他们与对象Object是不同的,为何它们也有属性和方法;二是为什么要有String、Number等构造器来构造对应的对象,却还需要所谓的基础类型?
第一个问题的原因是,当声明一个字符串如`var s = 'abc';`然后取`s.length`的时候,其实引擎自动帮我们将基础类型string转成String对象,所以自然就拥有里*length*属性。至于第二个问题,应该是考虑到内存的优化问题,毕竟基础类型只要存在栈上,也不像对象需要一个构造的过程,有性能的优势。
对象有属性描述符,顾名思义,就是对对象属性进行描述或者说规定。通常使用`Object.defineProperty`来进行配置,有`value、writable、enumerable、configurable`,通过这四个属性可以控制这个对象属性的增删改查。
[[GET]]是每个对象都会有的一个内置方法,用于访问属性,每次访问属性时都相当于`GET()`,有一个细节是,这个方法在本对象拿不到属性时,会尝试在原型链上查找。对应的还有个[[PUT]]内置方法,用于设置属性。
上面两个配置方法也可以被属性中的Getter和Setter来代替,这个同样也是由开发者通过`Object.defineProperty`修改的。其实Getter和Setter被称为访问描述符,通过设置对象属性的Getter和Setter也可以达到控制属性增删改查的效果,只是更灵活。
### 八、原型链
每个对象都有个[[prototype]]内置属性,他指向对象的原型,也就是原型链的形态。那么为什么需要原型呢?这是我一直的疑问,因为它虽然听起来很方便,可以向上找到属性,但却给这门语言带来了难以理解的复杂性。
如果说编程可以创造世界,那么我们为了避免重复描述,一般都会将一些通用描述“封装”起来,比如交通工具一般都有引擎,这是动力的来源,那么我们在创造汽车、轮船、飞机的时候就不需要去重复描述引擎这件事,因为是个交通工具就肯定有引擎的呀。那么我认为原型存在的目的就是去“封装”某些描述,这有点像类和类继承的概念。但是类继承其实是一个复制的方式,因为父子类是互不关联的,但原型链并不是。
而且由于JavaScript中一切皆对象,为了保证性能复制对象其实只是复制引用地址,所以自然而然地就出现原型链这种方式,以关联对象的方式达到“继承”的目的,原型链就是这种思想下的产物。
当你尝试去拿某个对象的属性时,会调用其内部方法[[GET]],它会先查找本对象,找不到再往原型链上找。
同样的设置属性值也是,当你设置`myObj.foo=123`时,会先查找myObj本身是否有,有则覆盖。myObj本身没有的话,我以前以为会直接添加,但实际并不是,它依然会先找原型链,如果上一级对象有相同属性则直接屏蔽掉它,再在myObj上添加,这听起来跟直接添加从结果上来说差别不大,但问题是当原型链上设置的同名属性是不可写的则这个操作不会进行下去,而是会报错(严格模式)或直接静默失败,如果原型链上设置了setter则会直接调用这个setter而不会在myObj上添加foo。使用defineProperty可以避免这个小问题。
### 九、Promise
在es6之前,异步操作都是跟JavaScript的宿主环境有关,所以异步操作往往是在的某个线程进行之后再通知js执行回调。但是es6引入了事件循环机制,同时把异步管理纳入了js引擎的范畴。
那么为什么需要引进时间循环或者说为什么需要promise呢?这主要是为了解决js传统的异步回调带来的问题:回调地狱以及回调的不确定性。promise的then和catch能清晰地描述异步步骤解决回调地狱。而promise.then只调用一次也避免了异步回调的不稳定性(回调的调用者可能不受控,可能被调用多次或者吞掉错误)。
promise意为承诺,承诺给你一个结果,拿到结果我就做接下来的事,而回调是将我要做的事告诉对方等他愿意做的时候再帮我做,他会做几次、会不会出错我都不确定。简单来说,用回调来处理异步事件是一件存在很多坑的事。
还有一些点,如promise.all解决多异步共享结果,race解决竞态,promise里只有同步操作也必须入队列解决同步代码导致执行顺序预期不正确。这些都是锦上添花的语法糖。
promise的错误处理有一点需要注意:
```js
// Promise.resolve会将传入参数promise化,如果传入的参数带有then属性,则会直接将其promise化并展开调用
p = Promise.resolve(123)
p.then((res) => {
throw Error() // 报错
console.log(res) // !!这里永远不会执行
}, (err) => {
// 我以为这里会执行,但不会。因为p.then实际会返回一个默认的Promise.resolve(undefined)
// 所以p和p.then是两个单独的promise,故这个reject回调不会被调用
console.log('error 1')
}).then(() => {
console.log("这里会执行吗") // 并不会执行
// 实际这个then里会默认补充一个rejected回调: (err) => Promise.reject(err)
// 所以报错error会继续传递下去
}).catch((err) => {
// 这里才会catch到p.then的错误
console.log('error 2')
})
// 结果:error 2
```
### 十、事件循环
JavaScript会维护一个执行栈,把所有函数的上下文合成一个栈帧压入栈,执行完后出栈,而关于异步的操作事件则是维护一个队列,循环去取出队列中的回调压入栈。
当执行栈清空后就开始清队列,规则是在每个循环中,不断对队列执行下面三个步骤:
- 执行一次宏任务(所有运行在外部事件源的任务,这里指浏览器的DOM操作、Ajax、用户交互、History Api、定时器、script标签的执行)
- 执行队列内**所有的微任务**(es6后加入的Promise和MutationObserver)
- 渲染页面
```js
setTimeout(() => {
console.log('setTimeout 1');
Promise.resolve().then(() => {
console.log('Promise 3');
}).then(() => {
console.log('Promise 4');
});
});
Promise.resolve().then(() => {
console.log('Promise 1');
}).then(() => {
console.log('Promise 2');
});
console.log('start')
setTimeout(() => {
console.log('setTimeout 2');
});
/**
结果:
"start"
"Promise 1"
"Promise 2"
"setTimeout 1"
"Promise 3"
"Promise 4"
"setTimeout 2"
*/
```
这里有一点要注意,按我上面的循环步骤,应该至少要先执行一次宏任务(setTimeout)才对,为什么还是先执行了所有Promise的结果,原因是这段代码会运行在<script>标签里,而这个标签的运行是一次宏任务。所以我之前一直误解是微任务先执行。