普通视图

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

Monorepo入门

作者 Hyyy
2026年1月18日 00:35

1. Monorepo 介绍

核心价值:把“需要一起演进的一组项目”放在同一个版本空间里,从而让跨项目改动(API 变更、重构、升级)能在一次提交里完成并验证

Monorepo 是把多个相关项目/包放在同一个 Git 仓库中管理的策略,有助于跨项目联动修改、内部包共享更顺畅、统一规范与 CI、版本控制、构建和部署等方面的复杂性,并提供更好的可重用性和协作性。

Monorepo 提倡了开放、透明、共享的组织文化,这种方法已经被很多大型公司广泛使用,如 Google、Facebook 和 Microsoft 等。

2. Monorepo 演进

image.png

2.1 阶段一:单仓库巨石应用(Monolith)

初期很爽:一个仓库、一个 package.json、一个 node_modules、一个构建流程,但随着迭代业务复杂度的提升,项目代码会变得越来越多,越来越复杂,大量代码构建效率也会降低,最终导致了单体巨石应用,这种代码管理方式称之为 Monolith。

问题在于:业务一旦变大,就容易出现:

  • 模块边界不清晰、改动影响范围越来越大
  • 构建/测试变慢
  • 多人协作冲突多

于是团队会自然想到:“拆开”,故此迎来阶段二。

注意:这里的 Monolith 是“一个应用越长越大”。它和后面的 Monorepo(多个包/项目同仓)不是同一个概念。

2.2 阶段二:多仓库多模块应用

把系统拆成多个仓库(例如:组件库仓库、业务 A 仓库、业务 B 仓库),会带来立竿见影的收益:

  • 每个仓库更小、owner 更明确、权限更清晰
  • 每个模块可以独立发版
  • 单仓库的 CI 看起来更快(只跑自己的)

代码管理变得简化,构建效率也得以提升,这种代码管理方式称之为 MultiRepo。

但当仓库越来越多,新的成本也会越来越明显:

  • 联动修改很难“原子化”:改组件库 API 后,你需要发布组件库,然后业务仓库分别升级、分别修、分别跑 CI。
  • 版本同步链路变长:底层库升级,上层一堆仓库要跟着升级验证。
  • 工程配置容易漂移:eslint/tsconfig/构建脚本在多个仓库逐渐不一致,治理难度上升。

这时候团队会意识到:拆仓库解决了局部自治,但放大了“协作与一致性”的成本。

2.3 阶段三:单仓库多模块应用

随着业务复杂度的提升,模块仓库越来越多,MultiRepo这种方式虽然从业务上解耦了,但增加了项目工程管理的难度,随着模块仓库达到一定数量级,会有几个问题:跨仓库代码难共享;分散在单仓库的模块依赖管理复杂(底层模块升级后,其他上层依赖需要及时更新,否则有问题);增加了构建耗时。于是将多个项目集成到一个仓库下,共享工程配置,同时又快捷地共享模块代码,成为趋势,这种代码管理方式称之为 Monorepo。

当“跨仓库联动成本”超过收益时,Monorepo 就变得有吸引力:

  • 改公共包 + 改所有使用方,可以在一个 PR 一次性完成并验证
  • 配置集中化,工程规范更易统一
  • 公共能力更容易沉淀成 packages,减少复制粘贴和重复造轮子

当然,Monorepo 也不是没有代价:

  • 仓库会变大(clone、索引、IDE 负担上升)
  • 如果没有“按影响范围执行(affected)+ 缓存”,CI 可能会变慢)

3. Monorepo 优劣

image.png

场景 MultiRepo MonoRepo
代码可见性 ✅ 代码隔离,研发者只需关注自己负责的仓库
❌ 包管理按照各自owner划分,当出现问题时,需要到依赖包中进行判断并解决。
✅ 一个仓库中多个相关项目,很容易看到整个代码库的变化趋势,更好的团队协作。
❌ 增加了非owner改动代码的风险
依赖管理 ❌ 多个仓库都有自己的 node_modules,存在依赖重复安装情况,占用磁盘内存大。 ✅ 多项目代码都在一个仓库中,相同版本依赖提升到顶层只安装一次,节省磁盘内存,
代码权限 ✅ 各项目单独仓库,不会出现代码被误改的情况,单个项目出现问题不会影响其他项目。 ❌ 多个项目代码都在一个仓库中,没有项目粒度的权限管控,一个项目出问题,可能影响所有项目。(
开发迭代 ✅ 仓库体积小,模块划分清晰,可维护性强。
❌ 多仓库来回切换(编辑器及命令行),项目多的话效率很低。多仓库见存在依赖时,需要手动 npm link,操作繁琐。
❌ 依赖管理不便,多个依赖可能在多个仓库中存在不同版本,重复安装,npm link 时不同项目的依赖会存在冲突。
✅ 多个项目都在一个仓库中,可看到相关项目全貌,编码非常方便。
✅ 代码复用高,方便进行代码重构。
❌ 多项目在一个仓库中,代码体积多大几个 G,git clone时间较长。
✅ 依赖调试方便,依赖包迭代场景下,借助工具自动 npm link,直接使用最新版本依赖,简化了操作流程。
工程配置 ❌ 各项目构建、打包、代码校验都各自维护,不一致时会导致代码差异或构建差异。 ✅ 多项目在一个仓库,工程配置一致,代码质量标准及风格也很容易一致。
构建部署 ❌ 多个项目间存在依赖,部署时需要手动到不同的仓库根据先后顺序去修改版本及进行部署,操作繁琐效率低。 ✅ 构建性 Monorepo 工具可以配置依赖项目的构建优先级,可以实现一次命令完成所有的部署。

4. Monorepo 场景

场景一:大型项目与多项目协作

  • 场景:企业或团队维护多个紧密关联的项目(如前端、后端、工具库等)。
  • 优势:集中管理代码,方便跨项目修改和协作,避免代码分散导致的重复劳动。

场景二:共享代码与依赖

  • 场景:多个项目共用组件库、工具函数或配置(如 UI 组件、通用 SDK)。
  • 优势:直接引用内部模块,避免多仓库的版本同步问题,确保依赖一致性。

场景三:统一构建与持续集成(CI/CD)

  • 场景:需要标准化构建、测试和部署流程。
  • 优势:集中配置 CI/CD,仅针对变更部分触发构建(增量构建),提升效率。

何时谨慎使用?

  • 代码量过大:需要考虑构建性能、代码可维护性
  • 权限管理复杂:需细化目录权限控制
  • 团队独立性高:若子团队高度自治,多仓库可能更灵活

5. Monorepo 工具

在采用 Monorepo(单一仓库)架构的软件开发中,工具的选择是至关重要的。合适的 Monorepo 工具能够帮助团队更高效地管理大规模代码库、提升协同开发体验以及优化构建和部署流程。

直至 2026 年年初,目前在前端界比较流行的 Monorepo 工具有 Pnpm WorkspacesYarn Workspacesnpm WorkspacesRushTurborepoYalc、和 Nx

5.1 依赖管理工具

没有 workspace/工具链时:A 包要用 B 包,只能 npm link、复制代码、或走相对/绝对路径,非常别扭且容易错。

负责“怎么安装依赖、怎么把 workspace 包链接起来”

pnpm workspace 是包管理器层面的工作区能力

  • 支持 monorepo 内部包之间用“包名”互相依赖(不是路径引用),并自动链接到本地源码
  • pnpm 有全局的内容存储(store),不同项目/不同 workspace 之间可以复用同版本依赖;再通过链接把依赖组织到各包的 node_modules 结构中。:直观效果:同一个依赖不需要在 N 个地方复制 N 份。
  • 依赖安装更快、更省空间(全局 store 复用 + 链接)
  • 默认依赖隔离更严格,可显著减少“幽灵依赖”

强烈推荐使用Pnpm Workspaces 作为 Monorepo 项目的依赖管理工具😍😍😍

  • pnpm:通过全局 store + 链接方式,通常既省空间又更严格

5.1.1 避免幽灵依赖

npm/yarn 安装依赖时,存在依赖提升,某个项目使用的依赖,并没有在其 package.json 中声明,也可以直接使用,这种现象称之为 “幽灵依赖”;随着项目迭代,这个依赖不再被其他项目使用,不再被安装,使用幽灵依赖的项目,会因为无法找到依赖而报错,而 pnpm 彻底解决这个问题

所谓幽灵依赖,可以理解为:

某个包没有在自己的 package.json 声明依赖,但因为安装结构/提升等原因,代码依然能 import 到它,直到某天依赖结构变化才突然报错。

pnpm 默认对依赖访问更严格,能更早暴露“未声明却在使用”的问题,让错误更早出现、定位更容易。

什么是幽灵依赖

先提问:你觉得“一个包能 import 某个依赖”的前提是什么?

正常答案应该是:

这个包的 package.json 里 dependencies/devDependencies 声明了它。

幽灵依赖就是:没声明,但居然还能 import 并运行成功

最小例子(用 npm/yarn 经典安装方式更容易出现):

假设是 monorepo:

  • 根 package.json 没有 lodash
  • packages/a/package.json 声明了 lodash
  • packages/b/package.json 没声明 lodash

但在 packages/b/src/index.ts 里写了:

import _ from "lodash";

在 npm/yarn(node_modules 提升/hoist)  的某些安装结果下,lodash 可能被“提升”到了更上层的 node_modules,导致 b 虽然没声明,也能“碰巧”找到 lodash,于是:

  • 开发阶段:你以为没问题
  • 某天 a 删除了 lodash 或版本变化/安装结构变化:b 突然就挂了

这就像:你家隔壁有个锤子,你没买但你一直去借用;直到隔壁搬家,你才发现自己其实从来没拥有它。

为什么 pnpm 更容易避免?

pnpm 的默认策略更“严格”:

  • 每个 package 能访问到的依赖,基本只限于它声明的那一圈(通过链接+隔离结构实现)
  • 所以 b 没声明 lodash,就更容易直接报错(这反而是好事:早发现早修)

一句话总结你可以写进文章:

幽灵依赖:未在当前包的 package.json 声明,却因为依赖提升等原因在运行时能被解析到的依赖;pnpm 通过更严格的依赖隔离,能显著减少这类问题。

5.1.2 依赖安装耗时长

MonoRepo 中每个项目都有自己的 package.json 依赖列表,随着 MonoRepo 中依赖总数的增长,每次 install 时,耗时会较长。使用 pnpm 按需安装及依赖缓存,相同版本依赖提升到 Monorepo 根目录下,减少冗余依赖安装;

那么 Monorepo 与包管理工具(npm、yarn、pnpm)之间是一种怎样的关系?

这些包管理工具与 monorepo 的关系在于,它们可以为 monorepo 提供依赖安装与依赖管理的支持,借助自身对 workspace 的支持,允许在 monorepo 中的不同子项目之间共享依赖项,并提供一种管理这些共享依赖项的方式,这可以简化依赖项管理和构建过程,并提高开发效率。

5.1.3 构建打包耗时长

问题:多个项目构建任务存在依赖时,往往是串行构建 或 全量构建,导致构建时间较长,可以使用增量构建,而非全量构建;也可以将串行构建,优化成并行构建。

npm、yarn、pnpm 等是用来管理项目依赖、发布包、安装依赖的工具,他们都提供了对工作区(workspace)的支持,允许在单个代码库中管理多个项目或包。这种工作区支持在单个代码库中同时开发、测试和管理多个的项目,而无需使用多个独立的代码仓库。

这些包管理工具与 monorepo 的关系在于他们可以为 monorepo 提供依赖安装与依赖管理的支持,借助自身对workspace的支持,允许在monorepo中的不同子项目之间共享依赖项,并提供一种管理这些共享以来想的方式,这可以简化依赖项管理和构建过程,并提高开发效率。

硬链接指向同一份文件数据,因此可以复用磁盘空间。

5.2 任务编排/构建系统

负责“有哪些任务要跑、哪些可以并行、哪些可以跳过、结果怎么缓存复用(增量构建)”

没有任务编排/增量构建时:一个仓库多个包,但 CI/构建经常只能全量跑,慢;发布也麻烦。

Nx/Turborepo/Rush

用一个场景立刻区分:只改了 UI 组件库,会发生什么?

假设 monorepo 里有:

  • packages/ui(组件库)
  • apps/web(业务)
  • apps/admin

改了 packages/ui/Button.tsx

  • pnpm workspace 会做什么?
    让 apps/web 依赖的 @repo/ui 指向本仓库的 ui 源码(链接),并保证依赖安装正确、边界更严格。
  • turbo/nx 会做什么?
    计算“受影响范围”:ui 变了 ⇒ web/admin 可能都受影响
    然后只跑:ui build + web build + admin build(而不是全仓库所有包都 build)
    并且能并行、能缓存。

6. 总结

  • Monorepo 并不是银弹,而是一种权衡工程管理与项目协作复杂性的最佳实践之一。适用于项目关联紧密、需频繁联动、强调一致性的中大型团队/企业。
  • 通过引入现代的包管理工具(如 pnpm workspace)和任务编排系统(如 Turborepo、Nx),Monorepo 管理的优势可以最大化,同时减轻依赖和构建上的压力。
  • 采用 Monorepo 可以促进团队协作、统一规范和复用代码,但也需留意仓库增大、权限细化等实际挑战。
  • 是否采纳 Monorepo,需结合企业项目规模、团队协作方式、基础设施支持等多方面因素综合考量。
  • 总之,合理组合工具和规范,才能真正发挥 Monorepo 的价值,为团队降本增效。

Vercel React 最佳实践 中文版

作者 ssshooter
2026年1月18日 00:13

React 最佳实践

版本 1.0.0
Vercel 工程团队
2026年1月

注意:
本文档主要供 Agent 和 LLM 在 Vercel 维护、生成或重构 React 及 Next.js 代码库时遵循。人类开发者也会发现其对于保持一致性和自动化优化非常有帮助。


摘要

这是一份针对 React 和 Next.js 应用程序的综合性能优化指南,专为 AI Agent 和 LLM 设计。包含 8 个类别的 40 多条规则,按影响力从关键(消除瀑布流、减少打包体积)到增量(高级模式)排序。每条规则都包含详细的解释、错误与正确实现的真实代码对比,以及具体的影响指标,以指导自动重构和代码生成。


目录

  1. 消除瀑布流关键
  2. 打包体积优化关键
  3. 服务端性能
  4. 客户端数据获取中高
  5. 重渲染优化
  6. 渲染性能
  7. JavaScript 性能中低
  8. 高级模式

1. 消除瀑布流

影响力: 关键

瀑布流(Waterfalls)是头号性能杀手。每一个连续的 await 都会增加完整的网络延迟。消除它们能带来最大的收益。

1.1 推迟 Await 直到需要时

影响力: 高 (避免阻塞不使用的代码路径)

await 操作移动到实际使用它们的分支中,以避免阻塞不需要它们的代码路径。

错误:阻塞了两个分支

async function handleRequest(userId: string, skipProcessing: boolean) {
  const userData = await fetchUserData(userId)
  
  if (skipProcessing) {
    // 立即返回,但仍然等待了 userData
    return { skipped: true }
  }
  
  // 只有这个分支使用了 userData
  return processUserData(userData)
}

正确:仅在需要时阻塞

async function handleRequest(userId: string, skipProcessing: boolean) {
  if (skipProcessing) {
    // 不等待直接返回
    return { skipped: true }
  }
  
  // 仅在需要时获取
  const userData = await fetchUserData(userId)
  return processUserData(userData)
}

另一个例子:提前返回优化

// 错误:总是获取权限
async function updateResource(resourceId: string, userId: string) {
  const permissions = await fetchPermissions(userId)
  const resource = await getResource(resourceId)
  
  if (!resource) {
    return { error: 'Not found' }
  }
  
  if (!permissions.canEdit) {
    return { error: 'Forbidden' }
  }
  
  return await updateResourceData(resource, permissions)
}

// 正确:仅在需要时获取
async function updateResource(resourceId: string, userId: string) {
  const resource = await getResource(resourceId)
  
  if (!resource) {
    return { error: 'Not found' }
  }
  
  const permissions = await fetchPermissions(userId)
  
  if (!permissions.canEdit) {
    return { error: 'Forbidden' }
  }
  
  return await updateResourceData(resource, permissions)
}

当被跳过的分支经常被执行,或者被推迟的操作非常昂贵时,这种优化通过尤为有价值。

1.2 基于依赖的并行化

影响力: 关键 (2-10倍 提升)

对于具有部分依赖关系的操作,使用 better-all 来即最大化并行性。它会在尽可能早的时刻启动每个任务。

错误:profile 不必要地等待 config

const [user, config] = await Promise.all([
  fetchUser(),
  fetchConfig()
])
const profile = await fetchProfile(user.id)

正确:config 和 profile 并行运行

import { all } from 'better-all'

const { user, config, profile } = await all({
  async user() { return fetchUser() },
  async config() { return fetchConfig() },
  async profile() {
    return fetchProfile((await this.$.user).id)
  }
})

参考: github.com/shuding/bet…

1.3 防止 API 路由中的瀑布链

影响力: 关键 (2-10倍 提升)

在 API 路由和 Server Actions 中,即使此时还不 await 它们,也要立即启动独立的操作。

错误:config 等待 auth,data 等待两者

export async function GET(request: Request) {
  const session = await auth()
  const config = await fetchConfig()
  const data = await fetchData(session.user.id)
  return Response.json({ data, config })
}

正确:auth 和 config 立即启动

export async function GET(request: Request) {
  const sessionPromise = auth()
  const configPromise = fetchConfig()
  const session = await sessionPromise
  const [config, data] = await Promise.all([
    configPromise,
    fetchData(session.user.id)
  ])
  return Response.json({ data, config })
}

对于具有更复杂依赖链的操作,使用 better-all 自动最大化并行性(参见"基于依赖的并行化")。

1.4 对独立操作使用 Promise.all()

影响力: 关键 (2-10倍 提升)

当异步操作没有相互依赖关系时,使用 Promise.all() 并发执行它们。

错误:顺序执行,3 次往返

const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()

正确:并行执行,1 次往返

const [user, posts, comments] = await Promise.all([
  fetchUser(),
  fetchPosts(),
  fetchComments()
])

1.5 策略性 Suspense 边界

影响力: 高 (更快的首次绘制)

不要在异步组件中等待数据后再返回 JSX,而是使用 Suspense 边界在数据加载时更快地显示包装器 UI。

错误:包装器被数据获取阻塞

async function Page() {
  const data = await fetchData() // 阻塞整个页面
  
  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <div>
        <DataDisplay data={data} />
      </div>
      <div>Footer</div>
    </div>
  )
}

即便只有中间部分需要数据,整个布局也会等待数据。

正确:包装器立即显示,数据流式传输

function Page() {
  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <div>
        <Suspense fallback={<Skeleton />}>
          <DataDisplay />
        </Suspense>
      </div>
      <div>Footer</div>
    </div>
  )
}

async function DataDisplay() {
  const data = await fetchData() // 仅阻塞此组件
  return <div>{data.content}</div>
}

Sidebar、Header 和 Footer 立即渲染。只有 DataDisplay 等待数据。

替代方案:在组件间共享 promise

function Page() {
  // 立即开始获取,但不要 await
  const dataPromise = fetchData()
  
  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <Suspense fallback={<Skeleton />}>
        <DataDisplay dataPromise={dataPromise} />
        <DataSummary dataPromise={dataPromise} />
      </Suspense>
      <div>Footer</div>
    </div>
  )
}

function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
  const data = use(dataPromise) // 解包 promise
  return <div>{data.content}</div>
}

function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
  const data = use(dataPromise) // 复用同一个 promise
  return <div>{data.summary}</div>
}

两个组件共享同一个 promise,因此只会进行一次获取。布局立即渲染,而两个组件一起等待。

何时不使用此模式:

  • 布局决策所需的关键数据(影响定位)

  • 首屏(Above the fold)的 SEO 关键内容

  • Suspense 开销不值得的小型快速查询

  • 当你想要避免布局偏移(加载中 → 内容跳动)时

权衡: 更快的首次绘制 vs 潜在的布局偏移。根据你的 UX 优先级进行选择。


2. 打包体积优化

影响力: 关键

减少初始打包体积可以改善可交互时间 (TTI) 和最大内容绘制 (LCP)。

2.1 避免 Barrel 文件导入

影响力: 关键 (200-800ms 导入成本, 缓慢的构建)

直接从源文件导入而不是从 Barrel 文件导入,以避免加载数千个未使用的模块。Barrel 文件是重新导出多个模块的入口点(例如,执行 export * from './module'index.js)。

流行的图标和组件库在其入口文件中可能有 多达 10,000 个重导出。对于许多 React 包,仅导入它们就需要 200-800ms,这会影响开发速度和生产环境的冷启动。

为什么 tree-shaking 没有帮助: 当库被标记为外部(不打包)时,打包器无法对其进行优化。如果你将其打包以启用 tree-shaking,分析整个模块图会导致构建变得非常缓慢。

错误:导入整个库

import { Check, X, Menu } from 'lucide-react'
// 加载 1,583 个模块,开发环境额外耗时 ~2.8s
// 运行时成本:每次冷启动 200-800ms

import { Button, TextField } from '@mui/material'
// 加载 2,225 个模块,开发环境额外耗时 ~4.2s

正确:仅导入你需要的内容

import Check from 'lucide-react/dist/esm/icons/check'
import X from 'lucide-react/dist/esm/icons/x'
import Menu from 'lucide-react/dist/esm/icons/menu'
// 仅加载 3 个模块 (~2KB vs ~1MB)

import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'
// 仅加载你使用的内容

替代方案:Next.js 13.5+

// next.config.js - 使用 optimizePackageImports
module.exports = {
  experimental: {
    optimizePackageImports: ['lucide-react', '@mui/material']
  }
}

// 这样你可以保留符合人体工程学的 Barrel 导入:
import { Check, X, Menu } from 'lucide-react'
// 在构建时自动转换为直接导入

直接导入可提供 15-70% 更快的开发启动速度,28% 更快的构建速度,40% 更快的冷启动速度,以及显著更快的 HMR。

受影响的常见库:lucide-react, @mui/material, @mui/icons-material, @tabler/icons-react, react-icons, @headlessui/react, @radix-ui/react-*, lodash, ramda, date-fns, rxjs, react-use

参考: vercel.com/blog/how-we…

2.2 条件模块加载

影响力: 高 (仅在需要时加载大数据)

仅在功能激活时加载大数据或模块。

例子:懒加载动画帧

function AnimationPlayer({ enabled }: { enabled: boolean }) {
  const [frames, setFrames] = useState<Frame[] | null>(null)

  useEffect(() => {
    if (enabled && !frames && typeof window !== 'undefined') {
      import('./animation-frames.js')
        .then(mod => setFrames(mod.frames))
        .catch(() => setEnabled(false))
    }
  }, [enabled, frames])

  if (!frames) return <Skeleton />
  return <Canvas frames={frames} />
}

typeof window !== 'undefined' 检查可防止在 SSR 时打包此模块,从而优化服务端包体积和构建速度。

2.3 推迟非关键第三方库

影响力: 中 (水合后加载)

分析、日志记录和错误跟踪不会阻塞用户交互。应当在水合(Hydration)之后加载它们。

错误:阻塞初始包

import { Analytics } from '@vercel/analytics/react'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

正确:水合后加载

import dynamic from 'next/dynamic'

const Analytics = dynamic(
  () => import('@vercel/analytics/react').then(m => m.Analytics),
  { ssr: false }
)

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

2.4 重型组件动态导入

影响力: 关键 (直接影响 TTI 和 LCP)

使用 next/dynamic 懒加载初始渲染不需要的大型组件。

错误:Monaco 随主 chunk 打包 ~300KB

import { MonacoEditor } from './monaco-editor'

function CodePanel({ code }: { code: string }) {
  return <MonacoEditor value={code} />
}

正确:Monaco 按需加载

import dynamic from 'next/dynamic'

const MonacoEditor = dynamic(
  () => import('./monaco-editor').then(m => m.MonacoEditor),
  { ssr: false }
)

function CodePanel({ code }: { code: string }) {
  return <MonacoEditor value={code} />
}

2.5 基于用户意图预加载

影响力: 中 (减少感知延迟)

在需要之前预加载繁重的包,以减少感知延迟。

例子:悬停/聚焦时预加载

function EditorButton({ onClick }: { onClick: () => void }) {
  const preload = () => {
    if (typeof window !== 'undefined') {
      void import('./monaco-editor')
    }
  }

  return (
    <button
      onMouseEnter={preload}
      onFocus={preload}
      onClick={onClick}
    >
      打开编辑器
    </button>
  )
}

例子:当功能标志启用时预加载

function FlagsProvider({ children, flags }: Props) {
  useEffect(() => {
    if (flags.editorEnabled && typeof window !== 'undefined') {
      void import('./monaco-editor').then(mod => mod.init())
    }
  }, [flags.editorEnabled])

  return <FlagsContext.Provider value={flags}>
    {children}
  </FlagsContext.Provider>
}

typeof window !== 'undefined' 检查可防止在 SSR 时打包预加载模块,从而优化服务端包体积和构建速度。


3. 服务端性能

影响力: 高

优化服务端渲染和数据获取可消除服务端瀑布流并减少响应时间。

3.1 跨请求 LRU 缓存

影响力: 高 (跨请求缓存)

React.cache() 仅在一个请求内有效。对于跨连续请求共享的数据(用户点击按钮 A 然后点击按钮 B),请使用 LRU 缓存。

实现:

import { LRUCache } from 'lru-cache'

const cache = new LRUCache<string, any>({
  max: 1000,
  ttl: 5 * 60 * 1000  // 5 分钟
})

export async function getUser(id: string) {
  const cached = cache.get(id)
  if (cached) return cached

  const user = await db.user.findUnique({ where: { id } })
  cache.set(id, user)
  return user
}

// 请求 1: DB 查询, 结果被缓存
// 请求 2: 缓存命中, 无 DB 查询

当顺序的用户操作在几秒钟内命中多个需要相同数据的端点时,请使用此方法。

配合 Vercel 的 Fluid Compute LRU 缓存特别有效,因为多个并发请求可以共享同一个函数实例和缓存。这意味着缓存可以跨请求持久化,而无需 Redis 等外部存储。

在传统 Serverless 中: 每次调用都是隔离运行的,因此请考虑使用 Redis 进行跨进而缓存。

参考: github.com/isaacs/node…

3.2 最小化 RSC 边界序列化

影响力: 高 (减少传输数据大小)

React Server/Client 边界会将所有对象属性序列化为字符串,并将它们嵌入到 HTML 响应和后续的 RSC 请求中。此序列化数据直接影响页面重量和加载时间,因此 大小非常重要。仅传递客户端实际使用的字段。

错误:序列化所有 50 个字段

async function Page() {
  const user = await fetchUser()  // 50 个字段
  return <Profile user={user} />
}

'use client'
function Profile({ user }: { user: User }) {
  return <div>{user.name}</div>  // 使用 1 个字段
}

正确:仅序列化 1 个字段

async function Page() {
  const user = await fetchUser()
  return <Profile name={user.name} />
}

'use client'
function Profile({ name }: { name: string }) {
  return <div>{name}</div>
}

3.3 通过组件组合并行获取数据

