阅读视图

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

深入理解浏览器渲染流程

深入理解浏览器渲染流程

0. 事件循环复习

我们之前总结过:事件循环是主线程的工作方式,每执行完一个宏任务,就清空所有微任务,然后可能渲染页面,再取下一个宏任务。

重点来了:渲染到底是怎么发生的? 这就是本篇文章要讲的内容。


1. 为什么需要了解渲染流程?

你每天都在写 HTML、CSS、JS,但浏览器到底是怎么把它们变成屏幕上像素的?

搞懂渲染流程,你就能明白:

  • 为什么改 left/top 会卡,改 transform 却很丝滑
  • 为什么有些 CSS 属性改了开销大,有些开销小
  • 面试官问“重排重绘”时该怎么答

这是前端性能优化的基础,也是面试必考题。


2. 渲染流程五步走

浏览器拿到 HTML 和 CSS 后,会按顺序做这 5 件事:

步骤 名称 做了什么
1 构建 DOM 树 把 HTML 标签转成树形结构
2 构建 CSSOM 树 把 CSS 规则转成树形结构
3 构建渲染树 合并 DOM 和 CSSOM,过滤掉不可见元素
4 布局(Layout) 计算每个元素的位置和大小
5 绘制(Paint) 把像素画到屏幕上

第 4 步也叫 重排(Reflow),第 5 步也叫 重绘(Repaint)

如下图:

2.1 构建 DOM 树

浏览器从上到下解析 HTML,把标签转成树形结构的 DOM 对象。
例如:

<html>
  <body>
    <div>hello</div>
  </body>
</html>

会变成类似这样的结构(伪代码):

document
  └ html
      └ body
          └ div → text "hello"

注意<script> 标签会阻塞解析,因为 JS 可能修改 DOM。可以加 deferasync 避免阻塞。

2.2 构建 CSSOM 树

浏览器解析 CSS 文件或 <style> 标签内的样式,构建成 CSSOM 树(CSS 对象模型)。
CSSOM 记录了选择器与样式规则的对应关系,以及继承关系(比如 bodyfont-size 会传给子元素)。

CSS 不会阻塞 DOM 树的构建,但会阻塞渲染(因为需要完整的样式才能绘制)。

2.3 构建渲染树(重点)

渲染树 = DOM 树 + CSSOM 树,但会过滤掉不需要显示的东西。

具体操作:

  1. 只保留能看见的元素
    • display: none 的元素不进入渲染树(连占位都没有)
    • <head> 标签里的元素不进入渲染树
    • visibility: hidden 的元素进入渲染树(它占位置,只是看不见)
    • opacity: 0 的元素也会进入渲染树(透明也是可见的一种)
  2. 给每个节点附上计算好的样式
    从 CSSOM 里找到匹配的规则,经过层叠、继承、优先级计算,得到每个节点的最终样式。

示例:

<div style="display: none;">看不见我</div>
<div>看得见我</div>

渲染树里只有第二个 div,第一个直接被丢掉了。

为什么需要渲染树?
因为 DOM 树里有很多不参与页面绘制的节点(headscriptdisplay: none 的元素),直接拿着 DOM 树去布局会浪费性能。渲染树就是“最终要画到屏幕上的东西”的清单。

2.4 布局(Layout / 重排)

遍历渲染树,计算每个元素在屏幕上的精确位置和尺寸(宽、高、x、y)。
比如一个 div 宽度是父容器的 50%,就要算出实际像素值。

触发布局的情况

  • 首次渲染
  • 窗口 resize
  • 修改元素的几何属性**(宽/高/边距/位置)**
  • 添加/删除 DOM
  • 读取某些属性(offsetHeightgetComputedStyle 等)

布局是开销最大的步骤。

2.5 绘制(Paint / 重绘)

把每个元素画成像素:背景、边框、文字、阴影、图片等。
浏览器会把页面分成多个图层,分别绘制,最后合成。

触发绘制的情况

  • 改变背景色、文字颜色、边框颜色等(不影响位置)

3. 重排 vs 重绘(核心重点)

这两个概念必须分清。

对比项 重排(Reflow) 重绘(Repaint)
什么时候发生 改宽高、边距、位置、增删 DOM、改字体等 改颜色、背景、阴影、可见性等
开销 很大(重新计算位置) 中等(只重新涂色)
会触发另一个吗 会,重排一定导致重绘 不会,重绘不一定导致重排
优化建议 尽量避免,或用 transform 替代 可接受,但不要频繁

3.1 代码示例

//  坏:触发重排
box.style.width = '200px'
box.style.height = '200px'
box.style.margin = '10px'

//  好:合并修改,只触发一次重排
box.style.cssText = 'width:200px; height:200px; margin:10px;'

//  更好:用 transform 做动画,完全不触发重排/重绘
box.style.transform = 'translateX(100px)'

4. 哪些操作会触发重排?

  • width / height / margin / padding / border
  • font-size(文字大小影响盒子大小)
  • display(比如 noneblock
  • 添加或删除 DOM 元素
  • 改变窗口大小
  • 读取某些属性:offsetHeightoffsetTopscrollTopgetComputedStyle 等(浏览器被迫立即重排)

最后一条只是读一下,浏览器也得乖乖重排才能给你准确值。所以不要在循环里读这些属性。


5. 如何减少重排?

优化手段 说明
合并样式修改 cssText 或切换 class,不要一条一条改
让元素脱离文档流 position: absolutefixed,它的重排不影响别人
批量插入 DOM documentFragment 先组装好,再一次性插入
动画用 transform transform 走合成线程,不触发重排/重绘
避免读触发布局的属性 不要频繁读 offsetHeight 等,如果必须读,先读好存起来

5.1 批量插入 DOM 示例

//  坏:每次插入都触发重排
for (let i = 0; i < 100; i++) {
  document.body.appendChild(div)
}

//  好:用 fragment 一次性插入
const fragment = document.createDocumentFragment()
for (let i = 0; i < 100; i++) {
  fragment.appendChild(div)
}
document.body.appendChild(fragment)  // 只触发一次重排

6. transform 为什么快?

transform 不走布局和绘制,它直接进入合成阶段,由 GPU 处理。

简单理解:

  • left/top:改位置 → 触发重排 → 重绘 → 合成(主线程干,慢)
  • transform:跳过前两步 → 直接合成(合成线程干,快)

所以做动画时,能用 transform 就别用 left/top

/*  慢 */
.box {
  transition: left 0.3s;
  left: 0;
}
.box.active {
  left: 100px;
}

/*  快 */
.box {
  transition: transform 0.3s;
  transform: translateX(0);
}
.box.active {
  transform: translateX(100px);
}

7. 常见面试题

7.1 重排和重绘的区别?哪个更耗性能?

重排是重新计算位置和大小,开销大;重绘是重新涂色,开销中等。重排一定触发重绘,反之不一定。

7.2 哪些属性会触发重排?

widthheightmarginpaddingborderfont-sizedisplayposition 等。还有添加/删除 DOM、改窗口大小。

7.3 如何避免重排?

  • 合并样式修改
  • 使用 transform 做动画
  • 批量操作 DOM
  • 让元素脱离文档流

7.4 transformleft/top 有什么区别?

left/top 触发布局(重排),慢;transform 只触发合成,由 GPU 处理,快。

7.5 为什么有时候读 offsetHeight 会让页面变慢?

因为浏览器需要立即计算最新的布局才能返回准确值,这会强制重排。如果在循环里读,会反复触发重排,性能极差。


8. 总结一句话

浏览器渲染分五步:DOM 树 → CSSOM 树 → 渲染树 → 布局(重排)→ 绘制(重绘)。
重排慢,重绘快,动画用 transform最流畅。
优化核心:减少重排,合并操作,能用合成就合成。

OpenLayers 地图绘制与交互实战:从零构建一个完整的绘制系统

前言

在 WebGIS 开发中,地图绘制功能是一个常见且重要的需求。本文将基于 OpenLayers 框架,手把手教你构建一个完整的地图绘制系统,包含点、线、面、圆的绘制,要素编辑、选择和删除等功能。通过本文,你将深入理解 OpenLayers 的交互机制和图层管理。

最终效果预览

我们将实现一个具有以下功能的地图应用:

  • 🎯 支持绘制点、线、面、圆
  • ✏️ 支持编辑已绘制的图形
  • 🖱️ 支持点击选择要素
  • 🗑️ 支持删除选中要素
  • 🧲 支持顶点吸附功能
  • 📍 支持 GeoJSON 数据展示

image.png

项目结构

ol-app/
├── main.js              # 入口文件,初始化地图
├── src/
│   ├── drawLayer.js     # 绘制图层核心类
│   └── draw.js          # GeoJSON 图层初始化
├── geojsonObject.js     # GeoJSON 数据
└── style.css            # 样式文件

一、核心类设计:DrawLayer

首先,我们创建一个 DrawLayer 类来封装所有的绘制和交互逻辑。这种封装方式让代码更加模块化,便于维护和复用。

1.1 类结构初始化

import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import { Draw, Modify, Snap, Select } from 'ol/interaction';
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style';
import { click } from 'ol/events/condition';

export class DrawLayer {
  constructor(map) {
    this.map = map;
    
    // 创建矢量数据源
    this.source = new VectorSource();
    
    // 创建矢量图层
    this.layer = new VectorLayer({
      source: this.source,
      style: this.getDefaultStyle()
    });
    
    // 添加到地图
    this.map.addLayer(this.layer);

    // 交互对象
    this.drawInteraction = null;
    this.modifyInteraction = null;
    this.snapInteraction = null;
    this.selectInteraction = null;
    this.selectedFeature = null;
  }
}

关键点解析:

  • VectorSource:存储所有绘制的要素数据
  • VectorLayer:负责将要素渲染到地图上
  • 各种 Interaction:OpenLayers 的交互对象,分别处理绘制、编辑、吸附、选择等功能

1.2 样式定义

/**
 * 获取默认样式
 */
getDefaultStyle() {
  return new Style({
    fill: new Fill({
      color: 'rgba(255, 255, 255, 0.2)'
    }),
    stroke: new Stroke({
      color: '#ffcc33',
      width: 2
    }),
    image: new CircleStyle({
      radius: 7,
      fill: new Fill({
        color: '#ffcc33'
      })
    })
  });
}

/**
 * 获取选中样式
 */
getSelectStyle() {
  return new Style({
    stroke: new Stroke({ color: 'red', width: 3 }),
    fill: new Fill({ color: 'rgba(255, 0, 0, 0.2)' }),
    image: new CircleStyle({
      radius: 7,
      fill: new Fill({ color: 'red' })
    })
  });
}

二、绘制功能实现

2.1 开始绘制

/**
 * 开始绘制
 * @param {string} type - 绘制类型: 'Point', 'LineString', 'Polygon', 'Circle'
 * @param {Function} callback - 绘制完成回调
 */
startDraw(type, callback) {
  // 清除之前的绘制交互
  this.stopDraw();
  // 禁用选择模式,避免冲突
  this.disableSelect();

  this.drawInteraction = new Draw({
    source: this.source,
    type: type
  });

  this.drawInteraction.on('drawend', (event) => {
    const feature = event.feature;
    if (callback) {
      callback(feature);
    }
  });

  this.map.addInteraction(this.drawInteraction);

  // 添加吸附功能
  this.snapInteraction = new Snap({
    source: this.source
  });
  this.map.addInteraction(this.snapInteraction);
}

技术要点:

  • Draw 交互会自动将绘制的要素添加到指定的 source 中
  • Snap 交互让新绘制的点可以吸附到已有要素的顶点上,提高精度
  • 绘制前需要停止其他交互,避免冲突

2.2 停止绘制

/**
 * 停止绘制
 */
stopDraw() {
  if (this.drawInteraction) {
    this.map.removeInteraction(this.drawInteraction);
    this.drawInteraction = null;
  }
  if (this.snapInteraction) {
    this.map.removeInteraction(this.snapInteraction);
    this.snapInteraction = null;
  }
}

三、编辑功能实现

3.1 启用编辑

/**
 * 启用编辑模式
 */
enableModify() {
  this.disableModify();
  this.disableSelect();

  this.modifyInteraction = new Modify({
    source: this.source
  });
  this.map.addInteraction(this.modifyInteraction);
}

/**
 * 禁用编辑模式
 */
disableModify() {
  if (this.modifyInteraction) {
    this.map.removeInteraction(this.modifyInteraction);
    this.modifyInteraction = null;
  }
}

Modify 交互允许用户拖拽要素的顶点来编辑图形形状。

四、选择功能实现

4.1 启用选择模式

/**
 * 启用选择模式
 */
enableSelect() {
  this.disableSelect();
  this.stopDraw();
  this.disableModify();

  this.selectInteraction = new Select({
    layers: [this.layer],
    style: this.getSelectStyle(),
    multi: true,
    toggleCondition: click
  });

  // 监听选择事件
  this.selectInteraction.on('select', (e) => {
    const selected = e.selected;
    if (selected.length > 0) {
      this.selectedFeature = selected[0];
      console.log('选中要素:', this.selectedFeature);
    } else {
      this.selectedFeature = null;
    }
  });

  this.map.addInteraction(this.selectInteraction);
}

重点解析:

  • layers: [this.layer]:指定可选中的图层,使用像素检测实现精确选择
  • multi: true:允许多选
  • toggleCondition: click:点击切换选中状态,无需按住 Shift 键

4.2 禁用选择

/**
 * 禁用选择模式
 */
disableSelect() {
  if (this.selectInteraction) {
    this.map.removeInteraction(this.selectInteraction);
    this.selectInteraction = null;
    this.selectedFeature = null;
  }
}

五、要素管理

/**
 * 移除选中的要素
 */
removeSelectedFeature() {
  if (this.selectedFeature) {
    this.source.removeFeature(this.selectedFeature);
    this.selectedFeature = null;
    return true;
  }
  return false;
}

/**
 * 清除所有绘制
 */
clear() {
  this.source.clear();
  this.selectedFeature = null;
}

/**
 * 获取所有绘制的要素
 */
getFeatures() {
  return this.source.getFeatures();
}

/**
 * 移除指定要素
 */
removeFeature(feature) {
  this.source.removeFeature(feature);
  if (this.selectedFeature === feature) {
    this.selectedFeature = null;
  }
}

六、工具栏创建

export function createDrawToolbar(container, drawLayer) {
  const toolbar = document.createElement('div');
  toolbar.className = 'draw-toolbar';
  toolbar.innerHTML = `
    <button data-type="Point" class="draw-point">点</button>
    <button data-type="LineString" class="draw-line">线</button>
    <button data-type="Polygon" class="draw-polygon">面</button>
    <button data-type="Circle" class="draw-circle">圆</button>
    <button id="modify-btn" class="draw-modify">编辑</button>
    <button id="clear-btn" class="draw-clear">清除</button>
    <button id="remove-btn" class="draw-remove">移除</button>
    <button id="select-btn" class="draw-select">选择</button>
  `;

  let isModifying = false;
  let isSelecting = false;

  toolbar.addEventListener('click', (e) => {
    // 绘制按钮
    if (e.target.classList.contains("draw-point")) {
      drawLayer.startDraw("Point");
    }
    if (e.target.classList.contains("draw-line")) {
      drawLayer.startDraw("LineString");
    }
    if (e.target.classList.contains("draw-polygon")) {
      drawLayer.startDraw("Polygon");
    }
    if (e.target.classList.contains("draw-circle")) {
      drawLayer.startDraw("Circle");
    }
    
    // 编辑按钮
    if (e.target.classList.contains("draw-modify")) {
      isModifying = !isModifying;
      if (isModifying) {
        drawLayer.enableModify();
        e.target.textContent = "完成";
      } else {
        drawLayer.disableModify();
        e.target.textContent = "编辑";
      }
    }
    
    // 清除按钮
    if (e.target.classList.contains("draw-clear")) {
      drawLayer.clear();
    }
    
    // 移除按钮
    if (e.target.classList.contains("draw-remove")) {
      if (drawLayer.removeSelectedFeature()) {
        console.log("移除成功");
      } else {
        console.log("没有选中的要素");
      }
    }
    
    // 选择按钮
    if (e.target.classList.contains("draw-select")) {
      isSelecting = !isSelecting;
      if (isSelecting) {
        drawLayer.enableSelect();
        e.target.textContent = "退出选择";
      } else {
        drawLayer.disableSelect();
        e.target.textContent = "选择";
      }
    }
  });

  container.appendChild(toolbar);
  return toolbar;
}

七、主入口文件

import './style.css';
import {Map, View} from 'ol';
import TileLayer from 'ol/layer/Tile';
import XYZ from 'ol/source/XYZ';
import Overlay from 'ol/Overlay';
import {fromLonLat} from 'ol/proj';

import {geojsonObject1} from '/geojsonObject.js';
import { initDrawLayer } from './src/draw.js';
import { DrawLayer, createDrawToolbar } from './src/drawLayer.js';

// 1. 创建基础绘制图层(显示 GeoJSON 数据)
const vectorLayer = initDrawLayer(geojsonObject1, {
  point: {
    radius: 8,
    fillColor: 'red',
    strokeColor: 'white',
    strokeWidth: 2
  },
  line: {
    color: 'blue',
    width: 4,
    lineDash: [10, 10]
  },
  polygon: {
    fillColor: 'rgba(0, 255, 0, 0.3)',
    strokeColor: 'green',
    strokeWidth: 2
  }
});

// 2. 创建地图
const map = new Map({
  target: 'map',
  layers: [
    new TileLayer({
      source: new XYZ({
        url: 'https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}'
      })
    }),
    vectorLayer
  ],
  controls: [],
  view: new View({
    center: fromLonLat([116.4074, 39.9042]),
    zoom: 15
  })
});

// 3. 创建用户绘制图层
const drawLayer = new DrawLayer(map);

// 4. 创建绘制工具栏
const toolbarContainer = document.createElement('div');
toolbarContainer.id = 'draw-toolbar-container';
document.body.appendChild(toolbarContainer);
createDrawToolbar(toolbarContainer, drawLayer);

// 5. 创建 popup 容器
const popupContainer = document.createElement('div');
popupContainer.className = 'popup';
const popupOverlay = new Overlay({
  element: popupContainer,
  positioning: 'bottom-center',
  offset: [0, -15]
});
map.addOverlay(popupOverlay);

// 6. 点击事件 - 显示要素信息
map.on('click', (evt) => {
  const feature = map.forEachFeatureAtPixel(evt.pixel, (feat) => feat);
  if (feature) {
    const props = feature.getProperties();
    popupContainer.innerHTML = `<b>${props.name}</b><br>类型: ${props.type}`;
    popupOverlay.setPosition(evt.coordinate);
  } else {
    popupOverlay.setPosition(undefined);
  }
});

八、关键技术点总结

8.1 Source vs Layer

特性 Source Layer
作用 数据存储 可视化渲染
关系 被 Layer 引用 引用 Source
类比 数据库 表格组件

8.2 交互优先级

OpenLayers 的交互是按照添加顺序执行的,后添加的优先级更高。因此需要合理管理交互的启用和禁用:

// 启用新交互前,先禁用冲突的交互
startDraw() {
  this.stopDraw();      // 停止之前的绘制
  this.disableSelect(); // 禁用选择,避免冲突
  // ... 创建新交互
}

8.3 多选实现

多选的关键在于 toggleCondition 配置:

const select = new Select({
  multi: true,
  toggleCondition: click  // 点击切换选中状态
});

如果不设置 toggleCondition,默认需要按住 Shift 键才能多选。

九、扩展思路

  1. 导出 GeoJSON:使用 GeoJSON format 将绘制的要素导出
  2. 撤销重做:维护操作历史栈,实现撤销重做功能
  3. 样式编辑器:提供 UI 让用户自定义绘制样式
  4. 测量工具:计算绘制图形的面积和长度

十、完整代码

本文的完整代码已开源,你可以在 GitHub 上找到: github.com/yourname/ol…

结语

通过本文,我们实现了一个功能完整的 OpenLayers 绘制系统。核心思想是将功能封装成独立的类,通过交互对象管理用户操作,使用 Source-Layer 模式管理数据。希望本文对你有所帮助,如果有任何问题,欢迎在评论区讨论!


参考链接:

前端表单构建神器 - formkit初体验

传统表单开发 vs 低代码方案

传统的表单开发,无论是基于dom还是数据驱动的,都离不开手写html模板。尤其对于复杂的表单:关联字段联动、校验、表单字段的排版等等都有相当大的工作量。为此近些年涌现出不少的低代码方案,旨在通过页面拖拽配置的形式来高效的维护表单功能,来代替繁重的代码开发维护。基于JSON Schema的表单构建方案就是在这个背景下诞生,而具有代表性的就是本文要介绍的FormKit

FormKit项目初始化

准备源码路径:D:\2026学习\study\code\formkit示例\001_formkit项目初始化\parent
右键parent,用idea打开

20260410112337.png

20260410112551.png

自动完成依赖安装更新

20260410113807.png

看下默认安装的依赖:

"dependencies": {  
  "@formkit/core": "^2.0.0",  
  "@formkit/icons": "^2.0.0",  
  "@formkit/themes": "^2.0.0",  
  "@formkit/vue": "^2.0.0",  
  "@tailwindcss/vite": "^4.2.2",  
  "tailwindcss": "^4.2.2",  
  "vue": "^3.5.32"  
}

formkit不光是UI框架,更是开箱即用的json schema渲染表单的解决方案。对于UI,formkit直接用Tailwind来构建和维护组件样式。

修复类型引入问题

20260410114017.png

创建pnpm启动项

20260410114231.png

运行dev,访问:http://localhost:5173/,将看到页面:

20260410114451.png

组件的渲染方式

有两种方式:html中编写组件标签和基于schema的集中维护定义。
前者属于传统的组件使用方式,大部分场景下我们的表单开发都是直接用开源组件库如element plus,来编写和维护表单,FormKit也支持这个方式,它提供了内置的常用表单组件,同时提供了非常好的机制让我们扩展自定义组件,包括集成现有的UI组件。

组件定义方式

直接写组件标签,类似于使用Element Plus中的组件来手动构建表单:

<FormKit
  type="form"
  #default="{ value }"
  @submit="submit"
>
  <FormKit
type="text"
name="name"
label="Name"
help="..."
  />
  <FormKit
type="checkbox"
name="flavors"
label="..."
:options="{ ... }"
validation="required|min:2"
  />
  
  <FormKit
type="checkbox"
name="agree"
label="..."
  />
  ...
</FormKit>

基于Schema的定义方式

这种方式方便集中维护表单字段定义,FormKit可以基于表单定义的Schema动态的渲染表单,是低代码表单设计器的构建产物。有了它,我们只要关注于字段配置的扩展以及如何设计和实现表单设计器来在线生成表单定义数据。

<script setup lang="ts">
import {ref} from "vue"

const formSchema = {
  $formkit: 'form',
  children: [{
    $formkit: 'text',
    name: 'name',
    label: 'Name',
    help: '...',
  },
  {
    $formkit: 'checkbox',
    name: 'flavors',
    label: 'Favorite ice cream flavors',
    options: { ... },
    validation: 'required|min:2',
  },
  {
    $formkit: 'checkbox',
    name: 'agree',
    label: '...',
  },
]}

const data = ref({})

async function submit() {
  await new Promise(r => setTimeout(r, 1000))
  alert('Submitted! 🎉')
}
</script>

<template>
  <div class="...">
    <img ...>
    <FormKitSchema :schema="formSchema" v-model="data" @submit="submit" />
    <pre class="...">{{ data }}</pre>
  </div>
</template>

校验

FormKit提供了非常强大的内置校验和自定义扩展的方式,具体可参考校验官方文档
示例中对一个字段启用非空和长度校验非常简单,比如这里的多选框字段,只需简单配置为:validation: 'required|min:2',页面效果:

20260410152500.png

FormKit支持国际化,只需要在formkit.config.ts中进行如下配置:

...
import { zh } from '@formkit/i18n'
const config: DefaultConfigOptions = {
  ...
  locales: { zh },
  locale: 'zh',
}
export default config

会看到系统的校验信息变成了中文

20260410153511.png

官方文档

以上我们的介绍只是FormKit功能特性的九牛一毛,具体的API用法配置请参考FormKit官方文档。个人觉得看了那么多技术文档,FormKit无论是可读性和用户体验都是非常好的,唯一的遗憾是没有中文版。后续的例子也都会从官方文档来扩展。

20260410155356.png

好了,本次的学习分享就到这里。希望本篇能给前端低代码研发的小伙伴一些启示,我是小卷,一个爱学习分享的搬砖老码农,我们下期再见!

ESTree 规范 (acorn@8.15.0示例)

ESTree 是一套用于描述 ECMAScript(JavaScript)代码抽象语法树(AST)的标准化规范。ESTree 规范并非一成不变,而是跟随 ECMAScript 官方版本迭代,分为多个阶段的规范:

  • ES5 规范:最早的 ESTree 规范,仅支持 ES5 语法(如 var、普通函数、if/for 等)。
  • ES6+ 规范:新增 ES6 及后续版本的语法节点(如 ArrowFunctionExpression 箭头函数、ClassDeclaration 类、ImportDeclaration 模块导入等)。
  • ESNext 规范:支持尚未正式纳入 ECMAScript 标准的实验性语法(如装饰器、管道运算符等),供工具提前适配。

语法节点类型

根节点唯一 (Program)

{
    "type": "Program", // 节点类型,`Program` 表示整个程序。
    "start": 0, // 在源码中的开始索引
    "end": 9, // 在源码中的结束索引,这里原代码长度为 9,即共 9 个字符
    "body": [ ... ], // 程序体,是一个语句数组
    "sourceType": "script" // "script" 表示源码是普通脚本(非模块),如果是 `"module"`,则支持 `import`/`export`
}

声明节点

  • VariableDeclaration 变量声明(统一包裹const/let/var)
  • FunctionDeclaration 函数声明(具名函数,提升)
  • ClassDeclaration 类声明(具名类,提升)
  • ImportDeclaration 模块导入声明(仅模块环境)
  • ExportDeclaration 模块导出声明(仅模块环境,含命名 / 默认)
  • ExportNamedDeclaration命名导出
  • ExportDefaultDeclaration默认导出
  • ExportAllDeclaration全部导出

语句节点

  • BlockStatement 块语句({}包裹的代码块)
  • ExpressionStatement 表达式语句(包裹单个表达式作为语句执行)
  • IfStatement 条件判断语句
  • ForStatement for 循环语句
  • WhileStatement while 循环语句
  • ReturnStatement 返回语句(函数内)
  • TryStatement 异常捕获语句
  • BreakStatement 中断循环语句
  • ContinueStatement 继续循环语句

表达式节点

  • Identifier标识符(变量名、函数名、属性名等
  • Literal字面量(直接写死的值)
  • BinaryExpression 二元表达式(双操作数运算)
  • UnaryExpression 一元表达式(单操作数运算)
  • AssignmentExpression 赋值表达式
  • CallExpression 函数调用表达式
  • MemberExpression 成员访问表达式
  • ArrowFunctionExpression 箭头函数表达式
  • ObjectExpression 对象字面量表达式
  • ArrayExpression 数组字面量表达式

其他节点

  • TryStatementtry...catch 语句
  • TemplateLiteral模板字符串
  • TaggedTemplateExpression带标签的模板字符串
  • SpreadElement扩展运算符
  • RestElement剩余参数

Acorn

Acorn 是一个轻量、快速的 JavaScript 解析器,能将代码转换为 ESTree 标准的抽象语法树(AST)。

它主要提供三大核心 API

  • parse(input, options) :解析一段完整的 JavaScript 程序。成功返回 ESTree AST,失败抛出包含位置信息的 SyntaxError 对象
  • parseExpressionAt(input, pos, options) :解析一个独立的 JavaScript 表达式。适用于解析模板字符串内的内嵌表达式等混合内容
  • tokenizer(input, options) :返回一个迭代器,逐个生成代码的 Token。可用于自定义的语法高亮或极简解析器。

parseExpressionAt

  const code = 'const x = 10; const y = 20; x + y * 2;'
  const result = acorn.parseExpressionAt(code, code.indexOf('x + y'),{
    ecmaVersion: 2020,
    sourceType: 'module',
  });
  console.log(result);

image.png

tokenizer

示例

  const result = acorn.tokenizer('let a = "hello";',{
    ecmaVersion: 2020,
    sourceType: 'module',
  });
  console.log(result);

image.png

关键字(可用于代码高亮)

^(?:break|case|catch|continue|debugger|default|do|else|finally|for|function|if|return|switch|throw|try|var|while|with|null|true|false|instanceof|typeof|void|delete|new|in|this|const|class|extends|export|import|super)$

示例

  const result = acorn.tokenizer('let a = "hello";',{
    ecmaVersion: 2020,
    sourceType: 'module',
  });
  console.log(result);

  for(let token of result){
    console.log('token',token);
  }

image.png

image.png

每个 Token 对象都会包含一个 type 属性,指向这样的类型描述对象。

{
    "label": "string", // Token 类型的人类可读名称
    "beforeExpr": false, // 该 Token 类型是否可以在表达式之前出现
    "startsExpr": true, // 该 Token 类型是否作为表达式的开始
    "isLoop": false, // 是否为循环关键字(如 for, while, do)
    "isAssign": false, // 是否为赋值操作符(如 =, +=, -=)
    "prefix": false, // 是否为前缀操作符(如 ++, --, !, ~)
    "postfix": false,  // 是否为后缀操作符(如 ++, --)
    "binop": null,// 如果是二元操作符,这里会有一个优先级数值;否则为 null
    "updateContext": null // 可选函数,用于在解析时更新上下文(通常为 null)
}

声明变量

例1 声明一个变量(基本类型)

const ast = acorn.parse(`let a = 1`, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));
{
  "type": "Program",
  "start": 0,
  "end": 9,
  "body": [
    {
      "type": "VariableDeclaration", // 变量声明符
      "start": 0,
      "end": 9,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 4,
          "end": 9,
          // 标识符节点,即变量名。
          "id": {
            "type": "Identifier", // 变量名标识符
            "start": 4,
            "end": 5,
            "name": "a" // 变量名
          },
          // 初始化表达式节点,即等号右边的值
          "init": {
            "type": "Literal", // 字面量
            "start": 8,
            "end": 9,
            "value": 1, // 运行时的值,这里是数字 1
            "raw": "1" // 源码中的原始字符串表示 "1"
          }
        }
      ],
      "kind": "let"  // 表示使用 let 关键字声明
    }
  ],
  "sourceType": "script"
}

