阅读视图

发现新文章,点击刷新页面。

重排、重绘与合成——浏览器渲染性能的底层逻辑

有一段时间我一直搞不明白一件事:同样是"移动一个元素",用 transform: translateX() 就很丝滑,用 left 就会掉帧——明明做的是同一件事,为什么差这么多?后来真正把浏览器渲染的这三个概念搞清楚之后,才发现这不是玄学,是完全可以用机制解释的。这篇是我的学习笔记。


一、先厘清一个容易混淆的概念

在进入正题前,必须先把这两件事分开:React re-render浏览器重排/重绘

它们经常被放在一起讨论,但其实是两个不同层的事情:

筛选条件变化
    ↓
React re-render(React 层)
→ 组件函数重新执行,生成新的虚拟 DOM
→ Diff 算出最小变更
→ 更新真实 DOM
    ↓
浏览器重排 / 重绘(浏览器层)
→ 浏览器感知到 DOM 变化,重新计算布局/绘制

React re-render 可能触发浏览器重排/重绘,但两者不是同一回事。React re-render 是 JS 层面的虚拟 DOM 计算,浏览器重排/重绘是渲染引擎层面的像素计算。优化方向也不同:useMemo/React.memo 减少的是 React re-render,transform 替代 top 优化的是浏览器渲染层。


二、浏览器渲染流程回顾

在"从 URL 到页面"的完整链路里,最后一段是浏览器拿到 DOM + CSSOM 之后的渲染工作:

DOM + CSSOM
    ↓
Render Tree(渲染树)
    ↓
Layout(重排)   ← 计算每个元素的位置和大小
    ↓
Paint(重绘)    ← 填充颜色、边框、阴影……
    ↓
Composite(合成)← 合并图层,输出到屏幕

重排、重绘、合成,是这条流水线的最后三步。理解它们的代价差异,是理解所有 CSS 性能优化的基础。


三、重排(Reflow):最贵的一步

什么是重排?

当元素的几何属性(位置、大小)发生变化,浏览器需要重新计算所有受影响元素的布局信息——这个过程叫重排,也叫 Reflow。

典型触发场景:

// 修改几何属性
element.style.width = '200px';
element.style.height = '100px';
element.style.margin = '20px';
element.style.padding = '10px';

// 改变元素显示状态
element.style.display = 'none';   // 从文档流移除,触发重排
element.style.display = 'block';  // 重新加入文档流,触发重排

// DOM 结构变化
document.body.appendChild(newElement);
parent.removeChild(child);

// 窗口大小变化
window.addEventListener('resize', handler);

为什么代价大?

重排的代价在于连锁反应。HTML 元素的布局是相互影响的——一个元素的宽度变了,它的兄弟元素可能需要重新排列,父元素的高度可能随之变化,父元素的父元素又可能受影响……

浏览器需要从受影响的节点开始,向上向下重新计算整棵子树的几何信息。如果变化发生在页面顶层,几乎等于重算整个页面布局。


四、重绘(Repaint):比重排轻,但不是没有代价

什么是重绘?

当元素的外观发生变化,但位置和大小没变,浏览器只需要重新绘制受影响区域的像素——这叫重绘,也叫 Repaint。

典型触发场景:

// 颜色类变化
element.style.color = '#333';
element.style.backgroundColor = '#f5f5f5';

// 装饰性变化
element.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
element.style.borderColor = 'red';
element.style.outline = '2px solid blue';

// 可见性(注意:visibility 不触发重排,display 触发)
element.style.visibility = 'hidden';

重绘不需要重新计算布局,只需要重新"上色"——所以比重排轻得多,但仍然有开销,不是免费的。


五、关键规律:三者的包含关系

重排 ⊃ 重绘 ⊃ 合成

重排一定触发重绘(几何变了,外观也要重画)
重绘不一定触发重排(外观变了,位置不一定变)
合成不触发重排和重绘(完全跳过前两步)

开销排序:重排 > 重绘 > 合成


六、合成层与 transform 为什么快

这是整篇文章最关键的部分。

三种操作的完整流程对比

操作 触发流程 性能
width / height / top / left 重排 → 重绘 → 合成 最差
color / background-color 重绘 → 合成 中等
transform / opacity 只合成 最好

transform 的工作原理

当浏览器发现一个元素使用了 transformopacity 动画,它会把这个元素提升到独立的合成层(Compositing Layer) ,交给 GPU 处理。

普通元素动画(left/top):
    修改样式
        ↓
    重新 Layout(计算位置)    ← CPU,影响其他元素
        ↓
    重新 Paint(绘制像素)     ← CPU,绘制整个区域
        ↓
    Composite(合成)          ← GPU

transform/opacity 动画:
    修改样式
        ↓
    Composite(合成)          ← GPU 直接处理
    (跳过 Layout 和 Paint)

关键在于:transform 是在已经绘制好的图层上做变换(平移、缩放、旋转),不改变元素在文档流中的实际位置,所以浏览器不需要重新计算布局,也不需要重新绘制像素——只需要 GPU 把这个图层的矩阵变换一下,直接合成输出。

实际代码对比

/* 触发重排 + 重绘,动画掉帧 */
.box-bad {
  position: absolute;
  left: 0;
  transition: left 0.3s ease;
}
.box-bad:hover {
  left: 200px; /* 每一帧都触发重排 */
}

/* 只触发合成,动画丝滑 */
.box-good {
  position: absolute;
  transform: translateX(0);
  transition: transform 0.3s ease;
}
.box-good:hover {
  transform: translateX(200px); /* 每一帧只触发合成,GPU 处理 */
}

视觉效果完全一样,但渲染代价天壤之别。这就是为什么 CSS 动画优先推荐使用 transform

主动触发合成层提升

除了 transformopacity,还可以通过 will-change 提示浏览器提前创建合成层:

/* 告诉浏览器:这个元素即将发生 transform 变化,提前准备合成层 */
.animated-card {
  will-change: transform;
}
// 动画结束后,记得移除(合成层有内存开销)
element.addEventListener('animationend', () => {
  element.style.willChange = 'auto';
});

will-change 不是越多越好——每个合成层都占用 GPU 内存,滥用反而会导致内存压力和性能下降。只在真正需要优化的动画元素上使用。


七、实际开发陷阱:循环里交替读写 DOM

这是一个在真实项目里很容易踩的坑,也是面试的高频考题。

为什么读取布局属性会触发强制重排?

当你读取 offsetHeightclientWidthgetBoundingClientRect() 等属性时,浏览器必须给你一个当前准确的值

如果在读取之前你刚刚写入了一些样式变化,而浏览器还没来得及执行重排,它就必须立即同步执行重排,才能返回准确数值。这叫强制同步重排(Forced Synchronous Layout)。

问题代码:循环内交替读写

// 每次循环都触发一次强制重排——100 次循环 = 100 次重排
const boxes = document.querySelectorAll('.box');

for (let i = 0; i < boxes.length; i++) {
  const height = boxes[i].offsetHeight;        // 读:强制触发重排,获取准确值
  boxes[i].style.height = height + 10 + 'px'; // 写:标记待重排
  // 下一次循环读 offsetHeight,又强制清算上面的标记
}

浏览器原本会把多次样式修改批量处理(一次重排),但读写交替打破了这个批处理——每次读取都迫使浏览器立即清算之前积累的修改。

修复:先批量读,再批量写

// 先读完所有值,再批量写——只触发 1 次重排
const boxes = document.querySelectorAll('.box');

// 第一步:批量读取(此时触发 1 次重排)
const heights = Array.from(boxes).map(box => box.offsetHeight);

// 第二步:批量写入(浏览器合并成 1 次重排处理)
boxes.forEach((box, i) => {
  box.style.height = heights[i] + 10 + 'px';
});

本质是:把读操作和写操作分离,让浏览器能够合批处理写操作。

如果修改逻辑更复杂,可以借助 requestAnimationFrame 把写操作推到下一帧的开头执行:

// 环境:浏览器
// 场景:确保在下一帧开始时批量执行所有 DOM 写操作
const heights = Array.from(boxes).map(box => box.offsetHeight);

requestAnimationFrame(() => {
  boxes.forEach((box, i) => {
    box.style.height = heights[i] + 10 + 'px';
  });
});

八、浏览器完整渲染流程总图

把前面所有内容串起来,完整看一遍:

URL 输入 → DNS → TCP → TLS → HTTP 请求/响应
                                    ↓
                              解析 HTML → DOM 树
                              解析 CSS  → CSSOM 树
                                    ↓
                              Render Tree(去掉不可见节点)
                                    ↓
┌───────────────────────────────────────────────────────────┐
│                    浏览器渲染流水线                         │
│                                                           │
│  Layout(重排)                                            │
│  触发条件:width/height/top/left/margin/display 等改变      │
│       ↓                                                   │
│  Paint(重绘)                                             │
│  触发条件:color/background/shadow/visibility 等改变        │
│       ↓                                                   │
│  Composite(合成)                                         │
│  所有操作最终都到这一步                                       │
│                                                           │
│  ✦ transform / opacity                                    │
│    → 元素提升为独立合成层,GPU 直接处理                        │
│    → 跳过 Layout 和 Paint,直达 Composite                   │
└───────────────────────────────────────────────────────────┘
                                    ↓
                               屏幕显示 🎉

延伸思考

梳理完这些,还有几个问题没完全搞清楚:

  1. 合成层的内存代价怎么量化? 什么情况下合成层的开销会超过它带来的性能收益,Chrome DevTools 里怎么观测?
  2. React 的批量更新(Batching)和浏览器的批量渲染是什么关系? React 18 的自动批处理,是不是某种程度上也在减少强制同步重排?
  3. CSS contain 属性是什么? 据说它可以把一个元素声明为"独立的渲染作用域",让重排影响范围收敛到局部——这个机制是怎么运作的?

🧠 面试常问版(核心记忆点)

5 条浓缩,面试前快速过一遍:

  1. React re-render ≠ 浏览器重排:React re-render 是 JS 层虚拟 DOM 的重新计算,浏览器重排是渲染引擎的布局重算。前者可能触发后者,但优化手段不同,不要混淆。
  2. 三层开销排序:重排(Reflow)> 重绘(Repaint)> 合成(Composite)。重排必触发重绘,重绘不必触发重排,合成跳过前两步。
  3. transform 快的原因:浏览器把 transform/opacity 的元素提升到独立合成层,由 GPU 直接处理矩阵变换,完全跳过 Layout 和 Paint。left/top 每帧都触发重排,transform 每帧只做合成——这是动画性能差异的根源。
  4. 强制同步重排陷阱:读取 offsetHeightgetBoundingClientRect() 等属性会强制浏览器立即执行重排。循环内交替读写 DOM = 每次循环触发一次重排。解决:先批量读,再批量写。
  5. will-change 的正确用法:提前声明元素将发生 transform 变化,让浏览器预先创建合成层。但合成层有内存开销,不要滥用,动画结束后用 will-change: auto 释放。

参考资料

虚拟 DOM 与 Diff 算法——React 性能优化的底层逻辑

用了两三年 React,我一直对"虚拟 DOM 更快"这个说法半信半疑。直到有一次优化一个长列表卡顿问题,才真正逼着自己把这套底层逻辑摸清楚。这篇是我的学习笔记,试图用具体例子把"为什么"和"怎么做"说清楚,而不是把概念堆在一起。


一、为什么需要虚拟 DOM?

先从"直接操作真实 DOM 有什么问题"聊起。

真实 DOM 操作慢在哪?

上一篇聊浏览器渲染时提到过,每次修改 DOM,浏览器都要重跑一遍渲染流水线:

修改 DOM → 重新计算样式 → Layout(重排)→ Paint(重绘)→ Composite

这个流水线本身没问题,问题在于频率。如果你有一个复杂页面,状态变化触发了 100 次 DOM 修改,流水线就要跑 100 次。每次都是真实的浏览器渲染工作,代价不低。

那"每次重新渲染整个页面"呢?

你可能会想:干脆每次状态变化,把整个页面 innerHTML 全部重写,不就省事了?

理论上是"最简单"的方案,但问题是:

  1. :重建整个 DOM 树,触发全量 Layout + Paint,比局部更新慢得多
  2. 丢失用户状态:用户正在输入的文本框内容会被清空、滚动位置跳回顶部、当前 focus 的元素失焦——体验直接崩掉

虚拟 DOM 要解决的,正是这两个问题之间的矛盾:既不想每次手动挑出要更新的 DOM 节点,又不想粗暴地全量重建。


二、虚拟 DOM 是什么?

虚拟 DOM(Virtual DOM)本质上就是用普通 JS 对象来描述 DOM 结构

操作真实 DOM 慢,但操作 JS 对象快得多(快几百倍)。所以 React 的思路是:先在内存里用 JS 对象"演练"要做的改动,算出最小改动集,再一次性更新到真实 DOM。

来看一个具体的对应关系:

<!-- 真实 DOM -->
<div class="card">
  <h1>标题</h1>
  <p>描述内容</p>
</div>
// 对应的虚拟 DOM(JS 对象)
{
  type: 'div',
  props: { className: 'card' },
  children: [
    {
      type: 'h1',
      props: {},
      children: ['标题']
    },
    {
      type: 'p',
      props: {},
      children: ['描述内容']
    }
  ]
}

React 的 JSX 语法,本质上就是在写这样的对象描述,只是换了一套更好看的语法糖。

// 你写的 JSX
const element = (
  <div className="card">
    <h1>标题</h1>
    <p>描述内容</p>
  </div>
);

// Babel 编译后,等价于
const element = React.createElement(
  'div',
  { className: 'card' },
  React.createElement('h1', null, '标题'),
  React.createElement('p', null, '描述内容')
);

三、虚拟 DOM 的工作流程

有了虚拟 DOM,React 的渲染流程变成了这样:

状态变化(setState / useState)
        ↓
生成新的虚拟 DOM 树
        ↓
与上一次的旧虚拟 DOM 树做 Diff(对比)
        ↓
找出差异部分(patch)
        ↓
只把差异更新到真实 DOM

核心价值只有一句话:最小化真实 DOM 操作次数

举个例子——一个有 1000 个节点的页面,某次状态变化只影响了其中 3 个节点。

方案 真实 DOM 操作次数
全量重建 1000 次
手动精准更新 3 次(但需要你自己写逻辑)
虚拟 DOM + Diff 3 次(自动计算)

虚拟 DOM 让你享受到了"手动精准更新"的性能,但不需要你自己写那些繁琐的 DOM 操作逻辑。


四、Diff 算法:如何高效比较两棵树?

现在问题来了:比较两棵树,算出最小改动,怎么做?

理论最优解有多慢?

计算机科学中,对比两棵树的最优算法复杂度是 O(n³)

100 个节点?10⁶ = 100 万次计算。 1000 个节点?10⁹ = 10 亿次计算

每次状态更新都跑 10 亿次操作,页面直接冻住。这个路走不通。

React 的解法:三个假设,换来 O(n)

React 选择了一个工程上的妥协:基于三个在实际开发中几乎总是成立的假设,把复杂度降到 O(n)。


假设 1:不同类型的节点,直接替换

如果一个节点从 <div> 变成了 <p>,React 不会试图比较它们的内部差异——直接销毁整棵旧树,重建新树。

// 旧的虚拟 DOM
<div>
  <input value="用户输入的内容" />
  <span>子元素</span>
</div>

// 新的虚拟 DOM(根节点类型变了)
<p>
  <input value="用户输入的内容" />
  <span>子元素</span>
</p>

这种情况下,React 会:

  1. 卸载整个 <div> 及其所有子节点(包括 input 里用户输入的内容)
  2. 重新挂载整个 <p>

所以如果你的根节点类型频繁切换,会造成不必要的子组件销毁重建。这个假设告诉我们:组件的根节点类型,能稳定就稳定


假设 2:只比较同层节点,不跨层级

React 的 Diff 是逐层对比的,不会尝试找跨层移动的节点。

旧树                    新树

    A                       A
   / \                     / \
  B   C        →          B   C
 / \                           \
D   E                           E

如果你把节点 D 从 B 的子节点移动到了 C 的子节点下,React 看到的是:

  • B 层:少了 D → 删除 D
  • C 层:多了 D → 新建 D

它不会识别出"这是同一个节点在移动",而是执行一次删除 + 一次创建。

这意味着:跨层级移动 DOM 节点,在 React 里代价比你想象的高。在实际组件设计中,尽量避免通过条件渲染在不同层级之间"搬运"同一个组件。


假设 3:用 key 识别列表节点

这是三个假设里和日常开发最紧密的一个。

当对比一组子节点(列表)时,如果没有 key,React 只能按顺序逐一对比:

// 旧列表
<ul>
  <li>张三</li>   // 位置 0
  <li>李四</li>   // 位置 1
  <li>王五</li>   // 位置 2
</ul>

// 在开头插入"赵六"后的新列表
<ul>
  <li>赵六</li>   // 位置 0
  <li>张三</li>   // 位置 1
  <li>李四</li>   // 位置 2
  <li>王五</li>   // 位置 3
</ul>

没有 key,React 按位置对比:位置 0 内容变了(张三→赵六)→ 更新;位置 1 内容变了 → 更新;位置 2 内容变了 → 更新;位置 3 是新增 → 新建。改了 4 个节点,实际上只是新增了 1 个。

有了 key,React 能识别出哪些节点是"同一个",从而准确复用:

<ul>
  <li key="zhaoliu">赵六</li>   // 新增
  <li key="zhangsan">张三</li>  // 复用,不更新
  <li key="lisi">李四</li>      // 复用,不更新
  <li key="wangwu">王五</li>    // 复用,不更新
</ul>

只做 1 次插入操作,剩下三个节点直接复用。


五、为什么不能用 index 做 key?

这是 React 开发中最经典的"坑"之一,我觉得有必要把例子说完整。

场景:删除列表项

初始列表 [张三, 李四, 王五],用 index 做 key:

// 初始状态
<ul>
  <li key={0}>张三</li>
  <li key={1}>李四</li>
  <li key={2}>王五</li>
</ul>

现在删除张三,列表变成 [李四, 王五]

// 删除后
<ul>
  <li key={0}>李四</li>  // key=0,内容从"张三"变成了"李四"
  <li key={1}>王五</li>  // key=1,内容从"李四"变成了"王五"
                          // key=2 消失 → 删除
</ul>

React 看到的是:

  • key=0:内容变了 → 更新
  • key=1:内容变了 → 更新
  • key=2:消失了 → 删除

结果:3 次 DOM 操作。但我们实际上只删了 1 个元素,只需要 1 次 DOM 操作


改用唯一 ID 做 key:

// 初始状态
<ul>
  <li key="zhangsan">张三</li>
  <li key="lisi">李四</li>
  <li key="wangwu">王五</li>
</ul>

// 删除后
<ul>
  <li key="lisi">李四</li>   // key 没变,内容没变 → 跳过
  <li key="wangwu">王五</li> // key 没变,内容没变 → 跳过
                              // key="zhangsan" 消失 → 删除
</ul>

React 准确识别出只有"zhangsan"消失了:1 次 DOM 操作,完全正确。


更严重的 bug:输入框状态错乱

上面的例子只是性能问题,但下面这个是功能 bug

场景:列表每一项有一个输入框,用户在第一项(张三)的输入框里填了内容,然后删除第一项。

// 每一项带输入框的组件
function ListItem({ name }) {
  return (
    <li>
      <span>{name}</span>
      <input placeholder={`备注 ${name}`} />
    </li>
  );
}

// 用 index 做 key
{list.map((item, index) => (
  <ListItem key={index} name={item.name} />
))}

删除"张三"后,React 对 key=0 做的是更新(把 name prop 改成"李四"),而不是销毁重建。

React 复用了原来"张三"那个 DOM 节点,只更新了 name 属性——但输入框是非受控的,它的内部状态(用户输入的内容)跟着 DOM 节点走,不跟着数据走。

结果:删掉张三之后,李四的输入框里还显示着刚才给张三写的备注内容。数据删了,UI 状态还留着

这种 bug 在测试环境容易被漏掉,到了生产环境才被用户发现,排查起来也很头疼。


结论

✅ 用数据的唯一 ID 做 key(数据库主键、UUID 等)
❌ 不用 index 做 key(除非列表永远不会增删排序)
❌ 不用随机数做 key(每次渲染都会强制重建,比没有 key 更差)

六、整体流程回顾

用户交互 / 数据请求
        ↓
setState / useState 触发更新
        ↓
React 调用 render,生成新的虚拟 DOM 树
        ↓
┌─────────────────────────────────────┐
│           Diff 算法(O(n))          │
│                                     │
│  类型不同?→ 直接替换                  │
│  只比同层  → 不跨层                   │
│  有 key?  → 精准识别复用              │
└─────────────────────────────────────┘
        ↓
生成最小 patch(差异集合)
        ↓
批量更新到真实 DOM
        ↓
浏览器渲染(只有变化的部分触发重排/重绘)

延伸思考

梳理完这些,我产生了几个新问题,暂时还没完全搞清楚:

  1. React Fiber 和虚拟 DOM 是什么关系? Fiber 架构是 React 16 引入的,它把虚拟 DOM 的 Diff 过程变成了可中断的,这对长列表渲染有什么具体影响?
  2. Vue 的 Diff 和 React 的 Diff 有什么区别? 听说 Vue 3 的双端对比算法在某些场景下效率更高,是什么原理?
  3. React.memouseMemo 和虚拟 DOM 的 Diff 是什么关系? 它们是在 Diff 之前就跳过了,还是 Diff 之后的优化?

这些可能是下一篇的方向,也欢迎有研究的朋友交流。


🧠 面试常问版(核心记忆点)

5 条浓缩,面试前快速过一遍:

  1. 虚拟 DOM 的本质:用 JS 对象描述 DOM 结构,在内存中做 Diff,最小化真实 DOM 操作次数,解决"全量重建"导致的慢和状态丢失问题。
  2. Diff 算法的三个假设:① 不同类型节点直接替换;② 只对比同层节点;③ 用 key 识别列表节点。三个假设把复杂度从 O(n³) 降到 O(n)。
  3. key 的作用:帮助 React 识别哪些节点是"同一个",从而在列表更新时准确复用,避免不必要的 DOM 操作。
  4. index 做 key 的两种问题:性能问题(删除头部节点会触发全量更新)+ 功能 bug(非受控组件的状态跟 DOM 节点走,不跟数据走,导致状态错乱)。
  5. key 的正确选择:用数据的唯一 ID(数据库主键、UUID 等),不用 index,不用随机数。

参考资料

从输入 URL 到页面显示——浏览器工作原理全解析

这篇文章的起因很朴素:被面试官问到"浏览器输入 URL 后发生了什么",我当时答得磕磕绊绊。事后复盘,发现自己其实每天都在和这条链路打交道,却从没认真梳理过它。所以这篇更多是我的学习笔记——不追求教科书式的完整,而是希望用对话感把每个概念说清楚。如果你也对这条链路模糊,欢迎一起往下读。


一、为什么要理解这条链路?

先说面试:这是前端面试里的"经典送命题"。问法很宽,可以从 DNS 聊到渲染,每个环节都能展开一个小时。

但比面试更重要的是:理解浏览器在帮你做什么

为什么 <script> 放底部?为什么 transformtop 流畅?为什么 HTTPS 比 HTTP 安全?这些问题的答案,都藏在这条链路里。

完整流程如下,我们逐段拆解:

URL 输入
  ↓
DNS 解析(域名 → IP)
  ↓
TCP 三次握手(建立连接)
  ↓
TLS 握手(HTTPS 加密,若有)
  ↓
HTTP 请求 / 响应
  ↓
浏览器解析渲染(HTML → 像素)
  ↓
页面显示

二、DNS 解析:找到服务器地址

域名是个"电话簿"

你输入的是 www.example.com,但网络层面真正认的是 IP 地址(比如 93.184.216.34)。域名只是给人看的别名。

DNS(Domain Name System)就是把域名翻译成 IP 的"电话簿"。

查询顺序:从近到远

浏览器不会每次都跑去问根服务器,它有一套缓存优先的查询链:

浏览器缓存
  ↓(没有?)
操作系统缓存(hosts 文件 / 系统 DNS 缓存)
  ↓(没有?)
路由器缓存
  ↓(没有?)
ISP(运营商)DNS 服务器
  ↓(没有?)
根域名服务器 → 顶级域服务器(.com)→ 权威域名服务器
  ↓
返回 IP 地址,逐层缓存

类比一下:你想找某个老同学的电话,你会先翻自己的手机通讯录,再问共同朋友,最后才去翻毕业纪念册。每一层都比下一层"近"。

TTL:为什么不能永久缓存?

DNS 记录带有 TTL(Time To Live,缓存有效期),过期后必须重新查询。

原因很简单:映射关系会变。比如网站迁移服务器,IP 换了,如果客户端永久缓存旧 IP,就再也找不到新服务器了。TTL 的存在,是在"缓存命中率"和"数据新鲜度"之间做权衡。


三、TCP 三次握手:建立可靠连接

找到 IP 之后,浏览器需要和服务器建立连接。HTTP 跑在 TCP 之上,而 TCP 是面向连接的协议——发数据之前,双方必须先"握手"确认线路通畅。

为什么是三次,不是两次或四次?

这是个很好的问题。我的理解是,三次握手需要确认三件事:

次序 方向 目的
第一次 客户端 → 服务器(SYN) 确认:客户端能发
第二次 服务器 → 客户端(SYN+ACK) 确认:服务器能收、能发
第三次 客户端 → 服务器(ACK) 确认:客户端能收

三次之后,双方都知道对方能收能发,通信信道建立完毕。

少一次(两次握手)的问题:客户端能收这件事没人确认,存在单向通道风险,且会引发"历史连接"问题(旧的延迟 SYN 包触发服务器建立无效连接)。

多一次没必要:四次就是冗余了,三次已经能确认所有需要确认的状态。

类比:打电话前的确认——"喂,你能听到我吗?"→"能,你能听到我吗?"→"能"。三句话,线路通畅,开始正式通话。


四、TLS 握手:加密 + 身份验证(HTTPS)

TCP 建好连接后,如果是 HTTPS,还要多一步:TLS 握手。

为什么需要它?

HTTP 是明文传输的。你发出去的每一个请求,路径上的任何节点(路由器、运营商、同一 WiFi 下的其他人)理论上都能看到完整内容。用户密码、信用卡号……全部裸奔。

TLS 解决了两个问题:

  • 身份验证:你连接的是真的 example.com,不是被人劫持的钓鱼站
  • 加密传输:内容只有你和服务器能读

握手流程(简化版)

1. 浏览器 → 服务器:我支持这些加密算法 [列表],给我你的证书

2. 服务器 → 浏览器:用这个算法,这是我的证书(含公钥)

3. 浏览器验证证书(向 CA 机构核实真实性)
   生成随机数,用服务器公钥加密后发过去

4. 服务器用私钥解密,得到随机数

5. 双方用这个随机数生成"会话密钥"(对称密钥)

6. 后续所有通信用会话密钥加密

两个角色分开理解

初学时我一直搞混"证书"和"加密",其实它们是两件事:

角色 类比 作用
证书 身份证 + 公证处盖章 证明"我真的是 example.com"
加密 双方约定的暗语本 保证通信内容只有双方能读

证书由 CA(证书颁发机构)签发,浏览器内置了受信任的 CA 列表。如果证书是自签名的或已过期,浏览器会弹出警告。

为什么不全程用公钥加密?

这是个常被忽略的细节。非对称加密(RSA)安全,但比对称加密(AES)慢约 100 倍

所以 TLS 的设计是:非对称加密只用于握手阶段安全交换密钥,真正的通信内容用对称密钥(AES)加密。兼顾了安全性和性能。

加密类型 代表算法 速度 用途
非对称加密 RSA、ECDH 密钥交换、签名
对称加密 AES 实际数据加密

TLS 管加密,Cookie 管身份

还有一个常见混淆点:TLS 建立的是加密信道,不等于"记住了你是谁"。

服务器如何区分不同用户?那是 HTTP 层面 Cookie / session_id 的事。TLS 每次连接都会重新握手(虽然有会话恢复机制),但识别"这个请求属于哪个用户",靠的是请求头里的 Cookie。


五、HTTP 请求与响应

握手完成,浏览器发出第一个 HTTP 请求:

GET /index.html HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0 ...
Accept: text/html
Cookie: session_id=abc123
Cache-Control: no-cache

几个重要的请求头:

  • Host:告诉服务器你访问的是哪个域名(一台服务器可能托管多个域名)
  • Cookie:带上本地存储的会话标识
  • Cache-Control:告诉服务器/中间缓存怎么处理这个请求的缓存

服务器返回响应:

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Cache-Control: max-age=3600

<!DOCTYPE html>...

常见状态码速查:

状态码 含义 常见场景
200 成功 正常响应
301 永久重定向 域名迁移
304 内容未修改 使用本地缓存
404 资源不存在 路径错误
500 服务器内部错误 后端异常

六、浏览器解析:从 HTML 到像素

拿到 HTML 之后,浏览器开始做"最后一公里"的工作:把代码变成屏幕上的像素。这个过程叫关键渲染路径(Critical Rendering Path)

Step 1:解析 HTML → DOM 树

浏览器从上到下解析 HTML,构建 DOM(Document Object Model)树。DOM 是页面结构的内存表示,每个标签对应一个节点。

<!-- 这段 HTML -->
<body>
  <div class="container">
    <p>Hello</p>
  </div>
</body>

<!-- 对应的 DOM 树(简化) -->
body
  └── div.container
        └── p
              └── "Hello"

Step 2:下载并解析 CSS → CSSOM 树

并行下载 CSS 文件,解析生成 CSSOM(CSS Object Model)树。结构和 DOM 类似,但存的是样式信息。

关键阻塞规则

这里有个绕不开的问题,很多性能优化都源于此:

资源类型 阻塞什么 原因
CSS 阻塞渲染 没 CSSOM 就没法确定元素最终样式
JS(无属性) 阻塞HTML 解析 JS 可能操作 DOM,所以得等 JS 执行完
JS(defer 不阻塞解析 延迟到 HTML 解析完才执行
JS(async 下载不阻塞,执行阻塞 下载完立刻执行

这就是为什么 <script> 推荐放在 </body> 前,或者使用 defer:避免阻塞 HTML 解析,提升首屏速度。

Step 3:DOM + CSSOM → Render Tree

合并 DOM 和 CSSOM,生成只包含可见节点的 Render Tree(渲染树)。

注意:display: none 的元素不进入 Render Tree(它不占空间、不显示);但 visibility: hidden 的元素会进入(它仍然占位)。

Step 4:Layout(重排 / Reflow)

基于 Render Tree,计算每个节点的精确位置和尺寸——相对视口的坐标、宽高、边距……

这步代价较高。任何改变元素几何属性的操作(改 widthmarginposition)都会触发 Reflow,浏览器需要重新计算布局。

Step 5:Paint(重绘 / Repaint)

按照布局结果,把每个元素"画"出来:填充颜色、绘制边框、阴影、文字……

Step 6:Composite(合成)

浏览器把不同图层合并,最终送到屏幕显示。

这里有个重要的性能优化点:

/* 只触发 Composite,性能最好 */
.card {
  transform: translateY(-4px);
  opacity: 0.9;
}

/* 触发 Layout + Paint + Composite,代价最高 */
.card {
  top: -4px; /* 改变几何属性 */
}

transformopacity 的变化不影响布局,浏览器可以直接在 GPU 层面处理,跳过 Layout 和 Paint,性能最优。这就是为什么 CSS 动画推荐优先用 transform


七、整条链路总结

用户输入 URL
        │
        ▼
┌──────────────────┐
│   DNS 解析        │  域名 → IP(电话簿查询,逐层缓存)
└──────────────────┘
        │
        ▼
┌──────────────────┐
│  TCP 三次握手     │  确认双方能收发(SYN → SYN+ACK → ACK)
└──────────────────┘
        │
        ▼
┌──────────────────┐
│  TLS 握手(HTTPS)│  证书验证 + 交换会话密钥(非对称→对称)
└──────────────────┘
        │
        ▼
┌──────────────────┐
│  HTTP 请求/响应   │  发送 Request,接收 HTML/CSS/JS
└──────────────────┘
        │
        ▼
┌──────────────────────────────────────────┐
│             浏览器渲染流水线              │
│  HTML → DOM  ┐                           │
│              ├→ Render Tree → Layout     │
│  CSS → CSSOM ┘         → Paint → 合成   │
└──────────────────────────────────────────┘
        │
        ▼
      页面显示 🎉

延伸与发散

在梳理这条链路的过程中,我产生了一些新的疑问,记录在这里:

  1. HTTP/2 和 HTTP/3 对这条链路的影响是什么? HTTP/2 的多路复用是不是意味着 TCP 握手的成本被摊薄了?HTTP/3 基于 UDP 的 QUIC 协议又是如何处理可靠性的?
  2. Service Worker 如何介入这条链路? PWA 的离线缓存是在哪个环节"截胡"请求的?
  3. 浏览器的预加载机制<link rel="preconnect"><link rel="prefetch">)是在提前做哪几步?

这些可能会是后续文章的方向,也欢迎有经验的朋友交流。


🧠 面试常问版(核心记忆点)

如果只有 5 分钟时间,记住这 5 条:

  1. DNS:域名→IP,查询链是浏览器缓存→OS→路由→ISP→根服务器,TTL 控制缓存时效。
  2. TCP 三次握手:确认双方能收发,三次刚好,少一次有安全隐患,多一次冗余。
  3. TLS:证书验证身份,非对称加密只用于交换密钥,实际内容用 AES(对称)加密,快 100 倍。
  4. 渲染阻塞:CSS 阻塞渲染,JS 阻塞 HTML 解析,所以 <script> 放底部或用 defer
  5. 渲染性能transform/opacity 只触发合成层,跳过 Layout 和 Paint,动画优先使用。

参考资料

手撕发布订阅与观察者模式:从原理到实践

前言

在JavaScript异步编程和组件通信中,发布订阅模式和观察者模式是两种至关重要的设计模式。

它们都能实现对象间的一对多依赖关系,但实现方式截然不同。

本文将通过两道手撕面试题代码,深入剖析这两种模式的核心原理、实现方式,以及它们之间的本质区别。

一、题目 FED19 发布订阅模式

描述

请补全JavaScript代码,完成"EventEmitter"类实现发布订阅模式。 注意:

  1. 同一名称事件可能有多个不同的执行函数
  2. 通过"on"函数添加事件
  3. 通过"emit"函数触发事件
<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            class EventEmitter {
                // 补全代码
                
            }
        </script>
    </body>
</html>

二 、发布订阅模式

发布/订阅模式的核心思想,是实现应用中那些彼此不相干的模块之间的轻松通信。

这种模式在 jQuery 插件生态和各类前端架构设计书籍中常有深入探讨,但需要说明的是,它并非 JavaScript 语言规范的一部分,所以在 MDN 等官方文档中并不会有直接的介绍。

原理

发布-订阅模式定义了一种一对多的依赖关系,当发布者(Publisher)对象的状态发生改变时,所有依赖它的订阅者(Subscriber)对象都会得到通知。它像一个“信息中介”,将消息的发送者和接收者彻底解耦,两者不需要知道对方的存在,只需要知道共同的“频道名称”。

它的工作原理可以拆解为以下几个角色:

  • 发布者 (Publisher):负责在特定“频道”上发送消息或事件,不关心谁会接收。
  • 订阅者 (Subscriber):负责订阅感兴趣的“频道”,并在频道有消息时执行相应的回调函数。
  • 事件调度中心 (Event Bus / PubSub):这是模式的核心,负责维护所有“频道”和订阅者的关系。它提供订阅(on / subscribe)、发布(emit / publish)、取消订阅(off / unsubscribe)等核心方法。

下面是一个极简的 JavaScript 实现:

// 创建一个事件中心 (Event Bus)
const eventHub = {
    // 用于存储事件和对应的回调函数
    topics: {},
    
    // 订阅方法
    subscribe: function(topic, listener) {
        if (!this.topics[topic]) this.topics[topic] = [];
        this.topics[topic].push(listener);
        
        // 返回一个可以用于取消订阅的函数
        return () => {
            const index = this.topics[topic].indexOf(listener);
            if (index !== -1) this.topics[topic].splice(index, 1);
        };
    },
    
    // 发布方法
    publish: function(topic, data) {
        if (!this.topics[topic]) return;
        this.topics[topic].forEach(listener => {
            listener(data);
        });
    }
};

// --- 使用示例 ---
// 模块A:订阅 'user-login' 事件
const unsubscribe = eventHub.subscribe('user-login', (userInfo) => {
    console.log(`模块A收到通知,用户 ${userInfo.name} 已登录。`);
});

// 模块B:发布 'user-login' 事件
eventHub.publish('user-login', { name: '张三' }); 
// 输出: 模块A收到通知,用户 张三 已登录。

// 当不再需要时,可以取消订阅
// unsubscribe(); 

经典实现

其实我觉得这个思想类似于浏览器的 addEventListener

浏览器 API 中的 window 对象上的事件机制,是发布-订阅模式的一种经典实现

DOM 事件系统(包括 window 上的事件)就是浏览器原生实现的、基于发布-订阅模式的事件架构

DOM 事件系统如何实现发布-订阅

让我们把浏览器的事件模型和标准的发布-订阅模式做个映射:

模式角色 DOM 事件系统中的对应实现 说明
事件调度中心 windowdocumentElement 等 DOM 节点 每个 DOM 节点都内置了事件管理能力
订阅 (Subscribe) addEventListener('eventName', callback) 订阅特定事件类型
发布 (Publish) 用户交互或代码触发:dispatchEvent(event)、点击等 触发事件,执行所有订阅的回调
取消订阅 (Unsubscribe) removeEventListener('eventName', callback) 移除事件监听,避免内存泄漏
事件通道 事件类型字符串,如 'click''resize''message' 类似发布-订阅中的"topic"

window 就是典型的事件总线

// ========== window 作为事件调度中心 ==========

// 1. 订阅 (Subscribe):监听一个自定义事件
window.addEventListener('user-logged-in', (event) => {
    console.log(`收到通知,用户 ${event.detail.name} 登录了`);
    // 可以触发任何行为
});

// 2. 发布 (Publish):在任意地方触发事件
function login() {
    // ... 登录逻辑 ...
    const customEvent = new CustomEvent('user-logged-in', {
        detail: { id: 1, name: '张三' }
    });
    window.dispatchEvent(customEvent);
}

// 3. 取消订阅 (Unsubscribe)
const handler = (event) => { console.log('只会执行一次'); };
window.addEventListener('once-event', handler);
// 不再需要时移除
window.removeEventListener('once-event', handler);

理解 window 事件是发布-订阅模式,对掌握浏览器 API 和设计模式有双重价值:

  1. 解释了很多原生 API 的行为

    • window.addEventListener('resize', handler) — 订阅窗口大小变化事件
    • window.addEventListener('online', handler) — 订阅网络状态变化
    • window.addEventListener('message', handler) — 订阅跨窗口消息(iframe 通信)
    • 这些都遵循同样的"先订阅、后触发、最后取消订阅"模式。
  2. 揭示了事件委托的原理: 由于事件会冒泡,在 windowdocument 上订阅一个事件,可以接收到任何子元素触发的事件。这正是利用了"一个调度中心可以接收所有发布"的特性。

    // 事件委托:在 window 上订阅,捕获所有点击
    window.addEventListener('click', (event) => {
        if (event.target.matches('.btn-delete')) {
            console.log('删除按钮被点击');
        }
    });
    

应用场景

理解原理后,更重要的是知道它在哪些场景下能真正派上用场。

  • 跨组件通信:在大型前端应用中,用于解决没有直接关系的组件(如兄弟组件、跨层级组件)之间的通信问题,可以避免通过父组件层层传递回调函数的麻烦。
  • 异步编程:在处理AJAX请求、图片加载、脚本加载等异步操作时,可以用发布-订阅模式来管理成功、失败、完成等不同状态下的回调,让代码更清晰。
  • 模块解耦:将一个复杂系统中的不同功能模块(如购物车、用户中心、商品展示)通过事件中心进行通信,可以显著降低模块间的直接依赖,使得各个模块可以独立开发、测试和维护。
  • MV 框架的底层实现*:Vue.js 中组件间的 $on / $emit 方法,本质上就是基于发布-订阅模式的实现。

注意事项

在使用这种模式时,有几个“坑”需要特别注意:

  • 内存泄漏:当一个组件或对象被销毁时,一定要记得调用 unsubscribeoff 方法,将它之前订阅的事件从事件中心移除。否则,事件中心的回调函数依然持有对已销毁对象的引用,导致其无法被垃圾回收,从而造成内存泄漏。
  • 过度使用:虽然模式好用,但过度使用会使应用中的数据流变得非常隐蔽和难以追踪。当一个事件的触发会引发一连串不可见的连锁反应时,代码的调试和维护会变得异常困难。对于简单的父子组件通信,直接传递 props 或调用方法仍是更清晰的选择。
  • 事件命名冲突:在大型项目中,事件名称容易重复,引发非预期的行为。建议使用一套清晰的命名规范,如 模块名:动作名(例如 user:login, cart:add)。

三、解法

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            class EventEmitter {
                constructor (){
                    this.events = {};
                }

                on(eventName , callback ){
                    if (!this.events[eventName]){
                        this.events[eventName] = [] ;
                    }
                    this.events[eventName].push(callback);
                }

                emit(eventName , ...args){
                    const callbacks = this.events[eventName];
                    if (callbacks && callbacks.length){
                        callbacks.forEach(callback => {
                            callback(...args);
                        });
                    }
                }
                
            }
        </script>
    </body>
</html>

根据题目要求,我们需要实现一个 EventEmitter 类,支持:

  1. 同一名称事件可以有多个不同的执行函数
  2. on 方法添加事件监听
  3. emit 方法触发事件
class EventEmitter {
    constructor() {
        // 存储事件及其对应的回调函数列表
        this.events = {};
    }
    
    // 添加事件监听
    on(eventName, callback) {
        // 如果该事件还没有对应的回调数组,则初始化一个空数组
        if (!this.events[eventName]) {
            this.events[eventName] = [];
        }
        // 将回调函数添加到数组中
        this.events[eventName].push(callback);
    }
    
    // 触发事件
    emit(eventName, ...args) {
        // 获取该事件对应的回调函数列表
        const callbacks = this.events[eventName];
        // 如果存在回调函数,则依次执行
        if (callbacks && callbacks.length) {
            callbacks.forEach(callback => {
                callback(...args);
            });
        }
    }
}

使用示例

const emitter = new EventEmitter();

// 添加多个监听同一个事件
emitter.on('click', () => console.log('clicked 1'));
emitter.on('click', (msg) => console.log('clicked 2:', msg));
emitter.on('click', (msg) => console.log('clicked 3:', msg));

// 触发事件
emitter.emit('click', 'hello');
// 输出:
// clicked 1
// clicked 2: hello
// clicked 3: hello

代码说明

  1. constructor:初始化一个空对象 events 用于存储事件名和对应的回调函数数组

  2. on(eventName, callback)

    • 检查 events 对象中是否已存在该事件名的回调数组
    • 如果不存在,创建空数组
    • 将回调函数添加到数组中
  3. emit(eventName, ...args)

    • 获取该事件对应的回调函数数组
    • 如果存在,遍历数组并依次执行每个回调函数
    • 使用扩展运算符 ...args 将传入的参数传递给每个回调函数

这个实现满足题目的所有要求:支持同一事件的多个回调函数,通过 on 添加,通过 emit 触发。

四、题目 FED20 观察者模式

描述

请补全JavaScript代码,完成"Observer"、"Observerd"类实现观察者模式。要求如下:

  1. 被观察者构造函数需要包含"name"属性和"state"属性且"state"初始值为"走路"

  2. 被观察者创建"setObserver"函数用于保存观察者们

  3. 被观察者创建"setState"函数用于设置该观察者"state"并且通知所有观察者

  4. 观察者创建"update"函数用于被观察者进行消息通知,该函数需要打印(console.log)数据,数据格式为:小明正在走路。其中"小明"为被观察者的"name"属性,"走路"为被观察者的"state"属性

注意:"Observer"为观察者,"Observerd"为被观察者

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            // 补全代码
            class Observerd {
                
            }

            class Observer {
                
            }
        </script>
    </body>
</html>

五、观察者模式

在 JavaScript 中,经常会遇到一个问题:你需要一种方法来响应特定事件,并利用这些事件提供的数据来更新页面的某些部分。

例如,用户输入后,你需要将其应用到一个或多个组件中。这会导致代码中出现大量的推送和拉取操作,以保持所有内容的同步。

观察者模式正是在这种情况下发挥作用——它支持元素之间的一对多数据绑定。

这种单向数据绑定可以由事件驱动。借助这种模式,您可以构建可重用的代码,以满足您的特定需求。

核心概念

  • 被观察者(Observable):维护一组观察者,状态变化时自动通知它们
  • 观察者(Observer):订阅被观察者,当被通知时执行相应逻辑

被观察者的三个核心部分

EventObserver
│ 
├── subscribe: adds new observable events
│ 
├── unsubscribe: removes observable events
|
└── broadcast: executes all events with bound data
部分 作用
observers 数组,存储所有观察者
subscribe() 添加观察者
unsubscribe() 移除观察者
notify(data) 通知所有观察者

基础实现(ES6 Class)

class Observable {
  constructor() {
    this.observers = [];
  }
  subscribe(func) {
    this.observers.push(func);
  }
  unsubscribe(func) {
    this.observers = this.observers.filter(observer => observer !== func);
  }
  notify(data) {
    this.observers.forEach(observer => observer(data));
  }
}

观察者模式的实际应用

例如博客字数统计演示

创建一个博客文章输入框,系统自动统计字数。用户每次按键输入,都通过观察者模式触发同步更新。

  1. 观察者模式追踪文本区域的变化
  2. 字数统计实时显示在输入框下方
  3. 箭头函数实现单行事件绑定
  4. 广播事件驱动变更给所有订阅者

字数统计函数

const getWordCount = (text) => text ? text.trim().split(/\s+/).length : 0;

单元测试示例

// 准备
const blogPost = 'This is a blog \n\n  post with a word count.     ';

// 执行
const count = getWordCount(blogPost);

// 验证
assert.strictEqual(count, 9);

注:该函数能处理多种边界情况,包括换行、多个空格等。

DOM 集成步骤

  1. HTML 结构
<textarea id="blogPost" placeholder="Enter your blog post..." class="blogPost">
</textarea>
  1. JavaScript 实现
// 创建字数显示元素
const wordCountElement = document.createElement('p');
wordCountElement.className = 'wordCount';
wordCountElement.innerHTML = 'Word Count: <strong id="blogWordCount">0</strong>';
document.body.appendChild(wordCountElement);

// 创建观察者实例
const blogObserver = new EventObserver();

// 订阅更新
blogObserver.subscribe((text) => {
  const blogCount = document.getElementById('blogWordCount');
  blogCount.textContent = getWordCount(text);
});

// 绑定事件
const blogPost = document.getElementById('blogPost');
blogPost.addEventListener('keyup', () => blogObserver.broadcast(blogPost.value));

扩展:RxJS

RxJS 是一个库,它通过使用 observable 序列来编写异步和基于事件的程序。它提供了一个核心类型 Observable,附属类型 (Observer、 Schedulers、 Subjects) 和受 [Array#extras] 启发的操作符 (map、filter、reduce、every, 等等),这些数组操作符可以把异步事件作为集合来处理。

可以把 RxJS 当做是用来处理事件的 Lodash

ReactiveX 结合了 观察者模式迭代器模式使用集合的函数式编程,以满足以一种理想方式来管理事件序列所需要的一切。

RxJS 中用来解决异步事件管理的的基本概念是:

  • Observable (可观察对象): 表示一个概念,这个概念是一个可调用的未来值或事件的集合。
  • Observer (观察者): 一个回调函数的集合,它知道如何去监听由 Observable 提供的值。
  • Subscription (订阅): 表示 Observable 的执行,主要用于取消 Observable 的执行。
  • Operators (操作符): 采用函数式编程风格的纯函数 (pure function),使用像 mapfilterconcatflatMap 等这样的操作符来处理集合。
  • Subject (主体): 相当于 EventEmitter,并且是将值或事件多路推送给多个 Observer 的唯一方式。
  • Schedulers (调度器): 用来控制并发并且是中央集权的调度员,允许我们在发生计算时进行协调,例如 setTimeoutrequestAnimationFrame 或其他。

结合观察者模式 + 迭代器模式 + 函数式编程

示例:监听鼠标拖拽事件

merge(
  fromEvent(document, "mousedown").pipe(mapTo(false)),
  fromEvent(document, "mousemove").pipe(mapTo(true))
)
.pipe(sample(fromEvent(document, "mouseup")))
.subscribe(isDragging => console.log(isDragging));

优点

  • 关注点分离
  • 符合单一职责原则
  • 观察者和被观察者低耦合

缺点

  • 观察者过多时,通知所有观察者可能带来性能问题

六、解法

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            class Observerd {
                constructor(name){
                    this.name = name ;
                    this.state = '走路' ;
                    this.observers = [];
                }

                setObserver(observer){
                    this.observers.push (observer);
                }

                setState(state){
                    this.state = state ;
                    this.notifyAllObservers();
                }     

                notifyAllObservers(){
                    this.observers.forEach(observer => {
                        observer.update(this);
                    });
                }
            }

            class Observer {
                update(observed){
                    console.log(`${observed.name}正在${observed.state}`);
                }
            }
        </script>
    </body>
</html>

根据题目要求,需要实现观察者模式。

以下是补全的代码:

class Observerd {
    constructor(name) {
        this.name = name;
        this.state = "走路";
        this.observers = [];
    }
    
    setObserver(observer) {
        this.observers.push(observer);
    }
    
    setState(state) {
        this.state = state;
        this.notifyAllObservers();
    }
    
    notifyAllObservers() {
        this.observers.forEach(observer => {
            observer.update(this);
        });
    }
}

class Observer {
    update(observed) {
        console.log(`${observed.name}正在${observed.state}`);
    }
}

代码说明:

  1. Observerd(被观察者)类

    • constructor(name):构造函数接收name参数,初始化name属性、state属性(初始值为"走路")和observers数组(存储观察者)
    • setObserver(observer):添加观察者到observers数组
    • setState(state):更新state属性,并通知所有观察者
    • notifyAllObservers():遍历所有观察者,调用每个观察者的update方法,传入当前被观察者实例
  2. Observer(观察者)类

    • update(observed):接收被观察者对象,按照指定格式打印日志(如:"小明正在走路")

使用示例:

const observed = new Observerd("小明");
const observer = new Observer();

observed.setObserver(observer);
observed.setState("跑步"); // 控制台输出:小明正在跑步

七、总结

观察者模式与发布者-订阅者模式有何不同?

虽然两种模式都涉及一对多依赖关系,但关键区别在于主体(或发布者)与其观察者(或订阅者)之间的通信方式。

  • 在观察者模式中,主体直接通知其观察者。

  • 在发布-订阅模式中,发布者将通知发送到中介(或通道),然后由中介将通知推送给订阅者。

这种额外的抽象层使得通知过程更加灵活和可定制。

原文:

How does the Observer Pattern differ from the Publisher-Subscriber Pattern?

While both patterns involve one-to-many dependencies, the key difference lies in how the subject (or publisher) communicates with its observers (or subscribers). In the Observer Pattern, the subject directly notifies its observers. In the Publisher-Subscriber Pattern, the publisher sends notifications to a mediator (or channel), which then pushes the notifications to the subscribers. This extra level of abstraction allows for greater flexibility and customization of the notification process.

发布订阅模式 观察者模式
有没有中间人 有(事件中心) 没有(直接通知)
双方知不知道对方存在 不知道(通过事件名交流) 知道(被观察者存着观察者列表)
生活类比 微信群:发消息的人不知道谁在看 你订阅了某人的微博:他更新了主动推给你

说实话,这两题面试手撕代码题实际上背负了很多抽象概念,单独的内容也都可以抽出来好好讲讲,难度并不低。

对于初学者建议按这个顺序来:

  1. 先熟悉上面的代码(应付面试)
  2. 然后自己手敲 3 遍(不要复制粘贴)
  3. 再去看本文的"应用场景"部分(这时候才有共鸣)
  4. 最后再去理解 RxJS、优缺点这些进阶内容

我也是初次深入学习一下这些概念,信息量大得有点懵。

但是回头看看我实际写过的项目代码,很多已经用到了这些思想,只是当时没有注意到这个模式。不妨现在好好回头去整理整理。

限于个人写作,文中若有疏漏,还请不吝赐教。

参考文档

发布订阅模式 vs 观察者模式:它们真的是一回事吗?本文深入解析发布订阅与观察者模式的核心差异:发布订阅通过事件中心实现 - 掘金

Node.js EventEmitter | 菜鸟教程

events 事件触发器 | Node.js v24 文档

观察者模式 - JavaScript 设计模式

JavaScript Design Patterns: The Observer Pattern — SitePoint

遇到前端题目,我现在会先问自己这四个问题

备考面试的过程里,我发现一件有意思的事:很多题目表面上考的是不同的知识点,但解题的起点其实是一样的——先搞清楚题目的结构,再选对应的模型

后来我把这个过程提炼成四个问题,每次看到新题目,不管是面试题还是实际业务需求,都先把这四个问题过一遍。这篇文章把这四个问题展开来讲,每个配上真实的代码场景,是我整理这套思考框架的过程记录。


为什么需要判断框架

先说一个让我意识到这件事重要性的场景。

同样是"批量操作",有人问的是"批量审批,允许部分失败",有人问的是"批量上传,限制同时最多 3 个"。表面上都是批量,但前者的核心是容错并发Promise.allSettled),后者的核心是背压控制(并发池)。如果只靠关键词匹配,看到"批量"就想到同一个答案,就会答错。

判断框架的作用是:在选答案之前,先把题目的维度看清楚

四个问题的完整决策树

image.png


问题一:这里的数据是什么形状?

数据的形状决定了应该用什么数据结构和遍历方式。常见的两个场景:

场景 A:需要聚合/分组 → Map + 复合 key

触发信号是"按 X 统计 Y"、"分组"、"去重后计数"。

// 环境:浏览器 / Node.js
// 场景:统计每个用户每个月的消费金额

const records = [
  { userId: 'u1', month: '2024-01', amount: 300 },
  { userId: 'u1', month: '2024-01', amount: 200 },
  { userId: 'u1', month: '2024-02', amount: 150 },
  { userId: 'u2', month: '2024-01', amount: 400 },
];

// ✅ 复合 key + Map:O(1) 查询,结构清晰
function aggregateByUserAndMonth(records) {
  const map = new Map();

  for (const record of records) {
    const key = `${record.userId}|${record.month}`;
    map.set(key, (map.get(key) ?? 0) + record.amount);
  }

  return map;
}

const result = aggregateByUserAndMonth(records);
console.log(result.get('u1|2024-01')); // 500
console.log(result.get('u2|2024-01')); // 400

为什么不用嵌套 reduce? 嵌套 reduce 能跑,但读起来难受——你要先理解"外层在做什么"再理解"内层在做什么"。Map + 复合 key 把两个维度拍平,逻辑是线性的,写的人和读的人都轻松。

场景 B:树形/嵌套结构 → 递归 + 状态提升

触发信号是"组织架构"、"分类目录"、"嵌套评论"。

// 环境:浏览器(React)
// 场景:部门树,展开状态集中管理,子节点只负责渲染

// 关键决策:展开状态存在父组件的 Set 里,不存在每个节点自己身上
// 好处:全部展开/折叠只需要操作父组件的 Set,不需要广播给所有节点

function DeptTree({ data }) {
  const [expandedIds, setExpandedIds] = useState(new Set());

  const toggle = useCallback((id) => {
    setExpandedIds((prev) => {
      const next = new Set(prev);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });
  }, []);

  return data.map((node) => (
    <DeptNode
      key={node.id}
      node={node}
      expandedIds={expandedIds}
      onToggle={toggle}
    />
  ));
}

// 递归节点:纯渲染,不持有状态
const DeptNode = React.memo(function DeptNode({ node, expandedIds, onToggle }) {
  const isExpanded = expandedIds.has(node.id);
  const hasChildren = node.children?.length > 0;

  return (
    <div style={{ paddingLeft: 16 }}>
      <div onClick={() => hasChildren && onToggle(node.id)}>
        {hasChildren ? (isExpanded ? '▼' : '▶') : '·'} {node.name}
      </div>
      {isExpanded && hasChildren && node.children.map((child) => (
        <DeptNode
          key={child.id}
          node={child}
          expandedIds={expandedIds}
          onToggle={onToggle}
        />
      ))}
    </div>
  );
});

问题二:这里的时间维度是什么?

"时间维度"是指:这个操作有没有等待、顺序、取消、重试的需求?有时间维度的题,核心都在异步控制。

场景 A:多个操作需要同时进行 → 并发模型

// 环境:浏览器 / Node.js
// 场景:页面初始化需要同时请求三个接口

// 需要全部成功才能渲染 → Promise.all(一个失败全部失败)
async function initDashboard() {
  const [user, orders, stats] = await Promise.all([
    fetchUser(),
    fetchOrders(),
    fetchStats(),
  ]);
  return { user, orders, stats };
}

// 允许部分失败,每条都要有结果 → Promise.allSettled
async function batchExport(orderIds) {
  const tasks = orderIds.map((id) =>
    exportOrder(id)
      .then(() => ({ id, ok: true }))
      .catch((err) => ({ id, ok: false, reason: err.message }))
  );

  const results = await Promise.allSettled(tasks);
  return results.map((r) => (r.status === 'fulfilled' ? r.value : r.reason));
}

场景 B:需要限制并发数 → 并发池(背压控制)

// 环境:浏览器 / Node.js
// 场景:批量上传 100 个文件,同时最多 3 个并发
// 核心思路:维护一个"正在执行"的集合,完成一个立刻补充下一个

async function asyncPool(limit, tasks) {
  const results = new Array(tasks.length);
  const executing = new Set();

  for (let i = 0; i < tasks.length; i++) {
    const task = tasks[i];

    const p = Promise.resolve().then(() => task()).then((result) => {
      results[i] = result;
      executing.delete(p); // 完成后从执行集合移除
    });

    executing.add(p);

    // 达到上限时,等待任意一个完成再继续
    if (executing.size >= limit) {
      await Promise.race(executing);
    }
  }

  // 等待所有剩余任务完成
  await Promise.all(executing);
  return results;
}

// 使用示例
const uploadTasks = files.map((file) => () => uploadFile(file));
const results = await asyncPool(3, uploadTasks);

为什么此处的 const p 需要在 Promise.resolve().then() 中执行 task?

核心原因:控制执行时机

for (const p of promises) {
  // ❌ 直接执行 - 会立即开始所有任务
  const result = p.task();
  
  // ✅ 延迟执行 - 按顺序控制并发
  const result = Promise.resolve().then(() => p.task());
}

具体场景对比

场景 1:直接执行(无 Promise.resolve().then)
const tasks = [task1, task2, task3];

for (const task of tasks) {
  const p = task();  // 立即执行!
  running.add(p);
}
// 结果:3 个任务同时开始执行(并发爆炸)
场景 2:使用 Promise.resolve().then(延迟执行)
const tasks = [task1, task2, task3];

for (const task of tasks) {
  const p = Promise.resolve().then(() => task());  // 延迟到微任务
  running.add(p);
}
// 结果:先注册所有延迟任务,再按事件循环执行

关键机制:事件循环

同步代码(for 循环)→ 微任务队列(Promise.then)→ 执行 task()
     ↓                        ↓                    ↓
  立即执行               注册回调但不执行        真正开始执行

实际用途:并发控制

这种模式常见于 限制并发数 的场景:

async function* asyncPool(concurrency, tasks) {
  const executing = new Set();
  
  for (const task of tasks) {
    // 关键:延迟执行,让控制器先"占坑"
    const p = Promise.resolve().then(() => task());
    executing.add(p);
    
    // 清理完成的
    p.then(() => executing.delete(p));
    
    // 控制并发:满了就等一个完成
    if (executing.size >= concurrency) {
      await Promise.race(executing);
    }
    
    yield p;
  }
  
  await Promise.all(executing);
}

// 使用:最大 2 个并发
for await (const result of asyncPool(2, heavyTasks)) {
  console.log(result);
}

一句话总结

Promise.resolve().then()同步创建异步执行 分离,让你有机会在任务真正开始前做控制(如限制并发、错误处理、取消等)。

如果没有这层包装,任务会在 for 循环遍历时就立即执行,失去了控制的机会。

场景 C:有取消需求 → AbortController + ref

// 环境:浏览器(React)
// 场景:搜索框,用户快速输入时取消上一个请求,只保留最新一个

function useSearch(query) {
  const abortRef = useRef(null);
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query.trim()) return;

    // 取消上一个还在进行的请求
    abortRef.current?.abort();
    const controller = new AbortController();
    abortRef.current = controller;

    fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    })
      .then((res) => res.json())
      .then(setResults)
      .catch((err) => {
        if (err.name !== 'AbortError') console.error(err);
        // AbortError 是主动取消,不是错误,静默处理
      });

    return () => controller.abort();
  }, [query]);

  return results;
}

场景 D:有重试需求 → 指数退避

// 环境:浏览器 / Node.js
// 场景:请求失败后重试,每次等待时间翻倍,避免持续打服务器

async function fetchWithRetry(url, options = {}, retries = 3) {
  const { baseDelay = 1000, ...fetchOptions } = options;

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await fetch(url, fetchOptions);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return await res.json();
    } catch (err) {
      // 最后一次也失败,直接抛出
      if (attempt === retries) throw err;

      // 指数退避:1s → 2s → 4s,加随机抖动避免多个客户端同时重试
      const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 500;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

问题三:这里谁需要感知变化?

这个问题专门针对 React 组件里的状态设计。搞清楚"谁需要感知",能避免大量不必要的重渲染。

场景 A:变化只有局部需要感知 → 状态下沉 + 精确订阅

// 环境:浏览器(React)
// 场景:50 个订单卡片,只有状态变化的那张需要重渲染
// 依赖:zustand

import { create } from 'zustand';

const useOrderStore = create((set) => ({
  statuses: {}, // { [orderId]: status }
  update: (orderId, status) =>
    set((state) => ({
      statuses: { ...state.statuses, [orderId]: status },
    })),
}));

// 每个 OrderCard 精确订阅自己那条
// 其他订单状态变化时,这个组件完全不感知,不重渲染
const OrderCard = React.memo(function OrderCard({ orderId }) {
  const status = useOrderStore(
    useCallback((state) => state.statuses[orderId], [orderId])
  );

  return (
    <div className={`card status-${status}`}>
      Order {orderId}: {status}
    </div>
  );
});

场景 A-2:从零手写一个精确订阅 store

用 Zustand 能解决问题,但理解"精确订阅为什么能工作",需要从零实现一次。这道题是一个很好的切入点:实现一个点赞 store,要求每个 item 的订阅者只在自己那条数据变化时才被通知。

// 环境:浏览器 / Node.js
// 场景:轻量点赞 store,精确订阅——只通知真正变化的那条

function createLikeStore(initialLikes = {}) {
  // 状态:点赞数据的真实来源
  const likes = { ...initialLikes };

  // 订阅表:每个 itemId 对应一个 listener Set
  // 用 Map<itemId, Set<listener>> 而不是 Map<itemId, listener[]>
  // 原因:Set 保证同一个 listener 不会被重复添加,且删除是 O(1)
  const listenersById = new Map();

  return {
    // 返回快照(浅拷贝),防止外部直接修改内部状态
    getState() {
      return { ...likes };
    },

    toggleLike(itemId) {
      const nextValue = !likes[itemId];
      likes[itemId] = nextValue;

      // 精确通知:只触发这个 itemId 的订阅者,其他 item 的订阅者完全不感知
      const listeners = listenersById.get(itemId);
      if (listeners) {
        for (const listener of listeners) {
          listener(nextValue);
        }
      }

      return nextValue;
    },

    subscribeById(itemId, listener) {
      // 懒初始化:第一次有人订阅这个 itemId 时才创建 Set
      if (!listenersById.has(itemId)) {
        listenersById.set(itemId, new Set());
      }
      listenersById.get(itemId).add(listener);

      // 返回卸载函数——这个设计的必要性见下方说明
      return () => {
        const listeners = listenersById.get(itemId);
        if (!listeners) return;

        listeners.delete(listener);

        // 当这个 itemId 没有任何订阅者时,清理 Map 中的条目
        // 防止 Map 无限增长(内存泄漏)
        if (listeners.size === 0) {
          listenersById.delete(itemId);
        }
      };
    },
  };
}

为什么 subscribeById 必须返回卸载函数?

这是这道题里最值得单独理解的设计决策。

想象一个商品列表页,有 100 个商品卡片,每个都订阅了自己的点赞状态。用户从这个页面跳转到详情页,列表页的 100 个组件全部卸载。如果没有卸载函数:

// 没有卸载函数时会发生什么

// 组件挂载时订阅
useEffect(() => {
  store.subscribeById(itemId, (nextValue) => {
    setIsLiked(nextValue); // 更新已卸载组件的 state
  });
  // 没有返回卸载函数
}, [itemId]);

// 问题一:内存泄漏
// listenersById 里仍然持有 100 个已卸载组件的 listener 引用
// 这 100 个闭包无法被垃圾回收,页面停留越久内存占用越高

// 问题二:调用已卸载组件的 setState
// 用户在详情页点了某个商品的赞,store 触发通知
// 那个已卸载的 listener 还在,被调用后尝试 setIsLiked
// React 会报警告:Can't perform a React state update on an unmounted component

有了卸载函数,就能在 useEffect 的 cleanup 里调用它:

// 环境:浏览器(React)
// 场景:组件卸载时自动清理订阅,避免内存泄漏

function LikeButton({ itemId, store }) {
  const [isLiked, setIsLiked] = useState(
    () => store.getState()[itemId] ?? false
  );

  useEffect(() => {
    // 订阅这个 item 的状态变化
    const unsubscribe = store.subscribeById(itemId, (nextValue) => {
      setIsLiked(nextValue);
    });

    // cleanup:组件卸载时调用 unsubscribe
    // → listener 从 Set 中删除
    // → 如果这个 itemId 没有其他订阅者,Map 中的条目也被清理
    return unsubscribe;
  }, [itemId, store]);

  return (
    <button onClick={() => store.toggleLike(itemId)}>
      {isLiked ? '❤️' : '🤍'}
    </button>
  );
}

这个模式在 React 生态里几乎无处不在——useEffect 返回的 cleanup 函数,本质上就是在调用"注册时拿到的卸载句柄"。Redux 的 store.subscribe、RxJS 的 subscription.unsubscribe、浏览器原生的 removeEventListener,都是同一个模式:注册时拿到句柄,卸载时用句柄清理

这也是为什么"cleanup 对称原则"(问题四的场景 C)和这里的"卸载函数设计"是同一个底层思想——你开了什么,就要有对应的关闭路径。

场景 B:变化需要跨 render 保留,但不触发渲染 → useRef

判断标准只有一句话:这个值变化时需要更新 UI 吗? 需要 → useState,不需要 → useRef

// 环境:浏览器(React)
// 场景:轮询实现,timer id 变化不需要触发渲染

function usePolling(fetchFn, interval = 5000) {
  const timerRef = useRef(null);     // timer id → 不需要触发渲染 → useRef
  const [data, setData] = useState(null); // 数据 → 需要更新 UI → useState

  useEffect(() => {
    const tick = async () => {
      const result = await fetchFn();
      setData(result);
    };

    tick(); // 立即执行一次
    timerRef.current = setInterval(tick, interval);

    // cleanup:组件卸载时停止轮询
    return () => clearInterval(timerRef.current);
  }, [fetchFn, interval]);

  return data;
}

场景 C:派生状态 → 不要单独存一份 state

// 环境:浏览器(React)
// 场景:购物车,"是否全选"可以从 items 推导,不需要单独管理

function Cart() {
  const [items, setItems] = useState([
    { id: 1, name: 'Item A', selected: true },
    { id: 2, name: 'Item B', selected: false },
  ]);

  // ✅ 派生状态:从 items 计算,不单独存 state
  // 避免 items 和 isAllSelected 不同步的 bug
  const isAllSelected = items.every((item) => item.selected);
  const selectedCount = items.filter((item) => item.selected).length;

  const toggleAll = () => {
    setItems((prev) =>
      prev.map((item) => ({ ...item, selected: !isAllSelected }))
    );
  };

  return (
    <div>
      <label>
        <input type="checkbox" checked={isAllSelected} onChange={toggleAll} />
        全选(已选 {selectedCount} 件)
      </label>
      {/* 渲染列表 */}
    </div>
  );
}

问题四:这里可能出什么错?

最后这个问题是防御性思维——在写"正常路径"的代码之前,先想清楚异常路径。

场景 A:部分操作可能失败 → allSettled + 结果收集

// 环境:浏览器 / Node.js
// 场景:批量发送通知,有些用户可能发送失败,需要知道哪些失败了

async function sendBatchNotifications(userIds, message) {
  const tasks = userIds.map((userId) =>
    sendNotification(userId, message)
      .then(() => ({ userId, success: true }))
      .catch((err) => ({ userId, success: false, error: err.message }))
  );

  const settled = await Promise.allSettled(tasks);

  const succeeded = [];
  const failed = [];

  for (const result of settled) {
    // allSettled 不会 reject,每个结果都是 fulfilled
    // value 里才是我们自己封装的成功/失败信息
    if (result.status === 'fulfilled' && result.value.success) {
      succeeded.push(result.value.userId);
    } else {
      failed.push(
        result.status === 'fulfilled'
          ? result.value
          : { userId: 'unknown', error: result.reason }
      );
    }
  }

  return { succeeded, failed };
}

场景 B:异步操作可能竞态 → 标记最新请求

竞态的本质是:多个异步操作在同一个"目标"上竞争,需要决定哪个的结果有效,过期的丢弃。

处理竞态有三种方案,力度递增:

方案一:isCurrent 标记      结果回来了,但我忽略它(被动丢弃,React 组件内)
方案二:requestId 计数器    结果回来了,但我忽略它(被动丢弃,框架无关)
方案三:AbortController    结果还没回来,我让它停止(主动取消)

方案一: isCurrent —— React 组件内的局部标记

// 环境:浏览器(React)
// 场景:Tab 切换时快速加载不同内容,只展示最后一次请求的结果

function useTabData(activeTab) {
  const [data, setData] = useState(null);

  useEffect(() => {
    // 标记活在这次 effect 的闭包里,只属于"这一次"
    // 每次 activeTab 变化,产生新的闭包和新的 isCurrent
    let isCurrent = true;

    fetchTabData(activeTab).then((result) => {
      if (isCurrent) setData(result); // 过期就不更新
    });

    return () => {
      isCurrent = false; // cleanup 让这次请求过期
    };
  }, [activeTab]);

  return data;
}

isCurrent二值凭证——只有有效/无效两种状态。标记的生命周期和单次 effect 绑定,cleanup 执行就过期,下次 effect 重新产生一个新的。

方案二:requestId 计数器 — 框架无关的通用包装

// 环境:浏览器 / Node.js
// 场景:包装任意异步函数,只让最新一次调用的结果被应用
// 可在 React 组件外使用,适合封装成通用工具

function createLatestOnlyRunner(asyncFn) {
  // 计数器活在外层闭包,跨所有调用共享
  // 每次新调用让计数器加一,旧调用的 id 永久地比最新 id 小
  let latestRequestId = 0;

  return async (...args) => {
    const requestId = ++latestRequestId; // 拿到这次调用的"身份 id"

    try {
      const value = await asyncFn(...args);

      // 结果回来时,检查自己是不是还是最新的那次
      if (requestId !== latestRequestId) {
        return { applied: false }; // 已过期,丢弃结果
      }
      return { applied: true, value };

    } catch (e) {
      // 失败时也要判断:是最新请求的失败才需要处理
      if (requestId !== latestRequestId) {
        return { applied: false }; // 过期请求的失败,静默丢弃
      }
      throw e; // 最新请求的失败,正常抛出
    }
  };
}

// 使用示例:搜索场景
const searchRunner = createLatestOnlyRunner(fetchSearchResults);

async function handleSearch(query) {
  const result = await searchRunner(query);
  if (result.applied) {
    renderResults(result.value); // 只有最新请求的结果才渲染
  }
  // applied: false 的直接忽略
}

requestId单调递增的数字凭证——通过大小关系自动判断新旧,不需要手动让旧的过期,新调用天然让旧调用失效。标记的生命周期和 runner 整个生命周期绑定。

两种方案的核心差异对比

                    isCurrent 方案              requestId 方案
标记存在哪里         useEffect 闭包(局部)       外层闭包(全局共享)
生命周期             和单次 effect 绑定           和 runner 整个生命周期绑定
过期机制             cleanup 主动设为 false        新调用让旧 id 自然过期
能否在组件外用        不能(依赖 useEffect)        能(纯函数,任何地方可用)
适合场景             React 组件内的异步请求        通用工具函数、非 React 环境

两者的底层思想完全相同:给每次操作打上身份凭证,结果回来时验证凭证是否仍然是最新的。 差异只在凭证的形态和管理方式。

方案三:AbortController — 主动取消,不等结果回来

// 环境:浏览器(React)
// 场景:搜索框,用户继续输入时直接取消上一个网络请求
// 不只是忽略结果,而是中止请求,节省服务器和网络资源

function useSearch(query) {
  const abortRef = useRef(null);
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query.trim()) return;

    abortRef.current?.abort(); // 取消上一个请求
    const controller = new AbortController();
    abortRef.current = controller;

    fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    })
      .then((res) => res.json())
      .then(setResults)
      .catch((err) => {
        if (err.name !== 'AbortError') console.error(err);
      });

    return () => controller.abort();
  }, [query]);

  return results;
}

AbortController 是主动取消凭证——不只是标记"这个结果过期了",而是直接发出取消信号,让网络层停止这个请求。代价更低(请求未完成就终止),但依赖 fetch 原生支持 signal 参数。

三种方案的选择建议

在 React 组件里,请求和组件生命周期绑定
  → isCurrent 标记,最简单,足够用

需要在组件外复用,或包装第三方异步函数
  → createLatestOnlyRunner,框架无关,可测试

请求体积大、耗时长,取消能节省明显资源
  → AbortController,主动中止,力度最强

场景 C:副作用可能泄漏 → cleanup 对称原则

// 环境:浏览器(React)
// 场景:WebSocket 连接,组件卸载时必须关闭,否则内存泄漏

function useOrderMonitor(orderId) {
  const [status, setStatus] = useState(null);

  useEffect(() => {
    const ws = new WebSocket(`wss://api.example.com/orders/${orderId}`);

    ws.onmessage = (event) => {
      const { status } = JSON.parse(event.data);
      setStatus(status);
    };

    // 启动了 WebSocket → cleanup 里就关闭 WebSocket
    // 对称原则:你在 effect 里做了什么,cleanup 里就撤销什么
    return () => ws.close(1000, 'component unmounted');
  }, [orderId]);

  return status;
}

场景 D:数据可能过期 → TTL + 重新验证

// 环境:浏览器
// 场景:缓存接口数据,超过有效期后重新请求

const cache = new Map(); // key → { data, timestamp }
const CACHE_TTL = 5 * 60 * 1000; // 5 分钟

async function fetchWithCache(url) {
  const cached = cache.get(url);

  if (cached) {
    const isExpired = Date.now() - cached.timestamp > CACHE_TTL;
    if (!isExpired) return cached.data; // 缓存有效,直接返回
  }

  // 缓存不存在或已过期,重新请求
  const data = await fetch(url).then((r) => r.json());
  cache.set(url, { data, timestamp: Date.now() });
  return data;
}

延伸与发散

整理这四个问题的过程里,我发现它们之间有一些有趣的交叉:

问题三和问题四的交叉:竞态问题(问题四)本质上也是"谁应该感知变化"的问题(问题三)——当两个异步操作都想更新同一个状态时,你需要决定"只有最新的那个有资格更新"。isCurrent 标记和 AbortController 都是在回答这个问题。

问题二和问题四的交叉:需要取消和需要重试,本质上都是"时间维度里的异常路径"。一个是"有更新的请求来了,旧的不需要了";一个是"这个请求失败了,隔一段时间再试"。两者都是对异步操作生命周期的管理。

还有一类题目这四个问题都没有完全覆盖:乐观更新。先更新 UI,再发请求,失败时回滚。这既涉及"谁感知变化"(状态管理),也涉及"可能出什么错"(失败回滚),是一个跨维度的场景。这个模型值得单独展开讨论。


小结

这四个问题不是一套算法,不能保证"输入题目,输出答案"。它们更像是一套减少遗漏的检查清单——帮助你在开始写代码之前,把题目的关键维度都想到。

实际使用时,四个问题往往不是顺序回答的,而是同时浮现的。看到"批量上传",同时会想到"时间维度(并发)"和"可能出错(部分失败)"。这种并行思考是熟练之后自然发生的,不需要刻意按顺序走。

如果你有其他觉得有用的判断维度,欢迎交流——这套框架本身也是在持续迭代的。


参考资料

JS手撕:对象创建、继承全解析

在 JavaScript 中,对象是核心数据类型,几乎所有业务开发都离不开对象的创建与复用。很多新手会困惑“为什么创建对象有这么多种方式?”“哪种继承方式最靠谱?”,本文将用「通俗类比+专业拆解」的方式,把 6 种对象创建方法、7 种继承方式讲透,同时补充底层原理和实战选型建议,兼顾入门理解与面试备考。

一、JS 对象创建方法(6种,按常用度排序)

创建对象的核心是“封装属性和方法”,不同方式的区别在于「代码简洁度」「复用性」「性能」,我们逐个拆解,结合代码示例和场景分析,让你一看就懂。

1. 对象字面量 {}(最常用、最简单,入门首选)

这是最直观、最简洁的创建方式,直接用 {} 包裹键值对,相当于“随手创建一个独立对象”,不用额外定义模板,适合快速创建单个对象。

