阅读视图

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

到底滚动了没有?用 CSS @container scroll-state 查询判断

原文:Is it scrolled? Is it not? Let's find out with CSS @container scroll-state() queries

翻译:TUARAN

欢迎关注 {{前端周刊}},每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

image.png

过去几年里,我们经常需要用 JavaScript(滚动事件、Intersection Observer)来回答一些看似简单的问题:

  • 这个 sticky 头部现在真的“贴住”了吗?
  • 这个 scroll-snap 列表现在“吸附到哪一项”了?
  • 这个容器是否还能继续滚?左边/右边还有没有内容?

@container scroll-state(本文简称“scroll-state 查询”)提供了一种 CSS 原生的状态查询方式:容器可以根据自己的滚动状态,去样式化子元素。

快速回顾:scroll-state 查询怎么用

先把某个祖先设置为 scroll-state 容器:

.scroll-ancestor {
  container-type: scroll-state;
}

然后用容器查询按状态应用样式:

@container scroll-state(stuck: top) {
  .child-of-scroll-parent {
    /* 只有“贴住顶部”时才生效 */
  }
}

Chrome 133:三件套(stuck / snapped / scrollable)

1) stuck:sticky 是否真的“贴住”了

当你用 position: sticky 做吸顶 header 时,常见需求是:只有在 header 真的贴住时才加背景、阴影。

.sticky-header-wrapper {
  position: sticky;
  inset-block-start: 0;
  container-type: scroll-state;
}

@container scroll-state(stuck: top) {
  .main-header {
    background-color: var(--color-header-bg);
    box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
  }
}

2) snapped:当前吸附项

对于 scroll-snap 画廊,你往往想高亮当前吸附项,例如放大当前卡片、改变滤镜。

.horizontal-track li {
  container-type: scroll-state;
}

@container scroll-state(snapped: inline) {
  .card-content img {
    transform: scale(1.1);
    filter: sepia(0);
  }
}

3) scrollable:某个方向上是否“还能滚”

这类需求过去常靠 JS 读 scrollLeft/scrollWidth/clientWidth。现在可以按方向做样式:

@container scroll-state(scrollable: left) {
  .scroll-arrow.left {
    opacity: 1;
  }
}

@container scroll-state(scrollable: right) {
  .scroll-arrow.right {
    opacity: 1;
  }
}

Chrome 144:新增 scrolled(最近一次滚动方向)

写作时 Chrome 144 带来了 scrolled,用于判断“最近一次滚动的方向”。这让一些常见的 UI 模式可以不写 JS:

经典的“hidey-bar” 头部

html {
  container-type: scroll-state;
}

@container scroll-state(scrolled: bottom) {
  .main-header {
    transform: translateY(-100%);
  }
}

@container scroll-state(scrolled: top) {
  .main-header {
    transform: translateY(0);
  }
}

“滚动提示”只在第一次交互后消失

例如横向滚动容器:用户一旦横向滚过,就隐藏提示。

@container scroll-state(scrolled: inline) {
  .scroll-indicator {
    opacity: 0;
  }
}

小结

scroll-state 查询把一部分“滚动状态机”的能力下放给 CSS:

  • 能做渐进增强时,UI 代码会更轻、更稳定;
  • 状态可由浏览器内部实现,避免滚动事件带来的性能与时序问题;
  • 但要大规模依赖,还需要更完整的跨浏览器支持。

进一步阅读:

测量 SVG 渲染时间

原文:Measuring SVG rendering time

翻译:TUARAN

欢迎关注 {{前端周刊}},每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

本文想回答两个很直接的问题:

  • 大型 SVG 的渲染是否显著比小 SVG 慢?有没有一个“超过就很糟糕”的尺寸阈值?
  • 如果把这些 SVG 转成 PNG,渲染表现会怎样?

为此,作者生成了一批测试图片,并用自动化脚本测量“点击插入图片到下一次绘制”的时间(INP 相关)。

测试图片

一个 Python 脚本(gen.py)生成了 199 个 SVG 文件:

  • 1KB 到 100KB:每 1KB 一个
  • 200KB 到 10MB:每 100KB 一个

每个 SVG 都是 1000×1000,包含随机的路径、圆、矩形等形状;颜色、位置、线宽随机化。

然后用 convert-to-png.js(Puppeteer)把所有 SVG 转成 PNG:

  • omitBackground: true(保持透明背景)
  • 转完再过一遍 ImageOptim

作者用 chart-sizes.html 展示了 SVG 与 PNG 的文件大小分布:SVG 一路可以到 10MB,但 PNG 很少到那么大;在小尺寸区间往往 SVG 更小,而超过约 2MB 后,PNG 反而更小。

(原文附图)

接下来是渲染测试页:一次只渲染一张图。

测试页面

test.html 接受文件名参数,例如:?file=test_100KB&type=svg

页面逻辑:

  • new Image() 预加载图片(因为我们不关心下载时间,只关心渲染)
  • 预加载完成后显示一个 “inject” 按钮
  • 点击按钮后,把图片 append 到 DOM

为了捕获交互到绘制的成本,用 PerformanceObserver 监听 event entries,并计算 INP 分解:

  • input delay
  • processing duration
  • presentation delay

其中 presentation delay 指点击处理结束到浏览器实际绘制的时间;作者主要关注最终的 INP。

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'pointerup' || entry.name === 'click') {
      const inputDelay = entry.processingStart - entry.startTime;
      const processingDuration = entry.processingEnd - entry.processingStart;
      const presentationDelay =
        entry.duration - (entry.processingEnd - entry.startTime);
      const totalINP = entry.duration;
      // ...
    }
  }
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });

自动化测量

measure.js 是一个 Puppeteer 脚本,流程大致是:

  • 启动 Chrome
  • 对每个测试文件:
    • 先打开 blank.html 重置状态
    • 再打开带参数的 test.html
    • 等预加载完成
    • 开始 DevTools trace
    • 点击 inject,把图片插入 DOM
    • 等待 PerformanceObserver 回报
    • 停止 trace
    • 从 observer 与 trace 中提取 INP
  • 每个文件跑 3 次,取中位数
  • 输出 JSON 结果

命令行参数:

  • --png:测 PNG(默认测 SVG)
  • --throttle=N:CPU 降速(例如 --throttle=4 表示 4× 变慢)
  • --output=file.json:输出文件名

作者试过开/不开 throttle,整体趋势不变,差别主要体现在绝对耗时变大。

开跑

node measure.js --svg --output=results-svg.json
node measure.js --png --output=results-png.json

结果

可以在 chart.html 查看完整图表。

SVG 结果(全量):

SVG 结果(<= 1MB):

PNG 结果:

作者观察到:

  • PerformanceObserver 的 INP 与 profiler 的 INP 很接近
  • SVG 的渲染时间呈现一种“阶梯式”增长:
    • 小于约 400KB 的 SVG,渲染耗时差不多
    • 之后会在某些区间出现明显跃迁(例如约 1.2MB)
  • PNG 也似乎有类似阶梯,但由于 1–2MB 区间样本较少,不如 SVG 明显
  • 不管格式如何,400KB 以下基本都在同一渲染档位;当文件更大时,尤其是非常大时,PNG 倾向更快

作者还展示了生成图片的样子(例如 60KB 的 SVG),更大文件只是叠加更多形状以提高体积:

前端向架构突围系列 - 基建与研发效能 [10 - 2]:前端 DevOps、容器化与 Nginx

前言

如果一个前端架构师对 nginx.conf 感到陌生,对 Dockerfile 感到恐惧,那他所谓的“效能优化”注定是虚幻的。

真正的交付体系,是让开发者从点击“Merge”的那一刻起,就能预见到代码在生产环境的完美运行。

image.png


一、 容器化:终结“我本地是好的”

前端环境看起来简单(不就是个 Node.js 吗?),但 Node 版本差异、构建依赖库(如 node-sass)的编译环境、甚至是操作系统层面的字符集,都会导致构建结果的偏差。

在没有 Docker 的时候,前端代码就像裸奔。你本地用 Node 18 编译得好好的,发到服务器上发现运维装的是 Node 14,结果因为一个可选链语法(?.)直接报错挂掉。

  • 锁定新鲜度: Docker 把代码需要的 Node 版本、甚至系统底层依赖全部打包在一起。
  • 拒绝对齐: 你不需要求着运维去升级服务器的 Node 环境。你的镜像自带 Node,服务器只需要支持运行 Docker 即可。

1.1 Docker 是前端的“保鲜膜”

不要再在服务器上直接安装 Node 环境。通过 Docker,我们将**“构建环境 + 运行时环境 + 静态资源”**打包成一个只读的镜像。

  • 一致性: 开发、测试、生产使用同一个镜像,环境抖动率降为零。
  • 分层构建 (Multi-stage Builds): 这是前端镜像瘦身的必修课。

最佳实践示例:

  1. 第一阶段(Build): 使用 node 镜像安装依赖并执行 npm build
  2. 第二阶段(Production): 丢弃 Node 环境,只将 dist 产物拷贝到极简的 nginx:alpine 镜像中。

结果: 镜像体积从 1GB 缩减到 20MB。

那么有人肯定会问了, 为什么呢?

第一阶段:重装武器的“工地”(Build Stage)

  • 任务: npm install 下载成千上万个 node_modules
  • 重量: 包含整个 Node.js 运行时、各种编译工具、缓存文件。
  • 结果: 这一层就像个巨大的厨房,占用了 1GB 空间,但这只是为了烤出一块饼干。

第二阶段:极简的“展示柜”(Production Stage)

  • 任务: 只要第一阶段生成的 /dist 静态文件夹。
  • 操作: 扔掉沉重的 Node 厨房,拿出一个只有几 MB 大小的 Nginx(静态资源服务器)。
  • 结果: 最终交付的镜像里只有 Nginx + 你的 HTML/JS。体积瞬间缩减到 20MB 左右。

对比总结:为什么这是最佳实践?

维度 传统方式(直接装 Node) Docker 多阶段构建
部署风险 高(“我本地明明是好的”) 极低(镜像即环境,全球统一)
服务器要求 必须安装指定版本的 Node/npm 只需安装 Docker,零环境依赖
传输效率 慢(传输 1GB 镜像到仓库) 快(20MB 镜像秒传)
安全性 源代码和 node_modules 暴露在生产环境 极高(只包含打包后的产物,源码不可见)

二、 CI/CD:自动化是效能的唯一出路

如果你还在手动运行脚本并上传产物,你不仅在浪费时间,还在制造事故。

2.1 现代前端流水线的“四道关卡”

一个合格的 CI/CD 管道(Pipeline)应该像工厂流水线一样严丝合缝:

  1. 检测关 (Lint & Type Check): 拒绝任何格式不符或 TS 类型报错的代码进入构建。
  2. 质量关 (Test): 运行单元测试和关键路径的 E2E 测试。
  3. 构建关 (Build & Scan): 云端构建,并自动扫描镜像漏洞和第三方库安全。
  4. 分发关 (Deploy): 自动推送到镜像仓库,并触发 K8s 或 CDN 更新。

三、 Nginx:前端架构的“守门神”

Nginx 不仅仅是反向代理,它是前端架构在网络层的延伸。

3.1 前端必须掌握的 Nginx 绝学

  • 单页应用 (SPA) 路由支持: 解决刷新页面 404 的顽疾。 location / { try_files uriuri uri/ /index.html; }

  • 极致压缩: 同时开启 Gzip 和 Brotli。Brotli 的压缩率比 Gzip 高 20%,对 JS/CSS 提速效果极其显著。

  • 缓存策略治理: * index.html:设置 no-cache,确保用户总能拿到最新的入口。

    • static assets (带 hash 的资源):设置 max-age=1y,实现永久缓存。

3.2 动态配置与 BFF 联通

在微前端或 BFF 架构下,Nginx 承担了流量分发的重任。架构师应学会利用 Nginx 的 proxy_pass 解决跨域问题,而不是在代码里写死 API 地址。


四、 架构演进:从“全量发布”到“优雅灰度”

交付的最高境界是:用户对发布无感知,且出错可秒级回滚。

  • 蓝绿部署 (Blue-Green): 同时存在两套环境,一键切换流量。
  • 金丝雀发布 (Canary): 先让 5% 的用户试用新版,观察监控指标(错误率、白屏率)无异常后再全量推开。
  • 核心逻辑: 这种能力通常依赖于 K8s 的 Ingress 配置或 CDN 的边缘计算(Edge Functions)。

五、 总结:交付是架构的归宿

没有稳健的交付,再精妙的代码也是空中楼阁。 当我们把 Docker、CI/CD 和 Nginx 揉碎并内化到前端研发流程中时,我们打通的不只是技术链路,更是团队的信任链路

架构师不应该只是“写代码的人”,更应该是“制定生产规则的人”。


结语:迈向“研发中台”

我们打通了零件(物料)和通路(交付),但随着业务线增加,每个项目都去配一套 Jenkins、写一遍 Dockerfile、调一遍 Nginx 依然是低效的。

我们需要一套**“一站式”**的系统,把这些能力封装起来,让开发者只需要关心业务逻辑。

Next Step:

既然每一个环节都已标准化,那我们为什么不把它们做成一个产品? 下一节,我们将讨论前端基建的集大成者。 我们将探讨如何通过平台化思维,彻底终结“人肉配置”时代。

JavaScript 内存泄漏与性能优化:V8引擎深度解析

当我们的应用变慢,甚至崩溃时,这可能并不是代码逻辑问题,而是内存和性能问题。理解V8引擎的工作原理,掌握性能分析和优化技巧,是现代JavaScript开发者必备的核心能力。

前言:从一次真实的内存泄漏说起

class User {
    constructor(name) {
        this.name = name;
        this.element = document.createElement('div');
        this.element.textContent = `用户: ${name}`;
        // 将DOM元素存储在类实例中
        this.element.onclick = () => this.handleClick();
        document.body.appendChild(this.element);
    }
    handleClick() {
        console.log(`${this.name} 被点击了`);
    }
    // 缺少清理方法!
}

// 使用
const users = [];
for (let i = 0; i < 1000; i++) {
    users.push(new User(`用户${i}`));
}

上述代码存在几个问题:

  1. 即使删除users数组,User实例也不会被垃圾回收:因为DOM元素和事件监听器仍然保持引用
  2. 内存使用会持续增长,直到页面崩溃

这个简单的例子展示了 JavaScript 内存管理的复杂性,本篇文章将深入讲解其背后的原理。

JavaScript内存管理基础

内存的生命周期:分配 → 使用 → 释放

1. 内存分配

// 原始类型:直接分配在栈内存
let number = 42;           // 数字
let string = 'hello';      // 字符串
let boolean = true;        // 布尔值
let nullValue = null;      // null
let undefinedValue;        // undefined
let symbol = Symbol('id'); // Symbol
let bigInt = 123n;         // BigInt

// 引用类型:分配在堆内存,栈中存储引用地址
let array = [1, 2, 3];     // 数组
let object = { a: 1 };     // 对象
let functionRef = () => {}; // 函数
let date = new Date();     // Date对象

2. 内存使用

function processData(data) {
  // 创建局部变量
  const processed = data.map(item => item * 2);

  // 创建闭包
  const counter = (() => {
    let count = 0;
    return () => ++count;
  })();

  // 使用内存
  console.log('处理数据:', processed);
  console.log('计数:', counter());

  // 内存引用关系
  const refExample = {
    data: processed,
    counter: counter,
    self: null // 自引用
  };
  refExample.self = refExample; // 循环引用

  return refExample;
}

3. 内存释放(垃圾回收)

function createMemory() {
  const largeArray = new Array(1000000).fill('x');
  return () => largeArray[0]; // 闭包保持引用
}

let memoryHolder = createMemory(); // 创建闭包并保持引用

// 手动释放引用
memoryHolder = null;

垃圾回收算法

1. 引用计数(Reference Counting)


class ReferenceCountingExample {
  constructor() {
    this.refCount = 0;
  }

  addReference() {
    this.refCount++;
    console.log(`引用计数增加: ${this.refCount}`);
  }

  removeReference() {
    this.refCount--;
    console.log(`引用计数减少: ${this.refCount}`);
    if (this.refCount === 0) {
      console.log('没有引用,可以回收内存');
      this.cleanup();
    }
  }

  cleanup() {
    console.log('执行清理操作');
  }
}

引用计数算法的问题:当A和B相互引用时,即使外部不再引用A和B,引用计数也不为0,无法回收。

2. 标记清除(Mark-and-Sweep)

class MarkAndSweepDemo {
  constructor() {
    this.marked = false;
    this.children = [];
  }

  // 模拟标记阶段
  mark() {
    if (this.marked) return;

    this.marked = true;
    console.log(`标记对象: ${this.name || '匿名对象'}`);

    // 递归标记所有引用的对象
    this.children.forEach(child => child.mark());
  }

  // 模拟清除阶段
  static sweep(objects) {
    const survivors = [];

    objects.forEach(obj => {
      if (obj.marked) {
        obj.marked = false; // 重置标记
        survivors.push(obj);
      } else {
        console.log(`回收对象: ${obj.name || '匿名对象'}`);
        obj.cleanup();
      }
    });

    return survivors;
  }

  cleanup() {
    console.log('清理对象资源');
  }
}

内存泄漏的常见模式

意外的全局变量

示例1:忘记声明变量

function createGlobalVariable() {
  // 错误:忘记写 var/let/const
  globalLeak = '这是一个全局变量'; // 实际上:window.globalLeak = ...
  console.log('创建了全局变量:', globalLeak);
}

示例2:this指向全局

function accidentalGlobalThis() {
  // 在非严格模式下,this指向window
  this.leakedProperty = '意外添加到window';
  console.log('this指向:', this === window);
}

示例3:事件监听器的this问题

const button = document.createElement('button');
button.textContent = '点击我';

button.addEventListener('click', function() {
  // 这里的this指向button元素
  this.clicked = true; // 正确:添加到DOM元素
  window.leakedFromEvent = '来自事件的泄漏'; // 错误:添加到window
});

解决方案

1. 使用严格模式
'use strict';
2. 使用let/const
function safeFunction() {
  const localVar = '局部变量';
  let anotherLocal = '另一个局部变量';
}
3. 使用模块作用域
(function() {
  var moduleScoped = '模块作用域变量';
})();
4. 使用类字段
class SafeClass {
  // 类字段自动绑定到实例
  leaked = '不会泄漏到全局';

  constructor() {
    this.instanceProperty = '实例属性';
  }

  method() {
    const localVar = '局部变量';
  }
}

遗忘的定时器和回调

示例1:未清理的定时器

class TimerLeak {
  constructor(name) {
    this.name = name;
    this.data = new Array(10000).fill('timer data');

    // 启动定时器但忘记清理
    this.intervalId = setInterval(() => {
      console.log(`${this.name} 定时器运行中...`);
      this.processData();
    }, 1000);
  }

  processData() {
    // 模拟数据处理
    return this.data.map(item => item.toUpperCase());
  }

  // 缺少清理方法!
}

示例2:未移除的事件监听器

class EventListenerLeak {
  constructor(elementId) {
    this.element = document.getElementById(elementId) ||
      document.createElement('div');
    this.data = new Array(5000).fill('event data');

    // 添加事件监听器
    this.handleClick = this.handleClick.bind(this);
    this.element.addEventListener('click', this.handleClick);

    // 添加多个监听器
    this.element.addEventListener('mouseenter', this.handleMouseEnter.bind(this));
    window.addEventListener('resize', this.handleResize.bind(this));
  }

  handleClick() {
    console.log('元素被点击');
    this.processData();
  }

  handleMouseEnter() {
    console.log('鼠标进入');
  }

  handleResize() {
    console.log('窗口大小改变');
  }

  processData() {
    return this.data.slice();
  }

  // 忘记在销毁时移除监听器
}

示例3:Promise和回调地狱

class PromiseLeak {
  constructor() {
    this.data = new Array(10000).fill('promise data');
    this.pendingPromises = [];
  }

  startRequests() {
    for (let i = 0; i < 10; i++) {
      const promise = this.makeRequest(i)
        .then(response => {
          console.log(`请求 ${i} 完成`);
          this.processResponse(response);
        })
        .catch(error => {
          console.error(`请求 ${i} 失败:`, error);
        });

      this.pendingPromises.push(promise);
    }
  }

  makeRequest(id) {
    return new Promise(resolve => {
      setTimeout(() => {
        resolve({ id, data: this.data });
      }, Math.random() * 3000);
    });
  }

  processResponse(response) {
    // 处理响应
    return response;
  }

  // 忘记清理pendingPromises数组
}

解决方案

1. 正确的定时器管理
class SafeTimer {
  constructor(name) {
    this.name = name;
    this.data = new Array(1000).fill('safe data');
    this.intervals = new Set();
    this.timeouts = new Set();
  }

  startInterval(interval = 1000) {
    const id = setInterval(() => {
      console.log(`${this.name} 安全运行`);
    }, interval);

    this.intervals.add(id);
    return id;
  }

  startTimeout(delay = 2000) {
    const id = setTimeout(() => {
      console.log(`${this.name} 超时执行`);
      this.timeouts.delete(id);
    }, delay);

    this.timeouts.add(id);
    return id;
  }

