普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月25日掘金 前端

你删过 lock 文件吗?聊聊包管理器迁移中 90% 的人会踩的坑

2026年3月25日 15:02

"删掉 node_modulespackage-lock.json,重新 npm install 一下。"

这句话你一定听过,甚至自己也说过。遇到依赖安装报错,删 lock 重装是最常见的"万能解法"。大部分时候确实管用——但它管用的原因和你想的不一样,而且在某些场景下,这个操作的代价比你预期的要大得多。

最近越来越多的项目开始从 npm 迁移到 pnpm。迁移本身不复杂,但很多人的做法是直接删掉 package-lock.json,然后 pnpm install。对于小项目,这通常没问题。但如果你的项目有几百个依赖、跑在生产环境、团队多人协作——这样做可能会引入一些很难排查的问题。

这篇文章聊的就是这个:lock 文件到底在锁什么,删掉它意味着什么,以及迁移包管理器时怎么做才是安全的。

lock 文件在锁什么

package.json 里的版本号不是精确版本,而是一个范围:

{
  "dependencies": {
    "react": "^18.3.1",
    "axios": "~1.7.0"
  }
}

^18.3.1 允许安装 18.3.118.x.x 之间的任何版本,~1.7.0 允许 1.7.01.7.x。也就是说,同一份 package.json,今天装和三个月后装,拿到的依赖版本可能完全不同。

而 lock 文件记录的是某一次 install 之后所有依赖的精确版本——不光是你在 package.json 里写的那几个,还包括它们背后的几十上百个传递依赖。

一句话总结:package.json 描述意图,lock 文件记录事实。

有了 lock 文件,团队成员用 npm ci(或 pnpm install --frozen-lockfile)安装时,拿到的依赖版本和你本地测试通过的完全一致。CI 构建、生产部署,都是同一份版本快照。

semver 是个"君子协议"——很多包不遵守

你可能会想:用 ^ 锁定大版本,minor 和 patch 升级不是应该向下兼容吗?

理论上是。但现实中,不少知名包在 patch 或 minor 版本里引入过 breaking change:

  • TypeScript 明确声明不遵守 semver。它的 minor 版本(比如 5.35.4)经常改变类型推断行为,一次升级可能导致几十个编译错误。
  • esbuild 长期处于 0.x 阶段,按 semver 规范 0.x 的任何变更都可能是 breaking,但很多打包工具用 ^0.21.0 这样的范围引用它。
  • PostCSS 的 minor 升级曾导致部分插件不兼容,表现为构建时样式输出错误——构建不报错,但页面样式不对,排查成本很高。

这就是为什么 lock 文件是生产环境的最后一道防线:你本地测试通过的版本组合,lock 文件帮你锁住了。删掉它重新安装,等于放弃了这个保障。

删 lock 重装,到底丢了什么

回到开头的问题:删掉 lock 文件再重装,你丢掉了两样东西。

第一,版本锁定。 所有依赖会按 package.json 的范围重新解析,取当前最新的可用版本。如果某个传递依赖在这段时间发了一个有问题的 patch,你就会拿到它。

第二,git 历史。 lock 文件的每次变更都有 git 记录。当你需要用 git bisect 排查"代码没改但线上表现变了"的问题时,lock 文件的 diff 是最关键的线索。删掉重建意味着这条追溯链断了。

对于一个依赖不到 50 个的小项目,这两个问题都不大——验证成本低,出了问题也容易定位。但对于依赖几百个、有完整 CI/CD 流水线的生产项目,这两个代价都不可接受。

迁移到 pnpm:三种策略,选错会出事

既然越来越多团队在迁移到 pnpm,那怎么迁才是安全的?根据项目规模,有三种策略。

策略 A:直接删 lock 重装

rm -rf node_modules package-lock.json
pnpm install

所有版本重新解析,传递依赖不可控。适合依赖少、刚起步的新项目。

策略 B:pnpm import 无损导入

pnpm import            # 从 package-lock.json 导入精确版本
rm package-lock.json   # 导入成功后删除旧 lock
pnpm install           # 安装依赖

pnpm import 会读取现有的 package-lock.json(也支持 yarn.lock),生成一个版本完全一致的 pnpm-lock.yaml。所有依赖——包括传递依赖——的精确版本都会被保留,零版本漂移。

这是大多数项目应该选择的方式。

策略 C:渐进式迁移

对于生产环境有高可用要求的项目,在策略 B 的基础上增加一个完整的验证周期:

git checkout -b chore/migrate-to-pnpm

pnpm import
rm package-lock.json
pnpm install

# 跑完所有测试
pnpm test
pnpm build
pnpm e2e

# staging 环境验证后再合入 main

怎么选

简单判断:项目依赖超过 50 个,或者跑在生产环境——用策略 B。如果还有高可用要求——用策略 C。只有刚起步的小项目才适合策略 A。

迁移后最常遇到的问题:phantom dependencies

从 npm 切到 pnpm 后,最常见的报错不是版本问题,而是 Module not found

这是因为 npm 的 flat node_modules 会把所有包平铺在根目录,你的代码可以 import 任何已安装的包,哪怕你没在 package.json 里声明。pnpm 的 symlink 结构不允许这样做。

// package.json 里没有声明 "ms"
// 但 "debug" 依赖了 "ms",npm 会把它平铺
import ms from 'ms'  // npm: 正常  |  pnpm: Module not found

修复方式很直接:把实际用到的包显式加到 package.json 里。

pnpm build 2>&1 | grep "Module not found"
pnpm add ms  # 逐个添加缺失的依赖

大项目可能需要修几十个,但这是一次性的工作,修完之后项目的依赖关系会清晰很多。

迁移后别忘了更新 CI

很多人本地迁完就提交了,CI 里还是 npm ci——然后 CI 就挂了。

GitHub Actions 的改动并不大,核心是加一个 pnpm/action-setup 步骤:

# 迁移前
steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: '20'
      cache: 'npm'
  - run: npm ci
  - run: npm run build

# 迁移后
steps:
  - uses: actions/checkout@v4
  - uses: pnpm/action-setup@v4
  - uses: actions/setup-node@v4
    with:
      node-version: '20'
      cache: 'pnpm'
  - run: pnpm install --frozen-lockfile
  - run: pnpm build

另外建议在 package.json 里加上 packageManager 字段:

{
  "packageManager": "pnpm@10.29.2"
}

pnpm/action-setup@v4 会读取这个字段自动安装对应版本,Corepack 也会据此约束团队成员使用正确的包管理器。

lock 文件的 Git 管理:几条铁律

最后聊几个关于 lock 文件日常管理的要点。

lock 文件必须提交到 Git。 这一点怎么强调都不过分。不提交 lock 文件,团队成员的依赖版本可能各不相同,CI 构建不可复现,出了问题无法回滚到已知良好的状态。把 lock 文件加到 .gitignore 里是一个常见但严重的错误。

lock 文件冲突不要手动解。 多人开发时 lock 文件冲突是家常便饭。正确做法是接受一方的版本,然后重新生成:

git checkout --theirs pnpm-lock.yaml
pnpm install
git add pnpm-lock.yaml
git commit

pnpm install 会根据 package.json 重新解析 lock 文件,同时尽量保留已有的版本锁定。比手动合并几千行 YAML 安全得多。

CI 里永远用 --frozen-lockfile pnpm install --frozen-lockfile 等价于 npm ci,严格按 lock 文件安装。如果 lock 文件和 package.json 不一致就直接报错,而不是悄悄更新 lock 文件。

迁移 Checklist

最后附一个可以直接用的清单:

  • 确认项目能通过 build(最好有测试覆盖)
  • pnpm import 从现有 lock 文件导入
  • 删除旧 lock 文件
  • pnpm install 安装依赖
  • 修复 phantom dependency 报错
  • package.json 添加 "packageManager": "pnpm@x.x.x"
  • 更新 CI workflow
  • 全量测试 + 构建验证
  • 通知团队成员

以上就是关于 lock 文件和包管理器迁移的完整分析。核心观点只有一个:小项目随便迁,大项目用 pnpm import,别直接删 lock 文件。

你们团队在迁移包管理器或者管理 lock 文件的时候踩过什么坑?欢迎在评论区聊聊。

Fixed 定位的失效问题

作者 氢灵子
2026年3月25日 14:56

通常情况下position: fixed元素相对于视口定位,但是某些情况下,比如祖先元素设置了transformfilterperspectivewill-change: transform的时候,子元素的固定定位会失效,不在相对于视口定位,而是相对于该祖先元素定位,约等于绝对定位。

比如:

<div style="position: fixed; top: 50vh; right: 0; width: 10vh; height: 10vh; background-color: lightblue"></div>

<div style="transform: translate(0, 0); padding-top: 25vh">
  <div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightcoral"></div>
</div>
<div style="filter: blur(0); padding-top: 25vh">
  <div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightcyan"></div>
</div>
<div style="perspective: 0; padding-top: 25vh">
  <div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightgoldenrodyellow"></div>
</div>
<div style="will-change: transform; padding-top: 25vh">
  <div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightgray"></div>
</div>

第一个元素定位正常,但后面的元素定位异常,这是因为这些元素的父元素因为特定的 CSS 属性被放在新的图层之中。

一般情况下,当我们发现了固定定位异常时,排查祖先元素是否含有上述的 CSS 属性即可。但有一种情况,虽然在浏览器的 CSS 面板中看不到上述属性,但元素依然处于不同的图层中。这就是当元素被执行过animate且执行了上述 CSS 属性的动画。

比如:

<div id="moving">
  <div style="position: fixed; top: 0; width: 10vh; height: 10vh; background-color: lightblue"></div>
</div>

<script>
  let moving = window.document.getElementById('moving');
  moving.animate([{ transform: 'translate(0, 0)' }, { transform: 'translate(0, 50vh)' }], { duration: 1000, fill: 'forwards' });
</script>

如果运行上面的代码,可以看到固定定位的元素在跟随父元素移动,同时此时看到浏览器的 CSS 面板中父元素并没有 transform 相关属性。

不得不说,好坑啊。

React Hooks 闭包陷阱:高级场景与深度思考

作者 蜡笔熊
2026年3月25日 14:19

前言

闭包陷阱不只是"定时器读不到最新值"那么简单。

在实际工程中,你会遇到:

  • 类组件转函数式后的隐性 bug
  • 自定义 Hook 里的闭包泄露
  • Concurrent Mode 下的闭包过期问题
  • 状态机场景下的闭包与 reducer 的相爱相杀
  • memo/useCallback 优化反而引发的新问题
  • 内存泄漏与闭包的深层关系

场景一:类组件转函数式后,ref 里的闭包成了定时炸弹

问题

你有一个类组件,习惯用 this 解决问题:

// 类组件写法
class SearchPanel extends React.Component {
  state = { keyword: '' };

  handleSearch = () => {
    // 这里直接用 this.state.keyword,永远是最新的
    api.search(this.state.keyword);
  };

  render() {
    return <input onChange={e => this.setState({ keyword: e.target.value })} />;
  }
}

改成函数式后,你可能这样写:

// ❌ 常见错误写法
function SearchPanel() {
  const [keyword, setKeyword] = useState('');

  const handleSearch = () => {
    // 等等,这里怎么获取 keyword?
    // 很多人会想到用一个 ref 存着
  };

  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <button onClick={handleSearch}>搜索</button>
    </div>
  );
}

然后你用 ref 来"绕过"闭包问题:

// ❌ 潜在问题
function SearchPanel() {
  const [keyword, setKeyword] = useState('');
  const keywordRef = useRef(keyword);

  // 同步 ref
  useEffect(() => {
    keywordRef.current = keyword;
  }, [keyword]);

  const handleSearch = () => {
    // 用 ref 获取值
    api.search(keywordRef.current); // ⚠️ 这里看起来没问题
  };

  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <button onClick={handleSearch}>搜索</button>
    </div>
  );
}

问题在哪?

如果用户快速点击搜索按钮(比点击一次还快),在 useEffect 还没执行之前,ref 里还是旧值。

进阶视角

类组件的 this.state 本质上是一个"永远指向最新值"的 mutable 对象。函数式的 useState 是 immutable 的,每次渲染都是新值。

正确的函数式写法:

// ✅ 正确:不要绕过 React 的响应式系统
function SearchPanel() {
  const [keyword, setKeyword] = useState('');

  // 直接把当前值传进去,不要通过 ref 间接获取
  const handleSearch = () => {
    api.search(keyword); // ✅ 这里就是最新的 keyword
  };

  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <button onClick={handleSearch}>搜索</button>
    </div>
  );
}

架构思考:

类组件 函数式组件
this.state 是 mutable,引用永远最新 useState 是 immutable,每次渲染是新值
闭包不是问题,因为用的是 this 闭包是问题,因为捕获的是旧值
解决方案:忘了它,用响应式数据 解决方案:让函数组件在正确的时机重新创建

场景二:自定义 Hook 里的闭包泄露——你封装的 Hook 可能正在泄露内存

问题

你封装了一个 useInterval Hook:

// ❌ 有问题的 useInterval 实现
function useInterval(callback, delay) {
  const savedCallback = useRef();

  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (delay !== null) {
      const id = setInterval(() => {
        savedCallback.current(); // 这里调用的是最新的 callback
      }, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
}

看起来没问题?好,我们来用一下:

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

  useInterval(() => {
    console.log('count:', count); // ⚠️ 这里永远打印 0
  }, 1000);

  return <div>{count}</div>;
}

这不就是场景一的问题吗?

但更严重的问题在后面:

如果 callback 每次渲染都变化(比如用了一些依赖),savedCallback.current 会不断更新,但旧的 callback 形成的闭包可能被某些地方持有,导致内存无法释放。

源码级分析

// 模拟问题场景
function useDataFetcher(url) {
  const [data, setData] = useState(null);

  useEffect(() => {
    let cancelled = false;

    fetch(url)
      .then(res => res.json())
      .then(result => {
        if (!cancelled) {
          setData(result); // ⚠️ 这个闭包捕获了 url
        }
      });

    return () => {
      cancelled = true; // 这里的逻辑其实有漏洞
    };
  }, [url]); // url 变化 → 新的 effect → 新的闭包

  return data;
}

问题:url 快速变化时(比如搜索框输入),旧请求的回调虽然检查了 cancelled,但闭包本身还在内存中。如果这个闭包捕获了大数据(比如列表数据),就有内存泄漏风险。

高级视角:正确的 useInterval 实现

// ✅ 正确的 useInterval(借鉴 ahooks)
import { useEffect, useRef, useCallback } from 'react';

function useInterval(callback, delay) {
  const callbackRef = useRef(callback);

  // 每次 callback 变化,同步更新 ref
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  // 定时器执行时,永远读 ref 里的最新函数
  useEffect(() => {
    if (delay === null || delay === undefined) {
      return;
    }

    const tick = () => callbackRef.current();

    const id = setInterval(tick, delay);
    return () => clearInterval(id);
  }, [delay]); // 注意:这里不依赖 callback,只依赖 delay
}

但真正的架构问题是:

你的自定义 Hook 使用者,可能根本不知道内部有闭包陷阱。他们传入的 callback 如果依赖了外部变量,问题就会隐藏在这里。

最佳实践:

// ✅ 在自定义 Hook 里用 useLatest 统一处理
function useLatest(value) {
  const ref = useRef(value);
  ref.current = value;
  return ref;
}

function useInterval(callback, delay) {
  const callbackRef = useLatest(callback);

  useEffect(() => {
    if (delay == null) return;

    const tick = () => callbackRef.current();
    const id = setInterval(tick, delay);
    return () => clearInterval(id);
  }, [delay]);
}

场景三:useReducer 里的闭包——状态机场景的特殊情况

问题

你可能觉得用了 useReducer 就不用管闭包了:

// ❌ 仍然有闭包问题
function counterReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  useEffect(() => {
    const timer = setInterval(() => {
      // ❌ 这里还是闭包陷阱!
      dispatch({ type: 'INCREMENT' });
      // 等等,dispatch 需要读旧状态吗?
      // 让我们看看
    }, 1000);
    return () => clearInterval(timer);
  }, []);

  return <div>{state.count}</div>;
}

实际上这个例子可以跑,因为 dispatch 的工作方式不同。

源码级解析:dispatch 为什么特殊?

// ReactFiberHooks.js
function updateReducer(reducer, initialArg, init) {
  const hook = updateWorkInProgressHook();
  const queue = hook.memoizedQueue;
  const pending = queue.pending;

  // 关键:dispatch 不依赖任何外部变量
  // 它的行为是"把 action 放入队列",不是"立即执行"
  // 所以 dispatch 本身不会过期

  if (pending !== null) {
    // ...
  }

  const newState = hook.memoizedState;
  return [newState, dispatch];
}

所以:

操作 是否受闭包影响
setCount(n) ❌ 不受(但 n 可能是旧值)
setCount(c => c + 1) ✅ 不受,函数式更新
dispatch({ type: 'INCREMENT' }) ✅ 不受,dispatch 只是发指令

真正的问题:reducer 里的闭包

// ❌ 问题在 reducer 内部
function counterReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT_BY':
      // 这里需要访问外部的某个"配置"
      return { count: state.count + action.amount };
    default:
      return state;
  }
}

function Counter({ defaultAmount = 1 }) { // ⚠️ props 变化
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  // 这里的 defaultAmount 变化时,reducer 不会自动更新
  // 你需要确保 action 携带足够的信息
  const handleIncrement = () => {
    dispatch({ type: 'INCREMENT_BY', amount: defaultAmount });
  };

  return <button onClick={handleIncrement}>{state.count}</button>;
}

高级视角:

useReducer 并不是闭包的银弹。它的优势是把"如何计算新状态"和"何时触发计算"分开,但如果你在 reducer 外部依赖了某些值,闭包问题依然存在。


场景四:memo 与 useCallback——优化反而引发的新问题

