Skip to content

单线程和事件轮询机制与JS 实现多线程(了解)(Day18 3/3) ​

本页对应 Day18_Senior,共 5 段视频,约 1 小时 18 分。

学习目标 ​

  • 能够用定时器与耗时同步循环示例证明 JS 主线程单线程执行,并解释定时器回调为何必须等待主线程空闲。
  • 能够区分同步任务与异步任务,准确判断定时器回调、DOM 事件回调等异步任务的执行时机。
  • 能够描述事件轮询机制中执行栈、异步任务管理模块、回调队列、事件轮询模块的协作流程,并据此预测代码输出顺序。
  • 能够分析包含 for 循环、定时器、闭包与作用域变量的异步题目,正确计算并解释最终输出值。
  • 能够说明 JS 采用单线程的原因及 Web Worker 的适用场景,并使用 postMessage 与 onmessage 完成主线程和子线程的数据传递。

知识点 ​

单线程如何证明:定时器不是“到点就插队” ​

为什么需要:只记“JS是单线程”很容易背概念,遇到定时器、事件回调时仍会误以为“时间一到就立即执行”。需要一个可观察的实验。

怎么想:如果JS只有一个主线程执行代码,那么主线程被耗时循环占住时,即使定时器时间到了,回调也只能排队等待。

怎么做:写一个1毫秒定时器,再写一个耗时循环。刷新页面会看到:循环结束后,才输出“定时器时间到了”。

js
setTimeout(function () {
  console.log('定时器时间到了');
}, 1);

for (var i = 0; i <= 10000000; i++) {
  i * i + i;
}
console.log('循环结束了');

易错点:不要理解成“浏览器只有一个线程”。浏览器进程里还有负责计时、监听事件等线程,但执行我们写的JS代码的主线程只有一个;其他线程主要做调度和控制,不替主线程执行JS代码。

为什么设计成单线程:核心是DOM操作的同步问题 ​

为什么需要:多线程能并行,为什么JS不直接多线程执行?

怎么想:JS常做DOM操作。若两个线程同时把同一个元素宽度改成800和600,浏览器无法确定最终以谁为准,同步问题很难处理。

怎么做:承认单线程的代价——一个任务没结束,后面的任务要等;换来的是代码按先后顺序执行,DOM修改有明确次序。多线程的线程调度、开启也有额外开销,但关键原因仍是DOM渲染同步。

易错点:不要说“单线程一定更先进”。它是取舍:牺牲并行能力,换取DOM操作的可预测性。

同步任务与异步任务:创建定时器是同步,回调才是异步 ​

为什么需要:定时器写成0毫秒,为什么仍然后输出?因为“创建定时器”和“定时器回调执行”不是一回事。

怎么想:同步任务按顺序、一步接一步执行;异步任务要满足条件,并且等主线程空闲才执行。定时器的回调函数是典型异步任务,条件是时间到达。

怎么做:

js
setTimeout(function () {
  console.log('定时器执行');
}, 0);
console.log('100');
console.log('200');
// 输出:100、200、定时器执行

易错点:异步任务的形式通常是回调函数,但回调函数不一定是异步任务,例如数组的forEach、map回调往往立即执行。DOM事件回调、定时器回调才是当前重点。

事件轮询机制:执行栈、管理模块、回调队列、轮询模块 ​

为什么需要:只有一个主线程,怎样同时管理同步任务和异步任务?

怎么想:把主线程看成一个执行栈;异步任务先由管理模块盯着条件;条件满足后进入回调队列排队;事件轮询模块不断检查主线程是否空闲,空闲就按顺序取回调放入执行栈。

怎么做:同步代码直接进执行栈执行;定时器到点后,回调进入回调队列;主线程忙时必须等。队列是先进先出,先排队的回调先执行。DOM事件同理:监听事件本身是同步的,触发后的回调要等主线程空闲。

易错点:异步任务满足条件后不是“立刻插队”,而是“进入回调队列等待”。主线程不空闲,回调就不能执行。

异步题推导:全局变量与闭包 ​

为什么需要:异步回调执行时,循环早已结束,回调里读到的变量值可能不是循环当时的值。

