普通视图

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

Hello 算法:贪心的世界

作者 灵感__idea
2026年4月13日 00:21

每个系列一本前端好书,帮你轻松学重点。

本系列来自上海交通大学硕士,华为高级算法工程师 靳宇栋《Hello,算法》

一轮轮的简单选择中,时刻追求自身成长的最大可能,逐步导向最佳答案。

本篇话题展开之前,先看个日常很常见的问题:零钱兑换

你去超市购物,给收银员100元,而你购买的商品只需要2元,他要怎样给你找钱?

很简单,会100内加减的都能轻易搞定,你会根据还剩余的找零额度,从可选择的币种中选择面值最大的,直至达到数额为止。

其实你会发现,“零钱”只是一种比较具象的表达,把“找零”这件事进一步抽象,可用于解决很多领域的问题。

它,就是“贪心算法”。

贪心算法

贪心算法(greedy algorithm)是一种常见的解决优化问题的算法,其基本思想是在问题的每个决策阶段,都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期获得全局最优解。

贪心算法简洁且高效,在许多实际问题中有着广泛的应用。

除了找零钱,它还可用于解决“分数背包问题”:

给一个背包,有容量限制,另有N件物品,每件物品都有它的重量和价值,每件物品只能选择一次,但可以选择物品的一部分,价值根据选择的重量比例计算

求:在限定背包容量下,背包中物品的最大价值。

这个问题的解答策略和“找零”类似:最大化背包内物品总价值,本质上是最大化单位重量下的物品价值

  1. 将物品按照单位价值从高到低进行排序。
  2. 遍历所有物品,每轮贪心地选择单位价值最高的物品。
  3. 若剩余背包容量不足,则使用当前物品的一部分填满背包。

注意:这里说的是“分数背包”,不是“0-1背包”。

代码实现

以“找零”为例,贪心算法的核心实现:

/* 零钱兑换:贪心 */
function coinChangeGreedy(coins, amt) {
    // 假设 coins 数组有序
    let i = coins.length - 1;
    let count = 0;
    // 循环进行贪心选择,直到无剩余金额
    while (amt > 0) {
        // 找到小于且最接近剩余金额的硬币
        while (i > 0 && coins[i] > amt) {
            i--;
        }
        // 选择 coins[i]
        amt -= coins[i];
        count++;
    }
    // 若未找到可行方案,则返回 -1
    return amt === 0 ? count : -1;
}

示意图如下:

微信图片_20260413001704_150.jpg

优点与局限

贪心算法的优点是操作直接、实现简单,通常效率也很高。

但不是所有分步解决的问题都适合使用贪心,什么样的问题适合呢?

主要关注两个性质。

  • 贪心选择性质:只有当局部最优选择始终可以导致全局最优解时,贪心算法才能保证得到最优解。
  • 最优子结构:原问题的最优解包含子问题的最优解。

关键词:最优解

还是找零问题,贪心能找到最优解的前提是,有足够的币值种类可选,如果没有,像下面这样:

给定币值:[1,20,50],目标值是 60,使用贪心策略,它会找到 50 + 1*10,总数是11,但实际更优的做法是 20 * 3,只需要3就可以。

所以可以理解为,贪心适合的场景是“想要多少(比如10),就有多少”,而不是妥协退而求其次。

其他应用

除了上面介绍的两种,贪心的适用场景还包括但不限于:

  • 区间调度问题:假设你有一些任务,每个任务在一段时间内进行,你的目标是完成尽可能多的任务。如果每次都选择结束时间最早的任务,那么贪心算法就可以得到最优解。
  • 股票买卖问题:给定一组股票的历史价格,你可以进行多次买卖,但如果你已经持有股票,那么在卖出之前不能再买,目标是获取最大利润。
  • 霍夫曼编码:霍夫曼编码是一种用于无损数据压缩的贪心算法。通过构建霍夫曼树,每次选择出现频率最低的两个节点合并,最后得到的霍夫曼树的带权路径长度(编码长度)最小。
  • Dijkstra 算法:它是一种解决给定源顶点到其余各顶点的最短路径问题的贪心算法。

小结

贪心是必掌握的经典算法之一,实现也不难,重点是吃透它的使用场景。

下一篇,将是本系列的终篇,让我们一起期待会是什么。

更多好文第一时间接收,可关注公众号:“前端说书匠”

昨天 — 2026年4月12日首页

《前端周刊》尤大开源 Vite+ 全家桶,前端工业革命启动;尤大爆料 Void 云服务新产品,Vite 进军全栈开发;ECMA 源码映射规范......

作者 Web情报局
2026年4月12日 19:40

🌐 今日要闻

打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。

欢迎阅读《Web 周刊》,上周全球 Web 开发圈的主要情报如下:

  • 🎉 尤大出席 Vue 大会,发表了关于 Vue & Vite 的重要讲话
  • ✨ Vue 生态“文艺复兴“,“蒸汽模式“公测,Pinia Colada 新品首发
  • ✅ TC39 工作组推进 ECMA 源码映射规范
  • 👍 Vite 生态“工业革命“,Vite+ 全家桶免费开源
  • 🙏 尤大爆料 Void 一键部署云平台,Vite 进军全栈开发

PS:本文附带甜妹解说的动画视频,粉丝请搜索哔哩哔哩@Web情报局

🎉 Vue 生态文艺复兴

近日,Vue 之父 & Vite 之父 & VoidZero CEO 尤雨溪出席了 Vue 阿姆斯特丹大会和 D2 技术大会,发表了重要讲话。

cover.png

本期我们就来回顾一下这位“前端之神“已公开的关于 Vite & Vue 生态的最新情报和未来规划。

Vue 3.6 Beta

Vue 是 GitHub 第二 UI 框架,也是唯一一个同时支持 SFC(单文件组件)/ JSX、集成 Signals(细粒度响应性)的渐进式框架。

Vue 目前发布了 v3.6-beta(公测版),主要包括:

  • 移植 alien-signals 重构 @vue/reactivity
  • 新增可选的 Vapor Mode 编译模式,这是一种专属于 <script setup> SFC 的无虚拟 DOM “蒸汽模式“
  • Vapor Mode 是 Vue 当前 API 的子集,所以部分功能受限,比如不支持 <Suspense /> 组件和 Options API

尤大最初在 2022 透露了 Vapor Mode(蒸汽模式),去年 Vue 从 Alpha 顺利晋升到 Beta,今年有望正式发布。但考虑到尤大还需要兼顾 Vite+ 等海量开源项目,暂不确定,敬请期待!

Vue Router 5.0

Vue Router 是 Vue 生态官方的客户端路由库,年初发布了 v5.0 主版本,主要包括:

  • 不再依赖 unplugin-vue-router,将其集成为 vue-router/unplugin,支持基于文件的路由
  • IIFE 构建不再包含 @vue/devtools-api,该模块升级到 v8.0 之后,不再提供 IIFE 格式
  • v5.0 是一个过渡版本,v6.0 将只支持纯 ESM 模块

Pinia Colada v1 首发

Pinia 近期没有主/次版本更新,但上线了新的产品 Pinia Colada。

pinia-colada.png

Pinia Colada 是基于 Pinia 的 Vue 专属异步状态管理库,第一个主版本正式发布,优点在于:

  • 支持缓存、去重、SWR(过期重验证)等高级功能,不用我们自己定义相关复杂逻辑
  • 无需手写 isLoading 等属性,内部封装后暴露这些接口
  • 消除数据请求的大量重复模板代码,更符合人体工学

colada-demo.png

Nuxt 4.4

Vue 生态的第一全栈元框架 Nuxt 发布了 v4.4 次版本,主要包括:

  • 新增 createUseFetch() 等工厂函数,支持组合拦截器定义高级实例,比如创建带有默认选项的 useApiFetch() 来替换 useFetch()
  • 新增 useAnnouncer() 组合函数和 <NuxtAnnouncer /> 组件,适用于页面内容动态变化、但焦点不变的场景,比如表单提交
  • 更棒的 import 保护,现在会显示建议和完整的追踪信息
  • 构建性能分析报告,显示构建阶段或打包插件的持续时间等数据,轻松诊断性能瓶颈

profiler.png

🎉 Vite 生态工业革命

而 Vite 生态,尤大所在的 VoidZero 团队掀起了一场前端“工业革命“。

上周我们提到了 Rust 驱动的第一个 Vite 稳定版 Vite 8 正式发布,替换 Rollup + esbuild,采用 Rolldown + Oxc,性能爆表。

此外,基于 Oxc 编译器的格式化神器 Oxfmt 发布了 beta(公测版),JavaScript 跟 TypeScript 的格式化功能 100% 兼容 Prettier,但性能比快了 30 倍。

本期补充更多 Vite 生态的进展,包括 Oxlint 和 Vitest。

Oxlint JS 插件 Alpha

ESLint 的 Rust 移植版 Oxlint 也有新进展,它的 JS 插件进入 Alpha 阶段,目前 100% 通过 ESLint 内置规则的官方测试套件。

oxc-test.png

具体而言,Oxlint 采用 Rust 重写了 650+ 多条代码质检规则,涵盖了 ESLint 的大部分规则。即使没有使用 Rust 重写的规则,Oxlint 也提供了 oxlint-plugin-eslint 插件来无缝迁移,使得 Oxlint 100% 兼容 ESLint 的所有内置规则。

性能方面,Oxlint 团队把 Node 源码库的 ESLint 替换掉,进行测评跑分,性能暴涨近 5 倍。

Oxlint JS 插件的成熟意味着目前 JS 生态现存的 ESLint 插件,比如非官方的社区插件,也能无缝迁移到 Oxlint 项目。这样用户无需重写插件,同时部分受益于 Rust 的原生性能。

Vitest 4.1

Vite 生态衍生的 Vitest 是 GitHub 前十的测试框架,近期也发布了 4.1 次版本,主要包括:

  • 采用新鲜出炉的 Vite 8
  • 测试标签分组,按标签设置或筛选测试,借鉴 pytest 筛选标签的自定义语法
  • 开发体验优化,比如自定义 UI 窗口配置,Playwright 追踪视图改进,自动生成 GitHub Actions Job 摘要
  • Vitest 的 VS Code 扩展现在支持 Deno,import 语句后会显示模块加载时间

vscode.png

🛜 官方情报

ECMA-426 源码映射格式规范

Source Map(源码映射)是一种特殊的 JSON 文件,用于在我们编写的源码和运行时代码之间进行映射。

举个栗子,我们在开发时可能编写一些强大的方言,比如 Sass 或 TypeScript,再把它们转换成 HTML、CSS 或 JavaScript,这样浏览器才能正常执行。

source-map.gif

问题在于,当我们使用 devtools(开发者工具) debug 时,我们希望直接定位到源码,而不是编译或压缩后人类不可读的代码。

这时就要用到 Source Map 了,这个 JSON 文件中保留了源码和运行时代码之间的映射关系,比如哪一行、哪一列等等。

过去,Source Map 并未被标准化,大家通过一份谷歌文档约定实现,但一些功能始终无法协调。

为此,彭博社成立了 TC39-TG4(源码映射工作组),制定了 ECMA-426(源码映射格式规范)。

ecma.png

近年来,它们标准化了更多功能,Scopes 和 Range Mappings 也即将上线 devtools!

React 文档更新

React 文档更新了 <ViewTransition /> 组件结合 <Activity /> 组件章节,如果你想让组件在保持状态的同时实现进场或出场动画,可以使用 <Activity /> 组件。

react-doc.png

Svelte 最佳实践

Svelte 文档更新了“最佳实践“章节,帮助大家编写快速健壮的 Svelte 应用,可以将 svelte-core-bestpractices 投喂给 AI 代理作为 Skills 使用。

svelte-doc.png

🛠️ 工具推荐

Vite+ 全家桶开源

随着 Vite 生态的各个工具逐渐成熟,尤大创立的 Void Zero 公司也官宣:Vite+ 进入 Alpha 阶段!

Vite+ 是将 Vite 生态所有开发工具叠加在一起的一体化工具链,由 Vite Task 任务运行器驱动,提供了 vp install / vp dev 等命令。

具体而言,Vite+ 把 Vite 生态的所有流行的开源软件 —— Vite、Vitest、Oxlint、Oxfmt、Rolldown 和 tsdown 都添加到一个全家桶,用于开发、测试、代码质检、格式化和构建生产环境项目。

此外,Vite+ 还支持管理 pnpm / Bun 等包管理器,甚至能管理 Node 的版本。同时,我们配置的 lint 或格式化规则,比如 eslint.config.js.prettierrc 等配置文件,可以整合到单一的 vite.config.js 中。

vite-plus.gif

之前,Vite+ 原本要求对企业用户付费授权,现在尤大直接开源,完全免费。不管是公司还是独立开发者,都能纵享丝滑了,感谢 Void Zero 的慷慨!