  cleanup() {
    console.log(`清理 ${this.name}`);

    // 清理所有定时器
    this.intervals.forEach(id => clearInterval(id));
    this.timeouts.forEach(id => clearTimeout(id));

    this.intervals.clear();
    this.timeouts.clear();

    // 清理数据
    this.data.length = 0;
  }
}
2. 使用WeakRef和FinalizationRegistry
class WeakTimerManager {
  constructor() {
    this.timers = new Map(); // 保存定时器ID
    this.registry = new FinalizationRegistry((id) => {
      console.log(`对象被垃圾回收,清理定时器 ${id}`);
      clearInterval(id);
    });
  }

  register(object, callback, interval) {
    const weakRef = new WeakRef(object);
    const id = setInterval(() => {
      const obj = weakRef.deref();
      if (obj) {
        callback.call(obj);
      } else {
        console.log('对象已被回收,停止定时器');
        clearInterval(id);
      }
    }, interval);

    this.timers.set(object, id);
    this.registry.register(object, id, object);

    return id;
  }

  unregister(object) {
    const id = this.timers.get(object);
    if (id) {
      clearInterval(id);
      this.timers.delete(object);
      this.registry.unregister(object);
    }
  }
}
3. 事件监听器的正确管理
class SafeEventListener {
  constructor(element) {
    this.element = element;
    this.handlers = new Map(); // 存储事件处理函数
  }

  add(event, handler, options) {
    const boundHandler = handler.bind(this);
    this.element.addEventListener(event, boundHandler, options);

    // 保存引用以便清理
    if (!this.handlers.has(event)) {
      this.handlers.set(event, []);
    }
    this.handlers.get(event).push({ handler, boundHandler });

    return boundHandler;
  }

  remove(event, handler) {
    const handlers = this.handlers.get(event);
    if (handlers) {
      const index = handlers.findIndex(h => h.handler === handler);
      if (index !== -1) {
        const { boundHandler } = handlers[index];
        this.element.removeEventListener(event, boundHandler);
        handlers.splice(index, 1);
      }
    }
  }

  removeAll() {
    this.handlers.forEach((handlers, event) => {
      handlers.forEach(({ boundHandler }) => {
        this.element.removeEventListener(event, boundHandler);
      });
    });
    this.handlers.clear();
  }
}
4. 使用AbortController取消异步操作
class SafeAsyncOperations {
  constructor() {
    this.controllers = new Map();
  }

  async fetchWithTimeout(url, timeout = 5000) {
    const controller = new AbortController();
    const abortId = setTimeout(() => controller.abort(), timeout);

    try {
      const response = await fetch(url, {
        signal: controller.signal
      });
      clearTimeout(abortId);
      return response.json();
    } catch (error) {
      clearTimeout(abortId);
      if (error.name === 'AbortError') {
        console.log('请求被取消');
      }
      throw error;
    }
  }

  startPolling(url, interval = 30000) {
    const controller = new AbortController();
    const poll = async () => {
      if (controller.signal.aborted) return;

      try {
        const data = await this.fetchWithTimeout(url, 10000);
        console.log('轮询数据:', data);
      } catch (error) {
        console.error('轮询失败:', error);
      }

      if (!controller.signal.aborted) {
        setTimeout(poll, interval);
      }
    };

    poll();
    return controller;
  }
}
5. 使用清理回调模式
function withCleanup(callback) {
  const cleanups = [];

  const cleanup = () => {
    cleanups.forEach(fn => {
      try {
        fn();
      } catch (error) {
        console.error('清理错误:', error);
      }
    });
    cleanups.length = 0;
  };

  const api = {
    addTimeout(fn, delay) {
      const id = setTimeout(fn, delay);
      cleanups.push(() => clearTimeout(id));
      return id;
    },

    addInterval(fn, interval) {
      const id = setInterval(fn, interval);
      cleanups.push(() => clearInterval(id));
      return id;
    },

    addEventListener(element, event, handler, options) {
      element.addEventListener(event, handler, options);
      cleanups.push(() => element.removeEventListener(event, handler, options));
    },

    cleanup
  };

  try {
    callback(api);
  } catch (error) {
    cleanup();
    throw error;
  }

  return cleanup;
}

DOM 引用和闭包

示例1:DOM引用泄漏

class DOMMemoryLeak {
  constructor() {
    // 保存DOM引用
    this.elementRefs = [];
    this.dataStore = new Array(10000).fill('DOM data');
  }

  createElements(count = 100) {
    for (let i = 0; i < count; i++) {
      const div = document.createElement('div');
      div.className = 'leaky-element';
      div.textContent = `元素 ${i}: ${this.dataStore[i]}`;

      // 保存DOM引用
      this.elementRefs.push(div);

      // 添加到页面
      document.body.appendChild(div);
    }
  }

  removeElements() {
    // 从DOM移除,但引用仍然存在
    this.elementRefs.forEach(el => {
      if (el.parentNode) {
        el.parentNode.removeChild(el);
      }
    });

    // 忘记清理数组引用
    console.log('元素已从DOM移除,但引用仍保存在内存中');
  }
}

示例2:闭包保持外部引用

function createClosureLeak() {
  const largeData = new Array(100000).fill('闭包数据');
  let eventHandler;

  return {
    setup(element) {
      // 闭包保持对largeData的引用
      eventHandler = () => {
        console.log('数据大小:', largeData.length);
        // 即使不再需要,largeData也无法被回收
      };

      element.addEventListener('click', eventHandler);
    },

    teardown(element) {
      if (eventHandler) {
        element.removeEventListener('click', eventHandler);
        // 但是eventHandler闭包仍然引用largeData
      }
    }
  };
}

示例3:缓存的不当使用

class CacheLeak {
  constructor() {
    this.cache = new Map();
  }

  getData(key) {
    if (this.cache.has(key)) {
      return this.cache.get(key);
    }

    // 模拟获取数据
    const data = {
      id: key,
      content: new Array(10000).fill('缓存数据').join(''),
      timestamp: Date.now()
    };

    this.cache.set(key, data);

    // 问题:缓存永远增长,从不清理
    return data;
  }

  // 忘记实现缓存清理策略
}

解决方案

1. 使用WeakMap和WeakSet
class SafeDOMManager {
  constructor() {
    // WeakMap保持对DOM元素的弱引用
    this.elementData = new WeakMap();
    this.elementListeners = new WeakMap();
  }

  registerElement(element, data) {
    this.elementData.set(element, data);

    const handleClick = () => {
      const elementData = this.elementData.get(element);
      console.log('点击元素:', elementData);
    };

    element.addEventListener('click', handleClick);

    // 保存监听器以便清理
    this.elementListeners.set(element, {
      click: handleClick
    });
  }

  unregisterElement(element) {
    const listeners = this.elementListeners.get(element);
    if (listeners) {
      element.removeEventListener('click', listeners.click);
      this.elementListeners.delete(element);
    }
    this.elementData.delete(element);
  }
}
2. 使用WeakRef和FinalizationRegistry清理DOM引用
class DOMReferenceManager {
  constructor() {
    this.registry = new FinalizationRegistry((element) => {
      console.log('DOM元素被垃圾回收,清理相关资源');
      // 清理与元素关联的资源
    });

    this.weakRefs = new Set();
  }

  trackElement(element, data) {
    const weakRef = new WeakRef(element);
    this.weakRefs.add(weakRef);

    this.registry.register(element, {
      element: element,
      data: data
    }, weakRef);

    return weakRef;
  }
}
3. 避免闭包保持不必要引用
function createSafeClosure() {
  // 需要保持的数据
  const essentialData = {
    config: { maxSize: 100 },
    state: { count: 0 }
  };

  // 不需要保持的大数据
  let temporaryData = new Array(100000).fill('临时数据');

  const processTemporaryData = () => {
    // 处理临时数据
    const result = temporaryData.map(item => item.toUpperCase());

    // 处理后立即释放引用
    temporaryData = null;

    return result;
  };

  return {
    process: processTemporaryData,

    updateConfig(newConfig) {
      Object.assign(essentialData.config, newConfig);
    },

    getState() {
      return { ...essentialData.state };
    }
  };
}

V8引擎优化策略

隐藏类(Hidden Classes)

隐藏类是V8内部优化对象访问的机制,相同结构的对象共享同一个隐藏类:

function createOptimizedObject() {
  const obj = {};
  obj.a = 1;  // 创建隐藏类 C0
  obj.b = 2;  // 创建隐藏类 C1
  obj.c = 3;  // 创建隐藏类 C2
  return obj;
}

内联缓存(Inline Caching)

内联缓存是V8优化属性访问的重要机制,通过缓存对象的隐藏类和属性位置来加速访问:

单态(Monomorphic):总是访问同一类型的对象

function monomorphicAccess(objects) {
  let sum = 0;
  for (const obj of objects) {
    sum += obj.value; // 总是访问相同隐藏类的对象
  }
  return sum;
}
const monomorphicObjects = [];
class TypeA { constructor(v) { this.value = v; } }
for (let i = 0; i < 10000; i++) {
  monomorphicObjects.push(new TypeA(i));
}

多态(Polymorphic):访问少量不同类型的对象

function polymorphicAccess(objects) {
  let sum = 0;
  for (const obj of objects) {
    sum += obj.value; // 访问2-4种隐藏类的对象
  }
  return sum;
}
const polymorphicObjects = [];
class TypeA { constructor(v) { this.value = v; } }
class TypeB { constructor(v) { this.value = v; } }
for (let i = 0; i < 10000; i++) {
  polymorphicObjects.push(i % 2 === 0 ? new TypeA(i) : new TypeB(i));
}

超态(Megamorphic):访问多种类型的对象

function megamorphicAccess(objects) {
  let sum = 0;
  for (const obj of objects) {
    sum += obj.value; // 访问超过4种隐藏类的对象
  }
  return sum;
}
const megamorphicObjects = [];
class TypeA { constructor(v) { this.value = v; } }
class TypeB { constructor(v) { this.value = v; } }
class TypeC { constructor(v) { this.value = v; } }
class TypeD { constructor(v) { this.value = v; } }
class TypeE { constructor(v) { this.value = v; } }
const types = [TypeA, TypeB, TypeC, TypeD, TypeE];
for (let i = 0; i < 10000; i++) {
  const Type = types[i % 5];
  megamorphicObjects.push(new Type(i));
}

内存管理黄金法则

  • 及时释放不再需要的引用
  • 避免创建不必要的全局变量
  • 小心处理闭包和回调
  • 使用弱引用管理缓存
  • 定期检查和清理内存

结语

性能优化是一个持续的过程,而不是一次性的任务。最好的性能优化是在问题发生之前预防它。理解V8引擎的工作原理,掌握正确的工具使用方法,建立完善的监控体系,这样才能构建出高性能、高可用的Web应用。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

酷监控!一款高颜值的监控工具!

大家好,我是 Java陈序员

在如今数字化运营时代,服务的稳定性直接决定用户体验。但搭建一套完善的服务监控体系往往门槛不低:要么是专业监控工具配置复杂、学习成本高,要么是轻量工具功能单一,难以覆盖全场景需求。

今天,给大家推荐一款高颜值的监控系统工具,轻量易部署!

项目介绍

coolmonitor —— 酷监控,一个高颜值的监控工具,支持网站监控、接口监控、HTTPS 证书监控等多种监控类型,帮助开发者及运维人员实时掌握网站、接口运行状态。

功能特色

  • 多维度监控覆盖:支持 HTTP、HTTPS 网站、API 接口、HTTPS 证书过期、TCP 端口、MySQL、Redis 数据库等多种监控
  • 多渠道通知配置:支持邮件、Webhook、微信、钉钉、企业微信等多类型通知渠道
  • 便捷的操作体验:响应式布局,适配桌面、平板、移动端,支持深色、浅色主题切换
  • 数据可视化:监控数据支持可视化展示,通过 ECharts 生成响应时间趋势图,支持按小时、天维度查看
  • 持久化存储:采用 SQLite 轻量数据库,监控配置、运行数据持久化存储,轻量级部署无需额外依赖

快速上手

coolmonitor 支持 Docker 部署,可通过 Docker 快速部署。

1、拉取镜像

docker pull star7th/coolmonitor:latest

2、创建挂载目录

mkdir -p /data/software/coolmonitor

3、运行容器

docker run -d \
--name coolmonitor \
-p 3333:3333 \
-v /data/software/coolmonitor:/app/data \
star7th/coolmonitor:latest

4、容器运行成功后,浏览器访问

http://{IP/域名}:3333

5、根据引导,设置管理员账号密码,完成系统初始化

功能体验

  • 主面板

  • 监控详情页

  • 添加监控项

  • 添加通知方式

  • 状态页

coolmonitor 没有复杂的配置项,能覆盖日常监控的核心需求,颜值高、易部署、易维护,不管是个人开发者监控自己的小网站,还是中小企业监控内部服务,都非常适用。快去部署体验吧~

项目地址:https://github.com/star7th/coolmonitor

最后

推荐的开源项目已经收录到 GitHub 项目,欢迎 Star

https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:

https://chencoding.top:8090/#/

大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!


JavaScript 函数式编程核心概念

函数式编程不是一种新的语法,而是一种思考方式。它让我们用更简洁、更可预测、更可测试的方式编写代码。理解这些概念,将彻底改变我们编写 JavaScript 的方式。

前言:从命令式到声明式的转变

命令式编程:关注"怎么做"

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

for (let i = 0; i < numbers.length; i++) {
    doubled.push(numbers[i] * 2);
}
console.log(doubled); // [2, 4, 6, 8, 10]

函数式编程:关注"做什么"

const numbers2 = [1, 2, 3, 4, 5];
const doubled2 = numbers2.map(n => n * 2);
console.log(doubled2); // [2, 4, 6, 8, 10]

立即调用函数表达式(IIFE)

IIFE 的基本概念

IIFE(Immediately Invoked Function Expression)是定义后立即执行的函数表达式。

IIFE的基本语法

语法1:括号包裹整个调用

(function() {
    console.log('括号包裹整个调用');
}());

语法2:括号包裹函数,然后调用

(function() {
    console.log('括号包裹函数,然后调用');
})();

注:以上两种写法只是两种不同的风格,它们在功能上完全等价,没有实质性区别。

语法对比:函数声明 vs 函数表达式 vs IIFE

1. 函数声明 - 不会立即执行

function greet() {
    console.log('Hello!');
}
greet(); // 需要显式调用

2. 函数表达式 - 也不会立即执行

const greetExpr = function() {
    console.log('Hello from expression!');
};
greetExpr(); // 需要显式调用

3. IIFE - 定义后立即执行

(function() {
    console.log('Hello from IIFE!'); // 立即执行
})();

IIFE操作符

在 JavaScript 中,以 function 开头的语句会被解析为函数声明,而函数声明不能直接跟 () 执行,因此出现了操作符。添加这些操作符后,JavaScript 引擎会将 function... 解析为函数表达式,这样就可以立即执行了。

逻辑非运算符!

const result = !function () {
  console.log('逻辑非');
}();
console.log(result);  // true

上述代码会将立即执行函数的返回值进行取反,由于上述函数没有明确返回值,故默认返回 undefined!undefined 结果为 true,因此 result 的值为 true

一元加运算符+

const result = +function () {
  console.log('一元加');
}();
console.log(result);  // NaN 

上述代码立即执行函数的返回值转换为数字,由于上述函数没有明确返回值,故默认返回 undefinedundefined 转为为数字结果为 NaN,因此 result 的值为 NaN

void 运算符

const result = void function () {
  console.log('void');
}();
console.log(result);  // undefined

上述代码中,立即执行函数的返回值永远为 undefined ,这是 void 关键字的特性使然。

IIFE 的实际应用

应用1:创建私有作用域

使用IIFE可以创建模块作用域,模块作用域内变量在IIFE外部无法直接访问,即为私有作用域。在IIFE内部,我们可以提供公共方法,去访问这些私有作用域:

(function () {
  // 这些变量在IIFE外部无法访问
  var privateVar = '我是私有的';
  var secret = 42;

  // 提供公共方法访问私有变量
  myModule = {
    getSecret: function () {
      return secret;
    },
    publicMethod: function () {
      console.log('公共方法可以访问私有变量:', privateVar);
    }
  };
})();

console.log(myModule.getSecret()); // 42
myModule.publicMethod(); // "公共方法可以访问私有变量: 我是私有的"
console.log(privateVar); // ReferenceError: privateVar is not defined
console.log(secret); // ReferenceError: secret is not defined

应用2:避免变量冲突

假设有多个第三方库,它们都使用了同一个变量,如 jQuery 和 Prototype.js ,它们都用了 $ 符号,直接使用 $ 符号就会冲突。这种情况下,我们就可以采用 IIFE 的方式,将 $ 保护起来:

(function($) {
    // 在这个作用域内,$就是jQuery
    $(document).ready(function() {
        console.log('jQuery准备好了');
    });
})(jQuery); // 传入jQuery对象

// Prototype.js的$不受影响

IIFE 的现代替代方案

方案1:ES Module(最佳方案)

// module.js
const privateVar = '私有变量';
export const publicVar = '公共变量';
export function publicMethod() {
  return privateVar;
}

// main.js
import { publicVar, publicMethod } from './module.js';

方案2:块级作用域 + 闭包

{
  const privateData = '块级私有数据';
  let counter = 0;

  counterModule = {
    increment: () => ++counter,
    getValue: () => counter
  };
}

console.log(counterModule.increment()); // 1
console.log(counterModule.getValue());  // 1
// console.log(privateData); // ReferenceError
// console.log(counter); // ReferenceError

方案3:类与私有字段

class SecureModule {
  #secret = '绝密信息';
  #counter = 0;

  getSecret() {
    return this.#secret;
  }

  increment() {
    return ++this.#counter;
  }
}

const module = new SecureModule();
console.log(module.getSecret()); // "绝密信息"
console.log(module.increment()); // 1
// console.log(module.#secret); // SyntaxError

纯函数(Pure Functions)

什么是纯函数?

纯函数是函数式编程的基石,它具有两个核心特征:

  • 相同的输入,总是得到相同的输出
  • 没有副作用

纯函数 vs 非纯函数

纯函数示例

function add(a, b) {
  return a + b;
}

非纯函数示例

let counter = 0;
function increment() {
  counter++; // 修改外部状态
  return counter;
}

纯函数的优势

优势1:可预测性

纯函数中对于相同的输入,总是得到相同的输出,因此其结果是可以预测的:

const calculatePrice = (price, taxRate) => price * (1 + taxRate);
console.log('价格计算: $100 * 1.1 =', calculatePrice(100, 0.1)); // 110
console.log('再次计算: $100 * 1.1 =', calculatePrice(100, 0.1)); // 110(总是相同)

优势2:易于测试

function testCalculatePrice() {
  const result = calculatePrice(100, 0.1);
  const expected = 110;
  console.log(`测试 ${result === expected ? '通过' : '失败'}: ${result} === ${expected}`);
}
testCalculatePrice();

优势3:引用透明性

const price1 = calculatePrice(100, 0.1);
const price2 = calculatePrice(100, 0.1);
console.log('price1 === price2:', price1 === price2); // true
console.log('可以直接替换:', calculatePrice(100, 0.1) === 110); // true

优势4:可缓存性

function square(x) {
  console.log(`计算 ${x} 的平方`);
  return x * x;
}

// 缓存包装器
function memoize(fn) {
  const cache = {};
  return function (x) {
    if (cache[x] !== undefined) {
      console.log(`从缓存获取 ${x} 的平方`);
      return cache[x];
    }
    const result = fn(x);
    cache[x] = result;
    return result;
  };
}

// 使用缓存
const memoizedSquare = memoize(square);

console.log(memoizedSquare(5)); // 第一次计算
console.log(memoizedSquare(5)); // 从缓存获取

常见的副作用及其解决方案

副作用类型1:修改输入参数

const impureAddToArray = (array, item) => {
  array.push(item); // 副作用:修改输入参数
  return array;
};
解法方案:返回新数组,不修改原数组
const pureAddToArray = (array, item) => {
  return [...array, item]; // 返回新数组,不修改原数组
};

副作用类型2:修改外部变量

let globalCount = 0;
const impureIncrement = () => {
  globalCount++; // 副作用:修改全局状态
  return globalCount;
};
解决方案:返回新值
const pureIncrement = (count) => {
  return count + 1; // 不修改外部状态
};

副作用类型3:I/O操作

const impureFetchData = (url) => {
  // 副作用:网络请求
  fetch(url)
    .then(response => response.json())
    .then(data => console.log('数据:', data));
};
解决方案:返回一个函数,延迟执行副作用
const pureFetchData = (url) => {
  // 返回一个函数,延迟执行副作用
  return () => {
    return fetch(url)
      .then(response => response.json());
  };
};

副作用类型4:异常和错误

const impureParseJSON = (str) => {
  return JSON.parse(str); // 可能抛出异常
};
解决方案:异常捕获
const pureParseJSON = (str) => {
  try {
    return { success: true, data: JSON.parse(str) };
  } catch (error) {
    return { success: false, error: error.message };
  }
};

高阶函数(Higher-Order Functions)

什么是高阶函数?

高阶函数是指能够接受函数作为参数,或者返回函数作为结果的函数:

接受函数作为参数

const greet = (name, formatter) => {
  return formatter(name);
};
const shout = (name) => `${name.toUpperCase()}!`;
const whisper = (name) => `psst... ${name}...`;
console.log(greet('zhangsan', shout));   // "ZHANGSAN!"
console.log(greet('lisi', whisper));   // "psst... lisi..."

返回函数作为结果

const multiplier = (factor) => {
  return (number) => number * factor;
};
const double = multiplier(2);
const triple = multiplier(3);
console.log('double(5):', double(5)); // 10
console.log('triple(5):', triple(5)); // 15

