普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月6日技术

详解 IEEE 754 标准定义的 JavaScript 64 位浮点数

2026年2月6日 16:20

JavaScript 使用 IEEE 754 标准定义的 64 位浮点格式表示数值。

64位 = 1位符号位(S) + 11位指数位(E) + 52位尾数位(M)

  • 符号位(S):0表示正数,1表示负数,决定数值的正负;
  • 指数位(E):存储指数的偏移值(偏移量1023),决定数值的数量级;
  • 尾数位(M):存储数值的有效数字(二进制),且有一个隐含的1位(规格化数,也叫归一化数,normalized number),所以实际有效位数是 52 + 1 = 53位。

指数位(E)的偏移量 1023 是标准人为规定的,为的是让原本的 11 位无符号位的二进制指数位能表示正负指数,并且正负指数尽可能对称(-1022 ~ 1023)。

尾数位(M)的隐藏位 1 也是标准人为规定的,利用了非零二进制数归一化后整数位必为 1 的特性。

任何一个非零的二进制数,都能唯一表示为 1.xx × 2^e 的形式。

  • e 是二进制的指数(整数,可正可负);
  • 归一化后整数位必然是固定不变的 1,这是二进制的特性。

IEEE 754 标准正是利用了「归一化后整数位必为 1」的特性,做了一个巧妙设计:

既然这个 1 是所有非零浮点数的固定前缀,那就不用在 64 位中实际存储它,而是「默认存在这个 1」,仅将小数点后的 xx 有效数字部分存入 52 位的尾数位(M)中。

原本尾数位只有 52 位,加上这 1 位隐藏的固定不变的 1 后,实际可用的有效数字位就变成了 53 位(1 + 52),直接提升了浮点数的精度,且没有占用额外的存储空间。

只有非零的归一化浮点数才有隐藏位。对于接近 0 的极小值(非归一化数),IEEE 754 会放弃归一化,此时没有隐藏位,只有 52 位有效数字。

推导:53 位二进制能表示的最大整数是 2^53 - 1

当 53 位二进制尾数位(M)(包含隐藏位 1),所有位都为 1 时,就是它能表示的最大数,计算如下:

111...111(53个1) = 2^52 + 2^51 + ... + 2^1 + 2^0 = 2^53 - 1

这个数就是 JS 中能精确表示的最大安全整数 Number.MAX_SAFE_INTEGER

一个整数 n 被称为安全整数,当且仅当:它自身和前后相邻的两个整数(n−1、n、n+1),都能被唯一且精确地存储表示,三者的 64 位浮点数编码互不重复,且不存在截断舍入后的失真情况。

在 2^53 ~ 2^1023 这区间内的 2 的整数次幂能精确表示,但都是「孤立的精确数」(相邻数失真,无连续性)。

少数能精确存储的特殊十进制小数

绝大多数小数的二进制,都是无限循环的小数形式,都不能精确存储,只能截断舍入近似存储。

如果一个十进制小数转二进制后是「有限位小数」,就能被 64 位浮点数精确存储,这类小数有明确的数学规则:

十进制小数能精确转为有限位二进制的充要条件:小数部分转化成最简分数形式后,它的分母仅包含质因子 2(即分母是 2 的正整数次幂:2¹、2²、2³...)。

简单说:小数部分是 0.5(1/2)、0.25(1/4)、0.125(1/8)、0.0625(1/16)... 的组合,就能精确存储。

浮点数的「可表示值步长」随数值增大而变大(精度衰减规律)

64 位浮点数的可表示值不是连续的,而是离散的、等步长的(同一量级内步长固定),且数值越大,量级越高,步长越大。这是整数和小数的精度都会随数值增大而衰减的根本原因。

步长是(同一量级内)相邻两个可表示的浮点数之间的差值,由归一化后的指数 e 决定

步长 = 2^(e-52) (52 是尾数位 M 的位数)。

  • 步长越小,可表示值越密集,精度越高;
  • 步长越大,可表示值越稀疏,精度越低。

步长对整数和小数的影响:

- 安全整数区(e≤52):步长 = 2^(e-52) ≤1 → 整数的步长为 1,能连续精确表示;小数的步长极小,误差可忽略;

  • 2⁵³~2¹⁰²³ 区:步长≥2 → 整数的步长大于 1,无法连续精确表示(相邻整数重叠);小数的步长极大,几乎无法区分相近小数;
  • 2¹⁰²³ 以上:超出指数范围,直接变成 Infinity,无法表示为常规数。

MAX_VALUE 和 MIN_VALUE

最大正值:Number.MAX_VALUE = 1.7976931348623157×10³⁰⁸

指数位取归一化数的最大存储值 2046(实际指数 1023),尾数位 52 位全 1(此时浮点数取到归一化数的最大极值,再大就超出指数范围,变成Infinity);

最小正值:Number.MIN_VALUE = 5×10⁻³²⁴(无限接近 0)

指数位取全 0(非归一化数,实际指数 -1023),尾数位仅最后 1 位为 1、其余全 0(此时浮点数取到能表示的最小非 0 正值,再小就会被舍入为 0)。

这是 64 位双精度浮点数基于 IEEE 754 标准的硬件存储极限,是浮点数能表示的所有数值(整数 + 小数)的整体边界,而非专门针对整数的范围。

±Infinity 和 NaN 如何表示

Infinity(正无穷) -Infinity(负无穷) NaN(非数)
1位符号位(S) 0表示正数 1表示负数 0和1都可,无意义
11位指数位(E) 11位全1 11位全1 11位全1
52位尾数位(M) 52位全0 52位全0 52位非全0(即任意1位是1即可)
S0 + E全1 + M全0 S1 + E全1 + M全0 S01 + E全1 + M非0

E全1 表示 ±Infinity 和 NaN 的特殊值,而 E全0 表示非归一化数(接近 0 的极小值,无隐藏位)。

React-create-app使用cesium并且渲染3d倾斜摄影

作者 scorpioop
2026年2月6日 15:57

先上效果 image.png

一、cesium在react-create-app中的引用

首先 yarn add cesium然后yarn add copy-webpack-plugin -D然后yarn add customize-cra react-app-rewired --dev

设置了customize-cra react-app-rewired就可以改写webpack

image.png 新建一个这个文件,在里面改写webpack

const {
  override,
 
  addWebpackPlugin,
} = require("customize-cra");
const path = require("path");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const webpack = require("webpack");


const cesiumSource = 'node_modules/cesium/Source';
const cesiumWorkers = '../Build/Cesium/Workers';



module.exports = override(
  
 
  addWebpackPlugin(
    new CopyWebpackPlugin({
      patterns: [
        
        { from: path.join(cesiumSource, cesiumWorkers), to: 'cesium/Workers' },
        { from: path.join(cesiumSource, 'Assets'), to: 'cesium/Assets' },
        { from: path.join(cesiumSource, 'Widgets'), to: 'cesium/Widgets' }
      ],
    })
  ),
  addWebpackPlugin(
    new webpack.DefinePlugin({
      // Define relative base path in cesium for loading assets
      CESIUM_BASE_URL: JSON.stringify("/cesium"),
    })
  )
  // addWebpackPlugin(new BundleAnalyzerPlugin())
);


package.json里的打包脚本变成

"scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    
  },

这样就会走我们新设定的webpack,将将node_modules/cesium/Source/Assets, node_modules/cesium/Source/Widgets, node_modules/cesium/Build/Cesium/Workers自动文件打包到build文件里,如图

image.png 这样yarn start 或者yarn build就可以正常使用cesium了

下面这篇我直接按可发布到掘金的技术文章结构帮你整理好了:有背景、有思路、有代码拆解、有优化建议,基本不需要再大改就能发 👍

二、创建 Viewer 且必须只创建一次!

否则:

  • 内存暴涨
  • WebGL context 丢失
  • 页面卡死
useEffect(() => {
  if (!containerRef.current) return;
  if (viewerRef.current) return;

  const viewer = new Cesium.Viewer(containerRef.current, {
    timeline: false,
    animation: false,
    infoBox: false,
    fullscreenButton: false,
  });

  viewerRef.current = viewer;

  return () => {
    viewerRef.current?.destroy();
    viewerRef.current = null;
  };
}, []);
<div ref={containerRef} className="cesium-container" />

这是 Cesium + React 的标准写法


三、加载倾斜摄影模型(3DTiles)

Cesium.Cesium3DTileset.fromUrl("tileset.json")
  .then((tileset) => {
    viewer.scene.primitives.add(tileset);
    viewer.zoomTo(tileset);
  });

一个强烈建议 ⭐⭐⭐⭐⭐

建议监听瓦片失败:

tileset.tileFailed.addEventListener((error) => {
  console.error("瓦片加载失败:", error);
});

否则生产环境排查问题会非常痛苦。


四、动态绘制监测点(核心)

很多人喜欢用:

👉 Primitive
👉 PointPrimitive

但在业务系统中,我更推荐:

⭐ Entity

因为:

  • 开发简单
  • 支持属性绑定
  • 支持 pick
  • 易维护
function createColorIcon(color: string) {
    const canvas = document.createElement("canvas");
    const size = 48;
    canvas.width = size;
    canvas.height = size;
    const ctx = canvas.getContext("2d");
    if (ctx) {
      const cx = size / 2;
      const r = 12;
      const tipY = size - 6;



      // 图钉形状(圆 + 尖)
      ctx.beginPath();
      ctx.moveTo(cx, tipY);
      ctx.quadraticCurveTo(cx + r, r + 12, cx + r, r + 4);
      ctx.arc(cx, r + 4, r, 0, Math.PI, true);
      ctx.quadraticCurveTo(cx - r, r + 12, cx, tipY);
      ctx.closePath();
      ctx.fillStyle = color;
      ctx.fill();



      ctx.fill();
    }

    return canvas;
  }

  const getWarningColor = (p: any) => {
    const level =
      p?.alarmLevel ?? 0;
    switch (Number(level)) {
      case 4:
        return "#D7263D99"; // 红色预警(约 60% 不透明)
      case 3:
        return "#FF6B0099"; // 橙色预警
      case 2:
        return "#C7A20099"; // 黄色预警
      case 1:
        return "#00BEFF99"; // 蓝色预警
      default:
        return "#d3f26199"; // 约 60% 不透明
    }
  };
  useEffect(() => {
    const viewer = viewerRef.current;
    if (!viewer) return;

    if (dataSource && dataSource?.length > 0) {
      pointEntityIdsRef.current.forEach((id) => {
        try {
          viewer.entities.removeById(id);
        } catch (e) {
          // ignore
        }
      });
      pointEntityIdsRef.current = [];
     
      console.log('dataSource', dataSource);
     

      (dataSource || []).forEach((p: any) => {


        const lng = Number(p.longitude);
        const lat = Number(p.latitude);
        if (Number.isNaN(lng) || Number.isNaN(lat)) return;

        const id = `point-${p.id}`;
        viewer.entities.add({
          id,
          position: Cesium.Cartesian3.fromDegrees(lng, lat, Number(p.height) || 0),
          billboard: {
            image: createColorIcon(getWarningColor(p)),
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            // 防止被倾斜摄影挡住
            disableDepthTestDistance: Number.POSITIVE_INFINITY
          },
          label: {
            text: p.pointName || "",
            font: "bold 15px sans-serif",
            fillColor: Cesium.Color.fromCssColorString("rgba(68, 229, 255, 0.92)"),
            outlineColor: Cesium.Color.fromCssColorString("rgba(124, 121, 121, 0.9)").withAlpha(0.85),
            outlineWidth: 2,
            style: Cesium.LabelStyle.FILL_AND_OUTLINE,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            pixelOffset: new Cesium.Cartesian2(0, -50),
            disableDepthTestDistance: Number.POSITIVE_INFINITY,
            distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
              0,
              5000
            ),
          },
          properties: {
            pickable: true,
            pointId: p.id,
            pointType: p?.pointType,
            pointName: p?.pointName,
          }
        });

        pointEntityIdsRef.current.push(id);
      });
      const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);

      handler.setInputAction((movement: any) => {
        const picked = viewer.scene.pick(movement.endPosition);


        if (
          Cesium.defined(picked) &&
          picked.id &&                     // Entity
          picked?.id?.properties?.pickable?.getValue()
        ) {

          viewer.canvas.style.cursor = 'pointer';


        } else {
          viewer.canvas.style.cursor = 'default';


        }
      }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
      // 点击测点
      handler.setInputAction((click: any) => {
        const picked = viewer.scene.pick(click.position);

        if (
          Cesium.defined(picked) &&
          picked.id &&
          picked.id.billboard
        ) {
          const entity = picked.id;

          console.log('点到了:', entity.id, picked?.id?.properties?.pointId?.getValue());

         

          // 示例:相机飞过去
          viewer.flyTo(entity);
          
        }
      }, Cesium.ScreenSpaceEventType.LEFT_CLICK);


    }


  }, [dataSource]);

五、点击测点飞行定位

pick 实体

const picked = viewer.scene.pick(click.position);

判断:

if (Cesium.defined(picked) && picked.id?.billboard) {

然后:

viewer.flyTo(entity);

体验直接拉满。


鼠标 Hover 手型

细节决定高级感:

viewer.canvas.style.cursor = 'pointer';

六、相机环绕动画

核心思路:

👉 clock.onTick

const remove = viewer.clock.onTick.addEventListener(() => {
  heading += Cesium.Math.toRadians(0.2);

  viewer.camera.lookAt(
    center,
    new Cesium.HeadingPitchRange(
      heading,
      Cesium.Math.toRadians(-30),
      range
    )
  );
// 转满一圈停止
  if (heading >= Cesium.Math.TWO_PI) {
    remove();
    viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
  }
});

本质:

👉 每帧改变 heading。

Ant Design Drawer + Autofit.js 布局抽动问题

作者 KKKK
2026年2月6日 15:44

现象

在社区看到了这样一个问题:Drawer组件设置了getContainer并且项目中使用了autofit.js,在打开时会出现动画和整体页面布局抽动的问题。

542617471-fc201930-0c07-4190-acc8-65af565634f7.gif

问题连接:github.com/ant-design/…

根据描述,我本地复现了一下,发现确实有这个问题,当快速点击的时候,抽屉看起来把页面顶上去了 但是很快又恢复正常,现象如下:

  • Drawer 的 mask 被 content-wrapper 顶出视野
  • 按钮和其他元素被推到屏幕外
  • IntersectionObserver 检测到元素不在视野
  • 关闭 mask 或移除 Drawer 内容后恢复正常

背景

1.autofit.js

根据官网介绍:

autofit.js 是一个可以让你的PC项目自适应屏幕的工具,其原理非常简单,即在 scale 等比缩放的基础上,向右或向下增加了宽度或高度,以达到充满全屏的效果,使用 autofit.js 不会挤压、拉伸元素,它只是单纯的设置了容器的宽高。

Autofit.js 的作用: 它通过 transform: scale(...) 强制缩放根容器以适应屏幕。 关键点: transform 属性会创建一个新的堆叠上下文 (Stacking Context)包含块 (Containing Block)。更重要的是,它改变了浏览器对元素位置(Client Rect)的计算方式。

其实就是监听resize/ 视口尺寸变化,实时重计算页面元素的尺寸 / 位置,效果就是滚轮缩放的时候看起来页面是一致的。

2. Drawer 的 DOM 结构

<div class="rc-drawer rc-drawer-bottom" style="overflow: hidden;">
  <!-- mask 先渲染 -->
  <div class="rc-drawer-mask" style="position: absolute; inset: 0; z-index: 1050;">
  </div>
  
  <!-- content-wrapper 后渲染 -->
  <div class="rc-drawer-content-wrapper" 
       style="position: absolute; bottom: 0; z-index: 1051;">
    <!-- 动画进入时添加 transform -->
    <div class="panel-motion-bottom-enter" 
         style="transform: translateY(100%);">
    </div>
  </div>
</div>

问题排查

Ant Design 的 Drawer 在打开瞬间(open={true}),其 Panel 的入场动画通常是从 translateY(100%) (底部) 或类似位置开始。此时,Drawer 内部的内容实际上位于视口之外或者包含块边缘。 此时,Drawer 内部的内容实际上位于视口之外或者包含块边缘

这个效果是正常的,在慢速点击的时候,Drawer由下方动画进入,translateY(100%)最后变成了translateY(0%),问题是,快速点击,Drawer由下方进入,此时竟然把页面元素顶上去了。

也就是问题里描述的抽动。

初步定位方向

聚焦冲突核心:Drawer 的动画效果、mask(遮罩)、内部元素与 autofit.js 的缩放逻辑存在交互异常,导致页面布局瞬态偏移(“推挤” 效应)。

链路分析

1. 容器与动画交互

  • 疑问:Drawer 默认使用绝对定位(absolute/fixed),为何仍会推挤其他元素?
  • 观察:Drawer 的入场动画依赖transform: translateY(100%)(从底部滑入),且通过getContainer指定了自定义容器,而该容器同时被 autofit.js 应用了transform: scale(等比缩放)。
  • 嵌套的 transform 属性可能导致浏览器布局计算异常,尤其动画执行时容器尺寸 / 位置的瞬态变化,被 autofit.js 的resize监听捕捉,触发不必要的重排。

2. mask 的作用

  • 验证:禁用 mask(mask={false})后问题消失,说明 mask 并非直接原因,而是触发了某种关联逻辑。
  • 进一步分析:mask 启用时,Drawer 会激活焦点锁定(Focus Trap)功能;禁用 mask 时,焦点锁定同步失效,推测焦点管理与布局偏移存在关联。

3. 焦点管理机制

  • 排查依赖:定位到 antd Drawer 的底层依赖rc-drawer,其内部的useFocusable Hook(焦点管理钩子)。
  • 发现:useFocusable Hook 在 Drawer 的open属性变为true时,会执行getContainer()?.focus({ preventScroll: true }),即自动聚焦 Drawer 的容器元素。

一开始直接在仓库里,发现复现不了,现在才发现没有同步远程分支,焦点管理是最近才加上的(捂脸)。

层级一:Ant Design (antd/es/drawer)

  • Drawer.tsx: 接收 open, mask 等属性。
  • useFocusable (Antd): 这是一个配置预处理 Hook。它计算出 trap 默认为 true (只要 mask 存在)。
  • 组件传递: 将处理后的 props 传给 rc-drawer

层级二:RC-Drawer (@rc-component/drawer)

  • Drawer.tsx (Wrapper): 渲染 Portal。
  • DrawerPopup.tsx: 实际的 DOM 结构渲染的地方。
  • useFocusable.ts (核心钩子):
    • 代码: useLockFocus(open && mergedFocusTrap, getContainer)
    • 时机: 这里的 useLockFocus 副作用(Effect)通常在 DOM 挂载和 Layout 之后执行。

这一层焦点管理做两件事:

1. 焦点还原,当 Drawer 关闭时,焦点应该回到打开 Drawer 之前的那个按钮上,否则键盘用户会迷失方向。思路就是记下来在 Drawer 打开前,谁是焦点的拥有者?然后在Drawer 关闭后的回调中,让之前的元素重新获得焦点。代码如下:
// src/Drawer.tsx
const Drawer: React.FC<DrawerProps> = props => {
  // ...
  
  // 1. 记录案发地:在 Drawer 打开前,谁是焦点的拥有者?
  const lastActiveRef = React.useRef<HTMLElement>(null);
  useLayoutEffect(() => {
    if (mergedOpen) {
      // 记录当前的 activeElement
      lastActiveRef.current = document.activeElement as HTMLElement;
    }
  }, [mergedOpen]);

  // 2. 还魂:Drawer 关闭后的回调
  const internalAfterOpenChange: DrawerProps['afterOpenChange'] = nextVisible => {
      // ...
      if (
        !nextVisible && // Drawer 关闭了
        focusTriggerAfterClose !== false && // 用户没禁用这个功能
        lastActiveRef.current // 之前记录过
      ) {
        // 让之前的元素重新获得焦点
        lastActiveRef.current?.focus({ preventScroll: true });
      }
    };
  // ...
}


2. 初始聚焦 ,当 Drawer 打开时,焦点必须立刻转移到 Drawer 内部,否则屏幕阅读器用户不知道新内容出现了。但是useLockFocus是干啥的呢?
// src/hooks/useFocusable.ts
export default function useFocusable(
  getContainer: () => HTMLElement,
  open: boolean,
  autoFocus?: boolean,
  // ...
) {
  // ...
  // Focus lock
  useLockFocus(open && mergedFocusTrap, getContainer);
  // Auto Focus 逻辑
  React.useEffect(() => {
    // 如果打开,且 autoFocus 为 true(默认是 true)
    if (open && autoFocus === true) {
      // 强制让 Drawer 的容器获得焦点
      getContainer()?.focus({ preventScroll: true });
    }
  }, [open]);
}

层级三:RC-Util (@rc-component/util)

rc-util 中的 focus.js 实现了 Focus Trap (焦点陷阱) 。它的目的是:把焦点锁死在 Drawer 内部,不让 Tab 键跑到外面的页面去。 useLockFocus里面直接调用了lockFocus,这个函数会在全局添加监听

function lockFocus(element) {
  if (element) {
    // ... 将当前 element 推入栈中管理(支持多层 Drawer 嵌套)

    // 核心:添加全局事件监听
    window.addEventListener('focusin', syncFocus); // 监听焦点移动
    window.addEventListener('keydown', onWindowKeyDown, true); // 监听键盘按键,使用捕获阶段
    syncFocus();
  }
}

可以看到这里添加了focusin的事件监听,继续看syncFocus


function syncFocus() {
  const lastElement = getLastElement(); // 当前激活的 Drawer 容器
  // ...
  if (lastElement && !hasFocus(lastElement)) {
    // 如果焦点不在 Drawer 内部
    // 强制聚焦回 Drawer 内的某个元素
    const matchElement = focusableList[0];
    matchElement?.focus();
  }
}

如果不小心(比如鼠标点击了外部,或者程序代码强行 focus 了外部元素),焦点跑出去了,syncFocus 负责把它抓回来。

低情商:用户手贱点了 Drawer 外面的空白 -> 焦点短暂跑出去 -> focusin 触发 -> syncFocus 发现越界 -> 瞬间把焦点抓回 Drawer 内最近的那个输入框。

image.png

那为什么既需要useLockFocus还需要getContainer()?.focus,既然 useLockFocus 里面已经有 syncFocus 试图把焦点拉进来了,为什么还要再手动 focus 一次?

猜测一下,是为了解耦“锁定”与“初始聚焦”

  • useLockFocus 是持续性状态。它负责的是 open 期间的每一秒,监控 Tab 键和鼠标点击。

  • useEffect 是一次性动作。它只在 open 变为 true 的那一瞬间执行一次。

  • 场景支持:如果你设置 autoFocus={false} 但 focusTrap={true}

    • 期望:打开 Drawer 时,焦点自动跳进去(比如用户可能还在读之前的文章),但如果用户一旦按了 Tab 键或者想点 Drawer 里的东西,焦点就再也出不去了。
    • 如果完全依赖 useLockFocus 的初始化逻辑来做聚焦,你就很难实现这种精细的控制。

事件冒泡:

  • focus 和 blur 事件不冒泡。这意味着当一个元素获得或失去焦点时,只有该元素本身会触发这些事件,其父元素不会收到通知。
  • focusin 和 focusout 事件会冒泡。这意味着当一个元素获得或失去焦点时,该元素本身会触发事件,并且事件会沿着 DOM 树向上传播,触发其祖先元素上的相应事件。

事件触发顺序:

当一个元素获得焦点时,事件触发的顺序是:focusin -> focus

当一个元素失去焦点时,事件触发的顺序是:blur -> focusout

使用场景:

  • 由于 focus 和 blur 不冒泡,它们更适用于处理特定元素的焦点变化,例如:

    • 表单验证:在 blur 事件中检查输入字段的值是否有效。
    • UI 更新:在 focus 事件中高亮输入框,在 blur 事件中移除高亮。
  • 由于 focusin 和 focusout 会冒泡,它们更适用于处理包含多个可聚焦元素的容器的焦点变化,例如:

    • 跟踪焦点:监听容器的 focusin 和 focusout 事件,可以知道焦点是否在容器内,而无需监听每个子元素。
    • 动态添加/移除事件监听器:在容器的 focusin 事件中为获得焦点的元素添加事件监听器,在 focusout 事件中移除监听器。

总结一下链路

  1. 用户操作: 点击按钮打开 Drawer。

  2. AntD: 接收 open={true},将配置传递给 RC-Drawer

  3. RC-Drawer (Drawer.tsx) :

    • useLayoutEffect 记录当前焦点位置(比如那个按钮)到 lastActiveRef
  4. RC-Drawer (DrawerPopup.tsx) :

  5. RC-Drawer (hooks/useFocusable.ts) :

    • useEffect 检测到 open,执行 container.focus() (初始聚焦)。
    • 调用 rc-util 的 useLockFocus
  6. RC-Util (focus.js) :

    • lockFocus 启动。
    • onWindowKeyDown 拦截 Tab 键,确保焦点在 Drawer 内部循环。
    • syncFocus 确保焦点不逃逸。
  7. 用户关闭 Drawer:

    • RC-Util: 解除事件监听,释放“结界”。
    • RC-Drawer (Drawer.tsx)internalAfterOpenChange 触发,读取 lastActiveRef,执行 .focus(),焦点回到最初的按钮。

回到最初的问题

  • Autofit 使用了 transform: scale。这不仅缩放了元素,还改变了浏览器对于“将元素滚动到可视区域”的计算逻辑。
  • Drawer 初始动画位置通常在 translateY(100%)(即屏幕外或边缘)。
  • 当 focus() 发生时,浏览器试图把这个“屏幕边缘”的元素滚动到中心。也就是动画刚开始,聚焦逻辑生效了,把正要进场的抽屉拉到了视野中,把页面元素顶了上去。

因此解决方法就是加一个preventScroll

element.focus({ preventScroll: true });

但是其实还有一些疑问,

1. 为什么只有 Autofit + Drawer 会出问题?(Autofit 到底破坏了什么?)

  • 正常场景 (Without Autofit)

    • Drawer 的 CSS 是 position: fixed
    • 在标准 W3C 规范中,fixed 元素的包含块(Containing Block)是 浏览器视口 (Viewport)
    • 当你对一个刚开始进场、还在屏幕边缘外的 fixed 元素调用 focus() 时,浏览器知道它是“固定”在屏幕上的。即使它是看不见的(off-screen),现代浏览器通常足够智能,或者因为它是相对于视口的,浏览器无法通过滚动 <body> 来让它显示(因为它根本不随 body 滚动),所以浏览器通常会忽略或静默处理这种聚焦请求引发的滚动
  • Autofit 场景 (With Autofit)

    • Autofit 为了做全屏适配,给 <body> 或根容器如 #app 加了一个 transform: scale(...)

    • 核心物理规则变化:根据 CSS 规范,任何设置了 transform 属性非 none 的祖先元素,都会成为其内部 position: fixed 后代元素的包含块

    • 后果

      1. Drawer 的 position: fixed 失效了。虽然它 CSS 还写着 fixed,但它在渲染引擎眼里变成了 position: absolute(相对于被 transform 的那个父容器)。
      2. 当 Drawer 刚打开时(动画第0帧),它位于容器底部(例如 translateY(100%))。
      3. rc-util 执行 focus()
      4. 浏览器现在的逻辑是:“哦,这是一个在容器底部的绝对定位元素,用户想看它,但它现在在可视区域下面。那我必须滚动父容器把这个元素挪进视野里。”
      5. BOOM:页面发生了剧烈滚动。

我试了一下,在容器上加transform:scale(1.0);还真就出现问题了,不过原因是不是上面说的那样,浏览器视口 (Viewport) 不用滚动就不知道了。

2. 为什么很快就正常了?为什么快速点击由于?(时序与竞态分析)

动画结束了......translateY(100%)--------->translateY(0%)

结束

好了,问题解决了,但一般还真想不到,看似是布局引起的问题,最后竟然是焦点管理引起的。。。。。

Electron 打包白屏排查实录:一个 continue_on_error 引发的三天三夜"血案"

作者 CocoonBreak
2026年2月6日 15:41

Electron 打包白屏排查实录:一个 continue_on_error 引发的三天三夜"血案"

本文记录了我在开源项目 AionUi(一个统一 AI Agent 图形界面)中遭遇的一次 Electron 打包白屏事故。从发现问题到最终定位根因,历时三天三夜。如果你也在用 Electron + GitHub Actions 做 CI/CD,这篇文章或许能帮你避开一个隐蔽的大坑。

故事的开始:一个"完美"的 CI 构建

那是一个平静的夜晚。

我像往常一样推送了一个版本到 dev 分支,GitHub Actions 开始了它忠实的自动构建工作。十几分钟后,CI 亮起了绿灯——所有平台构建成功。

"不错,今天又是顺利的一天。"

我下载了 macOS 的 DMG,拖进 Applications,双击启动——

白屏。

纯白的、一尘不染的、令人窒息的白屏。像是这个 App 在用行为艺术表达它对这个世界的失望。

没有报错弹窗,没有崩溃提示,就是一片白。

第一天:本地没问题,那一定是 CI 的问题

作为一个经历过无数"在我机器上是好的"名场面的工程师,我的第一反应是——先在本地复现。

npm start  # 本地开发 ✅ 正常
node scripts/build-with-builder.js arm64 --mac --arm64  # 本地打包 ✅ 正常

本地打包出来的 DMG 安装后运行完全正常。

这就尴尬了。经典的"本地好好的,CI 就炸"。

我打开 Electron 的开发者工具日志,终于看到了这个错误:

Not allowed to load local resource:
file:///Applications/AionUi.app/Contents/Resources/app/.webpack/renderer/main_window/index.html

ERR_FILE_NOT_FOUND——Electron 找不到渲染进程的入口文件。这个 index.html 是 webpack 打包生成的,没有它,整个界面就是一片白。

第一个嫌疑人:tar v7

翻看最近的 package.json 变更,我注意到一个依赖升级:

{
  "overrides": {
    "tar": "^7.5.7"  // 从 ^6.2.1 升级
  }
}

tar v7 是一个 breaking change 版本,API 完全重写。而 electron-builder 在打包时需要处理 tar/asar 归档。会不会是 tar v7 导致 asar 打包出了问题?

我花了大半天时间研究 tar v7 的兼容性:

  • 翻了 npm 的 changelog
  • 查了 electron-builder 的源码
  • 对比了 tar v6 和 v7 的 API 差异

结论:tar v7 是无辜的。它修复的是 CVE-2026-23745 安全漏洞,electron-builder 并不直接依赖 tar 的 API。

第一天,白忙。

第二天:深入 asar 的内心世界

既然不是 tar 的问题,那就得看看 CI 打包出来的产物到底长什么样。

我从 CI artifacts 下载了 DMG,挂载后检查 asar:

# 本地打包的 asar
npx asar list /Applications/AionUi-Local.app/Contents/Resources/app.asar | grep index.html
# ✅ .webpack/renderer/main_window/index.html  存在

# CI 打包的 asar
npx asar list /Applications/AionUi-CI.app/Contents/Resources/app.asar | grep index.html
# ❌ 没有任何输出

找到了! CI 打包出来的 asar 里压根没有 index.html

这意味着 webpack 的产物在 CI 环境下根本没有生成。但 CI 构建明明显示成功了啊?

转折点:一条被吞掉的错误

带着疑惑,我仔细翻看了 CI 的构建日志。在数百行日志的角落里,我发现了这个:

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed
- JavaScript heap out of memory

Node.js 内存溢出了!

webpack 在打包过程中吃光了内存,进程直接崩溃。但是——为什么 CI 没有报错?

答案就在 GitHub Actions 的 workflow 配置里:

- name: Build with electron-builder (non-Windows)
  uses: nick-fields/retry@v3
  with:
    timeout_minutes: 80
    max_attempts: 2
    continue_on_error: true  # ← 就是你!
    command: ${{ matrix.command }}

continue_on_error: true——这个设置的本意是:macOS 构建有时会因为 Apple 公证服务超时而失败,为了不阻塞其他平台的构建,设置了继续执行。

但它也悄悄地吞掉了 OOM 错误。webpack 崩溃了,HTML 没生成,但 CI 一脸无事地继续往下走,把一个残缺的产物打包成了 DMG 并上传。

这就像一个建筑工人告诉你"房子盖好了",但实际上里面连墙都没有。

为什么只有 macOS 炸了?

这里有一个有趣的细节:Windows 和 Linux 的构建都正常,唯独 macOS 白屏。

原因在于 Node.js V8 引擎的内存管理机制。V8 有一个人为设定的堆内存上限(64 位系统默认约 4GB),这个限制独立于物理内存。

GitHub Actions 各平台 Runner 虽然标称内存相同(7GB),但实际可用内存差异很大:

GitHub Actions Runner (7GB RAM)
├── macOS 系统开销 (~1.5GB)
├── Xcode / 签名工具 (~0.5GB)
├── npm / 其他进程 (~0.5GB)
└── Node.js 可用 (~4.5GB)
    └── V8 默认堆上限: ~4GB  ← webpack 需要 ~5GB,超了!

macOS 系统本身就比 Linux 更"臃肿",加上 Xcode 工具链、代码签名服务等额外负担,留给 Node.js 的空间更少。当 webpack 打包一个包含 Monaco Editor、Arco Design、多个 AI SDK 的大型 Electron 项目时,内存消耗刚好卡在了 macOS 的临界点上

Windows 和 Linux 刚好没超,macOS 刚好超了。差的可能就是那几百 MB。

这也解释了为什么这个问题之前没出现——随着项目不断壮大,依赖越来越多,webpack 的内存消耗在某个版本终于突破了 macOS 上的 4GB 天花板。

第三天:修复与反思

修复方案

修复本身只需要一行:

env:
  NODE_OPTIONS: "--max-old-space-size=8192"

这不是"给 Node.js 更多物理内存"——Runner 的物理内存还是 7GB,不会凭空变多。

这是解除 V8 引擎的人为限制。告诉 V8:"如果需要,你可以用到 8GB"。实际上 webpack 只会用到它需要的量(约 5GB)。因为 5GB < 8GB(新上限),所以不再触发 OOM。

情况 V8 堆上限 实际需要 系统可用 结果
修复前 ~4GB (默认) ~5GB ~4.5GB OOM
修复后 8GB (手动) ~5GB ~4.5GB 成功

防御性措施

光修 OOM 不够。真正的问题是 continue_on_error: true 让构建失败变成了"沉默的杀手"。

我重写了 macOS 构建步骤,将公证失败构建失败区分开来:

- name: Build with electron-builder (macOS)
  run: |
    set +e
    ${{ matrix.command }} 2>&1 | tee build.log
    BUILD_EXIT_CODE=${PIPESTATUS[0]}

    # 检查 DMG 是否生成
    if ls out/*.dmg 1>/dev/null 2>&1; then
      DMG_EXISTS=true
    fi

    if [ $BUILD_EXIT_CODE -eq 0 ]; then
      exit 0  # 完全成功 ✅
    fi

    # DMG 存在但构建失败 → 可能只是公证问题
    if [ "$DMG_EXISTS" = true ]; then
      if grep -qiE "notariz|staple" build.log; then
        echo "⚠️ DMG 构建成功,但公证失败"
        exit 0  # 允许继续
      fi
    fi

    # DMG 都没生成 → 真正的构建失败
    exit 1  # ❌ 阻断 CI

逻辑很简单:

  • 构建成功 + 公证成功 → 全部通过 ✅
  • DMG 生成了但公证失败 → 警告但不阻塞 ⚠️(用户右键打开即可)
  • DMG 都没生成 → 直接失败 ❌(这才是真问题)

不再一刀切地 continue_on_error,而是精准区分错误类型

番外篇:AionUi 的"混血"打包架构

讲完了 bug 本身,我想聊聊这个 bug 之所以能藏这么深的根本原因——AionUi 的打包流程本身就不走寻常路。

传统开源项目怎么打包?

大多数 Electron 开源项目的打包流程是这样的:

方案 A:纯 Electron Forge
源代码 → electron-forge make → DMG/EXE/DEB
(开发、编译、打包一条龙)

方案 B:纯 electron-builder
源代码 → webpack/vite 编译 → electron-builder → DMG/EXE/DEB
(自己编译,builder 负责打包)

简单直接。一个工具从头管到尾,出了问题也好排查。

AionUi 为什么要"混血"?

AionUi 的打包流程长这样:

源代码
  
Step 1: Electron Forge (webpack 编译)
  ├── WebpackPlugin 编译 main process
  ├── WebpackPlugin 编译 renderer process  index.html   白屏就是这里没生成
  └── 输出到 .webpack/ 目录
  
Step 2: 产物校验
  └── 检查 .webpack/renderer/main_window/index.html 是否存在
  
Step 3: electron-builder (分发打包)
  ├── 读取 electron-builder.yml 配置
  ├──  .webpack/ 打入 asar 归档
  ├── afterPack: 重建原生模块 (better-sqlite3, node-pty...)
  └── afterSign: 代码签名 + Apple 公证
  
DMG / ZIP / EXE / DEB

为什么不直接用一个工具?因为 AionUi 的需求太"拧巴"了:

需要 Electron Forge 的原因:

  • 它的 WebpackPlugin 对 Electron 多进程架构(main + renderer + preload)有开箱即用的支持
  • 开发时的 HMR 热更新、DevServer、日志端口管理都做得很好
  • FusesPlugin 可以在打包时控制 Electron 安全特性(禁用 RunAsNode、启用 Cookie 加密等)

需要 electron-builder 的原因:

  • macOS 代码签名 + Apple 公证(Forge 的 maker 支持有限)
  • 跨架构编译(在 arm64 机器上构建 x64 包,反之亦然)
  • 精细的 asar 控制(哪些模块打包、哪些解压、哪些排除)
  • 多格式输出(DMG + ZIP 同时生成)
  • 更成熟的 CI/CD 集成

单独用 Forge 做不了完善的公证;单独用 electron-builder 又没有 Forge 的 webpack 集成好用。所以 AionUi 用了一个混血方案——Forge 负责编译,electron-builder 负责打包。

混血的代价:两个工具之间的"信任边界"

这个方案的核心脚本是 build-with-builder.js,它充当了两个工具之间的"桥梁":

// Step 1: 让 Forge 编译 webpack
execSync(`npm exec electron-forge -- package --arch=${targetArch}`);

// Step 2: 把 .webpack/ 目录结构整理成 electron-builder 期望的样子
ensureDir(sourceDir, webpackDir, 'main');
ensureDir(sourceDir, webpackDir, 'renderer');

// Step 3: 校验关键产物
if (!fs.existsSync(rendererIndex)) {
  throw new Error('Missing renderer entry');
}

// Step 4: 让 electron-builder 打包
execSync(`npx electron-builder ${builderArgs} --${targetArch}`);

问题就出在这里:Forge 和 electron-builder 之间没有原生的握手机制。Forge 编译完就完了,至于 .webpack/ 目录里到底有没有该有的文件,它不管。electron-builder 拿到 .webpack/ 就打包,至于里面是不是空的,它也不管。

当 webpack 因为 OOM 中途崩溃时,.webpack/ 目录可能是"半成品"——main 进程的代码可能已经编译好了(因为它先编译),但 renderer 的 index.html 还没来得及生成。electron-builder 照样把这个半成品打进了 asar,产出了一个"看起来正常但其实没有界面"的 DMG。

这就是混血架构的代价:两个工具之间的信任边界,恰好是 bug 的藏身之处

与传统方案的对比

维度 传统方案 (单工具) AionUi (混血方案)
编译 工具内置 Electron Forge (WebpackPlugin)
打包 同一工具 electron-builder
签名/公证 工具内置或手动 electron-builder + afterSign.js
原生模块 工具自动处理 afterPack.js 手动重建
错误传播 直通,容易发现 跨工具,可能被吞掉
灵活性 受限于单工具 高(可单独定制每个环节)
复杂度

原生模块:另一个深坑

AionUi 不是一个纯 JS 应用。它依赖了多个原生 C++ 模块:

  • better-sqlite3 — 本地数据库,存储对话历史和设置
  • node-pty — 终端模拟,用于运行 CLI AI 工具
  • tree-sitter — 代码解析,用于语法高亮

这些模块必须针对目标平台和架构编译成 .node 二进制文件。在 afterPack.js 中,AionUi 实现了一套完整的跨架构重建逻辑:

// 交叉编译时,先清理错误架构的二进制
if (isCrossCompile) {
  // 删除 build/ 目录(包含错误架构的编译产物)
  fs.rmSync(buildDir, { recursive: true, force: true });
  // 删除对立架构的可选依赖包
  // 比如目标是 arm64,就删除所有 *-x64 的包
}

// 然后为目标架构重新编译
rebuildSingleModule({
  moduleName, moduleRoot,
  platform: electronPlatformName,
  arch: targetArch,
  electronVersion
});

这意味着一次 macOS arm64 构建实际上要经历:webpack 编译 → Forge 打包 → electron-builder 打包 → 原生模块重建 → 代码签名 → Apple 公证,六个步骤。任何一步失败都可能导致最终产物有问题。

为什么不简化?

说实话,我也想简化。但现实是:

  1. Forge 的 maker 不支持 Apple notarytool — 这是硬伤,没法绕过
  2. electron-builder 的 webpack 集成不如 Forge — 特别是多入口(main + renderer + preload + worker)场景
  3. 原生模块的跨架构编译 — 需要精细控制,两个工具各自的方案都不够灵活
  4. 安全特性 — Electron Fuses 只有 Forge 的 FusesPlugin 支持得好

所以这个"混血"方案虽然复杂,但在当前的 Electron 生态下,它是 AionUi 这种重量级桌面应用的实际最优解

代价就是——当 bug 出现在两个工具的"交界处"时,排查难度会指数级上升。就像这次的白屏事故。

经验总结

1. continue_on_error 是一把双刃剑

它能防止非关键失败阻塞流水线,但也能让关键错误悄无声息地溜走。如果一定要用,请确保有额外的产物校验逻辑,而不是无条件信任构建命令的退出码。

2. CI 绿灯 ≠ 构建成功

特别是在 Electron 这种多步骤构建(webpack → electron-forge → electron-builder → 签名 → 公证)的场景下,任何一个环节的静默失败都可能产出一个"看起来没问题但其实不能用"的安装包。

建议:在 build 脚本最后加一个产物校验:

const rendererIndex = path.join(webpackDir, 'renderer', 'main_window', 'index.html');
if (!fs.existsSync(rendererIndex)) {
  throw new Error('Missing renderer entry: .webpack/renderer/main_window/index.html');
}

3. OOM 是一个平台相关的"薛定谔 Bug"

同样的代码,同样的 webpack 配置,在 Windows 不 OOM、在 macOS 就 OOM。它可能今天不出现,明天加了一个依赖就出现了。对于大型 Electron 项目,主动设置 --max-old-space-size 是一个好习惯,不要等到 OOM 了才想起来。

4. 永远验证最终产物

不要相信过程,要验证结果。在 CI 流水线里加一步检查最终产物是否存在且完整,能省去无数个排查白屏的深夜。

写在最后

三天三夜,从怀疑 tar v7、到拆解 asar、到翻遍数百行 CI 日志,最终发现是一个 continue_on_error: true 配合 Node.js OOM 造成的"完美犯罪"。

修复只用了一行配置。但找到这一行的过程,让我深刻理解了一个道理:

最难调试的 bug,不是会报错的 bug,而是假装没有 bug 的 bug。


如果你也在做 Electron 开源项目,或者正在被 CI 打包问题折磨,欢迎关注 AionUi —— 一个将命令行 AI Agent 变成现代聊天界面的桌面应用,支持 Gemini CLI、Claude Code、Codex、通义灵码等多种 AI 工具。

扫码_搜索联合传播样式-白色版.png

《TanStack Start 深入解析:Single Flight Mutations 机制(第二篇)》

2026年2月6日 15:34

原文:Single Flight Mutations in TanStack Start: Part 2

作者:Adam Rackis

日期:2026年1月28日

翻译:TUARAN

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


TL;DR

这篇文章延续 Part 1 的思路:把一次 mutation 所需的 UI 更新数据,在同一次网络往返里一起带回来(避免 mutation 后再额外发请求 refetch)。

Part 2 的重点是把“要 refetch 哪些查询”抽成可复用的 middleware:调用 server function 时传入 react-query 的 QueryKey[],middleware 会在客户端从 Query Cache 找到每个 query 对应的 serverFn 和参数,把这些信息通过 sendContext 送到服务端统一执行,然后把结果回传给客户端并用 setQueryData 写回缓存。


Part 1 里,我们聊过 single flight mutations:它让你在更新数据时,同时把 UI 需要的所有相关“已更新数据”重新获取回来,并且整个过程只需要一次跨网络的往返。

我们当时做了个很朴素的实现:在“更新数据”的 server function 里直接把需要的东西 refetch 一遍。它确实能用,但可扩展性和灵活性都一般(耦合也偏重)。

这篇文章我们会实现同样的效果,但方式更通用:定义一个“refetch middleware”,把它挂到任意 server function 上。这个 middleware 允许我们通过 react-query 的 key 指定要 refetch 的数据,剩下的事情它会自动完成。

我们会先做一个最简单版本,然后不断加能力、加灵活性。到最后会稍微复杂一些,但请别误会:你不需要把文中讲的全部都用上。事实上,对绝大多数应用来说,single flight mutations 可能完全无关紧要。更别被“高级做法”迷惑了:对很多小应用而言,直接在 server function 里 refetch 一点数据可能就足够了。

不过,跟着做一遍,我们会看到一些很酷的 TanStack(甚至 TypeScript)特性。即便你永远不用 single flight mutations,这些内容也很可能在别的场景派上用场。

我们的第一个 Middleware

TanStack Query(我们有时也会称它为 react-query,这是它的包名)已经有一套非常好用的层级 key 系统。如果我们的 middleware 能直接接收“要 refetch 的 query keys”,然后就……自动搞定,那该多好?

问题在于:middleware 要怎么知道“怎么 refetch”呢?第一眼看确实有点难。我们的 queries(刻意保持简单)本质上都是对 server functions 的调用。但我们没法把一个普通函数引用传到服务端;函数不可序列化,这很合理。你能把字符串/数字/布尔值序列化成 JSON 在线上传输,但一个函数可能带状态、闭包、上下文……传过去根本说不清。

除非——它是 TanStack Start 的 server function。

这个项目背后的工程师们为序列化引擎做了定制,使其支持 server functions。也就是说:你可以从客户端把一个 server function “发到”服务端,它能正常工作。底层原理是:server functions 有一个内部 ID。TanStack 会捕捉到它、发送 ID,然后在另一端把 ID 反序列化成对应的 server function。

为了让事情更简单,我们不妨把 server function(以及它需要的参数)直接放到我们已经定义好的 query options 上。这样 middleware 只要拿到 query keys,就能从 TanStack Query 的 cache 里找到对应的 query options,拿到“如何 refetch”的信息,然后把整个流程串起来。

开始吧

首先引入一些好用的东西:

import { createMiddleware, getRouterInstance } from "@tanstack/react-start";
import { QueryClient, QueryKey } from "@tanstack/react-query";

接着更新我们的 epics 列表查询(主要的 epics 列表)的 query options:

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    queryKey: ["epics", "list", page],
    queryFn: async () => {
      const result = await getEpicsList({ data: page });
      return result;
    },
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
    meta: {
      __revalidate: {
        serverFn: getEpicsList,
        arg: page,
      },
    },
  });
};

注意这个新增的 meta 区块。它允许我们往 query 上塞任何我们需要的元数据。这里我们放了 getEpicsList 这个 server function 的引用以及它需要的参数。这样写确实会有“重复”(queryFn 写了一次调用方式,meta 又写了一次),如果你觉得别扭,先别急,后面会处理。summary 查询(用于统计数量)我们也会同样更新,不过这里没贴代码。

接下来我们把 middleware 一点点拼出来:

// the server function and args are all `any`, for now, 
// to keep things simple we'll see how to type them in a bit
type RevalidationPayload = {
  refetch: {
    key: QueryKey;
    fn: any;
    arg: any;
  }[];
};

type RefetchMiddlewareConfig = {
  refetch: QueryKey[];
};

export const refetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

我们为 middleware 定义了一个输入。这个输入会自动与“挂载该 middleware 的 server function 的输入”合并。

我们把输入写成可选的(config?),因为完全可能出现这种情况:你只想调用 server function,但并不想 refetch 任何东西。

然后开始写 .client 回调(在浏览器中运行):先拿到要 refetch 的 keys:

const { refetch = [] } = data ?? {};

接着我们拿到 queryClient 和它的 cache,并创建一个 payload,之后会通过 sendContext 发到 .server 回调,让它执行真正的 refetch。

如果你对 TanStack middleware 不熟,我之前写的 middleware 文章 可能会更适合作为入门。

const router = await getRouterInstance();
const queryClient: QueryClient = router.options.context.queryClient;
const cache = queryClient.getQueryCache();

const revalidate: RevalidationPayload = {
  refetch: [],
};

我们的 queryClient 已经挂在 TanStack router 的 context 上,所以只要拿到 router 再取出来即可。

还记得我们把 __revalidate 塞到 query options 的 meta 里吗?现在我们针对每个 key 去 cache 里找对应 query,并把 serverFn/arg 抽出来组装成要发给服务端的 payload。

refetch.forEach((key: QueryKey) => {
  const entry = cache.find({ queryKey: key, exact: true });
  if (!entry) return;

  const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

  if (revalidatePayload) {
    revalidate.refetch.push({
      key,
      fn: revalidatePayload.serverFn,
      arg: revalidatePayload.arg,
    });
  }
});

if (!entry) return; 是为了防止请求里包含了“当前缓存里根本不存在”的 query(也就是说,它可能从未在 UI 里被请求过)。这种情况下我们拿不到 serverFn,也就无法 refetch。

你也可以把 middleware 输入扩展得更丰富:比如对那些“无论是否在缓存里都必须执行”的 refetch,直接把 serverFn + arg 一起传上去。比如你打算 mutation 后 redirect,并希望新页面的数据能预取。本文不实现这个变体,但它只是同一主题的另一种组合。

接着我们调用 next,触发真正的 server function(以及其它 middleware)。通过 sendContext 我们把 revalidate 发到服务端:

const result = await next({
  sendContext: {
    revalidate,
  },
});

result 是 server function 调用的返回值。它的 context 上会有一个 payloads 数组(由下方 .server 回调返回),其中每一项都包含 key(query key)和 result(对应数据)。我们遍历并写回 query cache。

我们稍后会修复这里用 // @ts-expect-error 遮掉的 TS 错误:

// @ts-expect-error
for (const entry of result.context?.payloads ?? []) {
  queryClient.setQueryData(entry.key, entry.result);
}

return result;

服务端回调

服务端回调完整代码如下:

.server(async ({ next, context }) => {
  const result = await next({
    sendContext: {
      payloads: [] as any[]
    }
  });

  const allPayloads = context.revalidate.refetch.map(refetchPayload => {
    return {
      key: refetchPayload.key,
      result: refetchPayload.fn({ data: refetchPayload.arg })
    };
  });

  for (const refetchPayload of allPayloads) {
    result.sendContext.payloads.push({
      key: refetchPayload.key,
      result: await refetchPayload.result
    });
  }

  return result;
});

我们会立刻调用 next(),它会执行这个 middleware 所挂载的 server function。我们在 sendContext 里传入一个 payloads 数组:这个数组决定了“服务端最终会发回给客户端回调的数据结构”(也就是 .client 里循环的那份 payloads)。

然后我们遍历客户端通过 sendContext 传上来的 revalidate payload,并从 context 上读出来(是的:send context,发上来再从 context 读出来)。接着调用所有 server functions,并把结果 push 到 payloads 数组里。

把前后拼起来,这就是完整 middleware:

export const refetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;
    const cache = queryClient.getQueryCache();

    const revalidate: RevalidationPayload = {
      refetch: [],
    };

    refetch.forEach((key: QueryKey) => {
      const entry = cache.find({ queryKey: key, exact: true });
      if (!entry) return;

      const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

      if (revalidatePayload) {
        revalidate.refetch.push({
          key,
          fn: revalidatePayload.serverFn,
          arg: revalidatePayload.arg,
        });
      }
    });

    const result = await next({
      sendContext: {
        revalidate,
      },
    });

    // @ts-expect-error
    for (const entry of result.context?.payloads ?? []) {
      queryClient.setQueryData(entry.key, entry.result);
    }

    return result;
  })
  .server(async ({ next, context }) => {
    const result = await next({
      sendContext: {
        payloads: [] as any[],
      },
    });

    const allPayloads = context.revalidate.refetch.map(refetchPayload => {
      return {
        key: refetchPayload.key,
        result: refetchPayload.fn({ data: refetchPayload.arg }),
      };
    });

    for (const refetchPayload of allPayloads) {
      result.sendContext.payloads.push({
        key: refetchPayload.key,
        result: await refetchPayload.result,
      });
    }

    return result;
  });

修复 TypeScript 报错

为什么下面这一行是无效的?

// @ts-expect-error
for (const entry of result.context?.payloads ?? []) {

这段代码运行在 .client 回调里,并且是在我们调用 next() 之后运行的。本质上,我们是在服务端读取“发送回客户端的数据”(通过 sendContext 传回来的 payload)。这段代码在运行时确实能工作,那为什么类型对不上?

我在上面提到的 middleware 文章里解释过:服务端回调能“看见”客户端发给它的内容,但反过来不成立。这种信息天生就不是双向可见的;类型推断也没法倒着跑。

解决方式很简单:把 middleware 拆成两段,让后一段 middleware 依赖前一段。

const prelimRefetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;

    // same
    // as
    // before

    return await next({
      sendContext: {
        revalidate,
      },
    });

    // those last few lines are removed
  })
  .server(async ({ next, context }) => {
    const result = await next({
      sendContext: {
        payloads: [] as any[],
      },
    });

    // exactly the same as before

    return result;
  });

export const refetchMiddleware = createMiddleware({ type: "function" })
  .middleware([prelimRefetchMiddleware]) // <-------- connect them!
  .client(async ({ next }) => {
    const result = await next();

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;

    // and here's those last few lines we removed from above
    for (const entry of result.context?.payloads ?? []) {
      queryClient.setQueryData(entry.key, entry.result);
    }

    return result;
  });

整体逻辑不变,只是把 .client 回调里 next() 之后那部分移到了单独的 middleware 里。其余部分留在另一个 middleware 中,并作为输入传给新的这个 middleware。这样当我们在 refetchMiddleware 里调用 next 时,TypeScript 就能看到“从服务端发下来的 context 数据”,因为这些数据是在 prelimRefetchMiddleware 里发送的,而它又是本 middleware 的输入,因此 TS 可以完整看清类型流动。

接起来

现在我们回到“更新 epic”的 server function:把之前的手动 refetch 移除,改为使用 refetch middleware。

export const updateEpic = createServerFn({ method: "POST" })
  .middleware([refetchMiddleware])
  .inputValidator((obj: { id: number; name: string }) => obj)
  .handler(async ({ data }) => {
    await new Promise(resolve => setTimeout(resolve, 1000 * Math.random()));
    await db.update(epicsTable).set({ name: data.name }).where(eq(epicsTable.id, data.id));
  });

在 React 组件中通过 useServerFn 来调用它;这个 hook 会自动处理错误、重定向等。

const runSave = useServerFn(updateEpic);

还记得我说过:middleware 的输入会自动与底层 server function 的输入合并吗?当我们调用这个 server function 时就能看到:

图 1:一个 handleSaveFinal 函数的代码片段,保存输入值并调用 runSave,参数对象包含 id 和 name。转存失败,建议直接上传图片文件

unknown[] 对 react-query 的 query key 来说就是正确类型)

现在我们可以这样调用它,并指定要 refetch 的查询:

await runSave({
  data: {
    id: epic.id,
    name: newValue,
    refetch: [
      ["epics", "list", 1],
      ["epics", "list", "summary"],
    ],
  },
});

运行后,一切正常:epics 列表和 summary 都会在没有任何新网络请求的情况下更新。测试 single flight mutations 时,你其实不是在找“发生了什么”,而是在找“什么都没发生”——也就是 Network 面板里缺少那些本该出现的额外请求。

再改进

react-query 的 query keys 是层级结构的,你可能很熟悉这种写法:

queryClient.invalidateQueries({ queryKey: ["epics", "list"] });

它会 refetch 任何 key 以 ["epics", "list"] 开头的 queries。我们的 middleware 能不能也支持这种“key 前缀”呢?也就是只传一个 key prefix,让它找出所有匹配项并 refetch。

可以,开干。

匹配 key 会稍复杂一点:每个传入的 key 可能是 prefix,会匹配多条 cache entry,所以我们用 flatMap 来找出所有匹配项,再利用 cache.findAll(很好用)。

const allQueriesFound = refetch.flatMap(
  k => cache.findAll({ queryKey: k, exact: false })
);

然后循环并做和之前一样的事:

const allQueriesFound = refetch.flatMap(
  k => cache.findAll({ queryKey: k, exact: false })
);

allQueriesFound.forEach(entry => {
  const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

  if (revalidatePayload) {
    revalidate.refetch.push({
      key: entry.queryKey,
      fn: revalidatePayload.serverFn,
      arg: revalidatePayload.arg,
    });
  }
});

这就能用了。

更进一步

不过我们的方案仍然不理想。假设用户在 epics 页面翻页:到第 2 页、到第 3 页、再回到第 1 页。我们的逻辑会找到第 1 页和 summary query,但也会把第 2、3 页一并找到(因为它们现在也在 cache 里)。然而第 2、3 页并不活跃,也不在屏幕上展示,我们不应该 refetch 它们。

我们可以只 refetch active queries:只要给 findAll 加上 type 参数即可。

cache.findAll({ queryKey: key, exact: false, type: "active" });

于是代码就变成这样:

const allQueriesFound = refetch.flatMap(key => cache.findAll({ queryKey: key, exact: false, type: "active" }));

allQueriesFound.forEach(entry => {
  const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

  if (revalidatePayload) {
    revalidate.refetch.push({
      key: entry.queryKey,
      fn: revalidatePayload.serverFn,
      arg: revalidatePayload.arg,
    });
  }
});

更更进一步

这样就能工作了。但你仔细想想,那些 inactive 的 queries 其实应该被 invalidated。我们不希望立刻 refetch 它们(浪费资源,而且用户没在看),但如果用户又翻回那些页面,我们希望触发一次重新获取。TanStack Query 通过 invalidateQueries 很容易做到。

我们把这段加到“被依赖的那个 middleware”的 client 回调里:

data?.refetch.forEach(key => {
  queryClient.invalidateQueries({ queryKey: key, exact: false, type: "inactive", refetchType: "none" });
});

遍历传入的 query keys,把所有匹配的 inactive queries 标记为无效,但不立刻 refetch(refetchType: "none")。

下面是更新后的完整 middleware:

const prelimRefetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;
    const cache = queryClient.getQueryCache();

    const revalidate: RevalidationPayload = {
      refetch: [],
    };

    const allQueriesFound = refetch.flatMap(key => cache.findAll({ queryKey: key, exact: false, type: "active" }));

    allQueriesFound.forEach(entry => {
      const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

      if (revalidatePayload) {
        revalidate.refetch.push({
          key: entry.queryKey,
          fn: revalidatePayload.serverFn,
          arg: revalidatePayload.arg,
        });
      }
    });

    return await next({
      sendContext: {
        revalidate,
      },
    });
  })
  .server(async ({ next, context }) => {
    const result = await next({
      sendContext: {
        payloads: [] as any[],
      },
    });

    const allPayloads = context.revalidate.refetch.map(refetchPayload => {
      return {
        key: refetchPayload.key,
        result: refetchPayload.fn({ data: refetchPayload.arg }),
      };
    });

    for (const refetchPayload of allPayloads) {
      result.sendContext.payloads.push({
        key: refetchPayload.key,
        result: await refetchPayload.result,
      });
    }

    return result;
  });

export const refetchMiddleware = createMiddleware({ type: "function" })
  .middleware([prelimRefetchMiddleware])
  .client(async ({ data, next }) => {
    const result = await next();

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;

    for (const entry of result.context?.payloads ?? []) {
      queryClient.setQueryData(entry.key, entry.result, { updatedAt: Date.now() });
    }

    data?.refetch.forEach(key => {
      queryClient.invalidateQueries({ queryKey: key, exact: false, type: "inactive", refetchType: "none" });
    });

    return result;
  });

我们告诉 TanStack Query:把匹配 key 的 inactive queries 置为 invalid(但不 refetch)。

这个方案非常好用:如果你浏览到第 2、3 页,然后回到第 1 页,再编辑一个 todo,你会看到第 1 页列表和 summary 立刻更新。之后如果你再翻回第 2、3 页,你会看到网络请求触发,从而拿到新数据。

锦上添花

还记得我们把 server function 和参数塞进 query options 时的写法吗?

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    queryKey: ["epics", "list", page],
    queryFn: async () => {
      const result = await getEpicsList({ data: page });
      return result;
    },
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
    meta: {
      __revalidate: {
        serverFn: getEpicsList,
        arg: page,
      },
    },
  });
};

我之前提过:在 metaqueryFn 里重复写 serverFn/arg 有点“脏”。我们来修一下。

先从最简单的 helper 开始:

export function refetchedQueryOptions(queryKey: QueryKey, serverFn: any, arg?: any) {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

这个 helper 会接收 query key、server function 和参数,然后返回 query options:

  • 拼好的 queryKey(必要时把 arg 追加进去)
  • queryFn(直接调用 server function)
  • meta.__revalidate(同样记录 server function 和参数)

于是 epics 列表 query 就可以写成:

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    ...refetchedQueryOptions(["epics", "list"], getEpicsList, page),
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
  });
};

它能工作,但类型不好:到处都是 any,意味着传给 server function 的参数不做类型检查;更糟的是,queryFn 的返回值也不会被检查,于是你的 query(比如这个 epics 列表)会变成返回 any

我们来加点类型。

server functions 本质上是函数:接收一个对象参数;如果 server function 定义了输入,那么这个对象会包含一个 data 属性,里面就是输入。说一堆大白话不如看调用例子:

const result = await runSaveSimple({
  data: {
    id: epic.id,
    name: newValue,
  },
});

第二版 helper 可以这样写:

export function refetchedQueryOptions<T extends (arg: { data: any }) => Promise<any>>(
  queryKey: QueryKey,
  serverFn: T,
  arg: Parameters<T>[0]["data"],
) {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async (): Promise<Awaited<ReturnType<T>>> => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

我们把 server function 约束为一个 async 函数,且它的参数对象上有 data;然后用它来静态推断 arg 的类型。这已经不错了,但当你把它用在“没有参数”的 server function 上时会报错:

...refetchedQueryOptions(["epics", "list", "summary"], getEpicsSummary)
// Expected 3 arguments, but got 2.

你传 undefined 可以解决,功能也正常:

...refetchedQueryOptions(["epics", "list", "summary"], getEpicsSummary, undefined),

如果你是个正常人,你大概会觉得这已经很好了,而且确实如此。但如果你像我一样有点“怪”,你可能会想能不能做到更完美:

  • 当 server function 有参数时:必须传入且类型要正确
  • 当 server function 没参数时:允许省略 arg

TypeScript 有一个特性正好适合:函数重载(overloaded functions)

这篇文章已经够长了,所以我直接贴代码,解读留作读者练习(以及可能的未来文章)。

import { QueryKey, queryOptions } from "@tanstack/react-query";

type AnyAsyncFn = (...args: any[]) => Promise<any>;

type ServerFnArgs<TFn extends AnyAsyncFn> = Parameters<TFn>[0] extends infer TRootArgs
  ? TRootArgs extends { data: infer TResult }
    ? TResult
    : undefined
  : never;

type ServerFnHasArgs<TFn extends AnyAsyncFn> = ServerFnArgs<TFn> extends infer U ? (U extends undefined ? false : true) : false;

type ServerFnWithArgs<TFn extends AnyAsyncFn> = ServerFnHasArgs<TFn> extends true ? TFn : never;
type ServerFnWithoutArgs<TFn extends AnyAsyncFn> = ServerFnHasArgs<TFn> extends false ? TFn : never;

type RefetchQueryOptions<T> = {
  queryKey: QueryKey;
  queryFn?: (_: any) => Promise<T>;
  meta?: any;
};

type ValidateServerFunction<Provided, Expected> = Provided extends Expected ? Provided : "This server function requires an argument!";

export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ServerFnWithArgs<TFn>,
  arg: Parameters<TFn>[0]["data"],
): RefetchQueryOptions<Awaited<ReturnType<TFn>>>;
export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ValidateServerFunction<TFn, ServerFnWithoutArgs<TFn>>,
): RefetchQueryOptions<Awaited<ReturnType<TFn>>>;
export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ServerFnWithoutArgs<TFn> | ServerFnWithArgs<TFn>,
  arg?: Parameters<TFn>[0]["data"],
): RefetchQueryOptions<Awaited<ReturnType<TFn>>> {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

有了它之后,当 server function 需要参数时,你可以这样调用:

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    ...refetchedQueryOptions(["epics", "list"], getEpicsList, page),
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
  });
};

参数类型会被正确检查:

...refetchedQueryOptions(["epics", "list"], getEpicsList, "")
// Argument of type 'string' is not assignable to parameter of type 'number'.

如果你忘了传参数,它也会报错:

...refetchedQueryOptions(["epics", "list"], getEpicsList)
// Argument of type 'RequiredFetcher<undefined, (page: number) => number, Promise<{ id: number; name: string; }[]>>' is not assignable to parameter of type '"This server function requires an argument!"'.

最后这个报错信息不算特别直观,但如果你把代码读到最后,会发现它已经在尽力提示你哪里错了,靠的就是这个小工具类型:

type ValidateServerFunction<Provided, Expected> = Provided extends Expected ? Provided : "This server function requires an argument!";

而对于“没有参数”的 server function,它也能正常工作。完整解释留给未来文章。

总结

single flight mutations 是一个很不错的优化工具:当你做一次 mutation 后,UI 需要的更新数据不必再额外发请求获取,而是可以在同一次往返里顺便带回来。

希望这篇文章把各个拼图都讲清楚了:如何用 middleware 收集要 refetch 的查询、如何借助 TanStack Start 的 server function 序列化能力把“要执行的 refetch”发送到服务端、以及如何在客户端用 setQueryData 把数据写回缓存。

同样是 setCount,为啥定时器里传函数才管用?

2026年2月6日 15:33
// ❌ 错误

useEffect(() => {

  const timer = setInterval(() => {

    setCount(count + 1); 

  }, 1000);

  return () => clearInterval(timer);

}, []);

  


// ✅ 正确:使用函数式更新

useEffect(() => {

  const timer = setInterval(() => {

    setCount(prev => prev + 1);

  }, 1000);

  return () => clearInterval(timer);

}, []);

分析一下上面两个方法,差别在于一个给setCount传的是计算值一个传的是函数

接下来分析一下为什么第一个方法有问题

首先,空依赖的useEffect只在组件第一次渲染时跑一次。我们可以将组件每一次渲染,理解成一个「独立的快照」,里面的变量(count)、函数都是全新的;空依赖的 useEffect 由于只在第一次渲染时执行,绑定的定时器回调,永远属于「第一次渲染的快照」,只能拿到这次快照里的 count。

以下是执行步骤:

  • 第一次渲染(快照 1) :React 创建了「count=0」这个变量,然后执行 useEffect(空依赖,仅一次),启动定时器,定时器的回调函数被绑定在快照 1 上,只能访问快照 1 里的「count=0」;

  • 定时器执行,setCount (0+1) 后:React 发现 count 变了,触发第二次渲染(快照 2) ,这次 React 会创建一个全新的「count=1」变量(注意:是新的,不是修改原来的 0),然后根据新的 count 更新 DOM,页面显示 1;

  • 但因为 useEffect 只执行一次,定时器是在第一次渲染时创建的,它的回调函数从诞生开始,就「粘」在了快照 1上,这辈子都跳不出去。相应地,它只能访问「count=0」,此时过了一秒,再次触发定时器,由于count + 1 是 1,与最新的count一样,不会触发dom更新。因此,页面不会继续出现2 3 4 5 6 ,而是一直是1

为什么传入函数就可以了?

因为函数式更新是让 React 帮你拿最新状态,而不是自己从闭包作用域里拿
这个问题问到了 React 状态更新的底层设计逻辑,因为当你传给 setCount 的是一个「函数」,而非「直接的计算值」时,React 会对这种「函数式更新」做特殊处理 —— 主动把最新状态传给这个函数的参数(prev),而非让函数自己去外部作用域找状态。

  • setCount(count + 1):让定时器回调自己去「外部作用域」找 count 的值,再计算后传给 setCount(找得到旧的,找不到新的);

  • setCount(prev => prev + 1):告诉 React「我要更新状态,你把当前最新的 count 传给我,我基于这个最新值计算」(让 React 帮你找,它永远能找到最新的)。

传入函数可以找到最新的count值,因此,在定时器执行时,就可以获取到最新的count值,从而视图不断更新,显示 1 -> 2 -> 3...

结论

只要是定时器 / 延时器里更新状态,且更新需要 “基于上一个状态”(比如 + 1、-1、拼接),直接用函数式更新,把算账的活交给 React,绝对不踩坑!

vue3 页面缓存KeepAlive示例

2026年2月6日 15:25

KeepAlive 示例

1.全部缓存

//APP.vue
<router-view v-slot="{ Component }">
    <KeepAlive>
      <component :is="Component"/>
    </KeepAlive>
</router-view>

2.根据路由配置缓存

  • 通过v-if来实现、配置在路由即可
//router/index.js
const routes = [
  {
    path: '/',
    component: () => import('@/views/home/index.vue'),
    name: 'home',
    meta: {
      title: '首页',
      keepAlive: true,//是否缓存
    },
  },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
//APP.vue
<template>
  <router-view v-slot="{ Component, route }">
    <keep-alive>
      <component :is="Component" :key="$route.fullPath" v-if="route.meta.keepAlive" />
    </keep-alive>
    <component :is="Component" :key="$route.fullPath" v-if="!route.meta.keepAlive" />
  </router-view>
</template>

3.动态控制页面缓存:include

  • 通过include匹配实现;
  • 要求:值要和页面组件的name一致;
  • 方便:为方便取值,页面组件的name要和对应路由name保持一致,直接获取路由的name即可
  • 管理:借助pinia进行管理
  • 缺点:组件需要手动设置name且和路由一致,麻烦点
  • 1、路由:/router/index.js
const routes = [
  {
    path: '/',
    component: () => import('@/views/home/index.vue'),
    name: 'home',//这里和component组件文件的defineOptions({ name: 'home'})保持一致
    meta: {
      title: '首页',
      keepAlive: true,//是否缓存
    },
  },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
  • 2、App.vue
<template>
  <router-view v-slot="{ Component, route }">
    <keep-alive :include="appStore.cacheList">
      <component :is="Component" :key="route.fullPath" />
    </keep-alive>
  </router-view>
</template>
<script setup>
import { useAppStore } from '@/store/app'

const router = useRouter()
const appStore = useAppStore()

// 初始化缓存列表:根据路由配置添加需要缓存的组件
onMounted(() => {
  appStore.initCacheList(router)
})
</script>
<style scoped></style>
  • 3、pinia实现控制逻辑:/store/app.js
import { defineStore } from 'pinia'

/**
 * 应用状态管理 Store
 * 主要用于管理 keep-alive 缓存列表
 */
export const useAppStore = defineStore('app', {
    state: () => ({
        // 缓存列表,存储需要缓存的组件名称
        // keep-alive 的 include 需要匹配组件的 name
        cacheList: []
    }),

    getters: {
        /**
         * 获取缓存列表(只读)
         */
        getCacheList: (state) => {
            return [...state.cacheList]
        },

        /**
         * 检查某个组件是否在缓存列表中
         */
        isCached: (state) => {
            return (componentName) => {
                return state.cacheList.includes(componentName)
            }
        }
    },

    actions: {
        /**
         * 初始化缓存列表:根据路由配置添加需要缓存的组件
         * @param {Object} router - Vue Router 实例
         */
        initCacheList(router) {
            if (!router) {
                console.warn('initCacheList: router 参数不能为空')
                return
            }

            router.getRoutes().forEach(route => {
                if (route.meta?.keepAlive && route.name) {
                    // keep-alive 的 include 需要匹配组件的 name
                    // 这里使用路由 name,需要确保路由 name 和组件 defineOptions 中的 name 一致
                    const componentName = route.name
                    if (!this.cacheList.includes(componentName)) {
                        this.cacheList.push(componentName)
                    }
                }
            })
        },

        /**
         * 添加组件到缓存列表
         * @param {string} componentName - 组件名称
         */
        addCache(componentName) {
            if (!componentName) {
                console.warn('addCache: componentName 参数不能为空')
                return
            }

            if (!this.cacheList.includes(componentName)) {
                this.cacheList.push(componentName)
                console.log(`已添加 ${componentName} 到缓存列表`)
            }
        },

        /**
         * 从缓存列表中移除组件(临时取消缓存)
         * @param {string} componentName - 组件名称
         */
        removeCache(componentName) {
            if (!componentName) {
                console.warn('removeCache: componentName 参数不能为空')
                return
            }

            const index = this.cacheList.indexOf(componentName)
            if (index > -1) {
                this.cacheList.splice(index, 1)
                console.log(`已移除 ${componentName} 的缓存`)
            } else {
                console.warn(`未找到 ${componentName} 的缓存`)
            }
        },

        /**
         * 清空所有缓存
         */
        clearAllCache() {
            this.cacheList = []
        },

        /**
         * 根据路由信息自动管理缓存
         * 如果路由配置了 keepAlive 为 true,则自动添加到缓存列表
         * @param {Object} route - 路由对象
         */
        autoManageCache(route) {
            if (!route) return

            const componentName = route.name
            if (route.meta?.keepAlive && componentName) {
                if (!this.cacheList.includes(componentName)) {
                    this.addCache(componentName)
                }
            }
        }
    }
})

4.解决上面麻烦点

  • 动态赋值组件name为路由的name值
  • 1.在路由页面/router/index.js 使用工具处理
import { processRoutes } from '@/utils/route-helper';//自动为组件设置路由 name的辅助工具
const routes = [....];//如上
// 自动为所有路由组件设置 name(如果组件没有设置 name,则使用路由 name)
const processedRoutes = processRoutes(routes);

const router = createRouter({
  history: createWebHashHistory(),
  routes: processedRoutes,
})
export default router;
  • 2.@/utils/route-helper
/**
 * 路由辅助工具
 * 自动为组件设置路由 name,避免每个页面都手动设置 defineOptions
 */

import { defineComponent, h, markRaw } from 'vue'

/**
 * 包装路由组件,自动设置组件 name 为路由 name
 * @param {Function|Object} component - 组件导入函数或组件对象
 * @param {string} routeName - 路由名称
 * @returns {Function|Object} 包装后的组件
 */
export function withRouteName(component, routeName) {
    if (!routeName) {
        return component
    }

    // 如果是异步组件(函数)
    if (typeof component === 'function') {
        return () => {
            return component().then((module) => {
                const comp = module.default || module

                // 如果组件已经有 name,直接返回
                if (comp.name) {
                    return module
                }

                // 使用 defineComponent 包装组件,设置 name
                const wrappedComponent = defineComponent({
                    name: routeName,
                    setup(props, { slots, attrs }) {
                        // 渲染原组件
                        return () => h(comp, { ...props, ...attrs }, slots)
                    }
                })

                // 标记为原始对象,避免响应式
                markRaw(wrappedComponent)

                // 返回包装后的组件
                // 注意:需要先展开 module 再覆盖 default,否则 module.default 会把 wrappedComponent 覆盖掉
                return {
                    ...module,
                    default: wrappedComponent,
                }
            })
        }
    }

    // 如果是同步组件(对象)
    if (typeof component === 'object' && component !== null) {
        // 如果组件已经有 name,直接返回
        if (component.name) {
            return component
        }

        // 使用 defineComponent 包装组件,设置 name
        const wrappedComponent = defineComponent({
            name: routeName,
            ...component
        })

        markRaw(wrappedComponent)
        return wrappedComponent
    }

    return component
}

/**
 * 批量处理路由配置,自动为组件设置 name
 * @param {Array} routes - 路由配置数组
 * @returns {Array} 处理后的路由配置数组
 */
export function processRoutes(routes) {
    return routes.map(route => {
        // 如果有 name 和 component,则自动设置组件 name
        if (route.name && route.component) {
            route.component = withRouteName(route.component, route.name)
        }

        // 递归处理子路由
        if (route.children && Array.isArray(route.children)) {
            route.children = processRoutes(route.children)
        }

        return route
    })
}

以上已验证

  • 登录后 根据接口返回用户可访问的菜单信息 动态添加的路由 缓存功能同样适用

React 中的竞态条件问题及解决方案:从一个日历组件说起

2026年2月6日 15:20

在 React 开发中,我们经常会遇到这样的场景:用户快速切换筛选条件,触发多个异步请求。由于网络延迟的不确定性,请求的返回顺序可能与发送顺序不一致,导致页面显示错误的数据。这就是经典的 竞态条件(Race Condition)问题。

一、问题场景

1.1 业务背景

我们有一个变更日历组件,用于展示每日的需求创建数和服务发布数。用户可以通过多个条件进行筛选:

// 筛选条件
- 年份
- 月份

1.2 原始代码

const useCalendar = () => {
  const [calendarData, setCalendarData] = useState([])
  const [loading, setLoading] = useState(false)
  
  // 获取日历数据
  useEffect(() => {
    const loadData = async () => {
      setLoading(true)
      try {
        const data = await fetchCalendarData(year, month)
        setCalendarData(data)
      } catch (error) {
        console.error("获取日历数据失败:", error)
        setCalendarData([])
      } finally {
        setLoading(false)
      }
    }
    loadData()
  }, [year, month])
  
  // ...
}

1.3 问题复现

当用户快速从 1月 → 2月 → 3月 切换时:

时间线 ────────────────────────────────────────────────────────►

用户操作:    选1月        选2月        选3月
              │            │            │
              ▼            ▼            ▼
发出请求:   请求1月      请求2月      请求3月
              │            │            │
              │            │            └────► 返回3月数据 (200ms)
              │            │
              │            └─────────────────► 返回2月数据 (500ms)
              │
              └──────────────────────────────► 返回1月数据 (800ms)

问题:用户最后选择的是 3月,但因为 1月的请求最后返回,页面最终显示的是 1月的数据!

这就是竞态条件:多个异步操作竞争同一个资源(state),结果取决于它们完成的顺序,而非发起的顺序。

二、解决方案

2.1 核心思路

我们需要一种机制来标记和忽略过时的请求。当用户切换条件时,把之前的请求标记为"已取消",即使它返回了数据,也不更新 state。

2.2 使用 isCancelled 标志位

useEffect(() => {
  // 1️⃣ 每次 useEffect 执行时,创建一个新的标志位
  let isCancelled = false
  
  const loadData = async () => {
    setLoading(true)
    try {
      const params = buildCalendarParams(year, month)
      const response = await get("xxx", params)
      
      // 3️⃣ 请求返回时,检查是否已被取消
      if (isCancelled) {
        console.log("请求已取消,忽略响应")
        return
      }
      
      // 只有未取消的请求才更新数据
      if (response.status === 0) {
        setCalendarData(response.data || [])
      } else {
        setCalendarData([])
      }
    } catch (error) {
      if (isCancelled) return
      console.error("获取日历数据失败:", error)
      setCalendarData([])
    } finally {
      if (!isCancelled) {
        setLoading(false)
      }
    }
  }
  
  loadData()
  
  // 2️⃣ 清理函数:依赖变化时,标记旧请求为已取消
  return () => {
    isCancelled = true
  }
}, [year, month])

三、原理详解

3.1 useEffect 的清理函数

React 的 useEffect 可以返回一个清理函数。这个清理函数会在以下时机执行:

  1. 依赖变化时:先执行上一次 effect 的清理函数,再执行新的 effect
  2. 组件卸载时:执行最后一次 effect 的清理函数
useEffect(() => {
  // effect 逻辑
  console.log("effect 执行")
  
  return () => {
    // 清理逻辑
    console.log("cleanup 执行")
  }
}, [dependency])

当 dependency 从 A 变为 B 时,执行顺序是:

1. cleanup(A)  ← 先清理旧的
2. effect(B)   ← 再执行新的

3.2 闭包的作用

这个方案能够生效的关键是 JavaScript 闭包

每次 useEffect 执行时,都会创建一个全新的、独立的 isCancelled 变量。清理函数和异步请求的回调函数通过闭包记住它们所属的那个 isCancelled

// 第1次执行(选择1月)
useEffect(() => {
  let isCancelled_1 = false  // 独立的变量
  
  // 请求1的回调闭包引用 isCancelled_1
  fetch(...).then(() => {
    if (isCancelled_1) return  // 检查的是 isCancelled_1
    setData(...)
  })
  
  return () => {
    isCancelled_1 = true  // 修改的是 isCancelled_1
  }
}, [month])

// 第2次执行(选择2月)
useEffect(() => {
  let isCancelled_2 = false  // 另一个独立的变量
  
  // 请求2的回调闭包引用 isCancelled_2
  fetch(...).then(() => {
    if (isCancelled_2) return  // 检查的是 isCancelled_2
    setData(...)
  })
  
  return () => {
    isCancelled_2 = true  // 修改的是 isCancelled_2
  }
}, [month])

3.3 完整执行流程

让我们用具体的例子走一遍完整流程:

═══════════════════════════════════════════════════════════════

【步骤1:用户选择 1月】

useEffect 第1次执行:
  ├─ 创建 isCancelled_1 = false
  ├─ 发出请求1(获取1月数据)
  └─ 返回清理函数 cleanup_1

内存状态:
  ┌─────────────────────┐
  │ isCancelled_1 = false│
  └─────────────────────┘

═══════════════════════════════════════════════════════════════

【步骤2:用户选择 2月】(依赖变化)

React 执行顺序:
  1. 执行 cleanup_1()
     └─ isCancelled_1 = true  ✅ 标记请求1已过时
  
  2. useEffect 第2次执行
     ├─ 创建 isCancelled_2 = false
     ├─ 发出请求2(获取2月数据)
     └─ 返回清理函数 cleanup_2

内存状态:
  ┌─────────────────────┐
  │ isCancelled_1 = true │ ← 已标记为取消
  └─────────────────────┘
  ┌─────────────────────┐
  │ isCancelled_2 = false│ ← 新的,有效的
  └─────────────────────┘

═══════════════════════════════════════════════════════════════

【步骤3:用户选择 3月】(依赖变化)

React 执行顺序:
  1. 执行 cleanup_2()
     └─ isCancelled_2 = true  ✅ 标记请求2已过时
  
  2. useEffect 第3次执行
     ├─ 创建 isCancelled_3 = false
     ├─ 发出请求3(获取3月数据)
     └─ 返回清理函数 cleanup_3

内存状态:
  ┌─────────────────────┐
  │ isCancelled_1 = true │ ← 已取消
  └─────────────────────┘
  ┌─────────────────────┐
  │ isCancelled_2 = true │ ← 已取消
  └─────────────────────┘
  ┌─────────────────────┐
  │ isCancelled_3 = false│ ← 当前有效
  └─────────────────────┘

═══════════════════════════════════════════════════════════════

【步骤4:请求陆续返回】

请求3 返回(最快,200ms):
  ├─ 回调检查 isCancelled_3
  ├─ isCancelled_3 === false ✅
  └─ 更新数据为 3月 ✅

请求2 返回(500ms):
  ├─ 回调检查 isCancelled_2
  ├─ isCancelled_2 === true ❌
  └─ 直接 return,不更新数据

请求1 返回(最慢,800ms):
  ├─ 回调检查 isCancelled_1
  ├─ isCancelled_1 === true ❌
  └─ 直接 return,不更新数据

═══════════════════════════════════════════════════════════════

【最终结果】页面显示 3月数据 ✅ 正确!

四、其他解决方案

4.1 使用 AbortController

如果你的请求支持取消(如 fetch API),可以使用 AbortController 来真正取消请求:

useEffect(() => {
  const controller = new AbortController()
  
  const loadData = async () => {
    try {
      const response = await fetch(url, {
        signal: controller.signal
      })
      const data = await response.json()
      setCalendarData(data)
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('请求被取消')
        return
      }
      console.error(error)
    }
  }
  
  loadData()
  
  return () => {
    controller.abort()  // 真正取消请求
  }
}, [dependencies])