随着 Vite+ 官宣 Alpha 版本,很多项目开始试用,Vite+ 目前已经集成到 Vue 源码的相关分支,还有 Vue CLI create-vue,进一步投入到生产环境测评。

Void Cloud 全栈开发

尤大在 Vue 大会演讲的最后,致敬乔布斯经典的“One More Thing“环节放大招,透露了新产品 Void Cloud。

VOID 是 Vite+ / Optimized(优化)/ Isomorphic(同构)/ Deployment(部署)这几个设计理念的首字母缩写,这是一款 Vite 专属的云服务插件,也是一个也云服务平台,或者全栈元框架。

Void 内置了强大的后端 SDK(软件开发工具),包括数据库、键值存储、对象存储、AI 推理、身份认证等后端应用常见功能,可以按需采用。

void.gif

由于 Void 基于 Vite 生态,因此所有前端框架/元框架天然支持,比如 React、Vue、Nuxt 等,且支持静态站点生成或服务端渲染等不同渲染方式。

Void Cloud 旨在让 Vite 用户能够一键部署,直接上线全栈应用,标志着 Vite 生态将进军全栈开发领域。

Antdv Next 组件库

我一般不推荐组件库,因为 GitHub 有大量成熟的组件库供大家白嫖,容易选择困难。但最近新出了一个 Vue 3 的组件库,它就是 Antdv Next!

Antdv Next 是一套开箱即用的高质量 Vue3 企业级组件库,基于阿里系的蚂蚁设计系统构建。

阿里系之前 Ant Design Vue 是比较流行的组件库,虽然其源码仓库还有提交,但我发现 2024 之后就没有再发布新版本了,目测不会推出新功能了。Ant Design Vue 支持 Vue 2 和 Vue 3,而 Antdv Next 只服务于 Vue 3。

我粗略看了一下,Antdv Next 采用了现代化的技术栈,比如 Vite / Vitest / pnpm 等,可以集成 AI、UnoCSS、Tailwind CSS 等。

Vue 初学者最不习惯的应该是 Antd 系列的组件源码都是基于 TSX 来实现,而不是常见的 SFC,但这只影响开源贡献,不会影响我们以 SFC 的方式使用。

由于组件实现采用了 TSX,Antdv Next 的自定义主题相应地也采用了 CSS-in-JS,其主题是目前我个人比较喜欢的亮点之一。

antdv-next.gif

总之,Antdv Next v1.0 已经正式官宣,值得继续关注,欢迎大家去 GitHub star 支持一波~

🙏 特别鸣谢

以上就是本期《Web 周刊》的全部内容了,希望对你有所帮助。

👍 感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。

😘 已经关注我的粉丝们,我们下期再见啦,掰掰~~

cat-thank.gif

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

作者 yuki_uix
2026年4月12日 18:03

有一段时间我一直搞不明白一件事:同样是"移动一个元素",用 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 性能优化的底层逻辑

作者 yuki_uix
2026年4月12日 17:17

用了两三年 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,不用随机数。

参考资料

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

作者 im_AMBER
2026年4月12日 14:50

前言

在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

TypeScript学习系列(二):高级类型篇

作者 luckyCover
2026年4月12日 14:41

前言

在上一篇文章juejin.cn/post/762508… 中,介绍了 TypeScript 系统中的基础类型及其用法,本篇我们将进击 TypeScript 中一些高级类型,学完本篇,就能对 TypeScript 系统中的各大类型有个比较全面的理解了。

泛型

泛型可以理解为类型参数类型变量,在定义类型别名、接口、类、函数参数时都会用到。在上一篇文章中我们已经介绍过了,下边来看与它相关联的一些场景。

extends 约束

extends 关键字主要用于泛型约束中,例如:

type IsNumber<T> = T extends number ? T : never

IsNumber 类型接收泛型 T 作为类型参数,随后使用 extends 关键字

  • T extends number:表示 T 能赋值于 numberextends 表示 赋值于
  • T extends number ? T : never:这里运用条件类型(类似 js 的三元表达式),意思是,T 如果能赋值于 number 类型,那么返回结果就是 T 类型,反之就返回 never 忽略类型。

那上边这个例子就很好理解,主要使用 extends 关键字来约束泛型在满足 number 类型时再返回它。

内置工具约束

在了解了泛型extends 和基本的条件类型,我们可以来看 TypeScript 提供的一些内置工具,主要用于约束泛型,比如 Partial<T>Required<T>,都是工具名称 + <T> 的组合,专为约束泛型 T 而生。

Partial

Partial 允许我们将传入泛型中的所有属性变为可选的属性,例如:

type Person = {
  name: string,
  age: number
}

type MyPartialProperties = Partial<Person>

image.png

所有属性名旁边都加上了 ? 符号,表示可选属性。

Required

Required 将传入类型中的所有属性变为必传的属性,例如:

type Person = {
  name?: string,
  age: number
}

type MyRequiredProperties = Required<Person> // Person 上的所有属性都是必须的,? 会去掉

image.png

可以看到原本可选 name 属性旁边的 ? 被去掉了,变成了必须项。

ReadOnly

ReadOnly 可以将类型上的属性指定为只读的:

type Person = {
  name?: string,
  age: number
}

type MyReadOnlyProperties = Readonly<Person> // Person 上的所有属性前边会加上 readonly 表示只读属性

image.png

所有属性前边都加上了 readonly 描述符,表示属性是只读的。

Pick

Pick 可以从类型中筛选出某个属性:

type Person = {
  name: string,
  age: number
}

type MyPickProperties = Pick<Person, "name"> // 仅筛选出 Person 中为 name 的那个属性

image.png

从泛型 Person 中取出了 name 属性作为新类型,如上图 MyPickProperties 只剩下 name 属性。

Omit

可以从对象类型中排除掉不需要的属性,支持传联合类型用于同时排除多个属性:

type Person = {
  name: string,
  age: number,
  sex: string
}

type MyOmitProperties1 = Omit<Person, "name"> // 排除掉 Person 类型中的 name 属性
type MyOmitProperties2 = Omit<Person, "name" | "age"> // 排除掉 Person 类型中的 name 和 age 属性

image.png

将泛型 Person 中的 name 属性排除掉了。

image.png

通过联合类型将泛型 Person 中的 nameage 属性一块排除掉。

Record

可以创建一个新的对象类型,这个新对象类型是由某个指定类型中的属性组成的,同时可以指定新类型上的属性类型:

type Example = 'name' | 'age' | 'sex'

type MyRecord = Record<Example, string> // 新类型中属性由 Example 中组成,同时新类型上属性类型指定为 string

image.png

Example 类型中的所有属性组成的新类型 MyRecord,并且指定新类型中属性的类型为 string

ReturnType

ReturnType 用于提取函数类型的返回值类型,而可以不用手动指定函数的返回值类型:

function testExample(a: number, b: number) {
  return a + b
}

type GetFuncReturnType = ReturnType<typeof testExample> // number

image.png

提取 testExample 函数的返回值类型于新类型 GetFuncReturnType 中。

Extract

Extract 用于从两个类型中取出相互兼容的部分,其实就是取交集:

type A = string | number
type B = boolean | number

type ExtractAB = Extract<A, B> // number(相交的部分就是 number)

image.png

类型 A 和类型 B 共有的部分就是 number,自然取到的交集就是 number,作为 ExtractAB 的新类型。

Exclude

Exclude 主要用于从联合类型中排除掉不需要的属性:

type Example = string | number | boolean
type ExcludeString = Exclude<Example, string> // ExcludeString 剩下 number | boolean

image.png

Example 类型中排除掉 string 类型,剩下的 numberboolean 就作为 ExcludeString 的类型。

type Example1 = "dog" | "cat"
type Example2 = "cat"
type ExcludeCat = Exclude<Example1, Example2>

image.png

上边这个例子可以看出,Exclude 会排除掉 Example1 中和 Example2 相同那一部分,Example1 中和 Example2 相同的那部分是 cat,故排除掉 Example1 中的 cat

我们可以发现,ExtractExclude 的操作刚好相反,Extract 是取两个泛型相交的部分,而 Exclude 是从第一个泛型中排除掉和第二个泛型相同(相交)的那部分。Extract 是取,而 Exclude 是排,两者都是取交集

NonNullable

NonNullable 用于排除类型中为 nullundefined 的部分,返回一个新类型:

type Example = number | undefined | null
type NotNullAndUndefined = NonNullable<Example> // 排除类型 C 中为 null 和 undefined 的部分

image.png

NonNullable 可以排除 Example 类型中的 nullundefined

条件类型

条件类型我们在开篇泛型那里就见过了,ts 中的条件类型类似于 js 中的三元运算符,一般配合 extends 一起使用,如:T extends U ? U : never。下面我们来看下分布式条件类型

分布式条件类型

当条件类型作用于泛型类型参数时,如果该类型是联合类型(注意是联合类型),则条件会分布到每一个联合成员上,分别计算,再将结果合并成一个新的联合类型,我们来举例看下:

type Animal = "dog" | "cat"
type AnimalOrFruit = "dog" | "apple" | "cat" | "banana"

type TogetherExample<T> = T extends Animal ? T : never

type OnlyAnimal = TogetherExample<AnimalOrFruit>

image.png

  • T extends AnimalT 能否赋值于 Animal 类型(Animal 类型中包含 dogcat,也就是 T 是否为 dogcat)。
  • T extends Animal ? T : never:接收的泛型 T 如果能赋值于 Animaldogcat),那么就取这个 T,反之就用 never 来忽略类型。
  • TogetherExample<AnimalOrFruit>AnimalOrFruit 类型中为 dogcat 的就正常收集到新类型 OnlyAnimal 中,其他的 applebanana 由于不能赋值于 Animal,被使用 never 类型忽略了。故最终新类型 OnlyAnimal 中仅包含 dogcat

从上边例子也能看出来,所谓分布式条件类型,就是当接收的泛型参数为联合类型时,会将条件作用于每个类型中。

infer

infer 关键字的作用是延时推导,它会在类型未推导时进行占位,等到真正推导出来后,它能返回准确的类型:

type Example<T> = T extends (...args: any) => infer R ? R : never

type ExampleFunc = (a: number, b: number) => number

type TestGetFuncReturnExample = Example<ExampleFunc> // number
  • T extends (...args: any) => infer R ? R : never:判断 T 是否为一个函数类型
  • (...args: any) => infer Rargs 为函数入参,infer R 为返回值类型的占位操作

整个意思就是,T 是函数类型的话,能推导出它的返回值类型 R;反之,就返回 never。代入上边例子,ExampleFunc 是一个函数类型,先使用 infer R 占位返回值类型,等到真正推导出函数的返回值类型为 number 时,它能准确返回类型。

映射类型

映射类型可以基于现有的类型来修改某个属性或通过排除属性来生成新的类型,修改属性包括把属性映射为只读可选、属性名添加前缀等操作。排除属性主要就是将不符合条件的属性映射为 never。我们逐个举例来看下:

  • 映射为只读属性
interface Person {
    name: string,
    age: number,
}

type MyReadOnlyProperties<T> = {
    readonly [P in keyof T]: T[P] // 通过 readonly 关键字将属性映射为只读
}

type ReadOnlyPerson = MyReadOnlyProperties<Person>

image.png

  • 映射为可选属性
interface Person {
    name: string,
    age: number,
}

type MyPartialProperties<T> = {
    [P in keyof T]?: T[P] // 通过 ? 符号将属性映射为可选
}

type PartialPerson = MyPartialProperties<Person>

image.png

  • 为属性添加前缀,可以结合 as 来完成:
interface Person {
    name: string,
    age: number,
}

type MyPrefixProperties<T> = {
  [P in keyof T as `prefix_${string & P}`]: T[P]
}

type PrefixProperties = MyPrefixProperties<Person>

image.png

1、首先使用 P in keyof T 将枚举出 T 类型中的每个属性

2、使用 as 来将每个属性重命名为 prefix_${string & P},即加上 prefix_ 这个前缀,如 name 就会变成 prefix_name,这里的 string & P 作用就是确保 P 类型是一个字符串

3、T[P]:属性的值就正常映射为 P 属性在 T 上的原有类型

  • 仅映射特定类型来生成新的类型
interface Person { 
    name: string,
    age: number,
}

type GenerateNewTypeWithString<T> = {
    [P in keyof T as T[P] extends string ? P : never]: T[P] // 仅映射出类型为 string 的属性
}

type NewTypeString = GenerateNewTypeWithString<Person> // 仅包含 string 类型的属性,即 name

image.png

索引类型

索引类型可以通过属性名直接访问某个属性的具体类型,主要使用中括号 [],例如:

interface Person {
    name: string
    age: number
}
type PersonOfAge = Person["age"]

