阅读视图

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

我的大脑不下班:一个前端工程师的工作反刍自救指南

你有没有过这种感觉,早上 7 点,被楼上邻居的一声"咚"吵醒。离闹钟还有半小时,意识刚刚浮上来,大脑已经开始自动"开会"了——那个需求评审还没对齐,小王的代码今天能提测吗,后端接口的字段还没确认,得跟产品再聊一下排期……

你躺在床上,眼睛都没睁开,脑子已经转了三圈。

等你终于起来,发现这半小时一点用都没有。该沟通的还是得沟通,该等的还是得等。你只是提前消耗了自己。

晚上 7 点,你去游泳。这是你一天里为数不多的"属于自己"的时间。但游了两个来回,大脑又开始了——明天的站会要说什么,那个阻塞点谁来跟,新人的成长计划该怎么定……

水从指间划过,你的身体在泳池里,你的大脑在办公室。

如果你也是这样,恭喜你,你不是一个人。这个东西在心理学里有个名字,叫工作反刍

什么是工作反刍

反刍,本来是牛把吃下去的东西反复咀嚼。用在心理学里,就是大脑对同一些想法反复嚼、停不下来、而且没有产出。

心理学家把反刍分成两种。一种叫问题解决式反刍(problem-solving pondering),你真的在思考解决方案,想完了有结论,这种有用。另一种叫情感性反刍(affective rumination),就是我的状态——反复想,但一点用没有,只是在空转,还越转越焦虑。

德国心理学家 Sabine Sonnentag 提出过一个概念叫心理脱离(Psychological Detachment from Work)。她的研究发现,下班后能不能从工作中"心理脱离",直接影响一个人的疲劳恢复和长期心理健康。

用她的术语来说,我的问题就是:心理脱离失败——人离开了工作,但大脑没有。

一线开发时没这么严重,开始带人之后失控了

说实话,我做纯前端开发的时候,这个毛病没这么明显。写代码是一件很"闭合"的事——需求明确、边界清晰,写完了就是写完了,脑子可以关掉。

当了小组长开始带人之后,一切都变了。

你的工作不再是"写完这个组件",而是"确保这十件事都在往前推"。每件事都有不同的责任人、不同的进度、不同的阻塞点。你的脑子里永远挂着一张看不见的甘特图,随时在刷新。

更要命的是,很多事情的进度不在你手里。你能做的只是发个消息、拉个会、催一下,然后——等。

但你的大脑不会等。它会反复检查那张甘特图,即使你知道现在检查没有任何意义。心理学上叫蔡格尼克效应——未完成的任务比已完成的任务更容易占据你的注意力。带人的痛苦在于,你手上的任务永远不会"完成",总有下一个。

所以大脑永远有东西可以嚼。

大脑的"默认频道"

我后来意识到,这不是某个特定场景的问题。不是"游泳时容易想工作"或者"早上躺着容易想工作"的问题。而是——只要大脑没有被强制占用,它的默认频道就是工作。

跟老婆在一起的时候能忘掉,因为那是一个需要我真正"在场"的互动——有人在跟我说话、有反应、有情感交流,大脑必须实时处理,没有空隙给工作念头插进来。

打游戏以前也能占住大脑,但后来不行了,大脑能自动驾驶地玩,于是又空出来想工作。

游泳更别提了——动作重复、环境单调、不能听东西、眼睛也没什么可看的,简直是给工作念头开了 VIP 通道。

我的大脑要么被强制占满,要么就自动跑去想工作。中间没有一个"安静地什么都不想"的状态。

等于它从来没有真正休息过。

我和 Claude 的一次对话

我把上面这些困扰扔给了 Claude。没有抱着什么期待,只是想找个地方倒一倒。

但聊着聊着,它帮我把问题拆得很清楚。

首先,它指出了一个我没意识到的事情:我不是在"主动想",是大脑在"自动运转"。 我越想"别想了",反而越在想——就像有人说"别想白熊",你脑子里马上就是白熊。所以对抗它是没用的。

然后它问了我一个很关键的问题:你提前想了这么多次,有任何一次真的避免了问题吗?

答案是没有。那些提前想的东西,到了第二天该沟通还是得沟通,该等还是得等。空转从来没有产生过任何价值。

其实我的大脑早就知道这一点。那些在床上、在泳池里做的"规划",从来不会被记住,也不会被落实。第二天到了工位上,还是会重新梳理一遍。所谓的"提前想",只是大脑在自欺欺人。

我怀疑这跟我 J 人的属性有关——我天然喜欢做规划,喜欢把事情安排得井井有条。但问题是,不是所有的"想"都叫规划。

我之前写 sustand 的时候,连续好几天脑子里都在转这个库的设计,怎么组织 API、怎么处理状态订阅、怎么让用法更简洁。那些思考是有产出的,最后真的落地成了代码。那是问题解决式的思考,有明确的对象,有可以推进的空间。

但带人之后的大部分反刍不是这种。它是"小王的代码明天能提测吗"——你想一百遍也不会改变小王的进度。它是"后端接口还没确认"——你半夜想破头,对方也不会凌晨回你消息。这种纯粹等人、等结果的思考,没有任何可以推进的空间,只是在空转。

有创造性的深度思考 vs 无意义的等待式反刍——区分这两者,是我迈出的第一步。

三个阶段的练习

Claude 给我规划了一条路线,拆成三个阶段:

第一阶段:看见它。 不需要做任何改变,唯一的任务就是"发现自己在想"。早上醒来发现在想了,心里打个勾。游泳的时候发现了,也打个勾。不用拉回来,不用停,就只是看见。目标是从"想了半小时才发现"变成"几分钟内就能察觉"。

第二阶段:拉回来。 能快速察觉之后,开始练习把注意力拉回到一个具体的感官上——水的温度、呼吸的节奏、脚踩地板的感觉。刚开始拉回来十秒可能又飘走了,正常。关键是"拉回来"这个动作越来越轻松自然。

第三阶段:空转变短。 前两个阶段练够了以后,工作念头还是会冒出来,但停留的时间越来越短,从半小时缩到几分钟甚至几十秒就自己散了。

最终状态不是"再也不想工作"。那不现实。而是——念头来了你有选择权。你可以选择想一下,也可以选择不想,而不是被它拽着走。

整个过程有一个非常重要的细节:拉回来的时候,对自己的态度要轻。不是"我怎么又想了",而是"哦,又想了"。因为一旦你烦自己"又在想",那个烦本身又是一层新的消耗。

用代码治愈自己

聊到这里,身为程序员的本能上线了——我需要一个系统来追踪这件事,不然我怎么知道自己有没有进步?

于是我让 Claude 帮我做了一个正念练习追踪器。很简单的一个页面,每天花几秒钟记录:在什么场景想了工作、多快察觉到的、有没有拉回来、当天的感受。数据持久化存储,可以回看历史记录,也能看到自己当前在哪个阶段。

这特别符合程序员的思维——如果你不能度量它,你就不能改善它。当你能在追踪器里看到"察觉速度"从"想了很久才发现"慢慢变成"几分钟内发现",再变成"几十秒就发现",那种进步是可感知的。

比起空洞地告诉自己"要放下",一个可量化的系统让改变变得具体。

写给同样在空转的你

如果你也是一个从写代码到开始带人的程序员,如果你也发现自己的大脑在非工作时间停不下来,我想告诉你几件事:

这个东西有名字。 它叫工作反刍,不是你"想太多"或者"心态不好",是大脑的一种运行模式。

它不是你的错,但它是你的课题。 一旦你开始带人,就天然容易触发反刍,因为你处理的都是开放式的、依赖他人的、永远不会真正"完成"的任务。但这不意味着你只能忍受它。

解决它不需要什么大动作。 不需要冥想打坐一小时,不需要辞职换工作。从"发现自己在想"这一步开始,每天几秒钟就够了。

大脑不会真正"空"下来,但它可以不再只有一个默认频道。 念头来了像路边经过的车,看到了,不上车,它自己会开走。

我还在练习的路上。但至少现在游泳的时候,偶尔能看见指尖划过水面时冒出的泡泡了。

那几秒钟里,大脑很安静。

Element Plus 主题构建方案

Element Plus 主题构建步骤

第一步:安装依赖

项目里需要 sass 来编译 element-plus/theme-chalk 的 SCSS 源码。

pnpm add -D sass

第二步:新建主题构建插件文件

新建文件:

build/plugins/element-plus-theme.ts

最终代码如下:

import path from 'node:path'
import { promises as fs } from 'node:fs'

import { compileAsync } from 'sass'
import type { Plugin } from 'vite'

const OUTPUT_DIR = 'src/assets/generated'
const OUTPUT_FILE = 'element-plus-theme.css'
const TEMP_SCSS_FILE = 'element-plus-theme.scss'

const SERVICE_COMPONENTS = ['message', 'message-box', 'notification', 'loading']
const BASE_COMPONENTS = ['base']

const THEME_COLORS = {
  primary: '#215476',
  success: '#67c23a',
  warning: '#e6a23c',
  danger: '#f56c6c',
  error: '#f56c6c',
  info: '#909399',
}

const normalizePath = (targetPath: string) => targetPath.replace(/\\/g, '/')

const ensureRelativeImportPath = (targetPath: string) => {
  const normalizedPath = normalizePath(targetPath)

  if (normalizedPath.startsWith('./') || normalizedPath.startsWith('../')) {
    return normalizedPath
  }

  return `./${normalizedPath}`
}

const tagToComponentName = (tagName: string) => {
  return tagName.replace(/^el-/, '')
}

const scriptToComponentName = (componentName: string) => {
  return componentName
    .replace(/^El/, '')
    .replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)
    .replace(/^-/, '')
}

const createScssEntry = (themeChalkSrcImportPath: string, componentNames: string[]) => {
  const imports = componentNames
    .map(name => `@use "${themeChalkSrcImportPath}/${name}.scss" as *;`)
    .join('\n')

  return `@forward "${themeChalkSrcImportPath}/common/var.scss" with (
  $colors: (
    "primary": ("base": ${THEME_COLORS.primary}),
    "success": ("base": ${THEME_COLORS.success}),
    "warning": ("base": ${THEME_COLORS.warning}),
    "danger": ("base": ${THEME_COLORS.danger}),
    "error": ("base": ${THEME_COLORS.error}),
    "info": ("base": ${THEME_COLORS.info})
  )
);

${imports}
`
}

const scanUsedComponents = async (root: string) => {
  const srcDir = path.resolve(root, 'src')
  const usedComponents = new Set<string>([...BASE_COMPONENTS, ...SERVICE_COMPONENTS])

  const visit = async (targetPath: string): Promise<void> => {
    const stat = await fs.stat(targetPath)

    if (stat.isDirectory()) {
      const children = await fs.readdir(targetPath)
      await Promise.all(children.map(name => visit(path.join(targetPath, name))))
      return
    }

    if (!/\.(vue|ts|tsx|js|jsx)$/.test(targetPath)) {
      return
    }

    const source = await fs.readFile(targetPath, 'utf-8')
    const templateMatches = source.matchAll(/<\s*(el-[a-z0-9-]+)/g)
    const scriptMatches = source.matchAll(/\b(El[A-Z][A-Za-z]+)\b/g)

    for (const match of templateMatches) {
      usedComponents.add(tagToComponentName(match[1]))
    }

    for (const match of scriptMatches) {
      usedComponents.add(scriptToComponentName(match[1]))
    }
  }

  await visit(srcDir)
  return [...usedComponents].sort()
}

const buildThemeCss = async (root: string, command: 'serve' | 'build') => {
  const outputDir = path.resolve(root, OUTPUT_DIR)
  const outputPath = path.resolve(outputDir, OUTPUT_FILE)
  const tempScssPath = path.resolve(outputDir, TEMP_SCSS_FILE)
  const themeChalkSrcPath = path.resolve(root, 'node_modules/element-plus/theme-chalk/src')
  const themeChalkSrcImportPath = ensureRelativeImportPath(
    path.relative(outputDir, themeChalkSrcPath),
  )
  const componentNames = command === 'serve' ? ['index'] : await scanUsedComponents(root)
  const source = createScssEntry(themeChalkSrcImportPath, componentNames)

  await fs.mkdir(outputDir, { recursive: true })
  await fs.writeFile(tempScssPath, source, 'utf-8')

  const result = await compileAsync(tempScssPath, {
    loadPaths: [root],
    sourceMap: command === 'serve',
    style: command === 'serve' ? 'expanded' : 'compressed',
  })

  await fs.writeFile(outputPath, result.css, 'utf-8')
}

export const elementPlusThemePlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-plugin',
    apply: 'serve',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'serve')
    },
  }
}

export const elementPlusThemeBuildPlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-build-plugin',
    apply: 'build',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'build')
    },
  }
}

第三步:先看懂这几个常量

1. 输出目录

const OUTPUT_DIR = 'src/assets/generated'
const OUTPUT_FILE = 'element-plus-theme.css'
const TEMP_SCSS_FILE = 'element-plus-theme.scss'

最终会生成两个文件:

src/assets/generated/element-plus-theme.scss
src/assets/generated/element-plus-theme.css
  • scss 是临时入口文件
  • css 是项目真正引入的文件

2. 默认保留的组件

const SERVICE_COMPONENTS = ['message', 'message-box', 'notification', 'loading']
const BASE_COMPONENTS = ['base']

即使源码没扫到,也会保留这些样式。

原因:

  • base 是基础样式
  • messageloading 这类服务型组件容易漏

3. 主题色配置

const THEME_COLORS = {
  primary: '#215476',
  success: '#67c23a',
  warning: '#e6a23c',
  danger: '#f56c6c',
  error: '#f56c6c',
  info: '#909399',
}

后面改主题色,优先改这里。


第四步:处理 Sass 导入路径

看这两个函数:

const normalizePath = (targetPath: string) => targetPath.replace(/\\/g, '/')

const ensureRelativeImportPath = (targetPath: string) => {
  const normalizedPath = normalizePath(targetPath)

  if (normalizedPath.startsWith('./') || normalizedPath.startsWith('../')) {
    return normalizedPath
  }

  return `./${normalizedPath}`
}

这一步必须有。

因为 Sass 在 Windows 下处理路径时,如果路径不是:

./xxx
../xxx

就可能把它当成包名,然后报错:

Can't find stylesheet to import

所以这里做了两件事:

  1. 统一把 \ 转成 /
  2. 强制路径带上相对前缀

第五步:生成临时 SCSS 入口文件

核心函数:

const createScssEntry = (themeChalkSrcImportPath: string, componentNames: string[]) => {
  const imports = componentNames
    .map(name => `@use "${themeChalkSrcImportPath}/${name}.scss" as *;`)
    .join('\n')

  return `@forward "${themeChalkSrcImportPath}/common/var.scss" with (
  $colors: (
    "primary": ("base": ${THEME_COLORS.primary}),
    "success": ("base": ${THEME_COLORS.success}),
    "warning": ("base": ${THEME_COLORS.warning}),
    "danger": ("base": ${THEME_COLORS.danger}),
    "error": ("base": ${THEME_COLORS.error}),
    "info": ("base": ${THEME_COLORS.info})
  )
);

${imports}
`
}

这段代码做两件事:

1. 用 @forward 覆盖变量

最终会生成类似:

@forward "../../../node_modules/element-plus/theme-chalk/src/common/var.scss" with (
  $colors: (
    "primary": ("base": #215476),
    "success": ("base": #67c23a),
    "warning": ("base": #e6a23c),
    "danger": ("base": #f56c6c),
    "error": ("base": #f56c6c),
    "info": ("base": #909399)
  )
);

2. 用 @use 引入组件样式

开发环境会生成:

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

生产环境会生成:

@use "../../../node_modules/element-plus/theme-chalk/src/base.scss" as *;
@use "../../../node_modules/element-plus/theme-chalk/src/button.scss" as *;
@use "../../../node_modules/element-plus/theme-chalk/src/form.scss" as *;

所以这个函数的作用就是:

  • 先覆盖变量
  • 再拼接本次要构建的样式入口

第六步:扫描项目里实际使用到的 Element Plus 组件

核心函数:

const scanUsedComponents = async (root: string) => {
  const srcDir = path.resolve(root, 'src')
  const usedComponents = new Set<string>([...BASE_COMPONENTS, ...SERVICE_COMPONENTS])

  const visit = async (targetPath: string): Promise<void> => {
    const stat = await fs.stat(targetPath)

    if (stat.isDirectory()) {
      const children = await fs.readdir(targetPath)
      await Promise.all(children.map(name => visit(path.join(targetPath, name))))
      return
    }

    if (!/\.(vue|ts|tsx|js|jsx)$/.test(targetPath)) {
      return
    }

    const source = await fs.readFile(targetPath, 'utf-8')
    const templateMatches = source.matchAll(/<\s*(el-[a-z0-9-]+)/g)
    const scriptMatches = source.matchAll(/\b(El[A-Z][A-Za-z]+)\b/g)

    for (const match of templateMatches) {
      usedComponents.add(tagToComponentName(match[1]))
    }

    for (const match of scriptMatches) {
      usedComponents.add(scriptToComponentName(match[1]))
    }
  }

  await visit(srcDir)
  return [...usedComponents].sort()
}

它扫描哪些文件

  • .vue
  • .ts
  • .tsx
  • .js
  • .jsx

它怎么识别模板中的组件

靠这个正则:

/<\s*(el-[a-z0-9-]+)/g

比如:

<el-form />
<el-input />
<el-button />

会识别成:

  • form
  • input
  • button

它怎么识别服务型组件

靠这个正则:

/\b(El[A-Z][A-Za-z]+)\b/g

比如:

ElMessage.success('成功')
ElLoading.service(...)

会识别成:

  • message
  • loading

组件名为什么还要转换

因为模板和脚本中的名字,不是 theme-chalk 的文件名格式。

比如:

  • <el-form> 需要变成 form.scss
  • ElMessageBox 需要变成 message-box.scss

所以这里配了两个转换函数:

const tagToComponentName = (tagName: string) => {
  return tagName.replace(/^el-/, '')
}

const scriptToComponentName = (componentName: string) => {
  return componentName
    .replace(/^El/, '')
    .replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)
    .replace(/^-/, '')
}

第七步:真正执行主题构建

核心函数:

const buildThemeCss = async (root: string, command: 'serve' | 'build') => {
  const outputDir = path.resolve(root, OUTPUT_DIR)
  const outputPath = path.resolve(outputDir, OUTPUT_FILE)
  const tempScssPath = path.resolve(outputDir, TEMP_SCSS_FILE)
  const themeChalkSrcPath = path.resolve(root, 'node_modules/element-plus/theme-chalk/src')
  const themeChalkSrcImportPath = ensureRelativeImportPath(
    path.relative(outputDir, themeChalkSrcPath),
  )
  const componentNames = command === 'serve' ? ['index'] : await scanUsedComponents(root)
  const source = createScssEntry(themeChalkSrcImportPath, componentNames)

  await fs.mkdir(outputDir, { recursive: true })
  await fs.writeFile(tempScssPath, source, 'utf-8')

  const result = await compileAsync(tempScssPath, {
    loadPaths: [root],
    sourceMap: command === 'serve',
    style: command === 'serve' ? 'expanded' : 'compressed',
  })

  await fs.writeFile(outputPath, result.css, 'utf-8')
}

按执行顺序看:

1. 找到输出目录和输出文件

const outputDir = path.resolve(root, OUTPUT_DIR)
const outputPath = path.resolve(outputDir, OUTPUT_FILE)
const tempScssPath = path.resolve(outputDir, TEMP_SCSS_FILE)

2. 找到 theme-chalk/src

const themeChalkSrcPath = path.resolve(root, 'node_modules/element-plus/theme-chalk/src')

3. 转成 Sass 可识别的相对导入路径

const themeChalkSrcImportPath = ensureRelativeImportPath(
  path.relative(outputDir, themeChalkSrcPath),
)

4. 决定是全量还是按需

const componentNames = command === 'serve' ? ['index'] : await scanUsedComponents(root)
  • serve 时:直接用 index.scss
  • build 时:扫描后按需引入组件样式

5. 生成临时 SCSS 入口源码

const source = createScssEntry(themeChalkSrcImportPath, componentNames)

6. 写入临时文件

await fs.mkdir(outputDir, { recursive: true })
await fs.writeFile(tempScssPath, source, 'utf-8')

7. 调用 Sass 编译

const result = await compileAsync(tempScssPath, {
  loadPaths: [root],
  sourceMap: command === 'serve',
  style: command === 'serve' ? 'expanded' : 'compressed',
})

这里注意两个配置:

  • sourceMap: command === 'serve' 开发环境保留 sourceMap,方便调试。
  • style: command === 'serve' ? 'expanded' : 'compressed' 开发环境不压缩,生产环境压缩。

8. 输出最终 CSS

await fs.writeFile(outputPath, result.css, 'utf-8')

第八步:分别在开发和生产环境触发它

开发环境插件

export const elementPlusThemePlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-plugin',
    apply: 'serve',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'serve')
    },
  }
}

这段代码的意思是:

  • 只在 vite dev 时执行
  • 启动时构建一次全量主题

注意:

  • 不跟着业务源码热更新反复执行
  • 因为这里构建的是 Element Plus 主题,不是业务页面样式

生产环境插件

export const elementPlusThemeBuildPlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-build-plugin',
    apply: 'build',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'build')
    },
  }
}

这段代码的意思是:

  • 只在 vite build 时执行
  • 构建前按需扫描组件,再生成 CSS

第九步:在 Vite 中注册插件

文件: vite.config.ts

写法如下:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import vueJsx from '@vitejs/plugin-vue-jsx'
import UnoCSS from 'unocss/vite'
import { elementPlusThemeBuildPlugin, elementPlusThemePlugin } from './build/plugins/element-plus-theme'

export default defineConfig({
  resolve: {
    alias: {
      '@': '/src',
    },
  },
  plugins: [
    elementPlusThemePlugin(),
    elementPlusThemeBuildPlugin(),
    vue(),
    vueJsx(),
    UnoCSS(),
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/types/auto-imports.d.ts',
    }),
  ],
})

虽然这里写了两个插件,但不会同时执行:

  • pnpm dev 只执行 elementPlusThemePlugin
  • pnpm build 只执行 elementPlusThemeBuildPlugin

第十步:在应用入口引入生成后的 CSS

文件: main.ts

最终写法:

import { createApp } from 'vue'
import ElementPlus from 'element-plus'

import App from './App.vue'
import './assets/styles/ress.min.css'
import './assets/generated/element-plus-theme.css'
import 'virtual:uno.css'
import i18n from './i18n'

import router from './router'
import pinia from './store'

const app = createApp(App)

app.use(ElementPlus)
app.use(router)
app.use(pinia)
app.use(i18n)
app.mount('#app')

这里最关键的是:

import './assets/generated/element-plus-theme.css'

如果你之前有:

import 'element-plus/dist/index.css'

要把它删掉,不然就会和生成后的主题文件重复。


第十一步:怎么验证它是否生效

开发环境验证

执行:

pnpm dev

检查:

  1. 是否生成了文件:
src/assets/generated/element-plus-theme.scss
src/assets/generated/element-plus-theme.css
  1. 页面中的 Element Plus 组件是否使用了新的主题色。

生产环境验证

执行:

pnpm build

检查:

  1. 构建是否通过。
  2. 生成后的 element-plus-theme.css 是否存在。
  3. 打包后的页面中 Element Plus 样式是否正常。

第十二步:这套实现的边界

1. 动态拼接组件名,可能扫不到

比如:

const name = 'Button'
const component = `El${name}`

这种写法不一定能被正则准确识别。

2. 新的服务型组件如果没进白名单,可能漏样式

如果以后用了新的服务型组件,而当前正则又没扫到,就要把它补到:

const SERVICE_COMPONENTS = ['message', 'message-box', 'notification', 'loading']

里。

3. 主题变量扩展时,继续沿用 @forward ... with (...)

如果后面要覆盖的不只是颜色,比如圆角、边框、文字颜色,也继续在 createScssEntry 里往下扩。

从isMounted到跨页面状态:高级前端如何优雅解决订单场景的“幽灵陷阱”(附React/Vue完整代码)

作为前端开发者,我们每天都会和异步操作打交道——请求接口、定时器、回调函数,而其中最容易踩坑、也最容易被忽略的,就是“组件卸载后,异步操作仍在执行”的问题。尤其是在电商订单、支付、表单提交等核心场景,一个小小的疏忽,就可能导致用户重复下单、状态错乱、控制台报错,甚至引发线上故障。

很多前端新手遇到这类问题,第一反应就是用isMounted标志位“打补丁”:组件挂载时设为true,卸载时设为false,异步回调里先判断再执行状态更新。看似解决了控制台警告,但这只是“掩耳盗铃”,根本没有从根源上解决问题,反而会埋下更多隐患。

今天,我就以电商“创建订单”这个最常见的场景为例,从“痛点分析→错误方案拆解→三层递进解决方案→完整代码实现→实际项目适配”,手把手教你写出工业级的前端异步处理逻辑,同时附上React和Vue3的完整可运行代码,每一行代码都有详细注释,确保新手也能看懂、会用,全文约3200字,耐心读完,一定会有收获。

一、先看痛点:你写的订单按钮,可能正在偷偷“坑用户”

在开始讲解决方案之前,我们先还原几个真实的线上场景,看看你有没有遇到过类似的问题,这些问题背后,都是“组件卸载后异步操作”的幽灵陷阱在作祟。

场景1:用户在商品详情页点击“立即购买”,触发订单创建请求,手快的用户不等请求完成,直接点击返回键回到首页。几秒钟后,首页突然弹出“订单创建成功”的提示,用户一脸懵——我都退出来了,怎么还下单成功了?

场景2:用户点击下单后,页面加载缓慢,用户以为没点上,反复点击“立即购买”按钮,导致多次发起请求,最终创建了多个重复订单,后续需要客服介入取消,既增加了客服成本,也影响用户体验。

场景3:控制台疯狂报错“Can't perform a React state update on an unmounted component”(React)、“Avoid appending to a document that is not in the DOM”(Vue),虽然不影响功能,但看着心烦,而且一旦出现异常,很难定位问题。

场景4:用户点击下单后,刷新页面,之前的加载状态全部丢失,页面又显示“立即购买”,用户以为没下单,再次点击,导致重复提交。

这些问题,看似是“小bug”,但背后反映的是前端开发者对“异步操作生命周期”和“组件状态管理”的理解不足。很多新手之所以会用isMounted来解决,就是因为没有意识到:isMounted只是“过滤了回调”,并没有“终止异步操作”。

二、错误方案拆解:为什么isMounted是“饮鸩止渴”?

我们先来看一个面试中常见的反面教材,也是很多项目里真实存在的代码——用isMounted标志位解决组件卸载后异步回调的问题。我会分别给出React和Vue3的版本,并且详细拆解其中的问题,让大家明白“为什么这种写法不可取”。

1. 错误写法:React版本(新手最常用)

这段代码看似“没问题”,能解决控制台的状态更新警告,但实际上藏着3个致命缺陷,我们逐行分析。

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

// 商品详情页的订单按钮组件
function OrderButton({ goodsId, goodsName }) {
  // 加载状态:控制按钮禁用和文字显示
  const [loading, setLoading] = useState(false);
  // 组件挂载标志位:用来判断组件是否还在DOM中
  const isMounted = useRef(true);

  // 组件挂载时执行,返回的函数在组件卸载时执行
  useEffect(() => {
    // 组件卸载时,将标志位设为false
    return () => {
      isMounted.current = false;
      console.log('组件已卸载,标记isMounted为false');
    };
  }, []); // 空依赖,只在挂载和卸载时执行

  // 点击“立即购买”,创建订单的核心方法
  const handleCreateOrder = async () => {
    // 点击后立即禁用按钮,防止重复点击(看似做了防重复,但不够彻底)
    setLoading(true);
    try {
      // 模拟创建订单的接口请求,延迟3秒,模拟网络延迟
      const res = await fetch('/api/order/create', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ 
          goodsId: goodsId, 
          goodsName: goodsName,
          userId: '123456' // 模拟用户ID
        })
      });
      // 解析接口返回的数据
      const data = await res.json();

      // 关键判断:只有组件还挂载,才更新状态、弹出提示
      if (isMounted.current) {
        alert(`订单创建成功!订单ID:${data.orderId}`);
        // 重置加载状态,启用按钮
        setLoading(false);
      }
    } catch (err) {
      // 异常处理:同样判断组件是否挂载,再提示错误
      if (isMounted.current) {
        alert('订单创建失败,请重试!');
        setLoading(false);
      }
      console.error('订单创建失败:', err);
    }
  };

  // 渲染订单按钮
  return (
    <button 
      onClick={}
      style={{ 
        padding: '12px 24px', 
        fontSize: '16px', 
        backgroundColor: '#ff4400', 
        color: '#fff', 
        border: 'none', 
        borderRadius: '8px',
        cursor: loading ? 'not-allowed' : 'pointer'
      }}
    >
      {loading ? '订单处理中...' : '立即购买'}
    
  );
}

2. 错误写法:Vue3版本(script setup语法)

Vue3的写法和React类似,核心都是“用标志位判断组件是否挂载”,同样存在相同的问题。

<script setup>
// 引入Vue3的核心API
import { ref, onUnmounted } from 'vue';

// 接收父组件传递的商品信息
const props = defineProps({
  goodsId: {
    type: String,
    required: true
  },
  goodsName: {
    type: String,
    required: true
  }
});

// 加载状态:控制按钮禁用和文字显示
const loading = ref(false);
// 组件挂载标志位
let isMounted = true;

// 组件卸载时执行的钩子函数
onUnmounted(() => {
  isMounted = false;
  console.log('组件已卸载,标记isMounted为false');
});

// 点击“立即购买”,创建订单的核心方法
const handleCreateOrder = async () => {
  // 禁用按钮,防止重复点击
  loading.value = true;
  try {
    // 模拟接口请求,延迟3秒
    const res = await fetch('/api/order/create', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ 
        goodsId: props.goodsId, 
        goodsName: props.goodsName,
        userId: '123456'
      })
    });
    const data = await res.json();

    // 判断组件是否挂载,再更新状态
    if (isMounted) {
      alert(`订单创建成功!订单ID:${data.orderId}`);
      loading.value = false;
    }
  } catch (err) {
    if (isMounted) {
      alert('订单创建失败,请重试!');
      loading.value = false;
    }
    console.error('订单创建失败:', err);
  }
};
</script>

<template>
  <button 
    @click="handleCreateOrder" 
    :disabled="loading"
    style="
      padding: 12px 24px;
      font-size: 16px;
      background-color: #ff4400;
      color: #fff;
      border: none;
      border-radius: 8px;
      cursor: loading ? 'not-allowed' : 'pointer'
    "
  >
    {{ loading ? '订单处理中...' : '立即购买' }}
  </button>
</template>

3. 致命缺陷拆解(重点!新手必看)

很多新手觉得,“只要加了isMounted,控制台不报错,就没问题了”。但实际上,这种写法只是“治标不治本”,甚至会埋下更严重的线上隐患,我们拆解3个最核心的问题:

缺陷1:异步请求根本没有被取消——这是最核心的问题。用户点击下单后,即使退出页面(组件卸载),网络请求依然在后台运行,并没有被终止。这不仅会浪费用户的流量、服务器的资源,还可能导致“用户已经退出页面,订单却创建成功”的诡异情况(比如用户误点后退出,结果还是下单了,引发投诉)。

缺陷2:状态无法跨页面同步——isMounted标志位是“组件级”的,只存在于当前组件的内存中。如果用户刷新页面、重进商品页,或者从订单页返回商品页,之前的loading状态、订单处理状态都会被重置,页面又会显示“立即购买”,用户以为没下单,再次点击,就会导致重复提交。

缺陷3:用户体验严重割裂——用户退出页面后,异步请求如果成功,回调函数被isMounted过滤,用户收不到任何反馈,以为自己没下单;如果请求失败,用户也不知道,后续可能会反复尝试,既影响体验,也增加了接口压力。

总结一句话:isMounted只是“解决了控制台的警告”,并没有解决“异步操作失控”的核心问题。高级前端的解决方案,从一开始就会跳出“标志位过滤”的思维,而是从“终止异步操作→同步状态→兜底体验”三个层面,彻底解决问题。

三、第一层解决方案:请求取消,从根源上终止异步操作

解决“组件卸载后异步操作失控”的第一步,就是“真正终止异步操作”,而不是等操作完成后再过滤回调。对于前端异步请求(fetch、axios),我们有成熟的API可以实现请求取消,这也是工业级项目的基础操作。

这里我们分两种情况讲解:原生fetch请求(无需额外依赖)和axios请求(项目中最常用),分别给出React和Vue3的完整代码,每一行都有详细注释,确保新手能看懂、会用。

1. 原生fetch请求:用AbortController取消请求

现代浏览器(Chrome 60+、Firefox 57+、Edge 16+)原生支持AbortController API,它可以生成一个信号(signal),绑定到fetch请求上,当我们需要取消请求时,调用abort()方法即可,简单、高效、无依赖。

React版本(完整可运行)

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

function OrderButton({ goodsId, goodsName }) {
  const [loading, setLoading] = useState(false);
  // 存储AbortController实例的ref,用于组件卸载时取消请求
  // 为什么用ref?因为ref的值不会触发组件重新渲染,适合存储临时实例
  const controllerRef = useRef(null);

  // 组件卸载时,取消正在进行的请求
  useEffect(() => {
    // 组件卸载时执行的清理函数
    return () => {
      // 判断是否有正在进行的请求
      if (controllerRef.current) {
        // 取消请求
        controllerRef.current.abort();
        console.log('组件卸载,取消正在进行的订单请求');
      }
    };
  }, []); // 空依赖,只在挂载和卸载时执行

  const handleCreateOrder = async () => {
    setLoading(true);
    // 1. 创建AbortController实例
    const controller = new AbortController();
    // 2. 将实例存储到ref中,方便卸载时取消
    controllerRef.current = controller;

    try {
      // 3. 发起fetch请求,通过signal绑定取消信号
      const res = await fetch('/api/order/create', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ 
          goodsId: goodsId, 
          goodsName: goodsName,
          userId: '123456'
        }),
        signal: controller.signal // 关键:绑定取消信号
      });

      // 判断请求是否成功(status为200-299)
      if (!res.ok) {
        throw new Error(`请求失败,状态码:${res.status}`);
      }

      const data = await res.json();
      // 此时组件一定是挂载的(因为请求没被取消,说明组件没卸载)
      alert(`订单创建成功!订单ID:${data.orderId}`);
    } catch (err) {
      // 关键:区分“请求被取消”和“其他错误”
      if (err.name === 'AbortError') {
        // 请求被取消(组件卸载或主动取消),无需提示用户
        console.log('订单请求已被取消');
      } else {
        // 其他错误(网络错误、接口报错等),提示用户
        alert('订单创建失败,请重试!');
        console.error('订单创建失败:', err);
      }
    } finally {
      // 无论成功还是失败,都重置加载状态
      setLoading(false);
      // 清空ref中的控制器实例(避免内存泄漏)
      controllerRef.current = null;
    }
  };

  return (
    <button 
      onClick={}
      style={{ 
        padding: '12px 24px', 
        fontSize: '16px', 
        backgroundColor: '#ff4400', 
        color: '#fff', 
        border: 'none', 
        borderRadius: '8px',
        cursor: loading ? 'not-allowed' : 'pointer'
      }}
    >
      {loading ? '订单处理中...' : '立即购买'}
    
  );
}

Vue3版本(script setup,完整可运行)

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

const props = defineProps({
  goodsId: {
    type: String,
    required: true
  },
  goodsName: {
    type: String,
    required: true
  }
});

const loading = ref(false);
// 存储AbortController实例,无需用ref(Vue3的script setup中,普通变量即可存储,卸载时能访问到)
let controller = null;

// 组件卸载时,取消正在进行的请求
onUnmounted(() => {
  if (controller) {
    controller.abort();
    console.log('组件卸载,取消正在进行的订单请求');
  }
});

const handleCreateOrder = async () => {
  loading.value = true;
  // 1. 创建AbortController实例
  controller = new AbortController();

  try {
    // 2. 发起请求,绑定signal信号
    const res = await fetch('/api/order/create', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ 
        goodsId: props.goodsId, 
        goodsName: props.goodsName,
        userId: '123456'
      }),
      signal: controller.signal
    });

    if (!res.ok) {
      throw new Error(`请求失败,状态码:${res.status}`);
    }

    const data = await res.json();
    alert(`订单创建成功!订单ID:${data.orderId}`);
  } catch (err) {
    // 区分请求取消和其他错误
    if (err.name === 'AbortError') {
      console.log('订单请求已被取消');
    } else {
      alert('订单创建失败,请重试!');
      console.error('订单创建失败:', err);
    }
  } finally {
    loading.value = false;
    // 清空控制器实例,避免内存泄漏
    controller = null;
  }
};
</script>

<template>
  <button 
    @click="handleCreateOrder" 
    :disabled="loading"
    style="
      padding: 12px 24px;
      font-size: 16px;
      background-color: #ff4400;
      color: #fff;
      border: none;
      border-radius: 8px;
      cursor: loading ? 'not-allowed' : 'pointer'
    "
  >
    {{ loading ? '订单处理中...' : '立即购买' }}
  </button>
</template>

2. Axios请求:用AbortController取消(推荐)

很多项目中会用axios代替原生fetch(axios有拦截器、请求取消、响应处理等更强大的功能)。Axios从0.22.0版本开始,支持使用AbortController取消请求(旧版的CancelToken已废弃,不推荐使用),用法和fetch类似,我们直接上代码。

先确保你的axios版本≥0.22.0,安装命令:npm install axios@latest

React + Axios 版本

import { useState, useRef, useEffect } from 'react';
import axios from 'axios'; // 引入axios

