从运行机制谈Javascript异步

作为一个前端程序员,Javascript是前端的重要组成部分。那么这门让我花了整整一年时间,下了很大功夫来学习的语言到底是个什么样的东西?

什么是Javascript

JavaScript一种直译式脚本语言,是一种动态类型、弱类型、基于原型的语言,内置支持类型。它的解释器被称为JavaScript引擎,为浏览器的一部分,广泛用于客户端的脚本语言,最早是在HTML(标准通用标记语言下的一个应用)网页上使用,用来给HTML网页增加动态功能。

——摘自百度百科

起源与发展

Brendan Eich为Netscape网景浏览器开发出了这门脚本语言,由于当时Netscape与sun的合作关系,最终命名Javascript。后来,微软也为自己的IE浏览器推出了JScript。

为了互用性,在ECMA的协调下,Netscape、微软和Sun等公司共同制定了ECMA-262标准,由于Javascript是甲骨文公司的注册商标,所以这个标准被命名为ECMAScript。所以可以这么理解:Javascript和JScript都是对ECMA-262标准的实现。

在很长一段时间,微软借着Windows的占有率,把IE浏览器捆绑在了系统上,最终搞死了Netscape。此前,Brendan Eich和其他队友一起成立了Mozilla,就是为了没有Netscape后能够继续生存,后来他们推出了自己的浏览器Firefox脚本语言仍然使用javascript。

于是出现了两大阵营相互对立的局面:Mozilla的Fierfox使用javascript作为脚本语言,和微软的IE使用JScript和VBScript,同时支持javascript。IE6的成功使得微软傲慢无比,根本不曾顾其他系统(这和今天开放的微软形成了鲜明的对比),而javascript是跨平台的。也因为JScript和javascript没有什么竞争力,VBS又存在很大的安全隐患,更多的开发者选择了javascript。而Mozilla也得到了google的支持资金来对抗微软,Firefox也得以活到了今天。

随着互联网的飞速发展,浏览器种类呈现了百花齐放的状态,而javascript也成为了所有浏览器的默认脚本语言。移动设备的普及,Node.js的成熟,ECMAScript的迭代,都大大推进了javascript的发展,使其登上了github编程语言流行榜的榜首。

伴随着HTML5的发展,javascript能做的事情越来越多,甚至出现了许多javascript的上层语言,TypeScript、CoffeeScript和Script#等等。

Javascript如何运行

运行环境与引擎

javascript的运行需要一个环境来作为载体,通常是运行在客户端的各种浏览器,也可以是服务端的Node.js环境等。不管是什么运行环境,都会提供一个让javascript运行的虚拟机比如Chrome和Node的V8,Edge的Chakra,或者火狐的SpiderMonkey。这个虚拟机,我们一般称为引擎,这个引擎实现ECMA-262规范。

那么我们常说的webkit、Trident、Edge和Gecko又是个啥?

javascript的运行环境,这里专门指浏览器,不仅仅只是用来解析执行javascript的,它还需要渲染页面,这时候就用到了这个浏览器内核,叫做排版引擎,用来解析HTML和CSS并显示在浏览器中,这个引擎实现w3c规范,包括javascript操作DOM、BOM和CSS这部分。

运行机制

javascript是单线程的。

单线程意味着所有任务都是排队的,就像打断点单步调试时候一样,javascript是逐行从上到下执行的*,同一时间javascript只能执行某一段代码,并不能同时执行两段代码。

实际并不是严格的从上到下,比如var的变量提升*

由于javascript一开始设计的时候,是专门为了和浏览器打交道的,而由于当时的硬件限制CPU单核单线程,浏览器也是单线程,所以被设计成是单线程的。那么这个设计未来会不会改变?在我个人看来是不会的。因为Brendan Eich同时赋予了这门语言异步的特性,单线程+异步,正是javascript的独特之处。

异步与同步

同步,就是发起调用就能立即这里的立即是相对的得到结果。异步,就是发起调用不能马上得到结果,需要经过其他的操作或者步骤才能得到结果。