例2 声明一个变量(数组)

const ast = acorn.parse(`const arr = [1,2]`, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 17,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 17,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 17,
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 9,
            "name": "arr"
          },
          "init": {
            "type": "ArrayExpression",
            "start": 12,
            "end": 17,
            "elements": [
              {
                "type": "Literal",
                "start": 13,
                "end": 14,
                "value": 1,
                "raw": "1"
              },
              {
                "type": "Literal",
                "start": 15,
                "end": 16,
                "value": 2,
                "raw": "2"
              }
            ]
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "script"
}

例3 声明一个变量(对象)

const ast = acorn.parse(`const arr = {a: 1, b: 2}`, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 24,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 24,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 24,
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 9,
            "name": "arr"
          },
          "init": {
            "type": "ObjectExpression",
            "start": 12,
            "end": 24,
            "properties": [
              {
                "type": "Property",
                "start": 13,
                "end": 17,
                "method": false,
                "shorthand": false,
                "computed": false,
                "key": {
                  "type": "Identifier",
                  "start": 13,
                  "end": 14,
                  "name": "a"
                },
                "value": {
                  "type": "Literal",
                  "start": 16,
                  "end": 17,
                  "value": 1,
                  "raw": "1"
                },
                "kind": "init"
              },
              {
                "type": "Property",
                "start": 19,
                "end": 23,
                "method": false,
                "shorthand": false,
                "computed": false,
                "key": {
                  "type": "Identifier",
                  "start": 19,
                  "end": 20,
                  "name": "b"
                },
                "value": {
                  "type": "Literal",
                  "start": 22,
                  "end": 23,
                  "value": 2,
                  "raw": "2"
                },
                "kind": "init"
              }
            ]
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "script"
}

例4 三元表达式

const ast = acorn.parse(`const flag = a > b ? true : false`, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 33,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 33,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 33,
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 10,
            "name": "flag"
          },
          "init": {
            "type": "ConditionalExpression",
            "start": 13,
            "end": 33,
            "test": {
              "type": "BinaryExpression",
              "start": 13,
              "end": 18,
              "left": {
                "type": "Identifier",
                "start": 13,
                "end": 14,
                "name": "a"
              },
              "operator": ">",
              "right": {
                "type": "Identifier",
                "start": 17,
                "end": 18,
                "name": "b"
              }
            },
            "consequent": {
              "type": "Literal",
              "start": 21,
              "end": 25,
              "value": true,
              "raw": "true"
            },
            "alternate": {
              "type": "Literal",
              "start": 28,
              "end": 33,
              "value": false,
              "raw": "false"
            }
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "script"
}

例5 声明变量(逻辑运算符)

  const code = 'let name = jon || "hello";'
  const result = acorn.parse(code, {
    ecmaVersion: 2020,
  });
  console.log(JSON.stringify(result, null, 2));
{
    "type": "Program",
    "start": 0,
    "end": 26,
    "body": [
        {
            "type": "VariableDeclaration",
            "start": 0,
            "end": 26,
            "declarations": [
                {
                    "type": "VariableDeclarator",
                    "start": 4,
                    "end": 25,
                    // 声明标识
                    "id": {
                        "type": "Identifier",
                        "start": 4,
                        "end": 8,
                        "name": "name"
                    },
                    // 声明初始化内容
                    "init": {
                        "type": "LogicalExpression",// 逻辑表达式
                        "start": 11,
                        "end": 25,
                        "left": {
                            "type": "Identifier",
                            "start": 11,
                            "end": 14,
                            "name": "jon"
                        },
                        "operator": "||",// 操作符
                        "right": {
                            "type": "Literal",
                            "start": 18,
                            "end": 25,
                            "value": "hello",
                            "raw": "\"hello\""
                        }
                    }
                }
            ],
            "kind": "let"
        }
    ],
    "sourceType": "script"
}

函数

例1 箭头函数

const ast = acorn.parse(`const getFlag = (a, b) => a + b`, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 31,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 31,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 31,
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 13,
            "name": "getFlag"
          },
          "init": {
            "type": "ArrowFunctionExpression",
            "start": 16,
            "end": 31,
            "id": null,
            "expression": true,
            "generator": false,
            "async": false,
            "params": [
              {
                "type": "Identifier",
                "start": 17,
                "end": 18,
                "name": "a"
              },
              {
                "type": "Identifier",
                "start": 20,
                "end": 21,
                "name": "b"
              }
            ],
            "body": {
              "type": "BinaryExpression",
              "start": 26,
              "end": 31,
              "left": {
                "type": "Identifier",
                "start": 26,
                "end": 27,
                "name": "a"
              },
              "operator": "+",
              "right": {
                "type": "Identifier",
                "start": 30,
                "end": 31,
                "name": "b"
              }
            }
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "script"
}

例2 普通函数 含有返回值

const ast = acorn.parse(`function getFlag(a, b) { return a + b }  `, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 41,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 39,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 16,
        "name": "getFlag"
      },
      "expression": false,
      "generator": false,
      "async": false,
      "params": [
        {
          "type": "Identifier",
          "start": 17,
          "end": 18,
          "name": "a"
        },
        {
          "type": "Identifier",
          "start": 20,
          "end": 21,
          "name": "b"
        }
      ],
      "body": {
        "type": "BlockStatement",
        "start": 23,
        "end": 39,
        "body": [
          {
            "type": "ReturnStatement",
            "start": 25,
            "end": 37,
            "argument": {
              "type": "BinaryExpression",
              "start": 32,
              "end": 37,
              "left": {
                "type": "Identifier",
                "start": 32,
                "end": 33,
                "name": "a"
              },
              "operator": "+",
              "right": {
                "type": "Identifier",
                "start": 36,
                "end": 37,
                "name": "b"
              }
            }
          }
        ]
      }
    }
  ],
  "sourceType": "script"
}

例3 函数调用

const ast = acorn.parse(`function getFlag(a, b) { return a + b } getFlag(1, 2)`, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 53,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 39,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 16,
        "name": "getFlag"
      },
      "expression": false,
      "generator": false,
      "async": false,
      "params": [
        {
          "type": "Identifier",
          "start": 17,
          "end": 18,
          "name": "a"
        },
        {
          "type": "Identifier",
          "start": 20,
          "end": 21,
          "name": "b"
        }
      ],
      "body": {
        "type": "BlockStatement",
        "start": 23,
        "end": 39,
        "body": [
          {
            "type": "ReturnStatement",
            "start": 25,
            "end": 37,
            "argument": {
              "type": "BinaryExpression",
              "start": 32,
              "end": 37,
              "left": {
                "type": "Identifier",
                "start": 32,
                "end": 33,
                "name": "a"
              },
              "operator": "+",
              "right": {
                "type": "Identifier",
                "start": 36,
                "end": 37,
                "name": "b"
              }
            }
          }
        ]
      }
    },
    {
      "type": "ExpressionStatement",
      "start": 40,
      "end": 53,
      "expression": {
        "type": "CallExpression",
        "start": 40,
        "end": 53,
        "callee": {
          "type": "Identifier",
          "start": 40,
          "end": 47,
          "name": "getFlag"
        },
        "arguments": [
          {
            "type": "Literal",
            "start": 48,
            "end": 49,
            "value": 1,
            "raw": "1"
          },
          {
            "type": "Literal",
            "start": 51,
            "end": 52,
            "value": 2,
            "raw": "2"
          }
        ],
        "optional": false
      }
    }
  ],
  "sourceType": "script"
}

例4 条件语句

const ast = acorn.parse(`function getFlag(a, b) { if(a > b) { return true } } getFlag(1, 2)`, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 66,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 52,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 16,
        "name": "getFlag"
      },
      "expression": false,
      "generator": false,
      "async": false,
      "params": [
        {
          "type": "Identifier",
          "start": 17,
          "end": 18,
          "name": "a"
        },
        {
          "type": "Identifier",
          "start": 20,
          "end": 21,
          "name": "b"
        }
      ],
      "body": {
        "type": "BlockStatement",
        "start": 23,
        "end": 52,
        "body": [
          {
            "type": "IfStatement",
            "start": 25,
            "end": 50,
            "test": {
              "type": "BinaryExpression",
              "start": 28,
              "end": 33,
              "left": {
                "type": "Identifier",
                "start": 28,
                "end": 29,
                "name": "a"
              },
              "operator": ">",
              "right": {
                "type": "Identifier",
                "start": 32,
                "end": 33,
                "name": "b"
              }
            },
            "consequent": {
              "type": "BlockStatement",
              "start": 35,
              "end": 50,
              "body": [
                {
                  "type": "ReturnStatement",
                  "start": 37,
                  "end": 48,
                  "argument": {
                    "type": "Literal",
                    "start": 44,
                    "end": 48,
                    "value": true,
                    "raw": "true"
                  }
                }
              ]
            },
            "alternate": null
          }
        ]
      }
    },
    {
      "type": "ExpressionStatement",
      "start": 53,
      "end": 66,
      "expression": {
        "type": "CallExpression",
        "start": 53,
        "end": 66,
        "callee": {
          "type": "Identifier",
          "start": 53,
          "end": 60,
          "name": "getFlag"
        },
        "arguments": [
          {
            "type": "Literal",
            "start": 61,
            "end": 62,
            "value": 1,
            "raw": "1"
          },
          {
            "type": "Literal",
            "start": 64,
            "end": 65,
            "value": 2,
            "raw": "2"
          }
        ],
        "optional": false
      }
    }
  ],
  "sourceType": "script"
}

声明一个空类

{
    "type": "Program",
    "start": 0,
    "end": 11,
    "body": [
        {
            "type": "ClassDeclaration", // 类声明
            "start": 0,
            "end": 11,
            // 类名,是一个 Identifier 节点
            "id": {
                "type": "Identifier",
                "start": 6,
                "end": 9,
                "name": "Cat"
            },
            // 父类 ,如果有 extends 关键字,这里会是表达式节点
            "superClass": null,
            // 包含类的所有成员(方法、属性等)
            "body": {
                "type": "ClassBody",
                "start": 9,
                "end": 11,
                "body": []
            }
        }
    ],
    "sourceType": "module"
}

带构造函数的类

{
    "type": "Program",
    "start": 0,
    "end": 50,
    "body": [
        {
            "type": "ClassDeclaration",
            "start": 0,
            "end": 50,
            "id": {
                "type": "Identifier",
                "start": 6,
                "end": 9,
                "name": "Cat"
            },
            "superClass": null,
            "body": {
                "type": "ClassBody",
                "start": 9,
                "end": 50,
                "body": [
                    {
                        "type": "MethodDefinition",
                        "start": 11,
                        "end": 49,
                        "static": false,
                        "computed": false,
                        "key": {
                            "type": "Identifier",
                            "start": 11,
                            "end": 22,
                            "name": "constructor"
                        },
                        "kind": "constructor",
                        "value": {
                            "type": "FunctionExpression",
                            "start": 22,
                            "end": 49,
                            "id": null,
                            "expression": false,
                            "generator": false,
                            "async": false,
                            "params": [
                                {
                                    "type": "Identifier",
                                    "start": 23,
                                    "end": 27,
                                    "name": "name"
                                }
                            ],
                            "body": {
                                "type": "BlockStatement",
                                "start": 28,
                                "end": 49,
                                "body": [
                                    {
                                        "type": "ExpressionStatement",
                                        "start": 30,
                                        "end": 47,
                                        "expression": {
                                            "type": "AssignmentExpression",
                                            "start": 30,
                                            "end": 46,
                                            "operator": "=",
                                            "left": {
                                                "type": "MemberExpression",
                                                "start": 30,
                                                "end": 39,
                                                "object": {
                                                    "type": "ThisExpression",
                                                    "start": 30,
                                                    "end": 34
                                                },
                                                "property": {
                                                    "type": "Identifier",
                                                    "start": 35,
                                                    "end": 39,
                                                    "name": "name"
                                                },
                                                "computed": false,
                                                "optional": false
                                            },
                                            "right": {
                                                "type": "Identifier",
                                                "start": 42,
                                                "end": 46,
                                                "name": "name"
                                            }
                                        }
                                    }
                                ]
                            }
                        }
                    }
                ]
            }
        }
    ],
    "sourceType": "module"
}

截取片段this.name = name

{
 "body": [
    {
        "type": "ExpressionStatement", // 表达式语句
        "start": 30,
        "end": 47,
        // 真正的表达式
        "expression": {
            "type": "AssignmentExpression", // 赋值表达式
            "start": 30,
            "end": 46,
            "operator": "=",
            "left": {
                "type": "MemberExpression", // 属性访问表达式
                "start": 30,
                "end": 39,
                // 被访问的对象
                "object": {
                    "type": "ThisExpression", // this
                    "start": 30,
                    "end": 34
                },
                // 属性
                "property": {
                    "type": "Identifier",
                    "start": 35,
                    "end": 39,
                    "name": "name"
                },
                // 表示使用点号 . 访问属性(而非 [计算属性名])
                "computed": false,
                // 可选链操作符 ?.
                "optional": false
            },
            "right": {
                "type": "Identifier",
                "start": 42,
                "end": 46,
                "name": "name"
            }
        }
    }
]
}

继承

  const code = 'class Cat extends Animal { constructor(name){ super(name); }}'
  const result = acorn.parse(code, {
    ecmaVersion: 2020,
    sourceType: 'module',
  });
  console.log(JSON.stringify(result, null, 2));
{
    "type": "Program",
    "start": 0,
    "end": 61,
    "body": [
        {
            "type": "ClassDeclaration",
            "start": 0,
            "end": 61,
            "id": {
                "type": "Identifier",
                "start": 6,
                "end": 9,
                "name": "Cat"
            },
            "superClass": {
                "type": "Identifier",
                "start": 18,
                "end": 24,
                "name": "Animal"
            },
            "body": {
                "type": "ClassBody",
                "start": 25,
                "end": 61,
                "body": [
                    {
                        "type": "MethodDefinition",
                        "start": 27,
                        "end": 60,
                        "static": false,
                        "computed": false,
                        "key": {
                            "type": "Identifier",
                            "start": 27,
                            "end": 38,
                            "name": "constructor"
                        },
                        "kind": "constructor",
                        "value": {
                            "type": "FunctionExpression",
                            "start": 38,
                            "end": 60,
                            "id": null,
                            "expression": false,
                            "generator": false,
                            "async": false,
                            "params": [
                                {
                                    "type": "Identifier",
                                    "start": 39,
                                    "end": 43,
                                    "name": "name"
                                }
                            ],
                            "body": {
                                "type": "BlockStatement",
                                "start": 44,
                                "end": 60,
                                "body": [
                                    {
                                        "type": "ExpressionStatement",
                                        "start": 46,
                                        "end": 58,
                                        "expression": {
                                            "type": "CallExpression",
                                            "start": 46,
                                            "end": 57,
                                            "callee": {
                                                "type": "Super",
                                                "start": 46,
                                                "end": 51
                                            },
                                            "arguments": [
                                                {
                                                    "type": "Identifier",
                                                    "start": 52,
                                                    "end": 56,
                                                    "name": "name"
                                                }
                                            ],
                                            "optional": false
                                        }
                                    }
                                ]
                            }
                        }
                    }
                ]
            }
        }
    ],
    "sourceType": "module"
}

截取片段分析 super(name)

{
    "type": "ExpressionStatement",
    "start": 46,
    "end": 58,
    "expression": {
        "type": "CallExpression",//调用表达式
        "start": 46,
        "end": 57,
        // 被调用的函数或方法
        "callee": {
            "type": "Super", // super关键字
            "start": 46,
            "end": 51
        },
        // 参数列表
        "arguments": [
            {
                "type": "Identifier",
                "start": 52,
                "end": 56,
                "name": "name"
            }
        ],
        "optional": false
    }
}

