阅读视图

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

Canvas 直线点击事件处理优化

    在平常Canvas开发中,经常会遇到直线的点击事件问题,对于这类问题通常的做法就是使用isPointInStroke,但直接使用存在一个问题就是直线的宽度较小时,鼠标点击不太容易选中。下面是针对这类问题总结的一些优化方法。

使用isPointInStroke

    平常开发中,经常使用isPointInStroke方法判断鼠标点击位置是否位于直线上,常规代码如下:

<script setup>
    import { ref, onMounted } from 'vue';
    
    const canvasRef = ref();
    let ctx;
    let isLineSelected = false;
    
    // 直线的起点和终点坐标
    const lineStart = { x: 100, y: 200 };
    const lineEnd = { x: 500, y: 200 };
    
    const clear = () => {
        // 清除画布
        ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    }
    
    // 绘制直线的函数
    const drawLine = () => {
    
        // 设置线条样式
        ctx.strokeStyle = isLineSelected ? '#ff0000' : '#000000';
        ctx.lineWidth = isLineSelected ? 4 : 2;
    
        // 绘制直线
        ctx.beginPath();
        ctx.moveTo(lineStart.x, lineStart.y);
        ctx.lineTo(lineEnd.x, lineEnd.y);
        ctx.stroke();
    };
    onMounted(() => {
        if (canvasRef.value) {
            const canvas = canvasRef.value;
            canvas.width = window.innerWidth;
            canvas.height = window.innerHeight;
            ctx = canvas.getContext('2d');
    
            drawLine();
    
            // 添加鼠标点击事件监听器
            canvasRef.value.addEventListener('click', e => {
                const rect = canvasRef.value.getBoundingClientRect();
                const x = e.clientX - rect.left;
                const y = e.clientY - rect.top;
    
                if (ctx.isPointInStroke(x, y)) {
                    isLineSelected = !isLineSelected;
                    clear()
                    drawLine();
                }
            });
        }
    });
</script>    

    这样我们就可以实现鼠标点击的选中效果,但是这种方法并不完美,当线的宽度较小时,这是就很难选中这条线。 下面我们来优化一下,依旧使用isPointInStroke这个方法,代码如下:

// 检测点击是否在直线上的函数
const isPointOnLine = (x, y) => {
    if (!ctx) return false;

    // 创建直线路径
    ctx.beginPath();
    ctx.moveTo(lineStart.x, lineStart.y);
    ctx.lineTo(lineEnd.x, lineEnd.y);

    // 设置鼠标点击时的容错率
    ctx.lineWidth = 10;

    // 使用 Canvas API 的 isPointInStroke 方法检测点击是否在直线上
    return ctx.isPointInStroke(x, y);
};
if (isPointOnLine(x, y)) {
    isLineSelected = !isLineSelected;
    clear()
    drawLine();
}

    我们把判断条件写成一个方法,在判断之前模拟一条起始坐标和终点坐标相同的线,为了解决线的宽度较小时不太容易选中的问题, 我们在模拟这条线是设置一个较大的宽度,这样就可以优化鼠标点击时不容易选中的问题了。

使用点到直线的距离公式

    除了使用isPointInStroke方法判断鼠标点击位置是否位于直线上,我们还可以使用点到直线的距离公式判断鼠标点击位置是否位于直线上。计算点到直线的距离公式有很多种方法,比如一般式、参数式、向量式等。因为这里我们已知直线的两个坐标和鼠标点击 位置的坐标,使用向量叉积来计算点到直线的距离更为方便。
    点到直线的距离公式如下: iShot_2026-03-07_17.26.21.png     其中,(x1,y1)(x1, y1)(x2,y2)(x2, y2) 是直线的两个坐标,(x0,y0)(x0, y0) 是鼠标点击位置的坐标。代码实现如下:

/**
 * 计算点到直线的距离
 * @param x0 点的 x 坐标
 * @param y0 点的 y 坐标
 * @param x1 直线上一点的 x 坐标
 * @param y1 直线上一点的 y 坐标
 * @param x2 直线上另一点的 x 坐标
 * @param y2 直线上另一点的 y 坐标
 * @param threshold 距离阈值,默认为 10
 * @returns 点到直线的距离是否小于阈值
 */
function pointToLineDistance(x0, y0, x1, y1, x2, y2, threshold = 10) {
  // 计算向量 AB
  const vectorABx = x2 - x1;
  const vectorABy = y2 - y1;

  // 计算向量 AP
  const vectorAPx = x0 - x1;
  const vectorAPy = y0 - y1;

  // 计算叉乘的绝对值(点到直线的距离的分子)
  const crossProduct = Math.abs(vectorABx * vectorAPy - vectorABy * vectorAPx);

  // 计算线段 AB 的长度
  const segmentLength = Math.hypot(vectorABx, vectorABy);

  // 处理线段长度为 0 的情况(两点重合)
  if (segmentLength < 1e-6) {
    // 计算点到点的距离
    const pointDistance = Math.hypot(vectorAPx, vectorAPy);
    return pointDistance < threshold;
  }

  // 计算点到直线的距离
  const distance = crossProduct / segmentLength;

  return distance < threshold;
}

总结

    这两种方法都可以解决线的宽度较小时鼠标点击不容易选中的问题。在数据量不是很大的时候,推荐使用isPointInStroke方法, 在Canvas中直线是最小的单位,创建和绘制直线都是非常快的操作,不会对性能造成太大影响。当数据量大的时候,频繁的创建也会导致性能问题,这时候使用数学方法计算点到直线的距离会更加高效,不依赖 Canvas 状态,计算精确,可定制性强。

CSS 里的「if」:@media、@supports 与即将到来的 @when/@else

CSS 里的「if」:@media、@supports 与即将到来的 @when/@else

梳理 CSS 中实现「条件判断」的几种方式:媒体查询、特性查询,以及规范中的 @when/@else,并给出简单用法与兼容性说明。


一、CSS 有 if 吗?

CSS 没有像 JavaScript 那样的 if (x) { } 语句,但可以通过 @ 规则 做「条件式」样式:满足某条件时才应用某段样式。常见的有两类:媒体查询(@media)特性查询(@supports);规范里还有正在推进的 @when / @else,写法更接近「if-else」,但目前浏览器尚未普遍支持。下面按「能用 today」和「即将到来」分开说。


二、@media:按视口/设备「if」

@media 用来根据媒体类型与媒体特征(如视口宽度、横竖屏、分辨率)决定是否应用样式,相当于「如果屏幕满足某条件,用这段 CSS」。

/* 视口宽度 ≥ 768px 时用栅格布局 */
@media (min-width: 768px) {
  .grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
  }
}

/* 横屏时调整内边距 */
@media (orientation: landscape) {
  .panel {
    padding: 2rem;
  }
}

常见条件min-width / max-widthmin-heightorientationprefers-color-scheme(深色/浅色)、prefers-reduced-motion 等。多条件用 and 连接;需要「或」时写多个 @media 或在一个规则里用逗号。兼容性:现代浏览器均支持,是响应式布局的基础。


三、@supports:按浏览器能力「if」

@supports特性查询如果浏览器支持某 CSS 属性或语法,应用这段样式;不支持则跳过。适合做渐进增强(先写基础样式,再在支持新特性的浏览器里增强)。

/* 支持 Grid 时用 Grid 布局 */
@supports (display: grid) {
  .container {
    display: grid;
    gap: 1rem;
  }
}

/* 不支持时回退 */
@supports not (display: grid) {
  .container {
    display: flex;
    flex-wrap: wrap;
  }
}

/* 同时支持多个特性时 */
@supports (display: grid) and (gap: 1rem) {
  .container {
    display: grid;
    gap: 1rem;
  }
}

逻辑@supports (条件)notandor;还可检测选择器,如 @supports selector(:has(a))兼容性:主流浏览器早已支持,可放心用。


四、@when / @else:规范里的「if-else」(即将到来)

@when@elseCSS Conditional Rules Level 5 中的新规则,用来统一写条件:把媒体条件、特性支持等写进同一套「when-else」链里,语义更接近「if-else if-else」,减少多层 @media 嵌套。

示例(语法以最终规范为准)

@when media(min-width: 800px) {
  .sidebar { width: 300px; }
}
@else media(min-width: 600px) {
  .sidebar { width: 240px; }
}
@else {
  .sidebar { width: 100%; }
}

还可组合 mediasupports

@when media(min-width: 1024px) and supports(display: grid) {
  .layout { display: grid; }
}
@else {
  .layout { display: block; }
}

现状:截至 2024–2025 年,主流浏览器尚未支持 @when/@else,目前只能在支持该规范的实验环境或未来版本中使用。写新项目时仍以 @media + @supports 为主;等 @when/@else 普及后,再考虑重构为更简洁的条件链。


五、对比与使用建议

方式 作用 兼容性 典型场景
@media 视口/设备条件 全面支持 响应式、深色模式、动效偏好
@supports 浏览器能力条件 全面支持 渐进增强、Grid/Flex 回退
@when/@else 统一条件链 尚未支持 未来多条件、互斥分支

建议

  • 需要「根据屏幕大小/横竖屏/主题」切换样式 → 用 @media
  • 需要「根据是否支持某 CSS 特性」切换样式 → 用 @supports
  • 两者可以组合:先 @media 再在块内写 @supports,或反过来。
  • @when/@else 先了解语法即可,等 Can I Use 显示普遍支持后再在实际项目中使用。

六、小结

  • CSS 没有字面意义的 if,但用 @media(媒体条件)和 @supports(特性条件)可以实现「满足条件才应用样式」。
  • @media:按视口宽度、横竖屏、prefers-* 等写响应式与偏好适配。
  • @supports:按浏览器是否支持某属性/选择器写渐进增强与回退。
  • @when/@else:规范中的统一条件语法,可读性更好,目前浏览器未支持,可关注 CSS Conditional Level 5 与 Can I Use 的更新。

若对你有用,欢迎点赞、收藏;你若有基于 @supports 或 @media 的实战写法,也欢迎在评论区分享。

PM2 使用指南 - 踩坑记录

最近把本地项目改成用 PM2 跑,踩了一点坑,记录一下免得下次又忘。

基础配置

先装 PM2:

npm install -g pm2
或
pnpm install -g pm2

创建一个 ecosystem.config.cjs 文件,这是 PM2 的配置文件:

module.exports = {
  apps: [
    {
      name: 'blog',           // 应用名称
      script: 'npx',          // 用什么命令跑
      args: 'next start -H 0.0.0.0',  // 命令参数
      cwd: '/path/to/project', // 项目路径
      instances: 1,           // 实例数量
      exec_mode: 'fork',      // 运行模式
      autorestart: true,      // 崩溃自动重启
      watch: false,           // 不监听文件变化
      max_memory_restart: '1G', // 内存超限重启
      env: {
        NODE_ENV: 'production',
        PORT: 3000,
      },
    },
  ],
};

实例数量这个坑

刚开始配置的时候,我把 instances 设置成了 'max',结果一启动就开了 16 个实例。查了一下才知道,'max' 会根据 CPU 核心数自动启动对应数量的实例。我家里主机是 16 核的,所以直接开了 16 个。

对于小项目来说,2-4 个实例就够用了,没必要开那么多。直接写数字就行:

instances: 2,  // 开 2 个实例

重启不生效的问题

改完配置文件后,我直接用了 pm2 restart,结果配置根本没生效。查了文档才知道,pm2 restart 不会重新读取配置文件,只是重启现有的进程。

正确的做法是:

pm2 delete blog      # 先删除
pm2 start ecosystem.config.cjs  # 再启动

或者用我配置好的命令:

npm run pm2:delete
npm run pm2:start

script 和 args 的选择

一开始我用 script: 'npm'args: 'start',结果各种问题。后来改成直接用 npx 就好多了:

// 不推荐
script: 'npm',
args: 'start',

// 推荐
script: 'npx',
args: 'next start -H 0.0.0.0',

npx 直接运行命令更稳定,npm 作为中间层有时候会有奇怪的问题。

开发环境和生产环境的区别

这个坑我解决了好一会。开发环境要用 next dev,生产环境用 next start

开发环境配置:

{
  script: 'npx',
  args: 'next dev --turbopack -H 0.0.0.0',
  exec_mode: 'fork',  // 开发环境不支持 cluster
  env: {
    NODE_ENV: 'development',
  },
}

生产环境配置:

{
  script: 'npx',
  args: 'next start -H 0.0.0.0',
  exec_mode: 'cluster',  // 生产环境可以用 cluster
  instances: 2,
  env: {
    NODE_ENV: 'production',
  },
}

注意!!!开发环境不能用 cluster 模式,只能用 fork

监听地址的问题

Next.js 默认只监听 localhost,外部访问不了。需要加 -H 0.0.0.0 参数。

我试过用环境变量 HOSTNAME: '0.0.0.0',但在生产模式下不起作用,还是得用命令行参数。

args: 'next start -H 0.0.0.0',  // 这样才生效

Next.js 的 basePath

如果 Next.js 配置了 basePath,访问的时候要加上这个路径。比如:

// next.config.ts
export default {
  basePath: '/blog',
}

那访问地址就是 http://127.0.0.1:3000/blog,不是 http://127.0.0.1:3000

常用命令集合

# 启动
pm2 start ecosystem.config.cjs

# 停止
pm2 stop blog

# 重启(不重读配置)
pm2 restart blog

# 删除
pm2 delete blog

# 查看日志
pm2 logs blog

# 查看状态
pm2 list

# 查看详情
pm2 show blog

# 监控面板
pm2 monit

开机自启

# 保存当前进程列表
pm2 save

# 生成开机启动脚本
pm2 startup

package.json 脚本

把常用命令写到 package.json 里,方便使用:

{
  "scripts": {
    "pm2:start": "pm2 start ecosystem.config.cjs",
    "pm2:stop": "pm2 stop blog",
    "pm2:restart": "pm2 restart blog",
    "pm2:delete": "pm2 delete blog",
    "pm2:logs": "pm2 logs blog",
    "pm2:monit": "pm2 monit"
  }
}

总结复盘

  • instances: 'max' 会开很多实例,小项目直接写数字
  • 改配置后要先 deletestartrestart 不重读配置
  • npxnpm 稳定
  • 开发环境用 next dev,生产环境用 next start
  • 开发环境只能用 fork 模式
  • -H 0.0.0.0 让服务监听所有地址
  • 注意 Next.js 的 basePath 配置

差不多就这些点了,希望能帮到后面用 PM2 的同学。

React 中 useState、useEffect、useRef 的区别与使用场景详解,终于有人讲明白了

一、先用一句话概括这三个 Hook

如果你现在还很懵,先别慌,先记住下面这三句话。

useState

让组件记住会影响页面展示的数据

useEffect

让组件在渲染后去执行额外操作

useRef

让组件保存一个不会触发重新渲染的值,或者拿到 DOM 元素

这三句话,已经把它们最本质的区别说出来了。

如果还觉得抽象,没关系,接下来我一个个拆开讲。

二、先说 useState:它是“状态管理”的

React 组件最大的特点之一,就是:

数据一变,页面跟着变。

useState,就是专门用来保存这种“会驱动页面变化的数据”的。

先看最经典的例子。

import React, { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      当前点击了 {count} 次
    </button>
  );
}

export default Counter;

这里这句最关键:

const [count, setCount] = useState(0);

它的意思你可以直接翻译成人话:

React,帮我准备一个状态,初始值是 0,当前值叫 count,修改它的方法叫 setCount。

也就是说:

  • count 是当前状态值
  • setCount 是更新状态的方法
  • 0 是初始值

当你点击按钮执行:

setCount(count + 1);

React 会做两件事:

  1. 更新状态值
  2. 重新渲染组件

所以页面上的 count 就会变。

useState 最典型的应用场景

useState 常用于这些地方:

  • 计数器数字
  • 输入框内容
  • 弹窗是否显示
  • 下拉框选中项
  • 当前分页页码
  • 列表数据
  • 加载状态 loading
  • 错误提示信息

比如控制弹窗:

const [visible, setVisible] = useState(false);

比如保存输入框内容:

const [keyword, setKeyword] = useState("");

比如保存接口返回的数据:

const [list, setList] = useState([]);

这些都属于:

一旦数据变化,页面就要跟着变化。

这时候就应该用 useState

三、再说 useEffect:它是“副作用处理”的

很多人第一次看到“副作用”这个词,容易被吓到。

其实它没有那么玄乎。

你可以简单把副作用理解成:

除了渲染页面以外,还要额外做的事情。

比如:

  • 请求接口
  • 设置定时器
  • 监听事件
  • 修改浏览器标题
  • 操作本地存储
  • 手动操作 DOM
  • 组件销毁时做清理

这些都不是“渲染 JSX”本身,而是页面渲染之后要顺便做的事。

这时候就轮到 useEffect 出场了。

先看一个最简单的例子:

import React, { useEffect } from "react";

function Demo() {
  useEffect(() => {
    console.log("组件渲染完成了");
  }, []);

  return <div>Hello React</div>;
}

这段代码的意思就是:

页面渲染完以后,执行 console.log

所以你可以理解成:

useEffect = 渲染后执行任务

useEffect 最常见的使用场景

1. 请求接口

useEffect(() => {
  fetch("/api/user")
    .then((res) => res.json())
    .then((data) => {
      console.log(data);
    });
}, []);

2. 设置定时器

useEffect(() => {
  const timer = setInterval(() => {
    console.log("每秒执行一次");
  }, 1000);

  return () => clearInterval(timer);
}, []);

3. 监听事件

useEffect(() => {
  const handleResize = () => {
    console.log(window.innerWidth);
  };

  window.addEventListener("resize", handleResize);

  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);

4. 修改页面标题

useEffect(() => {
  document.title = "用户中心";
}, []);

这些都是副作用。

也就是说:

只要不是单纯为了渲染页面,而是渲染后还要做点别的事,大概率就要想到 useEffect。

四、再说 useRef:它是“持久容器”和“DOM 引用”

useRef 是很多初学者最容易迷糊的 Hook。

因为它不像 useState 那么直观,也不像 useEffect 那么容易理解成“执行动作”。

其实 useRef 可以简单理解成两个作用。

作用一:获取 DOM 元素

比如你想让输入框在页面加载后自动获取焦点:

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

function InputFocus() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current.focus();
  }, []);

  return <input ref={inputRef} placeholder="请输入内容" />;
}

export default InputFocus;

这里可以这样理解:

  • useRef(null) 创建一个引用对象
  • inputRef.current 会指向真实的 input DOM
  • 通过 focus() 就可以让输入框聚焦

也就是说:

useRef 可以帮你“拿到页面中的真实元素”。

作用二:保存一个值,但不触发页面重新渲染

这是 useRef 更重要、也更容易被忽略的能力。

比如保存定时器 id:

const timerRef = useRef(null);

赋值:

timerRef.current = setInterval(() => {
  console.log("running");
}, 1000);

清除:

clearInterval(timerRef.current);

这个值会一直保留在组件生命周期里,但它变化时不会导致页面重渲染。

所以你可以把 useRef 理解成:

组件里的一个“小盒子”,你可以往里面放东西,它会一直记着,但不会因为盒子里的东西变了就刷新页面。

五、它们三个最大的区别,到底是什么?

这是本文最核心的部分。

我先直接给你一个最重要的结论:

Hook 核心作用 数据变化后会不会触发重新渲染
useState 保存状态
useEffect 执行副作用 本身不是存数据的
useRef 保存引用/持久值 不会

把这张表吃透,你就不容易乱用了。

接下来我一个个解释。

六、useState 和 useRef 的区别,初学者最容易搞混

很多人学到这里时,最大的疑问就是:

既然 useState 能存值,useRef 也能存值,那到底啥时候用谁?

答案非常简单:

需要更新页面的,用 useState

不需要更新页面的,用 useRef

来看例子。

场景 1:页面上要显示这个值

import React, { useState } from "react";

function Demo() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>当前数字:{count}</p>
      <button onClick={() => setCount(count + 1)}>加一</button>
    </div>
  );
}

这里 count 是显示在页面上的。

点击按钮后,页面中的数字也要变化。

所以必须用 useState

场景 2:只是内部记一下,不需要显示

import React, { useRef } from "react";

function Demo() {
  const clickTimesRef = useRef(0);

  const handleClick = () => {
    clickTimesRef.current += 1;
    console.log("点击次数:", clickTimesRef.current);
  };

  return <button onClick={handleClick}>点击我</button>;
}

这里点击次数只是打印在控制台,并没有显示在页面上。

那就没必要用 useState,用 useRef 就够了。

再总结一遍

useState 的场景

  • 页面要展示这个数据
  • 数据变化后希望组件重新渲染
  • 数据会驱动 UI 更新

useRef 的场景

  • 只是临时保存一个值
  • 不希望因为这个值变化而重新渲染
  • 保存 DOM、定时器 id、上一次值等

七、为什么 useRef 改了值,页面不更新?

这个问题特别经典,面试也爱问。

比如下面这段代码:

import React, { useRef } from "react";

function Demo() {
  const countRef = useRef(0);

  const handleClick = () => {
    countRef.current += 1;
    console.log(countRef.current);
  };

  return (
    <div>
      <p>{countRef.current}</p>
      <button onClick={handleClick}>点击</button>
    </div>
  );
}

很多初学者会以为点击按钮后,页面上的数字会变。

但实际上,页面大概率不会更新。

为什么?

因为:

修改 ref.current 不会触发组件重新渲染。

React 只会在这些情况下重新渲染组件:

  • props 变了
  • state 变了
  • 父组件重新渲染导致子组件重新渲染

ref.current 的变化,不在 React 的“响应式更新系统”里。

所以它改了,React 不会主动刷新页面。

这就是 useRefuseState 最大的区别之一。

八、useEffect 和 useState 的关系是什么?

开发中经常看到这俩一起出现。

比如页面加载后请求数据:

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

function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/users")
      .then((res) => res.json())
      .then((data) => {
        setUsers(data);
      });
  }, []);

  return (
    <ul>
      {users.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

这里的配合方式非常典型:

  • useState 负责存数据
  • useEffect 负责获取数据

也就是说:

useState 管“保存结果”,useEffect 管“执行动作”。

你可以理解成:

  • useState 是仓库
  • useEffect 是工人
  • 工人出去搬货,最后把货放进仓库里

这是它们最经典的协作模式。

九、useEffect 的依赖数组到底怎么理解?

这个问题,是 React 初学者最容易卡壳的地方之一。

我们先看写法:

useEffect(() => {
  console.log("执行副作用");
}, []);

第二个参数 [],就叫 依赖数组

它决定这个副作用什么时候执行。

1. 传空数组 []

useEffect(() => {
  console.log("只执行一次");
}, []);

表示:

组件首次渲染完成后执行一次。

常见用途:

  • 页面加载请求一次接口
  • 初始化某些逻辑
  • 绑定事件监听并在销毁时清理

2. 不传依赖数组

useEffect(() => {
  console.log("每次渲染都执行");
});

表示:

组件每次渲染后都会执行。

这个一般要慎用,否则可能造成不必要的执行。

3. 传某个依赖项

useEffect(() => {
  console.log("count 变化了");
}, [count]);

表示:

首次渲染执行一次,以后只有 count 变化时才执行。

4. 传多个依赖项

useEffect(() => {
  console.log("count 或 keyword 变化了");
}, [count, keyword]);

表示:

只要 countkeyword 中任意一个变化,副作用就会重新执行。

最通俗的理解方式

你可以把依赖数组理解成一句话:

只要数组里的这些值变了,就重新执行这段副作用代码。

这就很好记了。

十、useEffect 的清理函数是干嘛的?

很多人刚开始看到这种写法会有点懵:

useEffect(() => {
  const timer = setInterval(() => {
    console.log("执行中");
  }, 1000);

  return () => {
    clearInterval(timer);
  };
}, []);

为什么 useEffect 里面还要 return 一个函数?

这个函数叫:

清理函数

它一般会在这些时候执行:

  1. 组件卸载时
  2. 副作用重新执行前,先清理上一次的副作用

最常见的用途有:

  • 清除定时器
  • 移除事件监听
  • 取消订阅
  • 中断请求

比如监听窗口大小变化:

useEffect(() => {
  const handleResize = () => {
    console.log(window.innerWidth);
  };

  window.addEventListener("resize", handleResize);

  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);

这里如果不做清理,组件销毁后事件还在,就可能造成内存泄漏或者逻辑混乱。

所以你可以这样记:

副作用用了什么外部资源,离开时就记得清掉。

十一、三个 Hook 的生活化比喻,一下就记住

为了让你更容易记住,我给你打个特别通俗的比方。

把 React 组件想象成一个办公室员工。

useState:员工的记事本

员工需要记住今天要做什么、当前完成多少、按钮是开还是关。

这些会影响工作展示给老板看。

所以:

useState = 会展示出来的正式数据

useEffect:员工的任务清单

员工上班后要做事:

  • 给客户打电话
  • 发邮件
  • 开会
  • 定时汇报

这些不是“展示内容”,而是要执行的动作。

所以:

useEffect = 渲染后执行的额外任务

useRef:员工的抽屉

员工抽屉里放着一些东西:

  • 钥匙
  • 工牌
  • 上一次会议记录
  • 某个客户电话
  • 临时编号

这些不需要写到汇报 PPT 上,但又得一直留着备用。

所以:

useRef = 持久保存但不驱动页面变化的数据容器

这个比喻基本能帮很多初学者彻底理顺。

十二、实际开发中该怎么选?

这里我给你一个非常实战的判断口诀。

场景一:数据变了,页面也要变

useState

比如:

  • 输入框输入内容
  • 列表数据变化
  • loading 状态
  • tab 切换
  • 当前选中项

场景二:页面出来后要执行动作

useEffect

比如:

  • 请求接口
  • 绑定事件
  • 启动定时器
  • 修改标题
  • 同步本地存储

场景三:只想记个值,不想刷新页面

useRef

比如:

  • 保存 timer id
  • 保存上一次值
  • 防抖节流中的锁
  • 获取 input DOM
  • 防止重复提交标记

这个口诀非常适合业务开发时快速判断。

十三、一个综合案例,把三个 Hook 串起来理解

下面我们写一个小案例:搜索框自动聚焦,并在输入时同步标题,同时记录输入次数。

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

function SearchDemo() {
  const [keyword, setKeyword] = useState("");
  const inputRef = useRef(null);
  const changeCountRef = useRef(0);

  useEffect(() => {
    inputRef.current.focus();
  }, []);

  useEffect(() => {
    document.title = keyword ? `正在搜索:${keyword}` : "搜索页面";
  }, [keyword]);

  const handleChange = (e) => {
    setKeyword(e.target.value);
    changeCountRef.current += 1;
    console.log("输入次数:", changeCountRef.current);
  };

  return (
    <div>
      <h2>搜索示例</h2>
      <input
        ref={inputRef}
        value={keyword}
        onChange={handleChange}
        placeholder="请输入关键词"
      />
      <p>当前关键词:{keyword}</p>
    </div>
  );
}

export default SearchDemo;

这个案例里:

useState

保存输入框内容 keyword

因为它要显示到页面上,所以必须用状态。

第一个 useEffect

页面加载后让输入框自动聚焦

因为这是渲染后执行的动作,所以用 useEffect

第二个 useEffect

每当 keyword 变化时更新浏览器标题

这也属于副作用,所以还是 useEffect

useRef

一个拿 DOM:inputRef
一个记录输入次数:changeCountRef

输入次数只是打印日志,并不展示到页面,所以没必要用 useState,用 useRef 更合适。

这个案例基本把三个 Hook 的职责划分得很清楚了。

十四、面试中怎么回答它们的区别?

如果面试官问你:

useStateuseEffectuseRef 的区别是什么?

你可以这么回答:

useState 主要用于管理组件状态,当状态变化时会触发组件重新渲染,通常用来保存那些会影响页面展示的数据。
useEffect 主要用于处理副作用,也就是组件渲染之后需要执行的额外逻辑,比如请求接口、事件监听、定时器、修改标题等。
useRef 主要用于保存引用或者持久化数据,它既可以获取 DOM 元素,也可以保存一些不需要触发组件重新渲染的值,比如定时器 id、上一次的值等。
它们的核心区别在于:useState 管状态并驱动视图更新,useEffect 管副作用执行,useRef 管持久化引用但不会触发视图更新。

这段话很适合面试时直接说。

十五、初学者最常犯的几个错误

1. 该用 useRef 的地方用了 useState

比如只是存一个定时器 id,却写成:

const [timer, setTimer] = useState(null);

其实这类数据不参与页面展示,用 useRef 更合理。

2. 该用 useState 的地方用了 useRef

比如页面上的数字要变化,却写成:

const countRef = useRef(0);
countRef.current += 1;

结果发现页面不更新。

因为 useRef 的变化不会触发渲染。

3. 把所有逻辑都往 useEffect 里塞

有些逻辑其实只是普通计算,不一定非要写 useEffect

不要一上来就觉得“只要是逻辑就放 useEffect”。

4. useEffect 依赖数组乱写

比如副作用里明明用到了 count,却不写到依赖数组里,容易造成旧值问题。

5. 忘记清理副作用

比如监听事件、开定时器却不清理,组件销毁后可能引发 bug。

十六、最后给你一个最简单的判断公式

以后开发时,如果你一时分不清到底该用谁,就套这三句判断。

第一问:这个数据要不要显示到页面上?

要,就优先考虑 useState

第二问:这个逻辑是不是要在渲染之后执行?

是,就优先考虑 useEffect

第三问:我是不是只是想记个值,或者拿 DOM,但不想刷新页面?

是,就优先考虑 useRef

这三个问题,基本能帮你解决 80% 的判断场景。

十七、总结

这篇文章讲了很多,其实最后你真正要记住的,就这几句话。

useState 是什么?

保存会影响页面展示的状态,状态变了会重新渲染。

useEffect 是什么?

处理渲染后的副作用,比如请求接口、事件监听、定时器等。

useRef 是什么?

保存不会触发重新渲染的值,或者获取 DOM 元素。

它们的最大区别是什么?

  • useState:存状态,更新会刷新页面
  • useEffect:执行副作用,不是拿来存数据的
  • useRef:存引用或值,但更新不会刷新页面

如果你之前一直觉得这三个 Hook 很绕,那你现在可以直接把它们理解成:

  • useState:页面数据管理员
  • useEffect:页面行为执行器
  • useRef:页面内部小仓库

这样再看 React Hook,很多东西就没那么抽象了。

Generator 迭代器协议 & co 库底层原理+实战

这两个是理解 Generator 的「关键底层知识点」,也是面试中容易被追问的细节——迭代器协议是 Generator 能被遍历的基础,co 库是 Generator 实现异步流程控制的核心,我用最通俗的语言+代码拆解清楚。

一、迭代器协议(Iterator Protocol):Generator 能“暂停/遍历”的底层规则

1. 先搞懂:什么是“协议”?

协议就是「约定好的规则」——ES6 规定了两套和遍历相关的协议:

  • 可迭代协议(Iterable Protocol) :一个对象只要有 [Symbol.iterator]() 方法,且该方法返回一个「迭代器对象」,就称这个对象“可迭代”(比如 Array、Set、Map、Generator 对象都符合);
  • 迭代器协议(Iterator Protocol) :一个对象只要有 next() 方法,且 next() 返回 { value: 产出值, done: 是否完成 } 格式的对象,就称这个对象是“迭代器”。

2. Generator 与迭代器协议的关系

Generator 函数调用后返回的「生成器对象」,同时满足可迭代协议 + 迭代器协议——这是它能被 for...of 遍历、能暂停/恢复的核心原因。

验证:生成器对象的协议合规性

function* gen() {
  yield 1;
  yield 2;
}
const g = gen(); // 生成器对象

// 1. 验证迭代器协议:有next(),返回{value, done}
console.log(g.next()); // { value: 1, done: false }
console.log(g.next()); // { value: 2, done: false }
console.log(g.next()); // { value: undefined, done: true }

// 2. 验证可迭代协议:有[Symbol.iterator](),且返回自身(迭代器)
console.log(typeof g[Symbol.iterator] === 'function'); // true
console.log(g[Symbol.iterator]() === g); // true(关键:返回自身)

// 3. 因此能被for...of遍历(for...of只遍历done: false的value,忽略return值)
for (let val of g) {
  console.log(val); // 1、2(return的值不会被遍历)
}

3. 手动实现迭代器协议(理解 Generator 底层)

Generator 本质是 ES6 帮我们自动实现了迭代器协议,我们手动写一个迭代器,就能明白它的核心逻辑:

// 手动实现一个“模拟Generator”的迭代器
const myIterator = {
  _step: 0, // 记录执行步骤
  next() {
    this._step++;
    if (this._step === 1) {
      return { value: 1, done: false }; // 对应yield 1
    } else if (this._step === 2) {
      return { value: 2, done: false }; // 对应yield 2
    } else {
      return { value: 3, done: true }; // 对应return 3
    }
  },
  // 实现可迭代协议:返回自身
  [Symbol.iterator]() {
    return this;
  }
};

// 调用方式和Generator完全一致
console.log(myIterator.next()); // { value: 1, done: false }
console.log(myIterator.next()); // { value: 2, done: false }
console.log(myIterator.next()); // { value: 3, done: true }

4. 面试高频:迭代器协议的核心考点

  • Q:为什么 Generator 对象能被 for...of 遍历?
    A:因为 Generator 对象符合「可迭代协议」——有 [Symbol.iterator]() 方法且返回迭代器(自身),而 for...of 会自动调用迭代器的 next() 直到 done: true
  • Q:迭代器协议和可迭代协议的区别?
    A:迭代器协议是“对象有 next() 且返回 {value, done}”,可迭代协议是“对象有 Symbol.iterator 且返回迭代器”;前者是“能一步步取值”,后者是“能被遍历”。

二、co 库:Generator 异步流程的“自动执行器”

1. co 库的核心作用

在 async/await 出现前,Generator 处理异步的最大痛点是「需要手动调用 next()」——co 库的本质是一个自动执行器:它能自动调用 Generator 迭代器的 next(),并把异步操作(Promise)的结果作为参数传入下一个 next(),直到 Generator 执行完毕。

简单说:co 库 = 自动调用 next() + 处理 Promise 结果 + 异常捕获

2. co 库的基本使用(先看效果)

先安装 co 库:

npm install co

使用示例(对比手动执行和 co 自动执行):

const co = require('co');

// 模拟异步请求
function fetchData(url) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(`数据:${url}`), 1000);
  });
}