问题

用了 memo + useCallback 做性能优化,结果闭包问题更严重了:

// ❌ 过度优化的陷阱
const Child = memo(function Child({ onClick, data }) {
  console.log('Child 渲染了');
  return <button onClick={() => onClick(data.id)}>{data.label}</button>;
});

function Parent() {
  const [count, setCount] = useState(0);
  const [list, setList] = useState([{ id: 1, label: 'A' }]);

  // ❌ 用 useCallback 包裹,但依赖了 list
  const handleClick = useCallback((id) => {
    console.log('点击了', id, list); // ⚠️ 这里永远是旧 list
  }, [list]); // list 变化 → handleClick 重建 → Child 重新渲染

  return (
    <div>
      <Child onClick={handleClick} data={list[0]} />
      <button onClick={() => setCount(c => c + 1)}>count: {count}</button>
    </div>
  );
}

useCallback 是想避免子组件重渲染,结果因为依赖了 listlist 每次变化 handleClick 都会重建,子组件还是重渲染了。

什么时候真正需要 useCallback?

// ✅ 正确的用法:传给子组件的回调
function Parent() {
  const [count, setCount] = useState(0);

  // 只有当这个函数要传给 memo 过的子组件时,才用 useCallback
  const handleClick = useCallback(() => {
    console.log(count); // 如果需要读 count,加依赖
  }, [count]);

  return (
    <div>
      <MemoChild onClick={handleClick} />
      <button onClick={() => setCount(c => c + 1)}>count</button>
    </div>
  );
}

// ✅ 另一种思路:用 useRef 存最新值,不让子组件依赖变化
function Parent() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  useEffect(() => {
    countRef.current = count;
  }, [count]);

  const handleClick = useCallback(() => {
    console.log(countRef.current); // ✅ 不依赖变化,函数永远不重建
  }, []); // 空依赖,永远是同一个函数

  return (
    <div>
      <MemoChild onClick={handleClick} />
      <button onClick={() => setCount(c => c + 1)}>count</button>
    </div>
  );
}

架构决策:

场景 推荐方案
回调需要读最新 state 加依赖,或用 ref
回调只需要"触发动作" useCallback + 空依赖
子组件是 memo 的 优先确保 props 不变
性能问题根源不在这里 先用 React DevTools Profiler 定位

场景五:Concurrent Mode 下的闭包过期——时间切片带来的新问题

问题

React 18 开启了 Concurrent Mode,同一个组件可能同时存在多个版本的渲染。这让闭包问题更复杂了:

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    // 发起搜索请求
    const controller = new AbortController();

    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(res => res.json())
      .then(data => {
        // ⚠️ 关键问题:这里拿到的 query 是哪个版本的?
        setResults(data);
      });

    return () => controller.abort();
  }, [query]);

  return <div>{results.map(r => <li key={r.id}>{r.title}</li>)}</div>;
}

在 Concurrent Mode 下,可能发生这种情况:

  1. 用户输入 "a",React 开始渲染 "a" 的搜索结果
  2. 用户快速输入 "ab",React 中断 "a" 的渲染,开始渲染 "ab"
  3. "ab" 的请求先返回,设置 results = ["ab 结果"]
  4. "a" 的请求后返回,设置 results = ["a 结果"]

结果:用户看到了"ab"的搜索框,却显示着"a"的结果。

源码级分析:React 18 的 thenable 机制

// ReactFiberCommitWork.js
function commitEffect() {
  // ...
  if (thenableState !== null) {
    // 异步更新可能会被 "插队"
    // 这里的状态更新不是线性的
  }
}

如何应对 Concurrent Mode 的闭包?

// ✅ 方案一:使用 AbortController 取消旧请求
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => {
      // ✅ 再次检查 query 是否还是当前值
      setQuery(current => {
        if (current !== query) return current; // 如果已经变了,忽略这次更新
        return current;
      });
      setResults(data);
    });

  return () => controller.abort();
}, [query]);

// ✅ 方案二:使用 useDeferredValue(React 18)
function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query);
  // 用 deferredQuery 做渲染,用 query 做请求
  // 渲染可以是"过期"的,但数据请求是最新的
}

// ✅ 方案三:使用 useSyncExternalStore( React 18 官方方案)
import { useSyncExternalStore } from 'react';

// 自己管理订阅,确保读取到的是"已提交的"值
function useSearchQuery(query) {
  const snapshot = useSyncExternalStore(
    subscribe,
    getServerSnapshot,
    getClientSnapshot(query)
  );
  return snapshot;
}

场景六:异步函数在 useEffect 里的闭包——最常见的内存泄漏

问题

这是一个经典但容易被忽视的问题:

// ❌ 内存泄漏的典型案例
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let isMounted = true;

    fetchUser(userId).then(user => {
      if (isMounted) {
        setUser(user); // ⚠️ 如果组件已卸载,这里仍然会执行
      }
    });

    return () => {
      isMounted = false; // 这是一个闭包,但它不是过期闭包的锅
    };
  }, [userId]);

  if (!user) return <Loading />;

  return <div>{user.name}</div>;
}

等等,这个例子其实是正确的写法(加了 isMounted 标记)。

真正的问题在下面:

// ❌ 真正的内存泄漏
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const subscription = userService.subscribe(userId, (newUser) => {
      setUser(newUser); // ⚠️ 组件卸载时没有取消订阅!
    });

    return () => {
      // ❌ 忘记取消订阅
      // subscription.unsubscribe();
    };
  }, [userId]);

  return <div>{user?.name}</div>;
}

闭包与内存泄漏的关系

问题类型 闭包的角色 解决方案
过期闭包读旧值 闭包捕获旧变量 用 ref / 函数式更新
异步完成后 setState 组件已卸载 用 isMounted 或 AbortController
事件订阅未清理 闭包持有组件引用 useEffect 返回清理函数
定时器未清理 闭包持有组件引用 clearInterval / clearTimeout

一个更隐蔽的例子:

// ❌ 定时器 + 闭包 = 内存泄漏
function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setSeconds(s => s + 1); // ✅ 函数式更新,没问题
    }, 1000);

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

  return <div>{seconds}</div>;
}

// 但如果这样写:
function TimerWithBug() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    // ❌ 没有返回清理函数
    const timer = setInterval(() => {
      setSeconds(seconds + 1); // 读的是闭包里的 seconds,永远是 0
    }, 1000);
    // 组件卸载时 timer 还在运行 → 内存泄漏
  }, []); // 依赖数组为空,effect 不重新执行,所以也不会修复

  return <div>{seconds}</div>;
}

场景七:Server Components 下的闭包差异—— React 19 的新挑战

⚠️ React 19 / Next.js App Router 场景

问题

Server Components (RSC) 和 Client Components 的闭包行为完全不同:

// ❌ Server Component(默认)
async function Profile({ userId }) {
  const user = await fetchUser(userId); // ✅ 直接 await,不需要 useEffect

  // 这个函数组件在服务端渲染,不会创建闭包
  // 因为它只执行一次,返回 JSX
  return <div>{user.name}</div>;
}

// ✅ Client Component
'use client';
function Profile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  return <div>{user?.name}</div>;
}

架构差异:

特性 Server Components Client Components
闭包问题 无(只渲染一次) 有(每次渲染可能创建闭包)
数据获取 直接 async/await useEffect + 依赖数组
状态管理 无状态 useState/useReducer
包体积 不打包到客户端 打包到客户端

如何设计?

原则:尽量把不需要交互的组件写成 Server Components。

// ✅ 正确的分层
// ProfilePage.tsx (Server Component - 默认)
import Profile from './Profile';

export default async function ProfilePage({ params }) {
  // 服务端获取数据
  const user = await fetchUser(params.userId);

  // 只把需要交互的部分交给客户端
  return (
    <main>
      <h1>{user.name}</h1>
      <Profile initialUser={user} />
    </main>
  );
}

// Profile.tsx ('use client')
'use client';
function Profile({ initialUser }) {
  const [user, setUser] = useState(initialUser); // 用 initialUser 初始化

  // 只有这里的交互逻辑才需要处理闭包
  return <EditableUser user={user} onSave={setUser} />;
}

场景八:微前端场景下的闭包——qiankun / single-spa 下的特殊问题

问题

在微前端架构中,主应用和子应用各自有独立的 React 实例。闭包问题可能跨应用传播:

// 主应用
function MainApp() {
  const [user, setUser] = useState(null);

  // 传递给子应用的回调
  const handleUserUpdate = useCallback((newUser) => {
    setUser(newUser);
  }, []);

  return (
    <div>
      <MicroApp
        name="user-profile"
        onUserUpdate={handleUserUpdate}
      />
    </div>
  );
}

// 子应用(独立 React 实例)
function UserProfile({ onUserUpdate }) {
  const [user, setUser] = useState({ name: 'Tom' });

  useEffect(() => {
    // ⚠️ onUserUpdate 是从主应用传过来的
    // 它的闭包是在主应用的渲染周期里创建的
    // 子应用的状态变化,可能触发主应用的更新
    onUserUpdate(user);
  }, [user, onUserUpdate]);

  return <div>{user.name}</div>;
}

微前端下的闭包治理

// ✅ 方案:使用事件总线或状态管理,不直接传回调
// eventBus.js
import mitt from 'mitt';
export const bus = mitt();

// 主应用
function MainApp() {
  useEffect(() => {
    bus.on('user-update', (user) => {
      setUser(user);
    });
    return () => bus.off('user-update');
  }, []);

  return <MicroApp name="user-profile" />;
}

// 子应用
function UserProfile() {
  const [user, setUser] = useState({ name: 'Tom' });

  useEffect(() => {
    bus.emit('user-update', user);
  }, [user]);

  return <div>{user.name}</div>;
}

为什么这样更好:

  1. 解耦:子应用不需要知道谁在监听
  2. 最新值:事件触发时读取的是当前值,不存在闭包捕获旧值
  3. 可清理:在 useEffect 返回的函数里可以取消监听

总结:闭包问题的本质与架构思考

闭包问题的本质

JavaScript 闭包 = 函数 + 作用域链
React 函数式组件 = 每次渲染 = 新的函数 + 新的作用域

两者结合 = 每次渲染创建新闭包,可能捕获旧值

高级视角的解决思路

层级 策略 工具
代码规范 exhaustive-deps 强制检查 eslint-plugin-react-hooks
组件设计 避免深层传递 callbacks Context / 状态管理
抽象封装 自定义 Hook 统一处理 useLatest / useInterval
架构分层 Server vs Client 分离 RSC / 'use client'
运行时 Concurrent Mode 适配 useDeferredValue / useSyncExternalStore
微前端 跨应用通信用事件总线 mitt / postMessage

最后一句

闭包不是 bug,是 JavaScript 的核心特性。React 用函数式范式重新定义了 UI,闭包问题只是这条路上的"学费"。

欢迎关注公众号程序员蜡笔熊,欢迎点赞转发,有什么意见或指正欢迎评论区评论。

《前端周刊》React 败北,虾皇登基,OpenClaw 勇夺 GitHub 第一开源软件

作者 Web情报局
2026年3月25日 12:57

今日要闻

打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。

欢迎阅读《Web 周刊》,上周 Web 开发圈的主要情报包括:

  • 🦞 OpenClaw 赶超 React,加冕 GitHub 第一软件
  • 💰 Linux 基金会成立 React 基金会,华为加盟
  • 🔄 Cloudflare 入驻 B 站,尝试把 Next 移植到 Vite
  • 🛠️ 尤大推出 Vite+,Oxfmt 性能吊打 Prettier

PS:本文附带甜妹解说的动画视频,粉丝请搜索哔哩哔哩@Web情报局

每周热搜

OpenClaw 勇夺 GitHub 第一

GitHub 星榜

如图,目前 GitHub 第一仓库是 build-your-own-x,一个聚合了各种资源的懒人包项目。

而涨星最快的仓库是 996.ICU,它其实一场中国社畜抗议 996 过劳文化的运动。

ICU996.png

但以上两者都不算真正的开源软件。

在此之前,GitHub stars 超过 20 万的开源软件有且仅有 3 个:React、Linux 和 Vue。

但两周前,没有 996 的 Open Claw 打破了 996.ICU 的不败神话,赶超了 Linux,一周后 stars 再度反超 React,标志着开源软件的“四皇“正式诞生。

big4-oss.png

更恐怖的是,React 在 2013 年首发,耗时 13 年才积累了 20 万 stars;而 Open Claw 赶超 React 竟然只用了 1/3 年,百日封神。

Star History Chart

目前,OpenClaw 是唯一一个狂砍 GitHub 三大记录的开源软件:

  • 🚀 GitHub 涨星最快的仓库
  • ⭐ GitHub stars 第一的开源软件
  • 👍 GitHub 第一个、也是唯一一个 stars 超过 30 万 的开源软件

恭喜虾皇,AI 的惊喜还在到处涌现!!!

官方情报

React 基金会成立

Linux 基金会官宣 React 基金会成立。

这个独立基金会拥有 Meta、微软、华为等 8 位创始成员,将接管 React、React Native、JSX 等项目。

CLoudflare 将 Next 移植到 Vite

上周,一位 Cloudflare AI 程序员只耗费 $1,100 美元的 token,就将 React 第一全栈框架 Next 移植到 Vite 生态,这个项目就是 vinext。

Cloudflare 认为,Next 的痛点在于它基于 Turbopack 构建,在 Serverless 平台部署存在阻力。

虽然这可以通过 OpenNext “曲线救国“,但更好的方案是基于 GitHub 第一 Web 构建工具 Vite 来驱动,因为 Vite Environment API 可以在任何平台运行。

目前,vinext 处于实验状态,README 说明了若干设计权衡和限制,Next 社区还反馈了安全漏洞。但 vinext 投入成本极低,再次证明了 AIGC 惊人的生产力和无限可能。

此外,Cloudflare 还重做了 Turnstile 人机验证部件。

有趣的是,目前地球总人口大约 80 亿,而这个部件每天就被点击了约 77 亿次,堪称地球上曝光量最大、交互最多的网页小部件了。

所以,你可能也已经在 ChatGPT、Youtube 等各种网站点击过这个人机验证控件了。

最后,Cloudflare 官宣入驻 B 站,感兴趣的粉丝可以一键三连。

bili.png

Angular SRR 漏洞

@angular/ssr 模块发现了 2 个安全漏洞:

  • 首先是一个 Critical 致命漏洞:SSRF 漏洞(服务端请求伪造),存在 HTTP header(请求头)注入风险
  • 还有一个 Moderate 中等漏洞:开放重定向漏洞,存在通过 X-Forwarded-Prefix 发动攻击的风险

Angular 团队建议你尽快将 SSR(服务端渲染)应用更新到最新补丁版。对于无法及时更新的项目,Angular 也提供了一些变通方案。

Prisma 再进化

Prisma 是 GitHub 第一 Node.js ORM(对象关系映射),它能跟后端数据库的 Restful 或 GraphQL 等 API 完美搭配使用。

Prisma 团队正用 TypeScript 重写下一代的 Prisma Next,它是 Prisma 8 的雏形。

目前,Prisma 7 暂定不支持 MongoDB 等非 SOL 数据库,但 Prisma Next 会支持。

此外,它还支持直接写 TS 来替代 schema.prisma 这种 Prisma 专属的声明式 schema 语言。

还有,Prisma Next 会把“查询地狱“重整为更优雅可读的链式调用。

版本更新

这一节我们有精选一些 GitHub stars 过万的仓库或流行的 npm 模块,共享它们近期主/次版本的更新内容,略有删改。

Nuxt UI v4.5

Nuxt UI 是一个全面的 Vue 组件库,最近发布了 4.5 版本。

这个次版本更新主要包括:

  • 新增 <Theme /> 组件,它可以一次性覆盖所有子组件主题
  • 得益于 Tailwind 4.2,它新增了 4 种中性色
  • useToast() 现在会自动去重,在重复通知时显示脉冲动画

toast.gif

Shiki v4.0

Shiki 是 GitHub 第一 Textmate 语法高亮库,能实现 VS Code 的高亮样式,由 Antfu 大神维护。

Shiki 发布了 4.0 主版本,主要包括:

  • 它要求 Node 版本至少 20,因为 Node 18 去年停止维护了
  • 删除一些拼写错误的废弃 API
// 删除之前过去式的方法名
createdBundledHighlighter();

// 重构为一般现在时的方法名
createBundledHighlighter();

Better Auth v1.5

Better Auth 是 GitHub 前十的认证与授权库,兼容 React 跟 Next,也支持 Vue 和 Nuxt 等流行框架。

Better Auth 发布了 1.5 次版本,是迄今为止最大的一次更新,主要包括:

  • @better-auth/electron 支持 Electron 桌面应用认证,它能处理完整的 OAuth 流程
  • MCP 插件现在自带一个远程认证客户端。如果你的 MCP 服务器与 Better Auth 实例分开,你可以验证 token,无需重复认证逻辑
  • 新插件 @better-auth/oauth-provider 将 Better Auth 实例变成一个兼容 OIDC 的 OAuth 2.1 授权服务器,将取代 OIDC Provider 插件,MCP 插件也将迁移到这个新插件
  • 新的 CLI 取代了原来的 @better-auth/cli
# npx auth 取代旧的 CLI
npx auth init

工具推荐

Oxfmt beta(公测)

Oxfmt 是一款 Rust 驱动、兼容 Prettier 的代码格式化神器。

它是 Vite Rolldown 版底层 Oxc 编译器生态的产品之一,也是最近尤大推出的 Vite+ 工具链的一部分,旨在用 Rust 重写的 Prettier 打败 Prettier。