模块

命名导入

const ast = acorn.parse(`import { add } from './utills.js'`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
    "type": "Program",
    "start": 0,
    "end": 33,
    "body": [
        {
            "type": "ImportDeclaration", // 导入声明
            "start": 0,
            "end": 33,
            
            "specifiers": [
                {
                    "type": "ImportSpecifier", // 导入语句
                    "start": 9,
                    "end": 12,
                    // 模块导入的名称
                    "imported": {
                        "type": "Identifier", 
                        "start": 9,
                        "end": 12,
                        "name": "add"
                    },
                    // 本地使用的名称
                    "local": {
                        "type": "Identifier",
                        "start": 9,
                        "end": 12,
                        "name": "add"
                    }
                }
            ],
            // 源
            "source": {
                "type": "Literal",
                "start": 20,
                "end": 33,
                "value": "./utills.js", // 运行中
                "raw": "'./utills.js'" // 代码中保留了引号
            }
        }
    ],
    "sourceType": "module"
}

命名导入

const ast = acorn.parse(`import { add } from './utills.js';const result = add(1, 2);`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
    "type": "Program",
    "start": 0,
    "end": 59,
    "body": [
        {
            "type": "ImportDeclaration",
            "start": 0,
            "end": 34,
            "specifiers": [
                {
                    "type": "ImportSpecifier",
                    "start": 9,
                    "end": 12,
                    "imported": {
                        "type": "Identifier",
                        "start": 9,
                        "end": 12,
                        "name": "add"
                    },
                    "local": {
                        "type": "Identifier",
                        "start": 9,
                        "end": 12,
                        "name": "add"
                    }
                }
            ],
            "source": {
                "type": "Literal",
                "start": 20,
                "end": 33,
                "value": "./utills.js",
                "raw": "'./utills.js'"
            }
        },
        {
            "type": "VariableDeclaration",
            "start": 34,
            "end": 59,
            "declarations": [
                {
                    "type": "VariableDeclarator",
                    "start": 40,
                    "end": 58,
                    "id": {
                        "type": "Identifier",
                        "start": 40,
                        "end": 46,
                        "name": "result"
                    },
                    "init": {
                        "type": "CallExpression",
                        "start": 49,
                        "end": 58,
                        "callee": {
                            "type": "Identifier",
                            "start": 49,
                            "end": 52,
                            "name": "add"
                        },
                        "arguments": [
                            {
                                "type": "Literal",
                                "start": 53,
                                "end": 54,
                                "value": 1,
                                "raw": "1"
                            },
                            {
                                "type": "Literal",
                                "start": 56,
                                "end": 57,
                                "value": 2,
                                "raw": "2"
                            }
                        ],
                        "optional": false
                    }
                }
            ],
            "kind": "const"
        }
    ],
    "sourceType": "module"
}

别名导入

const ast = acorn.parse(`import { add as addFun} from './utills.js'`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
    "type": "Program",
    "start": 0,
    "end": 42,
    "body": [
        {
            "type": "ImportDeclaration",
            "start": 0,
            "end": 42,
            "specifiers": [
                {
                    "type": "ImportSpecifier",
                    "start": 9,
                    "end": 22,
                    "imported": {
                        "type": "Identifier",
                        "start": 9,
                        "end": 12,
                        "name": "add"
                    },
                    "local": {
                        "type": "Identifier",
                        "start": 16,
                        "end": 22,
                        "name": "addFun"
                    }
                }
            ],
            "source": {
                "type": "Literal",
                "start": 29,
                "end": 42,
                "value": "./utills.js",
                "raw": "'./utills.js'"
            }
        }
    ],
    "sourceType": "module"
}

命名导出一个 变量声明

const ast = acorn.parse(`export const Max_Size = 100;`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
    "type": "Program",
    "start": 0,
    "end": 28,
    "body": [
        {
            "type": "ExportNamedDeclaration", // 表示一个命名导出
            "start": 0,
            "end": 28,
            // 被导出的声明节点
            "declaration": {
                "type": "VariableDeclaration",
                "start": 7,
                "end": 28,
                "declarations": [
                    {
                        "type": "VariableDeclarator",
                        "start": 13,
                        "end": 27,
                        "id": {
                            "type": "Identifier",
                            "start": 13,
                            "end": 21,
                            "name": "Max_Size"
                        },
                        "init": {
                            "type": "Literal",
                            "start": 24,
                            "end": 27,
                            "value": 100,
                            "raw": "100"
                        }
                    }
                ],
                "kind": "const"
            },
            "specifiers": [],
            "source": null // 从其他模块重导出
        }
    ],
    "sourceType": "module"
}

命名导出一个 函数声明

const ast = acorn.parse(`export function add(a, b) {return a + b;}`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
  "type": "Program",
  "start": 0,
  "end": 41,
  "body": [
    {
      "type": "ExportNamedDeclaration",
      "start": 0,
      "end": 41,
      "declaration": {
        "type": "FunctionDeclaration",
        "start": 7,
        "end": 41,
        "id": {
          "type": "Identifier",
          "start": 16,
          "end": 19,
          "name": "add"
        },
        "expression": false,
        "generator": false,
        "async": false,
        "params": [
          {
            "type": "Identifier",
            "start": 20,
            "end": 21,
            "name": "a"
          },
          {
            "type": "Identifier",
            "start": 23,
            "end": 24,
            "name": "b"
          }
        ],
        "body": {
          "type": "BlockStatement",
          "start": 26,
          "end": 41,
          "body": [
            {
              "type": "ReturnStatement",
              "start": 27,
              "end": 40,
              "argument": {
                "type": "BinaryExpression",
                "start": 34,
                "end": 39,
                "left": {
                  "type": "Identifier",
                  "start": 34,
                  "end": 35,
                  "name": "a"
                },
                "operator": "+",
                "right": {
                  "type": "Identifier",
                  "start": 38,
                  "end": 39,
                  "name": "b"
                }
              }
            }
          ]
        }
      },
      "specifiers": [],
      "source": null
    }
  ],
  "sourceType": "module"
}

命名导出一个变量

const ast = acorn.parse(`const Max_Size = 100;export { Max_Size };`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));

export { Max_Size };这是一个命名导出语句,但它不包含声明declaration: null),而是通过 specifiers 列表来指定要导出的已有变量

{
  "type": "Program",
  "start": 0,
  "end": 41,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 21,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 20,
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 14,
            "name": "Max_Size"
          },
          "init": {
            "type": "Literal",
            "start": 17,
            "end": 20,
            "value": 100,
            "raw": "100"
          }
        }
      ],
      "kind": "const"
    },
    {
      "type": "ExportNamedDeclaration",
      "start": 21,
      "end": 41,
      "declaration": null, // 没有内联声明
      // 导出说明符列表
      "specifiers": [
        {
          "type": "ExportSpecifier",
          "start": 30,
          "end": 38,
          // 当前模块本地名称
          "local": {
            "type": "Identifier",
            "start": 30,
            "end": 38,
            "name": "Max_Size"
          },
          // 导出后名称
          "exported": {
            "type": "Identifier",
            "start": 30,
            "end": 38,
            "name": "Max_Size"
          }
        }
      ],
      "source": null // 不是从其他模块中导出
    }
  ],
  "sourceType": "module"
}

命名导出一个函数

const ast = acorn.parse(`function add(a, b) {return a + b;} export { add };`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
  "type": "Program",
  "start": 0,
  "end": 50,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 34,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 12,
        "name": "add"
      },
      "expression": false,
      "generator": false,
      "async": false,
      "params": [
        {
          "type": "Identifier",
          "start": 13,
          "end": 14,
          "name": "a"
        },
        {
          "type": "Identifier",
          "start": 16,
          "end": 17,
          "name": "b"
        }
      ],
      "body": {
        "type": "BlockStatement",
        "start": 19,
        "end": 34,
        "body": [
          {
            "type": "ReturnStatement",
            "start": 20,
            "end": 33,
            "argument": {
              "type": "BinaryExpression",
              "start": 27,
              "end": 32,
              "left": {
                "type": "Identifier",
                "start": 27,
                "end": 28,
                "name": "a"
              },
              "operator": "+",
              "right": {
                "type": "Identifier",
                "start": 31,
                "end": 32,
                "name": "b"
              }
            }
          }
        ]
      }
    },
    {
      "type": "ExportNamedDeclaration",
      "start": 35,
      "end": 50,
      "declaration": null,
      "specifiers": [
        {
          "type": "ExportSpecifier",
          "start": 44,
          "end": 47,
          "local": {
            "type": "Identifier",
            "start": 44,
            "end": 47,
            "name": "add"
          },
          "exported": {
            "type": "Identifier",
            "start": 44,
            "end": 47,
            "name": "add"
          }
        }
      ],
      "source": null
    }
  ],
  "sourceType": "module"
}

默认导出字面量

const ast = acorn.parse(`export default 12;`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
  "type": "Program",
  "start": 0,
  "end": 18,
  "body": [
    {
      "type": "ExportDefaultDeclaration",
      "start": 0,
      "end": 18,
      "declaration": {
        "type": "Literal",
        "start": 15,
        "end": 17,
        "value": 12,
        "raw": "12"
      }
    }
  ],
  "sourceType": "module"
}

默认导出 变量(基本类型)

const ast = acorn.parse(`var Max_Size = 100;export default  Max_Size ;`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
  "type": "Program",
  "start": 0,
  "end": 45,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 19,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 4,
          "end": 18,
          "id": {
            "type": "Identifier",
            "start": 4,
            "end": 12,
            "name": "Max_Size"
          },
          "init": {
            "type": "Literal",
            "start": 15,
            "end": 18,
            "value": 100,
            "raw": "100"
          }
        }
      ],
      "kind": "var"
    },
    {
      "type": "ExportDefaultDeclaration",
      "start": 19,
      "end": 45,
      "declaration": {
        "type": "Identifier",
        "start": 35,
        "end": 43,
        "name": "Max_Size"
      }
    }
  ],
  "sourceType": "module"
}

默认导出变量 (函数)

const ast = acorn.parse(`function a(){} export default a;`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 32,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 14,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 10,
        "name": "a"
      },
      "expression": false,
      "generator": false,
      "async": false,
      "params": [],
      "body": {
        "type": "BlockStatement",
        "start": 12,
        "end": 14,
        "body": []
      }
    },
    {
      "type": "ExportDefaultDeclaration",
      "start": 15,
      "end": 32,
      // 被导出的声明或表达式
      "declaration": {
        "type": "Identifier",
        "start": 30,
        "end": 31,
        "name": "a"
      }
    }
  ],
  "sourceType": "module"
}

默认导出 对象表达式

const ast = acorn.parse(`function add(a, b) {return a + b;} export default { add };`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
  "type": "Program",
  "start": 0,
  "end": 58,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 34,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 12,
        "name": "add"
      },
      "expression": false,
      "generator": false,
      "async": false,
      "params": [
        {
          "type": "Identifier",
          "start": 13,
          "end": 14,
          "name": "a"
        },
        {
          "type": "Identifier",
          "start": 16,
          "end": 17,
          "name": "b"
        }
      ],
      "body": {
        "type": "BlockStatement",
        "start": 19,
        "end": 34,
        "body": [
          {
            "type": "ReturnStatement",
            "start": 20,
            "end": 33,
            "argument": {
              "type": "BinaryExpression",
              "start": 27,
              "end": 32,
              "left": {
                "type": "Identifier",
                "start": 27,
                "end": 28,
                "name": "a"
              },
              "operator": "+",
              "right": {
                "type": "Identifier",
                "start": 31,
                "end": 32,
                "name": "b"
              }
            }
          }
        ]
      }
    },
    {
      "type": "ExportDefaultDeclaration", // 默认导出声明
      "start": 35,
      "end": 58,
      "declaration": {
        "type": "ObjectExpression",
        "start": 50,
        "end": 57,
        // 对象属性
        "properties": [
          {
            "type": "Property", // 对象属性节点
            "start": 52,
            "end": 55,
            "method": false,
            "shorthand": true,
            "computed": false,
            "key": {
              "type": "Identifier",
              "start": 52,
              "end": 55,
              "name": "add"
            },
            "value": {
              "type": "Identifier",
              "start": 52,
              "end": 55,
              "name": "add"
            },
            // 表示普通数据属性,非getter、setter
            "kind": "init"
          }
        ]
      }
    }
  ],
  "sourceType": "module"
}

默认导出 函数声明

const ast = acorn.parse(`export default function fn() {}`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
  "type": "Program",
  "start": 0,
  "end": 31,
  "body": [
    {
      "type": "ExportDefaultDeclaration",
      "start": 0,
      "end": 31,
      "declaration": {
        "type": "FunctionDeclaration",
        "start": 15,
        "end": 31,
        "id": {
          "type": "Identifier",
          "start": 24,
          "end": 26,
          "name": "fn"
        },
        "expression": false,
        "generator": false,
        "async": false,
        "params": [],
        "body": {
          "type": "BlockStatement",
          "start": 29,
          "end": 31,
          "body": []
        }
      }
    }
  ],
  "sourceType": "module"
}

最后

  1. ESTree 规范
  2. 在线查看代码片段的AST语法结构

技术复盘文档:HTTPS 站点安全下载 HTTP 资源实践总结

技术复盘文档:HTTPS 站点安全下载 HTTP 资源实践总结

1. 业务背景

子系统日志中心 业务模块中,用户需要从主站界面下载各子系统的运行日志(如 ETL 日志、DCP 日志)。

  • 主站域名https://aims.dcsoft.localhttps://083.dev.aims.dcsoft.vip (安全 HTTPS 协议)
  • 下载服务器http://192.168.13.220:2800http://192.168.100.72:2800 (非安全 HTTP 协议)
  • 资源类型.log 后缀的文本文件
  • 核心诉求:点击下载按钮后,触发文件下载,严禁导致主站页面跳转或重定向

2. 核心问题描述

由于现代浏览器(尤其是 Chrome 88+)加强了安全防护,当一个 HTTPS 站点 尝试请求一个 HTTP 资源 进行下载时,会触发 Mixed Content(混合内容) 拦截和 Insecure Download Blocking(不安全下载拦截)

其表现为:浏览器认为该下载不安全,为了提醒用户,会强行夺取主站窗口的控制权并将其重定向到目标地址,导致主站业务中断。

3. 技术探索历程与失败原因分析

我们针对该问题先后尝试了 6 种前端方案,均由于浏览器底层的安全策略无法完美达成目标:

方案一:动态 <a> 标签 + download 属性

  • 操作:创建隐藏 <a> 标签,设置 target="_blank"download 属性
  • 结果:主站重定向跳转,下载未触发
  • 原因download 属性在跨域/跨协议时被浏览器忽略。Chrome 识别到安全降级,强行在当前窗口导航

方案二:隐藏 <iframe> 方案

  • 操作:创建不可见 iframe,将其 src 指向下载地址
  • 结果:控制台报错:was loaded over an insecure connection
  • 原因:Mixed Content 拦截。HTTPS 严禁以 Frame 形式加载 HTTP 资源

方案三:window.open('about:blank') + document.write 中转

  • 操作:先开空白页,在新页内注入跳转脚本
  • 结果:主站依然闪退/跳转,或者新页面无反应
  • 原因:Chrome 会追踪窗口的 opener(血缘关系)。只要新窗口处于主站的安全上下文中,不安全下载依然会触发父窗口的关联重定向

方案四:window.open + noopener noreferrer

  • 操作:彻底斩断新旧窗口的联系
  • 结果:主站保住了,但新窗口打开后显示日志文本,不触发下载;或弹出"您即将提交的信息不安全"的蓝色警告页
  • 原因
    • 蓝色警告是 Chrome 对跨协议提交的硬件级拦截,JS 无法消除
    • .log 被识别为文本,后端缺少 Content-Disposition 响应头,导致预览而非下载

方案五:Form 表单提交到 target="_blank"

  • 操作:创建隐藏表单模拟 POST/GET 提交
  • 结果:跳转到新窗口,但必现"您即将提交的信息不安全"提示
  • 原因:浏览器判定将加密页面的数据提交到非加密页面存在风险

4. 根源复盘总结

4.1 为什么前端无法完美解决?

浏览器厂商(Google/Microsoft)已经将 "HTTPS 页面静默触发 HTTP 下载" 这条路在代码层面封死了。

  • 如果使用 脚本触发,浏览器判定为流氓行为,直接重定向主站
  • 如果使用 用户点击,浏览器判定为安全降级,弹出蓝色警告提示

4.2 为什么手动复制链接可以下载?

因为手动复制链接属于 "用户在地址栏直接输入",不属于任何站点的派生行为,浏览器不执行混合内容检查。

代理的妙用:uni-app 小程序是怎样用 `Proxy` 和 `wrapper` 抹平平台差异的

前言

大家好,我是 笨笨狗吞噬者,uni-app、varlet、nrm 等众多知名仓库的核心开发,专注于分享前端技术和 AI实践知识,本篇文章是 uni-app 小程序源码解读的第一期,欢迎关注我的微信公众号 前端笨笨狗

很多人在使用 uni-app 时,会很自然地写出 uni.showToast()uni.login() 这样的代码,但很少会继续追问一个问题:为什么同样是 uni.xxx,到了微信能落到 wx.xxx,到了支付宝又能落到 my.xxx

这个问题背后,藏着一个巧妙的设计思路。它既不是简单的字符串替换,也不是把所有平台 API 硬编码映射一遍,接下来我们慢慢揭秘。

问题背景

在 uni-app 小程序端,开发者写的是:

uni.showToast({ title: 'Hello' })
uni.login()

但真正运行时:

  • 微信里调用的是 wx.xxx
  • 支付宝里调用的是 my.xxx

这一层统一,核心就靠两样东西:

  • Proxy:决定 uni.xxx 这次到底取哪个实现
  • wrapper:把不同平台之间的参数、方法名、返回值差异包起来

对应源码入口在 https://github.com/dcloudio/uni-app/blob/uni-app-vue3-dev/packages/uni-mp-core/src/api/index.ts#L61

Proxy:统一入口分发

先看核心代码:

export function initUni(api, protocols, platform = __GLOBAL__) {
  const wrapper = initWrapper(protocols)
  const UniProxyHandlers = {
    get(target, key) {
      if (hasOwn(api, key)) {
        return promisify(key, api[key])
      }
      if (hasOwn(baseApis, key)) {
        return promisify(key, baseApis[key])
      }
      return promisify(key, wrapper(key, platform[key]))
    },
  }

  return new Proxy({}, UniProxyHandlers)
}

这段代码的意思很简单:

uni 不是一个提前写死所有方法的对象,而是一个“访问属性时再决定返回什么”的代理对象。

比如你写:

uni.showToast

这时会触发 get(target, key),其中:

  • key 就是 showToast
  • 代理会临时判断这个方法应该从哪里来

也就是说,Proxy 的作用不是执行 API,而是做一次统一分发。

为什么这里一定要用 Proxy

因为 uni 的目标不是“绑定某个平台”,而是“对外提供稳定名字,对内按平台切换实现”。

如果不用 Proxy,那就只能:

  • 提前把所有 API 全部挂到 uni
  • 或者每个平台都维护一套映射逻辑

这样会很笨重。

用了 Proxy 之后,框架就可以做到:

  1. 开发者永远写 uni.xxx
  2. 真正访问到 uni.xxx 时,再动态决定用谁
  3. 同一个入口,底层可以切到不同平台对象

这正是代理最适合的场景:入口统一,底层实现可变。

这段 get 其实分了三层

源码里的分发顺序很清楚:

1. 先看 api

if (hasOwn(api, key)) {
  return promisify(key, api[key])
}

如果这个方法 uni 自己实现过,就优先返回 uni 自己的实现。

2. 再看 baseApis

if (hasOwn(baseApis, key)) {
  return promisify(key, baseApis[key])
}

像事件总线、拦截器这类基础能力,不一定来自小程序原生对象,也统一挂在 uni 上。

3. 最后兜底到平台对象

return promisify(key, wrapper(key, platform[key]))

这里的 platform 默认是 __GLOBAL__,可以理解成当前平台全局对象:

  • 微信环境下接近 wx
  • 支付宝环境下接近 my

所以 Proxy 最终做成了这件事:

uni.showToast -> 当前平台的 showToast
uni.login -> 当前平台的 login

wrapper:翻译平台差异

如果只是:

return platform[key]

表面上也能调用,但问题是不同小程序平台并不完全一致,常见差异有三种:

  1. 方法名不一致
  2. 参数名不一致
  3. 返回值结构不一致

所以 Proxy 只能解决“分发给谁”,解决不了“怎么适配差异”。

这就是 wrapper 的职责。

wrapper 在这里干了什么

看最关键的一句:

const returnValue = __GLOBAL__[options.name || methodName].apply(
  __GLOBAL__,
  args
)

这里能看出两件事。

第一,wrapper 允许改方法名。

  • 默认调用 methodName
  • 如果协议里配置了 options.name,就改成另一个平台方法名

第二,wrapper 会先处理参数,再调用平台方法。

前面的 processArgs、后面的 processReturnValue,本质上都在做一件事:

把 uni 暴露给开发者的统一接口,翻译成当前平台真正认识的接口。

所以可以把 wrapper 理解成一个“翻译层”。

举一个最容易理解的例子

假设 uni 对外想统一成这样:

uni.showToast({
  title: '保存成功'
})

但某个平台底层不是 title,而是 content

Proxy 只能做到:

uni.showToast -> 找到这个平台的方法

真正把参数从:

{ title: '保存成功' }

改成:

{ content: '保存成功' }

这一步必须由 wrapper 做。

也就是说:

  • Proxy 负责“找到人”
  • wrapper 负责“翻译话”

这两个配合起来,开发者才能始终写统一的 uni.xxx

简易实现

下面写一个极简版,只保留 Proxywrapper 两层核心思想。

const wx = {
  showToast(options) {
    console.log('wx.showToast', options)
  },
}

function wrapper(name, method) {
  if (name === 'showToast') {
    return function (options) {
      const newOptions = {
        content: options.title,
      }
      return method(newOptions)
    }
  }
  return method
}

function initUni(platform) {
  return new Proxy(
    {},
    {
      get(target, key) {
        const method = platform[key]
        if (typeof method !== 'function') {
          return undefined
        }
        return wrapper(key, method)
      },
    }
  )
}

const uni = initUni(wx)

uni.showToast({
  title: '保存成功',
})

执行时虽然外部写的是:

uni.showToast({ title: '保存成功' })

但实际到平台方法时,已经被改成了:

wx.showToast({ content: '保存成功' })

总结

不是“用了 Proxy 很高级”,而是职责拆得很清楚:

  • Proxy 只做入口分发
  • wrapper 只做平台适配

这样设计的好处是:

  • uni 对开发者始终保持统一
  • 平台差异被收口在框架内部
  • 后续要支持更多小程序平台时,只需要补适配逻辑,不需要改开发者写法