// Generator异步函数
function* gen() {
  const res1 = yield fetchData('https://api1.com');
  const res2 = yield fetchData(`https://api2.com?data=${res1}`);
  return res2;
}

// 方式1:手动执行(繁琐)
const g = gen();
g.next().value.then(res1 => {
  g.next(res1).value.then(res2 => {
    console.log(g.next(res2).value); // 数据:https://api2.com?data=数据:https://api1.com
  });
});

// 方式2:co自动执行(简洁)
co(gen).then(res => {
  console.log(res); // 数据:https://api2.com?data=数据:https://api1.com
});

3. co 库的核心原理(手写简化版)

co 库的源码不到 200 行,核心逻辑是「递归调用 next() + 处理 Promise」,我们手写一个简化版,吃透它的底层:

// 简化版co库:自动执行Generator
function co(genFunc) {
  // 返回Promise,符合现代异步规范
  return new Promise((resolve, reject) => {
    const g = genFunc(); // 获取生成器迭代器

    // 递归执行next的函数
    function next(val) {
      let result;
      try {
        result = g.next(val); // 恢复执行,传入上一次异步结果
      } catch (e) {
        return reject(e); // 捕获Generator内部异常
      }

      const { value, done } = result;
      if (done) {
        // 执行完毕,resolve最终值
        return resolve(value);
      }

      // 核心:如果value是Promise,等待resolve后继续next
      // 非Promise则直接传入下一个next
      Promise.resolve(value).then(
        (data) => next(data), // 异步成功:把结果传入下一个next
        (err) => g.throw(err) // 异步失败:向Generator内部抛异常
      );
    }

    // 启动执行器
    next();
  });
}

// 测试:和官方co库效果一致
co(gen).then(res => console.log(res)); // 数据:https://api2.com?data=数据:https://api1.com

4. co 库的核心规则(面试必知)

co 库能自动执行的前提是:Generator 中 yield 后面的值必须是以下类型之一(否则会直接传入 next()):

  1. Promise(最常用);
  2. 可迭代对象(Array、Set、Generator 对象等);
  3. 普通对象/函数(co 会尝试转换为 Promise)。

5. co 库 vs async/await(核心关系)

ES2017 引入的 async/await,本质是「Generator + co 库」的语法糖——浏览器/Node 内置了类似 co 的自动执行器,无需手动引入库。

维度 co + Generator async/await
执行方式 手动引入co库 语言原生支持,自动执行
异常处理 需配合try/catch + co的catch 原生try/catch即可
返回值 Promise(co返回) Promise(async函数返回)
语法简洁性 稍繁琐(function* + yield) 更简洁(async + await)
兼容性 需ES6环境 + co库 需ES2017环境(或babel转译)

6. 面试高频:co 库的考点

  • Q:co 库的作用是什么?
    A:co 库是 Generator 函数的自动执行器,核心解决 Generator 处理异步时需要手动调用 next() 的问题;它会自动递归调用 next(),并将 yield 后 Promise 的结果传入下一个 next(),最终返回一个 Promise,简化 Generator 异步流程控制。
  • Q:async/await 是不是替代了 co 库?
    A:是的。async/await 是 Generator + 自动执行器的语法糖,浏览器/Node 内置了类似 co 的执行逻辑,因此在现代开发中,co 库已几乎被 async/await 取代;但理解 co 库的原理,能更好地理解 async/await 的底层逻辑。

总结

迭代器协议核心

  1. 迭代器协议:对象有 next() 且返回 { value, done };可迭代协议:对象有 [Symbol.iterator]() 且返回迭代器;
  2. Generator 对象同时满足两套协议,因此能被 for...of 遍历、能暂停/恢复;
  3. 迭代器协议是 Generator 实现暂停/遍历的底层规则。

co 库核心

  1. co 库是 Generator 的自动执行器,核心逻辑是「递归调用 next() + 处理 Promise 结果」;
  2. co 库解决了 Generator 手动调用 next() 的痛点,是 async/await 的“前身”;
  3. 现代开发中 async/await 已替代 co 库,但理解 co 库原理是掌握 async/await 底层的关键。

这两个知识点是 Generator 进阶的核心,面试中只要能讲清「迭代器协议的规则」和「co 库的自动执行逻辑」,就能体现你对 Generator 不是只懂表面用法,而是理解底层设计。

逃离"Div汤":2026年,当AI写了75%的代码,前端开发者还剩什么?

引言:75%的代码由AI生成,但我的快乐去哪了?

2026年3月,某互联网大厂的前端开发会议上,一位资深工程师在白板上画出了一幅令人窒息的结构图。这个由AI生成的页面结构包含12层嵌套div,每个节点都裹挟着Tailwind的类名组合,像一锅熬煮过度的浓汤。"这代码连自己都看不懂,"他苦笑着摇头,"每天对着这些'Div汤'改bug,就像在拆炸弹。"

Gartner 2026软件工程报告显示,全球75%的企业级前端代码已由AI辅助生成。这个数字背后,是开发者们普遍面临的困境:当代码不再是自己亲手敲出来的,我们对它的掌控感、理解力、创造力还剩多少?这场由AI驱动的代码革命,正在重塑前端开发的生态,也迫使我们重新思考:在效率与创造力之间,如何找到平衡点?

什么是"Div汤":AI生成代码的隐忧

层层嵌套的HTML结构

AI生成的代码往往呈现出一种病态的嵌套结构。以下是一个典型的AI生成的页面结构示例:

<div class="flex flex-col md:flex-row">
  <div class="md:w-1/2">
    <div class="p-4">
      <div class="bg-gray-100 rounded">
        <div class="p-2">
          <div class="text-sm">
            <div class="flex justify-between">
              <div class="font-medium">订单号</div>
              <div class="text-gray-500">202603151234</div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
  <div class="md:w-1/2">
    <div class="p-4">
      <div class="bg-gray-100 rounded">
        <div class="p-2">
          <div class="text-sm">
            <div class="flex justify-between">
              <div class="font-medium">金额</div>
              <div class="text-gray-500">¥128.00</div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

这段代码呈现出明显的"Div汤"特征:12层嵌套的div结构,每个节点都添加了不必要的类名。这种结构不仅增加了页面的渲染开销,更让开发者难以理解页面的布局逻辑。

Tailwind类名的重复使用

AI生成的Tailwind类名组合往往呈现出"堆砌式"特征。以下是一个典型的类名组合示例:

<div class="flex flex-col md:flex-row justify-between items-center p-4 bg-white rounded shadow-md">

这种类名的堆砌不仅降低了代码的可读性,更让开发者难以进行有效的样式优化。当需要修改布局时,开发者往往需要逐个排查这些类名,耗时且低效。

代码遗产的不可逆性

AI生成的代码往往缺乏文档注释和结构说明。当开发者接手这样的代码时,常常面临"看不懂、不想改、不敢动"的困境。某电商网站的前端团队曾统计,其AI生成的代码中,有42%的代码存在"结构冗余"问题,但因缺乏文档,开发者不敢轻易重构。

AI带来的效率革命:从"写代码"到"审代码"

效率提升的量化数据

Gartner 2026报告显示,采用AI辅助开发的企业,其页面搭建时间平均缩短60%。某金融科技公司的数据表明,使用AI生成基础结构后,开发人员的编码效率提升了44%。这种效率提升主要体现在:

  • 基础结构生成时间从2小时缩短至15分钟
  • 常用组件的重复开发时间减少80%
  • 布局调试时间降低55%

开发模式的转变

AI的介入正在改变前端开发的模式。开发者从"写代码"的执行者转变为"审代码"的架构师。某头部互联网公司的开发流程显示:

  1. AI根据需求文档生成基础结构
  2. 开发者审核并优化结构逻辑
  3. 手动实现核心交互逻辑
  4. 使用AI辅助进行样式优化

这种模式要求开发者具备更强的架构设计能力,但同时也带来了新的挑战:当代码不再是自己亲手敲出来的,我们对代码的理解力是否在退化?

隐形的代价:开发者能力的隐性流失

DOM结构直觉的退化

某高校的开发者能力评估报告显示,使用AI生成代码的开发者,其DOM结构理解能力下降了37%。以下是一个典型的调试困境:

document.querySelectorAll('.order-item').forEach(item => {
  item.addEventListener('click', () => {
    // 无法确定事件冒泡路径
    console.log(item.closest('.order-list'));
  });
});

当开发者面对这种嵌套结构时,往往需要依赖调试工具才能理解事件冒泡路径,这种依赖性正在削弱开发者对DOM结构的直觉。

调试能力的下降

AI生成的代码往往缺乏调试信息。某开发者社区的调查显示,78%的开发者在调试AI生成的代码时需要额外添加console.log语句。以下是一个典型的调试困境:

const container = document.querySelector('.container');
container.addEventListener('scroll', () => {
  // 无法确定滚动事件的触发节点
  console.log(container.scrollTop);
});

这种调试困难不仅增加了开发时间,更可能导致潜在的性能问题。

创造力的标准化输出

AI生成的代码往往呈现出"标准化输出"特征。某设计系统的统计数据显示,AI生成的组件样式与设计规范的匹配度仅为62%。这种标准化虽然提高了开发效率,但也抑制了设计师的创意表达。以下是一个典型的样式冲突案例:

/* AI生成的样式 */
.order-item {
  padding: 1rem;
  border-radius: 0.5rem;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

/* 手动修改的样式 */
.order-item {
  padding: 1.5rem;
  border-radius: 1rem;
  box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}

当开发者需要修改AI生成的样式时,往往需要重新计算所有相关样式,这种繁琐过程正在消磨开发者的创造力。

如何重获创造力:从"写代码"到"设计架构"

从执行到设计的转变

开发者需要重新定位自己的角色,从代码执行者转变为架构设计师。某大型电商公司的开发流程显示:

  1. 使用AI生成基础结构
  2. 手动设计布局逻辑
  3. 制定样式规范
  4. 使用AI辅助实现细节
// 设计布局逻辑
const layout = {
  container: {
    maxWidth: '1200px',
    margin: '0 auto',
    padding: '2rem'
  },
  header: {
    height: '60px',
    backgroundColor: '#fff'
  }
};

// AI辅助生成代码
const code = generateCode(layout);

这种模式要求开发者具备更强的架构设计能力,但也能在AI辅助下实现更高效的开发。

规范驱动开发(SPEC模式)

SPEC(Specification)模式是一种通过规范驱动开发的方法。某开发团队采用的SPEC模式示例如下:

// SPEC规范
const spec = {
  layout: {
    type: 'flex',
    direction: 'column',
    gap: '1rem'
  },
  card: {
    type: 'grid',
    columns: 'repeat(3, 1fr)',
    gap: '1rem'
  }
};

// AI生成代码
const code = generateCode(spec);

这种模式让开发者能够通过规范定义布局,AI则负责将规范转化为实际代码,既保持了代码的可读性,又提高了开发效率。

定期"手写日":回归基础

某开发团队实施的"手写日"制度显示,定期手动编写代码的开发者,其代码质量提升了28%。以下是一个手写日的实践示例:

<!-- 手写日代码 -->
<div class="flex flex-col md:flex-row gap-4">
  <div class="md:w-1/2 p-4 bg-white rounded shadow">
    <div class="font-medium">订单号</div>
    <div class="text-gray-500">202603151234</div>
  </div>
  <div class="md:w-1/2 p-4 bg-white rounded shadow">
    <div class="font-medium">金额</div>
    <div class="text-gray-500">¥128.00</div>
  </div>
</div>

这种定期的手写实践不仅保持了开发者的代码直觉,也增强了对底层技术的理解。

关注CSS新特性:重获控制权

CSS的持续发展为开发者提供了新的控制手段。某开发团队采用的CSS新特性示例如下:

/* 使用CSS Grid实现响应式布局 */
.container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 1rem;
}

/* 使用CSS Variables实现样式复用 */
:root {
  --primary-color: #3b82f6;
}

.card {
  background-color: var(--primary-color);
  padding: 1rem;
  border-radius: 0.5rem;
}

通过关注CSS新特性,开发者可以重新获得对样式控制的主动权,避免完全依赖AI生成的样式代码。

工具的选择:在AI时代保持掌控

文心快码的SPEC模式

文心快码的SPEC模式提供了白盒化流程,让开发者能够精确控制AI生成的代码。以下是一个SPEC模式的使用示例:

// 定义SPEC规范
const spec = {
  layout: {
    type: 'flex',
    direction: 'column',
    gap: '1rem'
  },
  card: {
    type: 'grid',
    columns: 'repeat(3, 1fr)',
    gap: '1rem'
  }
};

// 生成代码
const code = generateCode(spec);

这种模式让开发者能够通过规范定义布局,AI则负责将规范转化为实际代码,既保持了代码的可读性,又提高了开发效率。

Cursor的Composer:多文件编辑掌控感

Cursor的Composer功能允许开发者在多文件中进行协同编辑,保持对代码的掌控感。以下是一个Composer的使用示例:

// 项目结构
project/
├── index.html
├── styles.css
└── script.js

// Composer编辑流程
1. 打开index.html
2. 选择"Add CSS Class"操作
3. 选择"Add JS Event"操作
4. 保存并同步到其他文件

这种多文件协同编辑模式,让开发者能够在保持代码连贯性的前提下,充分发挥创造力。

结语:AI不是替代者,而是放大器

在AI时代,前端开发者的角色正在发生深刻变化。我们不再是单纯的代码书写者,而是架构设计师、规范制定者和创造力的守护者。AI带来的效率革命不可逆转,但开发者需要主动掌握这场变革的方向。

选择权始终在开发者手中:是让AI完全接管代码生成,还是通过SPEC模式保持对架构的控制?是放弃手写实践,还是定期回归基础?是被动接受AI生成的样式,还是主动探索CSS新特性?

真正的智慧在于,让AI成为我们的放大器,而不是替代者。当我们学会在效率与创造力之间找到平衡点,才能在AI时代保持前端开发的活力与魅力。记住,代码的温度,永远来自开发者的手指。

最基础的类型检测工具——typeof, instanceof

typeofinstanceof 是 JavaScript 中两个最基础但也最容易让人困惑的类型检测工具。要深入理解它们的原理,我们需要从 JavaScript 的底层数据存储、类型系统和原型链机制入手。


typeof

typeof 是一个一元运算符,它的核心任务是返回一个代表操作数类型的字符串。其原理深入到 JavaScript 引擎是如何在底层存储和标识变量的。

1. 核心原理:底层类型标签(Type Tagging)

在 JavaScript 的早期实现中,值在引擎内部是由一个**类型标签(Type Tag)**和实际的数据值来表示的。这个类型标签存储在变量的机器码低位中,用于标识该值的类型。

  • 底层存储机制:JavaScript 引擎在存储变量时,会在内存中为变量分配空间,并用低位的1-3个比特位来存储其类型信息。
  • typeof 的工作方式:当你对变量使用 typeof 操作符时,JavaScript 引擎并不会去回溯变量的创建过程,而是直接读取这个变量在内存中机器码低位的类型标签,然后将其映射为对应的类型字符串返回。

2. 机器码的类型映射

不同的类型对应着不同的低位标识。一个常见的类型标签映射如下:

  • 000:对象(object)。
  • 010:浮点数(number)。
  • 100:字符串(string)。
  • 110:布尔值(boolean)。
  • 1:整数(number),整数类型标签是1,但会被归为 number 类型。
  • -2^30undefined

3. 著名的历史遗留问题:typeof null === 'object'

这是 JavaScript 中最著名的Bug之一,至今未被修复以保持兼容性。

  • 原因:如前所述,对象的类型标签是 000。而**null 在底层表示的是空指针,在大多数实现中,空指针的机器码全是 0**。因此,当 typeof 读取 null 的类型标签时,发现是 000,就错误地将其判断为 object

4. 特例:函数的识别

虽然函数在底层也是对象(类型标签是 000),但 typeof function(){} 返回的是 'function'。这是因为 JavaScript 引擎内部对可调用对象做了特殊处理。当 typeof 操作符检测到一个对象内部实现了 [[Call]] 方法时,它会特殊处理并返回 "function"


instanceof

instanceof 是一个二元运算符,用于检测一个对象的原型链上是否存在另一个构造函数prototype 对象。它的核心是原型链查找

1. 核心原理:原型链检查

instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。其内部机制可以简单地用以下代码模拟:

function myInstanceof(instanceObj, constructorFunc) {
    // 1. 获取实例对象的隐式原型(内部 [[Prototype]] 属性)
    let proto = Object.getPrototypeOf(instanceObj);

    // 2. 获取构造函数的显式原型
    let prototype = constructorFunc.prototype;

    // 3. 沿着原型链向上查找
    while (true) {
        // 4. 如果找到头(null)还没找到,返回 false
        if (proto === null) {
            return false;
        }
        // 5. 如果找到了匹配的原型,返回 true
        if (proto === prototype) {
            return true;
        }
        // 6. 继续向上一级查找
        proto = Object.getPrototypeOf(proto);
    }
}

2. 工作步骤详解

假设我们执行 obj instanceof Constructor,引擎会执行以下步骤:

  1. 获取隐式原型:获取左侧对象 obj 的内部 [[Prototype]] 属性(在浏览器中可以通过非标准 __proto__ 或标准 Object.getPrototypeOf() 访问)。
  2. 获取显式原型:获取右侧构造函数 Constructorprototype 属性。
  3. 循环比较:将 obj 的隐式原型与 Constructor 的显式原型进行比较。
    • 如果相等,返回 true
    • 如果不相等,则将 obj 的隐式原型的隐式原型(即原型链的上一级)取出来,再次与 Constructor.prototype 比较。
    • 这个过程持续进行,直到原型链的末端(即 null)。如果一直没找到相等的对象,则返回 false

3. 重要特征

  • 跨窗口问题instanceof 依赖于原型链,因此它不能跨不同的全局执行环境(例如,来自 iframe 的数组在父页面中使用 array instanceof parentWindow.Array 会返回 false),因为它们的原型链指向的是不同的 Array.prototype 对象。
  • 只能用于对象:由于 instanceof 的机制是查找原型链,对于原始类型(string, number, boolean 等),它们不是对象,没有原型链,因此直接用 instanceof 检测原始类型会始终返回 false

总结对比

特征 typeof instanceof
本质原理 读取变量机器码低位的类型标签 遍历左侧对象的原型链,查找右侧构造函数的 prototype
返回值 字符串(如 "string", "object", "function" 布尔值(true / false
适用场景 检测原始类型(除 null 外)和函数。 检测对象类型及其继承关系。
局限性 null 返回 "object";数组、日期等对象均返回 "object",无法细分。 不能跨窗口(iframe)使用;不能用于检测原始类型。

Playwright Component Testing 拆到底:组件怎么挂上去的,快照怎么在 CI 里不翻车

Playwright Component Testing 拆到底:组件怎么挂上去的,快照怎么在 CI 里不翻车

你写过单元测试,大概率用的 Jest + Testing Library。组件渲染在 jsdom 里,跑得飞快,但你心里清楚——jsdom 不是真浏览器。CSS 不生效,IntersectionObserver 要 mock,canvas 直接摆烂。

Playwright Component Testing(下面简称 CT)干的事情不一样:它把你的 React/Vue 组件丢进真实浏览器里跑。听起来像 E2E?不是。它没有完整的应用启动流程,只挂载你指定的那个组件。

这篇聊两件事:CT 模式下组件到底怎么挂上去的,以及视觉回归快照在 CI 里怎么搞才不会三天两头炸。

CT 的架构:三个进程在打配合

CT 跑起来之后,背后其实有三个角色:

┌─────────────┐     ┌──────────────┐     ┌──────────────┐
│  Test Runner │────▶│  Dev Server  │────▶│   Browser    │
│  (Node.js)  │     │  (Vite)      │     │  (Chromium)  │
└─────────────┘     └──────────────┘     └──────────────┘
       │                    │                     │
   测试代码             编译组件              真实渲染
   断言逻辑           HMR/bundling          真实 DOM/CSS

Test Runner 就是 Playwright Test 那套,跑在 Node 里。Dev Server 默认是 Vite(也支持 Webpack,但说实话现在没什么理由选 Webpack 了)。Browser 是 Chromium/Firefox/WebKit,真家伙。

关键在于:你的测试代码跑在 Node 里,但组件渲染在浏览器里。这俩是通过 WebSocket 通信的。

这意味着什么?你在测试里 console.log 一个组件的 props,打印在终端。但组件内部的 console.log 打印在浏览器 DevTools 里。刚上手的时候在这个地方困惑过一阵。

组件挂载:mount 背后发生了什么

先看最简单的用法:

// Button.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react'
import { Button } from './Button'

test('点击后文案变化', async ({ mount }) => {
  const component = await mount(<Button label="提交" />)

  await expect(component).toContainText('提交')
  await component.click()
  await expect(component).toContainText('已提交')
})

看着跟 Testing Library 差不多对吧?但 mount 这一步,链路完全不同。

React 的挂载链路

mount(<Button label="提交" />) 执行时,大致经过这几步:

1. Playwright 把 JSX 序列化成一个描述对象(组件路径 + props)
2. 通过 WebSocket 发给浏览器端的 "mount handler"
3. 浏览器端拿到描述,动态 import 对应的组件模块
4. 调用 React.createElement + ReactDOM.createRoot 挂载到一个空的 #root 上
5. 返回一个 Locator 给 Node 端,后续操作都通过这个 Locator

关键代码藏在 playwright-ct-react 包的 registerSource 里:

// 简化版的浏览器端挂载逻辑
window.__playwright_mount = async (rootElement, component) => {
  // component.type 是组件的 import 路径,不是组件本身
  // Vite 已经帮你编译好了,这里直接 resolve
  const Component = await __playwright_resolve(component.type)

  const root = ReactDOM.createRoot(rootElement)
  root.render(React.createElement(Component, component.props, ...component.children))

  return root
}

注意那个 __playwright_resolve——你的组件路径是在编译阶段就确定的。Playwright 的 Vite 插件会扫描测试文件里所有的 import,提前打包好。所以如果你动态拼组件路径,是跑不通的。

Vue 的挂载链路

Vue 的流程类似,但多了一步:

// Vue 的浏览器端挂载
window.__playwright_mount = async (rootElement, component) => {
  const Component = await __playwright_resolve(component.type)

  const app = createApp(Component, component.props)

  // Vue 特有:可以注入 plugins、provide 等
  if (component.hooksConfig) {
    await applyHooks(app, component.hooksConfig)
  }

  app.mount(rootElement)
  return app
}

Vue 比 React 多了个 hooksConfig,对应测试里的 beforeMount 钩子:

// Vue CT 独有的能力:挂载前注入 router、pinia 等
test('带路由的页面组件', async ({ mount }) => {
  const component = await mount(UserProfile, {
    props: { userId: '123' },
    hooksConfig: {
      // 这个配置会传到浏览器端,在 mount 之前执行
      router: true,
      pinia: { initialState: { user: { name: 'test' } } }
    }
  })
})

不过这个 hooksConfig 需要你自己在 playwright/index.ts 里实现对应的 hook 处理逻辑。Playwright 不会帮你自动注入 Vue Router 或 Pinia——它只提供机制,策略你自己定。

一个常见的坑:样式隔离

CT 模式下,组件是挂载在一个空白 HTML 页面上的。你的全局样式、CSS reset、主题变量——统统没有。

// ❌ 组件依赖全局 CSS 变量,但 CT 模式下没加载
// 渲染出来的按钮是白底黑字,跟线上完全不一样
test('按钮样式', async ({ mount }) => {
  const component = await mount(<ThemedButton />)
  // 样式全是错的,测了个寂寞
})

// ✅ 在 playwright/index.tsx 里引入全局样式
// 这个文件是浏览器端的入口,这里 import 的样式会生效
import '../src/styles/globals.css'
import '../src/styles/theme.css'

playwright/index.tsx(React)或 playwright/index.ts(Vue)是浏览器端的入口文件。全局样式、Provider、插件都在这里搞。很多人第一次用 CT 时组件渲染得乱七八糟,十有八九是这个文件没配好。

视觉回归快照:原理不复杂,工程化才是坑

Playwright 的截图对比用起来一行代码的事:

await expect(component).toHaveScreenshot('button-primary.png')

第一次跑,生成基准图。第二次跑,截新图,像素级对比。不一样就报错,同时生成三张图:expected、actual、diff。

对比算法

默认用的是 pixelmatch,逐像素比较。可以配容差:

await expect(component).toHaveScreenshot('card.png', {
  maxDiffPixelRatio: 0.01,    // 允许 1% 的像素差异
  // 或者用绝对值
  // maxDiffPixels: 100,       // 允许 100 个像素不同
  threshold: 0.2,              // 单个像素的颜色容差(0~1)
})

threshold 是给单个像素用的,处理抗锯齿之类的细微差异。maxDiffPixelRatio 是全局的,多少比例的像素不同算"变了"。

这两个值怎么调,完全看你的场景。图表类组件建议放宽一些,纯文本布局可以严一点。没有万能参数,都是试出来的。

CI 集成:这才是真正花时间的地方

本地跑 CT 没什么问题。一上 CI 就各种翻车。

问题一:字体渲染差异

同一个组件,macOS 和 Linux 渲染出来的字体就是不一样。亚像素渲染、字体 hinting、默认字体族——全不同。

# GitHub Actions 示例
jobs:
  visual-test:
    # ✅ 固定操作系统版本,别用 latest
    runs-on: ubuntu-22.04
    container:
      # ✅ 用 Playwright 官方镜像,字体和依赖都预装了
      image: mcr.microsoft.com/playwright:v1.52.0-jammy
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright test --project=ct

用 Playwright 官方 Docker 镜像是最稳的方案。它预装了各种字体包,渲染结果跟别的用同一镜像的环境高度一致。

但这就引出一个问题:本地开发用 macOS,CI 用 Linux 容器,基准图用谁的?

方案 A:基准图在 CI 上生成,本地开发只跑不对比
方案 B:本地也跑 Docker,保持环境一致
方案 C:维护两套基准图(别选这个,维护成本会让你后悔)

我个人倾向方案 A。基准图只在 CI 上生成和更新,提交到仓库里。本地开发时跑功能测试就行,视觉对比交给 CI。

问题二:快照更新的工作流

快照文件要不要提交到 Git?要。不然 CI 没有基准图可以对比。

但问题来了:快照更新的流程怎么搞?

# 快照更新的 CI workflow
name: Update Snapshots
on:
  workflow_dispatch:  # 手动触发
  pull_request:
    types: [labeled]  # 或者打标签触发

jobs:
  update:
    if: github.event.label.name == 'update-snapshots'
    runs-on: ubuntu-22.04
    container:
      image: mcr.microsoft.com/playwright:v1.52.0-jammy
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.head_ref }}
          token: ${{ secrets.GITHUB_TOKEN }}

      - run: npm ci
      - run: npx playwright test --update-snapshots

      # 自动 commit 更新后的快照
      - name: Commit updated snapshots
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add "**/*.png"
          git diff --staged --quiet || git commit -m "chore: update visual snapshots"
          git push