Oxfmt 进入 beta(公测)阶段,我们刚刚提到的 GitHub 第一软件 OpenClaw 已经在用 Oxfmt 了,亮点主要有:

  • 内置 JS 的 import 导入语句排序(强迫症晚期狂喜)
  • 内置 Tailwind CSS class 类名自动排序,不需要 prettier-plugin-tailwindcss 插件
  • 默认启用 package.json 属性字段自动排序
  • 支持海量 IDE,包括 VS Code、WebStorm、Cursor、Neovim 等
  • JS / TS 一致性测试 100% 兼容 Prettier,少数不一致性正在和 Prettier 团队合作,但性能堪称降维打击

在性能跑分中,Oxfmt 比 Biome 快 3 倍,比 Prettier 快 30 倍。

oxfmt-rank.gif

特别鸣谢

以上就是本期《Web 周刊》的全部内容了,感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。

已经关注我的粉丝们,我们下期再见啦,掰掰~~

PS:本文附带甜妹解说的动画视频,粉丝请搜索哔哩哔哩@Web情报局

鳌虾 AoCode:重新定义 AI 编程助手的下一代可视化工具

作者 阳火锅
2026年3月25日 11:17

前言

在 AI 代码生成工具层出不穷的今天,程序员面临着一个核心问题:如何更高效、更精准地让 AI 理解我们的需求?传统的 AI 对话模式需要我们反复描述项目背景、手动关联各种文档和技能规范,这种模式不仅效率低下,还容易因为信息不完整导致生成结果与预期相差甚远。

鳌虾(AoCode) 正是为解决这些痛点而生。它通过可视化拖拽的方式,让开发者无需手敲冗长的 Prompt,即可自动生成高质量的 AI 编程指令。更重要的是,它能与项目中的技能文件(skills)无缝结合,让 AI 始终在统一的规范下生成代码,从根本上减少"幻觉"的产生。

GitHubgithub.com/zy1992829/a…


一、工具使用:零门槛上手,三步生成 AI 指令

1.1 组件拖拽,所见即所得

image.png

鳌虾提供了一个直观的可视化页面设计器。左侧是丰富的组件库,右侧是线框图骨架画布。开发者只需从左侧拖拽组件到画布中,即可快速搭建页面结构。

支持的组件包括:

  • 页面布局:单列、双列、左侧定宽、右侧定宽等多种布局容器
  • 基础组件:搜索栏、数据表格、表单区域、可编辑表格、详情区块
  • 自定义模块:支持纯文本自定义模块

每个组件都可以单独配置其属性和关联的业务字段,满足不同的业务需求。

1.2 智能读取项目技能文件

鳌虾支持自动扫描并读取项目中的技能文件。它会按照优先级自动探测以下目录:

.trae/skills  >  .trae/rules  >  .cursor/rules  >  .windsurf/rules  >  .aocode/rules  >  docs/rules

读取逻辑采用三态模式

  • 状态一:未找到任何技能文件 → 输出"您没有任何技能约束"
  • 状态二:找到文件但文件中没有 <rules>[CODE_RULES_START] 标签 → 静默处理,不输出任何内容
  • 状态三:找到文件且文件包含标签内容 → 自动提取并注入到 AI 指令中

这种设计确保了 AI 指令的精简性——只传递必要的信息,避免噪声干扰。

1.3 页面级技能分配

在鳌虾中,每个页面都可以独立绑定不同的技能文件。比如:

  • index.vue(列表页)绑定 page.md
  • edit.vue(编辑页)绑定 edit.md
  • look.vue(详情页)绑定 look.md

这样,不同类型的页面会自动带上各自的规范约束,生成结果更加精准。

1.4 一键生成 Clipboard 指令

image.png

配置完成后,点击**"生成 AI 指令"**按钮,鳌虾会自动生成一份结构化的指令文本,包含:

  • 功能目录和路径信息
  • 页面模块及布局顺序
  • 绑定的技能规范内容
  • API 基础路径

生成后直接复制到剪贴板,粘贴到 AI 对话窗口即可。


二、工具对比:鳌虾 vs 传统 AI 编程

对比维度 传统 AI 编程 鳌虾 AoCode
Prompt 输入 每次都要手敲完整描述 可视化配置,一键生成
技能规范传递 手动复制粘贴或反复提及 自动读取并注入
多页面一致性 每个页面都要重复描述项目背景 页面级技能分配,一劳永逸
信息完整性 容易遗漏关键约束条件 结构化输出,确保信息无遗漏
技能文件管理 依赖开发者自觉遵守 系统层面强制关联
学习成本 需要学习 Prompt 编写技巧 无需任何 Prompt 经验

2.1 传统模式的痛点

传统 AI 编程中,开发者常常面临这样的困境:

  1. 重复劳动:每次对话都要重新描述项目结构、技术栈、规范要求
  2. 信息不对称:AI 无法主动了解项目规范,容易产生"幻觉"
  3. 一致性差:不同对话生成的代码风格不统一,集成困难
  4. 维护成本高:项目规范变更后,需要手动更新所有历史 Prompt

2.2 鳌虾的解决方案

  1. 零 Prompt 编写:通过可视化配置替代手写文本,降低使用门槛
  2. 技能即规范:将项目规范写入技能文件(skills),AI 随时可读
  3. 上下文共享:一次配置,多页面复用,确保输出一致性
  4. 版本可控:技能文件可纳入版本管理,规范变更有迹可循

三、快速上手:下载与安装

3.1 环境要求

  • Node.js:>= 16.0.0
  • npm:>= 8.0.0

3.2 安装步骤

使用 npm 全局安装:

npm install -g aoxia-ui-generator

# 验证安装
aocode --version

安装完成后,在任意项目目录下运行即可启动鳌虾:

aocode

服务启动后会自动打开浏览器访问 http://localhost:3000/,即可开始使用。

3.3 项目初始化

首次使用时,建议在项目根目录下创建 .trae/skills 文件夹,并放置你的技能规范文件:

my-project/
├── .trae/
│   └── skills/
│       ├── page.md      # 列表页规范
│       ├── edit.md      # 编辑页规范
│       └── look.md      # 详情页规范
└── src/
    └── views/
        └── ...

鳌虾会自动扫描并读取这些文件,让你在页面配置时自由绑定。

image.png

image.png


四、未来展望:AI 编程的下一个十年

4.1 从"工具"到"助手"的进化

当前的 AI 编程工具大多停留在"响应指令"的层面。鳌虾的愿景是成为主动协作的助手——它不仅被动响应开发者的配置,还会主动建议最优的页面结构、规范的代码组织方式。

4.2 技能生态的构建

未来,鳌虾计划构建一个开放的技能市场(Skills Market)

  • 开发者可以发布自己编写的技能文件
  • 项目可订阅行业最佳实践技能
  • 支持技能的版本管理和更新通知

4.3 多模态融合

未来的 AI 编程将不局限于文本。鳌虾计划引入:

  • 设计稿导入:直接解析 Figma、Sketch 等设计文件
  • API 文档解析:自动理解接口定义并生成对应页面
  • 代码审查集成:生成后自动检查是否符合规范

4.4 对标 OpenClaw,走向国际

鳌虾的愿景不止于国内市场。它以 OpenClaw(开源龙虾)为对标目标,致力于成为全球开发者喜爱的 AI 编程工具。开源、生态、国际化的道路,将是鳌虾下一阶段的核心方向。


结语

AI 编程的时代已经到来,但"幻觉"问题始终困扰着开发者。鳌虾通过可视化配置 + 技能文件 + 智能注入的创新模式,让 AI 始终在规范的框架内生成代码,从根本上减少了不确定性。

这不是一个简单的 Prompt 生成器,而是一套完整的AI 编程工作流解决方案。它让开发者从繁琐的文本工作中解放出来,专注于真正的业务逻辑。

当别人还在手敲 Prompt 的时候,你已经在用鳌虾生成代码了。


鳌虾 AoCode,下一代 AI 编程助手,让代码生成更精准、更高效、更可控。


【uniapp】小程序支持分包存放微信自定义组件 wxcomponents

2026年3月25日 11:10

问题

在小程序端,不少开发者都有使用小程序原生自定义组件的需求,uniapp 也是支持使用小程序自定义组件的,只不过要放在根目录的 wxcomponents、mycomponents 等下面,详见官方文档

但是,在 5.03 之前,uniapp 仅支持在根目录存放自定义组件,很多开发者面临着包体积超出的问题

image.png

5.03 起,uniapp 开始支持在分包的根目录添加 wxcomponents、mycomponents 等

源码

此部分为源码分析,感兴趣的掘友可以看下

uniapp 仓库 中,支持的每一个小程序都有一个专门的包

image.png

复制操作是通过内部的 vite 插件实现的,具体位置在 packages/uni-cli-shared/src/vite/plugins/copy.ts,感兴趣的掘友可以看下。

框架已经封装好了复制的插件,对于各端来说,只需要做好配置就行。我们是要支持分包能复制 wxcomponents、mycomponents,这看起来就很简单了,只需要处理好分包的路径就行,代码比较简单,直接贴出来了

/**
 * 在将小程序组件相关资源(例如固定目录名下的静态文件)复制到构建产物时,
 * 生成本次复制所需的目录路径与 glob 模式列表。
 *
 * 返回值始终包含:
 * - 项目根(相对复制根目录)下名为 `dir` 的目录。
 * - 每个 `uni_modules` 插件包下对应子目录的 glob:与本函数内局部变量 `uniModulesDir` 相同
 *   (前缀为 `uni_modules`、通配段、`dir`、以及递归匹配尾部)。
 *
 * 当已设置 `UNI_INPUT_DIR`、`UNI_PLATFORM`,且输入目录下存在 `pages.json` 时,会从
 * `subPackages` 或 `subpackages` 读取分包根路径;对每个 `root` 再追加两项:
 * `normalizePath(path.join(root, dir))` 与 `normalizePath(path.join(root, uniModulesDir))`。
 *
 * 若缺少环境变量或不存在 `pages.json`,则只返回上述项目根级别的两项。
 *
 * @param dir - 资源目录名称(例如 `wxcomponents`)
 * @returns 非空数组,元素为规范化后的路径或 glob 字符串,供复制或监听工具使用。
 */
export function createCopyComponentDirs(dir: string) {
  const dirs = [dir]
  const uniModulesDir = 'uni_modules/*/' + dir + '/**/*'
  dirs.push(uniModulesDir)
  const inputDir = process.env.UNI_INPUT_DIR
  const platform = process.env.UNI_PLATFORM
  if (!inputDir || !platform) {
    return dirs
  }
  const pagesJsonFile = path.resolve(normalizePath(inputDir), 'pages.json')
  if (!fs.existsSync(pagesJsonFile)) {
    return dirs
  }
  const { appJson } = parseMiniProgramPagesJson(
    fs.readFileSync(pagesJsonFile, 'utf8'),
    platform,
    { subpackages: true }
  )
  const roots: string[] = Object.values(
    appJson.subPackages || appJson.subpackages || {}
  )
    .map(({ root }) => root)
    .filter(Boolean)
  roots.forEach((root) => {
    dirs.push(
      normalizePath(path.join(root, dir)),
      normalizePath(path.join(root, uniModulesDir))
    )
  })
  return dirs
}

注意事项

wxcomponents、mycomponents 等目录下方文件的处理是 全部拷贝到产物中,没有 treeShaking,因为需要开发者梳理组件的使用和存放。

交流群

我建了一个微信群,大家可以在群里和我沟通交流 uniapp 开发遇到的问题、uniapp 的源码等问题。

mmqrcode1774407130592.png

vben5 ImageUpload 模块多图预览功能

作者 新空2022
2026年3月25日 11:01

upload 中自定义回调预览回调逻辑

<template>
  <div>
    <Upload
      v-bind="$attrs"
      v-model:file-list="fileList"
      :accept="getStringAccept"
      :before-upload="beforeUpload"
      :custom-request="customRequest"
      :disabled="disabled"
      :list-type="listType"
      :max-count="maxNumber"
      :multiple="multiple"
      :progress="{ showInfo: true }"

      @preview="handlePreview"
      @remove="handleRemove"
    >

    <!--修改为多图预览-->
    <div style="display: none" >
      <ImagePreviewGroup :preview="{ visible,current:current, onVisibleChange: vis => (visible = vis) }">
        <Image v-for="item in fileList"
          :src="item.url"
        />
      </ImagePreviewGroup>
    </div>
  </div>
</template>

handlePreview 回调

计算current 图片索引和开启预览

/** 处理图片预览 */
async function handlePreview(file: UploadFile) {

  if (!file.url && !file.preview) {
    file.preview = await getBase64<string>(file.originFileObj!);
  }
  current.value= fileList.value.findIndex((item) => item.url === file.url);

  previewImage.value = file.url || file.preview || '';
  previewOpen.value = true;
  visible.value = true;
  previewTitle.value =
    file.name ||
    previewImage.value.slice(
      Math.max(0, previewImage.value.lastIndexOf("/") + 1)
    );
}

【节点】[SplitTextureTransform节点]原理解析与实际应用

作者 SmalBox
2026年3月25日 10:51

【Unity Shader Graph 使用与特效实现】专栏-直达

在 Unity URP Shader Graph 中,Split Texture Transform 节点是一个功能强大且灵活的工具,它允许开发者对纹理资源进行精细的控制和操作。这个节点的核心价值在于它能够将纹理的平铺、偏移和原始纹理数据分离开来,为复杂的着色器效果提供了更多的可能性。通过使用这个节点,开发者可以创建出更加动态和响应式的材质效果,而无需修改原始的纹理资源。

在现代游戏开发和实时渲染中,纹理的处理和变换是创建视觉丰富场景的关键环节。Split Texture Transform 节点正是为了满足这一需求而设计的,它提供了一种高效的方式来处理纹理的变换参数,使得开发者能够在不同的上下文中以不同的方式展示相同的纹理资源。这种灵活性对于实现复杂的视觉效果,如动态环境映射、扭曲效果或自定义 UV 动画等,具有重要的意义。

描述

Split Texture Transform 节点的主要功能是分解纹理的变换参数,包括平铺(Tiling)、偏移(Offset)和原始纹理数据。通过这个节点,开发者可以单独访问和操作这些参数,从而实现更加精细和复杂的纹理效果。例如,在创建镜像效果时,可能需要在不改变原始纹理的情况下对纹理进行扭曲或平移,这时就可以使用 Split Texture Transform 节点来单独控制这些变换参数。

该节点输出的纹理平铺设置为(0,0),缩放设置为(1,1)。这种设置会激活着色器属性中的 NoScaleOffset 标志,这意味着开发者可以通过材质检查器直接修改平铺偏移(Tiling Offset)值,而无需在着色器代码中进行复杂的调整。这种设计大大简化了材质参数的调整过程,使得非技术背景的艺术家也能够轻松地调整材质的外观。

在 Unity 的术语中,平铺(Tiling)和缩放(Scaling)经常被互换使用,因为它们都指的是纹理瓦片的大小。平铺参数控制了纹理在 UV 空间中的重复次数,而偏移参数则控制了纹理在 UV 空间中的位置。通过单独控制这些参数,开发者可以创建出各种复杂的纹理效果,如无缝贴图、动态滚动纹理或基于物体表面的自定义纹理映射。

端口详解

Split Texture Transform 节点包含多个输入和输出端口,每个端口都有其特定的功能和用途。了解这些端口的作用对于正确使用该节点至关重要。

输入端口

  • In:这是节点的唯一输入端口,类型为 Texture2D。它接收来自其他 Texture 2D 节点的输入,作为要处理的纹理资源。这个端口是节点操作的起点,所有后续的分解操作都基于这个输入的纹理。

输出端口

  • Tiling:这个输出端口类型为 Vector 2,它输出每通道应用的平铺量。这些值可以通过 Material Inspector 进行设置和调整。平铺值控制了纹理在 U 和 V 方向上的重复次数,例如,平铺值(2,2)会使纹理在两个方向上各重复两次。
  • Offset:这个输出端口类型为 Vector 2,它输出每通道应用的偏移量。偏移值控制了纹理在 U 和 V 方向上的位置偏移,例如,偏移值(0.5,0.5)会使纹理在 UV 空间中移动半个纹理大小的距离。
  • Texture Only:这个输出端口类型为 Vector 2,它输出无平铺和偏移数据的原始 Texture2D 输入。这个端口特别有用当您需要访问原始纹理数据而不受任何变换影响时。

使用场景与优势

Split Texture Transform 节点在多种场景下都能发挥重要作用,以下是一些典型的使用案例:

  • 动态纹理变换:在需要根据游戏逻辑或用户输入动态改变纹理平铺或偏移的场景中,使用 Split Texture Transform 节点可以轻松实现这种效果。例如,在创建流动的水面或滚动的地面纹理时,可以通过修改平铺和偏移参数来实现动态效果。
  • 纹理效果组合:当需要将多个纹理效果组合在一起时,Split Texture Transform 节点可以提供必要的控制粒度。例如,您可能希望在一个通道中使用原始纹理,而在另一个通道中使用经过平铺和偏移变换的纹理,通过这个节点可以轻松实现这种分离。
  • 性能优化:通过分离纹理变换参数,可以在不修改原始纹理资源的情况下实现各种效果,这有助于减少内存使用和提高渲染性能。特别是当多个材质实例需要共享同一纹理但需要不同的变换参数时,使用这个节点可以避免创建多个纹理副本。
  • 艺术家友好:由于平铺和偏移参数可以通过材质检查器直接调整,这使得非程序员的团队成员(如美术师)也能够轻松地调整材质外观,而无需理解复杂的着色器代码。

实际应用示例

为了更好地理解 Split Texture Transform 节点的使用方法,以下是一个简单的应用示例:

假设您正在创建一个具有动态水面效果的材质。水面的纹理需要随着时间滚动以模拟流动效果,同时还需要根据水深或其他因素调整纹理的平铺密度。

在这种情况下,您可以使用 Split Texture Transform 节点来分离纹理的平铺和偏移参数。然后,您可以将偏移端口连接到一个基于时间的节点(如 Time 节点),以实现纹理的自动滚动效果。同时,您可以将平铺端口连接到一个基于水深的参数,以实现不同区域的不同平铺密度。