怎么想:先判断回调何时执行,再按作用域找变量。课堂示例中,v1、v2使用全局变量,三个回调执行时读到的是循环结束后的同一个值;v3用立即执行函数形成闭包,每次保存各自的i3。

怎么做:按转写稿结论,示例输出为v1=12、v2=9、v3=6。关键结构如下,循环边界以课堂示例结论为准:

js
var v1 = 0, v2 = 0, v3 = 0;

for (var i = 1; i <= 3; i++) {
  setTimeout(function () { v1 += i; }, 100);
}
for (var i2 = 0; i2 < 3; i2++) {
  setTimeout(function () { v2 += i2; }, 0);
}
for (var i3 = 1; i3 <= 3; i3++) {
  (function (i3) {
    setTimeout(function () { v3 += i3; }, 0);
  })(i3);
}

易错点:作用域看声明位置,不看调用位置;不要凭感觉判断变量是全局还是局部,要看有没有函数包住它。闭包保存的是每次执行时各自的变量环境。

JS多线程:Worker的基本用法 ​

为什么需要:遇到耗时计算时,放在主线程会卡住页面;Worker可把计算放到子线程。

怎么想:主线程和子线程分文件写,通过postMessage发数据,通过onmessage收数据。子线程不能操作DOM,也没有window,因此避免DOM同步问题。

怎么做:

js
// 主线程
var worker = new Worker('worker.js');
btn.onclick = function () {
  var data = [Number(input01.value), Number(input02.value)];
  worker.postMessage(data);
};
worker.onmessage = function (event) {
  resBox.innerHTML = event.data;
};
js
// worker.js
onmessage = function (event) {
  var data = event.data;
  var res = data[0] + data[1];
  postMessage(res);
};

易错点:页面必须通过本地服务器打开,直接双击HTML可能使Worker报错;postMessage一次只传一个参数,要传多个值可放进数组或对象;子线程不能操作DOM。

先理解:为什么需要 Web Worker ​

本节只要求“了解”JS 多线程。课堂先回顾了 JS 主线程是单线程:所有 JS 代码主要在一个线程里执行,所以遇到耗时循环时,定时器回调即使时间到了,也要等主线程空闲才执行。这能说明单线程的特点,也引出问题:如果有一个很耗时的计算,主线程会被占住,页面交互和 DOM 更新就会受影响。

为什么需要:把耗时计算挪走,别阻塞主线程。
怎么想:JS 仍然以主线程为主,但可以创建一个子线程(也叫分线程)帮忙算。子线程不能操作 DOM,也没有 window,这样反而避免了多线程同时改 DOM 的同步问题。
怎么做:用内置的 Worker 构造函数创建子线程,子线程代码必须单独写一个 JS 文件。
易错点:子线程不是用来操作 DOM 的;主线程和子线程代码不能混在同一个文件里;页面不能直接双击打开,课堂演示中双击会报错,需要通过 Live Server 这类服务器方式打开。

本节笔记里的核心 API 是:

Worker 构造函数
Worker.prototype.postMessage()  向分线程发送数据
Worker.prototype.onmessage      监听分线程的消息

postMessage 定义在 Worker.prototype 上,所以 new Worker(...) 得到的实例可以调用它;onmessage 用来监听子线程回传的消息。

Worker 构造函数:创建子线程 ​

为什么需要:要让子线程执行计算,先得创建它。
怎么想:new Worker('worker.js') 里的参数是子线程脚本路径,路径写错就找不到文件。课堂演示中,创建 Worker 的那一刻,子线程代码就会开始执行。
怎么做:

js
const worker = new Worker('worker.js');

易错点:文件名、路径、拼写要正确;课堂里出现过把 worker 拼错导致后续调用失败的情况。子线程里没有 window,所以写 onmessage、postMessage 时直接写,不要习惯性加 window.。

postMessage:主线程向子线程发送数据 ​

为什么需要:主线程拿到输入框数据后,要交给子线程计算。
怎么想:主线程通过 Worker 实例调用 postMessage。课堂演示中,一次只能传一个数据;想传两个数,要包成数组或对象。
怎么做:

