普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月7日首页

事件循环底层原理:从 V8 引擎到浏览器实现

2026年3月7日 01:35

前阵子面试被问到:async/await 被编译成什么样了?

我答不上来。面试官说:你用了这么久 async/await,连它怎么实现的都不知道?

回来研究了 V8 源码和 ECMAScript 规范,才发现异步编程的水比想象中深得多。

一、async/await 不是语法糖

很多人说 async/await 是 Promise 的语法糖,严格来说不对。

它更接近 Generator + Promise 的自动执行器。V8 引擎会把 async 函数编译成状态机。

看这段代码:

async function foo() {
  console.log(1);
  await bar();
  console.log(2);
}

V8 编译后大致等价于:

function foo() {
  return new Promise(resolve => {
    const stateMachine = {
      state: 0,
      next(value) {
        switch (this.state) {
          case 0:
            console.log(1);
            this.state = 1;
            return Promise.resolve(bar()).then(v => this.next(v));
          case 1:
            console.log(2);
            resolve();
            return;
        }
      }
    };
    stateMachine.next();
  });
}

每个 await 把函数分成不同的状态,执行完一个 await 就切换到下一个状态。

这就是为什么 await 后面的代码会被放进微任务队列——因为它实际上是 .then() 的回调。

面试追问:为什么 async/await 比 Promise.then 性能好?

因为 V8 对 async/await 做了优化,减少了 Promise 对象的创建。手写 .then().then().then() 会创建多个 Promise 实例,而 async/await 内部可能只创建一个。

二、微任务队列的真实实现

网上都说"微任务队列",但实际上不止一个队列。

根据 HTML 规范,浏览器有:

  1. 微任务队列(Microtask Queue)

    • Promise.then/catch/finally
    • MutationObserver
    • queueMicrotask
  2. Job Queue(ECMAScript 层面)

    • Promise Jobs
    • 这是 ES 规范定义的,比 HTML 规范更底层

Node.js 更复杂:

process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);

Node.js 输出:nextTickpromisetimeoutimmediate

Node.js 有多个队列:

  • nextTick Queue(优先级最高)
  • Promise Queue
  • Timer Queue(setTimeout/setInterval)
  • Check Queue(setImmediate)
  • Poll Queue(I/O)
  • Close Queue

这是一个很多人不知道的点:Node.js 和浏览器的事件循环实现完全不同。

浏览器:HTML 规范定义,一个微任务队列 + 一个宏任务队列

Node.js:libuv 实现,多个阶段,每个阶段有自己的队列

三、MutationObserver 为什么是微任务?

MutationObserver 用来监听 DOM 变化:

const observer = new MutationObserver(() => {
  console.log('DOM changed');
});
observer.observe(document.body, { childList: true });

document.body.appendChild(document.createElement('div'));
console.log('sync');

输出:syncDOM changed

DOM 变化后,回调不是立即执行,而是放进微任务队列。

为什么这样设计?

假设一个循环里改了 100 次 DOM:

for (let i = 0; i < 100; i++) {
  document.body.appendChild(document.createElement('div'));
}

如果每次 DOM 变化都触发回调,会执行 100 次。但如果放进微任务队列,100 次修改完成后只执行一次回调(批量处理)。

这是性能优化的经典设计。

四、Promise 的 then 为什么返回新 Promise?

看这道题:

const p = Promise.resolve(1);
const p2 = p.then(val => val + 1);

console.log(p === p2); // false

then 返回的是新 Promise,不是原来的。

为什么?

为了链式调用。如果返回同一个 Promise,链就会断掉:

Promise.resolve(1)
  .then(val => val + 1) // 返回新 Promise,resolve(2)
  .then(val => val + 2) // 拿到上一个 then 返回的 Promise
  .then(console.log);   // 4

每个 then 都返回新 Promise,形成一条链。

深层问题:then 返回的 Promise 什么时候 settle?

const p = new Promise(resolve => {
  setTimeout(() => resolve('done'), 1000);
});

const p2 = p.then(val => val + '!');

p2 不是立即 settle 的,而是等 p resolve 后,then 的回调执行完,p2 才 resolve。

这涉及 Promise Resolution Procedure(Promise 解决过程),是 ES 规范里最复杂的部分之一。

五、手写 Promise 的核心难点

网上手写 Promise 的文章很多,但大部分都漏了关键点。

1. then 的回调可以返回 Promise

Promise.resolve(1)
  .then(val => Promise.resolve(val + 1))
  .then(console.log); // 2

then 的回调如果返回 Promise,要等这个 Promise settle 后,外层 then 返回的 Promise 才 settle。

then(onFulfilled) {
  return new Promise((resolve, reject) => {
    const result = onFulfilled(this.value);
    // 关键:如果 result 是 Promise,要等它
    if (result instanceof Promise) {
      result.then(resolve, reject);
    } else {
      resolve(result);
    }
  });
}

2. then 可以被调用多次

const p = Promise.resolve(1);
p.then(console.log); // 1
p.then(console.log); // 1
p.then(console.log); // 1

每个 then 都要执行,所以要维护一个回调数组:

class MyPromise {
  constructor(executor) {
    this.callbacks = [];
    
    const resolve = value => {
      this.value = value;
      this.callbacks.forEach(cb => cb(value));
    };
    
    executor(resolve);
  }
  
  then(onFulfilled) {
    this.callbacks.push(onFulfilled);
  }
}

3. 错误穿透

Promise.reject('error')
  .then(val => val + 1)
  .then(val => val + 2)
  .catch(err => console.log(err)); // error

错误会沿着链传递,直到遇到 catch。

then(onFulfilled, onRejected) {
  return new Promise((resolve, reject) => {
    const handle = () => {
      if (this.state === 'fulfilled') {
        try {
          const result = onFulfilled(this.value);
          resolve(result);
        } catch (err) {
          reject(err);
        }
      } else if (this.state === 'rejected') {
        if (onRejected) {
          try {
            const result = onRejected(this.reason);
            resolve(result);
          } catch (err) {
            reject(err);
          }
        } else {
          // 错误穿透:没有 onRejected 就继续传递
          reject(this.reason);
        }
      }
    };
    
    if (this.state) {
      // 已 settle,异步执行
      queueMicrotask(handle);
    } else {
      // pending,加入队列
      this.callbacks.push(handle);
    }
  });
}

六、性能优化:避免 Promise 地狱

问题:Promise 创建是有开销的

// 不好:创建大量不必要的 Promise
async function processItems(items) {
  const results = [];
  for (const item of items) {
    const result = await Promise.resolve(item).then(x => x * 2);
    results.push(result);
  }
  return results;
}

// 好:直接处理
async function processItems(items) {
  return items.map(item => item * 2);
}

问题:微任务队列堆积

// 这段代码会导致微任务队列堆积,阻塞渲染
async function bad() {
  while (true) {
    await Promise.resolve();
    // 这个循环会永远执行,UI 会卡死
  }
}

微任务不会让出执行权给渲染,所以长时间运行的微任务会让页面卡顿。

解决方案:偶尔让出控制权

async function good() {
  while (true) {
    await new Promise(resolve => setTimeout(resolve, 0));
    // 让出控制权,让浏览器有机会渲染
  }
}

setTimeout(0) 会创建宏任务,每次宏任务之间浏览器有机会渲染。

七、冷门但重要的知识点

1. Promise 的构造函数是同步执行的

const p = new Promise(resolve => {
  console.log('executor');
  resolve(1);
});

console.log('after new');

// 输出:executor → after new

Promise 构造函数里的代码是同步执行的,只有 then 回调是异步的。

2. unhandledrejection 事件

Promise.reject('error');

window.addEventListener('unhandledrejection', event => {
  console.log('未处理的 rejection:', event.reason);
});

Promise 被 reject 但没有 catch,会触发这个事件。

Node.js 类似:

process.on('unhandledRejection', (reason, promise) => {
  console.log('未处理的 rejection:', reason);
});

3. Promise.finally 的特殊行为

Promise.resolve(1)
  .finally(() => {
    console.log('finally');
    return 2; // 返回值被忽略
  })
  .then(console.log); // 1,不是 2

finally 不改变传递的值,只执行副作用。

但如果 finally 返回 rejected Promise:

Promise.resolve(1)
  .finally(() => {
    return Promise.reject('error');
  })
  .then(
    val => console.log(val),
    err => console.log(err) // error
  );

4. async 函数的隐式 try-catch

async function foo() {
  throw new Error('fail');
}

foo();
// 错误被包装成 rejected Promise,不会抛到全局

等价于:

function foo() {
  return new Promise((resolve, reject) => {
    try {
      throw new Error('fail');
    } catch (err) {
      reject(err);
    }
  });
}

八、调试异步代码的技巧

1. Chrome DevTools 的 Async Stack Trace

勾选 Console 的 "Async" 选项,可以看到异步调用栈:

async function a() {
  await b();
}

async function b() {
  await c();
}

async function c() {
  console.log('here');
  throw new Error('fail');
}

a();

不开启 Async Stack Trace,调用栈只有 c。

开启后,可以看到 a → b → c 的完整调用链。

2. Node.js 的 --async-stack-traces

node --async-stack-traces app.js

Node.js 12+ 支持,让异步错误堆栈更清晰。

总结

异步编程的难点不在 API,而在于:

  1. 理解底层机制 — V8 如何编译 async/await,事件循环如何调度
  2. 知道边界情况 — Node.js 和浏览器的差异,微任务堆积问题
  3. 能写出正确实现 — Promise 的 resolve procedure,then 的链式调用

面试时,面试官问你"async/await 怎么实现的",不是让你背答案,而是看你是否真的理解原理。


参考资料:

从 URL 输入到页面展示:一场跨越进程与协议的“装修”大戏

2026年3月6日 23:37

摘要:春招季将至,“从 URL 输入到页面展示”是前端与后端面试中出场率高达 80% 的“八股文”之王。很多候选人习惯堆砌知识点,却难以串联成线。本文将摒弃枯燥的列表式回答,以“装修房子”为喻,结合浏览器多进程架构、操作系统原理、网络协议栈及 DNS 解析机制,为你构建一套清晰、深刻且通俗易懂的知识体系。这不仅是一次面试通关指南,更是一次对计算机底层逻辑的深度巡礼。


引言:不仅仅是“回车”那么简单

当你在浏览器地址栏输入 www.geekbang.org 并按下回车键的那一刻,看似平静的操作背后,实则上演着一场横跨应用层、网络层、传输层乃至操作系统内核的宏大交响乐。

在面试中,如果你只回答“DNS 解析 -> TCP 握手 -> 发送请求 -> 渲染”,考官可能会觉得你只是背了书。真正的高手,能够像项目经理一样,清晰地描述出浏览器主进程如何调度、网络进程如何采购、渲染进程如何在沙箱中施工,以及底层操作系统如何分配资源。

今天,我们就把整个页面加载过程比作一次**“装修房子”**,带你深入这场技术大戏的幕后。


第一幕:项目经理接单(浏览器主进程)

在现代浏览器(如 Chrome)的多进程架构中,浏览器主进程(Browser Process) 扮演着“项目经理”的角色。它不直接干活(不渲染页面,不下载数据),但它负责指挥、调度、验收以及处理用户交互。

1.1 接收指令与导航启动

当你输入 URL 时,主进程首先介入:

  • URL 补全与预处理:如果你只输入了关键词,主进程会将其交给默认搜索引擎;如果输入的是域名,它会尝试补全 http://https://
  • 历史管理:主进程会将此次导航记录压入“后退栈”(Backward Stack),并清空“前进栈”(Forward Stack)。这就是为什么刷新后无法“前进”的原因。
  • 状态反馈:界面立刻显示 Loading 图标,告知用户“工程已启动”。

1.2 安全拦截:beforeunload

在正式动工前,主进程会检查当前页面是否有未保存的数据。它会通知旧的渲染进程触发 beforeunload 事件。如果页面返回了拦截信号,浏览器会弹出原生确认框:“您确定要离开吗?未保存的修改可能会丢失。”这是防止用户误操作导致数据丢失的最后一道防线。

一旦确认无误,主进程正式将 URL 转发给网络进程,准备开始“采购材料”。


第二幕:采购员出动与地址查询(网络进程 & DNS)

网络进程(Network Process) 是浏览器的“采购员 + 物流司机”。它的核心任务是搞定网络连接,把服务器上的资源(HTML、CSS、图片等)拉取回来。但在此之前,它必须知道“仓库”在哪里。

2.1 DNS 解析:分布式的全球电话簿

计算机之间通信靠的是 IP 地址,而不是人类可读的域名。因此,第一步是将域名转换为 IP。DNS(Domain Name System)是一个巨大的分布式数据库。