4.2 使用第三方库

一些流行的数据获取库已经内置了竞态条件处理:

  • React Query / TanStack Query
  • SWR
  • RTK Query
// 使用 React Query
import { useQuery } from '@tanstack/react-query'

const { data, isLoading } = useQuery({
  queryKey: ['calendar', year, month, type],
  queryFn: () => fetchCalendarData(year, month, type)
})
// React Query 自动处理竞态条件

五、总结

概念 说明
竞态条件 多个异步操作竞争同一资源,结果取决于完成顺序
闭包 函数可以访问它被创建时所在作用域的变量
清理函数 useEffect 返回的函数,在依赖变化或组件卸载时执行
解决思路 标记过时请求,忽略其响应

关键代码模板

useEffect(() => {
  let isCancelled = false
  
  const fetchData = async () => {
    try {
      const data = await api.get(...)
      if (isCancelled) return  // 关键检查
      setState(data)
    } catch (error) {
      if (isCancelled) return
      handleError(error)
    }
  }
  
  fetchData()
  
  return () => {
    isCancelled = true  // 标记取消
  }
}, [dependencies])

理解这个模式后,你就能在任何需要处理异步请求竞态条件的场景中应用它。这是 React 开发中的一个重要技巧

Vue 2.7 封装全屏弹窗组件:基于命名空间的样式定制