这里有个取舍:自动 commit 快照更新方便是方便,但你得确保 review 流程能 cover 住。不然某个 PR 偷偷改了样式,自动更新快照,没人看 diff 就合了——视觉回归测试等于白做。

我见过比较靠谱的做法是:快照变化时 CI 把 diff 图片贴到 PR comment 里,reviewer 必须肉眼确认。

      - name: Upload diff artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diff
          path: test-results/  # Playwright 默认把 diff 图存这
          retention-days: 7

问题三:CI 耗时优化

CT 比 Jest 慢,这没法回避。每个测试都要启动浏览器、编译组件、渲染、截图。一个 200 个组件的项目,全跑一遍可能要 5~10 分钟。

几个实际能压缩时间的手段:

// playwright-ct.config.ts
export default defineConfig({
  // 并行跑,worker 数量看 CI 机器配置
  workers: process.env.CI ? 4 : undefined,

  // 只跑 Chromium 就够了,视觉一致性不需要三个浏览器
  projects: [
    {
      name: 'ct',
      use: {
        ...devices['Desktop Chrome'],
        // 固定视口,避免截图尺寸不一致
        viewport: { width: 1280, height: 720 },
      },
    },
  ],

  // Vite 配置
  ctViteConfig: {
    build: {
      // 关掉 sourcemap,CI 上不需要
      sourcemap: false,
    },
  },
})

另一个大招:只跑变更相关的测试。

      # 只跑改动文件关联的 CT 测试
      - name: Run affected tests
        run: |
          CHANGED=$(git diff --name-only origin/main...HEAD -- 'src/components/**')
          if [ -n "$CHANGED" ]; then
            # 把改动的组件路径转成测试文件路径
            TEST_FILES=$(echo "$CHANGED" | sed 's/\.tsx$/.spec.tsx/' | tr '\n' ' ')
            npx playwright test $TEST_FILES
          else
            echo "No component changes, skipping CT"
          fi

粗暴但有效。精确的依赖分析可以用 madge 之类的工具做,但大部分项目用文件名匹配就够了。

CT 的边界:什么时候不该用

CT 不是万能的。几个场景它搞不定或者性价比不高:

路由跳转、多页面流程——这是 E2E 的活。CT 只能挂单个组件(或组件树),没有路由层。

复杂的后端交互——CT 里 mock API 比 E2E 还麻烦。组件内部的 fetch 在浏览器里执行,你得用 page.route() 拦截,不能用 Node 端的 mock。

test('列表加载', async ({ mount, page }) => {
  // 注意:API mock 要在 mount 之前设置
  await page.route('/api/users', async route => {
    await route.fulfill({
      json: [{ id: 1, name: 'test' }]
    })
  })

  const component = await mount(<UserList />)
  await expect(component.getByText('test')).toBeVisible()
})

纯逻辑组件——如果一个组件没有视觉输出(比如一个纯粹管理状态的 Provider),用 Jest 测就行了,没必要启动浏览器。

我的一个经验法则:CT 适合测"长什么样"和"交互后变成什么样"。纯逻辑用 Jest,跨页面流程用 E2E。三层测试不是互相替代的关系。

聊到这

Playwright CT 这套东西,架构上挺优雅的——Vite 编译、真实浏览器渲染、Node 端断言,各司其职。但工程化层面的坑不少,尤其是视觉快照上 CI 之后,字体渲染、环境一致性、快照更新流程,每个都得花时间调。

我觉得它最大的价值不在于替代 Jest,而是补上了 Jest + jsdom 覆盖不到的那块——组件在真实浏览器里长什么样、交互起来对不对。如果你的项目有设计系统或者组件库,CT + 视觉快照这套组合拳值得投入。如果只是业务页面,E2E 可能性价比更高。

对了,CT 目前还是 @playwright/experimental-ct-*,带着 experimental 前缀。API 稳定性上偶尔会有 breaking change,升级的时候留意一下 changelog。

递归:别再"展开脑补"了,学会"信任"才是关键

最近在做 LeetCode 的深度对象过滤题(2823)时,我遇到了一个困扰很久的问题:虽然知道递归是"函数调用自己",也能照着模板写出代码,但每次遇到递归都要在脑子里"展开"整个调用过程——factorial(3) 调用 factorial(2)factorial(2) 又调用 factorial(1)……稍微复杂一点就会晕。

后来我意识到,这种思维方式本身就是错的。真正的递归思维不是"展开脑补",而是"相信递归"。这个转变让我重新理解了递归的本质。

这篇文章是我重新学习递归的总结,不是权威教程,而是从识别、模式、实战、思想四个维度的完整梳理。如果你也在递归上遇到困难,希望这篇文章能帮到你。

一、准确识别:什么时候需要递归?

在学习具体技巧之前,我发现最重要的能力是:快速判断一个问题是否需要递归

递归问题的三大特征

根据我的观察,递归问题通常有以下特征:

特征 1:问题具有"自相似性"

大问题 = 小问题 + 小问题 + ...(结构相同)

例子:

  • 计算阶乘:n! = n × (n-1)!
  • 遍历文件夹:遍历文件夹 = 遍历文件 + 遍历子文件夹
  • 汉诺塔:移动 n 个盘子 = 移动 (n-1) 个盘子 + 移动 1 个盘子 + 移动 (n-1) 个盘子

这种"自相似性"是递归最核心的特征——问题可以分解成和自己结构相同但规模更小的子问题。

特征 2:数据结构是递归的

// 环境: JavaScript
// 场景: 典型的递归数据结构

// 树结构:节点包含子节点
const tree = {
  value: 1,
  children: [
    {
      value: 2,
      children: [
        { value: 4, children: [] },
        { value: 5, children: [] }
      ]
    },
    { value: 3, children: [] }
  ]
};

// 嵌套对象:对象包含对象
const nested = {
  user: {
    profile: {
      settings: {
        theme: 'dark'
      }
    }
  }
};

// 嵌套数组:数组包含数组
const comments = [
  {
    id: 1,
    text: '评论',
    replies: [
      { id: 2, text: '回复', replies: [] }
    ]
  }
];

关键特征

  • 不知道有多少层
  • 每一层结构相同
  • 循环无法处理(层数未知

特征 3:有明确的"最小单元"(终止条件)

能回答这两个问题,就是递归问题:

问题 1:最简单的情况是什么?

  1. 阶乘:n = 0 或 n = 1 时,直接返回 1

  2. 遍历树:节点没有子节点时,停止遍历

问题 2:如何缩小问题规模?

  1. 阶乘:n! = n × (n-1)!,问题从 n 缩小到 n-1

  2. 遍历树:处理当前节点,然后遍历每个子节点

识别清单:一眼判断

遇到问题时,我会用这个清单快速判断:

✅ 强烈提示需要递归

  • 题目出现"深度"、"嵌套"、"层级"、"树"、"图"
  • 数据结构是嵌套对象/嵌套数组/树/图
  • 题目要求"遍历所有可能"(组合、排列、路径)
  • 不知道数据有多少层

⚠️ 可能需要递归

  • 题目涉及"分治"思想(二分、归并)
  • 需要"回溯"(尝试-撤销-再尝试)
  • 问题可以对半分解

❌ 不需要递归

  • 简单的顺序遍历(一个 for 循环搞定)
  • 已知固定层数(几个嵌套循环搞定)
  • 数组/字符串的简单处理(map/filter/reduce)

常见递归问题分类

我把常见的递归问题归为四类:

分类 应用场景
数/图遍历 1) DOM 树遍历(查找特定元素)
2) 文件系统遍历(统计文件大小)
3) 组织架构遍历(查找员工)
4) 评论楼层(无限嵌套评论)
5) 菜单树(多级菜单展开)
数据转换 1) 深拷贝/深度克隆
2) 扁平化嵌套数组/对象
3) 对象深度过滤/映射
4) JSON 格式转换
5) 数据清洗(移除 null/undefined)
搜索/回溯 1) 路径查找(面包屑导航)
2) 组合生成(选择题所有答案)
3) 排列生成(密码破解)
4) 迷宫求解
分治算法 1) 二分查找(搜索优化)
2) 归并排序(大数据排序)
3) 快速排序
4) 计算次方(快速幂)

识别练习

这是我整理的一些练习题,可以测试一下判断能力:

1. 反转字符串                          → ❌ 用 reverse() 或循环
2. 计算嵌套对象的最大【深度】               → ✅ 递归(不知道多少层)
3. 数组求和                            → ❌ 用 reduce
4. 查找【二叉搜索树】中的节点                → ✅ 递归(树结构)
5. 打印 1-100                         → ❌ 循环
6. 获取【所有子孙评论】(无限嵌套)           → ✅ 递归(层数未知)
7. 对象数组按某字段排序                  → ❌ 用 sort
8. 【遍历】 React 组件树找某个组件           → ✅ 递归(组件树)
9. 扁平化三层嵌套数组                   → ❌ 已知层数,可以循环
10. 扁平化【任意深度】数组                  → ✅ 递归(深度未知)

判断的关键:如果能用固定次数的循环解决,就不需要递归。递归是为了处理"层数未知"的情况。

二、常用模式:递归函数库

通过整理常见问题,我发现大部分递归都可以归纳为几个固定模式。掌握这些模式,就能快速写出递归代码。

核心模板

模板 1:单路径递归(最常见)

// 环境: JavaScript
// 场景: 处理线性递归问题

function recursion(data) {
  // 1. 终止条件(base case)
  if (isBaseCase(data)) {
    return baseResult;
  }
  
  // 2. 处理当前层(可选)
  const currentResult = process(data);
  
  // 3. 递归子问题
  const subResult = recursion(getSubProblem(data));
  
  // 4. 合并结果
  return combine(currentResult, subResult);
}

// 例子:计算阶乘
function factorial(n) {
  if (n === 0) return 1;              // 终止条件
  return n * factorial(n - 1);        // 递归 + 合并
}

模板 2:多路径递归(树/图)

// 环境: JavaScript
// 场景: 处理树形结构

function recursion(node) {
  // 1. 终止条件
  if (!node) return baseResult;
  
  // 2. 处理当前节点
  const currentResult = process(node);
  
  // 3. 递归所有子节点
  const childResults = node.children.map(child => 
    recursion(child)
  );
  
  // 4. 合并结果
  return combine(currentResult, ...childResults);
}

// 例子:计算树的最大深度
function maxDepth(node) {
  if (!node) return 0;
  
  const depths = node.children.map(child => maxDepth(child));
  return 1 + Math.max(...depths, 0);
}

模板 3:回溯递归(搜索)

// 环境: JavaScript
// 场景: 生成所有可能的组合

function backtrack(path, choices, result) {
  // 1. 终止条件:路径完整
  if (isComplete(path)) {
    result.push([...path]);
    return;
  }
  
  // 2. 遍历所有选择
  for (const choice of choices) {
    // 做选择
    path.push(choice);
    
    // 递归(缩小选择范围)
    backtrack(path, getNextChoices(choices, choice), result);
    
    // 撤销选择(回溯)
    path.pop();
  }
}

// 例子:生成所有子集
function subsets(nums) {
  const result = [];
  
  function backtrack(path, start) {
    result.push([...path]);
    
    for (let i = start; i < nums.length; i++) {
      path.push(nums[i]);
      backtrack(path, i + 1);
      path.pop();
    }
  }
  
  backtrack([], 0);
  return result;
}

实用递归函数库

下面是我整理的 5 个实用递归函数,几乎覆盖了日常开发的所有场景:

函数 1:深度遍历(DFS)

// 环境: JavaScript
// 场景: 遍历树/图的所有节点

function dfs(node, visit) {
  if (!node) return;
  
  // 访问当前节点
  visit(node);
  
  // 递归访问子节点
  if (node.children) {
    node.children.forEach(child => dfs(child, visit));
  }
}

// 使用:统计所有节点
let count = 0;
dfs(tree, () => count++);
console.log(count);

// 使用:查找特定节点
let found = null;
dfs(tree, node => {
  if (node.id === targetId) found = node;
});

函数 2:深度映射(Deep Map)

// 环境: JavaScript
// 场景: 转换嵌套结构的每个值

function deepMap(obj, fn) {
  // 终止条件:基础类型
  if (typeof obj !== 'object' || obj === null) {
    return fn(obj);
  }
  
  // 数组:递归映射
  if (Array.isArray(obj)) {
    return obj.map(item => deepMap(item, fn));
  }
  
  // 对象:递归映射
  const result = {};
  for (const key in obj) {
    result[key] = deepMap(obj[key], fn);
  }
  return result;
}

// 使用:所有数字乘以 2
const data = { a: 1, b: { c: 2, d: [3, 4] } };
const doubled = deepMap(data, value => 
  typeof value === 'number' ? value * 2 : value
);
console.log(doubled);
// { a: 2, b: { c: 4, d: [6, 8] } }

函数 3:深度过滤(Deep Filter)

// 环境: JavaScript
// 场景: 过滤嵌套结构,移除不符合条件的值(LeetCode 2823)

function deepFilter(obj, fn) {
  // 终止条件:基础类型
  if (typeof obj !== 'object' || obj === null) {
    return fn(obj) ? obj : undefined;
  }
  
  // 数组:递归过滤
  if (Array.isArray(obj)) {
    const filtered = obj
      .map(item => deepFilter(item, fn))
      .filter(item => item !== undefined);
    return filtered.length > 0 ? filtered : undefined;
  }
  
  // 对象:递归过滤
  const result = {};
  for (const key in obj) {
    const filtered = deepFilter(obj[key], fn);
    if (filtered !== undefined) {
      result[key] = filtered;
    }
  }
  return Object.keys(result).length > 0 ? result : undefined;
}

// 使用:移除所有负数
const data = { a: 1, b: -2, c: { d: 3, e: -4 } };
const positive = deepFilter(data, x => x > 0);
console.log(positive); // { a: 1, c: { d: 3 } }

函数 4:深度克隆(Deep Clone)

// 环境: JavaScript
// 场景: 完全复制嵌套对象(处理循环引用)

function deepClone(obj, map = new WeakMap()) {
  // 处理循环引用
  if (map.has(obj)) return map.get(obj);
  
  // 终止条件:基础类型
  if (typeof obj !== 'object' || obj === null) {
    return obj;
  }
  
  // 特殊对象
  if (obj instanceof Date) return new Date(obj);
  if (obj instanceof RegExp) return new RegExp(obj);
  
  // 数组/对象:递归克隆
  const clone = Array.isArray(obj) ? [] : {};
  map.set(obj, clone);
  
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      clone[key] = deepClone(obj[key], map);
    }
  }
  
  return clone;
}

// 使用:克隆复杂对象
const original = {
  date: new Date(),
  regex: /test/g,
  nested: { a: 1 }
};
const cloned = deepClone(original);
cloned.nested.a = 999;
console.log(original.nested.a); // 1(没有被修改)

函数 5:路径查找(Find Path)

// 环境: JavaScript
// 场景: 找到目标节点的路径

function findPath(tree, targetId, path = []) {
  // 找到目标
  if (tree.id === targetId) {
    return [...path, tree.id];
  }
  
  // 递归查找子节点
  if (tree.children) {
    for (const child of tree.children) {
      const found = findPath(child, targetId, [...path, tree.id]);
      if (found) return found;
    }
  }
  
  return null;
}

// 使用:查找组织架构中的员工路径
const org = {
  id: 1, name: 'CEO',
  children: [
    {
      id: 2, name: 'CTO',
      children: [
        { id: 4, name: '研发经理' }
      ]
    }
  ]
};

console.log(findPath(org, 4)); // [1, 2, 4]

递归优化技巧

掌握了基础模式后,还需要了解一些优化技巧:

技巧 1:记忆化(Memoization)

// 环境: JavaScript
// 场景: 避免重复计算

// 斐波那契(慢,大量重复计算)
function fib(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
}

console.time('fib(40)');
fib(40); // 几秒钟
console.timeEnd('fib(40)');

// 记忆化版本(快)
function fibMemo(n, memo = {}) {
  if (n <= 1) return n;
  if (memo[n]) return memo[n]; // 已计算过,直接返回
  
  memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
  return memo[n];
}

console.time('fibMemo(40)');
fibMemo(40); // 瞬间完成
console.timeEnd('fibMemo(40)');

技巧 2:转迭代(避免栈溢出)

// 环境: JavaScript
// 场景: 深度很深的情况,转为迭代

// 递归版本(深度 > 10000 可能栈溢出)
function traverseRecursive(tree, visit) {
  visit(tree);
  if (tree.children) {
    tree.children.forEach(child => traverseRecursive(child, visit));
  }
}

// 迭代版本(用栈模拟递归)
function traverseIterative(tree, visit) {
  const stack = [tree];
  
  while (stack.length > 0) {
    const node = stack.pop();
    visit(node);
    
    if (node.children) {
      // 注意:倒序入栈,保证顺序一致
      for (let i = node.children.length - 1; i >= 0; i--) {
        stack.push(node.children[i]);
      }
    }
  }
}

三、真实场景:递归在业务中的应用

理论总是抽象的,让我看看实际开发中哪些地方会用到递归。根据我的经验,前端开发中递归最常见的场景是处理树形数据。

场景 1:无限层级评论系统

这可能是我遇到最多的递归场景。

// 环境: React
// 场景: 递归渲染评论列表

// 数据结构
const comments = [
  {
    id: 1,
    text: '一级评论',
    author: 'Alice',
    replies: [
      {
        id: 2,
        text: '二级评论',
        author: 'Bob',
        replies: [
          {
            id: 3,
            text: '三级评论',
            author: 'Charlie',
            replies: []
          }
        ]
      }
    ]
  }
];

// 递归组件
function CommentList({ comments, level = 0 }) {
  return (
    <div style={{ marginLeft: level * 20 }}>
      {comments.map(comment => (
        <div key={comment.id}>
          <div className="comment">
            <strong>{comment.author}</strong>: {comment.text}
          </div>
          
          {/* 递归渲染子评论 */}
          {comment.replies && comment.replies.length > 0 && (
            <CommentList 
              comments={comment.replies} 
              level={level + 1} 
            />
          )}
        </div>
      ))}
    </div>
  );
}

// 常见操作:统计总评论数
function countComments(comments) {
  let count = comments.length;
  
  comments.forEach(comment => {
    if (comment.replies) {
      count += countComments(comment.replies); // 递归
    }
  });
  
  return count;
}

场景 2:组织架构树

// 环境: JavaScript
// 场景: 处理公司组织架构

const org = {
  id: 1,
  name: 'CEO',
  title: '首席执行官',
  children: [
    {
      id: 2,
      name: 'CTO',
      title: '技术总监',
      children: [
        { id: 4, name: '张三', title: '研发经理', children: [] }
      ]
    }
  ]
};

// 操作:查找某个员工的上级链路
function findManagerChain(org, employeeId, chain = []) {
  if (org.id === employeeId) {
    return [...chain, org.name];
  }
  
  if (org.children) {
    for (const child of org.children) {
      const found = findManagerChain(
        child, 
        employeeId, 
        [...chain, org.name]
      );
      if (found) return found;
    }
  }
  
  return null;
}

console.log(findManagerChain(org, 4)); 
// ['CEO', 'CTO', '张三']

// 操作:扁平化组织架构(用于导出 Excel)
function flattenOrg(org, level = 0, result = []) {
  result.push({
    id: org.id,
    name: org.name,
    title: org.title,
    level: level
  });
  
  if (org.children) {
    org.children.forEach(child => {
      flattenOrg(child, level + 1, result);
    });
  }
  
  return result;
}

场景 3:菜单权限过滤

// 环境: JavaScript
// 场景: 根据用户权限过滤菜单树

const allMenus = [
  {
    id: 1,
    name: '用户管理',
    permission: 'user',
    children: [
      { id: 2, name: '用户列表', permission: 'user.list' },
      { id: 3, name: '添加用户', permission: 'user.add' }
    ]
  },
  {
    id: 5,
    name: '系统设置',
    permission: 'system',
    children: [
      { id: 6, name: '角色管理', permission: 'system.role' }
    ]
  }
];

// 递归过滤菜单
function filterMenus(menus, permissions) {
  return menus
    // 1. 过滤当前层
    .filter(menu => permissions.includes(menu.permission))
    // 2. 递归过滤子菜单
    .map(menu => ({
      ...menu,
      children: menu.children 
        ? filterMenus(menu.children, permissions)
        : undefined
    }))
    // 3. 移除没有子菜单的父菜单
    .filter(menu => 
      !menu.children || menu.children.length > 0
    );
}

// 使用
const userPermissions = ['user', 'user.list', 'user.add'];
const visibleMenus = filterMenus(allMenus, userPermissions);

什么时候不该用递归?

在实际开发中,我也踩过一些坑:

// 环境: JavaScript
// 场景: 不适合递归的情况

// ❌ 错误示例:深度很深的数据
function createDeepNesting() {
  let comment = { id: 1, replies: [] };
  let current = comment;
  
  // 创建 10000 层嵌套
  for (let i = 2; i <= 10000; i++) {
    current.replies[0] = { id: i, replies: [] };
    current = current.replies[0];
  }
  
  return comment;
}

const deepComment = createDeepNesting();
// countComments([deepComment]); // 栈溢出!

// ✅ 正确做法:转为迭代
function countCommentsIterative(comments) {
  const stack = [...comments];
  let count = 0;
  
  while (stack.length > 0) {
    const comment = stack.pop();
    count++;
    
    if (comment.replies) {
      stack.push(...comment.replies);
    }
  }
  
  return count;
}

console.log(countCommentsIterative([deepComment])); // 正常运行

判断标准

  • 数据深度 < 100 层 → 安全使用递归
  • 数据深度 100-1000 层 → 谨慎使用,考虑加深度限制
  • 数据深度 > 1000 层 → 必须转为迭代

四、递归思想:从"展开脑补"到"相信递归"

在掌握了递归的识别、模式、实战之后,我发现最大的障碍其实是思维方式

初学者的误区:"展开脑补"

这是我之前的思考方式:

// 计算 factorial(3)
function factorial(n) {
  if (n === 0) return 1;
  return n * factorial(n - 1);
}

// 我的思路(错误):
// factorial(3) 调用 factorial(2)
// factorial(2) 调用 factorial(1)
// factorial(1) 调用 factorial(0)
// factorial(0) 返回 1
// factorial(1) = 1 * 1 = 1
// factorial(2) = 2 * 1 = 2
// factorial(3) = 3 * 2 = 6

// 问题:
// 1. 脑子里要记住整个调用栈
// 2. 稍微复杂一点就晕了
// 3. 根本不理解递归的本质

正确的思维:"相信递归"

后来我意识到,应该这样思考:

// 正确的思路:
// factorial(3) = 3 * factorial(2)
// 
// 我不需要知道 factorial(2) 内部怎么实现
// 我只需要"相信"它会返回正确结果(2)
// 所以 factorial(3) = 3 * 2 = 6
//
// 这就是"信任"的力量

关键洞察

写递归时,不要试图"展开"整个过程,只需要:

  1. 定义最简单的情况(终止条件)
  2. 假设递归调用会正确处理子问题
  3. 用子问题的结果构造当前问题的解

这种"信任"让复杂问题变简单

递归与数学归纳法

我发现递归和高中学的数学归纳法本质上是一回事:

数学归纳法

证明命题 P(n) 对所有 n 成立:

  1. 基础:证明 P(1) 成立
  2. 归纳:假设 P(k) 成立,证明 P(k+1) 也成立

递归

解决问题 Problem(n):

  1. 终止条件:处理最简单的情况
  2. 递归调用:假设 Problem(n-1) 已解决,构造 Problem(n) 的解

例子

// 环境: JavaScript
// 场景: 数组求和

// 数学归纳法思路:
// 基础:空数组的和是 0
// 归纳:假设 sum(arr[1:]) 正确
//      则 sum(arr) = arr[0] + sum(arr[1:])

// 递归实现:
function sum(arr) {
  // 基础情况
  if (arr.length === 0) return 0;
  
  // 归纳步骤:相信 sum(arr.slice(1)) 会返回正确结果
  return arr[0] + sum(arr.slice(1));
}

console.log(sum([1, 2, 3, 4])); // 10

递归的局限与权衡

虽然递归很优雅,但也有代价:

// 环境: JavaScript
// 场景: 斐波那契数列

// 朴素递归(慢)
function fib(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
}

// 问题:大量重复计算
// fib(5) = fib(4) + fib(3)
//        = (fib(3) + fib(2)) + fib(3)
// fib(3) 被计算了两次!

console.time('fib(40)');
fib(40); // 几秒钟才能算完
console.timeEnd('fib(40)');

// 优化:记忆化
function fibMemo(n, memo = {}) {
  if (n <= 1) return n;
  if (memo[n]) return memo[n];
  
  memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
  return memo[n];
}

console.time('fibMemo(40)');
fibMemo(40); // 瞬间完成
console.timeEnd('fibMemo(40)');

性能权衡

维度 递归 迭代
可读性 ⭐⭐⭐⭐⭐ ⭐⭐⭐
性能 ⭐⭐⭐ ⭐⭐⭐⭐⭐
内存 ⭐⭐ ⭐⭐⭐⭐⭐
适用场景 嵌套结构 所有场景

何时选择

适合递归:

✅ 问题天然递归(树、图、嵌套数据)

✅ 代码清晰度 > 性能

✅ 数据规模不大

✅ 可以用记忆化优化

不适合递归:

❌ 简单循环能解决

❌ 深度很深(栈溢出风险)

❌ 性能关键路径

❌ 大量重复计算且无法记忆化

延伸思考

在整理这些内容的过程中,我产生了一些新的疑问:

1. 尾递归优化在 JavaScript 中的支持?

// 环境: JavaScript (严格模式)
// 场景: 尾递归优化

'use strict';