举个例子,javascript发起了一个请求,到服务器获取数据。如果是同步请求,在服务端处理数据的过程中,javascript就无法往下执行,直到服务器返回了结果。如果是异步请求,在服务端处理数据的时候,javascript继续往下执行其他任务,当服务器返回来结果的时候,触发了一个事件,这个事件绑定的方法会通过浏览器的event loop机制放进消息队列,当主线程执行完同步任务后,便检查到了这个消息,并执行该任务。

这个例子简单地介绍了一种javascript的异步实现。上面说过异步是javascript的主要特征,下面重点介绍一下javascript的异步。

javascript如何实现异步

回调函数

在讲什么是回调函数之前,先来看一个同步任务的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const foo = () => {
console.log('foo')
}
const bar = () => {
let sum = 0
for (let i = 0; i < 999999999; i++) {
sum += i
}
console.log('bar')
}
const foo2 = () => {
console.log('foo2')
}
foo()
bar()
foo2()

这是一个普通的同步任务,由于bar是个很耗时间的操作,在执行bar时,foo2只能排队等着。

现在我想让当foo执行完毕后才执行bar,为了确保一定是在foo执行完后执行bar,我们可以把bar当作参数传进foo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const foo = cb => {
console.log('foo')
cb()
}
const bar = () => {
let sum = 0
for (let i = 0; i < 999999999; i++) {
sum += i
}
console.log('bar')
}
const foo2 = () => {
console.log('foo2')
}
foo(bar)
foo2()

这是一个回调函数的雏形,但可以发现,虽然foo调用了bar,但仍然是同步运行的,并没有来调用,仍然不是一个回调函数,因为我们缺少一个异步的操作。那么什么样的东西可以实现异步呢?举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const foo = cb => {
console.log('foo')
setTimeout(() => {
cb()
}, 1000)
}
const bar = () => {
let sum = 0
for (let i = 0; i < 999999999; i++) {
sum += i
}
console.log('bar')
}
const foo2 = () => {
console.log('foo2')
}
foo(bar)
foo2()

我们添加了一个计时器来在一段时间后执行bar,这时候bar的执行将被挂起,执行foo2,等到计时器结束,才回过头来调用bar。像这样执行的时候被挂起,达到某种时机才回过头来执行的函数,我们叫回调函数

上面的代码没有实际意义,因为事实上,多数时候的回调函数并不是用来处理耗时操作的,而是为了得到一个合适的时机来执行相关的操作,这只是演示了什么是回调。另一个栗子:

1
$.get(url, data => console.log(data))

$.get()是你去调用了JQ的API,事实上你并不知道具体什么时间会执行console.log(data),而是JQ处理完了,再反过来调用这个函数。

打个比方:
下单(狗东,鞋子 => 穿上)

你在狗东买了双鞋子,想好了买来要把它穿上,这就叫登记回调函数;鞋子开始发货,配送小哥把鞋子递给你,叫做触发了回调关联的事件;你接过了鞋子,叫做调用回调函数;并把它穿上,叫做响应回调事件。这是一个完整的回调函数的执行过程。这里重点就在于,你拿到了鞋子要怎么处理都可以,你可以把它送人、拿去卖甚至扔掉都可以,决定权在你。

事件监听

1
2
window.onload = () => console.log('loaded')
btn.addEventListener('click', e => console.log(e.target))

很显然这些函数也不会一开始就执行,而是等到被触发的时候调用。

计时器

1
2
setTimeout(() => console.log('timeover'), 1000)
setTimeout(() => console.log(Date.now()), 1000)

你知道你的鞋到了,是因为配送小哥给你打了个电话。那么浏览器是通过怎样的机制来执行这些代码的呢?

Event Loop

首先浏览器会分配一条主线程来从上到下执行代码,遇到异步操作则先挂起,继续执行代码,直到代码被执行完,这个执行代码的区域叫(Stack),未被组织的区域叫(Heap),当触发了函数关联的事件时*,该函数就会被放到一个等待执行的消息队列(Queue)里,等执行栈空的时候,就会检查队列里有没有函数等待执行,如果有就放入执行栈执行,执行完毕后继续检查队列。这个过程永远不断地循环,我们称这种执行机制为事件轮询(Event Loop)。