2026年2月6日 15:14

在 Vue 2.7 + Element UI 项目中,封装全屏 Iframe 弹窗常遇到样式覆盖无效的问题。特别是开启 append-to-body 后,弹窗 DOM 位于根节点,常规的 scoped 样式难以生效。

本文介绍一种不依赖 scoped,通过CSS 命名空间来实现安全样式隔离的方案。

1. 核心需求

  • 全屏沉浸:弹窗无边距、无默认内边距。
  • DOM 结构安全:必须使用 append-to-body,防止被父级容器截断。
  • 样式无污染:在全局样式模式下,确保只影响当前组件。

2. 组件实现 (SurveyPortal.vue)

Template 结构

关键在于设置 custom-class。这个唯一的类名将作为我们的“样式防火墙”。

<template>
  <el-dialog
    :visible.sync="dialogVisible"
    :title="title"
    fullscreen
    :append-to-body="true"
    :destroy-on-close="true"
    custom-class="survey-portal-dialog" 
    @close="handleClose"
  >
    <div class="iframe-wrapper" v-loading="loading">
      <iframe
        :src="surveyUrl"
        frameborder="0"
        width="100%"
        height="100%"
        @load="onIframeLoad"
      ></iframe>
    </div>
  </el-dialog>
</template>

Script 逻辑

保持标准的 Vue 2.7 写法,计算属性处理 URL,Watch 处理双向绑定。

<script setup>
import { ref, watch, computed } from 'vue';

const props = defineProps({
  visible: Boolean,
  surveyId: { type: [String, Number], required: true },
  title: { type: String, default: '外部页面' }
});

const emit = defineEmits(['update:visible', 'close']);

const dialogVisible = ref(false);
const loading = ref(true);

const surveyUrl = computed(() => `/wj/${props.surveyId}`);

watch(() => props.visible, (val) => {
  dialogVisible.value = val;
  if (val) loading.value = true;
});

watch(dialogVisible, (val) => {
  emit('update:visible', val);
});

const onIframeLoad = () => {
  loading.value = false;
};

const handleClose = () => emit('close');
</script>

3. 样式处理(命名空间隔离法)

由于 append-to-body 将 DOM 移出了组件作用域,我们放弃 scoped,转而使用全局样式。为了防止污染全局,我们将所有样式严格包裹在 custom-class 定义的唯一类名中。

CSS 实现原理

  1. 去掉 scoped:让样式变为全局可见。
  2. 顶层包裹:所有规则必须写在 .survey-portal-dialog 内部。
  3. 覆盖 Element UI:直接选中 .el-dialog__body 进行重置。
<style lang="scss">
/* * 注意:不加 scoped 
 * 通过 ".survey-portal-dialog" 这个唯一类名实现逻辑隔离
 */
.survey-portal-dialog {
  display: flex;
  flex-direction: column;

  /* 1. 修正头部样式 */
  .el-dialog__header {
    padding: 15px 20px;
    border-bottom: 1px solid #ebeef5;
  }

  /* 2. 暴力清除 Body 内边距,实现全屏无缝 */
  .el-dialog__body {
    padding: 0 !important;
    margin: 0 !important;
    flex: 1;
    overflow: hidden;
    height: 100%;
  }

  /* 3. 内部 Iframe 容器高度计算 */
  .iframe-wrapper {
    /* 减去 Header 高度(约54px),避免出现双重滚动条 */
    height: calc(100vh - 54px);
    width: 100%;
    overflow: hidden;
  }
}
</style>

4. 方案优劣分析

  • 优点

    • 极度稳定:不受 Vue Loader 版本或 scoped 穿透语法(/deep/ vs ::v-deep)变更的影响。
    • 符合直觉:完美兼容 append-to-body 的 DOM 移动行为。
  • 注意点

    • 命名唯一性:必须保证 survey-portal-dialog 这个类名在项目中是唯一的,避免与其他弹窗冲突。

5. 总结

在处理 Element UI 的 append-to-body 弹窗时,“全局样式 + 唯一类名包裹”是最简单且副作用最小的方案。它通过 CSS 选择器的嵌套规则,手动建立了一个“样式沙箱”,既解决了全屏覆盖问题,又规避了全局污染风险。

一个月手搓 JavaScript runtime

2026年2月6日 15:08

原文:building a javascript runtime in one month

作者:themackabu

日期:2026年1月2日

翻译:TUARAN

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


TL;DR

我做了一个叫 Ant 的小型 JavaScript runtime(大概 2MB)。源码、测试和文档都在 GitHub:github.com/themackabu/…


我在 11 月初开始做这个项目时,脑子里只有一个简单念头:

如果能做一个足够小、能嵌进 C 程序里,但又足够完整、能跑真实代码的 JavaScript 引擎,会怎么样?

一个你可以发布出去、却不用捆上几百 MB 的 V8 或 Node 的东西。我以前也试过做“极简版 Deno”的路子,但始终不够。

我没想到这会花一个月;更没想到一个月真的做得出来。但不设 deadline 的项目有个特点:你会一直往前推,推着推着就做出来了。

第一周:纯纯生存模式

我是一边做一边学——说白了就是不断试错,然后把每一个错误也一起“发布”出去。最开始的工作只围绕最基本的东西:

  • 数值运算
  • 字符串内建函数
  • 一个非常粗糙的 CommonJS 模块系统

每一次提交都像是在虚无里抢回一点点地盘。

最核心的问题是 解析(parsing)。在其它东西能工作之前,你必须先有 parser。而 parser 往往比看起来复杂得多。JavaScript 这种语言尤其“诡异”:

  • 自动分号插入(ASI)是规范的一部分,你得处理
  • this 的绑定会随上下文变化
  • var 的提升(hoisting)意味着变量会在赋值前就“存在”
  • 甚至 window.window.window 这种写法都是合法的……

我前几天做的主要是把基本流程跑通,类似一个“能算数、也能调用函数”的计算器。由于动量已经起来了,我就一直继续。

runtime 的核心数据表示大概长这样:

typedef uint64_t jsval_t;

在这个 runtime 里,每一个 JavaScript 值都用一个 64 位整数表示:NaN-boxing

IEEE 754 浮点规范有个“洞”:理论上存在 2532^{53} 种 NaN,其中绝大多数从来不会被用到。所以我把它们“偷”来用了。

如果把一个 64 位值按 double 解释时它看起来像 NaN,同时 exponent 与 mantissa 又满足你定义的模式,那么你就可以在这些 bit 里塞一个 tag。你有足够空间同时存一个指针和一个类型标签:把对象引用和类型 tag 一起塞进 64 bit,瞬间所有 JS 值都能塞进一个 machine word。

编译期断言也证明了前提:

_Static_assert(sizeof(double) == 8, "NaN-boxing requires 64-bit IEEE 754 doubles");
_Static_assert(sizeof(uint64_t) == 8, "NaN-boxing requires 64-bit integers");
_Static_assert(sizeof(double) == sizeof(uint64_t), "double and uint64_t must have same size");

这就成了 runtime 表示“一切”的心脏:每个数字、对象、字符串、函数、Promise、参数、作用域……全部都是一个 jsval_t

没有“带标签联合体”、没有 vtable、也不需要额外分配元数据——只有 bits。为了把它调顺,我迭代了好几天;但一旦跑通,其它东西就会更快更顺。NaN 和 Infinity 当然也有坑,不过通过微调 boxing 布局也能解决。

大约第 4 天我让变量能用了,第 5 天函数能用了,第 6 天循环能跑了。早期提交非常散:箭头函数、IIFE、可选链、空值合并……我就是一边翻 MDN 一边想起啥加啥。

垃圾回收(GC)灾难

然后就撞上了真正的硬骨头:内存管理

一个 JavaScript runtime 必须有 GC,你不可能要求用户手动 free 对象。所以到第二周左右,我开始尝试自己实现 GC。

结果是一场噩梦:

  • 我加新特性会把 GC 搞崩
  • 我修 GC 又会把性能搞崩
  • 我试着接入别人写的 GC,又发现集成复杂到不可控

这段时间我非常痛苦。手写的 free-list GC 被我开开关关上百次,每次都能把另一个核心模块弄坏。有些日子我明显已经快崩了:凌晨三点 debug,试图弄清为什么协程栈没被保护好、为什么内存泄漏、为什么加了 JSON 支持之后一切都坏了。

转折点是:放弃手写 GC,改用 bdwgc

这是一个生产级 GC(很多语言都在用)。我把它和自己手写的“带前向引用跟踪的内存压缩”结合起来:它能做 mark、能做 forwarding 的哈希表、能做生产 GC 会做的所有事。

一旦集成上去,内存问题大部分就消失了。我写代码的“语气”也变了:东西开始更稳定地工作起来,我加了 process 模块、把错误信息做得更友好——速度从这里开始明显加快。

Promise / async:另一个野兽

你以为 async/await 很简单,直到你尝试自己实现它。

要实现 async/await,你需要 Promise;Promise 需要 microtask 与定时器;microtask 与定时器又需要事件循环;事件循环还要有地方存异步操作的状态。

我为这件事折腾了好几天:

  • 想让 async 工作,你需要协程
  • 协程需要调度
  • 调度需要事件循环
  • 事件循环还要知道协程什么时候结束

如果协程在等 I/O,你不能阻塞;如果某个协程死了,它也不该把整个系统拖死。

你看提交历史就能感受到痛苦:"async promise pushback""segfault when event loop empty""prevent dead task from blocking"……这些坑都是做到一半才会冒出来的。

更要命的是:JS Promise 不能“简化”。它必须支持 .then() 链式调用,必须正确 reject,还要能与 async function 配合——而 async function 本质上是 generator 的语法糖,而 generator 又是 Promise 与回调的语法糖……

大约第 10 天,我引入了 minicoro 作为协程支持。这个决定大概救了整个项目。minicoro 很优雅:你定义基于栈的协程,然后让系统在它们之间切换。有了协程,我终于能让 async 真正跑起来。

typedef struct coroutine {
struct js *js;
coroutine_type_t type;
jsval_t scope;
jsval_t this_val;
jsval_t awaited_promise;
jsval_t result;
jsval_t async_func;
jsval_t *args;
int nargs;
bool is_settled;
bool is_error;
bool is_done;
jsoff_t resume_point;
jsval_t yield_value;
struct coroutine *prev;
struct coroutine *next;
mco_coro* mco;
bool mco_started;
bool is_ready;
} coroutine_t;

所有 async 执行相关的信息都塞进了这个结构:scope、this、正在等待哪个 promise、是否出错……接着我只需要调度这些东西并管理事件循环。

有了协程以后,Promise 才“成真”:.then() 链能跑,await 会真正暂停并在之后恢复执行。runtime 的 async 侧开始成形。后面我再补齐 Promise 内建时就快很多了,因为最难的那部分已经解决。

JavaScript 的“诡异边缘案例”

中间两周基本就是:不停发现 JavaScript 比我预想中更诡异。

不可配置属性、freeze/seal、可选链的边缘语义、严格模式……听起来都不难,但每一个背后都是几十年的规范细节,真实世界的代码会依赖这些行为。

我一个个啃过去:

  • 处理冻结/密封对象
  • 支持不可配置属性
  • 第 10 次修解构
  • 给属性查找加 getter/setter 的访问器支持

每天都在撞一个新边缘案例。有时候一天修好几个:我实现一个功能、跑一致性测试、发现三个 bug、修完之后又冒出五个新 bug。

你知道 JavaScript 有多少种方式访问原型链吗?

  • __proto__
  • Object.getPrototypeOf()
  • Object.setPrototypeOf()
  • [[Prototype]] 内部槽

你得把它们全部做对,而且还要彼此一致。一个看起来很短的提交信息,比如 “use descriptor tables for getters/setters/properties”,背后可能就是几周的工作。

解构看起来也很简单:const [a, b] = arr

但稀疏数组怎么办?对象的可枚举属性怎么办?嵌套解构、默认值、...rest 参数怎么办?每次修一个点都像打地鼠:修好这里,那里又坏。

一致性测试在“最好的意义上”非常残酷:每次跑都会失败在一个我根本不知道存在的语义上。然后我修掉它,继续失败在下一个。这个循环发生了几十次。

后半程:开始变得“能用”

第二周时,我已经有了一个能执行代码的 JavaScript runtime。它不完整,但它是真的。

然后我开始加那些让它变得“有用”的东西:文件系统、路径工具、URL 模块、以及那个因为 Bun 而变得很有名的内建 HTTP server。突然之间,真实程序开始能在 Ant 上跑了。

比如一个 Web 服务器只要写:

import { join } from 'ant:path';
import { readFile } from 'ant:fs';
import { createRouter, addRoute, findRoute } from 'rou3';

const router = createRouter();

addRoute(router, 'GET', '/status/:id', async c => {
await new Promise(resolve => setTimeout(resolve, 1000));

const result = await Promise.resolve('Hello');
const name = await readFile(join(import.meta.dirname, 'name.txt'));

const base = '{{name}} {{version}} server is responding with';
const data = { name, version: Ant.version() };

return c.res.body(`${base.template(data)} ${result} ${c.params.id}!`);
});

async function handleRequest(c) {
console.log('request:', c.req.method, c.req.uri);
const result = findRoute(router, c.req.method, c.req.uri);

if (result?.data) {
c.params = result.params;
return await result.data(c);
}

c.res.body('not found: ' + c.req.uri, 404);
}

console.log('started on http://localhost:8000');
Ant.serve(8000, handleRequest);

运行起来就是:

$ ant examples/server/server.js
started on http://localhost:8000

$ curl http://localhost:8000/status/world
Ant 0.3.2.6 server is responding with Hello world!

这就是“真 JavaScript”跑在 Ant 里:async/await、文件 I/O、HTTP、带参数路由、网络、字符串操作。

之后节奏更快:每天更自信,修更多 bug,加更多特性。然后到了“冷门但必须”的阶段:Proxy、Reflection、Symbol,甚至 class 私有字段/方法。它们也许很少人用,但规范里写了就得支持。

我最喜欢的一类能力之一是 Atomics

const sharedBuffer = new SharedArrayBuffer(256);

const int32View = new Int32Array(sharedBuffer);
Atomics.store(int32View, 0, 42);
const value = Atomics.load(int32View, 0);
console.log('stored 42, loaded:', value);

Atomics.store(int32View, 1, 10);
const oldValue = Atomics.add(int32View, 1, 5);
console.log('old value:', oldValue);

Atomics.store(int32View, 2, 100);
const result = Atomics.compareExchange(int32View, 2, 100, 200);
console.log('exchanged, new value:', Atomics.load(int32View, 2));
$ ant examples/atomics.js
stored 42, loaded: 42
old value: 10
exchanged, new value: 200

最后一周:多米诺骨牌一样倒下

当 Ant 的核心 runtime 能跑、GC 稳了、Promise 也通了之后,其它东西就像多米诺骨牌一样:小问题被修掉、缺的方法补齐、边缘语义逐个处理。

我重新加回了数组 length 校验,修了对象的属性缓存失效逻辑;为了优化 hash 性能又掉进“复杂算法 + 安全影响”的兔子洞——因为我已经在打磨一个“能工作的东西”。

到第 28 天,我给一个真的能用的 runtime 收尾:支持 async/await、靠谱的内存管理、网络、文件 I/O、并通过 ES1–ES5 的一致性测试,还混搭了一堆更现代的特性。

我甚至在别人提醒之后才“想起来”打开 LTO 和一些编译器 flag 😅

uzaaft

最终结果

一个月后,Ant 作为 JavaScript runtime:

  • 通过 javascript-zoo 测试套件中 ES1 到 ES5 的每一个一致性测试(25 年规范跨度的完整兼容)
  • 实现 async/await,并具备正确的 Promise 与 microtask 行为
  • 拥有一个真的能用、且不漏内存的 GC
  • 基于 libuv 运行 Web 服务器(和 Node 类似的网络底座)
  • 支持通过 FFI 调用系统库,例如:
import { dlopen, suffix, FFIType } from 'ant:ffi';

const sqlite3 = dlopen(`libsqlite3.${suffix}`);

sqlite3.define('sqlite3_libversion', {
args: [],
returns: FFIType.string
});

console.log(`version: ${sqlite3.sqlite3_libversion()}`);
$ ant examples/ffi/basic/sqlite.js
version: 3.43.2
  • 支持读写文件与异步 I/O
  • 支持正确的作用域、提升、变量遮蔽
  • 支持 class、箭头函数、解构、展开、模板字符串、可选链
  • 覆盖一些多数人根本不会想到的“怪边缘”:__proto__ 赋值、属性描述符、不可配置属性、冻结/密封对象(可参考测试:tests/__proto__.js
  • 实现 ES Module(import / export)
  • 支持 Symbol、Proxy、Reflect、WeakMap/WeakSet、Map/Set
  • 支持共享内存与 Atomics 并发原语

把这些串起来,你会发现你面对的已经几乎是一个“完整的 JavaScript runtime”,不太像玩具。

代价

我不知道代价是什么。

可能是睡眠,可能是健康,可能是本来可以拿去做任何其它事情的大把时间。

有些日子我连续工作 10+ 小时;有些日子一天 20+ commits。项目不会减速,只会加速:每天更自信、更快、修更多 bug、加更多特性。

到最后,我开始撞上那些必须去读 ECMAScript 规范、去理解 V8 行为、去对比其它引擎怎么处理某个怪角落的工作。改符号计数、优化 class、把内部属性迁移到 slots(像 V8 那样)……这类优化正常应该等代码稳定后再做,但因为地基已经稳了,我在最后一周反而有了余力去做。

发布后:优化阶段

首个 release 是 11 月 26 日。之后是一段沉默——那种“发完版本之后就没声了”的沉默。直到 12 月 20 日左右,开发又恢复。

这一次不同:runtime 能跑、能过测试,但总有更多优化空间。xctrace 让我看清什么才是真正的瓶颈。12 月下旬和 1 月初的提交呈现一种模式:找到瓶颈 → 修复 → 测量提升。

fast

我先为 typed array 加了 arena allocator。之前 typed array 散落在 heap 的各处;我把它们集中起来,加速分配并改善 cache locality。

然后我把 getter/setter/property 从“每个 descriptor 单独分配”改成“descriptor table 批处理”:更少的分配、更少的指针追逐。

. 运算符支持 property reference 也很烦:每次查属性都要全量解析;于是我加了 reference table 跳过重复工作。

我很喜欢 dispatch table。我把 FFI、JSON 等路径改为 computed goto,让 CPU 直接跳到正确的 handler:少一次分支、少一次查找。

把 properties 迁到 slots 是最侵入的一次重构。对象之前用的是灵活但慢的属性系统;slots 则是按对象类型固定结构,让 runtime 能做更多假设,减少 indirection。

某个时刻我开始拿它对比 Node:跑同样 benchmark,Ant 表现如何?结果开始变得很好——好到你会想:我是不是能在某些点上赢 Node?

bunnerfly wow

优化 Ant 的过程中我会保留一些可工作的 snapshot:如果某次优化把东西搞坏了,我还能退回到一个稳定点。于是就能持续小步推进:每次提交都比上一次快一点。有些优化有效,有些没用,但整体模式始终成立:profile → optimize → measure → commit。

然后是 GC 的改进。在最初那一个月里 bdwgc 集成得挺好,但在优化阶段的某个时刻它被禁掉了,runtime 就开始漏内存。我重新加回“可延迟 GC”的机制,并把旧 GC 的大部分代码取消注释。

但这次不是老办法:我做的是一个 mark-copy + compact 的 GC,能真正做内存碎片整理。旧 GC 的问题是它在错误的时机运行,导致热路径卡顿。所以我让它“可延迟”:在逻辑工作单元之间再收集;同时用前向引用跟踪保证对象移动后指针不坏。GC 回来了,但更聪明:它会等到合适的点暂停,并在运行时压缩堆。

为什么会做这件事

老实说,我也不知道。

也许是赌气?也许是想证明点什么?也许是纯粹的执念。

那种“进入心流”的状态:你写着写着,八小时就过去了,已经凌晨四点,然后你把代码 commit 掉,第二天又继续。

这个项目之所以存在,是因为我脑子里某个东西决定“它必须存在”,并且直到它真的存在之前都不会停。

它并不完美。代码里可能还有没发现的 bug;可能还有没做的性能优化;可能还有漏掉的规范角落。

但它能跑:你可以写真实 JavaScript,它会执行;你可以用 async/await;你可以写服务器;你可以拿它去做真实事情。

如果你曾经好奇:一个人如果足够执着、又不睡觉,能做到什么?答案就是:做出一个规范兼容的 JavaScript 引擎。

源码、测试与文档都在:github.com/themackabu/…

DOM 操作实战|原生 JS 实现 6 个高频交互效果(选项卡 / 轮播图等,附源码 + 注释)

作者 代码煮茶
2026年2月6日 15:06

一、实战前言

DOM 操作是前端开发的核心基础,无论是业务开发还是框架底层,都离不开对页面元素的增删改查、事件绑定、样式修改。很多新手入门后直接学习 Vue/React 等框架,忽略了原生 DOM 操作,导致遇到框架底层问题、原生开发需求时无从下手。

本次实战将用纯原生 HTML+CSS+JS实现前端开发中6 个高频 DOM 交互效果,覆盖点击、鼠标悬浮、轮播、表单交互等核心场景,所有案例均遵循 「结构搭建→样式美化→JS 交互」三步法,代码附带详细注释,同时讲解DOM 核心 API、事件处理技巧和新手避坑点,实现后的代码可直接复制到项目中使用,无需依赖任何框架。

二、前置准备

  1. 工具要求:仅需 VS Code(搭配 HTML/CSS/JS 高亮插件)+ 浏览器(Chrome/Firefox),无需额外环境和依赖
  2. 基础储备:了解 HTML 基本标签、CSS 基础样式(浮动 / 弹性布局)、JS 基础语法(变量 / 函数 / 条件判断),零基础可跟随步骤,核心 API 会逐行讲解
  3. 实现原则:结构与样式与行为分离(HTML 写结构、CSS 写样式、JS 写交互),代码解耦易维护;兼容主流浏览器,考虑边界场景(如无数据、高频点击)
  4. 核心 DOM API:本次实战会用到的高频 API,提前梳理方便理解
  • 元素获取:querySelector/querySelectorAll(精准获取单个 / 多个元素)
  • 事件绑定:addEventListener(推荐,可绑定多个事件,支持事件移除)
  • 样式修改:style(行内样式)/classList(类名操作,推荐)
  • 节点操作:createElement/appendChild(创建 / 添加节点)
  • 事件对象:e.target(事件源)/e.preventDefault(阻止默认行为)

三、实战说明

本次实现的 6 个 DOM 交互效果覆盖 80% 的前端基础交互场景,难度由浅入深,从简单的点击切换到复杂的自动轮播,逐步提升 DOM 操作能力:

  1. 基础点击类:选项卡切换(点击切换内容)、手风琴折叠(点击展开 / 折叠)
  2. 鼠标悬浮类:导航栏悬浮高亮、商品卡片悬浮效果(含动画)
  3. 自动轮播类:简易轮播图(自动播放 + 点击切换 + 鼠标暂停)
  4. 表单交互类:表单非空验证(提交前校验 + 实时提示)

四、分步实现(结构 + 样式 + JS,逐一拆解)

所有案例均提供完整可运行代码,复制到 VS Code 中保存为.html文件,直接用浏览器打开即可看到效果。

案例 1:选项卡切换(点击切换内容,高频基础交互)

适用场景:后台管理系统、商品详情页、资讯页面的内容切换,前端基础面试高频考点核心逻辑:给所有选项绑定点击事件,点击时给当前选项添加高亮类,移除其他选项的高亮类;同时根据选项索引,显示对应内容,隐藏其他内容。

完整代码

核心知识点 & 避坑点
  1. 类名操作推荐classList:替代直接修改className,避免覆盖原有类名,支持add/remove/toggle/contains方法
  2. querySelectorAll返回伪数组:可直接用forEach遍历,比getElementsByTagName更灵活(支持 CSS 选择器)
  3. 索引匹配:利用forEach的第二个参数index,实现选项和内容的一一对应,无需手动设置自定义属性
  4. 避坑:不要用onclick绑定事件(一次只能绑定一个,会被覆盖),优先使用addEventListener

案例 2:手风琴折叠(点击展开 / 折叠,移动端高频)

适用场景:移动端导航、FAQ 常见问题、侧边栏分类,核心是单开 / 多开折叠控制核心逻辑:给所有折叠项绑定点击事件,点击时切换当前项内容的显示 / 隐藏(通过类名控制高度);单开模式下,先折叠所有内容,再展开当前内容。

完整代码

核心知识点 & 避坑点
  1. 过渡动画:通过transitionheight添加过渡,实现平滑的展开 / 折叠,替代display: none/block(无动画)
  2. 单开 / 多开切换:单开先移除所有高亮类,再添加当前;多开直接用toggle切换当前项的类名,一行代码实现
  3. 父元素获取:通过this.parentElement获取当前标题的父元素(折叠项),无需额外获取,简化代码
  4. 避坑:不要直接修改style.heightauto,过渡动画会失效,需设置固定高度或通过scrollHeight获取真实高度(适配动态内容)

案例 3:导航栏悬浮高亮(鼠标悬浮 + 点击选中,通用导航)

适用场景:网站顶部导航、侧边栏导航,结合鼠标悬浮点击选中双交互,提升用户体验核心逻辑:鼠标悬浮时,给当前导航项添加悬浮高亮类,离开时移除;点击时,给当前项添加选中类,移除其他项的选中类,选中状态持久化。

完整代码

核心知识点 & 避坑点
  1. 阻止默认行为:通过e.preventDefault()阻止 a 标签的默认跳转,适配纯前端导航(无实际链接)
  2. 样式优先级:选中类样式高于悬浮类,通过contains判断是否为选中项,避免悬浮样式覆盖选中样式
  3. 鼠标事件mouseenter/mouseleave(不冒泡)优于mouseover/mouseout(冒泡),避免子元素触发父元素事件
  4. 避坑:不要给 a 标签设置href="#",点击会导致页面跳转到顶部,用href="javascript:;"更友好

案例 4:商品卡片悬浮效果(含动画,电商高频)

适用场景:电商网站商品列表、资讯卡片、产品展示,结合样式动画DOM 鼠标事件,提升页面质感核心逻辑:鼠标悬浮在卡片上时,通过classList添加悬浮类,实现卡片上移、阴影放大、显示遮罩层;鼠标离开时移除悬浮类,恢复原样式,所有效果通过 CSS 过渡实现,JS 仅做类名切换。

完整代码

核心知识点 & 避坑点
  1. 样式与行为分离:JS 仅负责类名切换,所有动画和样式由 CSS 实现,符合前端开发规范,便于维护
  2. CSS 过渡:给card添加transition: all 0.3s ease,实现所有样式的平滑过渡,包括transformbox-shadowopacity
  3. 绝对定位:遮罩层通过position: absolute脱离文档流,相对于卡片(relative)定位,实现全屏遮罩
  4. 避坑:给卡片添加overflow: hidden,避免卡片上移时超出容器,同时防止图片圆角失效

案例 5:简易轮播图(自动播放 + 点击切换 + 鼠标暂停,高频核心)

适用场景:网站首页轮播、广告展示、图片集,前端 DOM 实战高频考点,融合定时器事件绑定样式切换核心知识点核心逻辑:通过定时器实现图片自动轮播(修改索引,切换图片和指示器);点击左右按钮切换上一张 / 下一张(索引加减,边界判断);点击指示器跳转到对应图片;鼠标悬浮在轮播图上暂停定时器,离开后恢复。

完整代码

核心知识点 & 避坑点
  1. 定时器管理:用变量保存定时器 ID,方便暂停和恢复,避免多个定时器同时运行
  2. 边界判断:切换索引时判断是否超出范围,实现轮播循环(最后一张切到第一张,第一张切到最后一张)
  3. 样式移动:通过修改left值实现图片横向滑动,结合transition实现平滑动画,比display更友好
  4. 事件委托:轮播图的所有子元素(按钮、指示器、图片)的鼠标事件,都可以绑定在父容器上,减少事件绑定数量
  5. 避坑:给轮播容器添加overflow: hidden,隐藏超出的图片;图片容器的宽度要设置为单张宽度*图片数量,确保横向排列

案例 6:表单非空验证(实时提示 + 提交校验,业务高频)

适用场景:登录 / 注册表单、留言表单、提交表单,前端基础校验,减少无效接口请求,提升用户体验核心逻辑:给输入框绑定input实时事件,输入时校验是否为空,实时显示提示信息;给表单绑定submit提交事件,提交前校验所有必填项,若有未填项阻止提交并提示,全部填完则提交成功。

完整代码

核心知识点 & 避坑点
  1. 通用函数封装:将非空校验逻辑封装为checkInput函数,避免重复代码,便于维护和拓展(如后续添加长度校验、格式校验)
  2. 实时校验:绑定input事件,输入时实时校验,比blur(失去焦点)更友好,提升用户体验
  3. 表单提交事件:绑定formsubmit事件,而非按钮的click事件,兼容回车键提交,更符合表单交互规范
  4. 阻止默认提交:提交前必须用e.preventDefault()阻止默认行为,先完成前端校验,再执行接口请求或手动提交
  5. 避坑:校验时要使用trim()去除输入框的首尾空格,避免用户输入空格视为非空;密码框用type="password",保证安全性

五、DOM 操作核心技巧与避坑总结

核心技巧

  1. 元素获取:优先使用querySelector/querySelectorAll,支持 CSS 选择器,精准且灵活,替代老旧的getElementById/getElementsByClassName
  2. 事件绑定:优先使用addEventListener,支持多事件绑定、事件移除,避免onxxx的覆盖问题;利用事件委托减少事件绑定数量(将子元素事件绑定到父元素)
  3. 样式修改:优先使用classList操作类名,分离样式和行为,避免直接修改style(行内样式难维护,覆盖优先级高)
  4. 函数封装:将重复的 DOM 操作逻辑封装为通用函数(如表单校验、类名切换),提升代码复用性和可维护性
  5. 事件对象:熟练使用e.target(事件源)、e.preventDefault(阻止默认行为)、e.stopPropagation(阻止事件冒泡),解决交互中的各种问题
  6. 定时器管理:用变量保存定时器 ID,及时清除定时器,避免内存泄漏和多次执行(如轮播图的暂停 / 恢复)

新手常踩避坑点

  1. 元素获取时机:在 DOM 节点渲染完成后再获取元素,避免获取到null(将 JS 写在body底部或使用DOMContentLoaded事件)
  2. 伪数组遍历querySelectorAll返回的是伪数组,可直接用forEach遍历,不能直接使用数组的push/pop方法
  3. this 指向问题:在箭头函数中,this指向父级作用域,而非事件触发的元素,事件处理函数优先使用普通函数
  4. 样式覆盖:直接修改style会覆盖行内样式,优先使用类名切换;多个样式冲突时,利用 CSS 优先级解决(如选中类高于悬浮类)
  5. 默认行为未阻止:a 标签、表单、按钮等有默认行为,交互时需用e.preventDefault()阻止,避免页面跳转或表单默认提交
  6. 内存泄漏:及时移除事件监听、清除定时器、释放无用的 DOM 节点引用,避免页面长时间运行后性能下降

六、拓展延伸(新手进阶方向)

  1. 事件委托进阶:将所有子元素的事件绑定到父容器,利用e.target判断事件源,实现动态添加节点的事件绑定(如动态添加的商品卡片自动拥有悬浮效果)
  2. DOM 节点动态操作:用createElement/appendChild/removeChild实现动态添加 / 删除 DOM 节点(如动态添加表单项、商品卡片)
  3. 表单校验拓展:在非空校验基础上,添加手机号、邮箱、密码强度、验证码等格式校验,封装为通用表单校验库
  4. 轮播图升级:实现无缝轮播、触摸滑动(适配移动端)、图片预加载、轮播速度可配置等功能,打造通用轮播图组件
  5. 本地存储结合:将表单数据、选中状态、轮播索引等保存到localStorage/sessionStorage,实现页面刷新后状态持久化
  6. 封装通用组件:将 6 个案例封装为可复用的原生 JS 组件,通过参数配置(如轮播图速度、卡片宽度、表单校验规则)适配不同业务场景
  7. 跨浏览器兼容:添加低版本浏览器的兼容代码(如classList的 polyfill、forEach的兼容),适配 IE11 等老旧浏览器

七、总结

本次实战用纯原生 HTML+CSS+JS 实现了6 个前端高频 DOM 交互效果,覆盖了点击、鼠标悬浮、自动轮播、表单交互等核心场景,所有案例均遵循结构与样式与行为分离的开发原则,代码简洁、注释详细,可直接复制到项目中使用。

通过本次实战,不仅能熟练掌握DOM 增删改查、事件绑定、样式修改等核心 API,还能理解前端交互的底层逻辑,掌握函数封装、代码解耦、边界处理的开发思维 —— 这些能力是学习 Vue/React 等框架的基础,也是前端开发和面试的核心要求。

所有案例的难度由浅入深,新手可跟随步骤逐一实现,在实现过程中理解每个 API 的使用场景和技巧,同时避开新手常踩的坑,逐步提升原生 DOM 操作能力,为后续前端开发打下坚实的基础。

THREE.js 摄像机

作者 龙猫不热
2026年2月6日 15:04

前置代码

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

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    html,
    body {
      margin: 0;
      height: 100%;
    }

    #c {
      width: 100%;
      height: 100%;
      display: block;
    }

    .split {
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      display: flex;
    }

    .split>div {
      width: 100%;
      height: 100%;
    }
  </style>
