阅读视图

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

React 19 正式发布:这一次,表单和服务器组件终于"原生"了

React 19 正式发布:这一次,表单和服务器组件终于"原生"了

2024 年 12 月,React 19 正式登陆 npm。这是一次真正意义上的全栈升级——不仅有给 UI 开发者的新 Hook 和表单能力,还有面向框架作者的 React Server Components 稳定支持。

本文不堆砌升级指南,只聚焦一个核心问题:React 19 到底带来了什么值得你花时间学的新特性?


一、Actions:表单提交终于不用自己写 pending 了

React 19 最核心的改动是引入了 Actions 这个概念。

它的背景很真实:用户提交一个表单 → 发 API 请求 → 等待响应 → 处理错误或跳转。过去这一切都需要开发者手动写 isPendingsetErrorsetIsPending 三件套。

// React 18:手动管理 pending 和错误
function UpdateName() {
  const [name, setName] = useState("");
  const [error, setError] = useState(null);
  const [isPending, setIsPending] = useState(false);

  const handleSubmit = async () => {
    setIsPending(true);  // 手动设置 pending
    const error = await updateName(name);
    setIsPending(false); // 手动重置
    if (error) { setError(error); return; }
    redirect("/path");
  };

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>Update</button>
      {error && <p>{error}</p>}
    </div>
  );
}

Actions 的做法:把异步逻辑包进 startTransition,React 自动帮你处理 pending 状态。

// React 19:Actions 自动处理 pending + 错误
function UpdateName() {
  const [name, setName] = useState("");
  const [error, setError] = useState(null);
  const [isPending, startTransition] = useTransition();

  const handleSubmit = () => {
    startTransition(async () => {
      const error = await updateName(name);
      if (error) { setError(error); return; }
      redirect("/path");
    });
  };

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>Update</button>
      {error && <p>{error}</p>}
    </div>
  );
}

isPending 会自动跟随 transition 的状态变化,不需要手动 setIsPending


二、useActionState:连错误处理都包装好了

useActionState 是 Actions 的进一步封装,把最常见的"提交 → 等待 → 结果"模式再简化一层:

const [error, submitAction, isPending] = useActionState(
  async (previousState, formData) => {
    const error = await updateName(formData.get("name"));
    if (error) return error;  // 返回 error
    redirect("/path");
    return null;              // 成功返回 null
  },
  null,  // 初始状态
);

用法非常直觉:submitAction 就是你要调用的函数,error 是上次调用的结果,isPending 是自动管理的加载状态。


三、useOptimistic:乐观更新终于有官方方案了

"乐观更新"指的是:用户操作后立即显示预期结果,不用等服务器返回。比如点赞、发帖、修改用户名,都适合用这个模式。

function ChangeName({ currentName, onUpdateName }) {
  // 乐观 name:立即渲染,请求完成前显示用户预期的值
  const [optimisticName, setOptimisticName] = useOptimistic(currentName);

  const submitAction = async (formData) => {
    const newName = formData.get("name");
    setOptimisticName(newName);  // 立即更新 UI
    const updatedName = await updateName(newName);
    onUpdateName(updatedName);   // 服务器返回后自动切回真实值
  };

  return (
    <form action={submitAction}>
      <p>你的名字是:{optimisticName}</p>
      <input type="text" name="name" disabled={currentName !== optimisticName} />
    </form>
  );
}

如果请求失败超时,React 会自动回滚到 currentName,不需要手动处理。


四、<form> Actions:表单提交进入"声明式"时代

React 19 的 react-dom 新增了对 <form> 的原生支持,可以直接传一个函数给 action

function App() {
  const submitAction = async (formData) => {
    "use server";  // 这个函数会在服务器端执行
    await saveName(formData.get("name"));
  };

  return (
    <form action={submitAction}>
      <input type="text" name="name" />
      <button type="submit">提交</button>
    </form>
  );
}

提交成功后,React 会自动重置表单。如果需要手动重置,调用 requestFormReset() API 即可。


五、use:比 useContext 更灵活的资源读取方式

React 19 引入了全新 API use,它可以有条件地读取 Promise 和 Context:

读取 Promise(配合 Suspense)

import { use, Suspense } from 'react';

function Comments({ commentsPromise }) {
  const comments = use(commentsPromise);  // 挂起直到 resolved
  return comments.map(c => <p key={c.id}>{c.text}</p>);
}

function Page({ commentsPromise }) {
  return (
    <Suspense fallback={<div>加载中...</div>}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  );
}

读取 Context(支持 early return)

useContext 的痛点是不能在条件语句后调用use 解决了这个问题:

import { use } from 'react';
import ThemeContext from './ThemeContext';

function Heading({ children }) {
  if (children == null) return null;  // early return 了?
  const theme = use(ThemeContext);     // 照样能用 use
  return <h1 style={{ color: theme.color }}>{children}</h1>;
}

注意:use 不支持在 render 内部直接创建 Promise(会导致哨兵错误),需要从 Suspense 友好的框架或库获取已缓存的 Promise。


六、React Server Components:稳定支持,生产可用

React 19 包含了完整的 React Server Components(RSC) 能力,意味着:

  • 组件可以在服务器端运行一次(CI 构建时或每次请求时)
  • 服务器组件有零 bundle 体积(不会打到客户端 JS 里)
  • 可以直接在组件里 await 数据获取
// Server Component(服务器组件,默认支持)
async function ArticleList() {
  const articles = await db.query('SELECT * FROM articles');
  return (
    <ul>
      {articles.map(a => <li key={a.id}>{a.title}</li>)}
    </ul>
  );
}

配合 Server Actions(用 "use server" 标记的异步函数),可以做到从 Client Component 调用服务器端逻辑:

// 客户端调用服务器端函数,全程类型安全
async function updateName(name: string) {
  "use server";
  await db.update({ name });
}

七、其他值得注意的改进

改进 说明
ref 作为 prop 新版函数组件可以直接接收 ref 作为 prop,不再需要 forwardRef,未来会废弃 forwardRef
<Context> 作为 provider <Context.Provider> 可以直接写成 <Context value={...}>,旧的写法未来废弃
ref 回调支持清理函数 ref 回调可以返回清理函数,元素从 DOM 移除时自动调用
Hydration 错误更详细 报错信息直接显示服务器和客户端的 diff,不用再猜哪行出了问题
新的静态生成 API prerender / prerenderToNodeStream 改进静态站点生成,SSR 性能更好

八、React 19.2(2025年10月)更新速览

React 19.2 在 19 基础上新增了两个实验性功能:

  • Activity:类似 React 的"活动状态"追踪,适合实时协作类应用
  • React Performance Tracks:新的性能监控 API,帮助开发者精确分析组件渲染性能

这两个功能目前仍处于实验阶段,正式稳定支持尚需时间。


总结:React 19 解决了什么问题

React 19 的核心改进可以归结为两条线:

客户端:让表单、乐观更新、pending 状态这些常见模式开箱即用,不再需要 Copy/Paste 一堆样板代码。

全栈:Server Components + Server Actions 让前后端边界更清晰,同一个函数可以运行在服务器,返回结果给客户端,全程类型安全。

如果你的团队正在用 Next.js 14+ 或其他支持 RSC 的框架,React 19 的价值会非常明显。如果是纯客户端 React App,Actions + useOptimistic 也足以让表单开发体验提升一个档次。


参考来源

  • React 官方博客:react.dev/blog(2024/12/05)
  • React 19.2 Release Notes(2025/10/01)
  • React Conf 2025 Recap(2025/10/16)

【monorepo架构】前端 pnpm workspace详解

公众号:AI小揭秘

前端 pnpm workspace 架构详解

一篇帮你搞懂 pnpm workspace 的实战向教程,从「为啥要用」到「怎么配」全给你捋清楚;每个知识点都会讲清是什么、为什么、怎么用、注意啥,方便你系统学习、随时查阅、直接落地。


一、先聊聊:我们到底遇到了啥问题?

做前端久了,多包、monorepo、组件库联调这些事一多,就会踩到一堆具体又磨人的坑。下面把这些痛点拆开说:具体表现 → 典型场景 → 对你有啥影响。搞清楚这些,后面再看 pnpm workspace 解决啥就一目了然。

1.1 node_modules 膨胀,磁盘和时间都遭殃

具体表现:用 npm 搞 monorepo 时,根目录一个 node_modules,每个子包再来一个;或者多个独立项目各自一份。每个 node_modules 里,npm 会做扁平化:把子依赖提升到顶层,同一份包可能在不同项目的 node_modules 里各存一份,重复拷贝

典型场景:比如你有一个 monorepo,里面 5 个 app、3 个共享库,都用 React、lodash、一堆 Babel/Webpack 相关包。单项目 node_modules 可能就 400~600MB,monorepo 里再乘上包数量、加上提升带来的重复,轻松破 2GB。npm install 第一次全量装要几分钟,以后每次 npm ci 或清缓存重装,体感也很慢。

影响:占磁盘、拉代码慢、CI 缓存大、流水线耗时增加;本机多开几个项目,node_modules 动不动几十 GB。

1.2 依赖版本乱成一锅粥:幽灵依赖与冲突

幽灵依赖的定义:某个包没有在你自己的 package.jsondependencies / devDependencies 里声明,你却能在代码里 importrequire 到它。常见原因就是 npm 的扁平化:你装了 A,A 依赖 B,B 被提升到了项目根 node_modules,于是你的代码「意外」地能直接用 B。

典型场景:你习惯性 import _ from 'lodash',但从没在 package.json 里加过 lodash,因为它是某个依赖的子依赖,被提升上来了。后来你升级了那个依赖,人家不再依赖 lodash,或者换了版本,你这边没改一行业务代码就报错:找不到 lodash。更坑的是「本地能跑、CI 挂」:本地可能还有别的路径残留或缓存,CI 干净安装就炸。同理,删了某个你以为没用的依赖,结果别的地方一直隐式用着,一删就挂。

版本冲突:A 包要 React 18,B 包要 React 17,扁平化之后只能满足一边,另一边可能用了「不对」的版本,运行时才暴露问题,调试成本很高。

1.3 本地包联调贼麻烦:npm link 的坑

典型场景:你维护一个业务组件库,要在另一个前端项目里联调。通常做法是 npm link:在组件库目录 npm link,在业务项目里 npm link your-components。但经常会遇到:

  • 双实例问题:React、Vue 等对「单实例」有要求,link 过去可能出现两个版本,引发诡异 bug。
  • bin 路径:某些 CLI 或工具通过 node_modules/.bin 找可执行文件,link 后路径解析不对,跑不起来。
  • 不同 Node 版本 / 环境: link 的是「当时本机」的构建结果,换机器、换 Node、改点配置,行为可能不一致。

总之,改一下组件库就要反复 link、unlink、重装,体验很差,也容易忘步骤导致联调结果不可靠。

1.4 CI 又慢又占空间

典型场景:每次 CI 全量 npm install,没有跨项目或跨 job 的 store 复用;缓存 key 设计不当(例如只按 package.json 不按 lockfile),导致缓存命中率低,每次都几乎全量装。加上前面说的 node_modules 巨大,流水线耗时长、占用空间大,体验和成本都不好。


上面这些,本质都可以归为两类问题:一是多包怎么组织、怎么一起开发、怎么发布(项目结构 + 工作流);二是依赖怎么存、怎么解析、怎么隔离(存储与解析策略)。pnpm 的 workspace 就是在这两方面同时发力的方案之一:多包管理 + 更合理的依赖存储与解析。下面先把你可能最关心的——pnpm 底层是怎么干的——讲清楚,再回头看 workspace 具体解决了啥。


二、pnpm 底层原理:为啥能省空间、装得快、依赖还干净?

很多人只记住结论:「pnpm 省磁盘、快、没幽灵依赖」,但不知道它到底咋做到的。这一节把存储模型node_modules 结构说透,你后面看配置、看优缺点都会更有数。

2.1 全局 store:content-addressable + 硬链接