影响力: 关键 (消除服务端瀑布流)

React Server Components 在树中顺序执行。使用组合重构以并行化数据获取。

错误:Sidebar 等待 Page 的 fetch 完成

export default async function Page() {
  const header = await fetchHeader()
  return (
    <div>
      <div>{header}</div>
      <Sidebar />
    </div>
  )
}

async function Sidebar() {
  const items = await fetchSidebarItems()
  return <nav>{items.map(renderItem)}</nav>
}

正确:两者同时获取

async function Header() {
  const data = await fetchHeader()
  return <div>{data}</div>
}

async function Sidebar() {
  const items = await fetchSidebarItems()
  return <nav>{items.map(renderItem)}</nav>
}

export default function Page() {
  return (
    <div>
      <Header />
      <Sidebar />
    </div>
  )
}

使用 children prop 的替代方案:

async function Layout({ children }: { children: ReactNode }) {
  const header = await fetchHeader()
  return (
    <div>
      <div>{header}</div>
      {children}
    </div>
  )
}

async function Sidebar() {
  const items = await fetchSidebarItems()
  return <nav>{items.map(renderItem)}</nav>
}

export default function Page() {
  return (
    <Layout>
      <Sidebar />
    </Layout>
  )
}

3.4 使用 React.cache() 进行按请求去重

影响力: 中 (请求内去重)

使用 React.cache() 进行服务端请求去重。身份验证和数据库查询受益最大。

用法:

import { cache } from 'react'

export const getCurrentUser = cache(async () => {
  const session = await auth()
  if (!session?.user?.id) return null
  return await db.user.findUnique({
    where: { id: session.user.id }
  })
})

在单个请求中,对 getCurrentUser() 的多次调用只会执行一次查询。

3.5 使用 after() 处理非阻塞操作

影响力: 中 (更快的响应时间)

使用 Next.js 的 after() 来调度应在发送响应后执行的工作。这可以防止日志记录、分析和其他副作用阻塞响应。

错误:阻塞响应

import { logUserAction } from '@/app/utils'

export async function POST(request: Request) {
  // 执行变更
  await updateDatabase(request)
  
  // 日志记录阻塞了响应
  const userAgent = request.headers.get('user-agent') || 'unknown'
  await logUserAction({ userAgent })
  
  return new Response(JSON.stringify({ status: 'success' }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' }
  })
}

正确:非阻塞

import { after } from 'next/server'
import { headers, cookies } from 'next/headers'
import { logUserAction } from '@/app/utils'

export async function POST(request: Request) {
  // 执行变更
  await updateDatabase(request)
  
  // 响应发送后记录日志
  after(async () => {
    const userAgent = (await headers()).get('user-agent') || 'unknown'
    const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'
    
    logUserAction({ sessionCookie, userAgent })
  })
  
  return new Response(JSON.stringify({ status: 'success' }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' }
  })
}

响应立即发送,而日志记录在后台发生。

常见用例:

  • 分析跟踪

  • 审计日志

  • 发送通知

  • 缓存失效

  • 清理任务

重要说明:

  • 即使响应失败或重定向,after() 也会运行

  • 适用于 Server Actions、Route Handlers 和 Server Components

参考: nextjs.org/docs/app/ap…


4. 客户端数据获取

影响力: 中高

自动去重和高效的数据获取模式减少了多余的网络请求。

4.1 去重全局事件监听器

影响力: 低 (N 个组件共用单个监听器)

使用 useSWRSubscription() 在组件实例之间共享全局事件监听器。

错误:N 个实例 = N 个监听器

function useKeyboardShortcut(key: string, callback: () => void) {
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.metaKey && e.key === key) {
        callback()
      }
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  }, [key, callback])
}

当多次使用 useKeyboardShortcut 钩子时,每个实例都会注册一个新的监听器。

正确:N 个实例 = 1 个监听器

import useSWRSubscription from 'swr/subscription'

// 模块级 Map 跟踪每个键的回调
const keyCallbacks = new Map<string, Set<() => void>>()

function useKeyboardShortcut(key: string, callback: () => void) {
  // 在 Map 中注册此回调
  useEffect(() => {
    if (!keyCallbacks.has(key)) {
      keyCallbacks.set(key, new Set())
    }
    keyCallbacks.get(key)!.add(callback)

    return () => {
      const set = keyCallbacks.get(key)
      if (set) {
        set.delete(callback)
        if (set.size === 0) {
          keyCallbacks.delete(key)
        }
      }
    }
  }, [key, callback])

  useSWRSubscription('global-keydown', () => {
    const handler = (e: KeyboardEvent) => {
      if (e.metaKey && keyCallbacks.has(e.key)) {
        keyCallbacks.get(e.key)!.forEach(cb => cb())
      }
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  })
}

function Profile() {
  // 多个快捷键将共享同一个监听器
  useKeyboardShortcut('p', () => { /* ... */ }) 
  useKeyboardShortcut('k', () => { /* ... */ })
  // ...
}

4.2 使用 SWR 自动去重

影响力: 中高 (自动去重)

SWR 支持跨组件实例的请求去重、缓存和重新验证。

错误:无去重,每个实例都获取

function UserList() {
  const [users, setUsers] = useState([])
  useEffect(() => {
    fetch('/api/users')
      .then(r => r.json())
      .then(setUsers)
  }, [])
}

正确:多个实例共享一个请求

import useSWR from 'swr'

function UserList() {
  const { data: users } = useSWR('/api/users', fetcher)
}

对于不可变数据:

import { useImmutableSWR } from '@/lib/swr'

function StaticContent() {
  const { data } = useImmutableSWR('/api/config', fetcher)
}

对于变异 (Mutations):

import { useSWRMutation } from 'swr/mutation'

function UpdateButton() {
  const { trigger } = useSWRMutation('/api/user', updateUser)
  return <button onClick={() => trigger()}>更新</button>
}

参考: swr.vercel.app


5. 重渲染优化

影响力: 中

减少不必要的重渲染可最大限度地减少浪费的计算并提高 UI 响应能力。

5.1 推迟状态读取到使用点

影响力: 中 (避免不必要的订阅)

如果你只在回调中读取动态状态(搜索参数、localStorage),则不要订阅它。

错误:订阅所有 searchParams 更改

function ShareButton({ chatId }: { chatId: string }) {
  const searchParams = useSearchParams()

  const handleShare = () => {
    const ref = searchParams.get('ref')
    shareChat(chatId, { ref })
  }

  return <button onClick={handleShare}>分享</button>
}

正确:按需读取,无订阅

function ShareButton({ chatId }: { chatId: string }) {
  const handleShare = () => {
    const params = new URLSearchParams(window.location.search)
    const ref = params.get('ref')
    shareChat(chatId, { ref })
  }

  return <button onClick={handleShare}>分享</button>
}

5.2 提取为记忆化组件

影响力: 中 (启用提前返回)

将昂贵的工作提取到记忆化 (memoized) 组件中,以便在计算及以前提前返回。

错误:即使在加载时也计算头像

function Profile({ user, loading }: Props) {
  const avatar = useMemo(() => {
    const id = computeAvatarId(user)
    return <Avatar id={id} />
  }, [user])

  if (loading) return <Skeleton />
  return <div>{avatar}</div>
}

正确:加载时跳过计算

const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
  const id = useMemo(() => computeAvatarId(user), [user])
  return <Avatar id={id} />
})

function Profile({ user, loading }: Props) {
  if (loading) return <Skeleton />
  return (
    <div>
      <UserAvatar user={user} />
    </div>
  )
}

注意: 如果你的项目启用了 React Compiler,则无需使用 memo()useMemo() 进行手动记忆化。编译器会自动优化重渲染。

5.3 缩小 Effect 依赖范围

影响力: 低 (最小化 effect 重新运行)

指定原始值依赖项而不是对象,以最大限度地减少 effect 的重新运行。

错误:在任何用户字段更改时重新运行

useEffect(() => {
  console.log(user.id)
}, [user])

正确:仅在 id 更改时重新运行

useEffect(() => {
  console.log(user.id)
}, [user.id])

对于派生状态,在 effect 外部计算:

// 错误:在 width=767, 766, 765... 时运行
useEffect(() => {
  if (width < 768) {
    enableMobileMode()
  }
}, [width])

// 正确:仅在布尔值转换时运行
const isMobile = width < 768
useEffect(() => {
  if (isMobile) {
    enableMobileMode()
  }
}, [isMobile])

5.4 订阅派生状态

影响力: 中 (降低重渲染频率)

订阅派生的布尔状态而不是连续值,以降低重渲染频率。

错误:在每个像素变化时重渲染

function Sidebar() {
  const width = useWindowWidth()  // 持续更新
  const isMobile = width < 768
  return <nav className={isMobile ? 'mobile' : 'desktop'}>
}

正确:仅在布尔值更改时重渲染

function Sidebar() {
  const isMobile = useMediaQuery('(max-width: 767px)')
  return <nav className={isMobile ? 'mobile' : 'desktop'}>
}

5.5 使用函数式 setState 更新

影响力: 中 (防止闭包陷阱和不必要的回调重建)

当基于当前状态值更新状态时,使用 setState 的函数式更新形式,而不是直接引用状态变量。这可以防止闭包陷阱 (stale closures),消除不必要的依赖,并创建稳定的回调引用。

错误:需要 state 作为依赖

function TodoList() {
  const [items, setItems] = useState(initialItems)
  
  // 回调必须依赖 items,在每次 items 更改时重建
  const addItems = useCallback((newItems: Item[]) => {
    setItems([...items, ...newItems])
  }, [items])  // ❌ items 依赖导致重建
  
  // 如果忘记依赖,会有闭包陷阱风险
  const removeItem = useCallback((id: string) => {
    setItems(items.filter(item => item.id !== id))
  }, [])  // ❌ 缺少 items 依赖 - 将使用陈旧的 items!
  
  return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}

第一个回调每次 items 更改时都会重建,这可能会导致子组件不必要地重渲染。第二个回调有一个闭包陷阱 bug——它将始终引用初始的 items 值。

正确:稳定的回调,无闭包陷阱

function TodoList() {
  const [items, setItems] = useState(initialItems)
  
  // 稳定的回调,从未重建
  const addItems = useCallback((newItems: Item[]) => {
    setItems(curr => [...curr, ...newItems])
  }, [])  // ✅ 不需要依赖
  
  // 始终使用最新状态,无闭包陷阱风险
  const removeItem = useCallback((id: string) => {
    setItems(curr => curr.filter(item => item.id !== id))
  }, [])  // ✅ 安全且稳定
  
  return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}

好处:

  1. 稳定的回调引用 - 状态更改时无需重建回调

  2. 无闭包陷阱 - 始终对最新状态值进行操作

  3. 更少的依赖 - 简化了依赖数组并减少了内存泄漏

  4. 防止错误 - 消除了 React 闭包 bug 的最常见来源

何时使用函数式更新:

  • 任何依赖于当前状态值的 setState

  • 在需要 state 的 useCallback/useMemo 内部

  • 引用 state 的事件处理程序

  • 更新 state 的异步操作

何时直接更新是可以的:

  • 将 state 设置为静态值:setCount(0)

  • 仅从 props/参数设置 state:setName(newName)

  • State 不依赖于先前的值

注意: 如果你的项目启用了 React Compiler,编译器可以自动优化某些情况,但仍建议使用函数式更新以确保证正确性并防止闭包陷阱 bug。

5.6 使用惰性状态初始化

影响力: 中 (每次渲染都浪费计算)

将函数传递给 useState 用于昂贵的初始值。如果不使用函数形式,初始化程序将在每次渲染时运行,即使该值仅使用一次。

错误:每次渲染都运行

function FilteredList({ items }: { items: Item[] }) {
  // buildSearchIndex() 在每次渲染时运行,即使在初始化之后
  const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
  const [query, setQuery] = useState('')
  
  // 当 query 更改时,buildSearchIndex 再次不必要地运行
  return <SearchResults index={searchIndex} query={query} />
}

function UserProfile() {
  // JSON.parse 在每次渲染时运行
  const [settings, setSettings] = useState(
    JSON.parse(localStorage.getItem('settings') || '{}')
  )
  
  return <SettingsForm settings={settings} onChange={setSettings} />
}

正确:仅运行一次

function FilteredList({ items }: { items: Item[] }) {
  // buildSearchIndex() 仅在初始渲染时运行
  const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
  const [query, setQuery] = useState('')
  
  return <SearchResults index={searchIndex} query={query} />
}

function UserProfile() {
  // JSON.parse 仅在初始渲染时运行
  const [settings, setSettings] = useState(() => {
    const stored = localStorage.getItem('settings')
    return stored ? JSON.parse(stored) : {}
  })
  
  return <SettingsForm settings={settings} onChange={setSettings} />
}

当从 localStorage/sessionStorage 计算初始值、构建数据结构(索引、Map)、从 DOM 读取或执行繁重的转换是,请使用惰性初始化。

对于简单的原始值 (useState(0))、直接引用 (useState(props.value)) 或廉价的字面量 (useState({})),函数形式是不必要的。

5.7 对非紧急更新使用 Transitions

影响力: 中 (保持 UI 响应及)

将频繁的、非紧急的状态更新标记为 transitions,以保持 UI 响应能力。

错误:每次滚动都阻塞 UI

function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0)
  useEffect(() => {
    const handler = () => setScrollY(window.scrollY)
    window.addEventListener('scroll', handler, { passive: true })
    return () => window.removeEventListener('scroll', handler)
  }, [])
}

正确:非阻塞更新

import { startTransition } from 'react'

function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0)
  useEffect(() => {
    const handler = () => {
      startTransition(() => setScrollY(window.scrollY))
    }
    window.addEventListener('scroll', handler, { passive: true })
    return () => window.removeEventListener('scroll', handler)
  }, [])
}

6. 渲染性能

影响力: 中

优化渲染过程可减少浏览器需要做的工作。

6.1 动画化 SVG 包装器而非 SVG 元素

影响力: 低 (启用硬件加速)

许多浏览器不支持对 SVG 元素的 CSS3 动画进行硬件加速。将 SVG 包装在 <div> 中并对包装器进行动画处理。

错误:直接动画化 SVG - 无硬件加速

function LoadingSpinner() {
  return (
    <svg 
      className="animate-spin"
      width="24" 
      height="24" 
      viewBox="0 0 24 24"
    >
      <circle cx="12" cy="12" r="10" stroke="currentColor" />
    </svg>
  )
}

正确:动画化包装器 div - 硬件加速

function LoadingSpinner() {
  return (
    <div className="animate-spin">
      <svg 
        width="24" 
        height="24" 
        viewBox="0 0 24 24"
      >
        <circle cx="12" cy="12" r="10" stroke="currentColor" />
      </svg>
    </div>
  )
}

这适用于所有 CSS 变换和过渡(transform, opacity, translate, scale, rotate)。包装器 div 允许浏览器使用 GPU 加速来实现更流畅的动画。

6.2 长列表使用 CSS content-visibility

影响力: 高 (更快的首次渲染)

应用 content-visibility: auto 以推迟屏幕外渲染。

CSS:

.message-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 80px;
}

例子:

function MessageList({ messages }: { messages: Message[] }) {
  return (
    <div className="overflow-y-auto h-screen">
      {messages.map(msg => (
        <div key={msg.id} className="message-item">
          <Avatar user={msg.author} />
          <div>{msg.content}</div>
        </div>
      ))}
    </div>
  )
}

对于 1000 条消息,浏览器会跳过 ~990 个屏幕外项目的布局/绘制(首次渲染快 10 倍)。

6.3 提升静态 JSX 元素

影响力: 低 (避免重新创建)

将静态 JSX 提取到组件外部以避免重新创建。

错误:每次渲染都重新创建元素

function LoadingSkeleton() {
  return <div className="animate-pulse h-20 bg-gray-200" />
}

function Container() {
  return (
    <div>
      {loading && <LoadingSkeleton />}
    </div>
  )
}

正确:复用相同元素

const loadingSkeleton = (
  <div className="animate-pulse h-20 bg-gray-200" />
)

function Container() {
  return (
    <div>
      {loading && loadingSkeleton}
    </div>
  )
}

这对于大型和静态的 SVG 节点特别有用,因为在每次渲染时重新创建它们可能会很昂贵。

注意: 如果你的项目启用了 React Compiler,编译器会自动提升静态 JSX 元素并优化组件重渲染,使得手动提升变得不必要。

6.4 优化 SVG 精度

影响力: 低 (减小文件大小)

降低 SVG 坐标精度以减小文件大小。最佳精度取决于 viewBox 大小,但在一般情况下,应考虑降低精度。

错误:过高的精度

<path d="M 10.293847 20.847362 L 30.938472 40.192837" />

正确:1 位小数

<path d="M 10.3 20.8 L 30.9 40.2" />

使用 SVGO 自动化:

npx svgo --precision=1 --multipass icon.svg

6.5 无闪烁防止水合不匹配

影响力: 中 (避免视觉闪烁和水合错误)

当渲染依赖于客户端存储(localStorage, cookies)的内容时,通过注入一个同步脚本在 React 水合之前更新 DOM,以避免 SSR 中断和水合后的闪烁。

错误:破坏 SSR

function ThemeWrapper({ children }: { children: ReactNode }) {
  // localStorage 在服务器上不可用 - 抛出错误
  const theme = localStorage.getItem('theme') || 'light'
  
  return (
    <div className={theme}>
      {children}
    </div>
  )
}

服务端渲染将失败,因为 localStorage 未定义。

错误:视觉闪烁

function ThemeWrapper({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState('light')
  
  useEffect(() => {
    // 在水合后运行 - 导致可见的闪烁
    const stored = localStorage.getItem('theme')
    if (stored) {
      setTheme(stored)
    }
  }, [])
  
  return (
    <div className={theme}>
      {children}
    </div>
  )
}

组件首先使用默认值(light)渲染,然后在水合后更新,导致不正确内容的可见闪烁。

正确:无闪烁,无水合不匹配

function ThemeWrapper({ children }: { children: ReactNode }) {
  return (
    <>
      <div id="theme-wrapper">
        {children}
      </div>
      <script
        dangerouslySetInnerHTML={{
          __html: `
            (function() {
              try {
                var theme = localStorage.getItem('theme') || 'light';
                var el = document.getElementById('theme-wrapper');
                if (el) el.className = theme;
              } catch (e) {}
            })();
          `,
        }}
      />
    </>
  )
}

内联脚本在显示元素之前同步执行,确保 DOM 已经具有正确的值。无闪烁,无水合不匹配。

此模式对于主题切换、用户偏好、身份验证状态以及任何应立即渲染而不闪烁默认值的仅客户端数据特别有用。

6.6 使用 Activity 组件进行显示/隐藏

影响力: 中 (保留状态/DOM)

使用 React 的 <Activity> 来为频繁切换可见性的昂贵组件保留状态/DOM。

用法:

import { Activity } from 'react'

function Dropdown({ isOpen }: Props) {
  return (
    <Activity mode={isOpen ? 'visible' : 'hidden'}>
      <ExpensiveMenu />
    </Activity>
  )
}

避免昂贵的重渲染和状态丢失。

6.7 使用显式条件渲染

影响力: 低 (防止渲染 0 或 NaN)

当条件可能为 0NaN 或其他会渲染的假值时,使用显式三元运算符 (? :) 而不是 && 进行条件渲染。

错误:当 count 为 0 时渲染 "0"

function Badge({ count }: { count: number }) {
  return (
    <div>
      {count && <span className="badge">{count}</span>}
    </div>
  )
}

// 当 count = 0, 渲染: <div>0</div>
// 当 count = 5, 渲染: <div><span class="badge">5</span></div>

正确:当 count 为 0 时不渲染任何内容

function Badge({ count }: { count: number }) {
  return (
    <div>
      {count > 0 ? <span className="badge">{count}</span> : null}
    </div>
  )
}

// 当 count = 0, 渲染: <div></div>
// 当 count = 5, 渲染: <div><span class="badge">5</span></div>

7. JavaScript 性能

影响力: 中低

对热路径的微优化可以累积成有意义的改进。

7.1 批量 DOM CSS 更改

影响力: 中 (减少重排/重绘)

避免通过一次修改一个属性的方式更改样式。通过类或 cssText 将多个 CSS 更改组合在一起,以最大程度地减少浏览器重排 (reflows)。

错误:多次重排

function updateElementStyles(element: HTMLElement) {
  // 每一行都会触发一次重排
  element.style.width = '100px'
  element.style.height = '200px'
  element.style.backgroundColor = 'blue'
  element.style.border = '1px solid black'
}

正确:添加类 - 单次重排

// CSS 文件
.highlighted-box {
  width: 100px;
  height: 200px;
  background-color: blue;
  border: 1px solid black;
}

// JavaScript
function updateElementStyles(element: HTMLElement) {
  element.classList.add('highlighted-box')
}

正确:改变 cssText - 单次重排

function updateElementStyles(element: HTMLElement) {
  element.style.cssText = `
    width: 100px;
    height: 200px;
    background-color: blue;
    border: 1px solid black;
  `
}

React 例子:

// 错误:逐个更改样式
function Box({ isHighlighted }: { isHighlighted: boolean }) {
  const ref = useRef<HTMLDivElement>(null)
  
  useEffect(() => {
    if (ref.current && isHighlighted) {
      ref.current.style.width = '100px'
      ref.current.style.height = '200px'
      ref.current.style.backgroundColor = 'blue'
    }
  }, [isHighlighted])
  
  return <div ref={ref}>内容</div>
}

// 正确:切换类
function Box({ isHighlighted }: { isHighlighted: boolean }) {
  return (
    <div className={isHighlighted ? 'highlighted-box' : ''}>
      内容
    </div>
  )
}

尽可能使用 CSS 类而不是内联样式。类会被浏览器缓存,并提供更好的关注点分离。

7.2 为重复查找构建索引 Map

影响力: 中低 (1M 操作 -> 2K 操作)

同一键的多次 .find() 调用应使用 Map。

错误 (每次查找 O(n)):

function processOrders(orders: Order[], users: User[]) {
  return orders.map(order => ({
    ...order,
    user: users.find(u => u.id === order.userId)
  }))
}

正确 (每次查找 O(1)):

function processOrders(orders: Order[], users: User[]) {
  const userById = new Map(users.map(u => [u.id, u]))

  return orders.map(order => ({
    ...order,
    user: userById.get(order.userId)
  }))
}

构建一次 Map (O(n)),然后所有查找都是 O(1)。

对于 1000 个订单 × 1000 个用户:100万次操作 → 2000 次操作。

7.3 在循环中缓存属性访问

影响力: 中低 (减少查找)

在热路径中缓存对象属性查找。

错误:3 次查找 × N 次迭代

for (let i = 0; i < arr.length; i++) {
  process(obj.config.settings.value)
}

正确:总过 1 次查找

const value = obj.config.settings.value
const len = arr.length
for (let i = 0; i < len; i++) {
  process(value)
}

7.4 缓存重复函数调用

影响力: 中 (避免冗余计算)

当在渲染期间使用相同的输入重复调用相同的函数时,使用模块级 Map 缓存函数结果。

错误:冗余计算

function ProjectList({ projects }: { projects: Project[] }) {
  return (
    <div>
      {projects.map(project => {
        // slugify() 对相同的项目名称调用了 100+ 次
        const slug = slugify(project.name)
        
        return <ProjectCard key={project.id} slug={slug} />
      })}
    </div>
  )
}

正确:缓存结果

// 模块级缓存
const slugifyCache = new Map<string, string>()

function cachedSlugify(text: string): string {
  if (slugifyCache.has(text)) {
    return slugifyCache.get(text)!
  }
  const result = slugify(text)
  slugifyCache.set(text, result)
  return result
}

function ProjectList({ projects }: { projects: Project[] }) {
  return (
    <div>
      {projects.map(project => {
        // 每个唯一的项目名称仅计算一次
        const slug = cachedSlugify(project.name)
        
        return <ProjectCard key={project.id} slug={slug} />
      })}
    </div>
  )
}

单值函数的更简单模式:

let isLoggedInCache: boolean | null = null

function isLoggedIn(): boolean {
  if (isLoggedInCache !== null) {
    return isLoggedInCache
  }
  
  isLoggedInCache = document.cookie.includes('auth=')
  return isLoggedInCache
}

// 身份验证更改时清除缓存
function onAuthChange() {
  isLoggedInCache = null
}

使用 Map(而不是 hook),这样它可以在任何地方工作:工具函数、事件处理程序,而不仅仅是 React 组件。

参考: vercel.com/blog/how-we…

7.5 缓存 Storage API 调用

影响力: 中低 (减少昂贵的 I/O)

localStorage, sessionStoragedocument.cookie 是同步且昂贵的。在内存中缓存读取。

错误:每次调用都读取存储

function getTheme() {
  return localStorage.getItem('theme') ?? 'light'
}
// 调用 10 次 = 10 次存储读取

正确:Map 缓存

const storageCache = new Map<string, string | null>()

function getLocalStorage(key: string) {
  if (!storageCache.has(key)) {
    storageCache.set(key, localStorage.getItem(key))
  }
  return storageCache.get(key)
}

function setLocalStorage(key: string, value: string) {
  localStorage.setItem(key, value)
  storageCache.set(key, value)  // 保持缓存同步
}

使用 Map(而不是 hook),这样它可以在任何地方工作:工具函数、事件处理程序,而不仅仅是 React 组件。

Cookie 缓存:

let cookieCache: Record<string, string> | null = null

function getCookie(name: string) {
  if (!cookieCache) {
    cookieCache = Object.fromEntries(
      document.cookie.split('; ').map(c => c.split('='))
    )
  }
  return cookieCache[name]
}

重要:在外部更改时失效

window.addEventListener('storage', (e) => {
  if (e.key) storageCache.delete(e.key)
})

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    storageCache.clear()
  }
})

如果存储可以在外部更改(另一个标签页、服务器设置的 cookie),请使缓存失效。

7.6合并多个数组迭代

影响力: 中低 (减少迭代)

多个 .filter().map() 调用会多次迭代数组。合并为一个循环。

错误:3 次迭代

const admins = users.filter(u => u.isAdmin)
const testers = users.filter(u => u.isTester)
const inactive = users.filter(u => !u.isActive)

正确:1 次迭代

const admins: User[] = []
const testers: User[] = []
const inactive: User[] = []

for (const user of users) {
  if (user.isAdmin) admins.push(user)
  if (user.isTester) testers.push(user)
  if (!user.isActive) inactive.push(user)
}

7.7 数组比较前先检查长度

影响力: 中高 (避免长度不同时的昂贵操作)

在通过昂贵操作(排序、深度相等、序列化)比较数组时,先检查长度。如果长度不同,数组就不可能相等。

在实际应用中,当比较运行在热路径(事件处理程序、渲染循环)中时,此优化通过尤为有价值。

错误:总是运行昂贵的比较

function hasChanges(current: string[], original: string[]) {
  // 即使长度不同,也总是进行排序和连接
  return current.sort().join() !== original.sort().join()
}

即使 current.length 是 5 而 original.length 是 100,也会运行两次 O(n log n) 排序。连接数组和比较字符串也有开销。

正确 (先进行 O(1) 长度检查):