// 普通递归
function sum(n) {
  if (n === 0) return 0;
  return n + sum(n - 1); // 不是尾递归
}

// 尾递归
function sumTail(n, acc = 0) {
  if (n === 0) return acc;
  return sumTail(n - 1, acc + n); // 最后一步是递归调用
}

// 问题:
// ES6 规范要求支持尾递归优化
// 但实际上只有 Safari 支持
// Chrome、Firefox 都不支持

// 所以在生产环境中,尾递归优化靠不住

我的理解是,虽然尾递归优化很优雅,但在 JavaScript 中实用价值有限。如果真的需要处理深度很深的递归,还是转为迭代更靠谱。

2. 递归与函数式编程

递归是函数式编程的基石。在 JavaScript 中如何平衡递归的优雅性和实用性?这是一个值得深入思考的问题。

小结

通过这次系统梳理,我对递归有了全新的认识:

核心要点

  1. 识别递归问题:三大特征、识别清单、四大分类
  2. 掌握递归模式:三个核心模板、实用函数库、优化技巧
  3. 真实场景应用:评论系统、组织架构、菜单权限等
  4. 理解递归思想:"信任"比"展开"更重要

递归不只是一种编程技巧,更是一种思维方式。从"展开脑补"到"信任递归",这个转变需要时间和练习。

如果这篇文章对你有帮助,欢迎交流讨论。如果你有不同的理解或补充,也请不吝赐教。

参考资料

腾讯终于对个人开放了,5 分钟在 QQ 里养一只「真能干活」的 AI 😍😍😍

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。 很多人第一次打开 OpenClaw,会下意识把它当成"接在微信或 Slack 上的聊天机器人"。这种理解只对了一半。从架构上看,OpenClaw 更像一个网关:它站在你和一堆能力之间,负责路由、鉴权、记忆和工具调用。真正决定你能做多少事的,不是对话框有多好看,而是背后接了多少"身体"——也就是 Skills。

3月7日消息,腾讯面向个人用户开放了 QQ 开放平台的机器人创建权限。此前这项能力仅对企业开放,现在任何持有 QQ 账号的个人开发者都可以注册并创建自己的 QQ 机器人。配合 AI 智能体框架 OpenClaw,整个绑定流程只需不到五分钟。

注册与创建只需几分钟

打开 QQ 开放平台官网,用手机 QQ 扫码就能完成开发者账号注册,不需要填写任何企业资质或繁琐表单。

image.png

扫码登录后,点击一次"创建机器人"按钮,平台会立即为你生成一个专属的 AppIDAppSecret,这两个凭据就是后续与 OpenClaw 绑定的钥匙。

image.png

三条命令完成绑定

拿到 AppIDAppSecret 之后,切到 OpenClaw 控制台,依次执行以下三条命令:

openclaw plugins install @llvera/qqbot@latest
openclaw channels add --channel qqbot --token <你的 token>
openclaw gateway restart

第一条安装 QQ Bot 插件,第二条把你的 token 写入频道配置,第三条重启网关让配置生效。整个过程不超过五分钟。

真实上手效果

配置完成后,打开手机 QQ 直接和机器人说话就行,和普通聊天窗口没有任何区别。

问它"我现在有什么 agent",它会把 openclaw.json 里登记的所有 Agent 及其当前状态一次列出来,还会告知各 Agent 之间的通信是否已启用。

2c403f2be115f7b8c368884fca0b4bad

更有意思的是,它能直接操作你本机的文件系统。让它列出桌面 video 目录里有什么文件,它会按时间分组整理成一张表格返回给你——比如 32 个 .MOV 原始视频文件,按 2024 年、2025 年 1 月……逐年分组,每个文件名和体积一目了然,总计约 51.5 GB。

f8bf698675db3653529ac084d3d725fb

如果你进一步说"帮我截第 1 秒、第 5 秒、第 10 秒、第 14 秒的画面",它会直接调用本地工具提取视频帧,以 4K 原始分辨率(3840×2160)输出截图,保存到桌面的 frames 文件夹,然后把截图一张一张发回给你。

b44b8a9c44b816616cd784e4ada860bd

31f7d731b84e17995d10c99c10e12def

你说"把图片发给我,我要看",它就把文件复制到桌面再通过 QQ 直接发送过来,不需要你去文件管理器里翻。

2fd5481de03b4d885197ddb6f482b443

这些操作全程没有打开任何 App,只是在 QQ 聊天框里说了几句话。

OpenClaw 是什么

OpenClaw(曾用名 ClawdbotMoltbot)是一款开源 AI 智能体框架,由程序员彼得·斯坦伯格(Peter Steinberger)开发,核心语言为 TypeScript,采用标志性的"蓝色龙虾"图标设计。它的 slogan 是 "The AI that actually does things",意思是真正能干活的 AI。

与传统对话式 AI 不同,OpenClaw 不是只能回答问题的聊天机器人,而是能够执行任务的数字员工。它通过自然语言指令驱动,可在本地或私有云环境中完成文件管理、代码执行、网页抓取、API 调用等操作。

image.png

用一句话概括,它实现的是从"建议"到"执行"的跨越。

几个值得关注的细节

一个 QQ 号最多可以创建 5 个独立的 QQ 机器人。

绑定至 OpenClaw 环境后,机器人支持接收和发送 Markdown、图片、语音、文件等多种格式的消息,手机端 QQ 和桌面端 QQ 均可正常使用,不限终端。

另外,OpenClaw 的工具调用具备一定的容错能力。当某个工具不可用时,它会自动降级切换到备用工具。例如搜索插件不可用时,它会调用浏览器工具直接打开网站抓取内容,整理好再返回给你,整个过程无需人工干预。

OpenClaw 能帮自媒体人做什么

对自媒体从业者来说,OpenClaw 能覆盖的场景远不止联网搜索。从内容生产到数据复盘,它可以接管那些重复、耗时却省不了的环节。

image.png

目前能直接上手的场景包括:

  • 公众号写作,从选题调研到完整初稿,按模板排版,两小时内从灵感到成品
  • 小红书爆款,自动学习你的语气风格,一次给出 5 个备选标题
  • 视频内容拆解,转写文字稿、提取金句字幕、生成话题标签和简介文案,自动适配多个平台
  • 素材批量处理,一次处理 50 个文件,批量改名、自动归类
  • 品牌合作跟进,整理合作邀约信息,不让 offer 漏掉
  • 数据复盘报告,汇总阅读量和互动率,告诉你本周内容的表现和下一步方向

这些事情放在以前,要么手动一件一件做,要么需要在多个独立工具之间来回切换。现在通过自然语言指令,交给 OpenClaw 统一处理就行。

e42bf5f8926df2a849630c92eb53c146.png

目前我正在组建 OpenClaw 中文社区,如果你也在探索用 AI 工具提效、或者想一起玩转这套工作流,欢迎添加我的微信 yunmz777,拉你进群交流。

React 正在演变为一场不可逆的赛博瘟疫:AI 投毒、编译器迷信与装死的官方

React 正在演变为一场不可逆的赛博瘟疫:AI 投毒、编译器迷信与装死的官方

React 正在沦为前端圈的“孔乙己”:脱不下的长衫与失控的基建

React 团队一到前端圈,所有敲代码的人便都看着他笑,有的叫道:“React,你又悄摸摸搞出几个极其拧巴的缝合怪 API!”

他不回答,对柜里说:“加两套重型编译器,要一碟 useEffectEvent。”便排出几个难懂的心智模型。

他们又故意的高声嚷道:“你一定又在底层偷偷搞副作用(Side Effects)了!”

React 睁大眼睛说:“你怎么这样凭空污人清白……”

“什么清白?我前天亲眼见你的 useEffect 里异步网络请求满天飞,闭包陷阱套着闭包陷阱,连个最新状态都拿不到,还在 commit 阶段偷偷改 Ref,被全网吊着打。”

React 便涨红了脸,额上的青筋条条绽出,争辩道:“异步……异步的事怎么能叫副作用呢!……那叫代数效应(Algebraic Effects)!React 调度器里的事,能算不纯么?”接连便是些难懂的话,什么“Fiber 架构”,什么“并发渲染(Concurrent)”,什么“UI 是状态的纯函数映射”,引得整个 Web 社区内外充满了快活的空气。


你明明一身历史包袱,底层 DOM 突变和时序补丁糊了一层又一层,还要死死捂着那件打满补丁的「函数式编程」长衫装清高。你连业务代码里最基本的异步抓取和状态流转都做不到开箱即用,还天天搁这儿给开发者念经,说什么“要保持纯洁,要无副作用”。

既然你端着全球最大前端基建的架子,那我就得用配得上你这份傲慢的严苛标准来伺候你。我不听你那套自欺欺人的八股文,也不陪你玩“心智模型”的文字游戏。我只负责把你剥个精光,拿着放大镜逐行扒开你 ReactFiberCommitWork.js 的源码,把你装死关掉的 Issue 挨个掘出来。


一、useEffectEvent:设计拧巴,文档更拧巴

1.1 经典恶心场景:闭包逼你把「只想读一次」的东西写进依赖

所有写过 React 的人都遇到过这种事:

你写了一个聊天室组件,连接成功后要弹个提示,提示要用当前的主题色 theme

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(roomId)
    connection.on('connected', () => {
      showToast('连接成功!', theme) // 这里用到了 theme
    })
    connection.connect()

    return connection.disconnect
    // ...
  }, [roomId, theme]) // 🚨 噩梦来了:React 逼你把 theme 加进依赖数组
}

问题在哪?

你只是想在弹窗时读取一下最新的颜色(theme),但因为 React 的闭包机制,你被迫把 theme 写进依赖数组。
结果就是:用户随便切个暗黑模式(theme 变了),你的聊天室就会断开重连一次。 这简直是灾难。

1.2 官方的解法:useEffectEvent

React 在 19.2 给出了 useEffectEvent:把「需要最新值、但不想加依赖」的逻辑包起来。

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showToast('连接成功!', theme) // 这里总能拿到最新的 theme
  })

  useEffect(() => {
    const connection = createConnection(roomId)
    connection.on('connected', () => {
      onConnected()
    })
    connection.connect()
  }, [roomId]) // 🎉 roomId 变了才重连,theme 终于不用加进依赖了
}

听起来是个好东西。

1.3 槽点:极度反人类的调用限制

React 给这个 API 加了一条硬性限制

"A function wrapped in useEffectEvent can't be called during rendering."

也就是说:这个函数绝对不准在组件渲染过程中调用,只能在 Effect 或者事件里调用。
如果你把它传给子组件,子组件在 render 里 调了一下,整个应用直接报错白屏

为什么限制这么死?

源码里,这个函数的最新值是在 DOM 渲染完之后的 commit 阶段 才挂到内部 ref 上的(见 ReactFiberCommitWork.js)。渲染的时候它还是个空壳或旧值。官方解决不了这时序问题,于是粗暴地用报错来阻止开发者

相比之下社区是怎么做的?

社区早就用 useRef 自己封了一个 useLatestCallback(或等价物):包装函数引用稳定,调用时总是执行当前 ref 里的最新函数。
没有任何调用时机的限制,想在哪调在哪调——render、effect、事件、传给子组件,都不会因为「during rendering」被拦。

详见我的 useLatestCallback 实现:github.com/beixiyo/rea…

官方无视社区极其好用的作业不抄,偏要自己造一个内部实现极其拧巴、强加各种规则、心智负担极重的 useEffectEvent。这就是设计拧巴,文档更拧巴

引用:


二、赌徒的执念:从 Prepack 到 Compiler,用魔法掩盖缺陷

2.1 Prepack:React 团队的「前科」

一句话:Prepack 是 Meta(Facebook)在 2017 年左右搞的一个失败的 JavaScript 编译器项目

当时 Facebook 异想天开:既然 JS 运行慢,能不能在打包编译的时候就把能算出来的代码提前算好?

比如 let a = 1 + 2 在编译时直接变成 let a = 3。后来他们试图把 Prepack 用在 React 上,想在编译期把组件提前「折叠」优化。

但 JavaScript 太动态了,这个饼根本画不圆,项目黄了,被官方放弃

为什么文章里要提它?

因为 React 团队对「编译器」有一种病态的执念
当社区都在拥抱 Signal(用运行时的细粒度响应式解决性能问题)时,React 偏不。他们觉得:当年 Prepack 虽然失败了,但我现在搞个缩小版叫 React Compiler,继续搞编译期魔法。
提 Prepack 就是为了扒皮:React 宁可在「编译期优化」这条曾走过弯路的老树上吊死,也不肯听社区的意见去换一套更先进的响应式模型(Signal)。

2.2 不承认模型问题,用编译器堆屎山

面对依赖数组带来的灾难,社区呼吁引入 Signal 这种现代化的细粒度响应式。React 选了啥?选了他们当年失败过的「编译期优化」老路。

从当年胎死腹中的 Prepack,到如今的 React Compiler,官方展现出一种惊人的技术执念
我们宁可造一个巨型编译器来强行分析依赖、强行插入缓存,也绝不承认「组件级渲染 + 依赖数组」这个底层模型本身已经落后了。

这不叫优雅的工程演进,这叫为了掩盖第一代屎山的恶臭,强行喷香水

  • React Compiler 1.0(2025-10)称:"automatically optimizes components and hooks without requiring rewrites"。也就是:靠编译期自动优化兜住现有模型,而不是改模型。
  • React Labs 的 Automatic Effect Dependencies:嘴上说「effect 难理解」,手上在「依赖数组 + 组件树」上继续叠编译器与 IDE,而不是提供更简单的抽象(Signal 或至少 useLatestCallback 这类通用稳定回调)。

引用:


三、Signal:社区要,官方不接,关 Issue 不解释

3.1 社区直接问:为什么不做 Signal?

GitHub #27164"feature: make react more reactive (feedback for future)"):

  • 作者提出:useState/useEffect 本质是 observable + subscriber,但「读到的不是最新值」「依赖数组难写」,"Just implement signals. It will reduce complexity."
  • 结果没有任何 React 官方成员回复。Issue 被 stale bot 自动关掉(Resolution: Stale, closed as not_planned)。
    社区认真提的「换一种更简单的模型」被冷处理,连一句「我们考虑过,因为 XXX 所以选 Compiler」都没有。

GitHub #31393"React Why Not Consider Support Signals"):

  • 作者问:为什么官方不考虑 Signal 这种显而易见的方案?
  • 结果Joseph Savona(React Compiler 负责人)关闭,唯一一句回复
    "We covered this pretty thoroughly in our React Conf talk about performance." 附了一个 React Conf 演讲链接。
    也就是说:没有在 issue 里写任何「为什么不支持 Signal」「技术选型理由」,而是把问题推到「我们在一场演讲里讲过」——不写进文档、不写进 RFC、不留在 issue 里,等于让后来者自己去找视频听,且无法被搜索和引用。

3.2 这算啥?

  • #27164:零官方回复,stale 关掉 → 不接话、不解释
  • #31393:仅回复「Conf 里讲过」,不给正文、不给摘要 → 死鸭子嘴硬:既不承认「我们就是选 Compiler 不选 Signal」,也不在公开文本里说明选型理由。

身为被全世界当基建的库,对「为什么不做 Signal」这种级别的讨论,不在 issue / 博客 / RFC 里留下可检索的、负责任的说明,而是用「去听我们某次 Conf」打发,这是对社区反馈的轻慢,也是技术傲慢

替代品:满分作业拍在脸上,React 偏不抄

其实证明 React 底层不仅能上 Signal、而且能上得极其优雅的铁证,早就摆在那里了。

社区掏出的 Preact Signals,直接把运行时细粒度更新的满分作业拍在了官方脸上。它完美兼容现有的组件模型,直接证明了闭包陷阱完全可以靠一套现代化的响应式机制来根治。

最打脸的是什么?人家一个第三方库,在根本碰不到你 React 核心源码的情况下,都能靠外挂把这套机制跑得明明白白。而你官方握着 Fiber 调度器的生杀大权,却选择视而不见,死活说“做不了”。

只能说 React 团队这几年确实是写编译器写魔怔了。简单的运行时解法他们不屑于做,正路不走,非得拉坨大的出来,好像不搞个重型编译链就配不上大厂的 KPI 一样。

更可笑的是,如果你现在想用 Preact Signals,你会发现它跟官方硬推的 React Compiler 是直接冲突的。

为什么冲突?因为 React Compiler 根本就不是什么优雅的架构演进,它本质上就是一个极其自负的 AST 爆改插件。你原本干干净净的代码,被它过一遍,AST 树上全是被它强行塞进去的 useMemo 和缓存标记,代码执行轨迹完全成了一个黑盒。

搞得这么抽象,不知道的还以为你搁这做 JIT 呢。

(如果你也受够了官方这种强行喂屎的黑盒操作,想看看怎么在 React 屎山里自救,详见我踩坑两年写出的血泪总结:《花了两年用遍了 React 所有状态管理库,我选出了最现代化的 Signal 方案》)

引用:


四、技术选型:Compiler 而非 Signal,且不写清楚

从公开信息能拼出的「为什么是 Compiler 而不是 Signal」大致是:

  1. 架构和历史包袱:React 的调度、并发、SSR、reconciler 都是按「组件树 + 依赖数组」建的。原生 Signal 是细粒度订阅,要接进去等于在核心里再塞一套响应式模型,改动面巨大。
  2. 已经押注 Compiler:从 Prepack 到 React Compiler,团队长期押「用编译期优化」来逼近「少重渲染、少依赖心智」,而不是在 runtime 换一套响应式。公开承认「该上 Signal」等于承认这条路线不够,所以不会在官方叙事里这么说
  3. 生态与兼容:全世界都是 setState + deps,真要内置 Signal 要么长期双轨,要么 breaking 大改,政治和生态成本都高。

但这些没有在任何官方博客、RFC 或上述 issue 里被系统写出来
选型结果就是:Compiler + 更多工具链;对 Signal 的态度是:不计划、不接题、关 issue 时指到 Conf 视频
技术选型存在,但解释不透明;社区问「为啥不 Signal」得不到可检索的、负责任的答复——这就是技术傲慢:我们怎么做你们就怎么用,理由你们自己找。


五、为什么非要逮着 React 骂?

我可以不用 React,但躲不开

总有人问我:“既然你这么懂,自己封装一套解法不就行了?干嘛天天逮着骂?”

说实话,React 这一地鸡毛的闭包陷阱、渲染地狱,我早就摸透了,甚至有极其成熟的解法和替代方案。但这不代表我觉得它合理!这种开发模式简直蠢透了! 我一个做业务开发的,凭什么要天天搁这儿给框架擦屁股?

5.1 恶臭的鄙视链与叹息之墙:毒害行业新人

前端圈一直有股令人作呕的风气:“React 孝子”们看不起 Vue 等其它框架。他们把「用 React」当成政治正确,把对 React 的批评当成异端。结果就是烂设计没人敢往死里骂,屎山越堆越高。

它不仅折磨老手,更是在新人面前砌起了一堵叹息之墙。新人满怀热情想画个交互,结果光是搞懂 useEffect 为什么会死循环、定时器里的 State 为什么永远停留在上个世纪,就得先脱两层皮。 硬生生把一个前端门槛搞得如此畸形、反直觉,官方非但不反思,那一群孝子反而把这种极高的心智负担当成“技术深度”四处炫耀。把喂屎包装成“最佳实践”,这就是你们引以为傲的工程化?

5.2 AI 投毒:机传人的赛博瘟疫

进入 AI 时代,这场灾难彻底演变成了赛博瘟疫。全网投喂的语料导致现在的 AI 写前端默认就是 React,十有八九是 JSX + hooks。

我现在日常开发主要靠 AI 写代码,但面对 React 这个奇葩,即便我在 Prompt 和工作流里写了上百行的防坑铁律严防死守,AI 还是会时不时被 React 那套反人类的阴间规则绕晕,悄无声息地给你拉一坨极其隐蔽的屎。 到头来,我还得停下手中的活,亲自下场 Debug,拨开那一层层令人窒息的依赖数组,去查看到底是底层哪个 Hook 又在发癫。这不是在写代码,这是在做赛博排雷。整个 Web 社区的代码基建正在不可逆转地走向失控的屎山化。

5.3 SDK 绑架:强买强卖的生态流氓

你以为你不用 React 就能独善其身?直到有一天,你接一个核心的 SDK,点开文档一看:对不起,只有 React 版本。 为了用这一个组件,你被迫在项目里引入整套 React 运行时,被迫去吃那一套恶心的 Hooks 闭包。这不叫技术选型,这叫强买强卖的生态流氓。

所以,你问我为什么逮着 React 骂? 因为他早已不是一个你可以躲开的工具,而是一场避无可避的生态瘟疫。骂 React,不是在骂「一个你可以不用的库」,而是在骂已经失控的基建


狂热粉丝只会告诉你 useEffectEvent 怎么用,而我会翻出 ReactFiberCommitWork.js 的源码告诉你它为什么这么难用; 小白面对被关掉的 Issue 只会觉得是自己提错了,而我会顺着线索找到那场企图搪塞一切的 React Conf 视频。

我拿着所有的官方日志、源码和 Issue 链接站在这里,指着这些拧巴的设计说:作为基建,你现在的傲慢、闭门造车和对社区声音的冷处理,真的很难看。

这不是毫无逻辑的狂喷,而是学霸拿着满分试卷在教训连及格线都没达到的出题人—— 用详实的论据、严密的逻辑和底层的代码把你锤得体无完肤。


引用链接汇总

类型 内容 链接
官方博客 React 19.2 发布(Activity, useEffectEvent, cacheSignal 等) react.dev/blog/2025/1…
官方博客 React Compiler 1.0 react.dev/blog/2025/1…
官方博客 React Labs: View Transitions, Activity, Automatic Effect Dependencies react.dev/blog/2025/0…
官方文档 useEffectEvent Reference react.dev/reference/r…
GitHub #27164 – make react more reactive / implement signals(无官方回复,stale 关闭) github.com/facebook/re…
GitHub #31393 – Why Not Consider Support Signals(Joseph Savona 回复 Conf 链接后关闭) github.com/facebook/re…
源码 ReactFiberHooks.js(useEffectEventImpl, 渲染期禁止调用) github.com/facebook/re…
源码 ReactFiberCommitWork.js(commit 阶段更新 effect event ref.impl) github.com/facebook/re…
官方 Joseph Savona 在 #31393 中指向的 React Conf performance 演讲 www.youtube.com/watch?v=zyV…

GPT-5.4 Computer Use 实战:3 步让 AI 操控浏览器帮你干活 🖥️

上周五 OpenAI 发布 GPT-5.4 的时候,我盯着 Computer Use 的 demo 看了整整半小时——AI 自己打开浏览器、点按钮、填表单、截图验证结果,全程不需要人干预。

说实话,之前 Claude 的 Computer Use 我就体验过,但那个延迟和准确率劝退了不少人。这次 GPT-5.4 直接把 OSWorld 基准测试干到 75%,超过人类的 72.4%。我当天晚上就开始折腾 API,踩了不少坑,今天把完整的接入流程和代码分享出来。

先说结论

特性 GPT-5.4 Computer Use Claude Computer Use
OSWorld 准确率 75.0% 22.0%
上下文窗口 1M tokens 200K tokens
响应速度 较快(结构化动作) 较慢(截图循环多)
API 价格 $2.50/M 输入 $3/M 输入
适用场景 浏览器自动化/桌面操控 通用桌面操控

简单说:GPT-5.4 的 Computer Use 目前是最能打的方案,尤其适合浏览器自动化场景。

什么是 Computer Use?

Computer Use 不是传统意义上的 Selenium/Playwright 自动化。传统方案你需要写选择器、处理各种异常,一旦页面改版就全废了。

GPT-5.4 的 Computer Use 完全不同——它看截图,然后告诉你该点哪里、该输入什么。就像一个远程协助的真人,只不过反应速度比真人快几十倍。

工作流程是这样的:

你发任务 → 模型看截图 → 返回操作指令(点击/输入/滚动)
    ↑                                    ↓
    └──── 执行操作,再截一张图 ←──────────┘

这个循环一直转,直到任务完成。

第 1 步:环境准备

安装依赖

pip install openai playwright
playwright install chromium

配置 API Key

export OPENAI_API_KEY="sk-proj-xxxxx"

如果你在国内,直接调 OpenAI 官方 API 延迟会比较高,可以用兼容 OpenAI 协议的中转服务,改个 base_url 就行:

from openai import OpenAI

# 方式一:直连 OpenAI(需要网络条件)
client = OpenAI()

# 方式二:用 ofox.ai 的聚合接口,国内直连低延迟
client = OpenAI(
    base_url="https://api.ofox.ai/v1",
    api_key="你的 ofox key"
)

启动隔离浏览器

这一步很关键——一定要用隔离环境。Computer Use 会操控你的浏览器,如果用日常浏览器,AI 一不小心就把你的 GitHub 仓库删了(别问我怎么知道的)。

from playwright.async_api import async_playwright
import asyncio

async def launch_browser():
    pw = await async_playwright().start()
    browser = await pw.chromium.launch(
        headless=False,  # 设 True 跑生产,False 方便调试
        args=[
            "--disable-extensions",
            "--no-first-run",
            "--disable-default-apps",
        ]
    )
    context = await browser.new_context(
        viewport={"width": 1440, "height": 900}  # 推荐分辨率
    )
    page = await context.new_page()
    return pw, browser, page

为什么推荐 1440x900?因为 OpenAI 官方文档明确说这个分辨率下模型的点击准确率最高。

第 2 步:核心代码——截图-操作循环

这是完整的 Computer Use 调用代码:

import base64
import asyncio
from openai import OpenAI
from playwright.async_api import async_playwright

client = OpenAI()  # 或指向你的中转服务

async def take_screenshot(page) -> str:
    """截图并转 base64"""
    screenshot_bytes = await page.screenshot()
    return base64.b64encode(screenshot_bytes).decode("utf-8")

async def execute_action(page, action):
    """执行模型返回的操作指令"""
    action_type = action.get("type")

    if action_type == "click":
        x, y = action["x"], action["y"]
        button = action.get("button", "left")
        await page.mouse.click(x, y, button=button)
        print(f"  🖱️ 点击 ({x}, {y})")

    elif action_type == "type":
        text = action["text"]
        await page.keyboard.type(text, delay=50)
        print(f"  ⌨️ 输入: {text[:30]}...")

    elif action_type == "keypress":
        keys = action["keys"]
        for key in keys:
            await page.keyboard.press(key)
        print(f"  ⌨️ 按键: {keys}")

    elif action_type == "scroll":
        x = action.get("x", 0)
        y = action.get("y", 0)
        await page.mouse.wheel(x, y)
        print(f"  📜 滚动 ({x}, {y})")

    elif action_type == "drag":
        start = action["start"]
        end = action["end"]
        await page.mouse.move(start["x"], start["y"])
        await page.mouse.down()
        await page.mouse.move(end["x"], end["y"])
        await page.mouse.up()
        print(f"  🔀 拖拽 ({start['x']},{start['y']}) → ({end['x']},{end['y']})")

    elif action_type == "wait":
        ms = action.get("ms", 1000)
        await asyncio.sleep(ms / 1000)
        print(f"  ⏳ 等待 {ms}ms")

    elif action_type == "screenshot":
        print("  📸 模型请求截图")

async def computer_use_loop(page, task: str, max_turns: int = 20):
    """Computer Use 主循环"""

    # 第一步:截图 + 发送任务
    screenshot_b64 = await take_screenshot(page)

    response = client.responses.create(
        model="gpt-5.4",
        tools=[{"type": "computer"}],
        input=[
            {
                "role": "user",
                "content": task
            }
        ],
        # 附带初始截图
        truncation="auto"
    )

    for turn in range(max_turns):
        # 检查是否有 computer_call
        computer_calls = [
            item for item in response.output
            if item.type == "computer_call"
        ]

        if not computer_calls:
            # 没有操作指令了,任务可能完成
            text_outputs = [
                item for item in response.output
                if hasattr(item, "content")
            ]
            if text_outputs:
                print(f"\n✅ 任务完成!模型回复:")
                for t in text_outputs:
                    print(t.content)
            break

        # 执行所有操作
        for call in computer_calls:
            print(f"\n[Turn {turn + 1}] 执行操作批次:")
            for action in call.actions:
                await execute_action(page, action)
                await asyncio.sleep(0.3)  # 操作间隔,模拟真人

            # 执行完截图,发回给模型
            await asyncio.sleep(1)  # 等页面渲染
            screenshot_b64 = await take_screenshot(page)

            response = client.responses.create(
                model="gpt-5.4",
                previous_response_id=response.id,
                input=[{
                    "type": "computer_call_output",
                    "call_id": call.call_id,
                    "output": {
                        "type": "computer_screenshot",
                        "image_url": f"data:image/png;base64,{screenshot_b64}",
                        "detail": "original"  # 保持原始分辨率
                    }
                }]
            )
    else:
        print("⚠️ 达到最大轮次限制")