function OrderButton({ goodsId, goodsName }) {
  const [loading, setLoading] = useState(false);
  const controllerRef = useRef(null);

  useEffect(() => {
    return () => {
      if (controllerRef.current) {
        // axios中,取消请求也是调用abort()方法
        controllerRef.current.abort();
        console.log('组件卸载,取消axios请求');
      }
    };
  }, []);

  const handleCreateOrder = async () => {
    setLoading(true);
    const controller = new AbortController();
    controllerRef.current = controller;

    try {
      // axios请求中,通过signal绑定取消信号
      const res = await axios.post(
        '/api/order/create',
        {
          goodsId: goodsId,
          goodsName: goodsName,
          userId: '123456'
        },
        {
          headers: {
            'Content-Type': 'application/json'
          },
          signal: controller.signal // 关键:绑定信号
        }
      );

      // axios会自动判断状态码,200-299会进入then,其他会进入catch
      alert(`订单创建成功!订单ID:${res.data.orderId}`);
    } catch (err) {
      // 区分请求取消和其他错误
      if (err.name === 'CanceledError') {
        // axios中,请求被取消的错误名称是CanceledError(注意和fetch的AbortError区分)
        console.log('axios请求已被取消');
      } else {
        alert('订单创建失败,请重试!');
        console.error('订单创建失败:', err);
      }
    } finally {
      setLoading(false);
      controllerRef.current = null;
    }
  };

  return (
    <button 
      onClick={
      disabled={loading}
      style={{ 
        padding: '12px 24px', 
        fontSize: '16px', 
        backgroundColor: '#ff4400', 
        color: '#fff', 
        border: 'none', 
        borderRadius: '8px',
        cursor: loading ? 'not-allowed' : 'pointer'
      }}
    >
      {loading ? '订单处理中...' : '立即购买'}
    
  );
}

Vue3 + Axios 版本

<script setup>
import { ref, onUnmounted } from 'vue';
import axios from 'axios';

const props = defineProps({
  goodsId: {
    type: String,
    required: true
  },
  goodsName: {
    type: String,
    required: true
  }
});

const loading = ref(false);
let controller = null;

onUnmounted(() => {
  if (controller) {
    controller.abort();
    console.log('组件卸载,取消axios请求');
  }
});

const handleCreateOrder = async () => {
  loading.value = true;
  controller = new AbortController();

  try {
    const res = await axios.post(
      '/api/order/create',
      {
        goodsId: props.goodsId,
        goodsName: props.goodsName,
        userId: '123456'
      },
      {
        headers: {
          'Content-Type': 'application/json'
        },
        signal: controller.signal
      }
    );

    alert(`订单创建成功!订单ID:${res.data.orderId}`);
  } catch (err) {
    if (err.name === 'CanceledError') {
      console.log('axios请求已被取消');
    } else {
      alert('订单创建失败,请重试!');
      console.error('订单创建失败:', err);
    }
  } finally {
    loading.value = false;
    controller = null;
  }
};
</script>

<template>
  <button 
    @click="handleCreateOrder" 
    :disabled="loading"
    style="
      padding: 12px 24px;
      font-size: 16px;
      background-color: #ff4400;
      color: #fff;
      border: none;
      border-radius: 8px;
      cursor: loading ? 'not-allowed' : 'pointer'
    "
  >
    {{ loading ? '订单处理中...' : '立即购买' }}
  </button>
</template>

3. 关键提醒:不可取消的请求,该怎么处理?

这里有一个非常重要的细节:不是所有请求都能取消。比如“创建订单”“支付”这类“写操作”,即使用户退出页面,也不应该取消请求——因为用户点击“立即购买”,大概率是真的想下单,只是手快退出了页面,如果取消请求,会导致用户以为下单成功,实际却失败,体验更差。

对于这类“不可取消的关键请求”,我们需要跳出“请求取消”的思路,进入第二层解决方案:跨页面状态同步,让用户不管在哪个页面,都能知道订单的处理状态。

四、第二层解决方案:跨页面状态同步,避免状态错乱

解决了“请求取消”的问题后,我们还要处理“状态同步”的问题:用户退出页面后,不可取消的请求仍在执行,如何让用户再次进入页面时,能看到之前的订单处理状态?如何避免用户重复提交?

这里我们提供3种方案,从简单到复杂,适配不同的项目场景(新手可以先从方案二入手,无需额外依赖;中高级开发者可以用方案一,更符合工业级规范)。

方案一:全局状态管理(React+Zustand / Vue3+Pinia)

对于中大型项目,推荐使用全局状态管理工具,将订单状态存储在全局,所有组件都能读取和修改,实现跨页面、跨组件的状态同步。这里我们分别用React的Zustand(轻量、简洁,比Redux简单)和Vue3的Pinia(官方推荐,替代Vuex)来实现。

1. React + Zustand 实现

先安装Zustand:npm install zustand

第一步:创建全局订单状态存储(store/order.js)

// store/order.js
import { create } from 'zustand';

// 创建全局订单状态,存储正在处理的订单、已完成的订单
const useOrderStore = create((set, get) => ({
  // 存储正在处理的订单,key为商品ID,value为订单详情
  pendingOrders: {}, // 格式:{ goodsId: { orderId: string, status: 'pending/success/failed', createdAt: number } }
  
  // 存储已完成的订单(可选,用于展示订单历史)
  completedOrders: [],

  // 方法1:标记订单开始处理(点击下单时调用)
  startOrder: (goodsId) => {
    // 生成临时订单ID(用于标识请求,接口返回后替换为真实订单ID)
    const tempOrderId = `temp_${goodsId}_${Date.now()}`;
    set((state) => ({
      pendingOrders: {
        ...state.pendingOrders,
        [goodsId]: {
          orderId: tempOrderId,
          status: 'pending', // pending:处理中,success:成功,failed:失败
          createdAt: Date.now() // 记录开始时间,用于后续超时处理
        }
      }
    }));
  },

  // 方法2:更新订单状态(请求成功/失败时调用)
  updateOrderStatus: (goodsId, status, realOrderId = '') => {
    set((state) => {
      // 获取当前正在处理的订单
      const currentOrder = state.pendingOrders[goodsId] || {};
      // 如果订单状态是success,添加到已完成订单列表
      const newCompletedOrders = status === 'success' 
        ? [...state.completedOrders, { ...currentOrder, orderId: realOrderId, status }]
        : state.completedOrders;
      
      return {
        pendingOrders: {
          ...state.pendingOrders,
          [goodsId]: {
            ...currentOrder,
            status,
            orderId: realOrderId || currentOrder.orderId // 替换为真实订单ID
          }
        },
        completedOrders: newCompletedOrders
      };
    });
  },

  // 方法3:清除单个订单的处理状态(可选,用于手动重置)
  clearOrder: (goodsId) => {
    set((state) => {
      const newPendingOrders = { ...state.pendingOrders };
      delete newPendingOrders[goodsId];
      return { pendingOrders: newPendingOrders };
    });
  },

  // 方法4:清除所有订单状态(可选,用于用户退出登录)
  clearAllOrders: () => {
    set(() => ({
      pendingOrders: {},
      completedOrders: []
    }));
  }
}));

export default useOrderStore;

第二步:在订单按钮组件中使用全局状态

import { useState, useRef, useEffect } from 'react';
import axios from 'axios';
import useOrderStore from './store/order'; // 引入全局状态

function OrderButton({ goodsId, goodsName }) {
  const [loading, setLoading] = useState(false);
  const controllerRef = useRef(null);
  // 从全局状态中读取当前商品的订单状态
  const { pendingOrders, startOrder, updateOrderStatus } = useOrderStore();
  const currentOrder = pendingOrders[goodsId]; // 当前商品的订单信息

  // 组件挂载时,判断是否有正在处理的订单,如果有,设置loading状态
  useEffect(() => {
    if (currentOrder?.status === 'pending') {
      setLoading(true);
    }
  }, [currentOrder]);

  // 组件卸载时,取消非关键请求(创建订单请求不取消,只取消轮询等辅助请求)
  useEffect(() => {
    return () => {
      if (controllerRef.current) {
        controllerRef.current.abort();
      }
    };
  }, []);

  const handleCreateOrder = async () => {
    // 如果有正在处理的订单,直接返回,防止重复提交
    if (currentOrder?.status === 'pending') {
      alert('订单正在处理中,请不要重复点击!');
      return;
    }

    // 如果订单已成功,提示用户不能重复购买
    if (currentOrder?.status === 'success') {
      alert('该商品已创建订单,请勿重复购买!');
      return;
    }

    setLoading(true);
    // 1. 标记订单开始处理,更新全局状态
    startOrder(goodsId);

    // 2. 发起创建订单请求(不可取消,因为是关键写操作)
    try {
      const res = await axios.post(
        '/api/order/create',
        {
          goodsId: goodsId,
          goodsName: goodsName,
          userId: '123456'
        },
        {
          headers: {
            'Content-Type': 'application/json'
          }
          // 不设置signal,不取消请求
        }
      );

      // 3. 请求成功,更新全局订单状态为success
      updateOrderStatus(goodsId, 'success', res.data.orderId);
      alert(`订单创建成功!订单ID:${res.data.orderId}`);
    } catch (err) {
      // 4. 请求失败,更新全局订单状态为failed
      updateOrderStatus(goodsId, 'failed');
      alert('订单创建失败,请重试!');
      console.error('订单创建失败:', err);
    } finally {
      setLoading(false);
    }
  };

  // 根据全局订单状态,渲染不同的按钮
  if (currentOrder?.status === 'success') {
    return (
      <button 
        disabled
 '12px 24px', 
          fontSize: '16px', 
          backgroundColor: '#999', 
          color: '#fff', 
          border: 'none', 
          borderRadius: '8px',
          cursor: 'not-allowed'
        }}
      >
        已下单,请勿重复购买
      
    );
  }

  return (
    <button 
      onClick={handleCreateOrder} 
      disabled={loading || currentOrder?.status === 'pending'}
      style={{ 
        padding: '12px 24px', 
        fontSize: '16px', 
        backgroundColor: '#ff4400', 
        color: '#fff', 
        border: 'none', 
        borderRadius: '8px',
        cursor: (loading || currentOrder?.status === 'pending') ? 'not-allowed' : 'pointer'
      }}
    >
      {loading || currentOrder?.status === 'pending' ? '订单处理中...' : '立即购买'}
    
  );
}

2. Vue3 + Pinia 实现

先安装Pinia:npm install pinia(Vue3项目必装,官方推荐)

第一步:创建全局订单状态存储(stores/order.js)

// stores/order.js
import { defineStore } from 'pinia';

// 定义全局订单状态
export const useOrderStore = defineStore('order', {
  state: () => ({
    // 正在处理的订单
    pendingOrders: {},
    // 已完成的订单
    completedOrders: []
  }),
  actions: {
    // 标记订单开始处理
    startOrder(goodsId) {
      const tempOrderId = `temp_${goodsId}_${Date.now()}`;
      this.pendingOrders[goodsId] = {
        orderId: tempOrderId,
        status: 'pending',
        createdAt: Date.now()
      };
    },
    // 更新订单状态
    updateOrderStatus(goodsId, status, realOrderId = '') {
      const currentOrder = this.pendingOrders[goodsId] || {};
      this.pendingOrders[goodsId] = {
        ...currentOrder,
        status,
        orderId: realOrderId || currentOrder.orderId
      };
      // 如果订单成功,添加到已完成列表
      if (status === 'success') {
        this.completedOrders.push({
          ...currentOrder,
          orderId: realOrderId,
          status
        });
      }
    },
    // 清除单个订单状态
    clearOrder(goodsId) {
      delete this.pendingOrders[goodsId];
    },
    // 清除所有订单状态
    clearAllOrders() {
      this.pendingOrders = {};
      this.completedOrders = [];
    }
  }
});

第二步:在订单按钮组件中使用全局状态

<script setup>
import { ref, onUnmounted, watch } from 'vue';
import axios from 'axios';
import { useOrderStore } from '@/stores/order'; // 引入Pinia状态

const props = defineProps({
  goodsId: {
    type: String,
    required: true
  },
  goodsName: {
    type: String,
    required: true
  }
});

const loading = ref(false);
const orderStore = useOrderStore(); // 实例化全局状态
let controller = null;

// 监听全局状态中当前商品的订单状态,同步loading
watch(
  () => orderStore.pendingOrders[props.goodsId],
  (newVal) => {
    if (newVal?.status === 'pending') {
      loading.value = true;
    } else {
      loading.value = false;
    }
  },
  { immediate: true } // 初始渲染时就执行
);

// 组件卸载时,取消非关键请求
onUnmounted(() => {
  if (controller) {
    controller.abort();
  }
});

const handleCreateOrder = async () => {
  const currentOrder = orderStore.pendingOrders[props.goodsId];

  // 防止重复提交
  if (currentOrder?.status === 'pending') {
    alert('订单正在处理中,请不要重复点击!');
    return;
  }

  // 防止重复购买
  if (currentOrder?.status === 'success') {
    alert('该商品已创建订单,请勿重复购买!');
    return;
  }

  // 标记订单开始处理
  orderStore.startOrder(props.goodsId);
  loading.value = true;

  try {
    const res = await axios.post(
      '/api/order/create',
      {
        goodsId: props.goodsId,
        goodsName: props.goodsName,
        userId: '123456'
      },
      {
        headers: {
          'Content-Type': 'application/json'
        }
      }
    );

    // 更新订单状态为成功
    orderStore.updateOrderStatus(props.goodsId, 'success', res.data.orderId);
    alert(`订单创建成功!订单ID:${res.data.orderId}`);
  } catch (err) {
    // 更新订单状态为失败
    orderStore.updateOrderStatus(props.goodsId, 'failed');
    alert('订单创建失败,请重试!');
    console.error('订单创建失败:', err);
  } finally {
    loading.value = false;
  }
};
</script>

<template>
  <button 
    @click="handleCreateOrder" 
    :disabled="loading || orderStore.pendingOrders[goodsId]?.status === 'pending'"
    v-if="orderStore.pendingOrders[goodsId]?.status !== 'success'"
    style="
      padding: 12px 24px;
      font-size: 16px;
      background-color: #ff4400;
      color: #fff;
      border: none;
      border-radius: 8px;
      cursor: (loading || orderStore.pendingOrders[goodsId]?.status === 'pending') ? 'not-allowed' : 'pointer'
    "
  >
    {{ loading || orderStore.pendingOrders[goodsId]?.status === 'pending' ? '订单处理中...' : '立即购买' }}
  </button>
  <button 
    disabled
    v-else
    style="
      padding: 12px 24px;
      font-size: 16px;
      background-color: #999;
      color: #fff;
      border: none;
      border-radius: 8px;
      cursor: 'not-allowed'
    "
  >
    已下单,请勿重复购买
  </button>
</template>

方案二:本地存储缓存(localStorage)

如果你的项目是小型项目,没有使用全局状态管理工具,也可以用localStorage来缓存订单状态——localStorage是浏览器的本地存储,持久化存储(刷新页面、关闭浏览器再打开,数据依然存在),可以实现跨页面状态同步。

这种方案的优点是:无需额外依赖,简单易用;缺点是:数据存储在客户端,有安全风险(不适合存储敏感信息,如用户ID、订单金额),且容量有限(约5MB)。

// 工具函数:封装localStorage操作(可单独放在utils/storage.js中)
export const orderStorage = {
  // 存储订单状态
  saveOrderStatus: (goodsId, status, orderId = '') => {
    // 读取本地已有的订单数据
    const orders = JSON.parse(localStorage.getItem('pendingOrders') || '{}');
    // 更新当前商品的订单状态
    orders[goodsId] = {
      status,
      orderId,
      createdAt: Date.now()
    };
    // 重新存储到localStorage
    localStorage.setItem('pendingOrders', JSON.stringify(orders));
  },

  // 读取单个商品的订单状态
  getOrderStatus: (goodsId) => {
    const orders = JSON.parse(localStorage.getItem('pendingOrders') || '{}');
    return orders[goodsId] || null;
  },

  // 清除单个商品的订单状态
  clearOrderStatus: (goodsId) => {
    const orders = JSON.parse(localStorage.getItem('pendingOrders') || '{}');
    delete orders[goodsId];
    localStorage.setItem('pendingOrders', JSON.stringify(orders));
  },

  // 清除所有订单状态
  clearAllOrderStatus: () => {
    localStorage.removeItem('pendingOrders');
  }
};

使用示例(React/Vue通用,以React为例):

import { useState, useEffect } from 'react';
import axios from 'axios';
import { orderStorage } from './utils/storage';

function OrderButton({ goodsId, goodsName }) {
  const [loading, setLoading] = useState(false);
  // 从localStorage读取当前商品的订单状态
  const [currentOrder, setCurrentOrder] = useState(orderStorage.getOrderStatus(goodsId));

  // 监听localStorage变化(防止其他页面修改后,当前页面状态不更新)
  useEffect(() => {
    const handleStorageChange = () => {
      const newOrder = orderStorage.getOrderStatus(goodsId);
      setCurrentOrder(newOrder);
      // 如果订单正在处理,设置loading为true
      if (newOrder?.status === 'pending') {
        setLoading(true);
      } else {
        setLoading(false);
      }
    };

    // 监听localStorage变化
    window.addEventListener('storage', handleStorageChange);
    // 组件卸载时移除监听
    return () => {
      window.removeEventListener('storage', handleStorageChange);
    };
  }, [goodsId]);

  const handleCreateOrder = async () => {
    if (currentOrder?.status === 'pending') {
      alert('订单正在处理中,请不要重复点击!');
      return;
    }

    if (currentOrder?.status === 'success') {
      alert('该商品已创建订单,请勿重复购买!');
      return;
    }

    setLoading(true);
    // 存储订单开始处理的状态
    orderStorage.saveOrderStatus(goodsId, 'pending');
    setCurrentOrder({ status: 'pending', orderId: `temp_${goodsId}_${Date.now()}`, createdAt: Date.now() });

    try {
      const res = await axios.post('/api/order/create', {
        goodsId,
        goodsName,
        userId: '123456'
      });

      // 存储订单成功状态
      orderStorage.saveOrderStatus(goodsId, 'success', res.data.orderId);
      setCurrentOrder({
        status: 'success',
        orderId: res.data.orderId,
        createdAt: Date.now()
      });
      alert(`订单创建成功!订单ID:${res.data.orderId}`);
    } catch (err) {
      // 存储订单失败状态
      orderStorage.saveOrderStatus(goodsId, 'failed');
      setCurrentOrder({
        status: 'failed',
        orderId: `temp_${goodsId}_${Date.now()}`,
        createdAt: Date.now()
      });
      alert('订单创建失败,请重试!');
    } finally {
      setLoading(false);
    }
  };

  // 渲染逻辑和之前一致,省略...
}

方案三:轮询/WebSocket 通知(高级方案)

对于核心业务场景(如支付、订单创建),我们还可以用“轮询”或“WebSocket”来实现跨页面的状态同步——用户退出页面后,请求仍在执行,执行完成后,通过轮询或WebSocket将结果推送给用户,即使用户不在原页面,也能收到通知。

这里我们以“轮询”为例(简单易用,无需额外配置),给出核心代码(React/Vue通用):

// 轮询订单状态的工具函数
const pollOrderStatus = async (orderId, goodsId, updateStatus) => {
  // 轮询间隔(2秒一次,可根据业务调整)
  const interval = 2000;
  // 最大轮询次数(防止无限轮询,如10次,共20秒)
  const maxTimes = 10;
  let times = 0;

  const poll = async () => {
    if (times >= maxTimes) {
      // 超过最大次数,停止轮询,标记为失败
      updateStatus(goodsId, 'failed');
      showGlobalToast('订单处理超时,请重试!');
      return;
    }

    try {
      // 发起轮询请求,查询订单状态
      const res = await axios.get(`/api/order/status?orderId=${orderId}`);
      const { status, realOrderId } = res.data;

      if (status === 'success') {
        // 订单成功,停止轮询,更新状态
        updateStatus(goodsId, 'success', realOrderId);
        showGlobalToast(`订单创建成功!订单ID:${realOrderId}`);
        clearInterval(timer);
      } else if (status === 'failed') {
        // 订单失败,停止轮询
        updateStatus(goodsId, 'failed');
        showGlobalToast('订单创建失败,请重试!');
        clearInterval(timer);
      } else {
        // 订单仍在处理,继续轮询
        times++;
      }
    } catch (err) {
      console.error('轮询订单状态失败:', err);
      times++;
    }
  };

  // 开始轮询
  const timer = setInterval(poll, interval);
  // 首次立即执行一次
  poll();

  // 返回清除定时器的方法,方便组件卸载时停止轮询
  return () => clearInterval(timer);
};

// 在订单按钮组件中使用
const handleCreateOrder = async () => {
  // ... 省略之前的逻辑 ...

  try {
    const res = await axios.post('/api/order/create', { goodsId, goodsName, userId: '123456' });
    const { tempOrderId } = res.data; // 接口返回临时订单ID,用于轮询

    // 开始轮询订单状态
    const stopPoll = pollOrderStatus(tempOrderId, goodsId, updateOrderStatus);

    // 组件卸载时停止轮询
    useEffect(() => {
      return () => {
        stopPoll();
      };
    }, [stopPoll]);
  } catch (err) {
    // ... 省略错误处理 ...
  }
};

说明:WebSocket比轮询更高效(实时推送,无需频繁发起请求),适合对实时性要求高的场景(如支付状态同步),但需要后端配合实现WebSocket服务,这里就不展开讲解了,感兴趣的可以留言讨论。

五、第三层解决方案:用户体验兜底,避免重复提交和体验割裂

有了“请求取消”和“跨页面状态同步”,我们还需要做一层“用户体验兜底”——即使前面的逻辑都做好了,也要考虑用户的误操作、网络异常等情况,避免用户重复提交、收不到反馈。

这里我们给出3个核心的兜底方案,全部整合到代码中,让你的订单按钮更健壮、更友好。

1. 加载状态固化:按钮禁用+状态提示

用户点击“立即购买”后,立即禁用按钮,并显示“订单处理中...”,无论用户是否刷新页面、切换页面,再次进入时,都能从全局状态/localStorage中读取到“pending”状态,继续显示“订单处理中...”,避免用户重复点击。

核心逻辑:按钮的disabled属性,同时绑定loading状态和全局订单的pending状态(如:disabled={loading || currentOrder?.status === 'pending'})。

2. 全局Toast通知:跨页面的结果反馈

用户退出页面后,请求如果成功或失败,需要通过“全局Toast”(不依赖当前组件)提示用户,避免用户收不到反馈。比如:用户退出商品页后,订单创建成功,全局Toast弹出“订单创建成功,可在订单列表查看”,用户无论在哪个页面,都能看到。

这里我们用一个简单的全局Toast工具函数(React/Vue通用):

// utils/toast.js
export const showGlobalToast = (message, duration = 3000) => {
  // 创建Toast元素
  const toast = document.createElement('div');
  toast.style.cssText = `
    position: fixed;
    top: 20px;
    left: 50%;
    transform: translateX(-50%);
    padding: 12px 24px;
    background-color: rgba(0, 0, 0, 0.7);
    color: #fff;
    border-radius: 8px;
    z-index: 9999;
    font-size: 14px;
  `;
  toast.innerText = message;

  // 添加到页面
  document.body.appendChild(toast);

  // 定时移除
  setTimeout(() => {
    toast.style.opacity = '0';
    toast.style.transition = 'opacity 0.3s';
    setTimeout(() => {
      document.body.removeChild(toast);
    }, 300);
  }, duration);
};

使用方法:在订单请求成功、失败,或轮询结果返回时,调用showGlobalToast即可,比如:showGlobalToast('订单创建成功!')。

3. 防重复提交:请求ID去重

即使做了按钮禁用和状态同步,依然可能出现极端情况:网络延迟过高,用户快速点击两次按钮,两次请求同时发起(按钮禁用的状态还没来得及更新),最终导致重复下单。这时候,就需要用“请求ID去重”来兜底,从接口层面和前端层面双重防重复。

核心思路:每次发起订单请求时,生成一个唯一的请求ID(可结合用户ID、商品ID、时间戳生成),并将这个请求ID存储到全局状态或localStorage中;发起请求时,将请求ID携带在请求参数中;前端层面,判断当前请求ID是否已存在,存在则不发起新请求;后端层面,接收请求ID,判断该请求ID是否已处理过,已处理则直接返回成功,不重复执行订单创建逻辑。

前端实现代码(React/Vue通用,以React+Zustand为例):

import { useState, useRef, useEffect } from 'react';
import axios from 'axios';
import useOrderStore from './store/order';
import { showGlobalToast } from './utils/toast';

// 生成唯一请求ID(结合用户ID、商品ID、时间戳,确保唯一性)
const generateRequestId = (userId, goodsId) => {
  return `req_${userId}_${goodsId}_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
};

function OrderButton({ goodsId, goodsName, userId = '123456' }) {
  const [loading, setLoading] = useState(false);
  const controllerRef = useRef(null);
  const { pendingOrders, startOrder, updateOrderStatus } = useOrderStore();
  const currentOrder = pendingOrders[goodsId];
  // 存储当前正在发起的请求ID,用于前端去重
  const currentRequestId = useRef(null);

  // 组件挂载时同步状态
  useEffect(() => {
    if (currentOrder?.status === 'pending') {
      setLoading(true);
      // 恢复当前请求ID(避免页面刷新后,请求ID丢失导致无法去重)
      currentRequestId.current = currentOrder.requestId;
    }
  }, [currentOrder]);

  // 组件卸载时清理
  useEffect(() => {
    return () => {
      if (controllerRef.current) {
        controllerRef.current.abort();
      }
    };
  }, []);

  const handleCreateOrder = async () => {
    // 1. 基础防重复:判断是否有正在处理的订单
    if (currentOrder?.status === 'pending') {
      showGlobalToast('订单正在处理中,请不要重复点击!');
      return;
    }
    // 2. 基础防重复:判断是否已下单成功
    if (currentOrder?.status === 'success') {
      showGlobalToast('该商品已创建订单,请勿重复购买!');
      return;
    }

    // 3. 请求ID去重:生成并判断当前请求ID是否已存在
    const requestId = generateRequestId(userId, goodsId);
    // 若当前已有正在发起的请求(未完成),直接拦截
    if (currentRequestId.current && currentRequestId.current !== requestId) {
      showGlobalToast('请勿重复发起请求,请稍候!');
      return;
    }

    setLoading(true);
    currentRequestId.current = requestId;
    // 4. 标记订单开始处理,同时存储请求ID到全局状态
    startOrder(goodsId, requestId); // 需修改Zustand的startOrder方法,新增requestId参数

    try {
      const res = await axios.post(
        '/api/order/create',
        {
          goodsId: goodsId,
          goodsName: goodsName,
          userId: userId,
          requestId: requestId // 关键:将请求ID携带到接口参数中,供后端去重
        },
        {
          headers: {
            'Content-Type': 'application/json'
          }
        }
      );

      // 请求成功,更新全局状态,清空请求ID
      updateOrderStatus(goodsId, 'success', res.data.orderId);
      showGlobalToast(`订单创建成功!订单ID:${res.data.orderId}`);
      currentRequestId.current = null;
    } catch (err) {
      // 区分请求取消、重复请求、其他错误
      if (err.name === 'CanceledError') {
        console.log('请求已被取消');
      } else if (err.response?.data?.msg === '重复请求') {
        // 后端返回重复请求提示,同步更新状态
        showGlobalToast('已收到您的请求,正在处理中,请稍候!');
        updateOrderStatus(goodsId, 'pending', currentOrder?.orderId);
      } else {
        showGlobalToast('订单创建失败,请重试!');
        updateOrderStatus(goodsId, 'failed');
        console.error('订单创建失败:', err);
      }
      // 错误情况下,清空请求ID,允许重新发起请求
      currentRequestId.current = null;
    } finally {
      setLoading(false);
    }
  };

  // 同步修改Zustand的startOrder方法(补充requestId存储)
  // 打开store/order.js,修改startOrder:
  // startOrder: (goodsId, requestId) => {
  //   const tempOrderId = `temp_${goodsId}_${Date.now()}`;
  //   set((state) => ({
  //     pendingOrders: {
  //       ...state.pendingOrders,
  //       [goodsId]: {
  //         orderId: tempOrderId,
  //         status: 'pending',
  //         createdAt: Date.now(),
  //         requestId: requestId // 新增:存储请求ID,用于页面刷新后恢复
  //       }
  //     }
  //   }));
  // },

  // 渲染逻辑(与前文一致,略作补充)
  if (currentOrder?.status === 'success') {
    return (
      <button 
        disabled
        style={{ 
          padding: '12px 24px', 
          fontSize: '16px', 
          backgroundColor: '#999', 
          color: '#fff', 
          border: 'none', 
          borderRadius: '8px',
          cursor: 'not-allowed'
        }}
      >
        已下单,请勿重复购买
      </button>
    );
  }

  return (
    <button 
      onClick={handleCreateOrder} 
      disabled={loading || currentOrder?.status === 'pending'}
      style={{ 
        padding: '12px 24px', 
        fontSize: '16px', 
        backgroundColor: '#ff4400', 
        color: '#fff', 
        border: 'none', 
        borderRadius: '8px',
        cursor: (loading || currentOrder?.status === 'pending') ? 'not-allowed' : 'pointer'
      }}
    >
      {loading || currentOrder?.status === 'pending' ? '订单处理中...' : '立即购买'}
    </button>
  );
}

后端配合逻辑(简单示例,适配前端请求ID去重):

后端需要维护一个“请求ID缓存池”(可用Redis、内存缓存等),用于存储已接收、未处理完成的请求ID,核心逻辑如下:

  1. 接收前端传递的requestId、goodsId、userId等参数;
  2. 判断缓存中是否存在该requestId:若存在:说明是重复请求,直接返回“重复请求”提示,不执行订单创建逻辑;
  3. 若不存在:将requestId存入缓存(设置过期时间,如30秒,避免缓存堆积),执行订单创建逻辑;
  4. 订单创建完成(成功/失败)后,删除缓存中的requestId,允许后续重新发起请求(如失败后重试)。

示例代码(Node.js + Express,简化版):

// 模拟缓存池(实际项目用Redis更可靠)
const requestCache = new Map();

// 创建订单接口
app.post('/api/order/create', async (req, res) => {
  const { goodsId, goodsName, userId, requestId } = req.body;

  // 1. 请求ID去重判断
  if (requestCache.has(requestId)) {
    return res.status(400).json({ msg: '重复请求', code: 400 });
  }

  try {
    // 2. 存入缓存,设置30秒过期(避免缓存堆积)
    requestCache.set(requestId, true);
    setTimeout(() => {
      requestCache.delete(requestId);
    }, 30000);

    // 3. 执行订单创建逻辑(数据库操作等)
    const orderId = `order_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
    // 模拟数据库存储订单...

    // 4. 订单创建成功,删除缓存
    requestCache.delete(requestId);
    res.status(200).json({ msg: '订单创建成功', code: 200, orderId });
  } catch (err) {
    // 5. 订单创建失败,删除缓存,允许重试
    requestCache.delete(requestId);
    res.status(500).json({ msg: '订单创建失败', code: 500 });
  }
});

4. 第三层方案总结

用户体验兜底的核心是“堵漏洞、给反馈”——加载状态固化解决“用户看不到处理进度”的问题,全局Toast解决“跨页面反馈缺失”的问题,请求ID去重解决“极端情况下的重复提交”问题。这三层兜底逻辑,配合前文的“请求取消”和“跨页面状态同步”,形成完整的闭环,彻底解决订单场景的“幽灵陷阱”。

需要注意的是:兜底方案不是“多余的”,而是工业级项目的“必备项”。前端开发不能只追求“功能能用”,更要追求“用户用得放心、用得舒心”,尤其是订单、支付等核心场景,一个小小的体验优化,就能减少大量的用户投诉和客服成本。

六、完整代码整合与项目适配建议

为了方便大家直接将代码应用到实际项目中,这里我们整合React+Zustand+Axios和Vue3+Pinia+Axios的完整可运行代码,并给出项目适配的关键建议,新手也能快速上手。

1. React 完整项目代码整合(推荐中大型项目)

整合后包含3个核心文件:全局状态存储(store/order.js)、订单按钮组件(components/OrderButton.jsx)、工具函数(utils/toast.js),代码可直接复制使用,注释完整。

// 1. store/order.js(Zustand全局状态)
import { create } from 'zustand';

const useOrderStore = create((set) => ({
  pendingOrders: {}, // { goodsId: { orderId, status, createdAt, requestId } }
  completedOrders: [],
  // 标记订单开始处理(新增requestId参数)
  startOrder: (goodsId, requestId) => {
    const tempOrderId = `temp_${goodsId}_${Date.now()}`;
    set((state) => ({
      pendingOrders: {
        ...state.pendingOrders,
        [goodsId]: {
          orderId: tempOrderId,
          status: 'pending',
          createdAt: Date.now(),
          requestId: requestId
        }
      }
    }));
  },
  // 更新订单状态
  updateOrderStatus: (goodsId, status, realOrderId = '') => {
    set((state) => {
      const currentOrder = state.pendingOrders[goodsId] || {};
      const newCompletedOrders = status === 'success'
        ? [...state.completedOrders, { ...currentOrder, orderId: realOrderId, status }]
        : state.completedOrders;
      return {
        pendingOrders: {
          ...state.pendingOrders,
          [goodsId]: {
            ...currentOrder,
            status,
            orderId: realOrderId || currentOrder.orderId
          }
        },
        completedOrders: newCompletedOrders
      };
    });
  },
  clearOrder: (goodsId) => {
    set((state) => {
      const newPendingOrders = { ...state.pendingOrders };
      delete newPendingOrders[goodsId];
      return { pendingOrders: newPendingOrders };
    });
  },
  clearAllOrders: () => {
    set(() => ({ pendingOrders: {}, completedOrders: [] }));
  }
}));

export default useOrderStore;

// 2. utils/toast.js(全局Toast工具)
export const showGlobalToast = (message, duration = 3000) => {
  const toast = document.createElement('div');
  toast.style.cssText = `
    position: fixed;
    top: 20px;
    left: 50%;
    transform: translateX(-50%);
    padding: 12px 24px;
    background-color: rgba(0, 0, 0, 0.7);
    color: #fff;
    border-radius: 8px;
    z-index: 9999;
    font-size: 14px;
  `;
  toast.innerText = message;
  document.body.appendChild(toast);

  setTimeout(() => {
    toast.style.opacity = '0';
    toast.style.transition = 'opacity 0.3s';
    setTimeout(() => {
      document.body.removeChild(toast);
    }, 300);
  }, duration);
};

// 3. components/OrderButton.jsx(订单按钮组件)
import { useState, useRef, useEffect } from 'react';
import axios from 'axios';
import useOrderStore from '../store/order';
import { showGlobalToast } from '../utils/toast';

// 生成唯一请求ID
const generateRequestId = (userId, goodsId) => {
  return `req_${userId}_${goodsId}_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
};

function OrderButton({ goodsId, goodsName, userId = '123456' }) {
  const [loading, setLoading] = useState(false);
  const controllerRef = useRef(null);
  const { pendingOrders, startOrder, updateOrderStatus } = useOrderStore();
  const currentOrder = pendingOrders[goodsId];
  const currentRequestId = useRef(null);

  // 组件挂载时同步状态和请求ID
  useEffect(() => {
    if (currentOrder?.status === 'pending') {
      setLoading(true);
      currentRequestId.current = currentOrder.requestId;
    }
  }, [currentOrder]);

  // 组件卸载时取消非关键请求
  useEffect(() => {
    return () => {
      if (controllerRef.current) {
        controllerRef.current.abort();
      }
    };
  }, []);

  const handleCreateOrder = async () => {
    // 基础防重复
    if (currentOrder?.status === 'pending') {
      showGlobalToast('订单正在处理中,请不要重复点击!');
      return;
    }
    if (currentOrder?.status === 'success') {
      showGlobalToast('该商品已创建订单,请勿重复购买!');
      return;
    }

    // 请求ID去重
    const requestId = generateRequestId(userId, goodsId);
    if (currentRequestId.current && currentRequestId.current !== requestId) {
      showGlobalToast('请勿重复发起请求,请稍候!');
      return;
    }

    setLoading(true);
    currentRequestId.current = requestId;
    startOrder(goodsId, requestId);

    try {
      const res = await axios.post(
        '/api/order/create',
        { goodsId, goodsName, userId, requestId },
        { headers: { 'Content-Type': 'application/json' } }
      );

      updateOrderStatus(goodsId, 'success', res.data.orderId);
      showGlobalToast(`订单创建成功!订单ID:${res.data.orderId}`);
      currentRequestId.current = null;
    } catch (err) {
      if (err.name === 'CanceledError') {
        console.log('请求已被取消');
      } else if (err.response?.data?.msg === '重复请求') {
        showGlobalToast('已收到您的请求,正在处理中,请稍候!');
        updateOrderStatus(goodsId, 'pending', currentOrder?.orderId);
      } else {
        showGlobalToast('订单创建失败,请重试!');
        updateOrderStatus(goodsId, 'failed');
        console.error('订单创建失败:', err);
      }
      currentRequestId.current = null;
    } finally {
      setLoading(false);
    }
  };

  if (currentOrder?.status === 'success') {
    return (
      <button disabled style={{
        padding: '12px 24px',
        fontSize: '16px',
        backgroundColor: '#999',
        color: '#fff',
        border: 'none',
        borderRadius: '8px',
        cursor: 'not-allowed'
      }}>
        已下单,请勿重复购买
      </button>
    );
  }

  return (
    <button
      onClick={handleCreateOrder}
      disabled={loading || currentOrder?.status === 'pending'}
      style={{
        padding: '12px 24px',
        fontSize: '16px',
        backgroundColor: '#ff4400',
        color: '#fff',
        border: 'none',
        borderRadius: '8px',
        cursor: (loading || currentOrder?.status === 'pending') ? 'not-allowed' : 'pointer'
      }}
    >
      {loading || currentOrder?.status === 'pending' ? '订单处理中...' : '立即购买'}
    </button>
  );
}

export default OrderButton;

2. Vue3 完整项目代码整合(推荐中大型项目)

整合后包含3个核心文件:全局状态存储(stores/order.js)、订单按钮组件(components/OrderButton.vue)、工具函数(utils/toast.js),适配script setup语法,代码可直接复制使用。

<!-- 1. components/OrderButton.vue(订单按钮组件) -->
<script setup>
import { ref, onUnmounted, watch } from 'vue';
import axios from 'axios';
import { useOrderStore } from '@/stores/order';
import { showGlobalToast } from '@/utils/toast';

// 生成唯一请求ID
const generateRequestId = (userId, goodsId) => {
  return `req_${userId}_${goodsId}_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
};

// 接收父组件参数
const props = defineProps({
  goodsId: { type: String, required: true },
  goodsName: { type: String, required: true },
  userId: { type: String, default: '123456' }
});

const loading = ref(false);
const orderStore = useOrderStore();
let controller = null;
const currentRequestId = ref(null);

