阅读视图

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

你可能从未用过的浏览器 API:IndexedDB 实战入门

前端开发这几年,localStorage 和 sessionStorage 用得最多,cookie 偶尔也要打打交道。但说到 IndexedDB,很多人的反应是:“听说过,但没用过。”

今天聊聊这个被低估的浏览器内置数据库。

一、为什么需要另一个存储方案?

先看个实际场景。朋友公司做电商后台,产品经理要求:“能不能在列表页缓存 5000 条商品数据,让筛选和搜索快一点?”

第一版用 localStorage:

// 存储
localStorage.setItem('products', JSON.stringify(products)) // 5000条数据,页面卡了2秒

// 搜索
const keyword = '手机'
const allProducts = JSON.parse(localStorage.getItem('products')) // 又卡1秒
const results = allProducts.filter(p => p.name.includes(keyword)) // 遍历5000次

上线后用户反馈:“筛选时浏览器像卡住了一样。”

问题在哪?localStorage 有硬伤:

  • 同步操作,数据量大就阻塞页面
  • 只能存字符串,对象要序列化
  • 容量小(通常 5-10MB)
  • 只能全量读取,无法高效查询

二、IndexedDB 是什么?

简单说,它是浏览器里的 NoSQL 数据库。2011 年就出现了,但很多人不知道或觉得“用不上”。

几个关键特点:

  1. 容量大:通常能占硬盘 50%,几个 GB 没问题
  2. 异步操作:不卡页面
  3. 支持索引:查询速度快
  4. 能存多种类型:对象、文件、二进制数据都行

三、一个简单示例

如果你没用过,先看看基本用法:

// 1. 打开数据库
const request = indexedDB.open('myDB', 1)

// 2. 创建表结构(第一次或升级时)
request.onupgradeneeded = function(event) {
  const db = event.target.result
  
  // 创建对象存储(类似表)
  const store = db.createObjectStore('products', {
    keyPath: 'id',      // 主键
    autoIncrement: true // 自动生成ID
  })
  
  // 创建索引(加速查询的关键)
  store.createIndex('name', 'name')      // 按名称查
  store.createIndex('price', 'price')    // 按价格查
  store.createIndex('category', 'category') // 按分类查
}

// 3. 数据库就绪
request.onsuccess = function(event) {
  const db = event.target.result
  console.log('数据库已就绪')
}

四、核心优势:查询性能

这是 IndexedDB 真正厉害的地方。同样的 5000 条商品数据,查询完全不同:

// 用索引查,不需要遍历所有数据
async function searchProducts(keyword) {
  const transaction = db.transaction(['products'], 'readonly')
  const store = transaction.objectStore('products')
  const index = store.index('name') // 使用索引
  
  // 只搜索相关范围
  const range = IDBKeyRange.bound(keyword, keyword + '\uffff')
  const request = index.openCursor(range)
  
  return new Promise((resolve) => {
    const results = []
    request.onsuccess = function(event) {
      const cursor = event.target.result
      if (cursor) {
        results.push(cursor.value)
        cursor.continue() // 继续下一个
      } else {
        resolve(results) // 搜索完成
      }
    }
  })
}

// 毫秒级响应,不卡页面
const results = await searchProducts('手机')

你可以创建多个索引,实现各种复杂查询:

  • 价格区间筛选
  • 多条件组合查询
  • 分类统计
  • 模糊搜索

五、适用场景

什么情况下该考虑 IndexedDB?

1. 离线应用

邮件客户端、文档编辑器、笔记应用。数据先存本地,有网再同步。

2. 大数据缓存

电商商品目录、大量配置项、历史数据。替代接口频繁请求。

3. 文件管理

图片、PDF、音视频的本地缓存。不用每次都下载。

4. 游戏数据

存档、配置、资源文件。支持离线游戏。

5. 分析数据

收集用户行为,批量上传。避免频繁网络请求。

六、实用建议

1. 用封装库简化开发

原生 API 确实有点繁琐。推荐这些库:

// 用 idb 库(推荐)
import { openDB } from 'idb'

const db = await openDB('my-db', 1, {
  upgrade(db) {
    db.createObjectStore('products')
  }
})

// 操作简单多了
await db.add('products', { name: '商品1', price: 100 })
const products = await db.getAll('products')

2. 渐进增强

先判断支持性,不支持就降级:

function getStorage() {
  if ('indexedDB' in window) {
    return {
      type: 'indexedDB',
      save: saveToIndexedDB,
      load: loadFromIndexedDB
    }
  } else {
    console.log('降级到 localStorage')
    return {
      type: 'localStorage',
      save: saveToLocalStorage,
      load: loadFromLocalStorage
    }
  }
}

3. 注意版本迁移

修改表结构需要升级版本:

const request = indexedDB.open('myDB', 2) // 版本号+1

request.onupgradeneeded = function(event) {
  const db = event.target.result
  const oldVersion = event.oldVersion
  
  if (oldVersion < 1) {
    // 初始版本逻辑
  }
  
  if (oldVersion < 2) {
    // 版本2的升级逻辑
    // 比如添加新索引
    const store = event.currentTarget.transaction.objectStore('products')
    store.createIndex('createdAt', 'createdAt')
  }
}

七、什么时候不用?

IndexedDB 虽好,但也不是万能:

  • 存个用户 token → 用 localStorage 或 cookie
  • 会话级临时数据 → 用 sessionStorage
  • 简单配置项 → localStorage 更方便
  • 需要服务端读取 → cookie

记住:技术选型要看具体需求,不是越高级越好。

八、开始尝试

如果你从没用过 IndexedDB,可以从这些开始:

  1. 缓存接口数据:把频繁请求的 API 结果缓存起来
  2. 离线收藏功能:用户收藏的内容存本地
  3. 图片懒加载缓存:看过的图片存起来
  4. 表单草稿:复杂的表单数据实时保存

不需要一开始就大动干戈。找个合适的场景,先试试水。

写在最后

IndexedDB 在前端领域存在感不强,可能因为它解决的问题不是每个项目都会遇到。但当你真的需要处理大量客户端数据时,它会是个很好的选择。

技术没有绝对的好坏,只有合适与否。知道它的存在,了解它的能力,当合适的需求出现时,你就能做出更好的选择。


看完有点兴趣了?可以在个人项目里试试 IndexedDB,遇到问题欢迎交流。如果你已经在用,有什么经验或踩坑故事?评论区聊聊。

Vue3 + Vite 性能优化实战

Vue3 + Vite 性能优化实战:从开发到生产,全方位提速指南

前言:在前端开发的江湖里,Vue3 + Vite 组合早已成为主流选择,凭借简洁的语法、高效的构建能力,成为很多项目的首选技术栈。但不少开发者迁移后却纷纷吐槽“不够快”——开发时冷启动卡顿、热更新延迟,生产环境首屏加载缓慢、打包体积臃肿。其实不是 Vue3 和 Vite 不给力,而是你的配置和用法没到位!今天就结合实战经验,分享一套从开发期到生产期的全方位性能优化技巧,把这套组合的性能压榨到极致,让你的项目开发飞起、运行丝滑✨

一、先搞懂:Vite 快的核心原理

在开始优化前,先简单理清 Vite 比传统构建工具(如 Webpack)快的核心逻辑,才能精准找到优化切入点,避免盲目操作。

Vite 的速度优势主要体现在两个阶段,吃透这两点,后续优化会更有方向:

  1. 开发期:原生 ESM + ESBuild 预构建:Vite 启动时不会打包整个项目,只需启动一个开发服务器,通过浏览器原生 ESM 加载源码;同时用 ESBuild(Go 语言编写)对 node_modules 中的依赖进行预构建,比 Webpack 的 JS 编写的构建器快 10-100 倍,冷启动速度大幅提升,相当于“打开一扇门就能进房间,不用拆了整个房子重建”。
  2. 生产期:Rollup 深度优化打包:生产环境下,Vite 会切换到 Rollup 进行打包(Rollup 对 ES 模块的 tree-shaking 更彻底),配合一系列优化配置,能最大程度精简打包体积,兼顾速度和体积双重优势。

小提醒:很多开发者误以为“用了 Vite 就一定快”,其实默认配置下,面对大型项目或不合理的依赖引入,依然会出现性能瓶颈——这也是我们今天优化的核心意义。

二、开发期优化:告别卡顿,提升开发体验

开发期的优化核心是“降低启动时间、减少热更新延迟”,让我们在写代码时不用等待,专注开发本身。以下技巧均经过实战验证,直接复制配置即可生效。

1. 依赖预构建优化:精准控制预构建范围

Vite 会自动预构建 node_modules 中的依赖,但默认配置可能会预构建一些不必要的依赖,或遗漏常用依赖,导致启动速度变慢。我们可以手动配置 optimizeDeps,精准控制预构建范围。

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src') // 路径别名,减少路径查找时间
    }
  },
  // 依赖预构建优化
  optimizeDeps: {
    include: ['vue', 'vue-router', 'pinia', 'axios'], // 强制预构建常用依赖
    exclude: ['some-large-library'], // 排除大型第三方库(如echarts,按需引入即可)
    cacheDir: '.vite', // 缓存预构建结果,提升二次启动速度(默认就是.vite,可自定义路径)
  }
})

优化点说明:include 配置常用依赖,避免 Vite 重复判断是否需要预构建;exclude 排除大型库,避免预构建体积过大;路径别名不仅方便开发,还能减少 Vite 的路径查找时间,一举两得。

2. HMR 优化:解决热更新延迟问题

热更新(HMR)是开发期高频使用的功能,若出现延迟(修改代码后几秒才生效),会严重影响开发效率。尤其是在 Windows 或 Docker 环境下,大概率是文件监听配置不合理导致的,可通过以下配置优化:

// vite.config.ts 新增 server 配置
server: {
  watch: {
    usePolling: true, // Windows/Docker 环境必加,解决文件监听不灵敏问题
    ignored: ['**/node_modules/**', '**/.git/**', '**/dist/**'], // 忽略无需监听的目录
    interval: 100, // 监听间隔,单位ms,默认100,可根据需求调整
  },
  open: true, // 启动后自动打开浏览器
  port: 3000, // 固定端口,避免每次启动随机端口
  strictPort: true, // 端口被占用时,直接报错(避免自动切换端口导致的配置错乱)
}

补充:若项目体积过大,可额外配置 server.hmr.overlay: false,关闭热更新错误提示层(错误提示会打印到控制台),也能轻微提升热更新速度。

3. 多页面应用(MPA)优化:独立构建,提升效率

若你的项目是多页面应用(如后台管理系统 + 前台展示页面),默认配置下会构建所有页面,启动速度较慢。可通过配置多入口,让每个页面独立构建,按需加载:

// vite.config.ts 新增 build 配置
build: {
  rollupOptions: {
    input: {
      main: resolve(__dirname, 'index.html'), // 主页面入口
      admin: resolve(__dirname, 'admin.html'), // 后台页面入口
      mobile: resolve(__dirname, 'mobile.html') // 移动端页面入口
    },
  },
}

优化效果:启动时只会构建当前访问的页面,其他页面不加载,冷启动速度提升 50% 以上;打包时也能独立打包每个页面,后续部署可按需部署,降低部署成本。

三、生产期优化:精简体积,提升运行速度

生产期的优化核心是“减小打包体积、提升首屏加载速度”——用户不会等待一个加载十几秒的页面,首屏加载速度直接影响用户留存。以下优化从“体积精简、加载提速、性能监控”三个维度展开,覆盖生产期全场景。

1. 代码分割:合理分包,减少首屏加载体积

默认打包会将所有代码合并成一个大文件,首屏加载时需要加载整个文件,速度较慢。通过代码分割,将代码拆分成多个小文件,按需加载,能显著提升首屏加载速度。

// vite.config.ts build 配置新增
build: {
  rollupOptions: {
    output: {
      // 自定义分包策略
      manualChunks: {
        'vue-vendor': ['vue', 'vue-router', 'pinia'], // Vue 核心依赖打包成一个文件
        'ui-vendor': ['element-plus', 'ant-design-vue'], // UI 组件库打包成一个文件
        'utils': ['lodash-es', 'dayjs', 'axios'], // 工具库打包成一个文件
      },
      // 静态资源命名规范,便于缓存
      assetFileNames: 'assets/[name]-[hash].[extname]',
      chunkFileNames: 'chunks/[name]-[hash].js',
      entryFileNames: 'entry/[name]-[hash].js',
    },
  },
  // 开启压缩(默认开启,可进一步优化)
  minify: 'esbuild', // 用 esbuild 压缩,速度快;需要更极致压缩可改用 'terser'
}

优化逻辑:将核心依赖、UI 库、工具库分别打包,这些文件变更频率低,可利用浏览器缓存(后续用户访问时无需重新加载);业务代码单独打包,变更频率高,减小每次更新的加载体积。

2. 静态资源优化:减小传输体积,减少请求次数

前端项目中,图片、字体等静态资源往往是打包体积的“大头”,合理优化静态资源,能快速减小打包体积,提升加载速度。

(1)图片优化
// vite.config.ts 新增 assets 配置
build: {
  assetsInlineLimit: 4096, // 小于 4KB 的图片转 base64,减少 HTTP 请求
}
// 额外安装 vite-plugin-imagemin 插件,实现图片压缩(可选,需手动安装)
import imagemin from 'vite-plugin-imagemin'

plugins: [
  vue(),
  imagemin({
    gifsicle: { optimizationLevel: 7, interlaced: false }, // gif 压缩
    optipng: { optimizationLevel: 7 }, // png 压缩
    mozjpeg: { quality: 80 }, // jpg 压缩
    pngquant: { quality: [0.7, 0.8], speed: 4 }, // png 深度压缩
  })
]

补充建议:开发时尽量使用 WebP/AVIF 格式图片(体积比 JPG/PNG 小 30%-50%),可通过 picture 标签做降级兼容,兼顾兼容性和体积。

(2)字体优化

字体文件往往体积较大,可通过“按需引入字体子集”“压缩字体”优化:

  1. 使用 font-spider 工具,提取项目中实际用到的字体字符,生成字体子集(删除未用到的字符,体积可减小 80% 以上);
  2. 将字体文件放在 CDN 上,通过 preload 预加载关键字体,避免字体加载延迟导致的“闪屏”问题。

3. 组件懒加载:按需加载,减少首屏渲染压力

Vue3 提供了路由级懒加载和组件级懒加载两种方式,能有效减少首屏需要加载的组件数量,提升首屏渲染速度,尤其适合大型项目。