微信交流群

我有个 uni-app 微信交流群,大家有想进群的可以加我的微信 13460036576

@embedpdf/vue-pdf-viewer内网使用避坑

之前刷到过embedpdf这个新的pdf预览库,就想着把pdfjs-dist或者vue-pdfjs换掉,默认样式非常好看,和前端的设计也很贴近

image.png

默认是使用英语,可以直接配置一下展示中文:

import { PDFViewer } from '@embedpdf/vue-pdf-viewer'

<PDFViewer
  :config="{
    src,
    theme: { preference: 'light' },
    i18n: {
      defaultLocale: 'zh-CN',
      fallbackLocale: 'en',
    },
  }"
/>

但是真实部署测试的时候发现,有些资源会请求cdn

image.png

image.png

经过一段时间在embedpdf文档、npmjs 和 node_modules摸索,可以找到pdfium.wasm在 @embedpdf/snippet 包,manifest.json@embedpdf/default-stamps 包提供

如果使用pnpm这种没有幽灵依赖的包管理,需要手动加一个 @embedpdf/snippet 依赖:

image.png

打包的话,因为我使用了vite,vite本身提供了wasm导入的方式:cn.vitejs.dev/guide/asset…

所以可以直接引入并提供url:

import pdfiumUrl from '@embedpdf/snippet/dist/pdfium.wasm?url'
const wasmUrl = pdfiumUrl.startsWith('http') ? pdfiumUrl : `${window.location.origin}${pdfiumUrl}`

因为dev的时候url引入是/node_modules/xxx的地址,embedpdf似乎会先校验是否是一个正确的URL地址,如果不是就会加载失败。

然后就是manifest.json主要是一些盖章的功能,我感觉大部分人不需要这个,如果不需要的话就可以这样阻止加载文件,解决加载过程中一直阻塞pdf预览的问题。

<PDFViewer
  :config="{
    src,
    theme: { preference: 'light' },
    i18n: {
      defaultLocale: 'zh-CN',
      fallbackLocale: 'en',
    },
    stamp: {
      manifests: [],
    },
    wasmUrl,
  }"
/>

当然你需要加载这个默认json的话可以:

import stampJson from '@embedpdf/default-stamps/zh-CN/manifest.json?no-inline&url'
<PDFViewer
  :config="{
    src,
    theme: { preference: 'light' },
    i18n: {
      defaultLocale: 'zh-CN',
      fallbackLocale: 'en',
    },
    stamp: {
      manifests: [{
          url: stampJson,
        }],
    },
    wasmUrl,
  }"
/>

这个json文件很小,不加no-inline的话打包后会被内联成base64,会和上面的wasm的加载一样报错。json还表示了使用了一个stamp.pdf,如果需要使用盖章还需要把node_modules/@embedpdf/default-stamps/zh-CN/stamps.pdf复制到public/assets/stamps.pdf

至此应该就能完全内网使用了XD

微信小程序:如何优雅地修改富文本(u-parse/rich-text)内部样式?

在微信小程序开发中,渲染后端返回的富文本内容是一个非常常见的需求。为了获得更好的渲染效果(比如图片自适应、图片预览、更全的标签支持),我们通常会放弃原生的 <rich-text> 组件,转而使用第三方富文本解析组件,例如基于 mp-html 封装的 <u-parse>(uView 框架内置组件)。

但在实际开发中,我们经常会遇到修改富文本内部默认样式不生效的“坑”。本文将通过一个真实案例(修改无序列表的默认缩进),带你剖析问题的原因及最终的解决方案。

场景重现:被“无视”的 CSS 样式

最近接到一个需求:产品经理希望页面中通过富文本渲染的 <ul>(无序列表)和 <ol>(有序列表)不要有默认的缩进,而是让列表的点/数字和正文保持左对齐

正常在 Web 开发中,我们只需要几行 CSS 就能搞定:

ul, ol {
  padding-left: 0 !important;
  margin-left: 0 !important;
}
li {
  list-style-position: inside !important;
}

考虑到 Vue 的作用域,我在组件的 <style scoped> 中加上了深度选择器 ::v-deep

::v-deep ul,
::v-deep ol {
  padding-left: 0 !important;
  margin-left: 0 !important;
}
::v-deep li {
  list-style-position: inside !important;
}

结果:页面上的文字依然雷打不动地缩进,样式完全没有生效!

抽丝剥茧:为什么 CSS 选不中元素?

打开微信开发者工具的 WXML 面板审查元素,我发现了第一个盲点:

u-parse(或 mp-html)在解析富文本时,并没有把 <ul><ol> 渲染成真正的 HTML 标签,而是将其转换成了带有特定 class 的 <rich-text> 节点,类名类似于 ._ul._ol.__ul 等。

既然类名变了,那我针对这些特定的 class 再次修改 CSS 规则:

::v-deep ._ul,
::v-deep ._ol,
::v-deep .__ul,
::v-deep .__ol {
  padding-left: 0 !important;
  margin-left: 0 !important;
}

结果:样式在开发者工具的 Styles 面板中成功匹配到了对应的元素,但页面渲染的文字依然存在缩进!

终极真相:原生 <rich-text> 的样式隔离黑盒

这就触及到了小程序的底层机制。

为了提高渲染效率,u-parse 在处理嵌套的富文本时,会将解析后的 DOM 树转换为一个 JSON 节点数组(nodes),并将其整体丢给小程序的原生 <rich-text> 组件去渲染。

而原生的 <rich-text> 存在一个致命的特性:它的内部是一个样式隔离的“黑盒”

<rich-text nodes="{{nodes}}"></rich-text> 内部渲染出的标签,完全不受外部 .wxss.css 样式表的影响(即使你用了 ::v-deep 且选择器成功匹配)。内部节点能且只能读取 JSON 节点树中通过 attrs.style 传递进去的 内联样式(inline-style)

解决方案:从源头注入内联样式

既然外部的 CSS 无法穿透,我们就必须改变思路:让富文本解析器在解析 HTML 字符串时,就直接把我们想要的样式以 style="..." 的形式注入到标签中。

大多数优秀的富文本解析组件都提供了这种能力。在 u-parse (或 mp-html) 中,这个属性叫 tag-style

最终代码实现

1. 在 JS 的 data 中定义标签基础样式

export default {
  data() {
    return {
      htmlContent: '<ul><li>列表项1</li><li>列表项2</li></ul>',
      // 为 u-parse 注入标签默认样式,解决 rich-text 内部无法被外部 CSS 穿透的问题
      parseTagStyle: {
        ul: 'padding-left: 0; margin-left: 0;',
        ol: 'padding-left: 0; margin-left: 0;',
        li: 'list-style-position: inside;'
      }
    };
  }
}

2. 在模板中绑定 tag-style 属性

<template>
  <view class="content-wrapper">
    <u-parse
      :html="htmlContent"
      :tag-style="parseTagStyle"
    ></u-parse>
  </view>
</template>

通过这种方式,解析器会在生成 nodes 树时,自动将 padding-left: 0; margin-left: 0; 写入到 <ul><ol>style 属性中。最终原生 <rich-text> 渲染时,完美实现了列表符号和文字靠左对齐的需求。

总结

在微信小程序中处理富文本渲染样式时,请记住这个避坑指南:

  1. 优先避免使用 CSS 深度选择器(::v-deep)去强行修改富文本内部的样式,因为一旦底层交给了 <rich-text> 渲染,CSS 就大概率会失效。
  2. 永远优先使用解析组件提供的 tag-style(标签样式)功能,在解析阶段通过内联样式的形式注入,这才是小程序环境下最稳定、最正确的做法。

从 SQL DDL 到 ER 图:前端如何优雅地实现数据库可视化

在数据分析平台越来越卷的今天,各家都在琢磨怎么让用户更直观地理解自己的数据。
笔者所在团队维护着一个数据分析平台(技术栈:React18 + Vite + TypeScript + Ant Design),产品同学提了一个需求:用户导入数据库后,希望能以可视化的方式展示表结构及表之间的关系,就像数据库设计工具里的 ER 图那样。

听起来不难是吧?然而后端同学给的数据是——数据库的 DDL 语句。

就这?

好吧,SQL 解析这活儿看来得前端自己想办法了。

需求分析

先捋一下需求:

  1. 输入:用户导入的数据库 DDL 语句(CREATE TABLE 语句)
  2. 输出:可视化的 ER 图,展示表结构、字段信息、表之间的关联关系
  3. 交互:支持拖拽、缩放、节点展开/收起等常见操作

核心问题有两个:

  • SQL 解析:如何把 DDL 语句解析成结构化的表数据?
  • 图渲染:如何把表数据渲染成好看的 ER 图?

技术选型

SQL 解析库

在 GitHub 上一顿搜索,找到了几个候选方案:

库名 Stars 特点
sql.js 13k+ SQLite 的 WebAssembly 版本,偏重执行而非解析
pgsql-ast-parser 200+ 只支持 PostgreSQL
node-sql-parser 1k+ 支持多种数据库,解析成标准 AST

最终选择了 node-sql-parser,原因很简单:

  1. 支持 11 种数据库方言(MySQL、PostgreSQL、SQLite、MariaDB、SQL Server 等)
  2. 解析结果是标准的 AST,方便提取表结构和外键信息
  3. 文档清晰,API 简洁
import { Parser } from "node-sql-parser";

const parser = new Parser();
const ast = parser.astify(`
  CREATE TABLE users (
    id INT PRIMARY KEY,
    name VARCHAR(50)
  )
`, { database: "MySQL" });

console.log(ast);
// 输出完整的 AST 结构

可视化组件

图可视化这块,首先想到的是 D3.js,但 D3 太底层了,画个节点连线都得从头写,不太划算。
继续调研,发现了 React Flow,这个库专门为 React 设计,API 友好,自带很多交互能力:

  • 节点拖拽
  • 画布缩放
  • 小地图导航
  • 连线动画
  • 自定义节点样式

配合 Dagre 布局算法,可以实现节点的自动排列,不用手动调整位置。

import { ReactFlow, Background, MiniMap, Controls } from "@xyflow/react";

function ERDiagram({ nodes, edges }) {
  return (
    <ReactFlow nodes={nodes} edges={edges}>
      <Background />
      <MiniMap />
      <Controls />
    </ReactFlow>
  );
}

整体设计

确定了技术选型,接下来设计整体架构。

数据流

用户输入 SQL DDL
      ↓
node-sql-parser 解析
      ↓
TableData[] + RelationshipData
      ↓
转换为 React Flow 节点和边
      ↓
Dagre 自动布局
      ↓
React Flow 渲染 ER 图

项目结构

sql-to-er-table/
├── client/
│   ├── pages/SqlToER/
│   │   └── SqlToERPage.tsx      # 主页面
│   ├── components/ERDiagram/
│   │   ├── ERDiagram.tsx        # 图容器
│   │   ├── ERNode.tsx           # 自定义表节点
│   │   ├── ERDiagramParser.ts   # 数据转换
│   │   └── utils.ts             # 布局算法
│   └── utils/
│       └── sqlParser.ts         # SQL 解析(API 调用)
│
├── server/
│   ├── services/
│   │   └── sqlParser.ts         # SQL 解析服务
│   └── middleware/
│       └── serveApi.ts          # API 路由
│
└── shared/
    ├── types.ts                 # 共享类型
    └── crypto.ts                # 加密工具

类型定义

定义好数据结构,前后端共享:

// shared/types.ts
export interface ColumnSchema {
  type: string;
  nullable: boolean;
  comment: string;
}

export interface TableData {
  table_name: string;
  name: string;
  comment: string | null;
  schema: Record<string, ColumnSchema>;
  index_info?: {
    primary_key?: string[];
  };
}

export interface RelationshipData {
  relationships: string[][];  // [["orders.user_id", "users.id"], ...]
}

优化点一:SQL 解析放服务端

一开始图省事,SQL 解析直接在浏览器端做。跑起来之后发现一个问题:node-sql-parser 打包后有 410KB+,直接把 client bundle 撑大了一圈。

client未优化体积.png

对于一个工具页面来说,这个体积有点夸张。而且 SQL 解析本身是纯计算任务,放在服务端更合理。

改造思路

  1. 服务端新增 /api/parse-sql 接口,接收 SQL 语句,返回解析结果
  2. 客户端改为调用 API,不再直接依赖 node-sql-parser
  3. 前端 bundle 瞬间瘦身

服务端实现:

// server/services/sqlParser.ts
import nodeSqlParser from "node-sql-parser";
import type { TableData, RelationshipData, DatabaseType } from "../../shared/types";

const { Parser } = nodeSqlParser;
const sqlParser = new Parser();

export function parseSqlToERData(sql: string, database: DatabaseType = "MySQL") {
  const errors: string[] = [];
  const tables: TableData[] = [];
  
  try {
    const result = sqlParser.astify(sql, { database });
    const astList = Array.isArray(result) ? result : [result];
    
    for (const ast of astList) {
      if (ast?.type !== "create" || ast?.keyword !== "table") continue;
      const tableData = parseCreateTableAST(ast);
      tables.push(tableData);
    }
  } catch (err: any) {
    errors.push(`SQL 解析失败:${err?.message}`);
  }
  
  return { tables, relationships, errors };
}

客户端调用:

// client/utils/sqlParser.ts
export async function parseSqlToERData(sql: string, database: DatabaseType = "MySQL") {
  const response = await fetch("/api/parse-sql", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ sql, database }),
  });
  
  return response.json();
}

改造完成后,client bundle 下降了 400KB,效果显著。

  • 改造前:

vite构建未优化前.png

  • 改造后:

vite 优化构建后.png

优化点二:SQL 加密传输

需求评审的时候,安全同学提了一个问题:SQL 语句里可能包含敏感信息(表名、字段名、注释等),明文传输不太合适。

好吧,那就加个密。

加密方案

考虑到是内部系统,不需要非常复杂的加密体系,选择了 AES-256-GCM 对称加密:

  • 加密强度足够
  • 自带认证标签(AuthTag),可以防止数据被篡改
  • 前后端都有成熟的实现

客户端使用 Web Crypto API:

// client/utils/crypto.ts
const ENCRYPTION_KEY = "sql-er-diagram-secret-key-32byte!";

async function getEncryptionKey(): Promise<CryptoKey> {
  const keyData = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(ENCRYPTION_KEY)
  );
  return crypto.subtle.importKey(
    "raw",
    keyData,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt"]
  );
}

export async function encryptSql(sql: string): Promise<string> {
  const key = await getEncryptionKey();
  const iv = crypto.getRandomValues(new Uint8Array(12));
  
  const encrypted = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    new TextEncoder().encode(sql)
  );
  
  // 组合格式: iv:authTag:ciphertext (均为 base64)
  const encryptedArray = new Uint8Array(encrypted);
  const ciphertext = encryptedArray.slice(0, -16);
  const authTag = encryptedArray.slice(-16);
  
  return `${btoa(iv)}:${btoa(authTag)}:${btoa(ciphertext)}`;
}

服务端使用 Node.js crypto 模块解密:

// shared/crypto.ts
import crypto from "crypto";

export function decryptSql(payload: string): string {
  const [ivBase64, authTagBase64, encrypted] = payload.split(":");
  const key = crypto.createHash("sha256").update(ENCRYPTION_KEY).digest();
  const iv = Buffer.from(ivBase64, "base64");
  const authTag = Buffer.from(authTagBase64, "base64");
  
  const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
  decipher.setAuthTag(authTag);
  
  let decrypted = decipher.update(encrypted, "base64", "utf8");
  decrypted += decipher.final("utf8");
  
  return decrypted;
}

现在 SQL 传输流程变成了:

客户端输入 SQL
      ↓
AES-256-GCM 加密
      ↓
POST /api/parse-sql { payload: "加密后的字符串" }
      ↓
服务端解密
      ↓
node-sql-parser 解析
      ↓
返回解析结果

最终效果

经过一番折腾,终于实现了从 SQL DDL 到 ER 图的完整流程:

  1. 用户在输入框粘贴 SQL 语句
  2. 选择数据库类型(支持 MySQL、PostgreSQL 等 11 种)
  3. 点击「生成 ER 图」
  4. 自动渲染出带关系连线的 ER 图
  5. 支持拖拽、缩放、小地图导航

ER效果图.png

总结

技术选型优点

  1. node-sql-parser:支持多种数据库方言,解析结果标准化,满足大部分 DDL 解析需求
  2. React Flow:专为 React 设计的图可视化库,开箱即用,交互体验好
  3. Dagre:经典的图布局算法,自动排列节点位置,省去手动调整的麻烦
  4. AES-256-GCM:加密强度足够,自带完整性校验,前后端都有成熟实现

不足之处

  1. SQL 解析的局限性:node-sql-parser 对一些复杂语法(如存储过程、触发器)支持有限,部分非标准写法可能解析失败
  2. 关系识别依赖外键:目前只能通过 FOREIGN KEY 约束识别表关系,实际业务中很多表并没有显式定义外键
  3. 布局算法的局限:Dagre 是基于层次结构的布局,对于复杂的网状关系,布局效果可能不太理想
  4. 客户端加密的安全性:密钥硬编码在前端代码中,安全性有限,仅适用于内部系统

后续优化方向

  1. 支持通过字段命名规则(如 user_id -> users.id)智能识别表关系
  2. 支持导出 ER 图为图片或 PDF
  3. 考虑使用 WebAssembly 方案,在保证性能的同时减少服务端依赖

项目代码已开源(脱敏处理),欢迎查看 👉 sql-to-er-table

Cursor(vscode) debug for Chrome

本篇文章的内容为如何使用 cursor 调试,通过 xswitch 线上代理的项目。


背景

我们在平时调试的时候,大多数情况下只是在 Chrome 浏览器自带的调试工具下调试,cursor 里也有调试功能,如何用 cursor 去调试我们的代码呢?

cursor 调试的好处?遇到一些问题,我们可以直接修改源代码进行调试,而不是在 chrome 控制台进行调试。

接下来我们一起看下如何使用 cursorchrome 进行连接调试。


前置知识

  • sourceMap 的映射
  • 基础的调试配置

✍️ Cursor 配置

具体配置大家可以通过 AI 和自己实操来进行查看学习。

在项目根目录下创建 .vscode/launch.json

{
  // 指定 launch.json 配置文件的版本号
  "version": "0.2.0",
  // 包含所有调试配置的数组
  "configurations": [
    {
      // 调试配置的显示名称,会在 VS Code 调试面板的下拉菜单中显示
      "name": "Debug: Chrome (XSwitch)",
      // 表示使用 Chrome 浏览器的调试器
      "type": "pwa-chrome",
      // 调试请求类型,"attach" 表示附加到已运行的进程,而不是启动新进程
      "request": "attach",
      // 指定要附加到的进程的端口号
      "port": 9222,
      // 指定 Web 应用的根目录
      "webRoot": "${workspaceFolder}",
      // 指定源代码映射的路径覆盖规则
      "sourceMapPathOverrides": {
        "webpack://rms-prod-front-alarm-/src/*": "${workspaceFolder}/src/*",
        "webpack://rms-prod-front-alarm-/*": "${workspaceFolder}/*"
      },
      // 启用智能步进功能
      "smartStep": true,
      // 指定跳过哪些文件的调试
      "skipFiles": [
        "<node_internals>/**",
        "${workspaceFolder}/node_modules/**",
        "${workspaceFolder}/.umi/**"
      ]
    }
  ]
}

⛷ 浏览器配置

remote-debugging-port

浏览器需要开启一个临时的调试服务器来供 chromevscode 进行通信。

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222

我们可以通过 --remote-debugging-port 为 9222 来指定。

userDataDir

远程调试需要指定 userDataDir 参数。

userDataDir 是保存用户数据的地方,比如你的浏览记录、cookies、插件的数据等等。

我们指定 userDataDirfalse 打开浏览器看看效果:

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=false

image.png

可以看到打开了一个新的浏览器,这个浏览器没有任何数据

image.png 我们在启动的时候加了个调试服务器,输入 localhost:9222/json,这里的调试服务器会出现各页面的临时 websocket 服务器信息。


Chrome 136 版本后的变化

Chrome 136 版本之前,我们可以使用默认的保存数据的地方进行调试,这样打开浏览器之后就有我们之前配置的数据了。

但是在 136 之后,chrome 要求我们打开调试服务器的时候必须要指定一个新的 userDataDir,为了保护我们的数据安全。

参考:developer.chrome.com/blog/remote…


解决方案:复制浏览器缓存目录

那我们要通过 cursor 调试我们的项目,是必须要使用之前的数据的,比如 xswitch、登录信息等等。

我们可以把浏览器缓存的位置复制一份作为一个新的用户目录。

把缓存的文件复制到 /Users/admin/Desktop/chromeStorage2,然后执行以下命令启动浏览器:

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=/Users/admin/Desktop/chromeStorage2

关闭 Chrome 浏览器,执行这个命令,启动浏览器。

image.png

image.png 可以看到浏览器的代理服务器已经正确运行,而且也能正常加载我们的本地缓存数据(xswitch 配置、登录信息等)。


😼 打通 Cursor 与 Chrome

万事俱备,接下来我们就要打通 cursorchrome 的调试了。

前提条件

⚠️ 前提!!!代理的情况下必须保证 sourceMap 有正确映射加载。

确保 console.log 映射的是本地的源文件(如 useProtableProps.tsx:76),而不是编译后的代码。

image.png

sourceMapPathOverrides 配置说明

"sourceMapPathOverrides": {
  "webpack://rms-prod-front-alarm-/src/*": "${workspaceFolder}/src/*",
  "webpack://rms-prod-front-alarm-/*": "${workspaceFolder}/*"
}

这里的 webpack://rms-prod-front-alarm-/src/* 对应的就是当前文件的 webpack 路径。

这里一定要设置好,不然断点就不能正确映射本地文件。

image.png

image.png

如何查看 webpack 路径?在 Chrome DevTools 的 Sources 面板中,找到对应文件,查看其路径前缀即可。

启动调试

  1. cursor 的调试面板选择 Debug: Chrome (XSwitch) 配置

image.png 3. 点击运行(F5)开始调试

  1. 在代码里打上断点
  2. 在浏览器中触发对应操作(如点击查询按钮)

cursor 里就会发现已经代理调试成功了,断点命中,可以看到完整的变量信息和调用堆栈。

image.png


总结

至此我们就做到了在 cursor 里调试代码了!

整体流程:

复制 Chrome 用户数据目录
    ↓
以 --remote-debugging-port=9222 启动 Chrome
    ↓
配置 .vscode/launch.json(注意 sourceMapPathOverrides)
    ↓
确保 devtool 使用 eval-cheap-module-source-map
    ↓
Cursor 附加调试 → 打断点 → 触发操作 → 调试成功 ✅

线上调试代码,试试SourceMap?

线上调试代码,试试 SourceMap?

背景

大家有没有过在开发环境代理调试的时候,只能调试打包之后的代码,而不是调试源码?

那是因为没正确使用 sourceMap


SourceMap