image.png

如上访问 Person 上的 age 属性,其类型就是 number

还可以获取类型中所有属性的联合类型,如下:

interface Person {
    name: string
    age: number
}

type PersonOfValue = Person[keyof Person]

image.png

使用 keyof 遍历出 Person 上所有的属性,相当于:

  • Person["name"] | Person["age"]
  • 那结果就是 string | age

类型守卫

类型守卫可以根据条件来细化变量的具体类型,从而使代码在运行时更加安全和可维护,主要通过几种方式来实现,包括 typeofinstanceofin自定义类型函数,我们逐个来看看。

type A = string | number | boolean

function logInfo(a: A): void {
    if(typeof a === 'string') {
        // 当 a 为 string 时执行某些操作...
        console.log(`variable ${a} is a string`)
    }
}
  • typeof

image.png

logInfo 函数接收形参 a,用类型 A 约束,类型 A 是个联合类型,也就是说形参 a 的类型可能是 stringnumberboolean 中的一个,在函数体内通过 typeof 来判断 astring 类型,那么在该条件分支内,ts 就能确定 a 的类型为 string 了,这也很好的避免由于类型不确定导致的意外操作。

  • instanceof
class Person {
    speak() {
        console.log('people can speak')
    }
}

class Animal {
    fly() {
        console.log('some animals can fly')
    }
}

const p1 = new Person()
const a1 = new Animal()

function personOrAnimal(a: Person | Animal) {
    if(a instanceof Person) {
        a.speak()
    } else if(a instanceof Animal) {
        a.fly()
    }
}

personOrAnimal(a1) // people can speak

使用 instanceof 能够检查变量是否属于某个类的实例,这样在对应条件分支内 ts 编译器就能确定该实例所属类,从而能给予我们该实例上能调用的方法和属性的提示,这也很好保证了运行时的准确性。

  • in
interface Person {
    write(): void
}
interface Animal {
    eat(): void
    fly(): void
}

class A implements Animal {
    eat() {
        console.log('animal can eat')
    }
    fly() {
        console.log('animal can fly')
    }
}

function getInfo(a: Person | Animal) {
    if('write' in a) {
        a.write()
    } else if('fly' in a) {
        a.fly()
        a.eat()
    }
}

上边 getInfo 函数接收一个实例,通过 in 来判断属性是否存在于实例上,如果存在就能直接使用,而且该属性所属类上的其他属性也能直接访问,比如判断 a 上如果存在 fly 属性。那么 fly 属性所属类上的 eat 也能访问了。

  • 自定义类型
function isString(str: any) {
    return typeof str === 'string'
}

function getInfo(a: any) {
    if(isString(a)) {
        console.log('the operation of string a')
    }
}

通过使用自定义的 isString 函数来判断某个变量是否为特定的类型,满足就能在条件分支内对该类型变量进行一些操作。

类是面向对象的核心概念,它主要封装了对象的状态和行为,也就对应着属性和方法,ts 为类提供了类型检查功能。为此我们可以在类中为属性或方法定义类型,如下例子:

class Animal {
    name: string
    constructor(name: string) {
        this.name = name
    }
    say(): void {
        console.log('动物发出声音')
    }
}

继承

在 ts 中实现类的继承和 js 中是一致的,如下示例:

class Dog extends Animal {
    constructor(name: string) {
        super(name) // 调用父类构造函数初始化
    }
    say(): void { // 重写父类方法
        console.log("wang wang~")
    }
}

const d1 = new Dog("哈士奇")
console.log(d1.name) // 哈士奇

super

super 主要用于调用父类的构造函数,将子类构造函数接收的参数传给父类构造函数,由父类构造函数来做初始化,这样也省去了在子类构造函数中重复声明初始化的操作。

class Dog extends Animal {
    constructor(name: string) {
        super(name) // 调用父类构造函数初始化
        // this.name = name // 相当于
        // this.xxx = xxx // 更多参数
    }
    say(): void { // 重写父类方法
        console.log("wang wang~")
    }
}

修饰符

  • public:可在任何地方访问
  • private:仅可在类内部访问,子类也不允许访问
  • protected:仅可在类内部、子类中访问

下边我们举例来理解这三个修饰符:

  • 实例访问:仅能访问公有属性(public)
class Person {
    public name: string // 公共属性
    private age: number // 私有属性
    protected sex: string // 保护属性

    constructor(name: string, age: number, sex: string) {
        this.name = name
        this.age = age
        this.sex = sex
    }
    getPersonAge(): number { 
        return this.age // 私有属性,仅在当前类能访问
    }
}

const p1 = new Person("man", 11, 'male')
console.log(p1.name) // man 
console.log(p1.age) // 编译报错,age 是类私有属性
console.log(p1.sex) // 编译报错,sex 是类的保护属性,仅可子类中访问
  • 子类访问:可以访问公有属性(public)和保护属性(protected)
class Student extends Person {
    constructor(name: string, age: number, sex: string) {
        super(name, age, sex)
    }

    getStudentName(): string {
        return this.name // 正常
    }

    getStudentSex(): string {
        return this.sex // 正常
    }

    getStudentAge(): number {
        return this.age // 编译报错,age 是私有属性,仅能声明类自身访问
    }
}

抽象类与接口

抽象类就是使用 abstract 修饰的类,抽象类中可以定义抽象、也可以定义具体方法,继承抽象类的子类必须实现抽象类中定义的抽象方法,同时也可以重写抽象类中的具体方法,我们看下边例子就知道了:

abstract class Person {
    abstract myHobby(): void // 抽象方法

    walk(): void {
        console.log('people walking')
    }
}

class Student extends Person {
    myHobby(): void { // 子类必须实现父类的抽象方法
        console.log('music')
    }
    walk(): void { // 重写父类的方法
        console.log('student walking')
    }
}

const s1 = new Student()
s1.myHobby() // music
s1.walk() // student walking

再来看看接口: 定义一组规范,不提供具体的实现,仅包含函数的签名

interface Play {
    games(): void // 函数签名
}

由实现类来完成函数体:

class Student extends Person implements Play {
    // 实现接口中的函数签名
    games() {
        console.log('lol')
    }

    myHobby(): void {
        console.log('music')
    }
    walk(): void {
        console.log('student walking')
    }
}
const s1 = new Student()
s1.games() // lol

为此,我们可以得出抽象类接口的区别:

相同点:都用于定义行为规范,抽象类的抽象方法和接口中的函数签名都必须在子类中实现。

不同点:抽象类中可以包含具体方法的实现,而接口仅含函数签名或属性签名,不包含具体方法的实现。抽象类不能直接实例化,只能作为基类通过子类来继承;而接口可以被类实现,一个类可以同时实现多个接口,使用 implements 关键字。

总结

本篇文章主要围绕 TypeScript 中的高级类型展开介绍,从泛型开始,扩展来看它的一些场景,包括泛型约束、ts 中的内置工具类型。然后就是常用的条件类型、映射类型、索引类型、类型守卫,以及我们常用的类,最后对比了抽象类与接口的区别。

我是 luckyCover,我正在持续更新 TypeScript 学习系列的文章,欢迎大家一起讨论学习呀~

手撕 V8:我是如何用 2.67ms 的心跳活捉 700ms 冻结幽灵的

作者 comerzhang655
2026年4月12日 14:17

手撕 V8:我是如何用 2.67ms 的心跳活捉 700ms 冻结幽灵的

最近在搞一个高性能 Web 应用,被一个"幽灵"困扰了很久:页面总会无征兆地出现瞬间掉帧。

大家都知道这是 V8 的 Stop-The-World (STW) 在搞鬼,但在浏览器里,监控 STW 是个悖论——如果主线程被冻结了,你用来监控的主线程代码(比如 rAF 或 performance.now)本身也是冻结的。

你没法在自己心脏停跳的时候记录停跳时长。

为了抓到这个幽灵,我折腾出了一个叫 stw-sentinel 的小工具,思路挺"偏门"的,发出来给各位老哥 Review 下。

核心思路:找一个"编外"保镖

既然主线程不可信,我就把目光投向了 AudioWorklet

  • 优先级极高:它跑在音频回调线程上,受操作系统音频驱动调度,优先级甚至高于浏览器的渲染线程。
  • 物理隔离:哪怕 V8 的主线程正在进行大规模的垃圾回收(Major GC),只要 CPU 还没爆表,AudioWorklet 依然能稳定跳动。

技术栈:无锁通信

监控者(AudioWorklet)和被监控者(Main Thread)之间必须保持绝对的高效:

  • SharedArrayBuffer (SAB) :两边共享一块物理内存。
  • Atomics:主线程每隔一段时间去 SAB 里打个卡,AudioWorklet 负责高频检查。如果打卡中断,幽灵就现身了。

踩过的深坑

中间最想吐槽的是底层寻址。在处理 SAB 偏移量时,我被一个 16 bytes ÷ 4 = 4 elements 的寻址偏移搞掉了半个通宵。

在 SharedArrayBuffer 中,内存是连续的字节流。当你用 Int32Array 操作时,索引是按 4 字节步进的:

Index = ByteOffset / 4

所以 16 字节的 Header 对应的索引就是 4。这个 16 → 4 的转换,就是高级语言开发者和内存地址之间的"最后一公里"。JS 层的索引步长和 C 层的字节偏移在这里撞车了,这种底层 Bug 真的只能靠硬啃。

战果

在我的测试 Lab 里,我成功捕获到了一次长达 684.5ms 的 V8 冻结,而此时我的 Sentinel 心跳依然稳定在 2.67ms(48kHz/128 frames)。

这种"降维打击"的观测感非常爽。

如果你在本地跑不起来,先别急着提 Issue。检查下你的 Response Headers。在这个 Spectre 漏洞后的时代,没有 Cross-Origin-Opener-Policy: same-origin,你连 SharedArrayBuffer 的边都摸不到。这是属于硬核开发者的"入场券"。


  • 源码 & 文档: GitHub - stw-sentinel

  • 在线演示(Lab): diffserv.xyz/lab

  • 一行命令体验: npx stw-sentinel

    如果你也对 V8 性能、线程隔离或者 SharedArrayBuffer 感兴趣,欢迎来 GitHub 提个 Issue 或者点个 Star。

⚡精通Claude第3课:学会用Skills让Claude变身为专属专家

2026年4月12日 12:23

skill.png

Skills 是 Claude Code 的大招——你可以给它装上各种"技能包",让它变成代码review专家、部署达人、或者任何你需要的专业助手。一次配置,随时调用。

image.png

你有没有遇到过这种情况?

  • 每次让 Claude 帮你review代码,你都得先把评审标准说一遍。
  • 每次让它部署,你都得解释一遍流程。
  • 每次让它写文档,你都得强调一遍格式要求。

累不累.jpg

Claude Agent Skills 就是来解决这个问题的。它像是给 Claude 装了一个"技能插槽",你提前把专业知识塞进去,以后 Claude 一看到相关任务,自动就调用对应的技能——不用你重复唠叨。


什么是 Skills?说人话!

Skills = 预置的专业知识包。

你可以把它理解成:

  • 医学院的选修课:Claude 原本是个通才,你给它上一门"代码review专业课",它就成了这方面的专家
  • 厨房的调料盒:提前准备好各种调料,做菜时直接撒,不用每次都现调
  • 武侠小说的武功秘籍:把内功心法提前输入,Claude 遇到敌人自动出招

Skills 最大的特点:用的时候才加载,不用时不占地方

这就厉害了——你可以装几十个技能,Claude 也只会在真正用到的时候才把对应技能的内容调进来。不会把上下文窗口塞爆。


三层加载机制(渐进式披露)

Skills 用了一种很聪明的设计,分三层加载:

┌─────────────────────────────────────────────┐
│  第1层:元数据(约100 tokens)               │
│  - 技能名称 + 简短描述                        │
│  - Claude 启动时就知道了这些技能存在           │
└──────────────────────┬──────────────────────┘
                       │ 触发技能时
┌──────────────────────▼──────────────────────┐
│  第2层:指令(约5k tokens)                   │
│  - SKILL.md 的正文内容                        │
│  - 工作流程、指导原则                         │
└──────────────────────┬──────────────────────┘
                       │ 需要更多资源时
┌──────────────────────▼──────────────────────┐
│  第3层:资源文件(无限)                      │
│  - 模板、脚本、示例代码                        │
│  - 按需加载,不进上下文                        │
└─────────────────────────────────────────────┘

用人话讲就是:

  1. 启动时:Claude 知道你有 N 个技能,每个技能是干嘛的
  2. 触发时:Claude 发现这个任务需要某技能,才把技能说明书加载进来
  3. 需要时:Claude 发现还需要模板或脚本,才去读取对应文件

这样设计的好处:你装 100 个技能也不会变慢,因为 Claude 不是一次性全加载。