pnpm 有一个全局 store,所有安装过的包都会先放进这里,再通过硬链接挂到各个项目的 node_modules 里。

  • 存哪儿

    • Linux:默认 ~/.local/share/pnpm/store
    • macOS:默认 ~/Library/pnpm/store
    • Windows:默认 %LOCALAPPDATA%\pnpm\store(即 C:\Users\<你>\AppData\Local\pnpm\store
      若设置了 $XDG_DATA_HOME,Linux/macOS 会改用 $XDG_DATA_HOME/pnpm/store。可通过 .npmrcstore-dir 覆盖,例如 store-dir=D:\pnpm-store
  • content-addressable(按内容寻址)
    包在 store 里按内容哈希存,同一版本、同一份包只存一份。不同项目、不同 monorepo 子包,只要依赖的版本相同,都用这一份,去重、跨项目复用

  • 硬链接
    硬链接可以理解为「同一份文件的多个路径入口」,改一处全体生效,但不额外占磁盘。pnpm 从 store 把包硬链接到项目里的 node_modules/.pnpm/...,所以看起来每个项目都有一份,实际磁盘只存 store 里那一份。
    复制的区别:不占多余空间。和符号链接的区别:符号链接是「指向另一个路径」的小文件,硬链接是文件系统层面的多路径同一 inode,更省空间、也更稳定(删掉一个链接不会影响 store 里的那份,只要还有别的链接在)。

结果:同 monorepo、同样依赖,用 pnpm 时磁盘占用往往只有 npm 的一半左右(常见 benchmark 结论),二次安装时大量命中 store,pnpm install 明显更快。

2.2 node_modules 的真实结构:非扁平 + 严格依赖

npm 会把依赖扁平化提升到顶层,所以你能「意外」用到子依赖;pnpm 不这么做,结构是非扁平的。

目录结构示意(精简版):

  • 项目根目录的 node_modules/

    • 只放你直接声明的依赖(dependencies / devDependencies 里的包)。
    • 这些「包名」多数是符号链接,指向 node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>
  • node_modules/.pnpm/

    • 里面才是实际内容(或链到 store)。
    • 每个 package@version 一个目录,且每个包有自己的 node_modules,里面只装它自己的依赖
    • 子依赖不会提升到项目根 node_modules,所以你没法在业务代码里 require('某个未声明的子依赖')

严格依赖就是这样实现的:
只有package.json 里显式声明的包,才会出现在你项目的 node_modules 顶层(或子包自己的 node_modules 里)。未声明的包根本不在你可访问的路径下,require / import 会直接报错,从根上杜绝幽灵依赖

有些老旧工具会假设「所有依赖都在根 node_modules 扁平展开」,在 pnpm 默认结构下会找不到包。这时可以用 public-hoist-patternnode-linker=hoisted有限提升,相当于在「兼容旧工具」和「严格依赖」之间做权衡;提升多了,幽灵依赖风险又回来了,所以能窄就窄。

2.3 workspace 包怎么被链接进来?

当你在 package.json 里写 "@my/ui": "workspace:*" 时,pnpm 会:

  1. pnpm-workspace.yaml 定义的目录里找到对应包(如 packages/ui);
  2. 该包所在目录(源码目录)链接到 node_modules 里对应位置,不拷贝、不先打包

所以,你改 packages/ui 的源码,消费方(例如 apps/web立即可见,不用 npm link,也没有双实例、路径错乱那些破事。这就是 workspace 协议 带来的「本地包即源码」的联调体验。

2.4 和 npm / Yarn 的存储对比(简要)

  • npm:扁平化 + 每项目各自拷贝,多项目多份;易幽灵依赖;安装速度、磁盘占用都一般。
  • Yarn:经典模式类似 npm;Plug'n'Play 可选,但生态兼容性要看工具。
  • pnpm:全局 store + 硬链接 + 非扁平 node_modules,省空间、安装快、默认严格依赖。

差异主要在存储与解析策略,而不是「有没有 workspace」这个概念。


三、pnpm workspace 解决了什么问题?(深化版)

有了第二节的原理打底,这里直接说 workspace 在「多包管理」场景下,具体帮你解决了啥;每个点都往「能用、能查」上靠。

3.1 磁盘与安装

  • store + 硬链接:全 workspace 共享同一 store,同版本依赖只存一份;子包、apps 装依赖都是链过去,磁盘占用明显低于 npm 同规模 monorepo(约一半量级的说法很常见)。
  • workspace 包不占 store:像 @my/utils@my/ui 这种本地包,pnpm 只做链接到源码目录,不往 store 里塞,也不拷贝,改完即生效。
  • 安装速度pnpm install 在 monorepo 里通常比 npm install 快不少,尤其二次安装、CI 命中 store 时。

3.2 依赖隔离与一致性

  • 幽灵依赖
    pnpm 默认严格依赖,未声明就不能用。你刻意避免隐式依赖,配合 code review,能从根本上消灭「删了某依赖突然挂」「本地有 CI 没有」这类问题。
    若必须兼容旧工具,再考虑 public-hoist-pattern 有限提升,并清楚这会带来隐性依赖风险。

  • 版本统一

    • 单一 lockfile:整个 workspace 只有一个 pnpm-lock.yaml 在根目录,所有子包、所有环境的依赖解析都以它为准,版本全仓库一致,复现性高。
    • catalog(pnpm 9+):在 pnpm-workspace.yaml 里定义 catalog,给常用依赖约定版本(如 react: ^18.3.1),子包用 catalog: 引用,升级时只改一处,避免各包各自为政。
    • overrides:根 package.json 里可配 pnpm.overrides,强制某依赖在全 workspace 解析成指定版本,适合解决传递依赖冲突、安全修复等。

3.3 多包协作与发布

  • 统一装依赖、统一跑脚本:根目录一次 pnpm install,所有 workspace 包依赖都装好;用 pnpm -r run buildpnpm --filter ... 批量或定向跑脚本,配合根 package.jsonscripts,协作流程清晰。
  • 按需发布pnpm publish -r 可递归发布,结合 --filter 只发布改动的包;配合 changesets 做 version + changelog + publish,适合多包独立发版。
  • 权限与发包:可以按包名、按目录做 access 控制,和现有 npm registry 权限模型配合使用。

四、pnpm workspace 架构长什么样?

4.1 目录树与职责

下面是一个常见的 pnpm workspace 根目录结构,以及各部分的职责。

项目根目录
├── pnpm-workspace.yaml    # 声明哪些目录是 workspace 包(唯一、仅根目录)
├── package.json           # 根包:公共 devDependencies、批量脚本、overrides 等
├── pnpm-lock.yaml         # 全 workspace 唯一 lockfile,所有人、CI 共用一个
├── .npmrc                 # 可选:store-dir、node-linker、hoist 等
├── packages/
│   ├── ui/                # 如:组件库
│   ├── utils/             # 公共工具
│   ├── config-eslint/     # 共享 ESLint 配置
│   └── ...
└── apps/
    ├── web/               # 前端应用
    ├── docs/              # 文档站
    └── ...
  • package.json

    • 全仓库共用的 devDependencies(如 TypeScript、ESLint、Vitest、Prettier)。
    • 定义 scripts,用 pnpm -r--filter 批量或定向执行子包的 build、dev、test。
    • 根包通常 "private": true,不发布;可加 packageManagerpnpm.overrides 等。
  • pnpm-workspace.yaml

    • 唯一,只能放在根目录。
    • 通过 packages 数组声明哪些目录算 workspace 包(如 packages/*apps/*),只有这些才能被 workspace:* 引用。
    • pnpm 官方推荐用这个文件,而不是 package.jsonworkspaces 字段。
  • pnpm-lock.yaml

    • 全 workspace 共用一个,在根目录。
    • 锁死所有依赖(含 workspace 包解析结果),保证任意环境 pnpm install 结果一致。
  • packages/*

    • 一般放可复用库:组件库、工具库、配置包等。
    • 各自有 package.json,通过 workspace:* 相互依赖或被 apps/* 依赖。
  • apps/*

    • 一般放应用:前端项目、文档站、Demo 等。
    • 依赖 packages/* 时用 workspace:*,改库即生效。

有的项目还会加 tools/* 放脚本、CLI 等,本质上一样:在 pnpm-workspace.yaml 里写上对应 glob 即可。

4.2 命名与布局约定

  • packages:可复用、可能发布到 npm 的库;apps:入口应用、不发布或只发构建产物。
  • 何时拆 apps?当你明确有「多个应用 + 共享 packages」时,拆开更清晰;只有一两个 app 时,全放 packages 也没问题,按团队习惯来。
  • 依赖方向
    • 子包互相依赖、app 依赖子包,一律用 workspace:*
    • 禁止循环依赖(A 依赖 B,B 又依赖 A),否则安装、构建都会出问题。
    • 根包通常作为业务依赖,只提供脚本和公共 devDependencies。

4.3 workspace 包的解析与匹配机制

靠啥匹配?
pnpm 解析 workspace:* 时,只看 package.json 里的 name,和目录名、路径都无关。你写 "@my/ui": "workspace:*",pnpm 就会在 pnpm-workspace.yaml 声明的那堆目录里,找 name@my/ui 的包;找到就把该包所在目录链进 node_modules,找不到就直接报错,不会悄悄去 npm 装一个。

具体流程

  1. pnpm-workspace.yaml,收集所有匹配 packages 的目录(如 packages/*apps/*);
  2. 逐个读这些目录下的 package.json,拿到 name,建成一张 「name → 目录」 的映射;
  3. 解析依赖时,遇到 workspace:*workspace:^ 等,用依赖里的包名去这张表里查;
  4. 查到了 → 用该包所在目录做链接目标,链到当前包的 node_modules 里;
  5. 查不到 → 报错(例如 ERR_PNPM_NO_MATCHING_PACKAGE),安装中止。

所以:包名必须和依赖里写的一模一样packages/uiname 要是 @my/ui,别的地方才能 "@my/ui": "workspace:*";写成 @my/components 就匹配不上。

几种写法

  • workspace:*:匹配 workspace 里同名包的任意版本,并链到源码目录;开发联调最常用。
  • workspace:^workspace:~:按 semver 匹配 workspace 内版本;发布时会被替换成具体版本号(如 1.0.0),发布出去的 package.json 里不会还带着 workspace:
  • workspace:../packages/utils(相对路径):明确指向某个目录,不靠 name 匹配;适合临时调试或路径敏感的布局。

别名
可以用 "别名": "workspace:真实包名@*" 把 workspace 包挂到另一个名字下,例如 "react": "workspace:my-react@*"。发布时同样会替换成普通依赖形式。

找不到会怎样?
只会报错,不会回退到 npm 装。这样你才能确定:用的一定是本地的 workspace 包,没有误用远端的。

4.4 依赖图与构建顺序

workspace 里包和包之间的依赖关系,会形成一张有向图:谁依赖谁,一目了然。pnpm 跑 pnpm -r run build 这类递归命令时,默认按这张图的拓扑顺序执行:先跑被依赖的,再跑依赖别人的,避免「还没 build 完就被别人 require」的坑。

拓扑顺序是啥?
简单说:若 A 依赖 B,则一定执行 B 的 build执行 A 的 build。例如 utilsuiweb,顺序就是 utilsuiweb。同一层之间(比如多个 app 互不依赖)谁先谁后不保证,但层级不会乱。

默认行为

  • pnpm -r run build(以及 pnpm -r run <script>):按依赖图拓扑排序,再依次执行;没有 -r 时则只跑当前包。
  • pnpm -r --parallel run build不管顺序,所有包并行跑;跑 devtest 时常用 --parallel,但 build 一般要保证顺序,所以慎用 --parallel

怎么知道谁依赖谁?

  • 看各包 package.jsondependencies / devDependencies 里对 workspace 包、普通包的引用;
  • pnpm why <pkg> 看某包被谁依赖;pnpm list -r 看全 workspace 的依赖树(注意 list 默认不按拓扑序,按字母序);
  • 有些团队会接 Turborepo、Nx 等,用它们画依赖图、跑拓扑并行 build(同一层并行,层与层之间仍按依赖顺序)。

循环依赖
若出现 A → B → C → A,依赖图成环,拓扑排序搞不定,pnpm 会报错;安装、-r 执行都可能挂。所以必须保证 workspace 内无环,设计时就要避免「包互相依赖」。

4.5 安装与打包:workspace 如何工作

安装(pnpm install
根目录执行 pnpm install 时,大致会做这几步:

  1. 读 workspace 定义:解析 pnpm-workspace.yaml,得到所有 workspace 包目录(如 packages/*apps/*)。
  2. 收集包信息:逐个读这些目录下的 package.json,建 name → 目录 映射,并算出整棵依赖树(含对 npm 包的依赖)。
  3. 解析 workspace:*:遇到 workspace:* 等,按 4.3 的规则匹配到本地包目录,从 registry 拉包。
  4. 链接 workspace 包:把匹配到的本地包目录链到各包的 node_modules 里(符号链接或 junction),不拷贝、不往 store 塞;改源码立即生效。
  5. 装外部依赖:对 npm 上的包,按平时那套来:store + 硬链接,装到 node_modules/.pnpm 等位置。
  6. 写 lockfile:把所有依赖(含 workspace:* 的解析结果)写入根目录的 pnpm-lock.yaml

所以:workspace 包只做链接,不占 store;占磁盘、耗时的主要是外部依赖,而它们仍走 store 复用。

打包 / 构建(pnpm -r run build
构建改依赖安装方式,只是按依赖图顺序跑各包的 build 脚本:

  1. 算依赖图:根据各包 package.json 的依赖关系,得到有向图。
  2. 拓扑排序:排出「被依赖的在前、依赖别人的在后」的顺序(pnpm 内部用类似 graph-sequencer 的方式处理)。
  3. 依次执行:按该顺序对每个 workspace 包执行 pnpm run build(或你配的其它 script)。
  4. 若某包没有 build 脚本,pnpm 会报错或跳过该包,视配置而定。

因此:先装依赖,再构建;装依赖保证 node_modules 里 workspace 包、npm 包都就位,构建则按依赖顺序生成各包产物。
若用 --parallel,pnpm 会忽略拓扑顺序,所有包一起跑;适合 devtest 等不严格要求「被依赖的先跑」的场景,但 build 一般别开 --parallel,否则可能用到尚未 build 的依赖。

和 Turborepo / Nx 的关系
pnpm 只负责依赖安装 + 按拓扑序跑 script缓存、增量构建、远程缓存等,可交给 Turborepo、Nx。通常做法是:pnpm 管 install 和 workspace 链接,Turbo/Nx 管 build / test 的调度与缓存,两者一起用没问题。


五、优缺点一览(够直白版)+ 逐条详解

5.1 优点总览

说明
省磁盘、安装快 全局 store + 硬链接,避免重复存包;workspace 包用链接,不复制。
依赖干净 严格依赖,无幽灵依赖;lockfile 唯一,版本一致。
本地联调友好 workspace:* 直接链到源码,改即生效,无需 npm link
monorepo 友好 内建 workspace 支持,-r--filter 过滤、并行跑脚本很方便。
易于做权限与发布 配合 pnpm publish -r、changesets 做按包发布、权限控制。

详细说明

  • 省磁盘、安装快:原理即第二节的 store + 硬链接;workspace 包不进 store,只做链接。典型收益是 monorepo 磁盘占用和 pnpm install 耗时明显下降。
  • 依赖干净:严格依赖 + 单一 lockfile,少很多「删了某包就挂」「本地有 CI 没有」的玄学问题;注意若用了 public-hoist-pattern 等,要控制范围,否则又引入隐性依赖。
  • 本地联调:改 packages/ui 立刻在 apps/web 里生效,无需 link;注意跑 dev 的终端要在根目录或对应 app 目录,且已执行过根目录的 pnpm install
  • monorepo 友好pnpm -r--filter 能力足,再配合 Turborepo/Nx 做任务编排、缓存,体验更好。
  • 发布:按包发布、changesets 管理版本与 changelog,和现有 registry 流程兼容。

5.2 缺点 / 注意点总览

说明
和 npm 不完全兼容 部分工具假设「所有依赖扁平在根 node_modules」,可能报错,需适配。
学习与迁移成本 团队要搞懂 workspace、workspace:*pnpm-workspace.yaml--filter 等。
部分旧工具兼容性 极端老旧的构建/调试工具对 pnpm 的 node_modules 结构可能不友好。
需统一包管理 全 repo 必须用 pnpm,不能混用 npm/yarn,否则 lockfile、链接会乱。

详细说明

  • 和 npm 不完全兼容
    有些 Webpack 插件、老版 Babel、个别 CLI 会直接去根 node_modules 找包,pnpm 默认非扁平就可能找不到。处理办法:

    • node-linker=hoisted.npmrc)切回类 npm 扁平结构,会牺牲严格依赖;
    • 或只用 public-hoist-pattern 把有问题的包提升上来,尽量窄配。
  • 学习与迁移成本
    团队至少要会:workspace 概念、pnpm-workspace.yamlworkspace:* 协议、根目录 pnpm install--filter-r 的用法。可以抽半小时过一遍本文 + 官方文档,再在试点项目跑一遍。

  • 旧工具兼容性
    建议先小范围试点,遇到具体工具再查 pnpm 兼容性 或社区 issue;大多数现代前端工具已支持。

  • 统一包管理
    全仓库只用 pnpm,禁止 npm install / yarn。用 packageManager 锁版本,CI 里 corepack enable && pnpm install,避免有人用错包管理器导致 lockfile 或链接关系错乱。

适合:中大型前端项目、组件库 + 多应用、多包复用的 monorepo。
不大适合:单应用、没有多包复用需求的小项目;用 pnpm 单仓也能受益,但 workspace 收益有限。


六、应用场景(什么时候上 workspace?)

下面按场景拆:谁用、解决啥问题、推荐结构、关键配置、日常工作流。你对照自己项目,能直接套用或微调。

6.1 UI 组件库 + 多个业务项目

场景:你们有一个业务组件库,要同时支撑 2~3 个前端项目;组件库频繁迭代,需要在各项目里即时验证,而不是先发 npm 再装。

推荐结构

packages/
  ui/           # 组件库
apps/
  web-admin/
  web-h5/
  web-docs/     # 组件文档

web-adminweb-h5web-docs 都依赖 @my/ui,用 workspace:*

关键配置

  • pnpm-workspace.yamlpackages: ['packages/*', 'apps/*']
  • 各 app 的 package.json"@my/ui": "workspace:*"
  • scripts:如 "dev:docs": "pnpm --filter web-docs run dev""build:ui": "pnpm --filter @my/ui run build"

工作流
packages/ui → 在 apps/web-docs 或任意 app 里直接看效果;要发版时用 changesets 给 @my/ui 打 version、写 changelog、publish,各 app 再决定何时把 workspace:* 换成固定版本(若你们发 npm 的话)。

6.2 多应用 + 公共 utils / config

场景:多条产品线、多个前端应用,共享 utilsapi-clienteslint-config 等,希望统一版本、统一升级

推荐结构

packages/
  utils/
  api-client/
  config-eslint/
apps/
  app-a/
  app-b/

apps 按需依赖 @my/utils@my/api-clientconfig-eslint 被各 app 的 devDependencies 引用。

关键配置

  • pnpm-workspace.yaml:同上。
  • 各包用 workspace:* 互引;根 package.json 可放公共 devDependencies,或用 catalog 统一 React、TypeScript 等版本。
  • 根脚本:"build": "pnpm -r --filter './apps/*' run build",只构建 apps。

工作流
公共逻辑在 packages/* 改,各 app 自动用到;发版用 changesets 按包发布,各 app 通过 workspace:* 或固定版本消费。

6.3 文档站 + 组件库

场景:组件库配套一个文档站(如 VitePress、Docusaurus),文档站要直接引用源码里的组件做 Demo,而不是已发布的 npm 包。

推荐结构

packages/
  ui/
apps/
  docs/

docs 依赖 @my/uiworkspace:*

关键配置

  • 同上,packages + appsdocs"@my/ui": "workspace:*"
  • 文档站构建配置里保证能解析 packages/ui 的源码(通常 workspace 链接后没问题)。

工作流
改组件 → 跑 docs 的 dev,文档里实时看效果;发版时先发 @my/ui,再更新文档站里对版本的说明(若文档站自己也要发)。

6.4 全栈 monorepo(前后端同仓)

场景:前端 + Node 服务同仓,共享类型、常量或少量 utils,用同一套依赖管理。

推荐结构

packages/
  types/
  shared-utils/
apps/
  web/
  api/          # Node 服务

apiweb 都依赖 @my/types@my/shared-utilsworkspace:*

关键配置

  • pnpm-workspace.yaml 包含 packages/*apps/*
  • package.jsonscripts 里分别 --filter web--filter api 跑 dev/build。

工作流
typesshared-utils,前后端同时生效;各自部署时只构建对应 app,公共逻辑通过 workspace 链进去。


只要你存在「多个包 + 互相依赖 + 要一起开发」的需求,workspace 就很值得上;上面四种可以组合,比如「组件库 + 多应用 + 文档站」一起做。


七、详细教程:从零搭一个 pnpm workspace

下面按步骤做一遍,每步会写操作、预期结果、常见报错与排查。路径、包名和上文保持一致,你照抄就能跑通。

7.1 环境准备

  • 安装 pnpm

    npm install -g pnpm
    

    或用 Corepack(Node 16.9+):

    corepack enable
    corepack prepare pnpm@latest --activate
    

    建议用 pnpm 8.x 或 9.x,Node 18+ 更省心。

  • 校验

    pnpm -v
    node -v
    

    看到版本号即成功。

7.2 初始化根项目

mkdir my-workspace && cd my-workspace
pnpm init

会生成根目录 package.json。编辑成类似:

{
  "name": "my-workspace",
  "version": "1.0.0",
  "private": true,
  "packageManager": "pnpm@9.0.0"
}
  • private: true:根包不会被 pnpm publish 发出去,避免误发。
  • packageManager:锁死 pnpm 版本,配合 corepack enable 使用;可选但推荐。

7.3 配置 pnpm-workspace.yaml

项目根目录新建 pnpm-workspace.yaml

packages:
  - 'packages/*'
  - 'apps/*'
  • packages/*packages/ 下每个子目录(如 packages/uipackages/utils)都算一个 workspace 包。
  • apps/*:同理。
  • 只有被列出来的目录才会被 pnpm 当成 workspace 成员,才能被 workspace:* 引用。

预期:保存后暂无输出;之后 pnpm install 时 pnpm 会扫描这些目录。

7.4 创建子包目录并初始化

mkdir -p packages/ui packages/utils apps/web

然后逐个初始化(Windows 用户可用 PowerShell,mkdir -p 若不可用就分步 mkdir):

cd packages/utils && pnpm init && cd ../..
cd packages/ui   && pnpm init && cd ../..
cd apps/web      && pnpm init && cd ../..

Windows:若 mkdir -p 报错,可改为 mkdir packages\uimkdir packages\utilsmkdir apps\web 等分步创建;cd ../.. 在 PowerShell 中同样适用。)

每个子包会多一个 package.json。接下来改包名、入口、exports

packages/utils/package.json

{
  "name": "@my/utils",
  "version": "0.0.1",
  "main": "index.js",
  "exports": {
    ".": "./index.js"
  }
}

packages/ui/package.json

{
  "name": "@my/ui",
  "version": "0.0.1",
  "main": "index.js",
  "exports": {
    ".": "./index.js"
  }
}

apps/web/package.json

{
  "name": "web",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "dev": "echo \"dev placeholder\"",
    "build": "echo \"build placeholder\""
  }
}
  • exports:现代 Node 和打包器都认,用来明确入口,避免多余文件被引用;对 ESM、TS 等更友好。
  • webdev/build 先占位,后面验证完 workspace 再换成真实命令。

7.5 用 workspace:* 做包间依赖

packages/ui/package.json 里加依赖 @my/utils

{
  "name": "@my/ui",
  "version": "0.0.1",
  "main": "index.js",
  "exports": { ".": "./index.js" },
  "dependencies": {
    "@my/utils": "workspace:*"
  }
}

apps/web/package.json 里加依赖 @my/ui

{
  "name": "web",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "dev": "echo \"dev placeholder\"",
    "build": "echo \"build placeholder\""
  },
  "dependencies": {
    "@my/ui": "workspace:*"
  }
}

workspace:* 表示「用当前 workspace 里的同名包,追踪源码」;装完依赖后会链接到对应包目录,改代码即时生效。

7.6 根目录执行 pnpm install

务必在根目录执行(若不在根目录,先 cd 到项目根):

pnpm install

预期

  • 根目录出现 node_modules/pnpm-lock.yaml
  • packages/uiapps/webnode_modules 里会有 @my/utils@my/ui 的链接;
  • lockfile 里能看到对 workspace: 的解析,例如:
packages:
  '@my/utils@workspace:*':
    resolution: { directory: packages/utils, type: directory }
  '@my/ui@workspace:*':
    resolution: { directory: packages/ui, type: directory }

(省略其他字段;实际 lockfile 还有 nameversion 等。)

若报 ERR_PNPM_NO_MATCHING_PACKAGE:检查 pnpm-workspace.yamlpackages 是否包含对应目录,以及子包 name 是否和依赖里写的一致。

7.7 根 package.json 里加批量脚本

根目录 package.json 增加:

{
  "name": "my-workspace",
  "version": "1.0.0",
  "private": true,
  "packageManager": "pnpm@9.0.0",
  "scripts": {
    "dev": "pnpm -r --parallel run dev",
    "build": "pnpm -r run build",
    "build:web": "pnpm --filter web run build"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}
  • pnpm -r:递归在所有 workspace 包里执行同名 script。
  • pnpm -r --parallel:并行跑,适合 dev
  • pnpm --filter web run build:只对 web 包执行 build

Windows:若使用 PowerShell,scripts 里的双引号、&& 等和 Unix 略有差异,一般上述写法没问题;若遇解析错误,可改为 node 跑一小段脚本封装命令。

7.8 验证 workspace 链路

  • packages/utils/index.js 写:
module.exports = { add: (a, b) => a + b };
  • packages/ui/index.js 写:
const { add } = require('@my/utils');
module.exports = { add, hello: 'from ui' };
  • apps/web 里加个临时脚本验证。给 apps/web/package.jsonscripts 增加一行 "run:check",例如:
"scripts": {
  "dev": "echo \"dev placeholder\"",
  "build": "echo \"build placeholder\"",
  "run:check": "node -e \"const x=require('@my/ui'); console.log(x.add(1,2), x.hello)\""
}

保存后,在根目录执行:

pnpm --filter web run run:check

预期输出3 'from ui'
Cannot find module '@my/ui'

  • 确认在根目录执行过 pnpm install
  • 确认 apps/webdependencies 里有 "@my/ui": "workspace:*"
  • 看看 apps/web/node_modules/@my 下是否有 ui 的链接。

ENOENT 等路径类错误:

  • 检查 packages/utilspackages/ui 是否有 index.js,以及 package.jsonmain / exports 是否指向它。

验证通过后,可以把 webdev / build 换成真实命令(如 Vite、Next 等),继续开发。


八、配置说明(可查阅手册)

这一节把 pnpm workspace 相关配置 拆开讲:每项是啥、怎么配、适用场景、注意点。方便你以后查。

8.1 pnpm-workspace.yaml

  • 唯一性:整个仓库只放一个在根目录;pnpm 只认根目录这份。
  • packages
    • 字符串数组,每个元素是一个 glob 或具体路径。
    • 例:'packages/*''apps/*''tools/*',或 'packages/ui''packages/utils'
    • 只有匹配到的目录且其中包含 package.json,才会被当作 workspace 包。
  • 排除:部分版本支持 ! 排除,如 !'packages/legacy/*',以你用的 pnpm 文档为准。
  • package.jsonworkspaces:pnpm 官方推荐用 pnpm-workspace.yaml 定义 workspace,不用 workspaces 字段;若同时存在,以 pnpm-workspace.yaml 为准。

示例

packages:
  - 'packages/*'
  - 'apps/*'
  - 'tools/*'

8.2 根目录 package.json

  • private: true:根包不发布,避免误 pnpm publish
  • packageManager:如 "pnpm@9.0.0",锁包管理器 + 版本;需 corepack enable
  • scripts:结合 pnpm -r--filter 做批量或定向执行(见 8.6)。
  • pnpm.overrides:强制某依赖在全 workspace 解析成指定版本。
    {
      "pnpm": {
        "overrides": {
          "lodash": "4.17.21"
        }
      }
    }
    
    装依赖时 pnpm 会按 overrides 解析,并反映在 lockfile;适合修安全漏洞、解决传递依赖冲突。
  • catalog(pnpm 9+):在 pnpm-workspace.yaml 里定义(不是 package.json),子包用 catalog: 引用;见下方示例。

catalog 示例pnpm-workspace.yaml):

packages:
  - 'packages/*'
  - 'apps/*'

catalog:
  react: ^18.3.1
  react-dom: ^18.3.1

子包 package.json

{
  "dependencies": {
    "react": "catalog:",
    "react-dom": "catalog:"
  }
}

升级时只改 catalog 即可,所有用 catalog: 的包一起变。

8.3 workspace: 协议

  • workspace:*:用当前 workspace 里同名包任意版本,并链接到源码目录。开发联调默认用这个。
  • workspace:^workspace:~:按 semver 匹配 workspace 内版本;发布时 pnpm 会把它替换成实际版本号(如 1.0.0),所以发布到 npm 的包不会还带着 workspace:
  • 锁文件里的表现
    '@my/ui@workspace:*':
      resolution: { directory: packages/ui, type: directory }
    
    表示解析为本地 packages/ui 目录。

日常开发 workspace:* 就够用;若你们有严格的 semver 约束再考虑 ^ / ~

8.4 pnpm-lock.yaml

  • 唯一:整份 workspace 共用一个 lockfile,放在根目录。
  • 内容:锁住所有依赖(含 workspace 解析结果)的版本、完整性校验等。
  • 维护:用 pnpm installpnpm add 等变更依赖,不要手改
  • CI:务必把 pnpm-lock.yaml 纳入 git;CI 里 pnpm install --frozen-lockfile 可保证和 lockfile 完全一致,复现构建。

8.5 .npmrc(项目级)

放在项目根目录,只影响当前仓库。

常见项:

配置项 含义 示例
store-dir 全局 store 路径 store-dir=D:\pnpm-store
node-linker 链接方式 isolated(默认)/ hoisted
hoisted 已废弃,用 node-linker
public-hoist-pattern 哪些包提升到根 node_modules public-hoist-pattern[]=*eslint*
shamefully-hoist 全部提升,类似 npm true,易幽灵依赖,慎用
auto-install-peers 自动装 peerDependencies true
strict-peer-dependencies peer 未满足时报错 true
  • node-linker=hoisted:切回类 npm 扁平结构;兼容性好,但失去严格依赖。
  • public-hoist-pattern:只把匹配的包提升,例如 ESLint、Prettier 等工具常见需求;能窄就窄,减少幽灵依赖。
  • resolution-mode:依赖解析策略(如 lowest-direct);lockfile-include-tty 等可按需查文档。

示例(只提升部分工具):

public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

8.6 --filter 完整语法

--filter 用来限定要对哪些 workspace 包执行命令,常与 pnpm -rpnpm add 等一起用。

写法 含义 示例
--filter <pkg> 指定包(按 name 或路径) pnpm --filter web run build
--filter <pkg>... pkg 以及依赖了 pkg 的所有包(dependents) pnpm -r --filter '@my/ui...' run build
--filter ...<pkg> pkg 以及 pkg 依赖的所有包(dependencies) pnpm -r --filter '...web' run build
--filter ...^<pkg> 依赖了 pkg 的包,不含 pkg 自身 pnpm -r --filter '...^@my/ui' run test
@scope/* 通配,所有 @scope 下包 pnpm -r --filter '@my/*' run build

示例

# 只给 web 装 lodash
pnpm add lodash --filter web

# 只给名字匹配 @my/* 的包跑 build
pnpm -r --filter '@my/*' run build

# 只给依赖了 @my/ui 的包跑 test(不含 @my/ui 自身,例如 web、docs)
pnpm -r --filter '...^@my/ui' run test

# 只给 web 及其依赖的 workspace 包跑 build(含 web 自身)
pnpm -r --filter '...web' run build

多 filter 可组合,例如 --filter '@my/ui...' --filter web 表示满足任一条件的包。仅要「依赖了某包」的包且排除该包本身时,用 ...^<pkg>

8.7 依赖提升(hoisting)

  • 默认:pnpm 不提升,依赖装在各自包的 node_modules.pnpm 下,严格隔离。
  • public-hoist-pattern:把匹配的包额外提升到根 node_modules,方便某些工具查找;提升范围越大,幽灵依赖风险越高。
  • shamefully-hoist:几乎全部提升,和 npm 类似;不推荐,除非你只是临时兼容旧工具。

对比

  • 不提升:根 node_modules 只有直接依赖,子依赖在 .pnpm 里,严格。
  • 提升后:根 node_modules 会出现被提升的包,未声明也可能被引用,所以要想清楚再开。

8.8 只用 pnpm / 锁包管理

  • 全仓库统一用 pnpm,禁止 npmyarn,否则 lockfile 和链接会乱。
  • package.jsonpackageManager,如 "pnpm@9.0.0"
  • 启用 Corepackcorepack enable;CI 里先 corepack enablepnpm install,保证版本一致。

九、和 npm / Yarn workspace 的简单对比

能力 npm workspaces Yarn workspace pnpm workspace
磁盘占用 高,多份拷贝 一般 低,store+硬链接
安装速度 一般 较快
node_modules 结构 扁平 扁平或 PnP 非扁平,.pnpm
幽灵依赖 易出现 默认严格,无
lockfile 格式 package-lock.json yarn.lock pnpm-lock.yaml
workspace 协议 workspace:* workspace:* workspace:*
配置方式 package.json workspaces package.json workspaces pnpm-workspace.yaml
filter/scripts 无内置 filter 有 workspaces 脚本 -r--filter
CI 缓存友好度 一般 较好 好(store 可复用)

何时选 pnpm workspace

  • 你打算认真搞 monorepo、多包复用,且关注磁盘、安装速度、依赖干净。
  • 愿意统一用 pnpm,并接受一点学习与迁移成本。

何时继续用 npm / Yarn

  • 现有 npm/Yarn 脚本、CI 已经很成熟,团队不想动。
  • 单仓库、包很少,workspace 收益有限,用 pnpm 单仓也不错,不必非上 workspace。

pnpm 的差异主要来自存储与解析策略,而不是「有没有 workspace」本身。


十、进阶与延伸

10.1 发版:按包发布 + changesets

  • pnpm publish -r:递归发布所有 未 private 的 workspace 包;可加 --filter 只发改动的,例如先 pnpm -r --filter '@my/ui...' run buildpnpm publish -r --filter '@my/ui'
  • changesets
    • changeset 管理 version bumpchangelog
    • 流程大致:改代码 → pnpm changeset 选包、选版本类型、写 changelog → pnpm changeset version 更新版本号 → pnpm publish -r 发布。
      这样多包独立发版、可追溯,很常见。

10.2 任务编排:Turborepo / Nx

  • package.jsonbuilddev 等可以交给 TurboNx 跑:他们按依赖图做拓扑排序,只跑该跑的,且能做远程/本地缓存,加速 CI 和本地构建。
  • pnpm workspace 只负责依赖安装与链接;Turborepo/Nx 负责任务调度,两者配合良好。

10.3 参考


十一、小结与 FAQ

11.1 小结

  • 问题:多包重复安装、幽灵依赖、本地联调麻烦、CI 又慢又占空间 → 本质是多包管理 + 依赖存储/解析没做好;pnpm workspace 针对这两点设计。
  • 原理:全局 store + 硬链接省空间、提速;非扁平 node_modules + 严格依赖防幽灵依赖;workspace 包链到源码,改即生效。
  • 架构:根 pnpm-workspace.yaml + 根 package.json + 唯一 pnpm-lock.yaml + packages/* / apps/*;子包用 workspace:* 互引,禁止循环依赖。
  • 配置:弄清 pnpm-workspace.yaml、根 package.jsonworkspace: 协议、.npmrc 常用项、--filter 用法即可上手。
  • 建议:按第七节亲手搭一遍,再在一个小项目里拆一个 utils 包用 workspace:* 引用,跑几天 dev/build,体感会很明显;后续再接 changesets、Turborepo 等。

11.2 FAQ

Q:子包的依赖装到根还是装到各自包?
A:各自 package.json 里声明,各自装;pnpm 会把实体放在 store、在对应包的 node_modules/.pnpm 下链接。根 package.json 只放全仓库共用的 devDependencies(如 TS、ESLint)和脚本。

Q:workspace:* 发布到 npm 前要改吗?
A:不用pnpm publish 时会把 workspace:* 等替换成实际版本号再发布,发布出去的 package.json 里是普通版本范围。

Q:Windows 下路径或脚本有问题怎么办?
A:

  • 路径尽量别带中文、空格;store-dir 等用正斜杠或系统可识别的形式。
  • 若在 PowerShell 里 scripts 报错,可试着用 node 写一个小脚本封装 pnpm -r / --filter 等命令,再在 scripts 里调该脚本。
  • 全局 pnpm、Node 建议用官方安装包或 nvm-windows,避免权限、路径异常。

如果你有具体的目录结构或 package.json 想优化,可以贴出来,按你现在的项目一步步改也行。

【Node】操作磁盘文件底层原理:从「点外卖」到「厨房流水线」

公众号:AI小揭秘

Node.js 操作磁盘文件底层原理:从「点外卖」到「厨房流水线」

你以为 fs.readFile 是让 Node 帮你「拿一下文件」?不,其实是:你下单 → 前台记单 → 后厨线程池做菜 → 做好了再叫你。这篇文章带你看看这份「外卖」是怎么从磁盘端到你手里的。


一、先别急着写代码:为什么你要关心「底层」?

很多人学 Node.js 的 fs 模块,会背两句口诀就收工:「用异步别用同步」「大文件用 Stream」
背完发现:

  • 为什么我 readFile 读个 10GB 的日志直接 OOM?
  • 为什么说 Node 是单线程,但一堆文件操作时还是会「卡」?
  • fs.promisesfs.readFile 回调版,底层是不是同一套?

(补充一句:文件 I/O 本身是等磁盘,不会占满 CPU;你觉得「卡」多半是线程池被占满,新任务在排队。)

要回答这些,就得知道:你的 JS 代码 → Node 的 C++ 绑定 → libuv → 操作系统 → 磁盘,这条链上每一环在干什么。
知道之后,你选 API、调参数、排查性能问题,都会心里有数——而不是靠「玄学调参」。

所以这篇东西的目标很简单:用尽量人话 + 一点幽默,把 Node.js 操作磁盘文件的底层原理讲清楚,顺便带上能跑的示例。


二、从你敲下 fs.readFile 开始:调用链长什么样?

你写的可能是:

const fs = require('fs');
fs.readFile('/tmp/hello.txt', (err, data) => {
  if (err) throw err;
  console.log(data.toString());
});

在底层,大概发生了这些事(简化版):

  1. JavaScript 层
    fs.readFile 是 Node 内置模块 fs 上的方法,实现里会做路径解析、编码处理、以及「把回调塞进某个流程里」。

  2. C++ 绑定层(Node 的 node_file.cc 等)
    JS 调用的其实是 C++ 里封装好的函数。这里会:

    • 把路径、回调、选项等转成 C++ 能用的东西;
    • 调 libuv 的 API,发起「异步文件读请求」。
  3. libuv 层
    libuv 是 Node 用来抽象「异步 I/O」的 C 库,跨平台(Windows / Linux / macOS 都靠它)。
    文件 I/O,它一般不会用 epoll/kqueue 这种「纯事件」机制,而是:
    把实际读文件的工作丢进「线程池」,在池里某条线程里做阻塞式的 read。
    所以:你以为的单线程,只是 JS 执行单线程;文件读写是在别的线程里阻塞地干的。

  4. 操作系统 → 磁盘
    线程池里的线程调的就是 OS 的 read(或类似)系统调用,由内核去和磁盘驱动、块设备打交道,把数据从磁盘读到内核缓冲区,再拷到用户态(Node 的 Buffer)。

  5. 回到 JS
    读完后,libuv 在某个时机(下一次事件循环的 I/O 阶段)把结果和你的回调塞回主线程,于是你的 (err, data) => { ... } 被调到了,data 就是那个 Buffer。

一句话:fs.readFile = 你在 JS 里下单 → Node 通过 libuv 把「读文件」这个任务派给线程池 → 线程池里的线程阻塞地读磁盘 → 读完再通过事件循环把结果回传给 JS。
所以「Node 单线程」指的是 JS 只在一个线程跑,磁盘 I/O 并不在主线程上阻塞,而是在线程池里。


三、事件循环与 libuv:谁在真正「干活」?

Node 的事件循环(event loop)是由 libuv 实现的。
和文件相关的部分可以粗分为:

  • Poll 阶段:等 I/O(网络、部分原生异步 API 等)。
  • 线程池完成回调:文件 I/O 在池里做完后,会在合适的阶段把「完成」事件插回事件循环,从而执行你传的 callback 或 resolve Promise。

所以:

  • 主线程(跑 JS 的那条):只负责执行你的 JS、跑定时器、处理已完成 I/O 的回调,不直接去读磁盘
  • 真正摸磁盘的:是 libuv 的线程池里那几条 worker 线程(默认 4 个,可配 UV_THREADPOOL_SIZE)。

这就是为什么:

  • 你写 fs.readFileSync 时,主线程会阻塞(因为同步 API 就是在主线程上直接调系统调用读文件);
  • fs.readFile 不会阻塞主线程,因为读是在线程池里做的。

四、线程池:别被「单线程」三个字骗了

默认情况下,libuv 的线程池大小是 4(和你的 CPU 核数无关,就是个固定值)。
所以:

  • 同时发 10 个 fs.readFile,只有 4 个在「真·读磁盘」,剩下 6 个在排队。
  • 线程池既管文件 I/O,也管部分 crypto、部分 DNS 等,所以文件多的时候你会感觉「怎么慢下来了」——因为池子被占满了。

可以通过环境变量把池子调大(建议不超过 CPU 数太多,否则上下文切换会变多):

# 例如把线程池改成 8
set UV_THREADPOOL_SIZE=8   # Windows
export UV_THREADPOOL_SIZE=8  # Linux/macOS
// 你可以自己试:同时读多个文件,看完成顺序
const fs = require('fs');
const files = ['file1.txt', 'file2.txt', 'file3.txt', 'file4.txt', 'file5.txt'];

files.forEach((f, i) => {
  fs.readFile(f, () => console.log(`第 ${i + 1} 个完成: ${f}`));
});
// 前 4 个往往先完成(线程池只有 4),第 5 个要等池里有空位

五、Buffer:内存里那块「黑板」

Buffer 是 Node 里表示「一块二进制数据」的类型,本质是 V8 外的一块连续内存(不经过 V8 堆的 GC,由 Node 自己管理)。
文件读进来、网络收来的裸字节,在 JS 里最常见的就是用 Buffer 拿着。

  • fs.readFiledata 就是 Buffer。
  • data.toString() 是把这块内存按指定编码(默认 UTF-8)解码成字符串。
  • 大文件一次性 readFile,就是一次性在内存里开一块和文件一样大的 Buffer——所以 10GB 文件会直接 OOM,和「底层」没关系,就是设计如此。

所以:大文件不要用 readFile,用 Stream 或 read(fd, buffer, offset, length, position) 分段读。

const fs = require('fs');

// 小文件没问题(记得先判断 err,否则文件不存在时 buf 为 undefined)
fs.readFile('small.txt', (err, buf) => {
  if (err) return console.error(err);
  console.log(Buffer.isBuffer(buf)); // true
  console.log(buf.length);            // 字节数
});

// 大文件:别这么干,用 createReadStream
// fs.readFile('huge.log', ...);  // 可能 OOM

六、文件描述符:操作系统给你的「取餐号」

文件描述符(file descriptor, fd) 是操作系统里「打开的文件」的整数句柄。
open 一个文件,内核给你一个 fd(比如 3、4、5),后续 read/write 都用这个数字来指代「哪个打开的文件」。

Node 里:

  • fs.open(path, flags, callback) 会得到 (err, fd)
  • fs.read(fd, buffer, offset, length, position, callback) 表示:从 fd 对应的文件里,从 position 开始,读 length 字节,放进 bufferoffset 位置,读完再回调。
  • 用完后要 fs.close(fd),否则会占用内核资源(可打开 fd 数量有限制)。

用 fd + read 可以自己实现「分段读大文件」:

const fs = require('fs');

function readInChunks(filePath, chunkSize = 64 * 1024) {
  const buffer = Buffer.alloc(chunkSize);
  let position = 0;

  fs.open(filePath, 'r', (err, fd) => {
    if (err) return console.error(err);
    function readNext() {
      fs.read(fd, buffer, 0, chunkSize, position, (err, bytesRead) => {
        if (err) return fs.close(fd, () => console.error(err));
        if (bytesRead === 0) return fs.close(fd, () => console.log('读完了'));
        console.log(`读到 ${bytesRead} 字节,position=${position}`);
        position += bytesRead;
        readNext();
      });
    }
    readNext();
  });
}

readInChunks('./some-big-file.log');

这里就是「底层」用法:自己控 Buffer、position、每次读多少,不依赖 readFile 一次性装进内存。


七、Stream:别一口吞,一口一口吃

Stream(流) 是「一块一块处理数据」的抽象:不要求一次性把整个文件读进内存,而是读一块、处理一块、再读下一块。

  • fs.createReadStream(path) 会打开文件,并返回一个 Readable 流
  • 底层一般也是用 fd + 多次 read,每次读满一块 Buffer(默认 64KB,可配),通过 data 事件或 read() 推给你。
  • 流内部有 highWaterMark:内部缓冲超过这个值就暂停从底层拉数据,避免内存爆掉。

所以:大文件用 ReadStream + 管道或逐 chunk 处理,就不会 OOM。

const fs = require('fs');

// 大文件拷贝:流式,内存占用稳定
function copyBigFile(src, dest) {
  const readStream = fs.createReadStream(src, { highWaterMark: 64 * 1024 });
  const writeStream = fs.createWriteStream(dest, { highWaterMark: 64 * 1024 });
  readStream.pipe(writeStream);
  writeStream.on('finish', () => console.log('拷贝完成'));
}

// 边读边处理:例如数行数
let lines = 0;
fs.createReadStream('huge.log')
  .on('data', (chunk) => {
    for (let i = 0; i < chunk.length; i++) if (chunk[i] === 10) lines++;
  })
  .on('end', () => console.log('总行数:', lines));

八、同步 vs 异步:什么时候该用谁?

方式 谁在干活 阻塞主线程? 适用场景
fs.readFile 线程池 小文件、配置等
fs.readFileSync 主线程 启动时读配置、脚本
createReadStream 线程池 + 事件 大文件、日志
fs.read(fd, ...) 线程池 需要精细控制位置/块

原则:

  • 能异步就异步,避免阻塞事件循环。
  • 只有在「进程刚启动、必须立刻拿到结果才能往下跑」的场景,才考虑用 Sync(例如读一个 config.json 再启动服务)。

九、新特性与最新知识点(Promise、FileHandle、io_uring)

1. fs.promises 与 async/await

Node 内置了基于 Promise 的 fs API,不用自己包一层:

const fs = require('fs').promises;

async function main() {
  try {
    const data = await fs.readFile('config.json', 'utf8');
    const config = JSON.parse(data);
    console.log(config);
  } catch (e) {
    console.error(e);
  }
}
main();

底层和回调版是同一套:都是走 libuv 线程池,只是把 callback 换成了 Promise 的 resolve/reject。

2. FileHandle:长期持有 fd 的「句柄」

fs.promises.open() 返回的是 FileHandle,可以多次读/写再关闭,适合「同一个文件反复读」:

const fsp = require('fs').promises;

async function readHeadAndTail(path, headBytes = 100, tailBytes = 100) {
  const handle = await fsp.open(path, 'r');
  const stat = await handle.stat();
  const head = Buffer.alloc(headBytes);
  const tail = Buffer.alloc(tailBytes);
  await handle.read(head, 0, headBytes, 0);
  if (stat.size > tailBytes) {
    await handle.read(tail, 0, tailBytes, stat.size - tailBytes);
  }
  await handle.close();
  return { head: head.toString(), tail: tail.toString() };
}

3. Linux 上的 io_uring(了解即可)

从 libuv 1.45 起,Linux 上部分文件 I/O 曾尝试用 io_uring 做更高性能的异步磁盘 I/O;后来默认又改回线程池。若要用 io_uring,需要在创建 event loop 时显式开启(如 UV_LOOP_USE_IO_URING_SQPOLL)。
对写业务代码的我们来说:知道「文件 I/O 主要走线程池」就够了,除非你在做极致性能调优。


十、综合示例:一个「带流式读 + 行解析」的日志处理器

下面这段把「底层」和「实用」串起来:用 ReadStream 读大日志,按行切分、逐行处理(不会把整个文件载入内存)。

const fs = require('fs');
const readline = require('readline');

async function processLargeLog(filePath, onLine) {
  const stream = fs.createReadStream(filePath, {
    highWaterMark: 256 * 1024, // 256KB 一块
  });
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
  for await (const line of rl) {
    await onLine(line); // 你可以在这里做解析、写库、发 MQ 等
  }
}

// 使用示例:只打印包含 "ERROR" 的行
processLargeLog('./app.log', async (line) => {
  if (line.includes('ERROR')) console.log(line);
}).then(() => console.log('处理完毕'));

这里用到的就是:fs 的 ReadStream(底层 fd + 分块 read)+ readline 按行消费,既不会 OOM,又符合「流式」的思维方式。


十一、小结:一张「外卖流程图」收尾

  • 你调 fs.readFile / createReadStream 等 → Node fs 模块(JS)
  • C++ 绑定libuv
  • libuv 把文件 I/O 丢给 线程池(默认 4 个 worker)
  • 线程池里 阻塞式 read内核 → 磁盘
  • 读到的数据放进 Buffer,完成后通过 事件循环 把回调/Promise 推回 主线程
  • 若是 Stream,则是多次「读一块 → 推一块」,由 highWaterMark 等控制背压

记住这几件事:

  1. 单线程指的是 JS,文件 I/O 在 libuv 线程池里。
  2. 大文件用 Stream 或 fd + read,别用 readFile 一把梭。
  3. Buffer 是那块「装字节」的内存;fd 是操作系统给你的「取餐号」。
  4. 新代码优先用 fs.promisesFileHandle,逻辑更清晰;底层和回调版一致。

如果你愿意再往深挖,可以看:

这样,下次有人问「Node 读文件到底是同步还是异步」「为什么我读大文件会崩」,你就能从事件循环讲到线程池、从 Buffer 讲到 Stream,顺便用「外卖下单 → 后厨线程池 → 取餐号 fd」的比喻把对方讲懂。
祝写 Node 少踩坑,磁盘 I/O 稳如狗。

别再手搓 Skill 了,用这个工具 5 分钟搞定

大家好,我是凌览。

如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞评论转发),给我一些支持和鼓励谢谢。


说实话,第一次看到 "Skill" 这个词,我也有点懵。是不是又要写很多代码?是不是只有程序员能玩?

后来自己上手做了几个才发现——完全不是那么回事。Skill 更像是:把一件你本来就会做的事,写成一套能反复执行、不会乱跑的步骤

就说做饭吧。第一次做某道菜,你得一边看菜谱一边试探;做多了流程就固定了——先备料,再处理,最后出锅。这时候让别人帮你做,你大概会说"就按这个步骤来,别自己发挥"。Skill 干的就是这事,只是对象从「人」变成了「模型」——让大模型也照着这个步骤来,别自己发挥。

一个 Skill 到底长啥样?

先记住一句话:一个 Skill,本质上就是一个文件夹。

最简单的结构:

my-skill/
├── SKILL.md        # 说明:什么时候用、输入输出是什么
├── scripts/        # 执行:真正跑的逻辑
└── references/     # 参考:示例输入输出

SKILL.md 是最核心的——告诉模型"这件事干嘛用的、什么时候用、输出长啥样"。

你可以把它当成一份「使用说明书 + 注意事项」。

Skill 能解决什么问题?

举个例子,之前我开放了一个去水印下载鸭工具,同时写了份接口文档。但说实话,调用接口的步骤是固定的——传参数、发请求、解析返回。就这几步,每次都要重复。

调用步骤:

  1. 传参:token、url
  2. 发请求:
curl -X GET "https://nologo.code24.top/api/open/parse?url=https%3A%2F%2Fv.douyin.com%2Fxxxxx" \
  -H "Authorization: your-token"
  1. 解析返回数据

就这三步,每次都要重复一遍,挺烦的。 图片1.png

写成 Skill 之后,这些步骤直接固化下来,AI 碰到要调去水印接口的场景,自己就跑完了,不用你再手动复制粘贴。

其中 token、url 怎么获取,直接在 SKILL.md 里写清楚,或者丢个文档链接就行。

image.png

这个去水印解析的 Skill 已经开源了:github.com/CatsAndMice…,名字叫 nologo-open-api

效果测试成功:

image 1.png

但说实话,手搓还是有点麻烦

概念不难理解,但真要自己从头写一个 Skill,还是得折腾——要想触发条件,要写 SKILL.md,要调试……

有没有更省事的方法?

有。用 skill-creator。

skill-creator 是什么?

Skill-Creator是一款专为开发者设计的Skill创建向导,旨在简化开发流程。其便捷性和实用性已得到广泛验证,在SkillHub上的下载量已突破7.5万。

地址:www.skillhub.cn/skills/skil…

image 2.png

说到 SkillHub,这事儿还有点意思:

安装 Skill-Creator 特别简单——直接跟它聊天就行,它会一步步指导 AI 帮你创建技能

举个例子:

你:"用 skill-creator 帮我创建一个提取小红书链接的 Skill"

它:"好的,我来帮你创建。先告诉我:这个 Skill 要处理什么类型的链接?"

你:"抖音、小红书都行,主要提取无水印的视频地址"

它:"明白了。输出格式要不要加上 metadata?我给你两个选项……"

就这么一来一回,你描述需求,它帮你搞定剩下的——写 SKILL.md、搭目录结构、甚至调试代码。

说白了就是:你出想法,它帮你落地。

总结

Skill 的核心是把一件事的"固定流程"写成可反复执行的步骤,让模型按规程稳定产出,避免每次都从零复制粘贴、手动操作。像调用去水印接口这类标准化流程,尤其适合沉淀成 Skill 直接复用;而如果你不想从触发条件、SKILL.md 到目录结构都自己手搓,skill-creator 这种对话式向导能把创建与落地成本降到最低。

【pnpm 】pnpm 执行 xxx 的 底层原理

公众号:AI小揭秘。

pnpm install / pnpm run dev / pnpm run build 底层原理

讲清楚 pnpm ipnpm run devpnpm run build 在底层做了什么:执行步骤、数据流、以及它们如何与 lockfile、store、node_modulespackage.json scripts 配合。附流程图方便对照。


一、总览:三条命令分别干啥

命令 缩写 主要职责
pnpm install pnpm i 解析依赖 → 拉包/复用 store → 算目录结构 → 链接到 node_modules
pnpm run dev 执行 package.jsondev 脚本(及 predev / postdev),通常跑开发服务器
pnpm run build 执行 build 脚本(及 prebuild / postbuild),通常做生产构建

pnpm run 的底层逻辑对 devbuild完全一致,只是脚本名不同;差异来自你在 scripts 里写的具体命令(如 vitenext build)。


二、pnpm installpnpm i)底层原理

2.1 核心目标

  • 根据 package.json(及 workspace 的 pnpm-workspace.yaml)确定要装哪些包、哪些版本。
  • pnpm-lock.yaml 锁定解析结果,保证可复现。
  • 把包实体放进全局 store,再通过硬链接 + 符号链接挂到项目的 node_modules,避免重复拷贝。

2.2 整体执行流程(高层)

flowchart TD
    Start[pnpm install] --> ReadLock{存在 pnpm-lock.yaml?}
    ReadLock -->|否| Resolve[依赖解析,读 package.json 等]
    ReadLock -->|是| ParseLock[解析 lockfile]
    Resolve --> Fetch[从 registry 拉元数据与 tarball]
    Fetch --> BuildTree[构建依赖树]
    BuildTree --> WriteLock[写入/更新 pnpm-lock.yaml]
    ParseLock --> LockOK[锁内容与 package.json 一致?]
    LockOK -->|否且非 frozen| Resolve
    LockOK -->|是或 frozen 通过| CalcStruct[计算 node_modules 目录结构]
    WriteLock --> CalcStruct
    CalcStruct --> Store[包入 store 或复用已有]
    Store --> Link[硬链接到 .pnpm, 符号链接到 node_modules]
    Link --> Done[安装完成]

2.3 分阶段说明

阶段一:Lockfile 与依赖解析
  1. 读 lockfile

    • 若存在 pnpm-lock.yaml,先解析;得到「包名 → 解析后版本、integrity、resolved」等映射。
  2. package.json 对齐

    • 对比 package.json(及 workspace 子包)的 dependenciesdevDependencies 与 lockfile。
    • --frozen-lockfile:若不一致直接失败,不改 lockfile、不写 node_modules
    • 未 frozen:若不一致则重新解析,再更新 lockfile。
  3. 依赖解析(无 lockfile 或需要更新时)

    • registry(默认 npm)拉取元数据,按 semver 解析版本;workspace 内 workspace:* 等解析为本地包。
    • 递归处理传递依赖,得到整棵依赖树
    • 若有 overridescatalog 等,在此阶段应用。
  4. 写回 lockfile

    • 将解析结果写回 pnpm-lock.yaml--lockfile-only 时只做这一步,不进行后续链接)。
阶段二:目录结构计算
  1. 计算 node_modules 布局
    • 确定哪些包放在 node_modules(直接依赖)、哪些只在 .pnpm 下、以及 符号链接 的指向。
    • 满足 非扁平、严格依赖:未声明的包不会出现在项目可访问路径下。
阶段三:Store 与链接
  1. Store 存取

    • 实体存到 全局 store(默认 ~/.local/share/pnpm/store 等,可 store-dir 配置)。
    • 内容寻址:同版本、同 integrity 只存一份;缺少则从 registry 下载 tarball 写入 store。
  2. 硬链接到 .pnpm

    • node_modules/.pnpm 下按 package@version 建目录,包内文件以硬链接从 store 链出。
    • 每个 package@version 有自己的 node_modules,里面只放它自己的依赖的符号链接。
  3. 符号链接到「使用方」的 node_modules

    • node_modules:项目直接依赖的包,符号链接到 .pnpm/<pkg>@<version>/node_modules/<pkg>
    • workspace 包workspace:* 解析出的本地包,链接到源码目录,不占 store。

2.4 pnpm install 流程简图(按阶段)

flowchart LR
    subgraph Phase1 [阶段一 解析]
        A1[读 package.json] --> A2[读/解析 lockfile]
        A2 --> A3{一致?}
        A3 -->|否| A4[解析 + 拉 registry]
        A4 --> A5[写 lockfile]
        A3 -->|是| A5
    end

    subgraph Phase2 [阶段二 结构]
        B1[算依赖树] --> B2[算 node_modules 布局]
    end

    subgraph Phase3 [阶段三 存储与链接]
        C1[store 取/存包] --> C2[硬链接到 .pnpm]
        C2 --> C3[符号链接到 node_modules]
    end

    Phase1 --> Phase2 --> Phase3

2.5 小结

  • pnpm i = 解析(含 lockfile)→ 算结构 → store + 硬链接 + 符号链接。
  • Workspace 下会多一步:解析 pnpm-workspace.yaml、处理 workspace:*,再统一算布局、链接。

三、node_modules 目录结构与执行相关文件

本节把 pnpm install 完成后 node_modules 里有哪些目录和文件、pnpm run dev / pnpm run build 执行时又会用到其中哪些,按「目录 → 文件 → 执行逻辑」列清楚,并详细列出与执行相关的文件清单

3.1 node_modules 顶层目录一览

以单包项目、依赖了 vitelodash 为例,项目根目录下的 node_modules 大致长这样:

<项目根>/node_modules/
├── .bin/                    # 可执行命令的入口(见 3.3)
│   ├── vite                 # Unix 下执行 vite 时实际跑的文件
│   ├── vite.cmd             # Windows CMD
│   ├── vite.ps1             # Windows PowerShell
│   ├── tsc
│   ├── tsc.cmd
│   └── ...
├── .modules.yaml            # pnpm 元数据(store 路径、layout 版本等)
├── .pnpm/                   # 所有包实体所在处(硬链接到 store,见 3.2)
├── vite                     # 符号链接 → .pnpm/vite@x.x.x/node_modules/vite
├── lodash                   # 符号链接 → .pnpm/lodash@x.x.x/node_modules/lodash
└── ...
  • 直接依赖(如 vitelodash):在顶层以包名出现,实际是符号链接,指向 .pnpm/<包名>@<版本>/node_modules/<包名>
  • .bin:下面是对应各包 bin 字段的可执行入口(脚本或符号链接),pnpm run dev / pnpm run build 时 PATH 里会带上这个目录。
  • .modules.yaml:pnpm 自己用的元数据,记录 store 路径、layout 版本等,run 不读它,install 会写。

3.1.1 与「执行」相关的 node_modules 内文件详细清单

下表按路径列出 pnpm run dev / pnpm run build 执行链路中会读、会执行的 node_modules 内文件与目录;「执行逻辑」一列说明该文件在运行时的作用。

路径 类型 谁创建 执行时作用 / 执行逻辑
node_modules/.bin/ 目录 pnpm install 被加入 PATH 前面;shell 解析 vitetsc 等命令时在此目录查找可执行文件。
node_modules/.bin/vite 文件(脚本或符号链接) pnpm install(根据 vite 的 bin 字段) Unix/macOS:被 shell 执行。若为脚本,首行 shebang 调 node,正文调包内入口;若为符号链接,指向 .pnpm/vite@x.x.x/node_modules/vite/dist/node/cli.js 等,由 node 执行。
node_modules/.bin/vite.cmd 文件(批处理) pnpm install Windows CMD:执行时用 node "%~dp0\..\vite\dist\node\cli.js" 等形式调包内入口(%~dp0 为 .cmd 所在目录)。
node_modules/.bin/vite.ps1 文件(PowerShell) pnpm install Windows PowerShell:脚本内用 node $PSScriptRoot\..\vite\dist\node\cli.js 等调包内入口。
node_modules/.bin/tsc 文件 pnpm install(typescript 的 bin) 同上逻辑,最终执行 node .../typescript/bin/tsc 或 tsc.js。
node_modules/.bin/tsc.cmd / .ps1 文件 pnpm install Windows 下执行 tsc 时命中的 wrapper。
node_modules/.pnpm/ 目录 pnpm install 存所有包实体;run 不直接遍历,而是通过 .bin 里的 wrapper 间接执行到其下某包的 bin 入口文件
node_modules/.pnpm/vite@5.4.0/node_modules/vite/ 目录(硬链接到 store) pnpm install 包本体;.bin/vite 的 wrapper 最终会 node 这个目录下 package.json#bin 指定的入口(如 dist/node/cli.js)。
node_modules/.pnpm/vite@5.4.0/node_modules/vite/package.json 文件 包自带 定义 bin 入口路径;pnpm 安装时据此在 .bin 下生成 wrapper;运行时由 wrapper 或 node 间接读到入口路径。
node_modules/.pnpm/vite@5.4.0/node_modules/vite/dist/node/cli.js 文件 包自带 vite 的 CLI 入口;.bin/vite(或 .cmd/.ps1)最终执行 node .../cli.js,即此文件。
node_modules/.pnpm/typescript@x.x.x/node_modules/typescript/bin/tsctsc.js 文件 包自带 tsc 命令的真实入口;.bin/tsc 最终执行此文件。
node_modules/vite 符号链接 pnpm install 指向 .pnpm/vite@x.x.x/node_modules/viterun 时若脚本里用 node 的 require/import 解析 vite,会走到此链接再到 .pnpm 下包本体。
node_modules/.modules.yaml 文件 pnpm install 记录 store 路径、layout 版本;仅 install 使用run 不读。

目录小结

  • 执行 dev/build 直接用到package.json(scripts)、node_modules/.bin/*(wrapper)、.pnpm/<包>@<版本>/node_modules/<包>/bin 入口文件
  • 间接用到:顶层 node_modules/<包名> 符号链接(Node 解析 require('vite') 等时)、.pnpm 下各依赖的 node_modules(运行时模块解析)。

3.2 .pnpm 目录结构(包实体与依赖链)

.pnpm 里才是「包的真实内容」所在位置(内容来自 store 的硬链接)。每个 package@version 一个目录,且每个包有自己的 node_modules,只放自己声明的依赖的符号链接。

示例(项目依赖 vitevite 又依赖 esbuild 等):

node_modules/.pnpm/
├── vite@5.4.0
│   └── node_modules/
│       ├── vite          # 指向 store 的硬链接(包本体)
│       ├── esbuild       # 符号链接 → ../../esbuild@x.x.x/node_modules/esbuild
│       ├── rollup        # 符号链接 → ...
│       └── ...
├── esbuild@0.19.x
│   └── node_modules/
│       └── esbuild       # 指向 store
├── lodash@4.17.21
│   └── node_modules/
│       └── lodash
└── ...
  • <包名>@<版本>/node_modules/<包名>:包本体(目录或硬链接到 store 的目录)。
  • <包名>@<版本>/node_modules/<依赖名>:该包的依赖,以符号链接指到 ../../<依赖名>@<版本>/node_modules/<依赖名>
  • 根目录的 node_modules/vite:符号链接到 .pnpm/vite@5.4.0/node_modules/vite,所以你在代码里 import 'vite' 时,Node 解析到的就是 .pnpm 里这一份。

执行逻辑

  • pnpm install 只写 storenode_modules(含 .pnpm 与顶层符号链接、.bin);不执行任何业务脚本。
  • pnpm run dev / pnpm run build 不会去「遍历 .pnpm」;它们只是执行 package.json 里配置的命令,命令里若写 vite,就会通过 PATH 找到 node_modules/.bin/vite,再由该文件间接执行到 .pnpm 里对应包的入口

3.3 .bin 目录:有哪些文件、怎么被执行

.bin 下的文件来自各依赖包 package.jsonbin 字段。pnpm 在 install 阶段会为每个 bin 项在 node_modules/.bin 下生成可执行入口,名字即 bin 的 key(如 vitetsc)。

平台 / 类型 文件名示例 说明
Unix / Linux / macOS vitetsc(无后缀) 一般为脚本(shebang 调用 node)或符号链接到包内 bin 文件。
Windows CMD vite.cmdtsc.cmd 批处理,内部通常用 node "%~dp0\..\vite\dist\cli.js" 等形式调包内入口。
Windows PowerShell vite.ps1tsc.ps1 PowerShell 脚本,同样会去调包内对应 js。

.bin 下典型文件内容示例(执行逻辑)

  • Unix:node_modules/.bin/vite(脚本形式时)
    内容通常类似:

    #!/usr/bin/env node
    require('../vite/dist/node/cli.js')
    

    或直接为符号链接,指向 .pnpm/vite@5.4.0/node_modules/vite/dist/node/cli.js。执行时:shell 调起该文件 → 若为脚本则 #!/usr/bin/env node 导致用 node 执行本文件,进而 require 包内 cli.js;若为符号链接则 node 执行链接目标(即 cli.js)。

  • Windows CMD:node_modules/.bin/vite.cmd
    内容通常类似:

    @echo off
    node "%~dp0..\vite\dist\node\cli.js" %*
    

    执行逻辑%~dp0 为当前 .cmd 所在目录(即 node_modules/.bin),..\vite 为顶层符号链接 vite(在 pnpm 下会解析到 .pnpm 里对应包),最终用 node 执行 cli.js%* 把命令行参数原样传给 cli.js。

  • Windows PowerShell:node_modules/.bin/vite.ps1
    逻辑类似,用 $PSScriptRoot 定位到 .bin,再 node 执行上一级 vite 下的入口 js。

执行逻辑(以 pnpm run dev 且 scripts.dev 为 vite 为例)

  1. pnpm 在当前包package.json 里读到 scripts.dev = "vite"
  2. pnpm 把 <包目录>/node_modules/.bin(及 workspace 根同路径)加到 PATH 前面,再在子 shell 里执行 vite
  3. 系统在 PATH 里找到第一个名为 vite 的可执行文件:
    • Unix:即 node_modules/.bin/vite(无后缀),可能是脚本或符号链接;
    • Windows CMD:会找 vite.cmd;PowerShell 可能用 vite.ps1
  4. 执行该文件:
    • 若是脚本,内容通常类似 #!/usr/bin/env node + 调 node <包内入口>,或直接 node path/to/vite/dist/node/cli.js
    • 若是符号链接,会指向 .pnpm/vite@x.x.x/node_modules/vite 下的 bin 入口(如 dist/node/cli.js),再由 node 执行该 js。
  5. 最终实际运行的是 node + .pnpm/vite@x.x.x/node_modules/vite 里声明的 bin 入口文件

因此:执行链路 = package.json#scripts.dev → shell 执行 vite → PATH 解析到 node_modules/.bin/vite(或 .cmd/.ps1)→ 该文件内部执行 node + .pnpm 里 vite 的 bin 入口

执行时文件与目录关系(示意)

flowchart LR
    A[package.json] -->|读 scripts.dev/build| B[命令字符串 vite 等]
    B --> C[PATH 含 node_modules/.bin]
    C --> D[node_modules/.bin/vite]
    D --> E[.pnpm/vite@x.x.x/node_modules/vite 下 bin 入口]
    E --> F[node 执行 cli.js]

3.4 执行 dev / build 时涉及的文件与目录(按顺序)

步骤 类型 路径 / 文件 作用
1 <包目录>/package.json 确定当前包、查 scripts.dev / scripts.build 等。
2 scripts.predev / scripts.dev / scripts.build 得到要执行的命令字符串(如 vitevite build)。
3 环境 <包目录>/node_modules/.bin<workspace根>/node_modules/.bin 被 pnpm 追加到 PATH 前面。
4 执行 node_modules/.bin/vite(或 vite.cmd / vite.ps1 shell 解析 vite 时命中的可执行文件。
5 执行 node_modules/.pnpm/vite@x.x.x/node_modules/vite/dist/node/cli.js(以 vite 为例) .bin 里的 wrapper 最终用 node 执行的真实入口
6 该包及其依赖下的 package.jsonnode_modules/... Node / Vite 等运行时按模块解析规则继续读,与 pnpm 无直接关系。

3.4.1 按「是否参与执行」区分的 node_modules 目录一览

下面用一棵更完整的目录树,标出执行 dev/build 时直接参与的目录/文件(✅)与仅 install 使用、run 不读的(○):

<项目根>/node_modules/
├── .bin/                          ✅ run 时 PATH 包含此目录,执行 vite/tsc 等命中此处
│   ├── vite                       ✅ Unix 下执行 vite 时运行
│   ├── vite.cmd                   ✅ Windows CMD 下执行 vite 时运行
│   ├── vite.ps1                   ✅ Windows PowerShell 下执行 vite 时运行
│   ├── tsc / tsc.cmd / tsc.ps1    ✅ 同上,tsc 命令
│   └── ...
├── .modules.yaml                  ○ 仅 pnpm install 读写,run 不读
├── .pnpm/                         ✅ run 时通过 .bin wrapper 间接执行到其下包的 bin 入口
│   ├── vite@5.4.0/
│   │   └── node_modules/
│   │       ├── vite/              ✅ 包本体,.bin/vite 最终 node 其下 bin 入口
│   │       │   ├── package.json   ✅ 定义 bin 入口路径
│   │       │   └── dist/node/cli.js  ✅ vite 命令的真实执行文件
│   │       ├── esbuild            ○ run 时由 vite 等按 require 解析
│   │       └── ...
│   ├── typescript@5.x.x/
│   │   └── node_modules/
│   │       └── typescript/bin/tsc ✅ tsc 命令的真实执行文件
│   └── ...
├── vite                           ✅ 符号链接;Node require('vite') 等会解析到此
├── lodash                         ✅ 同上
└── ...

执行链路小结
scripts.dev 字符串(如 vite)→ shell 在 PATH 里找到 node_modules/.bin/vite(或 .cmd/.ps1)→ 该文件内部执行 node + .pnpm 下 vite 的 bin 入口(如 cli.js)→ 之后由 Vite/Node 按模块解析规则读 .pnpm 下各依赖,与 pnpm 无直接关系。

目录小结

  • install 生成并维护:node_modules/node_modules/.pnpm/node_modules/.bin/node_modules/.modules.yaml,以及顶层包名符号链接。
  • run 直接用到的是:package.json(读 scripts)、node_modules/.bin/*(执行入口),间接用到 .pnpm 里对应包的 bin 入口文件。

3.5 小结

  • node_modules 顶层:.bin(可执行入口)、.pnpm(包实体与依赖链)、.modules.yaml(pnpm 元数据)、以及直接依赖的符号链接。
  • .pnpm:按 包名@版本 存包本体(硬链接到 store),每个包有自己的 node_modules,里面是该包依赖的符号链接。
  • .bin:由 install 根据各包 bin 生成;run 时 PATH 包含 .bin,执行 vite 等会先走到 .bin 再转到 .pnpm 里对应包的入口文件。
  • 执行 dev/build:先读 package.json 的 scripts,再在子 shell 里执行命令字符串,通过 .bin 找到并执行对应包的 bin 入口。

四、pnpm run(含 dev / build)底层原理

4.1 核心目标

  • 当前包package.jsonscripts 里找到对应脚本(如 devbuild)。
  • pre / 本体 / post 顺序执行(若有)。
  • 执行时把 node_modules/.bin(及 workspace 根 node_modules/.bin)加入 PATH,以便直接跑本地安装的 CLI。

4.2 整体执行流程

flowchart TD
    Start[pnpm run scriptName] --> ResolvePkg[解析当前包,读 package.json]
    ResolvePkg --> FindScript{scripts.scriptName 存在?}
    FindScript -->|否| Err[报错 Missing script]
    FindScript -->|是| Pre{存在 pre scriptName?}
    Pre -->|是| RunPre[执行 pre scriptName]
    Pre -->|否| RunMain
    RunPre --> RunMain[执行 scriptName]
    RunMain --> Post{存在 post scriptName?}
    Post -->|是| RunPost[执行 post scriptName]
    Post -->|否| Done[结束]
    RunPost --> Done

4.3 分步骤说明

1. 确定「当前包」与 script
  • 当前目录 若不是 workspace 根,pnpm 会向上找 包含 package.json 的目录,当作当前包
  • Workspace:若在子包目录执行 pnpm run dev,则用该子包package.json;在根目录则用根包的。
2. 查找 script
  • package.jsonscripts 里找 scriptName(如 devbuild)。
  • 没有则报 Missing script: "dev" 等错误。
3. Pre / 本体 / Post 顺序
  • 若存在 prescriptName,先执行 pnpm run prescriptName(递归,同样有 pre/post)。
  • 再执行 scriptName 对应的命令。
  • 若存在 postscriptName,再执行 pnpm run postscriptName
  • 例如 pnpm run build → 有 prebuild 则先 prebuild,再 build,再 postbuild(若有)。
4. 准备执行环境(PATH 等)
  • <包目录>/node_modules/.bin 加入 PATH 前端。
  • Workspace:还会把 <workspace 根>/node_modules/.bin 加入 PATH,因此根目录装的 CLI(如 vitetsc)在子包里也能直接调用。
5. 执行命令
  • 子 shell 中执行 scripts[scriptName] 里的字符串(如 vitenext dev)。
  • 通常通过 nodenode_modules/.bin 下对应平台的 wrapper(如 vitevite.jsvite.cmd),再 node vite.js;具体由 npm lifecycles / run-script 等底层处理。

PATH 与 .bin 的关系
pnpm 先把 node_modules/.bin(及 workspace 根同路径)塞进 PATH 前面,再启子进程跑脚本。因此脚本里写的 vitetsc 等会解析到 node_modules/.bin 里的 wrapper,而 .bin 里的文件是 pnpm install 阶段根据各包 bin 字段创建的符号链接或脚本。流程关系如下:

flowchart LR
    subgraph Install [pnpm install]
        I1[解析依赖] --> I2[链接包到 node_modules]
        I2 --> I3[根据 bin 字段生成 .bin 下可执行文件]
    end

    subgraph Run [pnpm run dev 或 build]
        R1[查找 scripts.dev 或 scripts.build] --> R2[PATH 前追加 node_modules/.bin]
        R2 --> R3[子 shell 执行脚本命令]
        R3 --> R4[解析 vite 等到 .bin 对应 wrapper]
    end

    I3 -.->|install 完成后 .bin 就绪| R4

4.4 pnpm run 流程简图(环境 + 生命周期)

flowchart TD
    subgraph Env [环境准备]
        E1[确定当前包] --> E2[找 scripts.scriptName]
        E2 --> E3[PATH += node_modules/.bin]
        E3 --> E4[Workspace 时 PATH += 根 node_modules/.bin]
    end

    subgraph Lifecycle [生命周期]
        L1[pre scriptName] --> L2[scriptName]
        L2 --> L3[post scriptName]
    end

    Env --> Lifecycle
    Lifecycle --> Spawn[在子 shell 中执行命令]
    Spawn --> Exit[退出码决定 pnpm run 成功/失败]

4.5 devbuild 在「run」层面的区别

  • 执行机制完全相同:都是 pnpm run <script>,只是 <script> 名字不同。
  • 差异来自你在 scripts 里写的命令,例如:
    • dev:常为 vitenext devwebpack serve长期进程
    • build:常为 vite buildnext buildtsc一次性构建
  • Pre/post:若你配置了 predev / postdevprebuild / postbuild,会按顺序跑;没配则只跑本体。

4.6 小结

  • pnpm run dev / pnpm run build = 找 script → 可能 pre → 本体 → 可能 post;执行前把 node_modules/.bin 等加入 PATH,在子 shell 中跑对应命令。
  • node_modules/.bin 里的可执行文件由 依赖包bin 字段生成,pnpm 在 install 阶段已经链好;run 只负责 查 script、改 PATH、调起这些 bin

五、三者之间的关系

flowchart LR
    subgraph Install [pnpm install]
        I1[解析依赖] --> I2[store 与链接]
        I2 --> I3[node_modules 就绪]
        I3 --> I4[.bin 可执行文件就绪]
    end

    subgraph Run [pnpm run dev / build]
        R1[读 scripts] --> R2[PATH += .bin]
        R2 --> R3[pre / 本体 / post]
        R3 --> R4[执行 vite / next 等]
    end

    I4 --> R1
  • pnpm install 准备好 node_modulesnode_modules/.binpnpm run dev / pnpm run build 才能正确找到 vitenext 等命令。
  • 未安装就 run,通常会报 找不到命令Cannot find module

六、常用 flag 与行为

6.1 pnpm install

Flag 作用
--frozen-lockfile 不更新 lockfile;若与 package.json 不一致则失败
--lockfile-only 只更新 pnpm-lock.yaml,不写 node_modules
--prefer-offline 尽量用 store,缺的再拉
--offline 只用 store,不访问 registry

6.2 pnpm run

Flag 作用
--silent 少打日志
--prefix <path> 以指定目录为包根(找 package.json

dev / build 本身没有专属 flag;传参会透传给脚本,例如:

pnpm run build -- --mode production

-- 后面的 --mode production 会交给 scripts.build 对应的命令。


七、参考

用 codex AI 更新了下之前写的浏览器云书签标签页扩展

之前的文章

juejin.cn/post/749309…

开源地址

github.com/wumingluren…

image.png

image.png

image.png

最近重新做了 ui 还支持了换肤功能,复刻了一份 Omni 功能。

主要是玩玩 codex 的各种 skill。

之前还有配套的导航站,还没有升级 ui ,下一步就准备升级一下。

下面内容由 AI 生成。

这半个月,我们把无名云书签往前推了一大步

这段时间,无名云书签做了几次很关键的更新。

如果用一句话来概括,就是我们不再只满足于“把书签存起来”,而是开始认真把它做成一个真正顺手、能每天打开就用的浏览器工具。

最近,这个项目主要往前走了四步:重做了新标签页、做出了 Omni 命令面板的第一个可用版本、统一了开发工具链,也顺手解决了一个很影响体验的样式污染问题。

新标签页,不想再只是一个“列表页”

最先动刀的是新标签页。

以前的新标签页更偏功能导向,能用,但谈不上舒服。打开之后,你能看到推荐书签、能搜内容,但整体更像一个功能页,而不是一个你愿意长期停留的导航页。

这次改版,我们把它从“一个能看书签的页面”,往“一个真正能承接日常访问入口的首页”推进了一步。

新的版本里,页面结构被重新梳理了。搜索、推荐内容、反馈状态都被放进了更统一的视觉层级里,信息密度更高,但阅读负担反而更低。空状态、骨架屏、错误提示这些以前容易被忽略的细节,这次也都补上了。你在加载、搜索、无结果这些场景下,终于能明确知道系统现在在做什么。

另一个比较明显的变化,是换肤。

这次新标签页内置了 5 套主题风格,支持直接切换,并且会保存你的选择。我们希望它不是一个“只能用默认样式”的工具页,而是一个你可以按自己的习惯留在浏览器里的空间。无论你喜欢偏清爽、偏冷静,还是更适合夜间使用的风格,现在都有了一个更自然的落点。

对项目内部来说,这次改版也不只是改了外观。像 BookmarkItemThemeSwitcheruseNewTabThemethemes 这些模块被单独拆出来之后,后面不管是继续补主题、加模块,还是调整布局,都会轻松很多。

Omni 命令面板,终于有了第一个能打的版本

如果说新标签页解决的是“打开浏览器之后”的体验,那 Omni 命令面板解决的,就是“在任何页面里,怎么更快完成操作”。

最近,我们把 Omni 命令面板的 MVP 做出来了。

现在你可以直接通过 Cmd/Ctrl + Shift + K 呼出这个面板,不需要先切到某个固定页面,也不用绕到设置页或者侧边栏里找入口。它更像一个悬浮在浏览器里的统一操作台。

这个版本最核心的能力,是把原本分散的东西聚合到了一起。

你可以在同一个输入框里同时搜:

  • 飞书云书签
  • 当前浏览器标签页
  • 浏览器书签
  • 浏览历史
  • 常用快捷动作
  • 最近使用记录

这件事听起来简单,但它实际改变的是操作路径。以前你可能需要先想“我要去哪里找这个东西”,现在是先输入,再从结果里选。这个心智负担小很多,尤其在书签变多、标签页变多之后,差别会特别明显。

为了让搜索更顺手,这次也加了命令前缀能力。比如你想只查标签页,可以直接输入 /tabs;只查云书签,可以用 /feishu;只看历史记录、动作、浏览器书签,也都可以快速切换。它不是一个复杂的命令系统,但已经足够把“全局搜索”变成“可控搜索”。

除了搜,Omni 现在也能直接做事。

这个版本已经支持一组比较高频的快捷动作,比如打开新标签页、打开扩展设置、打开侧边栏、把当前页面存到飞书、存到浏览器书签、关闭当前标签页、关闭其他标签页、关闭右侧标签页、固定标签页、静音标签页,以及把当前输入直接交给默认搜索引擎。

为了避免误操作,危险动作还加了二次确认。设置页里也补了对应的 Omni 配置项,可以控制开关、搜索来源、每组显示结果数量,以及是否保留危险动作确认。

这一步对无名云书签来说挺重要。因为从这里开始,它不只是一个“存书签”的扩展,而是在往“浏览器里的个人信息入口”走。

修掉一个很小,但很烦的问题

最近,我们修了一个看起来不大、但实际很影响观感的问题:内容脚本的样式会污染宿主页面。

这个问题的本质是,扩展注入页面时使用的样式里,有一些通用类名,比如 .hidden.flex 这类工具类。如果它们被直接挂到宿主页面环境里,就有可能影响原网站自己的样式表现。

这类问题往往不容易第一时间被发现,因为它不一定是“页面直接坏掉”,很多时候只是某些站点会变得怪怪的。但一旦用户碰到,体感会很差,而且锅最后还是会落到扩展头上。

这次的处理方式,是不再通过 manifest 直接把内容脚本样式注入宿主页面,而是改成在运行时加载到 Shadow DOM 里。这样扩展自己的界面还能保留原来的样式能力,但不会继续往外泄漏。

同时,这次也补了一条测试,专门保证这个行为不会再被后续改动带回来。

这种改动不一定会出现在截图里,也不一定会成为“新功能”被感知到,但它会直接决定扩展是不是一个足够克制、足够可靠的工具。

顺手把开发链路也理顺了

除了功能本身,这轮还有一件更偏工程化的事情一起做了:项目里的命令和文档,统一切到了 pnpm

这包括 README 里的安装、开发、构建、打包说明,也包括项目脚本本身的调用方式。package-lock.json 被移除,pnpm-lock.yaml 成为了当前唯一的锁文件。

这个变化对普通使用者几乎没有感知,但对后续维护很有帮助。至少从现在开始,这个项目在“文档怎么写”和“实际怎么跑”这件事上是一致的,新同学接手时也不会一上来就踩到工具链不统一的问题。

这轮更新之后,无名云书签更像什么了

如果说以前的无名云书签,核心价值是“把书签放进飞书里”,那这轮更新之后,它开始更像一个围绕书签展开的浏览器工作台。

你可以在新标签页里更舒服地浏览和进入内容,也可以在任何网页里直接拉起命令面板,搜索、跳转、保存、整理。当这些入口被串起来之后,书签管理就不再只是“存档”,而更接近“随时可用的个人知识入口”。

这也是接下来这个项目更值得继续做下去的地方。

后面应该还会继续补 Omni 的能力、磨新标签页的细节,也把一些现在已经能用但还不够顺的部分继续打磨下去。至少从这半个月来看,无名云书签已经不再停留在“能用”的阶段,而是在慢慢往“好用、愿意一直用”靠近。

我从瑞幸咖啡小程序里,拆出了一套 22 个组件的开源 UI 库

我从瑞幸咖啡小程序里,拆出了一套 22 个组件的开源 UI 库

把它的设计语言完整提炼出来,做成了一个可以直接 npm install 的微信小程序组件库。

效果截图

Snipaste_2026-04-15_16-25-37.jpg

Snipaste_2026-04-15_16-27-47.jpg

Snipaste_2026-04-15_16-27-15.jpg

Snipaste_2026-04-15_16-26-37.jpg

Snipaste_2026-04-15_16-26-20.jpg

Snipaste_2026-04-15_16-26-00.jpg

克制的双色系统(蓝+橙),无阴影的卡片层次,菜单页那个从 + 按钮展开到数量步进器的丝滑交互,会员卡页面方案选择器的信息架构……这些细节放在一起,构成了一套非常完整且高辨识度的设计语言。

项目叫 LKCN UI,22 个组件,纯原生微信小程序自定义组件,零依赖,原生 / Taro / uni-app 项目都能直接用。

GitHub: https://github.com/user/lkcn-ui

色彩系统

瑞幸全局只用 两个强调色

色值 用途 使用场景
#1A6EFF Brand Blue 交互元素 TabBar 激活态、按钮、加购圆钮、链接
#FF6B35 Accent Orange 促销与价格 价格数字、CTA 按钮、Badge、优惠券

辅助色包括会员金 #C8A26E、即享绿 #2B7D5B、咖啡棕 #3D2D1F

一个重要发现:瑞幸的卡片没有阴影。整个 App 的层次感完全靠圆角 + 间距 + 背景色差来实现,这使得渲染性能非常好,也让整体视觉特别干净。

字体体系

价格是瑞幸 UI 最有辨识度的元素。它把价格拆成了三段不同大小的文字:

¥(小号加粗) 9(大号加粗) .9(小号加粗)  ¥32(小号灰色删除线)

这种「符号小、整数大、小数小」的层次处理让价格数字极具视觉冲击力,同时原价的删除线灰色处理制造了强烈的价差感知。我在 lkcn-price 组件里完整还原了这个效果。

间距与圆角

间距体系是标准的 8px 递增:4 / 8 / 12 / 16 / 24 / 32(rpx 翻倍)。

圆角有 5 级:4px(标签)→ 8px(按钮、输入框)→ 12px(卡片)→ 20px(弹窗)→ 999px(胶囊)。

所有 Token 都通过 CSS 变量注入,覆盖变量即可全局换肤:

page {
  --lkcn-blue: #1A6EFF;
  --lkcn-orange: #FF6B35;
  --lkcn-radius-md: 24rpx;
  /* ... 60+ 个变量 */
}

22 个组件一览

全部组件从瑞幸小程序的真实页面中提取,不是凭空设计的:

基础组件: Button(6 种类型 × 3 尺寸)、Tag(4 类型 × 4 颜色)、Price(整数/小数自动拆分)、Badge、Avatar

布局容器: Card、Grid(3/4/5 列自适应)、Swiper(胶囊形指示点)、CouponScroll、PromoCard

导航: TabBar(safe-area 适配)、Tabs(滑动下划线)、SegmentControl、SearchBar、CategorySidebar、LocationBar

业务组件: ProductCard(菜单列表项)、Stepper(折叠→展开态)、LevelCard(会员等级)、MembershipPlan(订阅方案选择)、NoticeBar、FloatingButton

1. Stepper:瑞幸的加购交互

瑞幸菜单页的加购交互是我见过最优雅的——数量为 0 时只显示一个蓝色 + 圆钮,点击后展开为 [-] [数字] [+] 三段式控件。

<!-- 使用方式 -->
<lkcn-stepper value="{{count}}" bind:change="onChange" />

组件内部的关键判断:

<!-- value <= min 时只显示 + 按钮 -->
<view wx:if="{{value <= min}}" class="lkcn-stepper__add lkcn-stepper__add--solo">
  <text class="lkcn-stepper__icon">+</text>
</view>
<!-- 否则展开完整控件 -->
<view wx:else class="lkcn-stepper__controls">
  <!-- [-] [count] [+] -->
</view>

加购按钮的 scale(0.88) + cubic-bezier(0.34, 1.56, 0.64, 1) 弹性回弹动画让点击手感特别好。

2. Price:三段式价格渲染

<lkcn-price value="9.9" original="32" prefix="预估到手" />

组件自动将 9.9 拆分为整数 9 和小数 .9,分别用不同字号渲染,currency symbol ¥ 用小号加粗。这种处理在电商类小程序里非常实用,直接拿去用就行。

3. CategorySidebar:菜单页左侧导航

这个组件还原了菜单页左侧的完整细节——激活态的白色背景、左侧橙色指示条、分类标签(新品产地季苦瓜轻体),以及新品小红点。

<lkcn-category-sidebar
  categories="{{categories}}"
  active="{{catActive}}"
  height="100vh"
  bind:change="onCatChange"
/>

数据结构支持纯文字和对象两种格式:

categories: [
  '人气Top',                           // 纯文字
  { text: '周边NEW', tag: '周边NEW', tagColor: 'blue' },  // 带标签
  { text: '果C美式', tag: '苦瓜轻体', tagColor: 'green', dot: true },
]

4. MembershipPlan:会员方案选择器

会员卡页面底部那个方案选择 + 订阅 CTA + 协议勾选的完整流程,一个组件搞定:

<lkcn-membership-plan
  plans="{{plans}}"
  active="{{planActive}}"
  agreement="开通会员代表接受"
  agreement-links="{{[{text:'《服务协议》'}, {text:'《续费说明》'}]}}"
  bind:subscribe="onSubscribe"
/>

为什么选原生而不是 Taro / uni-app

这是我在开发前做的一个关键决策。核心理由就一个——受众最大化

原生微信小程序自定义组件能被所有技术栈引入:

原生组件 → 原生项目 ✅、uni-app 项目 ✅、Taro 项目 ✅
uni-app 组件 → 只有 uni-app 能用 ❌
Taro 组件 → 只有 Taro 能用 ❌

uni-app 引入原生组件只需要放到 wxcomponents/ 目录,在 pages.json 注册即可。Taro 也类似。写一份代码三个生态都能吃到,这是 Vant Weapp 走过的路。

快速上手

npm install lkcn-ui

在微信开发者工具中构建 npm,然后注册组件:

{
  "usingComponents": {
    "lkcn-button": "lkcn-ui/button/index",
    "lkcn-price": "lkcn-ui/price/index",
    "lkcn-product-card": "lkcn-ui/product-card/index"
  }
}

直接使用:

<lkcn-button type="primary" round>立即下单</lkcn-button>

<lkcn-product-card
  image="/images/coconut-latte.png"
  title="生椰拿铁(首创)"
  tags="{{['全球销量第一', 'IIAC金奖']}}"
  price="9.9"
  original-price="32"
  bind:add="onAddToCart"
/>

也可以不用 npm,直接把 packages/ 下需要的组件目录复制到你的项目里。

换肤

所有视觉变量都通过 CSS 变量控制,覆盖即可适配你自己的品牌:

page {
  --lkcn-blue: #7C3AED;    /* 换成你的品牌紫 */
  --lkcn-orange: #F59E0B;  /* 换成你的品牌黄 */
  --lkcn-radius-md: 32rpx; /* 更大的圆角 */
}

不需要改任何组件源码,Design Token 体系的优势就在这里。

项目数据

  • 22 个组件,全部完成
  • 143 个源文件
  • 0 外部依赖
  • 每个组件 4 件套(wxml / wxss / js / json)
  • 60+ Design Token CSS 变量
  • 11 个可交互 demo 页面
  • 包体积 < 90KB(未压缩)

后续计划

  • 组件 TypeScript .d.ts 类型声明
  • VitePress 文档站
  • 暗色模式适配
  • GitHub Actions CI 自动发布

如果你也觉得有用,欢迎 Star:

GitHub: https://github.com/user/lkcn-ui

单例模式渐进式学习指南

单例模式渐进式学习指南

面向前端开发者,从“看懂概念”到“能写能辨别”,一步步掌握设计模式中的单例模式。


目录

  1. 什么是单例模式?
  2. 为什么前端里需要单例?
  3. 先从最小例子理解“唯一实例”
  4. 单例模式的标准结构
  5. 前端中常见的单例场景
  6. 几种常见实现方式
  7. 单例模式的优点与缺点
  8. 使用单例时的常见误区
  9. 面试中怎么回答单例模式
  10. 练习题与思考题
  11. 学习总结

一、什么是单例模式?

单例模式(Singleton Pattern)是一种创建型设计模式

它的核心目标只有一句话:

保证一个类、一个对象工厂、或一个功能模块在系统中只有一个实例,并提供一个全局访问点。

你可以把它理解成:

  • 系统里这个对象只能创建一次
  • 后面再获取时,拿到的都是同一个对象
  • 大家共用它,而不是每次都 new 一个新的

生活类比

可以把单例想象成:

  • 浏览器里的 window
  • 页面中的全局配置中心
  • 整个项目里唯一的消息提示组件管理器
  • 唯一的缓存中心

这些东西通常不需要来一个人就建一个新的,否则系统会乱套。

单例的两个关键词

关键词 含义
唯一实例 无论调用多少次,都只有一个对象
全局访问 任何需要它的地方都能拿到同一个对象

二、为什么前端里需要单例?

很多初学者会有个疑问:

前端不就是写页面吗?为什么还要学设计模式?

其实前端项目一旦变大,就会出现很多“全局唯一资源”的问题。

常见需求

  • 全局只有一个登录弹窗
  • 全局只有一个消息通知容器
  • 全局只有一个请求管理器
  • 全局只有一个事件总线实例
  • 全局只有一个缓存对象
  • 全局只有一个状态管理容器入口

如果每次使用都重新创建:

  • 会造成资源浪费
  • 会引发状态不一致
  • 会让调试复杂度上升
  • 甚至会出现界面重复渲染、重复请求等问题

一个典型问题

比如你写一个全局弹窗:

function createModal() {
  return {
    show() {
      console.log('弹窗打开')
    },
  }
}

const modal1 = createModal()
const modal2 = createModal()

console.log(modal1 === modal2) // false

这里 modal1modal2 不是同一个对象。

这意味着:

  • 你可能创建了多个弹窗实例
  • 每个实例的状态互不相通
  • 页面上可能冒出多个重复弹窗

这时候,单例模式就登场了。


三、先从最小例子理解“唯一实例”

普通写法:每次都创建新对象

function createUserStore() {
  return {
    name: 'frontend-store',
  }
}

const store1 = createUserStore()
const store2 = createUserStore()

console.log(store1 === store2) // false

单例写法:始终返回同一个对象

function createSingleUserStore() {
  let instance = null

  return function () {
    if (!instance) {
      instance = {
        name: 'frontend-store',
      }
    }

    return instance
  }
}

const getStore = createSingleUserStore()

const store1 = getStore()
const store2 = getStore()

console.log(store1 === store2) // true

这段代码发生了什么?

核心在这里:

let instance = null

它会把第一次创建出来的对象缓存起来。

后续再调用时:

  • 如果 instance 不存在,就创建
  • 如果 instance 已存在,就直接返回

于是无论调用多少次,拿到的都是同一个对象。

一句话:单例不是“不让你调用”,而是“让你重复调用时仍然拿到同一个实例”。


四、单例模式的标准结构

虽然前端里未必真的写“类”,但你最好知道它的标准思想。

结构拆解

一个典型的单例通常包含 3 个部分:

  1. 私有实例缓存:记录是否已经创建过对象
  2. 创建逻辑:第一次使用时创建对象
  3. 访问入口:外部通过统一方法获取实例

用类的方式理解

class Singleton {
  constructor() {
    if (Singleton.instance) {
      return Singleton.instance
    }

    this.data = '唯一实例'
    Singleton.instance = this
  }
}

const s1 = new Singleton()
const s2 = new Singleton()

console.log(s1 === s2) // true

更推荐前端中理解成“模块级唯一对象”

在现代前端中,很多单例并不是通过 class 写出来的,而是通过 模块缓存机制 自然形成的。

// config.js
const config = {
  apiBaseUrl: 'https://api.example.com',
  timeout: 5000,
}

export default config
// a.js
import config from './config.js'

// b.js
import config from './config.js'

因为 ES Module 会缓存模块实例,所以多个文件导入同一个模块时,通常拿到的是同一份模块对象。

这也是前端里最常见、最自然的“单例感”来源。


五、前端中常见的单例场景

这一部分最重要,因为真正写业务时,你不是为了“背定义”而用单例,而是为了解决全局唯一资源管理问题

1. 全局消息提示(Message / Toast)

很多 UI 库里的全局提示本质就是单例。

class Message {
  constructor() {
    this.queue = []
  }

  show(text) {
    this.queue.push(text)
    console.log('消息:', text)
  }
}

let messageInstance = null

export function getMessageInstance() {
  if (!messageInstance) {
    messageInstance = new Message()
  }

  return messageInstance
}

使用时:

const message1 = getMessageInstance()
const message2 = getMessageInstance()

message1.show('保存成功')
console.log(message1 === message2) // true

2. 全局弹窗管理器

如果每点击一次按钮都创建一个弹窗管理器,页面就可能出现多个重复节点。

单例的好处是:

  • 整个应用只维护一个弹窗容器
  • 状态统一管理
  • DOM 节点不会重复创建

3. 请求管理器 / API 客户端

比如你封装了一个请求实例:

class RequestService {
  constructor(baseURL) {
    this.baseURL = baseURL
  }

  get(url) {
    console.log(`GET: ${this.baseURL}${url}`)
  }
}

let requestInstance = null

export function getRequestService() {
  if (!requestInstance) {
    requestInstance = new RequestService('/api')
  }

  return requestInstance
}

这样做可以统一:

  • baseURL
  • 请求拦截器
  • token 注入
  • 错误处理策略

4. 缓存中心

const cache = {
  data: new Map(),
  set(key, value) {
    this.data.set(key, value)
  },
  get(key) {
    return this.data.get(key)
  },
}

export default cache

这本质上也是一个单例对象。

5. 状态共享对象

某些轻量项目不用 Pinia / Redux,也会自己写一个全局 store。

const store = {
  state: {
    userInfo: null,
  },
  setUser(user) {
    this.state.userInfo = user
  },
}

export default store

所有页面共享同一份 store,这就是一种模块单例。


六、几种常见实现方式

方式一:闭包实现单例(最适合入门)

function createSingleton() {
  let instance = null

  return function () {
    if (!instance) {
      instance = {
        id: Date.now(),
      }
    }
    return instance
  }
}

const getInstance = createSingleton()
const obj1 = getInstance()
const obj2 = getInstance()

console.log(obj1 === obj2) // true
优点
  • 容易理解
  • 不依赖 class
  • 很适合讲清“缓存实例”的本质
缺点
  • 如果逻辑复杂,代码可维护性一般

方式二:类 + 静态属性

class LoginDialog {
  static instance = null

  constructor() {
    if (LoginDialog.instance) {
      return LoginDialog.instance
    }

    this.visible = false
    LoginDialog.instance = this
  }

  open() {
    this.visible = true
    console.log('登录弹窗打开')
  }
}

const dialog1 = new LoginDialog()
const dialog2 = new LoginDialog()

console.log(dialog1 === dialog2) // true
优点
  • 结构清晰
  • 更贴近传统设计模式写法
  • 适合面试表达
缺点
  • 对前端业务代码来说有时略显“重”

方式三:模块单例(现代前端最常见)

// auth-store.js
const authStore = {
  token: '',
  setToken(token) {
    this.token = token
  },
}

export default authStore
import authStore from './auth-store.js'
为什么它是单例?

因为模块只会初始化一次,后续导入拿到的是同一个模块实例引用。

优点
  • 写法最自然
  • 非常适合工程化项目
  • 不需要显式写 getInstance
缺点
  • 初学者可能“用了单例却没意识到自己在用单例”

方式四:惰性单例(Lazy Singleton)

惰性单例指的是:

不在一开始就创建实例,而是在第一次真正需要时才创建。

function getModal() {
  if (!getModal.instance) {
    getModal.instance = {
      createdAt: Date.now(),
      show() {
        console.log('显示 modal')
      },
    }
  }

  return getModal.instance
}

这种方式很常见,因为很多全局对象并不一定在页面加载时就需要。

惰性单例的意义

  • 减少初始加载开销
  • 按需创建资源
  • 更适合弹窗、通知、复杂组件容器

七、单例模式的优点与缺点

任何设计模式都不是“银弹”,单例也一样。

优点

1. 节省资源

只创建一次对象,避免重复初始化。

2. 统一状态管理

所有地方访问的都是同一份实例,状态天然一致。

3. 便于全局协调

适合处理:

  • 全局配置
  • 全局弹窗
  • 全局缓存
  • 全局事件中心
4. 减少重复代码

不必每次都手动创建和管理相同对象。

缺点

1. 全局状态过多会让系统变复杂

一旦所有东西都做成单例,项目就会慢慢变成“全局变量乐园”。这可不是什么嘉年华。

2. 测试不友好

单例在测试中容易产生状态污染。

比如:

  • 上一个测试改了实例状态
  • 下一个测试拿到的还是同一个实例
  • 测试之间互相影响
3. 模块耦合增强

很多模块都依赖某个全局单例时,重构会变困难。

4. 容易被滥用

不是“全局都能访问”就该用单例,只有确实应该全局唯一时才适合。


八、使用单例时的常见误区

误区 1:把普通工具函数也做成单例

比如一个纯函数工具库:

function formatDate(date) {
  return String(date)
}

这种函数没有状态,不需要单例。

没有状态、没有初始化成本、没有唯一资源约束的对象,通常没必要单例化。

误区 2:把“全局可访问”误认为“必须单例”

全局可访问 ≠ 必须只有一个实例。

比如:

  • 表单校验器可能每个表单都应该有独立实例
  • 图表对象可能每个图表容器都应该各自创建

误区 3:忽略实例重置能力

在测试或热更新环境中,有些单例需要支持重置,否则状态会残留。

let instance = null

export function getInstance() {
  if (!instance) {
    instance = { count: 0 }
  }
  return instance
}

export function resetInstance() {
  instance = null
}

误区 4:把单例当作“解决一切共享问题”的万能方案

如果共享状态越来越复杂,应该考虑:

  • 状态管理库(Pinia / Redux / Zustand)
  • 依赖注入
  • 组合式函数(composables)
  • 上下文容器

单例是工具,不是宗教。


九、面试中怎么回答单例模式

如果面试官问:

你怎么理解单例模式?前端中有哪些应用?

你可以这样回答:

标准回答模板

单例模式是一种创建型设计模式,核心是保证某个对象在系统中只有一个实例,并提供统一的访问入口。

在前端开发中,它常用于管理全局唯一资源,比如:

  • 全局弹窗
  • 消息提示组件
  • 请求实例
  • 缓存对象
  • 全局配置对象

实现方式通常有:

  • 闭包缓存实例
  • 类的静态属性保存实例
  • ES Module 天然单例

它的优点是节省资源、统一状态;缺点是容易带来全局耦合、测试困难,因此要谨慎使用,避免滥用。

如果面试官继续追问:ES Module 算不算单例?

你可以回答:

在工程实践里,很多模块导出的对象会因为模块缓存机制而表现出单例特征,所以它是一种非常常见的“模块级单例”实现方式。

如果继续追问:单例和全局变量有什么区别?

你可以回答:

  • 全局变量只是“所有地方都能访问”
  • 单例模式强调“唯一实例 + 可控访问入口 + 创建时机管理”

所以单例比裸露的全局变量更有结构,也更便于维护。


十、练习题与思考题

练习 1:实现一个单例缓存对象

要求:

  • 只能创建一个缓存实例
  • 提供 setget 方法

你可以自己先暂停 5 分钟写一下,再参考下面思路:

function createCacheSingleton() {
  let instance = null

  return function () {
    if (!instance) {
      instance = {
        data: new Map(),
        set(key, value) {
          this.data.set(key, value)
        },
        get(key) {
          return this.data.get(key)
        },
      }
    }

    return instance
  }
}

练习 2:实现一个全局登录弹窗管理器

要求:

  • 整个应用中只能有一个登录弹窗实例
  • 支持 open()close()

练习 3:思考哪些场景不适合单例

请判断以下对象是否适合单例,并说明理由:

  • 每个页面一个轮播图实例
  • 全局埋点上报管理器
  • 每个表格一个筛选器对象
  • 全局请求客户端
  • 每个图表一个图表实例

参考答案方向

对象 是否适合单例 原因
每个页面一个轮播图实例 不适合 每个轮播图通常是独立的
全局埋点上报管理器 适合 全局统一上报规则和缓存队列
每个表格一个筛选器对象 不适合 每个表格状态独立
全局请求客户端 适合 请求配置、拦截器应统一
每个图表一个图表实例 不适合 每个容器对应独立实例

十一、学习总结

你应该记住的 4 句话

  1. 单例模式的核心是:一个实例、全局访问。
  2. 前端中凡是“全局唯一资源”,都值得考虑单例。
  3. 现代前端里最常见的单例形式,其实是模块单例。
  4. 不要滥用单例,能局部化的状态就不要硬塞成全局。

一张速记表

问题 结论
单例模式是什么? 保证对象只有一个实例
适合什么场景? 全局配置、消息提示、请求实例、缓存中心
常见实现方式? 闭包、类静态属性、ES Module
最大风险是什么? 全局耦合、状态污染、测试困难
判断标准是什么? 这个对象是否真的应该全局唯一

git cherry-pick Command: Apply Commits from Another Branch

Sometimes the change you need is already written, just on the wrong branch. A hotfix may land on main when it also needs to go to a maintenance branch, or a useful commit may be buried in a feature branch that you do not want to merge wholesale. In that situation, git cherry-pick lets you copy the effect of a specific commit onto your current branch.

This guide explains how git cherry-pick works, how to apply one or more commits safely, and how to handle the conflicts that can appear along the way.

Syntax

The general syntax for git cherry-pick is:

txt
git cherry-pick [OPTIONS] COMMIT...
  • OPTIONS - Flags that change how Git applies the commit.
  • COMMIT - One or more commit hashes, branch references, or commit ranges.

git cherry-pick replays the changes introduced by the selected commit on top of your current branch. Git creates a new commit, so the result has a different commit hash even when the file changes are the same.

Cherry-Picking a Single Commit

Start by finding the commit you want to copy. This example lists the recent commits on a feature branch:

Terminal
git log --oneline feature/auth
output
a3f1c92 Fix null pointer in auth handler
d8b22e1 Add login form validation
7c4e003 Refactor session logic

The output gives you the abbreviated commit hashes. In this case, a3f1c92 is the fix we want to move.

Switch to the target branch before running git cherry-pick:

Terminal
git switch main
git cherry-pick a3f1c92
output
[main 9b2d4f1] Fix null pointer in auth handler
Date: Tue Apr 14 10:42:00 2026 +0200
1 file changed, 2 insertions(+)

Git applies the change from a3f1c92 to main and creates a new commit, 9b2d4f1. The subject line is the same, but the commit hash is different because the parent commit is different.

Cherry-Picking Multiple Commits

If you need more than one non-consecutive commit, pass each hash in the order you want Git to apply them:

Terminal
git cherry-pick a3f1c92 d8b22e1

Git creates a separate new commit for each one. This works well when you need a few targeted fixes but do not want the rest of the source branch.

For a range of consecutive commits, use the range notation:

Terminal
git cherry-pick a3f1c92^..7c4e003

This tells Git to include a3f1c92 and every commit after it up to 7c4e003. If you omit the caret, the starting commit itself is excluded:

Terminal
git cherry-pick a3f1c92..7c4e003

That form applies every commit after a3f1c92 through 7c4e003.

Applying Changes Without Committing

Sometimes you want the changes from a commit, but not an automatic commit for each one. Use --no-commit (or -n) to apply the changes to your working tree and staging area without creating the commit yet:

Terminal
git cherry-pick --no-commit a3f1c92

This is useful when you want to combine several small fixes into one commit on the target branch, or when you need to edit the files before committing.

After reviewing the result, create the commit yourself:

Terminal
git status
git commit -m "Backport auth null-check fix"

This gives you more control over the final commit message and lets you group related backports together.

Recording Where the Commit Came From

For maintenance branches and backports, it is often helpful to keep a reference to the original commit. Use -x to append the source commit hash to the new commit message:

Terminal
git cherry-pick -x a3f1c92

Git adds a line like this to the new commit message:

output
(cherry picked from commit a3f1c92...)

That extra line makes future audits easier, especially when you need to prove that a fix on a release branch came from a reviewed change on another branch.

Cherry-Picking a Merge Commit

Cherry-picking a regular commit is straightforward, but merge commits need one extra option. Git must know which parent to treat as the main line:

Terminal
git cherry-pick -m 1 MERGE_COMMIT_HASH
  • -m 1 - Use the first parent as the base, which is usually the branch that received the merge.
  • -m 2 - Use the second parent instead.

If you are not sure which parent is which, inspect the history first:

Terminal
git log --oneline --graph

Cherry-picking merge commits is more advanced and easier to get wrong. If the goal is to bring over an entire merged feature, a normal merge is often clearer than cherry-picking the merge commit itself.

Resolving Conflicts

If the target branch has changed in the same area of code, Git may stop and ask you to resolve a conflict:

output
error: could not apply a3f1c92... Fix null pointer in auth handler
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git cherry-pick --continue".

Open the conflicted file and look for the conflict markers:

output
<<<<<<< HEAD
return session.getUser();
=======
if (session == null) return null;
return session.getUser();
>>>>>>> a3f1c92 (Fix null pointer in auth handler)

Edit the file to keep the final version you want, then stage it and continue:

Terminal
git add src/auth.js
git cherry-pick --continue

If you decide the commit is not worth applying after all, abort the operation:

Terminal
git cherry-pick --abort

git cherry-pick --abort puts the branch back where it was before the cherry-pick started.

A Safe Backport Workflow

When you cherry-pick onto a release or maintenance branch, slow down and make the source obvious. A simple workflow looks like this:

Terminal
git switch release/1.4
git pull --ff-only
git cherry-pick -x a3f1c92
git status

The important part is the sequence. Start from the branch that needs the fix, make sure it is up to date, cherry-pick with -x, then review and test the branch before pushing it. This avoids the common mistake of copying a fix into an outdated branch and shipping an untested backport.

If the picked commit depends on earlier refactors or new APIs that are not present on the target branch, stop there. In that case, either copy the prerequisite commits too or recreate the fix manually.

When to Use git cherry-pick

git cherry-pick is a good fit when you need a precise change without the rest of the branch:

  • Backporting a bug fix from main to a release branch
  • Recovering one useful commit from an abandoned feature branch
  • Moving a small fix that was committed on the wrong branch
  • Pulling a reviewed change into a hotfix branch without merging unrelated work

Avoid it when the target branch needs the full context of the source branch. If the commit depends on earlier commits, shared refactors, or schema changes, a merge or rebase is usually the cleaner option.

Troubleshooting

error: could not apply ... during cherry-pick
The target branch has conflicting changes. Resolve the files Git marks as conflicted, stage them with git add, then run git cherry-pick --continue.

Cherry-pick created duplicate-looking history
That is normal. Cherry-pick copies the effect of a commit, not the original object. The new commit has a different hash because it has a different parent.

The picked commit does not build on the target branch
The commit likely depends on earlier work that is missing from the target branch. Inspect the source branch history with git log and either cherry-pick the prerequisites too or reimplement the change manually.

You picked the wrong commit
If the cherry-pick already completed, use git revert on the new commit. If the operation is still in progress, use git cherry-pick --abort.

Quick Reference

For a printable quick reference, see the Git cheatsheet .

Task Command
Pick one commit git cherry-pick COMMIT
Pick several specific commits git cherry-pick C1 C2 C3
Pick a consecutive range including the first commit git cherry-pick A^..B
Apply changes without committing git cherry-pick --no-commit COMMIT
Record the source commit in the message git cherry-pick -x COMMIT
Continue after resolving a conflict git cherry-pick --continue
Skip the current commit in a sequence git cherry-pick --skip
Abort the operation git cherry-pick --abort
Pick a merge commit git cherry-pick -m 1 MERGE_COMMIT

FAQ

What is the difference between git cherry-pick and git merge?
git cherry-pick copies the effect of selected commits onto your current branch. git merge joins two branch histories and brings over all commits that are missing from the target branch.

Does cherry-pick change the original commit?
No. The source commit stays exactly where it is. Git creates a new commit on your current branch with the same file changes.

Should I use -x every time?
Not always, but it is a good habit for backports and maintenance branches. It gives you a clear link back to the original commit.

Can I cherry-pick commits from another remote branch?
Yes. Fetch the remote branch first, then cherry-pick the commit hash you want. You can inspect the incoming history with git log or review the file changes with git diff before applying anything.

Conclusion

git cherry-pick is the tool to reach for when one commit matters more than the branch it came from. Use it for targeted fixes, keep -x in mind for backports, and fall back to merge or rebase when the change depends on broader branch context.

基于 Element Plus 的企业级主题定制方案:SCSS 变量覆盖 + Vite 全局注入实战

前言

前端项目中,UI 组件库的主题定制是一个常见但又容易做"脏"的需求。常见的做法是在组件上疯狂加 !important 覆盖样式——短期有效,长期维护噩梦。

本文基于实际项目(某 SaaS 系统)中的主题定制实践,分享一套规范、可维护、可扩展的 Element Plus 主题定制方案。核心涉及:

  • Vite additionalData 实现 SCSS 全局注入
  • Element Plus SCSS 变量覆盖 API
  • 按钮状态系统设计
  • CSS 变量双层架构
  • 多组件覆盖与渐进式演进

一、问题背景

1.1 为什么需要定制主题?

该SaaS 系统,有以下特点:

  • 多品牌:需要同时支持(蓝色系)和(红色系)两套皮肤
  • 多组件:大量使用 Element Plus 的 Button、Checkbox、Radio、Select、DatePicker 等组件
  • 快速迭代:需要频繁调整主题色,不能每次都改源码

1.2 常见方案的弊端

方案 弊端
直接覆盖 .el-button CSS 类 样式分散、优先级混乱、升级组件库后失效
每个页面单独写样式文件 大量重复、无法复用、难以维护
修改 Element Plus 源码 升级即丢失、不利于长期维护
CSS !important 强行覆盖 优先级战争、样式冲突、维护噩梦

正确的思路:利用 Element Plus 提供的 SCSS 变量覆盖机制,在编译层面定制主题。


二、技术方案:总体架构

2.1 文件结构

src/assets/style/
├── elementPlus/
│   ├── index.scss          # CSS 变量层(:root 定义)
│   ├── theme.scss          # SCSS 变量覆盖层(编译时)
│   ├── button/
│   │   └── button.scss     # 按钮专项覆盖
│   ├── checkbox/
│   │   └── checkbox.scss
│   ├── date/
│   │   └── date.scss
│   └── select/
│       └── select.scss
└── common.less             # 全局通用样式(含 .theBtn 等)

2.2 两层变量架构

┌─────────────────────────────────────────────────────┐
│  Layer 1:SCSS 变量(编译时)                        │
│  theme.scss @forward 'element-plus/theme-chalk/...'  │
│  覆盖 Element Plus 内部的 $colors / $button / $checkbox │
│  ↓ 生成 CSS Custom Properties(--el-color-primary 等) │
├─────────────────────────────────────────────────────┤
│  Layer 2:CSS 变量(运行时)                          │
│  index.scss :root { --xx-button-text-color: ... }   │
│  覆盖 Element Plus 组件未覆盖到的自定义变量            │
│  ↓ 被 button.scss / common.less 等直接引用            │
├─────────────────────────────────────────────────────┤
│  Layer 3:组件专项覆盖                                │
│  button.scss / checkbox.scss 等                      │
│  处理组件内部特殊的、变量系统覆盖不到的状态            │
└─────────────────────────────────────────────────────┘

三、Vite 全局注入:additionalData

3.1 核心配置

这是整个方案的根基。在 vite.config.js 中配置:

// vite.config.js
export default defineConfig(({ mode }) => {
  return {
    // ... 其他配置
    css: {
      devSourcemap: true,  // 开发时保留 sourcemap 方便调试
      preprocessorOptions: {
        scss: {
          additionalData: `
            @use "@/assets/style/elementPlus/theme.scss" as *;
            @use "@/assets/style/elementPlus/index.scss" as *;
          `,
        },
      },
    },
  }
})

3.2 工作原理

additionalData 的作用是:在编译每个 SCSS 文件时,自动将指定内容 prepend 到文件头部

等效于在项目的每一个 SCSS 文件首行都自动插入了这两行 import:

@use "@/assets/style/elementPlus/theme.scss" as *;
@use "@/assets/style/elementPlus/index.scss" as *;

好处

  1. 零侵入:业务组件无需手动 import 主题文件
  2. 强一致性:所有文件引用同一套变量,不存在版本不一致
  3. 编译时展开:变量在编译时展开,运行时零开销

3.3 main.js 中的入口处理

同时在 main.js 中移除 Element Plus 默认全量 CSS,替换为按需覆盖:

import ElementPlus from 'element-plus'
- import 'element-plus/dist/index.css'  // 全量默认样式,移除
+ import '@/assets/style/index.scss'      // 替换为按需覆盖

效果:不加载 Element Plus 几十 KB 的默认 CSS,通过 SCSS 变量按需生成样式,减小产物提及。


四、Element Plus SCSS 变量覆盖

4.1 核心文件 theme.scss

// 覆盖 element-plus/theme-chalk/src/common/var.scss 中的变量
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
  $colors: (
    'primary':   ('base': #409eff),   // 主色
    'success':   ('base': #4CAF50),   // 成功绿
    'warning':   ('base': #D87214),   // 警告橙
    'error':     ('base': #f56c6c),   // 错误红
    'info':      ('base': #909399),   // 信息灰
  ),
  $font-family: (
    (''): "'PingFangSC-Regular, PingFang SC', Helvetica, 'PingFang SC', 'Hiragino Sans GB', Arial, sans-serif"
  ),
  $checkbox: (
    (
      'border-radius': '4px',
      'checked-text-color': #409eff,
      'checked-input-border-color': #409eff,
      'checked-bg-color': #409eff,
      'checked-icon-color': #fff,
      'input-border-color-hover': #409eff,
    )
  ),
  $radio-checked: (
    (
      'icon-color': #409eff,
      'text-color': #409eff,
    )
  ),
  $select: (
    (
      'input-focus-border-color': #2C2836,
    )
  ),
  $input: (
    (
      'focus-border-color': #2C2836,
    )
  ),
  $pagination: (
    (
      'button-bg-color': #fff,
    )
  ),
  $button: ((
    'hover-text-color': #409eff,
    'hover-link-text-color': #409eff,
  )),
);

@use "element-plus/theme-chalk/src/index.scss" as *;

4.2 原理讲解

这段代码的核心是 SCSS 模块系统

@forward 'element-plus/theme-chalk/src/common/var.scss' with (...)
  • @forward:转发 Element Plus 的变量声明文件,但允许在转发时用 with (...) 覆盖其中的默认值
  • with (...) 块中定义的值,会替换 Element Plus 内部的 SCSS 变量(编译时生效)
  • 最终这些变量被 Element Plus 的 SCSS 源码使用,生成对应的 CSS Custom Properties(--el-color-primary 等)

不需要修改 Element Plus 一行源码,通过变量覆盖即可定制主题。


五、按钮状态系统设计

5.1 五态全覆盖

按钮是系统中使用最频繁的组件,五种状态都需要精细控制:

// 默认主题色按钮
.el-button--default {
  color: #fff;
  background-color: #409eff;
  border-color: #409eff;
}

// hover:变浅一度
.el-button.is-link:not(.is-disabled):hover,
.el-button.is-link:not(.is-disabled):focus,
.el-button.is-link:not(.is-disabled):active {
  color: var(--xx-button-text-color-light);
}

// active:按压反馈
.el-button:active {
  outline: 0;
}

// text 按钮 hover:浅色降权
.el-button--text:not(.is-disabled):hover,
.el-button--text:not(.is-disabled):active,
.el-button--text:not(.is-disabled):focus {
  color: var(--xx-button-text-color-light);
}

// disabled:透明度统一降权
.el-button--primary.is-plain.is-disabled,
.el-button--primary.is-plain.is-disabled:hover,
.el-button--primary.is-plain.is-disabled:focus,
.el-button--primary.is-plain.is-disabled:active {
  color: var(--xx-text-color-disabled);  // rgba(44,40,54,0.44)
}

5.2 按钮状态体系总结

状态 色值策略 视觉语义
default --el-color-primary 品牌主色,视觉最强
hover --xx-button-text-color-light 浅一度降权,提示可交互
active outline: 0 消除 Focus 环,按压反馈
disabled rgba(44,40,54,0.44) 透明度降权,禁止交互
link 按钮 hover/active/focus 三态全写 link 类型样式特殊,单独覆盖

5.3 禁用态统一规范

// ❌ 常见错误:每个地方单独写 disabled 样式
.el-button.is-disabled { color: #ccc; }
.el-link.is-disabled { color: #ccc; }

// ✅ 规范做法:统一变量
--xx-text-color-disabled: rgba(44,40,54,0.44);

// ✅ 统一引用
.el-button.is-disabled { color: var(--xx-text-color-disabled); }
.el-link.is-disabled { color: var(--xx-text-color-disabled); }

六、CSS 变量双层架构

6.1 index.scss 中的 :root 定义

@use './checkbox/checkbox.scss' as *;
@use './button/button.scss' as *;
@use './date/date.scss' as *;
@use './select/select.scss' as *;

:root {
  --xx-color-select-primary: #409eff;          // 默认蓝色
  --xx-color-red: #df3419;                      // 主题红
  --xx-color-red-light: #fdf3f1;                // 浅红背景

  --xx-button-text-color: var(--el-color-primary);    // 引用 Element Plus 变量
  --xx-button-text-color-light: var(--el-color-primary);

  --xx-text-color-disabled: rgba(44,40,54,0.44);      // 禁用灰
}

6.2 双层变量的引用关系

SCSS 变量(编译时)
  └─→ theme.scss 中定义
       └─→ 覆盖 Element Plus $colors / $button 等
            └─→ 生成 CSS Custom Properties
                 └─→ --el-color-primary

CSS 变量(运行时)
  └─→ index.scss :root 中定义
       ├─→ --xx-button-text-color: var(--el-color-primary)  ← 引用 SCSS 变量展开后的值
       └─→ --xx-color-red: #df3419                          ← 独立定义

业务组件
  └─→ color: var(--xx-button-text-color)  ← 统一引用入口

6.3 消除硬编码

在迭代过程中,原有代码中大量存在硬编码色值:

// common.less - 修改前
.theBtn {
  color: #2E63FD;  // 硬编码蓝色
}

// 修改后
.theBtn {
  color: var(--el-color-info);  // 引用 CSS 变量,随主题切换
}

统一使用 CSS 变量后,切换主题只需修改 theme.scss 中的 SCSS 变量值,所有引用处自动更新。


七、多组件覆盖清单

组件 覆盖点 覆盖方式
Button hover/active/text/link/disabled 五态 专项 SCSS 文件
Checkbox 圆角、选中色、hover border 专项 SCSS 文件
Radio 选中图标色、文字色 theme.scss $radio-checked
Select focus 边框色 theme.scss $select
DatePicker 今日日期文字色 date.scss
Pagination 分页按钮背景色 theme.scss $pagination

八、多主题切换思路

基于 additionalData 的架构,切换主题色只需要在 vite.config.js 中切换 additionalData 的引用文件:

// vite.config.js
const themeFile = env.VITE_THEME === 'red' 
  ? '@/assets/style/elementPlus/theme-red.scss' 
  : '@/assets/style/elementPlus/theme-blue.scss';

css: {
  preprocessorOptions: {
    scss: {
      additionalData: `
        @use "${themeFile}" as *;
        @use "@/assets/style/elementPlus/index.scss" as *;
      `,
    },
  },
}

只需准备两套 theme-xxx.scss 变量文件,即可实现一键换肤,无需改动业务代码。


九、总结

本文分享的主题定制方案有以下核心要点:

  1. @forward with (...):利用 Element Plus 官方 SCSS 变量覆盖 API,编译时定制主题,不改源码
  2. additionalData:Vite 全局注入机制,确保每个 SCSS 文件零侵入地引用主题变量
  3. 两层变量架构:SCSS 变量(编译时)生成 Element Plus CSS 变量,CSS 变量(:root)作为业务覆盖层
  4. 组件专项覆盖:变量系统覆盖不到的特殊状态,通过专项 SCSS 文件覆盖
  5. 消除硬编码:统一使用 CSS 变量,主题切换零改动

这套方案已在生产环境验证,适用于需要多品牌/多主题切换的企业级 Element Plus 项目。


小程序 web-view 内嵌 H5 的会话管理:Token 失效跳转登录的完整方案

前言

在小程序开发中,<web-view> 组件是承载 H5 页面的重要载体。但小程序和 H5 有各自独立的 Storage,token 并不互通。当 H5 页面的 token 失效时,如何让用户顺利跳转到小程序原生登录页,并在登录成功后原路返回?

本文将以实际项目为例,分享一套完整的跨端会话管理方案,涵盖:

  • Token 失效时从 H5 跳转小程序登录页
  • 登录成功后携带参数原路返回
  • 登录页返回按钮的智能控制

一、问题背景

1.1 会话隔离问题

H5 页面通过小程序 <web-view> 内嵌运行时,存在一个典型的跨端会话隔离问题:

存储 作用域 特点
小程序 Storage 小程序进程 独立存储,生命周期跟随小程序
H5 Storage web-view 进程 独立存储,与小程序不互通

核心矛盾:H5 页面 token 失效时,无法通过 H5 页面直接跳转到小程序原生登录页(wx.miniProgram API 在 web-view 中访问受限),用户被"困在" web-view 里。

1.2 期望的用户旅程

H5 活动页 → token 失效 → Toast 提示 → 跳转小程序登录页 
    → 登录成功 → 携带参数返回 H5 页面 → 正常使用

二、Token 失效跳转小程序登录

2.1 核心方法

// pages/webH5View/index.vue
noValidTokenGoMiniProgramLogin() {
  // 1. Toast 提示用户即将跳转
  uni.showToast({
    title: '登录过期,即将进入登录页面',
    icon: 'none',
    duration: 2 * 1000
  })

  // 2. 判断是否在小程序运行环境
  if (typeof window.__wxjs_environment !== 'undefined' 
      && window.__wxjs_environment === 'miniprogram') {
    setTimeout(() => {
      // 3. 通过 JSSDK 跳转小程序原生登录页
      this.$wx.miniProgram.redirectTo({
        url: `/pages/login/login?isShare=1&back=1&page=${encodeURIComponent(
          '/pages/webH5View/index'
        )}&h5Src=${encodeURIComponent(
          'https://testh5/#/index?isMiniprogram=1'
        )}`
      })
    }, 2000)
  }
}

2.2 关键技术点解析

环境检测

window.__wxjs_environment === 'miniprogram'

这是微信 JSSDK 注入的环境变量,不同于 navigator.userAgent,在 web-view 内是唯一可靠的判断方式。

延迟跳转

setTimeout(() => { ... }, 2000)

给 Toast 足够的展示时间,避免用户感到突兀。

携带 H5 原始地址

h5Src=${encodeURIComponent('H5完整地址')}

h5Src 参数记录 H5 访问地址,登录完成后原路返回。

redirectTo vs navigateTo

方法 行为 适用场景
navigateTo 保留当前页面,推入新页面 普通跳转
redirectTo 关闭当前页面,替换为新页面 登录场景,避免返回键回到失效页面

三、登录成功后的回跳链路

3.1 登录页参数接收

// pages/login/login.vue — onLoad
onLoad(option) {
  this.activityH5 = option.activityH5
  this.goId = option.id
  option.page ? this.goPage = option.page : null
  option.h5Src ? this.h5Src = option.h5Src : null  // 接收 H5 地址

  
  // 处理分享页登录场景
  if (option.isShare && option.isShare === '1') {
    this.isShare = option.isShare
    this.toHideBackButton = true
    // 已登录则跳首页
    if (this.$store.getters.userInfo && this.$store.getters.userInfo.token) {
      uni.switchTab({ url: '/pages/index/index' })
    }
  } else {
    // 根据路由栈长度控制返回按钮
    const pages = getCurrentPages()
    this.toHideBackButton = (pages.length > 1) ? false : true
  }
}

3.2 登录成功回跳逻辑

// pages/login/login.vue — 登录成功后处理
if (this.h5Src) {
  // H5 场景:登录完成 → 打开 web-view 并带入 H5 地址
  uni.redirectTo({
    url: `${decodeURIComponent(this.goPage)}?webViewUrl=${this.h5Src}`
  })
} else {
  // 普通场景:直接跳转目标页面
  uni.redirectTo({ url: decodeURIComponent(this.goPage) })
}

四、WebView 加载时的 Token 校验

4.1 完整的 onLoad 流程

// pages/webH5View/index.vue — onLoad
onLoad(option) {
  this.option = option
  this.isShare = option.isShare === '1' ? '1' : '0'

  if (this.userInfo.token) {
    // 有 token → 先校验有效性
    this.isLoginFunction()
  } else {
    // 无 token → 直接打开 H5(H5 侧会触发登录流程)
    this.isLogin = false
    this.handleUrl(this.option)
  }
}

// 是否登陆判断(调用小程序侧接口校验 token)
async isLoginFunction() {
  try {
    const res = await checkMiniProgramLoginFlag()
    this.isLogin = !!res   // token 有效则复用
  } catch (e) {
    this.isLogin = false  // token 失效触发登录
  }
  this.handleUrl(this.option)
}

4.2 URL 参数拼接策略

handleUrl(option) {
  let url = option.webViewUrl 
    ? decodeURIComponent(option.webViewUrl) 
    : decodeURIComponent(option.url || '')
  
  // H5 访问时 token 取空字符串(因为小程序 token 不在 H5 域下)
  let token = this.isLogin ? this.userInfo.token : ''
  
  let params = [
    'token=' + encodeURIComponent(token || ''),
    'userId=' + (this.userInfo.userId || '')
  ]
  
  let baseUrl = url + (url.indexOf('?') !== -1 ? '&' : '?') + params.join('&')
  
  // 标记来源为小程序,供 H5 页面识别并触发登录流程
  if (baseUrl.indexOf('isMiniprogram') === -1) {
    baseUrl = baseUrl + '&isMiniprogram=1'
  }
  
  this.hrefUrl = baseUrl
}

五、登录页返回按钮智能控制

5.1 问题场景

登录页作为入口页面,如果按返回键:

场景 行为 结果
有历史页面 正常返回上一页 ✅ 正常
无历史页面(直接打开小程序) 返回到空页面 ❌ 体验差

5.2 解决方案

// pages/login/login.vue — onLoad
if (option.isShare && option.isShare === '1') {
  // 分享页进入:强制隐藏返回按钮
  this.toHideBackButton = true
} else {
  // 正常进入:根据路由栈长度智能判断
  const pages = getCurrentPages()
  this.toHideBackButton = (pages.length > 1) ? false : true
}

5.3 导航栏组件

<!-- topnavigation 接收 isfx prop 控制返回按钮显隐 -->
<topnavigation :isfx="toHideBackButton"></topnavigation>

5.4 逻辑总结

场景 pages.length toHideBackButton 返回按钮
直接打开小程序 → 登录页 1(只有当前页) true 隐藏
从其他页面跳转 → 登录页 ≥ 2 false 显示

六、完整流程图

flowchart TD
    A[用户在小程序内打开 H5 活动页] --> B{web-view 加载 H5 URL}
    B --> C{H5 检测 token}
    C -->|有效| D[H5 正常加载]
    C -->|失效| E[调用 noValidTokenGoMiniProgramLogin]
    E --> F{判断环境}
    F -->|小程序环境| G[2秒后 wx.miniProgram.redirectTo]
    F -->|非小程序| H[普通 H5 登录流程]
    G --> I[跳转小程序登录页<br/>携带 isShare/back/h5Src 参数]
    I --> J{登录页 onLoad}
    J --> K{isShare=1?}
    K -->|是| L[强制隐藏返回按钮]
    K -->|否| M[根据路由栈长度判断]
    M --> N{pages.length > 1?}
    N -->|是| O[显示返回按钮]
    N -->|否| P[隐藏返回按钮]
    L --> Q[用户完成登录]
    O --> Q
    P --> Q
    Q --> R{有 h5Src?}
    R -->|是| S[打开 web-view<br/>携带 webViewUrl=h5Src]
    R -->|否| T[普通跳转目标页面]
    S --> U[web-view 重新 onLoad]
    U --> V[checkMiniProgramLoginFlag 校验 token]
    V -->|有效| W[token 拼接进 URL]
    V -->|失效| E
    W --> D

七、踩坑记录

7.1 navigateTo 会导致返回键回到失效页面

错误做法

wx.miniProgram.navigateTo({
  url: '/pages/login/login?back=1'
})

正确做法

wx.miniProgram.redirectTo({
  url: '/pages/login/login?back=1&h5Src=...'
})

7.2 环境判断不能依赖 UA

错误做法

navigator.userAgent.includes('miniProgram')  // 不可靠

正确做法

window.__wxjs_environment === 'miniprogram'  // JSSDK 注入,可靠

7.3 忘记 encodeURIComponent

错误做法

h5Src=https://example.com/#/page?activityId=123&isMiniprogram=1
// URL 中的 ? 和 & 会与小程序路由参数冲突

正确做法

h5Src=${encodeURIComponent('https://example.com/#/page?activityId=123&isMiniprogram=1')}

八、总结

本文分享了一套小程序 <web-view> 内嵌 H5 的完整会话管理方案,核心要点:

  1. 环境检测:使用 window.__wxjs_environment 判断运行环境
  2. 跨端跳转:通过 JSSDK 的 wx.miniProgram.redirectTo 实现 H5 → 小程序原生页面跳转
  3. 参数透传h5Src 参数记录原始 H5 地址,登录成功后原路返回
  4. 智能 UI:根据路由栈长度控制返回按钮显隐,提升用户体验

这套方案已在生产环境验证,希望能帮助到有类似需求的开发者。


参考资料


Next.js精通SEO第一章(引言)

SEO介绍

SEO(Search Engine Optimization),即搜索引擎优化,是一种通过优化网站结构和内容,提高网站在搜索引擎中的排名,从而吸引更多流量和用户的策略。

tips: SEO是一个长期优化过程(一般优化1-3个月才能看到效果),无需急于求成。

黑帽SEO

黑帽SEO是指通过不正当的手段,如关键词堆砌、隐藏文本、欺诈性链接等,来提高网站在搜索引擎中的排名。这种做法虽然可以在短期内获得较好的效果,但长期来看会对网站造成严重的负面影响,甚至可能导致网站被搜索引擎惩罚。

例如我们在Google搜索笔记本,我们找排名第一的网站

image.png 然后进去网站之后

鼠标右键->查看网页源代码,发现他用了非常多的关键词堆砌(笔记本),这就是黑帽SEO(豆包说的,不是我说的😏)。

image.png

白帽SEO

白帽SEO就是通过正当技术手段,例如优化TDK,优化网站结构,优化robots.txt,优化sitemap.xml,优化JSON-LD,优化Open Graph,优化Web Vitals等,来提高网站在搜索引擎中的排名。

SEO实践

  1. 理解搜索引擎的工作原理
  2. robots.txt 和 sitemap.xml 的配置
  3. TDK优化 + HTML语义化标签
  4. JSON-LD
  5. Open Graph
  6. Web Vitals
  7. SEO工具的使用

Google搜索引擎

Google 搜索是一款全自动搜索引擎,会使用名为“网页抓取工具”的软件定期探索网络,找出可添加到 Google 索引中的网页。实际上,Google 搜索结果中收录的大多数网页都不是手动提交的,而是网页抓取工具在探索网络时找到并自动添加的。

Google搜索引擎原理

Google 搜索的工作流程分为 3 个阶段:

  1. 抓取:Google 会使用名为“抓取工具”的自动程序从互联网上发现各类网页,并下载其中的文本、图片和视频。
  2. 索引编制:Google 会分析网页上的文本、图片和视频文件,并将信息存储在大型数据库 Google 索引中。
  3. 呈现搜索结果:当用户在 Google 中搜索时,Google 会返回与用户查询相关的信息。

所以答案就是:抓取->索引编制->呈现搜索结果

抓取

谷歌会使用(Googlebot)去抓取网页,Googlebot也被称为(抓取工具、漫游器或“蜘蛛”程序),他会通过算法来决定哪些网页需要抓取,并且确保不会过快抓取,以免对网站造成负担。

那么它是怎么抓取的呢?

  1. 通过链接抓取例如你的网站有a标签,那么Googlebot会通过a标签的href属性来抓取网页。<a href="https://www.xxxxxx.com">xxxxx</a>

  2. robots.txt(告诉爬虫机器人哪些页面可以抓取,哪些页面不能抓取,后面会详细讲)

  3. 站点地图 sitemap.xml(列出网站中的网页、文件、视频等 URL,方便爬虫发现和抓取这些资源

  4. 如果网站未收录,可以通过Google Search Console提交网站。

image.png

  1. RSS订阅,例如你的网站有RSS订阅,那么Googlebot会通过RSS订阅来抓取网页。

  2. 重定向,谷歌机器人也会根据你301/302重定向来抓取网页。

  3. JavaScript,现代谷歌浏览器已经可以识别JavaScript代码中动态生成的链接,也会被收录。

索引编制

什么是索引编制?

  1. 索引编制是把抓取到的内容匹配成用户查询的形式,插入到索引数据库中。用户搜索时,Google 是在索引数据库中进行匹配和排序的,并不是实时抓取全网的,所以你修改的网页一般要(2-3周)才会被同步

  2. 被抓取 ≠ 被索引如果你在代码中编写了noindex,则该页面不会加入索引数据库中。

<meta name="robots" content="noindex">
  1. 索引信号 索引信号是指Googlebot分析网页的内容,例如TDKHTML语义化标签JSON-LDOpen GraphWeb Vitalsalt属性,分析这些内容和网站质量,用于进行评估提升排名。

  2. 注意事项 如果你的网站有以下情况,则会被降低排名:伪装真实内容 滥用门页 滥用过期域名 被黑内容 滥用隐藏文字和链接 关键字堆砌 垃圾链接 机器生成的流量 恶意软件和恶意行为 误导性功能 滥用规模化内容 滥用网站声誉 内容贫乏的联属营销 用户生成的垃圾内容

原文链接:developers.google.com/search/docs…

呈现搜索结果

谷歌官方承诺:Google 不会通过收取费用来提高网页排名,网页排名是程序化地完成的(靠的是你对SEO的实力)

image.png

  1. 排名的考量(相关性-内容与搜搜意图的匹配)(权威性-域名权重,外链质量)(用户体验-加载速度SEO友好)
  2. 收录,在被抓如到索引之后,通常是2-3周才会被收录,排名需要一段时间的积累权重,一般是2-3个月。
  3. 结果,搜索的结果会全方面考量,用户的语言,设备,历史记录,SEO优化的是整体,而不是固定某个位置。

RN 的新模块系统 Turbo module

本文所有代码如果没有特别标注的话,默认用的都是 v0.76.0 的 RN 代码

TurboModule 是什么

要讲清楚这个问题,我们要先从 bridge 架构的 Native module 系统开始说起,旧架构的 Native module 系统的调用流程大概是这样的:

JS code
  │
  │   UIManager.dispatchViewManagerCommand(tag, commandID, params)
  ▼
+----------------------+
| JS UIManager proxy   |
| (generated function) |
+----------------------+
  │
  │   enqueueNativeCall(moduleID, methodID, args)
  ▼
+----------------------+
|   BatchedBridge      |
|   call queue         |
+----------------------+
  │
  │   batch + flush
  ▼
+----------------------+
|    Bridge payload    |
| [moduleIDs]          |
| [methodIDs]          |
| [params]             |
+----------------------+
  │
  │   pass to native
  ▼
+----------------------+
|  Native bridge side  |
+----------------------+
  │
  ├─ lookup module by moduleID
  ├─ lookup method by methodID
  └─ convert args
  ▼
+----------------------+
|  Native UIManager    |
+----------------------+
  │
  ├─ resolve reactTag
  ├─ resolve command
  └─ dispatch
  ▼
+----------------------+
| ViewManager / View   |
+----------------------+
  │
  ▼
Actual native UI effect

首先 JS 在调用的时候需要知道有哪些模块,以及对应模块的方法,这些资讯是由宿主侧在初始化阶段注入的

当 JS 调用 Native module 时,JS 还有一层代理(proxy)层将模块方法的调用统一转换成 enqueueNativeCall 方法的调用

当这个方法进入 JS 侧 bridge 的时候,会被打包、序列化后传送给宿主侧的 bridge 由这边的 bridge 进行反序列化、解析后调用对应的原生模块方法;如果这个原生模块的方法存在回调,还需要把这个流程反过来一次,然后找到对应的 callID(JS 调用原生模块方法的编号)拿到对应的 callback 执行

这种调用模式会有什么问题呢?我们用一个表格来看看:

旧 Native Module 的架构问题 问题描述 TurboModule 怎么修复
以 Bridge 为中心 模块系统依赖跨桥消息模型,能力受限(强制异步、对高频/小细粒度调度不友好等等) 改为 JSI binding 为中心,使得模块可以像 JS runtime 中的本地对象能力被调用
接口契约松散 原生模块缺乏单一数据源约束,JS/Android/IOS 各自实现,三者依靠文档、约定、测试保持一致,长期容易造成平台不一致重构风险高团队协作成本上升等问题 用 spec 来约束类型 + Codegen 根据类型约束生成 C++/Android/IOS 侧脚手架文件强化契约,建立单一数据源
生命周期治理弱 旧原生模块系统虽然也支持懒加载,但它的生命周期依赖 bridge 上下文驱动,生命周期管理边界也十分分散 TurboModuleManager 统一按需创建和管理
支持两层缓存:模块实例、JS 方法属性按需加载
UI 模块和普通原生能力混杂 旧 Native Module 既承载普通能力模块,也承载像 UIManager 这类 UI 基础设施,导致 “能力模块” 和 “渲染/视图系统” 职责混杂,抽象层次不清晰 TurboModule 负责普通原生能力;Fabric 负责 UI/视图系统,从架构上完成职责拆分

基于这些区别,现在可以回答本章节标题的问题了:TurboModule system 是 RN 新架构中面向非 UI 原生能力的模块系统,它以 JS runtime 为中心,替代框架中以 bridge 为中心的 Native Module system;并通过更清晰的模块边界、按需创建和更强的接口契约支持 RN 架构长期演进

设计方案

新旧模块兼容方案

在进入架构设计前,我们需要先了解一些背景知识:

新的 TurboModule 不仅重写了整个 NativeModule 系统,还更改了之前的调用方式

在 Bridge 时代,NativeModule 的调用是这样子的:

import { NativeModules } from 'react-native';

NativeModules.Vibration.vibrate(500);

但是在 TurboModule 中,调用方式变成了:

import { TurboModuleRegistry } from 'react-native';

const NativeVibration = TurboModuleRegistry.getEnforcing('Vibration')
NativeVibration.vibrate(500)

个人猜测改变调用方式的动机有两个:

  1. 相比于之前分散的模块,新的方法按模块名从 TurboModule 系统取,更适合“按名称查找”和“按需解析”的新语义,且加强了原生模块之间的内聚性
  2. 考虑到原生模块的迁移成本,通过 TurboModuleRegistry 能更好的适配旧的原生模块调用方式,使得旧模块也能在新架构上继续使用

特别是第二点,RN 为了让新架构也能兼容旧写法的 Native module,设计了如下的兼容流程:

RN_legacy_vs_turboModule

说明一下:

  1. 当我们在新架构调用了 Vibration.vibrate(500) 方法后,会进入到对应的 spec 文件中
  2. spec 文件就是 typescript 编写的文件,它声明了原生模块的类型,并且会暴露一个 TurboModuleRegistry.getEnforcing('Vibration') 方法的返回值
  3. getEnforcing 方法中,会判断当前的模块是否为 TurboModule
  4. 【接步骤 3】如果是,则通过原生平台的 TurboModuleProvider 模块去查找对应实现(对应图中最右边的橘色正方形)
  5. 【接步骤 3】如果不是,则要先判断是否要启用兼容逻辑(判断依据是 useLegacyNativeModuleInterop 是否为 true,只有当前 RN 在 bridge 模式或者原生平台提供了 LegacyModuleProvider 才会返回 true)
  6. 【接步骤 5】如果不启用则直接返回 null,因为没有找到对应原生模块
  7. 【接步骤 5】如果启用了,就判断原生平台是否提供 LegacyModuleProvider,如果有则走 LegacyModuleProvider 的逻辑
  8. 【接步骤 7】如果没有就回退到 bridge 的 enqueueNativeCall 解决方案(对应到我们开篇的流程图)

至于 TurboModuleProviderLegacyModuleProvider 分别做了什么,以及 RN 是如何管理模块的生命周期,我们可以来看看下个章节的架构设计~

调用链路

由于 LegacyModuleProviderTurboModuleProvider 基本类似,区别在于 LegacyModuleProvider 需要兼容之前基于 Bridge 的原生模块,所以我们重点看一下新的 TurboModule 调用链路:

turboModule flow

TurboModule 的调用链路中总共有两个主要角色:Js runtime 以及 C++ 中的 TurboModuleBinding

看过本专栏 JSI 文章的读者应该知道,在新架构中的 XXXbinding 一般就是负责把各种能力挂到 global 对象上

整个调用链路分为两个部分:初始化阶段、调用阶段

在 RN 应用初始化过程中,会调用 TurboModuleBinding::install 方法,这个方法会做两件事:

  1. 往 global 挂 __turboModuleProxy 属性,这是一个 C++ 侧的方法,也是 JS 调用 turboModule 的入口
  2. 如果当前是 bridgeless 模式(也就是使用 JSI),会往 global 挂 nativeModuleProxy 属性,这也是一个 C++ 侧的方法,负责兼容老架构的原生模块

至此,初始化阶段就完成了,接下来进入调用阶段

调用的发起点是 JS 侧(对应图中左侧 JS runtime),总共分为两步:

  1. 获得 TurboModule 对应 JS 对象的引用(对应到图中左侧的 jsRepresentation,为了方便理解我们把它赋值给了 NativeVibration 这个更加语义化的变量)
  2. 从该引用取得方法然后调用

首先是第一步获取 TurboModule 对应 JS 对象的引用,我们可以通过 global.__turboModuleProxy 取得,这是一个可以通过 JSI 调用的 C++ 方法,最后会调用 TurboModuleBinding::getModule 方法,该方法会做四件事:

  1. 创建一个 JS 对象 jsRepresentation,用来存放后续所需 TurboModule 的实例
  2. 去宿主平台的 TurboModuleManager(IOSAndroid)取得对应 TurboModule 的实例
  3. 把 TurboModule 的实例放到 jsRepresentation.__proto__ 中,这是 TurboModule 系统实现模块按照方法/属性级别缓存的主要设计
  4. 最后,我们把 jsRepresentation 返回给 JS runtime,让 JS 持有这个对象的引用以便后续直接调用其中的方法

以上的步骤只有第一次的时候需要,后续由于 JS 已经持有了 jsRepresentation 的引用,所以 JS 直接通过这个对象访问所需方法即可~

源码解析

在这个小节,我们来看看上个小节的初始化以及调用阶段的具体代码实现

首先是初始化阶段,我们先来看看 TurboModuleBinding::install 是怎么实现的:

// in packages/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModuleBinding.cpp

// 这个方法会在应用初始化的时候被宿主平台各自实现的 TurboModuleManager 调用
// 目的是为了在 global 对象上挂载原生模块调用所需方法
void TurboModuleBinding::install(
    jsi::Runtime &runtime, TurboModuleProviderFunctionType &&moduleProvider,
    TurboModuleProviderFunctionType &&legacyModuleProvider,
    std::shared_ptr<LongLivedObjectCollection> longLivedObjectCollection) {
  // 挂载 __turboModuleProxy
  runtime.global().setProperty(
      runtime, "__turboModuleProxy",
    // 这是一个 C++ 方法
      jsi::Function::createFromHostFunction(
          runtime, jsi::PropNameID::forAscii(runtime, "__turboModuleProxy"), 1,
        // 传入了一个 lymbda,这个 lymbda 会在 JS 访问 global.__turboModuleProxy 时被调用
          [binding = TurboModuleBinding(runtime, std::move(moduleProvider),
                                        longLivedObjectCollection)](
              jsi::Runtime &rt, const jsi::Value &thisVal,
              const jsi::Value *args, size_t count) {
            // 检查参数的代码
            if (count < 1) {
              throw std::invalid_argument(
                  "__turboModuleProxy must be called with at least 1 argument");
            }
            std::string moduleName = args[0].getString(rt).utf8(rt);
            // 真正做事的方法
            return binding.getModule(rt, moduleName);
          }));

  // 因为 0.76.0 版本还需要兼容之前的 bridge 模式,所以会用 RN$Bridgeless 来标识现在用的是 JSI 架构的代码
  if (runtime.global().hasProperty(runtime, "RN$Bridgeless")) {
    bool rnTurboInterop = legacyModuleProvider != nullptr;
    auto turboModuleBinding =
        legacyModuleProvider ? std::make_unique<TurboModuleBinding>(
                                   runtime, std::move(legacyModuleProvider),
                                   longLivedObjectCollection)
                             : nullptr;
    auto nativeModuleProxy = std::make_shared<BridgelessNativeModuleProxy>(
        std::move(turboModuleBinding));
    defineReadOnlyGlobal(runtime, "RN$TurboInterop",
                         jsi::Value(rnTurboInterop));
    // 主要代码,目的是挂载 nativeModuleProxy 到 global 对象上
    defineReadOnlyGlobal(
        runtime, "nativeModuleProxy",
        jsi::Object::createFromHostObject(runtime, nativeModuleProxy));
  }
}

接下来我们来看看 getModule 做了什么:

// in packages/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModuleBinding.cpp

// 获得具体模块实现的方法,目标是最后返回一个带着模块实现的 JS 对象 jsRepresentation
jsi::Value TurboModuleBinding::getModule(jsi::Runtime &runtime,
                                         const std::string &moduleName) const {
  std::shared_ptr<TurboModule> module;
  {
    SystraceSection s("TurboModuleBinding::moduleProvider", "module",
                      moduleName);
// 调用双端实现的 TurboModuleProvider 来获取对应的模块实例
    module = moduleProvider_(moduleName);
  }
  if (module) {
    // 这里是第一层缓存
    // 如果这不是第一次获取该模块实例,我们可以从模块中找到上一次返回的 jsRepresentation 
    // 这里直接返回即可,无需再创建一次 jsRepresentation 对象
    auto &weakJsRepresentation = module->jsRepresentation_;
    if (weakJsRepresentation) {
      auto jsRepresentation = weakJsRepresentation->lock(runtime);
      if (!jsRepresentation.isUndefined()) {
        return jsRepresentation;
      }
    }

    // 如果是第一次获取该模块实例,我们就需要先创建一个空的 jsRepresentation 对象
    jsi::Object jsRepresentation(runtime);
    weakJsRepresentation =
        std::make_unique<jsi::WeakObject>(runtime, jsRepresentation);

    // ⚠️ 核心代码
    // jsRepresentation 解决的是 “实例属性的缓存问题”
    // 具体做法是:
    // 1. 第一次创建并返回的 jsRepresentation 是一个空对象,但是我们用原型链将其与模块实例关联起来
    // 2. 如果我们第一次调用了该模块的方法,会因为当前对象为空而去原型链找,于是就进入了模块原型的 get 方法
    // 3. 模块的 get 方法被触发会把对应的属性缓存在 jsRepresentation 的属性中
    // 4. 这样一来,后面如果我们再次调用同样的方法,就可以直接在 jsRepresentation 的属性中查找到,不需要再进行原型链查找了
    // 具体的实现等我们聊到 TurboModuleProvider 的实现会再进行分析
    auto hostObject =
        jsi::Object::createFromHostObject(runtime, std::move(module));
    jsRepresentation.setProperty(runtime, "__proto__", std::move(hostObject));

    // 把刚刚创建的对象返回给 JS
    return jsRepresentation;
  } else {
    // 如果找不到对应的模块,直接返回 null
    return jsi::Value::null();
  }
}

接下来,我们来看看神秘的 TurboModuleProvider 都做了什么~

TurboModuleProvider 是由平台自行实现,所以会有两套实现,我们先来看看 IOS 的实现:

// in packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.mm

// 这个方法是 IOS 侧把 TurboModule 系统安装进 JS runtime 的入口
// 在这里 IOS 提供了 turboModuleProvider 以及 legacyModuleProvider 并在最后调用了上述 TurboModuleBinding::install 方法
- (void)installJSBindings:(facebook::jsi::Runtime &)runtime
{
  // 创建 turboModuleProvider,可以看到这是一个 lymbda
  // 当 getModule 需要查找模块的时候就会调用这个 lymbda
  // 留意这里的返回值是一个 TurboModule,这个跟后续的 legacyModuleProvider 是一致的
  // 也就是说,新旧原生模块的差异在这里被抹平了
  auto turboModuleProvider = [self,
                              runtime = &runtime](const std::string &name) -> std::shared_ptr<react::TurboModule> {
    auto moduleName = name.c_str();
// 性能埋点
    TurboModulePerfLogger::moduleJSRequireBeginningStart(moduleName);
    // 判断当前模块是否初始化了,如果没有的话也要埋个性能埋点
    // 这里只记录 objc 实现的原生模块,因为 objc 原生模块的初始化过程比较复杂,这个我们后面会聊
    auto moduleWasNotInitialized = ![self moduleIsInitialized:moduleName];
    if (moduleWasNotInitialized) {
      [self->_bridge.performanceLogger markStartForTag:RCTPLTurboModuleSetup];
    }

    // 关键代码,真正获取模块的逻辑
    // 如果该模块已经初始化了会直接返回实例,否则会执行初始化流程
    auto turboModule = [self provideTurboModule:moduleName runtime:runtime];

    // 初始化性能埋点
    if (moduleWasNotInitialized && [self moduleIsInitialized:moduleName]) {
      [self->_bridge.performanceLogger markStopForTag:RCTPLTurboModuleSetup];
    }

    // 埋点,记录是否获取成功
    if (turboModule) {
      TurboModulePerfLogger::moduleJSRequireEndingEnd(moduleName);
    } else {
      TurboModulePerfLogger::moduleJSRequireEndingFail(moduleName);
    }
    // 返回一个模块实例
    return turboModule;
  };

  // 一个开关,如果在 bridgeless 模式下默认是开启的
  // 用来开启兼容旧模块的开关,在开启的情况下才会提供 legacyModuleProvider
  if (RCTTurboModuleInteropEnabled()) {
    // 这里的代码跟上面 turboModuleProvider 基本一致,区别只在于获取模块的方法变了
    // 这里变成了 provideLegacyModule
    auto legacyModuleProvider = [self](const std::string &name) -> std::shared_ptr<react::TurboModule> {
      auto moduleName = name.c_str();
      
      TurboModulePerfLogger::moduleJSRequireBeginningStart(moduleName);

      // 关键代码
      auto turboModule = [self provideLegacyModule:moduleName];

      if (turboModule) {
        TurboModulePerfLogger::moduleJSRequireEndingEnd(moduleName);
      } else {
        TurboModulePerfLogger::moduleJSRequireEndingFail(moduleName);
      }
      return turboModule;
    };

    // 调用带有 legacyModuleProvide 的 TurboModuleBinding::install
    TurboModuleBinding::install(runtime, std::move(turboModuleProvider), std::move(legacyModuleProvider));
  } else {
    // 调用只有 turboModuleProvider 的 TurboModuleBinding::install
    TurboModuleBinding::install(runtime, std::move(turboModuleProvider));
  }
}

接下来我们就要进入 provideTurboModule 的具体实现(由于 provideLegacyModule 方法逻辑类似,这里就不额外说明了)

不过在看代码之前,我们需要先了解一些背景知识:TurboModule 内部实现到底有几种

  1. 纯 C++ 实现的 TurboModule:最 “纯” 的新架构模块,这也是最接近新架构设计的原生模块

  2. 全局导出的 C++ TurboModule:这个跟第一类其实一样,唯一的区别在于它俩的查找方式不同,上一个是通过 delegate 查找、它是靠 register 查找

  3. Objc 平台模块:这一类泛指所有基于 objc 实现的平台模块,细分下去可以分为三类:

    1. ObjC 实现的 TurboModule:这个指的是由 Objc 实现且实现了 getTurboModule 方法的模块
    2. legacy ObjC NativeModule:这个由 provideLegacyModule 负责处理
    3. RCTCxxModule:这个是在旧架构中使用 C++ 实现的模块,因为在旧架构中 C++ 模块需要 Objc 来中转,所以它在之前也被当成了 Objc 的模块

而 provideTurboModule 查找的顺序也是依据上面的顺序来的,下面我们看看具体代码:

// in packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.mm

/**
 * provideTurboModule 方法接受一个模块名作为参数,然后查找并返回返回对应的 TurboModule 模块实例
 * 查找流程如下:
 * 1. 查 _turboModuleCache
 * 2. 查 delegate 提供的 pure C++ TurboModule
 * 3. 查 global exported C++ TurboModule map
 * 4. 再查 ObjC module
**/
- (std::shared_ptr<TurboModule>)provideTurboModule:(const char *)moduleName runtime:(jsi::Runtime *)runtime
{
  /**
   * 第一步:如果这个模块已经创建过有缓存了,直接返回缓存
   * _turboModuleCache 是一个 unordered_map,保存 “模块名” 到 “模块实例指针” 的映射
   */
  auto turboModuleLookup = _turboModuleCache.find(moduleName);
  if (turboModuleLookup != _turboModuleCache.end()) {
    TurboModulePerfLogger::moduleJSRequireBeginningCacheHit(moduleName);
    TurboModulePerfLogger::moduleJSRequireBeginningEnd(moduleName);
    return turboModuleLookup->second;
  }

  TurboModulePerfLogger::moduleJSRequireBeginningEnd(moduleName);

  /**
   * 第二步:检查纯 C++ 模块(C++ 模块拥有最高优先级)
   */
  if ([_delegate respondsToSelector:@selector(getTurboModule:jsInvoker:)]) {
    int32_t moduleId = getUniqueId();
    TurboModulePerfLogger::moduleCreateStart(moduleName, moduleId);
    // 往 delegate 上找模块的实例
    auto turboModule = [_delegate getTurboModule:moduleName jsInvoker:_jsInvoker];
    if (turboModule != nullptr) {
      // 如果找到了就保存到缓存中
      _turboModuleCache.insert({moduleName, turboModule});
      TurboModulePerfLogger::moduleCreateEnd(moduleName, moduleId);
      // 然后返回实例
      return turboModule;
    }

    TurboModulePerfLogger::moduleCreateFail(moduleName, moduleId);
  }

  /**
   * 第三步:检查全局导出的 C++ 模块
   */
  auto &cxxTurboModuleMapProvider = globalExportedCxxTurboModuleMap();
  auto it = cxxTurboModuleMapProvider.find(moduleName);
  if (it != cxxTurboModuleMapProvider.end()) {
    auto turboModule = it->second(_jsInvoker);
    _turboModuleCache.insert({moduleName, turboModule});
    return turboModule;
  }

  /**
   * 第四步:找平台相关的模块,在 IOS 中就是 objc 模块
   */
  // 只有当 TurboModuleInterop 关闭或者当前模块是 TurboModule 的时候才会调用 _provideObjCModule 方法查找
  // legacyModule 不在这里处理,而是直接交给了 legacyModuleProvider
  // _provideObjCModule 会找到对应的模块,并且返回模块实例
  id<RCTBridgeModule> module =
      !RCTTurboModuleInteropEnabled() || [self _isTurboModule:moduleName] ? [self _provideObjCModule:moduleName] : nil;

  TurboModulePerfLogger::moduleJSRequireEndingStart(moduleName);

  // 如果没有找到直接返回空指针
  if (!module) {
    return nullptr;
  }

  // 从模块实例找到对应的类
  Class moduleClass = [module class];

  // 找到模块需要的 queue
  dispatch_queue_t methodQueue = (dispatch_queue_t)objc_getAssociatedObject(module, &kAssociatedMethodQueueKey);
  if (methodQueue == nil) {
    RCTLogError(@"TurboModule \"%@\" was not associated with a method queue.", moduleClass);
  }

  // 根据 queue 创建 nativeMethodCallInvoker
  std::shared_ptr<NativeMethodCallInvoker> nativeMethodCallInvoker =
      std::make_shared<ModuleNativeMethodCallInvoker>(methodQueue);

  // 在 bridgeless 模式下没有 bridge,所以忽略
  // 这个方法主要是把 TurboModule 的 native method invoker 交给 RCTCxxBridge 包装一下,让 bridge 能感知 TurboModule 的异步 native 调用,从而维持 onBatchComplete 等旧 bridge 行为
  if ([_bridge respondsToSelector:@selector(decorateNativeMethodCallInvoker:)]) {
    nativeMethodCallInvoker = [_bridge decorateNativeMethodCallInvoker:nativeMethodCallInvoker];
  }

  // 处理 RCTCxxModule
  if ([moduleClass isSubclassOfClass:RCTCxxModule.class]) {
    // 直接用一个 TurboCxxModule 类包起来完事
    auto turboModule = std::make_shared<TurboCxxModule>([((RCTCxxModule *)module) createModule], _jsInvoker);
    // 还是一样放入 cache 中
    _turboModuleCache.insert({moduleName, turboModule});
    return turboModule;
  }

  // 最后我们来处理有 getTurboModul 方法的 objc 模块
  if ([module respondsToSelector:@selector(getTurboModule:)]) {
    ObjCTurboModule::InitParams params = {
        .moduleName = moduleName,
        .instance = module,
        .jsInvoker = _jsInvoker,
        .nativeMethodCallInvoker = nativeMethodCallInvoker,
        .isSyncModule = methodQueue == RCTJSThread,
        .shouldVoidMethodsExecuteSync = (bool)RCTTurboModuleSyncVoidMethodsEnabled(),
    };

    auto turboModule = [(id<RCTTurboModule>)module getTurboModule:params];
    if (turboModule == nullptr) {
      RCTLogError(@"TurboModule \"%@\"'s getTurboModule: method returned nil.", moduleClass);
    }
    _turboModuleCache.insert({moduleName, turboModule});

    if ([module respondsToSelector:@selector(installJSIBindingsWithRuntime:)]) {
      [(id<RCTTurboModuleWithJSIBindings>)module installJSIBindingsWithRuntime:*runtime];
    }
    return turboModule;
  }

  return nullptr;
}

总结一下,IOS 这块代码主要就是根据不同的模块类型一一处理,并且最后统一包裹成 TurboModule 返回

其中 _turboModuleCache 是一个关键的缓存机制,它保证了模块最多只会初始化一次

RN 团队在注释中有写了一个 TODO 想要把模块的生命周期管理下放到由模块自己管理(就是让 _turboModuleCache 不要像现在长时间保存实例缓存),但是我看了下到目前最新的 0.85 版本这个 TODO 还在

接下来我们来看看 Android 的 TurboModuleProvider 做了啥吧~

// in packages/react-native/ReactAndroid/src/main/jni/react/turbomodule/ReactCommon/TurboModuleManager.cpp

void TurboModuleManager::installJSIBindings(
    jni::alias_ref<jhybridobject> javaPart,
    bool shouldCreateLegacyModules) {
  auto cxxPart = javaPart->cthis();
  if (cxxPart == nullptr || !cxxPart->jsCallInvoker_) {
    return; 
  }

  // 从 runtimeExecutor 拿到 js runtime
  cxxPart->runtimeExecutor_([cxxPart,
                             javaPart = jni::make_global(javaPart),
                             shouldCreateLegacyModules](jsi::Runtime& runtime) {
    // 跟 IOS 一样,也是在这里调用了 install 方法
    TurboModuleBinding::install(
        runtime,
      // TurboModuleProvider
        cxxPart->createTurboModuleProvider(javaPart, &runtime),
        shouldCreateLegacyModules
      // LegacyModuleProvider
            ? cxxPart->createLegacyModuleProvider(javaPart)
            : nullptr);
  });
}

接下来我们看看具体实现:

// in packages/react-native/ReactAndroid/src/main/jni/react/turbomodule/ReactCommon/TurboModuleManager.cpp

TurboModuleProviderFunctionType TurboModuleManager::createTurboModuleProvider(
    jni::alias_ref<jhybridobject> javaPart,
    jsi::Runtime* runtime) {
  return [runtime, weakJavaPart = jni::make_weak(javaPart)](
             const std::string& name) -> std::shared_ptr<TurboModule> {
    auto javaPart = weakJavaPart.lockLocal();
    if (!javaPart) {
      return nullptr;
    }

    auto cxxPart = javaPart->cthis();
    if (cxxPart == nullptr) {
      return nullptr;
    }
// 具体实现在这~下面有代码~
    return cxxPart->getTurboModule(javaPart, name, *runtime);
  };
}

// 这个方法对应 IOS 的 provideTurboModule 方法
std::shared_ptr<TurboModule> TurboModuleManager::getTurboModule(
    jni::alias_ref<jhybridobject> javaPart,
    const std::string& name,
    jsi::Runtime& runtime) {
  const char* moduleName = name.c_str();
  TurboModulePerfLogger::moduleJSRequireBeginningStart(moduleName);

  /**
   * 第一步:如果这个模块已经创建过有缓存了,直接返回缓存
   */
  auto turboModuleLookup = turboModuleCache_.find(name);
  if (turboModuleLookup != turboModuleCache_.end()) {
    TurboModulePerfLogger::moduleJSRequireBeginningCacheHit(moduleName);
    TurboModulePerfLogger::moduleJSRequireBeginningEnd(moduleName);
    return turboModuleLookup->second;
  }

  TurboModulePerfLogger::moduleJSRequireBeginningEnd(moduleName);

  /**
   * 第二步:检查纯 C++ 模块(C++ 模块拥有最高优先级)
   */
  auto cxxDelegate = delegate_->cthis();
  auto cxxModule = cxxDelegate->getTurboModule(name, jsCallInvoker_);
  if (cxxModule) {
    turboModuleCache_.insert({name, cxxModule});
    return cxxModule;
  }

  /**
   * 第三步:检查全局导出的 C++ 模块
   */
  auto& cxxTurboModuleMapProvider = globalExportedCxxTurboModuleMap();
  auto it = cxxTurboModuleMapProvider.find(name);
  if (it != cxxTurboModuleMapProvider.end()) {
    auto turboModule = it->second(jsCallInvoker_);
    turboModuleCache_.insert({name, turboModule});
    return turboModule;
  }

  /**
   * 第四步:找平台相关的模块,在 Android 中就是 java 模块
   */
  static auto getTurboJavaModule =
      javaPart->getClass()
          ->getMethod<jni::alias_ref<JTurboModule>(const std::string&)>(
              "getTurboJavaModule");
  auto moduleInstance = getTurboJavaModule(javaPart.get(), name);
  if (moduleInstance) {
    TurboModulePerfLogger::moduleJSRequireEndingStart(moduleName);
    JavaTurboModule::InitParams params = {
        .moduleName = name,
        .instance = moduleInstance,
        .jsInvoker = jsCallInvoker_,
        .nativeMethodCallInvoker = nativeMethodCallInvoker_};

    auto turboModule = cxxDelegate->getTurboModule(name, params);
    if (moduleInstance->isInstanceOf(
            JTurboModuleWithJSIBindings::javaClassStatic())) {
      static auto getBindingsInstaller =
          JTurboModuleWithJSIBindings::javaClassStatic()
              ->getMethod<BindingsInstallerHolder::javaobject()>(
                  "getBindingsInstaller");
      auto installer = getBindingsInstaller(moduleInstance);
      if (installer) {
        installer->cthis()->installBindings(runtime);
      }
    }

    turboModuleCache_.insert({name, turboModule});
    TurboModulePerfLogger::moduleJSRequireEndingEnd(moduleName);
    return turboModule;
  }

  // 处理旧架构中使用 C++ 实现的模块(对应 IOS 的 RCTCxxModule)
  static auto getTurboLegacyCxxModule =
      javaPart->getClass()
          ->getMethod<jni::alias_ref<CxxModuleWrapper::javaobject>(
              const std::string&)>("getTurboLegacyCxxModule");
  auto legacyCxxModule = getTurboLegacyCxxModule(javaPart.get(), name);
  if (legacyCxxModule) {
    TurboModulePerfLogger::moduleJSRequireEndingStart(moduleName);

    auto turboModule = std::make_shared<react::TurboCxxModule>(
        legacyCxxModule->cthis()->getModule(), jsCallInvoker_);
    turboModuleCache_.insert({name, turboModule});

    TurboModulePerfLogger::moduleJSRequireEndingEnd(moduleName);
    return turboModule;
  }

  return nullptr;
}

可以看到 Android 的实现跟 IOS 基本没区别

在这个小节的最后,我们来聊一下 TurboModule 的两层缓存是怎么实现的:

  1. 第一层缓存是在宿主的 turboModuleCache_ 中这里存放了所有被初始化了的原生模块实例
  2. 第二层缓存是在 C++ 的 jsRepresentation,这是一个 JS 对象,每个对象对应到一个模块实例,模块实例通过挂在它的原型链上使得其可以访问模块的方法

访问模块的核心方法在 TurboModule.h

// in packages/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModule.h

// 在 TurboModuleBinding::getModule 方法中,我们知道它返回了 jsRepresentation 并且把对应模块实例挂上了它的原型链
// 如果在 JS 侧访问某个模块方法但是在 jsRepresentation 中找不到时,会试图往原型链上找,于是就会命中这里的 get 方法
facebook::jsi::Value get(
      facebook::jsi::Runtime& runtime,
      const facebook::jsi::PropNameID& propName) override {
    {
      // 在当前 TurboModule 实例上找到对应的属性
      auto prop = create(runtime, propName);
      
      // 对于访问过的实例,我们把它放进 jsRepresentation 的属性中,这样下次访问就不用在往原型链上走了
      if (jsRepresentation_ && !prop.isUndefined()) {
        jsRepresentation_->lock(runtime).asObject(runtime).setProperty(
            runtime, propName, prop);
      }
      return prop;
    }
  }

可以看到,这一个小小的 get 方法就完成了对模块属性级别的缓存

Codegen

在了解了 TurboModule 的设计方案以及调用链路后,我们接下来要补齐 TurboModule 的最后一块拼图:Codegen

根据 官网博客 的描述,codegen 是一个可选的工具,所以我们首先需要知道的是它的优势是什么,以及什么时候推荐使用

Codegen 的优势

Codegen 的优势可以一句话总结:它用一份强类型 JS/TS spec 生成 Android、iOS、C++ 原生层所需的接口与胶水代码,从而减少样板代码维护成本,并降低 JS 与 Native 之间类型不一致跨语言调用出错的风险

怎么理解这句话呢?

假设我们想要实现一个原生模块叫 NativeNotifier 它的职责是调用原生的能力,生成一个系统弹窗出来(示例见下图)

因为这个模块调用了 原生的能力 所以我决定用 objc 来实现,下面来讲一下如果不使用 codegen 的话,要怎么做:

第一步,先创建 JS wrapper:

// NativeNotifier.ts
import {TurboModuleRegistry} from 'react-native';

type NativeNotifierType = {
  show(message: string): void;
};

export default TurboModuleRegistry.getEnforcing<NativeNotifierType>(
  'NativeNotifier',
);

第二步,创建 IOS header:

// NativeNotifier.h
#import <React/RCTBridgeModule.h>

// 用 RCTBridgeModule 是为了让 RN 能发现这个 ObjC module,并读取 RCT_EXPORT_MODULE / RCT_EXPORT_METHOD 产生的 metadata
@interface NativeNotifier : NSObject <RCTBridgeModule>
@end

第三步,创建 iOS 实现:

// NativeNotifier.mm
#import "NativeNotifier.h"
#import <React/RCTUtils.h>
#import <ReactCommon/RCTInteropTurboModule.h>
#import <ReactCommon/RCTTurboModule.h>
#import <UIKit/UIKit.h>

using namespace facebook::react;
@interface NativeNotifier () <RCTTurboModule>
@end

@implementation NativeNotifier

// 暴露模块与方法名
RCT_EXPORT_MODULE(NativeNotifier)
RCT_EXPORT_METHOD(show:(NSString *)message)
{
  // 具体逻辑实现,这里省略
}

- (std::shared_ptr<TurboModule>)getTurboModule:
    (const ObjCTurboModule::InitParams &)params
{
  // 把这个 ObjC module 包装成 TurboModule
  return std::make_shared<ObjCInteropTurboModule>(params);
}

@end

第四步,为了让 xcode 编译 NativeNotifier.mm,我们需要把它加入到 Xcode target 中

第五步,在 App 中调用它:

import NativeNotifier from './NativeNotifier';

export default function App() {

  return (
    <SafeAreaView style={styles.container}>
      <Pressable
        style={({ pressed }) => [
          styles.button,
          pressed && styles.buttonPressed,
        ]}
        // 调用这个模块方法
        onPress={() => NativeNotifier.show('Hello from native iOS code')}>
        <Text style={styles.buttonText}>Show native message</Text>
      </Pressable>
    </SafeAreaView>
  );
}

如果我们纯手写的话,我们不仅要先知道 TurboModuleRegistry.getEnforcing 的用法,还需要知道 RCTBridgeModuleRCTTurboModuleObjCInteropTurboModule 的用法与区别,最关键的是,这个流程我们需要在 Android 再来一次

这还是初次开发这种简单模块的情况,如果后续需要迭代修改,或者是模块的复杂度上去了,维护成本巨大,双端的一致性也是个大问题

Codegen 是怎么解决的呢?

第一步,在项目根目录创建 spec 目录以及 JS wrapper

// specs/NativeNotifier.ts

// 这个文件既是 JS 调用入口,也是 Codegen 读取的 spec
// 文件目录名字可以通过在 package.json 自定义,但约定俗成使用 specs
import type {TurboModule} from 'react-native';
import {TurboModuleRegistry} from 'react-native';

// 很重要的类型声明,Codegen 会通过这个类型生成胶水代码
export interface Spec extends TurboModule {
  show(message: string): void;
}

export default TurboModuleRegistry.getEnforcing<Spec>('NativeNotifier');

第二步,去 package.json 配置相关信息,具体配置参考 官方博客

{
  "codegenConfig": {
    "name": "DemoSpec",  // SpecName
    "type": "modules",  // types
    "jsSrcsDir": "specs",  // source_dir
    "android": {
      "javaPackageName": "com.demo"  // java.package.name
    },
}

第三步,创建 iOS 实现:

// ios/Demo/NativeNotifier.h
#import <Foundation/Foundation.h>

@interface NativeNotifier : NSObject
@end
// ios/Demo/NativeNotifier.mm

#import "NativeNotifier.h"
#import <React/RCTUtils.h>
#import <DemoSpec/DemoSpec.h>
#import <UIKit/UIKit.h>

@interface NativeNotifier () <NativeNotifierSpec>
@end

@implementation NativeNotifier

RCT_EXPORT_MODULE()

- (void)show:(NSString *)message
{
  // 具体逻辑实现,这里省略
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
    (const facebook::react::ObjCTurboModule::InitParams &)params
{
  return std::make_shared<facebook::react::NativeNotifierSpecJSI>(params);
}

@end

第四步,把 NativeNotifier.mm 加入到 Xcode target 中

第五步,实现 App.tsx 中的调用逻辑

完成了这五步后,在配置正确的情况下,IOS/Android build 流程会触发 Codegen,自动生成双端所需的接口和胶水代码;但具体的原生模块实现,例如 IOS 的 NativeNotifier.mm、Android 的 NativeNotifierModule.kt,仍然需要开发者自己实现并注册

细心的读者会发现,即使用了 Codegen,我们仍然需要:

  1. 创建 spec
  2. 配置 package.json
  3. 实现 iOS native 逻辑
  4. 加入 Xcode target
  5. 在 JS 中调用

也就是说,Codegen 并不是让创建 native module 的步骤变少,它真正减少的是隐藏在这些步骤背后的胶水代码:方法表、类型转换、JSI adapter、Android abstract spec、JNI glue,以及双端 API 签名同步的成本

简单画个比较表:

对比点 不使用 Codegen 使用 Codegen
JS/native API 来源 JS wrapper 和 native method 各写一份(总共三份) JS/TS spec 是唯一 API 来源(Single source of truth)
类型检查 主要靠人工保证 Codegen 根据 spec 生成 native 类型约束
iOS glue code 依赖 ObjCInteropTurboModule 运行时解析 RCT_EXPORT_METHOD metadata 生成 NativeNotifierSpec /NativeNotifierSpecJSI
Android glue code 需要手动写 module/package,签名一致性靠人工 生成 abstract Spec 和 JNI 胶水代码
方法名/参数数量错误 运行时更容易暴露 编译期或生成阶段更容易暴露
后续修改 API JS、iOS、Android 多处人工同步 先改 spec,再让生成代码约束 native 实现
适用场景 迁移旧 ObjC bridge module、快速兼容 新架构下新模块,更适合长期维护

总结

本文从 TurboModule 的设计开始,讲到了 Codegen 的优势以及为什么要使用 Codegen

诚然 Codegen 可以降低很多开发 TurboModule 的隐形成本,但作为开发者了解 TurboModule 以及背后的 JSI 才是主要的根基

毕竟只有掌握了底层的机制,才能往上搭出像 Nitro moduleExpo Modules API 这样的上层库/工具

使用 IntersectionObserver + 哨兵元素实现长列表懒加载

一、背景与痛点

在一个设备监控数据看板项目中,设备列表可能包含 300+ 个设备卡片。如果一次性渲染全部 DOM 节点,会带来明显的性能问题:

  • 首屏白屏时间长:300+ 卡片组件同时挂载,主线程阻塞
  • 内存占用高:大量 DOM 节点常驻内存
  • 交互卡顿:滚动、点击等操作响应延迟

为此,我们采用 IntersectionObserver + 哨兵元素 方案实现懒加载。

二、核心思路

整体思路可以概括为  "分页截取 + 哨兵触发"

全量数据(300+)  →  分页截取显示(每页15条)  →  哨兵进入视口时追加下一页

关键设计:

  1. 数据全量存储,视图分页截取deviceList 保存完整数据,displayDeviceList 通过 computed 计算 slice(0, end) 返回当前应显示的子集
  2. 哨兵元素:在列表末尾放置一个不可见的 DOM 元素,当它进入视口时触发加载
  3. IntersectionObserver:原生浏览器 API,高效监听元素与视口的交叉状态,零滚动事件开销

三、架构图示

┌─────────────────────────────────────────────┐
│            Vue Component (data)              │
│  deviceList: [...]         // 全量300+设备   │
│  devicePageSize: 15        // 每页条数       │
│  deviceCurrentPage: 0      // 当前页码       │
│  observer: null            // Observer实例    │
├─────────────────────────────────────────────┤
│            Computed Properties               │
│  displayDeviceList → slice(0, pageSize*page) │
│  hasMoreDevices → displayed < total          │
├─────────────────────────────────────────────┤
│            Template 渲染逻辑                 │
│  v-for="device in displayDeviceList"         │
│  ┌─── Card ───┐  ┌─── Card ───┐  ...        │
│  └────────────┘  └────────────┘              │
│  ┌─── Sentinel (ref="sentinel") ───┐         │
│  │  v-if="hasMoreDevices"          │         │
│  │  <加载更多设备...>               │         │
│  └─────────────────────────────────┘         │
└─────────────────────────────────────────────┘
         │                    ▲
         │ observe(sentinel)  │ isIntersecting
         ▼                    │
┌─────────────────────────────────────────────┐
│        IntersectionObserver                  │
│  rootMargin: '200px'   // 提前200px触发      │
│  threshold: 0.1                             │
│  → 触发 loadMoreDevices()                    │
│  → deviceCurrentPage++                       │
│  → displayDeviceList 自动更新 → DOM 更新     │
│  → $nextTick → 重新绑定哨兵                   │
└─────────────────────────────────────────────┘

四、核心代码实现

4.1 数据定义

data() {
  return {
    deviceList: [],        // 全量设备数据
    devicePageSize: 15,    // 每页条数
    deviceCurrentPage: 0,  // 当前已加载页数
    observer: null,        // IntersectionObserver 实例
  }
}

4.2 计算属性(视图截取 + 状态判断)

computed: {
  /** 当前已加载的设备列表(懒加载切片) */
  displayDeviceList() {
    const end = this.devicePageSize * this.deviceCurrentPage
    return this.deviceList.slice(0, end)
  },
  /** 是否还有更多设备可加载 */
  hasMoreDevices() {
    return this.displayDeviceList.length < this.deviceList.length
  }
}

关键点:使用 computed 而非手动维护一个 displayed 数组,确保数据源变化时自动响应更新。

4.3 哨兵元素(模板)

<!-- 设备网格容器 -->
<div class="dm-device-grid">
  <!-- 仅渲染 displayDeviceList 而非 deviceList -->
  <div v-for="device in displayDeviceList" :key="device.id" class="dm-device-card">
    <!-- 设备卡片内容 -->
  </div>
  <!-- 哨兵元素:仅在还有未加载数据时显示 -->
  <div v-if="hasMoreDevices" ref="sentinel" class="dm-lazy-sentinel">
    <i class="el-icon-loading" />
    <span>加载更多设备...</span>
  </div>
</div>

关键点v-if="hasMoreDevices" 确保数据全部加载后哨兵消失,Observer 自动停止触发。

4.4 IntersectionObserver 初始化

initObserver() {
  // 先断开旧观察器,防止重复绑定
  this.disconnectObserver()
  this.$nextTick(() => {
    const sentinel = this.$refs.sentinel
    if (!sentinel) return  // 哨兵不存在(数据已全部加载)

    this.observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          this.loadMoreDevices()
        }
      },
      {
        rootMargin: '200px',  // 提前200px触发,用户无感知
        threshold: 0.1
      }
    )
    this.observer.observe(sentinel)
  })
}

关键参数说明

  • rootMargin: '200px':哨兵距离视口还有 200px 时就触发回调,提前加载数据,实现 无感加载
  • threshold: 0.1:哨兵 10% 可见时即触发

4.5 加载更多 & 清理

/** 加载更多设备 */
loadMoreDevices() {
  if (!this.hasMoreDevices) return
  this.deviceCurrentPage++
  // 页码增加 → displayDeviceList 自动重新计算 → DOM 更新
  // Vue 响应式保证了这一链条无需手动操作
},

/** 断开观察器(组件销毁 / 切换组织时调用) */
disconnectObserver() {
  if (this.observer) {
    this.observer.disconnect()
    this.observer = null
  }
}

4.6 数据加载后重置

async loadDeviceList(organizationId) {
  this.deviceLoading = true
  try {
    const res = await fetchDeviceStatusList(params)
    this.deviceList = res.data.data || []
    // 重置懒加载分页
    this.deviceCurrentPage = 1  // 初始加载第一页
    this.$nextTick(() => {
      this.initObserver()  // 重新绑定观察器
    })
  } finally {
    this.deviceLoading = false
  }
}

4.7 生命周期钩子

mounted() {
  // ...其他初始化
  this.$nextTick(() => {
    this.initObserver()
  })
},
beforeDestroy() {
  // 清理 Observer,防止内存泄漏
  this.disconnectObserver()
}

五、数据流转全流程

用户滚动页面
    │
    ▼
IntersectionObserver 检测哨兵进入视口(提前200px)
    │
    ▼
回调触发 → loadMoreDevices()
    │
    ▼
deviceCurrentPage++ (1→2→3...)
    │
    ▼
displayDeviceList (computed) 自动重新计算
    slice(0, 15*2) → slice(0, 15*3) → ...
    │
    ▼
Vue 响应式更新 DOM(新增15个卡片)
    │
    ▼
哨兵元素被推到更下方
    │
    ▼
Observer 继续监听新位置的哨兵
    │
    ... 重复直到 hasMoreDevices === false
    │
    ▼
v-if="hasMoreDevices" = false → 哨兵从DOM移除
    │
    ▼
Observer 无目标 → 自动不再触发

六、方案优势总结

对比维度 传统 scroll 事件 本方案 (IntersectionObserver)
性能 滚动时高频触发,需 throttle/debounce 浏览器底层异步回调,零性能损耗
代码复杂度 需手动计算元素位置 getBoundingClientRect 声明式配置 rootMargin/threshold
兼容性 全兼容 IE 不支持,现代浏览器均支持
触发精度 节流后可能延迟或重复触发 精确触发一次,无重复

额外优点

  • 零依赖:纯浏览器原生 API,无需引入第三方库(如 vue-virtual-scroller)
  • 低侵入:仅需修改数据切片逻辑 + 添加哨兵元素,不改动现有卡片组件
  • 提前加载:通过 rootMargin 提前 200px 触发,用户几乎感知不到加载过程
  • 自动停止:数据全部加载后哨兵自动移除,Observer 不再触发

七、注意事项与踩坑

  1. $nextTick 必不可少initObserver 中获取 $refs.sentinel 必须在 DOM 更新后执行,所以需要 $nextTick 包裹
  2. 重置时机:切换组织 / 重新加载数据时,必须重置 deviceCurrentPage 并重新 initObserver
  3. 内存泄漏beforeDestroy 中务必调用 disconnectObserver() 清理
  4. Grid 布局兼容:哨兵元素需设置 grid-column: 1 / -1 确保占满整行,不会被挤到某一列
  5. v-if 而非 v-show:哨兵使用 v-if 控制而非 v-show,这样数据全部加载后哨兵完全从 DOM 移除,Observer 自然不再触发

使用 react-canvas 制作一个 Figma 工具:从画布到编辑器

使用 react-canvas 制作一个 Figma 工具:从画布到编辑器

如果你想做一个类似 Figma 的设计工具,第一反应往往是:

  • 要有高性能画布渲染
  • 要有可组合的 UI 结构
  • 要有事件命中、选中框、拖拽缩放、文本编辑
  • 还要能接入 AI(生成、改图、改布局)

我这次在 apps/open-canvas-lab 里给出的思路是:
react-canvas 作为渲染与交互底座,逐步搭一个“Figma 工具内核”。

lab.png

项目地址


为什么选 react-canvas

react-canvas 这套能力很适合做设计工具,因为它天然覆盖了编辑器最核心的三层:

  1. 场景渲染层:CanvasKit + 场景树,支持复杂布局、文本、图片、矢量
  2. 交互命中层:pick buffer + pointer 事件分发,支持精确命中
  3. 运行时层:场景节点可增删改查,可做撤销重做、选择态同步

相比“直接裸写 Canvas 2D”,这个方案的关键优势是:
你不是在拼命堆 imperative 绘图代码,而是在维护一套可演进的场景模型。


一个可落地的 Figma 工具架构

建议把应用拆成 4 个子系统:

1) Scene(文档模型)

  • 以节点树表达 Frame / Group / Text / Image / Path
  • 每个节点有 transform、style、约束信息
  • 变更统一走 command(方便 undo/redo)

2) Renderer(渲染与命中)

  • 主渲染:react-canvas 场景渲染
  • 命中:pick buffer 解析到 nodeId
  • 选中态叠加:控制框、锚点、参考线

3) Interaction(编辑器手势)

  • pointer down/move/up 组合成 drag / resize / rotate
  • 框选、吸附、对齐辅助线
  • 多选与分组操作

4) Tooling(工具链)

  • 左侧图层树、右侧属性面板
  • 顶部工具栏(选择、文本、矩形、钢笔)
  • 快捷键系统(复制、粘贴、对齐、撤销重做)

在 open-canvas-lab 的实现路线(推荐)

如果你要从 0 到 1 做出可用 MVP,可以按这个顺序:

  1. 文档与选中

    • 建立 node schema
    • 点选节点、高亮边框
  2. 变换编辑

    • 拖拽移动
    • 8 点缩放
    • 基础旋转
  3. 文本与图片

    • 文本节点样式编辑(字体、字号、行高)
    • 图片节点 object-fit / 裁剪
  4. 编辑器体验

    • 框选、多选、组合
    • 对齐吸附与辅助线
    • 撤销重做 + 操作历史
  5. 协作与 AI(进阶)

    • JSON 文档持久化
    • CRDT 协同(多人编辑)
    • AI 生成组件/版式并写回场景树

AI 能力该怎么落地(重点)

很多编辑器把 AI 做成“聊天框 + 一键生成图”,但真正可用的 AI 设计工具,关键是:
AI 输出必须是结构化编辑指令,而不是一段不可控文本。

ai.png 建议把 AI 能力拆成 3 层:

1) 意图层(Prompt / Plan)

  • 输入:自然语言需求(如“生成一个电商详情页首屏”)
  • 输出:任务计划(页面结构、组件清单、风格约束)
  • 形态:可审阅的中间 plan(用户可确认/修改)

这一层不要直接改场景,先做“可解释计划”,能显著降低误生成成本。

2) 工具层(Structured Tools)

给模型的不是“任意写 JSON”,而是明确工具集合,例如:

  • create_frame({ parentId, x, y, width, height, name })
  • create_text({ parentId, text, style })
  • create_image({ parentId, src, fit })
  • update_style({ nodeId, patch })
  • align_nodes({ nodeIds, mode })

模型只负责“调用工具”,具体执行由编辑器 runtime 保证合法性。
这样能把 AI 变成“受约束的自动化操作员”。

3) 执行层(Command Pipeline)

工具调用最终都转换为 command:

  • command[] -> validate -> apply -> layout -> render
  • 全量写入 undo/redo 栈
  • 每一步都可回滚、可重放

这保证了 AI 操作和手动操作使用同一条数据通路,不会出现“双系统分叉”。

推荐的 AI 能力清单

open-canvas-lab 里,建议优先做这 6 类能力:

  1. 从描述生成线框

    • 输入“做一个登录页”,输出基础布局骨架(frame + text + button)
  2. 风格迁移

    • 对选区做“科技蓝 / 极简黑白 / 品牌色系”重绘(仅改 style,不改结构)
  3. 批量排版

    • 统一间距、字号层级、栅格对齐
  4. 组件重写

    • 例如把“普通卡片”一键转成“带封面 + 标签 + CTA”卡片
  5. 文案智能填充

    • 生成标题、副标题、按钮文案,并支持语气风格切换
  6. 设计审查(AI Review)

    • 检查对齐、对比度、可读性、间距一致性,输出可执行修复建议

一个最小 AI 执行链路(MVP)

可以先实现下面这个闭环:

  1. 用户输入需求
  2. 模型输出 tool calls
  3. 前端校验参数(schema)
  4. 转成 command 执行
  5. 在画布高亮本次改动节点
  6. 用户可 accept / undo / retry

这个 MVP 的价值是:
你不需要先做很强的模型能力,就能把“AI 可控编辑”体验跑通。

AI 接入时最容易踩的坑

坑 1:让模型直接返回整份文档 JSON

问题:diff 巨大、不可控、很难回滚。
建议:必须改为“增量工具调用 + command 化执行”。

坑 2:AI 操作绕开编辑器状态机

问题:会破坏选中态、历史栈、约束关系。
建议:AI 与用户操作走同一 command pipeline。

坑 3:没有失败兜底

问题:工具半执行状态下文档损坏。
建议:每批 AI 操作做事务边界(失败整体回滚)。

坑 4:可解释性不足

问题:用户不知道 AI 改了什么。
建议:展示“本次修改节点清单 + 属性 diff”。


JSON 设计(简版)

为了让 AI、编辑器、存储三方都能稳定协作,建议把 JSON 拆成两层:

  1. document schema:描述页面与节点树(可持久化)
  2. command schema:描述一次编辑动作(可回放、可撤销)

1) document schema 示例