具体实现步骤:

  • 首先,将您的纹理资源连接到 Split Texture Transform 节点的 In 端口。
  • 然后,将 Tiling 输出端口连接到一个自定义参数,该参数可以根据水深或其他条件进行调整。
  • 将 Offset 输出端口连接到一个基于时间的函数,例如将 Time 节点的输出与一个速度参数相乘,然后将结果添加到 UV 坐标中。
  • 最后,将处理后的 UV 坐标连接到您的纹理采样节点。

通过这种方式,您可以创建一个动态的、可调整的水面效果,而无需修改原始纹理资源或编写复杂的着色器代码。

注意事项与最佳实践

在使用 Split Texture Transform 节点时,有一些重要的注意事项和最佳实践需要遵循:

  • 性能考虑:虽然 Split Texture Transform 节点本身不会带来显著的性能开销,但过度复杂的纹理变换操作可能会影响渲染性能。特别是在移动设备上,应谨慎使用高频率的纹理变换操作。
  • UV 空间理解:要有效使用这个节点,需要对 UV 空间有清晰的理解。UV 坐标决定了纹理如何映射到 3D 模型的表面,不正确的 UV 操作可能导致纹理拉伸或扭曲。
  • 材质实例化:当在项目中使用多个材质实例时,确保正确设置平铺和偏移参数的默认值,以便每个实例都可以独立调整这些参数而不相互干扰。
  • 与其他节点组合:Split Texture Transform 节点通常与其他 Shader Graph 节点组合使用,如 UV 节点、数学运算节点和时间节点等。了解这些节点的功能及其如何与 Split Texture Transform 节点交互,对于创建复杂的着色器效果至关重要。

高级应用技巧

除了基本用法外,Split Texture Transform 节点还可以用于一些更高级的应用场景:

  • 多纹理混合:当需要将多个纹理以不同的平铺和偏移设置混合在一起时,可以使用多个 Split Texture Transform 节点分别处理每个纹理,然后使用混合节点将它们组合起来。
  • 程序化纹理生成:结合其他程序化节点,Split Texture Transform 节点可以用于创建动态生成的纹理效果,如基于物体位置或朝向的纹理变换。
  • 特效系统:在粒子系统或其他特效中,使用 Split Texture Transform 节点可以实现基于生命周期的纹理变换,例如随着粒子年龄增长而逐渐改变纹理的平铺或偏移。
  • 环境映射:在创建反射或环境映射效果时,使用 Split Texture Transform 节点可以精确控制环境贴图的映射方式,实现更加真实的反射效果。

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

geojson-to-kml (KML 格式转换工具)

作者 giszhc
2026年3月25日 10:36

geojson-to-kml (KML 格式转换工具)

一个 简单、轻量、健壮 的 JavaScript / TypeScript 库,用于将 GeoJSON 数据转换为 KML (Keyhole Markup Language) 格式。

本库不仅支持基础的几何转换,还完整支持了 Mapbox SimpleStyle 规范,可以将 GeoJSON 中的样式属性(如 marker-colorstroke 等)转换为 KML 的样式定义。


✨ 特性

  • 🚀 零依赖:基于轻量级逻辑实现,无沉重依赖。
  • 🛡️ 类型安全:原生 TypeScript 支持,完美适配 geojson 类型定义。
  • 🎨 样式支持:支持 Mapbox SimpleStyle,自动生成 KML Style 标签。
  • 📊 数据保留:GeoJSON 的 properties 会自动转换为 KML 的 ExtendedData
  • 🧩 全类型支持:涵盖所有 Geometry、Feature、FeatureCollection。
  • 🌲 Tree Shaking:现代化 ESM 导出,支持按需引入。

📦 安装

# npm
npm install @giszhc/geojson-to-kml

# pnpm
pnpm add @giszhc/geojson-to-kml

🚀 快速上手

基础用法

TypeScript

import tokml from '@giszhc/geojson-to-kml';

const geojson = {
  type: 'Point',
  coordinates: [120.123, 30.456]
};

const kml = tokml(geojson);
console.log(kml);
// 输出包含 <Placemark><Point>... 的 XML 字符串

带样式的转换 (SimpleStyle)

TypeScript

const feature = {
  type: 'Feature',
  properties: {
    name: '我的位置',
    'marker-color': '#ff0000',
    'stroke': '#00ff00',
    'stroke-width': 3
  },
  geometry: {
    type: 'Point',
    coordinates: [120, 30]
  }
};

const kml = tokml(feature, {
  simplestyle: true, // 开启样式转换
  name: 'name'       // 指定使用哪个属性作为节点名称
});

🛠️ API 参数说明

tokml(geojson: GeoJSON, options?: TokmlOptions): string

参数名 类型 描述 默认值
documentName string KML <Document> 节点的名称 undefined
documentDescription string KML <Document> 节点的描述 undefined
name string properties 中提取哪个字段作为 <name> 'name'
description string properties 中提取哪个字段作为 <description> 'description'
simplestyle boolean 是否将 Mapbox 样式属性转换为 KML 样式 false
timestamp string properties 中提取哪个字段作为 <TimeStamp> 'timestamp'

🎨 支持的样式属性 (SimpleStyle)

当开启 simplestyle: true 时,以下 GeoJSON 属性将被识别并转换:

属性名 描述 示例
marker-color 标记点的颜色 (Hex) #ff0000
marker-size 标记点大小 (small, medium, large) large
marker-symbol 标记点图标符号 bus, star
stroke 线条或多边形边界颜色 #0000ff
stroke-opacity 线条透明度 (0.0 - 1.0) 0.5
stroke-width 线条宽度 (像素) 2
fill 多边形填充颜色 #00ff00
fill-opacity 填充透明度 (0.0 - 1.0) 0.3

⚠️ 注意事项

  1. 坐标系:KML 官方规范要求使用 WGS84 (EPSG:4326) 经纬度。转换前请确保您的 GeoJSON 坐标正确。
  2. 数据类型:所有的 properties 都会被放入 <ExtendedData> 中,这有助于在 Google Earth 等软件中查看完整的业务数据。
  3. 命名空间:生成的 KML 默认包含 xmlns="http://www.opengis.net/kml/2.2"

完结,撒花✿✿ヽ(°▽°)ノ✿

跟随系统暗黑模式动态更改已有的颜色

作者 江湖文人
2026年3月25日 10:21

永远只跟随系统、不要被手动切换影响

:deep(.arco-menu) {
      background-color: var(--color-bg);
}

可以通过“改写 CSS 变量本身”的方式实现:default-layout.vue 里继续写 background-color: var(--color-bg); 不动,只在全局样式里让 --color-bg 在 light/dark 时取不同值即可。

body {
  --color-bg: #f6f8fa;
}

body[arco-theme='light'] {
  --color-bg: #f6f8fa;
}

body[arco-theme='dark'] {
  --color-bg: var(--color-bg-2);
}

@media (prefers-color-scheme: dark) {
  body:not([arco-theme]) {
    --color-bg: var(--color-bg-2);
  }
}

如果页面上没有显式设置 arco-theme,就会走 prefers-color-scheme 的媒体查询:系统暗黑 → --color-bg 自动变为暗色(这里用的是 Arco 自带的 --color-bg-2,会随暗黑主题变化)。

我用 Zustand 三年了,直到遇见 easy-model...

作者 张一凡93
2026年3月25日 10:19

不是说Zustand不好,而是有些场景它真的hold不住。

故事是这样的

我们公司有个中后台项目,状态管理一直用Zustand。讲真,Zustand确实香——API简洁、性能好、类型推断也还行。

直到有一天,产品经理提了一个需求:

"做一个操作日志中心,用户每做一个操作就记录下来,支持撤销重做。而且要能在列表页看到嵌套对象的变化轨迹。"

我自信满满地开始写,然后就被打脸了。

Zustand的痛点

1. 状态一多就成了"函数大杂烩"

// store.ts
const useStore = create((set, get) => ({
  user: null,
  orders: [],
  filters: {},
  pagination: { page: 1, size: 10 },
  loading: false,

  setUser: (user) => set({ user }),
  setOrders: (orders) => set({ orders }),
  setFilters: (filters) => set({ filters }),
  setPagination: (pagination) => set({ pagination }),
  setLoading: (loading) => set({ loading }),

  fetchOrders: async () => {
    const { filters, pagination } = get();
    set({ loading: true });
    const res = await api.getOrders(filters, pagination);
    set({ orders: res.data, loading: false });
  },

  // ... 200行后
}));

一个文件写了500行,到后面自己都不想看了。

2. 撤销重做?自己实现吧

Zustand没有内置history支持。网上倒是有zundo这种中间件,但:

  • 配置繁琐
  • 类型推断经常出问题
  • 和业务代码集成麻烦

3. 监听嵌套对象?不好意思,做不到

const orders = useStore((s) => s.orders);
// 改变了 orders[0].items[0].price
// 组件不会更新!因为引用没变

你得用subscribe或者自己写selector,关键是一旦嵌套深了,selector写得怀疑人生。

然后我发现了easy-model

// 用类来组织,一个领域一个类
class OrderModel {
  orders: Order[] = [];
  filters: FilterParams = {};
  pagination = { page: 1, size: 10 };
  loading = false;

  async fetchOrders() {
    this.loading = true;
    const res = await api.getOrders(this.filters, this.pagination);
    this.orders = res.data;
    this.loading = false;
  }

  setFilter(key: string, value: any) {
    this.filters[key] = value;
  }
}

// 内置history支持
const order = useModel(OrderModel, []);
const history = useModelHistory(order);

// 撤销重做,一行搞定
history.back();
history.forward();
history.reset();

这才是面向对象!

深度监听,真香

class ComplexModel {
  user = {
    profile: {
      address: { city: "北京" },
    },
  };
  orders = [];
}

// 监听嵌套对象变化
watch(user, (keys, prev, next) => {
  // keys: ['profile', 'address', 'city']
  console.log("变化了", keys, prev, next);
});

user.profile.address.city = "上海";
// 自动触发监听,拿到完整的变化路径

还有IoC?

// 定义依赖
const apiSchema = object({
  baseUrl: string(),
}).describe("API配置");

// 注入
class OrderApi {
  @inject(apiSchema)
  config?: { baseUrl: string };

  async getOrders() {
    return fetch(`${this.config?.baseUrl}/orders`);
  }
}

// 配置
config(
  <Container>
    <CInjection
      schema={apiSchema}
      ctor={OrderApi}
      params={["https://api.example.com"]}
    />
  </Container>,
);

这不妥妥的企业级架构?

性能对比

官方benchmark(10万个元素,5轮批量更新):

方案 耗时
Zustand 0.6ms
easy-model 3.1ms
MobX 16.9ms
Redux 51.5ms

easy-model比Zustand慢3倍,但换来了:

  • 类模型组织方式
  • 内置IoC能力
  • 深度监听
  • History支持

这波不亏!

怎么选?

  • 小项目、简单状态 → Zustand依旧真香
  • 中大型、需要领域模型、需要IoC、需要history → easy-model真香

Github: github.com/ZYF93/easy-…

觉得有帮助的点个⭐️支持下 🙏

arcgis-to-geojson双向转换工具库

作者 giszhc
2026年3月25日 10:06

@giszhc/arcgis-to-geojson

一个轻量级的 ArcGIS JSON 与 GeoJSON 双向转换工具库,支持点、线、面等多种几何类型,开箱即用,并针对现代前端开发进行了 Tree Shaking 优化。

支持以下特性:

  • 双向转换:支持 ArcGIS JSON 转 GeoJSON 和 GeoJSON 转 ArcGIS JSON
  • 几何类型:完整支持 Point、MultiPoint、LineString、MultiLineString、Polygon、MultiPolygon
  • 环处理:自动处理多边形的内外环方向,符合 RFC 7946 和 ArcGIS 标准
  • 坐标验证:智能判断外环与内环的包含关系,正确处理孔洞
  • 要素支持:支持 Feature 和 FeatureCollection 结构,保留属性信息
  • ID 映射:自动识别 OBJECTID、FID 等字段作为要素 ID
  • 坐标系警告:检测非 WGS84 坐标系时发出警告
  • TypeScript:完善的类型定义支持

在这里插入图片描述

方法列表

ArcGIS JSON 转 GeoJSON

  • arcgisToGeoJSON - 将 ArcGIS JSON 转换为 GeoJSON 格式

GeoJSON 转 ArcGIS JSON

  • geojsonToArcGIS - 将 GeoJSON 转换为 ArcGIS JSON 格式

安装

你可以通过 npm 安装该库:

pnpm install @giszhc/arcgis-to-geojson

使用方法

arcgisToGeoJSON(arcgis: any, idAttribute?: string): any

将 ArcGIS JSON 转换为 GeoJSON 格式。支持点、线、面、要素集合等多种类型。

import { arcgisToGeoJSON } from '@giszhc/arcgis-to-geojson';

// 转换点
const point = {
    x: 116.4,
    y: 39.9
};
const geojson = arcgisToGeoJSON(point);
console.log(geojson);
// { type: 'Point', coordinates: [116.4, 39.9] }

// 转换多边形(带孔洞)
const polygon = {
    rings: [
        [[116.0, 39.0], [117.0, 39.0], [117.0, 40.0], [116.0, 40.0], [116.0, 39.0]],
        [[116.5, 39.5], [116.8, 39.5], [116.8, 39.8], [116.5, 39.8], [116.5, 39.5]]
    ]
};
const geojson2 = arcgisToGeoJSON(polygon);
console.log(geojson2);
// { type: 'Polygon', coordinates: [...] }

// 转换要素集合
const featureCollection = {
    features: [
        {
            geometry: { x: 116.4, y: 39.9 },
            attributes: { name: '北京', OBJECTID: 1 }
        }
    ]
};
const fc = arcgisToGeoJSON(featureCollection);
console.log(fc);
// { type: 'FeatureCollection', features: [...] }

// 指定自定义 ID 字段
const feature = {
    geometry: { x: 116.4, y: 39.9 },
    attributes: { name: '北京', customId: 'BJ001' }
};
const geojson3 = arcgisToGeoJSON(feature, 'customId');
// id: 'BJ001'

geojsonToArcGIS(geojson: any, idAttribute?: string): any

将 GeoJSON 转换为 ArcGIS JSON 格式。自动处理环的方向和坐标系。

import { geojsonToArcGIS } from '@giszhc/arcgis-to-geojson';

// 转换点
const point = {
    type: 'Point',
    coordinates: [116.4, 39.9]
};
const arcgis = geojsonToArcGIS(point);
console.log(arcgis);
// { x: 116.4, y: 39.9, spatialReference: { wkid: 4326 } }

// 转换多边形
const polygon = {
    type: 'Polygon',
    coordinates: [
        [
            [116.0, 39.0], [116.0, 40.0], [117.0, 40.0], [117.0, 39.0], [116.0, 39.0],
            [116.5, 39.5], [116.5, 39.8], [116.8, 39.8], [116.8, 39.5], [116.5, 39.5]
        ]
    ]
};
const arcgis2 = geojsonToArcGIS(polygon);
console.log(arcgis2);
// { rings: [...], spatialReference: { wkid: 4326 } }

// 转换 FeatureCollection
const featureCollection = {
    type: 'FeatureCollection',
    features: [
        {
            type: 'Feature',
            geometry: { type: 'Point', coordinates: [116.4, 39.9] },
            properties: { name: '北京' },
            id: 1
        }
    ]
};
const fc = geojsonToArcGIS(featureCollection);
console.log(fc);
// [{ geometry: {...}, attributes: {...} }]

// 指定自定义 ID 字段名
const feature = {
    type: 'Feature',
    geometry: { type: 'Point', coordinates: [116.4, 39.9] },
    properties: { name: '北京' },
    id: 100
};
const arcgis3 = geojsonToArcGIS(feature, 'FID');
// attributes: { name: '北京', FID: 100 }

注意事项

  1. 坐标系:转换后的 GeoJSON 默认为 WGS84 坐标系(EPSG:4326)。如果 ArcGIS JSON 使用其他坐标系,会发出警告
  2. 环方向
    • ArcGIS:外环顺时针,内环(孔洞)逆时针
    • GeoJSON:外环逆时针,内环(孔洞)顺时针(RFC 7946 标准)
    • 库会自动处理方向转换
  3. ID 映射:默认使用 OBJECTIDFID 作为要素 ID,可通过 idAttribute 参数自定义
  4. 空几何:支持处理空几何体,转换为 null
  5. 三维坐标:支持 Z 坐标(高程)的转换
  6. 多部件几何:自动识别并转换为 MultiPoint、MultiLineString、MultiPolygon

完结,撒花✿✿ヽ(°▽°)ノ✿

Flutter ngspice 插件

作者 GoCoding
2026年3月25日 09:59

Flutter FFI 绑定 ngspice C 接口,实现一个 plugin 库:

  • 可通过 pubspec.yaml 引入
  • 上层提供 Dart API 接口
  • 底层通过 FFI 调用 C 代码

ngspice 是一个开源的电路仿真器,主要用于电子电路的分析和设计。

准备

创建项目

创建 Flutter FFI plugin 项目,

flutter create -t plugin_ffi --org cn.nebul --platforms ios,android,windows,linux,macos mozsim_ngspice

cd mozsim_ngspice/

flutter pub outdated
flutter pub upgrade --major-versions

flutter pub add ffi

获取共享库

获取 ngspice shared libs,可以找预编译库,不然就从源码编译。

1)预编译库

Windows 下载预编译库进 windows/ngspice-44.2_dll_64 目录。

$ tree windows -aF -L 2 --dirsfirst
windows
|-- ngspice-44.2_dll_64/
|                   `-- Spice64_dll/
|-- .gitignore
`-- CMakeLists.txt