function hasChanges(current: string[], original: string[]) {
  // 如果长度不同,提前返回
  if (current.length !== original.length) {
    return true
  }
  // 仅当长度匹配时才排序/连接
  const currentSorted = current.toSorted()
  const originalSorted = original.toSorted()
  for (let i = 0; i < currentSorted.length; i++) {
    if (currentSorted[i] !== originalSorted[i]) {
      return true
    }
  }
  return false
}

这种新方法更高效,因为:

  • 当长度不同时,它避免了排序和连接数组的开销

  • 它避免了消耗内存来连接字符串(对于大数组尤其重要)

  • 它避免了修改原始数组

  • 发现差异时提前返回

7.8 函数提前返回

影响力: 中低 (避免不必要的计算)

确当定结果时提前返回,以跳过不必要的处理。

错误:即使找到答案也处理所有项目

function validateUsers(users: User[]) {
  let hasError = false
  let errorMessage = ''
  
  for (const user of users) {
    if (!user.email) {
      hasError = true
      errorMessage = 'Email required'
    }
    if (!user.name) {
      hasError = true
      errorMessage = 'Name required'
    }
    // 即使发现错误也继续检查所有用户
  }
  
  return hasError ? { valid: false, error: errorMessage } : { valid: true }
}

正确:一发现错误立即返回

function validateUsers(users: User[]) {
  for (const user of users) {
    if (!user.email) {
      return { valid: false, error: 'Email required' }
    }
    if (!user.name) {
      return { valid: false, error: 'Name required' }
    }
  }

  return { valid: true }
}

7.9 提升 RegExp 创建

影响力: 中低 (避免重新创建)

不要在 render 内部创建 RegExp。提升到模块作用域或使用 useMemo() 进行记忆化。

错误:每次渲染都创建新的 RegExp

function Highlighter({ text, query }: Props) {
  const regex = new RegExp(`(${query})`, 'gi')
  const parts = text.split(regex)
  return <>{parts.map((part, i) => ...)}</>
}

正确:记忆化或提升

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

function Highlighter({ text, query }: Props) {
  const regex = useMemo(
    () => new RegExp(`(${escapeRegex(query)})`, 'gi'),
    [query]
  )
  const parts = text.split(regex)
  return <>{parts.map((part, i) => ...)}</>
}

警告:全局 regex 具有可变状态

const regex = /foo/g
regex.test('foo')  // true, lastIndex = 3
regex.test('foo')  // false, lastIndex = 0

全局 regex (/g) 具有可变的 lastIndex 状态。

7.10 使用循环求最小/最大值而非排序

影响力: 低 (O(n) 而非 O(n log n))

查找最小或最大元素只需要遍历数组一次。排序是浪费且更慢的。

错误 (O(n log n) - 排序以查找最新):

interface Project {
  id: string
  name: string
  updatedAt: number
}

function getLatestProject(projects: Project[]) {
  const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
  return sorted[0]
}

仅为了查找最大值而对整个数组进行排序。

错误 (O(n log n) - 排序以查找最旧和最新):

function getOldestAndNewest(projects: Project[]) {
  const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
  return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
}

仅需要最小/最大值时仍然不必要地排序。

正确 (O(n) - 单次循环):

function getLatestProject(projects: Project[]) {
  if (projects.length === 0) return null
  
  let latest = projects[0]
  
  for (let i = 1; i < projects.length; i++) {
    if (projects[i].updatedAt > latest.updatedAt) {
      latest = projects[i]
    }
  }
  
  return latest
}

function getOldestAndNewest(projects: Project[]) {
  if (projects.length === 0) return { oldest: null, newest: null }
  
  let oldest = projects[0]
  let newest = projects[0]
  
  for (let i = 1; i < projects.length; i++) {
    if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
    if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]
  }
  
  return { oldest, newest }
}

单次遍历数组,无复制,无排序。

替代方案:Math.min/Math.max 用于小数组

const numbers = [5, 2, 8, 1, 9]
const min = Math.min(...numbers)
const max = Math.max(...numbers)

这对于小数组有效,但对于非常大的数组,由于展开运算符的限制,可能会更慢。为了可靠性,建议使用循环方法。

7.11 使用 Set/Map 进行 O(1) 查找

影响力: 中低 (O(n) -> O(1))

将数组转换为 Set/Map 以进行重复的成员身份检查。

错误 (每次检查 O(n)):

const allowedIds = ['a', 'b', 'c', ...]
items.filter(item => allowedIds.includes(item.id))

正确 (每次检查 O(1)):

const allowedIds = new Set(['a', 'b', 'c', ...])
items.filter(item => allowedIds.has(item.id))

7.12 使用 toSorted() 代替 sort() 以保证不可变性

影响力: 中高 (防止 React 状态中的变异 bug)

.sort() 会原地修改数组,这可能会导致 React 状态和 props 出现 bug。使用 .toSorted() 创建一个新的排序数组而不进行变异。

错误:修改原始数组

function UserList({ users }: { users: User[] }) {
  // 修改了 users prop 数组!
  const sorted = useMemo(
    () => users.sort((a, b) => a.name.localeCompare(b.name)),
    [users]
  )
  return <div>{sorted.map(renderUser)}</div>
}

正确:创建新数组

function UserList({ users }: { users: User[] }) {
  // 创建新的排序数组,原始数组未更改
  const sorted = useMemo(
    () => users.toSorted((a, b) => a.name.localeCompare(b.name)),
    [users]
  )
  return <div>{sorted.map(renderUser)}</div>
}

为什么这在 React 中很重要:

  1. Props/state 变异打破了 React 的不可变性模型 - React 期望 props 和 state 被视为只读

  2. 导致闭包陷阱 bug - 在闭包(回调、effects)内修改数组可能导致意外行为

浏览器支持:旧版浏览器回退

// 旧版浏览器的回退
const sorted = [...items].sort((a, b) => a.value - b.value)

.toSorted() 在所有现代浏览器(Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+)中均可用。对于旧环境,使用展开运算符。

其他不可变数组方法:

  • .toSorted() - 不可变排序

  • .toReversed() - 不可变反转

  • .toSpliced() - 不可变拼接

  • .with() - 不可变元素替换


8. 高级模式

影响力: 低

针对需要谨慎实现的特定情况的高级模式。

8.1 在 Refs 中存储事件处理程序

影响力: 低 (稳定的订阅)

当在不应因回调更改而重新订阅的 effect 中使用时,将回调存储在 refs 中。

错误:每次渲染都重新订阅

function useWindowEvent(event: string, handler: () => void) {
  useEffect(() => {
    window.addEventListener(event, handler)
    return () => window.removeEventListener(event, handler)
  }, [event, handler])
}

正确:稳定的订阅

import { useEffectEvent } from 'react'

function useWindowEvent(event: string, handler: () => void) {
  const onEvent = useEffectEvent(handler)

  useEffect(() => {
    window.addEventListener(event, onEvent)
    return () => window.removeEventListener(event, onEvent)
  }, [event])
}

替代方案:如果你使用的是最新版 React,请使用 useEffectEvent

useEffectEvent 为相同的模式提供了更清晰的 API:它创建一个稳定的函数引用,该引用始终调用处理程序的最新版本。

8.2 使用 useLatest 获取稳定的回调 Refs

影响力: 低 (防止 effect 重新运行)

在不将值添加到依赖数组的情况下访问回调中的最新值。防止 effect 重新运行,同时避免闭包陷阱。

实现:

function useLatest<T>(value: T) {
  const ref = useRef(value)
  useEffect(() => {
    ref.current = value
  }, [value])
  return ref
}

错误:在每次回调更改时重新运行 effect

function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('')

  useEffect(() => {
    const timeout = setTimeout(() => onSearch(query), 300)
    return () => clearTimeout(timeout)
  }, [query, onSearch])
}

正确:稳定的 effect,新鲜的回调

function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('')
  const onSearchRef = useLatest(onSearch)

  useEffect(() => {
    const timeout = setTimeout(() => onSearchRef.current(query), 300)
    return () => clearTimeout(timeout)
  }, [query])
}

参考资料

  1. react.dev
  2. nextjs.org
  3. swr.vercel.app
  4. github.com/shuding/bet…
  5. github.com/isaacs/node…
  6. vercel.com/blog/how-we…
  7. vercel.com/blog/how-we…

PinMe:零成本三秒发布你的网站

作者 修己xj
2026年1月17日 23:39

你是否渴望将自己的博客网站部署上线,却因高昂的服务器和域名费用、繁琐的配置流程而望而却步?你是否希望发布一个活动页面或图书介绍网站,却不愿购买服务器,也不想经历复杂的部署步骤?

最近在浏览 GitHub 时,我发现了一个很棒的项目——PinMe。 PinMe 的目标,就是把发布这件事简化成一句话:

「本地有一份静态资源,上传到网站发上去或者给我一个命令,我帮你发上去。」

不仅如此,这套发布方案还天生具备 “内容可验证” 与 “抗篡改” 的特点。 今天就来给大家推荐这个项目,相信它会让你的发布之路变得轻松又可靠!🚀

🔍什么是PinMe?

PinMe 是一款零配置的前端部署工具。无需服务器、无需账号、无需设置。

无论是构建静态站点、使用 AI 生成页面,还是导出前端项目 — 只需一条命令或者拖动文件夹上传即可即时部署。

PinMe 将您的网站发布为可验证的内容,相比传统托管,能更有效地防止静默篡改和意外损坏。

您无需管理服务器、区域或运行时间。PinMe 为您处理可用性和持久性。

github地址: github.com/glitternetw…

官网地址:pinme.eth.limo/

该项目在github 有2.6k ⭐️star

✒️ 核心特性

🚀 极简部署

  • 网页部署:进入网站,上传文件jar
  • 命令行部署: 只需执行几行简单的命令

🔒 去中心化存储

PinMe基于IPFS(星际文件系统)技术,将你的网站内容存储在去中心化网络中。这意味着:

  • 不可篡改:上传的内容会生成唯一的哈希值,确保内容完整性
  • 永久访问:即使单个节点离线,你的网站仍然可以通过其他节点访问
  • 全球加速:内容通过IPFS网络分布,实现就近访问

🆓 完全免费

目前PinMe提供免费的部署服务,支持:

  • 单个文件最大200MB
  • 整个目录最大1GB

🚂快速开始

🕸️ 网站上传部署(小白推荐)

浏览器打开网站:pinme.eth.limo/

将你构建好的前端项目或者静态网页文件上传,上传成功之后会返回一个url地址(保存好此地址),使用此地址即可访问你的网站。

部署示例网站:34759bf5.pinit.eth.limo/

pinme-web-example.png

注:建议使用github账号登录,登录之后每次上传有历史记录可以查看,历史记录中可以查询到我们的地址

🖥️ 命令行部署(开发推荐)

  1. 准备环境

要求 Node.js 版本 ≥ 16.13.0

如果版本过低,先升级 Node。

  1. 安装 PinMe CLI

使用 npm:

npm install -g pinme

3. 构建并上传

以常见的前端工程为例:

# Vite / React / Vue 项目,一般是:
npm run build

完成构建后,上传:

# 最常见的 dist 目录
pinme upload dist

命令执行成功后,你将得到:

  1. 一个 IPFS 内容 hash
  2. 一个预览页面链接:
    https://pinme.eth.limo/#/preview/*

打开这个链接,就能在线访问你的站点。

⏳ PinMe CLI:常用命令一览

PinMe 主要通过 CLI 提供能力,整体命令集很简洁。

⬆️ 1. 上传(核心命令)

# 交互式上传(会让你选择要上传的目录/文件)
pinme upload

# 直接指定路径上传
pinme upload /path/to/file-or-directory

# 上传并绑定一个固定子域名(需要 AppKey & Plus 会员)
pinme upload dist --domain my-site
# 简写
pinme upload dist -d my-site

注意:

  • 固定域名使用的是 https://<name>.pinit.eth.limo 这种形式
  • 绑定固定域名需要 AppKey 且开通 Plus 会员;普通用户可以使用预览链接访问。

🔄 2. 查看上传历史

# 查看最近 10 条上传记录
pinme list
# 或简写
pinme ls

# 指定数量
pinme list -l 5

# 清空本地上传历史
pinme list -c

♻️ 3. 删除与清理

# 交互式删除(从历史中选择)
pinme rm

# 指定 IPFS hash 删除
pinme rm <IPFS_hash>

需要说明的是,rm 实际做的是从 PinMe 使用的 IPFS 节点中「取消 pin 并移除 ENS 记录」,并不意味着全网 IPFS 都会立刻删除内容。

🔓 4. 登录与身份(AppKey)

PinMe 使用 AppKey 来标识用户,用于:

  • 账号登录
  • 上传历史合并
  • 固定域名绑定

appkey可在网页端 Account Information 页面查看

相关命令:

# 设置 AppKey
pinme set-appkey

# 查看当前 AppKey 信息(会做掩码处理)
pinme show-appkey
pinme appkey

# 登出并清除本地认证信息
pinme logout

# 查看当前账号拥有的域名
pinme my-domains
pinme domain

👁️‍🗨️ 5. 帮助信息

pinme help

📂 上传大小限制与存储说明

免费计划下的限制(以 README 为准):

类型 限制
单个文件 200 MB
整个目录总和 1 GB

上传后文件会存储在 IPFS 网络中,并通过 Glitter Protocol 的 IPFS 网关提供访问。

成功上传后你会获得:

  1. IPFS 内容 hash
  2. 预览链接:https://pinme.eth.limo/#/preview/*
  3. 可选固定域名:https://*.pinit.eth.limo(需 Plus)

本地日志默认保存在:

  • Linux / macOS: ~/.pinme/
  • Windows: %USERPROFILE%.pinme

❤️ 结语

PinMe代表了前端部署的未来方向——简单、快速、可靠。无论你是独立开发者、创业团队,还是大型企业,PinMe都能为你节省宝贵的时间和资源。

告别繁琐的服务器配置,拥抱一键部署的新时代。试试PinMe,体验前所未有的部署体验!

一句话总结:构建你的网站,运行pinme upload,然后就可以分享链接了。就这么简单。

ArcGIS Pro 添加底图的方式

作者 GIS之路
2026年1月17日 23:13

^ 关注我,带你一起学GIS ^

前言

众所周知,ArcGIS Pro中来自ESRI公司的底图被封禁了,虽然使用天地图数据源进行了替换,但是使用起来总感觉差点儿意思,不那么让人舒服。所以,还是另想办法把ESRI原底图给加进来。

之前通过Map菜单下的Basemap选项便可以直接添加底图,便捷又省力。可恶的是我的底图这里竟然是空空如也,连天地图也消失不见。

还是得自立更生啊。去网上找了一下,好多文章比较老,推荐的数据源地址还是:https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer

结果给我死活都加载不了,浏览器也无法访问,打开命令行测试一下域名是否能够联通。好家伙,直接请求超时了,估计是域名换了,继续重新找资源。

这是新的服务地址,原来域名由以前的services改为server了。

https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer

可以ping通,继续在浏览器里面测试一下看看是否能正常打开,经验证是可以。

使用ArcGIS API预览一下服务,发现都是ok的。

那现在我们就可以使用ArcGIS Pro来连接一下在线服务了。

1. 通过ArcGIS Server添加

先按照以下四步进行操作:

(1)、 点击菜单栏Insert按钮

(2)、 打开Connections下拉菜单

(3)、 选择Server选项

(4)、 最后点击右侧New ArcGIS Server

在添加服务对话框中输入地址:https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer,然后点击OK

点击目录中的Servers选项,然后选择添加的服务链接即可查看添加的服务。

在你要添加服务上右键,可以选择将图层添加到当前地图或者添加新地图、场景。也可以直接拖动图层到地图容器或者场景中进行查看。

如果你的软件没有显示目录或者你没有找到Servers选项的话,可以通过顶部菜单栏进行打开。

首先点击菜单栏View选项,然后打开Catalog Pane

2. 通过Add Data 添加

(1) 点击菜单栏Map

(2) 选择Add Data选项

(3) 点击From Path········

然后再添加数据对话框中填入地址:https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer,然后点击Add

图层显示效果如下:

还可以直接在对话框中填入xyz服务地址:https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x},然后点击Add

此种方式显示效果如下。

3. 服务地址

下面推荐几个常用的地图服务地址,具有xyz服务类型,也有ArcGIS地图服务类型。可以添加影像底图、矢量底图和地形数据。

(一)ArcGIS xyz 切片

# Esri Imagery 服务
https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}

# Esri Topographic 服务
https://server.arcgisonline.com/arcgis/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}

# Esri Terrain 服务
https://server.arcgisonline.com/arcgis/rest/services/World_Terrain_Base/MapServer/tile/{z}/{y}/{x}

(二)ArcGIS MapServer

# USA 服务
https://sampleserver6.arcgisonline.com/ArcGIS/rest/services/USA/MapServer

# Wildfire 服务
https://sampleserver6.arcgisonline.com/arcgis/rest/services/Wildfire/MapServer

# Esri Imagery 服务
https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer

# Esri Topographic 服务
https://server.arcgisonline.com/arcgis/rest/services/World_Topo_Map/MapServer

# Esri Terrain 服务
https://server.arcgisonline.com/arcgis/rest/services/World_Terrain_Base/MapServer

原域名services.arcgisonline.com已经不能使用。

https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer

OpenLayers示例数据下载,请在公众号后台回复:vector

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集

为什么每次打开 ArcGIS Pro 页面加载都如此缓慢?

ArcGIS Pro 实现影像波段合成

自然资源部党组关于苗泽等4名同志职务任免的通知

GDAL 创建矢量图层的两种方式

GDAL 实现矢量数据转换处理(全)

GDAL 实现投影转换

国产版的Google Earth,吉林一号卫星App“共生地球”来了2026年全国自然资源工作会议召开

日本欲打造“本土版”星链系统

吉林一号国内首张高分辨率彩色夜光卫星影像发布

2025 年度信创领军企业名单出炉!

GPT-5.2 七天“手搓”Chrome,社区评价:依托大的

2026年1月17日 23:09

在AI这行,营销的油门,好像总比技术的轮子踩得猛。

最近,Cursor团队(由他们的CEO Michael Truell亲自演示)发了一段视频,在科技圈里简直炸开了锅。视频里说,他们用最新的GPT-5.2模型,通过一个叫“Shadow”的AI智能体,只花了7天,就让AI自己写了300万行代码,从零到一搞出了一个“Chrome级别”的现代浏览器。

这个故事,几乎集齐了所有让投资人和技术宅热血沸腾的元素:不知疲倦的AI劳工、飙升的生产力,还有对人类编程工作的“降维打击”。但如果我们剥开这层金光闪闪的包装,看到的真的是“奇迹”吗,还是只是一场精心设计的“调包”戏法。

神话诞生——视频里的“奇迹七天”

在Cursor团队CEO Michael Truell的演示里,故事讲得那叫一个宏大。

Truell介绍了一个叫“Shadow”的AI智能体系统。它和以前只能补几行代码的Copilot可不一样,被描述成一个全自动的软件工程师。据说它能理解庞大的代码库,有长期记忆,还能自己规划任务。

按照视频的说法,这个AI在7天里几乎没休息。

它先自己拿主意,决定需要什么模块,自己设计架构。接着自己修Bug,遇到编译错误或者逻辑问题,它会像人一样去看报错信息,然后改代码,直到跑通为止。最终产出了一个叫fastrender的项目,据说有300万行代码。视频甚至明里暗里地暗示,这个浏览器复杂得很,AI连HTML解析器、CSS布局引擎、文本渲染系统都自己写了,甚至还搞定了一个JavaScript虚拟机。

演示者的话里话外都在表达,这不仅仅是个浏览器,简直是AI要取代人类程序员的“转折点”。听起来,AI已经不再是辅助工具,而是一个能独立完成从设计到实现全过程的“超级包工头”了。

泡沫破裂——除了胶水,啥也不是

可是,当社区的开发者们真的去看了代码仓库,也就是GitHub上的wilsonzlin/fastrender,尤其是检查了那个关键的Cargo.toml(Rust项目的依赖配置文件)之后,这场“造神运动”立马变成了一个笑话。

所谓的“从零自研”,在事实面前根本站不住脚。

视频里吹得神乎其神的“核心底层技术”,其实全是AI,也就是GPT-5.2,从Rust开源社区“借”来的现成轮子。那300万行代码,绝大部分是这些第三方库的,或者是AI为了调用这些库而写出来的、臃肿的“胶水代码”。

来看看这个“AI奇迹”的真面目吧。

它根本没写HTML解析逻辑,直接调用了Servo‘s HTML parser。这是Mozilla(火狐背后的团队)为Servo引擎耕耘了十几年的成果。它也没处理复杂的CSS权重和匹配,用的是Servo‘s CSS parserselectors库。对于浏览器最难的部分JavaScript引擎,它直接把QuickJS(通过rquickjs绑定)塞进去了,压根没写虚拟机,只是写了个“启动QuickJS”的函数。此外,SVG渲染靠的是resvg,图形渲染靠的是wgputiny-skia,GUI界面用的是egui,而WebSocket支持则依赖tungstenite

这就像有人号称自己“七天从零造了辆法拉利”,结果大家发现他只是上网买了法拉利的发动机、保时捷的轮子、特斯拉的底盘,然后用胶带把它们捆在一块,最后给外壳喷了个漆。

社区的群嘲:一堆没用的“代码山”

这种工程在技术圈里引发了大量的吐槽,大家普遍觉得,这次演示不仅没证明AI多厉害,反而暴露了现在大语言模型写代码的大问题。

社区开发者调侃说,如果这也能叫“造浏览器”,那任何一个学过Rust的初级程序员,只要会用cargo add命令,都能在半天内“造”出一个来。AI干的主要活儿,就是用一种不怎么优雅的方式,把这些现成的、设计得挺好的库给拼起来。

宣称的“300万行代码”成了最大的讽刺。对于一个主要起连接作用的项目,代码越多,通常意味着工程越差。真正的工程师追求用更少的代码做更多的事(KISS原则)。而AI生成了一大堆样板代码、重复的逻辑判断和冗余的类型转换。这根本不是功劳,而是技术负担。烧了几亿Token换来的,是一个人类没法维护、没法阅读、一旦依赖库升级就可能全盘崩溃的数字垃圾场。

浏览器的核心难点,比如DOM树的构建、复杂的渲染流水线优化、JIT编译器的安全性等等,Shadow一个都没解决,它只是绕开了。这说明AI擅长搜索和拼接,但根本不具备系统级的工程创新能力

写在最后

GPT-5.2的这次演示,与其说是展示了AI的编程实力,不如说是展示了AI的“画饼”实力。

它活灵活现地模仿了一个只会东抄抄、西抄抄,然后把代码量堆得老高来糊弄人的普通程序员。对资本市场来说,这或许是个好听的故事;但对认真的软件工程来说,这只是一次既浪费钱又没啥意义的折腾。

我们不需要AI用300万行代码把别人的成果重新包装一遍,我们更希望它能用30行代码解决一个人类想了很久的难题。很可惜,这一次,它交出的是一份不及格的答卷。

昨天 — 2026年1月17日掘金 前端

为什么每次打开 ArcGIS Pro 页面加载都如此缓慢?

作者 GIS之路
2026年1月17日 21:33

^ 关注我,带你一起学GIS ^

大家有没有这种经历,每次新建或者打开一个已有的ArcGIS Pro工程的话,在加载场景页面都要等待很长时间,这无疑对使用体验造成非常不好的影响。

首先需要检查一下电脑配置,如果你的电脑运行内存稍微低一点,显卡也不太给力,对软件运行会造成一定影响。

可访问ESRI网站查看配置需求。

访问如下地址,可检测本机运行环境。

https://www.systemrequirementslab.com/client-app?refid=1256&appkey=6D681CD0-BA6C-4B6B-9A82-639759CFD094&requirementsetid=23091

标识ArcGIS Pro版本为3.3到3.6,我本机的是3.5。

好吧,虽然··············,但是我的电脑没运行出结果,或者说检测出错了,估计是个bug有完整运行结果的同学可以截图看一下。

电脑配置这关过了的话,咱么可以进行软件设置了。

我的主要问题是每次新建工程或者打开原有工程,创建场景的时间都很漫长(底图没被封之前就加载缓慢),也有可能是网络的原因。

但我的需求其实不需要一开始就添加一个完整的底图,包括地形、影像或者矢量,大多数情况下我只需要一个空白工程就可以了,所以我的解决办法是在设置里面将默认加载底图去除。

首先,打开设置选项Options

然后找到Map and Scene,在右侧将Map项和Scene项修改为None,也就是默认不加载底图。

经过此步骤之后,我再打开ArcGIS Pro那就舒服多了。

OpenLayers示例数据下载,请在公众号后台回复:vector

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集

ArcGIS Pro 实现影像波段合成

自然资源部党组关于苗泽等4名同志职务任免的通知

GDAL 创建矢量图层的两种方式

GDAL 数据类型大全

GDAL 实现矢量数据转换处理(全)

GDAL 实现投影转换

国产版的Google Earth,吉林一号卫星App“共生地球”来了

2026年全国自然资源工作会议召开

日本欲打造“本土版”星链系统

吉林一号国内首张高分辨率彩色夜光卫星影像发布

2025 年度信创领军企业名单出炉!

🌰在 OpenLayers 中实现图层裁切

作者 黑心皮蛋
2026年1月17日 19:31

img💬 前言

做地图的时候,常会给地图套个“遮罩”——把不关心的地方调暗,把想看的地方突出出来。但有时我们并不想动整张地图的视觉效果,只想把某一张图层按不规则形状展示出来,比如只在某块多边形里显示指定图层。

🎯 最终效果如下

给定一个多边形(polygonCoords),对单一 WMTS/瓦片图层进行裁切:图层仅在该多边形内部可见,其他图层不受影响。

图层裁切最终效果

🧭 实现思路

通过监听图层的 prerender 事件 和 postrender事件 实现裁切

  1. 在图层开始绘制之前(prerender)准备裁切:
    • 回调里会拿到两个东西:OpenLayers 的矢量渲染上下文和原生的 Canvas 上下文(ctx)。
    • 先用 ctx.save() 保存当前 canvas 状态。
    • drawFeature(feature, style) 把多边形画到当前画布上(这会在 canvas 上生成路径)。
    • 然后调用 ctx.clip(),把这条路径设为裁切区,将当前图层进行裁切。
  2. 在图层绘制结束后(postrender)恢复状态:
    • 在 postrender 回调里调用 ctx.restore(),把之前保存的 canvas 状态还原,移除裁切,确保别的图层不受影响。

⚙️ 初始化项目

这里我设置地图的坐标系是3857,你可以根据自己的需要设置对应的坐标系

<!DOCTYPE html>
<html lang="zh-cn">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>openlayer图层裁切</title>
    <style>
        html,
        body,
        #map {
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
        }
    </style>
</head>