Skill 的目录结构

一个技能长这样:

my-awesome-skill/
├── SKILL.md              # 主角,必须有
├── templates/            # 模板文件夹
│   └── output-format.md
├── examples/             # 示例文件夹
│   └── sample-output.md
└── scripts/              # 脚本文件夹
    └── validate.sh

最核心的是 SKILL.md,长这样:

---
name: my-skill
description: 这个技能是干嘛的,什么时候该用它
---

# 技能标题

## 使用说明
一步一步告诉 Claude 该怎么做

## 注意事项
有哪些坑要避开

举个例子:做一个代码review专家

假设你想让 Claude 每次review代码都按照你公司的标准来:

目录结构:

~/.claude/skills/code-review/
├── SKILL.md
├── templates/
│   └── review-checklist.md
└── scripts/
    └── analyze-metrics.py

SKILL.md 写起来:

---
name: code-review-expert
description: 代码评审专家,专注安全、性能、质量分析。
              当你提到 code review、代码评审、PR review 时触发。
---

# 代码评审专家

## 评审维度

1. **安全**:认证授权、数据泄露、注入风险
2. **性能**:算法效率、数据库查询优化
3. **质量**:SOLID原则、命名规范、测试覆盖
4. **可维护性**:代码可读性、函数长度、圈复杂度

## 评审流程

1. 先通读代码,理解整体结构
2. 按上面4个维度逐一检查
3. 整理问题,按严重程度排序
4. 给出具体的修复建议

## 严重程度定义

- **Critical**:必须立即修复,有安全风险
- **High**:应该在下次迭代前修复
- **Medium**:建议修复,不紧急
- **Low**:可选的优化项

详细 Checklist  [templates/review-checklist.md](templates/review-checklist.md)

review-checklist.md 长这样:

# Code Review Checklist

## 安全性
- [ ] 是否有 SQL 注入风险
- [ ] 用户输入是否做了校验
- [ ] 敏感数据是否明文存储
- [ ] 权限校验是否完整

## 性能
- [ ] 是否有 N+1 查询问题
- [ ] 循环中是否有不必要的数据库调用
- [ ] 是否需要加缓存
- [ ] 大数据量是否有分页

## ...

现在,当你跟 Claude 说"帮我review一下这个PR",它自动就知道:

  • 要从哪几个维度评审
  • 问题怎么分类
  • 严重程度怎么定义
  • 该输出什么格式的报告

不需要你每次都解释一遍。


控制 Claude 什么时候能调用技能

Skills 有三种调用模式,通过 frontmatter 控制:

---
# 模式1:默认(你也可以调用,Claude 也可以调用)
# 不写任何额外配置就行

# 模式2:只有你能调用,Claude 不能主动用
disable-model-invocation: true
# 适合有副作用的操作,比如部署、删除数据

# 模式3:只有 Claude 能调用,你看不到(不显示在 / 菜单)
user-invocable: false
# 适合后台知识,比如解释旧系统怎么工作的

用人话讲:

  • disable-model-invocation: true = 这个技能太危险了,只有我能触发

禁止模型自动调用该技能,仅允许用户手动通过 /skill-name 触发

  • user-invocable: false = 这是 Claude 的私藏知识,不需要当命令用

用户手动不能调用,只能模型自动调用


动态内容注入

Skills 支持用反引号执行命令,把结果塞进技能内容里:

---
name: pr-summary
description: 总结 Pull Request 的内容
---

## PR 信息
- PR 差异:!`gh pr diff`
- PR 评论:!`gh pr view --comments`
- 改动的文件:!`gh pr diff --name-only`

## 你的任务
根据以上信息,生成一份 PR 总结

image.png 比如这里有一个自动生成commit信息的skill

  1. git status - 显示当前工作区的状态(有哪些文件被修改、暂存或未跟踪)
  2. git diff HEAD - 显示 HEAD 指向的提交与工作区之间的差异(已修改但未暂存的内容)
  3. git branch --show-current - 显示当前所在的分支名称
  4. git log --oneline -10 - 显示最近 10 条提交记录,每条只显示一行(哈希值前7位 + 提交信息)

这些命令组合在一起,能快速了解仓库的完整状态:当前分支、有哪些变更、以及近期提交历史。这通常用于提交前的检查或生成变更记录。

!command`` 会在技能内容加载前执行命令,输出结果直接拼进去。Claude 拿到的时候已经是展开后的完整上下文了。


用 subagent 运行技能(隔离执行)

有时候技能执行起来很复杂,你会想把它放到一个独立的子进程里跑,不占用主会话的上下文。

---
name: deep-research
description: 深入研究某个主题
context: fork        # 关键:fork 一个独立子agent
agent: Explore      # 用 Explore 类型
---

深入研究 $ARGUMENTS:
1.  Glob  Grep 找相关文件
2. 读代码,分析逻辑
3. 总结发现,附上具体文件引用
  • context: fork:创建隔离的子对话 / 子 Agent,不污染主上下文

  • agent: Explore:使用探索专用 AI,擅长遍历、搜索、分析项目结构

context: fork 会在一个独立子 agent 里执行这个技能,子 agent 有自己的上下文窗口。适合:

  • 研究任务,需要深度探索
  • 复杂任务,步骤很多
  • 你不想让主会话变乱的时候

技能放在哪?四种作用域

类型 位置 谁能用 场景
企业级 管理员配置 全公司 公司统一规范
个人 ~/.claude/skills/ 只有你 个人工作流
项目 .claude/skills/ 项目成员 团队标准
插件 插件目录 看插件配置 插件附带

团队协作推荐用项目级技能:丢进 .claude/skills/ 目录,commit 到 git,团队成员 pull 下来就能用。


实际使用场景

场景1:每次代码提交都要规范信息

name: commit-helper
description: 帮助写规范的 commit message
---

# Commit Message 助手

## 格式要求

():

[optional body]

[optional footer]


## Type 只能选这些
- feat:新功能
- fix:bug修复
- docs:文档改动
- style:格式(不影响代码)
- refactor:重构
- test:测试
- chore:构建/工具

## 示例
feat(auth): 添加微信登录支持

实现了微信 OAuth2.0 登录流程
- 扫码登录
- token 刷新
- 退出登录

## 你的任务
根据我给的改动,写出符合格式的 commit message

场景2:部署要按流程来,不能出错

name: deploy
description: 部署应用到生产环境
disable-model-invocation: true  # 太危险,Claude 不能自己触发,只能/name,手动触发
---

部署 $ARGUMENTS 到生产环境:

1. 运行测试:`npm test`
2. 构建应用:`npm run build`
3. 推送部署目标
4. 验证部署是否成功
5. 报告部署状态

如果任何步骤失败,立即停止并报告错误。

场景3:品牌调性知识(Claude 自己看)

name: brand-voice
description: 确保输出内容符合品牌调性(Claude 后台使用)
user-invocable: false
---

## 语气要求
- 友好但不随意
- 清晰简洁,不用黑话
- 自信但不傲慢
- 有同理心,理解用户需求

## 写作规范
- 用"你"称呼读者
- 用主动语态
- 句子控制在20字以内
- 先说价值,再说细节

最佳实践

1. 描述要具体,包含触发词

# ❌ 太泛
description: 帮助处理文档

# ✅ 具体,Claude 知道什么时候该用
description: 提取 PDF 中的文字和表格,填写表单,合并文档。
             当你提到 PDF、表单、文档提取时触发。

2. 一个技能做一件事

# ❌ 太宽泛
name: document-helper
description: 处理各种文档相关任务

# ✅ 专注一件事
name: pdf-extractor
description:  PDF 文件提取文字、表格、图片

3. SKILL.md 控制在 500 行以内

详细的检查清单、API 文档放到 templates/references/ 目录,Claude 需要时再加载。

4. 描述要写清楚"什么时候用"

这是 Claude 决定是否触发技能的关键依据。


常见问题

Q: Claude 不触发我的技能怎么办? A: 检查 description 是否包含了用户会说的关键词。描述越具体,触发越准确。

Q: 技能触发太频繁怎么办? A: 把 description 写窄一点,或者加上 disable-model-invocation: true

Q: 安装太多技能会变慢吗? A: 不会。Skills 是渐进式加载,Claude 只在触发时才会加载对应技能的内容。

Q: 技能冲突怎么办? A: 优先级:企业 > 个人 > 项目。同名技能,高优先级生效。


总结

Skills 是什么?提前预置的专业知识包。

为什么有用?不用重复唠叨,Claude 自动调用。

怎么用?

  1. 创建 .claude/skills/<name>/SKILL.md
  2. 写清楚 name 和 description
  3. 描述里加上触发关键词

现在去试试吧,给你常用的工作流建一个技能,你会发现 Claude 突然变得专业多了。


更多资料

重排、重绘、合成:浏览器渲染的“三兄弟”,你惹不起也躲不过

作者 kyriewen
2026年4月12日 12:19

你给一个元素悄悄改了宽度,结果整个页面都抖了一下?你加了个动画,电脑风扇开始狂转?今天我们来认识浏览器渲染里的“三兄弟”——重排、重绘、合成。弄懂它们,你就能写出流畅60帧的页面,告别卡顿。

前言

想象一下,你家客厅要重新装修。你只是换了个抱枕(重绘),很轻松。但如果你要把墙拆了(重排),那得搬家具、砸墙、重新粉刷,累得半死。如果只是把电视画面换个图层(合成),连工人都不要,遥控器一按就行。

浏览器的渲染也是这个道理。理解这三种操作的成本,就能写出性能飞起的页面。

一、先复习:渲染流水线

之前我们讲过,浏览器把HTML/CSS变成屏幕上的像素,要经过:DOM树 + CSSOM树 → 渲染树 → 布局(计算位置大小)→ 绘制(填充像素)→ 合成(合并图层)。

其中:

  • 重排(Reflow):重新计算布局(位置、大小)。成本最高。
  • 重绘(Repaint):重新绘制像素(颜色、背景、阴影等)。成本中等。
  • 合成(Composite):重新合并图层。成本极低(走GPU)。

二、重排:动到筋骨,全员遭殃

什么操作会触发重排?

  • 改变元素的几何属性widthheightmarginpaddingbordertopleft……
  • 改变DOM结构:增删元素、改变内容(文字变了导致高度变化)。
  • 读取某些布局属性offsetTopscrollTopclientWidthgetComputedStyle()。因为浏览器需要返回最新值,不得不强制重排。
  • 改变窗口大小(resize事件)。
  • 激活伪类(如:hover导致样式变化影响布局)。

重排的代价:浏览器要重新计算整个或部分渲染树,然后重新布局、绘制、合成。就像你拆了一面墙,整个房子都得重新量尺寸。

三、重绘:只换皮肤,不动骨架

什么操作会触发重绘但不触发重排?

  • 改变颜色colorbackground-colorborder-colorbox-shadow等。
  • 改变可见性visibility(但display: none会触发重排)。
  • 改变背景图outline等。

重绘的代价:不需要重新布局,但还是要重新绘制像素,比重排轻,但也不是免费。

四、合成:GPU加速的“超车道”

合成是成本最低的环节,因为它不涉及布局和绘制,只把已有的图层合并。能触发合成的属性有:

  • transform(平移、旋转、缩放)
  • opacity
  • filter

当你用transform: translateZ(0)will-change: transform时,浏览器会把这个元素提升到单独的合成层,后续动画只由GPU处理,完全不触发重排和重绘。这就是为什么动画推荐用transform而不是left

/* 差:触发重排 */
.box {
  transition: left 0.3s;
  left: 0;
}
.box:hover {
  left: 100px;
}

/* 好:只触发合成 */
.box {
  transition: transform 0.3s;
  transform: translateX(0);
}
.box:hover {
  transform: translateX(100px);
}

五、如何减少重排和重绘?

1. 批量修改样式

不要挨个改属性,用class一次改完:

// 差
element.style.width = '100px';
element.style.height = '100px';
element.style.margin = '10px';

// 好
element.classList.add('new-size');

2. 让元素脱离文档流再操作

比如要插入多个li,可以先隐藏(display: none),改完再显示,只触发两次重排。

const ul = document.getElementById('list');
ul.style.display = 'none';
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = i;
  ul.appendChild(li);
}
ul.style.display = 'block';

3. 使用文档片段(DocumentFragment)

const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = i;
  fragment.appendChild(li);
}
ul.appendChild(fragment); // 只触发一次重排

4. 读写分离

不要交替读取和修改布局属性,否则会触发多次重排。

// 差
for (let i = 0; i < boxes.length; i++) {
  boxes[i].style.width = boxes[i].offsetWidth + 'px'; // 读后立即写
}