同时接受和返回函数

const compose = (f, g) => {
  return (x) => f(g(x));
};
const addOne = (x) => x + 1;
const square = (x) => x * x;
const addOneThenSquare = compose(square, addOne);
console.log('addOneThenSquare(2):', addOneThenSquare(2));

柯里化(Currying)

什么是柯里化?

柯里化是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

// 原始函数(多参数)
const addThreeNumbers = (a, b, c) => a + b + c;
console.log('原始函数:', addThreeNumbers(1, 2, 3)); // 6

// 柯里化版本
const curriedAdd = (a) => {
  return (b) => {
    return (c) => {
      return a + b + c;
    };
  };
};

console.log('柯里化版本:', curriedAdd(1)(2)(3)); // 6

柯里化的优势

1. 参数复用

const addFive = curriedAdd(5);
console.log('addFive(10)(15):', addFive(10)(15)); // 30

2. 延迟计算

const multiply = (a) => (b) => a * b;
const double = multiply(2);
const triple = multiply(3);

console.log('double(10):', double(10)); // 20
console.log('triple(10):', triple(10)); // 30

3. 函数组合

const greet = (greeting) => (name) => `${greeting}, ${name}!`;
const sayHello = greet('Hello');
const sayHi = greet('Hi');

console.log(sayHello('zhangsan')); // "Hello, zhangsan!"
console.log(sayHi('lisi'));      // "Hi, lisi!"

手动实现柯里化

const manualCurry = (fn) => {
  const arity = fn.length; // 函数期望的参数个数

  const curried = (...args) => {
    if (args.length >= arity) {
      return fn(...args);
    } else {
      return (...moreArgs) => {
        return curried(...args, ...moreArgs);
      };
    }
  };
  return curried;
};

函数组合(Function Composition)

什么是函数组合?

函数组合是将多个函数组合成一个新函数的过程,新函数的输出作为下一个函数的输入。

手动组合

const addOne = x => x + 1;
const double = x => x * 2;
const square = x => x * x;

// 手动组合:从右向左
const addOneThenDoubleThenSquare = (x) => {
  const afterAddOne = addOne(x);
  const afterDouble = double(afterAddOne);
  const afterSquare = square(afterDouble);
  return afterSquare;
};
console.log('手动组合:', addOneThenDoubleThenSquare(2)); // ((2+1)*2)^2 = 36

组合函数

const addOne = x => x + 1;
const double = x => x * 2;
const square = x => x * x;

// 组合函数
const compose = (...fns) => (x) => 
  fns.reduceRight((acc, fn) => fn(acc), x);

// 从右向左组合:square(double(addOne(x)))
const addOneThenDoubleThenSquare = compose(square, double, addOne);

console.log('函数组合:', addOneThenDoubleThenSquare(2));

管道函数

const addOne = x => x + 1;
const double = x => x * 2;
const square = x => x * x;

// 管道函数
const pipe = (...fns) => {
  return (initialValue) => {
    return fns.reduce((value, fn) => fn(value), initialValue);
  };
};

// 从左向右组合:addOne → double → square
const addOneThenDoubleThenSquarePipe = pipe(addOne, double, square);

console.log('管道组合:', addOneThenDoubleThenSquarePipe(2));

Pointfree 风格编程

Pointfree 风格(无参数风格)是一种编程风格,函数定义不显式提及它所操作的数据参数。

非 Pointfree 风格示例

const nonPointfree = (users) => {
  return users
    .filter(user => user.age >= 18)
    .map(user => user.name)
    .map(name => name.toUpperCase());
};

Pointfree 风格

const isAdult = user => user.age >= 18;
const getName = user => user.name;
const toUpperCase = str => str.toUpperCase();

const getAdultUserNames = (users) => {
  return users
    .filter(isAdult)
    .map(getName)
    .map(toUpperCase);
};

现代 JavaScript 中的函数式特性

1. 箭头函数

const add = (a, b) => a + b;

2. 解构与剩余参数

const processArgs = (first, second, ...rest) => {
  console.log('前两个:', first, second);
  console.log('其余:', rest);
};

3. 默认参数

const greet = (name, greeting = 'Hello') => `${greeting}, ${name}!`;

4. 数组和对象的扩展运算

const combine = (...arrays) => [].concat(...arrays);
const merge = (...objects) => Object.assign({}, ...objects);

5. Promise 和 async/await

const asyncPipe = (...fns) => async (initial) => {
  return fns.reduce(async (value, fn) => {
    const resolvedValue = await value;
    return fn(resolvedValue);
  }, Promise.resolve(initial));
};

6. 新的数组方法

const numbers = [1, 2, 3, 4, 5];
const flatMapped = numbers.flatMap(x => [x, x * 2]);

7. 可选链和空值合并

const safeGet = (obj, path) => {
  return path.split('.').reduce(
    (acc, key) => acc?.[key] ?? null,
    obj
  );
};

结语

函数式编程提供了一套强大的工具和思维方式,通过掌握这些核心概念,我们能够编写出更简洁、更可维护、更可测试的代码。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

前端安全问题详解:原理、风险与防护措施

在现代Web应用中,前端作为用户与后端交互的桥梁,承担着越来越多的逻辑处理和数据展示任务。然而,随着前端框架(如React、Vue、Angular)的普及和复杂度的增加,前端安全问题也日益突出。这些问题不仅可能导致数据泄露、用户隐私侵犯,还可能引发更严重的系统级攻击。根据OWASP(Open Web Application Security Project)的前端安全指南,XSS、CSRF 等漏洞常年位居Web安全风险榜单前列。本文将介绍几种常见的前端安全问题,包括其原理、潜在风险,并提供实用的防护措施,帮助开发者构建更安全的应用。

1. 跨站脚本攻击(XSS)

原理介绍

XSS(Cross-Site Scripting)是一种注入攻击,攻击者通过将恶意脚本(如JavaScript)注入到Web页面中,当用户浏览器渲染页面时,这些脚本被执行。核心原因是用户输入没有被正确转义或过滤,导致恶意代码与正常HTML/JS混淆。

XSS 分为三种类型:

  • 反射型(Reflected XSS):恶意代码通过URL参数(如?search=<script>alert(1)</script>)传入,后端直接反射回页面。浏览器执行时,脚本运行在受害者浏览器上下文中。
  • 存储型(Stored XSS):恶意代码存储在数据库(如评论区),当其他用户访问时被加载并执行。
  • DOM型(DOM-Based XSS):前端JavaScript直接从来源(如URL hash)取值并动态修改DOM,导致脚本注入。

浏览器同源策略(SOP)无法阻止XSS,因为恶意脚本被视为页面自身的一部分。

风险

  • 窃取Cookie、Session Token,导致账户劫持。
  • 键盘记录、钓鱼页面伪造。
  • 利用浏览器进行DDoS或挖矿。

防护措施

  • 输入输出转义:使用库如DOMPurify对用户输入进行HTML实体转义(e.g., <&lt;)。
  • Content Security Policy (CSP):在HTTP头设置CSP策略,限制脚本来源(如script-src 'self'),防止外部脚本加载。
  • HttpOnly Cookie:设置Cookie的HttpOnly标志,防止JS访问Cookie。
  • 框架内置防护:React/Vue使用虚拟DOM和模板系统自动转义;避免dangerouslySetInnerHTML。
  • 最佳实践:定期扫描漏洞,使用OWASP XSS Prevention Cheat Sheet。

2. 跨站请求伪造(CSRF)

原理介绍

CSRF(Cross-Site Request Forgery)利用浏览器自动携带Cookie的机制,诱导用户在已登录状态下访问恶意页面,该页面触发跨站请求(如<img src="bank.com/transfer?amount=1000">),服务器误以为是用户合法操作。

核心机制:浏览器发送请求时,只检查目标域名匹配Cookie的domain属性,而不验证请求来源。攻击无需窃取Cookie,只需“借用”用户的浏览器发送。

类型:

  • GET型:通过图片或链接触发。
  • POST型:通过隐藏表单自动提交。

风险

  • 执行敏感操作,如转账、修改密码、删除数据。
  • 在社交平台上伪造发帖或点赞,导致声誉损害。

防护措施

  • CSRF Token:服务器生成随机Token,嵌入表单或Header;提交时验证Token匹配。
  • SameSite Cookie:设置Cookie的SameSite属性为LaxStrict,浏览器在跨站请求中不携带Cookie(Chrome默认Lax)。
  • 自定义Header:要求AJAX请求携带X-CSRF-Token,浏览器默认不跨站添加。
  • Referer检查:验证HTTP Referer头是否来自本站(但可伪造,结合其他使用)。
  • 框架支持:Spring Security、Django等内置CSRF防护;前端框架如Axios可自动添加Token。

3. 点击劫持(Clickjacking)

原理介绍

点击劫持通过将目标页面嵌入恶意,并使用CSS使其透明或重叠在诱饵元素上。用户点击“诱饵”时,实际点击了目标页面的按钮(如“点赞”或“转账”)。

原理基于的嵌套和样式操控,浏览器允许跨域iframe,但用户交互被误导。

风险

  • 诱导用户执行 unintended 操作,如关注账户或授权权限。
  • 结合CSRF放大攻击。

防护措施

  • X-Frame-Options头:设置DENYSAMEORIGIN,禁止页面被iframe嵌入。
  • CSP frame-ancestors:限制可嵌入的来源(如frame-ancestors 'self')。
  • JavaScript检测:检查window.top !== window.self,如果是iframe则隐藏内容。
  • 用户教育:但主要靠服务器端防护。

4. 原型链污染(Prototype Pollution)

原理介绍

JavaScript对象继承自Object.prototype,攻击者通过合并用户输入(如JSON.parse)污染原型链(e.g., 设置__proto__.toString = evilFunction),影响所有对象的行为。

常见于lodash/underscore的merge函数漏洞,或Node.js环境下的参数污染。

风险

  • 导致DoS(拒绝服务),如污染Array.prototype导致循环崩溃。
  • 远程代码执行(RCE)在服务器端。
  • 前端逻辑篡改,如绕过权限检查。

防护措施

  • 避免不安全合并:使用Object.assign代替深合并;或库如lodash的safeMerge。
  • 冻结原型Object.freeze(Object.prototype)防止修改。
  • 输入验证:严格校验用户输入,避免动态键(如delete obj['proto'])。
  • 更新依赖:定期npm audit,升级漏洞库。

5. 其他常见问题与通用防护

除了上述,供应链攻击(Supply Chain Attack,如恶意npm包)和内容嗅探(Content Sniffing)也值得注意。

通用防护措施

措施类型 具体方法 适用场景
内容安全策略(CSP) 限制资源加载来源 XSS、Clickjacking
子资源完整性(SRI) 防止CDN篡改
HTTPS 强制加密传输 防中间人攻击
输入验证与转义 前后端双重校验 所有用户输入
监控与审计 使用Sentry/BugSnag监控异常 实时检测攻击

结语

前端安全并非孤立存在,它与后端、浏览器生态紧密相关。开发者应遵循“防御纵深”原则,从代码编写到部署全程考虑安全。推荐参考OWASP Top 10和MDN安全文档,进行定期渗透测试。最终,安全是动态过程,随着Web3和PWA的兴起,新挑战如WebAssembly漏洞将涌现——保持学习是关键。通过这些措施,你的Web应用将更robust,保护用户免受威胁。

TypeScript SDK Enum 运行时 404 问题解决方案

问题描述

在前端项目中引入 TypeScript SDK 后,编译阶段正常,但在浏览器运行时出现 404 Not Found 错误,导致功能异常。

报错示例:

GET http://localhost:3000/src/utils/sdk-dist/types 404 (Not Found)

代码场景:

// 业务代码
import { MeetingType } from '@my-sdk/types'; 

console.log(MeetingType.Cycle); // 运行时报错,因为 MeetingType 未被正确加载

原因分析

该问题由 TypeScript Enum 的编译特性SDK 模块导出配置 不匹配导致。

1. Enum 包含运行时值

TypeScript 中的 interfacetype 仅用于编译期类型检查,编译为 JavaScript 后会被移除。而 enum 是一个特例,它既包含类型定义,也包含运行时值。

// TypeScript 源码
export enum Status {
  Active = 1
}

// 编译后的 JavaScript
export var Status = {
  Active: 1
};

因此,Enum 必须存在于编译后的 JavaScript 产物中才能被运行时正确引用。

2. 导出方式不当

在 SDK 开发中,如果在入口或中间文件中使用了 export type 语法导出 Enum,TypeScript 编译器会将其视为纯类型进行处理,导致编译后的 JavaScript 产物中缺失了 Enum 对应的对象代码。

错误示例:

// sdk/src/index.ts
// 错误:export type 导致 ./types 下的 Enum 运行时代码被丢弃
export type * from './types'; 

3. 运行时加载失败

当业务代码尝试访问 MeetingType 时:

  1. 编译期:TypeScript 编译器从 .d.ts 类型定义文件中找到了 MeetingType,检查通过。
  2. 运行时:浏览器尝试加载对应的 JavaScript 模块。由于 SDK 构建产物中缺失了该 Enum 的运行时代码(被 Tree-shaking 移除或未导出),浏览器请求对应资源时无法找到,从而返回 404

解决方案

确保 Enum 被作为“值”正确导出,使其包含在 SDK 的最终构建产物中。

// 错误:仅导出类型
export type { MeetingType } from './meeting';

// 正确:导出值
export { MeetingType } from './meeting'; 
// 或
export * from './meeting';

检查构建配置: 检查构建工具(如 Rollup/Webpack)的 sideEffects 配置,确保包含 Enum 的文件未被错误地 Tree-shake 移除。


最佳实践

  1. 明确导出语法:区分类型与值。对于 Interface 使用 export type,对于 Enum 和 Class 使用 export
  2. 统一入口:SDK 应确保所有公开 API(包括 Enum)均可通过根路径(如 import { ... } from 'my-sdk')访问,避免引用内部深层路径。

【跨域options】为什么你的跨域 POST 请求被浏览器“吞”了?

引言

在做跨域请求时,你是否遇到过这种怪事:在 Network 面板里,OPTIONS 预检请求返回了 200 OK,看起来一切正常;但紧随其后的 POST 请求却直接报了 CORS error,甚至点开详情还提示 “Provisional headers are shown”

这到底是浏览器的锅,还是后端的错?今晚我们就来扒开这层皮。

image.png


一、 罪魁祸首:被遗忘的“入场券”

在跨域场景下,浏览器会发起 OPTIONS 预检。如果你在请求中使用了非简单的 Header(如 JSON 的 Content-Type 或链路追踪的 traceparent),浏览器会发起“礼貌询问”:

  • 浏览器问: Access-Control-Request-Headers: content-type, traceparent
  • 服务器答: Access-Control-Allow-Headers: Content-Type, Access-Token, ...

坑点就在这里: 如果服务器的白名单里漏掉了哪怕一个 Header(比如本文案例中的 traceparent),浏览器就会认为这场“商业洽谈”失败了。


二、 为什么 OPTIONS 是绿的,POST 却是红的?

这是最让人困惑的地方。既然“洽谈”失败,为什么 OPTIONS 不直接报错?

  1. OPTIONS 是“获取规则”: 服务器成功处理了你的咨询请求,并如实告知了它的配置。从 HTTP 协议看,咨询过程是完美的,所以返回 200 OK
  2. POST 是“执行意图”: 浏览器拿到服务器的规则后,发现和你手里的“会员卡”(traceparent)不匹配。为了安全,浏览器单方面拦截了 POST 请求,不让它真正发往服务器。
  3. “红色”是浏览器的警告: 那个红色的 POST 记录其实是浏览器生成的“虚拟记录”,目的是告诉你: “你原本想发这个,但我把它按下了。” > 核心结论: OPTIONS 的成功代表“问答过程成功”,不代表“准入结果成功”。

三、 关于 Origin 的安全防线

在排查过程中,我们曾想过:能不能手动在 Headers 里改 Origin

  • 答案是:不行。 在浏览器环境下,OriginForbidden Header,由浏览器强制控制。
  • 为什么? 如果开发者能改 Origin,那么钓鱼网站就能轻易伪装成银行官网,CORS 安全机制将形同虚设。
  • 真相: Origin 的存在不由后端决定,但它的“生死存亡”由后端决定。后端通过校验这个自动带上的 Origin 来决定是否给浏览器发放“过路费”(即 Access-Control-Allow-Origin 响应头)。

四、 避坑指南:如何彻底解决?

1. 后端侧(根治方案)

不要只配置 Origin。如果前端有自定义 Header(如分布式追踪、身份令牌),必须在 Access-Control-Allow-Headers 中显式添加。

  • 代码示例(Node.js):

    JavaScript

    res.header("Access-Control-Allow-Headers", "Content-Type, traceparent, Your-Custom-Header");
    

2. 前端侧(调试技巧)

当你看到 “Provisional headers are shown” 且伴随 CORS error 时:

  • 第一步: 检查 OPTIONS 请求头里的 Access-Control-Request-Headers
  • 第二步: 检查 OPTIONS 响应头里的 Access-Control-Allow-Headers
  • 第三步: 找茬,看谁多了,看谁少了。

结语

Web 开发中,CORS 不是敌人,而是保护数据的保镖。当你理解了浏览器、服务器和安全策略之间的那场“礼貌洽谈”,这些诡异的红色报错就不再是迷雾。

如何将 DOM 节点跨页面拖拽?搞清楚这一点,iframe 也能像原生 DOM 一样操作自如!

前言

在前端开发中,iframe是一个相对常见的元素,常用于嵌入外部页面、隔离独立组件或实现复杂的页面布局。如果你恰好遇到 iframe 与父页面之间的拖拽需求,那么这篇文章或许能给你一套清晰可行的解决方案。即便暂时没有这类场景,我们也可以一起了解 iframe 与父容器交互时会遇到哪些典型问题。

复习 iframe 元素

iframe(内联框架)是 HTML 里用来嵌入另一个页面的标签。用法很简单:

<iframe
  src="https://example.org"
  title="iframe 示例"
  width="400"
  height="300"
></iframe>

iframe-1.png 嵌入后,你得到一块「窗口里的窗口」:里面有自己独立的 DOM、JS 执行环境和安全边界。正因如此,跨 iframe 的交互(比如从父页面拖一个块到 iframe 里的区域)会涉及跨文档甚至跨域问题。

下面说说针对这两种不同的情况下实现拖拽的思路

拖拽的两种实现思路

1:HTML5 拖拽 API(draggable + dataTransfer)

把元素设为 draggable="true",再监听 dragstartdragoverdrop 等事件,用 dataTransfer 在拖拽过程中传数据。这是标准、语义化的做法。

  • 拖拽源dragstarte.dataTransfer.setData(...) 写入要传的内容
  • 放置目标dragover 里必须 e.preventDefault(),否则不会触发 dropdrop 里用 e.dataTransfer.getData(...) 读取

父页面和 iframe 内如果同源,这套 API 可以直接跨 iframe 使用:父页拖、iframe 里接,数据通过 dataTransfer 自然贯通。

2:用鼠标事件模拟拖拽(mousedown + mousemove + mouseup)

不依赖拖拽 API,完全用鼠标事件模拟「按下 → 移动 → 松开」:

  • mousedown:记录起始位置,标记「开始拖拽」
  • mousemove:根据位移更新被拖元素位置(或显示一个跟随鼠标的「幽灵」元素)
  • mouseup:结束拖拽,根据最终坐标决定「放到哪里」

不依赖 dataTransfer,只把「拖拽」当成一种 UI 行为;真正传数据走 postMessage;用 mousemove(配合碰撞检测)判断鼠标是否进入/离开 iframe,从而在父页和 iframe 之间协调「当前在拖什么、放到哪」。拖拽结束时除 mouseup 外,还要用 keydown / keyup 处理释放(例如 Escape 取消),避免鼠标移出窗口后状态无法结束。

我应该用哪种方式呢?

如果你的父子页面不存在跨域问题,首选方式一。 因为dataTransfer 在设计上受同源策略约束:

  • 同源(同一协议 + 域名 + 端口):父页面和 iframe 可以正常互相传递 dragstartdragoverdropgetData 能拿到对方 setData 的内容。
  • 跨域:浏览器不会让 iframe 内的页面访问父页的 dataTransfer 数据,dropgetData() 往往是空的,无法可靠地做「从父页拖到 iframe」的数据传递。

因此,一旦 iframe 的 src 是跨域页面,就不能再依赖「拖拽 API + dataTransfer」来实现跨页面内容传递,必须换一种通道:用鼠标事件(mousemove)判断「何时进入/离开 iframe」+ 用 postMessage 在父页与 iframe 之间传数据

下面重点讨论方式二的实现

好的,基于两个文件的实际代码逻辑,以下是重写后的内容:


实现步骤

整个跨 iframe 拖拽的核心是一套 双向 postMessage 通信协议。父页面和 iframe 各司其职,通过消息接力完成拖拽数据的传递。

父页面

父页面负责 拖拽状态管理数据下发,需要处理四件事:

1 启动拖拽 —— mousedown

在可拖拽元素上监听 mousedown,记录拖拽数据(draggingData),同时显示一个跟随鼠标的幽灵元素(Ghost)作为视觉反馈:

dragSource.addEventListener('mousedown', function (e) {
  if (e.button !== 0) return;
  e.preventDefault();
  draggingData = { payload: '来自父页面的拖拽内容' };
  dragGhost.style.display = 'block';
  dragGhost.style.left = e.clientX + 'px';
  dragGhost.style.top = e.clientY + 'px';
});

2 跟随鼠标 —— mousemove

document 上监听 mousemove,实时更新幽灵元素的位置。注意:当鼠标移入 iframe 区域后,父页面将 不再 收到 mousemove 事件(被 iframe 吞掉了),这正是需要子页面配合的原因。

document.addEventListener('mousemove', function (e) {
  if (!draggingData) return;
  dragGhost.style.left = e.clientX + 'px';
  dragGhost.style.top = e.clientY + 'px';
});

3 结束拖拽 —— mouseup + keydown(Escape)

松开鼠标或按下 Escape 时,向 iframe 发送 dragEnd 消息,通知子页面清除高亮状态,然后清理自身状态、隐藏幽灵元素:

// 松开鼠标
document.addEventListener('mouseup', function (e) {
  if (e.button !== 0 || !draggingData) return;
  iframe.contentWindow.postMessage({ type: 'dragEnd' }, TARGET_ORIGIN);
  draggingData = null;
  dragGhost.style.display = 'none';
});

// Escape 取消
document.addEventListener('keydown', function (e) {
  if (e.key !== 'Escape' || !draggingData) return;
  iframe.contentWindow.postMessage({ type: 'dragEnd' }, TARGET_ORIGIN);
  draggingData = null;
  dragGhost.style.display = 'none';
});

4 响应子页面上报 —— message

这是最关键的一环。父页面监听来自 iframe 的消息,处理两种类型:

  • mousemove:子页面告诉父页面「鼠标已经进入我这边了」,父页面收到后立即把拖拽数据通过 postMessage 发送给子页面。
  • dropped:子页面告诉父页面「用户已经松手,放置完成」,父页面据此清理自身状态。
window.addEventListener('message', function (e) {
  if (e.origin !== location.origin && e.origin !== 'null') return;

  // 子页面上报 mousemove → 父页面下发拖拽数据
  if (e.data.type === 'mousemove' && draggingData) {
    iframe.contentWindow.postMessage(
      { type: 'dragData', payload: draggingData.payload },
      TARGET_ORIGIN
    );
  }
  // 子页面上报 dropped → 清理状态
  if (e.data.type === 'dropped') {
    draggingData = null;
    dragGhost.style.display = 'none';
  }
});

iframe子页面

子页面负责 感知鼠标进入接收拖拽数据完成放置,同样需要处理三件事:

1. 上报鼠标移动 —— mousemove(带节流)

子页面在 document 上监听 mousemove,通过 window.parent.postMessage 向父页面上报「鼠标进入了我这边」。为了避免消息过于频繁,使用 80ms 的节流:

let mousemoveTimer = 0;
document.addEventListener('mousemove', function () {
  if (mousemoveTimer) return;
  mousemoveTimer = setTimeout(function () {
    mousemoveTimer = 0;
    window.parent.postMessage({ type: 'mousemove' }, parentOrigin);
  }, 80);
});

2. 接收父页面消息 —— message

监听 message 事件,校验 event.origin 后处理两种消息:

  • dragData:缓存拖拽数据,同时高亮放置区域,给用户「可以放下」的视觉提示。
  • dragEnd:父页面通知拖拽结束(松手或取消),移除高亮、清除缓存。
window.addEventListener('message', function (e) {
  if (e.origin !== location.origin && e.origin !== 'null') return;

  if (e.data.type === 'dragData') {
    cachedDragData = e.data.payload;
    dropTarget.classList.add('highlight');   // 高亮放置区
  }
  if (e.data.type === 'dragEnd') {
    dropTarget.classList.remove('highlight'); // 取消高亮
    cachedDragData = null;
  }
});

3. 完成放置 —— mouseup

在放置区监听 mouseup,如果当前有缓存的拖拽数据,说明这是一次有效放置。更新 UI 展示接收到的内容,并向父页面发送 dropped 消息:

dropTarget.addEventListener('mouseup', function (e) {
  if (e.button !== 0 || !cachedDragData) return;
  dropTarget.textContent = '成功接收:' + cachedDragData;
  dropTarget.classList.remove('highlight');
  window.parent.postMessage({ type: 'dropped' }, parentOrigin);
  cachedDragData = null;
});

4.3 消息流转全景

把上面的步骤串起来,一次完整的跨 iframe 拖拽经历了这样的消息链路:

sequenceDiagram
    participant User
    participant Parent as 父页
    participant Child as 子页(iframe)

    Note over User, Parent: 拖拽开始
    User->>Parent: mousedown
    activate Parent
    Parent->>Parent: 记录数据,显示Ghost
    deactivate Parent

    Note over User, Child: 进入iframe拖动
    User->>Child: 拖动进入
    Child->>Child: mousemove事件
    activate Child
    Child->>Parent: postMessage: mousemove
    deactivate Child

    activate Parent
    Parent->>Child: postMessage: dragData
    deactivate Parent

    activate Child
    Child->>Child: 缓存数据,高亮区域
    deactivate Child

    rect rgb(240, 255, 240)
        Note over User, Child: 场景一:放置成功
        User->>Child: mouseup
        activate Child
        Child->>Child: 展示内容
        Child->>Parent: postMessage: dropped
        deactivate Child

        activate Parent
        Parent->>Parent: 清理状态,隐藏Ghost
        deactivate Parent
    end

    rect rgb(255, 240, 240)
        Note over User, Parent: 场景二:取消拖拽
        User->>Parent: mouseup/Escape
        activate Parent
        Parent->>Child: postMessage: dragEnd
        deactivate Parent

        activate Child
        Child->>Child: 取消高亮,清除数据
        deactivate Child
    end

到这里,你已经看到了完整的实现思路——父子页面通过 四种消息类型mousemovedragDatadragEnddropped)构成了一个闭环的通信协议,各自只关心自己该做的事。逻辑清晰,职责分明。我们来实际跑一下看看效果。

iframe-2.gif

你会发现,拖拽的元素停留在 iframe 容器的边界上,无法跟随。这是为什么?我们来分析一下。

如何处理iframe的边界问题

分析:拖拽时的跟随元素是怎么来的

一般来讲,上面跟随的元素我们称为幽灵元素,一般会透明度降低展示。比较简单的方式是给dom元素添加draggable属性。当你开始拖拽的时候便会有幽灵元素跟随,这个方式中你无需关心它的xy坐标,浏览器会自动处理。若是采用手动绘制UI,则需要你自行更新它的xy坐标。 但无论选择哪种,实际上都依赖于浏览器的事件处理机制。这个机制来源于浏览器的事件冒泡

然而事件冒泡只在同一个 document 内有效。父页面和 iframe 是个独立的 document,当鼠标从父页移入 iframe 后,后续的 mousemove 只会发给 iframe 的 document,父页收不到,自然也就无法再更新父页上的幽灵位置。所以:

  • 鼠标在父页面上:父页的 document 收到 mousemove,幽灵可以跟着动。
  • 鼠标进入 iframe 后:mousemove 只发给 iframe 的 document,父页收不到,幽灵就定格在 iframe 边界上。

结论:需要把子容器的事件「冒泡」给父页面

既然事件不会跨 document 冒泡,要让父页继续感知鼠标在 iframe 里的移动,就只能我们自己把 iframe 里收到的事件「冒泡」上去:在 iframe 内监听 mousemovemouseup 等,把坐标、事件类型通过 postMessage 发给父页,父页收到后当作「从子层冒泡上来的事件」去更新幽灵或完成放置。

实现

整体思路是在 iframe 内部建立一套「事件采集 + 上报」机制,把原本只在子文档里流转的鼠标、键盘、滚轮事件(如果需要缩放)通过 postMessage 传给父页,再由父页做坐标换算后在 iframe 元素上重新派发,这样父页的拖拽监听器就能持续收到事件,幽灵元素也就能跨边界跟随了。

第一步,让子容器报告丢失的事件

iframe 内需要监听所有可能影响拖拽的事件类型:mousemovemouseupkeydownkeyupkeypresswheel。收到事件后,我们只做两件事:组装 payload + postMessage 上报,不做任何坐标换算(因为此时还不知道 iframe 在父页中的位置和缩放比例)。

const windowEventTypes = [
  'mousemove',
  'mouseup',
  'keydown',
  'keyup',
  'keypress',
  'wheel'
];

// 创建iframe事件监听器,收集事件并通过postMessage上报给父页面
function createIframeListen() {
  // 存储已绑定的事件监听器,用于后续移除
  const listeners = [];

  // 构造事件数据并发送给父页面
  const createAndDispatchEvent = (events, type) => {
    let payload;
    if (type.startsWith('mouse')) {
      payload = {
        bubbles: true,
        cancelable: false,
        clientX: events.clientX,
        clientY: events.clientY,
        button: type === 'mouseup' ? 0 : undefined
      };
    } else if (type === 'wheel') {
      payload = {
        bubbles: true,
        cancelable: false,
        clientX: events.clientX,
        clientY: events.clientY,
        deltaX: events.deltaX,
        deltaY: events.deltaY,
        deltaZ: events.deltaZ,
        ctrlKey: events.ctrlKey,
        shiftKey: events.shiftKey
      };
    } else {
      payload = {
        bubbles: true,
        cancelable: false,
        key: events.key,
        code: events.code,
        keyCode: events.keyCode, // 虽已废弃但仍广泛使用
        charCode: events.charCode, // 虽已废弃但仍广泛使用
        which: events.which, // 虽已废弃但仍广泛使用
        shiftKey: events.shiftKey || false,
        ctrlKey: events.ctrlKey || false,
        altKey: events.altKey || false,
        metaKey: events.metaKey || false
      };
    }
    events.preventDefault();
    // 向父页面发送事件数据(注意:生产环境建议替换*为具体域名,提升安全性)
    window.parent.postMessage({
      action: 'dispatchEvent',
      eventData: {
        type,
        payload
      }
    }, '*');
  };

  // 为每个事件类型绑定监听器
  windowEventTypes.forEach(type => {
    const listen = (evt) => createAndDispatchEvent(evt, type);
    window.addEventListener(type, listen, {
      passive: false
    });
    listeners.push([type, listen]);
  });

  // 返回移除所有事件监听器的方法
  return {
    removeAllEvent() {
      listeners.forEach(([type, listen]) => {
        window.removeEventListener(type, listen);
      });
    }
  };
}

这段代码的核心在于 createAndDispatchEvent:根据事件类型提取必要的属性(鼠标类取 clientX/clientY,键盘类取 key/code),组装成 payload 后通过 postMessage 发送 { action: 'dispatchEvent', eventData: { type, payload } }。注意这里的 clientX/clientYiframe 自己坐标系里的值,换算留给父页去做。

第二步,父页面接受事件并手动派发

父页监听 message 事件,筛选出 action === 'dispatchEvent' 的消息,然后做三件事:构造原生事件对象 → 坐标换算(含缩放)→ 在 iframe 元素上派发

/**
 * 自动将iframe内上报的事件转发到父页面的iframe元素上
 * @param {Object} iframeRef - 包含iframe DOM元素的Ref对象}
 * @param {Object} offset - 包含iframe偏移和缩放的对象 } }
 * @returns {Object} 包含移除事件监听的方法
 */
function useIframeEventAutoBubble(iframeRef, offset) {
  // 处理message事件,转发iframe内的事件到父页面的iframe元素
  const messageHandler = (event) => {
    // 只处理指定action的消息
    if (event.data?.action !== 'dispatchEvent') return;

    const { eventData } = event.data;
    let evt;

    // 根据事件类型创建对应原生事件,并换算坐标
    switch (eventData.type) {
      case 'mousemove':
      case 'mouseup':
      case 'mouse':
        // 换算iframe内坐标到父页面坐标系(适配缩放和偏移)
        eventData.payload.clientX = eventData.payload.clientX * offset.value.zoom + offset.value.left;
        eventData.payload.clientY = eventData.payload.clientY * offset.value.zoom + offset.value.top;
        evt = new MouseEvent(eventData.type, eventData.payload);
        break;
      case 'wheel':
        eventData.payload.clientX = eventData.payload.clientX * offset.value.zoom + offset.value.left;
        eventData.payload.clientY = eventData.payload.clientY * offset.value.zoom + offset.value.top;
        evt = new WheelEvent(eventData.type, eventData.payload);
        break;
      case 'keyup':
      case 'keydown':
      case 'keypress':
        evt = new KeyboardEvent(eventData.type, eventData.payload);
        break;
    }

    // 若创建了事件,在iframe元素上派发(让父页面能监听到)
    if (evt && iframeRef.value) {
      iframeRef.value.dispatchEvent(evt);
    }
  };

  // 绑定事件监听(替代Vue的onMounted)
  window.addEventListener('message', messageHandler);

  // 返回移除监听的方法
  return {
    removeEventListener: () => {
      window.removeEventListener('message', messageHandler);
    }
  };
}

这里最关键的是坐标换算,因为需要模拟真实的事件冒泡必须要基于当前子容器在父页面中的位置换算 + 鼠标在子容器的位移信息。完成后,用 new MouseEvent / new WheelEvent / new KeyboardEvent 构造出原生事件对象,再在 iframe 元素本身dispatchEvent。这样一来,父页里监听在 document 或容器上的拖拽逻辑(比如 document.addEventListener('mousemove', ...))会收到这个事件,就像鼠标真的在 iframe 边界上移动一样,幽灵元素自然能继续跟随。

以上代码为Vue实现,可与拖拽实现分开使用,使用时注意子容器的初始位置的参数即可

iframe-3.gif

总结

跨页面(跨 iframe)拖拽的核心在于两件事

  1. 数据通道:同源时可以用 HTML5 拖拽 API 的 dataTransfer;跨域时必须用 postMessage 在父页与 iframe 之间传「拖拽内容」和状态,拖拽只负责交互,数据由消息通道负责。
  2. 事件边界:事件冒泡不跨 document。鼠标进入 iframe 后,父页收不到 iframe 内的 mousemove/mouseup,所以需要子页采集事件 → postMessage 上报 → 父页坐标换算后在 iframe 元素上派发,让父页的拖拽逻辑持续收到事件,幽灵才能跨边界跟随。

把数据通道和事件边界这两件事理清,无论哪种方式,iframe 容器就可以像操作同一文档里的 DOM 一样,实现完整的跨页拖拽体验。

最后,我们来看一个完整的复杂应用场景:

example.gif

可点击这里「案例」查看

注意: 案例是只读状态无法添加组件,欢迎访问RollCode官网注册体验

CSS 大海:从选择器优先级到层叠规则,前端工程师的“避坑指南”

CSS 大海:从选择器优先级到层叠规则,前端工程师的“避坑指南”

CSS 不是编程语言,却比编程更考验逻辑与细节。
本文结合真实代码示例,深入浅出讲解 CSS 的核心机制:选择器、层叠、优先级、伪类/伪元素、格式化上下文,助你从“能跑就行”走向“心中有数”。


一、CSS 是什么?—— 不只是“样式表”

CSS(Cascading Style Sheets)的本质,是一组 “选择器 + 声明块” 的规则集合。

  • 声明(Declaration)color: red; —— 一个属性与值的键值对。
  • 声明块(Declaration Block):用 {} 包裹的多个声明。
  • 规则(Rule)选择器 + 声明块,如 p { color: blue; }
  • 样式表(StyleSheet):由多个规则组成,可来自:
    • 外联 <link rel="stylesheet">
    • 内嵌 <style>
    • 行内 style="..."

关键认知:CSS 的作用,是将样式规则“映射”到 HTML 元素上,而“映射”的依据,就是选择器


二、选择器:CSS 的“眼睛”

选择器决定了“谁被选中”。常见类型:

类型 示例 用途
元素选择器 p, div 选中所有 <p> 元素
类选择器 .container 选中 class 为 container 的元素
ID 选择器 #main 选中 id 为 main 的元素(唯一)
属性选择器 [data-category="科幻"] 选中含特定属性的元素
伪类 :hover, :nth-child() 选中特定状态或位置的元素
伪元素 ::before, ::first-letter 选中元素的虚拟部分

🔍 选择器组合实战

/* 后代选择器:.container 内所有 p */
.container p { text-decoration: underline; }

/* 子选择器:只选 .container 的直接子 p */
.container > p { color: pink; }

/* 相邻兄弟:h1 后紧跟的 p */
h1 + p { color: red; }

/* 通用兄弟:h1 后所有 p(同级) */
h1 ~ p { color: blue; }

💡 后代 vs 子选择器 (空格)匹配任意后代,> 只匹配直接子元素。


三、层叠(Cascading):CSS 的“决策机制”

当多个规则作用于同一元素时,谁生效? 这就是 层叠(Cascading) 要解决的问题。

层叠的判断顺序如下(优先级从高到低):

1️⃣ !important(最高,但慎用!)

p { color: red !important; } /* 覆盖一切 */

⚠️ 滥用会导致维护灾难。仅用于覆盖第三方库或紧急修复

2️⃣ 来源优先级(Origin)

  • 行内样式style="...") > 内嵌/外联 CSS
  • 用户自定义样式 > 浏览器默认样式

3️⃣ 选择器优先级(Specificity)—— 重点!

“个十百千” 法记忆(四元组 a-b-c-d):

含义 权重
千位 (a) !important 最高(单独处理)
百位 (b) ID 选择器 #main → 100
十位 (c) 类、属性、伪类 .btn, [type], :hover → 10
个位 (d) 元素、伪元素 p, ::before → 1

📌 口诀:ID 百,类十,元素个;谁大谁赢!

✅ 实战分析
<div id="main" class="container">
  <p>这是一个段落</p>
</div>
p { color: blue; }               /* 0-0-0-1 = 1 */
.container p { color: red; }     /* 0-0-1-1 = 11 */
#main p { color: green; }        /* 0-1-0-1 = 101 */

最终颜色:green(ID 优先级最高)。

再看这个:

.container #main p { color: orange; } /* 0-1-1-1 = 111 */

orange 胜出(ID + class > 单 ID)。

💡 建议:尽量用 class 控制样式,避免过度依赖 ID,保持低优先级、高可维护性。


四、伪类 vs 伪元素:别再混淆!

伪类(Pseudo-class) 伪元素(Pseudo-element)
作用 描述元素状态/位置 创建元素的虚拟部分
语法 单冒号 :hover(兼容) 双冒号 ::before(推荐)
示例 :hover, :nth-child(odd) ::before, ::first-letter
数量限制 可多个连用(:hover:focus 一个选择器只能用一个伪元素

🎯 伪元素的关键细节

.more::after {
  content: "\2192";
  display: inline-block; /* 必须!否则 transform 无效 */
  transition: transform 0.3s;
}

为什么加 display: inline-block
因为 ::after 默认是 纯 inline 元素,而 transform 对纯 inline 元素无效
改为 inline-block 后,它获得“盒模型”,支持 transformwidth 等属性。

例外:如果伪元素用了 position: absolute,则自动成为 block-level,无需额外设置。


五、nth-child vs nth-of-type:坑点解析

<div class="container">
  <h1>标题</h1>
  <p>段落1</p>
  <div>div</div>
  <p>段落2</p> <!-- 这是第4个子元素 -->
  <p>段落3</p> <!-- 第5个 -->
</div>
/* 选中第5个子元素(不管类型) */
.container p:nth-child(5) { ... } /* ❌ 不生效!第5个是 p,但前面有 h1/div 干扰 */

/* 选中第3个 <p> 元素 */
.container p:nth-of-type(3) { ... } /* ✅ 生效!只数 p 标签 */

记住

  • nth-child(n)在整个子元素序列中找第 n 个;
  • nth-of-type(n)在同类标签中找第 n 个。

六、其他高频知识点

1. margin 重叠(Margin Collapse)

  • 相邻块级元素的上下 margin 会合并为最大值,不是相加。
  • 解决方案:用 paddingborderflexgrid 隔离。

2. 小数像素(如 0.5px)如何处理?

  • 浏览器会四舍五入到物理像素(Retina 屏可渲染 0.5px)。
  • 通常用于移动端细边框:border: 0.5px solid #ccc;

3. 行内元素(inline)的局限性

  • 不支持 widthheightmargin-top/bottomtransform(除非是替换元素如 <img>)。
  • 解决方案:改为 inline-blockblock

七、总结:写好 CSS 的心法

  1. 语义化优先:用 <p> 表示段落,别用 <div> 代替;
  2. 低优先级策略:多用 class,少用 ID 和 !important
  3. 理解层叠规则:知道为什么某条样式没生效;
  4. 善用开发者工具:F12 查看 computed 样式和覆盖关系;
  5. Vibe Coding 可以,但要知其所以然

CSS 的魅力,在于细节中的秩序。
掌握这些底层逻辑,你就能在“大海”中航行,不再随波逐流。


附:快速自查清单

  • 我的选择器优先级是否过高?
  • 伪元素是否加了 display: inline-block
  • nth-child 是否误用了?
  • 是否滥用 !important

本文代码均可直接运行,建议动手调试,加深理解。
欢迎收藏、转发,一起告别“玄学 CSS”!

【前后端联调】接口代码生成 - hono + typescript + openapi 最佳实践

背景:在团队协作开发,前后端开发接口对齐永远是一道难题。在TS的世界里要保证类型安全往往浪费不必要的时间在定义类型上了。

实践方案:遵循 design-first,先设计接口,确定openapi文档。然后生成服务端API模板和前端请求SDK,保证类型安全,同时节省繁琐重复的代码编写时间。

约定 openAPI文档(通过apifox等方式)

这里展示使用apifox导出 openAPI描述文件。

image.png 一份简单的openAPI文档的json格式描述如下(默认模版.openapi.json):

{
  "openapi": "3.0.1",
  "info": {
    "title": "默认模块",
    "description": "",
    "version": "1.0.0"
  },
  "tags": [],
  "paths": {
"/sessions": {
      "post": {
        "summary": "登录",
        "deprecated": false,
        "description": "",
        "tags": [],
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string"
                  },
                  "password": {
                    "type": "string"
                  }
                },
                "required": [
                  "email",
                  "password"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {}
                }
              }
            }
          }
        },
        "security": []
      }
    },
  },
  "components": {
    "schemas": {},
    "responses": {},
    "securitySchemes": {}
  },
  "servers": [],
  "security": []
}  
  