sourceMap 有什么作用?🤔

sourceMap 用于映射编译后的代码与源码,这样如果编译后的代码出错了,可以很快速的定位到源文件的位置。

举个栗子 🌰

image.png

umi 的项目在开发环境默认的 sourceMapcheap-module-source-map

我们把它关了,看看编译的资源有什么不一样的:

image.png

// config.ts
devtool: false,

既然 sourceMap 可以快速定位到源码的位置,为了方便,我们在页面上执行个 console.log,看看 log 位置的源代码:

image.png

console.log('测试 SourceMap')

image.png 打印出来发现我们 log 处的源码是编译之后的

目前不加 SourceMap 的结论 😳

不加 sourceMap,页面上的 log 信息是无法精准定位到源码的位置的。


那我们加了呢?

先试试 bigfish 默认的,我们把 config 里的 devtool 删掉。

image.png

结果发现还是定位不到源码位置,控制台提示:source map failed to load

而且文件最后一行已经显示了 sourceMappingURL 的指定文件。

那是为什么?map 文件加载失败了。

仔细看发现它请求的位置是线上环境的 map 文件,请求了 404。

而 umi 在生产环境不会打开 sourceMap(为了防止其他人可以随意在 source 上看到自己的源码)。

image.png

奇怪的是我们在 Network 里并没发现请求的 map 文件。

image.png

查阅资料发现:

SourceMap 的加载不能从 Network 中看到,而要从 Developer Resources 看到。

打开 Developer Resources:command + shift + p 搜索 Show Developer Resources

image.png

在 Developer Resources 中可以看到 map 文件请求失败:

ERROR LOADING URL http://xxx.alipay.net/static-assets/.../P_ALARMLABELMANAGEMENT_INDEX.ASYNC.JS.MAP:
HTTP ERROR: STATUS CODE 404, NET::ERR_HTTP_RESPONSE_CODE_FAILURE

image.png

用 XSwitch 转发 map 文件行不行?

那我们把这个地址通过 xswitch 代理转发到本地是不是就可以了?

试了下,遗憾的说:不行

image.png

参考资料:zhuanlan.zhihu.com/p/674981525

原因分析:

  1. 只有在 DevTools 打开时才会加载 SourceMap(性能优化 & 用户并不需要)
  2. DevTools 也是一种扩展,而扩展是无法拦截另一个扩展的请求的(安全性问题)
  3. SourceMap 的加载不能从 Network 中看到而要从 Developer Resources 看到(这也是故意的设计)

基于以上信息,Chrome Extension 主要还是用于折腾 Content 区域,而不是希望你 Hack 浏览器。总之很遗憾,我们不能通过 XSwitch 这样的插件,把 SourceMap 文件的请求地址转发到正确的位置。


解决方案:使用不生成新 map 文件的 SourceMap 类型

目前看我们只能使用不生成新 map 文件sourceMap 类型了。

因为 sourceMap 的类型有很多,以下是不会生成新 map 文件的类型对比:

类型 构建速度 调试精度 推荐环境
eval 最快 快速开发
eval-cheap-source-map 行级别 开发环境
eval-cheap-module-source-map 较快 行级别(含 Loader) 开发环境(推荐)
eval-source-map 精确 精确调试
inline-source-map 精确 特殊需求
false 最快 生产环境

AI 推荐我们开发环境用 eval-cheap-module-source-map

image.png

配置如下:

devtool: 'eval-cheap-module-source-map',

验证结果:

✅ 使用 eval-cheap-module-source-map 确实可以定位到源码了!

控制台的 log 信息直接指向了源文件:useProtableProps.tsx:76,而不是编译后的代码。

加了 SourceMap 的结论 🤓

在线上代理的方法上使用默认的 bigfish 的 devtool 值做不到定位源码,我们使用了 eval-cheap-module-source-map,可以在开发环境定位到源码位置,方便调试。


总结 👀

建议大家设置 devtool

  • 开发环境eval-cheap-module-source-map
  • 生产环境false(为了代码安全)
devtool: process.env.NODE_ENV === 'development' ? 'eval-cheap-module-source-map' : false,

又可以开心地 ✍️ 代码了!

Nginx 反向代理

Nginx 反向代理

在实际的项目部署过程中,我们前端经常会使用到 nginx 这台服务器进行部署,后端可能会有多台服务器,那 nginx 服务器会请求多台服务器,请求后端的服务器就是一个反向代理的过程,这么说可能比较抽象,我们结合正向代理解释下。


正向代理

概念

正向代理:是一个位于客户端和目标服务器之间的服务器(代理服务器),为了从目标服务器取得内容,客户端向代理服务器发送一个请求并指定目标,然后代理服务器向目标服务器转交请求并将获得的内容返回给客户端。

案例

比如你想访问 Google,但直接连不上。你连到一个国外的代理服务器,让它帮你去访问 Google,然后把结果传给你,也就是常说的 VPN。

作用

  • 突破访问限制(翻墙)
  • 通过缓存加速访问资源(代理服务器如果已经访问过该资源,可以直接给你,不用再去源站拿)
  • 隐藏客户端真实 IP(目标网站看到的 IP 是代理服务器的,不是你的)

反向代理

概念

反向代理:与正向代理正好相反,反向代理中的代理服务器,代理的是服务器那端。代理服务器接收客户端请求,然后将请求转发给内部网络上的服务器,并将从服务器上得到的结果返回给客户端,此时代理服务器对外表现为一个反向代理服务器的角色。

反向代理服务器位于用户与目标服务器之间,但是对于用户而言,反向代理服务器就相当于目标服务器,即用户直接访问反向代理服务器就可以获得目标服务器的资源。同时,用户不需要知道目标服务器的地址,也无须在用户端作任何设定。

image.png

案例

比如每天几亿人访问淘宝,不可能只有一台服务器,你访问 www.taobao.com 时,请求其实是先到了一个巨大的反向代理服务器,这个代理看到你访问的图片,然后把请求转到负责图片的服务器,看到你访问的是下单页面,然后把请求转发到负责下单的服务器。

作用

  • 负载均衡:把流量分摊给后面 n 台服务器,不让某一台累死。
  • 统一入口:无论后面业务怎么变(服务器加减、换 IP),对用户来说,永远只需要访问这一个网址。

实践

本次实践使用 Docker Compose 搭建一个包含 Nginx 反向代理多个 Node.js 后端服务 的服务架构环境。

.
├── docker-compose.yml    # 容器编排配置:定义了 Nginx 和 3 个 Node 服务
├── nginx.conf            # Nginx 核心配置:定义了反向代理规则和 Upstream
├── index.html            # (可选) 根目录静态页面
└── server/               # Node.js 服务代码目录
    ├── server.js         # 简单的 HTTP 服务器,支持 --port 参数
    ├── index.html        # 服务默认返回的页面
    └── package.json      # 依赖配置

Node 服务器

我们让 AI 帮我生成了一个简易的 node 服务器,代码整体就是使用 node server.js --port=xxxx 可以开启一个服务器,服务器会返回当前的 HTML,HTML 会展示出来当前的端口号(供我们查看当前请求的服务器使用)。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Node.js 临时服务器</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    body {
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      min-height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
      padding: 20px;
    }
    .container {
      background: white;
      padding: 60px 40px;
      border-radius: 20px;
      box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
      max-width: 600px;
      width: 100%;
      text-align: center;
    }
    h1 {
      color: #667eea;
      font-size: 48px;
      margin-bottom: 20px;
    }
    p {
      color: #666;
      font-size: 18px;
      line-height: 1.6;
      margin-bottom: 15px;
    }
    .success {
      background: #d4edda;
      border: 1px solid #c3e6cb;
      color: #155724;
      padding: 15px;
      border-radius: 10px;
      margin-top: 30px;
      font-weight: bold;
    }
    .info-box {
      background: #f8f9fa;
      padding: 20px;
      border-radius: 10px;
      margin-top: 30px;
      text-align: left;
    }
    .info-box h2 {
      color: #764ba2;
      margin-bottom: 15px;
      font-size: 20px;
    }
    .info-box ul {
      list-style: none;
      padding-left: 0;
    }
    .info-box li {
      padding: 8px 0;
      color: #555;
    }
    .info-box li::before {
      content: "✓ ";
      color: #667eea;
      font-weight: bold;
      margin-right: 8px;
    }
    .emoji {
      font-size: 64px;
      margin-bottom: 20px;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="emoji">🚀</div>
    <h1>服务器运行中</h1>
    <p>恭喜!你的 Node.js 临时服务器已经成功启动。</p>
    <p style="font-size: 24px; color: #667eea; font-weight: bold; margin-top: 10px;">
      端口号: ${PORT}
    </p>
    <div class="success">
      ✅ 服务器正常运行在端口 ${PORT}
    </div>
    <div class="info-box">
      <h2>服务器特性</h2>
      <ul>
        <li>静态文件服务</li>
        <li>自动 MIME 类型识别</li>
        <li>美观的 404 错误页面</li>
        <li>请求日志记录</li>
        <li>支持多种文件类型</li>
      </ul>
    </div>
    <p style="margin-top: 30px; color: #999; font-size: 14px;">
      当前时间: <span id="time"></span>
    </p>
  </div>
  <script>
    function updateTime() {
      const now = new Date();
      document.getElementById('time').textContent = now.toLocaleString('zh-CN');
    }
    updateTime();
    setInterval(updateTime, 1000);
  </script>
</body>
</html>
const http = require('http');
const fs = require('fs');
const path = require('path');

// 从命令行参数中获取端口号
const args = process.argv.slice(2);
const portIndex = args.indexOf('--port');
const PORT = portIndex !== -1 ? parseInt(args[portIndex + 1]) : 3000;

console.log('PORT', PORT);

// MIME 类型映射
const mimeTypes = {
  '.html': 'text/html',
  '.css': 'text/css',
  '.js': 'text/javascript',
  '.json': 'application/json',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.gif': 'image/gif',
  '.svg': 'image/svg+xml',
  '.ico': 'image/x-icon',
  '.txt': 'text/plain'
};

const server = http.createServer((req, res) => {
  console.log(`[${new Date().toISOString()}] ${PORT} ${req.method} ${req.url}`);

  // 处理根路径
  let filePath = req.url === '/' ? '/index.html' : req.url;
  filePath = path.join(__dirname, filePath);

  // 获取文件扩展名
  const extname = path.extname(filePath).toLowerCase();
  const contentType = mimeTypes[extname] || 'application/octet-stream';

  // 读取并返回文件
  fs.readFile(filePath, (err, content) => {
    if (err) {
      if (err.code === 'ENOENT') {
        res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
        res.end(`
          <!DOCTYPE html>
          <html>
          <head>
            <meta charset="utf-8">
            <title>404 - 页面未找到</title>
            <style>
              body {
                font-family: Arial, sans-serif;
                display: flex;
                justify-content: center;
                align-items: center;
                height: 100vh;
                margin: 0;
                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
              }
              .container {
                text-align: center;
                color: white;
                padding: 40px;
                background: rgba(255, 255, 255, 0.1);
                border-radius: 10px;
              }
              h1 { font-size: 72px; margin: 0; }
              p { font-size: 24px; }
            </style>
          </head>
          <body>
            <div class="container">
              <h1>404</h1>
              <p>页面未找到</p>
              <p style="font-size: 16px;">请求路径: ${req.url}</p>
            </div>
          </body>
          </html>
        `);
      } else {
        res.writeHead(500);
        res.end(`服务器错误: ${err.code}`);
      }
    } else {
      let fileContent = content;
      // 如果是 HTML 文件,替换 ${PORT} 占位符
      if (extname === '.html') {
        fileContent = content.toString('utf-8').replace(/\$\{PORT\}/g, PORT);
      }
      res.writeHead(200, { 'Content-Type': contentType });
      res.end(fileContent, 'utf-8');
    }
  });
});

server.listen(PORT, () => {
  console.log(`🚀 服务器运行在 http://localhost:${PORT}`);
  console.log(`📁 服务目录: ${__dirname}`);
  console.log(`按 Ctrl+C 停止服务器`);
});

docker-compose 配置

version: '3'
services:
  web:
    image: nginx
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
    depends_on:
      - api-server
      - frontend-server
      - backend-server
    container_name: nginx-server

  # 模拟 API 服务 (对应 8080 需求)
  api-server:
    image: node:18-alpine
    working_dir: /app
    volumes:
      - ./server:/app
    command: node server.js --port 8080
    expose:
      - "8080"   # 只暴露给 Docker 网络,不暴露给宿主机
    container_name: api-server

  # 模拟前端服务 (对应 8081 需求)
  frontend-server:
    image: node:18-alpine
    working_dir: /app
    volumes:
      - ./server:/app
    command: node server.js --port 8081
    expose:
      - "8081"
    container_name: frontend-server

  # 模拟后端服务 (对应 8082 需求)
  backend-server:
    image: node:18-alpine
    working_dir: /app
    volumes:
      - ./server:/app
    command: node server.js --port 8082
    expose:
      - "8082"
    container_name: backend-server

由于我们要同时启动多个容器,所以使用了 docker-compose 配置了多个容器启动的配置。

简单解释下:

nginx-server

当前配置会启动 1 台 nginx 服务器,nginx 服务器会对外暴露一个 80 端口供宿主机访问。由于我们不方便改 nginx 容器内部的文件,所以使用了目录卷映射了当前目录下的 nginx.conf 为容器内的 nginx.conf。当前容器的启动依赖 api-server、frontend-server 和 backend-server 三个服务,等这三个服务启动后才会启动当前服务。

api-server

api-server:                      # Docker Compose 中的服务名称
    image: node:18-alpine        # 使用的镜像。Alpine 是一个超轻量级的 Linux 发行版,适合做基础镜像

    working_dir: /app            # 设置容器内的工作目录。后续的命令都会在这个目录下执行

    volumes:
      - ./server:/app            # 挂载卷:把本地当前目录下的 server 文件夹,映射到容器里的 /app
                                 # 这样你在本地修改代码,容器里能实时看到

    command: node server.js --port 8080  # 容器启动后默认执行的命令
                                         # 这里启动了 node 服务,并指定端口参数

    expose:
      - "8080"                   # 只是告诉 Docker 网络里的其他容器(如 Nginx):"我有这个端口可用"

    container_name: api-server   # 显式指定容器的名字。如果不写,Docker 会自动生成类似 folder_api-server_1 的名字

剩余两个配置一样。

Nginx 配置

当前的这个 nginx.conf 配置就是配置 nginx 反向代理服务器的核心文件。

#user  nobody;
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    sendfile        on;
    keepalive_timeout  65;

    # 定义 upstream,指向 docker-compose 里的服务名
    upstream api_pool {
        server api-server:8080;
    }

    upstream frontend_pool {
        server frontend-server:8081;
    }

    upstream backend_pool {
        server backend-server:8082;
    }

    upstream proxy_pool {
        server api-server:8080;
        server frontend-server:8081;
        server backend-server:8082;
    }

    server {
        listen       80;
        server_name  localhost;

        # 1. 访问 /api/ -> 代理给 api-server (8080)
        location /api/ {
            proxy_pass http://api_pool/;
        }

        # 2. 访问 / (根路径) -> 代理给 frontend-server (8081)
        location / {
            proxy_pass http://frontend_pool/;
        }

        # 3. 访问 /backend/ -> 代理给 backend-server (8082)
        location /backend/ {
            proxy_pass http://backend_pool/;
        }

        location /proxy/ {
            proxy_pass http://proxy_pool/;
        }
    }
}

该文件定义了 4 个 upstream 和 4 个 location。

upstream
upstream api_pool {
    # server <主机名>:<端口>;
    server api-server:8080;
}
  • 作用:定义了一个叫 api_pool 的组,里面只有一台机器 api-server:8080
  • 主机名解析:这里的 api-server 对应 docker-compose.yml 里的服务名。Docker 会自动把它解析成对应容器的内网 IP。
  • 使用场景:我希望把 /api/ 的流量专门导向 api_pool 服务时使用。
upstream proxy_pool {
    server api-server:8080;
    server frontend-server:8081;
    server backend-server:8082;
}
  • 作用:定义了一个叫 proxy_pool 的组,里面有三台机器。
  • 负载均衡:当 Nginx 把请求转发给 http://proxy_pool 时,它会默认使用轮询算法
    • 第一个请求 -> 转发给 api-server
    • 第二个请求 -> 转发给 frontend-server
    • 第三个请求 -> 转发给 backend-server
    • 第四个请求 -> 回到 api-server ...
  • 使用场景:用户访问同一个 URL,看到的响应会在不同的服务之间切换,用于演示反向代理和负载均衡的场景。
location
location /api/ {
    proxy_pass http://api_pool/;
}

配置含义:当匹配到 /api/ 时,将其转发给 api_pool

location /proxy/ {
    proxy_pass http://proxy_pool/;
}

当请求 /proxy/ 时,将其转到 proxy_pool 这个组内,nginx 会自动帮我们进行负载均衡的处理。

运行验证

目标 命令
一键启动 docker compose up -d
查看日志 docker compose logs -f
停止并清理 docker compose down
进入容器 docker compose exec <服务名> bash
查看状态 docker compose ps

在项目根目录下运行 docker compose up -d,显示启动完成了。

由于我们在 compose 里对外暴露的端口号是 80,我们访问 localhost,显示服务器已经运行成功,当前请求的服务器端口号为 8081,符合我们的配置(根路径 / 代理到 frontend-server:8081)。

访问 /api 请求了 8080 这个服务器,这样就实现了反向代理。

我们试试负载均衡前缀 /proxy,首次进来访问的是 8080 这个服务器,刷新一下会访问 8081 服务器,再次刷新会访问 8082 服务器,这样我们就做了一个简易的负载均衡的示例,用户请求过来,服务器的压力就会小很多。

Flutter组件封装:Sliver 中的 Container 对应组件NSliverContainer

一、需求来源

项目中遇到一些代 Sliver 老代码,需要经常调整显示样式,感觉特别麻烦就想封装一个 Container 那样功能比较丰富的基础组件。

const NSliverContainer({
  super.key,
  required this.sliver,
  this.margin,
  this.padding,
  this.decoration,
  this.foregroundPadding,
  this.foregroundDecoration,
  this.opacity,
  this.ignoring,
  this.offstage,
});

/// 外边距
final EdgeInsetsGeometry? margin;

/// 内边距
final EdgeInsetsGeometry? padding;

/// 背景装饰器
final Decoration? decoration;

/// 前景装饰器内间距
final EdgeInsetsGeometry? foregroundPadding;

/// 前景装饰器
final Decoration? foregroundDecoration;

/// 透明度
final double? opacity;

/// 是否忽略事件
final bool? ignoring;

/// 是否 offstage
final bool? offstage;

simulator_screenshot_337523A2-F925-465B-B63C-A6BC1368526B.png_副本.png

二、使用示例

Widget buildSliverContainer() {
  return NSliverContainer(
    margin: const EdgeInsets.all(8),
    padding: const EdgeInsets.all(8),
    decoration: BoxDecoration(
      color: Colors.purple[50],
      borderRadius: const BorderRadius.all(Radius.circular(8)),
      border: Border.all(color: Colors.blue),
    ),
    foregroundPadding: const EdgeInsets.all(8),
    foregroundDecoration: BoxDecoration(
      color: Colors.green.withOpacity(0.6),
      borderRadius: const BorderRadius.all(Radius.circular(24)),
      border: Border.all(color: Colors.blue),
      image: DecorationImage(
        image: AssetImage(Assets.imagesBgJiguang),
      ),
    ),
    // opacity: 0.3,
    // offstage: true,
    sliver: SliverPadding(
      padding: const EdgeInsets.all(0.0),
      sliver: SliverList.list(
        children: [
          Text("NSliverContainer"),
          Text("NSliverContainer1"),
          Text("NSliverContainer2"),
        ],
      ),
    ),
  );
}

三、源码 NSliverContainer

import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';

/// sliver 族 Container
class NSliverContainer extends StatelessWidget {
  const NSliverContainer({
    super.key,
    required this.sliver,
    this.margin,
    this.padding,
    this.decoration,
    this.foregroundPadding,
    this.foregroundDecoration,
    this.opacity,
    this.ignoring,
    this.offstage,
  });

  /// 外边距
  final EdgeInsetsGeometry? margin;

  /// 内边距
  final EdgeInsetsGeometry? padding;

  /// 背景装饰器
  final Decoration? decoration;

  /// 前景装饰器内间距
  final EdgeInsetsGeometry? foregroundPadding;

  /// 前景装饰器
  final Decoration? foregroundDecoration;

  /// 透明度
  final double? opacity;

  /// 是否忽略事件
  final bool? ignoring;

  /// 是否 offstage
  final bool? offstage;

  /// 子组件
  final Widget sliver;

  @override
  Widget build(BuildContext context) {
    Widget current = sliver;

    /// padding
    if (padding != null) {
      current = SliverPadding(
        padding: padding!,
        sliver: current,
      );
    }

    if (foregroundDecoration != null) {
      current = DecoratedSliver(
        decoration: foregroundDecoration!,
        position: DecorationPosition.foreground,
        sliver: current,
      );
    }

    if (foregroundPadding != null) {
      current = SliverPadding(
        padding: foregroundPadding!,
        sliver: current,
      );
    }

    /// decoration
    if (decoration != null) {
      current = DecoratedSliver(
        decoration: decoration!,
        sliver: current,
      );
    }

    /// margin(最外层)
    if (margin != null) {
      current = SliverPadding(
        padding: margin!,
        sliver: current,
      );
    }

    if (opacity != null) {
      current = SliverOpacity(
        opacity: opacity!,
        sliver: current,
      );
    }

    if (ignoring != null) {
      current = SliverIgnorePointer(
        ignoring: ignoring!,
        sliver: current,
      );
    }

    if (offstage != null) {
      current = SliverOffstage(
        offstage: offstage!,
        sliver: current,
      );
    }

    return current;
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('margin', margin, defaultValue: null));
    properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null));
    properties.add(DiagnosticsProperty<Decoration>('bg', decoration, defaultValue: null));
    properties.add(DiagnosticsProperty<Decoration>('fg', foregroundDecoration, defaultValue: null));
    properties.add(DiagnosticsProperty<double>('opacity', opacity, defaultValue: null));
    properties.add(DiagnosticsProperty<bool>('ignoring', ignoring, defaultValue: null));
    properties.add(DiagnosticsProperty<bool>('offstage', offstage, defaultValue: null));
  }
}

最后、总结

1、NSliverContainer 基于 SliverPadding、 DecoratedSliver、 SliverOpacity、 SliverIgnorePointer、 SliverOffstage 等 sliver 官方组件组合而成,可以放心使用。

github

超越 useState:掌握 React 进阶状态模式

useState 是 React 状态管理的主力。处理简单场景绰绰有余——一个控制弹窗的布尔值、一个输入框的字符串、一个计数器的数字。但需求稍微复杂一点——你需要上一次渲染的值、想对搜索词做防抖、要写一个既能受控又能非受控的组件——你就会发现自己反反复复写着同样的模板代码。用 ref 存旧值、清理 setTimeout 的 ID、受控和非受控的协调逻辑很快就变成一堆纠缠不清的 useEffect