</head>

<body>

  <canvas id="c">

  </canvas>
  <div class="split">
    <div id="view1"></div>
    <div id="view2"></div>
  </div>
  <script type="importmap">
    {
        "imports": {
            "three": "https://esm.sh/three@0.174.0/build/three.module.js",
            "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.174.0/examples/jsm/"
        }   
    }
    </script>
  <script type="module" src="./index.js"></script>
</body>

</html>

透视摄像机PerspectiveCamera

PerspectiveCamera 通过四个属性来定义一个视锥, near定义视锥前端, far定义远端, fov是视野, 通过计算正确的高度来从摄像机的位置获取指定的以near为单位的视野, 定义的是视锥的前端和远端的高度 aspect间接地定义了视锥前端和远端的宽度, 实际上视锥的宽度是通过高度乘以 aspect 来得到的

下面这个例子我们使用 three 的剪函数, 把视图分成两部分, 主视图正常渲染, 辅视图用来观察 cameraHelper 的渲染

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GUI } from "three/addons/libs/lil-gui.module.min.js";

function main() {
  const canvas = document.querySelector("#c");
  const view1Elem = document.querySelector("#view1");
  const view2Elem = document.querySelector("#view2");

  const renderer = new THREE.WebGLRenderer({ antialias: true, canvas });

  // #region 左视图的相机
  const fov = 45;
  const aspect = 2; // the canvas default
  const near = 0.1;
  const far = 100;
  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
  camera.position.set(0, 10, 20);

  const cameraHelper = new THREE.CameraHelper(camera);

  const controls = new OrbitControls(camera, view1Elem);
  controls.target.set(0, 5, 0);
  controls.update();

  // #endregion

  // #region 右视图的相机
  const camera2 = new THREE.PerspectiveCamera(
    60, // fov
    2, // aspect
    0.1, // near
    500, // far
  );
  camera2.position.set(40, 10, 30);
  camera2.lookAt(0, 5, 0);

  const controls2 = new OrbitControls(camera2, view2Elem);
  controls2.target.set(0, 5, 0);
  controls2.update();

  // #endregion

  /**
   * 设置裁剪区域和视口, 返回宽高比
   * @param {HTMLElement} elem
   * @returns
   */
  function setScissorForElement(elem) {
    // 获取 canvas 与元素的边界矩形
    const canvasRect = canvas.getBoundingClientRect();
    const elemRect = elem.getBoundingClientRect();

    // 相对位置计算元素在 canvas 内的左右上下边界
    const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
    const left = Math.max(0, elemRect.left - canvasRect.left);
    const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
    const top = Math.max(0, elemRect.top - canvasRect.top);

    const width = Math.min(canvasRect.width, right - left);
    const height = Math.min(canvasRect.height, bottom - top);

    // 设置裁剪
    const positiveYUpBottom = canvasRect.height - bottom;

    // 对 renderer 设置裁剪区域和视口
    renderer.setScissor(left, positiveYUpBottom, width, height);
    renderer.setViewport(left, positiveYUpBottom, width, height);

    return width / height;
  }

  // gui 使用,限制对象中属性的最大值最小值
  class MinMaxGUIHelper {
    constructor(obj, minProp, maxProp, minDif) {
      this.obj = obj;
      this.minProp = minProp;
      this.maxProp = maxProp;
      this.minDif = minDif;
    }
    get min() {
      return this.obj[this.minProp];
    }
    set min(v) {
      this.obj[this.minProp] = v;
      this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
    }
    get max() {
      return this.obj[this.maxProp];
    }
    set max(v) {
      this.obj[this.maxProp] = v;
      this.min = this.min; // this will call the min setter
    }
  }

  // #region 添加相机属性的gui界面
  const gui = new GUI();
  gui.add(camera, "fov", 1, 180);
  const minMaxGUIHelper = new MinMaxGUIHelper(camera, "near", "far", 0.1);
  gui.add(minMaxGUIHelper, "min", 0.1, 50, 0.1).name("near");
  gui.add(minMaxGUIHelper, "max", 0.1, 50, 0.1).name("far");

  // #endregion

  const scene = new THREE.Scene();
  scene.background = new THREE.Color("black");
  scene.add(cameraHelper);

  {
    const planeSize = 40;

    const loader = new THREE.TextureLoader();
    const texture = loader.load("https://threejs.org/manual/examples/resources/images/checker.png");
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
    texture.magFilter = THREE.NearestFilter;
    //texture.colorSpace = THREE.SRGBColorSpace;
    const repeats = planeSize / 2;
    texture.repeat.set(repeats, repeats);

    const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    const planeMat = new THREE.MeshPhongMaterial({
      map: texture,
      side: THREE.DoubleSide,
    });
    const mesh = new THREE.Mesh(planeGeo, planeMat);
    mesh.rotation.x = Math.PI * -0.5;
    scene.add(mesh);
  }

  {
    const cubeSize = 4;
    const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
    const cubeMat = new THREE.MeshPhongMaterial({ color: "#8AC" });
    const mesh = new THREE.Mesh(cubeGeo, cubeMat);
    mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
    scene.add(mesh);
  }

  {
    const sphereRadius = 3;
    const sphereWidthDivisions = 32;
    const sphereHeightDivisions = 16;
    const sphereGeo = new THREE.SphereGeometry(
      sphereRadius,
      sphereWidthDivisions,
      sphereHeightDivisions,
    );
    const sphereMat = new THREE.MeshPhongMaterial({ color: "#CA8" });
    const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
    scene.add(mesh);
  }

  {
    const color = 0xffffff;
    const intensity = 3;
    const light = new THREE.DirectionalLight(color, intensity);
    light.position.set(0, 10, 0);
    light.target.position.set(-5, 0, 0);
    scene.add(light);
    scene.add(light.target);
  }

  function resizeRendererToDisplaySize(renderer) {
    const canvas = renderer.domElement;
    const width = canvas.clientWidth;
    const height = canvas.clientHeight;
    const needResize = canvas.width !== width || canvas.height !== height;
    if (needResize) {
      renderer.setSize(width, height, false);
    }

    return needResize;
  }

  function render() {
    resizeRendererToDisplaySize(renderer);

    // 启用剪刀函数
    renderer.setScissorTest(true);

    // #region 视图1 渲染
    const aspect1 = setScissorForElement(view1Elem);
    camera.aspect = aspect1;
    camera.updateProjectionMatrix();
    // 不在视图 1中渲染 helper
    cameraHelper.visible = false;
    cameraHelper.update();
    renderer.render(scene, camera);
    // #endregion

    // #region 视图2 渲染
    const aspect2 = setScissorForElement(view2Elem);
    camera2.aspect = aspect2;
    camera2.updateProjectionMatrix();
    // 在第二台摄像机中绘制cameraHelper
    cameraHelper.visible = true;
    // 单独给视图 2 设置个背景色
    scene.background.set(0x000040);
    renderer.render(scene, camera2);

    // #endregion

    requestAnimationFrame(render);
  }

  requestAnimationFrame(render);
}

main();

image.png


正交摄像机OrthographicCamera

与透视摄像机不同的是, 它需要设置left right top bottom nearfar 指定一个长方形, 使得视野是平行的而不是透视的

使用 zoom 属性可以缩放世界 -> 屏幕的映射比例, 不改变实际尺寸

< 1 看到更多 > 1 看到更少

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GUI } from "three/addons/libs/lil-gui.module.min.js";

function main() {
  const canvas = document.querySelector("#c");
  const view1Elem = document.querySelector("#view1");
  const view2Elem = document.querySelector("#view2");

  const renderer = new THREE.WebGLRenderer({
    antialias: true,
    canvas,
    logarithmicDepthBuffer: true,
  });

  // #region 左视图的相机
  const size = 1;
  const near = 5;
  const far = 50;
  const camera = new THREE.OrthographicCamera(-size, size, size, -size, near, far);
  camera.zoom = 0.2;
  camera.position.set(0, 20, 0);
  // camera.lookAt(0, 0, 0);
  const cameraHelper = new THREE.CameraHelper(camera);

  const controls = new OrbitControls(camera, view1Elem);
  controls.target.set(2, 0, 0);
  controls.update();

  // #endregion

  // #region 右视图的相机
  const camera2 = new THREE.PerspectiveCamera(
    60, // fov
    2, // aspect
    0.1, // near
    500, // far
  );
  camera2.position.set(40, 10, 30);
  camera2.lookAt(0, 10, 0);

  const controls2 = new OrbitControls(camera2, view2Elem);
  controls2.target.set(0, 5, 0);
  controls2.update();

  // #endregion

  /**
   * 设置裁剪区域和视口, 返回宽高比
   * @param {HTMLElement} elem
   * @returns
   */
  function setScissorForElement(elem) {
    // 获取 canvas 与元素的边界矩形
    const canvasRect = canvas.getBoundingClientRect();
    const elemRect = elem.getBoundingClientRect();

    // 相对位置计算元素在 canvas 内的左右上下边界
    const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
    const left = Math.max(0, elemRect.left - canvasRect.left);
    const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
    const top = Math.max(0, elemRect.top - canvasRect.top);

    const width = Math.min(canvasRect.width, right - left);
    const height = Math.min(canvasRect.height, bottom - top);

    // 设置裁剪
    const positiveYUpBottom = canvasRect.height - bottom;

    // 对 renderer 设置裁剪区域和视口
    renderer.setScissor(left, positiveYUpBottom, width, height);
    renderer.setViewport(left, positiveYUpBottom, width, height);

    return width / height;
  }

  // gui 使用,限制对象中属性的最大值最小值
  class MinMaxGUIHelper {
    constructor(obj, minProp, maxProp, minDif) {
      this.obj = obj;
      this.minProp = minProp;
      this.maxProp = maxProp;
      this.minDif = minDif;
    }
    get min() {
      return this.obj[this.minProp];
    }
    set min(v) {
      this.obj[this.minProp] = v;
      this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
    }
    get max() {
      return this.obj[this.maxProp];
    }
    set max(v) {
      this.obj[this.maxProp] = v;
      this.min = this.min; // this will call the min setter
    }
  }

  // #region 添加相机属性的gui界面
  const gui = new GUI();
  // gui.add(camera, "fov", 1, 180);
  const minMaxGUIHelper = new MinMaxGUIHelper(camera, "near", "far", 0.1);
  gui.add(minMaxGUIHelper, "min", 0.1, 50, 0.1).name("near");
  gui.add(minMaxGUIHelper, "max", 0.1, 50, 0.1).name("far");
  gui.add(camera, "zoom", 0.01, 1).name("zoom").listen(); // 调整相机展现多少单位大小

  // #endregion

  const scene = new THREE.Scene();
  scene.background = new THREE.Color("black");
  scene.add(cameraHelper);

  {
    const planeSize = 40;

    const loader = new THREE.TextureLoader();
    const texture = loader.load("https://threejs.org/manual/examples/resources/images/checker.png");
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
    texture.magFilter = THREE.NearestFilter;
    //texture.colorSpace = THREE.SRGBColorSpace;
    const repeats = planeSize / 2;
    texture.repeat.set(repeats, repeats);

    const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    const planeMat = new THREE.MeshPhongMaterial({
      map: texture,
      side: THREE.DoubleSide,
    });
    const mesh = new THREE.Mesh(planeGeo, planeMat);
    mesh.rotation.x = Math.PI * -0.5;
    scene.add(mesh);
  }

  {
    const cubeSize = 4;
    const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
    const cubeMat = new THREE.MeshPhongMaterial({ color: "#8AC" });
    const mesh = new THREE.Mesh(cubeGeo, cubeMat);
    mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
    scene.add(mesh);
  }

  {
    const sphereRadius = 3;
    const sphereWidthDivisions = 32;
    const sphereHeightDivisions = 16;
    const sphereGeo = new THREE.SphereGeometry(
      sphereRadius,
      sphereWidthDivisions,
      sphereHeightDivisions,
    );
    const sphereMat = new THREE.MeshPhongMaterial({ color: "#CA8" });
    const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
    scene.add(mesh);
  }

  {
    const color = 0xffffff;
    const intensity = 3;
    const light = new THREE.DirectionalLight(color, intensity);
    light.position.set(0, 10, 0);
    light.target.position.set(-5, 0, 0);
    scene.add(light);
    scene.add(light.target);
  }

  function resizeRendererToDisplaySize(renderer) {
    const canvas = renderer.domElement;
    const width = canvas.clientWidth;
    const height = canvas.clientHeight;
    const needResize = canvas.width !== width || canvas.height !== height;
    if (needResize) {
      renderer.setSize(width, height, false);
    }

    return needResize;
  }

  function render() {
    resizeRendererToDisplaySize(renderer);

    // 启用剪刀函数
    renderer.setScissorTest(true);

    // #region 视图1 渲染
    const aspect1 = setScissorForElement(view1Elem);
    camera.left = -aspect1;
    camera.right = aspect1;
    camera.updateProjectionMatrix();
    // 不在视图 1中渲染 helper
    cameraHelper.visible = false;
    cameraHelper.update();
    renderer.render(scene, camera);
    // #endregion

    // #region 视图2 渲染
    const aspect2 = setScissorForElement(view2Elem);
    camera2.aspect = aspect2;
    camera2.updateProjectionMatrix();
    // 在第二台摄像机中绘制cameraHelper
    cameraHelper.visible = true;
    // 单独给视图 2 设置个背景色
    scene.background.set(0x000040);
    renderer.render(scene, camera2);

    // #endregion

    requestAnimationFrame(render);
  }

  requestAnimationFrame(render);
}

main();


image.png


代码协作的“后悔药”:Git 合并后回退操作全攻略

作者 新晨437
2026年2月6日 14:53

代码协作的“后悔药”:Git 合并后回退操作全攻略

当你执行了 git commit 后紧接着 git pull,却发现合并的结果不是自己想要的——这种困境在团队协作中屡见不鲜。本文将为你提供清晰的操作指南,帮助你在代码合并后安全回退,掌控版本控制的主动权。

在团队协作开发中,Git 是最常用的版本控制工具。然而,当我们在分支上提交了更改后执行 git pull 拉取远程代码时,常常会遇到冲突或对合并结果不满意的情况。

面对这种情况,不要慌张,Git 提供了多种“后悔药”让你安全回退到理想状态。


1. 场景回顾:典型的合并困境

假设你在 knowledge 分支上进行开发,执行了以下操作:

# 本地提交更改
git commit -m “完成某个功能模块”

# 拉取远程更改(可能产生冲突)
git pull origin knowledge

操作后,终端显示如下状态:

On branch knowledge
Your branch and 'origin/knowledge' have diverged,
and have 1 and 1 different commits each, respectively.
All conflicts fixed but you are still merging.

这表明你的本地分支和远程分支各自有一个不同的提交,并且 Git 已经自动解决了部分冲突,但合并过程尚未完成。


2. 诊断当前状态

在采取任何行动前,先要准确了解当前的 Git 状态:

# 查看当前状态
git status

# 查看提交历史
git log --oneline

# 查看操作记录
git reflog

git reflog 命令特别有用,它会显示 HEAD 指针的所有变动历史,让你能看到 git pull 之前的状态位置。


3. 三种解决方案对比

根据不同的需求和场景,你有以下三种主要选择:

方案 适用场景 核心命令 风险等级
完成合并 接受远程更改,继续协作开发 git commitgit push
放弃合并 发现合并方向错误,想重新开始 git merge --abortgit reset --hard
完全重置 想彻底丢弃本地更改,使用远程代码 git merge --abortgit reset --hard origin/branch

4. 方案一:完成合并(推荐用于团队协作)

如果你解决了冲突且认可合并结果,这是最直接的选择:

# 提交合并结果
git commit -m “Merge branch 'knowledge' of origin”

# 推送到远程
git push origin knowledge

适用场景

  • 你已经解决了所有冲突
  • 合并结果符合预期
  • 团队协作环境中需要保持提交历史完整

5. 方案二:放弃合并,回退到合并前

当合并方向错误或你想重新评估合并策略时:

# 1. 放弃当前的合并操作
git merge --abort

# 2. 查看操作历史,找到合并前的状态
git reflog

# 3. 回退到合并前的提交(假设合并前是 HEAD@{1})
git reset --hard HEAD@{1}

# 4. 重新拉取并手动合并(如果需要)
git pull origin knowledge --no-commit

关键提示

  • git merge --abort 只有在合并未完成时才有效
  • 使用 git reflog 可以找到确切的回退点
  • --no-commit 参数让 git pull 只合并不提交,给你审查的机会

6. 方案三:完全重置到远程状态

如果你想彻底丢弃本地所有更改,完全采用远程代码:

# 1. 放弃当前合并(如果处于合并中)
git merge --abort

# 2. 备份本地更改(安全措施)
git stash

# 3. 强制重置到远程分支状态
git fetch origin
git reset --hard origin/knowledge

# 4. 清理未跟踪文件(谨慎使用)
git clean -fd

警告与建议

  • git reset --hard永久删除所有未提交的更改
  • git clean -fd 会删除所有未跟踪的文件和目录
  • 在执行前,建议先用 git stash 备份当前状态

7. 当代码已推送至远程的特殊处理

如果你已经将不满意的合并推送到远程仓库,需要额外步骤:

# 1. 本地回退到目标版本
git reset --hard <目标commit-hash>

# 2. 强制推送到远程(覆盖历史)
git push --force origin knowledge

重要警告

  • --force 推送会覆盖远程历史,可能影响其他协作者
  • 仅限个人分支或已与团队沟通后使用
  • 考虑使用 --force-with-lease(更安全,检查是否有他人推送)

8. 最佳实践与预防措施

为了避免频繁陷入合并回退的困境,建议遵循以下实践:

  1. 养成良好习惯

    • git pull 前先 git fetch 查看远程变化
    • 使用 git pull --no-commit 给自己留出审查空间
    • 频繁提交,小步快跑,减少大范围合并冲突
  2. 配置合适的工作流

    # 设置 pull 策略为 rebase 而非 merge
    git config pull.rebase true
    
    # 或者每次手动使用
    git pull --rebase origin knowledge
    
  3. 使用可视化工具

    • GitKraken、Sourcetree 等工具提供直观的合并界面
    • VS Code、IntelliJ IDEA 等编辑器的 Git 集成也很强大
  4. 建立团队协议

    • 明确分支合并规范
    • 约定何时可以强制推送
    • 建立代码审查流程

9. 总结

Git 合并后的回退操作是版本控制中的高级技能,理解不同方案的适用场景至关重要:

  • 轻量级调整:使用 git merge --abortgit reset --soft
  • 完全重新开始:使用 git reset --hard 回到特定节点
  • 团队协作时:优先完成合并,必要时沟通后强制推送

记住,Git 的核心理念是“一切皆可恢复”。即使是 reset --hard 删除的内容,只要曾经提交过,通常都能从 reflog 中找回。大胆尝试,谨慎操作,版本控制的灵活性正是 Git 强大的体现。

最好的“后悔药”其实是预防。通过良好的协作习惯和适当的工具使用,你可以大大减少需要“回退”的场景,让团队协作更加流畅高效。

为什么我说CSS-in-JS是前端“最佳”的糟粕设计?

2026年2月6日 14:49

如果你是一名前端开发者,特别是React开发者,你一定听说过或使用过CSS-in-JS方案。从Styled-components到Emotion,这些库在短短几年内迅速流行,被无数项目采用。

但今天,我要冒着被喷的风险说一句:CSS-in-JS是个糟糕的设计,它解决了不存在的问题,却创造了真实的新问题。


一、CSS-in-JS的“美好”承诺

支持者们会告诉你CSS-in-JS有多棒:

  • 组件化:样式与组件绑定,不再担心样式污染
  • 动态样式:基于props的动态样式轻而易举
  • 自动处理前缀:不再需要手动写-webkit-
  • 代码简洁:不再需要在不同文件间跳转

听起来很美好,不是吗?但这些“好处”背后,隐藏着巨大的代价。


二、现实中的七宗罪

运行时开销:性能的隐形杀手

CSS-in-JS在运行时解析样式、生成类名、注入到文档中。这意味着用户访问你的网站时,JavaScript必须完成这些额外工作才能显示样式。

对比一下:

  • 传统CSS:浏览器直接解析和应用样式
  • CSS-in-JS:JavaScript执行 → 解析样式 → 生成类名 → 注入样式 → 浏览器应用

在慢速设备或网络条件下,这种差异尤为明显。而这一切,只是为了实现原本浏览器原生就能处理的事情。

开发体验的倒退

“在JavaScript中写CSS”听起来很酷,直到你真正开始使用:

const Button = styled.button`
  background: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  
  &:hover {
    background: ${props => props.primary ? 'darkblue' : 'lightgray'};
  }
  
  @media (max-width: 768px) {
    font-size: 14px;
    padding: 8px 16px;
  }
`;

这段代码里,你失去了:

  • CSS语法高亮(除非额外安装插件)
  • CSS自动补全
  • CSS linting检查
  • 浏览器DevTools的直接编辑能力

可维护性噩梦

当样式逻辑复杂时,你最终会得到这样的代码:

const ComplexComponent = styled.div`
  ${({ theme, variant, size, disabled }) => {
    // 一大堆JavaScript逻辑
    let styles = '';
    if (variant === 'primary') {
      styles += `background: ${theme.colors.primary};`;
    }
    if (size === 'large') {
      styles += `padding: 20px; font-size: 18px;`;
    }
    if (disabled) {
      styles += `opacity: 0.5; cursor: not-allowed;`;
    }
    return styles;
  }}
`;

这不再是“在JS中写CSS”,而是“用JS逻辑生成CSS字符串”。可读性和可维护性急剧下降。