# 使用示例
async def main():
    pw, browser, page = await launch_browser()

    await page.goto("https://www.google.com")
    await asyncio.sleep(2)

    await computer_use_loop(
        page,
        "搜索 'Python FastAPI tutorial 2026',打开第一个结果,总结页面内容"
    )

    await browser.close()
    await pw.stop()

asyncio.run(main())

第 3 步:实际跑起来看效果

我用上面的代码跑了几个实际场景:

场景 1:自动填写表单

await computer_use_loop(
    page,
    "打开 https://httpbin.org/forms/post,填写表单:Customer 填 'Zhang San',Size 选 Medium,Topping 选 Bacon,点击提交"
)

模型一共用了 4 轮截图循环就搞定了:截图→定位输入框→逐个填写→点提交。全程大概 15 秒。

场景 2:抓取动态加载的数据

之前用 Selenium 写爬虫,最烦的就是等 AJAX 加载完成。Computer Use 天然解决这个问题——它看截图判断页面是否加载完,不用写一堆 WebDriverWait

await computer_use_loop(
    page,
    "打开 GitHub Trending 页面,找到今天 Star 最多的 Python 项目,告诉我项目名和 Star 数"
)

场景 3:跨页面工作流

await computer_use_loop(
    page,
    """
    1. 打开 GitHub,搜索 'fastapi'
    2. 进入 tiangolo/fastapi 仓库
    3. 查看最新的 Release 版本号
    4. 回到搜索结果,查看第二个结果的 Star 数
    5. 对比两个项目,告诉我哪个更活跃
    """
)

这种多步骤跨页面的任务,传统自动化写起来巨麻烦,Computer Use 就是一段自然语言描述。

踩坑记录

坑 1:分辨率很重要

一开始我用 1920x1080,模型经常点歪,后来换成 1440x900 好了很多。OpenAI 文档里说可以缩放截图,但一定要重新映射坐标

# 如果你缩放了截图,坐标也要按比例调整
scale_x = original_width / screenshot_width
scale_y = original_height / screenshot_height
actual_x = action["x"] * scale_x
actual_y = action["y"] * scale_y

坑 2:detail 参数别省

发送截图的时候,detail 一定要设成 "original",不然模型看到的是压缩后的模糊图,点击位置会偏。虽然 "original" 会多消耗 token,但省这点钱不值得。

坑 3:操作间隔不能太快

每个操作之间至少加 300ms 延迟。不是因为模型需要,而是浏览器渲染需要时间。点击一个按钮后页面可能要弹窗、跳转、加载数据,太快截图会截到中间状态,模型就懵了。

坑 4:安全隔离是认真的

千万不要在你的日常浏览器里跑 Computer Use。用一个干净的 Playwright 浏览器实例,或者更稳妥——用 Docker 跑个隔离环境:

FROM ubuntu:22.04
RUN apt-get update && apt-get install -y xvfb x11vnc firefox xdotool
ENV DISPLAY=:99
CMD Xvfb :99 -screen 0 1440x900x24 & x11vnc -display :99 -nopw -forever &
EXPOSE 5900

然后 VNC 连进去观察 AI 在干嘛,眼见为实。

坑 5:费用控制

Computer Use 每轮都要发截图(按 image_url 计费),一个复杂任务跑 15-20 轮,token 消耗蹭蹭涨。建议:

  • 简单任务设 max_turns=10
  • 截图前缩放到合理尺寸(但不能太小)
  • previous_response_id 利用缓存,缓存命中的输入 token 只要 $0.25/M,比原价省 90%

GPT-5.4 三个版本怎么选?

版本 价格 (输入/输出) 适用场景
GPT-5.4 2.50/2.50 / 15 日常 Computer Use,性价比最高
GPT-5.4 Thinking 同上 + 推理token 复杂决策、多步规划
GPT-5.4 Pro 30/30 / 180 极端复杂任务,一般用不上

我的建议是先用标准版跑,90% 的场景够用了。只有那种需要"想一想再做"的复杂工作流,才需要 Thinking 版。

小结

GPT-5.4 的 Computer Use 确实是一个质变——从"AI 只能聊天"到"AI 能帮你操作电脑"。虽然现在还有一些限制(延迟、费用、偶尔点歪),但已经能覆盖很多实际场景了:表单填写、数据抓取、跨应用工作流。

我现在已经把一些重复性的浏览器操作都用 Computer Use 自动化了,真的省了不少时间。如果你也想尝试,建议先从简单的单页面任务开始,熟悉了截图-操作的循环模式,再逐步上复杂场景。

完整代码我放在 Gist 上了,有问题评论区见 👇

Service Worker 离线缓存这事,没你想的那么简单

Service Worker 离线缓存这事,没你想的那么简单

上个月接了个需求:把公司的 B 端管理系统做成"弱网可用"。产品说得轻巧——"加个离线缓存就行了嘛"。

我当时心想,行,上 Workbox,配几个路由策略,半天搞定。

结果呢?搞了整整一周。

问题不在"能不能缓存",而在"缓存了之后怎么更新"。用户打开页面用的是旧版本、新版本发上去了但 SW 还抱着老文件不放、偶尔还会出现半新半旧的"弗兰肯斯坦"状态——页面一半是新的一半是旧的,直接白屏。

这篇聊聊我最后是怎么用 Workbox 把这套离线缓存做到"能用、能更新、不炸"的。

先搞清楚 SW 的更新机制,不然后面全是坑

很多人对 Service Worker 的生命周期理解停留在 install → activate → fetch,觉得新文件上去了浏览器自动就换了。

没那么简单。

// SW 更新的真实流程:
// 1. 浏览器发现 sw.js 文件内容变了(逐字节比对)
// 2. 下载新 SW,触发 install 事件
// 3. 新 SW 进入 waiting 状态 —— 注意,不是直接激活
// 4. 等所有标签页都关了,新 SW 才 activate
// 5. 下次打开页面,才用新的缓存

// 问题来了:用户不关标签页怎么办?
// 答:新 SW 就一直 waiting,用户一直用旧缓存

这就是经典的"我明明发了新版本,用户看到的还是旧的"。

很多文章教你在 install 里加 skipWaiting(),activate 里加 clients.claim(),一步到位。

self.addEventListener('install', () => {
  self.skipWaiting() // 跳过 waiting,直接激活
})

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim()) // 立刻接管所有页面
})

能用,但粗暴。想象一下:用户正在填一个复杂表单,填了半天,SW 突然切了,页面资源全换成新版本,某个接口的响应格式变了——表单直接废了。B 端系统这么搞,会被投诉的。

用 Workbox 搭一套分层缓存策略

Workbox 提供了五种缓存策略,但不是选一种就完事了。不同资源该用不同策略,这事得想清楚。

我最后的分层方案长这样:

import { precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import {
  CacheFirst,
  StaleWhileRevalidate,
  NetworkFirst,
} from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'

// 第一层:构建产物 → precache(预缓存)
// hash 文件名的 JS/CSS,内容变了 hash 就变,天然版本控制
precacheAndRoute(self.__WB_MANIFEST)

// 第二层:图片/字体等静态资源 → CacheFirst
// 这些东西基本不变,命中缓存直接用,省带宽
registerRoute(
  ({ request }) =>
    request.destination === 'image' ||
    request.destination === 'font',
  new CacheFirst({
    cacheName: 'static-assets-v1',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 100,       // 最多缓存 100 个
        maxAgeSeconds: 30 * 24 * 3600, // 30 天过期
      }),
      new CacheableResponsePlugin({
        statuses: [0, 200],    // 0 是 opaque response,跨域资源
      }),
    ],
  })
)

// 第三层:API 请求 → NetworkFirst
// 优先拿新数据,网络挂了才用缓存兜底
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 3, // 3 秒没响应就用缓存
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // API 缓存只留 5 分钟
      }),
    ],
  })
)

// 第四层:HTML 页面 → StaleWhileRevalidate
// 先给旧的用着,后台偷偷更新
registerRoute(
  ({ request }) => request.mode === 'navigate',
  new StaleWhileRevalidate({
    cacheName: 'pages-cache',
  })
)

三个关键决策说一下:

API 用 NetworkFirst 而不是 StaleWhileRevalidate。 B 端系统数据一致性很重要,审批状态、订单数据这些,给用户看过期的可能出事。宁可慢一点,也要优先拿最新的。

HTML 用 StaleWhileRevalidate。 这里比较纠结,我一开始用的 NetworkFirst,但弱网下页面加载体验太差。后来改成先给旧页面、后台更新,配合后面说的版本控制机制,体验好了不少。

静态资源设了 maxEntries 上限。 之前没设,缓存越积越多,有个用户的 Cache Storage 膨胀到 800MB,手机直接卡死。

版本控制:怎么让更新不翻车

分层缓存解决了"缓存什么"的问题,但核心难题还没解决:怎么让新版本平滑上去,不出现半新半旧的状态?

Workbox 的 precache 机制本身带版本控制。构建时会生成一个 manifest:

// 构建产物大概长这样:
self.__WB_MANIFEST = [
  { url: '/js/app.3a7b2c.js', revision: null },  // 文件名带 hash,revision 不需要
  { url: '/js/vendor.9f8e1d.js', revision: null },
  { url: '/index.html', revision: 'v28' },         // 没 hash 的文件需要 revision
  { url: '/manifest.json', revision: 'v3' },
]

文件名带 hash 的,内容一变 hash 就变,precache 自动处理增量更新——只下载变了的文件,没变的直接跳过。这部分 Workbox 做得挺好,不用操心。

麻烦的是 index.html 这类没有 hash 的文件。revision 字段本质上是内容的 hash,靠构建工具生成。但问题在于,index.html 是入口,它引用了哪些 JS/CSS 文件决定了用户加载哪个版本。

如果 SW 更新了 JS 但还在用旧的 index.html,旧 HTML 里引用的是旧 JS hash,新 JS 缓存了但压根不会被加载——经典的版本不一致。

我的处理方式是,在主线程加一层更新检测:

// main.ts —— 应用入口
if ('serviceWorker' in navigator) {
  const registration = await navigator.serviceWorker.register('/sw.js')

  // 检测到新 SW 在 waiting
  registration.addEventListener('updatefound', () => {
    const newWorker = registration.installing
    if (!newWorker) return

    newWorker.addEventListener('statechange', () => {
      if (
        newWorker.state === 'installed' &&
        navigator.serviceWorker.controller // 说明不是首次安装
      ) {
        // 新版本就绪,通知用户
        showUpdateNotification({
          onConfirm: () => {
            newWorker.postMessage({ type: 'SKIP_WAITING' })
          },
        })
      }
    })
  })

  // SW 控制权切换后刷新页面
  let refreshing = false
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    if (refreshing) return
    refreshing = true
    window.location.reload() // 刷新拿新资源
  })
}

SW 那边对应地处理消息:

// sw.js
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting() // 用户确认后才跳过 waiting
  }
})

核心思路:不自动 skipWaiting,让用户决定什么时候更新。

弹个不起眼的提示条——"有新版本可用,点击刷新",用户手头事忙完了自己点,不打断操作流。这比强制刷新友好太多了。

增量更新:别让用户每次都全量下载

Precache 的增量更新是文件级别的:100 个文件只改了 3 个,就只下载那 3 个。但有个前提——你的构建配置得配合

踩过一个坑:项目用 Vite 打包,每次构建所有 chunk 的 hash 都变了。明明只改了一行代码,用户得重新下载全部 JS。

原因是 Vite 默认的 manualChunks 配置没做好,所有代码打成几个大 chunk,任何改动都会导致 chunk 内容变化。

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // 细粒度拆包,让改动的影响范围最小化
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 把大的第三方库单独拆出来
            if (id.includes('echarts')) return 'vendor-echarts'
            if (id.includes('lodash')) return 'vendor-lodash'
            if (id.includes('antd') || id.includes('ant-design'))
              return 'vendor-antd'
            return 'vendor' // 其余第三方统一放
          }
          // 业务代码按路由拆
        },
      },
    },
  },
})

拆完之后效果明显:改一个页面组件,只有对应的路由 chunk 的 hash 变了,其他 chunk 不受影响。增量更新从"几乎全量"变成了"真的增量"。

还有一个容易忽略的点:运行时缓存(Runtime Cache)没有增量更新的概念。CacheFirst 策略下,一个 2MB 的图片只要 URL 没变就永远用缓存。这大部分时候是对的,但如果你的图片 URL 不带版本号,改了图片但 URL 一样,用户永远看到旧图。

解法也简单,要么 URL 带 hash/版本号,要么把这类资源的策略从 CacheFirst 改成 StaleWhileRevalidate。

缓存清理:没人提但迟早会炸的事

缓存只进不出,Storage 迟早满。浏览器对 Cache Storage 有配额限制(Chrome 大概是磁盘空间的 60%,但不保证),超了会整个 origin 的数据被清——包括 IndexedDB、localStorage,全没。

ExpirationPlugin 能解决一部分问题,但老版本的 precache 缓存不会自动清理。

Workbox 的 precache 在 activate 阶段会清理旧版本的缓存条目,这部分是自动的。但如果你手动管理了一些缓存,或者 cacheName 改了(比如从 static-assets-v1 升到 v2),旧的 cache 不会自己消失。

// sw.js activate 阶段,手动清理废弃的 cache
self.addEventListener('activate', (event) => {
  const currentCaches = [
    'static-assets-v2',  // 当前版本
    'api-cache',
    'pages-cache',
  ]

  event.waitUntil(
    caches.keys().then((cacheNames) =>
      Promise.all(
        cacheNames
          .filter((name) => !currentCaches.includes(name))
          .filter((name) => !name.startsWith('workbox-precache')) // precache 的让 Workbox 自己管
          .map((name) => {
            console.log('[SW] 删除旧缓存:', name)
            return caches.delete(name)
          })
      )
    )
  )
})

另外一个实用的做法是加个 Storage 用量监控,快满的时候主动清理低优先级缓存:

async function checkStorageQuota() {
  if (!navigator.storage?.estimate) return

  const { usage, quota } = await navigator.storage.estimate()
  const usageRatio = (usage || 0) / (quota || 1)

  if (usageRatio > 0.8) {
    // 用了 80% 以上,清掉过期的运行时缓存
    const cache = await caches.open('static-assets-v2')
    const keys = await cache.keys()
    // 按时间删掉最老的一半
    const toDelete = keys.slice(0, Math.floor(keys.length / 2))
    await Promise.all(toDelete.map((key) => cache.delete(key)))
  }
}

灰度更新:线上不敢一把梭的时候

这是后来加的需求。有一次发版改了个核心组件,结果新版本有 bug,但 SW 已经把新资源 precache 了,用户刷新就加载新版本——回都回不来。

后来加了个简单的灰度机制。SW 安装前先问服务端:"我该不该用新版本?"

// sw.js install 阶段
self.addEventListener('install', (event) => {
  event.waitUntil(
    (async () => {
      const resp = await fetch('/api/sw-config').catch(() => null)

      if (resp?.ok) {
        const config = await resp.json()
        // { version: "2.3.1", rolloutPercent: 30, forceUpdate: false }

        if (!shouldActivate(config)) {
          // 不在灰度范围内,不装新版本
          // 注意:这里不调 skipWaiting,新 SW 会被丢弃
          return
        }
      }

      // 正常执行 precache
      // workbox 的 precache 逻辑在这之后
    })()
  )
})

function shouldActivate(config) {
  // 用 clientId 或者随机数做灰度分桶
  const bucket = Math.random() * 100
  return bucket < config.rolloutPercent
}

说实话这个方案有点糙。Math.random() 每次 install 都重新算,同一个用户可能一会在灰度内一会不在。更好的做法是用 IndexedDB 存一个固定的 clientId 做分桶。但对于我们当时的场景(内部 B 端系统,用户量不大),够用了。

有个问题我到现在也没完全想明白

SW 的 install 事件里如果 precache 失败了(比如某个文件 404),整个 SW 安装就失败了。这意味着一个文件挂了,所有缓存更新都不生效

Workbox 没有提供"部分成功"的能力。要么全装,要么不装。

这在 CDN 发布的时候偶尔会出问题——新文件还没全部同步到 CDN 节点,SW 就开始装了,某个文件 404,安装失败,用户卡在旧版本。下次再访问的时候可能 CDN 同步好了,又能装成功了。但这个时间窗口里的用户体验是不可控的。

我的临时方案是 precache 的文件列表尽量精简,只放入口必须的文件,其他的用运行时缓存按需加载。减少 precache 失败的概率。但根本问题还是没解决。如果有人有更好的方案,真的想听听。

聊到这

SW 离线缓存这套东西,原理不复杂,但工程化做起来全是细节。分层策略、版本控制、增量更新、缓存清理、灰度发布——每一块都不难,串起来就有得折腾了。

我的经验是:先把更新机制想清楚,再去配缓存策略。 大部分线上事故不是"缓存没命中",而是"缓存了但更新不了"。

还有一点,workbox-webpack-pluginvite-plugin-pwa 能帮你省掉很多手动配置的活,但别完全当黑盒用。至少把生成的 sw.js 打开看一眼,知道它干了什么。不然出了问题连排查方向都没有。

使用 clip-path: shape() 创建 Squircle 形状

你是否厌倦了传统的方形和圆形元素?想要为你的网页或设计增添一些独特的曲线美?今天,就让我们一起来探索如何使用CSS的clip-path属性来创建一个时尚的 Squircle 形状吧!

什么是Squircle?

Squircle,顾名思义,是Square(方形)和Circle(圆形)的结合体。它既有方形的棱角感,又融入了圆形的柔和曲线,给人一种既现代又舒适的视觉感受。

核心思路

由于corner-shape(专门实现Squircle的属性)目前浏览器支持度有限,我们通过clip-path: shape()来模拟实现,核心是用CSS变量统一控制弧度,通过数学计算让边角的曲线过渡更自然,变量可直接用百分比或像素值,灵活度拉满。

完整实现代码

直接复制这段代码,就能快速实现基础的Squircle效果,关键变量--r可直接调整,0%为纯正方形,50%为极致的Squircle效果,中间数值可按需自定义。

/* 基础Squircle样式,直接复用 */
.squircle {
  --r: 50%; /* 控制弧度,0%=正方形,50%=Squircle,支持像素值如20px */
  --_r: clamp(0%,var(--r)/2,25%);
  --_v: calc(var(--_r)*(1 - sqrt(2)/4));
  --_p: calc(var(--_v) - var(--_r)/2);
  clip-path: shape(
    from var(--_v) var(--_p),
    curve to 50% 0 with var(--_r) 0,
    curve to calc(100% - var(--_v)) var(--_p) with calc(100% - var(--_r)) 0,
    curve to calc(100% - var(--_p)) var(--_v) with calc(100% - 2*var(--_p)) calc(2*var(--_p)),
    curve to 100% 50% with 100% var(--_r),
    curve to calc(100% - var(--_p)) calc(100% - var(--_v)) with 100% calc(100% - var(--_r)),
    curve to calc(100% - var(--_v)) calc(100% - var(--_p)) with calc(100% - 2*var(--_p)) calc(100% - 2*var(--_p)),
    curve to 50% 100% with calc(100% - var(--_r)) 100%,
    curve to var(--_v) calc(100% - var(--_p)) with var(--_r) 100%,
    curve to var(--_p) calc(100% - var(--_v)) with calc(2*var(--_p)) calc(100% - 2*var(--_p)),
    curve to 0 50% with 0 calc(100% - var(--_r)),
    curve to var(--_p) var(--_v) with 0 var(--_r),
    curve to var(--_v) var(--_p) with calc(2*var(--_p)) calc(2*var(--_p))
  );
}

Demo 地址:codepen.io/editor/aire…

对比演示:clip-path实现 vs corner-shape原生

为了让大家直观看到效果差异,我们做一个可交互的对比demo,一边是clip-path: shape()的模拟实现,一边是corner-shape的原生实现,还能通过滑块实时调整弧度,代码如下(可直接运行测试)。

.squircle {
  --r: 40%;
  --_r: clamp(0%,var(--r)/2,25%);
  --_v: calc(var(--_r)*(1 - sqrt(2)/4));
  --_p: calc(var(--_v) - var(--_r)/2);
        clip-path: shape(
    from var(--_v) var(--_p),
    curve to 50% 0 with var(--_r) 0,
    curve to calc(100% - var(--_v)) var(--_p) with calc(100% - var(--_r)) 0,
    curve to calc(100% - var(--_p)) var(--_v) with calc(100% - 2*var(--_p)) calc(2*var(--_p)),
    curve to 100% 50% with 100% var(--_r),
    curve to calc(100% - var(--_p)) calc(100% - var(--_v)) with 100% calc(100% - var(--_r)),
    curve to calc(100% - var(--_v)) calc(100% - var(--_p)) with calc(100% - 2*var(--_p)) calc(100% - 2*var(--_p)),
    curve to 50% 100% with calc(100% - var(--_r)) 100%,
    curve to var(--_v) calc(100% - var(--_p)) with var(--_r) 100%,
    curve to var(--_p) calc(100% - var(--_v)) with calc(2*var(--_p)) calc(100% - 2*var(--_p)),
    curve to 0 50% with 0 calc(100% - var(--_r)),
    curve to var(--_p) var(--_v) with 0 var(--_r),
    curve to var(--_v) var(--_p) with calc(2*var(--_p)) calc(2*var(--_p))
  );
}

.corner-shape {
  --r: 40%;
  border-radius: var(--r);
  corner-shape: squircle;
}

Demo 地址:codepen.io/editor/aire…

总结

这次的技巧核心是用clip-path: shape()弥补corner-shape的兼容性不足,通过 CSS 变量让 Squircle 效果的调节更简单,一行代码修改弧度,适配各类前端样式开发需求。

这种丝滑的圆角效果,用在按钮、卡片、头像等元素上,能让页面的视觉质感提升一个档次,大家赶紧把代码收藏起来,下次开发直接复用~

扩展阅读

React Hook 到底是干嘛的?

一、什么是 React Hook?

React Hook,本质上是一套 让函数组件拥有更多能力的机制

你可以先记住一句最核心的话:

Hook 的作用,就是让函数组件也能拥有状态、生命周期、副作用处理、逻辑复用等能力。

通俗一点说:

以前 React 的函数组件,只会干一件事:

根据数据,把页面渲染出来。

但是现实开发中,一个组件往往不只是“显示页面”这么简单,它还需要做很多事情,比如:

  • 记住用户输入的内容
  • 控制弹窗开关
  • 发送接口请求
  • 监听页面变化
  • 获取 DOM 元素
  • 复用一段公共逻辑

而 Hook,就是 React 提供给函数组件的一套“能力插件”。

你可以把它理解成:

Hook = 给函数组件装功能的工具箱。

二、为什么会有 Hook?

要理解 Hook,先得知道 React 以前是怎么写的。

在 Hook 出现之前,React 中如果你想让组件拥有状态、生命周期这些能力,通常要使用 类组件(Class Component)

比如一个最简单的计数器,早期可能要这样写:

import React, { Component } from "react";

class Counter extends Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  render() {
    return (
      <button onClick={() => this.setState({ count: this.state.count + 1 })}>
        点击了 {this.state.count} 次
      </button>
    );
  }
}

这段代码没有错,但很多初学者会觉得有点麻烦:

  • 要写 class
  • 要写 constructor
  • 要写 super
  • 要写 this.state
  • 要写 this.setState
  • this 指向有时候还容易出问题

随着项目越来越复杂,类组件还会涉及各种生命周期函数,比如:

  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

代码会越来越分散,逻辑也越来越不容易维护。

于是 React 官方推出了 Hook。

它的目标非常明确:

让函数组件也能完成类组件的大部分能力,而且写起来更简洁、更清晰、更容易复用逻辑。

三、你可以怎么理解 Hook?

你可以把一个 React 组件想象成一个员工。

一开始,这个员工只会一件事:

把页面画出来。

比如:

function Hello() {
  return <h1>Hello React</h1>;
}

这就是一个最普通的函数组件。

但如果你想让这个员工更能干一点,比如:

  • 记住一个数字
  • 页面加载后发请求
  • 监听窗口变化
  • 找到某个输入框并让它自动获取焦点

那就需要给他配工具。

而 Hook,就是这些工具。

比如:

  • useState:给员工一个记事本,让他能记住东西
  • useEffect:给员工一个任务清单,让他在页面渲染后做额外事情
  • useRef:给员工一个抽屉,可以放东西,也能找到页面里的某个元素

所以,Hook 并不神秘。

它就是:

让函数组件从“只能展示页面”,升级成“能真正干活的组件”。

四、最常见的 Hook:useState

在 React 中,最常用的 Hook 之一,就是 useState

它的作用非常简单:

让组件记住一个值。

比如,我们写一个最基础的计数器:

import React, { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      点击了 {count} 次
    </button>
  );
}

export default Counter;

这段代码怎么理解?

最关键的是这句:

const [count, setCount] = useState(0);

你可以把它翻译成一句人话:

React,帮我准备一个状态,它的初始值是 0,当前值叫 count,修改它的方法叫 setCount。

于是:

  • count 是当前的状态值
  • setCount 是修改状态的方法
  • useState(0) 里的 0 是初始值

点击按钮后,执行:

setCount(count + 1);

React 就会更新状态,然后重新渲染页面。

为什么不能直接用普通变量?

很多初学者可能会写出这样的代码:

function Counter() {
  let count = 0;

  return (
    <button onClick={() => count++}>
      点击了 {count} 次
    </button>
  );
}

看起来你在修改 count,但页面并不会按预期更新。

原因很简单:

普通变量的变化,React 感知不到。

React 只会对“状态”的变化做出响应,而 useState 正是告诉 React:

这个值是组件状态,请你帮我管理它。

所以你可以这样记:

普通变量是你自己偷偷记,React 不知道;useState 是你正式告诉 React,这个值要参与页面更新。

五、第二个非常重要的 Hook:useEffect

除了状态,组件还经常需要做一些“额外的事情”。

比如:

  • 页面加载后请求接口
  • 设置定时器
  • 监听滚动事件
  • 修改页面标题
  • 组件销毁时做清理

这些事情并不是“渲染 UI”本身,而是渲染之外的行为。

React 把这类操作叫做 副作用(Effect)

听起来有点专业,其实你完全可以把它理解成:

组件渲染完后,顺手做的事。

这时候就要用到 useEffect

先看一个例子:

import React, { useEffect } from "react";

function Demo() {
  useEffect(() => {
    console.log("组件渲染完成了");
  }, []);

  return <div>你好,React Hook</div>;
}

export default Demo;

这段代码的意思就是:

页面渲染出来以后,执行一次里面的代码。

[] 是什么意思?

很多人第一次学 useEffect,最容易懵的地方就是第二个参数。

useEffect(() => {
  console.log("执行了");
}, []);

这里的 [] 叫做 依赖数组

它决定这个副作用什么时候执行。

1. 传空数组 []

useEffect(() => {
  console.log("只执行一次");
}, []);

表示:

组件第一次渲染完成后执行一次,以后不再执行。

这很像类组件里的 componentDidMount

2. 不传第二个参数

useEffect(() => {
  console.log("每次渲染都执行");
});

表示:

组件每次渲染后都会执行。

3. 传入依赖项

useEffect(() => {
  console.log("count 变化了");
}, [count]);

表示:

首次渲染会执行,以后只有 count 变化时才执行。

这个机制非常重要,写业务代码时经常会用到。

六、useEffect 最经典的应用:请求接口

比如页面加载后获取用户列表:

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

function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/users")
      .then((res) => res.json())
      .then((data) => {
        setUsers(data);
      });
  }, []);

  return (
    <div>
      <h2>用户列表</h2>
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

export default UserList;

逻辑很清晰:

  1. useState([]) 准备一个用户列表状态
  2. useEffect 在组件第一次渲染后发请求
  3. 请求回来后通过 setUsers(data) 更新状态
  4. 页面自动重新渲染,显示数据

这就是 Hook 配合使用的典型场景。

七、第三个常见 Hook:useRef

接下来再说一个很常用的 Hook:useRef

它主要有两个用途:

  • 获取 DOM 元素
  • 保存一个不会触发页面重新渲染的值

先看第一个用途:获取 DOM。

比如页面加载后让输入框自动聚焦:

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

function InputFocus() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current.focus();
  }, []);

  return <input ref={inputRef} placeholder="请输入内容" />;
}

export default InputFocus;

这里的逻辑是:

  • useRef(null) 创建一个引用对象
  • ref={inputRef} 把这个引用绑定到 input 上
  • 页面渲染后,通过 inputRef.current 拿到真实 DOM
  • 调用 focus() 让输入框自动获取焦点

你可以把 useRef 理解成:

给页面元素贴了个标签,方便以后找到它。

useRef 的第二个用途

useRef 还可以用来保存一些值,而且这些值变化时不会导致页面重新渲染。

比如保存一个定时器 id:

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

function TimerDemo() {
  const timerRef = useRef(null);

  useEffect(() => {
    timerRef.current = setInterval(() => {
      console.log("定时器执行中");
    }, 1000);

    return () => {
      clearInterval(timerRef.current);
    };
  }, []);

  return <div>定时器示例</div>;
}

export default TimerDemo;

这里的 timerRef.current 就像一个小盒子,可以存放数据。