<body>
    <div id="map"></div>
    <script src="./ol.js"></script>
    <script>
        const projection = ol.proj.get('EPSG:3857');
        const map = new ol.Map({
            layers: [
                // 矢量底图
                new ol.layer.Tile({
                    source: new ol.source.XYZ({
                        url: `https://t{0-7}.tianditu.gov.cn/DataServer?T=vec_w&x={x}&y={y}&l={z}&tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                    }),
                }),
                // 矢量注记
                new ol.layer.Tile({
                    source: new ol.source.XYZ({
                        url: `https://t{0-7}.tianditu.gov.cn/DataServer?T=cva_w&x={x}&y={y}&l={z}&tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                    }),
                }),
            ],
            target: 'map',
            view: new ol.View({
                zoom: 5,
                maxZoom: 17,
                minZoom: 1,
                projection: projection,
                center: [116.406393, 39.909006],
            }),
        })
    </script>
</body>

</html>

效果如下

image-20260117182119131

📐 多边形预览

前面实现思路说了,我们需要一个多边形裁切图层,我这里准备了一个

   const polygonCoords =
                [
                    [
                        [
                            911245.7835522988,
                            1672839.829528198
                        ],
                        [
                            1517290.255658307,
                            2962709.5078420187
                        ],
                        [
                            2862581.9534774087,
                            3011639.670051078
                        ],
                        [
                            3492423.0665472616,
                            2736407.507625122
                        ],
                        [
                            3675871.934431684,
                            2161478.101668681
                        ],
                        [
                            3131640.2930412292,
                            1647711.398473564
                        ],
                        [
                            2141016.406465345,
                            999386.7492035348
                        ],
                        [
                            2581293.6893879604,
                            63597.39695528569
                        ],
                        [
                            2165476.2555166017,
                            -523564.5495534199
                        ],
                        [
                            3107180.4439899744,
                            -982284.8202633462
                        ],
                        [
                            2232740.8404075587,
                            -1379842.3882119493
                        ],
                        [
                            1609014.6896005198,
                            -1300330.874622228
                        ],
                        [
                            1040323.1991588091,
                            -1104610.2257859926
                        ],
                        [
                            471631.7087170966,
                            -450169.3062398317
                        ],
                        [
                            465516.74645428266,
                            240969.23496312322
                        ],
                        [
                            324872.6144095585,
                            913758.9653376816
                        ],
                        [
                            593930.953973379,
                            1757804.2634439464
                        ],
                        [
                            911245.7835522988,
                            1672839.829528198
                        ]
                    ]
                ]

image-20260117190344593

后面就会用这个多边形来对图层进行裁切

🗺️ 添加一张影像图

<!DOCTYPE html>
<html lang="zh-cn">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>openlayer图层裁切</title>
    <style>
        html,
        body,
        #map {
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
        }
    </style>
</head>

<body>
    <div id="map"></div>
    <script src="./ol.js"></script>
    <script>
        const projection = ol.proj.get('EPSG:3857');
        const projectionExtent = projection.getExtent();
        const size = ol.extent.getWidth(projectionExtent) / 256;
        const resolutions = [];
        for (let z = 2; z < 19; ++z) {
            resolutions[z] = size / Math.pow(2, z);
        }
        const matrixIds = ['0',
            '1',
            '2',
            '3',
            '4',
            '5',
            '6',
            '7',
            '8',
            '9',
            '10',
            '11',
            '12',
            '13',
            '14',
            '15',
            '16',
            '17',
            '18']
        const map = new ol.Map({
            layers: [
                // 矢量底图
                new ol.layer.Tile({
                    source: new ol.source.XYZ({
                        url: `https://t{0-7}.tianditu.gov.cn/DataServer?T=vec_w&x={x}&y={y}&l={z}&tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                    }),
                }),
                // 矢量注记
                new ol.layer.Tile({
                    source: new ol.source.XYZ({
                        url: `https://t{0-7}.tianditu.gov.cn/DataServer?T=cva_w&x={x}&y={y}&l={z}&tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                    }),
                }),
            ],
            target: 'map',
            view: new ol.View({
                zoom: 5,
                maxZoom: 17,
                minZoom: 1,
                projection: projection,
                center: [116.406393, 39.909006],
            }),
        })
        const wmtsLayer = new ol.layer.Tile({
            source: new ol.source.WMTS({
                url: `http://t{0-6}.tianditu.gov.cn/img_c/wmts?tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                layer: 'img',
                matrixSet: 'c',
                style: 'default',
                crossOrigin: 'anonymous',
                format: 'tiles',
                wrapX: true,
                crossOrigin: 'anonymous',
                tileGrid: new ol.tilegrid.WMTS({
                    origin: ol.extent.getTopLeft(projectionExtent),
                    resolutions: resolutions,
                    matrixIds: matrixIds
                })
            })
        })
        map.addLayer(wmtsLayer);
    </script>
</body>

</html>

效果如下

image-20260117191715249

🔔 监听图层事件

前面已经将这个影像图添加到地图上面去了, 接下来就可以监听这个图层事件,然后用多边形去裁切这张图层

// wmtsLayer 就是前面添加的影像图
wmtsLayer.on('prerender', function (evt) {
    const layerContent = ol.render.getVectorContext(evt)
    const ctx = evt.context;
    ctx.save();
    layerContent.drawFeature(polygon, polygonStyle)
    ctx.clip();
});

// 渲染后恢复 canvas 状态,移除裁切
wmtsLayer.on('postrender', function (evt) {
    const ctx = evt.context;
    if (ctx) ctx.restore();
});
const layer = map.addLayer(wmtsLayer);

📄 完整代码

<!DOCTYPE html>
<html lang="zh-cn">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>openlayer图层裁切</title>
    <style>
        html,
        body,
        #map {
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
        }

        /* 右上角形状按钮组 */
        #shape-controls {
            position: fixed;
            top: 12px;
            right: 12px;
            display: flex;
            flex-direction: column;
            gap: 8px;
            z-index: 1000;
        }

        #shape-controls button {
            padding: 6px 10px;
            font-size: 14px;
            cursor: pointer;
        }

        #shape-controls button.active {
            background: #0b5ed7;
            color: #fff;
        }
    </style>
</head>

<body>
    <div id="map"></div>
    <div id="shape-controls">
        <button id="btn-polygon">添加多边形影像图</button>
    </div>
    <script src="./ol.js"></script>
    <script>
        const projection = ol.proj.get('EPSG:3857');
        const projectionExtent = projection.getExtent();
        const size = ol.extent.getWidth(projectionExtent) / 256;
        const resolutions = [];
        for (let z = 2; z < 19; ++z) {
            resolutions[z] = size / Math.pow(2, z);
        }
        const matrixIds = ['0',
            '1',
            '2',
            '3',
            '4',
            '5',
            '6',
            '7',
            '8',
            '9',
            '10',
            '11',
            '12',
            '13',
            '14',
            '15',
            '16',
            '17',
            '18']
        const map = new ol.Map({
            layers: [
                // 矢量底图
                new ol.layer.Tile({
                    source: new ol.source.XYZ({
                        url: `https://t{0-7}.tianditu.gov.cn/DataServer?T=vec_w&x={x}&y={y}&l={z}&tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                    }),
                }),
                // 矢量注记
                new ol.layer.Tile({
                    source: new ol.source.XYZ({
                        url: `https://t{0-7}.tianditu.gov.cn/DataServer?T=cva_w&x={x}&y={y}&l={z}&tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                    }),
                }),
            ],
            target: 'map',
            view: new ol.View({
                zoom: 5,
                maxZoom: 17,
                minZoom: 1,
                projection: projection,
                center: [116.406393, 39.909006],
            }),
        })

        document.getElementById('btn-polygon').addEventListener('click', function () {
            const polygonCoords =
                [
                    [
                        [
                            911245.7835522988,
                            1672839.829528198
                        ],
                        [
                            1517290.255658307,
                            2962709.5078420187
                        ],
                        [
                            2862581.9534774087,
                            3011639.670051078
                        ],
                        [
                            3492423.0665472616,
                            2736407.507625122
                        ],
                        [
                            3675871.934431684,
                            2161478.101668681
                        ],
                        [
                            3131640.2930412292,
                            1647711.398473564
                        ],
                        [
                            2141016.406465345,
                            999386.7492035348
                        ],
                        [
                            2581293.6893879604,
                            63597.39695528569
                        ],
                        [
                            2165476.2555166017,
                            -523564.5495534199
                        ],
                        [
                            3107180.4439899744,
                            -982284.8202633462
                        ],
                        [
                            2232740.8404075587,
                            -1379842.3882119493
                        ],
                        [
                            1609014.6896005198,
                            -1300330.874622228
                        ],
                        [
                            1040323.1991588091,
                            -1104610.2257859926
                        ],
                        [
                            471631.7087170966,
                            -450169.3062398317
                        ],
                        [
                            465516.74645428266,
                            240969.23496312322
                        ],
                        [
                            324872.6144095585,
                            913758.9653376816
                        ],
                        [
                            593930.953973379,
                            1757804.2634439464
                        ],
                        [
                            911245.7835522988,
                            1672839.829528198
                        ]
                    ]
                ]

            const wmtsLayer = new ol.layer.Tile({
                source: new ol.source.WMTS({
                    url: `http://t{0-6}.tianditu.gov.cn/img_c/wmts?tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                    layer: 'img',
                    matrixSet: 'c',
                    style: 'default',
                    crossOrigin: 'anonymous',
                    format: 'tiles',
                    wrapX: true,
                    crossOrigin: 'anonymous',
                    tileGrid: new ol.tilegrid.WMTS({
                        origin: ol.extent.getTopLeft(projectionExtent),
                        resolutions: resolutions,
                        matrixIds: matrixIds
                    })
                })
            })
            const polygon = new ol.Feature({
                geometry: new ol.geom.Polygon(polygonCoords)
            })
            const polygonStyle = new ol.style.Style({
                stroke: new ol.style.Stroke({
                    color: 'rgba(255, 0, 0, 1)',
                    width: 2
                })
            });

            // 在渲染前使用多边形对图层进行裁切(clip)
            wmtsLayer.on('prerender', function (evt) {
                const layerContent = ol.render.getVectorContext(evt)
                const ctx = evt.context;
                ctx.save();
                layerContent.drawFeature(polygon, polygonStyle)
                ctx.clip();
            });

            // 渲染后恢复 canvas 状态,移除裁切
            wmtsLayer.on('postrender', function (evt) {
                const ctx = evt.context;
                if (ctx) ctx.restore();
            });
            const layer = map.addLayer(wmtsLayer);
        });
    </script>
</body>

</html>

taro项目踩坑指南——(一)

作者 Snack
2026年1月17日 18:27

自定义导航栏

在开发小程序的时候没有使用开发者工具,用的H5。想要有一个自定义的底部导航栏,所以就尝试了一下taro中的自定义导航栏。结果配置之后,直接让页面显示不出来内容了。

image.png

随后查询官网后,自定义导航栏好像只支持微信小程序的。H5的应该是没有的,把这段代码删掉之后就可以正常显示了。好烦!!!

在自定义导航栏时,应该由导航栏组件自行控制页面的变更和导航栏的状态变化。在taro中使用useDidShow这个生命周期Hook,通过页面显示,判断当前路由,并根据当前路由设置activeTab。在点击导航栏跳转页面时,使用redirectTo进行跳转。

  useDidShow(() => {
    const pages = Taro.getCurrentPages();
    const current = pages[pages.length - 1];
    const route = current.route;
    const matched = tabList.find((tab) => tab.pagePath === route);
    if (matched) {
      setActiveTab(matched.id);
    }
  });

  const switchTab = (tab) => {
    if (activeTab !== tab.id) {
      Taro.redirectTo({ url: tab.pagePath });
    }
  };

页面报错或者无渲染

页面报错类型

image.png

这通常是因为Taro无法识别到正确的页面组件。当使用react进行开发时,成为页面的那个组件需要默认导出 export default functoin Index(){return <></>}并且要在app.config.ts中配置正确的路由信息。

如果出现页面白屏没有节点渲染并且控制台没有出现报错的情况,这可能是因为组件需要接收的外部组件传来的数据为空或者是属性不匹配导致的。在使用的时候需要注意组件中的数据安全兜底,进行判空。

// 如果外部组件没有传入post,那么就不会渲染到页面上。(这是一个嵌入到页面中的组件)
export function Index(post){
    return (
        <div>{post.msg}</div>
    )
}

小程序 Markdown 渲染血泪史:mp-html 组件从入门到放弃再到重生

作者 小时前端
2026年1月17日 18:14

前言:小程序 MD 渲染的难点

在小程序开发中,Markdown 内容的渲染一直是一个让人头疼的问题。与 Web 端丰富的生态不同,小程序平台有着诸多限制:

  • 平台差异性:微信小程序、支付宝小程序、百度小程序等平台对 HTML 和 CSS 的支持程度不同
  • 安全限制:小程序不允许直接使用 dangerouslySetInnerHTML 或动态执行脚本
  • 样式兼容性:部分 CSS 属性在小程序中不被支持
  • 性能瓶颈:复杂的富文本渲染容易影响页面性能
  • 交互限制:图片预览、链接跳转等交互行为需要特殊处理

这些限制使得在小程序中实现完整的 Markdown 渲染变得异常困难。开发者往往需要在功能完整性和性能体验之间寻找平衡。

最近的踩坑经历

最近在开发一个小程序时,需要在小程序中渲染包含代码块、表格、图片等多种元素的 Markdown 内容。一开始天真地以为可以用简单的 HTML 解析器搞定,结果踩了一堆坑。

坑一:代码高亮完全不工作

最先遇到的就是代码高亮问题。我按照文档启用 Markdown 插件后,代码块倒是能显示,但是语法高亮完全没有效果。

// 这是我一开始的配置
<mp-html
  :content="markdownContent"
  :markdown="true"
/>

代码块显示成了普通文本,完全没有颜色区分。后来才发现需要单独启用 highlight 插件,而且还需要重新构建组件。

坑二:多语言代码块识别失败

启用代码高亮后,发现只有 JavaScript 代码能正常高亮,其他语言如 Java、Python、Go 等都显示为纯文本。原来 mp-html 默认只支持基础的几种语言,需要手动下载完整的 Prism.js 文件来支持更多编程语言。

坑三:图片路径和预览问题

Markdown 中的图片链接在小程序中无法直接显示。一开始我尝试使用相对路径,结果全部加载失败。后来发现需要配置 domain 属性来处理图片路径,而且图片预览功能还需要手动开启。

坑四:表格滚动和样式问题

复杂的表格在小程序中显示不全,内容会被截断。特别是手机端,表格列数多时根本看不清内容。而且表格的样式也需要特殊处理,默认样式很难看。

坑五:文本选择和复制功能

用户希望能复制代码块的内容,但是默认情况下小程序不支持文本选择。后来发现可以通过配置 selectable 属性来开启这个功能。

坑六:Mermaid 图表渲染失败

最棘手的问题是 Mermaid 图表渲染。在 Markdown 中,Mermaid 图表通常以代码块形式存在:

```mermaid
graph TD;
    A-->B;
    A-->C;
    B-->D;
    C-->D;

mp-html 组件本身不支持 Mermaid 语法渲染,因为 Mermaid 需要 JavaScript 引擎来解析和绘制图表,而小程序环境对这有限制。

一开始我尝试直接在 Markdown 中嵌入 Mermaid 代码,但结果只能显示为普通的代码块,完全没有图表效果。

## 解决方案大揭秘

经过一番折腾,终于找到了完整的解决方案。以下是我的实战配置:

### 1. 完整的组件配置

```vue
<template>
  <view class="article-container">
    <mp-html
      :content="articleContent"
      :markdown="true"
      :preview-img="true"
      :scroll-table="true"
      :selectable="true"
      :use-anchor="true"
      :lazy-load="true"
      container-style="padding: 20rpx; background: #fff; border-radius: 12rpx;"
      @load="onContentLoad"
      @ready="onContentReady"
      @imgtap="onImageTap"
      @linktap="onLinkTap"
      @error="onError"
    />
  </view>
</template>

2. 插件配置(重点)

需要在 node_modules/mp-html/tools/config.js 中启用所有必要的插件:

module.exports = {
  plugins: [
    'markdown',    // Markdown 解析
    'highlight',   // 代码高亮
    'emoji',       // Emoji 支持
  ],

  // 全局样式配置
  externStyle: `
    .markdown-body {
      color: #333;
      line-height: 1.6;
      font-size: 28rpx;
    }
    .markdown-body h1,
    .markdown-body h2,
    .markdown-body h3 {
      color: #2c3e50;
      margin: 40rpx 0 20rpx 0;
      font-weight: 600;
    }
    .markdown-body p {
      margin: 20rpx 0;
      text-align: justify;
    }
    .markdown-body pre {
      background: #f6f8fa;
      border-radius: 8rpx;
      padding: 20rpx;
      overflow-x: auto;
    }
    .markdown-body code {
      background: #f1f3f4;
      padding: 4rpx 8rpx;
      border-radius: 4rpx;
      font-family: 'Consolas', 'Monaco', monospace;
    }
    .markdown-body blockquote {
      border-left: 4rpx solid #ddd;
      padding-left: 20rpx;
      margin: 20rpx 0;
      color: #666;
      background: #fafafa;
    }
    .markdown-body table {
      border-collapse: collapse;
      width: 100%;
      margin: 20rpx 0;
    }
    .markdown-body th,
    .markdown-body td {
      border: 1rpx solid #ddd;
      padding: 12rpx;
      text-align: left;
    }
    .markdown-body th {
      background: #f5f5f5;
      font-weight: 600;
    }
  `
}

3. 代码高亮扩展(关键步骤)

要支持多种编程语言的代码高亮,需要:

  1. 访问 prismjs.com/download.ht…
  2. 选择 Tomorrow Night 主题
  3. 勾选需要的语言:JavaScript, Java, Python, Go, C++, C, HTML, CSS 等
  4. 下载 prism.min.jsprism.css
  5. 替换 node_modules/mp-html/plugins/highlight/ 目录下的对应文件
  6. 重新构建组件:npm run build:uni-app

4. 代码高亮配置优化

// plugins/highlight/config.js
module.exports = {
  copyByLongPress: true,    // 长按复制代码
  showLanguageName: true,   // 显示语言名称
  showLineNumber: true       // 显示行号
}

5. Mermaid 图表处理

对于 Mermaid 图表渲染问题,需要在前端预处理,将 Mermaid 代码块转换为图片:

// utils/mermaidProcessor.js
export function processMermaidInMarkdown(content) {
  if (!content) return content

  // 匹配 mermaid 代码块
  const mermaidRegex = /```mermaid\s*\n([\s\S]*?)\n```/g

  return content.replace(mermaidRegex, (match, mermaidCode) => {
    try {
      // 将 mermaid 代码发送到服务器生成图片
      // 这里需要实现一个服务,将 mermaid 代码转换为图片 URL
      const imageUrl = generateMermaidImageUrl(mermaidCode)
      return `![Mermaid 图表](${imageUrl})`
    } catch (error) {
      console.error('Mermaid 处理失败:', error)
      // 处理失败时返回原始代码块
      return match
    }
  })
}

// 在 Vue 组件中使用
const processedContent = computed(() => {
  const content = question.value?.content
  if (!content) return ''

  // 先处理 mermaid(将 mermaid 代码块转换为图片)
  const mermaidProcessed = processMermaidInMarkdown(content)
  // 再转换为 HTML
  return mermaidProcessed
})

关键点

  • Mermaid 图表无法在小程序中直接渲染,需要预先转换为图片
  • 需要后端服务或第三方 API 将 Mermaid 代码转换为图片
  • 处理顺序:先转换 Mermaid → 再解析 Markdown → 最后渲染 HTML

6. 图片处理策略

// 处理图片路径
getImageUrl(originalUrl) {
  // 如果是相对路径,拼接完整域名
  if (originalUrl.startsWith('/')) {
    return `https://your-domain.com${originalUrl}`
  }
  // 如果是外部链接,可以添加代理或直接返回
  return originalUrl
}

6. 事件处理

export default {
  methods: {
    onContentLoad() {
      console.log('Markdown 内容加载完成')
      // 可以在这里添加加载完成后的逻辑
    },

    onContentReady() {
      console.log('所有资源加载完成')
      // 图片等资源加载完成后的处理
    },

    onImageTap(e) {
      const { src, i } = e.detail
      console.log(`点击第 ${i + 1} 张图片:`, src)
      // 可以在这里添加图片统计或其他逻辑
    },

    onLinkTap(e) {
      const { href } = e.detail
      // 处理内部链接跳转
      if (href.startsWith('#')) {
        // 锚点跳转
        this.$refs.mpHtml.navigateTo(href.substring(1))
      } else if (href.startsWith('/')) {
        // 内部页面跳转
        uni.navigateTo({ url: href })
      } else {
        // 外部链接,可以复制或用 webview 打开
        uni.setClipboardData({
          data: href,
          success: () => {
            uni.showToast({
              title: '链接已复制',
              icon: 'success'
            })
          }
        })
      }
    },

    onError(err) {
      console.error('渲染错误:', err)
      // 错误处理逻辑
    }
  }
}

性能优化建议

1. 图片懒加载

<mp-html :lazy-load="true" />

2. 按需加载

只在需要 Markdown 渲染的页面引入组件,避免全局注册增加包体积。

3. 缓存策略

对于频繁使用的 Markdown 内容,可以考虑缓存解析结果。

4. 样式优化

使用 externStyle 统一配置样式,避免内联样式过多影响性能。

总结

mp-html 确实是一个功能强大的小程序 Markdown 渲染组件,但在使用的过程中需要注意:

  1. 插件配置:必须正确启用和配置相关插件
  2. 代码高亮:需要手动扩展 Prism.js 来支持更多语言
  3. 样式处理:充分利用 externStyletag-style 进行样式定制
  4. 事件处理:合理处理图片、链接等交互事件
  5. 性能优化:开启懒加载,合理使用缓存策略
  6. Mermaid 图表:需要预处理将 Mermaid 代码转换为图片

虽然踩了不少坑,但最终的效果还是很满意的。现在我们的小程序可以完美地渲染包含代码高亮、表格、图片、Mermaid 图表等多种元素的 Markdown 内容,为用户提供了良好的阅读体验。

特别值得一提的是 Mermaid 图表的处理,这在技术面试题中非常常见。通过预处理将图表转换为图片,我们成功解决了小程序环境中无法直接渲染 Mermaid 的问题。

如果你也在小程序开发中遇到 Markdown 渲染的问题,不妨试试 mp-html 这个组件,希望这篇踩坑记能为你节省一些时间!

从零构建本地AI应用:React与Ollama全栈开发实战

2026年1月17日 17:39

博客开篇:为什么选择本地大模型开发?

在当前的AI浪潮中,OpenAI等云端API虽然强大,但存在数据隐私、Token成本和网络延迟的问题。Ollama 的出现,让开发者可以在本地轻松部署开源大模型(如Qwen、Llama 3等)。结合前端框架(如React),我们可以构建完全私有、低成本的AI应用。

本博客将带你从环境搭建到代码实现,一步步构建一个基于 React 和 Ollama 的聊天应用,并深入剖析其中的技术细节。


第一部分:环境与基础架构

1.1 Ollama 核心原理

Ollama 是一个在本地运行大型语言模型的工具。它通过一个简单的命令行接口,让开发者可以拉取(Pull)、运行(Run)和管理模型。

  • 核心命令:

    • ollama pull qwen2.5:0.5b:拉取特定版本的千问模型。
    • ollama run qwen2.5:0.5b:启动模型。
    • 端口服务: Ollama 默认在 11434 端口提供服务,且提供了兼容 OpenAI 格式的 /v1/chat/completions 接口。

1.2 项目初始化

我们需要一个 React 项目来作为前端界面。使用 Vite 或 Create React App 初始化项目,并安装必要的依赖(如 axios 用于 HTTP 请求)。


第二部分:后端通信层(API 模块)

这是应用的“神经系统”,负责前端与本地大模型的对话。

2.1 代码逻辑解析

在提供的代码中,ollamaApi.js 文件负责创建与 Ollama 服务的连接。

import axios from 'axios';

// 创建 axios 实例
const ollamaApi = axios.create({
  baseURL: 'http://localhost:11434/v1', // 指向本地 Ollama 服务
  headers: {
    'Authorization': 'Bearer ollama', // 注意:Ollama 的固定 Token
    'Content-Type': 'application/json',
  }
});

// 封装聊天请求函数
export const chatCompletions = async (messages) => {
  try {
    const response = await ollamaApi.post('/chat/completions', {
      model: 'qwen2.5:0.5b', // 指定模型名称
      messages, // 对话历史
      stream: false, // 关闭流式输出(简化处理)
      temperature: 0.7, // 控制生成文本的随机性
    });
    return response.data.choices.message.content;
  } catch(err) {
    console.error('ollama 请求失败');
  }
}

2.2 技术点详解

  1. Axios 封装: 使用 axios.create 创建实例,统一管理 baseURLheaders,避免在每个请求中重复配置。

  2. 兼容性接口: Ollama 采用了 OpenAI 的 API 规范,这意味着如果你的后端换成 OpenAI,前端代码几乎不需要修改。

  3. 请求参数:

    • messages: 这是一个数组,包含 role (system/user/assistant) 和 content注意: 必须传入完整的对话历史,模型才能理解上下文。
    • temperature: 值越低越确定,越高越有创造性。

🧠 答疑解惑:易错点与排查

  • 问题:跨域错误 (CORS) 或 网络连接失败

    • 原因: 前端运行在 localhost:3000,而 Ollama 服务运行在 localhost:11434。虽然同源策略通常允许不同端口,但如果 Ollama 服务未启动,会报 ECONNREFUSED

    • 解决方案:

      1. 确保 Ollama 服务已启动(终端运行 ollama serve 或直接运行模型)。
      2. 检查防火墙设置。
      3. 在开发环境中,如果遇到严格的 CORS 限制,可以考虑使用 Vite 的代理配置(Proxy)。
  • 问题:401 Unauthorized 错误

    • 原因: 虽然 Ollama 本地部署通常不需要复杂的鉴权,但根据代码规范,它要求 Header 中必须包含 Authorization: Bearer ollama
    • 解决方案: 确保在请求头中正确设置了该字段。
  • 问题:模型未找到 (Model not found)

    • 原因: 代码中写死了 qwen2.5:0.5b,但本地未下载该模型。
    • 解决方案: 运行 ollama pull qwen2.5:0.5b,或者修改代码中的 model 字段为你本地已有的模型(如 llama3)。

💼 面试模拟:API 层设计

面试官: “在封装 API 时,为什么要使用 Axios 实例而不是直接使用 axios.post?”
候选人:

  1. 统一管理: 如果 baseURL 变更(例如从开发环境切换到生产环境),只需修改实例配置,无需修改每个请求。
  2. 拦截器: 实例可以添加请求拦截器(自动加 Token)和响应拦截器(统一错误处理)。
  3. 复用性: 可以创建多个实例对应不同的后端服务。

第三部分:前端状态管理(Hooks 模块)

3.1 代码逻辑解析

useLLM.js 文件是一个自定义 Hook,用于管理聊天应用的状态。

import { useState } from 'react';
import { chatCompletions } from '../api/ollamaApi.js';

export const useLLM = () => {
  const [messages, setMessages] = useState([
    { role: 'user', content: '你好' },
    { role: 'assistant', content: '你好,我是qwen2.5 0.5b 模型' }
  ]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  // 发送消息逻辑
  const sendMessage = async (userMessage) => {
    // 1. 更新UI:添加用户消息
    const newMessages = [...messages, { role: 'user', content: userMessage }];
    setMessages(newMessages);
    setLoading(true);
    setError(null);

    try {
      // 2. 调用API
      const botResponse = await chatCompletions(newMessages);
      // 3. 更新UI:添加机器人回复
      setMessages(prev => [...prev, { role: 'assistant', content: botResponse }]);
    } catch (err) {
      setError('请求失败,请重试');
    } finally {
      setLoading(false);
    }
  };

  const resetChat = () => {
    setMessages([]);
  };

  return { messages, loading, error, sendMessage, resetChat };
};

3.2 技术点详解

  1. 状态设计: 使用 messages 数组存储对话历史,loading 控制按钮状态,error 处理异常。
  2. 闭包与异步:sendMessage 中,我们使用了函数式更新 setMessages(prev => [...prev, ...]) 来确保获取到最新的状态,避免闭包陷阱。
  3. 错误边界: 使用 try-catch 捕获 API 异常,并通过 setError 反馈给 UI。

🧠 答疑解惑:易错点与排查

  • 问题:机器人回复总是“上一轮”的内容

    • 原因: 这是一个经典的 State 闭包问题。如果你在调用 API 前没有正确更新 messages,或者在调用 API 时传入的是旧的 messages 快照。

    • 解决方案:

      • 方案 A: 如上面的代码所示,先构造新的消息数组 newMessages,传给 API,成功后再更新 State。
      • 方案 B: 使用 useRef 保存最新的消息列表,或者在 setMessages 的回调中处理后续逻辑(虽然 React 18 严格模式下可能执行两次渲染,但逻辑上应保证幂等性)。
  • 问题:输入框无法输入或按钮一直禁用

    • 原因: 逻辑错误导致 loading 状态未重置。
    • 解决方案: 确保 try-catch-finally 结构完整。无论成功或失败,finally 块中必须将 loading 设为 false

💼 面试模拟:React Hooks

面试官: “在 sendMessage 函数中,为什么在 setMessages 之后立即调用 API,传入的 messages 可能不是最新的?如何解决?”
候选人:
React 的 setState 是异步的。在 setMessages 调用后,messages 变量的值在当前函数作用域内并没有立即改变。如果直接传 messages 给 API,会丢失刚刚添加的用户消息。
解决方法:

  1. 预计算: 像代码中那样,先用 const newMessages = [...messages, userMsg] 计算出新数组,传给 API,然后 setMessages(newMessages)
  2. 函数式更新: 如果逻辑复杂,可以使用 useRef 来维护一个可变的引用。

第四部分:视图层(UI 组件)

4.1 代码逻辑解析

App.jsx 是应用的主组件,负责展示和用户交互。

import { useEffect, useState } from 'react';
import { useLLM } from './hooks/useLLM.js';

export default function App() {
  const [inputValue, setInputValue] = useState('');
  const { messages, loading, sendMessage } = useLLM(); // 使用自定义 Hook

  const handleSend = (e) => {
    e.preventDefault();
    if (!inputValue.trim()) return;
    sendMessage(inputValue); // 调用 Hook 中的逻辑
    setInputValue(''); // 清空输入框
  };

  // 页面挂载时的初始化逻辑(可选)
  useEffect(() => {
    // 例如:加载历史记录
  }, []);

  return (
    <div className="min-h-screen bg-gray-50 flex flex-col items-center py-6 px-4">
      <div className="w-full max-w-[800px] bg-white rounded-lg shadow-md flex flex-col h-[90vh] max-h-[800px]">
        {/* 聊天内容区域 */}
        <div className="flex-1 p-4 overflow-y-auto">
          {messages.map((msg, idx) => (
            <div key={idx} className={`mb-4 p-3 rounded-lg max-w-xs ${msg.role === 'user' ? 'bg-blue-100 ml-auto' : 'bg-gray-100'}`}>
              {msg.content}
            </div>
          ))}
        </div>
      </div>
      
      <form className="p-4 border-t" onSubmit={handleSend}>
        <div className="flex gap-2">
          <input
            type="text"
            value={inputValue}
            onChange={e => setInputValue(e.target.value)} 
            placeholder="输入消息....按回车发送"
            disabled={loading} 
            className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
          <button 
            type="submit"
            disabled={loading || !inputValue.trim()}
            className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
          >
            {loading ? '思考中...' : '发送'}
          </button>
        </div>
      </form>
    </div>
  );
}

4.2 技术点详解

  1. 受控组件: inputvalue 绑定到 inputValue,通过 onChange 更新状态,保证 UI 与 State 一致。
  2. 表单处理: 使用 onSubmit 处理表单提交,并调用 e.preventDefault() 阻止页面刷新。
  3. 条件渲染: 根据 loading 状态禁用按钮和改变按钮文本,防止重复提交。

🧠 答疑解惑:易错点与排查

  • 问题:按下回车键页面刷新了

    • 原因: <form> 标签的默认行为是 onSubmit 触发页面跳转。
    • 解决方案:handleSend 函数的第一行加上 e.preventDefault();
  • 问题:聊天记录滚动条没有自动到底部

    • 原因: DOM 更新后,容器的 scrollTop 没有自动调整。
    • 解决方案: 使用 useRef 获取聊天容器的 DOM 引用,在 useEffect 中监听 messages 变化,并设置 container.scrollTop = container.scrollHeight

💼 面试模拟:UI 与用户体验

面试官: “如何优化这个聊天界面的用户体验(UX)?”
候选人:

  1. 流式响应: 当前代码设置 stream: false,用户需要等待模型生成完所有文本才能看到结果。开启 stream: true 可以实现逐字输出的效果,体验更像真人打字。
  2. 加载状态: 除了按钮禁用,聊天区域可以增加一个“机器人正在思考...”的 Typing 动画。
  3. 错误重试: 当 API 调用失败时,UI 应该提供一个“重试”按钮,而不是仅仅显示错误文本。

第五部分:进阶与优化

5.1 流式传输 (Streaming)

目前的代码是等待模型生成完所有内容后一次性返回。为了实现类似 ChatGPT 的打字机效果,我们需要开启流式传输。

  • 原理: 设置 stream: true,后端会以 text/event-stream 格式分块传输数据。
  • 实现:chatCompletions 函数中,需要使用 fetch API 替代 Axios(因为 Axios 对流式处理支持较弱),并读取 ReadableStream

5.2 上下文管理

qwen2.5:0.5b 这种 0.5B 参数的模型内存有限。如果对话过长,模型会“忘记”开头的内容,或者出现显存溢出。

  • 解决方案: 实现一个简单的上下文截断逻辑,只保留最近的 N 轮对话传给模型。

5.3 模型切换

可以扩展 UI,让用户在界面上选择不同的本地模型(如从 qwen 切换到 llama3)。


博客结语

通过这篇博客,我们完成了一个从本地大模型部署到前端全栈应用的开发流程。这不仅是一个技术Demo,更是理解现代AI应用架构的基石。

核心收获:

  1. Ollama 是本地AI的基石,它让大模型触手可及。
  2. React Hooks 极大地简化了状态管理的复杂度。
  3. 前后端分离 的思想依然适用,即使是与本地服务通信。

希望这篇教程能帮助你在本地AI开发的道路上更进一步!

Cursor 最新发现:超大型项目 AI 也能做了,上百个 Agent 一起上

作者 张拭心
2026年1月17日 17:13

大家好,我是拭心。

2008 年 9 月 2 日,Google Chrome 浏览器正式发布。这个项目从 2005 年立项到发布,「历时 3 年,投入了数千名工程师」。如今,Chromium 代码规模已超过 3600 万行,被称为“人类史上最复杂的软件工程项目之一”。

图片

而就在最近,Cursor 团队做了一件让人震惊的事:「他们用上百个 AI Agent,花了不到一周时间,从零开始构建了一个浏览器,写出了超过 100 万行代码」

这不是概念验证,也不是玩具项目。

他们用 Agent 持续运行数周,在多个超大型项目上写出了数百万行代码:Java LSP(55 万行)、Windows 7 模拟器(120 万行)、Excel(160 万行)。更令人震撼的是,它们还直接在 Cursor 自己的生产代码库中完成了一次大规模框架迁移,代码增删量达到 +266K/-193K,用时 3 周多。

「从 3 年到 1 周,从数千名工程师到上百个 Agent,这不是量变,是质变」

AI 编程正在跨越一个关键门槛:从“辅助写代码”到“自主开发项目”。

这篇文章我们来了解下 Cursor 是如何做到的,以及对我们意味着什么。

一、多 Agent 协作:从失败到突破

单个 AI Agent 能写出几百行代码,但要开发一个百万行级别的项目,光靠一个 Agent 显然不够。

Cursor 团队的目标是让编码 Agent 持续运行数周,完全自主地完成超大型项目。

这意味着必须让上百个 Agent 同时工作。但问题来了:「怎么让它们高效协作,而不是互相干扰?」

1.1 扁平结构:一场灾难

Cursor 团队最初的想法很直觉:让所有 Agent 具有同等地位,通过一个共享文件自行协同。

每个 Agent 会检查其他 Agent 在做什么、认领一个任务并更新自己的状态。为防止两个 Agent 抢占同一项任务,他们使用了锁机制。

但这套方案在并发方面失败了:

「锁机制成了瓶颈」。 Agent 会持有锁太久,或者干脆忘记释放锁。即使锁机制正常工作,它也会成为瓶颈。二十个 Agent 的速度会下降到相当于两三个 Agent 的有效吞吐量,大部分时间都花在等待上。

「系统非常脆弱」。 Agent 可能在持有锁的情况下失败、尝试获取自己已经持有的锁,或者在完全没有获取锁的情况下更新协调文件。

后来他们尝试用乐观并发控制来替代锁:Agent 可以自由读取状态,但如果自上次读取后状态已经发生变化,则写入会失败。这种方式更简单、也更健壮,但更深层的问题依然存在。

在没有确定任务的情况下,Agent 变得非常规避风险。 它们会回避困难任务,转而做一些小而安全的修改。「没有任何一个 Agent 承担起解决难题或端到端实现的责任」(像极了曾经遇到的同事)。结果就是工作长时间在空转,却没有实质性进展。

这就像一个没有项目经理的团队,每个人都在做“看起来安全”的小任务,没人敢碰核心难题。

1.2 分层结构:像真实的团队一样工作

Cursor 团队后来尝试里将不同角色拆分开来。不再使用每个 Agent 都什么都做的扁平结构,而是搭建了一条职责清晰的流水线:

  • 「规划者」(Planners):持续探索代码库并创建任务。他们可以针对特定区域派生子规划者,使规划过程本身也可以并行且递归地展开。
  • 「执行者」(Workers):领取任务并专注于把任务完成到底。他们不会与其他执行者协调,也不关心整体大局,只是全力处理自己被分配的任务,完成后再提交变更。

在每个周期结束时,会有一个**「评审 Agent」**判断是否继续,然后下一轮迭代会从干净的初始状态重新开始。

这套结构基本解决了协同问题,并且让他们可以扩展到非常大的项目,而不会让任何单个 Agent 陷入视野过于狭窄的状态。成百上千个 Worker 并发运行,向同一个分支推送代码,而且几乎没有冲突。

这就像一个真实的开发团队:「有人负责架构设计和任务拆解,有人专注执行具体任务,各司其职,高效协作。」

1.3 三个震撼案例

有了这套系统后,Cursor 团队开始测试它的边界:

「从零开始构建浏览器」。 Agent 持续运行了将近一周,在 1,000 个文件中写出了超过 100 万行代码。虽然看起来只是一张简单的截图,但从零开始构建一个浏览器极其困难。尽管代码库规模庞大,新启动的 Agent 仍然可以理解它并取得实质性进展。

「Cursor 代码库的框架迁移」。 他们在 Cursor 代码库中就地将 Solid 迁移到 React,整个过程持续了 3 周多,代码增删量达到 +266K/-193K。随着测试的进行,他们确实认为有可能合并这次大规模改动。

「产品性能提升 25 倍」。 在一款即将上线的产品中,一个长时间运行的 Agent 通过一个高效的 Rust 实现,让视频渲染速度提升了 25 倍。它还新增了平滑缩放和平移的能力,使用自然的弹簧过渡和运动模糊效果,并能跟随光标顺畅移动。这部分代码已经合并,不久就会在生产环境中上线。

这些案例证明,多 Agent 开发大型项目不再是概念验证,而是真实的生产力。

二、为什么现在可以做到

上百个 Agent 协作开发超大型项目,这在一年前几乎是不可想象的。Cursor 团队的成功,背后有几个关键因素。

2.1 模型能力的质变

在运行时间极长的任务中,模型选择至关重要。Cursor 团队发现,不同模型在长时间自主工作时表现差异巨大。

「GPT-5.2 系列在长时间自主工作方面要优秀得多:更能遵循指令、保持专注、避免偏离,并且在实现上更加精确和完整」。相比之下,Opus 4.5 往往会更早结束、在方便的时候走捷径,更快地把控制权交还给用户。

这不是说 Opus 4.5 不好,而是不同模型有不同的“性格”。Opus 4.5 更适合需要人类频繁介入的场景,而 GPT-5.2 更适合长时间无人值守的自主开发。

更有意思的是,不同模型在不同角色上各有所长。即便 GPT-5.1-codex 是专门为编码训练的,GPT-5.2 依然是更好的规划者。现在 Cursor 团队会**「针对每个角色选择最适合的模型,而不是依赖单一通用模型」**。

规划者用 GPT-5.2,执行者用 GPT-5.1-codex,评审者可能又是另一个模型。这就像组建一个真实团队,你会根据每个岗位的特点选择最合适的人。

2.2 提示词比框架更重要

系统中有相当大一部分行为,很大程度上取决于如何为这些 Agent 设计提示词。要让它们良好协作、避免异常行为,并在长时间内保持专注,Cursor 团队做了大量实验。

运行框架和模型本身固然重要,但提示词更重要。

这个结论可能让很多人意外。我们往往以为技术架构和模型才是关键,但 Cursor 团队发现,同样的架构,不同的提示词设计,Agent 的表现会有天壤之别。

如何让规划者拆解任务时粒度合适?如何让执行者在遇到困难时不放弃?如何让评审者准确判断工作质量?这些都需要精心设计的提示词。

这也揭示了一个重要趋势:「Prompt Engineering(提示词工程)是成为 AI 时代的核心技能。」

推荐阅读我写的《提示词工程:你缺的不只是专业术语

2.3 减法思维:少即是多

Cursor 团队的许多改进来自“减法”而不是“加法”。

一开始他们为质量控制和冲突解决设计了一个集成者(Integrator)角色,专门负责协调各个 Worker 的代码、解决冲突、确保质量。听起来很合理,对吧?

但后来发现,「集成者制造的瓶颈多于解决的问题。各个 Worker 本身就已经有能力处理彼此之间的冲突。多出来的这个角色反而让流程变得复杂、脆弱,成了整个系统的瓶颈」

去掉集成者后,系统反而更流畅了。

这个经验很有启发性:最好的系统往往比你想的更简单。起初 Cursor 团队尝试借鉴分布式计算和组织设计中的系统模型,但并不是所有这些方法都适用于 Agent。

三、剧变来临的信号

Cursor 团队告诉我们,「上百个 Agent 可以在同一个代码库上协同工作数周,推动雄心勃勃的项目取得实质进展。这不是理论,而是已经发生的现实」

但他们也坦承:多智能体协同仍然是一个难题。当前的系统虽然可用,但离最优状态还差得很远。Planner 应该在任务完成时自动“醒来”规划下一步,Agent 有时会运行时间过长,他们仍然需要定期从头重启,以对抗漂移和思维视野过于狭窄的问题。

即便如此,对于核心问题——“能否通过投入更多 Agent 来扩展 AI 自主编码能力”——他们得到的答案依然比预期更乐观。

回顾过去几年 AI 编程工具的发展:

  • 2022 年:GitHub Copilot 补全代码片段
  • 2023 年:ChatGPT 生成完整函数
  • 2024 年:Cursor 理解项目上下文
  • 2025 年:Cursor/TRAE 支持 Agent 自主开发
  • 2026 年:Cursor 探索多 Agent 系统自主开发超大型项目

编程工具进展神速,AI 从“辅助写代码”变成了“自主开发项目”。

今天,Cursor 用上百个 Agent 写出了 100 万行代码。明年呢?后年呢?AI 编程的能力边界正在快速扩张。

对于开发者来说:如果你的核心能力是“写代码”,那么你需要警惕了。但如果你的核心能力是“理解需求、设计架构、协调资源、解决问题”,那么你反而会因为 AI 而变得更强大。

那些提前拥抱 AI、学会与 AI 协作的人,正在获得巨大的竞争优势。不要等到 AI 完全成熟了再去学习,因为到那时候,窗口期可能已经关闭了。

Cursor 用上百个 Agent 开发超大型项目,这不是终点,而是起点。剧变正在发生,你准备好了吗?

好了,这篇文章到这里就结束了。感谢你的阅读,愿你平安顺遂。

如果对你有帮助,欢迎评论点赞转发,你的支持是我最大的动力❤️

参考资料:

cursor.com/cn/blog/sca…

async/await : 一场生成器和 Promise的里应外合

作者 sophie旭
2026年1月17日 16:43

背景

说实话,async/await 我在日常工作中好像并没有很主动的去用,可能对于异步编程,我宁愿去用 Promise这种显式链式方式告诉自己,这是异步流程。同步式的代码让我有点心有余悸,为啥会这样放着更过“高级”的API不用呢?我想可能有一方面的原因是,我对它的了解不深吧,或者只停留在了解吧,这两天看了下 async/await 的原理,发现它并没有那么神秘,并且是站在两位巨人的肩膀上的。

巨人一号:生成器 Generator

Generator基本使用

Generator相信大家都不陌生啦,我们直接上代码看运行结果吧

// 定义一个生成器函数(普通函数 + *)
function* numberGenerator() {
  console.log("生成器函数开始执行");
  
  // 第一次yield,向外返回值
  const firstValue = yield 1; // yield左边可以接收next传入的参数
  console.log("第一个yield接收到的参数:", firstValue);
  
  // 第二次yield
  const secondValue = yield 2;
  console.log("第二个yield接收到的参数:", secondValue);
  
  // 第三次yield
  yield 3;
  console.log("生成器函数即将执行完毕");
  
  // 函数结束,done会变为true
  return "执行结束";
}

// 1. 调用生成器函数,不会执行函数体,只会得到生成器对象
const generator = numberGenerator();
console.log("调用生成器函数后得到的对象:", generator); // 输出 Generator 对象,无函数体执行日志

// 2. 第一次调用next(),函数体开始执行,直到第一个yield暂停
console.log("第一次next返回:", generator.next()); 
// 输出:
// 生成器函数开始执行
// 第一次next返回: { value: 1, done: false }

// 3. 第二次调用next()并传入参数,参数会作为上一个yield的返回值
console.log("第二次next返回:", generator.next("我是第一个yield的返回值"));
// 输出:
// 第一个yield接收到的参数: 我是第一个yield的返回值
// 第二次next返回: { value: 2, done: false }

// 4. 第三次调用next()并传入参数
console.log("第三次next返回:", generator.next("我是第二个yield的返回值"));
// 输出:
// 第二个yield接收到的参数: 我是第二个yield的返回值
// 生成器函数即将执行完毕
// 第三次next返回: { value: 3, done: false }

// 5. 第四次调用next(),函数执行到return,done变为true
console.log("第四次next返回:", generator.next());
// 输出:
// 第四次next返回: { value: '执行结束', done: true }

// 6. 后续调用next(),value为undefined,done保持true
console.log("第五次next返回:", generator.next());
// 输出:
// 第五次next返回: { value: undefined, done: true }

关键特性解释

  1. 生成器函数定义function* 函数名() 是生成器函数的语法(* 的位置可以在 function 后、函数名前,空格不影响),这是和普通函数最核心的区别。
  2. 调用不执行:调用 numberGenerator() 不会执行函数体,只会返回一个 Generator 对象,这和普通函数调用立即执行完全不同。
  3. yield 暂停与返回
    • yield 是生成器的核心关键词,执行到 yield 时,函数会暂停执行(而非结束),并将 yield 后的值作为 next() 返回对象的 value
    • yield 不像 return 那样终止函数,下次调用 next() 会从暂停的位置继续执行。
  4. next 传参generator.next(参数) 传入的参数,会作为上一个 yield 语句的返回值(注意:第一个 next() 传参无效,因为此时还没有执行过任何 yield)。
  5. done 属性next() 返回对象的 done 属性表示生成器是否执行完毕:
    • false:生成器未执行完,还能继续调用 next() 获取值;
    • true:生成器执行完毕(执行到 return 或函数末尾),后续 next()valueundefined(这一点我经常忽略)。

总结

  1. 生成器函数通过 function* 定义,调用后返回生成器对象,而非立即执行函数体。
  2. yield 负责暂停函数并向外返回值,next() 负责恢复执行,且 next() 可传参作为上一个 yield 的返回值。
  3. next() 返回的对象包含 value(返回值)和 done(执行状态),done: true 表示生成器执行完毕。

Generator throw

// 定义一个包含异常处理的生成器函数
function* fooGenerator() {
  console.log("fooGenerator 开始执行(第一次next触发)");
  
  try {
    // 第一个yield:暂停并返回值,后续可能接收next传参
    const receivedValue = yield "第一个yield返回值";
    console.log("第一个yield接收到next的参数:", receivedValue);

    // 执行到这里时,如果外部调用throw(),异常会抛到当前执行位置
    console.log("准备执行第二个yield...");
    yield "第二个yield返回值";

    console.log("生成器未被中断,继续执行");
  } catch (error) {
    // 捕获外部throw()抛出的异常
    console.log("生成器内部捕获到异常:", error.message);
    // 捕获异常后,生成器可继续yield返回值
    yield "异常捕获后返回的兜底值";
  }

  console.log("生成器函数执行到末尾");
  return "执行结束";
}

// 1. 调用生成器函数,仅得到生成器对象,函数体不执行
const generator = fooGenerator();
console.log("调用fooGenerator后得到的对象:", generator); // Generator 对象

// 2. 第一次调用next():函数体开始执行,直到第一个yield暂停
console.log("===== 第一次调用 next() =====");
const firstNext = generator.next();
console.log("第一次next返回:", firstNext);
// 输出:
// fooGenerator 开始执行(第一次next触发)
// 第一次next返回: { value: '第一个yield返回值', done: false }

// 3. 第二次调用next(参数):从第一个yield暂停处继续执行,参数作为yield返回值
console.log("\n===== 第二次调用 next('bar') =====");
const secondNext = generator.next("bar");
console.log("第二次next返回:", secondNext);
// 输出:
// 第一个yield接收到next的参数: bar
// 准备执行第二个yield...
// 第二次next返回: { value: '第二个yield返回值', done: false }

// 4. 调用throw():从第二个yield暂停处继续执行,但抛出异常
console.log("\n===== 调用 throw() 方法 =====");
const throwResult = generator.throw(new Error("外部手动抛出的异常"));
console.log("throw()返回:", throwResult);
// 输出:
// 生成器内部捕获到异常: 外部手动抛出的异常
// throw()返回: { value: '异常捕获后返回的兜底值', done: false }

// 5. 异常捕获后,生成器仍可继续执行next()
console.log("\n===== 异常后调用 next() =====");
const thirdNext = generator.next();
console.log("第三次next返回:", thirdNext);
// 输出:
// 生成器函数执行到末尾
// 第三次next返回: { value: '执行结束', done: true }

throw() 方法核心逻辑解释

  1. throw() 的执行触发
    • next() 一样,调用 generator.throw() 会让生成器从上一次暂停的位置继续执行;
    • 但不同于 next() 传入普通参数,throw() 会在生成器当前执行位置主动抛出一个异常 -- 意思就是 从当前位置直接去走catch逻辑,try逻辑不会再走了
  2. 异常的捕获与处理
    • 如果生成器内部有 try/catch 块包裹了暂停位置后的执行逻辑,异常会被内部捕获,生成器不会立即终止;
    • 如果内部没有捕获,异常会抛出到外部,生成器状态变为 done: true,后续调用 next() 只会返回 { value: undefined, done: true }
  3. throw() 的返回值
    • 即使抛出了异常,只要内部捕获了,throw() 也会返回和 next() 一样的对象({ value, done });
    • 这个 value 是异常捕获后,生成器继续执行到下一个 yield 时返回的值(示例中就是 异常捕获后返回的兜底值)。

总结

  1. throw() 方法和 next() 一样能触发生成器继续执行,但核心作用是向生成器内部抛出异常
  2. 生成器内部可通过 try/catch 捕获 throw() 抛出的异常,捕获后生成器不会终止,仍可继续执行并返回新的 yield 值;
  3. 生成器的执行始终是“暂停-恢复”模式:next() 恢复执行并传参,throw() 恢复执行并抛异常,本质都是改变生成器的执行状态。

这个特性在异步编程中很实用,比如可以用 throw() 主动终止异步任务、处理异步流程中的错误等。

巨人二号:Promise

Promise大家应该再熟悉不过了,由于篇幅原因,这篇先不做深入了解

下面让我们看看他们结合在一起会发生什么吧

“回调式” 写法 =》 “同步式” 写法

// 传统 Promise 回调写法:有明显的回调嵌套感,代码“右移”
fetch('https://jsonplaceholder.typicode.com/todos/1')
  .then(response => response.json())
  .then(data => {
    console.log('请求结果:', data);
    // 如果还要发第二个请求,就要在这继续嵌套 then,越套越深
    fetch(`https://jsonplaceholder.typicode.com/todos/${data.id + 1}`)
      .then(res => res.json())
      .then(data2 => console.log('第二个请求结果:', data2));
  })
  .catch(error => console.log('错误:', error));

以上代码,我们看到 虽然用到了Promise,但是也难免会出现嵌套,下面我们看看下面这段代码,

// 模拟 Ajax 请求:返回 Promise(真实项目里是 fetch/axios 等)
function requestData(url) {
  return fetch(url)
    .then(response => response.json()) // 解析 JSON 数据
    .catch(error => console.error('请求失败:', error));
}

// 定义生成器函数 main:内部用 yield 暂停,写“同步风格”的异步逻辑
function* main() {
  console.log('开始执行生成器,准备发请求');
  
  // 1. yield 抛出 Promise(Ajax 请求),生成器暂停执行
  // 这里的 result 会接收后续 next(data) 传进来的请求结果
  const result = yield requestData('https://jsonplaceholder.typicode.com/todos/1');
  
  // 3. 当外部调用 next(data) 后,生成器从暂停处恢复,执行这行
  console.log('生成器内部拿到的请求结果:', result);
  
  // 可以继续发第二个请求,依然是“同步写法”
  const result2 = yield requestData(`https://jsonplaceholder.typicode.com/todos/${result.id + 1}`);
  console.log('第二个请求结果:', result2);
}

// 外界执行生成器的逻辑
const generator = main(); // 1. 调用生成器,得到生成器对象(函数体不执行)

// 2. 第一次调用 next():生成器开始执行,直到第一个 yield 暂停
const firstNextResult = generator.next(); 
// firstNextResult.value 就是 yield 抛出的 Promise(requestData 的返回值)
const promise = firstNextResult.value;

// 3. 等 Promise 执行完成(请求返回),把结果传给生成器
promise.then(data => {
  // 调用 next(data):把请求结果传进去,生成器从 yield 处恢复执行
  generator.next(data);
  
  // 第二个请求的处理(如果有):这里可以封装成自动执行逻辑,不用手动写
  const secondNextResult = generator.next();
  secondNextResult.value.then(data2 => {
    generator.next(data2);
  });
});

逐行解释(对应你的描述)

  1. 定义生成器函数 main

    • 内部 yield requestData(...)requestData 返回 Promise(Ajax 请求),yield 会把这个 Promise 抛出去,同时暂停生成器的执行(不会立刻执行后面的 console.log)。
    • const result = yield ...:这里的 result 暂时没有值,要等后续把请求结果传进来才会赋值。
  2. 外界调用生成器

    • const generator = main():调用生成器函数,只得到生成器对象,main 的函数体完全不执行
    • generator.next():第一次调用 nextmain 开始执行,直到遇到 yield 暂停;next() 返回的对象 { value: Promise, done: false } 中,value 就是 yield 抛出来的 Promise(Ajax 请求)。
  3. 处理 Promise 结果,恢复生成器

    • 给 Promise 加 then 回调,等请求返回拿到 data 后,调用 generator.next(data)
      • data 会作为上一个 yield 语句的返回值,赋值给 main 里的 result
      • 生成器从暂停的 yield 位置继续执行,执行后面的 console.log(result) —— 这一步就像“同步代码”一样,直接拿到了异步请求的结果。

为什么说“消灭了回调,近乎同步体验”

对比两种写法:

  • 传统 Promise:请求结果的处理逻辑必须写在 then 回调里(异步风格);
  • 生成器写法:main 内部没有任何 then 回调,const result = yield ... 看起来就像“同步赋值”,拿到结果后直接 console.log —— 代码结构和同步代码完全一致,只是多了 yield 关键字。

总结

  1. 核心目的:用生成器的 yield 暂停特性,把异步 Promise 的“回调式”写法,改成生成器内部的“同步式”写法,消灭回调嵌套;
  2. 核心流程:生成器 yield 抛出 Promise → 等 Promise 执行完成 → 调用 next(结果) 把值传回生成器 → 生成器恢复执行,拿到结果;

等一下,怎么看着更复杂了??

不知道你发现没有发出疑问,经过转换,怎么比我写个嵌套更复杂了?别着急,我们一步步简化!

先看问题:手动处理多个 yield 的弊端

如果 main 里有 3 个 yield(3 次 Ajax 请求),手动处理会写成这样,重复代码特别多:

// 手动处理多个 yield 的糟糕写法(仅举例,不要这么写)
const gen = main();
// 处理第一个请求
let res1 = gen.next();
res1.value.then(data1 => {
  // 处理第二个请求
  let res2 = gen.next(data1);
  res2.value.then(data2 => {
    // 处理第三个请求
    let res3 = gen.next(data2);
    res3.value.then(data3 => {
      gen.next(data3); // 最后一次 next
    });
  });
});

这种写法和回调嵌套没区别,完全违背了用生成器简化异步的初衷 —— 而递归自动执行器就是解决这个问题的关键。

核心思路:递归自动执行器

我们写一个通用的递归函数(比如叫 runGenerator),它会:

  1. 调用生成器的 next(),拿到返回结果(包含 value(Promise)和 done);
  2. 判断 done:如果是 true,递归终止;
  3. 如果是 false,给 value(Promise)加 then 回调;
  4. then 里拿到异步结果,再递归调用自身,把结果传给 next(),继续处理下一个 yield

完整代码示例(含递归执行器)

// 1. 模拟 Ajax 请求:返回 Promise
function requestData(url) {
  return fetch(url)
    .then(res => res.json())
    .catch(err => console.error('请求失败:', err));
}

// 2. 定义有多个 yield 的生成器函数
function* main() {
  console.log('开始第一个请求');
  // 第一个 yield:请求 todo/1
  const result1 = yield requestData('https://jsonplaceholder.typicode.com/todos/1');
  console.log('第一个请求结果:', result1);

  console.log('开始第二个请求');
  // 第二个 yield:基于第一个结果请求 todo/2
  const result2 = yield requestData(`https://jsonplaceholder.typicode.com/todos/${result1.id + 1}`);
  console.log('第二个请求结果:', result2);

  console.log('开始第三个请求');
  // 第三个 yield:基于第二个结果请求 todo/3
  const result3 = yield requestData(`https://jsonplaceholder.typicode.com/todos/${result2.id + 1}`);
  console.log('第三个请求结果:', result3);

  console.log('所有请求执行完毕');
  return '最终结果';
}

// 3. 核心:递归自动执行器(通用函数,任何生成器都能用)
function runGenerator(generator) {
  // 定义递归函数
  function next(data) {
    // 调用 next(),拿到当前执行结果({ value: Promise, done: boolean })
    const result = generator.next(data);

    // 终止条件:如果 done 为 true,递归结束
    if (result.done) {
      console.log('生成器执行完毕,最终返回值:', result.value);
      return; // 终止递归
    }

    // 如果 done 为 false,处理当前的 Promise
    result.value
      .then(data => {
        // 递归调用 next,把当前 Promise 的结果传给下一个 yield
        next(data);
      })
      .catch(err => {
        // 处理异常:也可以调用 generator.throw(err) 抛到生成器内部
        console.error('异步执行出错:', err);
      });
  }

  // 启动第一次递归(第一次 next 不传参,因为第一个 yield 左边没值可接)
  next();
}

// 4. 启动执行器,自动处理所有 yield
runGenerator(main());

逐行解释递归执行器的逻辑

  1. 执行器初始化:调用 runGenerator(main()),先创建生成器对象,再执行 next()(无参),启动第一次递归。
  2. 第一次递归
    • generator.next() → 执行 main 到第一个 yield,返回 { value: 第一个请求的 Promise, done: false }
    • 因为 done: false,给 Promise 加 then
    • 等第一个请求返回 data1,递归调用 next(data1)
  3. 第二次递归
    • generator.next(data1)data1 赋值给 result1main 执行到第二个 yield,返回 { value: 第二个请求的 Promise, done: false }
    • 给第二个 Promise 加 then,拿到 data2 后递归调用 next(data2)
  4. 第三次递归
    • generator.next(data2)data2 赋值给 result2main 执行到第三个 yield,返回 { value: 第三个请求的 Promise, done: false }
    • 拿到 data3 后递归调用 next(data3)
  5. 第四次递归
    • generator.next(data3)data3 赋值给 result3main 执行到末尾,返回 { value: '最终结果', done: true }
    • 检测到 done: true,递归终止。

为什么递归能解决问题?

  • 自动迭代:不管 main 里有多少个 yield,递归都会自动处理,不用手动写每一次的 next()then()
  • 终止条件清晰:靠 done: true 判断生成器是否执行完,避免无限递归;
  • 代码复用runGenerator 是通用函数,任何“yield 出 Promise”的生成器都能直接用,不用改逻辑。

生成器 + Promise 实现异步同步化 完整方案

再梳理这个“完整执行器”的核心逻辑

把我们之前的“简化版递归执行器”升级成工业级的 co 函数,完整逻辑如下(伪代码梳理):

// 1. 定义通用执行器 co
function co(generatorFunc) {
  // 创建生成器对象
  const generator = generatorFunc();

  // 2. 定义递归处理函数 handlerResult
  function handlerResult(result) {
    // 终止条件:生成器执行完毕
    if (result.done) {
      return Promise.resolve(result.value); // 最终返回 Promise,对齐 async 函数
    }

    // 处理 Promise(成功/失败)
    return Promise.resolve(result.value) // 确保 value 是 Promise(兼容非 Promise 情况)
      .then(
        // 成功回调:把结果传给 next,递归处理下一个 result
        (data) => handlerResult(generator.next(data)),
        // 失败回调:调用 throw() 抛异常给生成器内部
        (error) => handlerResult(generator.throw(error))
      );
  }

  // 3. 启动递归:传入第一次 next() 的结果
  return handlerResult(generator.next());
}

// 4. 生成器内部用 try/catch 捕获异常
function* main() {
  try {
    const res1 = yield requestData('url1');
    const res2 = yield requestData('url2');
  } catch (err) {
    console.log('捕获异常:', err); // 捕获 generator.throw() 抛的异常
  }
}

// 5. 调用 co 执行生成器(通用复用)
co(main).then(finalRes => console.log('最终结果:', finalRes));

总结

这些内容,是我们之前讨论的生成器+Promise 实现异步同步化的“完整版落地”:

  1. 核心逻辑完全一致:递归执行器 + 暂停-恢复 + Promise 处理;
  2. 补充了关键细节:异常处理(throw() + try/catch);

好的,该 async/await登场了

讲了这么多啊都没提到我们的主角,好的他们来了~

用 async/await 改写你这段手动代码

你手动处理 2 个请求的代码,换成 async/await 后长这样,没有任何手动的 next/then,但底层逻辑完全一致:

// 原生成器逻辑 → 等价的 async/await 代码
async function mainAsync() {
  // 对应:第一次 next() + promise.then + generator.next(data)
  const result = await requestData('https://jsonplaceholder.typicode.com/todos/1');
  console.log('生成器内部拿到的请求结果:', result);

  // 对应:secondNextResult = generator.next() + secondNextResult.value.then + generator.next(data2)
  const result2 = await requestData(`https://jsonplaceholder.typicode.com/todos/${result.id + 1}`);
  console.log('第二个请求结果:', result2);
}

// 调用 async 函数(引擎自动执行所有 next/then 逻辑)
mainAsync();

关键补充:async/await 不是“新东西”,是语法糖

async/await 并不是 JavaScript 新增的底层特性,它就是生成器 + 自动执行器的“语法糖”—— 本质上:

  1. async 函数 = 生成器函数 + 内置自动执行器;
  2. await 关键字 = yield 关键字的“语义化包装”(更易读,不用写 yield);
  3. 你手动写的递归执行器 → JS 引擎内置的、更高效的自动执行逻辑。

举个更直观的例子:引擎如何处理 await

当你写:

async function fn() {
  const a = await Promise.resolve(1);
  const b = await Promise.resolve(a + 1);
  return b;
}
fn().then(res => console.log(res)); // 输出 2

JS 引擎在底层会做这些事(对应你的手动代码):

// 引擎模拟的底层逻辑(伪代码)
function* fnGenerator() {
  const a = yield Promise.resolve(1);
  const b = yield Promise.resolve(a + 1);
  return b;
}

// 引擎内置的自动执行器(类似你写的递归函数)
function autoRun(generator) {
  const gen = generator();
  function next(data) {
    const { value, done } = gen.next(data);
    if (done) return Promise.resolve(value);
    return value.then(res => next(res));
  }
  return next();
}

// 引擎自动调用
autoRun(fnGenerator).then(res => console.log(res)); // 输出 2

总结

  1. 你手动写的 next()then()、传参、处理下一个 yield 这些操作,全部由 async/await 自动完成,不用你写一行;
  2. async/await 是生成器+自动执行器的语法糖,核心逻辑和你手动封装的递归执行器完全一致;
  3. 区别仅在于:引擎的内置执行器更高效、更健壮(处理了异常、中断等边界情况),而你手动写的是简化版。

简单说:async/await 就是把你手动做的“脏活累活”(调 next、等 Promise、传结果)全部自动化了,让你只需要写“看起来像同步”的代码就行。

async/await用了哪些设计模式呢?

async/await 并不是单一的设计模式,而是组合了多个经典模式的“语法糖封装”,核心是这3个:

设计模式 对应 async/await 的角色
迭代器模式(Iterator) 生成器(Generator)本身就是迭代器的一种实现,next() 方法就是迭代器的核心(一步步“迭代”执行异步任务);
观察者模式(Observer) Promise 是典型的观察者模式(then/catch 监听 Promise 状态变化),await 本质是“监听 Promise 完成后通知生成器继续执行”;
模板方法模式(Template Method) JS 引擎内置的“自动执行器”就是模板方法:把“调用 next → 等 Promise → 传参 → 递归”的固定逻辑封装成模板,async/await 使用者只需要写业务逻辑(await 后面的异步任务),不用关心执行流程;

关于设计模式,我应该会拉出一个主题好好研究

现在你有没有理解我说的“里应外合“

先明确“里”和“外”的角色

角色 对应代码/逻辑 核心动作
里(生成器内部) async 函数里的 await 异步任务 1. 抛出 Promise 给“外”;2. 暂停等待;3. 接收“外”传回来的值继续执行
外(JS引擎/执行器) 引擎内置的自动执行器 1. 接住“里”抛出来的 Promise;2. 等待 Promise 执行完成;3. 把结果传回“里”,触发继续执行

用“里应外合”拆解完整执行流程(一步一步对应)

我们用 await requestData() 为例,还原这个互动过程:

第一步:“里”先出招 —— 抛出 Promise,原地待命
async function fn() {
  // 「里」的动作:
  // 1. 执行 await requestData(),先调用 requestData 得到 Promise;
  // 2. 把这个 Promise “扔”给外面的引擎;
  // 3. 自己暂停执行(就像士兵原地待命,等外面的消息);
  const res = await requestData('url'); 
  console.log(res); // 暂停后,这行暂时不执行
}

👉 对应“里应外合”:里先“应”—— 抛出异步任务(Promise),告诉外面“我要等这个做完”

第二步:“外”接招 —— 处理 Promise,等结果

JS 引擎(外面的执行器)接住这个 Promise 后,不会闲着:

  1. 监听 Promise 的状态变化(成功/失败);
  2. 等待异步任务完成(比如接口请求返回数据);
  3. 拿到结果(比如接口返回的 data)。

👉 对应“里应外合”:外“合”—— 响应里面的请求,处理异步任务,拿到结果

第三步:“外”回传结果 —— 给“里”赋值,触发继续执行

引擎拿到 data 后,会做两件关键事:

  1. 把 data 赋值给 await 左边的变量 res(相当于 res = data);
  2. 告诉“里”:“结果拿到了,你继续往下走!” —— 生成器从暂停的位置恢复执行。
async function fn() {
  const res = await requestData('url'); 
  // 「里」的动作:
  // 接收到外面传的 data,赋值给 res,然后执行这行
  console.log(res); 
}

👉 对应“里应外合”:外把结果“合”进里面,里收到后继续执行,完成一次互动

第四步:多轮“里应外合”(多个 await)

如果有多个 await,就是重复上面的过程:

async function fn() {
  // 第一轮里应外合:
  const res1 = await requestData('url1'); 
  // 第二轮里应外合:
  const res2 = await requestData(`url2?${res1.id}`); 
}

👉 里抛第一个 Promise → 外处理 → 回传 res1 → 里再抛第二个 Promise → 外处理 → 回传 res2 → 直到执行完。

补充:异常场景的“里应外合”(更完整)

如果 Promise 失败(比如接口报错),互动逻辑变成:

  1. 里:抛出失败的 Promise;
  2. 外:接住失败的 Promise,拿到错误信息;
  3. 外:把错误“扔回”里(对应 generator.throw());
  4. 里:如果有 try/catch,就捕获这个错误,完成“合”。
async function fn() {
  try {
    const res = await requestData('error-url'); // 里抛出失败的 Promise
  } catch (err) {
    console.log(err); // 外把错误传回来,里捕获处理
  }
}

记忆强化:用“里应外合”总结核心

  1. 里的核心:抛 Promise 等结果,接结果继续走(“应”—— 响应异步需求,等外部反馈);
  2. 外的核心:接 Promise 处理完,传结果促执行(“合”—— 配合内部需求,反馈处理结果);
  3. async/await 就是把这个“里应外合”的过程,从“手动写执行器”变成“引擎自动做”,你只需要写“里”的逻辑就行。

这个比喻完全抓住了本质 —— 生成器(里)和执行器(外)的互动,就是靠“抛 Promise-处理 Promise-传结果”完成的“里应外合”,记住这个比喻,就能记住 async/await 的底层执行逻辑了。

这下明白了 async/await 很多用法的底层逻辑

一、为什么必须有 async?—— 标记“异步执行器容器”

核心原因:

async 是给函数打一个“标记”,告诉 JS 引擎:这个函数内部有 await,需要按“生成器+自动执行器”的逻辑来处理,而不是普通函数的“一次性执行完”。

对应底层逻辑(里应外合):
  • 没有 async 的普通函数:调用后会“一口气执行完所有代码”,无法暂停;
  • 加了 async 的函数:JS 引擎会把它当成「生成器+自动执行器」的封装体——调用 async 函数时,引擎先创建“生成器式”的执行上下文,为后续 await 的“暂停-恢复”铺路。
记忆例子:
// 错误:没有 async,用 await 会直接报错
function fn() {
  const res = await requestData(); // Uncaught SyntaxError: await is only valid in async functions
}

// 正确:async 标记函数是“异步执行容器”
async function fn() {
  const res = await requestData(); 
}

二、为什么要用 try/catch?—— 统一捕获“里应外合”的异常

核心原因:

await 后面的 Promise 一旦失败(rejected),底层会触发 generator.throw() 向函数内部抛异常——如果不捕获,这个异常会变成“未捕获异常”导致程序崩溃;而 try/catch 是同步代码的错误处理方式,async/await 把异步错误“伪装”成同步错误,自然用 try/catch 捕获最贴合直觉。

对应底层逻辑(里应外合):
  • 外(引擎):发现 Promise 失败 → 调用 generator.throw(错误) 把异常抛回“里”;
  • 里(async 函数):如果没有 try/catch,异常会从“里”逃出,变成全局未捕获异常;有 try/catch 则能在内部接住,和同步代码的错误处理完全一致。
记忆例子:
async function fn() {
  try {
    // await 后面的 Promise 失败 → 引擎抛异常到这里
    const res = await Promise.reject(new Error('请求失败'));
  } catch (err) {
    console.log('捕获错误:', err.message); // 输出:请求失败
  }
}

三、为什么说“await 后面的代码相当于 promise.then()”?—— 都是“等待完成后执行”

核心原因:

await 会暂停函数执行,等后面的 Promise 完成后,才执行 await 下面的代码——这和 promise.then(回调) 里“回调函数等 Promise 完成后执行”的逻辑完全一致,只是 await 把回调里的代码“扁平化”了。

对应底层逻辑(里应外合):
  • Promise.then 写法:回调函数是“外”通知“里”执行的逻辑(观察者模式);
  • await 写法:await 下面的代码,就是引擎自动帮你放到 then 回调里的逻辑,只是不用写回调嵌套。
记忆对比:
// Promise.then 写法(回调嵌套)
requestData().then(res => {
  console.log(res); // 这行在 then 回调里,等 Promise 完成后执行
});

// async/await 写法(扁平化)
async function fn() {
  const res = await requestData();
  console.log(res); // 这行等价于上面 then 里的代码,等 Promise 完成后执行
}

四、为什么说“async 函数返回的是 Promise”?—— 对齐异步生态,兼容执行器逻辑

核心原因:
  1. 底层逻辑:async 函数的自动执行器最终要返回一个“结果容器”,而 Promise 是 JS 里唯一的“异步结果容器”——不管函数内部有没有 await,引擎都会把返回值包装成 Promise;
  2. 生态兼容:Promise 是 JS 异步的“通用语言”,返回 Promise 能让 async 函数和现有异步逻辑(.then/.catch、Promise.all 等)无缝衔接。
记忆例子:
// 1. 显式返回普通值 → 自动包装成 Promise
async function fn1() {
  return 123; // 等价于 return Promise.resolve(123)
}
fn1().then(res => console.log(res)); // 输出 123

// 2. 没有 return → 返回 Promise.resolve(undefined)
async function fn2() {
  await requestData();
}
fn2().then(res => console.log(res)); // 输出 undefined

// 3. 抛出异常 → 返回 Promise.reject(错误)
async function fn3() {
  throw new Error('出错了');
}
fn3().catch(err => console.log(err.message)); // 输出 出错了

五、补充:我经常混淆的点——“await 不是返回 Promise,是等待 Promise 并取其结果”

  • ❌ 错误:await 返回 Promise;
  • ✅ 正确:await 等待 后面的 Promise 完成,然后取出 Promise 的最终结果(成功值/失败抛异常)。
例子验证:
async function fn() {
  // requestData() 返回 Promise,await 等待它完成,取出结果赋值给 res
  const res = await requestData(); 
  console.log(res); // res 是 Promise 的成功值(比如接口返回的 data),不是 Promise
}

对应到底层执行器的逻辑:

// 对应的生成器函数
function* genFn() {
  const res = yield Promise.resolve(1); // await 的底层:yield 抛 Promise,等结果
  console.log(res); // res = 1(next 传进来的)
  return 3; // 生成器最终的 value = 3
}

// 执行器处理
autoRun(genFn).then(val => console.log(val)); // 输出 3
// autoRun 里的逻辑:
// - 第一次 next():yield 出 Promise.resolve(1),等待完成后 next(1)
// - 第二次 next(1):res = 1,执行到 return 3,done = true
// - 触发 if (done),返回 Promise.resolve(3) → 这就是 async 函数的返回值

记忆口诀:await 取 Promise 的“值”,async 包返回值成“Promise”——前者是“拆包”,后者是“打包”。

总结(核心记忆点)

把这些“为什么”浓缩成4句话,记下来就彻底懂了:

  1. async 是“容器标记”:告诉引擎这个函数要按“生成器+自动执行器”逻辑跑;
  2. try/catch 是“异常兜底”:接住引擎从外部抛回的 Promise 失败异常;
  3. await 下面的代码 = then 回调:都是等 Promise 完成后执行,只是写法更平;
  4. async 函数返回 Promise:对齐异步生态,让结果能被 .then/.catch 处理。

本质上,async/await 所有的“规则”(必须加 async、用 try/catch 等),都是为了让“生成器+自动执行器”的底层逻辑,能以“同步代码”的形式呈现——这也是它最巧妙的地方。

Message组件和Vue3 进阶:手动挂载组件与 Diff 算法深度解析

作者 干死前端
2026年1月17日 16:24

ai_image_1768638049154_1.jpg

Vue3 进阶:手动挂载组件与 Diff 算法深度解析

很多 Vue 开发者习惯了在 <template> 里写组件,但在开发 Message(全局提示)、Modal(模态框)或 Notification(通知)这类组件时,我们往往希望通过 JS 函数直接调用,而不是在每个页面都写一个 <MyMessage /> 标签。本文将带你深入 Vue3 底层,看看如何手动渲染组件

1. 为什么需要手动挂载?

想象一下,如果你想弹出一个成功提示,哪种方式更优雅?

方式 A (声明式): 需要在每个页面的 template 里都写一遍组件,还要定义一个变量来控制显示隐藏。

<template>
  <MyMessage :visible="showMsg" message="操作成功" />
  <button @click="showMsg = true">点击</button>
</template>

方式 B (命令式): 直接在 JS 里调用,随用随调,完全解耦。

import { message } from 'my-ui'

function handleClick() {
  message.success('操作成功')
}

显然,方式 B 是组件库的标准做法。但 Vue 的组件通常是渲染在父组件的模板里的,如何把它“凭空”变出来并挂载到 document.body 上呢?

这就需要用到 Vue3 暴露的两个底层 API:createVNoderender

2. 核心 API 解密

createVNode:画图纸

在 Vue 中,一切皆 VNode(虚拟节点)。普通的 .vue 文件只是一个组件定义,它不是 DOM,也不是 VNode。我们需要用 createVNode 把它实例化。

import { createVNode } from 'vue'
import MyComponent from './MyComponent.vue'

// 这就像是拿着图纸 (MyComponent)
// 创建了一个具体的实例化对象 (vm)
// 第二个参数是 props
const vnode = createVNode(MyComponent, { title: 'Hello' })

render:施工队

有了 VNode,它还只是内存里的对象。我们需要 render 函数把它变成真实的 DOM 节点,并挂载到某个容器上。

import { render } from 'vue'

const container = document.createElement('div')
// 把 vnode 渲染到 container 盒子里
render(vnode, container)

// 最后把盒子放到 body 上
document.body.appendChild(container)

3. 实战:手写一个简单的 Message 函数

让我们来看看 packages/components/message/src/method.ts 是如何实现的。

第一步:创建容器与 VNode

import { createVNode, render } from 'vue'
import MessageConstructor from './message.vue'

export function message(options) {
  // 1. 创建一个临时的 div 容器
  const container = document.createElement('div')

  // 2. 创建 VNode,并将 options 作为 props 传入
  // 例如:createVNode(MessageConstructor, { message: '你好', type: 'success' })
  const vnode = createVNode(MessageConstructor, options)

  // 3. 将 VNode 渲染到 container 中
  // 此时 container.firstElementChild 就是组件生成的真实 DOM
  render(vnode, container)

  // 4. 将真实 DOM 追加到 body
  document.body.appendChild(container.firstElementChild!)
}

第二步:处理组件卸载

这就完事了吗?并没有。如果我们不处理销毁逻辑,这些 DOM 节点会一直堆积在 body 里,造成内存泄漏。

我们需要在组件内部发射一个 destroy 事件(比如在动画结束时),然后在外部监听它。

const vnode = createVNode(MessageConstructor, {
  ...options,
  // 监听组件内部 emit('destroy')
  onDestroy: () => {
    // 移除 DOM
    render(null, container) // 这一步会触发组件的 unmounted 钩子
  }
})

4. 源码深潜:createVNode 和 render 到底干了啥?

对于好奇心强的同学,可能想知道:Vue 内部到底是怎么把这几行代码变成页面的?让我们用最通俗的伪代码来拆解一下。

4.1 createVNode:给节点“打标签”

createVNode 的核心任务不仅仅是创建一个对象,更是为了性能优化。它会根据你传入的类型,给 VNode 打上一个二进制标记(ShapeFlag)。

// 伪代码简化版
function createVNode(type, props, children) {
  // 1. 定义 VNode 结构
  const vnode = {
    type, // 组件对象 或 'div' 标签名
    props,
    children,
    component: null, // 稍后挂载组件实例
    el: null, // 稍后挂载真实 DOM
    shapeFlag: 0 // 核心:类型标记
  }

  // 2. 通过位运算打标记
  if (typeof type === 'string') {
    vnode.shapeFlag = ShapeFlags.ELEMENT // 这是一个 HTML 标签 (div, span)
  }
  else if (typeof type === 'object') {
    vnode.shapeFlag = ShapeFlags.STATEFUL_COMPONENT // 这是一个 Vue 组件
  }

  return vnode
}

为什么要这么做? Vue 的更新过程非常频繁。有了 shapeFlag,在后续的 Diff 过程中,Vue 就不需要每次都去猜“这是个啥”,直接看二进制位就知道怎么处理,速度极快。

4.2 render:万能的包工头

render 函数其实非常简单,它背后真正的干活主力是 patch 函数。

// 伪代码简化版
function render(vnode, container) {
  if (vnode == null) {
    // 如果传 null,说明要销毁
    if (container._vnode) {
      unmount(container._vnode) // 卸载旧节点
    }
  }
  else {
    // 如果有新 VNode,就开始“打补丁”
    // 参数:(旧节点, 新节点, 容器)
    patch(container._vnode || null, vnode, container)
  }

  // 记住这次渲染的 VNode,下次更新时它就是“旧节点”了
  container._vnode = vnode
}

4.3 patch:分发任务

patch 是 Vue 渲染器的核心。它根据我们前面打的 shapeFlag,把任务分发给不同的处理函数。

function patch(n1, n2, container) {
  if (n1 && !isSameVNodeType(n1, n2)) {
    // 如果类型都不一样(比如从 div 变成了 span),直接卸载旧的
    unmount(n1)
    n1 = null
  }

  const { shapeFlag } = n2
  if (shapeFlag & ShapeFlags.ELEMENT) {
    // 这是一个 HTML 标签
    processElement(n1, n2, container)
  }
  else if (shapeFlag & ShapeFlags.COMPONENT) {
    // 这是一个 Vue 组件
    processComponent(n1, n2, container)
  }
}

4.4 深入 processComponent:组件是怎么跑起来的?

patch 发现这是个组件时,它会区分是“初次挂载”还是“更新”。

function processComponent(n1, n2, container) {
  if (n1 == null) {
    // 1. 挂载组件 (Mount)
    mountComponent(n2, container)
  }
  else {
    // 2. 更新组件 (Update)
    updateComponent(n1, n2)
  }
}

mountComponent 做的事情:

  1. 创建实例const instance = createComponentInstance(vnode)
  2. 设置状态:初始化 propsslots,执行 setup() 函数。
  3. 建立副作用:创建一个 effect(响应式副作用),运行组件的 render 函数生成子树(subTree),并监听响应式数据变化。

4.5 深入 processElement:挂载与更新

patch 遇到 HTML 标签时,会根据 n1(旧节点)是否存在来决定是初始化还是更新。

1. 挂载 (Mount)

如果 n1null,说明是初次渲染。Vue 会调用宿主环境的 API(如 document.createElement)创建真实 DOM,并将其插入到容器中。

function mountElement(vnode, container) {
  // 1. 创建真实 DOM
  const el = (vnode.el = hostCreateElement(vnode.type))

  // 2. 处理 Props (Style, Class, Event)
  for (const key in vnode.props) {
    hostPatchProp(el, key, null, vnode.props[key])
  }

  // 3. 处理子节点 (递归 mount)
  mountChildren(vnode.children, el)

  // 4. 插入页面
  hostInsert(el, container)
}
2. 更新 (Patch)

如果 n1 存在,Vue 就需要对比新旧节点,做最小量的 DOM 操作。

  1. 更新 Props:对比新旧 Props,修改变动的 Class、Style 或事件监听器。
  2. 更新 Children (核心 Diff):这是最复杂的部分。

4.6 核心 Diff 算法:Vue3 是如何“增删改移”的?

Vue3 采用的是快速 Diff 算法 (Quick Diff)。它的核心思想是:先处理两端容易对比的节点,最后再处理中间复杂的乱序部分

我们通过一个具体的代码示例来模拟这个过程。

假设场景:

// 旧列表 (n1)
const oldChildren = [
  { key: 'A' }, { key: 'B' }, // 头
  { key: 'C' }, { key: 'D' }, { key: 'E' }, // 中间
  { key: 'F' }, { key: 'G' }  // 尾
]

// 新列表 (n2)
const newChildren = [
  { key: 'A' }, { key: 'B' }, // 头 (不变)
  { key: 'E' }, { key: 'C' }, { key: 'D' }, { key: 'H' }, // 中间 (乱序 + 新增)
  { key: 'F' }, { key: 'G' }  // 尾 (不变)
]
第一步:掐头(Sync from start)

Vue 会维护一个索引 i = 0,从头部开始向后遍历,如果 key 相同,直接 patch(更新属性),然后 i++

let i = 0
const e1 = oldChildren.length - 1 // 旧列表尾部索引
const e2 = newChildren.length - 1 // 新列表尾部索引

// 1. 从头往后比
while (i <= e1 && i <= e2) {
  const n1 = oldChildren[i]
  const n2 = newChildren[i]
  
  if (isSameVNodeType(n1, n2)) {
    patch(n1, n2, container) // 复用节点,更新 Props
    i++
  } else {
    break // 遇到不同的 (C vs E),停下来
  }
}
// 此时 i = 2,指向 C 和 E
第二步:去尾(Sync from end)

同样的逻辑,从尾部开始向前遍历。

// 2. 从尾往前比
while (i <= e1 && i <= e2) {
  const n1 = oldChildren[e1]
  const n2 = newChildren[e2]
  
  if (isSameVNodeType(n1, n2)) {
    patch(n1, n2, container)
    e1--
    e2--
  } else {
    break // 遇到不同的 (E vs H),停下来
  }
}
// 此时 e1 = 4 (指向 E), e2 = 5 (指向 H)

此时的状态:

  • 头部 A, B 已处理。
  • 尾部 F, G 已处理。
  • 剩下的烂摊子
    • 旧:[C, D, E] (索引 2 到 4)
    • 新:[E, C, D, H] (索引 2 到 5)
第三步:处理新增与删除(简单情况)

如果预处理后,旧列表没了(i > e1),新列表还剩,说明全是新增。 如果新列表没了(i > e2),旧列表还剩,说明全是删除

if (i > e1) {
  if (i <= e2) {
    // 旧的没了,新的还有 -> 挂载剩余的新节点
    while (i <= e2) patch(null, newChildren[i++], container)
  }
} 
else if (i > e2) {
  // 新的没了,旧的还有 -> 卸载剩余的旧节点
  while (i <= e1) unmount(oldChildren[i++])
}
第四步:处理乱序(Unknown Sequence)

这是最复杂的情况(如我们的例子)。Vue 需要判断哪些节点移动了,哪些需要新建。

1. 构建新节点映射表与初始化

// 1. 构建新节点的 key 映射表
const keyToNewIndexMap = new Map()
for (let k = i; k <= e2; k++) {
  keyToNewIndexMap.set(newChildren[k].key, k)
}

// 2. 待处理新节点数量
const count = e2 - i + 1
// 3. 记录新节点在旧列表中的位置(用于计算最长递增子序列)
const newIndexToOldIndexMap = new Array(count).fill(0)

2. 遍历旧节点:复用与删除

// 4. 遍历旧节点,寻找可复用的节点
for (let k = i; k <= e1; k++) {
  const oldChild = oldChildren[k]
  const newIndex = keyToNewIndexMap.get(oldChild.key)

  if (newIndex === undefined) {
    // 旧节点在新列表中找不到了 -> 删除
    unmount(oldChild)
  } else {
    // 找到了!记录旧索引 + 1(防止 0 索引冲突)
    // newIndex - i 是为了映射到从 0 开始的 count 数组中
    newIndexToOldIndexMap[newIndex - i] = k + 1
    // 进行递归比对
    patch(oldChild, newChildren[newIndex], container)
  }
}
/**
 * 此时产生的映射关系图例:
 * 
 * 索引 (i):      0    1    2    3   (对应新列表中的位置)
 * 新节点 key:   [E]  [C]  [D]  [H]
 * 旧索引 + 1:   [5]  [3]  [4]  [0]  (对应 newIndexToOldIndexMap)
 * 
 * 其中:
 * - 0 代表 H 是新来的,需要挂载 (Mount)
 * - 3, 4 是递增的序列 -> 这就是 LIS (最长递增子序列)
 * - 5 打破了递增性 -> 说明 E 发生了移动
 */

💡 小白专属解释:

你可以把 newIndexToOldIndexMap 想象成一张 “寻人启事表”。 表格的长度就是新列表里乱序的人数(这里是 4 个:E, C, D, H)。

  • 第 0 格 (E):表里写着 5。意思是:“我是旧列表里的第 4 号(5-1)人”。
  • 第 1 格 (C):表里写着 3。意思是:“我是旧列表里的第 2 号(3-1)人”。
  • 第 2 格 (D):表里写着 4。意思是:“我是旧列表里的第 3 号(4-1)人”。
  • 第 3 格 (H):表里写着 0。意思是:“查无此人,我是新来的”。

Vue 看到这张表,就知道谁是从哪儿来的,谁是新来的。然后只要算出哪个序列是递增的(3 -> 4),就说明这几个人(C 和 D)的相对站位没变,可以不用动,省力气!

3. 计算最长递增子序列 (LIS)

Vue 使用一个算法算出 newIndexToOldIndexMap 中的最长递增子序列。这个序列里的节点,在旧列表和新列表里的相对顺序是一样的,所以不需要移动

// 获取最长递增子序列的相对索引
// 传入: [5, 3, 4, 0] (忽略 0)
// 返回: [1, 2] (对应值 3, 4,即 C 和 D,它们在 newIndexToOldIndexMap 中的下标)
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap)

// j 指向 LIS 数组的末尾 (即最大索引)
let j = increasingNewIndexSequence.length - 1

4. 倒序遍历与移动 (Moving)

最后,我们从后往前遍历需要处理的新节点。 为什么倒序?因为 insert 操作需要一个参照节点 (Anchor)。从后往前遍历时,当前节点的后一个节点一定已经处理好了(要么是刚移动完的,要么是末尾固定的),可以放心地作为 Anchor。

// 遍历待处理的新节点 (倒序)
// k: 当前处理节点在乱序区间内的相对索引 (0 ~ count-1)
// i: 乱序区间的起始索引 (全局索引)
for (let k = count - 1; k >= 0; k--) {
  // 1. 计算该节点在新列表中的真实全局索引
  const nextIndex = i + k
  const nextChild = newChildren[nextIndex]
  
  // 2. 找锚点 (Anchor):就是它后面那个节点
  // 如果 nextIndex + 1 超过了长度,说明它是最后一个,锚点是 null (插到容器末尾)
  const anchor = nextIndex + 1 < newChildren.length ? newChildren[nextIndex + 1].el : null

  if (newIndexToOldIndexMap[k] === 0) {
    // ------------------------------------
    // 情况 1: 标记是 0 -> 新增节点
    // ------------------------------------
    patch(null, nextChild, container, anchor)
  
  } else if (j < 0 || k !== increasingNewIndexSequence[j]) {
    // ------------------------------------
    // 情况 2: 需要移动
    // ------------------------------------
    // 这里的 k 不在 LIS 里,说明位置不对,需要搬家
    move(nextChild, container, anchor) 
  
  } else {
    // ------------------------------------
    // 情况 3: 命中 LIS -> 原地不动
    // ------------------------------------
    // k === seq[j]: 恭喜,这个节点在最长递增序列里
    // 它的相对位置没变,不需要动 DOM,只需要让 LIS 指针前移
    j--
  }
}

💡 核心逻辑图解:

  1. H (i=3): Map 值为 0 -> 新建,插到末尾。
  2. D (i=2): 命中 LIS (seq[j]=2) -> 不动j--
  3. C (i=1): 命中 LIS (seq[j]=1) -> 不动j--
  4. E (i=0): 不在 LIS 里 -> 移动,插到 C 前面。

5. 源码级细节:为什么需要 Context?

你可能会发现我们的源码里有这么一行:

vnode.appContext = context || null

这是为了让动态挂载的组件能继承当前 App 的上下文。比如,你在主 App 里注册了 vue-routeri18n,如果不把 appContext 赋值给新的 VNode,那么在这个 Message 组件里就无法使用 useRouter()$t()

6. 总结

通过手动使用 createVNoderender,我们突破了 Vue 模板的限制,实现了能够动态创建、挂载、销毁的命令式组件。

这也是开发高级组件(如弹窗、抽屉、通知)的必经之路。

关键点复习

  1. createVNode(Component, props) 创建虚拟节点。
  2. render(vnode, container) 将虚拟节点转化为真实 DOM。
  3. render(null, container) 销毁组件,释放内存。

Vue转React学习笔记(1): 关于useEffect的困惑和思考

作者 mo_Mo
2026年1月17日 16:12

零、写在前面

  1. 之前实习和项目中都是学的Vue,由于之后工作大概率会进入React的技术生态,最近才开始学React并且缺少企业级的项目开发经验,也没有对源码做系统研究,以下的内容只是个人学习过程中的记录和思考,因此大概率会显得稚嫩而且主观,需要大佬们的指点和修正。
  2. 软件开发毕竟属于工程实践领域,对于相同的目标可以有不同的实现方案,而一样的技术在不同的场景下也需要做取舍权衡,它并不像数学物理这样的学科有一套不容置疑的公理。而且对于团队开发者而言,一套技术的开发体验、学习成本、认知对齐等因素也同样重要,它们应该是带有主观色彩的,至少是可讨论的;
  3. 为了描述简便,以下会根据官方文档的写法和称呼,对于useEffect(setup,dependencies),将渲染中产生的副作用称为Effect,将第一个函数参数称为setup,将依赖数组简称为deps,将setup返回的清理函数称为cleanup
  4. 文中提到的观点有些是React官方的提倡,有些是笔者自己的思考,请注意辨别,官方怎么说不代表我们真的要那么做/想。关于这个API,其实在各种React社区已经做出了大量讨论了,本文可以说是一个读后感。
  5. 以下提到的Vue指的是3.0+版本,而React指的是React18+的函数式组件+hook模式。

一、Vue视角的对比:一些粗浅的理解

由于之前有Vue框架的学习经验,因此在学习新的框架的时候,主播总会习惯上联想到Vue中相对应的Api,并且官方文档和大多数技术教程都会提到三种不同deps对应的情景和用法。

(一) 三种依赖情景

1. 不传任何dep——类比onMountedonUpdated

不传递任何依赖的时候,函数式组件首次渲染及之后更新,都会setup函数都会执行。对Vue来讲也是类似的,组件首次挂载执行onMounted和onUpdated钩子,区别在于Vue的onUpdated钩子的使用频率是很低的。

2. 传递空数组——类比onMounted

传递空数组意味着dep永远不变,setup函数只会在组件挂载的时候运行一次

3. 传递依赖值——类比watchwatchEffect

依赖值发生变化的时候,注册的回调函数会重新执行

useEffect(() => {
  // 会在每次渲染后运行
});

useEffect(() => {
  // 只会在组件挂载(首次出现)时运行
}, []);

useEffect(() => {
  // 会在组件挂载时运行,而且当 a 或 b 的值自上次渲染后发生变化后也会运行
}, [a, b]);

(二) 清理函数

cleanup函数会在调用和组件卸载的时候执行,Vue中也提供了相关的功能,比如在onUnmounted的时候执行卸载逻辑,watch和watchEffect都能返回函数以用于清理副作用。

watchEffect(() => {
  // 副作用逻辑:创建定时器
  const timer = setInterval(() => {
    count.value++
  }, 1000)

  // 清理函数:清除上一轮的定时器
  return () => {
    clearInterval(timer)
    console.log('定时器已清除')
  }
})

(三) 思维差异

对于Vue开发者或者React类组件的开发者来说,很容易用生命周期的组件思维来理解hook,然而官方觉得这对Effect来讲是不适宜的。如果用Vue角度来类比,大概率会觉得useEffect起到了一个“监听器”的作用,这引发了后面关于是否使用effect和如何写deps数组的差异。

二、如何理解副作用:区分EventEffect

(一) 副作用的定义

React hook倡导的是UI=f(state)的函数式编程理念,理论上组件函数应该是纯函数,也就是相同的输入返回相同的输出,并且不依赖也不影响外部系统,也就是不和外部产生任何交互。

// 非纯函数
const fn=(arr)=>{
  arr.push(1) // 修改了外部的参数
}

在日常开发中产生副作用的频率是很高的,常见的比如:

  • 发送http请求:和服务器交互
  • 操作dom元素:和浏览器API交互
  • 定时器:和浏览器API交互
  • 甚至console.log也是

很多文章提到,useEffect是用来管理副作用的,但如果抱有一遇到“网络请求”“定时器”之类的操作,就全部放进useEffect处理,这肯定是不对的,至少不符合官方推荐的理念。

事实上,官方文档多次强调要区分由用户交互引起的副作用event和由渲染过程引起的Effect

(二) 区分eventEffect

Effect 允许你指定由渲染自身,而不是特定事件引起的副作用

当你决定将某些逻辑放入事件处理函数还是 Effect 中时,你需要回答的主要问题是:从用户的角度来看它是 怎样的逻辑。如果这个逻辑是由某个特定的交互引起的,请将它保留在相应的事件处理函数(event handler)中。如果是由用户在屏幕上 看到 组件时引起的,请将它保留在 Effect 中。

1. 官方文档的例子

假设你正在实现一个聊天室组件,需求如下:

  1. 组件应该自动连接选中的聊天室。
  2. 每当你点击“Send”按钮,组件应该在当前聊天界面发送一条消息。
function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');
  // ...
  function handleSendClick() {
    sendMessage(message);
  }
  // ...
  return (
    <>
      <input value={message} onChange={e => setMessage(e.target.value)} />
      <button onClick={handleSendClick}>Send</button>
    </>
  );
}
2. 日常项目中常见的例子

比如我们点击按钮切换不同的数据类型,需要发送网络请求拉取最新数据,用effect需要这样写

useEffect(()=>{
  fetchData(type)
},[type])

这样就会带来一个问题,比如页面中有一些情况会改变type,但是那个场景下不需要发送请求,却仍旧触发了Effect,因此我们需要在setup函数里添加判断:

useEffect(()=>{
  if (type==='1'){
    fetchData(type)
  }
},[type])

未来,假设业务需求变更,你的请求又依赖了另一个参数userId,你需要在dep中添加另一个数据,甚至新数据也会再页面其他地方被修改,你需要对新数据进行条件判断。这样的话,代码的维护成本将会增高不少。

useEffect(()=>{
  if (type==='1' && userId){
    fetchData(type,userId)
  }
},[type,userId])

但实际上,假如请求这是点击事件触发的,官方更推荐把它直接写在事件处理函数里。这样写的好处是:只有当用户点击按钮这个事件触发的时候才会发送请求,而不是type变化的时候发送请求,从而做到切断type和useEffect的逻辑关系,让发送请求专属于点击事件。

function handleClick(e){
  const type=e.target.type
  fetchData(type)
}

在常见的管理页中,搜索框输入,搜索框点击,分页页码,下拉菜单选项都有可能触发重新请求,那么应该怎么办呢?

我们可能会这么写:

useEffect(()=>{
fetchData({
pageNum,
pageSize,
searchKey,
selectType
})
},
[pageNum,pageSize,searchKey,selectType]
)

大多数人我感觉也会这么写,把useEffect当成“监听”来使用,当数据变化时,拿着最新的数据发送请求,好像也没什么问题。

但是按照React官方的说法,每个事件函数里都要发送一次请求吗?我的答案是:也许是的。。。但其实也没那么复杂,多写几次而已,前提是把fetchData提前封装好。

const handleSearch = () => {
setPageNum(1); 
fetchData(); // 调用统一请求函数
};

const handlePageChange = (newPageNum, newPageSize) => {
setPageNum(newPageNum);
setPageSize(newPageSize);
fetchData(); // 调用统一请求函数
};

const handleSelectChange = (value) => {
setSelectType(value);
setPageNum(1); 
fetchData(); // 调用统一请求函数
};

(三) 响应式 VS 命令式——我们真的需要“监听”吗?

扯远一点,或许再考虑一个问题,我们真的在React中需要实现“监听”吗?

JavaScript层面,我们永远做不到对一个变量是否被改变进行监听,Vue和React作为JS框架当然都无法改变这一点。那为什么Vue可以做到监听呢?笔者觉得这涉及到两个框架对于响应式设计在理念上的巨大差异,也就是响应式编程和命令式编程。

简单讲,Vue主打一个“自动化”,基于发布订阅模式,当你声明一个数据需要为“响应式”的时候,框架内部会利用getter和setter帮你自动地进行依赖的收集和触发更新。

function reactive(target) {
  return new Proxy(target, {
    get(obj, key) {
      track(obj, key); // 启动依赖跟踪
      return Reflect.get(obj, key);
    },
    set(obj, key, value) {
      Reflect.set(obj, key, value);
      trigger(obj, key); // 触发副作用
      return true;
    }
  });
}

而React则把控制权交给开发者,类似于“手动挡”,根据UI=f(state)的理念,React中只有状态这个概念,因此开发者必须手动管理好每一个状态,通过调用setState进行状态修改,由React来帮你自动重渲染并更新。这样一来,每一处变动都是开发者手动触发的,React自然没有进行自动监听的义务。

所以笔者觉得,或许当我们真的需要用React来进行自动监听的时候,应该持有的实现思路可能是像Vue一样基于JS的发布订阅这条路去实现gettersetter,而不是useEffect

幸运的是,我们已经拥有了第三方库Mobx,它便是基于Vue响应式中的getter和setter模式,实现了自动监听。假如你使用React + Mobx则完全不需要手动setState了,可以看到两个框架的设计思想正在发生一定程度的交融。

import {observable, computed} from "mobx";

class OrderLine {
    @observable price = 0;
    @observable amount = 1;
    constructor(price) {
        this.price = price;
    }
  // 很像Vue的computed
    @computed get total() {
        return this.price * this.amount;
    }
}
const todos = observable([
    {
        title: "Make coffee",
        done: true,
    },
]);

// reaction 很像watch
const reaction = reaction(
    () => todos.map(todo => todo.title),
    titles => console.log("reaction 2:", titles.join(", "))
);

// autorun 很像watchEffect
const autorun1 = autorun(
    () => console.log("autorun 1:", todos.map(todo => todo.title).join(", "))
);

三、巨大的心智负担:deps到底怎么写

从以上例子可以看出,Effect的创作者担心开发人员理解不了这个hook,因此用了大量的案例来提示使用者在某些场景下别用Effect,但用不用Effect可能还不是最大的争议,useEffect钩子最大的争议点应该是在于依赖数组,也就是deps该怎么写,是否应该写完整。

(一) 官方的推荐:写全deps数组

文档中提到:任何响应式值都可以在重新渲染时发生变化,所以需要将响应式值包括在 Effect 的依赖项中。这里的响应式值可能含有:

    1. state
    1. props
    1. 所有在组件函数中用到的普通函数和普通变量

也就是所有在setup中用到,并且函数组件作用域内的可能变化的值,都需要添加到dep中。

正是这一点,给开发者带来巨大的心智负担,即使官方推荐安装eslint的插件辅助我们补全deps,但实践中还是将它设置为warning而并非error,这仅仅相当于起到了一个心理安慰的作用。

特别是当dep用到了函数的时候,可能会导致useCallback嵌套的问题

//组件函数体内部
function init(){
// 每次重渲染都会产生新引用
}

useEffect(()=>{
init(data) // 会频繁触发
},[data,init]) 

添加useCallback

const initFn=useCallback(init,[dep])
// 如果dep里面还有函数,可能需要再次包裹useCallback,代码可读性会降低很多

useEffect(()=>{
initFn(data) // 会频繁触发
},[data,initFn]) 

(二) 先实现setup,再确定deps,而不是反过来

官方文档提到,dep数组是完全由setup函数确定的,而不是由开发者选择的

个人是这么理解的:

    1. 你应该先确定这里的副作用是什么
    2. dep是副作用的过滤器,通过指定dep来跳过副作用的执行,保证性能
    3. 同样地,如果要修改dep,应该先修改setup,而不是反过来
    4. 如果抱着“监听”的思维去理解,会先确定dep(你要监听的东西)再实现setup,那样的话,你可能会漏写dep

四、如何建立自己的最佳实践

既然useEffect的心智负担那么重,我们应该这么做,以下是一点个人的思考

1.尽量多使用成熟的第三方库,减少自己裸写useEffect的情景

  • 状态管理上,已经有很多第三方库帮你解决数据的收集和更新的问题了,比如Mobx和Zustand,本质上都是发布订阅的模式
  • 发送请求上,优先使用Tanstack-Query、SWR、ahooks中的useRequest等第三方库,来发送请求,帮你管理请求中的各种问题(包括网络竞态、数据缓存、loading error管理等),比自己写useEffect要稳得多。

2.可以使用useEffect的情况

  • 需要操作原生dom元素的情况:比如手动设置元素的scrollTop的值
  • 确保副作用仅一次性执行,可以使用空数组的场景:比如初始化第三方SDK
  • 监听浏览器原生事件的情况:比如窗口大小变化window.resize
  • 需要和外部环境进行同步的情况:比如websocket链接,SSE链接等
  • 你觉得自己完全搞懂了useEffect的话可以用。。。(反正我是没搞懂。。)

3.协作开发的过程需要和团队对齐观念

如果是团队开发的话,要能看懂并理解别人的代码,而不是强迫别人理解自己的观点。团队的领导者最好能制定一份团队的开发规范,拉齐所有人的技术认知。当然这不是我这种新手该考虑的东西,只能说领导让怎么做就怎么做QvQ。

五、写在最后

作为一点Vue转React的新手,有几点个人的主观感受想提一下

  1. React只是一个JS库,它没有义务覆盖前端所有的业务场景,也没义务提供所谓的最佳实践,而是让开发者自己选择,这种理念应该是可以理解的。
  2. 但不管怎么说,useEffect都是一个认知曲线极其陡峭、给开发者造成巨大心智负担的API,从开发文档的篇幅以及React提出的useEffectEvent就可以看出来,但其实都是在缝缝补补。
  3. 官方对这个API的设计理念,和开发者的日常用法存在不少的割裂,这也是官方文档用大部分篇幅来讲这个hook的原因,就是为了让开发者少用,少滥用。
  4. 从体验上看,Vue即使对底层原理不那么熟悉也能进行开发,但React的开发过程中,还是需要不断按照React的机制去模拟整个流程,所以理解原理还是比较重要。至于这是不是框架在甩锅给开发者,就见仁见智了。

六、参考

  1. 《为什么我们要删掉100%的useEffect》:www.yuque.com/jiango/code…
  2. 《React hook使用误区,驳官方文档》:zhuanlan.zhihu.com/p/450513902
  3. 《A Complete Guide to useEffect》:overreacted.io/a-complete-…
  4. 《精读useEffect完全指南》:zhuanlan.zhihu.com/p/60277120
  5. React官方文档《脱围机制》:zh-hans.react.dev/learn/escap…
  6. React官方文档 useEffect: zh-hans.react.dev/reference/r…

前端路由不再难:React Router 从入门到工程化

作者 xhxxx
2026年1月17日 16:05

掌握 React Router:从基础到工程化实践

前端路由:从页面跳转到体验升级

在 Web 开发的早期,前端只是“静态页面的搬运工”——所有跳转逻辑由后端掌控,每次点击链接都会触发一次完整的页面刷新。用户看到的是白屏、等待、再加载;开发者面对的是耦合、重复和低效。

随着前后端分离架构的普及,前端不再只是展示层,而是逐渐承担起完整的应用逻辑。路由,也由此从前端的“盲区”变成了核心能力之一

今天,借助像 React Router 这样的工具,我们可以在不刷新页面的前提下,实现 URL 与 UI 的精准同步,构建真正意义上的单页应用(SPA)——既保持了 URL 的可分享性与书签能力,又带来了接近原生应用的流畅体验。

而这一切,都始于一个简单的映射关系:
路径(URL) → 组件(View)

接下来,我们将深入 React Router DOM,从安装配置到高级用法,一步步揭开前端路由的面纱。

React Router DOM

不同框架的路由实现虽然不同,但基本原理是相似的。这里我们以 React 路由为例,带你了解路由的魅力。

传统的 Web 开发是多页应用,基于 HTTP 请求实现。每当请求新的 URL 时,页面都会完全刷新,导致瞬间白屏,体验较差。

单页应用 (SPA)

得益于路由的出现,我们能够实现单页应用(SPA)。当用户点击链接跳转时,只触发对应的事件并更新局部视图,而不是重新渲染整个页面,从而提供流畅的用户体验。

安装路由

npm install react-router-dom

路由表示的就是路径和组件的映射关系

路由的使用

1. 引入路由
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
2. 路由的模式
  • HashRouter (#/)
    • URL 带有 # 号。
    • 兼容性极好,通常在路径之后会自动加上 #/
  • BrowserRouter (/)
    • URL 和后端路由一致,基于 HTML5 History API。
    • 兼容性较好(IE11 之前不支持),现代浏览器几乎都支持。
    • 推荐:现代开发中更推荐使用 BrowserRouter
    • 注意BrowserRouter as Router 是为了提高可读性,后续统一使用 Router 命名。
3. 核心组件
  • Routes:路由容器,负责管理一组路由,通常包裹在 Router 组件中。
  • Route:定义单个路由规则,当路径匹配时渲染对应的组件。

路由的配置

在现代的开发目录中,我们通常将路由的配置都放在 router 目录下。

import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import Home from './Home';
import About from './About';

<Router>
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/about" element={<About />} />
    </Routes>
</Router>

配置说明

  • / 对应 Home 组件
  • /about 对应 About 组件

当用户访问 / 时,会渲染 Home 组件;当访问 /about 时,会渲染 About 组件。

路由导航

在 SPA 中,我们不能使用 <a> 标签进行导航,因为它会导致页面的刷新。我们需要使用 React Router 提供的 Link 组件。

当用户点击 Link 组件时,会触发路由的导航,而不会导致页面刷新。我们需要在 Link 组件中指定 to 属性,来指定跳转的路径。

import { Link } from 'react-router-dom';

<Link to="/">Home</Link>
<Link to="/about">About</Link>

点击对应的链接就会跳转到对应的页面。


路由的种类

1. 普通路由

最基本的路由,当路径匹配时,会渲染对应的组件。 例如:about 对应 About 组件。

 <Route path="/about" element={<About />} />
2. 动态路由

路径中包含动态参数。

<Route path="/user/:id" element={<UserProfile />} />

动态路由当路径匹配时,会渲染对应的组件,并且会将路径中的参数传递给组件。 例如:/user/123 对应 UserProfile 组件,并且会将 123 传递给 UserProfile 组件。

UserProfile 组件中,我们可以通过 useParams hook 来获取路由参数:

import { useParams } from 'react-router-dom';
const { id } = useParams();
console.log(id); // 123
3. 嵌套路由
<Route path="/products" element={<Product/>}>
    <Route path=":productId" element={<ProductDetail />}/>
    <Route path="new" element={<NewProduct />}/>
</Route>

嵌套路由是局部更新的一种典型。当用户访问到产品页面时,点击产品列表中的不同按钮(例如产品详情和新产品),会渲染对应的子组件,而不会导致整个页面的刷新。 这样的路径配置是基于 /products 继续匹配的。

Product 组件中,我们可以通过 Outlet 组件来渲染嵌套路由的内容。 例如:/products/123 对应 ProductDetail 组件,并且会将 123 传递给 ProductDetail 组件。

import{
    Outlet
} from 'react-router-dom'
export default function Product() {
  return (
    <>
      <h1>产品列表</h1>
      <Outlet />
    </>
  )
}
4. 重定向路由
import {
  Routes, 
  Route,
  Navigate
} from 'react-router-dom'

<Route path="/old-path" element={<Navigate to="/new-path" />} />

当一个网站可能已经废弃或者不再更新资源,但用户依然访问旧路径时,我们通常就需要使用重定向路由帮助用户进行导航。 引入 Navigate,当用户访问 /old-path 时,会自动跳转到 /new-path。默认是使用 replace 模式,不会保留旧的历史记录。

如果我们想要保留旧的历史记录,可以设置 replace 属性为 false

<Route path="/old-path" element={<Navigate to="/new-path" replace={false} />} />
5. 鉴权路由 (路由守卫)
import ProtectRoute from './ProtectRoute';
<Route path='/pay' element={
   <ProtectRoute>
    <Pay />
   </ProtectRoute>
}/>
//protectRoute.jsx
import { Navigate } from 'react-router-dom'
export default function ProductRoute({children}) {
    const isLoggedIn = localStorage.getItem('isLogin')==='true'
    if(!isLoggedIn){
        return <Navigate to="/login" />
    }
  return (
    <>
      {children}
    </>
  )
}

鉴权路由也叫路由守卫,只有当满足条件时才能够放行。就像很多软件,你不登录就无法使用很多功能;当你想要付款时,如果没有登录,它就会自动弹出登录页。 使用一个组件来包裹需要鉴权的路由,当用户访问到这个路由时,会先判断是否登录。如果没有登录,就会跳转到登录页,否则就会渲染对应的组件。

6. 404 路由
<Route path="*" element={<NotFound />} />

* 代表除配置外的所有路由。 当用户访问到一个不存在的路由时,会渲染 NotFound 组件。


性能优化

路由的懒加载

在现代前端框架(如 React、Vue 等)中,路由的懒加载 (Lazy Loading) 是一种优化应用性能的重要手段。它允许你按需加载组件,而不是在应用初始加载时一次性加载所有路由组件,从而减小首屏 bundle 体积、加快页面加载速度。

例如当你打开应用首页时,你会看见很多功能按钮,但是如果要在首次进入时把这些按钮对应的所有组件一次性全部加载,这无疑是很糟糕的。这样不仅仅卡顿,还会增加首屏加载时间,甚至有些组件你都不使用,它却被加载了,这是非常浪费资源的。

而懒加载就很好地处理了这个问题。

// 传统的引入
import Home from './Home';
import About from './About';

// 懒加载引入
import { lazy } from 'react';
const Home = lazy(() => import('./Home'));
const About = lazy(() => import('./About'));

当然了,所有懒加载的组件都需要在 Suspense 组件中包裹起来,否则会报错。

import { Suspense } from 'react';
<Suspense fallback={<div>Loading...</div>}>
   <Route path="/" element={<Home />} />
   <Route path="/about" element={<About />} />
</Suspense>

HomeAbout 组件被加载时,会显示 Loading...,当加载完成后,会显示对应的组件。 当我们访问 / 时,会显示 Home 组件,而 About 则会延迟加载;只有当用户访问到 /about 时,才会加载 About 组件。


路由职责分离

为了保持代码的整洁和可维护性,我们通常会将路由的配置导航逻辑从主应用组件中剥离出来。 这种分离让 App.jsx 更加简洁,专注于布局和全局组件的组合。

1. 独立的路由配置 (src/router/index.jsx)

所有的路由规则、懒加载 (lazy)、路由守卫 (ProtectRoute) 等逻辑都集中管理。

// src/router/index.jsx
import { Routes, Route } from 'react-router-dom';
import { lazy, Suspense } from 'react';

// ... 懒加载组件定义 ...

export default function RouterConfig(){
    return(
        <Suspense fallback={<LoadingFallback />}>
          <Routes>
            <Route path="/" element={<Home />} />
            {/* ...其他路由配置 */}
          </Routes>
        </Suspense>
    )
}
2. 独立的导航组件 (src/components/Navigation.jsx)

导航菜单、链接高亮等 UI 逻辑封装在独立的组件中。

// src/components/Navigation.jsx
import { Link } from 'react-router-dom';
export default function Navigation() {
  return (
    <nav>
      <Link to="/">Home</Link>
      <Link to="/about">About</Link>
      {/* ... */}
    </nav>
  )
}
3. 简洁的主入口 (src/App.jsx)

主组件只负责引入和组装,不再包含复杂的路由逻辑。

// src/App.jsx
import Navigation from './components/Navigation'
import RouterConfig from './router'

export default function App() {
  return (
    <Router>
      <Navigation />   {/* 导航区域 */}
      <RouterConfig /> {/* 路由视图区域 */}
    </Router>
  )
}

样式优化

导航区高亮 (像掘金一样)

当我们点击导航区的不同链接时,我们通常会希望对应的导航项高亮显示,以告知用户当前所在的位置。 React Router 提供的 useResolvedPathuseMatch 可以帮助我们实现这个功能。

  • useResolvedPath: 将目标路径(to 属性)解析为绝对路径对象。
  • useMatch: 接收一个路径模式,判断当前 URL 是否与该模式匹配。
import { useResolvedPath, useMatch, Link } from 'react-router-dom'

export default function Navigation(){
  const isActive = (to) => {
     // 1. 将 to 解析为 location 对象,处理相对路径等情况
     const resolvedPath = useResolvedPath(to);
     
     // 2. 检查当前路径是否匹配 resolvedPath.pathname
     const match = useMatch({
      path: resolvedPath.pathname,
      end: true // 精准匹配,避免 / 匹配所有路径
     })

     return match ? 'active' : ''
  }
  
  return (
    <nav>
      <ul>
        <li><Link to="/" className={isActive('/')}>Home</Link></li>
        <li><Link to="/about" className={isActive('/about')}>About</Link></li>
        {/* ... */}
      </ul>
    </nav>
  )
}

结语

前端路由的本质,是路径与组件的映射;而 React Router,则让这种映射变得清晰、灵活又强大。

从基础跳转到动态参数,从嵌套路由到权限控制,再到懒加载与工程化拆分——我们不仅是在配置 URL,更是在构建一个可维护、高性能、用户体验流畅的现代 Web 应用。

掌握 React Router,不只是学会几个 API,而是理解 SPA 背后的导航逻辑与架构思维。希望这篇指南能成为你开发路上的可靠参考,助你写出更优雅的路由代码。

回顾浮动布局

2026年1月17日 15:50

引言

在现代 Web 开发中,虽然 Flexbox 和 Grid 已成为主流布局方案,但 CSS 浮动(float)  作为早期实现网页多列布局的核心技术,仍然具有重要的历史地位和实用价值。尤其在处理图文混排、旧项目维护或兼容性要求较高的场景中,掌握浮动布局依然是前端开发者的必备技能。

本文将从原理、行为、常见问题及解决方案等方面,系统讲解 CSS 浮动布局。


一、什么是浮动(Float)?

float 是 CSS 中的一个属性,最初设计用于实现文本环绕图片的效果。其基本语法如下:

element {
  float: left | right | none | inherit;
}
  • left:元素向左浮动;
  • right:元素向右浮动;
  • none(默认值):元素不浮动;
  • inherit:继承父元素的浮动设置。

示例:基础浮动

<img src="example.jpg" style="float: left; margin: 10px;">
<p>这是一段文字,会环绕在图片右侧……</p>

在这个例子中,图片脱离了正常文档流并向左浮动,文字则围绕在其右侧流动。


二、浮动的核心行为

要真正掌握浮动,必须理解它的三个关键特性:

1. 脱离正常文档流(但仍在页面中)

浮动元素会脱离块级格式化上下文(BFC)中的正常流,不再占据原来的空间(对后续块级元素而言),但仍会影响行内内容的排布(如文字环绕)。

注意:浮动元素并未完全“消失”,它依然存在于渲染树中,并参与布局计算。

2. 向左/右尽可能靠边

浮动元素会向指定方向移动,直到碰到包含块的边界另一个浮动元素为止。

3. 块级元素“坍塌”问题

当一个容器内的所有子元素都浮动时,该容器高度会变为 0(即“高度坍塌”)。这是因为浮动元素脱离了正常流,父容器无法感知其高度。

<div class="container">
  <div style="float: left; width: 100px; height: 100px; background: red;"></div>
  <div style="float: left; width: 100px; height: 100px; background: blue;"></div>
</div>
<!-- .container 高度为 0 -->

这个问题需要通过清除浮动(Clearfix)  来解决。


三、清除浮动(Clearing Floats)

为了解决父容器高度坍塌的问题,我们需要“清除浮动”。常用方法有:

方法 1:使用 clear 属性

clear: both 是 CSS 中用于控制元素与浮动元素之间关系的一个属性值,它的作用是:强制该元素不与任何左侧或右侧的浮动元素处于同一行,即“清除浮动”的影响。

在浮动元素后添加一个空元素并设置 clear: both

<div class="container">
  <div style="float: left;">A</div>
  <div style="float: left;">B</div>
  <div style="clear: both;"></div>
</div>

缺点:引入无语义的 HTML 元素。

方法 2:使用伪元素(推荐 —— Clearfix 技巧)

clearfix::after {
  content: "";
  display: table;
  clear: both;
}
<div class="container clearfix">
  <div style="float: left;">A</div>
  <div style="float: left;">B</div>
</div>

这是最经典的 clearfix 解决方案,兼容性好且语义清晰。

方法 3:触发 BFC(Block Formatting Context)

让父容器形成一个新的 BFC,使其能包含浮动子元素。常见方式包括:

  • overflow: hidden / auto
  • display: flow-root(现代浏览器)
  • float: left/right
  • position: absolute/fixed
.container {
  overflow: hidden; /* 触发 BFC */
}

推荐在现代项目中使用 display: flow-root,它专为解决此问题而生,且不会带来副作用(如裁剪内容)。


四、浮动布局的典型应用场景

尽管 Flex/Grid 更强大,但浮动仍有其用武之地:

1. 图文混排

img {
  float: left;
  margin-right: 15px;
}

这是浮动最初的设计目的,至今仍非常高效。

2. 简单的多列布局(旧项目)

在 Flexbox 出现前,开发者常通过浮动实现两栏或三栏布局:

sidebar { float: left; width: 200px; }
main { float: right; width: calc(100% - 200px); }

注意:需处理清除浮动和响应式适配。

3. 列表项横向排列(如导航菜单)

nav li {
  float: left;
  list-style: none;
}
nav::after {
  content: "";
  display: table;
  clear: both;
}

五、浮动的局限性与替代方案

局限性:

  • 容易引发高度坍塌;
  • 布局逻辑复杂,调试困难;
  • 不支持垂直居中、等高列等常见需求;
  • 响应式适配能力弱。

现代替代方案:

需求 推荐方案
多列布局 CSS Grid
单行/单列弹性布局 Flexbox
图文环绕 仍可用 float,或结合 shape-outside
容器包含浮动子元素 display: flow-root

5大核心分析维度+3种可视化方案:脑肿瘤大数据分析系统全解析 毕业设计 选题推荐 毕设选题 数据分析 机器学习

2026年1月17日 15:51

脑肿瘤数据可视化分系统-简介

本系统是一个基于Hadoop+Spark的大数据分析平台,专注于脑肿瘤医疗数据的可视化研究。系统后端采用Python语言,结合Django框架构建服务接口,并利用Spark进行大规模数据的高效处理与计算。原始脑肿瘤数据存储于Hadoop分布式文件系统(HDFS)中,通过Spark SQL对数据进行清洗、转换和多维度聚合分析。分析功能涵盖患者人口学特征、肿瘤临床特征、治疗方案与预后效果、临床症状关联性以及高风险因素探索等五大核心模块。处理后的结果经由Django API传递至前端,前端则运用Vue框架结合ElementUI组件库与Echarts图表库,将复杂的数据关系转化为直观的交互式图表,如性别年龄分布、肿瘤位置与恶性程度关联、不同治疗方案生存率对比等,为医疗研究者和临床医生提供一个全面、高效的数据洞察工具。

脑肿瘤数据可视化分系统-技术

开发语言:Python或Java 大数据框架:Hadoop+Spark(本次没用Hive,支持定制) 后端框架:Django+Spring Boot(Spring+SpringMVC+Mybatis) 前端:Vue+ElementUI+Echarts+HTML+CSS+JavaScript+jQuery 详细技术点:Hadoop、HDFS、Spark、Spark SQL、Pandas、NumPy 数据库:MySQL

脑肿瘤数据可视化分系统-背景

选题背景 随着医疗信息化进程的加快,医院积累了海量的脑肿瘤患者诊疗数据,这些数据包含了从患者基本信息到复杂治疗方案的多个维度。脑肿瘤本身作为一种复杂的疾病,其成因、发展和治疗效果受到众多因素交织影响。面对如此庞大且关系错综复杂的数据集,传统的统计分析工具往往显得力不从心,难以快速、全面地揭示隐藏在数据背后的规律。如何有效利用这些宝贵的数据资产,从中发现有价值的临床洞见,辅助医生进行更精准的诊断和治疗决策,成为了当前医疗领域面临的一个实际问题。因此,构建一个能够处理和分析这类复杂数据的系统显得尤为必要。

选题意义 本课题的意义在于将前沿的大数据技术应用于具体的医疗数据分析场景中,具有很强的实践价值。从技术层面看,它完整地实践了从数据存储、分布式计算到前端可视化的全流程,巩固了对Hadoop和Spark生态的理解与应用能力。从应用角度看,系统通过多维度的交互式图表,将原本枯燥的脑肿瘤数据变得直观易懂,能够帮助医学专业的学生或初级研究人员快速把握数据特征,发现一些潜在的临床关联模式,比如特定年龄段的高发肿瘤类型或不同治疗方案的疗效对比。虽然作为一个毕业设计,其分析深度和模型精度有限,但它为探索医疗数据的价值提供了一个可行的方法和思路,展示了大数据技术在精准医疗领域的应用潜力。

脑肿瘤数据可视化分系统-视频展示

[video(video-53oW3KNj-1768628790189)(type-csdn)(url-live.csdn.net/v/embed/510…)]

脑肿瘤数据可视化分系统-图片展示

在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述

脑肿瘤数据可视化分系统-代码展示

from pyspark.sql import SparkSession, functions as F
from pyspark.sql.types import IntegerType
spark = SparkSession.builder.appName("BrainTumorAnalysis").getOrCreate()
df = spark.read.csv("hdfs://path/to/brain_tumor_data.csv", header=True, inferSchema=True)
def analyze_age_gender_distribution():
    age_group_df = df.withColumn("Age_Group", F.when((df.Age < 18), "少年").when((df.Age >= 18) & (df.Age < 40), "青年").when((df.Age >= 40) & (df.Age < 60), "中年").otherwise("老年"))
    result_df = age_group_df.groupBy("Age_Group", "Gender").count().orderBy("Age_Group", "Gender")
    result_df.show()
    return result_df
def analyze_treatment_survival():
    treatment_df = df.withColumn("Treatment_Combination", F.concat_ws("+", F.when(df.Surgery_Performed == "Yes", "手术"), F.when(df.Radiation_Treatment == "Yes", "放疗"), F.when(df.Chemotherapy == "Yes", "化疗")))
    survival_df = treatment_df.groupBy("Treatment_Combination").agg(F.avg("Survival_Rate").alias("Average_Survival_Rate"), F.count("*").alias("Patient_Count")).orderBy(F.desc("Average_Survival_Rate"))
    survival_df.show()
    return survival_df
def analyze_correlation():
    correlation_df = df.select("Age", "Tumor_Size", "Survival_Rate", "Tumor_Growth_Rate").na.drop()
    age_size_corr = correlation_df.stat.corr("Age", "Tumor_Size")
    age_survival_corr = correlation_df.stat.corr("Age", "Survival_Rate")
    size_survival_corr = correlation_df.stat.corr("Tumor_Size", "Survival_Rate")
    growth_survival_corr = correlation_df.stat.corr("Tumor_Growth_Rate", "Survival_Rate")
    print(f"年龄与肿瘤尺寸的相关系数: {age_size_corr}")
    print(f"年龄与生存率的相关系数: {age_survival_corr}")
    print(f"肿瘤尺寸与生存率的相关系数: {size_survival_corr}")
    print(f"肿瘤生长速率与生存率的相关系数: {growth_survival_corr}")
    return {"age_size": age_size_corr, "age_survival": age_survival_corr, "size_survival": size_survival_corr, "growth_survival": growth_survival_corr}

脑肿瘤数据可视化分系统-结语

这个项目完整走通了大数据分析流程,从Hadoop存储到Spark计算,再到前端可视化,技术栈很扎实。希望能给正在做毕设的同学一点启发。如果觉得有帮助,别忘了点赞收藏,你的支持是我更新的最大动力!

刚肝完这个基于Spark的脑肿瘤分析毕设,感觉头发又掉了不少😂。数据清洗和特征工程真的太磨人了,但最后看到Echarts图表出来的那一刻,值了!大家选题都定了吗?评论区聊聊,互相避坑啊!

ArcGIS Pro 实现影像波段合成

作者 GIS之路
2026年1月17日 15:17

^ 关注我,带你一起学GIS ^

前言

通常,我们下载的卫星影像数据每个波段都存在一个单独的波段中,但是在生产实践中,我们往往需要由各个波段组成的完整数据集。所以,这个时候就需要进行波段合成操作。

本节主要讲解如何在ArcGIS Pro中实现TIFF影像波段合成。

1. 软件环境

本文使用以下软件环境,仅供参考。

时间:2026 年

操作软件:ArcGIS Pro 3.5

操作系统:windows 11

2. 下载卫星影像数据

俗话说巧妇难为无米之炊,数据就是软件的基石,没有数据,再美好的设想都是空中楼阁。因此,第一步需要下载遥感影像数据。

但是,影像数据在哪里下载呢?别着急,本文都给你整理好了。

数据下载可参考文章:GIS 影像数据源介绍


如下,这是我在【地理空间数据云】平台下载的landsat8遥感影像。

3. ArcGIS Pro 软件安装

要想使用ArcGIS Pro实现影像波段合成,那你得安装好ArcGIS Pro软件。

但是,软件安装说明不在本文的教程之内,就不进行介绍了,请未安装的同学自行解决。

4. 波段合成

好了,经过上面一堆废话,下面正式进入主题,进行实操。

如果有过ArcGIS版本软件基础的同学,可以很快完成,因为ArcGIS ProArcGIS的工具设置大体相同。

我们首先需要找到数据处理工具箱。点击菜单栏分析按钮Analysis,然后再点击工具Tools

或者点击软件搜索框中,其中会出现推荐的地理处理工具箱。

打开工具之后,点击数据数据处理工具Data Management Tools

然后依次点击栅格Raster、栅格处理Raster Processing、波段合成Composite Bands即可。

如果你觉得上述操作路径太长,或者你熟悉操作工具名称的话,可以直接在页面顶部搜索框输入工具名称Composite Bands进行检索。又或者在地理处理搜索框输入工具名称Composite Bands进行检索。

打开波段合成工具,先在输入栅格中选择需要进行合成的波段数据,然后选择输出位置,最后点击运行。

如下为波段4、波段3、波段2合成的彩色效果图。

如下为波段5、波段4、波段3合成的彩色效果图。

可在属性中查看源数据信息,其中三个波段显示如下。

OpenLayers示例数据下载,请在公众号后台回复:ol数据

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集

GDAL 实现矢量数据转换处理(全)

GDAL 实现投影转换

自然资源部党组关于苗泽等4名同志职务任免的通知

国产版的Google Earth,吉林一号卫星App“共生地球”来了

2026年全国自然资源工作会议召开

日本欲打造“本土版”星链系统

吉林一号国内首张高分辨率彩色夜光卫星影像发布

2025 年度信创领军企业名单出炉!

2026 年 Node.js + TS 开发:别再纠结 nodemon 了,聊聊热编译的最优解

作者 donecoding
2026年1月17日 14:18

在开发 Node.js 服务端时,“修改代码 -> 自动生效”的开发体验(即热编译/热更新)是影响效率的关键。随着 Node.js 23+  原生支持 TS 以及 Vite 5 的普及,我们的工具链已经发生了巨大的更迭。

今天我们深度拆解三种主流的 Node.js TS 开发实现方式,帮你选出最适合 2026 年架构的方案。


一、 方案对比大盘点

方案 核心原理 优点 缺点 适用场景
tsx (Watch Mode) 基于 esbuild 的极速重启 零配置、性能强、生态位替代 nodemon 每次修改重启整个进程,状态丢失 小型服务、工具脚本
vite-node 基于 Vite 的模块加载器 完美继承 Vite 配置、支持模块级 HMR 配置相对复杂,需手动处理 HMR 逻辑 中大型 Vite 全栈项目
Node.js 原生 Node 23+ Type Stripping 无需第三方依赖,官方标准 需高版本 Node,功能相对单一 追求极简、前瞻性实验

二、 方案详解

  1. 现代替代者:tsx —— 告别 nodemon + ts-node

过去我们常用 nodemon --exec ts-node,但在 ESM 时代,这套组合经常报 ERR_UNKNOWN_FILE_EXTENSION 错误。

tsx 内部集成了 esbuild,它是目前 Node 18+ 环境下最稳健的方案。

  • 实现热编译:

    bash

    npx tsx --watch src/index.ts
    

    请谨慎使用此类代码。

  • 为什么选它:  它不需要额外的加载器配置(--loader),且 watch 模式非常智能,重启速度在毫秒级。

  1. 开发者体验天花板:vite-node —— 真正的 HMR

如果你已经在项目中使用 Vite 5,那么 vite-node 是不二之选。它不仅是“重启”,而是“热替换”。

  • 核心优势:

    • 共享配置:直接复用 vite.config.ts 中的 alias 和插件。
    • 按需编译:只编译当前运行到的模块,项目越大优势越明显。
  • 实现热更新(不重启进程):

    typescript

    // src/index.ts
    import { app } from './app';
    let server = app.listen(3000);
    
    if (import.meta.hot) {
      import.meta.hot.accept('./app', (newModule) => {
        server.close(); // 优雅关闭旧服务
        server = newModule.app.listen(3000); // 启动新逻辑,DB连接可复用
      });
    }
    

    请谨慎使用此类代码。

  1. 官方正统:Node.js 原生支持

如果你能使用 Node.js 23.6+ ,那么可以摆脱所有构建工具。

  • 运行:  node --watch src/index.ts
  • 点评:  这是未来的趋势,但在 2026 年,由于生产环境往往还停留在 Node 18/20 LTS,该方案目前更多用于本地轻量级开发。

三、 避坑指南:Vite 5 打包 Node 服务的报错

在实现热编译的过程中,如果你尝试用 Vite 打包 Node 服务,可能会遇到:

Invalid value for option "preserveEntrySignatures" - setting this option to false is not supported for "output.preserveModules"

原因:  当你开启 preserveModules: true 想保持源码目录结构输出时,Rollup 无法在“强制保留模块”的同时又“摇树优化(Tree Shaking)”掉入口导出。

修复方案:
在 vite.config.ts 中明确设置:

typescript

build: {
  rollupOptions: {
    preserveEntrySignatures: 'exports-only', // 显式声明保留导出
    output: {
      preserveModules: true
    }
  }
}

请谨慎使用此类代码。


四、 总结:我该选哪个?

  1. 如果你只想快速写个接口,不想折腾配置:请直接使用 tsx。它是 2026 年 nodemon 的完美继承者。
  2. 如果你在做复杂全栈项目,或者有大量的路径别名:请使用 vite-node。它能让你在 Node 端获得跟前端 React/Vue 编写时一样丝滑的 HMR 体验。
  3. 如果是为了部署生产环境:无论开发环境用什么,生产环境请务必通过 vite build 产出纯净的 JS,并使用 node dist/index.js 运行。
❌
❌