(1)路由级懒加载(最基础、最推荐)
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    name: 'Home',
    // 路由懒加载:点击路由时才加载对应的组件
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('@/views/About.vue')
  },
  {
    path: '/admin',
    name: 'Admin',
    // 嵌套路由也支持懒加载
    component: () => import('@/views/Admin/Admin.vue'),
    children: [
      { path: 'dashboard', component: () => import('@/views/Admin/Dashboard.vue') }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router
(2)组件级懒加载(针对大型组件)

对于体积较大的组件(如富文本编辑器、图表组件),即使在当前路由中,也可通过 defineAsyncComponent 实现懒加载,用到时再加载:

// 组件中使用
首页
    <!-- 懒加载大型组件 -->
    <HeavyComponent v-if="showHeavyComponent" />
    <button @显示大型组件<script setup 
import { ref, defineAsyncComponent } from 'vue'

// 定义异步组件(懒加载)
const HeavyComponent = defineAsyncComponent(() => import('@/components/HeavyComponent.vue'))

const showHeavyComponent = ref(false)
(3)第三方组件按需引入

若使用 Element Plus、Ant Design Vue 等 UI 组件库,一定要开启按需引入,避免打包整个组件库(体积会增加几百 KB):

// vite.config.ts 配置 Element Plus 按需引入
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

plugins: [
  vue(),
  Components({
    resolvers: [ElementPlusResolver()], // 自动按需引入 Element Plus 组件
  })
]

注意:无需手动引入组件和样式,插件会自动识别模板中使用的组件,按需打包对应的组件和样式。

4. 性能监控:精准定位性能瓶颈

优化完成后,需要通过工具监控性能,确认优化效果,同时定位未优化到位的瓶颈。推荐两个常用工具,简单易上手:

(1)打包体积分析:rollup-plugin-visualizer

通过该插件,可生成打包体积分析图,清晰看到每个模块的体积占比,快速找到体积过大的模块:

// 安装插件:npm i rollup-plugin-visualizer -D
import { visualizer } from 'rollup-plugin-visualizer'

plugins: [
  vue(),
  // 打包体积分析
  visualizer({
    open: true, // 打包完成后自动打开分析图
    gzipSize: true, // 显示 gzip 压缩后的体积
    brotliSize: true, // 显示 brotli 压缩后的体积
  })
]

使用方法:执行 npm run build 后,会在 dist 目录下生成 stats.html 文件,打开后即可看到体积分析图,针对性优化体积过大的模块。

(2)浏览器性能监控:Lighthouse

Chrome 浏览器自带的 Lighthouse 工具,可全面检测页面的性能、可访问性、SEO 等指标,给出具体的优化建议:

  1. 打开 Chrome 开发者工具(F12),切换到 Lighthouse 标签;
  2. 勾选“Performance”(性能),点击“Generate report”;
  3. 等待检测完成,根据报告中的“Opportunities”(优化机会),进一步优化性能。

四、TS 集成优化:兼顾类型安全与性能

现在很多 Vue3 项目都会搭配 TypeScript 使用,TS 虽能提升代码可维护性,但也可能带来性能损耗(如类型检查耗时过长),可通过以下配置优化:

// tsconfig.json 核心配置优化
{
  "compilerOptions": {
    "target": "es2020", // 目标 ES 版本,匹配 Vite 构建目标
    "module": "esnext", // 模块格式,支持 ESM
    "experimentalDecorators": true, // 支持装饰器(若使用)
    "useDefineForClassFields": true,
    "isolatedModules": true, // 提升大型项目类型检查效率
    "skipLibCheck": true, // 跳过第三方库的类型检查,减少耗时
    "noEmit": true, // 只做类型检查,不生成编译文件(Vite 负责构建)
    "strict": true, // 开启严格模式,兼顾类型安全
    "moduleResolution": "bundler", // 让 TS 使用 Vite 的模块解析逻辑,避免冲突
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
  "exclude": ["node_modules"]
}

优化点说明:skipLibCheck 跳过第三方库类型检查,可大幅减少类型检查耗时;isolatedModules 开启后,TS 会将每个文件视为独立模块,提升构建和类型检查效率;moduleResolution: "bundler" 避免 TS 和 Vite 的模块解析逻辑冲突,减少报错。

五、实战总结:优化前后对比 & 避坑指南

1. 优化前后效果对比(大型 Vue3 + Vite + TS 项目)

优化维度 优化前 优化后 提升比例
开发期冷启动时间 8-10 秒 1-2 秒 80%+
热更新延迟 2-3 秒 ≤300ms 85%+
生产打包体积(未压缩) 1.2MB 450KB 62.5%
首屏加载时间(3G 网络) 8-10 秒 2-3 秒 70%+

2. 常见避坑点(必看)

  • 不要盲目开启所有优化:按需优化即可,比如小型项目无需配置多页面入口、手动分包,反而会增加配置复杂度;
  • 避免过度压缩:用 terser 压缩虽能减小体积,但会增加打包时间,大型项目可权衡选择,小型项目用 esbuild 足够;
  • 图片转 base64 要适度:大于 4KB 的图片不建议转 base64,会增加 JS 文件体积,反而拖慢首屏加载;
  • 第三方库优化优先:很多时候性能瓶颈来自第三方库(如 echarts、xlsx),优先考虑按需引入、CDN 引入,而非自己优化源码。

六、结尾互动

以上就是 Vue3 + Vite 从开发到生产的全方位性能优化实战技巧,所有配置均经过真实项目验证,直接复制就能用!

你在使用 Vue3 + Vite 时,还遇到过哪些性能问题?比如冷启动卡顿、打包体积过大、热更新失效等,欢迎在评论区留言讨论,一起解决前端性能难题~

如果觉得这篇文章对你有帮助,别忘了点赞、收藏、关注,后续会分享更多 Vue3、Vite、TS 相关的实战干货!

掘金标签推荐:#前端 #Vue3 #Vite #性能优化 #TypeScript(3-5 个标签,贴合主题,提升曝光)

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

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

高清场景颜色节点(HD Scene Color Node)是Unity高清渲染管线(HDRP)中一个功能强大的着色器图形节点,它扩展了传统场景颜色节点的能力,为开发者提供了更精细的颜色缓冲区访问控制。该节点的核心价值在于能够访问颜色缓冲区的Mipmap级别,这在实现各种高级渲染效果时至关重要。

在实时渲染中,颜色缓冲区存储了场景的最终渲染结果,而Mipmap链则是该缓冲区的一系列逐渐降低分辨率版本。HD Scene Color节点的独特之处在于它允许着色器程序访问这些不同分辨率的颜色数据,为后处理效果、屏幕空间反射、细节层次(LOD)系统等高级图形功能提供了技术基础。

渲染管线兼容性详解

HD Scene Color节点的可用性完全取决于所使用的渲染管线,这是开发者在选择和使用该节点时必须首先考虑的因素。

高清渲染管线(HDRP)支持

  • HDRP是Unity针对高端平台和高端硬件设计的高保真渲染解决方案
  • HD Scene Color节点专为HDRP设计,充分利用了HDRP的复杂渲染架构
  • 在HDRP中,颜色缓冲区通常包含HDR(高动态范围)数据,提供了更丰富的颜色信息和亮度范围
  • HDRP的渲染路径允许多个颜色缓冲区并存,HD Scene Color节点可以访问这些缓冲区中的特定数据

通用渲染管线(URP)不支持

  • URP是Unity的轻量级、跨平台渲染解决方案,设计目标是性能和效率
  • URP不支持HD Scene Color节点,因为它简化了渲染架构,不包含完整的Mipmap颜色缓冲区链
  • 在URP中,开发者应使用标准的Scene Color节点来访问场景颜色,但无法访问不同Mip级别的数据
  • 这种设计差异反映了URP和HDRP在目标应用场景和功能复杂度上的根本区别

选择正确的渲染管线对于项目成功至关重要。如果项目需要高级颜色缓冲区操作、复杂的后处理效果或面向高端硬件平台,HDRP和HD Scene Color节点是理想选择。而对于移动端、VR或需要广泛平台兼容性的项目,URP可能是更合适的选择,尽管它不支持HD Scene Color节点的所有高级功能。

端口详细说明

HD Scene Color节点的三个端口分别承担着不同的功能,理解每个端口的特性和用法是实现预期视觉效果的关键。

UV输入端口

UV输入端口是节点中最常用的输入之一,它定义了在颜色缓冲区中采样的位置。

数据类型与绑定

  • UV端口接受Vector 4类型的输入,提供了足够的维度来支持各种采样坐标系统
  • 该端口默认绑定到屏幕位置(Screen Position),这意味着如果不显式连接其他值,节点将使用当前像素的屏幕坐标进行采样
  • 屏幕坐标通常是归一化的,范围在[0,1]之间,其中(0,0)表示屏幕左下角,(1,1)表示屏幕右上角

高级使用技巧

  • 可以通过连接其他节点来修改UV值,实现平移、旋转、缩放等采样效果
  • 使用时间变量动画UV坐标可以创建动态采样效果,如屏幕波动、热浪扭曲等
  • 通过偏移UV坐标,可以实现视差效果、伪反射和其他基于屏幕空间的变形
  • 在多摄像机设置中,需要注意UV坐标的参考系,确保采样正确的摄像机颜色缓冲区

实际应用示例

假设我们想创建一个简单的屏幕扭曲效果,可以连接一个正弦波节点到UV端口的X和Y分量,使采样位置随时间轻微波动,模拟热量 haze 或水下的折射效果。

Lod输入端口

Lod(Level of Detail)输入端口是HD Scene Color节点区别于普通Scene Color节点的关键特性,它控制着采样时使用的Mipmap级别。

Mipmap基础概念

  • Mipmap是原始纹理的一系列缩小版本,每个后续级别的分辨率减半
  • 在实时渲染中,Mipmap主要用于减少远处表面的锯齿和提高缓存效率
  • HD Scene Color节点允许访问颜色缓冲区的Mipmap链,这意味着可以采样到不同分辨率的场景颜色数据

Lod端口特性

  • Lod端口接受Float类型的输入,表示要采样的Mip级别
  • 值为0表示最高分辨率的原始颜色缓冲区
  • 值每增加1,对应的Mip级别分辨率减半(级别1为1/2分辨率,级别2为1/4分辨率,以此类推)
  • 支持小数值,允许在三线性过滤模式下在Mip级别之间平滑插值

Lod值的计算与使用

  • 可以直接连接常量值来固定Mip级别
  • 可以根据像素到摄像机的距离动态计算Lod值,实现自适应细节级别
  • 可以使用屏幕空间导数函数(如ddx/ddy)来计算基于局部几何复杂度的Lod值
  • 在后处理效果中,通常使用较高的Lod值(如2-4)来获取模糊的场景颜色,用于泛光、景深等效果

性能考虑

  • 采样较高的Mip级别(较低分辨率)通常更快,因为需要处理的数据更少
  • 但是,频繁在不同Mip级别之间切换可能导致缓存效率降低
  • 在性能敏感的场景中,应平衡视觉效果需求和性能开销

输出端口

输出端口提供从颜色缓冲区指定位置和Mip级别采样得到的颜色值。

输出特性

  • 输出为Vector 3类型,对应RGB颜色空间中的红、绿、蓝三个通道
  • 颜色值通常位于HDR范围内,可能包含超过[0,1]传统范围的值
  • 输出颜色已经过当前摄像机的色调映射和颜色分级处理(除非在特殊渲染通道中)

颜色空间注意事项

  • 在HDRP中,颜色数据可能在线性空间或伽马空间,取决于项目设置
  • 进行颜色操作时,确保了解当前工作颜色空间,避免不正确的结果
  • 当与其他颜色值混合或操作时,可能需要手动进行颜色空间转换

输出数据的后续处理

  • 采样得到的颜色可以用于各种计算:亮度提取、颜色操作、与其他纹理混合等
  • 在自定义后处理效果中,HD Scene Color节点的输出通常作为主要输入之一
  • 可以通过连接其他着色器图形节点对输出颜色进行进一步处理:应用颜色曲线、调整饱和度、实施颜色替换等

曝光控制深入解析

曝光控制是HD Scene Color节点中一个微妙但重要的特性,正确理解和使用它对实现预期的视觉效果至关重要。

曝光属性基础

曝光属性决定了节点输出颜色时是否应用了场景的曝光设置。

启用曝光

  • 当Exposure属性启用时,输出颜色会乘以当前摄像机的曝光值
  • 这适用于大多数标准渲染情况,确保颜色与场景中的其他元素一致
  • 在自动曝光(自适应曝光)情况下,输出颜色会随曝光调整而动态变化

禁用曝光

  • 当Exposure属性禁用时,输出颜色不会应用曝光调整
  • 这可以防止在已经应用了曝光的颜色上重复应用曝光,避免过度明亮或黑暗的结果
  • 在后处理效果中,通常需要禁用曝光,因为后处理栈通常有自己独立的曝光控制

曝光与HDR渲染

在高动态范围渲染中,曝光控制尤为重要。

HDR颜色值

  • 在HDRP中,颜色缓冲区通常存储超过传统[0,1]范围的值
  • 这些值表示场景中真实的物理光照水平,可能从极暗到极亮
  • 色调映射过程将这些HDR值转换为显示设备能够处理的LDR(低动态范围)值

曝光在色调映射中的作用

  • 曝光是色调映射过程中的关键参数,控制着HDR到LDR的转换
  • 适当的曝光设置确保场景中的重要细节在最终图像中可见
  • HD Scene Color节点的曝光设置决定了采样颜色是否已经过这个转换过程

避免双重曝光问题

双重曝光是使用HD Scene Color节点时常见的错误,会导致颜色计算不正确。

双重曝光的成因

  • 当颜色缓冲区中的数据已经应用了曝光,而节点再次应用曝光时发生
  • 这会导致颜色值被两次乘以曝光值,产生过度明亮或饱和的结果
  • 在后处理效果中特别常见,因为后处理通常在全屏通道中执行,已经包含了曝光信息

识别双重曝光

  • 渲染结果异常明亮或黑暗,与场景照明不符
  • 颜色饱和度异常高,特别是在明亮区域
  • 当调整摄像机曝光时,效果强度变化异常剧烈

解决方案

  • 在大多数后处理场景中,应禁用HD Scene Color节点的Exposure属性
  • 如果需要在着色器中手动应用曝光,可以使用Exposure节点和当前曝光值
  • 测试时,尝试切换Exposure属性,观察结果变化,确定正确的设置

采样器模式详解

HD Scene Color节点使用的三线性钳位模式采样器对采样质量和性能有重要影响。

三线性过滤原理

三线性过滤是一种高级纹理过滤技术,结合了双线性过滤和Mipmap插值。

双线性过滤

  • 在单个Mip级别内,对四个最近的纹素进行加权平均
  • 减少了近距离观察纹理时的块状像素化现象
  • 但不能解决远处表面的闪烁和锯齿问题

Mipmap插值

  • 在两个最近的Mip级别之间进行插值
  • 根据像素在屏幕上的大小自动选择合适的细节级别
  • 解决了远处表面的闪烁和莫尔图案问题

三线性过滤

  • 结合了双线性过滤和Mipmap插值
  • 首先在两个Mip级别上分别执行双线性过滤
  • 然后在两个过滤结果之间进行线性插值
  • 提供了平滑的细节过渡,消除了Mip级别之间的突然变化

钳位模式特性

钳位模式定义了当采样坐标超出标准[0,1]范围时的采样行为。

标准钳位行为

  • 当UV坐标小于0时,使用边界处的颜色值(UV为0时的颜色)
  • 当UV坐标大于1时,使用边界处的颜色值(UV为1时的颜色)
  • 这防止了采样器在纹理边界外采样,避免了意外行为

与其他模式的比较

  • 重复(Wrap)模式会在超出边界时重复纹理
  • 镜像(Mirror)模式会镜像纹理
  • 边框(Border)模式会使用指定的边框颜色
  • 对于屏幕空间采样,钳位模式通常是最合适的选择,因为它符合屏幕边界的物理特性

性能影响与优化

三线性钳位采样虽然质量高,但也有性能成本。

性能考虑

  • 三线性过滤需要访问8个纹素(两个Mip级别各4个),而双线性只需4个
  • 这增加了内存带宽需求和纹理缓存压力
  • 在性能敏感的场景中,可能需要权衡质量与性能

优化策略

  • 对于不需要高质量过滤的效果,可以考虑使用双线性采样
  • 通过适当设置Lod值,可以减少不必要的Mip级别插值
  • 在移动平台或低端硬件上,可以考虑减少三线性过滤的使用范围

实际应用案例

HD Scene Color节点在实践中有多种应用,以下是一些常见的使用场景。

屏幕空间反射

屏幕空间反射(SSR)是HD Scene Color节点的经典应用之一。

基本原理

  • 通过射线行进在屏幕空间中查找反射表面
  • 使用HD Scene Color节点采样反射方向上的场景颜色
  • 通过适当的Lod设置减少反射中的噪点和闪烁

实现步骤

  • 计算当前像素的反射向量
  • 在反射方向上进行射线行进,检测与场景几何的碰撞
  • 使用碰撞点的屏幕坐标作为UV输入HD Scene Color节点
  • 根据射线行进距离和表面粗糙度设置适当的Lod值
  • 将采样得到的反射颜色与表面颜色混合

优化技巧

  • 使用分层射线行进提高性能
  • 根据表面粗糙度动态调整Lod值——粗糙表面使用较高Lod
  • 实施回退机制,当屏幕空间反射失败时使用其他反射技术

自定义后处理效果

HD Scene Color节点是创建自定义后处理效果的强大工具。

颜色分级效果

  • 采样场景颜色并进行非线性颜色变换
  • 实现自定义的色调映射曲线、颜色分级表(LUT)
  • 创建风格化的视觉效果,如复古、电影感或科幻风格

空间效果

  • 使用扭曲的UV坐标采样场景颜色,创建热浪、水下折射等效果
  • 通过时间变化的UV偏移实现屏幕波动效果
  • 结合深度缓冲区实现基于距离的颜色效果

多Pass效果

  • 在第一Pass中采样场景颜色并存储到自定义缓冲区
  • 在后续Pass中结合HD Scene Color节点采样进行复杂混合
  • 实现如运动模糊、景深、泛光等多阶段后处理效果

高级混合模式

HD Scene Color节点可以实现超越标准混合模式的复杂合成效果。

基于深度的混合

  • 结合深度缓冲区信息,实现仅在特定深度范围内生效的混合
  • 创建如雾气、水下水花等基于距离的效果

基于亮度的混合

  • 提取采样颜色的亮度,用于控制混合因子
  • 实现如泛光、镜头光晕等高光相关效果

自定义屏幕空间遮罩

  • 使用HD Scene Color节点采样特定颜色通道作为遮罩
  • 实现仅在屏幕特定区域生效的效果
  • 创建如体积光、上帝光线等局部后处理效果

性能优化与最佳实践

正确使用HD Scene Color节点对保持应用性能至关重要。

采样成本分析

了解HD Scene Color节点的性能特征有助于做出明智的优化决策。

影响因素

  • 采样位置(UV)的连贯性影响缓存效率
  • Lod值影响访问的Mip级别和内存带宽
  • 屏幕分辨率直接影响采样操作的绝对数量

性能监控

  • 使用Unity的Frame Debugger或Render Doc分析具体采样操作
  • 监控GPU时间和内存带宽使用情况
  • 在不同硬件平台上测试性能表现

优化策略

多种策略可以帮助优化使用HD Scene Color节点的着色器性能。

减少采样次数

  • 尽可能重用采样结果,避免重复采样相同位置
  • 使用双线性过滤的优势,通过单次采样获取平滑结果
  • 在可行的情况下,降低采样频率并使用插值

智能Lod选择

  • 根据视觉效果需求选择最低可接受的Lod级别
  • 对远处或次要效果使用较高Lod级别
  • 动态调整Lod级别,平衡质量与性能

平台特定优化

  • 在移动平台上,考虑使用更简单的采样策略
  • 利用特定硬件的纹理采样特性
  • 为不同性能级别的设备提供多个质量设置

故障排除与常见问题

使用HD Scene Color节点时可能遇到各种问题,了解如何识别和解决这些问题很重要。

采样结果不正确

当HD Scene Color节点返回意外结果时,可能的原因和解决方案。

UV坐标问题

  • 确认UV坐标在预期的[0,1]范围内
  • 检查UV坐标是否应用了正确的变换
  • 验证屏幕位置是否正确转换为纹理坐标

Lod设置问题

  • 确认Lod值在合理范围内,不会导致采样过低分辨率的Mip级别
  • 检查Lod计算逻辑是否正确,特别是基于距离或导数的计算
  • 验证三线性插值是否按预期工作

曝光相关问题

  • 检查Exposure属性设置是否符合当前渲染上下文
  • 验证是否存在双重曝光问题
  • 确认颜色空间转换是否正确处理

性能问题

当使用HD Scene Color节点导致性能下降时,可能的优化方向。

识别瓶颈

  • 使用性能分析工具确定是ALU瓶颈还是内存带宽瓶颈
  • 检查是否有不必要的重复采样操作
  • 评估采样频率是否高于视觉效果所需

优化方案

  • 减少全屏采样操作的数量和频率
  • 使用较低分辨率的Mip级别,特别是在后处理效果中
  • 考虑使用近似方法替代精确采样

平台兼容性问题

在不同平台或渲染设置下,HD Scene Color节点可能表现出不同行为。

渲染管线差异

  • 确认项目使用的是HDRP,因为HD Scene Color节点在URP中不可用
  • 检查HDRP版本和配置,确保所有必需功能已启用
  • 验证颜色缓冲区和Mipmap链的可用性

平台特定行为

  • 在不同图形API(DirectX、Vulkan、Metal)下测试着色器
  • 检查移动平台上的功能支持级别
  • 验证着色器变体是否为目标平台正确编译

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

五个chrome ! 我再也不用切账号了

告别多账号切换烦恼:Chrome 多实例配置管理方案

前言

相信做过自动化测试、爬虫开发或者需要管理多个平台账号的同学,都遇到过这样的痛点:

  • 🔄 频繁切换账号:测试不同账号的功能,手动登录登出效率低下
  • 🚫 无痕模式不给力:每次都要重新登录,Cookie、LocalStorage 全丢失
  • 🤯 脚本跑不起来:自动化脚本需要稳定的登录态,手动切换根本不现实
  • 📦 配置难管理:多个 Chrome 实例的配置散落各处,维护成本高

今天分享一个轻量级的解决方案:Chrome 多实例配置管理器,让你优雅地管理多个独立的 Chrome 实例。

核心思路

Chrome 提供了两个关键参数:

--user-data-dir=/path/to/profile    # 独立的用户数据目录
--remote-debugging-port=9222         # 远程调试端口

通过为每个账号分配独立的用户数据目录和调试端口,我们可以:

  1. 完全隔离:每个实例拥有独立的 Cookie、LocalStorage、插件等
  2. 持久化登录:关闭浏览器后,下次启动自动恢复登录态
  3. 并行运行:多个实例可以同时运行,互不干扰
  4. 脚本友好:通过调试端口,可以用 Puppeteer/Playwright 控制浏览器

效果也非常棒

image.png

每个实例都是完全隔开

快速上手

1. 创建配置

const { createConfig } = require('./chromeToolConfig')

// 创建抖音专用配置
const douyinConfig = await createConfig({
  name: '抖音账号1',
  config: {
    userDataDir: './chrome-profiles/douyin-1',
    remoteDebuggingPort: 9222
  }
})

// 创建微博专用配置
const weiboConfig = await createConfig({
  name: '微博账号1',
  config: {
    userDataDir: './chrome-profiles/weibo-1',
    remoteDebuggingPort: 9223
  }
})

// 创建抖音和微博第一个小号配置
const config2 = await createConfig({
  name: '小号A',
  config: {
    userDataDir: './chrome-profiles/douyin-2',
    remoteDebuggingPort: 9224
  }
})

// 创建抖音和微博第二个小号配置
const config3 = await createConfig({
  name: '小号B',
  config: {
    userDataDir: './chrome-profiles/douyin-2',
    remoteDebuggingPort: 9225
  }
})

2. 启动 Chrome

提供了三种启动模式,满足不同场景需求:

# 默认模式:如果已运行则提示
node chromeToolConfig/launch.js 1

# 强制重启:杀死现有进程并重启
node chromeToolConfig/launch.js 1 -hard

# 复用模式:将现有窗口置顶
node chromeToolConfig/launch.js 1 -soft

# 启动时打开指定网址
node chromeToolConfig/launch.js 1 https://www.douyin.com

模式对比

模式 场景 行为
-normal 日常使用 已运行时提示用户选择
-hard 脚本自动化 强制重启,确保干净环境
-soft 快速切换 复用进程,秒级响应

3. 管理账号链接

为每个配置关联平台账号信息:

const { addLink } = require('./chromeToolConfig')

// 为配置添加抖音账号信息
await addLink('chrome_config-1', 'douyin', {
  id: 'user_123456',
  name: '我的抖音账号',
  extra: {
    粉丝数: '10万',
    备注: '主账号'
  }
})

// 添加微博账号
await addLink('chrome_config-1', 'weibo', {
  id: 'weibo_789',
  name: '我的微博'
})

4. 书签同步

将一个配置的账号信息同步到另一个配置:

const { syncBookmarks } = require('./chromeToolConfig')

// 合并模式:保留目标配置的现有账号
await syncBookmarks('1', '2', { merge: true })

// 覆盖模式:完全替换目标配置的账号
await syncBookmarks('1', '2', { merge: false })

// 只同步指定平台
await syncBookmarks('1', '2', { 
  platforms: ['douyin', 'weibo'] 
})

实战场景

场景 1:自动化测试

const puppeteer = require('puppeteer')
const { launchChrome } = require('./chromeToolConfig')

async function runTest() {
  // 启动配置 1 的 Chrome
  const result = await launchChrome('1')
  
  // 连接到已启动的 Chrome
  const browser = await puppeteer.connect({
    browserURL: `http://localhost:${result.port}`
  })
  
  const page = await browser.newPage()
  await page.goto('https://www.douyin.com')
  
  // 执行测试...
  // 登录态已自动恢复,无需重新登录
}

场景 2:多账号并行操作

# 终端 1:启动账号 1
node chromeToolConfig/launch.js 1 https://www.douyin.com

# 终端 2:启动账号 2
node chromeToolConfig/launch.js 2 https://www.douyin.com

# 终端 3:启动账号 3
node chromeToolConfig/launch.js 3 https://www.douyin.com

三个窗口同时运行,互不干扰,可以同时进行不同账号的操作。

场景 3:快速切换账号

# 工作时使用账号 1
node chromeToolConfig/launch.js 1 -soft

# 需要切换到账号 2
node chromeToolConfig/launch.js 2 -soft

# 回到账号 1
node chromeToolConfig/launch.js 1 -soft

使用 -soft 模式,秒级切换,窗口自动置顶。

核心实现

1. 配置管理

// api/config.js
async function createConfig(data) {
  // 验证数据
  validateConfig(data)
  
  // 检查端口和目录唯一性
  await checkUniqueness(data.config)
  
  // 生成 ID
  const id = generateId('chrome_config')
  
  // 保存配置
  const config = {
    id,
    name: data.name,
    config: data.config,
    links: {},
    createdAt: new Date().toISOString()
  }
  
  await db.save('configs', id, config)
  return config
}

2. Chrome 启动

// api/chrome.js
async function launchChrome(id, options = {}) {
  const config = await getConfig(id)
  const userDataDir = path.resolve(config.config.userDataDir)
  
  // 确保目录存在
  await fs.ensureDir(userDataDir)
  
  // 构建启动参数
  const args = [
    `--user-data-dir=${userDataDir}`,
    `--remote-debugging-port=${config.config.remoteDebuggingPort}`
  ]
  
  if (options.url) {
    args.push(options.url)
  }
  
  // 启动 Chrome
  const chromePath = getChromePath()
  const chromeProcess = spawn(chromePath, args, {
    detached: true,
    stdio: 'ignore'
  })
  
  chromeProcess.unref()
  
  return {
    pid: chromeProcess.pid,
    port: config.config.remoteDebuggingPort
  }
}

3. 进程管理

// 检查是否运行
async function isChromRunning(id) {
  const config = await getConfig(id)
  const port = config.config.remoteDebuggingPort
  
  return new Promise((resolve) => {
    const req = http.get(
      `http://localhost:${port}/json/version`,
      (res) => resolve(res.statusCode === 200)
    )
    
    req.on('error', () => resolve(false))
    req.setTimeout(1000, () => {
      req.destroy()
      resolve(false)
    })
  })
}

// 杀死进程
async function killChrome(id) {
  const config = await getConfig(id)
  const port = config.config.remoteDebuggingPort
  
  // 通过端口查找并杀死进程
  return await killChromeByPort(port)
}

项目结构

chromeToolConfig/
├── api/                    # API 层
│   ├── config.js          # 配置管理
│   ├── link.js            # 账号链接管理
│   ├── chrome.js          # Chrome 启动管理
│   └── validator.js       # 数据验证
├── database/              # 数据层
│   ├── db.js              # 数据库操作
│   ├── configs.json       # 配置存储
│   └── todos.json         # 待办事项
├── utils/                 # 工具函数
│   ├── index.js           # 通用工具
│   └── sync.js            # 书签同步
├── launch.js              # 命令行启动工具
└── index.js               # 主入口

设计亮点

1. ID 命名规范

采用统一的命名规则:

  • 配置 ID:chrome_config-1
  • 待办 ID:todo-1

规则

  • 同一含义用 _ 连接(如 chrome_config
  • 不同含义用 - 连接(如 chrome_config-1

2. 自动 ID 补全

支持简写,自动补全前缀:

// 输入 "1",自动转换为 "chrome_config-1"
await getConfig('1')
await launchChrome('1')
await syncBookmarks('1', '2')

3. 数据验证

使用 Joi 进行严格的数据验证:

const configSchema = Joi.object({
  name: Joi.string().required(),
  config: Joi.object({
    userDataDir: Joi.string().required(),
    remoteDebuggingPort: Joi.number().integer().min(1024).max(65535).required(),
    headless: Joi.boolean(),
    windowSize: Joi.string().pattern(/^\d+,\d+$/)
  }).required()
})

4. 跨平台支持

自动检测操作系统,使用对应的 Chrome 路径:

function getChromePath() {
  const platform = process.platform
  
  if (platform === 'darwin') {
    return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
  } else if (platform === 'win32') {
    return 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  } else {
    return 'google-chrome'
  }
}

扩展能力

1. 与 Puppeteer 集成

const puppeteer = require('puppeteer')
const { launchChrome } = require('./chromeToolConfig')

async function connectToChrome(configId) {
  const result = await launchChrome(configId)
  
  const browser = await puppeteer.connect({
    browserURL: `http://localhost:${result.port}`
  })
  
  return browser
}

2. 与 Playwright 集成

const { chromium } = require('playwright')
const { launchChrome } = require('./chromeToolConfig')

async function connectToChrome(configId) {
  const result = await launchChrome(configId)
  
  const browser = await chromium.connectOverCDP(
    `http://localhost:${result.port}`
  )
  
  return browser
}

3. 定时任务

const cron = require('node-cron')

// 每天凌晨 2 点重启所有 Chrome 实例
cron.schedule('0 2 * * *', async () => {
  const configs = await getAllConfigs()
  
  for (const config of configs) {
    await killChrome(config.id)
    await new Promise(resolve => setTimeout(resolve, 1000))
    await launchChrome(config.id)
  }
})

最佳实践

1. 端口分配

建议为不同用途的配置分配不同的端口段:

  • 9222-9229:开发环境
  • 9230-9239:测试环境
  • 9240-9249:生产环境

2. 目录管理

使用有意义的目录名:

await createConfig({
  name: '抖音-主账号',
  config: {
    userDataDir: './chrome-profiles/douyin/main',
    remoteDebuggingPort: 9222
  }
})

await createConfig({
  name: '抖音-测试账号',
  config: {
    userDataDir: './chrome-profiles/douyin/test',
    remoteDebuggingPort: 9223
  }
})

3. 定期清理

定期清理不用的配置和用户数据目录:

const { deleteConfig } = require('./chromeToolConfig')
const fs = require('fs-extra')

async function cleanup(configId) {
  const config = await getConfig(configId)
  
  // 删除配置
  await deleteConfig(configId)
  
  // 删除用户数据目录
  await fs.remove(config.config.userDataDir)
}

4. 错误处理

async function safelaunchChrome(configId) {
  try {
    // 检查是否已运行
    const isRunning = await isChromRunning(configId)
    
    if (isRunning) {
      console.log('Chrome 已在运行,使用 -hard 模式重启')
      await killChrome(configId)
      await new Promise(resolve => setTimeout(resolve, 1000))
    }
    
    return await launchChrome(configId)
  } catch (error) {
    console.error('启动失败:', error.message)
    throw error
  }
}

性能优化

1. 延迟加载

只在需要时才启动 Chrome:

class ChromeManager {
  constructor() {
    this.instances = new Map()
  }
  
  async getInstance(configId) {
    if (!this.instances.has(configId)) {
      const result = await launchChrome(configId)
      this.instances.set(configId, result)
    }
    
    return this.instances.get(configId)
  }
}

2. 连接池

复用已启动的实例:

class ChromePool {
  constructor(maxSize = 5) {
    this.pool = []
    this.maxSize = maxSize
  }
  
  async acquire(configId) {
    // 查找空闲实例
    let instance = this.pool.find(i => i.configId === configId && !i.busy)
    
    if (!instance) {
      // 创建新实例
      if (this.pool.length >= this.maxSize) {
        throw new Error('Pool is full')
      }
      
      const result = await launchChrome(configId)
      instance = { ...result, busy: false }
      this.pool.push(instance)
    }
    
    instance.busy = true
    return instance
  }
  
  release(instance) {
    instance.busy = false
  }
}

总结

Chrome 多实例配置管理器通过以下特性,彻底解决了多账号管理的痛点:

完全隔离:每个账号独立的用户数据目录
持久化登录:关闭浏览器后自动恢复登录态
灵活启动:三种模式满足不同场景需求
脚本友好:通过调试端口轻松集成自动化工具
配置管理:统一管理所有配置和账号信息
跨平台:支持 macOS、Windows、Linux

无论是自动化测试、爬虫开发,还是日常的多账号管理,这个方案都能大幅提升效率。

参考资料


如果这篇文章对你有帮助,欢迎点赞收藏!有任何问题欢迎在评论区讨论 🎉

前端向架构突围系列 - 设计与落地 [9 - 1]:核心原则与决策方法论

写在前面

布鲁克斯在《人月神话》中提出过一个著名的命题:“没有银弹”。

在前端领域同样适用:没有一种架构模式能同时解决开发效率、运行性能、系统稳定性和代码可维护性。

架构师的价值,不在于他知道多少个 NPM 包,而在于他能在需求、资源、技术限制的三角博弈中,画出那条最优的平衡线。

image.png


一、 架构设计的四大铁律

在动手写任何一行架构代码前,请将这四条铁律刻在脑子里:

1.1 简单原则:奥卡姆剃刀

  • 现状: 很多架构师为了展示技术深度,在只有 5 个人的团队里强行推行微前端,结果导致打包时间翻倍,维护成本飙升。
  • 铁律: 如果一个简单的方案能解决问题,绝不引入复杂的方案。 架构的复杂度应该是为了解决业务的复杂度,而不是为了满足架构师的虚荣心。

1.2 关注点分离 (SoC) 与高内聚低耦合

  • 战术: * 逻辑与视图分离: 别在组件里写几百行的业务逻辑,去用 Hook 或 Service。

    • 数据与存储分离: 别让 API 的数据结构直接统治你的 UI。
  • 目标: 改变 A 模块时,B 模块不应该无故“躺枪”。

1.3 演进式架构 (Evolutionary Architecture)

  • 认知误区: “我一次性把未来三年的架构都设计好。”

  • 铁律: 好的架构是长出来的,不是画出来的。

    • 架构设计要预留**“可拆卸性”**。当你现在用单体架构时,代码结构要清晰到未来可以随时无痛拆分出微前端。

1.4 康威定律 (Conway's Law)

  • 核心: 系统的架构设计,本质上是组织沟通结构的反映。
  • 应用: 如果你的公司是按业务线划分团队的,那么强行搞一个跨业务线的“巨型单体应用”必然会产生严重的协作摩擦。架构要顺着人流走,而不是逆流而上。

二、 决策方法论:如何科学地“拍脑袋”?

架构师每天都要面临选择:用 Vite 还是 Webpack?用微前端还是 Iframe?用 Monorepo 还是 Multi-repo?

2.1 决策模型:象限分析法

不要只看“好不好”,要看“值不值”。

维度 高投入 (High Effort) 低投入 (Low Effort)
高收益 战略高地: 需长期投入(如:自研 UI 规范) 低垂果实: 优先执行(如:开启压缩)
低收益 技术陷阱: 坚决避免(如:过度重构旧代码) 日常琐事: 顺手而为

2.2 ADR (Architecture Decision Record)

口头决定的架构往往会在三个月后被遗忘,然后被后人骂作“坑”。

  • 方案: 建立 ADR 决策记录
  • 内容: 记录背景(为什么改)、决策(选了哪个)、权衡(放弃了什么,会有什么副作用)。

三、 架构师的禁忌:过度设计 (Over-Engineering)

过度设计是资深开发者的通病。

案例:

为了实现一个简单的“文件上传”,你封装了一个通用的插件系统、三个抽象类、两层适配器,并号称未来可以支持上传到火星。

代价: 同事看代码需要半小时,改 Bug 需要两小时,而业务其实只需要传个图片给后端。

架构师的自我修养: 识别哪些是“必要的灵活性”,哪些是“臆想的需求”。


四、 核心决策流程:从 0 到 1 落地架构

  1. 需求分析: 性能是核心?协作是核心?还是快速交付是核心?
  2. 现状评估: 团队的技术栈储备如何?旧代码的负债有多深?
  3. 技术选型: 调研 2-3 个方案,制作 POC (Proof of Concept) ,用数据对比(包体积、编译速度、上手难度)。
  4. 灰度落地: 先在一个边缘小模块试点,验证后再全量推行。

结语:架构师不仅是技术专家

架构设计不是在真空中进行的。

一个好的架构师,50% 的时间在写代码和设计图,另外 50% 的时间在沟通与说服。你需要说服老板为什么要投入资源搞基建,说服同事为什么要改变原有的开发习惯。

没有完美的架构,只有最契合当前业务阶段的取舍。

Next Step:

既然聊到了“分治”和“协作”,目前大厂里最火、也最容易踩坑的架构莫过于“微前端”了。

为什么很多公司做了微前端后反而更痛苦了?

下一节,我们进入**《微前端架构 (Micro-Frontends) 的设计陷阱与最佳实践》**,帮你避开那些昂贵的学费。

AI 编程陷阱:Hardcode

202605

最近重度依赖 AI Agent(比如 Claude Code/Codex)做开发,本以为效率原地起飞 🚀。直到这两天为了加新功能,我不得不去通读了一遍它写的代码。

看完直接一身冷汗 😓。

我发现目前的 AI 在写代码时,有一个极其隐蔽但致命的通病:疯狂 Hardcode (硬编码)。

在 TypeScript 的世界里,我们追求的是类型安全和重构友好。但 AI 似乎只想走捷径。举个例子,明明定义了枚举,AI 却偏要在逻辑判断里写魔术字符串 if (task.result === 'error'),而不是类型安全的 if (task.result === TaskStatus.Error)

这看起来是小事,实际上是个超级大坑:

  • 安全感假象:硬编码字符串直接绕过了 TS 编译器检查。
  • 重构灾难:当你修改状态名时,tsc 不会报错,漏改的死字符串成了埋在系统里的“定时炸弹” 💣。
  • 技术债堆积:AI 这种“能跑就行”的思维惰性,是对项目架构的慢性自杀。

既然 AI 喜欢偷懒,我只能给它“上强度”了 🔥。

我的解决办法简单粗暴,直接在项目根目录的 AGENTS.md(System Prompt)里追加了铁律:

  • 严禁 Hardcode:任何状态、配置必须使用常量或枚举,严禁使用原始字符串。
  • 闭环自检:每一轮修改后,必须自动执行并通过 tsc / go build。报错了自己改完再说话。

(完)

uni-app x 实战编程技巧:从高效开发到性能拉满,附可直接复用代码

前言:uni-app x 作为 DCloud 推出的新一代跨端开发框架,基于 uts 语言打造,实现了真正的原生渲染、跨端一致体验,同时兼容 Vue 语法,成为越来越多前端开发者的首选跨端方案。但很多开发者在使用过程中,仍会遇到“写法繁琐、性能瓶颈、跨端兼容踩坑”等问题,导致开发效率低下,最终项目体验不佳。
本文聚焦 uni-app x 实战开发中的高频场景,拆解 8 个可直接落地的编程技巧,涵盖 uts 语法优化、原生能力调用、性能优化、避坑指南等核心维度,每个技巧均配套完整代码示例和场景说明,新手能快速上手,进阶开发者可直接复用提升效率,助力大家用 uni-app x 快速开发高质量跨端应用,同时适配 CSDN 引流需求,收藏+点赞+评论拉满!

一、uts 语法高效用法:告别冗余,提升代码可维护性

uni-app x 以 uts(Uni TypeScript)为核心开发语言,兼容 TypeScript 语法,同时新增了原生类型、跨端接口等特性,用好 uts 能大幅减少冗余代码,降低后期维护成本。

技巧1: uts 类型定义复用,避免重复编码

痛点:开发中经常需要重复定义相同的接口类型(如接口返回数据、组件Props),不仅冗余,还容易出现类型不一致问题。

技巧:利用 uts 的 interface 和 type 特性,封装公共类型,全局复用,同时结合泛型适配多场景。

// src/typings/common.uts
// 1. 封装公共响应体类型(接口请求通用)
export interface ApiResponse<T> {
  code: number; // 状态码
  msg: string;  // 提示信息
  data: T;      // 响应数据(泛型适配不同接口)
  timestamp: number; // 时间戳
}

// 2. 封装分页参数类型(列表请求通用)
export type PageParams = {
  pageNum: number; // 页码
  pageSize: number; // 每页条数
  keyword?: string; // 可选搜索关键词
};

// 3. 业务相关类型(如用户信息)
export interface UserInfo {
  id: string;
  username: string;
  avatar?: string;
  role: 'admin' | 'user'; // 联合类型限定取值
}

// 实战使用(页面/组件中)
import { ApiResponse, PageParams, UserInfo } from '@/typings/common.uts';

// 请求用户列表(泛型指定data类型)
async function getUserList(params: PageParams): Promise<ApiResponse<UserInfo[]>> {
  const res = await uni.request({
    url: '/api/user/list',
    method: 'GET',
    data: params
  });
  return res.data;
}

注意:类型文件建议统一放在 src/typings 目录下,方便全局引入;泛型的合理使用能减少重复定义,提升代码灵活性,尤其适合接口请求场景。

技巧2: uts 异步处理优化,替代传统回调

痛点:uni-app x 中部分原生接口仍支持回调写法,嵌套层级多,代码可读性差,容易出现“回调地狱”。

技巧:利用 async/await 封装回调式接口,同时结合 try/catch 统一处理异常,简化代码逻辑。

// src/utils/uniAsync.uts
// 封装uni.getSystemInfo回调接口为Promise形式
export function getSystemInfoAsync(): Promise<UniApp.GetSystemInfoResult> {
  return new Promise((resolve, reject) => {
    uni.getSystemInfo({
      success: (res) => resolve(res),
      fail: (err) => reject(err)
    });
  });
}

// 封装uni.showActionSheet接口
export function showActionSheetAsync(options: UniApp.ShowActionSheetOptions): Promise<UniApp.ShowActionSheetSuccessCallbackResult> {
  return new Promise((resolve, reject) => {
    uni.showActionSheet({
      ...options,
      success: resolve,
      fail: reject
    });
  });
}

// 实战使用(页面中)
import { getSystemInfoAsync } from '@/utils/uniAsync.uts';

onLoad(async () => {
  try {
    // 异步获取系统信息,无需嵌套回调
    const systemInfo = await getSystemInfoAsync();
    console.log('系统信息:', systemInfo);
    // 后续逻辑(如根据系统类型适配布局)
    if (systemInfo.platform === 'android') {
      // 安卓端适配
    } else if (systemInfo.platform === 'ios') {
      // iOS端适配
    }
  } catch (err) {
    // 统一异常处理
    console.error('获取系统信息失败:', err);
    uni.showToast({ title: '获取系统信息失败', icon: 'none' });
  }
})

延伸:可将常用的回调式接口(如 uni.chooseImage、uni.getStorage)全部封装为 Promise 形式,放在 utils 目录下,全局复用,大幅提升开发效率。

二、原生能力调用技巧:简化配置,发挥 uni-app x 核心优势

uni-app x 最大的优势的是“一次编码,多端原生渲染”,但很多开发者在调用原生能力时,仍会出现配置繁琐、调用失败等问题,以下技巧可快速解决。

技巧3: 原生组件按需注册,减少包体积

痛点:uni-app x 支持全局注册原生组件,但全局注册过多组件会导致包体积增大,影响应用启动速度。

技巧:采用“局部注册”模式,只在需要使用组件的页面/组件中注册,同时利用 easycom 自动注册特性,简化配置(无需手动引入组件)。

<!-- 页面组件:pages/index/index.vue --&gt;
&lt;template&gt;
  &lt;view&gt;
    <!-- 直接使用组件,无需手动import和components注册 -->
    <uni-button type="primary" @click="handleClick">原生按钮</uni-button>
    <uni-list>
      <uni-list-item title="列表项1"></uni-list-item>
    </uni-list>
  </view>
</template>

<script setup lang="uts">
// 无需手动注册uni-button、uni-list(easycom自动注册)
const handleClick = () => {
  uni.showToast({ title: '点击成功' });
};
</script>

// 配置说明(pages.json)
{
  "easycom": {
    // 开启easycom自动注册(默认开启)
    "autoscan": true,
    // 自定义组件匹配规则(可选,默认适配uni-ui组件)
    "custom": {
      "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
    }
  },
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "首页"
      }
    }
  ]
}

注意:easycom 仅支持符合“组件名-组件文件名”规范的组件,自定义组件建议遵循此规范;对于不常用的原生组件,优先局部注册,避免全局注册占用资源。

技巧4: 原生模块调用避坑,解决跨端兼容问题

痛点:uni-app x 调用原生模块(如地图、支付)时,容易出现“某一端正常、某一端失效”的问题,尤其是小程序和App端的差异。

技巧:调用原生模块前,先判断当前运行环境,根据环境差异处理逻辑;同时利用 uni.getSystemInfoSync() 快速获取环境信息,简化判断。

// src/utils/env.uts
// 封装环境判断工具函数(全局复用)
export const envUtil = {
  // 判断是否为App端
  isApp(): boolean {
    const systemInfo = uni.getSystemInfoSync();
    return systemInfo.platform === 'android' || systemInfo.platform === 'ios';
  },
  // 判断是否为微信小程序
  isWechatMini(): boolean {
    const systemInfo = uni.getSystemInfoSync();
    return systemInfo.mpPlatform === 'weixin';
  },
  // 判断是否为支付宝小程序
  isAlipayMini(): boolean {
    const systemInfo = uni.getSystemInfoSync();
    return systemInfo.mpPlatform === 'alipay';
  },
  // 判断是否为H5端
  isH5(): boolean {
    const systemInfo = uni.getSystemInfoSync();
    return systemInfo.platform === 'h5';
  }
};

// 实战使用(调用地图模块)
import { envUtil } from '@/utils/env.uts';

async function openMap(latitude: number, longitude: number) {
  try {
    // 根据环境差异处理地图调用逻辑
    if (envUtil.isApp()) {
      // App端:调用原生地图模块
      await uni.openLocation({
        latitude,
        longitude,
        name: '目标位置',
        address: '详细地址'
      });
    } else if (envUtil.isWechatMini()) {
      // 微信小程序:判断是否有权限
      const auth = await uni.getSetting();
      if (!auth.authSetting['scope.userLocation']) {
        // 无权限,请求授权
        await uni.authorize({ scope: 'scope.userLocation' });
      }
      // 调用微信小程序地图
      await uni.openLocation({ latitude, longitude, name: '目标位置' });
    } else if (envUtil.isH5()) {
      // H5端:跳转百度/高德地图网页版
      const url = `https://api.map.baidu.com/marker?location=${latitude},${longitude}&title=目标位置&output=html`;
      window.open(url, '_blank');
    }
  } catch (err) {
    console.error('打开地图失败:', err);
    uni.showToast({ title: '打开地图失败,请检查权限', icon: 'none' });
  }
}

关键:原生模块的跨端差异主要集中在“权限配置”和“接口参数”上,提前判断环境,针对性处理,能避免80%的跨端兼容问题;同时,App端需在 manifest.json 中配置对应模块权限(如地图、定位)。

三、性能优化技巧:从启动到运行,全方位提速

性能是跨端应用的核心竞争力,uni-app x 虽然原生渲染性能优异,但不合理的开发方式仍会导致卡顿、启动慢等问题,以下技巧可全方位优化性能。

技巧5: 页面渲染优化,减少卡顿(核心重点)

痛点:列表渲染、频繁数据更新时,容易出现页面卡顿、掉帧,尤其是大数据列表场景。

技巧:结合 vue3 的响应式优化和 uni-app x 的渲染特性,做好3点:1. 列表渲染用 v-for + key(唯一值);2. 大数据列表用分页加载+虚拟列表;3. 避免频繁修改响应式数据。

<!-- 大数据列表优化:虚拟列表 + 分页加载 -->
<template>
  <view>
    <!-- 虚拟列表:只渲染可视区域内的列表项,减少DOM节点 -->
    <uni-virtual-list 
      :height="500" 
      :item-height="80" 
      :items="listData"
      key="id"
    >
      <template #item="{ item }">
        <view class="list-item">
          <image :src="item.avatar" class="avatar"></image>
          <view class="info">
            <text class="name">{{ item.username }}</text>
            <text class="desc">{{ item.desc }}</text>
          </view>
        </view>
      </template>
    </uni-virtual-list><!-- 加载中/无数据提示 -->
    <view v-if="loading" class="loading">加载中...</view>
    <view v-if="!loading && listData.length === 0" class="no-data">暂无数据</view>
  </view>
</template>

<script setup lang="uts">
import { ref, onLoad } from 'vue';
import { PageParams, UserInfo, ApiResponse } from '@/typings/common.uts';

const listData = ref<UserInfo[]>([]);
const loading = ref(false);
const pageNum = ref(1);
const pageSize = ref(10);
const hasMore = ref(true); // 是否还有更多数据

// 分页加载数据
const loadData = async () => {
  if (loading.value || !hasMore.value) return;
  loading.value = true;
  
  const params: PageParams = {
    pageNum: pageNum.value,
    pageSize: pageSize.value
  };
  
  try {
    const res: ApiResponse<UserInfo[]> = await getUserList(params);
    if (res.code === 200) {
      const newData = res.data || [];
      // 避免直接修改listData,用concat拼接(减少响应式更新次数)
      listData.value = listData.value.concat(newData);
      // 判断是否还有更多数据
      hasMore.value = newData.length === pageSize.value;
      pageNum.value++;
    }
  } catch (err) {
    console.error('加载数据失败:', err);
  } finally {
    loading.value = false;
  }
};

// 页面加载时初始化数据
onLoad(() => {
  loadData();
});

// 下拉刷新(页面配置enablePullDownRefresh: true)
onPullDownRefresh(() => {
  pageNum.value = 1;
  listData.value = [];
  hasMore.value = true;
  loadData().then(() => {
    uni.stopPullDownRefresh(); // 停止下拉刷新
  });
});

// 上拉加载(页面配置onReachBottomDistance: 50)
onReachBottom(() => {
  loadData();
});
</script>

<style scoped>
/* 样式省略,注意避免使用复杂选择器,减少渲染开销 */
</style>

补充:虚拟列表可使用 uni-ui 的 uni-virtual-list 组件,无需自己封装;列表渲染时,key 必须是唯一值(如 id),避免使用 index 作为 key,否则会导致渲染错乱;频繁更新的数据(如倒计时),可使用 vue3 的 shallowRef 替代 ref,减少响应式监听开销。

技巧6: 包体积优化,提升启动速度

痛点:App端包体积过大,导致下载慢、启动慢;小程序端包体积超过限制,无法上线。

技巧:从“资源、代码、依赖”三个维度优化,核心技巧如下:

  1. 资源优化:图片压缩(使用 tinypng 压缩)、图标使用 iconfont 替代本地图片、大文件(如视频、音频)采用在线加载,不打包进应用;

  2. 代码优化:删除无用代码(dead code)、合并重复代码、使用 tree-shaking 剔除未使用的依赖(uni-app x 打包时默认开启);

  3. 依赖优化:减少不必要的第三方依赖,优先使用 uni-app x 原生能力替代第三方插件(如日期处理用原生 Date,无需引入 moment.js);

  4. 分包加载:小程序端开启分包加载,将不常用的页面放在分包中,减少主包体积;App端可开启按需加载,提升启动速度。

// 小程序分包配置(pages.json)
{
  "pages": [
    // 主包页面(常用页面)
    { "path": "pages/index/index", "style": {} },
    { "path": "pages/login/login", "style": {} }
  ],
  // 分包配置(不常用页面,如个人中心、设置)
  "subPackages": [
    {
      "root": "pages/subpackage/", // 分包根目录
      "pages": [
        { "path": "my/my", "style": {} },
        { "path": "setting/setting", "style": {} }
      ]
    }
  ],
  // 预加载分包(进入主包页面时,预加载分包,提升体验)
  "preloadRule": {
    "pages/index/index": {
      "network": "all", // 所有网络环境都预加载
      "packages": ["pages/subpackage"]
    }
  }
}

四、避坑与实战技巧:解决开发中最常见的问题

结合大量 uni-app x 实战经验,整理了4个最常见的坑,以及对应的解决方案,帮你少走弯路。

技巧7: 页面跳转传参避坑,解决参数丢失、类型异常

痛点:页面跳转时,传递对象、数组等复杂参数,容易出现参数丢失、类型转换异常(如数字变为字符串)。

技巧:传递复杂参数时,先将参数转为 JSON 字符串,接收时再解析;传递简单参数(如 id、name),可直接拼接在路径后,注意参数类型转换。

// 跳转页面,传递复杂参数(如用户信息对象)
// 页面A:pages/index/index.uts
function goToDetail(user: UserInfo) {
  // 复杂参数转为JSON字符串
  const userStr = JSON.stringify(user);
  // 跳转页面,携带参数
  uni.navigateTo({
    url: `/pages/detail/detail?user=${encodeURIComponent(userStr)}` // 编码,避免特殊字符报错
  });
}

// 接收参数
// 页面B:pages/detail/detail.uts
import { onLoad } from 'vue';
import { UserInfo } from '@/typings/common.uts';

onLoad((options) => {
  // 接收参数,解码后解析为对象
  const userStr = decodeURIComponent(options.user as string);
  const user = JSON.parse(userStr) as UserInfo;
  console.log('接收的用户信息:', user);
  
  // 简单参数接收(如id)
  const id = Number(options.id); // 转为数字类型,避免字符串类型异常
});

注意:传递参数时,若参数包含特殊字符(如中文、&、=),需用 encodeURIComponent 编码,接收时用 decodeURIComponent 解码;JSON 字符串解析时,需做好异常捕获,避免参数格式错误导致报错。

技巧8: 本地存储优化,避免存储异常、数据泄露

痛点:使用 uni.setStorage 存储数据时,容易出现数据覆盖、存储容量超限、敏感数据泄露等问题。

技巧:封装统一的存储工具类,区分存储类型(临时存储、永久存储),添加存储前缀避免覆盖,敏感数据加密存储。

// src/utils/storage.uts
import { encode, decode } from '@/utils/crypto.uts'; // 假设已封装加密工具

// 存储前缀,避免与其他项目/插件存储的数据冲突
const STORAGE_PREFIX = 'uni_app_x_';

// 封装存储工具类
export const storageUtil = {
  // 永久存储(uni.setStorage)
  set(key: string, value: any, isEncrypt = false) {
    try {
      const realKey = STORAGE_PREFIX + key;
      let realValue = value;
      // 敏感数据加密存储(如token、用户密码)
      if (isEncrypt) {
        realValue = encode(JSON.stringify(realValue));
      } else {
        realValue = JSON.stringify(realValue);
      }
      uni.setStorage({ key: realKey, data: realValue });
    } catch (err) {
      console.error('存储失败:', err);
      // 存储容量超限处理
      uni.showToast({ title: '存储空间不足,请清理缓存', icon: 'none' });
    }
  },

  // 永久存储获取
  get(key: string, isEncrypt = false): any {
    try {
      const realKey = STORAGE_PREFIX + key;
      const value = uni.getStorageSync(realKey);
      if (!value) return null;
      // 敏感数据解密
      if (isEncrypt) {
        return JSON.parse(decode(value));
      } else {
        return JSON.parse(value);
      }
    } catch (err) {
      console.error('获取存储失败:', err);
      return null;
    }
  },

  // 临时存储(uni.setStorageSync,页面关闭后失效)
  setTemp(key: string, value: any) {
    try {
      const realKey = STORAGE_PREFIX + key;
      uni.setStorageSync(realKey, JSON.stringify(value));
    } catch (err) {
      console.error('临时存储失败:', err);
    }
  },

  // 临时存储获取
  getTemp(key: string): any {
    try {
      const realKey = STORAGE_PREFIX + key;
      const value = uni.getStorageSync(realKey);
      return value ? JSON.parse(value) : null;
    } catch (err) {
      console.error('获取临时存储失败:', err);
      return null;
    }
  },

  // 删除指定存储
  remove(key: string) {
    const realKey = STORAGE_PREFIX + key;
    uni.removeStorage({ key: realKey });
  },

  // 清空所有存储(谨慎使用)
  clear() {
    uni.clearStorage();
  }
};

// 实战使用
// 存储普通数据(如用户信息,不加密)
storageUtil.set('userInfo', { id: '123', username: 'test' });

// 存储敏感数据(如token,加密)
storageUtil.set('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', true);

// 获取数据
const userInfo = storageUtil.get('userInfo');
const token = storageUtil.get('token', true);

五、总结

uni-app x 作为新一代跨端开发框架,凭借 uts 语法、原生渲染、多端兼容等优势,成为前端开发者的优选工具。本文整理的 8 个实战编程技巧,涵盖 uts 语法、原生能力调用、性能优化、避坑指南等核心维度,每个技巧均配套完整代码示例,可直接落地复用,帮助你提升开发效率、解决实际开发问题。

后续将持续分享 uni-app x 进阶技巧(如插件开发、打包部署、原生插件集成),欢迎关注,一起成长

JavaScript Proxy与Reflect

在 JavaScript 中如何拦截对象的读取、修改、删除操作?Vue3的响应式系统如何实现?本文将深入讲解 JavaScript 的元编程世界,探索 Proxy 和 Reflect 的奥秘。

前言:从Vue3的响应式说起

// Vue3的响应式数据实现原理
const reactive = (target) => {
    return new Proxy(target, {
        get(target, key, receiver) {
            track(target, key); // 依赖收集
            return Reflect.get(target, key, receiver);
        },
        set(target, key, value, receiver) {
            const result = Reflect.set(target, key, value, receiver);
            trigger(target, key); // 触发更新
            return result;
        }
    });
};

Vue3的响应式数据实现原理的背后,就是 Proxy 和 Reflect 。理解它们,我们就能真正掌握 JavaScript 元编程的能力。

理解Proxy与Reflect

什么是Proxy(代理)?

Proxy 对象用于创建一个对象的代理,从而实现对基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

Proxy工作流程示意图


┌─────────┐   操作请求   ┌─────────┐   转发操作    ┌─────────┐
│ 客户端   │───────────→ │  Proxy  │───────────→ │ 目标对象 │
│         │             │  代理    │             │         │
│         │←─────────── │         │←─────────── │         │
└─────────┘   响应结果   └─────────┘   实际结果    └─────────┘
                ↓                           ↓
         ┌─────────────┐            ┌─────────────┐
         │ 拦截和自定义  │            │ 原始行为     │
         │   (陷阱)   │            │             │
         └─────────────┘            └─────────────┘

什么是Reflect(反射)?

Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与 Proxy 的陷阱方法一一对应。

为什么需要Reflect?

  1. 统一的操作API
  2. 更好的错误处理(返回布尔值而非抛出异常)
  3. 与Proxy陷阱方法一一对应

代理基础:创建第一个代理

// 目标对象
const target = {
  name: '张三',
  age: 30
};

// 处理器对象(包含陷阱方法)
const handler = {
  // 拦截属性读取
  get(target, property, receiver) {

    // 添加自定义逻辑
    if (property === 'age') {
      return `${target[property]}岁`;
    }

    // 默认行为:使用Reflect
    return Reflect.get(target, property, receiver);
  },

  // 拦截属性设置
  set(target, property, value, receiver) {
    // 添加验证逻辑
    if (property === 'age' && (value < 0 || value > 150)) {
      console.warn('年龄必须在0-150之间');
      return false; // 返回false表示设置失败
    }

    // 默认行为:使用Reflect
    return Reflect.set(target, property, value, receiver);
  }
};

// 创建代理
const proxy = new Proxy(target, handler);

console.log(proxy.name);  // 张三
console.log(proxy.age);   // 30岁
console.log(target.age); // 30

13种可拦截的陷阱方法

完整陷阱方法概览

const completeHandler = {
  // 1. 属性访问拦截
  get(target, prop, receiver) {
    console.log(`get: ${prop}`);
    return Reflect.get(target, prop, receiver);
  },

  // 2. 属性赋值拦截
  set(target, prop, value, receiver) {
    console.log(`set: ${prop} = ${value}`);
    return Reflect.set(target, prop, value, receiver);
  },

  // 3. in操作符拦截
  has(target, prop) {
    console.log(`has: ${prop}`);
    return Reflect.has(target, prop);
  },

  // 4. delete操作符拦截
  deleteProperty(target, prop) {
    console.log(`delete: ${prop}`);
    return Reflect.deleteProperty(target, prop);
  },

  // 5. 构造函数调用拦截
  construct(target, args, newTarget) {
    console.log(`construct with args:`, args);
    return Reflect.construct(target, args, newTarget);
  },

  // 6. 函数调用拦截
  apply(target, thisArg, args) {
    console.log(`apply: thisArg=`, thisArg, 'args=', args);
    return Reflect.apply(target, thisArg, args);
  },

  // 7. Object.getOwnPropertyDescriptor拦截
  getOwnPropertyDescriptor(target, prop) {
    console.log(`getOwnPropertyDescriptor: ${prop}`);
    return Reflect.getOwnPropertyDescriptor(target, prop);
  },

  // 8. Object.defineProperty拦截
  defineProperty(target, prop, descriptor) {
    console.log(`defineProperty: ${prop}`, descriptor);
    return Reflect.defineProperty(target, prop, descriptor);
  },

  // 9. 原型访问拦截
  getPrototypeOf(target) {
    console.log('getPrototypeOf');
    return Reflect.getPrototypeOf(target);
  },

  // 10. 原型设置拦截
  setPrototypeOf(target, prototype) {
    console.log(`setPrototypeOf:`, prototype);
    return Reflect.setPrototypeOf(target, prototype);
  },

  // 11. 可扩展性判断拦截
  isExtensible(target) {
    console.log('isExtensible');
    return Reflect.isExtensible(target);
  },

  // 12. 防止扩展拦截
  preventExtensions(target) {
    console.log('preventExtensions');
    return Reflect.preventExtensions(target);
  },

  // 13. 自身属性枚举拦截
  ownKeys(target) {
    console.log('ownKeys');
    return Reflect.ownKeys(target);
  }
};

陷阱方法详细解析

1. get陷阱:属性访问拦截

get() 会在获取属性值的操作中被调用:

const getHandler = {
  get(target, property, receiver) {
    // 处理不存在的属性
    if (!(property in target)) {
      throw new Error(`属性不存在: ${property}`);
    }

    // 处理私有属性(约定以_开头的属性为私有)
    if (property.startsWith('_')) {
      throw new Error(`不能访问私有属性: ${property}`);
    }
    return Reflect.get(target, property, receiver);
  }
};
  • 参数说明:
    1. target:目标对象
    2. property:引用的目标对象上的属性
    3. receiver:代理对象
  • 返回值说明:返回值无限制

2. set陷阱:属性赋值拦截

set() 会在设置属性值的操作中被调用:

const setHandler = {
  set(target, property, value, receiver) {
    // 只读属性检查(约定以$开头的属性为只读)
    if (property.startsWith('$')) {
      throw new Error(`属性不可修改: ${property}`);
    }
    // 执行设置
    return Reflect.set(target, property, value, receiver);
  }
};
  • 参数说明:
    1. target:目标对象
    2. property:引用的目标对象上的属性
    3. value:要给属性设置的值
    4. receiver:代理对象
  • 返回值说明:返回 true 表示成功;返回false表示失败。

3. has陷阱:in操作符拦截

has() 会在 in 操作符中被调用:

const hasHandler = {
  has(target, property) {
    // 隐藏某些属性(不在in操作中暴露)
    const hiddenProperties = ['password', 'token', '_internal'];
    if (hiddenProperties.includes(property)) {
      console.log(`隐藏属性: ${property}`);
      return false;
    }

    // 虚拟属性(不存在但返回true)
    const virtualProperties = ['isAdmin', 'hasAccess'];
    if (virtualProperties.includes(property)) {
      console.log(`虚拟属性: ${property}`);
      return true;
    }

    return Reflect.has(target, property);
  }
};
  • 参数说明:
    1. target:目标对象
    2. value:要给属性设置的值
  • 返回值说明:返回布尔值表示属性是否存在,返回非布尔型会被转成布尔型。

注:Object.keys() 不受 has() 影响,仍会返回所有属性。

4. apply陷阱:函数调用拦截

apply() 会在调用函数时被调用:

const applyHandler = {
  apply(target, thisArg, argumentsList) {
    // 参数验证
    if (target.name === 'calculate') {
      if (argumentsList.length < 2) {
        throw new Error('calculate函数需要至少2个参数');
      }
      if (!argumentsList.every(arg => typeof arg === 'number')) {
        throw new Error('calculate函数的所有参数必须是数字');
      }
    }
    return Reflect.apply(target, thisArg, argumentsList);
  }
};
  • 参数说明:
    1. target:目标对象
    2. thisArg:调用函数时的this参数
    3. argumentsList:调用函数时的参数列表
  • 返回值说明:返回值无限制

5. construct陷阱:构造函数拦截

construct() 会在调用函数时被调用:

const constructHandler = {
  construct(target, argumentsList, newTarget) {
    // 单例模式:确保只有一个实例
    if (target._instance) {
      console.log('返回现有实例');
      return target._instance;
    }

    // 创建实例
    const instance = Reflect.construct(target, argumentsList, newTarget);

    // 为单例保存实例
    if (target.name === 'Singleton') {
      target._instance = instance;
    }

    // 为实例添加额外属性
    instance.createdAt = new Date();
    instance._id = Math.random().toString(36).substr(2, 9);

    console.log('创建新实例,ID:', instance._id);
    return instance;
  }
};
  • 参数说明:
    1. target:目标构造函数
    2. argumentsList:传给目标构造函数的参数列表
    3. newTarget:最初被调用的构造函数
  • 返回值说明:必须返回一个对象

6. ownKeys陷阱:影响Object.keys()等遍历

ownKeys() 会在Object.keys()及类似方法中被调用:

const ownKeysHandler = {
  ownKeys(target) {
    // 过滤掉以_开头的私有属性
    const keys = Reflect.ownKeys(target);
    return keys.filter(key => {
      if (typeof key === 'string') {
        return !key.startsWith('_');
      }
      return true;
    });
  }
};
  • 参数说明:
    1. target:目标构造函数
  • 返回值说明:必须返回一个包含字符串或符号的可枚举对象

7. getOwnPropertyDescriptor陷阱:影响Object.getOwnPropertyDescriptor()

getOwnPropertyDescriptor() 会在Object.getOwnPropertyDescriptor()方法中被调用:

const getOwnPropertyDescriptorHandler = {
  getOwnPropertyDescriptor(target, prop) {
    // 隐藏私有属性的描述符
    if (prop.startsWith('_')) {
      return undefined;
    }
    return Reflect.getOwnPropertyDescriptor(target, prop);
  }
};
  • 参数说明:
    1. target:目标构造函数
    2. prop:引用的目标对象上的字符串属性
  • 返回值说明:必须返回对象,或在属性不存在时返回 undefined

8. defineProperty陷阱:拦截Object.defineProperty()

defineProperty() 会在Object.defineProperty()方法中被调用:

const definePropertyHandler = {
  defineProperty(target, prop, descriptor) {
    // 防止修改只读属性
    if (prop === 'id' && target.id) {
      console.warn('id属性是只读的');
      return false;
    }
    return Reflect.defineProperty(target, prop, descriptor);
  }
};
  • 参数说明:
    1. target:目标构造函数
    2. prop:引用的目标对象上的字符串属性
    3. descriptor:包含可选的enumrable、configurable、value、get 和 set 等定义的对象
  • 返回值说明:必须返回布尔值,表示属性是否成功定义

9. deleteProperty陷阱:拦截delete操作

deleteProperty() 会在 delete 操作中被调用:

const deletePropertyHandler = {
  deleteProperty(target, prop) {
    // 防止删除重要属性
    const protectedProps = ['id', 'createdAt'];
    if (protectedProps.includes(prop)) {
      console.warn(`不能删除受保护的属性: ${prop}`);
      return false;
    }
    return Reflect.deleteProperty(target, prop);
  }
};
  • 参数说明:
    1. target:目标构造函数
    2. property:引用的目标对象上的属性
  • 返回值说明:必须返回布尔值,表示属性是否被成功删除

10. preventExtensions陷阱:拦截Object.preventExtensions()

preventExtensions() 会在 Object.preventExtensions() 方法中被调用:

const preventExtensionsHandler = {
  preventExtensions(target) {
    // 在阻止扩展前添加标记
    target._frozenAt = new Date();
    return Reflect.preventExtensions(target);
  }
};
  • 参数说明:
    1. target:目标构造函数
  • 返回值说明:必须返回布尔值,表示 target 是否已经不可扩展

11. getPrototypeOf陷阱:拦截Object.getPrototypeOf()

getPrototypeOf() 会在 Object.getPrototypeOf() 方法中被调用:

const getPrototypeOfHandler = {
  getPrototypeOf(target) {
    return Reflect.getPrototypeOf(target);
  }
};
  • 参数说明:
    1. target:目标构造函数
  • 返回值说明:必须返回对象或null

12. setPrototypeOf陷阱:拦截Object.setPrototypeOf()

setPrototypeOf() 会在 Object.setPrototypeOf() 方法中被调用:

const setPrototypeOfHandler = {
  setPrototypeOf(target, prototype) {
    return Reflect.setPrototypeOf(target, prototype);
  }
};
  • 参数说明:
    1. target:目标构造函数
    2. property:引用的目标对象上的属性
  • 返回值说明:必须返回布尔值,表示原型赋值是否成功

13. isExtensible陷阱:拦截Object.isExtensible()

isExtensible() 会在 Object.isExtensible() 方法中被调用:

const isExtensibleHandler = {
  isExtensible(targete) {
    return Reflect.isExtensible(targete);
  }
};
  • 参数说明:
    1. target:目标构造函数
  • 返回值说明:必须返回布尔值,表示 target 是否可扩展

可撤销代理

什么是可撤销代理?

可撤销代理(Revocable Proxy)是一种特殊的代理,它提供了一个 revoke() 方法,调用该方法后,代理将不再可用,所有对代理的操作都会抛出 TypeError,即:会中断代理对象和目标对象之间的联系。

const target = { name: 'zhangsan' };
const handler = {
  get() {
    return 'foo';
  }
}
// 创建可撤销代理
const { proxy, revoke } = Proxy.revocable(target, handler);

console.log(proxy.name); // 'zhangsan'

// 撤销代理
revoke();

// 代理已失效
try {
  console.log(proxy.name); // TypeError: Cannot perform 'get' on a proxy that has been revoked
} catch (error) {
  console.log('错误:', error.message);
}

实现响应式数据(Vue3原理)

// 简易版Vue3响应式系统
class ReactiveSystem {
  constructor() {
    this.targetMap = new WeakMap(); // 存储依赖关系
    this.effectStack = [];          // 当前正在执行的effect
    this.batchQueue = new Set();    // 批量更新队列
    this.isBatching = false;        // 是否处于批量更新模式
  }

  // 核心:创建响应式对象
  reactive(target) {
    return new Proxy(target, {
      get(target, key, receiver) {
        // 获取原始值
        const result = Reflect.get(target, key, receiver);

        // 依赖收集
        track(target, key);

        // 深度响应式(如果值是对象,继续代理)
        if (result && typeof result === 'object') {
          return reactive(result);
        }

        return result;
      },

      set(target, key, value, receiver) {
        // 获取旧值
        const oldValue = target[key];

        // 设置新值
        const result = Reflect.set(target, key, value, receiver);

        // 触发更新(值确实改变时才触发)
        if (oldValue !== value) {
          trigger(target, key);
        }

        return result;
      },

      // 处理删除操作
      deleteProperty(target, key) {
        const hasKey = key in target;
        const result = Reflect.deleteProperty(target, key);

        if (hasKey && result) {
          trigger(target, key);
        }

        return result;
      }
    });
  }

  // 依赖收集
  track(target, key) {
    if (this.effectStack.length === 0) return;

    let depsMap = this.targetMap.get(target);
    if (!depsMap) {
      depsMap = new Map();
      this.targetMap.set(target, depsMap);
    }

    let dep = depsMap.get(key);
    if (!dep) {
      dep = new Set();
      depsMap.set(key, dep);
    }

    // 收集当前正在执行的effect
    const activeEffect = this.effectStack[this.effectStack.length - 1];
    if (activeEffect) {
      dep.add(activeEffect);
      // effect也需要知道哪些依赖收集了它
      activeEffect.deps.push(dep);
    }
  }

  // 触发更新
  trigger(target, key) {
    const depsMap = this.targetMap.get(target);
    if (!depsMap) return;

    const dep = depsMap.get(key);
    if (dep) {
      // 如果是批量更新模式,加入队列
      if (this.isBatching) {
        dep.forEach(effect => this.batchQueue.add(effect));
      } else {
        // 立即执行所有依赖的effect
        dep.forEach(effect => {
          if (effect !== this.effectStack[this.effectStack.length - 1]) {
            effect.run();
          }
        });
      }
    }
  }

  // 创建effect(副作用)
  effect(fn) {
    const effect = new ReactiveEffect(fn, this);
    effect.run();

    // 返回停止函数
    const runner = effect.run.bind(effect);
    runner.effect = effect;
    runner.stop = () => effect.stop();

    return runner;
  }

  // 开始批量更新
  batchStart() {
    this.isBatching = true;
  }

  // 结束批量更新
  batchEnd() {
    this.isBatching = false;

    // 执行队列中的所有effect
    this.batchQueue.forEach(effect => {
      if (effect !== this.effectStack[this.effectStack.length - 1]) {
        effect.run();
      }
    });

    this.batchQueue.clear();
  }

  // computed计算属性
  computed(getter) {
    let value;
    let dirty = true;

    const runner = this.effect(() => {
      value = getter();
      dirty = false;
    });

    return {
      get value() {
        if (dirty) {
          runner();
        }
        return value;
      }
    };
  }

  // watch侦听器
  watch(source, callback, options = {}) {
    let getter;

    if (typeof source === 'function') {
      getter = source;
    } else {
      getter = () => this.traverse(source);
    }

    let oldValue;
    const job = () => {
      const newValue = runner();
      callback(newValue, oldValue);
      oldValue = newValue;
    };

    const runner = this.effect(getter, {
      lazy: true,
      scheduler: job
    });

    oldValue = runner();

    if (options.immediate) {
      job();
    }
  }

  // 深度遍历对象(用于watch)
  traverse(value, seen = new Set()) {
    if (typeof value !== 'object' || value === null || seen.has(value)) {
      return value;
    }

    seen.add(value);

    for (const key in value) {
      this.traverse(value[key], seen);
    }

    return value;
  }
}

// 响应式effect类
class ReactiveEffect {
  constructor(fn, scheduler) {
    this.fn = fn;
    this.scheduler = scheduler;
    this.deps = [];
    this.active = true;
  }

  run() {
    if (!this.active) return;

    try {
      this.scheduler.effectStack.push(this);
      return this.fn();
    } finally {
      this.scheduler.effectStack.pop();
    }
  }

  stop() {
    if (this.active) {
      // 从所有依赖中移除自己
      this.deps.forEach(dep => dep.delete(this));
      this.deps.length = 0;
      this.active = false;
    }
  }
}

实现不可变数据(immer.js原理)

// 简易版immer实现
class Immer {
  constructor(baseState) {
    this.baseState = baseState;
    this.draft = this.createDraft(baseState);
    this.isModified = false;
  }

  // 创建草稿(代理)
  createDraft(base) {
    // 如果不是对象,直接返回
    if (typeof base !== 'object' || base === null) {
      return base;
    }

    // 处理数组
    if (Array.isArray(base)) {
      return this.createArrayDraft(base);
    }

    // 处理对象
    return this.createObjectDraft(base);
  }

  // 创建对象草稿
  createObjectDraft(base) {
    const draft = Array.isArray(base) ? [] : {};

    // 复制所有属性
    for (const key in base) {
      if (base.hasOwnProperty(key)) {
        draft[key] = this.createDraft(base[key]);
      }
    }

    // 创建代理
    return new Proxy(draft, {
      get: (target, prop, receiver) => {
        // 特殊属性处理
        if (prop === '__immer_draft__') return true;
        if (prop === '__immer_original__') return base;

        return Reflect.get(target, prop, receiver);
      },

      set: (target, prop, value, receiver) => {
        this.isModified = true;

        // 如果新值是普通值,直接设置
        if (typeof value !== 'object' || value === null) {
          return Reflect.set(target, prop, value, receiver);
        }

        // 如果新值是草稿,获取其最终状态
        if (value.__immer_draft__) {
          value = this.finalize(value);
        }

        // 如果新值是对象,创建新的草稿
        const newDraft = this.createDraft(value);
        return Reflect.set(target, prop, newDraft, receiver);
      },

      deleteProperty: (target, prop) => {
        this.isModified = true;
        return Reflect.deleteProperty(target, prop);
      }
    });
  }

  // 创建数组草稿(需要特殊处理)
  createArrayDraft(base) {
    const draft = this.createObjectDraft(base);

    // 代理数组方法
    const arrayMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];

    arrayMethods.forEach(method => {
      const originalMethod = Array.prototype[method];

      draft[method] = function (...args) {
        this.isModified = true;
        return originalMethod.apply(this, args);
      }.bind({ isModified: false }); // 这里简化处理
    });

    return draft;
  }

  // 获取草稿
  getDraft() {
    return this.draft;
  }

  // 完成修改,生成新状态
  produce(producer) {
    // 执行生产者函数
    producer(this.draft);

    // 如果没有修改,返回原状态
    if (!this.isModified) {
      return this.baseState;
    }

    // 最终化草稿,生成新状态
    return this.finalize(this.draft);
  }

  // 最终化草稿(将草稿转换为普通对象)
  finalize(draft) {
    // 如果不是草稿,直接返回
    if (!draft || !draft.__immer_draft__) {
      return draft;
    }

    const original = draft.__immer_original__;
    const result = Array.isArray(original) ? [] : {};
    let hasChanges = false;

    // 收集所有键(包括原对象和新对象)
    const allKeys = new Set([
      ...Object.keys(original || {}),
      ...Object.keys(draft)
    ]);

    for (const key of allKeys) {
      const originalValue = original ? original[key] : undefined;
      const draftValue = draft[key];

      // 如果属性被删除
      if (!(key in draft)) {
        hasChanges = true;
        continue;
      }

      // 如果值是草稿,递归最终化
      if (draftValue && draftValue.__immer_draft__) {
        const finalized = this.finalize(draftValue);
        if (finalized !== originalValue) {
          hasChanges = true;
          result[key] = finalized;
        } else {
          result[key] = originalValue;
        }
      }
      // 如果值被修改
      else if (draftValue !== originalValue) {
        hasChanges = true;
        result[key] = draftValue;
      }
      // 值未修改
      else {
        result[key] = originalValue;
      }
    }

    return hasChanges ? result : original;
  }
}

// 简化API
function produce(baseState, producer) {
  const immer = new Immer(baseState);
  return immer.produce(producer);
}

结语

Proxy和Reflect开启了JavaScript元编程的新篇章,它们不仅是框架开发的利器,也是理解现代JavaScript运行时特性的关键。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

使用 oxlint + oxfmt 替换 ESLint + Prettier

最近我将自己的 Nest 项目中的 ESLint 和 Prettier 替换为 oxlint 和 oxfmt,至今运行良好。最明显的改善是速度:原先完整的 lint 检查需要 10 多秒,现在仅需 1 秒左右;保存文件时的格式化也几乎无感延迟。今天就来分享一下为什么要做这个替换,以及如何平滑迁移。

什么是 oxlint 和 oxfmt

两者都属于 oxc 项目(一个用 Rust 编写的高性能 JavaScript/TypeScript 工具链)。

  • oxlint 对标 ESLint,用于代码质量检查。
  • oxfmt 对标 Prettier,用于代码自动格式化。

目前它们已实现绝大多数常用规则与配置选项,兼容性良好,完全能满足日常开发需求。

由于采用 Rust 编写,它们在性能上具有数量级优势,同时保持了与现有配置相似的迁移体验。

为什么选择它们?

  1. 极致的速度:相比 ESLint,oxlint 在大型项目中通常快 50 倍以上;oxfmt 也远快于 Prettier,尤其在实时格式化时感知明显。
  2. 高度兼容:支持常见的 ESLint 规则(包括 TypeScript)和 Prettier 配置方式,迁移成本低。
  3. 迁移简单:配置结构与原有工具相似,大部分规则名称一致,只需少量调整即可接入。
  4. 专注替代:相比于一体化工具链(如 Biome),oxlint/oxfmt 更专注于直接替代 ESLint/Prettier,对现有项目改造更友好。

如果你正在寻找更快的代码检查与格式化方案,且不希望大幅改动配置,oxlint + oxfmt 是一个值得尝试的选择。它们不仅带来了开发体验上的流畅感,也延续了熟悉的配置方式,是目前 Rust 工具链中迁移成本较低的一套方案。

在现有项目中我们如何将eslint + prettier替换为 oxlint 和oxfmt

我这里有一个之前的 nest 项目,使用的是 eslint + prettier的方案,我们一起将它改造一下。具体改造步骤如下:

1. 安装依赖

pnpm add -D oxlint oxfmt

2. 改造脚本

将之前使用 ESLint 和 Prettier 的脚本改成使用 oxlint 和 oxfmt

{
  "scripts": {
    "lint:check": "oxlint",
    "lint": "oxlint --fix",
    "fmt": "oxfmt",
    "fmt:check": "oxfmt --check",
  },
}

3. 运行迁移脚本

  • Prettier 迁移
pnpm dlx oxfmt --migrate prettier
  • ESLint 迁移
pnpm dlx @oxlint/migrate ./eslint.config.mjs
  • 限制它们的运行范围

这里我不需要它来格式化 drizzledist 中的文件,我们需要在 .oxfmtrc.json.oxlintrc.json 中添加以下代码。你可以根据你要限制的范围来配置

"ignorePatterns": ["drizzle/**", "dist/**/*"]
  • 删除之前的 .prettierrc
  • 删除 eslint.config.mjs 中的 prettier 插件
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; // [!code --]
// 其他内容
eslintPluginPrettierRecommended, // [!code --]
  • 移除 prettier 插件
pnpm remove prettier

4. 添加 vscode 配置

.vscode/extensions.json 中添加 oxc 插件

{
  "recommendations": ["oxc.oxc-vscode",],
}

这是让别人在使用该项目时 vscode 提示该项目需要安装 oxc

.vscode/settings.json 改造

{
  "oxc.fmt.configPath": ".oxfmtrc.json",
  "editor.defaultFormatter": "oxc.oxc-vscode",
  "editor.formatOnSave": true,
  "[javascript]": {
    "editor.defaultFormatter": "oxc.oxc-vscode",
    "editor.formatOnSave": true,
  },
  "[typescript]": {
    "editor.defaultFormatter": "oxc.oxc-vscode",
    "editor.formatOnSave": true,
  },
}

5. 运行脚本检查是否可以正确格式化

两者运行速度对比

CLI → TUI → GUI → Web,交互设计 4 次跃迁

新一代技术出现,上一代技术自然被淘汰。可在真实的工程世界里,事情往往更复杂。

从 CLI 到 TUI,再到 GUI,最终进入 Web 时代,这四次变化既是界面形式的变化,也是软件工程哲学的变化。

我们现在与大模型的交互如此, vibe coding 如何,Agent 也将如此!

一、CLI:把计算机当作语言机器

image.png

CLI(命令行界面):用户通过文本命令与系统沟通,命令既是操作入口,也是抽象接口。每一条命令都像一个小型 API,具备清晰的输入、输出与组合规则。

这种模式天然适合自动化。命令可以被脚本调用,可以被管道连接,可以被版本化管理。

CLI 的世界强调可组合性与可预测性,界面几乎没有视觉负担,所有复杂性都集中在语义层。

CLI 的局限同样明显。它要求用户记忆大量语法与约定,对新手不友好。计算机的能力在快速增长,人们希望通过更直观的方式驾驭这些能力,于是 TUI 出现了。

二、TUI:在文本中引入结构

image.png

TUI(文本用户界面)可以看作是 CLI 的结构化升级。它仍然运行在字符终端里,却通过窗口布局、颜色和控件模拟出更丰富的界面结构。文件管理器、文本编辑器、系统监控工具在 TUI 时代获得了更高的可用性。

TUI 的关键贡献在于把“状态”引入界面。用户不再只面对一条命令,而是面对一个持续存在的交互空间。界面成为状态的可视化载体,操作从“发出指令”转变为“操控环境”。

这种转变降低了使用门槛,同时保留了高效的键盘驱动模式。TUI 在资源受限环境中依然具有极强生命力,因为它在表达力与性能之间找到了平衡。

三、GUI:视觉直觉的胜利

image.png

GUI(图形用户界面)把交互彻底交给视觉系统。窗口、图标、菜单和指针构成了现代桌面计算的基础隐喻。用户通过点击和拖拽完成操作,界面成为一个可视化的工作台。

GUI 的成功来自两个方面。一方面,它极大降低了学习成本,让更多人能够使用计算机。另一方面,它为复杂应用提供了更丰富的表达空间,图像、动画和排版成为信息组织的重要工具。

代价同样存在。GUI 系统需要更强的硬件支持,软件栈变得更厚,状态管理更加复杂。随着应用规模扩大,桌面软件开始面临分发、更新和跨平台兼容的挑战。这些问题为 Web 的崛起创造了条件。

四、Web:统一平台的承诺

image.png

Web 的性能问题与兼容问题,很大程度上源于我们对 Web 的使用方式,而不是 Web 本身。

Web 把浏览器变成通用运行时,把 HTML、CSS 和 JavaScript 变成跨平台语言。开发者只需面向一个标准化环境,就能触达几乎所有设备。分发成本骤降,更新变得即时,应用形态从“安装的软件”转向“访问的服务”。

Web 的早期形态极其朴素。静态 HTML 页面加载迅速,兼容性问题很少,渲染模型简单透明。随着前端框架与富交互需求的爆发,Web 应用逐渐演变为复杂的客户端系统。虚拟 DOM、构建工具链、动画库和状态管理框架叠加在一起,浏览器开始承担接近操作系统级别的职责。

正是在这个阶段,性能焦虑与兼容焦虑集中爆发。页面加载变慢,设备差异放大,调试成本上升。一部分开发者开始质疑 Web 的方向,甚至回头寻找桌面或原生方案。

五、问题的真正来源

把性能问题归咎于 Web 平台本身,容易忽略一个关键事实:Web 依然可以运行极其高效的界面。一个结构清晰、样式克制的 HTML 页面,在现代浏览器中的渲染成本非常低。很多兼容性问题源于对浏览器特性的过度依赖,以及对复杂动画和特效的滥用。

当界面设计回归朴素,浏览器的优势会重新显现。HTML 的语义标签提供稳定的结构基础,CSS 的基础布局能力足以覆盖大多数需求。减少 JavaScript 运行时负担,意味着更少的阻塞与更可预测的性能曲线。

这种做法更像是一种工程取舍:在表达力与复杂度之间寻找合理区间。Web 的强大之处在于可伸缩性,同一套技术既能构建极简页面,也能支撑复杂应用。选择权始终掌握在开发者手中。

六、朴素 Web 的工程价值

采用朴素 HTML 的一个直接收益是可维护性提升。语义化结构更容易被理解与修改,样式层与行为层的边界更加清晰。团队协作时,新成员可以快速定位问题,而无需穿透厚重的抽象层。

性能稳定性同样随之改善。更少的脚本意味着更低的解析与执行开销,更简单的渲染路径意味着更少的浏览器差异触发点。在移动设备和低端硬件上,这种优势尤为明显。

兼容性问题往往出现在边缘特性上。坚持使用成熟标准,可以显著降低跨浏览器差异。历史经验表明,HTML 与 CSS 的核心子集在多年时间里保持高度稳定,这种稳定性本身就是一种长期资产。

简单的系统更容易测试,更容易推理,也更容易扩展。它为未来的演进留下空间,而不是在一开始就耗尽复杂度预算。Web 作为一个高度通用的平台,允许这种简单存在,也允许逐步增加复杂度。

真正的挑战在于建立判断力:

何时引入抽象,何时保持直接;何时追求炫目的效果,何时坚持克制的表达。这种判断力来自经验,也来自对技术演化历史的理解。

结语:向前,同时向内

交互设计的历史展示了一条清晰轨迹:

人类不断寻找更自然、更高效的方式与机器沟通。在这条轨迹上,每一次前进都伴随着对复杂性的再认识。Web 时代的我们,既拥有前所未有的能力,也面临前所未有的选择。

当界面回归朴素,HTML 的基础能力重新成为主角,很多看似棘手的问题会自然消解。这种现象提醒我们,技术进步并不总是依赖更复杂的工具,有时依赖更清醒的取舍。

CLI、TUI、GUI 与 Web 共同构成了现代计算的交互谱系。理解它们的关系,意味着在设计系统时拥有更多自由。我们可以向前探索新的可能,也可以向内收敛到简单而稳固的核心。在这种张力之中,软件工程持续演化,而简单始终是一种值得珍视的力量。

企业级 Prompt 工程实战指南(上):别让模糊指令浪费你的AI算力

企业级 Prompt 工程实战指南(上):别让模糊指令浪费你的AI算力

一、引言:80% 的人都踩坑!把 Prompt 当 “聊天”

Prompt(提示词)技术作为连接人类需求与大语言模型(LLM)能力的关键桥梁,已经是家常便饭了。但在一线实践中,我发现 80% 的使用者和开发者都陷入了一个误区:把 Prompt 简单等同于日常聊天,随意地输入指令,期待模型给出完美答案。结果呢?输出要么跑题万里,要么逻辑混乱,甚至出现重复冗余的废话。这不仅浪费了大量的算力资源,更严重制约了 AI 应用在实际业务中的落地效果。

举个简单例子,一家电商企业希望利用 AI 生成产品推广文案。运营人员直接在对话框输入 “给我写个手机推广文案”,得到的却是一篇毫无针对性、平淡无奇的内容,完全无法吸引目标客户。为什么会这样?因为模型没有得到明确的指令、必要的产品信息以及目标受众描述,只能在宽泛的语言空间里 “瞎猜”。

本文将结合我的实战经验,以工程化的视角深入剖析 Prompt 设计的底层逻辑、核心技巧与落地策略。希望帮助大家从 “凭感觉写提示词” 的初级阶段,迈向 “用工程思维构建高效 Prompt 体系” 的新阶段,充分释放大语言模型的潜力。

二、Prompt 的底层逻辑:AI 的 “岗位说明书”+“任务清单”

2.1 核心定义:Prompt 是人类与大模型的 “交互接口”

从技术本质看,Prompt 是引导大语言模型输出特定结果的结构化指令,其作用类似于给 AI 下达 “岗位说明书”(系统 Prompt)和 “具体任务清单”(用户 Prompt)。

大模型作为 “概率机器”,其输出质量完全取决于输入指令的清晰度与结构化程度,这也是 Prompt 工程的核心价值所在。如果把大语言模型比作一个能力超强但没有自主意识的 “超级员工”,那么 Prompt 就是我们向它传达工作要求的唯一方式。

这个 “员工” 虽然拥有海量的知识储备和强大的语言处理能力,但它并不知道我们想要什么,除非我们用清晰、准确的指令告诉它。

2.2 两大核心组件:System Prompt 与 User Prompt

System Prompt 负责定义 AI 的角色、能力边界与输出规则,是全局约束,优先级高于用户输入;User Prompt 则是具体任务需求,包含上下文、目标与格式要求。二者结合构成完整的指令集。以电商客服场景为例

  • 系统 Prompt:你是资深电商客服,仅处理售后问题;
  • 用户 Prompt:我的无线耳机充不进电,如何换货”,

这两个部分缺一不可。系统 Prompt 为 AI 设定了身份和职责范围,确保它不会偏离售后客服的角色去回答其他无关问题;而用户 Prompt 则明确了具体的任务内容,让 AI 能够针对性地提供解决方案。

如果只有用户 Prompt,AI 可能会因为缺乏角色定位而给出不专业或不相关的回答;反之,如果只有系统 Prompt,没有具体的用户需求,AI 就不知道该从何下手。

2.3 Prompt 四大核心要素:角色、背景信息、任务、约束

Prompt 工程的核心,本质是把模糊需求转化为模型可精准解读的“结构化指令”,而角色、背景信息、任务、约束这四大要素,就是构成指令的“四大基石”——缺少任何一个,都可能导致指令模糊、输出失控。这四大要素并非孤立存在,而是相互支撑,共同定义了“AI 该如何做、做什么、依据什么做、不能做什么”,是从“凭感觉写提示”到“工程化设计”的关键转变。

2.3.1 核心一:角色——给 AI 定“身份”

角色是 Prompt 的“灵魂”,核心作用是明确 AI 的身份、专业度和语气风格,相当于给“超级员工”定岗位,让它知道自己该以何种视角回应需求。很多开发者忽略角色设定,导致 AI 输出“不接地气”“不专业”,本质就是角色模糊。

实战案例:同样是“解读产品故障”,不同角色的输出天差地别。若不设定角色,Prompt 为“解读耳机充不进电的原因”,AI 可能输出晦涩的技术术语;若设定角色为“资深电商售后工程师,面向普通消费者,用通俗语言解读,避免专业术语”,AI 会输出“大概率是充电线接触不良或充电口有灰尘,你先换一根充电线试试,再用棉签清理下充电口”,更贴合业务需求。

常见误区:角色设定过于宽泛(如“专业人士”),未明确具体领域和沟通对象,导致 AI 输出偏离预期。

2.3.2 核心二:背景信息——给 AI 补“上下文”

背景信息是 AI 决策的“依据”,核心是提供任务相关的上下文、前提条件和关键信息。让它知道“为什么做”“基于什么做”。缺少背景信息,AI 只能依赖预训练知识猜测,易出现“幻觉”或偏离业务场景。

实战案例:结合前文工单分类场景,若 Prompt 仅设定角色和任务(“电商售后工单分类专家,分类工单”),未提供背景信息,AI 可能无法区分“物流延迟”和“产品故障”的边界;若补充背景信息“本电商平台主营3C产品,物流合作快递公司为中通、圆通,售后工单主要涉及物流配送、产品质量、退换货三类场景”,AI 分类准确率会大幅提升,避免将“中通快递延迟”误判为“产品故障”。

关键原则:背景信息无需冗余,只需提炼“与任务直接相关”的核心信息,优先补充“业务场景、行业规则、前提条件”,避免无关信息占用 token。

2.3.3 核心三:任务——给 AI 下“指令”

任务是 Prompt 的“核心目标”,核心是明确 AI 要完成的具体工作,必须清晰、具体、不能模糊。这是四大要素中最基础、也最容易踩坑的部分,前文提到的“随便发挥”误区,本质就是任务设定模糊。

实战对比:模糊任务 Prompt 为“优化产品描述”,AI 输出大概率杂乱无章;精准任务 Prompt 为“优化3C产品(无线耳机)的产品描述,突出‘续航20小时’‘降噪深度40dB’两大核心卖点,面向年轻消费者,语言简洁有感染力,控制在150字内”,AI 输出会更具针对性。

核心技巧:任务设定需遵循“可量化、可落地”,避免使用“更好、更专业、更生动”等模糊表述,明确“做什么、做到什么程度、输出什么形式”。

2.3.4 核心四:约束——给 AI 划“边界”

约束的核心是明确 AI 的输出边界、禁止行为和格式要求,让它知道“不能做什么”。缺少约束,即使角色、背景、任务明确,AI 也可能输出冗余、偏离格式或不符合业务规则的内容。

实战案例:仍以工单分类场景为例,若仅设定任务“生成工单摘要”,未加约束,AI 可能生成100字以上的冗余内容,不便于客服快速查看;若补充约束“摘要控制在50字内,仅包含用户核心诉求、涉及产品和关键时间,禁止冗余表述,不添加解决方案”,AI 输出会严格符合业务需求,如“用户昨日收到衣服,尺码不符,咨询换货寄回流程”。

常见约束类型:格式约束(如“输出为JSON格式”“分点罗列”)、内容约束(如“禁止使用专业术语”“不添加无关建议”)、篇幅约束(如“控制在200字内”)、边界约束(如“仅处理售后问题,不回答售前咨询”)。

四大要素总结:工作角色、工作背景资料、工作任务目标、工作规矩,四大要素协同作用,才能构成一份高质量的 Prompt。

三、避坑指南:四大典型 Prompt 误区及优化方案

3.1 误区一:“随便发挥”

错误示例:“写点推荐文案”。这种过于宽泛的指令,就像让一个厨师 “做点好吃的”,却不告诉他菜系、食材和用餐人数,结果必然是输出失控。由于缺少角色、场景、受众等关键约束,AI 无法准确把握需求,输出的文案可能风格混杂、主题模糊,无法满足任何实际业务需求。

优化方案:明确 “角色 + 场景 + 目标 + 格式”。以推荐文案为例,优化后的 Prompt 可以是 “你是小红书文案策划,为注重健康的都市白领写 100 字内无糖苏打饮料推荐文案,风格活泼有生活感”。这样详细的指令,从根源上避免了 AI 的 “自由发挥”,让它能够聚焦于目标受众和具体需求,生成符合预期的内容。通过清晰的角色设定(小红书文案策划)、场景描述(面向注重健康的都市白领)、目标界定(推荐无糖苏打饮料)和格式要求(100 字内、活泼有生活感),AI 能够更好地理解任务,输出更有针对性和吸引力的文案。

3.2 误区二:多目标并行

错误示例:“写会议纪要 + 行动清单 + 邮件模板”。在这个指令中,单一 Prompt 包含了三个独立且复杂的任务,这对于模型来说就像同时接到三个不同客户的订单,却没有明确的优先级和处理流程,很容易导致输出结构混乱、顾此失彼。模型可能会在不同任务之间来回切换,无法深入处理每个任务,最终生成的会议纪要缺乏重点、行动清单逻辑不清晰、邮件模板格式错误。

优化方案:拆分任务或结构化指令。一种方法是将任务分步骤进行,先让模型生成会议纪要,再基于纪要生成行动清单,最后根据前两者生成邮件模板;另一种方法是在 Prompt 中明确要求 “分别输出 3 部分内容:1. 会议纪要;2. 行动清单;3. 全员通知邮件模板”,并对每部分的内容和格式进行详细说明。这样可以降低模型的处理复杂度,使其能够专注于每个任务,提高输出的质量和准确性。通过结构化的指令,模型能够更好地组织思路,按照要求依次完成各个任务,生成逻辑连贯、结构清晰的结果。

3.3 误区三:“专业一点”

“更有感觉”“更专业”“更生动” 等模糊表述是 AI 的 “死敌”。因为这些表述缺乏量化标准,模型无法判断需求边界。例如,当我们要求 “把简介写得更专业” 时,不同的人对 “专业” 的理解可能千差万别,模型也只能在模糊的概念中挣扎,无法确定具体的修改方向和程度。这种模糊指令会导致模型输出的结果要么过于平淡,没有达到预期的专业度;要么过于夸张,偏离了实际需求。

优化方案:具象化要求。将 “把简介写得更专业” 改为 “将公司简介重写为企业官网版本,语言突出权威性与行业属性,面向制造业客户”,这样的指令明确了具体的应用场景(企业官网 )、语言风格(突出权威性与行业属性)和目标受众(制造业客户),让模型能够有针对性地进行创作。通过具象化的描述,模型能够更好地理解用户对 “专业” 的期望,从语言表达、内容组织等方面进行优化,生成更符合专业要求的公司简介。

3.4 误区四:指代不明

错误示例:“把它优化一下”。这个指令中的 “它” 指代不明,模型无法确定优化的对象是一段文字、一个设计还是其他内容。同时,由于缺乏上下文,模型也不知道优化的方向和重点,是要改进语法错误、提升逻辑清晰度还是增强内容的吸引力?这种指代不明和上下文缺失的指令,会让模型陷入困惑,无法准确理解用户的意图,从而生成错误或不相关的结果。

优化方案:补充完整信息。如果是要优化节能冰箱的产品描述,可以这样表述:“优化以下节能冰箱产品描述,突出其 24 小时耗电 0.5 度的环保优势,适用于环保类公众号,语气亲切真实”。这样的指令明确了优化对象(节能冰箱产品描述)、优化重点(突出 24 小时耗电 0.5 度的环保优势)、应用场景(环保)和语言风格(语气亲切真实),消除了指令中的歧义,让模型能够根据具体要求进行有针对性的优化。通过补充完整的上下文信息,模型能够更好地理解用户的需求,从多个维度对产品描述进行优化,提高其在特定场景下的吸引力和有效性。


下篇预告

掌握了 Prompt 的底层逻辑与避坑技巧,如何在实际业务中落地?下篇将带来完整实战案例(电商工单自动分类与摘要生成)、三大 Prompt 技术路径对比工程化落地策略,以及目前趋势展望

欢迎大家点赞关注,下期更精彩!

从Vue3到React:一场跨越范式的进化之旅

从Vue3到React:一场跨越范式的进化之旅

作为深耕前端领域7年++的开发者,我曾将Vue3视为"渐进式框架"的完美答案。直到公司决定将核心项目迁移至React生态,这场技术栈的变革让我深刻体会到:框架的更迭不是非此即彼的选择,而是开发者认知边界的拓展。在完成迁移的半年后,我既惊叹于React生态的强大生命力,也愈发珍惜Vue3带来的开发哲学启示。


一、范式碰撞:两种思维模式的对话

1.1 响应式系统的哲学差异

Vue3的Proxy响应式系统如同精密的瑞士钟表,通过refreactive自动追踪数据流动。在开发企业级后台时,我曾享受过watchEffect自动追踪依赖的优雅:

// Vue3数据追踪示例
const userInfo = reactive({ name: '张三', age: 28 });
watchEffect(() => {
  console.log(`用户 ${userInfo.name} 年龄更新为 ${userInfo.age}`);
});

而React的useState/useEffect更像乐高积木,需要开发者手动搭建状态管理逻辑。迁移初期,这种显式声明让我倍感繁琐:

// React状态管理对比
const [userInfo, setUserInfo] = useState({ name: '张三', age: 28 });
useEffect(() => {
  console.log(`用户 ${userInfo.name} 年龄更新为 ${userInfo.age}`);
}, [userInfo]);

但正是这种显式性,让我在复杂表单处理时避免了Vue中computed属性可能引发的依赖陷阱。

1.2 组件化开发的殊途同归

Vue3的单文件组件(SFC)将模板、逻辑、样式封装在.vue文件中,适合快速原型开发:

<template>
  <el-button @click="handleSubmit">{{ loading ? '提交中...' : '提交' }}</el-button>
</template>

<script setup>
const form = reactive({ title: '', content: '' });
const { loading } = useLoading();
</script>

React的函数组件+Hooks模式则更接近原生JavaScript,这种"代码即网页"的直白风格在大型项目中展现出独特优势:

// React组件对比
const SubmitButton = ({ loading }) => (
  <button disabled={loading}>
    {loading ? '提交中...' : '提交'}
  </button>
);

迁移过程中我发现,组件拆分的粒度控制是两大框架的核心差异:Vue更适合业务逻辑紧密耦合的模块,而React的组件化更强调单一职责原则。


二、迁移实战:在挑战中寻找突破

2.1 状态管理的重构艺术

将Pinia迁移到Redux Toolkit的过程充满挑战。我们采用渐进式改造策略:

  1. 核心状态层保留Pinia:用户认证、全局配置等基础状态继续使用Vuex模式
  2. 业务逻辑层转向Redux:使用createSlice重构表单提交、数据缓存等场景
  3. 中间件桥接方案:通过redux-thunk兼容原有API调用模式
// 混合架构示例
// stores/auth.js (Pinia)
export const useAuthStore = defineStore('auth', {
  state: () => ({ token: null }),
  actions: { login }
})

// features/formSlice.js (Redux Toolkit)
const formSlice = createSlice({
  name: 'form',
  reducers: { submit: (state) => { /* ... */ } }
})

2.2 路由系统的范式转换

vue-routerreact-router-dom的迁移需要重构整个导航逻辑:

// Vue3路由配置
const router = createRouter({
  routes: })

// React路由配置
const router = createBrowserRouter(})

我们创新性地采用混合路由方案:核心页面使用React Router,微前端子应用保留Vue Router,通过<iframe>实现无缝衔接。


三、认知升级:超越框架的开发者思维

3.1 性能优化的新维度

React 19的Server Components彻底改变了渲染范式。在开发数据看板时,我们将实时图表组件转为服务端渲染:

// Server Component示例
async function RealTimeChart() {
  const data = await fetchAnalytics();
  return <Chart data={data} />;
}

这种架构下,首屏加载时间从2.1s骤降至480ms,但代价是失去了Vue中<keep-alive>的组件缓存能力。我们通过分层缓存策略弥补缺陷:高频数据走Redis缓存,低频数据使用React Query。

3.2 开发体验的再定义

React 19的编译器革命带来了意想不到的收益。在重构旧版表单组件时,编译器自动将useEffect依赖项优化为精确追踪:

// 编译前
useEffect(() => {
  console.log(value);
}, [value]);

// 编译后(等效优化)
const memoizedValue = useMemo(() => value, [value]);
useEffect(() => {
  console.log(memoizedValue);
}, [memoizedValue]);

这种智能优化让我们在保持代码简洁性的同时,获得接近Vue3的响应式体验。


四、双向赋能:构建技术中立的世界观

4.1 Vue3的持续价值

在迁移过程中,Vue3的以下特性持续发挥作用:

  • 组合式API:为React Hooks提供了优雅的替代方案
  • 编译器增强:Vue3的编译器提示帮助我们规避了React中的常见错误
  • 生态兼容性:通过@vue/compiler-sfc继续维护旧版组件库

4.2 React的进化启示

React生态的以下创新反向滋养了Vue开发:

  • TypeScript深度集成:推动我们为Vue3项目添加完整类型定义
  • Web3开发模式:借鉴React的useEffect异步模式重构DApp交互逻辑
  • 微前端架构:采用React的模块联邦方案实现Vue3子应用集成

五、未来展望:框架无关的开发者之路

站在2026年的时间节点回望,Vue3与React的迁移经历让我领悟到:

  1. 工具是中性的:框架只是实现目标的手段,核心在于理解底层原理
  2. 认知需要迭代:保持对新技术的敏感度,但坚守技术判断力
  3. 生态决定未来:React在Web3、AI应用中的优势值得关注,Vue3在可视化、低代码领域的潜力同样不可忽视

正如我在团队分享会上常说的:"我们不是Vue开发者或React开发者,而是能用任何工具解决问题的问题解决者。" 这场框架迁移不是终点,而是打开更广阔技术视野的起点。在未来的项目中,我将继续以开放心态拥抱变化,因为真正的开发者永远在路上。

深入解析 JavaScript 执行机制:从代码到结果

深入解析 JavaScript 执行机制:从代码到结果

本文旨在系统性地阐述 JavaScript 代码在 V8 引擎中的执行机制。我们将以您提供的学习材料为基础,从原理层面,结合具体代码示例,深入剖析执行上下文变量环境词法环境编译阶段执行阶段等关键概念,构建完整的知识体系。

一、 核心模型:两阶段与执行上下文

与 C++/Java 等需要显式编译的语言不同,JavaScript 作为解释型语言,其“编译”发生在执行前的一刹那。V8 引擎处理任何一段可执行代码(如一个脚本文件或一个函数体)时,都遵循“先编译,后执行”的模型,并且这个模型以函数为单位,在调用栈的管理下循环往复。

这个模型的核心产物是执行上下文。你可以将其理解为一个为当前即将执行的代码所准备的“运行环境包裹”,里面包含了执行所需的所有信息。执行上下文主要分为两种:

  1. 全局执行上下文:在代码开始执行前创建,每个程序只有一个。
  2. 函数执行上下文:在每次调用函数时创建。

管理这些执行上下文的数据结构是调用栈。调用栈遵循后进先出的原则,负责将创建好的执行上下文压入栈中执行。正在栈顶执行的上下文拥有控制权,当其代码执行完毕后,该上下文会被销毁(弹出栈),控制权交还给下一个上下文。

二、 编译阶段:执行上下文的创建与填充

编译阶段的核心工作就是创建执行上下文对象,并为其内部的组件填充初始内容。一个执行上下文对象主要包含以下部分:

  • 变量环境:一个用于存储通过 var函数声明 定义的标识符的空间。
  • 词法环境:一个用于存储通过 letconst定义的标识符的空间。这是 ES6 引入的新概念,用于实现块级作用域和暂时性死区。
  • 可执行代码:经过编译后,去除了声明语句的、可直接执行的代码序列。

编译阶段的具体工作流程:

  1. 创建空的执行上下文对象

  2. 处理变量和函数声明(提升)

    • 扫描代码,找到所有 var声明的变量,将其变量名作为 key,值初始化为 undefined,存入变量环境
    • 扫描代码,找到所有函数声明(如 function showName() {}),将函数名作为 key,函数体作为 value直接存入变量环境。这意味着函数声明的优先级最高,会覆盖变量环境中同名的 undefined变量。
    • 扫描代码,找到所有 letconst声明的变量,将其变量名存入词法环境。但在编译阶段,它们不会被初始化,访问它们会触发“暂时性死区”错误。这就是 let/const不存在变量提升(或说提升但未初始化)的原理。
  3. 统一形参与实参的值(仅对函数执行上下文):

    在函数调用时,会将传入的实参与函数定义的形参在变量环境中进行绑定和赋值。

代码实例分析(编译阶段)

让我们结合您的代码文件,具体分析编译阶段的工作。

案例1:全局上下文的编译 (1.js)

// 文件 1.js
showName();
console.log(myName);
console.log(hero);
var myName='lcx';
let hero='钢铁侠';
function showName() {
    console.log('函数showName被执行');  
}
  • 编译阶段(创建全局执行上下文)

    • 变量环境

      • 找到 var myName,存入 { myName: undefined }
      • 找到函数声明 showName,存入 { showName: function() { ... } }。此时 myName被覆盖(但在此例中不冲突)。
    • 词法环境

      • 找到 let hero,存入标识符 hero,但未初始化,处于暂时性死区状态。
    • 可执行代码

      • 移除声明语句后,剩下:showName(); console.log(myName); console.log(hero); myName='lcx'; hero='钢铁侠';

案例2:函数上下文的编译 (3.js中的 fn(3))

function fn(a){ // 假设传入实参 3
    console.log(a);
    var a=2;
    function a() {};
    var b=a;
    console.log(a);
}
  • 编译阶段(创建 fn的函数执行上下文)

    • 变量环境

      1. 找到形参 a,绑定实参值:{ a: 3 }
      2. 找到 var a,由于 a已存在,var允许重复声明,但不改变当前值,所以仍是 { a: 3 }
      3. 找到 var b,存入 { a: 3, b: undefined }
      4. 找到函数声明 function a() {} 。这是关键步骤:将变量环境中 a的值替换为函数体 { a: function() {} }函数声明提升的优先级最高
    • 词法环境:无 let/const声明,为空。

    • 可执行代码console.log(a); a=2; b=a; console.log(a);

三、 执行阶段:代码的逐行运行

编译阶段结束后,进入执行阶段。JS 引擎开始逐行执行“可执行代码”部分的指令。

  • 访问规则:当需要访问一个变量时,引擎首先在当前执行上下文中查找。查找顺序是:先词法环境,后变量环境。如果当前上下文没有,则沿着作用域链去外层(创建该函数时的上下文)查找,直至全局上下文。
  • 赋值操作:对变量进行赋值,会修改其在对应环境(变量环境或词法环境)中的值。

代码实例分析(执行阶段)

继续案例1 (1.js) 的执行阶段

  1. showName();:从变量环境中找到 showName是一个函数,调用它。这会创建一个新的 showName函数执行上下文并入栈执行,输出“函数showName被执行”。执行完后出栈。
  2. console.log(myName);:从变量环境中找到 myName,其值为 undefined,输出 undefined
  3. console.log(hero);:从词法环境中找到标识符 hero,但它在初始化前被访问,抛出 ReferenceError
  4. myName='lcx';:对变量环境中的 myName赋值为 'lcx'
  5. hero='钢铁侠';:对词法环境中的 hero进行初始化并赋值为 '钢铁侠'

继续案例2 (fn(3)的执行阶段)

  1. console.log(a);:从变量环境中找到 a,此时它的值是函数体,所以输出 function a() {}
  2. a=2;:这是一个赋值操作,将变量环境中的 a的值从函数改为数字 2
  3. b=a;:计算 a的值(现在是 2),然后赋值给变量环境中的 bb变为 2
  4. console.log(a);:再次访问 a,输出 2

四、 关键概念的深入与辨析

1. 变量环境 vs. 词法环境

  • 设计目的var的设计缺陷(如变量提升、无块级作用域)催生了 let/const。词法环境就是为了实现 let/const的块级作用域和暂时性死区而引入的。
  • 存储内容:变量环境存 var和函数声明;词法环境存 let/const
  • 初始化时机:变量环境中的项在编译阶段被初始化为 undefined;词法环境中的项在编译阶段仅“创建标识符”,在执行到声明语句时才初始化,在此之前访问会触发错误。

2. 函数声明 vs. 函数表达式 (6.js)

func(); // 调用
let func=() =>{ // 函数表达式赋值给变量 func
    console.log('函数表达式不会被提升');   
}
  • 函数声明(如 function foo() {}):在编译阶段会被整体提升到变量环境,函数名和函数体均可用。
  • 函数表达式(如 let func = function() {}或箭头函数):本质上是将一个函数赋值给一个变量。只有变量的声明(var funclet func)会被提升,而赋值操作(= function...)留在执行阶段。因此,在执行赋值语句之前访问该变量,得到的是 undefinedvar声明)或处于暂时性死区(let声明),调用它会报错。

