JS 小知识

原型系统

线程模型

单线程

在 C/C++ 里,你可以开 pthread,让两个线程同时去读写内存。但在浏览器里,这是禁忌。

设想场景:线程 A 想把一个 DOM 节点删了,线程 B 想给这个节点加个样式。如果不加锁,浏览器直接崩了。

为了省去复杂的锁机制,JS 采用了单线程 (Single Thread) 模型。也就是主线程 (Main Thread) 只有一个。

如果只有一个线程,当你执行 sleep(5000) 或者请求网络时,CPU 就得傻等。此时 UI 渲染也会卡死,鼠标都动不了。这在 OS 里叫“忙等待”,是不可接受的。

浏览器主线程的工作

大多数屏幕是 60Hz 刷新率,意味着主线程每 16.6ms 必须完整地跑完下面这一整套流程,否则就会掉帧(卡顿)。

以下是主线程在 一次循环(One Tick) 里的完整执行流程:

第一阶段:宏任务 (Execute Macrotask)

循环开始,主线程首先看 Macrotask Queue(即我们常说的事件队列)。

动作: 取出一个(注意,通常只是一个)任务执行。为什么只取一个?因为屏幕每 16.6 ms 就要刷新一次,如果把队列里面的任务全部执行了,屏幕直接卡死了。

任务内容包括什么?

耗时: 如果同步 js 代码跑了 200ms,那整个流水线就卡在这里 200ms,后面的 Layout 和 Paint 只能干等(页面卡死)。

另外各种 js 回调是谁放到宏任务队列里面的?是除了主线程意外事件的其他线程,比如:

第二阶段:微任务 (Execute Microtasks)

宏任务跑完后,主线程绝对不会立刻去画画,也不会去跑下一个宏任务。它会立刻检查 Microtask Queue。

来源: Promise.then, async/await, queueMicrotask, MutationObserver。

规则: 清空全场! 主线程会一口气把微任务队列里的东西全部执行完。

陷阱: 如果你在微任务里又产生新的微任务(递归 Promise),主线程就会死在这个阶段,永远无法进入下一步(页面卡死)。

第三阶段:渲染前检测 (Request Animation Frame)

这时候,宏任务+微任务都跑完了。主线程会看一眼手表:“现在到了该画下一帧的时候了吗?”

如果是(比如距离上次绘制已经过了 16ms),它会进入渲染流程。

如果不是(比如刚刚才画过,硬件刷新率还没到),它可能跳过渲染,直接进入下一轮循环。

如果决定渲染,它会先执行 requestAnimationFrame() 注册的回调函数。这是做动画的最佳位置。

第四阶段:布局与绘制 (Layout & Paint)

这是你关心的 “非 JS 工作”。如果决定渲染,主线程会执行以下 C++ 内部函数:

第五阶段:休眠 (Idle)

如果上面的事都干完了:

主线程就会调用系统 API(如 epoll_wait),进入 Blocked/Sleep 状态,等待新的事件(鼠标、网络、定时器)把它唤醒,然后回到第一阶段。

NodeJS 主线程的工作

当你运行 node index.js 时,Node.js 做的第一件事并不是启动 Event Loop。nodejs 会执行:

Event Loop 有两个阶段:

宏任务

Node.js 中,宏任务队列被拆分成了 6 个不同的阶段 (Phases)。事件循环像一个转轮,依次经过这 6 个阶段,每个阶段都有自己独立的队列。

Node.js Event Loop 的六个阶段(按执行顺序):

微任务

执行完任意一个宏任务(比如一个 setTimeout 回调函数),立刻检查并执行所有微任务。

异步模型

异步回调和事件循环

主线程: 只负责执行栈里的代码(计算逻辑)。

外部环境(浏览器/Node): 负责干脏活累活(网络请求、定时器、文件 I/O)。这些是多线程的,但你不用管。

机制: 当你发起一个请求,你传给它一个函数指针(回调)。主线程继续往下跑,不等人。等外部环境干完活了,就把这个回调扔进任务队列 (Task Queue)。

Event Loop: 这就是个死循环(while(true)),它不断检查主线程空了没?空了就从队列里把回调拿出来执行。

代码例子:

// 这种写法很原始
requestData(url, function(result) { // 回调 1
    processData(result, function(data) { // 回调 2
        saveToDB(data, function(res) { // 回调 3
             console.log("终于搞定了");
        });
    });
});

Promise

Promise 是一个状态机对象。 它代表了一个“目前可能还没结果,但未来会有结果(成功或失败)”的值。 它把“回调函数”变成了一个“链式调用”的对象。

有三种状态:

怎么创建一个 promise:

// 这是一个“生产者”函数
function cookPizza(type) {
    // 1. 创建 Promise 对象
    // (resolve, reject) 是 JS 引擎传给你的两个“开关”函数
    return new Promise((resolve, reject) => {

        console.log(`1. 厨房接到单子:开始做 ${type} 披萨...`); // 同步执行!

        // 2. 模拟异步操作 (比如网络请求、定时器、文件读取)
        // 这里我们要用到刚才学的宏任务 setTimeout 来模拟耗时
        setTimeout(() => {
            const isSuccess = Math.random() > 0.3; // 模拟 70% 成功率

            if (isSuccess) {
                // 3a. 成功:调用 resolve,把结果传出去
                const result = { name: type, status: '香喷喷' };
                resolve(result);
            } else {
                // 3b. 失败:调用 reject,把错误原因传出去
                const error = new Error('烤箱炸了!');
                reject(error);
            }
        }, 2000); // 假装要做 2 秒
    });
}

怎么用:

拿到 Promise 实例后,我们用链式调用来处理结果。

console.log("A. 顾客点单");

// 调用上面定义的函数,它会立刻返回一个 Promise 小票
const pizzaPromise = cookPizza("夏威夷");

// 开始“消费”这个 Promise
pizzaPromise
    .then((pizza) => {
        // 这里只有在 resolve() 被调用后才会执行(微任务)
        console.log(`B. 成功吃上了:${pizza.name} (${pizza.status})`);

        // 【链式调用重点】:可以在这里 return 新的值,传给下一个 .then
        return "吃饱了";
    })
    .then((feeling) => {
        console.log(`C. 感觉:${feeling}`);
    })
    .catch((err) => {
        // 这里只有在 reject() 被调用后才会执行
        console.error(`D. 出大问题:${err.message}`);
    })
    .finally(() => {
        // 这里的代码最后总会执行
        console.log("E. 结账走人");
    });

console.log("F. 顾客去刷手机等餐");

总结起来,Promise 构造函数的参数必须是一个函数。这个函数可以有一个或者两个参数。如果只有一个参数,js 环境分配 resolve,如果有两个参数,js 环境分配 resolve 和 reject。resolve 和 reject 可以直接在该函数里被执行,也可以在异步回调函数里被执行。resolve 把 promise 的状态变成 resolved,reject 把 promise 的状态变成 rejected。

如果一个 promise 的状态在一个宏任务或者微任务执行的过程中,变成了 resolved,那么如果 promise 后面跟着 then 或者 finally,里面的回调函数会被放进微任务队列,无视 catch。

如果一个 promise 的状态在一个宏任务或者微任务执行的过程中,变成了 rejected,那么如果 promise 后面跟着 catch 或者 finally,里面的回调函数会被放进微任务队列,无视 then。

then 和 catch 里面的回调函数在微队列中执行完以后,只要回调函数 return 了某个值或者 undefined,那么 then/catch 会返回 resolved promise。只有回调函数 throw error 或者返回一个 rejected promise,then/catch 才返回 rejected promise。

Async/Await

痛点:Promise 链也挺烦的

虽然 Promise 解决了缩进问题,但一堆 .then() 看着还是不像正常的 C/Python 代码。而且参数传递很麻烦(Step 3 想用 Step 1 的结果,还得搞闭包)。

Async/Await 是什么?

它是 ES2017 引入的语法糖。底子依然是 Promise,但编译器帮你把 .then 隐藏了。

async 只能修饰函数定义。await 通常在 async 函数内部,也可以出现在 module 最顶层,不能出现在没有 async 修饰的函数内部。

await 后面需要是一个 Promise。

async 实际实现:

async 函数会自动把返回值包装成一个 Promise.resolve()。如果抛出错误,则包装成 Promise.reject()。

async function getData() {
    return "数据";
}

async function getError() {
    throw new Error("炸了");
}

function getData() {
    // 自动套一个 Promise
    return new Promise((resolve) => {
        resolve("数据");
    });
    // 或者简写:return Promise.resolve("数据");
}

function getError() {
    return new Promise((resolve, reject) => {
        reject(new Error("炸了"));
    });
    // 或者简写:return Promise.reject(new Error("炸了"));
}

单个 await 实际实现,await 这一行同步执行,下面的所有代码,都被打包放进了 .then 里面。

async function main() {
    console.log("1. 开始");

    // --- 刀切在这里 ---
    const result = await doSomething();

    // --- 下面全部进入微任务 ---
    console.log("3. 拿到结果:", result);
    return "结束";
}

function main() {
    // async 函数首先返回一个 Promise 容器
    return new Promise((resolve, reject) => {

        console.log("1. 开始"); // 同步执行

        // await doSomething() 等价于:
        // 1. 执行 doSomething()
        // 2. 把它的结果用 Promise.resolve() 包装(防止它返回的不是 Promise)
        Promise.resolve(doSomething()).then((result) => {

            // --- 这里的代码就是 await 下面的代码 ---
            console.log("3. 拿到结果:", result);

            // async 函数的 return "结束",变成了外层 Promise 的 resolve
            resolve("结束");

        }).catch((err) => {
            // 如果 doSomething 报错,或者 await 下面的代码报错
            // 触发外层 Promise 的 reject
            reject(err);
        });
    });
}