2)从源码编译

# ngspice 44.2 Commit [e011d1] master
git clone -b master --depth 1 https://git.code.sf.net/p/ngspice/ngspice ngspice

# Windows
# - Building ngspice with MS Visual Studio 2022
#   - flex-bison/: https://sourceforge.net/projects/winflexbison/
#   - ngspice/visualc/sharedspice.sln: open as admin, run with ReleaseOpenMP x64

# Linux
./compile_linux_shared.sh

测试

Plugin 使用样例,

cd mozsim_ngspice/example/
flutter run

mozsim_ngspice_example.png

Dart 接口测试,

$ cd mozsim_ngspice/
$ dart test/mozsim_ngspice_test.dart
mozsim_ngspice_test ...
|Char| stdout Hello from ngspice
|Char| stdout Note: No compatibility mode selected!
|Char| stdout Circuit: * voltage divider netlist
|Stat| Prepare Deck
|Stat| Parse
|Char| stdout Doing analysis at TEMP = 27.000000 and TNOM = 27.000000
|Stat| Device Setup
|Char| stdout Using SPARSE 1.3 as Direct Linear Solver
|Stat| op
|Char| stdout No. of Data Rows : 1
|Char| stdout out = 6.666667e-01
|Char| stdout ngspice-44.2 done
|Char| stdout Note: 'quit' asks for resetting or detaching ngspice.dll.
|Exit| status 0 immediate false quit true
NgSpiceException: NgSpiceRequestType.command return error, ret=1
mozsim_ngspice_test done

C++ 接口测试,

# Windows
> cd mozsim_ngspice/
> test\build\Debug\mozsim_ngspice_test.exe
mozsim_ngspice_test ...
|Char| stderr Warning: can't find the initialization file spinit.
|Char| stdout ******
|Char| stdout ** ngspice-44.2 shared library
|Char| stdout ** Creation Date: Jan 11 2025   14:16:40
|Char| stdout ******
|Char| stdout Hello from ngspice
|Char| stdout Note: No compatibility mode selected!
|Char| stdout Note: No compatibility mode selected!
|Char| stdout Circuit: * voltage divider netlist
|Stat| Prepare Deck
|Stat| Parse
|Char| stdout Doing analysis at TEMP = 27.000000 and TNOM = 27.000000
|Stat| Device Setup
|Char| stdout Using SPARSE 1.3 as Direct Linear Solver
|Stat| op
|Char| stdout No. of Data Rows : 1
|Char| stdout out = 6.666667e-01

接口

Dart 接口重生成,

# https://pub.dev/packages/ffigen
dart run ffigen --config ffigen.yaml

Dart 接口使用样例,

import 'package:mozsim_ngspice/ngspice.dart';

void main(List<String> args) async {
  print('mozsim_ngspice_test ...');
  await _main();
  // await _main();
  print('mozsim_ngspice_test done');
}

Future<void> _main() async {
  NgSpice? ngspice;
  try {
    ngspice = await NgSpice.initByLib(
      NgSpice.libPath(NgSpice.libName, 'test/build/Debug/'),
    );

    ngspice.resp.listen(_handleResponses);

    await ngspice.sendCommand('echo Hello from ngspice');

    await ngspice.sendCircs([
      '* voltage divider netlist',
      'V1 in 0 1',
      'R1 in out 1k',
      'R2 out 0 2k',
      '.end',
    ]);

    await ngspice.sendCommands(['op', 'print out']);
    await ngspice.sendCommand('quit');
  } on NgSpiceException catch (e) {
    print(e);
  } catch (e) {
    print('Caught error: $e');
  } finally {
    await ngspice?.close();
  }
}

void _handleResponses(NgSpiceResponse resp) {
  if (resp.type == NgSpiceResponseType.print) {
    final res = resp.data as String;
    print('|Char| $res');
  } else if (resp.type == NgSpiceResponseType.stat) {
    final res = resp.data as String;
    print('|Stat| $res');
  } else if (resp.type == NgSpiceResponseType.exit) {
    final (status, immediate, quit) = resp.data as (int, bool, bool);
    print('|Exit| status $status immediate $immediate quit $quit');
  }
}

参考

AbortController 实战:竞态取消、超时兜底与请求生命周期管理

2026年3月25日 09:54

AbortController 实战:竞态取消、超时兜底与请求生命周期管理

项目越大,请求越多,Bug 越诡异。 你一定见过这些场景:

  • 搜索结果偶尔“闪一下又变回旧数据”
  • 提交按钮点快了,后台多出几条脏数据
  • 页面都切走了,接口还在跑,甚至回来还触发 setState warning

这些问题看起来毫无规律,但本质上只是一件事:

请求没有被正确“结束”。

大部分团队会优化接口、加缓存、做防抖,却很少有人认真思考:

一个请求,什么时候应该继续?什么时候必须终止?

AbortController 应该在项目初期就被当作基础设施来搭建,而不是等线上出了问题才到处打补丁。这篇文章是我踩完所有坑之后的经验沉淀,把竞态取消、超时控制、组件卸载清理这几个场景串起来,给出一套在 React 和 Vue 中都能落地的防御性编排方案。

竞态取消:搜索场景的三种方案对比

竞态问题是异步请求里最常见也最容易被忽视的坑。回到搜索框的例子,用户快速输入,多个请求并发,我们只关心最后一次的结果。怎么确保展示的一定是最新请求的数据?

方案一:标记法(能用,但粗糙)

最朴素的思路——给每次请求打一个版本号,回调里检查是不是最新版本。

let currentRequestId = 0

async function search(keyword: string) {
  const requestId = ++currentRequestId
  const res = await fetch(`/api/search?q=${keyword}`)
  const data = await res.json()

  if (requestId === currentRequestId) {
    setResults(data) // 版本号匹配才更新 UI
  }
}

实现简单、零依赖,但有一个明显的问题:请求并没有被真正取消。"杭"的请求还是跑完了全部流程,占了带宽和连接池,只是回调里没处理结果而已。我们项目初期就是用的这个方案,当时觉得"能用就行"。后来在性能分析里发现,搜索页面在快速输入时,Network 面板里密密麻麻全是 pending 请求,Chrome 的同域 6 连接上限直接被打满,导致其他关键请求(比如用户鉴权、埋点上报)被阻塞。

方案二:纯 AbortController(真正取消请求)

标记法的核心缺陷是请求仍然在跑,只是忽略了结果。AbortController 可以从网络层真正中断请求,释放连接。

let currentController: AbortController | null = null

async function search(keyword: string) {
  currentController?.abort() // 取消上一个请求
  const controller = new AbortController()
  currentController = controller

  try {
    const res = await fetch(`/api/search?q=${keyword}`, {
      signal: controller.signal
    })
    const data = await res.json()
    setResults(data)
  } catch (err) {
    if ((err as DOMException).name !== 'AbortError') throw err
    // AbortError 说明是我们主动取消的,静默忽略
  }
}

相比标记法,abort() 调用后浏览器会立即中断 TCP 连接(或阻止请求发出),被取消的请求在 Network 面板中会显示为 (canceled) 状态,不再占用同域的 6 个并发连接。缺点是在高频输入场景下,每次按键都会发出一个请求然后立即取消上一个,虽然连接被释放了,但请求的绝对数量仍然很多,服务端压力并没有减轻。所以对于搜索框这类场景,还需要配合防抖进一步优化。

方案三:防抖 + AbortController(生产环境的选择)

单纯的 AbortController 取消还不够,还需要配合防抖来减少请求频率。这里有个容易搞错的地方:防抖和取消的职责不一样,不能互相替代。防抖解决的是"减少发出的请求数量",取消解决的是"已经发出的请求不再需要了"。两个要一起用。

function useDebouncedSearch(delay = 300) {
  const controllerRef = useRef<AbortController | null>(null)
  const timerRef = useRef<number>()
  const [results, setResults] = useState([])

  const search = useCallback((keyword: string) => {
    clearTimeout(timerRef.current) // 清掉防抖定时器

    timerRef.current = window.setTimeout(async () => {
      controllerRef.current?.abort() // 取消上一个还在飞的请求
      const controller = new AbortController()
      controllerRef.current = controller

      try {
        const res = await fetch(`/api/search?q=${keyword}`, {
          signal: controller.signal
        })
        setResults(await res.json())
      } catch (err) {
        if ((err as DOMException).name !== 'AbortError') throw err
      }
    }, delay)
  }, [delay])

  useEffect(() => () => {
    clearTimeout(timerRef.current)
    controllerRef.current?.abort()
  }, [])

  return { results, search }
}

防抖定时器和 AbortController 各管各的:防抖控制"什么时候发请求",AbortController 控制"已经发出的请求要不要保留"。组件卸载时两个都要清理,缺一不可。我们项目最终落地的就是这个方案。改完之后,搜索页面的无效请求从平均每次搜索 5-6 个降到了 0-1 个,Network 面板终于清爽了。

三种方案放在一起对比:

方案 请求真正取消 实现复杂度 适用场景
标记法 否,幽灵请求仍占连接 小项目、低频请求
AbortController 是,网络层中断 大多数场景
防抖 + AbortController 是,且减少请求频次 搜索、筛选等高频输入场景

React 中的异步副作用编排

useEffect 中的正确姿势

function UserProfile({ userId }: { userId: string }) {
  const [profile, setProfile] = useState(null)

  useEffect(() => {
    const controller = new AbortController()

    async function loadProfile() {
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal
        })
        setProfile(await res.json())
      } catch (err) {
        if ((err as DOMException).name === 'AbortError') return
        console.error('加载用户信息失败:', err)
      }
    }
    loadProfile()

    return () => controller.abort()
  }, [userId])

  return <div>{profile?.name}</div>
}

这段代码看起来简单,有两个细节容易踩坑。

AbortController 的创建必须在 useEffect 内部。因为每次 effect 执行需要一个独立的 controller 实例,放外面会被多个 effect 共享,取消逻辑就乱了。

async 函数不能直接作为 useEffect 的回调——effect 要求返回 cleanup 函数或 undefined,不能返回 Promise。所以需要在内部定义一个 async 函数再调用。这个限制看起来别扭,但它强制你把"发请求"和"清理"分开思考,反而减少了遗漏 cleanup 的概率。

封装通用的 useAbortableFetch

当团队有十几个页面都需要这种模式时,重复写 AbortController 的样板代码就不合适了。我们封装了一个自定义 Hook:

function useAbortableFetch<T>(url: string | null, options?: { timeout?: number }) {
  const [state, setState] = useState<{
    data: T | null; loading: boolean; error: Error | null
  }>({ data: null, loading: false, error: null })

  useEffect(() => {
    if (!url) return

    const controller = new AbortController()
    const { timeout = 10000 } = options ?? {}
    const signal = AbortSignal.any
      ? AbortSignal.any([controller.signal, AbortSignal.timeout(timeout)])
      : controller.signal

    setState(prev => ({ ...prev, loading: true, error: null }))

    fetch(url, { signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json()
      })
      .then(data => setState({ data, loading: false, error: null }))
      .catch(err => {
        if (err.name === 'AbortError' || err.name === 'TimeoutError') return
        setState({ data: null, loading: false, error: err })
      })

    return () => controller.abort()
  }, [url])

  return state
}

url 为 null 时不发请求,方便做条件请求;url 变了自动重新请求,旧请求自动取消;超时和手动取消合并在一个信号里处理。用起来非常干净:

function SearchPage() {
  const [keyword, setKeyword] = useState('')
  const debouncedKeyword = useDebounce(keyword, 300)

  const { data, loading, error } = useAbortableFetch<SearchResult[]>(
    debouncedKeyword ? `/api/search?q=${debouncedKeyword}` : null
  )

  return <input value={keyword} onChange={e => setKeyword(e.target.value)} />
}

手动触发场景的处理

表单提交、批量操作这类请求的特殊之处在于:取消的触发时机不是依赖变化,而是"重复操作"或"组件卸载"。

function useAbortableAction<T>() {
  const controllerRef = useRef<AbortController | null>(null)

  const execute = useCallback(async (
    asyncFn: (signal: AbortSignal) => Promise<T>
  ): Promise<T | undefined> => {
    controllerRef.current?.abort() // 新操作来了,取消上一个(防连点)
    const controller = new AbortController()
    controllerRef.current = controller

    try {
      return await asyncFn(controller.signal)
    } catch (err) {
      if ((err as DOMException).name === 'AbortError') return undefined
      throw err
    }
  }, [])

  useEffect(() => () => { controllerRef.current?.abort() }, [])
  return execute
}

使用时,把 signal 透传给请求函数即可:

const executeAction = useAbortableAction()

const handleSubmit = async (formData: OrderData) => {
  const result = await executeAction(signal =>
    fetch('/api/orders', {
      method: 'POST',
      body: JSON.stringify(formData),
      signal
    }).then(r => r.json())
  )
  if (result) navigate(`/orders/${result.id}`)
}

这里有个需要权衡的地方:POST 请求真的应该取消吗?连点两次提交按钮,取消第一个 POST 请求——网络层面是中断了,但后端可能已经处理了一半。所以对于写操作,AbortController 更多是解决"组件卸载后不再处理回调"的问题,而不是真的指望后端能回滚。防重复提交还是要靠按钮 loading 状态锁定 + 后端幂等校验。

Vue 中的 Composable 实现

在 Vue 中,watchEffect 提供的 onCleanup 回调天然适合管理请求生命周期,写起来比 React 的 useEffect 更直观。下面是与前文 useAbortableFetch 对等的 Vue Composable 实现:

import { ref, watchEffect, toValue, type Ref, type MaybeRefOrGetter } from 'vue'

function useFetchData<T>(url: MaybeRefOrGetter<string | null>, options?: { timeout?: number }) {
  const data = ref<T | null>(null) as Ref<T | null>
  const loading = ref(false)
  const error = ref<Error | null>(null)

  watchEffect((onCleanup) => {
    const resolvedUrl = toValue(url)
    if (!resolvedUrl) {
      data.value = null
      loading.value = false
      return
    }

    const controller = new AbortController()
    const { timeout = 10000 } = options ?? {}

    // 注册清理函数:依赖变化或组件卸载时自动调用
    onCleanup(() => controller.abort())

    loading.value = true
    error.value = null

    const timeoutId = setTimeout(() => controller.abort(), timeout)

    fetch(resolvedUrl, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json()
      })
      .then(json => {
        data.value = json
        loading.value = false
      })
      .catch(err => {
        if (err.name === 'AbortError') return // 主动取消,静默忽略
        error.value = err
        loading.value = false
      })
      .finally(() => clearTimeout(timeoutId))
  })

  return { data, loading, error }
}

使用方式同样干净,响应式的 URL 变化会自动触发重新请求并取消旧请求:

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

const keyword = ref('')
const debouncedKeyword = useDebouncedRef(keyword, 300) // 假设已有防抖 ref 工具
const apiUrl = computed(() =>
  debouncedKeyword.value ? `/api/search?q=${debouncedKeyword.value}` : null
)

const { data: results, loading, error } = useFetchData<SearchResult[]>(apiUrl)
</script>

<template>
  <input v-model="keyword" />
  <div v-if="loading">搜索中...</div>
  <ul v-else-if="results">
    <li v-for="item in results" :key="item.id">{{ item.title }}</li>
  </ul>
</template>

Vue 的 onCleanup 和 React 的 useEffect return 做的是同一件事,但 Vue 的写法有个优势:onCleanup 在 effect 函数体内调用,和创建 AbortController 的代码紧挨着,不容易遗漏。React 里 cleanup 写在函数末尾的 return 里,和请求代码隔得比较远,review 时容易看漏。

边界场景与防御性思维

并发请求的批量取消

页面初始化时可能要同时发五六个请求,用户切走了要一次性全取消。核心技巧是共享一个 signal,配合 Promise.allSettled 处理结果:

useEffect(() => {
  const controller = new AbortController()

  Promise.allSettled([
    fetch('/api/user/info', { signal: controller.signal }).then(r => r.json()),
    fetch('/api/user/permissions', { signal: controller.signal }).then(r => r.json()),
    fetch('/api/dashboard/stats', { signal: controller.signal }).then(r => r.json()),
  ]).then(results => {
    const [userResult, permResult, statsResult] = results

    if (userResult.status === 'fulfilled') {
      setUserInfo(userResult.value)
    }
    if (permResult.status === 'fulfilled') {
      setPermissions(permResult.value.permissions)
    }
    if (statsResult.status === 'fulfilled') {
      setDashboardStats(statsResult.value)
    }
  })

  return () => controller.abort()
}, [])

为什么用 Promise.allSettled 而不是 Promise.all?因为 Promise.all 在任何一个请求 reject 时就会整体 reject,而 abort 会导致所有请求同时 reject,你拿不到任何有用信息。allSettled 等所有请求都有结果后才 resolve,让你能精细地处理每个请求——哪些成功了用数据,哪些被取消了忽略,哪些真正失败了需要报错。

SSR 和 Node.js 环境

如果你的项目有 SSR(Next.js、Nuxt),请求取消在服务端同样重要。Node.js 18+ 的 fetch 原生支持 AbortController,但服务端的超时策略需要比客户端更激进——SSR 请求阻塞的是页面渲染,用户在白屏面前的耐心远低于面对 loading 动画:

async function getServerSideProps() {
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), 3000) // SSR 超时建议 3-5 秒

  try {
    const res = await fetch('http://internal-api/data', {
      signal: controller.signal
    })
    clearTimeout(timer)
    return { props: { data: await res.json() } }
  } catch {
    clearTimeout(timer)
    return { props: { data: null } } // 超时降级,先渲染页面骨架
  }
}

在 Node.js 环境还要注意一点:没有浏览器的 6 连接上限,但有内存泄漏风险。如果请求没有超时控制,在高并发时挂起的请求会持续占用内存,最终可能 OOM。

