普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月7日技术

JavaScript 的 NaN !== NaN 之谜:从 CPU 指令到 IEEE 754 标准的完整解密

2025年11月7日 22:54

本文主要参考并扩展自 Why NaN !== NaN in JavaScript (and the IEEE 754 story behind it),结合个人理解和补充说明,深入探讨 NaN 从语言层到硬件层的实现原理。

开篇

写 JavaScript 的时候,遇到过这样的情况:

> typeof NaN
'number'

> NaN === NaN
false

看到这里,脑子里冒出几个问号:

  • NaN 明明是"不是数字",为啥 typeof 显示是 number?
  • 为什么 NaN 不等于它自己?这不是违背了数学中"任何值都等于它自己"的基本原则吗?
  • NaN 参与运算总是返回 NaN,那它到底有什么用?
  • 这个反常识的设计是 JavaScript 的 bug 还是有意为之?

有人说这是 JavaScript 的历史包袱,但深入研究后发现,NaN 的设计根本不是 JavaScript 层面的事——它从硬件层就已经实现了,而且是解决了一个更大问题的优雅方案。

NaN 究竟是什么?

一个永远返回自己的"数字"

试着对 NaN 做点运算:

> NaN + 1
NaN
> NaN - 1
NaN
> Math.max(NaN)
NaN
> Math.min(NaN)
NaN

无论加减还是求最大最小值,结果永远是 NaN。这看起来毫无意义,但这正是 NaN 的设计初衷——让错误在计算链中传播,而不是立即中断程序。

不只是 JavaScript 的问题

看看 Firefox 和 V8 引擎的源码:

// Firefox
bool isNaN() const { 
    return isDouble() && std::isnan(toDouble()); 
}

// V8
if (IsMinusZero(value)) return has_minus_zero();
if (std::isnan(value)) return has_nan();

浏览器引擎都调用了 C++ 标准库的 std::isnan 方法。这说明 NaN 不是 JavaScript 发明的,而是来自更底层的标准。

事实上,NaN 首次被标准化是在 1985 年,由 IEEE 754 浮点数标准定义。

从 JavaScript 到硬件层:NaN 的真实面目

用 C 语言验证

写个简单的 C 程序,看看 NaN 在底层是怎么表现的:

#include <math.h>
#include <stdint.h>
#include <stdio.h>

int main() {
    double x = 0.0 / 0.0;
    
    if (x != x) {
        printf("NaN is not the same\n");
    }
    
    if (isnan(x)) {
        printf("x is NaN\n");
    }
    
    uint64_t bits = *(uint64_t*)&x;
    printf("NaN hex: 0x%016lx\n", bits);
    
    return 0;
}

输出结果:

NaN is not the same
x is NaN
NaN hex: 0xfff8000000000000

跟 JavaScript 里的行为完全一样!

其他语言也是如此

Python:

import math
nan = float('nan')
print(nan != nan)  # True
print(nan == nan)  # False
print(math.isnan(nan))  # True

C++:

#include <iostream>
#include <cmath>

int main() {
    double nan = NAN;
    std::cout << (nan != nan) << std::endl;  // 1 (true)
    std::cout << (nan == nan) << std::endl;  // 0 (false)
    std::cout << std::isnan(nan) << std::endl;  // 1 (true)
    return 0;
}

Rust:

fn main() {
    let nan = f64::NAN;
    println!("{}", nan != nan);  // true
    println!("{}", nan == nan);  // false
    println!("{}", nan.is_nan());  // true
}

这不是某种语言的设计缺陷,而是所有现代编程语言都遵循的统一标准。

深入汇编:NaN 的硬件实现

把刚才的 C 程序生成汇编代码,看看 CPU 是怎么处理 NaN 的:

# =====================================
# double x = 0.0 / 0.0;
# =====================================
pxor xmm0, xmm0           # xmm0 = 0.0
divsd xmm0, xmm0          # xmm0 = 0.0 / 0.0 = NaN
movsd QWORD PTR -8[rbp], xmm0  # x = NaN

# =====================================
# if (x != x) {
# =====================================
movsd xmm0, QWORD PTR -8[rbp]     # xmm0 = x
ucomisd xmm0, QWORD PTR -8[rbp]   # compare x with x (sets PF=1 for NaN)
jnp .L2                            # skip if NOT NaN (PF=0)

几个关键点:

  1. xmm0 寄存器 - 专门用于浮点数运算的 CPU 寄存器
  2. divsd 指令 - 执行浮点数除法,0.0/0.0 会产生 NaN
  3. ucomisd 指令 - 这是检测 NaN 的关键!

ucomisd:硬件级别的 NaN 检测

ucomisd 全称是 Unordered Compare Scalar Double-precision floating-point(无序标量双精度浮点数比较)。这条指令在 CPU 层面就能检测 NaN,会设置一个特殊的标志位(PF=1)。

这意味着什么?NaN 是在硬件层实现的,不是 JavaScript 抽象出来的概念。

为什么 NaN !== NaN?

历史原因:给程序员一个检测手段

在早期,很多编程语言还没有 isnan() 函数。工程师们需要一种方法来检测 NaN,于是设计了这个特性:

if (x != x) {
    // x 一定是 NaN
}

从逻辑上讲,这也说得通:一个"非值"不能等于另一个"非值"

不是 Bug,是精心设计

这是 IEEE 754 标准的有意设计,不是 JavaScript 的失误。所有遵循 IEEE 754 标准的语言都是这样实现的。

为什么 typeof NaN === "number"?

NaN 是 IEEE 754 数值系统的一部分,不是单独的类型。它是一个特殊的数值,用来表示数学运算错误。

在 JavaScript 中,number 类型的值都以双精度浮点数(double)表示,遵循 IEEE 754 标准。

整数 vs 浮点数的除零处理

整数除以零是明确的错误,会导致程序崩溃。但浮点数运算有很多会产生未定义结果的情况:

  • 0.0 / 0.0 → NaN
  • ∞ - ∞ → NaN
  • 0 * ∞ → NaN
  • sqrt(-1) → NaN

在 IEEE 754 标准出现之前,每个硬件厂商对这些情况的处理都不一样,导致代码可移植性极差。

IEEE 754 标准:浮点数运算的统一规范

标准概览

  • 发布时间: 1985 年
  • 主要贡献者: William Kahan(加州大学伯克利分校)+ IEEE 委员会
  • 定义内容: NaN、Infinity、非规格化数、舍入模式

关键决策

  1. NaN !== NaN - 比较时总是返回 false
  2. NaN 的二进制表示 - 指数 = 0x7FF,尾数 ≠ 0
  3. Quiet NaN (qNaN) - 在运算中传播,不触发异常
  4. Signaling NaN (sNaN) - 第一次使用时触发异常
  5. NaN 传播规则 - 任何涉及 NaN 的运算结果都是 NaN

1994 年:奔腾 FDIV Bug

1994 年,Intel 奔腾处理器的浮点除法出现 bug,某些除法运算给出错误结果。虽然不是 NaN 的问题,但这个事件凸显了精确实现 IEEE 754 标准的重要性。

Intel 更换了数百万颗处理器,损失了 4.75 亿美元。

NaN:程序员的救星

没有 NaN 之前的世界

在 IEEE 754 标准之前(1985 年),每个硬件厂商都有自己的做法,通常意味着 0/0 这种运算会直接让程序崩溃。

想象一下:你坐在飞机上,控制系统的程序员没有预料到某个 0/0 运算,指令在 CPU 上执行,触发 Division Error,整个程序崩溃——飞机失控!

这要求开发者在每次运算前都做防御性编程。Intel 和其他厂商受够了不同架构上程序行为不一致的混乱局面。

为什么选择 NaN?

考虑几种可能的方案:

方案 A: Division Error → 程序崩溃(IEEE 754 之前)

  • 程序意外终止(参见飞机例子)
  • 每次运算前都要做防御性检查

方案 B: 返回 0

  • 数学上不正确
  • 掩盖了错误
  • 后续计算会给出错误结果

方案 C: 返回 null 或错误码

  • 每次运算后都要检查
  • 中断数学计算链
  • 结果类型不一致

方案 D: 特殊值 NaN(IEEE 754 的选择)

  • 值在计算中传播
  • 程序继续运行
  • 可以在计算结束时检查结果
  • 保持类型一致性(number)

对比:有 NaN 和没有 NaN 的代码

没有 NaN 的情况

function divide(a, b) {
    // 检查类型
    if (typeof a !== 'number' || typeof b !== 'number') {
        throw new Error('Arguments must be numbers');
    }
    
    // 检查数字是否有效
    if (!isFinite(a) || !isFinite(b)) {
        throw new Error('Arguments must be finite');
    }
    
    // 检查除数
    if (b === 0) {
        throw new Error('Division by zero');
    }
    
    return a / b;
}

function calculate(expression) {
    try {
        const result = divide(10, 0);
        return result;
    } catch (e) {
        console.error(e.message);
        return null;  // 该返回什么? null? undefined? 0?
    }
}

每次运算都要写一堆检查代码,一旦出错就要处理异常,计算链被打断。

有 NaN 的情况

function divide(a, b) {
    return a / b;  // 硬件搞定一切!
}

function calculate(expression) {
    return divide(10, 0);
}

const result = calculate("10 / 0");
console.log("Result:", result);  // Infinity

const badResult = 0 / 0;
if (Number.isNaN(badResult)) {
    console.log("Invalid calculation");
}

代码简洁多了,不需要在每一步都检查错误。硬件层面已经处理好了异常情况,错误会在计算链中传播,最后统一检查即可。

总结

研究完 NaN,我的理解是:

原理层面:

  • NaN 在硬件层实现(通过 ucomisd 等 CPU 指令)
  • 是 IEEE 754 标准的一部分(1985 年)
  • 在运算中传播,允许在计算结束时检测错误
  • NaN !== NaN 是有意设计,方便检测

实用层面:

  • 避免了程序因浮点数运算错误而崩溃
  • 保持了类型一致性(始终是 number)
  • 简化了错误处理逻辑
  • 软肋:需要显式检查(使用 Number.isNaN())

使用建议:

  1. 检测 NaN:用 Number.isNaN(value),不要用 value !== value(虽然也能用,但可读性差)
// ✅ 推荐
if (Number.isNaN(result)) {
    console.log("计算出错");
}

// ❌ 不推荐(虽然可行)
if (result !== result) {
    console.log("计算出错");
}
  1. 避免 NaN 传播:如果不希望 NaN 污染整个计算链,在关键步骤检查
function safeCalculate(a, b) {
    const result = a / b;
    return Number.isNaN(result) ? 0 : result;  // 或其他默认值
}
  1. 理解 typeof 的结果:typeof NaN === "number" 不是 bug,记住它是数值系统的一部分

写在最后

NaN 的设计是浮点数运算错误处理的优雅解决方案。它让程序在遇到异常数学运算时不会崩溃,同时保留了错误信息,允许程序员在合适的时机统一处理。

下次看到 NaN !== NaN 时,你会知道:

  • 这不是 JavaScript 的 bug
  • 这是 IEEE 754 标准在 1985 年就确定的设计
  • 它在 CPU 硬件层就已经实现
  • 这个"反常识"的行为实际上是深思熟虑的结果

从 JavaScript 的抽象层一路追踪到 CPU 指令,发现一个看似简单的语言特性背后,是几十年计算机工程的智慧结晶。这也提醒我们:遇到"反常识"的设计时,不妨多问几个为什么,往往能发现更深层的道理。


参考资料

  1. IEEE 754 - Wikipedia - IEEE 浮点数标准的历史和技术细节
  2. Why NaN !== NaN in JavaScript (and the IEEE 754 story behind it) - 本文的主要参考来源,深入讲解了 NaN 从 JavaScript 到硬件层的实现
  3. MDN - NaN - JavaScript 中 NaN 的官方文档
  4. MDN - Number.isNaN() - 检测 NaN 的正确方法

前端新玩具:Vike 发布!

2025年11月7日 21:59

说到 Vite99% 的前端同学都会眼睛一亮——秒开 dev server、闪电般的 HMR、零配置开箱即用。

但今天的主角不是 Vite,而是站在它肩膀上的新晋框架 Vike!🚀

Vike 是一个模块化 Web 框架,定位为 Next.jsNuxt 的替代品!

🔍 什么是 Vike?

Vike = “Vite 之上、Next.js 之外”

它给你**服务器渲染(SSR/SSG)**的超能力,却:

  • 不绑架路由 —— 想文件路由就文件路由,想自定义就自定义
  • 不绑架数据源 —— RESTGraphQLtRPCRPC,你爱用啥用啥
  • 不绑架部署目标 —— NodeCloudflareVercelDeno、静态,一键部署
  • 随时 eject —— 不满意?把整个底层逻辑摊开来随便改!

✨ Vike 的优势 & 特性

  • 多框架支持
    • ReactVueSvelteSolid 随你挑,甚至可混用
  • 零配置 SSR
    • 页面文件即路由,自动 SSR/SSG/SPA 切换
  • 极致性能
    • 继承 Vite 的构建速度,代码分割、预取、流式渲染全都有
  • 微前端就绪
    • 官方集成 vite-plugin-federation,远程组件即插即用
  • 生命周期钩子
    • onBeforeRenderrenderonAfterRender 完全可控
  • 部署自由
    • 同一套产物,NodeEdgeStatic 想跑哪就跑哪

🚀 如何快速上手 Vike?

# 1. 脚手架挑技术栈(React / Vue / Svelte...)
npm create vike@latest
cd my-app

# 2. 起开发服务器(自带 SSR + HMR)
npm run dev

# 3. 构建 & 本地预览生产产物
npm run build
npm run preview

默认开箱自带:

  • TypeScript
  • ESLint
  • 代码分割
  • 预取
  • 错误边界
  • Helmet 安全头……

🌟 Vike-Photon 正式发布!

2025 年 10 月 28 日,Vike 团队推出全新部署利器 —— Photon

“Write once, deploy anywhere that runs JavaScript.”

Photon 带来的超能力:

  • 真・边缘开发
    • 本地跑 workerd,开发即生产,KV、D1、Env 全都能用
  • 零配置多平台
    • 一键部署到 Cloudflare、Vercel、Deno、Netlify、Node、Bun
  • 按路由拆 Worker
    • /api/checkout 各跑各的,冷启更快,计费更省
  • 渐进迁移
    • vike-server 项目 3 条命令即可迁移

🎯 Vike 适合哪些场景?

  • 想要 SSR/SSG 但不想被 Next.js 绑架
    • ✅ 强烈推荐 | 技术栈自由,随时 eject
  • 多团队多框架并行开发
    • ✅ 推荐 | 微前端 + 模块联邦官方集成
  • 老项目渐进迁移
    • ✅ 推荐 | 一页一页迁,边跑边改
  • 纯静态官网
    • ❌ 不推荐 | Vite 足够
  • 深度依赖 Next 插件生态
    • ❌ 三思 | 迁移成本高

🏁 结语

Vite 让你写得快,Vike 让你跑得远!
从本地开发到边缘部署,从单体到微前端,只需一套思维模型。

如果你渴望**“SSR 的性能 + 微前端的灵活 + 部署的自由”**,不妨把 Vike 加入你的下一次技术选型 —— 它,值得被玩一玩!

  • Vike 官网https://vike.dev/
  • Vike-Photonhttps://github.com/vikejs/vike-photon

AI时代的前端知识拾遗:前端事件循环机制详解(基于 WHATWG 最新规范)

作者 奇舞精选
2025年11月7日 21:48

引言

最近,我在部门校招生面试中发现,许多候选人更注重 UI 开发与前端框架的使用,却忽略了 JavaScript 的核心机制。虽然我不赞成死记硬背面试“八股文”,但如果对前端基础知识只懂皮毛,就很难让人相信你能在工作中写出清晰、可维护的代码。即便在 AI 辅助编程盛行的时代,掌握这些基础机制依然价值连城——至少能帮助你审视 AI 生成的代码是否合规、是否存在隐患。本文将基于 WHATWGHTML Living Standard,结合浏览器与 V8 引擎的实现视角,对 JavaScript 运行时的 事件循环(Event Loop)机制 进行补充与深入解析。

事件循环是 JavaScript 运行时的核心调度器,它决定了脚本执行、异步回调、定时器、浏览器渲染、事件处理等任务的顺序。理解这一机制,不仅能帮助你开发性能更优、响应更流畅的前端应用,还能揭示浏览器与 V8 引擎之间的协同工作原理。

一、浏览器多进程架构与渲染主线程

现代浏览器(例如 Chrome)采用了多进程架构。当用户打开一个标签页时,浏览器会为该页面创建一个 渲染进程(Renderer Process)

在这个进程中,最关键的部分是 渲染主线程(Main Thread),它负责:

  • 解析 HTML 与 CSS;
  • 计算样式 (style) 与布局 (layout);
  • 执行脚本(JavaScript 引擎 V8 在此运行);
  • 处理用户交互、事件监听、定时器回调;
  • 绘制及更新页面 (paint/repaint);

渲染主线程遵循单线程模型:任意时刻只有一个任务在执行。如果脚本执行耗时太久,就会阻塞页面渲染或用户交互。为缓解这一问题,浏览器引入了 事件循环(Event Loop) 机制,它将所有待处理任务有序排队和调度,从而保证主线程能够高效运转。

二、事件循环的总体模型

WHATWGHTML Standard 中,事件循环的关键描述包括:

“An event loop has one or more task queues. Each task queue is a set of tasks. The microtask queue is not a task queue.”

换言之,事件循环由两类核心结构组成:

  1. 任务队列 (Task Queues)

    在浏览器环境中,所有任务(如脚本执行、定时器到期、网络响应、用户输入)都被放入任务队列。不同类型的任务可以属于不同的任务队列(例如定时器队列、用户交互队列、网络回调队列等)。

  2. 微任务队列 (Microtask Queue)

    这是一个独立的 FIFO(先进先出)队列,用于调度微任务 (microtasks) —— 这些任务必须在当前任务结束后、下一个任务开始前立即执行。典型的来源包括 Promise.then/catch/finally 回调、queueMicrotask() 以及 MutationObserver 等。

基于此,事件循环的基本流程可概括为:

  1. 从任务队列中选择一个可执行的任务 (Task)。
  2. 执行该任务的回调或脚本。
  3. 当前任务结束后,检查微任务队列,如存在则立即执行直到队列为空。
  4. 如有必要,浏览器进行渲染更新或调用 requestAnimationFrame 回调。
  5. 返回步骤 1,进入下一轮循环。

需要补充说明的是:在 WHATWG 的模型中,并没有“宏任务 (macrotask)”这一术语;“宏任务”只是开发者社区的习惯用法,用来指代任务队列 (task) 中的各类任务。

三、异步机制与 Task 调度

截屏2025-10-23 15.59.25

当浏览器遇到不能立即完成的操作(如 setTimeout、网络请求或事件监听)时,主线程不会阻塞等待,而是将这些操作委托给其他系统线程或浏览器组件处理。一旦异步条件满足(例如定时器到期、网络响应返回或用户点击事件发生),浏览器会将对应的回调封装为一个任务对象,入队进入相应的任务队列。事件循环机制随后择机取出并执行这些任务。

这种调度方式带来了几大好处:

  • 避免主线程被长时间阻塞;
  • 不同任务类型之间实现良好的隔离;
  • UI 渲染能够在任务之间得到穿插,从而交互更流畅。

以下示例直观展示:

console.log('start');
setTimeout(() => console.log('timer done'), 0);
console.log('end');

输出顺序为:

start end timer done

原因在于:setTimeout 的回调被放入任务队列中,需要等待当前脚本(视为一个 task)执行完毕后,事件循环才会取出并执行定时器回调。

四、Microtask Queue 与优先级

微任务 (microtasks) 是在当前任务结束后、下一轮任务开始前必须被处理的“高优先级”回调。WHATWG 规范中明确规定:浏览器在每个任务(task)结束后,必须执行一次微任务检查点 (microtask checkpoint),将微任务队列中的所有任务执行完毕后,才可进入下一任务。

下面这个示例说明了微任务的执行时机:

console.log('A');
Promise.resolve().then(() => console.log('B'));
console.log('C');

输出结果:

A

C

B

这是因为 Promise.then 回调被加入微任务队列,在当前任务(整个 script)结束后、进入下一任务前立即执行。

如果在执行微任务过程中又添加了新的微任务,那么这些新任务也会排队执行,直到微任务队列清空:

Promise.resolve().then(() => {
 console.log('X');
 Promise.resolve().then(() => console.log('Y'));
});
console.log('Z');

输出顺序:Z → X → Y。