// 好:先读后写
const widths = [];
for (let i = 0; i < boxes.length; i++) {
  widths.push(boxes[i].offsetWidth);
}
for (let i = 0; i < boxes.length; i++) {
  boxes[i].style.width = widths[i] + 'px';
}

5. 使用transformopacity做动画

永远不要用lefttopwidthmargin做动画,改用transform

6. 固定元素位置

position: fixedabsolute的元素,其重排影响范围较小(只在自己层内)。

7. 避免使用table布局

一个小改动可能触发整个table的重排。

六、实战:一个性能优化的例子

假设你要做一个跟随鼠标移动的小光点(类似鼠标特效)。错误做法:每帧改变top/left,触发重排。正确做法:用transform

// 差:每移动1px就重排一次
dot.style.left = x + 'px';
dot.style.top = y + 'px';

// 好:只触发合成
dot.style.transform = `translate(${x}px, ${y}px)`;

七、怎么分析页面重排/重绘?

Chrome DevTools → Performance 面板,录制一段操作,查看“Layout Shift”、“Paint”等标记。红色紫色区域越少越好。

八、总结:三兄弟的“饭量”

  • 重排:吃满汉全席,最贵。动几何、DOM结构。
  • 重绘:吃快餐,中等。动颜色、背景。
  • 合成:喝矿泉水,几乎免费。动transform、opacity。

优化口诀:能用transform别用left,能用class别改style,读写分离,批量操作。

如果你觉得今天的“三兄弟”够形象,点个赞让更多人看到。明天我们将聊聊JavaScript引擎与内存管理,看看V8是怎么给代码“打扫卫生”的。我们明天见!

深入理解 JS 中的栈与堆:从内存模型到数据结构,再谈内存泄漏

作者 往日种种
2026年4月12日 11:09

深入理解 JS 中的栈与堆:从内存模型到数据结构,再谈内存泄漏

在 JavaScript 编程中,“栈”(Stack)和 “堆”(Heap)是两个高频出现的核心概念 —— 它们既贯穿于 JS 的内存管理逻辑,也是计算机科学中经典的数据结构。理清栈与堆的本质区别,不仅能帮助我们理解 JS 代码的执行机制,更能从根源上规避内存泄漏这类影响前端性能的关键问题。

一、JS 内存模型中的栈与堆:内存管理的核心

JS 引擎在运行代码时,会将内存划分为栈和堆两个区域,二者各司其职,共同支撑代码的执行,核心差异体现在存储内容管理方式上。

1. 栈:轻量高效的 “自动管理区”

栈是 JS 引擎为快速执行设计的内存区域,具备 “连续内存、后进先出(LIFO)” 的特性,由系统自动分配和释放,无需垃圾回收(GC)介入,访问速度极快。

  • 存储内容:栈主要存储执行上下文(比如函数调用时的执行环境)、基本数据类型(Number、String、Boolean、Undefined、Null、Symbol、BigInt),以及引用类型的 “引用地址”。
  • 管理逻辑:函数调用时,JS 引擎会为函数创建执行上下文并压入栈中;函数执行结束后,该执行上下文会被立即弹出栈,对应的内存自动释放。这种 “压栈 - 弹栈” 的操作仅需移动内存指针,效率极高。

2. 堆:灵活的 “动态数据区”

堆是用于存储复杂数据的内存区域,内存分配不连续,可能产生内存碎片,访问速度慢于栈,其生命周期由垃圾回收机制(GC)管理(而非系统自动释放)。

  • 存储内容:堆中存放所有引用类型的真实数据,包括对象、数组、函数等。
  • 管理逻辑:当我们创建一个对象(如let obj = {a: 1})时,变量obj会被存入栈中,而对象{a: 1}的真实数据会被存入堆中,栈里的obj实际保存的是指向堆中该对象的引用地址。GC 会通过 “标记清除”(标记可达对象,回收未标记对象)或 “引用计数”(为对象计数,引用数为 0 则回收)的方式,清理堆中无引用的无用数据。

3. 栈与堆的关联:闭包的特殊场景

闭包是栈与堆交互的典型案例 —— 本该在函数执行结束后随栈释放的局部数据(比如函数内的变量),因被内部函数引用,会被转移到堆中持续存在,直到引用关系消失。例如:

function outer() {
    let obj = { a: 1 }; // 本该随outer执行结束释放
    return function inner() {
        console.log(obj); // 闭包引用导致obj被存入堆
    }
}
let fn = outer(); // inner保留对obj的引用,obj无法被回收

二、数据结构视角下的栈与堆:结构与用途的差异

除了内存管理,栈和堆也是两种核心数据结构,其设计逻辑和用途与内存模型中的栈堆截然不同。

1. 栈:线性有序的 “容器”

作为数据结构的栈是线性结构,严格遵循 “后进先出(LIFO)” 原则 —— 只能在栈顶进行元素的插入(压栈)和删除(弹栈)操作,无法随机访问中间元素。这种特性使其适用于函数调用栈、表达式求值、括号匹配等场景。

2. 堆:树状无序的 “优先级结构”

作为数据结构的堆是非线性的树状结构(通常为完全二叉树),无 “后进先出” 的规则,核心特点是 “堆顶元素为最值”:

  • 大顶堆:堆顶元素是整个堆的最大值;
  • 小顶堆:堆顶元素是整个堆的最小值。堆的核心用途是排序(如堆排序)、优先队列实现等,与内存模型中 “存储引用类型” 的堆无直接关联,仅为同名不同概念。

三、内存泄漏:本该释放的内存 “被滞留”

理解了栈与堆的内存管理逻辑,就能更清晰地认识内存泄漏问题 —— 这是前端性能优化的核心痛点,其本质和成因都与 “引用关系” 密切相关。

1. 内存泄漏的本质

内存泄漏的核心本质是:本该被垃圾回收机制回收的内存,因被意外保留了可达引用,导致无法释放,内存占用持续增长。最终会引发页面卡顿、响应缓慢,甚至浏览器崩溃。

判断一个对象是否可被回收,核心标准是 “可达性”:只要对象能被 JS 的根对象(如 window)通过引用链访问到,就会被判定为 “有用”,不会被 GC 回收;反之则会被清理。

2. 内存泄漏的常见成因

在 JS 中,内存泄漏的诱因多与 “不当保留引用” 有关,常见场景包括:

  • 定时器 / 延时器未清理:组件卸载后,定时器未取消,其内部函数会持续引用外部变量,导致变量无法被回收。例如 React 组件中未清除的setInterval,会让组件内的状态始终处于可达状态。
  • 事件监听未移除:给 DOM 元素绑定事件后,未在组件卸载或元素销毁时移除监听,监听函数会持续引用相关变量,造成内存泄漏。
  • DOM 引用未释放:即使移除了 DOM 节点(如document.body.removeChild(el)),若仍保留对该节点的变量引用(el未置为null),该节点仍可达,无法被 GC 回收。
  • 全局变量滥用:意外声明的全局变量(如未加let/const的变量)会挂载到window上,成为根对象的可达引用,永远无法被回收;刻意创建的全局变量若未手动置空,也会长期占用内存。
  • 闭包导致的泄漏:不合理的闭包会让栈中本该释放的局部变量被持久化到堆中,若闭包长期存在(如赋值给全局变量),则变量会一直无法回收。
  • Map/Set 使用不当:普通Map/Set对键 / 值的强引用关系,会导致即使对象本身被置为nullobj = null),只要Map/Set仍引用该对象,就无法被回收;而WeakMap/WeakSet的弱引用特性可避免此问题(对象被回收时,对应的键值对会自动清除)。
  • 订阅 / 发布模式未取消订阅:若未取消对事件总线、状态管理库的订阅,订阅函数会持续引用相关数据,造成泄漏。
  • Promise/Promise 链未结束:Promise 处于pending/rejected未处理状态,或请求发起后组件已卸载但响应未中止(如未使用AbortController终止 API 请求),会导致相关引用无法释放。

总结

栈与堆在 JS 中承担着不同的角色:内存模型中,栈是高效的自动管理区,堆是动态的引用类型存储区;数据结构中,栈是线性 LIFO 结构,堆是树状最值结构。而内存泄漏的本质,是打破了堆内存的 “可达性” 规则 —— 本该被 GC 清理的对象,因意外的引用关系始终处于可达状态。

理清栈、堆的内存逻辑,规避不当的引用行为(如及时清理定时器、移除监听、使用弱引用容器等),是写出高性能、低内存泄漏风险 JS 代码的核心前提。

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

作者 Wect
2026年4月12日 09:56

在 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」,能覆盖所有场景,简洁又高效。

昨天以前首页

nslookup Command in Linux: Query DNS Records

When a website does not load or email stops arriving, the first thing to check is whether the domain resolves to the correct address. The nslookup command is a quick way to query DNS servers and inspect the records behind a domain name.

nslookup ships with most Linux distributions and works on macOS and Windows as well. It supports both one-off queries from the command line and an interactive mode for running multiple lookups in a row.

This guide explains how to use nslookup with practical examples covering record types, reverse lookups, and troubleshooting.

Syntax

txt
nslookup [OPTIONS] [NAME] [SERVER]
  • NAME — The domain name or IP address to look up.
  • SERVER — The DNS server to query. If omitted, nslookup uses the server configured in /etc/resolv.conf.
  • OPTIONS — Query options such as -type=MX or -debug.

When called without arguments, nslookup starts in interactive mode.

Installing nslookup

On most distributions nslookup is already installed. To check, run:

Terminal
nslookup -version

If the command is not found, install it using your distribution’s package manager.

Install nslookup on Ubuntu, Debian, and Derivatives

Terminal
sudo apt update && sudo apt install dnsutils

Install nslookup on Fedora, RHEL, and Derivatives

Terminal
sudo dnf install bind-utils

Install nslookup on Arch Linux

Terminal
sudo pacman -S bind

The nslookup command is bundled with the same packages that provide dig .

Look Up a Domain Name

The simplest use is passing a domain name as an argument:

Terminal
nslookup linux.org
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
Name: linux.org
Address: 104.26.14.72
Name: linux.org
Address: 104.26.15.72
Name: linux.org
Address: 172.67.73.26

The first two lines show the DNS server that answered the query. Everything under “Non-authoritative answer” is the actual result. In this case, linux.org resolves to three IPv4 addresses.

“Non-authoritative” means the answer came from a resolver’s cache rather than directly from the domain’s authoritative name server.

Query a Specific DNS Server

By default, nslookup queries the resolver configured in /etc/resolv.conf. To query a different server, add it as the last argument.

For example, to query Google’s public DNS:

Terminal
nslookup linux.org 8.8.8.8
output
Server: 8.8.8.8
Address: 8.8.8.8#53
Non-authoritative answer:
Name: linux.org
Address: 104.26.14.72
Name: linux.org
Address: 104.26.15.72
Name: linux.org
Address: 172.67.73.26

This is useful when you want to compare results across different resolvers or verify whether a DNS change has propagated to public servers.

Query Record Types

By default, nslookup returns A (IPv4 address) records. Use the -type option to query other record types.

MX Records (Mail Servers)

MX records identify the mail servers responsible for receiving email for a domain:

Terminal
nslookup -type=mx google.com
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
google.com mail exchanger = 10 smtp.google.com.

The number before the mail server hostname is the priority. A lower number means higher priority.

NS Records (Name Servers)

NS records show which name servers are authoritative for a domain:

Terminal
nslookup -type=ns google.com
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
google.com nameserver = ns1.google.com.
google.com nameserver = ns2.google.com.
google.com nameserver = ns3.google.com.
google.com nameserver = ns4.google.com.

TXT Records

TXT records store arbitrary text data, commonly used for SPF, DKIM, and domain ownership verification:

Terminal
nslookup -type=txt google.com
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
google.com text = "v=spf1 include:_spf.google.com ~all"
google.com text = "facebook-domain-verification=22rm551cu4k0ab0bxsw536tlds4h95"
google.com text = "docusign=05958488-4752-4ef2-95eb-aa7ba8a3bd0e"

The output may include many entries. The example above shows a subset of the TXT records returned for google.com.

AAAA Records (IPv6)

AAAA records return the IPv6 address of a domain:

Terminal
nslookup -type=aaaa google.com
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
Name: google.com
Address: 2a00:1450:4017:818::200e

SOA Record (Start of Authority)

The SOA record contains administrative information about the domain, including the primary name server, the responsible email address, and timing parameters for zone transfers:

Terminal
nslookup -type=soa google.com
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
google.com
origin = ns1.google.com
mail addr = dns-admin.google.com
serial = 897592583
refresh = 900
retry = 900
expire = 1800
minimum = 60

The serial number increments each time the zone is updated. DNS secondaries use it to decide whether they need a zone transfer.

CNAME Records

CNAME records point one domain name to another:

Terminal
nslookup -type=cname www.github.com