本文将带你走过七种超越基础 useState 的状态模式。每种模式我们先展示手动实现,让你看清其中的门道,然后用 ReactUse 中专门的 Hook 替换。最后,我们会把七个 Hook 组合进一个交互式设置面板,展示它们如何无缝协作。

1. 受控 vs 非受控组件:useControlled

痛点

可复用的 UI 组件通常需要支持两种模式:受控(父组件持有状态,传入 value + onChange)和非受控(组件自行管理内部状态,可选接受 defaultValue)。同时支持两种模式是 MUI、Radix 等成熟组件库的标配——但实现起来出乎意料地繁琐。

手动实现

import { useCallback, useRef, useState } from "react";

interface CustomInputProps {
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
}

function CustomInput({ value, defaultValue = "", onChange }: CustomInputProps) {
  const isControlled = value !== undefined;
  const [internalValue, setInternalValue] = useState(defaultValue);

  // 用 ref 始终持有最新的受控值
  const valueRef = useRef(value);
  valueRef.current = value;

  const currentValue = isControlled ? value : internalValue;

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const next = e.target.value;
      if (!isControlled) {
        setInternalValue(next);
      }
      onChange?.(next);
    },
    [isControlled, onChange]
  );

  return (
    <input
      value={currentValue}
      onChange={handleChange}
      style={{
        padding: "8px 12px",
        border: "1px solid #d1d5db",
        borderRadius: 6,
        fontSize: 16,
      }}
    />
  );
}

对于一个简单输入框来说够用了。但当受控值从外部变更时(需要同步)、当你要提醒开发者不要在受控和非受控之间切换时、当值是复杂对象而非基本类型时,这套逻辑就越来越复杂。每个需要双模式的组件都在重复同一段代码。

用 useControlled

useControlled 封装了整套受控/非受控协调逻辑,返回一个 [value, setValue] 元组,无论使用者选择哪种模式都能正常工作。

import { useControlled } from "@reactuses/core";

interface CustomInputProps {
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
}

function CustomInput({ value, defaultValue = "", onChange }: CustomInputProps) {
  const [currentValue, setCurrentValue] = useControlled({
    value,
    defaultValue,
    onChange,
  });

  return (
    <input
      value={currentValue}
      onChange={(e) => setCurrentValue(e.target.value)}
      style={{
        padding: "8px 12px",
        border: "1px solid #d1d5db",
        borderRadius: 6,
        fontSize: 16,
      }}
    />
  );
}

// 非受控用法——组件自行管理状态
function UncontrolledDemo() {
  return <CustomInput defaultValue="hello" />;
}

// 受控用法——父组件持有状态
function ControlledDemo() {
  const [text, setText] = useState("");
  return <CustomInput value={text} onChange={setText} />;
}

一次 Hook 调用就替代了 ref、isControlled 判断和双路径更新逻辑。组件在两种模式下行为完全一致,即使开发者意外地在受控和非受控之间切换,Hook 也能从容应对。

2. 追踪前一个值:usePrevious

痛点

你经常需要上一次渲染的值——用来比较 prop 是否变化、在新旧值之间做过渡动画、或者显示"从 X 变成了 Y"的 UI 反馈。React 没有内置这个能力。

手动实现

import { useEffect, useRef, useState } from "react";

function PriceDisplay({ price }: { price: number }) {
  const prevPriceRef = useRef<number | undefined>(undefined);

  useEffect(() => {
    prevPriceRef.current = price;
  });

  const prevPrice = prevPriceRef.current;
  const direction =
    prevPrice === undefined
      ? "neutral"
      : price > prevPrice
        ? "up"
        : price < prevPrice
          ? "down"
          : "neutral";

  return (
    <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
      <span style={{ fontSize: 32, fontWeight: 700 }}>
        ${price.toFixed(2)}
      </span>
      {direction === "up" && (
        <span style={{ color: "#16a34a", fontSize: 20 }}></span>
      )}
      {direction === "down" && (
        <span style={{ color: "#dc2626", fontSize: 20 }}></span>
      )}
      {prevPrice !== undefined && prevPrice !== price && (
        <span style={{ color: "#6b7280", fontSize: 14 }}>
          之前是 ${prevPrice.toFixed(2)}
        </span>
      )}
    </div>
  );
}

ref 加 effect 的技巧能用,但容易出错。如果把 effect 放在渲染逻辑之前(或者不该用 useLayoutEffect 的地方用了),"前值"可能会变成过期或当前的值。而且每个需要变更检测的组件都要复制这段样板代码。

用 usePrevious

usePrevious 返回上一次渲染的值,时机精确——在当前渲染期间你始终看到的是旧值。

import { usePrevious } from "@reactuses/core";

function PriceDisplay({ price }: { price: number }) {
  const prevPrice = usePrevious(price);

  const direction =
    prevPrice === undefined
      ? "neutral"
      : price > prevPrice
        ? "up"
        : price < prevPrice
          ? "down"
          : "neutral";

  return (
    <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
      <span style={{ fontSize: 32, fontWeight: 700 }}>
        ${price.toFixed(2)}
      </span>
      {direction === "up" && (
        <span style={{ color: "#16a34a", fontSize: 20 }}></span>
      )}
      {direction === "down" && (
        <span style={{ color: "#dc2626", fontSize: 20 }}></span>
      )}
      {prevPrice !== undefined && prevPrice !== price && (
        <span style={{ color: "#6b7280", fontSize: 14 }}>
          之前是 ${prevPrice.toFixed(2)}
        </span>
      )}
    </div>
  );
}

function StockTicker() {
  const [price, setPrice] = useState(142.5);

  return (
    <div style={{ padding: 24 }}>
      <PriceDisplay price={price} />
      <div style={{ marginTop: 16, display: "flex", gap: 8 }}>
        <button onClick={() => setPrice((p) => p + Math.random() * 5)}>
          涨价
        </button>
        <button onClick={() => setPrice((p) => p - Math.random() * 5)}>
          跌价
        </button>
      </div>
    </div>
  );
}

不需要 ref,不需要 effect。一行代码就能拿到前值,并且与 React 的渲染周期精确同步。

3. 防抖状态:useDebounce

痛点

搜索输入框、过滤字段、实时预览编辑器都面临同一个问题:每次按键都更新状态会触发昂贵的操作(API 请求、重渲染、复杂过滤),频率远超必要。防抖——等用户停止输入指定时间后再触发——是标准解决方案。

手动实现

import { useEffect, useRef, useState } from "react";

function ManualDebouncedSearch() {
  const [query, setQuery] = useState("");
  const [debouncedQuery, setDebouncedQuery] = useState("");
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  useEffect(() => {
    timerRef.current = setTimeout(() => {
      setDebouncedQuery(query);
    }, 300);

    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, [query]);

  // 卸载时清理
  useEffect(() => {
    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, []);

  return (
    <div style={{ padding: 24 }}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
        style={{
          padding: "8px 12px",
          border: "1px solid #d1d5db",
          borderRadius: 6,
          width: 300,
          fontSize: 16,
        }}
      />
      <p style={{ color: "#6b7280", marginTop: 8 }}>
        防抖后的值: <strong>{debouncedQuery}</strong>
      </p>
      <p style={{ color: "#9ca3af", fontSize: 14 }}>
        (这个值会触发 API 请求)
      </p>
    </div>
  );
}

两个状态变量、一个存定时器的 ref、一个调度防抖的 effect、另一个处理卸载清理的 effect。能用,但对于一个几十个组件都需要的功能来说,仪式感太重了。

用 useDebounce

useDebounce 返回任意值的防抖版本。你正常更新源状态,Hook 会产出一个滞后的副本,只在指定的静默期之后才更新。

import { useDebounce } from "@reactuses/core";
import { useState } from "react";

function DebouncedSearch() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);

  return (
    <div style={{ padding: 24 }}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
        style={{
          padding: "8px 12px",
          border: "1px solid #d1d5db",
          borderRadius: 6,
          width: 300,
          fontSize: 16,
        }}
      />
      <p style={{ color: "#6b7280", marginTop: 8 }}>
        防抖后的值: <strong>{debouncedQuery}</strong>
      </p>
      {query !== debouncedQuery && (
        <p style={{ color: "#f59e0b", fontSize: 14 }}>
          等待输入停止...
        </p>
      )}
    </div>
  );
}

一个 Hook,一行代码。定时器管理、清理和同步全部在内部处理。比较 query !== debouncedQuery 还能免费实现"输入中"指示。

4. 节流状态:useThrottle

痛点

节流是防抖的近亲。不同于等待静默,它确保更新在每个时间间隔内最多触发一次——适用于连续触发的事件,比如滚动位置、鼠标移动或实时数据流,你想要的是稳定的更新频率而非末尾的一次性爆发。

手动实现

import { useEffect, useRef, useState } from "react";

function ManualThrottledSlider() {
  const [value, setValue] = useState(50);
  const [throttledValue, setThrottledValue] = useState(50);
  const lastRun = useRef(Date.now());
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  useEffect(() => {
    const now = Date.now();
    const elapsed = now - lastRun.current;
    const delay = 200;

    if (elapsed >= delay) {
      setThrottledValue(value);
      lastRun.current = now;
    } else {
      timerRef.current = setTimeout(() => {
        setThrottledValue(value);
        lastRun.current = Date.now();
      }, delay - elapsed);
    }

    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, [value]);

  return (
    <div style={{ padding: 24 }}>
      <input
        type="range"
        min={0}
        max={100}
        value={value}
        onChange={(e) => setValue(Number(e.target.value))}
        style={{ width: 300 }}
      />
      <div style={{ marginTop: 12 }}>
        <p>原始值: {value}</p>
        <p>节流值: {throttledValue}</p>
      </div>
    </div>
  );
}

节流逻辑很容易写错。你需要追踪上次执行时间、处理末尾调用(保证最终值不丢失)、清理定时器。而且这只是针对单个值——每个需要节流的状态都得重复全部逻辑。

用 useThrottle

useThrottle 返回值的节流版本,在每个间隔内最多更新一次,同时确保最终值始终被捕获。

import { useThrottle } from "@reactuses/core";
import { useState } from "react";

function ThrottledSlider() {
  const [value, setValue] = useState(50);
  const throttledValue = useThrottle(value, 200);

  return (
    <div style={{ padding: 24 }}>
      <input
        type="range"
        min={0}
        max={100}
        value={value}
        onChange={(e) => setValue(Number(e.target.value))}
        style={{ width: 300 }}
      />
      <div style={{ marginTop: 12 }}>
        <p>原始值: {value}</p>
        <p>节流值: {throttledValue}</p>
      </div>
      <div
        style={{
          marginTop: 16,
          height: 20,
          width: `${throttledValue}%`,
          background: "#4f46e5",
          borderRadius: 4,
          transition: "width 0.1s",
        }}
      />
    </div>
  );
}

进度条以 200ms 的间隔平滑更新,而不是在滑块每移动一个像素时都抖动。一行代码搞定所有时序逻辑。

5. 循环选项:useCycleList

痛点

很多 UI 控件需要在一组固定选项中循环:主题切换(亮色 / 暗色 / 跟随系统)、排序方式(升序 / 降序 / 无序)、视图模式(网格 / 列表 / 紧凑)。常规做法是用一个状态变量加一个手动计算下一个值的函数。

手动实现

import { useState } from "react";

type ViewMode = "grid" | "list" | "compact";
const viewModes: ViewMode[] = ["grid", "list", "compact"];

function ManualViewToggle() {
  const [index, setIndex] = useState(0);
  const mode = viewModes[index];

  const next = () => setIndex((i) => (i + 1) % viewModes.length);
  const prev = () =>
    setIndex((i) => (i - 1 + viewModes.length) % viewModes.length);

  const icons: Record<ViewMode, string> = {
    grid: "▦",
    list: "☰",
    compact: "═",
  };

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button onClick={prev} style={{ fontSize: 20, cursor: "pointer" }}></button>
        <div
          style={{
            padding: "8px 16px",
            background: "#f1f5f9",
            borderRadius: 8,
            fontSize: 18,
            minWidth: 120,
            textAlign: "center",
          }}
        >
          {icons[mode]} {mode}
        </div>
        <button onClick={next} style={{ fontSize: 20, cursor: "pointer" }}></button>
      </div>
    </div>
  );
}

单个切换够简单了,但取模运算和独立的索引追踪是每个需要循环行为的地方都会出现的样板代码。它也不支持直接跳转到某个值或响应列表变化。

用 useCycleList

useCycleList 管理数组值的循环,提供 nextprev 以及直接跳转的 go 函数,连同当前值和索引。

import { useCycleList } from "@reactuses/core";

type ViewMode = "grid" | "list" | "compact";

function ViewToggle() {
  const [mode, { next, prev }] = useCycleList<ViewMode>(
    ["grid", "list", "compact"]
  );

  const icons: Record<ViewMode, string> = {
    grid: "▦",
    list: "☰",
    compact: "═",
  };

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button onClick={prev} style={{ fontSize: 20, cursor: "pointer" }}></button>
        <div
          style={{
            padding: "8px 16px",
            background: "#f1f5f9",
            borderRadius: 8,
            fontSize: 18,
            minWidth: 120,
            textAlign: "center",
          }}
        >
          {icons[mode]} {mode}
        </div>
        <button onClick={next} style={{ fontSize: 20, cursor: "pointer" }}></button>
      </div>
    </div>
  );
}

不用管索引,不用做取模运算。Hook 直接给你当前值和导航函数。用来做主题切换——点击在亮色、暗色、跟随系统之间循环——特别顺手。

6. 数值状态:useCounter

痛点

计数器无处不在——电商的数量选择器、分页控件、步骤指示器、缩放级别。每个都需要递增、递减、重置,通常还需要最小/最大值钳位。每次从头写这些很乏味。

手动实现

import { useCallback, useState } from "react";

function ManualQuantityPicker() {
  const [count, setCount] = useState(1);
  const min = 1;
  const max = 99;

  const increment = useCallback(
    () => setCount((c) => Math.min(c + 1, max)),
    [max]
  );
  const decrement = useCallback(
    () => setCount((c) => Math.max(c - 1, min)),
    [min]
  );
  const reset = useCallback(() => setCount(1), []);

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button
          onClick={decrement}
          disabled={count <= min}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count <= min ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count <= min ? "not-allowed" : "pointer",
          }}
        >
          -
        </button>
        <span style={{ fontSize: 24, fontWeight: 600, minWidth: 40, textAlign: "center" }}>
          {count}
        </span>
        <button
          onClick={increment}
          disabled={count >= max}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count >= max ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count >= max ? "not-allowed" : "pointer",
          }}
        >
          +
        </button>
        <button onClick={reset} style={{ marginLeft: 12, fontSize: 14, color: "#6b7280" }}>
          重置
        </button>
      </div>
    </div>
  );
}

钳位逻辑、禁用状态、记忆化回调——全是标准样板代码,应用里每个计数器都在重复。

用 useCounter

useCounter 开箱即用地提供 countincdecsetreset,并支持可选的最小/最大值边界。

import { useCounter } from "@reactuses/core";

function QuantityPicker() {
  const [count, { inc, dec, reset }] = useCounter(1, {
    min: 1,
    max: 99,
  });

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button
          onClick={() => dec()}
          disabled={count <= 1}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count <= 1 ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count <= 1 ? "not-allowed" : "pointer",
          }}
        >
          -
        </button>
        <span style={{ fontSize: 24, fontWeight: 600, minWidth: 40, textAlign: "center" }}>
          {count}
        </span>
        <button
          onClick={() => inc()}
          disabled={count >= 99}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count >= 99 ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count >= 99 ? "not-allowed" : "pointer",
          }}
        >
          +
        </button>
        <button onClick={reset} style={{ marginLeft: 12, fontSize: 14, color: "#6b7280" }}>
          重置
        </button>
      </div>
    </div>
  );
}

Hook 在内部处理钳位。你只需传一次 minmax,递增递减时永远不用担心越界。

7. 类组件风格 setState:useSetState

痛点

React 类组件的 setState 有一个很方便的特性:接受一个部分对象,然后合并到已有状态中。但 hooks 里的 useState 是整体替换。如果你的状态是一个多字段对象,每次更新都得展开:setState(prev => ({ ...prev, name: 'new' }))。对于字段很多的复杂表单或设置对象,这种写法既冗长又容易出错(忘了展开会无声地丢失字段)。

手动实现

import { useCallback, useState } from "react";

interface FormState {
  name: string;
  email: string;
  role: string;
  notifications: boolean;
}

function ManualSettingsForm() {
  const [state, setFullState] = useState<FormState>({
    name: "",
    email: "",
    role: "viewer",
    notifications: true,
  });

  // 每次更新都必须展开上一个状态
  const setState = useCallback(
    (patch: Partial<FormState>) =>
      setFullState((prev) => ({ ...prev, ...patch })),
    []
  );

  return (
    <form style={{ padding: 24, display: "flex", flexDirection: "column", gap: 12, maxWidth: 400 }}>
      <input
        value={state.name}
        onChange={(e) => setState({ name: e.target.value })}
        placeholder="姓名"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <input
        value={state.email}
        onChange={(e) => setState({ email: e.target.value })}
        placeholder="邮箱"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <select
        value={state.role}
        onChange={(e) => setState({ role: e.target.value })}
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      >
        <option value="viewer">查看者</option>
        <option value="editor">编辑者</option>
        <option value="admin">管理员</option>
      </select>
      <label style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <input
          type="checkbox"
          checked={state.notifications}
          onChange={(e) => setState({ notifications: e.target.checked })}
        />
        邮件通知
      </label>
      <pre style={{ background: "#f8fafc", padding: 12, borderRadius: 6, fontSize: 13 }}>
        {JSON.stringify(state, null, 2)}
      </pre>
    </form>
  );
}

你得自己创建合并用的 setState 包装器。如果团队里其他开发者忘了用这个包装器,直接拿部分对象调 setFullState,字段就会无声消失。

用 useSetState

useSetState 的行为和类组件的 setState 一样——传入部分对象,自动合并到已有状态中。

import { useSetState } from "@reactuses/core";

interface FormState {
  name: string;
  email: string;
  role: string;
  notifications: boolean;
}

function SettingsForm() {
  const [state, setState] = useSetState<FormState>({
    name: "",
    email: "",
    role: "viewer",
    notifications: true,
  });

  return (
    <form style={{ padding: 24, display: "flex", flexDirection: "column", gap: 12, maxWidth: 400 }}>
      <input
        value={state.name}
        onChange={(e) => setState({ name: e.target.value })}
        placeholder="姓名"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <input
        value={state.email}
        onChange={(e) => setState({ email: e.target.value })}
        placeholder="邮箱"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <select
        value={state.role}
        onChange={(e) => setState({ role: e.target.value })}
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      >
        <option value="viewer">查看者</option>
        <option value="editor">编辑者</option>
        <option value="admin">管理员</option>
      </select>
      <label style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <input
          type="checkbox"
          checked={state.notifications}
          onChange={(e) => setState({ notifications: e.target.checked })}
        />
        邮件通知
      </label>
      <pre style={{ background: "#f8fafc", padding: 12, borderRadius: 6, fontSize: 13 }}>
        {JSON.stringify(state, null, 2)}
      </pre>
    </form>
  );
}

Hook 返回的 setState 接受部分对象并自动合并。不需要包装函数,不存在意外替换整个状态的风险。

融会贯通:一个设置面板

这些 Hook 天然可组合。下面是一个综合运用全部七个 Hook 的设置面板:

import {
  useControlled,
  usePrevious,
  useDebounce,
  useThrottle,
  useCycleList,
  useCounter,
  useSetState,
} from "@reactuses/core";
import { useState } from "react";

// 一个受控/非受控搜索输入框
function SearchInput({
  value,
  defaultValue,
  onChange,
}: {
  value?: string;
  defaultValue?: string;
  onChange?: (v: string) => void;
}) {
  const [currentValue, setCurrentValue] = useControlled({
    value,
    defaultValue: defaultValue ?? "",
    onChange,
  });

  return (
    <input
      value={currentValue}
      onChange={(e) => setCurrentValue(e.target.value)}
      placeholder="搜索设置..."
      style={{
        padding: "8px 12px",
        border: "1px solid #d1d5db",
        borderRadius: 6,
        width: "100%",
        fontSize: 14,
      }}
    />
  );
}

function SettingsPanel() {
  // 带防抖的搜索
  const [searchQuery, setSearchQuery] = useState("");
  const debouncedSearch = useDebounce(searchQuery, 300);
  const prevSearch = usePrevious(debouncedSearch);

  // 主题循环切换
  const [theme, { next: nextTheme }] = useCycleList([
    "light",
    "dark",
    "system",
  ]);

  // 带计数器的字体大小
  const [fontSize, { inc: fontUp, dec: fontDown, reset: fontReset }] =
    useCounter(16, { min: 12, max: 24 });

  // 节流的实时预览
  const throttledFontSize = useThrottle(fontSize, 150);

  // 合并式表单状态
  const [settings, setSettings] = useSetState({
    username: "",
    email: "",
    notifications: true,
    language: "zh",
  });

  const themeColors: Record<string, { bg: string; text: string }> = {
    light: { bg: "#ffffff", text: "#1e293b" },
    dark: { bg: "#1e293b", text: "#f8fafc" },
    system: { bg: "#f1f5f9", text: "#334155" },
  };

  const allSettings = [
    "username",
    "email",
    "notifications",
    "language",
    "theme",
    "font size",
  ];

  const filtered = debouncedSearch
    ? allSettings.filter((s) =>
        s.toLowerCase().includes(debouncedSearch.toLowerCase())
      )
    : allSettings;

  return (
    <div
      style={{
        padding: 24,
        maxWidth: 500,
        margin: "0 auto",
        background: themeColors[theme].bg,
        color: themeColors[theme].text,
        borderRadius: 12,
        transition: "all 0.3s",
      }}
    >
      <h2 style={{ marginTop: 0 }}>设置</h2>

      {/* 受控搜索输入 */}
      <SearchInput value={searchQuery} onChange={setSearchQuery} />

      {prevSearch && prevSearch !== debouncedSearch && (
        <p style={{ fontSize: 12, opacity: 0.6, margin: "4px 0" }}>
          从 "{prevSearch}" 变为 "{debouncedSearch}"
        </p>
      )}

      <p style={{ fontSize: 12, opacity: 0.6 }}>
        显示 {filtered.length} / {allSettings.length} 项设置
      </p>

      {/* 主题切换 */}
      {filtered.includes("theme") && (
        <div
          style={{
            display: "flex",
            justifyContent: "space-between",
            alignItems: "center",
            padding: "12px 0",
            borderBottom: "1px solid rgba(128,128,128,0.2)",
          }}
        >
          <span>主题</span>
          <button
            onClick={nextTheme}
            style={{
              padding: "6px 16px",
              borderRadius: 6,
              border: "1px solid rgba(128,128,128,0.3)",
              background: "transparent",
              color: "inherit",
              cursor: "pointer",
            }}
          >
            {theme}
          </button>
        </div>
      )}

      {/* 字体大小计数器 */}
      {filtered.includes("font size") && (
        <div
          style={{
            display: "flex",
            justifyContent: "space-between",
            alignItems: "center",
            padding: "12px 0",
            borderBottom: "1px solid rgba(128,128,128,0.2)",
          }}
        >
          <span>字体大小</span>
          <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
            <button onClick={() => fontDown()}>-</button>
            <span style={{ fontWeight: 600 }}>{fontSize}px</span>
            <button onClick={() => fontUp()}>+</button>
            <button
              onClick={fontReset}
              style={{ fontSize: 12, color: "inherit", opacity: 0.6 }}
            >
              重置
            </button>
          </div>
        </div>
      )}

      {/* 带节流字体大小的实时预览 */}
      <p
        style={{
          fontSize: throttledFontSize,
          padding: "12px 0",
          transition: "font-size 0.15s",
          borderBottom: "1px solid rgba(128,128,128,0.2)",
        }}
      >
        以 {throttledFontSize}px 预览文本
      </p>

      {/* 合并状态的表单字段 */}
      {filtered.includes("username") && (
        <div style={{ padding: "12px 0" }}>
          <label style={{ display: "block", fontSize: 13, marginBottom: 4 }}>
            用户名
          </label>
          <input
            value={settings.username}
            onChange={(e) => setSettings({ username: e.target.value })}
            style={{
              padding: "6px 10px",
              border: "1px solid rgba(128,128,128,0.3)",
              borderRadius: 4,
              width: "100%",
              background: "transparent",
              color: "inherit",
            }}
          />
        </div>
      )}

      {filtered.includes("notifications") && (
        <label
          style={{
            display: "flex",
            alignItems: "center",
            gap: 8,
            padding: "12px 0",
          }}
        >
          <input
            type="checkbox"
            checked={settings.notifications}
            onChange={(e) =>
              setSettings({ notifications: e.target.checked })
            }
          />
          开启通知
        </label>
      )}
    </div>
  );
}