3. 严格模式的影响 (5.js)

文档未详述此点,但基于我所掌握的知识,在严格模式下(‘use strict’;),诸如重复声明变量(var a=1; var a=2;)等不安全的操作会被引擎禁止,直接抛出语法错误,而不是像在非严格模式下那样允许声明但可能引发意外行为。

4. 内存分配:基本类型与引用类型 (7.js)

  • 基本类型(如 String, Number):值直接存储在栈内存中。变量赋值是“值拷贝”,创建一个全新的副本,两者互不影响。

    let str='hello';
    let str2=str; // 在栈中创建一个新的值 'hello' 并赋给 str2
    str2='你好'; // 修改 str2,不影响 str
    
  • 引用类型(如 Object, Array):值存储在堆内存中,变量中存储的是该值在堆中的内存地址。变量赋值是“地址拷贝”,两个变量指向同一个堆内存空间,因此通过其中一个变量修改堆中的内容,另一个变量访问到的内容也会改变。

    let arr1 = [1, 2, 3]; // arr1 保存堆地址,假设为 0x1000
    let arr2 = arr1; // arr2 也保存地址 0x1000
    arr2.push(4); // 通过地址 0x1000 修改堆中的数组
    console.log(arr1); // 通过 arr1 (地址 0x1000) 访问,看到 [1,2,3,4]
    