js
const nums = [Number(input01.value), Number(input02.value)];
worker.postMessage(nums);

易错点:不要写成 worker.postMessage(a, b),课堂演示中多传参数会报错;多个数据要包成数组/对象。转写稿中还出现“不传也报错”的演示,实际使用时应始终传一个明确的数据。

onmessage:子线程接收并回传结果 ​

为什么需要:子线程要知道主线程发了什么,主线程也要知道子线程算完了。
怎么想:两边都用 onmessage 监听,通过事件对象 event.data 取数据。子线程算完后直接 postMessage(res) 回传。
怎么做:

子线程 worker.js:

js
onmessage = function (event) {
  const data = event.data;
  const res = data[0] + data[1];
  postMessage(res);
};

主线程接收:

js
worker.onmessage = function (event) {
  resBox.innerHTML = event.data;
};

易错点:取数据用 event.data,不是直接拿 event;子线程里直接写 postMessage,主线程里通过 worker.onmessage 监听;不要忘记把结果显示到页面元素上。

完整调试:两个输入框相加 ​

课堂演示的结构是两个输入框、一个计算按钮、一个结果 div。主线程点击按钮后,把两个输入框的值转成数字,组成数组发给子线程;子线程相加后回传;主线程把结果显示到 div。

html
<input id="input01">
<input id="input02">
<button id="btn">计算</button>
<div id="resBox"></div>
js
const input01 = document.querySelector('#input01');
const input02 = document.querySelector('#input02');
const btn = document.querySelector('#btn');
const resBox = document.querySelector('#resBox');

const worker = new Worker('worker.js');

btn.onclick = function () {
  const nums = [Number(input01.value), Number(input02.value)];
  worker.postMessage(nums);
};

worker.onmessage = function (event) {
  resBox.innerHTML = event.data;
};

易错点:输入框拿到的默认是字符串,课堂里用 Number() 转数字;子线程不能操作 resBox,只能把结果回传,让主线程改 DOM;调试时如果页面打不开 Worker,先检查是否通过服务器打开。

小结:了解即可,别把它当 DOM 线程 ​

Web Worker 的价值是把耗时运算放到子线程,主线程继续负责执行 JS 和 DOM 操作。课堂也说明,前端日常开发中真正需要大量耗时计算的情况不多,所以本节定位是“了解”。记住三件事:new Worker('子线程文件') 创建;主线程用 worker.postMessage() 发、worker.onmessage 收;子线程用 onmessage 收、postMessage() 发,并且不能操作 DOM。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

1. 证明 JS 是单线程运行(同步任务与异步任务的执行顺序) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>单线程与同步异步任务</title>
</head>
<body>
  <script>
    // ① 先写定时器,再写耗时循环
    setTimeout(function () {
      console.log('定时器时间到了');
    }, 1);

    // 耗时循环:占住主线程
    var i = 0;
    while (i < 10000000) {
      var x = i * i + i; // 随便做点运算,让循环耗时
      i++;               // 必须自增,否则死循环
    }
    console.log('循环结束了');

    // ② 0 毫秒的定时器也不会立即执行
    setTimeout(function () {
      console.log('定时器执行');
    }, 0);

    console.log('100');
    console.log('200');
  </script>
</body>
</html>

要点解释与易错点

  • setTimeout 的创建本身是同步任务,但它的回调函数是异步任务;异步任务必须满足条件(时间到)且主线程空闲才会执行。
  • 虽然定时器只等了 1 毫秒,但耗时循环一直占着主线程,所以“定时器时间到了”只能等循环结束后才输出——这正是 JS 单线程的最好证明。
  • 预期输出顺序:循环结束了 → 100 → 200 → 定时器时间到了 → 定时器执行。
  • 易错点:while 循环里一定要有 i++,否则会死循环,页面直接卡死(转写稿中就出现过卡死的情况)。
  • 易错点:setTimeout(fn, 0) 不是“立即执行”,只是“尽快把回调放入回调队列”,仍要等同步代码全部执行完。