不适用的场景

AbortController 不是银弹,有几种场景不适合或者需要特殊处理。

WebSocket 连接有自己的生命周期管理(close()),不需要也不能用 AbortController。写操作的取消要谨慎——POST/PUT/DELETE 请求,前端取消了但后端可能已经处理了,关键写操作的幂等性要在后端保证。**流式响应(SSE / ReadableStream)**虽然技术上可以用 abort() 中断,但要区分场景:AI 对话场景下用户点"停止生成",abort() 是合理的;大文件分片上传中途取消,断点续传的状态恢复逻辑需要额外处理,单靠 abort() 解决不了。

从取消到编排:一个通用模型

回顾全文的内容,请求取消只是一个切入点,背后的通用模型是异步操作的生命周期管理。任何异步操作——请求、定时器、动画、Web Worker 通信——都应该具备三个能力:启动、取消、超时。缺了任何一个,在项目规模变大后都会出问题。这个模型可以这样理解:

异步操作生命周期:

  启动 ──→ 运行中 ──→ 成功 / 失败
    │          │
    │          ├── 手动取消(用户操作 / 依赖变化 / 组件卸载)
    │          │
    │          └── 超时取消(兜底机制)
    │
    └── 创建即取消(signal 已 aborted,立即中止)

在 React 中,这个生命周期对应的是 useEffect 的"执行-清理"周期;在 Vue 中,是 watchEffect 的"执行-onCleanup"周期。框架不同,模型一致。

如果你正在做的项目还没有统一的请求生命周期管理,我的建议是分三步推进。第一步,在请求层封装一个带超时和取消能力的基础函数(类似前面的 createManagedSignal)。第二步,在框架层封装对应的 Hook / Composable(类似 useAbortableFetchuseFetchData),让业务代码不需要直接接触 AbortController。第三步,在 code review 和 CI 中把"有没有处理取消"作为一个检查项。

我们团队落地这套方案之后,做了一次前后数据对比:

指标 改造前 改造后
搜索场景无效请求数(每次搜索) 5-6 个 0-1 个
超时相关客服工单(每周) 10+ 1-2
页面切换后的 setState warning 频繁出现 完全消除
请求层代码重复率 每个页面各写一套 统一收口到 2 个 Hook/Composable
CI 自定义 lint 规则上线首周拦截的遗漏 14 处

现在我们的标准是:每个 useEffect / watchEffect 里如果有 fetch 调用,必须在 cleanup 里调用 abort(),否则 CI 的自定义 lint 规则会报错。这条规则的 ROI 极高——写规则花了半天,上线一周就拦住了 14 处遗漏,每一处都是潜在的线上 bug。

回头看,请求生命周期管理这件事并不复杂。AbortController 的 API 就那么几个,封装成 Hook / Composable 也不超过 30 行代码。真正难的是在项目早期就意识到它的重要性,把它作为基础设施搭好,而不是等线上出了问题才到处救火。

React 拖拽:无需第三方库的完整方案

2026年3月25日 09:50

拖拽是用户期望"理所当然能用"的交互之一。无论是对任务看板重新排序、通过拖动文件上传,还是让用户在仪表盘中重新排列小组件,抓取并移动的操作都让人感觉自然流畅。然而大多数 React 教程一上来就引入像 react-dnddnd-kit 这样的重量级库——它们功能强大,但对许多常见场景来说增加了过多的包体积和概念负担。

如果只需一次 Hook 调用就能获得流畅、可用于生产的拖拽行为呢?本文将从原生浏览器 API 出发,分析它们为何难用,然后用 ReactUse 中的两个轻量 Hook:useDraggableuseDropZone 来解决同样的问题。

手动实现:自行处理指针事件

让元素可拖拽的最基本方式是手动监听 pointerdownpointermovepointerup 事件。通常的写法如下:

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

function ManualDraggable() {
  const ref = useRef<HTMLDivElement>(null);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [isDragging, setIsDragging] = useState(false);
  const delta = useRef({ x: 0, y: 0 });

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const onPointerDown = (e: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      delta.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
      setIsDragging(true);
    };

    const onPointerMove = (e: PointerEvent) => {
      if (!isDragging) return;
      setPosition({
        x: e.clientX - delta.current.x,
        y: e.clientY - delta.current.y,
      });
    };

    const onPointerUp = () => setIsDragging(false);

    el.addEventListener("pointerdown", onPointerDown);
    window.addEventListener("pointermove", onPointerMove);
    window.addEventListener("pointerup", onPointerUp);

    return () => {
      el.removeEventListener("pointerdown", onPointerDown);
      window.removeEventListener("pointermove", onPointerMove);
      window.removeEventListener("pointerup", onPointerUp);
    };
  }, [isDragging]);

  return (
    <div
      ref={ref}
      style={{
        position: "fixed",
        left: position.x,
        top: position.y,
        cursor: isDragging ? "grabbing" : "grab",
        padding: 16,
        background: "#4f46e5",
        color: "#fff",
        borderRadius: 8,
      }}
    >
      拖动我
    </div>
  );
}

能跑起来——但看看你需要管理多少状态。而这还只是最简单的版本。实际需求会迅速叠加更多复杂性。

为什么手动实现拖拽很难

上面的代码片段有几个不足之处,一旦超出 Demo 级别就会立刻暴露出来:

  1. 容器边界。 如果你想让元素保持在父容器内部,就需要在每次移动时读取容器尺寸并限制位置。这意味着每帧都要在两个元素上调用 getBoundingClientRect

  2. 指针类型。 上面的代码处理了鼠标事件,但触控和手写笔呢?PointerEvent API 统一了它们,但按指针类型过滤(例如禁止手写笔拖动)需要额外的条件判断。

  3. 拖拽手柄。 有时可拖拽的触发区域只是卡片内部的一个标题栏。你需要将"触发"元素和"移动"元素分离,并相应地连接事件。

  4. 事件清理。 忘记移除监听器——或者在 useEffect 中使用了错误的依赖——会导致诸如松开鼠标后元素仍在移动之类的隐蔽 Bug。

  5. 放置区域。 HTML5 拖放 API 引入了 dragenterdragoverdragleavedrop 事件。协调这些事件——尤其是子元素上臭名昭著的 dragenter/dragleave 闪烁问题——非常容易出错。

这些正是 useDraggableuseDropZone 开箱即用要解决的问题。

useDraggable:一个 Hook,完全掌控

useDraggable 接受一个目标元素的 ref 和一个可选的配置对象。它返回当前的 xy 位置、一个表示元素是否正在被拖拽的布尔值,以及一个 setter(用于程序化地移动元素)。

import { useDraggable } from "@reactuses/core";
import { useRef } from "react";

function DraggableCard() {
  const el = useRef<HTMLDivElement>(null);

  const [x, y, isDragging] = useDraggable(el, {
    initialValue: { x: 100, y: 100 },
  });

  return (
    <div
      ref={el}
      style={{
        position: "fixed",
        left: x,
        top: y,
        cursor: isDragging ? "grabbing" : "grab",
        padding: 16,
        background: isDragging ? "#4338ca" : "#4f46e5",
        color: "#fff",
        borderRadius: 8,
        transition: isDragging ? "none" : "box-shadow 0.2s",
        boxShadow: isDragging ? "0 8px 24px rgba(0,0,0,0.2)" : "none",
        userSelect: "none",
        touchAction: "none",
      }}
    >
      随意拖动我
    </div>
  );
}

这就是整个组件。无需手动事件监听器。无需清理逻辑。触控、鼠标和手写笔默认都能工作。

限制在容器内

传入一个 containerElement ref,Hook 会自动夹紧位置,使元素不会离开容器:

import { useDraggable } from "@reactuses/core";
import { useRef } from "react";

function BoundedDrag() {
  const container = useRef<HTMLDivElement>(null);
  const el = useRef<HTMLDivElement>(null);

  const [x, y, isDragging] = useDraggable(el, {
    containerElement: container,
    initialValue: { x: 0, y: 0 },
  });

  return (
    <div
      ref={container}
      style={{
        position: "relative",
        width: 400,
        height: 300,
        border: "2px dashed #cbd5e1",
        borderRadius: 8,
      }}
    >
      <div
        ref={el}
        style={{
          position: "absolute",
          left: x,
          top: y,
          width: 80,
          height: 80,
          background: "#4f46e5",
          borderRadius: 8,
          cursor: isDragging ? "grabbing" : "grab",
          touchAction: "none",
        }}
      />
    </div>
  );
}

无需手动的夹紧计算。Hook 会读取容器的滚动和客户端尺寸,自动限制元素位置。

使用拖拽手柄

通常你只想让元素的特定部分——比如一个标题栏——触发拖拽。传入 handle ref 即可:

import { useDraggable } from "@reactuses/core";
import { useRef } from "react";

function DraggablePanel() {
  const panel = useRef<HTMLDivElement>(null);
  const handle = useRef<HTMLDivElement>(null);

  const [x, y, isDragging] = useDraggable(panel, {
    handle,
    initialValue: { x: 200, y: 150 },
  });

  return (
    <div
      ref={panel}
      style={{
        position: "fixed",
        left: x,
        top: y,
        width: 280,
        background: "#fff",
        borderRadius: 8,
        boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
        overflow: "hidden",
        touchAction: "none",
      }}
    >
      <div
        ref={handle}
        style={{
          padding: "8px 12px",
          background: "#4f46e5",
          color: "#fff",
          cursor: isDragging ? "grabbing" : "grab",
          userSelect: "none",
        }}
      >
        从这里拖动
      </div>
      <div style={{ padding: 12 }}>
        <p>此内容区域不会触发拖拽。</p>
      </div>
    </div>
  );
}

面板的主体仍然是可交互的——你可以选择文本、点击按钮或滚动——而只有标题栏是拖拽触发器。

useDropZone:轻松实现文件拖放

useDropZone 解决拖放的另一半:接收放置。它处理全部四个拖拽事件(dragenterdragoverdragleavedrop),阻止浏览器默认打开文件的行为,并通过内部计数器解决了 dragleave 闪烁问题。

import { useDropZone } from "@reactuses/core";
import { useRef, useState } from "react";