总结

JavaScript 的执行机制可以概括为:单线程、先编译后执行、依托于调用栈和以执行上下文为核心的环境管理

  1. 两阶段:对任何代码单元,V8 引擎都先进行编译,创建并填充执行上下文(变量环境、词法环境、可执行代码),然后才执行可执行代码。
  2. 调用栈:以栈的数据结构管理执行上下文的生命周期,确保执行顺序和资源回收。
  3. 作用域与提升var和函数声明在编译阶段被收集到变量环境,var初始化为 undefined,函数则保存整体。let/const被收集到词法环境但未初始化,形成暂时性死区。这解释了“提升”现象的本质差异。
  4. 执行流程:执行代码时,在当前的执行上下文中查找变量,修改变量值,遇到函数调用则创建新的函数执行上下文,并重复“编译-执行”流程。

通过理解执行上下文、变量环境、词法环境这些底层概念,我们就能从原理上解释包括变量提升、作用域链、闭包、this指向等在内的一系列 JavaScript 核心行为,从而编写出更可靠、可预测的代码。

【React-7/Lesson90(2025-12-29)】React useRef 完全指南🎯

📚 什么是 useRef

useRef 是 React 提供的一个 Hook,用于创建一个可变的 ref 对象。这个对象在整个组件生命周期内保持不变,其 .current 属性可以被修改而不会触发组件重新渲染。