专业说明:对象字面量是 ES5 引入的语法,底层会隐式调用 Object() 构造函数,但省略了冗余代码,JS 引擎会对其进行优化,执行效率高于显式调用 new Object()

// 基础写法(键名无特殊字符,可省略引号)
const person = {
  name: "张三",
  age: 20,
  // 方法简写(ES6 语法,等价于 sayHello: function() {})
  sayHello() {
    console.log(`你好,我是${this.name}`);
  }
};

// 特殊场景写法(键名含空格、特殊字符,需加引号)
const user = {
  "user-name": "lisi",
  "age+1": 21,
  ["say" + "Hi"]() { // 计算属性名(ES6)
    console.log("Hi~");
  }
};

// 使用方式(两种均可,推荐点语法,更简洁)
person.sayHello(); // 输出:你好,我是张三
user["user-name"]; // 输出:lisi
user.sayHi(); // 输出:Hi~

核心特点

  • ✅ 最简单、代码量最少,上手无门槛,日常开发高频使用

  • ✅ 适合创建「单个独立对象」(比如配置对象、单个用户信息)

  • ❌ 不适合批量创建多个相似对象(比如创建10个用户,会出现大量重复代码,维护成本高)

  • ✅ 支持 ES6 语法糖(方法简写、计算属性名),写法更灵活


2. 构造函数(new 函数名(),批量创建基础方案)

如果需要创建多个结构相似的对象(比如多个用户、多个商品),就需要一个“模板”——构造函数。构造函数本质是一个普通函数,通过 this 绑定属性和方法,配合 new 关键字生成实例,实现批量创建。

通俗类比:构造函数就像“工厂模具”,new 关键字就像“启动模具生产”,每个实例都是模具生产出的“产品”,结构一致但内容可自定义。

// 定义构造函数(首字母大写,约定俗成,区分普通函数)
function Person(name, age) {
  // this 指向当前创建的实例(new 关键字自动绑定)
  this.name = name; // 实例属性(每个实例独有)
  this.age = age;
  // 方法直接写在构造函数内(每个实例都会单独创建一个该方法)
  this.sayHello = function () {
    console.log(`我是${this.name},今年${this.age}岁`);
  };
}

// 创建实例(new 关键字不可省略,否则 this 指向 window)
const p1 = new Person("张三", 20);
const p2 = new Person("李四", 21);

// 使用实例
p1.sayHello(); // 输出:我是张三,今年20岁
p2.sayHello(); // 输出:我是李四,今年21岁

// 验证:两个实例的方法是不同的(内存地址不一样)
console.log(p1.sayHello === p2.sayHello); // 输出:false

核心特点

  • ✅ 适合「批量创建对象」,通过参数传递,实现实例属性自定义

  • ✅ 可通过 instanceof 判断实例类型(比如 p1 instanceof Person → true),便于类型校验

  • ❌ 核心缺点:方法会在每个实例中重复创建(比如上面的 sayHello 方法,p1 和 p2 各有一个,占用额外内存),实例越多,内存浪费越严重

  • ❌ 不适合复杂场景(比如方法复用、继承)


3. 原型模式(构造函数 + 原型,原生最优方案)

为了解决“构造函数方法重复创建”的问题,原型模式应运而生。核心思路:将公共方法挂载到构造函数的原型(prototype)上,所有实例共享同一个原型对象,因此公共方法只需要创建一次,节省内存。

专业原理:JS 中每个函数都有 prototype 属性(原型对象),每个实例都有 __proto__ 属性(隐式原型),实例的 __proto__ 会指向其构造函数的 prototype。当访问实例的方法时,JS 会先在实例自身查找,找不到就去原型对象中查找,这就是“原型链查找”。

// 1. 定义构造函数(只放实例独有属性)
function Person(name, age) {
  this.name = name;
  this.age = age;
}

// 2. 公共方法挂载到原型上(所有实例共享)
Person.prototype.sayHello = function () {
  console.log(`我是${this.name},今年${this.age}岁`);
};
// 原型上也可以添加公共属性
Person.prototype.gender = "男";

// 3. 创建实例
const p1 = new Person("张三", 20);
const p2 = new Person("李四", 21);

// 使用实例
p1.sayHello(); // 输出:我是张三,今年20岁
p2.sayHello(); // 输出:我是李四,今年21岁
console.log(p1.gender); // 输出:男(原型上的公共属性)

// 验证:两个实例的方法是同一个(内存地址相同)
console.log(p1.sayHello === p2.sayHello); // 输出:true

核心特点

  • ✅ 公共方法「共享」,只创建一次,大幅节省内存,性能最优

  • ✅ 兼顾批量创建和复用性,是原生 JS 中「最推荐的批量创建方案」

  • ✅ 支持原型链查找,可灵活扩展公共方法/属性

  • ✅ 企业开发中,原生 JS 场景首选此方案(比如封装原生组件、工具类)


4. Object.create()(基于原型创建,灵活继承方案)

Object.create() 是 ES5 引入的方法,核心功能是「基于一个现有对象作为原型,创建一个新对象」。它跳过了构造函数,直接通过原型对象生成新实例,适合灵活实现原型继承,或创建无原型的空对象。

通俗理解:相当于“复制一个现有对象的‘模板’,再基于这个模板创建新对象”,新对象会继承模板对象的所有属性和方法,同时可以自定义自己的属性。

// 1. 定义原型对象(模板对象)
const personProto = {
  name: "默认名字", // 原型属性(可被实例覆盖)
  sayHello() {
    console.log(`我是${this.name}`);
  },
  // 原型上的方法也可以访问原型上的其他属性
  showDefaultName() {
    console.log("默认名字:", this.name);
  }
};

// 2. 创建新对象(继承 personProto)
const p1 = Object.create(personProto);
p1.name = "张三"; // 覆盖原型上的 name 属性
p1.age = 20; // 新增实例独有属性

// 3. 使用新对象
p1.sayHello(); // 输出:我是张三(访问实例自身的 name)
p1.showDefaultName(); // 输出:默认名字:张三(this 指向 p1)

// 特殊场景:创建空原型对象(无任何继承,适合做纯净的映射表)
const emptyObj = Object.create(null);
console.log(emptyObj.toString); // 输出:undefined(没有继承 Object 的原型方法)

核心特点

  • ✅ 灵活实现「原型继承」,无需定义构造函数,直接复用现有对象的原型

  • ✅ 可创建「空原型对象」(Object.create(null)),避免继承 Object 的原型方法(比如 toString、hasOwnProperty),适合做纯净的数据容器

  • ❌ 不适合批量创建带参数的对象(每次创建都需要手动添加实例属性,无法像构造函数那样通过参数批量赋值)

  • ✅ 适合“基于现有对象扩展”的场景(比如修改某个对象,但不想影响原对象)


5. class 语法(ES6 标准,最现代、最优雅)

class 是 ES6 引入的语法糖,本质还是「构造函数 + 原型」,只是写法更简洁、语义化更强,解决了原生构造函数+原型写法繁琐、可读性差的问题,是现代 JS 开发(Vue、React 等框架)的首选方案。

专业说明class 语法没有改变 JS 原型继承的底层原理,只是对构造函数和原型的封装,让代码结构更清晰,更接近传统面向对象语言(比如 Java、Python)的写法。

// 1. 定义 class(相当于构造函数的语法糖)
class Person {
  // 构造方法(相当于构造函数的函数体,new 时自动执行)
  constructor(name, age) {
    this.name = name; // 实例属性
    this.age = age;
  }

  // 实例方法(自动挂载到 Person.prototype 上,所有实例共享)
  sayHello() {
    console.log(`我是${this.name},今年${this.age}岁`);
  }

  // 静态方法(static 关键字修饰,挂载到类本身,不被实例继承)
  static showClassName() {
    console.log("当前类:Person");
  }

  //  getter/setter(用于控制属性的读取和修改,增强属性安全性)
  get fullInfo() {
    return `${this.name}-${this.age}岁`;
  }
  set fullInfo(info) {
    const [name, age] = info.split("-");
    this.name = name;
    this.age = Number(age);
  }
}

// 2. 创建实例(和 new 构造函数用法一致)
const p1 = new Person("张三", 20);

// 3. 使用实例
p1.sayHello(); // 输出:我是张三,今年20岁
console.log(p1.fullInfo); // 输出:张三-20岁(调用 getter)
p1.fullInfo = "李四-21岁"; // 调用 setter,修改属性
console.log(p1.name); // 输出:李四

// 调用静态方法(只能通过类调用,不能通过实例调用)
Person.showClassName(); // 输出:当前类:Person
p1.showClassName(); // 报错:p1.showClassName is not a function

核心特点

  • ✅ 语法清晰、语义化强,代码结构更规整,可读性远高于原生构造函数+原型

  • ✅ 企业开发「首选方案」,适配所有现代框架(Vue3、React 等)

  • ✅ 原生支持继承(extends 关键字)、静态方法(static)、getter/setter 等高级特性,无需手动操作原型

  • ✅ 底层还是原型继承,兼顾性能和复用性,同时降低了学习和使用成本


6. 工厂函数(返回对象的函数,不推荐使用)

工厂函数是一种“模拟类”的方案,核心思路:在函数内部创建一个空对象,添加属性和方法后返回该对象,无需使用 new 关键字。它是 ES6 之前,为了简化批量创建对象而出现的过渡方案,现在已基本被 class 和原型模式替代。

// 定义工厂函数(普通函数,无需首字母大写)
function createPerson(name, age) {
  // 1. 创建空对象
  const o = {};
  // 2. 给空对象添加属性和方法
  o.name = name;
  o.age = age;
  o.getName = function () {
    console.log(this.name);
  };
  // 3. 返回创建好的对象
  return o;
}

// 使用(不用 new,直接调用函数)
const p1 = createPerson("张三", 20);
const p2 = createPerson("李四", 21);

// 调用方法
p1.getName(); // 输出:张三

核心特点

  • ✅ 不用 new 关键字,使用简单,无需理解原型和构造函数

  • ❌ 核心缺点1:无法识别对象类型(p1 instanceof createPerson → false,因为没有构造函数,无法通过 instanceof 判断实例归属)

  • ❌ 核心缺点2:方法会在每个对象中重复创建,浪费内存(和纯构造函数一样的问题)

  • ❌ 不推荐使用:ES6 之后,class 和原型模式完全可以替代它,仅作为历史知识点了解即可

对象创建选型总结(实战必看)

日常开发中,无需死记所有方法,根据场景选择即可,记住以下 3 条核心规则,覆盖 99% 场景:

  1. 创建「单个对象」(比如配置对象、单个数据对象)→ 用 对象字面量 {}(简洁、高效)

  2. 批量创建对象、追求性能和复用性 → 用 class(现代开发首选,语义化强)

  3. 原生 JS 场景、不依赖 ES6 语法 → 用 构造函数 + 原型(性能最优,兼容所有环境)

补充说明Object.create() 适合特殊场景(比如原型继承、创建空对象);工厂函数、纯构造函数(方法写在内部)尽量避免使用。

一句话记忆:日常开发 90% 场景用「对象字面量 + class」,剩下 10% 用「Object.create()」。


二、JS 继承方式(7种,从基础到最优,面试重点)

继承的核心是“复用父类的属性和方法”,JS 没有传统面向对象的“类继承”,而是通过「原型链」实现继承。下面从基础到最优,逐个拆解 7 种继承方式,分析其优缺点和适用场景,重点掌握“寄生组合式继承”和“class extends”。

1. 原型链继承(最基础,面试常考)

核心原理:子类的原型 = 父类的实例,让子类实例通过原型链,继承父类的实例属性和原型方法。这是最基础的继承方式,也是后续所有继承方式的基础。

// 父类(构造函数)
function Parent() {
  this.colors = ['red', 'blue']; // 引用类型属性
  this.name = "父类";
}

// 父类原型方法
Parent.prototype.sayName = function() {
  console.log(this.name);
};

// 子类(构造函数)
function Child() {}

// 核心:子类原型 = 父类实例(实现继承)
Child.prototype = new Parent();
// 修复子类原型的 constructor 指向(否则指向 Parent)
Child.prototype.constructor = Child;

// 创建子类实例
const c1 = new Child();
const c2 = new Child();

// 测试继承效果
c1.sayName(); // 输出:父类(继承父类原型方法)
console.log(c1.colors); // 输出:['red', 'blue'](继承父类实例属性)

// 问题演示:引用类型属性被所有子类实例共享
c1.colors.push('green');
console.log(c2.colors); // 输出:['red', 'blue', 'green'](c2 的 colors 也被修改了)

核心优缺点

  • ✅ 优点:实现简单,代码量少,能继承父类的实例属性和原型方法

  • ❌ 缺点1:父类的引用类型属性(比如数组、对象)会被「所有子类实例共享」,一个实例修改,其他实例都会受影响(这是最致命的问题)

  • ❌ 缺点2:无法向父类构造函数传递参数(比如创建子类实例时,无法给父类的 name 属性赋值)

  • ❌ 缺点3:子类实例的 constructor 指向会被修改,需要手动修复


2. 构造函数继承(借用 call/apply,解决引用类型共享问题)

核心原理:在子类构造函数内部,通过 call/apply 调用父类构造函数,将父类的 this 绑定到子类实例上,让子类实例单独拥有父类的实例属性,解决原型链继承中“引用类型共享”的问题。

// 父类
function Parent(name) {
  this.name = name; // 实例属性
  this.colors = ['red', 'blue']; // 引用类型属性
}

// 父类原型方法
Parent.prototype.sayName = function() {
  console.log(this.name);
};

// 子类
function Child(name, age) {
  // 核心:借用 call 调用父类构造函数,this 指向子类实例
  Parent.call(this, name); // 相当于给子类实例添加父类的实例属性
  this.age = age; // 子类独有属性
}

// 创建子类实例
const c1 = new Child("张三", 20);
const c2 = new Child("李四", 21);

// 测试:引用类型属性不再共享
c1.colors.push('green');
console.log(c1.colors); // 输出:['red', 'blue', 'green']
console.log(c2.colors); // 输出:['red', 'blue'](不受影响)

// 测试:无法继承父类原型方法
c1.sayName(); // 报错:c1.sayName is not a function

核心优缺点

  • ✅ 优点1:避免了引用类型属性被所有实例共享的问题(每个实例都有独立的父类实例属性)

  • ✅ 优点2:可以向父类构造函数传递参数(比如上面的 name 参数)

  • ❌ 缺点:只能继承父类的「实例属性」,无法继承父类的「原型方法」(父类原型上的方法,子类实例无法访问)

  • ❌ 缺点:父类的实例属性会在每个子类实例中重复创建(和纯构造函数一样,浪费内存)


3. 组合继承(最经典,实战常用过渡方案)

核心原理:构造函数继承 + 原型链继承 合体——用构造函数继承解决“引用类型共享”和“传参”问题,用原型链继承解决“继承原型方法”的问题,兼顾了两者的优点,是 ES6 之前最常用的继承方案。

// 父类
function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

// 父类原型方法
Parent.prototype.sayName = function() {
  console.log(this.name);
};

// 子类
function Child(name, age) {
  // 1. 构造函数继承:继承父类实例属性,解决传参和引用类型共享问题
  Parent.call(this, name);
  this.age = age;
}

// 2. 原型链继承:继承父类原型方法
Child.prototype = new Parent();
// 修复 constructor 指向
Child.prototype.constructor = Child;

// 创建子类实例
const c1 = new Child("张三", 20);
const c2 = new Child("李四", 21);

// 测试:既能继承原型方法,又能避免引用类型共享
c1.sayName(); // 输出:张三(继承原型方法)
c1.colors.push('green');
console.log(c1.colors); // ['red', 'blue', 'green']
console.log(c2.colors); // ['red', 'blue'](不受影响)

核心优缺点

  • ✅ 优点:既能继承父类的实例属性,又能继承父类的原型方法,兼顾了实用性和复用性

  • ✅ 优点:解决了原型链继承和构造函数继承的核心缺点,是 ES6 之前最推荐的继承方案

  • ❌ 缺点:父类构造函数被调用了两次(一次是 new Parent() 给子类原型赋值,一次是 Parent.call(this) 给子类实例赋值),多执行了一遍冗余代码,造成轻微的性能浪费


4. 原型式继承(基于浅拷贝,简化原型链继承)

核心原理:基于现有对象浅拷贝创建新对象,本质是简化版的原型链继承,无需定义构造函数,直接通过一个现有对象作为原型,创建新对象。Object.create() 方法的底层实现就是原型式继承。

// 模拟 Object.create() 的底层实现(原型式继承核心代码)
function createObj(o) {
  // 定义一个空构造函数
  function F() {}
  // 让空构造函数的原型 = 现有对象 o
  F.prototype = o;
  // 返回空构造函数的实例(该实例的 __proto__ 指向 o)
  return new F();
}

// 现有对象(作为原型)
const parent = {
  name: "父对象",
  colors: ['red', 'blue'],
  sayName() {
    console.log(this.name);
  }
};

// 创建新对象(继承 parent)
const c1 = createObj(parent);
const c2 = createObj(parent);

// 测试继承效果
c1.sayName(); // 输出:父对象(继承原型方法)
console.log(c1.colors); // 输出:['red', 'blue'](继承原型属性)

// 问题:引用类型依然共享
c1.colors.push('green');
console.log(c2.colors); // 输出:['red', 'blue', 'green']

核心优缺点

  • ✅ 优点:实现简单,无需定义构造函数,适合“快速复用现有对象”的场景

  • ❌ 缺点1:引用类型属性依然被所有实例共享(和原型链继承一样的问题)

  • ❌ 缺点2:无法向父原型对象传递参数(只能复用现有对象的属性,无法自定义)

  • ✅ 补充:Object.create() 就是对这种方式的标准化实现,日常开发中直接用 Object.create() 即可,无需手动实现 createObj


5. 寄生式继承(原型式继承 + 对象增强)

核心原理:在原型式继承的基础上,给新创建的对象添加额外的属性和方法(增强对象),本质是对原型式继承的扩展,让新对象拥有更多自定义功能。

// 原型式继承 + 对象增强
function createChild(o) {
  // 1. 原型式继承:创建新对象,继承 o
  const clone = Object.create(o);
  // 2. 增强对象:给新对象添加独有属性和方法
  clone.sayHi = function() {
    console.log("Hi~");
  };
  clone.age = 20;
  // 3. 返回增强后的对象
  return clone;
}

// 父原型对象
const parent = {
  name: "父对象",
  sayName() {
    console.log(this.name);
  }
};

// 创建增强后的子类对象
const c1 = createChild(parent);

// 测试
c1.sayName(); // 输出:父对象(继承父原型方法)
c1.sayHi(); // 输出:Hi~(增强的方法)
console.log(c1.age); // 输出:20(增强的属性)

核心优缺点

  • ✅ 优点:简单快捷,能在复用现有对象的同时,给新对象扩展自定义功能

  • ❌ 缺点1:引用类型属性依然被共享(继承了原型式继承的问题)

  • ❌ 缺点2:增强的方法无法复用(每次创建新对象,都会新建一次增强方法,浪费内存)

  • ❌ 适用场景有限:仅适合“一次性创建一个增强对象”的场景,不适合批量创建


6. 寄生组合式继承(最完美、最优,面试必背)

核心原理:构造函数继承 + 原型式继承(Object.create),解决了组合继承“父构造函数被调用两次”的问题,同时保留了组合继承的所有优点,是 JS 继承的最佳实践,也是 class extends 的底层实现原理。

核心改进:用 Object.create(Parent.prototype) 替代 new Parent() 给子类原型赋值,这样只会继承父类的原型,不会调用父类构造函数,避免了冗余代码。

// 父类
function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

// 父类原型方法
Parent.prototype.sayName = function() {
  console.log(this.name);
};

// 子类
function Child(name, age) {
  // 1. 构造函数继承:继承父类实例属性,传参,避免引用类型共享
  Parent.call(this, name);
  this.age = age;
}

// 2. 核心改进:用 Object.create 继承父类原型,不调用父类构造函数
Child.prototype = Object.create(Parent.prototype);
// 修复 constructor 指向
Child.prototype.constructor = Child;

// 创建子类实例
const c1 = new Child("张三", 20);
const c2 = new Child("李四", 21);

// 测试:完美解决所有问题
c1.sayName(); // 输出:张三(继承原型方法)
c1.colors.push('green');
console.log(c1.colors); // ['red', 'blue', 'green']
console.log(c2.colors); // ['red', 'blue'](不共享)
console.log(c1.constructor); // 输出:Child(constructor 指向正确)

核心优缺点

  • ✅ 优点1:只调用一次父类构造函数,避免了组合继承的冗余代码,性能最优

  • ✅ 优点2:引用类型属性不共享,每个子类实例都有独立的父类实例属性

  • ✅ 优点3:能继承父类的实例属性和原型方法,原型链结构正常

  • ✅ 优点4:子类实例的 constructor 指向正确,无需额外修复(修复仅为规范,不影响功能)

  • ❌ 缺点:写法比 class extends 繁琐(这也是 ES6 引入 class 的原因)

  • ✅ 结论:JS 原生继承的「最佳实践」,面试必考,也是 class extends 的底层原理


7. class extends(ES6 语法糖,现代开发首选)

核心原理:ES6 引入的 extends 关键字,本质是「寄生组合式继承」的语法糖,写法更简洁、语义化更强,无需手动操作原型和构造函数,是现代 JS 开发(框架、项目)的首选继承方式。

// 父类(class 语法)
class Parent {
  constructor(name) {
    this.name = name;
    this.colors = ['red', 'blue'];
  }

  // 父类原型方法
  sayName() {
    console.log(this.name);
  }

  // 父类静态方法
  static showParent() {
    console.log("这是父类");
  }
}

// 子类继承父类(extends 关键字)
class Child extends Parent {
  constructor(name, age) {
    // 核心:super() 相当于 Parent.call(this, name),调用父类构造函数
    super(name); // 必须在 this 之前调用,否则报错
    this.age = age; // 子类独有属性
  }

  // 子类原型方法
  sayAge() {
    console.log(`我今年${this.age}岁`);
  }
}

// 创建子类实例
const c1 = new Child("张三", 20);

// 测试继承效果
c1.sayName(); // 输出:张三(继承父类原型方法)
c1.sayAge(); // 输出:我今年20岁(子类自有方法)
Parent.showParent(); // 输出:这是父类(父类静态方法)
Child.showParent(); // 输出:这是父类(子类继承父类静态方法)

// 测试引用类型不共享
const c2 = new Child("李四", 21);
c1.colors.push('green');
console.log(c1.colors); // ['red', 'blue', 'green']
console.log(c2.colors); // ['red', 'blue']

核心特点

  • ✅ 写法最优雅、语义化最强,无需手动操作原型,降低学习和使用成本

  • ✅ 底层是寄生组合式继承,兼顾性能和实用性,无任何明显缺点

  • ✅ 原生支持 super 关键字(调用父类构造函数、访问父类方法),支持静态方法继承

  • ✅ 现代开发首选,适配所有框架(Vue3、React、Node.js 等)

继承方式总结(面试必背)

用一句话总结每种继承的核心特点,面试时直接套用,清晰易懂:

  1. 原型链继承:简单但引用类型共享,无法传参

  2. 构造函数继承:能传参、避免共享,但无法继承原型方法

  3. 组合继承:好用但父构造函数被调用两次,有冗余代码

  4. 原型式继承:浅拷贝继承,适合快速复用现有对象

  5. 寄生式继承:拷贝+增强,方法无法复用

  6. 寄生组合继承:完美继承,无缺点,原生最优方案

  7. class extends:语法糖,底层是寄生组合继承,现代开发首选


三、面试高频补充:instanceof 原理与模拟 new

理解了对象创建和继承,就必须掌握 instanceofnew 的底层原理,这两个是面试高频考点,下面用通俗的语言+代码模拟,帮你彻底搞懂。

1. instanceof 原理(判断对象类型的核心)

作用:判断一个对象(obj)是否是某个构造函数(constructor)的实例,本质是「沿着对象的原型链向上查找,看是否能找到构造函数的 prototype」。

核心逻辑:

  1. 获取构造函数的原型对象(constructor.prototype);

  2. 沿着对象的 __proto__(隐式原型)逐级向上查找;

  3. 如果找到某个原型对象和构造函数的 prototype 相等,返回 true;

  4. 如果查到原型链顶端(null)还没找到,返回 false。

// 模拟 instanceof 底层实现(myInstanceof)
function myInstanceof(obj, constructor) {
  // 1. 边界判断:如果 obj 是 null/undefined,直接返回 false
  if (obj === null || typeof obj !== 'object' && typeof obj !== 'function') {
    return false;
  }
  // 2. 获取构造函数的原型对象
  const prototype = constructor.prototype;
  // 3. 逐级获取 obj 的隐式原型(__proto__),向上查找
  while (true) {
    // 查到顶端 null,说明没找到,返回 false
    if (obj === null) {
      return false;
    }
    // 找到原型相等,说明是实例,返回 true
    if (obj === prototype) {
      return true;
    }
    // 核心:沿着原型链向上走一层(获取下一个原型对象)
    // 推荐用 Object.getPrototypeOf(obj) 替代 obj.__proto__(更规范)
    obj = Object.getPrototypeOf(obj);
  }
}

// 测试
function Person() {}
const p = new Person();

console.log(myInstanceof(p, Person)); // 输出:true(p 是 Person 的实例)
console.log(myInstanceof(p, Object)); // 输出:true(p 也是 Object 的实例,因为 Person.prototype.__proto__ 指向 Object.prototype)
console.log(myInstanceof([], Array)); // 输出:true
console.log(myInstanceof(123, Number)); // 输出:false(123 是基本类型,不是 Number 的实例)

2. 模拟 new 关键字(对象创建的核心)

new 关键字的作用是“通过构造函数创建实例”,其底层逻辑可拆解为 4 步,我们用代码模拟其实现,就能彻底理解。

new 的核心逻辑:

  1. 判断传入的 Constructor 是否是函数,不是则报错;

  2. 创建一个空对象,并且让这个空对象的 __proto__ 指向 Constructor 的 prototype(实现原型继承);

  3. 调用 Constructor 构造函数,将 this 绑定到刚创建的空对象上,并传入参数;

  4. 判断构造函数的返回值:如果返回的是对象/函数,就返回这个返回值;否则返回刚创建的空对象。

// 模拟 new 关键字(myNew)
function myNew(Constructor, ...args) {
  // 1. 边界判断:如果 Constructor 不是函数,报错
  if (typeof Constructor !== 'function') {
    throw new TypeError(Constructor + ' is not a constructor');
  }

  // 2. 创建空对象,并且让其 __proto__ 指向 Constructor.prototype
  const instance = Object.create(Constructor.prototype);

  // 3. 执行构造函数,this 绑定到 instance,传入参数
  const result = Constructor.apply(instance, args);

  // 4. 判断返回值:如果返回引用类型(对象/函数),则返回该值;否则返回 instance
  // 注意:null 是对象类型,但要排除(因为返回 null 时,依然返回 instance)
  return (typeof result === 'object' && result !== null) || typeof result === 'function' 
    ? result 
    : instance;
}

// 测试1:正常情况
function Person(name) {
  this.name = name;
}
const p1 = myNew(Person, '张三');
console.log(p1.name); // 输出:张三
console.log(p1 instanceof Person); // 输出:true

// 测试2:构造函数返回对象(特殊情况)
function Student(name) {
  this.name = name;
  // 手动返回一个对象
  return {
    name: '李四',
    age: 20
  };
}
const s1 = myNew(Student, '张三');
console.log(s1.name); // 输出:李四(返回的是构造函数手动返回的对象)
console.log(s1 instanceof Student); // 输出:false(因为返回的对象不是 Student 的实例)

// 测试3:构造函数返回基本类型(不影响)
function Teacher(name) {
  this.name = name;
  return 123; // 返回基本类型
}
const t1 = myNew(Teacher, '王五');
console.log(t1.name); // 输出:王五(返回的是 instance)


四、最终总结

  1. 对象创建:优先用「对象字面量」(单个对象)和「class」(批量对象),原生场景用「构造函数+原型」,特殊场景用「Object.create()」;

  2. 继承:现代开发首选「class extends」,面试重点掌握「寄生组合式继承」,其他方式作为基础知识点了解;

  3. 核心底层:理解「原型链」「instanceof 原理」「new 原理」,这是 JS 对象和继承的核心,也是面试高频考点;

  4. 一句话实战:日常开发用「对象字面量 + class + extends」,能覆盖所有场景,简洁又高效。

Vue3 日历组件选型指南:五大主流方案深度解析

在 Vue3 项目开发中,日历组件是日程管理、预约系统、数据可视化等场景的核心组件。不同项目对日历的功能需求差异极大——有的只需基础日期选择,有的需要支持多日程展示、自定义节假日、拖拽调整等复杂功能。本文从「易用性、扩展性、性能」三个维度,深入分析 5 款主流 Vue3 日历组件,并提供选型建议,帮助开发者快速找到适配场景的最佳方案。

一、Vue3 Datepicker:轻量无依赖的基础款

Vue3 Datepicker 是一款纯 Vue3+TypeScript 开发的日历组件,主打轻量与无依赖特性。该组件体积仅约 5KB,却提供了日期范围选择、禁用日期、自定义格式等实用功能。其样式简洁,开发者可通过 CSS 轻松覆盖默认样式,同时完美适配移动端与 PC 端。得益于纯 Vue3 的实现方式,该组件对 Composition API 和 Options API 都有良好的兼容性。

安装命令:

npm install vue3-datepicker --save

使用示例:

<template>
  <div class="basic-calendar">
    <Datepicker
      v-model="selectedDate"
      :disabled-dates="disabledDates"
      format="YYYY-MM-DD"
      placeholder="选择日期"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import Datepicker from 'vue3-datepicker';
import 'vue3-datepicker/dist/index.css';

const selectedDate = ref<Date | null>(null);

const disabledDates = (date: Date) => {
  const day = date.getDay();
  return day === 0 || day === 6;
};
</script>

<style scoped>
.basic-calendar {
  width: 300px;
  margin: 20px;
}
</style>

适用场景:表单中的生日选择、订单日期筛选等轻量级日期选择需求,以及追求极小打包体积的项目。

二、Element Plus Calendar:生态集成的标准化选择

Element Plus Calendar 是饿了么团队出品的企业级日历组件,与 Element Plus 组件库深度集成,视觉风格统一。该组件支持月视图、周视图、日视图三种模式切换,提供日程数据绑定能力,开发者可自定义单元格内容展示。内置国际化功能、日期范围选择、禁用日期等基础能力,并提供完整的 TypeScript 类型定义,可与 Vue3+Vite 开发环境无缝配合。

安装命令:

npm install element-plus --save

使用示例:

<template>
  <div class="el-calendar-demo">
    <el-calendar v-model="currentDate">
      <template #date-cell="{ data }">
        <p :class="data.isSelected ? 'is-selected' : ''">
          {{ data.day.split('-').pop() }}
        </p>
        <span v-if="scheduleMap[data.day]" class="schedule-count">
          {{ scheduleMap[data.day] }}条日程
        </span>
      </template>
    </el-calendar>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { ElCalendar } from 'element-plus';
import 'element-plus/dist/index.css';

const currentDate = ref<Date>(new Date());

const scheduleMap = ref({
  '2026-02-06': 3,
  '2026-02-08': 1,
  '2026-02-10': 2,
});
</script>

<style scoped>
.is-selected {
  color: #409eff;
  font-weight: bold;
}
.schedule-count {
  font-size: 12px;
  color: #f56c6c;
}
</style>

适用场景:使用 Element Plus 组件库的中后台管理系统,需要快速实现标准化日历和日程功能的项目。

三、FullCalendar Vue3:复杂场景的全功能方案

FullCalendar Vue3 是基于业界知名的 FullCalendar 核心库封装的 Vue3 组件,专为复杂日程管理场景设计。该组件支持月视图、周视图、日视图、列表视图、时间轴视图等 10 余种视图类型,提供了日程拖拽、调整时长、重复日程设置、自定义事件渲染等丰富功能。组件兼容 Vue3 的组合式 API,可与 Pinia 或 Vuex 状态管理库无缝集成。此外,还支持 Google 日历和 iCal 导入,具备国际化与时区切换能力。

安装命令:

npm install @fullcalendar/vue3 @fullcalendar/core @fullcalendar/daygrid @fullcalendar/interaction

使用示例:

<template>
  <div class="full-calendar-demo">
    <FullCalendar
      :plugins="calendarPlugins"
      initialView="dayGridMonth"
      :events="calendarEvents"
      editable="true"
      selectable="true"
      @dateClick="handleDateClick"
      @eventClick="handleEventClick"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';

const calendarPlugins = ref([dayGridPlugin, interactionPlugin]);

const calendarEvents = ref([
  { title: '产品评审会', start: '2026-02-06', end: '2026-02-07', color: '#409eff' },
  { title: '版本发布', start: '2026-02-09', color: '#67c23a' },
]);

const handleDateClick = (info: any) => {
  alert(`选择了日期: ${info.dateStr}`);
};

const handleEventClick = (info: any) => {
  alert(`点击了日程: ${info.event.title}`);
};
</script>

<style scoped>
.full-calendar-demo {
  width: 90%;
  margin: 20px auto;
}
</style>

适用场景:企业 OA 系统、会议室预约、课程表管理等复杂日程管理场景,需支持拖拽操作、多视图切换、复杂事件配置的项目。

四、Vant4 Calendar:移动端友好的轻量选择

Vant4 Calendar 是有赞团队出品的移动端日历组件,专为移动端 H5 和小程序场景优化。该组件在交互设计上充分考虑移动端特性,支持滑动切换月份、手势操作等移动端常见交互方式。功能方面支持日期范围选择、快捷日期选择(如近 7 天、近 30 天)、自定义弹窗样式等实用能力。组件体积仅约 8KB,性能表现优异,支持按需引入,与 Vant4 组件库整体风格保持一致。

安装命令:

npm install vant --save

使用示例:

<template>
  <div class="vant-calendar-demo">
    <van-button @click="showCalendar = true">选择日期</van-button>
    <van-calendar
      v-model:show="showCalendar"
      v-model="selectedDate"
      type="range"
      :min-date="minDate"
      :max-date="maxDate"
      @confirm="handleConfirm"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { VanCalendar, VanButton } from 'vant';
import 'vant/lib/index.css';

const showCalendar = ref(false);
const selectedDate = ref<[Date, Date]>([new Date(), new Date()]);
const minDate = ref(new Date('2026-01-01'));
const maxDate = ref(new Date('2026-12-31'));

const handleConfirm = (dates: [Date, Date]) => {
  console.log('选择的日期范围:', dates);
  showCalendar.value = false;
};
</script>

适用场景:移动端 H5 页面、小程序项目,需轻量级、交互友好的日期选择功能。

五、Vue3 Simple Calendar:极简逻辑的定制基石

Vue3 Simple Calendar 是一款独特的日历组件,它不包含任何样式封装,仅提供核心日历逻辑。该组件基于 Vue3 Composition API 开发,体积仅 3KB,没有任何第三方依赖。开发者可以完全自定义 UI 和交互方式,组件只负责处理日历的基本逻辑,如月份切换、日期选中、日期渲染回调等。

安装命令:

npm install vue3-simple-calendar --save

使用示例:

<template>
  <div class="custom-calendar">
    <simple-calendar
      v-model="currentMonth"
      @date-click="handleDateClick"
    >
      <template #header="{ year, month, prevMonth, nextMonth }">
        <div class="calendar-header">
          <button @click="prevMonth">上一月</button>
          <h3>{{ year }}年{{ month }}月</h3>
          <button @click="nextMonth">下一月</button>
        </div>
      </template>
      <template #day="{ date, isToday, isWeekend }">
        <div
          class="day-cell"
          :class="{ today: isToday, weekend: isWeekend, selected: selectedDate === date }"
        >
          {{ date.getDate() }}
        </div>
      </template>
    </simple-calendar>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import SimpleCalendar from 'vue3-simple-calendar';

const currentMonth = ref<Date>(new Date());
const selectedDate = ref<Date | null>(null);

const handleDateClick = (date: Date) => {
  selectedDate.value = date;
  console.log('选中日期:', date);
};
</script>