{
  "version": "1.0",
  "meta": { "name": "Landing Page", "updatedAt": 1776259200000 },
  "rootId": "frame_root",
  "nodes": {
    "frame_root": {
      "id": "frame_root",
      "type": "frame",
      "name": "Page",
      "children": ["title_1", "btn_1"],
      "layout": { "x": 0, "y": 0, "width": 1440, "height": 900 },
      "style": { "backgroundColor": "#ffffff" }
    },
    "title_1": {
      "id": "title_1",
      "type": "text",
      "text": "Build with react-canvas",
      "layout": { "x": 120, "y": 160, "width": 600, "height": 72 },
      "style": { "fontSize": 56, "fontWeight": 700, "color": "#111827" }
    },
    "btn_1": {
      "id": "btn_1",
      "type": "frame",
      "name": "CTA",
      "children": [],
      "layout": { "x": 120, "y": 280, "width": 168, "height": 48 },
      "style": { "borderRadius": 12, "backgroundColor": "#2563eb" }
    }
  }
}

2) command schema 示例

{
  "id": "cmd_20260415_001",
  "type": "update_style",
  "payload": {
    "nodeId": "btn_1",
    "patch": { "backgroundColor": "#1d4ed8", "borderRadius": 14 }
  },
  "meta": { "source": "ai", "traceId": "run_xxx" }
}