If a CNAME record exists, the output shows the canonical name the alias points to. If the domain does not have a CNAME record, nslookup returns No answer.

Run an ANY Query

To ask the DNS server for an ANY response, use -type=any:

Terminal
nslookup -type=any google.com

ANY queries do not reliably return every record type for a domain. Many DNS servers return only a subset of records or refuse the query entirely.

Reverse DNS Lookup

A reverse lookup finds the hostname associated with an IP address. Pass an IP address instead of a domain name:

Terminal
nslookup 208.118.235.148
output
148.235.118.208.in-addr.arpa name = ip-208-118-235-148.twdx.net.

Reverse lookups query PTR records. They are useful for verifying that an IP address maps back to the expected hostname, which matters for mail server configuration and security checks.

Interactive Mode

Running nslookup without arguments starts an interactive session where you can run multiple queries without retyping the command:

Terminal
nslookup
output
>

At the > prompt, type a domain name to look it up:

output
> linux.org
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
Name: linux.org
Address: 104.26.14.72
Name: linux.org
Address: 104.26.15.72

You can change query settings during the session with the set command. For example, to switch to MX record lookups and then query a domain:

output
> set type=mx
> google.com
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
google.com mail exchanger = 10 smtp.google.com.

To change the DNS server:

output
> server 8.8.8.8
Default server: 8.8.8.8
Address: 8.8.8.8#53

Type exit to leave interactive mode.

Interactive mode is convenient when you need to test several domains or record types in a row without running separate commands each time.

Debugging DNS Issues

The -debug option shows the full query and response details, including TTL values and additional sections that nslookup normally hides:

Terminal
nslookup -debug linux.org

The debug output is verbose, but it is helpful when you need to see TTL values, check whether answers are authoritative, or trace unexpected behavior.

nslookup vs dig

Both nslookup and dig query DNS servers, but they differ in output and capabilities:

  • nslookup produces simpler, more readable output. It also has an interactive mode that is convenient for quick checks.
  • dig provides detailed, structured output with sections (QUESTION, ANSWER, AUTHORITY, ADDITIONAL) and supports advanced options like +trace for tracing the full resolution path and +dnssec for verifying DNSSEC signatures.

For quick lookups and basic troubleshooting, nslookup is often faster to type and read. For in-depth DNS debugging, dig gives you more control and detail.

Troubleshooting

nslookup returns NXDOMAIN
The domain does not exist or is misspelled. Verify the domain name and check that it is registered.

nslookup returns SERVFAIL
The DNS server could not process the query. Try a different resolver to isolate the problem:

Terminal
nslookup linux.org 1.1.1.1

If public resolvers return the correct answer, the issue is with your configured resolver.

Connection timed out; no servers could be reached
This means nslookup could not contact the DNS server. Check your network connection and verify that /etc/resolv.conf contains a reachable name server. A firewall may also be blocking outbound DNS traffic on port 53.

Non-authoritative answer appears on every query
This is normal. It means the answer came from a resolver’s cache, not directly from the domain’s authoritative server. The result is still valid.

Quick Reference

For a printable quick reference, see the nslookup cheatsheet .

Task Command
Look up a domain nslookup example.com
Query a specific DNS server nslookup example.com 8.8.8.8
Query MX records nslookup -type=mx example.com
Query NS records nslookup -type=ns example.com
Query TXT records nslookup -type=txt example.com
Query AAAA (IPv6) records nslookup -type=aaaa example.com
Query SOA record nslookup -type=soa example.com
Query CNAME record nslookup -type=cname example.com
Run an ANY query nslookup -type=any example.com
Reverse DNS lookup nslookup 192.0.2.1
Start interactive mode nslookup
Enable debug output nslookup -debug example.com

FAQ

Can I use nslookup to check DNS propagation?
Yes. Query the same domain against several public DNS servers and compare the results. For example, run nslookup example.com 8.8.8.8, nslookup example.com 1.1.1.1, and nslookup example.com 9.9.9.9. If the answers differ, the change has not fully propagated.

Is nslookup deprecated?
The ISC (the organization behind BIND) once marked nslookup as deprecated in favor of dig, but later reversed that decision. nslookup is actively maintained and included in current BIND releases. It remains a practical tool for quick DNS lookups.

What does “Non-authoritative answer” mean?
It means the response came from a caching resolver, not from one of the domain’s authoritative name servers. The data is still accurate, but it may be slightly behind if a DNS change was made very recently and the cache has not expired yet.

Conclusion

The nslookup command is a quick way to query DNS records from the command line. Use -type to look up MX, NS, TXT, AAAA, and other record types, and pass a server argument to test against a specific resolver. For deeper DNS debugging, pair it with dig .

从零到一:在React前端中集成The Graph查询Uniswap V3池数据实战

作者 竹林818
2026年4月11日 18:02

从零到一:在React前端中集成The Graph查询Uniswap V3池数据实战

背景

上个月,我接手了一个DeFi收益聚合器项目的前端开发。产品经理提了一个需求:要在仪表盘首页展示用户可能感兴趣的几个热门Uniswap V3流动性池的实时数据,包括24小时交易量、总流动性和当前手续费率。

我的第一反应是:“简单,直接用 ethers.jsviem 去读合约的 public 变量和事件不就行了?” 于是,我吭哧吭哧写了段代码,通过 useEffect 轮询调用池子合约的 slot0 函数获取当前价格,再通过 provider.getLogs 拉取最近24小时的 Swap 事件来计算交易量。本地测试时,面对一个池子还好。一上线,用户钱包里要是多几个池子,页面直接卡死,RPC调用次数爆炸,速度慢得让人想砸键盘。我意识到,对于这种需要聚合和分析历史链上数据的场景,直接与节点交互是条死路。这时,我想起了那个听过很多次但一直没亲手用过的工具——The Graph。

问题分析

The Graph 的核心是一个去中心化的索引协议,它监听区块链事件,将数据按照定义好的模式(Subgraph)处理后存入可高效查询的数据库。对于前端来说,我们不用再关心如何从海量事件日志里筛选和计算,只需要像调用API一样,用GraphQL查询语句去获取已经处理好的结构化数据。

我的需求很明确:查询Uniswap V3在以太坊主网上特定池子的聚合数据。理论上,我不需要自己部署Subgraph,因为Uniswap官方已经维护了一个非常完善的 Uniswap V3 Subgraph。我的任务就是在前端React应用中,学会如何与这个已部署的Subgraph进行交互。

最初的尝试是直接用 fetchaxios 向Subgraph的GraphQL端点发送POST请求。这确实能跑通,但很快遇到了问题:1. 需要手动管理查询字符串和变量,容易出错;2. 缺乏类型安全,返回的数据结构全靠猜;3. 没有内置的请求状态(loading, error)管理,需要自己用useState和useEffect封装,很繁琐。我需要一个更“React”的、类型友好的解决方案。

核心实现

第一步:环境搭建与GraphQL客户端选择

首先,我创建了一个新的React + TypeScript项目(或者在你的现有项目中操作)。关键的依赖是 @apollo/clientgraphql。Apollo Client 是一个强大的GraphQL状态管理库,它提供了React Hook(如 useQuery)、缓存、错误处理等开箱即用的功能,能极大简化前端与The Graph的交互。

npm install @apollo/client graphql

接下来,我需要初始化Apollo Client,并配置其连接到Uniswap V3的Hosted Service端点。

这里有个坑:The Graph的Hosted Service端点URL结构是 https://api.thegraph.com/subgraphs/name/<用户名>/<子图名称>。对于Uniswap V3以太坊主网,用户名是 uniswap,子图名称是 uniswap-v3。千万别去官方文档里找“API Key”,Hosted Service在查询限额内是免费的,直接使用即可。

我创建了一个文件 lib/apolloClient.ts 来配置客户端:

// lib/apolloClient.ts
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';

// Uniswap V3 以太坊主网 Subgraph 端点
const UNISWAP_V3_GRAPH_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';

const httpLink = new HttpLink({
  uri: UNISWAP_V3_GRAPH_ENDPOINT,
});

// 创建 Apollo Client 实例
// 注意:默认缓存策略可能不适合实时性极高的数据,对于交易量等数据可以考虑调整fetchPolicy
export const apolloClient = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network', // 优先返回缓存,同时在后台更新
    },
    query: {
      fetchPolicy: 'network-only', // 对于主动查询,总是从网络获取
    },
  },
});

第二步:编写GraphQL查询并生成类型

这是核心步骤。我需要去 The Graph Explorer 找到 uniswap/uniswap-v3 子图,研究其数据模式(Schema)。我需要的池子(Pool)数据,在Schema中对应 Pool 实体,里面包含了 id(合约地址)、totalValueLockedUSDvolumeUSDfeesUSDtoken0token1 等字段。

为了获取24小时数据,子图通常会有类似 poolDayData 的时间序列实体。经过探索,我发现查询最近24小时数据的最佳方式是:先查询 Pool 实体本身获取当前快照数据(如TVL),再关联查询其最新的 poolDayData(按日期排序取第一条)来获取过去24小时的交易量和手续费。

我创建了一个GraphQL查询文件 queries/poolData.graphql

# queries/poolData.graphql
query PoolData($poolId: String!) {
  # 查询池子基础信息
  pool(id: $poolId) {
    id
    totalValueLockedUSD
    feeTier
    token0 {
      id
      symbol
      decimals
    }
    token1 {
      id
      symbol
      decimals
    }
    # 关联查询最近的日数据(过去24小时)
    poolDayData(first: 1, orderBy: date, orderDirection: desc) {
      volumeUSD
      feesUSD
      date
    }
  }
}

注意这个细节$poolId 是池子的合约地址,但在The Graph中,id 字段通常是全小写的地址字符串。所以从链上获取的地址,在传入查询变量前最好先 .toLowerCase() 处理一下,避免查不到数据。

接下来,为了让TypeScript认识查询返回的数据结构,我使用GraphQL Code Generator来自动生成类型。这需要额外配置,但一劳永逸。简单起见,我也可以手动定义类型,但对于复杂查询,自动生成更可靠。这里我展示手动定义的方式,更贴近快速上手的场景:

// types/poolData.ts
export interface Token {
  id: string;
  symbol: string;
  decimals: string;
}

export interface PoolDayData {
  volumeUSD: string;
  feesUSD: string;
  date: number;
}

export interface PoolData {
  id: string;
  totalValueLockedUSD: string;
  feeTier: string;
  token0: Token;
  token1: Token;
  poolDayData: PoolDayData[];
}

export interface GraphQLPoolResponse {
  pool: PoolData | null;
}

第三步:创建自定义React Hook

为了让数据获取逻辑可以在组件中优雅复用,我决定将其封装成一个自定义Hook:usePoolData

// hooks/usePoolData.ts
import { useQuery, gql } from '@apollo/client';
import { GraphQLPoolResponse } from '../types/poolData';

// 直接在Hook中定义GraphQL查询,避免额外文件
// 注意:gql`...` 是Apollo Client的模板标签函数,用于解析GraphQL查询字符串
const POOL_DATA_QUERY = gql`
  query PoolData($poolId: String!) {
    pool(id: $poolId) {
      id
      totalValueLockedUSD
      feeTier
      token0 {
        id
        symbol
        decimals
      }
      token1 {
        id
        symbol
        decimals
      }
      poolDayData(first: 1, orderBy: date, orderDirection: desc) {
        volumeUSD
        feesUSD
        date
      }
    }
  }
`;

interface UsePoolDataProps {
  poolId: string | undefined; // 池子合约地址
  skip?: boolean; // 是否跳过查询
}

export const usePoolData = ({ poolId, skip = false }: UsePoolDataProps) => {
  // 使用 useQuery Hook
  // 它自动处理 loading, error 状态,并返回 data
  const { loading, error, data, refetch } = useQuery<GraphQLPoolResponse>(
    POOL_DATA_QUERY,
    {
      variables: {
        poolId: poolId?.toLowerCase(), // 关键:地址转小写
      },
      skip: !poolId || skip, // 如果没有poolId或主动跳过,则不执行查询
      // fetchPolicy: 'network-only' // 可以根据需要覆盖默认策略
    }
  );

  // 对返回的数据进行简单处理和类型断言
  const poolData = data?.pool;

  return {
    loading,
    error,
    poolData,
    refetch, // 用于手动刷新数据
  };
};

这个Hook的设计非常“React”:它接收依赖项(poolId),管理内部状态,并返回一个清晰的状态对象。在组件中使用时,我可以轻松地根据 loading 显示加载框,根据 error 显示错误信息,用 poolData 渲染UI。

第四步:在组件中集成并使用

最后,我在一个React组件中使用这个Hook。假设我要显示USDC/ETH 0.05%费率的池子(一个非常常见的池)。

// components/PoolCard.tsx
import React from 'react';
import { usePoolData } from '../hooks/usePoolData';