// 监听全局订单状态,同步loading和请求ID
watch(
  () => orderStore.pendingOrders[props.goodsId],
  (newVal) => {
    if (newVal?.status === 'pending') {
      loading.value = true;
      currentRequestId.value = newVal.requestId;
    } else {
      loading.value = false;
    }
  },
  { immediate: true }
);

// 组件卸载时清理
onUnmounted(() => {
  if (controller) {
    controller.abort();
  }
});

const handleCreateOrder = async () => {
  const currentOrder = orderStore.pendingOrders[props.goodsId];
  // 基础防重复
  if (currentOrder?.status === 'pending') {
    showGlobalToast('订单正在处理中,请不要重复点击!');
    return;
  }
  if (currentOrder?.status === 'success') {
    showGlobalToast('该商品已创建订单,请勿重复购买!');
    return;
  }

  // 请求ID去重
  const requestId = generateRequestId(props.userId, props.goodsId);
  if (currentRequestId.value && currentRequestId.value !== requestId) {
    showGlobalToast('请勿重复发起请求,请稍候!');
    return;
  }

  loading.value = true;
  currentRequestId.value = requestId;
  orderStore.startOrder(props.goodsId, requestId);

  try {
    const res = await axios.post(
      '/api/order/create',
      {
        goodsId: props.goodsId,
        goodsName: props.goodsName,
        userId: props.userId,
        requestId: requestId
      },
      { headers: { 'Content-Type': 'application/json' } }
    );

    orderStore.updateOrderStatus(props.goodsId, 'success', res.data.orderId);
    showGlobalToast(`订单创建成功!订单ID:${res.data.orderId}`);
    currentRequestId.value = null;
  } catch (err) {
    if (err.name === 'CanceledError') {
      console.log('请求已被取消');
    } else if (err.response?.data?.msg === '重复请求') {
      showGlobalToast('已收到您的请求,正在处理中,请稍候!');
      orderStore.updateOrderStatus(props.goodsId, 'pending', currentOrder?.orderId);
    } else {
      showGlobalToast('订单创建失败,请重试!');
      orderStore.updateOrderStatus(props.goodsId, 'failed');
      console.error('订单创建失败:', err);
    }
    currentRequestId.value = null;
  } finally {
    loading.value = false;
  }
};
</script>

<template>
  <button
    @click="handleCreateOrder"
    :disabled="loading || orderStore.pendingOrders[goodsId]?.status === 'pending'"
    v-if="orderStore.pendingOrders[goodsId]?.status !== 'success'"
    style="
      padding: 12px 24px;
      font-size: 16px;
      background-color: #ff4400;
      color: #fff;
      border: none;
      border-radius: 8px;
      cursor: (loading || orderStore.pendingOrders[goodsId]?.status === 'pending') ? 'not-allowed' : 'pointer';
    "
  >
    {{ loading || orderStore.pendingOrders[goodsId]?.status === 'pending' ? '订单处理中...' : '立即购买' }}
  </button>
  <button
    disabled
    v-else
    style="
      padding: 12px 24px;
      font-size: 16px;
      background-color: #999;
      color: #fff;
      border: none;
      border-radius: 8px;
      cursor: 'not-allowed';
    "
  >
    已下单,请勿重复购买
  </button>
</template>

// 2. stores/order.js(Pinia全局状态)
import { defineStore } from 'pinia';

export const useOrderStore = defineStore('order', {
  state: () => ({
    pendingOrders: {},
    completedOrders: []
  }),
  actions: {
    // 新增requestId参数,存储请求ID
    startOrder(goodsId, requestId) {
      const tempOrderId = `temp_${goodsId}_${Date.now()}`;
      this.pendingOrders[goodsId] = {
        orderId: tempOrderId,
        status: 'pending',
        createdAt: Date.now(),
        requestId: requestId
      };
    },
    updateOrderStatus(goodsId, status, realOrderId = '') {
      const currentOrder = this.pendingOrders[goodsId] || {};
      this.pendingOrders[goodsId] = {
        ...currentOrder,
        status,
        orderId: realOrderId || currentOrder.orderId
      };
      if (status === 'success') {
        this.completedOrders.push({
          ...currentOrder,
          orderId: realOrderId,
          status
        });
      }
    },
    clearOrder(goodsId) {
      delete this.pendingOrders[goodsId];
    },
    clearAllOrders() {
      this.pendingOrders = {};
      this.completedOrders = [];
    }
  }
});

// 3. utils/toast.js(全局Toast工具,与React版本一致)
export const showGlobalToast = (message, duration = 3000) => {
  const toast = document.createElement('div');
  toast.style.cssText = `
    position: fixed;
    top: 20px;
    left: 50%;
    transform: translateX(-50%);
    padding: 12px 24px;
    background-color: rgba(0, 0, 0, 0.7);
    color: #fff;
    border-radius: 8px;
    z-index: 9999;
    font-size: 14px;
  `;
  toast.innerText = message;
  document.body.appendChild(toast);

  setTimeout(() => {
    toast.style.opacity = '0';
    toast.style.transition = 'opacity 0.3s';
    setTimeout(() => {
      document.body.removeChild(toast);
    }, 300);
  }, duration);
};

3. 项目适配关键建议

无论你使用React还是Vue3,将代码应用到实际项目时,需要注意以下3点,避免踩坑:

(1)请求取消的场景区分:创建订单、支付等“写操作”,不建议取消请求(防止用户误操作退出后,订单创建失败);轮询、列表查询等“读操作”,建议在组件卸载时取消请求(节省资源)。

(2)状态清理:用户退出登录时,需调用全局状态的clearAllOrders方法(或localStorage的clearAllOrderStatus方法),清除所有订单状态,避免切换用户后,状态错乱。

(3)过期处理:对于pending状态的订单,建议添加“超时处理”(如1分钟未完成,自动标记为failed),避免订单状态长期处于pending,导致用户无法重试。可在全局状态中添加定时任务,定期清理过期订单。

七、总结:高级前端的异步处理思维

回到文章开头的问题:为什么高级前端不用isMounted?因为高级前端的核心思维是“从根源解决问题”,而不是“打补丁”。

本文通过“三层递进解决方案”,彻底解决订单场景的“组件卸载后异步操作”幽灵陷阱,核心逻辑可总结为:

  1. 第一层:请求取消——用AbortController终止异步操作,从根源上避免“组件卸载后请求仍在执行”;
  2. 第二层:跨页面状态同步——用全局状态(Zustand/Pinia)或本地存储(localStorage),解决“状态错乱、重复提交”;
  3. 第三层:用户体验兜底——用加载状态固化、全局Toast、请求ID去重,解决“体验割裂、极端重复提交”。

这三层逻辑,不仅适用于订单场景,也适用于表单提交、支付、接口请求等所有涉及异步操作的场景。文中的代码可直接复制到项目中使用,新手可以先套用代码,再理解背后的逻辑;中高级开发者可以根据项目规模,灵活选择状态管理方案(小型项目用localStorage,中大型项目用Zustand/Pinia)。

最后提醒:前端开发,细节决定成败。一个小小的异步处理漏洞,可能引发线上故障;而一套完善的异步处理逻辑,不仅能避免故障,还能提升用户体验,体现你的专业性。希望本文能帮你跳出isMounted的思维误区,写出更健壮、更优雅的前端代码。

(全文完,感谢阅读!如果觉得有用,欢迎点赞、收藏、转发,如有疑问或补充,欢迎在评论区留言讨论。)

手把手教你玩转HDS沉浸光感效果

鸿蒙开发干货——手把手教你玩转HDS沉浸光感效果

大家好,我是青蓝逐码的云杰。

最近有不少用户在交流时间到,应用底部 Tab 栏那种高级的“发光”和“沉浸”质感是怎么做出来的?

用户交流截图

用户交流截图

用户交流截图

在鸿蒙应用开发中,细腻的光影和材质表现确实是提升 UI 质感、打造沉浸式体验的关键一环。无论是底部 Tab 栏的毛玻璃光感,还是顶部导航栏的高级光效,掌握系统级材质能力都能让你的应用瞬间“高大上”。本文将带你解锁鸿蒙开发中 HDS(HarmonyOS Design System)沉浸光感材质 的全流程技巧,附完整代码示例和降级适配指南,让你的应用轻松玩转高级光影效果。

一、沉浸光感是什么?

从 HarmonyOS 6.1.0(23) 版本开始,@kit.UIDesignKit 为 HDS 组件引入了强大的 systemMaterialEffect(系统材质效果)能力。其中最吸引人的就是沉浸光感(IMMERSIVE)

与传统的纯色或简单的毛玻璃不同,沉浸光感会在组件内部模拟真实的物理光照模型。当你点击或与之交互时,会产生细腻的“光晕”和“反射”反馈,极大地增强了控件的立体感和触控反馈的真实度。

先来看看最终的实现效果:

沉浸光感效果图

沉浸光感效果图

目前,这套能力主要应用于两大核心组件:

  1. HdsNavigation:可为标题栏(TitleBar)设置沉浸光感。
  2. HdsTabs:可为底部的悬浮页签(TabBar)设置沉浸光感。

二、实战演练:为底部悬浮页签添加沉浸光感

接下来,我们以底部的 HdsTabs 为例,一步步实现沉浸光感效果。

1. 基础配置与准备

首先,确保你的工程引入了 UIDesignKit 相关的模块。我们需要 HdsTabsHdsTabsController 以及 hdsMaterial

import { hdsMaterial, HdsTabs, HdsTabsController } from "@kit.UIDesignKit";
import { SymbolGlyphModifier } from "@kit.ArkUI";

同时,我们定义好 Tab 栏的菜单配置(使用系统 Symbol 图标,支持多色渲染):

interface MenuItem {
  symbolGlyph: SymbolGlyphModifier;
  symbolGlyph1: SymbolGlyphModifier;
  label: string;
}

const MENU_CONFIG: MenuItem[] = [
  {
    symbolGlyph: new SymbolGlyphModifier($r("sys.symbol.clock"))
      .renderingStrategy(SymbolRenderingStrategy.MULTIPLE_COLOR)
      .fontColor([
        $r("sys.color.ohos_id_color_bottom_tab_icon_off"),
        $r("sys.color.ohos_id_color_bottom_tab_icon_auxcolor_off02"),
      ]),
    symbolGlyph1: new SymbolGlyphModifier($r("sys.symbol.clock_fill"))
      .renderingStrategy(SymbolRenderingStrategy.MULTIPLE_COLOR)
      .fontColor([
        $r("app.color.primary_blue"),
        $r("sys.color.ohos_id_color_primary_contrary"),
      ]),
    label: "待取",
  },
  // ... 其他 Tab 项配置
];

2. 核心方案一:使用系统自适应沉浸光感(官方推荐)

在绝大多数场景下,我们推荐使用 ADAPTIVE(自适应)模式。系统会根据当前设备的算力和性能状态,自动为你选择最佳的光效表现,保证流畅度的同时达到最优的视觉效果。

@Entry
@Component
struct Index {
  private hdsTabsController: HdsTabsController = new HdsTabsController();

  build() {
    HdsTabs({ controller: this.hdsTabsController }) {
      ForEach(MENU_CONFIG, (item: MenuItem, index: number) => {
        TabContent() {
          // 这里放你的页面内容,比如 PackagesPage()
        }
        .tabBar(new BottomTabBarStyle({
          normal: item.symbolGlyph,
          selected: item.symbolGlyph1
        }, item.label).labelStyle({
          selectedColor: $r('app.color.primary_blue') // 设置文字高亮色
        }))
      })
    }
    .barOverlap(true) // 允许内容延伸到 Tab 栏底部
    .barPosition(BarPosition.End)
    // 核心配置:开启悬浮样式并设置自适应材质
    .barFloatingStyle({
      barBottomMargin: 28,
      systemMaterialEffect: {
        materialType: hdsMaterial.MaterialType.ADAPTIVE,
        materialLevel: hdsMaterial.MaterialLevel.ADAPTIVE
      }
    })
  }
}

在这段代码中,最核心的就是 barFloatingStyle 里的 systemMaterialEffect 属性。我们将类型和级别都交给了系统 ADAPTIVE 去决策。

3. 核心方案二:强制自定义沉浸光感效果与降级处理

如果你对视觉有极高的要求,希望在支持的设备上强制开启最顶级的沉浸光效(例如点击时出现明显的“白光”反馈),你可以手动指定材质类型和级别。

避坑指南:并不是所有设备都支持高级的沉浸光感(IMMERSIVE)。如果强行在低端设备上开启,可能会导致严重的卡顿和发热。因此,必须先通过 API 查询设备能力,再进行优雅降级

import { hdsMaterial, HdsTabs, HdsTabsController } from '@kit.UIDesignKit';

@Entry
@Component
struct Index {
  private hdsTabsController: HdsTabsController = new HdsTabsController();
  // 默认我们想用最精致的效果
  @State customMaterialLevel: hdsMaterial.MaterialLevel = hdsMaterial.MaterialLevel.EXQUISITE;
  @State customMaterialType: hdsMaterial.MaterialType = hdsMaterial.MaterialType.IMMERSIVE;

  aboutToAppear(): void {
    // 1. 查询当前设备支持的系统材质类型
    let materialTypes: Array<hdsMaterial.MaterialType> = hdsMaterial.getSystemMaterialTypes();

    // 2. 检查是否支持 IMMERSIVE (沉浸光感)
    if (materialTypes.indexOf(hdsMaterial.MaterialType.IMMERSIVE) < 0) {
      // 3. 如果不支持,进行降级处理,使用基础的背景模糊平滑效果
      this.customMaterialType = hdsMaterial.MaterialType.BACKGROUND_BLUR;
      this.customMaterialLevel = hdsMaterial.MaterialLevel.SMOOTH;
    }
  }

  build() {
    HdsTabs({ controller: this.hdsTabsController }) {
      // ... TabContent 配置与之前相同
    }
    .barFloatingStyle({
      barBottomMargin: 28,
      systemMaterialEffect: {
        materialType: this.customMaterialType, // 应用查询后的类型
        materialLevel: this.customMaterialLevel // 应用查询后的级别
      }
    })
  }
}

关于材质级别的选择

  • EXQUISITE (精致):光影反馈最强烈、最细腻(比如点击时有明显点光源照射的高亮白光),但对性能要求最高。
  • GENTLE (柔和):光影反馈相对柔和内敛。
  • SMOOTH (平滑):性能开销最低,通常用于不支持复杂光照的降级场景。

三、总结

通过上述两种方案,我们可以非常轻松地为鸿蒙应用接入极具高级感的 HDS 沉浸光感材质。

  • 追求稳定和省心:无脑选择 ADAPTIVE
  • 追求极致视觉体验:使用 IMMERSIVE + EXQUISITE,但切记一定要做设备能力查询和降级处理hdsMaterial.getSystemMaterialTypes()),这是保证用户体验底线的关键。

同时,别忘了结合 deviceInfo.sdkApiVersion >= 23 来做好旧版本系统的兼容。

掌握了这些,你的应用 UI 质感绝对能上一个大台阶。赶紧去代码里试试吧!如果有任何疑问,欢迎在评论区交流。

同域名、同项目、仅 hash 变化,window.location.href 不跳转

两个 URL 只有 hash 后面的内容不同,浏览器会认为没有真正跳转,所以 window.location.href 不执行刷新

https://域名/cash-center/#/payMethod?params=abc
https://域名/cash-center/#/bindCard?params=12345

原因:浏览器只看 # 号前面的网址,不看 # 号后面的!

什么是 Hash?

URL 中 # 后面的部分叫 hash

为什么 window.location.href 不执行?

浏览器对 hash 变化 的处理方式:

hash 变化 ≠ 页面跳转

变化类型

浏览器行为

hash 变化(#/payMethod#/bindCard

不刷新页面,只触发 hashchange 事件

pathname 变化(/payMethod/bindCard

刷新页面

你的情况

// 当前 URL
window.location.href = 'https://xxx.com/cash-center/#/payMethod?params=abc'

// 目标 URL(只有 hash 不同)
window.location.href = 'https://xxx.com/cash-center/#/bindCard?params=12345'
//                                                    ^^^^^^^ hash 变了

浏览器看到只是 hash 变了,不会刷新页面,SPA 应用内部通过监听 hashchange 来切换路由。

解决方案

方案 1:强制刷新

window.location.href = targetUrl
window.location.reload()  // 强制刷新

方案 2:使用 Vue Router(推荐)

this.$router.push({
  path: '/bindCard',
  query: { params: '12345' }
})

方案 3:替换 pathname(不是 hash)

// 构造新 URL 时,换掉 hash 部分
const url = window.location.href.replace('#/payMethod', '#/bindCard')
window.location.href = url

一句话总结

hash 变化 ≠ 页面跳转,浏览器只把它当作同一页面的"锚点"变化,不会重新加载。SPA 应用靠监听 hashchange 来处理路由,而不是靠页面刷新。

比如这样的两个地址

https://tank-cash-center-h5.xyuat.com/cash-center/#/payMethod
https://tank-cash-center-h5.xyuat.com/cash-center/#/bindCard

在浏览器眼里,它们是同一个网址!

因为浏览器认为:

  • # 后面的东西 = 页面内部的标记
  • 不算新页面
  • 所以你用 window.location.href 改后面的内容
  • 浏览器懒得动,不跳转、不刷新

就像:

一本书 = 网址
书里的页码 = #后面的内容

你只是翻页,没换书,浏览器觉得:

不用重新加载,我不跳!

再简化到极致

# 前面一样 = 同一个页面

改 # 后面 = 只是翻页,不是跳转

所以:

window.location.href 不生效!

你现在必须用的唯一方案(支持返回)

this.$router.push('/bindCard')

或者

window.location.hash = '/bindCard'

解决方案

1、用 window.location.hash 跳转

// 拼接新的 hash 路由
const newHash = '#/bindCard?params=12345';

// 直接修改 hash → 自动跳转,自动保留历史记录(可返回)
window.location.hash = newHash;

2、用 window.location.href(加时间戳强制跳转),已验证过还是行不通

const baseUrl = 'https://tank-cash-center-h5.xyuat.com/cash-center/';
const targetHash = '#/bindCard?params=12345';

// 加时间戳,让浏览器认为是新链接,强制跳转
const fullUrl = baseUrl + targetHash + '&_t=' + Date.now();

window.location.href = fullUrl;

同域名、同项目、hash 路由、window.location.href 完全失效,加时间戳也没用,是因为 浏览器认为 hash 变化不算真正的页面跳转

注意:这个无法返回上一页,禁用

window.location.replace(url) // ❌ 禁用这个!会清除历史记录,无法返回



window.location.hash = xxx   ✅ 可返回
window.location.href = xxx    ✅ 可返回
this.$router.push(xxx)        ✅ 可返回

🚀 最终最稳、最简洁、你直接用的代码

window.location.hash = '/bindCard?params=12345';

3、创建 标签模拟点击(浏览器无法拦截,强制跳转),已验证这个方法也不行

// 目标地址
const url = "https://tank-cash-center-h5.xyuat.com/cash-center/#/bindCard?params=12345";

// 创建 a 标签
const a = document.createElement("a");
a.href = url;
a.target = "_self"; // 在当前页面打开 → 可以返回上一页
document.body.appendChild(a);
a.click(); // 模拟点击
document.body.removeChild(a);

方法 1:强制修改 location(最推荐,最简单)

window.location.href = window.location.origin + window.location.pathname + '#/bindCard?params=12345'

方法 2:先清空 hash 再赋值(强制触发跳转)

window.location.hash = ''
setTimeout(() => {
  window.location.hash = '/bindCard?params=12345'
}, 10)

方法 3:history.pushState + 刷新 hash(Vue 同项目专用)

history.pushState({}, '', '/cash-center/#/bindCard?params=12345')
window.dispatchEvent(new HashChangeEvent('hashchange'))

方法 4:location.assign 跳转(支持返回)

const url = "https://tank-cash-center-h5.xyuat.com/cash-center/#/bindCard?params=12345"
window.location.assign(url)

方法 5:iframe 跳转(终极兜底,任何环境都能跳)

const url = "https://tank-cash-center-h5.xyuat.com/cash-center/#/bindCard?params=12345"

const iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.src = url
document.body.appendChild(iframe)

setTimeout(() => {
  document.body.removeChild(iframe)
}, 50)

🚀 你现在直接复制这个(100% 必跳)

// 终极万能跳转(任何失效都能用)
window.location.hash = ''
setTimeout(() => {
  window.location.hash = '/bindCard?params=12345'
}, 10)

你的 Vue 3 TS 类型声明,VuReact 会处理成什么样的 React?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:VuReact 如何自动分析 Vue 3 中的响应式依赖,精准生成 React Hooks 的依赖数组

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 和 React 的响应式与依赖追踪机制。

编译对照

Vue 自动依赖分析 → React Hook 依赖数组生成

VuReact 编译器内置了自动依赖分析能力,遵循 React 规则,智能分析顶层箭头函数顶层变量声明中的响应式访问,并生成准确的依赖数组。

  • Vue 代码:
<script setup lang="ts">
  import { reactive, ref } from 'vue';

  const count = ref(0);
  const foo = ref(0);
  const state = reactive({ foo: 'bar', bar: { c: 1 } });

  const fn1 = () => {
    count.value += state.bar.c;
    console.log(count.value);
  };

  const fn = () => {};

  const fn2 = () => {
    const c = foo.value;
    fn();

    const fn4 = () => {
      state.bar.c--;
      c + count.value;
    };
  };

  const fn3 = () => {
    foo.value++;

    const state = ref('fake');
    const count = state.value + 'yoxi';
    count.charAt(1);
  };
</script>
  • VuReact 编译后 React 代码:
const count = useVRef(0);
const foo = useVRef(0);
const state = useReactive({ foo: 'bar', bar: { c: 1 } });

const fn1 = useCallback(() => {
  count.value += state.bar.c;
  console.log(count.value);
}, [count.value, state.bar?.c]);

const fn = () => {};

const fn2 = useCallback(() => {
  const c = foo.value;
  fn();

  const fn4 = () => {
    state.bar.c--;
    c + count.value;
  };
}, [foo.value, state.bar?.c, count.value]);

const fn3 = useCallback(() => {
  foo.value++;

  const state = useVRef('fake');
  const count = state.value + 'yoxi';
  count.charAt(1);
}, [foo.value]);

这段对比展示了:

  • fn1 会被识别为顶层箭头函数并收集 count.valuestate.bar.c
  • fn2 会溯源 c 并忽略局部函数 fn4
  • fn3 会忽略函数内部新建的响应式变量,只收集外部依赖 foo.value

Vue 组合访问与别名追踪

VuReact 也会对复杂别名链和解构访问进行溯源。

  • Vue 代码:
<script setup lang="ts">
  const objRef = ref({ a: 1, b: { c: 1 } });
  const listRef = ref([1, 2, 3]);
  const aliasA = state.foo;
  const aliasB = aliasA;
  const aliasC = aliasB;
  const { foo: stateFoo } = state;
  const [first] = listRef.value;

  const traceFn = () => {
    aliasC;
  };

  const destructureFn = () => {
    stateFoo;
    first;
  };
</script>
  • VuReact 编译后 React 代码:
const objRef = useVRef({ a: 1, b: { c: 1 } });
const listRef = useVRef([1, 2, 3]);
const aliasA = useMemo(() => state.foo, [state.foo]);
const aliasB = useMemo(() => aliasA, [aliasA]);
const aliasC = useMemo(() => aliasB, [aliasB]);
const { foo: stateFoo } = useMemo(() => state, [state]);
const [first] = useMemo(() => listRef.value, [listRef.value]);

const traceFn = useCallback(() => {
  aliasC;
}, [aliasC]);

const destructureFn = useCallback(() => {
  stateFoo;
  first;
}, [stateFoo, first]);

这样可见:

  • alias 链会被逐层溯源到真实响应式来源;
  • 解构后的变量也会通过 useMemo 转换为可追踪依赖。

Vue 顶层变量声明 → React useMemo 依赖数组生成

  • Vue 代码:
<script setup lang="ts">
  const fooRef = ref(0);
  const reactiveState = reactive({ foo: 'bar', bar: { c: 1 } });

  const memoizedObj = {
    title: 'test',
    bar: fooRef.value,
    add: () => {
      reactiveState.bar.c++;
    },
  };

  let staticObj = {
    foo: 1,
    state: { bar: { c: 1 } },
  };

  const reactiveList = [fooRef.value, 1, 2];

  const mixedList = [
    { name: reactiveState.foo, age: fooRef.value },
    { name: 'A', age: 20 },
  ];

  const nestedObj = {
    a: {
      b: {
        c: reactiveList[0],
        d: () => {
          return memoizedObj.bar;
        },
      },
      e: mixedList,
    },
  };
</script>
  • VuReact 编译后 React 代码:
const memoizedObj = useMemo(
  () => ({
    title: 'test',
    bar: fooRef.value,
    add: () => {
      reactiveState.bar.c++;
    },
  }),
  [fooRef.value, reactiveState.bar?.c],
);

let staticObj = {
  foo: 1,
  state: {
    bar: {
      c: 1,
    },
  },
};

const reactiveList = useMemo(() => [fooRef.value, 1, 2], [fooRef.value]);

const mixedList = useMemo(
  () => [
    { name: reactiveState.foo, age: fooRef.value },
    { name: 'A', age: 20 },
  ],
  [reactiveState.foo, fooRef.value],
);

const nestedObj = useMemo(
  () => ({
    a: {
      b: {
        c: reactiveList[0],
        d: () => {
          return memoizedObj.bar;
        },
      },
      e: mixedList,
    },
  }),
  [reactiveList[0], memoizedObj.bar, mixedList],
);

这里的核心对比是:

  • memoizedObj 会收集对象内部的响应式字段与方法依赖;
  • staticObj 因为不含响应式访问,不会被优化为 useMemo
  • reactiveListmixedListnestedObj 会根据结构递归补齐依赖数组。

自动依赖分析的三大原则

  1. 仅分析顶层可优化表达式:局部函数、嵌套作用域不纳入顶层 Hook 自动优化;
  2. 遵循 React 依赖规则:只收集函数/变量外部的响应式访问,而非内部局部变量;
  3. 避免过度优化:无外部响应式依赖的顶层箭头函数和变量不会被强制转换为 Hook。

为什么这很关键?

在 React 中,函数组件每次渲染会重新创建顶层函数与变量。如果这些顶层表达式依赖响应式状态且未获得稳定性处理,会带来:

  • 不必要的子组件重新渲染;
  • 频繁的 Hook 重新计算;
  • 性能不可控的回调变化。

VuReact 在编译阶段自动生成准确依赖数组,既保留了 Vue 写法的简洁性,又实现了 React 端的性能优化。

相关资源


如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

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

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

DDXY 节点是 Unity URP Shader Graph 中一个功能强大但相对高级的节点,它返回输入值 In 分别相对于屏幕空间 X 坐标和屏幕空间 Y 坐标的两个偏导数之和。从数学角度理解,偏导数描述了函数在特定方向上的变化率,而 DDXY 节点正是利用了这一数学原理来计算像素着色器中值的变化程度。

在图形编程中,DDXY 节点的核心作用是估算当前处理的像素与其相邻像素之间某个值的差异。这种差异计算基于屏幕空间坐标系,即最终渲染到屏幕上的二维坐标系统。当我们在像素着色器中处理纹理坐标、颜色值或其他矢量数据时,DDXY 能够帮助我们了解这些值在屏幕空间中的变化速率。

需要特别注意的是,DDXY 节点只能在像素着色器阶段中使用,这是由其底层实现机制决定的。像素着色器阶段是渲染管线中处理每个独立像素的阶段,而 DDXY 的功能依赖于对相邻像素信息的访问和比较。在顶点着色器或其他计算阶段,由于无法直接获取相邻顶点的信息,因此无法使用此节点。

DDXY 节点的实现基于屏幕空间的有限差分法,它通过比较当前像素与相邻像素的值来估算导数。具体来说,对于屏幕空间中的任意点 (x,y),DDXY 计算的是 f(x+1,y) - f(x-1,y) + f(x,y+1) - f(x,y-1),其中 f 代表输入的值。这种计算方法虽然是一种近似,但在实时渲染中已经足够精确。

在实际应用中,DDXY 节点常用于边缘检测、细节增强、抗锯齿处理和各种屏幕空间效果。它能够敏锐地捕捉到图像中值发生剧烈变化的区域,这些区域通常对应着物体的边缘、高光区域或其他视觉上重要的特征。

端口

DDXY 节点的端口设计简洁但功能强大,它包含一个输入端口和一个输出端口,两者都支持动态矢量类型。

  • 输入端口 In 接受动态矢量类型的输入值,这意味着它可以处理 float、float2、float3 或 float4 等多种数据类型。这种灵活性使得 DDXY 节点能够适应各种不同的着色器需求。输入值可以是纹理坐标、颜色信息、法线数据或任何其他需要在屏幕空间中分析变化率的数值。当输入为多维矢量时,DDXY 会分别计算每个分量的偏导数之和,并返回相同维度的结果。
  • 输出端口 Out 提供与输入相同类型的动态矢量输出,包含计算得到的偏导数值。输出值的每个分量都代表了对应输入分量在屏幕空间中的变化率总和。较高的输出值通常表示输入值在该像素区域变化剧烈,而较低的值则表示相对平缓的区域。理解这些输出值的含义对于正确使用 DDXY 节点至关重要,因为它们直接反映了输入数据在屏幕空间中的变化特征。

端口之间的数据类型传递保持了一致性,这意味着如果你输入一个 float3 类型的值,输出也会是 float3 类型,每个分量都独立计算了对应的偏导数之和。这种设计使得节点可以无缝集成到现有的着色器连接中,而不需要额外的类型转换节点。

生成的代码示例

在 Shader Graph 背后,DDXY 节点会被编译成相应的 HLSL 代码。理解这些生成的代码有助于我们更深入地掌握节点的运作机制和优化可能性。

以下示例代码表示此节点的一种可能结果:

void Unity_DDXY_float4(float4 In, out float4 Out)
{
    Out = ddxy(In);
}

这段代码展示了一个典型的 DDXY 函数实现,它接受一个 float4 类型的输入参数 In,并通过 HLSL 内置的 ddxy() 函数计算偏导数,然后将结果存储在输出参数 Out 中。

在实际的着色器编译过程中,ddxy() 函数的具体实现取决于目标平台和图形 API。在大多数现代图形 API 中,如 DirectX 和 Vulkan,ddxy() 对应于特定的着色器指令,能够高效地计算屏幕空间导数。

  • 在 DirectX HLSL 中,ddx() 和 ddy() 函数分别计算水平方向和垂直方向的偏导数,而 ddxy() 通常是这两者之和的优化实现。
  • 在 OpenGL GLSL 中,类似的功能通过 dFdx() 和 dFdy() 函数实现,而 fwidth() 函数则提供了与 ddxy() 类似的功能,计算的是绝对值的和而非单纯的和。

理解这些底层差异有助于我们在跨平台开发时预见可能的问题和性能差异。虽然 Shader Graph 为我们抽象了这些平台差异,但在优化和调试时,了解底层机制仍然非常有价值。

DDXY 节点的实际应用

DDXY 节点在着色器开发中有着广泛的应用场景,以下是几个常见的应用示例:

屏幕空间边缘检测

边缘检测是 DDXY 节点最经典的应用之一。通过计算纹理坐标或颜色值的屏幕空间导数,我们可以识别出图像中物体的边缘区域。

  • 将纹理坐标连接到 DDXY 节点的输入端口
  • 计算得到的偏导数会在高频区域(如边缘)产生较大的值
  • 将这些值用作边缘蒙版,可以创建各种轮廓效果
  • 结合阈值控制,可以调整边缘检测的灵敏度

这种方法比传统的卷积边缘检测(如 Sobel 算子)更加高效,因为它直接利用硬件加速的导数计算,而不需要额外的纹理采样和加权计算。

动态细节增强

在表面着色器中,我们经常需要根据观察距离或屏幕空间频率调整细节层次。DDXY 节点可以帮助我们实现自适应的细节控制。

  • 计算纹理坐标的屏幕空间导数
  • 根据导数大小决定使用哪一层 Mipmap 或是否添加额外细节
  • 在近距离观察时显示更多细节,远距离时简化细节
  • 这种方法可以有效减少远处物体的纹理闪烁和性能开销

几何边缘高光

通过计算法线或深度值的屏幕空间导数,我们可以检测几何边缘并添加高光效果,增强物体的立体感。

  • 将世界空间法线或深度值连接到 DDXY 节点
  • 在几何边缘处会产生较高的导数值
  • 使用这些值控制边缘高光的强度和颜色
  • 创建出类似于卡通渲染或技术可视化的效果

自适应抗锯齿

在某些情况下,我们可以使用 DDXY 节点实现自适应的抗锯齿策略,根据屏幕空间频率调整抗锯齿强度。

  • 计算颜色的屏幕空间导数
  • 在高频区域应用更强的抗锯齿
  • 在平滑区域减少抗锯齿以保持清晰度
  • 这种方法可以在保持性能的同时提高视觉质量

性能考虑和最佳实践

虽然 DDXY 节点功能强大,但在使用时也需要考虑性能影响和最佳实践:

  • DDXY 节点的计算成本相对较高,因为它需要访问相邻像素的信息
  • 在移动平台上,应谨慎使用 DDXY 节点,特别是在低端设备上
  • 避免在复杂的循环或条件分支中频繁使用 DDXY
  • 考虑使用更简单的近似方法替代 DDXY,如果精度要求不高
  • 合理选择输入数据的类型,使用较低精度的浮点数可以减少计算开销

常见问题和解决方案

在使用 DDXY 节点时,开发者可能会遇到一些常见问题:

  • 节点在顶点着色器中不可用:这是由 DDXY 的底层实现机制决定的,只能将其用于像素着色器阶段
  • 导数计算不准确:在极端情况下,如非常陡峭的角度或极小的三角形,导数计算可能出现不准确的情况
  • 平台兼容性问题:虽然 Shader Graph 尽力抽象平台差异,但在某些老旧设备上可能仍然存在兼容性问题

对于这些问题,通常的解决方案包括使用替代的数学方法、添加边界条件检查或提供降级方案。


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

CSS 新特性与冷门属性深度剖析

00_cover.png

前言

层叠样式表(CSS)作为前端开发的核心技术之一,在近年来迎来了爆发式的演进。从早期的简单样式控制,到如今支持复杂布局、动画、逻辑运算,CSS 已经发展成为一门功能完备的样式语言。随着现代浏览器对最新规范的广泛支持,许多曾经被认为是"未来特性"的功能如今已经在生产环境中得到广泛应用。本文将系统性地介绍 CSS 领域的新特性和那些被忽视但极具实用价值的冷门属性,帮助开发者构建更高效、更优雅的用户界面。

第一部分:CSS 新特性深度解析

01_container_queries.png

一、容器查询(CSS Container Queries)

容器查询是 CSS 演进历程中最具革命性的特性之一,它彻底改变了响应式设计的思维方式。传统媒体查询(Media Queries)基于视口尺寸来调整样式,这种方式在组件化开发中显得过于僵化。容器查询允许开发者基于父容器而非视口的尺寸来定义样式规则,这意味着同一个组件可以在不同的容器中展现出不同的外观,极大地提升了组件的可复用性和灵活性。

/* 定义容器 */
.card-container {
  container-type: inline-size;
  container-name: card;
}

/* 基于容器尺寸的样式 */
@container card (min-width: 400px) {
  .card {
    display: grid;
    grid-template-columns: 200px 1fr;
  }
}

@container card (max-width: 399px) {
  .card {
    display: flex;
    flex-direction: column;
  }
}

在实际项目中,容器查询的价值体现在多个方面。首先,它解决了组件库开发中的响应式难题——一个按钮组件无需关心自己位于侧边栏还是主内容区,它只需要根据自己可用空间来调整尺寸。其次,容器查询与 CSS 自定义属性相结合,可以构建出真正上下文感知的组件系统。此外,容器查询还支持容器查询长度单位,如 cqw(容器查询宽度百分比)、cqh(容器查询高度百分比)等,进一步扩展了响应式设计的表达空间。

二、子网格(CSS Subgrid)

02_subgrid.png

CSS Grid 的出现简化了复杂布局的构建过程,而子网格的引入则解决了网格嵌套场景中的对齐难题。在子网格出现之前,如果需要在嵌套网格中保持与父网格的对齐关系,开发者通常需要重复定义相同的网格轨道,或者借助 JavaScript 来计算尺寸。子网格允许内层网格继承外层网格的轨道定义,实现了真正意义上的网格系统协调。

.parent-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: auto auto auto;
  gap: 20px;
}

.child-card {
  grid-column: span 2;
  grid-row: span 2;
  display: grid;
  /* 继承父网格的列和行轨道 */
  grid-template-columns: subgrid;
  grid-template-rows: subgrid;
}

.card-header {
  grid-column: 1 / -1;
  grid-row: 1;
}

.card-content {
  grid-column: 1 / -1;
  grid-row: 2;
}

.card-footer {
  grid-column: 1 / -1;
  grid-row: 3;
}

子网格的典型应用场景包括卡片组件内部元素与外部网格的对齐、相册布局中图片与文字描述的精确对齐、以及表单布局中标签与输入框的基线对齐。使用子网格时需要注意浏览器兼容性问题——截至目前,主流浏览器已全面支持这一特性,但在某些企业级项目的老旧浏览器环境中可能需要提供降级方案。

三、:has() 伪类

:has() 伪类被称为 CSS 的"父选择器",它允许开发者根据后代元素的存在或状态来选择父元素,这一特性填补了 CSS 选择器体系中长达数十年的空白。在 :has() 出现之前,样式的应用方向只能是从父到子、从祖先到后代,选择器无法向上追溯,这在很多场景下造成了不必要的类名添加或 JavaScript 辅助。

/* 选择包含图片的卡片 */
.card:has(img) {
  border-radius: 12px;
  overflow: hidden;
}

/* 选择没有孩子的表单组 */
.form-group:not(:has(input):has(select):has(textarea)) {
  display: none;
}

/* 选择第一个孩子是段落的容器 */
.container:has(> p:first-child) {
  padding-top: 2em;
}

/* 选择任意表单元素获得焦点的字段集 */
.fieldset:has(:focus) {
  border-color: var(--focus-color);
  box-shadow: 0 0 0 3px var(--focus-ring);
}

:has() 伪类的应用范围远超乎想象。它可以用于实现复杂的表单验证样式、根据内容状态动态调整布局、构建智能的导航菜单高亮逻辑,以及创建更加语义化的卡片组件。需要特别注意的是,:has() 选择器与 :not() 的组合使用需要谨慎,因为不存在的选择器在 :not() 中会导致整条规则失效,合理的写法应该避免在 :not() 中放置可能不匹配的选择器。

四、CSS 嵌套

CSS 嵌套(Nesting)是开发者社区期待已久的特性,它借鉴了 Sass 等预处理器中成熟的嵌套语法,允许在样式规则内部直接编写子规则。这一特性不仅简化了样式代码的书写,还使得样式表结构与 HTML 结构之间的对应关系更加清晰,提升了代码的可维护性。

/* 现代 CSS 嵌套语法 */
.article-card {
  background: white;
  border-radius: 8px;

  & .card-title {
    font-size: 1.25rem;
    color: var(--text-primary);

    &:hover {
      color: var(--accent-color);
    }
  }

  & .card-meta {
    font-size: 0.875rem;
    color: var(--text-secondary);

    /* 使用 @nest 显式指定嵌套上下文 */
    @nest .article-card:has(&) & {
      border-top: 1px solid var(--border-color);
    }
  }
}

CSS 嵌套支持多种语法形式:使用 & 符号显式引用父选择器、省略 & 的隐式嵌套、以及使用 @nest 规则处理复杂的嵌套场景。值得注意的是,嵌套规则不会改变选择器的特异性——无论嵌套多深,选择器的特异性都由其最终形式决定。此外,嵌套还可以应用于媒体查询和条件规则,使得响应式样式的编写更加连贯自然。

五、color-mix() 函数

05_color_mix.png

颜色混合是设计系统中常见的需求,传统方案依赖预处理器函数或 JavaScript 来实现颜色叠加效果。color-mix() 函数原生支持在浏览器中进行颜色混合,它接受两个颜色值和混合比例,返回混合后的结果颜色值。

/* 基础用法:50% 混合 */
.element {
  background: color-mix(in srgb, #ff0000, #0000ff);
}

/* 指定混合比例 */
.button-primary {
  background: color-mix(in srgb, var(--primary-color) 85%, white);
}

.button-primary:hover {
  background: color-mix(in srgb, var(--primary-color) 70%, white);
}

.button-primary:active {
  background: color-mix(in srgb, var(--primary-color) 60%, black);
}

/* 使用透明色进行混合 */
.surface-variant {
  background: color-mix(in srgb, var(--surface-color), transparent 20%);
}

color-mix() 函数支持多种色彩空间,包括 srgbsrgb-linearhslhwblab 等,不同色彩空间下的混合结果会有明显差异。在 srgb 空间中混合会产生最直观的结果,而 lch 空间则能保持色彩的亮度一致性。实际应用中,color-mix() 特别适合用于实现主题切换时的颜色平滑过渡、悬停状态的色彩渐变、以及根据背景亮度自动调整文字颜色的智能对比系统。

六、CSS 层叠层(Cascade Layers)

06_cascade_layers.png

层叠层是 CSS 新引入的优先级控制机制,它允许开发者显式定义不同样式规则的优先级层次,有效解决了大型样式表中选择器特异性冲突的问题。在引入层叠层之前,开发者只能通过特异性计算和 !important 来控制样式的应用优先级,这种方式在复杂项目中容易导致样式失控。

/* 定义层叠层,从低到高排列 */
@layer reset, base, components, utilities, overrides;

/* 每一层可以独立定义 */
@layer reset {
  *, *::before, *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
}

@layer base {
  body {
    font-family: system-ui, sans-serif;
    line-height: 1.5;
  }
}

@layer components {
  .btn {
    display: inline-flex;
    padding: 0.75em 1.5em;
    border-radius: 6px;
    font-weight: 500;
  }
}

@layer utilities {
  .text-center { text-align: center; }
  .mt-4 { margin-top: 1rem; }
}

/* 无层的样式优先级最高 */
.highlight {
  background: yellow;
}

层叠层的核心优势在于其明确的优先级语义和可维护性。通过预先定义好的层顺序,团队成员可以清晰地知道添加新样式时应该放在哪一层。更重要的是,层叠层提供了一种安全的方式来引入第三方样式库——可以将外部库放在特定层中,通过调整层的顺序来控制其与项目样式的优先级关系,避免样式冲突。

第二部分:冷门但强大的 CSS 属性

一、clip-path 的进阶应用

clip-path 属性通常被认为是用于创建简单几何裁剪的工具,实际上它支持极其丰富的裁剪路径定义。结合动画和 SVG 路径,clip-path 可以实现令人惊叹的视觉效果,包括复杂的多边形裁剪、基于百分比的相对裁剪、以及与 CSS 动画结合的创意效果。

/* 使用 SVG 路径进行复杂裁剪 */
.hero-image {
  clip-path: path('M0,0 L100%,0 L100%,80% Q50%,100% 0,80% Z');
}

/* 使用 inset() 创建复杂形状 */
.fancy-card {
  clip-path: inset(
    0 round 20px 0 20px 0,
    10px 5px,
    20px
  );
}

/* 使用 polygon 创建自定义多边形 */
.hexagon-button {
  clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
}

/* 动画裁剪路径 */
.animated-reveal {
  clip-path: inset(100% 0 0 0);
  transition: clip-path 0.6s cubic-bezier(0.65, 0, 0.35, 1);
}

.animated-reveal.revealed {
  clip-path: inset(0 0 0 0);
}

clip-path 的另一个强大应用是结合 CSS 变量实现响应式裁剪。通过在 JavaScript 中动态更新 CSS 变量的值,可以实现鼠标跟随裁剪、进度指示器等交互效果。在性能方面,clip-path 不会创建新的堆叠上下文,相比于 mask-image 等属性具有更好的渲染性能。需要注意的是,clip-path 会影响元素的可访问性——被裁剪隐藏的区域虽然不可见,但其内容对屏幕阅读器仍然可见,必要时应该配合 aria-hidden 属性使用。

二、aspect-ratio 属性

aspect-ratio 属性用于强制元素保持特定的宽高比,这一特性在前端开发中有着广泛的应用场景,从响应式图片到视频嵌入,再到卡片组件的封面图区域。传统的 padding 百分比 hack 方案虽然也能实现类似效果,但 aspect-ratio 提供了更直观、更易维护的解决方案。

/* 固定比例容器 */
.video-wrapper {
  aspect-ratio: 16 / 9;
  width: 100%;
  background: black;
}

.video-wrapper video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* 自适应比例:优先宽度 */
.square {
  aspect-ratio: 1;
}

.portrait {
  aspect-ratio: 3 / 4;
}

.landscape {
  aspect-ratio: 4 / 3;
}

/* 自动宽度模式 */
.auto-width-container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 1rem;
}

.grid-item {
  aspect-ratio: 16 / 9;
}

aspect-ratio 属性与 width: auto 结合使用时,会根据高度计算宽度;与 height: auto 结合时则根据宽度计算高度。常见的应用包括构建图库网格时确保所有缩略图比例一致、实现视频播放器响应式布局、以及创建完美正方形的社交媒体头像容器。需要注意的是,当元素同时设置了明确的宽度和高度时,aspect-ratio 会被忽略,浏览器会优先使用明确的尺寸值。

三、scroll-snap 进阶用法

08_scroll_snap.png

scroll-snap 是实现滚动吸附效果的标准 CSS 方案,虽然它常被用于轮播图组件,但它的应用远不止于此。通过精细的配置,scroll-snap 可以实现页面滚动吸附、标签页切换、手势导航等多种交互模式,而无需编写任何 JavaScript 代码。

/* 水平轮播 */
.carousel {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  -webkit-overflow-scrolling: touch;

  & > .carousel-item {
    flex: 0 0 85%;
    scroll-snap-align: center;
    margin-inline: 7.5%;
  }
}

/* 垂直滚动吸附 */
.gallery {
  height: 100vh;
  overflow-y: scroll;
  scroll-snap-type: y mandatory;

  & > .gallery-section {
    height: 100vh;
    scroll-snap-align: start;
  }
}

/* 吸附到多个关键点 */
.pagination-scroll {
  scroll-snap-type: x proximity;

  & > .page-item {
    scroll-snap-align: start;
    scroll-snap-stop: always;
  }
}

scroll-snap-type 接受两个值:轴向(xyblockinline)和吸附强度(noneproximitymandatory)。mandatory 会强制吸附到最近的捕捉点,而 proximity 仅在用户滚动到接近捕捉点时才吸附。scroll-snap-align 则控制子元素如何与捕捉区域对齐,scroll-snap-stop 属性(支持情况因浏览器而异)可以防止用户在滚动时跳过某些页面,这对于分页场景非常有用。

四、CSS contain 属性

contain 属性提供了一种声明式的方式来隔离元素的样式、布局和绘制范围,从而帮助浏览器优化渲染性能。在大型列表或复杂页面中,合理使用 contain 可以显著减少不必要的重排和重绘,提升页面的滚动流畅度。

/* 布局隔离:防止内部变化影响外部 */
.card {
  contain: layout;
}

/* 绘制隔离:防止溢出渲染 */
.thumbnail {
  contain: paint;
}

/* 样式隔离:禁用子元素计数器继承 */
.counter-list {
  contain: style;
}

/* 组合使用多种隔离策略 */
.list-item {
  contain: content;
  /* 等同于 contain: layout paint; */
}

/* 严格隔离:禁用所有继承 */
.fully-isolated {
  contain: strict;
}

contain 属性的四个核心值分别是:layout(布局隔离)、paint(绘制隔离)、style(样式隔离)和 size(尺寸隔离)。contentlayout paint 的简写,而 strict 则是 layout paint style size 的组合。需要特别注意的是,contain: size 会导致元素尺寸完全由自身决定,不会受到子元素影响,这在某些场景下可能导致布局问题。实际应用中,建议在虚拟列表、长列表渲染、以及第三方组件嵌入等场景中启用适当的隔离策略。

五、font-variant-* 属性家族

字体变体属性控制着文字的排版细节,这一家族包含多个属性,每个都针对特定的排版特征。合理使用这些属性可以在不替换字体的情况下获得更丰富的排版效果,特别适合中英文混排和强调语义的场景。

/* 等宽数字:在数字列中保持对齐 */
.price {
  font-variant-numeric: tabular-nums;
}

/* 分数显示 */
.recipe-amount {
  font-variant-numeric: diagonal-fractions;
}

/* 旧式数字 */
.historic-prices {
  font-variant-numeric: oldstyle-nums;
}

/* 小型大写字母 */
.acronym {
  font-variant-caps: small-caps;
}

/* 字符替代变体 */
.artistic-title {
  font-variant-alternates: stylistic(ornaments);
}

/* 综合配置 */
.typography-example {
  font-feature-settings: "ss01", "ss02", "cv01";
  /* ss01-ss20: 样式集
     cv01-cv99: 字符变体
     liga: 连字
     dlig: 选择性连字 */
}

font-variant-ligatures 属性控制连字的显示,包括普通连字(common-ligatures)、选择性连字(discretionary-ligatures)、历史连字(historical-ligatures)等。font-variant-position 属性用于创建上标和下标文字,相比使用 supsub 元素,它的优势在于不会改变行高。在实际排版中,建议对数字列使用 tabular-nums 以确保右对齐的整齐,对艺术字标题使用 stylistic 变体添加装饰效果。

六、text-wrap: balance

text-wrap 属性控制文本的换行方式,其中 balance 值是一个相对较新的特性,它能够智能平衡多行文本的断行,使每一行的长度尽可能均匀。相比于默认的贪心换行算法,balance 可以显著提升标题和引用的排版质量。

/* 平衡换行 */
.headline {
  text-wrap: balance;
  max-inline-size: 25ch; /* 建议配合最大宽度使用 */
}

/* 优雅换行 */
.prose {
  text-wrap: pretty;
  /* pretty 会优先在音节边界断行,避免孤寡字 */
}

balancepretty 是两个互补的值,前者强调视觉平衡,后者强调排版优雅。需要注意的是,balance 会对性能产生轻微影响,因此不建议在大量文本元素上使用。浏览器通常会限制 balance 处理的文本行数(通常是 6 行以内),超出限制的文本会自动回退到普通换行方式。对于需要精细控制的场景,可以结合 CSS 容器查询来针对不同容器尺寸应用不同的换行策略。

七、CSS accent-color

accent-color 是一个简洁但实用的属性,它用于统一表单元素的默认主题颜色。传统的表单元素(如复选框、单选按钮、进度条、滑块等)的样式定制通常需要大量 CSS 代码,accent-color 将这一过程简化为一行声明。

/* 全局主题色 */
:root {
  accent-color: #6366f1;
}

/* 区域化主题色 */
.light-section {
  accent-color: #4f46e5;
}

.dark-section {
  accent-color: #818cf8;
}

/* 结合 CSS 自定义属性 */
.theme-indigo {
  accent-color: var(--brand-indigo);
}

accent-color 会影响多个表单元素的默认样式:复选框和单选按钮的选中颜色、进度条的填充颜色、progress 元素的指示器颜色、以及 input[type="range"] 的滑块颜色。需要注意的是,accent-color 不会覆盖开发者显式设置的样式,它只是提供了元素的默认外观起点。在设计系统中,可以将 accent-color 与 CSS 自定义属性结合,通过切换根变量的值来实现主题切换,浏览器会自动更新所有受影响表单元素的外观。

第三部分:实用技巧与最佳实践

一、CSS 逻辑属性与现代布局

09_logical_properties.png

传统的物理属性(如 margin-leftpadding-top)在面对不同书写方向的语言时会遇到适配问题。CSS 逻辑属性(Logical Properties)使用逻辑轴(如 inlineblock)替代物理轴(如 lefttop),使得同一套样式代码能够自动适配从左到右、从右到左、从上到下等各种书写模式。

/* 物理属性(固定方向) */
.element-physical {
  margin-left: 1rem;
  padding-top: 0.5rem;
  border-left: 2px solid;
}

/* 逻辑属性(书写方向自适应) */
.element-logical {
  margin-inline-start: 1rem;
  padding-block-start: 0.5rem;
  border-inline-start: 2px solid;
}

/* 常用逻辑属性映射 */
inline-start  → margin/padding-inline-startmargin-left (LTR) / margin-right (RTL)
inline-end     → margin/padding-inline-endmargin-right (LTR) / margin-left (RTL)
block-start    → margin/padding-block-startmargin-top / margin-bottom (vertical)
block-end      → margin/padding-block-endmargin-bottom / margin-top (vertical)

在实际项目中,逻辑属性的应用场景包括:多语言网站的国际化适配、双向文本的混排布局、以及基于书写模式的动态主题。对于只需要支持单一书写方向的网站,使用物理属性仍然是合理的选择;但对于需要面向全球用户的产品,建议从项目初期就采用逻辑属性,以获得更好的可维护性和国际化支持。

二、层叠上下文与渲染性能

理解层叠上下文(Stacking Context)的创建条件对于调试复杂的层级问题和优化渲染性能至关重要。某些 CSS 属性会创建新的层叠上下文,这既是功能的体现,也可能带来性能开销。

/* 创建层叠上下文的常见属性 */
.layer-creators {
  /* 定位与 z-index */
  position: relative;
  z-index: 1;

  /* 滤镜效果 */
  filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));

  /* 混合模式 */
  mix-blend-mode: multiply;

  /* CSS 变换 */
  transform: translateZ(0);
  will-change: transform; /* 提示浏览器优化,但不创建上下文 */

  /* 透明度和剪裁 */
  opacity: 0.99;
  clip-path: inset(0);
}