useState 的区别是:

  • useState 变化会触发重新渲染
  • useRef 变化不会触发重新渲染

所以如果你只是想保存一个值,但这个值不需要展示到页面上,useRef 很合适。

八、React Hook 到底解决了什么问题?

这是很多面试中也会问到的问题。

React Hook 主要解决了三个问题。

1. 让函数组件拥有状态和副作用能力

以前函数组件只能负责展示 UI,复杂逻辑很多都要写在类组件里。

Hook 出现后,函数组件也能做这些事了,开发体验更统一。

2. 逻辑复用更方便

以前如果你想复用一段组件逻辑,常见方法有:

  • mixin
  • 高阶组件(HOC)
  • Render Props

这些方案不是不能用,但随着项目变复杂,代码嵌套会越来越深,理解成本也越来越高。

而 Hook 可以把一段逻辑直接提取成一个自定义 Hook。

比如获取窗口宽度:

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

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => {
      setWidth(window.innerWidth);
    };

    window.addEventListener("resize", handleResize);

    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []);

  return width;
}

function Page() {
  const width = useWindowWidth();

  return <div>当前窗口宽度:{width}</div>;
}

export default Page;

这里 useWindowWidth() 就是一个自定义 Hook。

你会发现,这种写法真的很舒服:

  • 逻辑抽离清晰
  • 复用方便
  • 代码结构更自然

3. 让组件代码更简洁、更易维护

类组件中,一个功能的相关代码可能分散在多个生命周期里。

而用 Hook,可以把“相关逻辑”写在一起。

比如接口请求、事件绑定、清理逻辑,都可以围绕一个功能集中组织,这对维护大型项目非常友好。

九、为什么 Hook 都要以 use 开头?

你肯定发现了,React 官方提供的 Hook 都叫:

  • useState
  • useEffect
  • useRef
  • useMemo
  • useCallback
  • useContext

这是 React 的约定。

凡是 Hook,名字都要以 use 开头。

包括你自己写的自定义 Hook,也最好遵守这个规则:

function useWindowWidth() {
  // ...
}

为什么这么要求?

因为 React 和 ESLint 插件会根据 use 开头来识别:

这是不是一个 Hook。

这样可以更好地检查代码是否符合 Hook 的使用规则。

十、React Hook 的使用规则

Hook 虽然很好用,但它有两条非常重要的规则。

1. 只能在函数组件或自定义 Hook 中调用

你不能在普通的 JavaScript 函数中乱用 Hook。

错误示例:

function test() {
  const [count, setCount] = useState(0);
}

这是不允许的。

正确示例:

function Counter() {
  const [count, setCount] = useState(0);
}

或者:

function useCounter() {
  const [count, setCount] = useState(0);
  return { count, setCount };
}

2. 不要在条件语句、循环、嵌套函数中调用 Hook

错误示例:

function Demo({ flag }) {
  if (flag) {
    const [count, setCount] = useState(0);
  }

  return <div>Demo</div>;
}

为什么不行?

因为 React 是按照 Hook 的调用顺序来管理状态的。

如果你把 Hook 写在 if 里面,那么某次渲染执行了,某次渲染又没执行,顺序就乱了,React 就无法正确知道:

哪个 useState 对应哪个状态。

所以一定要记住:

Hook 要写在组件顶层,不能乱嵌套。

十一、再来理解一下 Hook 的本质

很多同学学 Hook 时,最容易卡住的一点是:

函数组件不是每次渲染都会重新执行吗?那它是怎么“记住状态”的?

这个问题问得特别好。

确实,函数组件每次渲染都会重新执行一遍。

但是 React 内部会帮你“记住”每个 Hook 对应的数据。

比如:

function Demo() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("Tom");

  return <div>{count} - {name}</div>;
}

React 内部会根据 Hook 的调用顺序,记录:

  • 第一个 useState 对应 count
  • 第二个 useState 对应 name

下次渲染时,再按照同样顺序把数据取出来。

这就是为什么 Hook 必须按固定顺序调用。

所以从本质上说:

Hook 是 React 在函数组件中“挂接状态和副作用管理能力”的一种机制。

十二、常见 Hook 快速总结

下面把几个常见 Hook 用最简单的话概括一下。

1. useState

作用:管理状态

适合场景:

  • 计数器
  • 输入框内容
  • 弹窗开关
  • 列表数据
  • 当前页码

示例:

const [visible, setVisible] = useState(false);

2. useEffect

作用:处理副作用

适合场景:

  • 请求接口
  • 事件监听
  • 定时器
  • 手动操作 DOM
  • 修改页面标题

示例:

useEffect(() => {
  document.title = "首页";
}, []);

3. useRef

作用:获取 DOM 或保存不会引起重渲染的值

适合场景:

  • input 自动聚焦
  • 获取滚动容器
  • 保存定时器 id
  • 保存上一次的值

示例:

const inputRef = useRef(null);

4. useMemo

作用:缓存计算结果

适合场景:

  • 复杂计算
  • 避免重复计算
  • 优化性能

示例:

const total = useMemo(() => {
  return list.reduce((sum, item) => sum + item.price, 0);
}, [list]);

5. useCallback

作用:缓存函数

适合场景:

  • 把函数传给子组件时避免重复创建
  • 配合 React.memo 做性能优化

示例:

const handleClick = useCallback(() => {
  console.log("点击了");
}, []);

6. useContext

作用:跨层级共享数据

适合场景:

  • 主题切换
  • 用户信息共享
  • 全局配置共享

示例:

const theme = useContext(ThemeContext);

十三、初学者最容易犯的几个错误

1. 把 useEffect 当成“任何逻辑都往里塞”

有些初学者一学会 useEffect,就恨不得什么都丢进去。

其实不是所有逻辑都要写进 useEffect

原则是:

只有那些“渲染之后要做的事”,才适合写进 useEffect。

如果只是简单计算数据,很多时候直接在组件里写就可以。

2. 在 useEffect 里漏掉依赖

比如:

useEffect(() => {
  console.log(count);
}, []);

如果你的副作用里用到了 count,通常就应该把它写进依赖数组里:

useEffect(() => {
  console.log(count);
}, [count]);

否则可能会出现数据不是最新值的问题。

3. 把 useRef 和 useState 搞混

记住一句非常关键的话:

  • 需要更新页面的,用 useState
  • 只是存值但不需要更新页面的,用 useRef

这个区别一定要分清。

4. 在 if 中使用 Hook

这个是典型错误。

if (flag) {
  useEffect(() => {}, []);
}

千万别这么写。

Hook 一定要放在组件最外层。

十四、一个完整小案例:用 Hook 写一个待办事项列表

下面我们用 Hook 写一个简单的 Todo List,帮助你把 useStateuseEffect 串起来理解。

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

function TodoApp() {
  const [inputValue, setInputValue] = useState("");
  const [list, setList] = useState([]);

  useEffect(() => {
    const localData = localStorage.getItem("todo-list");
    if (localData) {
      setList(JSON.parse(localData));
    }
  }, []);

  useEffect(() => {
    localStorage.setItem("todo-list", JSON.stringify(list));
  }, [list]);

  const handleAdd = () => {
    if (!inputValue.trim()) return;
    setList([...list, inputValue]);
    setInputValue("");
  };

  const handleDelete = (index) => {
    const newList = list.filter((_, i) => i !== index);
    setList(newList);
  };

  return (
    <div>
      <h2>Todo List</h2>
      <input
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        placeholder="请输入待办事项"
      />
      <button onClick={handleAdd}>添加</button>

      <ul>
        {list.map((item, index) => (
          <li key={index}>
            {item}
            <button onClick={() => handleDelete(index)}>删除</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TodoApp;

这个案例里用到了什么?

useState

管理两个状态:

  • 输入框内容 inputValue
  • 待办列表 list

第一个 useEffect

页面第一次加载时,从本地缓存读取数据。

第二个 useEffect

每次 list 变化时,把最新数据存到 localStorage

这个例子特别适合初学者练手,因为它同时涉及:

  • 表单输入
  • 列表渲染
  • 状态更新
  • 副作用处理
  • 本地存储

十五、React Hook 和类组件相比,到底哪个好?

现在大多数新项目,几乎都更倾向于:

函数组件 + Hook

原因很简单。

Hook 的优点

1. 代码更简洁

不用写很多类组件模板代码。

2. 逻辑更聚合

一个功能相关的代码可以写在一起,不容易分散。

3. 更方便复用逻辑

自定义 Hook 非常适合抽离通用能力。

4. 更符合现在 React 的主流生态

很多现代 React 项目、组件库、教程,默认都基于 Hook。

当然,这并不是说类组件完全没用了。

只是从开发趋势来看,Hook 已经成为 React 的核心写法之一。

十六、面试中怎么回答“React Hook 是什么”?

如果你面试时被问到这个问题,可以参考下面这段回答:

React Hook 是 React 16.8 引入的一套新特性,它允许我们在函数组件中使用状态、生命周期、副作用处理、引用、上下文等能力。Hook 的出现让函数组件不再只是无状态组件,同时也让逻辑复用变得更加方便,比如可以通过自定义 Hook 抽离公共逻辑。常见的 Hook 有 useState、useEffect、useRef、useMemo、useCallback、useContext 等。

如果你想回答得更通俗一点,也可以说:

Hook 就是 React 给函数组件提供的一套能力扩展机制,让函数组件也能记数据、发请求、操作 DOM、复用逻辑。

十七、总结

学 React Hook,最重要的不是一开始就把所有 Hook 全背下来,而是先把最核心的三个搞懂:

  • useState
  • useEffect
  • useRef

你只要先彻底理解这三个,React Hook 的大门基本就打开了。

最后我们再用最简单的话总结一次:

useState 是什么?

让组件记住数据。

useEffect 是什么?

让组件在渲染后执行额外操作。

useRef 是什么?

让组件获取 DOM,或者保存不会触发重渲染的值。

Hook 是什么?

Hook 就是让 React 函数组件拥有状态管理、副作用处理、DOM 操作和逻辑复用能力的一套机制。

如果你之前一直觉得 Hook 很抽象,那看到这里,你至少应该已经明白:

Hook 并不是什么高深魔法,它就是 React 给函数组件配的工具箱。

十八、写在最后

对于 React 初学者来说,Hook 一开始确实会有点绕,尤其是:

  • 为什么要用 useState
  • 为什么 useEffect 有依赖数组
  • 为什么 Hook 不能写在 if 里
  • 为什么函数组件每次重新执行却还能记住状态

这些问题,几乎每个 React 学习者都会遇到。

但只要你多写几个小例子,比如:

  • 计数器
  • 输入框联动
  • Todo List
  • 页面请求数据
  • 输入框自动聚焦

Hook 很快就会从“抽象概念”变成“顺手工具”。

建议你下一步重点练这几个方向:

  1. useState 控制表单和列表
  2. useEffect 做接口请求和事件监听
  3. useRef 获取 DOM 和保存临时值
  4. 尝试自己写一个简单的自定义 Hook

当你把这些练熟以后,再去学 useMemouseCallbackuseContext,会轻松很多。

HTTP/3 的多路复用和 QUIC 到底能让页面快多少?聊聊连接迁移和 0-RTT

HTTP/3 的多路复用和 QUIC 到底能让页面快多少?聊聊连接迁移和 0-RTT

上个月灰度上了 HTTP/3,盯着 Grafana 看了一周的数据。LCP 掉了 200ms 左右,移动端弱网环境下收益更明显,某些场景甚至能砍掉 400ms。

但说实话,这个结果来之前我心里也没底。HTTP/3 的宣传材料看了不少,"队头阻塞解决了"、"握手更快了"——这些都是正确的废话。真正上线的时候,你关心的是:我的业务场景能吃到多少红利?哪些地方可能翻车?

这篇就聊聊实际落地的体感。

先说清楚 HTTP/3 改了什么

HTTP/2 的多路复用有个硬伤——它跑在 TCP 上。

TCP 是个"有序"协议,丢一个包,后面所有包都得等着。你在一条 TCP 连接上跑 6 个请求,其中一个请求丢了个包,其余 5 个请求也被卡住了。这就是 TCP 层面的队头阻塞。

TCP 连接(HTTP/2):
  请求A: [包1] [包2] [包3✗] ← 丢了
  请求B: [包1] [包2] ...等着  ← 被连坐
  请求C: [包1] ...等着        ← 也被连坐

QUIC 连接(HTTP/3):
  流A: [包1] [包2] [包3✗] ← 丢了,只有流A等重传
  流B: [包1] [包2] [包3]  ← 该干嘛干嘛
  流C: [包1] [包2]        ← 完全不受影响

QUIC 把多路复用下沉到了传输层。每个流(stream)独立管理丢包和重传,互不干扰。

这在理想网络下差别不大——丢包率 0.1% 的时候你根本感知不到。但一旦丢包率上去(移动网络切基站、地铁里、电梯口),差距就出来了。

0-RTT 握手到底省了什么

TCP + TLS 1.3 握手要 2-RTT(TCP 一次,TLS 一次)。QUIC 把传输层握手和加密握手合并了,首次连接 1-RTT,重连 0-RTT。

// TCP + TLS 1.3 (首次)
客户端 → SYN                     → 服务端     // RTT 1: TCP
客户端 ← SYN-ACK                 ← 服务端
客户端 → ClientHello             → 服务端     // RTT 2: TLS
客户端 ← ServerHello + 证书 + Finished ← 服务端
客户端 → Finished + 请求数据      → 服务端     // 终于可以发请求了

// QUIC (首次)
客户端 → Initial(ClientHello)    → 服务端     // RTT 1: QUIC + TLS 合并
客户端 ← Initial(ServerHello...) ← 服务端
客户端 → 请求数据                 → 服务端     // 直接发

// QUIC (重连, 0-RTT)
客户端 → Initial + 0-RTT数据     → 服务端     // 第一个包就带请求数据

0-RTT 是说:如果之前连过这个服务器,客户端缓存了一些加密参数,下次直接把请求数据塞进第一个包里发出去。服务端收到就能直接处理,不用等握手完成。

省下的这一个 RTT,在跨地域访问的时候特别值钱。北京到广州的 RTT 大概 30-40ms,到美西 150-200ms。对于一个首屏需要 3-4 个串行请求的页面,0-RTT 能直接砍掉一次握手延迟。

但 0-RTT 有个安全问题——重放攻击。

// ⚠️ 0-RTT 的数据可能被中间人截获并重放
// 所以只能用于幂等请求

// ✅ 适合 0-RTT 的:
fetch('/api/product/123', { method: 'GET' })  // 幂等,重放无副作用

// ❌ 不适合 0-RTT 的:
fetch('/api/order', { method: 'POST', body: orderData })  // 非幂等,重放会重复下单

服务端要自己判断哪些请求接受 0-RTT early data,哪些必须等握手完成。Nginx 的 ssl_early_data on 打开后,还得配合 Early-Data header 让后端知道这是 0-RTT 请求,由业务层决定是否处理。

连接迁移:移动端的大杀器

这个特性说出来简单,实际体感却最明显。

TCP 连接靠四元组标识(源 IP、源端口、目标 IP、目标端口)。手机从 WiFi 切到 4G,IP 变了,所有 TCP 连接全部断开,需要重新建连、重新握手、重新请求。

QUIC 用 Connection ID 标识连接,跟 IP 无关。网络切换时,换了 IP 没关系,Connection ID 还在,连接直接迁移过去。

// 模拟一个典型场景:用户在地铁里刷信息流

// HTTP/2 (TCP) 的表现:
// 1. 进隧道 → 信号丢失 → TCP 超时断开
// 2. 出隧道 → 重新 TCP 握手 (1 RTT)
// 3. 重新 TLS 握手 (1 RTT)
// 4. 重新发请求
// 用户感知:卡了 2-3 秒,页面白一下

// HTTP/3 (QUIC) 的表现:
// 1. 进隧道 → 信号丢失 → QUIC 探测包持续发送
// 2. 出隧道 → 探测包通了 → 连接恢复,继续传输
// 用户感知:卡了一下就好了

之前我们 App 里的 WebView 页面,在弱网环境下的白屏率有 8% 左右。上了 HTTP/3 之后降到 5% 出头。不全是连接迁移的功劳,但占了很大一块。

前端资源加载的实际收益量化

光说原理没用,得看数据。我们做了个 A/B 测试,对照组走 HTTP/2,实验组走 HTTP/3,跑了两周。

测试环境:
- CDN 已支持 HTTP/3 (Cloudflare)
- 页面资源:1 个 HTML + 3 个 JS bundle + 2 个 CSS + 12 张图片
- 样本量:各组约 50 万 PV

结果(中位数):

                    HTTP/2    HTTP/3    提升
DNS + 连接建立      120ms     68ms     -43%    ← 0-RTT 贡献最大
首字节 (TTFB)       210ms     155ms    -26%
LCP                 1420ms    1230ms   -13%
FCP                 890ms     780ms    -12%

按网络类型拆分 LCP:
  4G 稳定网络        1350ms    1250ms   -7%     ← 好网络下差距不大
  4G 弱信号          2100ms    1650ms   -21%    ← 弱网收益明显
  WiFi               1180ms    1100ms   -7%
  网络切换期间        3200ms    1800ms   -44%    ← 连接迁移的功劳

几个观察:

好网络下提升有限,大概 7% 左右。丢包率低的时候,队头阻塞本来就不是瓶颈。

弱网才是 HTTP/3 的主场。丢包率 2% 以上的时候,QUIC 的独立流控优势就很明显了。

连接迁移的收益最夸张,但触发频率不高。不过对于那些被影响到的用户来说,体验是质变。

怎么在项目里落地

CDN 侧

大部分情况你不需要自己部署 QUIC,CDN 厂商基本都支持了。Cloudflare 默认开启,Akamai 和 AWS CloudFront 也都有。

关键是确认你的 CDN 在响应头里带了 Alt-Svc

// 服务端响应头,告诉浏览器"我支持 HTTP/3,你可以来"
Alt-Svc: h3=":443"; ma=86400

浏览器首次还是走 HTTP/2,看到 Alt-Svc 后下次才会尝试 HTTP/3。所以第一次访问是吃不到 HTTP/3 红利的。

Nginx 自建的情况

server {
    # HTTP/3 需要 UDP 443
    listen 443 quic reuseport;
    # 同时保留 HTTP/2 做降级
    listen 443 ssl;

    http2 on;
    http3 on;

    ssl_certificate     /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    # 0-RTT 开启(注意重放风险)
    ssl_early_data on;

    # 告知浏览器支持 HTTP/3
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    # 防火墙别忘了放行 UDP 443,这个坑我踩过
    # 当时排查了半天,curl 死活握不上,最后发现安全组只开了 TCP 443
}

前端代码层面

前端代码基本不需要改。HTTP/3 是传输层的升级,fetch/XHR 的 API 没有变化。

但有几个地方值得注意:

// 检测当前连接是否走了 HTTP/3
// Performance API 可以拿到协议信息
const entries = performance.getEntriesByType('resource')
entries.forEach(entry => {
  // nextHopProtocol 会告诉你实际用的协议
  console.log(entry.name, entry.nextHopProtocol)
  // "h3" → HTTP/3
  // "h2" → HTTP/2
})

// 统计 HTTP/3 的覆盖率,塞到你的监控里
const h3Ratio = entries.filter(e => e.nextHopProtocol === 'h3').length / entries.length
reportMetric('h3_coverage', h3Ratio)
// 资源加载提示,帮浏览器更快建立 QUIC 连接
// preconnect 对 HTTP/3 同样有效
const link = document.createElement('link')
link.rel = 'preconnect'
link.href = 'https://cdn.example.com'
document.head.appendChild(link)

// 更激进的做法:dns-prefetch + preconnect 一起上
// <link rel="dns-prefetch" href="https://cdn.example.com">
// <link rel="preconnect" href="https://cdn.example.com">

资源打包策略可能要调整

HTTP/2 时代的"拆小包"策略在 HTTP/3 下更合理了。

// webpack / vite 配置思路

// HTTP/1.1 时代:合并成大文件减少请求数
// HTTP/2 时代:拆成中等大小,利用多路复用
// HTTP/3 时代:可以拆得更碎,因为不会有 TCP 队头阻塞

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 拆包粒度可以更细
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 按包名拆,别一股脑塞进 vendor
            const name = id.split('node_modules/')[1].split('/')[0]
            return `vendor/${name}`
          }
        },
        // HTTP/3 下小文件的传输惩罚更低
        // 但也别拆太碎,每个文件还是有解析开销
        experimentalMinChunkSize: 5 * 1024, // 5KB 兜底
      }
    }
  }
}

不过这块我个人觉得不用太激进。除非你的页面资源特别多(50+ 个请求),否则 HTTP/2 和 HTTP/3 在打包策略上的差异不大。

几个容易踩的坑

UDP 被墙了。 不少企业网络、学校网络会封 UDP 443。浏览器会自动降级到 HTTP/2,但这个降级过程本身有延迟——浏览器得先尝试 QUIC 握手,超时后才回退。Chrome 默认等 300ms。

// 如果你发现某些用户的连接建立时间反而变长了
// 大概率是 QUIC 被封,降级到 HTTP/2 多了 300ms

// 可以通过 Performance API 监控降级情况
const nav = performance.getEntriesByType('navigation')[0]
if (nav.nextHopProtocol === 'h2' && someHeuristic()) {
  // 记录下来,看看降级比例
  reportMetric('quic_fallback', 1)
}

0-RTT 没生效。 0-RTT 需要浏览器缓存 TLS session ticket。如果用户清了缓存、换了浏览器、或者 session ticket 过期了,就退化成 1-RTT。实测下来 0-RTT 的命中率大概在 60-70%,没有想象中那么高。

服务端没准备好。 开了 HTTP/3 之后,服务端的 CPU 开销会涨一些。QUIC 的加密是逐包的,不像 TCP+TLS 可以批量处理。我们上线初期 CPU 涨了约 15%,后来升级了 Nginx 版本(用上了 kernel 的 UDP GSO)才压下去。

回过头看

HTTP/3 不是银弹。好网络下它的收益有限,可能就快那么几十毫秒。但在弱网、网络切换这些"极端"场景下,体验提升是实打实的。

而且说实话,这事儿的投入产出比很高——大部分工作在运维侧(CDN 开个开关、Nginx 加几行配置),前端代码几乎不用改。加个监控统计一下 HTTP/3 覆盖率和性能数据,基本就完事了。

值不值得搞?如果你的用户主要在桌面端、好网络,优先级可以放低。但如果移动端占比高、有海外用户、或者对弱网体验有要求,那值得尽早推。

使用 Vite Mode 实现客户端与管理端的物理隔离

一、背景与目标

在我们的项目中,客户端管理端共享绝大部分的组件、工具和样式代码,但两者的登录入口业务路由完全不同。过去,我们将它们放在同一个Vue项目中,通过同一套路由混合管理。

目标

我们希望在不拆分代码仓库、保持公共代码高效复用的前提下,实现彻底的隔离:

  • 执行 pnpm dev:client 时,我们得到一个仅包含客户端路由和页面的纯净开发环境
  • 执行 pnpm dev:admin 时,我们得到一个仅包含管理端路由和页面的纯净开发环境
  • 在执行构建时,能产出两个互不包含对方代码的独立部署包。

实现这一目标的核心,便是利用 Vite 的 --mode 参数在构建时区分应用,并动态决定最终生效的路由配置。

二、核心机制:命令行参数驱动

整个方案的核心是利用 Vite 的 --mode 参数,在启动和构建时向应用注入一个“身份标识”。

  1. 定义启动与构建命令 (package.json)

    我们通过不同命令传递不同的 mode值。为了能在单个终端窗口同时启动或构建两个应用,我们使用 concurrently 工具。

    {
      "scripts": {
        // 1. 独立操作命令
        "dev:client": "vite --mode client",
        "dev:admin": "vite --mode admin",
        "build:client": "vite build --mode client",
        "build:admin": "vite build --mode admin",
    
        // 2. 核心效率工具:使用 concurrently 一键操作两端
        "dev:all": "concurrently "pnpm dev:client" "pnpm dev:admin"",
        "build:all": "concurrently "pnpm build:client" "pnpm build:admin""
      },
      "devDependencies": {
        "concurrently": "^9.1.2", // 需要安装此依赖
      }
    }
    
  2. 动态Vite配置 (vite.config.js)

    Vite 配置函数能接收到 mode 参数,我们据此动态设置所有差异化配置。

    import { defineConfig } from 'vite';
    
    export default defineConfig(({ mode }) => {
      const isAdmin = mode === 'admin';
      const appType = isAdmin ? 'admin' : 'client';
    
      return {
        // 核心:将应用类型注入为全局常量 APP_TYPE
        define: {
          APP_TYPE: JSON.stringify(appType) // 关键:必须用JSON.stringify
        },
        server: {
          port: isAdmin ? 3001 : 3000, // 为不同端分配不同端口,避免冲突
          open: true
        },
        build: {
          outDir: isAdmin ? 'dist-admin' : 'dist-client' // 构建输出到不同目录
        }
        // ... 其他公共配置
      };
    });
    

三、核心难点与解决方案

  1. 全局常量替换的“坑”:为什么必须用 JSON.stringify

    Vite 的 define配置本质是字符串级别的查找替换,并非 JavaScript 的变量赋值。理解“替换成什么文本”是关键。

    • 错误配置define: { APP_TYPE: 'admin' }

      • 替换过程:代码中 'console.log(APP_TYPE)'里的 APP_TYPE会被直接替换为文本 'admin'
      • 结果代码:'console.log(admin)'
      • 问题admin没有引号,会被 JavaScript 引擎当作一个变量名,导致报错 admin is not defined
    • 正确配置define: { APP_TYPE: JSON.stringify('admin') }

      • JSON.stringify('admin')的执行结果是字符串 '"admin"'(包含双引号)。
      • 替换过程:代码中的 APP_TYPE会被替换为文本 "admin"
      • 结果代码:console.log("admin")
      • 正确"admin"是一个字符串值,能正确打印。

    结论JSON.stringify的作用是把值转换成其对应的、有效的 JSON 字符串表示形式,确保替换后的代码语法正确。

  2. 路由动态配置:通过 APP_TYPE分离两套路由

    这是本方案的核心应用之一。在路由定义文件中,我们准备两套完全独立的路由数组,并通过 APP_TYPE 全局常量来决定最终使用哪一套。

    // src/router/index.js
    import { createRouter, createWebHistory } from 'vue-router'
    
    // 1. 定义客户端路由
    const clientRoutes = [
      { path: '/', component: () => import('@/views/client/Home.vue') },
      { path: '/profile', component: () => import('@/views/client/Profile.vue') }
    ]
    
    // 2. 定义管理员端路由
    const adminRoutes = [
      { path: '/admin', component: () => import('@/views/admin/Dashboard.vue') },
      { path: '/admin/users', component: () => import('@/views/admin/UserList.vue') }
    ]
    
    // 3. 根据 APP_TYPE 动态选择路由
    const routes = APP_TYPE === 'client' ? clientRoutes : adminRoutes
    
    export default createRouter({
      history: createWebHistory(),
      routes // 使用确定后的路由
    })
    
  3. TypeScript 支持:声明全局常量

    在项目文件中使用 APP_TYPE 时,TypeScript 会因找不到定义而报错。需在类型声明文件中声明此全局常量。

    // src/env.d.ts
    
    // 声明通过 define 注入的全局常量
    declare const APP_TYPE: 'client' | 'admin';
    

    此声明为 APP_TYPE 提供了类型支持,使其在代码中具备完整的类型提示与检查。

四、完整工作流程

  1. 安装依赖pnpm add -D concurrently

  2. 配置命令:按上文修改 package.json

  3. 配置Vite:按上文创建动态的 vite.config.js

  4. 声明类型:创建 src/env.d.ts文件声明 APP_TYPE

  5. 代码中区分逻辑:在任何需要区分两端的地方使用 APP_TYPE常量。

    // 例如在路由、组件、API配置中
    if (APP_TYPE === 'admin') {
      // 管理员端逻辑
    } else {
      // 客户端逻辑
    }
    
  6. 运行

    • pnpm dev:all一键启动两个端,分别访问 http://localhost:3000(client) 和 http://localhost:3001(admin)。
    • pnpm build:all一键构建两个端,产物分别输出到 dist-clientdist-admin 目录。

五、方案对比与更优方案

在理解了通过 define 配置全局常量的原理后,你会发现 Vite 本身就提供了更简洁的内置方案。

更优方案:直接使用 import.meta.env.MODE

Vite 会自动将 --mode参数的值注入到 import.meta.env.MODE这个内置环境变量中。这意味着你可以完全省略配置 define 和声明 .d.ts 文件的步骤,直接使用它。

在代码的任何地方,直接判断 import.meta.env.MODE即可。

// 路由配置中直接判断
const routes = import.meta.env.MODE === 'client' ? clientRoutes : adminRoutes;

// 在组件或逻辑中
if (import.meta.env.MODE === 'admin') {
  // 管理员端专属逻辑
}

一次视频会议的“生命旅程”:从点击加入到大屏相见,Mediasoup 背后发生了什么?