<style scoped>
.custom-calendar {
  width: 350px;
  margin: 20px;
}
.calendar-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 10px;
}
.day-cell {
  width: 50px;
  height: 50px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.today {
  background-color: #409eff;
  color: white;
  border-radius: 50%;
}
.weekend {
  color: #f56c6c;
}
.selected {
  border: 2px solid #67c23a;
  border-radius: 50%;
}
</style>

适用场景:需要品牌化设计日历 UI、特殊交互效果的项目,或仅需复用核心日历逻辑的定制化开发场景。

六、选型指南与核心原则

面对多种日历组件选择,开发者需要根据项目实际情况做出判断。以下是各组件的核心对比:

组件类型 核心优势 适用场景 打包体积
Vue3 Datepicker 轻量无依赖、易定制 基础日期选择、表单场景 ~5KB
Element Plus Calendar 大厂背书、生态集成 中后台标准化日程功能 ~15KB
FullCalendar Vue3 全功能、复杂交互 企业 OA、预约系统 ~100KB
Vant4 Calendar 移动端适配、交互友好 移动端 H5、小程序 ~8KB
Vue3 Simple Calendar 完全自定义、极简逻辑 个性化 UI、定制化交互 ~3KB

JS手撕:函数进阶 & 设计模式解析

在 JavaScript 开发中,无论是日常业务开发还是面试考察,有一批高频代码片段始终贯穿其中——它们涵盖函数封装、设计模式、异步处理等核心场景,既能提升开发效率,也是理解 JS 底层逻辑的关键。本文将以「通俗解读+专业拆解」的方式,逐一看懂这些实用代码,帮你吃透背后的原理,做到会用也会讲。

一、函数柯里化(Currying)

通俗理解

柯里化就像「分步点餐」:比如点一杯奶茶,不用一次性说清“中杯、少糖、常温”,可以先选“中杯”,再选“少糖”,最后选“常温”,每一步都记录你的选择,等所有选项凑齐,再最终下单(执行函数)。核心是“把多参数函数拆成单参数(或部分参数)的嵌套函数,逐步收集参数,最终执行”。

专业拆解(附代码解析)

柯里化的核心价值是参数复用、延迟执行,下面这段工具函数是面试中最常考的实现方式,逐行拆解其逻辑:

// 定义柯里化工具函数,接收原函数 fn + 初始参数
function curry(fn) {
  // 1. 校验入参:必须是函数,否则抛出类型错误(健壮性处理)
  if (typeof fn !== "function") throw new TypeError("Expected a function");
  
  // 2. 获取原函数【需要的必填参数个数】(函数的 length 属性 = 形参数量)
  // 比如 fn(a,b,c),fn.length 就是 3,代表需要3个参数才能执行
  const requiredArgsLength = fn.length;
  
  // 3. 截取除了第一个参数(fn)之外的所有【初始参数】
  // arguments 是类数组(不能直接用数组方法),用 slice 转成真正的数组
  const initialArgs = [].slice.call(arguments, 1);

  // 4. 内部柯里化核心函数:接收新传入的参数
  function _curry(...newArgs) {
    // 合并:初始参数 + 本次传入的新参数(收集所有已传入的参数)
    const allArgs = [...initialArgs, ...newArgs];
    
    // 5. 判断:参数是否凑够了原函数需要的数量
    if (allArgs.length >= requiredArgsLength) {
      // ✅ 凑够了:执行原函数,传入所有参数(用 apply 绑定 this,保证上下文正确)
      return fn.apply(this, allArgs);
    } else {
      // ❌ 没凑够:递归调用 curry,继续收集参数(把已收集的 allArgs 作为初始参数传入)
      return curry.call(this, fn, ...allArgs);
    }
  }

  // 6. 返回内部收集参数的函数(不立即执行,延迟到参数凑够后执行)
  return _curry;
}

用法示例

// 原函数:求三个数的和(需要3个参数)
function add(a, b, c) {
  return a + b + c;
}

// 柯里化处理
const curryAdd = curry(add);

// 分步传参(延迟执行)
curryAdd(1)(2)(3); // 6(分步传参,凑够3个执行)
curryAdd(1,2)(3); // 6(部分传参,再补全)
curryAdd(1)(2,3); // 6(任意分步组合)

关键注意点

  • 函数的 length 属性:仅统计“未指定默认值的形参”,如果形参有默认值(如 add(a=0,b)),length 会计算到第一个默认值参数为止(此时 add.length = 0)。

  • 递归收集参数:每次传参不足时,都会返回一个新的 _curry 函数,继续收集参数,直到满足要求。

二、函数组合(Compose)

通俗理解

函数组合就像「流水线作业」:比如生产一瓶饮料,先“加水”,再“加糖”,最后“装瓶”,每个步骤都是一个函数,组合起来就是“加水→加糖→装瓶”的完整流程,前一个函数的输出是后一个函数的输入。核心是“将多个单参数函数组合成一个函数,从右往左依次执行”。

专业拆解(附代码解析)

函数组合是函数式编程的核心技巧,常用于简化多步骤逻辑(如数据处理、中间件),下面是最简洁的实现方式:

function compose(...funcs) {
  // 没有传入函数,直接返回参数本身(边界处理:传入空函数时,不改变输入)
  if (funcs.length === 0) {
    return arg => arg;
  }

  // 只有一个函数,直接返回该函数(边界处理:无需组合,直接执行)
  if (funcs.length === 1) {
    return funcs[0];
  }

  // ✅ 核心:用 reduce 实现函数组合,从右往左执行
  // reduce 遍历 funcs,将前一个函数 a 和当前函数 b 组合成 (args) => a(b(...args))
  // 比如 compose(f1,f2,f3) 最终变成 (args) => f1(f2(f3(...args)))
  return funcs.reduce((a, b) => (...args) => a(b(...args)));
}

用法示例

// 步骤1:将数字转为字符串
const toString = num => num + "";
// 步骤2:给字符串加前缀
const addPrefix = str => "num_" + str;
// 步骤3:将字符串转为大写
const toUpperCase = str => str.toUpperCase();

// 组合函数:从右往左执行 → toString → addPrefix → toUpperCase
const transform = compose(toUpperCase, addPrefix, toString);

// 执行:123 → "123" → "num_123" → "NUM_123"
transform(123); // "NUM_123"

关键注意点

  • 执行顺序:从右往左,这是 compose 的默认规则(与 pipe 相反,pipe 是从左往右)。

  • 参数传递:组合后的函数接收的参数,会全部传给最右边的函数,后续函数仅接收前一个函数的返回值,因此建议每个组合的函数都是“单输入、单输出”。

三、模拟 call 方法

通俗理解

call 方法的作用是「给函数换个“主人”」:比如小明有一个“吃饭”函数,小红想借用这个函数(让函数里的 this 指向小红),就可以用 call 实现。核心是“改变函数内部的 this 指向,并立即执行函数”。

专业拆解(附代码解析)

call 是 Function.prototype 上的方法,所有函数都能调用。其底层逻辑是“将函数挂载到目标对象上,作为对象的方法调用(此时 this 指向该对象),执行后删除临时方法,避免污染原对象”,具体实现如下:

Function.prototype.mycall2 = function (thisArg, ...args) {
  // 1. 校验:调用 mycall2 的必须是函数,否则报错(健壮性处理)
  if (typeof this !== "function") {
    throw new TypeError(this + " is not a function");
  }

  // 2. 确定 this 指向:传入 null/undefined 时,this 指向全局对象(浏览器是 window,Node 是 global)
  let context = thisArg == null ? globalThis : Object(thisArg);

  // 3. 创建唯一 Symbol 属性,防止覆盖对象原有属性(比如对象本身就有 fn 方法,避免冲突)
  const fn = Symbol("fn");

  // 4. 把当前函数(this 指向的就是调用 mycall2 的函数)挂载到 context 上
  context[fn] = this; 

  // 5. 执行函数,传入参数,接收执行结果(作为对象方法调用,this 自然指向 context)
  const result = context[fn](...args);

  // 6. 删掉临时挂载的属性,不污染原对象(核心:用完即删,保持对象纯净)
  delete context[fn];

  // 7. 返回函数执行结果(与原生 call 行为一致,返回函数执行后的结果)
  return result
};

用法示例

function sayHi() {
  console.log(`Hi, 我是 ${this.name},年龄 ${this.age}`);
}

const person1 = { name: "张三", age: 20 };
const person2 = { name: "李四", age: 22 };

// 用自定义的 mycall2 改变 this 指向
sayHi.mycall2(person1); // Hi, 我是 张三,年龄 20
sayHi.mycall2(person2, 123); // Hi, 我是 李四,年龄 22(多余参数不影响,函数不接收即可)

关键注意点

  • thisArg 处理:如果传入 null/undefined,this 指向 globalThis(全局对象);如果传入基本类型(如 123、"abc"),会被 Object() 转成对应包装对象(如 Number、String)。

  • Symbol 作用:确保临时属性唯一,避免覆盖目标对象已有的属性,是实现的关键细节。

四、模拟 apply 方法

通俗理解

apply 和 call 几乎一样,都是“改变函数 this 指向并立即执行”,唯一区别是「传参方式」:call 是“逐个传参”(比如 call(obj, 1, 2, 3)),apply 是“数组传参”(比如 apply(obj, [1,2,3])),相当于“批量传参”。

专业拆解(附代码解析)

apply 的实现逻辑和 call 高度一致,核心差异在于“处理参数的方式”,具体实现如下:

Function.prototype.myapply2 = function (thisArg, argsArray) {
  // 1. 必须是函数才能调用(和 call 一致的健壮性校验)
  if (typeof this !== "function") {
    throw new TypeError(this + " is not a function");
  }

  // 2. 处理 this 指向(和 call 完全一致)
  let context = thisArg == null ? globalThis : Object(thisArg);

  // 3. 处理参数:不传 argsArray / 传 null → 默认为空数组(避免解构报错)
  // ?? 是空值合并运算符,只有当 argsArray 为 null/undefined 时,才返回 []
  const args = argsArray ?? [];

  // 4. 唯一 Symbol 防止属性冲突(和 call 一致)
  const fn = Symbol("fn");
  context[fn] = this;

  // 5. 执行函数:用扩展运算符 ... 将数组参数拆成逐个参数,和 call 逻辑一致
  const result = context[fn](...args);

  // 6. 清理临时属性,不污染原对象(和 call 一致)
  delete context[fn];

  return result;
};

用法示例

function sum(a, b, c) {
  return a + b + c;
}

const obj = { name: "测试" };

// 用 myapply2 传参(数组形式)
sum.myapply2(obj, [1, 2, 3]); // 6
sum.myapply2(obj); // 0(args 为空数组,a、b、c 都是 undefined,相加为 0)

关键注意点

  • 参数处理:argsArray 必须是数组(或类数组),如果传入非数组,会报错(原生 apply 也是如此);如果不传,默认按空数组处理。

  • 与 call 的区别:仅传参方式不同,底层执行逻辑完全一致,二者可相互替代(call 能做的,apply 也能做,只是传参麻烦一点)。

五、模拟 bind 方法

通俗理解

bind 和 call、apply 的区别是「不立即执行」:call/apply 是“改变 this 并马上执行”,bind 是“改变 this 并返回一个新函数,后续需要手动调用这个新函数才会执行”,相当于“提前绑定好 this,后续随时可用”。

专业拆解(附代码解析)

bind 的实现比 call/apply 复杂,核心要处理两个点:「参数柯里化」和「new 调用时的 this 指向」,具体实现如下:

Function.prototype.myBind = function(context, ...args) {
  // 1. 调用者必须是函数(健壮性校验)
  if (typeof this !== 'function') {
    throw new TypeError('The bound object must be a function');
  }

  // 2. 保存原函数(关键!因为后续返回的新函数需要执行原函数,this 会被改变,所以提前保存)
  const self = this; 

  // 3. 返回一个新的绑定函数(不立即执行,等待后续调用)
  function boundFunction(...newArgs) {
    // 4. 合并参数(柯里化:bind 时传入的 args + 后续调用新函数时传入的 newArgs)
    const allArgs = args.concat(newArgs);

    // 5. 执行原函数,判断是普通调用还是 new 调用
    // 用 new 调用 boundFunction 时,this 指向 new 出来的实例,此时要忽略之前绑定的 context
    // 否则,this 指向绑定的 context
    return self.apply(
      this instanceof boundFunction ? this : context,
      allArgs
    );
  }

  // 6. 继承原函数的原型,让 new 能正常工作(关键细节)
  // 比如用 new 调用绑定后的函数,实例能访问原函数原型上的属性/方法
  if (this.prototype) {
    function Empty() {} // 空函数作为中间层,避免原型链污染
    Empty.prototype = this.prototype;
    boundFunction.prototype = new Empty();
  }

  return boundFunction;
};

用法示例

function Person(name, age) {
  this.name = name;
  this.age = age;
  console.log(`我是 ${this.name},年龄 ${this.age}`);
}

const obj = { name: "默认名称" };

// 1. 普通绑定:提前绑定 this 和部分参数
const boundPerson = Person.myBind(obj, "张三");
boundPerson(20); // 我是 张三,年龄 20(this 指向 obj,合并参数 ["张三", 20])

// 2. new 调用:忽略绑定的 context,this 指向新实例
const instance = new boundPerson(22); // 我是 undefined,年龄 22(this 指向 instance,name 未赋值)
console.log(instance.age); // 22(实例能访问 age 属性,原型继承生效)

关键注意点

  • new 调用处理:这是 bind 和 call/apply 最大的区别之一,用 new 调用绑定后的函数时,this 会指向新实例,而非绑定的 context。

  • 原型继承:通过空函数中间层继承原函数原型,避免直接赋值原型导致的污染(如果直接 boundFunction.prototype = this.prototype,修改 boundFunction 原型会影响原函数原型)。

六、实现链式调用

通俗理解

链式调用就像「连环操作」:比如买奶茶时,“点单→加珍珠→加冰→付款”,每一步操作完成后,都能继续下一步,不用重复写对象名。核心是“每个方法执行后,返回当前对象(this),让后续方法能继续调用”。

专业拆解(附代码解析)

链式调用在 JS 中非常常见(如 jQuery、Promise),实现逻辑极其简单,核心就是「return this」,具体实现如下:

// 定义一个类(也可以是构造函数)
class class1 {
  constructor() {
    // 可选:初始化一些属性
    this.data = [];
  }
}

// 给类的原型添加方法,每个方法执行后 return this
class1.prototype.method = function (param) {
  console.log("执行方法,参数:", param);
  this.data.push(param); // 可以做一些业务逻辑
  return this; // 必须 return this,才能实现链式调用
};

// 扩展更多方法,同样 return this
class1.prototype.anotherMethod = function (param) {
  console.log("执行另一个方法,参数:", param);
  this.data.push(param);
  return this;
};

// 使用:创建实例后,链式调用方法
const ins = new class1();
ins.method('a').anotherMethod('b').method('c'); 
// 输出:执行方法,参数:a → 执行另一个方法,参数:b → 执行方法,参数:c
console.log(ins.data); // ['a', 'b', 'c'](业务逻辑生效)

关键注意点

  • 核心要求:每个需要链式调用的方法,必须返回 this(当前实例),如果返回其他值,后续链式调用会报错(因为其他值可能没有对应的方法)。

  • 适用场景:常用于封装工具类、组件方法(如表单验证、DOM 操作),简化代码写法。

七、发布订阅模式(EventEmitter)

通俗理解

发布订阅模式就像「公众号订阅」:你(订阅者)关注了一个公众号(发布者),当公众号发布新文章(发布事件)时,所有关注的人都会收到通知(执行订阅的回调)。核心是“解耦发布者和订阅者,二者互不依赖,通过事件仓库传递消息”。

专业拆解(附代码解析)

发布订阅模式是前端常用的设计模式,常用于组件通信、事件监听(如 Vue 的事件总线),下面是完整的 EventEmitter 实现,包含订阅、取消订阅、发布、一次性订阅四个核心方法:

class EventEmitter {
  // 1. 构造函数:初始化事件仓库(存储事件名和对应的回调函数数组)
  constructor() {
    // 用 Map 存储:key=事件名(字符串),value=回调函数数组(一个事件可以有多个订阅者)
    this.events = new Map();
  }

  // 2. 订阅事件:监听一个事件,添加回调函数
  on(eventName, listener) {
    // 如果事件不存在,先创建一个空数组(避免后续 push 报错)
    if (!this.events.has(eventName)) {
      this.events.set(eventName, []);
    }
    // 把回调函数 push 进数组(一个事件可以订阅多个回调)
    this.events.get(eventName).push(listener);
  }

  // 3. 取消订阅:移除指定事件的指定回调函数
  off(eventName, listener) {
    // 事件不存在,直接返回(无需处理)
    if (!this.events.has(eventName)) return;

    const listeners = this.events.get(eventName);
    // 找到回调函数在数组中的索引
    const index = listeners.indexOf(listener);
    // 找到并删除对应的函数(splice 会修改原数组)
    if (index !== -1) {
      listeners.splice(index, 1);
    }
  }

  // 4. 发布事件:触发指定事件,执行所有订阅的回调函数,并传递参数
  emit(eventName, ...args) {
    // 事件不存在,直接返回(没有订阅者,无需执行)
    if (!this.events.has(eventName)) return;

    const listeners = this.events.get(eventName);
    // 遍历执行所有回调函数,并传入发布时的参数
    listeners.forEach(listener => listener(...args));
  }

  // 5. 只监听一次:订阅事件后,执行一次回调就自动取消订阅
  once(eventName, listener) {
    // 包装一层函数,执行原回调后,立即取消订阅
    const wrappedListener = (...args) => {
      // 先执行原回调函数
      listener(...args);
      // 执行完立刻删除当前包装函数(取消订阅)
      this.off(eventName, wrappedListener);
    };
    // 订阅包装后的函数(而非原函数,确保执行一次后取消)
    this.on(eventName, wrappedListener);
  }
}

用法示例

// 创建 EventEmitter 实例(发布者)
const emitter = new EventEmitter();

// 1. 订阅事件(订阅者1)
function callback1(data) {
  console.log("订阅者1收到消息:", data);
}
emitter.on("message", callback1);

// 2. 订阅事件(订阅者2,只监听一次)
emitter.once("message", (data) => {
  console.log("订阅者2收到消息(只一次):", data);
});

// 3. 发布事件(触发所有订阅者)
emitter.emit("message", "Hello World"); 
// 输出:订阅者1收到消息:Hello World → 订阅者2收到消息(只一次):Hello World

// 4. 再次发布事件(订阅者2已取消订阅,不再执行)
emitter.emit("message", "再次发送消息");
// 输出:订阅者1收到消息:再次发送消息

// 5. 取消订阅者1的订阅
emitter.off("message", callback1);

// 6. 第三次发布事件(没有订阅者,无输出)
emitter.emit("message", "第三次发送消息");

关键注意点

  • 事件仓库:用 Map 存储比对象更灵活,能避免对象属性名的冲突,且能更方便地获取、删除事件。

  • once 实现:核心是“包装回调函数”,执行原回调后立即取消订阅,注意不能直接订阅原函数(否则无法取消)。

  • 取消订阅:必须传入订阅时的同一个回调函数(不能是匿名函数),否则无法找到并删除。

八、单例模式

通俗理解

单例模式就像「公司的 CEO」:整个公司只有一个 CEO,无论你什么时候、在哪里找,找到的都是同一个人。核心是“一个类只能创建一个实例,后续所有创建实例的操作,都返回同一个已存在的实例”。

专业拆解(附代码解析)

单例模式常用于封装全局工具类、数据库连接、全局状态管理等场景,避免重复创建实例造成资源浪费,下面是最简洁的 ES6 实现方式:

class Singleton {
  // 静态属性:存储唯一实例(静态属性属于类,不属于实例,全局唯一)
  static instance = null;

  constructor() {
    // 关键逻辑:如果已经有实例,直接返回旧实例(阻止创建新实例)
    if (Singleton.instance) {
      return Singleton.instance;
    }
    // 没有实例,创建并保存到静态属性中
    Singleton.instance = this;
    // 初始化实例属性(根据业务需求添加)
    this.data = [];
  }

  // 实例方法(业务逻辑):添加数据
  addData(item) {
    this.data.push(item);
  }

  // 实例方法(业务逻辑):获取数据
  getData() {
    return this.data;
  }
}

用法示例

// 多次创建实例
const instance1 = new Singleton();
const instance2 = new Singleton();
const instance3 = new Singleton();

// 验证:所有实例都是同一个
console.log(instance1 === instance2); // true
console.log(instance1 === instance3); // true

// 操作实例1,instance2、instance3 也会受到影响(因为是同一个实例)
instance1.addData("测试数据");
console.log(instance2.getData()); // ["测试数据"]
console.log(instance3.getData()); // ["测试数据"]

关键注意点

  • 静态属性 instance:必须用 static 修饰,确保属于类本身,而非实例,这样才能全局唯一。

  • 构造函数拦截:在 constructor 中判断 instance 是否存在,存在则返回旧实例,阻止新实例创建,这是单例的核心。

  • 适用场景:全局工具类(如日期工具、请求工具)、全局状态管理,避免重复创建实例造成资源浪费。

九、私有变量的实现(闭包+Symbol)

通俗理解

私有变量就像「个人的隐私」:只能自己访问和修改,别人无法直接获取或修改。在 JS 中,没有原生的 private 关键字(ES6 有,但兼容性有限),常用「闭包+Symbol」实现真正的私有变量。

专业拆解(附代码解析)

核心逻辑:用立即执行函数(IIFE)创建闭包,闭包内的 Symbol 变量外部无法访问;类内部用这个 Symbol 作为属性名,实现私有属性,具体实现如下:

const Person = (function() {
  // 1. 闭包内的 Symbol,外部无法访问(真正的私有标识)
  // Symbol 具有唯一性,即使外部也创建同名 Symbol,也和这个不是同一个
  const _name = Symbol('name');

  // 2. 定义类,类内部可以访问闭包内的 _name
  class Person {
    constructor(name) {
      // 3. 用 Symbol 作为属性名,实现私有属性(外部无法通过 obj.name 访问)
      this[_name] = name; 
    }

    // 4. 提供公共方法,供外部间接访问私有属性(可控访问)
    getName() {
      return this[_name];
    }

    // 可选:提供公共方法,供外部间接修改私有属性(可控修改)
    setName(newName) {
      this[_name] = newName;
    }
  }

  // 5. 把类返回出去,外部可以创建实例,但无法访问闭包内的 _name
  return Person;
})();

用法示例

const person = new Person("张三");

// 1. 无法直接访问私有属性(外部没有 _name Symbol,无法获取)
console.log(person.name); // undefined(没有这个公共属性)
console.log(person[_name]); // 报错(_name 是闭包内的变量,外部无法访问)

// 2. 通过公共方法访问和修改私有属性
console.log(person.getName()); // 张三
person.setName("李四");
console.log(person.getName()); // 李四

关键注意点

  • 闭包的作用:隔离作用域,让 _name Symbol 只能在 IIFE 内部访问,外部无法获取,确保私有性。

  • Symbol 的唯一性:即使外部创建 const _name = Symbol('name'),也和闭包内的 _name 不是同一个,无法访问私有属性。

  • 可控访问:通过公共方法(getName、setName)访问和修改私有属性,可以在方法中添加校验逻辑(如判断姓名长度),更安全。

十、函数字符串转成函数(new Function vs eval)

通俗理解

有时候我们会拿到一个「函数字符串」(比如从后端接口获取,或动态拼接),需要把它转成真正的函数才能执行。JS 中有两种常用方式:new Function 和 eval,二者核心区别是「作用域安全」。

专业拆解(附代码解析)

两种方式的实现的逻辑不同,安全性也有差异,下面分别实现并对比:

// 1. 使用 new Function(推荐:作用域独立、更安全)
function stringToFunction(funcStr) {
  try {
    // new Function 接收字符串参数,最后一个参数是函数体,前面是形参
    // 这里用 "return " + funcStr,把函数字符串转成函数表达式,执行后返回函数
    const func = new Function('return ' + funcStr)();
    return func;
  } catch (error) {
    console.error('转换失败:', error);
    return null;
  }
}

// 2. 使用 eval(不推荐:能访问当前作用域、不安全)
function stringToFunctionEval(funcStr) {
  try {
    /**
     * 给函数字符串加括号,转成函数表达式(避免被当作语句执行)
     * 比如 funcStr 是 "function add(){}",加括号后是 "(function add(){})",eval 执行后返回函数
     */
    const func = eval('(' + funcStr + ')');
    return func;
  } catch (error) {
    console.error('转换失败:', error);
    return null;
  }
}

// 测试示例
const funcStr = 'function add(a, b) { return a + b; }';

// 用 new Function 转换
const add1 = stringToFunction(funcStr);
console.log(add1(1, 2)); // 3(转换成功,能正常执行)

// 用 eval 转换
const add2 = stringToFunctionEval(funcStr);
console.log(add2(3, 4)); // 7(转换成功,能正常执行)

核心区别(重点)

方式 作用域 安全性 推荐度
new Function 独立作用域,只能访问全局变量,无法访问当前局部变量 高,不会污染当前作用域,也不会执行恶意代码(相对安全) 推荐
eval 能访问当前作用域的所有变量(局部、全局) 低,可能执行恶意代码,也可能污染当前作用域 不推荐(除非明确知道字符串安全)

关键注意点

  • new Function 转换时,需要给 funcStr 加 "return ",把函数字符串转成函数表达式,否则会返回 undefined。

  • eval 转换时,需要给 funcStr 加括号,避免被 JS 解析器当作语句执行(比如 function add(){} 会被当作函数声明,无法直接返回)。

  • 安全性:如果函数字符串来自不可信来源(如用户输入、未知接口),无论哪种方式都有风险,需先做校验。

十一、模板字符串执行(with + new Function)

通俗理解

有时候我们会有一个「模板字符串」(比如 "a+b,{a+b}, {b}"),需要结合一个对象(比如 {a:1, b:2}),动态替换模板中的变量并执行计算。核心是“用 with 绑定对象作用域,让模板中能直接使用对象的属性”。

专业拆解(附代码解析)

实现逻辑:用 new Function 创建动态函数,结合 with 语句将对象作为作用域,让模板字符串能直接访问对象属性,具体实现两种方式:

// 方式1:使用 with(简洁,兼容性好)
// with 可以把一个对象当作作用域,在代码块里直接用属性名,不用写 对象.属性
const sprintf2 = (template, obj) => {
  // 1. 动态创建函数:参数是 obj,函数体是 with(obj){return `模板字符串`}
  const fn = new Function("obj", `with(obj){return \`${template}\`;}`);
  
  // 2. 执行函数,传入 obj,返回模板执行后的结果
  return fn(obj);
};

// 方式2:使用解构赋值(更安全,避免 with 的副作用)
const sprintf3 = (template, obj) => {
  // 用解构赋值,把 obj 的所有属性变成函数内的局部变量
  // 比如 obj = {a:1,b:2},解构后变成 const {a,b} = obj;
  const fn = new Function(
    "obj",
    `const { ${Object.keys(obj).join(',')} } = obj; return \`${template}\`;`
  );
  return fn(obj);
};

// 测试示例
console.log(sprintf2("a:${a+b},b:${b}", { a: 1, b: 2 }));
// 输出:a:3,b:2(a+b 计算生效,直接使用 obj 的 a、b 属性)

console.log(sprintf3("a:${a*2},b:${b+3}", { a: 1, b: 2 }));
// 输出:a:2,b:5(解构赋值后,直接使用 a、b 变量)

核心区别

  • 方式1(with):简洁高效,但 with 会改变作用域链,可能导致变量查找变慢,且如果模板中使用了未在 obj 中定义的变量,会向上查找全局变量,有一定风险。

  • 方式2(解构赋值):更安全,模板中只能使用 obj 中的属性(未定义的变量会报错),不会向上查找全局变量,推荐使用。

关键注意点

  • 模板字符串转义:动态创建函数时,模板字符串中的 要转义成 \,否则会被 JS 解析器当作函数体的结束。

  • 属性名处理:如果 obj 的属性名包含特殊字符(如 -、空格),解构赋值会报错,需提前处理属性名。

十二、async 优雅处理(错误前置)

通俗理解

async/await 是 JS 处理异步的常用方式,但默认需要用 try/catch 捕获错误,代码会显得繁琐。错误前置的核心是“用一个包装函数,统一捕获异步错误,返回 [错误, 结果] 数组,后续直接判断错误即可,不用写 try/catch”。

专业拆解(附代码解析)

实现逻辑:封装一个异步包装函数,内部用 try/catch 捕获异步函数的错误,成功则返回 [null, 结果],失败则返回 [错误, null],简化错误处理流程:

// 定义一个异步包装函数,接收一个异步函数(或返回 Promise 的函数)
async function errorCaptured(asyncFunc) {
    try {
        // 执行传入的异步函数,等待结果(asyncFunc 是异步函数,用 await 等待)
        let res = await asyncFunc()
        // 成功:返回 [没有错误(null), 执行结果]
        return [null, res]
    } catch(e) {
        // 失败:返回 [错误信息, 没有结果(null)]
        return [e, null]
    }
}

// 模拟一个异步请求(比如接口请求)
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // 模拟成功:resolve("成功数据")
      // 模拟失败:reject("网络错误")
      reject("网络错误")
    }, 500)
  })
}

// 使用:无需写 try/catch,直接判断错误
async function demo() {
  // 调用包装函数,解构出错误和结果
  const [err, data] = await errorCaptured(fetchData)

  // 错误判断:有错误则处理,无错误则使用数据
  if (err) {
    console.log("❌ 错误:", err)
    return // 有错误,终止后续逻辑
  }
  console.log("✅ 成功:", data)
}

demo(); // 输出:❌ 错误:网络错误

核心优势

  • 简化代码:不用在每个 async 函数中写 try/catch,统一由包装函数捕获错误,代码更简洁。

  • 错误前置:先判断错误,再处理业务逻辑,逻辑更清晰,避免错误导致后续代码报错。

  • 通用性强:可用于所有异步场景(接口请求、定时器、文件读取等),只需传入异步函数即可。

关键注意点

  • asyncFunc 要求:必须是异步函数(async 修饰)或返回 Promise 的函数,否则 await 无法等待,会直接返回同步结果。

  • 返回值格式:固定返回 [err, data] 数组,err 为 null 表示成功,data 为 null 表示失败,后续使用需严格遵循这个格式。

十三、实现 Promise 任务调度器

通俗理解

Promise 任务调度器就像「餐厅排队取号」:餐厅一次只能接待2桌客人(最大并发数),后面来的客人排队,等前面的客人吃完(任务执行完),再依次接待下一桌。核心是“控制并发任务的数量,避免同时执行过多任务导致资源耗尽”。

专业拆解(附代码解析)

实际开发中,任务调度器常用于控制接口请求并发数(比如同时请求10个接口,控制最多2个并发),下面实现两种常用版本:通用并发调度器(面试常考)和业务实用版并发请求控制:

// ====================
// 1. 通用并发调度器 Scheduler(面试标准版)
// 核心:控制最大并发数,任务排队执行,执行完一个补一个
// ====================
class Scheduler {
  constructor(maxCount = 2) {
    this.maxCount = maxCount; // 最大并发数(默认2)
    this.queue = [];         // 任务队列(存储等待执行的任务)
    this.running = 0;        // 当前运行中的任务数
  }

  // 添加任务:将任务加入队列(不立即执行)
  add(task) {
    this.queue.push(task);
  }

  // 开始执行任务:初始化启动最大并发数的任务
  start() {
    for (let i = 0; i < this.maxCount; i++) {
      this.run(); // 启动任务执行
    }
  }

  // 执行任务核心逻辑:从队列取任务,执行后补充新任务
  run() {
    // 终止条件:队列空了 或 运行中的任务数 >= 最大并发数
    if (!this.queue.length || this.running >= this.maxCount) return;

    this.running++; // 运行中的任务数+1
    const task = this.queue.shift(); // 从队列头部取出一个任务

    // 执行任务(任务是返回 Promise 的函数),执行完后更新状态
    task().finally(() => {
      this.running--; // 任务执行完,运行中的任务数-1
      this.run(); // 递归调用 run,从队列取下一个任务执行
    });
  }
}

// ====================
// 2. 并发请求控制 multiRequest(业务实用版)
// 核心:控制接口请求并发数,收集所有请求结果,最终统一返回
// ====================
function multiRequest(urls, maxNum) {
  const total = urls.length; // 总请求数
  const result = new Array(total).fill(null); // 存储所有请求结果(按顺序)
  let current = 0; // 当前要执行的请求索引
  let finished = 0; // 已完成的请求数

  // 返回 Promise,所有请求完成后 resolve 结果
  return new Promise((resolve) => {
    // 初始启动:启动最大并发数的请求(不超过总请求数)
    for (let i = 0; i < Math.min(maxNum, total); i++) {
      next();
    }

    // 执行下一个请求的逻辑
    function next() {
      if (current >= total) return; // 所有请求都已启动,终止

      const index = current++; // 记录当前请求的索引(确保结果顺序正确)
      // 执行请求(urls 中的每个元素是返回 Promise 的请求函数)
      urls[index]()
        .then((res) => {
          // 请求成功:存储成功结果
          result[index] = { success: true, data: res };
        })
        .catch((err) => {
          // 请求失败:存储失败信息
          result[index] = { success: false, error: err };
        })
        .finally(() => {
          finished++; // 已完成请求数+1
          if (finished === total) {
            resolve(result); // 所有请求完成,返回结果
          }
          next(); // 执行完一个,启动下一个请求
        });
    }
  });
}

// ====================
// 3. 使用 DEMO(可直接运行)
// ====================
// 模拟任务队列(每个任务是返回 Promise 的函数)
const tasks = [
  () => new Promise(r => setTimeout(() => { console.log("任务1"); r(); }, 1000)),
  () => new Promise(r => setTimeout(() => { console.log("任务2"); r(); }, 500)),
  () => new Promise(r => setTimeout(() => { console.log("任务3"); r(); }, 1200)),
  () => new Promise(r => setTimeout(() => { console.log("任务4"); r(); }, 800)),
];

// 测试通用调度器(最大并发数2)
const scheduler = new Scheduler(2);
tasks.forEach(task => scheduler.add(task));
scheduler.start();
// 输出顺序:任务2(500ms)→ 任务1(1000ms)→ 任务4(800ms)→ 任务3(1200ms)

// 模拟请求队列(每个请求是返回 Promise 的函数)
const urls = [
  () => new Promise(resolve => setTimeout(() => resolve("URL1"), 1000)),
  () => new Promise((_, reject) => setTimeout(() => reject("URL2"), 500)),
  () => new Promise(resolve => setTimeout(() => resolve("URL3"), 2000)),
  () => new Promise(resolve => setTimeout(() => resolve("URL4"), 800)),
];

// 测试业务版并发请求控制(最大并发数2)
multiRequest(urls, 2).then(res => {
  console.log("全部请求完成:", res);
  // 输出:[{success:true,data:"URL1"}, {success:false,error:"URL2"}, {success:true,data:"URL3"}, {success:true,data:"URL4"}]
});

关键注意点

  • 通用调度器(Scheduler):适用于所有 Promise 任务(不局限于请求),核心是“队列+递归补充任务”,控制最大并发数。

  • 业务版(multiRequest):专门用于接口请求,会按请求顺序存储结果(即使某个请求先完成,也会存在对应索引位置),最终统一返回所有结果,符合业务需求。

  • 任务要求:无论是调度器还是请求控制,传入的任务/请求必须是「返回 Promise 的函数」,否则无法监听执行完成的状态。

总结

以上13个代码片段,覆盖了 JavaScript 中「函数封装、设计模式、异步处理、作用域控制」等核心场景,既是日常开发的高频工具,也是面试中的重点考察内容。

学习这些片段的关键,不是死记代码,而是理解背后的原理(比如闭包、this 指向、Promise 机制),这样才能灵活运用到实际业务中,甚至根据需求修改优化。建议结合示例代码亲手运行,感受每个细节的作用,加深理解。

前端正则表达式全解:从基础语法到实战应用

本文适合前端初学者、日常开发使用及面试复习,从正则基础到实战场景,全程可直接复制运行

前言

正则表达式(Regular Expression,简称 RegExp)是前端开发中处理字符串的核心利器,无论是表单校验、字符串格式转换、关键词提取、文本分割,还是数据清洗,都离不开正则表达式。相比于传统的循环遍历、字符截取等方式,正则用一套简洁的符号规则,实现高效、优雅的字符串操作。

本文将从正则基础语法讲起,结合连字符转驼峰命名手机号严格校验两大实战场景,深度解析代码逻辑,并补充面试高频实操题,帮助你彻底掌握正则表达式。


一、正则表达式核心基础语法

正则表达式由字面量字符、元字符、字符类、量词、边界、分组、修饰符七大部分组成,是匹配字符串的规则集合。

1. 字面量字符

字面量字符是正则中最基础、无特殊含义的字符,直接匹配自身。

  • 示例:正则 /abc/ 可匹配字符串中连续的 abc
  • 特点:大小写敏感,无特殊语义,仅做精准匹配。

2. 元字符

元字符是正则中具备特殊功能的符号,是正则的核心,不能直接匹配自身,需转义后才能匹配。