这套拆分的好处是:

  • 文档 JSON 负责“当前状态”
  • command JSON 负责“如何到达这个状态”
  • AI 输出 command,比直接覆盖整份 document 更安全

关键实现细节(踩坑重点)

坐标系统一

编辑器里至少有 3 套坐标:

  • 视口(client)
  • 画布(stage)
  • 节点局部(local)

一定要优先统一坐标映射,否则拖拽、选框和命中会经常“看起来差几像素但很难查”。

命中与视觉分离

不要用“可见像素”直接做命中判断。
正确姿势是用独立 pick 语义层(nodeId 编码),可维护性和稳定性会高很多。

编辑器状态尽量事件化

把“鼠标按下后进入哪种模式”建成有限状态机(FSM),比 scattered boolean 更稳,后续加钢笔、裁剪工具也不容易崩。


一个简单但重要的结论

做 Figma 工具真正困难的不是“画出来”,而是:

  • 模型是否可持续演进
  • 交互是否可组合
  • 渲染/命中/状态是否解耦

react-canvas 的价值在于,它已经把底层最难啃的部分(渲染与交互基础设施)提前搭好。
你可以把主要精力放在“产品能力”和“编辑体验”上。


结语

如果你正在基于 apps/open-canvas-lab 做编辑器方向的实验,这个方向是可行的:
先做一个“可编辑画板 MVP”,再逐步补齐 Figma 级能力,而不是一上来追求完整复刻。