说明:微任务严格按 FIFO 执行,且在返回事件循环 (转向下一 task) 之前,必须将微任务队列完全清空。

注意

任务没有优先级,在消息队列中先进先出,但消息队列是有优先级的。根据规范最新解释:

  • 每个任务都有⼀个任务类型,同⼀个类型的任务必须在⼀个队列,不同类型的任务可以分属于不同的队列。

  • 在⼀次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执⾏。

浏览器必须准备好⼀个微队列,微队列中的任务优先所有其他任务执⾏

五、浏览器内部实现视角

Chrome 为例,事件循环在渲染进程的主线程中运行。主线程通过组件如 MessageLoopScheduler、TaskRunner 来维持一个持续的循环结构:

  1. 从各个任务队列(TimersNetworkUser InputIPC 等)收集 task;
  2. 取出一个 task 执行(如 JS 脚本或回调);
  3. 调用 V8 引擎执行 JavaScript;
  4. 执行完后 触发 V8 的微任务检查点,执行微队列中的任务;
  5. 渲染引擎(Blink)进行样式计算与绘制;
  6. 继续下一 任务。

这与 WHATWGEvent Loop 算法高度一致,只是实现层面由 ChromiumSchedulerTaskRunner 组件具体负责。

六、V8 引擎中的微任务队列实现

在 V8 内部,微任务队列由 MicrotaskQueue 类实现。当脚本调用 Promise.resolve().then(fn) queueMicrotask(fn) 时,

V8 会将 fn 封装为一个 Job 对象,放入当前 microtask 队列。每当宿主(浏览器)执行一个 task 结束时,V8 会触发PerformMicrotaskCheckpoint();方法,该函数不断从 microtask 队列中取出任务去执行,直到队列为空。

因此可以归纳为:

  • 微任务队列的创建与管理由 V8 完成;
  • 检查点 (checkpoint) 的触发时机由宿主环境(浏览器)控制;
  • 在非浏览器环境(如 Node.js)中,也存在类似机制,但还额外引入了 process.nextTick 队列,其优先级甚至高于普通微任务。

“在 Node.js 环境中,事件循环模型与浏览器相似,但其微任务机制略有不同:

Node.js 在每个阶段结束后(如 timers、poll、check)执行 nextTick 队列和 microtask 队列,process.nextTick 的优先级甚至高于 Promise 微任务。”

七、Task 队列类型与执行策略

根据 WHATWG 规范,浏览器可维护多个任务队列,事件循环算法允许在这些队列中按实现定义方式选择任务。以 Chrome 为例,常见的任务来源包括:

  • 微任务队列:⽤户存放需要最快执⾏的任务,优先级「最⾼」
  • 延时任务队列(Timers)setTimeoutsetInterval 回调
  • 用户交互任务队列(User Interaction):鼠标、键盘等事件回调
  • 网络任务队列(Network):请求响应回调
  • 渲染任务队列(Render):布局与绘制相关任务

浏览器会根据页面的可见性、用户操作、功耗策略等,动态调整从哪个队列取任务执行,从而优化响应速度与资源消耗。

八、渲染时机与事件循环的关系

渲染 (Paint) 并不是每次执行完一个 task 就立即触发。WHATWG 的规范中表述:浏览器 “可以” 在任务之间或合适的时机进行渲染。

通常情况下,浏览器以约 16 ms 一次 (对应 60 FPS) 的刷新节奏去触发渲染机会。因此:

  • 如果一个 task 占用主线程时间过长(例如死循环或大量同步计算),就会阻塞渲染更新。
  • 在一个 task 内多次修改 DOM,只有在该 task 结束后、并在微任务、渲染逻辑之间切换时,才会把更新反映到屏幕上。

下面是一个典型的任务 + 微任务 +渲染时机示例:

console.log('start');

setTimeout(() => {
 console.log('timeout task');
}, 0);

queueMicrotask(() => console.log('microtask 1'));
Promise.resolve().then(() => console.log('microtask 2'));

console.log('end');

执行结果:

start end microtask 1 microtask 2 timeout task

解释如下:当前脚本是一个 task,执行同步代码 startend。期间注册的微任务 (来自queueMicrotaskPromise.then) 会加入微任务队列。该 task 结束后,立即执行微任务队列中的任务。微任务执行完毕后,事件循环才能取出下一 task (即 setTimeout 的回调)进行执行。

总结

  • 事件循环 (Event Loop) 是浏览器调度脚本与回调的核心机制。
  • WHATWG 规范定义的结构为:多个任务队列 (Task Queues) + 一个微任务队列 (Microtask Queue)
  • 浏览器每轮循环流程大致为:取一个 task → 执行 → 微任务检查点 (执行全部 microtasks) → 渲染更新(若有)→ 下一轮循环。
  • 在 V8 内部,microtask 队列由引擎管理,而何时触发由宿主环境控制。
  • WHATWG 的模型中,并无“宏任务 (macrotask)”这一正式术语;开发者使用的“宏任务”通常指任务队列中的任务。
  • 掌握事件循环机制能够帮助你在异步编程中游刃有余,从容应对复杂场景,并提升前端代码的性能、稳定性与可维护性。

参考文献

  1. WHATWG HTML Living Standard – 8.1 Event loops
  2. V8 Design Docs – Microtask Queue Implementation
  3. Chromium Source – MessageLoop and TaskRunner

JavaScript 学习笔记:深入理解 map() 方法与面向对象特性

作者 UIUV
2025年11月7日 18:49

JavaScript 学习笔记:深入理解 map() 方法与面向对象特性

一、Array.prototype.map() 方法详解

map() 是 ES6 中引入的重要数组方法之一,属于高阶函数(Higher-order Function) 。它的核心作用是对原数组的每个元素执行一个回调函数,并返回一个由回调函数返回值组成的新数组,而不会修改原数组。

1.1 基本语法

const newArray = arr.map(callbackFn(element, index, array), thisArg);
  • callbackFn:为每个元素执行的函数,必须返回一个值。

    • element:当前元素
    • index:当前索引
    • array:原数组本身
  • thisArg(可选) :指定回调函数中 this 的值

1.2 使用示例

const numbers = [1, 4, 9];
const roots = numbers.map(num => Math.sqrt(num)); // [1, 2, 3]
const doubles = numbers.map(num => num * 2);      // [2, 8, 18]

注意:map() 不会改变原数组,而是返回一个全新数组。

1.3 常见陷阱:map(parseInt) 的误区

一个经典面试题:

console.log(["1", "2", "3"].map(parseInt)); // [1, NaN, NaN]

原因分析

map() 会传递三个参数给回调函数:(element, index, array)
parseInt(string, radix) 接收两个参数:字符串和进制基数。

因此实际调用过程如下:

parseInt("1", 0)  // → 1(radix=0 被忽略,默认十进制)
parseInt("2", 1)  // → NaN(1进制非法)
parseInt("3", 2)  // → NaN(2进制不含数字3)

正确写法

// 方式1:显式指定基数
["1", "2", "3"].map(str => parseInt(str, 10));

// 方式2:使用 Number 构造函数(更简洁)
["1", "2", "3"].map(Number); // [1, 2, 3]

⚠️ 注意:Number() 会解析浮点数和科学计数法,而 parseInt() 只取整数部分。


二、NaN(Not a Number)详解

2.1 什么是 NaN?

  • NaN 表示“不是一个数字”,但其 typeof 结果为 'number'
  • 它出现在无效的数学运算或类型转换中。

2.2 常见产生 NaN 的场景

console.log(0 / 0);           // NaN
console.log("abc" - 1);       // NaN
console.log(undefined + 1);   // NaN
console.log(parseInt("hello")); // NaN
console.log(Math.sqrt(-1));   // NaN

2.3 NaN 的特殊性质

  • NaN 不等于任何值,包括它自己

    console.log(NaN == NaN);     // false
    console.log(NaN === NaN);    // false
    
  • 正确判断 NaN 的方法

    if (Number.isNaN(value)) {
      console.log("这是一个 NaN");
    }
    

    ✅ 推荐使用 Number.isNaN(),而非全局 isNaN()(后者会进行类型转换,可能导致误判)。


三、JavaScript 的面向对象特性与包装类

3.1 JS 是完全面向对象的语言

尽管 JavaScript 有原始类型(如 string, number, boolean),但它通过包装类(Wrapper Classes) 实现了统一的对象调用风格。

例如:

"hello".length;        // 5
114.514.toFixed(2);    // "114.51"

这些看似“原始类型调用方法”的操作,在底层实际上是:

(new String("hello")).length;
(new Number(114.514)).toFixed(2);

JS 引擎会临时创建包装对象,调用方法后立即销毁,实现“傻瓜式”编程体验。

3.2 包装类的生命周期

let str = "hello";               // 原始字符串
let strObj = new String(str);    // 显式创建 String 对象
console.log(typeof str);         // "string"
console.log(typeof strObj);      // "object"
strObj = null;                   // 手动释放(通常不需要)

💡 日常开发中无需手动创建包装对象,JS 会自动处理。


四、字符串处理相关知识补充

4.1 字符串长度与编码

JavaScript 使用 UTF-16 编码,大多数字符占 1 个单位,但某些 Unicode 字符(如 emoji)占 2 个或更多:

console.log("a".length);     // 1
console.log("中".length);    // 1
console.log("𝄞".length);    // 2(音乐符号)
console.log("👋".length);    // 2(emoji)

4.2 常用字符串方法对比

方法 支持负索引 参数顺序处理 示例
slice(start, end) 保持顺序,start > end 返回空 "Hello".slice(-3, -1)"ll"
substring(start, end) ❌(负数转为 0) 自动交换使 start ≤ end "Hello".substring(3, 1)"el"

4.3 查找字符位置

const str = "Hello";
console.log(str.indexOf('l'));      // 2(首次出现)
console.log(str.lastIndexOf('l'));  // 3(最后一次出现)

五、总结与最佳实践

  1. 慎用 map(parseInt) :务必显式指定进制或改用 Number
  2. 正确判断 NaN:使用 Number.isNaN() 而非 == NaN
  3. 理解包装类机制:原始类型能调用方法是 JS 的语法糖,背后是临时对象。
  4. 优先使用 map 返回新数组:若不需要返回值,应使用 forEach
  5. 注意字符串编码问题:处理 emoji 或生僻字时,length 可能不符合直觉。

通过深入理解 map()、NaN、包装类等核心概念,我们不仅能写出更健壮的代码,还能避免常见的“坑”。JavaScript 虽灵活,但其设计哲学强调开发者友好性与一致性,掌握这些底层机制,方能真正驾驭这门语言。

📚 参考资料:MDN - Array.prototype.map()

数据字典:从"猜谜游戏"到"优雅编程"的奇幻之旅

作者 多睡觉觉
2025年11月7日 18:36

👀 我们先来看一段没有使用数据字典的代码

javascript

// 看到这段代码,你什么感受?
if (user.type === 1) {
  // ... 
} else if (user.type === 2) {
  // ...
}

内心os:  "这1和2到底是什么?这我得去问多少人呀!"

javascript

//当我们需求要把'禁用'改成'冻结',把状态值从1改成2
if (user.status === 1) {
  return '禁用'; // 这里要改
}
<Option value={1}>禁用</Option> // 这里要改
const disabledUsers = users.filter(u => u.status === 1); // 这里要改

内心os:  "这到底是什么破代码!"

🎉 那用了数据字典后是什么样的呢?

javascript

if (user.type === USER_TYPE.VIP){
  // 奥奥,原来是vip用户!
}

const USER_STATUS = {
  NORMAL: { value: 0, label: '正常' },
  DISABLED: { value: 2, label: '冻结' } // 这里value从1改成2label从'禁用'改成'冻结'
};

内心os:  "天呐!好方便!"

所以,数据字典的真正作用是:让我们写的代码,既能被机器正确执行,也能被人轻松读懂。

🏠 那数据字典存储在哪里呢?

javascript

// 刚开始,我觉得把字典写死在前端简直太方便了
export const DEPARTMENT = {
  TECH: { value: 1, label: '技术部' },
  PRODUCT: { value: 2, label: '产品部' },
  DESIGN: { value: 3, label: '设计部' },
  OPERATION: { value: 4, label: '运营部' }
};

内心os:  "一次定义,到处使用,美滋滋!没有网络请求,性能杠杠的!代码清晰,类型安全,完美!"

javascript

export const USER_ROLE = {
  ADMIN: { value: 1, label: '管理员' },
  USER: { value: 2, label: '普通用户' },
  GUEST: { value: 3, label: '访客' }
};

💥 那么此时问题来了

问题1:  如果现在的需求变了,要把技术部分为前端部和后端部。哦!这简直是个灾难。因为所有的业务也跟着变化!那我要改到什么时候!!

问题2:  如果我现在想在USER_Role里面添加一个审核员的角色,那现在value值要写多少呢?4?会不会和后台冲突?

  • 后端也要同步改,前后端要一起上线!
  • 如果后端先上线,前端还没改,就显示不出来!
  • 如果前端先上线,后端还没改,就会报错!

🎯 所以什么适合写死在前端呢?

✅ 适合写死在前端的(不会变的)

javascript

// 1. 通用状态枚举(业务逻辑相关)
export const ORDER_STATUS = {
  PENDING: { value: 1, label: '待支付' },
  PAID: { value: 2, label: '已支付' },
  COMPLETED: { value: 3, label: '已完成' },
  CANCELLED: { value: 4, label: '已取消' }
};
// 理由:这些状态与业务逻辑强相关,基本不会改变

// 2. 界面状态
export const BUTTON_SIZE = {
  SMALL: { value: 'small', label: '小' },
  LARGE: { value: 'large', label: '大' }
};
// 理由:纯前端控制,与后端无关

// 3. 颜色、样式映射
export const STATUS_COLOR = {
  SUCCESS: { value: 'success', color: '#52c41a' },
  ERROR: { value: 'error', color: '#ff4d4f' },
  WARNING: { value: 'warning', color: '#faad14' }
};
// 理由:纯前端显示逻辑

❌ 不适合写死在前端的

javascript

// 1. 组织架构数据
export const DEPARTMENT = {
  TECH: { value: 1, label: '技术部' },
  PRODUCT: { value: 2, label: '产品部' }
};

// 应该从后端获取
const [departments, setDepartments] = useState([]);
useEffect(() => {
  api.getDepartments().then(setDepartments);
}, []);

// 2. 分类标签数据
export const ARTICLE_CATEGORY = {
  TECH: { value: 1, label: '技术文章' },
  NEWS: { value: 2, label: '公司新闻' }
};

const [categories, setCategories] = useState([]);
useEffect(() => {
  api.getArticleCategories().then(setCategories);
}, []);

// 3. 权限角色数据
export const USER_ROLE = {
  ADMIN: { value: 1, label: '管理员' },
  USER: { value: 2, label: '普通用户' }
};

// 应该由后端管理
const [roles, setRoles] = useState([]);
useEffect(() => {
  api.getRoles().then(setRoles);
}, []);

🤔 既然明白了字典的存储,那又有一个问题了。前端要怎么样去管理后端返回的数据字典呢?

在思考这个问题之前,我们先来思考一个问题:我们为什么要去管理后端返回的数据字典呢?

在实际开发中,一个常见的痛点是:同一个字典数据(如"用户状态"、"部门列表")可能在应用的多个组件或模块中被使用。如果每个使用的地方都独立发起请求,会导致对同一个接口的重复调用

这会带来三个问题:

  1. 增加服务端压力和网络开销
  2. 可能导致数据不一致(如果多次请求之间数据更新了)
  3. 影响用户体验,用户会反复看到Loading状态

因此,一个高效的策略是引入前端缓存机制。它的工作流程如下:

  1. 当需要某个字典数据时(例如 userStatus),前端首先检查缓存中是否存在
  2. 如果缓存中存在,则直接返回该数据
  3. 如果缓存中不存在,则向后台发起请求,获取数据后放入缓存,再返回

这套机制的核心价值在于:

  • 性能优化:避免了重复请求,减轻了前后端负担
  • 体验提升:缓存的读取是瞬时的,用户无需等待
  • 状态统一:确保了整个应用使用的字典数据是同一份,消除了不一致的风险

🏗️ 前端要怎么样去管理后端返回的数据字典

好的,直接给出核心答案。前端管理后端字典的核心方法是:建立一套中心化的缓存机制

1. 创建全局字典仓库

javascript

// 创建一个全局的字典仓库
const dictCache = new Map(); // 使用Map存储所有字典数据

// 或者使用状态管理库
const useDictStore = create((set, get) => ({
  dicts: {},
  fetchDict: async (dictKey) => {
    const state = get();
    // 1. 先查缓存,有则直接返回
    if (state.dicts[dictKey]) {
      return state.dicts[dictKey];
    }
    // 2. 没有则请求并缓存
    const data = await api.getDict(dictKey);
    set({ dicts: { ...state.dicts, [dictKey]: data } });
    return data;
  }
}));

2. 统一接入层

javascript

// 所有组件都通过这个Hook获取字典
const useDict = (dictKey) => {
  const { dicts, fetchDict } = useDictStore();
  
  useEffect(() => {
    if (!dicts[dictKey]) {
      fetchDict(dictKey);
    }
  }, [dictKey]);
  
  return dicts[dictKey] || [];
};

3. 业务组件使用

javascript

// 所有地方都这样使用
const UserForm = () => {
  const departments = useDict('departments'); // 同一份数据,多个组件共享
  const roles = useDict('user_roles');
  
  return (
    <select>
      {departments.map(item => 
        <option key={item.value} value={item.value}>{item.label}</option>
      )}
    </select>
  );
};

📚 三层管理架构

1. 数据层 - dictCache / useDictStore

角色:数据的唯一真相来源

职责:负责与后端通信,并在内存中持久化获取到的字典数据。它像一个全局仓库,所有字典数据都存储于此

2. 接入层 - useDict Hook

角色:连接组件与数据层的桥梁

职责:组件不直接接触底层缓存和API,而是通过这个Hook。它封装了复杂的逻辑:首先检查缓存,若存在则立即返回,若不存在则触发请求并更新缓存

3. 展示层 - 业务组件

角色:数据的使用者

职责:只需声明需要什么字典(如 useDict('departments')),无需关心数据从哪里来、是否已经加载过。它们总是能获得统一、一致的数据

MJML邮件如何随宽度变化动态切换有几列📮

2025年11月7日 18:36

需求:邮件中需要展示数组信息,每个模块宽高固定不变,在PC端(600px)三列展示在移动端(400px)两列展示,且该mjml格式邮件样式在GMail中可以正常显示。

MJML官方文档:MJML - The Responsive Email Framework

MJML在现示例查看:Email Editor

一、效果展示及完整代码

1.1. 效果展示

PC端(宽度600px)

移动端(宽度400px)

1.2. 完整代码

注:下列代码请在支持解析MJML文件的项目下运行查看


<mjml>
  <mj-head>
    <mj-style inline="inline">
      .card-content {
        width: 100%;
        text-align: left;
        font-size:0;
        background: red;
      }
      .fixed-item {
        display: inline-block !important;
        width: 180px !important;
        height: 100px !important;
        margin: 10px !important;
        color: #000;
        font-size: 14px;
        line-height: 100px;
        background: #f0f0f0 !important;
        text-align: center !important;
        vertical-align: top !important;
      }
      .item-image {
        float: left;
        width: 42%;
        height: 100%;
      }
      .item-image img {
        width: 100%;
      }
      .item-details {
        float: left;
        width: 58%;
        height: 100%;
        font-family: PingFang SC;
        text-align: left;
      }
      .item-details-text-title {
        margin: 15px 10px 5px 10px;
        height: 24px;
        line-height: 24px;
        font-size: 18px;
        font-weight: 600;
        color: #13171D;
      }
      .item-details-text-subtitle {
        margin: 0 10px;
        height: 24px;
        line-height: 24px;
        font-size: 14px;
        color: #6d6d6d;
      }
    </mj-style>
  </mj-head>
  <mj-body>
    <mj-section>
      <mj-column>
        <mj-raw>
          <div class="card-content">
            <!-- 固定宽高元素会自动换行 -->
            <div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字1</div>
                <div class="item-details-text-subtitle">这是一段描述文字「1」</div>
              </div>
            </div>
            <div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字2</div>
                <div class="item-details-text-subtitle">这是一段描述文字「2」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字3</div>
                <div class="item-details-text-subtitle">这是一段描述文字「3」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字4</div>
                <div class="item-details-text-subtitle">这是一段描述文字「4」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字5</div>
                <div class="item-details-text-subtitle">这是一段描述文字「5」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字6</div>
                <div class="item-details-text-subtitle">这是一段描述文字「6」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字7</div>
                <div class="item-details-text-subtitle">这是一段描述文字「7」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字8</div>
                <div class="item-details-text-subtitle">这是一段描述文字「8」</div>
              </div>
            </div>
          </div>
        </mj-raw>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

