每个渲染进程都有一个主线程,并且主线程很忙,既要处理 DOM,又要计算样式,还要处理布局,同时还要处理 JavaScript 任务及各种输入事件。要让这么多不同类型的任务在主线程中顺利执行,需要一个系统来统筹调度这些任务 —— 消息队列和事件循环系统。
假设有一系列任务:
用一个线程去执行任务:
void MainThread () {
int num1 = 1 + 2;
int num2 = 20 / 5;
int num3 = 7 * 8;
printf ("Tasks result: %d,%d,%d", num1, num2, num3);
}
上述代码中,将所有任务代码按照顺序写进主线程,等线程执行时,任务按顺序在线程中一次被执行,等所有任务执行完成后,线程自动退出。
实际上并不是所有任务都是在执行之前统一安排好的,大部分情况下,新任务是在线程运行过程中产生的。
要想在线程运行过程中,能接受被执行新的任务,就需要采用时间循环机制。
int GetInput () {
int input_number = 0;
count<<"请输入一个数字:";
cin>>input_number;
return input_number;
}
void MainThread () {
for (;;) {
int first_num = GetInput();
int second_num = GetInput();
result_num = first_num + second_num;
print("结果是:%d", result_num);
}
}
相比第一版,做了两点改进:
渲染线程会频繁接收到来自于 IO 线程的一些任务,接收任务之后,渲染线程就要着手处理,如收到资源加载完成的消息后,渲染线程就要开始进行 DOM 解析等。
如何设计一个线程模型,能够接收其他线程发送的消息呢?一个通用的模式是消息队列:
消息队列是一种数据结构,可以存放要执行的任务。它符合队列“先进先出”的特点,即要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。
使用消息队列,对线程模型进行优化:
构造消息队列:
class TaskQueue {
public:
Task takeTask(); // 从队列中取出任务
void pushTask(Task task); // 添加任务到队列的尾部
}
改造主线程,让主线程从队列中读取任务:
TaskQueue task_queue;
void ProcessTask();
void MainThread () {
for (;;) {
Task task = task_queue.takeTask();
ProcessTask(task);
}
}
其他线程想要发送任务让主线程去执行,只要将任务添加到消息队列中就可以:
Task clickTask;
task_queue.pushTask(clickTask);
由于多个线程操作同一个消息队列,所以在添加任务和取出任务时还会加同步锁。
可以使用消息队列,实现线程之间的消息通信。在 Chrome 中,跨进程之间的任务也是频繁发生的:
渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,收到消息后,会将这些消息组装成任务发送给渲染主线程处理。
以上事件都是在主线程中执行的,所以在编写 Web 应用时,需要衡量这些事件所占用的时长,并想办法解决单个任务占用主线程过久的问题。
当页面主线程执行完成后,确定要退出页面时,页面主线程会设置一个退出标志的变量,在每次执行完一个任务时,判断是否有设置退出标志。如有设置,就直接终端当前的所有任务,退出线程。
TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainThread () {
for (;;) {
Task task = task_queue.takeTask();
ProcessTask(task);
if (!keep_running) {
break;
}
}
}
可以在 “开发者工具-Performance” 中点击 “start profiling and load page” 观察页面加载过程中的事件执行情况。
setTimeout(() => {
console.log('setTimeout');
}, 200);
渲染进程中所有运行在主线程上的任务都需要先添加到消息队列中,然后事件循环系统再按照顺序执行消息队列中的任务。典型的事件:
在 Chrome 中除了正常使用的消息队列外,还有一个消息队列,用于维护需要延迟执行的任务列表,包括 定时器 和 Chromium 内部一些需要延迟执行的任务。
当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。
在处理完消息队列中的一个任务后,会执行 ProcessDelayTask
函数,这个函数会根据发起时间和延迟时间计算出到期的任务,然后依次执行到期的任务。等到期任务都执行完成后,再继续下一个循环过程。
设置一个定时器,JavaScript 引擎会返回一个定时器的 ID。通常,当一个定时器任务还没有被执行的时候,也可以取消的,通过 clearTimeout
函数。其原理是,直接根据 ID 去延迟队列中找到对应的任务,并移除。
clearTimeout(timerId);
如果当前任务执行时间过久,会影响延迟到期定时器任务的执行
function bar () {
console.log('bar');
}
function foo () {
setTimeout(bar, 0);
for (let i = 0; i < 5000; i++) {
let i = 5 + 8 + 8 + 8;
console.log(i);
}
}
foo();
如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 ms
function cb () {
setTimeout(cb, 0);
}
setTimeout(cb, 0);
未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
延时执行时间有最大值
使用 setTimeout 设置的回调函数中的 this
不符合直觉
this
将指向全局环境,而不是定义时所在的那个对象var name = 1;
var MyObj = {
name: 2,
showName: function () {
console.log(this.name);
}
};
setTimeout(MyObj.showName, 0);
// 1
// 使用匿名函数
setTimeout(() => {
MyObj.showName();
}, 0);
setTimeout(function () {
MyObj.showName();
}, 0);
// 使用 bind
setTimeout(MyObj.showName.bind(MyObj), 0);
let callback = function () {
console.log('i am do homework');
}
function doWork (cb) {
console.log('start do work');
cb();
console.log('end do work');
}
doWork(callback);
将函数作为参数传递给另一个函数,那作为参数的这个函数就是回调函数。上面代码中,callback
是在主函数 doWork
返回之前执行的,称同步回调。
let callback = function () {
console.log('i am do homework');
}
function doWork (cb) {
console.log('start do work');
setTimeout(cb, 1000);
console.log('end do work');
}
doWork(callback);
callback
不在主函数 doWork
内部被调用,而是延迟 1 s,这种回调函数在主函数外部执行的过程为异步回调。
从本质上看,消息队列和主线程循环机制保证了页面有条不紊地运行。当循环系统在执行一个任务时,都要为这个任务维护一个系统调用栈,类似于 JavaScript 调用栈。每个任务在执行过程中都有自己的调用栈,那么同步回调就是在当前主函数的上下文中执行回调函数,而异步回调是指在主函数之外执行,一般有两种方式:
function GetWebData (URL) {
let xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
switch (xhr.readyState) {
case 0:
console.log('请求未初始化');
break;
case 1:
console.log('OPENED');
break;
case 2:
console.log('HEADERS_RECEIVED');
break;
case 3:
console.log('LOADING');
break;
case 4:
if (this.status == 200 || this.status == 304) {
console.log(this.responseText);
}
console.log('DONE');
break;
}
};
xhr.ontimeout = function () {
console.log('请求超时');
};
xhr.onerror = function () {
console.log('请求失败');
};
xhr.open('GET', URL, true); // true 表示异步
xhr.timeout = 5000; // 设置超时时间
xhr.responseType = 'text'; // 设置响应类型
xhr.setRequestHeader('X_TK', 'cellinlab.xyz'); // 设置请求头
xhr.send();
}
利用 XMLHttpRequest 请求数据的执行过程:
ontimeout
onerror
onreadystatechange
,监控后台请求过程中的状态,如 HTTP 头加载完成的消息、HTTP 响应体以及数据加载完成的消息微任务可以在实时性和效率之间做一个有效的权衡。
页面中大部分任务都是在主线程上执行的,包括:
为了协调这些任务有条不紊在主线程上执行,页面进程引入了消息队列和事件循环,渲染进程内部会维护多个消息队列,如延迟执行队列和普通消息队列。然后,主线程采用 for
循环,不断从队列中取出任务并执行任务,将这些消息队列中的任务称为宏任务。
WHATWG (opens new window)中定义的事件循环机制:
oldTask
oldTask
设置为当前正在执行的任务oldTask
宏任务可以满足大部分日常需求,不过对于时间精度要求较高的需求,宏任务就难以胜任了。因为页面的渲染事件、各种 IO 的完成事件等都随时可能被添加到消息队列,而且添加事件是由系统操作的,JavaScript 代码不能准确掌握任务要添加到队列的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。
异步回调主要有两种:
setTimeout
和 XMLHttpRequest
都是通过这种方式实现的微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
在 V8 引擎层面分析微任务是怎么运转的:
MutationObserver
监控 DOM 节点,然后通过 JavaScript 修改节点或者为节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务Promise
,当调用 Promise.resolve()
或 Promise.reject()
时,也会产生微任务许多 Web 应用都利用 HTML 与 JavaScript 构建其自定义控件,与一些内置控件不同,为了与内置控件一起良好工作,这些控件必须能后适应内容更改、响应事件和用户交互。因此,Web 应用需要监视 DOM 变化并及时地做出响应。
早期页面并没有提供监听的支持,方案是使用轮询监测。直到 2000 年,有了 Mutation Event,它采用了观察者的设计模式,当 DOM 有变动时,会立刻触发相应事件,这种方式属于同步回调。
采用 Mutation Event 解决了实时性的问题,却因此带来了严重的性能问题,因为一旦 DOM 发生变化,就会调用 JavaScript,产生巨大的性能开销。后来,慢慢被移除。
为了解决 Mutation Event 由于同步调用导致的页面性能问题,从 DOM 4 开始,推荐使用 MutationObserver
代替 Mutation Event。MutationObserver API 可以用来监视 DOM 的变化,包括属性的变化、节点的增减、内容的变化等。
MutationObserver
做了以下改进:
即,MutationObserver
采用了 “异步 + 微任务” 的策略:
页面中任务都是执行在主线程之上的,相对于页面来说,主线程就是它的整个的世界,所以在执行一项耗时的任务时,如下载网络文件任务、获取摄像头等设备信息任务,这些任务都会放到页面主线程之外的进程或者线程中去执行,这样就避免了耗时任务“霸占”页面主线程的问题。
Web 页面的单线程架构决定了异步回调,而异步回调又影响编码方式。
function onResolve(response) {
console.log(response);
}
function onReject(error) {
console.log(error);
}
let xhr = new XMLHttpRequest();
xhr.ontimeout = function (e) { onReject(e); };
xhr.onerror = function (e) { onReject(e); };
xhr.onreadystatechange = function () { onResolve(xhr.response); };
let URL = `https://cellinlab.xyz`;
xhr.open('GET', URL, true);
xhr.timeout = 5000;
xhr.responseType = 'text';
xhr.setRequestHeader('X_TK', '123');
xhr.send();
过多的回调会导致代码的逻辑不连贯、不线性,不符合人的直觉,这就是异步回调影响我们的编码方式。
由于重点关注的是输入内容(请求信息) 和 输出内容(响应信息),至于中间的异步请求过程,不想在代码中体现太多,会干扰代码逻辑,可以将请求过程封装起来。
function makeRequest (request_url) {
let request = {
method: 'GET',
url: request_url,
headers: '',
body: '',
credentials: false,
sync: true,
responseType: 'text',
referrer: '',
timeout: 5000,
};
return request;
}
function XFetch (request, resolve, reject) {
let xhr = new XMLHttpRequest();
xhr.ontimeout = function (e) { reject(e); };
xhr.onerror = function (e) { reject(e); };
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
resolve(xhr.response);
} else {
reject(xhr.status);
}
}
};
xhr.open(request.method, request.url, request.sync);
xhr.timeout = request.timeout;
xhr.responseType = request.responseType;
xhr.send();
}
XFetch(makeRequest('https://cellinlab.xyz'), function (response) {
console.log(response);
}, function (error) {
console.log(error);
});
使用上面的方法,可以将代码思路更加线性化,可以应用在一些简单场景中。但是对于一些复杂项目,如果嵌套太多的回调容易陷入回调地狱。会导致:
从问题出发,可以从下面入手解决:
使用 Promise 重构 XFetch
function XFetch (request) {
function executor (resolve, reject) {
let xhr = new XMLHttpRequest();
xhr.ontimeout = function (e) { reject(e); };
xhr.onerror = function (e) { reject(e); };
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
resolve(xhr.responseText, xhr);
} else {
reject(xhr.status, xhr);
}
}
};
xhr.open(request.method, request.url, request.sync);
xhr.timeout = request.timeout;
xhr.responseType = request.responseType;
xhr.send();
}
return new Promise(executor);
}
var x = XFetch(makeRequest('https://cellinlab.xyz/x'));
var y = x.then(value => {
console.log(value);
return XFetch(makeRequest('https://cellinlab.xyz/y'));
});
var z = y.then(value => {
console.log(value);
return XFetch(makeRequest('https://cellinlab.xyz/z'));
});
z.catch(error => {
console.log(error);
});
Promise 主要通过下面两步来解决嵌套回调问题;
Promise 实现了回调函数的延时绑定
x1
,通过 Promise 的构造函数 executor
来执行业务逻辑x1
之后,再使用 x1.then
来设置回调函数// 创建 Promise 对象 x1,并在 executor 函数中执行业务逻辑
function executor (resolve, reject) {
resolve(100);
}
let x1 = new Promise(executor);
// x1 延迟绑定回调函数 onResolve
function onResolve (value) {
console.log(value);
}
x1.then(onResolve);
需要将回调函数 onResolve 的返回值穿透到最外层
Promise 处理异常的方法:
function executor (resolve, reject) {
resolve(100);
}
let demo = new Promise(executor);
function onResolve (value) {
console.log(value);
}
demo.then(onResolve);
上面代码的执行细节;
new Promise(executor)
时,Promise 的构造函数会被执行,不过由于 Promise 是 V8 引擎提供,暂时看不到 Promise 构造函数细节executor
函数,然后在 executor
中执行了 resolve
resolve
函数也是 V8 内部实现的,执行 resolve
函数会触发 demo.then
设置的回调函数 onResolve
resolve
函数的时候,回调函数还没有绑定,所以只能推迟回调函数的执行function MyPromise (executor) {
var _onResolve = null;
var _onReject = null;
this.then = function (onResolve, onReject) {
_onResolve = onResolve;
_onReject = onReject;
};
function resolve (value) {
setTimeout(function () {
_onResolve(value);
}, 0);
}
// ...
executor(resolve);
}
Promise 能很好地解决回调地狱问题,但是这种方法充满了 Promise 的 then()
方法,如果处理流程比较复杂,那么整段代码会充斥 then
,语义化不明显。
基于上述原因,ES7 引入了 async
和 await
语法,它们可以让代码更加简洁,更加语义化,这是 JavaScript 异步编程的一个重大改进,提供了在不阻赛主线程的情况下使用同步代码实现异步访问资源的能力,并且能使得代码逻辑更加清晰。
生成器函数是一个带星号的函数,而且可以暂停执行和恢复执行。
function* genDemo () {
console.log('first yield');
yield 'generator 2';
console.log('second yield');
yield 'generator 2';
console.log('third yield');
yield 'generator 2';
console.log('execute done');
return 'generator 2';
}
console.log('main 0'); // main 0
let g = genDemo();
console.log(g.next().value); // first yield generator 2
console.log('main 1'); // main 1
console.log(g.next().value); // second yield generator 2
console.log('main 2'); // main 2
console.log(g.next().value); // third yield generator 2
console.log('main 3'); // main 3
console.log(g.next().value); // execute done generator 2
console.log('main 4'); // main 4
console.log(g.next().value); // undefined
生成器的使用方式:
yield
关键字,那么 JavaScript 引擎将返回关键字后面的内容给外部,并暂停函数的执行next
方法恢复函数的执行要搞清楚函数为何能暂停和恢复,需要了解协程的概念。协程是一种比线程更加轻量级的存在:
父协程和 genDemo
协程切换调用栈:
在 JavaScript 中,生成器就是协程的一种实现方法,通过使用生成器配合执行器,就能实现用同步的方式写出异步代码,大大增强代码可读性。
ES 7 引入了 async/await
,其技术背景就是 Promise 和 生成器应用,或者更加底层的说法是微任务和协程应用。
async
是一个通过异步执行并隐式返回 Promise作为结果的函数。
// 关于 隐式返回 Promise
async function foo () {
return 2;
}
console.log(foo()); // Promise {<fulfilled>: 2}
那 await
又是什么呢?
async function foo () {
console.log(1);
let a = await 100; // 此处会 默认创建一个 Promise 对象
console.log(a);
console.log(2);
}
console.log(0);
foo();
console.log(3);
// 0
// 1
// 3
// 100
// 2