paths下定义了你的路由(包括路径、方法、入参、响应等等)。
openAPI一般有两个导出形式json和yaml。这里简单起见,只放了1个登录接口(/sessions)的定义。

后端

后端框架,有很多选项,比如express/koa,hono,nest等等。我选择了hono,主要因为能支持bun/node多运行时和性能不错。

生成 hono 代码有两种比较推荐的方式:

下面主要介绍使用 hono-takibi 。如果是需要生成其他TS服务端框架的模板代码,可以选择使用Kubb 。如果是针对Java Springboot 则使用openapi-generator

hono-takibi生成模板

首先介绍下@hono/zod-openapi,这个是在hono框架的基础上,提供了http入参校验(基于zod)和文档生成(代码即文档)。

使用hono-takibi生成的代码是基于@hono/zod-openapi。基于json/yaml文件生成命令如下:

npx hono-takibi [path/openapi.json] -o [path/routes.ts]

比如我在项目根目录下,生成模板代码。

image.png

以登录注册为例,下面是生成的服务端代码routes.ts

import { createRoute, z } from '@hono/zod-openapi'
export const postSessionsRoute = createRoute({
  method: 'post',
  path: '/sessions',
  tags: [],
  summary: '登录',
  request: {
    body: {
      content: {
        'application/json': {
          schema: z
            .object({ email: z.string(), password: z.string() })
            .openapi({ required: ['email', 'password'] }),
        },
      },
    },
  },
  responses: { 200: { content: { 'application/json': { schema: z.object({}) } } } },
  security: [],
})


export const postUsersRoute = createRoute({
  method: 'post',
  path: '/users',
  tags: [],
  summary: '注册',
  request: {
    body: {
      content: {
        'application/json': {
          schema: z
            .object({
              email: z.string().openapi({ title: '邮箱' }),
              password: z.string().openapi({ title: '密码' }),
              emailCode: z.string().openapi({ title: '邮箱验证码' }),
              inviteCode: z.string().exactOptional().openapi({ title: '邀请码' }),
            })
            .openapi({ required: ['email', 'password', 'emailCode'] }),
        },
      },
    },
  },
  responses: { 200: { content: { 'application/json': { schema: z.object({}) } } } },
  security: [],
})

存在这么几个问题:

  1. 对于请求体的内容request.body.content 可以发现schema 是内联的,这不利于复用zod schema(不利于类型复用)。同理 还有request.queryresponses等。
  2. responses这里的响应体结构,没有做到复用。你可以看到{ 200: { content: {...}}}这种重复,这种不利于统一维护。

那么如何解决?

  1. 针对内联schema,我们这样解决:在apifox设计阶段,建立数据模型(这个是对应到服务端的DTO对象),最好是符合命名规范,对于query/body中的入参命名为XxxDto,对于响应结果命名为XxxResponseDto
  2. 针对响应体结构,定义统一的响应组件:在apifox设计阶段,建立响应组件(针对不同状态码200/201/400/401等)。同时针对固定响应结构需要设计一个数据模型ApiResponseDto 来填充。

数据模型和一些特定状态码的响应结构:

image.png

登录接口示例:

image.png

最终 再使用 hono-takibi 生成一下服务端 代码,如下:

import { createRoute, z } from '@hono/zod-openapi'

const UserResponseDtoSchema = z
  .object({ id: z.string(), email: z.string(), username: z.string(), avatar: z.string() })
  .openapi({ required: ['id', 'email', 'username', 'avatar'] })
  .openapi('UserResponseDto')

const CreateUserDtoSchema = z
  .object({
    email: z.string().openapi({ title: '邮箱' }),
    password: z.string().openapi({ title: '密码' }),
    emailCode: z.string().openapi({ title: '邮箱验证码' }),
    inviteCode: z.string().exactOptional().openapi({ title: '邀请码' }),
  })
  .openapi({ required: ['email', 'password', 'emailCode'] })
  .openapi('CreateUserDto')

const LoginDtoSchema = z
  .object({ email: z.string(), password: z.string() })
  .openapi({ required: ['email', 'password'] })
  .openapi('LoginDto')

const ApiResponseDtoSchema = z
  .object({
    code: z.int().openapi({ description: '业务号码' }),
    data: z.object({}).nullable().openapi({ description: '业务数据' }),
    message: z.string().exactOptional().openapi({ description: '消息' }),
  })
  .openapi({ required: ['code', 'data'] })
  .openapi('ApiResponseDto')

const LoginResponseDtoSchema = z
  .object({
    accessToken: z.string().openapi({ description: '身份token' }),
    user: UserResponseDtoSchema,
  })
  .openapi({ required: ['accessToken', 'user'] })
  .openapi('LoginResponseDto')

const SuccessNullResponse = {
  description: '无内容的成功响应',
  content: { 'application/json': { schema: ApiResponseDtoSchema } },
}

const UnprocessableResponse = {
  description: '无法处理请求,失败响应',
  content: { 'application/json': { schema: ApiResponseDtoSchema } },
}

export const postSessionsRoute = createRoute({
  method: 'post',
  path: '/sessions',
  tags: [],
  summary: '登录',
  request: { body: { content: { 'application/json': { schema: LoginDtoSchema, examples: {} } } } },
  responses: {
    200: {
      description: '成功',
      headers: z.object({}),
      content: {
        'application/json': {
          schema: z
            .object({
              code: z.int().openapi({ description: '业务号码' }),
              data: LoginResponseDtoSchema.nullable().openapi({ description: '业务数据' }),
              message: z.string().exactOptional().openapi({ description: '消息' }),
            })
            .openapi({ required: ['code', 'data'] }),
        },
      },
    },
    400: UnprocessableResponse,
  },
  security: [],
})

export const postUsersRoute = createRoute({
  method: 'post',
  path: '/users',
  tags: [],
  summary: '注册',
  request: {
    body: { content: { 'application/json': { schema: CreateUserDtoSchema, examples: {} } } },
  },
  responses: {
    200: {
      description: '成功',
      headers: z.object({}),
      content: {
        'application/json': {
          schema: z
            .object({
              code: z.int().openapi({ description: '业务号码' }),
              data: z.object({}).nullable().openapi({ description: '业务数据' }),
              message: z.string().exactOptional().openapi({ description: '消息' }),
            })
            .openapi({ required: ['code', 'data'] }),
        },
      },
    },
    400: UnprocessableResponse,
  },
  security: [],
})


P.S.有个小问题,就是你定义 响应组件/响应时,一定要定义描述(description),否则生成代码会出现TS问题。

image.png

定义了description的效果:

image.png

集成到hono提供API服务

需要注意:前面提到的routes.ts中生成的xxxRoute是对参数校验和文档描述。实际上的路由逻辑是下面这样的:

import { OpenAPIHono } from '@hono/zod-openapi'
import { cors } from 'hono/cors'
import { postSessionsRoute, postUsersRoute } from './routes'

const app = new OpenAPIHono()

app.use(
  '/*',
  cors({
    origin: '*',
  })
)

app.get('/', (c) => {
  return c.text('Hello Hono!')
})

app.openapi(postSessionsRoute, async (c) => {
  const { password } = c.req.valid('json')
  if (password.length < 6) {
    return c.json(
      {
        code: 400,
        data: null,
        message: '密码长度小于6',
      },
      400
    )
  }
  // TODO: 实现登录逻辑
  return c.json({
    code: 0,
    data: {
      accessToken: 'token',
      user: {
        id: '1',
        email: 'user@example.com',
        username: 'user',
        avatar: '',
      },
    },
  })
})

app.openapi(postUsersRoute, async (c) => {
  const { password } = c.req.valid('json')
  if (password.length < 6) {
    return c.json(
      {
        code: 400,
        data: null,
        message: '密码长度小于6',
      },
      400
    )
  }
  // TODO: 实现注册逻辑
  return c.json({
    code: 0,
    data: null,
  })
})

export default app

使用 bun dev 运行(这是一个honojs项目),然后在apifox中测试如下:

image.png

前端

生成TS客户端代码,选择就很多了

  • 最轻量:openapi-typescript + openapi-fetch。见openapi-ts.dev
  • 框架党首选:Orval (配合 TanStack Query/React Query),见orval.dev
  • 体系一致性方案:Kubb,前后端都采用Kubb。见kubb.dev
  • @hey-api/openapi-ts FastAPI官方就推荐 这个。见heyapi.dev

这里没有推荐 openapi-generator 了,因为确实存在一些局限性,生成的前端SDK并不好用,在TS安全类型上不如其他选择(运行这个工具还要折腾Java环境)。

下面重点介绍下 openapi-typescript + openapi-fetchhey-api 2种方式。

openapi-typescript + openapi-fetch

使用

1.安装 两个依赖

pnpm add openapi-typescript -D
pnpm add openapi-fetch

2.运行openapi-typescript 生成ts类型

npx openapi-typescript "../默认模块.openapi.json" -o "app/utils/openapi/schema.d.ts"

3.编写客户端代码

import createClient from "openapi-fetch";
import type { paths } from "./schema";

export const client = createClient<paths>({ baseUrl: "http://127.0.0.1:3000" });

// Export type for use in components
export type Client = typeof client;

4.一个登录例子:

const { 
data,  // only present if 2XX response
error  // only present if 4XX or 5XX response
} = await client.POST("/sessions", {
    body: {
        email,
        password,
    },
});

if (error) {
setStatus("error");
setMessage(error.message || "登录失败");
} else if (data) {
setStatus("success");
setMessage("登录成功!");
console.log("Login successful:", data);
// 这里可以处理登录成功后的逻辑,比如保存 token 或跳转
// const token = data.data?.accessToken;
}

一个好的 fetch 包装器绝对不应该使用泛型。 泛型需要更多的输入,而且可能会隐藏错误!

可以看出client 提供了GETPOST等方法,熟悉的写法,传入url和body参数。返回的结果包括data和error,data就是我们前面定义的ApiResponseDto,如下:

const data: {
    code: number;
    data: {
        accessToken: string;
        user: {
            id: string;
            email: string;
            username: string;
            avatar: string;
        };
    };
    message?: string | undefined;
} | undefined

而 error,就是当状态码不是2xx时,不空,类型是UnprocessableResponse | XxxErrorResponse类型,如下:

const error: {
    code: number;
    data: Record<string, never> | null;
    message?: string;
} | undefined
特性

1.支持的请求库如下:

Library Size (min)
openapi-fetch 6 kB
openapi-typescript-fetch 3 kB
feature-fetch 15 kB
openapi-axios 32 kB

2.支持「中间件」。

使用axios的同学,肯定对请求拦截和响应拦截不陌生,而openapi-fetch 提供中间件完成同样的功能:

import createClient, { type Middleware } from "openapi-fetch";
import type { paths } from "./schema";



const myMiddleware: Middleware = {
  async onRequest({ request, options }) {
    // set "Authorization" header,认证
    request.headers.set("Authorization", "Bearer " + "your_access_token"); 
    return request;
  },
  async onResponse({ request, response, options }) {
    const { body, ...resOptions } = response;
    console.log('body', body); // ReadableStream
    console.log('response', response);
    if (response.status === 401) {
      const error = new Error("Unauthorized");
      (error as Error & { status?: number }).status = 401;
      
      window.location.href = "/login";
      return
    }

    return response;
    // 或者 return new Response(body, { ...resOptions});
  },
  async onError({ error }) {
    // wrap errors thrown by fetch
    console.log('error', error);
    if (error instanceof Error) {
      return error;
    }
    return new Error("Oops, fetch failed", { cause: error });
  },
};


export const client = createClient<paths>({ baseUrl: "http://127.0.0.1:3000" });


// register middleware
client.use(myMiddleware);

// Export type for use in components
export type Client = typeof client;

需要注意,openapi-fetch 一般不会抛出错误,比如401/403之类错误状态码(除非你在onResponse中手动抛出错误)。onError 回调函数允许你处理 fetch 抛出的错误。常见的错误包括 TypeError (当出现网络或 CORS 错误时可能发生)和 DOMException (当使用 AbortController 中止请求时可能发生)。

3.支持使用 DTO类型

之前生成的schema.d.ts中定义了interface components:

export interface components {
    schemas: {
        UserResponseDto: {
            id: string;
            email: string;
            username: string;
            avatar: string;
        };
        CreateUserDto: {
            /** 邮箱 */
            email: string;
            /** 密码 */
            password: string;
            /** 邮箱验证码 */
            emailCode: string;
            /** 邀请码 */
            inviteCode?: string;
        };
        LoginDto: {
            email: string;
            password: string;
        };
        ...

可以这样使用:

import { client , type components } from "~/utils/openapi";

const body: components["schemas"]["LoginDto"] = {
      email,
      password,
    }

4.对框架的支持

通过openapi-react-query库,也能支持结合tanstack query使用。use-query

hey-api

基础使用和特性

1.hey-api生成代码时,会创建一个文件夹(默认是"client")存放内容,和openapi-typescript相比,是它生成了一个默认的client,并且每个API都提供了方法直接调用(无需路径)

2.安装

pnpm add @hey-api/openapi-ts -D

3.配置openapi-ts命令

"scripts": {
    "build": "react-router build",
    "dev": "react-router dev",
    "openapi-ts": "openapi-ts --input ../默认模块.openapi.json --output ./app/utils/heyapi"
  }

参数复杂了,也可以放配置文件/openapi-ts.config.ts中,比如:

import { defineConfig } from '@hey-api/openapi-ts';

export default defineConfig({
  input: 'http://127.0.0.1:8800/openapi.json', //支持远程url和本地openapi文件
  output: './app/APIs',
  plugins: [{
      name: '@hey-api/client-fetch',
      runtimeConfigPath: '@/hey-api',  // 控制client.gen.ts生成 
    },
  ], 
});

4.执行pnpm openapi-ts. 生成的sdk代码都在heyapi目录下,比较复杂。请求方法的代码都生成在sdk.gen.ts,而DTO类型都生成在types.gen.ts中。

app/
├── utils/
│ ├── heyapi/
│ │ ├── client/
│ │ ├── core/
│ │ ├── client.gen.ts
│ │ ├── index.ts
│ │ ├── sdk.gen.ts
│ │ └── types.gen.ts
│ └── index.ts

5.看看是如何使用的吧:

import { postSessions, type LoginDto } from "~/utils/heyapi";

import { client } from "~/utils/heyapi/client.gen";

// 需要先做一些基础的client配置 (也支持自己重新创建一个新 client)
client.setConfig({
  baseUrl: "http://127.0.0.1:3000",
});


const body: LoginDto = {
      email,
      password,
    }

const { data, error } = await postSessions({
body: body,
  });

  if (error) {
setStatus("error");
setMessage(error.message || "登录失败");
  } else if (data) {
setStatus("success");
setMessage("登录成功!");
console.log("Login successful:", data);
// 这里可以处理登录成功后的逻辑,比如保存 token 或跳转
// const token = data.data?.accessToken;
  }

其中请求结果:data 和 error ,默认情况下 和 openapi-fetch的处理是一致的(都是不抛出错误,而是将错误通过error暴露)。

image.png

6.网络请求库方面也适配了fetch和axios,也支持tanstack query。并且计划未来对TS服务端框架支持,但还有很多没完成的(处在soon状态)。

结合 tanstack query

参考:plugin tanstack-query. 准备配置文件/openapi-ts.config.ts

import { defineConfig } from '@hey-api/openapi-ts';

export default defineConfig({
  input: '../默认模块.openapi.json',
  output: './app/utils/heyapi',
  plugins: ['@tanstack/react-query'], 
});

执行 pnpm openapi-ts,此时生成的目录下多出一个文件 ./@tanstack/react-query.gen.ts

// This file is auto-generated by @hey-api/openapi-ts

import type { UseMutationOptions } from '@tanstack/react-query';

import { type Options, postSessions, postUsers } from '../sdk.gen';
import type { PostSessionsData, PostSessionsError, PostSessionsResponse, PostUsersData, PostUsersError, PostUsersResponse } from '../types.gen';

/**
 * 登录
 */
export const postSessionsMutation = (options?: Partial<Options<PostSessionsData>>): UseMutationOptions<PostSessionsResponse, PostSessionsError, Options<PostSessionsData>> => {
    const mutationOptions: UseMutationOptions<PostSessionsResponse, PostSessionsError, Options<PostSessionsData>> = {
        mutationFn: async (fnOptions) => {
            const { data } = await postSessions({
                ...options,
                ...fnOptions,
                throwOnError: true
            });
            return data;
        }
    };
    return mutationOptions;
};

/**
 * 注册
 */
export const postUsersMutation = (options?: Partial<Options<PostUsersData>>): UseMutationOptions<PostUsersResponse, PostUsersError, Options<PostUsersData>> => {
    const mutationOptions: UseMutationOptions<PostUsersResponse, PostUsersError, Options<PostUsersData>> = {
        mutationFn: async (fnOptions) => {
            const { data } = await postUsers({
                ...options,
                ...fnOptions,
                throwOnError: true
            });
            return data;
        }
    };
    return mutationOptions;
};

整个登录页面的代码如下:

import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {postSessionsMutation }  from "~/utils/heyapi/@tanstack/react-query.gen"
import { postSessions, type LoginDto } from "~/utils/heyapi";
import { client } from "~/utils/heyapi/client.gen";
import type { Route } from "./+types/login3";

// Configure the client
client.setConfig({
  baseUrl: "http://127.0.0.1:3000",
});

export function meta({}: Route.MetaArgs) {
  return [
    { title: "登录" },
    { name: "description", content: "用户登录" },
  ];
}

export default function Login() {
  const [email, setEmail] = useState("abc@example.com");
  const [password, setPassword] = useState("123456");

  const { mutate, isPending, isSuccess, error } = useMutation({
    // mutationFn: async (body: LoginDto) => {
    //   const { data, error } = await postSessions({
    //     body: body,
    //   });
    //   if (error) {
    //     throw error;
    //   }
    //   return data;
    // },
    ...postSessionsMutation(),
    onSuccess: (data) => {
      console.log("Login successful:", data);
      // 这里可以处理登录成功后的逻辑,比如保存 token 或跳转
      // const token = data.data?.accessToken;
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    mutate({
      body: {
        email,
        password,
      }
    });
  };

  const status = isPending ? "loading" : isSuccess ? "success" : error ? "error" : "idle";
  const message = isSuccess
    ? "登录成功!"
    : error
    ? (error as any).message || "登录失败"
    : "";

  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
      <div className="w-full max-w-md space-y-8 bg-white p-8 shadow rounded-lg">
        <div>
          <h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
            登录您的账户3
          </h2>
        </div>
        <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
          <div className="-space-y-px rounded-md shadow-sm">
            <div>
              <label htmlFor="email-address" className="sr-only">
                邮箱地址
              </label>
              <input
                id="email-address"
                name="email"
                type="email"
                autoComplete="email"
                required
                className="relative block w-full rounded-t-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
                placeholder="邮箱地址"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
              />
            </div>
            <div>
              <label htmlFor="password" className="sr-only">
                密码
              </label>
              <input
                id="password"
                name="password"
                type="password"
                autoComplete="current-password"
                required
                className="relative block w-full rounded-b-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
                placeholder="密码"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
              />
            </div>
          </div>

          {message && (
            <div
              className={`text-sm text-center ${
                status === "success" ? "text-green-600" : "text-red-600"
              }`}
            >
              {message}
            </div>
          )}

          <div>
            <button
              type="submit"
              disabled={status === "loading"}
              className="group relative flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-50"
            >
              {status === "loading" ? "登录中..." : "登录"}
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

技术选型

技术方案比较

在选择 TypeScript OpenAPI 客户端生成方案时,核心的权衡点在于:“运行时开销 vs. 开发体验” 以及 “灵活性 vs. 自动化程度”

维度 openapi-typescript (+ fetch) Orval Kubb @hey-api/openapi-ts
定位 极简/轻量主义 前端框架深度集成 工业级流水线/全栈体系 官方首选/生产级SDK
生成内容 仅生成 TS 类型定义 类型 + API 请求函数 + Hooks + Mocks 类型 + Hooks + 验证器 + 路由 + 模版 类型 + SDK + Hooks + 验证器
核心优势 零运行时开销。直接利用 TS 类型收窄,包体积增加几乎为 0。 TanStack Query 亲儿子。一键生成全套 React/Vue Query 钩子。 高度可插件化。支持用 JSX 写代码生成模版,前后端契约高度一致。 FastAPI 官方推荐。由原作者维护的升级版,支持 20+ 插件。
状态管理集成 无 (需手动配合 TanStack Query) 内置支持 TanStack Query, SWR 内置支持 TanStack Query 插件支持 TanStack Query, Pinia
校验支持 无 (仅编译期) 支持 Zod 支持 Zod, Faker 支持 Zod, Valibot
网络库 原生 fetch (通过 openapi-fetch) Axios, Fetch, Hook Axios, Fetch Fetch, Axios, Angular, Nuxt
Mock 支持 内置支持 MSW 内置支持 Faker 计划支持 (Chance)
适用场景 极度关注包体积、喜欢原生 API、对封装有“洁癖”的项目。 典型的中后台管理系统,深度使用 React/Vue Query 的项目。 复杂项目,需要自定义生成逻辑(如自动生成后端路由、Schema)的团队。 需要高度成熟稳定、符合 FastAPI 体系或大厂规范的 SDK。
学习曲线 极低 中 (需配置 orval.config.js) 高 (需理解插件系统/模版)

  1. 如果你追求 “极致轻量”
  • 选择: openapi-typescript + openapi-fetch
  • 理由: 它是目前最符合 TypeScript 原生思维的方案。它不生成成千上万行的 JS 代码,只生成类型。你的 API 调用看起来就像原生的 fetch,但带有完美的自动补全。
  1. 如果你是 “TanStack Query (React/Vue Query) 用户”
  • 选择: Orval
  • 理由: Orval 是目前生成 React Query Hooks 最成熟的工具。它能自动生成 queryKey、处理缓存逻辑、甚至自动生成 MSW 的 Mock 数据,极大提升开发效率。
  1. 如果你想要 “全栈体系一致性”
  • 选择: Kubb
  • 理由: Kubb 的野心更大,它不仅是为了前端。通过它的插件系统,你可以把一套 OpenAPI 定义同时转化为前端的 Hooks 和后端的路由定义(如 Hono/Elysia),确保前后端代码在结构上是“镜像”的。
  1. 如果你追求 “官方规范与工程化”
  • 选择: @hey-api/openapi-ts
  • 理由: 这是 openapi-typescript-codegen 的正统继任者。如果你的后端是 FastAPI,或者你希望生成的代码像一个正式的 SDK(有完整的类、方法封装),它是最稳妥的选择。它的插件系统(Plugin)也让它在功能上非常全能。

总结一句话:

  • 想简单:openapi-typescript
  • 想省事(前端):Orval
  • 想折腾/全栈:Kubb
  • 想标准/大而全:Hey API

我个人的话,就主要从hey-apiopenapi-typescript/openapi-fetch中选了:

  • 对于管理端、ToB的应用,使用hey-api 或者 Orval
  • 对于比较轻量化的h5页面使用openapi-typescript/openapi-fetch

LeetCode 224. 基本计算器:手写实现加减+括号运算

LeetCode 上的经典栈应用题——224. 基本计算器,这道题的核心是实现一个支持 加减运算、括号、空格 的简易计算器,并且明确禁止使用 eval() 等内置表达式计算函数,完全需要我们手动解析字符串、处理运算逻辑。

很多同学遇到这道题会头疼,尤其是括号带来的运算优先级问题,今天就结合完整可运行的代码,从题目理解到代码拆解,一步步讲清楚每一步的逻辑,新手也能轻松看懂!

一、题目回顾(LeetCode 224. 基本计算器)

题目描述

给你一个字符串表达式 s ,请你实现一个基本计算器来计算并返回它的值。

注意:

  • 表达式 s 只包含数字、'+'、'-'、'('、')' 和空格 ' ';

  • 不允许使用任何将字符串作为数学表达式计算的内置函数,比如 eval();

  • 示例:输入 "(1+(4+5+2)-3)+(6+8)",输出 23;输入 " 2-1 + 2 ",输出 3。

题目核心难点

这道题的难点不在于加减运算本身,而在于两个点:

  1. 括号的处理:括号会改变运算优先级,需要先计算括号内的子表达式,再将结果与括号外的内容合并;

  2. 多位数的解析:字符串中的数字可能是个位数(如 "1"),也可能是多位数(如 "123"),需要正确拼接;

  3. 空格的过滤:空格不影响计算,需要跳过不处理。

二、核心解题思路

解决这道题的最优思路是使用 栈(Stack) 来保存计算过程中的状态,核心逻辑围绕「括号优先级」和「符号处理」展开:

  1. 用栈保存括号外的计算状态:遇到左括号 '(' 时,将当前已经计算出的结果、括号前的符号保存到栈中,然后重置状态,专门计算括号内的子表达式;

  2. 用变量维护当前计算状态:用 result 记录当前层级(括号内/外)的计算结果,用 sign 记录当前数字的符号(默认是 '+',因为表达式第一个数字隐含正号),用 num 临时拼接多位数;

  3. 遇到右括号 ')' 时,弹出栈中保存的状态(括号外的结果和括号前的符号),将括号内的计算结果与括号外的结果合并,继续后续计算;

  4. 空格直接跳过,不影响任何计算逻辑。

简单来说:栈的作用就是「暂存括号外的上下文」,让我们可以专注于括号内的计算,计算完成后再回退到之前的上下文继续运算。

三、完整代码实现(TypeScript)

先上完整可运行的代码,后面逐行拆解每一步逻辑,确保每一行代码都讲明白:

function calculate(s: string): number {
  const stack: number[] = [];
  let num = 0;
  let sign = '+'; // 当前数字的符号(+/-)
  let result = 0; // 当前层级(括号内/外)的计算结果

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

    // 1. 解析多位数(比如"123" -> 123)
    if (c >= '0' && c<= '9') {
      num = num * 10 + (c.charCodeAt(0) - '0'.charCodeAt(0));
    }

    // 2. 处理左括号:保存当前状态(结果、符号)到栈,重置状态计算括号内的值
    if (c === '(') {
      stack.push(result); // 保存括号外的结果
      stack.push(sign === '+' ? 1 : -1); // 保存括号前的符号(用1/-1代替+/-更方便)
      // 重置状态,开始计算括号内的子表达式
      result = 0;
      sign = '+';
    }

    // 3. 处理运算符(+/-)或右括号:结算当前数字
    if ((c === '+' || c === '-') || i === s.length - 1 || c === ')') {
      // 根据当前符号,把数字加到结果中
      result += sign === '+' ? num : -num;
      num = 0; // 重置临时数字

      // 更新符号(仅当是+/-时)
      if (c === '+' || c === '-') {
        sign = c;
      }

      // 4. 处理右括号:弹出栈中保存的状态,合并结果
      if (c === ')') {
        const prevSign = stack.pop()!; // 括号前的符号(1/-1)
        const prevResult = stack.pop()!; // 括号外的结果
        result = prevResult + prevSign * result; // 合并括号内和括号外的结果
      }
    }

    // 空格直接跳过,无需处理
  }

  return result;
}

四、代码逐行拆解(核心逻辑精讲)

我们按「初始化变量 → 遍历字符串 → 各场景处理 → 最终返回结果」的顺序,逐块拆解代码,重点讲清楚栈的用法和括号处理逻辑。

1. 初始化变量(关键变量说明)

const stack: number[] = []; // 栈:保存括号外的计算状态(结果+符号)
let num = 0; // 临时变量:拼接多位数(如"123",先算1,再12,最后123)
let sign = '+'; // 符号变量:记录当前数字的符号(默认+,因为第一个数字隐含正号)
let result = 0; // 结果变量:记录当前层级(括号内/外)的计算结果

这里有个小细节:sign 记录的是「当前数字的符号」,而不是「上一个运算符」,这样处理能更方便地对接多位数解析和括号逻辑,后面会看到具体作用。

2. 遍历字符串(核心循环)

循环的核心是「逐个处理字符」,根据字符的类型(数字、左括号、右括号、运算符、空格),执行不同的逻辑,我们逐个场景分析:

场景1:解析多位数(字符是 0-9)

if (c >= '0' && c <= '9') {
  num = num * 10 + (c.charCodeAt(0) - '0'.charCodeAt(0));
}

这行代码是「多位数拼接」的关键,举个例子:解析 "123" 时:

  • 遇到 '1':num = 0 * 10 + (49 - 48) = 1(字符 '0' 的 ASCII 码是 48,'1' 是 49);

  • 遇到 '2':num = 1 * 10 + (50 - 48) = 12;

  • 遇到 '3':num = 12 * 10 + (51 - 48) = 123;

这样就完成了多位数的正确拼接,避免把 "123" 解析成 1、2、3 三个单独的数字。

场景2:处理左括号 '('

if (c === '(') {
  stack.push(result); // 保存括号外的结果
  stack.push(sign === '+' ? 1 : -1); // 保存括号前的符号(用1/-1代替+/-)
  // 重置状态,开始计算括号内的子表达式
  result = 0;
  sign = '+';
}

这是括号处理的核心步骤之一,目的是「暂存括号外的上下文」,举个例子:当遇到表达式 "(1 + 2) - 3" 中的 '(' 时:

  1. 此时 result = 0(括号外还没计算),sign = '+'(括号前是正号);

  2. 把 result(0)压入栈,再把 sign 转换成 1('+' 对应 1,'-' 对应 -1)压入栈;

  3. 重置 result = 0、sign = '+',开始专注计算括号内的 "1 + 2"。

为什么用 1/-1 代替 '+'/'-'?因为后续合并括号内外结果时,直接用「括号外结果 + 括号前符号 × 括号内结果」就能快速计算,无需再判断符号字符串,更简洁。

场景3:处理运算符(+/-)、右括号 ')' 或字符串末尾

这部分是「结算当前数字」的核心,当遇到以下三种情况时,说明当前数字已经解析完成,需要把它加到 result 中:

  • 遇到运算符 '+' 或 '-'(下一个数字要开始解析,当前数字需要结算);

  • 遇到右括号 ')'(括号内的数字解析完成,需要结算后和括号外合并);

  • 遍历到字符串末尾(最后一个数字没有后续运算符,需要结算)。

if ((c === '+' || c === '-') || i === s.length - 1 || c === ')') {
  // 步骤1:根据当前符号,把数字加到结果中
  result += sign === '+' ? num : -num;
  num = 0; // 重置临时数字,准备解析下一个数

  // 步骤2:更新符号(仅当当前字符是+/-时)
  if (c === '+' || c === '-') {
    sign = c;
  }

  // 步骤3:处理右括号,合并括号内外结果
  if (c === ')') {
    const prevSign = stack.pop()!; // 弹出括号前的符号(1/-1)
    const prevResult = stack.pop()!; // 弹出括号外的结果
    result = prevResult + prevSign * result; // 合并结果
  }
}

我们分步骤拆解这部分逻辑,还是用例子辅助理解:

例子1:解析 "1 + 2" 时,遇到 '+' 运算符:

  • 此时 num = 1(已经解析完第一个数字),sign = '+';

  • result += 1 → result = 1;

  • num 重置为 0,sign 更新为 '+'(当前运算符);

  • 继续解析下一个数字 2,后续遇到字符串末尾时,再把 2 加到 result 中,最终 result = 3。

例子2:解析 "(1 + 2) - 3" 时,遇到 ')' 右括号:

  • 括号内已经解析完 "1 + 2",此时 result = 3,num = 0;

  • 弹出栈顶的 prevSign = 1(括号前是正号),再弹出 prevResult = 0(括号外的结果);

  • 合并结果:result = 0 + 1 × 3 = 3;

  • 继续解析后续的 '-' 和 3,最终 result = 3 - 3 = 0。

场景4:处理空格(直接跳过)

代码中没有专门写空格的处理逻辑,因为当 c 是空格时,不满足任何一个 if 条件,会直接进入下一次循环,相当于「自动跳过」,逻辑简洁高效。

3. 最终返回结果

循环结束后,result 中就保存了整个表达式的计算结果,直接返回即可:return result;

五、总结与优化思考

1. 核心知识点回顾

这道题的核心是「栈的应用」,用栈暂存括号外的上下文,解决括号优先级问题,同时用三个变量(num、sign、result)维护当前计算状态,高效解析多位数和符号。

关键亮点:

  • 用 1/-1 代替 '+'/'-' 符号,简化括号内外结果的合并逻辑;

  • 一次遍历完成所有解析和计算,时间复杂度 O(n)(n 是字符串长度);

  • 栈的空间复杂度最坏 O(n)(嵌套括号层数最多为 n/2),属于最优解法。

2. 优化方向(可选)

如果想进一步优化代码,可以考虑:

  • 用正则表达式先过滤掉所有空格,减少循环中的判断(但会增加一次正则遍历,整体效率影响不大);

  • 对于嵌套括号极深的场景,栈的空间开销无法避免,这是该思路的固有特性,也是最优选择。

3. 刷题启示

遇到「优先级处理」「上下文暂存」类的算法题,优先考虑栈这种数据结构(比如括号匹配、表达式求值等)。这道题虽然是中等难度,但覆盖了栈的核心用法、多位数解析、符号处理等多个细节,吃透这道题,能轻松应对同类的表达式求值问题(如 LeetCode 227. 基本计算器 II,支持乘除运算)。

从删除节点到快慢指针:一篇写给初学者的链表操作指南

从删除节点到快慢指针:一篇写给初学者的链表操作指南

前言

链表,这个数据结构对很多前端同学来说就像一道坎。明明 JavaScript 里到处都是对象引用,为什么到了链表这里就理不清了?

今天这篇文章,我会带你从最基础的链表删除开始,一步一步深入到快慢指针。每一行代码我都会解释为什么这么写,每一个变量我都会说清楚它的作用。相信我,看完这篇文章,链表不再是你的痛点。

什么是链表?一个最简单的比喻

想象一下寻宝游戏:每一张纸条上写着一个宝藏的名字,还有下一张纸条的位置。你拿到第一张纸条,看完宝藏,顺着地址找到第二张纸条...这就是链表。

// 链表中的一个节点
class ListNode {
    constructor(val) {
        this.val = val      // 当前节点的值(宝藏的名字)
        this.next = null   // 指向下一个节点的引用(下一张纸条的位置)
    }
}

第一部分:删除节点 - 为什么要引入哨兵节点?

场景:删除链表中值为 val 的节点

我们先从最简单的需求开始:给定一个链表,删除其中第一个值为 val 的节点。

初版代码:问题在哪里?
function remove(head, val) {
    // 问题1:头节点特殊处理
    if (head && head.val === val) {
        return head.next  // 直接返回第二个节点作为新头节点
    }

    // 问题2:这里的逻辑和上面不统一
    let cur = head
    while (cur.next) {
        if (cur.next.val === val) {
            cur.next = cur.next.next  // 跳过目标节点
            break
        }
        cur = cur.next
    }
    return head
}

这段代码有什么问题?

问题1:头节点需要特殊处理 为什么?因为删除节点的通用逻辑是:找到要删除节点的前一个节点,让它的 next 指向要删除节点的下一个节点。

但头节点没有前一个节点!所以我们必须单独处理。

问题2:逻辑不统一 删除头节点:return head.next 删除其他节点:cur.next = cur.next.next 两种写法,两个思维路径,容易出错。

问题3:尾节点的隐患 虽然这个例子没体现,但如果要删除尾节点,我们的代码也有问题。尾节点的 nextnull,但我们的删除逻辑依然适用,只是要小心别出现 null.next

引入哨兵节点:一个革命性的改进
function remove(head, val) {
    // 创建一个哨兵节点,值是多少不重要,0只是占位
    const dummy = new ListNode(0)
    // 哨兵节点指向头节点
    dummy.next = head
    
    // 现在,dummy 成为了头节点的前驱节点
    let cur = dummy
    
    // 遍历链表
    while (cur.next) {
        if (cur.next.val === val) {
            // 删除 cur.next 节点
            cur.next = cur.next.next
            break
        }
        cur = cur.next
    }
    
    // 返回真正的头节点(dummy.next 可能是原来的头,也可能是新头)
    return dummy.next
}

哨兵节点做了什么?

  1. 给头节点找了个"前驱":现在所有节点都有前驱节点了
  2. 统一了删除逻辑:删除任何节点都是 cur.next = cur.next.next
  3. 简化了返回值:永远返回 dummy.next,不需要判断头节点是否被删

为什么叫"哨兵"? 就像军队的哨兵站在营区门口一样,这个节点站在链表的最前面,帮我们处理边界情况。它不存储有效数据,但它让我们的代码更安全。

内存视角:删除节点时发生了什么?

很多初学者会问:cur.next = cur.next.next 之后,被删除的节点去哪里了?

答案是:没有人引用它了,它会被 JavaScript 的垃圾回收机制回收

删除前:
dummy → node1 → node2(node_to_delete) → node3 → null
                ↑
               cur

执行 cur.next = cur.next.next:

dummy → node1 ──────→ node3 → null
                ↘
                 node2(没有引用指向它了,等待垃圾回收)

第二部分:反转链表 - 哨兵节点的妙用

如果说删除节点是哨兵节点的"被动防御",那反转链表就是它的"主动进攻"。

理解头插法:像打牌一样反转链表

function reverseList(head) {
    // dummy 节点将作为新链表的头哨兵
    const dummy = new ListNode(0)
    // cur 指向当前要处理的节点,从原链表头开始
    let cur = head
    
    while (cur) {
        // 第一步:保存下一个节点
        // 为什么要保存?因为一旦改变了 cur.next 的指向,我们就找不到下一个节点了
        const nextNode = cur.next
        
        // 第二步:头插法的核心 - 把当前节点插入到 dummy 和 dummy.next 之间
        // 这一步让当前节点指向已反转部分的头部
        cur.next = dummy.next
        
        // 第三步:更新 dummy.next,让它指向最新的头节点
        dummy.next = cur
        
        // 第四步:移动到下一个节点
        cur = nextNode
    }
    
    return dummy.next
}

详细拆解每一步:

假设链表是:1 → 2 → 3 → null

初始状态:

dummy → null
cur = 123null

处理节点1:

保存 next = 2
1.next = dummy.next = null    // 1 → null
dummy.next = 1                // dummy → 1 → null
cur = 2                      // 移动到下一个节点

处理节点2:

保存 next = 3
2.next = dummy.next = 1       // 21 → null
dummy.next = 2               // dummy → 21 → null
cur = 3                      // 移动到下一个节点

处理节点3:

保存 next = null
3.next = dummy.next = 2       // 321 → null
dummy.next = 3               // dummy → 321 → null
cur = null                   // 循环结束

为什么这种方法好?

  1. 原地反转:不需要额外创建新的节点
  2. 思路清晰:每一轮都是在做同一件事 - 把当前节点"插"到最前面
  3. 哨兵节点锚定:dummy.next 始终指向最新的头节点

第三部分:检测环形链表 - 快慢指针入门

场景:如何判断一个链表里有环?

想象一下操场跑步的场景:

  • 如果是直线跑道,跑得快的人永远在前面,先到终点
  • 如果是环形跑道,跑得快的人会从后面追上跑得慢的人

这就是快慢指针的核心思想。

function hasCycle(head) {
    // 如果链表为空或只有一个节点,肯定没有环
    if (!head || !head.next) return false
    
    // 两个指针起点相同
    let slow = head
    let fast = head
    
    // 快指针每次走两步,所以必须保证 fast 和 fast.next 都存在
    while (fast && fast.next) {
        slow = slow.next        // 慢指针走1步
        fast = fast.next.next  // 快指针走2步
        
        // 如果两个指针相遇了,说明有环
        // 注意:这里比较的是引用地址,不是值
        if (slow === fast) {
            return true
        }
    }
    
    // 快指针到达了终点,说明没有环
    return false
}

为什么快指针每次走2步,慢指针走1步?

这是数学上的最优解。你可以理解为:

  • 如果快指针走3步,可能会"跳过"慢指针(在环中擦肩而过)
  • 如果快指针走1步,那就和慢指针永远在一起了
  • 走2步是最稳妥的,只要有环,快指针一定会在某圈追上慢指针

为什么返回 false 的条件是 fast 或 fast.next 为 null?

因为快指针走得快,如果链表没有环,它一定会先到达链表的末尾。而链表末尾的特征就是:

  • fast === null(链表长度为偶数,fast 直接走到了末尾)
  • fast.next === null(链表长度为奇数,fast 走到了最后一个节点)

一个常见的困惑:

问:如果链表很长,环很小,快指针会不会在环里转很多圈才能追上慢指针?

答:是的,但这不是问题。当慢指针进入环时,快指针已经在环里了。它们的速度差是1步/次,所以最多转一圈就会被追上。时间复杂度仍然是 O(n)。

第四部分:删除倒数第N个节点 - 快慢指针 + 哨兵节点

现在我们有了两个武器:

  1. 哨兵节点 - 处理边界情况
  2. 快慢指针 - 一次遍历定位

让我们把它们结合起来,解决一个经典问题。

问题分析

删除倒数第n个节点,最直观的思路是:

  1. 先遍历一遍,拿到链表长度 L
  2. 那么倒数第n个节点就是正数第 L-n+1 个节点
  3. 再遍历一遍,找到它的前驱节点,删除它

但我们可以做得更好:一次遍历搞定!

function removeNthFromEnd(head, n) {
    // 1. 创建哨兵节点,统一处理逻辑
    const dummy = new ListNode(0)
    dummy.next = head
    
    // 2. 快慢指针都从哨兵节点开始
    let fast = dummy
    let slow = dummy
    
    // 3. 快指针先走n步
    //   这样快指针和慢指针之间就保持了n个节点的距离
    for (let i = 0; i < n; i++) {
        fast = fast.next
    }
    
    // 4. 快慢指针一起走
    //   当快指针到达最后一个节点时,慢指针刚好在倒数第n个节点的前一个位置
    while (fast.next) {
        fast = fast.next
        slow = slow.next
    }
    
    // 5. 删除倒数第n个节点
    //   slow.next 就是要删除的节点
    slow.next = slow.next.next
    
    // 6. 返回真正的头节点
    return dummy.next
}

为什么这个解法是优雅的?

关键理解1:为什么快指针先走n步?

因为我们要删除倒数第n个节点。倒数第n个节点到链表的末尾的距离是n-1(不算尾节点的next)。

当快慢指针一起走时,快指针到达末尾(null的前一个)时,慢指针和快指针的距离保持不变(n步)。此时,慢指针指向的就是倒数第n个节点的前一个节点

关键理解2:为什么用dummy?

考虑一个极端情况:链表只有一个节点,要删除倒数第1个节点(也就是它自己)。

如果没有dummy:

  • slow和fast都指向head
  • 快指针先走1步:fast = fast.next = null
  • 进入while循环:fast.next 会报错,因为 null.next 不存在

有了dummy:

  • slow和fast都指向dummy
  • 快指针先走1步:fast = dummy.next = head
  • 再走1步:fast = head.next = null
  • 进入while循环:fast.next?fast是null,报错?

为什么是 while(fast.next) 而不是 while(fast)

因为我们想要的是:当fast是最后一个节点时停止。这样slow刚好指向倒数第n个节点的前驱。

如果写成 while(fast),fast会一直走到null,slow就会指向倒数第n个节点本身,而不是它的前驱。

总结:这些技巧的本质是什么?

哨兵节点的本质:用空间换逻辑的简洁性。我们多创建了一个节点,但换来的是:

  • 不需要if-else处理特殊情况
  • 代码更易读,更易维护
  • 边界条件自动化解

快慢指针的本质:用速度差来定位位置。就像两个人跑步,我们通过控制他们的速度差,让慢的人在我们想要的位置停下来。

组合技巧的本质:1+1 > 2。哨兵节点让快慢指针更安全,快慢指针让哨兵节点发挥更大作用。

写在最后

链表操作看似花样繁多,但核心技巧就那么几个。当你理解了:

  • 为什么需要哨兵节点(边界处理)
  • 为什么用头插法(原地反转)
  • 为什么快慢指针能相遇(数学原理)

你就掌握了链表的"内功心法"。剩下的就是多看、多写、多思考。

如果你读到这里,相信链表已经不再是你的痛点。但如果还有困惑,不妨收藏这篇文章,动手敲一遍代码。编程是动手的艺术,只有自己亲手写过,才能真正理解。


本文代码已通过基础测试,但若你发现任何问题或有更好的建议,欢迎在评论区指出。让我们一起写出更好的代码!

模板语法部分

vue可以渐进式增强HTML的前提是vue的模板语法不会使得浏览器报错,这样我们才可以在dom中内嵌vue语法,

所以vue文档在开头就说了一件事情, 就是vue的模板语法是语法合格的HTML

同时在在SFC(也就是后缀为.vue的文件)中, 模板被编译器编译后还会对代码进行优化,像手写vue提供的渲染函数代码没有经过编译阶段,就没有这个编译阶段,所以vue文档在开头说了另一件事情就是手写渲染函数而不采用模板,不会受到和模板同等级的编译优化

总结一下,vue文档开头说了两句话,一件是写了vue的语法的模板放浏览器上跑也没有问题,一件是编译器会对SFC的模板进行优化要比自己手写渲染函数好。

v-html

先看下面一个例子,

<body>
  <div id="app">

  </div>

  <!-- App组件 -->
  <template id="appTemplate">
    <div style="width: 100px;height: 100px;background-color: pink;" v-html="rawHtml">

    </div>
  </template>


  <script src="https://cdn.bootcdn.net/ajax/libs/vue/3.5.22/vue.global.prod.min.js"></script>
  <script>
    const App = {
      data() {
        return {
          rawHtml: "this is Compo {{count}}"
        }
      },
      template: "#appTemplate",
    };



    const app = Vue.createApp(App);


    app.mount("#app");
  </script>
</body>

可以看到v-html的作用就是插入一个html片段,在这个属性中没有办法使用Vue语法,很适合用来渲染服务器返回的html片段

Attribute绑定

<div v-bind:id="dynamicId"></div>

<div :id="dynamicId"></div>

在这里div标签的id属性的值会保持和组件的dynamicId属性一致,如果绑定的值是null或者是undefined,那么这个属性会从渲染的元素上移除,下面是这个用法的简写语法,而且当要绑定的参数id和提供值的数据dynamicId名字一样的话,还可以更加简写

<div v-bind:id></div>
<div :id></div>

注意,这里需要有一个注意点,那就是当你给属性使用一些假值的时候,它是否还会渲染,请看下面一个例子

<body>
  <div id="app">

  </div>

  <!-- App组件 -->
  <template id="appTemplate">
    <div :id>这是div</div>
  </template>


  <script src="./node_modules/vue/dist/vue.global.prod.js"></script>
  <script>
    const App = {
      data() {
        return {
          id: 0
                }
      },
      template: "#appTemplate"
    };



    const app = Vue.createApp(App);


    app.mount("#app");
  </script>

// 上述渲染结果为 id: "0"

//如果id为false
//则渲染id: "false"

//如果id为""
//则渲染id

//如果为null, undefined
//那么就不会渲染这个属性了

可以看到除了null,undefined不会渲染外,其它假值都会作为这个标签属性id的值,vue中除此之外还提到了布尔型Attribute,它不会将你的数据映射到属性的值上,而是判断你的数据真假,如果为真,则会把这个属性渲染到元素上,如果判断为假,则不会渲染这个属性, 会有这种布尔型属性,主要是和html的一些布尔属性有关,disabled, hidden这种,当你把属性写在标签上的时候,不管你给什么值,都会执行这个属性应有的效果, vue在处理的时候当然不能直接把数据映射上去,判断数据的真假然后决定这个属性是否应该存在是比较好的处理了, 请看下面一个例子

<body>
  <div id="app">

  </div>

  <!-- App组件 -->
  <template id="appTemplate">
    <div :hidden="id">这是div</div>
  </template>


  <script src="./node_modules/vue/dist/vue.global.prod.js"></script>
  <script>
    const App = {
      data() {
        return {
          id: null
        }
      },
      template: "#appTemplate"
    };

    const app = Vue.createApp(App);
    app.mount("#app");
  </script>
</body>

//这里没有渲染hidden, 如果id为undefined也是一样

//如果id为 0
//则没有渲染 hidden


//如果id为""
//则渲染出了 hidden


//如果id为 false
//则没有渲染hidden


可以看到,对于布尔型Attribute,vue的判断也和正常的js不一致,vue没有把本该是一个假值的""空字符串也定义为空,但是对于其它常见的假值false, 0, null, undefined,就不会渲染这个属性,我们也可以批量的绑定多个值。

<body>
  <div id="app">

  </div>

  <!-- App组件 -->
  <template id="appTemplate">
    <div v-bind="objectAttr">这是div</div>
  </template>

  <script src="./node_modules/vue/dist/vue.global.prod.js"></script>
  <script>
    const App = {
      data() {
        return {
          objectAttr: {
            id: false,
            class: "wrapper"
          }
        }
      },
      template: "#appTemplate"
    };

    const app = Vue.createApp(App);
    app.mount("#app");
  </script>
</body>

//这里id: false也可以渲染出来,因为id不是一个布尔型属性

指令

指令是带有v-前缀的特殊attribute, 上述描述的v-bind也就是一个指令,可以说v-bind:id.prevent="idValue"中,v-bind用于声明要使用的指令,:id部分是指令的参数部分,而idValue是属性的值部分, 其中prevent是修饰符的部分,请看下面一个例子

<a href="https://bilibili.com" @click.prevent="">bilibili
  <span>a的子元素span</span>
</a>

动态参数

vue还提供了一种动态参数的用法, 它具体的用法是v-bind:[attributeName]='url', 这里的attributeName是一个组件的数据,属性名称由这个attributeName的值决定,如果这里的attributeName为href,则在模板上就会渲染属性href='....',,但是这种动态用法很容易踩坑,,请先查看下面一个例子,

<body>
  <div id="app"></div>

  <template id="appTemplate">
   <img :[attributeName]='url'>
  </template>

  <!-- 使用 Vue 3 CDN -->
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script>
    const { createApp } = Vue;
    
    const App = {
      data() {
        return {
          attributeName: "src",
          url: "https://picsum.photos/200/300"  // 测试图片
        };
      },
      template: "#appTemplate"
    };

    createApp(App).mount("#app");
  </script>
</body>

并没有渲染出我们的图片!!!为什么??? 不妨让我看看dom的结构是什么样的

联想截图_20260211215405.png

可以看到,浏览器对属性进行了归一化处理,大写的字符被转化为小写的了,所以这里最好是把数据也写成小写形式,如下

 attributename: "src",

我们还可以使用动态绑定多个值规避这一点

   <img v-bind='{[attributeName]: url}'>

当然也可以使用计算属性计算出结果对象然后v-bind绑定上, 或者直接把DOM写在template字符串上, 当然在SFC上不会有这个问题,SFC没有把Vue模板给浏览器先解析一遍, 在vue文档中也描述了这个问题,当使用DOM内嵌模版时,我们需要避免在名称中使用大写字母,因为浏览器会强制将其转换为小写

动态参数在内嵌模板的dom中的问题

最后我们讨论来着vue文档的这么一句话,动态参数表达式因为某些字符串的缘故有一些语法限制,比如空格和引号,在HTML attribute名称中都是不合法

这是因为HTML属性名被规定必须是由一个或多个非空白的字符组成的,而且一定不能包含, 空格双引号, 单引号, =号 , <,>等特殊字符, 请看下面一个例子

<a :[foo + 1]="value"> ... </a>

我们看它在浏览器上是什么样子,

联想截图_20260211224948.png

一摸一样!,我们再看看这个标签元素的属性结构,

联想截图_20260211225020.png

原本我们想表达的一个完整意思 foo + 1被打断了!!! 为什么会这样呢,其实是浏览器宽松的解析导致的,它不会发生什么报错,它只会按HTML规则解析每一个HTML属性,所以这里浏览器看上去保留了上面写的完整字符串好像是真的作为属性了,但实际上它已经把属性按HTML分词规则拆成了多个属性,vue显然没办法解析这样逻辑被拆分出去的不完整属性, 所以让我们回到vue文档,比如空格和引号,这样的不合法结构无法被解析为正确的HTML属性,但是你可以写成foo+1,是可以被解析的,因为它没有空格和引号了,请看下面一个例子。

  <div id="app"></div>


  <!-- 使用 Vue 3 CDN -->
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script>
    const { createApp } = Vue;
    
    const App = {
      data() {
        return {
          src: 1
        }
      },
        template: `<a href="https://bilibili.com" @click.prevent="">bilibili
      <span :[src+1]="1">a的子元素span</span>
    </a>`
      };

    createApp(App).mount("#app");
  </script>

这段代码结果中a标签渲染出的属性为2="1"。

也可以使用SFC和template规避这个问题,像上面说的,SFC和template都没有让浏览器预先处理标签,自然也就能处理一些浏览器没办法处理的。

显示移除绑定

在文档中还有这么一句话,动态参数中表达式的值应当是一个字符串,或者是null, 特殊值null意为显示移除该绑定,其它非字符串的值会触发警告

请看下面一段代码

  <div id="app"></div>


  <!-- 使用 Vue 3 CDN -->
  <script src="./node_modules/vue/dist/vue.global.prod.js"></script>
  <script>
    const { createApp } = Vue;
    
    const App = {
      data() {
        return {
          src: 1,
          clickon: 'click'
        }
      },
        template: `<a href="https://bilibili.com" @[clickon].prevent="">bilibili
      <span :[src+1]="1">a的子元素span</span>
    </a>`
      };

    const rootComponent = createApp(App).mount("#app");
  </script>

可以看到我们的a标签的事件监听器确实监听名为click的事件

联想截图_20260211232624.png

当我们在控制台设置rootComponent.clickon为null的时候后再次点击a标签,这时页面就发生了跳转,但其实设置成其它非click的字符串也是可以的,null在这里是给了一个在某某个状态下动态参数的值为null的时候,什么也不监听/什么属性也不添加的选项,要不然给一个字符串,肯定会添加上别的事件监听/属性

如何监控浏览器crash?

📌 前言

页面崩溃是影响用户体验的最严重事故——页面完全失效,代码停止执行,即使等待也无法自行恢复,甚至常规的错误监控也会随之失效。 image.png

崩溃 vs 卡顿:卡顿是“暂时响应慢”,崩溃是“彻底无法用”。崩溃发生后,页面上报能力归零,必须依赖浏览器机制。


🎯 方案选型对比

方案 实现原理 ✅ 优势 ❌ 劣势 可靠性
本地状态存储 崩溃前标记状态,二次进入上报 实现简单 依赖用户二次进入,4数据滞后,wu bao ⭐⭐
Service Worker 心跳 SW 独立生命周期,跨页面检测 独立于页面,存活时间长 ;兼容性较reporting api高一点 误报率高(卡顿误判为崩溃) ;开发量大 ⭐⭐⭐
Reporting API 🏆 浏览器原生崩溃报告机制 页面崩溃后仍可上报,无需业务代码干预 兼容性要求高 ⭐⭐⭐⭐⭐

核心结论Reporting API 是当前唯一能在页面彻底崩溃后仍可靠上报的技术方案

reporting api 详解

1. 工作原理

浏览器通过 Reporting-Endpoints 响应头声明崩溃报告接收地址。当页面发生崩溃时,浏览器在后台独立线程完成上报,页面主线程的终止不影响报告发送。

ReportingObserverdeveloper.chrome.com/blog/report… 适合开发者在前端主动监听和处理报告 (只支持获取 deprecation || intervention2种报告)

Reporting-Endpoints 如果希望浏览器自动上报崩溃等异常,需要通过 Reporting-Endpoints 响应头声明上报端点。 这种方式下,前端业务代码无需改动,只需配置响应头即可。

2. Reporting-Endpoints 上报字段分析

字段 说明
age 报告的时间戳与当前时间之间的毫秒数。
body 实际报告数据,已序列化为 JSON 字符串。报告的 body 中包含的字段由报告的 type 决定。 ⚠️ 注意:不同类型的报告,其 body 结构不同。下面为 type = crash 时 body 包含的字段详解:
1.body.crash_report_api 可存放额外信息,读取自 window.crashReport 中的值。
2.body.reason 崩溃原因,取值包括: - oom:页面内存溢出 - unresponsive:页面长时间无响应,被浏览器强制终止
3.body.stack 崩溃时的 JavaScript 调用堆栈。body.reason 为 unresponsive且响应头需包含:document-policy = include-js-call-stacks-in-crash-reports(Chrome 137+ 开始支持) 该字段可从崩溃的文档中恢复调用堆栈。
3.body.is_top_level 崩溃页面是否属于顶级可遍历对象(top-level traversable)。
4.body.visibility_state 崩溃时页面的可见性状态,取值 visible 或 hidden
type 报告类型,例如 csp-violationcoep 等。 🎯 需要关注的是crash 类型。
url 崩溃发生的页面地址。
user_agent 生成报告时所使用的请求的 User-Agent 标头。

window.crashReport

window.crashReport的使用方法(可以携带一些额外的信息,便于后续分析),但需要注意的是,该api还在实验阶段,需要浏览器开启配置项后,才可以使用

github.com/WICG/crash-…

await window.crashReport.initialize('crash-report');   // 初始化
// 设置基础信息
window.crashReport.set('pageUrl', window.location.href) // set信息
window.crashReport.set('pageTitle', document.title)
//移除信息
window.crashReport.remove('pageTitle', document.title)

实现示例

在服务端配置Reporting-Endpoints头,default中是接受报告的服务器地址。由于window.crashReport还是实验api,那么如果一定需要传递额外信息,则可以给url上增加query来实现

app.use('/', express.static(path.join(__dirname, './public'), {
  setHeaders: (res) => {
    res.setHeader('Reporting-Endpoints', 'default="https://crash-report.free.beeceptor.com"');
  }
}));
结果展示

内存暴涨导致的crash

[  
    {  
        "age": 10,  
        "body": {  
            "crash_report_api": {  
                "cookieEnabled": "true",  
                "crashTime": "2025-12-30T09:05:53.703Z",  
                "crashType": "oom",  
                "language": "en",  
                "memory": "[object Object]",  
                "pageLoadTime": "2025-12-30T09:05:48.822Z",  
                "pageTitle": "页面崩溃模拟 Demo",  
                "pageUrl": "<https://localhost:5000/crash.html>",  
                "platform": "MacIntel",  
                "referrer": "direct",  
                "screenColorDepth": "24",  
                "screenHeight": "1080",  
                "screenWidth": "1920",  
                "sessionId": "1767085548824-v4mushd65ib",  
                "timestamp": "1767085548823",  
                "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",  
                "viewportHeight": "958",  
                "viewportWidth": "601"  
            },  
            "is_top_level": true,  
            "visibility_state": "visible"  
        },  
        "type": "crash",  
        "url": "https://localhost:5000/crash.html",  
        "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"  
    }  
]

报告接收服务搭建

  • 支持跨域
  • 支持解析application/reports+json类型
const express = require('express');
const app = express();
const bodyParser = require('body-parser');
app.use((req, res, next) => {
    // 设置 CORS 响应头
    res.header('Access-Control-Allow-Origin', '*'); // 生产环境建议指定具体域名
    res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.header('Access-Control-Max-Age', '86400'); // 预检请求缓存 24 小时

    // 处理预检请求(OPTIONS)
    if (req.method === 'OPTIONS') {
      return res.sendStatus(200);
    }
    
    next();
  });

  // // 中间件:解析 JSON 请求体
  app.use(bodyParser.json({type: 'application/json'}));
  app.use(bodyParser.json({type: 'application/reports+json'}));

参考资料

1.window.crashReport:github.com/WICG/crash-…

2.zhuanlan.zhihu.com/p/40273861 如何监控网页崩溃

3.developer.chrome.com/docs/capabi…

4.juejin.cn/post/743899…

Wiki 开发日记:学做 Markdown 大纲视图

大家好,我是AY。

笔者折腾了Wiki 的 Markdown 大纲视图,整个过程基本就是从“一头雾水”到“强行破局”,接下来记录下我充满艰辛的开发路程。

一、寻找 Markdown 的“真身”

刚开始做的时候,我一直在想 Markdown 大纲的核心本质到底是什么?

说白了就是提取、转化、渲染,这么三步走的一个数据流!

  1. 提取(Extraction) :从非结构化的 Markdown 或 Block 积木中识别标题。
  2. 转化(Transformation) :将积木转换为包含 idleveltext 的结构化 JSON 数组。
  3. 渲染(Rendering) :将数组映射为 UI 组件,通过 level 决定缩进。

但我首先得看懂现在的项目是怎么把 Markdown 长出来的——我看 Wiki 页面用了 Ant Design X 的 AI 组件,里面嵌套了 Markdown 解析器,我想从解析器入手看它是怎么渲染大纲的,结果发现我还没看明白,也许逻辑藏得太深...那么,我就简单粗暴地去找 F12 控制台,直接去 Source 里面溯源。

结果发现根源在 node_modules 一个灰色块里——这说明它是个三方组件库。

我顺着 DevTools 往上摸,盯着组件树一层层找,看 render 里的配置。

我心想,配置里一定有一个状态(State)储存着我们的 Wiki 文本内容,不然这个编辑区它到底是怎么长出来的?

二、深入 BlockNote:静态看不懂,就看动态

摸到最后发现,这玩意儿不是简单的纯文本渲染,而是用了 BlockNote —— 跟 Notion 类似,是块级编辑器。

我想搞清楚数据到底在哪,就去翻代码里的 page detail(从服务器数据库拿出来的整篇数据),然后去读 BlockNote 的 Editor API。

我发现我其实看不太明白,尤其是 editor.document 这个属性返回的数据结构,去看那个 document entity 的字段定义时,感觉自己有点迷失。

我看静态类型的定义搞不清楚,那我就要去学会看动态运行的数据。

我直接在配置里加了一段调试代码:

useEffect(() => {
  if (editor) {
    // 强制把 editor 挂在 window 对象上,管理员直接在控制台随时调遣
    (window as any).myEditor = editor;
    console.log("✅ 管理员已就位,请前往控制台输入 myEditor 检查");
  }
}, [editor]);

这一按回车,在控制台输入 myEditor.document,数组全展开了。我盯着看它的 typeprops 还有 level

发现 typeheading 的就是标题,而第三个非标题的 typeparagraph,也就是普通的文段,它确实没有 level 这个属性。

最关键的发现是 content:标题的文字内容不是一个简单的字符串,而是一个数组——所以我后面不能直接拿来用。

根据 BlockNote 官方文档定义,文档的核心是 Block 对象。我在控制台展开 myEditor.document 后,验证了其标准的 Block Schema

  • Block 类型:每个块都有一个 type 字段。大纲只关注 type: "heading"

  • Props 属性:标题的级别(H1/H2/H3)存储在块的 props 对象中,定义为 props: { level: number }

  • InlineContent 数组:这是最核心的发现。官方文档指出,BlockNote 的内容并非 string,而是 InlineContent[]

    定义: InlineContent 可以是 StyledText(带样式的文本)或 Link(链接)。这意味着提取标题时,必须遍历这个数组并拼接所有 text 节点。

三、架构方案:为什么必须实时?

关于这个大纲的数据源,我一开始没有弄清楚,以为要从后端获取。

但仔细一想逻辑不对:

大纲必须得是用户输入、内存状态更新,然后立刻触发 UI 渲染,大纲和编辑器的文字要同步变。

这种同步逻辑必须从当前编辑器的实时状态中提取,因为它是最快的,也是最真实的。如果非要走后端,那你在 Wiki 里打一个字,都要等数据传到服务器、存进数据库、再推回给大纲,中间那几百毫秒甚至几秒的延迟,会让大纲的操作感非常卡顿。

****即,大纲必须实现强实时性 (Real-time Synchronization) 。 若将逻辑放在后端,会产生明显的网络延迟 (Latency) 。因此,我选择监听编辑器的 Editor Snapshot(编辑器快照) 。这是最真实的内存数据源,能保证用户每输入一个字符,大纲都能毫秒级地响应。

四、逻辑拆解:监听、过滤、结构化

我想清楚了大纲实现的三个核心动作:

  • 监听:怎么感知用户敲了键盘?我去翻 BlockNote 里面有没有内容改变的 Hook(钩子)——那就是调用 editor.onChange 钩子。

  • 过滤:编辑器里可能有图片、段落,大纲却只要文字。对策是:我写了个 filter,专门根据 type === 'heading' 进行过滤,把正文部分全部扔掉。

  • 结构化

    • 处理层级:H1、H2、H3 怎么体现?我发现几级标题就藏在 props.level 里面,这就是我区分标题级别、显示缩进的依据。
    • 文字合并:因为 content 是数组,我得用 MDN 里的 Array.prototype.map 映射出文字,再用 join('') 把它们连成一串。
    • UI 渲染:缩进我打算直接在 div 里写个公式,把 level 数值乘上一定的像素(比如 12px 或 20px),CSS 的魔法计算就不打算写成函数了,直接内联。

五、性能优化:useMemo 的“洗豆子”理论

做的时候我发现一个很严重的问题:

如果不做优化,我每输入一个字符,控制台就显示整个页面被重新渲染一遍。

我又去补充学习了一下 memouseMemo。``

简单来说,memo 是跳过组件渲染,而 useMemo 是缓存计算结果。

  • useMemo (计算缓存) :大纲提取就像从万颗豆子里挑出黄豆。useMemo 确保只有在 editor.document 这个依赖项真正改变时,才重新执行复杂的过滤和拼接逻辑。
  • memo (组件记忆) :将大纲 UI 封装在 React.memo 中,防止父组件其他状态更新引发大纲的无效刷新。

我的“洗豆子”理论: 如果你有 1 万个 Block,那 filtermap 就像是从 1 万个豆子里挑出黄豆再洗干净。如果没有 useMemo,你每次打一个空格,React 就要重新挑一次、重新洗一次。但 useMemo 能让这一万次循环只在 editor.document 真正改变时才发生,减少了巨大的性能开销。

六、交互与布局的“临门一脚”

最后是大纲作为侧边栏的弹出与布局。

我参考了 项目已有的 Chat 页面的三栏布局,但它是完美的 Splitter 组件库。我们 Wiki 的边栏需要自己布局,我看了下父子组件都是 Flex。

我决定在父组件里加一个 useState 的 Hook 来控制大纲“这扇门”的开关,并把整个提取逻辑写进去。

可是,还有一个交互大坑:BlockNote 默认只会移光标,不会滚动页面。

文档长了,光标跑到底部去了,浏览器视口却不动。点击大纲跳转的逻辑我决定写在主页面(大管家)里,因为它同时拿着 Editor 实例和大纲组件。 我最后用了 DOM 修正。

为了防止滚动到顶时挡住标题,我还在内联 CSS 里加了高度限制(偏移量),并配合一个 setTimeout 定时器。

由于 Wiki 的 Header 是 Sticky (粘性布局) 的,直接跳转会导致标题被遮挡。

CSS 的 Scroll Margin 属性硬核修正:

[data-id] { scroll-margin-top: 80px; } /* 预留出 Header 的高度 */

虽然不知道是不是最好的方案,但它能规避原生滚动“弹一下”的抖动感,让视觉效果更丝滑。

七、学习防抖 (Debounce) 与 异步更新

原本我没有看懂配合的一个防抖,但确实稳了很多。统统通过这个小项目补上,坚强的菜鸟学习中!

防抖 (Debounce):高频状态更新的“节流阀”

在 BlockNote 这种高度响应式的编辑器中,用户的每一次击键都会触发 onChange 事件。如果文档包含数千个节点,不加干预的实时提取会导致 CPU 在用户输入时陷入持续的“遍历-计算-重绘”死循环。

防抖机制本质上是一个基于计时器的事件合并方案

它通过 useMemo 维持一个单例的计时器,当连续的 onChange 触发时,旧的计时器被不断销毁,只有当用户停止输入并超过 300ms 的阈值后,才会真正触发那一版“最真实”的大纲提取逻辑。这不仅大幅降低了计算开销,还规避了 UI 频繁闪烁的问题。

// 伪代码实现逻辑
const debouncedUpdate = useMemo(() => {
  return debounce(() => {
    // 只有在用户“停手”后的间隙,才去执行昂贵的过滤与拼接逻辑
    const snapshot = editor.document;
    const headings = filterHeadings(snapshot);
    setOutline(headings);
  }, 300);
}, [editor]);

异步调度 (Async Scheduling):跳转逻辑的“缓冲带”

在处理大纲跳转时,存在一个关键的交互冲突:编辑器的 setTextCursorPosition 会触发内部的焦点切换与视图定位,而我们自定义的 scrollIntoView 也在尝试改变滚动位置。如果这两者同步发生,浏览器会因为竞争控制权而产生一种“弹一下”的抖动感,甚至定位失败。

这个利用了任务队列(Task Queue) 的异步特性。

通过微任务或延迟调度,先让编辑器完成光标定位和状态更新,待 DOM 稳定后,再执行平滑滚动逻辑。这种“先定光标,后滚视图”的先后顺序,配合 CSS 的 scroll-margin-top 偏移量,完美消除了原生滚动冲突导致的视觉抖动。

// 伪代码实现逻辑
onItemClick = (id) => {
  // 1. 同步执行:先告诉编辑器光标在哪,它会处理自己的内部逻辑
  editor.focus();
  editor.setTextCursorPosition(id, 'start');

  // 2. 异步调度:将滚动任务推入下一轮任务循环,确保 DOM 已响应光标状态
  setTimeout(() => {
    const el = document.querySelector(`[data-id="${id}"]`);
    el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
  }, 0); 
};

啥意思呢?其实就是说:防抖负责“省 CPU”,异步调度负责“稳 UI”。

⭐小结

虽然只是这么一个小功能,但我在逐步尝试脱离AI喂代码,自己去思考代码这样写是否稳定,数据从哪里来,又应该到哪里去。

我不敢说这样一个小功能有多少难点,但我可以非常确定这样的学习方式对我有很大的成长:我学着去看一个完全陌生的BlockNote的API,也接触到当前AI开发场景下的组件库antd X;对于一个好的前端开发者来说,AI不是来杀死前端的,而是在提供更加新的开发场景。(不过当然,过去的我太依赖ai开发,没有自己的反思,绝对是无法在ai强大的编程能力下存活的,哈哈哈哈...)

前端开发所需要的技术素养实在是太多,单独拎出来一个浏览器视口为什么不随光标移动,也就可以深入去学浏览器的默认行为,底层原理;不单单是demo能用,还要模拟如果上线真实投入使用用户会如何操作?高频更新的顾虑,考虑怎么防抖,等等。保持我现有的耐心和热情,一次又一次的bug和debug都是学习机会,即使我只是做了一个这么小的功能。

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

参考文献:

介绍 - Ant Design X

总览 - Ant Design X

Using Pro - Marked Documentation

BlockNote - Manipulating Blocks

BlockNote - Introduction

BlockNote - Events

BlockNote - Real-time Collaboration

Array.prototype.map() - JavaScript | MDN

Array.prototype.join() - JavaScript | MDN

将 Props 传递给组件 – React 中文文档

BlockNote - Overview

BlockNote - Cursor & Selections

useMemo – React 中文文档

memo – React 中文文档

Element:scrollIntoView() 方法 - Web API | MDN

《React Props 实战避坑:新手必看的组件通信指南》

React Props 从入门到实战:吃透组件通信的核心逻辑

作为React组件化开发的核心基石,Props(Properties)是实现组件通信、复用和灵活组合的关键。很多React新手在入门时,常常混淆Props与State的用法,也不清楚如何规范传递和校验Props。今天就结合实战代码,从基础认知到高级用法,手把手带你吃透React Props,帮你快速上手组件化开发的核心逻辑。

本文所有示例均基于真实开发场景编写,配套完整可运行代码,新手可直接复制实践,老手可快速回顾核心知识点,查漏补缺~

一、先理清核心:Props 到底是什么?

在React的组件化思想中,我们可以把组件想象成“乐高积木”——页面就是由一个个独立的“积木”拼接而成,而Props就是拼接这些积木时的“连接参数”,负责实现组件之间的数据传递。

结合我们最基础的认知,先明确两个核心概念的区别,避免混淆:

  • State:组件自身拥有的数据,可修改、可维护,是组件的“自有属性”,仅作用于当前组件内部。
  • Props:父组件传递给子组件的数据,是组件的“外部输入”,只读不可改(子组件无法修改父组件传递的Props),是父子组件通信的唯一基础方式。

简单来说:State管“自己”,Props管“传递”。组件是开发任务的最小单元,通过组件嵌套形成父子关系(比如App.jsx是“老板”,Greeting.jsx是“员工”),而Props就是“老板”给“员工”下达的“工作指令”,实现组件间的协作与数据流转。

二、Props 基础用法:从简单传递到解构赋值

Props的使用门槛极低,核心流程只有三步:父组件传递 → 子组件接收 → 子组件使用。我们结合实战代码,一步步拆解(以下代码可直接复制到项目中运行)。

2.1 基础示例:简单Props传递

首先定义子组件Greeting,接收父组件传递的name、message、showIcon三个Props,用于渲染不同的问候内容:

// src/components/Greeting.jsx
import PropTypes from 'prop-types'; // 后续用于Props校验,先导入

// 子组件接收Props(props是一个对象,包含所有父组件传递的数据)
function Greeting(props) {
  // 直接通过 props.属性名 访问传递的值
  return (
    
      {/* 根据showIcon判断是否显示图标(布尔值Props) */}
      {props.showIcon && 👋}
      Hello, {props.name}!{props.message}
  );
}

export default Greeting;

然后在父组件App中,使用Greeting组件,并传递对应的Props:

// src/App.jsx
import Greeting from './components/Greeting';

function App() {
  return (
    
      {/* 父组件传递Props:name、message是字符串,showIcon是布尔值 */}
<Greeting name="娇娇" message="欢迎加入阿里" showIcon />
      {/* 传递部分Props(未传递的后续通过默认值补充) */}<Greeting name="磊" />
    
  );
}

export default App;

这里有两个关键细节,新手必看:

  1. 布尔值Props(如showIcon):仅写属性名,等价于showIcon={true},常用于控制UI元素的显示/隐藏;
  2. Props可传递任意JS类型:字符串、数字、布尔值、对象、数组甚至函数,后续会讲解高级用法。

2.2 优化写法:解构赋值(推荐)

如果Props较多,每次都写props.属性名会很繁琐。我们可以通过ES6解构赋值,直接提取Props中的属性,代码更简洁、可读性更高:

// src/components/Greeting.jsx(优化后)
import PropTypes from 'prop-types';

function Greeting(props) {
  // 解构赋值,提取需要的Props属性
  const { name, message, showIcon } = props;
  return (
    
      {showIcon && 👋}
      Hello, {name}!{message}
  );
}

export default Greeting;

更进一步,我们可以直接在函数参数中解构Props,简化代码:

// 更简洁的写法
function Greeting({ name, message, showIcon }) {
  return (
    
      {showIcon && 👋}
      Hello, {name}!{message}
  );
}

三、Props 进阶:默认值与类型校验(提升代码健壮性)

在实际开发中,我们无法保证父组件一定会传递所有需要的Props,也无法避免传递错误类型的数据(比如本该传递字符串name,却传递了数字)。这时候,Props默认值和类型校验就显得尤为重要,能极大提升代码的健壮性和可维护性。

3.1 Props 默认值(defaultProps)

当父组件未传递某个Props时,我们可以为其设置默认值,避免页面出现undefined或异常显示。有两种常用写法,推荐第二种:

// 写法1:使用defaultProps(传统写法,兼容所有版本)
Greeting.defaultProps = {
  message: 'Welcome to ByteDance!', // 未传递message时,使用该默认值
  showIcon: false // 未传递showIcon时,默认不显示图标
};

// 写法2:解构赋值时直接设置默认值(更现代、更简洁)
function Greeting({ 
  name, 
  message = 'Welcome to ByteDance!', 
  showIcon = false 
}) {
  return (
    
      {showIcon && 👋}
      Hello, {name}!{message}
  );
}

注意:默认值仅在Props缺失或值为undefined时生效,如果父组件显式传递了null,默认值不会触发。

3.2 Props 类型校验(prop-types)

类型校验用于约束父组件传递的Props类型,比如规定name必须是字符串、且为必填项。如果传递的类型错误或缺失必填项,控制台会出现清晰的警告,方便我们快速排查问题。

使用步骤很简单,分两步:

  1. 安装prop-types包(注意:包名是prop-types,不是pro-types,很多新手会拼错导致报错);
  2. 为组件定义校验规则。
// 安装prop-types(npm或pnpm均可)
npm install prop-types
# 或
pnpm add prop-types

定义校验规则,完善Greeting组件:

// src/components/Greeting.jsx(完整带校验版本)
import PropTypes from 'prop-types';

function Greeting({ name, message = 'Welcome to ByteDance!', showIcon = false }) {
  return (
    
      {showIcon && 👋}
     Hello, {name}!{message}
  );
}

// 定义Props类型校验规则
Greeting.propTypes = {
  name: PropTypes.string.isRequired, // name是字符串,且为必填项
  message: PropTypes.string, // message是字符串(可选)
  showIcon: PropTypes.bool // showIcon是布尔值(可选)
};

export default Greeting;

常见的校验类型的:

  • PropTypes.string:字符串
  • PropTypes.number:数字
  • PropTypes.bool:布尔值
  • PropTypes.func:函数(用于传递回调)
  • PropTypes.node:可以渲染的节点(如JSX、字符串)
  • .isRequired:标记该Props为必填项

提示:prop-types仅在开发环境生效,生产环境会自动移除,不会影响项目性能,是React开发的最佳实践之一。

四、Props 高级用法:传递组件与children插槽

Props的强大之处在于,它可以传递任意JavaScript值——除了基础类型,还能传递函数、对象,甚至是整个React组件。这也是实现组件高度复用和定制化的核心技巧,我们结合Modal和Card组件实战讲解。

4.1 传递组件作为Props(实现组件定制化)

我们经常会遇到“弹窗组件”这样的场景:弹窗的头部、底部可能需要根据不同场景显示不同内容(比如有的弹窗显示“确认”按钮,有的显示“关闭”按钮)。这时候,我们可以将头部和底部组件作为Props传递给Modal组件,实现高度定制化。

// src/components/Modal.jsx(弹窗组件)
const Modal = (props) => {
  // 接收父组件传递的HeaderComponent、FooterComponent和children
  const { HeaderComponent, FooterComponent, children } = props;
  
  // CSS in JS 写法,简化样式配置
  const styles = {
    overlay: {
      backgroundColor: 'rgba(0,0,0,0.5)',
      position: 'fixed',
      top: 0,
      left: 0,
      right: 0,
      bottom: 0,
      display: 'flex',
      justifyContent: 'center',
      alignItems: 'center'
    },
    modal: {
      backgroundColor: 'white',
      padding: '1rem',
      borderRadius: '8px',
      width: '400px'
    },
    content: {
      margin: '1rem 0'
    }
  };
  
  return (<div style={<div style={}>
        {/* 渲染父组件传递的头部组件 */}
        <HeaderComponent />
        {/* 渲染弹窗主体内容(children插槽) */}
        <div style={{children}
        {/* 渲染父组件传递的底部组件 */}
        <FooterComponent />
      
  );
};

export default Modal;

然后在父组件App中,定义头部和底部组件,传递给Modal:

// src/App.jsx(使用Modal组件)
import Modal from './components/Modal';

// 定义弹窗头部组件
const MyHeader = () => {
  return <h2 style={ 0, color: 'blue' }}>自定义标题;
};

// 定义弹窗底部组件
const MyFooter = () => {
  return (
    <div style={<button 
        onClick={ alert('关闭')}
        style={{ padding: '0.5rem 1rem' }}
      >关闭
  );
};

function App() {
  return (
    
      {/* 传递组件作为Props,实现弹窗定制化 */}
      <Modal HeaderComponent={MyHeader} FooterComponent={MyFooter}>
        {/* 弹窗主体内容,会被Modal组件的children接收(插槽用法) */}
        这是一个弹窗你可以在这里显示任何JSX。</Modal>
    
  );
}

export default App;

这种写法的核心价值:Modal组件只负责“弹窗的容器和样式”,具体的头部、底部和主体内容,完全由父组件通过Props控制,实现了组件的复用和定制化分离,符合React“组合优于继承”的开发理念。

4.2 children 特殊Props(组件插槽)

children是React内置的一个特殊Props,无需父组件显式传递,它会自动接收“子组件标签之间的所有内容”,相当于一个“默认插槽”,常用于实现组件的容器化包裹。

我们以Card组件为例,讲解children的用法(结合CSS样式,实现卡片组件复用):

// src/components/Card.jsx
import './Card.css'; // 导入卡片样式

// 接收children和自定义className(用于扩展样式)
const Card = ({ children, className = '' }) => {
  // 合并基础样式和自定义样式(模板字符串用法)
  return <div className={{children};
};

export default Card;

编写Card组件的CSS样式(src/components/Card.css):

.card {
  background-color: #ffffff; /* 白色背景 */
  border-radius: 12px; /* 圆角 */
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); /* 轻微阴影 */
  padding: 20px; /* 内边距 */
  margin: 16px auto; /* 居中并留出上下间距 */
  max-width: 400px; /* 设置最大宽度 */
  transition: all 0.3s ease; /* 动画过渡 */
  overflow: hidden; /* 防止内容溢出圆角 */
}

.card:hover {
  transform: translateY(-4px); /* 悬停上浮效果 */
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); /* 更强阴影 */
}

.card h2 {
  margin-top: 0;
  font-size: 1.5rem;
  color: #333;
}

.card p {
  color: #666;
  font-size: 1rem;
}

.card button {
  margin-top: 12px;
  padding: 8px 16px;
  font-size: 0.9rem;
  border: none;
  border-radius: 6px;
  background-color: #0070f3;
  color: white;
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.card button:hover {
  background-color: #005bb5;
}

在父组件中使用Card组件,通过children传递卡片内容:

// src/App.jsx(使用Card组件)
import Card from './components/Card';

function App() {
  return (
      {/* 卡片1:用户信息卡片 */}
      <Card className="user-card">
        张三高级前端工程师</Card>
      
      {/* 卡片2:商品信息卡片(复用Card组件,仅修改children内容) */}
      <Card className="goods-card">
        React实战教程从零入门React组件化开发</Card>
    
  );
}

可以看到,通过childrenProps,我们实现了Card组件的高度复用——同一个Card组件,只需传递不同的children内容,就能渲染出不同的卡片样式,极大减少了代码冗余。

五、Props 核心注意事项(避坑指南)

结合前面的实战代码,总结几个新手常踩的坑,帮你少走弯路:

  1. Props 只读不可改:子组件绝对不能修改父组件传递的Props,否则会触发React警告。如果需要修改数据,应在父组件中修改State,再通过Props重新传递(单向数据流原则)。
  2. 包名拼写错误:安装prop-types时,不要写成pro-types(少写一个p),否则会报ENOVERSIONS错误(无可用版本)。
  3. 必填项一定要加isRequired:对于必须传递的Props(如Greeting组件的name),一定要加上.isRequired,避免父组件遗漏传递导致页面异常。
  4. 组件目录规范:所有可复用组件建议放在src/components目录下,页面级组件放在src/pages目录下,便于项目维护和协作(组件化开发的最佳实践)。
  5. children的使用场景:当组件需要作为“容器”包裹其他内容时,优先使用childrenProps,避免冗余的Props传递。

六、总结

Props是React组件通信的核心,也是组件化开发的基础。它的用法看似简单,但吃透后能极大提升你的组件设计能力——从基础的Props传递、解构赋值,到默认值、类型校验,再到传递组件、children插槽,每一步都是实战中不可或缺的技巧。

本文结合完整的实战代码,覆盖了Props从入门到进阶的所有核心知识点,新手可以跟着代码一步步实践,熟悉Props的使用流程;老手可以回顾核心细节,规范自己的代码写法。

组件化开发的本质就是“拆分、复用、协作”,而Props就是连接这些环节的“桥梁”。掌握了Props,你就已经迈出了React组件化开发的关键一步,后续结合State、生命周期、 Hooks等知识点,就能轻松应对复杂的React项目开发啦

❌