「性能优化」虚拟列表极致优化实战:从原理到源码,打造丝滑滚动体验

前言

大家好,我是elk。

上篇文章我们聊了大文件的切片上传,这次再来看看另一个高频性能优化场景 —— 虚拟列表(Virtual List)

什么是虚拟列表?

虚拟列表「Virtual List」是一种前端性能优化技术,用于解决"长列表渲染"场景下,因DOM节点过多导致的页面卡顿,内存占用率高,首屏加载缓慢等问题。

核心思想是:只渲染当前视口可见的列表项,而非渲染全部列表数据。通过动态计算视口位置,复用DOM节点,实现"无限列表"的流畅渲染。

为什么需要虚拟列表?

在处理大数据量列表时,传统的渲染方式会面临两大瓶颈:

  1. DOM 节点过载:浏览器渲染 10,000 个复杂的 DOM 节点,内存消耗巨大。
  2. 布局与重绘:滚动时,大量的 DOM 节点重绘会导致帧率下降,产生明显的掉帧(Jank)。

适用业务场景

  • 大数据量列表渲染:后台管理系统的用户列表、日志列表、权限列表、数据报表等,数据量超1000条,全量渲染直接导致页面卡死、操作无响应。
  • 无限滚动场景:移动端信息流、商品列表、评论区、下拉选择器,用户持续下拉加载数据,DOM节点无限累加,最终引发页面崩溃。
  • 固定容器滚动列表:所有需要在固定高度容器内展示超长列表的业务场景。