2. 异步任务题目:for 循环 + setTimeout + 闭包 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>异步任务题目</title>
</head>
<body>
  <script>
    var v1 = 0;
    var v2 = 0;
    var v3 = 0;

    for (var i = 1, I2 = 1; i <= 3; i++, I2++) {
      // v1:回调里用的是全局变量 i
      setTimeout(function () {
        v1 += i;
      }, 100);

      // v2:I2 外面没有函数包着,仍然是全局变量
      setTimeout(function () {
        v2 += I2;
      }, 100);

      // v3:IIFE 形成闭包,每次循环都有自己的 I3
      (function () {
        var I3 = i;
        setTimeout(function () {
          v3 += I3;
        }, 100);
      })();
    }

    // 等所有定时器回调执行完再输出
    setTimeout(function () {
      console.log(v1, v2, v3); // 12 9 6
    }, 500);
  </script>
</body>
</html>

要点解释与易错点

  • for 循环本身是同步任务,循环过程中只是创建了 3 个定时器;3 个回调函数都是异步任务,要等循环结束后才执行。
  • 循环结束后 i 的值是 4(不是 3,因为还要执行一次 i++ 才退出循环),所以 v1 = 4 + 4 + 4 = 12。
  • I2 没有函数包着,是全局变量,循环结束后定格在 3,所以 v2 = 3 + 3 + 3 = 9。
  • IIFE 每次循环都执行一次,var I3 = i 为每次执行创建独立的局部变量,三个回调各自记住 1、2、3,所以 v3 = 1 + 2 + 3 = 6。
  • 易错点:判断变量取值要看作用域,不是看回调函数调用的位置;回调函数虽然最后才执行,但查找变量仍按声明位置的作用域链查找。
  • 易错点:用 var 声明循环变量时,回调里拿到的是循环结束后的最终值;需要每次独立的值时,用 IIFE 或 let 形成独立作用域。

3. JS 多线程:Worker 子线程计算(主线程 + 子线程两个文件) ​

主线程页面 main.html:

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>JS 多线程(Worker)</title>
  <style>
    #res-box {
      margin-top: 20px;
      font-size: 20px;
      color: red;
    }
  </style>
</head>
<body>
  <input type="number" id="input01" placeholder="第一个数">
  <input type="number" id="input02" placeholder="第二个数">
  <button id="btn">计算</button>
  <div id="res-box"></div>

  <script>
    var input01 = document.querySelector('#input01');
    var input02 = document.querySelector('#input02');
    var btn = document.querySelector('#btn');
    var resBox = document.querySelector('#res-box');

    // 创建子线程,参数是子线程脚本的路径
    var worker = new Worker('./worker.js');

    // 主线程监听子线程发回来的数据
    worker.onmessage = function (event) {
      resBox.innerHTML = event.data;
    };

    // 点击按钮,向子线程发送数据
    btn.onclick = function () {
      var nums = [Number(input01.value), Number(input02.value)];
      worker.postMessage(nums); // 只能传一个参数,多个数据打包成数组
    };
  </script>
</body>
</html>

子线程脚本 worker.js(与 main.html 放在同一目录):

js
// 子线程中没有 window,也不能操作 DOM
onmessage = function (event) {
  var nums = event.data;        // 拿到主线程发来的数据
  var res = nums[0] + nums[1];  // 在子线程中计算
  postMessage(res);             // 把结果发回主线程
};

要点解释与易错点

  • new Worker('./worker.js') 创建子线程,子线程的代码必须单独写在一个 JS 文件里,不能和主线程代码写在同一个 <script> 中。
  • 主线程用 worker.postMessage(数据) 向子线程发送数据,用 worker.onmessage 监听子线程返回的数据。
  • 子线程里 postMessage 和 onmessage 直接写在全局,不需要 worker. 前缀。
  • 易错点:postMessage 一次只能传一个参数,传两个参数会报错,多个数据要打包成数组或对象。
  • 易错点:子线程不能操作 DOM,也没有 window,所以计算结果必须通过 postMessage 发回主线程,由主线程渲染到页面。
  • 易错点:Worker 不能通过 file:// 双击打开页面使用,必须通过本地服务器(如 Live Server)打开,否则会报错。
  • 使用场景:把比较耗时的运算放到子线程,避免阻塞主线程的 DOM 操作和页面渲染。