import { useRef } from 'react'

const myRef = useRef(initialValue)
// myRef.current 可以被读取和修改

🔍 useRef 与 useEffect 的关系

在 React 中,useEffect 类似于 Vue 的 onMounted 生命周期钩子。它们都在组件挂载后执行副作用操作。

useEffect(() => {
  // 类似于 Vue 的 onMounted
  console.log('组件已挂载')
}, [])

🎨 useRef 的应用场景

1️⃣ DOM 节点的引用

useRef 最常见的用途之一是直接访问 DOM 元素。通过将 ref 传递给 JSX 元素的 ref 属性,React 会在组件渲染后将 DOM 节点赋值给 ref 的 .current 属性。

import { useRef, useEffect } from 'react'

export default function InputDemo() {
  const inputRef = useRef(null)
  
  useEffect(() => {
    console.log(inputRef.current)
    inputRef.current.focus()
  }, [])

  return (
    <>
      <input ref={inputRef} placeholder="自动聚焦这里" />
    </>
  )
}

关键点:

  • useRef(null) 创建初始值为 null 的 ref
  • ref={inputRef} 将 ref 绑定到 input 元素
  • useEffect 中可以访问 inputRef.current 获取真实的 DOM 节点
  • 可以调用 DOM 方法如 focus()scrollIntoView()