解析过程遵循“就近原则”,层层递进:

  1. 浏览器缓存:Chrome 内部有独立的 DNS 缓存(可通过 chrome://net-internals/#dns 查看)。这是最快的路径。

  2. 操作系统缓存:如果浏览器没找到,会查询操作系统的 DNS 缓存。这里涉及一个特殊的文件——Hosts 文件(Windows 位于 C:\Windows\System32\drivers\etc\hosts)。开发者常在此配置本地域名映射(如 127.0.0.1 www.douyin.com)进行本地测试。

    • 面试题深挖:为什么修改 Hosts 文件后有时不生效?因为浏览器有自己的缓存机制,甚至可能复用了之前的 TCP 长连接(Keep-Alive)。此时需清除浏览器 DNS 缓存或重启浏览器。
  3. 本地 DNS 服务器(LDNS) :通常由 ISP(如抚州电信)提供。

  4. 根域名服务器与顶级域名服务器:如果 LDNS 也没有,请求会逐级向上,经过根服务器(.)、顶级域服务器(.org),最终找到权威域名服务器,拿到目标 IP。

负载均衡的奥秘
DNS 返回的往往不是一个 IP,而是一组 IP 数组。这背后是负载均衡技术在起作用。就像“媒婆”介绍对象,DNS 会根据你的地域(地域特性机房)、服务器负载情况(轮询算法 Round Robin),将你引导至离你最近、压力最小的服务器集群(Nginx 反向代理)。

2.2 建立连接:三次握手

拿到 IP 后,网络进程需要与服务器建立可靠的传输通道。这就用到了 TCP 协议

  • 为什么是 TCP? 网页内容要求完整无误,不能像视频流(UDP)那样允许丢包。TCP 提供了可靠性保证。

  • 三次握手

    1. 客户端发送 SYN:我想和你聊天。
    2. 服务器回复 SYN + ACK:好的,我也想和你聊,我准备好了。
    3. 客户端回复 ACK:收到,那我们开始吧。

    这三次握手确保了双方都具备发送和接收能力,并同步了初始序列号,为后续数据传输打下基础。

2.3 发送请求与接收响应

连接建立后,网络进程发送 HTTP 请求:

  • 请求行GET /index.html HTTP/1.1
  • 请求头:携带 Cookie(会话信息)、Authorization(JWT 令牌)、User-Agent 等关键信息。

服务器处理后返回响应:

  • 状态码

    • 200 OK:成功。
    • 301/302:重定向。例如访问 http://time.geekbang.org 会被强制跳转到 https:// 版本。
    • 404:资源未找到。
    • 500:服务器内部错误。
  • Content-Type:告诉浏览器接下来收到的数据是什么。如果是 text/html,浏览器就知道要准备渲染了;如果是 image/jpeg,则直接下载展示。


第三幕:沙箱中的施工队(渲染进程)

当网络进程拿到 HTML 数据流后,它不能直接渲染,而是通过 IPC(进程间通信) 将数据交给渲染进程(Renderer Process)

3.1 为什么要用沙箱?

渲染进程是浏览器的“施工队”,负责画图、砌墙(解析 DOM/CSS)、刷漆(合成图层)。但它运行在**安全沙箱(Sandbox)**中。

  • 最小权限原则:沙箱不是操作系统送的,而是浏览器利用 OS 底层机制(Windows Token、Linux Seccomp-BPF、macOS Seatbelt)主动构建的“牢房”。
  • 限制:渲染进程不能直接读写磁盘、不能直接访问网络、不能调用敏感系统 API。
  • 意义:即使渲染进程加载了恶意代码被黑客攻破,黑客也仅仅控制了“牢房”里的内容,无法窃取用户硬盘数据或控制系统。所有的网络请求和文件读写,都必须通过 IPC 请求主进程或网络进程代劳。

3.2 提交文档与解析

  1. 提交文档:渲染进程向主进程发送“确认提交”消息。主进程收到后,移除旧文档,更新 UI 状态。
  2. 构建 DOM 树:渲染进程接收 HTML 字节流,将其解析为 DOM 树(Document Object Model)。这是页面的骨架。
  3. 构建 CSSOM 树:同时,解析 CSS 文件,生成 CSSOM 树(CSS Object Model)。这是页面的样式规则。
  4. 生成渲染树(Render Tree) :将 DOM 和 CSSOM 合并,剔除不可见节点(如 display: none),形成渲染树。
  5. 布局(Layout) :计算每个节点在屏幕上的确切位置和大小。
  6. 绘制(Paint) :将渲染树转换为像素,生成位图。
  7. 合成(Composite) :如果有多个图层(如视频、固定定位元素),GPU 会将它们合成为最终的图像展示给用户。

在这个过程中,如果遇到 <script> 标签,解析可能会暂停(除非标记为 asyncdefer),去加载并执行 JavaScript。JS 可以修改 DOM 和 CSSOM,导致重新布局(Reflow)和重绘(Repaint)。


第四幕:底层基石与协议深析

在上述流程中,有几个核心的计算机基础概念支撑着整个大厦。

4.1 操作系统:进程与线程

  • 进程(Process) :资源分配的最小单元。浏览器的每个标签页通常对应一个独立的渲染进程,互不干扰。一个标签页崩溃不会影响其他标签页。
  • 线程(Thread) :CPU 调度的最小单元。一个进程内包含多个线程,如主线程(负责 JS 执行、DOM 操作)、合成线程(负责图层合成)、网络线程等。
  • 进程间通信(IPC) :由于进程隔离,主进程、网络进程、渲染进程之间必须通过 IPC 传递消息。这是多进程架构的开销所在,也是安全性的保障。

4.2 OSI 七层模型与 TCP/IP

虽然实际应用中常用 TCP/IP 四层模型,但理解 OSI 七层有助于厘清职责:

  1. 物理层:比特流传输(光纤、网线)。

  2. 数据链路层:MAC 地址寻址,帧传输。

  3. 网络层:IP 地址寻址,路由选择(路由器工作在此层)。

  4. 传输层:TCP/UDP 协议,端到端连接,流量控制,差错重传。

    • 丢包重传:TCP 通过序号和确认应答机制,确保数据包丢失后能重发,保证文件不损坏。
  5. 会话层:管理会话(如保持登录状态)。

  6. 表示层:数据格式转换(加密、压缩)。

  7. 应用层:HTTP、DNS 等协议,直接面向用户。

4.3 正向代理 vs 反向代理

  • 正向代理(代购) :客户端主动配置代理,代表客户端去访问服务器。服务器不知道真实客户端是谁,只知道代理。场景:翻墙、突破内网限制。
  • 反向代理(前台) :服务端部署代理,代表服务器接收请求。客户端不知道真实服务器是谁,只知道代理。场景:负载均衡、隐藏后端架构、SSL 卸载。Nginx 是最典型的反向代理服务器。

结语:从知识点到知识体系

回顾整个过程,从用户在地址栏敲下第一个字符,到页面绚丽地展现在眼前:

  1. 浏览器主进程像项目经理一样统筹全局,管理历史、处理交互、调度子进程。
  2. 网络进程像精明的采购员,通过复杂的 DNS 层级找到目标,利用 TCP 三次握手建立可靠通道,并通过负载均衡策略获取最优资源。
  3. 渲染进程像被关在沙箱中的专业施工队,在严格的安全限制下,将 HTML/CSS 代码一步步转化为像素图像。
  4. 底层的操作系统提供了进程隔离、线程调度和 IPC 机制,保障了系统的稳定与安全。
  5. 网络协议栈则像精密的交通规则,确保数据包在全球网络中准确、有序地抵达。

在春招面试中,当你能够用这样一条清晰的逻辑线,配合生动的比喻,将操作系统、计算机网络、浏览器原理串联起来时,你就不再是一个只会背诵“八股文”的考生,而是一个具备系统观的工程师。

记住,技术不仅仅是知识点的堆砌,更是万物互联的逻辑之美。 祝各位在春招中旗开得胜,Offer 多多!


作者注:本文基于 Chromium 架构及通用网络原理编写。实际浏览器实现可能因版本不同略有差异,但核心思想一致。希望这篇文章能成为你面试路上的坚实护城河。

从 0 手写 Promise:拆解 Promise 链式调用的实现原理

作者 龙猫不热
2026年3月6日 18:38

手写promise思路

1. promise本质

本质promise就是一个状态机 + 回调队列 + 链式调用规则

核心就3件事:

  1. 状态管理
  2. 回调存储执行
  3. then 链式调用

2. 第一步: 实现 Promise状态机

promise有三种状态

pending   初始状态
fulfilled 成功
rejected  失败

状态转换规则:

pending -> fulfilled
pending -> rejected

注意:

状态一旦改变就不能再变

所以需要:

this.status = "pending"
this.value = undefined
this.reason = undefined

3. 第二步: 实现resolve / reject

Promise 构造函数会接受一个 executor

new Promise((resolve,reject)=>{})

这个函数:

  • 立即执行
  • 会收到resolvereject

实现逻辑:

// value: resolve的值
// reason: reject的值, 失败原因

const resolve = (value)=>{
  if(this.status !== "pending") return
  this.status = "fulfilled"
  this.value = value
}

const reject = (reason)=>{
  if(this.status !== "pending") return
  this.status = "rejected"
  this.reason = reason
}

注意两点:

  1. 状态只能改一次
  2. 保留value / reason

4. 第三步: 实现then (核心)

Promise必须支持:

promise.then(onFulfilled, onRejected)

then 有三个行为:


4.1 情况1: Promise 已经fulfilled

立即执行 onFulfilled

但注意:

必须放到微任务

queueMicrotask(()=>{
  onFulfilled(this.value)
})

4.2 情况2: Promise 已经rejected

执行onRejected

queueMicrotask(()=>{
  onRejected(this.reason)
})

4.3 情况3: Promise 还在 pending

这时候问题来了:

resolve 可能未来才执行

所以: 要把回调存起来

this.onFulfilledCallbacks = []
this.onRejectedCallbacks = []

then 里:

// 保证回调可以正常使用
onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (v) => v;
onRejected =
    typeof onRejected === "function"
    ? onRejected
: (r) => {
    throw r;
};


this.onFulfilledCallbacks.push(() => {
    queueMicrotask(() => {
        onFulfilled(this.value)
    });
});

this.onRejectedCallbacks.push(() => {
    queueMicrotask(() => {
        onFulfilled(this.value)
    });
});

等到 resolve / rejected时

this.onFulfilledCallbacks.forEach(fn=>fn());
或
this.onRejectedCallbacks.forEach(fn=>fn());

5. 第四步: then必须返回新的Promise

规范规定:

then 一定要返回一个新的 Promise
const promise2 = new MyPromise(...)
return promise2

因为Promise需要支持链式调用

promise
  .then()
  .then()
  .then()

6. 第五步: then 返回值决定下一个Promise

最难的部分

const x = onFulfilled(this.value)

然后:

promise2 的状态 = x 决定

规则

6.1 情况1: x是普通值

resolve(x)

例:

then(()=>100)

6.2 情况2: x是 promise

then(()=>Promise)

那就:

promise 跟随这个 Promise最后的执行状态

例:

then(()=>new Promise(...))

6.3 情况3: x是 thenable

thenable:

const obj = { then: function(){} };
// 或
function fn(){
    // ....
}

fn.prototype.then = function(){
    // ....
}

也要按照 Promise处理


7. 第六步:reslovePromise 算法

所以需要写一个 统一解析函数

resolvePromise(promise2,x,resolve,reject)

作用:

解析x的类型

步骤:

7.1 防止循环引用

if(promise2 === x){
 reject(new TypeError("循环引用"))
}

例:

p.then(() => p) // 会死循环

7.2 如果 x 是对象或函数

typeof x === 'object' || typeof x === 'function'

说明可能是 thenable。


7.3 取 then

then = x.then

7.4 如果then是函数

当做Promise处理:

then.call(x, resolve, reject)

使用call的原因是防止里面有this调用

const obj = {
    value: 111,
    then(){
        console.log(this.value);
    }
}

7.5 如果then 不是函数

说明只是普通对象, 直接resolve:

resolve(x)

7.6 called锁

Promise规范规定

resolve / reject 只能调用一次

所以:

let called = false;

8. 第七步: 为什么要微任务

Promise 规范规定:

then 回调必须是异步执行的

所以必须:

queueMicrotask: 传入一个回调函数, 将回调函数中的代码加入到微任务队列中执行
// https://developer.mozilla.org/zh-CN/docs/Web/API/Window/queueMicrotask

而不是同步:

例:

Promise.resolve(1)
console.log(2)

// 2
// 1

9. 完整代码:

实现顺序

1 实现 Promise 状态
2 实现 resolve / reject
3 executor 立即执行
4 then 方法
5 then 返回新 Promise
6 回调队列
7 resolvePromise 解析返回值
8 微任务

完整结构其实只有 三块

class MyPromise
    constructor
    then

resolvePromise
class MyPromise {
  constructor(executor) {
    // 初始状态
    this.status = "pending";
    // 成功的值
    this.value = undefined;
    // 失败的原因
    this.reason = undefined;

    // 存储成功和失败的回调函数
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => {
      // 保证状态不可逆
      if (this.status !== "pending") return;
      this.status = "fulfilled";
      this.value = value;
      // 执行成功的回调函数
      this.onFulfilledCallbacks.forEach((callback) => callback());
    };

    const reject = (reason) => {
      // 保证状态不可逆
      if (this.status !== "pending") return;
      this.status = "rejected";
      this.reason = reason;
      // 执行失败的回调函数
      this.onRejectedCallbacks.forEach((callback) => callback());
    };

    // 执行 executor,并捕获异常
    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (v) => v;
    onRejected =
      typeof onRejected === "function"
        ? onRejected
        : (r) => {
            throw r;
          };

    // .then需要可以返回一个新的promise
    // 并且promise的状态是按照回调函数的结果来做的
    const promise2 = new MyPromise((resolve, reject) => {
      if (this.status === "fulfilled") {
        queueMicrotask(() => {
          try {
            const x = onFulfilled(this.value);
            resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      } else if (this.status === "rejected") {
        queueMicrotask(() => {
          try {
            const x = onRejected(this.reason);
            resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      } else if (this.status === "pending") {
        // 将回调函数保存起来,等到状态改变的时候再执行
        onFulfilled &&
          this.onFulfilledCallbacks.push(() => {
            queueMicrotask(() => {
              try {
                const x = onFulfilled(this.value);
                resolvePromise(promise2, x, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });
          });
        onRejected &&
          this.onRejectedCallbacks.push(() => {
            queueMicrotask(() => {
              try {
                const x = onRejected(this.reason);
                resolvePromise(promise2, x, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });
          });
      }
    });
    return promise2;
  }

  /**
   * 1. 首先需要判断x是否和promise2相等, 如果相等将会造成循环引用, 需要reject出去一个error
   * 2. 如果 x 不是对象 或 函数, 则说明是普通值, resolve出去即可
   * 3. 如果是对象/函数, 需要看属性/原型上是否有 `then` 函数, 只要有就当成 promise 来处理
   * 4. 如果是对象/函数, 但没有`then`函数 或 `then`不是函数, 则直接resolve出去即可
   *
   * @param {*} promise2 将要返回的promise实例
   * @param {*} x 回调函数的返回值
   * @param {*} resolve
   * @param {*} reject
   * @returns
   */
}

function resolvePromise(promise2, x, resolve, reject) {
  if (x == promise2) {
    reject(new TypeError("Chaining cycle detected for promise"));
    return;
  }

  // 因为null也是object, 所以组合判断下
  if ((typeof x === "object" && x !== null) || typeof x == "function") {
    // 到这里说明是对象/函数

    let then;
    // Promise 只能 resolve 或 reject 一次, 做个锁
    let called = false;
    // 获取 then放到 try...catch中, 防止找不到then属性报错
    try {
      then = x.then;

      // 如果是个函数, 调用它
      // called做锁, 避免多次调用resolve 或 reject
      // 并且递归调用resolvePromise, 处理then返回的值
      if (typeof then === "function") {
        then.call(
          x,
          (y) => {
            if (called) return;
            called = true;
            resolvePromise(promise2, y, resolve, reject);
          },
          (r) => {
            // reject就不需要再递归调用了
            if (called) return;
            called = true;
            reject(r);
          },
        );
      } else {
        // then不是函数就直接 resolve出去
        resolve(x);
      }
    } catch (error) {
      // 这边也要判断一下,如果called已经被调用过了, 就不再调用
      if (called) return;
      called = true;
      reject(error);
    }
  } else {
    resolve(x);
  }
}
昨天 — 2026年3月6日首页

深度解构JavaScript:作用域链与闭包的内存全景图

作者 Lee川
2026年3月6日 16:32

深度解构JavaScript:作用域链与闭包的内存全景图

引言:看见不可见的执行世界

JavaScript 常常被误解为一门简单的脚本语言,但在其看似随性的语法背后,隐藏着一套严谨而精密的执行机制。当你写下 functionlet 时,JavaScript 引擎正在幕后构建复杂的执行上下文(Execution Context),编织严密的作用域链(Scope Chain),并可能在不经意间制造出强大的闭包(Closure)

很多开发者在面对“变量为什么找不到”、“闭包为什么内存泄漏”或者“this 指向为何诡异”等问题时感到困惑,根本原因在于缺乏对这套底层机制的直观认知。

本文将摒弃枯燥的定义堆砌,结合核心的代码案例与可视化的内存模型图,带您像调试器一样“透视”JavaScript 的运行过程。我们将通过七张关键的原理图,层层剥开作用域与闭包的神秘面纱。


第一章:执行的基石——执行上下文模型

1.1 代码运行的“容器”

在 JavaScript 中,任何代码的执行都发生在执行上下文中。你可以把它想象成一个容器,里面装着代码运行所需的所有信息。这个容器并非铁板一块,而是被精细地划分为两个核心区域:

  1. 变量环境(Variable Environment):主要存储由 var 声明的变量和函数声明。
  2. 词法环境(Lexical Environment):主要存储由 letconst 声明的变量以及代码块级作用域信息。

此外,每个上下文还持有一个指向外部环境的引用(Outer),这是形成作用域链的关键。

325d94b0befca7bc834520d10ad7a1d9.jpg

图解 1:如上图所示,一个标准的执行上下文(如 setName 函数)内部清晰地分为了“变量环境”和“词法环境”。注意右侧红色的 foo(closure),它暗示了内部函数可能形成的闭包,保留了对外部变量的引用。这是理解后续所有复杂逻辑的基石。

1.2 全局上下文的初始化

当脚本加载时,首先建立的是全局执行上下文。此时,全局变量被登记在册,而 outer 指针指向 null,因为它处于作用域链的顶端。


第二章:作用的层级——词法作用域链

2.1 嵌套的世界

JavaScript 采用词法作用域,这意味着函数的作用域在代码**编写(定义)**时就已经确定,而非运行时。当函数嵌套时,就形成了作用域链。

让我们看一个经典的嵌套模型:

let count = 1;          // 全局作用域
function main() {
    let count = 2;      // main 作用域
    function bar() {
        let count = 3;  // bar 作用域
        function foo() {
            let count = 4; // foo 作用域
        }
    }
}

在这个结构中,foo 可以访问 barmain 甚至全局的 count,但查找顺序是严格的“由内向外”。

cf22f379419ba33500ddeedda82f29ca.jpg

图解 2:这张图生动地展示了作用域的嵌套关系。下方的箭头链条(词法作用域链)清晰地表明:foo 的作用域指向 barbar 指向 main,最终指向全局。无论函数在哪里被调用,这条链在定义时就已经固化。


第三章:实战深潜——调用栈与变量查找迷雾

理论总是清晰的,但现实代码往往充满了陷阱。让我们进入一个复杂的实战场景,看看引擎如何在调用栈中处理变量遮蔽(Shadowing)和作用域查找。

3.1 复杂的变量查找案例

请仔细阅读以下代码,尝试判断 console.log(test) 的输出结果:

function foo() {
    var myName = "极客邦";
    let test = 2;
    {
        let test = 3; // 块级作用域遮蔽
        bar();        // 在这里调用 bar
    }
}

function bar() {
    var myName = "极客世界";
    let test1 = 100;
    if (1) {
        let myName = "Chrome浏览器";
        console.log(test); // 问题核心:test 是多少?
    }
}

var myName = "极客时间";
let test = 1; // 全局 test
foo();

直觉误区:很多人认为 bar 是在 foo 内部调用的,所以应该能访问 foo 里的 test(值是 2 或 3)。 真相:输出结果是 1

为什么?因为 bar 函数是在全局作用域定义的。根据词法作用域规则,bar 的作用域链直接指向全局,它与 foo 的执行上下文毫无关系,哪怕它是被 foo 调用的。

5ac9a8e8ca249b0d0bb1a948a2d697aa.jpg

图解 3:这张图是理解本案例的“钥匙”。

  • 左侧展示了当前的调用栈:顶层是 bar,中间是 foo,底部是全局。
  • 请注意红色的虚线箭头(作用域链指向):barouter 指针直接跳过了 foo,指向了全局执行上下文(标记⑤)。
  • 因此,当 bar 查找 test 时,它在自身环境和全局环境中找到了 test=1(标记④),而完全无视了 foo 环境中的 test=2test=3

3.2 常见的认知陷阱

为了进一步巩固这个概念,我们看一个更简化的例子,这也是面试题中的常客:

var myName = "极客时间";

function foo() {
    var myName = "极客邦";
    bar(); 
}

function bar() {
    console.log(myName); // 这里打印什么?
}

foo();

d0fb219c234722b2498d69dbd3ef0bf9.jpg

图解 4:图中的气泡提出了灵魂拷问:“myName 的值应该使用全局执行上下文的,还是使用 foo 函数执行上下文的?” 答案显而易见:全局。因为 bar 定义在全局,它的作用域链只连接全局。调用栈的压入(foo 调用 bar)不会改变 bar 的作用域链指向。


第四章:闭包的魔力——留住时间的变量

4.1 什么是闭包?

当函数返回后,通常其执行上下文会被销毁,局部变量随之消失。但是,如果返回的函数引用了外部函数的变量,JavaScript 引擎就会“网开一面”,将这些变量保留在内存中。这就是闭包

4.2 闭包的内存驻留

看这段代码:

function setName() {
    var myName = "极客时间";
    let test1 = 1;
    
    function foo() {
        console.log(myName);
    }
    
    return foo; // 返回内部函数
}

var closureFunc = setName(); // setName 执行完毕
closureFunc(); // 依然能访问 myName

setName 执行结束后,按理说它的上下文应该出栈。但因为 foo 被返回并赋值给了 closureFunc,且 foo 依赖 myName,引擎必须保留 setName 的变量环境。

5f7c408f09b3634f02407b8eba774e13.jpg

图解 5:注意看图中,调用栈(Call Stack)中已经没有了 setName 的身影。但是,一个标记为 foo(closure) 的对象独立存在于内存中,它紧紧抱着 myName = "极客时间"test1 = 1。这就是闭包的本质:函数与其词法环境的组合

4.3 综合场景:对象方法与闭包

闭包常用于创建私有变量或对象方法。考虑以下场景:

function foo() {
    var myName = "极客时间";
    let test1 = 1;
    let test2 = 2;
    
    // 返回一个包含方法的对象
    return {
        innerBar: function() {
            console.log(myName);
        }
    };
}

var obj = foo();
obj.innerBar(); // 输出 "极客时间"

016cde03c3179056885990fc5682083b.jpg

图解 6:这张图展示了 foo 函数执行上下文的细节,变量环境中不仅有基本类型,还有函数对象 innerBar。当 foo 返回后,这些变量并没有立即消失,而是成为了闭包的一部分。


第五章:终极视角——指针的指向艺术

最后,我们需要从宏观视角审视整个内存模型。无论是普通函数调用,还是闭包,核心都在于那个看不见的 outer 指针。

  • 如果函数在全局定义,outer 指向全局上下文。
  • 如果函数在另一个函数内定义,outer 指向外部函数的上下文。
  • 无论函数在哪里被调用,outer 指针在函数创建那一刻就已定格。

6452bdc165bf3f0a043e0bbdc74746c1.jpg

图解 7:这张图用红色虚线明确标注了“指向全局执行上下文”。我们可以看到,barfoo 虽然可能在不同的调用栈层级,但它们各自的 outer 指针都诚实地指向了它们定义时所在的环境。这解释了为什么作用域链不会被动态的调用栈所迷惑。


结语:从“知其然”到“知其所以然”

通过这七张图谱的深度解析,我们重新梳理了 JavaScript 的核心机制:

  1. 执行上下文是舞台,区分了 varlet/const 的存放位置。
  2. 作用域链是导航图,它在代码定义时生成,决定了变量查找的路径,与调用位置无关。
  3. 闭包是时光机,它让函数能够跨越生命周期,继续访问定义时的环境变量。

理解这些,你就不再是在盲目地试错代码,而是在脑海中构建出了一幅清晰的内存地图。当下一次遇到作用域问题或闭包陷阱时,请在脑中画出那张“调用栈”与“红色虚线箭头”的图,答案自会浮现。

🚀《JavaScript 灵魂深处:从 V8 引擎的“双轨并行”看执行上下文的演进之路》

作者 Lee川
2026年3月6日 11:30

引言

“如果你只懂 varlet 的语法区别,那你只看到了冰山一角。真正的魔法,藏在 V8 引擎执行上下文的双轨存储架构里。”

在 JavaScript 的发展历程中,有一个著名的“历史遗留问题”——变量提升(Hoisting)。它曾让无数开发者抓狂,也让 JS 背上了“设计缺陷”的骂名。然而,随着 ES6 的诞生,JavaScript 通过一种巧妙的**“双轨并行”策略**,不仅完美兼容了旧代码,还引入了现代化的块级作用域。

今天,我们将结合您提供的完整文档(readme.md8.js),深入 V8 引擎的底层机制,剖析执行上下文、作用域链、变量环境 vs 词法环境的奥秘。特别是针对 7.js 中的经典案例,我们将借助两张精美的示意图,为您揭开 JavaScript 变量管理的终极真相。


📜 第一章:历史的回响——为什么 JavaScript 会有“变量提升”?

1.1 一个“KPI 项目”的意外走红

正如 readme.md 中所言,JavaScript 最初只是 Netscape 为了浏览器竞争而快速推出的“KPI 项目”。设计周期极短,目标简单:给静态页面加点动态效果

在那个年代,复杂的面向对象特性(如 class, constructor, private 等)并不是首要任务。为了追求最快、最简单的实现方案,设计师做出了两个关键决定:

  1. 不支持块级作用域if, for, while 等代码块 {} 内部声明的变量,直接暴露在外层。
  2. 引入变量提升:将所有变量声明统一“抬升”到函数顶部,简化编译器的实现逻辑。

1.2 变量提升的“双刃剑”

让我们看看 4.js 中的经典案例:

showName();
console.log(myname);
var myname = "张三";
function showName() {
    console.log("函数 showName 执行了");
}

这段代码之所以能运行(不报错),是因为在编译阶段,JS 引擎做了如下处理:

// 编译后的伪代码
function showName() { ... } // 函数声明提升
var myname;                 // 变量声明提升,初始化为 undefined

showName();                 // 输出:函数 showName 执行了
console.log(myname);        // 输出:undefined (因为赋值语句还没执行)
myname = "张三";            // 执行赋值

⚠️ 缺陷暴露

  • 变量容易被意外覆盖(见 2.js 中的 var name 遮蔽全局变量)。
  • 本应销毁的变量因提升而长期驻留内存。
  • 代码行为与直觉不符,增加调试难度。

🌍 第二章:ES6 的救赎——“双轨并行”的巧妙设计

面对历史包袱,ES6 没有选择“推倒重来”(那样会破坏海量旧代码),而是采取了一种兼容性极强的解决方案:在执行上下文中实行“双轨并行”存储机制

2.1 执行上下文的双核架构

当 JavaScript 引擎执行一个函数时,会创建一个执行上下文(Execution Context)。在 ES6 及以后,这个上下文被划分为两个独立但协同工作的区域:

轨道 名称 管理对象 特性 对应关键字
轨道一:变量环境 (Variable Environment) 传统轨道 var 声明的变量 函数作用域、变量提升、可重复声明 var
轨道二:词法环境 (Lexical Environment) 现代轨道 let, const 声明的变量 块级作用域、暂时性死区 (TDZ)、不可重复声明 let, const

💡 核心思想

  • var 继续留在变量环境轨道,享受“提升特权”,保证旧代码正常运行。
  • let/const 进入全新的词法环境轨道,支持块级作用域,杜绝提升带来的隐患。
  • 两条轨道在同一个执行上下文中并行存在,互不干扰却又协同工作。

2.2 词法环境的“栈结构”秘密

readme.md 中提到:“块级作用域中通过 let/const 声明的变量,会被放在词法环境的一个单独的区域中,维护了一个小型栈结构。

这意味着:

  • 每进入一个块级作用域 {},引擎就在词法环境中压入一个新的“帧”(Frame)。
  • 变量查找时,优先从栈顶(当前块)开始。
  • 块执行完毕,该帧弹出,内部变量立即销毁,外界无法访问。

这正是 6.jsfor(let i=0;...) 循环后 i 未定义的原因,也是 8.js 中“暂时性死区”产生的根源。


🔍 第三章:实战演练——从 1.js8.js 的全景解析

现在,让我们遍历所有文件,逐一验证上述理论。

🧪 案例 1:作用域链的基础(1.js & 5.js

// 1.js
let name = "流萤";
function showName(){
    console.log(name); // 流萤
    if(true){
        let name = "大厂的苗子" // 块级变量,不影响外层
    }
}
showName();

// 5.js
var globalVar='我是全局变量';
function myFunction() {
    var localVar = '我是局部变量';
    console.log(globalVar); // 可访问
    console.log(localVar);  // 可访问
}
myFunction();
console.log(localVar); // ❌ ReferenceError: localVar is not defined

解析

  • 1.js 展示了 let 的块级隔离性:块内 name 不影响块外。
  • 5.js 展示了函数作用域的边界:localVar 仅在函数内有效。

🧪 案例 2:变量提升的陷阱(2.js & 4.js

// 2.js
var name = '张三';
function showName() {
    console.log(name); // undefined (局部变量遮蔽全局)
    if(false) {
        var name = '李四'; // 声明提升,赋值不执行
    }
    console.log(name); // undefined
}
showName();

解析

  • var name 在函数内被提升,导致全局 name 被遮蔽。
  • 即使 if(false) 不执行,name 仍存在于局部作用域,值为 undefined

🧪 案例 3:块级作用域的胜利(6.js & 8.js

// 6.js
function foo() {
    for(let i=0;i<7;i++) { }
    console.log(i); // ❌ ReferenceError: i is not defined
}
foo();

// 8.js
let name = '流萤';
{
    console.log(name); // ✅ 输出 "流萤" (访问外层)
    let othername = '大厂的苗子';
}
// 若取消注释下方代码,将触发 TDZ
// {
//     console.log(name); // ❌ ReferenceError
//     let name = '大厂的苗子';
// }

解析

  • 6.js 证明 let 循环变量仅限块内。
  • 8.js 展示两种情况:
    • 块内无同名 let → 访问外层变量。
    • 块内有同名 let → 触发暂时性死区 (TDZ),禁止在声明前访问。

🖼️ 第四章:深度图解——7.js 与执行上下文的视觉化

现在,我们来到本文的高潮部分:7.js 的代码与您提供的两张示意图。这两张图完美诠释了“双轨并行”机制在实际运行中的状态变化。

📄 代码回顾

function foo() {
    var a = 1;
    let b = 2;
    {
        let b = 3;
        var c = 4;
        let d = 5;
        console.log(a); // 1
        console.log(b); // 3
    }
    console.log(b); // 2
    console.log(c); // 4
    console.log(d); // ❌ ReferenceError
}
foo();

🖼️ 图一:函数初始化状态(预编译阶段)

image.png

此时,函数刚被调用,引擎完成“预编译”,双轨开始运作:

  • 左轨:变量环境

    • a = 1var a 已声明并赋值。
    • c = undefinedvar c 被提升到函数顶(变量环境顶层),但尚未赋值。
  • 右轨:词法环境

    • 外层帧:b = 2let b 已初始化。
    • 内层帧(块级):b = undefined, d = undefined ← 已绑定但未初始化(处于 TDZ)。

📌 关键点var c 虽在块内代码中书写,却出现在变量环境的顶层;而 let b/d 则严格限制在词法环境的块级帧中。这就是双轨并行的直观体现。

🖼️ 图二:执行到块内 console.log 时的状态

image.png

程序执行流进入块内,并完成赋值操作,双轨状态发生动态变化:

  • 左轨:变量环境

    • a = 1 ← 保持不变。
    • c = 4var c = 4 已执行,赋值成功!注意它依然位于函数级的变量环境中。
  • 右轨:词法环境

    • 外层帧:b = 2 ← 保留,暂时被遮蔽。
    • 内层帧(当前激活):
      • b = 3 ← 块内 let b = 3 已赋值,遮蔽了外层帧的 b
      • d = 5 ← 已赋值。

🔄 查找规则(双轨协同)

  • console.log(a) → 引擎查询变量环境 → 找到 1
  • console.log(b) → 引擎查询词法环境,从栈顶(内层帧)开始 → 找到 3(忽略外层 b=2)。

🎬 完整执行流程表

步骤 代码 输出/结果 原因分析
1 console.log(a) 1 访问变量环境中的 a
2 console.log(b) 3 访问词法环境栈顶的 b(块内遮蔽外层)
3 块结束 块级词法环境帧弹出,b=3, d=5 销毁
4 console.log(b) 2 恢复访问词法环境外层的 b
5 console.log(c) 4 访问变量环境中的 c(函数级有效)
6 console.log(d) ❌ Error d 位于已销毁的块级词法环境帧中,外界不可见

🛠️ 第五章:开发者指南——如何驾驭这套机制?

✅ 最佳实践

  1. 优先使用 letconst:利用词法环境轨道的块级特性,避免 var 的提升和函数作用域陷阱。
  2. 明确作用域边界:用 {} 包裹逻辑块,防止变量泄露到不必要的范围。
  3. 警惕 TDZ:不要在 let/const 声明前访问变量,理解这是词法环境的保护机制。
  4. 利用 DevTools 调试:观察 Scope 面板,你会清晰地看到“Variable”和“Local/Lexical”两个不同的区域。

常见误区

  • ❌ “let 也会提升” → 错!let 有“绑定提升”,但存在 TDZ,在声明前不可访问。
  • ❌ “块级作用域是新的作用域类型” → 不准确!它是词法环境中的“栈帧”,而非独立的作用域类型。
  • ❌ “var 在块内无效” → 错!var 无视块级,始终提升至变量环境的函数顶层。

🌟 结语:理解执行上下文,就是理解 JavaScript 的灵魂

readme.md 的历史回顾,到 7.js 的深度图解,我们走完了一段从“设计缺陷”到“优雅兼容”的旅程。JavaScript 通过变量环境与词法环境的“双轨并行”架构,成功实现了新旧语法的完美融合:既尊重了历史,又拥抱了未来。

下次当你写下 letvar 时,请记住:

你不仅仅是在声明一个变量,你是在指挥 V8 引擎在两条不同的轨道上存储数据。

掌握这套机制,你将不再畏惧任何作用域谜题,写出更健壮、更高效的代码。


📚 附录:核心概念速查表

概念 描述 示例
变量提升 var 声明移至函数顶 var x; x=1;
暂时性死区 (TDZ) let/const 声明前不可访问 console.log(y); let y=1; → Error
作用域链 变量查找路径:当前 → 外层 → 全局 内层 b 遮蔽外层 b
词法环境 存储 let/const,支持块级栈结构 { let a=1; }
变量环境 存储 var,函数级作用域 function(){ var b; }
双轨并行 执行上下文中同时存在变量环境和词法环境 var 走左轨,let 走右轨

🎉 恭喜! 你现在已掌握 JavaScript 执行上下文的核心精髓。无论是面试、工作还是开源贡献,这套知识都将是你最强大的武器。

Vue 3 新标准:<script setup> 核心特性、宏命令与避坑指南

作者 QLuckyStar
2026年3月6日 08:58

<script setup> 是 Vue 3.2 引入的一种编译时语法糖,旨在简化 Composition API 的使用。它并不是一个新的功能,而是对原有 <script> 中使用 Composition API 写法的一种语法优化

简单来说,它让你用更少的代码更直观的写法来实现同样的功能,同时在性能上也有显著提升。


1. 核心对比:传统写法 vs <script setup>

❌ 传统写法 (Vue 3.2 之前)

你需要手动导入 API,定义数据/方法,并显式 return 给模板使用。

<script>
import { ref, reactive } from 'vue'

export default {
  components: { MyComponent }, // 需手动注册组件
  props: ['title'],           // 需手动定义 props
  
  setup(props, { emit }) {
    const count = ref(0)
    const user = reactive({ name: 'Alice' })
    
    function increment() {
      count.value++
    }

    // ⚠️ 必须手动 return,模板才能访问
    return {
      count,
      user,
      increment,
      title // props 也要 return
    }
  }
}
</script>

✅ <script setup> 写法

无需 export default,无需 return,顶层变量自动暴露。

<script setup>
import { ref, reactive } from 'vue'
import MyComponent from './MyComponent.vue' // ✅ 自动注册组件

// ✅ 直接定义 props (编译后自动生成)
defineProps(['title'])

// ✅ 直接定义 emits
const emit = defineEmits(['change'])

// 顶层变量自动暴露给模板,无需 return
const count = ref(0)
const user = reactive({ name: 'Alice' })

function increment() {
  count.value++
  emit('change', count.value)
}
</script>

2. <script setup> 的五大核心好处

1. 代码更简洁(少写样板代码)

  • 无需 export default:组件选项直接在标签内定义。
  • 无需 return:在 <script setup> 中声明的所有顶层变量(reffunctionimport 的组件等)自动暴露给模板使用。这减少了大量的重复代码和出错可能。
  • 组件自动注册:导入的组件(如 import MyComp from ...)可以直接在模板中使用 <MyComp />,无需在 components 选项中注册。

2. 更好的 TypeScript 支持

  • 类型推导更精准:由于不需要通过 return 对象来暴露变量,TS 可以直接推断顶层变量的类型,无需复杂的泛型声明。
  • Props/Emits 类型化:配合 defineProps<Type>() 和 defineEmits<Type>(),可以获得完美的类型提示和校验,而传统写法需要繁琐的 withDefaults 或接口定义。

3. 更高的运行时性能

  • 编译优化<script setup> 的组件会被编译为一个匿名函数,作为 setup() 钩子的实现。
  • 避免代理开销:传统写法中,setup 返回的对象会被 Vue 包装成代理(Proxy)以便模板访问。而 <script setup> 中的绑定是通过闭包直接访问的,省去了创建代理对象的开销,访问速度更快。
  • Tree-shaking:未使用的代码更容易被打包工具剔除。

4. 逻辑更清晰

  • 消除“割裂感” :在传统写法中,定义的变量和模板中使用的变量之间隔着一个 return 块,阅读时需要上下跳转。<script setup> 让代码从上到下线性执行,定义即使用。
  • 专注于逻辑:开发者可以更专注于业务逻辑本身,而不是 Vue 的样板结构。

5. 原生支持宏(Macros)

提供了一些编译时宏,无需导入即可直接使用:

  • defineProps: 声明 props。
  • defineEmits: 声明 emits。
  • defineExpose: 显式暴露属性给父组件(默认情况下 <script setup> 组件实例是关闭的,即父组件无法通过 ref 访问其内部属性,除非使用此宏)。
  • defineOptions: (Vue 3.3+) 声明组件选项(如 nameinheritAttrs)。
  • withDefaults: 为 defineProps 设置默认值。

3. 特殊用法详解

A. 定义 Props 和 Emits

<script setup>
// 接收 props,具有类型推导
const props = defineProps({
  msg: String,
  count: { type: Number, required: true }
})

// 定义 emits
const emit = defineEmits(['update:count', 'submit'])

function update() {
  emit('update:count', props.count + 1)
}
</script>

B. 暴露给父组件 (defineExpose)

默认情况下,父组件通过 ref 获取子组件实例时,无法访问 <script setup> 内部的变量。如果需要暴露,必须显式声明:

<!-- Child.vue -->
<script setup>
import { ref } from 'vue'

const secret = 'hidden'
const publicData = ref(100)

function publicMethod() {
  console.log('called')
}

// 只暴露 publicData 和 publicMethod
defineExpose({
  publicData,
  publicMethod
})
</script>

C. 配合 TypeScript

<script setup lang="ts">
interface User {
  id: number
  name: string
}

// 泛型支持
const props = defineProps<{
  userId: number
  list: User[]
}>()

// 默认值
withDefaults(defineProps<{
  msg?: string
  labels?: string[]
}>(), {
  msg: 'Hello',
  labels: () => ['new'] // 对象/数组默认值需用工厂函数
})
</script>

4. 总结:为什么它是“最佳实践”?

特性 传统<script>+setup() <script setup>
代码量 多 (需 export, return, register) 极少 (声明即用)
性能 正常 (有代理开销) 更高 (闭包访问,无代理)
TS 支持 良好 (但需额外类型声明) 完美 (原生推导)
组件注册 手动 自动
推荐度 ⭐⭐ (兼容旧项目) ⭐⭐⭐⭐⭐ (新项目首选)

结论
除非你需要维护非常古老的 Vue 3 早期代码,否则在所有新的 Vue 3 项目中,都应该无条件使用 <script setup> 。它是 Vue 团队官方推荐的默认写法,代表了 Vue 未来的发展方向。

深入 React19 Diff 算法

2026年3月5日 23:58

一、为什么 React 需要 Diff 算法

早期前端如果直接操作 DOM:

div.innerHTML = newHTML

问题:

  1. DOM 操作极其昂贵
  2. 无法知道哪些节点真的变化
  3. 频繁重绘

如果使用传统树 diff 算法

复杂度:O(n^3)

浏览器根本无法接受。

因此 React 提出 对 diff 算法做了各种优化,最终复杂度 O(n)。

React 的核心约束:

约束 1:不同类型节点一定不同

<div />
<span />

直接删除重建。

约束 2:同层节点对比

React 不会跨层比较

只比较:

oldChildren
newChildren

约束 3:key 用来稳定节点

{list.map(item => (
  <Item key={item.id}/>
))}

key 让 React 知道:

这个节点是不是同一个

二、架构层面看 diff

React19 的更新流程:

setState
   ↓
scheduleUpdateOnFiber
   ↓
render阶段
   ↓
beginWork
   ↓
reconcileChildren  ← diff发生在这里
   ↓
completeWork
   ↓
commit阶段
   ↓
DOM mutation

源码位置:react-reconciler/src/ReactChildFiber.js

  • 核心函数:reconcileChildren、reconcileChildFibers
  • 创建 Fiber:createFiberFromElement
  • 复用 Fiber:useFiber
  • 处理数组:reconcileChildrenArray

React Diff 的核心逻辑:

oldFiberTree(current fiber node)
        ↓
newReactElementTree(jsx)
        ↓
生成 newFiberTree(wip fiber node)
        ↓
打 flags(等到 commit 再处理)

Fiber 结构(简化):

type Fiber = {
  tag: WorkTag
  key: null | string
  type: any

  stateNode: any

  return: Fiber
  child: Fiber
  sibling: Fiber

  pendingProps
  memoizedProps

  alternate: Fiber

  flags
}

1 alternate

current fiber 和 workInProgress fiber 形成 双缓存树

2 flags

记录需要执行的操作:Placement | Update | Deletion

commit 阶段使用。

三、最简单的 Diff:单节点

如果 DOM 更新后,还是一个节点的话,那么就采用单节点 diff。

function reconcileSingleElement(
  returnFiber,
  currentFirstChild,
  element
)

逻辑:

Step1:寻找 key 相同节点 child.key === element.key(如果都没有设置 key,那么都为 null,也属于相同)

Step2:type 是否相同 child.type === element.type

Step3:复用 Fiber useFiber(oldFiber)(如果内部文本不同,直接将内部文本节点更新即可)

否则:删除旧节点,创建新节点

四、数组 Diff

如果 DOM 更新后,为多个节点的话,就采用多节点 diff(数组 diff) 。

真正复杂的是:reconcileChildrenArray。React 团队认为,对节点更新操作的情况往往要多于对节点“新增、删除、移动”的操作。因此,源码逻辑分为 两轮遍历。

第一轮:从左到右对比

React 会先 顺序对比,目的就是希望尽可能的复用单节点。

  • 如果新旧子节点的 key 和 type 都相同,直接复用
  • 如果新旧子节点的 key 相同,但是 type 不相同,这时会根据 ReactElement 来生成一个全新的 Fiber,旧的 Fiber 被放入到 deletions 数组里面,之后统一删除。但是此时遍历并不会终止
  • 如果新旧子节点的 key 和 type 都不相同,结束遍历

旧:

A B C D

新:

A B E

流程(同时对比 key 和 type):

A = A ✔
B = B ✔
C ≠ E ✘

停止。

这一步叫:快速路径(Fast Path)

源码:

while (oldFiber && newIdx < newChildren.length)

复杂度:O(n)。

第二轮:构建 key map,遍历新 children

如果第一轮遍历被提前终止了,那么意味着有新的 React 元素或者旧的 FiberNode 没有遍历完,此时就会采用第二轮遍历。

情况一:有旧节点剩余,放入 deletions 数组中之后删掉。

情况二:有新节点出现,创建新的 fiber。

情况三:

  • 新旧子节点都有剩余:会将旧节点剩余的 FiberNode 节点放入一个 map 里面,遍历剩余的新节点,然后从 map 中去寻找能够复用的 FiberNode 节点,如果能够找到就复用。(移动的情况)
  • 如果不能找到就新增。然后如果剩余新节点都遍历完了,map 结构中还有剩余的 Fiber 节点,就将这些 Fiber 节点添加到 deletions 数组里面,之后统一做删除操作

React19 这里没有使用像 Vue3 那样的双端 diff 算法,具体原因 React 直接写在了源码内:

由于双端 diff 需要向前查找节点,但每个 FiberNode 节点上都没有反向指针,即前一个 FiberNode 通过 sibling 属性指向后一个 FiberNode,只能从前往后遍历,而不能反过来,因此该算法无法通过双端搜索来进行优化。

React 想看下现在用这种方式能走多远,如果这种方式不理想,以后再考虑实现双端 diff。React 认为对于列表反转和需要进行双端搜索的场景是少见的,所以在这一版的实现中,先不做额外的优化。

五、diff 和调度的关系

React Diff 不是一次完成。

因为 React 有:

Concurrent Rendering

Fiber 可以:

中断
恢复
优先级调度

例如:

render 10000 节点

React 可以:

render 200
yield
render 200

Diff 过程变成:

可中断计算

六、完整 Diff 流程图

beginWork
   │
   │
   ▼
reconcileChildren
   │
   │
   ▼
reconcileChildFibers
   │
   │
   ├── 单节点 diff
   │
   ├── 文本节点 diff
   │
   └── 数组 diff
           │
           │
           ├── 第一轮:顺序比较
           │
           ├── 第二轮:构建 map
           │
           └── 第三轮:查找复用 / 新建
                    │
                    │
                    ▼
              打 flags
                    │
                    ▼
              completeWork
                    │
                    ▼
                commit

前端设计模式

作者 流水白开
2026年3月5日 19:29

前言

前端开发中的设计模式就像是“代码模版”,它们提供了一套经过验证的解决方案,用于解决常见的设计问题,是为了解决开发中反复出现的特定问题而总结出的最佳实践。接下来会介绍一些常见的前端设计模式,并提供示例代码。

常见设计模式

1. 模块模式(Module Pattern)

模块模式是一种常见的设计模式,用于创建具有私有和公共成员的模块。它通过闭包来实现数据封装,避免了全局变量的污染。

const MyModule = (function () {
  // 私有变量
  let privateVariable = "I am private";
  // 私有函数
  function privateFunction() {
    console.log(privateVariable);
  }
  // 公共接口
  return {
    publicMethod: function () {
      privateFunction();
    },
  };
})();
MyModule.publicMethod(); // 输出: I am private

2. 单例模式(Singleton Pattern)

单例模式确保一个类只有一个实例,并提供一个全局访问点。它常用于管理全局状态或资源。

const Singleton = (function () {
  let instance;
  function createInstance() {
    return { name: "I am the only instance" };
  }
  return {
    getInstance: function () {
      if (!instance) {
        instance = createInstance();
      }
      return instance;
    },
  };
})();
const instance1 = Singleton.getInstance();
const instance2 = Singleton.getInstance();
console.log(instance1 === instance2); // 输出: true

3. 观察者模式(Observer Pattern)

观察者模式是一种设计模式,其中一个对象(称为“主题(Subject)”)维护一系列依赖于它的对象(称为“观察者(Observer)”),并在状态发生变化时通知它们。它常用于事件处理系统。

class Subject {
  constructor() {
    this.observers = [];
  }
  subscribe(observer) {
    this.observers.push(observer);
  }
  unsubscribe(observer) {
    this.observers = this.observers.filter((obs) => obs !== observer);
  }
  notify(data) {
    this.observers.forEach((observer) => observer.update(data));
  }
}
class Observer {
  update(data) {
    console.log("Observer received data:", data);
  }
}
const subject = new Subject();
const observer1 = new Observer();
const observer2 = new Observer();
subject.subscribe(observer1);
subject.subscribe(observer2);
subject.notify("Hello Observers!"); // 输出: Observer received data: Hello Observers!

4. 发布-订阅模式(Publish-Subscribe Pattern)

发布-订阅模式是一种设计模式,其中发布者(Publisher)发布事件,订阅者(Subscriber)订阅事件,并在事件发生时接收通知。它常用于解耦组件之间的通信。

class PubSub {
  constructor() {
    this.events = {};
  }
  subscribe(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);
  }
  publish(event, data) {
    if (this.events[event]) {
      this.events[event].forEach((callback) => callback(data));
    }
  }
}
const pubSub = new PubSub();
pubSub.subscribe("event1", (data) =>
  console.log("Subscriber 1 received:", data),
);
pubSub.subscribe("event1", (data) =>
  console.log("Subscriber 2 received:", data),
);
pubSub.publish("event1", "Hello PubSub!");
// 输出: Subscriber 1 received: Hello PubSub!
// 输出: Subscriber 2 received: Hello PubSub!

5. 工厂模式(Factory Pattern)

工厂模式是一种创建对象的设计模式,它提供一个接口用于创建对象,但允许子类决定实例化哪个类。它常用于需要根据条件创建不同类型对象的场景。

class Car {
  constructor(model) {
    this.model = model;
  }
}
class CarFactory {
  createCar(model) {
    return new Car(model);
  }
}
const factory = new CarFactory();
const car1 = factory.createCar("Tesla Model S");
console.log(car1.model); // 输出: Tesla Model S

6. 策略模式(Strategy Pattern)

策略模式是一种设计模式,它定义了一系列算法,并将每个算法封装起来,使它们可以互换。它常用于需要在运行时选择算法的场景。

class Strategy {
  execute() {
    throw new Error("Strategy#execute must be overridden");
  }
}
class ConcreteStrategyA extends Strategy {
  execute() {
    console.log("Executing strategy A");
  }
}
class ConcreteStrategyB extends Strategy {
  execute() {
    console.log("Executing strategy B");
  }
}
class Context {
  constructor(strategy) {
    this.strategy = strategy;
  }
  setStrategy(strategy) {
    this.strategy = strategy;
  }
  executeStrategy() {
    this.strategy.execute();
  }
}
const context = new Context(new ConcreteStrategyA());
context.executeStrategy(); // 输出: Executing strategy A
context.setStrategy(new ConcreteStrategyB());
context.executeStrategy(); // 输出: Executing strategy B

7. 装饰器模式(Decorator Pattern)

装饰器模式是一种设计模式,它允许向一个对象添加新的功能,而不改变其结构。它常用于需要动态地为对象添加功能的场景。

function decorator(func) {
  return function (...args) {
    console.log("Before executing the function");
    const result = func(...args);
    console.log("After executing the function");
    return result;
  };
}
function originalFunction() {
  console.log("Executing original function");
}
const decoratedFunction = decorator(originalFunction);
decoratedFunction();
// 输出: Before executing the function
// 输出: Executing original function
// 输出: After executing the function

单例模式实现方式

单例模式可以通过多种方式实现,以下是两种常见的实现方式:

1. 使用闭包实现单例模式

const Singleton = (function () {
  let instance;
  function createInstance() {
    return { name: "I am the only instance" };
  }
  return {
    getInstance() {
      if (!instance) {
        instance = createInstance();
      }
      return instance;
    },
  };
})();

const instance1 = Singleton.getInstance();
const instance2 = Singleton.getInstance();
console.log(instance1 === instance2); // 输出: true

2. 使用类实现单例模式

class Singleton {
  constructor(name) {
    if (Singleton.instance) {
      return Singleton.instance;
    }
    this.name = name;
    this.startTime = new Date();
    Singleton.instance = this;
  }

  static getInstance(name) {
    if (!this.instance) {
      this.instance = new Singleton(name);
    }
    return this.instance;
  }

  showTime() {
    console.log(`Instance created at: ${this.startTime}`);
  }
}

const instance1 = new Singleton("First Instance");
const instance2 = new Singleton("Second Instance");

console.log(instance1 === instance2); // 输出: true
console.log(instance1.name); // 输出: First Instance
console.log(instance2.name); // 输出: First Instance(因为 instance2 实际上是 instance1 的引用,instance2并没有被创建)

为什么单例模式用 class 的方式实现更合适?

  1. 命名空间与组织性:使用class可以将属性和方法组织在一个命名空间中,可以清晰地看到单例的结构和行为,而闭包方式可能会导致代码分散,难以维护。

  2. 延迟初始化:使用class的getInstance方法可以在有需要的时候才创建实例,而如果直接定义一个全局对象const singleton = new Singleton(),则在模块加载时就会创建实例,可能会导致不必要的资源浪费。

  3. 继承和扩展性:使用class支持extends,这样可以让单例可以拥有父类的通用能力,比如EventEmitter等,而闭包方式则不太方便实现原型链继承。

单例模式如何防止他人强行创建实例?

在Java或C++中,可以把构造函数设为private来防止外部直接创建实例,但在JavaScript中没有private构造函数的概念。JS虽然提供了private class fields(以#开头的字段)来实现私有属性,但它们并不能完全阻止外部通过new操作符创建实例,而我们可以通过直接抛出错误来强制限制。

class StrictSingleton {
    static #instance = null;

    constructor() {
        if(StrictSingleton.#instance) {
            throw new Error("请使用 StrictSingleton.getInstance() 获取实例");
        }
        StrictSingleton.#instance = this;
    }

    static getInstance() {
        if(!StrictSingleton.#instance) {
            StrictSingleton.#instance = new StrictSingleton();
        }

        return StrictSingleton.#instance;
    }
}

发布订阅模式和观察者模式的区别

发布订阅模式和观察者模式看起来都是在某一对象发生变化时通知其他对象,但它们之间实际上有一些区别,接下来会结合代码示例来说明它们的区别。

1. 核心对象

  • 观察者模式:核心对象是“主题(Subject)”,它维护一系列依赖于它的对象(称为“观察者(Observer)”),并在状态发生变化时通知它们。

  • 发布订阅模式:核心对象是“事件中心(Event Bus)”,它负责管理事件的订阅和发布,发布者(Publisher)发布事件,订阅者(Subscriber)订阅事件,并在事件发生时接收通知,在上面的代码中,PubSub类就是事件中心。

2. 耦合度

  • 观察者模式:主题和观察者之间存在直接的依赖关系,主题需要知道观察者的存在,并且在状态变化时直接调用观察者的方法。

  • 发布订阅模式:发布者和订阅者之间没有直接的依赖关系,它们通过事件中心进行通信,发布者只需要发布事件,而订阅者只需要订阅事件,彼此之间是完全解耦的。

3. 通信方式

  • 观察者模式:主题直接调用观察者的方法进行通信。

  • 发布订阅模式:发布者通过事件中心发布事件,订阅者通过事件中心接收事件进行通信,由事件中心决定通知的时间和方式。

4. 应用场景

  • 观察者模式:在基础库的内部逻辑中比较常见,比如Vue.js中的响应式系统就是基于观察者模式实现的。

ref 函数接受一个初始值,并返回一个包含该值的响应式对象。其核心原理如下:

function ref(initialValue) {
  const r = {
    get value() {
      // 依赖收集
      track(r, 'value')
      return initialValue
    },
    set value(newValue) {
      initialValue = newValue
      // 触发更新
      trigger(r, 'value')
    }
  }
  return r
}

reactive 函数接受一个对象,并返回该对象的响应式代理。其核心原理如下:

function reactive(target) {
  return new Proxy(target, {
    get(target, key) {
      // 依赖收集
      track(target, key)
      return Reflect.get(target, key)
    },
    set(target, key, value) {
      const result = Reflect.set(target, key, value)
      // 触发更新
      trigger(target, key)
      return result
    }
  })
}
  • 发布订阅模式:在跨组件通信、事件驱动的系统中比较常见,比如前端框架中的事件总线就是基于发布订阅模式实现的。如Vuex和Redux中的状态管理也是基于发布订阅模式实现的。

装饰器模式的优缺及使用场景

装饰器模式的核心理念是在不改变对象代码,不使用继承的情况下,动态地为对象添加额外的功能。

1. 与传统类继承的对比

  • 灵活性:装饰器模式允许在运行时动态地为对象添加功能,而不需要在编译时就确定对象的行为。这使得代码更加灵活,易于维护和扩展。

  • 遵循开闭原则:装饰器模式遵循开闭原则,即对扩展开放,对修改关闭。可以在不修改现有代码的情况下,通过添加新的装饰器来扩展对象的功能。

  • 避免了类的数量爆炸:使用类继承来组合各种功能,随着功能排列组合的增加,派生类的数量会呈指数级增长,而装饰器模式通过组合装饰器来实现功能的扩展,只需要少量装饰类即可完成复杂的组合。

  • 职责分离:装饰器模式将对象的功能分解为独立的装饰器,每个装饰器只负责一个特定的功能,使得代码更加模块化,易于维护和测试。

2. 适用场景

  • 动态添加功能:当需要在运行时动态地为对象添加功能时,装饰器模式是一个很好的选择,如在一个支付系统中,基础功能是“支付”。你可以根据用户需求,动态地叠加上“短信通知”、“积分奖励”、“多币种转换”等功能。

  • 处理多种功能的组合:如果一个对象有5个独立的功能扩展,使用继承可能需要实现2的五次方个子类,而装饰器模式只需要5个装饰器类。

  • 无法通过继承扩展的类:当类被定义为final或者无法修改时,装饰器模式可以通过组合的方式来扩展其功能,而不需要修改原有类的代码。

3. 具体示例

class Coffee {
  cost() {
    return 5;
  }
}

class MilkDecorator {
  constructor(coffee) {
    this.coffee = coffee;
  }
  cost() {
    return this.coffee.cost() + 2; // 加牛奶的费用
  }
}

class SugarDecorator {
  constructor(coffee) {
    this.coffee = coffee;
  }
  cost() {
    return this.coffee.cost() + 1; // 加糖的费用
  }
}

const myCoffee = new SugarDecorator(new MilkDecorator(new Coffee()));
console.log(myCoffee.cost()); // 输出: 8 (5 + 2 + 1)

在这个例子中,Coffee是基础类,MilkDecorator和SugarDecorator是装饰器类,通过组合的方式为Coffee对象动态地添加了牛奶和糖的功能,而不需要修改Coffee类的代码。

4. 装饰器的缺点

  • 增加了系统的复杂性:装饰器模式引入了更多的类和对象,可能会使系统变得更加复杂,尤其是在装饰器层次较深时,可能会导致代码难以理解和维护。

  • 调试困难:由于装饰器模式涉及多个对象的组合,调试时可能需要跟踪多个对象的状态和行为,增加了调试的难度。

  • 性能开销:每个装饰器都需要创建一个新的对象,这可能会导致性能开销,尤其是在装饰器层次较深时,可能会影响系统的性能。

5. 一些扩展

  • Python中的装饰器:Python内置了装饰器语法,可以直接使用@符号来装饰函数或类,极大地简化了装饰器的使用。

def decorator(func):
    def wrapper(*args, **kwargs):
        print("Before executing the function")
        result = func(*args, **kwargs)
        print("After executing the function")
        return result
    return wrapper

@decorator
def original_function():
    print("Executing original function")

original_function()

# 输出:
# Before executing the function
# Executing original function
# After executing the function
  • Java中的装饰器:Java中可以通过接口和抽象类来实现装饰器模式,常见的例子是Java IO库中的InputStream和OutputStream类。
public interface Coffee {
    double cost();
}

public class SimpleCoffee implements Coffee {
    @Override
    public double cost() {
        return 5;
    }
}

public class MilkDecorator implements Coffee {
    private Coffee coffee;

    public MilkDecorator(Coffee coffee) {
        this.coffee = coffee;
    }

    @Override
    public double cost() {
        return coffee.cost() + 2; // 加牛奶的费用
    }
}

public class SugarDecorator implements Coffee {
    private Coffee coffee;

    public SugarDecorator(Coffee coffee) {
        this.coffee = coffee;
    }

    @Override
    public double cost() {
        return coffee.cost() + 1; // 加糖的费用
    }
}

public class Main {
    public static void main(String[] args) {
        Coffee myCoffee = new SugarDecorator(new MilkDecorator(new SimpleCoffee()));
        System.out.println(myCoffee.cost()); // 输出: 8 (5 + 2 + 1)
    }
}

结语

设计模式实在太多了,以上只是提到了几个比较重要和常见的设计模式,个人感觉不用过于苛求每个设计模式都要熟练掌握,了解它们的核心思想和基本适用场景就足够了,在实际开发中,根据具体问题选择合适的设计模式来解决问题才是最重要的。设计模式是工具,不是目的,过度使用设计模式可能会导致代码过于复杂,反而不利于维护和理解,所以在使用设计模式时要根据实际情况进行权衡和选择。

面试踩大坑!同一段 Node.js 代码,CJS 和 ESM 的执行顺序居然是反的?!99% 的人都答错了

作者 sunny_
2026年3月5日 21:12

一道"经典"的 Event Loop 面试题,背了八股文的你以为稳了,结果一运行却被 ESM 背刺。本文带你深挖 Node.js 事件循环中一个 99% 的人都不知道的坑:同样的代码,CJS 和 ESM 输出顺序竟然不一样。

前言

如果你准备过前端/Node.js 面试,大概率刷到过这类题目:

setImmediate(() => {
    console.log(1);
});

process.nextTick(() => {
    console.log(2);
    process.nextTick(() => {
        console.log(6);
    });
});

console.log(3);

Promise.resolve().then(() => {
    console.log(4);
    process.nextTick(() => {
        console.log(5);
    });
});

你自信地写下答案:3 → 2 → 6 → 4 → 5 → 1

面试官微微一笑,说:"没问题,回去等通知吧。"

你心满意足地回家,顺手建了个项目想验证一下,npm init,改了下 package.jsonnode index.js 一跑——

3
4
2
5
6
1

你揉了揉眼睛,又跑了一遍。没错,4 跑到 2 前面去了

你开始怀疑人生:是我八股文背错了?还是 Node.js 出 bug 了?

都不是。是 ESM 在背后捅了你一刀。


一、先复习:Node.js 事件循环到底怎么转的

在搞清楚这个坑之前,我们得先把 Node.js 的事件循环机制理清楚。这部分是基础,老手可以快速跳过,但建议还是扫一遍,因为后面的分析会用到。

1.1 事件循环的六个阶段

Node.js 的事件循环基于 libuv,分为以下几个阶段,每一轮循环(tick)按顺序执行:

   ┌───────────────────────────┐
┌─>│           timers          │  ← setTimeout / setInterval 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  ← 系统级回调(如 TCP 错误)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  ← 内部使用
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │  ← I/O 回调(fs.readFile 等)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │  ← setImmediate 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │  ← socket.on('close') 等
│  └───────────────────────────┘

1.2 微任务的位置

在 Node.js v11 之后,每个阶段之间以及每个宏任务执行之后,都会清空微任务队列。微任务分为两类:

队列 API 优先级
nextTick 队列 process.nextTick()
微任务队列 Promise.then() / queueMicrotask()

也就是说,在传统认知中(CJS 模式下),优先级排序是:

同步代码 > process.nextTick > Promise 微任务 > 宏任务(timers / check / ...)

这也是为什么你面试时会回答 3 → 2 → 6 → 4 → 5 → 1 的原因——nextTick 队列会在 Promise 微任务之前被清空

1.3 微任务递归清空

一个重要的细节:nextTick 队列在清空时,如果回调中又注册了新的 nextTick,会在同一轮继续清空,直到队列为空。Promise 微任务也是同理。整个流程:

1. 清空 nextTick 队列(递归)
2. 清空 Promise 微任务队列(递归)
3. 如果上述步骤产生了新的 nextTick 或微任务,回到 1
4. 全部清空后,进入下一个事件循环阶段

到这里,一切都很"标准",也符合绝大多数八股文的描述。


二、验证"标准答案"——CJS 模式

我们先在 CJS 模式下跑一下,证明八股文没有骗你。

创建一个 test-cjs.js(注意:不要在 package.json 里设置 "type": "module"):

// test-cjs.js(CommonJS 模式)
setImmediate(() => {
    console.log(1);
});

process.nextTick(() => {
    console.log(2);
    process.nextTick(() => {
        console.log(6);
    });
});

console.log(3);

Promise.resolve().then(() => {
    console.log(4);
    process.nextTick(() => {
        console.log(5);
    });
});

执行:

node test-cjs.js

输出:

3
2
6
4
5
1

完美,和八股文一模一样。我们来走一遍流程:

第一步:执行同步代码

  • setImmediate(cb) → 注册到 check 阶段队列
  • process.nextTick(cb) → 注册到 nextTick 队列(打印 2)
  • console.log(3)输出 3
  • Promise.resolve().then(cb) → 注册到微任务队列(打印 4)

此时队列状态:

nextTick 队列:[cb(2)]
微任务队列:  [cb(4)]
check 队列:  [cb(1)]

第二步:清空 nextTick 队列

  • 执行 cb(2)输出 2,并注册 nextTick(cb(6))
  • 队列没清空,继续 → 执行 cb(6)输出 6

第三步:清空微任务队列

  • 执行 cb(4)输出 4,并注册 nextTick(cb(5))
  • 微任务执行完毕,检查 nextTick → 执行 cb(5)输出 5

第四步:进入事件循环 check 阶段

  • 执行 setImmediate 回调 → 输出 1

最终:3 → 2 → 6 → 4 → 5 → 1


三、翻车现场——ESM 模式

现在,我们做一件"无害"的事情——在 package.json 里加上一行:

{
  "type": "module"
}

代码一个字都不改,再跑一次:

node index.js

输出:

3
4
2
5
6
1

4 跑到 2 前面去了!Promise 微任务比 nextTick 先执行了!

等等,不是说好了 nextTick > Promise 吗?

这并不是 Node.js 的 bug,这是 ESM 模块系统的执行机制 导致的必然结果。


四、为什么 ESM 会改变执行顺序?

这是本文的核心。要理解这个差异,必须搞清楚 CJS 和 ESM 在 Node.js 中的执行方式有什么本质不同。

4.1 CJS 的执行方式

在 CJS 模式下,Node.js 的执行流程大致是:

1. 同步加载模块(require 是同步的)
2. 同步执行模块代码
3. 模块代码执行完毕,进入事件循环
4. 事件循环开始前,先清空 nextTick 队列,再清空微任务队列

关键点:模块代码的执行是在 Node.js 的"主执行流"中完成的。执行完毕后,Node.js 通过自己的调度逻辑先处理 nextTick,再处理 Promise 微任务。

4.2 ESM 的执行方式

ESM 就完全不一样了。根据 ECMAScript 规范,ES Module 的加载和求值是 异步 的:

1. 解析模块依赖图(静态分析 import/export)
2. 异步加载所有模块
3. 按照依赖顺序对模块进行求值(evaluate)

关键来了:ESM 模块的求值(evaluate)过程本身就是在一个微任务(microtask)上下文中进行的。

这意味着什么?当你的 ESM 代码执行时,它已经处在 V8 引擎的微任务调度体系中了。代码执行完毕后:

  1. V8 引擎会先执行自己的微任务检查点(microtask checkpoint)
  2. Promise 微任务是 V8 原生管理的,所以会被 V8 先消费
  3. 然后控制权交还给 Node.js
  4. Node.js 再清空 nextTick 队列

用一张对比图来看:

CJS 执行完毕后的清空顺序:
┌─────────────────────────────────┐
│  Node.js 接管                    │
│  1. 清空 nextTick 队列            │  ← Node.js 自己的机制
│  2. 清空 Promise 微任务队列        │  ← V8 的微任务
│  3. 进入事件循环                   │
└─────────────────────────────────┘

ESM 执行完毕后的清空顺序:
┌─────────────────────────────────┐
│  V8 微任务检查点触发               │
│  1. 清空 Promise 微任务队列        │  ← V8 先动手了!
│  2. Node.js 接管                  │
│  3. 清空 nextTick 队列            │  ← Node.js 的 nextTick 被延后了
│  4. 进入事件循环                   │
└─────────────────────────────────┘

本质区别:在 CJS 中,Node.js 拥有微任务调度的主动权,所以它能让 nextTick 先走;而在 ESM 中,V8 引擎的微任务检查点先于 Node.js 的 nextTick 调度触发,所以 Promise 反而先执行了。

4.3 一句话总结

process.nextTick 是 Node.js 的"私货",不属于 ECMAScript 标准。在 ESM 模式下,V8 引擎遵循标准的微任务执行机制,自然不会优先照顾 Node.js 的私货。


五、用代码证明这不是玄学

为了彻底证实这个结论,我们做一组最简实验——只用 nextTickPromise 对比:

实验 1:CJS 模式

node -e "
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
"

输出:

nextTick
promise

nextTick 先于 Promise

实验 2:ESM 模式

node --input-type=module -e "
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
"

输出:

promise
nextTick

Promise 先于 nextTick

同样的代码,两行都没改,只是切换了模块系统,执行顺序就反过来了。

实验 3:在事件循环内部(两者一致)

// 无论 CJS 还是 ESM,事件循环内部的行为是一致的
setTimeout(() => {
    process.nextTick(() => console.log('nextTick'));
    Promise.resolve().then(() => console.log('promise'));
}, 0);

输出(两种模式都一样):

nextTick
promise

这说明:ESM 只影响顶层代码(Top-Level)的微任务执行顺序,一旦进入事件循环内部,nextTick 和 Promise 的优先级关系恢复正常。


六、回到那道面试题——ESM 下的完整解析

现在我们用 ESM 的规则重新分析原题:

setImmediate(() => {
    console.log(1);
});

process.nextTick(() => {
    console.log(2);
    process.nextTick(() => {
        console.log(6);
    });
});

console.log(3);

Promise.resolve().then(() => {
    console.log(4);
    process.nextTick(() => {
        console.log(5);
    });
});

第一步:执行同步代码

和 CJS 完全一样:

  • 注册 setImmediate(cb(1)) → check 队列
  • 注册 nextTick(cb(2)) → nextTick 队列
  • 输出 3
  • 注册 Promise.then(cb(4)) → 微任务队列

队列状态:

nextTick 队列:[cb(2)]
微任务队列:  [cb(4)]
check 队列:  [cb(1)]

第二步:V8 微任务检查点(ESM 的关键差异!)

因为是 ESM 模式,V8 的微任务检查点先触发:

  • 执行 cb(4)输出 4,注册 nextTick(cb(5))

此时队列状态:

nextTick 队列:[cb(2), cb(5)]
微任务队列:  [](已清空)
check 队列:  [cb(1)]

第三步:Node.js 清空 nextTick 队列

  • 执行 cb(2)输出 2,注册 nextTick(cb(6))
  • 执行 cb(5)输出 5
  • 执行 cb(6)输出 6

此时队列状态:

nextTick 队列:[](已清空)
微任务队列:  []
check 队列:  [cb(1)]

第四步:进入事件循环 check 阶段

  • 执行 setImmediate 回调 → 输出 1

最终:3 → 4 → 2 → 5 → 6 → 1


七、面试怎么答?

如果你在面试中遇到这道题,我建议分三层回答:

第一层:给出标准答案

在 CJS 模式下,输出顺序是 3 → 2 → 6 → 4 → 5 → 1。因为同步代码优先执行,然后 process.nextTick 队列会在 Promise 微任务之前被清空,最后才是 setImmediate 宏任务。

第二层:主动提出 ESM 的差异

但如果这段代码运行在 ESM 模式下("type": "module".mjs 文件),输出顺序会变成 3 → 4 → 2 → 5 → 6 → 1。因为 ESM 模块的求值本身处于 V8 的微任务上下文中,Promise 微任务会被 V8 引擎优先消费,先于 Node.js 的 nextTick 队列。

第三层:解释根本原因

这个差异的本质是 process.nextTick 是 Node.js 自己的调度机制,不属于 ECMAScript 标准。在 CJS 模式下,Node.js 对执行流有完全的控制权,可以让 nextTick 优先;但在 ESM 模式下,V8 引擎遵循标准的微任务执行机制,Node.js 的私有调度会被延后。不过这个差异只存在于顶层代码中,进入事件循环后两者行为一致。

这三层答出来,面试官绝对会对你刮目相看。


八、延伸思考

8.1 这算是 Node.js 的 Bug 吗?

不算。这是 CJS 和 ESM 两种模块系统的 设计差异 导致的必然结果。Node.js 官方文档中也有相关说明:

Microtask callbacks take priority over nextTick callbacks in this specific case because of V8's microtask checkpoint behavior during ES module evaluation.

8.2 process.nextTick 还值得用吗?

process.nextTick 在 Node.js 生态中仍然有其存在价值,比如:

  • 在事件循环内部,它的优先级依然高于 Promise
  • 用来确保回调在当前操作完成后、I/O 之前执行
  • 在流(Stream)和 EventEmitter 中广泛使用

但考虑到 ESM 正在成为 Node.js 的主流模块系统,你需要意识到 在顶层代码中,nextTick 的优先级不再是绝对的。如果你对执行顺序有严格要求,应该通过代码结构来保证,而不是依赖 nextTick 和 Promise 的微妙优先级差异。

8.3 queueMicrotask vs process.nextTick

还有一个相关的知识点:queueMicrotask() 是 Web 标准 API,Node.js 也支持。它创建的微任务和 Promise 处于同一级别。在 CJS 中,queueMicrotask 的回调在 nextTick 之后执行;在 ESM 中,它和 Promise 一样会先于 nextTick 执行。

process.nextTick(() => console.log('nextTick'));
queueMicrotask(() => console.log('queueMicrotask'));
Promise.resolve().then(() => console.log('promise'));

CJS 输出:nextTick → queueMicrotask → promise ESM 输出:queueMicrotask → promise → nextTick

8.4 面试中常见的相关题目

理解了上面的原理后,下面这些变种你也能轻松应对:

题目 1:async/await 的执行顺序

async function foo() {
    console.log(1);
    await Promise.resolve();
    console.log(2);
}

process.nextTick(() => console.log(3));
foo();
console.log(4);

CJS 下:1 → 4 → 3 → 2 ESM 下:1 → 4 → 2 → 3

原理相同:await 后面的代码本质上就是 Promise.then,在 ESM 中会先于 nextTick 执行。

题目 2:setTimeout vs setImmediate

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

这道题的输出顺序是 不确定的(取决于系统调度),但如果放在 I/O 回调中,setImmediate 一定先于 setTimeout

const fs = require('fs');
fs.readFile(__filename, () => {
    setTimeout(() => console.log('timeout'), 0);
    setImmediate(() => console.log('immediate'));
});
// 始终输出:immediate → timeout

九、总结

维度 CJS ESM
模块加载 同步 (require) 异步 (import)
顶层代码执行上下文 Node.js 主执行流 V8 微任务上下文
顶层 nextTick vs Promise nextTick 优先 Promise 优先
事件循环内部 nextTick vs Promise nextTick 优先 nextTick 优先
"type" 设置 默认 / "commonjs" "module"
文件扩展名 .js / .cjs .js(需配置) / .mjs

一句话记忆:CJS 中 Node.js 说了算,nextTick 是大哥;ESM 中 V8 说了算,Promise 是大哥。但进了事件循环,nextTick 依然是大哥。


写在最后

这个坑之所以"阴间",是因为:

  1. 代码完全一样,只是 package.json 多了一行 "type": "module"
  2. 绝大多数八股文和面试题都没有区分模块系统来讨论
  3. 现在的新项目基本都用 ESM 了,所以你跑出来的结果大概率和背的不一样

下次面试官再问事件循环,别忘了反问一句:"请问这段代码是跑在 CJS 还是 ESM 下?"

如果面试官愣住了——恭喜你,你已经赢了。


如果这篇文章帮到了你,欢迎点赞收藏,关注我获取更多 Node.js 深水区技术分享。

昨天以前首页

解锁 JavaScript 的灵魂:深入浅出原型与原型链

作者 Lee川
2026年3月5日 16:30

引言

在 JavaScript 的世界里,没有传统意义上的“类”作为蓝图来构建对象(至少在 ES6 之前是这样)。取而代之的,是一套独特而优雅的机制——原型(Prototype)与原型链(Prototype Chain)。这套机制不仅是 JavaScript 面向对象编程的基石,更是其灵活性与动态性的源泉。

本文将结合具体的代码实例,带你彻底揭开原型与原型链的神秘面纱,理解它们如何协同工作,让对象之间实现高效的属性共享与继承。


一、从“造车”说起:为什么需要原型?

想象一下,你是一家汽车工厂的工程师。如果每生产一辆车,你都要重新编写一遍“这辆车有四个轮子、一个引擎、能跑”的代码,那将是多么低效且浪费资源!

在 JavaScript 中,构造函数(Constructor)就像是一个模具。我们来看一个经典的例子,定义一个 Car 构造函数:

function Car(color) {
    // 每辆车独特的属性,放在构造函数内部
    this.color = color; 
    // 如果把所有属性都放这里:
    // this.name = 'su7';
    // this.height = 1.4;
    // this.drive = function() { console.log('driving...'); };
}

如果我们把 nameheight 或者 drive 方法直接写在构造函数里,意味着每 new 一辆车,内存中就会复制一份完全相同的数据和方法。对于成千上万辆车来说,这是巨大的浪费。

原型的出现,就是为了解决“共享”的问题

我们可以将那些所有车辆共有的属性和方法,挂载到构造函数的 prototype 对象上:

// 共享的属性和方法,只存一份!
Car.prototype = {
    name: 'su7',
    height: 1.4,
    weight: 1.5,
    drive() {
        console.log('drive, 下赛道');
    }
};

const car1 = new Car('霞光紫');
const car2 = new Car('海湾蓝');

console.log(car1.name); // 输出: su7
console.log(car2.name); // 输出: su7
car1.drive();           // 输出: drive, 下赛道

在这个例子中,car1car2 虽然颜色不同(实例自有属性),但它们共享了 nameheight 以及 drive 方法。这些共享内容并没有存储在 car1car2 自身内部,而是存在于 Car.prototype 中。

核心概念 1prototype 是函数(构造函数)的一个属性,它是一个对象。这个对象上的属性和方法,会被该构造函数创建的所有实例共享


二、探秘内部机制:__proto__ 与寻找之旅

既然属性不在实例自己身上,那当我们执行 car1.drive() 时,JavaScript 引擎是如何找到 drive 方法的呢?这就引出了另一个关键角色:__proto__

1. 隐式原型链接

在 JavaScript 中,几乎每个对象(除了 null)都有一个内部的私有属性,通常表示为 __proto__(在标准中称为 [[Prototype]])。

  • 当你使用 new Car() 创建一个实例时,这个实例的 __proto__ 会自动指向构造函数的 prototype 对象。
  • 也就是说:car1.__proto__ === Car.prototype 成立。

我们可以用代码验证这一点:

function Person(name, age) {
    this.name = name;
    this.age = age;
}
Person.prototype.speci = '人类';

const p1 = new Person('张三', 18);
console.log(p1.__proto__); // 指向 Person.prototype
console.log(p1.__proto__ === Person.prototype); // true

2. 属性查找的“接力赛”

当你访问一个对象的属性(例如 p1.speci)时,JavaScript 引擎会启动一场查找接力赛

  1. 第一棒:先在对象自身(实例)上查找。如果有,直接返回;如果没有,进入下一棒。
  2. 第二棒:沿着 __proto__ 指针,去它的原型对象Person.prototype)上查找。
  3. 第三棒:如果原型对象上也没有,就继续沿着原型对象的 __proto__ 往上找。默认情况下,它指向 Object.prototype
  4. 终点Object.prototype 是所有普通对象的终极原型。它的 __proto__null。如果连这里都找不到,引擎就会返回 undefined

这条由 __proto__ 串联起来的链条,就是著名的原型链(Prototype Chain)。

核心概念 2:原型链是对象通过 __proto__ 属性向上追溯,直到 null 的一条链路。它是 JavaScript 实现属性继承和共享的根本机制。


三、🗺️ 全景图解:一张图看懂复杂关系

文字描述虽然逻辑清晰,但原型系统中错综复杂的引用关系往往让人在脑海中难以构建完整的模型。为了让你彻底“看见”原型链,我们引入下面这张JavaScript 原型关系全景图

这张图完美地串联了我们前面提到的所有概念:构造函数、实例、prototype__proto__ 以及 constructor

image.png

深度读图指南

请跟随图中的箭头,我们将这张图拆解为三个关键视角:

1. 横向视角:构造函数与原型的“双向奔赴”

请看图的左上部分:

  • **Person **(构造函数) 通过黑色的 prototype 箭头指向 Person.prototype
    • 这意味着:构造函数拥有一个“仓库”,用来存放共享给实例的方法。
  • Person.prototype 通过黑色的 constructor 箭头指回 Person
    • 这意味着:原型对象记得是谁创造了它。这是一个闭环,确保了 Person.prototype.constructor === Person 成立。

2. 纵向视角:实例与原型的“隐形脐带”

请看图中那条醒目的蓝色曲线

  • **person **(实例) 通过 __proto__ 箭头指向 Person.prototype
    • 这是原型链的起点。当你访问 person 的属性时,如果自身没有,JS 引擎就会顺着这条蓝色箭头,去 Person.prototype 里找。
    • 口诀:实例的 __proto__ 永远等于构造函数的 prototype

3. 链条视角:通往顶端的“天梯”

请看图右侧垂直向下的蓝色直线

  • Person.prototype 也有自己的 __proto__,它指向了 Object.prototype
    • 这说明 Person.prototype 本身也是一个对象,它也受 Object 管辖。
  • Object.prototype__proto__ 指向了 null
    • 这是原型链的终点null 意味着“无路可走”,查找至此结束。

结合代码的读图体验: 当你执行 person.toString() 时:

  1. 引擎看 person 自身?没有 toString
  2. 顺着蓝色曲线去 Person.prototype 找?没有。
  3. 顺着蓝色直线去 Object.prototype 找?找到了! (toString 是 Object 内置方法)。
  4. 任务完成。

这张图告诉我们:原型链本质上就是一串由 __proto__ 连接起来的对象链表,而 prototypeconstructor 则是维护这个系统结构完整性的关键纽带


四、实战演练:彻底搞懂继承

理解了原型和原型链,继承就变得顺理成章。假设我们要创建一个 SportsCar(跑车),它应该拥有普通 Car 的所有特性,还要有自己的特技。

// 父构造函数
function Car(color) {
    this.color = color;
}
Car.prototype.drive = function() {
    console.log('普通驾驶');
};

// 子构造函数
function SportsCar(color, speed) {
    // 借用父构造函数,继承实例属性
    Car.call(this, color); 
    this.speed = speed;
}

// 关键步骤:建立原型链继承
// 让 SportsCar 的原型指向一个由 Car 创建的实例
SportsCar.prototype = new Car(); 

// 修正 constructor 指向(最佳实践,对应图中 constructor 箭头的修复)
SportsCar.prototype.constructor = SportsCar;

// 添加子类特有的方法
SportsCar.prototype.race = function() {
    console.log('赛道狂飙,速度:' + this.speed);
};

const myCar = new SportsCar('红色', 300);

myCar.drive(); // 来自父级原型链:普通驾驶
myCar.race();  // 来自子类原型:赛道狂飙,速度:300
console.log(myCar.color); // 来自实例自身:红色

在这个过程中发生了什么?(对照全景图想象)

  1. SportsCar.prototype = new Car():这行代码创建了一个临时的 Car 实例。
  2. 这个临时实例的 __proto__ 指向 Car.prototype
  3. 我们将这个临时实例赋值给 SportsCar.prototype
  4. 此时,SportsCar.prototype__proto__ 就自然地指向了 Car.prototype
  5. myCar 访问 drive 方法时,查找路径变成了:
    • myCar -> SportsCar.prototype -> Car.prototype (找到!) -> Object.prototype -> null

这就是原型链继承的精髓:通过修改原型链的指向,让子类的实例能够访问到父类原型上的方法


五、总结与启示

回顾全文,结合那张清晰的全景图,我们可以提炼出以下核心要点:

  1. 构造函数与 Prototype:每个函数都有一个 prototype 属性,用于存放供实例共享的属性和方法。
  2. 实例与 __proto__:每个实例都有一个 __proto__ 属性,它在实例化时自动指向构造函数的 prototype(图中蓝色曲线的含义)。
  3. 原型链查找机制:访问属性时,JS 引擎会沿 __proto__ 链条逐级向上查找,直到 Object.prototypenull(图中蓝色直线的含义)。
  4. 闭环的重要性constructor 属性确保了原型对象能找回构造函数,维持系统的完整性(图中黑色反向箭头的含义)。
  5. 继承本质:JS 的继承不是拷贝,而是原型链的委托查找。

理解原型和原型链,是掌握 JavaScript 高级特性的必经之路。无论是后续的 class 语法糖,还是框架源码中的巧妙运用,其底层逻辑都离不开这套精妙的原型机制。

下次当你写下 new 关键字时,不妨在脑海中浮现出那张全景图:描绘出那条连接着实例、原型、再通向 Object 的隐形链条。正是这条链条,赋予了 JavaScript 无限的可能。

Vue 3 性能优化的 5 个隐藏技巧,第 4 个连老手都未必知道

作者 前端Hardy
2026年3月4日 10:50

上周,我们上线了一个数据看板页面,本地跑得飞快,一上生产——滚动卡成 PPT

Profiler 一抓,发现:

  • 每次滚动都在重复创建 computed 函数
  • 列表项里嵌套了 3 层 <Suspense>
  • 一个 watch 竟然监听了整个 reactive 对象……

问题不在逻辑,而在 “你以为没问题的写法”

今天,我就分享 5 个 Vue 3 中少有人提、但效果惊人的性能优化技巧,尤其第 4 个,连很多 5 年经验的老手都没用过。


技巧 1:别在模板里写“方法调用”,用 computed + 缓存

反面教材:

<template>
  <div>{{ formatUserName(user) }}</div> <!-- 每次渲染都执行! -->
</template>

<script setup>
const formatUserName = (user) => `${user.firstName} ${user.lastName}`;
</script>

正确做法:

const formattedName = computed(() => 
  `${user.value.firstName} ${user.value.lastName}`
);
<template>
  <div>{{ formattedName }}</div> <!-- 响应式缓存,依赖不变不重算 -->
</template>

关键点:模板中的函数调用 没有缓存,每次 re-render 都会执行!


技巧 2:v-for 里的组件,记得加 key —— 但别用 index

很多人知道要加 key,但随手写:

<div v-for="(item, index) in list" :key="index">
  <ItemCard :data="item" />
</div>

问题:当列表发生插入/删除时,index 会变,导致 Vue 错误复用组件实例,引发状态错乱 or 不必要的销毁重建。

正确做法:用唯一 ID

<div v-for="item in list" :key="item.id">
  <ItemCard :data="item" />
</div>

如果真没 ID?考虑用 Symbol()crypto.randomUUID() 生成稳定 key(仅限静态列表)。


技巧 3:慎用 watch 监听整个 reactive 对象

const state = reactive({ a: 1, b: 2, c: 3 });

watch(state, () => {
  console.log('state changed');
});

这会导致:只要 abc 任意一个变了,回调就触发,即使你只关心 a

更精准的写法:

// 方案 A:监听具体属性
watch(() => state.a, (newVal) => { ... });

// 方案 B:用 toRefs 解构后监听
const { a } = toRefs(state);
watch(a, (newVal) => { ... });

高级技巧:如果必须监听多个字段,用 getter 函数组合:

watch(
  () => ({ a: state.a, b: state.b }),
  (newVals) => { /* 只有 a 或 b 变才触发 */ }
);

技巧 4:用 shallowRefmarkRaw 跳过不必要的响应式(隐藏大招!)

这是 Vue 3 响应式系统中最被低估的 API

场景:你有一个大型配置对象 or 第三方库实例(如 echarts 实例),不需要响应式?

默认写法(性能杀手):

const chart = ref(null); // Vue 会尝试把 echarts 实例变成响应式!
onMounted(() => {
  chart.value = echarts.init(dom); // 内部 thousands of properties!
});

正确做法:

// 方案 A:用 shallowRef(只让 .value 响应,内部不递归)
const chart = shallowRef(null);

// 方案 B:用 markRaw 明确告诉 Vue “别动它”
const chartInstance = markRaw(echarts.init(dom));
const chart = ref(chartInstance);

效果:避免 Vue 递归遍历大型对象,节省内存 + 提升初始化速度 10x+

适用场景:

  • 图表实例(ECharts、Chart.js)
  • 复杂配置对象(如 Monaco Editor options)
  • 不变的数据结构(如路由 meta、常量字典)

技巧 5:懒加载组件 + 异步 setup,减少首屏负担

别让所有组件都在首屏加载!

<!-- 同步引入,打包进主 chunk -->
<script setup>
import HeavyChart from './HeavyChart.vue';
</script>

改成动态导入 + Suspense:

<template>
  <Suspense>
    <template #default>
      <LazyChart />
    </template>
    <template #fallback>
      <div>Loading chart...</div>
    </template>
  </Suspense>
</template>

<script setup>
// 自动代码分割
const LazyChart = defineAsyncComponent(() => import('./HeavyChart.vue'));
</script>

进阶:配合 IntersectionObserver 实现滚动到可视区再加载

const isVisible = ref(false);
// 当元素进入视口,isVisible = true → 再加载组件

总结:5 个技巧速查表

技巧 适用场景 性能收益
模板中用 computed 代替方法调用 频繁渲染的格式化逻辑 避免重复计算
v-for 用唯一 ID 做 key 动态列表(增删改) 减少 DOM 重建
精准 watch 而非监听整个对象 复杂状态管理 避免无效回调
shallowRef / markRaw 跳过响应式 大型对象、第三方实例 内存 & 初始化提速
异步组件 + Suspense 重型组件(图表、编辑器) 首屏加载更快

最后说两句

Vue 3 的性能,80% 取决于你如何使用响应式系统,而不是框架本身慢。

真正的优化,不是“加缓存”“开 SSR”,而是:

在正确的地方,用正确的 API,做最小化的响应式。

下次写组件前,先问自己:

“这个数据,真的需要响应式吗?”


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

从原理到手写:彻底吃透 call / apply / bind 与 arguments 的底层逻辑

作者 swipe
2026年3月5日 16:02

引言:为什么我们要“手写”这些 API?

在日常开发中,callapplybind 几乎每天都会用到。无论是处理 this 绑定问题、实现函数复用,还是做函数柯里化,它们都是绕不开的基础能力。

但有一个现实问题:

很多人“会用”,但说不清楚为什么这样设计,也不知道边界在哪里。

一旦进入复杂业务场景,比如高阶函数封装、事件回调丢失上下文、React 中函数绑定优化等问题,底层理解不扎实就会成为瓶颈。

本文我们做三件事:

  • 手写实现 call / apply / bind
  • 理解它们的设计哲学与差异
  • 彻底讲清楚 arguments 的本质与演进

目标不是背代码,而是形成“可迁移的工程认知”。


一、手写 call / apply / bind

1.0 先明确三个 API 的语法

func.call(thisArg, arg1, arg2, ...)
func.apply(thisArg, [argsArray])
const newFunc = func.bind(thisArg, arg1, arg2, ...)

区别非常明确:

方法 是否立即执行 参数形式 是否返回函数
call 参数列表
apply 数组
bind 参数列表

核心差异点只有两个:

  1. 是否立即执行
  2. 参数如何传递

1.1 手写 call —— 从“执行函数”开始

第一步:给所有函数添加能力

Function.prototype.hycall = function () {
  console.log("原型链调用了")
}

function foo() {
  console.log("foo函数调用了")
}

foo.hycall()

问题出现了:

只执行了 hycall,没有执行 foo 本身。

我们真正的目标是:

  • 谁调用 hycall
  • 就执行谁

关键点:

var fn = this
fn()

优化版:

Function.prototype.hycall = function () {
  var fn = this
  fn()
}

小结

  • Function.prototype 挂方法 = 所有函数都能用
  • this 指向调用 hycall 的函数
  • call 的第一能力:立即执行函数

1.2 改变 this 指向 —— 显式绑定的核心

默认调用:

fn()  // 默认绑定 → window

我们希望:

foo.hycall({ name: "小吴" })

this 指向传入对象。

关键思路:

借助“隐式绑定规则”

thisArg.fn = fn
thisArg.fn()

实现:

Function.prototype.hycall = function (thisArg) {
  var fn = this

  thisArg.fn = fn
  thisArg.fn()

  delete thisArg.fn
}

对比原生:

foo.hycall({ name: "小吴" })
foo.call({ name: "why" })

图10-1 call调用会执行函数

为什么这样能生效?

因为:

  • obj.fn() 是隐式调用
  • 隐式调用优先级 > 默认绑定
  • 所以 this 指向 obj

这就是“借鸡生蛋”的核心思想。


1.3 处理基本类型问题

问题:

foo.hycall(123)

报错,因为:

123.fn = fn // 不允许

解决方案:

thisArg = Object(thisArg)

最终优化:

Function.prototype.hycall = function (thisArg) {
  var fn = this

  thisArg =
    thisArg !== null && thisArg !== undefined
      ? Object(thisArg)
      : window

  thisArg.fn = fn
  var result = thisArg.fn()
  delete thisArg.fn

  return result
}

图10-4 转化为对象的处理方式结果

小结

  • 基本类型会被装箱
  • null / undefined 特殊处理
  • JS 实现无法做到“完全无痕绑定”

1.4 让 call 支持传参 —— ES6 剩余参数

核心能力:

foo.call(obj, a, b, c)

实现:

Function.prototype.hycall = function (thisArg, ...args) {
  var fn = this

  thisArg =
    thisArg !== null && thisArg !== undefined
      ? Object(thisArg)
      : window

  thisArg.fn = fn
  var result = thisArg.fn(...args)
  delete thisArg.fn

  return result
}

示例:

function foo(num1, num2, num3) {
  console.log(this, num1 + num2 + num3)
}

foo.hycall("小吴", 500, 20, 1)

为什么 call 必须支持参数?

因为:

  • 每次函数调用都会创建新的执行上下文
  • 改变 this 必须在“那次调用”里完成

错误方式:

foo.call("why")
foo(500,20,1) // this 失效

这是典型“刻舟求剑”。


1.5 手写 apply

区别只在参数形式。

Function.prototype.myapply = function (thisArg, argArray) {
  var fn = this

  thisArg =
    thisArg !== null && thisArg !== undefined
      ? Object(thisArg)
      : window

  thisArg.fn = fn

  argArray = argArray || []
  var result = thisArg.fn(...argArray)

  delete thisArg.fn

  return result
}

关键差异:

  • call:参数列表
  • apply:数组

小结

  • apply 更适合参数本来就是数组的场景
  • 本质逻辑与 call 一致
  • 区别只是“参数结构”

1.6 手写 bind —— 真正的升级版

bind 解决什么问题?

延迟执行 + 参数预设

示例:

function foo(num1, num2, num3, num4) {
  console.log(this, num1, num2, num3, num4)
}

三种用法:

var bar = foo.bind("小吴", 10, 20, 30, 40)
bar()

var bar = foo.bind("小吴")
bar(10, 20, 30, 40)

var bar = foo.bind("小吴", 10, 20)
bar(30, 40)

实现思路

  • 第一次调用 bind:固定 this + 默认参数
  • 返回新函数
  • 第二次执行:合并参数再执行

实现:

Function.prototype.mybind = function (thisArg, ...argArray) {
  var fn = this

  thisArg =
    thisArg !== null && thisArg !== undefined
      ? Object(thisArg)
      : window

  function proxyFn(...args) {
    thisArg.fn = fn

    var finalArgs = [...argArray, ...args]
    var result = thisArg.fn(...finalArgs)

    delete thisArg.fn
    return result
  }

  return proxyFn
}

工程理解

  • call:一次性执行
  • bind:函数工厂
  • bind 本质是“柯里化雏形”

二、认识 arguments

2.1 arguments 是什么?

定义:

类数组对象

特征:

  • 有 length
  • 可索引访问
  • 没有数组原型方法

示例:

function foo() {
  console.log(arguments.length)
  console.log(arguments[1])
  console.log(arguments.callee)
}

foo(10, 20, 30, 40, 50)

2.2 arguments 转数组

三种方式:

Array.prototype.slice.call(arguments)
Array.from(arguments)
[...arguments]

为什么 slice + call 能工作?

我们手写一个 slice:

Array.prototype.hyslice = function (start, end) {
  var arr = this
  start = start || 0
  end = end || arr.length

  var newArray = []

  for (var i = start; i < end; i++) {
    newArray.push(arr[i])
  }

  return newArray
}

var newArray = Array.prototype.hyslice.call(
  ["小吴", "why", "JS高级"],
  1,
  3
)

本质:

强行把 arguments 当作数组的 this


2.3 箭头函数为什么没有 arguments?

箭头函数:

  • 不绑定 this
  • 不绑定 arguments
  • 继承上层作用域

示例:

function foo() {
  var bar = () => {
    console.log(arguments)
  }

  return bar
}

var fn = foo(123)
fn()

图10-5 arguments打印结果

设计目的:

  • 保持语法简洁
  • 强化词法作用域一致性
  • 鼓励使用 ...rest

三、工程层面的思考

3.1 call / apply / bind 的真实差异

能力 call apply bind
改变 this
立即执行
返回函数
参数预设

3.2 实战建议

什么时候用 call?

  • 立即执行
  • 已知完整参数
  • 做方法借用

什么时候用 apply?

  • 参数已经是数组
  • Math.max.apply

什么时候用 bind?

  • 事件回调绑定
  • React 组件方法绑定
  • 部分参数预设

四、复盘与团队落地建议

关键结论

  1. 显式绑定本质是利用隐式调用规则
  2. bind 是对 call 的延迟封装
  3. arguments 是历史产物,优先使用 rest
  4. 所有 this 问题本质都是“调用方式问题”

团队落地建议

  1. code review 中严格检查 this 丢失问题
  2. 优先使用箭头函数 + rest
  3. 对高阶函数封装做统一规范
  4. 面试训练时必须能手写实现

理解 API 不等于掌握它。

真正的掌握,是知道:

  • 它解决什么问题
  • 为什么这样设计
  • 在复杂业务里如何避免踩坑

当你可以自己实现一遍,你就真正站在了语言机制这一层,而不只是使用层。

探索JavaScript的秘密令牌:独一无二的`Symbol`数据类型

作者 Lee川
2026年3月5日 12:29

引言

在JavaScript的广阔世界中,数据类型构成了其最基础的语法元素。随着ES6的发布,这个大家庭迎来了两位新成员:BigIntSymbol。如果说BigInt是为了解决大数运算的精度问题,那么Symbol的诞生,则像是一把为对象属性开启“隐私空间”和“唯一命名”的神奇钥匙。本文将带你深入理解这个“独一无二”的简单数据类型。

一、认识Symbol:一种新的简单数据类型

JavaScript的八种数据类型,是每一位开发者的基本功,常被戏称为“七上八下”:

  • 简单数据类型 (7种)

    • 传统numberbooleanstringnullundefined
    • ES6新增bigintsymbol
  • 复杂数据类型 (1种)object

Symbol虽然用起来有点像构造函数Symbol()),但它本质上是简单数据类型。你可以通过typeof操作符来验证这一点。

// 1.js
const id1 = Symbol();
console.log(typeof id1); // 输出:symbol

二、Symbol的核心特性:绝对的独一无二

Symbol最核心、最迷人的特性,就是它的“独一无二性”。每次调用Symbol()函数,都会返回一个全新的、与其他任何Symbol都不同的值,即使它们拥有相同的描述(label)。

// 1.js
const id1 = Symbol();
const id2 = Symbol();
console.log(id1 === id2); // 输出:false

// 2.js
const s1 = Symbol('二哈');
const s2 = Symbol('二哈');
console.log(s1 === s2); // 输出:false

你可以为Symbol传入一个可选的字符串参数作为描述(label) ,例如Symbol('descrption')。这个描述仅仅是为了调试时方便识别,它不会影响Symbol的唯一性。两个描述相同的Symbol,依然是两个完全不同的值。这就像给两把不同的锁都贴上了“书房”的标签,但锁的齿纹(值)完全不同。

三、Symbol的核心应用:作为对象属性的唯一键

Symbol最主要、最实用的场景,就是作为对象的属性键(key) 。在ES6之前,对象的键只能是字符串,这在一个复杂、多人协作的代码库中极易引发命名冲突。

JavaScript是动态语言,任何人都可以轻松修改对象的属性。当项目代码庞大时,你可能会无意中覆盖掉他人定义的重要属性,或者自己的属性被他人覆盖,造成难以排查的Bug。

Symbol的引入,就是为了解决这个问题。用Symbol作为属性名,可以创造出绝对安全的、不会与任何字符串属性或其他Symbol属性冲突的私有属性

1. 如何定义Symbol属性?

你需要使用计算属性名的语法,在[]中写入Symbol变量。

// 2.js
const secretKey = Symbol('secret'); // 创建一个Symbol
console.log(secretKey, '//////'); // Symbol(secret) //////

const a = 'ecut';
const user = {
    [secretKey]: '111222', // 使用Symbol作为键
    email: '123456@qq.com',
    name: '张三',
    'a': '456', // 字符串'a'作为键
    [a]: '123'  // 使用变量a的值`'ecut'`作为键,相当于 `ecut: '123'`
};
console.log(user.ecut, user[a]); // 输出:123 123

2. Symbol属性的独特优势

  • 命名安全secretKey这个属性是独一无二的,全局任何地方都无法用[Symbol('secret')]以外的其他Symbol访问到它,也无法用字符串'secretKey'来访问,这避免了属性被意外覆盖。
  • 标签不影响唯一性:即使两个Symbol描述相同,它们作为键也是互不冲突的。
// 3.html
const classRoom = {
    [Symbol('Mark')]: {grade: 50, gender: 'male'},
    [Symbol('oliva')]: {grade: 80, gender: 'female'},
    // 即使标签(描述)和上面一样,这也是一个新的、独立的属性
    [Symbol('oliva')]: {grade: 85, gender: 'female'}, 
    "dl": ["张三","李四"]
};

上述代码中,第二个[Symbol('oliva')]并没有覆盖第一个,而是创建了一个全新的属性,完美解决了同名标签可能带来的冲突。

3. 枚举与遍历:Symbol的“隐藏”特性

Symbol属性还有一个重要特性:它们不会被常规的遍历方法枚举到。例如,for...in循环、Object.keys()Object.values()Object.entries()以及JSON.stringify()都会“忽略”Symbol属性。

// 3.html
for (const person in classRoom) {
    console.log(classRoom[person], '////'); // 只会打印出 "dl" 的值
}

这使得Symbol属性具备了一定的“私有”和“内置”属性特征,不会被轻易暴露出去。

如果你需要获取对象中所有的Symbol属性,必须使用专门的方法:

// 3.html
const syms = Object.getOwnPropertySymbols(classRoom); // 返回一个包含对象自身所有Symbol键的数组
console.log(syms); // 打印出 [Symbol(Mark), Symbol(oliva), Symbol(oliva)]

// 可以结合map方法获取这些属性的值
const data = syms.map(sym => classRoom[sym]);
console.log(data); // 打印出三个学生的对象数组

四、总结

Symbol是ES6为解决JavaScript长期存在的属性命名冲突和元编程问题而引入的一种优雅方案。它:

  1. 是简单数据类型,独一无二。
  2. 是创建对象唯一键的理想选择,尤其在多人协作和库的开发中,能有效保证属性安全。
  3. 具有“半隐藏”特性,不会被常规方法枚举,需用Object.getOwnPropertySymbols()获取。

掌握了Symbol,你就拥有了在JavaScript对象中创建“命名空间”和“内部插槽”的能力,让你的代码结构更清晰、更健壮。

从入门到精通:Vue3 ref vs reactive 最佳实践与底层原理

作者 QLuckyStar
2026年3月5日 10:50

在 Vue 3 中,ref 和 reactive 是 Composition API 提供的两个核心响应式 API,用于创建响应式状态。它们都基于 JavaScript 的 Proxy(reactive)和 getter/setter(ref 的内部机制)来实现响应式追踪,但在使用场景和行为上有一些关键区别。


1. ref

用途

  • 用于定义基本数据类型(如 stringnumberboolean)的响应式数据。
  • 也可以用于定义对象或数组,此时内部会自动调用 reactive
  • 在模板中使用时,无需 .value,Vue 会自动解包。
  • 在 JavaScript 中访问或修改时,必须通过 .value

示例

import { ref } from 'vue'

const count = ref(0)
console.log(count.value) // 0

count.value++
<template>
  <p>{{ count }}</p> <!-- 自动解包,无需 .value -->
  <button @click="count++">增加</button>
</template>

特点

  • 适用于任何类型。
  • 对于对象/数组,ref 内部会调用 reactive
  • 可以通过 ref() 创建对对象的响应式引用,保留其引用身份(identity)。

2. reactive

用途

  • 专门用于定义对象或数组类型的响应式数据。
  • 不能用于基本数据类型(如 numberstring)。
  • 返回的是一个代理对象(Proxy),直接操作其属性即可,不需要 .value

示例

import { reactive } from 'vue'

const state = reactive({
  count: 0,
  user: { name: 'Alice' }
})

state.count++
state.user.name = 'Bob'
<template>
  <p>{{ state.count }}</p>
  <p>{{ state.user.name }}</p>
</template>

特点

  • 只能用于对象/数组。

  • 返回的是原始对象的代理,无法替换整个对象(否则失去响应性)。

    // ❌ 错误做法:直接赋值新对象会丢失响应性
    state = { count: 1 } 
    
    // ✅ 正确做法:修改属性
    state.count = 1
    

对比总结

特性 ref reactive
支持类型 任意类型(基本类型 + 对象/数组) 仅对象或数组
访问方式(JS 中) 需 .value 直接访问属性
模板中使用 自动解包,无需 .value 直接访问
替换整个对象 可以(重新赋值 .value 不可以(会丢失响应性)
内部实现 基本类型用 getter/setter;对象用 reactive 基于 Proxy
适用场景 简单值、需要替换整个对象的情况 复杂对象状态管理

最佳实践建议

  • 优先使用 ref:尤其在 TypeScript 项目中,ref 的类型推断更直观,且统一使用 .value 有助于代码一致性。
  • 当你有一个复杂的对象状态,并且不需要替换整个对象时,可以使用 reactive
  • 避免混用导致困惑。例如,不要在一个 reactive 对象中嵌套 ref 除非必要(虽然 Vue 会自动解包,但可能影响可读性)。

补充:toRefs 和 toRef

当你从 reactive 对象中解构属性时,会丢失响应性。此时可使用 toRefs 或 toRef

import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0 })
const { count } = toRefs(state) // count 是一个 ref

count.value++ // 仍然响应式

别再用 scoped 了!Vue 项目中真正安全的 CSS 封装方案,第 3 种连尤雨溪都在用

作者 前端Hardy
2026年3月3日 10:31

上周,设计师跑来问我:“为什么这个按钮在 A 页面是蓝色,在 B 页面变成紫色了?”

我一查代码,发现两个组件都写了:

.btn {
  background: blue;
}

<style scoped> 根本没生效——因为某个第三方 UI 库用了 :global(.btn),污染了全局。

那一刻我悟了:scoped 不是银弹,它只是“看起来安全”。

今天,我就带你盘点 Vue 项目中 4 种真正可靠的 CSS 封装方案,从“能用”到“企业级”,尤其第 3 种,连 Vue 官方文档和 Vite 团队都在悄悄推广。


先看一张对比表(建议收藏)

方案 隔离性 可维护性 支持动态主题 学习成本
<style scoped> ⚠️ 中(会被 :global 破坏) 低(命名仍可能冲突) ❌ 难
CSS Modules ✅ 强 ⚠️ 需额外处理
CSS-in-JS(如 Vanilla Extract) ✅✅ 极强 ✅ 原生支持 中高
CSS 变量 + 作用域类名(推荐!) ✅ 强 ✅✅ 极高 ✅✅ 天然支持

核心原则:隔离靠机制,不是靠“看起来不一样”


方案 1:<style scoped> —— 谨慎使用!

Vue 的 scoped 通过给元素加 data-v-xxxx 属性实现样式隔离:

<template>
  <button class="btn">Click</button>
</template>

<style scoped>
.btn { color: red; } /* 编译后 → .btn[data-v-f3f3eg9] */
</style>

致命缺陷

  • 无法防止 全局样式污染(比如 reset.css 或 UI 库)
  • 深度选择器>>>:deep())容易误伤其他组件
  • 动态插入的 HTML(如富文本)无法应用 scoped 样式

适用场景:内部工具、小型页面、快速原型

不要用在:对外组件库、多团队协作项目、需要主题切换的系统


方案 2:CSS Modules —— 经典但略重

启用后,每个 class 会被哈希化:

// Button.module.css
.primary { background: blue; }

// Button.vue
import styles from './Button.module.css';
// styles.primary → "Button_primary__aB3cD"
<template>
  <button :class="styles.primary">OK</button>
</template>

优点:

  • 100% 隔离,不怕任何全局污染
  • 支持组合(composes

缺点:

  • 模板里写 :class="styles.xxx" 略啰嗦
  • 不支持原生 CSS 嵌套(除非配合 PostCSS)
  • 动态主题需配合 JS 重新生成

在 Vite 中开启:

// vite.config.ts
export default defineConfig({
  css: { modules: { localsConvention: 'camelCase' } }
})

方案 3:CSS 变量 + 作用域类名(尤雨溪团队推荐!)

这是 Vue 官方新文档Vite 插件生态 中越来越主流的做法。

核心思想:用 CSS 变量定义设计 token,用唯一类名包裹组件

<template>
  <div class="my-button--root">
    <button class="my-button--inner">Submit</button>
  </div>
</template>

<style>
.my-button--root {
  /* 定义局部变量 */
  --btn-bg: var(--theme-primary, #3b82f6);
  --btn-color: white;
}

.my-button--inner {
  background: var(--btn-bg);
  color: var(--btn-color);
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
}
</style>

神奇在哪?

  1. 天然支持主题切换
/* 全局定义亮色主题 */
:root {
  --theme-primary: #3b82f6;
}
/* 暗色主题 */
.dark {
  --theme-primary: #60a5fa;
}

只需切换 <html class="dark">,所有组件自动适配!

  1. 无构建时哈希,调试友好
  2. 类名前缀化(如 my-button--)避免冲突,比随机 hash 更语义化

这正是 ShadCN VueRadix Vue 等现代组件库的做法。


方案 4:零运行时 CSS-in-JS(Vanilla Extract)

如果你追求极致工程化,试试 编译时 CSS-in-JS

// Button.css.ts
import { style } from '@vanilla-extract/css';

export const root = style({
  vars: {
    '--btn-bg': '#3b82f6'
  }
});

export const inner = style({
  background: 'var(--btn-bg)',
  color: 'white',
  borderRadius: 4,
  selectors: {
    '&:hover': { opacity: 0.9 }
  }
});
<script setup lang="ts">
import * as styles from './Button.css';
</script>

<template>
  <div :class="styles.root">
    <button :class="styles.inner">OK</button>
  </div>
</template>

优势:

  • 100% 类型安全(TS 直接提示拼写错误)
  • 零运行时(编译成静态 CSS 文件)
  • 自动作用域(生成哈希类名)
  • 支持主题变量、条件样式

配合 Vite 插件 @vanilla-extract/vite-plugin 即可使用。


实战建议:怎么选?

项目类型 推荐方案
内部后台系统 CSS 变量 + 作用域类名(方案 3)
对外组件库 CSS 变量 + 作用域类名 or Vanilla Extract
快速原型 scoped(但警惕全局污染)
超大型应用(含多主题/国际化) Vanilla Extract(方案 4)

永远不要:

  • 在 scoped 中大量使用 :deep()
  • 把业务样式写进全局 app.css
  • 用 BEM 命名试图“人工隔离”(治标不治本)

各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

从平面到空间:用 React Three Fiber 构建 3D 产品网格

2026年3月3日 10:00

原文:From Flat to Spatial: Creating a 3D Product Grid with React Three Fiber

翻译:TUARAN

欢迎关注 {{前端周刊}},每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

一篇实用的实战讲解:使用 React Three Fiber 和 GLSL 构建一个弯曲的 3D 商品网格,涵盖着色器、动画与性能。

作者:Matt Greenberg 分类:Tutorials 日期:2026 年 2 月 24 日

Demo

Code

免费课程推荐:通过 34 节免费视频课、循序渐进的项目以及可上手的演示,用 GSAP 精通 JavaScript 动画。立即报名 →

商品网格就像电商里的“白盒画廊”——默认中性,设计上尽量不冒犯任何人。奇怪的是,真正能推动产品销售的线下体验一直都知道:环境本身就是销售的一部分。光线会替你做决定。陈列传达价值。空间本身也有立场。

而网页版通常会把这些全部放弃。

我想看看要怎样才能缩小这道差距——不是为了新奇噱头,而是一次真正的尝试:让浏览商品的感觉更像“身处某个地方”。这篇文章会带你走完我构建它的过程:用 React Three Fiber 做一个弯曲的 3D 商品网格,配上地形图式的 GLSL 背景、全息风格的选中态,以及带弹簧阻尼的相机控制架构。过程中也会提到一些值得借鉴的模式——包括着色器架构、动画如何做到可打断,以及如何划分 React state 和可变 refs 的边界。

技术栈(The Stack)

这个项目使用的技术栈是 Next.jsReact Three FiberTailwindMotion。两个自定义着色器用 GLSL 编写,并通过 glslify 的 webpack 流水线作为 ES 模块导入。

这里值得单独强调一下 glslify 的配置,因为它是让着色器开发变得“现代化”的关键基础设施。在 next.config.mjs 里用两个 loader 串起来,就可以在 GLSL 内部写 #pragma glslify: snoise = require(&#039;glsl-noise/simplex/2d&#039;),并把编译后的结果作为字符串导入。

架构(Architecture)

整个系统分为四层;搞清楚每一层的起止边界,是保持项目整洁的关键:

┌─────────────────────────────────────────────────┐ │  DOM Layer (Framer Motion)                      │ │  Control bar, filters, minimap, overlays        │ ├─────────────────────────────────────────────────┤ │  Scene Layer (React Three Fiber)                │ │  Canvas, camera rig, lighting                   │ ├─────────────────────────────────────────────────┤ │  Tile Layer (per-card useFrame loops)           │ │  Position, scale, opacity, shader uniforms      │ ├─────────────────────────────────────────────────┤ │  Shader Layer (raw GLSL)                        │ │  Topography background, holographic card sheen  │ └─────────────────────────────────────────────────┘

数据流(Data flow)。 鞋子数据是一个 JSON 数组。每个集合(Nike、New Balance、Budget)都会映射到一个独立数组。筛选(filters)是在某个集合内部缩小范围;切换集合(collection switches)则是直接替换整个数组。

交互循环(Interaction loop)。 画布上的指针事件会更新一个可变的 rigState 对象。相机控制架构(camera rig)每帧读取它,并以阻尼方式向目标值收敛。每个 tile 也读取同一个 rigState 来判断自己是否被选中,然后调整自己的位置、缩放以及着色器的 uniforms。

影响一切的决策,是哪些东西放进 React state、哪些东西用可变 refs 来保存。我是吃过亏才学到的:任何以 60fps 变化的东西——相机位置、tile 的动画进度、着色器 uniforms——都不能放在 React state 里。调和(reconciliation)的开销会把你拖垮。这些值应该放在普通的可变对象里,让 useFrame 回调直接读取。React state 只留给离散的用户行为:当前激活的是哪个集合、设置了哪些筛选条件、选中了哪个 tile。

网格系统(The Grid)

第一个问题是布局。我需要把一份平铺的鞋子列表,排列成 3D 空间中居中的网格,并且要足够灵活,以支持筛选(会改变项目数量)和集合切换(会把一切都换掉)。

Configuration

所有网格参数都放在一个可变的单例里——不是 React state,也不是 context,只是一个普通对象:

const CONFIG = {
  gridCols: 8,
  itemSize: 2.5,
  gap: 0.4,
  zoomIn: 12,
  zoomOut: 31,
  curvatureStrength: 0.06,
  dampFactor: 0.2,
  tiltFactor: 0.08,
  cullDistance: 14,
};

开发期间,我把每一个值都接进了 Leva 的调试控制面板。拖动一个“curvature(曲率)”滑块,看着网格的“碗”形实时加深,对于调出理想手感非常有价值——这是用写死的常量加上不断刷新页面的方式根本做不到的。

Positioning

Tile 的位置通过简单的“按列优先(column-major)”数学计算得到,并以原点为中心:

const spacing = CONFIG.itemSize + CONFIG.gap;
const col = filteredIdx % CONFIG.gridCols;
const row = Math.floor(filteredIdx / CONFIG.gridCols);
const x = col * spacing - gridWidth / 2 + spacing / 2;
const y = -(row * spacing) + gridHeight / 2 - spacing / 2;

X 轴从左到右。Y 轴从上到下。Z 轴则完全留给深度效果——曲率、聚焦以及过渡动画。让 Z 保持“空闲”,事实证明是我早期做过的更好决策之一:这意味着我可以把多个深度效果用叠加的方式组合起来,而不会互相打架。

卡片系统(The Cards)

每只鞋都是一个 ShoeTile —— 一个 <group>,里面包含用于命中测试的平面、带有我们自定义 Shader 材质的图片网格、文字标签以及关闭按钮。

纹理(Textures)

我会在模块级别预加载所有纹理,确保在任何组件挂载之前就完成。这一点没有商量余地——否则在切换集合时,会出现明显的“跳出/突现”(pop-in):纹理会一张张上传到 GPU,导致画面逐个补齐。

shoes.forEach((shoe) => {
  useTexture.preload(shoe.image_url);
});

每个 tile 都会基于已加载的纹理计算符合宽高比的尺寸,因此图片永远不会被拉伸变形。

动画循环(The Animation Loop)

这是整个项目的核心。每个 tile 都运行自己的 useFrame 回调——一个每帧都会执行的函数,用来管理一组动画值,这些值组合起来构成最终的渲染状态。

我一开始试过 GSAP,后来放弃了。问题在于“可中断性”。如果用户在筛选过渡进行到一半时点击某只鞋,那么所有动画都需要平滑地改道。基于时间线(timeline)的系统会和这种需求对着干——你会花更多时间处理取消与清理,而不是写动画逻辑。CSS 动画从来就不是选项;它们无法深入到 WebGL 的 uniform。

最终我选择了非常棒的 maath 里的 easing.damp()——一个与帧率无关的指数阻尼函数。你设置一个目标值,当前值就会追过去;你在动画中途改目标,它就会立刻改道继续追。无需清理,无需取消。

const focusZ = useRef(0);
const curveZ = useRef(0);
const transitionZ = useRef(0);
const animatedPos = useRef({ x, y });
const filterOpacity = useRef(1);
const filterScale = useRef(1);

最终位置由这些相互独立的通道叠加而成:

ref.current.position.set(
  x,
  y + transitionY.current,
  curveZ.current + focusZ.current + transitionZ.current
);

三个 Z 向的贡献是加法叠加的:曲率把远处的 tile 推得更远,聚焦效果把选中的卡片向前“弹出”,过渡偏移负责处理进入/退出。它们各自以不同速度阻尼收敛。由于只是简单相加,因此永远不会相互冲突。

自定义 Shaders(Custom Shaders)

我使用 drei 的 shaderMaterial() 辅助方法写了两个自定义 GLSL 材质。它会给你一个声明式的 JSX 接口(<holoCardMaterial />),背后则由原生 GLSL 驱动。

我选择“按材质写 Shader”,而不是做后期处理(post-processing),原因很明确:我的效果是交互驱动、并且是按卡片(per-card)生效的。全息光泽只会出现在被选中的卡片上;如果用后期 bloom pass,就得处理屏幕上的每个像素,只为了影响一张卡。把效果放在材质里意味着对另外 59 张卡完全没有额外开销。

地形背景(Topography Background)

背景是一个带动画的等高线场——一张“活的”地形图,为场景提供技术感、类似 CAD 的空间深度,但又不会与鞋子的图像争抢注意力。

等高线如何工作(How the Isolines Work)

片元着色器会采样 2D simplex noise(通过 glslify 引入),并让它随时间缓慢漂移:

#pragma glslify: snoise = require('glsl-noise/simplex/2d')
float n = snoise(noiseUv * uScale + uTime * 0.05);

等高线来自一种经典的 isoline 提取技巧:把噪声乘以一个频率,取小数部分来生成重复的条带,然后用一对 smoothstep 在条带边界处雕刻出细线:

float lines = fract(n * 5.0);
float pattern = smoothstep(0.5 - uLineThickness, 0.5, lines)
              - smoothstep(0.5, 0.5 + uLineThickness, lines);

这两个 smoothstep 会在 0.5 处制造一个很窄的峰值——也就是每个条带“回卷”(wrap around)的边界位置。uLineThickness(默认 0.03)控制线宽;5.0 的倍数控制每个噪声 octave 中出现多少圈同心环。我花了不少时间调这些参数——太粗会像加载中的转圈 spinner,太细则在低 DPI 屏幕上几乎看不见。

遮罩与颗粒(Masking and Grain)

一个圆形 mask 让边缘柔和渐隐,胶片颗粒(film grain)则用来防止色带(banding):

float grain = (fract(sin(dot(vUv * 2.0, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) * 0.15;
vec3 finalColor = uColor + grain;
gl_FragColor = vec4(finalColor, pattern * opacity * mask * uOpacity);

整体放在 Z = -15 的平面上,并设置 depthWrite={false}renderOrder={-1},确保它永远不会遮挡卡片。当用户缩放进入某只鞋时,uOpacity 会淡出到 0.25——背景后退但不会消失。

全息卡片材质(Holographic Card Material)

当卡片被选中时,这个材质会添加一道扫过的全息光泽(holographic sheen)。这是我写得最开心的 Shader,因为整个效果完全由一个 uniform 驱动:uActive

顶点“呼吸”(Vertex Breathing)

顶点着色器会对选中的卡片施加轻微的正弦缩放振荡:

float breath = sin(uTime * 2.0) * 0.015 * uActive;
float scale = 1.0 + breath;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos * scale, 1.0);

uActive 为 0 时,呼吸量会被乘到 0——未选中的卡片不会做任何额外工作。

光泽扫过(The Sheen Sweep)

片元着色器里的光泽效果算是个“意外之喜”。我最初想要的是静态的全息渐变,但把光泽位置直接映射到 uActive 后,就免费得到了这种扫过动画——当 uniform 从 0 动到 1 时,这条光带会自然地滑过整张卡片:

float diagonal = (vUv.x * 0.8) + vUv.y;
float sheenPos = uActive * 2.5;
float sheenWidth = 0.5;
float dist = abs(diagonal - sheenPos);
float intensity = 1.0 - smoothstep(0.0, sheenWidth, dist);
intensity = pow(intensity, 3.0);

X 轴上的 0.8 倍数是“倾斜”(tilt)因子。在标准的 x+yx + y 设定里,渐变会以完美的 45° 角移动。通过让 X 轴的权重略小于 Y 轴,我们把扫光线旋转得更接近竖直方向,这更符合“卡片拿在光源下”的直觉。

pow(intensity, 3.0) 则是我们的“聚焦”(focus)控制。没有它时,光泽会变成一大片宽而浑的泛光。把强度提升到一个幂次,会把较低的值压向 0,只保留峰值,从而让衰减更锐利:从柔和的光晕变成更集中的高光(specular)条纹。

末尾的淡出可以防止光泽停住不走:

float sheenFade = 1.0 - smoothstep(0.7, 1.0, uActive);
vec3 sheenColor = vec3(0.85, 0.92, 1.0) * intensity * 0.9 * sheenFade;
vec3 finalColor = baseColor + sheenColor * texColor.a;

这种偏冷的蓝白色高光采用“叠加”的方式,并通过纹理的 alpha 进行遮罩,以确保效果始终限制在鞋子的轮廓之内。

非对称时序(Asymmetric Timing)

有一个小细节却带来了很大的差异:我在做选中与取消选中时,用不同的阻尼速度来动画 uActive

const activeDamp = isActive ? 0.6 : 0.15;
easing.damp(imageRef.current.material, "uActive", isActive ? 1 : 0, activeDamp, delta);

慢慢进入(0.6s),快速退出(0.15s)。你可以细细品味“显现”的过程,但永远不需要等待“收起”。这种不对称非常微妙,用户不会有意识地察觉到它;但一旦去掉,整个交互就会显得拖沓。

相机 Rig(The Camera Rig)

我从零开始写了一个自定义相机 rig,而不是使用 drei 的 OrbitControls。OrbitControls 提供的是围绕中心点旋转的相机轨道——而我需要的是一个 2D 平移相机:带有边界限制的拖拽、橡皮筋式边缘回弹,以及基于速度的倾斜效果。OrbitControls 里的每一条约束都会和我的需求“对着干”。

工作原理(How It Works)

这个 rig 是一个可变的单例状态,在相机组件与每一个 tile 之间共享:

const rigState = {
  target: new THREE.Vector3(0, 2, 0),
  current: new THREE.Vector3(0, 2, 0),
  velocity: new THREE.Vector3(0, 0, 0),
  zoom: CONFIG.zoomOut,
  isDragging: false,
  activeId: null,
};

指针事件会更新 target。每一帧里,current 会以阻尼方式向 target 靠拢。相机读取的是 current。这种“间接层”正是让一切感觉顺滑的原因——用户输入从来不会被直接应用到相机上。

拖拽与边界(Drag and Bounds)

我用一个距离阈值来区分点击与拖拽(桌面端 5px,触屏 15px)。拖拽灵敏度会随相机距离缩放,从而让平移在任何缩放级别下都保持一致的手感。

当拖过网格边缘时,会触发橡皮筋式阻力——你可以继续超拖 25%,之后才会被硬性夹住。松手后,相机会回弹到边界内。这和 iOS 的滚动回弹是同一种模式:它能在不“硬停”的情况下传达“你到边缘了”。

选中(Selection)

点击某个 tile 会同时触发平移与缩放。被选中的卡片会缩放到 1.5 倍,并在 Z 轴上向前弹出 2 个单位。其它所有卡片会缩小到 0.5 倍,并淡出到 15% 的不透明度——一种非常戏剧化的聚光灯效果。

筛选与集合切换(Filtering and Collection Switching)

这个应用支持两类过渡动画。有意思的是,它们需要完全不同的策略来实现。

原地筛选(In-Place Filtering)

当你在同一个集合内筛选(比如从 “All” 到 “Jordan”)时,我不会卸载再重新挂载这些 tile。那会导致纹理重新上传,而这意味着掉帧。相反,我让匹配的条目平滑地重新排布以填满更密的网格;不匹配的条目则在原地淡出并缩小:

easing.damp(animatedPos.current, "x", basePos.x, 0.2, delta);
easing.damp(animatedPos.current, "y", basePos.y, 0.2, delta);
const targetFilterOpacity = matchesFilter ? 1 : 0;
const targetFilterScale = matchesFilter ? 1 : 0.5;
easing.damp(filterOpacity, "current", targetFilterOpacity, 0.06, delta);

被隐藏的 tile 仍然保持挂载,但不可见——当不透明度低于 0.01 时,将 visible = false。这意味着筛选变化可以做到瞬时响应:没有额外的 GPU 工作,只有 uniform 的变化与位置的重新计算。

集合切换(Collection Switching)

切换集合是更重的操作——鞋子数据完全不同。我用“图层堆栈”的方式解决:旧网格与新网格会短暂共存,各自作为独立组件渲染,并拥有唯一的 React key。

const handleCollectionSwitch = (index) => {
  setGridLayers((prev) => {
    const exitingLayers = prev.map((layer) =>
      layer.mode === "enter"
        ? { ...layer, mode: "exit", startTime: now }
        : layer
    );
    const newLayer = {
      id: `grid-${index}-${now}`,
      items: collectionsData[index],
      mode: "enter",
      startTime: now,
    };
    return [...exitingLayers, newLayer];
  });
  setTimeout(() => {
    setGridLayers((prev) => prev.filter((l) => l.mode === "enter"));
  }, CONFIG.cleanupTimeout);
};

旧网格会朝相机飞来(Z +20),而新网格会从后方进入(Z -50)。每个 tile 都会获得一个随机的错峰延迟。这样读起来更像“爆散”而不是“平移”——这是刻意为之。单纯的交叉淡入淡出会显得很平。Z 轴上的运动带来真实的空间感,而随机错峰则避免了同步运动带来的机械感。

进入的新 tile 还会根据它们在网格中的位置,在 Y 轴上做“散开”:上方的条目从更高的位置开始,下方的条目从更低的位置开始——营造一种“从四面八方汇聚”的感觉。

打磨(Polish)

Dynamic Island(灵动岛)

底部控制栏借鉴了 Apple 的 Dynamic Island 模式:一个单一的玻璃拟态容器,在不同状态之间形变切换。我用的是 Framer Motion 的 layout 属性,因为它能处理 CSS 做不到的一件事——在完全不同的 DOM 结构之间进行动画过渡。

迷你地图(MiniMap)

一个 2D 的 <canvas> 覆盖层会运行自己独立的 requestAnimationFrame 循环,不依赖 R3F。每双鞋用一个点表示,被选中的鞋会发出金色光晕,而一个白色矩形表示当前可见视口。选中时,迷你地图会围绕激活的点平滑缩放到 2.5 倍。

性能(Performance)

有三种技术让我们保持在 60fps:

分片挂载(Time-sliced mounting)。 一次性挂载 60 张带纹理的卡片会造成 GPU 峰值。我改为每帧挂载 5 张,把工作分摊到约 200ms 内。快到让人无感,又慢到足以避免卡顿。我在这里没法用 InstancedMesh——因为每张卡片都有独一无二的纹理、独一无二的标签,以及独一无二的 shader 状态。实例化需要共享材质。



**三级剔除(Three-level culling)。** 每个 tile 都会做三层检查:是否已经完全退出?(直接跳过整个 `useFrame` 回调。)是否超出了视距?(把它隐藏。)它的透明度是否接近 0?(`visible = false`。)这些检查是叠加生效的——一旦某个 tile 在切换集合时已经退出,它就会跳过所有逐帧工作,而不只是跳过渲染。


**一切皆可变(Mutable everything)。** 相机位置、tile 的动画引用、着色器 uniforms ——都在 `useFrame` 里直接做可变更新,从不触碰 React state。唯一会触发重新渲染的时刻,是一些离散的用户操作:选择某个 tile、改变筛选条件、切换集合。


## 结语(Conclusion)


如果要把整个项目浓缩成一句话,那就是:难点不在 3D。难点在于让 3D 消失。没人应该看着这个就觉得“哦,一个 WebGL demo。”他们只该觉得:逛鞋子这件事,比平时稍微有趣了一点。


让我达到这个效果的那些模式——用指数阻尼替代 tween、用逐材质(per-material)着色器替代后期处理、对所有会动的东西都用可变 ref 而不是 React state——并不是什么特别稀奇的技巧。当你不再把 React Three Fiber 当作 demo 框架,而是把它当作生产级框架来对待时,这些选择会很自然地“长出来”。我在这个项目上花的大部分时间并不是在写着色器,而是在调阻尼常量、干掉不必要的重新渲染,并确保在动画进行到一半时改了筛选条件,不会把别的东西弄坏。


如果你在做类似的东西,直接抄这个架构:React 负责结构,GLSL 负责像素,一层很薄的可变状态把两者在 60fps 下桥接起来。其他一切,都是品味问题。

Vite项目中的SVG雪碧图

作者 NB_R
2026年3月4日 16:19

在当前前端开发过程中,图标管理痛点问题解决办法:

1、字体图标(如iconfont)虽然方便,但在高清屏幕下容易模糊,且颜色单一;

2、而直接使用<img>加载多个SVG文件又会带来大量HTTP请求。

3、SVG雪碧图结合了两者的优点:既能保持矢量清晰度,又能合并请求,还可以通过CSS灵活控制颜色。

4、以下介绍如何在Vite项目中使用vite-plugin-svg-icons插件,优雅地实现SVG雪碧图。

什么是SVG雪碧图?

SVG雪碧图的核心思想是将多个SVG图标合并到一个文件中,每个图标用一个<symbol>元素定义,并赋予唯一的id。使用时,通过<use xlink:href="#icon-id">来引用对应的图标。这样做不仅减少了网络请求,而且所有图标都以矢量形式存在,无论放大多少倍都清晰锐利。

一个典型的雪碧图文件结构如下:

xml

<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
  <symbol id="icon-home" viewBox="0 0 24 24">
    <path d="..."/>
  </symbol>
  <symbol id="icon-user" viewBox="0 0 24 24">
    <path d="..."/>
  </symbol>
</svg>

然后在页面中引用:

html

<svg><use xlink:href="#icon-home"/></svg>

为什么选择 vite-plugin-svg-icons?

手动维护雪碧图不仅繁琐,而且容易出错。vite-plugin-svg-icons是专为Vite设计的插件,它可以:

  • 自动扫描指定文件夹中的所有SVG文件,并生成雪碧图;
  • 开发环境实时更新,新增或修改图标无需重启服务;
  • 支持SVGO优化,压缩图标体积;
  • 可自定义symbolId格式,方便在代码中引用;
  • 与Vite完美集成,无需额外配置loader。

原理简述

插件的核心逻辑是在构建时(或开发服务器启动时)读取iconDirs目录下的所有SVG文件,将它们转换为<symbol>片段,并组合成一个大的<svg>元素。在开发模式下,这个<svg>会通过一个虚拟模块动态注入到DOM中;生产构建时,则会将雪碧图打包到最终的assets目录,并通过入口文件中的注册代码自动注入。

快速上手

1. 安装插件

在Vite项目根目录执行:

bash

npm install -D vite-plugin-svg-icons
# 或
yarn add -D vite-plugin-svg-icons

2. 配置 vite.config.js

打开vite.config.js,引入插件并配置:

javascript

import { defineConfig } from 'vite'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import path from 'path'

export default defineConfig({
  plugins: [
    // ... 其他插件
    createSvgIconsPlugin({
      // 指定要缓存的图标文件夹,即存放SVG文件的目录
      iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
      // 指定symbolId格式,这里设置为 'icon-文件名',后面使用 <use href="#icon-xxx"/>
      symbolId: 'icon-[name]',
      // 可选:SVGO压缩配置
      svgoOptions: {
        plugins: [
          // 例如,移除所有图标的fill属性,以便通过CSS控制颜色
          { name: 'removeAttrs', params: { attrs: 'fill' } }
        ]
      }
    })
  ]
})

注意iconDirs路径必须正确指向你的图标文件夹。建议将所有SVG图标存放在src/assets/icons下。

3. 在入口文件中注册插件

在项目的入口文件(如src/main.jssrc/main.ts)中添加一行特殊的导入语句,这行代码会触发插件的运行时逻辑,将雪碧图注入到页面中。

javascript

import { createApp } from 'vue'
import App from './App.vue'
import 'virtual:svg-icons-register'  // 关键!注入雪碧图

createApp(App).mount('#app')

virtual:svg-icons-register是一个由插件提供的虚拟模块,它会在DOM中创建一个隐藏的<svg>元素,包含所有图标的<symbol>定义。

4. 封装一个通用的 SvgIcon 组件

为了使用方便,我们通常封装一个可复用的图标组件。以Vue 3为例,创建一个SvgIcon.vue文件:

vue

<!-- src/components/SvgIcon.vue -->
<template>
  <svg
    aria-hidden="true"
    :class="['svg-icon', $attrs.class]"
    :style="customStyle"
  >
    <use :xlink:href="iconName" />
  </svg>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  name: {
    type: String,
    required: true
  },
  color: {
    type: String,
    default: 'currentColor'
  },
  size: {
    type: [Number, String],
    default: '1em'
  }
})

const iconName = computed(() => `#icon-${props.name}`)
const customStyle = computed(() => ({
  width: typeof props.size === 'number' ? `${props.size}px` : props.size,
  height: typeof props.size === 'number' ? `${props.size}px` : props.size,
  color: props.color
}))
</script>

<style scoped>
.svg-icon {
  vertical-align: -0.15em;  /* 与文字对齐 */
  fill: currentColor;        /* 继承颜色 */
  overflow: hidden;
}
</style>

5. 全局注册组件(可选)

为了让组件在项目中随处可用,可以在入口文件中全局注册:

javascript

// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import 'virtual:svg-icons-register'
import SvgIcon from '@/components/SvgIcon.vue'

const app = createApp(App)
app.component('SvgIcon', SvgIcon)
app.mount('#app')

6. 在页面中使用图标

现在,只需将SVG文件放入src/assets/icons目录,例如home.svguser.svg,然后在任意Vue组件中:

vue

<template>
  <div>
    <!-- 基础用法,使用 home.svg 图标,大小1em,颜色默认 -->
    <SvgIcon name="home" />

    <!-- 自定义颜色和大小 -->
    <SvgIcon name="user" color="#ff6b6b" size="32" />

    <!-- 动态绑定 -->
    <SvgIcon :name="isActive ? 'star-filled' : 'star'" />
  </div>
</template>

最终渲染时,插件会自动生成类似下面的DOM结构:

html

<svg id="__svg__icons__dom__" style="display: none;">
  <symbol id="icon-home" viewBox="...">...</symbol>
  <symbol id="icon-user" viewBox="...">...</symbol>
</svg>

然后<SvgIcon name="home">会生成<use href="#icon-home">,从而显示对应图标。

进阶配置

自定义 symbolId 格式

symbolId选项支持多种占位符:

  • [name]:文件名(不含扩展名)
  • [dir]:相对于iconDirs的目录路径
  • [path]:完整相对路径

例如:symbolId: 'icon-[dir]-[name]',如果你的文件结构是src/assets/icons/common/logo.svg,生成的id就是icon-common-logo

SVG 压缩与优化

通过svgoOptions可以自定义SVGO配置。例如,移除fill属性以允许通过CSS控制颜色:

javascript

svgoOptions: {
  plugins: [
    { name: 'removeAttrs', params: { attrs: 'fill' } }
  ]
}

你也可以完全自定义SVGO配置,详情参考SVGO文档

常见问题与解决

1. 图标不显示?

  • 检查iconDirs路径是否正确,确保SVG文件确实存放在该目录下。
  • 确认在入口文件中已导入'virtual:svg-icons-register'
  • 检查生成的symbolId是否与<use>中的href一致。可以在浏览器开发者工具中查看隐藏的<svg>元素,确认是否有对应的<symbol>存在。

2. 图标颜色无法改变?

SVG文件本身可能自带了fillstroke属性。可以通过SVGO的removeAttrs插件移除这些属性,或者在封装组件时通过CSS覆盖:

css

.svg-icon {
  fill: currentColor;
}

同时在vite.config.js中配置removeAttrs插件(如上所示),以确保图标不包含固定颜色。

参考资料:

CommonJS 与 ES6 模块引入的区别详解

作者 大知闲闲i
2026年3月4日 14:33

随着 JavaScript 的发展,模块化编程已经成为现代前端开发的基础。目前主流的模块系统有两种:CommonJS 和 ES6 模块。本文将详细对比这两种模块系统的语法、特性和使用场景。

一、CommonJS 模块系统

CommonJS 最初是为了让 JavaScript 能在服务端(如 Node.js)运行而设计的模块规范。

1. 基本语法

导出模块

// 方式一:直接导出单个值
// moduleA.js
const name = 'John';
module.exports = name;

// 方式二:导出一个对象
// person.js
const person = { 
  name: 'John', 
  age: 30,
  greet() {
    console.log(`Hello, I'm ${this.name}`);
  }
};
module.exports = person;

// 方式三:使用 exports 快捷方式
// utils.js
exports.add = (a, b) => a + b;
exports.subtract = (a, b) => a - b;
// 等价于:
// module.exports = { add, subtract }

引入模块

// main.js
// 引入单个值
const name = require('./moduleA');
console.log(name); // 'John'

// 引入对象
const person = require('./person');
console.log(person.name); // 'John'
console.log(person.age);  // 30
person.greet(); // "Hello, I'm John"

// 引入工具函数
const utils = require('./utils');
console.log(utils.add(5, 3)); // 8

2. 核心特性

动态引入

CommonJS 允许在代码运行时动态加载模块:

// 可以根据条件动态引入
if (process.env.NODE_ENV === 'development') {
  const debugModule = require('./debug');
  debugModule.enable();
}

// 可以在函数内部引入
function loadModule(moduleName) {
  return require(`./modules/${moduleName}`);
}

// 可以在循环中引入
const modules = ['moduleA', 'moduleB', 'moduleC'];
modules.forEach(name => {
  const module = require(`./${name}`);
  module.init();
});

同步加载

CommonJS 的模块加载是同步的:

// 同步加载,代码会等待模块加载完成
const fs = require('fs');        // 核心模块
const express = require('express'); // 第三方模块
const myModule = require('./my-module'); // 本地模块

console.log('模块加载完成,继续执行');

值的拷贝

CommonJS 导出的是值的拷贝:

// counter.js
let count = 0;
module.exports = {
  count,
  increment() {
    count += 1;
  },
  getCount() {
    return count;
  }
};

// main.js
const counter = require('./counter');
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 0 (仍然是0,因为 count 是原始值的拷贝)
console.log(counter.getCount()); // 1 (需要通过方法获取最新值)

二、ES6 模块系统

ES6 模块是 ECMAScript 2015 中引入的官方模块规范,现已被现代浏览器和 Node.js 支持。

1. 基本语法

导出模块

// 方式一:命名导出(逐个导出)
// person.js
export const name = 'John';
export const age = 30;
export function greet() {
  console.log(`Hello, I'm ${this.name}`);
}

// 方式二:批量导出
// utils.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
export { add, subtract };

// 方式三:默认导出
// math.js
export default class Math {
  static pi = 3.14159;
  static square(x) {
    return x * x;
  }
}

// 方式四:混合导出
// shapes.js
export const PI = 3.14159;
export default class Circle {
  constructor(radius) {
    this.radius = radius;
  }
  area() {
    return PI * this.radius ** 2;
  }
}

引入模块

// 引入命名导出
import { name, age, greet } from './person.js';
console.log(name, age);
greet();

// 引入并重命名
import { add as addNumbers, subtract } from './utils.js';

// 引入默认导出
import Math from './math.js';
console.log(Math.square(4));

// 同时引入默认和命名导出
import Circle, { PI } from './shapes.js';

// 引入所有导出(命名空间导入)
import * as utils from './utils.js';
console.log(utils.add(5, 3));
console.log(utils.subtract(5, 3));

// 只加载模块但不引入任何内容
import './styles.css';

2. 核心特性

静态引入

ES6 模块的引入必须位于顶层,不能动态引入(至少在基础语法上):

// ✅ 正确:顶层引入
import { readFile } from 'fs';

// ❌ 错误:不能在条件语句中引入
if (condition) {
  import { readFile } from 'fs'; // 语法错误
}

// ❌ 错误:不能在函数中引入
function loadModule() {
  import { readFile } from 'fs'; // 语法错误
}

异步加载

但在实际使用中,可以通过动态 import() 实现异步加载:

// ✅ 动态引入(返回 Promise)
if (condition) {
  import('./heavy-module.js')
    .then(module => {
      module.doSomething();
    })
    .catch(err => {
      console.error('模块加载失败', err);
    });
}

// 使用 async/await
async function loadAdminModule() {
  try {
    const adminModule = await import('./admin.js');
    adminModule.init();
  } catch (error) {
    console.error('加载失败', error);
  }
}

// 按需加载路由组件(Vue/React 常见用法)
const UserProfile = () => import('./views/UserProfile.vue');

值的引用

ES6 模块导出的是值的引用,导出和导入的变量指向同一块内存:

// counter.js
export let count = 0;
export function increment() {
  count += 1;
}

// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 (直接更新了)

三、核心区别对比

四、实际应用场景

1. CommonJS 适用场景

// Node.js 服务端应用
const express = require('express');
const mongoose = require('mongoose');
const config = require('./config');

// 条件加载不同环境的配置
const env = process.env.NODE_ENV || 'development';
const dbConfig = require(`./config/${env}.js`);

// 动态加载插件
function loadPlugin(pluginName) {
  try {
    return require(`./plugins/${pluginName}`);
  } catch (err) {
    console.error(`插件 ${pluginName} 加载失败`);
    return null;
  }
}

2. ES6 模块适用场景

// 现代前端应用(React/Vue 项目)
import React, { useState, useEffect } from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';

// 按需加载(代码分割)
const LazyComponent = React.lazy(() => import('./LazyComponent'));

// 明确导入需要的内容,便于 Tree Shaking
import { debounce, throttle } from 'lodash-es';

// 类型导入(TypeScript)
import type { User, Product } from './types';

五、混合使用注意事项

在 Node.js 环境中,可以混合使用两种模块系统,但需要注意:

// ES6 模块中引入 CommonJS 模块
import package from 'commonjs-package'; // 默认导入
import { something } from 'commonjs-package'; // 命名导入(有限支持)

// CommonJS 中引入 ES6 模块(使用动态 import)
async function loadESModule() {
  const esModule = await import('./es-module.mjs');
  console.log(esModule.default);
}

package.json 配置

{
  "name": "my-project",
  "version": "1.0.0",
  "type": "module", // 设置后,.js 文件默认使用 ES6 模块
  
  "exports": {
    ".": {
      "import": "./dist/index.mjs", // ES6 模块入口
      "require": "./dist/index.cjs"  // CommonJS 模块入口
    }
  }
}

总结

  1. CommonJS 适合 Node.js 服务端开发,特别是需要动态加载的场景

  2. ES6 模块 是现代前端开发的标准,支持静态分析和 Tree Shaking

  3. 动态 import() 填补了 ES6 模块的动态加载能力

  4. 实际开发 中,建议新项目优先使用 ES6 模块,可以获得更好的工具支持和性能优化

选择哪种模块系统,应根据项目运行环境、团队习惯和具体需求来决定。

🚀 别再乱写 16px 了!CSS 单位体系已经进入“计算时代”,真正的响应式布局

作者 Sailing
2026年3月3日 13:50

如果你的项目里还充满 px + media query —— 那说明你在“维护样式”,而不是“设计系统”。

现代 CSS 的能力,已经远远超出“写几个断点”那么简单。

今天我们从工程视角,把 单位体系 + 计算体系 一次讲透。

u=3751345329,3281070072&fm=253&fmt=auto&app=120&f=JPEG.webp

CSS 单位体系:本质不是长度,而是“依赖关系”

CSS 单位可以分成三类:

  1. 绝对单位
  2. 相对单位
  3. 视口单位

单位能力对比表

单位 类型 依赖对象 系统角色 推荐级别 典型场景
px 绝对 精准控制 ✅ 必须存在 图标 / 1px 边框
em 相对 父级字体 局部缩放 ⚠️ 慎用 组件内部
rem 相对 根字体 全局缩放 ✅✅ 核心 系统布局
vw 视口 视口宽度 宽度适配 响应布局
vh 视口 视口高度 高度控制 全屏模块
vmin 视口 较小值 动态比例 特殊适配
vmax 视口 较大值 极端场景 特殊适配
dvw 动态视口 实际可视宽 移动端修正 🚀 H5
dvh 动态视口 实际可视高 移动端高度修正 🚀 APP/H5

重点认知

单位不是写数值
单位是在声明“依赖谁”

  • px → 不依赖任何人(绝对控制)
  • rem → 依赖根节点(系统级缩放)
  • vw → 依赖视口(设备相关)
  • dvh → 依赖真实可视区域(移动端优化)

如果你做企业级系统:

推荐核心组合

rem + clamp + min/max + dvh

其他单位只是辅助。我们往下看!

WX20240522-152437@2x.png

函数才是现代 CSS 的“计算引擎”

它们不是单位,但它们决定单位如何协作。

函数 作用 优势 工程价值
calc() 计算 混合单位运算 结构关系表达
min() 上限控制 自动封顶 替代 max-width
max() 下限控制 自动兜底 保证可读性
clamp() 区间控制 响应式缩放 替代 media query

核心函数深度解析(实战理解)

现在我们进入“可落地”部分。

🔥 1. rem —— 系统级缩放开关

原理

1rem = htmlfont-size

推荐做法

html {
  font-size: 16px;
}

组件写法:

.box {
  padding: 2rem;
}

为什么它是系统基石?

如果未来:

  • 设计改缩放比例
  • 项目整体要变大

你只需要改:

html { font-size: 18px }

🔥 全站自动缩放。
🔥 无需改任何组件。

这才叫“系统”。

🔥 2. clamp() —— 响应式终极武器

这是现代 CSS 的核心。

语法

clamp(min, preferred, max)

实战:

h1 {
  font-size: clamp(20px, 4vw, 48px);
}

效果:小屏不小于 20px,大屏不超过 48px,中间随视口自动变化。

工程价值

❌ 不需要维护设备列表 media query (@media (max-width: 768px))
❌ 不需要写多个断点
❌ 不需要拆分 PC / Mobile

✅ 一行表达“数学区间关系”

这不是技巧,是范式升级。

🔥 3. min() —— 自动封顶

传统写法:

width: 90%;
max-width: 1200px;

现代写法:

width: min(1200px, 90%);

区别?

  • 数学表达
  • 单行逻辑
  • 结构更清晰

表达的是:

宽度 = 两者中更小的那个

这才叫可维护。

🔥 4. max() —— 自动兜底

.box {
  padding: max(16px, 2vw);
}

保证:

  • 最小 16px
  • 又允许动态放大

用于保证阅读体验、可触控面积。

🔥 5. calc() —— 混合运算核心

.sidebar {
  width: 300px;
}

.content {
  width: calc(100% - 300px);
}

能力:

  • 支持加减乘除
  • 支持单位混合
  • 表达结构关系

它表达的是:

主体宽度 = 容器宽度 - 侧栏宽度

这叫“布局计算”,而不是“写死数值”。

39eb0728a2c0407faacb769863300d59.gif

视口单位:vw / vh / vmin / vmax

它们的共同点只有一个:依赖设备视口

vw

1vw = 视口宽度的 1%
.box {
  width: 50vw;
}

用于横向比例布局。

vh

1vh = 视口高度的 1%
.hero {
  height: 100vh;
}

用于全屏模块。

⚠️ 移动端慎用(dvh 更稳定)。

vmin

vmin = min(vw, vh)

始终基于短边。

.circle {
  width: 50vmin;
  height: 50vmin;
}

横竖屏切换比例稳定。

vmax

vmax = max(vw, vh)

始终基于长边。

.bg {
  font-size: 20vmax;
}

适合视觉冲击型页面。

image(2).png

移动端必须升级:dvh、dvw

移动端地址栏会动态伸缩,100vh ≠ 实际可视高度。

解决方案:

height: 100dvh;

优势:

  • 永远是真实可视区域
  • 不会因浏览器 UI 变化跳动
  • H5 / WebApp 必备

企业级布局推荐方案

如果你做后台系统 / 复杂管理台:

❌ 过时写法

  • 大量 @media
  • 到处 max-width
  • 写死 16px / 20px
  • 用断点区分设备

那是样式堆叠时代。

✅ 现代写法

固定系统:

px + min + max + clamp

弹性系统:

rem + clamp + vw + dvh

目标只有一个:写“关系”,而不是写“数值”。

总结:真正的思维升级

如果你的项目:

  • 还在到处写 16px
  • 还在疯狂加 media query
  • 还在拆 PC / 移动端
@media (max-width: 768px)

你是在:手动划分设备,维护设备列表,增加未来维护成本。

而当你写:

font-size: clamp(16px, 2vw, 24px);

你是在:写数学区间,写系统规则,让浏览器自己计算

你认为呢,希望这篇文章对你有所帮助、有所借鉴,欢迎在评论区随时沟通。

❌
❌