*注:比如回调,点击事件,计时器到期。只有对事件添加了监听器时,才会添加至队列,否则事件丢失。另外,定时器到期也会添加到队列,值得注意的是,如果计时器到期时,队列前面还有未被执行的任务,同样会等待它们进栈执行,因此,定时器并不能总是准确按照设定的时间来执行任务,可能会被延迟。

这是javascript在浏览器环境的运行机制,为了更好地利用多核多线程的硬件,HTML5提出了
Web Workers标准,运行javascript创建多个线程,但创建出来的线程是作为主线程的子线程,完全受主线程控制,并且不能操作DOM等其他安全限制,因此javascript本质上仍然是单线程。

Web Workers

1
2
3
4
5
6
// main.js
const myWorker = new Worker('worker.js')
myWorker.postMessage(0)
myWorker.addEventListener('message', e => {
console.log('sum: ', e.data)
})

首先新建一个worker对象,启动worker线程并传递参数,监听message事件。

1
2
3
4
5
6
7
8
// worker.js
onmessage = initNum => {
let sum = initNum
for (let i = 0; i < 999999999; i++) {
sum += i
}
postMessage(sum)
}

多线程通常用于处理一些高消耗的计算,使用worker同样可以实现异步。

现代的异步处理方法

Promise对象

为了解决回调函数的不足而设计的API,之前我们在使用回调函数时,不可避免地会出现多层嵌套的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
ajax({
url: './a',
complete(data) {
if (data.code === 0) {
ajax({
url: './b',
complete(result) {
ajax({...})
},
})
}
},
})

这会使得代码很难维护,这种情况我们称为回调噩梦 callback hell。于是,就有了Promise的写法:

1
2
3
4
5
6
7
8
9
fetch('./a')
.then(res => res.json())
.then(data => {
if (data.code === 0) {
return fetch('./b')
}
}).then(rp => rp.text())
.then(result => console.log(result))
.catch(err => console.log(err))

这种写法把回调函数的深层嵌套改为同级链式,看上去舒服多了,但一串的.then似乎也不是最佳方案。

Generator函数

简单了解一下使用方法

1
2
3
4
5
6
7
8
function* generator() {
const response = yield fetch('./a')
const data = yield response.json()
console.log(data)
}
const g = generator()
g.next()
g.next()

这段代码可以简单的理解为两个部分,一部分是generator函数,另一部分是执行这个函数的。generator函数由*标记,被标记的函数内部可以使用yield关键字,遇到这个关键字则挂起继续执行,可以理解为一次return。当外部执行了next()方法之后,便会继续从挂起的地方执行generator函数。

了解了执行过程,我们分析一下代码,可以看出:generator函数内部非常接近同步的写法,但是很难去控制什么时候执行next(),而且*和next()语义也不是很好理解。要是程序可以自己判断fetch什么时候取得数据了就好了,这样我们就可以控制程序在这个时候next,或者再6一点,fetch取得了数据就自己往下执行,不用我写next。有这样的操作吗?有!generator作为过渡,下面我们讲终极方案

async函数

1
2
3
4
5
6
7
8
const getJSON = async url => {
const response = await fetch(url)
const data = await response.json()
return data
}
getJSON('./a')
.then(data => console.log(data))
.catch(err => console.log(err))

在声明函数时加上关键字async,并在返回Promise对象前await,async函数总是返回一个Promise。我们欣赏一下代码,一切都是那么自然,犹如同步函数一样操作,async和await带来更好的语义和操作,但无法捕捉单个reject,只要一个出错就直接抛出,跳过下面代码执行。

回调与Promise

在koa2中,用回调函数并不会正确返回结果,会在回调函数执行之前,就已经对客户端做出响应了,只能用Promise。如果需要返回,Promise会比回调更加可靠。