常用元字符:

  • .:匹配任意单个字符(换行符除外)
  • *:匹配前一个字符 0 次或多次
  • +:匹配前一个字符 1 次或多次(贪婪匹配)
  • ?:匹配前一个字符 0 次或 1 次
  • ``:转义符,将元字符转为字面量(如匹配 . 需写 .

3. 字符类

字符类用于匹配某一类特定字符,是正则中最常用的匹配规则。

表格

字符 匹配范围 等价写法 示例
\d 任意数字 [0-9] /\d/.test('5') → true
\D 非数字 [^0-9] /\D/.test('a') → true
\w 字母、数字、下划线 [a-zA-Z0-9_] /\w/.test('_') → true
\W 非字母 / 数字 / 下划线 [^a-zA-Z0-9_] /\W/.test('-') → true
\s 空白字符(空格、tab、换行) - /\s/.test(' ') → true
\S 非空白字符 - /\S/.test('a') → true
[] 字符组合,匹配任意一个 - /[a,b]/.test('a') → true

4. 量词

量词用于限定字符的匹配次数,精准控制匹配长度。

表格

量词 含义 示例
{n} 恰好匹配 n 次 /\d{3}/ 匹配 3 位数字
{n,} 匹配 n 次及以上 /\d{2,}/ 匹配 2 位及以上数字
{n,m} 匹配 n~m 次 /\d{2,4}/ 匹配 2-4 位数字
+ 1 次及以上(等价 {1,} /\d+/ 匹配任意长度数字
* 0 次及以上(等价 {0,} /\w*/ 匹配 0 个及以上单词字符
? 0 次或 1 次(等价 {0,1} /\d?/ 匹配 0 个或 1 个数字

5. 边界符

边界符用于限定匹配的位置,避免非目标内容干扰,是严格校验的关键。

  • ^:匹配字符串开头
  • $:匹配字符串结尾
  • \b:匹配单词边界(如单词与空格的交界处)

6. 分组

分组用 () 实现,核心作用是捕获匹配的子内容,方便后续提取或替换。

  • 捕获分组:(\w) 匹配并捕获内容,可通过 $1$2 或回调参数获取
  • 非捕获分组:(?:\w) 仅匹配不捕获,减少性能开销

7. 修饰符

修饰符写在正则末尾,全局控制匹配规则

  • g:全局匹配,匹配所有符合规则的内容(而非仅第一个)
  • i:忽略大小写
  • m:多行匹配,按行匹配 ^$

8. 正则核心方法

正则的使用离不开字符串和正则对象的方法,常用方法如下:

1)RegExp.prototype.test()

  • 作用:检测字符串是否匹配正则规则
  • 返回值:布尔值(true/false
  • 示例:/^1\d{10}$/.test('15766668888') → true

2)String.prototype.match()

  • 作用:提取字符串中匹配正则的内容
  • 返回值:匹配成功返回数组,失败返回 null
  • 示例:'价格10880元'.match(/\d+/) → ['10880']

3)String.prototype.replace()

  • 作用:替换匹配正则的内容,支持字符串 / 回调函数
  • 示例:'a-b-c'.replace(/-(\w)/g, (_, c) => c.toUpperCase()) → 'aBC'

4)String.prototype.split()

  • 作用:按正则规则分割字符串
  • 示例:'a,b c'.split(/[,\s]+/) → ['a','b','c']

二、实战场景一:连字符命名转驼峰命名

1. 需求说明

开发中常遇到 adb-cdf-qwe-try 这类连字符命名,需转换为驼峰命名 adbCdfqweTry,要求:

  • 去除开头的连字符
  • 连字符后的第一个字母转为大写
  • 支持全局替换所有连字符片段

2. 正则规则设计

核心正则:/-(\w)/g

  • -:匹配连字符字面量
  • (\w):分组捕获连字符后的字母 / 数字 / 下划线
  • g:全局修饰符,匹配所有连字符片段

3. 完整代码实现

/**
 * 连字符命名转驼峰命名
 * @param {string} str - 待转换的连字符字符串
 * @returns {string} 驼峰命名字符串
 */
function toCamelCase(str) {
  // 第一步:去除字符串开头的所有连字符
  let result = str.replace(/^-+/, '');
  // 第二步:全局匹配 "-字符",将捕获的字符转大写
  result = result.replace(/-(\w)/g, (match, char) => {
    // match:完整匹配的片段(如 -c)
    // char:分组捕获的字符(如 c)
    return char.toUpperCase();
  });
  return result;
}

// 测试用例
console.log(toCamelCase('adb-cdf')); // adbCdf
console.log(toCamelCase('-qwe-try')); // qweTry
console.log(toCamelCase('background-color')); // backgroundColor
console.log(toCamelCase('-webkit-animation-name')); // webkitAnimationName

4. 代码解析

  • 第一步 /^-+/:匹配开头 1 个及以上连字符,替换为空,解决开头符号问题
  • 第二步 /-(\w)/g:全局匹配所有连字符 + 字符组合,通过回调函数将字符转大写
  • 回调参数:第一个参数是完整匹配内容,第二个是分组捕获内容,无需完整匹配时可用 _ 占位

三、实战场景二:手机号格式严格校验

1. 需求说明

为保证后端数据准确性,需严格校验手机号:

  • 必须是 11 位数字
  • 以数字 1 开头
  • 无任何多余字符(字母、空格、符号)

2. 正则规则设计

核心正则:/^1\d{10}$/

  • ^:限定字符串开头,确保从第一个字符开始匹配
  • 1:匹配手机号开头的数字 1
  • \d{10}:匹配后续 10 位数字,精准控制总长度为 11 位
  • $:限定字符串结尾,确保无多余字符

3. 完整代码实现

// 正则常量复用:仅创建一次正则实例,提升性能
const PHONE_REGEX = /^1\d{10}$/;

/**
 * 手机号格式校验
 * @param {string} phone - 待校验的手机号
 * @returns {boolean} 合法返回 true,否则返回 false
 */
function validatePhone(phone) {
  // 类型校验:排除非字符串输入
  if (typeof phone !== 'string') return false;
  // 正则校验
  return PHONE_REGEX.test(phone);
}

// 测试用例
console.log(validatePhone('15766668888')); // true(合法)
console.log(validatePhone('d15766668888')); // false(含字母)
console.log(validatePhone('1576666888')); // false(长度不足)
console.log(validatePhone('25766668888')); // false(非 1 开头)
console.log(validatePhone('15766668888 ')); // false(含空格)

4. 关键知识点:正则常量复用

正则常量复用:将固定不变的正则表达式,用 const 定义在函数外部,仅创建一次正则实例,函数多次调用时复用该实例。

  • 优势:避免函数每次调用都重新创建正则对象,减少性能开销
  • 适用场景:规则固定的正则(如手机号、邮箱校验)
  • 反例:正则写在函数内部,每次调用都新建实例,造成资源浪费

四、面试高频实操题(含答案)

1. 基础面试题

题目 1:\w\W 的区别?

答案:

  • \w:匹配字母(大小写)、数字、下划线
  • \W\w 的取反,匹配非字母、数字、下划线的字符(如空格、符号、中文)

题目 2:正则中 ^$ 的作用?

答案:

  • ^:匹配字符串开头,防止开头出现多余字符
  • $:匹配字符串结尾,防止结尾出现多余字符
  • 两者结合可实现严格全匹配,是表单校验的核心

题目 3:+* 的区别?

答案:

  • +:匹配前一个字符 1 次或多次,至少匹配 1 次
  • *:匹配前一个字符 0 次或多次,可以匹配 0 次

2. 实操面试题

题目 1:实现下划线 + 连字符混合命名转驼峰

hello_world-testhelloWorldTest

function mixToCamel(str) {
  let result = str.replace(/^[-_]+/, '');
  result = result.replace(/[-_](sslocal://flow/file_open?url=%5Cw&flow_extra=eyJsaW5rX3R5cGUiOiJjb2RlX2ludGVycHJldGVyIn0=)/g, (_, c) => c.toUpperCase());
  return result;
}
console.log(mixToCamel('hello_world-test')); // helloWorldTest

题目 2:支持带分隔符的手机号校验

157-6666-8888157 6666 8888

function validatePhoneWithSymbol(phone) {
  if (typeof phone !== 'string') return false;
  // 先去除所有非数字字符
  const purePhone = phone.replace(/\D/g, '');
  return /^1\d{10}$/.test(purePhone);
}
console.log(validatePhoneWithSymbol('157-6666-8888')); // true

题目 3:提取字符串中所有数字

价格100元,折扣8折['100','8']

function getAllNumbers(str) {
  return str.match(/\d+/g) || [];
}
console.log(getAllNumbers('价格100元,折扣8折')); // ['100','8']

题目 4:用正则分割字符串(按逗号、空格、分号分割)

function splitString(str) {
  return str.split(/[,\s;]+/);
}
console.log(splitString('apple,banana orange;pear')); // ['apple','banana','orange','pear']

五、总结

  1. 正则是前端字符串处理的核心工具,掌握字符类、量词、边界、分组、修饰符五大核心,即可应对 90% 的场景
  2. 实战中,连字符转驼峰/-(\w)/g 全局替换,手机号校验/^1\d{10}$/ 严格匹配
  3. 性能优化:固定规则的正则采用常量复用,避免重复创建实例
  4. 面试重点:分组捕获、边界符、全局修饰符、正则复用、实战转换 / 校验

熟练运用正则,能让你的字符串代码更简洁、高效,是前端工程师必备的核心技能。

万字长文:手撕JS深浅拷贝完全指南

前言

深浅拷贝是 JavaScript 中非常经典且重要的概念。

本文将从三道手撕面试题出发,由浅入深地讲解浅拷贝、简易深拷贝和完整深拷贝的实现原理与代码细节。

三道题目分别覆盖:基础浅拷贝、限定数据类型的简易深拷贝、以及支持特殊对象和循环引用的完整深拷贝。

阅读本文,掌握深浅拷贝的核心知识点和手写实现。


一、题目:FED15 浅拷贝

描述

请补全JavaScript代码,要求实现一个对象参数的浅拷贝并返回拷贝之后的新对象。 注意:

  1. 参数可能包含函数、正则、日期、ES6新对象
<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            const _shallowClone = target => {
                // 补全代码
                
            }
        </script>
    </body>
</html>

二、浅拷贝

对象的浅拷贝是属性与拷贝的源对象属性共享相同的引用(指向相同的底层值)的副本。

因此,当你更改源对象或副本时,也可能导致另一个对象发生更改。

与之相比,在深拷贝中,源对象和副本是完全独立的。

形式化地,如果两个对象 o1o2 是浅拷贝,那么:

  1. 它们不是同一个对象(o1 !== o2)。
  2. o1o2 的属性具有相同的名称且顺序相同。
  3. 它们的属性值相等。
  4. 它们的原型链相等。

可能导致另一个对象更改

这一点需要特别注意:并不是修改任何属性都会互相影响,只有修改被共享引用的那层属性才会。

  • 会互相影响的情况:修改 original 中一个引用类型的属性(例如数组、对象)。因为 shallowCopy 的对应属性指向同一个地址,所以 shallowCopy 能看到这个修改。
  • 不会互相影响的情况:直接给 original 的某个属性重新赋一个全新的值。这会断开 original 对该共享地址的引用,但 shallowCopy 的对应属性仍然指向原来的地址,两者不再相关。

对比深拷贝

特性 浅拷贝 (Shallow Copy) 深拷贝 (Deep Copy)
对象本身地址 不同 (新对象) 不同 (新对象)
第一层属性地址 相同 (共享引用) 不同 (递归创建新副本)
修改嵌套引用属性 会互相影响 不会互相影响
独立性 部分独立 (结构独立,深层数据依赖) 完全独立

Object.assign() - JavaScript | MDN

Object.assign() 静态方法将一个或者多个源对象中所有可枚举自有属性复制到目标对象,并返回修改后的目标对象。

如果目标对象与源对象具有相同的键(属性名),则目标对象中的属性将被源对象中的属性覆盖,后面的源对象的属性将类似地覆盖前面的源对象的同名属性。

const target = { a: 1, b: 2 };
const source = { b: 4, c: 5 };

const returnedTarget = Object.assign(target, source);

console.log(target);
// Expected output: Object { a: 1, b: 4, c: 5 }

console.log(returnedTarget === target);
// Expected output: true

语法

```js Object.assign(target, ...sources)


## [RegExp - JavaScript | MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/RegExp)

**`RegExp`** 对象用于将文本与一个模式匹配。

有关正则表达式的介绍,请阅读 [JavaScript 指南](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide)中的[正则表达式章节](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Regular_expressions)。

## [Map.prototype.set() - JavaScript | MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Map/set)

[`Map`](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Map) 实例的 **`set()`** 方法会向 `Map` 对象添加或更新一个指定的键值对。

```js
const myMap = new Map();

// 将一个新元素添加到 Map 对象
myMap.set("bar", "foo");
myMap.set(1, "foobar");

// 在 Map 对象中更新某个元素的值
myMap.set("bar", "baz");

Set.prototype.add() - JavaScript | MDN

Set 实例的 add() 方法会在该集合中插入一个具有指定值的新元素,如果该 Set 对象中没有具有相同值的元素。

const mySet = new Set();

mySet.add(1);
mySet.add(5).add("some text"); // 可以链式调用

console.log(mySet);
// Set [1, 5, "some text"]

三、解法:浅拷贝实现

题目要求

  1. 实现浅拷贝(只拷贝第一层属性)
  2. 参数可能包含:函数、正则、日期、ES6新对象(如 MapSet 等)
  3. 返回一个新对象,且 target !== result

普通浅拷贝的问题

通常我们这样做浅拷贝:

// 方法1:扩展运算符
const result = { ...target };

// 方法2:Object.assign
const result = Object.assign({}, target);

但这对特殊对象(Date、RegExp、Map、Set等)会出问题

const date = new Date();
const copy = { ...date };        // 得到 {},不是日期对象
const copy2 = Object.assign({}, date); // 也是 {}

因为扩展运算符和 Object.assign 只拷贝可枚举的自身属性,而 DateRegExp 等对象的实际数据存储在内部槽(internal slots)中,不是普通属性。

内部槽”是 JavaScript 引擎用来存储对象真实核心数据的地方。它不是普通的属性,你不能用 .属性名 的方式直接访问它,也不能通过 Object.keys() 看到它。

const date = new Date();
console.log(Object.keys(date));  // [] ← 没有可枚举的自身属性
console.log({ ...date });        // {} ← 扩展运算符拷贝了个寂寞

也就是这些对象用普通的展开语法,或者点属性调用是没有办法访问到的。

即,普通的展开语法(...)和点属性访问(.)都无法访问到内部槽中的数据。

typeof - JavaScript | MDN

注意在类型判断的时候,typeof 运算符返回一个字符串,表示操作数的类型。

所以写法注意都是要这样写的

typeof target === 'function'

正确实现思路

需要先判断类型,针对不同类型做不同处理:

const _shallowClone = target => {
    // 处理 null 和基本类型
    if (target === null || typeof target !== 'object') {
        return target;
    }
    
    // 处理函数
    if (typeof target === 'function') {
        return target;
    }
    
    // 处理 Date
    if (target instanceof Date) {
        return new Date(target);
    }
    
    // 处理 RegExp(显式传递 flags)
    if (target instanceof RegExp) {
        return new RegExp(target.source, target.flags);
    }
    
    // 处理 Map
    if (target instanceof Map) {
        const newMap = new Map();
        target.forEach((value, key) => {
            newMap.set(key, value);
        });
        return newMap;
    }
    
    // 处理 Set
    if (target instanceof Set) {
        const newSet = new Set();
        target.forEach(value => {
            newSet.add(value);
        });
        return newSet;
    }
    
    // 处理数组
    if (Array.isArray(target)) {
        return [...target];
    }
    
    // 处理普通对象(保留原型链)
    return Object.assign(Object.create(Object.getPrototypeOf(target)), target);
};

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            const _shallowClone = target => {
                if(target === null || typeof target !== 'object'){
                    return target ;
                }

                if (typeof target === 'function'){
                    return target ;
                }

                if (target instanceof Date ){
                    return new Date(target);
                }

                if (target instanceof RegExp ){
                    return new RegExp(target.source , target.flag);
                }
                
                if (target instanceof Map){
                    const newMap = new Map();
                    target.forEach ((value , key ) => {
                        newMap.set(key ,value);
                    });
                    return newMap ;
                }

                if (target instanceof Set){
                    const newSet = new Set();
                    target.forEach (value => {
                        newSet.add(value);
                    });
                    return newSet ;
                }

                if(Array.isArray(target)){
                    return [...target];
                }

                return Object.assign(Object.create(Object.getPrototypeOf(target)),target);
                
            }
        </script>
    </body>
</html>

四、题目:FED16 简易深拷贝

描述

请补全JavaScript代码,要求实现对象参数的深拷贝并返回拷贝之后的新对象。 注意:

  1. 参数对象和参数对象的每个数据项的数据类型范围仅在数组、普通对象({})、基本数据类型中]
  2. 无需考虑循环引用问题
<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            const _sampleDeepClone = target => {
                // 补全代码
                
            }
        </script>
    </body>
</html>

五、深拷贝

对象的深拷贝是指其属性与其拷贝的源对象的属性不共享相同的引用(指向相同的底层值)的副本。

因此,当你更改源或副本时,可以确保不会导致其他对象也发生更改;也就是说,你不会无意中对源或副本造成意料之外的更改

这种行为与浅拷贝的行为形成对比,在浅拷贝中,对源或副本的更改可能也会导致其他对象的更改(因为两个对象共享相同的引用)。

如果两个对象 o1o2结构等价的,那么它们的观察行为是相同的。这些行为包括:

  1. o1o2 的属性具有相同的名称且顺序相同。
  2. 它们的属性的值是结构等价的。
  3. 它们的原型链是结构等价的(尽管在处理结构等价时,这些对象通常是普通对象,意味着它们都继承自 Object.prototype)。

结构等价的对象可以是同一个对象(o1 === o2)或副本o1 !== o2)。因为等价的原始值总是相等的,所以你无法对它们进行复制。

我们现在可以更正式地定义深拷贝:

  1. 它们不是同一个对象(o1 !== o2)。
  2. o1o2 的属性具有相同的名称且顺序相同。
  3. 它们的属性的值是彼此的深拷贝。
  4. 它们的原型链是结构等价的。

深拷贝可能会或可能不会复制它们的原型链(通常情况下不会)。

但是,具有结构不等价原型链的两个对象(例如,一个是数组,另一个是普通对象)永远不会是彼此的副本。

所有属性都具有原始值的对象的副本符合深拷贝和浅拷贝的定义。然而,讨论这种副本的深度并无意义,因为它没有嵌套属性,而我们通常在改变嵌套属性的上下文中讨论深拷贝。

在 JavaScript 中,标准的内置对象复制操作(展开语法Array.prototype.concat()Array.prototype.slice()Array.from()Object.assign()Object.create())不创建深拷贝(相反,它们创建浅拷贝)。

深拷贝就是创建了一个“全新”的对象,这个新对象跟原对象“长得一模一样”,但彼此独立。你改新对象,不会影响原对象;改原对象,也不会影响新对象。

Object.prototype.hasOwnProperty() - JavaScript | MDN

hasOwnProperty() 方法返回一个布尔值,表示对象自有属性(而不是继承来的属性)中是否具有指定的属性。

六、解法:简易深拷贝实现

好的,题目要求很明确:实现一个简易深拷贝。既然题目已经限定数据类型范围在数组、普通对象、基本数据类型,且无需考虑循环引用,那我们可以用一种清晰直接的方法来实现。

下面我直接给出补全的代码,并附上详细注释,帮助你理解每一行在做什么。

const _sampleDeepClone = target => {
    // 1. 处理基本数据类型 和 null
    if (target === null || typeof target !== 'object') {
        return target;  // 直接返回原值(数字、字符串、布尔、null、undefined等)
    }

    // 2. 根据 target 的类型创建空的容器(数组或对象)
    const newObj = Array.isArray(target) ? [] : {};

    // 3. 遍历原对象/数组的所有属性(包括可枚举的自身属性)
    for (let key in target) {
        // 确保只复制 target 自身的属性,不复制原型链上的属性
        if (target.hasOwnProperty(key)) {
            // 4. 递归调用深拷贝,把属性的值也进行深拷贝
            newObj[key] = _sampleDeepClone(target[key]);
        }
    }

    // 5. 返回新的对象/数组
    return newObj;
};

遍历时注意是 let key in target 为了遍历原对象/数组的所有属性(包括可枚举的自身属性)

特性 for...in for...of
遍历内容 属性名(键) 元素值
适用对象 对象、数组(但不推荐用于数组) 数组、字符串、Map、Set、arguments 等
是否遍历原型链 是(会遍历继承的属性) 否(只遍历可迭代对象自身的元素)
是否保证顺序 不保证(依赖引擎实现) 保证(按可迭代协议的顺序)

为什么这样写就能实现深拷贝?

用一个例子来测试:

const original = {
    name: "小明",
    age: 25,
    address: {
        city: "北京",
        zip: 100000
    },
    hobbies: ["篮球", "编程"]
};

const cloned = _sampleDeepClone(original);

// 修改克隆对象的嵌套属性
cloned.address.city = "上海";
cloned.hobbies.push("阅读");

console.log(original.address.city); // "北京" —— 原对象不受影响
console.log(original.hobbies);      // ["篮球", "编程"] —— 原对象不受影响

执行过程:

  1. target 是对象 → 进入处理逻辑
  2. 创建空对象 newObj = {}
  3. 遍历 name → 基本类型 → 直接复制
  4. 遍历 address → 又是一个对象 → 递归调用自身,再次执行深拷贝逻辑
  5. address 的递归调用中,创建新对象,复制 cityzip
  6. 遍历 hobbies → 是数组 → Array.isArray 检测为真 → 创建空数组 [] → 递归复制每个元素
  7. 最终返回完全独立的新对象

代码关键点说明

代码 作用
typeof target !== 'object' 判断是否是基本数据类型(包括函数,但题目范围没有函数)
target === null 单独处理 null(因为 typeof null === 'object' 是历史遗留问题)
Array.isArray(target) 区分数组和普通对象,保证复制后类型一致
for...in + hasOwnProperty 只复制对象自身的属性,不复制原型链上的
递归调用 处理嵌套结构,确保每一层都是新对象/新数组

对比:这道题为什么不能用 JSON.parse(JSON.stringify())

虽然 JSON 方法在某些场景下可以实现深拷贝,但它有缺点:

  • 无法处理 undefined、函数、Symbol
  • 性能较差
  • 本题要求手写实现,考察递归思想

而上面手写的递归方法更通用,且完全满足本题的条件。

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            const _sampleDeepClone = target => {
                if (target === null || typeof target !== 'object'){
                    return target ;
                }                

                const newObj = Array.isArray(target) ? [] : {} ;

                for (let key in target){
                    if (target.hasOwnProperty(key)){
                        newObj[key] = _sampleDeepClone(target[key]);
                    }
                }
                return newObj ;
            }
        </script>
    </body>
</html>

这道题的核心就是递归 + 类型判断


七、题目:FED17 深拷贝

描述

请补全JavaScript代码,要求实现对象参数的深拷贝并返回拷贝之后的新对象。 注意:

  1. 需要考虑函数、正则、日期、ES6新对象
  2. 需要考虑循环引用问题
<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            const _completeDeepClone = (target, map = new Map()) => {
                // 补全代码
                
            }
        </script>
    </body>
</html>

八、完整深拷贝

Object.prototype.constructor - JavaScript | MDN

Object 实例的 constructor 数据属性返回一个引用,指向创建该实例对象的构造函数。注意,此属性的值是对函数本身的引用,而不是一个包含函数名称的字符串。

除了 null 原型对象之外,任何对象都会在其 [[Prototype]] 上有一个 constructor 属性。使用字面量创建的对象也会有一个指向该对象构造函数类型的 constructor 属性,例如,数组字面量创建的 Array 对象和对象字面量创建的普通对象。

const o1 = {};
o1.constructor === Object; // true

const o2 = new Object();
o2.constructor === Object; // true

const a1 = [];
a1.constructor === Array; // true

const a2 = new Array();
a2.constructor === Array; // true

const n = 3;
n.constructor === Number; // true

Object.getOwnPropertySymbols() - JavaScript | MDN

const object1 = {};
const a = Symbol("a");
const b = Symbol.for("b");

object1[a] = "localSymbol";
object1[b] = "globalSymbol";

const objectSymbols = Object.getOwnPropertySymbols(object1);

console.log(objectSymbols.length);
// Expected output: 2

Symbol - JavaScript | MDN

symbol 是一种原始数据类型Symbol() 函数会返回 symbol 类型的值,该类型具有静态属性和静态方法。它的静态属性会暴露几个内建的成员对象;它的静态方法会暴露全局的 symbol 注册,且类似于内建对象类,但作为构造函数来说它并不完整,因为它不支持语法:"new Symbol()"。

每个从 Symbol() 返回的 symbol 值都是唯一的。一个 symbol 值能作为对象属性的标识符;这是该数据类型仅有的目的。更进一步的解析见——glossary entry for Symbol

Symbol 是 ES6 引入的一种全新的原始数据类型,它的核心特点是:每个 Symbol 值都是独一无二的

Symbol 用来创建“绝对不重名”的属性名,防止属性名冲突。

看一个实际场景:

// 你写了一个用户管理库
const user = {
    name: "小明",
    age: 18
};

// 别人用你的库时,想添加一个自定义属性
user.name = "小红";  // ❌ 把原来的 name 覆盖了!

问题:普通字符串属性名容易冲突。

用 Symbol 解决:

const user = {
    name: "小明",
    age: 18
};

// 别人添加属性时,用 Symbol
const customKey = Symbol("custom");
user[customKey] = "一些自定义数据";

// 原来的 name 完好无损
console.log(user.name);  // "小明"

// Symbol 属性不会冲突
console.log(user[customKey]);  // "一些自定义数据"

九、解法:完整深拷贝实现

与上一题(简易深拷贝)的核心区别

特性 上一题(简易深拷贝) 本题(完整深拷贝)
数据类型 仅数组、普通对象、基本类型 函数、正则、日期、Map、Set 等
循环引用 不考虑 需要考虑(关键难点)
原型链 不要求 需要保持原型链

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            const _completeDeepClone = (target, map = new Map()) => {
                if (target === null || typeof target !== 'object'){
                    return target ;
                }

                if (map.has(target)){
                    return map.get(target);
                }

                const constructor = target.constructor ;

                if (constructor === Date ){
                    return new Date(target);
                }

                if (constructor === RegExp){
                    return new RegExp(target);
                }

                if (constructor === Map ){
                    const newMap = new Map ();
                    map.set (target , newMap);
                    target.forEach((value , key ) => {
                        newMap.set(
                            _completeDeepClone(key , map),
                            _completeDeepClone(value , map)
                        );
                    });
                    return newMap ;
                }

                if (constructor === Set ){
                    const newSet = new Set();
                    map.set(target , newSet);
                    target.forEach(value => {
                        newSet.add(_completeDeepClone(value , map));
                    });
                    return newSet ;
                }

                const newObj = Array.isArray(target) ? [] : {} ;
                map.set(target , newObj);

                const keys = [...Object.keys(target),...Object.getOwnPropertySymbols(target)];
                for (let key of keys){
                    newObj[key] = _completeDeepClone(target[key] , map );
                }

                return newObj ;
            }
        </script>
    </body>
</html>

解决思路

1. 循环引用问题

当对象有相互引用时,会导致无限递归:

const obj = {};
obj.self = obj;  // 自己引用自己

// 普通递归会死循环 ❌

解决方案:用 Map 缓存已经拷贝过的对象。每次拷贝前先检查,如果拷贝过就直接返回。

2. 各种类型的处理策略

类型 处理方法
基本类型 / null 直接返回
日期 Date new Date(target)
正则 RegExp new RegExp(target)
数组 Array 遍历递归拷贝每个元素
普通对象 Object 遍历递归拷贝每个属性
Map 遍历 map,递归拷贝每个键值对
Set 遍历 set,递归拷贝每个值
函数 Function 直接返回(函数一般不深拷贝,复用即可)

完整代码

const _completeDeepClone = (target, map = new Map()) => {
    // 1. 处理基本类型 和 null
    if (target === null || typeof target !== 'object') {
        return target;
    }

    // 2. 处理循环引用:如果已经拷贝过,直接返回
    if (map.has(target)) {
        return map.get(target);
    }

    // 3. 处理特殊对象类型
    const constructor = target.constructor;
    
    // 日期
    if (constructor === Date) {
        return new Date(target);
    }
    
    // 正则
    if (constructor === RegExp) {
        return new RegExp(target);
    }
    
    // Map
    if (constructor === Map) {
        const newMap = new Map();
        map.set(target, newMap);
        target.forEach((value, key) => {
            newMap.set(
                _completeDeepClone(key, map),
                _completeDeepClone(value, map)
            );
        });
        return newMap;
    }
    
    // Set
    if (constructor === Set) {
        const newSet = new Set();
        map.set(target, newSet);
        target.forEach(value => {
            newSet.add(_completeDeepClone(value, map));
        });
        return newSet;
    }
    
    // 4. 处理数组和普通对象
    const newObj = Array.isArray(target) ? [] : {};
    map.set(target, newObj);
    
    // 遍历所有属性(包括 Symbol 属性)
    const keys = [...Object.keys(target), ...Object.getOwnPropertySymbols(target)];
    for (let key of keys) {
        newObj[key] = _completeDeepClone(target[key], map);
    }
    
    return newObj;
};

测试代码(验证循环引用)

// 测试循环引用
const obj = {
    name: "test",
    date: new Date(),
    regex: /abc/g,
    map: new Map([['key', 'value']]),
    set: new Set([1, 2, 3])
};
obj.self = obj;  // 循环引用

const cloned = _completeDeepClone(obj);

console.log(cloned !== obj);           // true
console.log(cloned.self === cloned);   // true(循环引用被正确保持)
console.log(cloned.date !== obj.date); // true(日期被正确拷贝)
console.log(cloned.regex !== obj.regex); // true(正则被正确拷贝)

关键点详解

1. map 参数的作用

const _completeDeepClone = (target, map = new Map()) => {
    // 第一次调用时 map 是空的
    // 递归调用时传入同一个 map,用来记录哪些对象已经拷贝过
}

2. 循环引用处理流程

// 步骤1: 检查是否已经拷贝过
if (map.has(target)) {
    return map.get(target);  // 直接返回已拷贝的版本,避免无限递归
}

// 步骤2: 创建新对象后立即存入 map
map.set(target, newObj);
// 这样后续遇到相同引用时就能直接返回

3. 为什么要处理 Symbol 属性?

const keys = [...Object.keys(target), ...Object.getOwnPropertySymbols(target)];
  • Object.keys() 获取字符串属性名
  • Object.getOwnPropertySymbols() 获取 Symbol 类型的属性名
  • 两者合并,确保所有属性都被拷贝

4. 为什么不拷贝函数?

if (constructor === Function) {
    return target;  // 直接返回原函数
}

函数内部可能依赖外部作用域,深拷贝没有意义,通常复用原函数即可。

注意

  1. Map 和 Set 的拷贝顺序:遍历时要注意先存 map,再递归内部元素
  2. Symbol 属性:普通 for...in 遍历不到,需要专门处理
  3. 正则的 flagsnew RegExp(target) 会自动保留标志位(g、i、m 等)

十、总结

通过三道题目,我们完整地学习了深浅拷贝的各个层次:

  1. 浅拷贝:只复制第一层,使用 Object.assign 或扩展运算符,但需要特殊处理 Date、RegExp、Map、Set 等内置对象。
  2. 简易深拷贝:递归复制所有层级,适用于数组和普通对象,不考虑循环引用。
  3. 完整深拷贝:在简易深拷贝基础上,增加对 Date、RegExp、Map、Set 的支持,并使用 Map 解决循环引用问题,同时处理 Symbol 属性。

深入理解浏览器存储方案:从Cookie到JWT登录认证

前言

在现代Web开发中,用户状态的持久化是一个永恒的话题。

无论是传统的多页应用还是当下的前后端分离架构,开发者都需要在客户端存储用户相关的数据。

Cookie、localStorage和SessionStorage作为浏览器原生提供的三种存储方案,各有特点和适用场景。

而围绕这些存储方案构建的登录认证机制,更是Web安全领域的基础知识。

本文将从原理出发,结合实际代码,带你全面理解这些技术的本质与差异。


一、浏览器存储方案的共性特征

浏览器存储方案的出现,是为了解决HTTP协议无状态带来的用户识别问题。这三种存储方案虽然实现细节不同,但存在几个共同的核心特征。

键值对存储模式:无论是Cookie、localStorage还是SessionStorage,它们都采用最简单的键值对来组织数据。开发者通过设置一个唯一的键(Key),即可存储对应的值(Value)。这种设计降低了使用门槛,使得状态管理变得直观高效。

数据类型限制:这三种存储方案都只能存储字符串类型的数据。当你尝试存储一个JavaScript对象时,实际上会被自动转换为字符串格式。这一限制意味着开发者需要自行处理数据的序列化和反序列化工作,JSON.stringify()和JSON.parse()因此成为前端开发中不可或缺的工具。

同源策略约束:安全性是浏览器存储方案的重要特性。三种存储都严格遵守同源策略,即只有来自相同协议、域名和端口的页面才能访问同一份存储数据。这一机制有效防止了跨站脚本攻击(XSS)和敏感数据的非授权访问。

用户状态管理:它们的根本目的都是保存用户状态,实现Web应用的会话管理。从简单的用户偏好设置到复杂的登录凭证,都依赖这些存储方案来实现状态的持久化。


二、三种存储方案的核心区别

虽然三种方案在上述方面保持一致,但在实际应用中,它们存在着显著的差异,这些差异决定了各自的适用场景。

2.1 存储容量与数据传输

Cookie的最大容量仅为4KB,且每次HTTP请求都会自动携带Cookie数据到服务器。这意味着如果存储过多数据,会显著增加网络带宽的消耗和请求延迟。对于高流量的应用而言,这种开销是不可忽视的。相比之下,localStorage和SessionStorage的存储容量通常在5-10MB左右,完全能够满足大多数前端存储需求,且不会产生任何网络传输负担。

2.2 数据生命周期

Cookie可以通过设置过期时间来实现持久化存储,未设置过期时间的Cookie会在浏览器关闭后自动删除。localStorage的数据除非被手动清除,否则会永久保存在浏览器中。SessionStorage则是一种会话级的存储,其数据仅在当前浏览器标签页或窗口关闭后自动清除,不同标签页之间的SessionStorage无法共享。

2.3 服务端与客户端的交互

这是三种方案最本质的区别。Cookie可以在浏览器端设置,也可以由服务器在HTTP响应头中生成和返回。当服务器收到请求时,可以直接读取和修改Cookie的内容。这种双向交互的特性使得Cookie成为实现Session认证的理想载体。而localStorage和SessionStorage完全由客户端JavaScript控制,服务器无法直接访问这些数据,这一特性使它们更适合存储不需要与服务端共享的纯客户端数据。

2.4 自动化携带机制

Cookie具有一个独特的行为:浏览器会自动将其包含在同源请求的HTTP头中。这种自动化携带机制既带来便利,也带来挑战。便利之处在于开发者无需手动处理请求头的设置,挑战则在于所有请求都会附带Cookie数据,可能导致性能问题,尤其在移动网络环境下更为明显。


三、Cookie与Session的经典登录方案

传统的Web应用普遍采用Cookie与Session结合的方式来实现用户认证。这种方案诞生于Web发展的早期阶段,至今仍被广泛使用,但其局限性在现代分布式系统中日益凸显。

3.1 原理概述

Cookie与Session的协作机制可以用"小饼干找位置"来形象理解。当用户首次登录时,服务器验证用户名和密码后,会生成一个唯一的会话标识符SessionId。这个SessionId本身不包含任何用户敏感信息,只是一个随机的唯一标识。服务器在内存中维护一个Session对象,将用户信息与SessionId关联起来。服务器响应时将SessionId放入Cookie中返回给浏览器。后续请求中,浏览器自动携带Cookie,服务器通过Cookie中的SessionId在内存中查找对应的用户信息,从而完成身份识别。

3.2 核心优势与天然缺陷

这种方案的优势在于安全性较高。用户信息存储在服务器端,客户端只持有SessionId,即使Cookie被截获,攻击者也无法直接获取用户数据。服务器还可以随时销毁Session来强制用户登出。

然而,随着互联网架构的演进,Session机制的缺陷逐渐显现。首先,服务器需要在内存中维护所有用户的Session对象,在高并发场景下会占用大量内存资源。其次,在分布式部署环境中,不同服务器之间无法共享内存中的Session数据,需要借助Redis等外部存储来解决,但增加了系统复杂度。最后,现代移动端应用和前后端分离架构中,Cookie的自动携带机制并不总是适用,跨域请求的处理也变得棘手。

3.3 实战代码:Express实现Cookie+Session登录

下面是一个基于Express框架的完整登录认证实现,演示了Cookie与Session机制的核心逻辑。

const express = require('express');
const cookieParser = require('cookie-parser');
const { v4: uuidv4 } = require('uuid');
const path = require('path');
const { error } = require('console');

const app = express();
const PORT = 3000;

// 模拟用户数据库
const usersDB = [
  {id: 1, username:"admin", password: "123", role: "admin"},
  {id: 2, username:"user", password: "123", role: "user"}
];

// Session对象集合,用于存储会话信息
const sessionStore = {};

// 启用中间件
app.use(express.json());
app.use(express.urlencoded({extended: true}));
app.use(cookieParser());
app.use(express.static('public'));

// 登录接口
app.post('/api/login', (req, res) => {
  const { username, password } = req.body;
  console.log(username, password, '-----');
  
  // 验证用户凭证
  const user = usersDB.find(u => u.username === username && u.password === password);
  if(!user){
    return res.status(401).json({error:"用户名或密码错误"});
  }
  
  // 生成唯一的会话ID
  const sessionId = uuidv4();
  
  // 在Session存储中记录会话信息
  sessionStore[sessionId] = {
    id: user.id,
    username: user.username,
    role: user.role,
    loginTime: new Date().toISOString()
  };
  
  // 将SessionId通过Cookie返回给客户端
  res.cookie('sessionId', sessionId, {
    httpOnly: true,  // 防止XSS攻击
    secure: process.env.NODE_ENV === 'production',
    maxAge: 24 * 60 * 60 * 1000  // 24小时过期
  });
  
  res.json({
    success: true,
    message: '登录成功',
    user: { id: user.id, username: user.username, role: user.role }
  });
});

// 获取用户信息接口(需要验证登录状态)
app.get('/api/userinfo', (req, res) => {
  const sessionId = req.cookies.sessionId;
  
  if (!sessionId || !sessionStore[sessionId]) {
    return res.status(401).json({ error: '未登录或会话已过期' });
  }
  
  const session = sessionStore[sessionId];
  res.json({ user: session });
});

// 退出登录接口
app.post('/api/logout', (req, res) => {
  const sessionId = req.cookies.sessionId;
  
  if (sessionId && sessionStore[sessionId]) {
    delete sessionStore[sessionId];
  }
  
  res.clearCookie('sessionId');
  res.json({ success: true, message: '已退出登录' });
});

app.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`);
});

上述代码展示了一个完整的登录认证流程:用户提交登录请求后,服务器验证凭证并生成SessionId,将用户信息存入内存中的sessionStore对象,最后通过Set-Cookie响应头将SessionId返回给浏览器。后续请求中,浏览器自动携带Cookie,服务器通过SessionId查找对应的会话数据完成身份验证。

3.4 前端登录页面实现

一个完整的前端登录界面需要处理用户输入、发送登录请求、保存登录状态以及展示用户信息。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Cookie + Session 登录演示</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    .container {
      background: white;
      padding: 2rem;
      border-radius: 12px;
      box-shadow: 0 20px 60px rgba(0,0,0,0.3);
      width: 100%;
      max-width: 400px;
    }
    h1 { text-align: center; color: #333; margin-bottom: 1.5rem; }
    .form-group { margin-bottom: 1rem; }
    label { display: block; margin-bottom: 0.5rem; color: #555; font-weight: 500; }
    input {
      width: 100%;
      padding: 0.75rem;
      border: 2px solid #e1e1e1;
      border-radius: 8px;
      font-size: 1rem;
      transition: border-color 0.3s;
    }
    input:focus { outline: none; border-color: #667eea; }
    button {
      width: 100%;
      padding: 0.75rem;
      background: #667eea;
      color: white;
      border: none;
      border-radius: 8px;
      font-size: 1rem;
      font-weight: 600;
      cursor: pointer;
      transition: background 0.3s;
    }
    button:hover { background: #5568d3; }
    button:disabled { background: #ccc; cursor: not-allowed; }
    .message {
      margin-top: 1rem;
      padding: 0.75rem;
      border-radius: 8px;
      text-align: center;
      display: none;
    }
    .message.error { background: #fee; color: #c00; display: block; }
    .message.success { background: #efe; color: #060; display: block; }
    .user-info {
      text-align: center;
      display: none;
    }
    .user-info.show { display: block; }
    .user-avatar {
      width: 80px;
      height: 80px;
      border-radius: 50%;
      background: #667eea;
      color: white;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 2rem;
      margin: 0 auto 1rem;
    }
  </style>
</head>
<body>
  <div class="container">
    <!-- 登录表单 -->
    <div id="loginForm">
      <h1>用户登录</h1>
      <form id="form">
        <div class="form-group">
          <label for="username">用户名</label>
          <input type="text" id="username" name="username" required placeholder="请输入用户名">
        </div>
        <div class="form-group">
          <label for="password">密码</label>
          <input type="password" id="password" name="password" required placeholder="请输入密码">
        </div>
        <button type="submit" id="submitBtn">登录</button>
      </form>
      <div id="message" class="message"></div>
    </div>
    
    <!-- 用户信息展示 -->
    <div id="userInfo" class="user-info">
      <div class="user-avatar" id="avatar"></div>
      <h2 id="displayUsername"></h2>
      <p id="displayRole" style="color: #666; margin: 0.5rem 0;"></p>
      <p id="loginTime" style="color: #999; font-size: 0.875rem;"></p>
      <button onclick="logout()" style="margin-top: 1.5rem; background: #e1e1e1; color: #333;">
        退出登录
      </button>
    </div>
  </div>

  <script>
    const form = document.getElementById('form');
    const loginForm = document.getElementById('loginForm');
    const userInfoEl = document.getElementById('userInfo');
    const messageEl = document.getElementById('message');
    const submitBtn = document.getElementById('submitBtn');

    // 显示提示消息
    function showMessage(msg, type = 'error') {
      messageEl.textContent = msg;
      messageEl.className = `message ${type}`;
    }

    // 隐藏提示消息
    function hideMessage() {
      messageEl.className = 'message';
    }

    // 检查登录状态
    async function checkLoginStatus() {
      try {
        const res = await fetch('/api/userinfo', { credentials: 'include' });
        if (res.ok) {
          const data = await res.json();
          showUserInfo(data.user);
        }
      } catch (e) {
        console.log('未登录');
      }
    }

    // 显示用户信息
    function showUserInfo(user) {
      loginForm.style.display = 'none';
      userInfoEl.classList.add('show');
      document.getElementById('avatar').textContent = user.username.charAt(0).toUpperCase();
      document.getElementById('displayUsername').textContent = user.username;
      document.getElementById('displayRole').textContent = `角色: ${user.role}`;
      document.getElementById('loginTime').textContent = `登录时间: ${user.loginTime}`;
    }

    // 登录表单提交
    form.addEventListener('submit', async (e) => {
      e.preventDefault();
      hideMessage();
      submitBtn.disabled = true;
      submitBtn.textContent = '登录中...';

      const username = document.getElementById('username').value;
      const password = document.getElementById('password').value;

      try {
        const res = await fetch('/api/login', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          credentials: 'include',
          body: JSON.stringify({ username, password })
        });

        const data = await res.json();

        if (res.ok) {
          showUserInfo(data.user);
        } else {
          showMessage(data.error || '登录失败');
          submitBtn.disabled = false;
          submitBtn.textContent = '登录';
        }
      } catch (e) {
        showMessage('网络错误,请重试');
        submitBtn.disabled = false;
        submitBtn.textContent = '登录';
      }
    });

    // 退出登录
    async function logout() {
      try {
        await fetch('/api/logout', { method: 'POST', credentials: 'include' });
        userInfoEl.classList.remove('show');
        loginForm.style.display = 'block';
        form.reset();
        submitBtn.disabled = false;
        submitBtn.textContent = '登录';
      } catch (e) {
        showMessage('退出失败');
      }
    }

    // 页面加载时检查登录状态
    checkLoginStatus();
  </script>
</body>
</html>

这个前端实现包含了完整的用户交互流程:登录表单提交时会显示加载状态,登录成功后隐藏表单并展示用户信息,退出登录则清除会话状态。credentials: 'include'选项确保Cookie能够正确发送。


四、JWT双Token登录:现代前后端分离的解决方案

随着前后端分离架构的普及,传统的Cookie+Session方案逐渐显露出局限性。JWT(JSON Web Token)作为一种自包含的身份凭证,凭借其无状态、可扩展的特性,成为现代Web应用的主流认证方案。

4.1 JWT的核心原理

JWT是一种开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间以JSON对象的形式安全传输信息。这些信息可以被验证和信任,因为它们经过数字签名。JWT由三部分组成:Header(头部)、Payload(负载)和Signature(签名),通过点号分隔形成最终的Token字符串。

与Session不同,JWT本身包含了用户身份信息,服务器无需存储任何会话数据。每一个Token都可以被独立验证和解码,这使得JWT特别适合分布式系统和跨域认证场景。

4.2 双Token机制的工作流程

双Token策略是JWT应用的最佳实践。系统同时颁发Access Token(访问令牌)和Refresh Token(刷新令牌)。Access Token用于接口访问,通常设置较短的过期时间(如15分钟到1小时);Refresh Token用于在Access Token过期后获取新的访问令牌,设置较长的过期时间(如7天到30天)。

当用户登录时,服务器验证凭证后同时生成这两种Token并返回给客户端。客户端使用Access Token访问受保护的资源。当Access Token过期时,客户端使用Refresh Token向服务器申请新的Access Token。如果Refresh Token也过期或被撤销,用户需要重新登录。这种机制在安全性和用户体验之间取得了良好的平衡。

4.3 JWT相比Session的优势

JWT的第一个优势是无状态扩展。服务器不需要存储Token,Token本身包含了所有验证所需的信息。这使得水平扩展变得简单,新加入的服务器节点无需同步会话状态,非常适合微服务架构和容器化部署。

第二个优势是跨域友好。JWT可以存储在localStorage中,通过HTTP请求头传递,不受Cookie的同源限制影响。这使得JWT天然支持移动端应用和第三方API集成。

第三个优势是细粒度控制。开发者可以在Payload中自定义声明,存储用户权限、角色等信息,Token本身就是一个完整的身份胶囊。

4.4 JWT实现示例

const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

const app = express();
const PORT = 3000;

const SECRET_KEY = crypto.randomBytes(32).toString('hex');
const REFRESH_SECRET = crypto.randomBytes(32).toString('hex');
const ACCESS_TOKEN_EXPIRE = '15m';
const REFRESH_TOKEN_EXPIRE = '7d';

const usersDB = [
  { id: 1, username: 'admin', password: '123', role: 'admin' },
  { id: 2, username: 'user', password: '123', role: 'user' }
];

app.use(express.json());

// 生成Token的辅助函数
function generateTokens(user) {
  const accessToken = jwt.sign(
    { id: user.id, username: user.username, role: user.role },
    SECRET_KEY,
    { expiresIn: ACCESS_TOKEN_EXPIRE }
  );
  
  const refreshToken = jwt.sign(
    { id: user.id, type: 'refresh' },
    REFRESH_SECRET,
    { expiresIn: REFRESH_TOKEN_EXPIRE }
  );
  
  return { accessToken, refreshToken };
}

// 登录接口
app.post('/api/login', (req, res) => {
  const { username, password } = req.body;
  
  const user = usersDB.find(u => u.username === username && u.password === password);
  if (!user) {
    return res.status(401).json({ error: '用户名或密码错误' });
  }
  
  const tokens = generateTokens(user);
  
  res.json({
    success: true,
    ...tokens,
    user: { id: user.id, username: user.username, role: user.role }
  });
});

// 刷新Token接口
app.post('/api/refresh', (req, res) => {
  const { refreshToken } = req.body;
  
  if (!refreshToken) {
    return res.status(400).json({ error: '缺少refreshToken' });
  }
  
  try {
    const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
    
    if (decoded.type !== 'refresh') {
      throw new Error('无效的token类型');
    }
    
    const user = usersDB.find(u => u.id === decoded.id);
    if (!user) {
      throw new Error('用户不存在');
    }
    
    const tokens = generateTokens(user);
    res.json({ success: true, ...tokens });
  } catch (e) {
    res.status(401).json({ error: 'refreshToken已过期,请重新登录' });
  }
});

// 受保护的接口
app.get('/api/userinfo', authenticateToken, (req, res) => {
  res.json({ user: req.user });
});

// Token验证中间件
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: '缺少accessToken' });
  }
  
  jwt.verify(token, SECRET_KEY, (err, user) => {
    if (err) {
      return res.status(403).json({ error: 'accessToken已过期' });
    }
    req.user = user;
    next();
  });
}

app.listen(PORT, () => {
  console.log(`JWT服务器运行在 http://localhost:${PORT}`);
});