/* 优化建议:使用 transform 而非 top/left 做动画 */
.animate-performance {
  transform: translateX(0);
  transition: transform 0.3s ease;
}

.animate-performance:hover {
  transform: translateX(10px);
}

will-change 属性是性能优化的重要工具,它向浏览器提示某个元素的某个属性将会发生变化,给予浏览器提前优化的机会。但滥用 will-change 会适得其反,因为它会占用额外的内存资源。建议在动画开始前添加 will-change,动画结束后移除它。对于需要持续运行的动画,建议使用 transformopacity 属性,因为这两个属性的变化不会触发布局重新计算,是实现流畅动画的最佳选择。

三、CSS 条件规则的高级应用

CSS 条件规则(Conditional Rules)允许开发者根据特定条件来应用样式,除了常见的 @media@supports 之外,还有一些较少使用但功能强大的条件规则。

/* 支持性查询 */
@supports (display: grid) {
  .grid-layout {
    display: grid;
  }
}

@supports not (aspect-ratio: 1) {
  .aspect-ratio-fallback {
    padding-bottom: 100%;
  }
}

/* 容器查询 */
@container (min-width: 500px) {
  .responsive-component {
    flex-direction: row;
  }
}

/* 样式查询 */
@container style(--theme: dark) {
  .themed-element {
    color: white;
  }
}

/* 用户偏好查询 */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

样式查询(Style Queries)是容器查询的扩展,它允许基于容器上的 CSS 自定义属性值来应用条件样式。这一特性使得组件可以根据传入的 prop(通过 CSS 自定义属性)来调整自身行为,无需使用 JavaScript 类操作。结合设计系统的 CSS 自定义属性体系,样式查询可以实现声明式的组件变体切换,极大地提升了样式的可组合性。

结语

CSS 技术的演进从未停止,从容器查询到子网格,从 :has() 伪类到层叠层,这些新特性正在重新定义前端开发者构建用户界面的方式。与此同时,那些被忽视的冷门属性——如 clip-path 的创意应用、scroll-snap 的进阶技巧、contain 的性能优化——同样蕴含着巨大的实用价值。掌握这些技术不仅能够提升代码质量,更能够拓展解决问题的思路。

作为一名现代前端开发者,保持对 CSS 新特性的学习和探索是职业发展的必经之路。建议开发者在实际项目中积极尝试这些新特性,在实践中积累经验,在解决问题中深化理解。同时,也要关注浏览器支持情况和潜在的性能影响,在创新与稳定之间找到最佳平衡点。CSS 的未来充满可能,让我们拭目以待。

前端卡顿的真相:不是你代码慢,是你阻塞了

引言

一个风和日丽的下午,前端工程师小王收到了一个bug反馈:

"用户反映页面有时候会卡住,点什么都点不了,整个浏览器好像死机了一样。"

小王打开浏览器,准备调试。然后——他自己的页面也卡住了。

这是前端性能问题中最常见、也最让人头疼的一类:主线程阻塞。

很多开发者会下意识地认为"页面卡顿 = 代码太慢"。于是开始优化算法、压缩代码、减少DOM操作……但往往收效甚微。因为问题的本质根本不是"代码慢",而是"主线程被阻塞了"。

这篇文章,就是要把"主线程阻塞"这个概念彻底讲清楚。

一、什么是JavaScript主线程?

1.1 浏览器的工作线程模型
要理解"阻塞",首先要理解浏览器是如何工作的。

现代浏览器是一个复杂的多线程系统:

┌─────────────────────────────────────────────────┐
│ 浏览器进程 │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────┐│
│ │ 渲染进程 │ │ 渲染进程 │ │ GPU进程 ││
│ │ (Renderer) │ │ (Renderer) │ │ ││
│ └─────────────┘ └─────────────┘ └─────────┘│
│ │
└─────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────┐
│ 渲染进程内部 │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ JavaScript│ │ GUI渲染 │ │ 事件响应 │ │
│ │ 主线程 │ │ 线程 │ │ 线程 │ │
│ │ (Main) │ │ │ │ │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │
└─────────────────────────────────────────────────┘
关键点:JavaScript 的执行和页面的渲染,共享同一个主线程。

这意味着:

JavaScript 执行时,渲染无法进行
渲染时,JavaScript 无法执行
如果 JavaScript 执行时间过长,页面就会"卡住"
1.2 为什么是"单线程"?
JavaScript 设计之初是一门用于浏览器端交互的脚本语言。设计者做了一个简单的决策:JavaScript 不允许操作 DOM 时存在竞态条件。

如果 JavaScript 是多线程的,那两个线程同时修改同一个 DOM 元素会发生什么?浏览器需要复杂的同步机制来处理冲突,这将大大增加语言的复杂度和运行成本。

所以,JavaScript 从诞生之日起就是单线程的。这不是技术缺陷,而是一个有意的设计决策。

但这个设计带来了一个代价:任何耗时操作都会阻塞主线程,导致页面无法响应用户操作。

二、"卡顿"的真正原因:你阻塞了主线程
2.1 事件循环模型
要理解阻塞是如何发生的,我们需要理解浏览器的事件循环(Event Loop)模型:

                ┌─────────────────────┐
                │       调用栈        │
                │    (Call Stack)     │
                └──────────┬──────────┘
                           │
                           ▼
                ┌─────────────────────┐
                │     Web APIs        │
                │  (setTimeout/AJAX/  │
                │   DOM Events等)     │
                └──────────┬──────────┘
                           │
                           ▼
                ┌─────────────────────┐
                │     任务队列        │
                │   (Task Queue)     │
                │   Microtask Queue   │
                │   (Promise等)       │
                └──────────┬──────────┘
                           │
      ┌────────────────────┬┴────────────────────┐
      │                    │                    │
      ▼                    ▼                    ▼
┌──────────┐        ┌──────────┐        ┌──────────┐
│  执行    │        │ 执行     │        │  执行    │
│ Microtask│        │ Macrotask│        │ 渲染更新 │
│ (优先)   │        │          │        │ (16.6ms) │
└──────────┘        └──────────┘        └──────────┘

事件循环的执行顺序:

  1. 从调用栈中执行 JavaScript 代码
  2. 执行所有 Microtask(Promise的.then、MutationObserver等)
  3. 检查是否需要渲染(每16.6ms一次)
  4. 从任务队列取出一个 Macrotask 执行(setTimeout、setInterval、UI事件等)
  5. 重复
    2.2 什么是"阻塞"?
    阻塞 = JavaScript 执行时间超过了浏览器渲染间隔(16.6ms)

时间轴:
│──────16.6ms──────│──────16.6ms──────│──────16.6ms──────│
│ │ │
渲染机会1 渲染机会2 渲染机会3
│ │ │
├────────────────────────┼────────────────────────┤
│ │ │
▼ ▼ ▼
执行JS代码... 执行JS代码... 执行JS代码...

如果JS执行时间超过16.6ms...

│─────────────超过100ms───────────────────│
│ │
渲染机会1 ✗(错失) 渲染机会2 ✗(错失)
│ │
用户点击无法响应 ←─── 100ms的"卡顿" ───→ 用户再次点击
帧率与流畅度的关系:

帧率(FPS) 每帧时间 用户体验
60 FPS 16.6ms 流畅
30 FPS 33.3ms 可接受(轻微卡顿)
15 FPS 66.6ms 卡顿明显
< 10 FPS > 100ms 严重卡顿,感觉"死机"
2.3 为什么"感觉"是代码慢?
很多人会把"阻塞"理解为"代码慢",这不完全对。

"代码慢"和"阻塞"的区别:

"代码慢":

  • 执行时间:50ms
  • 对用户体验的影响:小(可能只是略微延迟)
  • 性质:相对可控

"阻塞":

  • 执行时间:200ms
  • 对用户体验的影响:大(明显卡顿)
  • 性质:主线程被长时间占用,无法响应任何交互
    本质上,阻塞是"慢"的极端形式。但"慢"不一定阻塞(比如后台任务),而"阻塞"一定是"慢"。

三、导致主线程阻塞的常见场景

3.1 场景一:大型同步计算
典型代码:

javascript
// 计算斐波那契数列第40项(同步执行)
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}

// 在主线程执行
const result = fibonacci(40); // 耗时约1秒 ⚠️

// 这1秒内,页面完全无响应
问题分析:

调用栈状态:
fibonacci(40)
→ fibonacci(39)
→ fibonacci(38)
→ ...
→ fibonacci(2)

递归调用导致调用栈极深,整个过程无法被打断。
优化方案:

javascript
// 方案1:使用 Web Worker(推荐)
const worker = new Worker('fibonacci-worker.js');

worker.postMessage({ n: 40 });
worker.onmessage = (e) => {
console.log('结果:', e.data.result); // 不阻塞主线程
};

// 方案2:分片计算 + requestIdleCallback
function fibonacciAsync(n, chunkSize = 1000) {
return new Promise((resolve) => {
let result = 0;
let temp = 1;
let count = 0;

    function compute() {
        while (count < n) {
            const next = result + temp;
            result = temp;
            temp = next;
            count++;
            
            // 每计算1000次,让出主线程
            if (count % chunkSize === 0) {
                requestIdleCallback(compute);
                return;
            }
        }
        resolve(result);
    }
    
    requestIdleCallback(compute);
});

}

// 方案3:使用 memoization 缓存
const fibCache = { 0: 0, 1: 1 };

function fibonacciMemo(n) {
if (n in fibCache) return fibCache[n];

fibCache[n] = fibonacciMemo(n - 1) + fibonacciMemo(n - 2);
return fibCache[n];

}
3.2 场景二:大量DOM操作(Layout Thrashing)
典型代码:

javascript
// 糟糕的DOM操作方式
function updateElementWidths() {
const elements = document.querySelectorAll('.item');

elements.forEach((el) => {
    // ❌ 每次读取宽度(触发重排)
    const width = el.offsetWidth;
    
    // ❌ 每次修改宽度(触发重排)
    el.style.width = `${width * 1.1}px`;
});

}
问题分析:

DOM 操作 + 读取操作的顺序会导致 "强制同步布局"(Forced Synchronous Layout):

  1. 修改 el.style.width → 浏览器标记需要重排
  2. 读取 el.offsetWidth → 浏览器必须立即计算最新布局
  3. 循环重复 → 每次循环都触发重排

这叫做 "Layout Thrashing",性能杀手。
优化方案:

javascript
// 方案1:批量读写,先读后写
function updateElementWidths() {
const elements = document.querySelectorAll('.item');

// 步骤1:先读取所有宽度(触发一次重排)
const widths = [];
elements.forEach((el) => {
    widths.push(el.offsetWidth);
});

// 步骤2:再修改所有宽度(触发一次重排)
elements.forEach((el, i) => {
    el.style.width = `${widths[i] * 1.1}px`;
});

}

// 方案2:使用 requestAnimationFrame
function updateElementWidths() {
const elements = document.querySelectorAll('.item');
let index = 0;

function update() {
    // 每帧更新一个元素
    if (index < elements.length) {
        const el = elements[index];
        const width = el.offsetWidth;  // 读取
        el.style.width = `${width * 1.1}px`;  // 写入
        index++;
        requestAnimationFrame(update);
    }
}

requestAnimationFrame(update);

}

// 方案3:使用 CSS transform(不触发重排)
function updateElementWidths() {
const elements = document.querySelectorAll('.item');

elements.forEach((el) => {
    // 使用 transform,浏览器会合并这些操作
    el.style.transform = 'scaleX(1.1)';
});

}
3.3 场景三:大量数据渲染(Long Task)
典型代码:

javascript
// 一次性渲染10000个列表项
function renderList(items) {
const container = document.getElementById('list');
container.innerHTML = ''; // 清除现有内容

items.forEach((item) => {
    const div = document.createElement('div');
    div.textContent = item.name;
    div.className = 'list-item';
    
    // 每次创建元素都可能有重排
    container.appendChild(div);
});

}

renderList(generateItems(10000)); // 耗时约 500ms ⚠️
问题分析:

单个列表项渲染成本:

  • 创建DOM元素:~0.1ms
  • 设置内容:~0.1ms
  • 插入文档:~0.1ms

10000项总成本:

  • DOM创建:~1000ms(仅这一项就超过16.6ms阈值)
  • 这还是假设每个元素很简单的情况
    优化方案:

javascript
// 方案1:虚拟列表(只渲染可见项)
class VirtualList {
constructor(container, items, itemHeight = 50) {
this.container = container;
this.items = items;
this.itemHeight = itemHeight;
this.visibleCount = Math.ceil(container.clientHeight / itemHeight) + 2;

    this.init();
}

init() {
    // 设置容器样式
    this.container.style.overflow = 'auto';
    this.container.style.position = 'relative';
    
    // 创建内容区域
    this.content = document.createElement('div');
    this.content.style.height = `${this.items.length * this.itemHeight}px`;
    this.container.appendChild(this.content);
    
    // 事件监听
    this.container.addEventListener('scroll', () => this.onScroll());
    this.render();
}

onScroll() {
    requestAnimationFrame(() => this.render());
}

render() {
    const scrollTop = this.container.scrollTop;
    const startIndex = Math.floor(scrollTop / this.itemHeight);
    
    // 只渲染可见区域的元素
    const visibleItems = this.items.slice(
        startIndex,
        startIndex + this.visibleCount
    );
    
    this.content.innerHTML = '';
    visibleItems.forEach((item, i) => {
        const el = document.createElement('div');
        el.style.height = `${this.itemHeight}px`;
        el.style.position = 'absolute';
        el.style.top = `${(startIndex + i) * this.itemHeight}px`;
        el.textContent = item.name;
        this.content.appendChild(el);
    });
}

}

// 方案2:DocumentFragment 批量插入
function renderList(items) {
const container = document.getElementById('list');
const fragment = document.createDocumentFragment();

items.forEach((item) => {
    const div = document.createElement('div');
    div.textContent = item.name;
    fragment.appendChild(div);  // 添加到Fragment,不触发重排
});

container.appendChild(fragment);  // 一次性添加到DOM,只触发一次重排

}

// 方案3:分批渲染
function renderListBatched(items, batchSize = 100) {
const container = document.getElementById('list');
let index = 0;

function renderBatch() {
    const batch = items.slice(index, index + batchSize);
    
    batch.forEach((item) => {
        const div = document.createElement('div');
        div.textContent = item.name;
        container.appendChild(div);
    });
    
    index += batchSize;
    
    if (index < items.length) {
        // 让出主线程,下一帧继续
        requestAnimationFrame(renderBatch);
    }
}

renderBatch();

}
3.4 场景四:同步AJAX请求(Synchronous XMLHttpRequest)
典型代码:

javascript
// ⚠️ 已废弃但仍有人用
function loadData() {
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/data', false); // false = 同步
xhr.send(null);

return JSON.parse(xhr.responseText);  // 阻塞等待响应

}
问题分析:

同步请求的影响:

用户点击 → 发起同步请求 → 等待服务器响应(可能1-5秒)→ 继续执行 → 更新UI

在这1-5秒内:

  • 页面完全无响应
  • 无法点击、无法滚动
  • 感觉浏览器"死机了"

这比任何代码优化带来的卡顿都要严重。
优化方案:

javascript
// 方案1:使用 Promise + async/await
async function loadData() {
try {
const response = await fetch('/api/data');
const data = await response.json();
return data;
} catch (error) {
console.error('加载失败:', error);
}
}

// 方案2:显示加载状态
async function loadDataWithLoading() {
showLoadingSpinner();

const data = await loadData();

hideLoadingSpinner();
renderData(data);

}

// 方案3:使用 Web Worker 发起网络请求(复杂场景)
3.5 场景五:复杂正则表达式
典型代码:

javascript
// 复杂正则可能触发"灾难性回溯"
const regex = /^(a+)+b$/;
const maliciousInput = 'aaaaaaaaaaaaaaaaaaaaaaaaax'; // 耗时可能超过1秒

// 在表单验证中可能这样用
function validateInput(input) {
return regex.test(input); // 输入稍长就会卡住
}
问题分析:

正则表达式灾难性回溯:

输入:aaaaaaaaaaaaaaaaaaaaaaaaax
正则:^(a+)+b$

(a+) 尝试匹配:

  • 第一次:匹配20个a
    • 再次尝试匹配更多a
  • 无法匹配x,回溯
  • 尝试匹配19个a
    • 再次尝试...
  • ...

时间复杂度:O(2^n)

对于20个a:约100万次操作
对于25个a:约3300万次操作
优化方案:

javascript
// 方案1:使用更简单的正则
const safeRegex = /^a+b$/; // 直接匹配 a+b,避免嵌套量词

// 方案2:使用独占模式(原子分组,减少回溯)
const betterRegex = /^a++b$/; // a++ 表示占有优先,不回溯

// 方案3:使用长度限制 + 简单正则
function validateInput(input) {
// 先检查长度
if (input.length > 100) return false;

// 再检查格式
return /^[\w]+$/.test(input);

}

// 方案4:使用专业库(如 safe-regex2)
const safeRegex = require('safe-regex2');

if (!safeRegex(/^(a+)+b$/)) {
console.warn('正则表达式不安全');
}

四、如何检测主线程阻塞?

4.1 Chrome DevTools Performance
使用步骤:

  1. 打开 Chrome DevTools(F12)
  2. 切换到 Performance 标签
  3. 点击录制按钮
  4. 执行需要检测的操作
  5. 停止录制
    识别阻塞的信号:

Performance 面板关键指标:

  1. Main 线程中的 Long Task(> 50ms的任务)
    • 红色标记的任务块表示长任务
    • 展开可以看到具体是哪个函数
  1. Frame 超过 16.6ms 的情况
    • 如果 fps 很低,说明渲染被阻塞
  1. 红色警告 "Long Tasks"

}

    • 表示主线程被长时间占用
      4.2 Performance Observer API
      javascript
      // 监控长任务
      const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
      console.log('长任务检测:', {
      name: entry.name,
      duration: entry.duration, // 毫秒
      startTime: entry.startTime
      }); // 上报给监控系统
      if (entry.duration > 50) {
      monitor.reportLongTask(entry);
      }

});

observer.observe({ entryTypes: ['longtask'] });
4.3 User Timing API
javascript
// 标记关键性能节点
performance.mark('fetch-start');

// 执行操作
await fetchData();

performance.mark('fetch-end');
performance.measure('fetch-duration', 'fetch-start', 'fetch-end');

// 获取测量结果
const measures = performance.getEntriesByType('measure');
console.log(measures);
4.4 Lighthouse
bash

使用 Lighthouse CLI

npx lighthouse example.com --view

检查 "Long Tasks" 和 "Total Blocking Time"

五、解决主线程阻塞的通用策略

5.1 策略一:让出主线程
核心思想:长时间任务分批执行,每次执行后让出主线程。

javascript
// 分批处理大量数据
async function processItems(items, batchSize = 100) {
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
processBatch(batch);

    // 让出主线程,等待下一帧
    await new Promise(resolve => setTimeout(resolve, 0));
}

}

// 使用 requestIdleCallback(更智能的让出)
function processInIdleTime(items, deadline) {
while (deadline.timeRemaining() > 0 && items.length > 0) {
const item = items.shift();
processItem(item);
}

if (items.length > 0) {
    requestIdleCallback((deadline) => {
        processInIdleTime(items, deadline);
    });
}

}
5.2 策略二:使用 Web Worker
核心思想:将计算密集型任务移到后台线程。

javascript
// worker.js
self.onmessage = function(e) {
const result = heavyComputation(e.data);
self.postMessage(result);
};

function heavyComputation(data) {
// 任何耗时计算
return result;
}

// main.js
const worker = new Worker('worker.js');

worker.postMessage({ input: largeData });
worker.onmessage = function(e) {
// 处理结果,不阻塞主线程
displayResult(e.data);
};
5.3 策略三:优化渲染性能
核心思想:避免触发布局抖动,使用高效的渲染方式。

javascript
// 避免读写交替
// ❌ 不好
element.style.width = element.offsetWidth + 10 + 'px';

// ✅ 好:先读后写
const width = element.offsetWidth;
element.style.width = width + 10 + 'px';

// ✅ 更好:使用 transform(不触发布局)
element.style.transform = translateX(${currentX + 10}px);
5.4 策略四:懒加载和代码分割
核心思想:不要一次性加载和执行所有代码。

javascript
// 路由级别的代码分割
const AdminPanel = React.lazy(() => import('./AdminPanel'));

// 非关键组件懒加载
const HeavyChart = React.lazy(() => import('./HeavyChart'));

// 图片懒加载
const lazyImage = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.src = entry.target.dataset.src;
lazyImage.unobserve(entry.target);
}
});
});
六、实战案例:优化一个"卡死"的表格组件
初始问题
用户反馈:打开某数据表格页面后,页面卡死约 3-4 秒。

分析
javascript
// 问题代码
function renderTable(data) {
const tbody = document.querySelector('#table tbody');

data.forEach(row => {
    const tr = document.createElement('tr');
    
    // 每行有10个单元格
    row.forEach(cell => {
        const td = document.createElement('td');
        td.textContent = cell;
        tr.appendChild(td);
    });
    
    tbody.appendChild(tr);  // ❌ 每行都触发一次重排
});

}
诊断
使用 Performance 面板分析:

Main 线程:
├─ renderTable: 3200ms ⚠️ 长任务
│ └─ 每次 tbody.appendChild() 触发重排
└─ 总耗时:3200ms
优化方案
javascript
// 优化1:使用 DocumentFragment
function renderTableOptimized1(data) {
const tbody = document.querySelector('#table tbody');
const fragment = document.createDocumentFragment();

data.forEach(row => {
    const tr = document.createElement('tr');
    
    row.forEach(cell => {
        const td = document.createElement('td');
        td.textContent = cell;
        tr.appendChild(td);
    });
    
    fragment.appendChild(tr);
});

tbody.appendChild(fragment);

}

// 优化2:使用 HTML 字符串(最快)
function renderTableOptimized2(data) {
const tbody = document.querySelector('#table tbody');

const html = data.map(row => {
    return '<tr>' + row.map(cell => `<td>${cell}</td>`).join('') + '</tr>';
}).join('');

tbody.innerHTML = html;

}

// 优化3:虚拟列表(针对大数据量)
function renderTableVirtual(data, visibleRows = 20) {
const tbody = document.querySelector('#table tbody');
const rowHeight = 40;

// 设置总高度
const container = document.querySelector('#table');
container.style.height = `${visibleRows * rowHeight}px`;
container.style.overflowY = 'auto';

let scrollTop = 0;

function render() {
    const startRow = Math.floor(scrollTop / rowHeight);
    const endRow = startRow + visibleRows;
    
    const html = data.slice(startRow, endRow).map((row, i) => {
        return `<tr style="height:${rowHeight}px;position:absolute;top:${(startRow + i) * rowHeight}px">` +
               row.map(cell => `<td>${cell}</td>`).join('') + '</tr>';
    }).join('');
    
    tbody.innerHTML = html;
}

container.addEventListener('scroll', () => {
    scrollTop = container.scrollTop;
    requestAnimationFrame(render);
});

render();

}
优化结果
方案 数据量 渲染时间 FPS
原始 10000行 3200ms 3
DocumentFragment 10000行 150ms 60
HTML字符串 10000行 80ms 60
虚拟列表 100000行 50ms 60
结语
前端卡顿的本质,不是"代码慢",而是主线程被阻塞了。理解了这一点,你就掌握了解决问题的关键。