// 已知的 Uniswap V3 USDC/ETH 0.05% 池地址(以太坊主网)
const USDC_ETH_POOL_ADDRESS = '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640';

const PoolCard: React.FC = () => {
  const { loading, error, poolData } = usePoolData({
    poolId: USDC_ETH_POOL_ADDRESS,
  });

  if (loading) {
    return <div className="p-4 border rounded-lg">加载池数据中...</div>;
  }

  if (error) {
    return (
      <div className="p-4 border rounded-lg bg-red-50 text-red-700">
        查询失败: {error.message}
      </div>
    );
  }

  if (!poolData) {
    return <div className="p-4 border rounded-lg">未找到池子数据</div>;
  }

  const dailyVolume = poolData.poolDayData[0]?.volumeUSD || '0';
  const tvl = poolData.totalValueLockedUSD;

  return (
    <div className="p-4 border rounded-lg shadow-sm bg-white">
      <h3 className="font-bold text-lg">
        {poolData.token0.symbol} / {poolData.token1.symbol} Pool
      </h3>
      <p className="text-sm text-gray-500">费率: {Number(poolData.feeTier) / 10000}%</p>
      <div className="mt-3 space-y-2">
        <div>
          <span className="text-gray-600">总锁定价值 (TVL): </span>
          <span className="font-semibold">
            ${Number(tvl).toLocaleString(undefined, { maximumFractionDigits: 0 })}
          </span>
        </div>
        <div>
          <span className="text-gray-600">24小时交易量: </span>
          <span className="font-semibold">
            ${Number(dailyVolume).toLocaleString(undefined, { maximumFractionDigits: 0 })}
          </span>
        </div>
        <div className="text-xs text-gray-400">
          池地址: {poolData.id}
        </div>
      </div>
    </div>
  );
};

export default PoolCard;

至此,一个完整的、从The Graph获取Uniswap V3池数据并展示的前端功能就实现了。代码清晰、类型安全、且易于维护和扩展。

完整代码

以下是关键文件的完整代码汇总,你可以复制到一个新的React + TypeScript项目中运行测试:

1. 安装依赖:

npx create-react-app my-graph-demo --template typescript
cd my-graph-demo
npm install @apollo/client graphql

2. 配置 Apollo Client (src/lib/apolloClient.ts):

import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';

const UNISWAP_V3_GRAPH_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';

const httpLink = new HttpLink({
  uri: UNISWAP_V3_GRAPH_ENDPOINT,
});

export const apolloClient = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
    },
    query: {
      fetchPolicy: 'network-only',
    },
  },
});

3. 定义类型 (src/types/poolData.ts):

export interface Token {
  id: string;
  symbol: string;
  decimals: string;
}

export interface PoolDayData {
  volumeUSD: string;
  feesUSD: string;
  date: number;
}

export interface PoolData {
  id: string;
  totalValueLockedUSD: string;
  feeTier: string;
  token0: Token;
  token1: Token;
  poolDayData: PoolDayData[];
}

export interface GraphQLPoolResponse {
  pool: PoolData | null;
}

4. 创建自定义Hook (src/hooks/usePoolData.ts):

import { useQuery, gql } from '@apollo/client';
import { GraphQLPoolResponse } from '../types/poolData';

const POOL_DATA_QUERY = gql`
  query PoolData($poolId: String!) {
    pool(id: $poolId) {
      id
      totalValueLockedUSD
      feeTier
      token0 {
        id
        symbol
        decimals
      }
      token1 {
        id
        symbol
        decimals
      }
      poolDayData(first: 1, orderBy: date, orderDirection: desc) {
        volumeUSD
        feesUSD
        date
      }
    }
  }
`;

interface UsePoolDataProps {
  poolId: string | undefined;
  skip?: boolean;
}

export const usePoolData = ({ poolId, skip = false }: UsePoolDataProps) => {
  const { loading, error, data, refetch } = useQuery<GraphQLPoolResponse>(
    POOL_DATA_QUERY,
    {
      variables: {
        poolId: poolId?.toLowerCase(),
      },
      skip: !poolId || skip,
    }
  );

  const poolData = data?.pool;

  return {
    loading,
    error,
    poolData,
    refetch,
  };
};

5. 创建展示组件 (src/components/PoolCard.tsx):

import React from 'react';
import { usePoolData } from '../hooks/usePoolData';

const USDC_ETH_POOL_ADDRESS = '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640';

const PoolCard: React.FC = () => {
  const { loading, error, poolData } = usePoolData({
    poolId: USDC_ETH_POOL_ADDRESS,
  });

  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误: {error.message}</div>;
  if (!poolData) return <div>无数据</div>;

  const dailyVolume = poolData.poolDayData[0]?.volumeUSD || '0';
  const tvl = poolData.totalValueLockedUSD;

  return (
    <div style={{ border: '1px solid #ccc', padding: '1rem', borderRadius: '8px' }}>
      <h3>{poolData.token0.symbol} / {poolData.token1.symbol} Pool</h3>
      <p>费率: {Number(poolData.feeTier) / 10000}%</p>
      <div>
        <div>TVL: ${Number(tvl).toLocaleString()}</div>
        <div>24h Volume: ${Number(dailyVolume).toLocaleString()}</div>
      </div>
      <small>地址: {poolData.id}</small>
    </div>
  );
};

export default PoolCard;

6. 在应用入口集成 (src/App.tsx):

import React from 'react';
import { ApolloProvider } from '@apollo/client';
import { apolloClient } from './lib/apolloClient';
import PoolCard from './components/PoolCard';
import './App.css';

function App() {
  return (
    <ApolloProvider client={apolloClient}>
      <div className="App">
        <h1>Uniswap V3 池数据看板 (The Graph)</h1>
        <PoolCard />
        {/* 可以在这里添加更多 PoolCard,传入不同的 poolId */}
      </div>
    </ApolloProvider>
  );
}

export default App;

运行 npm start,你应该能看到一个显示USDC/ETH池数据的卡片。

踩坑记录

  1. “池子找不到 (Pool not found)”:这是我遇到的第一个也是最多人踩的坑。我确认地址没错,但查询返回 null。后来在The Graph的Discord社区提问才知道,Subgraph中存储的地址 id 字段全是小写。而我从链上或Etherscan复制的地址可能是大小写混合的校验和格式。解决方法:在将地址作为变量传入查询前,务必执行 .toLowerCase()

  2. 查询超时或响应慢:第一次查询一个不常被查询的冷门池子时,可能会遇到响应时间较长的情况。这是因为The Graph的索引器需要为这次查询执行索引工作。解决方法:对于用户体验要求高的场景,前端要做好加载状态提示。另外,可以检查Subgraph的健康状态,有时是公共端点负载问题。

  3. 数据类型不匹配:GraphQL查询返回的数字,即使是 BigInt 在Subgraph中,通过API返回时也是字符串格式。直接用于计算会出错。解决方法:在前端使用前,用 Number()parseFloat() 或更适合大数的库如 BigNumber.js (ethers.js自带) 进行转换。我的示例中用了 Number(),对于TVL和交易量这种可能很大的数,在生产环境中建议使用 ethers.BigNumberBigInt 来处理。

  4. “Cannot read property ‘symbol’ of null”:在测试时,我传了一个非Uniswap V3池的地址,查询返回的 pool 不为 null,但内部的 token0token1 可能为 null(如果子图索引不完整)。解决方法:在组件渲染中使用可选链操作符 ?. 或进行严格的空值检查,就像我在示例中处理 poolDayData[0] 一样。

小结

这次实战让我彻底把The Graph从“听说过”变成了“上手用过”。它的核心价值在于将复杂的链上数据索引、聚合工作从前端剥离,让开发者能像查询普通API一样高效获取结构化数据。对于构建数据驱动的DeFi、NFT应用前端,它几乎是必备工具。下一步,我可以探索更复杂的查询(如分页获取多个池子、历史时间序列分析),甚至尝试为自己项目的合约部署一个专属的Subgraph。

VSCode 插件推荐 Copy Filename Pro,快速复制文件、目录和路径的首选

2026年4月11日 12:30

大家好,我是笨笨狗吞噬者,uni-app、varlet、nrm 等众多知名仓库的核心开发,专注于分享 前端技术 和 AI 实践知识,欢迎关注我的微信公众号 前端笨笨狗,或者加我的微信 wxid_olsjlzuh4ivf22 沟通交流!

问题背景

大家平时写代码时,经常会遇到一些很碎的小动作,比如:

  • 想快速拿到组件名、页面名、模块名
  • 想复制路径写 import、写文档或者发消息
  • 想批量整理多个文件名或路径

这些操作不难,但做得多了就很烦,尤其是在目录结构比较复杂的工程里。我也被这类问题折腾了很久,试了插件市场里的很多插件,却总不如意,于是,我就自己写了插件 Copy Filename Pro

插件功能

Copy Filename Pro 主要提供下面几个功能:

复制带文件后缀的文件名

比如我想复制某个 vue 文件的完整文件名

with-filename.gif

复制不带扩展名的文件名

比如我想复制某个 vue 文件的不包含文件后缀的文件名

no-filename.gif

复制目录名

比如我想复制某个文件夹名称

dictory.gif

复制不带拓展名的绝对路径或者相对路径

由于 VSCode 本身有复制路径和相对路径的功能了,所以这里演示如何得到不包含文件后缀的路径

path.gif

一次复制多个文件或目录的信息

比如我想一次复制多个文件名

mul.gif

下载安装

大家可以参考下面的图片安装此插件

zhinan.png

另外,此插件的源码是完全免费公开的,访问 https://github.com/chouchouji/copy-filename-pro 即可获取,如果你有更好的想法和建议,也可以留言给我。

RN中如何处理权限申请(相机、相册、定位、存储)?使用第三方库还是原生封装?

作者 光影少年
2026年4月11日 12:02

在 React Native(RN)里处理权限申请,本质上有两条路:

一、推荐方案:使用第三方库(更省心 ✅)

最主流的是 👉 react-native-permissions

为什么推荐它?

  • 一套 API 统一处理 iOS + Android
  • 覆盖:相机 / 相册 / 定位 / 麦克风 / 通讯录等
  • 自动处理不同系统版本差异(特别是 Android 10+、13+)

安装

yarn add react-native-permissions

iOS 还需要:

cd ios && pod install

基本用法(以相机为例)

import {request, PERMISSIONS, RESULTS} from 'react-native-permissions';
import {Platform} from 'react-native';

export async function requestCameraPermission() {
  const permission =
    Platform.OS === 'ios'
      ? PERMISSIONS.IOS.CAMERA
      : PERMISSIONS.ANDROID.CAMERA;

  const result = await request(permission);

  switch (result) {
    case RESULTS.GRANTED:
      console.log('已授权');
      break;
    case RESULTS.DENIED:
      console.log('用户拒绝');
      break;
    case RESULTS.BLOCKED:
      console.log('被永久拒绝,需要引导去设置页');
      break;
  }
}

常见权限对应表

功能 iOS Android
相机 CAMERA CAMERA
相册 PHOTO_LIBRARY READ_MEDIA_IMAGES(Android 13+)
定位 LOCATION_WHEN_IN_USE ACCESS_FINE_LOCATION
存储 自动 READ/WRITE_EXTERNAL_STORAGE(已逐步废弃)

跳转系统设置页(很重要)

import {openSettings} from 'react-native-permissions';

openSettings();

二、官方原生 API(不推荐做主方案 ❌)

RN 自带:

👉 PermissionsAndroid(仅 Android)

import {PermissionsAndroid} from 'react-native';

await PermissionsAndroid.request(
  PermissionsAndroid.PERMISSIONS.CAMERA
);

问题:

  • ❌ iOS 不支持(需要自己写 Native)
  • ❌ Android 版本适配麻烦(13+权限拆分)
  • ❌ 代码分散,难维护

三、什么时候用“原生封装”?🤔

只有这些情况才建议:

✅ 场景

  • 需要深度定制(比如蓝牙、后台定位)
  • 使用原生 SDK(高德 / 百度定位)
  • 公司有统一权限中间层

❌ 不建议

  • 普通业务(拍照、选图、定位)
  • 中小项目

四、最佳实践(很关键🔥)

1️⃣ 封装统一权限工具

// permission.ts
export async function requestPermission(type: 'camera' | 'photo' | 'location') {
  // 内部统一处理
}

👉 避免业务代码到处写权限逻辑


2️⃣ 权限申请时机

不要一进 App 就申请 ❌
👉 要“用到时再申请” ✅

例如:

  • 点击“拍照” → 再申请相机权限
  • 点击“上传头像” → 再申请相册

3️⃣ 权限被拒绝的处理