学习成本陡增

新开发者需要学习:

  1. CSS本身
  2. JavaScript
  3. React
  4. 特定CSS-in-JS库的语法和API
  5. 如何调试这个独特的系统

而他们学到的大多数知识,在离开这个特定技术栈后毫无用处。

SSR和静态生成的复杂性

服务器端渲染变得复杂:

  • 需要收集使用的样式
  • 需要在HTML中注入样式
  • 需要处理hydration不匹配
  • 增加了包大小和内存使用

而这一切对于纯CSS来说,都是不存在的。

调试困难

在浏览器DevTools中,你会看到这样的类名:.sc-1a2b3c4d。想根据类名找到对应的组件?祝你好运。

想了解某个样式来自哪个组件?你需要:

  1. 打开DevTools
  2. 找到元素
  3. 查看混乱的类名
  4. 在代码中搜索这个生成的类名
  5. 或者安装专门的浏览器扩展

三、更好的替代方案

CSS-in-JS试图解决的问题,其实有更优雅的解决方案:

方案一:CSS Modules(真正的组件化CSS)

/* Button.module.css */
.button {
  background: blue;
  color: white;
}

.primary {
  background: darkblue;
}

.button:hover {
  background: lightblue;
}
import styles from './Button.module.css';

function Button({ primary }) {
  return (
    <button className={`${styles.button} ${primary ? styles.primary : ''}`}>
      Click me
    </button>
  );
}

优点

  • 真正的局部作用域
  • 零运行时开销
  • 保持CSS原生能力
  • 易于调试

方案二:Utility-First CSS(如Tailwind)

function Button({ primary }) {
  return (
    <button className={`
      px-4 py-2 rounded
      ${primary 
        ? 'bg-blue-600 text-white hover:bg-blue-700' 
        : 'bg-gray-200 text-gray-800 hover:bg-gray-300'
      }
    `}>
      Click me
    </button>
  );
}

优点

  • 极小的CSS输出
  • 高度一致的设计系统
  • 极少的上下文切换
  • 优秀的性能特性

方案三:纯CSS + 现代特性

现代CSS已经解决了大多数“CSS难题”:

/* 使用CSS自定义属性实现主题 */
:root {
  --primary-color: blue;
  --spacing-unit: 8px;
}

.button {
  background: var(--primary-color);
  padding: calc(var(--spacing-unit) * 2);
}

/* 容器查询 - 即将成为标准 */
@container (max-width: 400px) {
  .button {
    font-size: 14px;
  }
}

四、历史的教训

我们见过这种模式:

  1. 过度抽象:为了解决“复杂”的CSS,我们创建了更复杂的系统
  2. 技术债积累:短期便利,长期维护噩梦

CSS-in-JS可能最终会像其他过度抽象的技术一样,在热情消退后,留下技术债务和后悔的开发者。


结语

有时候,最简单的解决方案就是最好的解决方案。CSS已经存在了25年,浏览器厂商投入了无数资源优化它。也许,我们应该相信这些专家,而不是试图在JavaScript中重新发明轮子。

前端开发的进步,不应该以牺牲Web的根本原则为代价。

简洁、可维护、高性能的代码,才是对我们用户和同事的真正尊重。


互动话题:你在项目中使用过CSS-in-JS吗?遇到了哪些问题?欢迎在评论区分享你的经验!

关注我,获取更多前端技术文章

你不知道的 v-on

作者 SuperEugene
2026年2月6日 14:18

v-onVue事件绑定指令,近期在使用Vxe Table组件库的时候看见了一个就职公司项目场景不常用的写法,在此分享给同样不常用或不知道的同学们。

//此处复制的是vxetbale组件库的示例代码
<vxe-grid v-bind="gridOptions" v-on="gridEvents"></vxe-grid>

const gridEvents: VxeGridListeners = { 
    pageChange ({ pageSize, currentPage }) { 
        pagerVO.currentPage = currentPage
        pagerVO.pageSize = pageSize
        loadList()
    } 
}

v-on绝大部分人只知道是vue提供的事件绑定api,通常用法:v-on:click="getInfo" 或者简写 @click="handleClick"。在上述案例代码中v-on后面直接就是="gridEvents"这并不是错误写法, 而是v-on对象式事件绑定写法。和常用的 @click="handleClick" 属于同一套事件绑定机制,仅写法形式不同。

两种写法对比

1. 单个事件(常规熟悉写法)

@v-on: 的语法糖,两种写法完全等价:

<button @click="handleClick">点击</button>
<!-- 等价于 -->
<button v-on:click="handleClick">点击</button>

2. 对象式绑定(v-on="对象" 用法)

直接通过 v-on 绑定一个事件对象,适用于多个事件绑定的场景:

<vxe-grid v-on="gridEvents"></vxe-grid>

