普通视图
# 关于初学者对于JS异步编程十大误区
前端开发中 Promise 与异步编程还存在大量易混淆、易踩坑的场景,以下按「基础概念」「方法使用」「异步协作」「与其他机制配合」四大类整理,附带代码示例和正确逻辑:
一、基础概念类误区
误区 1:“Promise 新建后会立即执行,所以是同步的”
-
错误理解:认为
new Promise((resolve) => { ... })里的代码是同步的,或 Promise 整体是 “同步工具”。 -
真实逻辑:Promise 的「执行器函数」(
new Promise里的回调)是立即同步执行的,但 Promise 的「回调函数」(.then()/.catch())是异步微任务,会在当前同步代码执行完后才触发。 -
示例验证:
console.log('1: 同步开始'); new Promise((resolve) => { console.log('2: Promise 执行器(同步)'); resolve(); }).then(() => { console.log('4: .then() 回调(异步微任务)'); }); console.log('3: 同步结束'); // 输出顺序:1 → 2 → 3 → 4(而非 1→2→4→3)
误区 2:“Promise 状态一旦确定,后续调用 .then () 不会触发”
-
错误理解:认为 Promise 从
pending变为fulfilled/rejected后,再调用.then()会 “失效”。 -
真实逻辑:Promise 状态是「不可逆且记忆的」—— 状态确定后,后续再绑定的
.then()/.catch()会立即触发(基于已记忆的结果)。 -
示例验证:
// 1. 先创建 Promise 并让其成功 const p = Promise.resolve('已成功'); // 2. 1秒后再绑定 .then() setTimeout(() => { p.then(res => console.log(res)); // 1秒后输出 '已成功'(正常触发) }, 1000);
误区 3:“Promise 链中,return 后的值会直接传给下一个 .then (),无需 resolve”
-
错误理解:认为在
.then()中 return 普通值(非 Promise)时,需要手动调用resolve()才能传递,或 return Promise 时需要额外处理。 -
真实逻辑:
.then()会自动包装返回值—— 若 return 普通值(如数字、对象),会自动用Promise.resolve(返回值)包装;若 return Promise,会等待该 Promise 状态确定后再传递结果。 -
示例验证:
Promise.resolve(1) .then(res => { return res * 2; // 普通值,自动包装为 Promise.resolve(2) }) .then(res => { return new Promise(resolve => setTimeout(() => resolve(res * 2), 500)); // 返回 Promise }) .then(res => console.log(res)); // 500ms 后输出 4(无需手动 resolve)
二、方法使用类误区
误区 4:“Promise.all () 会等待所有任务完成,包括失败的”
-
错误理解:认为
Promise.all([p1, p2, p3])会等 p1、p2、p3 全部执行完(无论成功失败),再返回结果。 -
真实逻辑:
Promise.all()是「快速失败」机制 ——只要有一个任务变为 rejected,会立即触发 .catch (),并忽略后续其他任务的结果,不会等待所有任务完成。 -
反例验证:
const p1 = new Promise(resolve => setTimeout(() => resolve('p1'), 1000)); const p2 = new Promise((_, reject) => setTimeout(() => reject('p2 失败'), 500)); const p3 = new Promise(resolve => setTimeout(() => resolve('p3'), 1500)); Promise.all([p1, p2, p3]) .then(res => console.log(res)) // 不执行 .catch(err => console.log(err)); // 500ms 后输出 'p2 失败'(p1、p3 仍在执行,但结果被忽略) -
正确需求:若需等待所有任务完成(无论成败),应使用
Promise.allSettled()。
误区 5:“Promise.race () 只关心第一个成功的任务”
-
错误理解:认为
Promise.race()会筛选 “第一个成功的任务”,忽略第一个失败的任务。 -
真实逻辑:
Promise.race()关心的是「第一个状态确定的任务」—— 无论该任务是fulfilled(成功)还是rejected(失败),只要第一个确定状态,就返回该结果。 -
反例验证(超时控制场景易踩坑):
// 需求:接口请求3秒内成功则用结果,超时则提示失败 const request = new Promise((_, reject) => setTimeout(() => reject('接口报错'), 2000)); // 2秒后失败 const timeout = new Promise((_, reject) => setTimeout(() => reject('请求超时'), 3000)); // 3秒后超时 Promise.race([request, timeout]) .then(res => console.log(res)) // 不执行 .catch(err => console.log(err)); // 2秒后输出 '接口报错'(第一个确定状态的是失败任务)
误区 6:“.then () 的第二个参数(onRejected)与 .catch () 完全等价”
-
错误理解:认为
.then(res => {}, err => {})中的err => {}和单独的.catch(err => {})功能一样,可随意替换。 -
真实逻辑:
.then()的第二个参数只能捕获其上游 Promise 本身的错误,无法捕获.then()第一个参数(onFulfilled)中的错误;而.catch()能捕获其上游所有链路的错误(包括前一个.then()中抛出的错误)。 -
示例对比:
// 情况1:用 .then() 第二个参数 Promise.resolve(1) .then( res => { throw new Error('then 里抛错'); }, // 第一个参数中抛错 err => console.log('捕获到:', err) // 不执行(无法捕获前一个 then 的错误) ) .catch(err => console.log('最终捕获:', err)); // 执行,输出 'then 里抛错' // 情况2:用 .catch() Promise.resolve(1) .then(res => { throw new Error('then 里抛错'); }) .catch(err => console.log('捕获到:', err)); // 执行,直接捕获 then 里的错误 -
结论:推荐用
.catch()统一处理错误,而非.then()的第二个参数。
三、异步协作类误区
误区 7:“用 for 循环遍历执行 Promise,会按顺序触发”
-
错误理解:认为用
for循环调用多个返回 Promise 的函数,会等前一个执行完再执行下一个(顺序执行)。 -
真实逻辑:
for循环是同步代码,会一次性触发所有 Promise,它们会并行执行(而非顺序),最终结果的顺序取决于任务本身的执行速度。 -
反例验证:
// 模拟异步任务:传入延迟时间,延迟后输出数字 function delayTask(num, delay) { return new Promise(resolve => setTimeout(() => { console.log(num); resolve(num); }, delay)); } // 错误写法:一次性触发所有任务,并行执行 for (let i = 1; i <= 3; i++) { delayTask(i, 1000); // 1秒后同时输出 1、2、3(而非 1→2→3 依次间隔1秒) } -
正确需求(顺序执行):需用
async/await + for 循环或 Promise 链式调用:// 正确写法:async/await + for 循环(顺序执行) async function runSeq() { for (let i = 1; i <= 3; i++) { await delayTask(i, 1000); // 1秒后输出1 → 再等1秒输出2 → 再等1秒输出3 } } runSeq();
误区 8:“Promise 链中,return 了错误就会触发下一个 .catch ()”
-
错误理解:认为在
.then()中 return 一个错误对象(如return new Error('错了')),会自动触发下一个.catch()。 -
真实逻辑:只有当 Promise 状态变为
rejected时才会触发.catch()—— return 普通错误对象(非throw或reject)会被视为「成功的结果」,包装成Promise.resolve(错误对象),不会触发.catch()。 -
示例验证:
Promise.resolve() .then(() => { return new Error('return 错误对象'); // 视为成功结果,非 rejected }) .then(res => console.log('then 接收:', res)) // 执行,输出 "Error: return 错误对象" .catch(err => console.log('catch 接收:', err)); // 不执行 // 正确触发 catch 的方式:throw 或 return Promise.reject() Promise.resolve() .then(() => { throw new Error('throw 错误'); // 触发 rejected // 或 return Promise.reject(new Error('reject 错误')); }) .catch(err => console.log('catch 接收:', err)); // 执行
四、与其他机制配合类误区
误区 9:“async 函数里的所有错误,都能被外层 try...catch 捕获”
-
错误理解:认为
async function中所有代码的错误,只要用try...catch包裹函数调用,就能全部捕获。 -
真实逻辑:
try...catch只能捕获async函数中「await标记的 Promise 错误」和「同步错误」;若async函数中存在「未被await的 Promise 错误」,会成为「未处理的 Promise 拒绝」,无法被外层try...catch捕获。 -
示例验证:
async function asyncTask() { // 错误1:未被 await 的 Promise 错误 new Promise((_, reject) => reject('未 await 的错误')); // 错误2:被 await 的 Promise 错误 await new Promise((_, reject) => reject('已 await 的错误')); } try { asyncTask(); // 调用 async 函数 } catch (err) { console.log('捕获到:', err); // 只捕获到 "已 await 的错误","未 await 的错误" 会成为未处理拒绝 }
误区 10:“setTimeout 里的 Promise 错误,能被外层 try...catch 捕获”
-
错误理解:认为用
try...catch包裹setTimeout,就能捕获setTimeout回调中 Promise 的错误。 -
真实逻辑:
setTimeout回调是「宏任务」,会在当前同步代码(包括try...catch)执行完后才触发;Promise 错误属于「微任务」,会在宏任务回调内部的同步代码执行完后触发,二者不在同一执行上下文,外层try...catch无法捕获。 -
示例验证:
try { setTimeout(() => { // 该 Promise 错误在宏任务回调中,外层 try...catch 已执行完毕 Promise.reject('setTimeout 里的错误'); }, 1000); } catch (err) { console.log('捕获到:', err); // 不执行 } -
正确处理:需在
setTimeout回调内部或 Promise 链中处理错误:setTimeout(() => { Promise.reject('setTimeout 里的错误') .catch(err => console.log('捕获到:', err)); // 执行 }, 1000);
浏览器&Websocket&热更新
热更新基本流程图
![]()
一、先明确:什么是热更新(HMR)?
热更新是指:在开发过程中,当代码发生修改并保存后,浏览器无需刷新整个页面,仅更新修改的模块(如组件、样式、逻辑等),同时保留页面当前状态(如表单输入、滚动位置、组件数据等) 。
与传统的 “自动刷新”(如 live-reload)相比,HMR 的核心优势是:
- 局部更新:只替换修改的部分,不影响其他模块;
- 状态保留:避免因全页刷新导致的状态丢失;
- 速度极快:Vite 的 HMR 几乎是 “即时” 的(毫秒级)。
二、前端开发中:浏览器与开发服务器的 “连接基础”
要实现热更新,首先需要建立开发服务器与浏览器之间的 “实时通信通道”,否则浏览器无法知道 “代码何时被修改了”。
在 Vite 中:
-
开发服务器(Vite Dev Server) :启动项目时(
vite dev),Vite 会在本地启动一个 HTTP 服务器(默认端口 5173),负责提供页面资源(HTML、JS、CSS 等),同时监听文件变化。 - 浏览器:通过 HTTP 协议访问开发服务器,加载并渲染页面。
- 通信桥梁:仅靠 HTTP 协议无法实现 “服务器主动通知浏览器”(HTTP 是 “请求 - 响应” 模式,服务器不能主动发消息),因此需要 WebSocket 建立 “双向通信通道”。
三、WebSocket:浏览器与服务器的 “实时对讲机”
WebSocket 是一种全双工通信协议,允许客户端(浏览器)和服务器在建立连接后,双向实时发送消息(无需客户端反复请求)。这是热更新的 “通信核心”。
在 Vite 中,WebSocket 的作用是:
- 服务器监听文件变化,当文件被修改时,通过 WebSocket 向浏览器 “发送更新通知”;
- 浏览器收到通知后,通过 WebSocket 向服务器 “请求更新的模块内容”;
- 双方通过 WebSocket 交换 “更新信息”(如哪个模块变了、新模块的地址等)。
四、Vite 热更新的完整流程(一步一步拆解)
假设我们在开发一个 Vue 项目,修改了 src/components/Hello.vue 并保存,Vite 的热更新流程如下:
步骤 1:Vite 开发服务器监听文件变化
- Vite 启动时,会通过
chokidar库(文件监听工具)对项目目录(如src/)进行监听,实时检测文件的创建、修改、删除等操作。 - 当我们修改并保存
Hello.vue时,文件系统会触发 “修改事件”,Vite 服务器立刻感知到:src/components/Hello.vue发生了变化。
步骤 2:Vite 服务器编译 “变更模块”(而非全量编译)
-
Vite 基于 “原生 ESM(ES 模块)” 工作:开发时不会打包所有文件,而是让浏览器直接通过
<script type="module">加载模块。 -
当
Hello.vue被修改后,Vite 只会重新编译这个单文件组件(.vue 文件):- 解析模板(template)生成渲染函数;
- 处理脚本(script)和样式(style);
- 生成该组件的 “更新后模块内容”,并标记其唯一标识(如
id=123)。
-
同时,Vite 会分析 “依赖关系”:判断哪些模块依赖了
Hello.vue(比如父组件、页面等),确定需要更新的 “模块范围”。
步骤 3:服务器通过 WebSocket 向浏览器发送 “更新通知”
-
Vite 服务器内置了 WebSocket 服务(默认路径为
ws://localhost:5173/ws),浏览器加载页面时,会自动通过 JavaScript 连接这个 WebSocket。 -
服务器将 “变更信息” 通过 WebSocket 发送给浏览器,信息格式类似:
{ "type": "update", // 类型:更新 "updates": [ { "type": "js-update", // 更新类型:JS 模块 "path": "/src/components/Hello.vue", // 变更文件路径 "acceptedPath": "/src/components/Hello.vue", "timestamp": 1699999999999 // 时间戳(避免缓存) } ] }这个消息告诉浏览器:
Hello.vue模块更新了,需要处理。
步骤 4:浏览器接收通知,请求 “更新的模块内容”
-
浏览器的 Vite 客户端(Vite 注入的 HMR 运行时脚本)接收到 WebSocket 消息后,解析出需要更新的模块路径(
Hello.vue)。 -
客户端通过 HTTP 请求(而非 WebSocket)向服务器获取 “更新后的模块内容”,请求地址类似:
http://localhost:5173/src/components/Hello.vue?t=1699999999999(
t参数是时间戳,用于避免浏览器缓存旧内容)。
步骤 5:浏览器 “替换旧模块” 并 “局部更新视图”
-
客户端拿到新的
Hello.vue模块内容后,会执行 “模块替换”:- 对于 Vue 组件,Vite 会利用 Vue 的
defineComponent和热更新 API(import.meta.hot),将旧组件的实例替换为新组件的实例; - 保留组件的状态(如
data中的数据),仅更新模板、样式或逻辑; - 对于样式文件(如
.css),会直接替换<style>标签内容,无需重新渲染组件。
- 对于 Vue 组件,Vite 会利用 Vue 的
-
替换完成后,Vue 的虚拟 DOM 会对比新旧节点,只更新页面中受影响的部分(如
Hello.vue对应的 DOM 区域),实现 “局部刷新”。
步骤 6:处理 “无法热更新” 的情况(降级为刷新)
- 某些场景下(如修改了入口文件
main.js、路由配置、全局状态等),模块依赖关系过于复杂,无法安全地局部更新。 - 此时 Vite 会通过 WebSocket 发送 “全页刷新” 指令,浏览器收到后执行
location.reload(),确保代码更新生效。
五、关键技术点:Vite 如何实现 “极速 HMR”?
- 原生 ESM 按需加载:开发时不打包,浏览器直接加载模块,修改后只需重新编译单个模块,而非整个包(对比 Webpack 的 “打包后更新” 快得多)。
-
精确的依赖分析:Vite 会跟踪模块间的依赖关系(通过
import语句),修改一个模块时,只通知依赖它的模块更新,范围最小化。 - 轻量的客户端运行时:Vite 向浏览器注入的 HMR 脚本非常精简,仅负责接收通知、请求新模块、替换旧模块,逻辑高效。
-
与框架深度集成:针对 Vue、React 等框架,Vite 提供了专门的 HMR 处理逻辑(如 Vue 的
@vitejs/plugin-vue插件),确保组件状态正确保留。
总结:Vite 热更新的核心链路
文件修改(保存)
↓
Vite 服务器监听文件变化
↓
编译变更模块(仅修改的文件)
↓
WebSocket 发送更新通知(告诉浏览器“哪个模块变了”)
↓
浏览器通过 HTTP 请求新模块内容
↓
替换旧模块,框架(如 Vue)局部更新视图
↓
页面更新完成(状态保留,无需全量刷新)
场景假设:你修改了 src/App.vue 并保存
1. Vite 脚手架确实内置了 WebSocket 服务
-
当你运行
vite dev时,Vite 会同时启动两个服务:-
HTTP 服务:默认
http://localhost:5173,负责给浏览器提供页面、JS、CSS 等资源(比如你在浏览器输入这个地址就能看到项目)。 -
WebSocket 服务:默认
ws://localhost:5173/ws,专门用来和浏览器 “实时聊天”(双向通信)。
-
HTTP 服务:默认
-
浏览器打开项目页面时,会自动通过一段 Vite 注入的 JS 代码,连接这个 WebSocket(相当于浏览器和服务器之间架了一根 “实时电话线”)。
2. 当文件变化时,Vite 先 “发现变化”,再通过 WebSocket 喊一声 “有东西改了!”
-
你修改
App.vue并按Ctrl+S保存:- Vite 会通过文件监听工具(类似 “监控摄像头”)立刻发现
App.vue变了。 - 它会快速处理这个文件(比如编译 Vue 模板、处理样式),生成 “更新后的内容”,并记下来 “是
App.vue这个文件变了”。
- Vite 会通过文件监听工具(类似 “监控摄像头”)立刻发现
-
然后,Vite 通过 WebSocket 给浏览器发一条消息(就像打电话通知):
{ "type": "update", "updates": [{"path": "/src/App.vue", "timestamp": 123456}] }翻译成人话:“喂,浏览器!
src/App.vue这个文件刚刚改了,赶紧处理一下!”
3. 浏览器收到通知后,用 HTTP 请求 “主动要新内容”
-
浏览器接收到 WebSocket 的消息后,知道了 “
App.vue变了”,但此时它还没有新内容。 -
于是浏览器会通过 HTTP 协议,向 Vite 的 HTTP 服务发一个请求,要新的
App.vue内容:GET http://localhost:5173/src/App.vue?t=123456(
t=123456是时间戳,确保拿到的是最新的,不是缓存的旧内容)。 -
Vite 的 HTTP 服务收到请求后,把刚才处理好的 “更新后的
App.vue内容” 返回给浏览器。
4. 浏览器拿到新内容,局部更新页面
-
浏览器收到新的
App.vue代码后,会:- 丢掉旧的
App.vue模块(但保留组件状态,比如输入框里的文字)。 - 用新代码替换,并重新渲染
App.vue对应的页面部分(不会刷新整个页面)。
- 丢掉旧的
-
最终你看到的效果:页面上只有
App.vue相关的部分变了,其他内容和状态都没变。
一句话总结核心流程
你改了文件 → Vite 发现 → 用 WebSocket 通知浏览器“哪个文件变了” → 浏览器用 HTTP 要这个文件的新内容 → 浏览器局部更新页面
WebSocket 只负责 “传递变化的消息”(谁变了),真正的新内容还是通过 HTTP 请求获取 —— 分工明确,效率更高。这也是 Vite 热更新快的原因之一:只传必要的消息,拿必要的新内容,不做多余的事。
为啥websocket不一步到位传变化内容给浏览器
这个问题很关键!核心原因是: “直接传变化内容” 看似省一步,实际会引发更复杂的问题,反而降低效率。Vite 选择 “WebSocket 传通知 + HTTP 取内容” 的分工,是权衡了前端开发的复杂性后的最优解。
1. 首先,“变化的内容” 可能非常大,不适合用 WebSocket 直接传
-
前端开发中,一个文件的修改可能涉及大量内容(比如一个复杂的 Vue 组件、包含数百行 CSS 的样式文件)。
-
WebSocket 虽然支持二进制传输,但设计初衷是 “轻量实时通信”(比如消息通知、状态同步),并不擅长高效传输大体积的代码内容。
-
如果直接通过 WebSocket 传完整的更新内容,会:
- 增加 WebSocket 连接的负担,可能导致消息堵塞(比如同时修改多个大文件时);
- 浪费带宽(HTTP 对静态资源传输有更成熟的优化,如压缩、缓存控制)。
2. 其次,“变化的内容” 可能需要 “按需处理”,浏览器需要主动决策
- 一个文件的修改可能影响多个模块(比如 A 依赖 B,B 依赖 C,改了 C 后 A、B 都可能需要更新)。
- 浏览器需要先知道 “哪些模块变了”,再根据自己当前的模块依赖关系,决定 “要不要请求这个模块的新内容”(比如某些模块可能已经被卸载,不需要更新)。
- 如果服务器直接把所有相关内容都推过来,浏览器可能收到很多无用信息(比如已经不需要的模块内容),反而增加处理成本。
3. 更重要的是:HTTP 对 “代码模块” 的传输有天然优势
-
缓存控制:浏览器请求新模块时,通过
?t=时间戳可以轻松避免缓存(确保拿到最新内容),而 WebSocket 消息没有内置的缓存机制,需要手动处理。 - 断点续传与重试:HTTP 对大文件传输有成熟的断点续传和失败重试机制,WebSocket 若传输中断,通常需要重新建立连接并重传全部内容。
-
与浏览器模块系统兼容:现代浏览器原生支持通过
<script type="module">加载 ES 模块(Vite 开发时的核心机制),而模块加载天然依赖 HTTP 请求。直接用 WebSocket 传代码,还需要手动模拟模块加载逻辑,反而更复杂。
4. 举个生活例子:像外卖点餐
-
WebSocket 就像 “短信通知”:店家(服务器)告诉你 “你点的餐好了”(哪个文件变了),短信内容很短,效率高。
-
HTTP 请求就像 “去取餐”:你收到通知后,自己去店里(服务器)拿餐(新内容),按需行动。
-
如果店家直接 “把餐扔到你家”(WebSocket 传内容),可能会出现:
- 你不在家(浏览器没准备好处理),餐浪费了;
- 点了 3 个菜,店家一次性全扔过来(大文件),可能洒了(传输失败)。
总结
Vite 之所以让 WebSocket 只传 “通知”、让 HTTP 负责 “传内容”,是因为:
- 两者分工明确:WebSocket 擅长轻量实时通信,HTTP 擅长高效传输资源;
- 适应前端开发的复杂性:模块依赖多变,按需请求比盲目推送更高效;
- 利用浏览器原生能力:HTTP 与 ES 模块加载机制无缝兼容,减少额外逻辑。
这种设计看似多了一次 HTTP 请求,实则通过 “各司其职” 让整个热更新流程更稳定、更高效 —— 这也是 Vite 热更新速度远超传统工具的原因之一。