记住这张图:

┌─────────────────────────────────────────────────────────────┐
│ 主线程的一天 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 16.6ms 16.6ms 16.6ms 16.6ms 16.6ms │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │渲染│ │渲染│ │渲染│ │渲染│ │渲染│ → 流畅 │
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
│ │
│ │
│ 16.6ms 超过100ms的JS执行 16.6ms │
│ ┌───┐ ┌──────────────────────┐ ┌───┐ │
│ │渲染│ │ JavaScript │ │渲染│ → 卡顿 │
│ └───┘ │ 执行中... │ └───┘ │
│ └──────────────────────┘ │
│ ↑ │
│ 主线程被占用, 用户感觉页面"死了" │
│ 无法响应任何事件 │
│ │
└─────────────────────────────────────────────────────────────┘
优化方向:

  1. 避免长时间同步计算 → Web Worker / 分片计算
  2. 避免布局抖动 → 批量DOM操作 / CSS transform
  3. 避免大量同步渲染 → 虚拟列表 / 懒加载
  4. 识别长任务 → Performance Profiling
    下次页面卡顿时,不要急着优化算法。先问问自己:是代码慢,还是主线程被阻塞了?

最快的代码是那些从不阻塞主线程的代码。让浏览器保持响应,是前端工程师最基本的尊重。

Summary: 两篇前端性能优化文章

Description: 系统性讲解缓存决策方法与前端主线程阻塞问题,涵盖缓存策略选择、性能分析工具及优化实践。

别再乱加缓存:一套判断"该不该缓存"的方法

引言

"慢?加缓存啊。"

这句话大概是过去十年最流行的性能优化口头禅。从后端API到前端组件,从Redis到LocalStorage,从HTTP缓存到CDN,"缓存"几乎成了解决一切性能问题的万能钥匙。

但现实往往是残酷的。很多团队加了缓存之后,性能没有提升,反而带来了新的问题:数据不一致、内存溢出、缓存穿透、雪崩、击穿……运维跑来说Redis内存告警,开发跑来说缓存命中率只有30%,业务跑来说"用户看到的数据是错的"。

缓存不是万能药。乱加缓存,不如不加缓存。

这篇文章的目标,是给你一套判断"该不该缓存"的思考框架。不是"什么时候用缓存",而是"什么情况下缓存才是正确的选择"。


一、缓存的本质:时间换空间

1.1 缓存是什么?

从技术角度说,缓存是一种将计算结果或数据副本存储在高速存储介质中,以减少未来访问成本的机制

从哲学角度说,缓存是一种用空间换时间(或反之)的权衡

没有缓存:每次请求都需要完整计算
有缓存:第一次请求需要完整计算,后续请求直接读缓存

时间节省 = (完整计算时间 - 缓存读取时间) × 命中次数
额外成本 = 缓存存储空间 + 缓存维护成本 + 一致性保证成本

关键点:缓存只有在"命中次数足够多"的情况下才是划算的。如果一个缓存永远只被访问一次,那它只是浪费内存。

1.2 缓存的隐性成本

在决定加缓存之前,你需要考虑这些隐性成本:

1. 开发成本

  • 缓存逻辑的编写和测试
  • 缓存失效策略的实现
  • 缓存一致性保证的复杂度

2. 运维成本

  • 缓存服务器的部署和维护
  • 内存容量规划
  • 缓存监控和告警

3. 一致性成本

  • 缓存数据与源数据的一致性保证
  • 分布式环境下的缓存同步
  • 异常情况下的降级处理

4. 复杂度成本

  • 缓存层增加了系统的复杂性
  • Debug难度增加
  • 新人学习成本上升

二、一套判断框架:该不该缓存?

下面是我总结的"五问法"。在你决定加缓存之前,先问自己这五个问题。

2.1 第一问:这个数据的"访问频率"够高吗?

核心原则:缓存只对高频访问的数据有效。

缓存的本质是"减少重复计算"。如果一个数据很少被重复访问,那缓存它就没有意义。

评估指标

python
# 缓存收益公式
cache_benefit = hit_rate × (origin_latency - cache_latency) × request_count
cache_cost = memory_cost + maintenance_cost

# 只有当收益大于成本时,缓存才是划算的
is_cache_worthwhile = cache_benefit > cache_cost

典型场景分析

数据类型 访问频率 是否适合缓存
首页推荐内容 极高(万级QPS) ✅ 非常适合
用户个人信息 中等(百级QPS) ✅ 适合
商品详情页 高(千级QPS) ✅ 适合
冷门长尾内容 低(日均几次) ❌ 不适合
只访问一次的数据 极低 ❌ 不适合

如何量化

python
# 计算数据访问频率
access_frequency = access_count / time_window

# 如果访问频率低于某个阈值,就不值得缓存
MIN_CACHE_THRESHOLD = 100  # 每小时至少访问100次

if access_frequency < MIN_CACHE_THRESHOLD:
    return "不建议缓存"

2.2 第二问:计算这个数据的"成本"够高吗?

核心原则:缓存只对"计算成本高"的数据有价值。

如果一个数据的获取成本很低(比如从内存直接读取),那缓存它的收益就微乎其微。

高成本数据的特点

1. 计算密集型:复杂算法、大量数学运算
   - 推荐算法计算
   - 搜索排序计算
   - 报表聚合计算

2. I/O密集型:大量数据库查询或外部调用
   - 多表关联查询
   - 第三方API调用
   - 复杂事务处理

3. 资源密集型:消耗大量系统资源
   - 大文件读取
   - 图像/视频处理
   - 模型推理

成本计算示例

python
# 假设场景:用户主页需要展示哪些数据?
data_costs = {
    # 数据类型: (计算成本ms, 访问频率/小时)
    "用户基础信息": (5, 10000),      # 低成本,高频率
    "关注列表": (50, 1000),          # 中等成本,中等频率
    "个性化推荐": (500, 5000),       # 高成本,高频率 ← 最适合缓存
    "实时在线状态": (2, 50000),       # 低成本,极高频率(但需要实时)
}

for data_type, (cost, freq) in data_costs.items():
    cache_score = cost * freq  # 综合评分
    print(f"{data_type}: 缓存价值评分 = {cache_score}")

2.3 第三问:你能接受什么样的"一致性"级别?

核心原则:缓存一定会带来一致性问题,你需要明确业务能接受的不一致程度。

这是最容易被忽视的问题。缓存和数据源之间必然存在时间差,问题只是这个时间差有多大、业务能不能接受。

一致性级别分类

强一致性:缓存 = 数据源
  → 适合:金钱交易、库存扣减、账户余额
  → 成本:最高,需要同步更新机制

最终一致性:缓存最终会与数据源一致,但存在时间窗口
  → 适合:社交点赞数、阅读量、推荐内容
  → 成本:中等,只需设置合理的过期时间

弱一致性:允许缓存和数据源存在较大差异
  → 适合:CDN静态资源、历史数据归档
  → 成本:较低

业务一致性要求评估

python
# 一致性敏感度评估
consistency_requirements = {
    "账户余额": {
        "max_delay_acceptable": 0,  # 零容忍
        "strategy": "同步更新缓存"
    },
    "商品价格": {
        "max_delay_acceptable": 5,  # 秒级可接受
        "strategy": "缓存+异步更新"
    },
    "商品库存": {
        "max_delay_acceptable": 30,  # 允许30秒延迟
        "strategy": "缓存+定时同步"
    },
    "用户头像": {
        "max_delay_acceptable": 3600,  # 小时级可接受
        "strategy": "长期缓存+版本控制"
    },
}

def should_cache(consistency_requirement):
    max_delay = consistency_requirement["max_delay_acceptable"]
    if max_delay == 0:
        return "不适合缓存,或需要同步双写"
    elif max_delay < 60:
        return "适合短时缓存,需要主动失效机制"
    elif max_delay < 3600:
        return "适合中等缓存,定期刷新"
    else:
        return "适合长期缓存"

2.4 第四问:这个数据是否"可缓存"?

核心原则:不是所有数据都适合缓存。有些数据天然就不该被缓存。

不适合缓存的数据类型

1. 实时性要求极高的数据

python
# ❌ 不该缓存
current_price = get_current_stock_price()  # 股票价格,需要实时

# ✅ 应该缓存
historical_data = get_historical_prices()  # 历史数据,可以缓存

2. 会频繁变化的数据

python
# ❌ 不该缓存(变化太频繁)
active_users = get_current_active_user_count()  # 瞬时在线人数

# ✅ 可以缓存(相对稳定)
user_profile = get_user_profile(user_id)  # 用户资料

3. 包含用户敏感信息的数据

python
# ❌ 不该缓存(或需要特殊加密处理)
session_data = get_user_session()

# ✅ 可以缓存(无敏感信息)
public_article = get_article_content(article_id)

4. 状态相关的数据

python
# ❌ 不该缓存(依赖上下文)
cart_items = get_user_cart()  # 购物车内容

# ✅ 可以缓存
product_catalog = get_product_catalog()  # 商品目录

2.5 第五问:你有合适的"缓存策略"吗?

核心原则:没有正确的策略,只有合适的策略。不同的业务场景需要不同的缓存策略。

常见缓存策略对比

策略 适用场景 一致性 实现复杂度 成本
Cache-Aside 读多写少
Read-Through 读多写少
Write-Through 写多读多 最高
Write-Behind 写多读多
TTL过期 无特殊要求

策略选择决策树

数据访问模式是什么?
├── 读多写少
│   ├── 一致性要求高 → Cache-Aside + 主动失效
│   └── 一致性要求低 → Read-Through + TTL
├── 写多读少 → 不建议缓存,或短期TTL
├── 读写均衡
│   ├── 一致性要求高 → Write-Through
│   └── 允许最终一致 → Write-Behind
└── 写多写多 → 不建议缓存

三、常见缓存错误及避坑指南

3.1 错误一:缓存穿透(Cache Penetration)

问题描述:大量请求访问不存在的数据,缓存永远命中不了,直接打到数据库。

场景

python
# 恶意攻击或异常数据
for request in malicious_requests:
    # 缓存中没有这个key(因为数据根本不存在)
    # 数据库中也没有
    # 每次请求都穿透到数据库
    result = db.query(f"SELECT * FROM users WHERE id = {request.id}")

解决方案

python
# 方案1:缓存空值(但要设置较短TTL)
def get_user(user_id):
    cache_key = f"user:{user_id}"
    result = cache.get(cache_key)
    
    if result is None:
        result = db.query(f"SELECT * FROM users WHERE id = {user_id}")
        # 即使结果是None也缓存,避免重复查询
        cache.set(cache_key, result if result else "NULL", ttl=60)
    
    return None if result == "NULL" else result

# 方案2:布隆过滤器(适用于大量不存在的数据)
bloom_filter = BloomFilter(capacity=1000000, error_rate=0.01)

def get_user(user_id):
    if not bloom_filter.might_contain(user_id):
        return None  # 一定不存在
    
    # 继续查询缓存和数据库
    ...

3.2 错误二:缓存雪崩(Cache Avalanche)

问题描述:大量缓存同时过期,导致大量请求同时穿透到数据库。

场景

python
# 初始化时设置统一TTL
for product in all_products:
    cache.set(f"product:{product.id}", product, ttl=86400)  # 24小时过期
    
# 问题:24小时后,这些缓存同时过期
# 大量请求同时打到数据库

解决方案

python
# 方案1:随机TTL偏移
def set_cache_with_jitter(key, value, base_ttl):
    # 在基础TTL上增加随机偏移量,避免同时过期
    jitter = random.randint(0, int(base_ttl * 0.1))
    cache.set(key, value, ttl=base_ttl + jitter)

# 方案2:永不过期 + 异步更新
class CacheWithBackgroundRefresh:
    def get(self, key):
        value = cache.get(key)
        
        if value is None:
            value = db.query(key)
            cache.set(key, value)  # 永不过期
        
        # 异步检查是否需要刷新
        if self._should_refresh(key):
            asyncio.create_task(self._refresh_async(key))
        
        return value

# 方案3:互斥锁(最简单粗暴)
def get_with_lock(key):
    value = cache.get(key)
    
    if value is None:
        # 获取锁,防止大量请求同时查询数据库
        with redis.lock(f"lock:{key}", timeout=10):
            # 双重检查
            value = cache.get(key)
            if value is None:
                value = db.query(key)
                cache.set(key, value, ttl=3600)
    
    return value

3.3 错误三:缓存击穿(Cache Breakdown)

问题描述:某个热点数据过期瞬间,大量请求同时穿透到数据库。

场景

python
# 某个"爆款"商品缓存过期
# 大量用户同时访问这个商品
# 缓存中没有,请求全部打到数据库

# 举例:双十一零点,某个商品缓存刚好过期
# 10000个并发请求同时查询数据库

解决方案

python
# 方案1:永不过期 + 版本号控制
class CacheWithVersion:
    def get(self, key):
        value = cache.get(key)
        version = cache.get(f"{key}:version")
        
        if self._is_stale(value, version):
            # 后台异步更新,不阻塞请求
            asyncio.create_task(self._update_cache(key))
        
        return value
    
    def invalidate(self, key):
        # 删除缓存后,get时会触发异步更新
        cache.delete(key)
        cache.incr(f"{key}:version")

# 方案2:热点数据永不过期
HOT_PRODUCTS = {}  # 内存缓存,永不过期

def get_hot_product(product_id):
    if product_id in HOT_PRODUCTS:
        return HOT_PRODUCTS[product_id]
    
    product = cache.get(f"product:{product_id}")
    if product:
        # 热门商品放入永不过期的内存缓存
        HOT_PRODUCTS[product_id] = product
    return product

3.4 错误四:过度缓存(Over-Caching)

问题描述:缓存了太多数据,导致内存溢出或命中率极低。

典型症状

python
# 有人开始"见数据就缓存"
cache.set("page_1", fetch_page_1())
cache.set("page_2", fetch_page_2())
cache.set("page_3", fetch_page_3())
# ... 缓存了几十万个页面

# 结果:
# 1. 内存不足
# 2. 大量冷门页面永远不会被访问
# 3. 命中率可能只有5%

解决方案

python
# 监控缓存命中率
def monitor_cache_hit_rate():
    hits = redis.info("keyspace_hits")
    misses = redis.info("keyspace_misses")
    hit_rate = hits / (hits + misses)
    
    if hit_rate < 0.5:  # 命中率低于50%
        alert("缓存命中率过低,考虑减少缓存数据量")
        
    # 定期清理不活跃的缓存
    def cleanup_stale_cache():
        all_keys = redis.scan_iter(match="*")
        for key in all_keys:
            last_access = redis.get(f"{key}:last_access")
            if time.time() - last_access > 7 * 86400:  # 7天未访问
                redis.delete(key)

四、缓存决策流程图

为了帮助你快速决策,我设计了一个简化的决策流程:

开始评估是否需要缓存
        │
        ▼
┌───────────────────┐
│ 1. 数据访问频率   │
│ 是否 > 100次/小时?│
└─────────┬─────────┘
          │
    ┌─────┴─────┐
    │是         │否
    ▼           ▼
┌────────┐   ┌──────────────────┐
│ 继续   │   │ 评估其他优化手段  │
│ 评估   │   │ (索引、异步等)   │
└────────┘   └──────────────────┘
        │
        ▼
┌───────────────────┐
│ 2. 数据获取成本   │
│ 是否 > 50ms?     │
└─────────┬─────────┘
          │
    ┌─────┴─────┐
    │是         │否
    ▼           ▼
┌────────┐   ┌──────────────────┐
│ 继续   │   │ 优先优化数据获取 │
│ 评估   │   │ 暂缓缓存计划    │
└────────┘   └──────────────────┘
        │
        ▼
┌───────────────────┐
│ 3. 一致性要求     │
│ 最大可接受延迟?  │
└─────────┬─────────┘
          │
    ┌─────┴─────┐
    │ < 1秒     │ > 1秒
    ▼           ▼
┌────────┐   ┌──────────────────┐
│ 复杂   │   │ 继续评估         │
│ 双写   │   │                  │
└────────┘   └──────────────────┘
        │
        ▼
┌───────────────────┐
│ 4. 数据是否适合   │
│ 缓存?            │
└─────────┬─────────┘
          │
    ┌─────┴─────┐
    │是         │否
    ▼           ▼
┌────────┐   ┌──────────────────┐
│ 继续   │   │ 不适合缓存       │
│ 评估   │   │ 重新设计方案     │
└────────┘   └──────────────────┘
        │
        ▼
┌───────────────────┐
│ 5. 选择合适策略   │
│                   │
│ Cache-Aside       │
│ Read-Through      │
│ Write-Through     │
│ Write-Behind      │
└─────────┬─────────┘
        │
        ▼
┌───────────────────┐
│ ✓ 可以加缓存     │
│                   │
│ 记得:             │
│ - 监控命中率      │
│ - 设置TTL         │
│ - 处理雪崩        │
└───────────────────┘

五、实战案例:判断一个功能是否该缓存

案例背景

某电商平台商品详情页,页面加载时间 800ms,业务要求 < 500ms。分析后发现主要耗时:

商品基本信息查询:300ms(数据库)
商品库存查询:200ms(外部库存服务)
商品推荐计算:150ms(推荐算法)
商品评价列表:100ms(数据库)
页面渲染:50ms
─────────────────────
总计:800ms

逐一分析

1. 商品基本信息(300ms,查询DB)

访问频率:高(商品详情页是核心页面)
计算成本:高(多表关联查询)
一致性要求:中(允许分钟级延迟)
适合缓存:✅ 是

推荐策略:Cache-Aside + 5分钟TTL
预计收益:每次访问节省280ms

2. 商品库存(200ms,外部服务)

访问频率:高
计算成本:高(跨服务调用)
一致性要求:极高(库存数量直接影响下单)
适合缓存:⚠️ 需要谨慎

推荐策略:
- 页面展示可以缓存,但下单时必须查实时库存
- 或者使用乐观库存(显示"有货"但下单时校验)

3. 商品推荐(150ms,算法计算)

访问频率:中等
计算成本:极高(复杂推荐算法)
一致性要求:低(推荐结果不是关键信息)
适合缓存:✅ 非常适合

推荐策略:Read-Through + 1小时TTL
预计收益:每次访问节省145ms

4. 商品评价(100ms,数据库)

访问频率:中等偏低
计算成本:中等
一致性要求:中
适合缓存:✅ 适合

推荐策略:Cache-Aside + 30分钟TTL
预计收益:每次访问节省95ms

优化后的效果

优化前:800ms

优化后:
商品基本信息:20ms(缓存命中)
商品库存:200ms(需要实时,但下单才查)
商品推荐:5ms(缓存命中)
商品评价:10ms(缓存命中)
页面渲染:50ms
─────────────────────
总计:285ms ✅

收益:节省 515ms,降幅 64%

结语

缓存是一把双刃剑。用得好,可以四两拨千斤;用得不好,只会增加系统复杂度和运维负担。

记住这五问法

  1. 1.访问频率够高吗? — 缓存只对高频访问有效
  2. 2.计算成本够高吗? — 低成本数据不值得缓存
  3. 3.一致性级别是什么? — 零容忍的场景要谨慎
  4. 4.数据是否可缓存? — 实时数据和敏感数据要慎重复
  5. 5.有合适的策略吗? — 没有策略的缓存是灾难

在按下"加缓存"这个快捷键之前,先用这五问法做一次冷静的评估。 你可能会发现,很多情况下,不加缓存才是更好的选择。

5.响应式系统比对:手写 React 响应式状态库 Mobx

前言

我们从前几篇文章中学到了数据响应式的实现原理,虽然它们的实现方式并不相同,但本质原理都是一样的,都是在数据读取的时候进行依赖收集,在数据更改的时候触发依赖。我们知道在 React 的技术栈中也有一个状态管理库 —— Mobx 也是通过数据响应式的方式实现的,那么既然也是数据响应式,那么它的实现本质原理应该都跟 Vue 是一致的,但我们不应该它的代码设计方式改变了,就看不懂了,而恰恰相反正因为我们熟悉 Vue 的数据响应式原理,所以我们 Vue 技术栈的同学应该更容易理解 Mobx 的实现原理才对,不然你不能说你精通了 Vue 的数据响应式原理。

Mobx 与 Vue 的响应式数据的差异

具体来说就是如果在 Vue 中你创建了一个引用类型的响应式数据,你可以直接修改它:

const vueProxy = reactive({ name: 'Cobyte' })
// 直接修改
vueProxy.name = '掘金签约作者'

在 Vue 这种操作是很正常的,但在 Mobx 中这种行为却是不提倡的。那么在 Mobx 中需要怎么修改呢?在 Mobx 中你需要定义一个函数来进行修改:

const mobxProxy = observable({ name: 'Cobyte', update(value) { this.name = value }})
// 通过函数进行修改
mobxProxy.update('掘金签约作者')

当然,在 Mobx 中你也可以像 Vue 那样操作数据,但 Mobx 并不提倡,所以既然你使用了 Mobx 那就就要遵循它的规则,并学习它的优秀设计原理,然后融化为你知识的一部分,在将来你设计代码架构的时候,你所学习到的知识将在无形中响应式着你。

Mobx 的设计原理

我们知道虽然 Vue2 和 Vue3 数据响应式部分的实现有所不同,但实现思路还是一致的。那么跟 Vue 相比同样是实现响应式数据的 Mobx 最大的区别是什么呢?那么要了解这个就去了解 Mobx 的设计原理了。Mobx 的最核心设计原则就是跟 React 的单向数据流设计一致,也同样是单向数据流。也正是基于这个原则导致 Mobx 的代码架构跟 Vue 的数据响应式部分差别比较大。当然 Vue 也是单向数据流设计,并且 Vue 官方也提倡单向数据流,但只是从 Vue 框架层限制了组件的 props 的第一层,而并没有从数据响应式的底层进行限制,而 Mobx 则是从数据响应式的底层就进行限制,所以 Mobx 的单向数据流更为彻底。

我们在 Vue 中创建了一个响应式数据,如果这个响应式数据是引用类型的话,你可以在组件及任何一个其后代组件任何一个角度去修改它,这种方式对于开发功能的人员来说是非常方便的,但对于维护人员来说很可能就是灾难,因为维护人员有时候需要监听数据的更新行为,可并不知道这个响应式数据都在什么地方进行更新。

而在 Mobx 中你创建了一个响应式数据,即便这个响应式数据也是引用类型,在 Mobx 中如果你直接对响应式数据进行修改的话,Mobx 会发出警告,因为在 Mobx 中你需要 React 那样通过一个函数来进行修改,这样就保证了单向数据流的使用规范。

默认情况下,不允许在 actions 之外改变 state。这有助于在代码中清楚地对状态更新发生的位置进行定位。

上述引用来自 Mobx 中文官网,那么怎么可以做到直接修改响应式数据的属性值就发出警告,而通过响应式数据的函数就不会呢?其实原理很简单,我们可以设置一个全局开关,当这个开关打开的时候,我们就可以进行修改,否则就提示警告。在 Mobx 中会对修改函数进行一层封装,变成成一个高阶函数,在执行修改函数之前会打开开关,这样再去修改就不会提示警告了。

Mobx 的初步实现(observable 实现)

我们知道 Mobx 是参考了 Vue 的数据响应式原理,那么最初肯定是只有参考 Vue2 了,那么根据 Vue2 的数据响应式原理,我们很清楚知道一个对象要观察它的数据变化,需要通过 Object.defineProperty 劫持每一个属性的 gettersetter 的操作,同时属性值需要通过闭包进行缓存,还需要通过发布订阅模式来实现依赖(订阅者)和响应式数据之间的通信,具体就是在 getter 的时候进行订阅,在 setter 的时候进行发布,那么在 Vue2 数据响应式中每一个属性所形成的闭包就是一个发布者。那么在 Mobx 的属性值是否也需要通过闭包进行缓存呢?

在 Vue2 中需要一个 Observer 的观察器类来管理响应式数据的相关操作,在 Mobx 中同样需要一个观察器类来管理响应式数据的相关操作,它就是 ObservableObjectAdministration。那么根据我们前面的所学的经验可以很快得到 ObservableObjectAdministration 类的基础代码。如下:

// 对象观察器类
class ObservableObjectAdministration{
    constructor(target) {
        // 原始值保存
        this.target_ = target
        // 订阅者存储中心
        this.values_ = new Map()
    }
}

根据 Vue2 的数据响应式原理我们知道需要通过一个 observe 的函数创建响应式数据,在 Mobx 中也提供了一个叫 observable API 来创建响应式数据。那么根据 Vue2 我们知道需要实例化一个观察器对象,并且把观察器实例对象设置到需要观察的数据上,这样该数据就是响应式数据了。

function observable(target) {
    const adm = new ObservableObjectAdministration(target)
    // 把观察器实例对象设置到需要观察的数据上
    target.__ob__ = adm
    return target
}

在 Vue2 中是在观察器内部进行初始化对被观察数据进行遍历其属性通过 Object.defineProperty 劫持每一个属性的 gettersetter 操作。同样 Mobx 也需要这样,但 Mobx 的设计是在外部进行遍历属性,而不是在观察器内部进行遍历。

function observable(target) {
    const adm = new ObservableObjectAdministration(target)
    // 把观察器实例对象设置到需要观察的数据上
    target.__ob__ = adm
+    Object.keys(target).forEach(key => {
+        // 在这里通过 Object.defineProperty 劫持每一个属性的 `getter`、`setter` 操作
+        adm.defineObservableProperty_(key, target[key])
+    })
    return target
}

// 对象观察器
class ObservableObjectAdministration{
    constructor(target) {
        // 原始值保存
        this.target_ = target
        // 订阅者存储中心
        this.values_ = new Map()
    }
+    // 劫持属性的 getter、setter
+    defineObservableProperty_(key, value) {
+        Object.defineProperty(this.target_, key, {
+            get: () => {
+                // 获取值
+            },
+            set: (val) => {
+                // 设置值
+            }
+        })
+    }
}

在 Vue2 中循环劫持响应式对象的属性时是通过闭包的方式的,即每一个属性都会形成自己的一个闭包,最后读取和设置的值都是闭包中的变量值。而 Mobx 中则把每一个属性的值都包装成一个对象,本质上是通过沙箱模式将每个属性值进行隔离。

那么下面就让我们来实现 Mobx 中的属性劫持吧。

// 对象观察器
class ObservableObjectAdministration{
    constructor(target) {
        // 原始值保存
        this.target_ = target
        // 订阅者存储中心
        this.values_ = new Map()
    }
    // 劫持属性的 getter、setter
    defineObservableProperty_(key, value) {
+        // 将属性值包装成响应式对象
+        const observable = new ObservableValue(value)
        // 将每一个属性和属性值进行记录起来
+        this.values_.set(key, observable)
        Object.defineProperty(this.target_, key, {
            get: () => {
                // 获取值
+                return this.values_.get(key).get()
            },
            set: (val) => {
                // 设置值
+                this.values_.get(key).setNewVal(val)
            }
        })
    }
}
+ // 将属性值包装成响应式对象
+ class ObservableValue {
+    constructor(value) {
+        this.value_ = value
+    }

+    get() {
+        // 在这里进行依赖收集
+        console.log('依赖收集')
+        return this.value_
+    }

+    setNewVal(val) {
+        this.value_ = val
+        // 在这里进行依赖触发
+        console.log('依赖触发')
+    }
+ }

通过上面的代码我们可以看到 Mobx 在通过 Object.defineProperty 劫持对象属性的时候会把属性值通过一个对象进行包裹,也就是 ObservableValue 的实例对象 observable,并且通过键值对的方式保存在 ObservableObjectAdministrationthis.values_ 上,然后在 getter 的时候实际获取的是对应 keyobservable 对象中的值。那么很容易看出来每一个 ObservableValue 的实例对象 observable 都是一个发布者,或者叫被观察者更为贴切一些,反正是一个被观察的对象。

接下来我们就可以进行测试了:

// 创建响应式对象
const mobxProxy = observable({ name: 'Cobyte' })
// 读取触发依赖收集
mobxProxy.name
// 设置值触发依赖
mobxProxy.name = '我是掘金签约作者'

打印结果如下:

A01.png

小结

在前面的讲解 Vue2 的数据响应式原理的文章中,我们说其实每一个属性所形成的闭包就是一个发布者,可能大家还有点难以理解,那么在 Mobx 中每一个属性都通过一个沙箱对象进行包裹,那么这个沙箱对象就是一个发布者,而且代码结构和所谓传统发布订阅模式的代码结构也是比较相似。

在 Mobx 中实现发布订阅模式

那么根据上文我们知道 ObservableValue 是一个发布者,那么我们根据前面的所学的知识,可以很容易完善发布订阅模式的功能。代码如下:

+ // 全局属性
+ const globalState = {
+     trackingDerivation: null // Mobx 中的订阅者全局变量
+ }
 // 将属性值包装成响应式对象
class ObservableValue {
    constructor(value) {
        this.value_ = value,
+        // 订阅者存储中心
+        this.observers_ = new Set()
    }

    get() {
        // 在这里进行依赖收集
+        if (globalState.trackingDerivation) {
+            this.observers_.add(globalState.trackingDerivation)
+        }
        return this.value_
    }

    setNewVal(val) {
        this.value_ = val
        // 在这里进行依赖触发
+        this.observers_.forEach(derivation => derivation())
    }
}

我们经过上面的功能完善,我们从代码结构可以看得出 ObservableValue 是一个发布者。那么接下来我们就可以进行最简单的功能测试了。测试代码如下:

const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}

globalState.trackingDerivation = subscriber
subscriber()
globalState.trackingDerivation = null

// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

我们可以看到正确打印了我们期待的结果:

A02.png

实现订阅者中介 Reaction

接下来我们继续完善订阅者功能,根据我们前面所学习的知识,我们知道需要一个订阅者中介类,在 Mobx 中同样存在一个订阅者中介类,也就是 Reaction,那么根据 Vue2 的 Watcher 功能我们很快可以实现如下代码:

class Reaction {
    constructor(fn) {
        this._fn = fn
        this.get()
    }
    get() {
        globalState.trackingDerivation = this
        this._fn()
        globalState.trackingDerivation = null
    }
    update() {
        this._fn()
    }
}

因为订阅者的功能修改了,所以同时需要修改一下 ObservableValue 类:

// 将属性值包装成响应式对象
class ObservableValue {
    // 省略...
    setNewVal(val) {
        this.value_ = val
        // 在这里进行依赖触发
-        this.observers_.forEach(derivation => derivation())
+        this.observers_.forEach(derivation => derivation.update())
    }
}

接着我们进行测试:

const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}

new Reaction(subscriber)

// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

我们可以看到也是正确打印了我们期待的结果:

A02.png

但上述 Reaction 的实现是根据 Vue2 的 Watcher 类实现的,实现的特点是在初始化的时候进行传进来的副作用函数,并且进行依赖收集,在更新的时候则不再进行依赖收集。而 Mobx 中的实现并不是这样的,但基本原理是一致的,就是在初始化的时候进行依赖收集,更新的时候则不再进行依赖收集,所以我们根据 Mobx 中的实现重新改造一下 Reaction 类。

Reaction 改造如下:

class Reaction {
    constructor(onInvalidate) {
        this.onInvalidate_ = onInvalidate
    }

    track(fn) {
        globalState.trackingDerivation = this
        fn()
        globalState.trackingDerivation = null
    }
    // 更新的时候执行
    schedule_() {
        this.onInvalidate_()
    }
}

我们可以看到在 Reaction 初始化的时候会传进来一个回调函数,这个回调函数会在更新的时候进行,而依赖收集则在 track 函数中进行,看函数名都可以顾名思义了。

实现 autorun 函数

因为 Reaction 更新执行的函数变了,所以我们也需要修改 ObservableValue 类相关功能:

// 将属性值包装成响应式对象
class ObservableValue {
    // 省略...
    setNewVal(val) {
        this.value_ = val
        // 在这里进行依赖触发
-        this.observers_.forEach(derivation => derivation.update())
+        this.observers_.forEach(derivation => derivation.schedule_())
    }
}

那么接下来需要重新修改测试代码:

const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}
// 实例化订阅者中介
const reaction = new Reaction(
    () => {
        // 回调函数中执行依赖收集函数
        reaction.track(subscriber)
    }
)
// 立即执行
reaction.schedule_()
// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

重新执行也同样打印了正确的结果:

A02.png

我们可以看到要像之前那样实现自动执行订阅者函数,需要在实例化 Reaction 的时候设置回调函数 onInvalidate,然后把依赖收集函数的执行放到 onInvalidate 函数中,然后需要开始的时候就立即执行更新方法。这部分相对 Vue2 的 Watcher 类的实现就没有那么容易理解,这主要是因为 Mobx 主要是服务于 React,受 React 的特点影响,所以才这么设计。在后续我们再详细讲解为什么这么设计。

其实上述对订阅者的实现方法,就是 Mobx 的 autorun API 的实现原理。我们将其进行封装实现。

function autorun(view) {
    // 实例化订阅者中介
    const reaction = new Reaction(
        () => {
            // 回调函数中执行依赖收集函数
            reaction.track(view)
        }
    )
    // 立即执行
    reaction.schedule_()  
}

然后我们的测试代码就可以修改成:

const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}
autoruo(subscriber)
// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

修改之后,同样打印了正确的结果:

A02.png

实现使用 actions 更新 state

通过上文对 Mobx 的设计原理的讲解,我们知道为了帮助开发人员清楚地知道状态修改的位置,默认情况下,Mobx 不允许在 actions 之外改变状态。

Mobx 使用单向数据流,利用 action 改变 state ,进而更新所有受影响的 view

上述引用来自 Mobx 中文官网,所谓 action 其实就是一个函数,例如下面的例子:

const mobxProxy = observable({ 
    name: 'Cobyte',
    update(value) { 
        this.name = value 
    }
})
// 通过函数进行修改
mobxProxy.update('掘金签约作者')

通过上文我们知道它的基本原理就是设置一个全局开关,当这个开关打开的时候,我们就可以进行修改,否则就提示警告。其实对修改函数会进行一层封装,变成成一个高阶函数,在执行修改函数之前会打开开关,这样再去修改就不会提示警告了。

我们知道每一个属性值都被封装成了一个 observable 对象,那么我们就可以在 ObservableValue 类中对包装的值进行处理,如果是函数的话,就封装成一个高阶函数(高阶函数(higher-order function)—— 如果一个函数接收的参数为或返回的值为函数,那么我们可以将这个函数称为高阶函数)。

首先我们添加一个全局开关变量:

const globalState = {
    trackingDerivation: null,
+    // 是否允许修改状态的开关
+    allowStateChanges: false
}

那么我们就可以在 ObservableValue 类中对包装的值进行处理:

class ObservableValue {
    constructor(value) {
+        let action
+        // 如果是函数则封装 action 高阶函数
+        if (typeof value === 'function') {
+            action = function(...agrs) {
+                // 在执行原始函数之前开启允许修改开关
+                globalState.allowStateChanges = true
+                // 通过 apply 执行原始函数
+                value.apply(this, agrs)
+                // 执行完原始函数后又关闭开关
+                globalState.allowStateChanges = false
+            }
+        }
-        this.value_ = value
+        // 判断如果是函数则使用封装的 action 高阶函数
+        this.value_ = typeof value === 'function' ? action : value,
        this.observers_ = new Set()
    }
}

接着我们就可以设置值的时候进行判断了:

class ObservableValue {
    setNewVal(val) {
+       if (!globalState.allowStateChanges) {
+          console.warn('Since strict-mode is enabled, changing (observed) observable values without using an action is not allowed')
+       }
        this.value_ = val
        // 在这里进行依赖触发
        this.observers_.forEach(derivation => derivation.schedule_())
    }
}

这时我们就可以进行测试了:

const mobxProxy = observable({ 
    name: 'Cobyte',
    update(val) {
        this.name = val
    }
})
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}
autorun(subscriber)
// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

A03.png 这个时候我们就可以看到直接通过属性进行修改值会发出警告了,然后我们再通过函数修改,则不会了。

mobxProxy.update('掘金签约作者')

通过函数修改则不会发出警告了。

接下来,我们再对我们的代码进行重构一下,让代码结构更接近 Mobx 源码。

+ function createAction(fn) {
+     // 这里有一个需要注意的点,返回函数需要使用 function 进行声明会比较方便获取原生对象的上下文,这里涉及到 this 的问题
+     function res() {
+         // 最后通过 executeAction 执行
+         return executeAction(fn, this, arguments)
+     }
+     return res
+ }

+ function executeAction(fn, scope, args) {
+     // 在执行原始函数之前开启允许修改开关
+     globalState.allowStateChanges = true
+     // 因为是用户写的函数,可能会存在错误,所以使用 try
+     try {
+         // 通过 apply 执行原始函数
+         return fn.apply(scope, args)
+    } catch (err) {
+         throw err
+     } finally {
+         // 执行完原始函数后又关闭开关
+         globalState.allowStateChanges = false
+     }
+ }

+ function deepEnhancer(value) {
+     // 如果是函数则封装 action 高阶函数
+     if (typeof value === 'function') {
+         return createAction(value) 
+     }

+     // todo

+     // 如果是 observable 对象就返回,不处理
+     // 如果是对象进行递归处理
+     // 如果是数组也进行数组的递归处理

+     return value
+ }

class ObservableValue {
    constructor(value) {
+        // 通过 deepEnhancer 处理 value 值
+        this.value_ = deepEnhancer(value)
        this.observers_ = new Set()
    }

    setNewVal(val) {
+        // 设置值之前进行判断是否允许修改
+        checkIfStateModificationsAreAllowed(this)
        this.value_ = val
        // 在这里进行依赖触发
        this.observers_.forEach(derivation => derivation.schedule_())
    }
}

+ function checkIfStateModificationsAreAllowed(atom) {
+    if (!globalState.allowStateChanges) {
+        console.warn('Since strict-mode is enabled, changing (observed) observable values without using an action is not allowed')
+    }
+ }

经过上面的重构我们的代码结构就更接近 Mobx 源码了,所以重构是我们日常编程中非常重要的组成部分。

实现 makeAutoObservable