一、故事的开端:你有没有想过?

当你在腾讯会议、Zoom、飞书会议里点击"加入会议"后,几秒钟内就能看到其他人的画面、听到他们的声音——这背后发生了什么?

微信图片_20260307224848_5604_6.png 最简单的方案是"点对点"连接,但10个人开会就需要45个连接!更好的方案是 SFU(选择性转发单元) :大家把视频发给服务器,服务器转发给其他人。Mediasoup 就是这样的服务器。本文讲基于Mediasoup讲述这背后服务之间是如何进行配合的。

二、三个角色,各司其职

image.png

服务 比喻 职责
mediasoup-ui 电视机 采集画面、播放声音、用户交互
signal-bridge 信号转换器 协议翻译(JSON ↔ protoo)
signal-server 播控中心 管理房间、转发媒体流

三、一次视频会议的"生命旅程"

让我们跟随一个用户"小马"的视角,看看他从加入会议到看到其他人画面的完整过程:

第一步:小马打开网页 📺

sequenceDiagram
小马->>UI: 点击加入会议
UI->>Server: 建立websocket连接
Server-->>小马: 准备好接收和发送媒体流

第二步:获取"电视频道列表" 📋

// 小马问服务器:你们支持哪些视频格式?
const routerRtpCapabilities = await this.signaling.request('getRouterRtpCapabilities');

// 小马的浏览器检查:这些格式我支持吗?
this.device = new mediasoupClient.Device();
await this.device.load({ routerRtpCapabilities });
// 如果没有报错,说明可以正常通信!

通俗解释:就像你买了一个新电视,先要检查能不能收到当地电视台的信号格式(高清还是标清)。

第三步:铺设"信号线" 🔌

小马需要两条"线":

  • 发送线:把小马的画面传给服务器
  • 接收线:从服务器接收其他人的画面
async createTransports() {
    // 📤 创建发送线
    const sendInfo = await this.signaling.request('createWebRtcTransport', {
        forceTcp: false,
        appData: { direction: 'producer' },  // 我是生产者
    });

    this.sendTransport = this.device.createSendTransport({
        id: sendInfo.transportId,
        iceParameters: sendInfo.iceParameters,      // 冰块参数(网络地址)
        iceCandidates: sendInfo.iceCandidates,      // 候选地址列表
        dtlsParameters: sendInfo.dtlsParameters,    // 加密参数
    });

    // 📥 创建接收线(代码类似)
    const recvInfo = await this.signaling.request('createWebRtcTransport', {
        appData: { direction: 'consumer' },  // 我是消费者
    });
    this.recvTransport = this.device.createRecvTransport({...});
}

Transport: 就像一根水管,你需要两根——一根往里注水(发送),一根往外放水(接收)。

第四步:服务器端铺设"水管" 🏗️

服务器收到请求后,在 mediasoup 里创建真正的 Transport:

// signal-server/Room.ts
const transport = await mediasoupRouter.createWebRtcTransport({
    webRtcServer: mediasoupWebRtcServer,  // 共享端口服务器
    enableUdp: true,   // 支持UDP(更快)
    enableTcp: true,   // 支持TCP(更稳定)
    appData: { direction },  // 记录这是发送还是接收
});

// 返回给客户端
resolve({
    transportId: transport.id,
    iceParameters: transport.iceParameters,
    iceCandidates: transport.iceCandidates,
    dtlsParameters: transport.dtlsParameters,
});

第五步:小马打开摄像头 📹

async enableMic({ stream } = {}) {
    // 1. 向浏览器申请摄像头/麦克风权限
    const localStream = await navigator.mediaDevices.getUserMedia({ 
        audio: true, 
        video: false 
    });
    const track = localStream.getAudioTracks()[0];

    // 2. 通过发送线,把画面发出去
    this.micProducer = await this.sendTransport.produce({ track });
}

关键来了!  当调用 produce() 时,会触发一个事件:

// 监听 'produce' 事件 - 这是 WebRTC 的核心!
this.sendTransport.on('produce', async ({ kind, rtpParameters }, callback) => {
    // 通知服务器:我要发送一个媒体流
    const { producerId } = await this.signaling.request('produce', {
        transportId: this.sendTransport.id,
        kind,              // 'audio' 或 'video'
        rtpParameters,     // 编码参数
    });
    
    // 告诉本地 Transport:服务器已经准备好了
    callback({ id: producerId });
});

第六步:服务器创建 Producer 🎙️

服务器收到请求后,创建一个"生产者"对象:

// signal-server/Peer.ts
case 'produce': {
    const { transportId, kind, rtpParameters, appData } = data;
    const transport = this.getTransport(transportId);
    
    // 🎯 核心API:创建 Producer
    const producer = await transport.produce({
        kind,           // 音频还是视频
        rtpParameters,  // 编码参数
        appData: { 
            peerId: this.id,    // 是谁发的
            source: 'mic',      // 来源是什么
        },
    });

    // 🔔 重要:触发事件,通知房间里其他人
    this.emit('new-producer', { producer });
    
    // 返回 Producer ID 给客户端
    accept({ producerId: producer.id });
}

第七步:其他用户收到小马的画面 👥

Room 监听到 new-producer 事件后,会为其他用户创建 Consumer:

// signal-server/Room.ts
peer.on('new-producer', async ({ producer }) => {
    // 获取房间里除了小明以外的所有人
    const otherPeers = this.getOtherPeers(peer);
    
    // 为每个人创建 Consumer(消费者)
    for (const otherPeer of otherPeers) {
        await otherPeer.consume({ producer });
    }
});

创建 Consumer 的详细过程:

// signal-server/Peer.ts
async consume({ producer }) {
    const transport = this.getRecvTransport();
    
    // 🎯 创建消费者(初始暂停状态)
    const consumer = await transport.consume({
        producerId: producer.id,
        rtpCapabilities: this.rtpCapabilities,
        paused: true,  // 先暂停,等客户端准备好
    });

    // 📢 通知客户端:有新的媒体流可以消费
    await this.request('newConsumer', {
        peerId: producer.appData.peerId,   // 谁发的
        consumerId: consumer.id,
        producerId: producer.id,
        kind: consumer.kind,               // 音频还是视频
        rtpParameters: consumer.rtpParameters,
    });

    // 客户端确认后,恢复传输
    await consumer.resume();
}

第八步:小王的浏览器显示小马的画面 🖥️

// mediasoup-ui 处理 newConsumer 请求
async handleServerRequest(request) {
    if (request.method === 'newConsumer') {
        const { consumerId, producerId, kind, rtpParameters } = request.data;
        
        // 📥 消费这个媒体流
        const consumer = await this.recvTransport.consume({
            id: consumerId,
            producerId,
            kind,
            rtpParameters,
        });

        // 🎬 获取媒体轨道,创建可播放的流
        const stream = new MediaStream([consumer.track]);
        
        // 把流绑定到 video/audio 标签
        const videoElement = document.getElementById('remote-video');
        videoElement.srcObject = stream;
        
        // 接受请求,服务器开始传输
        request.accept();
    }
}

四、完整流程图

sequenceDiagram
    participant UI as mediasoup-ui<br/>(小马浏览器)
    participant Bridge as signal-bridge<br/>(协议转换)
    participant Server as signal-server<br/>(媒体服务器)

    Note over UI,Server: 1️⃣ 建立连接
    UI->>Bridge: WebSocket 连接
    Bridge->>Server: protoo 连接
    Server-->>Bridge: 连接成功
    Bridge-->>UI: protooOpen

    Note over UI,Server: 2️⃣ 获取路由能力
    UI->>Bridge: getRouterRtpCapabilities
    Bridge->>Server: 转发请求
    Server-->>Bridge: router.rtpCapabilities
    Bridge-->>UI: 返回能力
    UI->>UI: Device.load()

    Note over UI,Server: 3️⃣ 创建传输通道
    UI->>Bridge: createWebRtcTransport
    Bridge->>Server: 转发请求
    Server->>Server: 创建 Transport
    Server-->>UI: {transportId, iceParams...}
    UI->>UI: 创建 SendTransport/RecvTransport

    Note over UI,Server: 4️⃣ 加入房间
    UI->>Bridge: join {displayName, rtpCapabilities}
    Bridge->>Server: 转发请求
    Server->>Server: 创建 Peer
    Server-->>UI: {peers: [已在线用户]}

    Note over UI,Server: 5️⃣ 打开摄像头
    UI->>UI: getUserMedia()
    UI->>UI: sendTransport.produce()
    UI->>Bridge: produce {kind, rtpParameters}
    Bridge->>Server: 转发请求
    Server->>Server: 创建 Producer
    Server-->>UI: {producerId}

    Note over UI,Server: 6️⃣ 其他用户接收
    Server->>Server: 触发 new-producer 事件
    Server->>Server: 为其他 Peer 创建 Consumer
    Server-->>UI: newConsumer 请求
    UI->>UI: recvTransport.consume()
    UI-->>Server: accept
    Server->>Server: consumer.resume()

五、媒体流路由示意图

image.png

六、信令 vs 媒体

flowchart TB
    subgraph Signaling[信令通道 - 控制面]
        S1[WebSocket]
        S2[JSON/protoo 协议]
        S3[传输控制消息]
    end

    subgraph Media[媒体通道 - 数据面]
        M1[WebRTC]
        M2[ICE/DTLS/SRTP]
        M3[传输音视频数据]
    end

    Client[客户端] --> S1
    Client --> M1
    S1 --> Server[服务器]
    M1 --> Server
类型 协议 传输内容
信令 WebSocket + JSON 控制消息(加入房间、创建Transport等)
媒体 WebRTC (ICE/DTLS/SRTP) 音视频数据流

七 关键 API 速查表

mediasoup-client(浏览器端)

API 说明 使用场景
new Device() 创建设备对象 初始化时
device.load({ routerRtpCapabilities }) 加载服务器能力 加入房间前
device.createSendTransport() 创建发送通道 准备发送媒体
device.createRecvTransport() 创建接收通道 准备接收媒体
transport.produce({ track }) 生产媒体流 打开摄像头/麦克风
transport.consume({ id, ... }) 消费媒体流 接收远程媒体

mediasoup(服务器端)

API 说明 使用场景
worker.createRouter({ mediaCodecs }) 创建路由器 创建房间时
router.createWebRtcTransport() 创建传输通道 用户加入时
transport.produce({ kind, rtpParameters }) 创建生产者 用户发送媒体
transport.consume({ producerId, rtpCapabilities }) 创建消费者 分发媒体给其他人
router.pipeToRouter({ producerId, router }) 跨路由传输 高级场景,分离生产/消费

八、写在最后

理解 Mediasoup 的关键点:

  1. SFU 架构:服务器只转发,不编解码,所以延迟低
  2. Transport 是核心:一切媒体传输都通过 Transport
  3. Producer/Consumer 模式:一人生产,多人消费
  4. 信令与媒体分离:WebSocket 传控制消息,WebRTC 传媒体数据
  5. 事件驱动new-producer 事件触发 consume,形成完整链路

从递归组件到 DSL 引擎:我造了一个让 AI 能"搭 UI"的运行时

从递归组件到 DSL 引擎:我造了一个让 AI 能"搭 UI"的运行时

我最初只是想用 Vue 递归组件做动态渲染,后来发现这条路的天花板比想象中低得多。这篇文章记录了我从零设计一个 Schema-Driven 渲染引擎的过程——踩过的坑、做过的取舍、以及为什么我认为这种架构天然适合 AI 时代。


一、起点:递归组件的天花板

故事的起点很简单。我要做一个低代码平台,需要根据 JSON 配置动态渲染 UI。最直觉的方案是 Vue 的递归组件:

<template>
  <component :is="node.type" v-bind="node.props">
    <DynamicRenderer
      v-for="child in node.children"
      :key="child.id"
      :node="child"
    />
  </component>
</template>

一开始能跑通。但随着需求复杂度上升,问题一个接一个冒出来:

性能没法深入优化——每个递归组件都是一个完整的 Vue 组件实例,有自己的生命周期、reactive 系统开销。100 个节点就是 100 个组件实例,1000 个节点时页面已经开始卡了。你没有办法跳过没变化的子树,因为 Vue 的响应式系统是按组件粒度工作的。

事件处理不好做——JSON 里写的是 { event: 'click', handler: 'submitForm' },但递归组件要把这个字符串映射成真实的函数调用,你得自己写一套 $emit 转发链,越写越像在造一个 mini 框架。

双向绑定更麻烦——v-model 在递归组件里要一层层 $emit('update:modelValue') 往上冒泡,或者搞一个全局 store 做中间层,写法又丑又容易出 bug。

表达式求值是个坑——JSON 里写 "disabled": "{{ !isValid }}",你要么 eval() 一下(安全隐患),要么自己写个表达式解析器(工作量巨大),反正递归组件本身帮不了你。

我意识到,递归组件方案的本质问题是:它还是在用"组件"的粒度思考,但 Schema 驱动的 UI 需要的是"节点"粒度的控制权

于是我开始想:如果不用递归组件,而是直接把 Schema 编译成 VNode 呢?如果把"事件处理"抽成一个指令集虚拟机呢?如果把表达式解析做成一个安全沙箱呢?

这就是 Vario 的起点。


二、Vario 全貌:三层解耦的 Schema 渲染运行时

先交代 Vario 的完整架构。它不是一个组件库,不是一个低代码平台,是一个 Schema 渲染运行时——由 4 个包组成的 monorepo,总共约 10,000 行 TypeScript 源码,579 个单元/集成测试全部通过。

@variojs/types   — 跨包共享类型(无业务逻辑,消除循环依赖)
@variojs/core    — Action VM + 表达式引擎 + RuntimeContext(零 Vue 依赖)
@variojs/schema  — defineSchema + 验证 + 规范化
@variojs/vue     — useVario composable + VNode 渲染器

数据流是单向的:

Schema (JSON 对象)
     ↓  normalizeSchemaNode()  规范化(空格/格式统一,WeakMap 缓存)
     ↓  validateSchema()       结构验证 + 表达式 AST 白名单校验
     ↓
@variojs/core
     ↓  createRuntimeContext()  创建状态上下文(Proxy 保护系统 API)
     ↓  evaluate()             表达式求值(Babel AST → 白名单 → 编译/解释)
     ↓  execute()              Action VM 执行指令序列(超时 5s,最大 10000 步)
     ↓
@variojs/vue
     ↓  useVario()             Composition API 入口
     ↓  VueRenderer.render()   Schema 递归 → VNode 树
     ↓  Path Memo              缓存无变化的子树 VNode
     ↓
Vue 3 接管渲染

关键架构约束@variojs/core 零 Vue 依赖,这是从第一天就定下的硬性要求。Core 里的 Action VM、表达式引擎、RuntimeContext 完全不知道 Vue 的存在——这意味着将来换成 React、Solid、甚至 Node.js 服务端渲染,Core 层不需要改一行代码。


三、先看看 Vario 写出来长什么样

直接上代码。一个带交互逻辑的表单:

import { useVario } from '@variojs/vue'

const { vnode, state } = useVario({
  type: 'ElForm',
  props: { labelWidth: '100px' },
  children: [
    {
      type: 'ElFormItem', props: { label: '姓名' },
      children: [{ type: 'ElInput', model: 'name', props: { clearable: true } }]
    },
    {
      type: 'ElFormItem', props: { label: '邮箱' },
      children: [{ type: 'ElInput', model: 'email', props: { type: 'email' } }]
    },
    {
      type: 'ElButton',
      props: { type: 'primary', disabled: '{{ !(name && email) }}' },
      events: { 'click.prevent': [{ type: 'call', method: 'submit' }] },
      children: '提交'
    }
  ]
}, {
  state: { name: '', email: '' },
  computed: { isValid: (s) => !!(s.name && s.email) },
  methods: {
    submit: ({ state }) => { console.log('提交:', state.name, state.email) }
  }
})

如果你写过 Vue,你会发现:ElInputElButtonElFormItem 就是 Element Plus 的组件名,model: 'name' 就是 v-modelclick.prevent 就是 @click.preventuseVario() 返回的 { vnode, state } 就是标准的 Composition API 用法。

这是有意为之的设计。


四、深入 VueRenderer——Schema 如何变成 VNode

VueRenderer 是整个渲染链的核心,638 行代码,内部采用 DI 风格拆分为 9 个专职模块:

模块 职责
ComponentResolver 组件类型解析(80+ 原生 HTML 标签 Set + 全局组件 Map 缓存)
ModelPathResolver model 路径解析(228 行,支持嵌套循环变量 $item 解析、路径栈拼接)
ExpressionEvaluator 表达式求值(桥接 @variojs/core 的 evaluate)
EventHandler 事件绑定(366 行,6 种事件处理器格式规范化,修饰符解析)
AttrsBuilder 属性构建(props 表达式求值 + model 绑定 + 事件合并)
LoopHandler 循环渲染(createLoopContext 对象池复用 + Fragment 包裹)
ChildrenResolver 子节点解析(文本插值 / 作用域插槽 / VNode 子树)
LifecycleWrapper 生命周期包装(6 个 Vue 生命周期钩子 + provide/inject)
PathMemoCache VNode 缓存(路径 + schemaId + 依赖键三级缓存键)

一个 createVNode() 调用的完整流程(20 个步骤):

createVNode(schema, ctx, path)
 1. ─ 验证 schema.type 存在
 2. ─ cond 条件渲染:表达式 falsy → return null
 3. ─ show 预求值:计算可见性用于依赖追踪
 4. ─ Path Memo 判断:无 loop/model/表达式的静态子树 → 直接返回缓存
 5. ─ 子树组件化判断:shouldComponentize() → VarioNode 独立组件
 6. ─ Loop 处理:委托 LoopHandler → Fragment(循环项VNode[])
 7. ─ 组件解析:原生标签返回字符串,自定义组件 markRaw() 防响应式
 8. ─ Model 路径栈更新:嵌套 model 路径拼接
 9. ─ 属性构建:props 表达式求值 + model 双向绑定 + 事件处理器
10. ─ 子节点解析:递归 VNode / 插值文本 / 作用域插槽
11. ─ show 可见性:{display: 'none'} 合并到 style
12. ─ Children 格式化:原生元素用数组,组件用函数插槽
13. ─ 生命周期/provide-inject:有则创建 LifecycleWrapper 组件
14. ─ ref 绑定:attachRef 到 RefsRegistry
15. ─ 自定义指令:withDirectives() 应用
16. ─ KeepAlive 包裹
17. ─ Transition 包裹
18. ─ Teleport 包裹
19. ─ Path Memo 写入缓存
20. ─ 返回 VNode

这 20 步的排列顺序不是随意的——Teleport 必须是最外层包裹(否则内部元素不会被传送),KeepAlive 必须在 Transition 之前(Vue 的渲染约束),Path Memo 的缓存判断必须在 Loop 之前(带循环的子树不能缓存)。

双向绑定是怎么做的

createModelBinding() 是整个渲染器最复杂的单个函数(310 行),需要处理:

  • 原生表单元素 (input/textarea/select)——不同元素用不同事件名和属性名
  • Vue 3 组件——modelValue + update:modelValue 协议
  • 具名 model——model:checkedmodel:value 支持一个组件绑定多个 model
  • 修饰符——.trim(去空格),.number(parseFloat),.lazy(change 替代 input)
  • lazy 模式——setTimeout(() => isActive = true, 0) 延迟激活,挂载期间不写 state
  • 自定义绑定协议——通过 registerModelConfig() 注册

ctx ↔ Vue 状态同步——ReactiveAdapter 单一数据源

早期版本中,useVario() 需要在 RuntimeContext 的 plain object 和 Vue 的 reactive state 之间维护双向同步,靠三把锁(syncing / syncingPaths / watchSyncing)防止循环触发。这套机制能跑,但脆弱且难以理解。

当前版本已经用 ReactiveAdapter 协议彻底消灭了这个问题。核心思路受 Zustand 启发——状态只有一份:

// @variojs/types 中定义协议
interface ReactiveAdapter {
  get(path: string): unknown
  set(path: string, value: unknown): void
  getProperty(key: string): unknown
  setProperty(key: string, value: unknown): void
  has(key: string): boolean
  keys(): string[]
}

Vue 层提供 createVueReactiveAdapter(reactiveState),内部直接操作 reactive() 对象。Core 的 createRuntimeContext 接受 adapter 参数后,_get/_set 通过 adapter 读写,Proxy 的 5 个 trap(get/set/has/ownKeys/getOwnPropertyDescriptor)也路由到 adapter。

// useVario 中,三重锁被替换为两行代码:
const adapter = createVueReactiveAdapter<TState>(reactiveState)
const ctx = createRuntimeContext<TState>({}, { adapter, onStateChange, ... })

没有双份状态 = 没有同步 = 没有循环 = 不需要锁。 ctx._set('name', 'Alice') 直接写入 Vue 的 reactive 对象,onStateChange 只做缓存失效和渲染调度,不再做状态搬运。useVario 从 636 行减到 570 行,核心同步逻辑从 ~65 行减到 ~10 行。


五、Action VM:不用 eval 的动作执行引擎

传统方案处理"交互逻辑"的方式是往框架里挂副作用——watch、reaction、onChange。Vario 走的是完全不同的路:指令集虚拟机

当前支持 13 种指令,分 5 个类别:

类别 指令
状态 set { type: 'set', path: 'user.name', value: '{{ input }}' }
数组 push pop shift unshift splice { type: 'push', path: 'todos', value: { text: '{{ newText }}' } }
调用 call { type: 'call', method: 'submit', params: { id: '{{ userId }}' }, resultTo: 'result' }
流控 if loop batch { type: 'if', cond: '{{ isValid }}', then: [...], else: [...] }
通信 emit navigate log { type: 'navigate', to: '{{ targetUrl }}' }

这些指令之间是正交组合的关系——ifthen/else 分支里可以嵌套任何指令,loopbody 里也可以,batch 可以包裹一组指令并做错误聚合(所有指令都执行,收集所有错误,最后统一抛出 BatchError)。

执行器的核心设计:不是 switch/case——所有动作(包括内置的 13 种)通过 ctx.$methods[action.type] 统一分派。这意味着你可以注册自定义指令类型,和内置指令完全平等。

一个真实的 Todo App 中"按下 Enter 添加待办"的事件定义:

{
  "events": {
    "keyup": [{
      "type": "if",
      "cond": "{{ $event.key === 'Enter' }}",
      "then": [{ "type": "call", "method": "addTodo" }]
    }]
  }
}

这里 $event 是运行时注入的 DOM 事件对象。if 指令先用表达式引擎求值 cond,为 true 时执行 then 分支里的 call 指令。整个过程不需要一行 JavaScript 事件处理代码。

call 指令的三种参数形式

// 字符串表达式——整个 params 是一个表达式求值结果
{ "type": "call", "method": "search", "params": "{{ keyword }}" }

// 对象命名参数——逐属性求值
{ "type": "call", "method": "addToCart", "params": { "id": "{{ product.id }}", "qty": 1 } }

// 数组位置参数——逐元素求值
{ "type": "call", "method": "calc", "params": ["{{ a }}", "{{ b }}"] }

resultTo 字段可以把方法返回值写回状态:{ type: 'call', method: 'fetchUser', resultTo: 'currentUser' } —— 这让你可以在纯 JSON 中编排异步数据流。

安全保护

  • 超时 5 秒(AbortController + Date.now 双重保护)
  • 最大执行步数 10000 步
  • 独立的错误类型层级:VarioError → ActionError / ExpressionError / ServiceError / BatchError
  • 18 个标准错误码(ACTION_TIMEOUTSERVICE_NOT_FOUNDEXPRESSION_UNSAFE_ACCESS 等)

Schema 和 methods 的刻意分离

这里要说清楚一个设计边界——Schema 是"做什么"(纯数据,可序列化),methods 是"怎么做"(JS 函数,在代码库里,走 git 管理)。

{ type: 'call', method: 'addTodo' } 这条指令可以存进数据库、被 AI 生成、被服务端下发。但 addTodo 这个函数本身不在 Schema 里——它是你预先注册的业务代码。这不是缺陷,这是安全边界。 如果函数也能动态下发执行,等于在数据库里存了可执行代码,这是经典的安全漏洞。


六、表达式沙箱:Babel AST + 白名单 + 编译器 + LRU 缓存

在 Schema 里你可以写表达式:

{ "children": "Hello {{ name }}" }
{ "props": { "disabled": "{{ !(name && email) }}" } }
{ "cond": "{{ user.role === 'admin' }}" }
{ "children": "{{ items.filter(i => i.active).length }} 项激活" }

表达式引擎是整个 Core 里最大的模块(1,450 行),完整的处理流水线是:

"{{ user.name || 'Guest' }}"
    ↓ extractExpression()
"user.name || 'Guest'"
    ↓ getCachedExpression() → 命中? → 直接返回
    ↓ parseExpression() → @babel/parser
AST: LogicalExpression { left: MemberExpression, right: StringLiteral }
    ↓ validateAST() → 白名单逐节点检查
    ↓ compileSimpleExpression() → 简单表达式? → (ctx) => ctx._get("user.name") 快速路径
    ↓ evaluateExpression() → 复杂表达式? → AST 解释执行(682 行完整求值器)
    ↓ extractDependencies() + setCachedExpression() → LRU 缓存
→ "Alice"

白名单验证——逐 AST 节点检查

允许的(17 种节点类型)MemberExpressionOptionalMemberExpressionArrayExpressionObjectExpressionIdentifierBinaryExpressionLogicalExpressionUnaryExpressionConditionalExpressionCallExpressionTemplateLiteral 等。

永久禁止的(10 种节点类型)AssignmentExpression(赋值)、ArrowFunctionExpression(箭头函数)、ThisExpressionNewExpressionAwaitExpressionImportExpressionUpdateExpression++/--)、YieldExpressionMetaPropertySpreadElement

函数调用安全模型

  • 白名单全局函数:Math.*(abs/round/floor/ceil/random/max/min)、Array.isArrayObject.isNumber.isFinite/isInteger/isNaNDate.now
  • 数组实例方法:30 个安全方法(filter/map/find/includes/slice/concat/join/sort/at 等),push/pop/splice 等修改型方法被排除
  • 全局对象访问:window/document/global/ globalThis/self 引用被永久阻止
  • 危险属性:constructor/prototype/__proto__ 访问被禁止
  • 危险函数:eval/Function/setTimeout/setInterval 被永久禁止

编译器——简单表达式的快速路径

对于 {{ count }}{{ user.name }}{{ 42 }} 这种简单表达式,不需要走完整的 AST 解释器。编译器会把它们直接编译为:

// {{ count }}  →  (ctx) => ctx._get("count")
// {{ user.name }}  →  (ctx) => ctx._get("user.name")
// {{ 42 }}  →  () => 42

这些编译后的函数缓存在 Map<string, CompiledExpression> 中,后续调用直接执行函数,跳过 AST 解析和解释,执行耗时 <1ms。

缓存系统——按上下文隔离的 LRU

WeakMap<RuntimeContext, Map<string, ExpressionCache>>
  • 每个 RuntimeContext 有独立缓存,上下文被 GC 时缓存自动回收
  • 最大 100 条,超限 LRU 淘汰
  • 依赖驱动失效:invalidateCache('user.name', ctx) 会遍历缓存,清除所有依赖链中包含 user.nameuser.* 的条目

实际的 trade-off

要诚实面对:

  • 你不能在 {{ }} 里写 (() => { ... })(),因为箭头函数被禁了
  • 数组的修改型方法(push/pop)不能在表达式里用,要搬到 Action 指令或 methods 里
  • 没有 Formily 的 x-reactions 那种开箱即用的联动语法

这些限制是刻意的。 如果 Schema 是开发者手写的,限制确实增加了摩擦。但如果 Schema 来自数据库、AI 生成、用户可视化配置——白名单就是最后的安全防线。


七、Path Memo——让"1000 个节点只更新 1 个"成为可能

这是我在性能优化上投入最多的部分。Vario 提供 4 层可组合的渲染优化策略:

方案 A:Path Memo(默认启用)

核心思路:缓存每个路径的 VNode,下次渲染时判断依赖有没有变,没变直接返回缓存

Schema 树                    依赖追踪
───────────                  ──────────
root                         [](无依赖,静态容器)
├── header                   [](纯静态)
├── form
│   ├── input[username]      ["username"]
│   ├── input[email]         ["email"]
│   └── submit-btn           ["isValid"]
└── footer                   [](纯静态)

当 username 变化时:
→ input[username] → 依赖命中 → 重渲染
→ header/footer/email/submit-btn → 依赖未变 → 走缓存 ✅

哪些子树不能缓存:三个递归检测函数——hasExpressionInSubtree()hasLoopInSubtree()hasModelInSubtree()。任何含动态绑定的子树都跳过缓存。

缓存键由三部分组成:path + buildSchemaId(type|cond|show|loop|childrenLen) + buildDepsKey(condValue, showValue) ——确保同一路径在不同条件分支下不会返回错误的缓存。

方案 B:LoopItemAsComponent(循环场景推荐)