五、存储方案对比与实践建议

在实际项目中,选择合适的存储方案需要综合考虑多个因素。Cookie适用于需要服务器参与的状态管理场景,如用户认证。localStorage适合存储大量不需要频繁与服务端交互的数据,如用户偏好设置、本地缓存等。SessionStorage则用于会话级别的临时数据存储,如表单草稿、多步引导的中间状态。

对于认证方案的选择,传统的企业级多页应用仍可使用Cookie+Session方案,其成熟度和安全性经过长期验证。对于移动端应用、微服务架构或需要跨域认证的系统,JWT是更合适的选择。在使用JWT时,务必注意Token的安全存储、合理的过期时间设置以及Refresh Token的安全管理。


六、总结

浏览器存储方案和认证机制是Web开发的基础知识,理解它们的原理和差异对于构建安全、高效的应用至关重要。Cookie、localStorage和SessionStorage各有特点,适用于不同的场景。Cookie+Session作为经典的认证方案在传统Web应用中表现稳定,而JWT双Token方案则为现代前后端分离架构提供了更灵活的解决方案。作为开发者,应根据具体业务需求和系统架构选择最适合的技术方案。

超越 useState:掌握 React 进阶状态模式

useState 是 React 状态管理的主力。处理简单场景绰绰有余——一个控制弹窗的布尔值、一个输入框的字符串、一个计数器的数字。但需求稍微复杂一点——你需要上一次渲染的值、想对搜索词做防抖、要写一个既能受控又能非受控的组件——你就会发现自己反反复复写着同样的模板代码。用 ref 存旧值、清理 setTimeout 的 ID、受控和非受控的协调逻辑很快就变成一堆纠缠不清的 useEffect

本文将带你走过七种超越基础 useState 的状态模式。每种模式我们先展示手动实现,让你看清其中的门道,然后用 ReactUse 中专门的 Hook 替换。最后,我们会把七个 Hook 组合进一个交互式设置面板,展示它们如何无缝协作。

1. 受控 vs 非受控组件:useControlled

痛点

可复用的 UI 组件通常需要支持两种模式:受控(父组件持有状态,传入 value + onChange)和非受控(组件自行管理内部状态,可选接受 defaultValue)。同时支持两种模式是 MUI、Radix 等成熟组件库的标配——但实现起来出乎意料地繁琐。

手动实现

import { useCallback, useRef, useState } from "react";

interface CustomInputProps {
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
}

function CustomInput({ value, defaultValue = "", onChange }: CustomInputProps) {
  const isControlled = value !== undefined;
  const [internalValue, setInternalValue] = useState(defaultValue);

  // 用 ref 始终持有最新的受控值
  const valueRef = useRef(value);
  valueRef.current = value;

  const currentValue = isControlled ? value : internalValue;

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const next = e.target.value;
      if (!isControlled) {
        setInternalValue(next);
      }
      onChange?.(next);
    },
    [isControlled, onChange]
  );

  return (
    <input
      value={currentValue}
      onChange={handleChange}
      style={{
        padding: "8px 12px",
        border: "1px solid #d1d5db",
        borderRadius: 6,
        fontSize: 16,
      }}
    />
  );
}

对于一个简单输入框来说够用了。但当受控值从外部变更时(需要同步)、当你要提醒开发者不要在受控和非受控之间切换时、当值是复杂对象而非基本类型时,这套逻辑就越来越复杂。每个需要双模式的组件都在重复同一段代码。

用 useControlled

useControlled 封装了整套受控/非受控协调逻辑,返回一个 [value, setValue] 元组,无论使用者选择哪种模式都能正常工作。

import { useControlled } from "@reactuses/core";

interface CustomInputProps {
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
}

function CustomInput({ value, defaultValue = "", onChange }: CustomInputProps) {
  const [currentValue, setCurrentValue] = useControlled({
    value,
    defaultValue,
    onChange,
  });

  return (
    <input
      value={currentValue}
      onChange={(e) => setCurrentValue(e.target.value)}
      style={{
        padding: "8px 12px",
        border: "1px solid #d1d5db",
        borderRadius: 6,
        fontSize: 16,
      }}
    />
  );
}

// 非受控用法——组件自行管理状态
function UncontrolledDemo() {
  return <CustomInput defaultValue="hello" />;
}

// 受控用法——父组件持有状态
function ControlledDemo() {
  const [text, setText] = useState("");
  return <CustomInput value={text} onChange={setText} />;
}

一次 Hook 调用就替代了 ref、isControlled 判断和双路径更新逻辑。组件在两种模式下行为完全一致,即使开发者意外地在受控和非受控之间切换,Hook 也能从容应对。

2. 追踪前一个值:usePrevious

痛点

你经常需要上一次渲染的值——用来比较 prop 是否变化、在新旧值之间做过渡动画、或者显示"从 X 变成了 Y"的 UI 反馈。React 没有内置这个能力。

手动实现

import { useEffect, useRef, useState } from "react";

function PriceDisplay({ price }: { price: number }) {
  const prevPriceRef = useRef<number | undefined>(undefined);

  useEffect(() => {
    prevPriceRef.current = price;
  });

  const prevPrice = prevPriceRef.current;
  const direction =
    prevPrice === undefined
      ? "neutral"
      : price > prevPrice
        ? "up"
        : price < prevPrice
          ? "down"
          : "neutral";

  return (
    <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
      <span style={{ fontSize: 32, fontWeight: 700 }}>
        ${price.toFixed(2)}
      </span>
      {direction === "up" && (
        <span style={{ color: "#16a34a", fontSize: 20 }}></span>
      )}
      {direction === "down" && (
        <span style={{ color: "#dc2626", fontSize: 20 }}></span>
      )}
      {prevPrice !== undefined && prevPrice !== price && (
        <span style={{ color: "#6b7280", fontSize: 14 }}>
          之前是 ${prevPrice.toFixed(2)}
        </span>
      )}
    </div>
  );
}

ref 加 effect 的技巧能用,但容易出错。如果把 effect 放在渲染逻辑之前(或者不该用 useLayoutEffect 的地方用了),"前值"可能会变成过期或当前的值。而且每个需要变更检测的组件都要复制这段样板代码。

用 usePrevious

usePrevious 返回上一次渲染的值,时机精确——在当前渲染期间你始终看到的是旧值。

import { usePrevious } from "@reactuses/core";

function PriceDisplay({ price }: { price: number }) {
  const prevPrice = usePrevious(price);

  const direction =
    prevPrice === undefined
      ? "neutral"
      : price > prevPrice
        ? "up"
        : price < prevPrice
          ? "down"
          : "neutral";

  return (
    <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
      <span style={{ fontSize: 32, fontWeight: 700 }}>
        ${price.toFixed(2)}
      </span>
      {direction === "up" && (
        <span style={{ color: "#16a34a", fontSize: 20 }}></span>
      )}
      {direction === "down" && (
        <span style={{ color: "#dc2626", fontSize: 20 }}></span>
      )}
      {prevPrice !== undefined && prevPrice !== price && (
        <span style={{ color: "#6b7280", fontSize: 14 }}>
          之前是 ${prevPrice.toFixed(2)}
        </span>
      )}
    </div>
  );
}

function StockTicker() {
  const [price, setPrice] = useState(142.5);

  return (
    <div style={{ padding: 24 }}>
      <PriceDisplay price={price} />
      <div style={{ marginTop: 16, display: "flex", gap: 8 }}>
        <button onClick={() => setPrice((p) => p + Math.random() * 5)}>
          涨价
        </button>
        <button onClick={() => setPrice((p) => p - Math.random() * 5)}>
          跌价
        </button>
      </div>
    </div>
  );
}

不需要 ref,不需要 effect。一行代码就能拿到前值,并且与 React 的渲染周期精确同步。

3. 防抖状态:useDebounce

痛点

搜索输入框、过滤字段、实时预览编辑器都面临同一个问题:每次按键都更新状态会触发昂贵的操作(API 请求、重渲染、复杂过滤),频率远超必要。防抖——等用户停止输入指定时间后再触发——是标准解决方案。

手动实现

import { useEffect, useRef, useState } from "react";

function ManualDebouncedSearch() {
  const [query, setQuery] = useState("");
  const [debouncedQuery, setDebouncedQuery] = useState("");
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  useEffect(() => {
    timerRef.current = setTimeout(() => {
      setDebouncedQuery(query);
    }, 300);

    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, [query]);

  // 卸载时清理
  useEffect(() => {
    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, []);

  return (
    <div style={{ padding: 24 }}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
        style={{
          padding: "8px 12px",
          border: "1px solid #d1d5db",
          borderRadius: 6,
          width: 300,
          fontSize: 16,
        }}
      />
      <p style={{ color: "#6b7280", marginTop: 8 }}>
        防抖后的值: <strong>{debouncedQuery}</strong>
      </p>
      <p style={{ color: "#9ca3af", fontSize: 14 }}>
        (这个值会触发 API 请求)
      </p>
    </div>
  );
}

两个状态变量、一个存定时器的 ref、一个调度防抖的 effect、另一个处理卸载清理的 effect。能用,但对于一个几十个组件都需要的功能来说,仪式感太重了。

用 useDebounce

useDebounce 返回任意值的防抖版本。你正常更新源状态,Hook 会产出一个滞后的副本,只在指定的静默期之后才更新。

import { useDebounce } from "@reactuses/core";
import { useState } from "react";

function DebouncedSearch() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);

  return (
    <div style={{ padding: 24 }}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
        style={{
          padding: "8px 12px",
          border: "1px solid #d1d5db",
          borderRadius: 6,
          width: 300,
          fontSize: 16,
        }}
      />
      <p style={{ color: "#6b7280", marginTop: 8 }}>
        防抖后的值: <strong>{debouncedQuery}</strong>
      </p>
      {query !== debouncedQuery && (
        <p style={{ color: "#f59e0b", fontSize: 14 }}>
          等待输入停止...
        </p>
      )}
    </div>
  );
}

一个 Hook,一行代码。定时器管理、清理和同步全部在内部处理。比较 query !== debouncedQuery 还能免费实现"输入中"指示。

4. 节流状态:useThrottle

痛点

节流是防抖的近亲。不同于等待静默,它确保更新在每个时间间隔内最多触发一次——适用于连续触发的事件,比如滚动位置、鼠标移动或实时数据流,你想要的是稳定的更新频率而非末尾的一次性爆发。

手动实现

import { useEffect, useRef, useState } from "react";

function ManualThrottledSlider() {
  const [value, setValue] = useState(50);
  const [throttledValue, setThrottledValue] = useState(50);
  const lastRun = useRef(Date.now());
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  useEffect(() => {
    const now = Date.now();
    const elapsed = now - lastRun.current;
    const delay = 200;

    if (elapsed >= delay) {
      setThrottledValue(value);
      lastRun.current = now;
    } else {
      timerRef.current = setTimeout(() => {
        setThrottledValue(value);
        lastRun.current = Date.now();
      }, delay - elapsed);
    }

    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, [value]);

  return (
    <div style={{ padding: 24 }}>
      <input
        type="range"
        min={0}
        max={100}
        value={value}
        onChange={(e) => setValue(Number(e.target.value))}
        style={{ width: 300 }}
      />
      <div style={{ marginTop: 12 }}>
        <p>原始值: {value}</p>
        <p>节流值: {throttledValue}</p>
      </div>
    </div>
  );
}

节流逻辑很容易写错。你需要追踪上次执行时间、处理末尾调用(保证最终值不丢失)、清理定时器。而且这只是针对单个值——每个需要节流的状态都得重复全部逻辑。

用 useThrottle

useThrottle 返回值的节流版本,在每个间隔内最多更新一次,同时确保最终值始终被捕获。

import { useThrottle } from "@reactuses/core";
import { useState } from "react";

function ThrottledSlider() {
  const [value, setValue] = useState(50);
  const throttledValue = useThrottle(value, 200);

  return (
    <div style={{ padding: 24 }}>
      <input
        type="range"
        min={0}
        max={100}
        value={value}
        onChange={(e) => setValue(Number(e.target.value))}
        style={{ width: 300 }}
      />
      <div style={{ marginTop: 12 }}>
        <p>原始值: {value}</p>
        <p>节流值: {throttledValue}</p>
      </div>
      <div
        style={{
          marginTop: 16,
          height: 20,
          width: `${throttledValue}%`,
          background: "#4f46e5",
          borderRadius: 4,
          transition: "width 0.1s",
        }}
      />
    </div>
  );
}

进度条以 200ms 的间隔平滑更新,而不是在滑块每移动一个像素时都抖动。一行代码搞定所有时序逻辑。

5. 循环选项:useCycleList

痛点

很多 UI 控件需要在一组固定选项中循环:主题切换(亮色 / 暗色 / 跟随系统)、排序方式(升序 / 降序 / 无序)、视图模式(网格 / 列表 / 紧凑)。常规做法是用一个状态变量加一个手动计算下一个值的函数。

手动实现

import { useState } from "react";

type ViewMode = "grid" | "list" | "compact";
const viewModes: ViewMode[] = ["grid", "list", "compact"];

function ManualViewToggle() {
  const [index, setIndex] = useState(0);
  const mode = viewModes[index];

  const next = () => setIndex((i) => (i + 1) % viewModes.length);
  const prev = () =>
    setIndex((i) => (i - 1 + viewModes.length) % viewModes.length);

  const icons: Record<ViewMode, string> = {
    grid: "▦",
    list: "☰",
    compact: "═",
  };

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button onClick={prev} style={{ fontSize: 20, cursor: "pointer" }}></button>
        <div
          style={{
            padding: "8px 16px",
            background: "#f1f5f9",
            borderRadius: 8,
            fontSize: 18,
            minWidth: 120,
            textAlign: "center",
          }}
        >
          {icons[mode]} {mode}
        </div>
        <button onClick={next} style={{ fontSize: 20, cursor: "pointer" }}></button>
      </div>
    </div>
  );
}

单个切换够简单了,但取模运算和独立的索引追踪是每个需要循环行为的地方都会出现的样板代码。它也不支持直接跳转到某个值或响应列表变化。

用 useCycleList

useCycleList 管理数组值的循环,提供 nextprev 以及直接跳转的 go 函数,连同当前值和索引。

import { useCycleList } from "@reactuses/core";

type ViewMode = "grid" | "list" | "compact";

function ViewToggle() {
  const [mode, { next, prev }] = useCycleList<ViewMode>(
    ["grid", "list", "compact"]
  );

  const icons: Record<ViewMode, string> = {
    grid: "▦",
    list: "☰",
    compact: "═",
  };

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button onClick={prev} style={{ fontSize: 20, cursor: "pointer" }}></button>
        <div
          style={{
            padding: "8px 16px",
            background: "#f1f5f9",
            borderRadius: 8,
            fontSize: 18,
            minWidth: 120,
            textAlign: "center",
          }}
        >
          {icons[mode]} {mode}
        </div>
        <button onClick={next} style={{ fontSize: 20, cursor: "pointer" }}></button>
      </div>
    </div>
  );
}

不用管索引,不用做取模运算。Hook 直接给你当前值和导航函数。用来做主题切换——点击在亮色、暗色、跟随系统之间循环——特别顺手。

6. 数值状态:useCounter

痛点

计数器无处不在——电商的数量选择器、分页控件、步骤指示器、缩放级别。每个都需要递增、递减、重置,通常还需要最小/最大值钳位。每次从头写这些很乏味。

手动实现

import { useCallback, useState } from "react";

function ManualQuantityPicker() {
  const [count, setCount] = useState(1);
  const min = 1;
  const max = 99;

  const increment = useCallback(
    () => setCount((c) => Math.min(c + 1, max)),
    [max]
  );
  const decrement = useCallback(
    () => setCount((c) => Math.max(c - 1, min)),
    [min]
  );
  const reset = useCallback(() => setCount(1), []);

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button
          onClick={decrement}
          disabled={count <= min}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count <= min ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count <= min ? "not-allowed" : "pointer",
          }}
        >
          -
        </button>
        <span style={{ fontSize: 24, fontWeight: 600, minWidth: 40, textAlign: "center" }}>
          {count}
        </span>
        <button
          onClick={increment}
          disabled={count >= max}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count >= max ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count >= max ? "not-allowed" : "pointer",
          }}
        >
          +
        </button>
        <button onClick={reset} style={{ marginLeft: 12, fontSize: 14, color: "#6b7280" }}>
          重置
        </button>
      </div>
    </div>
  );
}

钳位逻辑、禁用状态、记忆化回调——全是标准样板代码,应用里每个计数器都在重复。

用 useCounter

useCounter 开箱即用地提供 countincdecsetreset,并支持可选的最小/最大值边界。

import { useCounter } from "@reactuses/core";

function QuantityPicker() {
  const [count, { inc, dec, reset }] = useCounter(1, {
    min: 1,
    max: 99,
  });

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button
          onClick={() => dec()}
          disabled={count <= 1}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count <= 1 ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count <= 1 ? "not-allowed" : "pointer",
          }}
        >
          -
        </button>
        <span style={{ fontSize: 24, fontWeight: 600, minWidth: 40, textAlign: "center" }}>
          {count}
        </span>
        <button
          onClick={() => inc()}
          disabled={count >= 99}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count >= 99 ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count >= 99 ? "not-allowed" : "pointer",
          }}
        >
          +
        </button>
        <button onClick={reset} style={{ marginLeft: 12, fontSize: 14, color: "#6b7280" }}>
          重置
        </button>
      </div>
    </div>
  );
}

Hook 在内部处理钳位。你只需传一次 minmax,递增递减时永远不用担心越界。

7. 类组件风格 setState:useSetState

痛点

React 类组件的 setState 有一个很方便的特性:接受一个部分对象,然后合并到已有状态中。但 hooks 里的 useState 是整体替换。如果你的状态是一个多字段对象,每次更新都得展开:setState(prev => ({ ...prev, name: 'new' }))。对于字段很多的复杂表单或设置对象,这种写法既冗长又容易出错(忘了展开会无声地丢失字段)。

手动实现

import { useCallback, useState } from "react";

interface FormState {
  name: string;
  email: string;
  role: string;
  notifications: boolean;
}

function ManualSettingsForm() {
  const [state, setFullState] = useState<FormState>({
    name: "",
    email: "",
    role: "viewer",
    notifications: true,
  });

  // 每次更新都必须展开上一个状态
  const setState = useCallback(
    (patch: Partial<FormState>) =>
      setFullState((prev) => ({ ...prev, ...patch })),
    []
  );

  return (
    <form style={{ padding: 24, display: "flex", flexDirection: "column", gap: 12, maxWidth: 400 }}>
      <input
        value={state.name}
        onChange={(e) => setState({ name: e.target.value })}
        placeholder="姓名"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <input
        value={state.email}
        onChange={(e) => setState({ email: e.target.value })}
        placeholder="邮箱"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <select
        value={state.role}
        onChange={(e) => setState({ role: e.target.value })}
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      >
        <option value="viewer">查看者</option>
        <option value="editor">编辑者</option>
        <option value="admin">管理员</option>
      </select>
      <label style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <input
          type="checkbox"
          checked={state.notifications}
          onChange={(e) => setState({ notifications: e.target.checked })}
        />
        邮件通知
      </label>
      <pre style={{ background: "#f8fafc", padding: 12, borderRadius: 6, fontSize: 13 }}>
        {JSON.stringify(state, null, 2)}
      </pre>
    </form>
  );
}

你得自己创建合并用的 setState 包装器。如果团队里其他开发者忘了用这个包装器,直接拿部分对象调 setFullState,字段就会无声消失。

用 useSetState

useSetState 的行为和类组件的 setState 一样——传入部分对象,自动合并到已有状态中。

import { useSetState } from "@reactuses/core";

interface FormState {
  name: string;
  email: string;
  role: string;
  notifications: boolean;
}

function SettingsForm() {
  const [state, setState] = useSetState<FormState>({
    name: "",
    email: "",
    role: "viewer",
    notifications: true,
  });

  return (
    <form style={{ padding: 24, display: "flex", flexDirection: "column", gap: 12, maxWidth: 400 }}>
      <input
        value={state.name}
        onChange={(e) => setState({ name: e.target.value })}
        placeholder="姓名"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <input
        value={state.email}
        onChange={(e) => setState({ email: e.target.value })}
        placeholder="邮箱"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <select
        value={state.role}
        onChange={(e) => setState({ role: e.target.value })}
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      >
        <option value="viewer">查看者</option>
        <option value="editor">编辑者</option>
        <option value="admin">管理员</option>
      </select>
      <label style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <input
          type="checkbox"
          checked={state.notifications}
          onChange={(e) => setState({ notifications: e.target.checked })}
        />
        邮件通知
      </label>
      <pre style={{ background: "#f8fafc", padding: 12, borderRadius: 6, fontSize: 13 }}>
        {JSON.stringify(state, null, 2)}
      </pre>
    </form>
  );
}

Hook 返回的 setState 接受部分对象并自动合并。不需要包装函数,不存在意外替换整个状态的风险。

融会贯通:一个设置面板

这些 Hook 天然可组合。下面是一个综合运用全部七个 Hook 的设置面板:

import {
  useControlled,
  usePrevious,
  useDebounce,
  useThrottle,
  useCycleList,
  useCounter,
  useSetState,
} from "@reactuses/core";
import { useState } from "react";

// 一个受控/非受控搜索输入框
function SearchInput({
  value,
  defaultValue,
  onChange,
}: {
  value?: string;
  defaultValue?: string;
  onChange?: (v: string) => void;
}) {
  const [currentValue, setCurrentValue] = useControlled({
    value,
    defaultValue: defaultValue ?? "",
    onChange,
  });

  return (
    <input
      value={currentValue}
      onChange={(e) => setCurrentValue(e.target.value)}
      placeholder="搜索设置..."
      style={{
        padding: "8px 12px",
        border: "1px solid #d1d5db",
        borderRadius: 6,
        width: "100%",
        fontSize: 14,
      }}
    />
  );
}

function SettingsPanel() {
  // 带防抖的搜索
  const [searchQuery, setSearchQuery] = useState("");
  const debouncedSearch = useDebounce(searchQuery, 300);
  const prevSearch = usePrevious(debouncedSearch);

  // 主题循环切换
  const [theme, { next: nextTheme }] = useCycleList([
    "light",
    "dark",
    "system",
  ]);

  // 带计数器的字体大小
  const [fontSize, { inc: fontUp, dec: fontDown, reset: fontReset }] =
    useCounter(16, { min: 12, max: 24 });

  // 节流的实时预览
  const throttledFontSize = useThrottle(fontSize, 150);

  // 合并式表单状态
  const [settings, setSettings] = useSetState({
    username: "",
    email: "",
    notifications: true,
    language: "zh",
  });

  const themeColors: Record<string, { bg: string; text: string }> = {
    light: { bg: "#ffffff", text: "#1e293b" },
    dark: { bg: "#1e293b", text: "#f8fafc" },
    system: { bg: "#f1f5f9", text: "#334155" },
  };

  const allSettings = [
    "username",
    "email",
    "notifications",
    "language",
    "theme",
    "font size",
  ];

  const filtered = debouncedSearch
    ? allSettings.filter((s) =>
        s.toLowerCase().includes(debouncedSearch.toLowerCase())
      )
    : allSettings;

  return (
    <div
      style={{
        padding: 24,
        maxWidth: 500,
        margin: "0 auto",
        background: themeColors[theme].bg,
        color: themeColors[theme].text,
        borderRadius: 12,
        transition: "all 0.3s",
      }}
    >
      <h2 style={{ marginTop: 0 }}>设置</h2>

      {/* 受控搜索输入 */}
      <SearchInput value={searchQuery} onChange={setSearchQuery} />

      {prevSearch && prevSearch !== debouncedSearch && (
        <p style={{ fontSize: 12, opacity: 0.6, margin: "4px 0" }}>
          从 "{prevSearch}" 变为 "{debouncedSearch}"
        </p>
      )}

      <p style={{ fontSize: 12, opacity: 0.6 }}>
        显示 {filtered.length} / {allSettings.length} 项设置
      </p>

      {/* 主题切换 */}
      {filtered.includes("theme") && (
        <div
          style={{
            display: "flex",
            justifyContent: "space-between",
            alignItems: "center",
            padding: "12px 0",
            borderBottom: "1px solid rgba(128,128,128,0.2)",
          }}
        >
          <span>主题</span>
          <button
            onClick={nextTheme}
            style={{
              padding: "6px 16px",
              borderRadius: 6,
              border: "1px solid rgba(128,128,128,0.3)",
              background: "transparent",
              color: "inherit",
              cursor: "pointer",
            }}
          >
            {theme}
          </button>
        </div>
      )}

      {/* 字体大小计数器 */}
      {filtered.includes("font size") && (
        <div
          style={{
            display: "flex",
            justifyContent: "space-between",
            alignItems: "center",
            padding: "12px 0",
            borderBottom: "1px solid rgba(128,128,128,0.2)",
          }}
        >
          <span>字体大小</span>
          <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
            <button onClick={() => fontDown()}>-</button>
            <span style={{ fontWeight: 600 }}>{fontSize}px</span>
            <button onClick={() => fontUp()}>+</button>
            <button
              onClick={fontReset}
              style={{ fontSize: 12, color: "inherit", opacity: 0.6 }}
            >
              重置
            </button>
          </div>
        </div>
      )}

      {/* 带节流字体大小的实时预览 */}
      <p
        style={{
          fontSize: throttledFontSize,
          padding: "12px 0",
          transition: "font-size 0.15s",
          borderBottom: "1px solid rgba(128,128,128,0.2)",
        }}
      >
        以 {throttledFontSize}px 预览文本
      </p>

      {/* 合并状态的表单字段 */}
      {filtered.includes("username") && (
        <div style={{ padding: "12px 0" }}>
          <label style={{ display: "block", fontSize: 13, marginBottom: 4 }}>
            用户名
          </label>
          <input
            value={settings.username}
            onChange={(e) => setSettings({ username: e.target.value })}
            style={{
              padding: "6px 10px",
              border: "1px solid rgba(128,128,128,0.3)",
              borderRadius: 4,
              width: "100%",
              background: "transparent",
              color: "inherit",
            }}
          />
        </div>
      )}

      {filtered.includes("notifications") && (
        <label
          style={{
            display: "flex",
            alignItems: "center",
            gap: 8,
            padding: "12px 0",
          }}
        >
          <input
            type="checkbox"
            checked={settings.notifications}
            onChange={(e) =>
              setSettings({ notifications: e.target.checked })
            }
          />
          开启通知
        </label>
      )}
    </div>
  );
}

七个 Hook,零冲突。useControlled 驱动搜索输入框,使其在别处也能以非受控方式使用。useDebounce 避免每次按键都执行过滤。usePrevious 展示搜索词的变化历史。useCycleList 处理主题切换。useCounter 管理带边界的字体大小。useThrottle 平滑实时预览的更新。useSetState 将表单字段保持在一个合并式状态对象中。每个 Hook 负责一个关注点,组合时不需要任何额外的胶水代码。

安装

npm i @reactuses/core

相关 Hook

  • useControlled -- 构建同时支持受控和非受控的组件
  • usePrevious -- 获取上一次渲染的值
  • useDebounce -- 对任意值按指定延迟进行防抖
  • useThrottle -- 对任意值按间隔进行节流
  • useCycleList -- 在数组值之间用 next/prev 循环切换
  • useCounter -- 带 inc/dec/reset 和可选 min/max 的数值状态
  • useSetState -- 像类组件 setState 一样合并部分对象到状态中
  • useBoolean -- 带 toggle、setTrue、setFalse 的布尔状态
  • useToggle -- 在两个值之间切换
  • useLocalStorage -- 将状态持久化到 localStorage 并自动序列化

ReactUse 提供了 100 多个 React Hook。浏览全部 →

CSS 模块化演进之路:从隔离到动态的样式革命

CSS 模块化演进之路:从隔离到动态的样式革命

引言:当 CSS 遇到组件化时代

在现代前端开发的浪潮中,组件化已成为构建复杂应用的标准范式。然而,当我们专注于 JavaScript 组件的逻辑封装时,一个长期被忽视的问题逐渐浮出水面:CSS 如何才能真正实现模块化?

想象这样一个场景:你和团队成员同时开发一个大型 React 应用。你精心编写了一个 .button 样式,满怀信心地提交代码。第二天,测试报告指出按钮样式异常——原来另一位开发者也在他的组件中定义了同名的 .button 类,后加载的样式覆盖了你的设计。这不是假设,而是每个前端开发者都经历过的样式冲突噩梦

本文将通过三个真实项目,深入剖析 CSS 模块化的三种主流解决方案,揭示它们如何从不同维度解决样式隔离问题,以及各自的技术哲学和适用场景。


第一章:CSS Modules —— 静态隔离的艺术

1.1 问题的根源

让我们从 css-demo 项目的一个细节说起。项目中存在两个按钮组件:Button.jsxAnotherButton.jsx。如果没有模块化,它们的 CSS 可能长这样:

/* 传统 CSS */
.button {
    background-color: blue;
    color: white;
}

当两个组件都使用 .button 类名时,后定义的样式会覆盖前者。这就是 CSS 全局命名空间带来的命名污染问题。

1.2 CSS Modules 的解决方案

Button.jsx 中,我们看到这样的代码:

import styles from './Button.module.css';

export default function Button() {
    return (
    <>
        <h1 className={styles.txt}>你好,世界!</h1>
        <button className={styles.button}>按钮</button>
    </>
)
}

关键在于 import styles from './Button.module.css'。这不是普通的 CSS 文件导入,而是 CSS Modules 的语法约定。注意文件名的 .module.css 后缀——这是告诉构建工具(如 Vite、Webpack)将其作为 CSS Module 处理。

对应的 Button.module.css 文件:

.button {
    background-color: blue;
    color: white;
    padding: 10px 20px;
}
.txt {
    color: pink;
    font-size: 20px;
    font-weight: bold;
}

1.3 编译时的魔法

当代码被编译时,CSS Modules 会执行以下转换:

  1. 生成唯一类名:将 .button 转换为类似 .Button_button__a7b3c 的哈希类名
  2. 导出映射对象styles 对象变成 { button: 'Button_button__a7b3c', txt: 'Button_txt__d8e9f' }
  3. 自动作用域隔离:每个组件的样式只影响自身

在控制台日志中可以看到(如代码中的 console.log('111styles:',styles)),styles 是一个 JavaScript 对象,其 key 是 CSS 类名,value 是哈希后的唯一类名。

1.4 多人协作的保障

AnotherButton.jsx 展示了这种方案的核心价值:

import styles from './AnotherButton.module.css';

export default function AnotherButton() {
    return <button className={styles.button}>another 按钮</button>
}

尽管两个组件都使用了 .button 类名,但编译后它们会变成完全不同的哈希值:

  • Button.buttonButton_button__a7b3c
  • AnotherButton.buttonAnotherButton_button__x9y8z

正如 App.jsx 中的注释所说:"多人协作的时候就会有这个 bug,我们怎么做能不影响别人,也不受别人的影响"。CSS Modules 通过编译时的静态隔离,完美解决了这个问题。

1.5 技术特点

优势:

  • 零运行时开销:样式在构建时处理,运行时只是普通的 class 应用
  • 工具链友好:与现有 CSS 语法完全兼容,支持所有 CSS 特性
  • 性能优化:自动提取唯一 CSS 文件,支持代码分割
  • 类型安全:可与 TypeScript 结合,提供类名提示

局限:

  • 动态性不足:难以根据 props 动态调整样式
  • 全局样式依赖:仍需通过 :global 处理全局样式
  • 配置依赖:需要构建工具支持(现代工具已默认支持)

第二章:Styled Components —— 动态样式的哲学

2.1 当样式需要"思考"

如果说 CSS Modules 解决了静态隔离问题,那么 styled-component-demo 项目则展示了更进一步的思考:样式能否根据组件的状态动态变化?

App.jsx 中的代码:

import styled from 'styled-components';

const Button = styled.button`
  background: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  border: 1px solid blue;
  padding: 8px 16px;
  border-radius: 4px;
`

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  )
}

2.2 CSS-in-JS 的革命

这里没有单独的 CSS 文件,样式直接写在 JavaScript 中,通过 styled-components 库的 styled.button 方法创建。这种范式被称为 CSS-in-JS

关键创新点:

  1. 样式即组件Button 既是 React 组件,也是样式定义
  2. 动态插值:通过 ${props => ...} 语法,样式可以响应 props 变化
  3. 自动作用域:每个 styled 组件生成唯一类名,天然隔离

2.3 动态样式的力量

两个按钮实例展示了动态能力:

  • <Button>primary 为 false,背景白色,文字蓝色
  • <Button primary>primary 为 true,背景蓝色,文字白色

同样的组件,不同的视觉表现。这在 CSS Modules 中需要额外的状态类名管理,而 styled-components 将其内建为语言特性。

2.4 运行时机制

styled-components 在运行时执行以下步骤:

  1. 解析模板字符串:提取 CSS 规则和动态插值函数
  2. 生成唯一类名:类似 CSS Modules,但发生在运行时
  3. 注入 style 标签:动态创建 <style> 标签注入页面
  4. 响应式更新:当 props 变化时,重新计算样式

查看 package.json,可以看到依赖:

"styled-components": "^6.3.12"

这是整个方案的核心库。

2.5 技术特点

优势:

  • 极致动态性:样式完全由 JavaScript 控制,可实现复杂逻辑
  • 组件封装完整:样式与组件逻辑在同一文件,便于维护
  • 主题支持:内置 Theme Provider,轻松实现主题切换
  • 自动 vendor prefix:自动添加浏览器前缀