二、实现方法及逻辑解析

需求整理:

  • PC端(宽度600px)下三列显示,移动端(400px)下两列展示
  • 数组的每个元素宽高固定不变,不会随着宽高变化而比例性 压缩/拉伸
  • GMail中样式内容正常显示
  • MJML中不支持javascrip逻辑,MJML智能单纯的显示同步显示的值

方法调研

方法一( ❌ 不可行)

基于以上需求调研发现GMail不支持CSS3样式语法,这样下来display:flexdisplay:girdposition等诸多样式均不可使用

方法二( ❌ 不可行)

MJML中不支持写入javascrip逻辑,所以试用javascrip 操控/监听 DOM的方法是行不通的

方法三( ❌ 不可行)

MJML标签中有一个<mj-fixed-column width="33.3%">可以设置一行有几列,最后将<mj-fixed-column width="33.3%">标签包裹在<mj-section padding="0">

    1. 但是因为不能使用javascrip语言来监听尺寸变化,所以不能动态切换<mj-fixed-column>标签中width何时为 50% 何时为 33.3%
    2. 所以通过网络上查询发现可以考虑使用@media screen and (max-width: 480px),来实现屏幕尺寸变化时,来通过class样式来改变元素宽度
    3. 但是配置后发现MJML不能识别 或 运行@media screen and (max-width: 480px)这种代码,类似于MJML不能运行javascrip一样
方法四( ✅ 可行)

故基于以上,思路需要调整为如何让数组元素在GMail支持的样式配置中,跟随宽度变化自动换行,这样使得宽度为600px时三列显示,在宽度为400px时两列显示

通过配置如下代码:


<mjml>
  <mj-head>
    <mj-style inline="inline">
      .fixed-item {
        display: inline-block !important;
        width: 100px !important;
        height: 100px !important;
        margin: 10px !important;
        background: #f0f0f0 !important;
        text-align: center !important;
        vertical-align: top !important;
      }
    </mj-style>
  </mj-head>
  <mj-body>
    <mj-section>
      <mj-column>
        <mj-raw>
          <div style="text-align: left; font-size: 0;">
            <!-- 固定宽高元素会自动换行 -->
            <div class="fixed-item">项目1</div>
            <div class="fixed-item">项目2</div>
            <div class="fixed-item">项目3</div>
            <div class="fixed-item">项目4</div>
            <div class="fixed-item">项目5</div>
            <div class="fixed-item">项目6</div>
            <div class="fixed-item">项目7</div>
            <div class="fixed-item">项目8</div>
          </div>
        </mj-raw>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

深入剖析 JavaScript 中 map() 与 parseInt 的“经典组合陷阱”

作者 玉宇夕落
2025年11月7日 18:35

为什么 ["1", "2", "3"].map(parseInt) 返回 [1, NaN, NaN]
这个看似简单的代码片段,却藏着 JavaScript 函数调用机制、参数传递规则和类型转换的多重细节。本文将带你彻底搞懂这个高频面试题,并掌握安全使用 mapparseInt 的最佳实践。


🧩 一、问题重现:一个让人困惑的输出

先看这段代码:

js
编辑
console.log([1, 2, 3].map(parseInt)); // [1, NaN, NaN]

我们期望的是 [1, 2, 3],但实际结果却是 [1, NaN, NaN]。这是怎么回事?

要理解这个问题,我们需要分别了解两个核心知识点:

  • Array.prototype.map() 的回调函数参数规则
  • parseInt() 的参数含义和行为

🔍 二、map() 的回调函数到底传了什么?

map() 方法会对数组中的每个元素调用一次提供的回调函数,并将以下三个参数传入:

js
编辑
arr.map((element, index, array) => { /* ... */ })
  • element:当前元素(如 "1"
  • index:当前索引(如 012
  • array:原数组本身(如 ["1", "2", "3"]

我们可以通过打印验证:

js
编辑
[1, 2, 3].map(function(item, index, arr) {
  console.log('item:', item, 'index:', index, 'arr:', arr);
  return item;
});
// 输出:
// item: 1 index: 0 arr: [1, 2, 3]
// item: 2 index: 1 arr: [1, 2, 3]
// item: 3 index: 2 arr: [1, 2, 3]

所以,当你写 [1, 2, 3].map(parseInt) 时,实际上等价于:

js
编辑
[  parseInt(1, 0, [1,2,3]),
  parseInt(2, 1, [1,2,3]),
  parseInt(3, 2, [1,2,3])
]

parseInt 只会使用前两个参数!


📚 三、parseInt() 的真实面目

parseInt(string, radix) 接收两个参数:

参数 说明
string 要解析的字符串(会被自动转为字符串)
radix 进制基数(2~36),可选,默认为 10

⚠️ 关键点:如果 radix0 或未提供,按十进制处理;但如果 radix 是非法值(如 1),则返回 NaN

让我们逐行分析:

js
编辑
console.log(parseInt(1, 0));   // 1 → radix=0 被忽略,按十进制解析 "1"
console.log(parseInt(2, 1));   // NaN → 1 进制不存在!
console.log(parseInt(3, 2));   // NaN → "3" 不是合法的二进制数字(只能是 0/1)

💡 补充:parseInt("10", 8) → 8(八进制);parseInt("ff", 16) → 255(十六进制)

因此,["1", "2", "3"].map(parseInt) 实际执行如下:

元素 调用 结果
"1" parseInt("1", 0) 1 ✅
"2" parseInt("2", 1) NaN ❌
"3" parseInt("3", 2) NaN ❌

🛠 四、正确写法:三种安全方案对比

方案 1:显式箭头函数(推荐)

js
编辑
const result = ["1", "2", "3"].map(str => parseInt(str, 10));
console.log(result); // [1, 2, 3]

优点:清晰、可控、明确指定十进制
适用场景:需要严格整数解析,忽略小数部分


方案 2:使用 Number() 构造器

js
编辑
const result = ["1", "2", "3"].map(Number);
console.log(result); // [1, 2, 3]

优点:代码极简
⚠️ 注意差异

js
编辑
["1.1", "2e2", "3e300"].map(Number);       // [1.1, 200, 3e+300]
["1.1", "2e2", "3e300"].map(str => parseInt(str, 10)); // [1, 2, 3]

Number() 会完整解析浮点数和科学计数法,而 parseInt 会在遇到非数字字符时停止。


方案 3:封装专用函数(适合复用)

js
编辑
const toInt = (str) => {
  const num = parseInt(str, 10);
  if (isNaN(num)) {
    throw new Error(`无法解析为整数: ${str}`);
  }
  return num;
};

["1", "2", "abc"].map(toInt); // 抛出错误,便于调试

优点:增强健壮性,便于错误处理


⚠️ 五、关于 NaN 的补充知识

NaN(Not-a-Number)是 JavaScript 中一个特殊的数值类型,不与任何值相同

js
编辑
console.log(typeof NaN); // "number" ← 是的,它属于 number 类型!
console.log(NaN === NaN); // false ← 最反直觉的特性之一

如何正确判断 NaN?

❌ 错误方式:

js
编辑
if (value === NaN) { ... } // 永远为 false!不与热表格值相同

✅ 正确方式:

js
编辑
if (Number.isNaN(value)) { ... } // ES6 推荐
// 或
if (isNaN(value) && typeof value === 'number') { ... } // 兼容旧环境
console.log(0 / 0,6 / 0,-6 / 0);
NaN 0/0(无意义) Infinity6/0(趋于无穷大)  -Infinity-6/0(趋于无穷小)
console.log(Math.sqrt(-1));
console.log("abc" - 10);
console.log(undefined + 10);
console.log(parseInt("hello"));
const a = 0/0;
这些都是无意义的计算所以都是NaN

📊 六、实测数据:不同方法的解析行为对比

输入字符串 parseInt(s, 10) Number(s) 说明
"123" 123 123 相同
"123.45" 123 123.45 parseInt 截断
" 42 " 42 42 都会忽略前后空格
"42abc" 42 NaN parseInt 遇到非数字停止
"abc42" NaN NaN 两者都失败
"0xFF" 0 255 parseInt("0xFF", 16) 才是 255
"1e3" 1 1000 Number 支持科学计数法

📌 结论:根据需求选择——要整数用 parseInt(str, 10),要完整数值用 Number(str)


✅ 七、总结与最佳实践

🎯 核心要点

  1. map(callback) 会传入三个参数,即使 callback 只声明一个参数。
  2. parseInt 第二个参数是进制,误传索引会导致非法进制(如 1 进制)。
  3. 永远显式指定 radix 为 10,避免隐式行为。
  4. 不要直接传递 parseInt 给 map,除非你知道后果。

🛡 安全编码建议

js
编辑
// ✅ 推荐写法
const numbers = strArray.map(s => parseInt(s.trim(), 10));

// ✅ 更健壮的写法(带验证)
const safeParseInt = (s) => {
  if (typeof s !== 'string') return NaN;
  const n = parseInt(s.trim(), 10);
  return isNaN(n) ? null : n; // 或抛出错误
};

🔄 替代方案选择指南

需求 推荐方法
字符串 → 整数 parseInt(str, 10)
字符串 → 数值(含小数) Number(str) 或 +str
严格验证数字格式 结合正则 + Number.isNaN
大量数据转换 考虑性能,避免 try/catch

📌 八、延伸思考

  • 为什么 JavaScript 设计 parseInt 支持 radix?
    历史原因:早期 Web 需要解析不同进制的字符串(如颜色值 #ff0000)。
  • 能否用 flatMap 或其他方法避免此问题?
    不能,问题根源在于函数签名不匹配,与方法无关。
  • TypeScript 能防止这类错误吗?
    可以!TS 会提示 parseInt 的参数类型不匹配,提前暴露问题。

📚 参考资料

作者结语:看似简单的 API 组合,背后却隐藏着语言设计的细节。理解这些“坑”,不仅能写出更健壮的代码,也能在面试中脱颖而出。
欢迎点赞、收藏、评论!你是否也曾在项目中踩过这个坑?来分享你的经历吧 👇

TypeScript核心类型系统完全指南

2025年11月7日 18:21

第一部分:TypeScript基础入门

TypeScript简介

1.什么是TypeScript
  • TSJS 的超集,简单来说就是为 js添加了类型限定。众所周知js的类型系统存在 先天的缺陷,程序中很多的问题都是因为错误的 类型导致的。

    ts属于静态类型编程语言,js属于动态编程语言

2. Ts的优势
  • ts是前端项目的首选语言,ts中存在类型推断机制 不需要在代码中的每个地方都显示标注

体验TS与配置

体验ts
  1. ts交于js会对数据类型进行检查

  2. 只需要第一次 定义变量的时候对 数据类型进行注解

     let age:number=18;
    //:number 是类型注解  表示age变量的类型是number
    
常见的TS类型注解
  • 原始类型

    • number
    • string
    • boolean
    • symbol
    • null
    • undefined
  • 对象类型

  • object(数组 对象 函数)

  • 联合类型

    • 自定义类型(类型别名)
    • 接口
    • 元组
    • 字面量类型
    • 枚举
    • void
    • any
配置tsconfig.json
  • 需要在根目录中被指ts的配置文件,这是ts开发必备的 操作之一

    • {
        "compilerOptions": {
          "target": "ES2020",
          "module": "CommonJS",
          "strict": true, 
          "esModuleInterop": true,
          "skipLibCheck": true,
          "forceConsistentCasingInFileNames": true
        }
      }
      

第二部分:TypeScript核心类型系统

基础类型

原始类型

原始类型就是常见的 字符串 数字 布尔值 未定义 等等类型 基本上js怎么使用那么ts就这么使用 只是需要添加一些类型注解而已

let age: number=18;
let name: string='张三';
let sex: boolean=true;
let symbol: symbol=Symbol('123');
let nullValue: null=null;
let undefinedValue: undefined=undefined;
//js 里面怎么用 ts就是怎么用
数组类型

数组类型属于对象类型,在对象类型中每个子类型都有自己的细分语法

数组类型的类型注解: number[](推荐使用) 或者 :Array<string> 如果数组中存在多种数据类型就使用联合类型 (number | string)[]

//推荐写法
let numbers: number[]=[1,2,3,4,5];
let objArr:Object[]=[{},{}]
//其他写法
let strings:Array<string>=['1','2','3'];
//数组中含有多种类型数据 --> 联合类型
let arr: (number | string)[]=[1,'2',3,'4',5];
元组类型

元组可以看作确定元素个数与类型的数组,在部分场景里面会 使用到确定元素个数的数组类型 这种类型就叫做元组类型(比如:地图的经纬度)

元组类型类型注解::[number,number]

let Position=[31.232,12.653];
//元组的类型也不一定必须要一样
let Position2:[number,string]=[31.232,'12.653'];

枚举类型

枚举类型可以作为字面量类型的平替方案 枚举类型类似于字面量类型+联合类型的组合形态

枚举的值称为命名常量

定义枚举:

通过enum关键词定义 使用{}包裹命名常量

enum Direction{up,down,right,left}

枚举类型使用:

当使用枚举类型的函数需要调用的时候 只能通过枚举命名常量的属性来作为函数的参数

// 方向枚举
enum Direction {
    Up,
    Down,
    Left,
    Right
}

// 状态枚举
enum Status {
    Pending = "PENDING",
    Approved = "APPROVED",
    Rejected = "REJECTED"
}

// 函数参数使用枚举
function move(direction: Direction) {
    switch (direction) {
        case Direction.Up:
            console.log("向上移动");
            break;
        case Direction.Down:
            console.log("向下移动");
            break;
        // ...其他情况
    }
}

// 调用函数时使用枚举
move(Direction.Up);
枚举的种类

1.数字枚举

数字枚举是最常见的枚举类型,默认情况下第一个成员的值为0,后续成员按顺序递增:

enum Direction {
    Up,     // 值为 0
    Down,   // 值为 1
    Left,   // 值为 2
    Right   // 值为 3
}

// 使用枚举
let dir: Direction = Direction.Up;  // 值为 0
console.log(dir);  // 输出: 0

可以手动设置枚举成员的值:

// 设置起始值
enum Direction {
    Up = 1,    // 值为 1
    Down,      // 值为 2
    Left,      // 值为 3
    Right      // 值为 4
}

// 为每个成员设置具体值
enum Direction {
    Up = 1,
    Down = 3,
    Left = 5,
    Right = 9
}

2.字符枚举:

字符串枚举的每个成员都必须显式地初始化为字符串字面量,它们没有自增长行为:

enum Direction {    Up = "UP",    Down = "DOWN",    Left = "LEFT",    Right = "RIGHT" }
// 使用字符串枚举 
let dir: Direction = Direction.Up;
console.log(dir); // 输出: "UP"
  1. 异构枚举

虽然不建议使用,但技术上是可以混合字符串和数字成员的:

typescript

enum BooleanLikeHeterogeneousEnum {    No = 0,    Yes = "YES", }

字面量类型

字面量类型是一种特殊的类型,它将变量的类型限制为特定的值。与普通的stringnumberboolean类型不同,字面量类型不仅指定了值的类型,还指定了值的具体内容。

字面量类型的特点:

  1. 精确性:字面量类型比普通类型更加精确,限定了变量只能是特定的值
  2. 常量推断:使用const声明的变量,TypeScript会自动推断为字面量类型
  3. 可组合性:通过联合类型(Union Types)可以组合多个字面量类型

字面量类型用法:

  • let str1='hello ts' //string类型
    const str2='hello ts' //hello ts 字面量类型
    
  • 字面量类型通常与联合类型结合使用,用于限制函数参数或配置对象的取值范围

    // 限制函数参数的取值
    function setPosition(direction: 'left' | 'right' | 'up' | 'down') {
        // 函数体
    }
    
    // 调用时只能传入指定的字符串字面量
    setPosition('left');  // 正确
    setPosition('forward');  // 错误
    
    // 配置对象的属性限制
    interface Config {
        theme: 'light' | 'dark';
        size: 'small' | 'medium' | 'large';
    }
    
    const config: Config = {
        theme: 'dark',   // 只能是'light'或'dark'
        size: 'medium'   // 只能是'small'、'medium'或'large'
    };
    

与枚举类型相比

通常字面量类型与枚举类型可以替换

// 使用字面量类型
type Direction = 'up' | 'down' | 'left' | 'right';

// 使用枚举类型
enum DirectionEnum {
    Up,
    Down,
    Left,
    Right
}

联合类型

联合类型(Union Types)是TypeScript中的一种高级类型特性,它允许一个变量或参数可以是多种类型中的一种。联合类型使用竖线(|)分隔每个类型,表示"或"的关系。 后续在高级类型特性里面会详细介绍联合类型的各种使用方法

基本语法:

// 基本语法:Type1 | Type2 | Type3
let value: number | string;
value = 123;     // 正确,number类型
value = "hello"; // 正确,string类型
value = true;    // 错误,boolean类型不在联合类型中

复杂类型

对象类型

对象类型就是在描述对象的结构与各个属性的类型与 方法类似

1.基本写法
let person:
{
    name:string;
    age:number;
    sayHi(name:string,age:number):void
} = {
    name:'张三',
    age:18,
    sayHi(name:string):void{
        console.log('hi',name)
    }
}
2.箭头函数写法 :
//箭头函数写法
let person2:{
    name:string;
    age:number;
    // 箭头的后面写返回值类型 
    sayHi:(name:string)=>void
} = {
    name:'张三',
    age:18,
    sayHi:(name:string):void=>{
        console.log('hi',name)
    }
}

3.对象类型的可选属性
//对象类型可选属性
let person3:{
    name:string;
    age:number;
    // 箭头的后面写返回值类型 
    sayHi:(name:string)=>void;
    sex?:string;
} = {
    name:'张三',
    age:18,
    sayHi:(name:string):void=>{
        console.log('hi',name)
    },
    //sex:'man' //可有可无
}
函数类型

函数类型就是在js的基础上单独为行数的 参数 与返回值类型进行类型标注.

单独标注参数类型与返回值类型

就是单对为函数的参数与返回值进行类型标注

function add(X:number,Y:number):number{
    return X+Y;
}
同时标注二者类型

这是第一种写法 可读性很差 前面两个类型定义是定义两个参数的 后面一个类型定义是定义返回值类型的

const add2:(num1:number,num2:number)=>number=(num1,num2)=>{
    return num1+num2;
}

下面是可读性更高的一中 写法

// 定义函数类型别名
type AddFunction = (num1:number, num2:number) => number;

// 使用类型别名
const add2:AddFunction = (num1, num2) => {
    return num1 + num2;
}

箭头函数常用的定义方法

const add1 = (X:number, Y:number):number => {
    return X + Y;
}
返回值为void
function add3(X:number,Y:number):void{
    console.log(X+Y);
}
可选参数类型

在参数后加一个? 就是可选参数 但是不建议使用可选参数在需要计算的函数中. 值得注意的是 必选参数一定要放在可选参数的前面

function add4(X:number,Y?:number):void{
    console.log(X+(Y??1));
}

//必选参数不能位于可选参数之后
function mySlice(start?:number,end?:number):void{
    console.log('开始',start,'结束',end);
}
mySlice()

特殊类型

any类型

any类型是我们极不推荐使用的类型 因为any类型不会对代码进行保护 和js基本没有两样了. 如果每个变量都说用any类型,那代码就和js基本一模一样了,失去了ts作为静态类型语言的作用了

let a:any = 123;
a = '123';
//any类型会忽略类型检查 还不如不用ts
void类型

void类型表示没有任何类型,通常用于函数没有返回值的情况。如果变量被注解为void类型一般只能赋值为null或者是undefined

基本用法
// 函数没有返回值时,返回类型标记为void
function sayHello(): void {
    console.log("Hello!");
    // 不需要return语句,或者可以return;
}

// 等同于
function sayHello2(): void {
    console.log("Hello!");
    return; // 可以显式返回undefined
}

// 变量声明为void类型(不常用)
let unusable: void = undefined; // void类型只能赋值为undefined或null
实际应用场景
// 事件处理函数通常没有返回值
function handleClick(event: Event): void {
    console.log("按钮被点击了");
}

// 日志记录函数
function logMessage(message: string): void {
    console.log(`[LOG]: ${message}`);
}
null和undefined

在TypeScript中,null和undefined都有各自的类型,分别是null和undefined类型。

基本用法
// null类型
let nullValue: null = null;

// undefined类型
let undefinedValue: undefined = undefined;

// 在严格模式下,null和undefined只能赋值给any类型和它们各自类型
let num: number = null; // 错误:在严格模式下不允许
let str: string = undefined; // 错误:在严格模式下不允许
与联合类型结合使用
// 变量可以是字符串或null
let userName: string | null = null;
userName = "张三"; // 正确

// 变量可以是数字或undefined
let userAge: number | undefined = undefined;
userAge = 25; // 正确

// 函数返回值可能是对象或null
function findUser(id: number): User | null {
    // 查找用户逻辑
    // 如果找到返回User对象,否则返回null
    return null;
}
在React中的应用
// React中常见的状态初始化为null
const [user, setUser] = useState<User | null>(null);

// 使用可选链操作符安全访问属性
console.log(user?.name); // 如果user为null,不会报错
never类型

never类型表示永远不会发生的值的类型。它是TypeScript类型系统中的底部类型

使用场景
// 1. 函数抛出异常,永远不会有返回值
function throwError(message: string): never {
    throw new Error(message);
}

// 2. 函数中有无限循环,永远不会结束
function infiniteLoop(): never {
    while (true) {
        // 无限循环
    }
}

// 3. 类型守卫中的never
function exhaustiveCheck(value: never): never {
    throw new Error(`Unexpected value: ${value}`);
}
类型特点
  1. 底部类型neverTypeScript类型系统中的底部类型,它是所有类型的子类型
  2. 不可赋值:除了never本身,没有其他值可以赋值给never类型
  3. 类型推断:在某些情况下,TypeScript会自动推断出never类型

第三部分:类型高级特性

类型别名(Type Alias)

类型别名 即为自定义类型 当统一类型被多次使用时,可以通过类型别名 简化该类型的使用

定义与使用

使用type关键字来创建类型别名

// 使用type关节子创建类型别名
type myType=(number|string)[]
let arr1:myType=[1,2,'3'];
console.log(arr1);

接口(Interface)

一般情况下如果一个对象类型被多次使用的时候,为了达到复用的目的,会使用接口来描述对象的类型

定义:
 interface Person{
    name:string;
     age:number;
    sayHi():void;
}
 let person1:Person={
     name:'张三',
     age:18,
     sayHi(){
         console.log('hi',this.name)
     }
 }
接口的继承

接口可以使用extends来继承另一个接口中的类型注解

//如果两个接口有公共属性 就可以通过继承的方式实现复用
interface People{
    name:string;
}

interface Teacther extends People{
    age:number;
    subject:string;
}

let t: Teacther={
    name:'张三',
    age:18,
    subject:'Math'
}
接口的合并
// 接口声明合并
interface Window {
    title: string;
}

interface Window {
    ts: TypeScriptAPI;
}

// 现在Window接口有title和ts两个属性
接口与类型别名:

接口和类型别名很相似,都可以为对象指定类型. 但是区别也是很明显的

  • 接口可以通过继承来拓展自身的类型注解
  • 接口可以通过合并拓展自身的类型注解
  • 类型别名可以为任何类型创建别名,但是接口只适用于对象
//结构与类型别名都可以为对象指定类型
type APerosn={
    name:string,
    age:number
}

interface BPerson{
    name:string,
    age:number
}

let a:APerosn={
    name:'张三',
    age:18
}

let b:BPerson={
    name:'张三',
    age:18
}

类(Class)

1.类的定义

TS中也引入了class的语法糖,写法基本和js中的class语法糖相似 不同的是 需要提前定义类中属性与方法的类型注解

//ts引入了class语法
class Person{
    name:string;
    age:number;
    //构造函数就是一种方法 所以参数必须规定类型
    constructor(name:string,age:number){
        this.name = name;
        this.age = age;
    }
    sayHi(){
        //因为没有return TS自动做了类型推断 所以这个方法可以不用写void返回类型
        console.log(`大家好,我叫${this.name},今年${this.age}岁`);
    }
    //类的实例方法和对象的方法是一样的 也需要指定类型
    changeName(name:string){
        this.name=name;
    }
}
// 如果类没有类型 会自动默认为any属性
let p1=new Person('张三',18)
2.类的继承

类的继承分为两种 一种是继承父类 另一种是继承接口中的类型定义

类的继承和其他语言面向对象类似,可以重写继承来的方法 也可以省略不写 使用父类继承来的方法. 而接口的继承和jva`是类似的.

//ts有两种继承方法 1.extends(继承父类) 2.implements(实现接口 ts特有)
class Animal{
    move(){
        console.log('move');
    }
}

class Dog extends Animal{
    bark(){
        console.log('bark');
    }
}

let dog=new Dog();
dog.bark()

//2.implements  继承interface接口
//和java一样 接口只能定义属性和抽象方法(没有实现的方法)
interface Person{
    name:string;
    age:number;
    move():void
}

class Student implements Person{
    name: string;
    age: number;
    constructor(name:string,age:number){
        this.name=name;
        this.age=age;
    }
    move(){
        console.log('move');
    }
}
3.类成员的可见性

类中的成员属性有四种访问修饰符 不仅仅是修饰属性的 也可以修饰方法

  • public - 公开的,任何人都可以访问(默认)
  • private - 私有的,只能在类内部访问
  • protected - 受保护的,只能在类和子类中访问
  • readonly - 只读的,只能在声明时或构造函数中初始化

演示代码

// 基类 - 演示所有四种访问修饰符
class Person {
    // 1. public - 公开的,任何人都可以访问(默认)
    public name: string;
    
    // 2. private - 私有的,只能在类内部访问
    private secret: string;
    
    // 3. protected - 受保护的,只能在类和子类中访问
    protected age: number;
    
    // 4. readonly - 只读的,只能在声明时或构造函数中初始化
    readonly id: number;
    
    constructor(name: string, secret: string, age: number, id: number) {
        this.name = name;
        this.secret = secret;
        this.age = age;
        this.id = id;
    }
    
    // 公共方法可以访问所有成员
    public introduce(): void {
        console.log(`我叫${this.name},年龄${this.age},ID: ${this.id}`);
        // console.log(this.secret); // 可以在类内部访问private成员
    }
    
    // 私有方法只能在类内部调用
    private tellSecret(): void {
        console.log(`我的秘密是: ${this.secret}`);
    }
}

// 子类 - 继承Person类
class Student extends Person {
    public grade: string;
    
    constructor(name: string, secret: string, age: number, id: number, grade: string) {
        super(name, secret, age, id);
        this.grade = grade;
    }
    
    public study(): void {
        console.log(`${this.name}正在学习`);
        // 可以访问protected成员
        console.log(`年龄: ${this.age}`);
        
        // 不能访问private成员 - 会报错
        // console.log(this.secret); // Error: Property 'secret' is private
        
        // 可以访问public成员
        console.log(`ID: ${this.id}`);
    }
}

// 测试代码
const person = new Person('张三', '我喜欢吃糖', 25, 1001);
const student = new Student('李四', '我害怕考试', 18, 1002, '高三');

// 1. public成员 - 可以任意访问
console.log(person.name);     // 输出: 张三
console.log(student.name);   // 输出: 李四

// 2. private成员 - 不能在类外部访问
 console.log(person.secret);  // Error: Property 'secret' is private
 console.log(student.secret); // Error: Property 'secret' is private

// 3. protected成员 - 不能在类外部访问
 console.log(person.age);     // Error: Property 'age' is protected
 console.log(student.age);   // Error: Property 'age' is protected

// 4. readonly成员 - 可以读取但不能修改
console.log(person.id);       // 输出: 1001
// person.id = 1003;          // Error: Cannot assign to 'id' because it is a read-only property

// 调用方法
person.introduce();           // 输出: 我叫张三,年龄25,ID: 1001
student.introduce();          // 输出: 我叫李四,年龄18,ID: 1002
student.study();              // 输出: 李四正在学习\n年龄: 18\nID: 1002

// 尝试修改readonly属性 - 编译时会报错
 student.id = 1005;         
// Error: Cannot assign to 'id' because it is a read-only property

泛型(Generics)

泛型基础概念

钻石运算符 <> 里面添加的是类型变量比如T 这个T是一个变量 往里填哪个类型 T就是什么类型 他是一个类型的容器 可以自动捕获用户提供的类型.

// 泛型是可以在保证安全的清况等下 让函数与多种类型一起工作 从而实现复用 常用于函数 接口 类中
// <>叫做钻石运算符 里面添加类型变量 比如T等等 
//T是一个特殊的变量 他的处理类型不是值 他是一个类型的容器 可以自动捕获用户提供的类型
function id<T>(value:T):T{
    return value;
}
const getId=<t>(Value:t):t=>{
    return Value;
}
//调用
const num=<string>getId('123')
const num1=<number>getId(123)

//简化调用 调用泛型函数的时候 可以把尖括号省了 ts会自动识别类型(类型参数推断)
const num2=getId(123);
//有时候推断的类型可能不准确 就需要手动去定义
泛型约束

默认情况下 泛型函数的类型数量type可以代表多个类型 这导致无法访问任何属性 比如id('a')调用函数时参数的长度

function id<T>(value:T):T{
    console.log(value.length);
    return value
}

使用上面的函数会报错 因为T可以代表任意类型 无法保证一定存在length属性 此时就需要为泛型添加约束来收缩类型

有两种为泛型添加约束的方法

  • 方法1: 为type指定更具体的类型

    function id<T>(value:T[]):T[]{
      console.log(value.length);
      return value
    }
    
  • 方法2: 定义接口为T添加约束

    interface LengthWise{
        length:number
    }
    function id<T extends LengthWise>(value:T):T{
        console.log(value.length);
        return value
    }
    
    • 方法二的解释:
      • 1.创建接口提供需要的属性 比如length
      • 2.通过extends关键字使用该接口 为泛型(控制变量)添加约束
      • 表述为 传入的类型必须具有length属性

多个泛型相互约束:

泛型的类型变量可以存在多个 而且类型变量之间也可以约束的 (比如 第二个类型变量受第一个变量的约束) 比如创建一个函数来获取兑现中属性的值

//泛型的类型变量可以存在多个 而且类型变量之间也可以约束的 (比如 第二个类型变量受第一个变量的约束) 比如创建一个函数来获取兑现中属性的值

function getProp<T,K extends keyof T>(Obj:T,key:K):T[K]{
    return Obj[key];
}
let person={name:'jack',age:18}
console.log(getProp(person,'name')); //jack
//keyof关键字会接受一个对象类型 生成其键名称(可能是字符串或是数字)的联合类型
//实例中keyof T实际上获取的是person对象所有键的联合类型 也就是'name'|'age'
//类型变量k受T的约束 可以理解为 k只能是t所有键的任意一个
泛型接口与泛型类
泛型接口:

接口也可以配合泛型使用.

// 接口也可以配合泛型来使用 已增加灵活性 增强复用性
interface IdFunc<T>{
    id:(Value:T)=>T
    ids:()=>T[]
}
let Obj:IdFunc<string>={
    id(Value){
        return Value
    },
    ids(){
        return []
    }
}
泛型类:

class 也可以搭配泛型来用. 比如: react的class组件的基类 Component就是泛型 不用的组件有不同的props和state

//创建泛型类
class GenericNumber<NumType>{
    defaultvalue: NumType;
    constructor(value: NumType) {
        this.defaultvalue = value;
    }
    add(x: NumType, y: NumType): NumType {
        return (x as any) + (y as any);
    }
}

//如果类存在构造函数并且构造函数正好使用到了类的泛型 就可以省略尖括号

联合类型与交叉类型

联合类型的使用和场景
联合类型与类型别名

为了简化复杂的联合类型,可以使用类型别名:

// 定义联合类型别名
type StringOrNumber = string | number;
type Status = "pending" | "approved" | "rejected";

let value: StringOrNumber;
value = 123;
value = "hello";

let status: Status;
status = "pending";   // 正确
status = "approved";  // 正确
status = "done";      // 错误,不在指定的字面量类型中
联合类型与类型守卫

当使用联合类型时,TypeScript只允许访问所有类型共有的属性和方法。要访问特定类型的属性,需要使用类型守卫:

function processValue(value: string | number) {
    // 错误:length属性只存在于string类型中
    // console.log(value.length);
    
    // 使用类型守卫
    if (typeof value === "string") {
        // 在这个代码块中,TypeScript知道value是string类型
        console.log(value.length); // 正确
        console.log(value.toUpperCase());
    } else {
        // 在这个代码块中,TypeScript知道value是number类型
        console.log(value.toFixed(2));
    }
}
联合类型与接口

联合类型也可以与接口结合使用:

interface Bird {
    type: "bird";
    flyingSpeed: number;
}

interface Horse {
    type: "horse";
    runningSpeed: number;
}

// 联合类型
type Animal = Bird | Horse;

function moveAnimal(animal: Animal) {
    switch (animal.type) {
        case "bird":
            console.log(`Bird flying at speed: ${animal.flyingSpeed}`);
            break;
        case "horse":
            console.log(`Horse running at speed: ${animal.runningSpeed}`);
            break;
    }
}
联合类型与null/undefined

联合类型常用于处理可能为null或undefined的值:

// 用户可能未定义
let user: User | null = null;

// 在使用前需要检查
if (user !== null) {
    console.log(user.name); // 安全访问
}

// 或者使用可选链操作符
console.log(user?.name);
联合类型与字面量类型

联合类型与字面量类型结合使用可以创建枚举式的类型:

// 方向只能是这四个字符串值之一
type Direction = "up" | "down" | "left" | "right";

function move(direction: Direction) {
    // ...
}

move("up");    // 正确
move("north"); // 错误,不在指定的字面量类型中
联合类型的注意事项
  1. 只能访问共有成员:使用联合类型时,只能访问所有类型共有的属性和方法
  2. 类型守卫:要访问特定类型的属性,需要使用类型守卫进行类型检查
  3. 可读性:对于复杂的联合类型,建议使用类型别名提高可读性
  4. 过度使用:避免过度使用联合类型,可能导致代码难以维护
交叉类型:
1.定义

使用符合& 对两个接口进行组合 成一个新的类型

交叉功能类似于接口的继承 用来组合多个类型为一个类型(一般用在对象类型中)

//交叉功能类似于接口继承 用于组合多个类型为一个类型(常用于对象类型)
interface Person{
    name:string
}
interface Contact{
    phone:number;
}
type PersonContact =Person & Contact;
let obj:PersonContact={
    name:'张三',
    phone:123456789 
}

2.接口交叉与继承
  • 相同点: 都可以实现对象类型的组合
  • 不同点:两种方式实现类型组合时 对于同名属性之间处理冲突的方式不同
//交叉类型和接口继承的对比
interface A {
    fn:(vlaie:number)=>string;
}

//接口继承 出现这种情况要么接口会报错 要么只保留一个属性
interface B extends A {
    fn(value:string):string
}
//交叉类型
interface A { 
    fn:(value:number)=>string;
}
interface B { 
    fn:(value:string)=>string;
}
type C = A & B;
//可以将组合后的c简单理解为 fn:(value:(number|string))=>string

处理方法: 对于接口继承要么类型会报错 要么只保留两个类型的其中之一 然后对于交叉合成来说 可以两个类型同时保留 类似于联合类型

第四部分:类型系统进阶

类型兼容性

结构化类型系统

ts使用的是结构化的类型系统 如果类的类型定义 是一样的 尽管类名是 不一样的 但是仍然可以当做一个类来看

class Point {
    x:number;
    y:number;
    constructor(x:number,y:number) {
        this.x = x;
        this.y = y;
    }
}

class Point2D {
    x:number;
    y:number;
    constructor(x:number,y:number) {
        this.x = x;
        this.y = y;
    }
}

let p1: Point =new Point2D(1,2)  //这种写法是允许的 因为Point2D兼容Point 所以Point和Point2D可以看作是一个类
对象类型兼容
函数类型兼容

类型推断与类型断言

类型推断机制
类型断言的使用场景

映射类型与工具类型

索引签名类型

绝大多数情况下 我们都在使用对象前就确定的对象的结构 但是并未对象添准确的类型 索性签名类型就是为接口中的 索引 都进行类型标注

使用场景:无法确定对象中有哪些类型信息 此时就用索引签名类型

interface AnyObject{
  [key:string]:number
}
let obj:AnyObject={
  a:1,
  b:2
}
//解释 使用[key:string] 用来约束接口中出现的属性名 表示只要是 string类型 的属性名称都可以出现在对象中
//:number约束了属性值的类型 表示只要是 number类型 的属性值都可以出现在对象中
//key只是一个占位符 有了[key:String]:number 就可以在对象中定义任意个属性 只要属性名是字符串 属性值是数字即可
//这里的key可以是任意名称
  • 使用[key:string] 用来约束接口中出现的属性名 表示只要是 string类型 的属性名称都可以出现在对象中
  • :number约束了属性值的类型 表示只要是 number类型 的属性值都可以出现在对象中
  • key只是一个占位符 有了[key:String]:number 就可以在对象中定义任意个属性 只要属性名是字符串 属性值是数字即可,这里的key可以是任意名称
映射类型

映射类型就是基于旧类型创建新类型(对象类型) 减少重复 提升开发效率

//例子
type Propkeys='x'|'y'|'z'
type Type1={
  x:number;
  y:number;
  z:number;
}
//这样写将x y z重复写了两遍 通常可以使用映射类型来进行简化
type Type2={
  [Key in Propkeys]:number
}

//实际开发还是使用Record类型工具
type Type3=Record<Propkeys,number>;

解释:

  • 映射类型是基于索引签名类型的 所以语法类似于索引签名类型 也是用[]
  • [Key in Propkeys] 表示遍历 Propkeys 中的每个元素 并将其赋值给 Key
  • 映射类型不能用于接口 只能用于类型别名
  • 实际开发还是使用Record泛型工具

对象类型的类型映射

type Props={
  a:number;
  b:string;
  c:boolean;
}
type Type={
  [key in keyof Props]:number
}
let obj:Type={
  a:1,
  b:2,
  c:3
}
泛型工具类型
Partial<Type>

用来构造一个类型 将type的所有属性设置为可选

  • interface Props{ //每个类型都是必选的属性 如果需要可选类型需要添加'?'
        id:string;
        children:number[]
    }
    type PartialProps=Partial<Props>
    // 创建的新类型结构和props一模一样 但是所有属性是可选的
    
     const obj0:Props={
         id:'1',
        // children:[1,2,3]
     }//缺少children属性 就会报错
     
    const obj:PartialProps={
        id:'1'
    }//可以只写一个属性
    
Readonly<type>
  • 创建一个只读的类型 不可更改 就不需要单独为属性添加readonly属性

  • type readonlyProps=Readonly<Props>
    const obj1:readonlyProps={
        id:'1',
        children:[1,2,3]
    }
     obj1.id='2'//不可以修改
    
Pick<type,keys>

type中选择一组属性来构造新类型

  • pick中有两个类型变量 如果值选择一个则值传入该属性名即可

  • 第二个变量传图的属性只能是第一个类型变量中存在的属性

  • type PickProps=Pick<Props,'id'>
    const obj2:PickProps={
        id:'1'
        //children:[1,2,3]//不可以添加 添加就会报错
    }
     type PickProps = {
         id: string;
     }
    
Record<key,type>

构造一个对象类型 属性键为key 属性类型为type

  • type RecordObj=Record<'a'|'b',string>
    const obj3:RecordObj={
        a:'1',
        b:'2'
    }
    //Record工具类型有两个类型变量 1.表示对象有哪些属性 2.表示对象属性对应的类型
    

第五部分:实用技巧与最佳实践

模块与声明文件

ts文件中 有两种声明文件的方法 一个是后缀为.ts 一个是后缀为.d.ts

  • .ts文件 既包含类型信息又包含可执行代码
  • .d.ts文件 只包含类型信息 不包含可执行代码 用途是为js提供类型信息

类型声明文件概述

在开发的时候会使用很多第三方库 我们不知道这些库是用js写的还是ts写的 所以我们需要类型声明文件为已经存在的js库提供类型信息,这样我们在使用这些库的时候就可以获得类型检查和智能提示

使用第三方库的类型声明

可以使用npm i @types/库名 --save来安装库的类型信息(第三方库)

React 第五十二节 Router中 useResolvedPath使用详解和注意事项示例

作者 刺客_Andy
2025年11月7日 18:15

前言

useResolvedPathReact Router v6 提供的一个实用钩子,用于解析给定路径为完整路径对象。 它根据当前路由上下文解析相对路径,生成包含 pathname、search 和 hash 的完整路径对象。

一、useResolvedPath 核心用途

  1. 路径解析:将相对路径解析为绝对路径
  2. 链接构建:安全地构建导航链接
  3. 路径比较:比较当前路径与目标路径
  4. 动态路由处理:正确处理嵌套路由中的路径

二、useResolvedPath 解析结果对象

useResolvedPath 返回一个包含以下属性的对象: 比如原路径是:const resolved = useResolvedPath('../users?id=123#profile')

// 返回内容为
{ pathname: '/users', search: '?id=123', hash: '#profile' }
  1. pathname: 解析后的绝对路径
  2. search: 查询字符串(如果有)
  3. hash: 哈希值(如果有)

三、useResolvedPath 基本用法示例

import { useResolvedPath } from 'react-router-dom';

function PathInfo() {
  const resolved = useResolvedPath('../users?sort=name#section');
  
  return (
    <div>
      <h3>路径解析结果</h3>
      <p>原始路径: "../users?sort=name#section"</p>
      <p>解析后路径名: {resolved.pathname}</p>
      <p>查询参数: {resolved.search}</p>
      <p>哈希值: {resolved.hash}</p>
    </div>
  );
}

四、useResolvedPath 实际应用场景

4.1、在面包屑导航中解析路径

import { useResolvedPath, Link, useLocation, useMatches } from 'react-router-dom';

function Breadcrumbs() {
  const location = useLocation();
  const matches = useMatches();
  
  // 获取所有路由匹配项
  const crumbs = matches
    .filter(match => match.handle?.crumb)
    .map(match => {
      // 解析每个路由的路径
      const resolvedPath = useResolvedPath(match.pathname);
      return {
        pathname: resolvedPath.pathname,
        crumb: match.handle.crumb
      };
    });

  return (
    <nav className="breadcrumbs">
      {crumbs.map((crumb, index) => (
        <span key={index}>
          {index > 0 && ' > '}
          {index === crumbs.length - 1 ? (
            <span className="current">{crumb.crumb}</span>
          ) : (
            <Link to={crumb.pathname}>{crumb.crumb}</Link>
          )}
        </span>
      ))}
    </nav>
  );
}

// 在路由配置中使用
const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      {
        path: 'dashboard',
        handle: { crumb: '控制面板' },
        element: <Dashboard />,
        children: [
          {
            path: 'stats',
            handle: { crumb: '统计' },
            element: <StatsPage />
          }
        ]
      },
      {
        path: 'users',
        handle: { crumb: '用户管理' },
        element: <UsersPage />
      }
    ]
  }
]);

4.2、创建自定义导航链接组件

import { 
  useResolvedPath, 
  useMatch, 
  Link 
} from 'react-router-dom';

function CustomNavLink({ to, children, ...props }) {
  const resolved = useResolvedPath(to);
  const match = useMatch({ path: resolved.pathname, end: true });
  
  return (
    <div className={`nav-item ${match ? 'active' : ''}`}>
      <Link to={to} {...props}>
        {children}
      </Link>
    </div>
  );
}

// 在导航中使用
function Navigation() {
  return (
    <nav>
      <CustomNavLink to="/">首页</CustomNavLink>
      <CustomNavLink to="/about">关于</CustomNavLink>
      <CustomNavLink to="/products">产品</CustomNavLink>
      <CustomNavLink to="/contact">联系我们</CustomNavLink>
    </nav>
  );
}

4.3、在嵌套路由中正确处理相对路径

import { useResolvedPath, Link, Outlet } from 'react-router-dom';

function UserProfileLayout() {
  return (
    <div className="user-profile">
      <nav className="profile-nav">
        <ProfileNavLink to=".">概览</ProfileNavLink>
        <ProfileNavLink to="activity">活动</ProfileNavLink>
        <ProfileNavLink to="settings">设置</ProfileNavLink>
        <ProfileNavLink to="../friends">好友</ProfileNavLink>
      </nav>
      <div className="profile-content">
        <Outlet />
      </div>
    </div>
  );
}

function ProfileNavLink({ to, children }) {
  const resolved = useResolvedPath(to);
  const match = useMatch({ path: resolved.pathname, end: true });
  
  return (
    <Link 
      to={to} 
      className={match ? 'active' : ''}
    >
      {children}
    </Link>
  );
}

// 路由配置
const router = createBrowserRouter([
  {
    path: 'users',
    element: <UsersLayout />,
    children: [
      {
        path: ':userId',
        element: <UserProfileLayout />,
        children: [
          { index: true, element: <ProfileOverview /> },
          { path: 'activity', element: <ProfileActivity /> },
          { path: 'settings', element: <ProfileSettings /> }
        ]
      },
      {
        path: ':userId/friends',
        element: <UserFriends />
      }
    ]
  }
]);

4.4、动态生成侧边栏菜单

import { useResolvedPath, useMatch, Link } from 'react-router-dom';

function SidebarMenu({ items }) {
  return (
    <nav className="sidebar">
      <ul>
        {items.map((item) => (
          <MenuItem key={item.path} to={item.path} label={item.label} />
        ))}
      </ul>
    </nav>
  );
}

function MenuItem({ to, label }) {
  const resolved = useResolvedPath(to);
  const match = useMatch({ path: resolved.pathname, end: false });
  
  return (
    <li className={match ? 'active' : ''}>
      <Link to={to}>{label}</Link>
      
      {/* 显示子菜单(如果存在且匹配) */}
      {match && resolved.pathname === to && (
        <ul className="submenu">
          <li><Link to={`${to}/details`}>详细信息</Link></li>
          <li><Link to={`${to}/analytics`}>分析</Link></li>
        </ul>
      )}
    </li>
  );
}

// 使用示例
const menuItems = [
  { path: '/dashboard', label: '仪表盘' },
  { path: '/projects', label: '项目' },
  { path: '/reports', label: '报告' },
  { path: '/team', label: '团队' }
];

function AppLayout() {
  return (
    <div className="app-layout">
      <SidebarMenu items={menuItems} />
      <main className="content">
        {/* 页面内容 */}
      </main>
    </div>
  );
}

五、useResolvedPath 高级用法:路径比较工具

import { useResolvedPath, useLocation } from 'react-router-dom';

// 自定义钩子:比较当前路径是否匹配目标路径
function usePathMatch(to) {
  const resolvedTo = useResolvedPath(to);
  const location = useLocation();
  
  // 创建当前路径对象(去除可能的尾部斜杠)
  const currentPath = {
    pathname: location.pathname.replace(/\/$/, ''),
    search: location.search,
    hash: location.hash
  };
  
  // 创建目标路径对象
  const targetPath = {
    pathname: resolvedTo.pathname.replace(/\/$/, ''),
    search: resolvedTo.search,
    hash: resolvedTo.hash
  };
  
  // 比较路径是否匹配
  return (
    currentPath.pathname === targetPath.pathname &&
    currentPath.search === targetPath.search &&
    currentPath.hash === targetPath.hash
  );
}

// 在组件中使用
function NavigationItem({ to, children }) {
  const isActive = usePathMatch(to);
  
  return (
    <li className={isActive ? 'active' : ''}>
      <Link to={to}>{children}</Link>
    </li>
  );
}

六、 useResolvedPath 注意事项

6.1、相对路径解析

useResolvedPath 基于当前路由位置解析相对路径

6.2、查询参数和哈希

保留原始路径中的查询字符串和哈希值

6.3、动态路由参数

不会解析路径参数(如 :id),保持原样

6.4、性能考虑

解析操作轻量,但避免在循环中过度使用

6.5、路由上下文

必须在路由组件内部使用(在 <Router> 上下文中)

七、useResolvedPath 与相关钩子对比

在这里插入图片描述

总结

useResolvedPathReact Router v6 中处理路径的强大工具,主要用于:

  1. 在嵌套路由中正确处理相对路径
  2. 构建动态导航组件
  3. 创建面包屑导航等复杂导航结构
  4. 安全地比较路径和构建链接

通过合理使用 useResolvedPath,可以创建更健壮、可维护的路由结构,避免硬编码路径导致的错误,并简化嵌套路由中的路径处理逻辑。

vue3+qiankun主应用和微应用的路由跳转返回

作者 豆浆945
2025年11月7日 18:01

继上一次vue3+vite+qiankun搭建微前端,这次处理下主应用和微应用的路由问题

主应用和微应用都是使用 History 路由模式

主要问题集中在各个模块的路由返回

  • 微应用返回微应用
  • 主应用返回微应用

相当于:/platform/test -> /main/home -> /test

微路由/platform/test跳转到主路由/main/home(或微路由),再返回微路由变成/test

这里都是用到了主应用的路由机制,返回时匹配不到微应用路由,页面就报404了

这边的解决方案是:完整路由替换微应用路由,浏览器才能识别到

  • 主应用的跳转会记录history的历史记录里面,返回是正常的;
  • 微应用是根据路由规则动态加载了一个容器组件,跳转到微应用的路由是没有记录到history的;

监听浏览器前进后退事件,将微应用路由替换成完整路由

```
// 监听浏览器前进后退事件
window.addEventListener('popstate', (event) => {
  if (window.history.state) {
    //current表示前进或后退的路由,意思是点击跳转或者返回上一页都是当前地址栏的路由
    console.log(window.history.state)
    const index=navList.value.findIndex(item=>item.path.includes(window.history.state.current));
    if(index>-1){
      //只处理qiankun的路由
      if(navList.value[index].isQianKun){
        console.log(navList.value[index].path,'qiankun监听');     
        //将完整路由替换当前的微应用路由
        window.history.replaceState(null, null, navList.value[index].path);
      }
    }
  }
});
```
const navList=ref([
  {
    path:'/platform/child-test',
    isQianKun:true,
    name:'微应用1',
  },
  {
    path:'/platform/platform-child/child-test2',
    isQianKun:true,
    name:'微应用2',
  },
  {
    path:'/hrm/hrmLeave',
    isQianKun:false,
    name:'请假申请',
  },
  {
    path:'/hrm/hrmLeaveStatistics',
    isQianKun:false,
    name:'请假统计',
  }
])

const nav=(url,item)=>{
  if(proxy.$route.path ===url) return;

  proxy.$router.push({path:url});
}
<template v-for="item in navList">
  <el-button @click="nav(item.path,item)">{{item.name}}</el-button>
</template>

Cesium 山洪流体模拟

作者 王将近
2025年11月7日 17:51

基于 Cesium 的流体体渲染技术实现

e8bb21f05b0562b337ae56325ba3ee69.png

整体渲染流程

项目采用多通道渲染架构,通过 GPU 计算实现流体模拟:

地形捕获阶段:
正交相机 → 深度渲染 → ENU坐标转换 → 高度图纹理

流体计算阶段(循环执行):
BufferA (地形+水位计算)
    ↓
BufferB (流出量计算1)
    ↓
BufferC (水位更新)
    ↓
BufferD (流出量计算2)
    ↓
循环回 BufferA

最终渲染阶段:
体渲染Pass → Ray Marching → 深度混合 → 屏幕输出

核心类设计

1. FluidRenderer - 流体渲染器

流体渲染的主控类,负责整个渲染生命周期管理。

配置参数:

const config = {
    resolution: new Cesium.Cartesian2(1024, 1024),  // 计算纹理分辨率
    dimensions: new Cesium.Cartesian3(10000, 10000, 1000), // 流体体积尺寸(米)
    heightRange: { min: 0, max: 1000 },  // 高度归一化范围
    fluidParams: new Cesium.Cartesian4(0.995, 0.25, 0.0001, 0.1),
    customParams: new Cesium.Cartesian4(10, 20, 3, 0),
    lonLat: [120.2099, 30.1365],  // 流体中心经纬度
}

关键方法实现思路:

_generateHeightMapTexture() - 地形高度图生成

  • 创建正交投影相机俯视地形
  • 拦截 Cesium 地形渲染命令
  • 修改片段着色器输出局部坐标高度
  • 将深度信息转换为高度纹理

_createComputePasses() - 计算通道初始化

  • 创建 4 个浮点纹理作为双缓冲
  • 配置每个计算 Pass 的 Uniform 映射
  • 设置纹理依赖关系形成计算链
2. CustomPrimitive - 自定义渲染原语

封装 Cesium 的底层渲染命令,支持两种命令类型:

// 计算命令 - 用于流体模拟计算
new CustomPrimitive({
    commandType: 'Compute',
    fragmentShaderSource: shaderSource,
    uniformMap: uniforms,
    outputTexture: targetTexture
})

// 绘制命令 - 用于最终体渲染
new CustomPrimitive({
    commandType: 'Draw',
    geometry: boxGeometry,
    vertexShaderSource: vs,
    fragmentShaderSource: fs,
    uniformMap: uniforms,
    modelMatrix: transformMatrix
})

核心逻辑:

createCommand(context) {
    switch (this.commandType) {
        case 'Compute':
            return new Cesium.ComputeCommand({
                fragmentShaderSource: this.fragmentShaderSource,
                uniformMap: this.uniformMap,
                outputTexture: this.outputTexture
            });
        case 'Draw':
            return new Cesium.DrawCommand({
                vertexArray: VertexArray.fromGeometry(...),
                shaderProgram: ShaderProgram.fromCache(...),
                renderState: RenderState.fromCache(...)
            });
    }
}

流体模拟算法

物理模型

采用基于高度场的浅水方程 (Shallow Water Equations) 简化模型:

状态变量:

  • h: 地形高度
  • d: 水深
  • f: 流出量 (四方向: 右/上/左/下)

计算流程:

  1. 流出量计算 (BufferB/BufferD)
float computeOutFlowDir(vec2 centerHeight, ivec2 pos) {
    vec2 dirHeight = readHeight(pos);
    // 计算水位差 (地形高度 + 水深)
    return max(0.0, (centerHeight.x + centerHeight.y) - (dirHeight.x + dirHeight.y));
}

vec4 nOutFlow;
nOutFlow.x = computeOutFlowDir(height, p + ivec2( 1,  0));  // 向右
nOutFlow.y = computeOutFlowDir(height, p + ivec2( 0,  1));  // 向上
nOutFlow.z = computeOutFlowDir(height, p + ivec2(-1,  0));  // 向左
nOutFlow.w = computeOutFlowDir(height, p + ivec2( 0, -1));  // 向下

// 时间积分: 新流出量 = 衰减 * 旧流出量 + 强度 * 新计算值
nOutFlow = fluidParam.x * oOutFlow + fluidParam.y * nOutFlow;
  1. 水位更新 (BufferA/BufferC)
// 计算总流出量
float totalOutFlow = OutFlow.x + OutFlow.y + OutFlow.z + OutFlow.w;

// 计算总流入量 (读取邻居的流出量)
float totalInFlow = 0.0;
totalInFlow += readOutFlow(p + ivec2( 1,  0)).z;  // 右侧流向我
totalInFlow += readOutFlow(p + ivec2( 0,  1)).w;  // 上方流向我
totalInFlow += readOutFlow(p + ivec2(-1,  0)).x;  // 左侧流向我
totalInFlow += readOutFlow(p + ivec2( 0, -1)).y;  // 下方流向我

// 更新水深
waterDept = height.y - totalOutFlow + totalInFlow;

水源添加机制

通过点击地形添加水源,坐标转换流程:

addWaterSource(cartesian) {
    // 1. 世界坐标转局部 ENU 坐标
    const center = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
    const enuMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(center);
    const localMat4 = Cesium.Matrix4.inverse(enuMatrix, new Cesium.Matrix4());
    const localPos = Cesium.Matrix4.multiplyByPoint(localMat4, cartesian, ...);
    
    // 2. 局部坐标转纹理 UV (0-1)
    const u = (localPos.x + halfX) / dimensions.x;
    const v = 1.0 - ((localPos.y + halfY) / dimensions.y);
    
    // 3. UV 转像素坐标
    const pixelX = u * resolution.x;
    const pixelY = v * resolution.y;
}

在着色器中添加水源:

if (waterSourcePos.x >= 0.0 && waterSourceAmount > 0.0) {
    float dist = distance(gl_FragCoord.xy, waterSourcePos);
    if (dist < waterSourceRadius) {
        waterDept += waterSourceAmount;
    }
}

体渲染实现

Ray Marching 算法

采用 Ray Marching 技术在三维体积中追踪光线:

vec3 Render(in vec3 ro, in vec3 rd) {
    // 1. 射线与包围盒求交
    vec2 ret = boxIntersection(ro, rd, boxSize, n);
    if(ret.x <= 0.0) discard;
    
    vec3 pi = ro + rd * ret.x;  // 入射点
    
    // 2. 追踪地形表面
    float tt = ret.x;
    for (int i = 0; i < 80; i++) {
        vec3 p = ro + rd * tt;
        float h = p.y - getHeight(p).x;  // 当前高度 - 地形高度
        if (h < 0.0002 || tt > ret.y) break;
        tt += h * 0.1;  // 步进距离自适应
    }
    
    // 3. 追踪水面
    float wt = ret.x;
    for (int i = 0; i < 80; i++) {
        vec3 p = ro + rd * wt;
        float h = p.y - getHeight(p).y;  // 当前高度 - 水面高度
        if (h < 0.0002 || wt > min(tt, ret.y)) break;
        wt += h * 0.1;
    }
    
    return finalColor;
}

水深可视化

根据水深映射不同颜色:

float normalizedDepth = clamp(dist / 0.05, 0.0, 1.0);
vec3 depthColor;

if (normalizedDepth > 0.8) {
    // 最深: 红色
    depthColor = mix(vec3(1.0, 0.35, 0.0), vec3(1.0, 0.05, 0.0), ...);
} else if (normalizedDepth > 0.55) {
    // 中深: 黄色
    depthColor = mix(vec3(1.0, 1.0, 0.0), vec3(1.0, 0.3, 0.0), ...);
} else if (normalizedDepth > 0.25) {
    // 中浅: 蓝色
    depthColor = mix(vec3(0.0, 0.4, 1.0), vec3(1.0, 1.0, 0.0), ...);
} else {
    // 最浅: 绿色
    depthColor = mix(vec3(0.0, 1.0, 0.4), vec3(0.0, 0.4, 1.0), ...);
}

// 应用雾效果混合
tc = applyFog(tc, depthColor, dist * customParam.x);

地形高度图捕获

着色器拦截技术

通过修改 Cesium 地形渲染着色器来捕获高度信息:

_processHeightMapShaders() {
    const enuMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(
        Cesium.Cartesian3.fromDegrees(...lonLat, 0)
    );
    this._inverseEnuMatrix = Cesium.Matrix4.inverse(enuMatrix, ...);
    
    const commands = this._getDepthRenderCommands();
    commands.forEach(command => {
        command.uniformMap.u_inverseEnuMatrix = () => this._inverseEnuMatrix;
        command.shaderProgram = this._getDerivedShaderProgram(...);
    });
}

着色器修改:

// 原始主函数重命名
void czm_heightMap_main() {
    // ... 原始地形渲染逻辑
}

// 新主函数
uniform mat4 u_inverseEnuMatrix;
void main() {
    czm_heightMap_main();
    
    // 转换到局部 ENU 坐标
    vec3 posMC = (u_inverseEnuMatrix * vec4(v_positionMC, 1.0)).xyz;
    
    // 输出高度到纹理 R 通道
    out_FragColor = vec4(posMC.z, out_FragColor.gb, 1.0);
}

正交相机配置

创建俯视地形的正交投影相机:

_createOrthographicCamera() {
    const camera = new Cesium.Camera(scene);
    camera.frustum = new Cesium.OrthographicOffCenterFrustum();
    
    const frustum = camera.frustum;
    frustum.near = 0.01;
    frustum.far = dimensions.z * 2;
    frustum.left = -dimensions.x / 2;
    frustum.right = dimensions.x / 2;
    frustum.bottom = -dimensions.y / 2;
    frustum.top = dimensions.y / 2;
    
    // 相机位置: 中心点上方
    const offset = Cesium.Cartesian3.multiplyByScalar(dir, -frustum.far, ...);
    camera.position = Cesium.Cartesian3.add(center, offset, ...);
    camera.direction = dir;  // 向下
    
    return camera;
}

大气散射后处理

实现基于物理的大气散射效果:

Rayleigh 散射

float rayleigh_phase_func(float mu) {
    return 3. * (1. + mu*mu) / (16. * PI);
}

const vec3 betaR = vec3(5.5e-6, 13.0e-6, 22.4e-6);  // Rayleigh 散射系数
const float hR = 10e3;  // Rayleigh 尺度高度

Mie 散射

float henyey_greenstein_phase_func(float mu) {
    const float g = 0.76;  // 各向异性参数
    return (1. - g*g) / ((4. * PI) * pow(1. + g*g - 2.*g*mu, 1.5));
}

const vec3 betaM = vec3(21e-6);  // Mie 散射系数
const float hM = 3.8e3;  // Mie 尺度高度

Ray Marching 积分

vec4 get_incident_light(ray_t ray) {
    float march_step = (ray_length.y - ray_length.x) / float(num_samples);
    
    for (int i = 0; i < num_samples; i++) {
        vec3 s = ray.origin + ray.direction * march_pos;
        float height = length(s) - 6360e3;
        
        // 计算当前点的散射贡献
        float hr = exp(-height / hR) * march_step;
        float hm = exp(-height / hM) * march_step;
        
        // 累积光学深度
        optical_depthR += hr;
        optical_depthM += hm;
        
        // 计算光线到太阳的透射率
        bool overground = get_sun_light(light_ray, ...);
        if (overground) {
            vec3 attenuation = exp(-tau);
            sumR += hr * attenuation;
            sumM += hm * attenuation;
        }
    }
    
    return vec4(sumR * phaseR * betaR + sumM * phaseM * betaM, alpha);
}

性能优化策略

1. 纹理分辨率控制

// 降低计算纹理分辨率
const resolution = new Cesium.Cartesian2(512, 512);  // 从 1024 降到 512

2. Ray Marching 步数优化

// 根据距离自适应步进
for (int i = 0; i < 40; i++) {  // 从 80 降到 40
    tt += h * 0.2;  // 增大步进系数从 0.1 到 0.2
}

3. 计算频率控制

_startRenderLoop() {
    this.viewer.scene.postRender.addEventListener(() => {
        this._frameCount += this.config.timeStep;  // 控制计算速度
    });
}

调试技巧

可视化中间结果

// 查看高度图
out_FragColor = vec4(vec3(texture(heightMap, uv).r), 1.0);

// 查看水深
vec2 h = getHeight(p);
out_FragColor = vec4(0.0, 0.0, h.y * 10.0, 1.0);

// 查看流出量
vec4 flow = readOutFlow(p);
out_FragColor = vec4(flow.xy, 0.0, 1.0);

性能监控

viewer.scene.debugShowFramesPerSecond = true;  // 显示 FPS
viewer.resolutionScale = 1.0;  // 渲染分辨率缩放
viewer.scene.msaaSamples = 4;  // MSAA 抗锯齿

使用示例

初始化流体渲染器

const waterFluid = new FluidRenderer(viewer, {
    lonLat: [lon, lat],
    width: 1024,
    height: 1024,
    dimensions: new Cesium.Cartesian3(10000, 10000, 1000),
    minHeight: 0,
    maxHeight: 1000
});

添加交互控制

const viewModel = {
    param1: 10,   // 雾密度
    param2: 20,   // 高光混合
    param3: 3,    // 光强
};

function updateParam() {
    waterFluid.config.customParams.x = Number(viewModel.param1);
    waterFluid.config.customParams.y = Number(viewModel.param2);
    waterFluid.config.customParams.z = Number(viewModel.param3);
}

Cesium.knockout.track(viewModel);
Cesium.knockout.applyBindings(viewModel, toolbar);

点击添加水源

clickHandler.setInputAction((movement) => {
    let cartesian = viewer.scene.pickPosition(movement.position);
    if (!cartesian) return;
    
    waterFluid.addWaterSource(cartesian);
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);

技术要点总结

  1. 坐标系统转换: 世界坐标 → ENU局部坐标 → 纹理UV → 像素坐标
  2. 双缓冲技术: 使用 4 个纹理实现 Ping-Pong 缓冲
  3. 着色器拦截: 通过 ShaderCache 修改 Cesium 内部着色器
  4. 体渲染优化: 自适应步进距离提高 Ray Marching 效率
  5. 物理模拟: 基于高度场的流体力学简化模型

感觉m3d大佬提供的技术 站在巨人的肩膀

当循环遇上异步:如何避免 JavaScript 中最常见的性能陷阱?

作者 小时前端
2025年11月7日 17:36

引言:从一道面试题说起

"如果 JavaScript 是单线程的,为什么我们的网页在加载数据时不会卡死?在循环遍历过程中异步任务会按照预期执行么?" 这个看似简单的问题,却触及了 JavaScript 异步编程的核心。作为一名前端工程师,深入理解异步编程不仅是为了应对面试,更是为了构建高性能、用户体验良好的现代 Web 应用。

1. 异步编程:JavaScript 的"非阻塞"之道

1.1 什么是异步编程?

异步编程是一种让程序能够在等待某些操作完成的同时,继续执行其他任务的编程范式。其核心理念是:"发起操作,无需等待,完成后回调"

生活化比喻:

  • 同步:在单车道排队通行,前车不走,后车只能等待
  • 异步:在餐厅点餐,下单后无需在厨房门口等待,可以继续聊天,餐好后服务员会送来

JavaScript 中的典型异步场景:

// 网络请求
fetch('/api/data').then(response => response.json());

// 定时器
setTimeout(() => console.log('延时执行'), 1000);

// 用户交互
button.addEventListener('click', handleClick);

// 文件操作(Node.js)
fs.readFile('file.txt', 'utf8', (err, data) => {});

1.2 为什么需要异步编程?

根本原因:JavaScript 的单线程架构

想象一下:如果浏览器中 JavaScript 的每个操作都是同步的,那么一个 3 秒的网络请求就会让整个页面"冻结" 3 秒——用户无法点击、无法滚动、无法输入。这种体验在现代 Web 应用中是不可接受的。

异步编程解决了:

  • 主线程阻塞问题
  • 用户体验卡顿问题
  • 资源利用效率低下问题

2. 异步编程的演进:从回调地狱到优雅同步

2.1 回调函数:最初的解决方案

// 经典的回调地狱
getUser(userId, function(user) {
    getPosts(user.id, function(posts) {
        getComments(posts[0].id, function(comments) {
            renderPage(user, posts, comments);
        });
    });
});

存在的问题:

  • 嵌套层级过深,代码难以维护
  • 错误处理分散,容易遗漏
  • 控制流复杂,难以实现并行、竞速等场景

2.2 Promise:更优雅的链式调用

getUser(userId)
    .then(user => getPosts(user.id))
    .then(posts => getComments(posts[0].id))
    .then(comments => renderPage(user, posts, comments))
    .catch(error => console.error('处理失败', error));

改进点:

  • 链式调用,扁平化代码结构
  • 统一的错误处理机制
  • 支持 Promise.allPromise.race 等控制流

2.3 async/await:同步写法的异步实现

async function renderUserPage(userId) {
    try {
        const user = await getUser(userId);
        const posts = await getPosts(user.id);
        const comments = await getComments(posts[0].id);
        return renderPage(user, posts, comments);
    } catch (error) {
        console.error('页面渲染失败', error);
    }
}

核心优势:

  • 代码逻辑清晰,符合同步思维习惯
  • 错误处理使用熟悉的 try/catch
  • 调试体验大幅提升

3. 循环中的异步陷阱:常见误区与解决方案

3.1 不同遍历方法的异步行为差异

❌ 错误示范:forEach 的异步陷阱

async function processItems(items) {
    // 这里不会按预期工作!
    items.forEach(async (item) => {
        const result = await processItem(item);
        console.log(result);
    });
    console.log('所有项目处理完成?'); // 实际上这会立即执行
}

✅ 正确的循环异步处理

3.1.1 顺序执行:保证执行顺序

// 使用 for...of 实现顺序执行
async function processSequentially(items) {
    const results = [];
    for (const item of items) {
        // 每个项目等待前一个完成
        const result = await processItem(item);
        results.push(result);
    }
    return results;
}

// 使用 for 循环
async function processWithForLoop(items) {
    const results = [];
    for (let i = 0; i < items.length; i++) {
        const result = await processItem(items[i]);
        results.push(result);
    }
    return results;
}

3.1.2 并行执行:提升执行效率

// 使用 Promise.all 实现并行执行
async function processInParallel(items) {
    const promises = items.map(item => processItem(item));
    const results = await Promise.all(promises);
    return results;
}

// 使用 map + Promise.all 的简洁写法
async function processParallelConcise(items) {
    return Promise.all(items.map(processItem));
}

3.1.3 控制并发:平衡性能与资源

// 控制并发数量的执行
async function processWithConcurrency(items, concurrency = 3) {
    const results = [];
    
    for (let i = 0; i < items.length; i += concurrency) {
        const chunk = items.slice(i, i + concurrency);
        const chunkPromises = chunk.map(item => processItem(item));
        const chunkResults = await Promise.all(chunkPromises);
        results.push(...chunkResults);
    }
    
    return results;
}

3.2 实际业务场景示例

场景:批量上传图片

async function batchUploadImages(images, onProgress) {
    const results = {
        successful: [],
        failed: []
    };
    
    for (let i = 0; i < images.length; i++) {
        try {
            // 顺序上传,避免服务器压力过大
            const result = await uploadImage(images[i]);
            results.successful.push(result);
            
            // 更新进度
            onProgress && onProgress({
                completed: i + 1,
                total: images.length,
                current: images[i].name
            });
            
        } catch (error) {
            results.failed.push({
                image: images[i],
                error: error.message
            });
        }
    }
    
    return results;
}

场景:并行请求用户数据

async function fetchUserDashboard(userId) {
    // 并行发起多个独立请求
    const [user, orders, notifications, preferences] = await Promise.all([
        fetchUser(userId),
        fetchOrders(userId),
        fetchNotifications(userId),
        fetchPreferences(userId)
    ]);
    
    return {
        user,
        orders,
        notifications,
        preferences
    };
}

3.3 错误处理最佳实践

// 单个失败不影响其他请求
async function robustParallelProcessing(items) {
    const promises = items.map(item =>
        processItem(item)
            .then(result => ({ success: true, data: result }))
            .catch(error => ({ success: false, error, item }))
    );
    
    const results = await Promise.all(promises);
    
    return {
        successful: results.filter(r => r.success).map(r => r.data),
        failed: results.filter(r => !r.success)
    };
}

// 使用 Promise.allSettled
async function processWithAllSettled(items) {
    const results = await Promise.allSettled(
        items.map(item => processItem(item))
    );
    
    const successful = results
        .filter(result => result.status === 'fulfilled')
        .map(result => result.value);
        
    const failed = results
        .filter(result => result.status === 'rejected')
        .map(result => result.reason);
    
    return { successful, failed };
}

4. 性能优化与实战建议

4.1 选择正确的循环策略

场景 推荐方案 理由
有依赖关系的操作 for...of 顺序执行 保证执行顺序
独立并行操作 Promise.all + map 最大化并发性能
大量 I/O 操作 分块并发控制 平衡性能与资源
需要实时进度 for...of + 进度回调 便于进度跟踪

4.2 避免常见的性能陷阱

// ❌ 避免:在循环中创建不必要的异步函数
async function inefficientProcessing(items) {
    const results = [];
    for (const item of items) {
        // 每次循环都创建新的异步函数
        results.push(await someAsyncFunction(item));
    }
    return results;
}

// ✅ 推荐:预先处理或批量处理
async function efficientProcessing(items) {
    // 批量处理减少函数调用开销
    return Promise.all(items.map(someAsyncFunction));
}

5. 总结

理解 JavaScript 异步编程是现代前端开发的必备技能。从最初的回调函数到如今的 async/await,JavaScript 的异步编程能力在不断进化,让开发者能够编写出既高效又易于维护的代码。

关键要点:

  • 异步编程解决了 JavaScript 单线程的阻塞问题
  • async/await 让异步代码拥有同步代码的可读性
  • 在循环中处理异步操作时,要根据业务需求选择合适的执行策略
  • 错误处理和性能优化是异步编程中的重要考量

掌握这些概念和技巧,不仅能在面试中游刃有余,更能在实际工作中构建出性能卓越、用户体验良好的 Web 应用。异步编程虽然有一定学习曲线,但一旦掌握,将成为你工作中的利器。

Electron 集成第三方项目

作者 Bacon
2025年11月7日 17:13

如何将形态各异的开源项目(PC端App、包含前后端的Web应用)集成到统一的Electron应用商店中,并实现“开箱即用”的体验。

下面这两张架构图清晰地展示了方案的总体设计和技术选型: app-start-line.png


未命名.png flowchart TD A[用户操作] --> B(应用商店主窗口
Vue.js)

    B --> C{检测应用类型}
    C -->|Web应用| D[Web应用打包器<br>WebAppPackager]
    C -->|桌面应用| E[桌面应用启动器<br>DesktopLauncher]
    
    D --> F
    subgraph F [Web应用处理流程]
        F1{智能检测项目类型} --> F2[启动后端服务<br>(Node.js/Python/Java)]
        F2 --> F3[构建前端静态资源]
        F3 --> F4[创建Electron窗口<br>加载服务/静态资源]
    end

    E --> G[调用系统程序<br>启动原生应用]
    
    F --> H[统一运行环境]
    G --> H
    
    H --> I[用户开箱即用]
    
    D --> J[进程管理器]
    E --> J
    J --> K[应用注册表<br>(安装/卸载/元数据)]

核心设计理念

本方案的核心是设计一个应用打包器和运行时容器。它不是简单地将应用图标放在一起,而是通过一个统一的“启动器”(AppLauncher),智能地处理不同类型应用的完整启动生命周期,特别是解决Web应用前后端联动的难题。

方案详解与技术选型

1. 应用类型智能检测与处理策略

首先,我们需要一个“智能检测”模块(在架构图中体现为智能检测项目类型),它在应用安装时自动分析项目结构,决定后续如何处理。

检测目标(检查文件/配置) 识别为类型 后续处理策略
package.json(含expressnext等脚本) Node.js Web应用 启动Node服务,然后加载前端页面。
requirements.txtapp.pypom.xml Python/Java Web应用 调用相应解释器启动后端服务。
index.html+ 静态资源(无复杂后端) 静态Web应用 直接由Electron窗口加载HTML文件。
.exe.app.dmg等可执行文件 原生桌面应用 通过Node.js的child_process直接调用系统命令运行。

关键技术工具推荐: 使用 **electron-is**库,它可以帮助在运行时便捷地检测当前操作系统、运行环境(开发/生产)等,从而执行正确的平台特定命令。

2. Web应用的核心解决方案:一体化打包与启动

这是方案的精髓,对应架构图中的“Web应用处理流程”。

  • 启动后端服务:在Electron的主进程中,使用Node.js的 child_process模块,动态地运行启动命令(如 npm startpython app.py)。这个后台进程的生命周期将与您的Electron应用商店绑定。
  • 构建前端资源:同样在主进程中,自动执行构建命令(如 npm run build),生成静态文件(dist目录)。
  • 创建渲染窗口:服务启动后,创建一个Electron的BrowserWindow窗口,但其加载的URL不再是本地文件,而是指向刚刚启动的后端服务地址(例如 http://localhost:3000)。这样就完美地将前后端串联了起来。

3. 桌面应用的直接启动方案

对于原生应用,方案相对直接。通过Electron的主进程,使用Node.js的API(如Mac上的open命令,Windows上的execFile)直接启动用户下载的.app.exe文件。这对应于架构图中的“调用系统程序”。

4. 应用商店的管理与元数据

  • 应用注册表:需要一个本地的数据库或JSON文件(应用注册表),记录所有已安装应用的信息,如名称、ID、安装路径、类型、图标等。
  • 生命周期管理:提供安装、启动、卸载的API。特别是卸载时,要确保能正确停止由商店启动的后台服务进程。

关键决策与进阶考量

  1. 安全性(沙箱与权限) :现代Electron强烈建议启用上下文隔离(Context Isolation)和进程沙箱(Sandbox) 。这意味着渲染进程(即您用来显示应用商店界面的网页)不应拥有Node.js访问权限。所有对系统操作的请求(如启动应用),都应通过预加载脚本(Preload Script)定义的安全API,发送给主进程执行。

  2. 性能与资源管理

    • 懒加载:只有当用户点击打开某个AI应用时,才启动对应的后端服务。
    • 进程清理:应用关闭时,务必在Electron主进程中清理掉对应的后端服务子进程,防止资源泄漏。
  3. 不同应用的集成深度

    • 深度集成(推荐) :将Web应用完全封装在Electron窗口内,用户体验统一。
    • 浅度集成:将桌面应用作为独立进程启动,体验更原生,但管理难度稍大。

总结

这个方案的核心价值在于通过自动化和进程管理,将复杂的部署和启动流程黑盒化,为用户提供极简的“点击即用”体验。它要求您作为开发者,在Electron主进程中构建一个强大的“应用运行时引擎”,来智能处理不同类型应用的完整生命周期。

希望这个不带代码的技术方案和架构图能为您提供清晰的路径。如果您对某个具体环节(如安全性实现、应用注册表设计)有更深入的疑问,我们可以继续探讨。

你不知道的javascript:深入理解 JavaScript 的 `map` 方法与包装类机制(从基础到大厂面试题)

2025年11月7日 17:12

“小爷我是看文档学习的。” —— 这句话道出了真正成长的起点。本文将结合 MDN 官方文档、底层原理、常见陷阱与高频面试题,系统梳理 Array.prototype.map、原始类型包装类、特殊数值(NaN/Infinity)以及字符串操作细节,助你构建扎实的 JavaScript 核心知识体系。


一、map 方法:函数式编程的基石

1.1 基础定义(源自 ES5,ES6 广泛应用)

map() 是数组的高阶函数,用于对每个元素执行一次回调函数,并返回一个全新数组,原数组不变。

javascript
编辑
const doubled = [1, 2, 3].map(x => x * 2); // [2, 4, 6]
  • ✅ 不修改原数组(immutable)
  • ✅ 返回新数组,长度与原数组一致
  • ✅ 回调函数接收三个参数:(element, index, array)

1.2 经典陷阱:[1, 2, 3].map(parseInt) 为何输出 [1, NaN, NaN]

这是大厂高频面试题!

错误直觉:
javascript
编辑
["1", "2", "3"].map(parseInt) // 期望 [1, 2, 3]
实际执行逻辑:

map 传参为 (element, index, array),而 parseInt(string, radix) 第二个参数是进制

调用 等价于 结果
parseInt("1", 0) radix=0 → 默认十进制 1
parseInt("2", 1) radix=1(非法) NaN
parseInt("3", 2) 二进制中无 "3" NaN

正确写法

javascript
编辑
arr.map(x => parseInt(x, 10)); // 显式指定十进制
// 或更简洁:
arr.map(Number); // 适用于纯数字字符串

💡 面试加分点:指出 Number()parseInt() 区别——前者支持浮点和科学计数法,后者只取整。


二、JavaScript 的“面向对象”幻觉:原始类型包装类

2.1 为什么 "hello".length 能用?

在传统语言(如 Java)中,原始类型(primitive)不能调用方法。但 JS 为了简化开发者体验,引入了自动包装机制

当你写:

javascript
编辑
"hello".length

JS 引擎内部会临时执行:

javascript
编辑
(new String("hello")).length

然后立即销毁这个临时对象。

2.2 包装类有哪些?

原始类型 包装类
string String
number Number
boolean Boolean
symbol Symbol(不可构造)
bigint BigInt(不可构造)

⚠️ 注意:nullundefined 没有包装类

2.3 验证包装行为

javascript
编辑
let str = "hello";           // 原始类型
console.log(typeof str);     // "string"

let obj = new String("hello"); // 对象类型
console.log(typeof obj);     // "object"

// 但两者 .length 行为一致
console.log(str.length);     // 5
console.log(obj.length);     // 5

📌 面试提示:不要手动使用 new String(),它会产生不必要的对象开销,且 == 比较可能出错。


三、特殊数值:NaN vs Infinity

3.1 本质区别

特性 NaN Infinity
含义 Not a Number(无效计算) 无穷大(有效但超出范围)
类型 "number" "number"
自反性 NaN !== NaN Infinity === Infinity
产生场景 0/0Math.sqrt(-1)"abc" - 1 1/0Number.MAX_VALUE * 2

3.2 如何正确判断?

javascript
编辑
// 判断 NaN
Number.isNaN(NaN);        // true(推荐)
isNaN("abc");             // true(危险!会类型转换)

// 判断 Infinity
x === Infinity;           // true
isFinite(Infinity);       // false(NaN 和 Infinity 都返回 false)

3.3 大厂面试题模板

Q:typeof NaN 是什么?如何判断一个值是 NaN

A

  • typeof NaN 返回 "number",这是 JS 的历史设计。
  • 正确判断应使用 Number.isNaN(value),因为它不会进行类型转换,只对真正的 NaN 返回 true
  • 避免使用全局 isNaN(),因为它会先尝试将参数转为数字,导致 "abc" 也被误判为 NaN

四、字符串操作细节:易错 API 对比

4.1 slice(start, end) vs substring(start, end)

特性 slice() substring()
负数索引 ✅ 支持(-1 表示末尾) ❌ 转为 0
参数顺序 start > end → 返回空串 自动交换参数
推荐度 ✅ 高(行为可预测) ⚠️ 低
javascript
编辑
"hello".slice(-3, -1);      // "ll"
"hello".substring(-3, -1);  // ""(因为 -30, -10"hello".slice(3, 1);        // ""
"hello".substring(3, 1);    // "el"(自动变为 substring(1, 3))

4.2 str[i] vs str.charAt(i)

特性 str[i] str.charAt(i)
越界返回 undefined 空字符串 ""
兼容性 ES5+ 所有浏览器
性能 略快 略慢
javascript
编辑
"hi"[10];         // undefined
"hi".charAt(10);  // ""

💡 实践建议:现代开发优先用 str[i],除非需要兼容 IE8 以下。

4.3 字符串长度与 Unicode

JS 使用 UTF-16 编码,因此:

  • 英文、中文:1 个字符 = 1 个 code unit → .length = 1
  • Emoji、生僻字:1 个字符 = 2 个 code units → .length = 2
javascript
编辑
"a".length;     // 1
"中".length;    // 1
"😂".length;    // 2(代理对)

🔍 深层知识:若需准确计算“用户可见字符数”,应使用 Array.from(str).lengthIntl.Segmenter(新 API)。


五、其他数组 API 小知识

5.1 map vs forEach

方法 返回值 用途
map 新数组 数据转换(必须 return)
forEach undefined 执行副作用(如 console.log)

❌ 错误用法:不用 map 返回值 → 应改用 forEach

5.2 稀疏数组处理

map 不会遍历空槽(empty slots) ,结果数组仍保持稀疏:

javascript
编辑
[1, , 3].map(x => x * 2); // [2, empty, 6]

5.3 通用性(Generic)

map 可通过 call 用于类数组对象:

javascript
编辑
const arrayLike = { 0: 'a', 1: 'b', length: 2 };
Array.prototype.map.call(arrayLike, x => x.toUpperCase()); // ['A', 'B']

六、总结:构建你的知识网络

主题 核心要点
map 返回新数组、不改变原数组、注意回调参数陷阱
包装类 原始类型临时转对象、不要手动 new String
NaN/Infinity 都是 number 类型、判断用 Number.isNaNx === Infinity
字符串 API slice > substring[] > charAt、注意 Unicode 长度
面向对象 JS 是“基于对象”的语言,通过包装类实现原始类型方法调用

🌟 终极建议

  • 遇到问题先查 MDN 文档(到时候面试官问你,你就说你是看文档的选手,文档教的)
  • 多写测试代码验证猜想
  • 面试时不仅要答“是什么”,更要讲清“为什么”和“怎么用”

附:高频面试题清单

  1. [1, 2, 3].map(parseInt) 为什么不是 [1, 2, 3]
  2. typeof NaN 是什么?如何安全判断 NaN
  3. "hello".length 背后发生了什么?
  4. slice 和 substring 有什么区别?
  5. 为什么 NaN !== NaN?如何利用这一点检测 NaN

掌握这些,你离大厂 offer 又近了一步!🚀

《JavaScript的"魔法"揭秘:为什么基本类型也能调用方法?》

作者 Yira
2025年11月7日 17:08

前言:从一段"不可思议"的代码说起

// 这看起来合理吗?
"hello".length           // 5 - 字符串有属性?
520.1314.toFixed(2)      // "520.13" - 数字有方法?
true.toString()          // "true" - 布尔值能转换?

// 更神奇的是:
const str = "hello";
str.customProperty = "test";
console.log(str.customProperty); // undefined - 属性去哪了?

如果你曾经对这些现象感到困惑,那么恭喜你,你即将揭开JavaScript最深层的设计秘密!

第一章:面向对象的"皇帝的新装"

1.1 什么是真正的面向对象?

在传统的面向对象语言中,比如Java或C#,一切都围绕"类"和"对象"展开:

// Java:严格的面向对象
String str = new String("hello");  // 必须创建对象
int length = str.length();         // 才能调用方法

// 基本类型没有方法
int num = 123;
// num.toFixed(2); // 编译错误!

但在JavaScript中,规则完全不同:

// JavaScript:看似"魔法"的操作
const str = "hello";      // 基本类型?
console.log(str.length);  // 5 - 却能调用方法!

const num = 123.456;      // 基本类型?
console.log(num.toFixed(2)); // "123.46" - 也有方法!

这就是JavaScript的设计哲学:让简单的事情简单,让复杂的事情可能。

1.2 包装类的诞生:为了"看起来"面向对象

JavaScript想要成为一门"全面面向对象"的语言,但又不愿放弃简单易用的特性。于是,包装类(Wrapper Objects) 这个巧妙的解决方案诞生了。

第二章:包装类的工作原理

2.1 背后的"魔术表演"

当你写下 "hello".length时,JavaScript在背后上演了一场精彩的魔术:

// 你写的代码:
const length = "hello".length;

// JavaScript在背后执行的代码:
// 步骤1:创建临时String对象
const tempStringObject = new String("hello");

// 步骤2:调用length属性
const result = tempStringObject.length;

// 步骤3:立即销毁临时对象
tempStringObject = null;

// 步骤4:返回结果
length = result;

这个过程如此之快,以至于你完全察觉不到临时对象的存在!

2.2 三种包装类:String、Number、Boolean

JavaScript为三种基本数据类型提供了对应的包装类:

// String包装类
const str = "hello";
// 背后:new String(str).toUpperCase()
console.log(str.toUpperCase()); // "HELLO"

// Number包装类  
const num = 123.456;
// 背后:new Number(num).toFixed(2)
console.log(num.toFixed(2)); // "123.46"

// Boolean包装类
const bool = true;
// 背后:new Boolean(bool).toString()
console.log(bool.toString()); // "true"

2.3 证明包装类的存在

虽然包装过程是隐式的,但我们可以通过一些技巧证明它的存在:

const str = "hello";

// 尝试添加属性(证明有对象行为)
str.customProperty = "test";

// 但属性立即丢失(证明对象被销毁)
console.log(str.customProperty); // undefined

// 查看原型链(证明与String对象共享原型)
console.log(str.__proto__ === String.prototype); // true

第三章:map方法:函数式编程的典范

3.1 什么是map方法?

ES6引入的map方法是函数式编程思想的完美体现:

const numbers = [1, 2, 3, 4, 5];

// 传统做法(命令式)
const squared1 = [];
for (let i = 0; i < numbers.length; i++) {
    squared1.push(numbers[i] * numbers[i]);
}

// map方法(声明式)
const squared2 = numbers.map(num => num * num);

console.log(squared2); // [1, 4, 9, 16, 25]

核心特点

  • 不改变原数组(纯函数特性)
  • 返回新数组(必须接收返回值)
  • 1对1映射(每个元素对应一个结果)

3.2 map与包装类的完美配合

map方法经常与包装类方法一起使用,创造出优雅的代码:

const prices = [100, 200, 300];

// 链式调用:包装类 + map
const formattedPrices = prices
    .map(price => price * 0.9)      // 打9折
    .map(discounted => discounted.toFixed(2))  // 格式化为字符串
    .map(str => `$${str}`);         // 添加货币符号

console.log(formattedPrices); // ["$90.00", "$180.00", "$270.00"]

第四章:NaN的奇幻之旅

4.1 最特殊的"数字"

NaN可能是JavaScript中最令人困惑的值:

console.log(typeof NaN); // "number" - 却是数字类型!
console.log(NaN === NaN); // false - 自己不等于自己!

4.2 NaN的产生场景

// 数学运算错误
console.log(0 / 0);          // NaN
console.log(Math.sqrt(-1));  // NaN

// 类型转换失败
console.log(Number("hello")); // NaN
console.log(parseInt("abc")); // NaN

// 无穷大运算
console.log(Infinity - Infinity); // NaN

4.3 正确检测NaN

由于NaN的特殊性,检测它需要特殊方法:

// ❌ 错误方式
console.log(NaN === NaN); // false

// ✅ 正确方式
console.log(Number.isNaN(NaN));     // true
console.log(isNaN("hello"));        // true(更宽松)
console.log(Number.isNaN("hello")); // false(更严格)

第五章:实际开发中的最佳实践

5.1 包装类的正确使用姿势

// ✅ 推荐:直接使用字面量
const name = "Alice";
const age = 25;
const active = true;

// ❌ 避免:手动创建包装对象
const nameObj = new String("Alice"); // 不必要的复杂性
const ageObj = new Number(25);
const activeObj = new Boolean(true);

5.2 map方法的高级技巧

// 1. 处理对象数组
const users = [
    { name: 'Alice', age: 25 },
    { name: 'Bob', age: 30 }
];

const names = users.map(user => user.name.toUpperCase());
console.log(names); // ["ALICE", "BOB"]

// 2. 使用索引参数
const items = ['a', 'b', 'c'];
const indexed = items.map((item, index) => `${index + 1}. ${item}`);
console.log(indexed); // ["1. a", "2. b", "3. c"]

// 3. 条件映射
const numbers = [1, 2, 3, 4, 5];
const processed = numbers.map(num => 
    num % 2 === 0 ? num * 2 : num / 2
);
console.log(processed); // [0.5, 4, 1.5, 8, 2.5]

5.3 避免常见的陷阱

// 陷阱1:忘记接收map的返回值
const numbers = [1, 2, 3];
numbers.map(x => x * 2); // ❌ 结果丢失!
console.log(numbers); // [1, 2, 3] - 原数组未变

const doubled = numbers.map(x => x * 2); // ✅
console.log(doubled); // [2, 4, 6]

// 陷阱2:在map中修改原数组
const data = [{ value: 1 }, { value: 2 }];
const badResult = data.map(item => {
    item.value *= 2; // ❌ 副作用!
    return item;
});
console.log(data); // [{value:2}, {value:4}] - 原数组被修改!

const goodResult = data.map(item => ({
    ...item,          // ✅ 创建新对象
    value: item.value * 2
}));

第六章:性能优化和底层原理

6.1 包装类的性能考虑

虽然包装类很方便,但在性能敏感的场景需要注意:

// 在循环中避免重复包装
const strings = ["a", "b", "c", "d", "e"];

// ❌ 不好:每次循环都创建临时对象
for (let i = 0; i < 10000; i++) {
    strings.map(str => str.toUpperCase());
}

// ✅ 更好:预先处理
const upperStrings = strings.map(str => str.toUpperCase());
for (let i = 0; i < 10000; i++) {
    // 使用预先处理的结果
}

6.2 mapvs for循环的性能对比

const largeArray = Array.from({length: 1000000}, (_, i) => i);

console.time('map');
const result1 = largeArray.map(x => x * 2);
console.timeEnd('map');

console.time('for loop');
const result2 = [];
for (let i = 0; i < largeArray.length; i++) {
    result2.push(largeArray[i] * 2);
}
console.timeEnd('for loop');

现代JavaScript引擎中map的性能已经非常接近for循环,而且代码更清晰。

第七章:从历史看JavaScript的设计哲学

7.1 为什么JavaScript要这样设计?

JavaScript诞生于1995年,当时的设计目标很明确:

  1. 让非程序员也能使用 - 语法要简单
  2. 在浏览器中运行 - 性能要轻量
  3. 与Java集成 - 要"看起来像"Java

包装类正是这种设计哲学的产物:让简单的事情简单,让复杂的事情可能

7.2 与其他语言的对比

// Java:严格但繁琐
String str = new String("hello");
int length = str.length();

// Python:实用但不一致
text = "hello"
length = len(text)  # 函数调用,不是方法
number = 123
# number.toFixed(2)  # 错误!

// JavaScript:简单统一
const str = "hello";
const length = str.length;     // 属性访问
const num = 123.45;
const fixed = num.toFixed(2);  // 方法调用

第八章:现代JavaScript的发展趋势

8.1 更函数式的编程风格

随着React、Vue等框架的流行,函数式编程越来越重要:

// 现代React组件大量使用map
function UserList({ users }) {
    return (
        <ul>
            {users.map(user => (
                <li key={user.id}>
                    {user.name.toUpperCase()} - {user.age}
                </li>
            ))}
        </ul>
    );
}

8.2 TypeScript的增强

TypeScript为这些特性提供了更好的类型支持:

// 更安全的map使用
const numbers: number[] = [1, 2, 3];
const doubled: number[] = numbers.map(x => x * 2);

// 包装类的类型推断
const str: string = "hello";
const length: number = str.length; // TypeScript知道这是number

结语:JavaScript的智慧

通过理解包装类和map方法,我们看到了JavaScript独特的设计智慧:

  1. 实用性优先 - 解决真实问题比理论纯洁性更重要
  2. 渐进式复杂 - 从简单开始,需要时提供高级功能
  3. 开发者友好 - 让代码写起来直观,读起来清晰

下次当你写下 "hello".lengthnumbers.map(...)时,记得欣赏背后精巧的设计。这些看似简单的语法糖,实则是JavaScript历经20多年演进的智慧结晶。

记住:好的语言设计不是让一切变得可能,而是让常见任务变得简单,让复杂任务变得可能。

这是一个很酷的金属球,点击它会产生涟漪……

作者 冴羽
2025年11月7日 16:55

1. 分享

最近看到一个很酷的金属球效果:

点击它的时候会产生涟漪,效果如下:

体验地址:gnufault.github.io/ripple-sphe…

移动端也可以,但因为部署在 Github,需要你科学上网。

2. 实现

该效果使用 three.js 实现,整体代码并不复杂,核心的 JS 代码也就 130 多行。

这是源码地址:github.com/GNUfault/ri…

import * as THREE from "https://unpkg.com/three@0.150.0/build/three.module.js";

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(devicePixelRatio);
document.body.appendChild(renderer.domElement);
document.body.style.margin = "0";
document.body.style.overflow = "hidden";
document.body.style.touchAction = "none";

const envMap = new THREE.CubeTextureLoader().load([
  "https://threejs.org/examples/textures/cube/Bridge2/posx.jpg",
  "https://threejs.org/examples/textures/cube/Bridge2/negx.jpg",
  "https://threejs.org/examples/textures/cube/Bridge2/posy.jpg",
  "https://threejs.org/examples/textures/cube/Bridge2/negy.jpg",
  "https://threejs.org/examples/textures/cube/Bridge2/posz.jpg",
  "https://threejs.org/examples/textures/cube/Bridge2/negz.jpg",
]);
scene.background = envMap;

const material = new THREE.MeshStandardMaterial({
  metalness: 1,
  roughness: 0,
  envMap: envMap,
});

const geometry = new THREE.SphereGeometry(1, 128, 128);
const sphere = new THREE.Mesh(geometry, material);
scene.add(sphere);

const light = new THREE.DirectionalLight(0xffffff, 2);
light.position.set(5, 5, 5);
scene.add(light);
scene.add(new THREE.AmbientLight(0xffffff, 0.5));

camera.position.z = 3;

const originalPositions = geometry.attributes.position.array.slice();

let ripples = [];
const maxRipples = 6;

function addRipple(point) {
  if (ripples.length >= maxRipples) ripples.shift();
  ripples.push({ point, start: performance.now() });
}

function updateRipples() {
  const positions = geometry.attributes.position.array;
  const now = performance.now();
  const vertex = new THREE.Vector3();
  const temp = new THREE.Vector3();

  for (let i = 0; i < positions.length; i += 3) {
    vertex.set(originalPositions[i], originalPositions[i + 1], originalPositions[i + 2]);

    let offset = 0;
    for (const ripple of ripples) {
      const age = (now - ripple.start) / 1000;
      if (age > 2.5) continue;
      const dist = vertex.distanceTo(ripple.point);

      const fadeIn = Math.min(age * 8.0, 1.0);
      const fadeOut = Math.exp(-age * 3.0);
      const wave = Math.sin(dist * 60 - age * 25) * Math.exp(-dist * 5);

      offset += wave * 0.01 * fadeIn * fadeOut;
    }

    temp.copy(vertex).normalize().multiplyScalar(offset);
    positions[i] = originalPositions[i] + temp.x;
    positions[i + 1] = originalPositions[i + 1] + temp.y;
    positions[i + 2] = originalPositions[i + 2] + temp.z;
  }

  geometry.attributes.position.needsUpdate = true;
  geometry.computeVertexNormals();
}

function animate() {
  requestAnimationFrame(animate);
  updateRipples();
  renderer.render(scene, camera);
}
animate();

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

function handleInteraction(clientX, clientY) {
  mouse.x = (clientX / innerWidth) * 2 - 1;
  mouse.y = -(clientY / innerHeight) * 2 + 1;

  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObject(sphere);
  if (intersects.length > 0) {
    const worldPoint = intersects[0].point.clone();
    const localPoint = sphere.worldToLocal(worldPoint);
    const normalizedPoint = localPoint.normalize();
    addRipple(normalizedPoint);
  }
}

window.addEventListener("click", (event) => {
  handleInteraction(event.clientX, event.clientY);
});

window.addEventListener("touchstart", (event) => {
  event.preventDefault();
  for (let i = 0; i < event.touches.length; i++) {
    const touch = event.touches[i];
    handleInteraction(touch.clientX, touch.clientY);
  }
});

window.addEventListener("touchmove", (event) => {
  event.preventDefault();
});

window.addEventListener("resize", () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});

你可以:

  • fork 一份,添加其他效果,作为自己的作品集
  • 作为一个学习 three.js 的示例代码

【React Native+Appwrite】获取数据时的分页机制

作者 一头小鹿
2025年11月7日 16:53

一、为什么要有分页

在实战开发中,如果需要做Feed流且上拉加载更多内容,说明后端肯定有很多的数据,那么向后端获取数据的时候,总不能一次性加载所有的数据吧,这样会导致加载速度变慢,且后端数据库的压力很大。

那么我们在获取数据的时候,可以分次获取,每次获取一定数量的数据,用户上拉的时候再拉取下一页,再发起一次请求,这就是分页机制

在Appwrite中常用的分页方式有两种:

  • 基于页码Offset分页
  • 基于游标Cursor分页

二、Offset分页(页码式分页)

Offset分页是基于页码的,原理是记录当前的页码page,获取数据的时候从数据集中跳过前面的offset = (page - 1) * limit 条,获取limit条数据。

优点:

  • 逻辑直观好理解,实现简单
  • 适合数据量不大、不常变动的场景

缺点:

  • 数据频繁变动的时候(增加减少数据)可能会导致缺漏和重复,因为整体的数据会偏移
  • 数据量大的时候,需要跳过大量的数据,后端在处理的时候效率会降低

使用示例:

import { Query } from "appwrite";

async function fetchPage_offset(page = 1, limit = 15) {
  const offset = (page - 1) * limit;
  const res = await databases.listDocuments(DB_ID, COLLECTION_ID, [
    Query.orderDesc("$createdAt"),  //排序
    Query.limit(limit),    //限制数据数量
    Query.offset(offset),  //偏移量
  ]);
  return res; // 返回来的数据在res.documents, 数量是res.total
}

使用场景: 小型数据集、开发期快速验证、或者你需要精准的“页码跳转到第x页”体验。

三、Cursor分页

Cursor分页是基于游标的,原理是每次请求都按照某个顺序取一定数量的数据(一般是按照创建时间),取完以后拿出最后一条数据的ID作为游标,下一次请求的时候从这个游标之后继续取,这样定位准确,不会缺漏记录。

相当于看书,往看到的地方插个书签,下次再读就不会找错了。

优点

  • 性能更高,数据库不需要跳过大量记录
  • 稳定性强,在数据被频繁插入和删除时更可靠,以游标为基础,不易缺漏和重复
  • 适合无限滚动的场景

缺点

  • 需要按稳定字段排序(通常是创建时间$createdAt 或其它有索引的字段)
  • 实现时要处理排序方向(asc/desc)和 cursor 的关系
  • 无法直接跳转到某一页

使用示例

import { Query } from "appwrite";

//第一次请求 
async function fetchFirstPage_cursor(limit = 15) {  
  const res = await databases.listDocuments(DB_ID, COLLECTION_ID, [
    Query.orderDesc("$createdAt"),  //排序
    Query.limit(limit),   //取limit条数据
  ]);
  return res; // 数据在res.documents, 数量是res.total
}

// 获取到数据后,保存最后一条数据的ID作为游标
// const lastDocId = res.documents[res.documents.length - 1].$id;


//下一页请求
async function fetchNextPage_cursor(lastDocId, limit = 15) {
  const res = await databases.listDocuments(DB_ID, COLLECTION_ID, [
    Query.orderDesc("$createdAt"),
    Query.cursorAfter(lastDocId),  //从游标处后面找起
    Query.limit(limit),
  ]);
  return res;
}

注意cursorAfter/cursorBefore 这两个方法是基于游标,不是基于时间戳。所以在使用的时候需要和排序结合使用,先排序后按游标查找,效果更稳定。

四、总结

  • 方法选择:

    • Offset分页:数据量小,需要直接跳转到第x页的场景,实现简单
    • Cursor分页:数据量大的大部分场景,例如Feed流、评论区、聊天记录或大型数据集,效果更好,但稍微复杂
  • 分页大小(limit):为了保证首屏加载的效率,量不需要太大,可以在8~20之间

  • 游标选择:使用 Cursor 分页时,确保排序字段稳定且有索引(如 $id$createdAt,但Appwrite官方文档中使用的是id)。

  • 去重策略:不管使用什么方法分页,获取到新数据后,都要记得去重,以免出现重复的数据

  • 刷新(pull-to-refresh)策略:下拉刷新通常应该重新拉第一页,并替换列表数据而不是追加

为什么 `Promise.then` 总比 `setTimeout(..., 0)` 快?微任务的秘密

作者 烛阴
2025年11月7日 16:47

一、事件循环的两条队列

要理解微任务,我们必须先回到 JavaScript的事件循环(Event Loop)

它处理两种不同类型任务:

  1. 宏任务队列 (Macrotask):存放着独立的、较大的任务。
  2. 微任务队列 (Microtask):存放着需要被紧急执行的、较小的任务。

二、微任务 vs. 宏任务

特性 微任务 (Microtask) 宏任务 (Macrotask)
常见来源 Promise.then/catch/finallyqueueMicrotask()MutationObserverprocess.nextTick (Node.js) setTimeoutsetIntervalrequestAnimationFrame、I/O操作(文件、网络)、UI事件(点击、键盘)
执行时机 在当前任务执行结束后、下一个任务开始前立即执行。 在上一个任务结束后,由事件循环调度,中间可能穿插渲染和微任务。
执行数量 在一次循环中,会全部清空 在一次循环中,只会执行一个
优先级 。可以看作是当前任务的“收尾工作”。 。可以看作是全新的、独立的工作。
比喻 去银行办业务时,填完一张表后,你需要立即去另一个窗口签字确认。这个“签字”就是微任务,必须立刻完成,才能算当前业务(宏任务)告一段落。 你在银行办完了一项业务(宏任务),然后重新取号,等待叫到你的号再去办另一项完全独立的业务(下一个宏任务)。

三、事件循环示例

// 1. 同步代码入栈执行
console.log('Script Start');

// 2. 遇到 setTimeout,将其回调函数注册到 Web API,计时器结束后,回调被放入「宏任务队列」。
setTimeout(() => {
  console.log('Timeout');
}, 0);

// 3. 遇到 Promise.resolve(),其 .then() 的回调被立即放入「微任务队列」。
Promise.resolve().then(() => {
  console.log('Promise');
});

// 4. 同步代码入栈执行
console.log('Script End');

解析:

  1. 同步代码执行完毕:调用栈变空。输出 Script StartScript End
  2. 检查微任务队列:发现里面有一个 () => console.log('Promise')
  3. 清空微任务队列:执行该任务。输出 Promise
  4. 检查微任务队列:现在空了。
  5. (可能进行渲染)
  6. 取一个宏任务:从宏任务队列中取出 () => console.log('Timeout')
  7. 执行宏任务:执行该任务。输出 Timeout

流程结束。这完美解释了我们看到的输出顺序。

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript/TypeScript开发干货

基于 Vue3+TypeScript+Vant 的评论组件开发实践

作者 胖虎265
2025年11月7日 16:41

在现代 Web 应用中,评论功能是提升用户互动性的核心模块之一。它不仅需要满足用户发表评论、回复互动的基础需求,还需要兼顾易用性、视觉体验和功能完整性。本文将结合完整代码,详细分享基于 Vue3+TypeScript+Vant 组件库开发的评论系统实现方案,从组件设计、代码实现到状态管理,层层拆解核心细节。

联想截图_20251107164213.jpg

一、整体架构设计

整个评论系统采用「组件化 + 状态管理」的架构模式,拆分为三个核心模块,各司其职且协同工作:

模块文件 核心职责 技术核心
CommentInput.vue 评论 / 回复输入弹窗,支持文本 + 表情输入 Vue3 组合式 API、Vant Popup/Field
CommentList.vue 评论列表展示,包含点赞、回复、删除等交互 条件渲染、事件监听、组件通信
comments.ts(Pinia) 全局评论状态管理,处理数据增删改查 Pinia 状态管理、TypeScript 接口定义

这种拆分遵循「单一职责原则」,让每个模块专注于自身功能,既提升了代码可维护性,也便于后续扩展。

二、核心模块代码详解

(一)评论输入组件:CommentInput.vue

负责接收用户输入(文本 + 表情),是交互入口。核心需求:支持多行输入、表情选择、内容同步、发送逻辑。

1. 模板结构核心代码

<van-popup v-model:show="show" position="bottom">
  <div class="comment-input">
    <!-- 文本输入框 -->
    <van-field
      type="textarea"
      rows="2"
      autosize
      v-model="inputValue"
      :placeholder="placeholder"
    />
    <!-- 操作栏:表情按钮+发送按钮 -->
    <div class="comment-actions">
      <van-icon name="smile-o" @click="onEmoji" />
      <van-button
        class="send-btn"
        round
        type="primary"
        :disabled="!inputValue"
        @click="handleSend"
      >发送</van-button>
    </div>
    <!-- 表情面板:折叠/展开切换 -->
    <div class="emoji-mart-wrapper" :class="{ expanded: showAllEmojis }">
      <div class="simple-emoji-list">
        <span
          v-for="(emoji, idx) in emojis"
          :key="idx"
          class="simple-emoji"
          @click="addEmojiFromPicker(emoji)"
        >{{ emoji }}</span>
      </div>
    </div>
  </div>
</van-popup>
  • 关键设计

    • van-popup实现底部弹窗,position="bottom"确保滑入效果;
    • 文本框用autosize自动适配高度,避免输入多行时滚动混乱;
    • 表情面板通过expanded类控制高度过渡(48px→240px),配合overflow-y:auto支持滚动。

2. 逻辑核心代码

import { ref, watch, defineProps, defineEmits } from 'vue'

// 定义props和emit,实现父子组件通信
const props = defineProps({
  show: Boolean,
  modelValue: String,
  placeholder: { type: String, default: '友善发言,理性交流' }
})
const emit = defineEmits(['update:show', 'update:modelValue', 'send'])

// 响应式变量
const show = ref(props.show) // 弹窗显示状态
const inputValue = ref(props.modelValue || '') // 输入内容
const showAllEmojis = ref(false) // 表情面板展开状态

// 表情库(包含表情、动物、食物等多分类)
const emojis = ['😀', '😁', '😂', ...] // 完整列表见原代码

// 监听props变化,同步到组件内部状态
watch(() => props.show, v => show.value = v)
watch(show, v => emit('update:show', v)) // 双向绑定弹窗状态
watch(() => props.modelValue, val => inputValue.value = val)
watch(inputValue, val => emit('update:modelValue', val)) // 同步输入内容

// 表情面板展开/收起切换
function onEmoji() {
  showAllEmojis.value = !showAllEmojis.value
}

// 选择表情:拼接至输入框
function addEmojiFromPicker(emoji: string) {
  inputValue.value += emoji
  emit('update:modelValue', inputValue.value)
}

// 发送评论
function handleSend() {
  if (!inputValue.value) return
  emit('send', inputValue.value) // 向父组件传递输入内容
  inputValue.value = '' // 清空输入框
  emit('update:modelValue', '')
  showAllEmojis.value = false // 收起表情面板
  show.value = false // 关闭弹窗
}
  • 关键逻辑

    • watch实现 props 与组件内部状态的双向同步,确保父子组件数据一致;
    • 表情选择直接拼接字符串,无需处理光标位置,简化逻辑;
    • 发送按钮通过!inputValue控制禁用状态,避免空内容提交。

3. 样式优化(SCSS)

.emoji-mart-wrapper {
  background: #fff;
  height: 48px;
  max-height: 48px;
  overflow-y: hidden;
  transition: max-height 0.3s, height 0.3s; // 平滑过渡
  &.expanded {
    height: 240px;
    max-height: 240px;
    overflow-y: auto;
  }
}
.simple-emoji {
  font-size: 24px;
  cursor: pointer;
  transition: transform 0.1s;
  &:hover {
    transform: scale(1.2); //  hover放大,提升交互反馈
  }
}

(二)评论列表组件:CommentList.vue

核心展示与交互模块,负责评论列表渲染、回复、点赞、删除、长按操作等。

1. 模板结构核心代码

<div class="comment-list">
  <!-- 评论列表 -->
  <div v-for="(comment, idx) in showComments" :key="comment.id" class="comment-item">
    <!-- 评论者头像 -->
    <img class="avatar" :src="comment.avatar" />
    <div class="comment-main">
      <div class="nickname">{{ comment.nickname }}</div>
      <!-- 评论内容:支持@高亮,绑定点击/长按事件 -->
      <div
        class="content"
        @click="openReply(idx, undefined, comment.userId)"
        @touchstart="onTouchStart(idx, undefined, comment.content)"
        @contextmenu.prevent="onContextMenu(idx, undefined, comment.content, $event)"
        v-html="comment.content"
      ></div>
      <!-- 操作栏:时间、回复、点赞 -->
      <div class="meta">
        <span class="time">{{ comment.time }}</span>
        <span class="reply" @click="openReply(idx, undefined, comment.userId)">回复</span>
        <span class="like" @click="likeComment(idx)" :class="{ 'liked-active': comment.liked }">
          <van-icon name="good-job-o" />
          {{ comment.likes }}
        </span>
      </div>
      <!-- 回复列表:支持折叠/展开 -->
      <div v-if="comment.replies && comment.replies.length" class="reply-list">
        <div
          v-for="(reply, ridx) in showAllReplies[idx] ? comment.replies : comment.replies.slice(0, 1)"
          :key="reply.id"
          class="comment-item reply-item"
        >
          <!-- 回复内容结构与评论一致,略 -->
        </div>
        <!-- 折叠/展开按钮 -->
        <div v-if="comment.replies.length > 1" class="expand-reply" @click="toggleReplies(idx)">
          {{ showAllReplies[idx] ? '收起' : `展开${comment.replies.length}条回复` }}
        </div>
      </div>
    </div>
  </div>

  <!-- 输入回复弹窗(复用CommentInput组件) -->
  <CommentInput
    v-model="replyContent"
    v-model:show="showReplyInput"
    :placeholder="replyTarget ? `回复 @${getNicknameByUserId(replyTarget.userId)}:` : '请输入回复内容~'"
    @send="sendReply"
  />

  <!-- 长按/右键操作菜单 -->
  <van-action-sheet
    v-model:show="showActionSheet"
    :actions="actionOptions"
    @select="onActionSelect"
    cancel-text="取消"
  />
</div>
  • 关键设计

    • 评论与回复共用一套结构,通过reply-item类区分样式,减少冗余;
    • 回复列表默认显示 1 条,超过 1 条显示「展开」按钮,优化视觉体验;
    • 复用CommentInput组件实现回复输入,提升代码复用率;
    • v-html渲染内容,支持回复中的 @用户高亮(蓝色文本)。

2. 核心逻辑代码

import { ref, watch, computed, PropType } from 'vue'
import CommentInput from '@/components/CommentInput.vue'
import { useCommentsStore, Comment, Reply } from '@/store/comments'
import { useUserStore } from '@/store/user'
import { showToast } from 'vant'

// Props定义:接收评论列表和是否显示全部
const props = defineProps({
  comments: { type: Array as PropType<Comment[]>, required: true },
  showAll: { type: Boolean, default: false }
})
const emit = defineEmits(['more'])

const commentsStore = useCommentsStore() // 评论状态管理
const userStore = useUserStore() // 用户状态(获取当前登录用户)

// 回复相关状态
const showReplyInput = ref(false) // 回复弹窗显示状态
const replyContent = ref('') // 回复内容
const replyTarget = ref<{ commentIdx: number; replyIdx?: number; userId: string } | null>(null) // 回复目标

// 控制回复列表折叠/展开
const showAllReplies = ref(props.comments.map(() => false))
watch(() => props.comments, val => {
  showAllReplies.value = val.map(() => false) // 评论列表变化时重置折叠状态
}, { immediate: true })

// 评论列表分页:默认显示2条,showAll为true时显示全部
const showComments = computed(() => {
  return props.showAll ? props.comments : props.comments.slice(0, 2)
})

// 当前登录用户ID(用于权限控制)
const currentUserId = computed(() => userStore.userInfo?.id?.toString() || 'anonymous')

// 1. 点赞评论
function likeComment(idx: number) {
  const comment = showComments.value[idx]
  commentsStore.likeComment(comment.id) // 调用Pinia Action修改状态
}

// 2. 回复评论/回复
function openReply(commentIdx: number, replyIdx?: number, userId?: string) {
  replyTarget.value = { commentIdx, replyIdx, userId: userId || '' }
  showReplyInput.value = true
  replyContent.value = '' // 清空输入框
}

// 3. 发送回复
function sendReply(val: string) {
  if (!val || !replyTarget.value) return
  const { commentIdx, replyIdx } = replyTarget.value
  const comment = showComments.value[commentIdx]
  let content = val
  // 回复某条回复时,添加@提及
  if (replyIdx !== undefined && comment.replies[replyIdx]) {
    content = `<span style='color:#409EFF'>@${comment.replies[replyIdx].nickname}</span> ${val}`
  }
  // 调用Pinia Action添加回复
  const userInfo = userStore.userInfo
  const reply: Reply = {
    id: Date.now(), // 用时间戳作为唯一ID
    avatar: userInfo?.avatar || getAssetUrl(userInfo?.gender === 'female' ? 'avatar_woman.svg' : 'avatar_man.svg'),
    nickname: userInfo?.nickname || '匿名用户',
    userId: userInfo?.id?.toString() || 'anonymous',
    content,
    time: new Date().toLocaleString(),
    likes: 0
  }
  commentsStore.addReply(comment.id, reply)
  showReplyInput.value = false
}

// 4. 长按/右键操作(复制/删除)
const showActionSheet = ref(false)
const actionOptions = ref([{ name: '复制' }, { name: '删除' }])
const actionTarget = ref<{ commentIdx: number; replyIdx?: number; content: string } | null>(null)
let touchTimer: any = null

// 设置操作菜单(只有自己的内容才显示删除)
function setActionOptions(commentIdx: number, replyIdx?: number) {
  let canDelete = false
  if (replyIdx !== undefined) {
    const comment = showComments.value[commentIdx]
    canDelete = comment.replies[replyIdx].userId === currentUserId.value
  } else {
    const comment = showComments.value[commentIdx]
    canDelete = comment.userId === currentUserId.value
  }
  actionOptions.value = canDelete ? [{ name: '复制' }, { name: '删除' }] : [{ name: '复制' }]
}

// 移动端长按触发
function onTouchStart(commentIdx: number, replyIdx: number | undefined, content: string) {
  setActionOptions(commentIdx, replyIdx)
  touchTimer = setTimeout(() => {
    actionTarget.value = { commentIdx, replyIdx, content }
    showActionSheet.value = true
  }, 500)
}

// 长按取消
function onTouchEnd() {
  if (touchTimer) clearTimeout(touchTimer)
}

// PC端右键菜单
function onContextMenu(commentIdx: number, replyIdx: number | undefined, content: string, e: Event) {
  e.preventDefault()
  setActionOptions(commentIdx, replyIdx)
  actionTarget.value = { commentIdx, replyIdx, content }
  showActionSheet.value = true
}

// 操作菜单选择(复制/删除)
async function onActionSelect(action: { name: string }) {
  if (!actionTarget.value) return
  const { commentIdx, replyIdx, content } = actionTarget.value
  if (action.name === '复制') {
    // 提取纯文本(过滤HTML标签)
    const tempDiv = document.createElement('div')
    tempDiv.innerHTML = content
    await navigator.clipboard.writeText(tempDiv.innerText)
    showToast('已复制')
  } else if (action.name === '删除') {
    if (replyIdx !== undefined) {
      commentsStore.deleteReply(showComments.value[commentIdx].id, showComments.value[commentIdx].replies[replyIdx].id)
    } else {
      commentsStore.deleteComment(showComments.value[commentIdx].id)
    }
    showToast('已删除')
  }
  showActionSheet.value = false
}
  • 关键逻辑

    • 权限控制:通过currentUserId与评论 / 回复的userId比对,仅显示自己内容的删除按钮;
    • 回复 @提及:回复特定用户时,自动拼接<span>标签实现蓝色高亮;
    • 兼容移动端 / PC 端:通过touchstart/touchend处理长按,contextmenu处理右键菜单;
    • 分页与折叠:评论列表默认显示 2 条,回复列表默认显示 1 条,优化长列表渲染性能。

(三)状态管理:comments.ts(Pinia)

负责管理评论全局状态,提供统一的数据操作 API,避免组件间数据传递混乱。

1. 数据模型定义(TypeScript 接口)

// 回复数据模型
export interface Reply {
  id: number
  avatar: string
  nickname: string
  userId: string
  content: string
  time: string
  likes: number
  liked?: boolean // 是否点赞
}

// 评论数据模型
export interface Comment {
  id: number
  avatar: string
  nickname: string
  userId: string
  content: string
  time: string
  likes: number
  liked?: boolean
  replies: Reply[] // 关联的回复列表
}
  • 用 TypeScript 接口定义数据结构,确保类型安全,减少开发时的类型错误。

2. Pinia Store 核心代码

import { defineStore } from 'pinia'
import { getAssetUrl } from '@/utils/index'
import { Comment, Reply } from './types'

export const useCommentsStore = defineStore('comments', {
  state: () => ({
    // 初始测试数据
    comments: [
      {
        id: 1,
        avatar: getAssetUrl('avatar_woman.svg'),
        nickname: '徐济锐',
        userId: 'xujirui',
        content: '内容详细丰富,详细的介绍了电信业务稽核系统技术规范,条理清晰。',
        time: '2025-06-09 17:08:17',
        likes: 4,
        replies: [
          {
            id: 11,
            avatar: getAssetUrl('avatar_man.svg'),
            nickname: '张亮',
            userId: 'zhangliang',
            content: '文本编辑调理清晰,很不错!',
            time: '2025-06-09 17:08:17',
            likes: 4
          }
        ]
      },
      // 更多测试数据...
    ] as Comment[]
  }),
  actions: {
    // 添加评论(插入到列表头部)
    addComment(comment: Comment) {
      this.comments.unshift(comment)
    },
    // 给指定评论添加回复
    addReply(commentId: number, reply: Reply) {
      const comment = this.comments.find(c => c.id === commentId)
      if (comment) comment.replies.push(reply)
    },
    // 点赞/取消点赞评论
    likeComment(id: number) {
      const comment = this.comments.find(c => c.id === id)
      if (comment) {
        comment.liked = !comment.liked
        comment.likes += comment.liked ? 1 : -1
      }
    },
    // 点赞/取消点赞回复
    likeReply(commentId: number, replyId: number) {
      const comment = this.comments.find(c => c.id === commentId)
      if (comment) {
        const reply = comment.replies.find(r => r.id === replyId)
        if (reply) {
          reply.liked = !reply.liked
          reply.likes += reply.liked ? 1 : -1
        }
      }
    },
    // 删除评论
    deleteComment(id: number) {
      this.comments = this.comments.filter(c => c.id !== id)
    },
    // 删除回复
    deleteReply(commentId: number, replyId: number) {
      const comment = this.comments.find(c => c.id === commentId)
      if (comment) {
        comment.replies = comment.replies.filter(r => r.id !== replyId)
      }
    }
  }
})
  • 关键设计

    • 所有数据操作都通过 Action 方法实现,组件无需直接修改 State,确保数据流向清晰;
    • 点赞逻辑通过liked状态切换,同步更新likes计数,避免重复点赞;
    • 初始测试数据模拟真实场景,便于开发调试。

三、核心技术亮点

  1. TypeScript 类型安全:从组件 Props 到 Pinia 状态,全程使用 TypeScript 接口约束,减少类型错误,提升开发体验;
  2. 组件复用CommentInput组件同时支持评论和回复输入,避免重复开发;
  3. 交互体验优化:表情面板平滑过渡、点赞状态切换反馈、长按防误触(500ms 延迟)、空状态提示;
  4. 性能优化:评论 / 回复列表分页渲染、折叠显示,减少 DOM 节点数量;;
  5. 权限控制:仅当前登录用户可删除自己的评论 / 回复,提升数据安全性。
❌
❌