2️⃣ 可变对象的存储

useRef 可以存储任何可变值,这些值在组件重新渲染时保持不变,非常适合存储不需要触发重新渲染的数据。

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

export default function TimerDemo() {
  let intervalId = useRef(null)
  const [count, setCount] = useState(0)

  function start() {
    intervalId.current = setInterval(() => {
      console.log('tick~~~~')
    }, 1000)
    console.log(intervalId)
  }

  function stop() {
    clearInterval(intervalId.current)
  }

  useEffect(() => {
    console.log(intervalId.current)
  }, [count])
  
  return (
    <>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
      {count}
      <button type="button" onClick={() => setCount(count + 1)}>count++</button>
    </>
  )
}

使用场景:

  • 存储定时器 ID(如上面的例子)
  • 存储动画帧 ID(requestAnimationFrame)
  • 存储 WebSocket 连接
  • 存储上一次的值用于比较
  • 存储任何不需要触发重新渲染的数据

3️⃣ 创建持久化的引用对象

useRef 创建的 ref 对象在组件的整个生命周期内保持不变,即使组件重新渲染,ref 对象本身也不会改变,只有其 .current 属性的值可能会改变。

export default function PersistentRefDemo() {
  const [count, setCount] = useState(0)
  const inputRef = useRef(null)
  
  console.log('组件重新渲染')
  console.log(inputRef.current)
  
  useEffect(() => {
    console.log('组件挂载时执行')
    inputRef.current?.focus()
  }, [])

  return (
    <>
      <input ref={inputRef} />
      {count}
      <button type="button" onClick={() => setCount(count + 1)}>count++</button>
    </>
  )
}

⚖️ useRef 与 useState 的比较

🟢 相同点

两者都是储存可变对象的容器

// useState
const [state, setState] = useState(initialValue)

// useRef
const ref = useRef(initialValue)

它们都可以:

  • 在组件中存储数据
  • 在多次渲染之间保持数据
  • 存储任何类型的值(对象、数组、函数等)

🔴 不同点

特性 useState useRef
响应式 ✅ 是响应式的 ❌ 不是响应式的
更新方式 通过 setState 函数 直接修改 .current
重新渲染 状态改变会触发重新渲染 修改 .current 不会触发重新渲染
返回值 返回 [值, 更新函数] 返回 { current: 值 }
适用场景 需要响应式更新的数据 不需要触发重新渲染的数据

useState - 响应式状态:

const [count, setCount] = useState(0)

// 每次调用 setCount 都会触发组件重新渲染
setCount(count + 1)

useRef - 可变对象的存储:

const countRef = useRef(0)

// 直接修改 .current 不会触发重新渲染
countRef.current = countRef.current + 1

💡 深入理解 useRef 的工作原理

🔄 Ref 对象的结构

useRef 返回的是一个普通对象:

{
  current: initialValue
}

这个对象在组件的整个生命周期内保持不变,React 会在渲染过程中更新其 .current 属性。

📝 何时更新 .current

  1. DOM 引用: React 在渲染完成后自动更新
  2. 手动更新: 可以在任何地方直接修改 .current
const ref = useRef(0)

// 在事件处理中修改
function handleClick() {
  ref.current += 1
}

// 在定时器中修改
useEffect(() => {
  const timer = setInterval(() => {
    ref.current += 1
  }, 1000)
  
  return () => clearInterval(timer)
}, [])

⚠️ 注意事项

  1. 不要在渲染过程中读取/写入 ref
// ❌ 错误:在渲染过程中修改 ref
function BadComponent() {
  const ref = useRef(0)
  ref.current += 1  // 这会导致问题
  return <div>{ref.current}</div>
}

// ✅ 正确:在事件处理或 useEffect 中修改
function GoodComponent()() {
  const ref = useRef(0)
  
  useEffect(() => {
    ref.current = 0
  }, [])
  
  return <div onClick={() => ref.current++}>Click me</div>
}
  1. Ref 的初始值只在第一次渲染时使用
const ref = useRef(0)
// 组件重新渲染时,ref.current 不会被重置为 0

🎯 实际应用示例

📝 示例 1:自动聚焦输入框

import { useRef, useEffect } from 'react'

export default function AutoFocusInput() {
  const inputRef = useRef(null)
  
  useEffect(() => {
    inputRef.current?.focus()
  }, [])

  return (
    <div>
      <input 
        ref={inputRef}
        type="text"
        placeholder="我会自动聚焦"
      />
    </div>
  )
}

⏱️ 示例 2:计时器管理

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

export default function Timer() {
  const timerRef = useRef(null)
  const [seconds, setSeconds] = useState(0)
  
  const start = () => {
    if (timerRef.current) return
    
    timerRef.current = setInterval(() => {
      setSeconds(prev => prev + 1)
    }, 1000)
  }
  
  const stop = () => {
    if (timerRef.current) {
      clearInterval(timerRef.current)
      timerRef.current = null
    }
  }
  
  const reset = () => {
    stop()
    setSeconds(0)
  }
  
  useEffect(() => {
    return () => {
      stop()
    }
  }, [])

  return (
    <div>
      <p>计时: {seconds} 秒</p>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
      <button onClick={reset}>重置</button>
    </div>
  )
}

📜 示例 3:滚动到元素

import { useRef } from 'react'

export default function ScrollToElement() {
  const targetRef = useRef(null)
  
  const scrollToTarget = () => {
    targetRef.current?.scrollIntoView({ 
      behavior: 'smooth',
      block: 'center'
    })
  }

  return (
    <div>
      <button onClick={scrollToTarget}>滚动到目标</button>
      
      <div style={{ height: '500px' }}>内容区域</div>
      
      <div 
        ref={targetRef}
        style={{ 
          padding: '20px',
          backgroundColor: 'lightblue'
        }}
      >
        目标元素
      </div>
      
      <div style={{ height: '500px' }}>更多内容</div>
    </div>
  )
}

🔄 示例 4:存储上一次的值

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

export default function PreviousValueDemo() {
  const [count, setCount] = useState(0)
  const prevCountRef = useRef(0)
  
  useEffect(() => {
    prevCountRef.current = count
  }, [count])

  return (
    <div>
      <p>当前值: {count}</p>
      <p>上一次的值: {prevCountRef.current}</p>
      <button onClick={() => setCount(count + 1)}>
        增加
      </button>
    </div>
  )
}

🎨 示例 5:Canvas 绘图

import { useRef, useEffect } from 'react'

export default function CanvasDemo() {
  const canvasRef = useRef(null)
  
  useEffect(() => {
    const canvas = canvasRef.current
    const ctx = canvas.getContext('2d')
    
    ctx.fillStyle = 'lightblue'
    ctx.fillRect(0, 0, canvas.width, canvas.height)
    
    ctx.fillStyle = 'red'
    ctx.beginPath()
    ctx.arc(100, 100, 50, 0, Math.PI * 2)
    ctx.fill()
  }, [])

  return (
    <canvas 
      ref={canvasRef}
      width={200}
      height={200}
    />
  )
}

🚀 高级技巧

💾 在自定义 Hook 中使用 useRef

function usePrevious(value) {
  const ref = useRef()
  useEffect(() => {
    ref.current = value
  }, [value])
  return ref.current
}

function Component() {
  const [count, setCount] = useState(0)
  const prevCount = usePrevious(count)
  
  return (
    <div>
      <p>当前: {count}</p>
      <p>之前: {prevCount}</p>
    </div>
  )
}

🔗 多个 Ref 的管理

function MultipleRefsDemo() {
  const input1Ref = useRef(null)
  const input2Ref = useRef(null)
  const input3Ref = useRef(null)
  
  const focusNext = (currentRef, nextRef) => {
    if (currentRef.current?.value) {
      nextRef.current?.focus()
    }
  }

  return (
    <form>
      <input 
        ref={input1Ref}
        onChange={() => focusNext(input1Ref, input2Ref)}
      />
      <input 
        ref={input2Ref}
        onChange={() => focusNext(input2Ref, input3Ref)}
      />
      <input ref={input3Ref} />
    </form>
  )
}

📖 总结

useRef 是 React 中一个强大而灵活的 Hook,它的主要特点包括:

持久化引用 - 在组件生命周期内保持不变
不触发重新渲染 - 修改 .current 不会导致组件更新
DOM 访问 - 可以直接访问和操作 DOM 元素
存储可变数据 - 适合存储不需要响应式的数据
性能优化 - 避免不必要的状态更新和重新渲染

选择 useRef 的时机:

  • 需要直接访问 DOM 元素
  • 需要存储定时器、动画帧等 ID
  • 需要在渲染之间保持数据但不需要触发重新渲染
  • 需要存储上一次的值用于比较
  • 需要在回调函数中访问最新的值

选择 useState 的时机:

  • 需要响应式更新 UI
  • 状态改变需要触发组件重新渲染
  • 需要在多个组件间共享状态

掌握 useRef 的使用,能够让你在 React 开发中更加得心应手,写出更高效、更优雅的代码!🎉

我修了一个注释代码,结果引出一连串线上 BUG…

概要

  本文涉及:axios 拦截器、重复请求取消、请求 / 响应参数差异、axios 数据序列化、调用栈溢出排查

  适合:遇到接口重复调用、拦截器异常、树形数据渲染崩溃的前端开发者

背景:附件上传异常,引出旧优化逻辑

某天,线上项目收到用户反馈:附件上传时,同时上传两个会出错。

排查后发现:旧系统框架使用 element-ui,新系统使用 ant-design-vue。两个框架的 upload 组件参数格式不一致,直接导致上传异常。

由于存量代码较多,完全重构上传组件成本较高,因此决定:利用项目中已有的 “重复请求优化” 能力,临时规避组件差异问题。

项目里原本就有一个优化逻辑:短时间内重复调用同一接口,只保留最后一次,前面的自动取消。

但同事反馈:这段代码看起来存在,但实际并未生效,重复接口并没有被合并。于是,这个问题转到了我这里。

理清来龙去脉,开始排查。

第一层问题:拦截器逻辑被注释失效

首先梳理重复请求优化的整体逻辑:通过 请求拦截器 + 响应拦截器 配合实现:

  • 把当前接口信息存入一个 pending 容器
  • 接口返回前,如果再次触发相同请求,取消前一次,只保留最后一次
  • 请求结束后,从容器中移除

在检查拦截器代码时发现:

请求拦截器中,removePending 一行被注释掉了,导致整个取消逻辑不完整。

(上图:请求拦截器代码,核心移除逻辑被注释) 响应拦截器

响应拦截器

查看历史记录,这是项目初期就被注释的代码,前序团队并未启用。为了解决附件上传问题,我先将这行代码恢复启用。

通知测试验证,附件上传功能恢复正常,问题暂时修复。

但真正的问题,才刚刚开始浮现。

第二层问题:树结构页面数据展示异常,调用栈溢出

复测通过后不久,测试反馈:某树表格页面数据无法展示。

控制台提示:调用栈溢出(Maximum call stack size exceeded)

(上图:树表格页面接口报错,调用栈溢出) 报错

这个功能长期稳定运行,理论上不应该突然出问题。

同事提议:把刚才修改的拦截器代码恢复注释,试试看。

结果:注释掉 removePending 后,页面立刻恢复正常。

问题很明确:启用重复请求拦截逻辑 → 触发新 BUG:树数据展示失败、栈溢出。

先贴出核心工具函数 removePending:作用是遍历 pending 容器,匹配相同请求并执行取消。

(上图:removePending 核心逻辑) 函数.png

根因一:请求 / 响应拦截器参数结构不一致