循环每项渲染为独立的 LoopItemCell 组件(82 行的 defineComponent),Vue 对 props 未变的组件自动跳过 re-render。

循环上下文通过 createLoopContext() 创建——使用 Object.create(parentCtx) 原型链继承,对象池复用(maxSize=10),finally 块确保归还。

方案 C:SubtreeComponent(大规模深嵌套场景)

每个 Schema 节点(或组件边界)渲染为 VarioNode 独立 Vue 组件(350 行),shouldComponentize() 根据粒度('all''boundary')和 maxDepth 决定哪些节点升级为组件。

方案 D:SchemaFragment(实验性,精确 Schema 更新)

不给整棵 Schema 树套一个大 reactive(),而是按路径碎片化存储:path → shallowReactive(node)patch(path, partialNode) 只触发依赖该 path 的 Vue effect。

实测数据

场景 无优化 Path Memo 加速
100 静态 + 1 动态 全量 只渲 1 个 88x
复杂嵌套表单 基线 缓存命中 2-15x
大表格单行更新 基线 精准行更新 4-29x

1772387082094-dflyfiu5.png

▲ 内置的性能测试仪表盘,可以对比开关各种优化策略的渲染耗时


八、Vue 开发者的上手成本——四种方案写同一个表单

这是 Vario 最在意的一件事:渐进式接入,对 Vue 开发者来说切换到 Schema 写法的心智负担应该尽可能低。

同一个表单,四种方案对比:

原生 Vue 3

<template>
  <el-form label-width="100px">
    <el-form-item label="姓名">
      <el-input v-model="name" clearable />
    </el-form-item>
    <el-button @click.stop="submit" :disabled="!isValid">提交</el-button>
  </el-form>
</template>
<script setup>
const name = ref('')
const isValid = computed(() => !!name.value)
const submit = () => { /* ... */ }
</script>

Formily

{
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "title": "姓名",
      "x-decorator": "FormItem",
      "x-component": "Input",
      "x-component-props": { "clearable": true }
    }
  }
}

还需要 createForm()FormProviderSchemaField 等包裹层。组件名是 Formily 注册名(Input),不是 Element Plus 原生名。

amis

{
  "type": "form",
  "body": [
    { "type": "input-text", "name": "name", "label": "姓名" }
  ]
}

极简,但组件名是 amis 自己的类型系统(input-text)。

Vario

const { vnode, state } = useVario({
  type: 'ElForm', props: { labelWidth: '100px' },
  children: [
    {
      type: 'ElFormItem', props: { label: '姓名' },
      children: [{ type: 'ElInput', model: 'name', props: { clearable: true } }]
    },
    {
      type: 'ElButton',
      props: { disabled: '{{ !isValid }}' },
      events: { 'click.stop': [{ type: 'call', method: 'submit' }] },
      children: '提交'
    }
  ]
}, {
  state: { name: '' },
  computed: { isValid: (s) => !!s.name },
  methods: { submit: ({ state }) => { /* ... */ } }
})

Vario 对齐了 Vue 的哪些概念:

Vue 概念 Vario 对应 说明
v-model="name" model: 'name' 一个字符串搞定
@click.stop.prevent events: { 'click.stop.prevent': [...] } 点语法完全一致
ref="myInput" ref: 'myInput' 模板引用同名
Element Plus ElInput type: 'ElInput' 直接用注册的组件名
:disabled="!isValid" props: { disabled: '{{ !isValid }}' } 表达式换了个括号
computed computed: { isValid: (s) => ... } Options 风格函数
v-show show: '{{ condition }}' 条件显示
v-if cond: '{{ condition }}' 条件渲染
v-for loop: { items: '{{ list }}', itemKey: 'item' } 循环渲染
provide/inject provide: {...} / inject: [...] 依赖注入
<Teleport> teleport: '#target' 传送门
<Transition> transition: { name: 'fade' } 过渡动画
<KeepAlive> keepAlive: true 组件缓存
生命周期 onMounted: 'initMethod' 6 个 Vue 生命周期钩子
useVario() 返回值 { vnode, state, ctx, refs, error, stats, retry, find, findAll, findById } 完整的 Composition API

你只需要接受一个新概念:把模板写成 JS 对象。 其他所有东西——组件名、prop 名、事件名、修饰符——都跟你平时写 Vue 一模一样。

代价也要说清楚:

  • IDE 支持弱于 .vue 文件——只有类型提示,没有模板语法高亮和组件标签补全
  • 比 amis 啰嗦——同样的表单 amis 4 行搞定
  • 校验联动目前要手动实现——Formily 的 x-validatorx-reactions 是开箱即用的

九、为什么不直接用 h() 函数?

这个问题是理解 Vario 架构的关键。

Vue 的 h() 函数完全可以做到 Schema → VNode 的映射:

// h() 写法
const vnode = h('div', {}, [
  h(ElInput, { modelValue: state.name, 'onUpdate:modelValue': v => state.name = v }),
  h(ElButton, { onClick: () => submit() }, '提交')
])

渲染结果完全一样。h() 更直接,TypeScript 支持更好(完整的 prop 类型推导),性能也更好(少了一层解析)。

那 Schema 多这一层解析换来了什么?

答案是:h() 是代码,Schema 是数据。

h() 函数 Schema 对象
本质 函数调用——指令 普通 JS 对象——描述
能否 JSON.stringify ❌ 函数不可序列化 ✅ 纯 JSON
静态分析 ❌ 必须执行才知道结构 ✅ 不执行就能遍历、验证、转换
AI 生成 ⚠️ 要生成合法 JS ✅ 生成 JSON,格式可约束
运行时增量修改 ⚠️ 重新组装函数 SchemaStore.patch('children.0.props', { disabled: true })
路径级缓存 ❌ 每次全量重执行 ✅ Path Memo 跳过未变子树
存数据库 / 服务端下发 ❌ 不能下发代码 ✅ 下发 JSON
查询 / 检索 ❌ 无法对函数调用做 findById find(n => n.type === 'ElInput') 查询引擎

如果你的 Schema 永远只在 .ts 文件里手写,那 h() 确实更直接。 但如果 Schema 来自数据库、来自 AI 生成、来自可视化配置后台——"数据 vs 代码"的区别就是一切。

Path Memo、SchemaStore.patch、QueryEngine、Schema 验证器——这些能力全都依赖于"Schema 是数据"这个基础假设。


十、AI + Schema:为什么这个架构天然适合 AI 时代

这是我做 Vario 最深层的动机,也是我认为它最大的潜力所在。

现在 AI 生成代码已经很成熟了。但你让 AI 生成一个完整的 Vue SFC——template、script、style——它经常会出错:import 写错、ref 和 reactive 混淆、生命周期用错地方、组件名不存在……

但如果让 AI 生成的不是代码,而是 JSON 呢?

{
  "type": "ElCard",
  "children": [
    { "type": "ElInput", "model": "keyword", "props": { "placeholder": "搜索..." } },
    {
      "type": "div",
      "loop": { "items": "{{ results }}", "itemKey": "item" },
      "children": [{ "type": "span", "children": "{{ item.title }}" }]
    }
  ]
}

这个 JSON:

  1. 格式可约束——你可以给 AI 一个 SchemaNode 的类型定义,生成结果一定符合格式
  2. 可校验——validateSchema() 会对每个节点做结构验证 + 表达式 AST 白名单校验,不存在的组件类型、非法表达式都会被捕获
  3. 安全——即使这个 JSON 来自用户对话、来自远程接口,AST 白名单保证它不能执行 eval()、不能访问 window、不能 import() 动态加载
  4. 可增量修改——AI 不需要每次重新生成整个 UI,通过 SchemaStore.patch(path, partialNode) 做外科手术式更新,只触发依赖该 path 的 Vue effect

你可以想象这样一个工作流:

用户说:「帮我做一个商品搜索页面」
    ↓
AI 生成一份 Schema JSON
    ↓
validateSchema() 验证结构和表达式安全性
    ↓
Vario 运行时直接渲染
    ↓
用户说:「把搜索结果改成卡片布局」
    ↓
AI 生成一个 patch(只修改 layout 相关的节点)
    ↓
SchemaStore.patch() 增量更新,只有受影响的 VNode 重渲染

这个工作流中,AI 从头到尾不需要生成一行 JavaScript——它只生成 JSON 结构和指令序列。业务逻辑函数(methods)是人预先注册好的,AI 通过 { type: 'call', method: 'search' } 去调用。

方法层扮演的角色类似于 AI Agent 的 "Tools"——预定义好的能力接口,AI 只负责编排调用顺序和参数。


十一、竞品横向对比

做之前我认真看了现有的方案。这里不是要说"我比他们好"——他们是大厂几百人维护了好几年的项目,我一个人做的东西没资格这样说。但设计选择确实不同,值得讨论。

维度 Vario Formily(阿里) amis(百度)
GitHub Stars 新项目 12.6k ⭐ 18.8k ⭐
贡献者 个人 207 266
定位 Schema 渲染运行时 Schema Form 引擎 低代码平台
组件名 Vue 原生组件名 Formily 注册名 amis 类型系统
接入方式 渐进式(单页可用) 需包裹 Provider All-in-one
表单校验 手动 内置 x-validator 内置
表达式 AST 白名单沙箱 reaction 副作用 公式引擎
动作模型 13 指令正交组合 x-reactions 60+ actionType
渲染优化 4 层可组合优化 React/Vue 各自机制 内部优化
Schema 可序列化 ✅ 纯 JSON ✅ 基本支持 ✅ 纯 JSON
Bundle 大小 轻量 中等 ≈2MB
适合谁 搭平台的技术团队 复杂表单场景 快速交付内部工具

如果你要做复杂表单,Formily 的 x-validator + x-reactions 开箱即用,比 Vario 省力得多。选 Formily。

如果你要快速交付内部运营工具,amis 的 4 行 JSON 出页面是真实的生产力。选 amis。

如果你要在自己的项目里引入 Schema 驱动能力、保持对技术栈的完全控制、或者在构建一个低代码平台需要底层渲染引擎——Vario 提供的是一个干净的、可嵌入的运行时。


十二、测试与质量

┌──────────────────────────────────────────────────────┐
│  Test Files  50 passed (50)                          │
│       Tests  579 passed (579)                        │
│   跨 5 个包:types / core / schema / vue / cli       │
│   含 3 个集成测试文件(core↔schema / schema↔vm / vue↔element-plus)│
│   性能基准测试覆盖 4 种优化策略对比                     │
└──────────────────────────────────────────────────────┘

集成测试覆盖了三层的打通:

// basic-integration.test.ts — core 和 schema 能协作
const view = defineSchema({ state: { count: 0 }, schema() { return { type: 'div', children: [] } } })
const ctx = createRuntimeContext(view.stateType)
expect(ctx.count).toBe(0)

// schema-vm-integration.test.ts — Schema 中定义的 Action 能被 VM 执行
const instructions = view.schema.events?.click || []
await execute(instructions, ctx)
expect(ctx.count).toBe(1)

// vue-element-plus.test.ts — Vue 渲染器能正确处理 Element Plus 组件
const renderer = new VueRenderer()
const vnode = renderer.render(view.schema, ctx)
expect(vnode.props.modelValue).toBeDefined()
expect(vnode.props['onUpdate:modelValue']).toBeDefined()

十三、Demo 展示

1772387082111-j3lto3xl.png▲ play 演示站首页

下载.png▲ 内置了 Todo App、购物车、搜索过滤、表单、ECharts 图表等完整示例,每个示例可切换"预览"和"Schema JSON"视图

1772387082112-t7nh8y5j.png

▲ 代码靶场——浏览器里直接编辑 Schema,实时预览渲染结果

1772387082113-rbus1bmb.png

▲ 独立的文档站(VitePress),覆盖 API 文档、架构说明、表达式语法、性能调优指南


十四、自问自答——预判你心里可能已经有的问题

Q1:Schema 驱动和"把 template 写成 JSON"有什么本质区别?如果只是换了个语法糖,那工程价值在哪?

这是最核心的问题。如果 Schema 只是 template 的另一种写法,那确实没有意义——反而丢掉了 SFC 的 IDE 支持、语法高亮、组件类型推导。

区别在于 Schema 是可操作的数据,template 是编译后消失的 DSL

Vue 的 <template> 经过编译器后变成 render function,在运行时你拿不到"这里有一个 <ElInput>,它的 model 绑定到 name"这个结构信息了。但 Schema 始终存在于内存里,你可以在运行时做这些事:

  1. findAll(n => n.model) ——找出所有有双向绑定的节点,自动生成表单校验规则
  2. patch('children.2.props', { disabled: true }) ——服务端推送一条消息就能禁用某个按钮
  3. analyzeSchema(){ nodeCount: 234, maxDepth: 8 } ——统计 Schema 复杂度,自动决定启用哪种优化策略
  4. JSON.stringify(schema) → 存 DB → 下次 JSON.parse() → 直接渲染 ——零代码生成,零编译

这不是"换了个语法糖",这是从"编译时产物"变成了"运行时一等公民"的根本转变。

Q2:表达式白名单会不会过于严格?实际项目中遇到需要写复杂逻辑的表达式怎么办?

会。你不能在表达式里写 items.sort((a, b) => a.price - b.price),因为箭头函数被禁了。

设计意图是"表达式只做读取和条件判断,逻辑在 methods 和 computed 里"。 这意味着你需要:

// 不能这样写
{ children: '{{ items.sort((a, b) => a.price - b.price) }}' }

// 要这样写
computed: { sortedItems: (s) => [...s.items].sort((a, b) => a.price - b.price) }
// Schema 里用 {{ sortedItems }}

这多了一步,但换来的是:表达式永远是"安全的只读求值",不需要人工 review 每个 {{ }} 里写了什么。对于 Schema 来源不可信的场景(AI 生成、用户配置),这是刚性需求。

对于开发者手写 Schema 的场景,这确实增加了摩擦。如果你 100% 确定 Schema 只会出现在你的代码仓库里,白名单的安全价值就不那么明显了。这是一个架构赌注,赌的是 Schema 将来会来自更多来源。

Q3:双向绑定的"三重锁"是怎么被消灭的?

早期版本中,useVario 靠三把布尔锁(syncing / syncingPaths / watchSyncing)在 RuntimeContext 和 Vue reactive 之间做双向同步。能跑,但本质是 hack——三把锁意味着有三种循环路径需要手动屏蔽。

问题的根因不是"锁不够精确",而是存在两份状态本身就是错误。Core 的 RuntimeContext 维护一份 plain object,Vue 维护一份 reactive(),任何一侧修改都要同步到另一侧——这就是经典的"双写一致性"问题,在分布式系统里也没有优雅解法。

唯一真正优雅的方案是:消灭第二份状态。

受 Zustand 启发(一个 store 接口 + 各框架各自适配),当前版本引入了 ReactiveAdapter 协议,已经在源码中实现并通过全部 590 个测试

// @variojs/types/src/runtime.ts — 真实代码
export interface ReactiveAdapter {
  get(path: string): unknown        // 路径读取('user.name')
  set(path: string, value: unknown): void  // 路径写入
  getProperty(key: string): unknown  // 顶层属性读(Proxy get trap)
  setProperty(key: string, value: unknown): void  // 顶层属性写(Proxy set trap)
  has(key: string): boolean          // 属性存在检查(Proxy has trap)
  keys(): string[]                   // 所有 key(Proxy ownKeys trap)
}

改动涉及 5 个文件,核心变化:

1. @variojs/corecreateRuntimeContext 接受可选 adapter 参数。当 adapter 存在时:

  • _get(path)adapter.get(path),直接从 Vue reactive 读
  • _set(path, value)adapter.set(path, value),直接写入 Vue reactive
  • 初始状态不拷贝到 ctx 对象上(adapter ? {} : initialState

2. @variojs/core 的 Proxy 5 个 trap 全部路由到 adapter:

  • getadapter.getProperty(key)
  • setadapter.setProperty(key, value)
  • hasadapter.has(key)
  • ownKeys → 合并 adapter.keys() 与系统 API keys
  • getOwnPropertyDescriptor → 为 adapter 管理的 key 返回正确的描述符

3. @variojs/vuecreateVueReactiveAdapter 将 Vue reactive() 对象适配为协议:

// packages/vario-vue/src/adapter.ts — 真实代码
export function createVueReactiveAdapter<TState extends Record<string, unknown>>(
  state: TState
): ReactiveAdapter {
  return {
    get: (path) => getPathValue(state, path),
    set: (path, value) => setPathValue(state, path, value, {
      createObject: () => reactive({}),
      createArray: () => reactive([]),
      createIntermediate: true
    }),
    getProperty: (key) => state[key],
    setProperty: (key, value) => { state[key] = value },
    has: (key) => key in state,
    keys: () => Object.keys(state)
  }
}

4. useVario 从 636 行减至 570 行,删除了:

  • 3 个同步锁变量(syncing / syncingPaths / watchSyncing
  • onStateChange 中 20 行的 setPathValue 同步逻辑
  • watch(reactiveState) 中 20 行的 syncStateToContext 反向同步
  • syncStateToContext() 函数本身(16 行 + 深度比较)
  • 初始状态拷贝循环(5 行)

替换后的 onStateChange 只有 4 行——缓存失效 + 渲染调度:

onStateChange: (path, _value, runtimeCtx) => {
  invalidateCache(path, runtimeCtx)
  scheduleRender()
}

数据流变化:

重构前:ctx._set('x', 1) → 写入 ctx 内部 → onStateChange → setPathValue(reactive) → 触发 watch → 🔒 被锁拦截
重构后:ctx._set('x', 1) → adapter.set('x', 1) → 直接写入 reactive → onStateChange → invalidateCache + scheduleRender → 完毕

向后兼容: 当不传 adapter 时,行为与旧版完全一致——所有 153 个 Core 测试无需修改。adapter 是纯增量,不是 breaking change。

额外收益: 这个协议直接为 React Renderer 铺路(见 Q7)。React 侧只需实现一个基于不可变快照的 ReactReactiveAdapter,Core 层完全不用动。

Q4:Schema 存数据库之后,版本迁移怎么办?老版本的 Schema 在新版本的渲染引擎上能跑吗?

这是一个真实的工程问题,而且 Vario 目前没有完整的答案。

Schema 的结构由 SchemaNode 接口定义,这是一个 readonly 接口。新版本如果加了新字段(比如已经有的 transitionkeepAlive),老 Schema 没有这些字段,渲染器会按默认值处理,通常不会挂。

但如果某个字段的语义变了(比如 model 从只支持字符串变成支持 { path, scope, default, modifiers } 对象),normalizeSchemaNode() 需要处理兼容性转换。当前的规范化器已经在做这件事——它处理字符串 model 和对象 model 两种形态,统一为标准格式。

真正危险的是 Action 指令集的变更。 如果某个指令的参数结构变了,存在数据库里的 Schema 中引用的旧格式指令就会执行出错。Action VM 的错误保护(超时、步数限制、类型化错误码)可以兜底不让程序崩溃,但业务逻辑会失效。

长期来看,需要的是一个 Schema 版本号 + 迁移脚本的机制(类似数据库 migration),但这目前还在规划中。

Q5:你自己在实际项目中用 Vario 了吗?踩过什么真实的坑?

用了。Vario 最初就是从实际的低代码平台项目中抽出来的。踩过的最大的坑是 model 路径在嵌套循环中的解析

考虑这个场景:

{
  "loop": { "items": "{{ categories }}", "itemKey": "cat" },
  "children": [{
    "loop": { "items": "{{ cat.products }}", "itemKey": "product" },
    "children": [{
      "type": "ElInput",
      "model": "product.name"
    }]
  }]
}

product.name 需要解析为 categories.0.products.2.name 这样的绝对路径,才能正确写回状态。这需要一个路径栈(modelPathStack),每层循环压一层,每次解析 model 路径时从栈顶开始拼接。

ModelPathResolver 的 228 行代码大部分在处理这个问题的各种边界情况:"." 表示当前路径栈(循环项是基本类型时绑定自身)、$item 动态解析、-1 索引(动态数组追加)、表达式内嵌的 model 路径(model: '{{ dynamicField }}')。

vario-vue 有 750 行专门测试 model 路径解析的测试用例(model-path-comprehensive.test.ts),这是项目里最长的单个测试文件。

Q6:对比大厂的 Formily 和 amis,你一个人做的项目,凭什么让别人用?

这个问题的诚实答案是:如果有人问"我要选一个做生产项目用",我没有立场推荐 Vario 而不推荐 Formily。

Formily 有 207 位贡献者、多年的生产环境打磨、完整的表单验证/联动生态。amis 有百度内部大量业务场景验证、几百个内置组件类型。这些是个人项目无法比拟的。

Vario 的价值不在于"比他们好",而在于:

  1. 不同的抽象层次——Formily 是"表单引擎",amis 是"低代码平台",Vario 是"渲染运行时"。如果你要自己搭平台、自己做编辑器,你需要的是运行时这一层,而不是一个成品平台。
  2. 完全的控制权——Vario 不绑定任何组件库、不内置任何业务组件,你的组件就是你的。amis 接受就要全盘接受它的组件体系。
  3. 作为学习和参考——从零造一个 Schema 渲染引擎的过程中,我理解了为什么 Formily 要那样设计 x-reactions、为什么 amis 要搞 60+ 种 actionType。这个过程本身就值得分享。

如果你在选型——评估你的场景,做表单选 Formily,做内部工具选 amis,做平台底座或者想深入理解这个领域,来看看 Vario。

Q7:如果 Core 层零 Vue 依赖,那 React Renderer 真的能做出来吗?代价是什么?

架构上已经预留了。Core 层的所有 API——createRuntimeContext()execute()evaluate()——不依赖任何 UI 框架。但上一版的回答太保守了,只列了"React 缺什么"。深入想之后,我认为这件事比"能做但体验差"要更乐观。

VNode 创建层——映射是直接的:

Vue 的 h() 和 React 的 createElement() 在 API 层面几乎同构:

// Vue
h('div', { class: 'box', onClick: handler }, [h('span', {}, 'text')])

// React
createElement('div', { className: 'box', onClick: handler }, createElement('span', {}, 'text'))

差异只在属性名(class → classNamefor → htmlFor、事件名大小写),用一个 20 行的 prop adapter 就能搞定。当前 VueRenderer 的 638 行代码中,真正 Vue 特有的与其说是 h() 调用,不如说是围绕 h() 的那些 Vue 特性包裹(Teleport / Transition / KeepAlive / v-show / withDirectives)。

Vue 特性的 React 对应物——比想象中完整:

Vue 特性 React 对应 实现复杂度
h() createElement() 低(prop 名映射)
Teleport ReactDOM.createPortal() 低(API 对等)
Transition react-transition-group 或 Framer Motion 中(API 不同但能力对等)
KeepAlive 无原生等价物 高(需手动 display:none + 状态缓存,或用 react-activation)
v-show style={{ display: 'none' }} 低(trivial)
v-model value + onChange 低(React 反而更简单,不需要 onUpdate:modelValue 这种协议)
withDirectives 无等价物 高(需要自实 ref callback pattern)
provide/inject React.createContext + useContext 中(概念对等,API 不同)

真正的难题不在 API 映射,在状态同步——而 Q3 的 ReactiveAdapter 已经落地解决了这个问题。

Core 的 createRuntimeContext 现在接受 ReactiveAdapter 参数。Vue 侧的 createVueReactiveAdapter 已经证明了这个协议的可行性(590 个测试全部通过)。React 侧只需实现同一接口的不可变快照版本:

function createReactAdapter<T>(initialState: T): ReactiveAdapter & { getSnapshot: () => T, subscribe: (l: () => void) => () => void } {
  let state = structuredClone(initialState)
  const listeners = new Set<() => void>()

  return {
    get: (path) => getPathValue(state, path),
    set: (path, value) => {
      // 不可变更新——新引用触发 React re-render
      state = produce(state, draft => { setPathValue(draft, path, value) })
      listeners.forEach(l => l())
    },
    getProperty: (key) => state[key],
    setProperty: (key, value) => {
      state = { ...state, [key]: value }
      listeners.forEach(l => l())
    },
    has: (key) => key in state,
    keys: () => Object.keys(state),
    subscribe: (listener) => {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
    getSnapshot: () => state
  }
}

React 侧的 useVario Hook:

function useVario(schema, options) {
  const adapter = useMemo(() => createReactAdapter(options.state), [])
  const state = useSyncExternalStore(adapter.subscribe, adapter.getSnapshot)
  const ctx = useMemo(() => createRuntimeContext({}, { adapter }), [adapter])

  return useMemo(() => {
    const renderer = new ReactRenderer()
    return renderer.render(schema, ctx)
  }, [schema, state])  // state 引用变化时触发重渲染
}

注意 createReactAdapter 实现的 get/set/getProperty/setProperty/has/keys 与 Vue 侧的 createVueReactiveAdapter 签名完全一致——因为它们实现的是同一个 ReactiveAdapter 接口。差异只在实现策略:Vue 用可变 reactive proxy,React 用不可变快照 + useSyncExternalStore

useSyncExternalStore(React 18+)是关键。 它是 React 官方提供的"外部状态 → React 渲染"的标准桥接方案,不需要 deep reactive proxy,也不需要 useEffect + 手动 diff。每次 set() 产生新的不可变快照,useSyncExternalStore 检测到引用变化,触发组件 re-render。

这里借鉴了 Zustand 的核心设计:store 是外部的,React 通过 useSyncExternalStore 订阅。但 Zustand 的 store 是用户手写的,Vario 的 store 是 RuntimeContext——由 Schema 驱动、Action VM 修改。

我现在的判断是:React Renderer 的工程量大约是 Vue Renderer 的 60%——不是因为 React 比 Vue 简单,而是因为 React 不需要三重锁。 Vue 的 deep reactive 带来了自动依赖追踪的便利,但也引入了双向同步的复杂度;React 的不可变模型虽然需要多写 immutable update,但状态流向是单向的——不存在回声问题。

具体的实施路线:

  1. 第一步:从 Core 中抽取 RendererProtocol 接口(createElement / createFragment / createPortal / wrapTransition),让 VueRenderer 和 ReactRenderer 都实现同一接口
  2. 第二步:实现 ReactReactiveAdapter,基于 useSyncExternalStore + 不可变快照
  3. 第三步:实现 ReactRenderer 基础版(createElement + 事件 + model 绑定),跳过 KeepAlive / Directive
  4. 第四步:补齐 Transition(react-transition-group)和 KeepAlive(react-activation 或自实现)

最大的技术风险不是"能不能做",而是性能。Vue 的 watch(state, { deep: true }) 可以精确知道哪个 path 变了(配合 Path Memo 做精准跳过),React 的不可变快照每次都是完整引用比较。在大规模 Schema(1000+ 节点)下,React 的渲染粒度控制可能不如 Vue fine-grained。这需要实际 benchmark 验证——理论推演到这一步就到极限了。


十五、欢迎参与

Vario 目前已开源,文档和示例都比较完整。但一个人做的项目终归有视野和精力的局限。如果你对 Schema 驱动 UI、AI + 低代码、渲染引擎设计这些方向感兴趣,非常欢迎参与:

🔧 提 Issue

  • 发现 bug?Schema 验证/表达式引擎/双向绑定/循环渲染——任何场景的问题都欢迎报告
  • 有功能建议?比如新增白名单函数、新的 Action 指令类型、更好的错误提示
  • 文档不清楚的地方?告诉我哪里看不懂

🚀 提 Pull Request

  • Good First Issues 适合初次贡献
  • 新的 Action 指令处理器(在 packages/vario-core/src/vm/handlers/ 下添加)
  • 新的表达式白名单函数(在 packages/vario-core/src/expression/whitelist.ts 中注册)
  • play 示例(在 play/src/examples/ 下添加 .vario.ts 文件)
  • 文档改进(在 docs/ 下修改 Markdown)
  • React Renderer(这是最大的待做项)

💬 参与讨论

  • 架构决策讨论——比如"表达式白名单应不应该开放 .sort() 带回调的用法?"
  • 性能优化方向——比如"SchemaFragment 方案的 API 应该怎么设计?"
  • AI 集成方案——比如"怎么为 Schema 生成约束 AI 的 JSON Schema 定义文件?"
git clone https://github.com/YuluoY/vario.git
cd vario
pnpm install
pnpm start  # 构建 + 启动 play(:5173) 和 docs(:5174)
pnpm test   # 跑一遍 579 个测试,确认环境正常

GitHub:github.com/YuluoY/vari…

在线演示:yuluoy.github.io/vario/

文档:yuluoy.github.io/vario/docs/


5 分钟快速上手

pnpm add @variojs/vue @variojs/core @variojs/schema
<template>
  <component :is="vnode" />
</template>

<script setup>
import { useVario } from '@variojs/vue'

const { vnode, state } = useVario({
  type: 'div',
  children: [
    { type: 'input', model: 'name', props: { placeholder: '你的名字' } },
    { type: 'p', children: 'Hello {{ name }}!' }
  ]
}, {
  state: { name: '' }
})
</script>

就这样。没有 Provider,没有额外的 store,没有新的模板语法——Schema 即 UI,状态即数据。


更多文章

❌