局限:

  • 运行时开销:需要在浏览器中解析和注入样式
  • SSR 复杂度:服务端渲染需要额外配置提取样式
  • 学习曲线:需要掌握模板字符串和 styled API
  • 调试难度:生成的类名难以直接对应源码

第三章:Vue Scoped CSS —— 框架集成的优雅

2.6 Vue 的单文件组件哲学

vue-css-demo 项目展示了 Vue 框架的 CSS 模块化方案。看 App.vue

<template>
  <div>
    <h1 class="txt">Hello world in app</h1>
    <HelloWorld />
    <h2 class="txt2">222</h2>
  </div>
</template>

<style scoped>
.txt {
  color: red;
}
.txt2 {
  color: green;
}
</style>

关键在于 <style scoped> 属性。这是 Vue 单文件组件(SFC)的内置特性。

2.7 编译时作用域隔离

Vue 的 scoped 样式机制与 CSS Modules 类似,但更简洁:

  1. 自动添加属性选择器:编译时为每个元素添加 data-v-hash 属性
  2. 样式规则重写:将 .txt { } 转换为 .txt[data-v-a7b3c] { }
  3. 作用域限制:样式只影响当前组件的元素

例如,编译后的 HTML 可能变成:

<h1 class="txt" data-v-a7b3c>Hello world in app</h1>

样式规则变为:

.txt[data-v-a7b3c] {
  color: red;
}

2.8 组件间隔离

HelloWorld.vue 展示了子组件的独立作用域:

<template>
  <div>
    <h1 class="txt">你好,世界!</h1>
    <h2 class="txt2">222</h2>
  </div>
</template>

<style scoped>
.txt {
  color: blue;
}
.txt2 {
  color: pink;
}
</style>

尽管父子组件都使用了 .txt 类名,但由于 scoped 机制,它们互不影响:

  • 父组件的 .txt 只影响父组件模板
  • 子组件的 .txt 只影响子组件模板

2.9 框架级集成

Vue 方案的核心优势是框架原生支持。查看 vite.config.js

import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
})

@vitejs/plugin-vue 插件自动处理 .vue 文件的 scoped 样式,无需额外配置。

2.10 技术特点

优势:

  • 零配置:框架内置支持,开箱即用
  • 语法简洁:只需添加 scoped 属性
  • 性能优秀:编译时处理,无运行时开销
  • 局部覆盖:可通过 :deep() 选择器穿透作用域

局限:

  • 框架绑定:仅适用于 Vue 生态
  • 动态性有限:不如 styled-components 灵活
  • 穿透复杂度:深度选择器需要特殊语法

第四章:技术选型与最佳实践

4.1 三种方案的对比

维度 CSS Modules Styled Components Vue Scoped
作用域 编译时哈希 运行时生成 编译时属性
动态性 ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐
性能 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐
学习曲线
框架依赖 React Vue
文件大小 中(需 runtime)

4.2 选型建议

选择 CSS Modules 当:

  • 项目基于 React 且需要轻量级方案
  • 样式相对静态,不需要复杂动态逻辑
  • 团队熟悉传统 CSS,希望平滑过渡
  • 对性能有极致要求

选择 Styled Components 当:

  • 需要高度动态的样式(如主题、状态驱动)
  • 追求样式与逻辑的完全统一
  • 团队 JavaScript 能力强于 CSS
  • 接受一定的运行时开销换取开发体验

选择 Vue Scoped 当:

  • 项目使用 Vue 框架
  • 希望零配置解决样式隔离
  • 需要平衡简洁性和功能性

4.3 混合策略

在实际大型项目中,常常采用混合策略:

// 基础样式用 CSS Modules
import styles from './Button.module.css';

// 动态变体用 styled-components
const VariantButton = styled(Button)`
  background: ${props => props.variant};
`;

或者在 Vue 项目中:

<style scoped>
/* 组件私有样式 */
</style>

<style>
/* 全局共享样式 */
</style>

第五章:CSS 模块化的未来趋势

5.1 CSS 原生作用域

CSS 规范正在引入原生的作用域机制:

@scope (.component) {
  .button { /* 只影响 .component 内的 .button */ }
}

这将使浏览器原生支持样式隔离,减少对构建工具的依赖。

5.2 CSS Houdini

CSS Houdini 允许开发者通过 JavaScript 扩展 CSS 引擎,为样式系统带来编程能力,可能模糊 CSS Modules 和 CSS-in-JS 的边界。

5.3 零运行时 CSS-in-JS

新一代库如 Vanilla Extract、Linaria 尝试结合两者优势:

  • 开发体验:CSS-in-JS 语法
  • 运行时性能:编译为纯 CSS 文件
// Vanilla Extract 示例
import { style } from '@vanilla-extract/css';

export const button = style({
  background: 'blue',
  color: 'white',
});

结语:没有银弹,只有权衡

回顾三个项目,我们看到 CSS 模块化没有唯一正确答案:

技术选型的本质是权衡。理解每种方案的设计哲学、技术实现和适用场景,比盲目追随潮流更重要。

正如组件化思想的核心——关注点分离,CSS 模块化的终极目标不是技术本身,而是让开发者能够专注于创造优秀的用户体验,而不必担心样式冲突的困扰。

当你在下一个项目中面对 CSS 架构选择时,希望这篇文章能为你提供清晰的思考框架。毕竟,最好的技术方案,永远是那个最适合你团队和项目的方案。


搞懂 BFC:一篇文章彻底拿下两列与三列自适应布局

前言

在前端开发中,我们经常会遇到这样的场景:左侧固定宽度做导航,右侧自适应填满剩余空间做内容区;或者左右两侧固定,中间自适应的经典三栏布局。

很多人会直接使用 Flexbox 或 Grid 来解决,这当然没问题。但如果面试官追问:“如果不使用 Flex 和 Grid,仅用传统 CSS,你如何实现?背后的原理是什么?

这时候,就需要请出 CSS 布局中的“定海神针”—— BFC(Block Formatting Context,块级格式化上下文)  了。

一、 到底什么是 BFC?

image.png 官方定义很绕,用人话来说:BFC 就像是一个封闭的、独立的“结界”。
在这个结界里面的元素怎么折腾(比如浮动、塌陷),都不会影响到外面的元素;同理,外面的元素也进不来干扰里面。

它有三个维度的核心规则:怎么创建它、它内部怎么排版、它跟外部怎么相处。

二、 怎么召唤出 BFC?(创建规则)

记住一个前提:普通的块级元素(比如最常见的 div)默认不是 BFC,必须通过特定条件触发。

以下是触发 BFC 的常见方式:

  1. 根元素<html> 标签本身就是最大的 BFC。
  2. 浮动元素:给元素加上 float: left / right(只要不是 none)。
  3. 绝对/固定定位position: absolute / fixed
  4. 行内块元素display: inline-block
  5. 表格相关display: table-cell 等(现在极少用来做布局)。
  6. 🔥 溢出非可见(最常用)overflow: hidden / auto / scroll。(注意:默认的 overflow: visible 不会触发)。
  7. 现代布局display: flex / grid / inline-flex / inline-grid(这会使其成为 Flex/Grid 容器,内部自然形成独立的 BFC 机制)。

三、 BFC 内部布局规则

当一个元素变成了 BFC,它内部就会遵守以下规矩:

  • 垂直排列:BFC 内的块级盒子只能从上到下垂直排布,绝对不可能横向并排。

  • Margin 折叠现象:在同一个 BFC 中,相邻的两个块级元素的垂直外边距会发生“折叠”。比如上面的 margin-bottom: 10px,下面的 margin-top: 20px,两者之间的真实距离是取最大值 20px,而不是相加的 30px

    • 破解之法:把这两个元素放在两个不同的 BFC 结界里,就不会折叠了。
  • 触边对齐(⚠️易错点) :BFC 内部的每个盒子的左外边缘,都会死死贴住这个 BFC 容器的左边缘。

    • 注意:这里说的是“盒子的边缘”贴住容器,但盒子里的“文字内容”是活体,看到浮动元素会主动避让,形成“文字环绕浮动元素”的效果。这也是为什么普通 div 会被浮动覆盖,而文字不会。

四、 BFC 的“对外防御机制”(外部交互规则)

这是 BFC 最核心的价值所在,它拥有两大“超能力”:

  1. 包含内部浮动(解决高度塌陷) :如果 BFC 里面有子元素浮动了,按理说父元素会失去高度(高度塌陷)。但只要父元素触发了 BFC,它就会强行计算并把所有浮动子元素包裹在内
  2. 排除外部浮动(防重叠) :如果一个 BFC 旁边有一个浮动的元素,这个 BFC 绝对不会去跟那个浮动元素重叠,它会自动收缩自己的宽度,给浮动元素让出位置。

正是这第二个超能力,造就了经典的浮动布局!

五、 实战演练:用 BFC 搞定两列与三列布局

1. 经典两列式布局(左固定,右自适应)

需求:左侧导航栏宽 200px,右侧内容区撑满剩余宽度。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>BFC实现两列布局</title>
</head>
<style>
    * {
        margin: 0;
        padding: 0;
    }
    .left {
        width: 200px;
        height: 500px;
        float: left; /* 1. 左侧脱标浮动 */
        background-color: #ccc;
    }
    .right {
        height: 500px;
        overflow: hidden; /* 2. 右侧触发 BFC */
        background-color: #333;
        color: #fff;
        /* 加点内边距让文字不贴边,更好看 */
        padding: 20px; 
        /* 因为加了padding,需要用box-sizing保证不撑大盒子 */
        box-sizing: border-box; 
    }
</style>
<body>
    <!-- 注意:HTML的class属性里不需要加 . 号 -->
    <div class="left">左侧固定区域 (200px)</div>
    
    <!-- 这里使用上面定义好的 .right 类名 -->
    <div class="right">右侧自适应区域 (无论浏览器多宽,我都会填满剩下的空间,且不会覆盖左侧)</div>
</body>
</html>

image.png

原理解析:
左侧 float 起来了,右侧本是一个普通的块级 div,按理说它会无视浮动,占满整行跑到左侧下面去。但我们给右侧加了 overflow: hidden 触发了 BFC。根据 BFC  【排除外部浮动】  的规则,右侧这个“结界”拒绝与左侧的浮动区域重叠,于是它老老实实退缩到了浮动元素的右边,剩余多少宽度它就占多少,完美实现自适应!

2. 经典三列式布局(左右固定,中间自适应)

需求:左侧 200px,右侧 200px,中间自适应。

HTML 结构:

代码生成完成

HTML代码

CSS 代码:

.left {
  width: 200px; height: 500px;
  float: left;
  background: #ccc;
}
.right {
  width: 200px; height: 500px;
  float: right;
  background: #aaa;
}
.center {
  height: 500px;
  overflow: hidden; /* 触发 BFC */
  background: #333;
  color: #fff;
}

image.png原理解析:
左右两侧分别向左向右浮动,脱离了文档流。中间的 div 加上 overflow: hidden 触发 BFC 后,它既要躲避左边的浮动,又要躲避右边的浮动。两头都被挤压,最终只能乖乖夹在中间,剩下的空间全归它所有。

总结

在 Flexbox 和 Grid 横行的今天,我们写业务可能很少再手写 float + BFC 布局了。但是,理解 BFC 绝不是为了写古董代码

当你在页面中遇到莫名的“高度塌陷”、遇到“Margin 合并不生效”、遇到“浮动元素覆盖了后续内容”时,如果你脑子里能瞬间闪过 BFC 的那几条规则,你能在一分钟内定位并解决问题。

BFC 不是过时的技术,而是透视 CSS 底层渲染逻辑的一把钥匙。  掌握了它,你的 CSS 功力才算真正跨入了进阶的大门。

泛型:像“填空”一样写类型,让你的代码从“复制粘贴”中解放

你是不是遇到过这种场景:写了一个函数,处理数字的版本写一遍,处理字符串的版本再写一遍,处理数组的又写一遍……最后代码里全是长得差不多的“双胞胎”。今天我们来学TypeScript的泛型——一个能让你写一次、处处用的“类型模板”。从此告别复制粘贴,做个体面的程序员。

前言

想象一下,你开了一家“万能快递公司”。客户要寄书,你准备书盒;要寄衣服,你准备衣服盒;要寄手机,你准备手机盒……每种物品都要单独设计盒子,累不累?

更好的做法:设计一种可调节大小的盒子,客户说寄什么,你就把盒子调成对应大小。这个“可调节的盒子”,就是泛型

TypeScript的泛型让你在定义函数、类、接口时,先“留个空”,等用的时候再往里填具体类型。这样既保证了类型安全,又避免了重复代码。

一、泛型长啥样?一个简单的例子

先看一个没有泛型的“悲惨世界”:

// 只能处理数字
function identityNumber(arg: number): number {
  return arg;
}
// 只能处理字符串
function identityString(arg: string): string {
  return arg;
}
// 要处理布尔值?再写一个……

用泛型,只需要一个:

function identity<T>(arg: T): T {
  return arg;
}

这里的<T>就像个“占位符”,你调用时可以指定具体类型:

let output1 = identity<string>('hello'); // 类型是 string
let output2 = identity<number>(123);     // 类型是 number

但TS很聪明,能自动推断,所以通常可以省略:

let output = identity('hello'); // TS推断出T为string

二、泛型不只是“传进去又返回来”

你可以约束参数的类型关系。比如,你想让函数接收一个数组,并返回数组的第一个元素:

function getFirst<T>(arr: T[]): T {
  return arr[0];
}

const firstNumber = getFirst([1, 2, 3]);   // 类型 number
const firstString = getFirst(['a', 'b']); // 类型 string

T帮我们保持了“数组元素类型”和“返回值类型”的一致性。

三、泛型约束:给“占位符”画个圈

有时候,你不能让T为所欲为。比如你想写一个函数,打印参数的length属性:

function logLength<T>(arg: T): T {
  console.log(arg.length); // ❌ 报错:T可能没有length
  return arg;
}

因为T可能是numberboolean,它们没有.length。这时候需要约束——告诉TS:T必须是有length属性的类型。

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(arg: T): T {
  console.log(arg.length); // ✅ 安全
  return arg;
}

logLength('hello');    // 字符串有length
logLength([1, 2, 3]);  // 数组有length
logLength(123);        // ❌ 数字没有length,报错

extends关键字在这里不是继承,而是“约束为某个类型的子集”。

四、泛型接口:把接口变成“模具”

接口也可以泛型化,比如定义一个通用的响应结构:

interface ApiResponse<T> {
  code: number;
  message: string;
  data: T;
}

// 使用
type User = { name: string; age: number };
const response: ApiResponse<User> = {
  code: 200,
  message: 'success',
  data: { name: '张三', age: 18 }
};

这样,你就能用一个接口描述所有API返回格式,只需替换T

五、泛型类:像造模具一样造类

类同样可以泛型:

class Queue<T> {
  private data: T[] = [];
  push(item: T) {
    this.data.push(item);
  }
  pop(): T | undefined {
    return this.data.shift();
  }
}

const numberQueue = new Queue<number>();
numberQueue.push(123);
numberQueue.push('456'); // ❌ 报错,只能放数字

六、泛型工具类型:TS内置的“变形金刚”

TS提供了一些内置的泛型工具,能帮你快速转换类型。

1. Partial<T>:把属性都变成可选

interface User {
  name: string;
  age: number;
}
type PartialUser = Partial<User>; // { name?: string; age?: number; }

2. Readonly<T>:把所有属性变成只读

type ReadonlyUser = Readonly<User>; // { readonly name: string; readonly age: number; }

3. Pick<T, K>:从T中挑选部分属性

type UserName = Pick<User, 'name'>; // { name: string; }

4. Omit<T, K>:从T中排除部分属性

type UserWithoutAge = Omit<User, 'age'>; // { name: string; }

还有Record<K, T>ExcludeExtract等,遇到具体场景时再查文档即可。

七、联合类型与交叉类型:不是泛型,但常一起用

联合类型(|):这个或那个

let value: string | number;
value = 'hello'; // OK
value = 123;     // OK
value = true;    // ❌

联合类型适合“不确定具体是哪个,但知道是有限的几种”。

交叉类型(&):既要又要

interface Name { name: string; }
interface Age { age: number; }
type Person = Name & Age; // 同时有name和age属性

const p: Person = { name: '张三', age: 18 };

交叉类型常用来合并多个类型。

八、类型保护:让TS相信你

当你使用联合类型时,TS会限制你只能调用所有类型共有的方法。要调用特定类型的方法,需要类型保护

function printLength(value: string | number) {
  // console.log(value.length); // ❌ 报错,number没有length
  if (typeof value === 'string') {
    console.log(value.length); // ✅ 这里TS知道value是string
  } else {
    console.log(value.toFixed(2));
  }
}

除了typeof,还有instanceofin关键字、自定义类型守卫。

九、实战:用泛型写一个“万能”的缓存函数

interface Cache<T> {
  get(key: string): T | undefined;
  set(key: string, value: T): void;
}

function createCache<T>(): Cache<T> {
  const store: Record<string, T> = {};
  return {
    get(key) { return store[key]; },
    set(key, value) { store[key] = value; }
  };
}

const stringCache = createCache<string>();
stringCache.set('name', '张三');
const name = stringCache.get('name'); // 类型是 string | undefined

const numberCache = createCache<number>();
numberCache.set('age', 18);

看,一套代码同时服务了字符串缓存和数字缓存,类型还完全安全。

十、总结:泛型是“类型编程”的起点

  • 泛型就是“类型的参数”,让组件(函数、类、接口)能适应多种类型,同时保留类型关系。
  • 约束extends限定泛型的范围。
  • 泛型接口/类让数据结构通用。
  • 内置工具类型(Partial、Pick等)简化常见类型转换。
  • 联合类型表示“或”,交叉类型表示“且”,类型保护用来区分联合中的具体类型。

掌握泛型,你就能写出更抽象、更复用、更安全的代码。明天我们将继续TypeScript的高级主题——装饰器,看看这个类似Java注解的特性,如何在TS里玩出花样。

如果你觉得今天的“万能模具”讲得通透,点个赞让更多人看到。明天我们聊聊装饰器——那个在Angular和NestJS里无处不在的黑魔法。我们明天见!

前端开发效率翻倍:15个超级实用的工具函数,直接复制进项目(建议收藏)

大家好,今天给大家整理了一套前端日常开发高频用到的工具函数

没有复杂算法,也没有花里胡哨的封装,全是业务里真正常用的:时间格式化、手机号脱敏、防抖节流、深拷贝、URL参数解析……全部即插即用,复制到项目里就能跑,非常适合放进自己的 utils 工具库。


1. 时间格式化(最常用)

把时间戳 / Date 对象转成 YYYY-MM-DD HH:mm:ss

function formatDate(date, fmt = 'YYYY-MM-DD HH:mm:ss') {
  if (!date) return ''
  date = date instanceof Date ? date : new Date(date)

  const o = {
    'M+': date.getMonth() + 1,
    'D+': date.getDate(),
    'H+': date.getHours(),
    'm+': date.getMinutes(),
    's+': date.getSeconds()
  }

  if (/(Y+)/.test(fmt)) {
    fmt = fmt.replace(RegExp.$1, date.getFullYear().toString().slice(4 - RegExp.$1.length))
  }

  for (const k in o) {
    if (new RegExp(`(${k})`).test(fmt)) {
      fmt = fmt.replace(
        RegExp.$1,
        RegExp.$1.length === 1 ? o[k] : o[k].toString().padStart(2, '0')
      )
    }
  }
  return fmt
}

// 使用
formatDate(new Date()) // 2026-04-09 15:30:20

2. 防抖(输入搜索专用)

function debounce(fn, delay = 300) {
  let timer = null
  return function (...args) {
    clearTimeout(timer)
    timer = setTimeout(() => fn.apply(this, args), delay)
  }
}

3. 节流(滚动/点击防重复)

function throttle(fn, interval = 500) {
  let last = 0
  return function (...args) {
    const now = Date.now()
    if (now - last >= interval) {
      last = now
      fn.apply(this, args)
    }
  }
}

4. 深拷贝(处理对象/数组)

function deepClone(obj, hash = new WeakMap()) {
  if (obj === null || typeof obj !== 'object') return obj
  if (hash.has(obj)) return hash.get(obj)

  const clone = Array.isArray(obj) ? [] : {}
  hash.set(obj, clone)

  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      clone[key] = deepClone(obj[key], hash)
    }
  }
  return clone
}

5. 获取 URL 参数

function getQueryParams(url = location.href) {
  const params = {}
  new URL(url).searchParams.forEach((v, k) => (params[k] = v))
  return params
}

// 使用
getQueryParams('https://xxx.com?id=1&name=test') // { id: '1', name: 'test' }

6. 手机号脱敏

function maskPhone(phone) {
  if (!phone || phone.length !== 11) return phone
  return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}

// 13812345678 → 138****5678

7. 姓名脱敏

function maskName(name) {
  if (!name) return ''
  if (name.length === 1) return name
  return name[0] + '*'.repeat(name.length - 1)
}

// 张三 → 张*
// 张三丰 → 张**

8. 数字千分位格式化

function formatMoney(num) {
  if (isNaN(num)) return '0'
  return Number(num).toLocaleString()
}

// 1234567 → 1,234,567

9. 存储操作(localStorage 封装)

const storage = {
  set(key, val) {
    localStorage.setItem(key, JSON.stringify(val))
  },
  get(key) {
    const val = localStorage.getItem(key)
    if (!val) return null
    try {
      return JSON.parse(val)
    } catch {
      return val
    }
  },
  remove(key) {
    localStorage.removeItem(key)
  },
  clear() {
    localStorage.clear()
  }
}

10. 判断数据类型

function getType(val) {
  return Object.prototype.toString.call(val).slice(8, -1).toLowerCase()
}

// getType([]) → 'array'
// getType({}) → 'object'
// getType(null) → 'null'

11. 数组去重

function uniqueArr(arr) {
  return [...new Set(arr)]
}

12. 数组扁平化

function flatten(arr) {
  return arr.flat(Infinity)
}

13. 生成随机字符串(ID)

function randomStr(len = 8) {
  const str = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678'
  let res = ''
  for (let i = 0; i < len; i++) {
    res += str[Math.floor(Math.random() * str.length)]
  }
  return res
}

14. 防抖立即执行版(提交按钮专用)

function debounceImmediate(fn, delay = 500) {
  let timer = null
  return function (...args) {
    const first = !timer
    clearTimeout(timer)
    timer = setTimeout(() => (timer = null), delay)
    if (first) fn.apply(this, args)
  }
}

15. 滚动到顶部(平滑)

function scrollToTop() {
  window.scrollTo({ top: 0, behavior: 'smooth' })
}

最后

这 15 个工具函数基本覆盖了80% 前端业务场景,建议直接新建一个 utils.js 全部存起来,以后开发至少快一倍。


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

前端面试通关指南:30个高频手写JS算法,吃透就能拿高薪(附完整代码)

聊前端面试,算法永远是绕不开的坎。很多小伙伴项目经验很丰富,框架用得溜,但一上面试场就被手写算法题卡住,直接导致面试失利。

我整理了一份30个高频手写JS算法清单,覆盖ES6语法、数组操作、链表、二叉树、动态规划等核心考点,从简单到进阶,每道题都附完整可复制代码+考点解析,跟着敲一遍,面试时直接手到擒来!

一、基础语法与数组变换(必拿分,入门级)

这类题考察基础语法功底,难度低、频率高,面试时先搞定这类题,稳拿基础分,给面试官留好第一印象。

1. 深度克隆(Deep Clone)

考察点:引用类型传址、循环引用、基本类型与引用类型区别

function deepClone(obj, map = new WeakMap()) {
  // 基本类型直接返回(null也是基本类型范畴)
  if (obj === null || typeof obj !== 'object') return obj;
  // 处理循环引用(避免无限递归)
  if (map.has(obj)) return map.get(obj);
  
  // 区分数组和对象,创建对应克隆容器
  const clone = Array.isArray(obj) ? [] : {};
  map.set(obj, clone); // 存入map,标记已克隆
  
  // 遍历对象/数组,递归克隆每一项
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) { // 只克隆自身属性,不克隆原型链属性
      clone[key] = deepClone(obj[key], map);
    }
  }
  return clone;
}

// 测试
const obj = { a: 1, b: { c: 2 }, d: [3, 4] };
const cloneObj = deepClone(obj);
obj.b.c = 100;
console.log(cloneObj.b.c); // 2(克隆后互不影响)

2. 数组扁平化(Flatten)

考察点:递归、数组方法(forEach、concat)、ES6新特性

// 解法1:递归(兼容性好,易理解)
function flatten(arr) {
  let result = [];
  arr.forEach(item => {
    // 若当前项是数组,递归扁平化,否则直接加入结果
    result = result.concat(Array.isArray(item) ? flatten(item) : item);
  });
  return result;
}

// 解法2:ES6 flat方法(简洁,实际开发常用)
const flatten = arr => arr.flat(Infinity); // Infinity表示无限层级扁平化

// 解法3:reduce实现(更简洁,面试加分)
const flatten = arr => arr.reduce((prev, curr) => {
  return prev.concat(Array.isArray(curr) ? flatten(curr) : curr);
}, []);

// 测试
console.log(flatten([1, [2, [3, 4], 5]])); // [1,2,3,4,5]

3. 防抖(Debounce)

考察点:高频事件控制、定时器、this指向

// 核心:频繁触发时,只在最后一次触发后延迟执行
function debounce(fn, delay = 500) {
  let timer = null; // 定时器标识,闭包保存
  return function(...args) {
    clearTimeout(timer); // 每次触发,清除上一次定时器
    // 重新设置定时器,延迟执行目标函数
    timer = setTimeout(() => {
      fn.apply(this, args); // 绑定this和参数,适配实际场景
    }, delay);
  };
}

// 用法(搜索框输入示例)
const handleSearch = debounce((val) => {
  console.log('请求搜索接口:', val);
}, 500);

4. 节流(Throttle)

考察点:高频事件控制、时间戳/定时器、性能优化

// 解法1:时间戳版(触发时立即执行,之后固定时间内不执行)
function throttle(fn, interval = 1000) {
  let lastTime = 0; // 上一次执行时间
  return function(...args) {
    const nowTime = Date.now(); // 当前时间
    // 若当前时间 - 上一次执行时间 > 间隔,执行函数
    if (nowTime - lastTime >= interval) {
      fn.apply(this, args);
      lastTime = nowTime; // 更新上一次执行时间
    }
  };
}

// 解法2:定时器版(触发后延迟执行,固定时间内只执行一次)
function throttle2(fn, interval = 1000) {
  let timer = null;
  return function(...args) {
    if (!timer) { // 若定时器不存在,执行函数
      timer = setTimeout(() => {
        fn.apply(this, args);
        timer = null; // 执行后清空定时器,允许下次执行
      }, interval);
    }
  };
}

// 用法(滚动加载示例)
window.addEventListener('scroll', throttle(() => {
  console.log('滚动加载更多');
}, 1000));

5. 数组去重(Unique)

考察点:数组方法、Set数据结构、兼容性

// 解法1:Set实现(最简洁,ES6+常用)
const unique = arr => [...new Set(arr)];

// 解法2:indexOf实现(兼容性好,适合旧项目)
function unique(arr) {
  const result = [];
  arr.forEach(item => {
    // 若结果数组中没有当前项,加入结果
    if (result.indexOf(item) === -1) {
      result.push(item);
    }
  });
  return result;
}

// 解法3:filter+indexOf(简洁,面试常用)
const unique = arr => arr.filter((item, index) => {
  // 只保留第一次出现的元素(indexOf返回第一个匹配的索引)
  return arr.indexOf(item) === index;
});

// 测试
console.log(unique([1, 2, 2, 3, 3, 3])); // [1,2,3]

6. 数组排序(冒泡排序)

考察点:排序原理、循环逻辑、基础算法思维

// 冒泡排序:相邻元素对比,大的往后移,每次循环确定一个最大值
function bubbleSort(arr) {
  const len = arr.length;
  // 外层循环:控制排序轮次(共len-1轮)
  for (let i = 0; i < len - 1; i++) {
    let flag = false; // 优化:标记是否发生交换,若无则排序完成
    // 内层循环:对比相邻元素,交换位置
    for (let j = 0; j < len - 1 - i; j++) {
      if (arr[j] > arr[j + 1]) {
        // 交换两个元素
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
        flag = true;
      }
    }
    if (!flag) break; // 无交换,直接退出循环
  }
  return arr;
}

// 测试
console.log(bubbleSort([3, 1, 4, 1, 5, 9])); // [1,1,3,4,5,9]

7. 数组排序(快速排序)

考察点:分治思想、递归、时间复杂度优化(面试高频)

// 快速排序:分治思想,选一个基准值,将数组分成两部分,递归排序
function quickSort(arr) {
  // 终止条件:数组长度<=1,直接返回
  if (arr.length <= 1) return arr;
  // 选基准值(中间项,避免极端情况)
  const pivot = arr[Math.floor(arr.length / 2)];
  // 分治:小于基准值的放左边,等于的放中间,大于的放右边
  const left = arr.filter(x => x < pivot);
  const middle = arr.filter(x => x === pivot);
  const right = arr.filter(x => x > pivot);
  // 递归排序左右两部分,拼接结果
  return [...quickSort(left), ...middle, ...quickSort(right)];
}

// 测试
console.log(quickSort([3, 1, 4, 1, 5, 9])); // [1,1,3,4,5,9]

8. 实现数组forEach方法

考察点:数组方法原理、this绑定、回调函数

// 模拟数组forEach,接收回调函数和this指向
Array.prototype.myForEach = function(callback, thisArg) {
  // 边界判断:回调必须是函数
  if (typeof callback !== 'function') {
    throw new TypeError('callback must be a function');
  }
  // 遍历当前数组(this指向调用myForEach的数组)
  for (let i = 0; i < this.length; i++) {
    // 执行回调,传入三个参数:当前项、索引、原数组,绑定thisArg
    callback.call(thisArg, this[i], i, this);
  }
};

// 用法
[1, 2, 3].myForEach((item, index) => {
  console.log(item, index); // 1 0 | 2 1 | 3 2
});

9. 实现数组map方法

考察点:数组方法原理、返回值处理、回调函数

// 模拟数组map,返回新数组,新数组元素是回调函数的返回值
Array.prototype.myMap = function(callback, thisArg) {
  if (typeof callback !== 'function') {
    throw new TypeError('callback must be a function');
  }
  const result = []; // 存储结果的新数组
  for (let i = 0; i < this.length; i++) {
    // 执行回调,将返回值加入结果数组
    result.push(callback.call(thisArg, this[i], i, this));
  }
  return result;
};

// 用法
const newArr = [1, 2, 3].myMap(item => item * 2);
console.log(newArr); // [2,4,6]

10. 实现数组filter方法

考察点:数组方法原理、条件判断、返回值处理

// 模拟数组filter,返回满足条件的元素组成的新数组
Array.prototype.myFilter = function(callback, thisArg) {
  if (typeof callback !== 'function') {
    throw new TypeError('callback must be a function');
  }
  const result = [];
  for (let i = 0; i < this.length; i++) {
    // 回调返回true,将当前项加入结果数组
    if (callback.call(thisArg, this[i], i, this)) {
      result.push(this[i]);
    }
  }
  return result;
};

// 用法
const evenArr = [1, 2, 3, 4].myFilter(item => item % 2 === 0);
console.log(evenArr); // [2,4]

二、原型与作用域(进阶必问,中层前端考点)

这类题考察对JS底层原理的理解,是区分初级和中级前端的关键,面试时高频出现,必须吃透。

11. 实现new关键字

考察点:原型链、构造函数、this绑定、返回值判断

// myNew:模拟new关键字的作用
function myNew(fn, ...args) {
  // 1. 创建一个空对象,让其原型指向构造函数的prototype
  const obj = Object.create(fn.prototype);
  // 2. 执行构造函数,将this绑定到新创建的对象上
  const result = fn.apply(obj, args);
  // 3. 判断构造函数的返回值:若为对象(非null),则返回该对象;否则返回新创建的obj
  return result instanceof Object ? result : obj;
}

// 测试
function Person(name, age) {
  this.name = name;
  this.age = age;
}
const person = myNew(Person, '张三', 25);
console.log(person.name); // 张三
console.log(person instanceof Person); // true

12. 手写Promise(简易版,支持then链式调用)

考察点:异步编程、状态机、回调队列、链式调用

class MyPromise {
  constructor(exector) {
    // 初始化状态:pending(等待)、fulfilled(成功)、rejected(失败)
    this.status = 'pending';
    this.value = null; // 成功时的返回值
    this.reason = null; // 失败时的原因
    // 存储成功/失败的回调队列(支持多个then绑定)
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    // 成功回调:改变状态,保存值,执行所有成功回调
    const resolve = (value) => {
      if (this.status === 'pending') {
        this.status = 'fulfilled';
        this.value = value;
        // 执行所有缓存的成功回调
        this.onFulfilledCallbacks.forEach(fn => fn());
      }
    };

    // 失败回调:改变状态,保存原因,执行所有失败回调
    const reject = (reason) => {
      if (this.status === 'pending') {
        this.status = 'rejected';
        this.reason = reason;
        // 执行所有缓存的失败回调
        this.onRejectedCallbacks.forEach(fn => fn());
      }
    };

    // 执行 executor,捕获异常,异常时调用reject
    try {
      exector(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }

  // then方法:支持链式调用,返回新的Promise
  then(onFulfilled, onRejected) {
    // 兼容:若then未传回调,默认透传值/原因
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason; };

    // 返回新Promise,实现链式调用
    return new MyPromise((resolve, reject) => {
      // 状态为成功时,执行成功回调
      if (this.status === 'fulfilled') {
        try {
          const result = onFulfilled(this.value);
          // 回调返回值,传递给下一个then的成功回调
          resolve(result);
        } catch (err) {
          // 回调执行失败,传递给下一个then的失败回调
          reject(err);
        }
      }

      // 状态为失败时,执行失败回调
      if (this.status === 'rejected') {
        try {
          const result = onRejected(this.reason);
          resolve(result);
        } catch (err) {
          reject(err);
        }
      }

      // 状态为等待时,缓存回调
      if (this.status === 'pending') {
        this.onFulfilledCallbacks.push(() => {
          try {
            const result = onFulfilled(this.value);
            resolve(result);
          } catch (err) {
            reject(err);
          }
        });
        this.onRejectedCallbacks.push(() => {
          try {
            const result = onRejected(this.reason);
            resolve(result);
          } catch (err) {
            reject(err);
          }
        });
      }
    });
  }
}

// 测试
new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve('成功');
  }, 1000);
}).then(res => {
  console.log(res); // 成功
  return '下一个then';
}).then(res => {
  console.log(res); // 下一个then
});

13. 实现Promise.all方法

考察点:Promise并发控制、数组遍历、状态判断

// Promise.all:接收一个Promise数组,所有Promise成功才成功,有一个失败则失败
Promise.myAll = function(promises) {
  return new Promise((resolve, reject) => {
    // 边界判断:若传入的不是数组,直接reject
    if (!Array.isArray(promises)) {
      return reject(new TypeError('arguments must be an array'));
    }

    const result = []; // 存储所有Promise的成功结果
    let count = 0; // 记录已完成的Promise数量

    // 若数组为空,直接resolve空数组
    if (promises.length === 0) return resolve(result);

    // 遍历每个Promise
    promises.forEach((promise, index) => {
      // 兼容非Promise值(直接视为成功)
      Promise.resolve(promise).then(res => {
        result[index] = res; // 按原顺序存储结果
        count++;
        // 所有Promise都完成,resolve结果数组
        if (count === promises.length) {
          resolve(result);
        }
      }).catch(err => {
        // 有一个失败,直接reject该错误
        reject(err);
      });
    });
  });
};

// 测试
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.resolve(3);
Promise.myAll([p1, p2, p3]).then(res => {
  console.log(res); // [1,2,3]
});

14. 实现Promise.race方法

考察点:Promise并发控制、第一个完成的状态优先

// Promise.race:接收一个Promise数组,第一个完成(成功/失败)的结果作为最终结果
Promise.myRace = function(promises) {
  return new Promise((resolve, reject) => {
    if (!Array.isArray(promises)) {
      return reject(new TypeError('arguments must be an array'));
    }

    // 遍历每个Promise,第一个完成的直接改变状态
    promises.forEach(promise => {
      Promise.resolve(promise).then(res => {
        resolve(res); // 第一个成功,直接resolve
      }).catch(err => {
        reject(err); // 第一个失败,直接reject
      });
    });
  });
};

// 测试
const p1 = new Promise((resolve) => setTimeout(() => resolve(1), 1000));
const p2 = new Promise((resolve, reject) => setTimeout(() => reject('失败'), 500));
Promise.myRace([p1, p2]).catch(err => {
  console.log(err); // 失败(p2先完成,且失败)
});

15. 函数柯里化(Currying)

考察点:闭包、参数复用、函数式编程

// 柯里化:将多参数函数,转化为单参数函数的链式调用
function curry(fn) {
  // 闭包保存已传入的参数
  return function curried(...args) {
    // 若传入的参数数量 >= 原函数的参数数量,执行原函数
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      // 否则,返回一个新函数,接收剩余参数,递归调用curried
      return function(...args2) {
        return curried.apply(this, [...args, ...args2]);
      };
    }
  };
}

// 用法
const add = (a, b, c) => a + b + c; // 原函数,3个参数
const curriedAdd = curry(add);

console.log(curriedAdd(1)(2)(3)); // 6(链式调用)
console.log(curriedAdd(1, 2)(3)); // 6(支持部分参数)
console.log(curriedAdd(1, 2, 3)); // 6(支持完整参数)

16. 实现call方法

考察点:this绑定、函数执行、参数传递

// 模拟Function.prototype.call:改变函数this指向,立即执行函数
Function.prototype.myCall = function(context, ...args) {
  // 边界判断:若context为null/undefined,指向window(浏览器环境)
  context = context || window;
  // 给context添加一个临时属性,指向当前函数(this就是调用myCall的函数)
  const fnKey = Symbol('tempFn'); // 用Symbol避免属性冲突
  context[fnKey] = this;
  // 执行函数,传入参数,获取返回值
  const result = context[fnKey](...args);
  // 删除临时属性,避免污染context
  delete context[fnKey];
  // 返回函数执行结果
  return result;
};

// 测试
const obj = { name: '张三' };
function sayHello(age) {
  console.log(`我是${this.name},年龄${age}`);
}
sayHello.myCall(obj, 25); // 我是张三,年龄25

17. 实现apply方法

考察点:this绑定、函数执行、参数传递(与call的区别:参数是数组)

// 模拟Function.prototype.apply:改变this指向,参数以数组形式传递,立即执行
Function.prototype.myApply = function(context, args = []) {
  context = context || window;
  const fnKey = Symbol('tempFn');
  context[fnKey] = this;
  // 执行函数,args是数组,用扩展运算符展开
  const result = context[fnKey](...args);
  delete context[fnKey];
  return result;
};

// 测试
const obj = { name: '李四' };
function sayHello(age, gender) {
  console.log(`我是${this.name},年龄${age},性别${gender}`);
}
sayHello.myApply(obj, [28, '男']); // 我是李四,年龄28,性别男

18. 实现bind方法