if (result === RESULTS.BLOCKED) {
  Alert.alert(
    '需要权限',
    '请前往设置开启权限',
    [
      {text: '取消'},
      {text: '去设置', onPress: openSettings}
    ]
  );
}

4️⃣ Android 13+ 注意点 ⚠️

存储权限拆分为:

  • READ_MEDIA_IMAGES
  • READ_MEDIA_VIDEO
  • READ_MEDIA_AUDIO

👉 用旧的 READ_EXTERNAL_STORAGE 会失效


五、总结(给你一个明确建议)

👉 90% 场景建议:

  • 用 👉 react-native-permissions

能够插入 DOM 的输入框

2026年4月11日 11:00

简易富文本编辑器

使用input、textarea 这种输入框会出现一个问题,就是无法在其中写入 DOM 结构,浏览器不会把 DOM 进行渲染,这样的话在某些情况下使用他们只会浪费时间,复制粘贴半天,发现没办法放 UI 内容,无敌了孩子。

如果你的内容需要很多操作可以选择去使用富文本编辑器,这里就说一下怎么写一个简单的富文本编辑器。

     <div
        id="editor"
        contenteditable="true" // 赋予容器可编辑的能力
        ref="editorRef"
      ></div>

只要是 DOM 能放的结构,他都可以。

他也有一些缺点,就是没有input简便,好写,而且它只有一部分 input 对应的方法, 比如以下常见方法:

  • input
  • paste
  • blur、focus
  • keydown、keyup

如何插入 DOM(组件) 和文本

插入 DOM

const textNode = document.createTextNode(featureData.description); // 创建文本
const placeholder = document.createElement('span'); // 创建节点
placeholder.contentEditable = false; // 不可编辑
// 变量记录文本节点
featureData.lastTextNode = textNode;
featureData.lastTagHolder = placeholder; 
// 在编辑器最前方进行插入
editor.insertBefore(textNode, editor.firstChild);
editor.insertBefore(placeholder, editor.firstChild);

在vue的程序里面想要在普通函数中动态创建、挂载、操作组件可以通过vue提供的createApp去创建vue的节点

const app = createApp({
    render: () =>
      h(Tag, {
        text: featureData.title, // 组件 props 
        bgColor: featureData.bgColor, // 组件 props 
        onClose: () => {
          featureData.lastApp?.unmount();
          featureData.lastApp = null;
          featureData.lastTextNode?.remove();
          featureData.lastTagHolder?.remove();
          featureData.lastTextNode = null;
          featureData.lastTagHolder = null;
        },
      }),
  });
  app.mount(placeholder);
  featureData.lastApp = app; // 记录app实例进行卸载

h 函数

用于创建虚拟节点,可以渲染多个/嵌套/动态结构。

  1. 渲染组件 vnode 时 children 参数需要通过插槽函数书写,可以通过设置props为null避免将插槽识别为props。
  2. 渲染为 html 的节点 children 可以随意文本或者数组传递多个节点。
function h(
  type: string | Component,
  props?: object | null,
  children?: Children | Slot | Slots   // 为组件时需要通过插槽函数
): VNode

h( 
    组件 / 标签名, 
    属性、props、事件, 
    子节点/内容            // 子节点不是插槽就可以省略 props 书写
)
// 多个节点
h(
    'div'
    null,
    [
        h('div','文字') 
    ]
)
// 动态结构
h('div', isShow ? h(Tag) : h('span', '无标签') )

// 组件插槽传递 vnode
h(Components,null,{default:()=>'你的内容'})// 默认插槽

// html节点
h('div',null,['文字', h('span', '内容')])

鼠标选中区域

可以通过选中区域对文本区域进行记录,选中区域内容、获取选区范围等等,可以用于加粗、添加标题。

// 创建鼠标选区
  const range = document.createRange();
  // 设定鼠标选中区域
  range.setStartAfter(textNode); // 在 textNode 后面开始
  range.setEndAfter(textNode);   // 在 textNode 后面结束
  // 获取选区管理
  const sel = window.getSelection();
  // 获取选中文字
  const selectedText = sel.toString()
  // 获取第一个选区
  const range = sel.getRangeAt(0)
  // 移除先前选区
  sel.removeAllRanges();
  // 记录当前鼠标选区
  sel.addRange(range);

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

作者 Wect
2026年4月11日 10:33

在 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 机制),这样才能灵活运用到实际业务中,甚至根据需求修改优化。建议结合示例代码亲手运行,感受每个细节的作用,加深理解。

【JS进阶】模拟正确处理并渲染后台数据

作者 vmiao
2026年4月11日 09:54

一、案例展示

js进阶第二天.png

二、部分数据展示

     const goodsList = [
            {
                id: '4001172',
                name: '称心如意手摇咖啡磨豆机咖啡豆研磨机',
                price: 289.9,
                picture: 'https://yanxuan-item.nosdn.127.net/84a59ff9c58a77032564e61f716846d6.jpg',
                count: 2,
                spec: { color: '白色' }
            },
            {
                id: '4001009',
                name: '竹制干泡茶盘正方形沥水茶台品茶盘',
                price: 109.8,
                picture: 'https://yanxuan-item.nosdn.127.net/2d942d6bc94f1e230763e1a5a3b379e1.png',
                count: 3,
                spec: { size: '40cm*40cm', color: '黑色' }
            },
     ]
  • ① 处为spec属性,是一个对象,在渲染时需要转换成字符串的形式
  • ② ④处为price和.amount模块中数据,需要保留两位小数
  • ③ 处为gift属性,渲染时要先判断是否有该属性,初始是字符串类型

三、前置知识点

1.数组转换为字符串方法:join();字符串转换为数组的方法:split()
2.累加器,用于数组求和的方法:reduce()
3.对象解构
4.模板字符串的使用

四、练习素材提供

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        .list {
            width: 990px;
            margin: 100px auto 0;
        }

        .item {
            padding: 15px;
            transition: all .5s;
            display: flex;
            border-top: 1px solid #e4e4e4;
        }

        .item:nth-child(4n) {
            margin-left: 0;
        }

        .item:hover {
            cursor: pointer;
            background-color: #f5f5f5;
        }

        .item img {
            width: 80px;
            height: 80px;
            margin-right: 10px;
        }

        .item .name {
            font-size: 18px;
            margin-right: 10px;
            color: #333;
            flex: 2;
        }

        .item .name .tag {
            display: block;
            padding: 2px;
            font-size: 12px;
            color: #999;
        }

        .item .price,
        .item .sub-total {
            font-size: 18px;
            color: firebrick;
            flex: 1;
        }

        .item .price::before,
        .item .sub-total::before,
        .amount::before {
            content: "¥";
            font-size: 12px;
        }

        .item .spec {
            flex: 2;
            color: #888;
            font-size: 14px;
        }

        .item .count {
            flex: 1;
            color: #aaa;
        }

        .total {
            width: 990px;
            margin: 0 auto;
            display: flex;
            justify-content: flex-end;
            border-top: 1px solid #e4e4e4;
            padding: 20px;
        }

        .total .amount {
            font-size: 18px;
            color: firebrick;
            font-weight: bold;
            margin-right: 50px;
        }
    </style>
</head>

<body>
    <div class="list">
        <!-- <div class="item">
      <img src="https://yanxuan-item.nosdn.127.net/84a59ff9c58a77032564e61f716846d6.jpg" alt="">
      <p class="name">称心如意手摇咖啡磨豆机咖啡豆研磨机 <span class="tag">【赠品】10优惠券</span></p>
      <p class="spec">白色/10寸</p>
      <p class="price">289.90</p>
      <p class="count">x2</p>
      <p class="sub-total">579.80</p>
    </div> -->
    </div>
    <div class="total">
        <div>合计:<span class="amount"></span></div>
    </div>
    <script>
        const goodsList = [
            {
                id: '4001172',
                name: '称心如意手摇咖啡磨豆机咖啡豆研磨机',
                price: 289.9,
                picture: 'https://yanxuan-item.nosdn.127.net/84a59ff9c58a77032564e61f716846d6.jpg',
                count: 2,
                spec: { color: '白色' }
            },
            {
                id: '4001009',
                name: '竹制干泡茶盘正方形沥水茶台品茶盘',
                price: 109.8,
                picture: 'https://yanxuan-item.nosdn.127.net/2d942d6bc94f1e230763e1a5a3b379e1.png',
                count: 3,
                spec: { size: '40cm*40cm', color: '黑色' }
            },
            {
                id: '4001874',
                name: '古法温酒汝瓷酒具套装白酒杯莲花温酒器',
                price: 488,
                picture: 'https://yanxuan-item.nosdn.127.net/44e51622800e4fceb6bee8e616da85fd.png',
                count: 1,
                spec: { color: '青色', sum: '一大四小' }
            },
            {
                id: '4001649',
                name: '大师监制龙泉青瓷茶叶罐',
                price: 139,
                picture: 'https://yanxuan-item.nosdn.127.net/4356c9fc150753775fe56b465314f1eb.png',
                count: 1,
                spec: { size: '小号', color: '紫色' },
                gift: '50g茶叶,清洗球'
            }
        ]
        
    </script>
</body>

</html>

五、渲染实现

1.封装一个渲染函数

    function render(arr){
    }
    render(goodsList) //调用这个函数

2.map函数遍历数组,动态渲染div数量 (这是写在封装函数里的,单拿出来为了逻辑更清晰)

     document.querySelector(".list").innerHTML =arr.map(item => {
                  return `<div class="item">
                                 <img src="https://yanxuan-item.nosdn.127.net/84a59ff9c58a77032564e61f716846d6.jpg" alt="">
                                  <p class="name">称心如意手摇咖啡磨豆机咖啡豆研磨机 <span class="tag">【赠品】10优惠券</span></p>
                                  <p class="spec">白色/10寸</p>
                                  <p class="price">289.90</p>
                                  <p class="count">x2</p>
                                  <p class="sub-total">579.80</p>
                          </div>`
    } ).join("")
    //由于map返回的是数组,要转换成字符串,然后添加到.list的div里面去
    //此时数据是写死的

3.对象解析后,添加到模板字符串中

    const { picture, name, price, count, spec, gift } = item
     return `
                    <div class="item">
                        <img src=${picture} alt="">
                        <p class="name">${name}<span class="tag">【赠品】10优惠券</span></p>
                        <p class="spec">白色/10寸</p>
                        <p class="price">${price.toFixed(2)}</p>
                        <p class="count">x${count}</p>
                        <p class="sub-total">${(price * 10 * count / 10).toFixed(2)}</p>
                    </div>
                `

4.处理spec对象

     const text = Object.values(spec).join("/")
     <p class="spec">${text}</p>
  • 首先用Object.values获取到spec中的数据,此时数据是数组的形式存在
  • 然后用join()拼接成字符串,用变量text接受
  • 最后填写到模板字符串中即可

5.赠品部分数据处理

    const str = gift ? gift.split(",").map(item => `<span class="tag">【赠品】${item}</span>`).join("") : ""
    <p class="name">${name} ${str}</p>
  • 首先要用三元运算符判断gift属性是否存在,不存在则为空
  • 然后gift是字符串类型,利用split()转换成数组然后map()遍历,原理等同于渲染函数render
  • 最后填写到模板字符串中

6.总计模块处理

    const total = goodsList.reduce((prev, item) => prev + (item.price * 100 * item.count) / 100, 0)
    document.querySelector(".amount").innerHTML = total.toFixed(2)
  • 利用ruduce()求和,千万不要忘记写初值
  • *100/100的做法是为了解决精度问题

六、JS部分完整代码

     function render(arr) {
            document.querySelector(".list").innerHTML = arr.map(item => {
                const { picture, name, price, count, spec, gift } = item
                const text = Object.values(spec).join("/") 
                const str = gift ? gift.split(",").map(item => `<span class="tag">【赠品】${item}</span>`).join("") : ""
                return `
                    <div class="item">
                        <img src=${picture} alt="">
                        <p class="name">${name} ${str}</p>
                        <p class="spec">${text}</p>
                        <p class="price">${price.toFixed(2)}</p>
                        <p class="count">x${count}</p>
                        <p class="sub-total">${(price * 10 * count / 10).toFixed(2)}</p>
                    </div>
                `
            }
            ).join("")
            
            const total = goodsList.reduce((prev, item) => prev + (item.price * 100 * item.count) / 100, 0)
            document.querySelector(".amount").innerHTML = total.toFixed(2)
        }
        render(goodsList)

前端正则表达式全解:从基础语法到实战应用

2026年4月10日 22:58

本文适合前端初学者、日常开发使用及面试复习,从正则基础到实战场景,全程可直接复制运行

前言

正则表达式(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. 面试重点:分组捕获、边界符、全局修饰符、正则复用、实战转换 / 校验

熟练运用正则,能让你的字符串代码更简洁、高效,是前端工程师必备的核心技能。

❌
❌