-
在 JS 里只有对象
在 JS 里,几乎所有的东西都是对象(Object)。你可以把一个对象想象成一个 C 语言的 struct,或者更准确地说是 Hash Map。
忘掉类和实例的概念。
-
函数也是对象
当你定义
function User(name) { ... }时,JS 引擎帮你执行了以下操作:// JS 引擎内部偷偷干的事: // 1. Malloc: 创建函数对象 const User = { length: 1, name: "User", prototype: null, // ... [[FunctionLocation]] // 源代码 // [[Call]]: <code address>, console调试中不可见 [[Prototype]]: Function.prototype, [[Scopes]] // 作用域 }; // 2. Auto-Create Prototype: 自动创建一个空对象作为模板 const tmp = {}; tmp.constructor = User; // 让模板指回函数 // 3. Link: 把这个模板挂在函数的 'prototype' 属性上 User.prototype = tmp;
关键点:
- 它有属性,所以你可以随意给它加属性(User.myStaticData = 123)。
- 它有
[[Call]]内部方法,所以你可以在它后面加括号 User() 来运行它。
-
访问对象属性流程
当你要访问对象 obj 的属性
obj.x时,JS 引擎会执行一个**链表查找(Lookup)**操作:- 检查自身: obj 这个 Hash Map 里有没有 key 叫 x 的?
- 有 -> 返回。
- 没有 -> 顺着指针往下找。
- 检查上级: JS 引擎会去读一个隐藏的指针,这个指针叫
[[Prototype]](在代码里曾通过__proto__getter setter 暴露)。它指向另一个对象(父节点)。 - 递归查找: 在父节点里找
x。如果没有,找父节点的父节点。 - 终点: 直到找到
null(类似于链表的 nullptr),如果还没找到,返回 undefined。
- 检查自身: obj 这个 Hash Map 里有没有 key 叫 x 的?
-
[[Prototype]]/__proto__[[Prototype]]是对象实例(Instance)身上携带的隐藏指针,指向它的上级。不能直接打印。__proto__是定义在Object.prototype中的一对 setter getter。当我们使用User.__proto__时,User 顺着自己的[[Prototype]]去Function.prototype。Function.prototype顺着自己的[[Protoype]]去Object.protoype。Object.prototype里找到了get __proto__() { return this.[[Prototype]]; // 返回内部指针 },this依旧指向User,所以返回User.[[Prototype]]。 -
Object.getPrototypeOf__proto__是一个历史遗留的访问器(Getter/Setter),现代标准推荐用Object.getPrototypeOf(User)。 -
prototype这是一个函数(构造器)特有的属性。它是一个指针,指向一块放着共用方法的公共内存区域。
作用: 当你用
new关键字调用这个函数时,JS 会把新创建出来的对象的[[Prototype]]指针,指向这个prototype所在的内存地址。C 语言类比: 这是一块静态的共享内存(Static Shared Memory),里面放着所有实例共用的方法(函数指针)。
-
new 的实际例子
// 有这样一个函数 function User(name) { this.name = name; } // 现在我们执行这个语句 const user = new User("Jin"); // 实际上执行了: // 1. 在堆上创建了一个空的对象 let tmpObject = {}; // 2. 把 [[Prototype]] 指针,指向 User 函数里的 prototype 属性(公共代码段) tmpObject.[[Prototype]] = User.prototype; // 3. 最后一步 // 这行代码的意思是:执行 User 函数,但在函数内部,'this' 就是 'tmpObject' // 此时,User 函数内部执行了: // tmpObject.name = "Jin"; const result = User.call(tmpObject, "Jin"); // 4. 地址赋值 // 如果构造函数显式 return 了一个对象,那就用它返回的; // 否则(return null/undefined/基本类型),才返回我们自动创建的 tmpObject。 const user = (typeof result === 'object' && result !== null) ? result : tmpObject;;
-
示意图
地址: 0xFC (变量 user) +---------------------+ | name: "Jin" | | [[Prototype]]: 0xFB | ---+ +---------------------+ | | 指向 v 地址: 0xFB (User.prototype) <-----------+ +-----------------------------+ | | constructor: 0xFA (User函数) |-----+ | | (并没有其他方法,除非你加) | | | | [[Prototype]]: Object.proto | | | +-----------------------------+ | | | | | | | | 地址: 0xFA (function User) <--------+ | +------------------------+ | | [Code] | | | prototype: 0xFB |-------------+ | [[Prototype]]: | | Function.prototype | | ... | +------------------------+ -
Object.createnew 的更底层替代品。它只做两件事:
- malloc 一个空对象。
- 把这个对象的
[[Prototype]]指向你传入的参数,没有任何自动转换。
注意:它不执行构造函数(不跑
User.call)!function User(name) { this.name = name; } // 现在我们执行这个语句 const user = Object.create(User.prototype); // 实际上执行了: // 1. 在堆上创建了一个空的对象 let tmpObject = {}; // 2. 把 [[Prototype]] 指针,指向 User 函数里的 prototype 属性(公共代码段) tmpObject.[[Prototype]] = User.prototype; // 3. 赋值 user = tmpObject
在 C/C++ 里,你可以开 pthread,让两个线程同时去读写内存。但在浏览器里,这是禁忌。
设想场景:线程 A 想把一个 DOM 节点删了,线程 B 想给这个节点加个样式。如果不加锁,浏览器直接崩了。
为了省去复杂的锁机制,JS 采用了单线程 (Single Thread) 模型。也就是主线程 (Main Thread) 只有一个。
如果只有一个线程,当你执行 sleep(5000) 或者请求网络时,CPU 就得傻等。此时 UI 渲染也会卡死,鼠标都动不了。这在 OS 里叫“忙等待”,是不可接受的。
大多数屏幕是 60Hz 刷新率,意味着主线程每 16.6ms 必须完整地跑完下面这一整套流程,否则就会掉帧(卡顿)。
以下是主线程在 一次循环(One Tick) 里的完整执行流程:
循环开始,主线程首先看 Macrotask Queue(即我们常说的事件队列)。
动作: 取出一个(注意,通常只是一个)任务执行。为什么只取一个?因为屏幕每 16.6 ms 就要刷新一次,如果把队列里面的任务全部执行了,屏幕直接卡死了。
任务内容包括什么?
- 解析 HTML: 当页面刚开始加载时,"Parse HTML" 本身就是一个宏任务。如果遇到
<script> </script>,直接当场执行。 - JS 回调: setTimeout, setInterval, 鼠标点击事件 (click), 网络请求回调 (onload)。
耗时: 如果同步 js 代码跑了 200ms,那整个流水线就卡在这里 200ms,后面的 Layout 和 Paint 只能干等(页面卡死)。
另外各种 js 回调是谁放到宏任务队列里面的?是除了主线程意外事件的其他线程,比如:
- 定时器线程: 计时结束,它负责把回调包装成 Task,加锁 (Mutex),塞进主线程的队列。
- 网络线程: 收到 HTTP 响应,它负责把 onLoad 回调塞进队列。
- UI 线程/OS 消息: 鼠标点击,OS 把它转成 Task 塞进队列。
宏任务跑完后,主线程绝对不会立刻去画画,也不会去跑下一个宏任务。它会立刻检查 Microtask Queue。
来源: Promise.then, async/await, queueMicrotask, MutationObserver。
规则: 清空全场! 主线程会一口气把微任务队列里的东西全部执行完。
陷阱: 如果你在微任务里又产生新的微任务(递归 Promise),主线程就会死在这个阶段,永远无法进入下一步(页面卡死)。
这时候,宏任务+微任务都跑完了。主线程会看一眼手表:“现在到了该画下一帧的时候了吗?”
如果是(比如距离上次绘制已经过了 16ms),它会进入渲染流程。
如果不是(比如刚刚才画过,硬件刷新率还没到),它可能跳过渲染,直接进入下一轮循环。
如果决定渲染,它会先执行 requestAnimationFrame() 注册的回调函数。这是做动画的最佳位置。
这是你关心的 “非 JS 工作”。如果决定渲染,主线程会执行以下 C++ 内部函数:
-
Recalculate Styles (样式计算):
检查刚才 JS 有没有改 DOM (div.style.color = 'red')。
计算每个节点最终长什么样(匹配 CSS 规则)。
-
Layout (Reflow/重排):
计算几何位置。这个 div 宽 100px,那个 button 在它下面 5px。
这是一项极其消耗 CPU 的计算操作。
-
Paint (Repaint/重绘):
生成绘制指令(Display List)。比如:“在 (10,10) 画一个红矩形”。
-
Composite (合成):
这是最后一步,主线程把生成的图层信息提交给 Compositor Thread(合成器线程)。
注意: 真正的像素填充(Rasterization)通常是合成器线程交给 GPU 去做的,主线程这就完事了。
如果上面的事都干完了:
- Macro Queue 空了。
- Micro Queue 空了。
- 也画完一帧了(或者不需要画)。
主线程就会调用系统 API(如 epoll_wait),进入 Blocked/Sleep 状态,等待新的事件(鼠标、网络、定时器)把它唤醒,然后回到第一阶段。
当你运行 node index.js 时,Node.js 做的第一件事并不是启动 Event Loop。nodejs 会执行:
- 初始化环境:Node.js 启动,初始化 V8 引擎。
- 执行同步代码:这是“第 0 阶段”。
- V8 会从头到尾、同步地执行 index.js 里的所有顶层代码。
- 如果遇到 console.log(同步),直接打印。
- 如果遇到 setTimeout(异步),它只是把回调交给计时管理器,然后立刻继续往下走。
- 如果遇到 fs.readFile(异步),它只是向底层线程池发出请求,然后立刻继续往下走。
- 启动 Event Loop:只有当所有的同步代码都执行完毕,主线程的调用栈(Call Stack)空了,Node.js 才会说:“好,现在没事干了,让我们开始转圈(Event Loop)吧。”
Event Loop 有两个阶段:
Node.js 中,宏任务队列被拆分成了 6 个不同的阶段 (Phases)。事件循环像一个转轮,依次经过这 6 个阶段,每个阶段都有自己独立的队列。
Node.js Event Loop 的六个阶段(按执行顺序):
- Timers(定时器阶段):执行 setTimeout 和 setInterval 的回调。
- Pending Callbacks:执行一些系统级操作的回调(比如 TCP 错误)。
- Idle, Prepare:内部使用,不用管。
- Poll(轮询阶段 - 最重要):
- 执行 I/O 回调(比如文件读取完毕、网络请求收到数据)。
- 如果队列空了,Node 可能会在这里阻塞 (Block) 等待新的 I/O 事件(这就解释了为什么 Node 叫“非阻塞I/O”,因为它专门有个阶段在等,而不是主线程傻等)。
- Check(检查阶段):专门执行 setImmediate 的回调。
- Close Callbacks:执行关闭事件的回调,如 socket.on('close', ...)。
执行完任意一个宏任务(比如一个 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 是一个状态机对象。 它代表了一个“目前可能还没结果,但未来会有结果(成功或失败)”的值。 它把“回调函数”变成了一个“链式调用”的对象。
有三种状态:
- Pending: 正在干活(比如 TCP 连接中)。
- Fulfilled (Resolved): 干成了,拿到了数据。
- Rejected: 干砸了,抛出了错误。
怎么创建一个 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 实例后,我们用链式调用来处理结果。
- .then(): 处理成功。
- .catch(): 处理失败。
- .finally(): 无论成功失败都会执行(比如关闭 loading 动画)。
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。
痛点: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);
});
});
}