排查到这里,大部分同学应该已经能看出问题:

  请求拦截器 与 响应拦截器 都将自身默认的参数传给了 removePending 函数,但,两个拦截器的默认参数的格式,是不同的!

  问题,就出在这里

  (上图:请求拦截器参数结构:config)

  1. 请求拦截器参数

    联想截图_20260204154129.png

    (上图:响应拦截器参数结构:response,需通过 .config 获取请求配置

  2. 响应拦截器参数 联想截图_20260204154151.png 确认:响应拦截器里,应该传 response.config,而不是直接传 response。

直接传 response 会发生什么?response 中包含完整的接口返回 data,对某些大体积数据(比如深度树结构)执行 qs.stringify,会因为递归深度过高,直接爆调用栈

这就是树页面崩溃、栈溢出的直接原因。

修复方式:响应拦截器调用 removePending 时,统一使用 .config

修改后,栈溢出问题消失,页面恢复正常。

第三层问题:pending 容器只增不减,内存累积

功能虽然恢复,但我在复查时发现一个隐藏问题:

接口执行完毕后,pending 容器并没有完全清空,部分请求一直残留。

上图:pending 容器不断累积,未正常清空 容器结果.png

逻辑上:

  • 请求拦截器:add → 加入容器
  • 响应拦截器:remove → 移出容器流程是闭环的,不应该残留。

继续排查,最终定位到:config.data

分别打印对比:

  • 请求拦截器中的 config.data

    请求拦截器的.png

  • 响应拦截器中的 config.data 响应拦截器的

现象:

  • 请求拦截器中:config.data对象
  • 响应拦截器中:config.data 变成了 JSON 字符串

但控制台显示却是相同的,两者的 data 均为下图所示。

联想截图_20260204165126.png

这里有一个前端非常经典的 “隐形坑”:控制台打印对象是懒加载引用,看到的不一定是代码执行时的真实值。

根因二:axios 自动序列化 data

回顾一下 removePending 函数,问题就出在 config.data 上 联想截图_20260204162657.png

真正原因来自 axios 底层行为:

axios 会在 请求拦截器执行完毕后、发送请求前,自动把 config.data 从 JS 对象序列化为 JSON 字符串。

这就导致:

  • 请求拦截器操作的是:原始对象
  • 响应拦截器拿到的是:序列化后的字符串

两者在 removePending 里做匹配、拼接、序列化时,自然无法正确匹配,最终表现为:部分请求能移除、部分请求移除失败,容器只增不减。

最终修复方案:在生成请求唯一标识时,统一处理 data 格式,对 JOSN 格式进行解析,保证请求 / 响应阶段类型一致。

修复后,pending 容器能够正常添加、正常移除,逻辑完全闭环。

修复后的代码: 联想截图_20260204171321.png

  最终,这个优化代码的逻辑与实现,完全正常了!

总结与复盘

这是一个典型的 架构阶段遗留、长期被掩盖、因业务 / 框架变更才暴露 的复合型 BUG。

整个问题链路可以梳理为:

  1. 历史代码:重复请求拦截逻辑被注释,处于半失效状态
  2. 业务场景:新旧框架 upload 组件参数不一致,需要依赖拦截器规避
  3. 第一层 BUG:拦截器参数结构不匹配,直接传 response 导致大数据序列化爆栈
  4. 第二层 BUG:axios 自动序列化 data,导致请求 / 响应阶段 config.data 类型不一致,匹配失败
  5. 最终表现:树结构页面崩溃、pending 容器累积、隐蔽且难定位

核心结论

  • 请求 / 响应拦截器参数结构不同,不能直接混用
  • 避免对大量级、深度嵌套数据无脑执行 qs.stringify
  • axios 会自动序列化 data,请求 / 响应阶段类型可能不一致
  • 控制台打印对象 ≠ 代码运行时真实值,务必以类型 / 快照为准

这类问题隐蔽性强、和底层框架强相关,也是前端工程化中非常典型的 “隐形坑”,记录下来供大家参考避坑。

【TS版 2026 从零学Langchain 1.x】(三)Agent和Memory

一、智能体 Agent

我们前面已经在简单使用agent了,这部分我们详细聊聊agent。

和模型对话是一种 请求-响应的模式,这对于解决复杂问题显然不够,我们希望有一种机制能让模型自主解决问题,能记忆、调用工具、循环,于是Agent登场了。

一个最简单的agent创建如下。

import { createAgent } from "langchain";
const agent = createAgent({
  model,
  systemPrompt: SYSTEM_PROMPT,
  tools: [get_user_location, get_weather_for_location],
  responseFormat: toolStrategy(ResponseFormat),
  // debug: true, # 开启debug模式,会打印出agent的运行过程
  checkpointer=checkpointer,
  middleware=[]
});

可以看出重点的部分:

  • model: Agent的推理引擎
  • tools: Agent可以使用的工具(Tool calling)
  • responseFormat: Agent的输出格式(结构化输出)
  • checkpointer: Agent的状态检查点,用于保存和恢复Agent的状态(记忆)
  • middleware: 中间件

agent 本质上是一个有状态的有限状态机 (FSM)。它会自动在“模型推理”和“工具执行”之间跳转/循环,直到问题解决。 而checkpointer会记录每次invoke后的完整状态(可理解为“存档”)。

1. 中间件

先介绍下「中间件」的概念,因为后面很多能力都依赖「中间件」的机制(比如动态模型、短期记忆)。

对于学过web框架的同学一定对「中间件」有所了解,Langchian也是借助了一些成熟的工程设计思路,将「中间件」概念集成到Langchain,解决了以往存在的痛点:

  • 各种复杂配置
  • 侵入修改agent的逻辑

无中间件的Agent

image.png

有中间件的Agent

image.png

可以看出,Langchain中间件的本质就是钩子,在agent流程的每个步骤前后设置回调钩子,能对执行步骤进行拦截处理,从而达到更细粒度流程控制。

哪些常见的中间件呢?

  • 对话历史记录整理
  • 日志记录
  • 提示词转换
  • 工具选择
  • 重试、回退逻辑
  • 速率限制

具体怎么做呢?

使用 createMiddleware 函数创建中间件, Langchain提供了下面两种类型风格的钩子。

1.Node-style hooks:

  • beforeAgent - 代理启动前(每次invoke调用开始执行一次)
  • beforeModel - 每次模型调用前
  • afterModel - 每次模型响应后
  • afterAgent - 代理完成时(每个invoke调用结束执行一次)

例子:统计invoke调用次数,是否reach到顶。自定义中间件:

import { createMiddleware, AIMessage } from "langchain";

const createMessageLimitMiddleware = (maxMessages: number = 50) => {
  return createMiddleware({
    name: "MessageLimitMiddleware",
    beforeModel: {
      canJumpTo: ["end"],
      hook: (state) => {
        if (state.messages.length === maxMessages) {
          return {
            messages: [new AIMessage("Conversation limit reached.")],
            jumpTo: "end",
          };
        }
        return;
      }
    },
    afterModel: (state) => {
      const lastMessage = state.messages[state.messages.length - 1];
      console.log(`Model returned: ${lastMessage.content}`);
      return;
    },
  });
};

2.Wrap-style hooks:

  • wrapModelCall - 在每个模型调用周围
  • wrapToolCall - 在每个工具调用周围

例子:设置invoke失败重试的最大次数。自定义中间件如下:

import { createMiddleware } from "langchain";

const createRetryMiddleware = (maxRetries: number = 3) => {
  return createMiddleware({
    name: "RetryMiddleware",
    wrapModelCall: (request, handler) => {
      for (let attempt = 0; attempt < maxRetries; attempt++) {
        try {
          return handler(request);
        } catch (e) {
          if (attempt === maxRetries - 1) {
            throw e;
          }
          console.log(`Retry ${attempt + 1}/${maxRetries} after error: ${e}`);
        }
      }
      throw new Error("Unreachable");
    },
  });
};

下面我们会逐渐了解和学习到这些装饰器的使用。

2. 模型

模型的配置分为静态模型动态模型

  • 静态模型:在agent创建时就确定好的模型,比如OpenAI的gpt-3.5-turbogpt-4等。
  • 动态模型:在agent运行时根据上下文动态切换的模型,比如根据用户输入的问题选择不同的模型。

我们重点看下动态模型是如何实现的,这里给出一个场景:根据用户问题的复杂度,动态选择模型。

  • 使用qwen_32b模型判断问题复杂度
  • 简单,则使用deepseek V3.2;复杂,则使用GLM 4.7
  • 最终模型返回回答

动态模型的选择需要依靠「中间件」的方式来实现。

1.定义三个模型


function createChatModel(model: string, maxTokens: number) {
  return new ChatOpenAI({
    model,
    apiKey: settings.siliconflow_api_key,
    configuration: {
      baseURL: settings.siliconflow_base_url,
    },
    temperature: 0.9,
    maxTokens,
    timeout: 60_000,
  });
}

const glmModel = createChatModel(settings.glm_model, 10_000);
const dsModel = createChatModel(settings.ds_model, 10_000);
const qwenRouterModel = createChatModel(settings.qwen3_32b_model, 64);

2.工具函数,根据“模型响应”用来判断问题复杂度


/**
 * 从输入中提取最新的用户文本
 * @param input 输入内容,可能是字符串或包含消息数组的对象
 * @returns 最新的用户文本内容
 */
function extractLatestUserText(input: unknown): string {
  if (typeof input === "string") return input;

  const messages: BaseMessage[] | undefined = Array.isArray(input)
    ? (input as BaseMessage[])
    : (input as { messages?: BaseMessage[] } | null | undefined)?.messages;

  if (!messages?.length) return "";
  for (let i = messages.length - 1; i >= 0; i -= 1) {
    const msg = messages[i];
    if (msg instanceof HumanMessage) return String(msg.content);
  }
  return String(messages.at(-1)?.content ?? "");
}


/**
 * 判断用户问题的复杂度
 * @param userText 用户输入的文本
 * @returns 问题复杂度,"simple" 或 "complex"
 */
async function judgeComplexity(userText: string): Promise<"simple" | "complex"> {
  const response = await qwenRouterModel.invoke([
    {
      role: "system" as const,
      content:
        "你是问题复杂度分类器。根据用户问题判断复杂度:\n- simple:单一事实/常识问答、简单翻译/润色、很短的直接回答、无需多步推理或设计。\n- complex:需要多步推理、方案设计/架构、长文写作、复杂代码/调试、严谨数学推导、对比权衡。\n只输出:simple 或 complex。",
    },
    { role: "user" as const, content: userText },
  ]);

  const text = String((response as any)?.content ?? "")
    .trim()
    .toLowerCase();

  if (text === "simple" || text.includes("simple") || text.includes("简单")) return "simple";
  if (text === "complex" || text.includes("complex") || text.includes("复杂")) return "complex";
  return "complex";
}

3.定义中间件,拦截模型请求

const dynamicModelMiddleware = createMiddleware({
  name: "DynamicModelMiddleware",
  wrapModelCall: async (request, handler) => {
    const userText = extractLatestUserText({ messages: (request as any)?.messages });
    const complexity = await judgeComplexity(userText);
    const model = complexity === "simple" ? dsModel : glmModel;
    request.model = model
    return handler(request);
  },
});

4.测试


/** 测试动态模型选择 */
async function testDynamicModelSelection() {
  const checkpointer = new MemorySaver();

  const agent = createAgent({
    model: dsModel as any,
    checkpointer,
    contextSchema,
    middleware: [dynamicModelMiddleware],
  });

  const config = { configurable: { thread_id: "1" }, context: { userId: "1" } };

  const r1 = await agent.invoke(
    { messages: [{ role: "user", content: "1.9 和1.11 哪个数字大?" }] },
    config,
  );
  const ai1 = r1.messages.at(-1);
  console.log("响应内容:\n", ai1?.content);
  console.log("调用模型:\n", getModelNameFromMessage(ai1));
  /*
  调用模型:
  deepseek-ai/DeepSeek-V3.2-Exp
  */

  const r2 = await agent.invoke(
    {
      messages: [
        {
          role: "user",
          content: "请用langchain 1.x 设计一个简单的问答系统,用户可以向系统咨询某地的天气信息,包括天气工具调用。",
        },
      ],
    },
    config,
  );
  const ai2 = r2.messages.at(-1);
  console.log("响应内容:\n", ai2?.content);
  console.log("调用模型:\n", getModelNameFromMessage(ai2));

  /*
  调用模型:
  Pro/zai-org/GLM-4.7
  */
}

3. 工具

构建Agent的时候可以传入一个tools数组,绑定工具。

Agent的工具部分,包括三种场景:

  • 工具的错误处理
  • ReAct循环中使用
  • 动态工具

1.先说说工具的错误处理,有时候工具处理出错了,我们希望反馈给LLM 自定义的错误信息。

1.1 继续前面的例子中 “1.9 和1.11 哪个数字大?”的问题,有时候LLM回答不正确,那么我希望它能调用工具来回答。首先工具定义如下:

import { createAgent, createMiddleware, tool, toolStrategy } from "langchain";
const compareTwoNumbers = tool(
  ({ a, b }: { a: number; b: number }) => {
    if (a > b) return 1;
    if (a < b) return -1;
    return 0;
  },
  {
    name: "compare_two_numbers",
    description: "比较两个数字a,b的大小",
    schema: z.object({
      a: z.number().describe("第一个数字"),
      b: z.number().describe("第二个数字"),
    }),
  },
);

1.2 可能出现调用tool出错(比如传参错误,内部触发边界错误等等),那么,可以使用 wrapToolCall 定义tool调用阶段的钩子(中间件),来处理错误。

const handleToolErrorsMiddleware = createMiddleware({
  name: "HandleToolErrorsMiddleware",
  wrapToolCall: async (request, handler) => {
    try {
      return await handler(request);
    } catch (e) {
      // 返回自定义的错误消息给LLM
      return new ToolMessage({
        content: `Tool error: Please check your input and try again. (${e})`,
        tool_call_id: request.toolCall.id || '',
      });
    }
  },
});

1.3 测试agent


/** 2.测试工具:比较两个数字 */
async function testToolCompareTwoNumbers() {
  const checkpointer = new MemorySaver();

  const agent = createAgent({
    model: dsModel,
    checkpointer,
    tools: [compareTwoNumbers],
    contextSchema,
    middleware: [ handleToolErrorsMiddleware],
  });

  const r = await agent.invoke(
    { messages: [{ role: "user", content: "1.9 和 1.11 哪个数字大?" }] },
    { configurable: { thread_id: "1" }, context: { userId: "1" } },
  );
  const ai = r.messages.at(-1);
  console.log("响应内容:\n", ai?.content);
  console.log("调用模型:\n", getModelNameFromMessage(ai));


  /*
  响应内容:
  比较结果是 **1**,这意味着 1.9 > 1.11。

  所以,**1.9** 比 **1.11** 大。

  虽然1.11在小数点后有两位数字,但比较小数大小时是看整体数值:
  - 1.9 实际上是 1.90
  - 1.11 是 1.11
  - 1.90 > 1.11
  调用模型:
  deepseek-ai/DeepSeek-V3.2-Exp
  */
}

2.ReAct循环中使用。工具是可以在agent循环中被反复使用的。

2.1 上面的testToolCompareTwoNumbers就是一个例子:调用一次Tool compareTwoNumbers后发现 可以得出答案,就停止循环了返回结果。如果发现问题还没解决就会继续 思考/调用工具 循环,直到有最终答案。

3.动态工具。我们可以预先注册工具,然后根据上下文动态调用工具。

3.1 比如 我 希望不同用户角色,能调用的工具是不一样的。普通用户无法使用管理员才能调用的工具。

3.2 官方示例(拦截工具调用,替换tools):

import { createAgent, createMiddleware } from "langchain";

const toolSelectorMiddleware = createMiddleware({
  name: "ToolSelector",
  wrapModelCall: (request, handler) => {
    // Select a small, relevant subset of tools based on state/context
    const relevantTools = selectRelevantTools(request.state, request.runtime);
    const modifiedRequest = { ...request, tools: relevantTools };
    return handler(modifiedRequest);
  },
});

const agent = createAgent({
  model: "gpt-4.1",
  tools: allTools,
  middleware: [toolSelectorMiddleware],
});

4. 响应格式

1.createAgent提供了一个参数responseFormat,可以传入一个zod对象,用来约束输出,这块内容在 第二篇有介绍过。底层是LLM模型的结构化输出强约束和Langchain的校验兜底。

2.如果没有 responseFormat,需要从agent.invoke 的结果的messages中取最后一条消息,得到最终的回答,并且是字符串.

3.而使用 responseFormat后,agent.invoke 的结果会多一个structuredResponse字段, 并且是一个结构化对象

4.我们继续之前的比较数字大小的例子,这次对响应结果加约束。

const compareResultSchema = z.object({
  num1: z.number().describe("第一个数字"),
  num2: z.number().describe("第二个数字"),
  result: z.number().int().describe("比较结果,1 表示 num1 大于 num2,-1 表示 num1 小于 num2,0 表示相等"),
});


/** 测试响应格式 */
async function testResponseFormat() {
  const checkpointer = new MemorySaver();

  const agent = createAgent({
    model: glmModel,
    checkpointer,
    tools: [compareTwoNumbers],
    contextSchema,
    responseFormat: compareResultSchema,
    middleware: [handleToolErrorsMiddleware],
  });

  const r = await agent.invoke(
    { messages: [{ role: "user", content: "1.9 和 1.11 哪个数字大?" }] },
    { configurable: { thread_id: "1" }, context: { userId: "1" } },
  );

  console.log(r.structuredResponse);
  /*
  {
    num1: 1.9,
    num2: 1.11,
    result: 1,
  }
  */
  console.log(r.messages.at(-1)?.content);
  /*
    Returning structured response: {"num1":1.9,"num2":1.11,"result":1}
  */
}

5. 状态检查点

checkpointer 是用来保存和恢复 agent 的状态。它具备以下能力

  • 记忆能力(记忆历史消息)
  • 线程隔离
  • 故障恢复和“时空旅行”

记录历史对话记录

如果你不传递checkpointer,那么agent是没有记忆能力的,下面例子中模型将无法记住你的名字

async function testNoCheckpointer() {
  console.log("\n" + "=".repeat(50));
  console.log("测试 1: createAgent 不带 checkpointer (应该无记忆)");
  console.log("=".repeat(50));

  const agent = createAgent({ model });
  const config = { configurable: { thread_id: "1" } };

  console.log("\n【步骤 1】\n [用户]: 嗨!我叫 Bob。");
  const response1 = await agent.invoke(
    { messages: [{ role: "user", content: "嗨!我叫 Bob。" }] },
    config,
  );
  console.log(`[Agent]: ${response1.messages.at(-1)?.content ?? ""}`);

  console.log("\n【步骤 2】\n [用户]: 我叫什么名字?");
  const response2 = await agent.invoke(
    { messages: [{ role: "user", content: "我叫什么名字?" }] },
    config,
  );
  console.log(`[Agent]: ${response2.messages.at(-1)?.content ?? ""}`);
}

下面例子中模型能记住你的名字。

async function testWithCheckpointer() {
  console.log("\n" + "=".repeat(50));
  console.log("测试 2: createAgent 带 checkpointer (应该有记忆)");
  console.log("=".repeat(50));

  const checkpointer = new MemorySaver();
  const agent = createAgent({ model, checkpointer });
  const config = { configurable: { thread_id: "thread-1" } };

  console.log("\n【步骤 1】\n [用户]: 嗨!我叫 Alice。");
  const response1 = await agent.invoke(
    { messages: [{ role: "user", content: "嗨!我叫 Alice。" }] },
    config,
  );
  console.log(`[Agent]: ${response1.messages.at(-1)?.content ?? ""}`);

  console.log("\n【步骤 2】\n [用户]: 我叫什么名字?");
  const response2 = await agent.invoke(
    { messages: [{ role: "user", content: "我叫什么名字?" }] },
    config,
  );
  console.log(`[Agent]: ${response2.messages.at(-1)?.content ?? ""}`);
}

线程隔离

所谓线程隔离就是你可以用同一个agent 发起多个会话,每个会话是独立的,互不干扰的。 下面的例子演示了,什么是线程隔离。通过第二个参数 { configurable: { thread_id: "thread-A" } } 设置线程。

def test_checkpointer_thread_isolation():
    """
    测试 3: create_agent 使用 checkpointer 和不同的 thread_id。
    预期:记忆通过 thread_id 隔离。
    """
    print("\n" + "="*50)
    print("测试 3: create_agent 线程隔离")
    print("="*50)
    
    memory = InMemorySaver()
    agent = create_agent(qwen3_32b_model, checkpointer=memory)
    
    # 1. 线程 A 交互
    print("\n[线程 A] 用户: 嗨!我叫 Charlie。")
    agent.invoke(
        {"messages": [{"role": "user", "content": "嗨!我叫 Charlie。"}]},
        {"configurable": {"thread_id": "thread-A"}}
    )

    # 2. 线程 B 交互 
    print("\n[线程 B] 用户: 你好!我叫 疯狂踩坑人")
    agent.invoke(
        {"messages": [{"role": "user", "content": "你好!我叫 疯狂踩坑人"}]},
        {"configurable": {"thread_id": "thread-B"}}
    )
    
    
    # 3. 线程 A 交互 (问名字)
    print("\n[线程 A] 用户: 我叫什么名字?")
    response_a = agent.invoke(
        {"messages": [{"role": "user", "content": "我叫什么名字?"}]},
        {"configurable": {"thread_id": "thread-A"}}
    )
    print(f"[线程 A] Agent: {response_a['messages'][-1].content}")


    # 4. 线程 B 交互 (问名字)
    print("\n[线程 B] 用户: 我叫什么名字?")
    response_b = agent.invoke(
        {"messages": [{"role": "user", "content": "我叫什么名字?"}]},
        {"configurable": {"thread_id": "thread-B"}}
    )
    print(f"[线程 B] Agent: {response_b['messages'][-1].content}")
    

image.png

检查点 checkpoint

运行下面代码,查看memory的变化


async function testCheckpoints() {
  console.log("\n" + "=".repeat(50));
  console.log("测试 4: 检查 checkpointer 保存的 checkpoint");
  console.log("=".repeat(50));

  const checkpointer = new MemorySaver();
  const agent = createAgent({ model, checkpointer });
  const config = { configurable: { thread_id: "thread-1" } };

  console.log("\n[用户]: 嗨!我叫 疯狂踩坑人。");
  const response = await agent.invoke(
    { messages: [{ role: "user", content: "嗨!我叫 疯狂踩坑人。" }] },
    config,
  );
  console.log(`[Agent]: ${response.messages.at(-1)?.content ?? ""}`);

  const checkpoints: unknown[] = [];
  for await (const checkpoint of checkpointer.list({ configurable: { thread_id: "thread-1" } })) {
    checkpoints.push(checkpoint);
  }
  console.log("checkpoint 数量:", checkpoints.length);
  for (const c of checkpoints) {
    console.log(c, "\n");
  }
}

memory保存了很多检查点,每个检查点都有idts(时间)和channel_values.messages等信息。

image.png

每个checkpoint代表一次存档,通过某一个checkpoint就可以恢复agent的状态,这样你可以穿越回去到之前invoke的任一个状态,这非常重要。

二、记忆 Memory

短期记忆 Short-term memory

短期记忆主要指在单个会话内,LLM 能够“记得”刚刚说过的话。它的目的是保持对话的连贯性。

管理消息 - SummarizationMiddleware

1.前面提到的checkpointer 会保存你的历史消息,但是这样会存在两个问题:

  • LLM 上下文限制:发送给大模型的 Token 数量超过其最大窗口,导致报错。
  • 成本与延迟增加:每次调用都会携带冗长的历史记录,消耗更多 Token 且模型响应变慢

2.针对这些问题,业界的处理方案基本就是:

  • 裁剪最早的消息,保持一个固定大小的"窗口"
  • 对裁剪的历史消息,使用LLM进行总结,作为一条

image.png

3.下面用一个例子来演示,Langchain是如何通过SummarizationMiddleware管理对话消息的。这个中间件做的事就是上面的方案:裁剪+总结。

import { createAgent, summarizationMiddleware } from "langchain";

const systemPrompt = "你是一个人工智能助手";

function buildModels() {
  const baseConfig = {
    apiKey: settings.siliconflow_api_key,
    configuration: {
      baseURL: settings.siliconflow_base_url,
    },
    timeout: 60_000,
  } as const;

  const agentModel = new ChatOpenAI({
    ...baseConfig,
    model: settings.qwen3_32b_model,
    temperature: 0.7,
    maxTokens: 2000,
  });

  const summarizerModel = new ChatOpenAI({
    ...baseConfig,
    model: settings.qwen3_32b_model,
    temperature: 0.2,
    maxTokens: 512,
  });

  return { agentModel, summarizerModel };
}


async function testSummarizationShortMemory() {
  const { agentModel, summarizerModel } = buildModels();
  const checkpointer = new MemorySaver();
  const agent = createAgent({
    model: agentModel,
    tools: [],
    systemPrompt,
    middleware: [
      summarizationMiddleware({
        model: summarizerModel,
        trigger: { messages: 4 }, // 也可以按 tokens 来
        keep: { messages: 4 }, // 也可以按 tokens 来
        summaryPrefix: "对话摘要:",
        summaryPrompt:
          "请将以下对话历史压缩成简短的中文摘要,保留关键信息(事实、偏好、约束、决定、结论):\n{messages}",
      }),
    ],
    checkpointer,
  });

  const userInputs = [
    "我叫小明,住在北京。",
    "请记住我更喜欢用中文回答。",
    "我这周想做一个 LangChain 的学习计划。简短控制在100字",
    "计划要按天拆分,每天不超过1小时。简短控制在100字",
    "顺便提醒我:周三晚上要健身。最后请把所有安排再用要点总结一次。",
  ];

  const config = { configurable: { thread_id: "short-memory-demo" } };

  for (let idx = 0; idx < userInputs.length; idx += 1) {
    const text = userInputs[idx] || '';
    const r = await agent.invoke({ messages: [{ role: "user", content: text }] }, config);
    const messages = r.messages;
    const last = messages.at(-1);

    console.log(
      `\n[Turn ${idx + 1}] 当前上下文消息数:${messages.length}(trigger=4, keep=4)\n`,
    );
    console.log(`用户:${text}`);
    console.log(`助手:${String(last?.content ?? "")}`);
  }
}       

4.trigger: { messages: 4 },keep: { messages: 4 } 表示当消息超过4条(不包含system_prompt)时触发裁剪,裁剪的信息保留4条。 这样一来,「消息窗口」大小控制在4。

5.理论上keep可以大于trigger,这样总结消息和keep的消息在内容上就会有重叠。

6.trigger和keep 除了通过message控制,还可以通过tokens控制,比如超过多少tokens就压缩。

7.从第三轮开始,当前消息数:6. 这意味下一次对话前,就会触发压缩,实际消息窗口大小为4.

image.png

8.下面是第三轮对话的messages打印结果,可以看到前面总结的消息。可以发现Langchain默认的做法是: 使用了总结性提示'here is a summary of ...',将1,2,3,4次消息发给了LLM做总结'

image.png

9.此外,Langchain还提供了RemoveMessage 来删除指定的消息,这个结合Langgraph会比较实用。

存储介质

import { MemorySaver } from "@langchain/langgraph"; 中的MemorySaver是将状态数据保存在内存中,那么程序已结束,这些状态就会丢失。所以官方更推荐在生产环境使用 PostgresSaver

bun add @langchain/langgraph-checkpoint-postgres

在生产环境中,使用一个由数据库支持的检查点:

import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";

const DB_URI = "postgresql://postgres:postgres@localhost:5442/postgres?sslmode=disable";
const checkpointer = PostgresSaver.fromConnString(DB_URI);

当然,除了Postgres数据库介质,还有其他存储介质,见文档,比如Sqlite。

bun add @langchain/langgraph-checkpoint-sqlite

但是这有个问题,@langchain/langgraph-checkpoint-sqlite 包依赖了Node的better-sqlite3,这个包目前不支持bun. 所以,只能用替代品:

bun add @beshkenadze/langgraph-checkpoint-libsql
import { SqliteSaver } from "@beshkenadze/langgraph-checkpoint-libsql";

async function testSqliteSaver() {
  const { agentModel } = buildModels();

  const checkpointer = SqliteSaver.fromConnString("file:checkpoints.db");
  const agent = createAgent({
    model: agentModel,
    tools: [],
    systemPrompt,
    checkpointer,
  });

  const config = { configurable: { thread_id: "test_sqlite_saver" } };

  const r1 = await agent.invoke(
    { messages: [{ role: "user", content: "你好,我叫“疯狂踩坑人”" }] },
    config,
  );
  console.log("[assistant]", String(r1.messages.at(-1)?.content ?? ""));
  // [assistant] 
  // 我是你的AI助手!不过“疯狂踩坑人”这个名字挺有...

  const r2 = await agent.invoke(
    { messages: [{ role: "user", content: "请问我叫什么名字?" }] },
    config,
  );
  console.log("[assistant]", String(r2.messages.at(-1)?.content ?? ""));
  // [assistant] 
  // 你叫“疯狂踩坑人”呀...
}

你会发现运行目录下多出一个checkpoints.db的数据库文件。 用sqlite客户端工具(比如vscode插件Database Client)打开查看checkpoints表,可以看到,这些数据都保存到了表里。

image.png

长期记忆 Long-term memory

长期记忆是指跨越多天、多周甚至数个不同会话,系统依然能记得用户的偏好、事实或历史背景。

  • 核心机制: 检索增强生成(RAG)与外部数据库。它不直接塞进当前的 Prompt 窗口,而是通过“按需检索”的方式工作。
  • LangChain 实现方式:
    • 向量数据库(Vector Stores): 如 Pinecone, Milvus, Chroma。将历史对话或知识切片并嵌入(Embedding),当用户提问时,通过语义搜索找回相关片段。
    • VectorStoreRetrieverMemory: LangChain 特有的组件,允许将向量数据库作为记忆组件挂载。
    • 实体记忆(Entity Memory): 提取对话中的特定实体(如“我的名字叫 疯狂踩坑人”)并存入结构化数据库。
  • 存储位置: 外部持久化数据库(磁盘)。
  • 优势: 理论上拥有无限容量,且不会占用不必要的 Token。只有当相关信息被触发时,才会被提取出来。

这个我们后面再聊,后面会慢慢介绍向量数据库和RAG。

图结构完全解析:从基础概念到遍历实现

图结构完全解析:从基础概念到遍历实现

图是计算机科学中最核心的数据结构之一,它能抽象现实世界中各类复杂的关系网络——从地图导航的路径规划,到社交网络的好友推荐,再到物流网络的成本优化,都离不开图结构的应用。本文将从图的基础概念出发,逐步讲解图的存储方式、通用实现,以及核心的遍历算法(DFS/BFS),帮助你彻底掌握图结构的核心逻辑。

一、图的核心概念

1.1 基本构成

图由节点(Vertex)边(Edge) 组成:

  • 节点:表示实体,有唯一ID标识;

  • 边:表示节点间的关系,可分为:

    • 有向/无向:有向边(如A→B)仅表示单向关系,无向边(如A-B)等价于双向有向边;

    • 加权/无权:加权边附带数值(如距离、成本),无权边可视为权重为1的特殊情况。

1.2 关键属性

(1)度
  • 无向图:节点的度 = 相连边的条数;

  • 有向图:入度(指向该节点的边数)+ 出度(该节点指向其他的边数)。

(2)稀疏图 vs 稠密图

简单图(无自环、无多重边)中,V个节点最多有 V(V1)/2V(V-1)/2 条边:

  • 稀疏图:边数E远小于 V2V^2 (如社交网络);

  • 稠密图:边数E接近 V2V^2 (如全连接网络)。

1.3 子图相关

  • 子图:节点和边均为原图的子集;

  • 生成子图:包含原图所有节点,仅保留部分边(如最小生成树);

  • 导出子图:选择部分节点,且包含这些节点在原图中的所有边。

1.4 连通性

  • 无向图:

    • 连通图:任意两节点间有路径可达;

    • 连通分量:非连通图中的最大连通子图;

  • 有向图:

    • 强连通:任意两节点间有双向有向路径;

    • 弱连通:忽略边方向后为连通图。

1.5 图与树的关系

图是多叉树的延伸:树无环、仅允许父→子指向,而图可成环、节点间可任意指向。树的遍历逻辑(DFS/BFS)完全适用于图,仅需增加「标记已访问节点」的逻辑避免环导致的死循环。

二、图的存储方式

图的存储核心是「记录节点间的连接关系」,主流方式有邻接表邻接矩阵两种,二者各有适用场景。

2.1 邻接表

核心结构

以节点ID为键,存储该节点的所有出边(包含目标节点+权重):

  • 数组版:graph[x] 存储节点x的出边列表(适用于节点ID为连续整数);

  • Map版:graph.get(x) 存储节点x的出边列表(适用于任意类型节点ID)。

特点
  • 空间复杂度: O(V+E)O(V+E) (仅存储实际存在的边,适合稀疏图);

  • 时间复杂度:增边 O(1)O(1) ,删/查边 O(E)O(E) (E为节点出边数),获取邻居 O(1)O(1)

2.2 邻接矩阵

核心结构

二维数组 matrix[from][to]

  • 无权图:true/false 表示是否有边;

  • 加权图:数值表示权重,null 表示无边(避免0权重歧义)。

特点
  • 空间复杂度: O(V2)O(V^2) (需预分配所有节点组合,适合稠密图);

  • 时间复杂度:增/删/查边/获取权重均为 O(1)O(1) ,获取邻居 O(V)O(V)

2.3 存储方式对比

特性 邻接表 邻接矩阵
空间效率 稀疏图更优 稠密图更优
增删查边 增边快、删/查边慢 所有操作均快
节点ID支持 支持任意类型(Map版) 仅支持连续整数
适用场景 大多数稀疏图场景 节点少、需快速查边

三、图的通用实现

基于邻接表/邻接矩阵,我们实现支持「增删查改」的通用加权有向图类,可灵活适配无向图(双向加边)、无权图(权重默认1)。

3.1 邻接表(数组版):适用于连续整数节点ID


/**
 * 加权有向图(邻接表-数组版)
 * 核心:节点ID为0~n-1的连续整数,二维数组存储出边
 */
class WeightedDigraphArray {
    constructor(n) {
        if (!Number.isInteger(n) || n <= 0) {
            throw new Error(`节点数必须是正整数(当前传入:${n})`);
        }
        this.nodeCount = n;
        this.graph = Array.from({ length: n }, () => []); // 邻接表初始化
    }

    // 私有方法:校验节点合法性
    _validateNode(node) {
        if (!Number.isInteger(node) || node < 0 || node >= this.nodeCount) {
            throw new Error(`节点${node}非法!合法范围:0 ~ ${this.nodeCount - 1}`);
        }
    }

    // 添加加权有向边
    addEdge(from, to, weight) {
        this._validateNode(from);
        this._validateNode(to);
        if (typeof weight !== 'number' || isNaN(weight)) {
            throw new Error(`边${from}${to}的权重必须是有效数字`);
        }
        // 避免重复加边
        if (this.hasEdge(from, to)) {
            this.removeEdge(from, to);
        }
        this.graph[from].push({ to, weight });
    }

    // 删除有向边
    removeEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        const originalLength = this.graph[from].length;
        this.graph[from] = this.graph[from].filter(edge => edge.to !== to);
        return this.graph[from].length < originalLength;
    }

    // 判断边是否存在
    hasEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        return this.graph[from].some(edge => edge.to === to);
    }

    // 获取边权重
    getEdgeWeight(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        const edge = this.graph[from].find(edge => edge.to === to);
        if (!edge) throw new Error(`不存在边${from}${to}`);
        return edge.weight;
    }

    // 获取节点所有出边
    getNeighbors(v) {
        this._validateNode(v);
        return [...this.graph[v]]; // 返回拷贝,避免外部修改
    }

    // 打印邻接表(调试用)
    printAdjList() {
        console.log('=== 加权有向图邻接表 ===');
        for (let i = 0; i < this.nodeCount; i++) {
            const edges = this.graph[i].map(edge => `${edge.to}(${edge.weight})`).join(', ');
            console.log(`节点${i}的出边:${edges || '无'}`);
        }
    }
}