核心原理

  • 视口计算:获取容器的可视高度,滚动距离,确定当前"可见区域"的范围
  • 数据截取:根据可见范围,计算需要渲染的列表项的起始索引和结束索引,从全部数据中截取范围内的数据,仅渲染截取后的可视数据
  • 偏移量计算:通过定位设置渲染区域的偏移量,让截取的数据精准的显现在视口内,模拟"滚动到指定位置的效果"
  • DOM复用:当滚动时,动态改变起始索引和结束索引,截取新的可视化数据,复用已渲染的DOM节点,减少DOM操作的开销

核心基础概念

  • 视口容器:用于展示列表的容器,用户的可见区域,通常设置为固定高度和overflow: auto
  • 列表项高度:单个列表项的高度,通常分为:"固定高度"和"动态高度"
  • 可见数量:可见区域中要展示的列表数量总个数,计算公式:Math.cell(视口高度 / 列表项高度)
  • 缓冲数量:在可见区域上下额外多渲染的数量,用于解决滚动时的"空白闪烁"问题。
  • 总高度:所有列表项的总高度,用于撑开容器,模拟长列表滚动(不设置,容器无法滚动)

核心知识点

主要是涉及到事件监听以及基础数据的计算和更新

基础知识点

滚动事件监听

通过监听容器的scoll事件,获取滚动距离(scrollTop),触发可见区域、起始索引、结束索引、可见列表、偏移量距离的计算