考察点:this绑定、闭包、函数柯里化、构造函数兼容

// 模拟Function.prototype.bind:改变this指向,返回一个新函数,不立即执行
Function.prototype.myBind = function(context, ...args1) {
  const fn = this; // 保存当前函数(this就是调用myBind的函数)
  // 返回新函数
  const boundFn = function(...args2) {
    // 兼容构造函数:若新函数被new调用,this指向实例,否则指向context
    const isNew = this instanceof boundFn;
    const targetContext = isNew ? this : context;
    // 合并参数,执行原函数
    return fn.apply(targetContext, [...args1, ...args2]);
  };
  // 继承原函数的原型,确保new调用时,实例能访问原函数原型上的属性
  boundFn.prototype = Object.create(fn.prototype);
  return boundFn;
};

// 测试1:普通调用
const obj = { name: '王五' };
function sayHello(age) {
  console.log(`我是${this.name},年龄${age}`);
}
const boundSay = sayHello.myBind(obj, 30);
boundSay(); // 我是王五,年龄30

// 测试2:new调用(构造函数兼容)
function Person(name, age) {
  this.name = name;
  this.age = age;
}
const BoundPerson = Person.myBind(null, '赵六');
const person = new BoundPerson(35);
console.log(person.name); // 赵六
console.log(person.age); // 35

19. 实现防抖+立即执行版

考察点:防抖原理、立即执行逻辑、定时器控制

// 立即执行版防抖:第一次触发立即执行,之后频繁触发不执行,延迟后可再次触发
function debounceImmediate(fn, delay = 500) {
  let timer = null;
  return function(...args) {
    // 若定时器存在,清除定时器(取消延迟执行)
    if (timer) clearTimeout(timer);
    // 判断是否是第一次触发(timer为null)
    const isImmediate = !timer;
    if (isImmediate) {
      fn.apply(this, args); // 立即执行
    }
    // 重置定时器,延迟后清空timer,允许下次立即执行
    timer = setTimeout(() => {
      timer = null;
    }, delay);
  };
}

// 用法(按钮提交示例,避免重复提交,第一次点击立即执行)
const handleSubmit = debounceImmediate(() => {
  console.log('提交表单');
}, 1000);

20. 实现节流+立即执行/延迟执行可选版

考察点:节流原理、参数配置、灵活性优化

// 可选版节流:可配置立即执行(leading)和延迟执行(trailing)
function throttleOpt(fn, interval = 1000, options = { leading: true, trailing: false }) {
  const { leading, trailing } = options;
  let lastTime = 0;
  let timer = null;

  return function(...args) {
    const nowTime = Date.now();
    // 若不允许立即执行,且是第一次触发,重置lastTime
    if (!leading && !lastTime) {
      lastTime = nowTime;
    }

    // 计算剩余时间
    const remainingTime = interval - (nowTime - lastTime);
    // 剩余时间<=0,执行函数
    if (remainingTime <= 0) {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      fn.apply(this, args);
      lastTime = nowTime;
    } else if (trailing && !timer) {
      // 允许延迟执行,且无定时器,设置延迟执行
      timer = setTimeout(() => {
        timer = null;
        lastTime = Date.now();
        fn.apply(this, args);
      }, remainingTime);
    }
  };
}

// 用法
// 立即执行,不延迟执行(默认)
const throttle1 = throttleOpt(() => console.log('立即执行'), 1000);
// 不立即执行,延迟执行
const throttle2 = throttleOpt(() => console.log('延迟执行'), 1000, { leading: false, trailing: true });

三、数据结构与算法(大厂高频,高级前端考点)

这类题考察数据结构基础和算法思维,是大厂面试的重点,也是拉开薪资差距的关键,建议重点练习。

21. 两数之和(Two Sum)

考察点:哈希表、时间复杂度优化(从O(n²)优化到O(n))

// 题目:给定一个整数数组和一个目标值,找出数组中和为目标值的两个整数的索引
function twoSum(nums, target) {
  const map = new Map(); // 用Map存储{数值: 索引},快速查找
  for (let i = 0; i < nums.length; i++) {
    const complement = target - nums[i]; // 互补值
    // 若互补值存在于Map中,返回两个索引
    if (map.has(complement)) {
      return [map.get(complement), i];
    }
    // 否则,将当前数值和索引存入Map
    map.set(nums[i], i);
  }
  return []; // 无匹配项,返回空数组
}

// 测试
console.log(twoSum([2, 7, 11, 15], 9)); // [0,1]

22. LRU缓存机制

考察点:哈希表+双向链表、缓存淘汰策略(Vue3响应式缓存底层类似)

// LRU:最近最少使用,超出容量时,删除最久未使用的元素
class LRUCache {
  constructor(capacity) {
    this.capacity = capacity; // 缓存容量
    this.cache = new Map(); // Map特性:插入顺序就是访问顺序,可快速获取最久未使用的元素
  }

  // 获取元素:访问后,将元素移到最近使用的位置
  get(key) {
    if (!this.cache.has(key)) return -1; // 无该元素,返回-1
    const value = this.cache.get(key);
    this.cache.delete(key); // 删除旧位置
    this.cache.set(key, value); // 重新插入,移到末尾(最近使用)
    return value;
  }

  // 存入元素:超出容量时,删除最久未使用的元素(Map的第一个键)
  put(key, value) {
    // 若元素已存在,先删除(避免覆盖后,位置不变)
    if (this.cache.has(key)) {
      this.cache.delete(key);
    }
    // 超出容量,删除最久未使用的元素
    if (this.cache.size >= this.capacity) {
      const oldestKey = this.cache.keys().next().value; // Map的第一个键是最久未使用的
      this.cache.delete(oldestKey);
    }
    // 存入新元素,移到最近使用的位置
    this.cache.set(key, value);
  }
}

// 测试
const lru = new LRUCache(2);
lru.put(1, 1);
lru.put(2, 2);
console.log(lru.get(1)); // 1(访问后,1变为最近使用)
lru.put(3, 3); // 超出容量,删除最久未使用的2
console.log(lru.get(2)); // -1(已被删除)

23. 发布-订阅模式(EventBus)

考察点:设计模式、组件通信、回调函数管理(Vue EventBus底层原理)

// 发布-订阅模式:实现组件间通信,解耦
class EventEmitter {
  constructor() {
    this.events = new Map(); // 存储事件:{事件名: [回调函数数组]}
  }

  // 订阅事件:绑定回调函数
  on(event, callback) {
    if (!this.events.has(event)) {
      this.events.set(event, []); // 若事件不存在,初始化回调数组
    }
    this.events.get(event).push(callback); // 加入回调数组
  }

  // 发布事件:触发该事件的所有回调函数
  emit(event, ...args) {
    const callbacks = this.events.get(event);
    if (callbacks) {
      // 执行所有回调,传入参数
      callbacks.forEach(cb => cb(...args));
    }
  }

  // 取消订阅:移除指定事件的指定回调
  off(event, callback) {
    const callbacks = this.events.get(event);
    if (callbacks) {
      // 过滤掉要取消的回调,保留其他回调
      this.events.set(event, callbacks.filter(cb => cb !== callback));
      // 若回调数组为空,删除该事件
      if (this.events.get(event).length === 0) {
        this.events.delete(event);
      }
    }
  }

  // 一次性订阅:触发一次后,自动取消订阅
  once(event, callback) {
    // 包装回调,执行后取消订阅
    const wrapCallback = (...args) => {
      callback(...args);
      this.off(event, wrapCallback);
    };
    this.on(event, wrapCallback);
  }
}

// 测试
const bus = new EventEmitter();
const callback = (msg) => console.log('收到消息:', msg);

bus.on('message', callback);
bus.emit('message', 'Hello World'); // 收到消息:Hello World

bus.off('message', callback);
bus.emit('message', 'Hello Again'); // 无输出(已取消订阅)

bus.once('onceEvent', () => console.log('一次性事件'));
bus.emit('onceEvent'); // 一次性事件
bus.emit('onceEvent'); // 无输出(已自动取消)

24. 实现链表反转(单链表)

考察点:链表数据结构、指针操作、递归/迭代思维

// 1. 定义单链表节点
class ListNode {
  constructor(val = 0, next = null) {
    this.val = val;
    this.next = next;
  }
}

// 解法1:迭代法(推荐,空间复杂度O(1))
function reverseList(head) {
  let prev = null; // 前驱节点
  let curr = head; // 当前节点
  while (curr !== null) {
    const next = curr.next; // 保存下一个节点
    curr.next = prev; // 反转当前节点的指针
    prev = curr; // 前驱节点后移
    curr = next; // 当前节点后移
  }
  return prev; // 反转后,prev是新的头节点
}

// 解法2:递归法(易理解,空间复杂度O(n))
function reverseListRecursive(head) {
  // 终止条件:空节点或只有一个节点,直接返回
  if (head === null || head.next === null) return head;
  // 递归反转后续节点
  const newHead = reverseListRecursive(head.next);
  // 反转当前节点和下一个节点的指针
  head.next.next = head;
  head.next = null; // 避免循环
  return newHead;
}

// 测试
const head = new ListNode(1, new ListNode(2, new ListNode(3)));
const reversedHead = reverseList(head);
// 遍历反转后的链表:3 -> 2 -> 1
let curr = reversedHead;
while (curr) {
  console.log(curr.val); // 3 2 1
  curr = curr.next;
}

25. 判断回文链表

考察点:链表操作、双指针、回文判断

// 题目:判断一个单链表是否是回文(正读和反读一样)
// 步骤:1. 找到链表中点;2. 反转后半部分;3. 对比前半部分和反转后的后半部分
function isPalindrome(head) {
  if (head === null || head.next === null) return true; // 空链表或单个节点,是回文

  // 1. 找到链表中点(慢指针走1步,快指针走2步,快指针到末尾时,慢指针到中点)
  let slow = head;
  let fast = head;
  while (fast.next !== null && fast.next.next !== null) {
    slow = slow.next;
    fast = fast.next.next;
  }

  // 2. 反转后半部分链表(从slow.next开始)
  let prev = null;
  let curr = slow.next;
  while (curr !== null) {
    const next = curr.next;
    curr.next = prev;
    prev = curr;
    curr = next;
  }
  slow.next = prev; // 反转后的后半部分链表,头节点是prev

  // 3. 对比前半部分和反转后的后半部分
  let left = head;
  let right = prev;
  while (right !== null) {
    if (left.val !== right.val) return false; // 不相等,不是回文
    left = left.next;
    right = right.next;
  }
  return true; // 全部相等,是回文
}

// 测试
const head1 = new ListNode(1, new ListNode(2, new ListNode(1)));
console.log(isPalindrome(head1)); // true

const head2 = new ListNode(1, new ListNode(2, new ListNode(3)));
console.log(isPalindrome(head2)); // false

26. 二叉树的前序遍历(递归+迭代)

考察点:二叉树数据结构、遍历算法、递归/迭代思维

// 1. 定义二叉树节点
class TreeNode {
  constructor(val = 0, left = null, right = null) {
    this.val = val;
    this.left = left;
    this.right = right;
  }
}

// 解法1:递归法(简洁,易理解)
function preorderTraversalRecursive(root, result = []) {
  if (root === null) return result;
  result.push(root.val); // 根节点
  preorderTraversalRecursive(root.left, result); // 左子树
  preorderTraversalRecursive(root.right, result); // 右子树
  return result;
}

// 解法2:迭代法(面试常考,避免递归栈溢出)
function preorderTraversalIterative(root) {
  const result = [];
  if (root === null) return result;
  const stack = [root]; // 用栈存储节点
  while (stack.length > 0) {
    const node = stack.pop(); // 弹出栈顶节点(根节点)
    result.push(node.val);
    // 注意:栈是先进后出,所以先压右子树,再压左子树
    if (node.right !== null) stack.push(node.right);
    if (node.left !== null) stack.push(node.left);
  }
  return result;
}

// 测试
const root = new TreeNode(1, null, new TreeNode(2, new TreeNode(3)));
console.log(preorderTraversalRecursive(root)); // [1,2,3]
console.log(preorderTraversalIterative(root)); // [1,2,3]

27. 二叉树的中序遍历(递归+迭代)

考察点:二叉树遍历、栈的应用

// 解法1:递归法
function inorderTraversalRecursive(root, result = []) {
  if (root === null) return result;
  inorderTraversalRecursive(root.left, result); // 左子树
  result.push(root.val); // 根节点
  inorderTraversalRecursive(root.right, result); // 右子树
  return result;
}

// 解法2:迭代法
function inorderTraversalIterative(root) {
  const result = [];
  const stack = [];
  let curr = root;
  while (curr !== null || stack.length > 0) {
    // 先遍历左子树,所有左节点入栈
    while (curr !== null) {
      stack.push(curr);
      curr = curr.left;
    }
    // 弹出栈顶节点(左子树最底层节点),加入结果
    curr = stack.pop();
    result.push(curr.val);
    // 遍历右子树
    curr = curr.right;
  }
  return result;
}

// 测试
const root = new TreeNode(1, null, new TreeNode(2, new TreeNode(3)));
console.log(inorderTraversalRecursive(root)); // [1,3,2]
console.log(inorderTraversalIterative(root)); // [1,3,2]

28. 斐波那契数列(递归+迭代+优化)

考察点:递归、动态规划、时间/空间复杂度优化

// 题目:求第n个斐波那契数(F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2))

// 解法1:递归法(简单但效率低,时间复杂度O(2ⁿ),有重复计算)
function fibRecursive(n) {
  if (n <= 1) return n;
  return fibRecursive(n - 1) + fibRecursive(n - 2);
}

// 解法2:迭代法(推荐,时间复杂度O(n),空间复杂度O(1))
function fibIterative(n) {
  if (n <= 1) return n;
  let prevPrev = 0; // F(n-2)
  let prev = 1; // F(n-1)
  let curr = 0;
  for (let i = 2; i <= n; i++) {
    curr = prevPrev + prev; // F(n) = F(n-2) + F(n-1)
    prevPrev = prev;
    prev = curr;
  }
  return curr;
}

// 解法3:动态规划(空间复杂度O(n),适合需要保存所有斐波那契数的场景)
function fibDP(n) {
  if (n <= 1) return n;
  const dp = new Array(n + 1);
  dp[0] = 0;
  dp[1] = 1;
  for (let i = 2; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[n];
}

// 测试
console.log(fibIterative(10)); // 55

29. 最长公共前缀

考察点:字符串操作、遍历对比、边界处理

// 题目:编写一个函数,找出字符串数组中的最长公共前缀
function longestCommonPrefix(strs) {
  // 边界判断:数组为空,返回空字符串
  if (strs.length === 0) return '';
  // 以第一个字符串为基准,逐个字符对比
  let prefix = strs[0];
  for (let i = 1; i < strs.length; i++) {
    // 循环对比当前字符串和基准字符串,直到找到公共前缀
    while (strs[i].indexOf(prefix) !== 0) {
      // 若不匹配,缩短基准字符串(去掉最后一个字符)
      prefix = prefix.slice(0, prefix.length - 1);
      // 若基准字符串为空,说明没有公共前缀,直接返回
      if (prefix === '') return '';
    }
  }
  return prefix;
}

// 测试
console.log(longestCommonPrefix(["flower","flow","flight"])); // "fl"
console.log(longestCommonPrefix(["dog","racecar","car"])); // ""

30. 验证回文串

考察点:字符串处理、正则表达式、双指针

// 题目:验证一个字符串是否是回文串(只考虑字母和数字,忽略大小写)
function isPalindromeStr(s) {
  // 1. 过滤无效字符(只保留字母和数字),并转为小写
  const validStr = s.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
  // 2. 双指针:左指针从开头,右指针从末尾,逐步对比
  let left = 0;
  let right = validStr.length - 1;
  while (left < right) {
    if (validStr[left] !== validStr[right]) {
      return false; // 不相等,不是回文串
    }
    left++;
    right--;
  }
  return true; // 全部相等,是回文串
}

// 测试
console.log(isPalindromeStr("A man, a plan, a canal: Panama")); // true
console.log(isPalindromeStr("race a car")); // false

写在最后

这30个手写JS算法,覆盖了前端面试从基础到进阶的所有高频考点——基础语法、数组操作、原型作用域、数据结构、算法思维,每道题都能直接复制到编辑器调试,吃透这30道题,面试时再遇到手写算法题,就能从容应对。

很多前端同学觉得算法难,其实是没找对方法:不用刷上千道题,重点吃透这些高频题,搞懂每道题的原理和考点,比盲目刷题更有效。


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

前端性能优化实战:从3秒到1秒,我只做了这5件事(全网通用)

做前端的都知道,页面加载速度就是生命线

一个项目做完,代码写得再漂亮、框架选得再先进,只要打开慢个1-2秒,用户流失率直接翻倍。最近我接手了一个老项目,首屏加载要 3.5秒,通过一系列针对性优化,最终压到了 0.8秒

今天把这套通用型性能优化方案分享给大家,不限Vue、React、小程序,只要是前端项目,复制粘贴就能提升加载速度,收藏这一篇,面试、工作都能用!


一、现状复盘:为什么你的页面那么卡?

先别急着写代码,先搞清楚瓶颈在哪里。建议在开发环境打开 Chrome 开发者工具 -> Lighthouse 跑一次测评。

通常前端加载慢,无非逃不过这3点:

  1. 体积过大:打包后的JS/CSS体积太大,网络传输慢;
  2. 请求过多:首屏加载了无关的接口、图片,造成网络拥堵;
  3. 渲染阻塞:JS没加载完,页面就是一片空白,用户体验极差。

下面的5步优化,就是针对这三大痛点,由浅入深,解决80%的性能问题。

二、核心干货:5个必做优化步骤(直接复制)

1. 路由懒加载:只加载当前需要的代码

这是性价比最高的优化!默认情况下,Webpack会把所有路由打包成一个巨大的 app.js。首屏不管去哪个页面,都要把所有代码下载下来。

解决思路: 按路由拆分,只加载当前页面的代码。

Vue3 / Vite 写法

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    name: 'Home',
    // 直接引入,首屏会加载
    component: () => import('@/views/Home.vue') 
  },
  {
    path: '/about',
    name: 'About',
    // 关键:使用箭头函数+import,实现路由懒加载
    // 访问该路由时才会加载对应的Chunk文件
    component: () => import('@/views/About.vue') 
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

React / React Router 写法

// 同样适用React,只需在路由配置处改一下
const About = React.lazy(() => import('@/views/About.vue')); 
// 配合Suspense显示加载中
import { Suspense } from 'react';

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  );
}

2. 图片优化:去重+压缩,体积减半

图片通常是项目体积最大的资源。不要直接把UI给的原图丢上去。

3招搞定图片优化:

  1. 使用现代格式:把PNG/JPG转为 WebPAVIF,体积可缩小50%以上,兼容性极好。
  2. 压缩图片:使用 TinyPNG 或 Webpack 插件(如image-webpack-loader)自动压缩。
  3. 懒加载(Lazy Loading):给图片加上 loading="lazy" 属性,滚动到可视区域再加载,首屏请求瞬间减少。

原生写法(所有框架通用)

<!-- 只需添加这个属性,自动实现懒加载 -->
<img src="image.webp" loading="lazy" alt="优化后" />

Vue/React 中使用

在你的UI库(Element/Ant Design)中使用图片组件时,直接添加属性即可:

<el-image 
  src="image.webp" 
  loading="lazy" 
  fallback="fallback.png" <!-- 降级处理 -->
/>

3. 移除console和注释:清掉“垃圾代码”

打包发布时,千万别把 console.logdebugger 和大量注释打包进去。这些不仅增加体积,还会暴露前端代码,存在安全风险。

解决方案: 在Vite/Webpack配置中一键清除。

Vite 配置(vue.config.js 或 vite.config.js)

// vite.config.js
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    // 生产环境清除console
    minify: 'ter', // 使用terser进行压缩
    terserOptions: {
      compress: {
        drop_console: true, // 移除所有console
        drop_debugger: true // 移除debugger
      }
    }
  }
})

4. 资源压缩:Gzip / Brotli 终极武器

这一步是服务器端的,但只要是前端开发,必须跟后端同学沟通开启。

开启Gzip或Brotli压缩后,服务器会对传输的JS、CSS、HTML文件进行压缩,传输体积能减少60%-80%

如何配置:

  • Nginx:在配置文件中开启 gzip on;
  • Node.js (Express):使用 compression 中间件
  • 云服务:在阿里云/腾讯云CDN控制台直接开启Brotli压缩

效果对比: 原来100KB的JS文件,压缩后只剩20-30KB,加载速度快得惊人!

5. 核心JS降级与polyfill:告别老旧浏览器拖累

现在的ES6+语法很强大,但如果不转译,老旧浏览器(如IE11,甚至旧版Chrome)无法识别,会被迫重新加载大量polyfill补丁。

解决方案: 使用Babel或ESBuild进行转译。

Babel 配置(.babelrc)

{
  "presets": [
    ["@babel/preset-env", {
      "useBuiltIns": "usage", // 按需引入polyfill
      "corejs": 3 // 指定core-js版本
    }]
  ]
}

作用:自动识别你代码中用到的ES6+语法,只引入必要的补丁,大幅减少打包体积。


三、避坑指南:这2件事千万别做

  1. 不要过度优化:比如把一个小工具库手动从项目中移除,得不偿失。优先优化首屏体积和网络请求,这才是最直观的体验提升。
  2. 不要忽略首屏空白:即使加载快了,如果页面是白屏直到JS加载完才显示,用户体验依然不好。可以在 index.html 中添加简单的骨架屏(Skeleton Screen)。

四、写在最后

性能优化不是一蹴而就的,它是一个持续迭代的过程。今天分享的这5个方法,是前端项目上线前的必做项

你会发现,很多时候不需要引入复杂的库,也不需要重写整个项目,只需要对现有配置做几处微调,性能就能有质的飞跃。


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

JS炼化:手写一下promise——用一份外卖,看懂状态机+两个回调篓子

用一份外卖,看懂状态机+两个回调篓子

不少初学者看到完整版Promise手写源码就犯难,繁杂的边界处理和进阶优化让人望而生畏。其实抛开这些锦上添花的拓展逻辑,Promise的核心本质特别简单:一个状态机 + 两个回调篓子,所有功能都由此衍生。手写Promise从来不是盲目造轮子,核心目的就是拆开原生API的黑盒,告别只会调用不懂原理的陌生感,彻底吃透异步运行的底层逻辑。

大家或许对源码逻辑感到晦涩,但一定熟悉点外卖的日常。接下来我就用点外卖的生活化比喻,带你轻松弄懂状态机和回调篓子的核心运作逻辑。

先看逻辑思路

你点了一份外卖 = 发起一个异步任务

1. 状态机 = 外卖订单状态
  •  pending :商家正在做饭(任务进行中)
  •  fulfilled :外卖送到你手上(成功)
  •  rejected :商家没货/取消订单(失败)

状态机干了啥?

  • 告诉你现在能不能吃
  • 保证只会送一次,不会反复送
  • 饭做好了就一直是做好的状态,不会变回“正在做”
  • 结果会永久保存,你什么时候拿都有

(状态机 = 给异步任务定规矩: 只能走一次,走到哪就是哪,结果永久留着。)

2.回调篓子 = 你给外卖员留的“送达通知方式”

你还没拿到外卖时( pending ),你跟外卖员说:

  • 送到了给我打电话
  • 送不成给我发短信

这些“打电话、发短信”,就是你存在  then  里的回调。

回调篓子干了啥?

  • 饭还没好,先把你的要求存起来
  • 不催、不闹、不嵌套
  • 等饭一好,一次性按顺序执行

(回调篓子 = 暂存你的“后续操作”, 异步没跑完,先排队等通知。)

3. 两者合在一起,才是 Promise

流程是这样的:

1. 你下单 → Promise 新建

2. 状态立刻变成  pending → 商家正在做饭

3. 你调用  .then()  留下回调 → 把“打电话/发短信”放进篓子存好

4.饭做好了 →  resolve()

  • 状态变成  fulfilled 
  • 拿出成功篓子里的所有回调,挨个执行 → 挨个打电话通知你

5.饭做不成 →  reject()

  • 状态变成  rejected 
  • 拿出失败篓子里的回调,挨个执行 → 发短信告诉你取消了

再到后面链式调用“骑手”干了什么,“平台”有哪些补救措施

(今天我们就以点外卖的方式: 从0 开始,一小块一小块叠代码,先懂原理再落地实现,彻底撕开 Promise 的黑盒!)

 

第一阶段:逐块拆解手写「纯状态机基础版Promise」

比喻以注释的形式写进代码里方便理解

下单必有初始状态,Promise 创建瞬间也必须固定 pending 态,状态只能单向流转,这是一切的根基。我们从零一块块码

第1块:定义三大核心状态常量(杜绝硬编码写错)

// 对标外卖三种固定状态:做饭中 / 已送达 / 已取消
const STATUS_PENDING = "pending";    // 等待中-正在做饭
const STATUS_FULFILLED = "fulfilled";// 成功-外卖送到
const STATUS_REJECTED = "rejected";  // 失败-订单取消

为什么单独定义常量? 统一管理状态名,全程复用,避免手写字符串拼写错误。(可以理解为有报错提示,同时也能让大家看代码更清晰的行业规范)

第2块:搭建Promise空类骨架(相当于开通下单通道)

// 创建自定义Promise类,开始搭建订单系统外壳
class _Promise {
    // 构造函数:实例化瞬间触发 = 用户点击下单
    constructor(executor = () => {}) {

    }
}

executor 就是商家做饭的核心流程,默认给空函数防止报错。

第3块:构造器初始化核心属性(订单基础信息登记)

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        // 刚下单默认状态:商家正在做饭 pending
        this.status = STATUS_PENDING;
        // 预留位置:存放送到手的外卖成果(成功结果)
        this.value = undefined;
        // 预留位置:存放订单失败的原因(商家没货/超时)
        this.reason = undefined;
    }
}

核心规则:只要Promise一创建,天生固定 pending,绝不允许开局直接成功/失败。

第4块:内部定义resolve函数(外卖送达开关)

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        // 专属开关:调用就代表外卖顺利送达
        const resolve = (value) => {
            // 铁律校验:只有还在做饭中,才能改成已送达
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                // 把到手的外卖存起来
                this.value = value;
            }
        };
    }
}

状态不可逆核心体现:已经送达/取消的订单,再也不能二次修改状态。

第5块:内部追加reject函数(订单取消开关)

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
            }
        };

        // 专属开关:调用就代表订单作废取消
        const reject = (reason) => {
            // 同样铁律:只有做饭中才能取消订单
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                // 把取消原因记录下来
                this.reason = reason;
            }
        };
    }
}

第6块:立即执行executor做饭流程(下单立刻开工)

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
            }
        };

        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
            }
        };

        // 下单瞬间直接开火做饭!把两个开关交给外部掌控
        executor(resolve, reject);
    }
}

关键特性:Promise构造器里的执行器是同步立即执行,不会等待延迟。

第7块:追加基础then方法(外卖到了要干啥)

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
            }
        };

        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
            }
        };

        executor(resolve, reject);
    }

    // 定制收货操作:成功干啥、失败干啥
    then(onFulfilled, onRejected) {
        // 外卖已经送到,有成功回调就直接执行
        if (this.status === STATUS_FULFILLED && typeof onFulfilled === 'function') {
            onFulfilled(this.value);//这个onFulfilled传进来必须是函数,加判断为了防止报错崩程序
        }
        // 订单已经取消,有失败回调就直接执行
        if (this.status === STATUS_REJECTED && typeof onRejected === 'function') {
            onRejected(this.reason);//这里同理
        }
        // 注意:当前版本暂时处理不了还在做饭中的异步等待场景
    }
}

第一阶段整合完整版(逐块拼装最终成品)

// 1. 定义外卖三大状态常量
const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

// 2. 自定义基础Promise类
class _Promise {
    constructor(executor = () => {}) {
        // 3. 初始化订单默认状态和存储容器
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        // 4. 送达开关逻辑
        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
            }
        };

        // 5. 取消开关逻辑
        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
            }
        };

        // 6. 立刻启动做饭流程
        executor(resolve, reject);
    }

    // 7. 收货处理then方法
    then(onFulfilled, onRejected) {
        if (this.status === STATUS_FULFILLED && typeof onFulfilled === 'function') {
            onFulfilled(this.value);
        }
        if (this.status === STATUS_REJECTED && typeof onRejected === 'function') {
            onRejected(this.reason);
        }
    }
}

下一阶段我们就给这套基础状态机装上「回调篓子队列」,解决异步等待存任务的问题,完美适配真实延时外卖场景~

第二阶段:加装「回调篓子」完整版(衔接基础状态机,递进改造)

上一版只有状态机,处理不了异步延时——就像外卖要等一会才送到,你提前说了收货要做的事,总得先记下来,这两个数组  resolveQueue/rejectQueue  就是专门存事的「回调篓子」。

第2块:类骨架不变,构造器里新增两个回调篓子属性

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        // 原有基础状态不变
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        // ========== 全新加装:两个回调篓子 ==========
        // 成功篓子:存外卖没送到时,所有收货要做的事
        this.resolveQueue = [];
        // 失败篓子:存订单没取消前,所有失败兜底要做的事
        this.rejectQueue = [];
    }
}

改动说明:凭空新增两个数组,专门排队存待执行的回调函数,对应「先记下来,等送达再办」。

第3块:改造 resolve 函数——送达后自动清空执行成功篓子

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;
        this.resolveQueue = [];
        this.rejectQueue = [];

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                // ========== 新增逻辑:外卖送到,挨个执行篓子里所有寄存的事 ==========
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };
    }
}

第4块:同步改造 reject 函数——订单取消清空执行失败篓子

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;
        this.resolveQueue = [];
        this.rejectQueue = [];

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };

        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
                // ========== 新增逻辑:订单取消,挨个执行失败篓子里寄存的事 ==========
                this.rejectQueue.forEach(fn => fn(this.reason));
            }
        };
    }
}

第6块:核心改造 then 方法——判断 pending,把回调塞进篓子

这是最关键改动:外卖还在做(pending),不执行,直接把事装篓子里排队

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;
        this.resolveQueue = [];
        this.rejectQueue = [];

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };

        const reject = reason => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
                this.rejectQueue.forEach(fn => fn(this.reason));
            }
        };

        executor(resolve, reject);
    }

    then(onFulfilled, onRejected) {
        // 情况1:已经送到了,直接办收货的事
        if (this.status === STATUS_FULFILLED ) {
            onFulfilled(this.value);
        }
        // 情况2:已经取消了,直接办兜底的事
        else if (this.status === STATUS_REJECTED ) {
            onRejected(this.reason);
        }
        // ========== 全新核心逻辑:还在做饭 pending → 塞进对应篓子存起来 ==========
        else if (this.status === STATUS_PENDING) {
           this.resolveQueue.push(onFulfilled);
           this.rejectQueue.push(onRejected);
        }
    }
}

加装回调篓子·阶段完整汇总代码

// 外卖三态常量
const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        // 基础状态机属性
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        // 两个回调篓子队列
        this.resolveQueue = [];
        this.rejectQueue = [];

        // 送达开关 + 执行成功队列
        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };

        // 取消开关 + 执行失败队列
        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
                this.rejectQueue.forEach(fn => fn(this.reason));
            }
        };

        // 立刻执行做饭流程
        executor(resolve, reject);
    }

    // 智能分发:已完成直接执行,pending就装篓子
    then(onFulfilled, onRejected) {
        if (this.status === STATUS_FULFILLED ) {
            onFulfilled(this.value);
        } else if (this.status === STATUS_REJECTED ) {
            onRejected(this.reason);
        } else if (this.status === STATUS_PENDING) {
             this.resolveQueue.push(onFulfilled);
             this.rejectQueue.push(onRejected);
        }
    }
}

第三阶段:刚需进阶版 基础链式调用(无边界裸奔版,核心骨架)

加装核心链式调用真正解决回调函数嵌套地狱

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;
        this.rejectReason = undefined;
        this.resolveQueue = [];
        this.rejectQueue = [];

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };

        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
                this.rejectQueue.forEach(fn => fn(this.reason));
            }
        };

        executor(resolve, reject);
    }

    then(onFulfilled, onRejected) {
        // 核心刚需:返回新实例,实现链式接龙
        return new _Promise((nextResolve, nextReject) => {
            // 封装统一骑手任务:复用逻辑、可立即执行可入篓寄存、中转链式值
             // 封装统一处理函数handleSuccess原因:
            // 1.逻辑抽离复用,不用多处重复写判断/执行逻辑
            // 2.包装成独立函数,既能立即执行,也可直接塞进队列寄存
            // 3.中转结果交给下一个Promise,支撑链式流转
            const handleSuccess = () => {
                const res = onFulfilled(this.value);
                nextResolve(res);
            };
               // 同成功处理逻辑:统一封装复用、可入队列、中转链式结果
            const handleFail = () => {
                const err = onRejected(this.reason);
                nextResolve(err);
            };

            if (this.status === STATUS_FULFILLED) {
                handleSuccess();
            } else if (this.status === STATUS_REJECTED) {
                handleFail();
            } else if (this.status === STATUS_PENDING) {
                this.resolveQueue.push(handleSuccess);
                this.rejectQueue.push(handleFail);
            }
        });
    }
}

刚需裸奔版存在核心问题(对应骑手配送漏洞)

1. 执行器executor报错直接崩程序(后厨做饭出事直接瘫痪,无应急)

2. then乱传非函数、空传省略回调直接报错(招了不会干活的假骑手,配送直接翻车)

3. 无值透传,中间空then直接断链式(中途骑手离岗,外卖没人接力送,链路废掉)   4. then内部回调自己报错无捕获(骑手送餐中途出事,全程没人兜底救援)

分步针对性边界优化(只改对应位置)

优化1:构造器加try/catch 兜底executor全局报错

只改constructor最后一行执行代码,其余全不变:

// 原有执行代码删掉,替换成下面
try {
  executor(resolve, reject); // 正常执行商家出餐
} catch (err) {
  reject(err); // 改动注释:后厨做饭报错直接转订单失败兜底,不崩系统
}

  优化2:加函数校验+值透传 解决乱传/空传骑手断链问题

只改then里handle核心逻辑+队列存入判断:

const handleSuccess = () => {
  // 改动注释:判断是不是正经骑手函数,不是就原值透传接力,不废单
  const res = typeof onFulfilled === 'function' ? onFulfilled(this.value) : this.value;
  nextResolve(res);
};
const handleFail = () => {
  // 改动注释:失败回调同样校验+原因透传,保证坏单也能顺畅接力
  const err = typeof onRejected === 'function' ? onRejected(this.reason) : this.reason;
  nextResolve(err);
};

// 底部pending入队也改一行
if (this.status === STATUS_PENDING) {
  // 改动注释:只把正经骑手存进任务篓,假骑手直接拒收不占用队列
  typeof onFulfilled === 'function' && this.resolveQueue.push(handleSuccess);
  typeof onRejected === 'function' && this.rejectQueue.push(handleFail);
}
 

优化3:内层try/catch 兜底骑手送餐中途自身报错(最终闭环)

只再包一层内部捕获:

const handleSuccess = () => {
  // 改动注释:骑手干活中途出错即时救援,报错直接切失败单流转
  try {
    const res = typeof onFulfilled === 'function' ? onFulfilled(this.value) : this.value;
    nextResolve(res);
  } catch (err) {
    nextReject(err);
  }
};
const handleFail = () => {
  try {
    const err = typeof onRejected === 'function' ? onRejected(this.reason) : this.reason;
    nextResolve(err);
  } catch (err) { // 改动注释:失败处理出错同样兜底捕获,全链路无死角
    nextReject(err);
  }
};

 

最终完整完善版(直接阅读注释说明看到整个流程)

// 外卖订单三大固定状态:待接单/配送完成/订单拒收取消
const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        // 初始化订单默认状态:全部处于待接单
        this.status = STATUS_PENDING;
        // 存放配送完成的餐品结果
        this.value = undefined;
        // 存放订单拒收的原因备注
        this.reason = undefined;

        // 骑手任务收纳篓:排队等候的配送任务/拒收善后任务
        this.resolveQueue = [];
        this.rejectQueue = [];

        // 配送放行开关:只有待接单能改成完成,批量执行所有等候配送骑手
        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };

        // 订单拒收开关:只有待接单能改成取消,批量执行善后骑手任务
        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
                this.rejectQueue.forEach(fn => fn(this.reason));
            }
        };

        // 后厨出餐容错:做饭翻车直接判订单失败,不瘫痪整个店铺
        try {
            executor(resolve, reject);
        } catch (err) {
            reject(err);
        }
    }

    then(onFulfilled, onRejected) {
        // 链式核心:每接一单就生成全新订单,实现骑手接力直送,完成异步时间扁平化
        return new _Promise((nextResolve, nextReject) => {
            // 统一封装正规配送骑手任务
            const handleSuccess = () => {
                // 骑手上岗核验+原值代送透传+送餐中途意外兜底
                try {
                    const res = typeof onFulfilled === 'function' ? onFulfilled(this.value) : this.value;
                    nextResolve(res);
                } catch (err) {
                    nextReject(err);
                }
            };
            // 统一封装订单善后退款骑手任务
            const handleFail = () => {
                // 坏单兜底核验+原因透传+善后出错应急保护
                try {
                    const err = typeof onRejected === 'function' ? onRejected(this.reason) : this.reason;
                    nextResolve(err);
                } catch (err) {
                    nextReject(err);
                }
            };

            // 根据订单当前状态调度骑手
            if (this.status === STATUS_FULFILLED) {
                handleSuccess(); // 已出餐直接派送
            } else if (this.status === STATUS_REJECTED) {
                handleFail(); // 已拒收直接善后
            } else if (this.status === STATUS_PENDING) {
                // 只收正经上岗骑手进任务篓,杂牌无效骑手直接拒收
                typeof onFulfilled === 'function' && this.resolveQueue.push(handleSuccess);
                typeof onRejected === 'function' && this.rejectQueue.push(handleFail);
            }
        });
    }
}

整个流程结尾

我们全程用外卖订单+骑手配送的思路走完了手写Promise的主要核心流程:

最初先搭建核心骨架,定好订单三态状态机,搭配存放任务的回调篓,实现了最基础的异步任务寄存能力;

接着核心打通链式调用逻辑,每调用一次then就生成一张全新外卖订单,让骑手接力顺路配送,把嵌套混乱的回调地狱改成线性直行的流程,完成了Promise最关键的异步扁平化;

最后一步步叠加全套边界优化,用try/catch兜底后厨出餐报错、骑手送餐中途异常,用函数筛查过滤不会干活的假骑手,搭配值透传规则让空岗骑手原样代送不丢单,保障整条配送链路永远顺畅不崩。

至此我们就从最简裸奔版,迭代打磨出了逻辑完整、健壮可用的完整版基础Promise。至于 Promise.all 、 Promise.race 这类静态工具方法,只是在这套核心完备的订单系统之上,额外封装的组合派单简易逻辑,属于锦上添花的拓展用法,不改动我们底层状态机、队列调度、链式流转的核心架构,这里就不再额外展开赘述了。

如有理解不当,欢迎大家指正,一起学习进步

❌