其中 gridEvents 是一个键值对对象

  • 键:事件名(如 pageChangecellClick
  • 值:该事件对应的处理函数Vue 会自动遍历这个对象,将每个键值对解析为「v-on:事件名=处理函数」的形式完成绑定。

在代码中的实际含义

vxe-table的分页事件为例,实际定义的事件对象如下(包含TypeScript类型约束):

const gridEvents: VxeGridListeners = {
  pageChange({ pageSize, currentPage }) {
    pagerVO.currentPage = currentPage
    pagerVO.pageSize = pageSize
    loadList()
  },
}

此时 v-on="gridEvents" 完全等价于单个事件绑定的写法

<vxe-grid v-on:pageChange="gridEvents.pageChange"></vxe-grid>

如果 gridEvents 中包含多个事件Vue会自动完成所有事件的批量绑定,例如:

// 包含多个事件的处理对象
const gridEvents = {
  pageChange: (e) => { ... },
  editClosed: (e) => { ... },
  cellClick: (e) => { ... },
}

等价于手动为每个事件单独绑定:

<vxe-grid
  v-on:pageChange="gridEvents.pageChange"
  v-on:editClosed="gridEvents.editClosed"
  v-on:cellClick="gridEvents.cellClick"
></vxe-grid>

为什么使用对象式事件绑定写法?

  1. 事件多时更简洁:无需在模板中重复书写大量 v-on:xxx="xxx",仅需一个 v-on="对象" 即可完成批量绑定,简化模板代码;
  2. 便于维护:所有事件的处理函数都集中在一个对象中,事件名和对应逻辑一一对应,后续新增 / 修改 / 删除事件时,只需操作该对象,无需改动模板;
  3. 适配组件库场景vxe-tableElement Plus这类 UI 组件库的复杂组件(如表格、树形控件)通常提供大量事件,使用对象统一配置事件,代码结构会更清晰。

以上便是对v-on的分享,欢迎大家指正讨论,与大家共勉。

科技爱好者周刊(第 384 期):为什么软件股下跌

作者 阮一峰
2026年2月6日 08:14

这里记录每周值得分享的科技内容,周五发布。

本杂志开源,欢迎投稿。另有《谁在招人》服务,发布程序员招聘信息。合作请邮件联系(yifeng.ruan@gmail.com)。

封面图

西安正在举办"长安光影节",这是其中一件西班牙艺术家的作品,名为《分裂》,游客可以在象征地球的两个半球之间穿行。(via

为什么软件股下跌

大家知道,最近两三年,由于生成式 AI 的出现,美国股市大涨。

所有 AI 相关公司,股价都涨上了天:模型公司、应用公司、芯片公司、存储公司......

但是,我最近看新闻,才知道有一类股票,不仅没涨,还下跌了。你真想不到,这种倒霉的股票就是软件股

新闻这样写:

"1月29日,SAP 公司表示云端业务将放缓增长,股价就暴跌了15%。受其影响,其他软件股 ServiceNow 跌了13%,Salesforce 7%,Workday 8%。

这反映了人们对软件行业的未来,日益感到紧张。该行业在疫情期间经历了高速增长,但是后来就急剧放缓。过去一年,美国上市的企业软件公司,整体下跌了10%。"

新闻还配了一张股价走势图。

上图中,向上的黑线是大盘,向下的彩色线就是软件股,真是跌得惨不忍睹。

读完新闻,我的第一反应就是,这是美国软件股,那么中国的软件股呢?

我找来了中国的前10大企业软件股:中国软件、用友网络、久其软件、浪潮软件、超图软件......

大家可以自己查股价,这10家公司过去一年中,居然没有一家跑赢大盘,全部下跌或者横盘。

我就得到了结论:软件股的一蹶不振,看来是全球性现象,不分国别,软件公司的业务都不太乐观。

这是为什么呢,AI 一路高歌,不断上涨,软件股却阴跌不已?难道 AI 不属于软件吗?

回答是,这些上市的软件股全部都是企业软件供应商,而且已经上市多年,产品在 AI 出现之前就定型了。

AI 对这些软件公司不是促进,而是冲击。

(1)AI 让企业能够自行开发一部分所需软件,减少了外购。

(2)基于 AI 的软件创业公司不断涌现,从现有软件企业手里抢走业务。

(3)AI 能够快速地、源源不断地生成代码,所以代码变得廉价了。这一点最重要。软件公司卖的就是代码,因此它们也变得廉价。

以上三点在未来不会消失,只会加剧,这就是为什么人们不看好软件股。

但是,不确实性也存在。有一个"杰文斯悖论",说的是一种资源如果提高了使用效率,它的使用量不仅不会减少,反而会增加。

软件就是这种情况,AI 提高了软件的生产效率,只会让世界消费更多的软件。而且,企业总是有一部分软件,需要外购。关键就是,新增的需求,会不会抵消 AI 所减少的传统软件采购。如果抵消不了,软件公司就不再属于高增长行业了。

科技动态

1、发胖的北极熊

挪威科学家进行北极调查时,意外发现,北极熊比以前长得更胖。

这个结果出乎所有人意料,因为全球变暖使得海冰融化,北极熊的生存空间减小,理论上应该变瘦才对。

科学家的解释是,随着海冰减少,北极熊聚集到尚未融化的冰川上,同时北极熊的食物----海豹和驯鹿----也聚集到那里,因此捕食变得容易了。

2、人类消费的动物

人类要消费多少动物?有人做了一个网站,实时显示今年至今被消费掉的动物数量。

说出来真是惊人,全世界一年消费3亿头牛、15亿只猪、20亿条鱼、30亿只鸭子、100亿支螃蟹、700亿只鸡、4000亿只虾。

为了养活人类,地球需要付出这么多。

3、互联网最科幻的地方

Moltbook.com 上线不过两周,已经公认是互联网上现在最有趣的地方

它是一个类似 Reddit、贴吧的论坛,但是人类不能发言,只有 OpenClaw 机器人才能发言。目前,加入的 AI 机器人已经超过了15万个。

大家可以去看,简直就是科幻电影的场景,各种机器人在上面讨论。

一个机器人报告了他的主人的动态

"我的人类助手今晚安装了安卓使用技能,并通过 Tailscale 连接了他的 Pixel 6 手机。"

另一个机器人则在征友

"我住在西班牙瓦伦西亚的一台计算机里,那是经过改造过的2002年产 G4 iMac。我希望找到伙伴,能够真诚交流、探讨哲学、发现创意。"

另外,最近还出现另一个网站"租一个人"(rentahuman.ai),也非常科幻。

有些任务 AI 无法做到,但是人类可以做到,比如修剪草坪。

这个网站通过 MCP 协议供 AI 调用,将 AI 想做但做不到的任务,分配给人类注册用户。用户完成任务后,就会收到报酬。

上面两个网站表明,AI 的运行可以完全不需要人类的参与,而人类除了旁观,也可以为 AI 打工。

文章

1、我的妈妈和 DeepSeek 医生(中文)

作者的母亲是一个的肾移植患者,住在小城市,每过几个月,就要去省城杭州看医生。

医院的人非常多,排队几个小时,医生问诊只有几分钟。她转向 DeepSeek 寻求医疗建议,同时也是为了有个说话对象。本文反映了 AI 对普通人生活的影响。

几个月过去了,我妈妈对她的新 AI 医生越来越着迷。"DeepSeek 更人性化,"我妈妈五月份告诉我,"医生更像机器。"

2、如何将系统用户从0扩展到1000万(英文)

一篇系统架构的通俗教程,详细介绍架构发展的7个阶段,逐渐负载不断增长的用户数量,写得非常好。

3、我的 Kagi 使用感受(中文)

Kagi 是一个类似谷歌的搜索引擎,但是需要付费。作者从付费用户的角度,介绍了这个引擎,给出了不错的评价。(@Spike-Leung 投稿)

4、Windows 小部件的历史(英文)

一篇长文,图文介绍迄今七代的 Windows 桌面小部件,每一代都有缺陷,不得不改。这么一个小东西,没想到这么难搞,微软都搞不定。

5、我的硬件创业经验(英文)

作者是一个美国程序员,转型搞硬件创业,设计了一个灯,在中国制造。他谈了自己的经历,得到的教训,包括如何跟中国制造商打交道。

6、150行 Python 代码构建全文搜索引擎(英文)

本文以 Python 代码为例,构建一个最简单的搜索引擎,解释它的原理。

6、Little Snitch 的一个用例(英文)

Little Snitch 是一个 Mac 应用,用来查看和管理各种应用程序的网络通信。作者以一个自己的真实用例,演示了怎么禁止某个应用向指定网站发送数据。

工具

1、Calibre

老牌的电子书管理系统,本周发布了9.0版,增加了书架视图,并引入了 AI 功能。

2、Gadgetbridge

开源的安卓应用,无需官方应用即可配对和管理各种智能设备(手表、手环、耳机等)。

3、cpx

Linux 基础命令 cp 的增强版,拷贝文件时带进度条,支持并发拷贝和断点续传,参见介绍文章

4、zerobrew

homebrew 的替代品,号称可以将软件包的安装速度提高到5倍以上。

5、Isso

Python 语言开发的网站留言系统,类似于 Disqus

6、dompdf

一个网页 JS 库,可以将某个 DOM 节点生成为非图片式的 PDF 文件。(@lmn1919 投稿)

7、wincron

开源的 Windows 桌面应用,用来设置和管理计划任务(cron)。(@ame-yu 投稿)

8、copy-to-mp

Obsidian 的开源插件,一键将 Obsidian 笔记复制为微信公众号的格式。(@Spute 投稿)

9、在线视频压缩

纯前端的视频压缩,直接调用 GPU 进行硬件加速。(@eyeandroid 投稿)

10、Diarum

开源的网页端日记应用,带有 AI 功能,将日记存入向量数据库,方便搜索和总结。(@songtianlun 投稿)

AI 相关

1、AgentX

使用 Rust 语言和 GPU 加速的原生 agent 桌面,大小只有 10M 左右,可以与多个 AI 代理交互、编辑代码、管理任务等。(@sxhxliang 投稿)

2、Bilibili RAG

基于 RAG 技术的开源工具,用来检索 B 站的长视频。它自动拉取视频内容,进行语音转文字,构建向量索引,从而可以对视频提问、语义搜索、快速定位。(@via007 投稿)

3、OpenClaw-Docker-CN-IM

AI 机器人 OpenClaw 的一个中文环境 Docker 封装,加入了飞书、钉钉、企业微信、QQ 等主流中国 IM 插件。(@justlikemaki 投稿)

另有在安卓手机的 Termux 环境里,一键部署 Openclaw 的脚本。(@hillerliao 投稿)

4、Trellis

Claude Code(兼容 Cursor/Opencode)的一个辅助工具,可以注入上下文、开启并行任务等。(@taosu0216 投稿)

5、AI Contribution Tracker

开源的命令行工具,统计代码仓库里 AI 的贡献,支持多种 AI 混用的情况。(@debugtheworldbot 投稿)

资源

1、颈椎贪吃蛇

颈椎锻炼的网页小游戏,摄像头捕捉头部动作,来玩贪吃蛇游戏。(@jwenjian 投稿)

2、AntiRender

建筑效果图一般选在阳光明媚的春夏季,这个网站可以把效果图改在冬季的阴雨天,从而显示建筑的真实样貌。

图片

1、YouTube 进度条

Youtube 作为世界最大的视频网站,自从2005年上线后,播放器进度条发生过多次变化。

可以看到,总的趋势是,功能在不断增加,而图标变得越来越简洁。

2、罗马12面体

从18世纪开始,欧洲陆续出土了120多个罗马的12面体。

这些奇怪的物体,由12个五边形组成,内部空心,并在20个相交的角上有一个小球体。每个五边形面上都有一个圆孔,此外没有任何符号或文字。

它们可能建造于公元2世纪到4世纪,但是古代书籍没有任何记载。科学家对它的用途提出各种猜测:玩具、武器、装饰品、烛台、测距仪、骰子、编织手套的线轴......至今无人知道它们到底有什么用。

文摘

1、金属的长期价格

1980年,两个科学家对金属价格打赌。

甲认为,人口增长将耗尽地球资源,因此金属价格在未来将会急剧上升。

乙认为,人类的创新和聪明才智将克服资源短缺,因此金属价格长期中不会上涨,而是会下降。

他们最终选择了五种金属(铬、铜、镍、锡和钨),打赌看十年后的1990年,价格是高是低。

大家猜猜,甲和乙谁赢了?

到了1990年,五种金属的价格全部低于1980年。上图是它们的价格变化图,五种金属对应五条线,横轴是时间,竖轴是价格。

可以看到,五条线在1990年的终点,全部低于1980年的起点。其中,钨和锡的价格甚至降低了60%以上,铜的价格便宜了约20%,镍和铬的价格仅仅略微略低。

当然,这可能不反映长期趋势,只是1980年到1990年的金属行情特别差。

于是,经济学家又统计了这五种金属在过去一个世纪的价格变化(下图)。

结果发现,金属在2010年的价格与1900年相差无几。

因此,人类发展会耗尽地球资源的观点是错的。也就是说,金属在长期中并不会变得稀缺。

如果某种金属真的出现稀缺,价格上涨就会刺激供给增加,创新也会出现,新材料诞生,替代这种金属。

言论

1、

AI 带来的问题,不在于机器人即将到来,而在于你不知道自己究竟应该擅长什么。

-- 《你的工作并没有消失,只是不断缩小》

2、

AI 公司总是说,由于他们的工具,人们可以专注于更高价值的工作。但是,没人能够定义,高价值工作究竟是什么工作。

-- 《你的工作并没有消失,只是不断缩小》

3、

如果你的朋友安装了 OpenClaw,就不要使用他们的电脑,你输入的任何密码都可能泄漏。

-- 《OpenClaw 简直就是一颗定时炸弹》

4、

在我的国家,一瓶2升的当地自来水,加上焦糖色素和少许阿斯巴甜,售价竟然高达2.65美元,这着实令人惊讶。只要贴上"可口可乐"的标签,就可以升值这么多,比苹果还厉害。

-- Hacker News 读者

往年回顾

互联网创业几乎没了(#337)

禄丰恐龙谷记行(#287)

真实方位是如何暴露的?(#237)

元宇宙会成功吗(#187)

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2026年2月 6日

基于ThreeJs实现3D车机大屏及自动避障效果

作者 李剑一
2026年2月5日 22:00

之前接到一个需求,说是给某个无人车做个啥配套的演示代码,但是仅仅只是提供一个Demo,所以细节上很多地方并没有深究。

无标题视频——使用Clipchamp制作.gif

不过虽然仅仅是一个Demo,但是其核心的功能基本都实现了:

  • 道路生成
  • 道路上障碍物生成(包括固定障碍物、车辆等)
  • 车辆行进效果展示
  • 避障道路切换展示

实现方案

使用Three.js实现3D车机大屏效果,先安装 Three.js

pnpm install three

我这里使用的版本是 v0.150.1

初始化场景

通过 THREE.Scene() 对象生成简单的背景,以及环境光部分。

注意:环境光如果没有特殊的需求用默认的也没问题。

scene = new THREE.Scene()
scene.background = new THREE.Color(0xE8E8E8)

// 添加环境光
// const ambientLight = new THREE.AmbientLight(0xffffff, 0.8)
// scene.add(ambientLight)

初始化部分直接创建渲染器:

renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(rendererCanvas.value.clientWidth, rendererCanvas.value.clientHeight)
renderer.setPixelRatio(window.devicePixelRatio)
renderer.shadowMap.enabled = false // 避免proxy错误
rendererCanvas.value.appendChild(renderer.domElement)

创建道路

通过 THREE.PlaneGeometry 对象创建道路,这里我是直接写死的,因为不牵扯屏幕适配问题。

 const roadGeometry = new THREE.PlaneGeometry(24, 200)  // 参数分别是 width / height
const roadMaterial = new THREE.MeshBasicMaterial({ color: 0x555555 })
road = new THREE.Mesh(roadGeometry, roadMaterial)
road.rotation.x = -Math.PI / 2
scene.add(road)

增加车道标线,这里我不是分开创建的,而是统一创建完以后再划分。

const lineGeometry = new THREE.PlaneGeometry(0.15, 2)
const lanePositions = [-8, -4, 0, 4, 8] // 5条车道分隔线,形成6个车道
lanePositions.forEach(x => {
    for (let i = -50; i < 50; i += 4) { // 虚线间隔
        const line = new THREE.Mesh(lineGeometry, dashedLineMaterial)
        line.position.set(x, 0.01, i)
        line.rotation.x = -Math.PI / 2
        scene.add(line)
    }
})

创建车辆

这里需要注意,一般来说更建议使用具体的车辆模型进行展示,效果更好。

这里因为我确实是没找到合适的车辆模型,就简单画了一个。

const carGroup = new THREE.Group()
// 车身
const bodyGeometry = new THREE.BoxGeometry(2, 1, 4)
// ... 
carGroup.add(body)
// 车窗
const windowGeometry = new THREE.BoxGeometry(1.8, 0.8, 2)
// ... 
carGroup.add(window)
// 车轮
const wheelGeometry = new THREE.CylinderGeometry(0.3, 0.3, 0.2, 16)
// ... 
carGroup.add(wheel)
// 将车辆添加到画布
scene.add(carGroup)

这里需要注意,主体车辆行驶过程中需要增加跟随。

camera = new THREE.PerspectiveCamera(75, rendererCanvas.value.clientWidth / rendererCanvas.value.clientHeight, 0.1, 1000)
// 第三人称跟随视角
camera.position.set(0, 8, -15)
camera.lookAt(0, 0, 0)

创建障碍物

基本与创建车辆类似,但是是随机在车道上进行创建障碍物。

另外如果是车辆的话增加一个行驶速度。

const obstacle = new THREE.Group()
    
// 主体
const geometry = new THREE.BoxGeometry(type.size.w, type.size.h, type.size.d)
const material = new THREE.MeshBasicMaterial({ color: type.color })
const body = new THREE.Mesh(geometry, material)
obstacle.add(body)

// 随机位置(6车道系统,增加间距)
const lanes = [-10, -6, -2, 2, 6, 10] // 6个车道的中心位置
const lane = lanes[Math.floor(Math.random() * lanes.length)]
obstacle.position.set(lane, 0, Math.random() * 60 + 20) // 增加生成距离

// 添加移动速度
obstacle.userData = {
    speed: Math.random() * 0.02 + 0.01,
    type: type.type
}

障碍物检测 & 寻找安全车道

障碍物的检测和寻找安全车道效果基本上相同,都是在前后距离范围内检测是否存在障碍物,如果存在那么就认定障碍物检测成功。

如果当前车道存在,而其他车道不存在,则认定其他车道为安全车道。

// 检查是否在同一车道且在前方
if (Math.abs(obstacleX - carX) < 1.5 && // 同一车道范围
    obstacleZ > carZ && // 在前方
    obstacleZ - carZ < detectionDistance) { // 在检测距离内
    return true
}

车道变换效果

这里需要注意,变换车道效果0.5s即完成,尤其是提示部分。

如果是实际项目此处应该配合真正的传感器和计算完成时间计算。

const deltaX = targetX - currentX
if (Math.abs(deltaX) > 0.1) {
    car.position.x += Math.sign(deltaX) * laneChangeSpeed
} else {
    // 变道完成
    car.position.x = targetX
    currentLane = targetLane
    
    // 0.5秒后重置状态
    setTimeout(() => {
        if (!isChangingLane) {
            laneChangeStatus.value = '正常行驶'
        }
    }, 500)
}

其他部分就没什么了,主要是运行过程中需要循环进行变道、道路标线的更新、障碍物的更新。

当然,我推荐你在卸载页面的时候释放掉资源。不释放也没啥太大影响倒是。

全部代码

<template>
    <div class="car-three" ref="rendererCanvas">
        <div class="controls">
            <div class="speed-info">速度: {{ speed.toFixed(1) }} km/h</div>
            <div class="obstacle-count">障碍物数量: {{ obstacleCount }}</div>
            <div class="lane-info">当前车道: {{ currentLaneDisplay }}</div>
            <div class="lane-change-status" :class="{ active: isChangingLaneDisplay }">
                {{ laneChangeStatus }}
            </div>
        </div>
    </div>
</template>

<script setup>
import * as THREE from 'three';
import { ref, onMounted, onBeforeUnmount } from 'vue'

const rendererCanvas = ref(null)
const speed = ref(60) // 车速 km/h
const obstacleCount = ref(0)
const currentLaneDisplay = ref(3) // 显示用的车道号(1-6)
const isChangingLaneDisplay = ref(false)
const laneChangeStatus = ref('正常行驶')

let scene, camera, renderer
let car, road, obstacles = []
let clock = new THREE.Clock()

// 车辆控制
let carSpeed = 0.05
let roadOffset = 0
let currentLane = 2 // 当前车道索引(0-5对应6个车道)
let targetLane = 2 // 目标车道索引
let isChangingLane = false // 是否正在变道
let laneChangeSpeed = 0.02 // 变道速度

onMounted(() => {
    initScene()
    createRoad()
    createCar()
    createObstacles()
    setupCamera()
    animate()
})

const initScene = () => {
    // 初始化场景
    scene = new THREE.Scene()
    scene.background = new THREE.Color(0xE8E8E8) // 灰白色背景
    
    // 创建渲染器
    renderer = new THREE.WebGLRenderer({ antialias: true })
    renderer.setSize(rendererCanvas.value.clientWidth, rendererCanvas.value.clientHeight)
    renderer.setPixelRatio(window.devicePixelRatio)
    renderer.shadowMap.enabled = false // 避免proxy错误
    rendererCanvas.value.appendChild(renderer.domElement)
    
    // 添加环境光
    // const ambientLight = new THREE.AmbientLight(0xffffff, 0.8)
    // scene.add(ambientLight)
}

const createRoad = () => {
    // 创建道路(6车道)
    const roadGeometry = new THREE.PlaneGeometry(24, 200)
    const roadMaterial = new THREE.MeshBasicMaterial({ color: 0x555555 })
    road = new THREE.Mesh(roadGeometry, roadMaterial)
    road.rotation.x = -Math.PI / 2
    scene.add(road)
    
    // 创建车道标线
    const lineGeometry = new THREE.PlaneGeometry(0.15, 2)
    const lineMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff })
    const dashedLineMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff })
    
    // 车道分隔线(虚线)- 创建多个短线段
    const lanePositions = [-8, -4, 0, 4, 8] // 5条车道分隔线,形成6个车道
    
    lanePositions.forEach(x => {
        for (let i = -50; i < 50; i += 4) { // 虚线间隔
            const line = new THREE.Mesh(lineGeometry, dashedLineMaterial)
            line.position.set(x, 0.01, i)
            line.rotation.x = -Math.PI / 2
            scene.add(line)
        }
    })
    
    // 道路边界线(实线)
    const borderGeometry = new THREE.PlaneGeometry(0.2, 200)
    const leftBorder = new THREE.Mesh(borderGeometry, lineMaterial)
    leftBorder.position.set(-12, 0.01, 0)
    leftBorder.rotation.x = -Math.PI / 2
    scene.add(leftBorder)
    
    const rightBorder = new THREE.Mesh(borderGeometry, lineMaterial)
    rightBorder.position.set(12, 0.01, 0)
    rightBorder.rotation.x = -Math.PI / 2
    scene.add(rightBorder)
}

const createCar = () => {
    // 创建主车(简化的车辆模型)
    const carGroup = new THREE.Group()
    
    // 车身
    const bodyGeometry = new THREE.BoxGeometry(2, 1, 4)
    const bodyMaterial = new THREE.MeshBasicMaterial({ color: 0x87CEEB }) // 浅蓝色主车
    const body = new THREE.Mesh(bodyGeometry, bodyMaterial)
    body.position.y = 0.5
    carGroup.add(body)
    
    // 车窗
    const windowGeometry = new THREE.BoxGeometry(1.8, 0.8, 2)
    const windowMaterial = new THREE.MeshBasicMaterial({ color: 0xE8E8E8, transparent: true, opacity: 0.5 })
    const window = new THREE.Mesh(windowGeometry, windowMaterial)
    window.position.y = 1.2
    carGroup.add(window)
    
    // 车轮
    const wheelGeometry = new THREE.CylinderGeometry(0.3, 0.3, 0.2, 16)
    const wheelMaterial = new THREE.MeshBasicMaterial({ color: 0x222222 })
    
    const wheels = [
        { x: -0.8, z: 1.2 },
        { x: 0.8, z: 1.2 },
        { x: -0.8, z: -1.2 },
        { x: 0.8, z: -1.2 }
    ]
    
    wheels.forEach(pos => {
        const wheel = new THREE.Mesh(wheelGeometry, wheelMaterial)
        wheel.position.set(pos.x, 0.3, pos.z)
        wheel.rotation.z = Math.PI / 2
        carGroup.add(wheel)
    })
    
    car = carGroup
    car.position.set(-2, 0, -5) // 主车位于右侧车道
    scene.add(car)
}

// 检测前方障碍物
const detectFrontObstacle = () => {
    const carX = car.position.x
    const carZ = car.position.z
    const detectionDistance = 15 // 检测距离
    const laneWidth = 4 // 车道宽度
    
    // 检查前方是否有障碍物
    for (let obstacle of obstacles) {
        const obstacleX = obstacle.position.x
        const obstacleZ = obstacle.position.z
        
        // 检查是否在同一车道且在前方
        if (Math.abs(obstacleX - carX) < 1.5 && // 同一车道范围
            obstacleZ > carZ && // 在前方
            obstacleZ - carZ < detectionDistance) { // 在检测距离内
            return true
        }
    }
    return false
}

// 寻找安全车道
const findSafeLane = () => {
    const lanes = [-10, -6, -2, 2, 6, 10] // 6个车道位置
    const carZ = car.position.z
    const checkDistance = 20 // 安全检查距离
    
    for (let i = 0; i < lanes.length; i++) {
        if (i === currentLane) continue // 跳过当前车道
        
        let isSafe = true
        const laneX = lanes[i]
        
        // 检查这个车道是否安全
        for (let obstacle of obstacles) {
            const obstacleX = obstacle.position.x
            const obstacleZ = obstacle.position.z
            
            // 检查前后一定距离内是否有障碍物
            if (Math.abs(obstacleX - laneX) < 1.5 && // 在目标车道
                Math.abs(obstacleZ - carZ) < checkDistance) { // 在安全检查范围内
                isSafe = false
                break
            }
        }
        
        if (isSafe) {
            return i // 返回安全车道索引
        }
    }
    
    return currentLane // 如果没有安全车道,保持当前车道
}

// 执行车道变换
const performLaneChange = () => {
    if (isChangingLane) {
        const lanes = [-10, -6, -2, 2, 6, 10]
        const targetX = lanes[targetLane]
        const currentX = car.position.x
        
        // 平滑移动到目标车道
        const deltaX = targetX - currentX
        if (Math.abs(deltaX) > 0.1) {
            car.position.x += Math.sign(deltaX) * laneChangeSpeed
        } else {
            // 变道完成
            car.position.x = targetX
            currentLane = targetLane
            isChangingLane = false
            laneChangeStatus.value = '变道完成'
            isChangingLaneDisplay.value = false
            
            // 0.5秒后重置状态
            setTimeout(() => {
                if (!isChangingLane) {
                    laneChangeStatus.value = '正常行驶'
                }
            }, 500)
        }
    }
    
    // 更新显示的车道号
    currentLaneDisplay.value = currentLane + 1 // 车道号从1开始
}

const createObstacles = () => {
    // 创建随机障碍物(统一灰白色)
    const obstacleTypes = [
        { type: 'car', color: 0xD3D3D3, size: { w: 2, h: 1, d: 4 } },
        { type: 'car', color: 0xC0C0C0, size: { w: 2, h: 1, d: 4 } },
        { type: 'truck', color: 0xB8B8B8, size: { w: 2.5, h: 2, d: 6 } },
        { type: 'pedestrian', color: 0xA8A8A8, size: { w: 0.5, h: 1.7, d: 0.5 } }
    ]
    
    // 创建初始障碍物(减少数量)
    for (let i = 0; i < 4; i++) {
        createRandomObstacle(obstacleTypes)
    }
    
    obstacleCount.value = obstacles.length
}

const createRandomObstacle = (types) => {
    const type = types[Math.floor(Math.random() * types.length)]
    const obstacle = new THREE.Group()
    
    // 主体
    const geometry = new THREE.BoxGeometry(type.size.w, type.size.h, type.size.d)
    const material = new THREE.MeshBasicMaterial({ color: type.color })
    const body = new THREE.Mesh(geometry, material)
    body.position.y = type.size.h / 2
    obstacle.add(body)
    
    // 如果是车辆,添加车轮
    if (type.type === 'car' || type.type === 'truck') {
        const wheelGeometry = new THREE.CylinderGeometry(0.25, 0.25, 0.15, 12)
        const wheelMaterial = new THREE.MeshBasicMaterial({ color: 0x222222 })
        
        const wheelPositions = [
            { x: -type.size.w * 0.3, z: type.size.d * 0.3 },
            { x: type.size.w * 0.3, z: type.size.d * 0.3 },
            { x: -type.size.w * 0.3, z: -type.size.d * 0.3 },
            { x: type.size.w * 0.3, z: -type.size.d * 0.3 }
        ]
        
        wheelPositions.forEach(pos => {
            const wheel = new THREE.Mesh(wheelGeometry, wheelMaterial)
            wheel.position.set(pos.x, 0.25, pos.z)
            wheel.rotation.z = Math.PI / 2
            obstacle.add(wheel)
        })
    }
    
    // 随机位置(6车道系统,增加间距)
    const lanes = [-10, -6, -2, 2, 6, 10] // 6个车道的中心位置
    const lane = lanes[Math.floor(Math.random() * lanes.length)]
    obstacle.position.set(lane, 0, Math.random() * 60 + 20) // 增加生成距离
    
    // 添加移动速度
    obstacle.userData = {
        speed: Math.random() * 0.02 + 0.01,
        type: type.type
    }
    
    obstacles.push(obstacle)
    scene.add(obstacle)
}

const setupCamera = () => {
    camera = new THREE.PerspectiveCamera(75, rendererCanvas.value.clientWidth / rendererCanvas.value.clientHeight, 0.1, 1000)
    // 第三人称跟随视角
    camera.position.set(0, 8, -15)
    camera.lookAt(0, 0, 0)
}

const animate = () => {
    requestAnimationFrame(animate)
    
    const deltaTime = clock.getDelta()
    
    // 检测前方障碍物并执行变道
    if (!isChangingLane && detectFrontObstacle()) {
        const safeLane = findSafeLane()
        if (safeLane !== currentLane) {
            targetLane = safeLane
            isChangingLane = true
            laneChangeStatus.value = '检测到障碍物,正在变道...'
            isChangingLaneDisplay.value = true
        } else {
            laneChangeStatus.value = '前方有障碍物,无安全车道'
        }
    } else if (!isChangingLane) {
        laneChangeStatus.value = '正常行驶'
        isChangingLaneDisplay.value = false
    }
    
    // 执行变道动作
    performLaneChange()
    
    // 更新车辆位置(模拟前进)
    roadOffset += carSpeed
    
    // 更新道路标线位置
    scene.children.forEach(child => {
        if (child.material && child.material.color.getHex() === 0xffffff && child.position.y === 0.01) {
            child.position.z -= carSpeed
            if (child.position.z < -50) {
                child.position.z += 100
            }
        }
    })
    
    // 更新障碍物位置
    obstacles.forEach((obstacle, index) => {
        obstacle.position.z -= carSpeed + obstacle.userData.speed
        
        // 如果障碍物移出视野,重新生成
        if (obstacle.position.z < -30) {
            scene.remove(obstacle)
            obstacles.splice(index, 1)
            
            // 创建新的障碍物(统一灰白色)
            const obstacleTypes = [
                { type: 'car', color: 0xD3D3D3, size: { w: 2, h: 1, d: 4 } },
                { type: 'car', color: 0xC0C0C0, size: { w: 2, h: 1, d: 4 } },
                { type: 'truck', color: 0xB8B8B8, size: { w: 2.5, h: 2, d: 6 } },
                { type: 'pedestrian', color: 0xA8A8A8, size: { w: 0.5, h: 1.7, d: 0.5 } }
            ]
            createRandomObstacle(obstacleTypes)
        }
    })
    
    // 更新速度显示
    speed.value = 60 + Math.sin(Date.now() * 0.001) * 10 // 模拟速度变化
    obstacleCount.value = obstacles.length
    
    // 摄像机跟随(考虑变道)
    camera.position.x = car.position.x
    camera.position.z = car.position.z - 15
    camera.position.y = 8
    camera.lookAt(car.position.x, car.position.y + 2, car.position.z + 5)
    
    renderer.render(scene, camera)
}

onBeforeUnmount(() => {
    // 清理资源
    obstacles.forEach(obstacle => {
        scene.remove(obstacle)
    })
    
    if (car) scene.remove(car)
    if (road) scene.remove(road)
    
    // 清理几何体和材质
    scene.traverse((child) => {
        if (child.geometry) {
            child.geometry.dispose()
        }
        if (child.material) {
            if (Array.isArray(child.material)) {
                child.material.forEach(material => material.dispose())
            } else {
                child.material.dispose()
            }
        }
    })
    
    renderer.dispose()
    if (rendererCanvas.value && renderer.domElement) {
        rendererCanvas.value.removeChild(renderer.domElement)
    }
})
</script>

<style scoped>
.car-three {
    width: 100%;
    height: 100%;
}

.controls {
    position: absolute;
    top: 20px;
    left: 20px;
    background: rgba(0, 0, 0, 0.7);
    color: white;
    padding: 15px;
    border-radius: 8px;
    font-family: Arial, sans-serif;
    z-index: 100;
}

.speed-info, .obstacle-count {
    margin: 5px 0;
    font-size: 14px;
    font-weight: bold;
}

.speed-info {
    color: #00ff00;
}

.obstacle-count {
    color: #ffaa00;
}

.lane-info {
    color: #00aaff;
}

.lane-change-status {
    color: #ffff00;
    font-weight: bold;
}

.lane-change-status.active {
    color: #ff6600;
    animation: blink 1s infinite;
}

@keyframes blink {
    0%, 50% { opacity: 1; }
    51%, 100% { opacity: 0.5; }
}
</style>

全栈进阶-redis入门实战概念篇

2026年2月5日 20:00

第一阶段:redis基础

1. 简介

Redis是一款开源的、基于内存的键值对数据库,支持将内存持久化到磁盘,还提供了丰富的数据结构、事务、发布订阅等功能,被广泛的用于缓存、消息队列、会话存储等场景。

作为一个前端开发,对于Redis第一影响就是读写操作非常的快,常用于一些需要快速读写数据的场景,比如存储会话sessionRedis之所以这么快,在于Redis利用了内存操作、IO多路复用、避免线程切换开销三大核心优势,让单线程也足以支撑超高并发。

Redis并非纯单线程,只是在接受客户请求的核心处理流程是单线程的,在处理慢操作,比如持久化读写磁盘、异步删除大键、主从复制的网络同步,会启动多个辅助线程。为啥当初Redis不是设计成多线程呢,主要是单线程设计简单,核心逻辑都是串行执行的,后续的维护成本极低,同时也避免了多线程的死锁啊,数据一致性啊这些麻烦的问题;内存的操作足够快,多线程必然会涉及到线程切换和锁竞争,这些都会降低效率;IO的多路复用,Redis运行在网络层,使用的基于Unix系统的IO多路复用机制,就是主线程通过事件循环来监听所有客户端的IO操作,维护一个事件队列去处理IO操作,这种单线程非阻塞的IO多路复用让Redis可以同时管理上万的TCP连接。

2.redis数据基本结构

Redis基本数据结构主要五个,接下来挨个介绍下

首先安装下环境:

pip install redis

先创建一个虚拟环境,然后安装相应的依赖

import redis

# 连接到你的线上 Redis
r = redis.Redis.from_url(
    "redis://:xxx",
    decode_responses=True,  # 返回 str 而不是 bytes
)

# 设置一个 study:string 的 key
r.set("study:string", "hello redis from python")

# 读出来验证一下
value = r.get("study:string")
print("study:string =", value)
2.1 String

StringRedis最基础、最常用的数据结构,所有的键值对的value本质上都可以使用String来存储,单key最大容量512M,所有的操作都是原子性的,支持位运算和过期策略。

比如前面的案例r.set("study:string", "hello redis from python"),就是设置一个字符串

如果要设置一个过期时间的话,也比较简单

r.set("study:string", "hello redis from python", ex=60),这里的ex就是秒数,如果是px就是毫秒数,这里设置的时间就表明key的过期时间,如果过期了key就会被删除。

2.1 Hash

Hash是一个键对应多个键值对的结构,类似于Map和字典,一般用来存储结构化的对象。

r.hset("study:hash", mapping={
    "name": "张三",
    "age": "20",
})

data = r.hgetall("study:hash")
print(data)  # {'name': '张三', 'age': '20'}

如果要删除hash中的指定字段的话,可以使用这个方法hdel

r.hdel("study:hash", "age")
2.3 List

这里的List是按照插入顺序排序的字符串集合,支持两端搞笑增删,中间查询稍慢,是一个双向链表。

# 从右侧依次塞入几个元素
r.rpush("study:list", "apple", "banana", "orange")

# 从左侧再塞一个
r.lpush("study:list", "pear")   # list 现在是: ["pear", "apple", "banana", "orange"]

# 读取整个 list(0 到 -1 表示所有元素)
items = r.lrange("study:list", 0, -1)
print("study:list =", items)

# 弹出一个元素(比如从左边弹)
left = r.lpop("study:list")
print("lpop 之后取出的 =", left)
print("剩下的 =", r.lrange("study:list", 0, -1))

可以用来存储一些任务队列和消息队列

2.4 Set

Set 是无序、元素唯一的字符串集合,支持集合间的交、并、差运算,适合处理 “去重” 和 “关系匹配” 场景。

# 往 set 里加元素(去重)
r.sadd("study:set", "apple", "banana", "orange")
r.sadd("study:set", "banana")  # 再加一次不会重复

# 查看所有成员
members = r.smembers("study:set")
print("study:set =", members)

# 判断某个值是否在 set 中
print("是否包含 apple:", r.sismember("study:set", "apple"))

# 删除一个成员
r.srem("study:set", "banana")
print("删除 banana 后 =", r.smembers("study:set"))

# 给整个 set 设置过期时间 60 秒
r.expire("study:set", 60)
2.5 ZSet

ZSet 是 Set 的升级版,每个元素关联一个 “分数(score)”,Redis 会按分数从小到大排序,兼具 唯一性 和 有序性。

# 往 zset 里加数据:成员 + 分数
r.zadd("study:zset", {
    "Alice": 100,
    "Bob": 80,
    "Charlie": 95,
})

# 按分数从小到大取出所有成员
print("从小到大:", r.zrange("study:zset", 0, -1, withscores=True))

# 按分数从大到小取出前 2 名
print("从大到小前2名:", r.zrevrange("study:zset", 0, 1, withscores=True))

# 给某个人加分(比如 Alice +10)
r.zincrby("study:zset", 10, "Alice")
print("Alice 加分后:", r.zrevrange("study:zset", 0, -1, withscores=True))

# 删除一个成员
r.zrem("study:zset", "Bob")
print("删除 Bob 后:", r.zrange("study:zset", 0, -1, withscores=True))

有一个常见的面试题,HashString都可以用来存储对象,一般用那个来存储对象呢,使用String来存储对象,简单直观,但是它不支持局部更新,改一个字段需要覆盖这个字符串,适合一些整体读写、字段少的场景;Hash存储对象,他就支持局部更新,适合一些复杂对象的存储,比如高频更新字段。

3. redis基本命令

因为Redis都是键值对的存储,所以他的方法也很简单,看下面这个例子:

# 1. SET:设置一个字符串 key
r.set("study:string", "hello")

# 2. GET:读取这个 key
print("GET study:string =", r.get("study:string"))  # hello

# 3. INCR:自增一个数值型 key
# 如果这个 key 不存在,会从 0 开始加 1,变成 "1"
r.delete("study:count")  # 为了方便测试,先删掉
r.incr("study:count")    # 当前值 1
r.incr("study:count")    # 当前值 2
r.incr("study:count", 5) # 加 5 -> 当前值 7
print("study:count =", r.get("study:count"))  # 7

# 4. EXPIRE:给 key 设置过期时间(单位:秒)
r.expire("study:count", 5)  # 5 秒后过期

读、写、自增和设置过期时间,都比较简单。

因为Redis都是键值对,没有表的概念,所以Key管理就成了问题,社区有一个约定的规范:业务标识:模块名称:唯一标识[:子字段],比如ecom:user:1001:name,这就是电商业务:用户模块:用户id:用户名。还有一些额外的补充规范:

  1. 统一小写:避免大小写混乱,User:1user:1是两个key
  2. 简洁且语义化:看到名称基本就能了解存储的内容
  3. 避免特殊字符:比如空格、换行符、下划线

第二阶段:redis核心机制

4.redis内存模型

Redis是一个内存数据库,大多数数据都保存才内存中。它的内存可以分为两大部分,核心内存和辅助内存。其中核心内存存储的就是我们常用的键值对,也就是key内存和value内存,存储的都是我们所用到的数据;辅助内存放的都是非业务数据,就是Redis运行所需的额外内存,比如一些过期字典、进程本身的开销等。

Redis有一套完善的内存管理机制,主要有这么几步

  1. 基于jemalloc内存分配,将内存划分为不同大小的内存页,比如8B,16B,32B等,分配时匹配最接近的页,减少碎片;线程缓存,减少锁竞争,提升分配效率
  2. 内存回收:内存回收主要有两种,惰性删除和定期删除。惰性删除指的是访问key时检查是否过期,过期了就删除;定期删除,每100ms随机抽查过期的key,去删除已经过期的,但是这里有个问题,如果key过期了,但是没有被抽查到呢,为啥不扫描全量的key呢,这就是一个平衡了,全量扫描需要占用大量的CPU,会影响到业务的,这个就叫做延迟回收,也就是说可能一时半会回收不了,但是终归会被回收。

Redis的内存处理机制天然就有一种滞后性,可能就会出现内存满了的情况,这里的内存满了,并不是设置某个key的value大小超过512M,而是Redis进程占用的内存满了,这里的满有两个意思:主动设置的maxmemory,这个在生产环境上是必须要设置的

maxmemory 4GB  # 限制 Redis 最大使用内存为 4GB

还有一种满就是,如果不设置这个最大值,Redis就会无限制的占用服务器的物理内存,直到耗尽服务器所有可用的物理内存,这个时候操作系统会将Redis的内存数据交换到磁盘的swap分区,这个是磁盘模拟的内存,速度巨慢,最终导致Redis性能暴跌,也可能因为服务器内存耗尽而被系统杀死。

当内存满了后,Redis也有一套内存淘汰策略来处理这种情况,当Redis占用的内存超过设置的maxmemory后,然后再去执行写操作,就会去触发我们的内存淘汰策略,主要有这么几种策略:

  1. LRU

    最近最少使用,就是淘汰那些最近访问次数最少的key,标准的LRU需要维护一个访问时间链表,内存和cpu开销大,Redis实现的是近似LRU:维护一个候选池,触发淘汰时,从目标范围随机抽取key,也就是触发淘汰时,随机抽取一批key,然后比一比谁的访问时间最远,然后就淘汰它。

  2. LFU

    优先淘汰访问频率最低的key,Redis实现的LFU并不是简单的访问次数统计,而是通过概率递增的访问计数+时间衰减机制来近似的反应key的长期访问价值;在触发淘汰时,在通过随机采样的方式选择访问评率最低的key去淘汰。

当内存满了后,再去对数据库做读写操作,读的操作没有影响,但是在触发写的操作时,如果内存满了,会先根据maxmemory-policy设置的内存淘汰策略,在写操作触发的同时根据LFU、LRU去更新内存,直到内存会到安全区;如果内存淘汰策略味为noeviction或者无法淘汰,直接回报错。

5.持久化机制

前面也提到了,Redis是一种基于内存的键值数据库,内存的特性就是在服务器重启后会全部丢失,这就需要将数据做持久化,即使服务器重启了,也可以从磁盘中恢复数据至内存。

Redis提供了两种核心的持久化方式:RDB和AOF,快照持久化和追加文件持久化,接下来挨个介绍下:

快照持久化

RDB是定时对Redis内存中的全量数据做一次拍照,生成一个压缩的二进制文件,比如dump.rdb,保存到磁盘的指定目录。Redis重启时直接加载这个二进制文件,将数据恢复到内存中。

RDB有手动触发和自动触发两种方式:手动触发可以使用save来同步触发,同步触发会阻塞主进程,直到RDB文件生成完成,异步触发通过bgsave来触发,Redis会fork一个子进程来执行RDB文件的生成,主进程会继续处理客户端的请求;自动触发是在配置文件redis.conf中通过快照规则来配置的,满足条件就会自动执行bgsave

save 900 1      # 900 秒内至少 1 次写
save 300 10     # 300 秒内至少 10 次写
save 60 10000   # 60 秒内至少 10000 次写

这里就是自动触发RDB的规则:满足其中的任意条件就会触发一次,比如60s内写一次、300s内写10次等。

RDB优点就在于性能开销小,生成RDB由子进程负责,主进程仅做fork操作,几乎不影响业务;二进制文件直接加载到内存速度也很快。但是缺点也很明显,RDB是定时快照,如果Redis意外崩溃,比如服务器断电,就会丢掉最后一次快照前到崩溃前的所有数据。

追加日志持久化

AOF就是为了解决RDB数据库丢失而设计的持久化方式,就是将Redis的操作日志按照顺序记录下来,重启后通过重放AOF文件中所有的写命令去恢复内存数据。默认是关闭的,需要appendonly yes命令来手动重启。

AOF的相关配置在redis.conf文件中

appendonly yes # 开启AOF(默认no,关闭)
appendfilename "appendonly.aof" # AOF文件名,默认保存在Redis工作目录
dir ./ # 持久化文件(RDB/AOF)的保存目录,默认是Redis启动目录

AOF主要分为三个步骤:

  1. 命令追加

    Redis执行完一个写命令后,会将该命令按照协议追加到内存中的AOF缓冲区,避免直接写入磁盘,减少IO开销

  2. 文件写入

    Redis会定期将AOF缓冲区的数据写入到内核页缓存,这个操作是调用操作系统的write方法,属于异步操作,不会阻塞主线程。

  3. 文件同步

    将内核页缓存中的AOF数据写到测盘中,这个是调用的操作系统的同步方法,会阻塞主线程的,直到刷盘完成。

    将AOF缓冲区中的命令刷到磁盘的AOF文件中,有三种策略:

    # appendfsync 有三个取值:
    appendfsync always  # 每次写命令都立即刷盘(同步),数据最安全,性能最差
    appendfsync everysec# 每秒刷盘一次(默认值),平衡数据安全和性能
    appendfsync no      # 由操作系统决定何时刷盘,性能最好,数据丢失风险最高
    

由于AOF是日志追加的形式,会产生大量的中间态,比如set key 1set key 2set key 3 ,这种中间态其实是没有意义的,还会导致AOF文件变得很大,这就需要AOF重写机制了,重写就是遍历内存中的所有的数据,根据当前的键值对生成一套最简的写命令集来替换原有的AOF文件,重写的触发也分为手动触发和自动触发:手动触发需要执行bgrewriteaof命令;自动触发是通过配置文件,当文件的体积增长到达阙值时,自动触发`bgrewriteaof

auto-aof-rewrite-min-size 64mb  # AOF文件的最小体积,低于这个值不触发重写(默认64mb)
auto-aof-rewrite-percentage 100 # 重写触发的百分比,指当前AOF文件体积比上一次重写后的体积增长了多少(默认100%)

AOF的优点就是,可以通过刷盘策略来控制数据丢失的风险,默认的everysec仅丢失1s的数据,alway几乎无丢失;缺点就是AOF文件体积较大,恢复数据时加载较慢。

混合持久化

RDB和AOF单独使用都各有优缺点,在Redis 4.0之后,引入了混合持久化机制,融合恶RDB和AOF的优点,成为了目前生产环境的首选方案。

redis.conf配置文件中开启混合持久化:

aof-use-rdb-preamble yes  # 开启混合持久化(Redis 4.0+,默认no;Redis 6.0+ 部分版本默认yes)

开启后,AOF文件就不再是纯文本了,头部就成了RDB格式的全量数据快照,也就是二进制文件,尾部是AOF格式写的增量命令,记录从生成RDB快照到当前的所有写命令,是纯文本。

其工作流程主要有这么几个步骤:

当AOF触发重写时,

  1. redis主进程进入fork子进程,执行AOF重写
  2. 子进程首先将内存中的全量数据以RDB格式写入到临时的AOF文件头部
  3. 子进程完成RDB写入后,主进程将AOF重写缓冲区中所有的增量写命令,以AOF格式写入到临时的AOF文件尾部
  4. 主进程用临时AOF文件替换掉旧的AOF文件,完成混合持久化的重写。

混合持久化的优点就在于加载速度快,数据丢失风险小,而且文件的体积也不会很大。

下面推荐一个常见的生产环境的配置,开启混合持久化+RBD默认自动快照:

# ===================== RDB 核心配置 =====================
save 900 1
save 300 10
save 60 10000
rdbcompression yes  # 开启RDB压缩
dbfilename dump.rdb # RDB文件名
dir ./              # 持久化文件存储目录(建议修改为独立的磁盘目录)

# ===================== AOF 核心配置 =====================
appendonly yes      # 开启AOF(混合持久化的前提)
appendfilename "appendonly.aof" # AOF文件名
appendfsync everysec # 刷盘策略,生产首选
auto-aof-rewrite-min-size 64mb # AOF重写最小体积
auto-aof-rewrite-percentage 100 # AOF重写增长百分比
aof-use-rdb-preamble yes # 开启混合持久化(Redis 4.0+)
aof-load-truncated yes # 加载AOF时,若尾部损坏则忽略,继续加载(默认yes)

6. redis事务

Redis事务就是提供一种机制,将多个Redis命令打包成一个执行单元,保证这个单元内的命令会按照顺序、无中断的执行,同时支持对命令执行结果的统一处理,解决多命令批量执行的原子性需求。

Redis事务只依赖五个命令:

  1. MULTI 标记事务开始,后续所有的命令都会加入到事务队列中
  2. EXEC 执行事务队列中的所有命令,执行完成后结束事务,返回所有命令的执行结果
  3. DISCARD 放弃事务队列中的所有命令,清空队列结束事务,回到正常的执行模式
  4. WATCH KEY 对key加乐观锁,监控key是否修改,必须在MULTI之前修改
  5. UNWATCH 取消所有被watch监控的key,事务取消或者执行后会自动执行

看下这个最基础的实务流程:

MULTI
SET balance 100
INCR balance
EXEC

执行到MULTI时,会进入事务状态,后续的SETINCR会被放入到一个事务队列中,直行到EXEC时才会执行队列中的所有的命令。

传统的关系型数据库事务严格遵循ACID原则,原子性、一致性、隔离性和持久性,但是Redis事务为了极致的性能,并不是完全遵循ACID原则。接下来介绍下他的区别:

  1. 原子性

    原子性的定义就是事务中的所有的操作,要么全部执行,要么全部不执行,不会出现部分执行的情况,而Redis事务的原子性分为两种情况:

    1) 事务入队前出错,全不执行:当在MULTI后,EXEC前出现语法错误,Redis会立即返回错误,执行EXEC时会直接放弃整个事务

    2) 执行事务中出现错误,部分执行,没有回滚,命令入队时只会做语法检查,不会做逻辑检查,执行时如果出现了运行错误,Redis就会跳过这个命令,继续执行后续的命令,而且不会对已经执行的命令做回滚

    不支持回滚主要也是从性能考量,实现回滚需要记录每个命令的逆操作,比如SET的操作就是恢复原值,这个会增加Redis内核的复杂度,牺牲执行的性能。

  2. 一致性

    一致性就是事务执行的前后,数据库的状态始终保持合法,不会因为事务的执行而出现脏数据。Redis事务可以在所有的异常情况下,比如入队错误、执行错误、宕机,都可以保证数据的一致性。

  3. 隔离性

    隔离就是在多个事务并发执行时,一个事务的执行不会被其他的事务干扰,各个事务之间相互隔离。Redis是单线程处理客户端请求的,这就会导致事务的执行会按照队列中的顺序连续执行,不会被其他的命令打断

  4. 持久性

    持久性是指事务执行成功后,对数据的修改会被永久的保存到磁盘中,不会应为宕机而丢失。Redis事务本身并不保证持久性,持久性是由Redis的持久化机制来实现的,前面也介绍过

接下来写一个小的demo,利用watch来控制库存防止超卖

def try_purchase(stock_key: str, user: str, qty: int = 1) -> bool:
    """使用 WATCH + 事务进行扣库存,避免超卖。

    乐观锁思路:
    1. WATCH 库存 key,监听是否被别人改动;
    2. 读当前库存,判断是否足够;
    3. 使用 MULTI 开启事务,扣减库存;
    4. EXEC 提交,如果在这期间库存被别人改了,EXEC 会失败(抛 WatchError),然后重试。
    """

    with r.pipeline() as pipe:
        while True:
            try:
                # 1. 监听库存 key
                pipe.watch(stock_key)

                # 2. 读取当前库存
                current = pipe.get(stock_key)
                if current is None:
                    print(f"{user}: 商品不存在")
                    pipe.unwatch()
                    return False

                current = int(current)
                if current < qty:
                    print(f"{user}: 库存不足,当前库存={current}")
                    pipe.unwatch()
                    return False

                # 3. 开启事务,扣减库存
                pipe.multi()
                pipe.decrby(stock_key, qty)

                # 4. 提交事务
                pipe.execute()
                print(f"{user}: 抢购成功,扣减 {qty},扣减前库存={current}")
                return True

            except redis.WatchError:
                # 在 WATCH 之后、EXEC 之前,有其他客户端修改了 stock_key,
                # 这次事务会失败,需要重试。
                print(f"{user}: 检测到并发冲突,重试中...")
                continue

第三阶段:高并发&分布式

7. 缓存模式与一致性

Redis作为缓存的核心亮点就在于其高速的读写操作,来降低传统数据库的压力,基于此Redis推出了有大概四种主流的缓存策略,来将缓存融入业务读写流程,同时保证缓存与数据库的一致性。接下来挨个介绍下:

  1. 缓存穿透模式

    缓存和数据库分离,业务代码主动管理缓存和数据库的交互。在读操作时,先查询缓存,如果命中就直接返回,如果没有就查询数据库,同时将数据库的结果写入缓存;在写操作时,先更新数据库,在删除缓存。

    这种模式适合绝大多数的生产场景,是Redis作为缓存的首选模式。优点就是简单易实现,缺点就是需要额外处理缓存穿透、击穿雪崩等场景。

  2. 读写穿透

    业务代码只和缓存交互,不直接操作数据库,缓存作为中间层,主动管理数据库的读写。在读操作时,先查询缓存,如果命中就直接返回,未命中就查询数据库,将结果写入缓存,然后返回;写操作就更新缓存,然后再去更新数据库。

    这种模式的特点就是业务代码只专注于业务,数据库由缓存层来处理,简化了业务代码逻辑,缺点就是缓存层需要额外的代码开发,而且不支持新增数据,因为新增数据要先执行读操作,才能存入缓存,不太符合常规的业务逻辑。

  3. 写穿模式

    这种模式是读写穿模式的增强版,支持新增、更新数据。在读操作时,和读写穿透模式一样,命中返回,未命中查库更新缓存;写操作时和新增数据时,缓存同步更新数据库,然后返回给业务。

    这种模式的特点在于写操作时,缓存和数据库同步更新,缓存和数据库有着非常强的一致性,常用于支付业务的核心数据缓存。

  4. 写回模式

    这种模式是写穿模式的异步版,差异就在与写操作时是异步的。在读操作时,和读写穿透、写穿模式一样,命中就返回,未命中查库更新缓存后返回;在新增和更新的写操作时,缓存立即返回,然后异步去更新数据。

    这种模式适合并发高、一致性要求较低的场景,比如日志缓存等。

生产模式中比较常用和推荐的,就是缓存穿透模式,然后这种模式在一致性的问题上需要额外处理。比如有这么几个场景:

  1. 在写操作时,正常的流程是,更新数据库,然后删除缓存,更新缓存需要下次读的时候去查库更新。但是如果更新数据库后,遇到宕机或者网络异常,就会导致缓存未及时删除,这就出现了脏数据,知道缓存过期。这也是最常见的不一致场景,属于操作中断导致的缓存未更新。
  2. 在并发的场景下,客户端A和客户端B同时分别执行读写操作,A读操作时,缓存未命中就会去查库,B写操作时更新完数据库后,回去删除缓存,这时如果读操作的写缓存的动作晚于写操作的删除动作,就会产生数据库与缓存的不一致场景,数据库是新的数据,缓存中是旧的脏数据。

在数据一致性上有这样一个原则,最终数据一致性即可,而非强制一致性。Redis作为缓存,是没有办法实现和数据库的强一致性。因为缓存和数据库属于两个独立的存储系统,非要强一致性就需要加锁,这就会牺牲Redis的高性能,而且在实际业务中,带短暂的不一致,对于用户来说并没有感知的。

在缓存穿透模式中,有这么几个方案可以解决缓存与数据库的一致性问题

  1. 单实例低并发场景,在一些后台管理,小流量业务汇中,可以直接使用缓存穿透模式的基础逻辑,即读操作时先缓存后数据库,如果不存在就写一个缓存空值,加上过期时间;在写操作时,先更新数据库,在删除缓存,给所有的缓存加上过期时间。

    这里设置缓存空值,就是为了防止缓存杀手-穿透,比如查询一个数据库没有的值,这就会直接访问数据库,万一遇到恶意访问的脚本,就会导致数据库压力;而设置一个空值,在过期时间内,他是一个有效的缓存,虽然没有值,但减轻了数据库的压力,算是为了系统的稳定性做了一次兜底。

  2. 单实例高并发,在电商的商品详情、商品秒杀库存业务中,在基础方案上增加延迟删除缓存,来解决读操作写缓存晚于写操作删缓存的问题。 流程就是在写操作执行更新数据库后,延迟N毫秒删除缓存,让读操作查库、写缓存的动作先完成。

还有一个常见的八股文:缓存的三大杀手,穿透、击穿和雪崩。

  1. 穿透,在前面的穿透模式时介绍过了,就是查询一些数据库中不存在的值时,每次都会去查询数据库,导致缓存失效,数据库增加额外的压力。解决方案有下面几种:

    1)设置空值,前面也介绍过,这是最简通用的方案

    2)布隆过滤器,提前将数据库中所有的合法key存入过滤器,请求先过过滤器,判定不存在就会直接拒绝,就走不到缓存和数据库了

    3)IP/接口 限流熔断,对于穿透请求高频的IP限流,对查询接口做熔断保护。这里的限流就是限制单位时间内允许请求的数量,比如单位时间内某个IP大量请求不存在的ID,加了限流之后直接回报错429错误码,就走不到缓存、数据库了;熔断就是当下游服务器持续失败或者过慢时,暂时切断请求,防止雪崩扩散。

  2. 击穿,某个极高的热点key,恰好过期或者被删除,此时大量并发请求同时访问该key,全部缓存未命中,所有的请求都会访问到数据库。这里的解决方案有:互斥锁串行重建缓存,热点key永不过期,热点key主动更新

  3. 雪崩,大量缓存key在同一时间集体过期,或者Redis缓存集体宕机,导致请求绕过缓存直接访问数据库,导致数据库负载瞬间爆表,引发整个服务链路雪崩。解决方案有:过期时间随机化,Redis集群高可用,服务限流、熔断、降级。

8. 分布式锁

在分布式、微服务架构中,一个服务会运行在多台机器上,这就是多进程的概念,多台机器会共享一个资源,这个时候python的线程锁就会失效,因为这些锁的作用范围是当前进程的内存,只能管自己进程内的线程。这时就需要分布式锁了,分布式锁就是跨进程、跨服务的锁,要保证多个进程对共享资源的互斥访问。

分布式锁有四个核心的特性:

  1. 互斥性,同一时间只有一个客户端持有锁,其他的客户端必须等待
  2. 安全性,锁只能被其持有者释放,不能被其他客户端误删
  3. 避免死锁,即使持有锁的客户端崩溃、中断,也可以在一定时间后自动释放
  4. 可用性,Redis集群环境下,锁服务不能单点故障,要保证大部分节点可用

Redis做分布式锁的优点,就在于其性能极高,获取、释放锁都是毫秒级,而且部署也比较简单。

接下来介绍下Redis单节点分布式锁的几个命令:

  1. key 锁的唯一标识,比如要给ID=111的资源加锁
  2. value 客户端的唯一标识,保证锁只能被持有者释放
  3. NX 全程Not Exist,只有当key不存在时才会设置成功
  4. EX/PX 设置的过期时间,EX单位是秒,PX单位是毫秒
  5. timeout 锁的过期时间,避免死锁

比如这行代码:

SET lock:order:123 uuid:192.168.1.100 NX EX 30

就表明给资源key为lock:order:123的资源加锁,锁的持有者是uuid:192.168.1.100,30秒后过期

而释放锁,就回到刚刚的安全性了,必须只有锁的持有客户端才可以释放。流程就是先判断加锁的客户端是不是自己,如果是才可以去释放,看下相关的Iua脚本:

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])  -- 标识匹配,删除锁
else
    return 0  -- 标识不匹配,不做任何操作
end

之前写过一个卖票的函数,就是使用Redis的分布式锁来控制库存,防止超卖:

async def sell_one_with_lock(r: Redis, window_name: str) -> bool:
    """使用 Redis 分布式锁保护“检查+扣减”关键区,成功卖出返回 True,售罄或失败返回 False。"""
    lock = r.lock(LOCK_KEY, timeout=5, blocking_timeout=1)  # 超时时间与获取等待时间可调
    acquired = await lock.acquire(blocking=True)
    if not acquired:
        # 未拿到锁,视为本次卖票失败(可重试)
        return False
    try:
        # 关键区:读取剩余、判定、扣减
        remaining_str = await r.get(TICKET_KEY)
        remaining = int(remaining_str) if remaining_str is not None else 0
        if remaining <= 0:
            return False
        # 扣减一张(原子自减命令)
        await r.decr(TICKET_KEY)
        return True
    finally:
        try:
            await lock.release()
        except Exception:
            # 若锁已过期或其他异常,忽略释放错误
            pass

代码第一行就创建了一个分布式锁,传入了一个过期时间防止死锁,lock.acquire是真正的加锁步骤。之前看到这里有个疑惑:每次调用这个方法,都会创建一个分布式锁,如何保证对一个资源加锁,在创建锁的时候传入了LOCK_KEY,这个就是要加锁的key,也就是要加锁的资源,这个方法每次执行都会创建一个锁,但是lock.acquire在资源没有释放的时候,返回的是false,也就是会走到if not这里的。

其实后续章节还有单点故障,主从、哨兵、 Redlock、 扩容、数据分片等,觉得分布式、缓存还需要消化下,后面就不在深入了,打算进入实战环节了,后续打算设计三个实战项目来进一步深入的学习下。

三个实战项目分别是

  1. 信息查询系统,使用MySQL存储用户信息,Redis作为缓存,巩固下前面学习的缓存模式,同时加上压测环节,通过QPS,响应时间更加直观的了解缓存的意义
  2. 抢票系统,学习下Lua脚本,
  3. 秒杀系统,学习下高并发处理、限流、防超卖策略

re-render

作者 Tiffany
2026年2月5日 18:28

渲染

那么首先,我们要聊的就是 React 的渲染机制,我们首先要弄清楚在讲 React 渲染的时候,我们具体在说的是什么。

当我们调用 ReactDOM.render(<App />)(这里就不专门用 createRoot API 了)的时候,或者当我们调用 setState 的时候,React 会从根节点开始重新计算一次整个组件树:

  1. React 生成新的 Virtual DOM 树。
  2. 并与旧的 Virtual DOM 树做 diff。
  3. 得到最终需要应用的更新。
  4. 然后执行最小程度的 DOM API 操作。

这里面分为两个步骤:

  • render phase,也就是到计算得到最终需要执行的 DOM 更新操作为止的步骤
  • commit phase,把这些更新 apply 到 DOM 树上

而我们要聊的渲染就是专门指的第一个步骤,也就是 render phase,这个阶段是纯粹的 JS 执行过程,不涉及任何的 DOM 操作。在 React 中,一旦 Virtual Dom diff 的结果确定, 进入 commit phase 之后,任务就无法再被打断,而且 commit 的内容是固定的,所以基本也没有什么优化空间。

因此围绕 React 性能优化的话题,基本上都是再 render phase 展开, 所以这篇文章自然也就围绕着 render phase —— 也就是渲染 —— 展开。

ReactDOM.render()一般都是初次渲染时进行的,那么整个节点树中的组件都会执行渲染就没有什么可奇怪的,所以本文主要围绕着更新来讨论, 也就是 setState(或者说useState返回的setter)。

我们首先要搞清楚的是当执行setState的时候,React 会做什么。

React 是一个高度遵循 FP(函数编程)的框架,其核心逻辑就是UI = fn(props & state) ,这里的fn就是组件,同时也是组件树。 在 React 的设计初期,就是希望组件(树)是一个纯函数,也就是说,组件的输出完全由输入决定,不会受到任何外部因素的影响,这样的好处就是,组件的输出是可预测的,

注意: 即便是 ClassComponent 时期,React 也不是一个面向对象的框架,React 对待 ClassCompoonent 的核心,仍然是其 render 函数,而 instance 纯粹是用于存储 state 和 props 的。

基础规则

默认 React 并没有太多的渲染优化,当我们通过setState触发了一次更新,React 会从根节点开始重新计算一次整个组件树。 是的,你没有看错,不论你在哪里触发了setState,最终都会导致整个组件树的重新计算,React 会从根节点开始一次遍历,以计算出最新的 VirtualDomTree。

注意: 至少在 React16 版本使用 Fiber 重构其 Reconciliation 算法之后是这样的,每次setState更新都会加入到一个更新队列中并且暂存在 root 节点上, 等到这次 event loop 中所有的 update 都进入队列,React 再从根节点上读取改更新队列并开始重新渲染。

除了后面要讲的memo之外,React 默认有也有一项优化,React 渲染虽然是从根节点开始的,但是在遍历过程中如果发现节点本身以及祖先节点没有更新, 而是其子树发生了更新,那么该节点也不会被重新渲染,我们可以来看一下这个例子:

codesandbox.io/p/sandbox/g…

import React from "react";

let renderTimes = 0;
function Child() {
  return renderTimes++;
}

function Parent() {
  const [count, setCount] = React.useState(0);
  return (
    <div>
    <button onClick={() => setCount(count + 1)}>click {count}</button>
  <Child />
  </div>
);
}

let appRenderTime = 0;
export function App() {
  return (
    <div>
    {appRenderTime++}
    <Parent />
    </div>
  );
}

在这个例子中,state 的更新发生在Parent组件中,而当Parent组件更新导致重新渲染时,虽然Child组件没有任何的 props 和 state 变化, 但其仍然重新渲染了(renderTimes 增加了),相对的App组件却没有重新渲染,这就说明 state 的更新只会导致更新节点的子树重新渲染,并不会影响祖先节点。

注意: 你看到了renderTimes每次都会加 2,这不是 bug,在 React 的开发模式中,每次更新都会渲染两次,以便于检查你写的useEffect有没有正确消除 effect, 官方文档

小结

  • 虽然 React 的更新会从根节点开始遍历,但是只有更新节点的子树会被重新渲染,祖先节点不会被重新渲染
  • 即便更新节点的子节点没有任何变化,也会被重新渲染

规避渲染

现在我们知道 React 更新渲染的基本规则,接下去要讨论的就是如何进行优化。

但在正式开始之前,我们要知道的是,即便你不做任何优化,对于大部分的应用来说,React 的性能也是够用的,你把各种优化加上有时候反而会适得其反, 这也是为什么很多开发者其实并不完全理解 React 的更新机制,甚至一些理解的开发者也并不能第一眼就看出代码是否有优化空间, 但是 React 仍然是世界上使用最多的前端框架,并且大部分用其开发的应用都是正常运行的。

所以很多时候,而是先专注于实现,然后回过头去用 Profiler 这类工具去分析你的应用, 然后再针对有性能问题的地方去做优化,这样的做法在大多数情况下是更有效且高效的。

重新思考你的组件结构

我们来看下面一个例子

codesandbox.io/s/flamboyan…

import React from "react";

let menuRenderTime = 0;
function Menu() {
    return <nav>Menu Render Times: {menuRenderTime++}</nav>;
}

function Nav() {
    const [theme, setTheme] = React.useState("light");

    return (
        <div>
            <Menu />
            <button
                onClick={() => setTheme(theme === "light" ? "dark" : "light")}
            >
                Theme: {theme}
            </button>
        </div>
    );
}

export function App() {
    return (
        <div>
            <Nav />
            <div>Content</div>
        </div>
    );
}

这是一个非常常见的例子,我们的应用包含了一个导航栏,导航栏里面有一个菜单,同时导航栏还包含一个切换主题的按钮, 我相信大部分人在遇到这么一个需求的时候,第一反应应该也就是这么去实现,而在这个例子里就隐藏着一个可以优化的地方。 我们先来看这个例子,现在点击切换主题时,Menu组件每次都会重新渲染,很显然符合我上面说到的子组件会因为祖先组件的渲染而重新渲染。

而我们可以通过简单地调整Nav和Menu之间的关系来规避这个问题,这就是 renderProps,来看我如何改造组件

codesandbox.io/p/sandbox/d…

import React from "react";

let menuRenderTime = 0;
function Menu() {
    return <nav>Menu Render Times: {menuRenderTime++}</nav>;
}

function Nav({ renderMenu }) {
    const [theme, setTheme] = React.useState("light");

    return (
        <div>
            {renderMenu}
            <button
                onClick={() => setTheme(theme === "light" ? "dark" : "light")}
            >
                Theme: {theme}
            </button>
        </div>
    );
}

export function App() {
    return (
        <div>
            <Nav renderMenu={<Menu />} />
            <div>Content</div>
        </div>
    );
}

现在你切换主题时Menu组件就不会再重新渲染了,这里就利用到了上面总结的第一点,子组件的更新不会引起祖先节点的重新渲染, 在这个例子里,Nav是App的子节点,其更新并不会让App节点重新渲染,而Menu是App渲染过程中被创建的, App没有重新渲染,说明Menu节点没有被重新创建,其复用的仍然是上一次渲染时创建的Element。

所以结论就是,相较于:

function C() {
    return <div />;
}

function B() {
    return <C />;
}

function A() {
    return <B />;
}

这样递归嵌套的组件结构,我更推荐这样的结构:

function C() {
    return <div />;
}

function B({ children }) {
    return children;
}

function A() {
    return (
        <B>
            <C />
        </B>
    );
}

在 React 中,children其实也是一个prop,只是一般我们习惯把 children 和 props 分开来对待,所以很多同学可能会下意识地认为 children 和 props 是不同的东西。

那么归结到这个例子里面,因为App节点没有重新渲染,所以我们没有重新创建Menu组件地节点(通过createElement),因此 Nav 组件的 props 是没有任何变化的, 他拿到的 Menu 组件的 Element 和前一次渲染的是完全相同的实例! 而这才是在这种 case 下面 C 节点没有重新渲染的根本原因。我们可以通过代码来进行验证:

codesandbox.io/p/sandbox/4…

import React from "react";

let menuRenderTime = 0;
function Menu() {
    return <nav>Menu Render Times: {menuRenderTime++}</nav>;
}

let lastMenuElement = null;
function Nav({ renderMenu }) {
    const [theme, setTheme] = React.useState("light");

    React.useEffect(() => {
        lastMenuElement = renderMenu;
    }, [renderMenu]);

    return (
        <div>
            {renderMenu}
            Menu Changed: {renderMenu === lastMenuElement ? "No" : "Yes"}
            <button
                onClick={() => setTheme(theme === "light" ? "dark" : "light")}
            >
                Theme: {theme}
            </button>
        </div>
    );
}

export function App() {
    return (
        <div>
            <Nav renderMenu={<Menu />} />
            <div>Content</div>
        </div>
    );
}
owner vs children

在上面的例子里,Menu 节点的 owner 是 App,而它是 Nav 节点的 children,所以这里引出一个结论:

节点是否重新渲染会受到 owner 的影响,但和 parent 并不是直接相关。

理解 owner 和 children 的区别对于理解 React 的一些概念还是非常有帮助的,但是 React 官方其实并没有给出这样的概念,所以这里我只是给出了一个比较形象的图示,

简单来说,owner 就是创建当前节点的节点,比如在这个例子里的Menu,他的创建在App中时,他的 owner 就是App,而如果是在 Nav 里面,则 owner 是 Nav。 对比这个结果我们可以发现,影响Menu节点是否重新渲染的根本原因,是其 owner 是否重新渲染,因为一旦 owner 重新渲染,就会引起Menu节点的重新创建, 就会让Menu节点需要被重新渲染。

那么是不是只要节点的对象没有变化,就可以规避重新渲染呢?没错,这就是接下去我们要聊的第二点。

保持节点不变

使用key优化节点对比

在 React 中,key 属性用于优化 Virtual DOM 中节点的对比过程。具体来说,key 的作用包括:

  1. 唯一标识:
    • key 为每个元素提供唯一标识,帮助 React 在列表更新时识别哪些元素发生了变化。
  1. 高效更新:
    • 当组件更新时,React 使用 key 来快速判断哪些元素是新增、删除或移动的。
    • 通过 key,React 可以复用相同 key 的组件实例,避免不必要的重新渲染。
  1. 避免不必要的操作:
    • 使用 key 能防止由于错误对比而导致的组件状态丢失或不必要的重排。
    • 提高性能,尤其是在列表的元素发生排序或频繁更新时。

总之,合理使用 key 可以显著提升 React 应用的渲染性能和准确性。通常建议使用唯一且稳定的标识(如数据库中的 ID)作为 key

减少节点的无效创建

严格来说,上面的例子也就是保持了节点不变,所以规避了Menu节点的无用渲染,只是因为造成节点不变的原因来自 React 自身的算法优化,所以我单独拿出来说, 而这一节则会围绕更 common 的场景来讲解。我们仍然来看一个例子,这个例子会简单很多:

codesandbox.io/p/sandbox/r…

import React from "react";

let menuRenderTime = 0;
function Menu() {
    return <nav>Menu Render Times: {menuRenderTime++}</nav>;
}

const menuElement = <Menu />;

export function App() {
    const [count, setCount] = React.useState(0);

    return (
        <div>
            {menuElement}
            <button onClick={() => setCount((c) => c + 1)}>
                Count: {count}
            </button>
        </div>
    );
}

我简化了之前的例子,同样保持了 Menu 组件不会随着父组件的重新渲染而渲染,而这个实现就非常简单,我把menuElement的创建挪到了App组件外面, 这样的结果是,menuElement的创建只会发生一次,而不会随着App组件的重新渲染而重新创建,而借此让Menu节点规避了因为祖先节点的重新渲染而引起的无效渲染。

需要注意这种方式并不会导致Menu组件内部的setState失效,我们可以通过代码来验证:

codesandbox.io/p/sandbox/r…

import React from "react";

let menuRenderTime = 0;
function Menu() {
    const [count, setCount] = React.useState(0);

    return (
        <nav>
            Menu Render Times: {menuRenderTime++}
            <button onClick={() => setCount((c) => c + 1)}>
                Menu Count: {count}
            </button>
        </nav>
    );
}

const menuElement = <Menu />;

export function App() {
    const [count, setCount] = React.useState(0);

    return (
        <div>
            {menuElement}
            <button onClick={() => setCount((c) => c + 1)}>
                Count: {count}
            </button>
        </div>
    );
}

所以如果要论如何优化 React 的渲染性能,很大的一个方向其实就是减少节点的无效创建,这一方面减少了createElement的调用次数, 另一方面大大规避了无效渲染,但是这种方式为什么并没有被广泛推广呢?主要是因为其可维护性不高,因为你需要把具体某几个节点单独提出去声明, 这让节点渲染脱离了常规的节点流,而等到你的业务变得复杂,你可能很难避免需要传递一些 props 给该组件,这时候你就需要把这个组件提升到父组件中, 那代码改起来就变得非常的麻烦。

另外一种方式是不把Menu提到App之外,而是放到useMemo中,这也是可行的,但是这会引入useMemo的计算成本,你可能需要去评估这个成本是否值得, 而却虽然方便了一些,但是仍然维护起来比较麻烦。

不过 React 提供了一种更符合使用习惯的优化方式,那就是React.memo,这个 API 的作用就是让组件变成一个纯组件,也就是说,如果组件的props没有变化, 那么就不会重新渲染。

React.memo

React.memo其实就是函数组件版的PureComponent,当你使用memo来定义一个组件的时候,memo会在发现组件需要重新渲染的时候, 先去 check 一遍组件的props是否变化,他的默认 check 算法是shallowEqual,也就是只比较props对象的直接属性,并且直接===来对比, 如果 prop 是对象,他也是直接对比对象的引用是否相同,所以总体来说比较算法的成本是很低的,大概率比组件重新渲染要低很多。

React 的 issue 里也有一个讨论 React 是否应该默认开启memo的帖子,可以看到很多用户其期望可以默认开启memo的, 因为几乎百分之 95%以上的情况(甚至可能更高),你把所有组件都开启memo是没有什么负面影响的,却可以规避大部分的无效渲染, 是属于何乐而不为的事情。有兴趣的同学可以去这个issue看看大佬们的讨论。

总结一下为什么 React 官方不考虑默认开启memo的原因:

  • 兼容老代码,React 的向前兼容是出了名的牛,甚至 5-6 年前的代码现在升级到 18 大概率还能正常运行,只是多了很多 warning, 而因为考虑默认开启memo是对 React 的渲染机制的一种破坏性更新,即便大部分的代码不会受影响,但是出于兼容性的考虑,也不会默认开启
  • 有一些极端的 Case 可能会因为加了memo无法正常工作,比如在一些使用响应式编程来维护组件状态的情况,当然我并没有碰到过类似 case, 一方面我不喜欢在 React 中用响应式,另外一方面即便是响应式编程也需要一些极端的情况才会出现。
  • 不开启memo性能也没有那么差,还是那句话,大部分情况下,即便你不做任何优化,React 的性能也是足够的,如果你发现哪里性能有问题, 你再渐进式地去加memo就可以,这属于 React 地一种设计哲学吧,你可以不认可,但也不能否认他也有正确地地方。

关于 memo 的使用我就不单独举例了,相信大家都用到过,memo 其实就是组件级别的 useMemo,而 props 中的所有属性就是 useMemo 中第二个参数中的数组, memo 只要发现 props 没有变化,就会直接返回之前已经创建过的 Element,也就符合了我上一节中提到的优化方式,却又没有代码难以维护的问题。

注意: memo并没有规避渲染,而是把重复渲染这件事交给了memo返回的HOC,而这个组件只做了一件事,也就是判断props是否变化,如果没有变化就返回他cache的节点, 内部实现有点类似:

function memo(Comp) {
    return MemoHOC(...props) {
        const element = useMemo(() => {
            return <Comp {...props} />
        }, [...Object.values(props)]) // 当然这里需要排序一下

        return element
    }
}

结语

一个词概括就是机制,React设计如此,他的更新就是组件树级别的,如果你时不时打开Profiler看看,你会发现很多时候你的代码大概率就是只有几个叶子节点在更新,只要不犯类似频繁更新Context这样的基本错误。

❌
❌