避免频繁触发滚动事件,需使用节流进行优化,避免过量计算损失性能

尺寸计算

  • 视口高度:可通过容器的「clientHeight」获得,一般定义固定高度
  • 滚动距离:通过容器滚动事件触发获得「scrollTop属性」
  • 固定高度:无需计算,自行设置的高度「itemHeight」
  • 动态高度:当容器滚动时,动态计算列表项的高度「clientHeight」,并列入缓存中

索引计算

起始索引「startIndex」

固定高度

index = Math.floor(scrollTop / ITEM_HEIGHT) 「滚动距离 / 固定单个项高度」

startIndex = Math.max(0, index - bufferCount) 「 减去缓冲个数获取真实起始索引 」

动态高度:需通过"累计高度"计算startIndex「遍历缓存的高度列表,通过二分法查找到大于等于scrollTop滚动距离的索引」

结束索引「endIndex」

index = startIndex + visibiliItemsCount + bufferCount 「起始索引 + 可见区域列表数量 + 缓冲量」

endIndex = Math.min( list.length, index )

偏移量计算

固定高度

    top = startIndex * ITEM_HEIGHT 「起始索引 * 单个项固定高度」

动态高度

top = prefixSumCache[startIndex] 「从高度缓存列表中获取当前起始索引的数据」

进阶知识点

在基础知识点上进行的优化措施,提升列表性能,优化用户体验

缓冲机制

当用户快速滚动时,如果是仅渲染可见区域内的数据,会出现"空白区域",数据未及时渲染

  • 缓存量设置1-5个,过多会增加DOM数量,削弱优化效果
  • 上方偏移量计算 startIndex + bufferCount , endIndex - bufferCount,就是确保上下都有缓冲

动态高度缓存与更新

在动态高度场景下,初始化时不知道每一项的真实高度,常见优化策略:

  • 先进行预估高度的渲染,渲染后通过nextTick获取真实高度
  • 将真实高度写入缓存,并重新计算前缀和
  • 后续滚动时,当实际高度和初始化缓存高度不匹配的时候才重新计算一次高度缓存

滚动事件节流

在滚动事件 handelScroll中使用了ticking锁和requestAnimationFrame

  • 滚动事件触发非常频繁,使用RAF可以确保浏览器在下一帧重绘前执行计算逻辑,避免掉帧,使滚动更平滑

二分查找优化索引定位

在动态高度场景下,需要根据 scrollTop 找到起始索引。如果每次都线性查找,时间复杂度 O(n)。利用 前缀和数组的单调递增特性,使用二分查找可将复杂度降至 O(log n)。

整体代码 —— 组件封装(Vue 3 + TypeScript)

以下是一个支持 动态高度缓冲区高度缓存二分查找 的完整虚拟列表组件。

<template>
  <div
    @scroll="handleScroll"
    ref="containerRef"
    :style="{ height: `${height}px` }"
    class="w-full position-relative top-0 left-0 overflow-auto"
  >
    <!-- 空状态 -->
    <div v-if="data.length === 0" class="w-full h-full flex items-center justify-center">
      <slot name="empty" />
    </div>
    <!-- 占位撑高容器 -->
    <template v-else>
      <div
        :style="{ height: `${containerHeight}px` }"
        class="w-full position-absolute top-0 left-0"
      ></div>
      <!-- 可视化容器 -->
      <div
        :style="{ transform: `translateY(${offset}px)` }"
        class="w-full position-absolute top-0 left-0"
      >
        <div
          v-for="(item, index) in visibleList"
          :key="item.id || index"
          ref="itemRef"
          :style="{ height: `${itemHeight}px` }"
          class="w-full flex items-center justify-center"
        >
          <slot name="default" :item="item" :index="index + startIndex" />
        </div>
      </div>
    </template>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watchEffect } from 'vue'
import type { PropType } from 'vue'

interface ListItem {
  id: number | string
  name: string
}

interface PropsParams {
  // 列表数据
  data: ListItem[]
  // 容器高度
  height: number
  // 项高度-预估高度
  itemHeight: number
  // 缓冲区数量
  bufferCount: number
}
const props: PropsParams = defineProps({
  data: {
    type: Array as PropType<ListItem[]>,
    default: () => [],
    required: true,
  },
  height: {
    type: Number,
    default: 250,
  },
  itemHeight: {
    type: Number,
    default: 50,
  },
  bufferCount: {
    type: Number,
    default: 5,
  },
})

// 容器ref
const containerRef = ref<HTMLDivElement>()
// 项ref
const itemRef = ref<HTMLDivElement[]>([])
// 滚动距离
const scrollTop = ref(0)

// 项高度-缓存集合
const itemHeightCache = ref<number[]>([])
// 前缀和-缓存集合
const prefixSumCache = ref<number[]>([])

// 可视化容器-开始索引
const startIndex = computed(() => {
  const index = getStartIndex(scrollTop.value)
  return Math.max(0, index - props.bufferCount)
})

// 可视化容器-结束索引
const endIndex = computed(() => {
  const index = startIndex.value + visibleCount.value + props.bufferCount * 2
  return Math.min(props.data.length, index)
})

// 撑开容器-高度
const containerHeight = computed(() => {
  return prefixSumCache.value[prefixSumCache.value.length - 1]
})
// 可视化容器-列表数量
const visibleCount = computed(() => {
  return Math.ceil(props.height / props.itemHeight)
})

// 可视化容器-渲染列表
const visibleList = computed(() => {
  return props.data.slice(startIndex.value, endIndex.value)
})

// 偏移量-计算
const offset = computed(() => {
  return prefixSumCache.value[startIndex.value]
})

/**
 * @description: 二分法-计算初始索引
 * @return {*}
 */
const getStartIndex = (scrollTop: number) => {
  let left = 0
  let right = prefixSumCache.value.length - 1
  while (left <= right) {
    const mid = Math.floor((left + right) / 2)
    if (prefixSumCache.value[mid] === scrollTop) return mid
    if (prefixSumCache.value[mid] > scrollTop) {
      right = mid - 1
    } else {
      left = mid + 1
    }
  }
  return left
}

/**
 * @description: 初始化高度
 * @return {*}
 */
const initHeight = () => {
  try {
    // 初始化项高度缓存集合
    itemHeightCache.value = props.data.map(() => props.itemHeight)
    // 初始化前缀和缓存集合
    initPrefixSum()
  } catch (error) {
    console.error('初始化高度失败:', error)
  }
}

/**
 * @description: 初始化|修改 前缀和缓存集合
 * @return {*}
 */
const initPrefixSum = (index: number = 0) => {
  try {
    prefixSumCache.value = []
    let sum = 0
    // 计算前缀和缓存集合,从索引开始计算,直到列表结束
    itemHeightCache.value.forEach((item, i) => {
      if (i >= index) {
        prefixSumCache.value.push(sum)
        sum += item
      }
    })
  } catch (error) {
    console.error('初始化前缀和缓存集合失败:', error)
  }
}

/**
 * @description: 修改项的真实高度-当高度发生变化时才更新
 * @return {*}
 */
const updateItemHeight = async () => {
  try {
    await nextTick()
    const visibleItems = itemRef.value
    if (visibleItems.length === 0) return
    let hasHeightChanged = false
    visibleItems.forEach((el, index) => {
      if (el) {
        const itemIndex = index + startIndex.value
        const itemHeight = el.clientHeight
        // const itemHeight = el.getBoundingClientRect().height
        // 只有高度变化的时候才更新缓存
        if (itemHeight !== itemHeightCache.value[itemIndex]) {
          itemHeightCache.value[itemIndex] = itemHeight
          hasHeightChanged = true
        }
        if (hasHeightChanged) {
          initPrefixSum(itemIndex)
        }
      }
    })
  } catch (error) {
    console.error('更新项目高度失败:', error)
  }
}

/**
 * @description: 处理滚动事件
 * @return {*}
 */
let ticking = false
const handleScroll = () => {
  console.log('🚀 ~ handleScroll ~ containerRef: 触发了滚动事件')
  if (!ticking) {
    requestAnimationFrame(() => {
      if (containerRef.value) {
        scrollTop.value = containerRef.value?.scrollTop || 0
        updateItemHeight()
      }
      ticking = false
    })
    ticking = true
  }
}

// 监听数据变化-更新项高度
watchEffect(() => {
  if (props.data.length > 0) {
    initHeight()
    updateItemHeight()
  }
})

// 初始化-更新项高度
onMounted(() => {
  initHeight()
  updateItemHeight()
})
</script>

<style lang="css" scoped></style>

常见问题 & 最佳实践

Q1:为什么我的虚拟列表在快速滚动时还是会白屏?

  • 缓冲区太小:适当增加 bufferCount(比如从 2 提升到 5)。
  • 动态高度更新不及时:确保在 nextTick 后获取真实高度,并重新计算前缀和。
  • 未使用 requestAnimationFrame:滚动回调中的 DOM 操作可能被延迟,导致渲染跟不上。

Q2:动态高度组件中,prefixSum 的维护很容易出错,有什么建议?

推荐使用 长度 = n+1 的前缀和数组,其中 prefixSum[0] = 0prefixSum[i] 表示前 i 项的总高度。这样:

  • 第 i 项的偏移量 = prefixSum[i]
  • 总高度 = prefixSum[n]
  • 查找 scrollTop 对应索引时,二分查找第一个大于 scrollTop 的 prefixSum[i],然后 i-1 即为起始索引。

Q3:如何支持列表项内容动态变化(比如展开/收起)?

  • 监听内容变化,调用 updateRealHeights 重新测量受影响的项。
  • 如果是通过用户交互(如点击展开),可以手动触发更新并重新构建前缀和。

Q4:除了 transform 偏移,还有别的方案吗?

也可以使用 padding-top 偏移,但 transform 性能更好(不触发重排)。推荐使用 translateY

总结

虚拟列表是前端性能优化中 性价比极高 的一类技术 —— 实现成本可控,却能将万级列表的渲染性能从秒级降到毫秒级。本文从原理到代码,覆盖了固定高度、动态高度、缓冲区、二分查找、滚动节流等关键点。

优化永无止境,如果你还想更进一步,可以探索:

  • 使用 ResizeObserver 监听每一项的尺寸变化,自动更新高度缓存。
  • 结合 IntersectionObserver 实现可视区外图片懒加载。
  • 将虚拟列表与 分页 / 懒加载数据 结合,实现真正意义上的“无限滚动”。

希望这篇文章能帮你彻底掌握虚拟列表,写出更流畅的 Web 应用。如果觉得有帮助,欢迎点赞、评论、转发~

Angular 基础知识点全汇总(附实战示例 | 新手友好)

前言

Angular 是由 Google 维护的企业级前端框架,基于 TypeScript 开发,内置路由、表单、HTTP、依赖注入等全套解决方案,适合中大型后台管理系统、企业级应用开发。本文整理了 Angular 从入门到实战的全套基础知识点,每个知识点搭配可直接运行的代码示例,新手也能快速上手!

适用版本:Angular 14+ / 18+(长期支持版)阅读对象:前端新手、Vue/React 转 Angular 开发者

1. Angular 核心概述

核心特点

  • 完整的企业级框架(全家桶,无需额外集成第三方库)
  • 强类型:基于 TypeScript 开发
  • 内置依赖注入 (DI)、路由、表单、HTTP 客户端
  • 单向数据流 + 可选双向绑定
  • 适合大型团队、长期维护的项目

核心组成

  • 模块 (Module) :组织应用的最小单元
  • 组件 (Component) :页面的最小单元
  • 服务 (Service) :公共逻辑封装
  • 指令 / 管道 / 路由:扩展功能

2. 环境搭建与项目创建

2.1 安装 Angular CLI

bash 运行

# 全局安装 Angular 脚手架
npm install -g @angular/cli

# 验证安装
ng version

2.2 创建新项目

bash 运行

# 创建项目(支持路由、SCSS、严格模式)
ng new angular-demo --routing --style=scss --strict

# 进入项目
cd angular-demo

# 启动项目(默认端口4200)
ng serve --open

2.3 常用 CLI 命令

bash 运行

# 创建组件
ng generate component components/home
# 简写
ng g c components/home

# 创建服务
ng g s services/http

# 创建路由模块
ng g m app-routing --module=app --flat

3. 核心概念:模块 (Module)

定义

模块是 Angular 应用的组织结构,一个应用至少有一个根模块 AppModule

核心作用

  • 声明组件、指令、管道
  • 导入依赖模块
  • 提供服务
  • 启动应用

示例:根模块 app.module.ts

typescript 运行

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
// 路由模块
import { AppRoutingModule } from './app-routing.module';
// 根组件
import { AppComponent } from './app.component';
// 自定义组件
import { HomeComponent } from './components/home/home.component';

@NgModule({
  // 声明:当前模块的组件/指令/管道
  declarations: [
    AppComponent,
    HomeComponent
  ],
  // 导入:依赖的其他模块
  imports: [
    BrowserModule,
    AppRoutingModule
  ],
  // 提供:全局服务
  providers: [],
  // 启动:根组件
  bootstrap: [AppComponent]
})
export class AppModule { }

4. 组件 (Component) 基础

定义

组件是 Angular 应用的页面最小单元,由 HTML 模板 + TS 逻辑 + CSS 样式 组成。

组件结构

  1. xxx.component.ts:逻辑 / 数据
  2. xxx.component.html:模板
  3. xxx.component.scss:样式
  4. xxx.component.spec.ts:测试文件

示例:自定义组件

typescript 运行

// home.component.ts
import { Component } from '@angular/core';