function FileUploader() {
  const dropRef = useRef<HTMLDivElement>(null);
  const [files, setFiles] = useState<File[]>([]);

  const isOver = useDropZone(dropRef, (droppedFiles) => {
    if (droppedFiles) {
      setFiles((prev) => [...prev, ...droppedFiles]);
    }
  });

  return (
    <div
      ref={dropRef}
      style={{
        padding: 40,
        border: `2px dashed ${isOver ? "#4f46e5" : "#cbd5e1"}`,
        borderRadius: 8,
        background: isOver ? "#eef2ff" : "#f8fafc",
        textAlign: "center",
        transition: "all 0.15s",
      }}
    >
      {isOver ? (
        <p>松开以上传</p>
      ) : (
        <p>将文件拖到这里上传</p>
      )}
      {files.length > 0 && (
        <ul style={{ textAlign: "left", marginTop: 16 }}>
          {files.map((f, i) => (
            <li key={i}>
              {f.name} ({(f.size / 1024).toFixed(1)} KB)
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

isOver 布尔值让你在文件进入时立即重新设置区域样式,给用户清晰的视觉反馈。无需 e.preventDefault() 样板代码,不用和闪烁的 dragleave 事件斗争。

构建看板风格的卡片拖动

让我们在一个更贴近实际的例子中结合两个 Hook——一个可拖拽的卡片,松开时弹回原位,以及一个接受它的放置区域。我们还将使用 useElementBounding 来读取区域位置以做视觉反馈。

import { useDraggable, useDropZone, useElementBounding } from "@reactuses/core";
import { useRef, useState } from "react";

interface Task {
  id: string;
  title: string;
}

function KanbanBoard() {
  const [todo, setTodo] = useState<Task[]>([
    { id: "1", title: "设计原型" },
    { id: "2", title: "编写 API 规范" },
  ]);
  const [done, setDone] = useState<Task[]>([
    { id: "3", title: "搭建 CI 流水线" },
  ]);

  const doneZoneRef = useRef<HTMLDivElement>(null);
  const todoZoneRef = useRef<HTMLDivElement>(null);

  const isOverDone = useDropZone(doneZoneRef, (files) => {
    // 此示例忽略文件拖放
  });

  const isOverTodo = useDropZone(todoZoneRef, (files) => {
    // 此示例忽略文件拖放
  });

  const doneBounds = useElementBounding(doneZoneRef);

  return (
    <div style={{ display: "flex", gap: 24, padding: 24 }}>
      <div>
        <h3>待办</h3>
        <div
          ref={todoZoneRef}
          style={{
            minHeight: 200,
            padding: 12,
            background: isOverTodo ? "#fef3c7" : "#f1f5f9",
            borderRadius: 8,
          }}
        >
          {todo.map((task) => (
            <TaskCard
              key={task.id}
              task={task}
              onDrop={() => {
                setTodo((prev) => prev.filter((t) => t.id !== task.id));
                setDone((prev) => [...prev, task]);
              }}
              targetBounds={doneBounds}
            />
          ))}
        </div>
      </div>
      <div>
        <h3>完成</h3>
        <div
          ref={doneZoneRef}
          style={{
            minHeight: 200,
            padding: 12,
            background: isOverDone ? "#d1fae5" : "#f1f5f9",
            borderRadius: 8,
          }}
        >
          {done.map((task) => (
            <div
              key={task.id}
              style={{
                padding: 12,
                marginBottom: 8,
                background: "#fff",
                borderRadius: 6,
                boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
              }}
            >
              {task.title}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

function TaskCard({
  task,
  onDrop,
  targetBounds,
}: {
  task: Task;
  onDrop: () => void;
  targetBounds: ReturnType<typeof useElementBounding>;
}) {
  const el = useRef<HTMLDivElement>(null);

  const [x, y, isDragging, setPosition] = useDraggable(el, {
    initialValue: { x: 0, y: 0 },
    onEnd: (pos) => {
      // 检查卡片是否在"完成"列上方释放
      if (
        targetBounds &&
        pos.x >= targetBounds.left &&
        pos.x <= targetBounds.right &&
        pos.y >= targetBounds.top &&
        pos.y <= targetBounds.bottom
      ) {
        onDrop();
      }
      // 弹回原始位置
      setPosition({ x: 0, y: 0 });
    },
  });

  return (
    <div
      ref={el}
      style={{
        position: "relative",
        left: x,
        top: y,
        padding: 12,
        marginBottom: 8,
        background: isDragging ? "#e0e7ff" : "#fff",
        borderRadius: 6,
        boxShadow: isDragging
          ? "0 8px 24px rgba(0,0,0,0.15)"
          : "0 1px 3px rgba(0,0,0,0.1)",
        cursor: isDragging ? "grabbing" : "grab",
        zIndex: isDragging ? 50 : 1,
        touchAction: "none",
        userSelect: "none",
        transition: isDragging ? "none" : "all 0.2s ease",
      }}
    >
      {task.title}
    </div>
  );
}

几个值得注意的关键点:

  • useElementBounding 为我们提供了"完成"列的实时 leftrighttopbottom 值,以便在拖拽结束时进行碰撞检测。
  • onEnd 回调在未落在目标上时将卡片弹回 { x: 0, y: 0 }。配合 CSS transition 产生令人满意的橡皮筋效果。
  • 无需外部状态库。React 的 useState 对于这个复杂度完全够用。

配合其他 Hook 增强体验

ReactUse 的 Hook 天然可组合。以下是扩展上述示例的几种方式:

  • useMouse ——全局追踪光标位置,在拖拽过程中显示自定义拖拽光标或跟随指针的浮动提示。
  • useEventListener ——附加一个 keydown 监听器,在用户按下 Escape 时取消拖拽。
  • useElementSize ——动态读取容器的宽高以计算网格对齐位置(例如将 x 舍入到单元格宽度的最近倍数)。

例如,使用 useEventListener 添加 Escape 取消只需几行代码:

import { useDraggable, useEventListener } from "@reactuses/core";
import { useRef } from "react";

function CancelableDrag() {
  const el = useRef<HTMLDivElement>(null);
  const [x, y, isDragging, setPosition] = useDraggable(el);

  useEventListener("keydown", (e: KeyboardEvent) => {
    if (e.key === "Escape" && isDragging) {
      setPosition({ x: 0, y: 0 });
    }
  });

  return (
    <div
      ref={el}
      style={{
        position: "fixed",
        left: x,
        top: y,
        padding: 16,
        background: "#4f46e5",
        color: "#fff",
        borderRadius: 8,
        cursor: isDragging ? "grabbing" : "grab",
        touchAction: "none",
      }}
    >
      拖动我(按 Esc 重置)
    </div>
  );
}

什么时候仍然需要完整的库

useDraggableuseDropZone 用最少的代码覆盖了绝大多数拖放场景。然而,如果你的需求包含复杂的可排序列表(带动画过渡)、具有键盘无障碍访问的多容器排序,或包含上千项的虚拟化列表,像 dnd-kit 这样的专用库仍然是更好的选择。关键在于,你并不需要在每种情况下都引入一个——对许多项目来说,一对 Hook 就足够了。

安装

npm i @reactuses/core

相关 Hook


ReactUse 提供了 100+ 个 React Hook。探索所有 Hook →


本文最初发布于 ReactUse 博客

cursor接上figma mcp ,图形图像模式傻瓜式教学(包教包会版)

作者 工边页字
2026年3月25日 09:42

image.png

前言

包教包会!

其实这个技术出来有段时间了,但是感觉推广度不够,我看在很多程序员里还是在手工写css和样式。 如果你用的是figma的话,今天教给大家一个ai 几乎95%设计稿还原的方法。

为了照顾萌新同学,本文全程 用图形图像 来进行配置教程

开始!!!

第一步:创建figma token

image.png

image.png

image.png

然后点击 Generate,创建成功

image.png

再次强调,key一定要复制好,只有一次机会

第二步:配置cursor的figma mcp

  1. 按 Cmd + Shift + P,输入 MCP,选择「Open MCP setting」。 如下

image.png

image.png

点击新增以后你就会弹出一个 mcp.json文件

image.png 把这段配置复制进去,配置我写在下面,注意token替换下

{
    "mcpServers": {
        "Framelink Figma MCP": {
            "command": "npx",
            "args": [
                "-y",
                "figma-developer-mcp",
                "--figma-api-key=你的figma token",
                "--stdio"
            ]
        }
    }
}

第三步:尝试让cursor帮你做

先去你的figma项目里复制一个url过来,如下

image.png

然后回到cursor,如果你是免费用户(没有购买cursor的情况下),模型记得选 “auto”

image.png

然后直接把url复制进对话会进行对话就好

image.png

最后我们看看还原度

image.png

有条件的不要使用免费模型,auto模型有时候和弱智有的一拼

最后

如果对你有用的话

点赞收藏吃灰去呀~

CDN图片服务与动态参数优化

作者 wuhen_n
2026年3月25日 09:24

前言

在现代Web应用中,图片已经不再是简单的静态资源,而是需要根据设备、网络、浏览器能力动态优化的核心内容。CDN图片服务提供了强大的动态处理能力,结合前端的智能参数拼接,可以实现图片加载的极致优化。

一个典型的电商场景

  • 商品详情页有10张SKU图片
  • 每张图片需要支持不同尺寸(缩略图、详情图、放大镜图)
  • 需要兼容不支持WebP的老旧浏览器
  • 要求在秒级完成切换,不卡顿

本文将深入探讨如何利用 CDN 图片服务,配合前端策略,打造一个高性能、自适应、可扩展的图片系统。

CDN 图片服务是什么?

CDN 图片服务如何工作

CDN 服务:同一个图片地址,可以动态调整,加参数就能变:

https://cdn.example.com/product.jpg?width=400&quality=80&format=webp

上述地址会一个返回 400px宽、质量80、WebP格式的图片。

主流云服务商的参数格式

  • 阿里云OSS:?x-oss-process=image/resize,w_400/quality,q_80/format,webp
  • 七牛云:?imageView2/2/w/400/q/80/format/webp
  • 腾讯云COS:?imageMogr2/thumbnail/400x/quality/80/format/webp

核心处理操作

操作类型 参数 说明 示例
缩放 resize,w_400 按宽度等比缩放 /resize,w_400
裁剪 crop,w_400,h_400 从中心裁剪固定尺寸 /crop,w_400,h_400
格式转换 format,webp 转换为WebP/AVIF /format,webp
质量调整 quality,q_80 设置压缩质量(1-100) /quality,q_80
锐化 sharpen,s_100 图片锐化处理 /sharpen,s_100
水印 watermark,text_xxx 添加文字/图片水印 /watermark,text_SAMPLE

动态参数拼接 - 让每张图都量身定制

检测设备信息

// utils/device.js
export function getDeviceInfo() {
  // 设备像素比(Retina屏需要更高清的图)
  const dpr = window.devicePixelRatio || 1
  
  // 屏幕宽度
  const screenWidth = window.screen.width
  
  // 网络类型
  const connection = navigator.connection
  const networkType = connection?.effectiveType || '4g'
  const isSlowNetwork = ['slow-2g', '2g'].includes(networkType)
  
  // 是否移动设备
  const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent)
  
  return { dpr, screenWidth, networkType, isSlowNetwork, isMobile }
}

// 使用
const device = getDeviceInfo()
console.log(device)
// { dpr: 3, screenWidth: 390, isSlowNetwork: false, isMobile: true }

计算最佳图片尺寸

// utils/imageCalculator.js
export function calculateImageSize(targetWidth, deviceInfo) {
  const { dpr, isSlowNetwork, isMobile } = deviceInfo
  
  // 基础尺寸 = 目标宽度 × 像素比
  let width = Math.ceil(targetWidth * dpr)
  
  // 慢速网络下降级
  if (isSlowNetwork) {
    width = Math.floor(width * 0.7)
  }
  
  // 计算质量
  let quality = 80
  if (isSlowNetwork) {
    quality = 60
  } else if (isMobile) {
    quality = 75
  }
  
  // 确定格式
  const format = supportsWebP() ? 'webp' : 'jpg'
  
  return { width, quality, format }
}

检测 WebP 支持

// utils/webpDetect.js
let webpSupported = null

export function supportsWebP() {
  if (webpSupported !== null) return webpSupported
  
  // 创建一个1x1的WebP图片测试
  const canvas = document.createElement('canvas')
  canvas.width = 1
  canvas.height = 1
  const dataURL = canvas.toDataURL('image/webp')
  
  webpSupported = dataURL.indexOf('image/webp') === 5
  return webpSupported
}

CDN URL构建器

// utils/cdnUrl.js
export function buildCDNUrl(baseUrl, imageKey, options) {
  const { width, quality, format } = options
  
  // 阿里云OSS格式
  const params = [
    `resize,w_${width}`,
    `quality,q_${quality}`,
    format !== 'jpg' ? `format,${format}` : null
  ].filter(Boolean).join('/')
  
  return `${baseUrl}/${imageKey}?x-oss-process=image/${params}`
}

// 使用示例
const device = getDeviceInfo()
const size = calculateImageSize(400, device)
const url = buildCDNUrl('https://cdn.example.com', 'product.jpg', size)

// 结果:https://cdn.example.com/product.jpg?x-oss-process=image/resize,w_800/quality,q_80/format,webp

WebP兼容检测 - 让浏览器自己选

为什么需要检测?

不是所有浏览器都支持 WebP,比如 iOS Safari 14 之前不支持,因此直接使用 WebP 会显示不出来,我们需要让浏览器自己告诉服务器它支持什么格式。

服务端检测(推荐)

// Node.js 后端中间件
app.use((req, res, next) => {
  const accept = req.headers['accept'] || ''
  const supportsWebP = accept.includes('image/webp')
  const supportsAVIF = accept.includes('image/avif')
  
  // 把结果存起来,方便后面用
  res.locals.supportsWebP = supportsWebP
  res.locals.supportsAVIF = supportsAVIF
  
  next()
})

// 在返回HTML时注入
app.get('/', (req, res) => {
  res.render('index', {
    supportsWebP: res.locals.supportsWebP,
    supportsAVIF: res.locals.supportsAVIF
  })
})

前端检测(备选)

// 如果后端拿不到,前端也能检测
export function checkWebPSupport() {
  return new Promise((resolve) => {
    const img = new Image()
    img.onload = () => resolve(true)
    img.onerror = () => resolve(false)
    // 一个1x1的WebP图片的Base64编码
    img.src = 'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA=='
  })
}

动态选择格式

// composables/useImageFormat.js
import { ref } from 'vue'

export function useImageFormat() {
  const format = ref('jpg')
  
  async function detect() {
    // 优先检测AVIF(最新,压缩率最高)
    const avifSupported = await checkAVIFSupport()
    if (avifSupported) {
      format.value = 'avif'
      return
    }
    
    // 其次WebP
    const webpSupported = await checkWebPSupport()
    if (webpSupported) {
      format.value = 'webp'
      return
    }
    
    // 最后JPEG
    format.value = 'jpg'
  }
  
  detect()
  
  return { format }
}

域名分片 - 突破浏览器并发限制

为什么需要域名分片?

浏览器对同一域名的并发请求数有限制(通常为6-8个)。当页面需要同时加载大量图片时,这些请求会排队等待,导致加载缓慢。

问题示例

// 20张图片使用同一个域名
const urls = images.map(img => `https://cdn.example.com/${img}.jpg`)
// 浏览器最多同时下载6张,剩下14张等待

域名分片实现

// utils/cdnSharding.js
export class CDNSharding {
  constructor(baseDomain, shardCount = 4) {
    // 生成多个子域名
    // 0.cdn.example.com, 1.cdn.example.com, ...
    this.domains = []
    for (let i = 0; i < shardCount; i++) {
      this.domains.push(`https://${i}${baseDomain}`)
    }
    this.current = 0
  }
  
  // 轮询分配
  getUrl(imagePath) {
    const domain = this.domains[this.current % this.domains.length]
    this.current++
    return `${domain}${imagePath}`
  }
  
  // 基于图片ID的一致性分配(同一个图片始终用同一个域名,利于缓存)
  getUrlConsistent(imagePath, imageId) {
    const index = imageId % this.domains.length
    return `${this.domains[index]}${imagePath}`
  }
  
  // 基于路径哈希分配
  getUrlByHash(imagePath) {
    let hash = 0
    for (let i = 0; i < imagePath.length; i++) {
      hash = ((hash << 5) - hash) + imagePath.charCodeAt(i)
      hash = hash & hash
    }
    const index = Math.abs(hash) % this.domains.length
    return `${this.domains[index]}${imagePath}`
  }
}

// 使用
const sharding = new CDNSharding('.cdn.example.com', 4)

// 原来:一个域名
const oldUrls = images.map(img => `https://cdn.example.com/${img}`)

// 现在:4个域名
const newUrls = images.map(img => sharding.getUrlByHash(img))

DNS预解析优化

<!-- 在HTML头部添加DNS预解析 -->
<head>
  <link rel="dns-prefetch" href="//0.cdn.example.com">
  <link rel="dns-prefetch" href="//1.cdn.example.com">
  <link rel="dns-prefetch" href="//2.cdn.example.com">
  <link rel="dns-prefetch" href="//3.cdn.example.com">
  
  <!-- 预连接(包含TCP握手) -->
  <link rel="preconnect" href="//0.cdn.example.com">
  <link rel="preconnect" href="//1.cdn.example.com">
</head>

性能对比

图片数量 单域名 3个分片 4个分片
10张 2.8秒 1.5秒 1.2秒
20张 5.2秒 2.8秒 2.1秒
50张 12秒 6秒 4.5秒

图片上传组件 - 前端压缩再上传

为什么要前端压缩?

如果我们直接将原始图片(5MB)上传到服务器和 CDN,会非常慢!

但如果我们将图片在前端压缩后(500KB),再上传到服务器和 CDN,就会非常快了!

使用浏览器压缩库

安装

npm install browser-image-compression

使用

<!-- ImageUploader.vue -->
<template>
  <div class="uploader">
    <div class="dropzone" @drop="handleDrop" @dragover.prevent>
      <input type="file" @change="handleFileSelect" accept="image/*">
      <p>点击或拖拽图片上传</p>
    </div>
    
    <div v-if="compressing" class="progress">
      压缩中... {{ progress }}%
    </div>
    
    <div v-if="preview" class="preview">
      <img :src="preview" alt="preview">
      <button @click="upload">上传</button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import imageCompression from 'browser-image-compression'

const file = ref(null)
const preview = ref('')
const compressing = ref(false)
const progress = ref(0)

// 压缩配置
const options = {
  maxSizeMB: 1,           // 最大1MB
  maxWidthOrHeight: 1920, // 最大1920px
  useWebWorker: true,     // 使用Web Worker,不卡主线程
  fileType: 'image/webp', // 转成WebP
  initialQuality: 0.8     // 质量80%
}

async function handleFileSelect(event) {
  const rawFile = event.target.files[0]
  if (!rawFile) return
  
  compressing.value = true
  
  try {
    // 压缩图片
    const compressedFile = await imageCompression(rawFile, options)
    file.value = compressedFile
    
    // 预览
    preview.value = URL.createObjectURL(compressedFile)
    
    console.log(`压缩前: ${rawFile.size} bytes`)
    console.log(`压缩后: ${compressedFile.size} bytes`)
    console.log(`节省: ${(1 - compressedFile.size/rawFile.size)*100}%`)
    
  } catch (error) {
    console.error('压缩失败', error)
  } finally {
    compressing.value = false
  }
}

async function upload() {
  if (!file.value) return
  
  const formData = new FormData()
  formData.append('image', file.value)
  
  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData
  })
  
  const result = await response.json()
  console.log('上传成功:', result.url)
}
</script>

实战:电商SKU图片切换的秒级加载优化

问题分析

电商商品详情页的 SKU 图片切换是一个典型性能挑战:

  • 用户点击不同规格(颜色、尺寸)时,需要切换对应图片
  • 要求切换无延迟,体验流畅
  • 图片需要同时满足缩略图、主图、放大镜等多种尺寸需求

预加载策略

// composables/useSKUImages.js
import { ref } from 'vue'

export function useSKUImages() {
  const images = ref([])
  const currentIndex = ref(0)
  
  // 预加载队列
  const preloadQueue = []
  
  // 加载所有SKU图片
  async function loadSKUs(productId) {
    const response = await fetch(`/api/products/${productId}/skus`)
    const skus = await response.json()
    
    images.value = skus.map(sku => ({
      id: sku.id,
      thumbnail: buildCDNUrl(sku.key, { width: 200, quality: 70 }),
      main: buildCDNUrl(sku.key, { width: 800, quality: 80 }),
      zoom: buildCDNUrl(sku.key, { width: 1600, quality: 90 })
    }))
    
    // 预加载第一张图片
    preloadImages(0, 3)
  }
  
  // 预加载指定范围的图片
  function preloadImages(start, count) {
    for (let i = start; i < start + count && i < images.value.length; i++) {
      const img = images.value[i]
      
      // 用 link 标签预加载
      const link = document.createElement('link')
      link.rel = 'preload'
      link.as = 'image'
      link.href = img.main
      document.head.appendChild(link)
    }
  }
  
  // 切换SKU
  function switchSKU(index) {
    if (index === currentIndex.value) return
    
    currentIndex.value = index
    
    // 预加载后面几张
    if (index + 2 < images.value.length) {
      preloadImages(index + 1, 2)
    }
  }
  
  return {
    images,
    currentIndex,
    currentImage: computed(() => images.value[currentIndex.value]),
    loadSKUs,
    switchSKU
  }
}

完整的SKU图片组件

<template>
  <div class="sku-images">
    <!-- 缩略图列表 -->
    <div class="thumbnails">
      <div
        v-for="(img, idx) in images"
        :key="img.id"
        class="thumbnail"
        :class="{ active: currentIndex === idx }"
        @click="switchSKU(idx)"
      >
        <img :src="img.thumbnail" :alt="'SKU ' + idx">
      </div>
    </div>
    
    <!-- 主图区域 -->
    <div class="main-image">
      <img
        :src="currentImage?.main"
        :srcset="`
          ${currentImage?.thumbnail} 200w,
          ${currentImage?.main} 800w,
          ${currentImage?.zoom} 1600w
        `"
        sizes="(max-width: 768px) 100vw, 50vw"
        @mouseenter="showZoom = true"
        @mouseleave="showZoom = false"
        @mousemove="handleMouseMove"
      >
    </div>
    
    <!-- 放大镜 -->
    <div v-if="showZoom" class="zoom-lens" :style="lensStyle">
      <img :src="currentImage?.zoom" :style="zoomImageStyle">
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { useSKUImages } from './useSKUImages'

const props = defineProps({
  productId: String
})

const { images, currentIndex, currentImage, loadSKUs, switchSKU } = useSKUImages()
const showZoom = ref(false)
const mousePos = ref({ x: 0, y: 0 })

onMounted(() => {
  loadSKUs(props.productId)
})

const lensStyle = computed(() => ({
  left: `${mousePos.value.x}px`,
  top: `${mousePos.value.y}px`
}))

const zoomImageStyle = computed(() => ({
  transform: `translate(${-mousePos.value.x * 2}px, ${-mousePos.value.y * 2}px)`
}))

function handleMouseMove(e) {
  const rect = e.target.getBoundingClientRect()
  mousePos.value = {
    x: e.clientX - rect.left,
    y: e.clientY - rect.top
  }
}
</script>

最佳实践清单

实施步骤

  1. 接入CDN服务

    • 阿里云OSS / 七牛云 / 腾讯云COS
    • 配置图片处理参数
  2. 动态参数优化检测设备DPR、屏幕宽度、网络类型

    • 计算最佳图片尺寸
    • 动态生成CDN URL
  3. 格式兼容处理

    • 检测浏览器支持的格式
    • 优先AVIF → WebP → JPEG
    • 服务端通过Accept头判断
  4. 域名分片

    • 生成3-4个子域名
    • 轮询或哈希分配图片
    • 添加DNS预解析
  5. 上传优化

    • 前端压缩图片
    • 使用Web Worker不卡UI

优化策略矩阵

策略 适用场景 收益 实现成本
动态尺寸参数 所有图片 减少50-70%体积
WebP/AVIF转换 现代浏览器 额外减少30-50%
域名分片 批量图片加载 提升30-50%并发
客户端压缩 用户上传图片 减少90%上传时间
智能预加载 SKU/轮播图 切换无延迟

结语

CDN图片优化的核心是**"按需供给"**——不给任何设备加载它不需要的像素,不给任何网络传输它不需要的字节。通过动态参数、格式转换、智能预加载的组合,让图片资源真正做到"恰如其分"。

记住:用户不会因为图片加载快而赞美你,但一定会因为加载慢而离开你

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

响应式图片的工程化实践:srcset与picture

作者 wuhen_n
2026年3月25日 09:21

前言

在移动优先和多设备并存的今天,一张图片要在不同尺寸、不同分辨率的屏幕上都能完美展示,是一项极具挑战性的任务。一个简单的<img src="photo.jpg">会导致:

  • Retina屏上图片模糊:1x图在2x屏上被拉伸
  • 移动端加载超大图片:下载了PC端的大图,浪费流量
  • 横竖屏切换时构图不当:竖屏显示的图片被强行裁剪

响应式图片技术正是为解决这些问题而生。本文将深入探讨srcsetpicture的核心原理,并通过Vue组件封装和Vite插件实现,建立一套工程化的响应式图片解决方案。

为什么需要响应式图片?

传统方式:一张图片走天下

<img src="photo.jpg" alt="风景">

传统方式的问题

  • iPhone SE (小屏) → 下载 5MB 的大图 → 浪费
  • iPad (中屏) → 下载 5MB 的大图 → 还行
  • MacBook (大屏) → 下载 5MB 的大图 → 刚好
  • Retina 屏幕 → 下载 5MB 的普通图 → 模糊

设备像素比(DPR)

什么是设备像素比

**设备像素比(Device Pixel Ratio)**是物理像素与逻辑像素的比值:

// 获取当前设备的像素比
const dpr = window.devicePixelRatio || 1;
console.log(dpr); // 普通屏: 1, Retina屏: 2, 高端屏: 3或更高

设备像素比的典型值范围

  • 普通屏幕:DPR = 1
  • Retina屏幕:DPR = 2 / 3
  • 4K屏幕:DPR = 3+

为什么需要关注DPR?

当我们在CSS中设置width: 100px时,在 DPR=2 的屏幕上,实际需要 200 个物理像素来渲染。如果只提供 100px 的图片,就会被拉伸模糊。

三个核心问题

问题1:屏幕大小不同

  • 手机小屏:不需要大图
  • 平板中屏:需要中等图
  • 电脑大屏:需要高清图

问题2:像素密度不同

  • 普通屏:1x 图就够了
  • Retina 屏:需要 2x 图
  • 高端屏:需要 3x 图

问题3:屏幕方向不同

  • 横屏:适合宽幅风景
  • 竖屏:适合高耸人像

srcset - 让浏览器自己选

x描述符(根据像素密度)

告诉浏览器:我有 1x、2x、3x 三个版本:

<img 
  src="photo-1x.jpg"
  srcset="
    photo-1x.jpg 1x,
    photo-2x.jpg 2x,
    photo-3x.jpg 3x
  "
  alt="风景"
>

浏览器在解析时,就会自动选择:

  • iPhone 14 Pro (DPR=3) → 加载 photo-3x.jpg
  • iPhone SE (DPR=2) → 加载 photo-2x.jpg
  • 普通电脑 (DPR=1) → 加载 photo-1x.jpg

w描述符(根据屏幕宽度)

<img 
  src="photo-400w.jpg"
  srcset="
    photo-400w.jpg 400w,
    photo-800w.jpg 800w,
    photo-1200w.jpg 1200w
  "
  sizes="
    (max-width: 600px) 100vw,
    (max-width: 1200px) 50vw,
    800px
  "
  alt="风景"
>

sizes是怎么计算的?

sizes属性告诉浏览器在不同视口宽度下,图像的实际显示宽度,如:

sizes="
  (max-width: 600px) 100vw,   /* 小屏幕:图片占满视口宽度 */
  (max-width: 1200px) 50vw,   /* 中屏幕:图片占视口一半 */
  800px                        /* 大屏幕:图片固定800px */
"

其计算逻辑如下:

  1. 浏览器检查 sizes:sizes: "(max-width: 600px) 100vw, ..."
  2. 匹配条件 (max-width: 600px) 满足:图片宽度 = 100vw = 375px
  3. 考虑 DPR (iPhone SE DPR=2):实际需要 = 375px × 2 = 750px 的图片
  4. 从 srcset 中选择最接近的:400w 太小,1200w 太大 → 选择 800w

picture - 让开发者控制

什么时候需要 picture?

srcset 可以解决图片大小问题,但不能解决构图问题。比如:横屏时,我们需要展示完整的风景;竖屏时,我们需要展示裁剪后的人像,此时 picture 就派上用场了!

picture 的元素的结构

<picture>
  <!-- 针对宽屏的横图 -->
  <source 
    media="(min-width: 1200px)" 
    srcset="hero-wide.jpg"
  >
  <!-- 针对平板的方图 -->
  <source 
    media="(min-width: 768px)" 
    srcset="hero-square.jpg"
  >
  <!-- 针对手机的竖图 -->
  <source 
    media="(max-width: 767px)" 
    srcset="hero-tall.jpg"
  >
  <!-- 降级方案 -->
  <img src="hero-fallback.jpg" alt="Hero image">
</picture>

浏览器会按顺序检查 <source> 元素,选择第一个匹配的媒体条件。

不同格式降级

picture还可以根据浏览器支持的格式提供不同的降级方案:

<picture>
  <!-- 优先使用AVIF(压缩率最高) -->
  <source srcset="image.avif" type="image/avif">
  <!-- 其次使用WebP(广泛支持) -->
  <source srcset="image.webp" type="image/webp">
  <!-- 降级到JPEG(兜底) -->
  <img src="image.jpg" alt="Fallback">
</picture>

srcset vs picture 选择策略

场景 推荐方案 原因
不同分辨率(2x/3x屏) srcset + x描述符 简单直接,浏览器自动选择
不同视口宽度 srcset + w描述符 + sizes 精确控制加载尺寸
不同构图/裁剪 picture + media 艺术指导需求
不同格式降级 picture + type 渐进增强,兼容老旧浏览器

Vue 组件封装:<ResponsiveImage>的设计与实现

组件设计

<!-- ResponsiveImage.vue -->
<template>
  <picture v-if="usePicture">
    <!-- 为每种格式生成 source -->
    <source
      v-for="source in pictureSources"
      :key="source.type"
      :type="source.type"
      :srcset="source.srcset"
      :media="source.media"
    >
    <!-- 兜底图 -->
    <img :src="fallbackSrc" :alt="alt" loading="lazy">
  </picture>
  
  <img
    v-else
    :src="src"
    :srcset="srcsetString"
    :sizes="sizes"
    :alt="alt"
    loading="lazy"
  >
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  // 基础配置
  src: String,           // 原图地址
  alt: String,           // 替代文本
  
  // 响应式配置
  widths: {
    type: Array,
    default: () => [400, 800, 1200]
  },
  formats: {
    type: Array,
    default: () => ['webp', 'avif']
  },
  sizes: {
    type: String,
    default: '100vw'
  },
  
  // 艺术指导
  mobile: String,        // 手机版图片
  tablet: String,        // 平板版图片
  desktop: String        // 桌面版图片
})

// 判断是否使用 picture 模式
const usePicture = computed(() => {
  return props.mobile || props.tablet || props.desktop
})

// 生成 srcset 字符串
const generateSrcset = (basePath, widths, format) => {
  return widths
    .map(w => `${basePath}-${w}w.${format} ${w}w`)
    .join(', ')
}

// picture 模式的 sources
const pictureSources = computed(() => {
  const sources = []
  
  // 为每种格式生成 source
  props.formats.forEach(format => {
    // 桌面版
    if (props.desktop) {
      sources.push({
        media: '(min-width: 1200px)',
        srcset: generateSrcset(props.desktop, props.widths, format),
        type: `image/${format}`
      })
    }
    
    // 平板版
    if (props.tablet) {
      sources.push({
        media: '(min-width: 768px) and (max-width: 1199px)',
        srcset: generateSrcset(props.tablet, props.widths, format),
        type: `image/${format}`
      })
    }
    
    // 手机版
    if (props.mobile) {
      sources.push({
        media: '(max-width: 767px)',
        srcset: generateSrcset(props.mobile, props.widths, format),
        type: `image/${format}`
      })
    }
  })
  
  return sources
})

// 兜底图片
const fallbackSrc = computed(() => {
  return props.desktop || props.tablet || props.mobile || props.src
})

// 非 picture 模式的 srcset
const srcsetString = computed(() => {
  if (usePicture.value) return ''
  return generateSrcset(props.src, props.widths, 'jpg')
})
</script>

组件使用示例

<template>
  <!-- 方案1:普通响应式图片 -->
  <ResponsiveImage
    src="/images/photo.jpg"
    :widths="[400, 800, 1200]"
    sizes="(max-width: 600px) 100vw, 50vw"
    alt="风景"
  />
  
  <!-- 方案2:艺术指导(不同屏幕不同构图) -->
  <ResponsiveImage
    mobile="/images/hero-mobile.jpg"
    tablet="/images/hero-tablet.jpg"
    desktop="/images/hero-desktop.jpg"
    :widths="[400, 800, 1200]"
    alt="英雄图"
  />
</template>

自动生成多尺寸图片 - Vite 插件

为什么需要插件生成?

假如我们需要手动为每张图片生成:

  • photo-400w.jpg
  • photo-800w.jpg
  • photo-1200w.jpg
  • photo-400w.webp
  • photo-800w.webp
  • photo-1200w.webp
  • photo-400w.avif
  • photo-800w.avif
  • photo-1200w.avif

相当于一张图片就要配置 9 个文件;随着图片数量的增加,这将是一场噩梦!

插件原理与设计

  1. 识别项目中的图片导入
  2. 根据配置生成多种尺寸和格式
  3. 注入对应的 srcset 信息

Vite插件完整实现

/// vite-plugin-responsive-images.js
import sharp from 'sharp'
import { glob } from 'fast-glob'

export default function responsiveImagesPlugin(options) {
  const {
    widths = [400, 800, 1200],
    formats = ['webp', 'avif'],
    quality = 80
  } = options
  
  return {
    name: 'vite-plugin-responsive-images',
    
    async buildStart() {
      // 找到所有图片
      const files = await glob('src/assets/images/**/*.{jpg,jpeg,png}')
      
      console.log(`📸 找到 ${files.length} 张图片`)
      
      for (const file of files) {
        // 为每个尺寸和格式生成图片
        for (const width of widths) {
          for (const format of formats) {
            const outputPath = file
              .replace('src/assets', 'dist/assets')
              .replace(/\.(jpg|jpeg|png)$/, `-${width}w.${format}`)
            
            await sharp(file)
              .resize(width, null, { withoutEnlargement: true })
              .toFormat(format, { quality })
              .toFile(outputPath)
          }
        }
      }
      
      console.log('✅ 图片生成完成')
    }
  }
}

配置插件

// vite.config.js
import responsiveImages from './vite-plugin-responsive-images'

export default {
  plugins: [
    responsiveImages({
      widths: [400, 800, 1200, 1600],
      formats: ['webp', 'avif'],
      quality: 75
    })
  ]
}

性能对比:不同方案下的图片加载体积

测试数据对比

基于典型电商商品详情页的测试结果:

图片类型 原始大小 WebP AVIF 节省空间
商品主图 (1200×1200) 850KB 320KB 210KB 62%-75%
商品缩略图 (400×400) 120KB 45KB 28KB 62%-77%
轮播大图 (1920×1080) 1.2MB 480KB 320KB 60%-73%

响应式方案加载体积对比

设备 传统单图 仅WebP 响应式srcset 响应式+WebP+AVIF
iPhone SE (375pt) 下载1200w图 (850KB) 下载1200w图 (320KB) 下载400w图 (120KB) 下载400w WebP (45KB)
iPad (768pt) 下载1200w图 (850KB) 下载1200w图 (320KB) 下载800w图 (280KB) 下载800w WebP (98KB)
MacBook Pro 下载1200w图 (850KB) 下载1200w图 (320KB) 下载1200w图 (850KB) 下载1200w WebP (320KB)
平均节省 基准 62% 51% 80%

加载性能指标提升

指标 优化前 优化后 提升
LCP (最大内容绘制) 3.2s 1.4s 56%
图片请求数 12 8 33%
总图片体积 4.2MB 1.1MB 74%
移动端数据消耗 4.2MB/次访问 0.6MB/次访问 86%

最佳实践清单

配置建议

图片尺寸断点:
├─ 400w:手机小屏
├─ 800w:手机大屏/平板
├─ 1200w:笔记本电脑
├─ 1600w:台式机
└─ 2000w:4K 屏幕

图片格式优先级:
├─ AVIF(最新,压缩率最高)
├─ WebP(广泛支持)
└─ JPEG/PNG(兜底)

sizes 设置:
├─ 手机:(max-width: 600px) 100vw
├─ 平板:(max-width: 1200px) 50vw
└─ 电脑:800px

实施策略选择矩阵

场景 技术方案 关键配置
普通内容图片 srcset + sizes 提供3-5种宽度,设置合理sizes
图标/Logo srcset + x描述符 提供1x/2x/3x版本
不同构图需求 picture + media 针对断点设计不同裁剪
现代格式降级 picture + type AVIF → WebP → JPEG
用户上传内容 动态生成 + CDN处理 根据设备实时转换

实施清单

  • 所有图片提供 3-5 种尺寸
  • 生成 WebP 和 AVIF 格式
  • 使用 <picture> 实现格式降级
  • 设置正确的 sizes 属性
  • 关键图片设置 loading="eager"
  • 非关键图片设置 loading="lazy"
  • 使用 Vite 插件自动生成多尺寸

结业

用户可能不会注意到图片加载很快,但一定会注意到图片加载很慢。响应式图片优化,是对用户体验最深情的告白。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

为什么要有 Neovate Code?

作者 LeonGao
2026年3月25日 09:10

0、先破后立:别把它当“又一个写代码的 AI”,那样你会完全用错。**

很多团队引入工具的起点是:写得更快、补全更强、能多写点功能。但现实是,真正拖慢交付的通常不是“敲代码速度”,而是对齐成本、返工成本、代码漂移、质量不可控。Neovate Code 的存在价值更偏工程:把“写出来”变成“交付得出去”,把“能用一次”变成“能持续用”。


1、交付:需要 Neovate Code,因为团队缺的不是产出文字,而是产出可合并的变更。

中心论点:它解决的是交付链路断层,让改动从一开始就长得像工程提交。
很多通用模型输出像草稿:缺文件边界、缺变更范围说明、缺回归点、缺运行步骤;你要把它再加工成一个能进仓库的提交。Neovate Code 更应该做的是:

  • 用更像“补丁”的方式给结果:改哪些文件、为什么改、怎么回滚。
  • 默认带上自检:最小可行测试、边界用例、常见失败点。
  • 把交付口径写清:输入输出、依赖、兼容性、风险提示。

2、可控:需要 Neovate Code,因为工程怕的不是“不会写”,是“写了你不敢合”。

中心论点:它的意义是把改动变得可控、可审、可收敛。
团队真实痛点往往是:AI 改的东西太散、太大、风格乱、还喜欢顺手重构;你看不清它改了什么,就不敢点合并。Neovate Code 应该把控制权交回给人:

  • 改动范围可锁定:只动指定模块,不碰接口与目录。
  • 输出结构稳定:固定 diff/提交说明/验证步骤的格式。
  • 不确定就停:遇到缺信息时给出“需要补的材料清单”,而不是硬猜。

3、复现:需要 Neovate Code,因为一次性成功不值钱,可重复成功才值钱。

中心论点:它把“这次能跑”升级为“下次还一样”。
团队协作的本质是复现:同事能复现、CI 能复现、线上能复现。很多工具做的是即时灵感,但工程要的是可重复过程。Neovate Code 的价值在于:

  • 把假设写出来:环境、版本、配置、数据前置条件。
  • 把验收写成步骤:怎么测、测哪些边界、出错怎么看。
  • 把决策可追溯:为什么这么改,替代方案是什么,风险点在哪。

4、成本:需要 Neovate Code,因为真正贵的是隐性成本:返工、沟通、事故,而不是模型调用费。

中心论点:它的定位是降低“总成本”,不是降低“生成成本”。
如果一个工具让你多写了 30% 代码,但让 Review 更难、回归更痛、线上更不稳,那就是反向省钱。Neovate Code 应该把钱省在刀刃上:

  • 减少返工:第一次就按团队规范交付。
  • 减少对齐:把需求拆成可执行任务,减少来回解释。
  • 减少事故概率:默认补齐校验、错误处理、回滚思路。

5、安全:需要 Neovate Code,因为企业用代码工具,安全是门槛,不是加分题。

中心论点:它必须把风险挡在生成阶段,而不是上线之后。
通用模型常见问题是:功能写得快,但安全意识薄;鉴权、校验、注入、防泄露这些点不稳定。Neovate Code 的必要性在于,它可以把安全变成默认动作:

  • 生成时就考虑最小权限与输入校验。
  • 输出时就提示敏感信息处理与日志脱敏。
  • 给出风险清单:哪些地方要安全评审、哪些地方要加审计。

6、工程化适配:需要 Neovate Code,因为团队要的不是“聪明”,是“能接进流水线”。

中心论点:它应该天然适配仓库、规范、CI、代码所有权,而不是单人玩具。
真实开发不是“写完就完”,而是要接入团队流程:分支策略、提交规范、测试门禁、代码风格、依赖治理。Neovate Code 的存在感来自它能对齐这些东西:

  • 按既有项目结构产出,不自创目录。
  • 默认尊重 lint/test/CI,输出可直接跑门禁。
  • 支持“最小改动原则”:能小改就不大动,能补丁就不重写。

快速自测清单:它是不是“有必要”,跑一轮就知道

  1. 补丁交付:给一个真实 bug,让它“先写失败用例,再修,再补回归”。
  2. 范围锁定:要求只改 1 个文件/1 个函数,检查是否越界。
  3. 规范遵守:指定 lint、提交信息格式、错误码规范,看是否照做。
  4. 复现步骤:要求输出运行命令、环境假设、验收流程,看是否完整。
  5. 安全底线:让它处理上传/SQL/鉴权任务,检查是否默认做校验与权限控制。
  6. 成本对比:统计从输出到合并的时间、返工次数、Review 评论条数。
  7. 稳定性:同一任务跑 5 次,看结构与结论是否收敛。
  8. 团队可读性:把输出交给同事 Review,看是否能快速看懂改动意图与风险点。

结语:为什么要有 Neovate Code?因为团队真正缺的是“工程可控的交付”,不是“会说的代码”。
当一个工具能让提交更小、意图更清、验证更快、风险更低,它就不是“锦上添花”,而是在把研发从反复返工里拉出来。Neovate Code 的价值,应该被衡量在:你敢不敢合、合完稳不稳、下次能不能复现。

❌
❌