我们知道 Redux 是函数式编程的推崇者,API 的设计对喜欢函数式编程的开发者非常友好,而 Mobx 的设计则更多偏向于面向对象编程(OOP),在 Mobx 中 class 是一等公民,这对喜欢 OOP 思想的开发者则非常友好。甚至于在 Mobx 的官网给出的实例代都是 OOP 实现的。

Mobx 官网 OOP 例子:

import { makeAutoObservable } from "mobx"

class Timer {
    secondsPassed = 0
    constructor() {
        makeAutoObservable(this)
    }
    increase() {
        this.secondsPassed += 1
    }
    reset() {
        this.secondsPassed = 0
    }
}

const myTimer = new Timer()

我们通过上面的例子可以看到 class 对象的响应式是通过 makeAutoObservable 这个 API 实现的,我们有了上述实现的 Mobx 基本原理的代码基础,再去实现 makeAutoObservable API 是很容易的。

在实现之前,我们需要对 ES class 的基础知识复习一下,class 中的属性在实例化是在实例化对象上的,而 class 的方法则是在原型上的,也就是说上述例子的实现等同于下面的实现:

class Timer {
    secondsPassed = 0
    constructor() {
        makeAutoObservable(this)
    }
}
Timer.prototype.increase = function() {
    this.secondsPassed += 1
}
Timer.prototype.reset = function(){
    this.secondsPassed = 0
}

这些是属于 JavaScript 的面向对象与继承部分的基础知识,这里不作过度深入说明。

通过上文我们知道在 Mobx 中实现数据响应式跟 Vue2 中的基本原理是一样的,也就是遍历要实现响应式的对象所有的属性,并使用 Object.defineProperty 把这些属性全部转为 getter/setter。但 通过 class 实例化的对象除了要获取自身的属性之外,还要获取原型对象上的属性,因为 class 中的方法是设置在原型上的。那么理解了这些之后我们就可以实现 makeAutoObservable API 了。

接下来我们实现一下:

function makeAutoObservable(target) {
    const adm = new ObservableObjectAdministration(target)
    target.__ob__ = adm
    // 获取实例的原型对象
    const proto = Object.getPrototypeOf(target)
    // 同时获取实例对象上的 key 和 原型对象上的 key,才能完整获取 class 中的属性和方法,同时通过 Set 进行去重
    const keys = new Set([...Reflect.ownKeys(target), ...Reflect.ownKeys(proto)])
    // 删除不需要监听的属性
    keys.delete("constructor")
    keys.delete('__ob__')
    // 遍历所有属性进行监听
    keys.forEach(key => {
        adm.defineObservableProperty_(key, target[key])
    })
    return target
}

我们可以看到有了之前实现 Mobx 的基础,再实现 makeAutoObservable 是非常容易的。相比较上面实现的 observablemakeAutoObservable 的实现最大的不同就是属性的获取,因为 makeAutoObservable 是应用在 class 类上的,所以除了获取对象自身上的属性之外,还要获取原型对象上的属性才能完整获取 class 中的属性和方法,同时还需要对所获取的属性和方法进行去重,最后去掉不需要监听的属性。

接下来我们就可以进行测试了:

class Timer {
    secondsPassed = 0
    constructor() {
        makeAutoObservable(this)
    }
    increase() {
        this.secondsPassed += 1
    }
    reset() {
        this.secondsPassed = 0
    }
}

const myTimer = new Timer()     
// 设置订阅者
const subscriber = function() {
    console.log(`现在的秒数:${myTimer.secondsPassed}`)
}

autorun(subscriber)

// 每秒更新一次
setInterval(() => {
    myTimer.increase()
}, 1000)

打印结果如下:

A04.png

可以看到我们实现的 makeAutoObservable 方法可以正确应用在 class 上了。

将手写的 Mobx 应用到 React 上

这小结对 React 不太熟悉的同学也没关太大关系,跟着敲就可以了。首先我们通过 create-react-app 这个脚手架快速创建一个 React 项目。

npx create-react-app react-app

我们把上面实现的 Mobx 功能内容设置到 ./src/mini-mobx.js 中,并且把使用到的函数进行导出。

接着我们把 App.js 文件的内容修改如下:

import { makeAutoObservable, observer } from "./mini-mobx"

class Timer {
  secondsPassed = 0

  constructor() {
    makeAutoObservable(this)
  }

  increaseTimer() {
    this.secondsPassed += 1
  }
}

const myTimer = new Timer()

const TimerView = observer(({ timer }) => <span>Seconds passed: {timer.secondsPassed}</span>)

function App() {
  return (
    <TimerView timer={myTimer}></TimerView>
  );
}

setInterval(() => {
    myTimer.increaseTimer()
}, 1000)

export default App;

我们看到上述的例子其实就是 Mobx 官网的例子,我们把 Mobx 官网的例子跑起来,就说明我们手写的 Mobx 功能是成功的了。上述例子中,我们还需要实现一个函数 observer,我们可以参考上面实现过的 autorun 函数。

我们可以看到 observer 接受的是一个函数组件,返回的也是一个函数组件,那么这就是一个典型的高阶组件,所谓高阶组件,也就是高阶函数,因为函数组件本质就是一个函数。那么我们根据这些特点,我们很容易就构造出 observer 函数基础架构。代码如下:

export function observer(baseComponent) {
    return (props) => {
        return baseComponent(props)
    }
}

页面正常渲染出来了,但还不能自动更新。

A05.png

从发布订阅的角度来说在 React 应用 Mobx 后,所写的函数组件就是一个订阅者,那么根据我们上面实现的 autorun 函数,我们先要实例化一个 Reaction 对象,而不管在 Vue 中还是 React 中函数组件在更新的时候,都是重新执行整个函数组件的,所以我们实例化的 Reaction 对象需要保存起来,那么在 React 里面有提供了一个 useRef 的 Hook,它可以创建一个 mutable ref 对象,在组件的整个生命周期内该对象保持不变。简单来说就是 useRef 可以创建一个可以保存状态的 Hook,即使组件重新渲染,其内部的值也不会变化。

import { useRef } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
    return (props) => {
        const admRef = useRef(null)
        if (!admRef.current) {
            // 实例化订阅者中介
            const reaction = new Reaction(
                () => {
                    // 回调函数中执行依赖收集函数
                    reaction.track(baseComponent)
                }
            )
            admRef.current = reaction
        }
        const reaction = admRef.current
        // 立即执行
        reaction.schedule_()
    }
}

页面显示如下:

A06.png

我们根据 autorun 的实现原理初步实现了上述功能,但报错了,原因是组件的 props 没有传进去,所以我们进行以下修改:

import { useRef } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
    return (props) => {
        const admRef = useRef(null)
        if (!admRef.current) {
            // 实例化订阅者中介
            const reaction = new Reaction(
                () => {
                    // 回调函数中执行依赖收集函数
+                    reaction.track(() => {
+                        baseComponent(props)
+                    })
                }
            )
            admRef.current = reaction
        }
        const reaction = admRef.current
        // 立即执行
        reaction.schedule_()
    }
}

页面显示如下:

A07.png

我们发现不报错了,但页面并没有渲染,没渲染的原因是我们并没有把函数组件执行的内容返回,所以我们继续进行以下修改:

import { useRef } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
    return (props) => {
+        let renderResult
        const admRef = useRef(null)
        if (!admRef.current) {
            // 实例化订阅者中介
            const reaction = new Reaction(
                () => {
                    // 回调函数中执行依赖收集函数
                    reaction.track(() => {
+                      renderResult = baseComponent(props)
                    })
                }
            )
            admRef.current = reaction
        }
        const reaction = admRef.current
        // 立即执行
        reaction.schedule_()
+        return renderResult
    }
}

修改后页面显示如下:

A08.png

经过上面修改,我们的页面可以渲染出来了,但又遇到新的问题了,页面并没有更新。按理来说,我们上面的 observer 是已经根据 autorun 的实现方式进行实现了。我们可以在 Reaction 的回调函数中进行打印,

export function observer(baseComponent) {
    return (props) => {

        if (!admRef.current) {
            const reaction = new Reaction(
                () => {
                    // 回调函数中执行依赖收集函数
                    reaction.track(() => {
                      renderResult = baseComponent(props)
+                      console.log('renderResult', renderResult)
                    })
                }
            )
            admRef.current = reaction
        }
+        console.log('outer')
    }
}

打印显示如下:

A09.png

我们发现其实我们的 Reaction 的回调函数已经重新执行了,但整个组件函数并没有重新执行,所以并没重新渲染内容。所以我们现在只要考虑把整个组件实现重新渲染就可以了。那么熟悉 React 的同学可能会知道在 React 函数组件中可以通过 useState 改变 state 值来触发组件的重新渲染。这个也是 Vue 和 React 区别非常大的一个地方。那么我们可以在 Reaction 的回调函数中执行更新函数,把依赖收集的相关代码放到外面执行。

代码修改如下:

import { useRef, useState } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
    return (props) => {
+        const [, setState] = useState()
        let renderResult
        const admRef = useRef(null)
        if (!admRef.current) {
            // 实例化订阅者中介
            const reaction = new Reaction(
                () => {
+                    // 执行更新
+                    setState(Symbol())
               }
            )
            admRef.current = reaction
        }
        const reaction = admRef.current
+        // 执行依赖收集函数
+        reaction.track(() => {
+          renderResult = baseComponent(props)
+        })
 
        return renderResult
    }
}

页面渲染如下:

01.gif

我们重新修改后,可以正常如期执行了。至此我们手写的 Mobx 也实现了在真实 React 环境中执行了。

总结

本文通过相对比较简洁的代码实现了 Mobx 的核心原理,同时对比了同时响应式的 Vue 和 Mobx 的最大设计区别,在 Vue 中创建的响应式数据,是可以随意在任何地方通过普通属性访问器进行修改的,但 Mobx 中则不提倡这种可以随意修改 state 的方式,在 Mobx 中希望开发者通过 actions 来改变 state,本质是像 React 那样通过一个函数来修改 state,或者说是遵循 Flux 和 Redux 的单向数据流思想。同时 Mobx 中的订阅者中介 Reaction 和 Vue 中的订阅者中介实现则有比较大的区别,主要是因为 Mobx 主要的设计受 React 的影响,在更新的时候需要特别的设置,而不像 Vue 那样直接重新运行副作用函数就可以了,这个说到底也是因为 React 不是靠依赖追踪来实现响应式的缘故。

那么具体 Mobx 的 Reaction 的要这样设计,而不能像 Vue 那样简洁呢,我们下一篇文章中继续探讨。

上述文章写于:2023 年,由于个人原因今年 2026 年发布。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

「JS全栈AI学习」九、Multi-Agent 系统设计:架构与编排

📌 系列简介:「JS全栈AI Agent学习」系统学习 21 个 Agent 设计模式,篇数随学习进度持续更新。

📖 原书地址adp.xindoo.xyz

前端转 JS 全栈,正在学 AI,理解难免有偏差,欢迎批评指正 ~


往期系列导航

主题
第一篇 提示链 · 路由 · 并行化
第二篇 反思 · 工具使用 · 规划
第三篇 多智能体 · 记忆管理 · 学习适应
第四篇 MCP:给AI工具世界造一个USB接口
第五篇 目标设定与监控 · 异常处理与恢复
第六篇 Human-in-the-Loop 设计
第七篇 深入理解 RAG(检索增强生成)技术
第八篇 A2A 协议完全指南:理解 Agent 协作体系
本篇 Multi-Agent 系统设计:架构与编排

写在前面

这个系列前几篇,我们从 RAG 开始——把简历切片、向量化、检索,让 AI 能"翻书再答题"。 再到 A2A 协议——搞清楚多个 Agent 之间怎么互相发现、互相协作、互相信任。

但学到这里,我意识到还有一个问题没解决:

Agent 之间协作,谁来指挥?谁来编排?出了问题谁来兜底?

这就是 Multi-Agent 系统设计要回答的问题。

这篇是这个话题的第一篇,聚焦在三件事:架构选择、动态编排、状态管理。 用"旅行规划"这个场景贯穿始终——不是因为它特别,而是因为它足够复杂,能把问题说清楚。

九、十、十一 3篇对应学习的 第15章:Multi-Agent 系统架构、第16章:工作流编排与规划、第17章:成本优化与执行策略 很多孤立起来说没意义,加上 multi-agent 比较重要就放一起了


目录

  1. 为什么需要 Multi-Agent?
  2. 架构设计:中心化 vs 去中心化
  3. 动态工作流编排
  4. 上下文管理
  5. 状态管理与一致性
  6. Human-in-the-loop
  7. 完整流程串联
  8. 总结

Multi-Agent 系统设计


1. 为什么需要 Multi-Agent?

单 Agent 的局限

假设用户说:"帮我规划一次去三亚的旅行,预算 5000 元。"

如果用单个 Agent 处理,它需要同时具备:理解意图、查航班、查酒店、查景点、规划路线、计算预算……

这些能力混在一起,代码会变得臃肿且难以维护。更重要的是,每个环节都有专业知识和外部 API,单个 Agent 很难做到精通所有领域。

分工协作的思路

借鉴现实世界的分工,我们可以设计多个专业的 Agent,各司其职:

NLU Agent      → 理解用户意图
Profile Agent  → 分析用户偏好
Planner Agent  → 制定整体计划
Flight Agent   → 查询航班信息
Hotel Agent    → 查询酒店信息

好处很直接:

  • 职责清晰:每个 Agent 只做一件事,做好一件事
  • 易于维护:修改航班查询逻辑,只需要改 Flight Agent
  • 可复用:Flight Agent 可以用在其他旅行相关场景

这和前面学 A2A 时的思路是一脉相承的——A2A 解决的是 Agent 之间"怎么通信",Multi-Agent 设计解决的是"怎么协作"。


2. 架构设计:中心化 vs 去中心化

Multi-Agent 系统有两种常见的架构模式,选哪个,取决于场景。

中心化架构(Coordinator 模式)

有一个中心协调者(Coordinator)负责调度所有 Agent:

class Coordinator {
  async execute(userInput: string): Promise<Result> {
    // 串行:理解意图 → 分析画像 → 制定计划
    const intent   = await this.agents.nlu.execute({ userInput });
    const profile  = await this.agents.profile.execute({ intent });
    const plan     = await this.agents.planner.execute({ intent, profile });

    // 并行:同时查询航班、酒店、景点
    const [flights, hotels, attractions] = await Promise.all([
      this.agents.flight.execute({ plan }),
      this.agents.hotel.execute({ plan }),
      this.agents.attraction.execute({ plan }),
    ]);

    return this.integrate({ flights, hotels, attractions });
  }
}

流程清晰,统一的错误处理和状态管理,便于调试——代价是 Coordinator 是单点,压力大。

去中心化架构(P2P 模式)

Agent 之间通过消息总线直接通信,没有中心协调者:

// Flight Agent 完成后,发布事件,其他 Agent 自行订阅响应
class FlightAgent {
  async execute(context: Context): Promise<Result> {
    const result = await this.queryFlights(context);
    this.messageBus.publish({ type: 'flights_ready', data: result });
    return result;
  }
}

没有单点瓶颈,扩展性好——代价是流程不直观,调试困难。

我的选择

对于旅行规划这种有明确步骤的场景,我选择了中心化架构

原因很简单:旅行规划有清晰的先后顺序(理解意图 → 制定计划 → 查询信息 → 整合结果),需要强一致性(预算控制不能各个 Agent 各自为政),也需要便于调试。

如果是实时监控、事件驱动的场景,去中心化可能更合适。架构没有对错,只有合不合适。


3. 动态工作流编排

有了架构,接下来的问题是:如何编排这些 Agent 的执行顺序?

静态编排的问题

最简单的方式是写死流程——但太死板了:

  • 如果用户直接说"帮我订明天去北京的机票",还需要分析画像吗?
  • 如果用户已经订好了酒店,还需要查询酒店吗?

动态主导权转移

我想到一个思路:让 Agent 自己决定下一步该谁执行

就像接力赛,当前跑的人决定把棒交给谁——流程就灵活了:

class Agent {
  async execute(context: Context): Promise<ExecutionResult> {
    const result = await this.doWork(context);

    // 根据当前状态,决定把主导权交给谁
    const nextAgent = this.decideNextAgent(context, result);

    return { result, nextAgent, context: this.updateContext(context, result) };
  }

  private decideNextAgent(context: Context, result: any): string | null {
    if (context.needsFlightInfo && !context.hasFlightInfo) return 'flight_agent';
    if (context.needsHotelInfo  && !context.hasHotelInfo)  return 'hotel_agent';
    return null; // 没有下一步了
  }
}

Coordinator 只需要不断传递主导权,直到没有下一步:

class DynamicCoordinator {
  async execute(userInput: string): Promise<Result> {
    let context     = this.initContext(userInput);
    let currentAgent = 'nlu_agent';

    while (currentAgent) {
      const { result, nextAgent, context: newContext } =
        await this.agents.get(currentAgent).execute(context);

      context      = newContext;
      currentAgent = nextAgent; // 主导权转移
    }

    return context.finalResult;
  }
}

这个思路让我想起乾卦的"时乘六龙以御天"——不是死守固定的步骤,而是顺应时机,动态调整。

充分条件原则

动态编排带来一个新问题:Agent 怎么知道自己能不能执行?

我的答案是:定义每个 Agent 的前置条件,条件不满足就反向补全

class FlightAgent {
  canExecute(context: Context): boolean {
    return context.has('destination') &&
           context.has('departureCity') &&
           context.has('travelDate');
  }

  async execute(context: Context): Promise<Result> {
    const missing = this.checkMissing(context);

    if (missing.length > 0) {
      // 反向传播:请求 NLU Agent 补全缺失信息
      context.requestInfo(missing);
      return { status: 'pending', nextAgent: 'nlu_agent' };
    }

    return await this.queryFlights(context);
  }
}

这就像神经网络的反向传播:从目标反推需要什么输入,然后向前传播补全信息。


4. 上下文管理

多个 Agent 协作,必然涉及信息共享。Context 的设计很关键。

上下文的结构

interface Context {
  requestId: string;
  traceId: string;       // 链路追踪

  user: { id: string; preferences: UserPreferences };

  intent: Intent;
  destination: string;
  budget: number;

  completedAgents: string[];
  results: Map<string, any>;
}

只传递必要的信息

不是所有信息都需要传递。我的原则是:每个 Agent 只提取自己需要的,只返回必要的结果

class FlightAgent {
  async execute(context: Context): Promise<Result> {
    // 只提取需要的字段
    const { destination, departureCity, travelDate, budget } = context;

    const flights = await this.queryFlights({
      destination, departureCity, travelDate,
      maxPrice: budget * 0.4,  // 航班预算占总预算 40%
    });

    // 只返回必要的结果,不把原始数据全部往下传
    return {
      flights: flights.slice(0, 5),
      cheapestPrice: flights[0].price,
      recommendedFlight: this.selectBest(flights),
    };
  }
}

信息过载和信息不足一样危险——这是做 RAG 时就踩过的坑,在 Multi-Agent 里同样成立。


5. 状态管理与一致性

当多个 Agent 并行执行时,会遇到状态一致性问题。

问题场景

t0: 用户说"预算 5000 元"
t1: Flight Agent  Hotel Agent 同时开始查询(基于 5000 元)
t2: 用户说"我想把预算改成 8000 元"
问题:Flight Agent 已经查完了,结果还有效吗?

版本控制

解决方案:给上下文加版本号

class StateManager {
  private version = 0;

  // 更新上下文时,版本号递增
  updateContext(updates: Partial<Context>): void {
    this.version++;
    this.context = { ...this.context, ...updates, version: this.version };
  }

  // Agent 开始执行时,创建快照(记录当前版本)
  createSnapshot(agentId: string): ContextSnapshot {
    return { version: this.version, context: { ...this.context }, agentId };
  }

  // Agent 提交结果时,检查版本是否一致
  submitResult(agentId: string, result: any, snapshotVersion: number): boolean {
    if (snapshotVersion < this.version) {
      console.log(`${agentId} 的结果已过期,需要重新执行`);
      return false;
    }
    return true;
  }
}

乐观锁 vs 悲观锁

对于状态冲突,有两种策略:

  • 乐观锁:先执行,提交时检查版本——适合读多写少的场景(查询航班)
  • 悲观锁:先加锁,执行完再释放——适合写操作(预订机票)

我的选择是混合策略:查询用乐观锁,性能高;预订用悲观锁,保证一致性。

这个思路和数据库事务设计是一回事——底层的逻辑,跨越了层次,是相通的。


6. Human-in-the-loop

完全自动化不一定是最好的。有时候,让用户参与决策反而更好。

最小干预原则

我的原则是:只在关键决策点询问用户,其他信息能推断就推断,能用默认值就用默认值

class ProgressiveConfirmation {
  async execute(userInput: string): Promise<Result> {
    const intent = await this.nluAgent.execute({ userInput });

    // 只问缺失的关键信息
    if (!intent.destination) {
      intent.destination = await this.askUser("您想去哪里?");
    }

    // 非关键信息:推断或使用默认值
    intent.budget     = intent.budget     || this.inferBudget(intent);
    intent.travelDate = intent.travelDate || this.getDefaultDate();

    // 非关键信息在后续流程中再问,不要一次性问完
    return this.continueExecution(intent);
  }
}

一次性问用户十个问题,用户会直接关掉。逐步确认,每次只问最关键的那一个。

何时必须让用户介入?

回顾前面学 A2A 时总结的四种情况,在 Multi-Agent 编排里同样适用:

  1. 权限/能力边界:Agent 遇到了自己无权处理的事
  2. 死锁/僵局:系统自己解不开
  3. 高风险不可逆操作:预订、付款、发送——做了就很难撤回
  4. 置信度低于阈值:Agent 不够确定,不该自己做主

7. 完整流程串联

把上面的思路串起来,看一个完整的执行流程:

class TravelPlanningSystem {
  async plan(userInput: string): Promise<TravelPlan> {
    // 1. 初始化上下文
    const context = { traceId: generateId(), version: 0, userInput, results: new Map() };

    // 2. NLU → 补全缺失信息 → Profile → Planner(串行)
    const intent = await this.nluAgent.execute(context);
    if (!intent.destination) {
      intent.destination = await this.askUser("您想去哪里?");
    }
    context.intent  = intent;
    context.profile = await this.profileAgent.execute(context);
    context.plan    = await this.plannerAgent.execute(context);

    // 3. 并行查询(带版本快照)
    const snapshot = this.stateManager.createSnapshot('parallel_query');
    const [flights, hotels, attractions] = await Promise.all([
      this.flightAgent.execute(snapshot.context),
      this.hotelAgent.execute(snapshot.context),
      this.attractionAgent.execute(snapshot.context),
    ]);

    // 4. 检查版本冲突(用户可能中途修改了预算)
    if (snapshot.version < this.stateManager.currentVersion) {
      return this.plan(userInput); // 重新规划
    }

    // 5. 整合结果
    return this.integrate({ flights, hotels, attractions, plan: context.plan });
  }
}

流程里有几个细节值得注意:

  • 串行和并行混用——有依赖关系的步骤串行,独立的步骤并行
  • 版本快照在并行开始前创建,不是在结束后
  • 版本冲突时直接重新规划,不是尝试修补

8. 总结

这篇学到的几个判断

架构选择没有对错,只有合不合适。 有明确流程的场景用中心化,事件驱动的场景用去中心化。

动态编排比静态编排更灵活,但更难调试。 主导权转移的思路很好,但要做好链路追踪,不然出了问题很难定位。

充分条件原则是 Multi-Agent 设计的基础。 每个 Agent 都应该知道自己需要什么、能做什么、做不了的时候该怎么办。

状态一致性是并行执行的核心挑战。 版本控制 + 混合锁策略,是目前我觉得最实用的解法。

和前面内容的关系

回头看这个系列走过的路:

RAG          → 让 Agent 能"翻书再答题"(知识检索)
A2A 协议     → 让 Agent 之间能互相发现、协作、信任(通信协议)
Multi-Agent  → 让多个 Agent 能有序地协同完成复杂任务(编排调度)

每一层都在解决上一层留下的问题。


写在最后

学这一章的时候,有一个细节让我停下来想了一下。

动态主导权转移那里,每个 Agent 在执行完之后,都要做一个判断:下一步该谁?

不是由外部强行指定,而是由当前执行者根据现状来决定。

这让我想起易经里的一个说法:"知几其神乎"——几,是事物变化的苗头,是时机的信号。 真正懂得顺势而为的人,不是按计划行事,而是在每一个当下,感知现状,做出最合适的那个判断。

Multi-Agent 的动态编排,其实是在用代码实现这件事: 不是写死流程,而是让每个节点都有感知、有判断、有选择。

系统如此,人也如此。


昇哥 · 2026年4月 学 Multi-Agent 系统设计途中,把想清楚的事写下来

给普通人的 AI 黑话翻译手册:一文看懂 LLM、RAG、Agent 到底是什么

给普通人的 AI 黑话翻译手册:一文看懂 LLM、RAG、Agent 到底是什么

这两年,人工智能像突然学会了"说人话"一样冲进了每个人的工作和生活。有人用它写文案,有人用它做表格、查资料、写代码、做客服。与此同时,一堆原本只在技术圈里流行的词,也开始频繁出现在产品发布会、行业新闻和公司汇报里:LLM、RAG、Embedding、向量数据库、微调、Agent、幻觉、上下文窗口……

很多人第一次看到这些词时,都会有一种共同感受:每个字都认识,连在一起就看不懂。

这篇文章就是写给这类读者的。你不需要会编程,也不需要有算法背景,只要把它当成一本"AI 黑话翻译手册"来看就行。我的目标不是把你训练成工程师,而是帮你建立一张足够清晰的地图:这些词分别是什么意思,它们在整套 AI 系统里干什么,彼此之间又是什么关系。

一、先把最基础的四个词分清:AI、机器学习、深度学习、神经网络

很多人一开始就被最基础的几个词绕晕了:人工智能、机器学习、深度学习、神经网络。它们不是并列关系,而更像一层层套着的盒子。

  • 人工智能(AI) 是最大的那个框。凡是让机器表现出某种"像人一样"的智能能力,比如识别图片、理解语言、做判断、生成内容,都可以放进 AI 里。
  • 机器学习(Machine Learning) 可以理解成:不给机器写死所有规则,而是让它从数据里自己学规律。传统编程更像"人先把规则写好";机器学习更像"给机器很多例子,让它自己总结模式"。
  • 深度学习(Deep Learning) 是机器学习里最火的一支。它依赖多层神经网络来学习复杂模式,所以叫"深度"。很多现代 AI 能力,比如语音识别、图像识别、大模型,背后都离不开深度学习。
  • 神经网络(Neural Network) 则可以看成深度学习的骨架。它借用了"大脑神经元连接"的启发,用数学网络去完成"输入—加工—输出"的过程。

💡 只记一句话就够了:AI 是总称,机器学习是方法,深度学习是更强的一类方法,神经网络是深度学习的骨架。

graph TD
    subgraph AI [人工智能 AI]
        subgraph ML [机器学习 Machine Learning]
            subgraph DL [深度学习 Deep Learning]
                NN(("神经网络<br>Neural Network"))
            end
        end
    end
    style AI fill:#e1f5fe,stroke:#0288d1,stroke-width:2px
    style ML fill:#b3e5fc,stroke:#0288d1,stroke-width:2px
    style DL fill:#81d4fa,stroke:#0288d1,stroke-width:2px
    style NN fill:#4fc3f7,stroke:#0288d1,stroke-width:2px

二、生成式 AI 为什么会突然爆发?

过去很多 AI 系统主要只会"判断",比如判断这封邮件是不是垃圾邮件、这张图里是不是有车、这笔交易有没有风险。它们更像"分类器"。

但这几年真正爆火的是 生成式 AI(Generative AI) 。它和以前 AI 最大的区别在于:以前很多系统只能告诉你"是什么",现在它可以直接"写出来""画出来""做出来"。

你输入一句"帮我写一封道歉邮件",它能给你整封信;你输入一句"画一张赛博朋克风的城市夜景",它能直接生成图片;你给它一个需求,它甚至能写出一段代码。

所以,生成式 AI 的核心不是"更聪明地判断",而是"更主动地创造内容"。

graph LR
    subgraph traditional [传统AI模型]
        A1["输入: 一张图片"] --> B1{"分类器"} --> C1["输出标签: 这是一只猫"]
    end
    
    subgraph generative [生成式AI]
        A2["输入 Prompt:<br>画一只赛博朋克的猫"] --> B2{"生成大模型"} --> C2["创造新内容:<br>一张赛博朋克猫的高清图"]
    end
    
    style traditional fill:#f5f5f5,stroke:#9e9e9e
    style generative fill:#f3e5f5,stroke:#9c27b0

三、NLP、LLM、多模态,这几个词差在哪?

1. NLP:让机器理解人类语言

自然语言处理(NLP) 是 AI 和计算机科学中的一个方向,目标是让机器理解、处理和生成人类语言。 搜索引擎、语音助手、翻译软件、客服机器人,很多我们习以为常的能力,本质上都属于 NLP。

2. LLM:会读会写的大语言模型

LLM(Large Language Model,大语言模型) 是最近最常见的词之一。你可以把它想象成一个"读过很多东西、特别会组织语言的系统"。它擅长聊天、总结、翻译、写代码、改写文风、提取信息,甚至做一些初步推理。

但它也有局限:知识可能过时,不天然知道你的私有资料,而且有时会一本正经地胡说八道。

3. 多模态:不只懂文字,还会看图听音

现在越来越多模型不只处理文本,还能处理图片、音频、视频。这类模型通常被叫做 多模态模型(Multimodal)

💡 所以,如果说传统 LLM 更像"会读会写的人",那多模态模型更像"既会读写,也会看、会听、会综合判断的人"。

graph LR
    A1["文本 Text"] --> B(("多模态大模型<br>Multimodal LLM"))
    A2["图像 Image"] --> B
    A3["音频 Audio"] --> B
    B --> C1["生成文字回答与推理"]
    B --> C2["生成全新的图像/语音"]

四、Prompt、Token、上下文窗口:为什么同一句话,问法不同结果差很多?

1. Prompt:你怎么问,决定它怎么答

Prompt 就是你给模型的输入,也就是提示词。提示词工程的本质,不是什么神秘咒语,而是:把任务说清楚,把边界讲明白,把结果定义好。

你说"帮我总结一下",和你说"请用小白能看懂的语言,从背景、核心概念、例子、风险四部分总结,每部分不超过 120 字",效果通常会差很多。前者太模糊,后者给了明确任务、目标读者和输出格式。

2. Token:AI 眼中的"文字颗粒"

模型处理输入时,会先把文本切成更小的单元,这些单元叫 Token。它不完全等于汉字,也不完全等于单词,但你可以把 token 理解成模型处理文字时的基本颗粒。

3. 上下文窗口:模型一次能看到多少内容

上下文窗口(Context Window) 指的是模型在单次请求里最多能处理多少 token。

你可以把它想象成模型面前的一张工作台。台子越大,一次能摊开的材料就越多;台子越小,就得先删减信息。为什么超长文档要切片?为什么对话太长模型会"忘事"?原因之一就在这里。

五、Embedding、向量数据库、语义搜索:RAG 之前必须懂的三件事

1. Embedding:把内容变成数字坐标

Embedding(向量嵌入) 是很多人第一次听会觉得很抽象的词。简单说,就是把一句话、一段文档甚至一张图片,转换成一串数字。数字本身你看不懂,但这些数字之间的距离,可以反映"语义上像不像"。

例如"苹果手机充电慢怎么办"和"iPhone 电池掉电快如何处理"这两句话,字面并不一样,但语义接近。Embedding 正是为了帮助系统发现这种"意思相近"。

2. 向量数据库:专门存这些坐标的地方

如果 Embedding 是把内容变成坐标,那么 向量数据库(Vector Database) 就是专门存这些坐标,并且能快速找出"谁最像谁"的数据库。

普通数据库更擅长精确查找,向量数据库更擅长找"语义相近"的内容。

3. 语义搜索:不是搜字面,而是搜意思

传统搜索偏关键词匹配;语义搜索则更进一步,它尝试理解"你真正想找什么"。

💡 所以,Embedding + 向量数据库 + 语义搜索,常常是现代 AI 检索系统的三件套。

六、RAG:为什么几乎成了企业 AI 的标配?

终于可以讲最常被提到的 RAG 了。

RAG(Retrieval-Augmented Generation,检索增强生成) ,你可以把它理解成:模型先别急着回答,先去查资料,再根据查到的资料作答。

你可以把 RAG 想成开卷考试系统。普通 LLM 更像"只靠自己记忆答题";RAG 则是"先翻书,再作答"。这对企业尤其重要,因为企业里有大量模型训练时根本没见过的资料,比如公司制度、产品说明书、内部知识库、合同模板、项目文档。

RAG 的典型流程通常是:

sequenceDiagram
    actor 用户
    participant 检索系统
    participant 企业知识库
    participant 大语言模型
    
    用户->>检索系统: 1. 提问:"公司最新报销额度是多少?"
    检索系统->>企业知识库: 2. 向量化检索相关内部文档
    企业知识库-->>检索系统: 3. 返回匹配片段:《员工手册.pdf》
    检索系统->>大语言模型: 4. 组装Prompt (原始问题 + 检索到的内部规则)
    大语言模型-->>用户: 5. 基于文档内容生成精准回答

它的价值很直接:

  • 补私有知识:让模型能用企业自己的资料回答问题
  • 补新知识:资料更新后,不必重训整个模型
  • 降幻觉:回答更容易"有据可依"

当然,RAG 也不是万能药。它可能搜不到关键资料,也可能搜到的片段不完整,或者模型虽然看到了资料,却没有正确引用。所以工程上还会继续讨论"文档切片""重排序""引用""评测"等问题。

七、微调、SFT、RLHF:这是在"改模型",不是在"查资料"

很多人会把 RAG微调(Fine-tuning) 混在一起。其实它们解决的是两类问题。

1. 微调:让模型更像你想要的样子

监督微调(SFT) 可以理解成:用示例输入和理想输出去继续训练模型,让它更可靠地产生你需要的风格和内容。

所以:

  • RAG 是在回答前给模型临时补资料(查资料)。
  • 微调 是把行为习惯"练进模型里"(改模型)。

适合微调的场景通常包括:固定输出格式、品牌语气、特定行业术语、稳定执行某类任务。但如果你的问题是"知识经常更新",更适合 RAG,而不是微调。

2. RLHF:让模型更符合人类偏好

你可能还听过一个词:RLHF(Reinforcement Learning from Human Feedback,人类反馈强化学习) 。它可以简单理解成:利用人类偏好不断给模型"打分",让模型慢慢学会什么样的回答更好、更自然、更符合人类习惯。

如果继续用"训练一个助理"来打比方:

graph LR
    A["预训练模型<br>读书破万卷"] -->|SFT 监督微调| B["专项训练模型<br>做标准模拟卷"]
    B -->|RLHF 人类反馈| C["偏好对齐模型<br>老师根据表现打分"]
    C --> D(("懂规矩、听话的<br>AI 助手"))
    
    style A fill:#fff3e0,stroke:#ff9800
    style B fill:#e3f2fd,stroke:#2196f3
    style C fill:#f3e5f5,stroke:#9c27b0
    style D fill:#e8f5e9,stroke:#4caf50

八、Function Calling、Workflow、Agent:让 AI 不只是会说,还会做

1. Function Calling:模型开始学会调用工具

Function Calling(函数调用/工具调用) 的意思是:让模型不只是输出文字,还能去调用外部工具和系统,比如查数据库、调接口、搜订单、发邮件、写日历、执行代码。

这一步很关键,因为真实世界的很多任务,不是"会说"就够了,而是"得真的做"。模型相当于大脑,工具相当于手脚。

2. Workflow:先把流程钉住,再把模型放进去

很多企业一开始做 AI,最稳的路线不是直接上 Agent,而是先做 Workflow(工作流) 。也就是把流程先拆好、定好,再把模型嵌进其中某些步骤里。它的优点是稳定、可控、容易审计。

3. Agent:能规划、能调用工具、能多步完成任务的 AI

Agent(智能体) 是这两年另一个热词。它和普通聊天机器人的最大区别在于:聊天机器人通常是"你问一句,它答一句";Agent 更像"你给一个目标,它自己拆步骤想办法完成"。

比如你说:"帮我做一份某行业的竞品分析。"一个 Agent 可能会列提纲、搜资料、整理信息、生成表格、写总结,甚至在发现证据不够时回头继续查。

💡 但现实里最常见的情况其实不是"全都做成 Agent",而是:Workflow 打底,Agent 只负责其中更灵活的部分。

graph TD
    subgraph workflow_sys [Workflow 固定工作流]
        W1["步骤1: 提取网页文本"] --> W2["步骤2: LLM翻译"] --> W3["步骤3: 存入数据库"]
    end
    
    subgraph agent_sys [Agent 智能体]
        A1(("Agent核心大脑")) <-->|"设定目标与拆解"| A2["自主规划"]
        A1 <-->|"执行"| A3["调用外部工具 / API"]
        A1 <-->|"记忆与反思"| A4["获取历史信息并调整策略"]
    end
    
    style workflow_sys fill:#f5f5f5,stroke:#9e9e9e
    style agent_sys fill:#e8eaf6,stroke:#3f51b5

九、幻觉、评测、护栏:为什么 AI 不是会回答就够了?

1. 幻觉:一本正经说错话

AI 幻觉(Hallucination) 指的是模型输出了看起来像真的、但其实不准确甚至完全错误的内容。 这也是为什么很多人觉得 AI 很强,却又不敢完全信它。它厉害的地方在于表达流畅,危险的地方也在于此:它就算错了,也可能错得非常自信。

2. Evals:不评测,就不知道它到底行不行

很多 AI 产品演示时都很惊艳,但一到真实业务场景就容易翻车。于是就有了 Evals(评测) 。评测的核心不是"这次演示不错",而是系统化地衡量准确率、幻觉率、检索命中率、格式稳定性、工具调用成功率等指标。

3. Guardrails:给 AI 装护栏

真实业务里,企业不会把 AI 裸奔上线。通常都会加各种 护栏(Guardrails) ,比如敏感信息过滤、高风险问题转人工、强制引用来源、限制工具权限、限制输出格式等。护栏的意义不是让模型变完美,而是让它在出错时别错得太离谱。

十、把这些词串起来,你就看懂现在的大多数 AI 应用了

如果你现在还是觉得词很多,不妨把它们拼成一张架构图:

graph TD
    User["用户输入 Prompt"] --> Token["Token化 & 受到上下文窗口限制"]
    
    Token -->|"如果需要查企业资料"| RAG["RAG 检索增强<br>Embedding + 向量数据库"]
    Token --> LLM
    RAG --> LLM
    
    LLM(("LLM / 多模态模型<br>系统核心大脑<br>经过SFT/RLHF微调"))
    
    LLM -->|"如果需要执行任务"| FC["Function Calling 工具调用"]
    FC --> Actions["Workflow 流程 / Agent 自主规划"]
    
    Actions --> Guard["Guardrails 护栏<br>敏感词过滤 / 拦截幻觉等"]
    LLM --> Guard
    
    Guard --> Output["产生最终的安全输出与执行结果"]
    
    style LLM fill:#e1bee7,stroke:#8e24aa,stroke-width:3px
    style RAG fill:#bbdefb,stroke:#1976d2
    style FC fill:#c8e6c9,stroke:#388e3c
    style Guard fill:#ffcdd2,stroke:#d32f2f

你会发现,AI 并不是某一个神奇按钮,而更像一整套拼装起来的系统工程。

结语:理解术语,不是为了显得专业,而是为了少被忽悠

今天的 AI 圈非常热闹,也非常容易制造概念泡沫。很多时候,术语一多,普通读者就容易被带着跑:好像只要产品里有 LLM、有 RAG、有 Agent,就一定很先进。但其实不是。真正重要的从来不是"用了多少热词",而是:它到底解决了什么问题,稳定不稳定,成本高不高,是否真的比旧方案更有效。

所以,理解这些术语最大的价值,不是让你去背定义,而是让你在面对一切 AI 方案、AI 新闻和 AI 产品时,能多问几个关键问题:

  • 这是在查资料,还是在改模型?
  • 这是在回答问题,还是能执行任务?
  • 它有没有接外部知识?
  • 有没有做安全控制?
  • 有没有做过评测?

当你开始这样看 AI,你就已经比很多只会复读流行词的人,更接近真正的理解了。

📚 延伸阅读

如果您对 AI 技术的实践细节、更多提效工具和落地案例感兴趣,推荐您进一步阅读这篇非常详细的实战汇总文档:

👉 AI 提效指北:从入门到实践

本文内容致力于用最平白的话翻译复杂的 AI 概念,希望能帮到在 AI 时代探索的你!如果觉得有帮助,欢迎点赞收藏。

继承和原型链:js如何实现继承

注:本文是学习事件循环后的个人笔记,建议配合以下参考资料一起阅读。

资料来自


日常使用js里经常遇到一个需求:用map对数组做映射 比如把数组内每一个值都加上后缀:

const arr = [10, 20, 30, 40, 50];
const res = arr.map(item => item + "%");

但在控制台打印 arr,展开后会发现,它自己身上并没有 map 方法,不过有一个[[Prototype]]image.png

再次展开[[Prototype]],就能找到map: image.png

为什么明明arr里没有写关于map的定义,但却能直接使用?这里的[[Prototype]]又是什么,为什么map会在它上面?这就涉及到了一个叫做”原型链“的概念,下文会详细讲解。

[[Prototype]] 是什么

[[Prototype]] 是用于指定对象的原型对象的内部插槽,它指向自己的原型。使用 Object.getPrototypeOf() 和 Object.setPrototypeOf() 函数分别访问和修改 [[Prototype]] 内部插槽。

注:内部插槽"是 ECMAScript 规范的术语,意思是引擎内部实现用的,不能直接用 obj.[[Prototype]] 这样的语法访问,只能通过 Object.getPrototypeOf() 来读取。

原型对象和原型链

每个对象内部都有一个 [[Prototype]],指向另一个对象,这个被指向的对象就叫做它的原型对象

原型对象本身也是一个普通对象,也有自己的 [[Prototype]],指向另一个对象……这样一路链接下去,直到 null 为止,这条链就叫做原型链。

如图所示: image.png

为什么会有原型对象和原型链

JavaScript 在设计之初选择了基于原型的继承模式——对象直接从其他对象继承,而非像传统的基于类的语言那样必须先定义类。这让语言在保持轻量的同时,天然支持了动态、灵活的代码复用。

注:Javascript 跟 Java 没有任何关系,只是为了蹭 Java 热度而把名字从 LiveScript 改成了JavaScript。

原型对象是从哪来

在创建对象的阶段,无论是直接创建对象字面量const obj = {},还是通过构造函数创建实例const dog = new Animal(),js引擎都会自动设置[[Prototype]],指向它的原型对象,以及设置constructor字段,指向自己的构造函数。

代码说明

const dog = new Animal();
console.log(Object.getPrototypeOf(dog) === Animal.prototype); // true
console.log(Animal.prototype.constructor === Animal); // true

引擎自动完成的过程(伪代码)

const dog = {};
dog.[[Prototype]] = Animal.prototype;
Animal.prototype.constructor = Animal;

构造函数和 new 关键字

构造函数只是一个普通函数,区别于其他函数,它的首字母采用大写(例如Animal),并且通常采用new的形式调用。

在用new调用时做了三件事:

  • 创建一个新对象
  • 把这个新对象的 [[Prototype]] 设置为构造函数的 prototype
  • this 绑定到这个新对象,然后执行构造函数

代码示例

function Animal(name) {
  this.name = name;
}

const dog = new Animal("旺财");
console.log(Object.getPrototypeOf(dog) === Animal.prototype);
console.log(dog.name); // "旺财"

效果上,等价于

function Animal(name) {
  // new 会把 this 绑定到新创建的对象上
  // 普通函数写法无法还原这一步,这里省略
  return { name }
}

const dog = Animal("旺财");
Object.setPrototypeOf(dog, Animal.prototype)

prototype[[Prototype]] 的区别

prototype:是一个显式属性。只有函数(特别是构造函数)才拥有它。当你定义一个函数时,JS 会自动为它创建一个 prototype 对象。

代码示例

function Person(name) {
this.name = name;

const sayHi = () => {
console.log(`hello, I am ${name}`);
};
}

// 打印后会看到{},这就是Person的原型对象,是随着构造函数的定义的时同时创建的
console.log(Person.prototype)

[[Prototype]]:是一个隐式插槽(Internal Slot)。所有对象(包括函数、数组、普通对象)都拥有它。 它是引擎内部使用的私有属性,指向自己的原型对象,无法直接通过.[[Prototype]]访问,只能通过Object.getPrototypeOf函数访问,和Object.setPrototypeOf修改原型指向

代码示例

function Person(name) {
this.name = name;

const sayHi = () => {
console.log("hello")
}
}
const jack = new Person("jack")

// 无法访问,直接报错,因为只是内部插槽,不是真实存在的属性
console.log(jack.[[Prototype]])
// 可用于访问原型,会显示原型,该例子里为一个空对象
Object.getPrototypeOf(jack)

// 定义一个新的构造函数 
function AnotherPerson(name) {
this.name = name; 
}

// 在这个新原型上加一个特殊方法 
AnotherPerson.prototype.identify = function() {
console.log("我现在是 AnotherPerson 的实例了!"); 
};

// 指定新的原型
Object.setPrototypeOf(jack, AnotherPerson.prototype)

// 验证 
console.log(Object.getPrototypeOf(jack) === AnotherPerson.prototype); // true 
jack.identify(); // "我现在是 AnotherPerson 的实例了!"

继承,class extends 的底层

在es6中,js提供了classextends语法,可以实现让写法更接近 Java 等语言,让构建对象的过程更符合直觉,但其本质依旧是原型链,下文中将会进行说明。

下方的代码使用class的语法创建了一个对象

class Animal {
static planet = "Earth";

eat() {
console.log("eating..."); 
}
}

class Dog extends Animal {
bark() {
console.log("woof!"); 
}
}

const myDog = new Dog();

但其写法等价于

// 1. 定义父类构造函数
function Animal() {}

// 静态属性直接挂在函数名上
Animal.planet = "Earth"; 

// 实例方法挂在原型上,供所有实例共享
Animal.prototype.eat = function() {
  console.log("eating...");
};

// 2. 定义子类构造函数
function Dog() {}

// 实例方法挂在原型上,供所有实例共享
Dog.prototype.bark = function() {
  console.log("woof!");
};

// 3. 实现继承的关键:设置原型链
// 让 Dog.prototype 的原型指向 Animal.prototype
Object.setPrototypeOf(Dog.prototype, Animal.prototype);

// 让 Dog 本身指向 Animal(为了继承静态属性 planet)
Object.setPrototypeOf(Dog, Animal); 

const myDog = new Dog();

验证

console.log(Dog.planet); // "Earth"
console.log(Dog.planet === Animal.planet); // true

总结

在js中,函数也是一种特殊形式的对象,也正因如此,js才能用基于原型链的形式实现构造实例、继承等操作。

因为是对象,所以我们可以随时修改 prototype 指向,或者用 Object.setPrototypeOf 强行改变血缘。这种“动态修改对象属性”的能力,赋予了 JS 极高的灵活性。

附录:构造函数原型链完整图

image.png

注:.__proto__是实例用于访问自己原型的访问器,最初是非标准的浏览器私有实现,后来因为使用太广泛而被纳入规范,但标注为遗留特性。推荐使用标准的 Object.getPrototypeOf() 替代。

如何实现自定义的虚拟列表

从零实现一个虚拟列表,支持固定高度与动态高度两种场景

在大数据列表渲染场景中,虚拟列表是提升性能的利器。本文将从原理到实践,带你手动实现一个支持固定高度和动态高度的虚拟列表组件。

前言

当页面需要展示成千上万条数据时,如果直接全部渲染到 DOM 中,会导致:

  • DOM 节点过多:浏览器渲染压力大,页面卡顿
  • 内存占用高:每个 DOM 节点都占用内存
  • 滚动性能差:大量节点的重排重绘消耗性能

虚拟列表的核心思想:只渲染可视区域内的元素,通过动态计算和位置定位,实现海量数据的高性能渲染。

核心原理

1. 基本概念

虚拟列表的实现基于以下几个关键点:

┌─────────────────────────────────────┐
│          Container (可视区域)        │
│  ┌─────────────────────────────┐    │
│  │     可见列表项 (实际渲染)     │    │
│  │                             │    │
│  │        Item 3               │    │
│  │        Item 4               │    │
│  │        Item 5               │    │
│  │        Item 6               │    │
│  │        Item 7               │    │
│  └─────────────────────────────┘    │
│                                     │
│  ↑ 缓冲区 (预渲染)                   │
│  ↓ 缓冲区 (预渲染)                   │
└─────────────────────────────────────┘
│          Phantom (撑开容器)          │  ← 总高度 = 所有项高度之和
└─────────────────────────────────────┘
  • Container:固定高度的容器,设置 overflow: auto 实现滚动
  • Phantom:一个占位元素,高度等于所有列表项高度之和,用于撑开滚动条
  • Visible Items:只渲染可视区域 + 缓冲区内的列表项
  • Buffer:上下缓冲区,防止快速滚动时出现白屏

2. 两种场景对比

特性 固定高度 动态高度
位置计算 index * itemHeight,O(1) 复杂度 需要累积计算,O(n) 复杂度
实现难度 简单 较复杂
适用场景 列表项高度一致 列表项高度不一致
性能 极高 较高(需要缓存和测量)

实现方案

核心点一:位置计算

固定高度模式
// 固定高度:直接计算,O(1) 复杂度
function calculatePositions(data, itemHeight) {
  return data.map((_, index) => ({
    top: index * itemHeight,
    height: itemHeight
  }));
}
动态高度模式
// 动态高度:需要累积计算
function calculatePositions(data, heightCache, estimateHeight) {
  const positions = [];
  let currentTop = 0;

  for (let i = 0; i < data.length; i++) {
    // 优先使用已测量的高度,否则使用预估高度
    const height = heightCache.get(i) ?? estimateHeight(data[i], i);
    
    positions.push({
      top: currentTop,
      height
    });
    
    currentTop += height;
  }
  
  return positions;
}

核心点二:二分查找定位可视区域

当列表项数量巨大时,线性查找可视区域的起始和结束索引效率太低。使用二分查找可以将时间复杂度从 O(n) 降到 O(log n)。

/**
 * 二分查找:找到第一个顶部位置 >= scrollTop 的项索引
 * 时间复杂度:O(log n)
 */
function binarySearchFirstVisible(positions, scrollTop) {
  let left = 0;
  let right = positions.length - 1;
  let result = 0;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    const midBottom = positions[mid].top + positions[mid].height;

    if (midBottom <= scrollTop) {
      left = mid + 1;
    } else {
      result = mid;
      right = mid - 1;
    }
  }

  return result;
}

/**
 * 二分查找:找到第一个底部位置 > scrollBottom 的项索引
 */
function binarySearchLastVisible(positions, scrollBottom) {
  let left = 0;
  let right = positions.length - 1;
  let result = positions.length - 1;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);

    if (positions[mid].top < scrollBottom) {
      left = mid + 1;
    } else {
      result = mid;
      right = mid - 1;
    }
  }

  return result;
}

核心点三:缓冲区机制

快速滚动时,如果只渲染可视区域内的元素,会出现短暂的白屏。缓冲区机制通过预渲染可视区域上下额外的元素来解决这一问题。

/**
 * 计算缓冲区大小
 * 快速滚动时增大缓冲区,减少白屏
 */
function getBufferSize(containerHeight, bufferRatio, isScrolling) {
  // 滚动中时增加缓冲区
  return isScrolling 
    ? containerHeight * bufferRatio * 2 
    : containerHeight * bufferRatio;
}

/**
 * 获取可视区域的范围(含缓冲区)
 */
function getVisibleRange(positions, scrollTop, containerHeight, bufferSize, overscan) {
  const scrollTopWithBuffer = Math.max(0, scrollTop - bufferSize);
  const scrollBottomWithBuffer = scrollTop + containerHeight + bufferSize;

  // 二分查找可视区域
  let start = binarySearchFirstVisible(positions, scrollTopWithBuffer);
  let end = binarySearchLastVisible(positions, scrollBottomWithBuffer);

  // 添加 overscan 预渲染项
  start = Math.max(0, start - overscan);
  end = Math.min(positions.length - 1, end + overscan);

  return { start, end };
}

核心点四:动态高度测量与缓存

动态高度的难点在于:渲染前无法知道元素的实际高度。解决方案:

  1. 初始预估:使用 estimateHeight 函数预估初始高度
  2. 渲染后测量:使用 getBoundingClientRect() 测量实际高度
  3. 缓存更新:将测量结果缓存,避免重复测量
  4. 批量更新:所有测量完成后统一更新位置,避免频繁重算
// 渲染可视区域的元素
function render() {
  const { start, end } = getVisibleRange();
  
  // 记录需要测量高度的元素
  const pendingMeasure = [];

  for (let i = start; i <= end; i++) {
    if (!renderedItems.has(i)) {
      const item = data[i];
      const position = positions[i];

      // 创建并定位元素
      const el = document.createElement('div');
      el.style.position = 'absolute';
      el.style.top = `${position.top}px`;
      el.innerHTML = renderItem(item, i);
      container.appendChild(el);
      renderedItems.set(i, el);

      // 动态高度:记录需要测量的元素
      if (!isFixedHeight && !heightCache.has(i)) {
        pendingMeasure.push({ el, index: i });
      }
    }
  }

  // 批量测量高度,避免频繁更新位置
  if (pendingMeasure.length > 0) {
    requestAnimationFrame(() => {
      let hasUpdate = false;
      
      pendingMeasure.forEach(({ el, index }) => {
        const actualHeight = el.getBoundingClientRect().height;
        heightCache.set(index, actualHeight);
        hasUpdate = true;
      });
      
      // 所有高度测量完成后统一更新一次
      if (hasUpdate) {
        updatePositions();
        rerenderVisible();
      }
    });
  }
}

核心点五:滚动优化

滚动事件触发频繁,需要优化性能:

function bindEvents() {
  let rafId = null;
  let scrollTimer = null;

  container.addEventListener('scroll', (e) => {
    scrollTop = e.target.scrollTop;

    // 快速滑动检测
    isScrolling = true;
    
    if (scrollTimer) {
      clearTimeout(scrollTimer);
    }
    
    // 滚动停止后 150ms 重置状态
    scrollTimer = setTimeout(() => {
      isScrolling = false;
    }, 150);

    // 使用 requestAnimationFrame 优化渲染
    if (rafId) {
      cancelAnimationFrame(rafId);
    }
    
    rafId = requestAnimationFrame(() => {
      render();
    });
  });
}

效果演示

固定高度模式

每项高度固定为 50px,列表滚动流畅,渲染项数稳定。切换到固定高度模式后,可以看到所有列表项高度一致,适合用于简单列表场景。

image.png

image.png

动态高度模式

不同类型的内容高度不同,通过颜色标签区分:

  • 🔵 蓝色(单行):约 45px,简短内容
  • 🟢 绿色(中等):约 85px,2-3 行内容
  • 🟠 橙色(较长):约 155px,5-6 行内容
  • 🔴 红色(超长):约 285px,包含多段内容
  • 🟣 紫色(随机):约 60-120px,高度随机波动

image.png

image.png

性能优化总结

优化点 说明 效果
二分查找 定位可视区域 O(log n) 查找效率
缓冲区 上下预渲染 减少快速滚动白屏
高度缓存 避免重复测量 每项只测量一次
批量更新 统一更新位置 减少频繁重算
rAF 节流 requestAnimationFrame 平滑滚动渲染
滚动检测 快速滚动时增大缓冲区 提升用户体验

完整代码

原生 JavaScript 实现(可直接运行)

以下是完整的 HTML 文件,保存后可直接在浏览器中打开运行:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>虚拟列表 Demo</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: #f5f5f5;
      padding: 20px;
    }
    .demo-container {
      max-width: 900px;
      margin: 0 auto;
      background: #fff;
      border-radius: 12px;
      box-shadow: 0 2px 12px rgba(0,0,0,0.1);
      padding: 24px;
    }
    h1 { font-size: 24px; margin-bottom: 20px; color: #333; }
    .control-panel {
      display: flex;
      gap: 24px;
      margin-bottom: 24px;
      padding: 16px;
      background: #fafafa;
      border-radius: 8px;
      flex-wrap: wrap;
      align-items: center;
    }
    .control-group { display: flex; align-items: center; gap: 8px; }
    .control-group label { font-size: 14px; color: #666; font-weight: 500; }
    .control-group select {
      padding: 6px 12px;
      border: 1px solid #d9d9d9;
      border-radius: 6px;
      font-size: 14px;
      background: #fff;
      cursor: pointer;
      min-width: 120px;
    }
    .stats {
      margin-left: auto;
      display: flex;
      gap: 16px;
      font-size: 13px;
      color: #999;
    }
    .stats span {
      padding: 4px 12px;
      background: #e6f7ff;
      border-radius: 4px;
      color: #1890ff;
    }
    .list-wrapper {
      border: 1px solid #e8e8e8;
      border-radius: 8px;
      overflow: hidden;
      margin-bottom: 24px;
    }
    .virtual-list-container {
      height: 600px;
      overflow: auto;
      position: relative;
      background: #fff;
    }
    .virtual-list-phantom { position: relative; }
    .virtual-list-item {
      position: absolute;
      left: 0;
      right: 0;
      border-bottom: 1px solid #f0f0f0;
    }
    .virtual-list-item:hover { background: #f5f5f5; }
    .fixed-item {
      height: 100%;
      padding: 0 16px;
      display: flex;
      align-items: center;
    }
    .fixed-item .index { width: 80px; color: #999; font-size: 13px; }
    .fixed-item .content { flex: 1; }
    .dynamic-item { padding: 12px 16px; }
    .dynamic-item .header {
      font-weight: 600;
      margin-bottom: 8px;
      color: #1890ff;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    .dynamic-item .text { color: #666; line-height: 1.6; font-size: 14px; white-space: pre-line; }
    .mode-tag {
      display: inline-block;
      padding: 2px 8px;
      background: #52c41a;
      color: #fff;
      border-radius: 4px;
      font-size: 12px;
      margin-left: 8px;
    }
    .mode-tag.dynamic { background: #722ed1; }
  </style>
</head>
<body>
  <div class="demo-container">
    <h1>虚拟列表 Demo <span class="mode-tag dynamic" id="modeTag">动态高度</span></h1>
    <div class="control-panel">
      <div class="control-group">
        <label>模式:</label>
        <select id="modeSelect">
          <option value="fixed">固定高度</option>
          <option value="dynamic" selected>动态高度</option>
        </select>
      </div>
      <div class="control-group">
        <label>数据量:</label>
        <select id="countSelect">
          <option value="1000">1,000 条</option>
          <option value="10000" selected>10,000 条</option>
          <option value="100000">100,000 条</option>
        </select>
      </div>
      <div class="control-group">
        <label>缓冲区:</label>
        <select id="bufferSelect">
          <option value="0">无缓冲</option>
          <option value="0.25">25%</option>
          <option value="0.5" selected>50%</option>
          <option value="1">100%</option>
        </select>
      </div>
      <div class="stats">
        <span id="renderCount">渲染: 0 项</span>
        <span id="scrollPos">滚动: 0px</span>
      </div>
    </div>
    <div class="list-wrapper">
      <div class="virtual-list-container" id="container">
        <div class="virtual-list-phantom" id="phantom"></div>
      </div>
    </div>
  </div>

  <script>
    // 配置参数
    const CONFIG = {
      containerHeight: 600,
      fixedItemHeight: 50,
      bufferRatio: 0.5,
      overscan: 3,
      mode: 'dynamic',
      itemCount: 10000
    };

    // DOM 元素
    const container = document.getElementById('container');
    const phantom = document.getElementById('phantom');
    const renderCountEl = document.getElementById('renderCount');
    const scrollPosEl = document.getElementById('scrollPos');
    const modeTag = document.getElementById('modeTag');

    // 数据生成
    function generateData(count, mode) {
      const result = [];
      for (let i = 0; i < count; i++) {
        if (mode === 'fixed') {
          result.push({ id: i, text: `列表项 ${i + 1}`, index: i });
        } else {
          const heightType = i % 5;
          let content = '', tag = '';
          switch (heightType) {
            case 0: content = '简短内容'; tag = '单行'; break;
            case 1: content = '这是一段中等长度的内容,占据两到三行的空间。'; tag = '中等'; break;
            case 2: content = '这是一段较长的内容,用于展示需要更多空间的信息展示场景。'; tag = '较长'; break;
            case 3: content = '这是一段非常长的内容,模拟真实业务场景中的富文本展示。\n\n在实际开发中,列表项可能包含各种复杂内容。'; tag = '超长'; break;
            case 4: content = Array(3).fill('这是随机内容行。').join('\n'); tag = '随机'; break;
          }
          result.push({ id: i, text: `列表项 ${i + 1}`, content, tag, heightType, index: i });
        }
      }
      return result;
    }

    // 虚拟列表类
    class VirtualList {
      constructor(options) {
        this.container = options.container;
        this.phantom = options.phantom;
        this.data = options.data || [];
        this.itemHeight = options.itemHeight;
        this.containerHeight = options.containerHeight;
        this.bufferRatio = options.bufferRatio || 0.5;
        this.overscan = options.overscan || 3;
        this.renderItem = options.renderItem;
        this.estimateHeight = options.estimateHeight;
        this.isFixedHeight = this.itemHeight !== undefined;
        this.heightCache = new Map();
        this.positions = [];
        this.scrollTop = 0;
        this.isScrolling = false;
        this.renderedItems = new Map();
        this.init();
      }

      init() {
        this.updatePositions();
        this.render();
        this.bindEvents();
      }

      updatePositions() {
        this.positions = [];
        let currentTop = 0;
        for (let i = 0; i < this.data.length; i++) {
          let height;
          if (this.isFixedHeight) {
            height = this.itemHeight;
          } else {
            height = this.heightCache.get(i) ?? (this.estimateHeight?.(this.data[i], i) ?? 50);
          }
          this.positions.push({ top: currentTop, height });
          currentTop += height;
        }
        this.totalHeight = currentTop;
        this.phantom.style.height = `${this.totalHeight}px`;
      }

      binarySearchStart(scrollTop) {
        let left = 0, right = this.positions.length - 1, result = 0;
        while (left <= right) {
          const mid = Math.floor((left + right) / 2);
          const midBottom = this.positions[mid].top + this.positions[mid].height;
          if (midBottom <= scrollTop) { left = mid + 1; } 
          else { result = mid; right = mid - 1; }
        }
        return result;
      }

      binarySearchEnd(scrollBottom) {
        let left = 0, right = this.positions.length - 1, result = this.positions.length - 1;
        while (left <= right) {
          const mid = Math.floor((left + right) / 2);
          if (this.positions[mid].top < scrollBottom) { left = mid + 1; } 
          else { result = mid; right = mid - 1; }
        }
        return result;
      }

      getBufferSize() {
        return this.isScrolling ? this.containerHeight * this.bufferRatio * 2 : this.containerHeight * this.bufferRatio;
      }

      getVisibleRange() {
        if (this.positions.length === 0) return { start: 0, end: 0 };
        const bufferSize = this.getBufferSize();
        const scrollTopWithBuffer = Math.max(0, this.scrollTop - bufferSize);
        const scrollBottomWithBuffer = this.scrollTop + this.containerHeight + bufferSize;
        let start = this.binarySearchStart(scrollTopWithBuffer);
        let end = this.binarySearchEnd(scrollBottomWithBuffer);
        start = Math.max(0, start - this.overscan);
        end = Math.min(this.positions.length - 1, end + this.overscan);
        return { start, end };
      }

      render() {
        const { start, end } = this.getVisibleRange();
        this.renderedItems.forEach((el, index) => {
          if (index < start || index > end) { el.remove(); this.renderedItems.delete(index); }
        });
        const pendingMeasure = [];
        for (let i = start; i <= end; i++) {
          if (!this.renderedItems.has(i)) {
            const item = this.data[i];
            const position = this.positions[i];
            const el = document.createElement('div');
            el.className = 'virtual-list-item';
            el.style.cssText = `position: absolute; top: ${position.top}px; left: 0; right: 0;`;
            if (this.isFixedHeight) el.style.height = `${this.itemHeight}px`;
            el.innerHTML = this.renderItem(item, i, this.isFixedHeight);
            this.phantom.appendChild(el);
            this.renderedItems.set(i, el);
            if (!this.isFixedHeight && !this.heightCache.has(i)) pendingMeasure.push({ el, index: i });
          }
        }
        if (pendingMeasure.length > 0) {
          requestAnimationFrame(() => {
            let hasUpdate = false;
            pendingMeasure.forEach(({ el, index }) => {
              if (this.renderedItems.has(index)) {
                this.heightCache.set(index, el.getBoundingClientRect().height);
                hasUpdate = true;
              }
            });
            if (hasUpdate) { this.updatePositions(); this.rerenderVisible(); }
          });
        }
        renderCountEl.textContent = `渲染: ${end - start + 1} 项`;
      }

      rerenderVisible() {
        this.renderedItems.forEach((el, index) => {
          const position = this.positions[index];
          if (position) el.style.top = `${position.top}px`;
        });
      }

      bindEvents() {
        let rafId = null, scrollTimer = null;
        this.container.addEventListener('scroll', (e) => {
          this.scrollTop = e.target.scrollTop;
          scrollPosEl.textContent = `滚动: ${Math.round(this.scrollTop)}px`;
          this.isScrolling = true;
          if (scrollTimer) clearTimeout(scrollTimer);
          scrollTimer = setTimeout(() => { this.isScrolling = false; }, 150);
          if (rafId) cancelAnimationFrame(rafId);
          rafId = requestAnimationFrame(() => this.render());
        });
      }

      setData(data) {
        this.data = data;
        this.heightCache.clear();
        this.renderedItems.forEach(el => el.remove());
        this.renderedItems.clear();
        this.scrollTop = 0;
        this.container.scrollTop = 0;
        this.updatePositions();
        this.render();
      }

      updateConfig(options) {
        if ('itemHeight' in options) {
          this.itemHeight = options.itemHeight;
          this.isFixedHeight = options.itemHeight !== undefined && options.itemHeight !== null;
        }
        if ('estimateHeight' in options) this.estimateHeight = options.estimateHeight;
        if (options.bufferRatio !== undefined) this.bufferRatio = options.bufferRatio;
        if (options.overscan !== undefined) this.overscan = options.overscan;
        this.heightCache.clear();
        this.renderedItems.forEach(el => el.remove());
        this.renderedItems.clear();
        this.scrollTop = 0;
        this.container.scrollTop = 0;
        this.updatePositions();
      }
    }

    // 渲染函数
    function renderItem(item, index, isFixed) {
      if (isFixed) {
        const bgColor = index % 2 === 0 ? '#fff' : '#fafafa';
        return `<div class="fixed-item" style="background: ${bgColor}"><span class="index">#${index + 1}</span><span class="content">${item.text}</span></div>`;
      } else {
        const colors = {
          0: { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' },
          1: { bg: '#f6ffed', border: '#52c41a', tag: '#52c41a' },
          2: { bg: '#fff7e6', border: '#fa8c16', tag: '#fa8c16' },
          3: { bg: '#fff1f0', border: '#f5222d', tag: '#f5222d' },
          4: { bg: '#f9f0ff', border: '#722ed1', tag: '#722ed1' },
        };
        const c = colors[item.heightType] || colors[0];
        return `<div class="dynamic-item" style="background: ${c.bg}; border-left: 3px solid ${c.border};"><div class="header"><span>#${index + 1} - ${item.text}</span><span style="background: ${c.tag}; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 12px;">${item.tag}</span></div><div class="text">${item.content}</div></div>`;
      }
    }

    function estimateHeight(item) {
      return { 0: 45, 1: 85, 2: 155, 3: 285, 4: 100 }[item.heightType] || 60;
    }

    // 初始化
    let data = generateData(CONFIG.itemCount, CONFIG.mode);
    const virtualList = new VirtualList({
      container, phantom, data,
      itemHeight: CONFIG.mode === 'fixed' ? CONFIG.fixedItemHeight : undefined,
      containerHeight: CONFIG.containerHeight,
      bufferRatio: CONFIG.bufferRatio,
      overscan: CONFIG.overscan,
      renderItem,
      estimateHeight: CONFIG.mode === 'dynamic' ? estimateHeight : undefined
    });

    // 事件绑定
    document.getElementById('modeSelect').addEventListener('change', (e) => {
      CONFIG.mode = e.target.value;
      modeTag.textContent = CONFIG.mode === 'fixed' ? '固定高度' : '动态高度';
      modeTag.className = `mode-tag ${CONFIG.mode === 'dynamic' ? 'dynamic' : ''}`;
      virtualList.updateConfig({
        itemHeight: CONFIG.mode === 'fixed' ? CONFIG.fixedItemHeight : undefined,
        estimateHeight: CONFIG.mode === 'dynamic' ? estimateHeight : undefined
      });
      data = generateData(CONFIG.itemCount, CONFIG.mode);
      virtualList.setData(data);
    });

    document.getElementById('countSelect').addEventListener('change', (e) => {
      CONFIG.itemCount = parseInt(e.target.value);
      data = generateData(CONFIG.itemCount, CONFIG.mode);
      virtualList.setData(data);
    });

    document.getElementById('bufferSelect').addEventListener('change', (e) => {
      CONFIG.bufferRatio = parseFloat(e.target.value);
      virtualList.updateConfig({ bufferRatio: CONFIG.bufferRatio });
    });
  </script>
</body>
</html>

React 版本实现

React 版本使用 Hooks 实现,支持 TypeScript 类型,完全参照原生 JavaScript 版本的实现逻辑:

/**
 * 虚拟列表完整实现 - React 版本
 * 支持固定高度和动态高度两种模式
 */

import React, { useRef, useState, useEffect, useCallback, useMemo } from 'react';

// ============================================
// 类型定义
// ============================================

interface VirtualListProps<T> {
  data: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T, index: number) => string | number;
  containerHeight: number;
  itemHeight?: number;  // 固定高度模式:传入此项则使用固定高度
  estimateItemHeight?: (item: T, index: number) => number;  // 动态高度预估函数
  bufferRatio?: number;
  overscan?: number;
}

// ============================================
// 二分查找函数:O(log n) 定位可视区域
// ============================================

function binarySearchStart(
  positions: { top: number; height: number }[],
  scrollTop: number
): number {
  let left = 0;
  let right = positions.length - 1;
  let result = 0;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    const midBottom = positions[mid].top + positions[mid].height;

    if (midBottom <= scrollTop) {
      left = mid + 1;
    } else {
      result = mid;
      right = mid - 1;
    }
  }

  return result;
}

function binarySearchEnd(
  positions: { top: number; height: number }[],
  scrollBottom: number
): number {
  let left = 0;
  let right = positions.length - 1;
  let result = positions.length - 1;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);

    if (positions[mid].top < scrollBottom) {
      left = mid + 1;
    } else {
      result = mid;
      right = mid - 1;
    }
  }

  return result;
}

// ============================================
// 核心组件:虚拟列表
// ============================================

function VirtualList<T>({
  data,
  renderItem,
  keyExtractor,
  containerHeight,
  itemHeight,
  estimateItemHeight,
  bufferRatio = 0.5,
  overscan = 3,
}: VirtualListProps<T>) {
  // 判断是否固定高度模式
  const isFixedHeight = itemHeight !== undefined;

  // Refs:使用 ref 存储可变值,避免频繁触发重渲染
  const containerRef = useRef<HTMLDivElement>(null);
  const phantomRef = useRef<HTMLDivElement>(null);
  const itemsRef = useRef<Map<number, HTMLDivElement>>(new Map());
  const heightCacheRef = useRef<Map<number, number>>(new Map());
  const positionsRef = useRef<{ top: number; height: number }[]>([]);
  const scrollTopRef = useRef(0);
  const isScrollingRef = useRef(false);
  const scrollTimerRef = useRef<ReturnType<typeof setTimeout>>();

  // 状态
  const [, forceUpdate] = useState(0);
  const [isScrolling, setIsScrolling] = useState(false);

  // ============================================
  // 核心点1:计算所有项的位置信息
  // ============================================
  const updatePositions = useCallback(() => {
    const positions: { top: number; height: number }[] = [];
    let currentTop = 0;

    for (let i = 0; i < data.length; i++) {
      let height: number;

      if (isFixedHeight) {
        height = itemHeight!;
      } else {
        if (heightCacheRef.current.has(i)) {
          height = heightCacheRef.current.get(i)!;
        } else if (estimateItemHeight) {
          height = estimateItemHeight(data[i], i);
        } else {
          height = 50;
        }
      }

      positions.push({
        top: currentTop,
        height,
      });

      currentTop += height;
    }

    positionsRef.current = positions;

    // 更新 phantom 高度
    if (phantomRef.current) {
      phantomRef.current.style.height = `${currentTop}px`;
    }
  }, [data, isFixedHeight, itemHeight, estimateItemHeight]);

  // ============================================
  // 核心点2:计算缓冲区大小
  // ============================================
  const getBufferSize = useCallback(() => {
    // 快速滚动时增大缓冲区,减少白屏
    return isScrolling
      ? containerHeight * bufferRatio * 2
      : containerHeight * bufferRatio;
  }, [containerHeight, bufferRatio, isScrolling]);

  // ============================================
  // 核心点3:获取可视区域的项目(二分查找)
  // ============================================
  const getVisibleRange = useCallback(() => {
    const positions = positionsRef.current;
    if (positions.length === 0) {
      const defaultEnd = Math.min(20, data.length - 1);
      return { start: 0, end: Math.max(0, defaultEnd) };
    }

    const bufferSize = getBufferSize();
    const scrollTop = scrollTopRef.current;
    const scrollTopWithBuffer = Math.max(0, scrollTop - bufferSize);
    const scrollBottomWithBuffer = scrollTop + containerHeight + bufferSize;

    // 二分查找可视区域
    let start = binarySearchStart(positions, scrollTopWithBuffer);
    let end = binarySearchEnd(positions, scrollBottomWithBuffer);

    // 添加预渲染项
    start = Math.max(0, start - overscan);
    end = Math.min(positions.length - 1, end + overscan);

    return { start, end };
  }, [containerHeight, getBufferSize, overscan, data.length]);

  // ============================================
  // 核心点4:重新渲染可见区域位置
  // ============================================
  const rerenderVisible = useCallback(() => {
    const positions = positionsRef.current;
    itemsRef.current.forEach((el, index) => {
      const position = positions[index];
      if (position) {
        el.style.top = `${position.top}px`;
      }
    });
  }, []);

  // 初始化和更新
  useEffect(() => {
    updatePositions();
    forceUpdate((prev) => prev + 1);
  }, [updatePositions]);

  // 监听 itemHeight 变化(模式切换)
  const prevItemHeightRef = useRef(itemHeight);
  useEffect(() => {
    // 检测模式切换(固定高度 <-> 动态高度)
    if ((prevItemHeightRef.current === undefined) !== (itemHeight === undefined)) {
      // 模式切换时重置所有状态
      // 注意:不要直接操作 DOM,让 React 自己处理 DOM 的更新
      heightCacheRef.current.clear();
      itemsRef.current.clear();
      scrollTopRef.current = 0;
      if (containerRef.current) {
        containerRef.current.scrollTop = 0;
      }
      updatePositions();
      forceUpdate((prev) => prev + 1);
    }
    prevItemHeightRef.current = itemHeight;
  }, [itemHeight, updatePositions]);

  // ============================================
  // 核心点5:滚动事件处理
  // ============================================
  const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
    scrollTopRef.current = e.currentTarget.scrollTop;

    isScrollingRef.current = true;
    setIsScrolling(true);

    if (scrollTimerRef.current) {
      clearTimeout(scrollTimerRef.current);
    }

    // 滚动停止后重置状态
    scrollTimerRef.current = setTimeout(() => {
      isScrollingRef.current = false;
      setIsScrolling(false);
    }, 150);

    forceUpdate((prev) => prev + 1);
  }, []);

  // 清理定时器
  useEffect(() => {
    return () => {
      if (scrollTimerRef.current) {
        clearTimeout(scrollTimerRef.current);
      }
    };
  }, []);

  // 数据变化时重置
  const prevDataLengthRef = useRef(data.length);
  useEffect(() => {
    if (data.length !== prevDataLengthRef.current) {
      heightCacheRef.current.clear();
      itemsRef.current.clear();
      scrollTopRef.current = 0;
      prevDataLengthRef.current = data.length;
      if (containerRef.current) {
        containerRef.current.scrollTop = 0;
      }
      updatePositions();
    }
  }, [data.length, updatePositions]);

  // ============================================
  // 计算可视数据
  // ============================================
  const { start, end } = getVisibleRange();
  const visibleData = useMemo(() => {
    return data.slice(start, end + 1).map((item, i) => ({
      item,
      index: start + i,
    }));
  }, [data, start, end]);

  const totalHeight = useMemo(() => {
    const positions = positionsRef.current;
    if (positions.length === 0) return 0;
    const last = positions[positions.length - 1];
    return last.top + last.height;
  }, [data.length, forceUpdate]);

  // ============================================
  // 动态高度测量:使用 requestAnimationFrame 批量更新
  // ============================================
  useEffect(() => {
    if (isFixedHeight) return;

    const pendingMeasure: { el: HTMLDivElement; index: number }[] = [];

    itemsRef.current.forEach((el, index) => {
      if (!heightCacheRef.current.has(index)) {
        pendingMeasure.push({ el, index });
      }
    });

    if (pendingMeasure.length > 0) {
      requestAnimationFrame(() => {
        let hasUpdate = false;
        pendingMeasure.forEach(({ el, index }) => {
          if (itemsRef.current.has(index)) {
            const actualHeight = el.getBoundingClientRect().height;
            heightCacheRef.current.set(index, actualHeight);
            hasUpdate = true;
          }
        });

        if (hasUpdate) {
          updatePositions();
          rerenderVisible();
        }
      });
    }
  }, [visibleData, isFixedHeight, updatePositions, rerenderVisible]);

  // ============================================
  // 渲染
  // ============================================
  return (
    <div
      ref={containerRef}
      style={{
        height: containerHeight,
        overflow: 'auto',
        position: 'relative',
      }}
      onScroll={handleScroll}
    >
      <div
        ref={phantomRef}
        style={{
          height: totalHeight,
          position: 'relative',
        }}
      >
        {visibleData.map(({ item, index }) => {
          const position = positionsRef.current[index];
          return (
            <div
              key={keyExtractor(item, index)}
              ref={(el) => {
                if (el) {
                  itemsRef.current.set(index, el);
                } else {
                  itemsRef.current.delete(index);
                }
              }}
              style={{
                position: 'absolute',
                top: position?.top ?? 0,
                left: 0,
                right: 0,
                height: isFixedHeight ? itemHeight : 'auto',
              }}
            >
              {renderItem(item, index)}
            </div>
          );
        })}
      </div>
    </div>
  );
}

export default VirtualList;

// ============================================
// 使用示例
// ============================================

/**
 * 示例1:固定高度列表
 */
export const FixedHeightExample = () => {
  const data = Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    text: `列表项 ${i + 1}`,
  }));

  return (
    <VirtualList
      data={data}
      containerHeight={600}
      itemHeight={50}
      keyExtractor={(item) => item.id}
      renderItem={(item) => (
        <div
          style={{
            height: '100%',
            padding: '0 16px',
            display: 'flex',
            alignItems: 'center',
            borderBottom: '1px solid #eee',
          }}
        >
          {item.text}
        </div>
      )}
    />
  );
};