常见错误 ​

  1. 现象:setTimeout(fn, 0) 写在前面,却晚于后面的同步代码输出。
    原因:定时器回调是异步任务,要等主线程空闲后才执行。
    修法:按“同步代码先执行、异步回调后执行”分析输出顺序,不要用 0ms 假设立即执行。

  2. 现象:循环中创建多个定时器,回调里读到的 i 都是同一个最终值。
    原因:回调异步执行时循环已结束,var 变量被共享。
    修法:改用 let 形成块级作用域,或用 IIFE/闭包保存当次值。

  3. 现象:把“JS 单线程”理解成浏览器只有一个线程,解释不了计时器和事件监听。
    原因:单线程指执行 JS 代码的主线程只有一个,浏览器还有辅助线程负责计时、事件调度等。
    修法:区分“执行 JS 的主线程”和“辅助调度线程”,不要混为一谈。

  4. 现象:在 Worker 子线程里写 document、window 或操作 DOM,直接报错。
    原因:Worker 子线程没有 DOM/window 权限,不能操作页面。
    修法:子线程只做计算,通过 postMessage 回传结果,主线程 onmessage 接收后更新 DOM。

  5. 现象:worker.postMessage(a, b) 传多个参数报错,或传函数/DOM 节点失败。
    原因:postMessage 只接收一个可结构化克隆的数据,不能传函数、DOM 等。
    修法:把多个值打包成数组或对象一次发送,接收端用 event.data 取数据。

  6. 现象:本地双击 HTML 打开时创建 Worker 失败。
    原因:Worker 受同源/协议限制,file:// 方式常不可用。
    修法:通过本地服务器或 http(s) 打开页面再测试 Worker。

  7. 现象:认为所有回调函数都是异步任务,例如把 forEach 回调也当成异步。
    原因:回调只是函数形式,是否异步要看是否满足条件并等待主线程空闲。
    修法:判断异步任务看执行时机,不要只看“是不是回调”。

练习 ​

  1. 写出输出顺序:

    js
    setTimeout(() => console.log('A'), 0);
    console.log('B');

    参考答案:B → A。

  2. 写出输出结果,并说明如何改成 0 1 2:

    js
    for (var i = 0; i < 3; i++) &#123;
      setTimeout(() => console.log(i), 0);
    &#125;

    参考答案:输出 3 3 3;把 var 改成 let,或用 IIFE 保存每次的 i。

  3. 判断并说明:DOM 事件回调、forEach 回调是否都是异步任务?
    参考答案:DOM 事件回调是异步任务;forEach 回调是同步回调,立即执行。

  4. 改错:子线程中写 document.getElementById('box').innerHTML = 'ok';主线程写 worker.postMessage(num1, num2)。
    参考答案:子线程不能操作 DOM,应把计算结果 postMessage 回主线程;主线程应打包发送,如 worker.postMessage([num1, num2])。

  5. 场景题:点击按钮后要做长时间计算,页面会卡住,如何用 Worker 改造?
    参考答案:主线程监听点击并 postMessage 传参;子线程 onmessage 接收后计算,再 postMessage 回传;主线程 worker.onmessage 接收结果并更新页面。

原视频对照 ​

#视频时长
1JS单线程运行11:42
2同步任务和异步任务14:48
3事件轮询机制12:24
4异步题目20:59
5JS多线程18:32

小结 ​

  1. JS 代码由主线程执行,浏览器辅助线程负责计时、事件调度等,不等于浏览器只有一个线程。
  2. 同步任务先执行;异步回调满足条件后进入回调队列,等待主线程空闲。
  3. 事件轮询机制负责在主线程空闲时取出回调执行。
  4. 定时器只保证“不早于设定时间”,不保证到点立刻执行。
  5. 回调函数不一定是异步任务,forEach 等同步回调会立即执行。
  6. 循环中异步回调读取 var 变量容易拿到最终值,应使用 let 或闭包保存快照。
  7. Worker 子线程不能操作 DOM/window,适合放置耗时计算。
  8. 主线程与 Worker 通过 postMessage 和 onmessage 通信,多个数据应打包成一个对象或数组发送。