@Component({
  // 组件选择器(HTML标签)
  selector: 'app-home',
  // 模板路径
  templateUrl: './home.component.html',
  // 样式路径
  styleUrls: ['./home.component.scss']
})
export class HomeComponent {
  // 组件数据
  title = 'Angular 入门组件';
  // 方法
  sayHello() {
    alert('Hello Angular!');
  }
}

html 预览

<!-- home.component.html -->
<div class="home">
  <h2>{{ title }}</h2>
  <button (click)="sayHello()">点击我</button>
</div>

5. 模板基础语法

5.1 插值表达式

作用:渲染组件中的变量

html 预览

<h1>{{ 变量名 }}</h1>
<p>{{ 1 + 1 }}</p>
<p>{{ name.toUpperCase() }}</p>

5.2 属性绑定

作用:给 HTML 标签动态绑定属性

html 预览

<!-- 原生属性 -->
<img [src]="imgUrl" alt="图片">
<!-- 类名绑定 -->
<div [class.active]="isActive">激活状态</div>
<!-- 样式绑定 -->
<div [style.color]="textColor">文字颜色</div>

6. 数据绑定(单向 / 双向)

6.1 单向绑定

  1. 组件 → 模板[]
  2. 模板 → 组件()

6.2 双向绑定(核心)

依赖 FormsModule,用于表单数据同步

html 预览

<input [(ngModel)]="username" placeholder="请输入用户名">
<p>你输入的用户名:{{ username }}</p>

使用前提:在 app.module.ts 导入模块

typescript 运行

import { FormsModule } from '@angular/forms';

imports: [BrowserModule, FormsModule]

7. Angular 内置指令

分为 结构指令(修改 DOM 结构)和 属性指令(修改 DOM 样式 / 属性)

7.1 结构指令

1. *ngIf 条件渲染

html 预览

<div *ngIf="isShow">显示内容</div>
<div *ngIf="!isShow">隐藏内容</div>

2. *ngFor 列表渲染

html 预览

<ul>
  <li *ngFor="let item of list; let i = index">
    索引:{{ i }},内容:{{ item.name }}
  </li>
</ul>

3. *ngSwitch 多条件判断

html 预览

<div [ngSwitch]="status">
  <p *ngSwitchCase="1">待支付</p>
  <p *ngSwitchCase="2">已支付</p>
  <p *ngSwitchDefault>未知状态</p>
</div>

7.2 属性指令

1. ngClass 动态类名

html 预览

<div [ngClass]="{ active: isActive, disabled: isDisabled }">
  动态样式
</div>

2. ngStyle 动态样式

html 预览

<div [ngStyle]="{ color: 'red', fontSize: '20px' }">
  内联样式
</div>

8. 事件绑定与用户交互

8.1 基础事件绑定

html 预览

<!-- 点击事件 -->
<button (click)="handleClick()">点击</button>
<!-- 输入事件 -->
<input (input)="handleInput($event)" />
<!-- 表单提交 -->
<form (ngSubmit)="handleSubmit()"></form>

8.2 事件对象

typescript 运行

handleInput(e: Event) {
  const value = (e.target as HTMLInputElement).value;
  console.log(value);
}

9. 组件通讯(核心)

9.1 父组件 → 子组件(@Input)

子组件

typescript 运行

import { Component, Input } from '@angular/core';

@Component({ selector: 'app-child' })
export class ChildComponent {
  // 接收父组件数据
  @Input() msg = '';
}

父组件模板

html 预览

<app-child [msg]="父组件传递的数据"></app-child>

9.2 子组件 → 父组件(@Output + EventEmitter)

子组件

typescript 运行

import { Component, Output, EventEmitter } from '@angular/core';

export class ChildComponent {
  @Output() sendMsg = new EventEmitter<string>();
  
  sendToParent() {
    this.sendMsg.emit('子组件传递的消息');
  }
}

父组件

html 预览

<app-child (sendMsg)="getMsg($event)"></app-child>

typescript 运行

getMsg(msg: string) {
  console.log(msg);
}

9.3 兄弟组件通讯

步骤 1:创建消息服务

bash 运行

ng g s services/message

typescript 运行

// message.service.ts
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class MessageService {
  // 订阅主体
  private msgSubject = new Subject<any>();

  // 发送消息
  sendMessage(data: any) {
    this.msgSubject.next(data);
  }

  // 接收消息
  getMessage() {
    return this.msgSubject.asObservable();
  }
}

步骤 2:兄弟组件 A(发送方)

typescript 运行

// brother-a.component.ts
import { MessageService } from '../../services/message.service';
constructor(private msgService: MessageService) {}

sendBrotherMsg() {
  this.msgService.sendMessage('来自兄弟A的消息');
}

html 预览

<button (click)="sendBrotherMsg()">发送给兄弟B</button>

步骤 3:兄弟组件 B(接收方)

typescript 运行

// brother-b.component.ts
import { MessageService } from '../../services/message.service';
import { Subscription } from 'rxjs';

msg = '';
sub!: Subscription;

constructor(private msgService: MessageService) {}

ngOnInit() {
  // 订阅消息
  this.sub = this.msgService.getMessage().subscribe(data => {
    this.msg = data;
  });
}

// 销毁时取消订阅(防内存泄漏)
ngOnDestroy() {
  this.sub.unsubscribe();
}

html 预览

<p>接收兄弟消息:{{ msg }}</p>

步骤 4:父组件承载两个兄弟组件

html 预览

<!-- parent.component.html -->
<app-brother-a></app-brother-a>
<app-brother-b></app-brother-b>

10. 服务与依赖注入 (Service)

typescript

运行

// data.service.ts
@Injectable({ providedIn: 'root' })
export class DataService {
  user = { name: 'Angular' };
  getUser() { return this.user; }
}

组件使用

typescript

运行

constructor(private dataService: DataService) {}
ngOnInit() {
  console.log(this.dataService.getUser());
}

11. 路由 (Routing) 基础

typescript

运行

// app-routing.module.ts
const routes: Routes = [
  { path: '', redirectTo: 'home', pathMatch: 'full' },
  { path: 'home', component: HomeComponent },
  { path: '**', component: HomeComponent }
];

html

预览

<router-outlet></router-outlet>
<a routerLink="/home">首页</a>

10. 服务与依赖注入 (Service)

定义

服务用于封装公共逻辑(HTTP 请求、工具函数、全局状态),实现业务解耦。

10.1 创建服务

bash 运行

ng g s services/data

10.2 服务示例

typescript 运行

// data.service.ts
import { Injectable } from '@angular/core';

// 注入根组件(全局单例)
@Injectable({ providedIn: 'root' })
export class DataService {
  // 公共数据
  userInfo = { name: 'Angular用户' };
  
  // 公共方法
  getUserInfo() {
    return this.userInfo;
  }
}

10.3 组件使用服务(依赖注入)

typescript 运行

import { DataService } from '../../services/data.service';

export class HomeComponent {
  // 依赖注入服务
  constructor(private dataService: DataService) {}
  
  ngOnInit() {
    // 使用服务
    const user = this.dataService.getUserInfo();
    console.log(user);
  }
}

11. 路由 (Routing) 基础

11.1 路由配置

typescript 运行

// app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './components/home/home.component';
import { AboutComponent } from './components/about/about.component';

const routes: Routes = [
  // 默认路由
  { path: '', redirectTo: '/home', pathMatch: 'full' },
  { path: 'home', component: HomeComponent },
  { path: 'about', component: AboutComponent },
  // 404路由
  { path: '**', redirectTo: '/home' }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

11.2 路由出口 + 路由跳转

html 预览

<!-- 路由出口(渲染组件) -->
<router-outlet></router-outlet>

<!-- 声明式跳转 -->
<a routerLink="/home">首页</a>
<a routerLink="/about">关于</a>

<!-- 编程式跳转 -->
<button (click)="toAbout()">跳转到关于页</button>

typescript 运行

// 编程式导航
import { Router } from '@angular/router';
constructor(private router: Router) {}
toAbout() {
  this.router.navigate(['/about']);
}

12. 表单开发(模板驱动 / 响应式)

12.1 模板驱动表单(简单表单)

html 预览

<form #myForm="ngForm" (ngSubmit)="onSubmit(myForm)">
  <input name="username" ngModel required placeholder="用户名">
  <button type="submit">提交</button>
</form>

12.2 响应式表单(复杂表单,推荐)

导入模块

typescript 运行

import { ReactiveFormsModule } from '@angular/forms';

使用

typescript 运行

import { FormBuilder, FormGroup, Validators } from '@angular/forms';

export class LoginComponent {
  loginForm: FormGroup;
  
  constructor(private fb: FormBuilder) {
    // 初始化表单
    this.loginForm = this.fb.group({
      username: ['', [Validators.required, Validators.minLength(2)]],
      password: ['', [Validators.required]]
    });
  }
  
  onSubmit() {
    console.log(this.loginForm.value);
  }
}

html 预览

<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
  <input formControlName="username">
  <input formControlName="password">
  <button type="submit" [disabled]="!loginForm.valid">提交</button>
</form>

13. HTTP 网络请求

13.1 导入模块

typescript 运行

import { HttpClientModule } from '@angular/common/http';

13.2 封装 HTTP 服务

typescript 运行

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class HttpService {
  constructor(private http: HttpClient) {}
  
  // GET请求
  getList(): Observable<any> {
    return this.http.get('https://api.example.com/list');
  }
  
  // POST请求
  addData(data: any): Observable<any> {
    return this.http.post('https://api.example.com/add', data);
  }
}

13.3 组件使用

typescript 运行

this.httpService.getList().subscribe({
  next: (res) => console.log(res),
  error: (err) => console.error(err)
});

14. 管道 (Pipe)

14.1 内置管道

html 预览

<!-- 日期管道 -->
<p>{{ now | date:'yyyy-MM-dd' }}</p>
<!-- 大小写管道 -->
<p>{{ name | uppercase }}</p>
<!-- 小数管道 -->
<p>{{ num | number:'1.2-2' }}</p>
<!-- JSON管道 -->
<p>{{ obj | json }}</p>

14.2 自定义管道

示例 1:性别转换管道

bash 运行

ng g p pipes/sex-transform

typescript 运行

// sex-transform.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'sexTransform' })
export class SexTransformPipe implements PipeTransform {
  // value:传入值,args:额外参数
  transform(value: number): string {
    switch (value) {
      case 1: return '男';
      case 2: return '女';
      default: return '未知';
    }
  }
}

使用方式

html 预览

<p>{{ 1 | sexTransform }}</p> <!-- 输出:男 -->
<p>{{ 2 | sexTransform }}</p> <!-- 输出:女 -->

示例 2:数组过滤管道

typescript 运行

// filter.pipe.ts
@Pipe({ name: 'filterList' })
export class FilterListPipe implements PipeTransform {
  transform(list: any[], key: string, keyword: string): any[] {
    if (!keyword) return list;
    return list.filter(item => item[key].includes(keyword));
  }
}

html 预览

<li *ngFor="let item of list | filterList: 'name': keyword">{{item.name}}</li>

15. 组件生命周期钩子

15.1 Angular 共有 8 个生命周期钩子,按执行顺序排列,包含创建 / 更新 / 销毁全流程

typescript 运行

import {
  Component,
  OnChanges,
  OnInit,
  DoCheck,
  AfterContentInit,
  AfterContentChecked,
  AfterViewInit,
  AfterViewChecked,
  OnDestroy,
  Input
} from '@angular/core';

@Component({
  selector: 'app-life-cycle',
  template: `<p>{{ msg }}</p>`
})
export class LifeCycleComponent implements
  OnChanges,
  OnInit,
  DoCheck,
  AfterContentInit,
  AfterContentChecked,
  AfterViewInit,
  AfterViewChecked,
  OnDestroy {

  @Input() msg = '测试';

  // 1. 输入属性(@Input)改变时触发
  ngOnChanges(): void {
    console.log('1. ngOnChanges - 属性改变');
  }

  // 2. 组件初始化(最常用,请求数据)
  ngOnInit(): void {
    console.log('2. ngOnInit - 组件初始化完成');
  }

  // 3. 脏值检测(每次变更检测触发)
  ngDoCheck(): void {
    console.log('3. ngDoCheck - 变更检测');
  }

  // 4. 内容投影初始化完成
  ngAfterContentInit(): void {
    console.log('4. ngAfterContentInit - 内容投影初始化');
  }

  // 5. 内容投影变更检测
  ngAfterContentChecked(): void {
    console.log('5. ngAfterContentChecked - 内容检测');
  }

  // 6. 视图初始化完成(操作DOM)
  ngAfterViewInit(): void {
    console.log('6. ngAfterViewInit - 视图渲染完成');
  }

  // 7. 视图变更检测
  ngAfterViewChecked(): void {
    console.log('7. ngAfterViewChecked - 视图检测');
  }

  // 8. 组件销毁(清理定时器/订阅)
  ngOnDestroy(): void {
    console.log('8. ngOnDestroy - 组件销毁');
  }
}

15.2 执行顺序

  1. ngOnChanges → 输入属性变化
  2. ngOnInit → 初始化
  3. ngDoCheck → 每次渲染检查
  4. ngAfterContentInit → 内容投影
  5. ngAfterContentChecked → 内容检查
  6. ngAfterViewInit → DOM 渲染完成
  7. ngAfterViewChecked → 视图检查
  8. ngOnDestroy → 组件销毁

15.3 核心使用场景

  • ngOnInit发起网络请求、初始化数据
  • ngOnChanges:监听父组件传值变化
  • ngAfterViewInit:操作 DOM 元素
  • ngOnDestroy清除定时器、取消订阅、防内存泄漏

16. 常用装饰器总结

表格

装饰器 作用 位置
@Component 定义组件 组件类
@NgModule 定义模块 模块类
@Injectable 定义服务 服务类
@Input 父传子 子组件属性
@Output 子传父 子组件事件
@ViewChild 获取 DOM / 子组件 组件属性

17. 项目打包与部署

17.1 生产打包

bash 运行

ng build --prod
# 或
ng build --configuration production

打包产物:dist/ 目录

17.2 部署

dist 目录静态文件部署到 Nginx、Apache、GitHub Pages 等平台。


18. 总结

本文覆盖了 Angular 入门所有核心基础知识点,包含:

  • 环境搭建、模块 / 组件基础
  • 模板语法、数据绑定、内置指令
  • 组件通讯、服务注入、路由、表单、HTTP
  • 生命周期、管道、打包部署

Angular 作为企业级框架,学习曲线略陡,但规范统一、生态完善、长期维护,非常适合中大型项目开发。掌握以上知识点,即可独立开发 Angular 中小型应用!

flutter布局(列表组件)

通用ScrollController

  • 控制器加上必先挂载销毁

      /// 初始化
      ScrollController _scrollController = ScrollController();
      
      /// 销毁控制器
      @override
      void dispose(){
          _scrollController.dispose();
          super.dispose();
      }
      
      /// 绑定
      ListView(
          controller:_scrollController, //绑定控制器
      )
    
  • 滚动到顶部/指定位置

      /// 安全判断
      if(!_scrollController.hasClients) return;
      /// 滚动到顶部
      _scrollController.jumpTo(0); /// 跳转到顶部,无动画
      _scrollController.animateTo( /// 动画滚动
          0,
          duration: const Duration(molliseconds:300), //
          curve:Curves.easeOut, // 动画曲线
      )
      
      /// 滚动至底部(_scrollController.position.maxScrollExtent)
      final maxExtent = _scrollController.position.maxScrollExtent;
      _scrollController.jumpTo(maxExtent);
      ...
      
    
  • 滚动吸顶/隐藏导航栏/下拉更多等

      /// 监听+状态
      _scrollController.addListener((){
          /// 底部200px 触发加载
          double maxExtent = _scrollController.position.maxScrollExtent;
          double currentOffset = _scrollController.offset;/// 滚动到的位置
          
          if(currentOffset >= maxExtent - 200 && !isloading) {
              loadMoreDate();
          }
          
          /// 吸顶
          if(currentOffset <= 50){
              setState({
                  isCeilingMounted = true;
              })
          }else{
              setState({
                  isCeilingMounted = false;
              })        
          }
      })
    
  • 常用api

    • 判定是否有挂载

        _scrollController.hasClients
      
    • 卸载

        _scrollController.dispose();
        
      
    • 当前滚动位置

        _scrollController.offset
        _scrollController.position.pixels
      
    • 列表最大滚动位置

        _scrollController.position.maxScrollExtent;
      
    • 滚动

        _scrollController.jumpTo();
        _scrollController.animateTo(
            0,
            duration:const Duration(milliseconds:300),
            curve: Curves.ease
        )
      
    • 滚动监听

        _scrollController.addListener((){
            
        })
      

列表组件

ListView/ListView.builder/ListView.separated

  • 共用api

      scrollDirection //默认Axis.vertical(垂直) Axis.horizontal(水平)
      reverse // 默认false
      padding // 默认null
      shrinkWrap // 高度自适应 默认false,性能慎用!
      controller // 控制器
      itemExtent //固定子项高度/宽度
      cacheExtent // 预渲染缓存区域大小
      prototypeItem // 按照样本组件自适应高度
      addautomaticKeepAlives //默认true 保持已经加载好的子项状态,防止滑出屏幕后重建
      physics // 滚动物理效果 
      ///ClampingScrollPhysics(边界“撞墙”+微光效果 安卓默认)
      ///BouncingScrollPhysics(弹性回弹效果 IOS默认)
      ///NeverScrollableScrollPhysics(禁止滚动)
      ///AlwaysScrollableScrollPhysics(强制可滚动)
      ///PageScrollPhysics(以整页滑动、吸附效果明显)
      
    

补充全局效果设置:MaterialApp( scrollBehavior: MaterialScrollBehavior().copyWith( physics: const ClampingScrollPhysics(), // 全平台强制使用Android效果 ), );

  • 列表不超过15条可用ListView

  • 重点属性

    • ListView.builder——只创建/渲染屏幕可见区域+附近区域(超过15条考虑使用)
    • itemExtent——告知每一项的宽度/高度,提高命中率和性能(必用)
    • control——控制滚动
    • scrollDirection——Axis.horizontal/Axis.vertical控制滚动方向
    • itemExtent——列表长度
    • builder/separated专属(itemCount+itemBuilder)
  • ListView.separated

     ListView.separated(
         itemExtent:,
         itemCount:,
         itemBuilder:(context,index),
         separatorBuilder:(context,index){
             return Padding()
         }
     )
     
    
  • 性能优化相关

    • ListView.builder/ListView.separated(必需)
    • itemExtent (尽量必需,性能最好)
    • cacheExtent (必需,但值不能太大)
    • addAutomaticKeepAlives: true (通常必需)
    • shrinkWrap:true (性能消耗大,慎用!!!)

GridView/GridView.builder/GridView.extent

  • gridDelegate

    SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2, // 强制2列
        mainAxisSpacing: 10, // 上下间距10px
        crossAxisSpacing: 10, // 左右间距10px
        childAspectRatio: 1.5, // 子项宽高比
    )
    SliverGridDelegateWithMaxCrossAxisExtent( // 设置子项的最大宽度,即如果屏幕宽度有500,则(150*n)+(10*(n-1))<=500
        maxCrossAxisExtent: 150, // 子项最大宽度150px
        mainAxisSpacing: 10, // 上下间距10px
        crossAxisSpacing: 10, // 左右间距10px
        childAspectRatio: 1.0, // 正方形
    )
    
  • GridView.extent 即透传了 SliverGridDelegateWithMaxCrossAxisExtent,可以直接在GridView.extent中写SliverGridDelegateWithMaxCrossAxisExtent的属性

    GridView.extent(
        controller:_controller,
        children:<Widget>[],
        double maxCrossAxisExtent,
        double mainAxisSpacing,
        double crossAxisSpacing,
        double? childAspectRatio 
    )
    

SingleChildScrollView

  • 较短的滚动页面(长列表情况性能消耗较大)
  • 通常用作页面防溢出或短滚动

CustomScrollView

  • 大部分属性跟ListView和GridView一样,下面只例举较常用的api
  • cacheExtent(默认250)可设置150左右
  • shrinkWrap(是否自适应子组件高度)——会破坏懒加载,性能变差
  • slivers
    • SliverAppBar——折叠式标题栏,支持悬浮、吸顶、折叠
    • SliverList——对应ListView
    • SliverGrid——对应GridView
    • SliverToBoxAdapter——将普通Widget作为child传入Sliver
    • SliverPadding——内边距
    • SliverFillRemaining——填充页面剩余空间

用wagmi v2构建DeFi前端:从连接钱包到读取合约数据的完整实战与避坑指南

背景

上个月,我接手了一个DeFi收益聚合器项目的前端重构任务。老代码用的是 ethers.js + 自己封装的钱包连接逻辑,维护起来非常头疼,尤其是多链支持和交易状态跟踪的部分,bug频出。团队决定迁移到更现代的 wagmi v2 搭配 viem,希望利用其声明式的Hooks来简化状态管理。我的任务很明确:用 React + wagmi v2 搭建一个新的前端基础框架,核心要搞定钱包连接、实时读取用户在不同链上的资产余额、以及一个关键合约(质押池)的数据。

一开始我以为照着官方文档拼凑一下就行,结果在实际开发中,从钱包连接状态同步到合约数据读取,我踩了一路的坑。这篇文章就是我解决这些问题的完整记录。

问题分析

我最开始的思路很简单:按照wagmi官方示例,配置好WagmiProvider,用useConnect连接钱包,用useAccount获取账户,然后用useReadContract读取数据。但一上手就发现了问题。

首先,当用户在MetaMask里切换网络时,前端应用的状态并没有立即同步更新。用户从以太坊主网切换到Arbitrum,但UI上显示的链ID还是1,这会导致后续所有针对错误链的合约调用失败。其次,在读取用户在不同链上的ERC20代币余额时,我需要根据当前激活的链动态切换合约地址,但最初的实现里,链切换后合约查询并没有自动重新执行。最后,在用户进行质押操作后,我需要准确监听交易状态(提交、打包、成功/失败),并实时更新UI上的余额数据,避免用户看到陈旧信息。

排查过程让我意识到,wagmi虽然抽象得很好,但如果不理解其内部的状态更新机制和Hooks的依赖关系,很容易写出看起来能跑但实际上有隐性bug的代码。问题的核心在于如何让React组件状态与钱包的外部状态(链、账户)保持强同步,以及如何正确构造依赖数组以触发查询的重新执行。

核心实现

1. 配置Provider与多链支持

第一步是正确配置WagmiProvider。这里我选择了项目需要支持的四个链:Ethereum, Arbitrum, Optimism和Polygon。我使用viem提供的预定义链配置,并创建了一个自定义的wagmi配置对象。这里有个关键点config对象必须被稳定地引用,最好在React组件外部创建,或者用useMemo包裹,防止它在每次渲染时重新创建,导致不必要的上下文重置。

// src/providers/WagmiProvider.tsx
import { createConfig, http, WagmiProvider as WagmiProviderCore } from 'wagmi';
import { mainnet, arbitrum, optimism, polygon } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { injected } from 'wagmi/connectors';

// 创建稳定的查询客户端和配置
const queryClient = new QueryClient();

const config = createConfig({
  chains: [mainnet, arbitrum, optimism, polygon],
  connectors: [injected()], // 主要支持注入式钱包(如MetaMask)
  transports: {
    [mainnet.id]: http(),
    [arbitrum.id]: http(),
    [optimism.id]: http(),
    [polygon.id]: http(),
  },
});

export function WagmiProvider({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProviderCore config={config}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProviderCore>
  );
}

2. 实现可靠的钱包连接与链状态同步

接下来是连接组件。我不仅需要连接按钮,还需要一个实时显示当前网络和账户的组件。useAccount Hook提供了address, chainId, connector等信息,并且会响应钱包扩展程序的状态变化。但为了处理网络切换,我必须结合使用useSwitchChain

踩过的一个坑:最初我试图用useAccountchainId直接作为读取合约数据的链依据,但当用户拒绝网络切换请求时,chainId可能处于“期望切换”但“实际未变”的中间状态。更好的做法是,对于关键操作(如发送交易),始终使用useAccount返回的chain对象,并结合错误处理。

// src/components/ConnectButton.tsx
import { useConnect, useAccount, useDisconnect, useSwitchChain } from 'wagmi';

export function ConnectButton() {
  const { connect, connectors, isPending } = useConnect();
  const { address, chain, isConnected } = useAccount();
  const { disconnect } = useDisconnect();
  const { switchChain } = useSwitchChain();

  const handleConnect = () => {
    // 默认连接第一个注入式连接器(如MetaMask)
    connect({ connector: connectors[0] });
  };

  const handleSwitchChain = (targetChainId: number) => {
    switchChain({ chainId: targetChainId });
  };

  if (!isConnected) {
    return (
      <button onClick={handleConnect} disabled={isPending}>
        {isPending ? 'Connecting...' : 'Connect Wallet'}
      </button>
    );
  }

  return (
    <div>
      <p>Connected: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
      <p>Network: {chain?.name} (ID: {chain?.id})</p>
      <div>
        <button onClick={() => handleSwitchChain(arbitrum.id)}>Switch to Arbitrum</button>
        <button onClick={() => disconnect()}>Disconnect</button>
      </div>
    </div>
  );
}

3. 动态读取多链合约数据

这是DeFi前端的核心。我需要读取用户在某条链上的特定代币余额。合约地址因链而异。useReadContract Hook接收一个配置对象,当其中的addresschainIdaccount发生变化时,它会自动重新获取数据。

注意这个细节useReadContractquery选项中的enabled属性非常有用。我可以设置enabled: !!address && !!chainId,这样只有当用户钱包已连接且链ID明确时,才会发起查询,避免了不必要的错误请求和日志噪音。

// src/hooks/useTokenBalance.ts
import { useReadContract, useAccount } from 'wagmi';
import { erc20Abi } from 'viem';

// 不同链上的USDC合约地址映射
const USDC_ADDRESS: Record<number, `0x${string}`> = {
  1: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum Mainnet
  42161: '0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8', // Arbitrum
  10: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism
  137: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', // Polygon
};

export function useTokenBalance() {
  const { address, chainId } = useAccount();

  const { data: balance, isLoading, error, refetch } = useReadContract({
    abi: erc20Abi,
    address: chainId ? USDC_ADDRESS[chainId] : undefined,
    functionName: 'balanceOf',
    args: address ? [address] : undefined,
    query: {
      enabled: !!address && !!chainId, // 关键:确保条件满足才查询
      refetchInterval: 10000, // 每10秒自动刷新一次
    },
  });

  return {
    balance,
    isLoading,
    error,
    refetch, // 暴露手动刷新函数,用于交易后更新
  };
}

4. 执行合约写入与交易状态监听

用户操作,比如质押代币,需要发送交易。我使用useWriteContract来发起交易,但更重要的是监听交易状态。wagmi v2 通过useWaitForTransactionReceipt Hook提供了优雅的解决方案。

这里有个大坑useWriteContract返回的writeContractAsync函数在调用时,必须明确指定chainId。即使你的config里配置了多链,且用户当前已切换到目标链,如果你不传chainId,它有时会默认使用配置中的第一个链(比如主网),导致交易发错链。务必显式传递accountchainId

// src/components/StakeForm.tsx
import { useState } from 'react';
import { useWriteContract, useWaitForTransactionReceipt, useAccount } from 'wagmi';
import { parseUnits } from 'viem';

const stakingPoolAbi = [ /* 你的质押合约ABI */ ] as const;
const STAKING_POOL_ADDRESS = '0x...'; // 你的质押合约地址

export function StakeForm() {
  const [amount, setAmount] = useState('');
  const { address, chainId } = useAccount();

  const {
    writeContractAsync,
    isPending: isWritePending,
    data: hash,
    error: writeError,
    reset: resetWrite,
  } = useWriteContract();

  const {
    isLoading: isConfirming,
    isSuccess: isConfirmed,
    error: receiptError,
  } = useWaitForTransactionReceipt({
    hash,
    query: {
      enabled: !!hash, // 只有有交易哈希时才启动监听
    },
  });

  const handleStake = async () => {
    if (!address || !chainId) return;
    try {
      resetWrite(); // 重置上一次的写入状态
      await writeContractAsync({
        abi: stakingPoolAbi,
        address: STAKING_POOL_ADDRESS,
        functionName: 'stake',
        args: [parseUnits(amount, 18)], // 假设代币精度18
        account: address,
        chainId: chainId, // !!!务必显式指定链ID
      });
      // 交易哈希已提交,状态由 useWaitForTransactionReceipt 监听
    } catch (err) {
      console.error('Stake failed:', err);
    }
  };

  return (
    <div>
      <input value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="Amount to stake" />
      <button onClick={handleStake} disabled={isWritePending || !amount}>
        {isWritePending ? 'Confirming in wallet...' : 'Stake'}
      </button>
      {isConfirming && <p>Transaction is being confirmed...</p>}
      {isConfirmed && <p>Stake successful! <button onClick={() => refetchBalance()}>Refresh Balance</button></p>}
      {writeError && <p style={{ color: 'red' }}>Error: {writeError.message}</p>}
    </div>
  );
}

完整代码示例

下面是一个整合了以上关键部分的简化版主应用组件,你可以直接复制到一个新的React项目中运行(需先安装依赖)。

// App.tsx
import { WagmiProvider } from './providers/WagmiProvider';
import { ConnectButton } from './components/ConnectButton';
import { useTokenBalance } from './hooks/useTokenBalance';
import { StakeForm } from './components/StakeForm';

function AppContent() {
  const { balance, isLoading, error, refetch } = useTokenBalance();

  return (
    <div style={{ padding: '20px' }}>
      <h1>DeFi Staking Dashboard</h1>
      <ConnectButton />
      <hr />
      <h2>Your USDC Balance</h2>
      {isLoading && <p>Loading balance...</p>}
      {error && <p>Error loading balance: {error.message}</p>}
      {balance !== undefined && (
        <p>Balance: {balance.toString()} units (raw)</p>
        // 实际应用中,这里需要根据代币精度格式化显示
      )}
      <button onClick={() => refetch()}>Refresh Balance</button>
      <hr />
      <h2>Stake Tokens</h2>
      <StakeForm onSuccess={refetch} /> {/* 传入刷新余额的回调 */}
    </div>
  );
}

export default function App() {
  return (
    <WagmiProvider>
      <AppContent />
    </WagmiProvider>
  );
}

踩坑记录

  1. useReadContract 不自动更新:当用户切换钱包账户后,余额查询没有更新。原因:我忘记将address作为args的一部分。args: [address]必须依赖address变量,当address变化时,查询才会重新执行。解决:确保args正确绑定到响应式变量(如来自useAccountaddress)。

  2. 交易发错链:用户在Arbitrum上点击质押,交易却发到了以太坊主网,导致失败和Gas费损失。原因:调用writeContractAsync时没有显式传递chainId参数。解决:始终从useAccount中获取当前的chainId,并在写入合约时明确指定chainId: currentChainId

  3. “RPC Error: Rate Limited”:在开发时频繁刷新页面,快速连接/断开钱包,导致Infura或Alchemy的RPC端点报速率限制错误。原因wagmihttp()传输层默认没有配置请求节流或重试。解决:为生产环境配置更健壮的RPC提供商,或者使用viemfallback传输层,设置多个RPC端点作为备用。例如:transport: fallback([http('https://mainnet.infura.io/v3/your-key'), http()])

  4. **TypeScript类型错误:0xstring:在定义合约地址常量时,直接写字符串字面量,TypeScript报错,要求是0ˋx{string}`”`**:在定义合约地址常量时,直接写字符串字面量,TypeScript报错,要求是`\`0x{string}`类型。**原因**:viemwagmi为了类型安全,要求地址是严格的以0x开头的十六进制字符串类型。**解决**:使用类型断言as `0x${string}``,或者确保你的地址常量符合该模板字面量类型。

小结

通过这一轮实战,我深刻体会到在Web3前端开发中,状态同步的可靠性远比功能实现更重要。wagmi v2配合viem提供了强大的基础,但开发者必须清晰地理解:账户、链ID、合约地址如何作为Hooks的依赖项驱动数据流。下一步,我计划深入研究wagmi的存储持久化和自定义缓存策略,以进一步提升复杂DeFi应用的用户体验。

❌