我们的代码在浏览器上是怎么运行的
理解js的背后运行原理
首先,这是一个很大的话题,在了解js是怎么运行前,我们需要先去掌握一些基本概念,掌握了这些概念,我们才能更好的去理解js在背后是怎么运行的。
线程和进程
我们老是听别人说,js是单线程的。
那么问题来了。
- 什么是进程?
- 什么是线程?
- 线程又和我们常听说的进程有什么关系呢?
- 为什么js要是单线程的呢?
什么是进程?
这个问题要追溯到计算机诞生初期,那时候的计算机是由一个一个的晶体管组成,功能是比较单一的,通常只能做固定的某件事情, 要想它去做另一件事情,需要对它进行重新组装,成本非常大, 所以后面就诞生了操作系统, 由操作系统对计算机内存,cpu等进行同一管理,然后对外暴露出统一的接口, 程序要想使用计算机的哪项功能直接调相应的接口即可。这样就可以很方便的在同一台计算机上运行各种各样的程序了。 但是最开始我们的电脑只有单核CPU,一次只能处理一件事情,那么计算机是怎么同时运行两个以上的程序的呢? 。那是因为计算机的CPU的运行速率,内存和外存的存取速度又不在一个量级上。所以在电脑上同时运行多个应用程序时,就会出现某个应用程序在I/O操作的时候,CPU处于闲置状态,这个时候就会将这个程序先挂起,切换到下一个程序去执行,等它I/O完毕后再切换回来处理。为了方便对程序来回切换的管理和避免程序之间的相互干扰, 就引进了 “进程” 这一概念。 进程是CPU资源分配的最小单位,一个进程对应一个应用程序,一个进程一次也只能执行一个任务。进程拥有分配计算机内存等资源的权力。多个进程可以同时工作。
什么是线程?
随着我们项目越来越复杂, 一个应用可能会同时做多件事情,而一个进程却一次只能做一个任务,所以就又把进程细化为线程,也就是一个进程下有多个能独立运行的更小的单位。**线程是进程的一个实体,是CPU调度和分派的基本单位。**线程不能够独立执行,必须依托于进程中。
换个角度来看进程和线程
我们可以把一个应用程序想成我们要把一批布料加工成衣服
这时候就需要有一个工厂来加工,这个工厂就是一个进程。
一个工厂至少要有一个工人, 工人就是线程
如果我需要加工的布料很多,为了提高效率。
我可以同时让多个工厂来加工,这就是多进程
也可以让工厂招多个工人,这就是多线程。
为什么js是单线程的?
js是单线程,与它的用途有关。作为浏览器脚本语言,js的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,js就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
但这并不是意味着js不能利用多核CPU,HTML5提出Web Worker标准,允许js创建多个子线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
总结:
- 多线程,多进程效率高,但涉及到信息同步,上下文切换,就显得较为复杂。
- 单线程就较为简单,一次只做一件事。自然效率也就比较低。
- 进程是CPU资源分配的最小单位,线程是CPU调度的最小单位。
- 一个程序至少有一个进程,一个进程至少有一个线程
执行栈
执行栈 又称为 调用栈(这里我们统称执行栈),是用来存放函数执行时生成的执行上下文的,主要用于js解释器追踪函数的执行。
我们在调用一个函数的时候,解释器就会根据函数生成一个执行上下文,并压入栈的底部,如果函数内有调用其他函数,又会继续生成一个新的执行上下文,并添压入栈顶。
我门来看一个例子
function fn() {
console.log(1)
fn1()
fn2()
}
function fn1() {
console.log(2)
fn3()
console.log(3)
}
function fn2() {
console.log(4)
}
function fn3() {
console.log(5)
}
fn(); // 调用fn
我们用伪代码来解释下调用fn()
后执行栈的变化。
初始状态的执行栈
- GlobalContext
调用 fn()
方法后
- FnContext
- GlobalContext
将fn()
生成的上下文压入栈顶后,会立即执行对应的代码,于是打印了 1
,然后遇到了 fn1()
函数的调用。
- Fn1Context
- FnContext
- GlobalContext
将 fn1()
加入执行栈后,也会立即执行fn1()
方法内的代码, 于是打印出了 2
, 然后发现调用了fn3()
方法。
- Fn3Context
- Fn1Context
- FnContext
- GlobalContext
执行 Fn3
对应的代码, 打印出了5
, 发现 Fn3
内后续没有可执行的代码了。就会将该执行上下文弹射出执行栈
- Fn1Context
- FnContext
- GlobalContext
继续执行 Fn1
没有执行完的代码,打印出 3
, 发现没有可执行代码后,该执行上下文也会被弹出执行栈
- FnContext
- GlobalContext
继续 Fn
中的后续代码,遇到 fn2()
的调用
- Fn2Context
- FnContext
- GlobalContext
执行 Fn2
的代码。打印出 4
,完毕后弹出
- FnContext
- GlobalContext
Fn
也没啥可执行的了,弹出
- GlobalContext
GlobalContext
是全局的执行上下文,进入程序就会生成,只会在应用程序退出时才会被弹出执行栈。
总结:
-
每调用一个函数,就会在执行栈顶添加一个执行上下文并执行
-
当函数内调用了另一个函数,也会在栈顶添加一个新的执行上下文
-
当函数执行完毕后,对应的执行上下文就会被弹出执行栈,继续执行后续代码
-
执行栈是一种先入后出(LIFO)的数据结构,
-
进入应用程序,就会在执行栈底添加一个全局的执行上下文
-
当程序退出的时候全局的执行上下文才会被清除
-
当分配的栈空间被沾满时,就会引发 堆栈溢出,递归函数不设停止条件就会导致这个问题。
执行上下文
上面我们讲到每一个函数调用, 都会生成一个对应的执行上下文,然后才会执行函数内的代码。 那么这个执行上下文到底是什么呢?
什么是执行上下文?
我们可以理解成代码的活动范围。
它可以分成三种类型,
全局执行上下文:上面我们也提到过进入程序后,执行栈底就会有一个全局的执行上下文,它只会在程序退出时弹出。
函数类执行上下文:这种类型的上下文会在函数被调用时生成并压入执行栈顶,函数执行完毕后弹出执行栈。
eval类执行上下文: 在用 eval()
函数执行一段代码时生成,不推荐使用。
每个执行上下文都包含了三个属性。
- 绑定的this值
- 活动对象
- 变量对象
EventContent = {
this: value,
AO: {},
Scope: {},
}
This的绑定
我们都知道js是一门面向对象的语言, 所以为了在程序中方便引用对象,采用了this关键字来表示当前的环境对象。
我们在函数中可以随时的使用 this 这个关键字,所以我们在执行上下文中要指明This到底指向谁,一般情况下会有如下几种情况
fn() // window 严格模式下为undefined
obj.fn() // obj
const p = new Person() // p
活动对象
词法环境可以理解成一种 标识符 — 值 相互映射的一个对象,存放着函数内声明的变量,函数等。
标识符指的是(变量名,函数名,参数名)
值指的是 (实际的值,或者值得引用)
变量对象
变量对象存放的是当前执行上下文的活动对象和所有父级执行上下文的活动对象,其实就是一个作用域链。
执行上下文的创建
js引擎在执行代码的时候,其实还有一个预编译的过程,编译完成支持才会执行具体的代码语句。 我们把他分成如下两个阶段
-
代码解析阶段:
- 绑定this值
- 提出所有函数形参当作活动对象的属性,并初始化值,值为实参,入没传实参,则值为undefined
- 提出所有函数名当作活动对象的属性,并初始化值,如有同名函数, 后着会替换前者。
- 提出var声明的变量名作为活动对象的属性,并初始化值,其值此时全是(undefined)
- 提出let 或 const声明的变量(常量),不初始化值, 所以存在暂时性死区。 所以let和const声明的变量到底提升了吗?
- 拿到函数的[[scope]]属性和当前的活动对象赋值给变量对象
-
代码执行:
这一阶段会执行函数体内的代码, 重新更改活动对象的属性值。
这么说可能有点不太直观,下面我们来看如下代码。
var hobby = 'game'
function person(name, age) {
console.log(hobby) // Cannot access 'hobby' before initialization
console.log(profession) // undefined
let hobby = 'play'
var profession = 'programmer'
say()
}
function say() {
console.log(hobby) // game
}
person('miracle', 18)
注意:
问: person() 内打印 hobby 变量时为什么报错,而不是打印game?
答: 因为调用person函数时,生成执行上下文,会把函数类所有的变量函数名,参数提升, 所有在打印hobby这个变量的时候,会先到自己的活动对象中寻找有没有这个变量, 发现有, 所以就会打印它, 至于为什么会报错? 是因为这个变量是let声明的, 只是提升了,但是没有初始化值, 所以报错 Cannot access 'hobby' before initialization
。
问: say()方法中打印 hobby
为什么是 game, 而不是 play ?
答: 这是因为js是属于词法作用域, 也就是变量的作用域在声明的时候就已经决定了,而不是调用时决定的。每个函数都有一个隐藏属性: [[scope]], 里面包含了根据词法作用域筛选出来的所有父级函数生成的执行上下文的活动对象。 在代码解析阶段生成活动对象之后, 就会将当前执行上下文的活动对象 和 函数的 [[scope]] 属性值合并,当作变量对象的值。 所以函数在查找一个变量的时候, 首先回到自己的活动对象中查找, 如果找不到, 就会到父级的活动对象(根据词法作用域规则筛选之后的对象)中查找,再找不到又会到父级的父级的活动对象中查找,一直查到全局活动对象为止。
我们用伪代码来解释下调用上面函数之后发生了什么?
解析阶段
// 全局上下文
GlobalContext = {
this: window,
AO: {
hobby: undefined,
},
Scope: [GlobalContext.AO]
}
// person
PersonContext = {
this: window,
AO: {
argument: {
0: 'miracle',
1: 18,
length: 2
},
name: 'miracle',
age: 18,
hobby: , // let 和const声明的变量和常量是不会初始化值的。 所以不可以先调用后声明
profession: undefined,
say: point to function c() {}
},
Scope: [PersonContext.AO, GlobalContext.AO]
}
SayContext = {
this: window,
AO: {
argument: {
length: 0
}
},
Scope: [SayContext.AO, PersonContext.AO, GlobalContext.AO]
}
执行阶段伪代码
GlobalContext = {
this: window,
AO: {
hobby: 'game',
},
Scope: [GlobalContext.AO]
}
PersonContext = {
this: window,
AO: {
argument: {
0: 'miracle',
1: 18,
length: 2
},
name: 'miracle',
age: 18,
hobby: 'play',
profession: 'programmer',
say: point to function c() {}
},
Scope: [PersonAO, GlobalAO]
}
// SayContext = { } 无变化
argument 对象是函数特有的。在初始化阶段就会生成。
总结:
-
活动对象里面存储的是函数内所有声明的变量,函数,形参 及它们对应的值。
-
js采用词法作用域, 作用域在函数声明时就决定, 而不是调用时才决定的。
-
作用域链就是执行上下文中的变量对象。里面存了自己和所有父级执行上下文的活动对象,在查找变量的时候,会先到自己的活动对象中查找,找不到会到父级区找... 直到null为止都还找不到的话,就会报错。
-
let和const声明的变量在解析阶段不会初始化值, 所以不可以在声明前调用,而var则相反。
-
const声明的常量不可变只是说他的指向内存位置不可以再变,而内存里面存储的东西是可以发生改变的。
任务队列和事件循环
众所周知,js是单线程的,所有任务排队执行,后续任务需要等待前面的任务执行完成之后才能继续,如果前一个任务耗时很长, 后续的任务就会一直等着。 但造成任务执行慢的往往不是cup性能跟不上,而是像ajax这样子的I/O操作。所以这里我们把任务分为两种, 同步任务和异步任务。
- 同步任务在主线程上按序执行形成一个执行栈,
- 异步任务包括ajax请求,setTimeout, setInterval ...
- 异步任务不会进入主线程,而是先注册一个回调函数,等待异步任务成功得到结果,才会把这个回调函数推入到一个任务队列中
- 主线程任务执行完毕,执行栈为空的时候。就会去任务队列中查看是否有可执行的回调函数,有就会拿到主线程上执行,当执行完成之后,又会去查看任务队列中是否有可执行的回调,这个流程会一直循环,直到执行栈和任务队列都为空为止。
- 任务队列是一种先进先出的数据结构。先推入的回调先执行。
- 主线程会从循环的从从任务队列中读取回调,这又叫事件循环(Event Loop)
1、Node.js 事件循环:http://nodejs.cn/learn/the-nodejs-event-loop 2、什么是 process.nextTick:http://nodejs.cn/learn/understanding-process-nexttick 3、什么是 setImmediate:http://nodejs.cn/learn/understanding-setimmediate 4、Web 和 Node.js 事件循环对比:http://www.ruanyifeng.com/blog/2014/10/event-loop.html
宏任务和微任务
事件循环在处理js任务的时候,又会把js的任务分为两个种:宏任务和微任务。
- 微任务指的是那些总会在当前这次循环体的任务执行完毕之后, 下一次循环开始之前调用的任务(主线程任务执行完毕之后从任务队列中拿出回调到主线程执行之前)。
- 宏任务则指的是常规的同步函数,Ajax回调,setTimeout, setInterval ...
console.log(1)
setTimeout(()=> {
console.log(2)
}, 0)
new Promise((resolve) => {
console.log(3)
resolve()
}).then(()=> {
console.log(4)
})
console.log(5)
// 1 3 5 4 2
setTimeout 和 setInterval
setTimeout(fn, delay)
方法支持两个参数(通常用法), 一个是回调函数,一个是时间(单位毫秒),表示时隔多少时间后将回调函数推送到队列里面去。
delay可以不传,不传时默认取0, 但实际如果传比小于4的时间时,浏览器会默认等于4。 还有需要注意的是:函数的开始执行的时间一定大于 delay。 因为该方法只是在隔delay毫秒后把回调函数推入队列,还需要等待主线程函数执行完成且队列前面没有待执行的回调后才会执行。
setTimeout推入的任务属于宏任务。
setInterval 和 setTimeout 唯一的不同在于setInterval是隔多少时间就往任务队列中添加一个回调。
Promise
调用Promise构造函数时传入的函数参数会在当前主线程立即执行, then 和 catch 中的方法会在执行resolve或者reject方法的这个主线程任务完毕之后,下一次循环开始之前执行。 所以then 和 catch 属于微任务。
process.nextStack
requestAnimationFrame
setImmediate
内存分配和垃圾回收
执行机制
想一想, 平时我们和一个网页进行交互, 其实就是去调用了一个一个具有实现指定功能的函数,而这些函数又是通过各种事件来调用的。比如网页加载完毕的 onLoad
事件, 鼠标点击 onClick
事件,网页滚动 onScroll
事件等。
想下以下场景:
-
要实现某一功能,我们可能要写一个至多个函数,多个函数嵌套调用。
-
我们连续触发了多个事件,包含鼠标点击页面交互(同步), Ajax请求(异步)。
-
但是js又是单线程的,一次只能执行一个任务。
js线程会去怎么处理这种情况呢?
在一个函数被调用后,他会被解释器生成一个执行上下文,添加到一种叫做栈的数据结构中,同时给它分配堆内存。如果函数内部调用了其他函数, 那么又会把对应的函数生成一个上下文来继续放入栈顶。