3.2 邻接表(Map版):适用于任意类型节点ID


/**
 * 加权有向图(邻接表-Map版)
 * 核心:支持动态添加节点,节点ID可为任意可哈希类型(数字/字符串等)
 */
class WeightedDigraphMap {
    constructor() {
        this.graph = new Map(); // key: 节点ID,value: 出边列表
    }

    // 添加加权有向边
    addEdge(from, to, weight) {
        if (typeof weight !== 'number' || isNaN(weight)) {
            throw new Error('边的权重必须是有效数字');
        }
        if (!this.graph.has(from)) {
            this.graph.set(from, []);
        }
        this.removeEdge(from, to); // 去重
        this.graph.get(from).push({ to, weight });
    }

    // 删除有向边
    removeEdge(from, to) {
        if (!this.graph.has(from)) return false;
        const edges = this.graph.get(from);
        const filtered = edges.filter(edge => edge.to !== to);
        this.graph.set(from, filtered);
        return filtered.length < edges.length;
    }

    // 判断边是否存在
    hasEdge(from, to) {
        if (!this.graph.has(from)) return false;
        return this.graph.get(from).some(edge => edge.to === to);
    }

    // 获取边权重
    getEdgeWeight(from, to) {
        if (!this.graph.has(from)) {
            throw new Error(`不存在节点${from}`);
        }
        const edge = this.graph.get(from).find(edge => edge.to === to);
        if (!edge) throw new Error(`不存在边${from}${to}`);
        return edge.weight;
    }

    // 获取节点所有出边
    getNeighbors(v) {
        return this.graph.get(v) || [];
    }

    // 获取所有节点
    getNodes() {
        return Array.from(this.graph.keys());
    }
}

3.3 邻接矩阵版:适用于节点数少的场景


/**
 * 加权有向图(邻接矩阵版)
 * 核心:二维数组存储边权重,null表示无边
 */
class WeightedDigraphMatrix {
    constructor(n) {
        if (!Number.isInteger(n) || n <= 0) {
            throw new Error('节点数必须是正整数');
        }
        this.nodeCount = n;
        this.matrix = Array.from({ length: n }, () => Array(n).fill(null));
    }

    // 校验节点合法性
    _validateNode(node) {
        if (!Number.isInteger(node) || node < 0 || node >= this.nodeCount) {
            throw new Error(`节点${node}非法!合法范围:0 ~ ${this.nodeCount - 1}`);
        }
    }

    // 添加加权有向边
    addEdge(from, to, weight) {
        this._validateNode(from);
        this._validateNode(to);
        if (typeof weight !== 'number' || isNaN(weight)) {
            throw new Error(`边${from}${to}的权重必须是有效数字`);
        }
        this.matrix[from][to] = weight;
    }

    // 删除有向边
    removeEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        if (this.matrix[from][to] === null) return false;
        this.matrix[from][to] = null;
        return true;
    }

    // 判断边是否存在
    hasEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        return this.matrix[from][to] !== null;
    }

    // 获取边权重
    getEdgeWeight(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        return this.matrix[from][to];
    }

    // 获取节点所有出边
    getNeighbors(v) {
        this._validateNode(v);
        const neighbors = [];
        for (let to = 0; to < this.nodeCount; to++) {
            const weight = this.matrix[v][to];
            if (weight !== null) {
                neighbors.push({ to, weight });
            }
        }
        return neighbors;
    }

    // 打印邻接矩阵(调试用)
    printMatrix() {
        console.log('=== 邻接矩阵 ===');
        process.stdout.write('    ');
        for (let i = 0; i < this.nodeCount; i++) process.stdout.write(`${i}   `);
        console.log();
        for (let from = 0; from < this.nodeCount; from++) {
            process.stdout.write(`${from} | `);
            for (let to = 0; to < this.nodeCount; to++) {
                const val = this.matrix[from][to] === null ? '∅' : this.matrix[from][to];
                process.stdout.write(`${val}   `);
            }
            console.log();
        }
    }
}

3.4 适配无向图/无权图

  • 无向图:添加/删除边时,同时操作 from→toto→from

  • 无权图:复用加权图类,addEdge 时权重默认传1。

四、图的核心遍历算法

图的遍历是所有图论算法的基础,核心为深度优先搜索(DFS)广度优先搜索(BFS),二者的核心区别是「遍历顺序」:DFS先探到底再回溯,BFS逐层扩散。

4.1 深度优先搜索(DFS)

核心思想

从起点出发,沿着一条路径走到头,再回溯探索其他分支,需通过 visited/onPath 数组避免环导致的死循环。

场景1:遍历所有节点(visited数组)

visited 标记已访问的节点,确保每个节点仅遍历一次:


/**
 * DFS遍历所有节点
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} s - 起点
 * @param {boolean[]} visited - 访问标记数组
 */
function dfsTraverseNodes(graph, s, visited) {
    if (s < 0 || s >= graph.nodeCount || visited[s]) return;
    // 前序位置:标记并访问节点
    visited[s] = true;
    console.log(`访问节点 ${s}`);
    // 递归遍历所有邻居
    for (const edge of graph.getNeighbors(s)) {
        dfsTraverseNodes(graph, edge.to, visited);
    }
    // 后序位置:可处理节点相关逻辑(如统计、计算)
}

// 调用示例
const graph = new WeightedDigraphArray(3);
graph.addEdge(0, 1, 1);
graph.addEdge(0, 2, 2);
graph.addEdge(1, 2, 3);
dfsTraverseNodes(graph, 0, new Array(graph.nodeCount).fill(false));
场景2:遍历所有路径(onPath数组)

onPath 标记当前路径上的节点,后序位置撤销标记(回溯),用于寻找所有路径:


/**
 * DFS遍历所有路径(从src到dest)
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} src - 起点
 * @param {number} dest - 终点
 * @param {boolean[]} onPath - 当前路径标记
 * @param {number[]} path - 当前路径
 * @param {number[][]} res - 所有路径结果
 */
function dfsTraversePaths(graph, src, dest, onPath, path, res) {
    if (src < 0 || src >= graph.nodeCount || onPath[src]) return;
    // 前序位置:加入当前路径
    onPath[src] = true;
    path.push(src);
    // 到达终点:记录路径
    if (src === dest) {
        res.push([...path]); // 拷贝路径,避免后续修改
        path.pop();
        onPath[src] = false;
        return;
    }
    // 递归遍历邻居
    for (const edge of graph.getNeighbors(src)) {
        dfsTraversePaths(graph, edge.to, dest, onPath, path, res);
    }
    // 后序位置:回溯(移出当前路径)
    path.pop();
    onPath[src] = false;
}

// 调用示例
const res = [];
dfsTraversePaths(graph, 0, 2, new Array(graph.nodeCount).fill(false), [], res);
console.log('所有从0到2的路径:', res); // [[0,1,2], [0,2]]
场景3:有向无环图(DAG)遍历

若图无环,可省略 visited/onPath,直接遍历(如寻找所有从0到终点的路径):


var allPathsSourceTarget = function(graph) {
    const res = [];
    const path = [];
    const traverse = (s) => {
        path.push(s);
        if (s === graph.length - 1) {
            res.push([...path]);
            path.pop();
            return;
        }
        for (const v of graph[s]) traverse(v);
        path.pop();
    };
    traverse(0);
    return res;
};

4.2 广度优先搜索(BFS)

核心思想

从起点出发,逐层遍历所有节点,天然适合寻找「最短路径」(第一次到达终点的路径即为最短)。

基础版:记录遍历步数

/**
 * BFS遍历(记录步数)
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} s - 起点
 */
function bfsTraverse(graph, s) {
    const nodeCount = graph.nodeCount;
    const visited = new Array(nodeCount).fill(false);
    const q = [s];
    visited[s] = true;
    let step = -1; // 初始-1,进入循环后++为0(起点步数)

    while (q.length > 0) {
        step++;
        const sz = q.length;
        // 遍历当前层所有节点
        for (let i = 0; i < sz; i++) {
            const cur = q.shift();
            console.log(`访问节点 ${cur},步数 ${step}`);
            // 加入所有未访问的邻居
            for (const edge of graph.getNeighbors(cur)) {
                if (!visited[edge.to]) {
                    q.push(edge.to);
                    visited[edge.to] = true;
                }
            }
        }
    }
}
进阶版:State类适配复杂场景

通过State类封装节点和步数,适配不同权重、不同遍历目标的场景:


// 封装节点状态
class State {
    constructor(node, step) {
        this.node = node;
        this.step = step;
    }
}

/**
 * BFS遍历(State版)
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} s - 起点
 */
function bfsTraverseState(graph, s) {
    const nodeCount = graph.nodeCount;
    const visited = new Array(nodeCount).fill(false);
    const q = [new State(s, 0)];
    visited[s] = true;

    while (q.length > 0) {
        const state = q.shift();
        const cur = state.node;
        const step = state.step;
        console.log(`访问节点 ${cur},步数 ${step}`);

        for (const edge of graph.getNeighbors(cur)) {
            if (!visited[edge.to]) {
                q.push(new State(edge.to, step + 1));
                visited[edge.to] = true;
            }
        }
    }
}

4.3 遍历算法总结

算法 核心数据结构 核心标记 适用场景 时间复杂度
DFS 递归栈 visited(遍历节点)/onPath(遍历路径) 遍历所有节点、所有路径 O(V+E)O(V+E)
BFS 队列 visited 寻找最短路径、逐层遍历 O(V+E)O(V+E)

五、总结

图结构的核心是「节点+边」的关系抽象,掌握以下关键点即可应对绝大多数场景:

  1. 存储选择:稀疏图用邻接表(省空间),稠密图用邻接矩阵(查边快);

  2. 遍历逻辑:DFS适合遍历所有路径,BFS适合找最短路径,均需标记已访问节点避免环;

  3. 扩展适配:无向图=双向有向图,无权图=权重为1的加权图,可复用通用图类;

  4. 核心思想:图是树的延伸,遍历的本质是「穷举+剪枝」(标记已访问避免死循环)。

从基础的遍历到进阶的最短路径(Dijkstra)、最小生成树(Kruskal/Prim)、拓扑排序,图论算法的核心都是「基于遍历的优化」,掌握本文的基础内容,后续学习进阶算法会事半功倍。

前端性能优化:图片懒加载的三种手写方案

前言

在电商、社交等图片密集型应用中,一次性加载所有图片会导致首屏白屏时间(FP)过长,消耗用户大量流量。图片懒加载的核心思想就是: “按需加载” ——只有当图片进入或即将进入可视区域时,才真正发起网络请求。

一、 核心原理

  1. 占位图:初始化时,图片的 src 属性指向一张极小的 base64 图片或 loading 占位图。
  2. 存储地址:将真实的图片 URL 存放在自定义属性中(如 data-src)。
  3. 触发判断:通过 JS 监听位置变化,当图片进入可视区,将 data-src 的值赋给 src

二、 方案对比与实现

方案 1:传统滚动监听(Scroll + OffsetTop)

这是最基础的方案,通过计算绝对位置来判断。

公式: window.innerHeight (可视窗口高) + document.documentElement.scrollTop (滚动条高度) > element.offsetTop (元素距离页面顶部高度)

代码实现:

function lazyLoad() {
  const images = document.querySelectorAll('img[data-src]');
  const clientHeight = window.innerHeight;
  const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;

  images.forEach(img => {
    // 判断是否进入可视区
    if (clientHeight + scrollTop > img.offsetTop) {
      img.src = img.dataset.src;
      img.removeAttribute('data-src'); // 加载后移除属性,防止重复执行
    }
  });
}

// 注意:必须添加节流(Throttle),防止滚动时高频触发导致卡顿
window.addEventListener('scroll', throttle(lazyLoad, 200));

方案 2:现代位置属性(getBoundingClientRect)

此方法通过getBoundingClientRect获取元素相对于浏览器视口的位置来判断,逻辑更简洁。

判断条件: rect.top(元素顶部距离视口顶部的距离) < window.innerHeight

代码实现:

function handleScroll() {
  const img = document.getElementById('target-img');
  const rect = img.getBoundingClientRect();
  const viewHeight = window.innerHeight || document.documentElement.clientHeight;

  // 元素顶部出现在视口内,且没有超出视口底部
  if (rect.top >= 0 && rect.top < viewHeight) {
    console.log('图片进入可视区,开始加载');
    img.src = img.dataset.src;
    window.removeEventListener('scroll', handleScroll); // 加载后卸载监听
  }
}

window.addEventListener('scroll', handleScroll);

方案 3:最优解方案(IntersectionObserver API)

IntersectionObserver这是目前最推荐的方案,它是异步的,不会阻塞主线程,且不需要手动计算位置,性能最高。

使用语法:const observer = new IntersectionObserver(callback, options)

  • callback:是元素可见性发生变化时的回调函数,接收两个参数:
    • entries:观察目标的对象数组。对象中存在isIntersecting属性(布尔值),代表目标元素是否与根元素交叉(即进入视口)。
  • options:配置对象(该参数可选)。其中 root表示指定一个根元素,默认是浏览器窗口、rootMargin表示控制根元素的外边距、threshold 为目标元素与根元素中的可见比例,可以通过设置值来触发回调函数

代码实现:

const observerOptions = {
  root: null, // 默认为浏览器视口
  rootMargin: '0px 0px 50px 0px', // 提前 50px 触发加载,提升用户体验
  threshold: 0.1 // 交叉比例达到 10% 时触发
};

const handleIntersection = (entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      console.log('IntersectionObserver 推送:图片加载成功');
      observer.unobserve(img); // 停止观察该元素
    }
  });
};

const observer = new IntersectionObserver(handleIntersection, observerOptions);
// 观察页面中所有带 data-src 的图片
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));

方案 1 和 2 在获取 offsetTopgetBoundingClientRect 时会强制浏览器重新计算布局(回流),在长列表场景下可能导致掉帧。方案 3 不受此影响


三、总结

特性 方案 1 (OffsetTop) 方案 2 (Rect) 方案 3 (Intersection)
计算复杂度 较高 (需计算累计高度) 中等 极低 (引擎原生实现)
性能消耗 高 (频繁触发回流) 高 (触发回流) 低 (异步非阻塞)
兼容性 极好 (所有浏览器) 好 (IE9+) 一般 (现代浏览器, IE 需 Polyfill)

四、补充

  • 现代浏览器(Chrome 76+)已原生支持 <img loading="lazy">,如果是简单场景,一行 HTML 属性即可搞定。
  • 务必给图片设置固定的宽高比或底色占位。否则图片加载前高度为 0,加载瞬间高度撑开会引发剧烈的页面抖动。

AI 应用工程化实战:使用 LangChain.js 编排 DeepSeek 复杂工作流

在 2024 年至 2025 年的技术浪潮中,大语言模型(LLM)的应用开发已经从“尝鲜”阶段迈向了“工程化”阶段。对于开发者而言,仅仅调用 fetch 接口获取模型回复是远远不够的。在构建复杂的生产级应用时,我们面临着提示词管理混乱、模型切换成本高、上下文处理复杂以及任务编排困难等诸多痛点。

LangChain 的出现,正是为了解决这些工程化难题。它不是一个模型,而是一个框架,旨在将 LLM 的能力封装成可维护、可复用的组件。

本文将通过四个循序渐进的代码示例,演示如何利用 LangChain.js 结合当下热门的 DeepSeek(深度求索)模型,完成从基础调用到复杂工作流编排的进阶之路。

第一阶段:标准化的开始——适配器模式的应用

在没有任何框架之前,调用 LLM 通常意味着处理各种非标准化的 HTTP 请求。OpenAI、DeepSeek、Claude 的 API 格式各不相同。LangChain 的第一个核心价值在于标准化

以下是基于 main.js 的基础调用示例:

JavaScript

// main.js
import 'dotenv/config'; // 加载环境变量
import { ChatDeepSeek } from '@langchain/deepseek';

// 1. 实例化模型
const model = new ChatDeepSeek({
    model: 'deepseek-reasoner', // 使用 DeepSeek 的推理模型
    temperature: 0, // 设定温度,0 代表最确定性的输出
    // apiKey 自动从 process.env.DEEPSEEK_API_KEY 读取
});

// 2. 执行调用
const res = await model.invoke('用一句话解释什么是RAG?');
console.log(res.content);

深度解析:适配器模式 (Adapter Pattern)

这段代码看似简单,却蕴含了 AI 工程化的第一块基石:适配器模式

在软件工程中,适配器模式用于屏蔽底层接口的差异。ChatDeepSeek 类就是一个适配器(Provider)。

  • 统一接口:无论底层使用的是 DeepSeek、OpenAI 还是 Google Gemini,在 LangChain 中我们都统一调用 .invoke() 方法,invoke(英文:调用)。
  • 配置解耦:开发者无需关心 baseURL 配置、鉴权头部的拼接或请求体格式。
  • 参数控制:temperature: 0 是一个关键参数。在开发代码生成或逻辑推理(如使用 deepseek-reasoner)应用时,我们将温度设为 0 以减少随机性;而在创意写作场景,通常设为 0.7 或更高,这是决定你的大模型输出的内容严谨还是天马行空的关键因素之一。

通过这种方式,我们实现了业务逻辑与模型实现的解耦。如果未来需要更换模型,只需修改实例化部分,业务代码无需变动。

第二阶段:提示词工程化——数据与逻辑分离

直接在 .invoke() 中传入字符串(Hardcoding)在 Demo 阶段可行,但在实际项目中是反模式。因为提示词(Prompt)往往包含静态的指令和动态的用户输入。

下面这段代码展示了如何使用 PromptTemplate(对prompt设计一个模板,只需要提供关键的参数) 进行管理:

JavaScript

// 1.js
import { PromptTemplate } from '@langchain/core/prompts';
import { ChatDeepSeek } from '@langchain/deepseek';

// 1. 定义模板:静态结构与动态变量分离
const prompt = PromptTemplate.fromTemplate(`
你是一个{role}。
请用不超过{limit}字回答以下问题:
{question}
`);

// 2. 格式化:注入数据
const promptStr = await prompt.format({
    role: '前端面试官',
    limit: '50',
    question: '什么是闭包'
});

// 3. 调用模型
const model = new ChatDeepSeek({
    model: 'deepseek-reasoner',
    temperature: 0.7
});

const res = await model.invoke(promptStr);
console.log(res.content);

深度解析:提示词模板的意义

这里体现了关注点分离(Separation of Concerns)的设计原则。

  1. 复用性:同一个 prompt 对象可以生成“前端面试官”、“后端面试官”甚至“测试工程师”的问答场景,只需改变 format 的入参。
  2. 维护性:当需要优化 Prompt(例如增加“请使用中文回答”的系统指令)时,只需修改模板定义,而不用在代码库的各个角落查找字符串拼接逻辑。
  3. 类型安全:虽然 JavaScript 是弱类型,但在 LangChain 的 TypeScript 定义中,模板的输入变量(Variables)是可以被静态分析和校验的。

然而,上述代码仍显得有些“命令式”:我们需要手动格式化,拿到字符串,再手动传给模型。这依然是两步操作。

第三阶段:链式流转——LCEL 与声明式编程

LangChain 的核心精髓在于 Chain(链) 。通过 LangChain 表达式语言(LCEL),我们可以通过管道(Pipe)将组件连接起来,形成自动化的工作流。

下面的这段代码展示了这一范式转变:

JavaScript

// 2.js
import { ChatDeepSeek } from '@langchain/deepseek';
import { PromptTemplate } from '@langchain/core/prompts';

const model = new ChatDeepSeek({
    model: 'deepseek-reasoner',
    temperature: 0.7
});

const prompt = PromptTemplate.fromTemplate(`
  你是一个前端专家,用一句话解释: {topic}  
`);

// 核心变化:构建 Chain
// prompt (模板节点) -> model (LLM 节点)
const chain = prompt.pipe(model);

// 执行 Chain
const response = await chain.invoke({
    topic: '闭包'
});
console.log(response.content);

深度解析:LCEL 与声明式编程

这段代码引入了 .pipe() 方法,它深受 Unix 管道思想的影响。

  1. 声明式编程 (Declarative)
    我们不再编写“如何做”(先格式化,再调用),而是定义“是什么”(链条是 Prompt 流向 Model)。LangChain 运行时会自动处理数据的传递。
  2. Runnable 接口
    在 LangChain 中,Prompt、Model、OutputParser 甚至整个 Chain 都实现了 Runnable 接口。这意味着它们具有统一的调用方式(invoke, stream, batch)。
  3. 自动化数据流
    当我们调用 chain.invoke({ topic: '闭包' }) 时,对象 { topic: '闭包' } 首先进入 Prompt,Prompt 将其转化为完整的提示词字符串,然后该字符串自动流入 Model,最终输出结果。

这是构建 Agent(智能体)的基础单元。

第四阶段:编排复杂工作流——任务拆解与序列化

在真实业务中,单一的 Prompt 往往难以完美解决复杂问题。例如,我们希望 AI 既能“详细解释原理”,又能“精简总结要点”。如果试图在一个 Prompt 中完成,模型往往会顾此失彼。

更好的工程化思路是任务拆解。下面的这段代码展示了如何使用 RunnableSequence 串联多个任务:

JavaScript

// 3.js
import { ChatDeepSeek } from '@langchain/deepseek';
import { PromptTemplate } from '@langchain/core/prompts';
import { RunnableSequence } from '@langchain/core/runnables';

const model = new ChatDeepSeek({
    model: 'deepseek-reasoner',
    temperature: 0.7
});

// 任务 A:详细解释
const explainPrompt = PromptTemplate.fromTemplate(`
    你是一个前端专家,请详细介绍以下概念: {topic}
    要求:覆盖定义、原理、使用方式,不超过300字。
`);

// 任务 B:总结核心点
const summaryPrompt = PromptTemplate.fromTemplate(`
    请将以下前端概念总结为3个核心要点 (每点不超过20字):
    {explanation}
`);

// 创建两个独立的子链
const explainChain = explainPrompt.pipe(model);
const summaryChain = summaryPrompt.pipe(model);

// 核心逻辑:编排序列
const fullChain = RunnableSequence.from([
    // 第一步:输入 topic -> 获取详细解释 text
    (input) => explainChain.invoke({ topic: input.topic }).then(res => res.content),
    
    // 第二步:接收 explanation -> 生成总结 -> 组合最终结果
    (explanation) => summaryChain.invoke({ explanation }).then(res => 
        `知识点详情:\n${explanation}\n\n精简总结:\n${res.content}`
    )
]);

const response = await fullChain.invoke({
    topic: '闭包'
});
console.log(response);

深度解析:序列化工作流

这是一个典型的 Sequential Chain(顺序链)  模式。

  1. 输入/输出对齐
    第一步的输出(详细解释)通过函数传递,直接成为了第二步的输入变量 { explanation }。这种数据流的自动衔接是复杂 AI 应用的关键。
  2. DeepSeek Reasoner 的优势
    在这个场景中,我们使用了 deepseek-reasoner。对于解释原理和归纳总结这类需要逻辑分析(Reasoning)的任务,DeepSeek 的 R1 系列模型表现优异。通过拆解任务,我们让模型在每个步骤都专注于单一目标,从而大幅提升了输出质量。
  3. 可观测性与调试
    将长任务拆分为短链,使得我们在调试时可以单独检查 explainChain 的输出是否准确,而不必在一个巨大的黑盒 Prompt 中盲目尝试。

总结

到此为止我们见证了 AI 代码从“脚本”到“工程”的进化:

  1. 适配器模式:解决了模型接口碎片化问题。
  2. 提示词模板:实现了数据与逻辑的分离。
  3. LCEL 管道:将原子能力组装成自动化流程。
  4. 序列化编排:通过任务拆解解决复杂业务逻辑。
  5. **要想拿到大模型输出的结果,别忘了配置APIKEY和环境变量

LangChain.js 结合 DeepSeek,不仅仅是调用了一个 API,更是为您提供了一套构建可扩展、可维护 AI 系统的脚手架。作为前端开发者,掌握这种“搭积木”的思维方式,是在 AI 时代保持竞争力的关键。

❌