/**
 * 示例2:动态高度列表
 */
export const DynamicHeightExample = () => {
  const data = Array.from({ length: 10000 }, (_, i) => {
    const heightType = i % 5;
    let content = '';
    let tag = '';

    switch (heightType) {
      case 0:
        content = '简短内容';
        tag = '单行';
        break;
      case 1:
        content = '这是一段中等长度的内容,占据两到三行的空间。';
        tag = '中等';
        break;
      case 2:
        content = '这是一段较长的内容,用于展示需要更多空间的信息展示场景。';
        tag = '较长';
        break;
      case 3:
        content = `这是一段非常长的内容,模拟真实业务场景中的富文本展示。

在实际开发中,列表项可能包含:
• 用户详细信息
• 商品卡片
• 订单摘要`;
        tag = '超长';
        break;
      case 4:
        content = Array(3).fill('这是随机内容行。').join('\n');
        tag = '随机';
        break;
    }

    return { id: i, text: `列表项 ${i + 1}`, content, tag, heightType };
  });

  // 预估高度函数:根据内容类型返回预估高度
  const estimateHeight = (item: { heightType: number }) => {
    const heightMap: Record<number, number> = {
      0: 45,   // 单行
      1: 85,   // 中等
      2: 155,  // 较长
      3: 285,  // 超长
      4: 100   // 随机
    };
    return heightMap[item.heightType] || 60;
  };

  const renderItem = (item: { text: string; content: string; tag: string; heightType: number }, index: number) => {
    const colorMap: Record<number, { bg: string; border: string; tag: string }> = {
      0: { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' },
      1: { bg: '#f6ffed', border: '#52c41a', tag: '#52c41a' },
      2: { bg: '#fff7e6', border: '#fa8c16', tag: '#fa8c16' },
      3: { bg: '#fff1f0', border: '#f5222d', tag: '#f5222d' },
      4: { bg: '#f9f0ff', border: '#722ed1', tag: '#722ed1' },
    };
    const colors = colorMap[item.heightType] || colorMap[0];

    return (
      <div
        style={{
          padding: '12px 16px',
          backgroundColor: colors.bg,
          borderLeft: `3px solid ${colors.border}`,
          borderBottom: '1px solid #f0f0f0',
        }}
      >
        <div
          style={{
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
            marginBottom: 8,
          }}
        >
          <span style={{ fontWeight: 600, color: '#333' }}>#{index + 1} - {item.text}</span>
          <span
            style={{
              backgroundColor: colors.tag,
              color: '#fff',
              padding: '2px 8px',
              borderRadius: 4,
              fontSize: 12,
            }}
          >
            {item.tag}
          </span>
        </div>
        <div style={{ color: '#666', lineHeight: 1.6, whiteSpace: 'pre-line' }}>
          {item.content}
        </div>
      </div>
    );
  };

  return (
    <VirtualList
      data={data}
      containerHeight={600}
      keyExtractor={(item) => item.id}
      estimateItemHeight={estimateHeight}
      renderItem={renderItem}
    />
  );
};

/**
 * 示例3:完整 Demo 组件(支持模式切换)
 */
export const VirtualListDemo = () => {
  const [mode, setMode] = useState<'fixed' | 'dynamic'>('dynamic');
  const [itemCount, setItemCount] = useState(10000);

  const fixedData = useMemo(
    () => Array.from({ length: itemCount }, (_, i) => ({ id: i, text: `列表项 ${i + 1}` })),
    [itemCount]
  );

  const dynamicData = useMemo(() => {
    return Array.from({ length: itemCount }, (_, i) => {
      const heightType = i % 5;
      let content = '';
      let tag = '';

      switch (heightType) {
        case 0:
          content = '简短内容';
          tag = '单行';
          break;
        case 1:
          content = '这是一段中等长度的内容,占据两到三行的空间。';
          tag = '中等';
          break;
        case 2:
          content = '这是一段较长的内容,用于展示需要更多空间的信息展示场景。';
          tag = '较长';
          break;
        case 3:
          content = `这是一段非常长的内容,模拟真实业务场景。\n\n包含多行内容展示。`;
          tag = '超长';
          break;
        case 4:
          content = Array(3).fill('这是随机内容行。').join('\n');
          tag = '随机';
          break;
      }

      return { id: i, text: `列表项 ${i + 1}`, content, tag, heightType };
    });
  }, [itemCount]);

  const estimateHeight = (item: { heightType: number }) => {
    return { 0: 45, 1: 85, 2: 155, 3: 285, 4: 100 }[item.heightType] || 60;
  };

  const renderFixedItem = (item: { text: string }, index: number) => (
    <div
      style={{
        height: '100%',
        padding: '0 16px',
        display: 'flex',
        alignItems: 'center',
        backgroundColor: index % 2 === 0 ? '#fff' : '#f9f9f9',
        borderBottom: '1px solid #eee',
      }}
    >
      <span style={{ width: 80, color: '#999' }}>#{index + 1}</span>
      <span>{item.text}</span>
    </div>
  );

  const renderDynamicItem = (item: { text: string; content: string; tag: string; heightType: number }, index: number) => {
    const colors = {
      0: { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' },
      1: { bg: '#f6ffed', border: '#52c41a', tag: '#52c41a' },
      2: { bg: '#fff7e6', border: '#fa8c16', tag: '#fa8c16' },
      3: { bg: '#fff1f0', border: '#f5222d', tag: '#f5222d' },
      4: { bg: '#f9f0ff', border: '#722ed1', tag: '#722ed1' },
    }[item.heightType] || { bg: '#e6f7ff', border: '#1890ff', tag: '#1890ff' };

    return (
      <div
        style={{
          padding: '12px 16px',
          backgroundColor: colors.bg,
          borderLeft: `3px solid ${colors.border}`,
        }}
      >
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
          <span style={{ fontWeight: 600, color: '#1890ff' }}>#{index + 1} - {item.text}</span>
          <span style={{ backgroundColor: colors.tag, color: '#fff', padding: '2px 8px', borderRadius: 4, fontSize: 12 }}>
            {item.tag}
          </span>
        </div>
        <div style={{ color: '#666', lineHeight: 1.6, whiteSpace: 'pre-line' }}>{item.content}</div>
      </div>
    );
  };

  return (
    <div style={{ padding: 20 }}>
      <h2>虚拟列表 Demo</h2>

      <div style={{ marginBottom: 20, display: 'flex', gap: 16, alignItems: 'center' }}>
        <div>
          <label>模式:</label>
          <select value={mode} onChange={(e) => setMode(e.target.value as 'fixed' | 'dynamic')}>
            <option value="fixed">固定高度</option>
            <option value="dynamic">动态高度</option>
          </select>
        </div>

        <div>
          <label>数据量:</label>
          <select value={itemCount} onChange={(e) => setItemCount(Number(e.target.value))}>
            <option value={1000}>1,000 条</option>
            <option value={10000}>10,000 条</option>
            <option value={100000}>100,000 条</option>
          </select>
        </div>
      </div>

      <div style={{ border: '1px solid #ddd', borderRadius: 8, overflow: 'hidden' }}>
        {mode === 'fixed' ? (
          <VirtualList
            data={fixedData}
            containerHeight={600}
            itemHeight={50}
            keyExtractor={(item) => item.id}
            renderItem={renderFixedItem}
          />
        ) : (
          <VirtualList
            data={dynamicData}
            containerHeight={600}
            keyExtractor={(item) => item.id}
            estimateItemHeight={estimateHeight}
            renderItem={renderDynamicItem}
          />
        )}
      </div>
    </div>
  );
};

参考资料

总结

虚拟列表是处理大数据列表渲染的经典方案,核心思想是只渲染可视区域内的元素。本文详细介绍了:

  1. 固定高度模式:实现简单,O(1) 时间复杂度计算位置
  2. 动态高度模式:需要高度缓存和测量,O(n) 时间复杂度计算位置
  3. 性能优化:二分查找、缓冲区、批量更新等策略

掌握虚拟列表的实现原理,不仅能解决实际开发中的性能问题,也能加深对浏览器渲染机制的理解。希望本文对你有所帮助!


如果觉得本文有帮助,欢迎点赞收藏,有问题欢迎在评论区讨论!

可选链 `?.`——再也不用写一长串 `&&` 了!

引言

Cannot read property 'name' of undefined——又是这个错!我明明已经写了 if (user && user.profile && user.profile.name) 啊!”

同事凑过来看了一眼:“你只检查了 useruser.profile,但没检查 user.profile.name 本身?哦,其实 user.profile.name 不可能是 undefined 的……等等,如果 user.profile 是空对象呢?”

我陷入了沉思:难道我要写 user && user.profile && user.profile.name && user.profile.name.firstName?这代码长得像铁轨,谁看得懂啊!

直到有一天,我发现了可选链操作符 ?.。它就像一把瑞士军刀,轻轻一划,所有 undefined 的烦恼都烟消云散。

一、传统防守:&& 的“人肉护盾”

在过去,为了安全地访问深层嵌套的属性,我们不得不写这样的代码:

const firstName = user && user.profile && user.profile.name && user.profile.name.firstName;

如果中间任何一环是 nullundefined,整个表达式短路返回 undefined,不会报错。但这写法,读起来像在爬楼梯,每层都要确认一下。

更别提调用可能存在的方法:

const result = api && api.getData && api.getData();

万一 api.getData 不是函数?又得加判断。

二、可选链:.?. 的优雅空降

可选链操作符 ?. 允许你读取位于连接链深处的属性,而无需显式验证每一环是否有效。如果引用是 nullundefined,表达式短路返回 undefined

const firstName = user?.profile?.name?.firstName;

就这么简单!如果 userprofilename 任何一个不存在,整个表达式返回 undefined,而不是报错。

2.1 函数调用可选链

const result = api?.getData?.();

如果 apinull/undefined,或者 api.getData 不是函数,都返回 undefined,不会抛错。

2.2 数组元素可选链

const firstItem = arr?.[0];

如果 arr 不是数组或者是 null/undefined,返回 undefined

2.3 与空值合并搭配使用

const firstName = user?.profile?.name?.firstName ?? '匿名';

如果最终结果是 undefinednull,就换成默认值。完美!

三、实战对比:代码简洁度暴增

场景1:读取深层 API 响应

// 旧写法
const city = response && response.data && response.data.user && response.data.user.address && response.data.user.address.city;

// 新写法
const city = response?.data?.user?.address?.city;

场景2:调用可选回调

// 旧写法
if (onSuccess && typeof onSuccess === 'function') {
  onSuccess(data);
}

// 新写法
onSuccess?.(data);

场景3:动态属性名

const value = obj?.[key];

四、注意事项:别滥用

  • ?. 只检查左侧是否为 nullundefined,不检查 false0'' 等假值。如果你需要过滤假值,用 ||??
  • 不能用于赋值obj?.prop = value 是语法错误。
  • 短路效应:一旦遇到 null/undefined,右侧整个链停止求值,包括函数调用。
  • 性能:现代浏览器对 ?. 优化很好,放心用。

五、兼容性与降级

可选链是 ES2020 特性,现代浏览器都支持(Chrome 80+、Firefox 74+、Safari 13.1+)。如果需要兼容旧浏览器,可以用 Babel 插件 @babel/plugin-proposal-optional-chaining 转译。

六、总结:告别防御性编程噩梦

可选链操作符让 JavaScript 代码变得更加简洁、安全、可读。你不再需要写一长串 && 来保护每一层属性访问,也不用担心 Cannot read property of undefined 半夜叫醒你。

记住:?. 代替 && 链,用 ?? 提供默认值。这两个好基友,能让你的代码年轻十岁。


每日一问:你曾经因为忘记检查深层属性,导致过线上报错吗?或者写过最长的 && 链有多长?评论区晒出你的“防御塔”代码,让大家开开眼!

🔧 Rattail | 面向 Vite+ 和 AI Agent 的前端工具链

写在前面

掘金的同学们大家好呀,作者是 Varlet UI 的作者。掘金文章已经一年没更新了,去年跳槽到了一家创业公司负责前端架构工作,写文章这件事就一直搁置了。最近稍微缓过来了一点点(其实还是压力很大...),但不妨碍今天来给大家分享一下我们最新的开源项目 rattail

先聊聊 Vite Plus

上个月 VoidZero 正式以 MIT 协议开源了 Vite+,它把 ViteVitestOxlintOxfmtRolldowntsdown 统一收拢到了一个 vp 命令下面,一套工具链覆盖 devbuildtestlintfmtpack 等所有工程化环节。作者第一时间就把 varlet 周边的项目迁移到了 Vite+ 上面试试水,迁移下来发现效果特别好。以前那些散落在各处的 eslint 配置、prettier 配置、lint-staged 配置、commitlint 配置可以统一收拢到一个 vite.config.ts 里面,项目根目录一下子干净了不少(以前打开根目录看到十几个 .xxxrc 文件的日子终于结束了)。而且因为工具链统一了,AI Agent 在理解项目配置的时候幻觉也少了很多。

公司项目迁移

既然体验这么好,作者就决定把公司内部的前端项目也都迁移到 Vite+ 上。迁移的过程中也让作者重新审视了一下 rattail,我们在 varlet 生态里积累了大量的工具函数、请求库、校验规则工厂、CLI 工具链,之前一直是分散在各个包里的,正好借这次机会做一次大整合,于是就有了 rattail 2.0——一个面向 Vite+、对 AI Agent 非常友好的前端工具链。140+ 工具函数、渐进式请求库、链式校验规则工厂、CLI 工具链、类型安全枚举,pnpm add rattail 一条命令全部拉齐。

目前作者也在公司项目中全面使用了 Vite+ + rattail 这套技术栈,体验下来非常舒服。另外值得一提的是,这次 rattail 2.0 的迁移和开发过程中,作者大量使用了 AI 辅助编程,包括工具函数的编写、单元测试的补全、文档的生成等等,效率提升非常明显。配合 rattail 提供的 Agent Skills,AI Agent 能够很好的理解项目上下文并正确使用 rattail 的 API,整个工作流跑下来还是相当丝滑的。后续在业务开发中也明显感觉到,因为 rattail 把工具函数、请求库、校验规则这些东西 all in one 了,AI 在生成代码的时候幻觉变得特别少,而且很会按照规范做事。

相关链接

特性一览

  • ⚙️ 面向 Vite+ 的开箱即用配置预设
  • 🔧 CLI 工具链,支持发布、日志、Git Hooks、Commit Lint、API 生成
  • 🧰 140+ 工具函数,覆盖通用、字符串、数字、数组、对象、数学等场景
  • 🚀 基于 axios 的渐进式请求工具,支持 Vue 组合式 API
  • 📏 链式校验规则工厂,适配任意 UI 框架
  • 🏷️ 类型安全的枚举工具
  • 🤖 提供 Agent Skills,帮助 AI 编程助手理解和使用 Rattail
  • 🌲 可 Tree-shake,轻量,TypeScript 完整类型支持
  • 💪 90%+ 单元测试覆盖率

下面作者挑几个有意思的能力展开聊聊。

Vite+ 配置预设

做过前端工程化的同学应该都有体会,每次新项目光配 eslintprettier 这些东西就够喝一壶的了,配好了还得处理各种冲突。rattail 内置了面向 Vite+ 的开箱即用预设,一个 vite.config.ts 搞定 lintformatstagedgit hooks 等所有工程化配置。

import { lint, fmt, staged, clean, hook, defineConfig } from 'rattail/vite-plus'

export default defineConfig({
  lint: lint(),

  fmt: fmt(),

  staged: staged(),

  rattail: {
    clean: clean(),

    hook: hook(),
    
    api: {},

    release: {},

    changelog: {}
  },
})

之前作者为了配这些东西写了好几个配置文件,现在一个文件就够了(少写代码是第一生产力)。

CLI 工具链

安装 rattail 后会注册一个 rt 命令,覆盖了作者日常开发中最常用的几个场景。

# 清理产物
rt clean

# 安装 git hooks
rt hook

# 发布
rt release

# 生成 changelog
rt changelog

# 从 OpenAPI 生成 API 模块
rt api

这些命令都支持通过 vite.config.ts 中的 rattail 字段进行配置,也就是说项目根目录不需要再多出一堆 .xxxrc 文件了。这一点作者是比较在意的,毕竟谁也不想打开项目根目录看到十几个配置文件吧(有些项目根目录比 node_modules 还热闹)。

140+ 工具函数

lodash 大家都耳熟能详了,rattail 里的工具函数覆盖的场景和 lodash 类似,包括类型判断数组对象字符串数学函数集合文件等分类,用法就不逐个列举了。和 lodash 不同的是,这些函数从第一天就是用 TypeScript 写的,类型推导是第一优先级,全部可 Tree-shake。除了 lodash 风格的工具函数以外,rattail 还内置了一些前端项目中常用的实用工具,比如 sumHash 计算哈希、uuid 生成唯一 ID、mitt 事件总线、duration 时间格式化、storage / cookieStorage 存储封装、copyText 复制文本、download 文件下载等等,省得同学们每次都要单独装一堆小包。更多的可以去文档里查看完整的 API 列表。

类型安全的枚举工具

这个是作者个人比较喜欢的一个工具。前端项目里到处都是枚举值,比如订单状态、用户角色之类的。一般我们用 enum 或者常量对象来管理它们,但是 labeldescription 这些配套信息就只能另外维护了。enumOf 把值和它的元信息放在一起管理,并且类型推导是完备的。

import { enumOf } from 'rattail'

const Status = enumOf({
  Pending: { value: 0, label: '待处理' },
  Active: { value: 1, label: '进行中' },
  Done: { value: 2, label: '已完成' },
})

Status.Pending        // 0
Status.Active         // 1
Status.values()       // [0, 1, 2]
Status.labels()       // ['待处理', '进行中', '已完成']
Status.label(Status.Pending) // '待处理'
Status.options()      // [{ value: 0, label: '待处理' }, ...]

// 直接丢给 select 组件的 options,再也不用手动维护了

前端项目里到处都需要枚举值和它对应的文案,以前每次都要写个 map 或者 switch,现在一个 enumOf 就够了。另外 enumOflabeldescription 支持传入一个 getter 函数,配合 vue-i18n 之类的国际化方案可以很方便的实现多语言:

const Status = enumOf({
  Pending: { value: 0, label: () => t('status.pending') },
  Active: { value: 1, label: () => t('status.active') },
  Done: { value: 2, label: () => t('status.done') },
})

基于 axios 的渐进式请求工具

这个能力来自于作者之前开源的 @varlet/axle,现在通过 rattail/axle 直接引入。熟悉作者的同学可能看过之前介绍 axle 的文章,它在兼容 axios 的同时,天然支持 Vue3 Composition API

import { createAxle } from 'rattail/axle'
import { createUseAxle } from 'rattail/axle/use'

const axle = createAxle({ baseURL: '/api' })
const useAxle = createUseAxle({ axle })

const [users, getUsers, { loading, error }] = useAxle({
  method: 'get',
  url: '/user',
  params: { current: 1, pageSize: 10 },
})

作者一直觉得前端请求库和 Vue 的响应式系统应该有更好的结合方式,axle 就是在这个方向上的一个尝试。如果你不喜欢 axle 也完全没问题,rattail 的其他能力和请求库是解耦的,换成你喜欢的方案就好。

OpenAPI 生成 API 模块

rt api 可以直接解析后端提供的 OpenAPI / Swagger schema 文件,自动生成类型安全的 API 调用代码,这个在实际项目里把工作流做通之后体验可太好了。

vite.config.ts 里配置好 schema 路径和输出目录:

import { defineConfig } from 'rattail/vite-plus'

export default defineConfig({
  rattail: {
    api: {
      input: './openapi.json'
    },
  },
})

执行 rt api 后会自动生成这样的代码:

import { api } from '@/request'
import { type paths } from './_types'

export type ApiGetUsers = paths['/users']['get']
export type ApiCreateUser = paths['/users']['post']
export type ApiGetUser = paths['/users/{uuid}']['get']
export type ApiUpdateUser = paths['/users/{uuid}']['put']
export type ApiDeleteUser = paths['/users/{uuid}']['delete']

export type ApiGetUsersQuery = ApiGetUsers['parameters']['query']
export type ApiGetUsersRequestBody = undefined
export type ApiGetUsersResponseBody = ApiGetUsers['responses']['200']['content']['application/json']
// ... 其他类型同理

export const apiGetUsers = api<
  ApiGetUsersResponseBody, ApiGetUsersQuery, ApiGetUsersRequestBody>('/users', 'get')
export const apiCreateUser = api<
  ApiCreateUserResponseBody, ApiCreateUserQuery, ApiCreateUserRequestBody>('/users', 'post')
export const apiGetUser = api<
  ApiGetUserResponseBody, ApiGetUserQuery, ApiGetUserRequestBody>('/users/:uuid', 'get')
export const apiUpdateUser = api<
  ApiUpdateUserResponseBody, ApiUpdateUserQuery, ApiUpdateUserRequestBody>('/users/:uuid', 'put')
export const apiDeleteUser = api<
  ApiDeleteUserResponseBody, ApiDeleteUserQuery, ApiDeleteUserRequestBody>('/users/:uuid', 'delete')

请求类型、响应类型全部从 schema 里提取,不需要手写。后端接口变了,重新跑一遍 rt api 就行,前后端的类型始终保持同步。这个工作流对 AI Agent 也特别友好,AI 可以直接基于生成的类型去写业务代码,不会出现参数类型对不上的问题。甚至 AI Agent 可以通过 api 定义的变化,推测出你接下来要写什么业务。默认使用 axle,也支持 axios 的预设,同时支持 自定义输出

链式校验规则工厂

做表单的同学应该都写过类似 requiredminmax 这些校验规则。不同的 UI 框架校验规则的格式还不一样,每个项目都要适配一遍。rattail 提供了一个链式校验规则工厂,写起来很流畅,并且可以适配任意 UI 框架。这种内联的声明式写法和 TailwindCSS 的思路类似,可读性和可迁移性都非常好,对 AI 也特别友好,AI 可以直接从模板里读懂校验意图,生成和修改规则的准确度很高。

Naive UIElement Plus 为例:

<!-- Naive UI -->
<script setup lang="ts">
import type { FormItemRule } from 'naive-ui'
import { rulerFactory } from 'rattail/ruler'

const r = rulerFactory<FormItemRule>((validator, params = {}) => ({
  trigger: ['blur', 'change', 'input'],
  validator: (_, value) => validator(value),
  ...params,
}))
</script>

<template>
  <n-form :model>
    <n-form-item 
      path="name" 
      label="姓名"
      :rule="r().required('必填').min(2, '长度不正确').done()"
    >
      <n-input v-model:value="model.name" />
    </n-form-item>
  </n-form>
</template>
<!-- Element Plus -->
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { rulerFactory } from 'rattail/ruler'

const r = rulerFactory<FormItemRule>((validator, params) => ({
  validator(_, value, callback) {
    const e = validator(value)
    e ? callback(e) : callback()
  },
  trigger: ['blur', 'change', 'input'],
  ...params,
}))
</script>

<template>
  <el-form :model>
    <el-form-item 
      prop="email" 
      label="邮箱"
      :rules="r().email('必须是邮箱格式').done()"
    >
      <el-input v-model="model.email" />
    </el-form-item>
  </el-form>
</template>

AI Agent Skills

rattail 提供了一套 Agent Skills,说白了就是给 AI 写了一份"说明书",让 AI Agent 知道 rattail 有哪些能力、怎么用,不用你每次都手动告诉 AI。作者觉得未来的开源库都应该考虑对 AI Agent 的友好度。

写在最后

rattail 的工具函数和能力大多来自前端社区的通用实践。感谢同学们能看到这里,但是希望 rattail 能够帮助到大家。项目基于 MIT 协议。如果在使用的过程中遇到任何问题,欢迎在 issue 里反馈给我们,同时也十分欢迎对项目有兴趣的同学给我们发 pull request

支持我们的话留下一个 star 就好~

Bun 1.3.12 发布:内置无头浏览器和终端 Markdown 渲染

Bun v1.3.12 带来了内置无头浏览器自动化、终端 Markdown 渲染、进程内定时任务等新特性,同时在性能优化和兼容性方面取得了显著进展。本文将通过示例代码和实战指南,帮助开发者快速上手这些新功能。

大家好,我是 iDao。10 年全栈开发,做过架构、运维,也在落地 AI 工程化。这里不搞虚的,只分享能直接跑、能直接用的代码、方案和经验。内容包括:全栈开发实战、系统搭建、可视化大屏、自动化部署、AI 应用、私有化部署等。关注我,一起写能落地的代码,做能上线的项目。

一、Bun.WebView:内置无头浏览器自动化

Bun v1.3.12 引入了 Bun.WebView,这是一个内置的无头浏览器自动化工具,支持 WebKit 和 Chrome 两种后端,提供类似 Playwright 的 API。

主要特性

  • 原生事件模拟:所有输入均以操作系统级别事件分发,无法被网站检测为自动化。
  • 自动等待:支持选择器操作的自动等待,确保元素可见、稳定后再执行。
  • 跨平台支持:WebKit 默认用于 macOS,Chrome 后端支持所有平台。

示例代码

以下代码展示了如何使用 Bun.WebView 进行页面导航、点击和截图:

await using view = new Bun.WebView({ width: 800, height: 600 });
await view.navigate("https://bun.sh");

await view.click("a[href='/docs']"); // 等待元素可点击并执行点击
await view.scroll(0, 400); // 模拟滚轮事件

const title = await view.evaluate("document.title");
const png = await view.screenshot({ format: "jpeg", quality: 90 });
await Bun.write("page.jpg", png);

二、Markdown 渲染:终端直接预览

Bun v1.3.12 支持直接在终端渲染 Markdown 文件,提供了两种方式:

  1. 运行 bun ./file.md
  2. 使用 Bun.markdown.ansi() API。

示例代码

以下代码展示了如何使用 Bun.markdown.ansi() 渲染 Markdown:

const out = Bun.markdown.ansi("# Hello\n\n**bold** and *italic*\n");
process.stdout.write(out);

// 启用超链接
const linked = Bun.markdown.ansi("[docs](https://bun.sh)", { hyperlinks: true });
process.stdout.write(linked);

三、Bun.cron:进程内定时任务

Bun.cron 新增了回调函数支持,适合长时间运行的服务和容器。

示例代码

以下代码展示了如何使用 Bun.cron 定时执行异步任务:

Bun.cron("* * * * *", async () => {
  console.log("每分钟执行一次");
});

四、性能优化与新特性

URLPattern 性能提升

URLPattern.test()URLPattern.exec() 的性能提升了最高 2.3 倍。

const pattern = new URLPattern({ pathname: "/api/users/:id/posts/:postId" });
pattern.test("https://example.com/api/users/42/posts/123");

Bun.stripANSI 和 Bun.stringWidth 的 SIMD 优化

Bun.stripANSIBun.stringWidth 的性能显著提升,处理速度最高提升 11 倍。

bun build 构建优化

修复了线程池问题,使低核机器上的构建速度提升了 1.43 倍。

五、Bug 修复与兼容性改进

  • 修复了多个 Node.js 兼容性问题,例如 process.env 在某些情况下为空的问题。
  • 改进了 Bun.serve 的 TCP_DEFER_ACCEPT 支持,降低了 HTTP 请求延迟。

六、升级指南与验证步骤

升级到 v1.3.12

运行以下命令升级到最新版本:

bun upgrade

验证新功能

验证 Bun.WebView 是否正常工作:

await using view = new Bun.WebView();
await view.navigate("https://example.com");
console.log(await view.title);

七、总结

Bun v1.3.12 带来了众多令人兴奋的新特性和性能优化,尤其是 Bun.WebViewBun.cron 的引入,为开发者提供了更多可能性。通过本文的示例代码和实战指南,相信你已经掌握了这些新功能的使用方法。

关注 【iDao技术魔方】,获取更多全栈到AI可落地的实战干货。

用AI读源码这件事:前端视角的实战方法论,附Vue3 reactivity源码解读示范

用AI读源码这件事:前端视角的实战方法论,附Vue3 reactivity源码解读示范

读源码这件事,前端开发者应该不陌生。

学新框架要看源码、理解某个第三方库的行为要看源码、接手一个没人维护的老项目更要翻源码。但说实话,源码读到一半上下文丢了、函数调用链路追踪到后面找不到头绪、第三方库没有文档只能靠猜——这些场景应该每个人都遇到过。

这两年AI工具多了,我开始尝试用AI辅助读源码。一开始踩了不少坑,后来慢慢摸索出几个相对稳定的用法,今天把实际验证过的方法论配合真实代码讲清楚。


1. 先定位再提问:不要把整个文件丢给AI

这是最容易犯的错误——把几百行代码一股脑丢给AI问"这段是做什么的"。

AI的上下文窗口虽然长,但代码量大了之后它容易失焦,回复要么泛泛而谈、要么开始自己编一段你根本找不到出处的逻辑。更关键的是,这样浪费了上下文token,效果反而差。

正确的做法

用IDE的 Go to Definition / Find References 定位到核心函数,只把相关的片段丢给AI

实战演示:用Vue3 reactive 源码

假设我想理解 Vue3 的 reactive 函数做了什么。先找到函数定义:

// vuejs/core/packages/reactivity/src/reactive.ts
export function reactive<T extends object>(target: T): Reactive<T>
/*@__NO_SIDE_EFFECTS__*/
export function reactive(target: object) {
  // 如果目标已经是readonly代理,直接返回
  if (isReadonly(target)) {
    return target
  }
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers,
    reactiveMap,
  )
}

这段代码本身不到20行,包含的类型声明和接口定义加起来也就50行左右。

丢给AI的提问方式:

"请解读这个reactive函数的实现逻辑,重点关注:1)为什么第一步要判断isReadonly;2)createReactiveObject各参数的含义;3)false这个标记位的作用是什么?"

比这样问效果好很多:

"reactive函数是做什么的?"(太宽泛,AI会复述文档而非分析代码)

不要这样问:

"帮我分析整个reactive.ts文件"(上下文太散,AI容易乱编)


2. 追问链:连续追问形成探索链路

读源码不是做问答游戏,不是问一句答一句就结束了。好的使用方式是连续追问,每一轮都基于上一轮的回答延伸

追问链的标准模板

第一轮:这个函数的入参是什么类型,它做了哪些初步判断?
第二轮:如果入参已经是响应式对象会怎样?走哪个分支?
第三轮:这个分支里的Proxy是在哪里创建的?
第四轮:Proxy的get handler里,为什么要做scheduler判断?

每一轮只问一个"深入一层"的问题,不要同时问多个相关但不同层次的问题——问多了AI会选最浅的回答你。

实战演示:用追问链追踪reactive的proxy创建

我:
reactive函数里的createReactiveObject做了什么?

AI:
createReactiveObject是创建响应式代理的核心函数...
它的第三个参数mutableHandlers是一个Proxy的handler配置对象...
它会拦截对象的get/set等操作来实现依赖收集和触发更新...

我:
那mutableHandlers在哪里定义的?

AI:
在reactive.ts同目录下的baseHandlers.ts中...
它定义了getset、deleteProperty等handler...
核心逻辑在get函数里,涉及到isRef判断、嵌套reactive等内容...

我:
get handler里的isRef判断具体在哪个条件下触发?

AI:
当访问的属性值是一个ref对象时(通过isRef函数判断),
会自动解包ref并返回其内部值。这个逻辑在get handler中大约第15行附近...

这样一层层追问下去,你会获得一条清晰的追踪路径,而且每一步都有代码依据。

追问链的进阶技巧

① 给AI一个假设,让它验证或否定:

"我猜测对数组调用reactive时,会进入COLLECTION分支,请对照代码确认这个猜测,如果不对指出第一个分叉点在哪里。"

这种方式的好处是:你有猜测,AI不会泛泛而谈;如果你猜错了,修正过程本身就是深入理解。

② 告诉AI你读到这里"卡住了":

"我在追踪proxy的创建流程,但在createReactiveObject的第四个参数那里卡住了——这个WeakMap的作用是什么?它和reactiveMap有什么区别?"

把"卡住"说出来,AI会针对你的具体断点给出分析,而不是重新泛泛概述。


3. 标注"不确定":让AI帮你做验证性推理

读代码时经常会有这种感觉——"这里逻辑看起来奇怪"或者"这个条件判断可能有问题"。

这时候可以直接把你的不确定告诉AI:

"我不确定这里为什么需要判断 !isObject,如果传进来的是数组会怎样?" "这段代码里如果target是null会走哪个分支?" "这个else分支我觉得永远不会执行,请帮我确认。"

这种"不确定+具体猜测"的方式,比直接问"这段代码的逻辑是什么"效果好很多。

原因在于:你在引导AI做验证性推理——验证或否定你的猜测,而不是做描述性推理——把代码表面意思翻译成自然语言。前者能挖到深层逻辑,后者只是换了种表达方式。

实战演示

在reactive源码中有这样一段:

function getTargetType(value: Target) {
  return value[ReactiveFlags.SKIP] || !Object.isExtensible(value)
    ? TargetType.INVALID
    : targetTypeMap(toRawType(value))
}

可以这样问AI:

"我不确定这里为什么用 Object.isExtensible(value) 来判断是否是无效目标,如果一个普通对象但它的原型被锁定了(Object.preventExtensions),会不会被误判为INVALID类型?"

AI会带你去看 Object.preventExtensionsObject.isExtensible 的区别,结合这段代码的具体场景分析这是有意为之还是潜在问题。


4. 用"用户视角"理解第三方库的外部行为

这个技巧适合在理解一个库的API行为时使用,而不是追踪内部实现。

核心思路:让AI站在调用方的角度,从外向内追踪。

比如我想理解 vueuseuseLocalStorage 为什么在SSR时会失效,我会这样说:

"作为一个使用useLocalStorage的开发者,我在SSR环境下发现值不同步。请帮我追踪这个函数的实现,找到可能导致SSR场景下行为不一致的原因。"

从"用户视角"切入,AI会倾向于从暴露的API开始追踪,而不是陷入内部的工具函数。对于理解一个库的外部行为特别有用。


5. 结合IDE做交叉验证

AI说的内容不一定完全准确,特别是涉及复杂调用链时。交叉验证非常重要。

我的习惯是:

  1. AI给出的关键结论,在IDE里用 Go to Definition 快速确认
  2. 如果AI说的函数名在IDE里找不到,那说明AI在编造
  3. 对于特别重要的结论,同时问两个不同的AI工具,看结论是否一致

这本质上是一种工程思维——不迷信单一信息源,用多个工具交叉核对。


总结

用AI读源码这件事,用对了确实能提升效率,但有几个前提:

  • 你得对代码结构有基本的方向感,知道去哪找文件、找哪个函数
  • AI是辅助工具,不是替代品,核心逻辑还是得自己理解
  • 不要过度依赖,遇到关键逻辑最好在IDE里自己跑一遍
  • AI的结论要交叉验证,不轻信

这些方法不一定多新颖,但都是我实际工作里反复验证过的。如果有更好的技巧,欢迎交流。


参考资料

  1. Vue3 Core 源码 - reactivity 模块(MIT License)
  2. Vue3 官方文档 - Reactive 响应式原理
  3. MDN - Object.isExtensible
  4. MDN - Proxy
  5. Anthropic - Claude for Code(AI代码辅助工具相关研究)
  6. GitHub Blog - Developer Experience

以上内容均为技术原理分享,源码引用遵守Vue3的MIT开源协议。

❌