七个 Hook,零冲突。useControlled 驱动搜索输入框,使其在别处也能以非受控方式使用。useDebounce 避免每次按键都执行过滤。usePrevious 展示搜索词的变化历史。useCycleList 处理主题切换。useCounter 管理带边界的字体大小。useThrottle 平滑实时预览的更新。useSetState 将表单字段保持在一个合并式状态对象中。每个 Hook 负责一个关注点,组合时不需要任何额外的胶水代码。

安装

npm i @reactuses/core

相关 Hook

  • useControlled -- 构建同时支持受控和非受控的组件
  • usePrevious -- 获取上一次渲染的值
  • useDebounce -- 对任意值按指定延迟进行防抖
  • useThrottle -- 对任意值按间隔进行节流
  • useCycleList -- 在数组值之间用 next/prev 循环切换
  • useCounter -- 带 inc/dec/reset 和可选 min/max 的数值状态
  • useSetState -- 像类组件 setState 一样合并部分对象到状态中
  • useBoolean -- 带 toggle、setTrue、setFalse 的布尔状态
  • useToggle -- 在两个值之间切换
  • useLocalStorage -- 将状态持久化到 localStorage 并自动序列化

ReactUse 提供了 100 多个 React Hook。浏览全部 →

为什么生产环境很少手写流式响应:AI SDK 三层架构一次讲清

我们在 AI 应用开发 | 手写流式输出:把打字机效果背后的数据流拆开看 里面,已经把流式输出这件事手写跑通了。

但真把这套东西往聊天应用里接的时候,你很快就会感觉到,问题已经不是字怎么出来了,而是后面那一串和业务本身无关、却又必须有人接住的细节:

chunk 边界、半截 JSON、消息历史怎么记、流到一半要不要让用户停下。

今天聊的是 为什么要从手写切到 Vercel AI SDK

先划重点

手写流式响应跑通之后,往真实聊天应用走,会被两件事情拖住:

一类是看不见的协议翻译,拆 chunk、拼半个字、认结束标记; 另一类是看不见的对话状态,记历史、知道在不在流、能不能重生成。

AI SDK 把这两类工作,分散到三个位置接走:

  • 在最上游,把不同模型的 API 翻成一种统一格式
  • 在服务端,统一调模型 + 统一回一条结构化事件流
  • 在前端,统一维护消息数组和流状态

手写版不是不能跑,是越往前走越像在造基础设施

手写版跑通之后,真接聊天应用你会发现,花时间的地方已经不在打字机效果上了。

一部分时间花在协议边界上。

一个中文字符完全可能被拆到前后两个 chunk 里,一段 JSON 只拿到半截就 JSON.parse 直接炸掉,SSE 事件还得自己按 \n\n 切分、挑出 data 字段、认结束标记。

这些工作原本和业务没一点关系,但不接住一个,整条链就走不通。

chunk 是网络边界,不是业务消息边界。

chunk-vs-message-boundary.png

另一部分时间花在对话状态上。

手写版的前端 state 一开始通常就长这样:

const [answer, setAnswer] = useState('');
const [isStreaming, setIsStreaming] = useState(false);

answer 这个字符串 state 一开始单轮够用,但一旦需求里出现多轮历史、重生成、从中间分叉,每条消息还得分清是用户还是 AI、是文字还是工具调用、有没有完成,这一串东西塞不进一个字符串里。

一段字符串记得住一句话,记不住一段对话。

answer-vs-messages.png

所以手写版的问题不是不能跑,是你一旦从 demo 往真实聊天应用走,就得在协议和状态这两层各造一套自己的基础设施

花时间的地方不再是业务,而是这些重复又容易出错的细节。


AI SDK 覆盖的是整条链,不是某一个点

聊到 Vercel AI SDK 之前,我先说一下我自己对它的认知是怎么变的。

我一开始也以为它就是个 React hook,useChat 一调就完事了。后来多看几次才发现,它其实是一整套工具

从模型怎么接进来、服务端怎么调模型、到前端怎么维护对话状态,整条链都覆盖了。

前面那两层工作,协议翻译状态管理不是某一个文件的问题,是从模型到前端整条链上散落的细节

能把这两层一起接住的,也只能是一套覆盖整条链的东西,而不是一个孤立的 hook 或者一个孤立的服务端函数。

我自己是用一家餐厅在脑子里记这条链的

一家餐厅要上菜,它先得有供应商。

不同供应商送来的东西规格千差万别,有的按斤、有的按箱,包装单据也都不一样。

如果后厨每接一家就要重学一遍人家的规矩,这家餐厅根本开不起来。

所以稍微大一点的餐厅都会有一个收货环节,不管哪家供应商送来的,都按统一的规格入库,后厨拿到的永远是同一种格式。

收货之后,后厨才开始干自己的活,按统一的菜单做菜,按统一的餐具盛出来,再交给前台。后厨不关心这块牛肉是哪家送的,它只认入库后的规格。

前台的工作又是另一回事。它不做菜,但它要记住这一桌点了什么、上到第几道、客人有没有催菜、能不能换菜。

AI SDK 这套架构,几乎就是把这家餐厅的分工原封不动搬过来了。

  • 不同模型厂商就是不同的供应商
  • provider adapter(@ai-sdk/xxx 就是收货环节,把不同厂商 API 的差异在这里一次性抹平
  • streamText + toUIMessageStreamResponse() 就是后厨,统一调模型 + 统一往外回一条结构化事件流
  • useChat 就是前台,记住这桌的整段对话现在是什么状态

ai-sdk-three-layer-chain.png

带着这张图往下读,后面三个节点就是这条链上的三个停靠点。


第一个节点:先把模型接进来

不同模型厂商的 API 本来就未必长一样,路径、鉴权 header、流式事件格式、文本字段路径都可能不一样。

你如果每次都直接对着厂商 API 写代码,很快就会反复写这一家怎么接、那一家怎么接。

这时候 AI SDK 的模型接入层就开始把这事接过去了。

provideradapter 这两个词后面会反复出现,我们先来认识一下。

provider 就是模型的供货方DeepSeek 是一个 provider,OpenAI 是一个 provider,Anthropic 也是一个 provider

adapter 就是替你跟这家 provider 对话的那段代码。AI SDK 的做法是,每家 provider 都对应一个 adapter 包

一个 adapter 收一家 provider

import { createOpenAICompatible } from '@ai-sdk/openai-compatible';

export function createDeepSeekAiSdkProvider() {
return createOpenAICompatible({
name: 'deepseek',
apiKey: process.env.DEEPSEEK_API_KEY!,
baseURL: process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com'
});
}

@ai-sdk/openai-compatible@ai-sdk/anthropic 这一类包,做的事不是生成文本。

它先替你把不同模型厂商接成 AI SDK 能认的一种统一模型接口。

adapter 的粒度,不是公司品牌,而是协议形状

这里我一开始也有点疑惑,DeepSeek 是一家独立的大模型公司,为什么要用一个叫 openai-compatible 的包来接?

DeepSeek 的 API 形状和 OpenAI 高度兼容,请求路径、请求体字段、流式事件格式基本一致,所有为 OpenAI 写的工具链几乎零代码就能接入。

这不是 DeepSeek 一家的选择,Moonshot、Groq、OpenRouter、Ollama 走的都是同一条路。

所以 @ai-sdk/openai-compatible 根本不在乎对面是 OpenAI 还是 DeepSeek,它只看协议形状对不对,给它配上 baseURLapiKey 就行。

Claude 是走另一条路的典型。

它有自己一套独立的 API 形状,路径、鉴权 header、事件类型、字段结构都和 OpenAI 不一样,所以要走 @ai-sdk/anthropic 这种专门适配。

这一层先替你接住的,不是页面,而是上游模型 API 的差异。


第二个节点:服务端怎么统一调模型和回流

模型接上之后,前端发一条消息过来,服务端总得先有一层把这次请求接住,拿到消息、调模型、再把结果回给前端

如果你用的是 Next.js,这层通常就在 app/api/.../route.ts 里,最常见就是那个 POST 函数。

后面如果我提到 route handler,你先把它理解成这层服务端入口就行。

原来这层里那一坨读流、缓冲、拼字符串的代码,现在基本就剩调一个函数加返回一个函数。

SDK 版服务端入口还剩什么

import { convertToModelMessages, streamText, type UIMessage } from 'ai';

export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();

const result = streamText({
model: deepseek.chatModel('deepseek-chat'),
messages: await convertToModelMessages(messages)
});

return result.toUIMessageStreamResponse();
}

这几行做了三件事:

convertToModelMessages 把前端消息格式翻成模型能认的格式;

streamText 调模型拿流式结果;

toUIMessageStreamResponse() 再把结果翻回前端能消费的事件流。

原来手写版那一大坨读流、缓冲、拼字符串的代码,基本都被这三个函数接走了。

手写版那层细节,SDK 是怎么处理的

mermaid-sdk.png

请求上游模型读流续字处理事件边界结束标记,这些原来散落在服务端入口这层的工作,

全部被 provider adapter 加 streamText 在内部接走。

最后那条「怎么把结果回给前端」的统一响应,则由 toUIMessageStreamResponse() 接走。

服务端轻下来的不是业务逻辑,是那些重复的流式协议处理。

前后端之间,其实还隔着一道翻译

convertToModelMessagestoUIMessageStreamResponse() 这两个函数,是一对翻译器

前端要渲染工具调用卡片、错误块、思考过程,所以 UIMessage 里带了一堆 parts

但模型只认扁平的 role + content

一端多一端少,中间就得有人翻。

convertToModelMessages把前端消息翻给模型toUIMessageStreamResponse()把模型流再翻回前端消息流

进去翻一次、出来也翻一次。

为什么回流不能只是纯文本

这里我一开始也没想清楚,手写版那套纯文本流明明能 work,toUIMessageStreamResponse() 为什么要做成一串带 type 字段的 JSON 事件?

因为前端要消费的不止是字。

AI 中途要显示「正在调用 search 工具」和工具结果卡片,要把灰色的「思考过程」和最终回答分开。

中途抛错要让 UI 识别这不是回答内容,结束时还要有一个明确信号解锁输入框。

如果流里只有字,浏览器根本没法区分哪段是思考、哪段是工具调用、哪段是错误、哪段是最终回答。

所以 SDK 把回流做成了结构化事件流,每个事件都带 type: "text-delta"type: "tool-call" 这种标签,前端看到什么 type 就走什么分支。

structured-event-stream.png

打开浏览器 DevTools 看一眼真实返回流会更直观:

{"type":"text-delta","id":"txt-0","delta":"Vercel"}
{"type":"text-delta","id":"txt-0","delta":" AI"}
{"type":"text-end","id":"txt-0"}
{"type":"finish-step"}
{"type":"finish","finishReason":"stop"}
[DONE]

模型 原始 SSE 里,文本增量走的是厂商自己的字段路径,结束信号也走的是厂商自己的结束格式。

AI SDK 回给前端的事件流里,文本增量统一变成 text-delta,结束信号统一变成 text-endfinish-stepfinish 这一类标签。

浏览器看到的已经是统一后的结果,不是模型厂商自己的原始协议。

前端处理渲染只要按 type 分发就行,不需要再认任何厂商的方言。

顺带留个钩子:messages 为什么是整段传上来的

你看这层服务端入口里 const { messages } = await req.json(),可能会以为前端传一句话、后端维护历史就行。

大模型 API 本身是无状态的

OpenAI、Claude 等大模型的 HTTP 接口其实都没有会话这个概念,每次请求都得把整段对话历史重新喂一次,模型拿到数组那一刻才「恢复」出上下文,生成完立刻丢掉。

所以这条链上的服务端入口每次都是全新的、无记忆的,它只是个转发层,状态落在前端的 messages 数组里。

对话越来越长之后,这个数组也会越来越大,token 吃不消了怎么办、记忆怎么维护,这是下一篇要讲的事。


第三个节点:前端怎么接住消息状态

服务端这层轻下来以后,前端这层也就没那么复杂了。

手写版前端状态,一开始通常就长这样

const [input, setInput] = useState('');
const [answer, setAnswer] = useState('');
const [isStreaming, setIsStreaming] = useState(false);

SDK 版就会变成:

const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/api/ai-sdk-chat' })
});

useChat 暴露的不是 answer,是整个 messages 数组

这两段代码的差别,真不是少写几个 state 这么简单。

useChat 接住的是请求发送、流读取、消息追加、状态流转

你不用再自己读 response.body,也不用自己一边拼字符串,一边维护 isStreaming 什么时候开、什么时候关。

更关键的是它暴露给你的不是一个 answer,是一整个 messages 数组,每条消息长这样:

type UIMessage = {
id: string;
role: 'user' | 'assistant';
parts: Array<{ type: string; text?: string }>;
};

这套结构本来就是给整段对话准备的。

多轮、重生成、从中间分叉这些功能往上一加,全都顺势就成立。

举个例子,regenerate() 就是对 AI 最后一条回答不满意,重新生成一次

手写版里这件事通常不是再发一次请求这么简单,你得先砍掉最后一条 answer、把字符串状态倒推回消息数组、再补重发逻辑。

而 AI SDK 这边,messages 数组本来就记着每条消息,调一个方法的事。

所以真正少的不是代码行数,是你不用自己从零搭一套消息结构。

但页面最后怎么长,还是业务自己决定

它也没有替你把前端全部做完。

输入框 state、消息怎么渲染、错误 UI 怎么展示、按钮什么时候禁用、滚动什么时候到底,这些还是业务自己决定。

比如输入框的 input / setInput 就是业务自己维护的,useChat 不管:

<textarea value={input} onChange={(event) => setInput(event.target.value)} />
<button type="submit" disabled={status !== 'ready'}>
  {status === 'streaming' ? 'AI SDK 流式输出中...' : '调用 AI SDK 流式输出'}
</button>

useChat 接的是聊天状态,不是聊天页面长相。


如果你正从手写版迁过来,先改这三处

前面那条三层链路看懂之后,回到代码里第一刀该下在哪,其实顺序是固定的。

第一刀,先改 provider。

先别动前端,用 @ai-sdk/openai-compatible 或对应的 adapter 包把厂商 API 包一层,让后面 streamText 能直接调,上游差异先在这里隔离掉。

第二刀,再改服务端入口。

把手写读流、缓冲、组装响应那层换成 streamTexttoUIMessageStreamResponse()服务端重复的协议细节先拿掉

第三刀,最后改前端状态。

answer 这种单字符串状态换成 useChatmessages后面多轮、重生成、分叉、工具调用才有稳定地基

从手写版切到 AI SDK,第一刀先别改页面,先改协议层和状态层。

migration-three-cuts.png

改完这三刀,你会在两件事上感觉到变化。

一是原来服务端那坨 chunk 拼接、JSON 兜底、字段路径的代码,不用再看第二眼,协议那层不用碰了

二是后面再冒出「重生成」「从某条消息重开」这类需求的时候,你不用先回头重构 state,useChat 暴露的 messages 数组已经为这些需求打好地基了。

省下来的不只是代码行数,更像是脑子不用再同时装怎么读流怎么记对话这两件事。


现在来想想以下问题

Q1:服务端返回了一个 type: "tool-call" 的事件,但前端不知道怎么渲染。你觉得问题出在三层里的哪一层?

💡 provider adapter 负责接模型,streamText 负责调模型和回流,useChat 负责前端状态。tool-call 事件已经到了前端,说明前两层没问题。

Q2:如果你要给聊天页面加一个「从这条消息重新开始」的功能,手写版和 SDK 版各需要改什么?

💡 手写版的 answer 是一段字符串,你得先重构出消息数组才能定位到"这条消息"。SDK 版的 messages 数组里每条消息都有 id,天然支持这个操作。

Q3:现在 useChat 每次请求都把整个 messages 数组传给服务端,对话聊了 50 轮之后,你觉得会先撞到什么问题?

💡 这是下一篇的主题:对话越来越长,token 吃不消了,记忆怎么维护。


感谢您的阅读~🌹

我在微信公众号 前端Fusion 中也会持续同步更新关于 AI 与前端开发的相关文章,欢迎大家关注,一起交流学习。

分享底图_压缩.png

SBTI 测试挤崩服务器:一个程序员视角的技术复盘

昨晚你的朋友圈,是不是也被"尤物""吗喽""愤世者"刷屏了?

4月9日晚,一个叫 SBTI 的人格测试突然引爆社交网络。用户蜂拥而至,网站直接崩了——页面打不开、链接失效,大家只能靠截图"云测试"。作者深夜紧急发布新链接,称"做了略微修改,应该不会再崩了"。

作为一个技术人,看到"网站崩了"和"略微修改就不崩了"这两句话,我的职业病就犯了。今天我们不聊测试准不准,只聊:这背后到底发生了什么?如果是你来做,怎么才能扛住这波流量?

一、崩溃现场还原:一个经典的"雷群效应"

SBTI 测试的崩溃,是教科书级别的 Thundering Herd(雷群效应)案例。

简单说就是:一个原本只为"劝朋友戒酒"而做的小项目,突然被几百万人同时访问。这就好比你开了一家只有两张桌子的面馆,突然被美食博主推荐,门口排了两公里的队。

根据公开信息推测,SBTI 最初大概率是这样的架构:

用户浏览器 → 单台服务器(前端 + 后端 + 数据库 all-in-one

这种架构在日常几百、几千 PV 的场景下完全够用。但当朋友圈裂变式传播启动后,并发量可能在几分钟内从个位数飙升到数万甚至数十万级别。单机扛不住,结果就是:

  • 连接池耗尽:服务器能同时处理的请求数是有限的,超出后新请求直接被拒绝
  • 带宽打满:测试页面包含图片、样式、脚本,每个用户加载一次就消耗几百 KB 到几 MB 的带宽
  • 如果有后端逻辑:数据库连接数爆满,CPU 被打满,响应时间从毫秒级飙升到超时

二、"略微修改"背后的技术真相

作者说"做了略微修改,应该不会再崩了"。这句话信息量很大。

对于一个测试类 H5 应用,最高效的"略微修改"大概率是以下几种操作之一(或组合):

方案 A:纯静态化 + CDN 分发

测试类应用的核心逻辑其实很简单:展示题目 → 用户选择 → 前端计算结果 → 展示结果页。整个过程完全可以在浏览器端完成,不需要后端服务器参与。

之前:用户 → 源站服务器(动态渲染)
之后:用户 → CDN 边缘节点(静态资源)→ 前端 JS 本地计算结果

把所有页面打包成纯静态文件(HTML + CSS + JS + 图片),扔到 CDN 上。CDN 在全国有几百个边缘节点,用户访问时会自动路由到最近的节点。这样源站压力几乎为零,理论上可以承载千万级并发。

方案 B:更换托管平台

从自建服务器迁移到 Vercel、Cloudflare Pages、Netlify 等现代静态托管平台。这些平台天然具备全球 CDN 分发能力,部署一个静态站点只需要几分钟。

方案 C:Serverless 化

如果测试逻辑中确实有需要后端参与的部分(比如 AI 生成结果文案),可以将后端逻辑迁移到 Serverless 函数(如 AWS Lambda、阿里云函数计算)。Serverless 的核心优势是自动弹性伸缩——流量来了自动扩容,流量走了自动缩容,按实际调用次数计费。

三、如果让你从零设计,架构应该长什么样?

假设你现在要做一个类似 SBTI 的病毒式传播测试应用,并且预期它可能会爆火,推荐的架构如下:

┌─────────────────────────────────────────────────┐
│                    用户浏览器                      │
│  ┌───────────┐  ┌──────────┐  ┌───────────────┐  │
│  │ 答题引擎   │  │ 计分逻辑  │  │ 结果图片生成   │  │
│  │ (纯前端)   │  │ (纯前端)  │  │ (Canvas/SVG)  │  │
│  └───────────┘  └──────────┘  └───────────────┘  │
└──────────────────────┬──────────────────────────┘
                       │ 静态资源请求
                       ▼
              ┌─────────────────┐
              │   CDN 边缘节点    │
              │  (全国 300+ 节点) │
              └────────┬────────┘
                       │ 回源(极少触发)
                       ▼
              ┌─────────────────┐
              │  对象存储 (OSS)   │
              │  HTML/CSS/JS/图片 │
              └─────────────────┘

核心设计原则:

  1. 计算下沉到客户端:题目数据、计分逻辑、结果映射全部内嵌在前端代码中,浏览器本地完成所有计算,服务端零压力
  2. 资源全量 CDN 化:所有静态资源通过 CDN 分发,用户就近访问,首屏加载时间控制在 1-2 秒内
  3. 结果图片客户端生成:使用 Canvas API 或 html2canvas 在用户浏览器中直接生成分享图片,避免服务端图片渲染的性能瓶颈
  4. 零后端依赖:整个应用不需要数据库、不需要后端 API,运维成本趋近于零

四、病毒传播的技术引擎:分享链路优化

SBTI 能刷屏朋友圈,除了内容本身的娱乐性,分享链路的技术设计也至关重要。

微信分享卡片优化

// 微信 JS-SDK 分享配置
wx.updateAppMessageShareData({
  title: '我的SBTI人格是【尤物】,你是什么?',  // 动态标题,包含测试结果
  desc: 'MBTI已经过时了,来测测你的SBTI人格吧',
  link: 'https://example.com/sbti?from=share',   // 带来源追踪参数
  imgUrl: 'https://cdn.example.com/sbti-share.jpg' // 高辨识度的分享缩略图
})

关键技术点:

  • 动态分享标题:将用户的测试结果嵌入分享标题,制造好奇心驱动的点击欲望
  • 结果图片生成:用 Canvas 将测试结果渲染为一张精美的图片,方便用户保存并发到朋友圈
  • 短链接 + UTM 追踪:通过 URL 参数追踪传播路径,了解哪个渠道带来的流量最大

分享图片的客户端生成方案

import html2canvas from 'html2canvas';

async function generateShareImage(resultElement) {
  const canvas = await html2canvas(resultElement, {
    scale: 2,              // 2倍分辨率,保证清晰度
    useCORS: true,         // 允许跨域图片
    backgroundColor: null  // 透明背景
  });
  
  // 转为图片供用户长按保存
  const imgUrl = canvas.toDataURL('image/png');
  return imgUrl;
}

这个方案的好处是:图片在用户手机上生成,不需要服务端渲染,即使同时有 100 万人生成分享图,服务器也毫无压力。

五、AI 在其中扮演的角色

根据公开信息,SBTI 的人格描述内容使用了 AI 生成技术。这带来了一个有趣的架构选择:

方案一:预生成(推荐)

在开发阶段就用 AI 生成好所有人格类型的描述文案,作为静态数据打包到前端代码中。运行时不需要调用 AI 接口,零延迟、零成本。

// 预生成的结果数据,直接内嵌在前端代码中
const SBTI_RESULTS = {
  'ABCD': {
    title: '尤物',
    description: 'AI生成的人格描述文案...',
    image: '/assets/results/abcd.png'
  },
  'EFGH': {
    title: '吗喽',
    description: 'AI生成的人格描述文案...',
    image: '/assets/results/efgh.png'
  }
  // ... 其他类型
}

方案二:实时生成(不推荐用于病毒传播场景)

每次用户完成测试后实时调用 AI API 生成个性化描述。这种方案在流量暴增时会面临:API 调用成本飙升、响应延迟增大、API 限流等问题。

从 SBTI 的实际表现来看(崩溃后"略微修改"就恢复了),大概率采用的是方案一——AI 只在开发阶段参与内容生产,运行时是纯静态应用。

六、成本算一笔账

假设 SBTI 在爆火期间有 500 万次访问,每次访问加载约 2MB 资源:

方案 预估成本 能否扛住
单台云服务器(2核4G) ¥100/月,但会崩
CDN + 对象存储 流量费约 ¥500-1000
Vercel/Cloudflare Pages 免费版 ¥0(有带宽限制) ⚠️
Vercel Pro + CDN ¥150/月

一个纯静态的测试应用,即使面对百万级流量,CDN 方案的成本也就是一顿火锅钱。而如果用单机硬扛,服务器费用可能不高,但用户体验的损失是无法估量的——多少潜在的传播链路因为"页面打不开"而断裂了。

七、给开发者的 Takeaway

SBTI 事件给我们的启示:

  1. 永远为最好的情况做准备:如果你的产品有社交传播属性,请在架构设计时就考虑流量暴增的场景。CDN + 静态化的成本几乎为零,但收益是巨大的。

  2. 能在前端做的事,别放到后端:测试类应用的计算逻辑完全可以在浏览器端完成。每减少一次服务端请求,就多了一份稳定性。

  3. 分享体验就是增长引擎:结果图片的生成质量、分享卡片的文案设计,直接决定了传播系数。技术上要保证分享链路的流畅性。

  4. AI 是内容生产工具,不是运行时依赖:对于这类应用,AI 最适合在开发阶段批量生成内容,而不是在运行时实时调用。

  5. 小项目也值得好架构:SBTI 的作者最初只是想劝朋友戒酒,没想到会火。但如果一开始就用静态托管方案,根本不会有崩溃这回事。好的架构不一定复杂,但一定要匹配场景。


一个为劝朋友戒酒而生的测试,意外成为了一堂生动的高并发架构课。技术世界的浪漫,大概就是这样吧。


八、最后

文中技术分析基于公开信息推测,不代表 SBTI 实际技术实现。
如果你也在职场摸索成长路线,想了解更多内部跳槽、团队优化、技术实践和职场认知升级的经验,可以关注我的公众号:   [码农职场]
后续我会分享更多干货,帮助你在职场和技术上持续突破。

【前端基础】原生JS实现Tab栏切换--根据价格筛选商品

一、效果展示

图片展示:

屏幕截图 2026-04-10 115729.png 效果展示视频:(忽略清朝画质)

4月10日.gif

二、前置知识点

1.解构赋值:数组解构、对象解构
2.事件委托,利用冒泡原理为多个元素绑定事件
3.forEach()--遍历数组,循环;filter()筛选数组
4.箭头函数

三、练习素材(html+css)

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

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>第一天练习</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        li {
            list-style: none;
        }

        a {
            text-decoration: none;
        }

        .viewPort {
            width: 1024px;
            height: 780px;
            margin: 0 auto;
        }

        .list {
            margin-top: 30px;
            display: flex;
            width: 1024px;
            flex-wrap: wrap;
        }

        .item {
            width: 240px;
            margin-left: 10px;
            padding: 20px 30px;
            transition: all .5s;
            margin-bottom: 20px;
        }

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

        .item:hover {
            box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.2);
            transform: translate3d(0, -4px, 0);
            cursor: pointer;
        }

        .item img {
            width: 100%;
        }

        .item .name {
            font-size: 18px;
            margin-bottom: 10px;
            color: #666;
        }

        .item .price {
            font-size: 22px;
            color: firebrick;
        }

        .item .price::before {
            content: "¥";
            font-size: 14px;
        }

        .filter {
            display: flex;
            width: 990px;
            margin: 0 auto;
            padding: 50px 30px;
        }

        .filter a {
            padding: 10px 20px;
            background: #f5f5f5;
            color: #666;
            text-decoration: none;
            margin-right: 20px;
        }

        .filter a:active,
        .filter a:focus {
            background: #05943c;
            color: #fff;
        }
    </style>
</head>

<body>
    <div class="viewPort">
        <div class="filter">
            <a data-index="1" href="javascript:;">0-100元</a>
            <a data-index="2" href="javascript:;">100-300元</a>
            <a data-index="3" href="javascript:;">大于300元</a>
            <a href="javascript:;">全部区间</a>
        </div>
        <div class="list">
            <!-- <div class="item">
                <img src="" alt="">
                <p class="name"></p>
                <p class="price"></p>
            </div> -->
        </div>
    </div>
    <script>
        // 2. 初始化数据
        const goodsList = [
            {
                id: '4001172',
                name: '称心如意手摇咖啡磨豆机咖啡豆研磨机',
                price: '289.00',
                picture: 'https://yanxuan-item.nosdn.127.net/84a59ff9c58a77032564e61f716846d6.jpg',
            },
            {
                id: '4001594',
                name: '日式黑陶功夫茶组双侧把茶具礼盒装',
                price: '288.00',
                picture: 'https://yanxuan-item.nosdn.127.net/3346b7b92f9563c7a7e24c7ead883f18.jpg',
            },
            {
                id: '4001009',
                name: '竹制干泡茶盘正方形沥水茶台品茶盘',
                price: '109.00',
                picture: 'https://yanxuan-item.nosdn.127.net/2d942d6bc94f1e230763e1a5a3b379e1.png',
            },
            {
                id: '4001874',
                name: '古法温酒汝瓷酒具套装白酒杯莲花温酒器',
                price: '488.00',
                picture: 'https://yanxuan-item.nosdn.127.net/44e51622800e4fceb6bee8e616da85fd.png',
            },
            {
                id: '4001649',
                name: '大师监制龙泉青瓷茶叶罐',
                price: '139.00',
                picture: 'https://yanxuan-item.nosdn.127.net/4356c9fc150753775fe56b465314f1eb.png',
            },
            {
                id: '3997185',
                name: '与众不同的口感汝瓷白酒杯套组1壶4杯',
                price: '108.00',
                picture: 'https://yanxuan-item.nosdn.127.net/8e21c794dfd3a4e8573273ddae50bce2.jpg',
            },
            {
                id: '3997403',
                name: '手工吹制更厚实白酒杯壶套装6壶6杯',
                price: '99.00',
                picture: 'https://yanxuan-item.nosdn.127.net/af2371a65f60bce152a61fc22745ff3f.jpg',
            },
            {
                id: '3998274',
                name: '德国百年工艺高端水晶玻璃红酒杯2支装',
                price: '139.00',
                picture: 'https://yanxuan-item.nosdn.127.net/8896b897b3ec6639bbd1134d66b9715c.jpg',
            },
        ]

四、功能JS模块

1.渲染函数的封装

function(arr){
    let str = "" //声明一个空字符串
    arr.forEach(item => {
        const {name,picture,price} = item //对象解构,快速批量的声明变量,缩减代码量
        str += `
            <div class="item">
                <img src=${picture} alt="">
                <p class="name">${name}</p>
                <p class="price">${price}</p>
            </div> 
        ` 
        //arr中有几个元素就渲染几个div,将数据填入 ${},动态渲染
    })
    document.queryselector(".list").innerHTML = str //将字符串放入.list的div盒子中
   }
   render(goodsList) //调用函数
    

2.绑定点击事件

    //事件委托,为父元素绑定点击事件
    document.queryselector("filter").addEventListener("click",e =>{
        const {tagName,dataset} = e.target //获取点击对象+对象解构
        let arr = goodsList //因为如果都不点击就显示全部元素,所以初始值将arr直接等于goodsList
        if(tagName === "A"){ //点击a标签时才触发,点击父元素空白区域不触发
            if (dataset.index === "1") {
                    arr = goodsList.filter(item => item.price > 0 && item.price <= 100)
                } else if (dataset.index === "2") {
                    arr = goodsList.filter(item => item.price >= 100 && item.price <= 300)
                } else if (dataset.index === "3") {
                    arr = goodsList.filter(item => item.price >= 300)
                }
                render(arr)
        }
    })

五、易错点

1.对象解构时,例如const {name,price} = item //这里是花括号,数组解构时是[]。并且对象解构时声名的变量名必须和数据中的属性名一致
2.函数不调用不执行,封装好函数后一定要调用执行
3.箭头函数省略原则:参数只有一个时,()可以省略;若函数中只有一句return语句时,{}和return都可以省略;!!!没有参数时,()一定不要省

装饰器:那个在代码里“贴标签”的黑魔法,到底有什么用?

你有没有在Angular或NestJS里见过@Component@Injectable这种稀奇古怪的“@符号”?它们就像给代码贴的“便利贴”,背后却能自动帮你做一堆事情。今天我们就来揭开TypeScript装饰器的神秘面纱,看看这个“贴标签”魔法到底怎么用,以及为什么它能让你少写几千行重复代码。

前言

想象你去餐厅吃饭,你在菜单上贴了个标签“@少油”,厨房看到后自动给你少放油。你又贴个“@加辣”,厨房又自动加辣。你只需要贴标签,厨房负责执行。

这就是装饰器。它是一种特殊的声明,可以附加在类、方法、属性、参数上,用来修改或增强它们的行为。你不用手动调用什么函数,只要贴上“标签”,背后的逻辑就会自动生效。

一、装饰器长啥样?先看个例子

在TypeScript里,装饰器以@expression的形式出现,expression是一个函数,会在运行时被调用。

function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  console.log('调用了方法:', propertyKey);
}

class Person {
  @log
  sayHello() {
    console.log('Hello');
  }
}

const p = new Person();
p.sayHello();
// 输出:
// 调用了方法: sayHello
// Hello

你什么都没改,只是加了个@log,每次调用sayHello就会自动打印日志。这就是装饰器的魅力。

二、启用装饰器:别急,先开个开关

TypeScript的装饰器目前是实验性特性,需要在tsconfig.json里开启:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true  // 可选,用于反射元数据
  }
}

三、装饰器的四种类型

装饰器可以贴在四个地方:类、方法、访问器/属性、参数。每种都有不同的参数签名。

1. 类装饰器

作用在类上,通常用来修改或替换类的定义。

function addTimestamp<T extends { new (...args: any[]): {} }>(constructor: T) {
  return class extends constructor {
    timestamp = new Date();
  };
}

@addTimestamp
class User {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

const user = new User('张三');
console.log(user); // User { name: '张三', timestamp: 2025-04-10... }

类装饰器接收一个参数:类的构造函数。你可以返回一个新类替换它,或者直接修改原型。

2. 方法装饰器

最常用,可以拦截、修改、替换方法。

function measure(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function(...args: any[]) {
    const start = performance.now();
    const result = original.apply(this, args);
    const end = performance.now();
    console.log(`${propertyKey} 耗时 ${end - start}ms`);
    return result;
  };
  return descriptor;
}

class Calculator {
  @measure
  add(a: number, b: number) {
    return a + b;
  }
}

参数:

  • target:类的原型(静态方法则是构造函数)
  • propertyKey:方法名
  • descriptor:属性描述符(可以修改value、writable等)

3. 属性装饰器

作用在属性上,通常用于配合元数据做依赖注入或验证。

function format(formatStr: string) {
  return function(target: any, propertyKey: string) {
    let value: string;
    const getter = function() {
      return value;
    };
    const setter = function(newVal: string) {
      value = formatStr.replace('%s', newVal);
    };
    Object.defineProperty(target, propertyKey, {
      get: getter,
      set: setter,
      enumerable: true,
      configurable: true
    });
  };
}

class Greeting {
  @format('Hello, %s')
  name: string;
}

属性装饰器只能拿到目标类和属性名,不能直接修改属性值,但可以通过Object.defineProperty替换getter/setter。

4. 参数装饰器

作用在函数参数上,常用于依赖注入框架(比如Angular)。

function paramLogger(target: any, propertyKey: string, parameterIndex: number) {
  console.log(`参数位置 ${parameterIndex} 被装饰了`);
}

class UserService {
  getUser(@paramLogger id: number) {
    return { id };
  }
}

参数装饰器很少单独用,通常配合类装饰器或方法装饰器收集元数据。

四、装饰器工厂:给装饰器传参数

你看到@log@measure这些是不带参数的。如果想让装饰器接受配置,需要再包一层函数:

function log(prefix: string) {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const original = descriptor.value;
    descriptor.value = function(...args: any[]) {
      console.log(`${prefix} 调用 ${propertyKey}`);
      return original.apply(this, args);
    };
  };
}

class Test {
  @log('DEBUG')
  doSomething() {
    console.log('执行');
  }
}

这就是装饰器工厂:外层函数接收参数,内层函数是真正的装饰器。

五、多个装饰器:从下往上,从右往左

当你在同一个目标上使用多个装饰器时,它们的执行顺序是:先执行靠近目标的(从下往上),再执行外层的

@classDecoratorA
@classDecoratorB
class MyClass {}

执行顺序:classDecoratorB 先执行,然后 classDecoratorA

方法上的装饰器类似:先执行参数装饰器,再执行方法装饰器,最后是类装饰器(但方法装饰器本身的调用顺序是从下往上)。

六、实战:用装饰器实现权限校验

假设你要写一个类,某些方法只有管理员能调用。你可以用装饰器优雅地实现:

function adminOnly(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function(...args: any[]) {
    if (!this.isAdmin) {
      throw new Error('无权限,需要管理员角色');
    }
    return original.apply(this, args);
  };
}

class UserController {
  isAdmin = false;

  @adminOnly
  deleteUser(id: number) {
    console.log(`删除用户 ${id}`);
  }
}

const ctrl = new UserController();
ctrl.deleteUser(1); // 报错:无权限
ctrl.isAdmin = true;
ctrl.deleteUser(1); // 成功

看,你只需要在需要权限的方法上贴个@adminOnly,逻辑自动注入。

七、装饰器的实际应用场景

  • 日志记录:自动打印方法入参、返回值、耗时。
  • 权限校验:检查当前用户角色。
  • 数据验证:验证方法参数格式。
  • 依赖注入:Angular、NestJS 里大量使用。
  • 性能监控:自动记录方法执行时间。
  • 重试机制:方法失败后自动重试。

八、注意事项与坑点

  1. 装饰器目前是实验特性,虽然Angular、NestJS等框架广泛使用,但未来ECMAScript标准可能会有所变化。
  2. 不能用在普通JS文件,必须在TS或Babel中启用。
  3. 属性装饰器不能直接修改属性值,需要通过Object.defineProperty替换getter/setter。
  4. 装饰器在类定义时执行,而不是实例化时。这意味着你不能依赖实例属性(比如this.isAdmin)来做静态分析,但可以在返回的函数中延迟读取。

九、总结:装饰器就像“代码贴纸”

  • 装饰器是给类、方法、属性、参数贴的“标签”。
  • 标签背后的函数会在运行时自动执行,修改目标的行为。
  • 装饰器工厂可以传参,实现定制化。
  • 多个装饰器从下往上执行。
  • 常见用途:日志、权限、验证、注入。

学会装饰器,你就能写出更声明式、更优雅的代码。很多框架的魔法背后,其实就是这些小小的“@”符号。

如果你觉得今天的“便利贴”魔法够神奇,点个赞让更多人看到。明天我们将开启浏览器渲染原理之旅,从输入URL到页面显示,中间到底发生了什么?我们明天见!

别再被 `npx` 骗了:Debug 纪实 —— 为什么总是找不到文件?

做全栈开发,最让人抓狂的往往不是复杂的业务逻辑,而是各种匪夷所思的 “环境玄学”

  • “为什么教学视频里敲 npx xxx 秒开,我一敲就报错?”
  • “为什么我昨天在这台电脑上敲就没事,今天怎么突然就不行了?”
  • “按照控制台弹出的方案重试了 3 次,为什么一行能在 Windows 跑通的都没有?”

今天,我们就以开发用到的 Inngest CLI 为例(同样适用于 Prisma, esbuild, sharp 等工具),彻底扒开前端包管理器的底层黑盒,讲透这个恶心无数开发者的 Binary not found 现象。


💥 案发现场

当你在本地输入 npx inngest-cli@latest dev 时,满心欢喜地等待面板启动,结果迎面砸来这样一段报错:

Error: Inngest CLI binary not found.
This happened because install scripts were skipped.
To fix this, use the method most appropriate for your setup:
  NPM_CONFIG_CACHE=$(mktemp -d) npx --ignore-scripts=false inngest-cli@latest
  ...

你尝试复制了报错提示里的命令,然后发现它在 Windows 的 PowerShell 里连语法都不对! 这是为什么?


🕵️‍♂️ 剥开黑盒探寻本质:为什么找不到肉身?

这个报错并不是说你断网了没装上包,而是说你装下来的包**“少了灵魂”**。

1. 挂羊头卖狗肉的 NPM 包装戏法

现代的开发工具链(如 Inngest、esbuild、Prisma 等)由于对性能有极致要求,它们底层的引擎绝大多数是用 Go、C++ 或 Rust 写的。 为了能兼容前端庞大的 npm 生态,开发者通常会在 npm 仓库里发一个纯粹由 JS 构成的 “空壳子”

它的真实运作机制是: 触发安装 -> 下载 JS 空壳 -> 触发 postinstall 钩子脚本 -> 脚本自动从 Github Releases 拉取对应系统(Win/Mac/Linux)的 .exe 可执行文件。

一旦这个 postinstall 脚本因为任何原因(网络超时、没有权限)没有跑成功,你的包里就只剩下一个没用的 JS 空壳。这就叫 Binary not found

2. 拦路虎:pnpm v10 的“安全铁腕”

你可能会问:“我的网络有魔法代理,为什么还会失败?” 真相隐藏在你的包管理器里。如果你升级到了 pnpm v10,由于它引入了极其严格的“受信任依赖”机制,默认会悄悄拦截一切第三方包在后台执行构建脚本(postinstall)的行为

你的命令行里大概会有这样一行一闪而过的高大上的警告:

Ignored build scripts: inngest-cli@1.16.1. Run "pnpm approve-builds" to pick ...

是的,是 pnpm 觉得这个包不安全,亲手把下载 .exe 的途径给掐断了。

3. NPX 的“就近连坐”病毒(解释时灵时不灵)

这是最魔幻的一点:为什么昨天能行,今天装完反而坏了?

  • 当你没安装时(昨天):运行 npx 时,它去自己干净的全局临时目录下载了一个包,刚好没受困于安全拦截,顺利拿到了二进制文件,成功运行。
  • 当你在本地项目里安装了它但被拦截时(今天):你的项目 node_modules 里多了一个“没有二进制文件的空壳包”。
  • 致命的偷懒机制:当你再次敲击 npx inngest-cli 时,npx 会自作聪明地优先使用本地项目中已有的坏包,而不是去全局深究。

这就造成了:只要你的项目里混进了一个“太监版”的依赖,无论你敲多少次全局 npx,它都会被就近传染,当场暴毙。


🛠️ 解法:做防弹的工程底座

搞懂了原理,我们就绝不能像“脚本小子”一样,每次报错就去删除 %LOCALAPPDATA%\npm-cache\_npx 缓存。在正规的全栈商业级项目中,所有基建都必须是绝对受控且确定的。

彻底杜绝玄学的标准动作:将隐式全局依赖,转变为显式本地依赖。

Step 1: 签署白名单 (pnpm.onlyBuiltDependencies)

不要让 pnpm 盲猜,直接在你的 package.json 中明确发给 Inngest 发“放行条”:

{
  "pnpm": {
    "onlyBuiltDependencies": [
      "inngest-cli",
      "prisma",
      "esbuild",
      "sharp"
    ]
  }
}

🔥 Tips: 另一个快捷写法是在终端执行 pnpm approve-builds --save-bundle,它会自动把被拦截的包扫进信任名单。

Step 2: 固化到开发依赖

将不靠谱的 npx 游击战术转编为正规军:

# 保证当前终端顺畅访问 Github 的前提下
pnpm add -D inngest-cli

这时候你再看日志,必定能看到真正的 .exe 安稳落地。

Step 3: 固定项目启动快捷键

打开 package.jsonscripts

"scripts": {
  "dev:inngest": "inngest dev"
}

以后只需优雅地执行 pnpm run dev:inngest,把复杂的事情彻底封装在项目内部。不管换谁接手、换什么电脑拉下代码,都不再需要承受你昨天吃过的苦!


🎯 总结与认知升级

全栈开发往往就是在和这些看似无聊的“基建脏活”抗争。当你能够把“这破电脑怎么又抽风了”,转变为“哦,这显然是 pnpm 包提取钩子被跳过导致的本地模块污染”,你的水平就已经跟初级搬砖工拉开了真正的身位。

下次如果有人对你说“这机器跑不起来,但我本地没问题”,记得用这套理论降维打击他。👨‍💻

❌